@visual-z/xeno 0.1.4 → 0.2.1

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 (147) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +143 -33
  3. package/docs/install.md +23 -6
  4. package/docs/providers.md +147 -0
  5. package/package.json +8 -2
  6. package/src/apps/cli.ts +45 -18
  7. package/src/apps/commands.ts +32 -4
  8. package/src/apps/completions.ts +9 -6
  9. package/src/apps/export-cli.ts +24 -0
  10. package/src/apps/extension-ui.ts +330 -0
  11. package/src/apps/input.ts +16 -1
  12. package/src/apps/model-catalog.ts +42 -0
  13. package/src/apps/project-trust.ts +49 -0
  14. package/src/apps/render.ts +1 -0
  15. package/src/apps/resource-browser.ts +75 -0
  16. package/src/apps/resources.ts +25 -0
  17. package/src/apps/session-browser.ts +113 -0
  18. package/src/apps/session-picker.ts +55 -0
  19. package/src/apps/shell.ts +1177 -378
  20. package/src/apps/startup-input.ts +94 -0
  21. package/src/apps/startup.ts +413 -295
  22. package/src/core/action-defaults.ts +306 -0
  23. package/src/core/attachment-input.ts +17 -0
  24. package/src/core/catalog-merge.ts +19 -0
  25. package/src/core/event-validation.ts +19 -2
  26. package/src/core/events.ts +45 -2
  27. package/src/core/export.ts +6 -2
  28. package/src/core/extension-flags.ts +14 -0
  29. package/src/core/images.ts +46 -0
  30. package/src/core/json-event.ts +2 -0
  31. package/src/core/keymap.ts +43 -119
  32. package/src/core/model-replay.ts +22 -0
  33. package/src/core/model-scope.ts +53 -0
  34. package/src/core/packages.ts +47 -0
  35. package/src/core/project.ts +21 -3
  36. package/src/core/prompts.ts +33 -5
  37. package/src/core/queue.ts +18 -0
  38. package/src/core/resource-selection.ts +36 -0
  39. package/src/{runtime → core}/resume.ts +15 -22
  40. package/src/core/session-archive.ts +4 -0
  41. package/src/core/session-browser.ts +82 -0
  42. package/src/core/session-entry.ts +56 -0
  43. package/src/core/session-position.ts +26 -0
  44. package/src/core/settings.ts +169 -154
  45. package/src/core/theme.ts +15 -6
  46. package/src/core/thinking.ts +26 -0
  47. package/src/core/tokens.ts +2 -0
  48. package/src/core/transcript.ts +15 -5
  49. package/src/runtime/auth.ts +116 -11
  50. package/src/runtime/catalog.ts +54 -151
  51. package/src/runtime/clipboard.ts +114 -0
  52. package/src/runtime/extension-loader.ts +37 -0
  53. package/src/runtime/extension-ui.ts +55 -0
  54. package/src/runtime/extensions.ts +273 -0
  55. package/src/runtime/images.ts +76 -0
  56. package/src/runtime/loop.ts +213 -83
  57. package/src/runtime/model-config.ts +232 -0
  58. package/src/runtime/model-data.LICENSE +27 -0
  59. package/src/runtime/model-data.ts +21 -0
  60. package/src/runtime/model-replay.ts +10 -0
  61. package/src/runtime/model-scope.ts +20 -0
  62. package/src/runtime/model-sources.ts +151 -0
  63. package/src/runtime/model.ts +74 -11
  64. package/src/runtime/models-fetch.ts +6 -104
  65. package/src/runtime/oauth.ts +4 -3
  66. package/src/runtime/openai-responses.ts +12 -4
  67. package/src/runtime/openai.ts +13 -4
  68. package/src/runtime/package-manager.ts +125 -0
  69. package/src/runtime/pi-provider.ts +235 -0
  70. package/src/runtime/progress.ts +26 -0
  71. package/src/runtime/provider-access.ts +21 -0
  72. package/src/runtime/provider-protocols.ts +29 -0
  73. package/src/runtime/provider-request.ts +73 -0
  74. package/src/runtime/provider.ts +26 -6
  75. package/src/runtime/rich-text.ts +36 -0
  76. package/src/runtime/tools.ts +41 -5
  77. package/src/store/command.ts +7 -1
  78. package/src/store/context-files.ts +1 -1
  79. package/src/store/log.ts +65 -0
  80. package/src/store/models.ts +28 -9
  81. package/src/store/observations.ts +4 -2
  82. package/src/store/packages.ts +98 -0
  83. package/src/store/recovery.ts +8 -0
  84. package/src/store/resource-index.ts +170 -0
  85. package/src/store/resources.ts +75 -142
  86. package/src/store/session-archive.ts +162 -0
  87. package/src/store/session-browser.ts +68 -0
  88. package/src/store/session-position.ts +27 -0
  89. package/src/store/sessions.ts +87 -66
  90. package/src/store/trust.ts +13 -0
  91. package/src/tui/chat-viewport.ts +47 -0
  92. package/src/tui/editor.ts +62 -15
  93. package/src/tui/host.ts +50 -9
  94. package/src/tui/input.ts +8 -2
  95. package/src/tui/select.ts +24 -6
  96. package/src/tui/settings.ts +4 -0
  97. package/src/tui/system-theme.ts +700 -0
  98. package/src/tui/transcript.ts +63 -11
  99. package/src/tui/tree.ts +161 -95
  100. package/src/vendor/pi-rich/LICENSE.upstream +21 -0
  101. package/src/vendor/pi-rich/UPSTREAM.json +17 -0
  102. package/src/vendor/pi-rich/clipboard-command.ts +44 -0
  103. package/src/vendor/pi-rich/exif-orientation.ts +182 -0
  104. package/src/vendor/pi-rich/html.ts +51 -0
  105. package/src/vendor/pi-rich/image-convert.ts +49 -0
  106. package/src/vendor/pi-rich/image-resize-core.ts +164 -0
  107. package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
  108. package/src/vendor/pi-rich/image-resize.ts +123 -0
  109. package/src/vendor/pi-rich/mime.ts +116 -0
  110. package/src/vendor/pi-rich/photon.ts +139 -0
  111. package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
  112. package/src/vendor/pi-tui/UPSTREAM.json +50 -0
  113. package/src/vendor/pi-tui/VENDOR.md +7 -5
  114. package/src/vendor/pi-tui/autocomplete.ts +35 -10
  115. package/src/vendor/pi-tui/colors.ts +367 -0
  116. package/src/vendor/pi-tui/components/box.ts +8 -9
  117. package/src/vendor/pi-tui/components/editor.ts +4 -2
  118. package/src/vendor/pi-tui/components/markdown.ts +8 -2
  119. package/src/vendor/pi-tui/index.ts +31 -1
  120. package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
  121. package/src/vendor/pi-tui/native/clipboard.h +208 -0
  122. package/src/vendor/pi-tui/native/darwin/README.md +22 -0
  123. package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
  124. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  125. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  126. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
  127. package/src/vendor/pi-tui/native/linux/README.md +23 -0
  128. package/src/vendor/pi-tui/native/linux/build.sh +65 -0
  129. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  130. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  131. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
  132. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
  133. package/src/vendor/pi-tui/native/napi.h +91 -0
  134. package/src/vendor/pi-tui/native/win32/README.md +31 -0
  135. package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
  136. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  137. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  138. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
  139. package/src/vendor/pi-tui/native-platform.ts +2 -0
  140. package/src/vendor/pi-tui/oklab.ts +233 -0
  141. package/src/vendor/pi-tui/terminal-colors.ts +26 -8
  142. package/src/vendor/pi-tui/terminal-image.ts +43 -8
  143. package/src/vendor/pi-tui/terminal.ts +16 -9
  144. package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
  145. package/src/vendor/pi-tui/tui.ts +112 -75
  146. package/src/vendor/pi-tui/utils.ts +89 -49
  147. package/src/vendor/pi-tui/wheel-scroll.ts +82 -0
@@ -13,25 +13,35 @@ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
13
13
  import { Session, systemClock } from "../runtime/loop.ts";
14
14
  import { DEFAULT_PROVIDER, type ProviderConfig } from "../runtime/provider.ts";
15
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";
16
+ import { ExtensionRegistry } from "../runtime/extensions.ts";
17
+ import { CATALOG, outputTokenBudget, type ModelSpec } from "../runtime/model.ts";
18
+ import { asThinkingLevel, clampThinkingLevel, supportedThinkingLevels } from "../core/thinking.ts";
19
+ import { modelKey } from "../core/session-position.ts";
20
+ import { extensionFlagValue, type ExtensionFlag, type FlagInput } from "../core/extension-flags.ts";
21
+ import { scopedModels } from "../runtime/model-scope.ts";
17
22
  import {
18
23
  resolveCredential, envLabel, storedIsOAuth,
19
24
  type AuthFile, type ResolveEffects, type Resolved, describeMissingCredential } from "../runtime/auth.ts";
20
25
  import { authPath, configDir, readJsonFile } from "../store/config.ts";
21
26
  import { loadUiResources, loadTextResources, type UiResources } from "../store/resources.ts";
