@visual-z/xeno 0.3.0 → 0.5.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 (60) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +55 -16
  3. package/docs/app-server.md +86 -0
  4. package/docs/install.md +1 -1
  5. package/docs/providers.md +20 -6
  6. package/package.json +3 -2
  7. package/src/apps/app-server.ts +28 -0
  8. package/src/apps/cli.ts +25 -10
  9. package/src/apps/commands.ts +10 -6
  10. package/src/apps/completions.ts +15 -13
  11. package/src/apps/control-transport.ts +89 -0
  12. package/src/apps/model-catalog.ts +2 -1
  13. package/src/apps/preferences.ts +20 -0
  14. package/src/apps/provider-connect.ts +4 -4
  15. package/src/apps/shell.ts +73 -50
  16. package/src/apps/startup-input.ts +7 -2
  17. package/src/apps/startup.ts +52 -19
  18. package/src/apps/ui/app.ts +210 -56
  19. package/src/apps/ui/control-model.ts +26 -0
  20. package/src/apps/ui/surface.ts +2 -0
  21. package/src/core/completion-action.ts +20 -0
  22. package/src/core/completion-review.ts +38 -29
  23. package/src/core/control-protocol.ts +62 -0
  24. package/src/core/control-view.ts +42 -0
  25. package/src/core/event-validation.ts +1 -1
  26. package/src/core/events.ts +2 -0
  27. package/src/core/harness-diagnostics.ts +206 -0
  28. package/src/core/model-preferences.ts +28 -0
  29. package/src/core/oauth-fields.ts +44 -0
  30. package/src/core/permissions.ts +13 -7
  31. package/src/core/program-status.ts +31 -0
  32. package/src/core/project.ts +25 -1
  33. package/src/core/request-progress.ts +87 -0
  34. package/src/core/request-state.ts +114 -0
  35. package/src/core/settings.ts +8 -1
  36. package/src/core/tool-input.ts +6 -0
  37. package/src/runtime/auth.ts +10 -3
  38. package/src/runtime/catalog.ts +21 -4
  39. package/src/runtime/control-connection.ts +104 -0
  40. package/src/runtime/harness-eval.ts +3 -1
  41. package/src/runtime/loop.ts +72 -19
  42. package/src/runtime/model.ts +9 -0
  43. package/src/runtime/native-eval.ts +6 -3
  44. package/src/runtime/oauth.ts +80 -3
  45. package/src/runtime/pi-provider.ts +169 -12
  46. package/src/runtime/program-status.ts +38 -0
  47. package/src/runtime/provider-connect.ts +2 -2
  48. package/src/runtime/provider-presets.ts +1 -1
  49. package/src/runtime/provider-request.ts +13 -2
  50. package/src/runtime/provider.ts +23 -9
  51. package/src/runtime/session-control.ts +198 -0
  52. package/src/runtime/tools.ts +11 -9
  53. package/src/store/resources.ts +13 -22
  54. package/src/store/user-settings.ts +30 -0
  55. package/src/tui/ui/composer.ts +3 -2
  56. package/src/tui/ui/control-panel.ts +25 -0
  57. package/src/tui/ui/control-sidebar.ts +49 -0
  58. package/src/tui/ui/mascot.ts +1 -1
  59. package/src/tui/ui/screen.ts +107 -11
  60. package/src/tui/ui/status.ts +8 -0
@@ -12,8 +12,7 @@
12
12
  */
13
13
  import type { GuidanceEvent, LogEvent } from "./events.ts";
14
14
  import type { AcceptanceState } from "./acceptance.ts";
15
- import { changedPaths } from "./checkpoints.ts";
16
- import { resolveEvidence } from "./evidence.ts";
15
+ import { deriveRequestState, type RequestState } from "./request-state.ts";
17
16
 
