@visual-z/xeno 0.1.3 → 0.2.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 (148) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +143 -33
  3. package/docs/install.md +18 -6
  4. package/docs/providers.md +147 -0
  5. package/package.json +8 -2
  6. package/src/apps/cli.ts +42 -14
  7. package/src/apps/commands.ts +32 -4
  8. package/src/apps/completions.ts +9 -6
  9. package/src/apps/export-cli.ts +24 -0
  10. package/src/apps/extension-ui.ts +330 -0
  11. package/src/apps/input.ts +16 -1
  12. package/src/apps/model-catalog.ts +42 -0
  13. package/src/apps/project-trust.ts +49 -0
  14. package/src/apps/render.ts +1 -0
  15. package/src/apps/resource-browser.ts +75 -0
  16. package/src/apps/resources.ts +25 -0
  17. package/src/apps/session-browser.ts +113 -0
  18. package/src/apps/session-picker.ts +55 -0
  19. package/src/apps/shell.ts +1177 -378
  20. package/src/apps/startup.ts +400 -268
  21. package/src/core/action-defaults.ts +306 -0
  22. package/src/core/attachment-input.ts +17 -0
  23. package/src/core/catalog-merge.ts +19 -0
  24. package/src/core/event-validation.ts +19 -2
  25. package/src/core/events.ts +45 -2
  26. package/src/core/export.ts +6 -2
  27. package/src/core/extension-flags.ts +14 -0
  28. package/src/core/images.ts +46 -0
  29. package/src/core/json-event.ts +2 -0
  30. package/src/core/keymap.ts +43 -119
  31. package/src/core/model-replay.ts +22 -0
  32. package/src/core/model-scope.ts +53 -0
  33. package/src/core/packages.ts +47 -0
  34. package/src/core/progress.ts +9 -0
  35. package/src/core/project.ts +21 -3
  36. package/src/core/prompts.ts +33 -5
  37. package/src/core/queue.ts +18 -0
  38. package/src/core/resource-selection.ts +36 -0
  39. package/src/{runtime → core}/resume.ts +15 -22
  40. package/src/core/session-archive.ts +4 -0
  41. package/src/core/session-browser.ts +82 -0
  42. package/src/core/session-entry.ts +56 -0
  43. package/src/core/session-position.ts +26 -0
  44. package/src/core/settings.ts +169 -154
  45. package/src/core/theme.ts +15 -6
  46. package/src/core/thinking.ts +26 -0
  47. package/src/core/tokens.ts +2 -0
  48. package/src/core/transcript.ts +15 -5
  49. package/src/runtime/auth.ts +116 -11
  50. package/src/runtime/catalog.ts +54 -151
  51. package/src/runtime/clipboard.ts +114 -0
  52. package/src/runtime/extension-loader.ts +37 -0
  53. package/src/runtime/extension-ui.ts +55 -0
  54. package/src/runtime/extensions.ts +273 -0
  55. package/src/runtime/images.ts +76 -0
  56. package/src/runtime/loop.ts +213 -83
  57. package/src/runtime/model-config.ts +232 -0
  58. package/src/runtime/model-data.LICENSE +27 -0
  59. package/src/runtime/model-data.ts +21 -0
  60. package/src/runtime/model-replay.ts +10 -0
  61. package/src/runtime/model-scope.ts +20 -0
  62. package/src/runtime/model-sources.ts +151 -0
  63. package/src/runtime/model.ts +74 -11
  64. package/src/runtime/models-fetch.ts +6 -104
  65. package/src/runtime/oauth.ts +4 -3
  66. package/src/runtime/openai-responses.ts +12 -4
  67. package/src/runtime/openai.ts +13 -4
  68. package/src/runtime/package-manager.ts +125 -0
  69. package/src/runtime/pi-provider.ts +235 -0
  70. package/src/runtime/progress.ts +26 -0
  71. package/src/runtime/provider-access.ts +21 -0
  72. package/src/runtime/provider-protocols.ts +29 -0
  73. package/src/runtime/provider-request.ts +73 -0
  74. package/src/runtime/provider.ts +26 -6
  75. package/src/runtime/rich-text.ts +36 -0
  76. package/src/runtime/tools.ts +41 -5
  77. package/src/store/command.ts +7 -1
  78. package/src/store/context-files.ts +1 -1
  79. package/src/store/log.ts +65 -0
  80. package/src/store/models.ts +28 -9
  81. package/src/store/observations.ts +4 -2
  82. package/src/store/packages.ts +98 -0
  83. package/src/store/recovery.ts +8 -0
  84. package/src/store/resource-index.ts +170 -0
  85. package/src/store/resources.ts +75 -142
  86. package/src/store/session-archive.ts +162 -0
  87. package/src/store/session-browser.ts +68 -0
  88. package/src/store/session-position.ts +27 -0
  89. package/src/store/sessions.ts +87 -66
  90. package/src/store/trust.ts +13 -0
  91. package/src/store/workspace.ts +6 -0
  92. package/src/tui/chat-viewport.ts +47 -0
  93. package/src/tui/editor.ts +62 -15
  94. package/src/tui/host.ts +50 -9
  95. package/src/tui/input.ts +8 -2
  96. package/src/tui/select.ts +24 -6
  97. package/src/tui/settings.ts +4 -0
  98. package/src/tui/system-theme.ts +700 -0
  99. package/src/tui/transcript.ts +63 -11
  100. package/src/tui/tree.ts +161 -95
  101. package/src/vendor/pi-rich/LICENSE.upstream +21 -0
  102. package/src/vendor/pi-rich/UPSTREAM.json +17 -0
  103. package/src/vendor/pi-rich/clipboard-command.ts +44 -0
  104. package/src/vendor/pi-rich/exif-orientation.ts +182 -0
  105. package/src/vendor/pi-rich/html.ts +51 -0
  106. package/src/vendor/pi-rich/image-convert.ts +49 -0
  107. package/src/vendor/pi-rich/image-resize-core.ts +164 -0
  108. package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
  109. package/src/vendor/pi-rich/image-resize.ts +123 -0
  110. package/src/vendor/pi-rich/mime.ts +116 -0
  111. package/src/vendor/pi-rich/photon.ts +139 -0
  112. package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
  113. package/src/vendor/pi-tui/UPSTREAM.json +50 -0
  114. package/src/vendor/pi-tui/VENDOR.md +7 -5
  115. package/src/vendor/pi-tui/autocomplete.ts +35 -10
  116. package/src/vendor/pi-tui/colors.ts +367 -0
  117. package/src/vendor/pi-tui/components/box.ts +8 -9
  118. package/src/vendor/pi-tui/components/editor.ts +4 -2
  119. package/src/vendor/pi-tui/components/markdown.ts +8 -2
  120. package/src/vendor/pi-tui/index.ts +31 -1
  121. package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
  122. package/src/vendor/pi-tui/native/clipboard.h +208 -0
  123. package/src/vendor/pi-tui/native/darwin/README.md +22 -0
  124. package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
  125. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  126. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  127. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
  128. package/src/vendor/pi-tui/native/linux/README.md +23 -0
  129. package/src/vendor/pi-tui/native/linux/build.sh +65 -0
  130. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  131. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  132. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
  133. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
  134. package/src/vendor/pi-tui/native/napi.h +91 -0
  135. package/src/vendor/pi-tui/native/win32/README.md +31 -0
  136. package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
  137. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  138. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  139. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
  140. package/src/vendor/pi-tui/native-platform.ts +2 -0
  141. package/src/vendor/pi-tui/oklab.ts +233 -0
  142. package/src/vendor/pi-tui/terminal-colors.ts +26 -8
  143. package/src/vendor/pi-tui/terminal-image.ts +43 -8
  144. package/src/vendor/pi-tui/terminal.ts +16 -9
  145. package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
  146. package/src/vendor/pi-tui/tui.ts +112 -75
  147. package/src/vendor/pi-tui/utils.ts +89 -49
  148. package/src/vendor/pi-tui/wheel-scroll.ts +82 -0
