@visual-z/xeno 0.3.0 → 0.4.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 (51) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +21 -9
  3. package/docs/app-server.md +84 -0
  4. package/docs/install.md +1 -1
  5. package/docs/providers.md +16 -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/provider-connect.ts +4 -4
  14. package/src/apps/shell.ts +39 -20
  15. package/src/apps/startup.ts +22 -9
  16. package/src/apps/ui/app.ts +196 -26
  17. package/src/apps/ui/control-model.ts +26 -0
  18. package/src/apps/ui/surface.ts +2 -0
  19. package/src/core/completion-action.ts +20 -0
  20. package/src/core/completion-review.ts +26 -26
  21. package/src/core/control-protocol.ts +62 -0
  22. package/src/core/control-view.ts +42 -0
  23. package/src/core/event-validation.ts +1 -1
  24. package/src/core/events.ts +2 -0
  25. package/src/core/harness-diagnostics.ts +206 -0
  26. package/src/core/oauth-fields.ts +44 -0
  27. package/src/core/program-status.ts +31 -0
  28. package/src/core/project.ts +25 -1
  29. package/src/core/request-progress.ts +87 -0
  30. package/src/core/request-state.ts +114 -0
  31. package/src/core/settings.ts +7 -0
  32. package/src/core/tool-input.ts +6 -0
  33. package/src/runtime/auth.ts +10 -3
  34. package/src/runtime/catalog.ts +13 -3
  35. package/src/runtime/control-connection.ts +104 -0
  36. package/src/runtime/harness-eval.ts +3 -1
  37. package/src/runtime/loop.ts +72 -19
  38. package/src/runtime/model.ts +9 -0
  39. package/src/runtime/native-eval.ts +6 -3
  40. package/src/runtime/oauth.ts +80 -3
  41. package/src/runtime/pi-provider.ts +169 -12
  42. package/src/runtime/program-status.ts +38 -0
  43. package/src/runtime/provider-connect.ts +2 -2
  44. package/src/runtime/provider-presets.ts +1 -1
  45. package/src/runtime/provider-request.ts +13 -2
  46. package/src/runtime/provider.ts +23 -9
  47. package/src/runtime/session-control.ts +198 -0
  48. package/src/tui/ui/composer.ts +3 -2
  49. package/src/tui/ui/control-panel.ts +23 -0
  50. package/src/tui/ui/mascot.ts +1 -1
  51. package/src/tui/ui/status.ts +8 -0