18
17
  /**
19
18
  * Wording that hands the task back instead of finishing it. Ported from
@@ -40,43 +39,53 @@ export function completionReview(
40
39
  remainingRequests: number,
41
40
  options?: { strictVerification?: boolean; acceptance?: AcceptanceState; taskAware?: boolean },
42
41
  ): Pick<GuidanceEvent, "reason" | "text" | "sources"> | null {
43
- if (remainingRequests < 1) return null;
44
- const start = events.findLastIndex((event) => event.t === "user");
45
- if (start < 0) return null;
46
- const current = events.slice(start);
47
- if (current.some((event) => event.t === "guidance" && event.reason === "completion_review")) return null;
48
- const final = current.findLast((event) => event.t === "assistant");
49
- if (final?.t !== "assistant" || final.blocks.some((block) => block.type === "tool_use")) return null;
42
+ const state = deriveRequestState([{ node: 0, events }]);
43
+ const decision = state ? requestCompletion(state, remainingRequests, options) : { action: "finish" as const };
44
+ return decision.action === "continue" ? decision.guidance : null;
45
+ }
46
+
47
+ export type RequestCompletion = { action: "finish" } |
48
+ { action: "continue"; guidance: Pick<GuidanceEvent, "reason" | "text" | "sources"> };
50
49
 
51
- const text = final.blocks.flatMap((block) => block.type === "text" ? [block.text] : []).join("\n").trim();
52
- const usedTools = current.some((event) => event.t === "tool_result");
53
- const lastEdit = current.findLastIndex((event) => event.t === "tool_result" && event.effect?.kind === "edit" ||
54
- event.t === "checkpoint" && event.phase === "applied" && changedPaths(event.changes).length > 0);
55
- const verifiedAfter = lastEdit >= 0 && current.slice(lastEdit + 1)
56
- .some((event) => event.t === "tool_result" && event.effect?.kind === "command" && event.effect.exit === 0 &&
57
- resolveEvidence(current, { command: event.effect.cmd }).ok &&
58
- (!options?.strictVerification || event.effect.gate !== undefined));
50
+ /** Q23: loop termination and the public request view consume the same evidence. */
51
+ export function requestCompletion(state: RequestState, remainingRequests: number,
52
+ options?: { strictVerification?: boolean; acceptance?: AcceptanceState; taskAware?: boolean }): RequestCompletion {
53
+ const finish = { action: "finish" as const };
54
+ if (remainingRequests < 1 || state.reviewed || state.status === "blocked") return finish;
55
+ const final = state.final;
56
+ if (!final || final.has_tools || state.execution?.reason !== "model_end" && state.execution?.reason !== "unknown") return finish;
57
+ const text = final.text;
58
+ const verifiedAfter = state.commands.some(command => command.status === "pass" && (!options?.strictVerification || command.gate !== undefined));
59
59
 
60
60
  const gaps: string[] = [];
61
- if (usedTools && text.length === 0) gaps.push("the final reply is empty");
62
- if (lastEdit >= 0 && !verifiedAfter && !options?.acceptance) gaps.push("files were changed but no command has succeeded since the last edit");
63
- const acceptance = options?.acceptance;
61
+ const acceptance = options?.acceptance ?? state.acceptance;
62
+ const missingCommands = acceptance?.status === "needs_work" ? acceptance.criteria.flatMap(item =>
63
+ item.kind === "command" && item.status !== "pass" ? [{ id: item.id, command: item.command, status: item.status }] : []) : [];
64
+ if (state.used_tools && text.length === 0) gaps.push("the final reply is empty");
65
+ if (state.changed && !verifiedAfter && !acceptance) gaps.push("files were changed but no command has succeeded since the last edit");
64
66
  if (acceptance?.status === "needs_work") gaps.push(`declared acceptance criteria need evidence: ${[
65
- ...acceptance.criteria.filter(item => item.kind === "command" && item.status !== "pass").map(item => `${item.id}=${item.status}`),
67
+ ...missingCommands.map(item => `${item.id}=${item.status}`),
66
68
  ...(acceptance.targets ?? []).filter(target => target.status !== "ready").map(target => `target ${target.id}=${target.status}`),
67
69
  ].join(", ")}`);
68
70
  if (text.length > 0 && looksLikeHandBack(text) && acceptance?.status !== "awaiting_human") gaps.push(options?.taskAware
69
71
  ? "the reply deferred the request; check whether authorized work remains or a specific blocker requires the user"
70
72
  : "the reply handed the task back instead of finishing it; no one will answer");
71
- if (gaps.length === 0) return null;
72
- return {
73
+ if (gaps.length === 0) return finish;
74
+ // D11 / O.11: a declared command needs its exact receipt even when the
75
+ // request has no recorded edit. Compound shell success is not that receipt.
76
+ const next = missingCommands.length > 0
77
+ ? "Obtain a fresh successful receipt for each missing command. Run each command exactly as a separate invocation without adding other commands:\n" +
78
+ missingCommands.map(item => `- ${item.id}: ${JSON.stringify(item.command)}`).join("\n") +
79
+ "\nIf verification cannot run, report the specific blocker. "
80
+ : options?.taskAware && !state.changed
81
+ ? "Use original sources for research and full inputs for calculations; do not invent command evidence. Finish with the result or the specific blocker. "
82
+ : "Run the command that verifies the result, or finish with the specific blocker that prevents verification. ";
83
+ return { action: "continue", guidance: {
73
84
  reason: "completion_review",
74
- sources: [current[0]!.id, final.id],
85
+ sources: [state.request.event, final.source.event],
75
86
  text: `Harness completion check: ${gaps.join("; ")}. ` +
76
- `Re-read the original request (event ${current[0]!.id}) and check each requirement against evidence in this session. ` +
77
- (options?.taskAware && lastEdit < 0
78
- ? "Use original sources for research and full inputs for calculations; do not invent command evidence. Required human acceptance remains pending. Finish with the result or the specific blocker. "
79
- : "Run the command that verifies the result, or finish with the specific blocker that prevents verification. ") +
87
+ `Re-read the original request (event ${state.request.event}) and check each requirement against evidence in this session. ` +
88
+ next + (acceptance?.criteria.some(item => item.kind === "human" && item.status !== "accepted") ? "Required human acceptance remains pending. " : "") +
80
89
  "This check runs once per request.",
81
- };
90
+ } };
82
91
  }
