@visual-z/xeno 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +143 -33
  3. package/docs/install.md +18 -6
  4. package/docs/providers.md +147 -0
  5. package/package.json +8 -2
  6. package/src/apps/cli.ts +42 -14
  7. package/src/apps/commands.ts +32 -4
  8. package/src/apps/completions.ts +9 -6
  9. package/src/apps/export-cli.ts +24 -0
  10. package/src/apps/extension-ui.ts +330 -0
  11. package/src/apps/input.ts +16 -1
  12. package/src/apps/model-catalog.ts +42 -0
  13. package/src/apps/project-trust.ts +49 -0
  14. package/src/apps/render.ts +1 -0
  15. package/src/apps/resource-browser.ts +75 -0
  16. package/src/apps/resources.ts +25 -0
  17. package/src/apps/session-browser.ts +113 -0
  18. package/src/apps/session-picker.ts +55 -0
  19. package/src/apps/shell.ts +1177 -378
  20. package/src/apps/startup.ts +400 -268
  21. package/src/core/action-defaults.ts +306 -0
  22. package/src/core/attachment-input.ts +17 -0
  23. package/src/core/catalog-merge.ts +19 -0
  24. package/src/core/event-validation.ts +19 -2
  25. package/src/core/events.ts +45 -2
  26. package/src/core/export.ts +6 -2
  27. package/src/core/extension-flags.ts +14 -0
  28. package/src/core/images.ts +46 -0
  29. package/src/core/json-event.ts +2 -0
  30. package/src/core/keymap.ts +43 -119
  31. package/src/core/model-replay.ts +22 -0
  32. package/src/core/model-scope.ts +53 -0
  33. package/src/core/packages.ts +47 -0
  34. package/src/core/project.ts +21 -3
  35. package/src/core/prompts.ts +33 -5
  36. package/src/core/queue.ts +18 -0
  37. package/src/core/resource-selection.ts +36 -0
  38. package/src/{runtime → core}/resume.ts +15 -22
  39. package/src/core/session-archive.ts +4 -0
  40. package/src/core/session-browser.ts +82 -0
  41. package/src/core/session-entry.ts +56 -0
  42. package/src/core/session-position.ts +26 -0
  43. package/src/core/settings.ts +169 -154
  44. package/src/core/theme.ts +15 -6
  45. package/src/core/thinking.ts +26 -0
  46. package/src/core/tokens.ts +2 -0
  47. package/src/core/transcript.ts +15 -5
  48. package/src/runtime/auth.ts +116 -11
  49. package/src/runtime/catalog.ts +54 -151
  50. package/src/runtime/clipboard.ts +114 -0
  51. package/src/runtime/extension-loader.ts +37 -0
  52. package/src/runtime/extension-ui.ts +55 -0
  53. package/src/runtime/extensions.ts +273 -0
  54. package/src/runtime/images.ts +76 -0
  55. package/src/runtime/loop.ts +213 -83
  56. package/src/runtime/model-config.ts +232 -0
  57. package/src/runtime/model-data.LICENSE +27 -0
  58. package/src/runtime/model-data.ts +21 -0
  59. package/src/runtime/model-replay.ts +10 -0
  60. package/src/runtime/model-scope.ts +20 -0
  61. package/src/runtime/model-sources.ts +151 -0
  62. package/src/runtime/model.ts +74 -11
  63. package/src/runtime/models-fetch.ts +6 -104
  64. package/src/runtime/oauth.ts +4 -3
  65. package/src/runtime/openai-responses.ts +12 -4
  66. package/src/runtime/openai.ts +13 -4
  67. package/src/runtime/package-manager.ts +125 -0
  68. package/src/runtime/pi-provider.ts +235 -0
  69. package/src/runtime/progress.ts +26 -0
  70. package/src/runtime/provider-access.ts +21 -0
  71. package/src/runtime/provider-protocols.ts +29 -0
  72. package/src/runtime/provider-request.ts +73 -0
  73. package/src/runtime/provider.ts +26 -6
  74. package/src/runtime/rich-text.ts +36 -0
  75. package/src/runtime/tools.ts +41 -5
  76. package/src/store/command.ts +7 -1
  77. package/src/store/context-files.ts +1 -1
  78. package/src/store/log.ts +65 -0
  79. package/src/store/models.ts +28 -9
  80. package/src/store/observations.ts +4 -2
  81. package/src/store/packages.ts +98 -0
  82. package/src/store/recovery.ts +8 -0
  83. package/src/store/resource-index.ts +170 -0
  84. package/src/store/resources.ts +75 -142
  85. package/src/store/session-archive.ts +162 -0
  86. package/src/store/session-browser.ts +68 -0
  87. package/src/store/session-position.ts +27 -0
  88. package/src/store/sessions.ts +87 -66
  89. package/src/store/trust.ts +13 -0
  90. package/src/tui/chat-viewport.ts +47 -0
  91. package/src/tui/editor.ts +62 -15
  92. package/src/tui/host.ts +50 -9
  93. package/src/tui/input.ts +8 -2
  94. package/src/tui/select.ts +24 -6
  95. package/src/tui/settings.ts +4 -0
  96. package/src/tui/system-theme.ts +700 -0
  97. package/src/tui/transcript.ts +63 -11
  98. package/src/tui/tree.ts +161 -95
  99. package/src/vendor/pi-rich/LICENSE.upstream +21 -0
  100. package/src/vendor/pi-rich/UPSTREAM.json +17 -0
  101. package/src/vendor/pi-rich/clipboard-command.ts +44 -0
  102. package/src/vendor/pi-rich/exif-orientation.ts +182 -0
  103. package/src/vendor/pi-rich/html.ts +51 -0
  104. package/src/vendor/pi-rich/image-convert.ts +49 -0
  105. package/src/vendor/pi-rich/image-resize-core.ts +164 -0
  106. package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
  107. package/src/vendor/pi-rich/image-resize.ts +123 -0
  108. package/src/vendor/pi-rich/mime.ts +116 -0
  109. package/src/vendor/pi-rich/photon.ts +139 -0
  110. package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
  111. package/src/vendor/pi-tui/UPSTREAM.json +50 -0
  112. package/src/vendor/pi-tui/VENDOR.md +7 -5
  113. package/src/vendor/pi-tui/autocomplete.ts +35 -10
  114. package/src/vendor/pi-tui/colors.ts +367 -0
  115. package/src/vendor/pi-tui/components/box.ts +8 -9
  116. package/src/vendor/pi-tui/components/editor.ts +4 -2
  117. package/src/vendor/pi-tui/components/markdown.ts +8 -2
  118. package/src/vendor/pi-tui/index.ts +31 -1
  119. package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
  120. package/src/vendor/pi-tui/native/clipboard.h +208 -0
  121. package/src/vendor/pi-tui/native/darwin/README.md +22 -0
  122. package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
  123. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  124. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  125. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
  126. package/src/vendor/pi-tui/native/linux/README.md +23 -0
  127. package/src/vendor/pi-tui/native/linux/build.sh +65 -0
  128. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  129. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  130. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
  131. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
  132. package/src/vendor/pi-tui/native/napi.h +91 -0
  133. package/src/vendor/pi-tui/native/win32/README.md +31 -0
  134. package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
  135. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  136. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  137. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
  138. package/src/vendor/pi-tui/native-platform.ts +2 -0
  139. package/src/vendor/pi-tui/oklab.ts +233 -0
  140. package/src/vendor/pi-tui/terminal-colors.ts +26 -8
  141. package/src/vendor/pi-tui/terminal-image.ts +43 -8
  142. package/src/vendor/pi-tui/terminal.ts +16 -9
  143. package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
  144. package/src/vendor/pi-tui/tui.ts +112 -75
  145. package/src/vendor/pi-tui/utils.ts +89 -49
  146. 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 {
@@ -165,6 +175,15 @@ export interface Args {
165
175
  provider: string;
166
176
  apiKey: string;
167
177
  listModels: boolean;
178
+ modelSearch?: string;
179
+ help?: boolean;
180
+ version?: boolean;
181
+ exportFile?: string;
182
+ offline?: boolean;
183
+ tuiMode?: "regular" | "fullscreen";
184
+ noThemes?: boolean;
185
+ themePaths?: string[];
186
+ sessionId?: string;
168
187
  modelsCommand: boolean;
169
188
  modelsRefresh: boolean;
170
189
  fresh: boolean;
@@ -180,6 +199,10 @@ export interface Args {
180
199
  allowTools: string[];
181
200
  /** `--approve` true, `--no-approve` false. Omitted when neither. */
182
201
  approve?: boolean;
202
+ configCommand?: boolean;
203
+ local?: boolean;
204
+ packageCommand?: PackageCommand;
205
+ packagePaths?: {source:string;path:string}[];
183
206
  /**
184
207
  * `-p` / `--print`. Distinct from a leftover positional prompt: only this
185
208
  * flag reads piped stdin, so a one-shot that did not ask for a pipe cannot
@@ -192,8 +215,14 @@ export interface Args {
192
215
  thinking?: string;
193
216
  /** `--models <patterns>`. Comma-separated; empty when the flag was not passed. */
194
217
  models?: string;
218
+ modelsExplicit?: boolean;
195
219
  noSkills: boolean;
196
220
  noPromptTemplates: boolean;
221
+ noExtensions?: boolean;
222
+ extensionPaths?: string[];
223
+ extensionFlags?: Record<string, string | boolean>;
224
+ extensionArgv?: string[];
225
+ extensionInputs?: Record<string, FlagInput>;
197
226
  skillPaths: string[];
198
227
  promptTemplatePaths: string[];
199
228
  /** `-c` / `--continue`. */
@@ -266,10 +295,12 @@ const KNOWN_FLAGS: readonly string[] = [
266
295
  "--prompt-template",
267
296
  "--no-skills",
268
297
  "--no-prompt-templates",
298
+ "--extension", "-e", "--no-extensions", "-ne", "--extension-flag",
269
299
  "--mode",
270
300
  "--verbose",
271
301
  "--print", "-p",
272
302
  "--help", "-h",
303
+ "--version", "-v", "--export", "--session-id", "--offline", "--tui-mode", "--use-theme", "--no-themes", "-ns", "-np", "-nbt",
273
304
  ];
274
305
 
275
306
  /**
@@ -341,7 +372,8 @@ export function silenceTtyEarly(opts: {
341
372
  };
342
373
  }
343
374
 
344
- export function parseArgs(argv: string[]): Args {
375
+ export function parseArgs(argv: string[], options: { deferExtensions?: boolean; flags?: ReadonlyMap<string, ExtensionFlag> } = {}): Args {
376
+ let selectedTheme: string | undefined;
345
377
  const args: Args = {
346
378
  workspace: process.cwd(), logDir: "", maxTurns: 40, prompt: "",
347
379
  // Overridable so a node boundary can be forced in a short session --
@@ -362,6 +394,11 @@ export function parseArgs(argv: string[]): Args {
362
394
  };
363
395
  for (let i = 0; i < argv.length; i++) {
364
396
  const a = argv[i]!;
397
+ if (["--log", "--session", "--fork", "--session-dir", "--state-dir"].includes(a) &&
398
+ (!argv[i + 1] || argv[i + 1]!.startsWith("-"))) {
399
+ console.error(`${a} requires a value`);
400
+ process.exit(1);
401
+ }
365
402
  if (a === "--workspace" || a === "-w") args.workspace = argv[++i] ?? args.workspace;
366
403
  else if (a === "--path-root") args.pathRoot = resolve(argv[++i] ?? "/");
367
404
  else if (a === "--state-dir") args.stateDir = resolve(argv[++i] ?? ".");
@@ -382,13 +419,22 @@ export function parseArgs(argv: string[]): Args {
382
419
  else if (a === "--model" || a === "-m") args.model = argv[++i] ?? "";
383
420
  else if (a === "--provider") args.provider = argv[++i] ?? "";
384
421
  else if (a === "--api-key") args.apiKey = argv[++i] ?? "";
385
- else if (a === "--list-models") args.listModels = true;
422
+ else if (a === "--list-models") { args.listModels = true; const next = argv[i + 1]; if (next && !next.startsWith("-") && !next.startsWith("@")) args.modelSearch = argv[++i]!; }
423
+ else if (a === "--version" || a === "-v") args.version = true;
424
+ else if (a === "--export") { const file = argv[++i]; if (!file || file.startsWith("-")) throw new Error("--export requires a session path"); args.exportFile = file; }
425
+ 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; }
426
+ else if (a === "--offline") args.offline = true;
427
+ 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
428
  else if (a === "models" && i === 0) {
387
429
  // Subcommand, not a prompt. Do not consume the rest: a later
388
430
  // `--models-url` must still be an unknown-flag error, and `-h`
389
431
  // must still print help.
390
432
  args.modelsCommand = true;
391
433
  }
434
+ else if (a === "config" && i === 0) args.configCommand = true;
435
+ else if ((a === "install" || a === "remove" || a === "update" || a === "list") && i === 0) args.packageCommand = {operation:a,sources:[]};
436
+ else if ((a === "--local" || a === "-l") && (args.packageCommand || args.configCommand)) { args.local=true; if(args.packageCommand)args.packageCommand.local=true; }
437
+ else if (a === "--extensions" && args.packageCommand?.operation === "update") { /* Package-only update; xeno never self-updates. */ }
392
438
  else if (a === "--refresh" && args.modelsCommand) {
393
439
  args.modelsRefresh = true;
394
440
  }
@@ -410,70 +456,41 @@ export function parseArgs(argv: string[]): Args {
410
456
  else if (a === "--tools" || a === "-t") args.tools = parseToolList(argv[++i] ?? "");
411
457
  else if (a === "--exclude-tools" || a === "-xt") args.excludeTools = parseToolList(argv[++i] ?? "");
412
458
  else if (a === "--no-tools" || a === "-nt") args.noTools = true;
413
- else if (a === "--no-builtin-tools") args.noBuiltinTools = true;
459
+ else if (a === "--no-builtin-tools" || a === "-nbt") args.noBuiltinTools = true;
414
460
  else if (a === "--allow-tools") args.allowTools = parseToolList(argv[++i] ?? "");
415
461
  else if (a === "--approve" || a === "-a") args.approve = true;
416
462
  else if (a === "--no-approve" || a === "-na") args.approve = false;
417
- else if (a === "--theme") args.theme = argv[++i] ?? "";
463
+ else if (a === "--theme") {
464
+ const theme = argv[++i]; if (!theme || theme.startsWith("-")) throw new Error("--theme requires a name or path");
465
+ if (theme.includes("/") || theme.includes("\\") || theme.endsWith(".json")) { (args.themePaths ??= []).push(theme); if (theme.endsWith(".json")) args.theme = theme; }
466
+ else args.theme = theme;
467
+ }
468
+ else if (a === "--use-theme") { const theme = argv[++i]; if (!theme || theme.startsWith("-")) throw new Error("--use-theme requires a name"); selectedTheme = theme; }
469
+ else if (a === "--no-themes") args.noThemes = true;
418
470
  else if (a === "--thinking") args.thinking = argv[++i] ?? "";
419
- else if (a === "--models") args.models = argv[++i] ?? "";
471
+ 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
472
  else if (a === "--skill") args.skillPaths.push(argv[++i] ?? "");
421
473
  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;
474
+ else if (a === "--no-skills" || a === "-ns") args.noSkills = true;
475
+ else if (a === "--no-prompt-templates" || a === "-np") args.noPromptTemplates = true;
476
+ else if (a === "--no-extensions" || a === "-ne") args.noExtensions = true;
477
+ else if (a === "--extension" || a === "-e") {
478
+ const path = argv[++i]; if (!path || path.startsWith("-")) throw new Error("--extension requires a path");
479
+ (args.extensionPaths ??= []).push(path);
480
+ }
481
+ else if (a === "--extension-flag") {
482
+ const value = argv[++i] ?? "", at = value.indexOf("=");
483
+ if (at <= 0) throw new Error("--extension-flag requires name=value");
484
+ (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);
485
+ }
424
486
  else if (a === "--mode") args.mode = argv[++i] ?? "";
425
487
  else if (a === "--verbose") args.verbose = true;
426
488
  else if (a === "-p" || a === "--print") {
427
489
  args.print = true;
428
- const next = argv[++i] ?? "";
429
- args.prompt += (args.prompt ? " " : "") + next;
430
490
  }
431
491
  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
- );
492
+ if (options.deferExtensions) { args.help = true; continue; }
493
+ printCliHelp();
477
494
  process.exit(0);
478
495
  } else if (a === "--") {
479
496
  // Everything after `--` is content, never a flag. The delimiter
@@ -483,9 +500,23 @@ export function parseArgs(argv: string[]): Args {
483
500
  }
484
501
  break;
485
502
  } else if (a.startsWith("-") && a !== "-") {
486
- rejectUnknownFlag(a);
487
- } else args.prompt += (args.prompt ? " " : "") + a;
503
+ const equal = a.indexOf("="), name = a.slice(2, equal === -1 ? undefined : equal), definition = options.flags?.get(name);
504
+ if (a.startsWith("--") && (definition || options.deferExtensions)) {
505
+ const next = argv[i + 1], input: FlagInput = { inline: equal !== -1,
506
+ ...(equal !== -1 ? { value: a.slice(equal + 1) } : next !== undefined && !next.startsWith("-") && !next.startsWith("@") ? { value: next } : {}) };
507
+ if (definition) {
508
+ const value = extensionFlagValue(name, definition, input);
509
+ if (value !== undefined) (args.extensionFlags ??= Object.create(null) as Record<string, string | boolean>)[name] = value;
510
+ if (!input.inline && input.value !== undefined && (definition.type === "string" || input.value === "true" || input.value === "false")) i++;
511
+ } else {
512
+ args.extensionArgv = [...argv]; (args.extensionInputs ??= Object.create(null) as Record<string, FlagInput>)[name] = input;
513
+ if (!input.inline && input.value !== undefined) i++;
514
+ }
515
+ } else rejectUnknownFlag(a);
516
+ } else if(args.packageCommand)args.packageCommand.sources.push(a);
517
+ else args.prompt += (args.prompt ? " " : "") + a;
488
518
  }
