@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,15 +12,17 @@ import { bedrockConverseStreamApi } from "@earendil-works/pi-ai/api/bedrock-conv
12
12
  import { mistralConversationsApi } from "@earendil-works/pi-ai/api/mistral-conversations.lazy";
13
13
  import { piMessagesApi } from "@earendil-works/pi-ai/api/pi-messages.lazy";
14
14
  import { assertNever } from "../core/exhaustive.ts";
15
+ import { completedToolInput } from "../core/tool-input.ts";
15
16
  import type { AssistantBlock } from "../core/events.ts";
16
- import type { FrozenPrefix, Message } from "../core/project.ts";
17
+ import { toolCallIdentityViolations, type FrozenPrefix, type Message } from "../core/project.ts";
17
18
  import type { Api, ModelSpec } from "./model.ts";
18
19
  import { providerRequest } from "./provider-request.ts";
19
20
  import type { ResolveEffects } from "./auth.ts";
20
21
  import { imageData } from "./images.ts";
21
- import type { OnDelta, Provider, ProviderConfig, ToolSchema, Turn } from "./provider.ts";
22
+ import { FailedResponseError, type OnDelta, type Provider, type ProviderConfig, type ToolSchema, type Turn } from "./provider.ts";
22
23
  import { DEFAULT_RETRY, withRetry, type RetryPolicy } from "./retry.ts";
23
24
  import { HttpError, StreamError } from "./openai.ts";
25
+ import type { UsageSample } from "./usage.ts";
24
26
 
