@visual-z/xeno 0.1.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 (180) hide show
  1. package/README.md +498 -0
  2. package/docs/install.md +130 -0
  3. package/package.json +43 -0
  4. package/src/apps/approve.ts +49 -0
  5. package/src/apps/cli.ts +37 -0
  6. package/src/apps/commands.ts +226 -0
  7. package/src/apps/completions.ts +28 -0
  8. package/src/apps/input.ts +297 -0
  9. package/src/apps/render.ts +207 -0
  10. package/src/apps/shell.ts +2353 -0
  11. package/src/apps/startup.ts +1302 -0
  12. package/src/core/benchmark.ts +195 -0
  13. package/src/core/boundary.ts +174 -0
  14. package/src/core/churn.ts +59 -0
  15. package/src/core/completion-review.ts +66 -0
  16. package/src/core/context-policy.ts +60 -0
  17. package/src/core/context-window.ts +54 -0
  18. package/src/core/continuation.ts +114 -0
  19. package/src/core/cycle.ts +17 -0
  20. package/src/core/data-query.ts +201 -0
  21. package/src/core/deadline.ts +39 -0
  22. package/src/core/derive.ts +489 -0
  23. package/src/core/edit-plan.ts +200 -0
  24. package/src/core/environment.ts +29 -0
  25. package/src/core/event-validation.ts +99 -0
  26. package/src/core/events.ts +486 -0
  27. package/src/core/evidence-links.ts +71 -0
  28. package/src/core/evidence.ts +89 -0
  29. package/src/core/execution.ts +12 -0
  30. package/src/core/exhaustive.ts +18 -0
  31. package/src/core/experiment.ts +40 -0
  32. package/src/core/export.ts +169 -0
  33. package/src/core/file-references.ts +35 -0
  34. package/src/core/frontmatter.ts +213 -0
  35. package/src/core/handoff.ts +397 -0
  36. package/src/core/harness-comparison.ts +90 -0
  37. package/src/core/history-query.ts +103 -0
  38. package/src/core/history.ts +100 -0
  39. package/src/core/json-event.ts +135 -0
  40. package/src/core/json-pointer.ts +46 -0
  41. package/src/core/keymap.ts +241 -0
  42. package/src/core/observation-view.ts +87 -0
  43. package/src/core/outcome.ts +45 -0
  44. package/src/core/overflow.ts +59 -0
  45. package/src/core/permissions.ts +182 -0
  46. package/src/core/policy-evaluation.ts +55 -0
  47. package/src/core/progress.ts +101 -0
  48. package/src/core/project.ts +372 -0
  49. package/src/core/prompts.ts +83 -0
  50. package/src/core/queue.ts +60 -0
  51. package/src/core/read-bounds.ts +15 -0
  52. package/src/core/recall-analysis.ts +90 -0
  53. package/src/core/recall-trial.ts +153 -0
  54. package/src/core/response-recovery.ts +46 -0
  55. package/src/core/scheduling.ts +8 -0
  56. package/src/core/settings.ts +174 -0
  57. package/src/core/skills.ts +121 -0
  58. package/src/core/stall.ts +48 -0
  59. package/src/core/text-normalize.ts +81 -0
  60. package/src/core/theme.ts +179 -0
  61. package/src/core/tokens.ts +149 -0
  62. package/src/core/tool-contract.ts +102 -0
  63. package/src/core/tool-input.ts +38 -0
  64. package/src/core/transcript.ts +60 -0
  65. package/src/core/window-trial.ts +65 -0
  66. package/src/core/work-state.ts +103 -0
  67. package/src/core/yaml.ts +282 -0
  68. package/src/index.ts +10 -0
  69. package/src/runtime/auth.ts +255 -0
  70. package/src/runtime/benchmark.ts +187 -0
  71. package/src/runtime/catalog.ts +365 -0
  72. package/src/runtime/evidence-notes.ts +33 -0
  73. package/src/runtime/exec-sessions.ts +214 -0
  74. package/src/runtime/execution-control.ts +39 -0
  75. package/src/runtime/harness-comparison.ts +356 -0
  76. package/src/runtime/history-tools.ts +108 -0
  77. package/src/runtime/loop.ts +1983 -0
  78. package/src/runtime/model.ts +614 -0
  79. package/src/runtime/models-fetch.ts +155 -0
  80. package/src/runtime/oauth-device.ts +86 -0
  81. package/src/runtime/oauth.ts +759 -0
  82. package/src/runtime/openai-responses.ts +286 -0
  83. package/src/runtime/openai.ts +675 -0
  84. package/src/runtime/provider.ts +498 -0
  85. package/src/runtime/recall-trial.ts +226 -0
  86. package/src/runtime/resume.ts +66 -0
  87. package/src/runtime/retry.ts +297 -0
  88. package/src/runtime/tools.ts +1193 -0
  89. package/src/runtime/usage.ts +225 -0
  90. package/src/runtime/window-trial.ts +140 -0
  91. package/src/runtime/working-notes.ts +30 -0
  92. package/src/store/atomic.ts +31 -0
  93. package/src/store/auth-store.ts +50 -0
  94. package/src/store/blobs.ts +142 -0
  95. package/src/store/command.ts +75 -0
  96. package/src/store/config.ts +69 -0
  97. package/src/store/context-files.ts +356 -0
  98. package/src/store/file-index.ts +81 -0
  99. package/src/store/git-branch.ts +35 -0
  100. package/src/store/history-search.ts +19 -0
  101. package/src/store/log.ts +642 -0
  102. package/src/store/models.ts +41 -0
  103. package/src/store/observations.ts +181 -0
  104. package/src/store/query-source.ts +44 -0
  105. package/src/store/recovery.ts +39 -0
  106. package/src/store/replay.ts +63 -0
  107. package/src/store/resources.ts +361 -0
  108. package/src/store/restore.ts +302 -0
  109. package/src/store/search.ts +644 -0
  110. package/src/store/serial.ts +13 -0
  111. package/src/store/sessions.ts +463 -0
  112. package/src/store/trust.ts +69 -0
  113. package/src/store/workspace.ts +256 -0
  114. package/src/store/worktree.ts +233 -0
  115. package/src/tui/ansi.ts +67 -0
  116. package/src/tui/autocomplete.ts +29 -0
  117. package/src/tui/complete.ts +87 -0
  118. package/src/tui/diff.ts +160 -0
  119. package/src/tui/editor.ts +893 -0
  120. package/src/tui/exhaustive.ts +14 -0
  121. package/src/tui/fuzzy.ts +75 -0
  122. package/src/tui/host.ts +223 -0
  123. package/src/tui/input.ts +69 -0
  124. package/src/tui/keys.ts +378 -0
  125. package/src/tui/markdown.ts +17 -0
  126. package/src/tui/render.ts +153 -0
  127. package/src/tui/select.ts +240 -0
  128. package/src/tui/settings.ts +40 -0
  129. package/src/tui/statusline.ts +162 -0
  130. package/src/tui/style.ts +205 -0
  131. package/src/tui/terminal.ts +30 -0
  132. package/src/tui/text.ts +52 -0
  133. package/src/tui/toolview.ts +386 -0
  134. package/src/tui/transcript.ts +71 -0
  135. package/src/tui/tree.ts +405 -0
  136. package/src/tui/width.ts +567 -0
  137. package/src/vendor/pi-tui/LICENSE.upstream +21 -0
  138. package/src/vendor/pi-tui/VENDOR.md +23 -0
  139. package/src/vendor/pi-tui/alt-screen-search.ts +327 -0
  140. package/src/vendor/pi-tui/autocomplete.ts +835 -0
  141. package/src/vendor/pi-tui/components/alt-screen-flash.ts +51 -0
  142. package/src/vendor/pi-tui/components/box.ts +167 -0
  143. package/src/vendor/pi-tui/components/cancellable-loader.ts +40 -0
  144. package/src/vendor/pi-tui/components/editor.ts +2470 -0
  145. package/src/vendor/pi-tui/components/h-stack.ts +44 -0
  146. package/src/vendor/pi-tui/components/image.ts +127 -0
  147. package/src/vendor/pi-tui/components/input.ts +494 -0
  148. package/src/vendor/pi-tui/components/loader.ts +101 -0
  149. package/src/vendor/pi-tui/components/markdown.ts +1015 -0
  150. package/src/vendor/pi-tui/components/mouse-region.ts +33 -0
  151. package/src/vendor/pi-tui/components/scroll-view.ts +224 -0
  152. package/src/vendor/pi-tui/components/select-list.ts +273 -0
  153. package/src/vendor/pi-tui/components/settings-list.ts +328 -0
  154. package/src/vendor/pi-tui/components/spacer.ts +28 -0
  155. package/src/vendor/pi-tui/components/stack.ts +154 -0
  156. package/src/vendor/pi-tui/components/text.ts +107 -0
  157. package/src/vendor/pi-tui/components/truncated-text.ts +65 -0
  158. package/src/vendor/pi-tui/components/v-stack.ts +33 -0
  159. package/src/vendor/pi-tui/editor-component.ts +74 -0
  160. package/src/vendor/pi-tui/fuzzy.ts +138 -0
  161. package/src/vendor/pi-tui/index.ts +156 -0
  162. package/src/vendor/pi-tui/keybindings.ts +320 -0
  163. package/src/vendor/pi-tui/keys.ts +1401 -0
  164. package/src/vendor/pi-tui/kill-ring.ts +46 -0
  165. package/src/vendor/pi-tui/latex.ts +1506 -0
  166. package/src/vendor/pi-tui/layout-node.ts +51 -0
  167. package/src/vendor/pi-tui/layout.ts +449 -0
  168. package/src/vendor/pi-tui/native-modifiers.ts +13 -0
  169. package/src/vendor/pi-tui/native-module-path.ts +31 -0
  170. package/src/vendor/pi-tui/native-platform.ts +63 -0
  171. package/src/vendor/pi-tui/stdin-buffer.ts +444 -0
  172. package/src/vendor/pi-tui/terminal-colors.ts +73 -0
  173. package/src/vendor/pi-tui/terminal-image.ts +696 -0
  174. package/src/vendor/pi-tui/terminal.ts +547 -0
  175. package/src/vendor/pi-tui/tui-alt-screen.ts +1742 -0
  176. package/src/vendor/pi-tui/tui-main-screen.ts +655 -0
  177. package/src/vendor/pi-tui/tui.ts +1456 -0
  178. package/src/vendor/pi-tui/undo-stack.ts +28 -0
  179. package/src/vendor/pi-tui/utils.ts +1345 -0
  180. package/src/vendor/pi-tui/word-navigation.ts +117 -0
