mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Show activity indicators for terminals and their workspaces (#1507)
* feat: show activity indicators for terminals and their workspaces A terminal surfaces whether the agent running inside it is working, idle, or waiting for input as a tab dot, and that state rolls up into the owning workspace status — so an agent run inside a terminal reads the same as a native agent. Finishing a turn raises a "Terminal finished" notification that routes back to the workspace terminal tab. Activity is reported by the agents, not parsed from terminal output: Claude and Codex command hooks and an OpenCode plugin post coarse state to a local daemon endpoint, and a source-agnostic per-terminal tracker maps it to working/idle/attention. Hook installation is opt-in and off by default because it edits your real agent config files; enable it under a host's Terminal settings and Paseo installs the hooks globally, runs them via `paseo hooks <agent> <event>`, and removes its own hooks when you opt back out. Installation and reporting are idempotent and fail-open, so a missing or failing hook never breaks the user's shell. * Fix terminal activity routing and CI test Test drift: anchor the provider-leak test to the repo root instead of process.cwd() so it passes under the server workspace CI runner. Also restore auth-first JSON parsing outside the terminal-activity route and route subdirectory terminal activity to the deepest active workspace. * Fix workspace resolver regression Code drift: terminal activity routing needs parent-workspace prefix matching, but agent directory and historical fetches keep exact registered workspace semantics, including archived exact matches. * Narrow terminal/draft workspace subscriptions to the fields they use terminal-panel and the draft agent tab subscribed to the whole workspace descriptor via useWorkspace but read only workspaceDirectory (plus projectKind in TerminalPanel, plus id in the draft tab), so they re-rendered on any field change of that workspace (status, diffStat, scripts). Switch them to the existing useWorkspaceDirectory / useWorkspaceFields hooks so they re-render only when the fields they actually read change. No behavior change. * Make TerminalActivity.state forward-compatible A strict z.enum rejects the whole payload when a newer daemon reports a terminal activity state this client predates, breaking the protocol's old-client-parses-new-daemon contract (the activity field has no .catch at its embedding in messages.ts, so the failure bubbles up). Degrade unknown states to "idle" (no indicator, no notification) via .catch, matching the enum-with-catch pattern used elsewhere in the protocol. Add a forward-compat regression test.
This commit is contained in:
@@ -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
|
||||
|
||||
107
docs/terminal-activity.md
Normal file
107
docs/terminal-activity.md
Normal file
@@ -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 <agent> <event>` 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 <event>
|
||||
```
|
||||
|
||||
Codex also receives the Windows equivalent:
|
||||
|
||||
```bat
|
||||
if defined PASEO_TERMINAL_ID (if defined PASEO_HOOK_CLI ("%PASEO_HOOK_CLI%" hooks codex <event>) else (paseo hooks codex <event>))
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> {
|
||||
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<TerminalInstance> {
|
||||
const result = await this.client.createTerminal(this.tempRepo.path, input.name);
|
||||
async createTerminal(input: CreateTerminalInput): Promise<TerminalInstance> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await this.client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
|
||||
174
packages/app/e2e/terminal-activity-indicators.spec.ts
Normal file
174
packages/app/e2e/terminal-activity-indicators.spec.ts
Normal file
@@ -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<void> {
|
||||
await expect(
|
||||
terminalTab(page, terminalId).locator(`[data-status-bucket="${status}"]`),
|
||||
).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function focusTerminalTab(page: Page, terminalId: string): Promise<void> {
|
||||
await terminalTab(page, terminalId).click();
|
||||
}
|
||||
|
||||
class ControlledActivityTerminal {
|
||||
constructor(
|
||||
readonly terminal: TerminalInstance,
|
||||
private readonly harness: TerminalE2EHarness,
|
||||
private readonly triggerDir: string,
|
||||
) {}
|
||||
|
||||
async report(state: HookActivityState): Promise<void> {
|
||||
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<ControlledActivityTerminal> {
|
||||
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<void>,
|
||||
): Promise<void> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<Map<string, AgentLifecycleStatus>>(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 });
|
||||
};
|
||||
}, [
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof setInterval> | null = null;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<View style={settingsStyles.card} testID="host-page-terminal-agent-hooks-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Enable terminal agent hooks</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Get notifications and status from terminal agents. This installs hooks in your agent
|
||||
config files.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={config?.enableTerminalAgentHooks === true}
|
||||
onValueChange={handleValueChange}
|
||||
accessibilityLabel="Enable terminal agent hooks"
|
||||
testID="host-page-terminal-agent-hooks-switch"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -1449,6 +1489,9 @@ export function HostTerminalsPage({ serverId }: { serverId: string }) {
|
||||
|
||||
return (
|
||||
<View>
|
||||
<SettingsSection title="Terminal agents">
|
||||
<EnableTerminalAgentHooksCard serverId={serverId} />
|
||||
</SettingsSection>
|
||||
<TerminalProfilesSection serverId={serverId} />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -223,6 +223,7 @@ function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDa
|
||||
providers,
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
enableTerminalAgentHooks: false,
|
||||
appendSystemPrompt: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -474,8 +474,13 @@ function TabHandleContent({
|
||||
tabLabelSkeletonStyle: React.ComponentProps<typeof View>["style"];
|
||||
tabLabelStyle: React.ComponentProps<typeof Text>["style"];
|
||||
}) {
|
||||
const tabHandleDataSet = useMemo(
|
||||
() => ({ statusBucket: presentation.statusBucket ?? "none" }),
|
||||
[presentation.statusBucket],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.tabHandle}>
|
||||
<View style={styles.tabHandle} dataSet={tabHandleDataSet}>
|
||||
<View style={styles.tabIcon}>
|
||||
<WorkspaceTabIcon presentation={presentation} active={isHighlighted} />
|
||||
</View>
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> | null | undefined;
|
||||
type NotificationRoute = Extract<Href, string>;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
20
packages/app/src/utils/terminal-activity-bucket.test.ts
Normal file
20
packages/app/src/utils/terminal-activity-bucket.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
10
packages/app/src/utils/terminal-activity-bucket.ts
Normal file
10
packages/app/src/utils/terminal-activity-bucket.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
169
packages/cli/src/commands/hooks.test.ts
Normal file
169
packages/cli/src/commands/hooks.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
116
packages/cli/src/commands/hooks.ts
Normal file
116
packages/cli/src/commands/hooks.ts
Normal file
@@ -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<string | Buffer>;
|
||||
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>", "Agent hook source")
|
||||
.argument("<event>", "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<void> {
|
||||
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<string | null> {
|
||||
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<T>(promise: Promise<T>, ms: number): Promise<T | null> {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<null>((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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
19
packages/protocol/src/terminal-activity.test.ts
Normal file
19
packages/protocol/src/terminal-activity.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
15
packages/protocol/src/terminal-activity.ts
Normal file
15
packages/protocol/src/terminal-activity.ts
Normal file
@@ -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<typeof TerminalActivitySchema>;
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>): 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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,
|
||||
|
||||
@@ -386,6 +386,7 @@ export function loadConfig(
|
||||
mcpEnabled,
|
||||
mcpInjectIntoAgents,
|
||||
autoArchiveAfterMerge,
|
||||
enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false,
|
||||
appendSystemPrompt,
|
||||
terminalProfiles,
|
||||
mcpDebug: env.MCP_DEBUG === "1",
|
||||
|
||||
@@ -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: [] },
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
|
||||
@@ -118,6 +118,7 @@ interface SessionTestAccess {
|
||||
paseoHome: string;
|
||||
terminalManager: {
|
||||
killTerminal(id: string): unknown;
|
||||
clearTerminalAttention?(id: string): Promise<boolean>;
|
||||
} | null;
|
||||
workspaceGitService: {
|
||||
getCheckout: (cwd: string) => Promise<unknown>;
|
||||
@@ -460,6 +461,7 @@ function createSessionForWorkspaceTests(
|
||||
appVersion?: string | null;
|
||||
onMessage?: (message: SessionOutboundMessage) => void;
|
||||
workspaceGitService?: ReturnType<typeof createNoopWorkspaceGitService>;
|
||||
terminalManager?: ReturnType<typeof asTerminalManager> | 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 {
|
||||
|
||||
162
packages/server/src/server/terminal-activity-route.test.ts
Normal file
162
packages/server/src/server/terminal-activity-route.test.ts
Normal file
@@ -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<void> {
|
||||
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);
|
||||
});
|
||||
@@ -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<string, (...args: unknown[]) => 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<void> {
|
||||
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<TerminalManager>({
|
||||
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>): 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<WorkspaceRegistry>({
|
||||
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<HTTPServer>({}),
|
||||
createStub<pino.Logger>(createLogger()),
|
||||
"srv-test",
|
||||
createStub<AgentManager>(agentManager),
|
||||
createStub<AgentStorage>({}),
|
||||
createStub<DownloadTokenStore>({}),
|
||||
"/tmp/paseo-test",
|
||||
createStub<DaemonConfigStore>(daemonConfigStore),
|
||||
null,
|
||||
{ allowedOrigins: new Set() },
|
||||
undefined,
|
||||
undefined,
|
||||
terminalManager,
|
||||
undefined,
|
||||
"1.2.3-test",
|
||||
undefined,
|
||||
undefined,
|
||||
workspaceRegistry,
|
||||
createStub<FileBackedChatService>({}),
|
||||
createStub<LoopService>({}),
|
||||
createStub<ScheduleService>({}),
|
||||
createStub<CheckoutDiffManager>({
|
||||
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<unknown, unknown> }>(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<typeof createOpenSocket>) {
|
||||
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<void> {
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<typeof monitorEventLoopDelay> | 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<string | undefined> {
|
||||
const workspaces = await this.workspaceRegistry.list();
|
||||
return resolveActiveWorkspaceRecordForCwd(cwd, workspaces)?.workspaceId;
|
||||
}
|
||||
|
||||
private async broadcastTerminalAttention(params: {
|
||||
terminalId: string;
|
||||
cwd: string;
|
||||
terminalName: string;
|
||||
reason: TerminalAttentionReason;
|
||||
}): Promise<void> {
|
||||
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 {
|
||||
|
||||
@@ -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<WorkspaceDescriptorPayload> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<PersistedWorkspaceRecord[]>;
|
||||
};
|
||||
listAgentPayloads(): Promise<AgentSnapshotPayload[]>;
|
||||
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<string>;
|
||||
}): Promise<Map<string, WorkspaceDescriptorPayload>> {
|
||||
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<string, WorkspaceDescriptorPayload>,
|
||||
): Map<string, Array<{ bucket: WorkspaceStateBucket; changedAtIso: string }>> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
): 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(
|
||||
|
||||
@@ -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 () => {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
247
packages/server/src/terminal/agent-hooks/agent-hook-installer.ts
Normal file
247
packages/server/src/terminal/agent-hooks/agent-hook-installer.ts
Normal file
@@ -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<string | null>;
|
||||
}
|
||||
|
||||
export interface AgentHookProvider<TConfig = unknown> {
|
||||
id: string;
|
||||
events: AgentHookEventDefinition[];
|
||||
resolveActivity(input: {
|
||||
event: string;
|
||||
input: AgentHookActivityInput;
|
||||
}): Promise<AgentHookActivityState | null>;
|
||||
install: AgentHookInstallStrategy<TConfig>;
|
||||
}
|
||||
|
||||
export type AgentHookInstallStrategy<TConfig> =
|
||||
| AgentHookConfigFileInstallStrategy<TConfig>
|
||||
| AgentHookPluginFileInstallStrategy;
|
||||
|
||||
interface AgentHookInstallStrategyBase {
|
||||
kind: "config-file" | "plugin-file";
|
||||
configDir: string;
|
||||
configDirBase?: "home" | "xdg-config";
|
||||
configFile: string;
|
||||
configDirEnvOverride?: string;
|
||||
hookMarker: string;
|
||||
}
|
||||
|
||||
export interface AgentHookConfigFileInstallStrategy<TConfig> extends AgentHookInstallStrategyBase {
|
||||
kind: "config-file";
|
||||
format: AgentHookConfigFormat<TConfig>;
|
||||
}
|
||||
|
||||
export interface AgentHookPluginFileInstallStrategy extends AgentHookInstallStrategyBase {
|
||||
kind: "plugin-file";
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface AgentHookConfigFormat<TConfig> {
|
||||
empty(): TConfig;
|
||||
parse(raw: string): TConfig;
|
||||
stringify(config: TConfig): string;
|
||||
install(config: TConfig, provider: AgentHookProvider<TConfig>): TConfig;
|
||||
uninstall(config: TConfig, provider: AgentHookProvider<TConfig>): TConfig;
|
||||
isInstalled(config: TConfig, provider: AgentHookProvider<TConfig>): boolean;
|
||||
}
|
||||
|
||||
export interface AgentHookInstallOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: string;
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
export interface AgentHookInstallLogger {
|
||||
warn(bindings: Record<string, unknown>, message: string): void;
|
||||
}
|
||||
|
||||
export interface AgentHookInstallResult {
|
||||
configPath: string;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export function installAgentHooks<TConfig>(
|
||||
provider: AgentHookProvider<TConfig>,
|
||||
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<TConfig>(
|
||||
provider: AgentHookProvider<TConfig>,
|
||||
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<TConfig>(
|
||||
provider: AgentHookProvider<TConfig>,
|
||||
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<TConfig>(
|
||||
provider: AgentHookProvider<TConfig>,
|
||||
options: AgentHookInstallOptions = {},
|
||||
): string {
|
||||
return resolveAgentHookInstallPath(provider.install, options);
|
||||
}
|
||||
|
||||
function resolveAgentHookInstallPath<TConfig>(
|
||||
install: AgentHookInstallStrategy<TConfig>,
|
||||
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<TConfig>(
|
||||
provider: AgentHookProvider<TConfig>,
|
||||
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<TConfig>(
|
||||
provider: AgentHookProvider<TConfig>,
|
||||
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<TConfig>(
|
||||
provider: AgentHookProvider<TConfig>,
|
||||
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<TConfig>(input: {
|
||||
install: AgentHookInstallStrategy<TConfig>;
|
||||
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('"', '\\"')}"`;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const claudeSettingsFormat: AgentHookConfigFormat<ClaudeSettings> = {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<void>((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<void>((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;
|
||||
}
|
||||
191
packages/server/src/terminal/agent-hooks/claude/claude.test.ts
Normal file
191
packages/server/src/terminal/agent-hooks/claude/claude.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
function readSettings(configDir: string): TestClaudeSettings {
|
||||
return JSON.parse(readFileSync(join(configDir, "settings.json"), "utf8")) as {
|
||||
hooks?: Record<string, unknown>;
|
||||
theme?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function hookCommands(settings: { hooks?: Record<string, unknown> }, 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<string, unknown> {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
48
packages/server/src/terminal/agent-hooks/claude/claude.ts
Normal file
48
packages/server/src/terminal/agent-hooks/claude/claude.ts
Normal file
@@ -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<string, AgentHookActivityState> = {
|
||||
UserPromptSubmit: "running",
|
||||
Stop: "idle",
|
||||
StopFailure: "idle",
|
||||
SessionEnd: "idle",
|
||||
};
|
||||
|
||||
export const claudeAgentHookProvider: AgentHookProvider<ClaudeSettings> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
143
packages/server/src/terminal/agent-hooks/codex/codex-settings.ts
Normal file
143
packages/server/src/terminal/agent-hooks/codex/codex-settings.ts
Normal file
@@ -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<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const codexHooksFormat: AgentHookConfigFormat<CodexHooksFile> = {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
147
packages/server/src/terminal/agent-hooks/codex/codex.test.ts
Normal file
147
packages/server/src/terminal/agent-hooks/codex/codex.test.ts
Normal file
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
32
packages/server/src/terminal/agent-hooks/codex/codex.ts
Normal file
32
packages/server/src/terminal/agent-hooks/codex/codex.ts
Normal file
@@ -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<string, AgentHookActivityState> = {
|
||||
UserPromptSubmit: "running",
|
||||
PreToolUse: "running",
|
||||
PostToolUse: "running",
|
||||
PermissionRequest: "needs-input",
|
||||
Stop: "idle",
|
||||
};
|
||||
|
||||
export const codexAgentHookProvider: AgentHookProvider<CodexHooksFile> = {
|
||||
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;
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { AgentHookActivityState, AgentHookProvider } from "../agent-hook-installer.js";
|
||||
import { createOpenCodePluginInstallStrategy } from "./opencode-plugin.js";
|
||||
|
||||
const OPENCODE_EVENT_STATES: Record<string, AgentHookActivityState> = {
|
||||
"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;
|
||||
},
|
||||
};
|
||||
@@ -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<string, unknown>;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface WarningLogger {
|
||||
entries: WarningLogEntry[];
|
||||
warn(bindings: Record<string, unknown>, 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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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<string, AgentHookProvider>;
|
||||
|
||||
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<AgentHookActivityState | null> {
|
||||
const provider = AGENT_HOOK_PROVIDERS[request.provider.toLowerCase()];
|
||||
if (!provider) return null;
|
||||
|
||||
return provider.resolveActivity({ event: request.event, input: request.input });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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<TerminalSession[]>;
|
||||
createTerminal(options: {
|
||||
@@ -32,14 +46,19 @@ export interface TerminalManager {
|
||||
env?: Record<string, string>;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
activityToken?: string;
|
||||
activityUrl?: string | null;
|
||||
}): Promise<TerminalSession>;
|
||||
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
|
||||
validateTerminalActivityToken(terminalId: string, token: string): "valid" | "unknown" | "invalid";
|
||||
getTerminal(id: string): TerminalSession | undefined;
|
||||
getTerminalState(
|
||||
id: string,
|
||||
options?: TerminalStateSnapshotOptions,
|
||||
): Promise<TerminalStateSnapshot | null>;
|
||||
setTerminalTitle(id: string, title: string): boolean;
|
||||
setTerminalActivity(id: string, state: TerminalActivityState): Promise<boolean>;
|
||||
clearTerminalAttention(id: string): Promise<boolean>;
|
||||
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<string, TerminalSession[]>();
|
||||
const terminalsById = new Map<string, TerminalSession>();
|
||||
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
||||
const terminalTitleUnsubscribeById = new Map<string, () => void>();
|
||||
const terminalActivityUnsubscribeById = new Map<string, () => void>();
|
||||
const terminalActivityTokenById = new Map<string, string>();
|
||||
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
|
||||
const terminalActivityListeners = new Set<TerminalActivityListener>();
|
||||
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
|
||||
|
||||
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<TerminalSession[]> {
|
||||
assertAbsolutePath(cwd);
|
||||
@@ -190,6 +258,8 @@ export function createTerminalManager(): TerminalManager {
|
||||
env?: Record<string, string>;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
activityToken?: string;
|
||||
activityUrl?: string | null;
|
||||
}): Promise<TerminalSession> {
|
||||
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<boolean> {
|
||||
const session = terminalsById.get(id);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
session.setActivity(state);
|
||||
return true;
|
||||
},
|
||||
|
||||
async clearTerminalAttention(id: string): Promise<boolean> {
|
||||
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);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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<TerminalSession, "id" | "name" | "getTitle">): {
|
||||
private toTerminalInfo(
|
||||
terminal: Pick<TerminalSession, "id" | "name" | "getTitle" | "getActivity">,
|
||||
): {
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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
|
||||
|
||||
@@ -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<string, string>;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
activityToken?: string;
|
||||
activityUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkerKillAndWaitOptions {
|
||||
@@ -47,6 +51,12 @@ export type TerminalWorkerRequest =
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
}
|
||||
| {
|
||||
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;
|
||||
|
||||
@@ -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<string, string>;
|
||||
activityEnv?: Record<string, string>;
|
||||
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<string, string>;
|
||||
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<string, string>,
|
||||
cliPath: string | null,
|
||||
): Record<string, string> {
|
||||
if (!cliPath) {
|
||||
return env;
|
||||
}
|
||||
|
||||
return {
|
||||
...env,
|
||||
PASEO_HOOK_CLI: resolvePath(resolveExternalProcessPath(cliPath)),
|
||||
};
|
||||
}
|
||||
|
||||
function prependPaseoCliToPath(
|
||||
env: Record<string, string>,
|
||||
cliBinDir: string | null,
|
||||
): Record<string, string> {
|
||||
if (!cliBinDir) {
|
||||
return env;
|
||||
}
|
||||
|
||||
const pathKey = getPathEnvKey(env);
|
||||
const currentPath = env[pathKey] ?? "";
|
||||
return {
|
||||
...env,
|
||||
[pathKey]: prependPathEntry(currentPath, cliBinDir),
|
||||
};
|
||||
}
|
||||
|
||||
function getPathEnvKey(env: Record<string, string>): 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<Te
|
||||
cwd,
|
||||
shell,
|
||||
env = {},
|
||||
activityEnv = {},
|
||||
rows = 24,
|
||||
cols = 80,
|
||||
name = "Terminal",
|
||||
@@ -675,13 +822,15 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
const recentOutputChunks: string[] = [];
|
||||
let recentOutputLength = 0;
|
||||
let title: string | undefined;
|
||||
let titleMode: "auto" | "manual" = presetTitle?.trim() ? "manual" : "auto";
|
||||
let titleMode = resolveInitialTitleMode(presetTitle);
|
||||
let pendingTitle: string | undefined;
|
||||
let titleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingInput = "";
|
||||
let inputFlushImmediate: ReturnType<typeof setImmediate> | 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<Te
|
||||
cols,
|
||||
rows,
|
||||
cwd,
|
||||
env: buildTerminalEnvironment({ shell: spawnCommand, env }),
|
||||
env: buildTerminalEnvironment({ shell: spawnCommand, env: { ...env, ...activityEnv } }),
|
||||
});
|
||||
|
||||
function emitTitleChange(nextTitle: string | undefined): void {
|
||||
@@ -834,6 +983,23 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
return true;
|
||||
});
|
||||
|
||||
activityTracker.onChange((snapshot, previousSnapshot) => {
|
||||
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<Te
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
activityTracker.clear();
|
||||
pendingInput = "";
|
||||
recentOutputChunks.length = 0;
|
||||
recentOutputLength = 0;
|
||||
@@ -884,11 +1051,13 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
clearPendingTitleChange();
|
||||
disposeTitleChangeSubscription();
|
||||
disposeCommandLifecycleSubscription.dispose();
|
||||
activityTracker.dispose();
|
||||
terminal.dispose();
|
||||
listeners.clear();
|
||||
exitListeners.clear();
|
||||
commandFinishedListeners.clear();
|
||||
titleChangeListeners.clear();
|
||||
activityChangeListeners.clear();
|
||||
}
|
||||
|
||||
function writeOutputToHeadless(data: string): void {
|
||||
@@ -1147,10 +1316,31 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
};
|
||||
}
|
||||
|
||||
function onActivityChange(
|
||||
listener: (transition: TerminalActivityTransition) => 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<Te
|
||||
onExit,
|
||||
onCommandFinished,
|
||||
onTitleChange,
|
||||
onActivityChange,
|
||||
getSize,
|
||||
getState,
|
||||
getStateSnapshot,
|
||||
getReplayPreamble,
|
||||
getTitle,
|
||||
getActivity,
|
||||
setActivity,
|
||||
clearActivityAttention,
|
||||
setTitle,
|
||||
getExitInfo,
|
||||
kill,
|
||||
|
||||
@@ -2,12 +2,17 @@ import { afterEach, expect, it } from "vitest";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createWorkerTerminalManager } from "./worker-terminal-manager.js";
|
||||
import type { TerminalManager } from "./terminal-manager.js";
|
||||
import type { TerminalSession } from "./terminal.js";
|
||||
import type { TerminalActivityTransitionEvent, TerminalManager } from "./terminal-manager.js";
|
||||
import {
|
||||
resolvePaseoCliBinDir,
|
||||
resolvePaseoCliExecutablePath,
|
||||
type TerminalSession,
|
||||
} from "./terminal.js";
|
||||
import type { TerminalState } from "@getpaseo/protocol/messages";
|
||||
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
||||
import type {
|
||||
TerminalWorkerRequest,
|
||||
TerminalWorkerToParentMessage,
|
||||
@@ -375,7 +380,12 @@ it("does not surface fire-and-forget send timeouts as unhandled rejections", asy
|
||||
|
||||
worker.emitWorkerMessage({
|
||||
type: "terminalCreated",
|
||||
terminal: { id: "terminal-1", name: "Terminal", cwd: "/tmp" },
|
||||
terminal: {
|
||||
id: "terminal-1",
|
||||
name: "Terminal",
|
||||
cwd: "/tmp",
|
||||
activity: { state: "idle", changedAt: 0 },
|
||||
},
|
||||
state: createTerminalState(),
|
||||
});
|
||||
const session = manager.getTerminal("terminal-1");
|
||||
@@ -425,6 +435,57 @@ it("keeps registered cwd env inheritance behind the worker manager interface", a
|
||||
expect(readFileSync(markerPath, "utf8")).toBe("worker-env");
|
||||
});
|
||||
|
||||
it("injects parent-minted terminal activity env through the worker", async () => {
|
||||
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);
|
||||
|
||||
@@ -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<string, PendingRequest>();
|
||||
const recordsById = new Map<string, WorkerTerminalRecord>();
|
||||
const terminalIdsByCwd = new Map<string, Set<string>>();
|
||||
const terminalActivityTokenById = new Map<string, string>();
|
||||
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
|
||||
const terminalActivityListeners = new Set<TerminalActivityListener>();
|
||||
let workerExited = false;
|
||||
let workerShutdownTimer: ReturnType<typeof setTimeout> | 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<TerminalWorkerToParentMessage, { type: "terminalActivityChange" }>,
|
||||
): 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<TerminalWorkerToParentMessage, { type: "terminalsChanged" }>,
|
||||
): 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<TerminalSession> {
|
||||
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<string, string> }): 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<boolean> {
|
||||
const record = recordsById.get(id);
|
||||
if (!record) {
|
||||
return false;
|
||||
}
|
||||
await sendRequest({ type: "setActivity", terminalId: id, state });
|
||||
return true;
|
||||
},
|
||||
|
||||
async clearTerminalAttention(id: string): Promise<boolean> {
|
||||
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);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"autoArchiveAfterMerge": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"enableTerminalAgentHooks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"appendSystemPrompt": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user