25
27
  const APIS: Record<Api, ProviderStreams> = {
26
28
  "anthropic-messages": anthropicMessagesApi(), "openai-completions": openAICompletionsApi(),
@@ -32,6 +34,141 @@ const APIS: Record<Api, ProviderStreams> = {
32
34
  function record(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
33
35
  const EMPTY_USAGE: AssistantMessage["usage"] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
34
36
 
37
+ /** D11: SDK defaults cannot certify a receipt; native input/output counters can, including measured zeros. */
38
+ function nativeUsageReported(api: Api, event: unknown): boolean {
39
+ if (!record(event)) return false;
40
+ let usage: unknown;
41
+ if (api.includes("responses")) {
42
+ if (event["type"] !== "response.completed" && event["type"] !== "response.incomplete") return false;
43
+ usage = record(event["response"]) ? event["response"]["usage"] : undefined;
44
+ } else if (api === "anthropic-messages") {
45
+ if (event["type"] === "message_start") usage = record(event["message"]) ? event["message"]["usage"] : undefined;
46
+ else if (event["type"] === "message_delta") usage = event["usage"];
47
+ else return false;
48
+ } else if (api === "openai-completions" || api === "mistral-conversations") usage = event["usage"];
49
+ else return false;
50
+ if (!record(usage)) return false;
51
+ const fields = api === "openai-completions" || api === "mistral-conversations" ? ["prompt_tokens", "completion_tokens"] : ["input_tokens", "output_tokens"];
52
+ return fields.some(field => typeof usage[field] === "number" && Number.isSafeInteger(usage[field]) && usage[field] >= 0);
53
+ }
54
+
55
+ /** O.3: Pi's streaming repair is presentation, never evidence that final arguments were valid JSON. */
56
+ class OriginalToolArguments {
57
+ readonly values = new Map<string, string>();
58
+ private readonly slots = new Map<string, { id: unknown; argumentId: string | null; raw: string; position: number }>();
59
+ private readonly faults = new Set<string>();
60
+ private readonly api: Api;
61
+ constructor(api: Api) { this.api = api; }
62
+
63
+ private slotKey(index: unknown, id?: unknown): string | null {
64
+ if (typeof index === "number" && Number.isSafeInteger(index) && index >= 0) return `index:${index}`;
65
+ // Mistral accepts a complete call keyed by ID without an index. A fragment
66
+ // with neither cannot be assigned to a call without inventing its identity.
67
+ if (this.api === "mistral-conversations" && typeof id === "string" && id.trim() && id !== "null") return `id:${id}`;
68
+ this.faults.add("native tool call has no stable stream slot");
69
+ return null;
70
+ }
71
+
72
+ private slot(key: string, id: unknown, argumentId: string | null, supplied: boolean) {
73
+ const existing = this.slots.get(key);
74
+ if (existing) {
75
+ if (supplied && id !== existing.id) this.faults.add(`native call ID changed in stream slot ${existing.position}`);
76
+ if (argumentId !== null && argumentId !== existing.argumentId) this.faults.add(`native argument identity changed in stream slot ${existing.position}`);
77
+ return existing;
78
+ }
79
+ const slot = { id, argumentId, raw: "", position: this.slots.size };
80
+ this.slots.set(key, slot);
81
+ return slot;
82
+ }
83
+
84
+ private publish(slot: { argumentId: string | null; raw: string }): void {
85
+ if (slot.argumentId !== null) this.values.set(slot.argumentId, slot.raw);
86
+ }
87
+
88
+ observe(value: unknown): void {
89
+ if (!record(value)) return;
90
+ if (this.api.includes("responses")) {
91
+ const item = value["item"];
92
+ if (record(item) && (item["type"] === "function_call" || item["type"] === "custom_tool_call")) {
93
+ const key = this.slotKey(value["output_index"]);
94
+ if (key === null) return;
95
+ const id = item["call_id"], argumentId = typeof id === "string" ? `${id}|${String(item["id"])}` : null;
96
+ const slot = this.slot(key, id, argumentId, true);
97
+ if (item["type"] === "function_call") {
98
+ if (typeof item["arguments"] === "string") slot.raw = item["arguments"];
99
+ this.publish(slot);
100
+ }
101
+ }
102
+ const slot = typeof value["output_index"] === "number" ? this.slots.get(`index:${value["output_index"]}`) : undefined;
103
+ if (slot && value["type"] === "response.function_call_arguments.delta" && typeof value["delta"] === "string") slot.raw += value["delta"];
104
+ if (slot && value["type"] === "response.function_call_arguments.done" && typeof value["arguments"] === "string") slot.raw = value["arguments"];
105
+ if (slot && ["response.function_call_arguments.delta", "response.function_call_arguments.done"].includes(String(value["type"]))) this.publish(slot);
106
+ return;
107
+ }
108
+ if (this.api === "anthropic-messages") {
109
+ const block = value["content_block"];
110
+ const index = value["index"];
111
+ if (record(block) && block["type"] === "tool_use") {
112
+ const key = this.slotKey(index);
113
+ if (key === null) return;
114
+ const id = block["id"], slot = this.slot(key, id, typeof id === "string" ? id : null, true);
115
+ if (slot.argumentId !== null) this.values.set(slot.argumentId, JSON.stringify(block["input"] ?? {}));
116
+ }
117
+ const slot = typeof index === "number" ? this.slots.get(`index:${index}`) : undefined, delta = value["delta"];
118
+ if (slot && record(delta) && delta["type"] === "input_json_delta" && typeof delta["partial_json"] === "string") {
119
+ slot.raw += delta["partial_json"];
120
+ this.publish(slot);
121
+ }
122
+ return;
123
+ }
124
+ // Mistral's native adapter uses the same completions stream vocabulary.
125
+ if (this.api !== "openai-completions" && this.api !== "mistral-conversations") return;
126
+ const choice = Array.isArray(value["choices"]) ? value["choices"][0] : undefined;
127
+ const delta = record(choice) && record(choice["delta"]) ? choice["delta"] : undefined;
128
+ if (!delta || !Array.isArray(delta["tool_calls"])) return;
129
+ // One native delta contains one entry per slot. Continuations may reuse a
130
+ // slot in later chunks, but merging two entries here could execute neither
131
+ // original declaration's arguments under a silently collapsed identity.
132
+ const chunkSlots = new Set<string>();
133
+ for (const call of delta["tool_calls"]) {
134
+ if (!record(call)) continue;
135
+ const id = call["id"], key = this.slotKey(call["index"], id);
136
+ if (key === null) continue;
137
+ if (chunkSlots.has(key)) { this.faults.add("native stream chunk repeats a tool call slot"); continue; }
138
+ chunkSlots.add(key);
139
+ // Native continuation chunks may spell omitted IDs as null. An indexed
140
+ // slot retains its original identity; a first slot still needs a real ID.
141
+ const supplied = id !== null && id !== undefined;
142
+ const slot = this.slot(key, id, typeof id === "string" ? id : null, supplied);
143
+ if (!record(call["function"])) continue;
144
+ const fragment = call["function"]["arguments"];
145
+ if (typeof fragment === "string") slot.raw += fragment;
146
+ else if (this.api === "mistral-conversations" && fragment !== undefined) slot.raw += JSON.stringify(fragment);
147
+ this.publish(slot);
148
+ }
149
+ }
150
+
151
+ identityErrors(): string[] {
152
+ // D5: SDK block IDs may merge Completions calls or replace missing Mistral
153
+ // IDs. Only the protocol's original slots can prove one receipt per call.
154
+ const native = [...this.slots.values()].map(slot => slot.id);
155
+ const malformed = native.flatMap((id, index) => typeof id === "string" && !(this.api === "mistral-conversations" && id === "null")
156
+ ? [] : [`native call ID at slot ${index} must be a real string identity`]);
157
+ const calls: AssistantBlock[] = native.filter((id): id is string => typeof id === "string")
158
+ .map(call_id => ({ type: "tool_use", call_id, name: "", input: null }));
159
+ return [...this.faults, ...malformed, ...toolCallIdentityViolations(calls).map(violation => violation.detail)];
160
+ }
161
+ }
162
+
163
+ /** Complete usage survives a rejected response without making malformed calls durable conversation history. */
164
+ export class ProviderIntegrityError extends FailedResponseError {
165
+ readonly code = "invalid_tool_call_identity";
166
+ constructor(details: readonly string[], usage?: UsageSample) {
167
+ super(`Invalid provider tool call identity: ${details.join("; ")}`, usage);
168
+ this.name = "ProviderIntegrityError";
169
+ }
170
+ }
171
+
35
172
  /** Native envelopes keep their SDK-owned tools/system/signal when adding nested generation options. */
36
173
  function mergePayload(payload: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown> {
37
174
  const next = { ...payload };
@@ -109,13 +246,20 @@ export function piContext(frozen: FrozenPrefix, messages: readonly Message[], sp
109
246
  timestamp: 0, usage: structuredClone(EMPTY_USAGE), stopReason: content.some(block => block.type === "toolCall") ? "toolUse" : "stop" });
110
247
  } else {
111
248
  let content: Extract<PiMessage, { role: "user" }>["content"] = [];
249
+ let result: Extract<PiMessage, { role: "toolResult" }> | undefined;
112
250
  const flush = () => { if (content.length) out.push({ role: "user", content, timestamp: 0 }); content = []; };
113
251
  for (const block of message.content) {
114
252
  if (block.type === "tool_result") {
115
- flush(); out.push({ role: "toolResult", toolCallId: ids.get(block.tool_use_id) ?? block.tool_use_id, toolName: names.get(block.tool_use_id) ?? "tool",
116
- content: [{ type: "text", text: block.content }], isError: block.is_error ?? false, timestamp: 0 });
117
- } else if (block.type === "text") content.push({ type: "text", text: block.text });
118
- else if (block.type === "image") content.push({ type: "image", data: imageData(block), mimeType: block.image.mimeType });
253
+ flush(); result = { role: "toolResult", toolCallId: ids.get(block.tool_use_id) ?? block.tool_use_id, toolName: names.get(block.tool_use_id) ?? "tool",
254
+ content: [{ type: "text", text: block.content }], isError: block.is_error ?? false, timestamp: 0 };
255
+ out.push(result);
256
+ } else if (block.type === "text") { content.push({ type: "text", text: block.text }); result = undefined; }
257
+ else if (block.type === "image") {
258
+ // D5: the projection puts each image after its paired text receipt. Keep that ownership;
259
+ // Pi emits complete result batches before fallback user images when a protocol requires it.
260
+ const image = { type: "image" as const, data: imageData(block), mimeType: block.image.mimeType };
261
+ if (result) result.content.push(image); else content.push(image);
262
+ }
119
263
  }
120
264
  flush();
121
265
  }
@@ -126,7 +270,7 @@ export function piContext(frozen: FrozenPrefix, messages: readonly Message[], sp
126
270
  }) };
