@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.
Files changed (102) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +19 -4
  3. package/docs/app-server.md +32 -3
  4. package/docs/foundations.md +88 -0
  5. package/docs/install.md +23 -2
  6. package/docs/research/foundation-clients-2026-10-09.md +27 -0
  7. package/docs/research/foundation-editing-2026-10-09.md +56 -0
  8. package/docs/research/foundation-network-2026-10-09.md +37 -0
  9. package/docs/research/foundation-workflow-2026-10-09.md +37 -0
  10. package/package.json +10 -2
  11. package/src/apps/acp-server.ts +41 -0
  12. package/src/apps/agent-profiles.ts +19 -0
  13. package/src/apps/app-server.ts +4 -2
  14. package/src/apps/cli.ts +18 -2
  15. package/src/apps/commands.ts +20 -0
  16. package/src/apps/control-transport.ts +3 -2
  17. package/src/apps/model-catalog.ts +57 -31
  18. package/src/apps/question-ui.ts +27 -0
  19. package/src/apps/resources.ts +63 -25
  20. package/src/apps/review.ts +109 -0
  21. package/src/apps/session-hub.ts +95 -0
  22. package/src/apps/shell.ts +55 -1
  23. package/src/apps/startup.ts +157 -22
  24. package/src/apps/ui/app.ts +89 -38
  25. package/src/apps/ui/image-cache.ts +71 -28
  26. package/src/apps/ui/surface.ts +1 -0
  27. package/src/apps/update-check.ts +71 -0
  28. package/src/apps/workflow.ts +58 -0
  29. package/src/core/activity-status.ts +28 -0
  30. package/src/core/agent-profile.ts +130 -0
  31. package/src/core/control-protocol.ts +8 -1
  32. package/src/core/file-precondition.ts +6 -0
  33. package/src/core/git-status.ts +29 -0
  34. package/src/core/image-limits.ts +72 -0
  35. package/src/core/lsp-framing.ts +34 -0
  36. package/src/core/mcp-config.ts +68 -0
  37. package/src/core/patch.ts +130 -0
  38. package/src/core/permissions.ts +143 -4
  39. package/src/core/progress.ts +9 -0
  40. package/src/core/question.ts +94 -0
  41. package/src/core/request-budget.ts +13 -0
  42. package/src/core/review.ts +68 -0
  43. package/src/core/session-exit.ts +26 -0
  44. package/src/core/settings.ts +1 -1
  45. package/src/core/stall.ts +18 -6
  46. package/src/core/stop-report.ts +25 -0
  47. package/src/core/update-check.ts +24 -5
  48. package/src/core/web-content.ts +80 -0
  49. package/src/core/workflow-mode.ts +34 -0
  50. package/src/runtime/acp-agent.ts +289 -0
  51. package/src/runtime/agent-profiles.ts +21 -0
  52. package/src/runtime/catalog.ts +2 -2
  53. package/src/runtime/control-client.ts +149 -0
  54. package/src/runtime/control-connection.ts +39 -4
  55. package/src/runtime/exec-sessions.ts +40 -4
  56. package/src/runtime/extensions.ts +20 -2
  57. package/src/runtime/formatter-tool.ts +38 -0
  58. package/src/runtime/formatter.ts +50 -0
  59. package/src/runtime/foundation.ts +78 -0
  60. package/src/runtime/images.ts +6 -2
  61. package/src/runtime/integrations.ts +8 -2
  62. package/src/runtime/loop.ts +124 -21
  63. package/src/runtime/lsp-client.ts +201 -0
  64. package/src/runtime/mcp-client.ts +264 -0
  65. package/src/runtime/mcp-oauth.ts +168 -0
  66. package/src/runtime/mcp-schema.ts +51 -0
  67. package/src/runtime/oauth.ts +1 -1
  68. package/src/runtime/openai-responses.ts +6 -4
  69. package/src/runtime/openai.ts +6 -1
  70. package/src/runtime/output-schema.ts +26 -0
  71. package/src/runtime/patch-tool.ts +38 -0
  72. package/src/runtime/permission-resources.ts +11 -0
  73. package/src/runtime/pi-provider.ts +5 -4
  74. package/src/runtime/provider-tool-policy.ts +33 -0
  75. package/src/runtime/provider.ts +8 -1
  76. package/src/runtime/question-tool.ts +62 -0
  77. package/src/runtime/review-tool.ts +48 -0
  78. package/src/runtime/session-control.ts +61 -2
  79. package/src/runtime/session-hub.ts +85 -0
  80. package/src/runtime/tool-discovery.ts +4 -3
  81. package/src/runtime/tools.ts +44 -10
  82. package/src/runtime/update-check.ts +3 -2
  83. package/src/runtime/web-http.ts +56 -0
  84. package/src/runtime/web-tools.ts +88 -0
  85. package/src/runtime/workspace-lifecycle.ts +14 -1
  86. package/src/store/agent-profile-files.ts +13 -0
  87. package/src/store/blobs.ts +2 -1
  88. package/src/store/context-files.ts +77 -1
  89. package/src/store/file-checkpoints.ts +5 -4
  90. package/src/store/foundation-config.ts +24 -0
  91. package/src/store/git-status.ts +15 -0
  92. package/src/store/lsp-document.ts +30 -0
  93. package/src/store/mcp-credentials.ts +85 -0
  94. package/src/store/patch.ts +92 -0
  95. package/src/store/permission-paths.ts +34 -0
  96. package/src/store/review.ts +109 -0
  97. package/src/store/search.ts +48 -9
  98. package/src/tui/ui/layout.ts +7 -7
  99. package/src/tui/ui/messages.ts +1 -1
  100. package/src/tui/ui/pictures.ts +26 -9
  101. package/src/tui/ui/screen.ts +12 -8
  102. 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
- const control = this.attach();
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.subscribed = true;
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 one wait. A longer wait is a longer silence the model cannot act in. */
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 waitSession(session, yieldMs, signal);
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 properties");
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
+ }
@@ -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 ? undefined : { maxWidth: Number.MAX_SAFE_INTEGER, maxHeight: Number.MAX_SAFE_INTEGER, maxBytes: Number.MAX_SAFE_INTEGER });
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 readonly lifetime = new AbortController();
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.busy || this.pending.size || this.cleanup === "failed") return false;
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
  }