@visual-z/xeno 0.2.1 → 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 (282) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +126 -34
  3. package/docs/app-server.md +84 -0
  4. package/docs/install.md +51 -13
  5. package/docs/providers.md +24 -3
  6. package/package.json +7 -5
  7. package/src/apps/app-server.ts +28 -0
  8. package/src/apps/approve.ts +3 -3
  9. package/src/apps/cli.ts +25 -10
  10. package/src/apps/commands.ts +45 -8
  11. package/src/apps/completions.ts +78 -22
  12. package/src/apps/control-transport.ts +89 -0
  13. package/src/apps/image-draft.ts +46 -0
  14. package/src/apps/model-catalog.ts +2 -1
  15. package/src/apps/project-trust.ts +2 -42
  16. package/src/apps/provider-connect.ts +346 -0
  17. package/src/apps/render.ts +22 -47
  18. package/src/apps/resources.ts +5 -2
  19. package/src/apps/shell.ts +333 -1542
  20. package/src/apps/startup-input.ts +45 -9
  21. package/src/apps/startup.ts +149 -94
  22. package/src/apps/ui/app.ts +1553 -0
  23. package/src/apps/ui/control-model.ts +26 -0
  24. package/src/apps/ui/extension-host.ts +194 -0
  25. package/src/apps/ui/image-cache.ts +78 -0
  26. package/src/apps/ui/palette.ts +88 -0
  27. package/src/apps/ui/project.ts +127 -0
  28. package/src/apps/ui/standalone.ts +146 -0
  29. package/src/apps/ui/status-model.ts +68 -0
  30. package/src/apps/ui/surface.ts +86 -0
  31. package/src/apps/ui/theme.ts +86 -0
  32. package/src/core/acceptance.ts +107 -0
  33. package/src/core/action-defaults.ts +47 -295
  34. package/src/core/benchmark.ts +2 -2
  35. package/src/core/branch-receipt.ts +24 -0
  36. package/src/core/checkpoints.ts +64 -0
  37. package/src/core/churn.ts +2 -0
  38. package/src/core/command-artifacts.ts +94 -0
  39. package/src/core/command-contract.ts +35 -0
  40. package/src/core/completion-action.ts +20 -0
  41. package/src/core/completion-review.ts +37 -21
  42. package/src/core/context-detail.ts +37 -0
  43. package/src/core/context-policy.ts +74 -1
  44. package/src/core/continuation.ts +17 -2
  45. package/src/core/control-protocol.ts +62 -0
  46. package/src/core/control-view.ts +42 -0
  47. package/src/core/derive.ts +29 -3
  48. package/src/core/draft-images.ts +56 -0
  49. package/src/core/environment.ts +145 -0
  50. package/src/core/evaluation-coverage.ts +74 -0
  51. package/src/core/event-validation.ts +81 -6
  52. package/src/core/events.ts +99 -3
  53. package/src/core/evidence-links.ts +7 -0
  54. package/src/core/evidence.ts +13 -3
  55. package/src/core/execution.ts +8 -2
  56. package/src/core/experiment.ts +133 -0
  57. package/src/core/export.ts +9 -0
  58. package/src/core/failure-recovery.ts +173 -0
  59. package/src/core/file-precondition.ts +72 -0
  60. package/src/core/handoff.ts +65 -4
  61. package/src/core/harness-diagnostics.ts +206 -0
  62. package/src/core/history-query.ts +38 -2
  63. package/src/core/history.ts +38 -1
  64. package/src/core/integration-health.ts +34 -0
  65. package/src/core/keymap.ts +8 -20
  66. package/src/core/model-picker.ts +64 -0
  67. package/src/core/native-eval.ts +25 -0
  68. package/src/core/note-guidance.ts +34 -0
  69. package/src/core/oauth-fields.ts +44 -0
  70. package/src/core/observation-view.ts +49 -8
  71. package/src/core/permissions.ts +24 -2
  72. package/src/core/process-receipt.ts +105 -0
  73. package/src/core/process-state.ts +37 -0
  74. package/src/core/program-status.ts +31 -0
  75. package/src/core/project.ts +70 -19
  76. package/src/core/prompt-policy.ts +37 -0
  77. package/src/core/query-eval-contract.ts +14 -0
  78. package/src/core/read-observation.ts +82 -0
  79. package/src/core/recovery-corpus.ts +17 -0
  80. package/src/core/recovery-evaluation.ts +60 -0
  81. package/src/core/recovery-sources.ts +74 -0
  82. package/src/core/request-progress.ts +87 -0
  83. package/src/core/request-state.ts +114 -0
  84. package/src/core/runtime-target.ts +94 -0
  85. package/src/core/search-observation.ts +97 -0
  86. package/src/core/session-tree.ts +147 -0
  87. package/src/core/settings.ts +33 -10
  88. package/src/core/skills.ts +44 -0
  89. package/src/core/sql-query.ts +211 -0
  90. package/src/core/theme.ts +36 -58
  91. package/src/core/tool-contract.ts +51 -2
  92. package/src/core/tool-discovery.ts +87 -0
  93. package/src/core/tool-failure.ts +60 -0
  94. package/src/core/tool-input.ts +6 -0
  95. package/src/core/tool-mode-protocol.ts +25 -0
  96. package/src/core/transcript.ts +19 -4
  97. package/src/core/trial-lifecycle.ts +14 -0
  98. package/src/core/tui-mode.ts +29 -0
  99. package/src/core/update-check.ts +97 -0
  100. package/src/core/version.ts +55 -0
  101. package/src/core/work-state.ts +49 -3
  102. package/src/core/working-set.ts +91 -0
  103. package/src/runtime/auth.ts +11 -4
  104. package/src/runtime/branch-jobs.ts +36 -0
  105. package/src/runtime/catalog.ts +13 -3
  106. package/src/runtime/clipboard.ts +15 -4
  107. package/src/runtime/command-artifacts.ts +64 -0
  108. package/src/runtime/control-connection.ts +104 -0
  109. package/src/runtime/environment-tools.ts +60 -0
  110. package/src/runtime/evidence-notes.ts +4 -2
  111. package/src/runtime/exec-sessions.ts +206 -61
  112. package/src/runtime/execution-control.ts +1 -0
  113. package/src/runtime/extension-ui.ts +22 -30
  114. package/src/runtime/extensions.ts +95 -16
  115. package/src/runtime/harness-comparison.ts +27 -7
  116. package/src/runtime/harness-eval.ts +462 -0
  117. package/src/runtime/history-cache.ts +57 -0
  118. package/src/runtime/history-tools.ts +62 -12
  119. package/src/runtime/integrations.ts +210 -0
  120. package/src/runtime/loop.ts +1088 -282
  121. package/src/runtime/model-discovery.ts +208 -0
  122. package/src/runtime/model.ts +9 -0
  123. package/src/runtime/native-eval-fixtures.ts +33 -0
  124. package/src/runtime/native-eval.ts +146 -0
  125. package/src/runtime/oauth.ts +80 -3
  126. package/src/runtime/pi-provider.ts +169 -12
  127. package/src/runtime/program-status.ts +38 -0
  128. package/src/runtime/provider-connect.ts +109 -0
  129. package/src/runtime/provider-presets.ts +87 -0
  130. package/src/runtime/provider-request.ts +13 -2
  131. package/src/runtime/provider.ts +23 -9
  132. package/src/runtime/query-eval-verifier.ts +28 -0
  133. package/src/runtime/recovery-coverage.ts +71 -0
  134. package/src/runtime/recovery-evidence.ts +76 -0
  135. package/src/runtime/recovery-fixtures.ts +116 -0
  136. package/src/runtime/runtime-target.ts +149 -0
  137. package/src/runtime/session-control.ts +198 -0
  138. package/src/runtime/tool-broker.ts +137 -0
  139. package/src/runtime/tool-discovery.ts +57 -0
  140. package/src/runtime/tool-mode-eval.ts +146 -0
  141. package/src/runtime/tool-queue.ts +77 -0
  142. package/src/runtime/tools.ts +628 -183
  143. package/src/runtime/trial-control.ts +45 -0
  144. package/src/runtime/update-check.ts +47 -0
  145. package/src/runtime/window-trial.ts +2 -1
  146. package/src/runtime/working-notes.ts +33 -3
  147. package/src/runtime/workspace-lifecycle.ts +97 -0
  148. package/src/store/acceptance.ts +15 -0
  149. package/src/store/checkpoint-history.ts +43 -0
  150. package/src/store/command-artifacts.ts +96 -0
  151. package/src/store/command.ts +65 -37
  152. package/src/store/file-checkpoints.ts +104 -0
  153. package/src/store/file-index.ts +13 -1
  154. package/src/store/git-checkpoints.ts +67 -0
  155. package/src/store/observations.ts +61 -20
  156. package/src/store/process-receipts.ts +34 -0
  157. package/src/store/query-source.ts +13 -8
  158. package/src/store/recovery.ts +2 -3
  159. package/src/store/replay.ts +16 -4
  160. package/src/store/runtime-target.ts +64 -0
  161. package/src/store/search.ts +171 -132
  162. package/src/store/session-archive.ts +4 -0
  163. package/src/store/source-cancellation.ts +6 -0
  164. package/src/store/update-cache.ts +36 -0
  165. package/src/store/workspace-observation.ts +113 -0
  166. package/src/tui/ansi-spans.ts +126 -0
  167. package/src/tui/style.ts +19 -16
  168. package/src/tui/ui/atomic-draft.ts +121 -0
  169. package/src/tui/ui/canvas.ts +96 -0
  170. package/src/tui/ui/colors.ts +70 -0
  171. package/src/tui/ui/composer.ts +200 -0
  172. package/src/tui/ui/control-panel.ts +23 -0
  173. package/src/tui/ui/dialog.ts +379 -0
  174. package/src/tui/ui/draft.ts +23 -0
  175. package/src/tui/ui/edge.ts +186 -0
  176. package/src/tui/ui/image-size.ts +40 -0
  177. package/src/tui/ui/ink.ts +210 -0
  178. package/src/tui/ui/items.ts +40 -0
  179. package/src/tui/ui/keys.ts +40 -0
  180. package/src/tui/ui/layout.ts +74 -0
  181. package/src/tui/ui/list-model.ts +86 -0
  182. package/src/tui/ui/list-view.ts +172 -0
  183. package/src/tui/ui/mascot.ts +67 -0
  184. package/src/tui/ui/messages.ts +518 -0
  185. package/src/tui/ui/motion.ts +28 -0
  186. package/src/tui/ui/paths.ts +10 -0
  187. package/src/tui/ui/pictures.ts +78 -0
  188. package/src/tui/ui/screen.ts +421 -0
  189. package/src/tui/ui/status.ts +279 -0
  190. package/src/tui/ui/styled.ts +85 -0
  191. package/src/tui/ui/testing.ts +5 -0
  192. package/src/tui/ui/tool-card.ts +150 -0
  193. package/src/apps/extension-ui.ts +0 -330
  194. package/src/apps/resource-browser.ts +0 -75
  195. package/src/apps/session-browser.ts +0 -113
  196. package/src/apps/session-picker.ts +0 -55
  197. package/src/tui/ansi.ts +0 -67
  198. package/src/tui/autocomplete.ts +0 -29
  199. package/src/tui/chat-viewport.ts +0 -47
  200. package/src/tui/complete.ts +0 -87
  201. package/src/tui/editor.ts +0 -940
  202. package/src/tui/host.ts +0 -264
  203. package/src/tui/input.ts +0 -75
  204. package/src/tui/keys.ts +0 -378
  205. package/src/tui/markdown.ts +0 -17
  206. package/src/tui/render.ts +0 -153
  207. package/src/tui/select.ts +0 -258
  208. package/src/tui/settings.ts +0 -44
  209. package/src/tui/statusline.ts +0 -162
  210. package/src/tui/system-theme.ts +0 -700
  211. package/src/tui/terminal.ts +0 -30
  212. package/src/tui/text.ts +0 -52
  213. package/src/tui/toolview.ts +0 -386
  214. package/src/tui/transcript.ts +0 -123
  215. package/src/tui/tree.ts +0 -471
  216. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  217. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  218. package/src/vendor/pi-tui/VENDOR.md +0 -25
  219. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  220. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  221. package/src/vendor/pi-tui/colors.ts +0 -367
  222. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  223. package/src/vendor/pi-tui/components/box.ts +0 -166
  224. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  225. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  226. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  227. package/src/vendor/pi-tui/components/image.ts +0 -127
  228. package/src/vendor/pi-tui/components/input.ts +0 -494
  229. package/src/vendor/pi-tui/components/loader.ts +0 -101
  230. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  231. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  232. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  233. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  234. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  235. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  236. package/src/vendor/pi-tui/components/stack.ts +0 -154
  237. package/src/vendor/pi-tui/components/text.ts +0 -107
  238. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  239. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  240. package/src/vendor/pi-tui/editor-component.ts +0 -74
  241. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  242. package/src/vendor/pi-tui/index.ts +0 -186
  243. package/src/vendor/pi-tui/keybindings.ts +0 -320
  244. package/src/vendor/pi-tui/keys.ts +0 -1401
  245. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  246. package/src/vendor/pi-tui/latex.ts +0 -1506
  247. package/src/vendor/pi-tui/layout-node.ts +0 -51
  248. package/src/vendor/pi-tui/layout.ts +0 -449
  249. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  250. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  251. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  252. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  253. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  254. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  255. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  256. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  257. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  258. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  259. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  260. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  261. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  262. package/src/vendor/pi-tui/native/napi.h +0 -91
  263. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  264. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  265. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  266. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  267. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  268. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  269. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  270. package/src/vendor/pi-tui/native-platform.ts +0 -65
  271. package/src/vendor/pi-tui/oklab.ts +0 -233
  272. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  273. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  274. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  275. package/src/vendor/pi-tui/terminal.ts +0 -554
  276. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  277. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  278. package/src/vendor/pi-tui/tui.ts +0 -1493
  279. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  280. package/src/vendor/pi-tui/utils.ts +0 -1385
  281. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  282. package/src/vendor/pi-tui/word-navigation.ts +0 -117
