@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
@@ -0,0 +1,168 @@
1
+ /** OAuth is a user-owned repair operation; the SDK owns discovery, PKCE and issuer/resource binding. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { auth, checkResourceAllowed, resourceUrlFromServerUrl, StreamableHTTPClientTransport,
4
+ type OAuthClientProvider, type OAuthClientInformationContext, type StoredOAuthClientInformation, type StoredOAuthTokens,
5
+ type OAuthClientMetadata, type OAuthDiscoveryState, type FetchLike } from "@modelcontextprotocol/client";
6
+ import type { McpOAuthConfig } from "../core/mcp-config.ts";
7
+ import { isJsonValue } from "../core/tool-contract.ts";
8
+ import type { McpCredentials, McpCredentialsStore } from "../store/mcp-credentials.ts";
9
+ import { boundedResponseBytes } from "./web-http.ts";
10
+
11
+ export class McpAuthRequired extends Error { constructor() { super("MCP authentication requires an explicit login"); this.name = "McpAuthRequired"; } }
12
+ /** The SDK warns with OAuth error_description; normalize peer errors before they can reach its console path. */
13
+ export function safeOAuthFetch(fetcher: FetchLike): FetchLike {
14
+ return async (input, init) => {
15
+ let response: Response;
16
+ try { response = await fetcher(input, init); } catch { throw new McpAuthRequired(); }
17
+ if (response.ok) return response;
18
+ let code = "server_error";
19
+ try {
20
+ const value: unknown = JSON.parse(new TextDecoder().decode(await boundedResponseBytes(response, 16 * 1024)));
21
+ if (value && typeof value === "object" && "error" in value && typeof value.error === "string"
22
+ && ["invalid_request", "invalid_client", "invalid_grant", "unauthorized_client", "unsupported_grant_type", "invalid_scope", "access_denied", "server_error", "temporarily_unavailable"].includes(value.error)) code = value.error;
23
+ } catch { /* Unknown peer bodies contribute status only, never diagnostics containing secrets. */ }
24
+ return Response.json({ error: code, error_description: "OAuth request rejected" }, { status: response.status, headers: { "content-type": "application/json" } });
25
+ };
26
+ }
27
+ function savedClient(value: unknown): value is StoredOAuthClientInformation {
28
+ return !!value && typeof value === "object" && "client_id" in value && typeof value.client_id === "string";
29
+ }
30
+ function savedTokens(value: unknown): value is StoredOAuthTokens {
31
+ return !!value && typeof value === "object" && "access_token" in value && typeof value.access_token === "string"
32
+ && "token_type" in value && typeof value.token_type === "string";
33
+ }
34
+ /** A per-resource provider; discovery/verifier/state exist only during this owned process's login. */
35
+ export class McpOAuthProvider implements OAuthClientProvider {
36
+ private readonly url: string;
37
+ private readonly config: McpOAuthConfig;
38
+ private readonly store: McpCredentialsStore;
39
+ private readonly env: Readonly<Record<string, string | undefined>>;
40
+ private credentials: McpCredentials;
41
+ private verifier: string | undefined;
42
+ private discovery: OAuthDiscoveryState | undefined;
43
+ private expectedState: string | undefined;
44
+ private redirect: ((url: URL) => void | Promise<void>) | undefined;
45
+ private refreshing: Promise<void> | undefined;
46
+ private resourceMetadataUrl: URL | undefined;
47
+ private challengedScope: string | undefined;
48
+ private constructor(url: string, config: McpOAuthConfig,
49
+ store: McpCredentialsStore, env: Readonly<Record<string, string | undefined>>, credentials?: McpCredentials) {
50
+ this.url = url; this.config = config; this.store = store; this.env = env;
51
+ this.credentials = credentials ?? { version: 1, serverUrl: url, redirectUrl: config.redirectUrl ?? "http://127.0.0.1:0/callback",
52
+ clients: {}, tokens: {}, expiresAt: {}, activeIssuer: null, ...(config.clientId ? { clientIdentity: config.clientId } : {}) };
53
+ if (config.redirectUrl) this.credentials.redirectUrl = config.redirectUrl;
54
+ }
55
+ static async create(url: string, config: McpOAuthConfig, store: McpCredentialsStore, env: Readonly<Record<string, string | undefined>>) {
56
+ return new McpOAuthProvider(url, config, store, env, await store.read(url, config.clientId));
57
+ }
58
+ get redirectUrl() { return this.credentials.redirectUrl; }
59
+ get clientMetadata(): OAuthClientMetadata {
60
+ return { client_name: "xeno", application_type: "native", redirect_uris: [this.redirectUrl], response_types: ["code"],
61
+ grant_types: ["authorization_code", "refresh_token"], token_endpoint_auth_method: this.config.clientSecretEnv ? "client_secret_basic" : "none",
62
+ ...(this.config.scope ? { scope: this.config.scope } : {}) };
63
+ }
64
+ clientInformation(ctx?: OAuthClientInformationContext): StoredOAuthClientInformation | undefined {
65
+ if (this.config.clientId) {
66
+ const secret = this.config.clientSecretEnv ? this.env[this.config.clientSecretEnv] : undefined;
67
+ if (this.config.clientSecretEnv && !secret) throw new McpAuthRequired();
68
+ return { client_id: this.config.clientId, ...(secret ? { client_secret: secret } : {}), ...(ctx ? { issuer: ctx.issuer } : {}) };
69
+ }
70
+ const value = ctx ? this.credentials.clients[ctx.issuer] : undefined;
71
+ return savedClient(value) ? value : undefined;
72
+ }
73
+ async saveClientInformation(info: StoredOAuthClientInformation, ctx?: OAuthClientInformationContext) {
74
+ const issuer = ctx?.issuer ?? info.issuer;
75
+ if (!issuer || !isJsonValue(info)) throw new McpAuthRequired();
76
+ this.credentials.clients[issuer] = info;
77
+ await this.store.write(this.credentials);
78
+ }
79
+ tokens(ctx?: OAuthClientInformationContext): StoredOAuthTokens | undefined {
80
+ const issuer = ctx?.issuer ?? this.credentials.activeIssuer, value = issuer ? this.credentials.tokens[issuer] : undefined;
81
+ return savedTokens(value) ? value : undefined;
82
+ }
83
+ async saveTokens(tokens: StoredOAuthTokens, ctx?: OAuthClientInformationContext) {
84
+ const issuer = ctx?.issuer ?? tokens.issuer;
85
+ if (!issuer || !isJsonValue(tokens)) throw new McpAuthRequired();
86
+ this.credentials.tokens[issuer] = tokens; this.credentials.activeIssuer = issuer;
87
+ if (tokens.expires_in !== undefined) this.credentials.expiresAt[issuer] = Date.now() + tokens.expires_in * 1_000;
88
+ else delete this.credentials.expiresAt[issuer];
89
+ await this.store.write(this.credentials);
90
+ }
91
+ state() { this.expectedState = randomUUID(); return this.expectedState; }
92
+ redirectToAuthorization(url: URL) {
93
+ if (!this.redirect) throw new McpAuthRequired();
94
+ return this.redirect(url);
95
+ }
96
+ saveCodeVerifier(verifier: string) { this.verifier = verifier; }
97
+ codeVerifier() { if (!this.verifier) throw new McpAuthRequired(); return this.verifier; }
98
+ saveDiscoveryState(state: OAuthDiscoveryState) { this.discovery = state; }
99
+ discoveryState() { return this.discovery; }
100
+ setChallenge(resourceMetadataUrl: URL | undefined, scope: string | undefined) {
101
+ this.resourceMetadataUrl = resourceMetadataUrl; this.challengedScope = scope; this.discovery = undefined;
102
+ }
103
+ private authOptions(fetcher: FetchLike) {
104
+ const scope = this.config.scope ?? this.challengedScope;
105
+ return { serverUrl: this.url, fetchFn: safeOAuthFetch(fetcher), ...(this.resourceMetadataUrl ? { resourceMetadataUrl: this.resourceMetadataUrl } : {}), ...(scope ? { scope } : {}) };
106
+ }
107
+ async validateResourceURL(serverUrl: string | URL, resource?: string) {
108
+ const expected = resourceUrlFromServerUrl(serverUrl);
109
+ if (resource && !checkResourceAllowed({ requestedResource: expected, configuredResource: resource })) throw new McpAuthRequired();
110
+ return expected;
111
+ }
112
+ async invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery") {
113
+ if (scope === "all" || scope === "client") this.credentials.clients = {};
114
+ if (scope === "all" || scope === "tokens") { this.credentials.tokens = {}; this.credentials.expiresAt = {}; this.credentials.activeIssuer = null; }
115
+ if (scope === "all" || scope === "verifier") this.verifier = undefined;
116
+ if (scope === "all" || scope === "discovery") this.discovery = undefined;
117
+ if (scope === "all") await this.store.remove(this.url, this.config.clientId); else await this.store.write(this.credentials);
118
+ }
119
+ async token(fetcher: FetchLike): Promise<string | undefined> {
120
+ const issuer = this.credentials.activeIssuer, expiresAt = issuer ? this.credentials.expiresAt[issuer] : undefined;
121
+ // Assumed 30s skew prevents sending a token that expires while the new request is in flight.
122
+ if (expiresAt !== undefined && expiresAt <= Date.now() + 30_000) {
123
+ if (!this.refreshing) {
124
+ this.refreshing = (async () => {
125
+ if (!this.tokens()?.refresh_token) { await this.invalidateCredentials("tokens"); throw new McpAuthRequired(); }
126
+ try { if (await auth(this, this.authOptions(fetcher)) !== "AUTHORIZED") throw new McpAuthRequired(); }
127
+ catch { await this.invalidateCredentials("tokens"); throw new McpAuthRequired(); }
128
+ })().finally(() => { this.refreshing = undefined; });
129
+ }
130
+ await this.refreshing;
131
+ }
132
+ return this.tokens()?.access_token;
133
+ }
134
+ async login(onAuthorizationUrl: (url: URL) => void | Promise<void>, fetcher: FetchLike, signal: AbortSignal): Promise<void> {
135
+ if (this.redirect) throw new McpAuthRequired();
136
+ signal.throwIfAborted();
137
+ const callback = new URL(this.redirectUrl);
138
+ let resolveCallback: (value: URLSearchParams) => void = () => {}, rejectCallback: (error: Error) => void = () => {};
139
+ const received = new Promise<URLSearchParams>((resolve, reject) => { resolveCallback = resolve; rejectCallback = reject; });
140
+ // Reject handlers are installed before starting auth; cancellation never leaves an unhandled callback promise.
141
+ void received.catch(() => {});
142
+ const server = Bun.serve({ hostname: "127.0.0.1", port: Number(callback.port), fetch: request => {
143
+ const current = new URL(request.url);
144
+ if (current.pathname !== callback.pathname) return new Response("Not found", { status: 404 });
145
+ if (request.method !== "GET" || !this.expectedState || current.searchParams.get("state") !== this.expectedState) return new Response("Invalid authorization state", { status: 400 });
146
+ if (current.searchParams.has("error") || !current.searchParams.get("code")) {
147
+ rejectCallback(new McpAuthRequired()); return new Response("Authorization was not completed", { status: 400 });
148
+ }
149
+ resolveCallback(current.searchParams); return new Response("Authorization received. Return to xeno.", { headers: { "content-type": "text/plain" } });
150
+ } });
151
+ const selected = new URL(callback.pathname, server.url).href;
152
+ if (selected !== this.credentials.redirectUrl && !this.config.clientId) this.credentials.clients = {};
153
+ this.credentials.redirectUrl = selected; this.redirect = onAuthorizationUrl;
154
+ const onAbort = () => rejectCallback(new McpAuthRequired());
155
+ signal.addEventListener("abort", onAbort, { once: true });
156
+ try {
157
+ const safeFetch = safeOAuthFetch(fetcher);
158
+ const result = await auth(this, this.authOptions(fetcher));
159
+ if (result === "AUTHORIZED") return;
160
+ const params = await received;
161
+ const transport = new StreamableHTTPClientTransport(new URL(this.url), { authProvider: this, fetch: safeFetch, onInsufficientScope: "throw" });
162
+ try { await transport.finishAuth(params); } finally { await transport.close(); }
163
+ } finally {
164
+ signal.removeEventListener("abort", onAbort); server.stop(true); this.redirect = undefined;
165
+ this.expectedState = undefined; this.verifier = undefined; this.discovery = undefined;
166
+ }
167
+ }
168
+ }
@@ -0,0 +1,51 @@
1
+ /** MCP 2025-11-25 defaults to 2020-12; validation never repairs the frozen invocation. */
2
+ import { Ajv, type ValidateFunction } from "ajv";
3
+ import { Ajv2019 } from "ajv/dist/2019.js";
4
+ import { Ajv2020 } from "ajv/dist/2020.js";
5
+ import { addFormats } from "@modelcontextprotocol/client/validators/ajv";
6
+ import type { JsonSchemaType, JsonSchemaValidator, jsonSchemaValidator } from "@modelcontextprotocol/client";
7
+ import { NOT_STARTED, ToolFault } from "../core/tool-failure.ts";
8
+
9
+ export class McpSchemaError extends Error {
10
+ constructor() { super("MCP schema is invalid or uses an unsupported dialect, reference, keyword or format; repair the server schema before reloading."); this.name = "McpSchemaError"; }
11
+ }
12
+
13
+ /** The official client's format package is bundled in its public validator entry; no extra dependency. */
14
+ export class McpSchemas implements jsonSchemaValidator {
15
+ private readonly compiled = new Map<string, ValidateFunction>();
16
+ validator(schema: unknown): ValidateFunction {
17
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) throw new McpSchemaError();
18
+ const key = JSON.stringify(schema), cached = this.compiled.get(key);
19
+ if (cached) return cached;
20
+ try {
21
+ const dialect = (schema as Record<string, unknown>)["$schema"];
22
+ const options = { strictSchema: true, strictNumbers: true, strictTypes: false, strictTuples: false, strictRequired: false,
23
+ validateFormats: true, coerceTypes: false, useDefaults: false, removeAdditional: false, allErrors: false, logger: false as const };
24
+ // Strict schema rejects unknown semantics, while strict style checks are disabled:
25
+ // valid JSON Schema need not spell out every tuple bound or repeat inherited types.
26
+ const ajv = dialect === undefined || dialect === "https://json-schema.org/draft/2020-12/schema" ? new Ajv2020(options)
27
+ : dialect === "https://json-schema.org/draft/2019-09/schema" ? new Ajv2019(options)
28
+ : dialect === "http://json-schema.org/draft-07/schema#" || dialect === "http://json-schema.org/draft-07/schema" ? new Ajv(options)
29
+ : undefined;
30
+ if (!ajv) throw new McpSchemaError();
31
+ addFormats(ajv);
32
+ // MCP's header annotation is interpreted by the official transport, not a validation constraint.
33
+ ajv.addKeyword({ keyword: "x-mcp-header", schemaType: "string", valid: true });
34
+ // Ajv validates this entire JSON schema; the assertion only bridges the SDK's wider wire type.
35
+ const validate = ajv.compile(structuredClone(schema) as JsonSchemaType);
36
+ this.compiled.set(key, validate); return validate;
37
+ } catch { throw new McpSchemaError(); }
38
+ }
39
+ getValidator<T>(schema: JsonSchemaType): JsonSchemaValidator<T> {
40
+ const validate = this.validator(schema);
41
+ return input => validate(input) ? { valid: true, data: input as T, errorMessage: undefined }
42
+ : { valid: false, data: undefined, errorMessage: "MCP structured output failed the advertised schema." };
43
+ }
44
+ prepare(schema: unknown): (input: unknown) => unknown {
45
+ const validate = this.validator(schema);
46
+ return input => {
47
+ if (!validate(input)) throw new ToolFault("invalid_input", "MCP arguments do not satisfy the complete advertised schema. Correct the input before calling the remote tool.", NOT_STARTED);
48
+ return input;
49
+ };
50
+ }
51
+ }
@@ -18,7 +18,7 @@ import { pollDeviceCode } from "./oauth-device.ts";
18
18
  import { parseDeviceAuthorization, parseOAuthTokens } from "../core/oauth-fields.ts";