27
+ import { discoverResources, type ResourceIndex } from "../store/resource-index.ts";
28
+ import { runResourceConfig } from "./resource-browser.ts";
29
+ import { PackageManager, type PackageCommand } from "../runtime/package-manager.ts";
30
+ import { pickProjectTrust } from "./project-trust.ts";
22
31
  import { appendSkillsToSystem } from "../core/skills.ts";
23
- import { resolveSlashCatalog, type SlashCatalog } from "./commands.ts";
32
+ import { COMMANDS, resolveSlashCatalog, type SlashCatalog } from "./commands.ts";
24
33
  import { createStyle, IDENTITY, type Style } from "../tui/style.ts";
25
34
  import type { Settings } from "../core/settings.ts";
26
- import { readModelsText, writeModelsFile, modelsPath } from "../store/models.ts";
35
+ import { readModelSources, readCatalogCacheText, writeCatalogCache, catalogCachePath } from "../store/models.ts";
27
36
  import { refreshRemoteCatalog } from "../runtime/models-fetch.ts";
28
37
  import { readCursor, writeCursor, type Cursor } from "../store/log.ts";
29
- import {
30
- copyAncestryTo, createSession, findSessionByPrefix, resolveLogDir,
31
- } from "../store/sessions.ts";
38
+ import { resolveLogDir, findSessionByPrefix, listSessions, sessionsDir, type ResolveLogOpts } from "../store/sessions.ts";
39
+ import { recoverCut } from "../store/recovery.ts";
40
+ import { planSessionEntry, restoreSessionModel } from "../core/session-entry.ts";
41
+ import { pickStartupSession } from "./session-picker.ts";
32
42
  import { loadContext, assembleSystemPrompt, formatNotice, type LoadedContext } from "../store/context-files.ts";
33
43
  import {
34
- lookupTrust, projectSettingsPath, readTrust,
44
+ projectTrust,
35
45
  } from "../store/trust.ts";
36
46
  import { stat } from "node:fs/promises";