@@ -0,0 +1,62 @@
1
+ /** Q24: the experimental wire contract and its validator share one schema. No SDK dependency (D13). */
2
+ const text = { type: "string", minLength: 1, maxLength: 65536 } as const;
3
+ const identity = { type: "string", minLength: 1, maxLength: 128 } as const;
4
+ const object = (properties: Record<string, unknown>, required: readonly string[] = Object.keys(properties)) =>
5
+ ({ type: "object", properties, required, additionalProperties: false });
6
+ const thread = { threadId: identity };
7
+ const turn = { ...thread, turnId: identity };
8
+
9
+ export const CONTROL_METHODS = {
10
+ initialize: object({ clientInfo: object({ name: identity, version: identity }) }),
11
+ initialized: object({}),
12
+ "thread/read": object(thread),
13
+ "thread/subscribe": object({ ...thread, after: object({ streamId: identity, sequence: { type: "integer", minimum: 0 } }) }, ["threadId"]),
14
+ "turn/start": object({ ...thread, input: text }),
15
+ "turn/steer": object({ ...turn, input: text }),
16
+ "turn/interrupt": object(turn),
17
+ "approval/respond": object({ ...turn, approvalId: identity, choice: { enum: ["once", "command", "tool", "deny"] } }),
18
+ "acceptance/accept": object({ ...thread, requestId: identity, criterionId: identity }),
19
+ "context/read": object({ ...thread, focus: { enum: ["messages", "omitted", "history"] }, offset: { type: "integer", minimum: 0 },
20
+ limit: { type: "integer", minimum: 1, maximum: 100 } }, ["threadId", "focus"]),
21
+ "item/list": object({ ...thread, after: object({ node: { type: "integer", minimum: 0 }, event: identity }),
22
+ limit: { type: "integer", minimum: 1, maximum: 100 } }, ["threadId"]),
23
+ "item/read": object({ ...thread, node: { type: "integer", minimum: 0 }, event: identity }),
24
+ } as const;
25
+ export const CONTROL_SCHEMA = { protocol: "xeno-control/0.1", experimental: true,
26
+ transport: "JSONL; JSON-RPC envelopes with jsonrpc omitted", methods: CONTROL_METHODS,
27
+ notifications: ["turn/started", "turn/event", "item/completed", "turn/completed", "turn/error", "turn/steeringQueued", "approval/request", "approval/resolved", "thread/statusChanged"],
28
+ recovery: "subscribe first, then use the returned snapshot cursor; gaps or a new streamId require a snapshot and paginated item/list, never replaying commands",
29
+ identity: "threadId hashes the resolved session directory path; turnId is the original user event ID; item identity is node plus event ID",
30
+ limits: { frameBytes: 1048576, queuedRequests: 32, outputBytes: 2097152 } } as const;
31
+
32
+ export type RpcId = string | number;
33
+ export type ControlRequest = { id?: RpcId; method: keyof typeof CONTROL_METHODS; params: Record<string, unknown> };
34
+ export class ControlProtocolError extends Error {
35
+ readonly code: number;
36
+ constructor(code: number, message: string) { super(message); this.code = code; }
37
+ }
38
+ function record(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
39
+ function matches(value: unknown, schemaValue: unknown): boolean {
40
+ const schema = schemaValue as Record<string, unknown>;
41
+ if (Array.isArray(schema["enum"])) return schema["enum"].includes(value);
42
+ if (schema["type"] === "string") return typeof value === "string" && value.length >= Number(schema["minLength"]) && value.length <= Number(schema["maxLength"]);
43
+ if (schema["type"] === "integer") return typeof value === "number" && Number.isSafeInteger(value) && value >= Number(schema["minimum"]) && (schema["maximum"] === undefined || value <= Number(schema["maximum"]));
44
+ if (schema["type"] === "object") {
45
+ if (!record(value)) return false;
46
+ const properties = schema["properties"] as Record<string, unknown>, required = schema["required"] as string[];
47
+ return required.every(key => Object.hasOwn(value, key)) && Object.keys(value).every(key => Object.hasOwn(properties, key) && matches(value[key], properties[key]));
48
+ }
49
+ return false;
50
+ }
51
+ export function parseControlRequest(value: unknown): ControlRequest {
52
+ if (!record(value) || typeof value["method"] !== "string" || Object.keys(value).some(key => !["id", "method", "params"].includes(key)))
53
+ throw new ControlProtocolError(-32600, "invalid request envelope");
54
+ const id = value["id"], method = value["method"];
55
+ if (id !== undefined && !(typeof id === "string" && id.length <= 128 || typeof id === "number" && Number.isSafeInteger(id)))
56
+ throw new ControlProtocolError(-32600, "invalid request id");
57
+ if (!Object.hasOwn(CONTROL_METHODS, method)) throw new ControlProtocolError(-32601, "unknown method");
58
+ const name = method as ControlRequest["method"], params = value["params"] ?? {};
59
+ if ((name === "initialized") !== (id === undefined)) throw new ControlProtocolError(-32600, "only initialized is a client notification");
60
+ if (!matches(params, CONTROL_METHODS[name])) throw new ControlProtocolError(-32602, `invalid parameters for ${name}`);
61
+ return { ...(id === undefined ? {} : { id }), method: name, params: params as Record<string, unknown> };
62
+ }
@@ -0,0 +1,42 @@
1
+ /** Q24: control surfaces inspect original facts; they never own a second goal or context. */
2
+ import { currentRequestRecords, deriveRequestState, type RequestPart, type RequestState } from "./request-state.ts";
3
+ import { contextDetail } from "./context-detail.ts";
4
+ import { projectContext } from "./project.ts";
5
+ import { assertNever } from "./exhaustive.ts";
6
+
7
+ export type GoalView = { text: string; state: RequestState; gaps: string[] };
8
+
9
+ export function goalView(parts: readonly RequestPart[]): GoalView | null {
10
+ const state = deriveRequestState(parts);
11
+ const first = currentRequestRecords(parts)[0]?.event;
12
+ if (!state || first?.t !== "user") return null;
13
+ const gaps = state.acceptance
14
+ ? state.acceptance.criteria.filter(item => item.status !== "pass" && item.status !== "accepted").map(item => `${item.description}: ${item.status}`)
15
+ : ["No acceptance contract; command success does not verify the goal."];
16
+ gaps.push(...state.reported_blockers.map(item => `Reported blocker: ${item.reason}`));
17
+ return { text: first.text, state, gaps };
18
+ }
19
+
20
+ export type ContextLens = ReturnType<typeof contextLens>;
21
+ export type LensFocus = "messages" | "omitted" | "history";
22
+
23
+ export function contextLens(input: Parameters<typeof contextDetail>[0]) {
24
+ const detail = contextDetail(input);
25
+ const projected = projectContext(input);
26
+ return { ...detail,
27
+ messages: projected.messages.map((message, index) => ({ index, role: message.role, sources: detail.sources[index] ?? [],
28
+ reason: detail.sources[index]?.some(id => id.startsWith("handoff:")) ? "boundary handoff with recovery references" : "original events in the current segment" })),
29
+ omitted: detail.omittedEvents.map(id => ({ id, type: input.events.find(event => event.id === id)?.t ?? "unknown",
30
+ reason: "not replayed as a model message; original event remains in the log" })),
31
+ };
32
+ }
33
+
34
+ /** A lens filters the inspector only; it cannot change the provider projection (D1/D8). */
35
+ export function selectLens(lens: ContextLens, focus: LensFocus): readonly unknown[] {
36
+ switch (focus) {
37
+ case "messages": return lens.messages;
38
+ case "omitted": return lens.omitted;
39
+ case "history": return lens.sections;
40
+ default: return assertNever(focus, "context lens");
41
+ }
42
+ }
@@ -68,7 +68,7 @@ export function isLogEvent(value: unknown): value is LogEvent {
68
68
  (value["target_binding"] === undefined ? value["target_observation"] === undefined : has("target_binding", "target_observation"));
69
69
  case "target_observation": return isRuntimeTargetReceipt(value["observation"]);
70
70
  case "extension": return has("customType", "content") && typeof value["display"] === "boolean" && typeof value["inContext"] === "boolean" && (value["data"] === undefined || isJsonValue(value["data"]));
71
- case "user": return has("text");
71
+ case "user": return has("text") && (value["request"] === undefined || typeof value["request"] === "string" && value["request"] !== "");
72
72
  case "outcome": return has("reason") && isExecutionOutcome(value["outcome"]);
73
73
  case "user_bash": {
74
74
  const ref = value["content_ref"];
@@ -84,6 +84,8 @@ export interface UserEvent {
84
84
  id: EventId;
85
85
  at: IsoTime;
86
86
  text: string;
87
+ /** Q23: in-flight steering belongs to this original user event; legacy events open a request. */
88
+ request?: EventId;
87
89
  images?: readonly ImageRef[];
88
90
  }
89
91
 
@@ -0,0 +1,206 @@
1
+ /** D11 / O.3: classify durable execution facts, never error words in tool output. */
2
+ import type { LogEvent, ToolResultEvent } from "./events.ts";
3
+ import type { BenchmarkUsage } from "./benchmark.ts";
4
+ import { canonicalValue } from "./continuation.ts";
5
+
6
+ export type ResultClass = "success" | "invalid_input" | "cancelled" | "timeout" | "command_nonzero" |
7
+ "execution_failed" | "rejected" | "unclassified_failure";
8
+
9
+ export function classifyToolResult(event: ToolResultEvent): ResultClass {
10
+ const code = event.diagnostic?.code;
11
+ if (code === "invalid_input") return "invalid_input";
12
+ if (code === "cancelled" || event.outcome?.reason === "cancelled") return "cancelled";
13
+ if (code === "timeout" || event.outcome?.reason === "timeout") return "timeout";
14
+ if (event.effect?.kind === "command" && event.effect.exit !== 0) return "command_nonzero";
15
+ if (event.ok) return "success";
16
+ if (event.diagnostic?.execution === "not_started" ||
17
+ ["version_mismatch", "not_found", "ambiguous_edit", "permission_denied", "environment_missing"].includes(code ?? "")) return "rejected";
18
+ if (code === "execution_failed" || code === "unknown_effect" || event.outcome?.reason === "execution_error") return "execution_failed";
19
+ return "unclassified_failure";
20
+ }
21
+
22
+ export interface DiagnosticRequest {
23
+ request: number;
24
+ node: number | null;
25
+ usage: BenchmarkUsage | null;
26
+ responseModel: string | null;
27
+ failure: string | null;
28
+ firstDeltaMs: number | null;
29
+ wallMs: number | null;
30
+ prefixUnchanged: boolean | null;
31
+ sharedInputBytes: number | null;
32
+ frozenSha256: string | null;
33
+ /** Raw terminal counts can remain known when a cache breakdown is absent. */
34
+ reportedPromptTokens?: number | null;
35
+ reportedOutputTokens?: number | null;
36
+ }
37
+ export interface ToolCounters {
38
+ calls: number; providerCalls: number; invocations: number; nestedInvocations: number;
39
+ results: number; pairedCalls: number; orphanCalls: number; orphanResults: number;
40
+ duplicateProviderCalls: number; duplicateInvocations: number; duplicateResults: number;
41
+ repeatedProviderInputs: number; repeatedInvocationInputs: number;
42
+ classes: Record<ResultClass, number>;
43
+ diagnosticCodes: Record<string, number>;
44
+ commandExits: Record<string, number>;
45
+ expectedNonzeroCommands: number;
46
+ undeclaredNonzeroCommands: number;
47
+ }
48
+ const classes = (): Record<ResultClass, number> => ({ success: 0, invalid_input: 0, cancelled: 0, timeout: 0,
49
+ command_nonzero: 0, execution_failed: 0, rejected: 0, unclassified_failure: 0 });
50
+ const counters = (): ToolCounters => ({ calls: 0, providerCalls: 0, invocations: 0, nestedInvocations: 0,
51
+ results: 0, pairedCalls: 0, orphanCalls: 0, orphanResults: 0, duplicateProviderCalls: 0,
52
+ duplicateInvocations: 0, duplicateResults: 0, repeatedProviderInputs: 0, repeatedInvocationInputs: 0,
53
+ classes: classes(), diagnosticCodes: {}, commandExits: {}, expectedNonzeroCommands: 0, undeclaredNonzeroCommands: 0 });
54
+ function add(record: Record<string, number>, key: string): void { record[key] = (record[key] ?? 0) + 1; }
55
+ const validCount = (value: number): boolean => Number.isSafeInteger(value) && value >= 0;
56
+ const reportedCount = (value: number | null | undefined): number | null => typeof value === "number" && validCount(value) ? value : null;
57
+ const validTime = (value: number | null): number | null => value !== null && Number.isFinite(value) && value >= 0 ? value : null;
58
+ function observedUsage(usage: BenchmarkUsage | null): BenchmarkUsage | null {
59
+ return usage && [usage.input_tokens, usage.output_tokens, usage.cache_read_input_tokens,
60
+ usage.cache_creation_input_tokens].every(validCount) ? usage : null;
61
+ }
62
+ function quantile(values: readonly number[], q: number): number | null {
63
+ return values.length ? values.toSorted((a, b) => a - b)[Math.max(0, Math.ceil(values.length * q) - 1)]! : null;
64
+ }
65
+ interface Call {
66
+ name: string; provider: boolean; invocation: boolean; nested: boolean;
67
+ providerInput: string | null; invocationInput: string | null;
68
+ }
69
+
70
+ /**
71
+ * Inputs and outputs remain private. Equal-input counts use canonical original
72
+ * model arguments and immutable admitted-input hashes separately (O.3 / Pi A).
73
+ * Expected nonzero exits need an evaluator declaration; an exit alone cannot
74
+ * prove a command was accidental, and model prose cannot establish intent.
75
+ */
76
+ export function summarizeHarnessDiagnostics(input: {
77
+ events: readonly LogEvent[]; requests: readonly DiagnosticRequest[];
78
+ expectedNonzeroCallIds?: readonly string[];
79
+ }) {
80
+ const tools: Record<string, ToolCounters> = {};
81
+ const tool = (name: string): ToolCounters => tools[name] ??= counters();
82
+ const calls = new Map<string, Call>(), results = new Map<string, ToolResultEvent[]>(), seenEvents = new Set<string>();
83
+ const guidance: Record<string, number> = {}, cuts: Record<string, number> = {}, cleanupProcessExits: Record<string, number> = {};
84
+ let duplicateEvents = 0, interruptions = 0;
85
+ for (const event of input.events) {
86
+ if (seenEvents.has(event.id)) { duplicateEvents++; continue; }
87
+ seenEvents.add(event.id);
88
+ if (event.t === "guidance") add(guidance, event.reason);
89
+ if (event.t === "cut") add(cuts, event.reason);
90
+ if (event.t === "interrupt") interruptions++;
91
+ if (event.t === "process_exit") add(cleanupProcessExits, String(event.exit));
92
+ if (event.t === "assistant") for (const block of event.blocks) {
93
+ if (block.type !== "tool_use") continue;
94
+ const existing = calls.get(block.call_id), stats = tool(block.name);
95
+ if (existing?.provider) stats.duplicateProviderCalls++;
96
+ calls.set(block.call_id, { name: existing?.name ?? block.name, provider: true,
97
+ invocation: existing?.invocation ?? false, nested: existing?.nested ?? false,
98
+ providerInput: canonicalValue(block.input), invocationInput: existing?.invocationInput ?? null });
99
+ }
100
+ if (event.t === "tool_invocation") {
101
+ const existing = calls.get(event.call_id), stats = tool(event.name);
102
+ if (existing?.invocation) stats.duplicateInvocations++;
103
+ calls.set(event.call_id, { name: existing?.name ?? event.name, provider: existing?.provider ?? false,
104
+ invocation: true, nested: event.parent_call_id !== undefined,
105
+ providerInput: existing?.providerInput ?? null, invocationInput: event.input_ref.sha256 });
106
+ }
107
+ if (event.t === "tool_result") {
108
+ const found = results.get(event.call_id) ?? [];
109
+ found.push(event); results.set(event.call_id, found);
110
+ }
111
+ }
112
+ const providerInputs = new Map<string, Set<string>>(), invocationInputs = new Map<string, Set<string>>();
113
+ const repeat = (groups: Map<string, Set<string>>, name: string, key: string | null): boolean => {
114
+ if (key === null) return false;
115
+ const seen = groups.get(name) ?? new Set<string>(), repeated = seen.has(key);
116
+ seen.add(key); groups.set(name, seen); return repeated;
117
+ };
118
+ for (const [id, call] of calls) {
119
+ const stats = tool(call.name); stats.calls++;
120
+ if (call.provider) stats.providerCalls++;
121
+ if (call.invocation) stats.invocations++;
122
+ if (call.nested) stats.nestedInvocations++;
123
+ if (repeat(providerInputs, call.name, call.providerInput)) stats.repeatedProviderInputs++;
124
+ if (repeat(invocationInputs, call.name, call.invocationInput)) stats.repeatedInvocationInputs++;
125
+ if (results.has(id)) stats.pairedCalls++; else stats.orphanCalls++;
126
+ }
127
+ const expected = new Set(input.expectedNonzeroCallIds ?? []);
128
+ for (const [id, found] of results) {
129
+ const call = calls.get(id), stats = tool(call?.name ?? "<orphan>");
130
+ stats.duplicateResults += Math.max(0, found.length - 1);
131
+ for (const event of found) {
132
+ stats.results++;
133
+ if (!call) stats.orphanResults++;
134
+ stats.classes[classifyToolResult(event)]++;
135
+ if (event.diagnostic) add(stats.diagnosticCodes, event.diagnostic.code);
136
+ if (event.effect?.kind === "command") {
137
+ add(stats.commandExits, String(event.effect.exit));
138
+ if (event.effect.exit !== 0) {
139
+ if (expected.has(id)) stats.expectedNonzeroCommands++; else stats.undeclaredNonzeroCommands++;
140
+ }
141
+ }
142
+ }
143
+ }
144
+ const requestIds = new Set<number>(), requests: {
145
+ request: number; node: number | null; usageKnown: boolean; promptTokens: number | null;
146
+ cachedTokens: number | null; cacheWriteTokens: number | null; outputTokens: number | null;
147
+ cacheHitRate: number | null; responseModel: string | null; failure: string | null;
148
+ firstDeltaMs: number | null; wallMs: number | null; prefixUnchanged: boolean | null; sharedInputBytes: number | null;
149
+ }[] = [];
150
+ const models: Record<string, number> = {};
151
+ const usage = { knownRequests: 0, unknownRequests: 0, requestsWithCacheReads: 0, promptTokens: 0, cachedTokens: 0,
152
+ cacheWriteTokens: 0, outputTokens: 0, promptKnownRequests: 0, promptUnknownRequests: 0,
153
+ cacheCoveredPromptTokens: 0, cacheCoverageRate: null as number | null,
154
+ observedCacheHitRate: null as number | null, cacheHitRate: null as number | null };
155
+ const prefix = { sameNodeComparisons: 0, sameNodeMutations: 0, boundaryComparisons: 0, boundaryMutations: 0,
156
+ unknownComparisons: 0, sameNodeFrozenMutations: 0, boundaryFrozenMutations: 0 };
157
+ let previous: DiagnosticRequest | undefined, duplicateRequests = 0;
158
+ for (const request of input.requests.toSorted((a, b) => a.request - b.request)) {
159
+ if (requestIds.has(request.request)) { duplicateRequests++; continue; }
160
+ requestIds.add(request.request);
161
+ const observed = observedUsage(request.usage);
162
+ const prompt = reportedCount(request.reportedPromptTokens) ?? (observed ?
163
+ observed.input_tokens + observed.cache_read_input_tokens + observed.cache_creation_input_tokens : null);
164
+ const output = reportedCount(request.reportedOutputTokens) ?? observed?.output_tokens ?? null;
165
+ if (prompt !== null) { usage.promptKnownRequests++; usage.promptTokens += prompt; } else usage.promptUnknownRequests++;
166
+ if (output !== null) usage.outputTokens += output;
167
+ if (observed) {
168
+ usage.knownRequests++; usage.cacheCoveredPromptTokens += prompt!; usage.cachedTokens += observed.cache_read_input_tokens;
169
+ usage.cacheWriteTokens += observed.cache_creation_input_tokens;
170
+ if (observed.cache_read_input_tokens > 0) usage.requestsWithCacheReads++;
171
+ } else usage.unknownRequests++;
172
+ if (request.responseModel !== null) add(models, request.responseModel);
173
+ if (previous) {
174
+ if (previous.node === null || request.node === null || request.prefixUnchanged === null) prefix.unknownComparisons++;
175
+ else if (previous.node === request.node) { prefix.sameNodeComparisons++; if (!request.prefixUnchanged) prefix.sameNodeMutations++; }
176
+ else { prefix.boundaryComparisons++; if (!request.prefixUnchanged) prefix.boundaryMutations++; }
177
+ if (previous.frozenSha256 !== null && request.frozenSha256 !== null && previous.frozenSha256 !== request.frozenSha256) {
178
+ if (previous.node === request.node && request.node !== null) prefix.sameNodeFrozenMutations++;
179
+ else if (previous.node !== null && request.node !== null) prefix.boundaryFrozenMutations++;
180
+ }
181
+ }
182
+ previous = request;
183
+ requests.push({ request: request.request, node: request.node, usageKnown: observed !== null, promptTokens: prompt,
184
+ cachedTokens: observed?.cache_read_input_tokens ?? null, cacheWriteTokens: observed?.cache_creation_input_tokens ?? null,
185
+ outputTokens: output, cacheHitRate: observed && prompt ? observed.cache_read_input_tokens / prompt : null,
186
+ responseModel: request.responseModel, failure: request.failure, firstDeltaMs: validTime(request.firstDeltaMs),
187
+ wallMs: validTime(request.wallMs), prefixUnchanged: request.prefixUnchanged, sharedInputBytes: request.sharedInputBytes });
188
+ }
189
+ usage.observedCacheHitRate = usage.cacheCoveredPromptTokens > 0 ? usage.cachedTokens / usage.cacheCoveredPromptTokens : null;
190
+ usage.cacheCoverageRate = usage.promptUnknownRequests === 0 && usage.promptTokens > 0 ? usage.cacheCoveredPromptTokens / usage.promptTokens : null;
191
+ usage.cacheHitRate = usage.unknownRequests === 0 && usage.promptTokens > 0 ? usage.cachedTokens / usage.promptTokens : null;
192
+ const delta = requests.flatMap(r => r.firstDeltaMs === null ? [] : [r.firstDeltaMs]);
193
+ const totals = counters();
194
+ for (const stats of Object.values(tools)) {
195
+ for (const key of Object.keys(totals) as (keyof ToolCounters)[]) {
196
+ const value = stats[key];
197
+ if (typeof value === "number") (totals[key] as number) += value;
198
+ }
199
+ for (const key of Object.keys(stats.classes) as ResultClass[]) totals.classes[key] += stats.classes[key];
200
+ for (const key of Object.keys(stats.commandExits)) totals.commandExits[key] = (totals.commandExits[key] ?? 0) + stats.commandExits[key]!;
201
+ for (const key of Object.keys(stats.diagnosticCodes)) totals.diagnosticCodes[key] = (totals.diagnosticCodes[key] ?? 0) + stats.diagnosticCodes[key]!;
202
+ }
203
+ return { eventCount: seenEvents.size, duplicateEvents, tools, totals, guidance, cuts, interruptions, cleanupProcessExits,
204
+ requestCount: requests.length, duplicateRequests, models, usage, prefix, requests,
205
+ firstDeltaMs: { samples: delta.length, missing: requests.length - delta.length, p50: quantile(delta, 0.5), p95: quantile(delta, 0.95) } };
206
+ }
@@ -0,0 +1,28 @@
1
+ /** User selections become startup preferences; restored session state stays in the log (D8/Q.25). */
2
+ import { recordRecent } from "./model-picker.ts";
3
+ import { modelKey } from "./session-position.ts";
4
+ import { settingsToWrite, type Settings } from "./settings.ts";
5
+ import type { ThinkingLevel } from "./thinking.ts";
6
+
7
+ interface ModelIdentity { readonly provider: string; readonly id: string }
8
+
9
+ /** Keep the effective effort with its model, without changing another model's saved effort. */
10
+ export function modelPreferencePatch(model: ModelIdentity, recent: readonly string[], effort: ThinkingLevel): Settings {
11
+ return { defaultProvider: model.provider, defaultModel: model.id, recentModels: recordRecent(recent, modelKey(model)),
12
+ modelThinkingLevels: { [modelKey(model)]: effort } };
13
+ }
14
+
15
+ export function thinkingPreferencePatch(model: ModelIdentity, effort: ThinkingLevel): Settings {
16
+ return { defaultThinking: effort, modelThinkingLevels: { [modelKey(model)]: effort } };
17
+ }
18
+
19
+ /** Patch only known preferences; retain future keys and nested fields not owned by this selection. */
20
+ export function mergePreferenceJson(previous: Readonly<Record<string, unknown>>, patch: Settings): Record<string, unknown> {
21
+ const next = { ...previous, ...settingsToWrite(patch) };
22
+ for (const key of ["terminal", "images", "markdown", "modelThinkingLevels"] as const) {
23
+ if (patch[key] === undefined) continue;
24
+ const old = previous[key];
25
+ next[key] = { ...(old !== null && typeof old === "object" && !Array.isArray(old) ? old : {}), ...patch[key] };
26
+ }
27
+ return next;
28
+ }
@@ -0,0 +1,44 @@
1
+ /** Validated wire data only; the runtime owns HTTP, time, polling and persistence. */
2
+ export interface OAuthTokens { access: string; refresh: string; expiresIn: number }
3
+ export interface DeviceAuthorization {
4
+ deviceCode: string;
5
+ userCode: string;
6
+ verificationUri: string;
7
+ verificationUriComplete?: string;
8
+ expiresIn: number;
9
+ interval?: number;
10
+ }
11
+
12
+ function record(value: unknown): Record<string, unknown> | undefined {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
14
+ }
15
+ function positive(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value) && value > 0; }
16
+ function token(value: unknown): value is string { return typeof value === "string" && value.length > 0 && !/[\s\x00-\x1f\x7f]/.test(value); }
17
+
18
+ /** Refresh rotation is optional; omission preserves the existing refresh capability. */
19
+ export function parseOAuthTokens(value: unknown, previousRefresh?: string): OAuthTokens | undefined {
20
+ const data = record(value);
21
+ const access = data?.["access_token"], refresh = data?.["refresh_token"] ?? previousRefresh, expiresIn = data?.["expires_in"];
22
+ if (!token(access) || !token(refresh) || !positive(expiresIn)) return undefined;
23
+ return { access, refresh, expiresIn };
24
+ }
25
+
26
+ export function trustedOAuthUrl(value: unknown, origins: readonly string[]): value is string {
27
+ if (typeof value !== "string" || /[\x00-\x20\x7f]/.test(value)) return false;
28
+ try {
29
+ const url = new URL(value);
30
+ return url.protocol === "https:" && !url.username && !url.password && origins.includes(url.origin);
31
+ } catch { return false; }
32
+ }
33
+
34
+ /** Never open an issuer-supplied arbitrary origin or print its control characters. */
35
+ export function parseDeviceAuthorization(value: unknown, origins: readonly string[]): DeviceAuthorization | undefined {
36
+ const data = record(value);
37
+ const deviceCode = data?.["device_code"], userCode = data?.["user_code"], verificationUri = data?.["verification_uri"];
38
+ const complete = data?.["verification_uri_complete"], expiresIn = data?.["expires_in"], interval = data?.["interval"];
39
+ if (!token(deviceCode) || typeof userCode !== "string" || !/^[A-Za-z0-9-]+$/.test(userCode)
40
+ || !trustedOAuthUrl(verificationUri, origins) || !positive(expiresIn)
41
+ || (complete !== undefined && !trustedOAuthUrl(complete, origins)) || (interval !== undefined && !positive(interval))) return undefined;
42
+ return { deviceCode, userCode, verificationUri, expiresIn,
43
+ ...(typeof complete === "string" ? { verificationUriComplete: complete } : {}), ...(typeof interval === "number" ? { interval } : {}) };
44
+ }
@@ -3,8 +3,8 @@
3
3
  * call is allow / ask / deny.
4
4
  *
5
5
  * Approval is the last look at what is about to run, not a boundary.
6
- * Path tools are already confined to the workspace root; bash is not,
7
- * which is why bash defaults to ask and everything else to allow.
6
+ * Path tools are already confined to the workspace root; host commands
7
+ * are not, which is why shell entry points default to ask.
8
8
  *
9
9
  * This module must stay free of I/O, clocks, and randomness. The CLI
10
10
  * compiles flags and session memory into a rule list; this file only
@@ -75,8 +75,8 @@ export interface ToolSelection {
75
75
  export const ALLOW_TOOLS_FLAG = "--allow-tools";
76
76
 
77
77
  /**
78
- * Product default. bash asks because it is not confined to the workspace
79
- * root; every other built-in is allow because the path tools are.
78
+ * Product default. Shell entry points ask because their effects are not
79
+ * confined to the workspace root; path tools keep their own confinement.
80
80
  */
81
81
  export function defaultRules(): readonly PermissionRule[] {
82
82
  return [
@@ -91,9 +91,15 @@ export function defaultRules(): readonly PermissionRule[] {
91
91
  }
92
92
 
93
93
  /**
94
- * `--allow-tools` compiled to rules, in front of the defaults so they win.
94
+ * CLI opt-ins compiled to one process-scoped rule list. YOLO permits every
95
+ * tool; capability admission and path confinement still apply (D13).
96
+ * Neither this flag nor remembered approvals become a persisted policy.
95
97
  */
96
- export function rulesWithAllowTools(allowTools: readonly string[]): PermissionRule[] {
98
+ export function rulesWithAllowTools(
99
+ allowTools: readonly string[],
100
+ options: { readonly yolo?: boolean } = {},
101
+ ): PermissionRule[] {
102
+ if (options.yolo === true) return [{ action: "allow" }];
97
103
  return [
98
104
  ...allowTools.map((tool) => ({ action: "allow" as const, tool })),
99
105
  ...defaultRules(),
@@ -109,7 +115,7 @@ export function ruleMatches(rule: PermissionRule, request: PermissionRequest): b
109
115
  /**
110
116
  * First matching rule wins. An empty list allows: the tool tests pass no
111
117
  * rules because they are testing the tool, not the gate. The CLI always
112
- * supplies defaultRules().
118
+ * supplies the current process's compiled policy.
113
119
  */
114
120
  export function decide(
115
121
  rules: readonly PermissionRule[],
@@ -0,0 +1,31 @@
1
+ /** OSC 7501 revision 0.2: program state, never a model's claim of goal completion (Q24/D11). */
2
+ import type { ExecutionOutcome } from "./outcome.ts";
3
+
4
+ export type ProgramStatus = { state: "idle" | "working" | "done" | "blocked" | "error" | "clear";
5
+ kind?: "permission" | "question" | "auth"; msg?: string };
6
+ export function programStatus(input: { running: boolean; waiting?: "permission" | "question" | "auth" | null;
7
+ outcome?: ExecutionOutcome; goal?: string }): ProgramStatus {
8
+ if (input.waiting) return { state: "blocked", kind: input.waiting, msg: input.waiting === "auth" ? "Sign in to continue" : input.waiting === "permission" ? "Tool approval required" : "User input required" };
9
+ if (input.running) return { state: "working", msg: "Agent is working" };
10
+ if (input.outcome?.status === "cancelled") return { state: "idle", msg: "Turn cancelled" };
11
+ if (input.outcome?.status === "failed") return { state: "error", msg: "Turn failed; inspect the session" };
12
+ if (input.outcome?.status === "stopped") return { state: "blocked", kind: "question", msg: "Turn stopped; review evidence before continuing" };
13
+ if (input.goal === "awaiting_human" || input.goal === "blocked") return { state: "blocked", kind: "question", msg: "Goal awaits user review" };
14
+ if (input.outcome?.reason === "model_end") return { state: "done", msg: input.goal === "verified" ? "Goal verified against its contract" : "Turn finished; goal verification incomplete" };
15
+ return { state: "idle", msg: "Ready for a request" };
16
+ }
17
+ export const PROGRAM_STATUS_QUERY = "\x1b]7501;?\x1b\\";
18
+ export function programStatusReply(sequence: string): boolean { return /^\x1b\]7501;\?[^\x00-\x1f\x7f]*(?:\x07|\x1b\\)$/.test(sequence); }
19
+
20
+ export function encodeProgramStatus(status: ProgramStatus, id: string): string {
21
+ if (id.length > 128 || id.split("/").length > 8 || !id.split("/").every(part => /^[A-Za-z0-9_.+-]{1,32}$/.test(part))) throw Error("invalid program status id");
22
+ let message = "", size = 0;
23
+ // Protocol's decoded UTF-8 cap. Remove controls before encoding; never split a code point.
24
+ for (const point of (status.msg ?? "").replace(/[\x00-\x1f\x7f-\x9f\p{Cf}]/gu, " ")) {
25
+ const bytes = new TextEncoder().encode(point); if (size + bytes.length > 2048) break;
26
+ message += point; size += bytes.length;
27
+ }
28
+ const encoded = btoa(Array.from(new TextEncoder().encode(message), byte => String.fromCharCode(byte)).join(""));
29
+ return `\x1b]7501;state=${status.state}:app=xeno:id=${id}` +
30
+ (status.state === "blocked" && status.kind ? `:kind=${status.kind}` : "") + (message ? `:msg=${encoded}` : "") + "\x1b\\";
31
+ }
@@ -89,10 +89,31 @@ export interface ProjectResult {
89
89
  }
90
90
 
91
91
  export interface ProjectViolation {
92
- code: "UNPAIRED_TOOL_USE" | "ORPHAN_TOOL_RESULT" | "EMPTY_PROJECTION";
92
+ code: "UNPAIRED_TOOL_USE" | "ORPHAN_TOOL_RESULT" | "EMPTY_PROJECTION" | "DUPLICATE_TOOL_USE" | "EMPTY_TOOL_USE_ID";
93
93
  detail: string;
94
94
  }
95
95
 
96
+ /**
97
+ * D5: a receipt identifies exactly one call in its assistant batch. Validate
98
+ * before either the projection's Set or the runtime's scheduling Map can erase
99
+ * a collision; repairing IDs would invent provider protocol history.
100
+ */
101
+ export function toolCallIdentityViolations(blocks: readonly AssistantBlock[]): ProjectViolation[] {
102
+ const violations: ProjectViolation[] = [], seen = new Map<string, number>();
103
+ for (const [index, block] of blocks.entries()) {
104
+ if (block.type !== "tool_use") continue;
105
+ if (block.call_id.trim().length === 0) {
106
+ violations.push({ code: "EMPTY_TOOL_USE_ID", detail: `tool_use block ${index} has an empty call_id` });
107
+ continue;
108
+ }
109
+ const previous = seen.get(block.call_id);
110
+ if (previous !== undefined) {
111
+ violations.push({ code: "DUPLICATE_TOOL_USE", detail: `tool_use block ${index} repeats the call_id of block ${previous}` });
112
+ } else seen.set(block.call_id, index);
113
+ }
114
+ return violations;
115
+ }
116
+
96
117
  /**
97
118
  * The only event types that replay as messages.
98
119
  *
@@ -346,6 +367,9 @@ export function projectContext(input: ProjectInput): ProjectResult {
346
367
 
347
368
  if (e.t === "assistant") {
348
369
  flushPending();
370
+ violations.push(...toolCallIdentityViolations(e.blocks).map(violation => ({
371
+ ...violation, detail: `assistant turn ${e.id}: ${violation.detail}`,
372
+ })));
349
373
  if (awaiting.size > 0) {
350
374
  violations.push({
351
375
  code: "UNPAIRED_TOOL_USE",