@@ -0,0 +1,25 @@
1
+ /** Q.21: the retired orchestration contrast cannot enter new direct-query trials. */
2
+ export const TOOL_MODE_FACTORS = ["querySyntax"] as const;
3
+ export const TOOL_MODE_TASKS = ["tool-small", "tool-batch", "tool-aggregate"] as const;
4
+ export type ToolModeTask = typeof TOOL_MODE_TASKS[number];
5
+ export const isToolModeTask = (task: string): task is ToolModeTask => (TOOL_MODE_TASKS as readonly string[]).includes(task);
6
+ export interface ToolModeEngine { packages: Record<string, string>; assets: Record<string, string> }
7
+ export function validateToolModeEngine(engine: ToolModeEngine | undefined): asserts engine is ToolModeEngine {
8
+ if (!engine || engine.packages["@earendil-works/pi-ai"] !== "0.99.1" || Object.keys(engine.packages).length !== 1 ||
9
+ !engine.assets || !Object.entries(engine.assets).every(([path, sha]) =>
10
+ /^@earendil-works\/pi-ai\/[a-zA-Z0-9_./-]+$/.test(path) && !path.split("/").includes("..") && /^[a-f0-9]{64}$/.test(sha)) ||
11
+ ["@earendil-works/pi-ai/package.json", "@earendil-works/pi-ai/dist/api/openai-codex-responses.js"].some(path => !engine.assets[path]))
12
+ throw Error("tool mode engine assets are missing or unsupported");
13
+ }
14
+ export interface ToolModeMetrics {
15
+ elapsedMs: number; queryCalls: number; primitiveCalls: number;
16
+ sourcePaths: string[]; requiredSourcePaths: string[]; sourceComplete: boolean;
17
+ }
18
+ export function isToolModeMetrics(value: unknown): value is ToolModeMetrics {
19
+ if (!value || typeof value !== "object") return false;
20
+ const v = value as ToolModeMetrics;
21
+ const paths = (list: unknown): list is string[] => Array.isArray(list) && list.every(item => typeof item === "string") && new Set(list).size === list.length;
22
+ return Number.isFinite(v.elapsedMs) && v.elapsedMs >= 0 && [v.queryCalls, v.primitiveCalls].every(n => Number.isSafeInteger(n) && n >= 0) &&
23
+ paths(v.sourcePaths) && paths(v.requiredSourcePaths) && v.requiredSourcePaths.length > 0 &&
24
+ v.sourceComplete === v.requiredSourcePaths.every(path => v.sourcePaths.includes(path));
25
+ }
@@ -9,6 +9,8 @@ export type TranscriptItem = Identity & (
9
9
  | { kind: "assistant" | "thinking" | "notice"; text: string }
10
10
  | { kind: "custom"; text: string; customType: string; data?: unknown; entry: boolean; timestamp: string }
11
11
  | { kind: "tool"; callId: string; name: string; input: unknown; result: { text: string; ok: boolean; data?: unknown; images?: readonly ImageRef[] } | null }
12
+ | { kind: "shell"; command: string; output: string; exit: number; excluded: boolean }
13
+ | { kind: "segment"; reason: string; handoff: string; nextNode?: number; nextModel?: string }
12
14
  );