@@ -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,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
+ }
@@ -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",
@@ -0,0 +1,87 @@
1
+ /** Q23 / D9: diagnostic observations, never a new anti-stall or success policy. */
2
+ import type { LogEvent, ToolResultEvent } from "./events.ts";
3
+ import { deriveAcceptance, type AcceptanceState } from "./acceptance.ts";
4
+ import { advanceRecovery, emptyRecoveryState, failureIdentity } from "./failure-recovery.ts";
5
+ import { canonicalValue } from "./continuation.ts";
6
+ import { changedPaths } from "./checkpoints.ts";
7
+
8
+ export type RequestSource = { node: number; event: string };
9
+ export type RequestRecord = { node: number; event: LogEvent };
10
+ export type RequestProgress = Record<"observations" | "artifact_changes" | "acceptance_advances" |
11
+ "new_diagnostics" | "unchanged_retries" | "unknown_diagnostics", RequestSource[]>;
12
+ const object = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
13
+
14
+ function observationDigest(event: ToolResultEvent): string | undefined {
15
+ if (event.observation_sha256 !== undefined) return event.observation_sha256;
16
+ // Command receipts archive stdout/stderr before the timing/session header is
17
+ // rendered. Only that explicit matching output reference is a safe fallback.
18
+ const data = event.data;
19
+ if (event.effect?.kind !== "command" || !event.content_ref || !object(data) || data["capture"] === "partial") return undefined;
20
+ const output = data["output"];
21
+ return object(output) &&
22
+ output["sha256"] === event.content_ref.sha256 && output["bytes"] === event.content_ref.bytes ? event.content_ref.sha256 : undefined;
23
+ }
24
+
25
+ export function deriveRequestProgress(records: readonly RequestRecord[]): RequestProgress {
26
+ const result: RequestProgress = { observations: [], artifact_changes: [], acceptance_advances: [],
27
+ new_diagnostics: [], unchanged_retries: [], unknown_diagnostics: [] };
28
+ const observations = new Set<string>(), versions = new Set<string>(), failures = new Set<string>();
29
+ let acceptance: AcceptanceState | undefined;
30
+ let recovery = emptyRecoveryState();
31
+ const version = (path: string, before: string | null, after: string | null, source: RequestSource) => {
32
+ const key = (sha: string | null) => canonicalValue([path, sha]);
33
+ versions.add(key(before));
34
+ if (!versions.has(key(after))) result.artifact_changes.push(source);
35
+ versions.add(key(after));
36
+ };
37
+ for (const { node, event } of records) {
38
+ const source = { node, event: event.id };
39
+ const before = acceptance;
40
+ acceptance = deriveAcceptance(acceptance, [event]);
41
+ if (acceptance && before?.contract_event === acceptance.contract_event && acceptance.criteria.some(item =>
42
+ (item.status === "pass" || item.status === "accepted") &&
43
+ before.criteria.find(old => old.id === item.id)?.status !== item.status)) result.acceptance_advances.push(source);
44
+ if (event.t === "tool_result") {
45
+ const effect = event.effect;
46
+ const call = recovery.calls.get(event.call_id);
47
+ if (effect?.kind === "edit") version(effect.path, effect.sha256_before, effect.sha256_after, source);
48
+ if (effect?.kind === "read") versions.add(canonicalValue([effect.path, effect.sha256]));
49
+ // Semantic digests exclude command timing/preview metadata. Legacy raw
50
+ // output hashes cannot prove a new diagnostic and stay explicitly unknown.
51
+ const digest = observationDigest(event);
52
+ if (!event.ok) {
53
+ const identity = call ? failureIdentity(call, event, recovery, path => path) : null;
54
+ if (digest === undefined || identity === null) result.unknown_diagnostics.push(source);
55
+ else {
56
+ const key = canonicalValue([identity, digest]);
57
+ (failures.has(key) ? result.unchanged_retries : result.new_diagnostics).push(source);
58
+ failures.add(key);
59
+ }
60
+ } else if ((effect?.kind === "read" || effect?.kind === "command" ||
61
+ ["grep", "find", "ls", "query", "history", "environment"].includes(call?.name ?? "")) &&
62
+ digest !== undefined && (event.content_ref?.bytes ?? event.content.length) > 0 && !observations.has(digest)) {
63
+ result.observations.push(source); observations.add(digest);
64
+ }
65
+ if (effect?.kind === "command") for (const output of effect.artifacts?.outputs ?? []) {
66
+ if (output.after.state === "observed") version(output.path,
67
+ output.before.state === "observed" ? output.before.sha256 : null, output.after.sha256, source);
68
+ }
69
+ } else if (event.t === "checkpoint" && event.phase === "applied") {
70
+ for (const path of changedPaths(event.changes)) {
71
+ const change = event.changes[path]!;
72
+ version(path, change.before?.sha256 ?? null, change.after?.sha256 ?? null, source);
73
+ }
74
+ }
75
+ recovery = advanceRecovery(recovery, [event], path => path);
76
+ }
77
+ // One receipt may change several artifacts or satisfy several criteria.
78
+ for (const key of Object.keys(result) as (keyof RequestProgress)[]) {
79
+ const seen = new Set<string>();
80
+ result[key] = result[key].filter(source => {
81
+ const id = canonicalValue(source);
82
+ if (seen.has(id)) return false;
83
+ seen.add(id); return true;
84
+ });
85
+ }
86
+ return result;
87
+ }
@@ -0,0 +1,114 @@
1
+ /** Q23 / D8 / D11: disposable request evidence, derived only from the selected log ancestry. */
2
+ import type { LogEvent } from "./events.ts";
3
+ import { deriveAcceptance, type AcceptanceState } from "./acceptance.ts";
4
+ import { changedPaths } from "./checkpoints.ts";
5
+ import { evidenceStale, isCommandResult, resolveEvidence } from "./evidence.ts";
6
+ import { executionOutcome, modelOutcome, type ExecutionOutcome } from "./outcome.ts";
7
+ import { deriveWorkItems, type WorkItem } from "./work-state.ts";
8
+ import { deriveRequestProgress, type RequestProgress, type RequestRecord, type RequestSource } from "./request-progress.ts";
9
+
10
+ export type RequestPart = { node: number; events: readonly LogEvent[] };
11
+ export type RequestCommand = {
12
+ source: RequestSource; command: string; exit: number; status: "pass" | "fail" | "stale" | "superseded";
13
+ gate?: string; workspace_sha256?: string; workspace_scope?: "source-v1"; criteria: string[];
14
+ };
15
+ export type RequestArtifact = { source: RequestSource; path: string; sha256: string | null;
16
+ kind: "read" | "edit" | "checkpoint" | "command_output" };
17
+ export type RequestState = {
18
+ request: RequestSource; inputs: RequestSource[];
19
+ status: "verified" | "awaiting_human" | "needs_work" | "blocked" | "unverified" | "not_applicable";
20
+ execution?: ExecutionOutcome; acceptance?: AcceptanceState;
21
+ commands: RequestCommand[]; artifacts: RequestArtifact[]; work: WorkItem[];
22
+ reported_blockers: { source: RequestSource; reason: string }[];
23
+ progress: RequestProgress;
24
+ changed: boolean; used_tools: boolean; reviewed: boolean;
25
+ final?: { source: RequestSource; text: string; has_tools: boolean };
26
+ };
27
+
28
+ /** New steering events refer to their original user event. Legacy users remain separate requests. */
29
+ export function currentRequestRecords(parts: readonly RequestPart[]): RequestRecord[] {
30
+ const records = parts.flatMap(part => part.events.map(event => ({ node: part.node, event })));
31
+ const last = records.findLastIndex(({ event }) => event.t === "user");
32
+ if (last < 0) return [];
33
+ const user = records[last]!.event;
34
+ if (user.t !== "user") return [];
35
+ const start = user.request === undefined ? last : records.findIndex(({ event }) => event.t === "user" && event.id === user.request);
36
+ // Never reach through an unrelated user request because of an invalid reference.
37
+ if (start < 0 || records.slice(start + 1, last + 1).some(({ event }) => event.t === "user" && event.request !== user.request)) return records.slice(last);
38
+ return records.slice(start);
39
+ }
40
+
41
+ export function deriveRequestState(parts: readonly RequestPart[]): RequestState | undefined {
42
+ const records = currentRequestRecords(parts);
43
+ const first = records[0];
44
+ if (!first) return undefined;
45
+ const events = records.map(record => record.event);
46
+ const source = ({ node, event }: RequestRecord): RequestSource => ({ node, event: event.id });
47
+ const request = source(first);
48
+ const acceptance = deriveAcceptance(undefined, events.filter(event => event.t !== "acceptance_contract" || event.request === request.event));
49
+ const commands: RequestCommand[] = [], artifacts: RequestArtifact[] = [];
50
+ const lastCommands = new Map<string, string>();
51
+ for (const event of events) if (isCommandResult(event)) lastCommands.set(event.effect.cmd, event.id);
52
+ let work: WorkItem[] = [];
53
+ const ids = new Set(events.map(event => event.id)), completed = new Set<string>();
54
+ // Keep the existing work-item same-segment evidence contract (D11/O.2).
55
+ for (const part of parts) {
56
+ work = deriveWorkItems(work, part.events);
57
+ for (let index = 0; index < part.events.length; index++) {
58
+ const event = part.events[index]!;
59
+ // Legacy completed claims supersede a same-named blocker only when the
60
+ // original same-segment evidence resolves, just as in deriveHandoff.
61
+ if (event.t === "claim" && event.work_id === undefined && ids.has(event.id) &&
62
+ resolveEvidence(part.events.slice(0, index), event.evidence).ok) completed.add(event.what);
63
+ }
64
+ }
65
+ work = work.filter(item => ids.has(item.opened_at) || ids.has(item.updated_at));
66
+ const reported_blockers: RequestState["reported_blockers"] = [];
67
+ let execution: ExecutionOutcome | undefined, final: RequestState["final"], changed = false;
68
+ for (const record of records) {
69
+ const event = record.event, ref = source(record);
70
+ if (event.t === "user") { execution = undefined; final = undefined; }
71
+ else if (event.t === "assistant") {
72
+ final = { source: ref, text: event.blocks.flatMap(block => block.type === "text" ? [block.text] : []).join("\n").trim(),
73
+ has_tools: event.blocks.some(block => block.type === "tool_use") };
74
+ execution = final.has_tools ? undefined : modelOutcome(event.stop_reason ?? null);
75
+ } else if (event.t === "outcome") execution = { ...event.outcome };
76
+ else if (event.t === "interrupt") execution = executionOutcome("cancelled");
77
+ else if (event.t === "guidance" && (event.reason === "completion_review" || event.reason === "output_recovery")) execution = undefined;
78
+ if (event.t === "blocked" && (event.work_id ? work.some(item => item.id === event.work_id && item.status === "blocked") : !completed.has(event.what))) {
79
+ reported_blockers.push({ source: ref, reason: event.reason });
80
+ }
81
+ if (isCommandResult(event)) {
82
+ const effect = event.effect;
83
+ const evidence = { command: effect.cmd, exit: effect.exit, event_id: event.id, at: event.at,
84
+ ...(effect.workspace_sha256 === undefined ? {} : { workspace_sha256: effect.workspace_sha256 }),
85
+ ...(effect.workspace_scope === undefined ? {} : { workspace_scope: effect.workspace_scope }) };
86
+ commands.push({ source: ref, command: effect.cmd, exit: effect.exit,
87
+ status: !event.ok || effect.exit !== 0 ? "fail" : effect.workspace_observed === false || evidenceStale(events, evidence) ? "stale"
88
+ : lastCommands.get(effect.cmd) !== event.id ? "superseded" : "pass",
89
+ ...(effect.gate === undefined ? {} : { gate: effect.gate }),
90
+ ...(effect.workspace_sha256 === undefined ? {} : { workspace_sha256: effect.workspace_sha256 }),
91
+ ...(effect.workspace_scope === undefined ? {} : { workspace_scope: effect.workspace_scope }),
92
+ criteria: acceptance?.criteria.filter(item => item.kind === "command" && item.command === effect.cmd).map(item => item.id) ?? [] });
93
+ for (const output of effect.artifacts?.outputs ?? []) if (output.after.state === "observed") artifacts.push({
94
+ source: ref, path: output.path, sha256: output.after.sha256, kind: "command_output" });
95
+ } else if (event.t === "tool_result" && (event.effect?.kind === "read" || event.effect?.kind === "edit")) {
96
+ const effect = event.effect;
97
+ artifacts.push({ source: ref, path: effect.path, sha256: effect.kind === "read" ? effect.sha256 : effect.sha256_after, kind: effect.kind });
98
+ if (effect.kind === "edit" && effect.sha256_before !== effect.sha256_after) changed = true;
99
+ } else if (event.t === "checkpoint" && event.phase === "applied") {
100
+ for (const path of changedPaths(event.changes)) artifacts.push({ source: ref, path, sha256: event.changes[path]!.after?.sha256 ?? null, kind: "checkpoint" });
101
+ changed ||= changedPaths(event.changes).length > 0;
102
+ } else if (event.t === "workspace_observation") changed ||= event.changed;
103
+ else if (event.t === "snapshot") changed ||= Object.keys(event.added).length + Object.keys(event.changed).length + event.removed.length > 0;
104
+ }
105
+ return { request, inputs: records.filter(record => record.event.t === "user").map(source),
106
+ // Work notes can describe a later phase the user explicitly asked to defer.
107
+ // Only the declared request contract defines what must finish in this request.
108
+ status: reported_blockers.length ? "blocked" : acceptance?.status ?? "unverified",
109
+ ...(execution ? { execution } : {}), ...(acceptance ? { acceptance } : {}),
110
+ commands, artifacts, work, reported_blockers, progress: deriveRequestProgress(records), changed,
111
+ used_tools: events.some(event => event.t === "tool_result"),
112
+ reviewed: events.some(event => event.t === "guidance" && event.reason === "completion_review"),
113
+ ...(final ? { final } : {}) };
114
+ }
@@ -113,6 +113,13 @@ export const DISPLAY_SETTINGS = [
113
113
  { id: "terminal.clearOnShrink", label: "Clear on shrink", fallback: "off" },
114
114
  { id: "terminal.showTerminalProgress", label: "Terminal progress", fallback: "on" },
115
115
  ] as const;
