diff --git a/extensions/background-terminals/index.ts b/extensions/background-terminals/index.ts index 5d38a846..3fb16d7b 100644 --- a/extensions/background-terminals/index.ts +++ b/extensions/background-terminals/index.ts @@ -33,6 +33,7 @@ import { patchOwnedTools, } from "../shared/tool-surface.ts"; import { + projectBackgroundTerminalDetail, projectBackgroundTerminalCapability, registerWebCapability, } from "../shared/web-observer-registry.ts"; @@ -133,6 +134,12 @@ export default function (pi: ExtensionAPI) { kind: "background-terminals", snapshot: () => projectBackgroundTerminalCapability(manager.view.list()), + detail: (id) => { + const terminal = manager.view.get(id); + return terminal + ? projectBackgroundTerminalDetail(terminal) + : undefined; + }, subscribe: (listener) => manager.view.subscribe(listener), }) : undefined; diff --git a/extensions/shared/web-observer-registry.ts b/extensions/shared/web-observer-registry.ts index 61aba682..f4e4b57d 100644 --- a/extensions/shared/web-observer-registry.ts +++ b/extensions/shared/web-observer-registry.ts @@ -6,6 +6,12 @@ export type WebCapabilityKind = export const WEB_MAX_CAPABILITY_ITEMS = 32; const WEB_MAX_ACTIVITY_TEXT = 160; const WEB_MAX_WORKFLOW_AGENTS_SCANNED = 1_024; +const WEB_MAX_CAPABILITY_ID = 160; +const WEB_MAX_TERMINAL_COMMAND_BYTES = 4 * 1024; +const WEB_MAX_TERMINAL_CWD_BYTES = 2 * 1024; +const WEB_MAX_TERMINAL_ERROR_BYTES = 2 * 1024; +const WEB_MAX_TERMINAL_STDOUT_BYTES = 16 * 1024; +const WEB_MAX_TERMINAL_STDERR_BYTES = 8 * 1024; export interface WebSubagentActivity { readonly id: string; @@ -43,6 +49,42 @@ export interface WebBackgroundTerminalActivity { readonly signal?: string; } +export interface WebBackgroundTerminalOutput { + readonly text: string; + readonly totalBytes: number; + readonly retainedBytes: number; + readonly omittedBytes: number; + readonly truncated: boolean; + readonly recoveryAvailable: boolean; +} + +export interface WebBackgroundTerminalDetail { + readonly kind: "background-terminals"; + readonly id: string; + readonly title: string; + readonly command: string; + readonly cwd: string; + readonly pid?: number; + readonly status: WebBackgroundTerminalActivity["status"]; + readonly createdAt: number; + readonly settledAt?: number; + readonly timeoutAt?: number; + readonly exitCode?: number; + readonly signal?: string; + readonly errorText?: string; + readonly stdout: WebBackgroundTerminalOutput; + readonly stderr: WebBackgroundTerminalOutput; + readonly truncated: boolean; +} + +export type WebCapabilityDetail = WebBackgroundTerminalDetail; + +export type WebCapabilityDetailReceipt = + | { readonly status: "found"; readonly detail: WebCapabilityDetail } + | { readonly status: "invalid" } + | { readonly status: "missing" } + | { readonly status: "unavailable" }; + export interface WebCapabilityProjection< Item = | WebSubagentActivity @@ -64,6 +106,7 @@ export interface WebCapabilitySnapshot { export interface WebCapabilityProvider { readonly kind: WebCapabilityKind; readonly snapshot: () => WebCapabilityProjection; + readonly detail?: (id: string) => WebCapabilityDetail | undefined; readonly subscribe?: (listener: () => void) => () => void; } @@ -82,6 +125,53 @@ function boundedActivityText(value: string): BoundedActivityText { }; } +interface BoundedUtf8Text { + readonly value: string; + readonly bytes: number; + readonly truncated: boolean; +} + +function boundedUtf8Tail(value: string, maxBytes: number): BoundedUtf8Text { + const encoded = new TextEncoder().encode(value); + if (encoded.byteLength <= maxBytes) { + return { value, bytes: encoded.byteLength, truncated: false }; + } + let start = encoded.byteLength - maxBytes; + while (start < encoded.byteLength && (encoded[start]! & 0xc0) === 0x80) { + start++; + } + const retained = encoded.slice(start); + return { + value: new TextDecoder().decode(retained), + bytes: retained.byteLength, + truncated: true, + }; +} + +function projectTerminalOutput( + source: { + readonly modelSafeText: string; + readonly totalBytes: number; + readonly truncatedBytes: number; + readonly spillPath?: string; + }, + maxBytes: number, +): WebBackgroundTerminalOutput { + const text = boundedUtf8Tail(source.modelSafeText, maxBytes); + const omittedBytes = Math.max( + source.truncatedBytes, + source.totalBytes - text.bytes, + ); + return { + text: text.value, + totalBytes: source.totalBytes, + retainedBytes: text.bytes, + omittedBytes, + truncated: text.truncated || source.truncatedBytes > 0, + recoveryAvailable: source.spillPath !== undefined, + }; +} + function newestActivityAt(value: { readonly createdAt?: number; readonly startedAt?: number; @@ -258,6 +348,77 @@ export function projectBackgroundTerminalCapability( }); } +export function projectBackgroundTerminalDetail(source: { + readonly id: string; + readonly title: string; + readonly command: string; + readonly cwd: string; + readonly pid?: number; + readonly status: WebBackgroundTerminalActivity["status"]; + readonly createdAt: number; + readonly settledAt?: number; + readonly timeoutAt?: number; + readonly exitCode?: number; + readonly signal?: string; + readonly errorText?: string; + readonly stdout: { + readonly modelSafeText: string; + readonly totalBytes: number; + readonly truncatedBytes: number; + readonly spillPath?: string; + }; + readonly stderr: { + readonly modelSafeText: string; + readonly totalBytes: number; + readonly truncatedBytes: number; + readonly spillPath?: string; + }; +}): WebBackgroundTerminalDetail { + const title = boundedActivityText(source.title); + const command = boundedUtf8Tail( + source.command, + WEB_MAX_TERMINAL_COMMAND_BYTES, + ); + const cwd = boundedUtf8Tail(source.cwd, WEB_MAX_TERMINAL_CWD_BYTES); + const signal = source.signal ? boundedActivityText(source.signal) : undefined; + const errorText = source.errorText + ? boundedUtf8Tail(source.errorText, WEB_MAX_TERMINAL_ERROR_BYTES) + : undefined; + const stdout = projectTerminalOutput( + source.stdout, + WEB_MAX_TERMINAL_STDOUT_BYTES, + ); + const stderr = projectTerminalOutput( + source.stderr, + WEB_MAX_TERMINAL_STDERR_BYTES, + ); + return { + kind: "background-terminals", + id: source.id, + title: title.value, + command: command.value, + cwd: cwd.value, + ...(source.pid !== undefined ? { pid: source.pid } : {}), + status: source.status, + createdAt: source.createdAt, + ...(source.settledAt !== undefined ? { settledAt: source.settledAt } : {}), + ...(source.timeoutAt !== undefined ? { timeoutAt: source.timeoutAt } : {}), + ...(source.exitCode !== undefined ? { exitCode: source.exitCode } : {}), + ...(signal ? { signal: signal.value } : {}), + ...(errorText ? { errorText: errorText.value } : {}), + stdout, + stderr, + truncated: + title.truncated || + command.truncated || + cwd.truncated || + signal?.truncated === true || + errorText?.truncated === true || + stdout.truncated || + stderr.truncated, + }; +} + /** The Pi SessionManager object itself is the capability-lifetime identity. */ export type WebCapabilityScope = object; @@ -385,6 +546,27 @@ export function webCapabilitySnapshot( ); } +export function webCapabilityDetail( + scope: WebCapabilityScope, + kind: WebCapabilityKind, + id: string, +): WebCapabilityDetailReceipt { + if ( + id.length === 0 || + id.length > WEB_MAX_CAPABILITY_ID || + /[\u0000-\u001f\u007f]/u.test(id) + ) { + return { status: "invalid" }; + } + const provider = providers.get(scope)?.get(kind); + if (!provider?.detail) return { status: "unavailable" }; + const detail = provider.detail(id); + if (!detail) return { status: "missing" }; + if (detail.kind !== kind || detail.id !== id) + return { status: "unavailable" }; + return { status: "found", detail }; +} + export function notifyWebCapabilities(scope: WebCapabilityScope) { for (const listener of listeners.keys()) listener(scope); } diff --git a/tests/web/observer-registry.test.ts b/tests/web/observer-registry.test.ts index 77bd89c4..bbc64ff7 100644 --- a/tests/web/observer-registry.test.ts +++ b/tests/web/observer-registry.test.ts @@ -8,11 +8,13 @@ import type { SessionManager } from "@earendil-works/pi-coding-agent"; import { notifyWebCapabilities, projectBackgroundTerminalCapability, + projectBackgroundTerminalDetail, projectSubagentCapability, projectWorkflowCapability, registerWebCapability, subscribeWebCapabilities, type WebCapabilityScope, + webCapabilityDetail, webCapabilitySnapshot, } from "../../extensions/shared/web-observer-registry.ts"; @@ -244,3 +246,89 @@ test("projects bounded canonical activity without private payloads", () => { }, ]); }); + +test("projects bounded terminal detail with exact identity and recovery evidence", () => { + const detail = projectBackgroundTerminalDetail({ + id: "bt-exact", + title: "dev server", + command: `prefix-${"c".repeat(5_000)}`, + cwd: `/workspace/${"d".repeat(3_000)}`, + pid: 42, + status: "failed", + createdAt: 1, + settledAt: 2, + exitCode: 1, + errorText: "e".repeat(3_000), + stdout: { + modelSafeText: `old-${"x".repeat(20_000)}-tail`, + totalBytes: 30_000, + truncatedBytes: 4_000, + spillPath: "/private/full-stdout.log", + }, + stderr: { + modelSafeText: "failure", + totalBytes: 7, + truncatedBytes: 0, + }, + }); + + assert.equal(detail.id, "bt-exact"); + assert.equal(detail.kind, "background-terminals"); + assert.equal(detail.command.endsWith("c".repeat(100)), true); + assert.equal(Buffer.byteLength(detail.command) <= 4 * 1024, true); + assert.equal(Buffer.byteLength(detail.cwd) <= 2 * 1024, true); + assert.equal(Buffer.byteLength(detail.errorText ?? "") <= 2 * 1024, true); + assert.equal(Buffer.byteLength(detail.stdout.text) <= 16 * 1024, true); + assert.equal(detail.stdout.text.endsWith("-tail"), true); + assert.equal(detail.stdout.omittedBytes > 0, true); + assert.equal(detail.stdout.recoveryAvailable, true); + assert.equal("spillPath" in detail.stdout, false); + assert.equal(detail.stderr.truncated, false); + assert.equal(detail.truncated, true); +}); + +test("detail lookup is Session-scoped, exact, and fail-closed", () => { + const scope = sessionScope(); + const otherScope = sessionScope(); + const detail = projectBackgroundTerminalDetail({ + id: "bt-1", + title: "server", + command: "run-server", + cwd: process.cwd(), + status: "running", + createdAt: 1, + stdout: { + modelSafeText: "ready", + totalBytes: 5, + truncatedBytes: 0, + }, + stderr: { modelSafeText: "", totalBytes: 0, truncatedBytes: 0 }, + }); + const unregister = registerWebCapability(scope, { + kind: "background-terminals", + snapshot: () => ({ items: [], omitted: 0, truncated: false }), + detail: (id) => (id === detail.id ? detail : undefined), + }); + try { + assert.deepEqual( + webCapabilityDetail(scope, "background-terminals", "bt-1"), + { + status: "found", + detail, + }, + ); + assert.deepEqual( + webCapabilityDetail(scope, "background-terminals", "bt-missing"), + { status: "missing" }, + ); + assert.deepEqual( + webCapabilityDetail(otherScope, "background-terminals", "bt-1"), + { status: "unavailable" }, + ); + assert.deepEqual(webCapabilityDetail(scope, "background-terminals", ""), { + status: "invalid", + }); + } finally { + unregister(); + } +}); diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index d80df4fa..5ac059a9 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -34,6 +34,39 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn truncated: false, }), }); + const unregisterTerminalDetails = registerWebCapability(sessionManager, { + kind: "background-terminals", + snapshot: () => ({ items: [], omitted: 0, truncated: false }), + detail: (id) => + id === "bt-test" + ? { + kind: "background-terminals", + id, + title: "server", + command: "run-server", + cwd, + status: "running", + createdAt: 1, + stdout: { + text: "ready", + totalBytes: 5, + retainedBytes: 5, + omittedBytes: 0, + truncated: false, + recoveryAvailable: false, + }, + stderr: { + text: "", + totalBytes: 0, + retainedBytes: 0, + omittedBytes: 0, + truncated: false, + recoveryAvailable: false, + }, + truncated: false, + } + : undefined, + }); const prompts: string[] = []; const creationCommandIds: string[] = []; let newSessions = 0; @@ -266,6 +299,46 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn }); assert.equal(modelsResponse.status, 200); assert.deepEqual((await modelsResponse.json()).models, snapshot.models); + const terminalDetailResponse = await fetch( + `${launched.origin}/api/capabilities/detail?kind=background-terminals&id=bt-test`, + { headers: authorized }, + ); + assert.equal(terminalDetailResponse.status, 200); + assert.deepEqual((await terminalDetailResponse.json()).detail, { + kind: "background-terminals", + id: "bt-test", + title: "server", + command: "run-server", + cwd, + status: "running", + createdAt: 1, + stdout: { + text: "ready", + totalBytes: 5, + retainedBytes: 5, + omittedBytes: 0, + truncated: false, + recoveryAvailable: false, + }, + stderr: { + text: "", + totalBytes: 0, + retainedBytes: 0, + omittedBytes: 0, + truncated: false, + recoveryAvailable: false, + }, + truncated: false, + }); + const staleTerminalResponse = await fetch( + `${launched.origin}/api/capabilities/detail?kind=background-terminals&id=bt-missing`, + { headers: authorized }, + ); + assert.equal(staleTerminalResponse.status, 404); + assert.deepEqual(await staleTerminalResponse.json(), { + code: "CAPABILITY_NOT_FOUND", + error: "capability resource was not found in the active Session", + }); const unavailableModel = await fetch(`${launched.origin}/api/model`, { method: "POST", headers: authorized, @@ -507,6 +580,7 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn } finally { await host.stop(); assert.equal(disposed, true); + unregisterTerminalDetails(); unregister(); await Promise.all( [cwd, imported].map((path) => rm(path, { recursive: true, force: true })), diff --git a/web/host/web-host.ts b/web/host/web-host.ts index 6203e647..be81eb35 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -9,7 +9,10 @@ import { } from "node:http"; import { URL } from "node:url"; import { promisify } from "node:util"; -import { subscribeWebCapabilities } from "../../extensions/shared/web-observer-registry.ts"; +import { + subscribeWebCapabilities, + webCapabilityDetail, +} from "../../extensions/shared/web-observer-registry.ts"; import { PiWebAdapter } from "../adapter/pi-adapter.ts"; import { jsonByteLength, @@ -557,6 +560,45 @@ export class WebHost { } if (url.pathname === "/api/models") return this.json(response, 200, { models: this.runtime.listModels() }); + if (url.pathname === "/api/capabilities/detail") { + const kind = url.searchParams.get("kind"); + const id = url.searchParams.get("id"); + if ( + (kind !== "subagents" && + kind !== "workflows" && + kind !== "background-terminals") || + id === null + ) { + return this.json(response, 400, { + code: "INVALID_CAPABILITY_DETAIL_TARGET", + error: "a supported capability kind and exact id are required", + }); + } + const receipt = webCapabilityDetail( + this.runtime.sessionManager, + kind, + id, + ); + if (receipt.status === "invalid") { + return this.json(response, 400, { + code: "INVALID_CAPABILITY_DETAIL_TARGET", + error: "a supported capability kind and exact id are required", + }); + } + if (receipt.status === "unavailable") { + return this.json(response, 404, { + code: "CAPABILITY_DETAILS_UNAVAILABLE", + error: "capability details are unavailable for the active Session", + }); + } + if (receipt.status === "missing") { + return this.json(response, 404, { + code: "CAPABILITY_NOT_FOUND", + error: "capability resource was not found in the active Session", + }); + } + return this.json(response, 200, { detail: receipt.detail }); + } if (url.pathname === "/api/snapshot") { const cursor = this.sequence; const projection = await this.adapter.getSnapshot(