13
15
 
14
16
  export function projectTranscript(events: readonly LogEvent[], entryTypes: ReadonlySet<string> = new Set()): TranscriptItem[] {
@@ -26,8 +28,7 @@ export function projectTranscript(events: readonly LogEvent[], entryTypes: Reado
26
28
  users.set(event.id, items.length);
27
29
  items.push({ ...identity, kind: "user", text: event.text, interrupted: false, ...(event.images?.length ? { images: event.images } : {}) });
28
30
  break;
29
- case "user_bash": items.push({ ...identity, kind: "notice",
30
- text: `${event.excludeFromContext ? "!!" : "!"}${event.command}\n${event.content}` }); break;
31
+ case "user_bash": items.push({ ...identity, kind: "shell", command: event.command, output: event.content, exit: event.exit, excluded: event.excludeFromContext }); break;
31
32
  case "outcome": items.push({ ...identity, kind: "notice",
32
33
  text: `${event.outcome.status} · ${event.outcome.reason} · ${event.reason}` }); break;
33
34
  case "assistant":
@@ -39,6 +40,12 @@ export function projectTranscript(events: readonly LogEvent[], entryTypes: Reado
39
40
  } else items.push({ id, revision: id, kind: block.type === "text" ? "assistant" : "thinking", text: block.text });
40
41
  });
41
42
  break;