519
+ if (selectedTheme !== undefined) args.theme = selectedTheme;
489
520
  return args;
490
521
  }
491
522
 
@@ -621,7 +652,7 @@ function countCatalogDelta(
621
652
  return map;
622
653
  }
623
654
  for (const spec of parseUserCatalog(json).models) {
624
- map.set(spec.id, JSON.stringify(spec));
655
+ map.set(modelKey(spec), JSON.stringify(spec));
625
656
  }
626
657
  return map;
627
658
  };
@@ -650,13 +681,13 @@ export async function runModelsRefresh(opts: {
650
681
  | { ok: true; count: number; added: number; changed: number }
651
682
  | { ok: false; reason: string }
652
683
  > {
653
- const before = await readModelsText();
684
+ const before = await readCatalogCacheText();
654
685
  const result = await refreshRemoteCatalog({
655
686
  fetch: opts.fetch,
656
687
  write: opts.write,
657
688
  });
658
689
  if (!result.ok) return result;
659
- const after = await readModelsText();
690
+ const after = await readCatalogCacheText();
660
691
  const delta = countCatalogDelta(before.text, after.text);
661
692
  return { ok: true, count: result.count, added: delta.added, changed: delta.changed };
662
693
  }
@@ -729,6 +760,10 @@ export interface App {
729
760
  askSlot: AskSlot;
730
761
  ui?: UiState;
731
762
  slash?: SlashCatalog;
763
+ resources?: ResourceIndex;
764
+ projectTrusted?: boolean;
765
+ extensions?: ExtensionRegistry;
766
+ shutdownRequested?: boolean;
732
767
  }
733
768
 
734
769
  export interface UiState {
@@ -749,13 +784,6 @@ function toUiState(loaded: UiResources, color: boolean): UiState {
749
784
  };
750
785
  }
751
786
 
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
787
  /** `--models a,b` and settings.scopedModels share this split so a stray space cannot invent a pattern. */
760
788
  export function splitModelPatterns(raw: string | undefined): string[] {
761
789
  if (raw === undefined || raw.length === 0) return [];
@@ -791,7 +819,7 @@ export function missingCredentialMessage(spec: ModelSpec, explicit: boolean, cat
791
819
  }
792
820
 
793
821
  export function isInteractiveSession(args: Args): boolean {
794
- return args.prompt.length === 0 && stdinIsTty() && stdoutIsTty();
822
+ return !args.print && !args.help && args.mode !== "json" && args.prompt.length === 0 && stdinIsTty() && stdoutIsTty();
795
823
  }
796
824
 
797
825
  /**
@@ -847,8 +875,9 @@ export function toolSelectionOf(args: Args) {
847
875
  * the provider bills, and an extra field there would look like a tool
848
876
  * argument.
849
877
  */
850
- export function visibleSchemas(args: Args) {
851
- const available = TOOL_SCHEMAS.map((t) => ({ ...t, source: "builtin" as const }));
878
+ export function visibleSchemas(args: Args, extensions?: ExtensionRegistry) {
879
+ const custom = [...extensions?.definitions().values() ?? []].map(({ name, description, input_schema }) => ({ name, description, input_schema, source: "extension" as const }));
880
+ const available = [...TOOL_SCHEMAS.filter(tool => !extensions?.tools.has(tool.name)).map((t) => ({ ...t, source: "builtin" as const })), ...custom];
852
881
  return selectTools(available, toolSelectionOf(args)).map((tool) => {
853
882
  const { source, ...schema } = tool;
854
883
  void source;
@@ -872,9 +901,11 @@ export function createSessionFor(opts: {
872
901
  io: AppIO;
873
902
  askSlot: AskSlot;
874
903
  oauth?: boolean;
904
+ effects?: ResolveEffects;
905
+ extensions?: ExtensionRegistry;
875
906
  }): Session {
876
907
  const { args, workspace, logDir, blobDir, system, providerConfig, credential, io, askSlot } = opts;
877
- const schemas = visibleSchemas(args);
908
+ const schemas = visibleSchemas(args, opts.extensions);
878
909
  const names = schemas.map((t) => t.name);
879
910
  const sessionRules: PermissionRule[] = [];
880
911
  const tools: ToolContext = {
@@ -885,6 +916,7 @@ export function createSessionFor(opts: {
885
916
  rules: rulesWithAllowTools(args.allowTools),
886
917
  sessionRules,
887
918
  askSlot,
919
+ ...(opts.extensions ? { extensions: opts.extensions.definitions(), extensionHooks: opts.extensions.toolHooks() } : {}),
888
920
  };
889
921
  return new Session(
890
922
  {
@@ -916,21 +948,171 @@ export function createSessionFor(opts: {
916
948
  providerConfig,
917
949
  credential.ok ? credential.key : "",
918
950
  retryHandler(io),
919
- opts.oauth === true ? { oauth: true } : undefined,
951
+ { ...(opts.oauth === true ? { oauth: true } : {}), ...(opts.effects ? { effects: opts.effects } : {}) },
920
952
  ),
921
953
  systemClock(),
922
954
  );
923
955
  }
924
956
 
957
+ /** One location policy for startup, /new, /resume and /fork. */
958
+ export function sessionDirFor(args: Args, workspace: string): string | undefined {
959
+ if (args.sessionDir) return args.sessionDir;
960
+ return args.stateDir === undefined ? undefined : statePaths(workspace, args.stateDir).sessions;
961
+ }
962
+
963
+ type OpenSessionOptions = Omit<Parameters<typeof createSessionFor>[0], "providerConfig" | "credential" | "oauth" | "system"> & {
964
+ catalog: readonly ModelSpec[];
965
+ auth: AuthFile;
966
+ credentialOf: (spec: ModelSpec) => Resolved;
967
+ interactive: boolean;
968
+ settings?: Settings;
969
+ /** CLI overrides belong to this open only; /resume must not inherit them. */
970
+ modelFlags?: { model: string; provider: string };
971
+ preparedResources?: { workspace: string; value: Awaited<ReturnType<typeof loadSessionResources>> };
972
+ fallback?: ProviderConfig;
973
+ projectTrusted?: boolean;
974
+ };
975
+
976
+ /** Pi replaces cwd-scoped resources with the session runtime; publish only after all reads succeed. */
977
+ async function prepareExtensionPackages(args: Args, workspace: string): Promise<void> {
978
+ const remote=(args.extensionPaths??[]).filter(path=>/^(npm:|git:|git\+|https?:\/\/|ssh:\/\/|git@)/.test(path));
979
+ if(!remote.length)return;
980
+ if(args.offline || process.env["XENO_OFFLINE"] === "1") throw new Error("--offline prevents remote extension package preparation; install it explicitly beforehand");
981
+ const manager=new PackageManager({workspace});
982
+ for(const source of remote)(args.packagePaths??=[]).push({source,path:await manager.temporary(source)});
983
+ args.extensionPaths=(args.extensionPaths??[]).filter(path=>!remote.includes(path));
984
+ }
985
+
986
+ export async function loadSessionResources(args: Args, workspace: string, io: AppIO, trustOverride?: boolean): Promise<{
987
+ loadedContext: LoadedContext; slash: SlashCatalog; ui: UiState; index: ResourceIndex; trusted: boolean; system: string; extensions: ExtensionRegistry;
988
+ }> {
989
+ const { trusted } = await projectTrust(workspace, trustOverride ?? args.approve);
990
+ await prepareExtensionPackages(args,workspace);
991
+ const index = await discoverResources({ ...args, workspace, trusted });
992
+ const extensions = new ExtensionRegistry({ reserved: COMMANDS.map(command => command.name), reservedFlags: KNOWN_FLAGS.map(flag => flag.replace(/^-+/, "")),
993
+ 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 });
994
+ await extensions.load(index);
995
+ if(args.extensionArgv) {
996
+ const validated = parseArgs(args.extensionArgv, { flags: extensions.flags, ...(args.help ? { deferExtensions: true } : {}) });
997
+ if (validated.extensionArgv) rejectUnknownFlag("--" + Object.keys(validated.extensionInputs ?? {})[0]);
998
+ args.prompt = validated.prompt; args.extensionFlags = validated.extensionFlags ?? {};
999
+ }
1000
+ const loaded = await loadUiResources({ ...args, workspace, trusted, ...(args.theme ? { theme: args.theme } : {}) }, index);
1001
+ const loadedContext = await loadContext({ workspace, cwd: workspace, configDir: configDir(),
1002
+ noContextFiles: args.noContextFiles, appendSystemPrompt: args.appendSystemPrompt,
1003
+ ...(args.systemPrompt === null ? {} : { systemPrompt: args.systemPrompt }) });
1004
+ for (const notice of loadedContext.notices) if (notice.kind === "skipped" || notice.truncated) io.say(io.palette.yellow(formatNotice(notice)));
1005
+ const text = await loadTextResources({ workspace, configDir: configDir(), trusted, noSkills: args.noSkills,
1006
+ noPromptTemplates: args.noPromptTemplates, skillPaths: args.skillPaths, promptTemplatePaths: args.promptTemplatePaths }, index);
1007
+ const slash = resolveSlashCatalog(text.prompts, text.skills);
1008
+ for (const line of new Set([...loaded.diagnostics, ...text.diagnostics, ...slash.diagnostics])) io.say(io.palette.yellow(` ${line}`));
1009
+ const system = appendSkillsToSystem(assembleSystemPrompt(systemPromptFor(visibleSchemas(args, extensions).map(tool => tool.name), sessionEnvironment(workspace)), loadedContext), text.skills);
1010
+ const ui = toUiState(loaded, colorEnabled());
1011
+ if (args.tuiMode) ui.settings = { ...ui.settings, tuiMode: args.tuiMode };
1012
+ return { loadedContext, slash: { ...slash.catalog, skillCommands: loaded.settings.enableSkillCommands !== false }, ui, index, trusted, system, extensions };
1013
+ }
1014
+
1015
+ /**
1016
+ * Pi's session-before-model order, with xeno's N.8 checkpoint on an explicit
1017
+ * model change. Opening never sends a request to the previous provider.
1018
+ * Callers publish the returned session/config/credential together on success.
1019
+ */
1020
+ export async function openSessionFor(opts: OpenSessionOptions): Promise<{
1021
+ session: Session; priorCursor: Cursor | null; providerConfig: ProviderConfig; credential: Resolved;
1022
+ workspace: string; blobDir: string; system: string; resources: Awaited<ReturnType<typeof loadSessionResources>>;
1023
+ }> {
1024
+ const recorded = await readCursor(opts.logDir);
1025
+ const priorCursor = recorded === null ? null : await recoverCut(opts.logDir, recorded);
1026
+ const workspace = priorCursor?.workspace ?? opts.workspace;
1027
+ if (opts.args.pathRoot) {
1028
+ const rel = relative(opts.args.pathRoot, workspace);
1029
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error("saved workspace is outside --path-root");
1030
+ }
1031
+ if (opts.preparedResources && opts.preparedResources.workspace !== workspace) await opts.preparedResources.value.extensions.dispose();
1032
+ const resources = opts.preparedResources?.workspace === workspace ? opts.preparedResources.value
1033
+ : await loadSessionResources(opts.args, workspace, opts.io, workspace === opts.workspace ? opts.projectTrusted : undefined);
1034
+ const interactive = opts.interactive && !opts.args.print && !opts.args.prompt && opts.args.mode !== "json";
1035
+ const named = [...opts.args.tools ?? [], ...opts.args.excludeTools, ...opts.args.allowTools];
1036
+ const unknown = unknownToolNames(named).filter(name => !resources.extensions.tools.has(name));
1037
+ if (unknown.length) throw new Error(`unknown tool: ${unknown.join(", ")}`);
1038
+ const saved = priorCursor === null ? undefined : restoreSessionModel(priorCursor, opts.catalog);
1039
+ const flags = { ...opts.modelFlags ?? { model: "", provider: "" } };
1040
+ const parsedModel = flags.model ? scopedModels([flags.model], flags.provider ? opts.catalog.filter(model => model.provider === flags.provider) : opts.catalog, true).entries[0] : undefined;
1041
+ if (parsedModel) flags.model = modelKey(parsedModel.model);
1042
+ const explicit = flags.model.length > 0 || flags.provider.length > 0;
1043
+ const settings = opts.settings ?? {};
1044
+ const preferred = saved ?? opts.fallback?.spec;
1045
+ const selected = !explicit && preferred !== undefined
1046
+ ? { ok: true as const, spec: preferred }
1047
+ : selectModel(settings, flags, (spec) => opts.credentialOf(spec).ok, opts.catalog);
1048
+ let spec: ModelSpec;
1049
+ if (selected.ok) spec = selected.spec;
1050
+ else {
1051
+ const missingAll = selected.reason.startsWith("no model has a credential");
1052
+ if (!missingAll || !interactive) {
1053
+ throw new Error(missingAll ? describeUnauthenticatedScript(opts.catalog) : selected.reason);
1054
+ }
1055
+ const fallback = selectModel(settings, flags, () => true, opts.catalog);
1056
+ if (!fallback.ok) throw new Error(fallback.reason);
1057
+ spec = fallback.spec;
1058
+ }
1059
+ const credential = opts.credentialOf(spec);
1060
+ if (!credential.ok && !interactive) {
1061
+ throw new Error(credential.reason.startsWith("no credential for ")
1062
+ ? missingCredentialMessage(spec, explicit || saved !== undefined, opts.catalog) : credential.reason);
1063
+ }
1064
+ const explicitEffort = asThinkingLevel(opts.args.thinking), requestedEffort = explicitEffort ?? parsedModel?.thinking;
1065
+ if (opts.args.thinking && (!explicitEffort || !supportedThinkingLevels(spec).includes(explicitEffort))) {
1066
+ throw new Error(`thinking ${opts.args.thinking} is not supported by ${modelKey(spec)}; supported: ${supportedThinkingLevels(spec).join(", ")}`);
1067
+ }
1068
+ const providerConfig: ProviderConfig = {
1069
+ spec, maxTokens: outputTokenBudget(spec),
1070
+ effort: clampThinkingLevel(spec, requestedEffort ?? asThinkingLevel(priorCursor?.thinking) ??
1071
+ settings.modelThinkingLevels?.[modelKey(spec)] ?? opts.fallback?.effort ?? asThinkingLevel(settings.defaultThinking) ??
1072
+ asThinkingLevel(settings.thinking) ?? DEFAULT_PROVIDER.effort),
1073
+ thinkingDisplay: opts.fallback?.thinkingDisplay ?? DEFAULT_PROVIDER.thinkingDisplay,
1074
+ };
1075
+ const restoring = saved ?? spec;
1076
+ const restoringCredential = saved === undefined ? credential : opts.credentialOf(restoring);
1077
+ const oauth = (model: ModelSpec, got: Resolved) => got.ok && storedIsOAuth(opts.auth, model.provider) && opts.args.apiKey.length === 0;
1078
+ const session = createSessionFor({
1079
+ ...opts,
1080
+ system: resources.system,
1081
+ extensions: resources.extensions,
1082
+ workspace: priorCursor?.workspace ?? opts.workspace,
1083
+ blobDir: priorCursor?.blob_dir ?? (priorCursor?.workspace && priorCursor.workspace !== opts.workspace
1084
+ ? statePaths(priorCursor.workspace, opts.args.stateDir).blobs : opts.blobDir),
1085
+ providerConfig: { ...providerConfig, spec: restoring, maxTokens: outputTokenBudget(restoring) },
1086
+ credential: restoringCredential,
1087
+ ...(oauth(restoring, restoringCredential) ? { oauth: true } : {}),
1088
+ });
1089
+ await session.start();
1090
+ if (resources.extensions.loaded.length || session.config.frozen.tools.some(tool =>
1091
+ typeof tool === "object" && tool !== null && "name" in tool && typeof tool.name === "string" && unknownToolNames([tool.name]).length)) {
1092
+ await session.reloadResources({ system: resources.system, tools: visibleSchemas(opts.args, resources.extensions) });
1093
+ }
1094
+ if (restoring.id !== spec.id || restoring.provider !== spec.provider) {
1095
+ await session.switchModel(createProvider(providerConfig, credential.ok ? credential.key : "", retryHandler(opts.io),
1096
+ { ...(oauth(spec, credential) ? { oauth: true } : {}), ...(opts.effects ? { effects: opts.effects } : {}) }));
1097
+ opts.io.say(opts.io.palette.yellow(` switched ${restoring.id} → ${spec.id} at a model checkpoint`));
1098
+ }
1099
+ resources.ui.settings = { ...resources.ui.settings, thinking: providerConfig.effort,
1100
+ ...(opts.args.modelsExplicit || opts.args.models ? { scopedModels: splitModelPatterns(opts.args.models) } : {}) };
1101
+ if (resources.ui.settings.scopedModels) for (const warning of scopedModels(resources.ui.settings.scopedModels, opts.catalog).diagnostics) opts.io.say(` ${warning}`);
1102
+ return { session, priorCursor, providerConfig, credential, workspace: session.config.tools.root,
1103
+ blobDir: session.config.blobDir ?? opts.blobDir, system: session.config.frozen.system, resources };
1104
+ }
1105
+
925
1106
  export async function startup(args: Args): Promise<App> {
1107
+ if (args.extensionArgv && (args.listModels || args.modelsCommand || args.configCommand)) rejectUnknownFlag("--" + Object.keys(args.extensionInputs ?? {})[0]);
926
1108
  const C = createPalette(colorEnabled());
927
- const printMode = isPrintMode(args);
1109
+ let printMode = isPrintMode(args);
928
1110
  const jsonMode = isJsonMode(args);
929
- if ((args.mode ?? "").length > 0 && args.mode !== "json") {
930
- console.error(`unknown --mode ${args.mode} (supported: json)`);
1111
+ if ((args.mode ?? "").length > 0 && args.mode !== "json" && args.mode !== "text") {
1112
+ console.error(`unknown --mode ${args.mode} (supported: text, json)`);
931
1113
  process.exit(1);
932
1114
  }
933
- const divert = printMode || jsonMode;
1115
+ let divert = printMode || jsonMode;
934
1116
  const io: AppIO = {
935
1117
  palette: C,
936
1118
  say: (line) => (divert ? console.error(line) : console.log(line)),
@@ -943,6 +1125,11 @@ export async function startup(args: Args): Promise<App> {
943
1125
  }
944
1126
  : {}),
945
1127
  };
1128
+ const refreshOutputMode = () => {
1129
+ printMode = isPrintMode(args); divert = printMode || jsonMode;
1130
+ if (printMode && !jsonMode) io.answer = text => { process.stdout.write(text.endsWith("\n") ? text : `${text}\n`); };
1131
+ else delete io.answer;
1132
+ };
946
1133
  const effects = processEffects();
947
1134
 
948
1135
  const authRead = await readJsonFile<AuthFile>(authPath());
@@ -955,12 +1142,13 @@ export async function startup(args: Args): Promise<App> {
955
1142
  io.say(C.dim(" fetches models.dev into ~/.xeno/models.json; startup never does this."));
956
1143
  process.exit(0);
957
1144
  }
1145
+ if (args.offline || process.env["XENO_OFFLINE"] === "1") throw new Error("--offline prevents catalog refresh");
958
1146
  const result = await runModelsRefresh({
959
1147
  fetch: globalThis.fetch,
960
- write: writeModelsFile,
1148
+ write: writeCatalogCache,
961
1149
  });
962
1150
  if (result.ok) {
963
- io.say(` added ${result.added}, changed ${result.changed} (${result.count} total) → ${modelsPath()}`);
1151
+ io.say(` added ${result.added}, changed ${result.changed} (${result.count} total) → ${catalogCachePath()}`);
964
1152
  process.exit(0);
965
1153
  } else {
966
1154
  io.say(C.yellow(` ${result.reason}`));
@@ -970,17 +1158,16 @@ export async function startup(args: Args): Promise<App> {
970
1158
  }
971
1159
 
972
1160
  // 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 });
1161
+ const loaded = loadCatalog(await readModelSources());
975
1162
  for (const warning of loaded.warnings) io.say(C.yellow(` ${warning}`));
976
1163
  const catalog = loaded.models;
977
1164
 
978
1165
  const credentialOf = (spec: ModelSpec) => resolveCredential(spec, auth, args.apiKey, effects);
979
- const hasCredential = (spec: ModelSpec) => credentialOf(spec).ok;
980
1166
 
981
1167
  if (args.listModels) {
982
1168
  io.say(C.bold("models"));
983
- for (const group of groupedModels(catalog)) {
1169
+ const query = args.modelSearch?.toLowerCase();
1170
+ 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
1171
  io.say(C.bold(group.provider));
985
1172
  for (const spec of group.models) {
986
1173
  const got = credentialOf(spec);
@@ -992,18 +1179,6 @@ export async function startup(args: Args): Promise<App> {
992
1179
  process.exit(0);
993
1180
  }
994
1181
 
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
1182
  /**
1008
1183
  * Resolving the workspace is the first thing that can fail, and it fails for
1009
1184
  * ordinary reasons -- a typo in `-w`, a directory that was moved. Reported as
@@ -1031,139 +1206,81 @@ export async function startup(args: Args): Promise<App> {
1031
1206
  }
1032
1207
  }
1033
1208
 
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
- }
1209
+ await prepareExtensionPackages(args,workspace);
1210
+ const trust = await projectTrust(workspace, args.approve);
1211
+ let loadProjectSettings = trust.trusted;
1212
+ if (args.configCommand) {
1213
+ if (!stdinIsTty() || !stdoutIsTty()) {
1214
+ const index = await discoverResources({ ...args, workspace, trusted: loadProjectSettings });
1215
+ for (const entry of index.entries) io.say(`${entry.enabled ? "[x]" : "[ ]"} ${entry.kind} ${entry.scope}${entry.package?" · "+entry.package.source:""} ${entry.path}`);
1216
+ } else await runResourceConfig({ ...args, workspace, trusted: loadProjectSettings });
1217
+ process.exit(0);
1059
1218
  }
1219
+ 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);
1220
+ if (!args.help && hasProjectResources && args.approve === undefined && !trust.saved && trust.policy === "ask" && isInteractiveSession(args)) loadProjectSettings = await pickProjectTrust(workspace);
1221
+ if (hasProjectResources && !loadProjectSettings) io.say(C.dim(" Project resources not loaded (project is not trusted). /trust changes resource trust; tool permissions are separate."));
1060
1222
 
1061
- const uiLoaded = await loadUiResources({
1223
+ // Resolve the resource owner before factories run. Parsing flags must not load a different cwd first.
1224
+ let resourceWorkspace = workspace;
1225
+ const prepareFlags = (args.extensionArgv && args.resume !== true) || args.help;
1226
+ if (prepareFlags) {
1227
+ const target = args.logDir || args.session || args.forkFrom || (typeof args.resume === "string" ? args.resume : undefined);
1228
+ let resourceLog: string | undefined;
1229
+ if (target) { const found = await findSessionByPrefix(workspace, target, sessionDirFor(args, workspace)); if (found.ok) resourceLog = found.session.path; }
1230
+ else if (args.sessionId) resourceLog = join(sessionsDir(workspace, sessionDirFor(args, workspace)), args.sessionId);
1231
+ else if (args.continueLatest) resourceLog = (await listSessions(workspace, sessionDirFor(args, workspace), Infinity)).sessions.find(session => session.ok)?.path;
1232
+ if (resourceLog) resourceWorkspace = (await readCursor(resourceLog))?.workspace ?? workspace;
1233
+ }
1234
+ const preparedResources = prepareFlags
1235
+ ? { workspace: resourceWorkspace, value: await loadSessionResources(args, resourceWorkspace, io, resourceWorkspace === workspace ? loadProjectSettings : undefined) } : undefined;
1236
+ if (args.help) { printCliHelp(preparedResources?.value.extensions.flags); await preparedResources?.value.extensions.dispose(); process.exit(0); }
1237
+ refreshOutputMode();
1238
+ const uiLoaded = preparedResources ? undefined : await loadUiResources({
1239
+ ...args,
1062
1240
  workspace,
1063
1241
  trusted: loadProjectSettings,
1064
1242
  ...(args.theme.length > 0 ? { theme: args.theme } : {}),
1065
1243
  });
1066
- for (const line of uiLoaded.diagnostics) io.say(C.yellow(` ${line}`));
1067
- const mergedSettings = uiLoaded.settings;
1068
- const ui = toUiState(uiLoaded, colorEnabled());
1244
+ for (const line of uiLoaded?.diagnostics ?? []) io.say(C.yellow(` ${line}`));
1245
+ const ui = preparedResources?.value.ui ?? toUiState(uiLoaded!, colorEnabled());
1246
+ const mergedSettings = ui.settings;
1069
1247
  const scopedFromFlag = splitModelPatterns(args.models);
1070
- if (scopedFromFlag.length > 0) {
1248
+ if (args.modelsExplicit || scopedFromFlag.length > 0) {
1071
1249
  ui.settings = { ...ui.settings, scopedModels: scopedFromFlag };
1072
1250
  }
1073
1251
 
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
1252
  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;
1253
+ const sessionDir = sessionDirFor(args, workspace);
1254
+ let sessionOpts: ResolveLogOpts = {
1255
+ ...(args.logDir ? { explicit: resolve(args.logDir) } : {}),
1256
+ fresh: args.fresh,
1257
+ ...(args.continueLatest === undefined ? {} : { continueLatest: args.continueLatest }),
1258
+ ...(args.noSession === undefined ? {} : { noSession: args.noSession }),
1259
+ ...(args.session === undefined ? {} : { session: args.session }),
1260
+ ...(args.sessionId === undefined ? {} : { sessionId: args.sessionId }),
1261
+ ...(args.resume === undefined ? {} : { resume: args.resume }),
1262
+ ...(args.forkFrom === undefined ? {} : { forkFrom: args.forkFrom }),
1263
+ ...(sessionDir === undefined ? {} : { sessionDir }),
1264
+ };
1129
1265
  let logDir: string;
1130
1266
  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);
1267
+ if (planSessionEntry(sessionOpts).kind === "select") {
1268
+ if (printMode || jsonMode || !stdinIsTty() || !stdoutIsTty()) {
1269
+ throw new Error("--resume needs an interactive terminal. Pass -r <id> or --session <id> instead");
1137
1270
  }
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;
1271
+ const picked = await pickStartupSession({ workspace, style: ui.style, say: io.say, keymap: ui.keymap,
1272
+ ...(sessionDir === undefined ? {} : { sessionDir }) });
1273
+ if (picked === null) { io.say(C.dim("No session selected")); process.exit(0); }
1274
+ sessionOpts = { explicit: picked };
1154
1275
  }
1276
+ const resolved = await resolveLogDir(workspace, sessionOpts);
1277
+ if (resolved.legacyNotice !== null && !args.logDir) io.say(C.yellow(` ${resolved.legacyNotice}`));
1278
+ logDir = resolved.logDir;
1155
1279
  } catch (err) {
1156
1280
  const detail = err instanceof Error ? err.message : String(err);
1157
1281
  io.err(C.red(`\n ${detail}\n`));
1158
1282
  process.exit(1);
1159
1283
  }
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
1284
  // Snapshots are on by default here. They are optional in the loop because a
1168
1285
  // read-only branch has nothing to snapshot, but a session a person drives is
1169
1286
  // exactly the one that will want to go back, and the content has to have been
@@ -1172,64 +1289,27 @@ export async function startup(args: Args): Promise<App> {
1172
1289
  await mkdir(logDir, { recursive: true });
1173
1290
  await mkdir(blobDir, { recursive: true });
1174
1291
 
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);
1292
+ // Resource ownership follows the resolved session workspace. openSessionFor
1293
+ // loads and freezes it once; explicit reloads checkpoint a changed prefix (D6).
1190
1294
  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
1295
 
1215
1296
  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
-
1297
+ let opened: Awaited<ReturnType<typeof openSessionFor>>;
1225
1298
  try {
1226
- await session.start();
1299
+ opened = await openSessionFor({
1300
+ args, workspace, logDir, blobDir, io, askSlot, catalog, auth, credentialOf, effects,
1301
+ settings: mergedSettings, modelFlags: { model: args.model, provider: args.provider }, projectTrusted: loadProjectSettings,
1302
+ ...(preparedResources ? { preparedResources } : {}),
1303
+ interactive: isInteractiveSession(args),
1304
+ });
1227
1305
  } catch (err) {
1228
1306
  const detail = err instanceof Error ? err.message : String(err);
1229
1307
  io.err(C.red(`\n cannot start a session in ${workspace}`));
1230
1308
  io.err(C.dim(` ${detail}\n`));
1231
1309
  process.exit(1);
1232
1310
  }
1311
+ const { session, priorCursor, providerConfig, credential } = opened;
1312
+ refreshOutputMode();
1233
1313
 
1234
1314
  const sessionName = args.sessionName;
1235
1315
  if (sessionName !== undefined && sessionName.length > 0) {
@@ -1239,17 +1319,19 @@ export async function startup(args: Args): Promise<App> {
1239
1319
  }
1240
1320
  }
1241
1321
 
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()],
1322
+ if (opened.resources.ui.settings.quietStartup !== true || args.verbose) printBanner(args, io, {
1323
+ workspace: opened.workspace, logDir, providerConfig, credential, loadedContext: opened.resources.loadedContext, session, priorCursor, catalog,
1324
+ keymap: opened.resources.ui.keymap,
1325
+ skills: [...opened.resources.slash.skills.keys()],
1326
+ prompts: [...opened.resources.slash.prompts.keys()],
1247
1327
  tools: visible.map((t) => t.name),
1248
1328
  });
1249
1329
 
1250
1330
  return {
1251
- args, session, workspace, logDir, blobDir, system, providerConfig, credential,
1252
- auth, effects, loadedContext, credentialOf, catalog, io, askSlot, ui, slash,
1331
+ args, session, workspace: opened.workspace, logDir, blobDir: opened.blobDir, system: opened.system, providerConfig, credential,
1332
+ auth, effects, loadedContext: opened.resources.loadedContext, credentialOf, catalog, io, askSlot,
1333
+ ui: opened.resources.ui, slash: opened.resources.slash, resources: opened.resources.index, projectTrusted: opened.resources.trusted,
1334
+ extensions: opened.resources.extensions,
1253
1335
  };
1254
1336
  }
1255
1337
 
@@ -1321,3 +1403,53 @@ function printBanner(
1321
1403
  }
1322
1404
  io.say("");
1323
1405
  }
1406
+
1407
+ export function printCliHelp(flags?: ReadonlyMap<string, ExtensionFlag>): void {
1408
+ console.log(
1409
+ "usage: xeno [-m <model>] [--provider <name>] [--api-key <key>]\n" +
1410
+ " [-w <workspace>] [--log <dir>] [--max-turns N]\n" +
1411
+ " [--context-policy windowed|segmented|continuous|adaptive]\n" +
1412
+ " [--trigger N --hard N] [--list-models] [--fresh]\n" +
1413
+ " [-c|--continue] [-r|--resume [id]] [--session path|id]\n" +
1414
+ " [--fork path|id] [--no-session] [--session-dir dir]\n" +
1415
+ " [-n|--name name] [--session-id id] [--version|-v]\n" +
1416
+ " [models --refresh]\n" +
1417
+ " [install|remove <source>... [--local]] [update [source] [--extensions]] [list]\n" +
1418
+ " [config [--local]] [--export session [output.html]] [--offline]\n" +
1419
+ " [--system-prompt <text-or-file>] [--append-system-prompt <text>]\n" +
1420
+ " [--no-context-files|-nc]\n" +
1421
+ " [--tools names] [--exclude-tools names] [--no-tools]\n" +
1422
+ " [--no-builtin-tools]\n" +
1423
+ " [--allow-tools names] [--approve|--no-approve]\n" +
1424
+ " [--theme <name|path>] [--use-theme name] [--no-themes] [--thinking level] [--models patterns]\n" +
1425
+ " [--skill <path>] [--no-skills]\n" +
1426
+ " [--prompt-template <path>] [--no-prompt-templates]\n" +
1427
+ " [--extension <path>] [--no-extensions] [--extension-flag name=value]\n" +
1428
+ " [--mode text|json] [--tui-mode regular|fullscreen] [--verbose]\n" +
1429
+ " [-p <prompt>] [prompt...]\n\n" +
1430
+ " Starts a new session by default; --fresh is an explicit alias. Nothing is deleted.\n" +
1431
+ " -c continues the newest (or starts one); -r opens the session picker.\n" +
1432
+ " --session / -r <id> pick one (unique prefix only). Resume restores its model;\n" +
1433
+ " an explicit --model switches at a checkpoint before the next turn.\n" +
1434
+ " --no-session runs without writing .xeno/sessions/. --fork copies into a new one.\n" +
1435
+ " A leftover .xeno/log is not moved; continue it with --log .xeno/log.\n\n" +
1436
+ " With a prompt, runs it and exits. Without one, reads prompts from stdin.\n" +
1437
+ " Commands: " + COMMANDS.map(command => "/" + command.name).join(" ") + "\n\n" +
1438
+ " Credentials resolve in this order: --api-key, then ~/.xeno/auth.json,\n" +
1439
+ " then the provider's environment variable.\n" +
1440
+ " --system-prompt replaces the built-in instructions (text, or a file that exists).\n" +
1441
+ " --append-system-prompt appends; repeatable. -nc skips AGENTS.md discovery.\n" +
1442
+ " --tools is a strict allowlist; excluded tools leave the schema.\n" +
1443
+ " --no-builtin-tools drops built-ins and keeps any other source; --tools still wins.\n" +
1444
+ " bash asks before it runs. --allow-tools bash skips the ask for this process.\n" +
1445
+ " --time-budget <seconds> tells the model, once, when 75% of the run's wall-clock budget is used.\n" +
1446
+ " --state-dir <dir> keeps snapshots, observations and sessions out of the workspace.\n" +
1447
+ " --path-root <dir> lets file tools reach absolute paths under <dir> (e.g. / in a container);\n" +
1448
+ " relative paths, fingerprints and /back stay in the workspace, so edits outside it are not rewound.\n" +
1449
+ " --approve loads .xeno/settings.json this run; --no-approve skips it.\n" +
1450
+ " Project trust does not affect tool execution.\n" +
1451
+ " --mode json writes one JSON object per event to stdout; diagnostics stay on stderr.\n" +
1452
+ " --verbose expands the startup header (AGENTS.md, skills, prompt templates).",
1453
+ );
1454
+ if (flags?.size) console.log("\nExtension CLI flags:\n" + [...flags].map(([name, flag]) => ` --${name}${flag.type === "string" ? " <value>" : ""} ${flag.description ?? ""}`).join("\n"));
1455
+ }