37
47
  import {
@@ -47,6 +57,7 @@ import { DEFAULT_CONTEXT_POLICY, isContextPolicy, type ContextPolicy } from "../
47
57
  import { windowBudget } from "../core/context-window.ts";
48
58
  import { colorEnabled, createPalette, describeError, stdinIsTty, stdoutIsTty, type Palette } from "./render.ts";
49
59
  import { type ActionId, type Keymap } from "../core/keymap.ts";
60
+ import type { StartupInput } from "./startup-input.ts";
50
61
 
51
62
  export const SYSTEM_PROMPT = systemPromptFor(TOOL_SCHEMAS.map((tool) => tool.name));
52
63
 
@@ -165,6 +176,15 @@ export interface Args {
165
176
  provider: string;
166
177
  apiKey: string;
167
178
  listModels: boolean;
179
+ modelSearch?: string;
180
+ help?: boolean;
181
+ version?: boolean;
182
+ exportFile?: string;
183
+ offline?: boolean;
184
+ tuiMode?: "regular" | "fullscreen";
185
+ noThemes?: boolean;
186
+ themePaths?: string[];
187
+ sessionId?: string;
168
188
  modelsCommand: boolean;
169
189
  modelsRefresh: boolean;
170
190
  fresh: boolean;
@@ -180,6 +200,10 @@ export interface Args {
180
200
  allowTools: string[];
181
201
  /** `--approve` true, `--no-approve` false. Omitted when neither. */
182
202
  approve?: boolean;
203
+ configCommand?: boolean;
204
+ local?: boolean;
205
+ packageCommand?: PackageCommand;
206
+ packagePaths?: {source:string;path:string}[];
183
207
  /**
184
208
  * `-p` / `--print`. Distinct from a leftover positional prompt: only this
185
209
  * flag reads piped stdin, so a one-shot that did not ask for a pipe cannot
@@ -192,8 +216,14 @@ export interface Args {
192
216
  thinking?: string;
193
217
  /** `--models <patterns>`. Comma-separated; empty when the flag was not passed. */
194
218
  models?: string;
219
+ modelsExplicit?: boolean;
195
220
  noSkills: boolean;
196
221
  noPromptTemplates: boolean;
222
+ noExtensions?: boolean;
223
+ extensionPaths?: string[];
224
+ extensionFlags?: Record<string, string | boolean>;
225
+ extensionArgv?: string[];
226
+ extensionInputs?: Record<string, FlagInput>;
197
227
  skillPaths: string[];
198
228
  promptTemplatePaths: string[];
199
229
  /** `-c` / `--continue`. */
@@ -266,10 +296,12 @@ const KNOWN_FLAGS: readonly string[] = [
266
296
  "--prompt-template",
267
297
  "--no-skills",
268
298
  "--no-prompt-templates",
299
+ "--extension", "-e", "--no-extensions", "-ne", "--extension-flag",
269
300
  "--mode",
270
301
  "--verbose",
271
302
  "--print", "-p",
272
303
  "--help", "-h",
304
+ "--version", "-v", "--export", "--session-id", "--offline", "--tui-mode", "--use-theme", "--no-themes", "-ns", "-np", "-nbt",
273
305
  ];
274
306
 
275
307
  /**
@@ -315,33 +347,8 @@ function rejectUnknownFlag(flag: string): never {
315
347
  process.exit(1);
316
348
  }
317
349
 
318
- /**
319
- * Stop the terminal echoing before startup does anything slow.
320
- *
321
- * Reading auth, scanning context files and printing the header all happen
322
- * before the TUI attaches. In cooked mode the tty echoes whatever is typed
323
- * during that window, so keystrokes land *between* the banner's own lines
324
- * and look like output the program produced. Raw mode is the TUI's anyway;
325
- * taking it at the first instruction just closes the gap.
326
- *
327
- * `stdin` is a parameter so the decision is testable without a real tty.
328
- * Returns an undo so a caller that bails out can put the terminal back.
329
- */
330
- export function silenceTtyEarly(opts: {
331
- interactive: boolean;
332
- stdin?: Pick<NodeJS.ReadStream, "isTTY" | "setRawMode" | "resume">;
333
- }): () => void {
334
- const stdin = opts.stdin ?? process.stdin;
335
- if (!opts.interactive || stdin.isTTY !== true) return () => {};
336
- stdin.setRawMode(true);
337
- // Leave input buffered until ProcessTerminal installs its listener. Resuming
338
- // here drops keystrokes typed while startup or transcript loading awaits I/O.
339
- return () => {
340
- if (stdin.isTTY === true) stdin.setRawMode(false);
341
- };
342
- }
343
-
344
- export function parseArgs(argv: string[]): Args {
350
+ export function parseArgs(argv: string[], options: { deferExtensions?: boolean; flags?: ReadonlyMap<string, ExtensionFlag> } = {}): Args {
351
+ let selectedTheme: string | undefined;
345
352
  const args: Args = {
346
353
  workspace: process.cwd(), logDir: "", maxTurns: 40, prompt: "",
347
354
  // Overridable so a node boundary can be forced in a short session --
@@ -362,6 +369,11 @@ export function parseArgs(argv: string[]): Args {
362
369
  };
363
370
  for (let i = 0; i < argv.length; i++) {
364
371
  const a = argv[i]!;
372
+ if (["--log", "--session", "--fork", "--session-dir", "--state-dir"].includes(a) &&
373
+ (!argv[i + 1] || argv[i + 1]!.startsWith("-"))) {
374
+ console.error(`${a} requires a value`);
375
+ process.exit(1);
376
+ }
365
377
  if (a === "--workspace" || a === "-w") args.workspace = argv[++i] ?? args.workspace;
366
378
  else if (a === "--path-root") args.pathRoot = resolve(argv[++i] ?? "/");
367
379
  else if (a === "--state-dir") args.stateDir = resolve(argv[++i] ?? ".");
@@ -382,13 +394,22 @@ export function parseArgs(argv: string[]): Args {
382
394
  else if (a === "--model" || a === "-m") args.model = argv[++i] ?? "";
383
395
  else if (a === "--provider") args.provider = argv[++i] ?? "";
384
396
  else if (a === "--api-key") args.apiKey = argv[++i] ?? "";
385
- else if (a === "--list-models") args.listModels = true;
397
+ else if (a === "--list-models") { args.listModels = true; const next = argv[i + 1]; if (next && !next.startsWith("-") && !next.startsWith("@")) args.modelSearch = argv[++i]!; }
398
+ else if (a === "--version" || a === "-v") args.version = true;
399
+ else if (a === "--export") { const file = argv[++i]; if (!file || file.startsWith("-")) throw new Error("--export requires a session path"); args.exportFile = file; }
400
+ else if (a === "--session-id") { const id = argv[++i]; if (!id || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(id)) throw new Error("--session-id requires a plain session identifier"); args.sessionId = id; }
401
+ else if (a === "--offline") args.offline = true;
402
+ else if (a === "--tui-mode") { const mode = argv[++i]; if (mode !== "regular" && mode !== "fullscreen") throw new Error("--tui-mode requires regular or fullscreen"); args.tuiMode = mode; }
386
403
  else if (a === "models" && i === 0) {
387
404
  // Subcommand, not a prompt. Do not consume the rest: a later
388
405
  // `--models-url` must still be an unknown-flag error, and `-h`
389
406
  // must still print help.
390
407
  args.modelsCommand = true;
391
408
  }
409
+ else if (a === "config" && i === 0) args.configCommand = true;
410
+ else if ((a === "install" || a === "remove" || a === "update" || a === "list") && i === 0) args.packageCommand = {operation:a,sources:[]};
411
+ else if ((a === "--local" || a === "-l") && (args.packageCommand || args.configCommand)) { args.local=true; if(args.packageCommand)args.packageCommand.local=true; }
412
+ else if (a === "--extensions" && args.packageCommand?.operation === "update") { /* Package-only update; xeno never self-updates. */ }
392
413
  else if (a === "--refresh" && args.modelsCommand) {
393
414
  args.modelsRefresh = true;
394
415
  }
@@ -410,70 +431,41 @@ export function parseArgs(argv: string[]): Args {
410
431
  else if (a === "--tools" || a === "-t") args.tools = parseToolList(argv[++i] ?? "");
411
432
  else if (a === "--exclude-tools" || a === "-xt") args.excludeTools = parseToolList(argv[++i] ?? "");
412
433
  else if (a === "--no-tools" || a === "-nt") args.noTools = true;
413
- else if (a === "--no-builtin-tools") args.noBuiltinTools = true;
434
+ else if (a === "--no-builtin-tools" || a === "-nbt") args.noBuiltinTools = true;
414
435
  else if (a === "--allow-tools") args.allowTools = parseToolList(argv[++i] ?? "");
415
436
  else if (a === "--approve" || a === "-a") args.approve = true;
416
437
  else if (a === "--no-approve" || a === "-na") args.approve = false;
417
- else if (a === "--theme") args.theme = argv[++i] ?? "";
438
+ else if (a === "--theme") {
439
+ const theme = argv[++i]; if (!theme || theme.startsWith("-")) throw new Error("--theme requires a name or path");
440
+ if (theme.includes("/") || theme.includes("\\") || theme.endsWith(".json")) { (args.themePaths ??= []).push(theme); if (theme.endsWith(".json")) args.theme = theme; }
441
+ else args.theme = theme;
442
+ }
443
+ else if (a === "--use-theme") { const theme = argv[++i]; if (!theme || theme.startsWith("-")) throw new Error("--use-theme requires a name"); selectedTheme = theme; }
444
+ else if (a === "--no-themes") args.noThemes = true;
418
445
  else if (a === "--thinking") args.thinking = argv[++i] ?? "";
419
- else if (a === "--models") args.models = argv[++i] ?? "";
446
+ else if (a === "--models") { const value = argv[++i]; if (value === undefined || value.startsWith("--")) throw new Error("--models requires a value"); args.models = value; args.modelsExplicit = true; }
420
447
  else if (a === "--skill") args.skillPaths.push(argv[++i] ?? "");
421
448
  else if (a === "--prompt-template") args.promptTemplatePaths.push(argv[++i] ?? "");
422
- else if (a === "--no-skills") args.noSkills = true;
423
- else if (a === "--no-prompt-templates") args.noPromptTemplates = true;
449
+ else if (a === "--no-skills" || a === "-ns") args.noSkills = true;
450
+ else if (a === "--no-prompt-templates" || a === "-np") args.noPromptTemplates = true;
451
+ else if (a === "--no-extensions" || a === "-ne") args.noExtensions = true;
452
+ else if (a === "--extension" || a === "-e") {
453
+ const path = argv[++i]; if (!path || path.startsWith("-")) throw new Error("--extension requires a path");
454
+ (args.extensionPaths ??= []).push(path);
455
+ }
456
+ else if (a === "--extension-flag") {
457
+ const value = argv[++i] ?? "", at = value.indexOf("=");
458
+ if (at <= 0) throw new Error("--extension-flag requires name=value");
459
+ (args.extensionFlags ??= Object.create(null) as Record<string, string | boolean>)[value.slice(0, at)] = value.slice(at + 1) === "true" ? true : value.slice(at + 1) === "false" ? false : value.slice(at + 1);
460
+ }
424
461
  else if (a === "--mode") args.mode = argv[++i] ?? "";
425
462
  else if (a === "--verbose") args.verbose = true;
426
463
  else if (a === "-p" || a === "--print") {
427
464
  args.print = true;
428
- const next = argv[++i] ?? "";
429
- args.prompt += (args.prompt ? " " : "") + next;
430
465
  }
431
466
  else if (a === "--help" || a === "-h") {
432
- console.log(
433
- "usage: xeno [-m <model>] [--provider <name>] [--api-key <key>]\n" +
434
- " [-w <workspace>] [--log <dir>] [--max-turns N]\n" +
435
- " [--context-policy windowed|segmented|continuous|adaptive]\n" +
436
- " [--trigger N --hard N] [--list-models] [--fresh]\n" +
437
- " [-c|--continue] [-r|--resume [id]] [--session path|id]\n" +
438
- " [--fork path|id] [--no-session] [--session-dir dir]\n" +
439
- " [-n|--name name]\n" +
440
- " [models --refresh]\n" +
441
- " [--system-prompt <text-or-file>] [--append-system-prompt <text>]\n" +
442
- " [--no-context-files|-nc]\n" +
443
- " [--tools names] [--exclude-tools names] [--no-tools]\n" +
444
- " [--no-builtin-tools]\n" +
445
- " [--allow-tools names] [--approve|--no-approve]\n" +
446
- " [--theme <name|path>] [--thinking <level>] [--models a,b]\n" +
447
- " [--skill <path>] [--no-skills]\n" +
448
- " [--prompt-template <path>] [--no-prompt-templates]\n" +
449
- " [--mode json] [--verbose]\n" +
450
- " [-p <prompt>] [prompt...]\n\n" +
451
- " A workspace that already holds a session is resumed. --fresh starts a\n" +
452
- " separate one alongside it; nothing is ever deleted.\n" +
453
- " -c continues the newest; --session / -r <id> pick one (unique prefix only).\n" +
454
- " --no-session runs without writing .xeno/sessions/. --fork copies into a new one.\n" +
455
- " A leftover .xeno/log is not moved; continue it with --log .xeno/log.\n\n" +
456
- " With a prompt, runs it and exits. Without one, reads prompts from stdin.\n" +
457
- " Commands: /model /thinking /login /new /resume /name /session /usage /state\n" +
458
- " /export /copy /fork /clone\n" +
459
- " /back [n [files|context]] [confirm] /trust /settings /hotkeys\n" +
460
- " /help /quit\n\n" +
461
- " Credentials resolve in this order: --api-key, then ~/.xeno/auth.json,\n" +
462
- " then the provider's environment variable.\n" +
463
- " --system-prompt replaces the built-in instructions (text, or a file that exists).\n" +
464
- " --append-system-prompt appends; repeatable. -nc skips AGENTS.md discovery.\n" +
465
- " --tools is a strict allowlist; excluded tools leave the schema.\n" +
466
- " --no-builtin-tools drops built-ins and keeps any other source; --tools still wins.\n" +
467
- " bash asks before it runs. --allow-tools bash skips the ask for this process.\n" +
468
- " --time-budget <seconds> tells the model, once, when 75% of the run's wall-clock budget is used.\n" +
469
- " --state-dir <dir> keeps snapshots, observations and sessions out of the workspace.\n" +
470
- " --path-root <dir> lets file tools reach absolute paths under <dir> (e.g. / in a container);\n" +
471
- " relative paths, fingerprints and /back stay in the workspace, so edits outside it are not rewound.\n" +
472
- " --approve loads .xeno/settings.json this run; --no-approve skips it.\n" +
473
- " Project trust does not affect tool execution.\n" +
474
- " --mode json writes one JSON object per event to stdout; diagnostics stay on stderr.\n" +
475
- " --verbose expands the startup header (AGENTS.md, skills, prompt templates).",
476
- );
467
+ if (options.deferExtensions) { args.help = true; continue; }
468
+ printCliHelp();
477
469
  process.exit(0);
478
470
  } else if (a === "--") {
479
471
  // Everything after `--` is content, never a flag. The delimiter
@@ -483,9 +475,23 @@ export function parseArgs(argv: string[]): Args {
483
475
  }
484
476
  break;
485
477
  } else if (a.startsWith("-") && a !== "-") {
486
- rejectUnknownFlag(a);
487
- } else args.prompt += (args.prompt ? " " : "") + a;
478
+ const equal = a.indexOf("="), name = a.slice(2, equal === -1 ? undefined : equal), definition = options.flags?.get(name);
479
+ if (a.startsWith("--") && (definition || options.deferExtensions)) {
480
+ const next = argv[i + 1], input: FlagInput = { inline: equal !== -1,
481
+ ...(equal !== -1 ? { value: a.slice(equal + 1) } : next !== undefined && !next.startsWith("-") && !next.startsWith("@") ? { value: next } : {}) };
482
+ if (definition) {
483
+ const value = extensionFlagValue(name, definition, input);
484
+ if (value !== undefined) (args.extensionFlags ??= Object.create(null) as Record<string, string | boolean>)[name] = value;
485
+ if (!input.inline && input.value !== undefined && (definition.type === "string" || input.value === "true" || input.value === "false")) i++;
486
+ } else {
487
+ args.extensionArgv = [...argv]; (args.extensionInputs ??= Object.create(null) as Record<string, FlagInput>)[name] = input;
488
+ if (!input.inline && input.value !== undefined) i++;
489
+ }
490
+ } else rejectUnknownFlag(a);
491
+ } else if(args.packageCommand)args.packageCommand.sources.push(a);
492
+ else args.prompt += (args.prompt ? " " : "") + a;
488
493
  }
494
+ if (selectedTheme !== undefined) args.theme = selectedTheme;
489
495
  return args;
490
496
  }
491
497
 
@@ -621,7 +627,7 @@ function countCatalogDelta(
621
627
  return map;
622
628
  }
623
629
  for (const spec of parseUserCatalog(json).models) {
624
- map.set(spec.id, JSON.stringify(spec));
630
+ map.set(modelKey(spec), JSON.stringify(spec));
625
631
  }
626
632
  return map;
627
633
  };
@@ -650,13 +656,13 @@ export async function runModelsRefresh(opts: {
650
656
  | { ok: true; count: number; added: number; changed: number }
651
657
  | { ok: false; reason: string }
652
658
  > {
653
- const before = await readModelsText();
659
+ const before = await readCatalogCacheText();
654
660
  const result = await refreshRemoteCatalog({
655
661
  fetch: opts.fetch,
656
662
  write: opts.write,
657
663
  });
658
664
  if (!result.ok) return result;
659
- const after = await readModelsText();
665
+ const after = await readCatalogCacheText();
660
666
  const delta = countCatalogDelta(before.text, after.text);
661
667
  return { ok: true, count: result.count, added: delta.added, changed: delta.changed };
662
668
  }
@@ -729,6 +735,10 @@ export interface App {
729
735
  askSlot: AskSlot;
730
736
  ui?: UiState;
731
737
  slash?: SlashCatalog;
738
+ resources?: ResourceIndex;
739
+ projectTrusted?: boolean;
740
+ extensions?: ExtensionRegistry;
741
+ shutdownRequested?: boolean;
732
742
  }
733
743
 
734
744
  export interface UiState {
@@ -749,13 +759,6 @@ function toUiState(loaded: UiResources, color: boolean): UiState {
749
759
  };
750
760
  }
751
761
 
752
- function asEffort(value: string | undefined): Effort | undefined {
753
- if (value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max") {
754
- return value;
755
- }
756
- return undefined;
757
- }
758
-
759
762
  /** `--models a,b` and settings.scopedModels share this split so a stray space cannot invent a pattern. */
760
763
  export function splitModelPatterns(raw: string | undefined): string[] {
761
764
  if (raw === undefined || raw.length === 0) return [];
@@ -791,7 +794,7 @@ export function missingCredentialMessage(spec: ModelSpec, explicit: boolean, cat
791
794
  }
792
795
 
793
796
  export function isInteractiveSession(args: Args): boolean {
794
- return args.prompt.length === 0 && stdinIsTty() && stdoutIsTty();
797
+ return !args.print && !args.help && args.mode !== "json" && args.prompt.length === 0 && stdinIsTty() && stdoutIsTty();
795
798
  }
796
799
 
797
800
  /**
@@ -847,8 +850,9 @@ export function toolSelectionOf(args: Args) {
847
850
  * the provider bills, and an extra field there would look like a tool
848
851
  * argument.
849
852
  */
850
- export function visibleSchemas(args: Args) {
851
- const available = TOOL_SCHEMAS.map((t) => ({ ...t, source: "builtin" as const }));
853
+ export function visibleSchemas(args: Args, extensions?: ExtensionRegistry) {
854
+ const custom = [...extensions?.definitions().values() ?? []].map(({ name, description, input_schema }) => ({ name, description, input_schema, source: "extension" as const }));
855
+ const available = [...TOOL_SCHEMAS.filter(tool => !extensions?.tools.has(tool.name)).map((t) => ({ ...t, source: "builtin" as const })), ...custom];
852
856
  return selectTools(available, toolSelectionOf(args)).map((tool) => {
853
857
  const { source, ...schema } = tool;
854
858
  void source;
@@ -872,9 +876,11 @@ export function createSessionFor(opts: {
872
876
  io: AppIO;
873
877
  askSlot: AskSlot;
874
878
  oauth?: boolean;
879
+ effects?: ResolveEffects;
880
+ extensions?: ExtensionRegistry;
875
881
  }): Session {
876
882
  const { args, workspace, logDir, blobDir, system, providerConfig, credential, io, askSlot } = opts;
877
- const schemas = visibleSchemas(args);
883
+ const schemas = visibleSchemas(args, opts.extensions);
878
884
  const names = schemas.map((t) => t.name);
879
885
  const sessionRules: PermissionRule[] = [];
880
886
  const tools: ToolContext = {
@@ -885,6 +891,7 @@ export function createSessionFor(opts: {
885
891
  rules: rulesWithAllowTools(args.allowTools),
886
892
  sessionRules,
887
893
  askSlot,
894
+ ...(opts.extensions ? { extensions: opts.extensions.definitions(), extensionHooks: opts.extensions.toolHooks() } : {}),
888
895
  };
889
896
  return new Session(
890
897
  {
@@ -916,21 +923,177 @@ export function createSessionFor(opts: {
916
923
  providerConfig,
917
924
  credential.ok ? credential.key : "",
918
925
  retryHandler(io),
919
- opts.oauth === true ? { oauth: true } : undefined,
926
+ { ...(opts.oauth === true ? { oauth: true } : {}), ...(opts.effects ? { effects: opts.effects } : {}) },
920
927
  ),
921
928
  systemClock(),
922
929
  );
923
930
  }
924
931
 
925
- export async function startup(args: Args): Promise<App> {
932
+ /** One location policy for startup, /new, /resume and /fork. */
933
+ export function sessionDirFor(args: Args, workspace: string): string | undefined {
934
+ if (args.sessionDir) return args.sessionDir;
935
+ return args.stateDir === undefined ? undefined : statePaths(workspace, args.stateDir).sessions;
936
+ }
937
+
938
+ type OpenSessionOptions = Omit<Parameters<typeof createSessionFor>[0], "providerConfig" | "credential" | "oauth" | "system"> & {
939
+ startupPhase?: (name: string) => void;
940
+ catalog: readonly ModelSpec[];
941
+ auth: AuthFile;
942
+ credentialOf: (spec: ModelSpec) => Resolved;
943
+ interactive: boolean;
944
+ settings?: Settings;
945
+ /** CLI overrides belong to this open only; /resume must not inherit them. */
946
+ modelFlags?: { model: string; provider: string };
947
+ preparedResources?: { workspace: string; value: Awaited<ReturnType<typeof loadSessionResources>> };
948
+ fallback?: ProviderConfig;
949
+ projectTrusted?: boolean;
950
+ };
951
+
952
+ /** Pi replaces cwd-scoped resources with the session runtime; publish only after all reads succeed. */
953
+ async function prepareExtensionPackages(args: Args, workspace: string): Promise<void> {
954
+ const remote=(args.extensionPaths??[]).filter(path=>/^(npm:|git:|git\+|https?:\/\/|ssh:\/\/|git@)/.test(path));
955
+ if(!remote.length)return;
956
+ if(args.offline || process.env["XENO_OFFLINE"] === "1") throw new Error("--offline prevents remote extension package preparation; install it explicitly beforehand");
957
+ const manager=new PackageManager({workspace});
958
+ for(const source of remote)(args.packagePaths??=[]).push({source,path:await manager.temporary(source)});
959
+ args.extensionPaths=(args.extensionPaths??[]).filter(path=>!remote.includes(path));
960
+ }
961
+
962
+ export async function loadSessionResources(args: Args, workspace: string, io: AppIO, trustOverride?: boolean): Promise<{
963
+ loadedContext: LoadedContext; slash: SlashCatalog; ui: UiState; index: ResourceIndex; trusted: boolean; system: string; extensions: ExtensionRegistry;
964
+ }> {
965
+ const { trusted } = await projectTrust(workspace, trustOverride ?? args.approve);
966
+ await prepareExtensionPackages(args,workspace);
967
+ const index = await discoverResources({ ...args, workspace, trusted });
968
+ const extensions = new ExtensionRegistry({ reserved: COMMANDS.map(command => command.name), reservedFlags: KNOWN_FLAGS.map(flag => flag.replace(/^-+/, "")),
969
+ flagValue: (name, definition) => args.extensionFlags && Object.hasOwn(args.extensionFlags, name) ? args.extensionFlags[name] : extensionFlagValue(name, definition, args.extensionInputs && Object.hasOwn(args.extensionInputs, name) ? args.extensionInputs[name] : undefined), report: io.say });
970
+ await extensions.load(index);
971
+ if(args.extensionArgv) {
972
+ const validated = parseArgs(args.extensionArgv, { flags: extensions.flags, ...(args.help ? { deferExtensions: true } : {}) });
973
+ if (validated.extensionArgv) rejectUnknownFlag("--" + Object.keys(validated.extensionInputs ?? {})[0]);
974
+ args.prompt = validated.prompt; args.extensionFlags = validated.extensionFlags ?? {};
975
+ }
976
+ const loaded = await loadUiResources({ ...args, workspace, trusted, ...(args.theme ? { theme: args.theme } : {}) }, index);
977
+ const loadedContext = await loadContext({ workspace, cwd: workspace, configDir: configDir(),
978
+ noContextFiles: args.noContextFiles, appendSystemPrompt: args.appendSystemPrompt,
979
+ ...(args.systemPrompt === null ? {} : { systemPrompt: args.systemPrompt }) });
980
+ for (const notice of loadedContext.notices) if (notice.kind === "skipped" || notice.truncated) io.say(io.palette.yellow(formatNotice(notice)));
981
+ const text = await loadTextResources({ workspace, configDir: configDir(), trusted, noSkills: args.noSkills,
982
+ noPromptTemplates: args.noPromptTemplates, skillPaths: args.skillPaths, promptTemplatePaths: args.promptTemplatePaths }, index);
983
+ const slash = resolveSlashCatalog(text.prompts, text.skills);
984
+ for (const line of new Set([...loaded.diagnostics, ...text.diagnostics, ...slash.diagnostics])) io.say(io.palette.yellow(` ${line}`));
985
+ const system = appendSkillsToSystem(assembleSystemPrompt(systemPromptFor(visibleSchemas(args, extensions).map(tool => tool.name), sessionEnvironment(workspace)), loadedContext), text.skills);
986
+ const ui = toUiState(loaded, colorEnabled());
987
+ if (args.tuiMode) ui.settings = { ...ui.settings, tuiMode: args.tuiMode };
988
+ return { loadedContext, slash: { ...slash.catalog, skillCommands: loaded.settings.enableSkillCommands !== false }, ui, index, trusted, system, extensions };
989
+ }
990
+
991
+ /**
992
+ * Pi's session-before-model order, with xeno's N.8 checkpoint on an explicit
993
+ * model change. Opening never sends a request to the previous provider.
994
+ * Callers publish the returned session/config/credential together on success.
995
+ */
996
+ export async function openSessionFor(opts: OpenSessionOptions): Promise<{
997
+ session: Session; priorCursor: Cursor | null; providerConfig: ProviderConfig; credential: Resolved;
998
+ workspace: string; blobDir: string; system: string; resources: Awaited<ReturnType<typeof loadSessionResources>>;
999
+ }> {
1000
+ opts.startupPhase?.("Reading session history");
1001
+ const recorded = await readCursor(opts.logDir);
1002
+ const priorCursor = recorded === null ? null : await recoverCut(opts.logDir, recorded);
1003
+ const workspace = priorCursor?.workspace ?? opts.workspace;
1004
+ if (opts.args.pathRoot) {
1005
+ const rel = relative(opts.args.pathRoot, workspace);
1006
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error("saved workspace is outside --path-root");
1007
+ }
1008
+ if (opts.preparedResources && opts.preparedResources.workspace !== workspace) await opts.preparedResources.value.extensions.dispose();
1009
+ opts.startupPhase?.("Loading session resources");
1010
+ const resources = opts.preparedResources?.workspace === workspace ? opts.preparedResources.value
1011
+ : await loadSessionResources(opts.args, workspace, opts.io, workspace === opts.workspace ? opts.projectTrusted : undefined);
1012
+ const interactive = opts.interactive && !opts.args.print && !opts.args.prompt && opts.args.mode !== "json";
1013
+ const named = [...opts.args.tools ?? [], ...opts.args.excludeTools, ...opts.args.allowTools];
1014
+ const unknown = unknownToolNames(named).filter(name => !resources.extensions.tools.has(name));
1015
+ if (unknown.length) throw new Error(`unknown tool: ${unknown.join(", ")}`);
1016
+ const saved = priorCursor === null ? undefined : restoreSessionModel(priorCursor, opts.catalog);
1017
+ const flags = { ...opts.modelFlags ?? { model: "", provider: "" } };
1018
+ const parsedModel = flags.model ? scopedModels([flags.model], flags.provider ? opts.catalog.filter(model => model.provider === flags.provider) : opts.catalog, true).entries[0] : undefined;
1019
+ if (parsedModel) flags.model = modelKey(parsedModel.model);
1020
+ const explicit = flags.model.length > 0 || flags.provider.length > 0;
1021
+ const settings = opts.settings ?? {};
1022
+ const preferred = saved ?? opts.fallback?.spec;
1023
+ opts.startupPhase?.("Resolving model credentials");
1024
+ const selected = !explicit && preferred !== undefined
1025
+ ? { ok: true as const, spec: preferred }
1026
+ : selectModel(settings, flags, (spec) => opts.credentialOf(spec).ok, opts.catalog);
1027
+ let spec: ModelSpec;
1028
+ if (selected.ok) spec = selected.spec;
1029
+ else {
1030
+ const missingAll = selected.reason.startsWith("no model has a credential");
1031
+ if (!missingAll || !interactive) {
1032
+ throw new Error(missingAll ? describeUnauthenticatedScript(opts.catalog) : selected.reason);
1033
+ }
1034
+ const fallback = selectModel(settings, flags, () => true, opts.catalog);
1035
+ if (!fallback.ok) throw new Error(fallback.reason);
1036
+ spec = fallback.spec;
1037
+ }
1038
+ const credential = opts.credentialOf(spec);
1039
+ if (!credential.ok && !interactive) {
1040
+ throw new Error(credential.reason.startsWith("no credential for ")
1041
+ ? missingCredentialMessage(spec, explicit || saved !== undefined, opts.catalog) : credential.reason);
1042
+ }
1043
+ const explicitEffort = asThinkingLevel(opts.args.thinking), requestedEffort = explicitEffort ?? parsedModel?.thinking;
1044
+ if (opts.args.thinking && (!explicitEffort || !supportedThinkingLevels(spec).includes(explicitEffort))) {
1045
+ throw new Error(`thinking ${opts.args.thinking} is not supported by ${modelKey(spec)}; supported: ${supportedThinkingLevels(spec).join(", ")}`);
1046
+ }
1047
+ const providerConfig: ProviderConfig = {
1048
+ spec, maxTokens: outputTokenBudget(spec),
1049
+ effort: clampThinkingLevel(spec, requestedEffort ?? asThinkingLevel(priorCursor?.thinking) ??
1050
+ settings.modelThinkingLevels?.[modelKey(spec)] ?? opts.fallback?.effort ?? asThinkingLevel(settings.defaultThinking) ??
1051
+ asThinkingLevel(settings.thinking) ?? DEFAULT_PROVIDER.effort),
1052
+ thinkingDisplay: opts.fallback?.thinkingDisplay ?? DEFAULT_PROVIDER.thinkingDisplay,
1053
+ };
1054
+ const restoring = saved ?? spec;
1055
+ const restoringCredential = saved === undefined ? credential : opts.credentialOf(restoring);
1056
+ const oauth = (model: ModelSpec, got: Resolved) => got.ok && storedIsOAuth(opts.auth, model.provider) && opts.args.apiKey.length === 0;
1057
+ const session = createSessionFor({
1058
+ ...opts,
1059
+ system: resources.system,
1060
+ extensions: resources.extensions,
1061
+ workspace: priorCursor?.workspace ?? opts.workspace,
1062
+ blobDir: priorCursor?.blob_dir ?? (priorCursor?.workspace && priorCursor.workspace !== opts.workspace
1063
+ ? statePaths(priorCursor.workspace, opts.args.stateDir).blobs : opts.blobDir),
1064
+ providerConfig: { ...providerConfig, spec: restoring, maxTokens: outputTokenBudget(restoring) },
1065
+ credential: restoringCredential,
1066
+ ...(oauth(restoring, restoringCredential) ? { oauth: true } : {}),
1067
+ });
1068
+ opts.startupPhase?.("Preparing workspace snapshot");
1069
+ await session.start();
1070
+ if (resources.extensions.loaded.length || session.config.frozen.tools.some(tool =>
1071
+ typeof tool === "object" && tool !== null && "name" in tool && typeof tool.name === "string" && unknownToolNames([tool.name]).length)) {
1072
+ await session.reloadResources({ system: resources.system, tools: visibleSchemas(opts.args, resources.extensions) });
1073
+ }
1074
+ if (restoring.id !== spec.id || restoring.provider !== spec.provider) {
1075
+ await session.switchModel(createProvider(providerConfig, credential.ok ? credential.key : "", retryHandler(opts.io),
1076
+ { ...(oauth(spec, credential) ? { oauth: true } : {}), ...(opts.effects ? { effects: opts.effects } : {}) }));
1077
+ opts.io.say(opts.io.palette.yellow(` switched ${restoring.id} → ${spec.id} at a model checkpoint`));
1078
+ }
1079
+ resources.ui.settings = { ...resources.ui.settings, thinking: providerConfig.effort,
1080
+ ...(opts.args.modelsExplicit || opts.args.models ? { scopedModels: splitModelPatterns(opts.args.models) } : {}) };
1081
+ if (resources.ui.settings.scopedModels) for (const warning of scopedModels(resources.ui.settings.scopedModels, opts.catalog).diagnostics) opts.io.say(` ${warning}`);
1082
+ return { session, priorCursor, providerConfig, credential, workspace: session.config.tools.root,
1083
+ blobDir: session.config.blobDir ?? opts.blobDir, system: session.config.frozen.system, resources };
1084
+ }
1085
+
1086
+ export async function startup(args: Args, input?: StartupInput): Promise<App> {
1087
+ const withTerminal = <T>(run: (handoff: () => void) => Promise<T>): Promise<T> => input ? input.runWithTerminal(run) : run(() => {});
1088
+ if (args.extensionArgv && (args.listModels || args.modelsCommand || args.configCommand)) rejectUnknownFlag("--" + Object.keys(args.extensionInputs ?? {})[0]);
926
1089
  const C = createPalette(colorEnabled());
927
- const printMode = isPrintMode(args);
1090
+ let printMode = isPrintMode(args);
928
1091
  const jsonMode = isJsonMode(args);
929
- if ((args.mode ?? "").length > 0 && args.mode !== "json") {
930
- console.error(`unknown --mode ${args.mode} (supported: json)`);
1092
+ if ((args.mode ?? "").length > 0 && args.mode !== "json" && args.mode !== "text") {
1093
+ console.error(`unknown --mode ${args.mode} (supported: text, json)`);
931
1094
  process.exit(1);
932
1095
  }
933
- const divert = printMode || jsonMode;
1096
+ let divert = printMode || jsonMode;
934
1097
  const io: AppIO = {
935
1098
  palette: C,
936
1099
  say: (line) => (divert ? console.error(line) : console.log(line)),
@@ -943,6 +1106,11 @@ export async function startup(args: Args): Promise<App> {
943
1106
  }
944
1107
  : {}),
945
1108
  };
1109
+ const refreshOutputMode = () => {
1110
+ printMode = isPrintMode(args); divert = printMode || jsonMode;
1111
+ if (printMode && !jsonMode) io.answer = text => { process.stdout.write(text.endsWith("\n") ? text : `${text}\n`); };
1112
+ else delete io.answer;
1113
+ };
946
1114
  const effects = processEffects();
947
1115
 
948
1116
  const authRead = await readJsonFile<AuthFile>(authPath());
@@ -955,12 +1123,13 @@ export async function startup(args: Args): Promise<App> {
955
1123
  io.say(C.dim(" fetches models.dev into ~/.xeno/models.json; startup never does this."));
956
1124
  process.exit(0);
957
1125
  }
1126
+ if (args.offline || process.env["XENO_OFFLINE"] === "1") throw new Error("--offline prevents catalog refresh");
958
1127
  const result = await runModelsRefresh({
959
1128
  fetch: globalThis.fetch,
960
- write: writeModelsFile,
1129
+ write: writeCatalogCache,
961
1130
  });
962
1131
  if (result.ok) {
963
- io.say(` added ${result.added}, changed ${result.changed} (${result.count} total) → ${modelsPath()}`);
1132
+ io.say(` added ${result.added}, changed ${result.changed} (${result.count} total) → ${catalogCachePath()}`);
964
1133
  process.exit(0);
965
1134
  } else {
966
1135
  io.say(C.yellow(` ${result.reason}`));
@@ -970,17 +1139,17 @@ export async function startup(args: Args): Promise<App> {
970
1139
  }
971
1140
 
972
1141
  // File only. Passing fetch here would make the test of D16 un-runnable.
973
- const userFile = await readModelsText();
974
- const loaded = loadCatalog({ userText: userFile.text });
1142
+ input?.phase("Loading model catalog");
1143
+ const loaded = loadCatalog(await readModelSources());
975
1144
  for (const warning of loaded.warnings) io.say(C.yellow(` ${warning}`));
976
1145
  const catalog = loaded.models;
977
1146
 
978
1147
  const credentialOf = (spec: ModelSpec) => resolveCredential(spec, auth, args.apiKey, effects);
979
- const hasCredential = (spec: ModelSpec) => credentialOf(spec).ok;
980
1148
 
981
1149
  if (args.listModels) {
982
1150
  io.say(C.bold("models"));
983
- for (const group of groupedModels(catalog)) {
1151
+ const query = args.modelSearch?.toLowerCase();
1152
+ for (const group of groupedModels(query ? catalog.filter(model => [modelKey(model), model.name].some(text => { let at=0; for(const char of text.toLowerCase()) if(char===query[at])at++; return at===query.length; })) : catalog)) {
984
1153
  io.say(C.bold(group.provider));
985
1154
  for (const spec of group.models) {
986
1155
  const got = credentialOf(spec);
@@ -992,18 +1161,6 @@ export async function startup(args: Args): Promise<App> {
992
1161
  process.exit(0);
993
1162
  }
994
1163
 
995
- const named = [
996
- ...(args.tools ?? []),
997
- ...args.excludeTools,
998
- ...args.allowTools,
999
- ];
1000
- const unknown = unknownToolNames(named);
1001
- if (unknown.length > 0) {
1002
- io.err(C.red(`\n unknown tool: ${unknown.join(", ")}`));
1003
- io.err(C.dim(` known: ${TOOL_SCHEMAS.map((t) => t.name).join(", ")}\n`));
1004
- process.exit(1);
1005
- }
1006
-
1007
1164
  /**
1008
1165
  * Resolving the workspace is the first thing that can fail, and it fails for
1009
1166
  * ordinary reasons -- a typo in `-w`, a directory that was moved. Reported as
@@ -1012,6 +1169,7 @@ export async function startup(args: Args): Promise<App> {
1012
1169
  * It has to happen before model selection so a trusted `.xeno/settings.json`
1013
1170
  * can override the default model. Trust does not affect tools.
1014
1171
  */
1172
+ input?.phase("Opening workspace");
1015
1173
  let workspace: string;
1016
1174
  try {
1017
1175
  workspace = await workspaceRoot(args.workspace);
@@ -1031,139 +1189,83 @@ export async function startup(args: Args): Promise<App> {
1031
1189
  }
1032
1190
  }
1033
1191
 
1034
- const trustRead = await readTrust();
1035
- if (trustRead.error) io.say(C.yellow(` ${trustRead.error}`));
1036
- let loadProjectSettings = false;
1037
- if (args.approve === true) {
1038
- loadProjectSettings = true;
1039
- } else if (args.approve === false) {
1040
- loadProjectSettings = false;
1041
- } else {
1042
- const decision = lookupTrust(trustRead.value, workspace);
1043
- if (decision === true) loadProjectSettings = true;
1044
- else if (decision === false) loadProjectSettings = false;
1045
- else {
1046
- loadProjectSettings = false;
1047
- try {
1048
- await stat(projectSettingsPath(workspace));
1049
- io.say(
1050
- C.dim(
1051
- ` ${projectSettingsPath(workspace)} not loaded (project is not trusted). ` +
1052
- `Trust does not affect tool execution. /trust saves a decision, or pass --approve this run.`,
1053
- ),
1054
- );
1055
- } catch {
1056
- // No project settings file: nothing to load, no notice.
1057
- }
1058
- }
1192
+ input?.phase("Preparing extensions and project settings");
1193
+ await prepareExtensionPackages(args,workspace);
1194
+ const trust = await projectTrust(workspace, args.approve);
1195
+ let loadProjectSettings = trust.trusted;
1196
+ if (args.configCommand) {
1197
+ if (!stdinIsTty() || !stdoutIsTty()) {
1198
+ const index = await discoverResources({ ...args, workspace, trusted: loadProjectSettings });
1199
+ for (const entry of index.entries) io.say(`${entry.enabled ? "[x]" : "[ ]"} ${entry.kind} ${entry.scope}${entry.package?" · "+entry.package.source:""} ${entry.path}`);
1200
+ } else await withTerminal(handoff => runResourceConfig({ ...args, workspace, trusted: loadProjectSettings }, handoff));
1201
+ process.exit(0);
1059
1202
  }
1203
+ const hasProjectResources = (await Promise.all(["settings.json", "keybindings.json", "skills", "prompts", "themes", "extensions"].map(name => stat(join(workspace, ".xeno", name)).then(() => true, () => false)))).some(Boolean);
1204
+ if (!args.help && hasProjectResources && args.approve === undefined && !trust.saved && trust.policy === "ask" && isInteractiveSession(args)) loadProjectSettings = await withTerminal(handoff => pickProjectTrust(workspace, handoff));
1205
+ if (hasProjectResources && !loadProjectSettings) io.say(C.dim(" Project resources not loaded (project is not trusted). /trust changes resource trust; tool permissions are separate."));
1060
1206
 
1061
- const uiLoaded = await loadUiResources({
1207
+ // Resolve the resource owner before factories run. Parsing flags must not load a different cwd first.
1208
+ let resourceWorkspace = workspace;
1209
+ const prepareFlags = (args.extensionArgv && args.resume !== true) || args.help;
1210
+ if (prepareFlags) {
1211
+ const target = args.logDir || args.session || args.forkFrom || (typeof args.resume === "string" ? args.resume : undefined);
1212
+ let resourceLog: string | undefined;
1213
+ if (target) { const found = await findSessionByPrefix(workspace, target, sessionDirFor(args, workspace)); if (found.ok) resourceLog = found.session.path; }
1214
+ else if (args.sessionId) resourceLog = join(sessionsDir(workspace, sessionDirFor(args, workspace)), args.sessionId);
1215
+ else if (args.continueLatest) resourceLog = (await listSessions(workspace, sessionDirFor(args, workspace), Infinity)).sessions.find(session => session.ok)?.path;
1216
+ if (resourceLog) resourceWorkspace = (await readCursor(resourceLog))?.workspace ?? workspace;
1217
+ }
1218
+ input?.phase("Loading session resources");
1219
+ const preparedResources = prepareFlags
1220
+ ? { workspace: resourceWorkspace, value: await loadSessionResources(args, resourceWorkspace, io, resourceWorkspace === workspace ? loadProjectSettings : undefined) } : undefined;
1221
+ if (args.help) { printCliHelp(preparedResources?.value.extensions.flags); await preparedResources?.value.extensions.dispose(); process.exit(0); }
1222
+ refreshOutputMode();
1223
+ const uiLoaded = preparedResources ? undefined : await loadUiResources({
1224
+ ...args,
1062
1225
  workspace,
1063
1226
  trusted: loadProjectSettings,
1064
1227
  ...(args.theme.length > 0 ? { theme: args.theme } : {}),
1065
1228
  });
1066
- for (const line of uiLoaded.diagnostics) io.say(C.yellow(` ${line}`));
1067
- const mergedSettings = uiLoaded.settings;
1068
- const ui = toUiState(uiLoaded, colorEnabled());
1229
+ for (const line of uiLoaded?.diagnostics ?? []) io.say(C.yellow(` ${line}`));
1230
+ const ui = preparedResources?.value.ui ?? toUiState(uiLoaded!, colorEnabled());
1231
+ const mergedSettings = ui.settings;
1069
1232
  const scopedFromFlag = splitModelPatterns(args.models);
1070
- if (scopedFromFlag.length > 0) {
1233
+ if (args.modelsExplicit || scopedFromFlag.length > 0) {
1071
1234
  ui.settings = { ...ui.settings, scopedModels: scopedFromFlag };
1072
1235
  }
1073
1236
 
1074
- const selected = selectModel(mergedSettings, { model: args.model, provider: args.provider }, hasCredential, catalog);
1075
- const interactive = isInteractiveSession(args);
1076
- let spec: ModelSpec;
1077
- if (selected.ok) {
1078
- spec = selected.spec;
1079
- } else {
1080
- const missingAll = selected.reason.startsWith("no model has a credential");
1081
- if (!missingAll || !interactive) {
1082
- io.err(C.red(`\n ${missingAll ? describeUnauthenticatedScript(catalog) : selected.reason}\n`));
1083
- process.exit(1);
1084
- }
1085
- // Same order as selectModel, ignoring credentials, so the banner names a
1086
- // real catalog model rather than inventing one.
1087
- const fallback = selectModel(mergedSettings, { model: args.model, provider: args.provider }, () => true, catalog);
1088
- const picked = fallback.ok ? fallback.spec : catalog[0];
1089
- if (picked === undefined) {
1090
- io.err(C.red("\n catalog is empty\n"));
1091
- process.exit(1);
1092
- }
1093
- spec = picked;
1094
- }
1095
-
1096
- const credential = credentialOf(spec);
1097
- if (!credential.ok && !interactive) {
1098
- io.err(C.red(`\n ${missingCredentialMessage(spec, args.model.length > 0, catalog)}\n`));
1099
- process.exit(1);
1100
- }
1101
-
1102
- const providerConfig: ProviderConfig = {
1103
- spec,
1104
- maxTokens: outputTokenBudget(spec),
1105
- // --thinking wins, then a Ctrl+S default, then /settings thinking / builtin.
1106
- effort:
1107
- asEffort(args.thinking) ??
1108
- asEffort(mergedSettings.defaultThinking) ??
1109
- asEffort(mergedSettings.thinking) ??
1110
- DEFAULT_PROVIDER.effort,
1111
- thinkingDisplay: DEFAULT_PROVIDER.thinkingDisplay,
1112
- };
1113
- /**
1114
- * `--fresh` opens a NEW session beside the old one rather than clearing it.
1115
- *
1116
- * Deleting a session's history is destructive and irreversible, and it must
1117
- * never be the side effect of a flag that reads like "start over". The old
1118
- * log stays where it is, still resumable by pointing --log at it. A leftover
1119
- * `.xeno/log` from before named sessions is the same: pointed at, not moved.
1120
- */
1121
1237
  const paths = statePaths(workspace, args.stateDir);
1122
- const sessionDir = (args.sessionDir ?? "").length > 0 ? args.sessionDir : args.stateDir !== undefined ? paths.sessions : undefined;
1123
- const sessionPick =
1124
- (args.session ?? "").length > 0
1125
- ? args.session
1126
- : typeof args.resume === "string"
1127
- ? args.resume
1128
- : undefined;
1238
+ const sessionDir = sessionDirFor(args, workspace);
1239
+ let sessionOpts: ResolveLogOpts = {
1240
+ ...(args.logDir ? { explicit: resolve(args.logDir) } : {}),
1241
+ fresh: args.fresh,
1242
+ ...(args.continueLatest === undefined ? {} : { continueLatest: args.continueLatest }),
1243
+ ...(args.noSession === undefined ? {} : { noSession: args.noSession }),
1244
+ ...(args.session === undefined ? {} : { session: args.session }),
1245
+ ...(args.sessionId === undefined ? {} : { sessionId: args.sessionId }),
1246
+ ...(args.resume === undefined ? {} : { resume: args.resume }),
1247
+ ...(args.forkFrom === undefined ? {} : { forkFrom: args.forkFrom }),
1248
+ ...(sessionDir === undefined ? {} : { sessionDir }),
1249
+ };
1129
1250
  let logDir: string;
1130
1251
  try {
1131
- const forkFrom = args.forkFrom;
1132
- if (forkFrom !== undefined && forkFrom.length > 0) {
1133
- const found = await findSessionByPrefix(workspace, forkFrom, sessionDir);
1134
- if (!found.ok) {
1135
- io.err(C.red(`\n ${found.reason}\n`));
1136
- process.exit(1);
1252
+ if (planSessionEntry(sessionOpts).kind === "select") {
1253
+ if (printMode || jsonMode || !stdinIsTty() || !stdoutIsTty()) {
1254
+ throw new Error("--resume needs an interactive terminal. Pass -r <id> or --session <id> instead");
1137
1255
  }
1138
- const created = await createSession(workspace, undefined, sessionDir);
1139
- await copyAncestryTo(found.session.path, created.path, new Date().toISOString());
1140
- logDir = created.path;
1141
- } else {
1142
- const resolved = await resolveLogDir(workspace, {
1143
- ...(args.logDir ? { explicit: resolve(args.logDir) } : {}),
1144
- fresh: args.fresh,
1145
- ...(args.continueLatest === true ? { continueLatest: true } : {}),
1146
- ...(args.noSession === true ? { noSession: true } : {}),
1147
- ...(sessionPick !== undefined ? { session: sessionPick } : {}),
1148
- ...(sessionDir !== undefined ? { sessionDir } : {}),
1149
- });
1150
- if (resolved.legacyNotice !== null && !args.logDir) {
1151
- io.say(C.yellow(` ${resolved.legacyNotice}`));
1152
- }
1153
- logDir = resolved.logDir;
1256
+ const picked = await withTerminal(handoff => pickStartupSession({ workspace, style: ui.style, say: io.say, keymap: ui.keymap,
1257
+ ...(sessionDir === undefined ? {} : { sessionDir }) }, handoff));
1258
+ if (picked === null) { io.say(C.dim("No session selected")); process.exit(0); }
1259
+ sessionOpts = { explicit: picked };
1154
1260
  }
1261
+ const resolved = await resolveLogDir(workspace, sessionOpts);
1262
+ if (resolved.legacyNotice !== null && !args.logDir) io.say(C.yellow(` ${resolved.legacyNotice}`));
1263
+ logDir = resolved.logDir;
1155
1264
  } catch (err) {
1156
1265
  const detail = err instanceof Error ? err.message : String(err);
1157
1266
  io.err(C.red(`\n ${detail}\n`));
1158
1267
  process.exit(1);
1159
1268
  }
1160
- if (args.resume === true && sessionPick === undefined && (args.forkFrom ?? "").length === 0 && args.noSession !== true) {
1161
- // `-r` with no id is a selector, not a guess. Print mode cannot pick.
1162
- if (isPrintMode(args)) {
1163
- io.err(C.red("\n -r needs a session id. Pass -r <id> or --session <id>.\n"));
1164
- process.exit(1);
1165
- }
1166
- }
1167
1269
  // Snapshots are on by default here. They are optional in the loop because a
1168
1270
  // read-only branch has nothing to snapshot, but a session a person drives is
1169
1271
  // exactly the one that will want to go back, and the content has to have been
@@ -1172,64 +1274,28 @@ export async function startup(args: Args): Promise<App> {
1172
1274
  await mkdir(logDir, { recursive: true });
1173
1275
  await mkdir(blobDir, { recursive: true });
1174
1276
 
1175
- /**
1176
- * Context files are read once, here, and the result is frozen. Re-reading
1177
- * them at a node boundary would change `system` and invalidate the prefix
1178
- * cache for the whole conversation (D6). A reload is a cut; that is a
1179
- * later card.
1180
- */
1181
- const loadedContext = await loadContext({
1182
- workspace,
1183
- cwd: process.cwd(),
1184
- configDir: configDir(),
1185
- noContextFiles: args.noContextFiles,
1186
- appendSystemPrompt: args.appendSystemPrompt,
1187
- ...(args.systemPrompt !== null ? { systemPrompt: args.systemPrompt } : {}),
1188
- });
1189
- const selection = toolSelectionOf(args);
1277
+ // Resource ownership follows the resolved session workspace. openSessionFor
1278
+ // loads and freezes it once; explicit reloads checkpoint a changed prefix (D6).
1190
1279
  const visible = visibleSchemas(args);
1191
- const filtered =
1192
- selection.only !== undefined ||
1193
- selection.none === true ||
1194
- (selection.exclude !== undefined && selection.exclude.length > 0) ||
1195
- selection.noBuiltin === true;
1196
- const builtin = systemPromptFor((filtered ? visible : TOOL_SCHEMAS).map((t) => t.name), sessionEnvironment(workspace));
1197
- const textLoaded = await loadTextResources({
1198
- workspace,
1199
- configDir: configDir(),
1200
- trusted: loadProjectSettings,
1201
- noSkills: args.noSkills,
1202
- noPromptTemplates: args.noPromptTemplates,
1203
- skillPaths: args.skillPaths,
1204
- promptTemplatePaths: args.promptTemplatePaths,
1205
- });
1206
- for (const line of textLoaded.diagnostics) io.say(C.yellow(` ${line}`));
1207
- const slashResolved = resolveSlashCatalog(textLoaded.prompts, textLoaded.skills);
1208
- for (const line of slashResolved.diagnostics) io.say(C.yellow(` ${line}`));
1209
- const slash = slashResolved.catalog;
1210
- const system = appendSkillsToSystem(
1211
- assembleSystemPrompt(builtin, loadedContext),
1212
- [...slash.skills.values()],
1213
- );
1214
1280
 
1215
1281
  const askSlot: AskSlot = {};
1216
- const oauth = credential.ok && storedIsOAuth(auth, spec.provider) && args.apiKey.length === 0;
1217
- const session = createSessionFor({
1218
- args, workspace, logDir, blobDir, system, providerConfig, credential, io, askSlot,
1219
- ...(oauth ? { oauth: true } : {}),
1220
- });
1221
- // Read before starting, so the banner can say whether this is a new session
1222
- // or a continued one. start() moves the cursor, which would erase the answer.
1223
- const priorCursor = await readCursor(logDir);
1224
-
1282
+ let opened: Awaited<ReturnType<typeof openSessionFor>>;
1225
1283
  try {
1226
- await session.start();
1284
+ opened = await openSessionFor({
1285
+ args, workspace, logDir, blobDir, io, askSlot, catalog, auth, credentialOf, effects,
1286
+ settings: mergedSettings, modelFlags: { model: args.model, provider: args.provider }, projectTrusted: loadProjectSettings,
1287
+ ...(preparedResources ? { preparedResources } : {}),
1288
+ interactive: isInteractiveSession(args),
1289
+ ...(input ? { startupPhase: input.phase } : {}),
1290
+ });
1227
1291
  } catch (err) {
1228
1292
  const detail = err instanceof Error ? err.message : String(err);
1229
1293
  io.err(C.red(`\n cannot start a session in ${workspace}`));
1230
1294
  io.err(C.dim(` ${detail}\n`));
1231
1295
  process.exit(1);
1232
1296
  }
1297
+ const { session, priorCursor, providerConfig, credential } = opened;
1298
+ refreshOutputMode();
1233
1299
 
1234
1300
  const sessionName = args.sessionName;
1235
1301
  if (sessionName !== undefined && sessionName.length > 0) {
@@ -1239,17 +1305,19 @@ export async function startup(args: Args): Promise<App> {
1239
1305
  }
1240
1306
  }
1241
1307
 
1242
- printBanner(args, io, {
1243
- workspace, logDir, providerConfig, credential, loadedContext, session, priorCursor, catalog,
1244
- keymap: ui.keymap,
1245
- skills: [...slash.skills.keys()],
1246
- prompts: [...slash.prompts.keys()],
1308
+ if (opened.resources.ui.settings.quietStartup !== true || args.verbose) printBanner(args, io, {
1309
+ workspace: opened.workspace, logDir, providerConfig, credential, loadedContext: opened.resources.loadedContext, session, priorCursor, catalog,
1310
+ keymap: opened.resources.ui.keymap,
1311
+ skills: [...opened.resources.slash.skills.keys()],
1312
+ prompts: [...opened.resources.slash.prompts.keys()],
1247
1313
  tools: visible.map((t) => t.name),
1248
1314
  });
1249
1315
 
1250
1316
  return {
1251
- args, session, workspace, logDir, blobDir, system, providerConfig, credential,
1252
- auth, effects, loadedContext, credentialOf, catalog, io, askSlot, ui, slash,
1317
+ args, session, workspace: opened.workspace, logDir, blobDir: opened.blobDir, system: opened.system, providerConfig, credential,
1318
+ auth, effects, loadedContext: opened.resources.loadedContext, credentialOf, catalog, io, askSlot,
1319
+ ui: opened.resources.ui, slash: opened.resources.slash, resources: opened.resources.index, projectTrusted: opened.resources.trusted,
1320
+ extensions: opened.resources.extensions,
1253
1321
  };
1254
1322
  }
1255
1323
 
@@ -1321,3 +1389,53 @@ function printBanner(
1321
1389
  }
1322
1390
  io.say("");
1323
1391
  }
1392
+
1393
+ export function printCliHelp(flags?: ReadonlyMap<string, ExtensionFlag>): void {
1394
+ console.log(
1395
+ "usage: xeno [-m <model>] [--provider <name>] [--api-key <key>]\n" +
1396
+ " [-w <workspace>] [--log <dir>] [--max-turns N]\n" +
1397
+ " [--context-policy windowed|segmented|continuous|adaptive]\n" +
1398
+ " [--trigger N --hard N] [--list-models] [--fresh]\n" +
1399
+ " [-c|--continue] [-r|--resume [id]] [--session path|id]\n" +
1400
+ " [--fork path|id] [--no-session] [--session-dir dir]\n" +
1401
+ " [-n|--name name] [--session-id id] [--version|-v]\n" +
1402
+ " [models --refresh]\n" +
1403
+ " [install|remove <source>... [--local]] [update [source] [--extensions]] [list]\n" +
1404
+ " [config [--local]] [--export session [output.html]] [--offline]\n" +
1405
+ " [--system-prompt <text-or-file>] [--append-system-prompt <text>]\n" +
1406
+ " [--no-context-files|-nc]\n" +
1407
+ " [--tools names] [--exclude-tools names] [--no-tools]\n" +
1408
+ " [--no-builtin-tools]\n" +
1409
+ " [--allow-tools names] [--approve|--no-approve]\n" +
1410
+ " [--theme <name|path>] [--use-theme name] [--no-themes] [--thinking level] [--models patterns]\n" +
1411
+ " [--skill <path>] [--no-skills]\n" +
1412
+ " [--prompt-template <path>] [--no-prompt-templates]\n" +
1413
+ " [--extension <path>] [--no-extensions] [--extension-flag name=value]\n" +
1414
+ " [--mode text|json] [--tui-mode regular|fullscreen] [--verbose]\n" +
1415
+ " [-p <prompt>] [prompt...]\n\n" +
1416
+ " Starts a new session by default; --fresh is an explicit alias. Nothing is deleted.\n" +
1417
+ " -c continues the newest (or starts one); -r opens the session picker.\n" +
1418
+ " --session / -r <id> pick one (unique prefix only). Resume restores its model;\n" +
1419
+ " an explicit --model switches at a checkpoint before the next turn.\n" +
1420
+ " --no-session runs without writing .xeno/sessions/. --fork copies into a new one.\n" +
1421
+ " A leftover .xeno/log is not moved; continue it with --log .xeno/log.\n\n" +
1422
+ " With a prompt, runs it and exits. Without one, reads prompts from stdin.\n" +
1423
+ " Commands: " + COMMANDS.map(command => "/" + command.name).join(" ") + "\n\n" +
1424
+ " Credentials resolve in this order: --api-key, then ~/.xeno/auth.json,\n" +
1425
+ " then the provider's environment variable.\n" +
1426
+ " --system-prompt replaces the built-in instructions (text, or a file that exists).\n" +
1427
+ " --append-system-prompt appends; repeatable. -nc skips AGENTS.md discovery.\n" +
1428
+ " --tools is a strict allowlist; excluded tools leave the schema.\n" +
1429
+ " --no-builtin-tools drops built-ins and keeps any other source; --tools still wins.\n" +
1430
+ " bash asks before it runs. --allow-tools bash skips the ask for this process.\n" +
1431
+ " --time-budget <seconds> tells the model, once, when 75% of the run's wall-clock budget is used.\n" +
1432
+ " --state-dir <dir> keeps snapshots, observations and sessions out of the workspace.\n" +
1433
+ " --path-root <dir> lets file tools reach absolute paths under <dir> (e.g. / in a container);\n" +
1434
+ " relative paths, fingerprints and /back stay in the workspace, so edits outside it are not rewound.\n" +
1435
+ " --approve loads .xeno/settings.json this run; --no-approve skips it.\n" +
1436
+ " Project trust does not affect tool execution.\n" +
1437
+ " --mode json writes one JSON object per event to stdout; diagnostics stay on stderr.\n" +
1438
+ " --verbose expands the startup header (AGENTS.md, skills, prompt templates).",
1439
+ );
1440
+ if (flags?.size) console.log("\nExtension CLI flags:\n" + [...flags].map(([name, flag]) => ` --${name}${flag.type === "string" ? " <value>" : ""} ${flag.description ?? ""}`).join("\n"));
1441
+ }