diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index b204643ee8..52a527bee5 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -16,6 +16,7 @@ import ( "github.com/joho/godotenv" "github.com/wavetermdev/waveterm/pkg/aiusechat" "github.com/wavetermdev/waveterm/pkg/authkey" + "github.com/wavetermdev/waveterm/pkg/agenttracker" "github.com/wavetermdev/waveterm/pkg/blockcontroller" "github.com/wavetermdev/waveterm/pkg/blocklogger" "github.com/wavetermdev/waveterm/pkg/filebackup" @@ -575,6 +576,7 @@ func main() { blocklogger.InitBlockLogger() jobcontroller.InitJobController() blockcontroller.InitBlockController() + agenttracker.InitAgentTracker() err = wcore.InitBadgeStore() if err != nil { log.Printf("error initializing badge store: %v\n", err) diff --git a/emain/emain-menu.ts b/emain/emain-menu.ts index 308db3e16b..b9a07b41a2 100644 --- a/emain/emain-menu.ts +++ b/emain/emain-menu.ts @@ -233,6 +233,21 @@ function makeViewMenu( }, }, { type: "separator" }, + { + label: "Toggle Wave AI Panel", + accelerator: "CommandOrControl+Shift+A", + click: (_, window) => { + (getWindowWebContents(window) ?? webContents)?.send("menu-item-toggle-panel", "waveai"); + }, + }, + { + label: "Toggle Agents Panel", + accelerator: "CommandOrControl+Shift+G", + click: (_, window) => { + (getWindowWebContents(window) ?? webContents)?.send("menu-item-toggle-panel", "agents"); + }, + }, + { type: "separator" }, { label: "Reset Zoom", accelerator: "CommandOrControl+0", diff --git a/emain/preload.ts b/emain/preload.ts index 8d2b18a308..83f8b0cd25 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -41,6 +41,7 @@ contextBridge.exposeInMainWorld("api", { getUpdaterChannel: () => ipcRenderer.sendSync("get-updater-channel"), installAppUpdate: () => ipcRenderer.send("install-app-update"), onMenuItemAbout: (callback) => ipcRenderer.on("menu-item-about", callback), + onMenuItemTogglePanel: (callback) => ipcRenderer.on("menu-item-toggle-panel", (_event, panel) => callback(panel)), updateWindowControlsOverlay: (rect) => ipcRenderer.send("update-window-controls-overlay", rect), onReinjectKey: (callback) => ipcRenderer.on("reinject-key", (_event, waveEvent) => callback(waveEvent)), setWebviewFocus: (focused: number) => ipcRenderer.send("webview-focus", focused), diff --git a/frontend/app/agentspanel/agentspanel-model.ts b/frontend/app/agentspanel/agentspanel-model.ts new file mode 100644 index 0000000000..09d7961232 --- /dev/null +++ b/frontend/app/agentspanel/agentspanel-model.ts @@ -0,0 +1,142 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { globalStore } from "@/app/store/jotaiStore"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { getLayoutModelForStaticTab } from "@/layout/index"; +import { atoms, getApi } from "@/store/global"; +import { waveEventSubscribeSingle } from "@/store/wps"; +import * as jotai from "jotai"; + +const MinPanelWidth = 260; +const MaxPanelWidth = 640; +const DefaultPanelWidth = 340; +const WidthStorageKey = "agentspanel:width"; +const RefreshTickMs = 30000; +// delay before focusing a block after switching tabs (tab content needs to mount) +const CrossTabFocusDelayMs = 350; + +export const AgentStatus_Attention = "attention"; +export const AgentStatus_Working = "working"; +export const AgentStatus_Idle = "idle"; +export const AgentStatus_Ended = "ended"; + +function loadStoredWidth(): number { + const raw = localStorage.getItem(WidthStorageKey); + const parsed = parseInt(raw); + if (isNaN(parsed)) { + return DefaultPanelWidth; + } + return Math.max(MinPanelWidth, Math.min(parsed, MaxPanelWidth)); +} + +export class AgentsPanelModel { + private static instance: AgentsPanelModel = null; + + visibleAtom = jotai.atom(false) as jotai.PrimitiveAtom; + sessionsAtom = jotai.atom([]) as jotai.PrimitiveAtom; + nowAtom = jotai.atom(Date.now()) as jotai.PrimitiveAtom; + // workspace oids known to THIS instance; sessions from other Wave instances + // (e.g. prod vs dev sharing the same hooks file) are shown but not navigable + knownWorkspaceIdsAtom = jotai.atom([]) as jotai.PrimitiveAtom; + widthAtom: jotai.PrimitiveAtom; + attentionCountAtom!: jotai.Atom; + + private constructor() { + this.widthAtom = jotai.atom(loadStoredWidth()); + this.attentionCountAtom = jotai.atom((get) => { + return get(this.sessionsAtom).filter((s) => s.status === AgentStatus_Attention).length; + }); + waveEventSubscribeSingle({ + eventType: "agenttracker:update", + handler: () => this.refresh(), + }); + setInterval(() => { + globalStore.set(this.nowAtom, Date.now()); + }, RefreshTickMs); + this.refresh(); + } + + static getInstance(): AgentsPanelModel { + if (!AgentsPanelModel.instance) { + AgentsPanelModel.instance = new AgentsPanelModel(); + } + return AgentsPanelModel.instance; + } + + async refresh() { + try { + const sessions = await RpcApi.AgentTrackerListCommand(TabRpcClient); + globalStore.set(this.sessionsAtom, sessions ?? []); + } catch (e) { + console.log("agentspanel: error fetching sessions", e); + } + try { + const workspaces = await RpcApi.WorkspaceListCommand(TabRpcClient); + globalStore.set( + this.knownWorkspaceIdsAtom, + (workspaces ?? []).map((w) => w.workspacedata?.oid).filter((oid) => oid != null) + ); + } catch (e) { + console.log("agentspanel: error fetching workspace list", e); + } + } + + getVisible(): boolean { + return globalStore.get(this.visibleAtom); + } + + setVisible(visible: boolean) { + globalStore.set(this.visibleAtom, visible); + if (visible) { + this.refresh(); + } + } + + toggle() { + this.setVisible(!this.getVisible()); + } + + setWidth(width: number) { + const clamped = Math.max(MinPanelWidth, Math.min(width, MaxPanelWidth)); + globalStore.set(this.widthAtom, clamped); + localStorage.setItem(WidthStorageKey, String(clamped)); + } + + canFocusSession(session: AgentSessionInfo): boolean { + if (!session.blockid || !session.tabid) { + return false; + } + return globalStore.get(this.knownWorkspaceIdsAtom).includes(session.workspaceid); + } + + focusSession(session: AgentSessionInfo) { + if (!this.canFocusSession(session)) { + return; + } + const workspace = globalStore.get(atoms.workspace); + if (session.workspaceid !== workspace?.oid) { + // workspace switch either swaps this window's workspace or focuses the + // window that already owns it; tab/block focus after that is racy, so + // the user clicks again once the workspace is up + getApi().switchWorkspace(session.workspaceid); + return; + } + const currentTabId = globalStore.get(atoms.staticTabId); + const sameTab = session.tabid === currentTabId; + if (!sameTab) { + getApi().setActiveTab(session.tabid); + } + setTimeout( + () => { + const layoutModel = getLayoutModelForStaticTab(); + const node = layoutModel?.getNodeByBlockId(session.blockid); + if (node) { + layoutModel.focusNode(node.id); + } + }, + sameTab ? 0 : CrossTabFocusDelayMs + ); + } +} diff --git a/frontend/app/agentspanel/agentspanel.tsx b/frontend/app/agentspanel/agentspanel.tsx new file mode 100644 index 0000000000..c2d002650f --- /dev/null +++ b/frontend/app/agentspanel/agentspanel.tsx @@ -0,0 +1,221 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { + AgentsPanelModel, + AgentStatus_Attention, + AgentStatus_Ended, + AgentStatus_Idle, + AgentStatus_Working, +} from "@/app/agentspanel/agentspanel-model"; +import { Tooltip } from "@/app/element/tooltip"; +import { cn } from "@/util/util"; +import { useAtomValue } from "jotai"; +import { memo, useCallback } from "react"; + +const StatusOrder = [AgentStatus_Attention, AgentStatus_Working, AgentStatus_Idle, AgentStatus_Ended]; +const StatusLabels: Record = { + [AgentStatus_Attention]: "Needs Attention", + [AgentStatus_Working]: "Working", + [AgentStatus_Idle]: "Idle", + [AgentStatus_Ended]: "Ended", +}; +const MaxEndedShown = 8; + +function statusDotClass(status: string): string { + switch (status) { + case AgentStatus_Attention: + return "bg-warning animate-pulse"; + case AgentStatus_Working: + return "bg-accent animate-pulse"; + case AgentStatus_Idle: + return "bg-secondary"; + default: + return "bg-muted opacity-50"; + } +} + +function formatRelTime(tsMs: number, nowMs: number): string { + if (!tsMs) { + return ""; + } + const deltaSec = Math.max(0, Math.floor((nowMs - tsMs) / 1000)); + if (deltaSec < 60) { + return `${deltaSec}s`; + } + if (deltaSec < 3600) { + return `${Math.floor(deltaSec / 60)}m`; + } + if (deltaSec < 86400) { + return `${Math.floor(deltaSec / 3600)}h`; + } + return `${Math.floor(deltaSec / 86400)}d`; +} + +function baseName(path: string): string { + if (!path) { + return ""; + } + const parts = path.split("/"); + return parts[parts.length - 1] || path; +} + +const AgentSessionRow = memo( + ({ + session, + nowMs, + knownWorkspaceIds, + }: { + session: AgentSessionInfo; + nowMs: number; + knownWorkspaceIds: string[]; + }) => { + const model = AgentsPanelModel.getInstance(); + const isExternal = !!session.workspaceid && !knownWorkspaceIds.includes(session.workspaceid); + const clickable = !!session.blockid && !isExternal && session.status !== AgentStatus_Ended; + const title = session.lastprompt || `session ${session.sessionid.slice(0, 8)}`; + return ( +
model.focusSession(session) : undefined} + title={isExternal ? `${session.cwd} (running in another Wave instance)` : session.cwd} + > +
+
+
{title}
+ {isExternal && ( +
external
+ )} +
+
+ {baseName(session.cwd)} ยท {formatRelTime(session.updatedts, nowMs)} +
+ {session.status === AgentStatus_Attention && session.lastnotification && ( +
{session.lastnotification}
+ )} +
+ ); + } +); +AgentSessionRow.displayName = "AgentSessionRow"; + +const AgentsPanel = memo(() => { + const model = AgentsPanelModel.getInstance(); + const sessions = useAtomValue(model.sessionsAtom); + const nowMs = useAtomValue(model.nowAtom); + const width = useAtomValue(model.widthAtom); + const attentionCount = useAtomValue(model.attentionCountAtom); + const knownWorkspaceIds = useAtomValue(model.knownWorkspaceIdsAtom); + + const handleResizeMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + const m = AgentsPanelModel.getInstance(); + const startX = e.clientX; + const startWidth = (document.getElementById("agents-panel-root")?.offsetWidth ?? 340) as number; + const onMove = (ev: MouseEvent) => { + m.setWidth(startWidth + (ev.clientX - startX)); + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, []); + + const groups = StatusOrder.map((status) => { + let items = sessions.filter((s) => s.status === status).sort((a, b) => b.updatedts - a.updatedts); + if (status === AgentStatus_Ended) { + items = items.slice(0, MaxEndedShown); + } + return { status, items }; + }).filter((g) => g.items.length > 0); + + return ( +
+
+
+ +
Agents
+ {attentionCount > 0 && ( +
+ {attentionCount} +
+ )} +
+ model.refresh()} + /> + model.setVisible(false)} + /> +
+
+ {groups.length === 0 && ( +
+
No Claude Code sessions tracked yet.
+
+ Sessions started in Wave terminals register here automatically via Claude Code hooks. +
+
+ )} + {groups.map((group) => ( +
+
+ {StatusLabels[group.status]} ({group.items.length}) +
+ {group.items.map((session) => ( + + ))} +
+ ))} +
+
+ ); +}); +AgentsPanel.displayName = "AgentsPanel"; + +const AgentsButton = memo(() => { + const model = AgentsPanelModel.getInstance(); + const panelOpen = useAtomValue(model.visibleAtom); + const attentionCount = useAtomValue(model.attentionCountAtom); + + return ( + model.toggle()} + > + + {attentionCount > 0 && ( +
+ )} + + ); +}); +AgentsButton.displayName = "AgentsButton"; + +export { AgentsButton, AgentsPanel }; diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index cca01753bb..d8e7276cf9 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -1,6 +1,7 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { AgentsPanelModel } from "@/app/agentspanel/agentspanel-model"; import { WaveAIModel } from "@/app/aipanel/waveai-model"; import { FocusManager } from "@/app/store/focusManager"; import { @@ -745,6 +746,18 @@ function registerGlobalKeys() { WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible); return true; }); + globalKeyMap.set("Cmd:Shift:g", () => { + AgentsPanelModel.getInstance().toggle(); + return true; + }); + getApi().onMenuItemTogglePanel((panel: string) => { + if (panel === "waveai") { + const currentVisible = WorkspaceLayoutModel.getInstance().getAIPanelVisible(); + WorkspaceLayoutModel.getInstance().setAIPanelVisible(!currentVisible); + } else if (panel === "agents") { + AgentsPanelModel.getInstance().toggle(); + } + }); const allKeys = Array.from(globalKeyMap.keys()); // special case keys, handled by web view allKeys.push("Cmd:l", "Cmd:r", "Cmd:ArrowRight", "Cmd:ArrowLeft", "Cmd:o"); diff --git a/frontend/app/store/wshclientapi.ts b/frontend/app/store/wshclientapi.ts index 8482be260d..a736b9c4ec 100644 --- a/frontend/app/store/wshclientapi.ts +++ b/frontend/app/store/wshclientapi.ts @@ -24,6 +24,12 @@ export class RpcApiType { return client.wshRpcCall("activity", data, opts); } + // command "agenttrackerlist" [call] + AgentTrackerListCommand(client: WshClient, opts?: RpcOpts): Promise { + if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "agenttrackerlist", null, opts); + return client.wshRpcCall("agenttrackerlist", null, opts); + } + // command "aisendmessage" [call] AiSendMessageCommand(client: WshClient, data: AiMessageData, opts?: RpcOpts): Promise { if (this.mockClient) return this.mockClient.mockWshRpcCall(client, "aisendmessage", data, opts); diff --git a/frontend/app/tab/tabbar.tsx b/frontend/app/tab/tabbar.tsx index b404afcb7e..961bcc25f4 100644 --- a/frontend/app/tab/tabbar.tsx +++ b/frontend/app/tab/tabbar.tsx @@ -1,6 +1,7 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 +import { AgentsButton } from "@/app/agentspanel/agentspanel"; import { Tooltip } from "@/app/element/tooltip"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useWaveEnv } from "@/app/waveenv/waveenv"; @@ -614,6 +615,7 @@ const TabBar = memo(({ workspace, noTabs }: TabBarProps) => {
)} + { const showLeftTabBar = tabBarPosition === "left"; const aiPanelVisible = useAtomValue(workspaceLayoutModel.panelVisibleAtom); const widgetsSidebarVisible = useAtomValue(workspaceLayoutModel.widgetsSidebarVisibleAtom); + const agentsPanelVisible = useAtomValue(AgentsPanelModel.getInstance().visibleAtom); const windowWidth = window.innerWidth; const leftGroupInitialPct = workspaceLayoutModel.getLeftGroupInitialPercentage(windowWidth, showLeftTabBar); const innerVTabInitialPct = workspaceLayoutModel.getInnerVTabInitialPercentage(windowWidth, showLeftTabBar); @@ -158,6 +161,7 @@ const WorkspaceElem = memo(() => { No Active Tab ) : (
+ {agentsPanelVisible && } {widgetsSidebarVisible && }
diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 06157e2566..ed7ba4bc8b 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -106,6 +106,7 @@ declare global { getUpdaterChannel: () => string; // get-updater-channel installAppUpdate: () => void; // install-app-update onMenuItemAbout: (callback: () => void) => void; // menu-item-about + onMenuItemTogglePanel: (callback: (panel: string) => void) => void; // menu-item-toggle-panel updateWindowControlsOverlay: (rect: Dimensions) => void; // update-window-controls-overlay onReinjectKey: (callback: (waveEvent: WaveKeyboardEvent) => void) => void; // reinject-key setWebviewFocus: (focusedId: number) => void; // webview-focus, focusedId is the getWebContentsId of the webview diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index d975d49291..6af5dc779a 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -80,6 +80,22 @@ declare global { conn?: {[key: string]: number}; }; + // wshrpc.AgentSessionInfo + type AgentSessionInfo = { + sessionid: string; + status: string; + cwd?: string; + transcriptpath?: string; + blockid?: string; + tabid?: string; + workspaceid?: string; + pid?: number; + lastprompt?: string; + lastnotification?: string; + startts?: number; + updatedts?: number; + }; + // wshrpc.AiMessageData type AiMessageData = { message?: string; diff --git a/frontend/types/waveevent.d.ts b/frontend/types/waveevent.d.ts index c3e5bd7822..af3ddd808b 100644 --- a/frontend/types/waveevent.d.ts +++ b/frontend/types/waveevent.d.ts @@ -26,6 +26,7 @@ declare global { | "waveai:modeconfig" | "block:jobstatus" | "badge" + | "agenttracker:update" ; type WaveEvent = { @@ -53,7 +54,8 @@ declare global { { event: "tsunami:updatemeta"; data?: AppMeta; } | { event: "waveai:modeconfig"; data?: AIModeConfigUpdate; } | { event: "block:jobstatus"; data?: BlockJobStatusData; } | - { event: "badge"; data?: BadgeEvent; } + { event: "badge"; data?: BadgeEvent; } | + { event: "agenttracker:update"; data?: any; } ); } diff --git a/hooks/wave-agents-hook.sh b/hooks/wave-agents-hook.sh new file mode 100755 index 0000000000..1d9cbf3593 --- /dev/null +++ b/hooks/wave-agents-hook.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Copyright 2026, Command Line Inc. +# SPDX-License-Identifier: Apache-2.0 +# +# Claude Code lifecycle hook for the Wave agents panel. +# +# Register this script in ~/.claude/settings.json for the SessionStart, +# UserPromptSubmit, Stop, Notification, and SessionEnd hook events. It appends +# one JSON line per event to ~/.claude/wave-agents/events.jsonl, which wavesrv +# (pkg/agenttracker) tails to drive the Agents panel. +# +# Only sessions running inside Wave terminals are tracked: outside Wave, +# WAVETERM_BLOCKID is unset and the hook exits immediately. + +[ -z "$WAVETERM_BLOCKID" ] && exit 0 +command -v jq >/dev/null 2>&1 || exit 0 + +dir="$HOME/.claude/wave-agents" +mkdir -p "$dir" || exit 0 + +# $PPID is the process that spawned this hook shell, i.e. the claude process +# (used by the tracker for PID-liveness reconciliation). +jq -c \ + --arg blockid "$WAVETERM_BLOCKID" \ + --arg tabid "$WAVETERM_TABID" \ + --arg workspaceid "$WAVETERM_WORKSPACEID" \ + --argjson pid "$PPID" \ + '{ + ts: (now * 1000 | floor), + event: .hook_event_name, + sessionid: .session_id, + transcriptpath: (.transcript_path // ""), + cwd: (.cwd // ""), + prompt: (.prompt // ""), + message: (.message // ""), + blockid: $blockid, + tabid: $tabid, + workspaceid: $workspaceid, + pid: $pid + }' >> "$dir/events.jsonl" 2>/dev/null + +exit 0 diff --git a/pkg/agenttracker/agenttracker.go b/pkg/agenttracker/agenttracker.go new file mode 100644 index 0000000000..aa7f3743fc --- /dev/null +++ b/pkg/agenttracker/agenttracker.go @@ -0,0 +1,332 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +// Package agenttracker maintains a registry of Claude Code sessions running inside +// Wave terminal blocks. Claude Code lifecycle hooks append JSON events to +// ~/.claude/wave-agents/events.jsonl; this package tails that file, applies a small +// status state machine, and notifies the frontend via a WPS event. +package agenttracker + +import ( + "bytes" + "encoding/json" + "io" + "log" + "os" + "path/filepath" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/shirou/gopsutil/v4/process" + "github.com/wavetermdev/waveterm/pkg/panichandler" + "github.com/wavetermdev/waveterm/pkg/wps" + "github.com/wavetermdev/waveterm/pkg/wshrpc" +) + +const ( + Status_Working = "working" + Status_Idle = "idle" + Status_Attention = "attention" + Status_Ended = "ended" +) + +const ( + HookEvent_SessionStart = "SessionStart" + HookEvent_UserPromptSubmit = "UserPromptSubmit" + HookEvent_Stop = "Stop" + HookEvent_Notification = "Notification" + HookEvent_PermissionRequest = "PermissionRequest" + HookEvent_PostToolUse = "PostToolUse" + HookEvent_SessionEnd = "SessionEnd" +) + +const ( + EventsDirName = ".claude/wave-agents" + EventsFileName = "events.jsonl" + LivenessInterval = 30 * time.Second + // grace period before a dead PID marks the session ended (hook PPID can be an + // intermediate shell that exits immediately, so a fresh event resets the clock) + LivenessGraceMs = 60 * 1000 + EndedRetentionMs = 24 * 60 * 60 * 1000 + MaxTextSnippetLen = 300 +) + +type hookEvent struct { + Ts int64 `json:"ts"` + Event string `json:"event"` + SessionId string `json:"sessionid"` + TranscriptPath string `json:"transcriptpath,omitempty"` + Cwd string `json:"cwd,omitempty"` + BlockId string `json:"blockid,omitempty"` + TabId string `json:"tabid,omitempty"` + WorkspaceId string `json:"workspaceid,omitempty"` + Pid int `json:"pid,omitempty"` + Prompt string `json:"prompt,omitempty"` + Message string `json:"message,omitempty"` +} + +type AgentTracker struct { + lock sync.Mutex + sessions map[string]*wshrpc.AgentSessionInfo + readOffset int64 + eventsPath string +} + +var globalTracker = &AgentTracker{sessions: make(map[string]*wshrpc.AgentSessionInfo)} + +func InitAgentTracker() { + homeDir, err := os.UserHomeDir() + if err != nil { + log.Printf("agenttracker: cannot resolve home dir: %v\n", err) + return + } + eventsDir := filepath.Join(homeDir, filepath.FromSlash(EventsDirName)) + if err := os.MkdirAll(eventsDir, 0o755); err != nil { + log.Printf("agenttracker: cannot create events dir: %v\n", err) + return + } + globalTracker.eventsPath = filepath.Join(eventsDir, EventsFileName) + numEvents := globalTracker.readNewEvents() + log.Printf("agenttracker: initialized, replayed %d events, %d sessions\n", numEvents, len(ListSessions())) + go watchLoop(eventsDir) + go livenessLoop() +} + +func ListSessions() []wshrpc.AgentSessionInfo { + globalTracker.lock.Lock() + defer globalTracker.lock.Unlock() + rtn := make([]wshrpc.AgentSessionInfo, 0, len(globalTracker.sessions)) + for _, session := range globalTracker.sessions { + rtn = append(rtn, *session) + } + return rtn +} + +func publishUpdate() { + wps.Broker.Publish(wps.WaveEvent{Event: wps.Event_AgentTrackerUpdate}) +} + +func watchLoop(eventsDir string) { + defer func() { + panichandler.PanicHandler("agenttracker:watchLoop", recover()) + }() + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Printf("agenttracker: fsnotify unavailable (%v), falling back to polling\n", err) + pollLoop() + return + } + defer watcher.Close() + if err := watcher.Add(eventsDir); err != nil { + log.Printf("agenttracker: cannot watch events dir (%v), falling back to polling\n", err) + pollLoop() + return + } + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if filepath.Base(event.Name) != EventsFileName { + continue + } + if event.Op&(fsnotify.Write|fsnotify.Create) == 0 { + continue + } + if globalTracker.readNewEvents() > 0 { + publishUpdate() + } + case _, ok := <-watcher.Errors: + if !ok { + return + } + } + } +} + +func pollLoop() { + defer func() { + panichandler.PanicHandler("agenttracker:pollLoop", recover()) + }() + for { + time.Sleep(2 * time.Second) + if globalTracker.readNewEvents() > 0 { + publishUpdate() + } + } +} + +// readNewEvents reads complete lines appended since the last read offset and +// applies them to the registry. Returns the number of events applied. +func (t *AgentTracker) readNewEvents() int { + t.lock.Lock() + defer t.lock.Unlock() + file, err := os.Open(t.eventsPath) + if err != nil { + return 0 + } + defer file.Close() + finfo, err := file.Stat() + if err != nil { + return 0 + } + if finfo.Size() < t.readOffset { + // file was truncated or replaced; start over + t.readOffset = 0 + } + if finfo.Size() == t.readOffset { + return 0 + } + if _, err := file.Seek(t.readOffset, io.SeekStart); err != nil { + return 0 + } + data, err := io.ReadAll(file) + if err != nil { + return 0 + } + numApplied := 0 + consumed := 0 + for { + idx := bytes.IndexByte(data[consumed:], '\n') + if idx < 0 { + // trailing partial line (writer mid-append); leave it for the next read + break + } + line := data[consumed : consumed+idx] + consumed += idx + 1 + line = bytes.TrimSpace(line) + if len(line) == 0 { + continue + } + var event hookEvent + if err := json.Unmarshal(line, &event); err != nil { + continue + } + if t.applyEvent_withlock(&event) { + numApplied++ + } + } + t.readOffset += int64(consumed) + return numApplied +} + +// applyEvent_withlock upserts the session so that hooks installed mid-session +// still register on the next prompt/stop, not just on SessionStart. Returns +// whether anything user-visible changed (used to skip pushing frontend updates +// for high-frequency events like PostToolUse that only refresh liveness). +func (t *AgentTracker) applyEvent_withlock(event *hookEvent) bool { + if event.SessionId == "" { + return false + } + visibleChange := false + session := t.sessions[event.SessionId] + if session == nil { + session = &wshrpc.AgentSessionInfo{ + SessionId: event.SessionId, + Status: Status_Idle, + StartTs: event.Ts, + } + t.sessions[event.SessionId] = session + visibleChange = true + } + if event.Cwd != "" && event.Cwd != session.Cwd { + session.Cwd = event.Cwd + visibleChange = true + } + if event.TranscriptPath != "" { + session.TranscriptPath = event.TranscriptPath + } + if event.BlockId != "" && event.BlockId != session.BlockId { + session.BlockId = event.BlockId + session.TabId = event.TabId + session.WorkspaceId = event.WorkspaceId + visibleChange = true + } + if event.Pid > 0 { + session.Pid = event.Pid + } + session.UpdatedTs = event.Ts + oldStatus := session.Status + switch event.Event { + case HookEvent_SessionStart: + // resume of an ended session revives it in place (StartTs preserved) + session.Status = Status_Idle + case HookEvent_UserPromptSubmit: + session.Status = Status_Working + if event.Prompt != "" && event.Prompt != session.LastPrompt { + session.LastPrompt = truncateText(event.Prompt) + visibleChange = true + } + case HookEvent_Stop: + if session.Status != Status_Ended { + session.Status = Status_Idle + } + case HookEvent_Notification, HookEvent_PermissionRequest: + session.Status = Status_Attention + if event.Message != "" && event.Message != session.LastNotification { + session.LastNotification = truncateText(event.Message) + visibleChange = true + } + case HookEvent_PostToolUse: + // a tool completing means a pending permission request was approved + if session.Status == Status_Attention { + session.Status = Status_Working + } + case HookEvent_SessionEnd: + session.Status = Status_Ended + } + if session.Status != oldStatus { + visibleChange = true + } + return visibleChange +} + +func livenessLoop() { + defer func() { + panichandler.PanicHandler("agenttracker:livenessLoop", recover()) + }() + for { + time.Sleep(LivenessInterval) + if reconcileLiveness() { + publishUpdate() + } + } +} + +// reconcileLiveness ends sessions whose claude process is gone (covers kill -9 / +// crashes where the SessionEnd hook never fires) and prunes old ended sessions. +func reconcileLiveness() bool { + globalTracker.lock.Lock() + defer globalTracker.lock.Unlock() + nowMs := time.Now().UnixMilli() + changed := false + for sessionId, session := range globalTracker.sessions { + if session.Status == Status_Ended { + if nowMs-session.UpdatedTs > EndedRetentionMs { + delete(globalTracker.sessions, sessionId) + changed = true + } + continue + } + if session.Pid <= 0 || nowMs-session.UpdatedTs < LivenessGraceMs { + continue + } + exists, err := process.PidExists(int32(session.Pid)) + if err == nil && !exists { + session.Status = Status_Ended + session.UpdatedTs = nowMs + changed = true + } + } + return changed +} + +func truncateText(text string) string { + runes := []rune(text) + if len(runes) <= MaxTextSnippetLen { + return text + } + return string(runes[:MaxTextSnippetLen]) + "..." +} diff --git a/pkg/wps/wpstypes.go b/pkg/wps/wpstypes.go index 0077ec9d5e..d8fc00cd73 100644 --- a/pkg/wps/wpstypes.go +++ b/pkg/wps/wpstypes.go @@ -34,6 +34,7 @@ const ( Event_AIModeConfig = "waveai:modeconfig" // type: wconfig.AIModeConfigUpdate Event_BlockJobStatus = "block:jobstatus" // type: wshrpc.BlockJobStatusData Event_Badge = "badge" // type: baseds.BadgeEvent + Event_AgentTrackerUpdate = "agenttracker:update" // type: none ) var AllEvents []string = []string{ @@ -56,6 +57,7 @@ var AllEvents []string = []string{ Event_AIModeConfig, Event_BlockJobStatus, Event_Badge, + Event_AgentTrackerUpdate, } type WaveEvent struct { diff --git a/pkg/wshrpc/wshclient/wshclient.go b/pkg/wshrpc/wshclient/wshclient.go index d5333aec2b..da61d941c1 100644 --- a/pkg/wshrpc/wshclient/wshclient.go +++ b/pkg/wshrpc/wshclient/wshclient.go @@ -23,6 +23,12 @@ func ActivityCommand(w *wshutil.WshRpc, data wshrpc.ActivityUpdate, opts *wshrpc return err } +// command "agenttrackerlist", wshserver.AgentTrackerListCommand +func AgentTrackerListCommand(w *wshutil.WshRpc, opts *wshrpc.RpcOpts) ([]wshrpc.AgentSessionInfo, error) { + resp, err := sendRpcRequestCallHelper[[]wshrpc.AgentSessionInfo](w, "agenttrackerlist", nil, opts) + return resp, err +} + // command "aisendmessage", wshserver.AiSendMessageCommand func AiSendMessageCommand(w *wshutil.WshRpc, data wshrpc.AiMessageData, opts *wshrpc.RpcOpts) error { _, err := sendRpcRequestCallHelper[any](w, "aisendmessage", data, opts) diff --git a/pkg/wshrpc/wshrpctypes.go b/pkg/wshrpc/wshrpctypes.go index 51e2338ba8..4dee402571 100644 --- a/pkg/wshrpc/wshrpctypes.go +++ b/pkg/wshrpc/wshrpctypes.go @@ -147,6 +147,7 @@ type WshRpcInterface interface { WorkspaceListCommand(ctx context.Context) ([]WorkspaceInfoData, error) GetUpdateChannelCommand(ctx context.Context) (string, error) + AgentTrackerListCommand(ctx context.Context) ([]AgentSessionInfo, error) // terminal VDomCreateContextCommand(ctx context.Context, data vdom.VDomCreateContext) (*waveobj.ORef, error) @@ -482,6 +483,21 @@ type WorkspaceInfoData struct { WorkspaceData *waveobj.Workspace `json:"workspacedata"` } +type AgentSessionInfo struct { + SessionId string `json:"sessionid"` + Status string `json:"status"` + Cwd string `json:"cwd,omitempty"` + TranscriptPath string `json:"transcriptpath,omitempty"` + BlockId string `json:"blockid,omitempty"` + TabId string `json:"tabid,omitempty"` + WorkspaceId string `json:"workspaceid,omitempty"` + Pid int `json:"pid,omitempty"` + LastPrompt string `json:"lastprompt,omitempty"` + LastNotification string `json:"lastnotification,omitempty"` + StartTs int64 `json:"startts,omitempty"` + UpdatedTs int64 `json:"updatedts,omitempty"` +} + type BlocksListRequest struct { WindowId string `json:"windowid,omitempty"` WorkspaceId string `json:"workspaceid,omitempty"` diff --git a/pkg/wshrpc/wshserver/wshserver.go b/pkg/wshrpc/wshserver/wshserver.go index 38006fd9a8..f110c5af3e 100644 --- a/pkg/wshrpc/wshserver/wshserver.go +++ b/pkg/wshrpc/wshserver/wshserver.go @@ -21,6 +21,7 @@ import ( "time" "github.com/skratchdot/open-golang/open" + "github.com/wavetermdev/waveterm/pkg/agenttracker" "github.com/wavetermdev/waveterm/pkg/aiusechat" "github.com/wavetermdev/waveterm/pkg/aiusechat/chatstore" "github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes" @@ -944,6 +945,10 @@ func (ws *WshServer) BlocksListCommand( return results, nil } +func (ws *WshServer) AgentTrackerListCommand(ctx context.Context) ([]wshrpc.AgentSessionInfo, error) { + return agenttracker.ListSessions(), nil +} + func (ws *WshServer) WorkspaceListCommand(ctx context.Context) ([]wshrpc.WorkspaceInfoData, error) { workspaceList, err := wcore.ListWorkspaces(ctx) if err != nil {