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:
@@ -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);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user