19
19
 
20
20
  /** Matches package.json. Sent as User-Agent so the server can see who we are (D18). */
21
- export const XENO_VERSION = "0.4.0";
21
+ export const XENO_VERSION = "0.6.0";
22
22
 
23
23
  /**
24
24
  * Minutes subtracted from server expiry so a request issued at the edge
@@ -0,0 +1,26 @@
1
+ /** Ajv owns JSON Schema semantics; validation never repairs model output (D11/D13). */
2
+ import { Ajv } from "ajv";
3
+ import { Ajv2020 } from "ajv/dist/2020.js";
4
+ import { Ajv2019 } from "ajv/dist/2019.js";
5
+ import { addFormats } from "@modelcontextprotocol/client/validators/ajv";
6
+
7
+ export type OutputValidation = { ok: true; value: unknown } | { ok: false; error: string };
8
+ export function outputValidator(schema: unknown): (text: string) => OutputValidation {
9
+ if (typeof schema !== "boolean" && (schema === null || typeof schema !== "object" || Array.isArray(schema))) throw Error("output schema must be a JSON Schema object or boolean");
10
+ const dialect = typeof schema === "object" && schema !== null && "$schema" in schema ? String(schema.$schema) : "";
11
+ const options = { allErrors: true, strictSchema: true, strictTypes: false, strictTuples: false, strictRequired: false, validateFormats: true,
12
+ coerceTypes: false, useDefaults: false, removeAdditional: false, logger: false as const };
13
+ const ajv = dialect === "https://json-schema.org/draft/2020-12/schema" ? new Ajv2020(options)
14
+ : dialect === "https://json-schema.org/draft/2019-09/schema" ? new Ajv2019(options)
15
+ : dialect === "" || dialect === "http://json-schema.org/draft-07/schema#" || dialect === "http://json-schema.org/draft-07/schema" ? new Ajv(options) : undefined;
16
+ if (!ajv) throw Error("unsupported output schema dialect");
17
+ // The approved MCP client exposes Ajv's standard formats; share them rather than silently ignoring formats.
18
+ addFormats(ajv);
19
+ const validate = ajv.compile(schema);
20
+ return text => {
21
+ let value: unknown;
22
+ try { value = JSON.parse(text); } catch { return { ok: false, error: "final output is not JSON" }; }
23
+ if (!validate(value)) return { ok: false, error: ajv.errorsText(validate.errors, { separator: "; " }).slice(0, 2048) };
24
+ return { ok: true, value };
25
+ };
26
+ }
@@ -0,0 +1,38 @@
1
+ /** One durable mutation owner for multi-file Patch, independent of provider syntax. */
2
+ import { changedPaths } from "../core/checkpoints.ts";
3
+ import { NOT_STARTED, READ_ATTEMPTED, UNKNOWN_EFFECT, failureDiagnostic, failureFromError } from "../core/tool-failure.ts";
4
+ import { preparePatch, applyPreparedPatch } from "../store/patch.ts";
5
+ import { presentObservation, type ToolDefinition } from "./tools.ts";
6
+
7
+ export function createPatchTool(): ToolDefinition {
8
+ return {
9
+ name: "apply_patch", access: "write", execution: "exclusive", branch: "write",
10
+ description: "Apply a Codex-style multi-file patch containing Add File, Update File, optional Move to, and Delete File. Existing sources require a prior session read or explicit expected_sha256. All targets and exact unique hunks are preflighted before changes; add/move destinations must be absent. Uses durable file checkpoints and compensates partial application without overwriting external changes. Preserves original BOM, line endings and modes. Return contains resulting file SHA-256 versions.",
11
+ input_schema: { type: "object", required: ["patchText"], properties: {
12
+ patchText: { type: "string", description: "Full *** Begin Patch ... *** End Patch text; update hunks start with @@ and lines use space/+/- prefixes." },
13
+ expected_sha256: { type: "object", additionalProperties: { type: "string", pattern: "^[a-f0-9]{64}$" }, description: "Optional source path to exact SHA-256 premise; otherwise use observed session versions." },
14
+ } },
15
+ run: async (ctx, input) => {
16
+ if (!ctx.workspace) return { ok: false, content: "Patch requires the session's durable workspace checkpoint owner.", diagnostic: failureDiagnostic("environment_missing", "Patch requires the session's durable workspace checkpoint owner.", NOT_STARTED) };
17
+ let admitted = false;
18
+ try {
19
+ const expected = input["expected_sha256"];
20
+ if (expected !== undefined && (expected === null || typeof expected !== "object" || Array.isArray(expected))) throw new Error("expected_sha256 must be an object of source digests");
21
+ const prepared = await preparePatch(ctx.root, ctx.workspace.blobs, String(input["patchText"]), {
22
+ ...(ctx.observedFileVersion === undefined ? {} : { observedFileVersion: ctx.observedFileVersion }),
23
+ ...(expected === undefined ? {} : { expectedSha256: expected as Record<string, string> }),
24
+ ...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
25
+ });
26
+ admitted = true;
27
+ await ctx.workspace.patch(prepared.changes, () => applyPreparedPatch(ctx.root, ctx.workspace!.blobs, prepared, ctx.signal), ctx.signal);
28
+ const paths = changedPaths(prepared.changes);
29
+ const summary = paths.map(path => `${path}: ${prepared.changes[path]!.after?.sha256 ?? "deleted"}`).join("\n");
30
+ const data = paths.map(path => ({ path, before: prepared.changes[path]!.before?.sha256 ?? null, after: prepared.changes[path]!.after?.sha256 ?? null }));
31
+ return { ...await presentObservation(ctx, `Applied patch to ${paths.length} file path(s).\n${summary}\n\n${prepared.diffs.map(diff => diff.diff).join("\n")}`), ok: true, data: { files: data } };
32
+ } catch (error) {
33
+ const diagnostic = failureFromError(error, admitted ? UNKNOWN_EFFECT : READ_ATTEMPTED);
34
+ return { ok: false, content: diagnostic.message, diagnostic };
35
+ }
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,11 @@
1
+ /** R13: an indirect content reader must retain the same read-resource policy. */
2
+ import { decideWithSessionRules, type PermissionAction, type PermissionRule } from "../core/permissions.ts";
3
+ import { permissionPaths } from "../store/permission-paths.ts";
4
+
5
+ export async function permissionResourceAction(root: string, path: string, configured: readonly PermissionRule[],
6
+ remembered: readonly PermissionRule[] = [], tool = "read"): Promise<PermissionAction> {
7
+ const paths = await permissionPaths(root, [path]);
8
+ const read = decideWithSessionRules(configured, remembered, { tool: "read", paths });
9
+ const specific = decideWithSessionRules(configured, remembered, { tool, paths });
10
+ return read === "deny" || specific === "deny" ? "deny" : read === "ask" || specific === "ask" ? "ask" : "allow";
11
+ }
@@ -44,6 +44,8 @@ export interface ToolSchema {
44
44
  name: string;
45
45
  description: string;
46
46
  input_schema: {
47
+ /** Standard JSON Schema keywords from MCP must survive the frozen declaration. */
48
+ [keyword: string]: unknown;
47
49
  type: "object";
48
50
  properties: Record<string, unknown>;
49
51
  required: string[];
@@ -0,0 +1,62 @@
1
+ /** R13: UI and protocol clients share one active-turn question adapter. */
2
+ import { parseQuestions, questionRequest, validateQuestionReply, MAX_QUESTIONS, MAX_QUESTION_CHOICES,
3
+ type QuestionRequest, type QuestionReply } from "../core/question.ts";
4
+ import { failureDiagnostic, NOT_STARTED } from "../core/tool-failure.ts";
5
+ import { executionOutcome } from "../core/outcome.ts";
6
+ import type { JsonValue, ToolDiagnostic } from "../core/tool-contract.ts";
7
+ import type { ToolContext, ToolDefinition, ToolResult } from "./tools.ts";
8
+
9
+ export type QuestionAdapter = (request: QuestionRequest, signal: AbortSignal) => Promise<unknown | undefined>;
10
+ export interface QuestionSlot {
11
+ /** The Session/app owner changes this at user-turn boundaries, never model input. */
12
+ activeTurn?: string;
13
+ current?: QuestionAdapter;
14
+ }
15
+ function failure(code: ToolDiagnostic["code"], message: string): ToolResult {
16
+ return { ok: false, content: message, diagnostic: failureDiagnostic(code, message, NOT_STARTED),
17
+ outcome: executionOutcome(code === "cancelled" ? "cancelled" : "tool_failed") };
18
+ }
19
+ function plainReply(reply: QuestionReply): JsonValue {
20
+ return { request_id: reply.request_id, turn_id: reply.turn_id,
21
+ answers: reply.answers.map(answer => ({ id: answer.id, choices: [...answer.choices], ...(answer.text !== undefined ? { text: answer.text } : {}) })) };
22
+ }
23
+ async function ask(slot: QuestionSlot, ctx: ToolContext, input: Record<string, unknown>): Promise<ToolResult> {
24
+ let questions;
25
+ try { questions = parseQuestions(input); }
26
+ catch (error) { return failure("invalid_input", error instanceof Error ? error.message : "Invalid question"); }
27
+ const turn = slot.activeTurn, call = ctx.callId, adapter = slot.current;
28
+ if (!turn || !call) return failure("permission_denied", "question requires an active turn and tool invocation");
29
+ if (!adapter) return failure("environment_missing", "question requires an interactive terminal or a connected client that can answer questions");
30
+ const signal = ctx.signal ?? new AbortController().signal;
31
+ if (signal.aborted) return failure("cancelled", "Question cancelled");
32
+ const request = questionRequest(turn, call, questions);
33
+ let stop = () => {};
34
+ const cancelled = new Promise<undefined>(resolve => { stop = () => resolve(undefined); signal.addEventListener("abort", stop, { once: true }); });
35
+ try {
36
+ // Late adapter rejection is observed by race; an adapter ignoring its
37
+ // signal cannot keep the original tool exchange unpaired (D5).
38
+ const answer = await Promise.race([Promise.resolve().then(() => adapter(structuredClone(request), signal)), cancelled]);
39
+ if (signal.aborted || answer === undefined || slot.activeTurn !== turn) return failure("cancelled", "Question cancelled or its turn is no longer active");
40
+ let reply;
41
+ try { reply = validateQuestionReply(request, answer); }
42
+ catch { return failure("invalid_input", "Question client returned an invalid answer or stale invocation identity"); }
43
+ const data = plainReply(reply);
44
+ return { ok: true, content: JSON.stringify(data), data, outcome: executionOutcome("tool_completed") };
45
+ } catch { return failure("execution_failed", "Question client failed before returning a validated answer"); }
46
+ finally { signal.removeEventListener("abort", stop); }
47
+ }
48
+
49
+ export function createQuestionTool(slot: QuestionSlot): ToolDefinition {
50
+ return { name: "question", access: "state", execution: "exclusive", branch: "none",
51
+ description: "Ask 1..3 concise questions for missing requirements or material decisions; await the user's answer in this turn. Use stable question and option ids. Set multiple=true for multiple choices; custom text is enabled by default. An absent/cancelled answer is not approval. This tool does not approve tool execution or change workflow mode.",
52
+ input_schema: { type: "object", required: ["questions"], properties: {
53
+ questions: { type: "array", minItems: 1, maxItems: MAX_QUESTIONS, items: { type: "object", additionalProperties: false,
54
+ required: ["id", "header", "question"], properties: {
55
+ id: { type: "string", pattern: "^[a-z][a-z0-9_-]{0,63}$" }, header: { type: "string", minLength: 1, maxLength: 40 },
56
+ question: { type: "string", minLength: 1, maxLength: 2000 }, multiple: { type: "boolean" }, custom: { type: "boolean" },
57
+ options: { type: "array", maxItems: MAX_QUESTION_CHOICES, items: { type: "object", additionalProperties: false,
58
+ required: ["id", "label"], properties: { id: { type: "string", pattern: "^[a-z][a-z0-9_-]{0,63}$" },
59
+ label: { type: "string", minLength: 1, maxLength: 120 }, description: { type: "string", minLength: 1, maxLength: 600 } } } },
60
+ } } },
61
+ } }, run: (ctx, input) => ask(slot, ctx, input) };
62
+ }
@@ -0,0 +1,48 @@
1
+ /** Shared diff preparation for model tools and user review commands; no provider calls. */
2
+ import type { FileChanges } from "../core/checkpoints.ts";
3
+ import type { ReviewScope } from "../core/review.ts";
4
+ import { failureFromError, READ_ATTEMPTED } from "../core/tool-failure.ts";
5
+ import { readReview, readCheckpointReview } from "../store/review.ts";
6
+ import { presentObservation, type ToolContext, type ToolDefinition } from "./tools.ts";
7
+
8
+ export interface LastTurnReview { blobs: string; changes: FileChanges; limitations: string[] }
9
+ export interface DiffToolOptions {
10
+ lastTurn?: (context: ToolContext) => Promise<LastTurnReview>;
11
+ /** Current resource policy must be checked before diff bytes enter an artifact or model output. */
12
+ canRead?: (context: ToolContext, path: string) => Promise<boolean>;
13
+ }
14
+ export function parseReviewScope(input: Record<string, unknown>): ReviewScope {
15
+ const kind = input["scope"] ?? "working";
16
+ if (kind === "working" || kind === "last-turn") {
17
+ if (input["base"] !== undefined || input["commit"] !== undefined) throw new Error(`${kind} scope accepts no revision`);
18
+ return { kind };
19
+ }
20
+ if (kind === "branch" && typeof input["base"] === "string" && input["base"].trim() && input["commit"] === undefined) return { kind, base: input["base"] };
21
+ if (kind === "commit" && typeof input["commit"] === "string" && input["commit"].trim() && input["base"] === undefined) return { kind, commit: input["commit"] };
22
+ throw new Error("review scope must be working, branch with base, commit with commit, or last-turn");
23
+ }
24
+
25
+ export function createDiffTool(options: DiffToolOptions = {}): ToolDefinition {
26
+ return {
27
+ name: "diff", access: "read", execution: "parallel", branch: "read",
28
+ description: "Read a unified diff for current staged/unstaged/untracked changes, a branch merge base, a selected commit, or durable checkpoints in the last user turn. Fixed Git argv disables external diff/textconv drivers and confines paths to the workspace. Reports uncaptured or unavailable data explicitly. This observes changes; it does not change files or run a model review.",
29
+ input_schema: { type: "object", required: [], properties: { scope: { type: "string", enum: ["working", "branch", "commit", "last-turn"] }, base: { type: "string" }, commit: { type: "string" } } },
30
+ run: async (ctx, input) => {
31
+ try {
32
+ const scope = parseReviewScope(input);
33
+ const canRead = options.canRead === undefined ? undefined : (path: string) => options.canRead!(ctx, path);
34
+ const snapshot = scope.kind === "last-turn" ? await (async () => {
35
+ if (!options.lastTurn) throw new Error("last-turn diff requires original session checkpoint history");
36
+ const original = await options.lastTurn(ctx);
37
+ return readCheckpointReview(original.blobs, original.changes, original.limitations, canRead);
38
+ })() : await readReview(ctx.root, scope, ctx.signal, canRead);
39
+ const notices = snapshot.limitations.length ? "\nLimitations:\n" + snapshot.limitations.map(line => "- " + line).join("\n") : "";
40
+ return { ...await presentObservation(ctx, `${snapshot.label}\n${snapshot.diff || "(no captured changes)"}${notices}`), ok: true,
41
+ data: { scope: scope.kind, paths: snapshot.paths, limitations: snapshot.limitations, ...(snapshot.base === undefined ? {} : { base: snapshot.base }) } };
42
+ } catch (error) {
43
+ const diagnostic = failureFromError(error, READ_ATTEMPTED);
44
+ return { ok: false, content: diagnostic.message, diagnostic };
45
+ }
46
+ },
47
+ };
48
+ }
@@ -11,16 +11,25 @@ import type { LogEvent } from "../core/events.ts";
11
11
  import { readNodeHistory } from "../store/restore.ts";
12
12
  import type { Session, LoopEvent } from "./loop.ts";
13
13
  import type { ApprovalChoice } from "./tools.ts";
14
+ import { validateQuestionReply, type QuestionRequest, type QuestionReply } from "../core/question.ts";
15
+ import type { QuestionSlot } from "./question-tool.ts";
14
16
 
15
17
  export type ControlNotice = { method: string; params: { threadId: string; streamId: string; sequence: number; turnId?: string; [key: string]: unknown } };
16
18
  export type PendingApproval = { id: string; turnId: string; request: PermissionRequest };
17
19
  export type ControlSnapshot = {
18
20
  version: "xeno-control/0.1"; threadId: string; streamId: string; sequence: number; node: number;
19
21
  activeTurnId: string | null; goal: GoalView | null; context: ContextLens;
20
- waiting: "permission" | "question" | "auth" | null; approvals: PendingApproval[];
22
+ workflowMode: "build" | "plan";
23
+ waiting: "permission" | "question" | "auth" | null; approvals: PendingApproval[]; questions: QuestionRequest[];
21
24
  };
22
25
 
23
26
  export class SessionControl {
27
+ /** App-owned configuration switches resources at an idle checkpoint; observers cannot mutate it. */
28
+ configureWorkflow?: (mode: "build" | "plan") => Promise<void>;
29
+ readonly questionSlot: QuestionSlot;
30
+ private questionClients = 0;
31
+ private readonly questionAdapter: NonNullable<QuestionSlot["current"]> = (request, signal) => this.askQuestion(request, signal);
32
+ private readonly questions = new Map<string, { request: QuestionRequest; resolve(reply: QuestionReply | undefined): void }>();
24
33
  readonly session: Session;
25
34
  readonly threadId: string;
26
35
  readonly streamId = randomUUID();
@@ -35,6 +44,7 @@ export class SessionControl {
35
44
 
36
45
  constructor(session: Session) {
37
46
  this.session = session;
47
+ this.questionSlot = session.config.tools.questionSlot ?? {};
38
48
  // Same identity rule as provider affinity; no new durable identity store (D8).
39
49
  this.threadId = createHash("sha256").update(resolve(session.config.logDir)).digest("hex");
40
50
  session.subscribeRecords(item => this.emit("item/completed", { item }));
@@ -42,6 +52,14 @@ export class SessionControl {
42
52
  get activeTurnId(): string | null { return this.active?.id ?? null; }
43
53
  get busy(): boolean { return this.changing || this.active !== null || this.session.running; }
44
54
 
55
+ /** One owner excludes turn admission across asynchronous checkpoint/configuration work. */
56
+ async withIdleChange<T>(operation: () => Promise<T>): Promise<T> {
57
+ if (this.busy) throw Error("configuration changes require an idle session");
58
+ this.changing = true;
59
+ try { const result = await operation(); this.emit("thread/statusChanged"); return result; }
60
+ finally { this.changing = false; }
61
+ }
62
+
45
63
  subscribe(listener: (event: ControlNotice) => void): () => void {
46
64
  this.listeners.add(listener); return () => { this.listeners.delete(listener); };
47
65
  }
@@ -72,8 +90,9 @@ export class SessionControl {
72
90
  const projectedGoal = goalView(parts);
73
91
  const goal = this.active && projectedGoal?.state.request.event !== this.active.id ? null : projectedGoal;
74
92
  return { version: "xeno-control/0.1", threadId: this.threadId, streamId: this.streamId, sequence: this.sequence, node,
75
- activeTurnId: this.activeTurnId, goal, waiting: this.waiting,
93
+ activeTurnId: this.activeTurnId, goal, waiting: this.waiting, workflowMode: s.config.tools.workflowMode ?? "build",
76
94
  approvals: structuredClone([...this.approvals.values()].map(item => item.data)),
95
+ questions: structuredClone([...this.questions.values()].map(item => item.request)),
77
96
  context: contextLens({ ...s.contextInput(), anchor: s.state.anchor, policy: s.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
78
97
  contextWindow: s.provider.config.spec.contextWindow, outputReserve: s.provider.config.maxTokens,
79
98
  ...(s.config.counter ? { counter: s.config.counter } : {}), ...(s.config.boundary ? { boundary: s.config.boundary } : {}) }) };
@@ -92,6 +111,7 @@ export class SessionControl {
92
111
  if (this.busy) throw Error("a turn is already running");
93
112
  const active = { id: this.session.clock.nextId(), abort: new AbortController(), queue: [] as UserInput[] };
94
113
  this.active = active;
114
+ this.questionSlot.activeTurn = active.id;
95
115
  const signals = signal ? AbortSignal.any([signal, active.abort.signal]) : active.abort.signal;
96
116
  let outcome: ExecutionOutcome | undefined;
97
117
  try {
@@ -114,6 +134,8 @@ export class SessionControl {
114
134
  throw error;
115
135
  } finally {
116
136
  for (const approval of [...this.approvals.values()]) approval.resolve("deny");
137
+ for (const question of [...this.questions.values()]) question.resolve(undefined);
138
+ delete this.questionSlot.activeTurn;
117
139
  this.waiting = null;
118
140
  // A thrown provider error also appends an outcome. Clients must be able to discover it.
119
141
  try {
@@ -147,6 +169,7 @@ export class SessionControl {
147
169
  interrupt(turnId: string): void {
148
170
  this.current(turnId).abort.abort();
149
171
  for (const approval of [...this.approvals.values()]) approval.resolve("deny");
172
+ for (const question of [...this.questions.values()]) question.resolve(undefined);
150
173
  this.session.cancel();
151
174
  }
152
175
  setWaiting(kind: ControlSnapshot["waiting"]): void {
@@ -178,6 +201,41 @@ export class SessionControl {
178
201
  approval.resolve(choice);
179
202
  }
180
203
  denyApprovals(): void { for (const approval of [...this.approvals.values()]) approval.resolve("deny"); }
204
+ attachQuestionClient(): () => void {
205
+ this.questionClients++;
206
+ if (!this.questionSlot.current) this.questionSlot.current = this.questionAdapter;
207
+ let closed = false;
208
+ return () => {
209
+ if (closed) return; closed = true;
210
+ if (--this.questionClients === 0) {
211
+ if (this.questionSlot.current === this.questionAdapter) delete this.questionSlot.current;
212
+ for (const question of [...this.questions.values()]) question.resolve(undefined);
213
+ }
214
+ };
215
+ }
216
+ askQuestion(request: QuestionRequest, signal: AbortSignal, present?: (request: QuestionRequest) => Promise<unknown>): Promise<QuestionReply | undefined> {
217
+ if (!this.active || request.turn_id !== this.active.id || signal.aborted || !present && this.questionClients === 0) return Promise.resolve(undefined);
218
+ if (this.questions.has(request.request_id)) return Promise.reject(Error("duplicate question invocation"));
219
+ return new Promise(resolve => {
220
+ const finish = (reply: QuestionReply | undefined) => {
221
+ if (!this.questions.delete(request.request_id)) return;
222
+ signal.removeEventListener("abort", aborted); this.setWaiting(this.questions.size ? "question" : null);
223
+ this.emit("question/resolved", { requestId: request.request_id }); resolve(reply);
224
+ };
225
+ const aborted = () => finish(undefined);
226
+ this.questions.set(request.request_id, { request: structuredClone(request), resolve: finish });
227
+ signal.addEventListener("abort", aborted, { once: true }); this.setWaiting("question");
228
+ this.emit("question/request", { question: structuredClone(request) });
229
+ if (present) void present(structuredClone(request)).then(value => {
230
+ if (value === undefined) finish(undefined); else try { finish(validateQuestionReply(request, value)); } catch { finish(undefined); }
231
+ }).catch(() => finish(undefined));
232
+ });
233
+ }
234
+ respondQuestion(turnId: string, requestId: string, reply: unknown): void {
235
+ this.current(turnId); const question = this.questions.get(requestId);
236
+ if (!question || question.request.turn_id !== turnId) throw Error("stale or unknown question requestId");
237
+ question.resolve(validateQuestionReply(question.request, reply));
238
+ }
181
239
  async accept(requestId: string, criterion: string): Promise<void> {
182
240
  if (this.busy) throw Error("acceptance requires an idle session");
183
241
  this.changing = true;