diff --git a/docs/terminal-activity.md b/docs/terminal-activity.md index ab1fd7a42..24bf394a1 100644 --- a/docs/terminal-activity.md +++ b/docs/terminal-activity.md @@ -27,7 +27,7 @@ TerminalSession Each `onChange` delivers both the new snapshot and the `previous` one (`{ state, changedAt }`). The transition flows unchanged up through `TerminalSession.onActivityChange` (as `{ activity, previous }`), the worker protocol's `terminalActivityChange` event, and the manager-level `subscribeTerminalActivity(listener)` stream (`{ terminalId, name, cwd, activity, previous }`). -The daemon's notification policy consumes these transitions, not snapshots. When a transition moves from `working` to `idle`, the websocket layer fires a "Terminal finished" attention notification. It keeps no per-terminal timing state of its own — the previous snapshot carried in the transition is the entire history it needs. A terminal that exits while still working emits no turn-end notification. +The daemon consumes these transitions, not snapshots. When a transition moves from `working` to `idle`, the tracker records finished attention, so the terminal shows the same green finished dot as an idle agent that needs review. The websocket layer also fires a "Terminal finished" attention notification. A terminal that exits while still working emits no turn-end notification. Terminal workspace membership is path-prefix based: a terminal opened in a workspace subdirectory rolls up to the deepest active parent workspace for status buckets and notification routing. @@ -62,11 +62,11 @@ OpenCode uses a server plugin instead of command hooks. The plugin listens to Op - `permission.asked` → `needs-input` - `permission.replied` → `running` -The daemon maps hook states onto internal terminal activity states: `running` → `working`, `idle` → `idle`, and `needs-input` → `attention`. +The daemon maps hook states onto terminal activity like an agent lifecycle plus unread attention: `running` → `state: working`, `idle` → `state: idle`, and `needs-input` → `state: idle` with `attentionReason: needs_input`. A `working` → `idle` transition records `state: idle` with `attentionReason: finished` until the user focuses that terminal; plain idle terminals still contribute no workspace status. ## Focus clearing -Client heartbeats include the focused terminal id. When a visible client focuses a terminal that is in `attention`, the daemon clears that terminal back to `idle`. `idle` terminal activity does not contribute to workspace status, so a workspace whose only attention source was that terminal rolls up from `needs_input` back to `done`. +Client heartbeats include the focused terminal id. When a visible client focuses a terminal with an `attentionReason`, the daemon clears the attention and leaves the terminal idle. Plain idle terminal activity does not contribute to workspace status, so a workspace whose only attention source was that terminal rolls up from `needs_input` or `attention` back to `done`. ### Agent hook installation diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 1fc430fd5..88b868398 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -5,6 +5,7 @@ import { Terminal } from "lucide-react-native"; import { Text, View } from "react-native"; import invariant from "tiny-invariant"; import type { ListTerminalsResponse } from "@getpaseo/protocol/messages"; +import { deriveTerminalActivityStatusBucket } from "@getpaseo/protocol/terminal-activity"; import { TerminalPane } from "@/components/terminal-pane"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; @@ -13,7 +14,6 @@ import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state"; import { usePanelStore } from "@/stores/panel-store"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks"; -import { terminalActivityToStatusBucket } from "@/utils/terminal-activity-bucket"; type ListTerminalsPayload = ListTerminalsResponse["payload"]; @@ -70,7 +70,7 @@ function useTerminalPanelDescriptor( subtitle: t("workspace.tabs.fallback.terminal"), titleState: "ready", icon: Terminal, - statusBucket: terminalActivityToStatusBucket(terminal?.activity?.state), + statusBucket: deriveTerminalActivityStatusBucket(terminal?.activity), }; } diff --git a/packages/app/src/utils/terminal-activity-bucket.test.ts b/packages/app/src/utils/terminal-activity-bucket.test.ts deleted file mode 100644 index 89d111ab1..000000000 --- a/packages/app/src/utils/terminal-activity-bucket.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { terminalActivityToStatusBucket } from "./terminal-activity-bucket"; - -describe("terminalActivityToStatusBucket", () => { - it("maps working to running", () => { - expect(terminalActivityToStatusBucket("working")).toBe("running"); - }); - - it("maps idle to null", () => { - expect(terminalActivityToStatusBucket("idle")).toBeNull(); - }); - - it("maps null to null", () => { - expect(terminalActivityToStatusBucket(null)).toBeNull(); - }); - - it("maps undefined to null", () => { - expect(terminalActivityToStatusBucket(undefined)).toBeNull(); - }); -}); diff --git a/packages/app/src/utils/terminal-activity-bucket.ts b/packages/app/src/utils/terminal-activity-bucket.ts deleted file mode 100644 index 15d02528c..000000000 --- a/packages/app/src/utils/terminal-activity-bucket.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; -import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; - -export function terminalActivityToStatusBucket( - state: TerminalActivityState | null | undefined, -): SidebarStateBucket | null { - if (state === "working") return "running"; - if (state === "attention") return "needs_input"; - return null; -} diff --git a/packages/protocol/src/terminal-activity.test.ts b/packages/protocol/src/terminal-activity.test.ts index b96d8514b..3fd52552f 100644 --- a/packages/protocol/src/terminal-activity.test.ts +++ b/packages/protocol/src/terminal-activity.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { TERMINAL_ACTIVITY_STATES, TerminalActivitySchema } from "./terminal-activity.js"; +import { + TERMINAL_ACTIVITY_ATTENTION_REASONS, + TERMINAL_ACTIVITY_STATES, + TerminalActivitySchema, + deriveTerminalActivityStatusBucket, +} from "./terminal-activity.js"; describe("TerminalActivitySchema", () => { it("parses the known activity states", () => { @@ -16,4 +21,32 @@ describe("TerminalActivitySchema", () => { expect(parsed.state).toBe("idle"); expect(parsed.changedAt).toBe(1718000000000); }); + + it("parses known attention reasons", () => { + for (const attentionReason of TERMINAL_ACTIVITY_ATTENTION_REASONS) { + expect( + TerminalActivitySchema.parse({ state: "attention", attentionReason, changedAt: 1 }) + .attentionReason, + ).toBe(attentionReason); + } + }); + + it("maps terminal activity to workspace status buckets", () => { + expect(deriveTerminalActivityStatusBucket({ state: "working", changedAt: 1 })).toBe("running"); + expect( + deriveTerminalActivityStatusBucket({ + state: "idle", + attentionReason: "finished", + changedAt: 1, + }), + ).toBe("attention"); + expect( + deriveTerminalActivityStatusBucket({ + state: "idle", + attentionReason: "needs_input", + changedAt: 1, + }), + ).toBe("needs_input"); + expect(deriveTerminalActivityStatusBucket({ state: "idle", changedAt: 1 })).toBeNull(); + }); }); diff --git a/packages/protocol/src/terminal-activity.ts b/packages/protocol/src/terminal-activity.ts index 0da57505e..d90688d34 100644 --- a/packages/protocol/src/terminal-activity.ts +++ b/packages/protocol/src/terminal-activity.ts @@ -1,15 +1,31 @@ import { z } from "zod"; export const TERMINAL_ACTIVITY_STATES = ["idle", "working", "attention"] as const; +export const TERMINAL_ACTIVITY_ATTENTION_REASONS = ["finished", "needs_input"] as const; export type TerminalActivityState = (typeof TERMINAL_ACTIVITY_STATES)[number]; +export type TerminalActivityAttentionReason = (typeof TERMINAL_ACTIVITY_ATTENTION_REASONS)[number]; export const TerminalActivitySchema = z.object({ // Forward-compat: a newer daemon may send a state this client doesn't know. // Degrade unknown states to "idle" (no indicator, no notification) so the // message still parses, instead of a strict enum rejecting the whole payload. state: z.enum(TERMINAL_ACTIVITY_STATES).catch("idle"), + attentionReason: z.enum(TERMINAL_ACTIVITY_ATTENTION_REASONS).nullable().optional().catch(null), changedAt: z.number(), }); export type TerminalActivity = z.infer; + +export type TerminalActivityStatusBucket = "running" | "needs_input" | "attention"; + +export function deriveTerminalActivityStatusBucket( + activity: TerminalActivity | null | undefined, +): TerminalActivityStatusBucket | null { + if (!activity) return null; + if (activity.attentionReason === "needs_input") return "needs_input"; + if (activity.attentionReason === "finished") return "attention"; + if (activity.state === "working") return "running"; + if (activity.state === "attention") return "needs_input"; + return null; +} diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index f02aabd71..00a3aa4fb 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -27,6 +27,7 @@ import { wrapSessionMessage, } from "./messages.js"; import { asUint8Array, decodeBinaryFrame } from "@getpaseo/protocol/binary-frames/index"; +import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import type { HostnamesConfig } from "./hostnames.js"; import { isHostnameAllowed } from "./hostnames.js"; import { Session, type SessionLifecycleIntent, type SessionRuntimeMetrics } from "./session.js"; @@ -84,9 +85,12 @@ type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics; type TerminalAttentionReason = "finished" | "needs_input"; function resolveTerminalAttentionReason(input: { + attentionReason?: TerminalActivity["attentionReason"]; previousState: "working" | "idle" | "attention" | null; state: "working" | "idle" | "attention" | null; }): TerminalAttentionReason | null { + if (input.attentionReason === "finished") return "finished"; + if (input.attentionReason === "needs_input") return "needs_input"; if (input.state === "attention") return "needs_input"; if (input.previousState === "working" && input.state === "idle") return "finished"; return null; @@ -556,6 +560,7 @@ export class VoiceAssistantWebSocketServer { if (this.terminalManager) { this.unsubscribeTerminalActivity = this.terminalManager.subscribeTerminalActivity((event) => { const reason = resolveTerminalAttentionReason({ + attentionReason: event.activity?.attentionReason, previousState: event.previous?.state ?? null, state: event.activity?.state ?? null, }); diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 4e881f356..9e51023fe 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -177,6 +177,13 @@ class WorkspaceStatus { }); } + hasFinishedTerminal(changedAt: number): void { + this.terminals.push({ + cwd: this.workspace.cwd, + activity: { state: "idle", attentionReason: "finished", changedAt }, + }); + } + hasUnknownTerminal(): void { this.terminals.push({ cwd: this.workspace.cwd, @@ -342,6 +349,15 @@ describe("WorkspaceDirectory", () => { await expect(workspace.workspaceStatus()).resolves.toBe("running"); }); + test("finished terminal contributes attention to workspace status", async () => { + const workspace = new WorkspaceStatus(); + const changedAt = new Date(NOW).getTime(); + + workspace.hasFinishedTerminal(changedAt); + + await expect(workspace.workspaceStatus()).resolves.toBe("attention"); + }); + test("idle terminal contributes nothing to workspace status", async () => { const workspace = new WorkspaceStatus(); const changedAt = new Date(NOW).getTime(); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 85fb837ea..9fa846cf1 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -20,7 +20,10 @@ import { resolveActiveWorkspaceRecordForCwd, resolveWorkspaceIdForRecord, } from "./workspace-registry-model.js"; -import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; +import { + deriveTerminalActivityStatusBucket, + type TerminalActivity, +} from "@getpaseo/protocol/terminal-activity"; type WorkspaceIdResolver = (cwd: string) => string | undefined; @@ -359,14 +362,8 @@ export class WorkspaceDirectory { if (!activity) { continue; } - let bucket: WorkspaceStateBucket; - if (activity.state === "working") { - bucket = "running"; - } else if (activity.state === "attention") { - bucket = "needs_input"; - } else { - continue; - } + const bucket = deriveTerminalActivityStatusBucket(activity); + if (!bucket) continue; const workspaceId = resolveWorkspaceIdForRecord({ workspaceId: contributedWorkspaceId, cwd }, activeRecords) ?? resolveWorkspaceIdForCwd(cwd); diff --git a/packages/server/src/terminal/activity/terminal-activity-tracker.test.ts b/packages/server/src/terminal/activity/terminal-activity-tracker.test.ts index de1c6dc73..62afa6658 100644 --- a/packages/server/src/terminal/activity/terminal-activity-tracker.test.ts +++ b/packages/server/src/terminal/activity/terminal-activity-tracker.test.ts @@ -18,6 +18,31 @@ describe("TerminalActivityTracker — set", () => { expect(tracker.getSnapshot().state).toBe("working"); }); + + it("marks a working to idle transition as finished attention", () => { + const tracker = new TerminalActivityTracker(); + + tracker.set("working"); + tracker.set("idle"); + + expect(tracker.getSnapshot()).toMatchObject({ + state: "idle", + attentionReason: "finished", + }); + }); + + it("keeps finished attention across repeated idle reports", () => { + const tracker = new TerminalActivityTracker(); + + tracker.set("working"); + tracker.set("idle"); + tracker.set("idle"); + + expect(tracker.getSnapshot()).toMatchObject({ + state: "idle", + attentionReason: "finished", + }); + }); }); describe("TerminalActivityTracker — clearAttention", () => { @@ -27,7 +52,7 @@ describe("TerminalActivityTracker — clearAttention", () => { tracker.set("attention"); expect(tracker.clearAttention()).toBe(true); - expect(tracker.getSnapshot().state).toBe("idle"); + expect(tracker.getSnapshot()).toMatchObject({ state: "idle", attentionReason: null }); }); it("leaves non-attention states unchanged", () => { @@ -102,7 +127,10 @@ describe("TerminalActivityTracker — onChange listener", () => { expect(transitions[0].previous.state).toBeNull(); expect(transitions[0].snapshot.state).toBe("working"); expect(transitions[1].previous).toEqual(transitions[0].snapshot); - expect(transitions[1].snapshot.state).toBe("idle"); + expect(transitions[1].snapshot).toMatchObject({ + state: "idle", + attentionReason: "finished", + }); }); }); diff --git a/packages/server/src/terminal/activity/terminal-activity-tracker.ts b/packages/server/src/terminal/activity/terminal-activity-tracker.ts index 82d3a410b..168e12a25 100644 --- a/packages/server/src/terminal/activity/terminal-activity-tracker.ts +++ b/packages/server/src/terminal/activity/terminal-activity-tracker.ts @@ -1,13 +1,18 @@ -import type { TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; +import type { + TerminalActivityAttentionReason, + TerminalActivityState, +} from "@getpaseo/protocol/terminal-activity"; export interface TerminalActivitySnapshot { state: TerminalActivityState | null; + attentionReason: TerminalActivityAttentionReason | null; changedAt: number; } export class TerminalActivityTracker { // unknown != idle: a plain shell, or a terminal whose agent was killed, has no dot or rollup. private resolvedState: TerminalActivityState | null = null; + private attentionReason: TerminalActivityAttentionReason | null = null; private changedAt = Date.now(); private readonly changeListeners = new Set< @@ -15,28 +20,44 @@ export class TerminalActivityTracker { >(); set(state: TerminalActivityState): void { - this.setState(state); + if (state === "idle" && this.resolvedState === "working") { + this.setState("idle", "finished"); + return; + } + + if (state === "idle" && this.attentionReason === "finished") { + return; + } + + this.setState( + state === "attention" ? "idle" : state, + state === "attention" ? "needs_input" : null, + ); } clear(): void { - this.setState(null); + this.setState(null, null); } clearAttention(): boolean { - if (this.resolvedState !== "attention") { + if (!this.attentionReason) { return false; } - this.setState("idle"); + this.setState("idle", null); return true; } - private setState(state: TerminalActivityState | null): void { - if (state === this.resolvedState) { + private setState( + state: TerminalActivityState | null, + attentionReason: TerminalActivityAttentionReason | null, + ): void { + if (state === this.resolvedState && attentionReason === this.attentionReason) { return; } const previous = this.getSnapshot(); this.resolvedState = state; + this.attentionReason = attentionReason; this.changedAt = Date.now(); const snapshot = this.getSnapshot(); @@ -57,6 +78,7 @@ export class TerminalActivityTracker { getSnapshot(): TerminalActivitySnapshot { return { state: this.resolvedState, + attentionReason: this.attentionReason, changedAt: this.changedAt, }; } diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 032849ff8..526687ce8 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -132,12 +132,17 @@ export interface CreateTerminalOptions { function toTerminalActivity(snapshot: { state: TerminalActivityState | null; + attentionReason?: TerminalActivity["attentionReason"]; changedAt: number; }): TerminalActivity | null { if (!snapshot.state) { return null; } - return { state: snapshot.state, changedAt: snapshot.changedAt }; + return { + state: snapshot.state, + ...(snapshot.attentionReason ? { attentionReason: snapshot.attentionReason } : {}), + changedAt: snapshot.changedAt, + }; } function resolveInitialTitleMode(presetTitle: string | undefined): "auto" | "manual" {