From 4bb2bbe5ad152aaee032358c8c9295deddd21f6a Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 18 Dec 2025 12:08:43 -0800 Subject: [PATCH 1/9] make Err() a real latch, and catch ctx errors consistently. simplify. --- pkg/web/sse/ssehandler.go | 94 +++++++++++++++------------------------ 1 file changed, 36 insertions(+), 58 deletions(-) diff --git a/pkg/web/sse/ssehandler.go b/pkg/web/sse/ssehandler.go index 70c4706d8..aa5d03a5b 100644 --- a/pkg/web/sse/ssehandler.go +++ b/pkg/web/sse/ssehandler.go @@ -64,11 +64,10 @@ type SSEMessage struct { type SSEHandlerCh struct { w http.ResponseWriter rc *http.ResponseController - ctx context.Context + ctx context.Context // the r.Context() writeCh chan SSEMessage - errCh chan error - mu sync.RWMutex + lock sync.Mutex closed bool initialized bool err error @@ -83,14 +82,13 @@ func MakeSSEHandlerCh(w http.ResponseWriter, ctx context.Context) *SSEHandlerCh rc: http.NewResponseController(w), ctx: ctx, writeCh: make(chan SSEMessage, 10), // Buffered to prevent blocking - errCh: make(chan error, 1), // Buffered for single error } } // SetupSSE configures the response headers and starts the writer goroutine func (h *SSEHandlerCh) SetupSSE() error { - h.mu.Lock() - defer h.mu.Unlock() + h.lock.Lock() + defer h.lock.Unlock() if h.closed { return fmt.Errorf("SSE handler is closed") @@ -152,6 +150,7 @@ func (h *SSEHandlerCh) writerLoop() { } case <-h.ctx.Done(): + h.setError(h.ctx.Err()) return } } @@ -159,6 +158,9 @@ func (h *SSEHandlerCh) writerLoop() { // writeMessage writes a message to the SSE stream func (h *SSEHandlerCh) writeMessage(msg SSEMessage) error { + if h.ctx.Err() != nil { + return h.ctx.Err() + } switch msg.Type { case SSEMsgData: return h.writeDirectly(msg.Data, SSEMsgData) @@ -175,8 +177,8 @@ func (h *SSEHandlerCh) writeMessage(msg SSEMessage) error { // isInitialized returns whether SetupSSE has been called func (h *SSEHandlerCh) isInitialized() bool { - h.mu.RLock() - defer h.mu.RUnlock() + h.lock.Lock() + defer h.lock.Unlock() return h.initialized } @@ -225,31 +227,30 @@ func (h *SSEHandlerCh) flush() error { // setError sets the error state thread-safely func (h *SSEHandlerCh) setError(err error) { - h.mu.Lock() - defer h.mu.Unlock() + h.lock.Lock() + defer h.lock.Unlock() if h.err == nil { h.err = err - // Send error to error channel if there's space - select { - case h.errCh <- err: - default: - } } } -// WriteData queues data to be written in SSE format -func (h *SSEHandlerCh) WriteData(data string) error { - h.mu.RLock() +// queueMessage queues an SSEMessage to be written +func (h *SSEHandlerCh) queueMessage(msg SSEMessage) error { + h.lock.Lock() closed := h.closed - h.mu.RUnlock() + h.lock.Unlock() if closed { return fmt.Errorf("SSE handler is closed") } + if err := h.Err(); err != nil { + return err + } + select { - case h.writeCh <- SSEMessage{Type: SSEMsgData, Data: data}: + case h.writeCh <- msg: return nil case <-h.ctx.Done(): return h.ctx.Err() @@ -258,6 +259,11 @@ func (h *SSEHandlerCh) WriteData(data string) error { } } +// WriteData queues data to be written in SSE format +func (h *SSEHandlerCh) WriteData(data string) error { + return h.queueMessage(SSEMessage{Type: SSEMsgData, Data: data}) +} + // WriteJsonData marshals data to JSON and queues it for writing func (h *SSEHandlerCh) WriteJsonData(data interface{}) error { jsonData, err := json.Marshal(data) @@ -282,63 +288,36 @@ func (h *SSEHandlerCh) WriteError(errorMsg string) error { // WriteEvent queues an SSE event with optional event type func (h *SSEHandlerCh) WriteEvent(eventType, data string) error { - h.mu.RLock() - closed := h.closed - h.mu.RUnlock() - - if closed { - return fmt.Errorf("SSE handler is closed") - } - - select { - case h.writeCh <- SSEMessage{Type: SSEMsgEvent, Data: data, EventType: eventType}: - return nil - case <-h.ctx.Done(): - return h.ctx.Err() - default: - return fmt.Errorf("write channel is full") - } + return h.queueMessage(SSEMessage{Type: SSEMsgEvent, Data: data, EventType: eventType}) } // WriteComment queues an SSE comment func (h *SSEHandlerCh) WriteComment(comment string) error { - h.mu.RLock() - closed := h.closed - h.mu.RUnlock() - - if closed { - return fmt.Errorf("SSE handler is closed") - } - - select { - case h.writeCh <- SSEMessage{Type: SSEMsgComment, Data: comment}: - return nil - case <-h.ctx.Done(): - return h.ctx.Err() - default: - return fmt.Errorf("write channel is full") - } + return h.queueMessage(SSEMessage{Type: SSEMsgComment, Data: comment}) } // Err returns any error that occurred during writing func (h *SSEHandlerCh) Err() error { - h.mu.RLock() - defer h.mu.RUnlock() + h.lock.Lock() + defer h.lock.Unlock() + if h.err == nil && h.ctx.Err() != nil { + h.err = h.ctx.Err() + } return h.err } // Close closes the write channel, sends [DONE], and cleans up resources func (h *SSEHandlerCh) Close() { - h.mu.Lock() + h.lock.Lock() if h.closed || !h.initialized { - h.mu.Unlock() + h.lock.Unlock() return } h.closed = true // Close the write channel, which will trigger [DONE] in writerLoop close(h.writeCh) - h.mu.Unlock() + h.lock.Unlock() // Wait for writer goroutine to finish (without holding the lock) h.wg.Wait() @@ -461,7 +440,6 @@ func (h *SSEHandlerCh) AiMsgError(errText string) error { return h.WriteJsonData(resp) } - func (h *SSEHandlerCh) AiMsgData(dataType string, id string, data interface{}) error { if !strings.HasPrefix(dataType, "data-") { panic(fmt.Sprintf("AiMsgData type must start with 'data-', got: %s", dataType)) From 8ac60f8760d3cf02e5226b64eac3f45bc59c845e Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 18 Dec 2025 13:50:31 -0800 Subject: [PATCH 2/9] some terminal command classes telemetry for affordance tracking --- frontend/app/store/global.ts | 11 ++++++++++ frontend/app/view/term/term-model.ts | 10 +++++++++ frontend/app/view/term/termwrap.ts | 23 ++++++++++++++++++++ pkg/telemetry/telemetrydata/telemetrydata.go | 1 + 4 files changed, 45 insertions(+) diff --git a/frontend/app/store/global.ts b/frontend/app/store/global.ts index 35eb9f158..42e2c635f 100644 --- a/frontend/app/store/global.ts +++ b/frontend/app/store/global.ts @@ -458,6 +458,16 @@ function useBlockDataLoaded(blockId: string): boolean { return useAtomValue(loadedAtom); } +/** + * Safely read an atom value, returning null if the atom is null. + */ +function readAtom(atom: Atom): T { + if (atom == null) { + return null; + } + return globalStore.get(atom); +} + /** * Get the preload api. */ @@ -863,6 +873,7 @@ export { getUserName, globalPrimaryTabStartup, globalStore, + readAtom, initGlobal, initGlobalWaveEventSubs, isDev, diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index 4ea57faf8..bc4d15899 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -21,6 +21,8 @@ import { getOverrideConfigAtom, getSettingsKeyAtom, globalStore, + readAtom, + recordTEvent, useBlockAtom, WOS, } from "@/store/global"; @@ -478,6 +480,14 @@ export class TermViewModel implements ViewModel { } keyDownHandler(waveEvent: WaveKeyboardEvent): boolean { + if (keyutil.checkKeyPressed(waveEvent, "Ctrl:r")) { + const shellIntegrationStatus = readAtom(this.termRef?.current?.shellIntegrationStatusAtom); + if (shellIntegrationStatus === "ready") { + recordTEvent("action:term", { "action:type": "term:ctrlr" }); + } + // just for telemetry, we allow this keybinding through, back to the terminal + return false; + } if (keyutil.checkKeyPressed(waveEvent, "Cmd:Escape")) { const blockAtom = WOS.getWaveObjectAtom(`block:${this.blockId}`); const blockData = globalStore.get(blockAtom); diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 0ad7fa749..89229d05d 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -32,6 +32,29 @@ import { createTempFileFromBlob, extractAllClipboardData } from "./termutil"; const dlog = debug("wave:termwrap"); +function checkCommandForTelemetry(decodedCmd: string) { + if (!decodedCmd) { + return; + } + + if (decodedCmd.startsWith("ssh ")) { + recordTEvent("conn:connect", { "conn:conntype": "ssh-manual" }); + return; + } + + const editorsRegex = /^(vim|vi|nano|nvim)\b/; + if (editorsRegex.test(decodedCmd)) { + recordTEvent("action:term", { "action:type": "cli-edit" }); + return; + } + + const tailFollowRegex = /(^|\|\s*)tail\s+-[fF]\b/; + if (tailFollowRegex.test(decodedCmd)) { + recordTEvent("action:term", { "action:type": "cli-tailf" }); + return; + } +} + const TermFileName = "term"; const TermCacheFileName = "cache:term:full"; const MinDataProcessedForCache = 100 * 1024; diff --git a/pkg/telemetry/telemetrydata/telemetrydata.go b/pkg/telemetry/telemetrydata/telemetrydata.go index 6bc1e6ee9..d2bf7deea 100644 --- a/pkg/telemetry/telemetrydata/telemetrydata.go +++ b/pkg/telemetry/telemetrydata/telemetrydata.go @@ -27,6 +27,7 @@ var ValidEventNames = map[string]bool{ "action:createblock": true, "action:openwaveai": true, "action:other": true, + "action:term": true, "wsh:run": true, From 57a386395aecc05fc5cc83ebfabbdc1ba248c208 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 18 Dec 2025 14:25:12 -0800 Subject: [PATCH 3/9] idlist ds --- pkg/utilds/idlist.go | 64 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 pkg/utilds/idlist.go diff --git a/pkg/utilds/idlist.go b/pkg/utilds/idlist.go new file mode 100644 index 000000000..08dec2aac --- /dev/null +++ b/pkg/utilds/idlist.go @@ -0,0 +1,64 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package utilds + +import ( + "sync" + + "github.com/google/uuid" +) + +type idListEntry[T any] struct { + id string + val T +} + +type IdList[T any] struct { + lock sync.Mutex + entries []idListEntry[T] +} + +func (il *IdList[T]) Register(val T) string { + il.lock.Lock() + defer il.lock.Unlock() + + id := uuid.New().String() + il.entries = append(il.entries, idListEntry[T]{id: id, val: val}) + return id +} + +func (il *IdList[T]) RegisterWithId(id string, val T) { + il.lock.Lock() + defer il.lock.Unlock() + + il.unregister_nolock(id) + il.entries = append(il.entries, idListEntry[T]{id: id, val: val}) +} + +func (il *IdList[T]) Unregister(id string) { + il.lock.Lock() + defer il.lock.Unlock() + + il.unregister_nolock(id) +} + +func (il *IdList[T]) unregister_nolock(id string) { + for i, entry := range il.entries { + if entry.id == id { + il.entries = append(il.entries[:i], il.entries[i+1:]...) + return + } + } +} + +func (il *IdList[T]) GetList() []T { + il.lock.Lock() + defer il.lock.Unlock() + + result := make([]T, len(il.entries)) + for i, entry := range il.entries { + result[i] = entry.val + } + return result +} \ No newline at end of file From 453e2a85aadf65dbae8a933ffaddd69d5423de11 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 18 Dec 2025 15:19:16 -0800 Subject: [PATCH 4/9] updating tool approval flow, tie to SSE lifecycle --- .vscode/settings.json | 1 - pkg/aiusechat/toolapproval.go | 54 +++++++++++++------------------ pkg/aiusechat/uctypes/uctypes.go | 1 - pkg/aiusechat/usechat.go | 6 ++-- pkg/web/sse/ssehandler.go | 38 +++++++++++++++++++++- pkg/wshrpc/wshserver/wshserver.go | 2 +- 6 files changed, 63 insertions(+), 39 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 25c1ea49f..85f4c06cd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -55,7 +55,6 @@ "files.associations": { "*.css": "tailwindcss" }, - "go.lintTool": "staticcheck", "gopls": { "analyses": { "QF1003": false diff --git a/pkg/aiusechat/toolapproval.go b/pkg/aiusechat/toolapproval.go index 7c374a15b..4e11f7f6a 100644 --- a/pkg/aiusechat/toolapproval.go +++ b/pkg/aiusechat/toolapproval.go @@ -5,22 +5,17 @@ package aiusechat import ( "sync" - "time" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" -) - -const ( - InitialApprovalTimeout = 10 * time.Second - KeepAliveExtension = 10 * time.Second + "github.com/wavetermdev/waveterm/pkg/web/sse" ) type ApprovalRequest struct { - approval string - done bool - doneChan chan struct{} - timer *time.Timer - mu sync.Mutex + approval string + done bool + doneChan chan struct{} + mu sync.Mutex + onCloseUnregFn func() } type ApprovalRegistry struct { @@ -38,6 +33,12 @@ func registerToolApprovalRequest(toolCallId string, req *ApprovalRequest) { globalApprovalRegistry.requests[toolCallId] = req } +func UnregisterToolApproval(toolCallId string) { + globalApprovalRegistry.mu.Lock() + defer globalApprovalRegistry.mu.Unlock() + delete(globalApprovalRegistry.requests, toolCallId) +} + func getToolApprovalRequest(toolCallId string) (*ApprovalRequest, bool) { globalApprovalRegistry.mu.Lock() defer globalApprovalRegistry.mu.Unlock() @@ -45,19 +46,23 @@ func getToolApprovalRequest(toolCallId string) (*ApprovalRequest, bool) { return req, exists } -func RegisterToolApproval(toolCallId string) { +func RegisterToolApproval(toolCallId string, sseHandler *sse.SSEHandlerCh) { req := &ApprovalRequest{ doneChan: make(chan struct{}), } - req.timer = time.AfterFunc(InitialApprovalTimeout, func() { - UpdateToolApproval(toolCallId, uctypes.ApprovalTimeout, false) + onCloseId := sseHandler.RegisterOnClose(func() { + UpdateToolApproval(toolCallId, uctypes.ApprovalTimeout) }) + req.onCloseUnregFn = func() { + sseHandler.UnregisterOnClose(onCloseId) + } + registerToolApprovalRequest(toolCallId, req) } -func UpdateToolApproval(toolCallId string, approval string, keepAlive bool) error { +func UpdateToolApproval(toolCallId string, approval string) error { req, exists := getToolApprovalRequest(toolCallId) if !exists { return nil @@ -70,31 +75,16 @@ func UpdateToolApproval(toolCallId string, approval string, keepAlive bool) erro return nil } - if keepAlive && approval == "" { - req.timer.Reset(KeepAliveExtension) - return nil - } - req.approval = approval req.done = true - if req.timer != nil { - req.timer.Stop() + if req.onCloseUnregFn != nil { + req.onCloseUnregFn() } close(req.doneChan) return nil } -func CurrentToolApprovalStatus(toolCallId string) string { - req, exists := getToolApprovalRequest(toolCallId) - if !exists { - return "" - } - - req.mu.Lock() - defer req.mu.Unlock() - return req.approval -} func WaitForToolApproval(toolCallId string) string { req, exists := getToolApprovalRequest(toolCallId) diff --git a/pkg/aiusechat/uctypes/uctypes.go b/pkg/aiusechat/uctypes/uctypes.go index f8bdc2169..f698b8e74 100644 --- a/pkg/aiusechat/uctypes/uctypes.go +++ b/pkg/aiusechat/uctypes/uctypes.go @@ -520,7 +520,6 @@ type WaveChatOpts struct { TabStateGenerator func() (string, []ToolDefinition, string, error) BuilderAppGenerator func() (string, string, string, error) WidgetAccess bool - RegisterToolApproval func(string) AllowNativeWebSearch bool BuilderId string BuilderAppId string diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index 9ccf847c8..5407fd498 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -340,8 +340,8 @@ func processToolCalls(backend UseChatBackend, stopReason *uctypes.WaveStopReason log.Printf("AI data-tooluse %s\n", toolCall.ID) _ = sseHandler.AiMsgData("data-tooluse", toolCall.ID, toolUseData) updateToolUseDataInChat(backend, chatOpts, toolCall.ID, toolUseData) - if toolUseData.Approval == uctypes.ApprovalNeedsApproval && chatOpts.RegisterToolApproval != nil { - chatOpts.RegisterToolApproval(toolCall.ID) + if toolUseData.Approval == uctypes.ApprovalNeedsApproval { + RegisterToolApproval(toolCall.ID, sseHandler) } } // At this point, all ToolCalls are guaranteed to have non-nil ToolUseData @@ -350,6 +350,7 @@ func processToolCalls(backend UseChatBackend, stopReason *uctypes.WaveStopReason for _, toolCall := range stopReason.ToolCalls { result := processToolCall(backend, toolCall, chatOpts, sseHandler, metrics) toolResults = append(toolResults, result) + UnregisterToolApproval(toolCall.ID) } toolResultMsgs, err := backend.ConvertToolResultsToNativeChatMessage(toolResults) @@ -666,7 +667,6 @@ func WaveAIPostMessageHandler(w http.ResponseWriter, r *http.Request) { ClientId: client.OID, Config: *aiOpts, WidgetAccess: req.WidgetAccess, - RegisterToolApproval: RegisterToolApproval, AllowNativeWebSearch: true, BuilderId: req.BuilderId, BuilderAppId: req.BuilderAppId, diff --git a/pkg/web/sse/ssehandler.go b/pkg/web/sse/ssehandler.go index aa5d03a5b..cdd055fbd 100644 --- a/pkg/web/sse/ssehandler.go +++ b/pkg/web/sse/ssehandler.go @@ -11,6 +11,8 @@ import ( "strings" "sync" "time" + + "github.com/wavetermdev/waveterm/pkg/utilds" ) // see /aiprompts/usechat-streamingproto.md for protocol @@ -72,7 +74,9 @@ type SSEHandlerCh struct { initialized bool err error - wg sync.WaitGroup + wg sync.WaitGroup + onCloseHandlers utilds.IdList[func()] + handlersRun bool } // MakeSSEHandlerCh creates a new channel-based SSE handler @@ -125,6 +129,7 @@ func (h *SSEHandlerCh) SetupSSE() error { // writerLoop handles all writes and keepalives in a single goroutine func (h *SSEHandlerCh) writerLoop() { defer h.wg.Done() + defer h.runOnCloseHandlers() keepaliveTicker := time.NewTicker(SSEKeepaliveInterval) defer keepaliveTicker.Stop() @@ -306,6 +311,37 @@ func (h *SSEHandlerCh) Err() error { return h.err } +// RegisterOnClose registers a handler function to be called when the connection closes +// Returns an ID that can be used to unregister the handler +func (h *SSEHandlerCh) RegisterOnClose(fn func()) string { + h.lock.Lock() + defer h.lock.Unlock() + return h.onCloseHandlers.Register(fn) +} + +// UnregisterOnClose removes a previously registered onClose handler by ID +func (h *SSEHandlerCh) UnregisterOnClose(id string) { + h.lock.Lock() + defer h.lock.Unlock() + h.onCloseHandlers.Unregister(id) +} + +// runOnCloseHandlers runs all registered onClose handlers exactly once +func (h *SSEHandlerCh) runOnCloseHandlers() { + h.lock.Lock() + if h.handlersRun { + h.lock.Unlock() + return + } + h.handlersRun = true + h.lock.Unlock() + + handlers := h.onCloseHandlers.GetList() + for _, fn := range handlers { + fn() + } +} + // Close closes the write channel, sends [DONE], and cleans up resources func (h *SSEHandlerCh) Close() { h.lock.Lock() diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 80a6b21fe..cc4fc1d7e 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -1257,7 +1257,7 @@ func (ws *WshServer) GetWaveAIRateLimitCommand(ctx context.Context) (*uctypes.Ra } func (ws *WshServer) WaveAIToolApproveCommand(ctx context.Context, data wshrpc.CommandWaveAIToolApproveData) error { - return aiusechat.UpdateToolApproval(data.ToolCallId, data.Approval, data.KeepAlive) + return aiusechat.UpdateToolApproval(data.ToolCallId, data.Approval) } func (ws *WshServer) WaveAIGetToolDiffCommand(ctx context.Context, data wshrpc.CommandWaveAIGetToolDiffData) (*wshrpc.CommandWaveAIGetToolDiffRtnData, error) { From b1d3885dde6fba1a85ee2b8b17566c4a31c454b1 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 18 Dec 2025 15:32:07 -0800 Subject: [PATCH 5/9] remove FE keepalives for tooluse --- frontend/app/aipanel/aitooluse.tsx | 27 --------------------------- frontend/types/gotypes.d.ts | 2 +- pkg/wshrpc/wshrpctypes.go | 1 - 3 files changed, 1 insertion(+), 29 deletions(-) diff --git a/frontend/app/aipanel/aitooluse.tsx b/frontend/app/aipanel/aitooluse.tsx index 3406e0a5f..7868c188e 100644 --- a/frontend/app/aipanel/aitooluse.tsx +++ b/frontend/app/aipanel/aitooluse.tsx @@ -146,26 +146,11 @@ interface AIToolUseBatchProps { const AIToolUseBatch = memo(({ parts, isStreaming }: AIToolUseBatchProps) => { const [userApprovalOverride, setUserApprovalOverride] = useState(null); - const partsRef = useRef(parts); - partsRef.current = parts; - // All parts in a batch have the same approval status (enforced by grouping logic in AIToolUseGroup) const firstTool = parts[0].data; const baseApproval = userApprovalOverride || firstTool.approval; const effectiveApproval = getEffectiveApprovalStatus(baseApproval, isStreaming); - useEffect(() => { - if (!isStreaming || effectiveApproval !== "needs-approval") return; - - const interval = setInterval(() => { - partsRef.current.forEach((part) => { - WaveAIModel.getInstance().toolUseKeepalive(part.data.toolcallid); - }); - }, 4000); - - return () => clearInterval(interval); - }, [isStreaming, effectiveApproval]); - const handleApprove = () => { setUserApprovalOverride("user-approved"); parts.forEach((part) => { @@ -212,8 +197,6 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { const showRestoreModal = restoreModalToolCallId === toolData.toolcallid; const highlightTimeoutRef = useRef(null); const highlightedBlockIdRef = useRef(null); - const toolCallIdRef = useRef(toolData.toolcallid); - toolCallIdRef.current = toolData.toolcallid; const statusIcon = toolData.status === "completed" ? "✓" : toolData.status === "error" ? "✗" : "•"; const statusColor = @@ -224,16 +207,6 @@ const AIToolUse = memo(({ part, isStreaming }: AIToolUseProps) => { const isFileWriteTool = toolData.toolname === "write_text_file" || toolData.toolname === "edit_text_file"; - useEffect(() => { - if (!isStreaming || effectiveApproval !== "needs-approval") return; - - const interval = setInterval(() => { - WaveAIModel.getInstance().toolUseKeepalive(toolCallIdRef.current); - }, 4000); - - return () => clearInterval(interval); - }, [isStreaming, effectiveApproval]); - useEffect(() => { return () => { if (highlightTimeoutRef.current) { diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 56b9253b3..8a91d4d57 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -529,7 +529,6 @@ declare global { // wshrpc.CommandWaveAIToolApproveData type CommandWaveAIToolApproveData = { toolcallid: string; - keepalive?: boolean; approval?: string; }; @@ -1235,6 +1234,7 @@ declare global { "action:type"?: string; "debug:panictype"?: string; "block:view"?: string; + "block:controller"?: string; "ai:backendtype"?: string; "ai:local"?: boolean; "wsh:cmd"?: string; diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 8c1bc0ddb..ef5dac725 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -817,7 +817,6 @@ type CommandGetWaveAIChatData struct { type CommandWaveAIToolApproveData struct { ToolCallId string `json:"toolcallid"` - KeepAlive bool `json:"keepalive,omitempty"` Approval string `json:"approval,omitempty"` } From c2f7660bb5b85b5c0c381f40e4f40e5339fffdcd Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 18 Dec 2025 15:41:00 -0800 Subject: [PATCH 6/9] remove old style wshrpc calls from wshcmd files... --- cmd/wsh/cmd/wshcmd-connserver.go | 6 +----- cmd/wsh/cmd/wshcmd-deleteblock.go | 3 ++- cmd/wsh/cmd/wshcmd-editconfig.go | 3 ++- cmd/wsh/cmd/wshcmd-notify.go | 3 ++- cmd/wsh/cmd/wshcmd-secret.go | 2 +- cmd/wsh/cmd/wshcmd-setmeta.go | 3 ++- cmd/wsh/cmd/wshcmd-view.go | 3 ++- package-lock.json | 4 ++-- pkg/wshrpc/wshrpctypes.go | 1 + 9 files changed, 15 insertions(+), 13 deletions(-) diff --git a/cmd/wsh/cmd/wshcmd-connserver.go b/cmd/wsh/cmd/wshcmd-connserver.go index 678ea77cc..0726ea066 100644 --- a/cmd/wsh/cmd/wshcmd-connserver.go +++ b/cmd/wsh/cmd/wshcmd-connserver.go @@ -191,14 +191,10 @@ func serverRunRouter(jwtToken string) error { func checkForUpdate() error { remoteInfo := wshutil.GetInfo() - needsRestartRaw, err := RpcClient.SendRpcRequest(wshrpc.Command_ConnUpdateWsh, remoteInfo, &wshrpc.RpcOpts{Timeout: 60000}) + needsRestart, err := wshclient.ConnUpdateWshCommand(RpcClient, remoteInfo, &wshrpc.RpcOpts{Timeout: 60000}) if err != nil { return fmt.Errorf("could not update: %w", err) } - needsRestart, ok := needsRestartRaw.(bool) - if !ok { - return fmt.Errorf("wrong return type from update") - } if needsRestart { // run the restart command here // how to get the correct path? diff --git a/cmd/wsh/cmd/wshcmd-deleteblock.go b/cmd/wsh/cmd/wshcmd-deleteblock.go index 6ff817dfc..76518e721 100644 --- a/cmd/wsh/cmd/wshcmd-deleteblock.go +++ b/cmd/wsh/cmd/wshcmd-deleteblock.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var deleteBlockCmd = &cobra.Command{ @@ -35,7 +36,7 @@ func deleteBlockRun(cmd *cobra.Command, args []string) (rtnErr error) { deleteBlockData := &wshrpc.CommandDeleteBlockData{ BlockId: fullORef.OID, } - _, err = RpcClient.SendRpcRequest(wshrpc.Command_DeleteBlock, deleteBlockData, &wshrpc.RpcOpts{Timeout: 2000}) + err = wshclient.DeleteBlockCommand(RpcClient, *deleteBlockData, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("delete block failed: %v", err) } diff --git a/cmd/wsh/cmd/wshcmd-editconfig.go b/cmd/wsh/cmd/wshcmd-editconfig.go index 5f2153dd7..6dc9c13f6 100644 --- a/cmd/wsh/cmd/wshcmd-editconfig.go +++ b/cmd/wsh/cmd/wshcmd-editconfig.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var editConfigMagnified bool @@ -48,7 +49,7 @@ func editConfigRun(cmd *cobra.Command, args []string) (rtnErr error) { Focused: true, } - _, err := RpcClient.SendRpcRequest(wshrpc.Command_CreateBlock, wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) + _, err := wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("opening config file: %w", err) } diff --git a/cmd/wsh/cmd/wshcmd-notify.go b/cmd/wsh/cmd/wshcmd-notify.go index 826e38ba6..de2086e1f 100644 --- a/cmd/wsh/cmd/wshcmd-notify.go +++ b/cmd/wsh/cmd/wshcmd-notify.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" "github.com/wavetermdev/waveterm/pkg/wshutil" ) @@ -38,7 +39,7 @@ func notifyRun(cmd *cobra.Command, args []string) (rtnErr error) { Body: message, Silent: notifySilent, } - _, err := RpcClient.SendRpcRequest(wshrpc.Command_Notify, notificationOptions, &wshrpc.RpcOpts{Timeout: 2000, Route: wshutil.ElectronRoute}) + err := wshclient.NotifyCommand(RpcClient, *notificationOptions, &wshrpc.RpcOpts{Timeout: 2000, Route: wshutil.ElectronRoute}) if err != nil { return fmt.Errorf("sending notification: %w", err) } diff --git a/cmd/wsh/cmd/wshcmd-secret.go b/cmd/wsh/cmd/wshcmd-secret.go index f2c287579..7d555c0de 100644 --- a/cmd/wsh/cmd/wshcmd-secret.go +++ b/cmd/wsh/cmd/wshcmd-secret.go @@ -187,7 +187,7 @@ func secretUiRun(cmd *cobra.Command, args []string) (rtnErr error) { Focused: true, } - _, err := RpcClient.SendRpcRequest(wshrpc.Command_CreateBlock, wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) + _, err := wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("opening secrets UI: %w", err) } diff --git a/cmd/wsh/cmd/wshcmd-setmeta.go b/cmd/wsh/cmd/wshcmd-setmeta.go index 13e3a352b..79faa7e78 100644 --- a/cmd/wsh/cmd/wshcmd-setmeta.go +++ b/cmd/wsh/cmd/wshcmd-setmeta.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var setMetaCmd = &cobra.Command{ @@ -192,7 +193,7 @@ func setMetaRun(cmd *cobra.Command, args []string) (rtnErr error) { ORef: *fullORef, Meta: fullMeta, } - _, err = RpcClient.SendRpcRequest(wshrpc.Command_SetMeta, setMetaWshCmd, &wshrpc.RpcOpts{Timeout: 2000}) + err = wshclient.SetMetaCommand(RpcClient, *setMetaWshCmd, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("setting metadata: %v", err) } diff --git a/cmd/wsh/cmd/wshcmd-view.go b/cmd/wsh/cmd/wshcmd-view.go index ccba3a3d9..b0aafe148 100644 --- a/cmd/wsh/cmd/wshcmd-view.go +++ b/cmd/wsh/cmd/wshcmd-view.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/cobra" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wshrpc" + "github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient" ) var viewMagnified bool @@ -99,7 +100,7 @@ func viewRun(cmd *cobra.Command, args []string) (rtnErr error) { wshCmd.BlockDef.Meta[waveobj.MetaKey_Connection] = conn } } - _, err := RpcClient.SendRpcRequest(wshrpc.Command_CreateBlock, wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) + _, err := wshclient.CreateBlockCommand(RpcClient, *wshCmd, &wshrpc.RpcOpts{Timeout: 2000}) if err != nil { return fmt.Errorf("running view command: %w", err) } diff --git a/package-lock.json b/package-lock.json index 9a81ab4bf..8503c1a21 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.13.1-beta.0", + "version": "0.13.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.13.1-beta.0", + "version": "0.13.1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index ef5dac725..14ffb1477 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -58,6 +58,7 @@ const ( Command_RouteAnnounce = "routeannounce" // special (for routing) Command_RouteUnannounce = "routeunannounce" // special (for routing) Command_Message = "message" + Command_GetMeta = "getmeta" Command_SetMeta = "setmeta" Command_SetView = "setview" From 6dbd19d647534eb930ba30f401ecdcf02812c903 Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 18 Dec 2025 15:56:19 -0800 Subject: [PATCH 7/9] unreg onclosefn on unreg tool approval, hook up cmd telemetry --- frontend/app/view/term/termwrap.ts | 50 ++++++++++++++---------------- pkg/aiusechat/toolapproval.go | 4 +++ 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 89229d05d..f34ba68a0 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -32,29 +32,6 @@ import { createTempFileFromBlob, extractAllClipboardData } from "./termutil"; const dlog = debug("wave:termwrap"); -function checkCommandForTelemetry(decodedCmd: string) { - if (!decodedCmd) { - return; - } - - if (decodedCmd.startsWith("ssh ")) { - recordTEvent("conn:connect", { "conn:conntype": "ssh-manual" }); - return; - } - - const editorsRegex = /^(vim|vi|nano|nvim)\b/; - if (editorsRegex.test(decodedCmd)) { - recordTEvent("action:term", { "action:type": "cli-edit" }); - return; - } - - const tailFollowRegex = /(^|\|\s*)tail\s+-[fF]\b/; - if (tailFollowRegex.test(decodedCmd)) { - recordTEvent("action:term", { "action:type": "cli-tailf" }); - return; - } -} - const TermFileName = "term"; const TermCacheFileName = "cache:term:full"; const MinDataProcessedForCache = 100 * 1024; @@ -232,6 +209,29 @@ function addTestMarkerDecoration(terminal: Terminal, marker: TermTypes.IMarker, }); } +function checkCommandForTelemetry(decodedCmd: string) { + if (!decodedCmd) { + return; + } + + if (decodedCmd.startsWith("ssh ")) { + recordTEvent("conn:connect", { "conn:conntype": "ssh-manual" }); + return; + } + + const editorsRegex = /^(vim|vi|nano|nvim)\b/; + if (editorsRegex.test(decodedCmd)) { + recordTEvent("action:term", { "action:type": "cli-edit" }); + return; + } + + const tailFollowRegex = /(^|\|\s*)tail\s+-[fF]\b/; + if (tailFollowRegex.test(decodedCmd)) { + recordTEvent("action:term", { "action:type": "cli-tailf" }); + return; + } +} + // OSC 16162 - Shell Integration Commands // See aiprompts/wave-osc-16162.md for full documentation type ShellIntegrationStatus = "ready" | "running-command"; @@ -297,9 +297,7 @@ function handleOsc16162Command(data: string, blockId: string, loaded: boolean, t const decodedCmd = base64ToString(cmd.data.cmd64); rtInfo["shell:lastcmd"] = decodedCmd; globalStore.set(termWrap.lastCommandAtom, decodedCmd); - if (decodedCmd?.startsWith("ssh ")) { - recordTEvent("conn:connect", { "conn:conntype": "ssh-manual" }); - } + checkCommandForTelemetry(decodedCmd); } catch (e) { console.error("Error decoding cmd64:", e); rtInfo["shell:lastcmd"] = null; diff --git a/pkg/aiusechat/toolapproval.go b/pkg/aiusechat/toolapproval.go index 4e11f7f6a..c2b7f277c 100644 --- a/pkg/aiusechat/toolapproval.go +++ b/pkg/aiusechat/toolapproval.go @@ -36,7 +36,11 @@ func registerToolApprovalRequest(toolCallId string, req *ApprovalRequest) { func UnregisterToolApproval(toolCallId string) { globalApprovalRegistry.mu.Lock() defer globalApprovalRegistry.mu.Unlock() + req := globalApprovalRegistry.requests[toolCallId] delete(globalApprovalRegistry.requests, toolCallId) + if req != nil && req.onCloseUnregFn != nil { + req.onCloseUnregFn() + } } func getToolApprovalRequest(toolCallId string) (*ApprovalRequest, bool) { From 1eb5d0ab48ebd4f07639ec5bf1a7e0373388ea2a Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 18 Dec 2025 16:30:05 -0800 Subject: [PATCH 8/9] better cleanup in toolapproval.go, pass context, create new approval canceled status --- pkg/aiusechat/toolapproval.go | 51 +++++++++++++++++++------------- pkg/aiusechat/uctypes/uctypes.go | 1 + pkg/aiusechat/usechat.go | 11 ++++--- 3 files changed, 38 insertions(+), 25 deletions(-) diff --git a/pkg/aiusechat/toolapproval.go b/pkg/aiusechat/toolapproval.go index c2b7f277c..49fd3960c 100644 --- a/pkg/aiusechat/toolapproval.go +++ b/pkg/aiusechat/toolapproval.go @@ -4,6 +4,7 @@ package aiusechat import ( + "context" "sync" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" @@ -18,6 +19,24 @@ type ApprovalRequest struct { onCloseUnregFn func() } +func (req *ApprovalRequest) updateApproval(approval string) { + req.mu.Lock() + defer req.mu.Unlock() + + if req.done { + return + } + + req.approval = approval + req.done = true + + if req.onCloseUnregFn != nil { + req.onCloseUnregFn() + } + + close(req.doneChan) +} + type ApprovalRegistry struct { mu sync.Mutex requests map[string]*ApprovalRequest @@ -38,8 +57,8 @@ func UnregisterToolApproval(toolCallId string) { defer globalApprovalRegistry.mu.Unlock() req := globalApprovalRegistry.requests[toolCallId] delete(globalApprovalRegistry.requests, toolCallId) - if req != nil && req.onCloseUnregFn != nil { - req.onCloseUnregFn() + if req != nil { + req.updateApproval("") } } @@ -72,31 +91,21 @@ func UpdateToolApproval(toolCallId string, approval string) error { return nil } - req.mu.Lock() - defer req.mu.Unlock() - - if req.done { - return nil - } - - req.approval = approval - req.done = true - - if req.onCloseUnregFn != nil { - req.onCloseUnregFn() - } - - close(req.doneChan) + req.updateApproval(approval) return nil } -func WaitForToolApproval(toolCallId string) string { +func WaitForToolApproval(ctx context.Context, toolCallId string) (string, error) { req, exists := getToolApprovalRequest(toolCallId) if !exists { - return "" + return "", nil } - <-req.doneChan + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-req.doneChan: + } req.mu.Lock() approval := req.approval @@ -106,5 +115,5 @@ func WaitForToolApproval(toolCallId string) string { delete(globalApprovalRegistry.requests, toolCallId) globalApprovalRegistry.mu.Unlock() - return approval + return approval, nil } diff --git a/pkg/aiusechat/uctypes/uctypes.go b/pkg/aiusechat/uctypes/uctypes.go index f698b8e74..b857f141b 100644 --- a/pkg/aiusechat/uctypes/uctypes.go +++ b/pkg/aiusechat/uctypes/uctypes.go @@ -180,6 +180,7 @@ const ( ApprovalUserDenied = "user-denied" ApprovalTimeout = "timeout" ApprovalAutoApproved = "auto-approved" + ApprovalCanceled = "canceled" ) type AIModeConfig struct { diff --git a/pkg/aiusechat/usechat.go b/pkg/aiusechat/usechat.go index 5407fd498..08e675c43 100644 --- a/pkg/aiusechat/usechat.go +++ b/pkg/aiusechat/usechat.go @@ -252,11 +252,12 @@ func processToolCallInternal(backend UseChatBackend, toolCall uctypes.WaveToolCa if toolCall.ToolUseData.Approval == uctypes.ApprovalNeedsApproval { log.Printf(" waiting for approval...\n") - approval := WaitForToolApproval(toolCall.ID) - log.Printf(" approval result: %q\n", approval) - if approval != "" { - toolCall.ToolUseData.Approval = approval + approval, err := WaitForToolApproval(context.Background(), toolCall.ID) + if err != nil || approval == "" { + approval = uctypes.ApprovalCanceled } + log.Printf(" approval result: %q\n", approval) + toolCall.ToolUseData.Approval = approval if !toolCall.ToolUseData.IsApproved() { errorMsg := "Tool use denied or timed out" @@ -264,6 +265,8 @@ func processToolCallInternal(backend UseChatBackend, toolCall uctypes.WaveToolCa errorMsg = "Tool use denied by user" } else if approval == uctypes.ApprovalTimeout { errorMsg = "Tool approval timed out" + } else if approval == uctypes.ApprovalCanceled { + errorMsg = "Tool approval canceled" } toolCall.ToolUseData.Status = uctypes.ToolUseStatusError toolCall.ToolUseData.ErrorMessage = errorMsg From b9242dbfada836b11ce335a039d73a63f47a525e Mon Sep 17 00:00:00 2001 From: sawka Date: Thu, 18 Dec 2025 16:30:59 -0800 Subject: [PATCH 9/9] use canceled approval for SSE close --- pkg/aiusechat/toolapproval.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/aiusechat/toolapproval.go b/pkg/aiusechat/toolapproval.go index 49fd3960c..4009c6dd7 100644 --- a/pkg/aiusechat/toolapproval.go +++ b/pkg/aiusechat/toolapproval.go @@ -75,7 +75,7 @@ func RegisterToolApproval(toolCallId string, sseHandler *sse.SSEHandlerCh) { } onCloseId := sseHandler.RegisterOnClose(func() { - UpdateToolApproval(toolCallId, uctypes.ApprovalTimeout) + UpdateToolApproval(toolCallId, uctypes.ApprovalCanceled) }) req.onCloseUnregFn = func() {