@visual-z/xeno 0.4.0 → 0.6.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 (90) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +41 -10
  3. package/docs/app-server.md +35 -4
  4. package/docs/foundations.md +88 -0
  5. package/docs/install.md +23 -2
  6. package/docs/providers.md +4 -0
  7. package/docs/research/foundation-clients-2026-10-09.md +27 -0
  8. package/docs/research/foundation-editing-2026-10-09.md +56 -0
  9. package/docs/research/foundation-network-2026-10-09.md +37 -0
  10. package/docs/research/foundation-workflow-2026-10-09.md +37 -0
  11. package/package.json +10 -2
  12. package/src/apps/acp-server.ts +41 -0
  13. package/src/apps/agent-profiles.ts +19 -0
  14. package/src/apps/app-server.ts +4 -2
  15. package/src/apps/cli.ts +18 -2
  16. package/src/apps/commands.ts +20 -0
  17. package/src/apps/control-transport.ts +3 -2
  18. package/src/apps/model-catalog.ts +57 -31
  19. package/src/apps/preferences.ts +20 -0
  20. package/src/apps/question-ui.ts +27 -0
  21. package/src/apps/resources.ts +63 -25
  22. package/src/apps/review.ts +109 -0
  23. package/src/apps/session-hub.ts +84 -0
  24. package/src/apps/shell.ts +88 -30
  25. package/src/apps/startup-input.ts +7 -2
  26. package/src/apps/startup.ts +180 -25
  27. package/src/apps/ui/app.ts +74 -91
  28. package/src/apps/ui/surface.ts +1 -0
  29. package/src/apps/update-check.ts +71 -0
  30. package/src/apps/workflow.ts +58 -0
  31. package/src/core/agent-profile.ts +130 -0
  32. package/src/core/completion-review.ts +13 -4
  33. package/src/core/control-protocol.ts +8 -1
  34. package/src/core/file-precondition.ts +6 -0
  35. package/src/core/lsp-framing.ts +34 -0
  36. package/src/core/mcp-config.ts +68 -0
  37. package/src/core/model-preferences.ts +28 -0
  38. package/src/core/patch.ts +130 -0
  39. package/src/core/permissions.ts +156 -11
  40. package/src/core/question.ts +94 -0
  41. package/src/core/review.ts +68 -0
  42. package/src/core/settings.ts +1 -1
  43. package/src/core/update-check.ts +24 -5
  44. package/src/core/web-content.ts +80 -0
  45. package/src/core/workflow-mode.ts +34 -0
  46. package/src/runtime/acp-agent.ts +289 -0
  47. package/src/runtime/agent-profiles.ts +21 -0
  48. package/src/runtime/catalog.ts +10 -3
  49. package/src/runtime/control-client.ts +149 -0
  50. package/src/runtime/control-connection.ts +39 -4
  51. package/src/runtime/extensions.ts +20 -2
  52. package/src/runtime/formatter-tool.ts +38 -0
  53. package/src/runtime/formatter.ts +50 -0
  54. package/src/runtime/foundation.ts +78 -0
  55. package/src/runtime/integrations.ts +8 -2
  56. package/src/runtime/loop.ts +68 -10
  57. package/src/runtime/lsp-client.ts +201 -0
  58. package/src/runtime/mcp-client.ts +264 -0
  59. package/src/runtime/mcp-oauth.ts +168 -0
  60. package/src/runtime/mcp-schema.ts +51 -0
  61. package/src/runtime/oauth.ts +1 -1
  62. package/src/runtime/output-schema.ts +26 -0
  63. package/src/runtime/patch-tool.ts +38 -0
  64. package/src/runtime/permission-resources.ts +11 -0
  65. package/src/runtime/provider.ts +2 -0
  66. package/src/runtime/question-tool.ts +62 -0
  67. package/src/runtime/review-tool.ts +48 -0
  68. package/src/runtime/session-control.ts +60 -2
  69. package/src/runtime/session-hub.ts +85 -0
  70. package/src/runtime/tool-discovery.ts +4 -3
  71. package/src/runtime/tools.ts +48 -16
  72. package/src/runtime/update-check.ts +3 -2
  73. package/src/runtime/web-http.ts +56 -0
  74. package/src/runtime/web-tools.ts +88 -0
  75. package/src/runtime/workspace-lifecycle.ts +14 -1
  76. package/src/store/agent-profile-files.ts +13 -0
  77. package/src/store/context-files.ts +77 -1
  78. package/src/store/file-checkpoints.ts +5 -4
  79. package/src/store/foundation-config.ts +24 -0
  80. package/src/store/lsp-document.ts +30 -0
  81. package/src/store/mcp-credentials.ts +85 -0
  82. package/src/store/patch.ts +92 -0
  83. package/src/store/permission-paths.ts +34 -0
  84. package/src/store/resources.ts +13 -22
  85. package/src/store/review.ts +109 -0
  86. package/src/store/search.ts +48 -9
  87. package/src/store/user-settings.ts +30 -0
  88. package/src/tui/ui/control-panel.ts +7 -5
  89. package/src/tui/ui/control-sidebar.ts +49 -0
  90. package/src/tui/ui/screen.ts +107 -11