@@ -0,0 +1,1302 @@
1
+ import { release } from "node:os";
2
+ import { workingProtocol } from "../core/continuation.ts";
3
+ import { environmentSection, type SessionEnvironment } from "../core/environment.ts";
4
+ /**
5
+ * From argv to a session that can run. Credential order, model selection,
6
+ * --fresh and the leftover-log notice all live here so the CLI entry can
7
+ * stay a few calls -- and so those rules can be tested without starting
8
+ * a REPL.
9
+ */
10
+
11
+ import { mkdir } from "node:fs/promises";
12
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
13
+ import { Session, systemClock } from "../runtime/loop.ts";
14
+ import { DEFAULT_PROVIDER, type ProviderConfig } from "../runtime/provider.ts";
15
+ import { createProvider, selectModel, loadCatalog, groupedModels, parseUserCatalog, type CatalogFetch } from "../runtime/catalog.ts";
16
+ import { CATALOG, outputTokenBudget, type Effort, type ModelSpec } from "../runtime/model.ts";
17
+ import {
18
+ resolveCredential, envLabel, storedIsOAuth,
19
+ type AuthFile, type ResolveEffects, type Resolved, describeMissingCredential } from "../runtime/auth.ts";
20
+ import { authPath, configDir, readJsonFile } from "../store/config.ts";
21
+ import { loadUiResources, loadTextResources, type UiResources } from "../store/resources.ts";
22
+ import { appendSkillsToSystem } from "../core/skills.ts";
23
+ import { resolveSlashCatalog, type SlashCatalog } from "./commands.ts";
24
+ import { createStyle, IDENTITY, type Style } from "../tui/style.ts";
25
+ import type { Settings } from "../core/settings.ts";
26
+ import { readModelsText, writeModelsFile, modelsPath } from "../store/models.ts";
27
+ import { refreshRemoteCatalog } from "../runtime/models-fetch.ts";
28
+ import { readCursor, writeCursor, type Cursor } from "../store/log.ts";
29
+ import {
30
+ copyAncestryTo, createSession, findSessionByPrefix, resolveLogDir,
31
+ } from "../store/sessions.ts";
32
+ import { loadContext, assembleSystemPrompt, formatNotice, type LoadedContext } from "../store/context-files.ts";
33
+ import {
34
+ lookupTrust, projectSettingsPath, readTrust,
35
+ } from "../store/trust.ts";
36
+ import { stat } from "node:fs/promises";
37
+ import {
38
+ parseToolList, rulesWithAllowTools, selectTools,
39
+ type PermissionRequest, type PermissionRule,
40
+ } from "../core/permissions.ts";
41
+ import {
42
+ TOOL_SCHEMAS, DEFAULT_TOOL_CONTEXT, workspaceRoot,
43
+ type ApprovalChoice, type ToolContext,
44
+ } from "../runtime/tools.ts";
45
+ import { DEFAULT_BOUNDARY } from "../core/boundary.ts";
46
+ import { DEFAULT_CONTEXT_POLICY, isContextPolicy, type ContextPolicy } from "../core/context-policy.ts";
47
+ import { windowBudget } from "../core/context-window.ts";
48
+ import { colorEnabled, createPalette, describeError, stdinIsTty, stdoutIsTty, type Palette } from "./render.ts";
49
+ import { type ActionId, type Keymap } from "../core/keymap.ts";
50
+
51
+ export const SYSTEM_PROMPT = systemPromptFor(TOOL_SCHEMAS.map((tool) => tool.name));
52
+
53
+ /**
54
+ * The instructions mention only tools the model can actually see. Naming a
55
+ * tool that was excluded from the schema teaches it to call something that
56
+ * will never work. The default prompt is left alone when every tool is visible.
57
+ */
58
+ export function systemPromptFor(toolNames: readonly string[], env?: SessionEnvironment): string {
59
+ const has = (name: string) => toolNames.includes(name);
60
+ const toolsLine = toolNames.length === 0
61
+ ? "No tools are available in this session."
62
+ : `Tools: ${toolNames.join(", ")}.` +
63
+ (toolNames.some(name => ["grep", "find", "ls"].includes(name)) ? " Use the available grep/find/ls tools for confined search." : "") +
64
+ (has("bash")
65
+ ? " Use bash for verification commands and operations the other tools do not cover."
66
+ : "");
67
+
68
+ // Each line answers a failure class in the Terminal-Bench taxonomy
69
+ // (arXiv 2601.11868): premature termination, no verification, step
70
+ // repetition. The wording of the first and last follows the Codex
71
+ // prompting guide: persist end to end, and end on a change or a blocker.
72
+ const work = [
73
+ "How to work:",
74
+ "- Keep going until the request is fully resolved. When a reasonable assumption lets you proceed, make it and mention it at the end; stop to ask only when blocked on a decision only the user can make.",
75
+ "- Understand before changing: read the relevant code, and for a bug, reproduce it first.",
76
+ "- Put independent reads and searches in one response; they run in parallel.",
77
+ "- After a change, run the project's own tests, build or type check. When a command fails, read the end of its output first; errors and summaries are printed last.",
78
+ "- If the same approach fails twice, change the approach instead of retrying it.",
79
+ "- A claim without a passing command is not evidence. Finish with what changed and the command that verified it, or the specific blocker.",
80
+ ];
81
+ if (has("bash")) work.push(
82
+ "- Each bash call is a fresh non-interactive shell with no TTY: use workdir or `cd dir && ...` within one call. Never open pagers or interactive editors. " +
83
+ "Start servers in the background with output redirected (`nohup cmd > app.log 2>&1 &`), then poll the log or port. Pass a larger timeout for long builds and installs.",
84
+ );
85
+ if (has("exec_command")) work.push("- For servers, REPLs and programs that prompt for input, use exec_command (tty=true when the program needs a terminal) and continue with write_stdin.");
86
+ if (has("edit")) work.push("- Prefer edit to rewriting a whole file; copy old_string from read output without the line numbers.");
87
+
88
+ return [
89
+ "You are a coding agent working in a terminal.",
90
+ toolsLine,
91
+ ...(env === undefined ? [] : [environmentSection(env)]),
92
+ work.join("\n"),
93
+ workingProtocol(toolNames),
94
+ "Be concise. Do not narrate what you are about to do; do it, then say what happened.",
95
+ ].join("\n\n");
96
+ }
97
+
98
+ /** I/O half of the environment snapshot; the formatting is pure (core/environment.ts). */
99
+ export function sessionEnvironment(workspace: string): SessionEnvironment {
100
+ const now = new Date();
101
+ const pad = (n: number) => String(n).padStart(2, "0");
102
+ let git = false;
103
+ try {
104
+ git = Bun.spawnSync(["git", "rev-parse", "--is-inside-work-tree"], { cwd: workspace, stdout: "pipe", stderr: "ignore" })
105
+ .stdout.toString().trim() === "true";
106
+ } catch { /* no git on PATH is "not a repository" for the model's purposes */ }
107
+ return {
108
+ platform: `${process.platform} ${release()} (${process.arch})`,
109
+ shell: "bash (tool commands run with bash -lc)",
110
+ cwd: workspace,
111
+ date: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`,
112
+ git,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Session policy a CLI coding session differs from a benchmark run in.
118
+ * Benchmarks construct their own SessionConfig and keep the core defaults.
119
+ */
120
+ export function cliSessionConfig(args: Args): { continuationAfter: number; timeBudgetMs?: number } {
121
+ // Never within a request. A real Kimi K3 run with the reminder at reply six
122
+ // spent half its requests on notes after the work was already verified
123
+ // (fix-cli-continuation-noise). The reminder still fires where it earns its
124
+ // place: a resumed handoff that cannot continue, and boundary preparation.
125
+ return { continuationAfter: Infinity, ...(args.timeBudgetMs === undefined ? {} : { timeBudgetMs: args.timeBudgetMs }) };
126
+ }
127
+
128
+ export type AskSlot = {
129
+ current?: (request: PermissionRequest) => Promise<ApprovalChoice>;
130
+ };
131
+
132
+ export interface Args {
133
+ workspace: string;
134
+ /** `--time-budget <seconds>`, in ms: when to warn the model its run is nearly out of time. */
135
+ timeBudgetMs?: number;
136
+ /**
137
+ * `--path-root`: how far file tools may reach, absolute. Must contain the
138
+ * workspace. Fingerprints, snapshots and rewind stay workspace-scoped.
139
+ */
140
+ pathRoot?: string;
141
+ logDir: string;
142
+ maxTurns: number;
143
+ prompt: string;
144
+ trigger: number;
145
+ hard: number;
146
+ contextPolicy?: ContextPolicy;
147
+ model: string;
148
+ provider: string;
149
+ apiKey: string;
150
+ listModels: boolean;
151
+ modelsCommand: boolean;
152
+ modelsRefresh: boolean;
153
+ fresh: boolean;
154
+ noContextFiles: boolean;
155
+ appendSystemPrompt: string[];
156
+ systemPrompt: string | null;
157
+ /** `--tools` allowlist. Omitted when the flag was not passed. */
158
+ tools?: string[];
159
+ excludeTools: string[];
160
+ noTools: boolean;
161
+ /** `--no-builtin-tools`. False when the flag was not passed. */
162
+ noBuiltinTools: boolean;
163
+ allowTools: string[];
164
+ /** `--approve` true, `--no-approve` false. Omitted when neither. */
165
+ approve?: boolean;
166
+ /**
167
+ * `-p` / `--print`. Distinct from a leftover positional prompt: only this
168
+ * flag reads piped stdin, so a one-shot that did not ask for a pipe cannot
169
+ * hang on an open stdin the caller never meant to send.
170
+ */
171
+ print: boolean;
172
+ /** `--theme <name|path>`. Empty when the flag was not passed. */
173
+ theme: string;
174
+ /** `--thinking <level>`. Empty when the flag was not passed. */
175
+ thinking?: string;
176
+ /** `--models <patterns>`. Comma-separated; empty when the flag was not passed. */
177
+ models?: string;
178
+ noSkills: boolean;
179
+ noPromptTemplates: boolean;
180
+ skillPaths: string[];
181
+ promptTemplatePaths: string[];
182
+ /** `-c` / `--continue`. */
183
+ continueLatest?: boolean;
184
+ /**
185
+ * `-r` / `--resume`. A string is an id prefix; `true` is the selector
186
+ * with no id (list, do not guess).
187
+ */
188
+ resume?: string | true;
189
+ /** `--session <path|id prefix>`. */
190
+ session?: string;
191
+ /** `--fork <path|id prefix>`. */
192
+ forkFrom?: string;
193
+ /** `--no-session`. */
194
+ noSession?: boolean;
195
+ /** `--session-dir <dir>`. */
196
+ sessionDir?: string;
197
+ /** `-n` / `--name`. */
198
+ sessionName?: string;
199
+ /** `--mode json`. Empty / omitted when the flag was not passed. */
200
+ mode?: string;
201
+ /** `--verbose`. Expands the startup header's counts into lists. */
202
+ verbose?: boolean;
203
+ }
204
+
205
+ /**
206
+ * Names `parseArgs` actually accepts. `--refresh` is not here: it is only
207
+ * valid after the `models` subcommand, and listing it would make a top-level
208
+ * `--refresh` suggest itself while still being rejected.
209
+ *
210
+ * Keep in sync with the branches in `parseArgs`. A missing entry still
211
+ * parses; it just cannot be offered as a suggestion.
212
+ */
213
+ const KNOWN_FLAGS: readonly string[] = [
214
+ "--workspace", "-w",
215
+ "--path-root",
216
+ "--time-budget",
217
+ "--log",
218
+ "--max-turns",
219
+ "--trigger",
220
+ "--hard",
221
+ "--context-policy",
222
+ "--model", "-m",
223
+ "--provider",
224
+ "--api-key",
225
+ "--list-models",
226
+ "--fresh",
227
+ "--continue", "-c",
228
+ "--resume", "-r",
229
+ "--session",
230
+ "--fork",
231
+ "--no-session",
232
+ "--session-dir",
233
+ "--name", "-n",
234
+ "--no-context-files", "-nc",
235
+ "--system-prompt",
236
+ "--append-system-prompt",
237
+ "--tools", "-t",
238
+ "--exclude-tools", "-xt",
239
+ "--no-tools", "-nt",
240
+ "--no-builtin-tools",
241
+ "--allow-tools",
242
+ "--approve", "-a",
243
+ "--no-approve", "-na",
244
+ "--theme",
245
+ "--thinking",
246
+ "--models",
247
+ "--skill",
248
+ "--prompt-template",
249
+ "--no-skills",
250
+ "--no-prompt-templates",
251
+ "--mode",
252
+ "--verbose",
253
+ "--print", "-p",
254
+ "--help", "-h",
255
+ ];
256
+
257
+ /**
258
+ * Levenshtein. Ten lines instead of a dependency. `--no-session` and
259
+ * `--no-sessions` are distance 1: the caller must suggest, never apply.
260
+ */
261
+ function editDistance(a: string, b: string): number {
262
+ const n = b.length;
263
+ const prev = Array.from({ length: n + 1 }, (_, j) => j);
264
+ const cur = new Array<number>(n + 1);
265
+ for (let i = 1; i <= a.length; i++) {
266
+ cur[0] = i;
267
+ for (let j = 1; j <= n; j++) {
268
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
269
+ cur[j] = Math.min(prev[j]! + 1, cur[j - 1]! + 1, prev[j - 1]! + cost);
270
+ }
271
+ for (let j = 0; j <= n; j++) prev[j] = cur[j]!;
272
+ }
273
+ return prev[n]!;
274
+ }
275
+
276
+ function closestKnownFlag(flag: string): string {
277
+ let best = KNOWN_FLAGS[0]!;
278
+ let bestD = Infinity;
279
+ for (const known of KNOWN_FLAGS) {
280
+ const d = editDistance(flag, known);
281
+ if (d < bestD || (d === bestD && known < best)) {
282
+ bestD = d;
283
+ best = known;
284
+ }
285
+ }
286
+ return best;
287
+ }
288
+
289
+ /**
290
+ * A warning that still exits 0 is how this bug hid: scripts treated a typo
291
+ * as success. Suggest the nearest name and stop. Never rewrite the argv.
292
+ */
293
+ function rejectUnknownFlag(flag: string): never {
294
+ const suggestion = closestKnownFlag(flag);
295
+ console.error(`unknown flag: ${flag}`);
296
+ console.error(` did you mean ${suggestion}?`);
297
+ process.exit(1);
298
+ }
299
+
300
+ /**
301
+ * Stop the terminal echoing before startup does anything slow.
302
+ *
303
+ * Reading auth, scanning context files and printing the header all happen
304
+ * before the TUI attaches. In cooked mode the tty echoes whatever is typed
305
+ * during that window, so keystrokes land *between* the banner's own lines
306
+ * and look like output the program produced. Raw mode is the TUI's anyway;
307
+ * taking it at the first instruction just closes the gap.
308
+ *
309
+ * `stdin` is a parameter so the decision is testable without a real tty.
310
+ * Returns an undo so a caller that bails out can put the terminal back.
311
+ */
312
+ export function silenceTtyEarly(opts: {
313
+ interactive: boolean;
314
+ stdin?: Pick<NodeJS.ReadStream, "isTTY" | "setRawMode" | "resume">;
315
+ }): () => void {
316
+ const stdin = opts.stdin ?? process.stdin;
317
+ if (!opts.interactive || stdin.isTTY !== true) return () => {};
318
+ stdin.setRawMode(true);
319
+ // Leave input buffered until ProcessTerminal installs its listener. Resuming
320
+ // here drops keystrokes typed while startup or transcript loading awaits I/O.
321
+ return () => {
322
+ if (stdin.isTTY === true) stdin.setRawMode(false);
323
+ };
324
+ }
325
+
326
+ export function parseArgs(argv: string[]): Args {
327
+ const args: Args = {
328
+ workspace: process.cwd(), logDir: "", maxTurns: 40, prompt: "",
329
+ // Overridable so a node boundary can be forced in a short session --
330
+ // otherwise cutting is only reachable after tens of thousands of tokens,
331
+ // which makes the most interesting path the least exercised one.
332
+ trigger: 0, hard: 0,
333
+ model: "", provider: "", apiKey: "", listModels: false, modelsCommand: false, modelsRefresh: false, fresh: false,
334
+ noContextFiles: false, appendSystemPrompt: [],
335
+ systemPrompt: null,
336
+ excludeTools: [], noTools: false, noBuiltinTools: false, allowTools: [],
337
+ print: false,
338
+ theme: "",
339
+ thinking: "",
340
+ models: "",
341
+ noSkills: false, noPromptTemplates: false, skillPaths: [], promptTemplatePaths: [],
342
+ continueLatest: false, session: "", forkFrom: "", noSession: false, sessionDir: "", sessionName: "",
343
+ mode: "", verbose: false,
344
+ };
345
+ for (let i = 0; i < argv.length; i++) {
346
+ const a = argv[i]!;
347
+ if (a === "--workspace" || a === "-w") args.workspace = argv[++i] ?? args.workspace;
348
+ else if (a === "--path-root") args.pathRoot = resolve(argv[++i] ?? "/");
349
+ else if (a === "--time-budget") {
350
+ const seconds = Number(argv[++i]);
351
+ if (!Number.isFinite(seconds) || seconds <= 0) throw new Error("--time-budget requires a positive number of seconds");
352
+ args.timeBudgetMs = seconds * 1000;
353
+ }
354
+ else if (a === "--log") args.logDir = argv[++i] ?? "";
355
+ else if (a === "--max-turns") args.maxTurns = Number(argv[++i] ?? args.maxTurns);
356
+ else if (a === "--trigger") args.trigger = Number(argv[++i] ?? 0);
357
+ else if (a === "--hard") args.hard = Number(argv[++i] ?? 0);
358
+ else if (a === "--context-policy") {
359
+ const policy = argv[++i];
360
+ if (!isContextPolicy(policy)) throw new Error("--context-policy requires windowed, segmented, continuous, or adaptive");
361
+ args.contextPolicy = policy;
362
+ }
363
+ else if (a === "--model" || a === "-m") args.model = argv[++i] ?? "";
364
+ else if (a === "--provider") args.provider = argv[++i] ?? "";
365
+ else if (a === "--api-key") args.apiKey = argv[++i] ?? "";
366
+ else if (a === "--list-models") args.listModels = true;
367
+ else if (a === "models" && i === 0) {
368
+ // Subcommand, not a prompt. Do not consume the rest: a later
369
+ // `--models-url` must still be an unknown-flag error, and `-h`
370
+ // must still print help.
371
+ args.modelsCommand = true;
372
+ }
373
+ else if (a === "--refresh" && args.modelsCommand) {
374
+ args.modelsRefresh = true;
375
+ }
376
+ else if (a === "--fresh") args.fresh = true;
377
+ else if (a === "-c" || a === "--continue") args.continueLatest = true;
378
+ else if (a === "-r" || a === "--resume") {
379
+ const next = argv[i + 1];
380
+ if (next !== undefined && !next.startsWith("-")) args.resume = argv[++i]!;
381
+ else args.resume = true;
382
+ }
383
+ else if (a === "--session") args.session = argv[++i] ?? "";
384
+ else if (a === "--fork") args.forkFrom = argv[++i] ?? "";
385
+ else if (a === "--no-session") args.noSession = true;
386
+ else if (a === "--session-dir") args.sessionDir = argv[++i] ?? "";
387
+ else if (a === "-n" || a === "--name") args.sessionName = argv[++i] ?? "";
388
+ else if (a === "--no-context-files" || a === "-nc") args.noContextFiles = true;
389
+ else if (a === "--system-prompt") args.systemPrompt = argv[++i] ?? "";
390
+ else if (a === "--append-system-prompt") args.appendSystemPrompt.push(argv[++i] ?? "");
391
+ else if (a === "--tools" || a === "-t") args.tools = parseToolList(argv[++i] ?? "");
392
+ else if (a === "--exclude-tools" || a === "-xt") args.excludeTools = parseToolList(argv[++i] ?? "");
393
+ else if (a === "--no-tools" || a === "-nt") args.noTools = true;
394
+ else if (a === "--no-builtin-tools") args.noBuiltinTools = true;
395
+ else if (a === "--allow-tools") args.allowTools = parseToolList(argv[++i] ?? "");
396
+ else if (a === "--approve" || a === "-a") args.approve = true;
397
+ else if (a === "--no-approve" || a === "-na") args.approve = false;
398
+ else if (a === "--theme") args.theme = argv[++i] ?? "";
399
+ else if (a === "--thinking") args.thinking = argv[++i] ?? "";
400
+ else if (a === "--models") args.models = argv[++i] ?? "";
401
+ else if (a === "--skill") args.skillPaths.push(argv[++i] ?? "");
402
+ else if (a === "--prompt-template") args.promptTemplatePaths.push(argv[++i] ?? "");
403
+ else if (a === "--no-skills") args.noSkills = true;
404
+ else if (a === "--no-prompt-templates") args.noPromptTemplates = true;
405
+ else if (a === "--mode") args.mode = argv[++i] ?? "";
406
+ else if (a === "--verbose") args.verbose = true;
407
+ else if (a === "-p" || a === "--print") {
408
+ args.print = true;
409
+ const next = argv[++i] ?? "";
410
+ args.prompt += (args.prompt ? " " : "") + next;
411
+ }
412
+ else if (a === "--help" || a === "-h") {
413
+ console.log(
414
+ "usage: xeno [-m <model>] [--provider <name>] [--api-key <key>]\n" +
415
+ " [-w <workspace>] [--log <dir>] [--max-turns N]\n" +
416
+ " [--context-policy windowed|segmented|continuous|adaptive]\n" +
417
+ " [--trigger N --hard N] [--list-models] [--fresh]\n" +
418
+ " [-c|--continue] [-r|--resume [id]] [--session path|id]\n" +
419
+ " [--fork path|id] [--no-session] [--session-dir dir]\n" +
420
+ " [-n|--name name]\n" +
421
+ " [models --refresh]\n" +
422
+ " [--system-prompt <text-or-file>] [--append-system-prompt <text>]\n" +
423
+ " [--no-context-files|-nc]\n" +
424
+ " [--tools names] [--exclude-tools names] [--no-tools]\n" +
425
+ " [--no-builtin-tools]\n" +
426
+ " [--allow-tools names] [--approve|--no-approve]\n" +
427
+ " [--theme <name|path>] [--thinking <level>] [--models a,b]\n" +
428
+ " [--skill <path>] [--no-skills]\n" +
429
+ " [--prompt-template <path>] [--no-prompt-templates]\n" +
430
+ " [--mode json] [--verbose]\n" +
431
+ " [-p <prompt>] [prompt...]\n\n" +
432
+ " A workspace that already holds a session is resumed. --fresh starts a\n" +
433
+ " separate one alongside it; nothing is ever deleted.\n" +
434
+ " -c continues the newest; --session / -r <id> pick one (unique prefix only).\n" +
435
+ " --no-session runs without writing .xeno/sessions/. --fork copies into a new one.\n" +
436
+ " A leftover .xeno/log is not moved; continue it with --log .xeno/log.\n\n" +
437
+ " With a prompt, runs it and exits. Without one, reads prompts from stdin.\n" +
438
+ " Commands: /model /thinking /login /new /resume /name /session /usage /state\n" +
439
+ " /export /copy /fork /clone\n" +
440
+ " /back [n [files|context]] [confirm] /trust /settings /hotkeys\n" +
441
+ " /help /quit\n\n" +
442
+ " Credentials resolve in this order: --api-key, then ~/.xeno/auth.json,\n" +
443
+ " then the provider's environment variable.\n" +
444
+ " --system-prompt replaces the built-in instructions (text, or a file that exists).\n" +
445
+ " --append-system-prompt appends; repeatable. -nc skips AGENTS.md discovery.\n" +
446
+ " --tools is a strict allowlist; excluded tools leave the schema.\n" +
447
+ " --no-builtin-tools drops built-ins and keeps any other source; --tools still wins.\n" +
448
+ " bash asks before it runs. --allow-tools bash skips the ask for this process.\n" +
449
+ " --time-budget <seconds> tells the model, once, when 75% of the run's wall-clock budget is used.\n" +
450
+ " --path-root <dir> lets file tools reach absolute paths under <dir> (e.g. / in a container);\n" +
451
+ " relative paths, fingerprints and /back stay in the workspace, so edits outside it are not rewound.\n" +
452
+ " --approve loads .xeno/settings.json this run; --no-approve skips it.\n" +
453
+ " Project trust does not affect tool execution.\n" +
454
+ " --mode json writes one JSON object per event to stdout; diagnostics stay on stderr.\n" +
455
+ " --verbose expands the startup header (AGENTS.md, skills, prompt templates).",
456
+ );
457
+ process.exit(0);
458
+ } else if (a === "--") {
459
+ // Everything after `--` is content, never a flag. The delimiter
460
+ // is consumed so a dashed token cannot be mistaken for an option.
461
+ for (let j = i + 1; j < argv.length; j++) {
462
+ args.prompt += (args.prompt ? " " : "") + argv[j]!;
463
+ }
464
+ break;
465
+ } else if (a.startsWith("-") && a !== "-") {
466
+ rejectUnknownFlag(a);
467
+ } else args.prompt += (args.prompt ? " " : "") + a;
468
+ }
469
+ return args;
470
+ }
471
+
472
+ /**
473
+ * Command-line prompt first, piped stdin after. Both belong in the first
474
+ * user message; reversing that would make `cat README | xeno -p "总结"`
475
+ * look like the file was the instruction.
476
+ */
477
+ export function mergePrintPrompt(prompt: string, stdinText: string): string {
478
+ if (stdinText.length === 0) return prompt;
479
+ if (prompt.length === 0) return stdinText;
480
+ return (prompt.endsWith("\n") ? prompt : `${prompt}\n`) + stdinText;
481
+ }
482
+
483
+ /**
484
+ * Drain stdin only when it is not a tty. Reading a tty would wait forever
485
+ * for a pipe that an interactive `-p` never has.
486
+ *
487
+ * `firstByteMs` bounds the wait for the FIRST byte, and is set when the
488
+ * command line already carries a prompt. An eval runner, an IDE or a parent
489
+ * script often hands a child a pipe it never writes and never closes; reading
490
+ * that to EOF hung the whole run before the banner (fix-print-stdin-hang).
491
+ * Claude Code applies the same 3s guard. Once data starts, it is read to EOF
492
+ * however slowly it arrives, so a slow producer is not truncated.
493
+ */
494
+ export async function readPipedStdin(
495
+ input: { readonly isTTY?: boolean } & AsyncIterable<unknown> = process.stdin,
496
+ opts: { firstByteMs?: number; warn?: (line: string) => void } = {},
497
+ ): Promise<string> {
498
+ if (input.isTTY === true) return "";
499
+ const chunks: Uint8Array[] = [];
500
+ const encoder = new TextEncoder();
501
+ const iterator = input[Symbol.asyncIterator]();
502
+ const push = (chunk: unknown) => {
503
+ if (typeof chunk === "string") chunks.push(encoder.encode(chunk));
504
+ else if (chunk instanceof Uint8Array) chunks.push(chunk);
505
+ };
506
+ let first: IteratorResult<unknown>;
507
+ if (opts.firstByteMs === undefined) first = await iterator.next();
508
+ else {
509
+ let timer: ReturnType<typeof setTimeout> | undefined;
510
+ const timeout = new Promise<null>((resolve) => { timer = setTimeout(() => resolve(null), opts.firstByteMs); });
511
+ const raced = await Promise.race([iterator.next(), timeout]);
512
+ clearTimeout(timer);
513
+ if (raced === null) {
514
+ (opts.warn ?? ((line) => process.stderr.write(line + "\n")))(
515
+ `warning: no stdin data received in ${(opts.firstByteMs / 1000).toFixed(0)}s, proceeding without it; ` +
516
+ "redirect stdin from /dev/null to skip the wait");
517
+ // Stop holding the pipe open for the rest of the run.
518
+ if (input === process.stdin) process.stdin.pause();
519
+ return "";
520
+ }
521
+ first = raced;
522
+ }
523
+ for (let result = first; !result.done; result = await iterator.next()) push(result.value);
524
+ if (chunks.length === 0) return "";
525
+ return new TextDecoder().decode(Buffer.concat(chunks));
526
+ }
527
+
528
+ export function isPrintMode(args: Args): boolean {
529
+ return args.print || args.prompt.length > 0;
530
+ }
531
+
532
+ export function isJsonMode(args: Args): boolean {
533
+ return args.mode === "json";
534
+ }
535
+
536
+ /**
537
+ * Most-used actions, in glance order. Keys are read off the map so an
538
+ * unbound action cannot appear as a hint (feat-status-line rule 6, reused
539
+ * here). Labels are not chords; a chord that is not in `byKey` is a bug.
540
+ */
541
+ const HEADER_SHORTCUTS: readonly { action: ActionId; label: string }[] = [
542
+ { action: "app.interrupt", label: "interrupt" },
543
+ { action: "app.toggleTools", label: "tools" },
544
+ { action: "app.toggleThinking", label: "think" },
545
+ { action: "app.externalEditor", label: "editor" },
546
+ { action: "app.exit", label: "quit" },
547
+ ];
548
+
549
+ export function formatShortcutLine(keymap: Keymap): string {
550
+ const parts: string[] = [];
551
+ for (const { action, label } of HEADER_SHORTCUTS) {
552
+ const key = keymap.byAction[action]?.[0];
553
+ if (key === undefined) continue;
554
+ parts.push(`${key} ${label}`);
555
+ }
556
+ return parts.join(" · ");
557
+ }
558
+
559
+ export function formatStartupHeader(input: {
560
+ readonly model: string;
561
+ readonly workspace: string;
562
+ readonly session: string;
563
+ readonly agents: readonly string[];
564
+ readonly skills: readonly string[];
565
+ readonly prompts: readonly string[];
566
+ readonly tools?: readonly string[];
567
+ readonly keymap: Keymap;
568
+ readonly verbose: boolean;
569
+ }): string[] {
570
+ const lines: string[] = [
571
+ input.model,
572
+ `workspace ${input.workspace}`,
573
+ `session ${input.session}`,
574
+ ];
575
+ const pushCount = (label: string, items: readonly string[]): void => {
576
+ lines.push(`${label.padEnd(10)}${items.length}`);
577
+ if (input.verbose) {
578
+ for (const item of items) lines.push(` ${item}`);
579
+ }
580
+ };
581
+ pushCount("agents", input.agents);
582
+ pushCount("skills", input.skills);
583
+ pushCount("prompts", input.prompts);
584
+ if (input.tools !== undefined) pushCount("tools", input.tools);
585
+ const keys = formatShortcutLine(input.keymap);
586
+ if (keys.length > 0) lines.push(`keys ${keys}`);
587
+ return lines;
588
+ }
589
+
590
+ function countCatalogDelta(
591
+ beforeText: string | null,
592
+ afterText: string | null,
593
+ ): { added: number; changed: number } {
594
+ const ids = (text: string | null): Map<string, string> => {
595
+ const map = new Map<string, string>();
596
+ if (text === null || text.trim().length === 0) return map;
597
+ let json: unknown;
598
+ try {
599
+ json = JSON.parse(text);
600
+ } catch {
601
+ return map;
602
+ }
603
+ for (const spec of parseUserCatalog(json).models) {
604
+ map.set(spec.id, JSON.stringify(spec));
605
+ }
606
+ return map;
607
+ };
608
+ const before = ids(beforeText);
609
+ const after = ids(afterText);
610
+ let added = 0;
611
+ let changed = 0;
612
+ for (const [id, body] of after) {
613
+ const prev = before.get(id);
614
+ if (prev === undefined) added += 1;
615
+ else if (prev !== body) changed += 1;
616
+ }
617
+ return { added, changed };
618
+ }
619
+
620
+ /**
621
+ * Explicit catalog refresh. Download and validate before the write; a
622
+ * failure returns a reason and must not have touched the existing file.
623
+ * Exit status is the caller's: the CLI process exits non-zero, `/models
624
+ * refresh` only prints.
625
+ */
626
+ export async function runModelsRefresh(opts: {
627
+ fetch: CatalogFetch;
628
+ write: (value: unknown) => Promise<void>;
629
+ }): Promise<
630
+ | { ok: true; count: number; added: number; changed: number }
631
+ | { ok: false; reason: string }
632
+ > {
633
+ const before = await readModelsText();
634
+ const result = await refreshRemoteCatalog({
635
+ fetch: opts.fetch,
636
+ write: opts.write,
637
+ });
638
+ if (!result.ok) return result;
639
+ const after = await readModelsText();
640
+ const delta = countCatalogDelta(before.text, after.text);
641
+ return { ok: true, count: result.count, added: delta.added, changed: delta.changed };
642
+ }
643
+
644
+ /**
645
+ * The two effects credential resolution needs. Real ones here; the tests
646
+ * inject fakes, which is why the priority rules can be pinned offline.
647
+ *
648
+ * A key command runs once per process. Re-running `security find-generic-
649
+ * password` on every request would spend a keychain round trip per turn to
650
+ * re-learn something that has not changed.
651
+ */
652
+ const commandCache = new Map<string, string>();
653
+
654
+ export function processEffects(): ResolveEffects {
655
+ return {
656
+ env: (name) => process.env[name],
657
+ run: (command) => {
658
+ const cached = commandCache.get(command);
659
+ if (cached !== undefined) return cached;
660
+ const p = Bun.spawnSync(["bash", "-lc", command]);
661
+ if (p.exitCode !== 0) {
662
+ throw new Error(`exit ${p.exitCode}: ${new TextDecoder().decode(p.stderr).trim()}`);
663
+ }
664
+ const out = new TextDecoder().decode(p.stdout);
665
+ commandCache.set(command, out);
666
+ return out;
667
+ },
668
+ };
669
+ }
670
+
671
+ /**
672
+ * Mutable I/O for a running app. The TUI replaces `say` so retry lines
673
+ * land in the transcript rather than punching through the live region.
674
+ * The object identity is stable: providers close over this reference.
675
+ */
676
+ export interface AppIO {
677
+ palette: Palette;
678
+ say: (line: string) => void;
679
+ err: (line: string) => void;
680
+ /**
681
+ * Print mode only. The assistant's final text belongs on stdout; `say` is
682
+ * stderr there so a script cannot mistake a draft or a banner for the
683
+ * answer. Absent in interactive and piped-REPL runs, which still print
684
+ * the whole transcript through `say`.
685
+ */
686
+ answer?: (text: string) => void;
687
+ }
688
+
689
+ export interface App {
690
+ args: Args;
691
+ session: Session;
692
+ workspace: string;
693
+ logDir: string;
694
+ blobDir: string;
695
+ system: string;
696
+ providerConfig: ProviderConfig;
697
+ /** May be `{ ok: false }` in an interactive session waiting on /login. */
698
+ credential: Resolved;
699
+ auth: AuthFile;
700
+ effects: ResolveEffects;
701
+ loadedContext: LoadedContext;
702
+ credentialOf: (spec: ModelSpec) => Resolved;
703
+ catalog: readonly ModelSpec[];
704
+ io: AppIO;
705
+ /**
706
+ * The TUI writes `current` while it can ask. One-shot and piped runs
707
+ * leave it empty, so an ask becomes a named refusal.
708
+ */
709
+ askSlot: AskSlot;
710
+ ui?: UiState;
711
+ slash?: SlashCatalog;
712
+ }
713
+
714
+ export interface UiState {
715
+ settings: Settings;
716
+ theme: UiResources["theme"];
717
+ keymap: UiResources["keymap"];
718
+ userOverrides: Partial<Settings>;
719
+ style: Style;
720
+ }
721
+
722
+ function toUiState(loaded: UiResources, color: boolean): UiState {
723
+ return {
724
+ settings: loaded.settings,
725
+ theme: loaded.theme,
726
+ keymap: loaded.keymap,
727
+ userOverrides: loaded.userOverrides,
728
+ style: color ? createStyle(loaded.theme.colors) : IDENTITY,
729
+ };
730
+ }
731
+
732
+ function asEffort(value: string | undefined): Effort | undefined {
733
+ if (value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max") {
734
+ return value;
735
+ }
736
+ return undefined;
737
+ }
738
+
739
+ /** `--models a,b` and settings.scopedModels share this split so a stray space cannot invent a pattern. */
740
+ export function splitModelPatterns(raw: string | undefined): string[] {
741
+ if (raw === undefined || raw.length === 0) return [];
742
+ return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
743
+ }
744
+
745
+ /**
746
+ * One line saying where the key came from, or why there isn't one.
747
+ *
748
+ * When a stored entry is what failed, the reason has to be the stored entry
749
+ * -- naming the environment variable would offer a fix that this resolver
750
+ * will then deliberately ignore, because a stored credential owns its
751
+ * provider.
752
+ */
753
+ export function whyNot(spec: ModelSpec, got: Resolved, auth: AuthFile, C: Palette): string {
754
+ if (got.ok) return C.dim(` via ${got.source}`);
755
+ if (auth[spec.provider] !== undefined) return C.yellow(` ${got.reason}`);
756
+ return C.dim(` ${envLabel(spec.provider)}`);
757
+ }
758
+
759
+ /**
760
+ * /login only exists inside a live session. A one-shot prompt and a pipe
761
+ * cannot answer it, so those runs must still refuse -- naming /login there
762
+ * is how this file used to deadlock a first run.
763
+ */
764
+ /**
765
+ * What a non-interactive run says when it has no key. A model named with
766
+ * --model is the only thing the caller asked about; listing every provider
767
+ * hides the one variable that would fix it.
768
+ */
769
+ export function missingCredentialMessage(spec: ModelSpec, explicit: boolean, catalog: readonly ModelSpec[] = CATALOG): string {
770
+ return explicit ? `${spec.id}: ${describeMissingCredential(spec.provider)}` : describeUnauthenticatedScript(catalog);
771
+ }
772
+
773
+ export function isInteractiveSession(args: Args): boolean {
774
+ return args.prompt.length === 0 && stdinIsTty() && stdoutIsTty();
775
+ }
776
+
777
+ /**
778
+ * Script-facing missing-key text. Environment variables and --api-key are
779
+ * the routes that actually work without a tty; /login is not one of them.
780
+ */
781
+ export function describeUnauthenticatedScript(catalog: readonly ModelSpec[] = CATALOG): string {
782
+ const providers = [...new Set(catalog.map((m) => m.provider))];
783
+ const vars = providers.map((p) => envLabel(p));
784
+ return (
785
+ `no model has a credential. Set an API key for one of: ${providers.join(", ")}` +
786
+ ` -- export ${vars.join(" or ")}, or pass --api-key`
787
+ );
788
+ }
789
+
790
+ /** Banner / hint for an interactive session that started without a key. */
791
+ export function describeUnauthenticatedSession(catalog: readonly ModelSpec[] = CATALOG): string {
792
+ const vars = [...new Set(catalog.map((m) => m.provider))].map((p) => envLabel(p));
793
+ return (
794
+ `no credential. This session cannot send until one is set. ` +
795
+ `Export ${vars.join(" or ")}, or run /login <provider>`
796
+ );
797
+ }
798
+
799
+ export function retryHandler(io: AppIO): (attempt: number, delayMs: number, error: unknown) => void {
800
+ return (attempt, delayMs, error) => {
801
+ const C = io.palette;
802
+ // A retry that prints nothing is indistinguishable from a hang, which is
803
+ // the problem retrying was supposed to remove rather than one to add.
804
+ io.say(
805
+ C.yellow(` retry ${attempt} in ${(delayMs / 1000).toFixed(1)}s`) +
806
+ C.dim(` ${describeError(error)}`),
807
+ );
808
+ };
809
+ }
810
+
811
+ export function toolSelectionOf(args: Args) {
812
+ return {
813
+ ...(args.noTools ? { none: true as const } : {}),
814
+ ...(args.tools !== undefined ? { only: args.tools } : {}),
815
+ ...(args.excludeTools.length > 0 ? { exclude: args.excludeTools } : {}),
816
+ ...(args.noBuiltinTools === true ? { noBuiltin: true as const } : {}),
817
+ };
818
+ }
819
+
820
+ /**
821
+ * Schema assembly: every tool is tagged with a source here so
822
+ * `--no-builtin-tools` can drop a category without a later cross-layer
823
+ * recut. Today the only producer is builtin; an extension would push
824
+ * `{ ...schema, source: "extension" }` into this list.
825
+ *
826
+ * `source` is stripped before the frozen prefix: the wire schema is what
827
+ * the provider bills, and an extra field there would look like a tool
828
+ * argument.
829
+ */
830
+ export function visibleSchemas(args: Args) {
831
+ const available = TOOL_SCHEMAS.map((t) => ({ ...t, source: "builtin" as const }));
832
+ return selectTools(available, toolSelectionOf(args)).map((tool) => {
833
+ const { source, ...schema } = tool;
834
+ void source;
835
+ return schema;
836
+ });
837
+ }
838
+
839
+ export function unknownToolNames(names: readonly string[]): string[] {
840
+ const known = new Set(TOOL_SCHEMAS.map((t) => t.name));
841
+ return names.filter((n) => !known.has(n));
842
+ }
843
+
844
+ export function createSessionFor(opts: {
845
+ args: Args;
846
+ workspace: string;
847
+ logDir: string;
848
+ blobDir: string;
849
+ system: string;
850
+ providerConfig: ProviderConfig;
851
+ credential: Resolved;
852
+ io: AppIO;
853
+ askSlot: AskSlot;
854
+ oauth?: boolean;
855
+ }): Session {
856
+ const { args, workspace, logDir, blobDir, system, providerConfig, credential, io, askSlot } = opts;
857
+ const schemas = visibleSchemas(args);
858
+ const names = schemas.map((t) => t.name);
859
+ const sessionRules: PermissionRule[] = [];
860
+ const tools: ToolContext = {
861
+ root: workspace,
862
+ ...DEFAULT_TOOL_CONTEXT,
863
+ ...(args.pathRoot === undefined ? {} : { pathRoot: args.pathRoot }),
864
+ allow: names,
865
+ rules: rulesWithAllowTools(args.allowTools),
866
+ sessionRules,
867
+ askSlot,
868
+ };
869
+ return new Session(
870
+ {
871
+ logDir,
872
+ frozen: { system, tools: schemas },
873
+ blobDir,
874
+ tools,
875
+ maxTurns: args.maxTurns,
876
+ ...cliSessionConfig(args),
877
+ // Explicit legacy thresholds still select that experiment unless overridden.
878
+ contextPolicy: args.contextPolicy ?? (args.trigger > 0 ? "segmented" : DEFAULT_CONTEXT_POLICY),
879
+ ...(args.trigger > 0
880
+ ? {
881
+ boundary: {
882
+ triggerTokens: args.trigger,
883
+ hardTokens: args.hard || args.trigger * 2,
884
+ // The work floor has to scale with the trigger: the two are
885
+ // validated against each other, and a default floor of 5000 under
886
+ // an experimental trigger of 1200 is a contradiction.
887
+ minNodeWorkTokens: Math.max(1, Math.floor(args.trigger / 6)),
888
+ },
889
+ }
890
+ : {}),
891
+ },
892
+ // Empty key is a placeholder the shell must not send through. Interactive
893
+ // startup reaches this path so /login exists; run() refuses until a real
894
+ // credential is installed on the session.
895
+ createProvider(
896
+ providerConfig,
897
+ credential.ok ? credential.key : "",
898
+ retryHandler(io),
899
+ opts.oauth === true ? { oauth: true } : undefined,
900
+ ),
901
+ systemClock(),
902
+ );
903
+ }
904
+
905
+ export async function startup(args: Args): Promise<App> {
906
+ const C = createPalette(colorEnabled());
907
+ const printMode = isPrintMode(args);
908
+ const jsonMode = isJsonMode(args);
909
+ if ((args.mode ?? "").length > 0 && args.mode !== "json") {
910
+ console.error(`unknown --mode ${args.mode} (supported: json)`);
911
+ process.exit(1);
912
+ }
913
+ const divert = printMode || jsonMode;
914
+ const io: AppIO = {
915
+ palette: C,
916
+ say: (line) => (divert ? console.error(line) : console.log(line)),
917
+ err: (line) => console.error(line),
918
+ ...(printMode && !jsonMode
919
+ ? {
920
+ answer: (text: string) => {
921
+ process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
922
+ },
923
+ }
924
+ : {}),
925
+ };
926
+ const effects = processEffects();
927
+
928
+ const authRead = await readJsonFile<AuthFile>(authPath());
929
+ if (authRead.error) io.say(C.yellow(` ${authRead.error}`));
930
+ const auth: AuthFile = authRead.value ?? {};
931
+
932
+ if (args.modelsCommand) {
933
+ if (!args.modelsRefresh) {
934
+ io.say("usage: xeno models --refresh");
935
+ io.say(C.dim(" fetches models.dev into ~/.xeno/models.json; startup never does this."));
936
+ process.exit(0);
937
+ }
938
+ const result = await runModelsRefresh({
939
+ fetch: globalThis.fetch,
940
+ write: writeModelsFile,
941
+ });
942
+ if (result.ok) {
943
+ io.say(` added ${result.added}, changed ${result.changed} (${result.count} total) → ${modelsPath()}`);
944
+ process.exit(0);
945
+ } else {
946
+ io.say(C.yellow(` ${result.reason}`));
947
+ io.say(C.dim(" existing models.json left unchanged"));
948
+ process.exit(1);
949
+ }
950
+ }
951
+
952
+ // File only. Passing fetch here would make the test of D16 un-runnable.
953
+ const userFile = await readModelsText();
954
+ const loaded = loadCatalog({ userText: userFile.text });
955
+ for (const warning of loaded.warnings) io.say(C.yellow(` ${warning}`));
956
+ const catalog = loaded.models;
957
+
958
+ const credentialOf = (spec: ModelSpec) => resolveCredential(spec, auth, args.apiKey, effects);
959
+ const hasCredential = (spec: ModelSpec) => credentialOf(spec).ok;
960
+
961
+ if (args.listModels) {
962
+ io.say(C.bold("models"));
963
+ for (const group of groupedModels(catalog)) {
964
+ io.say(C.bold(group.provider));
965
+ for (const spec of group.models) {
966
+ const got = credentialOf(spec);
967
+ const mark = got.ok ? C.green("ready") : C.dim(" -- ");
968
+ io.say(` ${mark} ${spec.id.padEnd(28)}${C.dim(spec.name.padEnd(28))}${whyNot(spec, got, auth, C)}`);
969
+ }
970
+ }
971
+ io.say(C.dim("\n run one with --model <id>"));
972
+ process.exit(0);
973
+ }
974
+
975
+ const named = [
976
+ ...(args.tools ?? []),
977
+ ...args.excludeTools,
978
+ ...args.allowTools,
979
+ ];
980
+ const unknown = unknownToolNames(named);
981
+ if (unknown.length > 0) {
982
+ io.err(C.red(`\n unknown tool: ${unknown.join(", ")}`));
983
+ io.err(C.dim(` known: ${TOOL_SCHEMAS.map((t) => t.name).join(", ")}\n`));
984
+ process.exit(1);
985
+ }
986
+
987
+ /**
988
+ * Resolving the workspace is the first thing that can fail, and it fails for
989
+ * ordinary reasons -- a typo in `-w`, a directory that was moved. Reported as
990
+ * a sentence rather than as a stat() stack trace.
991
+ *
992
+ * It has to happen before model selection so a trusted `.xeno/settings.json`
993
+ * can override the default model. Trust does not affect tools.
994
+ */
995
+ let workspace: string;
996
+ try {
997
+ workspace = await workspaceRoot(args.workspace);
998
+ } catch (err) {
999
+ const detail = err instanceof Error ? err.message : String(err);
1000
+ io.err(C.red(`\n cannot use ${args.workspace} as a workspace`));
1001
+ io.err(C.dim(` ${detail}\n`));
1002
+ process.exit(1);
1003
+ }
1004
+ // A path root that does not contain the workspace would confine relative
1005
+ // paths out of the very directory they resolve in.
1006
+ if (args.pathRoot !== undefined) {
1007
+ const rel = relative(args.pathRoot, workspace);
1008
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
1009
+ io.err(C.red(`\n --path-root ${args.pathRoot} does not contain the workspace ${workspace}\n`));
1010
+ process.exit(1);
1011
+ }
1012
+ }
1013
+
1014
+ const trustRead = await readTrust();
1015
+ if (trustRead.error) io.say(C.yellow(` ${trustRead.error}`));
1016
+ let loadProjectSettings = false;
1017
+ if (args.approve === true) {
1018
+ loadProjectSettings = true;
1019
+ } else if (args.approve === false) {
1020
+ loadProjectSettings = false;
1021
+ } else {
1022
+ const decision = lookupTrust(trustRead.value, workspace);
1023
+ if (decision === true) loadProjectSettings = true;
1024
+ else if (decision === false) loadProjectSettings = false;
1025
+ else {
1026
+ loadProjectSettings = false;
1027
+ try {
1028
+ await stat(projectSettingsPath(workspace));
1029
+ io.say(
1030
+ C.dim(
1031
+ ` ${projectSettingsPath(workspace)} not loaded (project is not trusted). ` +
1032
+ `Trust does not affect tool execution. /trust saves a decision, or pass --approve this run.`,
1033
+ ),
1034
+ );
1035
+ } catch {
1036
+ // No project settings file: nothing to load, no notice.
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ const uiLoaded = await loadUiResources({
1042
+ workspace,
1043
+ trusted: loadProjectSettings,
1044
+ ...(args.theme.length > 0 ? { theme: args.theme } : {}),
1045
+ });
1046
+ for (const line of uiLoaded.diagnostics) io.say(C.yellow(` ${line}`));
1047
+ const mergedSettings = uiLoaded.settings;
1048
+ const ui = toUiState(uiLoaded, colorEnabled());
1049
+ const scopedFromFlag = splitModelPatterns(args.models);
1050
+ if (scopedFromFlag.length > 0) {
1051
+ ui.settings = { ...ui.settings, scopedModels: scopedFromFlag };
1052
+ }
1053
+
1054
+ const selected = selectModel(mergedSettings, { model: args.model, provider: args.provider }, hasCredential, catalog);
1055
+ const interactive = isInteractiveSession(args);
1056
+ let spec: ModelSpec;
1057
+ if (selected.ok) {
1058
+ spec = selected.spec;
1059
+ } else {
1060
+ const missingAll = selected.reason.startsWith("no model has a credential");
1061
+ if (!missingAll || !interactive) {
1062
+ io.err(C.red(`\n ${missingAll ? describeUnauthenticatedScript(catalog) : selected.reason}\n`));
1063
+ process.exit(1);
1064
+ }
1065
+ // Same order as selectModel, ignoring credentials, so the banner names a
1066
+ // real catalog model rather than inventing one.
1067
+ const fallback = selectModel(mergedSettings, { model: args.model, provider: args.provider }, () => true, catalog);
1068
+ const picked = fallback.ok ? fallback.spec : catalog[0];
1069
+ if (picked === undefined) {
1070
+ io.err(C.red("\n catalog is empty\n"));
1071
+ process.exit(1);
1072
+ }
1073
+ spec = picked;
1074
+ }
1075
+
1076
+ const credential = credentialOf(spec);
1077
+ if (!credential.ok && !interactive) {
1078
+ io.err(C.red(`\n ${missingCredentialMessage(spec, args.model.length > 0, catalog)}\n`));
1079
+ process.exit(1);
1080
+ }
1081
+
1082
+ const providerConfig: ProviderConfig = {
1083
+ spec,
1084
+ maxTokens: outputTokenBudget(spec),
1085
+ // --thinking wins, then a Ctrl+S default, then /settings thinking / builtin.
1086
+ effort:
1087
+ asEffort(args.thinking) ??
1088
+ asEffort(mergedSettings.defaultThinking) ??
1089
+ asEffort(mergedSettings.thinking) ??
1090
+ DEFAULT_PROVIDER.effort,
1091
+ thinkingDisplay: DEFAULT_PROVIDER.thinkingDisplay,
1092
+ };
1093
+ /**
1094
+ * `--fresh` opens a NEW session beside the old one rather than clearing it.
1095
+ *
1096
+ * Deleting a session's history is destructive and irreversible, and it must
1097
+ * never be the side effect of a flag that reads like "start over". The old
1098
+ * log stays where it is, still resumable by pointing --log at it. A leftover
1099
+ * `.xeno/log` from before named sessions is the same: pointed at, not moved.
1100
+ */
1101
+ const sessionDir = (args.sessionDir ?? "").length > 0 ? args.sessionDir : undefined;
1102
+ const sessionPick =
1103
+ (args.session ?? "").length > 0
1104
+ ? args.session
1105
+ : typeof args.resume === "string"
1106
+ ? args.resume
1107
+ : undefined;
1108
+ let logDir: string;
1109
+ try {
1110
+ const forkFrom = args.forkFrom;
1111
+ if (forkFrom !== undefined && forkFrom.length > 0) {
1112
+ const found = await findSessionByPrefix(workspace, forkFrom, sessionDir);
1113
+ if (!found.ok) {
1114
+ io.err(C.red(`\n ${found.reason}\n`));
1115
+ process.exit(1);
1116
+ }
1117
+ const created = await createSession(workspace, undefined, sessionDir);
1118
+ await copyAncestryTo(found.session.path, created.path, new Date().toISOString());
1119
+ logDir = created.path;
1120
+ } else {
1121
+ const resolved = await resolveLogDir(workspace, {
1122
+ ...(args.logDir ? { explicit: resolve(args.logDir) } : {}),
1123
+ fresh: args.fresh,
1124
+ ...(args.continueLatest === true ? { continueLatest: true } : {}),
1125
+ ...(args.noSession === true ? { noSession: true } : {}),
1126
+ ...(sessionPick !== undefined ? { session: sessionPick } : {}),
1127
+ ...(sessionDir !== undefined ? { sessionDir } : {}),
1128
+ });
1129
+ if (resolved.legacyNotice !== null && !args.logDir) {
1130
+ io.say(C.yellow(` ${resolved.legacyNotice}`));
1131
+ }
1132
+ logDir = resolved.logDir;
1133
+ }
1134
+ } catch (err) {
1135
+ const detail = err instanceof Error ? err.message : String(err);
1136
+ io.err(C.red(`\n ${detail}\n`));
1137
+ process.exit(1);
1138
+ }
1139
+ if (args.resume === true && sessionPick === undefined && (args.forkFrom ?? "").length === 0 && args.noSession !== true) {
1140
+ // `-r` with no id is a selector, not a guess. Print mode cannot pick.
1141
+ if (isPrintMode(args)) {
1142
+ io.err(C.red("\n -r needs a session id. Pass -r <id> or --session <id>.\n"));
1143
+ process.exit(1);
1144
+ }
1145
+ }
1146
+ // Snapshots are on by default here. They are optional in the loop because a
1147
+ // read-only branch has nothing to snapshot, but a session a person drives is
1148
+ // exactly the one that will want to go back, and the content has to have been
1149
+ // stored before the turn a person wants back -- it cannot be opted into after.
1150
+ const blobDir = join(workspace, ".xeno", "blobs");
1151
+ await mkdir(logDir, { recursive: true });
1152
+ await mkdir(blobDir, { recursive: true });
1153
+
1154
+ /**
1155
+ * Context files are read once, here, and the result is frozen. Re-reading
1156
+ * them at a node boundary would change `system` and invalidate the prefix
1157
+ * cache for the whole conversation (D6). A reload is a cut; that is a
1158
+ * later card.
1159
+ */
1160
+ const loadedContext = await loadContext({
1161
+ workspace,
1162
+ cwd: process.cwd(),
1163
+ configDir: configDir(),
1164
+ noContextFiles: args.noContextFiles,
1165
+ appendSystemPrompt: args.appendSystemPrompt,
1166
+ ...(args.systemPrompt !== null ? { systemPrompt: args.systemPrompt } : {}),
1167
+ });
1168
+ const selection = toolSelectionOf(args);
1169
+ const visible = visibleSchemas(args);
1170
+ const filtered =
1171
+ selection.only !== undefined ||
1172
+ selection.none === true ||
1173
+ (selection.exclude !== undefined && selection.exclude.length > 0) ||
1174
+ selection.noBuiltin === true;
1175
+ const builtin = systemPromptFor((filtered ? visible : TOOL_SCHEMAS).map((t) => t.name), sessionEnvironment(workspace));
1176
+ const textLoaded = await loadTextResources({
1177
+ workspace,
1178
+ configDir: configDir(),
1179
+ trusted: loadProjectSettings,
1180
+ noSkills: args.noSkills,
1181
+ noPromptTemplates: args.noPromptTemplates,
1182
+ skillPaths: args.skillPaths,
1183
+ promptTemplatePaths: args.promptTemplatePaths,
1184
+ });
1185
+ for (const line of textLoaded.diagnostics) io.say(C.yellow(` ${line}`));
1186
+ const slashResolved = resolveSlashCatalog(textLoaded.prompts, textLoaded.skills);
1187
+ for (const line of slashResolved.diagnostics) io.say(C.yellow(` ${line}`));
1188
+ const slash = slashResolved.catalog;
1189
+ const system = appendSkillsToSystem(
1190
+ assembleSystemPrompt(builtin, loadedContext),
1191
+ [...slash.skills.values()],
1192
+ );
1193
+
1194
+ const askSlot: AskSlot = {};
1195
+ const oauth = credential.ok && storedIsOAuth(auth, spec.provider) && args.apiKey.length === 0;
1196
+ const session = createSessionFor({
1197
+ args, workspace, logDir, blobDir, system, providerConfig, credential, io, askSlot,
1198
+ ...(oauth ? { oauth: true } : {}),
1199
+ });
1200
+ // Read before starting, so the banner can say whether this is a new session
1201
+ // or a continued one. start() moves the cursor, which would erase the answer.
1202
+ const priorCursor = await readCursor(logDir);
1203
+
1204
+ try {
1205
+ await session.start();
1206
+ } catch (err) {
1207
+ const detail = err instanceof Error ? err.message : String(err);
1208
+ io.err(C.red(`\n cannot start a session in ${workspace}`));
1209
+ io.err(C.dim(` ${detail}\n`));
1210
+ process.exit(1);
1211
+ }
1212
+
1213
+ const sessionName = args.sessionName;
1214
+ if (sessionName !== undefined && sessionName.length > 0) {
1215
+ const cursor = await readCursor(logDir);
1216
+ if (cursor !== null) {
1217
+ await writeCursor(logDir, { ...cursor, name: sessionName });
1218
+ }
1219
+ }
1220
+
1221
+ printBanner(args, io, {
1222
+ workspace, logDir, providerConfig, credential, loadedContext, session, priorCursor, catalog,
1223
+ keymap: ui.keymap,
1224
+ skills: [...slash.skills.keys()],
1225
+ prompts: [...slash.prompts.keys()],
1226
+ tools: visible.map((t) => t.name),
1227
+ });
1228
+
1229
+ return {
1230
+ args, session, workspace, logDir, blobDir, system, providerConfig, credential,
1231
+ auth, effects, loadedContext, credentialOf, catalog, io, askSlot, ui, slash,
1232
+ };
1233
+ }
1234
+
1235
+ function printBanner(
1236
+ args: Args,
1237
+ io: AppIO,
1238
+ opts: {
1239
+ workspace: string;
1240
+ logDir: string;
1241
+ providerConfig: ProviderConfig;
1242
+ credential: Resolved;
1243
+ loadedContext: LoadedContext;
1244
+ session: Session;
1245
+ priorCursor: Cursor | null;
1246
+ catalog: readonly ModelSpec[];
1247
+ keymap: Keymap;
1248
+ skills: readonly string[];
1249
+ prompts: readonly string[];
1250
+ tools: readonly string[];
1251
+ },
1252
+ ): void {
1253
+ const C = io.palette;
1254
+ const {
1255
+ workspace, logDir, providerConfig, credential, loadedContext, session, priorCursor, catalog,
1256
+ keymap, skills, prompts, tools,
1257
+ } = opts;
1258
+ io.say(
1259
+ C.bold("xeno") +
1260
+ C.dim(` ${providerConfig.spec.name} effort=${providerConfig.effort}`) +
1261
+ (credential.ok ? C.dim(` key from ${credential.source}`) : C.yellow(" no credential")),
1262
+ );
1263
+ const header = formatStartupHeader({
1264
+ model: providerConfig.spec.id,
1265
+ workspace,
1266
+ session: basename(logDir),
1267
+ agents: loadedContext.agents.map((f) => f.path),
1268
+ skills,
1269
+ prompts,
1270
+ tools,
1271
+ keymap,
1272
+ verbose: args.verbose === true,
1273
+ });
1274
+ for (const line of header) {
1275
+ // The first header line is the model; the banner already named it.
1276
+ if (line === providerConfig.spec.id) continue;
1277
+ io.say(C.dim(line));
1278
+ }
1279
+ io.say(C.dim(`log ${logDir}`));
1280
+ for (const notice of loadedContext.notices) {
1281
+ const line = formatNotice(notice);
1282
+ io.say(notice.kind === "skipped" ? C.yellow(` ${line}`) : C.dim(` ${line}`));
1283
+ }
1284
+ const { triggerTokens: trigger, hardTokens: hard } = { ...DEFAULT_BOUNDARY, ...session.config.boundary };
1285
+ const policy = session.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY;
1286
+ const budget = windowBudget(session.provider.config.spec.contextWindow, session.provider.config.maxTokens);
1287
+ const description = policy === "continuous" ? "continuous replay; stops at model window"
1288
+ : policy === "windowed" && budget !== null ? `windowed replay; prepare ${budget.prepare}, cut ${budget.trigger}, output reserved`
1289
+ : `${policy}${policy === "windowed" ? " (unknown window; fixed fallback)" : ""}; cut ${trigger}, hard ${hard}`;
1290
+ io.say(C.dim(`boundary ${description} (estimated tokens)`));
1291
+ if (priorCursor !== null) {
1292
+ const events = session.state.events.length;
1293
+ io.say(
1294
+ C.green(`resumed node ${session.state.node}`) +
1295
+ C.dim(` ${events} event${events === 1 ? "" : "s"} in context, last ${priorCursor.updated_at}`),
1296
+ );
1297
+ }
1298
+ if (!credential.ok) {
1299
+ io.say(C.yellow(` ${describeUnauthenticatedSession(catalog)}`));
1300
+ }
1301
+ io.say("");
1302
+ }