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