From c329a08cba2ed8dea55c4d315afed1206dc4812e Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Feb 2026 11:03:22 -0800 Subject: [PATCH 1/7] create a userinput interface (for testing) --- pkg/userinput/userinput.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/pkg/userinput/userinput.go b/pkg/userinput/userinput.go index 22fd217b1e..e5cb8f5891 100644 --- a/pkg/userinput/userinput.go +++ b/pkg/userinput/userinput.go @@ -21,6 +21,12 @@ import ( var MainUserInputHandler = UserInputHandler{Channels: make(map[string](chan *UserInputResponse), 1)} +var defaultProvider UserInputProvider = &FrontendProvider{} + +type UserInputProvider interface { + GetUserInput(ctx context.Context, request *UserInputRequest) (*UserInputResponse, error) +} + type UserInputRequest struct { RequestId string `json:"requestid"` QueryText string `json:"querytext"` @@ -48,6 +54,8 @@ type UserInputHandler struct { Channels map[string](chan *UserInputResponse) } +type FrontendProvider struct{} + func (ui *UserInputHandler) registerChannel() (string, chan *UserInputResponse) { ui.Lock.Lock() defer ui.Lock.Unlock() @@ -96,7 +104,7 @@ func determineScopes(ctx context.Context) ([]string, error) { return []string{windowId}, nil } -func GetUserInput(ctx context.Context, request *UserInputRequest) (*UserInputResponse, error) { +func (p *FrontendProvider) GetUserInput(ctx context.Context, request *UserInputRequest) (*UserInputResponse, error) { id, uiCh := MainUserInputHandler.registerChannel() defer MainUserInputHandler.unregisterChannel(id) request.RequestId = id @@ -132,3 +140,11 @@ func GetUserInput(ctx context.Context, request *UserInputRequest) (*UserInputRes return response, err } + +func GetUserInput(ctx context.Context, request *UserInputRequest) (*UserInputResponse, error) { + return defaultProvider.GetUserInput(ctx, request) +} + +func SetUserInputProvider(provider UserInputProvider) { + defaultProvider = provider +} From 06dedf829cd97da12fe26c3e746ee0fb1fd647a0 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Feb 2026 14:43:46 -0800 Subject: [PATCH 2/7] minor updates to ai:verbosity change. set the base waveai modes to be low verbosity to match old defaults --- pkg/aiusechat/uctypes/uctypes.go | 4 ++++ pkg/aiusechat/usechat.go | 2 +- pkg/wconfig/defaultconfig/waveai.json | 9 ++++++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/pkg/aiusechat/uctypes/uctypes.go b/pkg/aiusechat/uctypes/uctypes.go index fac8cb7f2a..8c6eb57e06 100644 --- a/pkg/aiusechat/uctypes/uctypes.go +++ b/pkg/aiusechat/uctypes/uctypes.go @@ -154,6 +154,10 @@ const ( ThinkingLevelLow = "low" ThinkingLevelMedium = "medium" ThinkingLevelHigh = "high" + + VerbosityLevelLow = "low" + VerbosityLevelMedium = "medium" + VerbosityLevelHigh = "high" ) const ( diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index 2088e181d0..ca7587e339 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -112,7 +112,7 @@ func getWaveAISettings(premium bool, builderMode bool, rtInfo waveobj.ObjRTInfo, } verbosity := config.Verbosity if verbosity == "" { - verbosity = uctypes.ThinkingLevelMedium // default to medium + verbosity = uctypes.VerbosityLevelMedium // default to medium } opts := &uctypes.AIOptsType{ Provider: config.Provider, diff --git a/pkg/wconfig/defaultconfig/waveai.json b/pkg/wconfig/defaultconfig/waveai.json index c115211b73..eba31f5030 100644 --- a/pkg/wconfig/defaultconfig/waveai.json +++ b/pkg/wconfig/defaultconfig/waveai.json @@ -8,8 +8,9 @@ "ai:apitype": "openai-responses", "ai:model": "gpt-5-mini", "ai:thinkinglevel": "low", + "ai:verbosity": "low", "ai:capabilities": ["tools", "images", "pdfs"], - "ai:switchcompat": ["wavecloud"] + "ai:switchcompat": ["wavecloud"] }, "waveai@balanced": { "display:name": "Balanced", @@ -20,9 +21,10 @@ "ai:apitype": "openai-responses", "ai:model": "gpt-5.1", "ai:thinkinglevel": "low", + "ai:verbosity": "low", "ai:capabilities": ["tools", "images", "pdfs"], "waveai:premium": true, - "ai:switchcompat": ["wavecloud"] + "ai:switchcompat": ["wavecloud"] }, "waveai@deep": { "display:name": "Deep", @@ -33,8 +35,9 @@ "ai:apitype": "openai-responses", "ai:model": "gpt-5.1", "ai:thinkinglevel": "medium", + "ai:verbosity": "low", "ai:capabilities": ["tools", "images", "pdfs"], "waveai:premium": true, - "ai:switchcompat": ["wavecloud"] + "ai:switchcompat": ["wavecloud"] } } From ff91db7a242f919409cf96021ee0be247cf7f5c2 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Feb 2026 15:24:22 -0800 Subject: [PATCH 3/7] working on test-conn program --- cmd/test-conn/cliprovider.go | 56 +++++++ cmd/test-conn/main-test-conn.go | 104 ++++++++++++ cmd/test-conn/testutil.go | 288 ++++++++++++++++++++++++++++++++ pkg/wconfig/filewatcher.go | 4 +- 4 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 cmd/test-conn/cliprovider.go create mode 100644 cmd/test-conn/main-test-conn.go create mode 100644 cmd/test-conn/testutil.go diff --git a/cmd/test-conn/cliprovider.go b/cmd/test-conn/cliprovider.go new file mode 100644 index 0000000000..661c40544a --- /dev/null +++ b/cmd/test-conn/cliprovider.go @@ -0,0 +1,56 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/wavetermdev/waveterm/pkg/userinput" +) + +type CLIProvider struct { + AutoAccept bool +} + +func (p *CLIProvider) GetUserInput(ctx context.Context, request *userinput.UserInputRequest) (*userinput.UserInputResponse, error) { + response := &userinput.UserInputResponse{ + Type: request.ResponseType, + RequestId: request.RequestId, + } + + if request.Title != "" { + fmt.Printf("\n=== %s ===\n", request.Title) + } + fmt.Printf("%s\n", request.QueryText) + + if p.AutoAccept { + fmt.Printf("Auto-accepting (use -i for interactive mode)\n") + response.Confirm = true + response.Text = "yes" + return response, nil + } + + reader := bufio.NewReader(os.Stdin) + fmt.Printf("Accept? [y/n]: ") + text, err := reader.ReadString('\n') + if err != nil { + response.ErrorMsg = fmt.Sprintf("error reading input: %v", err) + return response, err + } + + text = strings.TrimSpace(strings.ToLower(text)) + if text == "y" || text == "yes" { + response.Confirm = true + response.Text = "yes" + } else { + response.Confirm = false + response.Text = "no" + } + + return response, nil +} diff --git a/cmd/test-conn/main-test-conn.go b/cmd/test-conn/main-test-conn.go new file mode 100644 index 0000000000..7995832278 --- /dev/null +++ b/cmd/test-conn/main-test-conn.go @@ -0,0 +1,104 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "flag" + "fmt" + "log" + "os" + "time" +) + +var ( + WaveVersion = "0.0.0" + BuildTime = "0" +) + +func usage() { + fmt.Fprintf(os.Stderr, `Test Harness for SSH Connection Flows + +Usage: + test-conn [flags] [args...] + +Commands: + connect - Test basic SSH connection with wsh + ssh - Test basic SSH connection + exec - Execute command and show output (no wsh) + wshexec - Execute command with wsh enabled + shell - Start interactive shell session + +Flags: + -t duration Connection timeout (default: 60s) + -i Interactive mode (prompt for user input instead of auto-accept) + -v Show version and exit + +Examples: + test-conn ssh user@example.com + test-conn exec user@example.com "ls -la" + test-conn wshexec user@example.com "wsh version" + test-conn -i connect user@example.com + test-conn shell user@example.com + +`) + os.Exit(1) +} + +func main() { + timeoutFlag := flag.Duration("t", 60*time.Second, "connection timeout") + interactiveFlag := flag.Bool("i", false, "interactive mode (prompt for user input)") + versionFlag := flag.Bool("v", false, "show version") + + flag.Usage = usage + flag.Parse() + + if *versionFlag { + fmt.Printf("test-conn version %s (built %s)\n", WaveVersion, BuildTime) + os.Exit(0) + } + + args := flag.Args() + if len(args) < 2 { + usage() + } + + command := args[0] + connName := args[1] + + autoAccept := !*interactiveFlag + + err := initTestHarness(autoAccept) + if err != nil { + log.Fatalf("Failed to initialize: %v", err) + } + + switch command { + case "ssh", "connect": + err = testBasicConnect(connName, *timeoutFlag) + + case "exec": + if len(args) < 3 { + log.Fatalf("exec command requires a command argument") + } + cmd := args[2] + err = testShellWithCommand(connName, cmd, *timeoutFlag) + + case "wshexec": + if len(args) < 3 { + log.Fatalf("wshexec command requires a command argument") + } + cmd := args[2] + err = testWshExec(connName, cmd, *timeoutFlag) + + case "shell": + err = testInteractiveShell(connName, *timeoutFlag) + + default: + log.Fatalf("Unknown command: %s", command) + } + + if err != nil { + log.Fatalf("Error: %v", err) + } +} diff --git a/cmd/test-conn/testutil.go b/cmd/test-conn/testutil.go new file mode 100644 index 0000000000..3f59ab0ddd --- /dev/null +++ b/cmd/test-conn/testutil.go @@ -0,0 +1,288 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/wavetermdev/waveterm/pkg/remote" + "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" + "github.com/wavetermdev/waveterm/pkg/shellexec" + "github.com/wavetermdev/waveterm/pkg/userinput" + "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/waveobj" + "github.com/wavetermdev/waveterm/pkg/wconfig" + "github.com/wavetermdev/waveterm/pkg/wshutil" + "github.com/wavetermdev/waveterm/pkg/wstore" +) + +func setupWaveEnvVars() error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get home directory: %w", err) + } + + isDev := os.Getenv("WAVETERM_DEV") != "" + devSuffix := "" + if isDev { + devSuffix = "-dev" + } + + configHome := os.Getenv("WAVETERM_CONFIG_HOME") + if configHome == "" { + configHome = filepath.Join(homeDir, ".config", "waveterm"+devSuffix) + os.Setenv("WAVETERM_CONFIG_HOME", configHome) + } + log.Printf("Using config directory: %s", configHome) + + dataHome := os.Getenv("WAVETERM_DATA_HOME") + if dataHome == "" { + if runtime.GOOS == "darwin" { + dataHome = filepath.Join(homeDir, "Library", "Application Support", "waveterm"+devSuffix) + os.Setenv("WAVETERM_DATA_HOME", dataHome) + } else { + return fmt.Errorf("WAVETERM_DATA_HOME must be set on non-macOS systems") + } + } + log.Printf("Using data directory: %s", dataHome) + + return nil +} + +func initTestHarness(autoAccept bool) error { + log.Printf("Initializing test harness...") + + err := setupWaveEnvVars() + if err != nil { + return fmt.Errorf("failed to setup wave env vars: %w", err) + } + + err = wavebase.CacheAndRemoveEnvVars() + if err != nil { + return fmt.Errorf("failed to cache env vars: %w", err) + } + + wshutil.DefaultRouter = wshutil.NewWshRouter() + wshutil.DefaultRouter.SetAsRootRouter() + + wstore.SetClientId("test-client-" + fmt.Sprintf("%d", time.Now().Unix())) + + userinput.SetUserInputProvider(&CLIProvider{AutoAccept: autoAccept}) + + log.Printf("Test harness initialized") + return nil +} + +func testBasicConnect(connName string, timeout time.Duration) error { + opts, err := remote.ParseOpts(connName) + if err != nil { + return fmt.Errorf("failed to parse connection string: %w", err) + } + + log.Printf("Connecting to %s...", opts.String()) + + conn := conncontroller.GetConn(opts) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + err = conn.Connect(ctx, &wconfig.ConnKeywords{}) + if err != nil { + return fmt.Errorf("connection failed: %w", err) + } + + status := conn.DeriveConnStatus() + log.Printf("✓ Connected!") + log.Printf(" Status: %s", status.Status) + log.Printf(" WshEnabled: %v", status.WshEnabled) + log.Printf(" Connection: %s", status.Connection) + if status.WshVersion != "" { + log.Printf(" WshVersion: %s", status.WshVersion) + } + if status.WshError != "" { + log.Printf(" WshError: %s", status.WshError) + } + if status.NoWshReason != "" { + log.Printf(" NoWshReason: %s", status.NoWshReason) + } + + return nil +} + +func testShellWithCommand(connName string, cmd string, timeout time.Duration) error { + opts, err := remote.ParseOpts(connName) + if err != nil { + return fmt.Errorf("failed to parse connection string: %w", err) + } + + log.Printf("Connecting to %s...", opts.String()) + + conn := conncontroller.GetConn(opts) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + err = conn.Connect(ctx, &wconfig.ConnKeywords{}) + if err != nil { + return fmt.Errorf("connection failed: %w", err) + } + + log.Printf("✓ Connected! Starting shell...") + + termSize := waveobj.TermSize{Rows: 24, Cols: 80} + shellProc, err := shellexec.StartRemoteShellProcNoWsh(ctx, termSize, "", shellexec.CommandOptsType{}, conn) + if err != nil { + return fmt.Errorf("failed to start shell: %w", err) + } + defer shellProc.Close() + + log.Printf("✓ Shell started! Executing: %s", cmd) + + _, err = shellProc.Cmd.Write([]byte(cmd + "\n")) + if err != nil { + return fmt.Errorf("failed to write command: %w", err) + } + + time.Sleep(500 * time.Millisecond) + + buf := make([]byte, 8192) + n, err := shellProc.Cmd.Read(buf) + if err != nil { + log.Printf("Warning: read error (may be expected): %v", err) + } + + if n > 0 { + log.Printf("\n--- Output ---\n%s\n--- End Output ---", string(buf[:n])) + } else { + log.Printf("No output received (timeout or no data)") + } + + return nil +} + +func testWshExec(connName string, cmd string, timeout time.Duration) error { + opts, err := remote.ParseOpts(connName) + if err != nil { + return fmt.Errorf("failed to parse connection string: %w", err) + } + + log.Printf("Connecting to %s with wsh enabled...", opts.String()) + + conn := conncontroller.GetConn(opts) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + wshEnabled := true + err = conn.Connect(ctx, &wconfig.ConnKeywords{ + ConnWshEnabled: &wshEnabled, + }) + if err != nil { + return fmt.Errorf("connection failed: %w", err) + } + + status := conn.DeriveConnStatus() + log.Printf("✓ Connected! (wsh enabled: %v)", status.WshEnabled) + if status.WshVersion != "" { + log.Printf(" wsh version: %s", status.WshVersion) + } + if !status.WshEnabled { + log.Printf(" WARNING: wsh not enabled - reason: %s", status.NoWshReason) + } + + log.Printf("Starting wsh-enabled shell...") + + termSize := waveobj.TermSize{Rows: 24, Cols: 80} + shellProc, err := shellexec.StartRemoteShellProc(ctx, ctx, termSize, "", shellexec.CommandOptsType{}, conn) + if err != nil { + return fmt.Errorf("failed to start shell: %w", err) + } + defer shellProc.Close() + + log.Printf("✓ Shell started! Executing: %s", cmd) + + _, err = shellProc.Cmd.Write([]byte(cmd + "\n")) + if err != nil { + return fmt.Errorf("failed to write command: %w", err) + } + + time.Sleep(500 * time.Millisecond) + + buf := make([]byte, 8192) + n, err := shellProc.Cmd.Read(buf) + if err != nil { + log.Printf("Warning: read error (may be expected): %v", err) + } + + if n > 0 { + log.Printf("\n--- Output ---\n%s\n--- End Output ---", string(buf[:n])) + } else { + log.Printf("No output received (timeout or no data)") + } + + return nil +} + +func testInteractiveShell(connName string, timeout time.Duration) error { + opts, err := remote.ParseOpts(connName) + if err != nil { + return fmt.Errorf("failed to parse connection string: %w", err) + } + + log.Printf("Connecting to %s...", opts.String()) + + conn := conncontroller.GetConn(opts) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + err = conn.Connect(ctx, &wconfig.ConnKeywords{}) + if err != nil { + return fmt.Errorf("connection failed: %w", err) + } + + log.Printf("✓ Connected! Starting interactive shell...") + log.Printf("Note: This is a simple test - output may be mixed with prompts") + log.Printf("Type commands and press Enter. Type 'exit' to quit.\n") + + termSize := waveobj.TermSize{Rows: 24, Cols: 80} + shellProc, err := shellexec.StartRemoteShellProcNoWsh(ctx, termSize, "", shellexec.CommandOptsType{}, conn) + if err != nil { + return fmt.Errorf("failed to start shell: %w", err) + } + defer shellProc.Close() + + go func() { + buf := make([]byte, 8192) + for { + n, err := shellProc.Cmd.Read(buf) + if err != nil { + return + } + if n > 0 { + fmt.Print(string(buf[:n])) + } + } + }() + + go func() { + buf := make([]byte, 1) + for { + n, err := os.Stdin.Read(buf) + if err != nil { + return + } + if n > 0 { + shellProc.Cmd.Write(buf[:n]) + } + } + }() + + shellProc.Wait() + log.Printf("\nShell exited") + + return nil +} diff --git a/pkg/wconfig/filewatcher.go b/pkg/wconfig/filewatcher.go index 40a008d921..d0d4d2e9c1 100644 --- a/pkg/wconfig/filewatcher.go +++ b/pkg/wconfig/filewatcher.go @@ -5,6 +5,7 @@ package wconfig import ( "log" + "os" "path/filepath" "regexp" "sync" @@ -41,6 +42,7 @@ func GetWatcher() *Watcher { return } configDirAbsPath := wavebase.GetWaveConfigDir() + log.Printf("create config watcher, configdir=%q", configDirAbsPath) instance = &Watcher{watcher: watcher} err = instance.watcher.Add(configDirAbsPath) const failedStr = "failed to add path %s to watcher: %v" @@ -51,7 +53,7 @@ func GetWatcher() *Watcher { subdirs := GetConfigSubdirs() for _, dir := range subdirs { err = instance.watcher.Add(dir) - if err != nil { + if err != nil && !os.IsNotExist(err) { log.Printf(failedStr, dir, err) } } From d06f56b53687f402dcaba39c2a24f4e7ae7919cb Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 5 Feb 2026 16:44:18 -0800 Subject: [PATCH 4/7] get the wshexec test actually working, fixed bugs in test code --- cmd/test-conn/testutil.go | 40 ++++++++++++++++++++- pkg/remote/conncontroller/conncontroller.go | 4 +-- pkg/shellexec/shellexec.go | 3 ++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/cmd/test-conn/testutil.go b/cmd/test-conn/testutil.go index 3f59ab0ddd..f82e7b7195 100644 --- a/cmd/test-conn/testutil.go +++ b/cmd/test-conn/testutil.go @@ -12,13 +12,17 @@ import ( "runtime" "time" + "github.com/google/uuid" "github.com/wavetermdev/waveterm/pkg/remote" "github.com/wavetermdev/waveterm/pkg/remote/conncontroller" "github.com/wavetermdev/waveterm/pkg/shellexec" "github.com/wavetermdev/waveterm/pkg/userinput" + "github.com/wavetermdev/waveterm/pkg/util/shellutil" "github.com/wavetermdev/waveterm/pkg/wavebase" + "github.com/wavetermdev/waveterm/pkg/wavejwt" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshserver" "github.com/wavetermdev/waveterm/pkg/wshutil" "github.com/wavetermdev/waveterm/pkg/wstore" ) @@ -76,6 +80,26 @@ func initTestHarness(autoAccept bool) error { userinput.SetUserInputProvider(&CLIProvider{AutoAccept: autoAccept}) + keyPair, err := wavejwt.GenerateKeyPair() + if err != nil { + return fmt.Errorf("failed to generate JWT key pair: %w", err) + } + + err = wavejwt.SetPrivateKey(keyPair.PrivateKey) + if err != nil { + return fmt.Errorf("failed to set JWT private key: %w", err) + } + + err = wavejwt.SetPublicKey(keyPair.PublicKey) + if err != nil { + return fmt.Errorf("failed to set JWT public key: %w", err) + } + + rpc := wshserver.GetMainRpcClient() + wshutil.DefaultRouter.RegisterTrustedLeaf(rpc, wshutil.DefaultRoute) + + wconfig.GetWatcher().Start() + log.Printf("Test harness initialized") return nil } @@ -196,8 +220,22 @@ func testWshExec(connName string, cmd string, timeout time.Duration) error { log.Printf("Starting wsh-enabled shell...") + swapToken := &shellutil.TokenSwapEntry{ + Token: uuid.New().String(), + Env: make(map[string]string), + Exp: time.Now().Add(5 * time.Minute), + } + swapToken.Env["TERM_PROGRAM"] = "waveterm" + swapToken.Env["WAVETERM"] = "1" + swapToken.Env["WAVETERM_VERSION"] = wavebase.WaveVersion + swapToken.Env["WAVETERM_CONN"] = connName + + cmdOpts := shellexec.CommandOptsType{ + SwapToken: swapToken, + } + termSize := waveobj.TermSize{Rows: 24, Cols: 80} - shellProc, err := shellexec.StartRemoteShellProc(ctx, ctx, termSize, "", shellexec.CommandOptsType{}, conn) + shellProc, err := shellexec.StartRemoteShellProc(ctx, ctx, termSize, "", cmdOpts, conn) if err != nil { return fmt.Errorf("failed to start shell: %w", err) } diff --git a/pkg/remote/conncontroller/conncontroller.go b/pkg/remote/conncontroller/conncontroller.go index 4e6f0aeefd..6347476518 100644 --- a/pkg/remote/conncontroller/conncontroller.go +++ b/pkg/remote/conncontroller/conncontroller.go @@ -323,11 +323,11 @@ func (conn *SSHConn) GetEnvironmentMaps(ctx context.Context) (map[string]string, func runSessionWithContext(ctx context.Context, session *ssh.Session, cmd string) error { errCh := make(chan error, 1) - + go func() { errCh <- session.Run(cmd) }() - + select { case <-ctx.Done(): session.Close() diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index d930dcf69d..1e867866f2 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -332,6 +332,9 @@ func StartRemoteShellProcNoWsh(ctx context.Context, termSize waveobj.TermSize, c } func StartRemoteShellProc(ctx context.Context, logCtx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *conncontroller.SSHConn) (*ShellProc, error) { + if cmdOpts.SwapToken == nil { + return nil, fmt.Errorf("SwapToken is required in CommandOptsType") + } client := conn.GetClient() connRoute := wshutil.MakeConnectionRouteId(conn.GetName()) rpcClient := wshclient.GetBareRpcClient() From 513ee6165f9e107260e7f7b302853fcf03bc016c Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 6 Feb 2026 10:01:02 -0800 Subject: [PATCH 5/7] add swaptoken nil check to local/wsl procs as well --- pkg/shellexec/shellexec.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index 1e867866f2..ef6afacb6b 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -174,6 +174,9 @@ func StartWslShellProcNoWsh(ctx context.Context, termSize waveobj.TermSize, cmdS } func StartWslShellProc(ctx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, conn *wslconn.WslConn) (*ShellProc, error) { + if cmdOpts.SwapToken == nil { + return nil, fmt.Errorf("SwapToken is required in CommandOptsType") + } client := conn.GetClient() conn.Infof(ctx, "WSL-NEWSESSION (StartWslShellProc)") connRoute := wshutil.MakeConnectionRouteId(conn.GetName()) @@ -577,6 +580,9 @@ func StartRemoteShellJob(ctx context.Context, logCtx context.Context, termSize w } func StartLocalShellProc(logCtx context.Context, termSize waveobj.TermSize, cmdStr string, cmdOpts CommandOptsType, connName string) (*ShellProc, error) { + if cmdOpts.SwapToken == nil { + return nil, fmt.Errorf("SwapToken is required in CommandOptsType") + } shellutil.InitCustomShellStartupFiles() var ecmd *exec.Cmd var shellOpts []string From c4eee85abf49dd0c8e6baa4d18704ea4dedd994d Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 6 Feb 2026 10:03:26 -0800 Subject: [PATCH 6/7] check for opencode/claude usage --- frontend/app/view/term/termwrap.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 40dea37d20..62e20a4e00 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -250,6 +250,16 @@ function checkCommandForTelemetry(decodedCmd: string) { recordTEvent("action:term", { "action:type": "cli-tailf" }); return; } + + if (decodedCmd.startsWith("claude")) { + recordTEvent("action:term", { "action:type": "claude" }); + return; + } + + if (decodedCmd.startsWith("opencode")) { + recordTEvent("action:term", { "action:type": "opencode" }); + return; + } } // OSC 16162 - Shell Integration Commands From d006af61fd4761da62a98d00dcc8175204320a79 Mon Sep 17 00:00:00 2001 From: sawka Date: Fri, 6 Feb 2026 10:51:49 -0800 Subject: [PATCH 7/7] fix, switch to regexp --- frontend/app/view/term/termwrap.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 62e20a4e00..498356b8c9 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -251,12 +251,14 @@ function checkCommandForTelemetry(decodedCmd: string) { return; } - if (decodedCmd.startsWith("claude")) { + const claudeRegex = /^claude\b/; + if (claudeRegex.test(decodedCmd)) { recordTEvent("action:term", { "action:type": "claude" }); return; } - if (decodedCmd.startsWith("opencode")) { + const opencodeRegex = /^opencode\b/; + if (opencodeRegex.test(decodedCmd)) { recordTEvent("action:term", { "action:type": "opencode" }); return; }