@visual-z/xeno 0.5.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.
- package/CHANGELOG.md +10 -0
- package/README.md +7 -3
- 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 +84 -0
- package/src/apps/shell.ts +54 -0
- package/src/apps/startup.ts +157 -22
- package/src/apps/ui/app.ts +21 -22
- 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/agent-profile.ts +130 -0
- package/src/core/control-protocol.ts +8 -1
- package/src/core/file-precondition.ts +6 -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/question.ts +94 -0
- package/src/core/review.ts +68 -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/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/integrations.ts +8 -2
- package/src/runtime/loop.ts +68 -10
- 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/output-schema.ts +26 -0
- package/src/runtime/patch-tool.ts +38 -0
- package/src/runtime/permission-resources.ts +11 -0
- package/src/runtime/provider.ts +2 -0
- package/src/runtime/question-tool.ts +62 -0
- package/src/runtime/review-tool.ts +48 -0
- package/src/runtime/session-control.ts +60 -2
- package/src/runtime/session-hub.ts +85 -0
- package/src/runtime/tool-discovery.ts +4 -3
- package/src/runtime/tools.ts +37 -7
- 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/context-files.ts +77 -1
- package/src/store/file-checkpoints.ts +5 -4
- package/src/store/foundation-config.ts +24 -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
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|
package/src/runtime/loop.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
768
|
-
|
|
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:
|
|
958
|
-
yield { kind: "done", outcome
|
|
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
|
|
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 === "
|
|
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);
|