43
+ case "tool_invocation": {
44
+ if (!event.parent_call_id && !event.origin) break;
45
+ calls.set(event.call_id, items.length);
46
+ items.push({ ...identity, kind: "tool", callId: event.call_id, name: event.name, input: event.input ?? { artifact: event.input_ref.sha256 }, result: null });
47
+ break;
48
+ }
42
49
  case "tool_result": {
43
50
  const index = calls.get(event.call_id);
44
51
  const call = index === undefined ? undefined : items[index];
@@ -54,13 +61,21 @@ export function projectTranscript(events: readonly LogEvent[], entryTypes: Reado
54
61
  items.push({ ...identity, kind: "notice", text: "Turn interrupted" });
55
62
  break;
56
63
  }
57
- case "cut": items.push({ ...identity, kind: "notice", text: `Context boundary · ${event.reason} · ${event.handoff_ref}` }); break;
64
+ // A segment boundary is the log's own structure; the view draws it as a divider (D20).
65
+ case "cut": items.push({ ...identity, kind: "segment", reason: event.reason, handoff: event.handoff_ref,
66
+ ...(event.next_node === undefined ? {} : { nextNode: event.next_node }), ...(event.next_model === undefined ? {} : { nextModel: event.next_model }) }); break;
58
67
  case "rewind": items.push({ ...identity, kind: "notice", text: `Navigated context (${event.mode}) · ${event.prompt}` }); break;
59
68
  case "spawn": items.push({ ...identity, kind: "notice", text: `Branch ${event.branch.node} · ${event.purpose}` }); break;
60
69
  case "joined": items.push({ ...identity, kind: "notice", text: `Branch ${event.branch.node} finished · ${event.summary}` }); break;
61
70
  case "adopt": items.push({ ...identity, kind: "notice", text: `Adopted branch ${event.branch.node}` }); break;
62
71
  case "discard": items.push({ ...identity, kind: "notice", text: `Discarded branch ${event.branch.node} · ${event.reason}` }); break;
63
- case "guidance": case "snapshot": case "claim": case "decision": case "pitfall": case "blocked": case "question": case "next_action": case "work_status": case "evidence_link":
72
+ case "skill_loaded": items.push({ ...identity, kind: "notice", text: `Skill ${event.name} · ${event.mode} · ${event.sha256}` }); break;
73
+ case "process_exit": items.push({ ...identity, kind: "notice", text: `Process ${event.session} exited ${event.exit} · output ${event.output.sha256}` }); break;
74
+ case "acceptance_contract": items.push({ ...identity, kind: "notice", text: `Acceptance contract · ${event.contract.id}` }); break;
75
+ case "target_observation": items.push({ ...identity, kind: "notice", text: `Target ${event.observation.target} · ${event.observation.state} · ${event.observation.reason}` }); break;
76
+ case "acceptance_decision": items.push({ ...identity, kind: "notice", text: `User accepted · ${event.criterion}` }); break;
77
+ case "checkpoint": case "workspace_observation":
78
+ case "guidance": case "snapshot": case "claim": case "decision": case "pitfall": case "blocked": case "question": case "next_action": case "work_status": case "work_owner": case "work_dependencies": case "evidence_link":
64
79
  // These remain inspectable via /state and /export; they are not chat messages.
65
80
  break;
66
81
  default: assertNever(event, "projectTranscript");
@@ -0,0 +1,14 @@
1
+ /** R12 / Q.14: runner observations complement, but never replace, gateway billing evidence. */
2
+ export type TrialInterruption = { source: "deadline" | "caller"; phase: "initialization" | "execution"; backendRequests: number };
3
+ export type TrialLifecycle = { version: 1; backendRequests: number; interruption: TrialInterruption | null; cleanup: "complete" | "failed" };
4
+
5
+ export function isTrialLifecycle(value: unknown): value is TrialLifecycle {
6
+ if (!value || typeof value !== "object") return false;
7
+ const v=value as Record<string,unknown>, n=v["backendRequests"], interruption=v["interruption"];
8
+ if (v["version"]!==1 || typeof n!=="number" || !Number.isSafeInteger(n) || n<0 || v["cleanup"]!=="complete" && v["cleanup"]!=="failed") return false;
9
+ if (interruption===null) return true;
10
+ if (!interruption || typeof interruption!=="object") return false;
11
+ const stop=interruption as Record<string,unknown>;
12
+ return (stop["source"]==="deadline" || stop["source"]==="caller") && (stop["phase"]==="initialization" || stop["phase"]==="execution")
13
+ && stop["backendRequests"]===n && (stop["phase"]!=="initialization" || n===0);
14
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Which screen an interactive session uses. Pure: flag, environment and
3
+ * setting go in, a concrete mode and the reason come out.
4
+ *
5
+ * Fullscreen is the default (OpenCode, grok-build and Pi 1.0 all default to
6
+ * the alternate screen). `auto` is Codex's / grok-build's policy rather than
7
+ * "always", because the alternate screen has known dead ends: Zellij keeps
8
+ * no scrollback for it (zellij#1032, Codex turns it off there), and a dumb
9
+ * terminal cannot address the screen at all. Every route back to native
10
+ * scrollback stays: `--tui-mode regular`, `XENO_TUI_MODE`, the setting.
11
+ */
12
+ export type TuiModeSetting = "auto" | "regular" | "fullscreen";
13
+ export type TuiMode = "regular" | "fullscreen";
14
+
15
+ export interface TuiModeInput {
16
+ readonly flag?: TuiMode | undefined;
17
+ readonly setting?: TuiModeSetting | undefined;
18
+ readonly env: Readonly<Record<string, string | undefined>>;
19
+ }
20
+
21
+ export function resolveTuiMode(input: TuiModeInput): { mode: TuiMode; reason: string } {
22
+ if (input.flag) return { mode: input.flag, reason: "--tui-mode" };
23
+ const fromEnv = input.env["XENO_TUI_MODE"];
24
+ if (fromEnv === "regular" || fromEnv === "fullscreen") return { mode: fromEnv, reason: "XENO_TUI_MODE" };
25
+ if (input.setting === "regular" || input.setting === "fullscreen") return { mode: input.setting, reason: "settings" };
26
+ if (input.env["TERM"] === "dumb") return { mode: "regular", reason: "TERM=dumb cannot draw a fullscreen UI" };
27
+ if (input.env["ZELLIJ"] !== undefined) return { mode: "regular", reason: "Zellij keeps no scrollback for the alternate screen" };
28
+ return { mode: "fullscreen", reason: "default" };
29
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Whether to look for a newer xeno and what to say about it (docs/tasks/feat-update-check.md). Pure.
3
+ *
4
+ * Notify only: xeno names the command for the way it was installed and never
5
+ * runs it. At most one registry request a day, recorded even when it fails,
6
+ * and none at all offline, in CI, when turned off, or from a source checkout
7
+ * (where the right update is `git pull`, not a package manager). Clock,
8
+ * environment and the cached answer are all arguments, so every rule is a
9
+ * plain function of its inputs.
10
+ */
11
+ import { isUpgrade } from "./version.ts";
12
+
13
+ export const PACKAGE_NAME = "@visual-z/xeno";
14
+ /** One request a day, like update-notifier; Codex uses 20 hours. Assumed. */
15
+ export const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
16
+
17
+ export type InstallKind = "bun" | "npm" | "other" | "checkout";
18
+
19
+ export interface UpdateCache {
20
+ /** Epoch ms of the last attempt, successful or not. */
21
+ readonly checkedAt: number;
22
+ /** The newest published version seen, when the last success had one. */
23
+ readonly latest?: string;
24
+ }
25
+
26
+ export interface UpdateInput {
27
+ readonly current: string;
28
+ readonly now: number;
29
+ readonly cache: UpdateCache | null;
30
+ readonly env: Readonly<Record<string, string | undefined>>;
31
+ readonly offline: boolean;
32
+ /** Settings `updateCheck`; undefined means on. */
33
+ readonly enabled: boolean | undefined;
34
+ readonly install: InstallKind;
35
+ }
36
+
37
+ export interface UpdatePlan {
38
+ /** Ask the registry now. */
39
+ readonly check: boolean;
40
+ /** What to tell the person, if anything is newer. */
41
+ readonly notice?: string;
42
+ }
43
+
44
+ function truthy(value: string | undefined): boolean {
45
+ return value !== undefined && value !== "" && value !== "0" && value.toLowerCase() !== "false";
46
+ }
47
+
48
+ /** Reasons not to send a request at all, in the order they are checked. */
49
+ export function updateDisabled(input: Pick<UpdateInput, "env" | "offline" | "enabled" | "install">): string | undefined {
50
+ if (input.install === "checkout") return "running from a source checkout";
51
+ if (input.offline || truthy(input.env["XENO_OFFLINE"])) return "offline";
52
+ if (input.enabled === false) return "updateCheck is off";
53
+ if (truthy(input.env["XENO_NO_UPDATE_CHECK"]) || truthy(input.env["NO_UPDATE_NOTIFIER"])) return "turned off by environment";
54
+ if (truthy(input.env["CI"]) || truthy(input.env["CONTINUOUS_INTEGRATION"])) return "running in CI";
55
+ return undefined;
56
+ }
57
+
58
+ export function updatePlan(input: UpdateInput): UpdatePlan {
59
+ if (updateDisabled(input)) return { check: false };
60
+ const due = !input.cache || !Number.isFinite(input.cache.checkedAt) || input.now - input.cache.checkedAt >= CHECK_INTERVAL_MS
61
+ || input.cache.checkedAt > input.now; // a clock set back must not silence checks for a year
62
+ const latest = input.cache?.latest;
63
+ return { check: due, ...(latest && isUpgrade(latest, input.current) ? { notice: updateNotice(latest, input.current, input.install) } : {}) };
64
+ }
65
+
66
+ export function upgradeCommand(install: InstallKind): string | undefined {
67
+ switch (install) {
68
+ case "bun": return `bun add -g ${PACKAGE_NAME}@latest`;
69
+ case "npm": return `npm i -g ${PACKAGE_NAME}@latest`;
70
+ case "other": case "checkout": return undefined;
71
+ }
72
+ }
73
+
74
+ /** Two lines: what is new, then the command on its own line so it can be copied whole. */
75
+ export function updateNotice(latest: string, current: string, install: InstallKind): string {
76
+ const command = upgradeCommand(install);
77
+ return `xeno ${latest} is available (you have ${current})\n ${command ?? "see docs/install.md to update"}`;
78
+ }
79
+
80
+ /**
81
+ * How this copy was installed, from where its package root really lives.
82
+ * Detected from the path rather than by running package managers, which
83
+ * costs seconds at startup (OpenCode runs up to seven list commands).
84
+ */
85
+ export function installKind(packageRoot: string, hasGit: boolean): InstallKind {
86
+ const path = packageRoot.replaceAll("\\", "/");
87
+ if (hasGit || !path.includes("/node_modules/")) return "checkout";
88
+ if (path.includes("/.bun/install/global/")) return "bun";
89
+ if (path.includes("/lib/node_modules/") || /\/npm\/node_modules\//i.test(path)) return "npm";
90
+ return "other";
91
+ }
92
+
93
+ /** The registry's dist-tags for the package, honouring npm's registry setting. */
94
+ export function distTagsUrl(registry: string | undefined): string {
95
+ const base = (registry && /^https?:\/\//.test(registry) ? registry : "https://registry.npmjs.org/").replace(/\/+$/, "");
96
+ return `${base}/-/package/${PACKAGE_NAME.replace("/", "%2f")}/dist-tags`;
97
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Semantic version comparison (semver.org §11), without a dependency. Pure.
3
+ *
4
+ * Used to decide whether a published version is newer than the running one
5
+ * (docs/tasks/feat-update-check.md). Anything that does not parse is not a
6
+ * version, and an unparseable version never produces an update notice.
7
+ */
8
+
9
+ export interface Version {
10
+ readonly major: number;
11
+ readonly minor: number;
12
+ readonly patch: number;
13
+ /** Dot-separated pre-release identifiers; empty for a release. */
14
+ readonly prerelease: readonly (number | string)[];
15
+ }
16
+
17
+ const PATTERN = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
18
+
19
+ export function parseVersion(text: string): Version | null {
20
+ const match = PATTERN.exec(text.trim());
21
+ if (!match) return null;
22
+ const numbers = [match[1], match[2], match[3]].map(Number);
23
+ if (numbers.some(n => !Number.isSafeInteger(n))) return null;
24
+ const prerelease = match[4] ? match[4].split(".").map(part => /^\d+$/.test(part) ? Number(part) : part) : [];
25
+ return { major: numbers[0]!, minor: numbers[1]!, patch: numbers[2]!, prerelease };
26
+ }
27
+
28
+ /** -1, 0 or 1, by §11: numbers, then a release outranks its pre-releases, then identifiers left to right. Build metadata is ignored. */
29
+ export function compareVersions(a: Version, b: Version): -1 | 0 | 1 {
30
+ for (const key of ["major", "minor", "patch"] as const) if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1;
31
+ const x = a.prerelease, y = b.prerelease;
32
+ if (!x.length || !y.length) return x.length === y.length ? 0 : x.length ? -1 : 1;
33
+ for (let i = 0; i < Math.min(x.length, y.length); i++) {
34
+ const p = x[i]!, q = y[i]!;
35
+ if (p === q) continue;
36
+ if (typeof p === "number" && typeof q === "number") return p < q ? -1 : 1;
37
+ // Numeric identifiers have lower precedence than alphanumeric ones.
38
+ if (typeof p === "number") return -1;
39
+ if (typeof q === "number") return 1;
40
+ return p < q ? -1 : 1;
41
+ }
42
+ return x.length === y.length ? 0 : x.length < y.length ? -1 : 1;
43
+ }
44
+
45
+ /**
46
+ * Whether `latest` should be offered to someone running `current`. Never a
47
+ * downgrade, never something unparseable, and a release is not nudged
48
+ * towards a pre-release (a pre-release user may move to the next release).
49
+ */
50
+ export function isUpgrade(latest: string, current: string): boolean {
51
+ const next = parseVersion(latest), now = parseVersion(current);
52
+ if (!next || !now) return false;
53
+ if (next.prerelease.length && !now.prerelease.length) return false;
54
+ return compareVersions(next, now) > 0;
55
+ }
@@ -15,8 +15,35 @@ export type WorkItem = {
15
15
  evidence?: Evidence;
16
16
  /** Reopening keeps provenance without carrying a second copy of old proof. */
17
17
  previous_evidence?: string;
18
+ depends_on?: readonly string[];
19
+ owner?: string;
20
+ owner_event?: string;
18
21
  };
19
22
 
23
+ export type WorkReadiness = { ready: boolean; reason: "ready" | "waiting" | "blocked" | "closed" | "owned" | "missing"; waiting_for: string[] };
24
+ export function workReadiness(items: readonly WorkItem[], id: string, actor?: string): WorkReadiness {
25
+ const item = items.find(work => work.id === id);
26
+ if (!item) return { ready: false, reason: "missing", waiting_for: [id] };
27
+ if (item.status === "completed" || item.status === "cancelled") return { ready: false, reason: "closed", waiting_for: [] };
28
+ const waiting = (item.depends_on ?? []).filter(dep => items.find(work => work.id === dep)?.status !== "completed");
29
+ const reason = item.status === "blocked" ? "blocked" : waiting.length ? "waiting" : item.owner && item.owner !== actor ? "owned" : "ready";
30
+ return { ready: reason === "ready", reason, waiting_for: [...waiting] };
31
+ }
32
+
33
+ function dependencyError(items: readonly WorkItem[], id: string, dependencies: readonly string[]): string | null {
34
+ if (new Set(dependencies).size !== dependencies.length) return "duplicate work dependencies";
35
+ if (dependencies.includes(id)) return "work dependency cycle: self dependency";
36
+ if (dependencies.some(dep => !items.some(item => item.id === dep))) return "unknown work dependency";
37
+ const seen = new Set<string>(), pending = [...dependencies];
38
+ while (pending.length) {
39
+ const dep = pending.pop()!;
40
+ if (dep === id) return "work dependency cycle";
41
+ if (seen.has(dep)) continue;
42
+ seen.add(dep); pending.push(...items.find(item => item.id === dep)?.depends_on ?? []);
43
+ }
44
+ return null;
45
+ }
46
+
20
47
  /** O.2: sibling sessions may choose the same local label without aliasing work. */
21
48
  export function scopedWorkId(namespace: string, requested: string, items: readonly WorkItem[]): string {
22
49
  return items.some(item => item.id === requested) || requested.startsWith(`${namespace}:`)
@@ -26,6 +53,18 @@ export function scopedWorkId(namespace: string, requested: string, items: readon
26
53
  export function workChangeError(items: readonly WorkItem[], event: LogEvent): string | null {
27
54
  if (!("work_id" in event) || event.work_id === undefined) return null;
28
55
  const item = items.find(item => item.id === event.work_id);
56
+ if ((event.t === "next_action" || event.t === "work_dependencies") && event.depends_on !== undefined) {
57
+ const error = dependencyError(items, event.work_id, event.depends_on);
58
+ if (error) return error;
59
+ }
60
+ if (event.t === "work_dependencies" && !item) return `unknown work_id ${event.work_id}`;
61
+ if (event.t === "work_owner") {
62
+ if (!item) return `unknown work_id ${event.work_id}`;
63
+ if (event.operation === "release") return item.owner === event.owner ? null : "only the recorded owner can release work";
64
+ const ready = workReadiness(items, item.id, event.owner);
65
+ return ready.ready ? null : `work ${item.id} is ${ready.reason}; cannot claim it`;
66
+ }
67
+ if (event.t === "claim" && item && (item.depends_on ?? []).some(dep => items.find(work => work.id === dep)?.status !== "completed")) return "work dependencies are not completed";
29
68
  if (event.t === "work_status") {
30
69
  // D15 carries post-fork instructions, not their whole history. A harness-
31
70
  // authenticated transition may seed intent in the adopted branch, never proof.
@@ -51,13 +90,19 @@ export function deriveWorkItems(previous: readonly WorkItem[], events: readonly
51
90
  const items = new Map(previous.map(item => [item.id, { ...item }]));
52
91
  for (let index = 0; index < events.length; index++) {
53
92
  const event = events[index]!;
54
- if (!("work_id" in event) || event.work_id === undefined || workChangeError([...items.values()], event)) continue;
93
+ if ((event.t !== "next_action" && event.t !== "blocked" && event.t !== "work_status" && event.t !== "claim" && event.t !== "work_dependencies" && event.t !== "work_owner") ||
94
+ event.work_id === undefined || workChangeError([...items.values()], event)) continue;
55
95
  const old = items.get(event.work_id);
56
- const item: WorkItem = old ? { ...old, what: event.what, updated_at: event.id } : {
96
+ const item: WorkItem = old ? { ...old, what: event.t === "work_owner" || event.t === "work_dependencies" ? old.what : event.what, updated_at: event.id } : {
57
97
  id: event.work_id, what: event.what, status: "active", verification: "not_run",
58
98
  opened_at: event.id, updated_at: event.id,
59
99
  };
60
- if (event.t === "next_action") item.next_action = event.next_action;
100
+ if (event.t === "next_action") { item.next_action = event.next_action; if (event.depends_on !== undefined) item.depends_on = [...event.depends_on]; }
101
+ else if (event.t === "work_dependencies") item.depends_on = [...event.depends_on];
102
+ else if (event.t === "work_owner") {
103
+ if (event.operation === "claim") { item.owner = event.owner; item.owner_event = event.id; }
104
+ else { delete item.owner; delete item.owner_event; }
105
+ }
61
106
  else if (event.t === "blocked") {
62
107
  item.status = "blocked";
63
108
  item.reason = event.reason;
@@ -89,6 +134,7 @@ export function deriveWorkItems(previous: readonly WorkItem[], events: readonly
89
134
  } else { item.verification = "unverified"; item.reason = resolved.reason; }
90
135
  }
91
136
  items.set(item.id, item);
137
+ if (item.status === "completed" || item.status === "cancelled") { delete item.owner; delete item.owner_event; }
92
138
  }
93
139
  return [...items.values()].map(item => {
94
140
  if (!item.evidence) return item;
@@ -0,0 +1,91 @@
1
+ /** O.10: prioritize explicit intent and references without changing D20 arrays. */
2
+ import type { Handoff } from "./handoff.ts";
3
+ import type { NodeRef } from "./events.ts";
4
+ import { workReadiness, type WorkItem, type WorkReadiness } from "./work-state.ts";
5
+ import { emitYaml } from "./yaml.ts";
6
+ import { estimateTokens } from "./tokens.ts";
7
+ import { RECOVERY_SECTIONS, recoverySources, type RecoveryNodes, type RecoveryRanges, type RecoveryReference } from "./recovery-sources.ts";
8
+ import { recoveryInstructions } from "./prompt-policy.ts";
9
+
10
+ export type WorkingSet = {
11
+ version: 1 | 2; source: NodeRef;
12
+ work: (Pick<WorkItem, "id" | "what" | "status" | "next_action" | "reason" | "depends_on" | "owner"> & { readiness?: WorkReadiness })[];
13
+ references: ({ node?: number; event: string; kind: string; work?: string } & Partial<Omit<RecoveryReference, "node" | "event" | "kind" | "work">>)[];
14
+ omitted: { work: number; references: number };
15
+ recovery_sections?: RecoveryRanges;
16
+ recovery: string;
17
+ };
18
+
19
+ export function workingSet(full: Handoff, budget: number): WorkingSet | null {
20
+ if (!full.predecessor) return null;
21
+ const items = (full.state.work_items ?? []).filter(item => item.status === "blocked" || item.status === "active")
22
+ .toSorted((a, b) => Number(b.status === "blocked") - Number(a.status === "blocked") || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
23
+ const active = new Set(items.map(item => item.id));
24
+ const references: WorkingSet["references"] = [
25
+ ...(full.evidence_links ?? []).filter(link => active.has(link.work) && !link.superseded_by)
26
+ .map(link => ({ event: link.event, kind: "evidence", work: link.work })),
27
+ ...full.decisions.filter(item => item.work_id && active.has(item.work_id) && item.source)
28
+ .map(item => ({ node: item.source!.node, event: item.source!.event_id, kind: "decision", work: item.work_id! })),
29
+ ...full.pitfalls.filter(item => item.work_id && active.has(item.work_id) && item.source)
30
+ .map(item => ({ node: item.source!.node, event: item.source!.event_id, kind: "pitfall", work: item.work_id! })),
31
+ ...(full.progress_evidence ?? []).toReversed().map(item => ({ node: item.source.node, event: item.source.event_id, kind: item.kind })),
32
+ ];
33
+ const view: WorkingSet = { version: 1, source: full.predecessor, work: [], references: [],
34
+ omitted: { work: items.length, references: references.length }, recovery: "history(work_id=ID), history(event=ID), or history(node=source.node, section=...)" };
35
+ const fits = () => budget === Infinity || estimateTokens.count([], emitYaml(view)) <= budget;
36
+ if (!fits()) return null;
37
+ for (const item of items) {
38
+ const { id, what, status, next_action, reason, depends_on, owner } = item;
39
+ view.work.push({ id, what, status, ...(next_action ? { next_action } : {}), ...(reason ? { reason } : {}),
40
+ ...(depends_on ? { depends_on, readiness: workReadiness(full.state.work_items ?? [], id) } : {}), ...(owner ? { owner } : {}) });
41
+ view.omitted.work--;
42
+ if (!fits()) { view.work.pop(); view.omitted.work++; }
43
+ }
44
+ for (const reference of references) {
45
+ view.references.push(reference); view.omitted.references--;
46
+ if (!fits()) { view.references.pop(); view.omitted.references++; }
47
+ }
48
+ return view;
49
+ }
50
+
51
+ /** R9: a new projection version keeps the original v1 ordering byte-stable. */
52
+ export function recoveryWorkingSet(full: Handoff, nodes: RecoveryNodes, budget: number, tools?: readonly string[]): WorkingSet | null {
53
+ const legacy = workingSet(full, Infinity);
54
+ if (!legacy) return null;
55
+ const sources = recoverySources(nodes);
56
+ const view: WorkingSet = { version: 2, source: legacy.source, work: [], references: [],
57
+ omitted: { work: legacy.work.length, references: legacy.references.length + RECOVERY_SECTIONS.reduce((sum, section) => sum + sources[section].length, 0) },
58
+ recovery_sections: Object.fromEntries(RECOVERY_SECTIONS.map(section => [section, { total: sources[section].length, omitted: sources[section].length }])) as RecoveryRanges,
59
+ recovery: tools === undefined ? "Read original user messages before continuing: history(node=source.node,recovery=users,offset=0). Other pages: failures, environment, next_actions; event=ID reads one original. Abandoned messages are cancelled; annotations are not user instructions. Observations are historical: recheck resource, host and current permissions before acting. history(work_id=ID) recovers work." : recoveryInstructions(tools) };
60
+ const fits = () => estimateTokens.count([], emitYaml(view)) <= budget;
61
+ if (!fits()) return null;
62
+ const queues = RECOVERY_SECTIONS.map(section => ({ section, items: [...sources[section]], at: 0 }));
63
+ // The latest and first live user messages anchor correction and initial scope;
64
+ // remaining user sources are newest first. Cancelled turns stay pageable.
65
+ const live = sources.users.filter(ref => !ref.abandoned), first = live.at(-1);
66
+ queues[0]!.items = [...live.slice(0, 1), ...(first && first !== live[0] ? [first] : []),
67
+ ...live.slice(1, -1), ...sources.users.filter(ref => ref.abandoned)];
68
+ const takeReference = (reference: WorkingSet["references"][number], section?: keyof RecoveryRanges) => {
69
+ view.references.push(reference); view.omitted.references--;
70
+ if (section) view.recovery_sections![section].omitted--;
71
+ if (!fits()) {
72
+ view.references.pop(); view.omitted.references++;
73
+ if (section) view.recovery_sections![section].omitted++;
74
+ }
75
+ };
76
+ // One source per role precedes work prose; round-robin growth thereafter
77
+ // prevents a long task list or repeated failures starving other categories.
78
+ for (const queue of queues) { const reference = queue.items[queue.at++]; if (reference) takeReference(reference, queue.section); }
79
+ let at = 0;
80
+ while (at < legacy.work.length || at < legacy.references.length || queues.some(queue => queue.at < queue.items.length)) {
81
+ const work = legacy.work[at];
82
+ if (work) {
83
+ view.work.push(work); view.omitted.work--;
84
+ if (!fits()) { view.work.pop(); view.omitted.work++; }
85
+ }
86
+ for (const queue of queues) { const reference = queue.items[queue.at++]; if (reference) takeReference(reference, queue.section); }
87
+ const reference = legacy.references[at++];
88
+ if (reference) takeReference(reference);
89
+ }
90
+ return view;
91
+ }
@@ -22,6 +22,7 @@ import { providerAccess } from "./provider-access.ts";
22
22
  import { SOURCE_PROVIDERS } from "./model-data.ts";
23
23
  import {
24
24
  refreshOAuthCredential,
25
+ GROK_PROVIDER, GROK_BASE_URL,
25
26
  type OAuthCredential,
26
27
  type RefreshEffects,
27
28
  } from "./oauth.ts";
@@ -167,6 +168,9 @@ function configuredEffects(spec: ModelSpec, fx: ResolveEffects): { ok: true; eff
167
168
  if (providerAccess(spec).invalid) return { ok: false, reason: providerAccess(spec).invalid! };
168
169
  try {
169
170
  const scoped = providerEnvironment(spec, fx).effects;
171
+ if (spec.provider === GROK_PROVIDER && spec.baseUrl?.replace(/\/$/, "") !== GROK_BASE_URL) {
172
+ return { ok: false, reason: "Grok account tokens require the official inference proxy" };
173
+ }
170
174
  const access = providerAccess(spec);
171
175
  if (["google-generative-ai", "google-vertex"].includes(spec.api) && (access.auth === false || access.authHeader === false || typeof access.authHeader === "string")) {
172
176
  return { ok: false, reason: `${spec.api} owns API-key authentication; use apiKey or an OpenAI-compatible local endpoint` };
@@ -268,6 +272,7 @@ export const ENV_VAR: Readonly<Record<string, string | readonly string[]>> = {
268
272
  "google-vertex": "GOOGLE_CLOUD_API_KEY",
269
273
  "kimi-coding": ["KIMI_CODING_API_KEY", "KIMI_API_KEY"],
270
274
  "openai-codex": "OPENAI_CODEX_ACCESS_TOKEN",
275
+ "xai-grok": "GROK_ACCESS_TOKEN",
271
276
  "ant-ling": "ANT_LING_API_KEY",
272
277
  "qwen-token-plan": "QWEN_TOKEN_PLAN_API_KEY",
273
278
  "qwen-token-plan-cn": "QWEN_TOKEN_PLAN_CN_API_KEY",
@@ -303,9 +308,11 @@ export function envLabel(provider: string): string {
303
308
  * small is the difference between trying the tool and closing it.
304
309
  */
305
310
  export function describeMissingCredential(provider: string): string {
311
+ const subscription = provider === "openai-codex" ? "codex" : provider === GROK_PROVIDER ? "grok" : provider === "github-copilot" ? "github-copilot" : undefined;
312
+ if (subscription) return `no credential for ${provider}. Run /provider ${subscription} to sign in, or export ${envLabel(provider)}`;
306
313
  return (
307
- `no credential for ${provider}. Either export ${envLabel(provider)}, ` +
308
- `or run /login ${provider} to store one in ~/.xeno/auth.json`
314
+ `no credential for ${provider}. Run /provider ${provider} to add a key, ` +
315
+ `or export ${envLabel(provider)}`
309
316
  );
310
317
  }
311
318
 
@@ -316,7 +323,7 @@ export interface FreshEffects extends RefreshEffects {
316
323
  /**
317
324
  * Resolve, refreshing an expired OAuth access token first.
318
325
  *
319
- * A dead refresh fails here and names `/login <provider>`. It does not fall
326
+ * A dead refresh fails here and names `/provider <provider>`. It does not fall
320
327
  * through to the environment: that is the same rule as a stored API key that
321
328
  * cannot resolve. Otherwise a forgotten export quietly starts billing a
322
329
  * different account.
@@ -349,7 +356,7 @@ export async function ensureFreshCredential(
349
356
  if (!refreshed.ok) {
350
357
  return {
351
358
  ok: false,
352
- reason: `${spec.provider} OAuth expired. Run /login ${spec.provider} to sign in again.`,
359
+ reason: `${spec.provider} OAuth expired. Run /provider ${spec.provider} to sign in again.`,
353
360
  };
354
361
  }
355
362
  const next: AuthFile = { ...stored, [spec.provider]: refreshed.credential };
@@ -0,0 +1,36 @@
1
+ /** O.14: volatile ownership, never a replacement for the durable branch log. */
2
+ export class BranchJobs<T> {
3
+ private readonly jobs = new Map<number, { controller: AbortController; done: Promise<void>; value?: T; error?: string; detach(): void; ready: boolean }>();
4
+
5
+ get active(): number { return [...this.jobs.values()].filter(job => !job.ready).length; }
6
+ state(node: number): "running" | "ready" | undefined {
7
+ const job = this.jobs.get(node); return job ? job.ready ? "ready" : "running" : undefined;
8
+ }
9
+ nodes(): number[] { return [...this.jobs.keys()]; }
10
+ start(node: number, controller: AbortController, run: () => Promise<T>, detach: () => void): void {
11
+ if (this.jobs.has(node)) throw new Error("branch already owned");
12
+ const job: { controller: AbortController; done: Promise<void>; value?: T; error?: string; detach(): void; ready: boolean } = {
13
+ controller, ready: false, detach, done: Promise.resolve(),
14
+ };
15
+ this.jobs.set(node, job);
16
+ job.done = run().then(value => { job.value = value; }, () => { job.error = "branch finalization failed; inspect its original log"; })
17
+ .finally(() => { job.ready = true; job.detach(); });
18
+ }
19
+ cancel(node?: number): void {
20
+ for (const [id, job] of this.jobs) if (node === undefined || node === id) job.controller.abort();
21
+ }
22
+ async drain(node?: number): Promise<void> { await Promise.all([...this.jobs].filter(([id]) => node === undefined || node === id).map(([, job]) => job.done)); }
23
+ async wait(node: number, ms: number): Promise<{ value?: T; error?: string; running: boolean } | undefined> {
24
+ const job = this.jobs.get(node); if (!job) return undefined;
25
+ if (!Number.isFinite(ms) || ms < 0 || ms > 60_000) throw new RangeError("wait_ms must be between 0 and 60000");
26
+ let timer: ReturnType<typeof setTimeout> | undefined;
27
+ try {
28
+ if (!job.ready && ms > 0) await Promise.race([job.done, new Promise<void>(resolve => { timer = setTimeout(resolve, ms); })]);
29
+ } finally { clearTimeout(timer); }
30
+ return { ...(job.value !== undefined ? { value: job.value } : {}), ...(job.error ? { error: job.error } : {}), running: !job.ready };
31
+ }
32
+ forget(node: number): void {
33
+ if (this.state(node) === "running") throw new Error("cannot forget a running branch");
34
+ this.jobs.delete(node);
35
+ }
36
+ }
@@ -15,6 +15,8 @@
15
15
  */
16
16
 
17
17
  import { assertNever } from "../core/exhaustive.ts";
18
+ import { createHash } from "node:crypto";
19
+ import { resolve } from "node:path";
18
20
  import { modelKey } from "../core/session-position.ts";
19
21
  import { mergeCatalogs } from "../core/catalog-merge.ts";
20
22
  import { parseModelConfiguration } from "./model-config.ts";
@@ -58,6 +60,8 @@ export function loadCatalog(opts: { userText: string | null; cacheText?: string
58
60
  delete next.driver; delete next.compat; delete next.nativeCompat; delete next.thinkingLevelMap;
59
61
  if (known.compat) next.compat = known.compat;
60
62
  if (known.thinkingLevelMap) next.thinkingLevelMap = known.thinkingLevelMap;
63
+ if (known.driver) next.driver = known.driver;
64
+ if (known.nativeCompat) next.nativeCompat = known.nativeCompat;
61
65
  if (known.baseUrl !== undefined) next.baseUrl = known.baseUrl;
62
66
  copyProviderAccess(spec, next);
63
67
  return next;
@@ -233,7 +237,7 @@ export function selectModel(
233
237
  reason:
234
238
  "no model has a credential. Set an API key for one of: " +
235
239
  [...new Set(catalog.map((m) => m.provider))].join(", ") +
236
- " -- export its environment variable, or run /login",
240
+ " -- export its environment variable, or run /provider",
237
241
  };
238
242
  }
239
243
 
@@ -242,11 +246,12 @@ export function createProvider(
242
246
  config: ProviderConfig,
243
247
  apiKey: string,
244
248
  onRetry?: (attempt: number, delayMs: number, error: unknown) => void,
245
- opts?: { oauth?: boolean; fetch?: typeof fetch; effects?: ResolveEffects },
249
+ opts?: { oauth?: boolean; fetch?: typeof fetch; effects?: ResolveEffects; sessionId?: string },
246
250
  ): Provider {
247
251
  const oauth = opts?.oauth === true;
248
252
  if (config.spec.driver === "pi" || hasProviderAccess(config.spec)) return new PiProvider(config, apiKey, {
249
253
  oauth,
254
+ ...(opts?.sessionId ? { sessionId: opts.sessionId } : {}),
250
255
  ...(onRetry ? { onRetry } : {}), ...(opts?.fetch ? { fetch: opts.fetch } : {}), ...(opts?.effects ? { effects: opts.effects } : {}),
251
256
  });
252
257
  switch (config.spec.api) {
@@ -261,8 +266,13 @@ export function createProvider(
261
266
  return new OpenAIResponsesProvider(config, apiKey, { ...(onRetry ? { onRetry } : {}) });
262
267
  case "azure-openai-responses": case "openai-codex-responses": case "google-generative-ai": case "google-vertex":
263
268
  case "bedrock-converse-stream": case "mistral-conversations": case "pi-messages":
264
- return new PiProvider(config, apiKey, { ...(onRetry ? { onRetry } : {}), ...(opts?.fetch ? { fetch: opts.fetch } : {}), ...(opts?.effects ? { effects: opts.effects } : {}) });
269
+ return new PiProvider(config, apiKey, { ...(onRetry ? { onRetry } : {}), ...(opts?.fetch ? { fetch: opts.fetch } : {}), ...(opts?.effects ? { effects: opts.effects } : {}), ...(opts?.sessionId ? { sessionId: opts.sessionId } : {}) });
265
270
  default:
266
271
  return assertNever(config.spec.api, "createProvider");
267
272
  }
268
273
  }
274
+
275
+ /** D6/D8: routing affinity survives reload and cold resume without server-owned history or path disclosure. */
276
+ export function providerSessionId(logDir: string): string {
277
+ return createHash("sha256").update(resolve(logDir)).digest("hex");
278
+ }