116
+ /** Only fields with a real picker may be suggested without a value. Raw config keys remain writable explicitly. */
117
+ export function interactiveSettingKeys(): string[] {
118
+ const pickers = ["theme", "thinking", "defaultModel", "modelThinkingLevels"];
119
+ return [...new Set([...pickers, ...SETTINGS_KEYS, ...DISPLAY_SETTINGS.map(item => item.id)])]
120
+ .filter(key => pickers.includes(key) || settingValues(key).length > 0);
121
+ }
122
+
116
123
  export function defaultSettings(): Settings { return { theme: "dark", thinking: "high", statusBar: true }; }
117
124
  export function mergeSettings(layers: SettingsLayers): Settings {
118
125
  const { defaultProjectTrust: _projectCannotGrantTrust, ...project } = layers.project ?? {};
@@ -12,6 +12,12 @@
12
12
  */
13
13
  type Input = Record<string, unknown>;
14
14
 
15
+ /** O.3: incomplete wire JSON remains the original input so admission can refuse it without effects. */
16
+ export function completedToolInput(raw: string): unknown {
17
+ try { return JSON.parse(raw) as unknown; }
18
+ catch { return raw; }
19
+ }
20
+
15
21
  function parsedJson(value: unknown): unknown {
16
22
  if (typeof value !== "string") return value;
17
23
  const trimmed = value.trim();