@@ -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
+ }
@@ -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
  }
@@ -187,6 +187,12 @@ export const DEFAULT_BRANCH_LIMITS: BranchLimits = {
187
187
  };
188
188
 
189
189
  export interface SessionConfig {
190
+ /** Runtime-only output contract, explicitly selected by the current caller. */
191
+ validateOutput?: (text: string) => { ok: boolean; error?: string };
192
+ /** Persist a selector, never persisted profile code or authorization (D6/D13). */
193
+ agentProfile?: { name: string; workflowExplicit: boolean };
194
+ /** An explicit mode command's capability default, independent of role authority. */
195
+ workflowToolProfile?: "foundation";
190
196
  /** P.3: legacy full snapshots are an explicit compatibility mode, never the CLI default. */
191
197
  workspaceTracking?: "journal" | "legacy";
192
198
  shellCheckpoints?: boolean;
@@ -330,7 +336,7 @@ export type RewindResult =
330
336
  | { ok: false; error: string };
331
337
 
332
338
  /** O.5: child execution returns facts; only the parent coordinator accepts them. */
333
- type DeferredToolResult = ToolResult & { accept?: () => Promise<LoopEvent[]>; annotation?: LogEvent };
339
+ type DeferredToolResult = ToolResult & { accept?: () => Promise<LoopEvent[]>; annotation?: LogEvent; finalText?: string };
334
340
  type ToolCall = Extract<AssistantBlock, { type: "tool_use" }>;
335
341
  type SessionBrokerReceipt = BrokerReceipt<DeferredToolResult> & { events: LoopEvent[] };
336
342
  type PreparedBrokerCall = { run(emit?: (event: LoopEvent) => void): Promise<SessionBrokerReceipt> };
@@ -384,6 +390,13 @@ export class Session {
384
390
  // Branch capability sets are owned by branchTools; a parent's optional catalog is not a grant.
385
391
  if ((config.branchDepth ?? 0) > 0) delete this.config.tools.deferredTools;
386
392
  this.config.tools.outputStore ??= join(config.logDir, "artifacts");
393
+ this.config.tools.historyEvents = async () => {
394
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
395
+ if (!history.ok) throw Error(history.error);
396
+ return history.nodes.flatMap(node => node.node === this.state.node ? structuredClone(this.state.events) : node.events);
397
+ };
398
+ if (config.blobDir) this.config.tools.checkpointBlobs = config.blobDir;
399
+ this.config.tools.frozenSystem = () => this.config.frozen.system;
387
400
  // R4 / D8: full ancestry, not the bounded model view or a second mutable ledger.
388
401
  // Each child Session installs its own callback even when contexts are copied.
389
402
  this.config.tools.observedFileVersion = async absolute => {
@@ -492,7 +505,16 @@ export class Session {
492
505
  if (plan.cursor.context_ref) {
493
506
  const saved = await getBlob(join(dir, "artifacts"), plan.cursor.context_ref);
494
507
  if (saved === null) throw new Error("missing frozen session context");
495
- const context = JSON.parse(saved) as { skills?: Skill[]; acceptanceContract?: AcceptanceContract; strictVerification?: boolean; workingSet?: boolean; workingSetVersion?: 1 | 2; guidanceVersion?: 1; projectionVersion?: number; frozen: FrozenPrefix; boundary?: Partial<BoundaryConfig>; contextPolicy?: unknown; stallLimit?: number; outputRecoveryLimit?: number; guidance?: boolean; gates: Record<string, string>; snapshotIgnore?: readonly string[]; workspaceTracking?: "journal" | "legacy"; shellCheckpoints?: boolean; workNamespace?: string; maxParallelTools?: number };
508
+ const context = JSON.parse(saved) as { agentProfile?: { name: string; workflowExplicit: boolean }; workflowMode?: "build" | "plan"; skills?: Skill[]; acceptanceContract?: AcceptanceContract; strictVerification?: boolean; workingSet?: boolean; workingSetVersion?: 1 | 2; guidanceVersion?: 1; projectionVersion?: number; frozen: FrozenPrefix; boundary?: Partial<BoundaryConfig>; contextPolicy?: unknown; stallLimit?: number; outputRecoveryLimit?: number; guidance?: boolean; gates: Record<string, string>; snapshotIgnore?: readonly string[]; workspaceTracking?: "journal" | "legacy"; shellCheckpoints?: boolean; workNamespace?: string; maxParallelTools?: number };
509
+ if (context.agentProfile !== undefined) {
510
+ if (!context.agentProfile || typeof context.agentProfile.name !== "string" || !/^[a-z][a-z0-9_-]{0,63}$/.test(context.agentProfile.name) || typeof context.agentProfile.workflowExplicit !== "boolean") throw Error("unsupported saved agent profile selector");
511
+ this.config.agentProfile = { ...context.agentProfile };
512
+ }
513
+ if (context.workflowMode !== undefined) { if (!["build", "plan"].includes(context.workflowMode)) throw Error("unsupported saved workflow mode"); this.config.tools.workflowMode = context.workflowMode; }
514
+ if ("workflowToolProfile" in context && context.workflowToolProfile !== undefined) {
515
+ if (context.workflowToolProfile !== "foundation") throw Error("unsupported saved workflow tool profile");
516
+ this.config.workflowToolProfile = "foundation";
517
+ }
496
518
  if (context.projectionVersion !== undefined && context.projectionVersion !== PROJECTION_VERSION) throw new Error("unsupported saved projection version");
497
519
  this.config.workingSet = context.workingSet === true;
498
520
  if (context.workingSetVersion !== undefined && ![1, 2].includes(context.workingSetVersion)) throw new Error("unsupported saved working-set version");
@@ -760,12 +782,18 @@ export class Session {
760
782
  }
761
783
 
762
784
  /** D6: changing the frozen prefix is an explicit checkpoint, never in-place prompt surgery. */
763
- async reloadResources(frozen: FrozenPrefix, skills = this.config.skills): Promise<boolean> {
764
- if (JSON.stringify(frozen) === JSON.stringify(this.config.frozen) && JSON.stringify(skills) === JSON.stringify(this.config.skills)) return false;
785
+ assertResourceIdle(): void {
765
786
  if (this.running || this.isHalfRound()) throw new Error("cannot reload resources while a turn is active or a tool call is unpaired");
787
+ }
788
+
789
+ async reloadResources(frozen: FrozenPrefix, skills = this.config.skills, nextProvider?: Provider, forceCheckpoint = false): Promise<boolean> {
790
+ if (!nextProvider && !forceCheckpoint && JSON.stringify(frozen) === JSON.stringify(this.config.frozen) && JSON.stringify(skills) === JSON.stringify(this.config.skills)) return false;
791
+ this.assertResourceIdle();
766
792
  const contextRef = await this.persistContext(frozen, skills);
767
- await this.performCut("model_checkpoint", this.provider.config.spec.id, undefined,
768
- this.provider.config.spec.provider, this.provider.config.effort, contextRef);
793
+ const provider = nextProvider ?? this.provider;
794
+ await this.performCut("model_checkpoint", provider.config.spec.id, undefined,
795
+ provider.config.spec.provider, provider.config.effort, contextRef);
796
+ this.provider = provider;
769
797
  this.config.frozen = frozen;
770
798
  if (skills !== undefined) this.config.skills = skills;
771
799
  this.installSkillResources();
@@ -850,6 +878,9 @@ export class Session {
850
878
  ...(this.config.workingSet && this.config.workingSetVersion === 2 ? { workingSetVersion: 2 } : {}),
851
879
  ...(this.config.guidanceVersion === 1 ? { guidanceVersion: 1 } : {}),
852
880
  frozen, gates: this.config.tools.gates,
881
+ ...(this.config.tools.workflowMode ? { workflowMode: this.config.tools.workflowMode } : {}),
882
+ ...(this.config.agentProfile ? { agentProfile: this.config.agentProfile } : {}),
883
+ ...(this.config.workflowToolProfile ? { workflowToolProfile: this.config.workflowToolProfile } : {}),
853
884
  workNamespace: this.config.workNamespace ?? "main",
854
885
  maxParallelTools: this.config.maxParallelTools ?? 3,
855
886
  ...(this.config.boundary ? { boundary: this.config.boundary } : {}),
@@ -953,13 +984,31 @@ export class Session {
953
984
  await result.accept?.();
954
985
  await this.record({ t: "extension", id: this.clock.nextId(), at: this.clock.now(), customType: "skill_result", content: result.content,
955
986
  display: true, inContext: true });
987
+ // Evidence stays in the receipt; structured output validates the child's
988
+ // actual final message, never the human-readable receipt wrapper (D11).
989
+ const finalText = this.config.validateOutput ? result.finalText ?? "" : result.content;
990
+ let outcome = result.outcome ?? executionOutcome("execution_error"), reason = "isolated skill finished";
991
+ if (outcome.status === "completed") {
992
+ const error = await this.validateFinalOutput(finalText);
993
+ if (error !== undefined) { outcome = executionOutcome("execution_error"); reason = error; }
994
+ }
995
+ await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(), reason, outcome });
956
996
  await this.saveCursor();
957
- yield { kind: "assistant_text", text: result.content };
958
- yield { kind: "done", outcome: result.outcome ?? executionOutcome("execution_error"), reason: "isolated skill finished" };
997
+ yield { kind: "assistant_text", text: finalText };
998
+ yield { kind: "done", outcome, reason };
959
999
  } finally { owned.finish(); }
960
1000
  } finally { this.config.tools = savedTools; this.restoreDeclaredTools(); }
961
1001
  }
962
1002
 
1003
+ /** Every final publication path records the same validator receipt (D11). */
1004
+ private async validateFinalOutput(text: string): Promise<string | undefined> {
1005
+ if (!this.config.validateOutput) return undefined;
1006
+ const validation = this.config.validateOutput(text);
1007
+ await this.appendExtension({ customType: "output_validation", content: validation.ok ? "Final JSON output passed the selected schema." : `Final JSON output failed: ${validation.error ?? "schema mismatch"}`,
1008
+ display: false, inContext: false, data: { valid: validation.ok } });
1009
+ return validation.ok ? undefined : `output schema validation failed: ${validation.error ?? "schema mismatch"}`;
1010
+ }
1011
+
963
1012
  /** O.8: dispatch and diagnostics share exact source hints, including forks. */
964
1013
  contextInput(): ProjectInput {
965
1014
  return { frozen: this.config.frozen, handoff: this.state.handoff, events: this.state.events,
@@ -1427,6 +1476,12 @@ export class Session {
1427
1476
  continue;
1428
1477
  }
1429
1478
  const stopped = yield* this.maybeCut(counter, false);
1479
+ if (!stopped) {
1480
+ const error = await this.validateFinalOutput(result.blocks.filter(block => block.type === "text").map(block => block.text).join("\n"));
1481
+ if (error !== undefined) {
1482
+ yield { kind: "done", reason: error, outcome: executionOutcome("execution_error") }; return;
1483
+ }
1484
+ }
1430
1485
  yield {
1431
1486
  kind: "done",
1432
1487
  reason: stopped ? "stopped: no verifiable progress" : result.stopReason ?? "end_turn",
@@ -2021,14 +2076,15 @@ export class Session {
2021
2076
  this.children.set(node, branch);
2022
2077
  yield { kind: "branch_start", node, reason: purpose, at: Date.now() };
2023
2078
 
2024
- let text = "";
2079
+ let finalParts: string[] = [];
2025
2080
  let outcome = executionOutcome("unknown");
2026
2081
  try {
2027
2082
  for await (const event of branch.snapshotTurn([], true)) {
2028
2083
  if (event.kind === "snapshot_failed") throw new Error(`cannot establish branch baseline: ${event.reason}`);
2029
2084
  }
2030
2085
  for await (const e of branch.send(prompt, signal)) {
2031
- if (e.kind === "assistant_text" && e.text) text = e.text;
2086
+ if (e.kind === "turn_start" || e.kind === "discard") finalParts = [];
2087
+ if (e.kind === "assistant_text") finalParts.push(e.text ?? "");
2032
2088
  if (e.kind === "done") outcome = e.outcome ?? executionOutcome("unknown");
2033
2089
  }
2034
2090
  } catch {
@@ -2055,6 +2111,7 @@ export class Session {
2055
2111
  ? `\n\nIts changes are in ${worktree.path} on branch ${worktree.branch}, ` +
2056
2112
  "not in your files. Adopting continues in that checkout without merging it into this one."
2057
2113
  : "";
2114
+ const text = finalParts.join("\n");
2058
2115
  const summary = text.trim() || "(the branch produced no closing message)";
2059
2116
  const receiptHistory = await readNodeHistory(this.config.logDir, tip, undefined, "context");
2060
2117
  if (!receiptHistory.ok) throw new Error(receiptHistory.error);
@@ -2081,6 +2138,7 @@ export class Session {
2081
2138
  (this.adviceNames().includes("resolve") ? `Resolve it with resolve(node: ${node}, ...).` : "Branch-resolution capability unavailable."),
2082
2139
  ok: outcome.status === "completed",
2083
2140
  outcome,
2141
+ finalText: text,
2084
2142
  accept: async () => {
2085
2143
  if ((await this.branchStatuses()).some(item => item.node === node && item.handoff_ref)) return [];
2086
2144
  this.state.usage.push(...branch.state.usage);