Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions extensions/background-terminals/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
patchOwnedTools,
} from "../shared/tool-surface.ts";
import {
projectBackgroundTerminalDetail,
projectBackgroundTerminalCapability,
registerWebCapability,
} from "../shared/web-observer-registry.ts";
Expand Down Expand Up @@ -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;
Expand Down
182 changes: 182 additions & 0 deletions extensions/shared/web-observer-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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;
}

Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
88 changes: 88 additions & 0 deletions tests/web/observer-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
}
});
Loading
Loading