@@ -0,0 +1,73 @@
1
+ import { providerEnvironment, resolveKeyValue, type ResolveEffects } from "./auth.ts";
2
+ import { providerAccess } from "./provider-access.ts";
3
+ import type { ModelSpec } from "./model.ts";
4
+ import { XENO_VERSION } from "./oauth.ts";
5
+
6
+ const SDK_IDENTITY_HEADERS = ["x-app", "editor-version", "editor-plugin-version", "copilot-integration-id", "http-referer", "x-title"];
7
+
8
+ /** D18/D16: a protocol SDK may not silently identify xeno as a different product. */
9
+ function clientIdentity(headers: Headers): void {
10
+ headers.set("User-Agent", `xeno/${XENO_VERSION}`);
11
+ for (const name of SDK_IDENTITY_HEADERS) headers.delete(name);
12
+ if (headers.has("originator")) headers.set("originator", "xeno");
13
+ const beta = headers.get("anthropic-beta");
14
+ if (beta) headers.set("anthropic-beta", beta.split(",").filter(value => !value.trim().startsWith("claude-code-")).join(","));
15
+ }
16
+
17
+ export function requestEffects(): ResolveEffects {
18
+ return { env: name => process.env[name], run: command => {
19
+ const result = Bun.spawnSync(["bash", "-lc", command]);
20
+ if (result.exitCode !== 0) throw new Error("credential command failed");
21
+ return new TextDecoder().decode(result.stdout);
22
+ } };
23
+ }
24
+
25
+ export function providerRequest(spec: ModelSpec, key: string, effects: ResolveEffects = requestEffects(), fetchImpl: typeof fetch = globalThis.fetch, oauth = false): {
26
+ baseUrl: string; env: Record<string, string>; headers: Record<string, string | null>; fetch: typeof fetch;
27
+ options: Record<string, unknown>; body: Record<string, unknown>; key?: string;
28
+ } {
29
+ const access = providerAccess(spec);
30
+ if (access.invalid) throw new Error(access.invalid);
31
+ const scoped = providerEnvironment(spec, effects);
32
+ const env = scoped.env;
33
+ const resolve = (value: string, field: string) => {
34
+ const resolved = resolveKeyValue(value, scoped.effects);
35
+ if (!resolved.ok) throw new Error(`${spec.provider} ${field}: could not resolve configured value`);
36
+ return resolved.key;
37
+ };
38
+ let baseUrl = spec.baseUrl ?? (spec.api === "anthropic-messages" ? "https://api.anthropic.com" : "");
39
+ if (baseUrl) baseUrl = resolve(baseUrl, "baseUrl");
40
+ if (baseUrl && !/^https?:\/\//.test(baseUrl)) throw new Error(`${spec.provider} baseUrl must use http or https`);
41
+ const headers: Record<string, string | null> = { ...spec.defaultHeaders };
42
+ for (const [name, value] of Object.entries(access.headers ?? {})) headers[name] = value === null ? null : resolve(value, `headers.${name}`);
43
+ const anthropicOAuth = spec.api === "anthropic-messages" && spec.provider !== "github-copilot" && (oauth || key.includes("sk-ant-oat"));
44
+ const noAutomaticAuth = access.auth === false || access.authHeader === false || typeof access.authHeader === "string" || anthropicOAuth;
45
+ if (noAutomaticAuth && (spec.api === "google-generative-ai" || spec.api === "google-vertex")) {
46
+ throw new Error(`${spec.api} owns API-key authentication; use apiKey or an OpenAI-compatible local endpoint`);
47
+ }
48
+ if (noAutomaticAuth && spec.api === "bedrock-converse-stream") env["AWS_BEDROCK_SKIP_AUTH"] = "1";
49
+ if (typeof access.authHeader === "string") headers[access.authHeader] = key;
50
+ if (anthropicOAuth && access.auth !== false && access.authHeader !== false) {
51
+ headers["authorization"] = `Bearer ${key}`;
52
+ headers["anthropic-beta"] = "oauth-2025-04-20";
53
+ }
54
+ // Apply the same identity to native SDKs which own their HTTP stack.
55
+ const identity = new Headers();
56
+ for (const [name, value] of Object.entries(headers)) if (value !== null) identity.set(name, value);
57
+ clientIdentity(identity);
58
+ for (const name of Object.keys(headers)) if (!identity.has(name)) headers[name] = null;
59
+ identity.forEach((value, name) => { headers[name] = value; });
60
+ delete headers["user-agent"];
61
+ headers["User-Agent"] = `xeno/${XENO_VERSION}`;
62
+ // SDKs still require a key to construct local clients. The placeholder never leaves this boundary.
63
+ const privateKey = noAutomaticAuth ? "xeno-keyless-local" : key || undefined;
64
+ const transport = (async (input: RequestInfo | URL, init?: RequestInit) => {
65
+ const request = new Request(input, init);
66
+ const nextHeaders = new Headers(request.headers);
67
+ if (noAutomaticAuth) for (const name of ["authorization", "x-api-key", "api-key", "x-goog-api-key"]) nextHeaders.delete(name);
68
+ for (const [name, value] of Object.entries(headers)) { if (value === null) nextHeaders.delete(name); else nextHeaders.set(name, value); }
69
+ clientIdentity(nextHeaders);
70
+ return fetchImpl(new Request(request, { headers: nextHeaders }));
71
+ }) as typeof fetch;
72
+ return { baseUrl, env, headers, fetch: transport, options: access.options ?? {}, body: access.body ?? {}, ...(privateKey ? { key: privateKey } : {}) };
73
+ }
@@ -32,6 +32,7 @@
32
32
 
33
33
  import Anthropic from "@anthropic-ai/sdk";
34
34
  import type { Message, ContentBlock, FrozenPrefix } from "../core/project.ts";
35
+ import { imageData } from "./images.ts";
35
36
  import type { AssistantBlock } from "../core/events.ts";
36
37
  import { assertNever } from "../core/exhaustive.ts";
37
38
  import type { UsageSample } from "./usage.ts";
@@ -89,6 +90,8 @@ export const DEFAULT_PROVIDER: ProviderConfig = {
89
90
  export type StreamDelta =
90
91
  | { kind: "text"; text: string }
91
92
  | { kind: "thinking"; text: string }
93
+ | { kind: "tool"; id: string; name: string; arguments: string }
94
+ | { kind: "retry"; attempt: number; delayMs: number }
92
95
  | { kind: "discard" };
93
96
 
94
97
  export type OnDelta = (delta: StreamDelta) => void;
@@ -156,13 +159,14 @@ export class RefusalError extends Error {
156
159
 
157
160
  function buildAnthropicClient(
158
161
  apiKey: string | undefined,
159
- options: { fetch?: typeof fetch; oauth?: boolean },
162
+ options: { fetch?: typeof fetch; oauth?: boolean; baseUrl?: string },
160
163
  ): AnthropicTransport {
161
164
  if (options.oauth === true && apiKey) {
162
165
  // authToken is Bearer. Passing apiKey here would send x-api-key, which
163
166
  // an OAuth token is not accepted as.
164
167
  return new Anthropic({
165
168
  maxRetries: 0,
169
+ ...(options.baseUrl ? { baseURL: options.baseUrl } : {}),
166
170
  authToken: apiKey,
167
171
  defaultHeaders: anthropicOAuthHeaders(),
168
172
  ...(options.fetch ? { fetch: options.fetch } : {}),
@@ -170,6 +174,7 @@ function buildAnthropicClient(
170
174
  }
171
175
  return new Anthropic({
172
176
  maxRetries: 0,
177
+ ...(options.baseUrl ? { baseURL: options.baseUrl } : {}),
173
178
  ...(apiKey ? { apiKey } : {}),
174
179
  ...(options.fetch ? { fetch: options.fetch } : {}),
175
180
  });
@@ -206,7 +211,7 @@ export class AnthropicProvider implements Provider {
206
211
  // it, so the usage report would quietly stop adding up. The SDK timer
207
212
  // also ignores AbortSignal, so an outer wait is the only one Escape
208
213
  // can actually cancel.
209
- this.client = options.client ?? buildAnthropicClient(apiKey, options);
214
+ this.client = options.client ?? buildAnthropicClient(apiKey, { ...options, ...(config.spec.baseUrl ? { baseUrl: config.spec.baseUrl } : {}) });
210
215
  this.retry = options.retry ?? DEFAULT_RETRY;
211
216
  this.onRetry = options.onRetry;
212
217
  }
@@ -226,6 +231,7 @@ export class AnthropicProvider implements Provider {
226
231
  // The next attempt starts from empty. Without this the UI concatenates
227
232
  // two openings of the same sentence and has no sign a retry happened.
228
233
  onDelta?.({ kind: "discard" });
234
+ onDelta?.({ kind: "retry", attempt, delayMs });
229
235
  this.onRetry?.(attempt, delayMs, error);
230
236
  },
231
237
  signal,
@@ -262,8 +268,9 @@ export class AnthropicProvider implements Provider {
262
268
  // Anthropic-compatible endpoint serving some other model, answers
263
269
  // them with a 400 or silently ignores them.
264
270
  ...(this.config.spec.reasoning
265
- ? { thinking: { type: "adaptive" as const, display: this.config.thinkingDisplay },
266
- output_config: { effort: this.config.effort } }
271
+ ? this.config.effort === "off" ? { thinking: { type: "disabled" as const } }
272
+ : { thinking: { type: "adaptive" as const, display: this.config.thinkingDisplay },
273
+ output_config: { effort: anthropicEffort(this.config) } }
267
274
  : {}),
268
275
  messages: messages.map(toWireMessage),
269
276
  },
@@ -304,6 +311,13 @@ export class AnthropicProvider implements Provider {
304
311
  }
305
312
  }
306
313
 
314
+ /** Pi maps minimal to low for adaptive thinking; off is a thinking switch, never an effort string. */
315
+ function anthropicEffort(config: ProviderConfig): "low" | "medium" | "high" | "xhigh" | "max" {
316
+ const level = config.spec.thinkingLevelMap?.[config.effort] ?? config.effort;
317
+ return level === "minimal" || level === "off" ? "low" :
318
+ level === "low" || level === "medium" || level === "high" || level === "xhigh" || level === "max" ? level : "high";
319
+ }
320
+
307
321
  function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
308
322
  return typeof value === "object" && value !== null && Symbol.asyncIterator in value;
309
323
  }
@@ -402,7 +416,10 @@ async function consumeAnthropicStream(
402
416
  const index = event.index ?? content.length;
403
417
  if (event.content_block !== undefined) {
404
418
  content[index] = { ...event.content_block } as Anthropic.ContentBlock;
405
- if (event.content_block.type === "tool_use") jsonBuf.set(index, "");
419
+ if (event.content_block.type === "tool_use") {
420
+ jsonBuf.set(index, "");
421
+ onDelta?.({ kind: "tool", id: event.content_block.id, name: event.content_block.name, arguments: "" });
422
+ }
406
423
  }
407
424
  break;
408
425
  }
@@ -421,6 +438,7 @@ async function consumeAnthropicStream(
421
438
  block.signature = (block.signature ?? "") + delta.signature;
422
439
  } else if (delta.type === "input_json_delta" && delta.partial_json) {
423
440
  jsonBuf.set(index, (jsonBuf.get(index) ?? "") + delta.partial_json);
441
+ if (block.type === "tool_use") onDelta?.({ kind: "tool", id: block.id, name: block.name, arguments: jsonBuf.get(index)! });
424
442
  }
425
443
  break;
426
444
  }
@@ -452,12 +470,14 @@ async function consumeAnthropicStream(
452
470
  function toWireMessage(m: Message): Anthropic.MessageParam {
453
471
  return {
454
472
  role: m.role,
455
- content: m.content.map(toWireBlock) as Anthropic.ContentBlockParam[],
473
+ // Anthropic requires all parallel tool results before any text or image in the user message.
474
+ content: [...m.content.filter(b => b.type === "tool_result"), ...m.content.filter(b => b.type !== "tool_result")].map(toWireBlock) as Anthropic.ContentBlockParam[],
456
475
  };
457
476
  }
458
477
 
459
478
  function toWireBlock(b: ContentBlock): unknown {
460
479
  switch (b.type) {
480
+ case "image": return { type: "image", source: { type: "base64", media_type: b.image.mimeType, data: imageData(b) } };
461
481
  case "text":
462
482
  return { type: "text", text: b.text };
463
483
  case "thinking":
@@ -0,0 +1,36 @@
1
+ /** Ported from Pi (MIT), theme.ts and components/mermaid.ts at 02eed88; only presentation, never log rewriting. */
2
+ import { Marked } from "marked";
3
+ import { render } from "grok-mermaid";
4
+ import { highlight, supportsLanguage, loadAllHighlightLanguages } from "../vendor/pi-rich/syntax-highlight.ts";
5
+
6
+ type Paint = (token: string, text: string) => string;
7
+ export const warmSyntaxLanguages = loadAllHighlightLanguages;
8
+ export function highlightCode(code: string, language: string | undefined, paint: Paint): string[] {
9
+ if (!language || !supportsLanguage(language)) return code.split("\n").map(line => paint("mdCodeBlock", line));
10
+ const tokens: Record<string, string> = { keyword: "Keyword", built_in: "Function", title: "Function", function: "Function",
11
+ string: "String", regexp: "String", number: "Number", literal: "Number", type: "Type", class: "Type",
12
+ variable: "Variable", params: "Variable", attr: "Variable", attribute: "Variable", symbol: "Variable",
13
+ comment: "Comment", meta: "Comment", operator: "Operator", punctuation: "Punctuation" };
14
+ try { return highlight(code, { language, ignoreIllegals: true,
15
+ theme: Object.fromEntries(Object.entries(tokens).map(([scope, token]) => [scope, (text: string) => paint("syntax" + token, text)])),
16
+ }).split("\n"); } catch { return code.split("\n"); }
17
+ }
18
+ const parser = new Marked();
19
+ function codeSpan(line: string): string {
20
+ const content = line || "\u00a0";
21
+ const longest = Math.max(0, ...Array.from(content.matchAll(/`+/g), match => match[0].length));
22
+ const fence = "`".repeat(longest + 1), padding = content.startsWith("`") || content.endsWith("`") ? " " : "";
23
+ return `${fence}${padding}${content}${padding}${fence}`;
24
+ }
25
+ export function transformMermaid(markdown: string, width: number, mode: "off" | "final" | "streaming", streaming: boolean, paint: Paint): string {
26
+ if (mode === "off" || streaming && mode !== "streaming") return markdown;
27
+ try { return parser.lexer(markdown).map(token => {
28
+ if (token.type !== "code" || token.lang?.trim().split(/\s+/, 1)[0]?.toLowerCase() !== "mermaid") return token.raw;
29
+ // Incomplete stream fences remain source; rendering half a graph would flicker and lose error context.
30
+ if (streaming && !/\n\s*(`{3,}|~{3,})\s*$/.test(token.raw.trimEnd())) return token.raw;
31
+ const art = render(token.text);
32
+ if (!art || art.width > width || art.warnings.length) return token.raw;
33
+ const tokens: Record<string, string> = { border: "borderMuted", text: "text", edge: "accent", edgeLabel: "muted", title: "accent" };
34
+ return art.styled.map(row => codeSpan(row.map(span => paint(tokens[span.cls] ?? "text", span.text)).join(""))).join(" \n") + "\n";
35
+ }).join(""); } catch { return markdown; }
36
+ }
@@ -25,6 +25,7 @@ import { createHash } from "node:crypto";
25
25
  import { dirname, resolve, relative, isAbsolute, join, sep } from "node:path";
26
26
  import type { Effect, LineRange, ArtifactRef } from "../core/events.ts";
27
27
  import { archiveObservation, type ArchivedObservation } from "../store/observations.ts";
28
+ import { blobAddress } from "../store/blobs.ts";
28
29
  import { commandHeader, headTailView } from "../core/observation-view.ts";
29
30
  import {
30
31
  DEFAULT_YIELD_MS, MAX_YIELD_MS, MIN_YIELD_MS, findSession, runningSessions, startSession, takeOutput, waitSession,
@@ -51,10 +52,15 @@ import {
51
52
  type PermissionRule,
52
53
  } from "../core/permissions.ts";
53
54
  import type { ToolSchema } from "./provider.ts";
55
+ import type { ImageRef } from "../core/images.ts";
56
+ import { imageDescription } from "../core/images.ts";
57
+ import { attachImageBytes } from "./images.ts";
58
+ import { detectSupportedImageMimeType } from "../vendor/pi-rich/mime.ts";
54
59
  import { find as findFiles, grep, ls } from "../store/search.ts";
55
60
  import { scanWorkspace, workspaceVersion, DEFAULT_IGNORE } from "../store/workspace.ts";
56
61
 
57
62
  export interface ToolResult {
63
+ images?: readonly ImageRef[];
58
64
  /** What the model sees. */
59
65
  content: string;
60
66
  ok: boolean;
@@ -70,6 +76,16 @@ export interface ToolResult {
70
76
  }
71
77
 
72
78
  export interface ToolContext {
79
+ /** Session-owned registrations never mutate the built-in table (D13). */
80
+ extensions?: ReadonlyMap<string, ToolDefinition>;
81
+ callId?: string;
82
+ extensionDepth?: number;
83
+ extensionHooks?: {
84
+ before(name: string, input: unknown, id: string | undefined): Promise<string | undefined>;
85
+ event(type: string, fields: Record<string, unknown>): Promise<void>;
86
+ };
87
+ imageStore?: string;
88
+ imageAutoResize?: boolean;
73
89
  /** O.1: per-execution copy; never mutate the session's shared tool context with a live signal. */
74
90
  signal?: AbortSignal;
75
91
  /** Absolute path. Every tool path is confined to this subtree. */
@@ -99,6 +115,7 @@ export interface ToolContext {
99
115
  /** Tool output above this is truncated; the marker says how much was cut. */
100
116
  maxOutputBytes: number;
101
117
  outputStore?: string;
118
+ onOutput?: (chunk: Uint8Array) => void;
102
119
  /**
103
120
  * First-match permission rules. Absent means every call is allowed, which
104
121
  * is what the existing tests rely on: they exercise the tool, not the gate.
@@ -193,6 +210,7 @@ export interface ToolDefinition extends ToolSchema {
193
210
  branch: "read" | "write" | "none";
194
211
  /** Accepted by admission but not advertised to the model: harness and test switches. */
195
212
  hiddenInputs?: readonly string[];
213
+ prepareArguments?: (input: unknown) => unknown;
196
214
  run?: (ctx: ToolContext, input: Record<string, unknown>) => Promise<ToolResult>;
197
215
  }
198
216
 
@@ -525,8 +543,8 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
525
543
  /** Runtime policy metadata never leaks into provider-specific wire schemas. */
526
544
  export const TOOL_SCHEMAS: ToolSchema[] = TOOL_DEFINITIONS.map(({ name, description, input_schema }) => ({ name, description, input_schema }));
527
545
 
528
- export function toolDefinition(name: string): ToolDefinition | undefined {
529
- return TOOL_DEFINITIONS.find(tool => tool.name === name);
546
+ export function toolDefinition(name: string, ctx?: ToolContext): ToolDefinition | undefined {
547
+ return ctx?.extensions?.get(name) ?? TOOL_DEFINITIONS.find(tool => tool.name === name);
530
548
  }
531
549
 
532
550
  // ---------------------------------------------------------------------------
@@ -560,6 +578,10 @@ async function toolRead(ctx: ToolContext, input: Record<string, unknown>): Promi
560
578
  return { content: `cannot read ${path}: ${(err as Error).message}`, ok: false };
561
579
  }
562
580
 
581
+ if (ctx.imageStore && detectSupportedImageMimeType(bytes)) {
582
+ const image = await attachImageBytes(bytes, path, ctx.imageStore, ctx.imageAutoResize ?? true);
583
+ return { ok: true, content: imageDescription(image), images: [image], effect: { kind: "read", path, sha256: blobAddress(bytes) } };
584
+ }
563
585
  if (bytes.subarray(0, 8_192).includes(0)) {
564
586
  return { ok: false, content: `${path} looks binary (${bytes.length} bytes, contains NUL); inspect it with bash, e.g. \`file ${path}\` or \`xxd ${path} | head\`` };
565
587
  }
@@ -695,6 +717,7 @@ async function toolBash(ctx: ToolContext, input: Record<string, unknown>): Promi
695
717
  outputStore: ctx.outputStore ?? join(ctx.root, ".xeno", "observations"),
696
718
  maxOutputBytes: ctx.maxOutputBytes, timeoutMs,
697
719
  ...(ctx.signal ? { signal: ctx.signal } : {}),
720
+ ...(ctx.onOutput ? { onOutput: ctx.onOutput } : {}),
698
721
  });
699
722
 
700
723
  const gate = matchGate(ctx, command);
@@ -933,7 +956,7 @@ export async function gateTool(
933
956
  ok: false,
934
957
  };
935
958
  }
936
- const definition = toolDefinition(name);
959
+ const definition = toolDefinition(name, ctx);
937
960
  input = repairToolInput(name, input);
938
961
  const invalid = definition ? inputError(definition.input_schema, input, "input", definition.hiddenInputs) : null;
939
962
  if (invalid) return { ok: false, content: invalid, diagnostic: { code: "invalid_input", message: invalid } };
@@ -995,14 +1018,27 @@ export async function runTool(
995
1018
 
996
1019
  /** O.5: ordered admission returns a capability for this exact call, never a policy bypass flag. */
997
1020
  export async function prepareTool(ctx: ToolContext, name: string, input: unknown): Promise<ToolResult | (() => Promise<ToolResult>)> {
1021
+ try { input = toolDefinition(name, ctx)?.prepareArguments?.(input) ?? input; }
1022
+ catch (error) { return finishToolResult({ ok: false, content: `Invalid ${name} arguments: ${String(error)}` }); }
998
1023
  const gated = await gateTool(ctx, name, input);
999
1024
  if (gated) return finishToolResult(gated);
1000
- return () => dispatchTool(ctx, name, input);
1025
+ const veto = await ctx.extensionHooks?.before(name, input, ctx.callId);
1026
+ if (veto) return finishToolResult({ ok: false, content: veto });
1027
+ return async () => {
1028
+ await ctx.extensionHooks?.event("tool_execution_start", { toolName: name, toolCallId: ctx.callId, args: input });
1029
+ const result = await dispatchTool({ ...ctx, onOutput: chunk => {
1030
+ ctx.onOutput?.(chunk);
1031
+ void ctx.extensionHooks?.event("tool_execution_update", { toolName: name, toolCallId: ctx.callId, partialResult: { content: [{ type: "text", text: new TextDecoder().decode(chunk) }] } });
1032
+ } }, name, input);
1033
+ await ctx.extensionHooks?.event("tool_result", { toolName: name, toolCallId: ctx.callId, input, content: [{ type: "text", text: result.content }], isError: !result.ok, details: result.data });
1034
+ await ctx.extensionHooks?.event("tool_execution_end", { toolName: name, toolCallId: ctx.callId, result, isError: !result.ok });
1035
+ return result;
1036
+ };
1001
1037
  }
1002
1038
 
1003
1039
  async function dispatchTool(ctx: ToolContext, name: string, input: unknown): Promise<ToolResult> {
1004
1040
  if (ctx.signal?.aborted) return finishToolResult(cancelledTool());
1005
- const impl = toolDefinition(name)?.run;
1041
+ const impl = toolDefinition(name, ctx)?.run;
1006
1042
  if (!impl) {
1007
1043
  const near = closestName(name, TOOL_DEFINITIONS.filter((tool) => tool.run !== undefined).map((tool) => tool.name));
1008
1044
  return finishToolResult({ content: `unknown tool: ${name}` + (near === undefined ? "" : `; did you mean ${near}?`), ok: false });
@@ -31,7 +31,13 @@ export async function observeCommand(input: {
31
31
  maxOutputBytes: number;
32
32
  timeoutMs: number;
33
33
  signal?: AbortSignal;
34
+ onOutput?: (chunk: Uint8Array) => void;
34
35
  }) {
36
+ if (input.signal?.aborted) {
37
+ const empty = () => new ReadableStream<Uint8Array>({ start: controller => controller.close() });
38
+ const captured = await captureCommandObservation(input.outputStore, empty(), empty(), Promise.resolve(130), input.maxOutputBytes);
39
+ return { ...captured, wallMs: 0, stopped: "cancelled" as const };
40
+ }
35
41
  const started = performance.now();
36
42
  const proc = Bun.spawn(["bash", "-o", "pipefail", "-lc", input.command], {
37
43
  cwd: input.cwd, stdout: "pipe", stderr: "pipe", detached: true, env: commandEnvironment(process.env),
@@ -59,7 +65,7 @@ export async function observeCommand(input: {
59
65
  if (input.signal?.aborted) abort();
60
66
  try {
61
67
  const captured = await captureCommandObservation(input.outputStore, proc.stdout, proc.stderr,
62
- proc.exited, input.maxOutputBytes);
68
+ proc.exited, input.maxOutputBytes, input.onOutput);
63
69
  return { ...captured, wallMs: performance.now() - started, ...(stopped ? { stopped } : {}) };
64
70
  } catch (error) {
65
71
  kill("SIGKILL");
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * Re-reading these files at a node boundary would change `system` and
17
17
  * invalidate the prefix cache for the whole conversation (D6). They are
18
- * frozen at startup; a reload is a cut, and is a later card.
18
+ * frozen at startup; an explicit reload checkpoints a changed prefix.
19
19
  */
20
20
 
21
21
  import { readFile, stat } from "node:fs/promises";
package/src/store/log.ts CHANGED
@@ -36,6 +36,7 @@ import { isLogEvent, isRecord, validNodeRef } from "../core/event-validation.ts"
36
36
  import { emitHandoff } from "../core/handoff.ts";
37
37
  import { atomicWrite, durableAppend } from "./atomic.ts";
38
38
  import { serialized } from "./serial.ts";
39
+ import { stateAtPosition } from "../core/session-position.ts";
39
40
 
40
41
  /** Allocation number. Not an ordering over the tree. */
41
42
  export type NodeId = number;
@@ -110,6 +111,14 @@ export interface Cursor {
110
111
  * this rather than discovering it on the wire.
111
112
  */
112
113
  model: string;
114
+ /** New cursors disambiguate identical model IDs served by different providers. */
115
+ provider?: string;
116
+ /** Configuration takes effect after this committed event; absent means node start. */
117
+ at_event?: string;
118
+ thinking?: string;
119
+ blob_dir?: string;
120
+ /** Legacy historical positions with unknown model provenance must cut before dispatch. */
121
+ needs_model_checkpoint?: boolean;
113
122
  updated_at: IsoTime;
114
123
  /** Active checkout, which changes when a writing branch becomes mainline. */
115
124
  workspace?: string;
@@ -126,8 +135,12 @@ export interface Cursor {
126
135
  export interface Manifest {
127
136
  version: 1;
128
137
  nodes: NodeRecord[];
138
+ /** An import keeps historical bytes and relocates their storage through journal metadata. */
139
+ relocated?: true;
129
140
  /** Absent in a log written before resume existed, and in an empty one. */
130
141
  cursor?: Cursor;
142
+ parent_session?: string;
143
+ labels?: Record<string, { text: string; at: string }>;
131
144
  }
132
145
 
133
146
  /**
@@ -500,7 +513,10 @@ async function readLegacyManifest(dir: string): Promise<Manifest> {
500
513
 
501
514
  type MetadataRecord =
502
515
  | { kind: "seed"; manifest: Manifest }
516
+ | { kind: "relocate"; workspace: string }
503
517
  | { kind: "cursor"; cursor: Cursor }
518
+ | { kind: "parent_session"; path: string }
519
+ | { kind: "label"; target: string; text: string; at: string }
504
520
  | { kind: "node"; record: NodeRecord };
505
521
 
506
522
  function metadataPath(dir: string): string { return join(dir, "metadata.jsonl"); }
@@ -521,6 +537,22 @@ async function readMetadata(dir: string): Promise<MetadataRecord[] | null> {
521
537
  });
522
538
  }
523
539
 
540
+ /** The metadata journal already owns configuration history; no second state log is needed. */
541
+ export async function readCursorAt(dir: string, node: number, atEvent?: string): Promise<Cursor | null> {
542
+ const records = await readMetadata(dir);
543
+ const legacy = records === null ? await readLegacyManifest(dir) : undefined;
544
+ const states: Cursor[] = legacy?.cursor ? [legacy.cursor] : [];
545
+ let relocated = legacy?.relocated === true;
546
+ for (const record of records ?? []) {
547
+ if (record.kind === "cursor") states.push({ ...record.cursor });
548
+ else if (record.kind === "seed") { if (record.manifest.cursor) states.push({ ...record.manifest.cursor }); relocated ||= record.manifest.relocated === true; }
549
+ else if (record.kind === "relocate") { relocated = true; for (const state of states) state.workspace = record.workspace; }
550
+ }
551
+ if (relocated) for (const state of states) state.blob_dir = join(dir, "blobs");
552
+ const { events } = await readNode(dir, node);
553
+ return stateAtPosition(states, node, events.map(event => event.id), atEvent);
554
+ }
555
+
524
556
  /** D3: the replaceable manifest is a projection of committed metadata events. */
525
557
  export async function readManifest(dir: string): Promise<Manifest> {
526
558
  return serialized(dir, () => readManifestUnlocked(dir));
@@ -534,6 +566,16 @@ async function readManifestUnlocked(dir: string): Promise<Manifest> {
534
566
  switch (event.kind) {
535
567
  case "seed": manifest = structuredClone(event.manifest); break;
536
568
  case "cursor": manifest.cursor = event.cursor; break;
569
+ case "relocate":
570
+ manifest.relocated = true;
571
+ if (manifest.cursor) manifest.cursor = { ...manifest.cursor, workspace: event.workspace };
572
+ break;
573
+ case "parent_session": manifest.parent_session = event.path; break;
574
+ case "label":
575
+ manifest.labels ??= {};
576
+ if (event.text) Object.defineProperty(manifest.labels, event.target, { value: { text: event.text, at: event.at }, enumerable: true, configurable: true, writable: true });
577
+ else delete manifest.labels[event.target];
578
+ break;
537
579
  case "node": {
538
580
  const i = manifest.nodes.findIndex((n) => n.node === event.record.node);
539
581
  if (i < 0) manifest.nodes.push(event.record);
@@ -544,9 +586,18 @@ async function readManifestUnlocked(dir: string): Promise<Manifest> {
544
586
  }
545
587
  }
546
588
  manifest.nodes.sort((a, b) => a.node - b.node);
589
+ if (manifest.relocated && manifest.cursor) manifest.cursor = { ...manifest.cursor, blob_dir: join(dir, "blobs") };
547
590
  return manifest;
548
591
  }
549
592
 
593
+ /** Relocation changes recovery locations, not historical events or evidence (D3/D8). */
594
+ export async function relocateSession(dir: string, workspace: string): Promise<void> {
595
+ return serialized(dir, async () => {
596
+ await appendMetadata(dir, { kind: "relocate", workspace });
597
+ await atomicWrite(manifestPath(dir), JSON.stringify(await readManifestUnlocked(dir), null, 2) + "\n");
598
+ });
599
+ }
600
+
550
601
  async function appendMetadata(dir: string, event: MetadataRecord): Promise<void> {
551
602
  await mkdir(dir, { recursive: true });
552
603
  const encode = (record: MetadataRecord) => JSON.stringify({
@@ -589,6 +640,20 @@ export async function readCursor(dir: string): Promise<Cursor | null> {
589
640
  return (await readManifest(dir)).cursor ?? null;
590
641
  }
591
642
 
643
+ /** Labels are display metadata, never model evidence or rewritten conversation events. */
644
+ export async function setSessionLabel(dir: string, target: string, text: string, at: string): Promise<void> {
645
+ return serialized(dir, async () => {
646
+ await appendMetadata(dir, { kind: "label", target, text: text.trim(), at });
647
+ await atomicWrite(manifestPath(dir), JSON.stringify(await readManifestUnlocked(dir), null, 2) + "\n");
648
+ });
649
+ }
650
+ export async function setParentSession(dir: string, path: string): Promise<void> {
651
+ return serialized(dir, async () => {
652
+ await appendMetadata(dir, { kind: "parent_session", path });
653
+ await atomicWrite(manifestPath(dir), JSON.stringify(await readManifestUnlocked(dir), null, 2) + "\n");
654
+ });
655
+ }
656
+
592
657
  /** Direct children of `node`, in allocation order. */
593
658
  export function childrenOf(manifest: Manifest, node: NodeId): NodeRecord[] {
594
659
  return manifest.nodes.filter((r) => r.parent?.node === node);
@@ -8,23 +8,35 @@
8
8
  * forbids.
9
9
  */
10
10
 
11
- import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
11
+ import { mkdir, readFile, rename, writeFile, rm } from "node:fs/promises";
12
+ import { randomUUID } from "node:crypto";
12
13
  import { join } from "node:path";
13
14
  import { configDir } from "./config.ts";
14
15
 
15
16
  export function modelsPath(): string {
16
17
  return join(configDir(), "models.json");
17
18
  }
19
+ export function catalogCachePath(): string { return join(configDir(), "models-cache.json"); }
18
20
 
19
21
  export async function readModelsText(): Promise<{ text: string | null; error: string | null }> {
22
+ return readModelFile(modelsPath());
23
+ }
24
+ export async function readCatalogCacheText(): Promise<{ text: string | null; error: string | null }> {
25
+ return readModelFile(catalogCachePath());
26
+ }
27
+ async function readModelFile(path: string): Promise<{ text: string | null; error: string | null }> {
20
28
  try {
21
- const text = await readFile(modelsPath(), "utf8");
29
+ const text = await readFile(path, "utf8");
22
30
  return { text, error: null };
23
- } catch {
24
- // Absent is the normal first-run state, not an error.
25
- return { text: null, error: null };
31
+ } catch (err) {
32
+ const absent = typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
33
+ return { text: null, error: absent ? null : `cannot read ${path}` };
26
34
  }
27
35
  }
36
+ export async function readModelSources(): Promise<{ userText: string | null; cacheText: string | null; readWarnings: string[] }> {
37
+ const [user, cache] = await Promise.all([readModelsText(), readCatalogCacheText()]);
38
+ return { userText: user.text, cacheText: cache.text, readWarnings: [user.error, cache.error].filter((value): value is string => value !== null) };
39
+ }
28
40
 
29
41
  /**
30
42
  * Replace, don't truncate. writeFile on the live path empties it first, so a
@@ -33,9 +45,16 @@ export async function readModelsText(): Promise<{ text: string | null; error: st
33
45
  * existing bytes untouched (feat-json-mode-and-header).
34
46
  */
35
47
  export async function writeModelsFile(value: unknown): Promise<void> {
48
+ return writeModelFile(modelsPath(), value);
49
+ }
50
+ export async function writeCatalogCache(value: unknown): Promise<void> {
51
+ return writeModelFile(catalogCachePath(), value);
52
+ }
53
+ async function writeModelFile(dest: string, value: unknown): Promise<void> {
36
54
  await mkdir(configDir(), { recursive: true });
37
- const dest = modelsPath();
38
- const tmp = `${dest}.tmp`;
39
- await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8");
40
- await rename(tmp, dest);
55
+ const tmp = `${dest}.${randomUUID()}.tmp`;
56
+ try {
57
+ await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
58
+ await rename(tmp, dest);
59
+ } finally { await rm(tmp, { force: true }); }
41
60
  }
@@ -52,6 +52,7 @@ const EXIT_DRAIN_MS = 1_000;
52
52
  */
53
53
  async function spoolMerged(
54
54
  dir: string, streams: readonly ReadableStream<Uint8Array>[], exited: Promise<number>,
55
+ onOutput?: (chunk: Uint8Array) => void,
55
56
  ): Promise<string> {
56
57
  await mkdir(dir, { recursive: true });
57
58
  const path = join(dir, `.${randomUUID()}.stream`);
@@ -74,7 +75,7 @@ async function spoolMerged(
74
75
  const result = await reader.read();
75
76
  if (result.done) return;
76
77
  armIdle();
77
- queue = queue.then(() => file.writeFile(result.value));
78
+ queue = queue.then(async () => { await file.writeFile(result.value); onOutput?.(result.value); });
78
79
  await queue;
79
80
  }
80
81
  }));
@@ -165,8 +166,9 @@ export async function archiveObservation(
165
166
  export async function captureCommandObservation(
166
167
  dir: string, stdout: ReadableStream<Uint8Array>, stderr: ReadableStream<Uint8Array>,
167
168
  exited: Promise<number>, previewBytes: number,
169
+ onOutput?: (chunk: Uint8Array) => void,
168
170
  ): Promise<ArchivedObservation & { exit: number; prefixBytes: number }> {
169
- const merged = await spoolMerged(dir, [stdout, stderr], exited);
171
+ const merged = await spoolMerged(dir, [stdout, stderr], exited, onOutput);
170
172
  try {
171
173
  const exit = await exited;
172
174
  const prefix = new TextEncoder().encode(`exit ${exit}\n`);