@visual-z/xeno 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +19 -4
- package/docs/app-server.md +32 -3
- package/docs/foundations.md +88 -0
- package/docs/install.md +23 -2
- package/docs/research/foundation-clients-2026-10-09.md +27 -0
- package/docs/research/foundation-editing-2026-10-09.md +56 -0
- package/docs/research/foundation-network-2026-10-09.md +37 -0
- package/docs/research/foundation-workflow-2026-10-09.md +37 -0
- package/package.json +10 -2
- package/src/apps/acp-server.ts +41 -0
- package/src/apps/agent-profiles.ts +19 -0
- package/src/apps/app-server.ts +4 -2
- package/src/apps/cli.ts +18 -2
- package/src/apps/commands.ts +20 -0
- package/src/apps/control-transport.ts +3 -2
- package/src/apps/model-catalog.ts +57 -31
- package/src/apps/question-ui.ts +27 -0
- package/src/apps/resources.ts +63 -25
- package/src/apps/review.ts +109 -0
- package/src/apps/session-hub.ts +95 -0
- package/src/apps/shell.ts +55 -1
- package/src/apps/startup.ts +157 -22
- package/src/apps/ui/app.ts +89 -38
- package/src/apps/ui/image-cache.ts +71 -28
- package/src/apps/ui/surface.ts +1 -0
- package/src/apps/update-check.ts +71 -0
- package/src/apps/workflow.ts +58 -0
- package/src/core/activity-status.ts +28 -0
- package/src/core/agent-profile.ts +130 -0
- package/src/core/control-protocol.ts +8 -1
- package/src/core/file-precondition.ts +6 -0
- package/src/core/git-status.ts +29 -0
- package/src/core/image-limits.ts +72 -0
- package/src/core/lsp-framing.ts +34 -0
- package/src/core/mcp-config.ts +68 -0
- package/src/core/patch.ts +130 -0
- package/src/core/permissions.ts +143 -4
- package/src/core/progress.ts +9 -0
- package/src/core/question.ts +94 -0
- package/src/core/request-budget.ts +13 -0
- package/src/core/review.ts +68 -0
- package/src/core/session-exit.ts +26 -0
- package/src/core/settings.ts +1 -1
- package/src/core/stall.ts +18 -6
- package/src/core/stop-report.ts +25 -0
- package/src/core/update-check.ts +24 -5
- package/src/core/web-content.ts +80 -0
- package/src/core/workflow-mode.ts +34 -0
- package/src/runtime/acp-agent.ts +289 -0
- package/src/runtime/agent-profiles.ts +21 -0
- package/src/runtime/catalog.ts +2 -2
- package/src/runtime/control-client.ts +149 -0
- package/src/runtime/control-connection.ts +39 -4
- package/src/runtime/exec-sessions.ts +40 -4
- package/src/runtime/extensions.ts +20 -2
- package/src/runtime/formatter-tool.ts +38 -0
- package/src/runtime/formatter.ts +50 -0
- package/src/runtime/foundation.ts +78 -0
- package/src/runtime/images.ts +6 -2
- package/src/runtime/integrations.ts +8 -2
- package/src/runtime/loop.ts +124 -21
- package/src/runtime/lsp-client.ts +201 -0
- package/src/runtime/mcp-client.ts +264 -0
- package/src/runtime/mcp-oauth.ts +168 -0
- package/src/runtime/mcp-schema.ts +51 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/openai-responses.ts +6 -4
- package/src/runtime/openai.ts +6 -1
- package/src/runtime/output-schema.ts +26 -0
- package/src/runtime/patch-tool.ts +38 -0
- package/src/runtime/permission-resources.ts +11 -0
- package/src/runtime/pi-provider.ts +5 -4
- package/src/runtime/provider-tool-policy.ts +33 -0
- package/src/runtime/provider.ts +8 -1
- package/src/runtime/question-tool.ts +62 -0
- package/src/runtime/review-tool.ts +48 -0
- package/src/runtime/session-control.ts +61 -2
- package/src/runtime/session-hub.ts +85 -0
- package/src/runtime/tool-discovery.ts +4 -3
- package/src/runtime/tools.ts +44 -10
- package/src/runtime/update-check.ts +3 -2
- package/src/runtime/web-http.ts +56 -0
- package/src/runtime/web-tools.ts +88 -0
- package/src/runtime/workspace-lifecycle.ts +14 -1
- package/src/store/agent-profile-files.ts +13 -0
- package/src/store/blobs.ts +2 -1
- package/src/store/context-files.ts +77 -1
- package/src/store/file-checkpoints.ts +5 -4
- package/src/store/foundation-config.ts +24 -0
- package/src/store/git-status.ts +15 -0
- package/src/store/lsp-document.ts +30 -0
- package/src/store/mcp-credentials.ts +85 -0
- package/src/store/patch.ts +92 -0
- package/src/store/permission-paths.ts +34 -0
- package/src/store/review.ts +109 -0
- package/src/store/search.ts +48 -9
- package/src/tui/ui/layout.ts +7 -7
- package/src/tui/ui/messages.ts +1 -1
- package/src/tui/ui/pictures.ts +26 -9
- package/src/tui/ui/screen.ts +12 -8
- package/src/tui/ui/status.ts +32 -6
|
@@ -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
|
+
}
|
|
@@ -23,6 +23,7 @@ import { FailedResponseError, type OnDelta, type Provider, type ProviderConfig,
|
|
|
23
23
|
import { DEFAULT_RETRY, withRetry, type RetryPolicy } from "./retry.ts";
|
|
24
24
|
import { HttpError, StreamError } from "./openai.ts";
|
|
25
25
|
import type { UsageSample } from "./usage.ts";
|
|
26
|
+
import { disablePayloadTools, type ProviderSendOptions } from "./provider-tool-policy.ts";
|
|
26
27
|
|
|
27
28
|
const APIS: Record<Api, ProviderStreams> = {
|
|
28
29
|
"anthropic-messages": anthropicMessagesApi(), "openai-completions": openAICompletionsApi(),
|
|
@@ -305,14 +306,14 @@ export class PiProvider implements Provider {
|
|
|
305
306
|
this.config = config; this.apiKey = apiKey; this.options = options;
|
|
306
307
|
}
|
|
307
308
|
|
|
308
|
-
send(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta): Promise<Turn> {
|
|
309
|
+
send(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta, policy?: ProviderSendOptions): Promise<Turn> {
|
|
309
310
|
// Bedrock's Pi adapter leaves retry ownership with the AWS SDK; never multiply that loop.
|
|
310
311
|
const retry = this.options.retry ?? (this.config.spec.api === "bedrock-converse-stream" ? { ...DEFAULT_RETRY, maxRetries: 0 } : DEFAULT_RETRY);
|
|
311
|
-
return withRetry(() => this.attempt(frozen, messages, node, signal, onDelta), retry, undefined,
|
|
312
|
+
return withRetry(() => this.attempt(frozen, messages, node, signal, onDelta, policy), retry, undefined,
|
|
312
313
|
(attempt, delayMs, error) => { onDelta?.({ kind: "discard" }); onDelta?.({ kind: "retry", attempt, delayMs }); this.options.onRetry?.(attempt, delayMs, error); }, signal);
|
|
313
314
|
}
|
|
314
315
|
|
|
315
|
-
private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta): Promise<Turn> {
|
|
316
|
+
private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta, policy?: ProviderSendOptions): Promise<Turn> {
|
|
316
317
|
signal?.throwIfAborted();
|
|
317
318
|
const started = Date.now();
|
|
318
319
|
const request = providerRequest(this.config.spec, this.apiKey, this.options.effects, this.options.fetch, this.options.oauth);
|
|
@@ -342,7 +343,7 @@ export class PiProvider implements Provider {
|
|
|
342
343
|
const next = mergePayload(payload, request.body);
|
|
343
344
|
// Stateless Responses is required by D8: never replace the recomputable projection with server state.
|
|
344
345
|
if (model.api.includes("responses")) { next["store"] = false; delete next["previous_response_id"]; }
|
|
345
|
-
return next;
|
|
346
|
+
return policy?.toolUse === "disabled" ? disablePayloadTools(model.api, next) : next;
|
|
346
347
|
} };
|
|
347
348
|
let completed: AssistantMessage | undefined;
|
|
348
349
|
// Pi emits fragments; xeno's progress contract replaces the displayed argument buffer.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** D6: native request controls may change; frozen system and tool declarations may not. */
|
|
2
|
+
import { assertNever } from "../core/exhaustive.ts";
|
|
3
|
+
import type { Api } from "./model.ts";
|
|
4
|
+
|
|
5
|
+
export interface ProviderSendOptions { toolUse: "disabled" }
|
|
6
|
+
|
|
7
|
+
export function supportsToolDisabling(api: Api): boolean {
|
|
8
|
+
// Bedrock's SDK implements none by deleting toolConfig. It cannot preserve D6.
|
|
9
|
+
switch (api) {
|
|
10
|
+
case "anthropic-messages": case "openai-completions": case "openai-responses": case "azure-openai-responses": case "openai-codex-responses":
|
|
11
|
+
case "google-generative-ai": case "google-vertex": case "mistral-conversations": case "pi-messages": return true;
|
|
12
|
+
case "bedrock-converse-stream": return false;
|
|
13
|
+
default: return assertNever(api, "tool disabling capability");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const object = (value: unknown): Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
17
|
+
|
|
18
|
+
/** Called after provider body overrides, at the last native payload boundary. */
|
|
19
|
+
export function disablePayloadTools(api: Api, payload: Record<string, unknown>): Record<string, unknown> {
|
|
20
|
+
switch (api) {
|
|
21
|
+
case "anthropic-messages": return { ...payload, tool_choice: { type: "none" } };
|
|
22
|
+
case "openai-completions": case "openai-responses": case "azure-openai-responses": case "openai-codex-responses":
|
|
23
|
+
return { ...payload, tool_choice: "none" };
|
|
24
|
+
case "google-generative-ai": case "google-vertex": {
|
|
25
|
+
const config = object(payload["config"]), toolConfig = object(config["toolConfig"]);
|
|
26
|
+
return { ...payload, config: { ...config, toolConfig: { ...toolConfig, functionCallingConfig: { mode: "NONE" } } } };
|
|
27
|
+
}
|
|
28
|
+
case "mistral-conversations": return { ...payload, toolChoice: "none" };
|
|
29
|
+
case "pi-messages": return { ...payload, options: { ...object(payload["options"]), toolChoice: "none" } };
|
|
30
|
+
case "bedrock-converse-stream": throw new Error("Bedrock cannot disable tools while preserving frozen definitions");
|
|
31
|
+
default: return assertNever(api, "tool disabling protocol");
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/runtime/provider.ts
CHANGED
|
@@ -39,11 +39,14 @@ import type { UsageSample } from "./usage.ts";
|
|
|
39
39
|
import { type Effort, type ModelSpec, CLAUDE_OPUS_5 } from "./model.ts";
|
|
40
40
|
import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
|
|
41
41
|
import { anthropicOAuthHeaders } from "./oauth.ts";
|
|
42
|
+
import type { ProviderSendOptions } from "./provider-tool-policy.ts";
|
|
42
43
|
|
|
43
44
|
export interface ToolSchema {
|
|
44
45
|
name: string;
|
|
45
46
|
description: string;
|
|
46
47
|
input_schema: {
|
|
48
|
+
/** Standard JSON Schema keywords from MCP must survive the frozen declaration. */
|
|
49
|
+
[keyword: string]: unknown;
|
|
47
50
|
type: "object";
|
|
48
51
|
properties: Record<string, unknown>;
|
|
49
52
|
required: string[];
|
|
@@ -104,6 +107,7 @@ export interface Provider {
|
|
|
104
107
|
node: number,
|
|
105
108
|
signal?: AbortSignal,
|
|
106
109
|
onDelta?: OnDelta,
|
|
110
|
+
options?: ProviderSendOptions,
|
|
107
111
|
): Promise<Turn>;
|
|
108
112
|
}
|
|
109
113
|
|
|
@@ -233,9 +237,10 @@ export class AnthropicProvider implements Provider {
|
|
|
233
237
|
node: number,
|
|
234
238
|
signal?: AbortSignal,
|
|
235
239
|
onDelta?: OnDelta,
|
|
240
|
+
options?: ProviderSendOptions,
|
|
236
241
|
): Promise<Turn> {
|
|
237
242
|
return withRetry(
|
|
238
|
-
() => this.attempt(frozen, messages, node, signal, onDelta),
|
|
243
|
+
() => this.attempt(frozen, messages, node, signal, onDelta, options),
|
|
239
244
|
this.retry,
|
|
240
245
|
undefined,
|
|
241
246
|
(attempt, delayMs, error) => {
|
|
@@ -255,6 +260,7 @@ export class AnthropicProvider implements Provider {
|
|
|
255
260
|
node: number,
|
|
256
261
|
signal?: AbortSignal,
|
|
257
262
|
onDelta?: OnDelta,
|
|
263
|
+
options?: ProviderSendOptions,
|
|
258
264
|
): Promise<Turn> {
|
|
259
265
|
const started = Date.now();
|
|
260
266
|
|
|
@@ -275,6 +281,7 @@ export class AnthropicProvider implements Provider {
|
|
|
275
281
|
// Auto-caching walks the breakpoint forward over the growing tail.
|
|
276
282
|
cache_control: { type: "ephemeral" },
|
|
277
283
|
tools: frozen.tools as Anthropic.Tool[],
|
|
284
|
+
...(options?.toolUse === "disabled" ? { tool_choice: { type: "none" as const } } : {}),
|
|
278
285
|
// Only a reasoning model understands these. An older Claude, or an
|
|
279
286
|
// Anthropic-compatible endpoint serving some other model, answers
|
|
280
287
|
// them with a 400 or silently ignores them.
|
|
@@ -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
|
-
|
|
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 {
|
|
@@ -142,11 +164,13 @@ export class SessionControl {
|
|
|
142
164
|
// Assumed ingress bound, not an execution policy. Keep a client from queuing unbounded work.
|
|
143
165
|
if (turn.queue.length >= 32) throw Error("steering queue is full");
|
|
144
166
|
turn.queue.push(structuredClone(input));
|
|
167
|
+
this.session.wakeProcessWaits();
|
|
145
168
|
this.emit("turn/steeringQueued", { count: turn.queue.length });
|
|
146
169
|
}
|
|
147
170
|
interrupt(turnId: string): void {
|
|
148
171
|
this.current(turnId).abort.abort();
|
|
149
172
|
for (const approval of [...this.approvals.values()]) approval.resolve("deny");
|
|
173
|
+
for (const question of [...this.questions.values()]) question.resolve(undefined);
|
|
150
174
|
this.session.cancel();
|
|
151
175
|
}
|
|
152
176
|
setWaiting(kind: ControlSnapshot["waiting"]): void {
|
|
@@ -178,6 +202,41 @@ export class SessionControl {
|
|
|
178
202
|
approval.resolve(choice);
|
|
179
203
|
}
|
|
180
204
|
denyApprovals(): void { for (const approval of [...this.approvals.values()]) approval.resolve("deny"); }
|
|
205
|
+
attachQuestionClient(): () => void {
|
|
206
|
+
this.questionClients++;
|
|
207
|
+
if (!this.questionSlot.current) this.questionSlot.current = this.questionAdapter;
|
|
208
|
+
let closed = false;
|
|
209
|
+
return () => {
|
|
210
|
+
if (closed) return; closed = true;
|
|
211
|
+
if (--this.questionClients === 0) {
|
|
212
|
+
if (this.questionSlot.current === this.questionAdapter) delete this.questionSlot.current;
|
|
213
|
+
for (const question of [...this.questions.values()]) question.resolve(undefined);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
askQuestion(request: QuestionRequest, signal: AbortSignal, present?: (request: QuestionRequest) => Promise<unknown>): Promise<QuestionReply | undefined> {
|
|
218
|
+
if (!this.active || request.turn_id !== this.active.id || signal.aborted || !present && this.questionClients === 0) return Promise.resolve(undefined);
|
|
219
|
+
if (this.questions.has(request.request_id)) return Promise.reject(Error("duplicate question invocation"));
|
|
220
|
+
return new Promise(resolve => {
|
|
221
|
+
const finish = (reply: QuestionReply | undefined) => {
|
|
222
|
+
if (!this.questions.delete(request.request_id)) return;
|
|
223
|
+
signal.removeEventListener("abort", aborted); this.setWaiting(this.questions.size ? "question" : null);
|
|
224
|
+
this.emit("question/resolved", { requestId: request.request_id }); resolve(reply);
|
|
225
|
+
};
|
|
226
|
+
const aborted = () => finish(undefined);
|
|
227
|
+
this.questions.set(request.request_id, { request: structuredClone(request), resolve: finish });
|
|
228
|
+
signal.addEventListener("abort", aborted, { once: true }); this.setWaiting("question");
|
|
229
|
+
this.emit("question/request", { question: structuredClone(request) });
|
|
230
|
+
if (present) void present(structuredClone(request)).then(value => {
|
|
231
|
+
if (value === undefined) finish(undefined); else try { finish(validateQuestionReply(request, value)); } catch { finish(undefined); }
|
|
232
|
+
}).catch(() => finish(undefined));
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
respondQuestion(turnId: string, requestId: string, reply: unknown): void {
|
|
236
|
+
this.current(turnId); const question = this.questions.get(requestId);
|
|
237
|
+
if (!question || question.request.turn_id !== turnId) throw Error("stale or unknown question requestId");
|
|
238
|
+
question.resolve(validateQuestionReply(question.request, reply));
|
|
239
|
+
}
|
|
181
240
|
async accept(requestId: string, criterion: string): Promise<void> {
|
|
182
241
|
if (this.busy) throw Error("acceptance requires an idle session");
|
|
183
242
|
this.changing = true;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** A hub owns live sessions, not transcript mirrors. Durable listing stays a store projection. */
|
|
2
|
+
import type { SessionControl } from "./session-control.ts";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
|
|
5
|
+
// Assumed live-owner bound includes admitted opens, so concurrent requests cannot exceed it.
|
|
6
|
+
export const MAX_LIVE_SESSIONS = 32;
|
|
7
|
+
|
|
8
|
+
export interface SessionHubHost {
|
|
9
|
+
list(): Promise<unknown>;
|
|
10
|
+
open(kind: "new" | "resume" | "fork", params: Record<string, unknown>): Promise<SessionControl>;
|
|
11
|
+
dispose(control: SessionControl): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export class SessionHub {
|
|
14
|
+
private readonly controls = new Map<string, SessionControl>();
|
|
15
|
+
private readonly opening = new Set<Promise<SessionControl>>();
|
|
16
|
+
private readonly resuming = new Map<string, Promise<SessionControl>>();
|
|
17
|
+
private readonly closing = new Map<string, Promise<void>>();
|
|
18
|
+
private reserved = 0;
|
|
19
|
+
private closed = false;
|
|
20
|
+
private disposal: Promise<void> | undefined;
|
|
21
|
+
private readonly host: SessionHubHost;
|
|
22
|
+
readonly initial: SessionControl;
|
|
23
|
+
constructor(initial: SessionControl, host: SessionHubHost) {
|
|
24
|
+
this.initial = initial; this.host = host; this.controls.set(initial.threadId, initial);
|
|
25
|
+
}
|
|
26
|
+
get(threadId: string): SessionControl {
|
|
27
|
+
if (this.closed || this.closing.has(threadId)) throw Error("session hub or thread is closing");
|
|
28
|
+
const control = this.controls.get(threadId);
|
|
29
|
+
if (!control) throw Error("unknown or closed threadId; resume the session first");
|
|
30
|
+
return control;
|
|
31
|
+
}
|
|
32
|
+
async list(): Promise<unknown> { if (this.closed) throw Error("session hub is closing"); return this.host.list(); }
|
|
33
|
+
open(kind: "new" | "resume" | "fork", params: Record<string, unknown>): Promise<SessionControl> {
|
|
34
|
+
if (this.closed) return Promise.reject(Error("session hub is closing; no new session was admitted"));
|
|
35
|
+
const captured = structuredClone(params);
|
|
36
|
+
// Coalesce resume only. Two explicit new/fork requests create two distinct sessions.
|
|
37
|
+
const key = kind === "resume" ? JSON.stringify(captured) : "";
|
|
38
|
+
const pending = key ? this.resuming.get(key) : undefined;
|
|
39
|
+
if (pending) return pending;
|
|
40
|
+
const requested = captured["session"];
|
|
41
|
+
const known = kind === "resume" && [...this.controls.values()].some(control => !this.closing.has(control.threadId)
|
|
42
|
+
&& [control.threadId, control.session.config.logDir, basename(control.session.config.logDir)].includes(String(requested)));
|
|
43
|
+
if (!known && this.controls.size + this.reserved >= MAX_LIVE_SESSIONS) return Promise.reject(Error("live session limit reached; close a session first"));
|
|
44
|
+
if (!known) this.reserved++;
|
|
45
|
+
const operation = (async () => {
|
|
46
|
+
try {
|
|
47
|
+
const control = await this.host.open(kind, captured), existing = this.controls.get(control.threadId);
|
|
48
|
+
if (this.closing.has(control.threadId) || !existing && this.controls.size >= MAX_LIVE_SESSIONS) {
|
|
49
|
+
if (existing !== control) await this.host.dispose(control);
|
|
50
|
+
throw Error("thread is closing or the live session limit was reached");
|
|
51
|
+
}
|
|
52
|
+
if (existing && existing !== control) { await this.host.dispose(control); if (this.closed) throw Error("session hub closed during opening"); return existing; }
|
|
53
|
+
this.controls.set(control.threadId, control);
|
|
54
|
+
if (this.closed) throw Error("session hub closed during opening; the admitted owner will be drained");
|
|
55
|
+
return control;
|
|
56
|
+
} finally { if (!known) this.reserved--; }
|
|
57
|
+
})();
|
|
58
|
+
this.opening.add(operation); if (key) this.resuming.set(key, operation);
|
|
59
|
+
// Observe rejected opens without changing the caller's promise or leaking unhandled cleanup rejections.
|
|
60
|
+
void operation.finally(() => { this.opening.delete(operation); if (key) this.resuming.delete(key); }).catch(() => {});
|
|
61
|
+
return operation;
|
|
62
|
+
}
|
|
63
|
+
close(threadId: string): Promise<void> {
|
|
64
|
+
const pending = this.closing.get(threadId); if (pending) return pending;
|
|
65
|
+
const control = this.controls.get(threadId); if (!control) return Promise.reject(Error("unknown or closed threadId"));
|
|
66
|
+
const operation = (async () => {
|
|
67
|
+
if (control.activeTurnId) control.interrupt(control.activeTurnId);
|
|
68
|
+
await control.whenIdle().catch(() => {});
|
|
69
|
+
await this.host.dispose(control); this.controls.delete(threadId);
|
|
70
|
+
})();
|
|
71
|
+
this.closing.set(threadId, operation);
|
|
72
|
+
void operation.then(() => { if (this.closing.get(threadId) === operation) this.closing.delete(threadId); }, () => {});
|
|
73
|
+
return operation;
|
|
74
|
+
}
|
|
75
|
+
dispose(): Promise<void> {
|
|
76
|
+
if (this.disposal) return this.disposal;
|
|
77
|
+
this.closed = true;
|
|
78
|
+
return this.disposal = (async () => {
|
|
79
|
+
// Every kind is tracked; late publication belongs to this same draining generation.
|
|
80
|
+
await Promise.allSettled([...this.opening]);
|
|
81
|
+
const results = await Promise.allSettled([...this.controls.keys()].map(id => this.close(id)));
|
|
82
|
+
if (results.some(result => result.status === "rejected")) throw Error("session cleanup could not be confirmed");
|
|
83
|
+
})();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** R11: a harness-owned discovery entry shares normal Session admission and result persistence. */
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { discoverTools, MAX_DISCOVERY_QUERY, MAX_DISCOVERY_RESULTS, type DeclarationRequest } from "../core/tool-discovery.ts";
|
|
4
|
-
import {
|
|
4
|
+
import { decideWithSessionRules } from "../core/permissions.ts";
|
|
5
5
|
import { failureDiagnostic, NOT_STARTED } from "../core/tool-failure.ts";
|
|
6
6
|
import type { ToolSchema } from "./provider.ts";
|
|
7
7
|
import type { ToolContext, ToolDefinition, ToolResult } from "./tools.ts";
|
|
@@ -14,7 +14,7 @@ export const DISCOVERY_TOOL: ToolDefinition = {
|
|
|
14
14
|
query: { type: "string", minLength: 1, maxLength: MAX_DISCOVERY_QUERY },
|
|
15
15
|
limit: { type: "integer", minimum: 1, maximum: MAX_DISCOVERY_RESULTS, description: "Maximum matches to request; defaults to 5." },
|
|
16
16
|
}, required: ["query"] },
|
|
17
|
-
access: "
|
|
17
|
+
access: "read", execution: "exclusive", branch: "none",
|
|
18
18
|
run: async () => ({ ok: false, content: "Tool discovery requires a live Session checkpoint." }),
|
|
19
19
|
};
|
|
20
20
|
|
|
@@ -23,7 +23,8 @@ export function discoveryFingerprint(tool: ToolSchema): string {
|
|
|
23
23
|
return createHash("sha256").update(JSON.stringify({ name: tool.name, description: tool.description, input_schema: tool.input_schema })).digest("hex");
|
|
24
24
|
}
|
|
25
25
|
export function discoveryPermitted(ctx: ToolContext, name: string): boolean {
|
|
26
|
-
|
|
26
|
+
if (ctx.workflowMode === "plan" && ctx.extensions?.get(name)?.access !== "read") return false;
|
|
27
|
+
return decideWithSessionRules(ctx.rules ?? [], ctx.sessionRules ?? [], { tool: name }) !== "deny";
|
|
27
28
|
}
|
|
28
29
|
/** A directory entry without its matching live implementation is unavailable, regardless of history. */
|
|
29
30
|
export function currentDeferredTools(ctx: ToolContext): readonly ToolSchema[] {
|