diff --git a/CLAUDE.md b/CLAUDE.md index 26c82593f..8b3de2fff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the | [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness | | [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows | | [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist | +| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider | | [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth | ## Quick start diff --git a/docs/terminal-activity.md b/docs/terminal-activity.md new file mode 100644 index 000000000..ab1fd7a42 --- /dev/null +++ b/docs/terminal-activity.md @@ -0,0 +1,107 @@ +# Terminal Activity Indicators + +Paseo surfaces terminal activity as a tab indicator (the same "running" dot used by agents). + +## Current state + +Terminal activity is source-agnostic plumbing. `TerminalActivityTracker` holds the current per-terminal state and emits transitions to the manager, worker protocol, websocket subscription, app buckets, dots, and notifications. + +The tracker defaults to unknown (`null`). Activity production lives outside terminal stream parsing: agent hook commands report coarse activity to the daemon's local `/api/terminal-activity` endpoint. + +## Architecture + +``` +TerminalSession + ├── TerminalActivityTracker one per session + │ ├── set(state) records the latest state + │ └── onChange(snapshot, previous) fires only on resolved-state transitions + │ + └── onActivityChange({ activity, previous }) subscribed in TerminalManager + ├── emits terminalsChanged workspace bucket + tab indicators + └── subscribeTerminalActivity per-transition stream for notification policy +``` + +`TerminalActivityTracker` is the single stateful object per session. It holds `{ state, changedAt }`, starts at unknown (`null`), and fires `onChange` only when the state actually changes. + +### Transitions carry their own history + +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. + +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. + +## Hook reporting + +Terminals receive four environment variables when the daemon creates the shell: + +- `PASEO_TERMINAL_ID` +- `PASEO_ACTIVITY_TOKEN` +- `PASEO_TERMINAL_ACTIVITY_URL` +- `PASEO_HOOK_CLI` — absolute path to the current `paseo` CLI executable. + +The generated shell command uses `PASEO_HOOK_CLI` to run the current CLI. `paseo hooks ` then reads the terminal id, token, and activity URL, asks the agent hook provider registry to resolve the event to a coarse activity state, and silently posts `{ terminalId, token, state }` to the activity URL. Missing env, unsupported agents/events, malformed hook input, and daemon/network failures are no-ops so agent hooks never break the user's terminal session. + +Claude hook mapping: + +- `UserPromptSubmit` → `running` +- `Stop`, `StopFailure`, `SessionEnd` → `idle` +- `Notification` with `reason` or `matcher` equal to `idle_prompt` → `needs-input` + +Codex hook mapping: + +- `UserPromptSubmit` → `running` +- `PreToolUse`, `PostToolUse` → `running` +- `PermissionRequest` → `needs-input` +- `Stop` → `idle` + +OpenCode uses a server plugin instead of command hooks. The plugin listens to OpenCode bus events and emits these Paseo hook events: + +- `session.status` with `busy` or `retry` → `running` +- `session.status` with `idle` → `idle` +- `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`. + +## 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`. + +### Agent hook installation + +Installing hooks edits the user's real agent config files, so it is opt-in. The daemon setting +`enableTerminalAgentHooks` (persisted under `daemon.enableTerminalAgentHooks`, default `false`) +gates installation. It is surfaced in the app under a host's **Terminals** settings as "Enable +terminal agent hooks" — "Get notifications and status from terminal agents. This installs hooks in +your agent config files." `applyTerminalAgentHookSetting` reconciles the installed hooks with the +setting: at startup it installs only when enabled; toggling the setting live installs on enable and +removes Paseo's marker-matched hooks on disable. `paseo hooks` keeps working regardless — the gate +only controls whether the daemon writes hooks into agent configs, not whether the CLI can post +activity when the env is present. + +When enabled, Paseo installs provider hooks globally: + +- Claude hooks are written to `~/.claude/settings.json` (or `CLAUDE_CONFIG_DIR/settings.json` when that override is set). +- Codex hooks are written to `~/.codex/hooks.json` (or `CODEX_HOME/hooks.json` when that override is set). Codex supports a native `commandWindows`, so each Paseo hook includes both POSIX and Windows commands. Non-managed Codex hooks are trust-gated by Codex; users may see Codex's hook review prompt before the hook runs. +- OpenCode gets a self-contained plugin at `$XDG_CONFIG_HOME/opencode/plugins/paseo-terminal-activity.js` (or `~/.config/opencode/plugins/paseo-terminal-activity.js` when XDG is unset; `OPENCODE_CONFIG_DIR` still wins when set). + +Installation is marker-based/idempotent for config hooks and exact-file/idempotent for the OpenCode plugin. Paseo preserves user hooks, removes only its own marker-matched command hooks, and leaves hooks installed across daemon shutdown. Outside a Paseo terminal they are inert because the command or plugin is gated on `PASEO_TERMINAL_ID`. + +Provider variation lives in `AGENT_HOOK_PROVIDERS`: provider id, installed events, config install metadata, and runtime event-to-activity resolution. The daemon calls `installRegisteredAgentHooks()` once; the CLI calls `resolveHookActivity(provider, event, input)`. Adding a provider should add one provider entry and register it in `AGENT_HOOK_PROVIDERS`, without editing the generic CLI command or daemon bootstrap. + +The installed hook command keeps the config portable and resolves the CLI at runtime: + +```sh +[ -n "$PASEO_TERMINAL_ID" ] && "${PASEO_HOOK_CLI:-paseo}" hooks claude +``` + +Codex also receives the Windows equivalent: + +```bat +if defined PASEO_TERMINAL_ID (if defined PASEO_HOOK_CLI ("%PASEO_HOOK_CLI%" hooks codex ) else (paseo hooks codex )) +``` + +Paseo injects `PASEO_HOOK_CLI` so Codex's hook shell cannot pick up a stale global `paseo` before the current one. The command still falls back to bare `paseo` if the env is missing, and it still no-ops outside Paseo terminals because the `PASEO_TERMINAL_ID` gate remains first. Paseo also prepends the CLI binary directory to each terminal `PATH` as a secondary fallback. All other behavior lives in `paseo hooks`: read the env, map the event, POST activity, and no-op/fail-open when anything is missing or unavailable. + +If config installation fails, daemon startup and terminal spawn continue without terminal activity hooks. diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index c2195390d..88bae550e 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { readFileSync } from "node:fs"; +import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import { connectDaemonClient } from "./daemon-client-loader"; import { createTempDirectory, createTempGitRepo } from "./workspace"; @@ -26,10 +27,22 @@ export interface SeedDaemonClient { createTerminal( cwd: string, name?: string, + requestId?: string, + options?: { agentId?: string; command?: string; args?: string[] }, ): Promise<{ - terminal: { id: string; name: string; cwd: string } | null; + terminal: { id: string; name: string; cwd: string; activity?: TerminalActivity | null } | null; error: string | null; }>; + listTerminals(cwd?: string): Promise<{ + terminals: Array<{ + id: string; + name: string; + cwd: string; + title?: string; + activity?: TerminalActivity | null; + }>; + error?: string | null; + }>; createAgent(options: { provider: string; cwd: string; diff --git a/packages/app/e2e/helpers/terminal-dsl.ts b/packages/app/e2e/helpers/terminal-dsl.ts index fa9125b35..00c40fdcb 100644 --- a/packages/app/e2e/helpers/terminal-dsl.ts +++ b/packages/app/e2e/helpers/terminal-dsl.ts @@ -1,4 +1,5 @@ import type { Page } from "@playwright/test"; +import type { TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; import { createTempGitRepo } from "./workspace"; import { navigateToTerminal, setupDeterministicPrompt } from "./terminal-perf"; import { connectSeedClient, type SeedDaemonClient } from "./seed-client"; @@ -14,6 +15,16 @@ export interface TerminalInstance { cwd: string; } +interface CreateTerminalInput { + name: string; + command?: string; + args?: string[]; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export class TerminalE2EHarness { readonly client: SeedDaemonClient; readonly tempRepo: TempRepo; @@ -50,14 +61,46 @@ export class TerminalE2EHarness { await this.tempRepo.cleanup().catch(() => {}); } - async createTerminal(input: { name: string }): Promise { - const result = await this.client.createTerminal(this.tempRepo.path, input.name); + async createTerminal(input: CreateTerminalInput): Promise { + const options = + input.command || input.args + ? { + command: input.command, + args: input.args, + } + : undefined; + const result = await this.client.createTerminal( + this.tempRepo.path, + input.name, + undefined, + options, + ); if (!result.terminal) { throw new Error(`Failed to create terminal: ${result.error}`); } return result.terminal; } + async waitForTerminalActivity(input: { + terminalId: string; + state: TerminalActivityState | null; + timeoutMs?: number; + }): Promise { + const timeoutMs = input.timeoutMs ?? 10_000; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const result = await this.client.listTerminals(this.tempRepo.path); + const terminal = result.terminals.find((entry) => entry.id === input.terminalId); + if ((terminal?.activity?.state ?? null) === input.state) { + return; + } + await sleep(50); + } + throw new Error( + `Timed out waiting for terminal ${input.terminalId} activity state ${input.state ?? "unknown"}`, + ); + } + async killTerminal(terminalId: string): Promise { await this.client.killTerminal(terminalId).catch(() => {}); } diff --git a/packages/app/e2e/terminal-activity-indicators.spec.ts b/packages/app/e2e/terminal-activity-indicators.spec.ts new file mode 100644 index 000000000..d0b938189 --- /dev/null +++ b/packages/app/e2e/terminal-activity-indicators.spec.ts @@ -0,0 +1,174 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { test, expect, type Page } from "./fixtures"; +import { TerminalE2EHarness, type TerminalInstance } from "./helpers/terminal-dsl"; + +type HookActivityState = "running" | "idle" | "needs-input"; +type TabStatusBucket = "running" | "needs_input" | "none"; + +const TERMINAL_ACTIVITY_REPORTER_SCRIPT = ` +const fs = require("node:fs"); +const path = require("node:path"); + +const triggerDir = process.argv[1]; +const states = ["running", "idle", "needs-input"]; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForTrigger(state) { + const triggerPath = path.join(triggerDir, state); + while (!fs.existsSync(triggerPath)) { + await sleep(50); + } +} + +async function reportActivity(state) { + const response = await fetch(process.env.PASEO_TERMINAL_ACTIVITY_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + terminalId: process.env.PASEO_TERMINAL_ID, + token: process.env.PASEO_ACTIVITY_TOKEN, + state, + }), + }); + if (!response.ok) { + throw new Error("Activity report failed: " + response.status); + } + process.stdout.write("PASEO_ACTIVITY_REPORTED:" + state + "\\n"); +} + +(async () => { + for (const state of states) { + await waitForTrigger(state); + await reportActivity(state); + } + setInterval(() => {}, 1000); +})().catch((error) => { + console.error(error && error.stack ? error.stack : error); + setInterval(() => {}, 1000); +}); +`; + +function internalActivityState(state: HookActivityState): "working" | "idle" | "attention" { + if (state === "running") return "working"; + if (state === "needs-input") return "attention"; + return "idle"; +} + +function tabStatusBucket(state: HookActivityState): TabStatusBucket { + if (state === "running") return "running"; + if (state === "needs-input") return "needs_input"; + return "none"; +} + +function terminalTab(page: Page, terminalId: string) { + return page.getByTestId(`workspace-tab-terminal_${terminalId}`).first(); +} + +async function expectTerminalTabStatus( + page: Page, + terminalId: string, + status: TabStatusBucket, +): Promise { + await expect( + terminalTab(page, terminalId).locator(`[data-status-bucket="${status}"]`), + ).toBeVisible({ + timeout: 15_000, + }); +} + +async function focusTerminalTab(page: Page, terminalId: string): Promise { + await terminalTab(page, terminalId).click(); +} + +class ControlledActivityTerminal { + constructor( + readonly terminal: TerminalInstance, + private readonly harness: TerminalE2EHarness, + private readonly triggerDir: string, + ) {} + + async report(state: HookActivityState): Promise { + await writeFile(join(this.triggerDir, state), ""); + await this.harness.waitForTerminalActivity({ + terminalId: this.terminal.id, + state: internalActivityState(state), + timeoutMs: 15_000, + }); + } +} + +async function createControlledActivityTerminal( + harness: TerminalE2EHarness, +): Promise { + const triggerDir = join(harness.tempRepo.path, ".activity-triggers"); + await mkdir(triggerDir, { recursive: true }); + const terminal = await harness.createTerminal({ + name: "activity-source", + command: process.execPath, + args: ["-e", TERMINAL_ACTIVITY_REPORTER_SCRIPT, triggerDir], + }); + return new ControlledActivityTerminal(terminal, harness, triggerDir); +} + +async function withTerminalActivityFixture( + harness: TerminalE2EHarness, + fn: (input: { + activityTerminal: ControlledActivityTerminal; + focusTerminal: TerminalInstance; + }) => Promise, +): Promise { + const activityTerminal = await createControlledActivityTerminal(harness); + const focusTerminal = await harness.createTerminal({ name: "focus-sink" }); + try { + await fn({ activityTerminal, focusTerminal }); + } finally { + await harness.killTerminal(activityTerminal.terminal.id); + await harness.killTerminal(focusTerminal.id); + } +} + +test.describe("Terminal activity indicators", () => { + let harness: TerminalE2EHarness; + + test.beforeAll(async () => { + harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-activity-indicators-" }); + }); + + test.afterAll(async () => { + await harness?.cleanup(); + }); + + test("terminal activity follows the tab and clears when the terminal is focused", async ({ + page, + }) => { + await withTerminalActivityFixture(harness, async ({ activityTerminal, focusTerminal }) => { + await harness.openTerminal(page, { terminalId: activityTerminal.terminal.id }); + await harness.openTerminal(page, { terminalId: focusTerminal.id }); + + await activityTerminal.report("running"); + await expectTerminalTabStatus(page, activityTerminal.terminal.id, tabStatusBucket("running")); + + await activityTerminal.report("idle"); + await expectTerminalTabStatus(page, activityTerminal.terminal.id, tabStatusBucket("idle")); + + await activityTerminal.report("needs-input"); + await expectTerminalTabStatus( + page, + activityTerminal.terminal.id, + tabStatusBucket("needs-input"), + ); + + await focusTerminalTab(page, activityTerminal.terminal.id); + await harness.waitForTerminalActivity({ + terminalId: activityTerminal.terminal.id, + state: "idle", + timeoutMs: 15_000, + }); + await expectTerminalTabStatus(page, activityTerminal.terminal.id, "none"); + }); + }); +}); diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 12b28d976..06bdcdf58 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -193,6 +193,7 @@ export function TerminalPane({ const supportsTerminalRestoreModes = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-restore-modes"] === true, ); + const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId); const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]); @@ -297,9 +298,19 @@ export function TerminalPane({ }, [isPaneFocused, isWorkspaceFocused, requestTerminalReflow, scopeKey, terminalId]); const handleTerminalFocus = useCallback(() => { + if (isWorkspaceFocused && isPaneFocused) { + setFocusedTerminalId(serverId, terminalId); + } lastSentTerminalSizeRef.current = null; requestTerminalReflow(); - }, [requestTerminalReflow]); + }, [ + isPaneFocused, + isWorkspaceFocused, + requestTerminalReflow, + serverId, + setFocusedTerminalId, + terminalId, + ]); const clearKeyboardRefitTimeouts = useCallback(() => { if (keyboardRefitTimeoutsRef.current.length === 0) { diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index 49ed69218..19031022f 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -23,7 +23,7 @@ import { buildDraftStoreKey } from "@/stores/draft-keys"; import { usePanelStore } from "@/stores/panel-store"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import type { Agent } from "@/stores/session-store"; -import { useWorkspace } from "@/stores/session-store-hooks"; +import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { encodeImages } from "@/utils/encode-images"; import type { WorkspaceFileOpenRequest } from "@/workspace/file-open"; @@ -317,8 +317,11 @@ export function WorkspaceDraftAgentTab({ const insets = useSafeAreaInsets(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - const workspace = useWorkspace(serverId, workspaceId); - const workspaceDirectory = workspace?.workspaceDirectory || null; + const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({ + workspaceDirectory: w.workspaceDirectory, + id: w.id, + })); + const workspaceDirectory = workspaceFields?.workspaceDirectory || null; const draftSetup = initialSetup ?? null; const draftWorkingDirectory = resolveDraftWorkingDirectory({ workspaceDirectory, @@ -470,7 +473,7 @@ export function WorkspaceDraftAgentTab({ attachments, client, workspaceDirectory: draftWorkingDirectory, - workspaceId: workspace?.id ?? null, + workspaceId: workspaceFields?.id ?? null, autoSubmitConfig, composerState, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index d7f408d89..f2f362762 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -490,6 +490,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const focusedAgentId = useSessionStore( (state) => state.sessions[serverId]?.focusedAgentId ?? null, ); + const focusedTerminalId = useSessionStore( + (state) => state.sessions[serverId]?.focusedTerminalId ?? null, + ); const sessionAgents = useSessionStore((state) => state.sessions[serverId]?.agents); const previousAgentStatusRef = useRef>(new Map()); @@ -815,7 +818,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ); // Client activity tracking (heartbeat, push token registration) - useClientActivity({ client, focusedAgentId, onAppResumed: handleAppResumed }); + useClientActivity({ client, focusedAgentId, focusedTerminalId, onAppResumed: handleAppResumed }); usePushTokenRegistration({ client, serverId }); const notifyAgentAttention = useCallback( @@ -1648,6 +1651,27 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }); }); + const unsubTerminalAttention = client.on("terminal_attention_required", (message) => { + if (message.type !== "terminal_attention_required") { + return; + } + if (!message.payload.shouldNotify) { + return; + } + void sendOsNotification({ + title: message.payload.title, + body: message.payload.body, + // serverId + workspaceId + terminalId route a tap to the terminal tab; cwd is + // carried as a fallback identifier when the daemon resolved no workspace. + data: { + serverId: message.payload.serverId ?? serverId, + terminalId: message.payload.terminalId, + cwd: message.payload.cwd, + ...(message.payload.workspaceId ? { workspaceId: message.payload.workspaceId } : {}), + }, + }); + }); + return () => { unsubAgentUpdate(); unsubAgentStream(); @@ -1667,6 +1691,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubVoiceInputState(); unsubAgentDeleted(); unsubAgentArchived(); + unsubTerminalAttention(); agentStreamReducerQueue.dispose({ flush: true }); }; }, [ diff --git a/packages/app/src/hooks/client-activity-tracker.test.ts b/packages/app/src/hooks/client-activity-tracker.test.ts index b4506f569..db6f2e547 100644 --- a/packages/app/src/hooks/client-activity-tracker.test.ts +++ b/packages/app/src/hooks/client-activity-tracker.test.ts @@ -65,6 +65,7 @@ function buildTracker( client, deviceType: overrides.deviceType ?? "web", initialFocusedAgentId: overrides.initialFocusedAgentId ?? "agent-1", + initialFocusedTerminalId: overrides.initialFocusedTerminalId ?? null, initialAppVisible: overrides.initialAppVisible ?? true, now: clock.now, onAppResumed: overrides.onAppResumed, @@ -130,6 +131,20 @@ describe("client activity tracker", () => { expect(client.recordedHeartbeats).toHaveLength(0); }); + it("sends one immediate heartbeat when the focused terminal changes", () => { + const { tracker, client, clock } = buildTracker({ initialFocusedTerminalId: null }); + + clock.advance(5_000); + tracker.setFocusedTerminalId("terminal-1"); + + expect(client.recordedHeartbeats).toHaveLength(1); + expect(client.latest()).toMatchObject({ + focusedAgentId: "agent-1", + focusedTerminalId: "terminal-1", + lastActivityAt: new Date(START_MS + 5_000).toISOString(), + }); + }); + it("drives lastActivityAt forward from system idle polling", () => { const { tracker, client, clock } = buildTracker(); diff --git a/packages/app/src/hooks/client-activity-tracker.ts b/packages/app/src/hooks/client-activity-tracker.ts index 594a75486..d00b92c11 100644 --- a/packages/app/src/hooks/client-activity-tracker.ts +++ b/packages/app/src/hooks/client-activity-tracker.ts @@ -5,6 +5,7 @@ export const DESKTOP_IDLE_POLL_INTERVAL_MS = 5_000; export interface HeartbeatPayload { deviceType: "web" | "mobile"; focusedAgentId: string | null; + focusedTerminalId: string | null; lastActivityAt: string; appVisible: boolean; appVisibilityChangedAt?: string; @@ -19,6 +20,7 @@ export interface ClientActivityTrackerInput { client: HeartbeatClient; deviceType: "web" | "mobile"; initialFocusedAgentId: string | null; + initialFocusedTerminalId: string | null; initialAppVisible: boolean; now: () => number; onAppResumed?: (awayMs: number) => void; @@ -28,6 +30,7 @@ export interface ClientActivityTracker { recordUserActivity(): void; maybeSendImmediateHeartbeat(): void; setFocusedAgentId(id: string | null): void; + setFocusedTerminalId(id: string | null): void; notifyAppVisibility(visible: boolean): { changed: boolean }; notifySystemIdleMs(idleMs: number | null): void; sendHeartbeat(): void; @@ -42,6 +45,7 @@ export function createClientActivityTracker( let appVisibilityChangedAtMs = now(); let backgroundedAtMs: number | null = appVisible ? null : now(); let focusedAgentId = input.initialFocusedAgentId; + let focusedTerminalId = input.initialFocusedTerminalId; let lastImmediateHeartbeatAtMs = 0; function sendHeartbeat(): void { @@ -49,6 +53,7 @@ export function createClientActivityTracker( client.sendHeartbeat({ deviceType, focusedAgentId, + focusedTerminalId, lastActivityAt: new Date(lastActivityAtMs).toISOString(), appVisible, appVisibilityChangedAt: new Date(appVisibilityChangedAtMs).toISOString(), @@ -76,6 +81,12 @@ export function createClientActivityTracker( recordUserActivity(); sendHeartbeat(); }, + setFocusedTerminalId(id) { + if (focusedTerminalId === id) return; + focusedTerminalId = id; + recordUserActivity(); + sendHeartbeat(); + }, notifyAppVisibility(nextVisible) { if (appVisible === nextVisible) return { changed: false }; appVisible = nextVisible; diff --git a/packages/app/src/hooks/use-client-activity.ts b/packages/app/src/hooks/use-client-activity.ts index 3dfcb6c0c..65d8bf406 100644 --- a/packages/app/src/hooks/use-client-activity.ts +++ b/packages/app/src/hooks/use-client-activity.ts @@ -14,6 +14,7 @@ import { interface ClientActivityOptions { client: DaemonClient; focusedAgentId: string | null; + focusedTerminalId: string | null; onAppResumed?: (awayMs: number) => void; } @@ -26,6 +27,7 @@ interface ClientActivityOptions { export function useClientActivity({ client, focusedAgentId, + focusedTerminalId, onAppResumed, }: ClientActivityOptions): void { const onAppResumedRef = useRef(onAppResumed); @@ -37,6 +39,7 @@ export function useClientActivity({ client, deviceType: isWeb ? "web" : "mobile", initialFocusedAgentId: focusedAgentId, + initialFocusedTerminalId: focusedTerminalId, initialAppVisible: AppState.currentState === "active", now: () => Date.now(), onAppResumed: (awayMs) => onAppResumedRef.current?.(awayMs), @@ -114,6 +117,11 @@ export function useClientActivity({ tracker.setFocusedAgentId(focusedAgentId); }, [focusedAgentId, tracker]); + // Send heartbeat on focused terminal change. + useEffect(() => { + tracker.setFocusedTerminalId(focusedTerminalId); + }, [focusedTerminalId, tracker]); + // Periodic heartbeat gated by connection status. useEffect(() => { let intervalId: ReturnType | null = null; diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 9056910c1..2084e3afa 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -11,7 +11,8 @@ import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry import { queryClient } from "@/query/query-client"; import { usePanelStore } from "@/stores/panel-store"; import { useSessionStore } from "@/stores/session-store"; -import { useWorkspace } from "@/stores/session-store-hooks"; +import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks"; +import { terminalActivityToStatusBucket } from "@/utils/terminal-activity-bucket"; type ListTerminalsPayload = ListTerminalsResponse["payload"]; @@ -37,8 +38,7 @@ function useTerminalPanelDescriptor( ): PanelDescriptor { const { t } = useTranslation(); const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null); - const workspace = useWorkspace(context.serverId, context.workspaceId); - const workspaceDirectory = workspace?.workspaceDirectory || null; + const workspaceDirectory = useWorkspaceDirectory(context.serverId, context.workspaceId); const terminalsQuery = useQuery( { queryKey: ["terminals", context.serverId, workspaceDirectory] as const, @@ -63,16 +63,19 @@ function useTerminalPanelDescriptor( subtitle: t("workspace.tabs.fallback.terminal"), titleState: "ready", icon: Terminal, - statusBucket: null, + statusBucket: terminalActivityToStatusBucket(terminal?.activity?.state), }; } function TerminalPanel() { const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext(); const { isWorkspaceFocused, isPaneFocused } = usePaneFocus(); - const workspace = useWorkspace(serverId, workspaceId); - const workspaceDirectory = workspace?.workspaceDirectory || null; - const isGitCheckout = workspace?.projectKind === "git"; + const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({ + workspaceDirectory: w.workspaceDirectory, + isGitCheckout: w.projectKind === "git", + })); + const workspaceDirectory = workspaceFields?.workspaceDirectory || null; + const isGitCheckout = workspaceFields?.isGitCheckout ?? false; const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout); const handleOpenFileExplorer = useCallback(() => { if (!workspaceDirectory) { diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 919eb41da..68e2748a9 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -767,6 +767,46 @@ function AutoArchiveMergedWorkspacesCard({ serverId }: { serverId: string }) { ); } +function EnableTerminalAgentHooksCard({ serverId }: { serverId: string }) { + const isConnected = useHostRuntimeIsConnected(serverId); + const { config, patchConfig } = useDaemonConfig(serverId); + + const handleValueChange = useCallback( + (next: boolean) => { + void patchConfig({ enableTerminalAgentHooks: next }).catch((error) => { + console.error("[HostPage] Failed to update terminal agent hooks", error); + Alert.alert( + "Unable to update terminal agent hooks", + error instanceof Error ? error.message : String(error), + ); + }); + }, + [patchConfig], + ); + + if (!isConnected) return null; + + return ( + + + + Enable terminal agent hooks + + Get notifications and status from terminal agents. This installs hooks in your agent + config files. + + + + + + ); +} + function AppendSystemPromptCard({ serverId }: { serverId: string }) { const { t } = useTranslation(); const isConnected = useHostRuntimeIsConnected(serverId); @@ -1449,6 +1489,9 @@ export function HostTerminalsPage({ serverId }: { serverId: string }) { return ( + + + ); diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index 054ebf58e..4e16a5d03 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -223,6 +223,7 @@ function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDa providers, metadataGeneration: { providers: [] }, autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, appendSystemPrompt: "", }; } diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 66cf586a2..90c8814ca 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -474,8 +474,13 @@ function TabHandleContent({ tabLabelSkeletonStyle: React.ComponentProps["style"]; tabLabelStyle: React.ComponentProps["style"]; }) { + const tabHandleDataSet = useMemo( + () => ({ statusBucket: presentation.statusBucket ?? "none" }), + [presentation.statusBucket], + ); + return ( - + diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 10e81944a..861686996 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -2030,6 +2030,7 @@ function WorkspaceScreenContent({ [uiTabs, workspaceLayout], ); const setFocusedAgentId = useSessionStore((state) => state.setFocusedAgentId); + const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId); const focusedPaneAgentId = useMemo(() => { const target = focusedPaneTabState.activeTab?.descriptor.target; if (target?.kind !== "agent") { @@ -2037,13 +2038,28 @@ function WorkspaceScreenContent({ } return target.agentId; }, [focusedPaneTabState.activeTab]); + const focusedPaneTerminalId = useMemo(() => { + const target = focusedPaneTabState.activeTab?.descriptor.target; + if (target?.kind !== "terminal") { + return null; + } + return target.terminalId; + }, [focusedPaneTabState.activeTab]); useEffect(() => { if (!isRouteFocused) { return; } setFocusedAgentId(normalizedServerId, focusedPaneAgentId); - }, [focusedPaneAgentId, isRouteFocused, normalizedServerId, setFocusedAgentId]); + setFocusedTerminalId(normalizedServerId, focusedPaneTerminalId); + }, [ + focusedPaneAgentId, + focusedPaneTerminalId, + isRouteFocused, + normalizedServerId, + setFocusedAgentId, + setFocusedTerminalId, + ]); useEffect(() => { if (!isRouteFocused) { @@ -2051,8 +2067,9 @@ function WorkspaceScreenContent({ } return () => { setFocusedAgentId(normalizedServerId, null); + setFocusedTerminalId(normalizedServerId, null); }; - }, [isRouteFocused, normalizedServerId, setFocusedAgentId]); + }, [isRouteFocused, normalizedServerId, setFocusedAgentId, setFocusedTerminalId]); const openWorkspaceDraftTab = useCallback( function openWorkspaceDraftTab(input?: { draftId?: string; focus?: boolean }) { diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 2ce3b2a33..dc69fa824 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -282,6 +282,7 @@ export interface SessionState { // Focus focusedAgentId: string | null; + focusedTerminalId: string | null; // Messages messages: MessageEntry[]; @@ -340,6 +341,7 @@ interface SessionStoreActions { // Focus setFocusedAgentId: (serverId: string, agentId: string | null) => void; + setFocusedTerminalId: (serverId: string, terminalId: string | null) => void; // Messages setMessages: ( @@ -480,6 +482,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess hasHydratedWorkspaces: false, isPlayingAudio: false, focusedAgentId: null, + focusedTerminalId: null, messages: [], currentAssistantMessage: "", agentStreamTail: new Map(), @@ -724,6 +727,25 @@ export const useSessionStore = create()( }); }, + setFocusedTerminalId: (serverId, terminalId) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session || session.focusedTerminalId === terminalId) { + return prev; + } + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { + ...session, + focusedTerminalId: terminalId, + }, + }, + }; + }); + }, + // Messages setMessages: (serverId, messages) => { set((prev) => { diff --git a/packages/app/src/utils/notification-routing.test.ts b/packages/app/src/utils/notification-routing.test.ts index 7ecbbc716..f492b56e8 100644 --- a/packages/app/src/utils/notification-routing.test.ts +++ b/packages/app/src/utils/notification-routing.test.ts @@ -13,6 +13,7 @@ describe("resolveNotificationTarget", () => { serverId: "server-123", agentId: "agent-456", workspaceId: null, + terminalId: null, }); }); @@ -21,11 +22,13 @@ describe("resolveNotificationTarget", () => { serverId: null, agentId: null, workspaceId: null, + terminalId: null, }); expect(resolveNotificationTarget(undefined)).toEqual({ serverId: null, agentId: null, workspaceId: null, + terminalId: null, }); }); @@ -40,6 +43,7 @@ describe("resolveNotificationTarget", () => { serverId: "srv-1", agentId: "agent-1", workspaceId: null, + terminalId: null, }); }); }); @@ -61,6 +65,26 @@ describe("buildNotificationRoute", () => { ); }); + it("routes to the workspace terminal tab when workspace and terminal ids are present", () => { + expect( + buildNotificationRoute({ + serverId: "srv-1", + workspaceId: "ws-main", + terminalId: "term-1", + }), + ).toBe("/h/srv-1/workspace/ws-main?open=terminal%3Aterm-1"); + }); + + it("falls back to host root for a terminal without a workspace id", () => { + expect( + buildNotificationRoute({ + serverId: "srv-1", + terminalId: "term-1", + cwd: "/tmp/repo", + }), + ).toBe("/h/srv-1"); + }); + it("falls back to host root when only serverId is present", () => { expect(buildNotificationRoute({ serverId: "srv-only" })).toBe("/h/srv-only"); }); diff --git a/packages/app/src/utils/notification-routing.ts b/packages/app/src/utils/notification-routing.ts index ac69cef68..ba6d9f1fa 100644 --- a/packages/app/src/utils/notification-routing.ts +++ b/packages/app/src/utils/notification-routing.ts @@ -1,5 +1,9 @@ import type { Href } from "expo-router"; -import { buildHostAgentDetailRoute, buildHostRootRoute } from "@/utils/host-routes"; +import { + buildHostAgentDetailRoute, + buildHostRootRoute, + buildHostWorkspaceOpenRoute, +} from "@/utils/host-routes"; type NotificationData = Record | null | undefined; type NotificationRoute = Extract; @@ -17,19 +21,24 @@ export function resolveNotificationTarget(data: NotificationData): { serverId: string | null; agentId: string | null; workspaceId: string | null; + terminalId: string | null; } { return { serverId: readNonEmptyString(data, "serverId"), agentId: readNonEmptyString(data, "agentId"), workspaceId: readNonEmptyString(data, "workspaceId"), + terminalId: readNonEmptyString(data, "terminalId"), }; } export function buildNotificationRoute(data: NotificationData): NotificationRoute { - const { serverId, agentId } = resolveNotificationTarget(data); + const { serverId, agentId, workspaceId, terminalId } = resolveNotificationTarget(data); if (serverId && agentId) { return buildHostAgentDetailRoute(serverId, agentId); } + if (serverId && workspaceId && terminalId) { + return buildHostWorkspaceOpenRoute(serverId, workspaceId, `terminal:${terminalId}`); + } if (serverId) { return buildHostRootRoute(serverId); } diff --git a/packages/app/src/utils/terminal-activity-bucket.test.ts b/packages/app/src/utils/terminal-activity-bucket.test.ts new file mode 100644 index 000000000..89d111ab1 --- /dev/null +++ b/packages/app/src/utils/terminal-activity-bucket.test.ts @@ -0,0 +1,20 @@ +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 new file mode 100644 index 000000000..15d02528c --- /dev/null +++ b/packages/app/src/utils/terminal-activity-bucket.ts @@ -0,0 +1,10 @@ +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/cli/src/cli.ts b/packages/cli/src/cli.ts index 11d9af154..f89004dd5 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -9,6 +9,7 @@ import { createScheduleCommand } from "./commands/schedule/index.js"; import { createSpeechCommand } from "./commands/speech/index.js"; import { createTerminalCommand } from "./commands/terminal/index.js"; import { createWorktreeCommand } from "./commands/worktree/index.js"; +import { createHooksCommand } from "./commands/hooks.js"; import { startCommand as daemonStartCommand } from "./commands/daemon/start.js"; import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js"; import { runRestartCommand as runDaemonRestartCommand } from "./commands/daemon/restart.js"; @@ -96,6 +97,7 @@ export function createCli(): Command { // Top-level local daemon shortcuts program.addCommand(onboardCommand()); program.addCommand(daemonStartCommand()); + program.addCommand(createHooksCommand()); addJsonOption( program diff --git a/packages/cli/src/commands/hooks.test.ts b/packages/cli/src/commands/hooks.test.ts new file mode 100644 index 000000000..93f2219be --- /dev/null +++ b/packages/cli/src/commands/hooks.test.ts @@ -0,0 +1,169 @@ +import { AGENT_HOOK_PROVIDERS } from "@getpaseo/server/agent-hooks"; +import { describe, expect, it } from "vitest"; +import { runHooksCommand } from "./hooks.js"; + +const hookEnv = { + PASEO_TERMINAL_ID: "terminal-1", + PASEO_ACTIVITY_TOKEN: "token-1", + PASEO_TERMINAL_ACTIVITY_URL: "http://127.0.0.1:6767/api/terminal-activity", +}; + +function inputFrom(value: string) { + return { + async *[Symbol.asyncIterator]() { + yield value; + }, + }; +} + +function ttyInput() { + return { + isTTY: true, + async *[Symbol.asyncIterator]() {}, + }; +} + +interface FetchCall { + url: string; + init: RequestInit | undefined; +} + +interface RecordingFetch { + send: typeof fetch; + calls: FetchCall[]; +} + +const claudeProvider = AGENT_HOOK_PROVIDERS.claude; +const codexProvider = AGENT_HOOK_PROVIDERS.codex; +const opencodeProvider = AGENT_HOOK_PROVIDERS.opencode; + +function createFetch(): RecordingFetch { + const calls: FetchCall[] = []; + const send = (async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init }); + return { ok: true } as Response; + }) as typeof fetch; + return { send, calls }; +} + +async function runHook(agent: string, event: string, input = ttyInput()) { + const fetch = createFetch(); + await runHooksCommand(agent, event, { + env: hookEnv, + input, + fetch: fetch.send, + }); + return fetch; +} + +function expectPostedState(fetch: RecordingFetch, state: string) { + expect(fetch.calls).toEqual([ + { + url: hookEnv.PASEO_TERMINAL_ACTIVITY_URL, + init: { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + terminalId: hookEnv.PASEO_TERMINAL_ID, + token: hookEnv.PASEO_ACTIVITY_TOKEN, + state, + }), + signal: expect.any(AbortSignal), + }, + }, + ]); +} + +describe("runHooksCommand", () => { + it.each([ + [claudeProvider.events[0].event, "running"], + [claudeProvider.events[1].event, "idle"], + [claudeProvider.events[2].event, "idle"], + [claudeProvider.events[3].event, "idle"], + ])("maps Claude %s to %s", async (event, state) => { + const send = await runHook(claudeProvider.id, event); + + expectPostedState(send, state); + }); + + it.each([ + ["UserPromptSubmit", "running"], + ["PreToolUse", "running"], + ["PostToolUse", "running"], + ["PermissionRequest", "needs-input"], + ["Stop", "idle"], + ])("maps Codex %s to %s", async (event, state) => { + const send = await runHook(codexProvider.id, event); + + expectPostedState(send, state); + }); + + it.each([ + ["session.status.busy", "running"], + ["session.status.retry", "running"], + ["session.status.idle", "idle"], + ["permission.asked", "needs-input"], + ["permission.replied", "running"], + ])("maps OpenCode %s to %s", async (event, state) => { + const send = await runHook(opencodeProvider.id, event); + + expectPostedState(send, state); + }); + + it("maps Claude idle prompt notifications to needs-input", async () => { + const send = await runHook( + claudeProvider.id, + claudeProvider.events[4].event, + inputFrom('{"reason":"idle_prompt"}'), + ); + + expectPostedState(send, "needs-input"); + }); + + it.each(["permission_prompt", "elicitation_prompt", "elicitation_response", "auth_success"])( + "ignores Claude %s notifications", + async (reason) => { + const send = await runHook( + claudeProvider.id, + claudeProvider.events[4].event, + inputFrom(JSON.stringify({ reason })), + ); + + expect(send.calls).toEqual([]); + }, + ); + + it("does nothing when terminal activity env is missing", async () => { + const fetch = createFetch(); + + await runHooksCommand(claudeProvider.id, claudeProvider.events[0].event, { + env: {}, + input: ttyInput(), + fetch: fetch.send, + }); + + expect(fetch.calls).toEqual([]); + }); + + it("does nothing for unknown agents and events", async () => { + const unknownAgent = await runHook("unknown-provider", claudeProvider.events[0].event); + const unknownEvent = await runHook(claudeProvider.id, "UnknownEvent"); + + expect(unknownAgent.calls).toEqual([]); + expect(unknownEvent.calls).toEqual([]); + }); + + it("does not throw when the daemon post fails", async () => { + const send = (async () => { + throw new Error("daemon down"); + }) as typeof fetch; + + await expect( + runHooksCommand(claudeProvider.id, claudeProvider.events[0].event, { + env: hookEnv, + input: ttyInput(), + fetch: send, + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/hooks.ts b/packages/cli/src/commands/hooks.ts new file mode 100644 index 000000000..ceb5b44b0 --- /dev/null +++ b/packages/cli/src/commands/hooks.ts @@ -0,0 +1,116 @@ +import { Command } from "commander"; +import { resolveHookActivity, type AgentHookActivityState } from "@getpaseo/server/agent-hooks"; + +interface HookEnvironment { + PASEO_TERMINAL_ID?: string; + PASEO_ACTIVITY_TOKEN?: string; + PASEO_TERMINAL_ACTIVITY_URL?: string; +} + +interface HookInput { + [Symbol.asyncIterator](): AsyncIterator; + isTTY?: boolean; +} + +interface HooksRuntime { + env: HookEnvironment; + input: HookInput; + fetch: typeof fetch; +} + +export function createHooksCommand(): Command { + return new Command("hooks") + .description("Record agent hook activity") + .argument("", "Agent hook source") + .argument("", "Agent hook event") + .action((agent: string, event: string) => runHooksCommand(agent, event)); +} + +export async function runHooksCommand( + agent: string, + event: string, + runtime: HooksRuntime = { + env: process.env, + input: process.stdin, + fetch, + }, +): Promise { + const target = resolveTarget(runtime.env); + if (!target) return; + + const state = await resolveHookActivity({ + provider: agent, + event, + input: { + isTTY: runtime.input.isTTY, + read: () => readInput(runtime.input), + }, + }); + if (!state) return; + + await postActivity(target, state, runtime.fetch); +} + +function resolveTarget(env: HookEnvironment) { + const terminalId = env.PASEO_TERMINAL_ID; + const token = env.PASEO_ACTIVITY_TOKEN; + const url = env.PASEO_TERMINAL_ACTIVITY_URL; + + if (!terminalId || !token || !url) return null; + return { terminalId, token, url }; +} + +async function readInput(input: HookInput): Promise { + const iterator = input[Symbol.asyncIterator](); + const chunks: string[] = []; + + while (true) { + const next = await withTimeout(iterator.next(), 100); + if (!next) { + await iterator.return?.(); + return null; + } + if (next.done) return chunks.join(""); + chunks.push(String(next.value)); + } +} + +async function withTimeout(promise: Promise, ms: number): Promise { + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timeout = setTimeout(() => resolve(null), ms); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +async function postActivity( + target: { terminalId: string; token: string; url: string }, + state: AgentHookActivityState, + send: typeof fetch, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 500); + + try { + await send(target.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + terminalId: target.terminalId, + token: target.token, + state, + }), + signal: controller.signal, + }); + } catch { + return; + } finally { + clearTimeout(timeout); + } +} diff --git a/packages/cli/src/run.test.ts b/packages/cli/src/run.test.ts index cb92d1c43..163e2f89f 100644 --- a/packages/cli/src/run.test.ts +++ b/packages/cli/src/run.test.ts @@ -25,6 +25,16 @@ describe("runCli", () => { ).toEqual(["node", "paseo", "daemon", "set-password"]); }); + it("preserves the hooks command argv", () => { + expect( + createCliParseArgv({ + argv: ["hooks", "claude", "UserPromptSubmit"], + cwd: process.cwd(), + nodeArgv: ["node", "paseo"], + }), + ).toEqual(["node", "paseo", "hooks", "claude", "UserPromptSubmit"]); + }); + it("classifies existing unknown directories as open-project invocations", () => { const root = mkdtempSync(path.join(tmpdir(), "paseo-cli-run-")); const project = path.join(root, "project"); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 36effda6e..482ab6fb4 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1565,6 +1565,7 @@ export class DaemonClient { sendHeartbeat(params: { deviceType: "web" | "mobile"; focusedAgentId: string | null; + focusedTerminalId?: string | null; lastActivityAt: string; appVisible: boolean; appVisibilityChangedAt?: string; @@ -1573,6 +1574,7 @@ export class DaemonClient { type: "client_heartbeat", deviceType: params.deviceType, focusedAgentId: params.focusedAgentId, + focusedTerminalId: params.focusedTerminalId ?? null, lastActivityAt: params.lastActivityAt, appVisible: params.appVisible, appVisibilityChangedAt: params.appVisibilityChangedAt, diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index f5b92c0f5..a765839dc 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -675,6 +675,7 @@ test("config actions delegate to existing daemon config RPCs", async () => { providers: {}, metadataGeneration: { providers: [] }, autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, appendSystemPrompt: "", }, }); @@ -728,6 +729,7 @@ test("config actions delegate to existing daemon config RPCs", async () => { }, metadataGeneration: { providers: [] }, autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, appendSystemPrompt: "", }, }); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 8aa905db8..7e527961d 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { TerminalActivitySchema } from "./terminal-activity.js"; import { CLIENT_CAPS } from "./client-capabilities.js"; import { AGENT_LIFECYCLE_STATUSES } from "./agent-lifecycle.js"; import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "@getpaseo/protocol/agent-title-limits"; @@ -138,6 +139,7 @@ export const MutableDaemonConfigSchema = z providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}), metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }), autoArchiveAfterMerge: z.boolean().default(false), + enableTerminalAgentHooks: z.boolean().default(false), appendSystemPrompt: z.string().default(""), terminalProfiles: z.array(TerminalProfileSchema).optional(), }) @@ -151,6 +153,7 @@ export const MutableDaemonConfigPatchSchema = z .optional(), metadataGeneration: MutableMetadataGenerationConfigSchema.partial().optional(), autoArchiveAfterMerge: z.boolean().optional(), + enableTerminalAgentHooks: z.boolean().optional(), appendSystemPrompt: z.string().optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(), }) @@ -1740,6 +1743,8 @@ export const ClientHeartbeatMessageSchema = z.object({ type: z.literal("client_heartbeat"), deviceType: z.enum(["web", "mobile"]), focusedAgentId: z.string().nullable(), + // COMPAT(terminalFocusHeartbeat): added in v0.1.97, remove optional default after 2026-12-13 once old clients no longer send heartbeats without terminal focus. + focusedTerminalId: z.string().nullable().optional().default(null), lastActivityAt: z.string(), appVisible: z.boolean(), appVisibilityChangedAt: z.string().optional(), @@ -3687,6 +3692,7 @@ const TerminalInfoSchema = z.object({ name: z.string(), cwd: z.string(), title: z.string().optional(), + activity: TerminalActivitySchema.nullable().optional(), }); export const TerminalCellSchema = z.object({ @@ -3807,6 +3813,20 @@ export const TerminalStreamExitSchema = z.object({ }), }); +export const TerminalAttentionRequiredSchema = z.object({ + type: z.literal("terminal_attention_required"), + payload: z.object({ + serverId: z.string().optional(), + terminalId: z.string(), + cwd: z.string(), + workspaceId: z.string().optional(), + reason: z.enum(["finished", "needs_input"]), + title: z.string(), + body: z.string(), + shouldNotify: z.boolean(), + }), +}); + export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ActivityLogMessageSchema, AssistantChunkMessageSchema, @@ -3913,6 +3933,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ KillTerminalResponseSchema, CaptureTerminalResponseSchema, TerminalStreamExitSchema, + TerminalAttentionRequiredSchema, ChatCreateResponseSchema, ChatListResponseSchema, ChatInspectResponseSchema, diff --git a/packages/protocol/src/terminal-activity.test.ts b/packages/protocol/src/terminal-activity.test.ts new file mode 100644 index 000000000..b96d8514b --- /dev/null +++ b/packages/protocol/src/terminal-activity.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { TERMINAL_ACTIVITY_STATES, TerminalActivitySchema } from "./terminal-activity.js"; + +describe("TerminalActivitySchema", () => { + it("parses the known activity states", () => { + for (const state of TERMINAL_ACTIVITY_STATES) { + expect(TerminalActivitySchema.parse({ state, changedAt: 1 }).state).toBe(state); + } + }); + + // Protocol forward-compat: a newer daemon may report a state this client predates. + // The old client must still parse the payload (degrading to idle) rather than + // rejecting the whole message on a strict enum. + it("degrades an unknown future state to idle while keeping the rest of the payload", () => { + const parsed = TerminalActivitySchema.parse({ state: "compacting", changedAt: 1718000000000 }); + expect(parsed.state).toBe("idle"); + expect(parsed.changedAt).toBe(1718000000000); + }); +}); diff --git a/packages/protocol/src/terminal-activity.ts b/packages/protocol/src/terminal-activity.ts new file mode 100644 index 000000000..0da57505e --- /dev/null +++ b/packages/protocol/src/terminal-activity.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const TERMINAL_ACTIVITY_STATES = ["idle", "working", "attention"] as const; + +export type TerminalActivityState = (typeof TERMINAL_ACTIVITY_STATES)[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"), + changedAt: z.number(), +}); + +export type TerminalActivity = z.infer; diff --git a/packages/server/package.json b/packages/server/package.json index 55cdb8179..432173cec 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -22,6 +22,11 @@ "types": "./dist/server/utils/tool-call-parsers.d.ts", "source": "./src/utils/tool-call-parsers.ts", "default": "./dist/server/utils/tool-call-parsers.js" + }, + "./agent-hooks": { + "types": "./dist/server/terminal/agent-hooks/provider-registry.d.ts", + "source": "./src/terminal/agent-hooks/provider-registry.ts", + "default": "./dist/server/terminal/agent-hooks/provider-registry.js" } }, "publishConfig": { diff --git a/packages/server/src/server/agent-attention-policy.test.ts b/packages/server/src/server/agent-attention-policy.test.ts index 25c799e81..ac9d92f16 100644 --- a/packages/server/src/server/agent-attention-policy.test.ts +++ b/packages/server/src/server/agent-attention-policy.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { computeNotificationPlan, + isPushEligibleAttentionReason, type ClientPresenceState, PRESENCE_THRESHOLD_MS, } from "./agent-attention-policy.js"; @@ -9,6 +10,7 @@ function state(overrides: Partial): ClientPresenceState { return { appVisible: true, focusedAgentId: null, + focusedTerminalId: null, lastActivityAtMs: null, ...overrides, }; @@ -28,8 +30,8 @@ describe("computeNotificationPlan", () => { expect( computeNotificationPlan({ allStates: [staleFocused], - agentId: "agent-1", - reason: "finished", + focusTarget: { kind: "agent", id: "agent-1" }, + pushEligible: true, nowMs, }), ).toEqual({ inAppRecipientIndex: null, shouldPush: true }); @@ -48,8 +50,8 @@ describe("computeNotificationPlan", () => { expect( computeNotificationPlan({ allStates: [staleFocused, presentFocused], - agentId: "agent-1", - reason: "finished", + focusTarget: { kind: "agent", id: "agent-1" }, + pushEligible: true, nowMs, }), ).toEqual({ inAppRecipientIndex: null, shouldPush: false }); @@ -65,8 +67,8 @@ describe("computeNotificationPlan", () => { expect( computeNotificationPlan({ allStates: [backgroundFocused], - agentId: "agent-1", - reason: "finished", + focusTarget: { kind: "agent", id: "agent-1" }, + pushEligible: true, nowMs, }), ).toEqual({ inAppRecipientIndex: 0, shouldPush: false }); @@ -81,8 +83,8 @@ describe("computeNotificationPlan", () => { lastActivityAtMs: nowMs - 1_000, }), ], - agentId: "agent-1", - reason: "finished", + focusTarget: { kind: "agent", id: "agent-1" }, + pushEligible: true, nowMs, }), ).toEqual({ inAppRecipientIndex: 0, shouldPush: false }); @@ -96,8 +98,8 @@ describe("computeNotificationPlan", () => { state({ lastActivityAtMs: nowMs - 1_000 }), state({ lastActivityAtMs: staleAtMs }), ], - agentId: "agent-1", - reason: "permission", + focusTarget: { kind: "agent", id: "agent-1" }, + pushEligible: true, nowMs, }), ).toEqual({ inAppRecipientIndex: 1, shouldPush: false }); @@ -110,8 +112,8 @@ describe("computeNotificationPlan", () => { state({ lastActivityAtMs: nowMs - 1_000 }), state({ lastActivityAtMs: nowMs - 1_000 }), ], - agentId: "agent-1", - reason: "finished", + focusTarget: { kind: "agent", id: "agent-1" }, + pushEligible: true, nowMs, }), ).toEqual({ inAppRecipientIndex: 0, shouldPush: false }); @@ -124,8 +126,8 @@ describe("computeNotificationPlan", () => { state({ lastActivityAtMs: nowMs - 1 }), state({ lastActivityAtMs: nowMs + 600_000 }), ], - agentId: "agent-1", - reason: "permission", + focusTarget: { kind: "agent", id: "agent-1" }, + pushEligible: true, nowMs, }), ).toEqual({ inAppRecipientIndex: 1, shouldPush: false }); @@ -135,8 +137,8 @@ describe("computeNotificationPlan", () => { expect( computeNotificationPlan({ allStates: [state({ lastActivityAtMs: null })], - agentId: "agent-1", - reason: "finished", + focusTarget: { kind: "agent", id: "agent-1" }, + pushEligible: true, nowMs, }), ).toEqual({ inAppRecipientIndex: null, shouldPush: true }); @@ -146,8 +148,8 @@ describe("computeNotificationPlan", () => { expect( computeNotificationPlan({ allStates: [state({ lastActivityAtMs: staleAtMs })], - agentId: "agent-1", - reason: "permission", + focusTarget: { kind: "agent", id: "agent-1" }, + pushEligible: true, nowMs, }), ).toEqual({ inAppRecipientIndex: null, shouldPush: true }); @@ -157,8 +159,8 @@ describe("computeNotificationPlan", () => { expect( computeNotificationPlan({ allStates: [state({ lastActivityAtMs: staleAtMs })], - agentId: "agent-1", - reason: "error", + focusTarget: { kind: "agent", id: "agent-1" }, + pushEligible: false, nowMs, }), ).toEqual({ inAppRecipientIndex: null, shouldPush: false }); @@ -171,8 +173,8 @@ describe("computeNotificationPlan", () => { state({ focusedAgentId: "agent-2", lastActivityAtMs: nowMs - 20_000 }), state({ focusedAgentId: null, lastActivityAtMs: nowMs - 500 }), ], - agentId: "agent-1", - reason: "finished", + focusTarget: { kind: "agent", id: "agent-1" }, + pushEligible: true, nowMs, }), ).toEqual({ inAppRecipientIndex: 1, shouldPush: false }); @@ -182,10 +184,51 @@ describe("computeNotificationPlan", () => { expect( computeNotificationPlan({ allStates: [state({ lastActivityAtMs: staleAtMs }), state({ lastActivityAtMs: staleAtMs })], - agentId: "agent-1", - reason: "finished", + focusTarget: { kind: "agent", id: "agent-1" }, + pushEligible: true, + nowMs, + }), + ).toEqual({ inAppRecipientIndex: null, shouldPush: true }); + }); + + it("never suppresses when focusTarget is null even if a client focuses a matching id", () => { + expect( + computeNotificationPlan({ + allStates: [state({ focusedAgentId: "terminal-1", lastActivityAtMs: nowMs - 500 })], + focusTarget: null, + pushEligible: true, + nowMs, + }), + ).toEqual({ inAppRecipientIndex: 0, shouldPush: false }); + }); + + it("suppresses terminal notifications when a present visible client focuses the terminal", () => { + expect( + computeNotificationPlan({ + allStates: [state({ focusedTerminalId: "terminal-1", lastActivityAtMs: nowMs - 500 })], + focusTarget: { kind: "terminal", id: "terminal-1" }, + pushEligible: true, + nowMs, + }), + ).toEqual({ inAppRecipientIndex: null, shouldPush: false }); + }); + + it("pushes for a null-focus target when no client is present and push is eligible", () => { + expect( + computeNotificationPlan({ + allStates: [state({ lastActivityAtMs: staleAtMs })], + focusTarget: null, + pushEligible: true, nowMs, }), ).toEqual({ inAppRecipientIndex: null, shouldPush: true }); }); }); + +describe("isPushEligibleAttentionReason", () => { + it("allows push for finished and permission but not error", () => { + expect(isPushEligibleAttentionReason("finished")).toBe(true); + expect(isPushEligibleAttentionReason("permission")).toBe(true); + expect(isPushEligibleAttentionReason("error")).toBe(false); + }); +}); diff --git a/packages/server/src/server/agent-attention-policy.ts b/packages/server/src/server/agent-attention-policy.ts index 1c21c7e23..98b3a4dcd 100644 --- a/packages/server/src/server/agent-attention-policy.ts +++ b/packages/server/src/server/agent-attention-policy.ts @@ -6,8 +6,11 @@ export interface ClientPresenceState { appVisible: boolean; lastActivityAtMs: number | null; focusedAgentId: string | null; + focusedTerminalId: string | null; } +export type AttentionFocusTarget = { kind: "agent"; id: string } | { kind: "terminal"; id: string }; + export interface NotificationPlan { inAppRecipientIndex: number | null; shouldPush: boolean; @@ -15,15 +18,31 @@ export interface NotificationPlan { interface ComputeNotificationPlanInput { allStates: ClientPresenceState[]; - agentId: string; - reason: AgentAttentionReason; + // A present, app-visible client focused on the attention target suppresses the + // notification entirely. Pass null when the target should not suppress notifications. + focusTarget: AttentionFocusTarget | null; + // Whether a push notification is allowed when no client is present. + pushEligible: boolean; nowMs: number; } +function isFocusedOnTarget( + state: ClientPresenceState, + target: AttentionFocusTarget | null, +): boolean { + if (target === null) { + return false; + } + if (target.kind === "agent") { + return state.focusedAgentId === target.id; + } + return state.focusedTerminalId === target.id; +} + export function computeNotificationPlan({ allStates, - agentId, - reason, + focusTarget, + pushEligible, nowMs, }: ComputeNotificationPlanInput): NotificationPlan { let mostRecentPresentIndex: number | null = null; @@ -39,7 +58,7 @@ export function computeNotificationPlan({ continue; } - if (state.appVisible && state.focusedAgentId === agentId) { + if (state.appVisible && isFocusedOnTarget(state, focusTarget)) { return { inAppRecipientIndex: null, shouldPush: false }; } @@ -53,5 +72,9 @@ export function computeNotificationPlan({ return { inAppRecipientIndex: mostRecentPresentIndex, shouldPush: false }; } - return { inAppRecipientIndex: null, shouldPush: reason !== "error" }; + return { inAppRecipientIndex: null, shouldPush: pushEligible }; +} + +export function isPushEligibleAttentionReason(reason: AgentAttentionReason): boolean { + return reason !== "error"; } diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 3976e38c9..4673e3e32 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -8,6 +8,7 @@ import path from "node:path"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import type { Logger } from "pino"; +import { z } from "zod"; import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js"; export type ListenTarget = @@ -115,6 +116,7 @@ import { setupAutoArchiveOnMerge } from "./auto-archive-on-merge/index.js"; import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import { createConfiguredTerminalManager } from "../terminal/terminal-manager-factory.js"; +import { applyTerminalAgentHookSetting } from "../terminal/agent-hooks/terminal-agent-hook-setting.js"; import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; import { startRelayTransport, type RelayTransportController } from "./relay-transport.js"; @@ -171,6 +173,75 @@ function createAgentMcpBaseUrl(listenTarget: ListenTarget | null): string | null ).toString(); } +function createTerminalActivityUrl(listenTarget: ListenTarget | null): string | null { + if (!listenTarget || listenTarget.type !== "tcp") { + return null; + } + const host = resolveAgentMcpClientHost(listenTarget.host); + return new URL( + "/api/terminal-activity", + `http://${formatHostForHttpUrl(host)}:${listenTarget.port}`, + ).toString(); +} + +const TerminalActivityReportSchema = z.object({ + terminalId: z.string().min(1), + token: z.string().min(1), + state: z.enum(["running", "idle", "needs-input"]), +}); + +const TERMINAL_ACTIVITY_STATE_MAP = { + running: "working", + idle: "idle", + "needs-input": "attention", +} as const; + +const LOOPBACK_REMOTE_ADDRESSES = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]); + +function isLoopbackRemoteAddress(remoteAddress: string | undefined): boolean { + return remoteAddress !== undefined && LOOPBACK_REMOTE_ADDRESSES.has(remoteAddress); +} + +export function createTerminalActivityRouteHandler( + terminalManager: TerminalManager, +): express.RequestHandler { + return async (req, res) => { + if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) { + res.status(403).json({ error: "Forbidden" }); + return; + } + + const parsed = TerminalActivityReportSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: "Invalid terminal activity report" }); + return; + } + + const validation = terminalManager.validateTerminalActivityToken( + parsed.data.terminalId, + parsed.data.token, + ); + if (validation !== "valid") { + res.status(403).json({ error: "Forbidden" }); + return; + } + + try { + const updated = await terminalManager.setTerminalActivity( + parsed.data.terminalId, + TERMINAL_ACTIVITY_STATE_MAP[parsed.data.state], + ); + if (!updated) { + res.status(403).json({ error: "Forbidden" }); + return; + } + res.status(204).end(); + } catch { + res.status(500).json({ error: "Failed to update terminal activity" }); + } + }; +} + function summarizeAgentMcpDebugMessage(body: unknown): Record { if (!body || typeof body !== "object" || Array.isArray(body)) { return { @@ -240,6 +311,7 @@ export interface PaseoDaemonConfig { mcpEnabled?: boolean; mcpInjectIntoAgents?: boolean; autoArchiveAfterMerge?: boolean; + enableTerminalAgentHooks?: boolean; appendSystemPrompt?: string; terminalProfiles?: TerminalProfile[]; staticDir: string; @@ -316,6 +388,7 @@ export async function createPaseoDaemon( providers: config.metadataGeneration?.providers ?? [], }, autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false, + enableTerminalAgentHooks: config.enableTerminalAgentHooks ?? false, appendSystemPrompt: config.appendSystemPrompt ?? "", ...(config.terminalProfiles !== undefined ? { terminalProfiles: config.terminalProfiles } @@ -349,6 +422,10 @@ export async function createPaseoDaemon( const app = express(); let boundListenTarget: ListenTarget | null = null; let workspaceRegistry: FileBackedWorkspaceRegistry | null = null; + const terminalManager = createConfiguredTerminalManager({ + getTerminalActivityUrl: () => createTerminalActivityUrl(boundListenTarget), + }); + applyTerminalAgentHookSetting({ store: daemonConfigStore, logger }); const serviceProxyPublicBaseUrl = config.serviceProxy?.publicBaseUrl ? config.serviceProxy.publicBaseUrl @@ -433,18 +510,24 @@ export async function createPaseoDaemon( next(); }); + // Local, harmless, and token-gated; deliberately skips daemon auth. + app.post( + "/api/terminal-activity", + express.json(), + createTerminalActivityRouteHandler(terminalManager), + ); + app.use( createRequireBearerMiddleware(config.auth, (context) => { logger.warn(context, "Rejected HTTP request with invalid daemon password"); }), ); + app.use(express.json()); + // Serve static files from public directory app.use("/public", express.static(staticDir)); - // Middleware - app.use(express.json()); - // Health check endpoint app.get("/api/health", (_req, res) => { res.json({ status: "ok", timestamp: new Date().toISOString() }); @@ -541,7 +624,6 @@ export async function createPaseoDaemon( paseoHome: config.paseoHome, logger, }); - const terminalManager = createConfiguredTerminalManager(); const github = createGitHubService(); const workspaceGitService = new WorkspaceGitServiceImpl({ logger, diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index a44e37c34..ee4af864d 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -386,6 +386,7 @@ export function loadConfig( mcpEnabled, mcpInjectIntoAgents, autoArchiveAfterMerge, + enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false, appendSystemPrompt, terminalProfiles, mcpDebug: env.MCP_DEBUG === "1", diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 34f1f4ff2..cd7f90b65 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -97,6 +97,7 @@ describe("DaemonConfigStore", () => { providers: {}, metadataGeneration: { providers: [] }, autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, appendSystemPrompt: "", }, undefined, @@ -128,6 +129,7 @@ describe("DaemonConfigStore", () => { providers: {}, metadataGeneration: { providers: [] }, autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, appendSystemPrompt: "", }, undefined, @@ -152,6 +154,7 @@ describe("DaemonConfigStore", () => { providers: {}, metadataGeneration: { providers: [] }, autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, appendSystemPrompt: "", }, undefined, @@ -192,6 +195,7 @@ describe("DaemonConfigStore", () => { providers: {}, metadataGeneration: { providers: [] }, autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, appendSystemPrompt: "", }, undefined, @@ -205,6 +209,29 @@ describe("DaemonConfigStore", () => { expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies."); }); + test("patch persists enable terminal agent hooks into config.json", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + + const store = new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + }, + undefined, + ); + + store.patch({ enableTerminalAgentHooks: true }); + + const persisted = loadPersistedConfig(paseoHome); + expect(persisted.daemon?.enableTerminalAgentHooks).toBe(true); + }); + test("patch persists metadata generation providers into config.json", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -216,6 +243,7 @@ describe("DaemonConfigStore", () => { providers: {}, metadataGeneration: { providers: [] }, autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, appendSystemPrompt: "", }, undefined, @@ -266,6 +294,7 @@ describe("DaemonConfigStore", () => { mcp: { injectIntoAgents: false }, providers: {}, autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, appendSystemPrompt: "", metadataGeneration: { providers: [{ provider: "claude", model: "haiku" }] }, }, @@ -288,6 +317,7 @@ describe("DaemonConfigStore", () => { mcp: { injectIntoAgents: false }, providers: {}, autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, appendSystemPrompt: "", metadataGeneration: { providers: [] }, }, diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index f8f6c54a7..24ef05b19 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -209,6 +209,7 @@ function mergeMutableConfigIntoPersistedConfig(params: { injectIntoAgents: mutable.mcp.injectIntoAgents, }, autoArchiveAfterMerge: mutable.autoArchiveAfterMerge, + enableTerminalAgentHooks: mutable.enableTerminalAgentHooks, appendSystemPrompt: mutable.appendSystemPrompt, ...(mutable.terminalProfiles !== undefined ? { terminalProfiles: mutable.terminalProfiles } diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 21c8974fd..d8c2488eb 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -223,6 +223,7 @@ export const PersistedConfigSchema = z .passthrough() .optional(), autoArchiveAfterMerge: z.boolean().optional(), + enableTerminalAgentHooks: z.boolean().optional(), appendSystemPrompt: z.string().optional(), terminalProfiles: z.array(TerminalProfileSchema).optional(), cors: z diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index e7875c17c..c4a26420b 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -31,6 +31,7 @@ import { } from "./messages.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import { TerminalSessionController } from "../terminal/terminal-session-controller.js"; +import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; import { type BinaryFrame, encodeFileTransferFrame, @@ -810,11 +811,13 @@ export class Session { private readonly downloadTokenStore: DownloadTokenStore; private readonly pushTokenStore: PushTokenStore; private unsubscribeAgentEvents: (() => void) | null = null; + private unsubscribeTerminalActivityEvents: (() => void) | null = null; private agentUpdatesSubscription: AgentUpdatesSubscriptionState | null = null; private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null; private clientActivity: { deviceType: "web" | "mobile"; focusedAgentId: string | null; + focusedTerminalId: string | null; lastActivityAt: Date; appVisible: boolean; appVisibilityChangedAt: Date; @@ -1004,6 +1007,7 @@ export class Session { projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, listAgentPayloads: () => this.listAgentPayloads(), + listTerminalActivityContributions: () => this.listTerminalActivityContributions(), isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input), }); @@ -1093,6 +1097,7 @@ export class Session { public getClientActivity(): { deviceType: "web" | "mobile"; focusedAgentId: string | null; + focusedTerminalId: string | null; lastActivityAt: Date; appVisible: boolean; appVisibilityChangedAt: Date; @@ -1280,6 +1285,13 @@ export class Session { */ private subscribeToOptionalManagers(): void { this.terminalController.start(); + if (this.terminalManager) { + this.unsubscribeTerminalActivityEvents = this.terminalManager.subscribeTerminalsChanged( + (event) => { + void this.emitWorkspaceUpdateForCwd(event.cwd); + }, + ); + } const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => { // COMPAT(providersSnapshot): keep provider visibility gating for older clients. const visibleEntries = entries.filter((entry) => @@ -4467,20 +4479,38 @@ export class Session { private handleClientHeartbeat(msg: { deviceType: "web" | "mobile"; focusedAgentId: string | null; + focusedTerminalId?: string | null; lastActivityAt: string; appVisible: boolean; appVisibilityChangedAt?: string; }): void { + const focusedTerminalId = msg.focusedTerminalId?.trim() || null; const appVisibilityChangedAt = msg.appVisibilityChangedAt ? new Date(msg.appVisibilityChangedAt) : new Date(msg.lastActivityAt); this.clientActivity = { deviceType: msg.deviceType, focusedAgentId: msg.focusedAgentId, + focusedTerminalId, lastActivityAt: new Date(msg.lastActivityAt), appVisible: msg.appVisible, appVisibilityChangedAt, }; + if (msg.appVisible && focusedTerminalId) { + void this.clearFocusedTerminalAttention(focusedTerminalId); + } + } + + private async clearFocusedTerminalAttention(terminalId: string): Promise { + const terminalManager = this.terminalManager; + if (!terminalManager) { + return; + } + try { + await terminalManager.clearTerminalAttention(terminalId); + } catch (error) { + this.sessionLogger.warn({ err: error, terminalId }, "Failed to clear terminal attention"); + } } /** @@ -6043,6 +6073,23 @@ export class Session { } } + private async listTerminalActivityContributions(): Promise< + Array<{ cwd: string; activity: TerminalActivity | null }> + > { + const terminalManager = this.terminalManager; + if (!terminalManager) { + return []; + } + const directories = terminalManager.listDirectories(); + const terminalsByDirectory = await Promise.all( + directories.map((cwd) => terminalManager.getTerminals(cwd)), + ); + return terminalsByDirectory.flat().map((session) => ({ + cwd: session.cwd, + activity: session.getActivity(), + })); + } + /** * Build the current agent list payload (live + persisted), optionally filtered by labels. */ @@ -8803,6 +8850,10 @@ export class Session { this.unsubscribeAgentEvents(); this.unsubscribeAgentEvents = null; } + if (this.unsubscribeTerminalActivityEvents) { + this.unsubscribeTerminalActivityEvents(); + this.unsubscribeTerminalActivityEvents = null; + } if (this.unsubscribeProviderSnapshotEvents) { this.unsubscribeProviderSnapshotEvents(); this.unsubscribeProviderSnapshotEvents = null; diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index a227e8c22..082a30a4b 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -118,6 +118,7 @@ interface SessionTestAccess { paseoHome: string; terminalManager: { killTerminal(id: string): unknown; + clearTerminalAttention?(id: string): Promise; } | null; workspaceGitService: { getCheckout: (cwd: string) => Promise; @@ -460,6 +461,7 @@ function createSessionForWorkspaceTests( appVersion?: string | null; onMessage?: (message: SessionOutboundMessage) => void; workspaceGitService?: ReturnType; + terminalManager?: ReturnType | null; } = {}, ): TestSession { const logger = { @@ -582,12 +584,41 @@ function createSessionForWorkspaceTests( stt: null, tts: null, providerSnapshotManager: createProviderSnapshotManagerStub().manager, - terminalManager: null, + terminalManager: options.terminalManager ?? null, }), ); return session; } +test("client heartbeat clears attention for the focused terminal", async () => { + const clearedTerminalIds: string[] = []; + const session = createSessionForWorkspaceTests({ + terminalManager: asTerminalManager({ + subscribeTerminalsChanged: () => () => {}, + clearTerminalAttention: async (terminalId: string) => { + clearedTerminalIds.push(terminalId); + return true; + }, + }), + }); + + await session.handleMessage({ + type: "client_heartbeat", + deviceType: "web", + focusedAgentId: null, + focusedTerminalId: "terminal-1", + lastActivityAt: "2026-06-13T12:00:00.000Z", + appVisible: true, + }); + + expect(clearedTerminalIds).toEqual(["terminal-1"]); + expect(session.getClientActivity()).toMatchObject({ + focusedAgentId: null, + focusedTerminalId: "terminal-1", + appVisible: true, + }); +}); + test("create_agent_request keeps requested child cwd when grouped under an existing parent workspace", async () => { const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-cwd-")); try { diff --git a/packages/server/src/server/terminal-activity-route.test.ts b/packages/server/src/server/terminal-activity-route.test.ts new file mode 100644 index 000000000..afc97d77e --- /dev/null +++ b/packages/server/src/server/terminal-activity-route.test.ts @@ -0,0 +1,162 @@ +import { afterEach, expect, it } from "vitest"; +import type express from "express"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js"; +import { createTerminalActivityRouteHandler } from "./bootstrap.js"; + +interface MockResponse { + statusCode: number; + body: unknown; + ended: boolean; + status(code: number): MockResponse; + json(body: unknown): MockResponse; + end(): MockResponse; +} + +async function waitForCondition( + predicate: () => boolean, + timeoutMs: number, + intervalMs = 25, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`); +} + +function createMockResponse(): MockResponse { + return { + statusCode: 200, + body: undefined, + ended: false, + status(code: number): MockResponse { + this.statusCode = code; + return this; + }, + json(body: unknown): MockResponse { + this.body = body; + this.ended = true; + return this; + }, + end(): MockResponse { + this.ended = true; + return this; + }, + }; +} + +function createMockRequest(input: { body: unknown; remoteAddress?: string }): express.Request { + return { + body: input.body, + socket: { + remoteAddress: input.remoteAddress ?? "127.0.0.1", + }, + } as express.Request; +} + +let manager: TerminalManager | null = null; +const temporaryDirs: string[] = []; + +afterEach(async () => { + if (manager) { + const terminalsByCwd = await Promise.all( + manager.listDirectories().map((cwd) => manager!.getTerminals(cwd)), + ); + for (const terminal of terminalsByCwd.flat()) { + await manager.killTerminalAndWait(terminal.id); + } + manager.killAll(); + manager = null; + } + while (temporaryDirs.length > 0) { + const dir = temporaryDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +it("accepts terminalId and token reports through the route into the tracker", async () => { + const cwd = mkdtempSync(join(tmpdir(), "terminal-activity-route-")); + temporaryDirs.push(cwd); + const envPath = join(cwd, "activity-env.json"); + manager = createTerminalManager({ + getTerminalActivityUrl: () => "http://127.0.0.1:6767/api/terminal-activity", + }); + + const session = await manager.createTerminal({ + cwd, + command: process.execPath, + args: [ + "-e", + `require("node:fs").writeFileSync(${JSON.stringify(envPath)}, JSON.stringify({ terminalId: process.env.PASEO_TERMINAL_ID, token: process.env.PASEO_ACTIVITY_TOKEN, url: process.env.PASEO_TERMINAL_ACTIVITY_URL })); setInterval(() => {}, 1000);`, + ], + }); + await waitForCondition(() => existsSync(envPath), 10000); + const env = JSON.parse(readFileSync(envPath, "utf8")) as { + terminalId: string; + token: string; + url: string; + }; + const response = createMockResponse(); + const handler = createTerminalActivityRouteHandler(manager); + + await handler( + createMockRequest({ body: { terminalId: env.terminalId, token: env.token, state: "running" } }), + response as unknown as express.Response, + () => undefined, + ); + + expect(env.terminalId).toBe(session.id); + expect(env.url).toBe("http://127.0.0.1:6767/api/terminal-activity"); + expect(response.statusCode).toBe(204); + expect(session.getActivity()?.state).toBe("working"); +}); + +it("rejects non-loopback activity reports before token handling", async () => { + manager = createTerminalManager(); + const response = createMockResponse(); + const handler = createTerminalActivityRouteHandler(manager); + + await handler( + createMockRequest({ + body: { terminalId: "terminal-1", token: "token", state: "running" }, + remoteAddress: "192.168.1.5", + }), + response as unknown as express.Response, + () => undefined, + ); + + expect(response.statusCode).toBe(403); +}); + +it("uses one rejection for unknown terminals and wrong tokens", async () => { + const cwd = mkdtempSync(join(tmpdir(), "terminal-activity-route-")); + temporaryDirs.push(cwd); + manager = createTerminalManager(); + const session = await manager.createTerminal({ cwd }); + const handler = createTerminalActivityRouteHandler(manager); + const unknownResponse = createMockResponse(); + const invalidResponse = createMockResponse(); + + await handler( + createMockRequest({ body: { terminalId: "unknown", token: "bad", state: "running" } }), + unknownResponse as unknown as express.Response, + () => undefined, + ); + await handler( + createMockRequest({ body: { terminalId: session.id, token: "bad", state: "running" } }), + invalidResponse as unknown as express.Response, + () => undefined, + ); + + expect(unknownResponse.statusCode).toBe(403); + expect(invalidResponse.statusCode).toBe(403); + expect(unknownResponse.body).toEqual(invalidResponse.body); +}); diff --git a/packages/server/src/server/websocket-server.terminal-notifications.test.ts b/packages/server/src/server/websocket-server.terminal-notifications.test.ts new file mode 100644 index 000000000..54d0d92f3 --- /dev/null +++ b/packages/server/src/server/websocket-server.terminal-notifications.test.ts @@ -0,0 +1,449 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Server as HTTPServer } from "http"; +import type pino from "pino"; +import type { AgentManager } from "./agent/agent-manager.js"; +import type { AgentStorage } from "./agent/agent-storage.js"; +import type { DownloadTokenStore } from "./file-download/token-store.js"; +import type { DaemonConfigStore } from "./daemon-config-store.js"; +import type { FileBackedChatService } from "./chat/chat-service.js"; +import type { LoopService } from "./loop-service.js"; +import type { ScheduleService } from "./schedule/service.js"; +import type { CheckoutDiffManager } from "./checkout-diff-manager.js"; +import type { + TerminalActivityListener, + TerminalActivityTransitionEvent, + TerminalManager, +} from "../terminal/terminal-manager.js"; +import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js"; +import { asInternals, createStub } from "./test-utils/class-mocks.js"; +import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; +import type { PushNotificationSender, PushPayload } from "./push/notifications.js"; + +const wsModuleMock = vi.hoisted(() => { + class MockWebSocketServer { + readonly handlers = new Map void>(); + + on(event: string, handler: (...args: unknown[]) => void) { + this.handlers.set(event, handler); + return this; + } + + close() { + // no-op + } + } + + return { MockWebSocketServer }; +}); + +vi.mock("ws", () => ({ + WebSocketServer: wsModuleMock.MockWebSocketServer, +})); + +vi.mock("./session.js", () => ({ + Session: function Session() { + return {}; + }, +})); + +import { VoiceAssistantWebSocketServer } from "./websocket-server.js"; + +class RecordingPushNotificationSender implements PushNotificationSender { + readonly sent: PushPayload[] = []; + + async send(payload: PushPayload): Promise { + this.sent.push(payload); + } +} + +function createLogger() { + const logger = { + child: vi.fn(() => logger), + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + return logger; +} + +function createTerminalManager() { + let listener: TerminalActivityListener | null = null; + + const manager = createStub({ + subscribeTerminalActivity: vi.fn((l: TerminalActivityListener) => { + listener = l; + return () => { + listener = null; + }; + }), + }); + + function emit(event: TerminalActivityTransitionEvent): void { + listener?.(event); + } + + return { manager, emit }; +} + +function workspaceRecord(overrides?: Partial): PersistedWorkspaceRecord { + return { + workspaceId: "ws-1", + projectId: "project-1", + cwd: CWD, + kind: "directory", + displayName: "Project", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + ...overrides, + }; +} + +function createWorkspaceRegistry(records: PersistedWorkspaceRecord[]): WorkspaceRegistry { + return createStub({ + list: vi.fn(async () => records), + }); +} + +function createServer(terminalManager: TerminalManager, workspaceRegistry?: WorkspaceRegistry) { + const pushNotifications = new RecordingPushNotificationSender(); + const agentManager = { + setAgentAttentionCallback: vi.fn(), + getAgent: vi.fn(() => null), + getLastAssistantMessage: vi.fn(async () => null), + getMetricsSnapshot: vi.fn(() => ({ + total: 0, + byLifecycle: {}, + withActiveForegroundTurn: 0, + timelineStats: { + totalItems: 0, + maxItemsPerAgent: 0, + }, + })), + }; + const daemonConfigStore = { + onChange: vi.fn(() => () => {}), + }; + + const server = new VoiceAssistantWebSocketServer( + createStub({}), + createStub(createLogger()), + "srv-test", + createStub(agentManager), + createStub({}), + createStub({}), + "/tmp/paseo-test", + createStub(daemonConfigStore), + null, + { allowedOrigins: new Set() }, + undefined, + undefined, + terminalManager, + undefined, + "1.2.3-test", + undefined, + undefined, + workspaceRegistry, + createStub({}), + createStub({}), + createStub({}), + createStub({ + subscribe: vi.fn(), + scheduleRefreshForCwd: vi.fn(), + getMetrics: vi.fn(() => ({ + checkoutDiffTargetCount: 0, + checkoutDiffSubscriptionCount: 0, + checkoutDiffWatcherCount: 0, + checkoutDiffFallbackRefreshTargetCount: 0, + })), + dispose: vi.fn(), + }), + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + pushNotifications, + createProviderSnapshotManagerStub().manager, + ); + + return { server, pushNotifications }; +} + +function createOpenSocket() { + return { + readyState: 1, + send: vi.fn(), + close: vi.fn(), + on: vi.fn(), + once: vi.fn(), + }; +} + +function connectClient(server: VoiceAssistantWebSocketServer) { + const ws = createOpenSocket(); + asInternals<{ sessions: Map }>(server).sessions.set(ws, { + session: { + getClientActivity: vi.fn(() => null), + }, + clientId: "client-test", + appVersion: null, + connectionLogger: createLogger(), + sockets: new Set([ws]), + externalDisconnectCleanupTimeout: null, + }); + return ws; +} + +function readTerminalAttentionMessage(ws: ReturnType) { + const rawMessage = ws.send.mock.calls[0]?.[0]; + expect(typeof rawMessage).toBe("string"); + if (typeof rawMessage !== "string") throw new Error("Expected string WebSocket frame"); + const message = JSON.parse(rawMessage); + expect(message.type).toBe("session"); + expect(message.message.type).toBe("terminal_attention_required"); + return message.message.payload as { + terminalId: string; + cwd: string; + workspaceId?: string; + shouldNotify: boolean; + reason: "finished" | "needs_input"; + title: string; + body: string; + }; +} + +const CWD = "/home/user/project"; + +// Drain microtasks: the broadcast awaits the workspace registry before sending. +function flushAsync(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +function transition(input: { + previousState: "working" | "idle" | "attention"; + previousChangedAt: number; + state: "working" | "idle" | "attention" | null; + changedAt: number; + id?: string; +}): TerminalActivityTransitionEvent { + return { + terminalId: input.id ?? "term-1", + name: "bash", + cwd: CWD, + activity: input.state ? { state: input.state, changedAt: input.changedAt } : null, + previous: { state: input.previousState, changedAt: input.previousChangedAt }, + }; +} + +describe("VoiceAssistantWebSocketServer terminal attention notifications", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("broadcasts terminal_attention_required after working -> idle", async () => { + const { manager, emit } = createTerminalManager(); + const { server } = createServer(manager); + const ws = connectClient(server); + + emit( + transition({ + previousState: "working", + previousChangedAt: 1000, + state: "idle", + changedAt: 11001, + }), + ); + + // Wait for async broadcast + await flushAsync(); + + const payload = readTerminalAttentionMessage(ws); + expect(payload.terminalId).toBe("term-1"); + expect(payload.cwd).toBe(CWD); + expect(payload.workspaceId).toBeUndefined(); + expect(payload.reason).toBe("finished"); + expect(payload.title).toBe("Terminal finished"); + expect(payload.body).toBe("bash"); + // Client has no recent activity so it is not the in-app recipient. + expect(payload.shouldNotify).toBe(false); + }); + + it("resolves the workspaceId for the terminal cwd into the payload", async () => { + const { manager, emit } = createTerminalManager(); + const { server } = createServer(manager, createWorkspaceRegistry([workspaceRecord()])); + const ws = connectClient(server); + + emit( + transition({ + previousState: "working", + previousChangedAt: 1000, + state: "idle", + changedAt: 11001, + }), + ); + + await flushAsync(); + + expect(readTerminalAttentionMessage(ws).workspaceId).toBe("ws-1"); + }); + + it("resolves the parent workspaceId for a subdirectory terminal cwd", async () => { + const { manager, emit } = createTerminalManager(); + const { server } = createServer(manager, createWorkspaceRegistry([workspaceRecord()])); + const ws = connectClient(server); + + emit({ + ...transition({ + previousState: "working", + previousChangedAt: 1000, + state: "idle", + changedAt: 11001, + }), + cwd: `${CWD}/packages/app`, + }); + + await flushAsync(); + + expect(readTerminalAttentionMessage(ws).workspaceId).toBe("ws-1"); + }); + + it("omits workspaceId for archived or unregistered workspaces", async () => { + const { manager, emit } = createTerminalManager(); + const { server } = createServer( + manager, + createWorkspaceRegistry([ + workspaceRecord({ archivedAt: "2026-02-01T00:00:00.000Z" }), + workspaceRecord({ workspaceId: "ws-other", cwd: "/home/user/other" }), + ]), + ); + const ws = connectClient(server); + + emit( + transition({ + previousState: "working", + previousChangedAt: 1000, + state: "idle", + changedAt: 11001, + }), + ); + + await flushAsync(); + + expect(readTerminalAttentionMessage(ws).workspaceId).toBeUndefined(); + }); + + it("does not broadcast on working -> working (no transition to idle)", async () => { + const { manager, emit } = createTerminalManager(); + const { server } = createServer(manager); + const ws = connectClient(server); + + emit( + transition({ + previousState: "working", + previousChangedAt: 1000, + state: "working", + changedAt: 15000, + }), + ); + + await flushAsync(); + + expect(ws.send).not.toHaveBeenCalled(); + }); + + it("does not broadcast on working -> unknown", async () => { + const { manager, emit } = createTerminalManager(); + const { server } = createServer(manager); + const ws = connectClient(server); + + emit( + transition({ + previousState: "working", + previousChangedAt: 1000, + state: null, + changedAt: 15000, + }), + ); + + await flushAsync(); + + expect(ws.send).not.toHaveBeenCalled(); + }); + + it("broadcasts needs_input after working -> attention", async () => { + const { manager, emit } = createTerminalManager(); + const { server } = createServer(manager); + const ws = connectClient(server); + + emit( + transition({ + previousState: "working", + previousChangedAt: 1000, + state: "attention", + changedAt: 15000, + }), + ); + + await flushAsync(); + + const payload = readTerminalAttentionMessage(ws); + expect(payload.reason).toBe("needs_input"); + expect(payload.title).toBe("Terminal needs input"); + expect(payload.body).toBe("bash"); + }); + + it("broadcasts needs_input after idle -> attention", async () => { + const { manager, emit } = createTerminalManager(); + const { server } = createServer(manager); + const ws = connectClient(server); + + emit( + transition({ + previousState: "idle", + previousChangedAt: 1000, + state: "attention", + changedAt: 15000, + }), + ); + + await flushAsync(); + + const payload = readTerminalAttentionMessage(ws); + expect(payload.reason).toBe("needs_input"); + expect(payload.title).toBe("Terminal needs input"); + }); + + it("sends push notification when no clients are present", async () => { + const { manager, emit } = createTerminalManager(); + const { pushNotifications } = createServer( + manager, + createWorkspaceRegistry([workspaceRecord()]), + ); + // No connected clients + + emit( + transition({ + previousState: "working", + previousChangedAt: 0, + state: "idle", + changedAt: 15000, + }), + ); + + await flushAsync(); + + expect(pushNotifications.sent).toHaveLength(1); + expect(pushNotifications.sent[0]?.title).toBe("Terminal finished"); + expect(pushNotifications.sent[0]?.data).toMatchObject({ + serverId: "srv-test", + terminalId: "term-1", + workspaceId: "ws-1", + }); + }); +}); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 9bace4441..81285e646 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -9,6 +9,7 @@ import type { DownloadTokenStore } from "./file-download/token-store.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type pino from "pino"; import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js"; +import { resolveActiveWorkspaceRecordForCwd } from "./workspace-registry-model.js"; import type { FileBackedChatService } from "./chat/chat-service.js"; import type { LoopService } from "./loop-service.js"; import type { ScheduleService } from "./schedule/service.js"; @@ -40,7 +41,11 @@ import type { ServiceProxySubsystem } from "./service-proxy.js"; import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { SpeechReadinessSnapshot, SpeechService } from "./speech/speech-runtime.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js"; -import { computeNotificationPlan, type ClientPresenceState } from "./agent-attention-policy.js"; +import { + computeNotificationPlan, + isPushEligibleAttentionReason, + type ClientPresenceState, +} from "./agent-attention-policy.js"; import { buildAgentAttentionNotificationPayload, findLatestPermissionRequest, @@ -76,6 +81,21 @@ interface WebSocketServerConfig { type WebSocketRuntimeMetrics = SessionRuntimeMetrics & CheckoutDiffMetrics; +type TerminalAttentionReason = "finished" | "needs_input"; + +function resolveTerminalAttentionReason(input: { + previousState: "working" | "idle" | "attention" | null; + state: "working" | "idle" | "attention" | null; +}): TerminalAttentionReason | null { + if (input.state === "attention") return "needs_input"; + if (input.previousState === "working" && input.state === "idle") return "finished"; + return null; +} + +function terminalAttentionTitle(reason: TerminalAttentionReason): string { + return reason === "needs_input" ? "Terminal needs input" : "Terminal finished"; +} + function createFallbackWorkspaceGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot { return { cwd, @@ -383,6 +403,7 @@ export class VoiceAssistantWebSocketServer { private eventLoopDelayMonitor: ReturnType | null = null; private unsubscribeSpeechReadiness: (() => void) | null = null; private unsubscribeDaemonConfigChange: (() => void) | null = null; + private unsubscribeTerminalActivity: (() => void) | null = null; constructor( server: HTTPServer, @@ -530,6 +551,28 @@ export class VoiceAssistantWebSocketServer { }): void { this.speech = params.speech ?? null; this.terminalManager = params.terminalManager ?? null; + if (this.terminalManager) { + this.unsubscribeTerminalActivity = this.terminalManager.subscribeTerminalActivity((event) => { + const reason = resolveTerminalAttentionReason({ + previousState: event.previous?.state ?? null, + state: event.activity?.state ?? null, + }); + if (!reason) { + return; + } + void this.broadcastTerminalAttention({ + terminalId: event.terminalId, + cwd: event.cwd, + terminalName: event.name, + reason, + }).catch((err) => { + this.logger.warn( + { err, terminalId: event.terminalId }, + "Failed to broadcast terminal attention", + ); + }); + }); + } this.dictation = params.dictation ?? null; this.onLifecycleIntent = params.onLifecycleIntent ?? null; this.serviceProxy = params.serviceProxy ?? null; @@ -694,6 +737,8 @@ export class VoiceAssistantWebSocketServer { this.unsubscribeSpeechReadiness = null; this.unsubscribeDaemonConfigChange?.(); this.unsubscribeDaemonConfigChange = null; + this.unsubscribeTerminalActivity?.(); + this.unsubscribeTerminalActivity = null; if (this.runtimeMetricsInterval) { clearInterval(this.runtimeMetricsInterval); this.runtimeMetricsInterval = null; @@ -1695,6 +1740,7 @@ export class VoiceAssistantWebSocketServer { return { appVisible: false, focusedAgentId: null, + focusedTerminalId: null, lastActivityAtMs: null, }; } @@ -1702,6 +1748,7 @@ export class VoiceAssistantWebSocketServer { return { appVisible: activity.appVisible, focusedAgentId: activity.focusedAgentId, + focusedTerminalId: activity.focusedTerminalId, lastActivityAtMs: activity.lastActivityAt.getTime(), }; } @@ -1737,8 +1784,8 @@ export class VoiceAssistantWebSocketServer { const plan = computeNotificationPlan({ allStates, - agentId: params.agentId, - reason: params.reason, + focusTarget: { kind: "agent", id: params.agentId }, + pushEligible: isPushEligibleAttentionReason(params.reason), nowMs, }); @@ -1770,6 +1817,82 @@ export class VoiceAssistantWebSocketServer { this.sendToClient(ws, message); } } + + private async resolveWorkspaceIdForCwd(cwd: string): Promise { + const workspaces = await this.workspaceRegistry.list(); + return resolveActiveWorkspaceRecordForCwd(cwd, workspaces)?.workspaceId; + } + + private async broadcastTerminalAttention(params: { + terminalId: string; + cwd: string; + terminalName: string; + reason: TerminalAttentionReason; + }): Promise { + const clientEntries: Array<{ + ws: WebSocketLike; + state: ClientPresenceState; + }> = []; + + for (const [ws, connection] of this.sessions) { + clientEntries.push({ + ws, + state: this.getClientActivityState(connection.session), + }); + } + + const allStates = clientEntries.map((e) => e.state); + const nowMs = Date.now(); + const workspaceId = await this.resolveWorkspaceIdForCwd(params.cwd); + + const plan = computeNotificationPlan({ + allStates, + focusTarget: { kind: "terminal", id: params.terminalId }, + pushEligible: true, + nowMs, + }); + + const title = terminalAttentionTitle(params.reason); + const body = params.terminalName; + + if (plan.shouldPush) { + void this.pushNotificationSender + .send({ + title, + body, + data: { + serverId: this.serverId, + terminalId: params.terminalId, + cwd: params.cwd, + ...(workspaceId ? { workspaceId } : {}), + }, + }) + .catch((err) => { + this.logger.warn( + { err, terminalId: params.terminalId }, + "Failed to send push notification", + ); + }); + } + + for (const [clientIndex, { ws }] of clientEntries.entries()) { + const shouldNotify = clientIndex === plan.inAppRecipientIndex; + const message = wrapSessionMessage({ + type: "terminal_attention_required", + payload: { + serverId: this.serverId, + terminalId: params.terminalId, + cwd: params.cwd, + ...(workspaceId ? { workspaceId } : {}), + reason: params.reason, + title, + body, + shouldNotify, + }, + }); + this.sendToClient(ws, message); + } + } } interface SocketRequestMetadata { diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 307888387..0c8d74c22 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -4,6 +4,7 @@ import { createTestLogger } from "../test-utils/test-logger.js"; import type { AgentSnapshotPayload, WorkspaceDescriptorPayload } from "./messages.js"; import { WorkspaceDirectory } from "./workspace-directory.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; +import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; const NOW = "2026-03-01T12:00:00.000Z"; @@ -44,11 +45,13 @@ class WorkspaceStatus { private readonly workspaces = [this.workspace]; private readonly agents: AgentSnapshotPayload[] = []; + private readonly terminals: Array<{ cwd: string; activity: TerminalActivity | null }> = []; private readonly directory = new WorkspaceDirectory({ logger: createTestLogger(), projectRegistry: { list: async () => [this.project] }, workspaceRegistry: { list: async () => this.workspaces }, listAgentPayloads: async () => this.agents, + listTerminalActivityContributions: async () => this.terminals, isProviderVisibleToClient: () => true, buildWorkspaceDescriptor: async ({ workspace }) => ({ id: workspace.workspaceId, @@ -113,6 +116,46 @@ class WorkspaceStatus { }); return Object.fromEntries(entries.entries.map((entry) => [entry.id, entry.status])); } + + hasWorkingTerminal(changedAt: number): void { + this.terminals.push({ + cwd: this.workspace.cwd, + activity: { state: "working", changedAt }, + }); + } + + hasWorkingTerminalInSubdirectory(changedAt: number): void { + this.terminals.push({ + cwd: `${this.workspace.cwd}/packages/app`, + activity: { state: "working", changedAt }, + }); + } + + hasIdleTerminal(changedAt: number): void { + this.terminals.push({ + cwd: this.workspace.cwd, + activity: { state: "idle", changedAt }, + }); + } + + hasUnknownTerminal(): void { + this.terminals.push({ + cwd: this.workspace.cwd, + activity: null, + }); + } + + async workspaceDescriptor(): Promise { + const entries = await this.directory.listFetchEntries({ + type: "fetch_workspaces_request", + requestId: "workspace-descriptor", + }); + const entry = entries.entries[0]; + if (!entry) { + throw new Error("No workspace descriptor found"); + } + return entry; + } } interface AgentState { @@ -198,4 +241,75 @@ describe("WorkspaceDirectory", () => { "workspace-worktree": "done", }); }); + + test("working terminal contributes running status, beating done", async () => { + const workspace = new WorkspaceStatus(); + const changedAt = new Date(NOW).getTime(); + + workspace.hasWorkingTerminal(changedAt); + + await expect(workspace.workspaceStatus()).resolves.toBe("running"); + }); + + test("working terminal in a subdirectory contributes to the parent workspace", async () => { + const workspace = new WorkspaceStatus(); + const changedAt = new Date(NOW).getTime(); + + workspace.hasWorkingTerminalInSubdirectory(changedAt); + + await expect(workspace.workspaceStatus()).resolves.toBe("running"); + }); + + test("idle terminal contributes nothing to workspace status", async () => { + const workspace = new WorkspaceStatus(); + const changedAt = new Date(NOW).getTime(); + + workspace.hasIdleTerminal(changedAt); + + await expect(workspace.workspaceStatus()).resolves.toBe("done"); + }); + + test("unknown terminal contributes nothing to workspace status", async () => { + const workspace = new WorkspaceStatus(); + + workspace.hasUnknownTerminal(); + + await expect(workspace.workspaceStatus()).resolves.toBe("done"); + }); + + test("working terminal does not override a higher-priority needs_input agent", async () => { + const workspace = new WorkspaceStatus(); + const changedAt = new Date(NOW).getTime(); + + workspace.hasRootAgent({ id: "agent-needs-input", status: "idle", pendingPermissionCount: 1 }); + workspace.hasWorkingTerminal(changedAt); + + await expect(workspace.workspaceStatus()).resolves.toBe("needs_input"); + }); + + test("working terminal statusEnteredAt uses terminal changedAt", async () => { + const workspace = new WorkspaceStatus(); + const changedAt = new Date("2026-05-01T10:00:00.000Z").getTime(); + + workspace.hasWorkingTerminal(changedAt); + + const descriptor = await workspace.workspaceDescriptor(); + expect(descriptor.status).toBe("running"); + expect(descriptor.statusEnteredAt).toBe("2026-05-01T10:00:00.000Z"); + }); + + test("statusEnteredAt picks the newest between agent updatedAt and terminal changedAt", async () => { + const workspace = new WorkspaceStatus(); + // The createAgent helper uses NOW for updatedAt; use a terminal timestamp + // that is newer to confirm it wins. + const terminalChangedAt = new Date("2027-01-01T00:00:00.000Z").getTime(); + + workspace.hasRootAgent({ id: "running-agent", status: "running" }); + workspace.hasWorkingTerminal(terminalChangedAt); + + const descriptor = await workspace.workspaceDescriptor(); + expect(descriptor.status).toBe("running"); + // terminal timestamp (2027) is newer than agent updatedAt (NOW = 2026-03-01) + expect(descriptor.statusEnteredAt).toBe("2027-01-01T00:00:00.000Z"); + }); }); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 229e5cccb..411430747 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -15,6 +15,10 @@ import { import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol/agent-labels"; import { SortablePager } from "./pagination/sortable-pager.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; +import { resolveActiveWorkspaceRecordForCwd } from "./workspace-registry-model.js"; +import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; + +type WorkspaceIdResolver = (cwd: string) => string | undefined; const FETCH_WORKSPACES_SORT_KEYS = [ "status_priority", @@ -87,6 +91,9 @@ export interface WorkspaceDirectoryDeps { list(): Promise; }; listAgentPayloads(): Promise; + listTerminalActivityContributions(): Promise< + Array<{ cwd: string; activity: TerminalActivity | null }> + >; isProviderVisibleToClient(provider: string): boolean; buildWorkspaceDescriptor(input: { workspace: PersistedWorkspaceRecord; @@ -182,11 +189,13 @@ export class WorkspaceDirectory { includeGitData: boolean; workspaceIds?: Iterable; }): Promise> { - const [agents, persistedWorkspaces, persistedProjects] = await Promise.all([ - this.deps.listAgentPayloads(), - this.deps.workspaceRegistry.list(), - this.deps.projectRegistry.list(), - ]); + const [agents, persistedWorkspaces, persistedProjects, terminalContributions] = + await Promise.all([ + this.deps.listAgentPayloads(), + this.deps.workspaceRegistry.list(), + this.deps.projectRegistry.list(), + this.deps.listTerminalActivityContributions(), + ]); const activeProjects = new Map( persistedProjects @@ -204,6 +213,8 @@ export class WorkspaceDirectory { const workspaceIdsByDirectory = new Map( activeRecords.map((workspace) => [resolve(workspace.cwd), workspace.workspaceId] as const), ); + const resolveActiveWorkspaceIdForCwd: WorkspaceIdResolver = (cwd) => + resolveActiveWorkspaceRecordForCwd(cwd, activeRecords)?.workspaceId; const includedWorkspaces = activeRecords.filter( (workspace) => !workspaceIds || workspaceIds.has(workspace.workspaceId), @@ -268,6 +279,13 @@ export class WorkspaceDirectory { } } + // Terminal activity contributions: working terminal → running bucket. + const terminalEntriesByWorkspaceId = this.applyTerminalContributions( + terminalContributions, + resolveActiveWorkspaceIdForCwd, + descriptorsByWorkspaceId, + ); + // Resolve the workspace-level `statusEnteredAt` (see aggregate semantics // on `resolveStatusEnteredAt`). const nowIso = new Date().toISOString(); @@ -278,10 +296,12 @@ export class WorkspaceDirectory { this.deps.isProviderVisibleToClient(agent.provider) && workspaceIdsByDirectory.get(resolve(agent.cwd)) === workspaceId, ); + const terminalEntries = terminalEntriesByWorkspaceId.get(workspaceId) ?? []; const result = this.resolveStatusEnteredAt({ workspaceId, winningBucket: descriptor.status, contributingAgents, + terminalEntries, previous: this.bucketHistoryByWorkspaceId.get(workspaceId) ?? null, nowIso, }); @@ -296,14 +316,57 @@ export class WorkspaceDirectory { return descriptorsByWorkspaceId; } + // Apply working terminal contributions to descriptor statuses and build a map + // of terminal timestamp entries per workspace for use in `resolveStatusEnteredAt`. + private applyTerminalContributions( + terminalContributions: Array<{ cwd: string; activity: TerminalActivity | null }>, + resolveWorkspaceIdForCwd: WorkspaceIdResolver, + descriptorsByWorkspaceId: Map, + ): Map> { + const terminalEntriesByWorkspaceId = new Map< + string, + Array<{ bucket: WorkspaceStateBucket; changedAtIso: string }> + >(); + for (const { cwd, activity } of terminalContributions) { + if (!activity) { + continue; + } + let bucket: WorkspaceStateBucket; + if (activity.state === "working") { + bucket = "running"; + } else if (activity.state === "attention") { + bucket = "needs_input"; + } else { + continue; + } + const workspaceId = resolveWorkspaceIdForCwd(cwd); + if (workspaceId === undefined) { + continue; + } + const existing = descriptorsByWorkspaceId.get(workspaceId); + if (!existing) { + continue; + } + if ( + getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status) + ) { + existing.status = bucket; + } + const entries = terminalEntriesByWorkspaceId.get(workspaceId) ?? []; + entries.push({ bucket, changedAtIso: new Date(activity.changedAt).toISOString() }); + terminalEntriesByWorkspaceId.set(workspaceId, entries); + } + return terminalEntriesByWorkspaceId; + } + // Aggregate the workspace-level `statusEnteredAt` from its contributing - // agents. Aggregate semantics: - // - winning bucket = highest-priority across contributing agents; - // - entry time = best-effort timestamp from agents in the winning bucket; + // agents and terminals. Aggregate semantics: + // - winning bucket = highest-priority across contributing agents and terminals; + // - entry time = best-effort timestamp from agents/terminals in the winning bucket; // - priority unmasking: when the winning bucket transitions (e.g. a // higher-priority bucket cleared), the new entry time is "now"; // - same-bucket emits reuse the previous entered-at; - // - empty workspaces that never had contributing agents get + // - empty workspaces that never had contributing agents or terminals get // `statusEnteredAt: null`. // - when archived agents leave a previously active workspace empty, keep // the previous done timestamp or stamp the transition to done now. @@ -311,6 +374,7 @@ export class WorkspaceDirectory { workspaceId: string; winningBucket: WorkspaceStateBucket; contributingAgents: AgentSnapshotPayload[]; + terminalEntries: Array<{ bucket: WorkspaceStateBucket; changedAtIso: string }>; previous: WorkspaceBucketHistoryEntry | null; nowIso: string; }): { @@ -318,9 +382,9 @@ export class WorkspaceDirectory { recordUpdate?: WorkspaceBucketHistoryEntry; recordDelete?: true; } { - const { winningBucket, contributingAgents, previous, nowIso } = params; + const { winningBucket, contributingAgents, terminalEntries, previous, nowIso } = params; - if (contributingAgents.length === 0) { + if (contributingAgents.length === 0 && terminalEntries.length === 0) { if (!previous) { return { statusEnteredAt: null }; } @@ -333,8 +397,9 @@ export class WorkspaceDirectory { } if (!previous) { - const newestInWinningBucket = this.findNewestAgentTimestampInBucket( + const newestInWinningBucket = this.findNewestTimestampInBucket( contributingAgents, + terminalEntries, winningBucket, ); const enteredAt = newestInWinningBucket ?? nowIso; @@ -357,16 +422,17 @@ export class WorkspaceDirectory { }; } - // Best-effort newest timestamp across contributing agents whose derived - // bucket matches `winningBucket`. Uses available agent fields: + // Best-effort newest timestamp across contributing agents and terminal entries + // whose bucket matches `winningBucket`. For agents, uses: // - `attentionTimestamp` when attention is set (covers attention/failed) // - `updatedAt` as a general fallback for any bucket - // Returns `null` if no matching agent has a parseable timestamp. - private findNewestAgentTimestampInBucket( + // Returns `null` if no matching contributor has a parseable timestamp. + private findNewestTimestampInBucket( contributingAgents: AgentSnapshotPayload[], + terminalEntries: Array<{ bucket: WorkspaceStateBucket; changedAtIso: string }>, winningBucket: WorkspaceStateBucket, ): string | null { - const candidates = contributingAgents + const agentTimestamps = contributingAgents .filter((agent) => { const derived = deriveAgentStateBucket({ status: agent.status, @@ -385,8 +451,13 @@ export class WorkspaceDirectory { // Fall back to updatedAt as a general proxy for recent activity. return agent.updatedAt; }) - .filter((value): value is string => typeof value === "string" && value.length > 0) - .sort(); + .filter((value): value is string => typeof value === "string" && value.length > 0); + + const terminalTimestamps = terminalEntries + .filter((entry) => entry.bucket === winningBucket) + .map((entry) => entry.changedAtIso); + + const candidates = [...agentTimestamps, ...terminalTimestamps].sort(); return candidates.at(-1) ?? null; } diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 08af1ad27..39e83d59c 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -1,4 +1,5 @@ import { randomBytes } from "node:crypto"; +import { homedir } from "node:os"; import { resolve } from "node:path"; import type { @@ -6,6 +7,7 @@ import type { ProjectPlacementPayload, } from "@getpaseo/protocol/messages"; import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; +import { isSameOrDescendantPath } from "./path-utils.js"; import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; export type PersistedProjectKind = "git" | "non_git"; @@ -32,6 +34,28 @@ export function generateWorkspaceId(): string { return `wks_${randomBytes(8).toString("hex")}`; } +export function resolveActiveWorkspaceRecordForCwd( + cwd: string, + workspaces: Iterable, +): PersistedWorkspaceRecord | null { + const resolvedCwd = resolve(cwd); + const userHome = resolve(homedir()); + let bestMatch: { workspace: PersistedWorkspaceRecord; cwd: string } | null = null; + + for (const workspace of workspaces) { + if (workspace.archivedAt) continue; + + const workspaceCwd = resolve(workspace.cwd); + if (workspaceCwd === userHome && resolvedCwd !== workspaceCwd) continue; + if (!isSameOrDescendantPath(workspaceCwd, resolvedCwd)) continue; + if (!bestMatch || workspaceCwd.length > bestMatch.cwd.length) { + bestMatch = { workspace, cwd: workspaceCwd }; + } + } + + return bestMatch?.workspace ?? null; +} + // Path-derived grouping key for a workspace directory. This is NOT the opaque // workspace identity (see generateWorkspaceId); never persist or compare it as one. export function deriveWorkspaceDirectoryKey( diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index f9a691a52..b2624d173 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -382,8 +382,11 @@ describe("runAsyncWorktreeBootstrap", () => { }), kill: () => {}, onTitleChange: () => () => {}, + onActivityChange: () => () => {}, getSize: () => ({ rows: 1, cols: 1 }), getTitle: () => undefined, + getActivity: () => null, + setActivity: () => {}, getExitInfo: () => null, killAndWait: async () => {}, }; @@ -391,11 +394,26 @@ describe("runAsyncWorktreeBootstrap", () => { return session; }, registerCwdEnv() {}, + validateTerminalActivityToken() { + return "unknown" as const; + }, getTerminal(id) { return sessionsById.get(id); }, + async getTerminalState() { + return null; + }, + setTerminalTitle() { + return false; + }, + async setTerminalActivity() { + return false; + }, killTerminal() {}, async killTerminalAndWait() {}, + async captureTerminal() { + return { lines: [], totalLines: 0 }; + }, listDirectories() { return []; }, @@ -403,6 +421,9 @@ describe("runAsyncWorktreeBootstrap", () => { subscribeTerminalsChanged() { return () => {}; }, + subscribeTerminalActivity() { + return () => {}; + }, }; } diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 43900df46..587e0e4bf 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -195,6 +195,7 @@ function createTerminalManagerStub(options?: { onExit: () => () => {}, onCommandFinished: () => () => {}, onTitleChange: () => () => {}, + onActivityChange: () => () => {}, send: (message: { type: string; data: string }) => { if (message.type === "input") { sent.push(message.data); @@ -204,6 +205,8 @@ function createTerminalManagerStub(options?: { killAndWait: async () => {}, getSize: () => ({ rows: 1, cols: 1 }), getTitle: () => undefined, + getActivity: () => null, + setActivity: () => {}, getExitInfo: () => null, } satisfies TerminalSession; terminals.push({ @@ -216,13 +219,19 @@ function createTerminalManagerStub(options?: { return terminal; }, ), + validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminals: vi.fn(async () => []), getTerminal: vi.fn(() => undefined), killTerminal: vi.fn(), killTerminalAndWait: vi.fn(async () => {}), + setTerminalTitle: vi.fn(), + setTerminalActivity: vi.fn(async () => false), + getTerminalState: vi.fn(async () => null), + captureTerminal: vi.fn(async () => ({ lines: [], totalLines: 0 })), listDirectories: vi.fn(() => []), killAll: vi.fn(), subscribeTerminalsChanged: vi.fn(() => () => {}), + subscribeTerminalActivity: vi.fn(() => () => {}), } satisfies TerminalManager, }; } diff --git a/packages/server/src/terminal/activity/terminal-activity-tracker.test.ts b/packages/server/src/terminal/activity/terminal-activity-tracker.test.ts new file mode 100644 index 000000000..de1c6dc73 --- /dev/null +++ b/packages/server/src/terminal/activity/terminal-activity-tracker.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest"; +import { TerminalActivityTracker } from "./terminal-activity-tracker.js"; +import type { TerminalActivitySnapshot } from "./terminal-activity-tracker.js"; + +describe("TerminalActivityTracker — initial state", () => { + it("starts as unknown", () => { + const tracker = new TerminalActivityTracker(); + + expect(tracker.getSnapshot().state).toBeNull(); + }); +}); + +describe("TerminalActivityTracker — set", () => { + it("updates the snapshot state", () => { + const tracker = new TerminalActivityTracker(); + + tracker.set("working"); + + expect(tracker.getSnapshot().state).toBe("working"); + }); +}); + +describe("TerminalActivityTracker — clearAttention", () => { + it("moves attention back to idle", () => { + const tracker = new TerminalActivityTracker(); + + tracker.set("attention"); + + expect(tracker.clearAttention()).toBe(true); + expect(tracker.getSnapshot().state).toBe("idle"); + }); + + it("leaves non-attention states unchanged", () => { + const tracker = new TerminalActivityTracker(); + + tracker.set("working"); + + expect(tracker.clearAttention()).toBe(false); + expect(tracker.getSnapshot().state).toBe("working"); + }); +}); + +describe("TerminalActivityTracker — onChange listener", () => { + it("fires when state changes", () => { + const tracker = new TerminalActivityTracker(); + const changes: TerminalActivitySnapshot[] = []; + tracker.onChange((snap) => changes.push(snap)); + + tracker.set("working"); + + expect(changes).toHaveLength(1); + expect(changes[0].state).toBe("working"); + }); + + it("does not fire when state stays the same", () => { + const tracker = new TerminalActivityTracker(); + const changes: TerminalActivitySnapshot[] = []; + tracker.onChange((snap) => changes.push(snap)); + + tracker.clear(); + + expect(changes).toHaveLength(0); + }); + + it("fires when state clears to unknown", () => { + const tracker = new TerminalActivityTracker(); + const changes: TerminalActivitySnapshot[] = []; + tracker.onChange((snap) => changes.push(snap)); + + tracker.set("working"); + tracker.clear(); + + expect(changes.map((change) => change.state)).toEqual(["working", null]); + }); + + it("listener can be unsubscribed", () => { + const tracker = new TerminalActivityTracker(); + const changes: TerminalActivitySnapshot[] = []; + const off = tracker.onChange((snap) => changes.push(snap)); + + tracker.set("working"); + expect(changes).toHaveLength(1); + + off(); + tracker.set("idle"); + + expect(changes).toHaveLength(1); + }); + + it("delivers the previous snapshot alongside each transition", () => { + const tracker = new TerminalActivityTracker(); + const transitions: Array<{ + snapshot: TerminalActivitySnapshot; + previous: TerminalActivitySnapshot; + }> = []; + tracker.onChange((snapshot, previous) => transitions.push({ snapshot, previous })); + + tracker.set("working"); + tracker.set("idle"); + + expect(transitions).toHaveLength(2); + 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"); + }); +}); + +describe("TerminalActivityTracker — dispose", () => { + it("removes listeners", () => { + const tracker = new TerminalActivityTracker(); + const listener = vi.fn(); + tracker.onChange(listener); + + tracker.dispose(); + tracker.set("working"); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/terminal/activity/terminal-activity-tracker.ts b/packages/server/src/terminal/activity/terminal-activity-tracker.ts new file mode 100644 index 000000000..82d3a410b --- /dev/null +++ b/packages/server/src/terminal/activity/terminal-activity-tracker.ts @@ -0,0 +1,67 @@ +import type { TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; + +export interface TerminalActivitySnapshot { + state: TerminalActivityState | 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 changedAt = Date.now(); + + private readonly changeListeners = new Set< + (snapshot: TerminalActivitySnapshot, previous: TerminalActivitySnapshot) => void + >(); + + set(state: TerminalActivityState): void { + this.setState(state); + } + + clear(): void { + this.setState(null); + } + + clearAttention(): boolean { + if (this.resolvedState !== "attention") { + return false; + } + this.setState("idle"); + return true; + } + + private setState(state: TerminalActivityState | null): void { + if (state === this.resolvedState) { + return; + } + + const previous = this.getSnapshot(); + this.resolvedState = state; + this.changedAt = Date.now(); + + const snapshot = this.getSnapshot(); + for (const listener of Array.from(this.changeListeners)) { + listener(snapshot, previous); + } + } + + onChange( + listener: (snapshot: TerminalActivitySnapshot, previous: TerminalActivitySnapshot) => void, + ): () => void { + this.changeListeners.add(listener); + return () => { + this.changeListeners.delete(listener); + }; + } + + getSnapshot(): TerminalActivitySnapshot { + return { + state: this.resolvedState, + changedAt: this.changedAt, + }; + } + + dispose(): void { + this.changeListeners.clear(); + } +} diff --git a/packages/server/src/terminal/agent-hooks/agent-hook-installer.ts b/packages/server/src/terminal/agent-hooks/agent-hook-installer.ts new file mode 100644 index 000000000..bb660016c --- /dev/null +++ b/packages/server/src/terminal/agent-hooks/agent-hook-installer.ts @@ -0,0 +1,247 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import { writePrivateFileAtomicSync } from "../../server/private-files.js"; + +export interface AgentHookEventDefinition { + event: string; +} + +export type AgentHookActivityState = "running" | "idle" | "needs-input"; + +export interface AgentHookActivityInput { + isTTY?: boolean; + read(): Promise; +} + +export interface AgentHookProvider { + id: string; + events: AgentHookEventDefinition[]; + resolveActivity(input: { + event: string; + input: AgentHookActivityInput; + }): Promise; + install: AgentHookInstallStrategy; +} + +export type AgentHookInstallStrategy = + | AgentHookConfigFileInstallStrategy + | AgentHookPluginFileInstallStrategy; + +interface AgentHookInstallStrategyBase { + kind: "config-file" | "plugin-file"; + configDir: string; + configDirBase?: "home" | "xdg-config"; + configFile: string; + configDirEnvOverride?: string; + hookMarker: string; +} + +export interface AgentHookConfigFileInstallStrategy extends AgentHookInstallStrategyBase { + kind: "config-file"; + format: AgentHookConfigFormat; +} + +export interface AgentHookPluginFileInstallStrategy extends AgentHookInstallStrategyBase { + kind: "plugin-file"; + source: string; +} + +export interface AgentHookConfigFormat { + empty(): TConfig; + parse(raw: string): TConfig; + stringify(config: TConfig): string; + install(config: TConfig, provider: AgentHookProvider): TConfig; + uninstall(config: TConfig, provider: AgentHookProvider): TConfig; + isInstalled(config: TConfig, provider: AgentHookProvider): boolean; +} + +export interface AgentHookInstallOptions { + env?: NodeJS.ProcessEnv; + homeDir?: string; + configDir?: string; +} + +export interface AgentHookInstallLogger { + warn(bindings: Record, message: string): void; +} + +export interface AgentHookInstallResult { + configPath: string; + changed: boolean; +} + +export function installAgentHooks( + provider: AgentHookProvider, + options: AgentHookInstallOptions = {}, +): AgentHookInstallResult { + if (provider.install.kind === "plugin-file") { + return installAgentHookPluginFile(provider.install, options); + } + + const format = provider.install.format; + return updateAgentHookConfig(provider, options, (config) => format.install(config, provider)); +} + +export function uninstallAgentHooks( + provider: AgentHookProvider, + options: AgentHookInstallOptions = {}, +): AgentHookInstallResult { + if (provider.install.kind === "plugin-file") { + return uninstallAgentHookPluginFile(provider.install, options); + } + + const format = provider.install.format; + return updateAgentHookConfig(provider, options, (config) => format.uninstall(config, provider)); +} + +export function agentHooksAreInstalled( + provider: AgentHookProvider, + options: AgentHookInstallOptions = {}, +): boolean { + const configPath = resolveAgentHookConfigPath(provider, options); + if (!existsSync(configPath)) { + return false; + } + if (provider.install.kind === "plugin-file") { + const currentRaw = readFileSync(configPath, "utf8"); + return normalizeRawConfig(currentRaw) === normalizeRawConfig(provider.install.source); + } + + const config = provider.install.format.parse(readFileSync(configPath, "utf8")); + return provider.install.format.isInstalled(config, provider); +} + +export function resolveAgentHookConfigPath( + provider: AgentHookProvider, + options: AgentHookInstallOptions = {}, +): string { + return resolveAgentHookInstallPath(provider.install, options); +} + +function resolveAgentHookInstallPath( + install: AgentHookInstallStrategy, + options: AgentHookInstallOptions, +): string { + const configDir = + options.configDir ?? + resolveConfiguredDirectory({ + install, + env: options.env ?? process.env, + homeDir: options.homeDir ?? homedir(), + }); + return path.join(configDir, install.configFile); +} + +export function buildAgentHookShellCommand( + provider: AgentHookProvider, + event: AgentHookEventDefinition, +): string { + const hookCommand = `"\${PASEO_HOOK_CLI:-paseo}" hooks ${shellToken(provider.id)} ${shellToken(event.event)}`; + return `[ -n "$PASEO_TERMINAL_ID" ] && ${hookCommand}`; +} + +export function buildAgentHookWindowsCommand( + provider: AgentHookProvider, + event: AgentHookEventDefinition, +): string { + const hookArgs = `hooks ${windowsToken(provider.id)} ${windowsToken(event.event)}`; + return `if defined PASEO_TERMINAL_ID (if defined PASEO_HOOK_CLI ("%PASEO_HOOK_CLI%" ${hookArgs}) else (paseo ${hookArgs}))`; +} + +function installAgentHookPluginFile( + install: AgentHookPluginFileInstallStrategy, + options: AgentHookInstallOptions, +): AgentHookInstallResult { + const configPath = resolveAgentHookInstallPath(install, options); + const currentRaw = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; + const nextRaw = normalizeRawConfig(install.source); + + if (currentRaw === null || normalizeRawConfig(currentRaw) !== nextRaw) { + writePrivateFileAtomicSync(configPath, nextRaw); + return { configPath, changed: true }; + } + + return { configPath, changed: false }; +} + +function uninstallAgentHookPluginFile( + install: AgentHookPluginFileInstallStrategy, + options: AgentHookInstallOptions, +): AgentHookInstallResult { + const configPath = resolveAgentHookInstallPath(install, options); + if (!existsSync(configPath)) { + return { configPath, changed: false }; + } + + rmSync(configPath, { force: true }); + return { configPath, changed: true }; +} + +function updateAgentHookConfig( + provider: AgentHookProvider, + options: AgentHookInstallOptions, + update: (config: TConfig) => TConfig, +): AgentHookInstallResult { + const install = provider.install; + if (install.kind !== "config-file") { + throw new Error(`Provider ${provider.id} does not use config-file hooks`); + } + const configPath = resolveAgentHookConfigPath(provider, options); + const currentRaw = existsSync(configPath) ? readFileSync(configPath, "utf8") : null; + const currentConfig = + currentRaw === null ? install.format.empty() : install.format.parse(currentRaw); + const nextConfig = update(currentConfig); + const nextRaw = install.format.stringify(nextConfig); + + if (currentRaw === null || nextRaw !== normalizeRawConfig(currentRaw)) { + writePrivateFileAtomicSync(configPath, nextRaw); + return { configPath, changed: true }; + } + + return { configPath, changed: false }; +} + +function resolveConfiguredDirectory(input: { + install: AgentHookInstallStrategy; + env: NodeJS.ProcessEnv; + homeDir: string; +}): string { + const overrideName = input.install.configDirEnvOverride; + const override = overrideName ? input.env[overrideName] : undefined; + if (override) { + return override; + } + + if (input.install.configDirBase === "xdg-config") { + return path.join(resolveXdgConfigHome(input), input.install.configDir); + } + + return path.join(input.homeDir, input.install.configDir); +} + +function resolveXdgConfigHome(input: { env: NodeJS.ProcessEnv; homeDir: string }): string { + if (input.env.XDG_CONFIG_HOME) { + return input.env.XDG_CONFIG_HOME; + } + + return path.join(input.homeDir, ".config"); +} + +function normalizeRawConfig(raw: string): string { + return raw.endsWith("\n") ? raw : `${raw}\n`; +} + +function shellToken(value: string): string { + if (/^[A-Za-z0-9_./:-]+$/.test(value)) { + return value; + } + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function windowsToken(value: string): string { + if (/^[A-Za-z0-9_./:-]+$/.test(value)) { + return value; + } + return `"${value.replaceAll('"', '\\"')}"`; +} diff --git a/packages/server/src/terminal/agent-hooks/claude/claude-settings.ts b/packages/server/src/terminal/agent-hooks/claude/claude-settings.ts new file mode 100644 index 000000000..6296e6a4d --- /dev/null +++ b/packages/server/src/terminal/agent-hooks/claude/claude-settings.ts @@ -0,0 +1,117 @@ +import { type AgentHookConfigFormat, buildAgentHookShellCommand } from "../agent-hook-installer.js"; + +interface ClaudeCommandHook { + type?: unknown; + command?: unknown; + timeout?: unknown; +} + +interface ClaudeHookMatcher { + matcher?: unknown; + hooks?: unknown; +} + +export interface ClaudeSettings { + hooks?: Record; + [key: string]: unknown; +} + +export const claudeSettingsFormat: AgentHookConfigFormat = { + empty() { + return {}; + }, + parse(raw) { + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed)) { + return {}; + } + return parsed; + }, + stringify(config) { + return `${JSON.stringify(config, null, 2)}\n`; + }, + install(config, provider) { + const install = provider.install; + const hooks = normalizeHooks(config.hooks); + for (const event of provider.events) { + const userEntries = removePaseoHooks(hooks[event.event], install.hookMarker); + hooks[event.event] = [ + ...userEntries, + { + matcher: "", + hooks: [ + { + type: "command", + command: buildAgentHookShellCommand(provider, event), + timeout: 10, + }, + ], + }, + ]; + } + return { ...config, hooks }; + }, + uninstall(config, provider) { + const install = provider.install; + const hooks = normalizeHooks(config.hooks); + for (const event of provider.events) { + const entries = removePaseoHooks(hooks[event.event], install.hookMarker); + if (entries.length > 0) { + hooks[event.event] = entries; + } else { + delete hooks[event.event]; + } + } + return { ...config, hooks }; + }, + isInstalled(config, provider) { + const install = provider.install; + const hooks = normalizeHooks(config.hooks); + return provider.events.every((event) => + normalizeMatchers(hooks[event.event]).some((entry) => + normalizeCommandHooks(entry.hooks).some((hook) => + commandContainsMarker(hook, install.hookMarker), + ), + ), + ); + }, +}; + +function normalizeHooks(value: unknown): Record { + return isRecord(value) ? { ...value } : {}; +} + +function normalizeMatchers(value: unknown): ClaudeHookMatcher[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter(isRecord); +} + +function normalizeCommandHooks(value: unknown): ClaudeCommandHook[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter(isRecord); +} + +function removePaseoHooks(value: unknown, marker: string): ClaudeHookMatcher[] { + const entries: ClaudeHookMatcher[] = []; + for (const entry of normalizeMatchers(value)) { + const hooks = normalizeCommandHooks(entry.hooks).filter( + (hook) => !commandContainsMarker(hook, marker), + ); + if (hooks.length > 0) { + entries.push(Object.assign({}, entry, { hooks })); + } + } + return entries; +} + +function commandContainsMarker(hook: ClaudeCommandHook, marker: string): boolean { + return typeof hook.command === "string" && hook.command.includes(marker); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/server/src/terminal/agent-hooks/claude/claude.real.e2e.test.ts b/packages/server/src/terminal/agent-hooks/claude/claude.real.e2e.test.ts new file mode 100644 index 000000000..dbe1d8110 --- /dev/null +++ b/packages/server/src/terminal/agent-hooks/claude/claude.real.e2e.test.ts @@ -0,0 +1,174 @@ +import { spawnSync } from "node:child_process"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import * as pty from "node-pty"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolvePaseoCliBinDir } from "../../terminal.js"; +import { installRegisteredAgentHooks } from "../provider-registry.js"; + +interface ActivityPost { + terminalId: string; + token: string; + state: string; +} + +interface ClaudeAvailability { + available: boolean; + detail: string; +} + +const temporaryDirs: string[] = []; +const claudeAvailability = checkClaudeAvailability(); + +afterEach(() => { + while (temporaryDirs.length > 0) { + const dir = temporaryDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function checkClaudeAvailability(): ClaudeAvailability { + const which = spawnSync("/bin/sh", ["-lc", "command -v claude"], { + encoding: "utf8", + }); + const claudePath = which.stdout.trim(); + if (which.status !== 0 || !claudePath) { + return { available: false, detail: "real claude binary is not installed on PATH" }; + } + + const version = spawnSync(claudePath, ["--version"], { + encoding: "utf8", + timeout: 10_000, + }); + if (version.status !== 0) { + return { + available: false, + detail: `real claude --version failed: ${(version.stderr || version.stdout).trim()}`, + }; + } + + return { + available: true, + detail: `${claudePath} ${(version.stdout || version.stderr).trim()}`, + }; +} + +function createTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + temporaryDirs.push(dir); + return dir; +} + +function readBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + request.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + request.on("error", reject); + }); +} + +async function createActivityRecorder() { + const posts: ActivityPost[] = []; + const server = createServer(async (request: IncomingMessage, response: ServerResponse) => { + if (request.method !== "POST" || request.url !== "/api/terminal-activity") { + response.statusCode = 404; + response.end(); + return; + } + + const body = await readBody(request); + posts.push(JSON.parse(body) as ActivityPost); + response.statusCode = 200; + response.end("ok"); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected TCP activity recorder address"); + } + + return { + posts, + url: `http://127.0.0.1:${address.port}/api/terminal-activity`, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +function waitForExit(process: pty.IPty, timeoutMs: number): Promise<{ exitCode: number | null }> { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + process.kill(); + reject(new Error(`Timed out waiting for claude after ${timeoutMs}ms`)); + }, timeoutMs); + + process.onExit((event) => { + clearTimeout(timeout); + resolve({ exitCode: event.exitCode }); + }); + }); +} + +function statesFor(posts: ActivityPost[], terminalId: string, token: string): string[] { + return posts + .filter((post) => post.terminalId === terminalId && post.token === token) + .map((post) => post.state); +} + +describe.skipIf(!claudeAvailability.available)( + `real Claude terminal activity hooks (${claudeAvailability.detail})`, + () => { + it("posts running then idle through the installed Claude config hooks", async () => { + const recorder = await createActivityRecorder(); + const terminalId = "real-claude-terminal"; + const token = "real-claude-token"; + const configDir = createTempDir("paseo-real-claude-config-"); + const cwd = createTempDir("paseo-real-claude-cwd-"); + const paseoCliBinDir = resolvePaseoCliBinDir(); + if (!paseoCliBinDir) { + throw new Error("Could not resolve paseo CLI bin directory"); + } + + installRegisteredAgentHooks({ configDir }); + + try { + const claude = pty.spawn( + "claude", + ["--settings", join(configDir, "settings.json"), "-p", "reply with: done"], + { + name: "xterm-256color", + cols: 80, + rows: 24, + cwd, + env: { + ...process.env, + PASEO_TERMINAL_ID: terminalId, + PASEO_ACTIVITY_TOKEN: token, + PASEO_TERMINAL_ACTIVITY_URL: recorder.url, + PATH: [paseoCliBinDir, process.env.PATH].filter(isString).join(delimiter), + }, + }, + ); + + const output: string[] = []; + claude.onData((data) => output.push(data)); + const exit = await waitForExit(claude, 90_000); + expect(exit.exitCode, output.join("")).toBe(0); + + const states = statesFor(recorder.posts, terminalId, token); + expect(states).toContain("running"); + expect(states).toContain("idle"); + expect(states.indexOf("running")).toBeLessThan(states.lastIndexOf("idle")); + } finally { + await recorder.close(); + } + }, 100_000); + }, +); + +function isString(value: string | undefined): value is string { + return typeof value === "string" && value.length > 0; +} diff --git a/packages/server/src/terminal/agent-hooks/claude/claude.test.ts b/packages/server/src/terminal/agent-hooks/claude/claude.test.ts new file mode 100644 index 000000000..3a6dfb40f --- /dev/null +++ b/packages/server/src/terminal/agent-hooks/claude/claude.test.ts @@ -0,0 +1,191 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildTerminalEnvironment } from "../../terminal.js"; +import { buildAgentHookShellCommand } from "../agent-hook-installer.js"; +import { + AGENT_HOOK_PROVIDERS, + installRegisteredAgentHooks, + registeredAgentHooksAreInstalled, + uninstallRegisteredAgentHooks, +} from "../provider-registry.js"; + +const temporaryDirs: string[] = []; +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../../../.."); + +afterEach(() => { + while (temporaryDirs.length > 0) { + const dir = temporaryDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function createTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + temporaryDirs.push(dir); + return dir; +} + +function createFakeCliBinDir(): string { + const dir = createTempDir("paseo-cli-bin-"); + writeFileSync(join(dir, "paseo"), ""); + return dir; +} + +interface TestClaudeSettings { + hooks?: Record; + theme?: string; +} + +function readSettings(configDir: string): TestClaudeSettings { + return JSON.parse(readFileSync(join(configDir, "settings.json"), "utf8")) as { + hooks?: Record; + theme?: string; + }; +} + +function hookCommands(settings: { hooks?: Record }, event: string): string[] { + const entries = settings.hooks?.[event]; + if (!Array.isArray(entries)) { + return []; + } + return entries.flatMap((entry) => { + if (!isRecord(entry) || !Array.isArray(entry.hooks)) { + return []; + } + return entry.hooks + .map((hook) => (isRecord(hook) ? hook.command : undefined)) + .filter((command): command is string => typeof command === "string"); + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +describe("Claude terminal agent hooks", () => { + it("installs registered provider hooks idempotently", () => { + const configDir = createTempDir("paseo-claude-config-"); + const provider = AGENT_HOOK_PROVIDERS.claude; + const install = provider.install; + + installRegisteredAgentHooks({ configDir }); + installRegisteredAgentHooks({ configDir }); + + const settings = readSettings(configDir); + for (const event of provider.events) { + const paseoCommands = hookCommands(settings, event.event).filter((command) => + command.includes(install.hookMarker), + ); + expect(paseoCommands).toHaveLength(1); + expect(paseoCommands[0]).toBe( + `[ -n "$PASEO_TERMINAL_ID" ] && "\${PASEO_HOOK_CLI:-paseo}" hooks ${provider.id} ${event.event}`, + ); + } + expect(registeredAgentHooksAreInstalled({ configDir })).toBe(true); + }); + + it("preserves unrelated user hooks", () => { + const configDir = createTempDir("paseo-claude-config-preserve-"); + writeFileSync( + join(configDir, "settings.json"), + `${JSON.stringify( + { + theme: "dark", + hooks: { + Stop: [ + { + matcher: "", + hooks: [{ type: "command", command: "say done", timeout: 5 }], + }, + ], + }, + }, + null, + 2, + )}\n`, + ); + + installRegisteredAgentHooks({ configDir }); + + const settings = readSettings(configDir); + expect(settings.theme).toBe("dark"); + expect(hookCommands(settings, "Stop")).toContain("say done"); + expect( + hookCommands(settings, "Stop").some((command) => command.includes("hooks claude Stop")), + ).toBe(true); + }); + + it("uninstalls only marker-matched hooks", () => { + const configDir = createTempDir("paseo-claude-config-uninstall-"); + installRegisteredAgentHooks({ configDir }); + const settings = readSettings(configDir); + settings.hooks = { + ...settings.hooks, + Stop: [ + ...(Array.isArray(settings.hooks?.Stop) ? settings.hooks.Stop : []), + { + matcher: "", + hooks: [{ type: "command", command: "say still-here", timeout: 5 }], + }, + ], + }; + writeFileSync(join(configDir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`); + + uninstallRegisteredAgentHooks({ configDir }); + + const nextSettings = readSettings(configDir); + expect(hookCommands(nextSettings, "Stop")).toEqual(["say still-here"]); + expect(registeredAgentHooksAreInstalled({ configDir })).toBe(false); + }); + + it("builds a minimal gated hook command", () => { + const provider = AGENT_HOOK_PROVIDERS.claude; + const command = buildAgentHookShellCommand(provider, provider.events[0]); + + expect(command).toBe( + '[ -n "$PASEO_TERMINAL_ID" ] && "${PASEO_HOOK_CLI:-paseo}" hooks claude UserPromptSubmit', + ); + }); + + it("keeps provider names out of generic CLI and bootstrap integration points", () => { + const genericFiles = [ + join(repositoryRoot, "packages", "cli", "src", "commands", "hooks.ts"), + join(repositoryRoot, "packages", "server", "src", "server", "bootstrap.ts"), + ]; + + for (const filePath of genericFiles) { + const contents = readFileSync(filePath, "utf8").toLowerCase(); + for (const providerId of Object.keys(AGENT_HOOK_PROVIDERS)) { + expect(contents).not.toContain(providerId); + } + } + }); + + it("prepends the paseo CLI directory and injects the hook CLI path", () => { + const cliBinDir = createFakeCliBinDir(); + const hookCliPath = join(cliBinDir, "paseo"); + + const env = buildTerminalEnvironment({ + shell: "/bin/sh", + env: { PATH: ["/usr/bin", "/bin"].join(delimiter) }, + paseoCliBinDir: cliBinDir, + paseoHookCliPath: hookCliPath, + }); + + expect(env.PATH?.split(delimiter)).toEqual([cliBinDir, "/usr/bin", "/bin"]); + expect(env.PASEO_HOOK_CLI).toBe(hookCliPath); + }); + + it("leaves terminal PATH unchanged when the CLI directory cannot be resolved", () => { + const env = buildTerminalEnvironment({ + shell: "/bin/sh", + env: { PATH: ["/usr/bin", "/bin"].join(delimiter) }, + paseoCliBinDir: null, + }); + + expect(env.PATH?.split(delimiter)).toEqual(["/usr/bin", "/bin"]); + }); +}); diff --git a/packages/server/src/terminal/agent-hooks/claude/claude.ts b/packages/server/src/terminal/agent-hooks/claude/claude.ts new file mode 100644 index 000000000..35bfa9eca --- /dev/null +++ b/packages/server/src/terminal/agent-hooks/claude/claude.ts @@ -0,0 +1,48 @@ +import type { AgentHookActivityState, AgentHookProvider } from "../agent-hook-installer.js"; +import { type ClaudeSettings, claudeSettingsFormat } from "./claude-settings.js"; + +const CLAUDE_EVENT_STATES: Record = { + UserPromptSubmit: "running", + Stop: "idle", + StopFailure: "idle", + SessionEnd: "idle", +}; + +export const claudeAgentHookProvider: AgentHookProvider = { + id: "claude", + events: [ + { event: "UserPromptSubmit" }, + { event: "Stop" }, + { event: "StopFailure" }, + { event: "SessionEnd" }, + { event: "Notification" }, + ], + install: { + kind: "config-file", + configDir: ".claude", + configFile: "settings.json", + configDirEnvOverride: "CLAUDE_CONFIG_DIR", + hookMarker: "hooks claude", + format: claudeSettingsFormat, + }, + async resolveActivity({ event, input }) { + if (event === "Notification") { + const raw = input.isTTY ? null : await input.read(); + return isIdlePrompt(raw) ? "needs-input" : null; + } + + return CLAUDE_EVENT_STATES[event] ?? null; + }, +}; + +function isIdlePrompt(raw: string | null): boolean { + if (!raw) return false; + try { + const notification = JSON.parse(raw) as unknown; + if (!notification || typeof notification !== "object") return false; + const payload = notification as { matcher?: unknown; reason?: unknown }; + return payload.matcher === "idle_prompt" || payload.reason === "idle_prompt"; + } catch { + return false; + } +} diff --git a/packages/server/src/terminal/agent-hooks/codex/codex-settings.ts b/packages/server/src/terminal/agent-hooks/codex/codex-settings.ts new file mode 100644 index 000000000..2b70d379d --- /dev/null +++ b/packages/server/src/terminal/agent-hooks/codex/codex-settings.ts @@ -0,0 +1,143 @@ +import { + type AgentHookConfigFormat, + buildAgentHookShellCommand, + buildAgentHookWindowsCommand, +} from "../agent-hook-installer.js"; + +interface CodexCommandHook { + type?: unknown; + command?: unknown; + commandWindows?: unknown; + command_windows?: unknown; + timeout?: unknown; +} + +interface CodexMatcherGroup { + matcher?: unknown; + hooks?: unknown; +} + +export interface CodexHooksFile { + hooks?: Record; + [key: string]: unknown; +} + +export const codexHooksFormat: AgentHookConfigFormat = { + empty() { + return {}; + }, + parse(raw) { + const parsed = JSON.parse(raw) as unknown; + if (!isRecord(parsed)) { + return {}; + } + return parsed; + }, + stringify(config) { + return `${JSON.stringify(config, null, 2)}\n`; + }, + install(config, provider) { + const install = provider.install; + const hooks = normalizeHooks(config.hooks); + for (const event of provider.events) { + const userEntries = removePaseoHooks(hooks[event.event], install.hookMarker); + hooks[event.event] = [ + ...userEntries, + { + matcher: "", + hooks: [ + { + type: "command", + command: buildAgentHookShellCommand(provider, event), + commandWindows: buildAgentHookWindowsCommand(provider, event), + timeout: 10, + }, + ], + }, + ]; + } + return { ...config, hooks }; + }, + uninstall(config, provider) { + const install = provider.install; + const hooks = normalizeHooks(config.hooks); + for (const event of provider.events) { + const entries = removePaseoHooks(hooks[event.event], install.hookMarker); + if (entries.length > 0) { + hooks[event.event] = entries; + } else { + delete hooks[event.event]; + } + } + return { ...config, hooks }; + }, + isInstalled(config, provider) { + const install = provider.install; + const hooks = normalizeHooks(config.hooks); + return provider.events.every((event) => + normalizeMatchers(hooks[event.event]).some((entry) => + normalizeCommandHooks(entry.hooks).some((hook) => + hasPaseoCommands(hook, install.hookMarker), + ), + ), + ); + }, +}; + +function normalizeHooks(value: unknown): Record { + return isRecord(value) ? { ...value } : {}; +} + +function normalizeMatchers(value: unknown): CodexMatcherGroup[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter(isRecord); +} + +function normalizeCommandHooks(value: unknown): CodexCommandHook[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter(isRecord); +} + +function removePaseoHooks(value: unknown, marker: string): CodexMatcherGroup[] { + const entries: CodexMatcherGroup[] = []; + for (const entry of normalizeMatchers(value)) { + const hooks = normalizeCommandHooks(entry.hooks).filter( + (hook) => !commandContainsMarker(hook, marker), + ); + if (hooks.length > 0) { + entries.push(Object.assign({}, entry, { hooks })); + } + } + return entries; +} + +function hasPaseoCommands(hook: CodexCommandHook, marker: string): boolean { + return ( + commandFieldContainsMarker(hook.command, marker) && windowsCommandContainsMarker(hook, marker) + ); +} + +function commandContainsMarker(hook: CodexCommandHook, marker: string): boolean { + return ( + commandFieldContainsMarker(hook.command, marker) || windowsCommandContainsMarker(hook, marker) + ); +} + +function windowsCommandContainsMarker(hook: CodexCommandHook, marker: string): boolean { + return ( + commandFieldContainsMarker(hook.commandWindows, marker) || + commandFieldContainsMarker(hook.command_windows, marker) + ); +} + +function commandFieldContainsMarker(value: unknown, marker: string): boolean { + return typeof value === "string" && value.includes(marker); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/server/src/terminal/agent-hooks/codex/codex.test.ts b/packages/server/src/terminal/agent-hooks/codex/codex.test.ts new file mode 100644 index 000000000..6446d51ae --- /dev/null +++ b/packages/server/src/terminal/agent-hooks/codex/codex.test.ts @@ -0,0 +1,147 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + agentHooksAreInstalled, + installAgentHooks, + uninstallAgentHooks, +} from "../agent-hook-installer.js"; +import { codexAgentHookProvider } from "./codex.js"; + +const temporaryDirs: string[] = []; + +afterEach(() => { + while (temporaryDirs.length > 0) { + const dir = temporaryDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +interface TestCodexHooksFile { + hooks?: Record; +} + +interface TestCodexCommandHook { + command?: string; + commandWindows?: string; +} + +function createTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + temporaryDirs.push(dir); + return dir; +} + +function readHooksFile(configDir: string): TestCodexHooksFile { + return JSON.parse(readFileSync(join(configDir, "hooks.json"), "utf8")) as TestCodexHooksFile; +} + +function commandHooks(config: TestCodexHooksFile, event: string): TestCodexCommandHook[] { + const entries = config.hooks?.[event]; + if (!Array.isArray(entries)) { + return []; + } + return entries.flatMap((entry) => { + if (!isRecord(entry) || !Array.isArray(entry.hooks)) { + return []; + } + return entry.hooks.filter(isRecord).map((hook) => ({ + command: typeof hook.command === "string" ? hook.command : undefined, + commandWindows: typeof hook.commandWindows === "string" ? hook.commandWindows : undefined, + })); + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +describe("Codex terminal agent hooks", () => { + it("installs POSIX and Windows hook commands idempotently", () => { + const configDir = createTempDir("paseo-codex-config-"); + + installAgentHooks(codexAgentHookProvider, { configDir }); + const secondInstall = installAgentHooks(codexAgentHookProvider, { configDir }); + + const config = readHooksFile(configDir); + for (const event of codexAgentHookProvider.events) { + expect(commandHooks(config, event.event)).toEqual([ + { + command: `[ -n "$PASEO_TERMINAL_ID" ] && "\${PASEO_HOOK_CLI:-paseo}" hooks codex ${event.event}`, + commandWindows: `if defined PASEO_TERMINAL_ID (if defined PASEO_HOOK_CLI ("%PASEO_HOOK_CLI%" hooks codex ${event.event}) else (paseo hooks codex ${event.event}))`, + }, + ]); + } + expect(secondInstall.changed).toBe(false); + expect(agentHooksAreInstalled(codexAgentHookProvider, { configDir })).toBe(true); + }); + + it("preserves unrelated user hooks", () => { + const configDir = createTempDir("paseo-codex-config-preserve-"); + writeFileSync( + join(configDir, "hooks.json"), + `${JSON.stringify( + { + hooks: { + Stop: [ + { + matcher: "", + hooks: [{ type: "command", command: "say codex done", timeout: 5 }], + }, + ], + }, + }, + null, + 2, + )}\n`, + ); + + installAgentHooks(codexAgentHookProvider, { configDir }); + + const stopCommands = commandHooks(readHooksFile(configDir), "Stop").map((hook) => hook.command); + expect(stopCommands).toEqual([ + "say codex done", + '[ -n "$PASEO_TERMINAL_ID" ] && "${PASEO_HOOK_CLI:-paseo}" hooks codex Stop', + ]); + }); + + it("uninstalls only marker-matched hooks", () => { + const configDir = createTempDir("paseo-codex-config-uninstall-"); + installAgentHooks(codexAgentHookProvider, { configDir }); + const config = readHooksFile(configDir); + config.hooks = { + ...config.hooks, + Stop: [ + ...(Array.isArray(config.hooks?.Stop) ? config.hooks.Stop : []), + { + matcher: "", + hooks: [{ type: "command", command: "say still-here", timeout: 5 }], + }, + ], + }; + writeFileSync(join(configDir, "hooks.json"), `${JSON.stringify(config, null, 2)}\n`); + + uninstallAgentHooks(codexAgentHookProvider, { configDir }); + + expect(commandHooks(readHooksFile(configDir), "Stop").map((hook) => hook.command)).toEqual([ + "say still-here", + ]); + expect(agentHooksAreInstalled(codexAgentHookProvider, { configDir })).toBe(false); + }); + + it.each([ + ["UserPromptSubmit", "running"], + ["PreToolUse", "running"], + ["PostToolUse", "running"], + ["PermissionRequest", "needs-input"], + ["Stop", "idle"], + ] as const)("maps %s to %s", async (event, state) => { + await expect( + codexAgentHookProvider.resolveActivity({ + event, + input: { read: async () => null }, + }), + ).resolves.toBe(state); + }); +}); diff --git a/packages/server/src/terminal/agent-hooks/codex/codex.ts b/packages/server/src/terminal/agent-hooks/codex/codex.ts new file mode 100644 index 000000000..2bc02f65c --- /dev/null +++ b/packages/server/src/terminal/agent-hooks/codex/codex.ts @@ -0,0 +1,32 @@ +import type { AgentHookActivityState, AgentHookProvider } from "../agent-hook-installer.js"; +import { type CodexHooksFile, codexHooksFormat } from "./codex-settings.js"; + +const CODEX_EVENT_STATES: Record = { + UserPromptSubmit: "running", + PreToolUse: "running", + PostToolUse: "running", + PermissionRequest: "needs-input", + Stop: "idle", +}; + +export const codexAgentHookProvider: AgentHookProvider = { + id: "codex", + events: [ + { event: "UserPromptSubmit" }, + { event: "PreToolUse" }, + { event: "PostToolUse" }, + { event: "PermissionRequest" }, + { event: "Stop" }, + ], + install: { + kind: "config-file", + configDir: ".codex", + configFile: "hooks.json", + configDirEnvOverride: "CODEX_HOME", + hookMarker: "hooks codex", + format: codexHooksFormat, + }, + async resolveActivity({ event }) { + return CODEX_EVENT_STATES[event] ?? null; + }, +}; diff --git a/packages/server/src/terminal/agent-hooks/opencode/opencode-plugin.ts b/packages/server/src/terminal/agent-hooks/opencode/opencode-plugin.ts new file mode 100644 index 000000000..5fce566cd --- /dev/null +++ b/packages/server/src/terminal/agent-hooks/opencode/opencode-plugin.ts @@ -0,0 +1,48 @@ +import type { AgentHookPluginFileInstallStrategy } from "../agent-hook-installer.js"; + +export const OPENCODE_PLUGIN_SOURCE = [ + "const STATUS_EVENTS = {", + ' busy: "session.status.busy",', + ' retry: "session.status.retry",', + ' idle: "session.status.idle",', + "};", + "", + "function paseoEventFor(event) {", + ' if (event?.type === "permission.asked") return "permission.asked";', + ' if (event?.type === "permission.replied") return "permission.replied";', + ' if (event?.type !== "session.status") return null;', + " return STATUS_EVENTS[event?.properties?.status?.type] ?? null;", + "}", + "", + "function runPaseoHook(event) {", + " if (!process.env.PASEO_TERMINAL_ID) return;", + " try {", + ' const child = Bun.spawn(["paseo", "hooks", "opencode", event], {', + ' stdin: "ignore",', + ' stdout: "ignore",', + ' stderr: "ignore",', + " });", + " void child.exited.catch(() => {});", + " } catch {}", + "}", + "", + "export default async () => ({", + " event: async ({ event }) => {", + " const paseoEvent = paseoEventFor(event);", + " if (paseoEvent) runPaseoHook(paseoEvent);", + " },", + "});", + "", +].join("\n"); + +export function createOpenCodePluginInstallStrategy(): AgentHookPluginFileInstallStrategy { + return { + kind: "plugin-file", + configDir: "opencode", + configDirBase: "xdg-config", + configFile: "plugins/paseo-terminal-activity.js", + configDirEnvOverride: "OPENCODE_CONFIG_DIR", + hookMarker: "paseo hooks opencode", + source: OPENCODE_PLUGIN_SOURCE, + }; +} diff --git a/packages/server/src/terminal/agent-hooks/opencode/opencode.test.ts b/packages/server/src/terminal/agent-hooks/opencode/opencode.test.ts new file mode 100644 index 000000000..6ba28b22e --- /dev/null +++ b/packages/server/src/terminal/agent-hooks/opencode/opencode.test.ts @@ -0,0 +1,123 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + agentHooksAreInstalled, + installAgentHooks, + resolveAgentHookConfigPath, + uninstallAgentHooks, +} from "../agent-hook-installer.js"; +import { opencodeAgentHookProvider } from "./opencode.js"; +import { OPENCODE_PLUGIN_SOURCE } from "./opencode-plugin.js"; + +const temporaryDirs: string[] = []; + +afterEach(() => { + while (temporaryDirs.length > 0) { + const dir = temporaryDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function createTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + temporaryDirs.push(dir); + return dir; +} + +describe("OpenCode terminal agent hooks", () => { + it("installs a self-contained OpenCode plugin idempotently", () => { + const configDir = createTempDir("paseo-opencode-config-"); + + const firstInstall = installAgentHooks(opencodeAgentHookProvider, { configDir }); + const secondInstall = installAgentHooks(opencodeAgentHookProvider, { configDir }); + + expect(firstInstall.configPath).toBe(join(configDir, "plugins", "paseo-terminal-activity.js")); + expect(firstInstall.changed).toBe(true); + expect(secondInstall.changed).toBe(false); + expect(readFileSync(firstInstall.configPath, "utf8")).toBe(OPENCODE_PLUGIN_SOURCE); + expect(agentHooksAreInstalled(opencodeAgentHookProvider, { configDir })).toBe(true); + }); + + it("writes the plugin that maps OpenCode bus events to paseo hook events", () => { + const configDir = createTempDir("paseo-opencode-config-source-"); + const { configPath } = installAgentHooks(opencodeAgentHookProvider, { configDir }); + const source = readFileSync(configPath, "utf8"); + + expect(source).toContain('busy: "session.status.busy"'); + expect(source).toContain('retry: "session.status.retry"'); + expect(source).toContain('idle: "session.status.idle"'); + expect(source).toContain('event?.type === "permission.asked"'); + expect(source).toContain('event?.type === "permission.replied"'); + expect(source).toContain('Bun.spawn(["paseo", "hooks", "opencode", event]'); + expect(source).toContain("PASEO_TERMINAL_ID"); + }); + + it("uninstalls the OpenCode plugin file", () => { + const configDir = createTempDir("paseo-opencode-config-uninstall-"); + const configPath = resolveAgentHookConfigPath(opencodeAgentHookProvider, { configDir }); + installAgentHooks(opencodeAgentHookProvider, { configDir }); + + const result = uninstallAgentHooks(opencodeAgentHookProvider, { configDir }); + + expect(result).toEqual({ configPath, changed: true }); + expect(existsSync(configPath)).toBe(false); + expect(agentHooksAreInstalled(opencodeAgentHookProvider, { configDir })).toBe(false); + }); + + it("prefers OPENCODE_CONFIG_DIR over the XDG config home", () => { + const homeDir = createTempDir("paseo-home-"); + const configDir = createTempDir("paseo-opencode-override-"); + const xdgConfigHome = createTempDir("paseo-xdg-config-"); + + const configPath = resolveAgentHookConfigPath(opencodeAgentHookProvider, { + env: { OPENCODE_CONFIG_DIR: configDir, XDG_CONFIG_HOME: xdgConfigHome }, + homeDir, + }); + + expect(configPath).toBe(join(configDir, "plugins", "paseo-terminal-activity.js")); + }); + + it("uses the XDG config home for the default OpenCode config dir", () => { + const homeDir = createTempDir("paseo-home-"); + const xdgConfigHome = createTempDir("paseo-xdg-config-"); + + const configPath = resolveAgentHookConfigPath(opencodeAgentHookProvider, { + env: { XDG_CONFIG_HOME: xdgConfigHome }, + homeDir, + }); + + expect(configPath).toBe( + join(xdgConfigHome, "opencode", "plugins", "paseo-terminal-activity.js"), + ); + }); + + it("falls back to the home .config OpenCode dir without an XDG config home", () => { + const homeDir = createTempDir("paseo-home-"); + + const configPath = resolveAgentHookConfigPath(opencodeAgentHookProvider, { + env: {}, + homeDir, + }); + + expect(configPath).toBe( + join(homeDir, ".config", "opencode", "plugins", "paseo-terminal-activity.js"), + ); + }); + + it.each([ + ["session.status.busy", "running"], + ["session.status.retry", "running"], + ["session.status.idle", "idle"], + ["permission.asked", "needs-input"], + ["permission.replied", "running"], + ] as const)("maps %s to %s", async (event, state) => { + await expect( + opencodeAgentHookProvider.resolveActivity({ + event, + input: { read: async () => null }, + }), + ).resolves.toBe(state); + }); +}); diff --git a/packages/server/src/terminal/agent-hooks/opencode/opencode.ts b/packages/server/src/terminal/agent-hooks/opencode/opencode.ts new file mode 100644 index 000000000..d1503cceb --- /dev/null +++ b/packages/server/src/terminal/agent-hooks/opencode/opencode.ts @@ -0,0 +1,25 @@ +import type { AgentHookActivityState, AgentHookProvider } from "../agent-hook-installer.js"; +import { createOpenCodePluginInstallStrategy } from "./opencode-plugin.js"; + +const OPENCODE_EVENT_STATES: Record = { + "session.status.busy": "running", + "session.status.retry": "running", + "session.status.idle": "idle", + "permission.asked": "needs-input", + "permission.replied": "running", +}; + +export const opencodeAgentHookProvider: AgentHookProvider = { + id: "opencode", + events: [ + { event: "session.status.busy" }, + { event: "session.status.retry" }, + { event: "session.status.idle" }, + { event: "permission.asked" }, + { event: "permission.replied" }, + ], + install: createOpenCodePluginInstallStrategy(), + async resolveActivity({ event }) { + return OPENCODE_EVENT_STATES[event] ?? null; + }, +}; diff --git a/packages/server/src/terminal/agent-hooks/provider-registry.test.ts b/packages/server/src/terminal/agent-hooks/provider-registry.test.ts new file mode 100644 index 000000000..2dd8a6a3f --- /dev/null +++ b/packages/server/src/terminal/agent-hooks/provider-registry.test.ts @@ -0,0 +1,73 @@ +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { installRegisteredAgentHooks } from "./provider-registry.js"; + +const temporaryDirs: string[] = []; + +interface WarningLogEntry { + bindings: Record; + message: string; +} + +interface WarningLogger { + entries: WarningLogEntry[]; + warn(bindings: Record, message: string): void; +} + +afterEach(() => { + while (temporaryDirs.length > 0) { + const dir = temporaryDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function createTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + temporaryDirs.push(dir); + return dir; +} + +function createWarningLogger(): WarningLogger { + return { + entries: [], + warn(bindings, message) { + this.entries.push({ bindings, message }); + }, + }; +} + +describe("terminal agent hook provider registry", () => { + it("continues installing provider hooks after one provider fails", () => { + const root = createTempDir("paseo-agent-hook-registry-"); + const badClaudeConfigDir = join(root, "not-a-directory"); + const codexHome = join(root, "codex"); + const opencodeConfigDir = join(root, "opencode"); + const logger = createWarningLogger(); + writeFileSync(badClaudeConfigDir, ""); + + const results = installRegisteredAgentHooks({ + env: { + CLAUDE_CONFIG_DIR: badClaudeConfigDir, + CODEX_HOME: codexHome, + OPENCODE_CONFIG_DIR: opencodeConfigDir, + }, + homeDir: join(root, "home"), + logger, + }); + + expect(results.map((result) => result.configPath)).toEqual([ + join(codexHome, "hooks.json"), + join(opencodeConfigDir, "plugins", "paseo-terminal-activity.js"), + ]); + expect(existsSync(join(codexHome, "hooks.json"))).toBe(true); + expect(existsSync(join(opencodeConfigDir, "plugins", "paseo-terminal-activity.js"))).toBe(true); + expect(logger.entries).toEqual([ + { + bindings: expect.objectContaining({ err: expect.any(Error), provider: "claude" }), + message: "Failed to install terminal activity hook provider", + }, + ]); + }); +}); diff --git a/packages/server/src/terminal/agent-hooks/provider-registry.ts b/packages/server/src/terminal/agent-hooks/provider-registry.ts new file mode 100644 index 000000000..6a5103df5 --- /dev/null +++ b/packages/server/src/terminal/agent-hooks/provider-registry.ts @@ -0,0 +1,76 @@ +import { + type AgentHookActivityInput, + type AgentHookActivityState, + type AgentHookInstallLogger, + type AgentHookInstallOptions, + type AgentHookInstallResult, + type AgentHookProvider, + agentHooksAreInstalled, + installAgentHooks, + uninstallAgentHooks, +} from "./agent-hook-installer.js"; +import { claudeAgentHookProvider } from "./claude/claude.js"; +import { codexAgentHookProvider } from "./codex/codex.js"; +import { opencodeAgentHookProvider } from "./opencode/opencode.js"; + +export type { + AgentHookActivityInput, + AgentHookActivityState, + AgentHookProvider, +} from "./agent-hook-installer.js"; + +export const AGENT_HOOK_PROVIDERS = { + [claudeAgentHookProvider.id]: claudeAgentHookProvider, + [codexAgentHookProvider.id]: codexAgentHookProvider, + [opencodeAgentHookProvider.id]: opencodeAgentHookProvider, +} satisfies Record; + +export type AgentHookProviderId = keyof typeof AGENT_HOOK_PROVIDERS; + +export interface AgentHookActivityRequest { + provider: string; + event: string; + input: AgentHookActivityInput; +} + +export interface RegisteredAgentHookInstallOptions extends AgentHookInstallOptions { + logger?: AgentHookInstallLogger; +} + +export function installRegisteredAgentHooks( + options: RegisteredAgentHookInstallOptions = {}, +): AgentHookInstallResult[] { + const results: AgentHookInstallResult[] = []; + for (const provider of Object.values(AGENT_HOOK_PROVIDERS)) { + try { + results.push(installAgentHooks(provider, options)); + } catch (error) { + options.logger?.warn( + { err: error, provider: provider.id }, + "Failed to install terminal activity hook provider", + ); + } + } + return results; +} + +export function uninstallRegisteredAgentHooks(options: AgentHookInstallOptions = {}): void { + for (const provider of Object.values(AGENT_HOOK_PROVIDERS)) { + uninstallAgentHooks(provider, options); + } +} + +export function registeredAgentHooksAreInstalled(options: AgentHookInstallOptions = {}): boolean { + return Object.values(AGENT_HOOK_PROVIDERS).every((provider) => + agentHooksAreInstalled(provider, options), + ); +} + +export async function resolveHookActivity( + request: AgentHookActivityRequest, +): Promise { + const provider = AGENT_HOOK_PROVIDERS[request.provider.toLowerCase()]; + if (!provider) return null; + + return provider.resolveActivity({ event: request.event, input: request.input }); +} diff --git a/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.test.ts b/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.test.ts new file mode 100644 index 000000000..aa9e8e01d --- /dev/null +++ b/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.test.ts @@ -0,0 +1,98 @@ +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { DaemonConfigStore } from "../../server/daemon-config-store.js"; +import { applyTerminalAgentHookSetting } from "./terminal-agent-hook-setting.js"; + +const temporaryDirs: string[] = []; + +afterEach(() => { + while (temporaryDirs.length > 0) { + const dir = temporaryDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function createTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + temporaryDirs.push(dir); + return dir; +} + +function createInstallEnv(root: string) { + return { + env: { + CLAUDE_CONFIG_DIR: join(root, "claude"), + CODEX_HOME: join(root, "codex"), + OPENCODE_CONFIG_DIR: join(root, "opencode"), + }, + homeDir: join(root, "home"), + }; +} + +function hookPaths(root: string) { + return { + claude: join(root, "claude", "settings.json"), + codex: join(root, "codex", "hooks.json"), + opencode: join(root, "opencode", "plugins", "paseo-terminal-activity.js"), + }; +} + +function createStore(paseoHome: string, enableTerminalAgentHooks: boolean): DaemonConfigStore { + return new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks, + appendSystemPrompt: "", + }, + undefined, + ); +} + +describe("applyTerminalAgentHookSetting", () => { + it("leaves agent configs untouched when the setting is disabled", () => { + const root = createTempDir("paseo-hook-setting-"); + const store = createStore(createTempDir("paseo-hook-setting-home-"), false); + + applyTerminalAgentHookSetting({ store, install: createInstallEnv(root) }); + + const paths = hookPaths(root); + expect(existsSync(paths.claude)).toBe(false); + expect(existsSync(paths.codex)).toBe(false); + expect(existsSync(paths.opencode)).toBe(false); + }); + + it("installs agent hooks when the setting is enabled", () => { + const root = createTempDir("paseo-hook-setting-"); + const store = createStore(createTempDir("paseo-hook-setting-home-"), true); + + applyTerminalAgentHookSetting({ store, install: createInstallEnv(root) }); + + const paths = hookPaths(root); + expect(existsSync(paths.claude)).toBe(true); + expect(existsSync(paths.codex)).toBe(true); + expect(existsSync(paths.opencode)).toBe(true); + }); + + it("installs on enable and removes hooks on disable when toggled live", () => { + const root = createTempDir("paseo-hook-setting-"); + const store = createStore(createTempDir("paseo-hook-setting-home-"), false); + const paths = hookPaths(root); + + applyTerminalAgentHookSetting({ store, install: createInstallEnv(root) }); + expect(existsSync(paths.opencode)).toBe(false); + + store.patch({ enableTerminalAgentHooks: true }); + expect(existsSync(paths.codex)).toBe(true); + expect(existsSync(paths.opencode)).toBe(true); + + store.patch({ enableTerminalAgentHooks: false }); + expect(existsSync(paths.opencode)).toBe(false); + }); +}); diff --git a/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.ts b/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.ts new file mode 100644 index 000000000..bfb2d79d8 --- /dev/null +++ b/packages/server/src/terminal/agent-hooks/terminal-agent-hook-setting.ts @@ -0,0 +1,41 @@ +import type { AgentHookInstallLogger, AgentHookInstallOptions } from "./agent-hook-installer.js"; +import { + installRegisteredAgentHooks, + type RegisteredAgentHookInstallOptions, + uninstallRegisteredAgentHooks, +} from "./provider-registry.js"; +import type { DaemonConfigStore } from "../../server/daemon-config-store.js"; + +interface ApplyTerminalAgentHookSettingOptions { + store: DaemonConfigStore; + logger?: AgentHookInstallLogger; + install?: AgentHookInstallOptions; +} + +// Installing agent hooks edits the user's real agent config files, so it only +// happens when `enableTerminalAgentHooks` is on. At boot we install when enabled +// and otherwise leave the configs untouched; toggling the setting live installs +// on enable and removes our marker-matched hooks on disable so opting out cleans +// up after itself. Returns an unsubscribe for the field-change listener. +export function applyTerminalAgentHookSetting( + options: ApplyTerminalAgentHookSettingOptions, +): () => void { + const { store, logger, install } = options; + const installOptions: RegisteredAgentHookInstallOptions = { ...install, logger }; + + if (store.get().enableTerminalAgentHooks) { + installRegisteredAgentHooks(installOptions); + } + + return store.onFieldChange("enableTerminalAgentHooks", (value) => { + if (value === true) { + installRegisteredAgentHooks(installOptions); + return; + } + try { + uninstallRegisteredAgentHooks(install); + } catch (error) { + logger?.warn({ err: error }, "Failed to remove terminal activity hooks"); + } + }); +} diff --git a/packages/server/src/terminal/terminal-manager-factory.ts b/packages/server/src/terminal/terminal-manager-factory.ts index d0fbd2845..88dcc2d69 100644 --- a/packages/server/src/terminal/terminal-manager-factory.ts +++ b/packages/server/src/terminal/terminal-manager-factory.ts @@ -1,6 +1,12 @@ import type { TerminalManager } from "./terminal-manager.js"; import { createWorkerTerminalManager } from "./worker-terminal-manager.js"; -export function createConfiguredTerminalManager(): TerminalManager { - return createWorkerTerminalManager(); +export interface ConfiguredTerminalManagerOptions { + getTerminalActivityUrl?: () => string | null; +} + +export function createConfiguredTerminalManager( + options: ConfiguredTerminalManagerOptions = {}, +): TerminalManager { + return createWorkerTerminalManager(options); } diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index 9ffb77194..964a07c5f 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -429,3 +429,102 @@ it("setTerminalTitle returns true and updates the terminal title for existing te expect(manager.setTerminalTitle(session.id, "x")).toBe(true); expect(session.getTitle()).toBe("x"); }); + +it("new terminal starts with unknown activity", async () => { + manager = createTerminalManager(); + const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) }); + + expect(session.getActivity()).toBeNull(); +}); + +it("includes nullable activity in terminals-changed snapshot items", async () => { + manager = createTerminalManager(); + const cwd = realpathSync(tmpdir()); + const snapshots: Array<{ id: string; activityState: string | null }[]> = []; + const unsubscribe = manager.subscribeTerminalsChanged((input) => { + snapshots.push( + input.terminals.map((terminal) => ({ + id: terminal.id, + activityState: terminal.activity?.state ?? null, + })), + ); + }); + + const session = await manager.createTerminal({ cwd }); + + expect(snapshots.length).toBeGreaterThan(0); + const lastSnapshot = snapshots[snapshots.length - 1]; + expect(lastSnapshot?.find((t) => t.id === session.id)?.activityState).toBeNull(); + + unsubscribe(); +}); + +it("setTerminalActivity returns true and emits terminals-changed", async () => { + manager = createTerminalManager(); + const cwd = realpathSync(tmpdir()); + interface ActivityEntry { + id: string; + state: string | null; + } + const snapshots: ActivityEntry[][] = []; + const unsubscribe = manager.subscribeTerminalsChanged((input) => { + snapshots.push( + input.terminals.map((terminal) => ({ + id: terminal.id, + state: terminal.activity?.state ?? null, + })), + ); + }); + + const session = await manager.createTerminal({ cwd }); + await expect(manager.setTerminalActivity(session.id, "working")).resolves.toBe(true); + + const hasWorkingActivity = (entries: ActivityEntry[]) => + entries.some((e) => e.id === session.id && e.state === "working"); + + expect(snapshots.some(hasWorkingActivity)).toBe(true); + + unsubscribe(); +}); + +it("clearTerminalAttention returns attention terminals to idle", async () => { + manager = createTerminalManager(); + const cwd = realpathSync(tmpdir()); + interface ActivityEntry { + id: string; + state: string | null; + } + const snapshots: ActivityEntry[][] = []; + const unsubscribe = manager.subscribeTerminalsChanged((input) => { + snapshots.push( + input.terminals.map((terminal) => ({ + id: terminal.id, + state: terminal.activity?.state ?? null, + })), + ); + }); + + const session = await manager.createTerminal({ cwd }); + await manager.setTerminalActivity(session.id, "attention"); + + await expect(manager.clearTerminalAttention(session.id)).resolves.toBe(true); + + expect(session.getActivity()?.state).toBe("idle"); + expect( + snapshots.some((entries) => + entries.some((entry) => entry.id === session.id && entry.state === "idle"), + ), + ).toBe(true); + + unsubscribe(); +}); + +it("clearTerminalAttention leaves non-attention terminals unchanged", async () => { + manager = createTerminalManager(); + const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) }); + await manager.setTerminalActivity(session.id, "working"); + + await expect(manager.clearTerminalAttention(session.id)).resolves.toBe(false); + + expect(session.getActivity()?.state).toBe("working"); +}); diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index 9f45f5be4..1f3264bc1 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -1,18 +1,22 @@ import { createTerminal, + type TerminalActivityTransition, type TerminalSession, type TerminalStateSnapshot, type TerminalStateSnapshotOptions, } from "./terminal.js"; import { captureTerminalLines, type CaptureTerminalLinesResult } from "./terminal-capture.js"; +import { randomBytes, randomUUID } from "node:crypto"; import { resolve, sep, win32, posix } from "node:path"; import { isSameOrDescendantPath } from "../server/path-utils.js"; +import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; export interface TerminalListItem { id: string; name: string; cwd: string; title?: string; + activity: TerminalActivity | null; } export interface TerminalsChangedEvent { @@ -22,6 +26,16 @@ export interface TerminalsChangedEvent { export type TerminalsChangedListener = (input: TerminalsChangedEvent) => void; +export interface TerminalActivityTransitionEvent { + terminalId: string; + name: string; + cwd: string; + activity: TerminalActivity | null; + previous: TerminalActivity | null; +} + +export type TerminalActivityListener = (event: TerminalActivityTransitionEvent) => void; + export interface TerminalManager { getTerminals(cwd: string): Promise; createTerminal(options: { @@ -32,14 +46,19 @@ export interface TerminalManager { env?: Record; command?: string; args?: string[]; + activityToken?: string; + activityUrl?: string | null; }): Promise; registerCwdEnv(options: { cwd: string; env: Record }): void; + validateTerminalActivityToken(terminalId: string, token: string): "valid" | "unknown" | "invalid"; getTerminal(id: string): TerminalSession | undefined; getTerminalState( id: string, options?: TerminalStateSnapshotOptions, ): Promise; setTerminalTitle(id: string, title: string): boolean; + setTerminalActivity(id: string, state: TerminalActivityState): Promise; + clearTerminalAttention(id: string): Promise; killTerminal(id: string): void; killTerminalAndWait( id: string, @@ -52,14 +71,28 @@ export interface TerminalManager { listDirectories(): string[]; killAll(): void; subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void; + subscribeTerminalActivity(listener: TerminalActivityListener): () => void; } -export function createTerminalManager(): TerminalManager { +export interface TerminalManagerOptions { + getTerminalActivityUrl?: () => string | null; +} + +function createActivityToken(): string { + return randomBytes(32).toString("base64url"); +} + +export function createTerminalManager( + managerOptions: TerminalManagerOptions = {}, +): TerminalManager { const terminalsByCwd = new Map(); const terminalsById = new Map(); const terminalExitUnsubscribeById = new Map void>(); const terminalTitleUnsubscribeById = new Map void>(); + const terminalActivityUnsubscribeById = new Map void>(); + const terminalActivityTokenById = new Map(); const terminalsChangedListeners = new Set(); + const terminalActivityListeners = new Set(); const defaultEnvByRootCwd = new Map>(); function assertAbsolutePath(cwd: string): void { @@ -84,8 +117,14 @@ export function createTerminalManager(): TerminalManager { unsubscribeTitle(); terminalTitleUnsubscribeById.delete(id); } + const unsubscribeActivity = terminalActivityUnsubscribeById.get(id); + if (unsubscribeActivity) { + unsubscribeActivity(); + terminalActivityUnsubscribeById.delete(id); + } terminalsById.delete(id); + terminalActivityTokenById.delete(id); const terminals = terminalsByCwd.get(session.cwd); if (terminals) { @@ -130,8 +169,13 @@ export function createTerminalManager(): TerminalManager { const unsubscribeTitle = session.onTitleChange(() => { emitTerminalsChanged({ cwd: session.cwd }); }); + const unsubscribeActivity = session.onActivityChange((transition) => { + emitTerminalActivityTransition({ session, transition }); + emitTerminalsChanged({ cwd: session.cwd }); + }); terminalExitUnsubscribeById.set(session.id, unsubscribeExit); terminalTitleUnsubscribeById.set(session.id, unsubscribeTitle); + terminalActivityUnsubscribeById.set(session.id, unsubscribeActivity); return session; } @@ -141,6 +185,7 @@ export function createTerminalManager(): TerminalManager { name: input.session.name, cwd: input.session.cwd, title: input.session.getTitle(), + activity: input.session.getActivity(), }; } @@ -166,6 +211,29 @@ export function createTerminalManager(): TerminalManager { } } + function emitTerminalActivityTransition(input: { + session: TerminalSession; + transition: TerminalActivityTransition; + }): void { + if (terminalActivityListeners.size === 0) { + return; + } + const event: TerminalActivityTransitionEvent = { + terminalId: input.session.id, + name: input.session.name, + cwd: input.session.cwd, + activity: input.transition.activity, + previous: input.transition.previous, + }; + for (const listener of terminalActivityListeners) { + try { + listener(event); + } catch { + // no-op + } + } + } + return { async getTerminals(cwd: string): Promise { assertAbsolutePath(cwd); @@ -190,6 +258,8 @@ export function createTerminalManager(): TerminalManager { env?: Record; command?: string; args?: string[]; + activityToken?: string; + activityUrl?: string | null; }): Promise { assertAbsolutePath(options.cwd); @@ -198,17 +268,36 @@ export function createTerminalManager(): TerminalManager { const inheritedEnv = resolveDefaultEnvForCwd(options.cwd); const mergedEnv = inheritedEnv || options.env ? { ...inheritedEnv, ...options.env } : undefined; - const session = registerSession( - await createTerminal({ - ...(options.id ? { id: options.id } : {}), - cwd: options.cwd, - name: options.name ?? defaultName, - ...(options.title ? { title: options.title } : {}), - ...(options.command ? { command: options.command } : {}), - ...(options.args ? { args: options.args } : {}), - ...(mergedEnv ? { env: mergedEnv } : {}), - }), - ); + const terminalId = options.id ?? randomUUID(); + const activityToken = options.activityToken ?? createActivityToken(); + const terminalActivityUrl = + options.activityUrl === undefined + ? (managerOptions.getTerminalActivityUrl?.() ?? null) + : options.activityUrl; + const activityEnv = { + PASEO_TERMINAL_ID: terminalId, + PASEO_ACTIVITY_TOKEN: activityToken, + ...(terminalActivityUrl ? { PASEO_TERMINAL_ACTIVITY_URL: terminalActivityUrl } : {}), + }; + terminalActivityTokenById.set(terminalId, activityToken); + let session: TerminalSession; + try { + session = registerSession( + await createTerminal({ + id: terminalId, + cwd: options.cwd, + name: options.name ?? defaultName, + ...(options.title ? { title: options.title } : {}), + ...(options.command ? { command: options.command } : {}), + ...(options.args ? { args: options.args } : {}), + ...(mergedEnv ? { env: mergedEnv } : {}), + activityEnv, + }), + ); + } catch (error) { + terminalActivityTokenById.delete(terminalId); + throw error; + } terminals.push(session); terminalsByCwd.set(options.cwd, terminals); @@ -222,6 +311,17 @@ export function createTerminalManager(): TerminalManager { defaultEnvByRootCwd.set(resolve(options.cwd), { ...options.env }); }, + validateTerminalActivityToken( + terminalId: string, + token: string, + ): "valid" | "unknown" | "invalid" { + const expected = terminalActivityTokenById.get(terminalId); + if (!expected) { + return "unknown"; + } + return expected === token ? "valid" : "invalid"; + }, + getTerminal(id: string): TerminalSession | undefined { return terminalsById.get(id); }, @@ -243,6 +343,25 @@ export function createTerminalManager(): TerminalManager { return true; }, + async setTerminalActivity(id: string, state: TerminalActivityState): Promise { + const session = terminalsById.get(id); + if (!session) { + return false; + } + + session.setActivity(state); + return true; + }, + + async clearTerminalAttention(id: string): Promise { + const session = terminalsById.get(id); + if (!session) { + return false; + } + + return session.clearActivityAttention(); + }, + killTerminal(id: string): void { removeSessionById(id, { kill: true }); }, @@ -292,5 +411,12 @@ export function createTerminalManager(): TerminalManager { terminalsChangedListeners.delete(listener); }; }, + + subscribeTerminalActivity(listener: TerminalActivityListener): () => void { + terminalActivityListeners.add(listener); + return () => { + terminalActivityListeners.delete(listener); + }; + }, }; } diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index a729f8d0e..777e3ee3b 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -69,11 +69,14 @@ describe("terminal-session-controller restore", () => { onExit: () => vi.fn(), onCommandFinished: () => vi.fn(), onTitleChange: () => vi.fn(), + onActivityChange: () => vi.fn(), getSize: () => ({ rows: 1, cols: 80 }), getState: () => terminalState("restore-before"), getStateSnapshot: () => ({ state: terminalState("restore-before"), revision: 1 }), getReplayPreamble: () => "", getTitle: () => undefined, + getActivity: () => null, + setActivity: vi.fn(), setTitle: vi.fn(), getExitInfo: () => null, kill: vi.fn(), @@ -83,15 +86,18 @@ describe("terminal-session-controller restore", () => { getTerminals: vi.fn(), createTerminal: vi.fn(), registerCwdEnv: vi.fn(), + validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminal: vi.fn(() => terminal), getTerminalState: vi.fn(() => snapshot.promise), setTerminalTitle: vi.fn(), + setTerminalActivity: vi.fn(), killTerminal: vi.fn(), killTerminalAndWait: vi.fn(), captureTerminal: vi.fn(), listDirectories: vi.fn(() => []), killAll: vi.fn(), subscribeTerminalsChanged: vi.fn(() => vi.fn()), + subscribeTerminalActivity: vi.fn(() => vi.fn()), }; const controller = new TerminalSessionController({ terminalManager, @@ -152,11 +158,14 @@ function listSession(input: { id: string; name: string; cwd: string }): Terminal onExit: () => vi.fn(), onCommandFinished: () => vi.fn(), onTitleChange: () => vi.fn(), + onActivityChange: () => vi.fn(), getSize: () => ({ rows: 1, cols: 80 }), getState: () => terminalState(""), getStateSnapshot: () => ({ state: terminalState(""), revision: 0 }), getReplayPreamble: () => "", getTitle: () => undefined, + getActivity: () => null, + setActivity: vi.fn(), setTitle: vi.fn(), getExitInfo: () => null, kill: vi.fn(), @@ -191,11 +200,14 @@ describe("terminal-session-controller wrap-flag gating", () => { onExit: () => vi.fn(), onCommandFinished: () => vi.fn(), onTitleChange: () => vi.fn(), + onActivityChange: () => vi.fn(), getSize: () => ({ rows: 1, cols: 80 }), getState: () => terminalState("hello"), getStateSnapshot: () => ({ state: terminalState("hello"), revision: 1 }), getReplayPreamble: () => "", getTitle: () => undefined, + getActivity: () => null, + setActivity: vi.fn(), setTitle: vi.fn(), getExitInfo: () => null, kill: vi.fn(), @@ -208,15 +220,18 @@ describe("terminal-session-controller wrap-flag gating", () => { getTerminals: vi.fn(), createTerminal: vi.fn(), registerCwdEnv: vi.fn(), + validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminal: vi.fn(() => terminal), getTerminalState, setTerminalTitle: vi.fn(), + setTerminalActivity: vi.fn(), killTerminal: vi.fn(), killTerminalAndWait: vi.fn(), captureTerminal: vi.fn(), listDirectories: vi.fn(() => []), killAll: vi.fn(), subscribeTerminalsChanged: vi.fn(() => vi.fn()), + subscribeTerminalActivity: vi.fn(() => vi.fn()), } as unknown as TerminalManager; const controller = new TerminalSessionController({ terminalManager, @@ -277,9 +292,11 @@ describe("terminal-session-controller subdirectory aggregation", () => { getTerminals: vi.fn(async (cwd: string) => (cwd === rootCwd ? aggregatedRootTerminals : [])), createTerminal: vi.fn(), registerCwdEnv: vi.fn(), + validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminal: vi.fn(), getTerminalState: vi.fn(), setTerminalTitle: vi.fn(), + setTerminalActivity: vi.fn(), killTerminal: vi.fn(), killTerminalAndWait: vi.fn(), captureTerminal: vi.fn(), @@ -289,6 +306,7 @@ describe("terminal-session-controller subdirectory aggregation", () => { changedListener = listener; return vi.fn(); }), + subscribeTerminalActivity: vi.fn(() => vi.fn()), }; const outboundMessages: SessionOutboundMessage[] = []; @@ -318,8 +336,8 @@ describe("terminal-session-controller subdirectory aggregation", () => { payload: { cwd: rootCwd, terminals: [ - { id: "root-term", name: "Terminal 1" }, - { id: "subdir-term", name: "Mobile" }, + { id: "root-term", name: "Terminal 1", activity: null }, + { id: "subdir-term", name: "Mobile", activity: null }, ], }, }, @@ -341,15 +359,18 @@ describe("terminal-session-controller subdirectory aggregation", () => { ), createTerminal: vi.fn(), registerCwdEnv: vi.fn(), + validateTerminalActivityToken: vi.fn(() => "unknown"), getTerminal: vi.fn(), getTerminalState: vi.fn(), setTerminalTitle: vi.fn(), + setTerminalActivity: vi.fn(), killTerminal: vi.fn(), killTerminalAndWait: vi.fn(), captureTerminal: vi.fn(), listDirectories: vi.fn(() => [rootCwd, worktreeCwd]), killAll: vi.fn(), subscribeTerminalsChanged: vi.fn(() => vi.fn()), + subscribeTerminalActivity: vi.fn(() => vi.fn()), }; const outboundMessages: SessionOutboundMessage[] = []; const controller = new TerminalSessionController({ @@ -378,7 +399,7 @@ describe("terminal-session-controller subdirectory aggregation", () => { type: "list_terminals_response", payload: { cwd: rootCwd, - terminals: [{ id: "root-term", name: "Terminal 1" }], + terminals: [{ id: "root-term", name: "Terminal 1", activity: null }], requestId: "req-root", }, }, @@ -386,7 +407,7 @@ describe("terminal-session-controller subdirectory aggregation", () => { type: "list_terminals_response", payload: { cwd: worktreeCwd, - terminals: [{ id: "worktree-term", name: "Feature" }], + terminals: [{ id: "worktree-term", name: "Feature", activity: null }], requestId: "req-worktree", }, }, diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 210e5a75b..cddf6e2d1 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -33,6 +33,7 @@ import { } from "./terminal-restore.js"; import type { TerminalSession } from "./terminal.js"; import type { TerminalManager, TerminalsChangedEvent } from "./terminal-manager.js"; +import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; const MAX_TERMINAL_STREAM_SLOTS = 256; @@ -289,7 +290,12 @@ export class TerminalSessionController { private emitTerminalsChangedSnapshot(input: { cwd: string; - terminals: Array<{ id: string; name: string; title?: string }>; + terminals: Array<{ + id: string; + name: string; + title?: string; + activity: TerminalActivity | null; + }>; }): void { this.emit({ type: "terminals_changed", @@ -300,16 +306,21 @@ export class TerminalSessionController { }); } - private toTerminalInfo(terminal: Pick): { + private toTerminalInfo( + terminal: Pick, + ): { id: string; name: string; title?: string; + activity: TerminalActivity | null; } { const title = terminal.getTitle(); + const activity = terminal.getActivity(); return { id: terminal.id, name: terminal.name, ...(title ? { title } : {}), + activity, }; } @@ -502,6 +513,7 @@ export class TerminalSessionController { name: session.name, cwd: session.cwd, ...(session.getTitle() ? { title: session.getTitle() } : {}), + activity: session.getActivity(), }, error: null, requestId: msg.requestId, diff --git a/packages/server/src/terminal/terminal-worker-process.ts b/packages/server/src/terminal/terminal-worker-process.ts index 403ea0cb9..7699f78b4 100644 --- a/packages/server/src/terminal/terminal-worker-process.ts +++ b/packages/server/src/terminal/terminal-worker-process.ts @@ -67,6 +67,7 @@ function toTerminalInfo(session: TerminalSession): WorkerTerminalInfo { name: session.name, cwd: session.cwd, ...(session.getTitle() ? { title: session.getTitle() } : {}), + activity: session.getActivity(), }; } @@ -168,12 +169,21 @@ function watchTerminal(session: TerminalSession): void { info, }); }); + const unsubscribeActivity = session.onActivityChange((transition) => { + sendToParent({ + type: "terminalActivityChange", + terminalId: session.id, + activity: transition.activity, + previous: transition.previous, + }); + }); unsubscribeByTerminalId.set(session.id, [ unsubscribeMessage, unsubscribeExit, unsubscribeTitle, unsubscribeCommandFinished, + unsubscribeActivity, ]); } @@ -252,6 +262,12 @@ async function handleRequest(message: TerminalWorkerRequest): Promise { return; } + case "setActivity": { + await manager.setTerminalActivity(message.terminalId, message.state); + sendToParent({ type: "response", requestId: message.requestId, ok: true }); + return; + } + case "killTerminal": { const session = manager.getTerminal(message.terminalId); const cwd = session?.cwd; @@ -299,16 +315,6 @@ async function handleRequest(message: TerminalWorkerRequest): Promise { return; } - case "listDirectories": { - sendToParent({ - type: "response", - requestId: message.requestId, - ok: true, - result: manager.listDirectories(), - }); - return; - } - case "captureTerminal": { const session = manager.getTerminal(message.terminalId); const result = session diff --git a/packages/server/src/terminal/terminal-worker-protocol.ts b/packages/server/src/terminal/terminal-worker-protocol.ts index 6abda8c1e..a9c087afb 100644 --- a/packages/server/src/terminal/terminal-worker-protocol.ts +++ b/packages/server/src/terminal/terminal-worker-protocol.ts @@ -6,6 +6,7 @@ import type { TerminalStateSnapshotOptions, } from "./terminal.js"; import type { TerminalState } from "@getpaseo/protocol/messages"; +import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; import type { CaptureTerminalLinesResult } from "./terminal-capture.js"; export interface WorkerTerminalInfo { @@ -13,6 +14,7 @@ export interface WorkerTerminalInfo { name: string; cwd: string; title?: string; + activity: TerminalActivity | null; } export interface WorkerCreateTerminalOptions { @@ -23,6 +25,8 @@ export interface WorkerCreateTerminalOptions { env?: Record; command?: string; args?: string[]; + activityToken?: string; + activityUrl?: string | null; } export interface WorkerKillAndWaitOptions { @@ -47,6 +51,12 @@ export type TerminalWorkerRequest = cwd: string; env: Record; } + | { + type: "setActivity"; + requestId: string; + terminalId: string; + state: TerminalActivityState; + } | { type: "killTerminal"; requestId: string; @@ -72,10 +82,6 @@ export type TerminalWorkerRequest = end?: number; stripAnsi?: boolean; } - | { - type: "listDirectories"; - requestId: string; - } | { type: "killAll"; requestId: string; @@ -138,6 +144,12 @@ export type TerminalWorkerEvent = type: "terminalsChanged"; cwd: string; terminals: WorkerTerminalInfo[]; + } + | { + type: "terminalActivityChange"; + terminalId: string; + activity: TerminalActivity | null; + previous: TerminalActivity | null; }; export type TerminalWorkerToParentMessage = TerminalWorkerResponse | TerminalWorkerEvent; diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 692fa258f..13594cf4c 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -3,7 +3,7 @@ import xterm, { type Terminal as TerminalType } from "@xterm/headless"; import { randomUUID } from "crypto"; import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; import { tmpdir, userInfo } from "node:os"; -import { basename, dirname, extname, join } from "node:path"; +import { basename, delimiter, dirname, extname, join, resolve as resolvePath } from "node:path"; import { createRequire } from "node:module"; import { fileURLToPath } from "node:url"; import { createExternalProcessEnv } from "../server/paseo-env.js"; @@ -11,9 +11,12 @@ import { writePrivateFileAtomicSync } from "../server/private-files.js"; import { findExecutable } from "../executable-resolution/executable-resolution.js"; import type { TerminalCell, TerminalState } from "@getpaseo/protocol/messages"; import { TerminalInputModeTracker } from "@getpaseo/protocol/terminal-input-mode"; +import { TerminalActivityTracker } from "./activity/terminal-activity-tracker.js"; +import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; const { Terminal } = xterm; const require = createRequire(import.meta.url); +const PASEO_CLI_BIN_ENTRY = "@getpaseo/cli/bin/paseo"; let nodePtySpawnHelperChecked = false; const TERMINAL_TITLE_DEBOUNCE_MS = 150; const TERMINAL_EXIT_OUTPUT_LINE_LIMIT = 12; @@ -65,6 +68,11 @@ export type ServerMessage = | { type: "snapshotReady"; revision?: number; replayPreamble?: string } | { type: "titleChange"; title?: string }; +export interface TerminalActivityTransition { + activity: TerminalActivity | null; + previous: TerminalActivity | null; +} + export interface TerminalSession { id: string; name: string; @@ -74,11 +82,15 @@ export interface TerminalSession { onExit(listener: (info: TerminalExitInfo) => void): () => void; onCommandFinished(listener: (info: TerminalCommandFinishedInfo) => void): () => void; onTitleChange(listener: (title?: string) => void): () => void; + onActivityChange(listener: (transition: TerminalActivityTransition) => void): () => void; getSize(): { rows: number; cols: number }; getState(): TerminalState; getStateSnapshot(options?: TerminalStateSnapshotOptions): TerminalStateSnapshot; getReplayPreamble(): string; getTitle(): string | undefined; + getActivity(): TerminalActivity | null; + setActivity(state: TerminalActivityState): void; + clearActivityAttention(): boolean; setTitle(title: string): void; getExitInfo(): TerminalExitInfo | null; kill(): void; @@ -107,6 +119,7 @@ export interface CreateTerminalOptions { cwd: string; shell?: string; env?: Record; + activityEnv?: Record; rows?: number; cols?: number; name?: string; @@ -115,10 +128,26 @@ export interface CreateTerminalOptions { args?: string[]; } +function toTerminalActivity(snapshot: { + state: TerminalActivityState | null; + changedAt: number; +}): TerminalActivity | null { + if (!snapshot.state) { + return null; + } + return { state: snapshot.state, changedAt: snapshot.changedAt }; +} + +function resolveInitialTitleMode(presetTitle: string | undefined): "auto" | "manual" { + return presetTitle?.trim() ? "manual" : "auto"; +} + interface BuildTerminalEnvironmentInput { shell: string; env: Record; zshShellIntegrationDir?: string; + paseoCliBinDir?: string | null; + paseoHookCliPath?: string | null; } interface EnsureNodePtySpawnHelperExecutableOptions { @@ -268,7 +297,77 @@ export function resolveZshShellIntegrationDir(): string { } function resolveExternalProcessPath(filePath: string): string { - return filePath.replace(/\.asar(?=\/|$)/, ".asar.unpacked"); + return filePath.replace(/\.asar(?=[/\\]|$)/, ".asar.unpacked"); +} + +export function resolvePaseoCliBinDir(): string | null { + const cliEntrypoint = resolvePaseoCliBinEntrypoint(); + if (!cliEntrypoint) { + return null; + } + + const externalCliEntrypoint = resolveExternalProcessPath(cliEntrypoint); + return findNpmBinDir(dirname(externalCliEntrypoint)) ?? dirname(externalCliEntrypoint); +} + +export function resolvePaseoCliExecutablePath(): string | null { + const cliEntrypoint = resolvePaseoCliBinEntrypoint(); + if (!cliEntrypoint) { + return null; + } + + const externalCliEntrypoint = resolveExternalProcessPath(cliEntrypoint); + const npmBinDir = findNpmBinDir(dirname(externalCliEntrypoint)); + if (npmBinDir) { + const shim = resolvePaseoCliShim(npmBinDir); + if (shim) { + return shim; + } + } + + return externalCliEntrypoint; +} + +function resolvePaseoCliBinEntrypoint(): string | null { + try { + return require.resolve(PASEO_CLI_BIN_ENTRY); + } catch { + return null; + } +} + +function findNpmBinDir(startPath: string): string | null { + let current = startPath; + while (true) { + const candidate = join(current, "node_modules", ".bin"); + if (hasPaseoCliShim(candidate)) { + return candidate; + } + + const parent = dirname(current); + if (parent === current) { + return null; + } + current = parent; + } +} + +function hasPaseoCliShim(binDir: string): boolean { + return resolvePaseoCliShim(binDir) !== null; +} + +function resolvePaseoCliShim(binDir: string): string | null { + for (const name of paseoCliShimNames()) { + const candidate = join(binDir, name); + if (existsSync(candidate)) { + return candidate; + } + } + return null; +} + +function paseoCliShimNames(): string[] { + return process.platform === "win32" ? ["paseo.cmd", "paseo.exe", "paseo"] : ["paseo"]; } function resolveZshShellIntegrationRuntimeDir(): string { @@ -304,19 +403,66 @@ export function buildTerminalEnvironment( TERM: "xterm-256color", TERM_PROGRAM: "kitty", }); + const envWithAgentHooks = prependPaseoCliToPath( + baseEnv, + input.paseoCliBinDir === undefined ? resolvePaseoCliBinDir() : input.paseoCliBinDir, + ); + const envWithHookCli = injectPaseoHookCli( + envWithAgentHooks, + input.paseoHookCliPath === undefined ? resolvePaseoCliExecutablePath() : input.paseoHookCliPath, + ); if (basename(input.shell) !== "zsh") { - return baseEnv; + return envWithHookCli; } - const originalZdotdir = baseEnv.ZDOTDIR ?? ""; + const originalZdotdir = envWithHookCli.ZDOTDIR ?? ""; return { - ...baseEnv, + ...envWithHookCli, PASEO_ZSH_ZDOTDIR: originalZdotdir, ZDOTDIR: prepareZshShellIntegrationRuntimeDir(input.zshShellIntegrationDir), }; } +function injectPaseoHookCli( + env: Record, + cliPath: string | null, +): Record { + if (!cliPath) { + return env; + } + + return { + ...env, + PASEO_HOOK_CLI: resolvePath(resolveExternalProcessPath(cliPath)), + }; +} + +function prependPaseoCliToPath( + env: Record, + cliBinDir: string | null, +): Record { + if (!cliBinDir) { + return env; + } + + const pathKey = getPathEnvKey(env); + const currentPath = env[pathKey] ?? ""; + return { + ...env, + [pathKey]: prependPathEntry(currentPath, cliBinDir), + }; +} + +function getPathEnvKey(env: Record): string { + return Object.keys(env).find((key) => key.toLowerCase() === "path") ?? "PATH"; +} + +function prependPathEntry(currentPath: string, entry: string): string { + const entries = currentPath.split(delimiter).filter((value) => value && value !== entry); + return [entry, ...entries].join(delimiter); +} + function extractCell(terminal: TerminalType, row: number, col: number): TerminalCell { const buffer = terminal.buffer.active; const line = buffer.getLine(row); @@ -648,6 +794,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise | null = null; let pendingInput = ""; let inputFlushImmediate: ReturnType | null = null; let stateRevision = 0; const inputModeTracker = new TerminalInputModeTracker(); + const activityTracker = new TerminalActivityTracker(); + const activityChangeListeners = new Set<(transition: TerminalActivityTransition) => void>(); let titleChangeSubscription: { dispose(): void } | null = null; // Create xterm.js headless terminal @@ -703,7 +852,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { + if (disposed || killed) { + return; + } + const transition: TerminalActivityTransition = { + activity: toTerminalActivity(snapshot), + previous: toTerminalActivity(previousSnapshot), + }; + for (const listener of Array.from(activityChangeListeners)) { + try { + listener(transition); + } catch { + // no-op + } + } + }); + function buildExitInfo(input?: { exitCode?: number | null; signal?: number | null; @@ -873,6 +1039,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise void, + ): () => void { + activityChangeListeners.add(listener); + return () => { + activityChangeListeners.delete(listener); + }; + } + function getTitle(): string | undefined { return title; } + function getActivity(): TerminalActivity | null { + return toTerminalActivity(activityTracker.getSnapshot()); + } + + function setActivity(state: TerminalActivityState): void { + activityTracker.set(state); + } + + function clearActivityAttention(): boolean { + return activityTracker.clearAttention(); + } + function getExitInfo(): TerminalExitInfo | null { return exitInfo; } @@ -1248,11 +1438,15 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { + const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-activity-env-")); + temporaryDirs.push(cwd); + const envPath = join(cwd, "activity-env.json"); + const activityUrl = "http://127.0.0.1:12345/api/terminal-activity"; + manager = createWorkerTerminalManager({ + getTerminalActivityUrl: () => activityUrl, + }); + + const session = trackTerminal( + await manager.createTerminal({ + cwd, + ...nodeTerminalCommand(` + require("node:fs").writeFileSync( + ${JSON.stringify(envPath)}, + JSON.stringify({ + terminalId: process.env.PASEO_TERMINAL_ID, + token: process.env.PASEO_ACTIVITY_TOKEN, + url: process.env.PASEO_TERMINAL_ACTIVITY_URL, + hookCli: process.env.PASEO_HOOK_CLI, + path: process.env.PATH ?? process.env.Path, + }), + ); + setInterval(() => {}, 1000); + `), + }), + ); + + await waitForCondition(() => existsSync(envPath), 10000); + + const env = JSON.parse(readFileSync(envPath, "utf8")) as { + terminalId?: string; + token?: string; + url?: string; + hookCli?: string; + path?: string; + }; + const paseoCliBinDir = resolvePaseoCliBinDir(); + const paseoCliPath = resolvePaseoCliExecutablePath(); + expect(paseoCliBinDir).not.toBeNull(); + expect(paseoCliPath).not.toBeNull(); + expect(env.terminalId).toBe(session.id); + expect(env.token).toEqual(expect.any(String)); + expect(env.token).not.toBe(""); + expect(env.url).toBe(activityUrl); + expect(env.hookCli).toBe(paseoCliPath); + expect(manager.validateTerminalActivityToken(session.id, env.token ?? "")).toBe("valid"); + await expect(manager.setTerminalActivity(session.id, "attention")).resolves.toBe(true); + expect(env.path?.split(delimiter)[0]).toBe(paseoCliBinDir); +}); + it("starts the default shell through the worker and accepts quoted commands", async () => { manager = createWorkerTerminalManager(); const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-shell-")); @@ -462,6 +523,133 @@ it("lists subdirectory terminals when querying the workspace root", async () => expect(rootTerminals.map((terminal) => terminal.id)).toEqual([created.id]); }); +it("surfaces worker activity changes via getActivity, onActivityChange, and terminalsChanged", async () => { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + + worker.emitWorkerMessage({ + type: "terminalCreated", + terminal: { + id: "terminal-a", + name: "Shell", + cwd: "/workspace", + activity: { state: "idle", changedAt: 0 }, + }, + state: createTerminalState(), + }); + + const session = manager.getTerminal("terminal-a"); + expect(session).toBeDefined(); + expect(session!.getActivity()).toEqual({ state: "idle", changedAt: 0 }); + + const activityChanges: Array<{ + activity: TerminalActivity | null; + previous: TerminalActivity | null; + }> = []; + const activityTransitions: TerminalActivityTransitionEvent[] = []; + const terminalsChangedCwds: string[] = []; + session!.onActivityChange((transition) => { + activityChanges.push(transition); + }); + manager.subscribeTerminalActivity((event) => { + activityTransitions.push(event); + }); + manager.subscribeTerminalsChanged((event) => { + terminalsChangedCwds.push(event.cwd); + }); + + const workingActivity = { state: "working" as const, changedAt: 1000 }; + const idleActivity = { state: "idle" as const, changedAt: 0 }; + worker.emitWorkerMessage({ + type: "terminalActivityChange", + terminalId: "terminal-a", + activity: workingActivity, + previous: idleActivity, + }); + + expect(session!.getActivity()).toEqual(workingActivity); + expect(activityChanges).toEqual([{ activity: workingActivity, previous: idleActivity }]); + expect(activityTransitions).toEqual([ + { + terminalId: "terminal-a", + name: "Shell", + cwd: "/workspace", + activity: workingActivity, + previous: idleActivity, + }, + ]); + expect(terminalsChangedCwds).toContain("/workspace"); +}); + +it("sets terminal activity through a worker request", async () => { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + worker.emitWorkerMessage({ + type: "terminalCreated", + terminal: { + id: "terminal-a", + name: "Shell", + cwd: "/workspace", + activity: null, + }, + state: createTerminalState(), + }); + + const result = manager.setTerminalActivity("terminal-a", "attention"); + const request = worker.sentMessages.find((message) => message.type === "setActivity"); + expect(request).toMatchObject({ + type: "setActivity", + terminalId: "terminal-a", + state: "attention", + }); + if (!request) { + throw new Error("setActivity request not sent"); + } + worker.emitWorkerMessage({ type: "response", requestId: request.requestId, ok: true }); + + await expect(result).resolves.toBe(true); +}); + +it("clears terminal attention through a worker activity update", async () => { + const worker = new FakeTerminalWorker(); + manager = createWorkerTerminalManager({ + requestTimeoutMs: 50, + forkWorker: () => worker, + }); + worker.emitWorkerMessage({ + type: "terminalCreated", + terminal: { + id: "terminal-a", + name: "Shell", + cwd: "/workspace", + activity: { state: "attention", changedAt: 1000 }, + }, + state: createTerminalState(), + }); + + const result = manager.clearTerminalAttention("terminal-a"); + const request = worker.sentMessages.find( + (message) => message.type === "setActivity" && message.state === "idle", + ); + expect(request).toMatchObject({ + type: "setActivity", + terminalId: "terminal-a", + state: "idle", + }); + if (!request) { + throw new Error("attention clear request not sent"); + } + worker.emitWorkerMessage({ type: "response", requestId: request.requestId, ok: true }); + + await expect(result).resolves.toBe(true); +}); + it("removes worker terminals after killAndWait", async () => { const cwd = mkdtempSync(join(tmpdir(), "worker-terminal-manager-kill-")); temporaryDirs.push(cwd); diff --git a/packages/server/src/terminal/worker-terminal-manager.ts b/packages/server/src/terminal/worker-terminal-manager.ts index 3741d38ca..dbbebac03 100644 --- a/packages/server/src/terminal/worker-terminal-manager.ts +++ b/packages/server/src/terminal/worker-terminal-manager.ts @@ -1,10 +1,12 @@ -import { fork } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { randomUUID } from "node:crypto"; +import { fork } from "node:child_process"; +import { randomBytes, randomUUID } from "node:crypto"; import type { TerminalState } from "@getpaseo/protocol/messages"; +import type { TerminalActivity, TerminalActivityState } from "@getpaseo/protocol/terminal-activity"; import type { ClientMessage, ServerMessage, + TerminalActivityTransition, TerminalCommandFinishedInfo, TerminalExitInfo, TerminalSession, @@ -12,6 +14,8 @@ import type { } from "./terminal.js"; import type { CaptureTerminalLinesResult } from "./terminal-capture.js"; import type { + TerminalActivityListener, + TerminalActivityTransitionEvent, TerminalListItem, TerminalManager, TerminalsChangedEvent, @@ -43,6 +47,7 @@ interface PendingRequest { interface WorkerTerminalRecord { info: WorkerTerminalInfo; state: TerminalState; + activity: TerminalActivity | null; // Cached input-mode preamble from the worker (the authoritative tracker lives // in the worker process). Refreshed on every getTerminalState response and on // the snapshotReady event that precedes a live-restore replay. @@ -52,6 +57,7 @@ interface WorkerTerminalRecord { exitListeners: Set<(info: TerminalExitInfo) => void>; commandFinishedListeners: Set<(info: TerminalCommandFinishedInfo) => void>; titleChangeListeners: Set<(title?: string) => void>; + activityChangeListeners: Set<(transition: TerminalActivityTransition) => void>; session: TerminalSession; } @@ -68,6 +74,11 @@ interface TerminalWorkerProcess { interface WorkerTerminalManagerOptions { requestTimeoutMs?: number; forkWorker?: () => TerminalWorkerProcess; + getTerminalActivityUrl?: () => string | null; +} + +function createActivityToken(): string { + return randomBytes(32).toString("base64url"); } function resolveWorkerUrl(): URL { @@ -105,6 +116,7 @@ function cloneTerminalInfo(info: WorkerTerminalInfo): WorkerTerminalInfo { name: info.name, cwd: info.cwd, ...(info.title ? { title: info.title } : {}), + activity: info.activity, }; } @@ -124,7 +136,9 @@ export function createWorkerTerminalManager( const pendingRequests = new Map(); const recordsById = new Map(); const terminalIdsByCwd = new Map>(); + const terminalActivityTokenById = new Map(); const terminalsChangedListeners = new Set(); + const terminalActivityListeners = new Set(); let workerExited = false; let workerShutdownTimer: ReturnType | null = null; @@ -138,6 +152,16 @@ export function createWorkerTerminalManager( } } + function emitTerminalActivityTransition(event: TerminalActivityTransitionEvent): void { + for (const listener of Array.from(terminalActivityListeners)) { + try { + listener(event); + } catch { + // no-op + } + } + } + function listTerminalItemsForCwd(cwd: string): TerminalListItem[] { const terminalIds = terminalIdsByCwd.get(cwd); if (!terminalIds) { @@ -154,6 +178,7 @@ export function createWorkerTerminalManager( name: record.info.name, cwd: record.info.cwd, ...(record.info.title ? { title: record.info.title } : {}), + activity: record.activity, }); } return terminals; @@ -173,12 +198,14 @@ export function createWorkerTerminalManager( const record: WorkerTerminalRecord = { info: cloneTerminalInfo(input.info), state: input.state, + activity: input.info.activity, replayPreamble: "", exitInfo: null, messageListeners: new Set(), exitListeners: new Set(), commandFinishedListeners: new Set(), titleChangeListeners: new Set(), + activityChangeListeners: new Set(), session: undefined as unknown as TerminalSession, }; @@ -247,6 +274,27 @@ export function createWorkerTerminalManager( record.titleChangeListeners.delete(listener); }; }, + onActivityChange(listener: (transition: TerminalActivityTransition) => void): () => void { + record.activityChangeListeners.add(listener); + return () => { + record.activityChangeListeners.delete(listener); + }; + }, + getActivity(): TerminalActivity | null { + return record.activity; + }, + setActivity(state: TerminalActivityState): void { + record.activity = { state, changedAt: Date.now() }; + sendBestEffortRequest({ type: "setActivity", terminalId: record.info.id, state }); + }, + clearActivityAttention(): boolean { + if (record.activity?.state !== "attention") { + return false; + } + record.activity = { state: "idle", changedAt: Date.now() }; + sendBestEffortRequest({ type: "setActivity", terminalId: record.info.id, state: "idle" }); + return true; + }, getSize(): { rows: number; cols: number } { return { rows: record.state.rows, @@ -320,6 +368,7 @@ export function createWorkerTerminalManager( return undefined; } recordsById.delete(terminalId); + terminalActivityTokenById.delete(terminalId); const terminalIds = terminalIdsByCwd.get(record.info.cwd); if (terminalIds) { terminalIds.delete(terminalId); @@ -406,9 +455,43 @@ export function createWorkerTerminalManager( } } + function handleTerminalActivityChangeEvent( + message: Extract, + ): void { + const record = recordsById.get(message.terminalId); + if (!record) { + return; + } + record.activity = message.activity; + const transition: TerminalActivityTransition = { + activity: message.activity, + previous: message.previous, + }; + for (const listener of Array.from(record.activityChangeListeners)) { + listener(transition); + } + emitTerminalActivityTransition({ + terminalId: record.info.id, + name: record.info.name, + cwd: record.info.cwd, + activity: message.activity, + previous: message.previous, + }); + emitTerminalsChanged({ + cwd: record.info.cwd, + terminals: listTerminalItemsForCwd(record.info.cwd), + }); + } + function handleTerminalsChangedEvent( message: Extract, ): void { + for (const terminal of message.terminals) { + const record = recordsById.get(terminal.id); + if (record) { + record.activity = terminal.activity; + } + } emitTerminalsChanged({ cwd: message.cwd, terminals: message.terminals.map((terminal) => ({ @@ -416,6 +499,7 @@ export function createWorkerTerminalManager( name: terminal.name, cwd: terminal.cwd, ...(terminal.title ? { title: terminal.title } : {}), + activity: terminal.activity, })), }); } @@ -460,6 +544,11 @@ export function createWorkerTerminalManager( handleTerminalsChangedEvent(message); return; } + + case "terminalActivityChange": { + handleTerminalActivityChangeEvent(message); + return; + } } } @@ -542,11 +631,33 @@ export function createWorkerTerminalManager( }, async createTerminal(options: WorkerCreateTerminalOptions): Promise { - const result = (await sendRequest({ type: "createTerminal", options })) as { + const terminalId = options.id ?? randomUUID(); + const activityToken = createActivityToken(); + const terminalActivityUrl = managerOptions.getTerminalActivityUrl?.() ?? null; + terminalActivityTokenById.set(terminalId, activityToken); + let result: { terminal: WorkerTerminalInfo; state: TerminalState; }; - return registerRecord({ info: result.terminal, state: result.state }); + try { + result = (await sendRequest({ + type: "createTerminal", + options: { + ...options, + id: terminalId, + activityToken, + activityUrl: terminalActivityUrl, + }, + })) as { + terminal: WorkerTerminalInfo; + state: TerminalState; + }; + } catch (error) { + terminalActivityTokenById.delete(terminalId); + throw error; + } + const session = registerRecord({ info: result.terminal, state: result.state }); + return session; }, registerCwdEnv(options: { cwd: string; env: Record }): void { @@ -557,6 +668,17 @@ export function createWorkerTerminalManager( }); }, + validateTerminalActivityToken( + terminalId: string, + token: string, + ): "valid" | "unknown" | "invalid" { + const expected = terminalActivityTokenById.get(terminalId); + if (!expected) { + return "unknown"; + } + return expected === token ? "valid" : "invalid"; + }, + getTerminal(id: string): TerminalSession | undefined { return recordsById.get(id)?.session; }, @@ -588,6 +710,24 @@ export function createWorkerTerminalManager( return true; }, + async setTerminalActivity(id: string, state: TerminalActivityState): Promise { + const record = recordsById.get(id); + if (!record) { + return false; + } + await sendRequest({ type: "setActivity", terminalId: id, state }); + return true; + }, + + async clearTerminalAttention(id: string): Promise { + const record = recordsById.get(id); + if (!record || record.activity?.state !== "attention") { + return false; + } + await sendRequest({ type: "setActivity", terminalId: id, state: "idle" }); + return true; + }, + killTerminal(id: string): void { void sendRequest({ type: "killTerminal", terminalId: id }).catch(() => { // no-op; kill is intentionally best-effort and synchronous in the public interface. @@ -648,6 +788,13 @@ export function createWorkerTerminalManager( terminalsChangedListeners.delete(listener); }; }, + + subscribeTerminalActivity(listener: TerminalActivityListener): () => void { + terminalActivityListeners.add(listener); + return () => { + terminalActivityListeners.delete(listener); + }; + }, }; } diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index f306777a8..cd2e750af 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -60,6 +60,9 @@ "autoArchiveAfterMerge": { "type": "boolean" }, + "enableTerminalAgentHooks": { + "type": "boolean" + }, "appendSystemPrompt": { "type": "string" },