127
271
  }
128
272
 
129
- export function piBlocks(message: AssistantMessage): AssistantBlock[] {
273
+ export function piBlocks(message: AssistantMessage, originalArguments: ReadonlyMap<string, string> = new Map()): AssistantBlock[] {
130
274
  return message.content.map((block): AssistantBlock => {
131
275
  switch (block.type) {
132
276
  case "text": return { type: "text", text: block.text, ...(block.textSignature ? { wire: { textSignature: block.textSignature } } : {}) };
@@ -142,7 +286,8 @@ export function piBlocks(message: AssistantMessage): AssistantBlock[] {
142
286
  }
143
287
  return { type: "thinking", text: block.thinking, raw };
144
288
  }
145
- case "toolCall": return { type: "tool_use", call_id: block.id, name: block.name, input: structuredClone(block.arguments),
289
+ case "toolCall": return { type: "tool_use", call_id: block.id, name: block.name,
290
+ input: originalArguments.has(block.id) ? completedToolInput(originalArguments.get(block.id)!) : structuredClone(block.arguments),
146
291
  ...(block.thoughtSignature || block.namespace ? { wire: { pi: { thoughtSignature: block.thoughtSignature, namespace: block.namespace } } } : {}) };
147
292
  default: return assertNever(block, "pi response block");
148
293
  }
@@ -153,7 +298,8 @@ export class PiProvider implements Provider {
153
298
  readonly config: ProviderConfig;
154
299
  private readonly apiKey: string;
155
300
  private readonly options: {
156
- fetch?: typeof fetch; effects?: ResolveEffects; oauth?: boolean; retry?: RetryPolicy; onRetry?: (attempt: number, delayMs: number, error: unknown) => void;
301
+ fetch?: typeof fetch; effects?: ResolveEffects; oauth?: boolean; sessionId?: string;
302
+ retry?: RetryPolicy; onRetry?: (attempt: number, delayMs: number, error: unknown) => void;
157
303
  };
158
304
  constructor(config: ProviderConfig, apiKey: string, options: PiProvider["options"] = {}) {
159
305
  this.config = config; this.apiKey = apiKey; this.options = options;
@@ -179,10 +325,18 @@ export class PiProvider implements Provider {
179
325
  return response;
180
326
  }) as typeof fetch;
181
327
  const nativeHttp = ["google-generative-ai", "google-vertex", "bedrock-converse-stream"].includes(model.api);
328
+ const originalArguments = new OriginalToolArguments(model.api);
329
+ let hasKnownUsage = false;
182
330
  const options: SimpleStreamOptions = { ...request.options, ...(request.key ? { apiKey: request.key } : {}),
331
+ // Cache routing follows a root session and its forks without introducing server transcript state (D8).
332
+ ...(this.options.sessionId === undefined ? {} : { sessionId: this.options.sessionId }),
183
333
  env: request.env, headers: request.headers, ...(nativeHttp ? {} : { fetch: observedFetch }), maxRetries: 0, transport: "sse",
184
334
  maxTokens: this.config.maxTokens, ...(this.config.effort !== "off" ? { reasoning: this.config.effort } : {}),
185
335
  ...(signal ? { signal } : {}), onResponse: response => { status = response.status; responseHeaders = new Headers(response.headers); },
336
+ onProviderStreamEvent: event => {
337
+ originalArguments.observe(event);
338
+ hasKnownUsage ||= nativeUsageReported(model.api, event);
339
+ },
186
340
  onPayload: payload => {
187
341
  if (!record(payload)) return payload;
188
342
  const next = mergePayload(payload, request.body);
@@ -226,10 +380,13 @@ export class PiProvider implements Provider {
226
380
  }
227
381
  signal?.throwIfAborted();
228
382
  if (!completed || completed.stopReason === "deferred") throw new StreamError("Provider did not complete a synchronous agent turn");
383
+ const usage: UsageSample = { input_tokens: completed.usage.input, output_tokens: completed.usage.output,
384
+ cache_read_input_tokens: completed.usage.cacheRead, cache_creation_input_tokens: completed.usage.cacheWrite, node, ms: Date.now() - started };
385
+ const identityErrors = originalArguments.identityErrors();
386
+ if (identityErrors.length) throw new ProviderIntegrityError(identityErrors, hasKnownUsage ? usage : undefined);
229
387
  // The SDK has already classified native STOP/end_turn/toolUse/length values. The loop consumes
230
388
  // xeno's closed completion vocabulary, never a raw native spelling that could skip tool execution.
231
- return { blocks: piBlocks(completed), stopReason: completed.stopReason === "length" ? "max_tokens" : completed.stopReason === "toolUse" ? "tool_use" : "end_turn",
232
- usage: { input_tokens: completed.usage.input, output_tokens: completed.usage.output, cache_read_input_tokens: completed.usage.cacheRead,
233
- cache_creation_input_tokens: completed.usage.cacheWrite, node, ms: Date.now() - started } };
389
+ return { blocks: piBlocks(completed, originalArguments.values), stopReason: completed.stopReason === "length" ? "max_tokens" : completed.stopReason === "toolUse" ? "tool_use" : "end_turn",
390
+ usage };
234
391
  }
235
392
  }
@@ -0,0 +1,38 @@
1
+ import { encodeProgramStatus, PROGRAM_STATUS_QUERY, programStatusReply, type ProgramStatus } from "../core/program-status.ts";
2
+
3
+ /** Terminal transport only. Capability detection has no relationship to agent evidence. */
4
+ export class ProgramStatusReporter {
5
+ private readonly write: (text: string) => void;
6
+ private readonly id: string;
7
+ private readonly mode: "auto" | "on" | "off";
8
+ private supported = false;
9
+ private started = false;
10
+ private closed = false;
11
+ private last: ProgramStatus = { state: "idle" };
12
+ private emitted = "";
13
+ constructor(options: { write: (text: string) => void; id: string; tty: boolean; mode?: string; multiplexed?: boolean }) {
14
+ this.write = options.write; this.id = options.id;
15
+ this.mode = !options.tty || options.mode === "0" ? "off" : options.mode === "1" ? "on" : options.multiplexed ? "off" : "auto";
16
+ }
17
+ start(): void {
18
+ if (this.started || this.closed) return; this.started = true;
19
+ if (this.mode === "auto") this.write(PROGRAM_STATUS_QUERY);
20
+ if (this.mode === "on") { this.supported = true; this.flush(); }
21
+ }
22
+ receive(sequence: string): void {
23
+ if (this.closed || !this.started || this.mode !== "auto" || !programStatusReply(sequence)) return;
24
+ this.supported = true; this.flush();
25
+ }
26
+ update(status: ProgramStatus): void { this.last = { ...status }; if (this.started) this.flush(); }
27
+ private flush(): void {
28
+ if (this.closed || !this.supported) return;
29
+ const sequence = encodeProgramStatus(this.last, this.id);
30
+ if (sequence !== this.emitted) { this.emitted = sequence; this.write(sequence); }
31
+ }
32
+ close(): void {
33
+ if (this.closed) return;
34
+ // done/error are durable terminal records. Clear only our own transient record.
35
+ if (this.supported && this.last.state !== "done" && this.last.state !== "error") this.write(encodeProgramStatus({ state: "clear" }, this.id));
36
+ this.closed = true;
37
+ }
38
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The `models.json` edit `/login` makes for a connected provider, as a pure transform.
2
+ * The `models.json` edit `/provider` makes for a connected provider, as a pure transform.
3
3
  *
4
4
  * The file stays the single source of model configuration and keeps the
5
5
  * documented `providers.<id>` shape (docs/providers.md), so a connected
@@ -97,7 +97,7 @@ export function applyConnect(json: unknown, request: ConnectRequest, catalog: re
97
97
  return root;
98
98
  }
99
99
 
100
- /** Remove what `/login` wrote for a provider; other providers and flat rows are untouched. */
100
+ /** Remove what `/provider` wrote for a provider; other providers and flat rows are untouched. */
101
101
  export function applyDisconnect(json: unknown, id: string): Record<string, unknown> {
102
102
  const root: Record<string, unknown> = Array.isArray(json) ? { models: json } : record(json) ? { ...json } : {};
103
103
  if (record(root["providers"])) {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * What `/login` offers for custom endpoints before the user types anything.
2
+ * What `/provider` offers for custom endpoints before the user types anything.
3
3
  *
4
4
  * A preset names a catalog provider (whose API, base URL and model metadata
5
5
  * already ship in model-data) or a custom endpoint family. Order is the
@@ -1,7 +1,7 @@
1
1
  import { providerEnvironment, resolveKeyValue, type ResolveEffects } from "./auth.ts";
2
2
  import { providerAccess } from "./provider-access.ts";
3
3
  import type { ModelSpec } from "./model.ts";
4
- import { XENO_VERSION } from "./oauth.ts";
4
+ import { XENO_VERSION, GROK_PROVIDER, GROK_BASE_URL } from "./oauth.ts";
5
5
 
6
6
  const SDK_IDENTITY_HEADERS = ["x-app", "editor-version", "editor-plugin-version", "copilot-integration-id", "http-referer", "x-title"];
7
7
 
@@ -40,6 +40,12 @@ export function providerRequest(spec: ModelSpec, key: string, effects: ResolveEf
40
40
  if (baseUrl && !/^https?:\/\//.test(baseUrl)) throw new Error(`${spec.provider} baseUrl must use http or https`);
41
41
  const headers: Record<string, string | null> = { ...spec.defaultHeaders };
42
42
  for (const [name, value] of Object.entries(access.headers ?? {})) headers[name] = value === null ? null : resolve(value, `headers.${name}`);
43
+ if (spec.provider === GROK_PROVIDER) {
44
+ if (baseUrl.replace(/\/$/, "") !== GROK_BASE_URL) throw new Error("Grok account tokens require the official inference proxy");
45
+ // These select the auth protocol and backend; the User-Agent still identifies xeno (D18).
46
+ headers["X-XAI-Token-Auth"] = "xai-grok-cli";
47
+ headers["x-grok-model-override"] = spec.wireId ?? spec.id;
48
+ }
43
49
  const anthropicOAuth = spec.api === "anthropic-messages" && spec.provider !== "github-copilot" && (oauth || key.includes("sk-ant-oat"));
44
50
  const noAutomaticAuth = access.auth === false || access.authHeader === false || typeof access.authHeader === "string" || anthropicOAuth;
45
51
  if (noAutomaticAuth && (spec.api === "google-generative-ai" || spec.api === "google-vertex")) {
@@ -63,11 +69,16 @@ export function providerRequest(spec: ModelSpec, key: string, effects: ResolveEf
63
69
  const privateKey = noAutomaticAuth ? "xeno-keyless-local" : key || undefined;
64
70
  const transport = (async (input: RequestInfo | URL, init?: RequestInit) => {
65
71
  const request = new Request(input, init);
72
+ const grokAccount = spec.provider === GROK_PROVIDER;
73
+ // Check the SDK's final target too, and never forward an account token through a redirect.
74
+ if (grokAccount && new URL(request.url).origin !== new URL(GROK_BASE_URL).origin) {
75
+ throw new Error("Grok account tokens require the official inference proxy");
76
+ }
66
77
  const nextHeaders = new Headers(request.headers);
67
78
  if (noAutomaticAuth) for (const name of ["authorization", "x-api-key", "api-key", "x-goog-api-key"]) nextHeaders.delete(name);
68
79
  for (const [name, value] of Object.entries(headers)) { if (value === null) nextHeaders.delete(name); else nextHeaders.set(name, value); }
69
80
  clientIdentity(nextHeaders);
70
- return fetchImpl(new Request(request, { headers: nextHeaders }));
81
+ return fetchImpl(new Request(request, { headers: nextHeaders, ...(grokAccount ? { redirect: "error" } : {}) }));
71
82
  }) as typeof fetch;
72
83
  return { baseUrl, env, headers, fetch: transport, options: access.options ?? {}, body: access.body ?? {}, ...(privateKey ? { key: privateKey } : {}) };
73
84
  }
@@ -144,13 +144,24 @@ export interface Turn {
144
144
  usage: UsageSample;
145
145
  }
146
146
 
147
+ /** D11: a rejected response can still prove usage; arbitrary errors cannot prove a billable receipt. */
148
+ export class FailedResponseError extends Error {
149
+ readonly usage: UsageSample | undefined;
150
+
151
+ constructor(message: string, usage?: UsageSample) {
152
+ super(message);
153
+ this.name = "FailedResponseError";
154
+ this.usage = usage === undefined ? undefined : { ...usage };
155
+ }
156
+ }
157
+
147
158
  /** Thrown for a refusal, which arrives as HTTP 200 rather than an error. */
148
- export class RefusalError extends Error {
159
+ export class RefusalError extends FailedResponseError {
149
160
  readonly category: string | null;
150
161
  readonly explanation: string | null;
151
162
 
152
- constructor(category: string | null, explanation: string | null) {
153
- super(`model refused the request${category ? ` (${category})` : ""}`);
163
+ constructor(category: string | null, explanation: string | null, usage?: UsageSample) {
164
+ super(`model refused the request${category ? ` (${category})` : ""}`, usage);
154
165
  this.name = "RefusalError";
155
166
  this.category = category;
156
167
  this.explanation = explanation;
@@ -283,6 +294,7 @@ export class AnthropicProvider implements Provider {
283
294
  blocks: response.content.map(fromWireBlock),
284
295
  stopReason: response.stop_reason,
285
296
  stopDetails: response.stop_details ?? null,
297
+ usageKnown: true,
286
298
  usage: {
287
299
  input_tokens: response.usage.input_tokens,
288
300
  output_tokens: response.usage.output_tokens,
@@ -290,23 +302,20 @@ export class AnthropicProvider implements Provider {
290
302
  cache_read_input_tokens: response.usage.cache_read_input_tokens ?? 0,
291
303
  },
292
304
  };
305
+ const usage = { ...assembled.usage, node, ms: Date.now() - started };
293
306
 
294
307
  // A refusal is HTTP 200 with stop_reason "refusal", so content must not be
295
308
  // trusted after it. Anything already painted has to go with it.
296
309
  if (assembled.stopReason === "refusal") {
297
310
  onDelta?.({ kind: "discard" });
298
311
  const details = assembled.stopDetails;
299
- throw new RefusalError(details?.category ?? null, details?.explanation ?? null);
312
+ throw new RefusalError(details?.category ?? null, details?.explanation ?? null, assembled.usageKnown ? usage : undefined);
300
313
  }
301
314
 
302
315
  return {
303
316
  blocks: assembled.blocks,
304
317
  stopReason: assembled.stopReason,
305
- usage: {
306
- ...assembled.usage,
307
- node,
308
- ms: Date.now() - started,
309
- },
318
+ usage,
310
319
  };
311
320
  }
312
321
  }
@@ -326,6 +335,8 @@ interface AssembledTurn {
326
335
  blocks: AssistantBlock[];
327
336
  stopReason: string | null;
328
337
  stopDetails: { category?: string | null; explanation?: string | null } | null;
338
+ /** Provider receipt presence distinguishes a measured zero from absent usage (D11). */
339
+ usageKnown: boolean;
329
340
  usage: {
330
341
  input_tokens: number;
331
342
  output_tokens: number;
@@ -382,6 +393,7 @@ async function consumeAnthropicStream(
382
393
  blocks: [],
383
394
  stopReason: null,
384
395
  stopDetails: null,
396
+ usageKnown: false,
385
397
  usage: {
386
398
  input_tokens: 0,
387
399
  output_tokens: 0,
@@ -411,6 +423,7 @@ async function consumeAnthropicStream(
411
423
  switch (event.type) {
412
424
  case "message_start":
413
425
  takeUsage(assembled.usage, event.message?.usage);
426
+ if (event.message?.usage !== undefined) assembled.usageKnown = true;
414
427
  break;
415
428
  case "content_block_start": {
416
429
  const index = event.index ?? content.length;
@@ -456,6 +469,7 @@ async function consumeAnthropicStream(
456
469
  assembled.stopDetails = event.delta.stop_details;
457
470
  }
458
471
  takeUsage(assembled.usage, event.usage);
472
+ if (event.usage !== undefined) assembled.usageKnown = true;
459
473
  break;
460
474
  }
461
475
  default:
@@ -0,0 +1,198 @@
1
+ /** Q24: one controller per live Session, shared by the TUI and structured adapters. */
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { resolve } from "node:path";
4
+ import { goalView, contextLens, type GoalView, type ContextLens } from "../core/control-view.ts";
5
+ import { DEFAULT_CONTEXT_POLICY } from "../core/context-policy.ts";
6
+ import { userInputData, type UserInput } from "../core/images.ts";
7
+ import type { Phase } from "../core/queue.ts";
8
+ import type { PermissionRequest } from "../core/permissions.ts";
9
+ import type { ExecutionOutcome } from "../core/outcome.ts";
10
+ import type { LogEvent } from "../core/events.ts";
11
+ import { readNodeHistory } from "../store/restore.ts";
12
+ import type { Session, LoopEvent } from "./loop.ts";
13
+ import type { ApprovalChoice } from "./tools.ts";
14
+
15
+ export type ControlNotice = { method: string; params: { threadId: string; streamId: string; sequence: number; turnId?: string; [key: string]: unknown } };
16
+ export type PendingApproval = { id: string; turnId: string; request: PermissionRequest };
17
+ export type ControlSnapshot = {
18
+ version: "xeno-control/0.1"; threadId: string; streamId: string; sequence: number; node: number;
19
+ activeTurnId: string | null; goal: GoalView | null; context: ContextLens;
20
+ waiting: "permission" | "question" | "auth" | null; approvals: PendingApproval[];
21
+ };
22
+
23
+ export class SessionControl {
24
+ readonly session: Session;
25
+ readonly threadId: string;
26
+ readonly streamId = randomUUID();
27
+ private sequence = 0;
28
+ private listeners = new Set<(event: ControlNotice) => void>();
29
+ private active: { id: string; abort: AbortController; queue: UserInput[] } | null = null;
30
+ private task: Promise<void> | null = null;
31
+ private approvals = new Map<string, { data: PendingApproval; resolve: (choice: ApprovalChoice) => void }>();
32
+ private waiting: ControlSnapshot["waiting"] = null;
33
+ private changing = false;
34
+ private inputSource: ((phase: Phase) => Promise<UserInput | null>) | undefined;
35
+
36
+ constructor(session: Session) {
37
+ this.session = session;
38
+ // Same identity rule as provider affinity; no new durable identity store (D8).
39
+ this.threadId = createHash("sha256").update(resolve(session.config.logDir)).digest("hex");
40
+ session.subscribeRecords(item => this.emit("item/completed", { item }));
41
+ }
42
+ get activeTurnId(): string | null { return this.active?.id ?? null; }
43
+ get busy(): boolean { return this.changing || this.active !== null || this.session.running; }
44
+
45
+ subscribe(listener: (event: ControlNotice) => void): () => void {
46
+ this.listeners.add(listener); return () => { this.listeners.delete(listener); };
47
+ }
48
+ /** The attached editor owns retractable drafts even when an adapter starts the turn. */
49
+ attachInput(pull: (phase: Phase) => Promise<UserInput | null>): () => void {
50
+ if (this.inputSource) throw Error("the session already has an attached input owner");
51
+ this.inputSource = pull;
52
+ return () => { if (this.inputSource === pull) this.inputSource = undefined; };
53
+ }
54
+ private emit(method: string, data: Record<string, unknown> = {}): void {
55
+ const event: ControlNotice = { method, params: { ...data, threadId: this.threadId, streamId: this.streamId,
56
+ sequence: ++this.sequence, ...(this.active ? { turnId: this.active.id } : {}) } };
57
+ for (const listener of this.listeners) {
58
+ // Observers are outside the evidence owner: mutations and failures cannot alter execution.
59
+ try { listener(structuredClone(event)); } catch { /* the transport owns delivery errors */ }
60
+ }
61
+ }
62
+ async snapshot(observe = true): Promise<ControlSnapshot> {
63
+ if (observe && !this.busy) await this.session.acceptanceState();
64
+ const node = this.session.state.node;
65
+ const history = await readNodeHistory(this.session.config.logDir, node, undefined, "context");
66
+ if (!history.ok) throw Error(history.error);
67
+ if (node !== this.session.state.node) throw Error("session changed while reading; refresh the snapshot");
68
+ const s = this.session;
69
+ // Ancestors are sealed. The live segment must share the cursor's publication
70
+ // boundary: a disk read can finish between append and in-memory publication.
71
+ const parts = history.nodes.map(part => part.node === node ? { node, events: structuredClone(s.state.events) } : part);
72
+ const projectedGoal = goalView(parts);
73
+ const goal = this.active && projectedGoal?.state.request.event !== this.active.id ? null : projectedGoal;
74
+ return { version: "xeno-control/0.1", threadId: this.threadId, streamId: this.streamId, sequence: this.sequence, node,
75
+ activeTurnId: this.activeTurnId, goal, waiting: this.waiting,
76
+ approvals: structuredClone([...this.approvals.values()].map(item => item.data)),
77
+ context: contextLens({ ...s.contextInput(), anchor: s.state.anchor, policy: s.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
78
+ contextWindow: s.provider.config.spec.contextWindow, outputReserve: s.provider.config.maxTokens,
79
+ ...(s.config.counter ? { counter: s.config.counter } : {}), ...(s.config.boundary ? { boundary: s.config.boundary } : {}) }) };
80
+ }
81
+ async items(): Promise<{ node: number; event: LogEvent }[]> {
82
+ const node = this.session.state.node;
83
+ const history = await readNodeHistory(this.session.config.logDir, node, undefined, "context");
84
+ if (!history.ok) throw Error(history.error);
85
+ if (node !== this.session.state.node) throw Error("session changed while reading; refresh the snapshot");
86
+ return history.nodes.flatMap(part => (part.node === node ? structuredClone(this.session.state.events) : part.events)
87
+ .map(event => ({ node: part.node, event })));
88
+ }
89
+
90
+ /** Both local and remote turns pass through this iterator; Session remains the execution owner. */
91
+ async *send(input: UserInput, signal?: AbortSignal, pull?: (phase: Phase) => Promise<UserInput | null>): AsyncGenerator<LoopEvent> {
92
+ if (this.busy) throw Error("a turn is already running");
93
+ const active = { id: this.session.clock.nextId(), abort: new AbortController(), queue: [] as UserInput[] };
94
+ this.active = active;
95
+ const signals = signal ? AbortSignal.any([signal, active.abort.signal]) : active.abort.signal;
96
+ let outcome: ExecutionOutcome | undefined;
97
+ try {
98
+ this.emit("turn/started");
99
+ const steering = async (phase: Phase): Promise<UserInput | null> => {
100
+ const external = active.queue.splice(0), local = await (pull ?? this.inputSource)?.(phase);
101
+ if (local) external.push(local);
102
+ const data = external.map(value => userInputData(value));
103
+ const text = data.map(value => value.text).join("\n\n"), images = data.flatMap(value => value.images ?? []);
104
+ return images.length ? { text, images } : text || null;
105
+ };
106
+ for await (const event of this.session.send(input, signals, steering, active.id)) {
107
+ this.emit("turn/event", { event });
108
+ if (event.kind === "done") outcome = event.outcome;
109
+ yield event;
110
+ }
111
+ } catch (error) {
112
+ // Raw provider errors may contain credentials. The owner can report a sanitized error separately.
113
+ this.emit("turn/error", { message: "turn failed; inspect the session outcome" });
114
+ throw error;
115
+ } finally {
116
+ for (const approval of [...this.approvals.values()]) approval.resolve("deny");
117
+ this.waiting = null;
118
+ // A thrown provider error also appends an outcome. Clients must be able to discover it.
119
+ try {
120
+ if (!outcome) {
121
+ const state = (await this.snapshot(false)).goal?.state;
122
+ if (state?.request.event === active.id) outcome = state.execution;
123
+ }
124
+ } catch { /* unreadable storage stays unknown, never an invented completion */ }
125
+ this.emit("turn/completed", { ...(outcome ? { outcome } : {}), steeringUndelivered: active.queue.length });
126
+ this.active = null;
127
+ }
128
+ }
129
+ start(input: UserInput): string {
130
+ if (this.busy) throw Error("a turn is already running");
131
+ this.task = (async () => { for await (const _event of this.send(input)) { /* notices are the client stream */ } })();
132
+ void this.task.catch(() => {});
133
+ return this.active!.id;
134
+ }
135
+ async whenIdle(): Promise<void> { await this.task; await this.session.whenIdle(); }
136
+ private current(turnId: string): NonNullable<SessionControl["active"]> {
137
+ if (!this.active || this.active.id !== turnId) throw Error("stale or unknown turnId");
138
+ return this.active;
139
+ }
140
+ steer(turnId: string, input: UserInput): void {
141
+ const turn = this.current(turnId);
142
+ // Assumed ingress bound, not an execution policy. Keep a client from queuing unbounded work.
143
+ if (turn.queue.length >= 32) throw Error("steering queue is full");
144
+ turn.queue.push(structuredClone(input));
145
+ this.emit("turn/steeringQueued", { count: turn.queue.length });
146
+ }
147
+ interrupt(turnId: string): void {
148
+ this.current(turnId).abort.abort();
149
+ for (const approval of [...this.approvals.values()]) approval.resolve("deny");
150
+ this.session.cancel();
151
+ }
152
+ setWaiting(kind: ControlSnapshot["waiting"]): void {
153
+ this.waiting = this.approvals.size ? "permission" : kind;
154
+ this.emit("thread/statusChanged", { waiting: this.waiting });
155
+ }
156
+ ask(request: PermissionRequest, present?: (id: string, resolve: (choice: ApprovalChoice) => void) => (() => void)): Promise<ApprovalChoice> {
157
+ if (!this.active) return Promise.resolve("deny");
158
+ const data = { id: randomUUID(), turnId: this.active.id, request: structuredClone(request) };
159
+ return new Promise(resolveChoice => {
160
+ let close: (() => void) | undefined;
161
+ const resolveOnce = (choice: ApprovalChoice) => {
162
+ if (!this.approvals.delete(data.id)) return;
163
+ close?.(); this.waiting = this.approvals.size ? "permission" : null;
164
+ this.emit("approval/resolved", { approvalId: data.id }); resolveChoice(choice);
165
+ };
166
+ this.approvals.set(data.id, { data, resolve: resolveOnce }); this.waiting = "permission";
167
+ this.emit("approval/request", { approval: data });
168
+ if (this.approvals.has(data.id)) {
169
+ try { close = present?.(data.id, resolveOnce); }
170
+ catch { resolveOnce("deny"); }
171
+ }
172
+ });
173
+ }
174
+ respond(turnId: string, id: string, choice: ApprovalChoice): void {
175
+ this.current(turnId);
176
+ const approval = this.approvals.get(id);
177
+ if (!approval || approval.data.turnId !== turnId) throw Error("stale or unknown approvalId");
178
+ approval.resolve(choice);
179
+ }
180
+ denyApprovals(): void { for (const approval of [...this.approvals.values()]) approval.resolve("deny"); }
181
+ async accept(requestId: string, criterion: string): Promise<void> {
182
+ if (this.busy) throw Error("acceptance requires an idle session");
183
+ this.changing = true;
184
+ try {
185
+ const state = await this.session.requestState();
186
+ if (state?.request.event !== requestId) throw Error("stale or unknown requestId");
187
+ await this.session.acceptCriterion(criterion);
188
+ this.emit("thread/statusChanged");
189
+ } finally { this.changing = false; }
190
+ }
191
+ }
192
+
193
+ const controls = new WeakMap<Session, SessionControl>();
194
+ export function sessionControl(session: Session): SessionControl {
195
+ let control = controls.get(session);
196
+ if (!control) { control = new SessionControl(session); controls.set(session, control); }
197
+ return control;
198
+ }