@visual-z/xeno 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +19 -4
- package/docs/app-server.md +32 -3
- package/docs/foundations.md +88 -0
- package/docs/install.md +23 -2
- package/docs/research/foundation-clients-2026-10-09.md +27 -0
- package/docs/research/foundation-editing-2026-10-09.md +56 -0
- package/docs/research/foundation-network-2026-10-09.md +37 -0
- package/docs/research/foundation-workflow-2026-10-09.md +37 -0
- package/package.json +10 -2
- package/src/apps/acp-server.ts +41 -0
- package/src/apps/agent-profiles.ts +19 -0
- package/src/apps/app-server.ts +4 -2
- package/src/apps/cli.ts +18 -2
- package/src/apps/commands.ts +20 -0
- package/src/apps/control-transport.ts +3 -2
- package/src/apps/model-catalog.ts +57 -31
- package/src/apps/question-ui.ts +27 -0
- package/src/apps/resources.ts +63 -25
- package/src/apps/review.ts +109 -0
- package/src/apps/session-hub.ts +95 -0
- package/src/apps/shell.ts +55 -1
- package/src/apps/startup.ts +157 -22
- package/src/apps/ui/app.ts +89 -38
- package/src/apps/ui/image-cache.ts +71 -28
- package/src/apps/ui/surface.ts +1 -0
- package/src/apps/update-check.ts +71 -0
- package/src/apps/workflow.ts +58 -0
- package/src/core/activity-status.ts +28 -0
- package/src/core/agent-profile.ts +130 -0
- package/src/core/control-protocol.ts +8 -1
- package/src/core/file-precondition.ts +6 -0
- package/src/core/git-status.ts +29 -0
- package/src/core/image-limits.ts +72 -0
- package/src/core/lsp-framing.ts +34 -0
- package/src/core/mcp-config.ts +68 -0
- package/src/core/patch.ts +130 -0
- package/src/core/permissions.ts +143 -4
- package/src/core/progress.ts +9 -0
- package/src/core/question.ts +94 -0
- package/src/core/request-budget.ts +13 -0
- package/src/core/review.ts +68 -0
- package/src/core/session-exit.ts +26 -0
- package/src/core/settings.ts +1 -1
- package/src/core/stall.ts +18 -6
- package/src/core/stop-report.ts +25 -0
- package/src/core/update-check.ts +24 -5
- package/src/core/web-content.ts +80 -0
- package/src/core/workflow-mode.ts +34 -0
- package/src/runtime/acp-agent.ts +289 -0
- package/src/runtime/agent-profiles.ts +21 -0
- package/src/runtime/catalog.ts +2 -2
- package/src/runtime/control-client.ts +149 -0
- package/src/runtime/control-connection.ts +39 -4
- package/src/runtime/exec-sessions.ts +40 -4
- package/src/runtime/extensions.ts +20 -2
- package/src/runtime/formatter-tool.ts +38 -0
- package/src/runtime/formatter.ts +50 -0
- package/src/runtime/foundation.ts +78 -0
- package/src/runtime/images.ts +6 -2
- package/src/runtime/integrations.ts +8 -2
- package/src/runtime/loop.ts +124 -21
- package/src/runtime/lsp-client.ts +201 -0
- package/src/runtime/mcp-client.ts +264 -0
- package/src/runtime/mcp-oauth.ts +168 -0
- package/src/runtime/mcp-schema.ts +51 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/openai-responses.ts +6 -4
- package/src/runtime/openai.ts +6 -1
- package/src/runtime/output-schema.ts +26 -0
- package/src/runtime/patch-tool.ts +38 -0
- package/src/runtime/permission-resources.ts +11 -0
- package/src/runtime/pi-provider.ts +5 -4
- package/src/runtime/provider-tool-policy.ts +33 -0
- package/src/runtime/provider.ts +8 -1
- package/src/runtime/question-tool.ts +62 -0
- package/src/runtime/review-tool.ts +48 -0
- package/src/runtime/session-control.ts +61 -2
- package/src/runtime/session-hub.ts +85 -0
- package/src/runtime/tool-discovery.ts +4 -3
- package/src/runtime/tools.ts +44 -10
- package/src/runtime/update-check.ts +3 -2
- package/src/runtime/web-http.ts +56 -0
- package/src/runtime/web-tools.ts +88 -0
- package/src/runtime/workspace-lifecycle.ts +14 -1
- package/src/store/agent-profile-files.ts +13 -0
- package/src/store/blobs.ts +2 -1
- package/src/store/context-files.ts +77 -1
- package/src/store/file-checkpoints.ts +5 -4
- package/src/store/foundation-config.ts +24 -0
- package/src/store/git-status.ts +15 -0
- package/src/store/lsp-document.ts +30 -0
- package/src/store/mcp-credentials.ts +85 -0
- package/src/store/patch.ts +92 -0
- package/src/store/permission-paths.ts +34 -0
- package/src/store/review.ts +109 -0
- package/src/store/search.ts +48 -9
- package/src/tui/ui/layout.ts +7 -7
- package/src/tui/ui/messages.ts +1 -1
- package/src/tui/ui/pictures.ts +26 -9
- package/src/tui/ui/screen.ts +12 -8
- package/src/tui/ui/status.ts +32 -6
|
@@ -4,6 +4,7 @@ import { selectLens, type LensFocus } from "../core/control-view.ts";
|
|
|
4
4
|
import { assertNever } from "../core/exhaustive.ts";
|
|
5
5
|
import type { SessionControl } from "./session-control.ts";
|
|
6
6
|
import type { ApprovalChoice } from "./tools.ts";
|
|
7
|
+
import type { SessionHub } from "./session-hub.ts";
|
|
7
8
|
|
|
8
9
|
export type RpcOutput = { id: RpcId | null; result?: unknown; error?: { code: number; message: string } } | { method: string; params: unknown };
|
|
9
10
|
|
|
@@ -17,9 +18,13 @@ export class ControlConnection {
|
|
|
17
18
|
private attached: SessionControl | undefined;
|
|
18
19
|
private stop: (() => void) | undefined;
|
|
19
20
|
private readonly denyOnClose: boolean;
|
|
21
|
+
private readonly hub: SessionHub | undefined;
|
|
22
|
+
private readonly subscriptions = new Map<SessionControl, () => void>();
|
|
23
|
+
private readonly questionClients = new Map<SessionControl, () => void>();
|
|
20
24
|
|
|
21
|
-
constructor(control: () => SessionControl, write: (output: RpcOutput) => void, options: { denyOnClose?: boolean } = {}) {
|
|
25
|
+
constructor(control: () => SessionControl, write: (output: RpcOutput) => void, options: { denyOnClose?: boolean; hub?: SessionHub } = {}) {
|
|
22
26
|
this.control = control; this.write = write; this.denyOnClose = options.denyOnClose ?? false;
|
|
27
|
+
this.hub = options.hub;
|
|
23
28
|
}
|
|
24
29
|
private attach(): SessionControl {
|
|
25
30
|
const control = this.control();
|
|
@@ -36,23 +41,50 @@ export class ControlConnection {
|
|
|
36
41
|
if (typeof value === "object" && value !== null && "id" in value &&
|
|
37
42
|
(typeof value.id === "string" && value.id.length <= 128 || typeof value.id === "number" && Number.isSafeInteger(value.id))) id = value.id;
|
|
38
43
|
const request = parseControlRequest(value), params = request.params;
|
|
39
|
-
|
|
44
|
+
let control = this.attach();
|
|
40
45
|
if (request.method === "initialize") {
|
|
41
46
|
if (this.initialized) throw new ControlProtocolError(-32000, "already initialized");
|
|
42
47
|
this.initialized = true;
|
|
43
48
|
this.write({ id, result: { protocol: CONTROL_SCHEMA.protocol, experimental: true, threadId: control.threadId, streamId: control.streamId,
|
|
44
|
-
capabilities: { turns: true, steering: true, approvals: true, contextLens: true, snapshotResync: true, durableEventReplay: "item/list", adaptiveExecution: false } } });
|
|
49
|
+
capabilities: { turns: true, steering: true, approvals: true, questions: true, contextLens: true, snapshotResync: true, durableEventReplay: "item/list", adaptiveExecution: false } } });
|
|
45
50
|
return;
|
|
46
51
|
}
|
|
47
52
|
if (!this.initialized) throw new ControlProtocolError(-32000, "not initialized");
|
|
48
53
|
if (request.method === "initialized") { if (this.ready) throw new ControlProtocolError(-32000, "already ready"); this.ready = true; return; }
|
|
49
54
|
if (!this.ready) throw new ControlProtocolError(-32000, "send initialized before using the session");
|
|
55
|
+
if (request.method === "thread/list") {
|
|
56
|
+
if (!this.hub) throw Error("multi-session methods require app-server");
|
|
57
|
+
this.write({ id, result: await this.hub.list() }); return;
|
|
58
|
+
}
|
|
59
|
+
if (request.method === "thread/new" || request.method === "thread/resume") {
|
|
60
|
+
if (!this.hub) throw Error("multi-session methods require app-server");
|
|
61
|
+
const opened = await this.hub.open(request.method === "thread/new" ? "new" : "resume", params);
|
|
62
|
+
this.write({ id, result: await opened.snapshot() }); return;
|
|
63
|
+
}
|
|
64
|
+
if (this.hub && typeof params["threadId"] === "string") control = this.hub.get(params["threadId"]);
|
|
50
65
|
if (params["threadId"] !== control.threadId) throw new ControlProtocolError(-32000, "stale or unknown threadId; initialize a new connection after changing sessions");
|
|
51
66
|
let result: unknown;
|
|
52
67
|
switch (request.method) {
|
|
68
|
+
case "thread/fork": {
|
|
69
|
+
if (!this.hub) throw Error("multi-session methods require app-server");
|
|
70
|
+
result = await (await this.hub.open("fork", { ...params, session: control.session.config.logDir })).snapshot(); break;
|
|
71
|
+
}
|
|
72
|
+
case "thread/close": {
|
|
73
|
+
if (!this.hub) throw Error("multi-session methods require app-server");
|
|
74
|
+
await this.hub.close(control.threadId); this.subscriptions.get(control)?.(); this.subscriptions.delete(control);
|
|
75
|
+
this.questionClients.get(control)?.(); this.questionClients.delete(control);
|
|
76
|
+
result = { closed: true }; break;
|
|
77
|
+
}
|
|
78
|
+
case "thread/mode": {
|
|
79
|
+
if (!control.configureWorkflow) throw Error("workflow changes are unavailable for this session");
|
|
80
|
+
await control.configureWorkflow(params["mode"] as "build" | "plan"); result = await control.snapshot(); break;
|
|
81
|
+
}
|
|
53
82
|
case "thread/read": result = await control.snapshot(); break;
|
|
54
83
|
case "thread/subscribe": {
|
|
55
|
-
this.
|
|
84
|
+
if (!this.questionClients.has(control)) this.questionClients.set(control, control.attachQuestionClient());
|
|
85
|
+
if (this.hub) {
|
|
86
|
+
if (!this.subscriptions.has(control)) this.subscriptions.set(control, control.subscribe(notice => { if (!this.closed) this.write(notice); }));
|
|
87
|
+
} else this.subscribed = true;
|
|
56
88
|
const snapshot = await control.snapshot();
|
|
57
89
|
const after = params["after"] as { streamId: string; sequence: number } | undefined;
|
|
58
90
|
result = { snapshot, resyncRequired: after !== undefined && (after.streamId !== snapshot.streamId || after.sequence !== snapshot.sequence) };
|
|
@@ -62,6 +94,7 @@ export class ControlConnection {
|
|
|
62
94
|
case "turn/steer": control.steer(String(params["turnId"]), String(params["input"])); result = { queued: true }; break;
|
|
63
95
|
case "turn/interrupt": control.interrupt(String(params["turnId"])); result = { interruptRequested: true }; break;
|
|
64
96
|
case "approval/respond": control.respond(String(params["turnId"]), String(params["approvalId"]), params["choice"] as ApprovalChoice); result = { accepted: true }; break;
|
|
97
|
+
case "question/respond": control.respondQuestion(String(params["turnId"]), String(params["requestId"]), JSON.parse(String(params["reply"]))); result = { accepted: true }; break;
|
|
65
98
|
case "acceptance/accept": await control.accept(String(params["requestId"]), String(params["criterionId"])); result = { accepted: true }; break;
|
|
66
99
|
case "context/read": {
|
|
67
100
|
const snapshot = await control.snapshot(false), all = selectLens(snapshot.context, params["focus"] as LensFocus);
|
|
@@ -96,6 +129,8 @@ export class ControlConnection {
|
|
|
96
129
|
}
|
|
97
130
|
close(): void {
|
|
98
131
|
this.closed = true; this.stop?.();
|
|
132
|
+
for (const stop of this.subscriptions.values()) stop(); this.subscriptions.clear();
|
|
133
|
+
for (const stop of this.questionClients.values()) stop(); this.questionClients.clear();
|
|
99
134
|
if (this.denyOnClose && this.attached) {
|
|
100
135
|
this.attached.denyApprovals();
|
|
101
136
|
if (this.attached.activeTurnId) this.attached.interrupt(this.attached.activeTurnId);
|
|
@@ -30,7 +30,7 @@ import type { CommandArtifactReceipt } from "../core/command-artifacts.ts";
|
|
|
30
30
|
|
|
31
31
|
/** Codex's default: long enough for most commands to finish in one call. */
|
|
32
32
|
export const DEFAULT_YIELD_MS = 10_000;
|
|
33
|
-
/** Bounds on
|
|
33
|
+
/** Bounds on a foreground poll or input send. Owned observation waits have no polling deadline. */
|
|
34
34
|
export const MIN_YIELD_MS = 100;
|
|
35
35
|
export const MAX_YIELD_MS = 30_000;
|
|
36
36
|
/**
|
|
@@ -66,6 +66,7 @@ interface Session {
|
|
|
66
66
|
spoolPath: string;
|
|
67
67
|
queue: Promise<unknown>;
|
|
68
68
|
operations: Promise<unknown>;
|
|
69
|
+
outputListeners: Set<() => void>;
|
|
69
70
|
final?: TakenOutput;
|
|
70
71
|
captureError?: string;
|
|
71
72
|
written: number;
|
|
@@ -128,6 +129,7 @@ export function closedSession(id: number, owner?: string): ClosedSession | undef
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
function append(session: Session, chunk: Uint8Array): void {
|
|
132
|
+
if (chunk.length === 0) return;
|
|
131
133
|
const bytes = chunk.slice();
|
|
132
134
|
session.queue = session.queue.then(async () => {
|
|
133
135
|
let offset = 0;
|
|
@@ -136,6 +138,7 @@ function append(session: Session, chunk: Uint8Array): void {
|
|
|
136
138
|
if (bytesWritten < 1) throw new Error("process output spool made no write progress");
|
|
137
139
|
offset += bytesWritten; session.written += bytesWritten;
|
|
138
140
|
}
|
|
141
|
+
for (const notify of session.outputListeners) notify();
|
|
139
142
|
}).catch(error => {
|
|
140
143
|
session.captureError = `process output capture failed: ${String(error)}`;
|
|
141
144
|
stopSession(session, "cancelled");
|
|
@@ -161,7 +164,7 @@ export async function startSession(input: {
|
|
|
161
164
|
const exited = new Promise<void>((resolve) => { resolveExit = resolve; });
|
|
162
165
|
const session = {
|
|
163
166
|
id: nextId++, ...(input.owner === undefined ? {} : { owner: input.owner }), command: input.command, cwd: input.cwd, tty: input.tty, source: input.source, started: performance.now(),
|
|
164
|
-
spool, spoolPath, queue: Promise.resolve(), operations: Promise.resolve(), written: 0, read: 0,
|
|
167
|
+
spool, spoolPath, queue: Promise.resolve(), operations: Promise.resolve(), outputListeners: new Set<() => void>(), written: 0, read: 0,
|
|
165
168
|
exit: null, timeoutMs: input.timeoutMs, exited, drained: Promise.resolve(),
|
|
166
169
|
...(input.artifactObserver ? { artifactObserver: input.artifactObserver } : {}),
|
|
167
170
|
} as Omit<Session, "pid" | "write" | "kill" | "timer"> as Session;
|
|
@@ -282,6 +285,38 @@ async function waitSession(session: Session, yieldMs: number, signal?: AbortSign
|
|
|
282
285
|
await session.queue;
|
|
283
286
|
}
|
|
284
287
|
|
|
288
|
+
export interface ObservationWait {
|
|
289
|
+
/** Steering releases this observation wait without cancelling the owned command. */
|
|
290
|
+
wake?: AbortSignal;
|
|
291
|
+
onWaiting?: (active: boolean) => void;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** OMP's job owner pattern: no paid model turn merely to observe elapsed time. */
|
|
295
|
+
async function waitForObservation(session: Session, signal: AbortSignal | undefined, wait: ObservationWait): Promise<void> {
|
|
296
|
+
await session.queue;
|
|
297
|
+
let notify!: () => void;
|
|
298
|
+
const changed = new Promise<void>(resolve => { notify = resolve; });
|
|
299
|
+
session.outputListeners.add(notify);
|
|
300
|
+
signal?.addEventListener("abort", notify, { once: true });
|
|
301
|
+
wait.wake?.addEventListener("abort", notify, { once: true });
|
|
302
|
+
const pending = session.exit === null && session.read === session.written && !signal?.aborted && !wait.wake?.aborted;
|
|
303
|
+
try {
|
|
304
|
+
if (pending) {
|
|
305
|
+
wait.onWaiting?.(true);
|
|
306
|
+
await Promise.race([session.exited, changed]);
|
|
307
|
+
}
|
|
308
|
+
} finally {
|
|
309
|
+
session.outputListeners.delete(notify);
|
|
310
|
+
signal?.removeEventListener("abort", notify);
|
|
311
|
+
wait.wake?.removeEventListener("abort", notify);
|
|
312
|
+
if (pending) wait.onWaiting?.(false);
|
|
313
|
+
}
|
|
314
|
+
if (signal?.aborted) stopSession(session, "cancelled");
|
|
315
|
+
if (session.stopped !== undefined) await session.exited;
|
|
316
|
+
if (session.exit !== null) await drainExitedSession(session);
|
|
317
|
+
await session.queue;
|
|
318
|
+
}
|
|
319
|
+
|
|
285
320
|
/**
|
|
286
321
|
* Bytes printed since the previous call, archived in full. A session whose
|
|
287
322
|
* exit has now been reported is closed.
|
|
@@ -325,7 +360,7 @@ async function takeOutput(session: Session, outputStore: string, previewBytes: n
|
|
|
325
360
|
|
|
326
361
|
/** One lease covers stdin, waiting, archival and cursor commit; cancellation can interrupt a queued wait. */
|
|
327
362
|
export async function pollSession(session: Session, outputStore: string, previewBytes: number, yieldMs: number,
|
|
328
|
-
chars = "", signal?: AbortSignal): Promise<TakenOutput> {
|
|
363
|
+
chars = "", signal?: AbortSignal, wait?: ObservationWait): Promise<TakenOutput> {
|
|
329
364
|
const cancel = () => stopSession(session, "cancelled");
|
|
330
365
|
if (signal?.aborted) cancel(); else signal?.addEventListener("abort", cancel, { once: true });
|
|
331
366
|
const result = session.operations.then(async () => {
|
|
@@ -337,7 +372,8 @@ export async function pollSession(session: Session, outputStore: string, preview
|
|
|
337
372
|
if (session.exit !== null) throw new ToolFault("not_found", "process has exited; input was not sent; poll with empty chars", NOT_STARTED);
|
|
338
373
|
session.write(chars);
|
|
339
374
|
}
|
|
340
|
-
await
|
|
375
|
+
if (chars.length === 0 && wait) await waitForObservation(session, signal, wait);
|
|
376
|
+
else await waitSession(session, yieldMs, signal);
|
|
341
377
|
return takeOutput(session, outputStore, previewBytes);
|
|
342
378
|
});
|
|
343
379
|
session.operations = result.catch(() => {});
|
|
@@ -43,6 +43,7 @@ export interface ExtensionTool {
|
|
|
43
43
|
exposure?: "direct" | "deferred";
|
|
44
44
|
promptSnippet?: string; promptGuidelines?: string[]; executionMode?: "parallel" | "sequential";
|
|
45
45
|
prepareArguments?(input: unknown): unknown;
|
|
46
|
+
validateInput?(input: unknown): void;
|
|
46
47
|
execute(id: string, args: Record<string, unknown>, signal: AbortSignal | undefined,
|
|
47
48
|
onUpdate: (result: ExtensionToolResult) => void, ctx: ExtensionContext): Promise<ExtensionToolResult>;
|
|
48
49
|
renderCall?(args: unknown, theme: ExtensionTheme, context: ExtensionToolRenderContext): ExtensionComponent;
|
|
@@ -102,6 +103,14 @@ interface ExtensionRegistryOptions {
|
|
|
102
103
|
|
|
103
104
|
/** Registrations are staged before activation, and disposed as one generation on reload. */
|
|
104
105
|
export class ExtensionRegistry {
|
|
106
|
+
/** Harness-owned tools keep capability metadata; arbitrary extensions cannot claim it. */
|
|
107
|
+
readonly builtinTools = new Map<string, ToolDefinition>();
|
|
108
|
+
private readonly ownedDisposers: (() => Promise<void>)[] = [];
|
|
109
|
+
installBuiltin(tool: ToolDefinition): void {
|
|
110
|
+
if (this.tools.has(tool.name) || this.builtinTools.has(tool.name)) throw Error(`duplicate harness tool ${tool.name}`);
|
|
111
|
+
this.builtinTools.set(tool.name, tool);
|
|
112
|
+
}
|
|
113
|
+
own(dispose: () => Promise<void>): void { this.ownedDisposers.push(dispose); }
|
|
105
114
|
readonly commands = new Map<string, ExtensionCommand>();
|
|
106
115
|
readonly tools = new Map<string, ExtensionTool>();
|
|
107
116
|
readonly shortcuts = new Map<string, { description?: string; handler(ctx: ExtensionContext): void | Promise<void> }>();
|
|
@@ -160,6 +169,7 @@ export class ExtensionRegistry {
|
|
|
160
169
|
this.insert(this.commands, name, command, source);
|
|
161
170
|
},
|
|
162
171
|
registerTool: tool => {
|
|
172
|
+
if (this.builtinTools.has(tool.name)) throw Error(`${tool.name} is owned by the harness`);
|
|
163
173
|
if (!/^[a-zA-Z0-9_-]+$/.test(tool.name) || typeof tool.execute !== "function") throw new Error("Invalid tool definition");
|
|
164
174
|
if (["note", "history", "branch", "resolve", DISCOVERY_TOOL_NAME].includes(tool.name)) throw new Error(`${tool.name} owns the append-only harness and cannot be replaced`);
|
|
165
175
|
if (tool.exposure !== undefined && tool.exposure !== "direct" && tool.exposure !== "deferred") throw new Error("Unknown tool exposure");
|
|
@@ -287,6 +297,7 @@ export class ExtensionRegistry {
|
|
|
287
297
|
access: "write", execution: "exclusive", branch: "none",
|
|
288
298
|
...(this.integrationTools.has(name) ? { integrationHealth: () => this.integrationTools.get(name)!.observe() } : {}),
|
|
289
299
|
...(tool.prepareArguments ? { prepareArguments: tool.prepareArguments } : {}),
|
|
300
|
+
...(tool.validateInput ? { validateInput: tool.validateInput } : {}),
|
|
290
301
|
run: async (ctx: ToolContext, raw: Record<string, unknown>) => {
|
|
291
302
|
const context = { ...this.active().context(), signal: ctx.signal, tools: this.active().getTools(),
|
|
292
303
|
executeTool: async (target: string, input: unknown) => {
|
|
@@ -313,6 +324,7 @@ export class ExtensionRegistry {
|
|
|
313
324
|
},
|
|
314
325
|
}]));
|
|
315
326
|
if ([...this.tools.values()].some(tool => tool.exposure === "deferred")) definitions.set(DISCOVERY_TOOL_NAME, DISCOVERY_TOOL);
|
|
327
|
+
for (const [name, tool] of this.builtinTools) definitions.set(name, tool);
|
|
316
328
|
return definitions;
|
|
317
329
|
}
|
|
318
330
|
toolHooks(): NonNullable<ToolContext["extensionHooks"]> {
|
|
@@ -328,6 +340,7 @@ export class ExtensionRegistry {
|
|
|
328
340
|
}
|
|
329
341
|
async dispose(): Promise<void> {
|
|
330
342
|
if (this.disposed) return;
|
|
343
|
+
for (const dispose of this.ownedDisposers.splice(0)) await dispose();
|
|
331
344
|
await Promise.all([...this.integrations.values()].map(owner => owner.dispose()));
|
|
332
345
|
for (const detach of this.detachListeners) try { detach(); } catch (error) { this.report(String(error)); }
|
|
333
346
|
this.detachListeners.clear(); this.toolUpdateListeners.clear();
|
|
@@ -337,16 +350,21 @@ export class ExtensionRegistry {
|
|
|
337
350
|
}
|
|
338
351
|
/** Q.13: a new generation must not overlap an unresolved callback from its predecessor. */
|
|
339
352
|
async releaseIntegrations(): Promise<boolean> {
|
|
353
|
+
if ([...this.integrations.values()].some(owner => !owner.canReleaseForReload())) return false;
|
|
340
354
|
const released = await Promise.all([...this.integrations.values()].map(owner => owner.releaseForReload()));
|
|
341
355
|
return released.every(Boolean);
|
|
342
356
|
}
|
|
357
|
+
restoreIntegrations(): boolean {
|
|
358
|
+
const restored = [...this.integrations.values()].map(owner => owner.resumeAfterReload());
|
|
359
|
+
return restored.every(Boolean);
|
|
360
|
+
}
|
|
343
361
|
}
|
|
344
362
|
|
|
345
363
|
function extensionSchema(parameters: unknown): ToolDefinition["input_schema"] {
|
|
346
364
|
if (typeof parameters !== "object" || parameters === null) throw new Error("Tool parameters must be an object schema");
|
|
347
365
|
const schema = parameters as Record<string, unknown>;
|
|
348
|
-
if (schema["type"] !== "object" || typeof schema["properties"] !== "object" || schema["properties"] === null) throw new Error("Tool parameters need object
|
|
366
|
+
if (schema["type"] !== "object" || schema["properties"] !== undefined && (typeof schema["properties"] !== "object" || schema["properties"] === null || Array.isArray(schema["properties"]))) throw new Error("Tool parameters need an object schema");
|
|
349
367
|
const required = schema["required"] ?? [];
|
|
350
368
|
if (!Array.isArray(required) || required.some(value => typeof value !== "string")) throw new Error("Tool required fields must be names");
|
|
351
|
-
return { ...schema, type: "object", properties: schema["properties"] as Record<string, unknown>, required: required as string[] };
|
|
369
|
+
return { ...schema, type: "object", properties: (schema["properties"] ?? {}) as Record<string, unknown>, required: required as string[] };
|
|
352
370
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Trusted formatter configuration controls commands; model arguments supply only paths. */
|
|
2
|
+
import { planFormatters, runConfiguredFormatters, validateFormatters, type FormatterConfig } from "./formatter.ts";
|
|
3
|
+
import { presentObservation, type ToolDefinition } from "./tools.ts";
|
|
4
|
+
import { failureDiagnostic, failureFromError, NOT_STARTED, UNKNOWN_EFFECT } from "../core/tool-failure.ts";
|
|
5
|
+
import { readEditableFile } from "../store/file-checkpoints.ts";
|
|
6
|
+
|
|
7
|
+
export function createFormatterTool(configs: readonly FormatterConfig[]): ToolDefinition {
|
|
8
|
+
const frozen = validateFormatters(configs);
|
|
9
|
+
return {
|
|
10
|
+
name: "format", access: "shell", execution: "exclusive", branch: "write",
|
|
11
|
+
description: "Run explicitly configured trusted formatters for these existing workspace files. Every formatter command goes through the session's normal command admission, logs and checkpoints; no executable is auto-discovered or installed. Returns real command outcomes and resulting file digests. Formatting can change content; read the current file before deriving further edits.",
|
|
12
|
+
input_schema: { type: "object", required: ["paths"], properties: { paths: { type: "array", minItems: 1, maxItems: 100, items: { type: "string" } } } },
|
|
13
|
+
run: async (ctx, input) => {
|
|
14
|
+
if (!ctx.invokeHost) return { ok: false, content: "Formatter requires the session command lifecycle.", diagnostic: failureDiagnostic("environment_missing", "Formatter requires the session command lifecycle.", NOT_STARTED) };
|
|
15
|
+
let executing = false;
|
|
16
|
+
try {
|
|
17
|
+
const paths = input["paths"] as string[], plans = await planFormatters(ctx.root, paths, frozen);
|
|
18
|
+
if (!plans.length) return { ok: false, content: "No configured formatter matches these file extensions.", diagnostic: failureDiagnostic("environment_missing", "No configured formatter matches these file extensions.", NOT_STARTED) };
|
|
19
|
+
executing = true;
|
|
20
|
+
const results = await runConfiguredFormatters(plans, ctx.invokeHost, ctx.signal);
|
|
21
|
+
if (!results.length) return { ok: false, content: "Formatter cancelled before execution.", diagnostic: failureDiagnostic("cancelled", "Formatter cancelled before execution.", NOT_STARTED) };
|
|
22
|
+
const files = [];
|
|
23
|
+
for (const path of [...new Set(results.map(result => result.plan.path))]) {
|
|
24
|
+
const observed = await readEditableFile(ctx.root, path);
|
|
25
|
+
files.push({ path, sha256: observed.version?.sha256 ?? null });
|
|
26
|
+
}
|
|
27
|
+
const receipts = results.map(({ plan, result }) => `${plan.formatter} ${plan.path}: ${result.ok ? "completed" : "failed"}\n${result.content}`).join("\n\n");
|
|
28
|
+
const last = results.at(-1)!.result;
|
|
29
|
+
return { ...await presentObservation(ctx, receipts + "\n\nCurrent file versions:\n" + files.map(file => `${file.path}: ${file.sha256 ?? "absent"}`).join("\n")),
|
|
30
|
+
ok: results.length === plans.length && results.every(result => result.result.ok), data: { files, commands: results.length },
|
|
31
|
+
...(last.outcome === undefined ? {} : { outcome: last.outcome }), ...(last.diagnostic === undefined ? {} : { diagnostic: last.diagnostic }) };
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const diagnostic = failureFromError(error, executing ? UNKNOWN_EFFECT : NOT_STARTED);
|
|
34
|
+
return { ok: false, content: diagnostic.message, diagnostic };
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Explicit trusted formatters use the original command broker, never hidden subprocesses. */
|
|
2
|
+
import { extname, resolve } from "node:path";
|
|
3
|
+
import { readEditableFile } from "../store/file-checkpoints.ts";
|
|
4
|
+
import type { NativeCommandInput, ToolResult } from "./tools.ts";
|
|
5
|
+
export interface FormatterConfig { name: string; extensions: readonly string[]; command: readonly string[]; disabled?: boolean }
|
|
6
|
+
export interface FormatterPlan { formatter: string; path: string; input: NativeCommandInput }
|
|
7
|
+
|
|
8
|
+
export function validateFormatters(value: unknown): FormatterConfig[] {
|
|
9
|
+
if (!Array.isArray(value)) throw new Error("formatters must be an array of trusted command definitions");
|
|
10
|
+
const names = new Set<string>();
|
|
11
|
+
return value.map((entry: unknown) => {
|
|
12
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) throw new Error("formatter must be an object");
|
|
13
|
+
const config = entry as Record<string, unknown>, name = config["name"], extensions = config["extensions"], command = config["command"], disabled = config["disabled"];
|
|
14
|
+
if (Object.keys(config).some(key => !["name", "extensions", "command", "disabled"].includes(key))) throw new Error("unknown formatter configuration field");
|
|
15
|
+
if (typeof name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/.test(name) || names.has(name)) throw new Error("formatter names must be unique stable identifiers");
|
|
16
|
+
if (!Array.isArray(extensions) || !extensions.length || !extensions.every(extension => typeof extension === "string" && (extension === "*" || /^\.[A-Za-z0-9]+$/.test(extension)))) throw new Error("formatter extensions must contain dotted extensions or *");
|
|
17
|
+
if (!Array.isArray(command) || !command.length || !command.every(argument => typeof argument === "string" && !argument.includes("\0")) || !command[0]?.trim() || !command.some(argument => argument.includes("$FILE"))) throw new Error("formatter command must be literal argv containing $FILE");
|
|
18
|
+
if (disabled !== undefined && typeof disabled !== "boolean") throw new Error("formatter disabled must be boolean");
|
|
19
|
+
names.add(name);
|
|
20
|
+
return { name, extensions: extensions as string[], command: command as string[], ...(disabled === undefined ? {} : { disabled }) };
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Absolute filename substitution prevents option and shell parsing of model paths. */
|
|
25
|
+
export async function planFormatters(root: string, paths: readonly string[], configs: readonly FormatterConfig[]): Promise<FormatterPlan[]> {
|
|
26
|
+
const definitions = validateFormatters(configs), plans: FormatterPlan[] = [];
|
|
27
|
+
const known = new Set<string>();
|
|
28
|
+
for (const path of paths) {
|
|
29
|
+
const absolute = resolve(root, path);
|
|
30
|
+
if (known.has(absolute)) continue;
|
|
31
|
+
known.add(absolute);
|
|
32
|
+
const source = await readEditableFile(root, path);
|
|
33
|
+
if (source.version === null) throw new Error(`formatter target is missing: ${path}`);
|
|
34
|
+
for (const config of definitions) if (!config.disabled && (config.extensions.includes("*") || config.extensions.includes(extname(absolute))))
|
|
35
|
+
plans.push({ formatter: config.name, path, input: { argv: config.command.map(argument => argument.replaceAll("$FILE", absolute)), cwd: root } });
|
|
36
|
+
}
|
|
37
|
+
return plans;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Every command returns its real receipt; stop after failure or cancellation. */
|
|
41
|
+
export async function runConfiguredFormatters(plans: readonly FormatterPlan[], invokeHost: (input: NativeCommandInput, signal?: AbortSignal) => Promise<ToolResult>, signal?: AbortSignal): Promise<{ plan: FormatterPlan; result: ToolResult }[]> {
|
|
42
|
+
const results: { plan: FormatterPlan; result: ToolResult }[] = [];
|
|
43
|
+
for (const plan of plans) {
|
|
44
|
+
if (signal?.aborted) break;
|
|
45
|
+
const result = await invokeHost(plan.input, signal);
|
|
46
|
+
results.push({ plan, result });
|
|
47
|
+
if (!result.ok || result.outcome?.status === "stopped") break;
|
|
48
|
+
}
|
|
49
|
+
return results;
|
|
50
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Foundations use the existing catalog, broker, permission and checkpoint owners. */
|
|
2
|
+
import { parsePermissionRule, type PermissionRule } from "../core/permissions.ts";
|
|
3
|
+
import { parsePatch, patchPaths } from "../core/patch.ts";
|
|
4
|
+
import { parseWebSearchConfig } from "../core/web-content.ts";
|
|
5
|
+
import { lastTurnChanges } from "../core/review.ts";
|
|
6
|
+
import { discoverTargetInstructions, renderTargetInstructions } from "../store/context-files.ts";
|
|
7
|
+
import { createPatchTool } from "./patch-tool.ts";
|
|
8
|
+
import { createDiffTool } from "./review-tool.ts";
|
|
9
|
+
import { createQuestionTool, type QuestionSlot } from "./question-tool.ts";
|
|
10
|
+
import { createWebFetchTool, createWebSearchTool } from "./web-tools.ts";
|
|
11
|
+
import { LspManager, parseLspConfig } from "./lsp-client.ts";
|
|
12
|
+
import { validateFormatters } from "./formatter.ts";
|
|
13
|
+
import { createFormatterTool } from "./formatter-tool.ts";
|
|
14
|
+
import { permissionResourceAction } from "./permission-resources.ts";
|
|
15
|
+
import type { ToolContext, ToolDefinition, ToolResult } from "./tools.ts";
|
|
16
|
+
import { McpManager } from "./mcp-client.ts";
|
|
17
|
+
import { parseMcpServers } from "../core/mcp-config.ts";
|
|
18
|
+
import type { ExtensionRegistry } from "./extensions.ts";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { getBlob } from "../store/blobs.ts";
|
|
21
|
+
|
|
22
|
+
export class FoundationRuntime {
|
|
23
|
+
readonly questionSlot: QuestionSlot;
|
|
24
|
+
readonly mcp: McpManager;
|
|
25
|
+
readonly tools: ToolDefinition[];
|
|
26
|
+
readonly permissions: PermissionRule[];
|
|
27
|
+
private readonly lsp: LspManager;
|
|
28
|
+
constructor(workspace: string, config: Record<string, unknown>, offline: boolean, configDirectory: string, questionSlot: QuestionSlot = {}) {
|
|
29
|
+
this.questionSlot = questionSlot;
|
|
30
|
+
const servers = Object.fromEntries(Object.entries(parseMcpServers(config["mcp"])).map(([name, server]) => [name, offline && server.transport === "http" ? { ...server, enabled: false } : server]));
|
|
31
|
+
this.mcp = new McpManager(servers, { cwd: workspace, credentialsDirectory: join(configDirectory, "mcp-credentials") });
|
|
32
|
+
if (config["permissions"] !== undefined && !Array.isArray(config["permissions"])) throw Error("permissions must be an array");
|
|
33
|
+
this.permissions = Array.isArray(config["permissions"]) ? config["permissions"].map(parsePermissionRule) : [];
|
|
34
|
+
const lsp = parseLspConfig(config["lsp"]); this.lsp = new LspManager(workspace, lsp);
|
|
35
|
+
const formatters = config["formatters"] === undefined ? [] : validateFormatters(config["formatters"]);
|
|
36
|
+
const search = parseWebSearchConfig(config["webSearch"]);
|
|
37
|
+
const network = [createWebFetchTool(), createWebSearchTool(search)];
|
|
38
|
+
if (offline) for (const tool of network) tool.run = async () => ({ ok: false, content: "Network tools are unavailable in offline mode." });
|
|
39
|
+
this.tools = [createQuestionTool(this.questionSlot), createPatchTool(), createDiffTool({ lastTurn: async ctx => {
|
|
40
|
+
if (!ctx.historyEvents || !ctx.checkpointBlobs) throw Error("original checkpoint history is unavailable");
|
|
41
|
+
return { blobs: ctx.checkpointBlobs, ...lastTurnChanges(await ctx.historyEvents()) };
|
|
42
|
+
}, canRead: async (ctx, path) => await permissionResourceAction(ctx.root, path, ctx.rules ?? [], ctx.sessionRules ?? [], "diff") === "allow" }), ...network, ...(lsp.length ? [this.lsp.tool()] : [])];
|
|
43
|
+
this.tools.push({ ...createFormatterTool(formatters), branch: "none" });
|
|
44
|
+
}
|
|
45
|
+
async register(registry: ExtensionRegistry): Promise<void> {
|
|
46
|
+
for (const tool of this.tools) registry.installBuiltin(tool);
|
|
47
|
+
const discovered = await this.mcp.discover();
|
|
48
|
+
for (const integration of discovered.integrations) registry.api("xeno-mcp").registerIntegration(integration);
|
|
49
|
+
}
|
|
50
|
+
async instructions(ctx: ToolContext, name: string, input: unknown, noContextFiles: boolean): Promise<ToolResult | null> {
|
|
51
|
+
if (noContextFiles || !["read", "write", "edit", "apply_patch", "query", "format", "lsp"].includes(name) || !input || typeof input !== "object") return null;
|
|
52
|
+
const args = input as Record<string, unknown>;
|
|
53
|
+
const paths = name === "apply_patch" ? patchPaths(parsePatch(String(args["patchText"]))) : typeof args["path"] === "string" ? [args["path"]] : Array.isArray(args["paths"]) ? args["paths"].filter((path): path is string => typeof path === "string") : [];
|
|
54
|
+
if (!paths.length) return null;
|
|
55
|
+
const known = new Set<string>();
|
|
56
|
+
for (const event of await ctx.historyEvents?.() ?? []) {
|
|
57
|
+
if (event.t !== "tool_result") continue;
|
|
58
|
+
let data: unknown = event.data;
|
|
59
|
+
if (event.data_ref && ctx.outputStore) {
|
|
60
|
+
const saved = await getBlob(ctx.outputStore, event.data_ref.sha256);
|
|
61
|
+
if (saved) data = JSON.parse(saved) as unknown;
|
|
62
|
+
}
|
|
63
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) continue;
|
|
64
|
+
const receipt = data as Record<string, unknown>;
|
|
65
|
+
if (receipt["kind"] !== "scoped_instructions") continue;
|
|
66
|
+
const keys = receipt["keys"]; if (Array.isArray(keys)) for (const key of keys) if (typeof key === "string") known.add(key);
|
|
67
|
+
}
|
|
68
|
+
const found = await discoverTargetInstructions({ workspace: ctx.root, paths, known,
|
|
69
|
+
canRead: async path => await permissionResourceAction(ctx.root, path, ctx.rules ?? [], ctx.sessionRules ?? []) === "allow" });
|
|
70
|
+
if (found.notices.some(notice => notice.kind === "skipped" && notice.reason === "instruction read permission is unavailable")) return { ok: false, content: "Scoped instructions require read permission before this operation." };
|
|
71
|
+
const files = found.files.filter(file => !ctx.frozenSystem?.().includes(file.content));
|
|
72
|
+
if (!files.length) return null;
|
|
73
|
+
for (const file of files) if (await permissionResourceAction(ctx.root, file.path, ctx.rules ?? [], ctx.sessionRules ?? []) !== "allow") return { ok: false, content: "Scoped instructions require read permission before this operation." };
|
|
74
|
+
return { ok: false, content: "Scoped instructions were discovered. Read and follow them before deciding whether to retry the original operation. No original operation was executed.\n\n" + renderTargetInstructions(files),
|
|
75
|
+
data: { kind: "scoped_instructions", keys: files.map(file => file.key), files: files.map(file => ({ path: file.path, scope: file.scope, key: file.key, content: file.content, truncated: file.truncated })) } };
|
|
76
|
+
}
|
|
77
|
+
async dispose(): Promise<void> { await Promise.all([this.lsp.dispose(), this.mcp.dispose()]); }
|
|
78
|
+
}
|
package/src/runtime/images.ts
CHANGED
|
@@ -7,13 +7,17 @@ import { convertImageBytesToPng, convertToPng } from "../vendor/pi-rich/image-co
|
|
|
7
7
|
import { putBlob, getBlobBytes } from "../store/blobs.ts";
|
|
8
8
|
import { imageDescription, validImageRef, type ImageRef } from "../core/images.ts";
|
|
9
9
|
import type { ContentBlock, Message } from "../core/project.ts";
|
|
10
|
+
import { imageDimensions, modelImageSide, MAX_IMAGE_SOURCE_BYTES, MAX_IMAGE_SOURCE_PIXELS } from "../core/image-limits.ts";
|
|
10
11
|
|
|
11
12
|
// Admission limit, not a context threshold: avoid reading arbitrarily large local binaries into WASM.
|
|
12
|
-
const MAX_IMAGE_SOURCE_BYTES = 64 * 1024 * 1024;
|
|
13
13
|
export async function attachImageBytes(bytes: Uint8Array, name: string, blobDir: string, autoResize = true): Promise<ImageRef> {
|
|
14
14
|
if (bytes.byteLength > MAX_IMAGE_SOURCE_BYTES) throw new Error("Image exceeds the 64 MiB attachment limit");
|
|
15
15
|
let mime = detectSupportedImageMimeType(bytes);
|
|
16
16
|
if (!mime) throw new Error("Unsupported or malformed image: " + name);
|
|
17
|
+
const dimensions = imageDimensions(bytes, mime);
|
|
18
|
+
if (!dimensions) throw new Error("Cannot read image dimensions: " + name);
|
|
19
|
+
if (dimensions.width * dimensions.height > MAX_IMAGE_SOURCE_PIXELS) throw new Error("Image exceeds the 25 megapixel decode limit: " + name);
|
|
20
|
+
const maxSide = modelImageSide(dimensions);
|
|
17
21
|
const originalBytes = bytes;
|
|
18
22
|
if (mime === "image/bmp") {
|
|
19
23
|
const converted = await convertImageBytesToPng(bytes);
|
|
@@ -21,7 +25,7 @@ export async function attachImageBytes(bytes: Uint8Array, name: string, blobDir:
|
|
|
21
25
|
bytes = converted; mime = "image/png";
|
|
22
26
|
}
|
|
23
27
|
// Pi's disabled auto-resize setting preserves the input; decoding still verifies its dimensions.
|
|
24
|
-
const result = await resizeImage(bytes, mime, autoResize ?
|
|
28
|
+
const result = await resizeImage(bytes, mime, autoResize ? { maxWidth: maxSide, maxHeight: maxSide } : { maxWidth: Number.MAX_SAFE_INTEGER, maxHeight: Number.MAX_SAFE_INTEGER, maxBytes: Number.MAX_SAFE_INTEGER });
|
|
25
29
|
if (!result) throw new Error("Cannot decode or resize image: " + name);
|
|
26
30
|
if (result.mimeType !== "image/png" && result.mimeType !== "image/jpeg" && result.mimeType !== "image/gif" && result.mimeType !== "image/webp") {
|
|
27
31
|
throw new Error("Unsupported image encoding: " + result.mimeType);
|
|
@@ -55,7 +55,7 @@ export class LazyIntegration {
|
|
|
55
55
|
private busy = false;
|
|
56
56
|
private phase: "idle" | "connect" | "call" | "repair" = "idle";
|
|
57
57
|
private disposed = false;
|
|
58
|
-
private
|
|
58
|
+
private lifetime = new AbortController();
|
|
59
59
|
private readonly pending = new Set<Promise<unknown>>();
|
|
60
60
|
private readonly closed = new WeakSet<IntegrationConnection>();
|
|
61
61
|
private cleanup: "none" | "pending" | "complete" | "failed" = "none";
|
|
@@ -199,7 +199,7 @@ export class LazyIntegration {
|
|
|
199
199
|
// No promise can kill trusted in-process code. Pending ownership remains visible until it settles.
|
|
200
200
|
}
|
|
201
201
|
async releaseForReload(): Promise<boolean> {
|
|
202
|
-
if (this.
|
|
202
|
+
if (!this.canReleaseForReload()) return false;
|
|
203
203
|
this.disposed = true; this.lifetime.abort();
|
|
204
204
|
const closing = this.retire(this.handle);
|
|
205
205
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -207,4 +207,10 @@ export class LazyIntegration {
|
|
|
207
207
|
clearTimeout(timer);
|
|
208
208
|
return this.pending.size === 0 && this.observe().cleanup !== "failed";
|
|
209
209
|
}
|
|
210
|
+
canReleaseForReload(): boolean { return !this.busy && this.pending.size === 0 && this.cleanup !== "failed"; }
|
|
211
|
+
/** Re-enable explicit future invocations after an abandoned staged reload. No operation is replayed. */
|
|
212
|
+
resumeAfterReload(): boolean {
|
|
213
|
+
if (!this.canReleaseForReload()) return false;
|
|
214
|
+
this.disposed = false; this.lifetime = new AbortController(); return true;
|
|
215
|
+
}
|
|
210
216
|
}
|