@visual-z/xeno 0.8.4 → 0.9.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 (350) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +31 -746
  3. package/bin/xeno.exe +26 -0
  4. package/install.cjs +88 -0
  5. package/package.json +18 -48
  6. package/docs/app-server.md +0 -115
  7. package/docs/foundations.md +0 -88
  8. package/docs/install.md +0 -244
  9. package/docs/providers.md +0 -172
  10. package/docs/research/foundation-clients-2026-10-09.md +0 -27
  11. package/docs/research/foundation-editing-2026-10-09.md +0 -56
  12. package/docs/research/foundation-network-2026-10-09.md +0 -37
  13. package/docs/research/foundation-workflow-2026-10-09.md +0 -37
  14. package/src/apps/acp-server.ts +0 -41
  15. package/src/apps/agent-profiles.ts +0 -19
  16. package/src/apps/app-server.ts +0 -30
  17. package/src/apps/approve.ts +0 -49
  18. package/src/apps/cli.ts +0 -99
  19. package/src/apps/commands.ts +0 -319
  20. package/src/apps/completions.ts +0 -87
  21. package/src/apps/control-transport.ts +0 -90
  22. package/src/apps/export-cli.ts +0 -24
  23. package/src/apps/goal-flow.ts +0 -70
  24. package/src/apps/image-draft.ts +0 -46
  25. package/src/apps/input.ts +0 -312
  26. package/src/apps/model-catalog.ts +0 -69
  27. package/src/apps/preferences.ts +0 -20
  28. package/src/apps/project-trust.ts +0 -9
  29. package/src/apps/provider-connect.ts +0 -346
  30. package/src/apps/question-ui.ts +0 -27
  31. package/src/apps/render.ts +0 -181
  32. package/src/apps/resources.ts +0 -66
  33. package/src/apps/review.ts +0 -109
  34. package/src/apps/session-hub.ts +0 -96
  35. package/src/apps/session-lifecycle.ts +0 -64
  36. package/src/apps/shell.ts +0 -2071
  37. package/src/apps/startup-input.ts +0 -135
  38. package/src/apps/startup.ts +0 -1677
  39. package/src/apps/ui/app.ts +0 -1818
  40. package/src/apps/ui/control-model.ts +0 -43
  41. package/src/apps/ui/extension-host.ts +0 -194
  42. package/src/apps/ui/image-cache.ts +0 -121
  43. package/src/apps/ui/palette.ts +0 -88
  44. package/src/apps/ui/project.ts +0 -127
  45. package/src/apps/ui/standalone.ts +0 -148
  46. package/src/apps/ui/status-model.ts +0 -68
  47. package/src/apps/ui/surface.ts +0 -91
  48. package/src/apps/ui/theme.ts +0 -86
  49. package/src/apps/update-check.ts +0 -112
  50. package/src/apps/workflow.ts +0 -58
  51. package/src/core/acceptance.ts +0 -139
  52. package/src/core/action-defaults.ts +0 -58
  53. package/src/core/activity-status.ts +0 -52
  54. package/src/core/agent-profile.ts +0 -130
  55. package/src/core/attachment-input.ts +0 -17
  56. package/src/core/blind-checks.ts +0 -72
  57. package/src/core/boundary.ts +0 -174
  58. package/src/core/branch-receipt.ts +0 -26
  59. package/src/core/catalog-merge.ts +0 -19
  60. package/src/core/checkpoints.ts +0 -85
  61. package/src/core/churn.ts +0 -61
  62. package/src/core/command-artifacts.ts +0 -94
  63. package/src/core/command-contract.ts +0 -151
  64. package/src/core/completion-action.ts +0 -20
  65. package/src/core/completion-review.ts +0 -98
  66. package/src/core/context-detail.ts +0 -37
  67. package/src/core/context-policy.ts +0 -133
  68. package/src/core/context-window.ts +0 -54
  69. package/src/core/continuation.ts +0 -130
  70. package/src/core/control-protocol.ts +0 -69
  71. package/src/core/control-view.ts +0 -39
  72. package/src/core/cycle.ts +0 -17
  73. package/src/core/data-query.ts +0 -201
  74. package/src/core/deadline.ts +0 -40
  75. package/src/core/derive.ts +0 -516
  76. package/src/core/draft-images.ts +0 -56
  77. package/src/core/edit-plan.ts +0 -200
  78. package/src/core/environment.ts +0 -181
  79. package/src/core/event-validation.ts +0 -220
  80. package/src/core/events.ts +0 -670
  81. package/src/core/evidence-ladder.ts +0 -62
  82. package/src/core/evidence-links.ts +0 -68
  83. package/src/core/evidence.ts +0 -240
  84. package/src/core/execution.ts +0 -18
  85. package/src/core/exhaustive.ts +0 -18
  86. package/src/core/export.ts +0 -179
  87. package/src/core/extension-flags.ts +0 -14
  88. package/src/core/external-effects.ts +0 -488
  89. package/src/core/failure-recovery.ts +0 -173
  90. package/src/core/file-precondition.ts +0 -78
  91. package/src/core/file-references.ts +0 -35
  92. package/src/core/frontmatter.ts +0 -213
  93. package/src/core/git-status.ts +0 -29
  94. package/src/core/goal-view.ts +0 -64
  95. package/src/core/goal.ts +0 -200
  96. package/src/core/handoff.ts +0 -479
  97. package/src/core/history-query.ts +0 -139
  98. package/src/core/history.ts +0 -237
  99. package/src/core/image-limits.ts +0 -72
  100. package/src/core/images.ts +0 -46
  101. package/src/core/integration-health.ts +0 -34
  102. package/src/core/json-event.ts +0 -131
  103. package/src/core/json-pointer.ts +0 -46
  104. package/src/core/keymap.ts +0 -153
  105. package/src/core/lsp-framing.ts +0 -34
  106. package/src/core/mcp-config.ts +0 -68
  107. package/src/core/model-picker.ts +0 -64
  108. package/src/core/model-preferences.ts +0 -28
  109. package/src/core/model-replay.ts +0 -22
  110. package/src/core/model-scope.ts +0 -53
  111. package/src/core/note-guidance.ts +0 -34
  112. package/src/core/oauth-fields.ts +0 -44
  113. package/src/core/observation-view.ts +0 -152
  114. package/src/core/outcome.ts +0 -44
  115. package/src/core/overflow.ts +0 -59
  116. package/src/core/packages.ts +0 -47
  117. package/src/core/patch.ts +0 -130
  118. package/src/core/path-suggestions.ts +0 -59
  119. package/src/core/permissions.ts +0 -410
  120. package/src/core/process-receipt.ts +0 -105
  121. package/src/core/process-state.ts +0 -37
  122. package/src/core/program-status.ts +0 -40
  123. package/src/core/progress.ts +0 -150
  124. package/src/core/project.ts +0 -431
  125. package/src/core/prompt-policy.ts +0 -46
  126. package/src/core/prompts.ts +0 -111
  127. package/src/core/question.ts +0 -94
  128. package/src/core/queue.ts +0 -78
  129. package/src/core/read-bounds.ts +0 -24
  130. package/src/core/read-observation.ts +0 -106
  131. package/src/core/recovery-sources.ts +0 -74
  132. package/src/core/regex-dialect.ts +0 -25
  133. package/src/core/request-budget.ts +0 -23
  134. package/src/core/request-progress.ts +0 -87
  135. package/src/core/request-state.ts +0 -273
  136. package/src/core/resource-selection.ts +0 -36
  137. package/src/core/response-recovery.ts +0 -59
  138. package/src/core/resume.ts +0 -59
  139. package/src/core/review.ts +0 -68
  140. package/src/core/ripgrep-release.ts +0 -64
  141. package/src/core/runtime-target.ts +0 -94
  142. package/src/core/scheduling.ts +0 -8
  143. package/src/core/search-observation.ts +0 -97
  144. package/src/core/secrets.ts +0 -188
  145. package/src/core/session-archive.ts +0 -4
  146. package/src/core/session-browser.ts +0 -82
  147. package/src/core/session-entry.ts +0 -61
  148. package/src/core/session-exit.ts +0 -42
  149. package/src/core/session-format.ts +0 -169
  150. package/src/core/session-position.ts +0 -26
  151. package/src/core/session-tree.ts +0 -147
  152. package/src/core/settings.ts +0 -192
  153. package/src/core/skills.ts +0 -165
  154. package/src/core/sql-query.ts +0 -211
  155. package/src/core/stall.ts +0 -128
  156. package/src/core/stop-report.ts +0 -68
  157. package/src/core/text-normalize.ts +0 -81
  158. package/src/core/theme.ts +0 -166
  159. package/src/core/thinking.ts +0 -26
  160. package/src/core/tokens.ts +0 -151
  161. package/src/core/tool-contract.ts +0 -153
  162. package/src/core/tool-discovery.ts +0 -87
  163. package/src/core/tool-failure.ts +0 -81
  164. package/src/core/tool-input.ts +0 -44
  165. package/src/core/transcript.ts +0 -108
  166. package/src/core/tui-mode.ts +0 -29
  167. package/src/core/update-check.ts +0 -157
  168. package/src/core/version.ts +0 -55
  169. package/src/core/web-content.ts +0 -80
  170. package/src/core/work-state.ts +0 -154
  171. package/src/core/workflow-mode.ts +0 -34
  172. package/src/core/working-set.ts +0 -119
  173. package/src/core/yaml.ts +0 -282
  174. package/src/runtime/acp-agent.ts +0 -289
  175. package/src/runtime/agent-profiles.ts +0 -21
  176. package/src/runtime/auth.ts +0 -367
  177. package/src/runtime/blind-check.ts +0 -36
  178. package/src/runtime/branch-jobs.ts +0 -36
  179. package/src/runtime/catalog.ts +0 -285
  180. package/src/runtime/clipboard.ts +0 -125
  181. package/src/runtime/command-artifacts.ts +0 -64
  182. package/src/runtime/connection-reuse.ts +0 -40
  183. package/src/runtime/control-client.ts +0 -149
  184. package/src/runtime/control-connection.ts +0 -139
  185. package/src/runtime/counter-check.ts +0 -74
  186. package/src/runtime/environment-tools.ts +0 -60
  187. package/src/runtime/evidence-notes.ts +0 -35
  188. package/src/runtime/exec-sessions.ts +0 -456
  189. package/src/runtime/execution-control.ts +0 -40
  190. package/src/runtime/extension-loader.ts +0 -37
  191. package/src/runtime/extension-ui.ts +0 -47
  192. package/src/runtime/extensions.ts +0 -370
  193. package/src/runtime/formatter-tool.ts +0 -38
  194. package/src/runtime/formatter.ts +0 -50
  195. package/src/runtime/foundation.ts +0 -116
  196. package/src/runtime/history-cache.ts +0 -57
  197. package/src/runtime/history-tools.ts +0 -168
  198. package/src/runtime/images.ts +0 -80
  199. package/src/runtime/integrations.ts +0 -216
  200. package/src/runtime/loop.ts +0 -3108
  201. package/src/runtime/lsp-client.ts +0 -201
  202. package/src/runtime/mcp-client.ts +0 -264
  203. package/src/runtime/mcp-oauth.ts +0 -168
  204. package/src/runtime/mcp-schema.ts +0 -51
  205. package/src/runtime/model-config.ts +0 -232
  206. package/src/runtime/model-data.LICENSE +0 -27
  207. package/src/runtime/model-data.ts +0 -21
  208. package/src/runtime/model-discovery.ts +0 -208
  209. package/src/runtime/model-replay.ts +0 -10
  210. package/src/runtime/model-scope.ts +0 -20
  211. package/src/runtime/model-sources.ts +0 -151
  212. package/src/runtime/model.ts +0 -686
  213. package/src/runtime/models-fetch.ts +0 -57
  214. package/src/runtime/oauth-device.ts +0 -86
  215. package/src/runtime/oauth.ts +0 -853
  216. package/src/runtime/open-image.ts +0 -42
  217. package/src/runtime/openai-responses.ts +0 -304
  218. package/src/runtime/openai.ts +0 -697
  219. package/src/runtime/output-schema.ts +0 -26
  220. package/src/runtime/package-manager.ts +0 -125
  221. package/src/runtime/patch-tool.ts +0 -38
  222. package/src/runtime/permission-resources.ts +0 -18
  223. package/src/runtime/pi-provider.ts +0 -423
  224. package/src/runtime/program-status.ts +0 -38
  225. package/src/runtime/progress.ts +0 -26
  226. package/src/runtime/provider-access.ts +0 -21
  227. package/src/runtime/provider-connect.ts +0 -109
  228. package/src/runtime/provider-presets.ts +0 -98
  229. package/src/runtime/provider-protocols.ts +0 -29
  230. package/src/runtime/provider-request.ts +0 -87
  231. package/src/runtime/provider-tool-policy.ts +0 -33
  232. package/src/runtime/provider.ts +0 -580
  233. package/src/runtime/question-tool.ts +0 -62
  234. package/src/runtime/retry.ts +0 -447
  235. package/src/runtime/review-tool.ts +0 -48
  236. package/src/runtime/rich-text.ts +0 -36
  237. package/src/runtime/runtime-target.ts +0 -149
  238. package/src/runtime/secrets.ts +0 -79
  239. package/src/runtime/self-update.ts +0 -74
  240. package/src/runtime/session-control.ts +0 -265
  241. package/src/runtime/session-hub.ts +0 -85
  242. package/src/runtime/stream-idle.ts +0 -80
  243. package/src/runtime/tool-broker.ts +0 -137
  244. package/src/runtime/tool-discovery.ts +0 -58
  245. package/src/runtime/tool-queue.ts +0 -77
  246. package/src/runtime/tools.ts +0 -1820
  247. package/src/runtime/turn-effects.ts +0 -31
  248. package/src/runtime/update-check.ts +0 -48
  249. package/src/runtime/usage.ts +0 -253
  250. package/src/runtime/version.ts +0 -12
  251. package/src/runtime/web-http.ts +0 -56
  252. package/src/runtime/web-tools.ts +0 -88
  253. package/src/runtime/working-notes.ts +0 -60
  254. package/src/runtime/workspace-lifecycle.ts +0 -141
  255. package/src/store/acceptance.ts +0 -15
  256. package/src/store/agent-profile-files.ts +0 -13
  257. package/src/store/atomic.ts +0 -31
  258. package/src/store/auth-store.ts +0 -50
  259. package/src/store/blind-checks.ts +0 -32
  260. package/src/store/blobs.ts +0 -143
  261. package/src/store/checkpoint-history.ts +0 -32
  262. package/src/store/command-artifacts.ts +0 -96
  263. package/src/store/command.ts +0 -111
  264. package/src/store/config.ts +0 -69
  265. package/src/store/context-files.ts +0 -436
  266. package/src/store/file-checkpoints.ts +0 -108
  267. package/src/store/file-index.ts +0 -174
  268. package/src/store/foundation-config.ts +0 -24
  269. package/src/store/git-checkpoints.ts +0 -67
  270. package/src/store/git-status.ts +0 -15
  271. package/src/store/history-search.ts +0 -19
  272. package/src/store/log.ts +0 -725
  273. package/src/store/lsp-document.ts +0 -30
  274. package/src/store/mcp-credentials.ts +0 -85
  275. package/src/store/models.ts +0 -60
  276. package/src/store/observations.ts +0 -224
  277. package/src/store/packages.ts +0 -98
  278. package/src/store/patch.ts +0 -92
  279. package/src/store/permission-paths.ts +0 -34
  280. package/src/store/process-receipts.ts +0 -34
  281. package/src/store/query-source.ts +0 -49
  282. package/src/store/recovery.ts +0 -44
  283. package/src/store/replay.ts +0 -76
  284. package/src/store/resource-index.ts +0 -170
  285. package/src/store/resources.ts +0 -285
  286. package/src/store/restore.ts +0 -110
  287. package/src/store/review.ts +0 -109
  288. package/src/store/runtime-target.ts +0 -64
  289. package/src/store/search.ts +0 -728
  290. package/src/store/secrets.ts +0 -44
  291. package/src/store/serial.ts +0 -13
  292. package/src/store/session-archive.ts +0 -165
  293. package/src/store/session-browser.ts +0 -68
  294. package/src/store/session-position.ts +0 -27
  295. package/src/store/sessions.ts +0 -525
  296. package/src/store/source-cancellation.ts +0 -6
  297. package/src/store/tool-binaries.ts +0 -90
  298. package/src/store/trust.ts +0 -82
  299. package/src/store/update-cache.ts +0 -36
  300. package/src/store/user-settings.ts +0 -30
  301. package/src/store/workspace-clone.ts +0 -42
  302. package/src/store/workspace-observation.ts +0 -113
  303. package/src/store/workspace.ts +0 -115
  304. package/src/store/worktree.ts +0 -278
  305. package/src/tui/ansi-spans.ts +0 -126
  306. package/src/tui/diff.ts +0 -160
  307. package/src/tui/exhaustive.ts +0 -14
  308. package/src/tui/fuzzy.ts +0 -75
  309. package/src/tui/style.ts +0 -208
  310. package/src/tui/ui/atomic-draft.ts +0 -121
  311. package/src/tui/ui/branch-panel.ts +0 -47
  312. package/src/tui/ui/canvas.ts +0 -96
  313. package/src/tui/ui/colors.ts +0 -70
  314. package/src/tui/ui/composer.ts +0 -199
  315. package/src/tui/ui/control-panel.ts +0 -25
  316. package/src/tui/ui/control-sidebar.ts +0 -49
  317. package/src/tui/ui/dialog.ts +0 -398
  318. package/src/tui/ui/draft.ts +0 -23
  319. package/src/tui/ui/duration.ts +0 -16
  320. package/src/tui/ui/edge.ts +0 -186
  321. package/src/tui/ui/image-size.ts +0 -44
  322. package/src/tui/ui/ink.ts +0 -210
  323. package/src/tui/ui/items.ts +0 -42
  324. package/src/tui/ui/keys.ts +0 -40
  325. package/src/tui/ui/layout.ts +0 -74
  326. package/src/tui/ui/list-model.ts +0 -86
  327. package/src/tui/ui/list-view.ts +0 -172
  328. package/src/tui/ui/mascot.ts +0 -67
  329. package/src/tui/ui/messages.ts +0 -518
  330. package/src/tui/ui/motion.ts +0 -28
  331. package/src/tui/ui/paths.ts +0 -10
  332. package/src/tui/ui/pictures.ts +0 -108
  333. package/src/tui/ui/prompt-history.ts +0 -60
  334. package/src/tui/ui/screen.ts +0 -579
  335. package/src/tui/ui/status.ts +0 -403
  336. package/src/tui/ui/styled.ts +0 -85
  337. package/src/tui/ui/tool-card.ts +0 -150
  338. package/src/tui/width.ts +0 -567
  339. package/src/vendor/pi-rich/LICENSE.upstream +0 -21
  340. package/src/vendor/pi-rich/UPSTREAM.json +0 -17
  341. package/src/vendor/pi-rich/clipboard-command.ts +0 -44
  342. package/src/vendor/pi-rich/exif-orientation.ts +0 -182
  343. package/src/vendor/pi-rich/html.ts +0 -51
  344. package/src/vendor/pi-rich/image-convert.ts +0 -49
  345. package/src/vendor/pi-rich/image-resize-core.ts +0 -164
  346. package/src/vendor/pi-rich/image-resize-worker.ts +0 -42
  347. package/src/vendor/pi-rich/image-resize.ts +0 -123
  348. package/src/vendor/pi-rich/mime.ts +0 -116
  349. package/src/vendor/pi-rich/photon.ts +0 -139
  350. package/src/vendor/pi-rich/syntax-highlight.ts +0 -214
package/src/apps/shell.ts DELETED
@@ -1,2071 +0,0 @@
1
- /**
2
- * Interactive loop and one-shot runner.
3
- *
4
- * The live UI is OpenTUI (D20), driven through the same business hooks as
5
- * line mode. A pipe or CI run never constructs it: `-p` output is parsed
6
- * by scripts, while the interactive app projects the durable transcript.
7
- */
8
-
9
- import { criterionSummary, draftGoal, pickCriteria, type PendingGoal } from "./goal-flow.ts";
10
- import { openImageFile } from "../runtime/open-image.ts";
11
- import { closeAppSession, switchAppSession } from "./session-lifecycle.ts";
12
- import { basename, dirname, join, resolve } from "node:path";
13
- import { tmpdir } from "node:os";
14
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
15
- import { assertNever } from "../core/exhaustive.ts";
16
- import type { CounterVerdict } from "../core/acceptance.ts";
17
- import type { LogEvent } from "../core/events.ts";
18
- import type { Skill } from "../core/skills.ts";
19
- import { workReadiness } from "../core/work-state.ts";
20
- import { userInputData, type ImageRef, type UserInput } from "../core/images.ts";
21
- import { attachImageFile } from "../runtime/images.ts";
22
- import { writeClipboard } from "../runtime/clipboard.ts";
23
- import type { ExtensionContext, ExtensionRegistry } from "../runtime/extensions.ts";
24
- import { headlessUI } from "../runtime/extension-ui.ts";
25
- import { fileReferences } from "../core/file-references.ts";
26
- import { readNodeHistory } from "../store/restore.ts";
27
- import type { Handoff } from "../core/handoff.ts";
28
- import { projectContext, type FrozenPrefix } from "../core/project.ts";
29
- import { contextDetail } from "../core/context-detail.ts";
30
- import type { BoundaryConfig } from "../core/boundary.ts";
31
- import {
32
- contextLimits, decideContext, DEFAULT_CONTEXT_POLICY, gaugeLevel, nextBoundary,
33
- type ContextDecision, type ContextLimits, type ContextPolicy, type GaugeLevel,
34
- } from "../core/context-policy.ts";
35
- import { anchoredCount, estimateTokens, type Anchor, type TokenCounter } from "../core/tokens.ts";
36
- import type { RestorePoint, RewindMode, RequestOptions } from "../runtime/loop.ts";
37
- import { createProvider, providerSessionId, findModelByPattern, groupedModels, loadCatalog } from "../runtime/catalog.ts";
38
- import { outputTokenBudget, type Effort, type ModelSpec } from "../runtime/model.ts";
39
- import { scopedModels } from "../runtime/model-scope.ts";
40
- import { equivalentCost, reportedCost } from "../runtime/usage.ts";
41
- import type { ProviderConfig } from "../runtime/provider.ts";
42
- import { describeMissingCredential, envLabel, ensureFreshCredential, storedIsOAuth } from "../runtime/auth.ts";
43
- import {
44
- SUBSCRIPTION_NOTICE, SUBSCRIPTION_OPTIONS, subscriptionById, loginGrok,
45
- loginGitHubCopilot, generatePKCE, generateState, anthropicAuthorizeUrl, openaiCodexAuthorizeUrl,
46
- startCallbackServer, exchangeAnthropicCode, exchangeOpenAICode, parseAuthorizationInput,
47
- ANTHROPIC_CALLBACK_PORT, ANTHROPIC_CALLBACK_PATH, ANTHROPIC_REDIRECT_URI,
48
- OPENAI_CALLBACK_PORT, OPENAI_CALLBACK_PATH, OPENAI_REDIRECT_URI,
49
- type CallbackServer, type OAuthCredential, type SubscriptionId,
50
- } from "../runtime/oauth.ts";
51
- import { writeAuthFile } from "../store/auth-store.ts";
52
- import { authPath, configDir } from "../store/config.ts";
53
- import { catalogCachePath, readModelSources, writeCatalogCache } from "../store/models.ts";
54
- import { buildFileIndex, indexTruncationNote, type FileIndex } from "../store/file-index.ts";
55
- import { projectTrust, trustPath } from "../store/trust.ts";
56
- import { discoverResources, themeResourcePath } from "../store/resource-index.ts";
57
- import { applyTrustChoice, type TrustChoice } from "./project-trust.ts";
58
- import { reloadAppResources } from "./resources.ts";
59
- import { publishModelCatalog } from "./model-catalog.ts";
60
- import { readCursor, writeCursor, listNodes, readNode, readManifest, setSessionLabel } from "../store/log.ts";
61
- import { registerSessionRoot } from "../store/session-browser.ts";
62
- import { createSession, findSessionByPrefix, hasConversation, listSessions, truncationNote, sessionsDir } from "../store/sessions.ts";
63
- import { exportSessionArchive, importSessionArchive } from "../store/session-archive.ts";
64
- import { resolveTuiMode } from "../core/tui-mode.ts";
65
- import { modelPreferencePatch, thinkingPreferencePatch } from "../core/model-preferences.ts";
66
- import { flattenTree, treeFromPoints, LEFT_BRANCH_MARK, EMPTY_TREE_MESSAGE, type TreeNode, type NodeParent, type TreePoint } from "../core/session-tree.ts";
67
- import { cycle } from "../core/cycle.ts";
68
- import { THINKING_LEVELS, asThinkingLevel as asEffort, supportedThinkingLevels, clampThinkingLevel } from "../core/thinking.ts";
69
- import { modelKey } from "../core/session-position.ts";
70
- import { defaultKeymap, listBindings } from "../core/keymap.ts";
71
- import { settingPatch } from "../core/settings.ts";
72
- import { resolveTheme } from "../store/resources.ts";
73
- import { commitUserSettings, type PreferenceOrigin } from "./preferences.ts";
74
- import { colorEnabled } from "./render.ts";
75
- import { createStyle, IDENTITY } from "../tui/style.ts";
76
- import { lastAssistantText, toHtml } from "../core/export.ts";
77
- import { toJsonEvent } from "../core/json-event.ts";
78
- import { sessionControl } from "../runtime/session-control.ts";
79
- import { hostExternalOptions, requestExternalEffects } from "../runtime/turn-effects.ts";
80
- import { formatExternalEffects } from "../core/external-effects.ts";
81
- import { COMMANDS, parseCommand, type Command } from "./commands.ts";
82
- import { expandInput } from "./input.ts";
83
- import {
84
- describeError, firstLine, formatEvent, indent, stdinIsTty, stdoutIsTty, wrap,
85
- } from "./render.ts";
86
- import {
87
- openSessionFor, sessionDirFor, retryHandler, whyNot, splitModelPatterns, runModelsRefresh, sessionToolCatalog, type App,
88
- } from "./startup.ts";
89
- import type { TurnSurface, Ui } from "./ui/surface.ts";
90
- import { runManualUpdate } from "./update-check.ts";
91
- import { presentQuestions } from "./question-ui.ts";
92
- import { bindAppWorkflow, setAppWorkflow } from "./workflow.ts";
93
- import { appReviewSnapshot, startReview, reviewScopeFromText } from "./review.ts";
94
- import { selectAppAgent, listAppAgents } from "./agent-profiles.ts";
95
-
96
- /**
97
- * One line. Listing all ~40 commands here wrapped into a two-line wall that
98
- * nobody reads; typing `/` already opens the searchable command menu.
99
- */
100
- const HINT = `type a prompt · / for ${COMMANDS.length} commands · /provider connects a provider · /model switches · /help`;
101
- const OAUTH_PASTE = "\0oauth";
102
-
103
- const initializedExtensions = new WeakSet<ExtensionRegistry>();
104
- const extensionHistories = new WeakMap<App, LogEvent[]>();
105
- function extensionContext(app: App, tui?: Ui): ExtensionContext {
106
- const entries = () => structuredClone([...(extensionHistories.get(app) ?? []).filter(event => !app.session.state.events.some(current => current.id === event.id)), ...app.session.state.events]);
107
- return {
108
- ui: tui?.extensionUi ?? headlessUI((text, type) => app.io.say(type === "error" ? app.io.palette.red(text) : text)),
109
- cwd: app.workspace, mode: tui ? "tui" : app.args.mode === "json" ? "json" : "print", hasUI: !!tui,
110
- model: app.providerConfig.spec, thinkingLevel: app.providerConfig.effort, signal: tui?.signal,
111
- isIdle: () => !app.session.running, isProjectTrusted: () => app.projectTrusted === true,
112
- hasPendingMessages: () => (tui?.queueLength ?? 0) > 0,
113
- abort: () => { tui?.interrupt(); app.session.cancel(); }, shutdown: () => { app.shutdownRequested = true; app.session.cancel(); tui?.requestShutdown(); },
114
- getSystemPrompt: () => app.session.config.frozen.system,
115
- getContextUsage: () => ({ tokens: app.session.state.anchor?.promptTokens ?? null, contextWindow: app.providerConfig.spec.contextWindow }),
116
- waitForIdle: () => app.session.whenIdle(),
117
- reload: async () => { if (app.session.running) throw new Error("Reload requires an idle session"); await reloadAppResources(app); await tui?.refreshResources(); },
118
- newSession: async () => { await openNewSession(app, tui); await bindExtensions(app, tui); return { cancelled: false }; },
119
- switchSession: async (path: string) => { const changed = await adoptLogDir(app, path); if (changed) { await bindExtensions(app, tui); await tui?.refreshResources(); } return { cancelled: !changed }; },
120
- fork: async (id: string) => { const changed = await spawnForkedSession(app, id, "forked by extension", "before"); if (changed) await bindExtensions(app, tui); return { cancelled: !changed }; },
121
- sessionManager: { getEntries: entries, getBranch: entries, getSessionId: () => basename(app.logDir), getSessionFile: () => app.logDir, getCwd: () => app.workspace },
122
- modelRegistry: { getAll: () => [...app.catalog], getAvailable: () => app.catalog.filter(model => app.credentialOf(model).ok), find: (provider: string, id: string) => app.catalog.find(model => model.provider === provider && model.id === id) },
123
- };
124
- }
125
-
126
- async function bindExtensions(app: App, tui?: Ui): Promise<void> {
127
- bindAppWorkflow(app);
128
- if (tui) {
129
- const control = sessionControl(app.session);
130
- control.questionSlot.current = (request, signal) => control.askQuestion(request, signal, current => presentQuestions(tui.extensionUi, current, signal));
131
- }
132
- tui?.bindExtensionRegistry();
133
- const registry = app.extensions; if (!registry) return;
134
- const history = await readNodeHistory(app.logDir, app.session.state.node);
135
- if (history.ok) extensionHistories.set(app, history.nodes.flatMap(node => node.events));
136
- registry.bind({
137
- context: () => extensionContext(app, tui),
138
- append: async (entry, options) => {
139
- if (entry.inContext && app.session.running) await app.session.whenIdle();
140
- await app.session.appendExtension(entry); await tui?.syncTranscript();
141
- if (options?.triggerTurn) { if (tui) tui.deliverExtension("Continue.", "followup"); else await run(app, "Continue."); }
142
- },
143
- user: async (text, options) => { if (tui) tui.deliverExtension(text, options?.deliverAs === "steer" ? "steering" : "followup"); else await run(app, text); },
144
- setName: name => nameSession(app, name, tui),
145
- setLabel: async (id, label) => { await setSessionLabel(app.logDir, id, label ?? "", app.session.clock.now()); },
146
- getTools: () => app.session.config.frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" &&
147
- "name" in tool && typeof tool.name === "string" && "description" in tool && typeof tool.description === "string" && "input_schema" in tool
148
- ? [{ name: tool.name, description: tool.description, parameters: JSON.parse(JSON.stringify(tool.input_schema)) as unknown }] : []),
149
- getAllTools: () => {
150
- const { schemas, deferred } = sessionToolCatalog(app.args, registry, app.session.config.frozen.tools);
151
- return [...new Map([...schemas, ...deferred].map(tool => [tool.name, tool])).values()]
152
- .map(tool => ({ name: tool.name, description: tool.description, parameters: tool.input_schema }));
153
- },
154
- setTools: async names => {
155
- if (app.session.running) throw new Error("Active tools can only change at an idle checkpoint");
156
- const args = { ...app.args, tools: names, noTools: false };
157
- const { schemas: tools, deferred } = sessionToolCatalog(args, registry);
158
- if (names.some(name => !tools.some(tool => tool.name === name))) throw new Error("Unknown active tool");
159
- await app.session.reloadResources({ ...app.session.config.frozen, tools });
160
- app.session.config.tools.deferredTools = deferred; app.args = args;
161
- },
162
- setModel: async model => {
163
- if (typeof model !== "object" || !model || !("provider" in model) || !("id" in model)) return false;
164
- await switchByPattern(app, String(model.provider) + "/" + String(model.id), "session"); return app.providerConfig.spec.id === model.id && app.providerConfig.spec.provider === model.provider;
165
- },
166
- setThinking: async level => { await applyThinkingCommand(app, level, tui ?? null, "session"); },
167
- exec: (command, args, options) => app.session.runExtensionCommand(command, args, options),
168
- });
169
- if (!initializedExtensions.has(registry)) {
170
- initializedExtensions.add(registry);
171
- const start = async () => { await registry.emit("session_start"); await registry.settle(); };
172
- if (tui) tui.runExtensionAction(start); else await start();
173
- }
174
- }
175
-
176
- function parseLine(app: App, text: string): Command {
177
- const match = /^\/([^\s]+)(?:\s+([\s\S]*))?$/.exec(text.trim());
178
- if (match && app.extensions?.commands.has(match[1]!)) return { kind: "extension", name: match[1]!, args: match[2] ?? "" };
179
- return parseCommand(text, app.slash);
180
- }
181
-
182
- /**
183
- * Inputs maybeCut reads when it decides whether to cut. The gauge must use
184
- * this same set -- a second estimate would disagree with the actual cut. Policy,
185
- * window and reserve have no defaults: a default is how the footer came to
186
- * measure against a boundary the active policy never consults.
187
- */
188
- export interface BudgetInput {
189
- readonly logHint?: { manifestPath: string; segmentPath: string };
190
- readonly frozen: FrozenPrefix;
191
- readonly handoff: Handoff | null;
192
- readonly events: readonly LogEvent[];
193
- readonly anchor: Anchor | null;
194
- readonly counter?: TokenCounter;
195
- readonly boundary?: Partial<BoundaryConfig>;
196
- readonly policy: ContextPolicy;
197
- readonly contextWindow: number | undefined;
198
- readonly outputReserve: number;
199
- }
200
-
201
- export interface ContextBudget {
202
- readonly used: number;
203
- /** Nothing the provider reported backs the count: before the first response, after a cut. */
204
- readonly estimated: boolean;
205
- /** Where the active policy next acts; null when it names no boundary. */
206
- readonly trigger: number | null;
207
- readonly inherited: number;
208
- readonly limits: ContextLimits;
209
- readonly decision: ContextDecision;
210
- readonly level: GaugeLevel;
211
- }
212
-
213
- /**
214
- * The numbers the status gauge shows, computed the same way maybeCut does:
215
- * anchoredCount for the current projection, inheritedTokens from the empty
216
- * node, contextLimits and decideContext for what the next turn boundary does.
217
- * The TUI must not recompute the work floor.
218
- */
219
- export function contextBudget(input: BudgetInput): ContextBudget {
220
- const counter = input.counter ?? estimateTokens;
221
- const projected = projectContext({
222
- frozen: input.frozen,
223
- handoff: input.handoff,
224
- events: input.events,
225
- ...(input.logHint ? { logHint: input.logHint } : {}),
226
- });
227
- const { tokens, exactUpTo } = anchoredCount(
228
- projected.messages,
229
- projected.system,
230
- input.anchor,
231
- counter,
232
- input.frozen.tools,
233
- );
234
- const floor = projectContext({
235
- frozen: input.frozen,
236
- handoff: input.handoff,
237
- events: [],
238
- ...(input.logHint ? { logHint: input.logHint } : {}),
239
- });
240
- const inherited = counter.count(floor.messages, floor.system, input.frozen.tools);
241
- const limits = contextLimits({
242
- policy: input.policy,
243
- contextWindow: input.contextWindow,
244
- outputReserve: input.outputReserve,
245
- ...(input.boundary ? { boundary: input.boundary } : {}),
246
- });
247
- // The footer asks what the next turn boundary does, even mid-turn. Readiness
248
- // and price are not known here, so adaptive reads as deferrable at its soft
249
- // trigger and certain only at the hard limit.
250
- const decision = decideContext({
251
- policy: input.policy,
252
- continuationReady: false,
253
- cost: null,
254
- tokens,
255
- inheritedTokens: inherited,
256
- atTurnBoundary: true,
257
- ...(input.contextWindow === undefined ? {} : { contextWindow: input.contextWindow }),
258
- outputReserve: input.outputReserve,
259
- ...(input.boundary ? { config: input.boundary } : {}),
260
- });
261
- return {
262
- used: tokens,
263
- estimated: exactUpTo === 0,
264
- trigger: nextBoundary(limits),
265
- inherited,
266
- limits,
267
- decision,
268
- level: gaugeLevel(tokens, limits, decision),
269
- };
270
- }
271
-
272
- export function resolveEditorCommand(
273
- env: { readonly [key: string]: string | undefined } = process.env,
274
- ): string {
275
- const visual = env["VISUAL"]?.trim();
276
- if (visual !== undefined && visual.length > 0) return visual;
277
- const editor = env["EDITOR"]?.trim();
278
- if (editor !== undefined && editor.length > 0) return editor;
279
- return "nano";
280
- }
281
-
282
- export type ExternalEditorResult =
283
- | { ok: true; content: string }
284
- | { ok: false; message: string };
285
-
286
- /**
287
- * The only I/O in the editor-completion card. applyKey stays pure; this
288
- * writes a temp file, runs $VISUAL/$EDITOR/nano, and deletes the file
289
- * whether the editor saved or :cq'd. A non-zero exit is a discard — vim's
290
- * :cq means "I do not want this" and stuffing the half-written file back
291
- * would turn a cancel into an edit.
292
- */
293
- export async function editInExternalEditor(input: {
294
- readonly content: string;
295
- readonly command: string;
296
- }): Promise<ExternalEditorResult> {
297
- const dir = await mkdtemp(join(tmpdir(), "xeno-editor-"));
298
- const filePath = join(dir, "prompt.md");
299
- try {
300
- await writeFile(filePath, input.content, "utf8");
301
- const parts = input.command.trim().split(/\s+/).filter((p) => p.length > 0);
302
- const bin = parts[0];
303
- if (bin === undefined) {
304
- return { ok: false, message: "external editor: no command; discarding changes" };
305
- }
306
- const proc = Bun.spawn([...parts, filePath], {
307
- stdin: "inherit",
308
- stdout: "inherit",
309
- stderr: "inherit",
310
- });
311
- const code = await proc.exited;
312
- if (code !== 0) {
313
- return { ok: false, message: `external editor exited ${code}; discarding changes` };
314
- }
315
- const content = await readFile(filePath, "utf8");
316
- return { ok: true, content };
317
- } catch (err) {
318
- const msg = err instanceof Error ? err.message : String(err);
319
- return { ok: false, message: `external editor failed (${msg}); discarding changes` };
320
- } finally {
321
- await rm(dir, { recursive: true, force: true });
322
- }
323
- }
324
-
325
- /** Scan once per workspace: a glob on every @ is the stall the card rejected. */
326
- const fileIndexes = new Map<string, FileIndex>();
327
- const indexNotes = new Set<string>();
328
-
329
- async function fileIndexFor(app: App, refresh = false): Promise<FileIndex> {
330
- const hit = fileIndexes.get(app.workspace);
331
- if (!refresh && hit !== undefined) return hit;
332
- const built = await buildFileIndex(app.workspace);
333
- fileIndexes.set(app.workspace, built);
334
- const note = indexTruncationNote(built);
335
- if (note !== null && !indexNotes.has(app.workspace)) {
336
- indexNotes.add(app.workspace);
337
- app.io.say(app.io.palette.yellow(` ${note}`));
338
- }
339
- return built;
340
- }
341
-
342
- export async function runPrompt(app: App, prompt: string): Promise<void> {
343
- await bindExtensions(app);
344
- if (app.shutdownRequested) { await closeAppSession(app); return; }
345
- let ok: boolean;
346
- try { ok = await run(app, prompt); }
347
- finally { await closeAppSession(app); }
348
- printUsage(app);
349
- // One-shot mode is scriptable, so a failed turn has to be a failed exit.
350
- if (!ok) process.exit(1);
351
- }
352
-
353
- export async function runRepl(app: App, releaseEarlyTty?: () => void): Promise<void> {
354
- if (stdinIsTty() && stdoutIsTty()) {
355
- await runTui(app, releaseEarlyTty);
356
- } else {
357
- await runLines(app);
358
- }
359
- }
360
-
361
- async function runLines(app: App): Promise<void> {
362
- await bindExtensions(app);
363
- const C = app.io.palette;
364
- if (app.ui?.settings.quietStartup !== true || app.args.verbose) app.io.say(C.dim(HINT));
365
- /** Set by `/provider <provider>`; the next line typed is the key, not a prompt. */
366
- let awaitingKeyFor: string | null = null;
367
- for await (const line of console) {
368
- const text = line.trim();
369
- if (awaitingKeyFor !== null) {
370
- const provider = awaitingKeyFor;
371
- awaitingKeyFor = null;
372
- if (text.length === 0) {
373
- if (provider === OAUTH_PASTE) await cancelPendingOAuth(app);
374
- app.io.say(C.dim(" cancelled\n"));
375
- continue;
376
- }
377
- if (provider === OAUTH_PASTE) {
378
- if (!pendingLogins.has(app) || pendingLogins.get(app)?.done) {
379
- const outcome = await execute(app, parseLine(app, text), null);
380
- if (outcome === "quit") break;
381
- if (typeof outcome === "object") awaitingKeyFor = outcome.login;
382
- continue;
383
- }
384
- await completeOAuthPaste(app, text);
385
- continue;
386
- }
387
- await login(app, provider, text);
388
- continue;
389
- }
390
- if (!text) continue;
391
- const outcome = await execute(app, parseLine(app, text), null);
392
- if (outcome === "quit" || app.shutdownRequested) break;
393
- if (typeof outcome === "object") awaitingKeyFor = outcome.login;
394
- }
395
- await cancelPendingOAuth(app);
396
- await closeAppSession(app);
397
- printUsage(app);
398
- }
399
-
400
- async function runTui(app: App, releaseEarlyTty?: () => void): Promise<void> {
401
- if (!app.args.noSession) await registerSessionRoot({ path: dirname(app.logDir), workspace: app.workspace }).catch(error => app.io.say(String(error)));
402
- // Process start is the one place `auto` meets the real terminal (core/tui-mode.ts).
403
- if (app.ui) app.ui.settings = { ...app.ui.settings, tuiMode: resolveTuiMode({ flag: app.args.tuiMode, setting: app.ui.settings.tuiMode, env: process.env }).mode };
404
- const { runInteractive } = await import("./ui/app.ts");
405
- await runInteractive(app, interactiveHooks, releaseEarlyTty);
406
- printUsage(app);
407
- }
408
-
409
- /**
410
- * The business operations the interactive controller drives. Exported as one
411
- * object so apps/ui/app.ts depends on shell.ts in one direction only and the
412
- * set of things the UI can do is visible in one place.
413
- */
414
- export const interactiveHooks = {
415
- handleLine: (app: App, text: string, ui: Ui) => handleLine(app, text, ui),
416
- run: (app: App, prompt: string, signal: AbortSignal | undefined, ui: Ui | undefined, images: readonly ImageRef[] = [], skill?: Skill, options?: RequestOptions) =>
417
- run(app, prompt, signal, ui, images, skill, options),
418
- parseLine: (app: App, text: string) => parseLine(app, text),
419
- bindExtensions: (app: App, ui?: Ui) => bindExtensions(app, ui),
420
- extensionContext: (app: App, ui?: Ui) => extensionContext(app, ui),
421
- cancelPendingOAuth: (app: App) => cancelPendingOAuth(app),
422
- switchToModel: (app: App, spec: ModelSpec) => switchToModel(app, spec),
423
- applyThinking: (app: App, effort: Effort, ui: Ui | null) => applyThinking(app, effort, ui),
424
- cycleThinking: (app: App, ui: Ui | null) => cycleThinking(app, ui),
425
- cycleScopedModel: (app: App, direction: 1 | -1) => cycleScopedModel(app, direction),
426
- commitSetting: (app: App, field: string, value: string, ui: Ui | null) => commitSetting(app, field, value, ui),
427
- startSubscriptionLogin: (app: App, id: string, ui: Ui | null) => startSubscriptionLogin(app, id, ui),
428
- login: (app: App, provider: string, key: string) => login(app, provider, key),
429
- logout: (app: App, provider: string) => logout(app, provider),
430
- resumeSession: (app: App, path: string, ui: Ui) => resumeSession(app, path, ui),
431
- openNewSession: (app: App, ui: Ui) => openNewSession(app, ui),
432
- forkSession: (app: App, arg: string, ui: Ui) => forkSession(app, arg, ui),
433
- trustProject: (app: App, choice: TrustChoice, ui: Ui) => trustProject(app, choice, ui),
434
- treeNodesFor: (app: App) => treeNodesFor(app),
435
- copyLastReply: (app: App) => copyLastReply(app),
436
- effortSupported: (spec: ModelSpec, level: Effort) => effortSupported(spec, level),
437
- fileIndexFor: (app: App, refresh?: boolean) => fileIndexFor(app, refresh),
438
- dropFileIndex: (workspace: string) => { fileIndexes.delete(workspace); },
439
- /** Show a stored image in the system viewer; resolves to why it could not, or null. */
440
- openImage: (app: App, sha256: string) => openImageFile(app.blobDir, sha256),
441
- };
442
-
443
- type Outcome = "continue" | "quit" | { login: string };
444
-
445
- async function execute(app: App, command: Command, tui: Ui | null): Promise<Outcome> {
446
- switch (command.kind) {
447
- case "workflow":
448
- await setAppWorkflow(app, command.mode); app.io.say(` Workflow: ${command.mode}`); await tui?.refreshResources(); return "continue";
449
- case "agent":
450
- if (command.name) { await selectAppAgent(app, command.name); await bindExtensions(app, tui ?? undefined); await tui?.refreshResources(); }
451
- else app.io.say(await listAppAgents(app)); return "continue";
452
- case "mcp": {
453
- const manager = app.foundation?.mcp; if (!manager) throw Error("MCP resources are unavailable");
454
- if (command.action === "status") app.io.say(JSON.stringify(manager.status(), null, 2));
455
- else {
456
- if (!command.name) throw Error(`Use /mcp ${command.action} <server>`);
457
- if (command.action === "login") await manager.login(command.name, url => { app.io.say(` Complete MCP login: ${url.href}`); }, tui?.signal);
458
- else await manager.logout(command.name);
459
- await reloadAppResources(app); await bindExtensions(app, tui ?? undefined); await tui?.refreshResources();
460
- }
461
- return "continue";
462
- }
463
- case "diff": { const snapshot = await appReviewSnapshot(app, reviewScopeFromText(command.args), tui?.signal); app.io.say(`${snapshot.label}\n${snapshot.diff || "(no captured changes)"}\n${snapshot.limitations.join("\n")}`); return "continue"; }
464
- case "review": {
465
- // A background branch (feat-review-branch): the parent stays usable; the result joins when it ends.
466
- const node = await startReview(app, reviewScopeFromText(command.args));
467
- app.io.say(`Review running as branch ${node}. Keep working; its findings join this session when it ends.${tui ? " ↓ from the prompt shows it." : " /branches inspects it."}`);
468
- await tui?.syncTranscript(); return "continue";
469
- }
470
- case "extension":
471
- if (tui) tui.runExtensionCommand(command.name, command.args);
472
- else { await bindExtensions(app); await app.extensions?.command(command.name, command.args); }
473
- return "continue";
474
- case "attachments":
475
- if (tui) tui.openAttachments();
476
- else app.io.say("Attachments are edited in the interactive prompt.");
477
- return "continue";
478
- case "quit":
479
- return "quit";
480
- case "usage":
481
- printUsage(app);
482
- return "continue";
483
- case "state":
484
- await printState(app);
485
- return "continue";
486
- case "control":
487
- if (tui) await tui.openControl();
488
- else app.io.say(JSON.stringify(await sessionControl(app.session).snapshot(), null, 2));
489
- return "continue";
490
- case "context":
491
- printContext(app);
492
- return "continue";
493
- case "acceptance":
494
- await printAcceptance(app);
495
- return "continue";
496
- case "tasks": {
497
- const items = await app.session.workItems();
498
- app.io.say(`\nwork tasks: ${items.length}`);
499
- for (const item of items) {
500
- const ready = workReadiness(items, item.id);
501
- app.io.say(` ${item.id}: ${ready.reason} · ${item.what} · verification=${item.verification}${item.owner ? ` · owner=${item.owner}` : ""}`);
502
- if (ready.waiting_for.length) app.io.say(` waiting for: ${ready.waiting_for.join(", ")}`);
503
- }
504
- return "continue";
505
- }
506
- case "branches": {
507
- if (command.action !== undefined && command.node !== undefined) app.io.say((await app.session.resolveBranch(command.node, command.action)).content);
508
- for (const item of await app.session.branchStatuses()) app.io.say(` branch ${item.node}: ${item.status} · ${item.purpose}${item.receipt_ref ? ` · receipt ${item.receipt_ref}` : ""}`);
509
- return "continue";
510
- }
511
- case "processes": {
512
- for (const item of await app.session.processStatuses()) app.io.say(` process ${item.session}: ${item.status} · last observation=${item.observed}${item.exit !== undefined ? ` · exit=${item.exit}` : ""}${item.output ? ` · output ${item.output.sha256}` : ""}`);
513
- return "continue";
514
- }
515
- case "accept":
516
- await app.session.acceptCriterion(command.criterion);
517
- await printAcceptance(app);
518
- return "continue";
519
- case "goal":
520
- await goalCommand(app, command, tui ?? undefined);
521
- return "continue";
522
- case "help":
523
- printHelp(app);
524
- return "continue";
525
- case "hotkeys":
526
- printHotkeys(app);
527
- return "continue";
528
- case "settings":
529
- return applySettingsCommand(app, command.field, command.value, tui);
530
- case "reload": {
531
- const cut = await reloadAppResources(app);
532
- if (tui) await tui.refreshResources(); else await bindExtensions(app);
533
- app.io.say(app.io.palette.green(` Resources reloaded${cut ? " at a context checkpoint" : ""}`));
534
- return "continue";
535
- }
536
- case "config":
537
- if (tui) await tui.openResources();
538
- else {
539
- const resources = await discoverResources({ ...app.args, workspace: app.workspace, trusted: (await projectTrust(app.workspace, app.projectTrusted ?? app.args.approve)).trusted });
540
- for (const entry of resources.entries) app.io.say(`${entry.enabled ? "[x]" : "[ ]"} ${entry.kind} ${entry.scope} ${entry.path}`);
541
- }
542
- return "continue";
543
- case "session":
544
- await printSession(app);
545
- return "continue";
546
- case "model":
547
- if (command.pattern !== undefined) {
548
- await switchByPattern(app, command.pattern);
549
- return "continue";
550
- }
551
- if (tui) {
552
- tui.openModel();
553
- return "continue";
554
- }
555
- printModels(app);
556
- return "continue";
557
- case "scoped-models":
558
- if (tui) tui.openScopedModels();
559
- else app.io.say((app.ui?.settings.scopedModels ?? app.catalog.filter(spec => app.credentialOf(spec).ok).map(modelKey)).join("\n"));
560
- return "continue";
561
- case "thinking":
562
- return applyThinkingCommand(app, command.level, tui);
563
- case "login": {
564
- if (command.provider === undefined) {
565
- if (tui) { tui.openLogin(); return "continue"; }
566
- const C = app.io.palette;
567
- const providers = [...new Set(app.catalog.map((m) => m.provider))].filter(provider => !subscriptionById(provider));
568
- app.io.say(C.bold("\nproviders"));
569
- app.io.say(C.bold(" API keys"));
570
- for (const p of providers) {
571
- app.io.say(` ${p.padEnd(12)}${C.dim(envLabel(p))}`);
572
- }
573
- app.io.say(C.bold(" Subscriptions"));
574
- for (const s of SUBSCRIPTION_OPTIONS) {
575
- app.io.say(` ${s.id.padEnd(16)}${C.dim(s.label)}`);
576
- }
577
- app.io.say(C.dim("\n /provider <provider> then paste the key. Empty line cancels."));
578
- app.io.say(C.dim(` /provider ${SUBSCRIPTION_OPTIONS.map(option => option.id).join("|")} starts an account login.\n`));
579
- return "continue";
580
- }
581
- const subscription = subscriptionById(command.provider);
582
- if (subscription) {
583
- return startSubscriptionLogin(app, subscription.id, tui);
584
- }
585
- if (!app.catalog.some(model => model.provider === command.provider)) { app.io.say(app.io.palette.red(` unknown provider: ${command.provider}. Use /provider to choose one.`)); return "continue"; }
586
- if (tui) {
587
- const provider = command.provider;
588
- const key = await tui.askSecret(`API key · ${provider}`, "Key", "paste the key · $VAR reads the environment · !cmd runs a command · enter save · esc cancel");
589
- if (key) await login(app, provider, key); else if (key !== undefined) tui.notice(" cancelled");
590
- return "continue";
591
- }
592
- const C = app.io.palette;
593
- // Line mode echoes nothing for this line; the sentence must say so,
594
- // because "it will be visible here" is how a secret ends up in a screenshot.
595
- app.io.say(C.yellow(` paste the ${command.provider} key and press enter. It is not shown.`));
596
- app.io.say(C.dim(" a value starting with ! runs as a command; $VAR reads the environment."));
597
- app.io.say(C.dim(" empty line cancels."));
598
- return { login: command.provider };
599
- }
600
- case "logout": {
601
- if (tui && command.provider === undefined) { tui.openLogout(); return "continue"; }
602
- await logout(app, command.provider);
603
- return "continue";
604
- }
605
- case "back": {
606
- if (command.arg === undefined) { await printBack(app); return "continue"; }
607
- const points = await app.session.restorePoints();
608
- const i = Number(command.arg);
609
- const point = Number.isInteger(i) ? points[i - 1] : undefined;
610
- if (!point) {
611
- app.io.say(app.io.palette.red(` no such point: ${command.arg}. /back lists them.`));
612
- return "continue";
613
- }
614
- await printRewind(app, point, command.mode, command.confirm);
615
- await tui?.syncTranscript();
616
- return "continue";
617
- }
618
- case "tree":
619
- if (tui) {
620
- await tui.openTree();
621
- return "continue";
622
- }
623
- await printTree(app);
624
- return "continue";
625
- case "new":
626
- await openNewSession(app, tui ?? undefined);
627
- return "continue";
628
- case "resume":
629
- if (command.id !== undefined) {
630
- await resumeSession(app, command.id, tui ?? undefined);
631
- return "continue";
632
- }
633
- if (tui) {
634
- void tui.openResume();
635
- return "continue";
636
- }
637
- await printSessionList(app);
638
- return "continue";
639
- case "name":
640
- await nameSession(app, command.name, tui ?? undefined);
641
- return "continue";
642
- case "export":
643
- await exportSession(app, command.file);
644
- return "continue";
645
- case "import": {
646
- if (!command.file) { app.io.say(" Usage: /import <session.jsonl>"); return "continue"; }
647
- const importing = async () => {
648
- if (tui && !await tui.confirm("Import session", "Open the archive as a separate session? The current session stays saved.")) { app.io.say(" Import cancelled"); return; }
649
- try {
650
- const path = await importSessionArchive(resolve(app.workspace, command.file!), sessionsDir(app.workspace, sessionDirFor(app.args, app.workspace)), app.workspace);
651
- if (await adoptLogDir(app, path)) { await bindExtensions(app, tui ?? undefined); await tui?.refreshResources(); await tui?.syncTranscript(); app.io.say(` Imported session: ${path}`); }
652
- } catch (error) { app.io.say(app.io.palette.red(` Cannot import: ${describeError(error)}`)); }
653
- };
654
- if (tui) tui.runSessionTransition(importing); else await importing();
655
- return "continue";
656
- }
657
- case "debug": {
658
- const file = resolve(app.workspace, command.file ?? join(configDir(), "debug.log"));
659
- const frame = tui?.snapshot() ?? { width: 80, height: 0, lines: [] };
660
- const { width, height, lines } = frame;
661
- const messages = projectContext({ frozen: app.session.config.frozen, handoff: app.session.state.handoff, events: app.session.state.events });
662
- const body = [`xeno diagnostics ${app.session.clock.now()}`, `Terminal: ${width}x${height}`, `Total lines: ${lines.length}`,
663
- ...lines.map((line, i) => `[${i}] ${JSON.stringify(line)}`), "", "Agent messages (JSONL)", ...messages.messages.map(message => JSON.stringify(message)), ""].join("\n");
664
- try { await mkdir(dirname(file), { recursive: true }); await writeFile(file, body, { mode: 0o600 }); app.io.say(` Debug log written: ${file}`); }
665
- catch (error) { app.io.say(` Cannot write debug log: ${describeError(error)}`); }
666
- return "continue";
667
- }
668
- case "changelog": {
669
- const text = await readFile(new URL("../../CHANGELOG.md", import.meta.url), "utf8");
670
- if (tui) tui.showDocument("Changelog", text); else app.io.say(text);
671
- return "continue";
672
- }
673
- case "update":
674
- if (tui) tui.checkForUpdate();
675
- else await runManualUpdate({ offline: app.args.offline === true, install: true }, app.io.say);
676
- return "continue";
677
- case "copy":
678
- await copyLastReply(app, tui ?? undefined);
679
- return "continue";
680
- case "fork":
681
- if (tui && command.arg === undefined) { await tui.openFork(); return "continue"; }
682
- await forkSession(app, command.arg, tui ?? undefined);
683
- return "continue";
684
- case "clone":
685
- await cloneSession(app);
686
- await tui?.syncTranscript();
687
- return "continue";
688
- case "trust":
689
- if (command.target) await trustProject(app, command.target, tui ?? undefined);
690
- else if (tui) await tui.openTrust();
691
- else app.io.say(" /trust once|here|parent|never controls project resources; tool permissions are separate.");
692
- return "continue";
693
- case "models-refresh":
694
- await refreshModels(app);
695
- return "continue";
696
- case "unknown":
697
- app.io.say(app.io.palette.yellow(` unknown command: ${command.input}. /help lists them.`));
698
- return "continue";
699
- case "prompt":
700
- await run(app, command.text, tui?.signal, tui ?? undefined, [], command.skill);
701
- app.io.say("");
702
- return "continue";
703
- default:
704
- return assertNever(command, "execute");
705
- }
706
- }
707
-
708
- async function handleLine(app: App, text: string, tui: Ui): Promise<Outcome> {
709
- return execute(app, parseLine(app, text), tui);
710
- }
711
-
712
- /**
713
- * S.2 / S.5. Line mode keeps a drafted goal until `/goal accept` names the
714
- * criteria to keep; the TUI asks with a checklist instead.
715
- */
716
- const pendingGoals = new WeakMap<App, PendingGoal>();
717
-
718
- async function goalCommand(app: App, command: Extract<Command, { kind: "goal" }>, tui?: Ui): Promise<void> {
719
- const C = app.io.palette;
720
- switch (command.action) {
721
- case "status": await printGoal(app); return;
722
- case "clear": app.io.say(await app.session.clearGoal() ? C.green(" goal cleared") : C.dim(" no active goal")); return;
723
- case "set": {
724
- if (tui) { tui.startGoal(command.objective); return; }
725
- const drafted = await draftGoal(app, command.objective, (prompt, options) => run(app, prompt, undefined, undefined, [], undefined, options));
726
- if (!drafted) return;
727
- pendingGoals.set(app, drafted);
728
- app.io.say(C.bold("\ndrafted criteria") + C.dim(" (accept with /goal accept all, or /goal accept 1,3)"));
729
- drafted.criteria.forEach((item, index) => app.io.say(` ${index + 1}. ${item.id} · ${criterionSummary(item)}`));
730
- return;
731
- }
732
- case "accept": {
733
- const drafted = pendingGoals.get(app);
734
- if (!drafted) { app.io.say(C.yellow(" no drafted goal; run /goal <objective> first")); return; }
735
- const picked = pickCriteria(drafted.criteria, command.pick);
736
- if (!picked.ok) { app.io.say(C.yellow(` ${picked.error}`)); return; }
737
- pendingGoals.delete(app);
738
- await app.session.setGoal({ ...drafted, accepted: picked.ids });
739
- app.io.say(C.green(` goal set with ${picked.ids.length} accepted criteria`));
740
- await run(app, drafted.objective);
741
- await printGoal(app);
742
- return;
743
- }
744
- default: return assertNever(command, "goal command");
745
- }
746
- }
747
-
748
- async function printGoal(app: App): Promise<void> {
749
- const C = app.io.palette, goal = await app.session.goal();
750
- if (!goal) { app.io.say(C.dim(" no active goal; /goal <objective> drafts one")); return; }
751
- const request = await app.session.requestState();
752
- const current = request?.acceptance?.contract_id === goal.contract.id ? request : undefined;
753
- app.io.say(C.bold("\ngoal") + ` ${goal.objective}`);
754
- for (const item of goal.contract.criteria) {
755
- const criterion = current?.criteria.find(entry => entry.id === item.id), status = criterion?.status ?? "not_run";
756
- const guard = item.kind === "command" && item.guard === true;
757
- app.io.say(` ${status === "pass" || status === "accepted" ? C.green("✓") : C.yellow("○")} ${item.id}${guard ? " (guard)" : ""} · ${status} · ${item.description}` +
758
- (criterion?.counter ? counterSuffix(C, criterion.counter) : ""));
759
- }
760
- for (const blocker of current?.reported_blockers ?? []) app.io.say(C.yellow(` reported blocker: ${blocker.reason}`));
761
- if (current && current.evidence.independent !== "not_applicable") app.io.say(` blind checks ${current.evidence.independent}`);
762
- }
763
-
764
- /** One counter-check verdict after a criterion, in the words of its role (S.7). */
765
- function counterSuffix(C: App["io"]["palette"], verdict: CounterVerdict): string {
766
- switch (verdict) {
767
- case "vacuous": return C.yellow(" · also passes without the changes; it does not test them");
768
- case "discriminating": return C.green(" · fails without the changes");
769
- case "held": return C.green(" · held before the changes too");
770
- case "not_held": return C.dim(" · did not hold before the changes; it tests them rather than guarding");
771
- case "unavailable": return C.dim(" · counter-check unavailable");
772
- default: return assertNever(verdict, "counter verdict");
773
- }
774
- }
775
-
776
- function printUsage(app: App): void {
777
- const C = app.io.palette;
778
- const t = app.session.totals;
779
- const spec = app.session.provider.config.spec;
780
- const rates = spec.rates;
781
- app.io.say(C.bold("\nusage"));
782
- app.io.say(` requests ${t.requests}`);
783
- app.io.say(` prompt tokens ${t.prompt_tokens.toLocaleString()}`);
784
- app.io.say(` uncached ${t.input_tokens.toLocaleString()}`);
785
- if (rates === undefined) {
786
- app.io.say(` cache write ${t.cache_creation_input_tokens.toLocaleString()} (unknown)`);
787
- app.io.say(` cache read ${t.cache_read_input_tokens.toLocaleString()} (unknown)`);
788
- } else {
789
- // The multiples come from the model that actually ran. They used to be
790
- // Anthropic's, printed verbatim beside a DeepSeek total, where the write
791
- // rate is 0 and the read rate is a third of what the label claimed.
792
- app.io.say(
793
- ` cache write ${t.cache_creation_input_tokens.toLocaleString()} ` +
794
- `(${rates.write}x${rates.write === 0 ? ", not billed" : ""})`,
795
- );
796
- app.io.say(` cache read ${t.cache_read_input_tokens.toLocaleString()} (${rates.read.toFixed(3)}x)`);
797
- }
798
- app.io.say(` output tokens ${t.output_tokens.toLocaleString()}`);
799
- const rate = (t.cache_hit_rate * 100).toFixed(1);
800
- const rateColour = t.cache_hit_rate > 0.7 ? C.green : t.cache_hit_rate > 0.3 ? C.yellow : C.red;
801
- app.io.say(` cache hit rate ${rateColour(rate + "%")}`);
802
- if (rates !== undefined) {
803
- const cost = equivalentCost(t, rates);
804
- app.io.say(
805
- ` input equivalent ${Math.round(cost.input_equivalent).toLocaleString()} ` +
806
- C.dim(`(vs ${Math.round(cost.uncached_equivalent).toLocaleString()} uncached, ` +
807
- `${cost.savings_ratio.toFixed(2)}x saved)`),
808
- );
809
- }
810
- const dollars = reportedCost(t, spec.price);
811
- if (dollars.kind === "unknown") {
812
- app.io.say(` cost unknown`);
813
- } else if (dollars.kind === "usd") {
814
- app.io.say(` cost $${dollars.amount.toFixed(4)}`);
815
- } else if (dollars.kind === "partial") {
816
- // `known` is not a complete bill. Printing it on the usd path would
817
- // look like cache had been free.
818
- app.io.say(` cost $${dollars.known.toFixed(4)} (cache not counted)`);
819
- } else {
820
- return assertNever(dollars, "printUsage");
821
- }
822
- app.io.say(` model time ${(t.ms / 1000).toFixed(1)}s`);
823
- app.io.say("");
824
- }
825
-
826
- async function printState(app: App): Promise<void> {
827
- const C = app.io.palette;
828
- const s = app.session.state;
829
- app.io.say(C.bold("\nstate"));
830
- app.io.say(` node ${s.node} (depth ${s.depth})`);
831
- app.io.say(` events in node ${s.events.length}`);
832
- const request = await app.session.requestState();
833
- if (request) {
834
- app.io.say(` request ${request.request.event} · ${request.status}`);
835
- app.io.say(` execution ${request.execution?.reason ?? "in progress or unknown"}`);
836
- app.io.say(` command evidence ${request.commands.filter(command => command.status === "pass").length} current passes / ${request.commands.length} receipts`);
837
- app.io.say(` observations ${request.progress.observations.length} new; artifact changes ${request.progress.artifact_changes.length}; acceptance advances ${request.progress.acceptance_advances.length}`);
838
- app.io.say(` diagnostics ${request.progress.new_diagnostics.length} new; unchanged retries ${request.progress.unchanged_retries.length}; unknown ${request.progress.unknown_diagnostics.length}`);
839
- app.io.say(` evidence ran ${request.evidence.ran} · discriminating ${request.evidence.discriminating} · independent ${request.evidence.independent} · human ${request.evidence.human}`);
840
- for (const gap of request.unmet) app.io.say(` unmet ${gap}`);
841
- for (const blocker of request.reported_blockers) app.io.say(` reported blocker ${blocker.reason}`);
842
- if (!request.acceptance) app.io.say(" task verification no declared contract; a fresh check shows only that the changes ran, not that the request was met");
843
- }
844
- if (s.handoff) {
845
- const h = s.handoff;
846
- app.io.say(` completed ${h.state.completed.length}`);
847
- app.io.say(` unverified ${h.state.unverified_claims.length}`);
848
- app.io.say(` in flight ${h.state.in_flight.length}`);
849
- app.io.say(` blocked ${h.state.blocked.length}`);
850
- app.io.say(
851
- ` gates ${h.verification.map((v) => `${v.gate}=${v.status}`).join(" ") || "none"}`,
852
- );
853
- app.io.say(` no progress for ${h.budget.nodes_without_progress} node(s)`);
854
- } else {
855
- app.io.say(C.dim(" no handoff yet (still in the first node)"));
856
- }
857
- app.io.say("");
858
- }
859
-
860
- function printContext(app: App): void {
861
- const session = app.session;
862
- const detail = contextDetail({ ...session.contextInput(), anchor: session.state.anchor,
863
- policy: session.config.contextPolicy ?? DEFAULT_CONTEXT_POLICY,
864
- contextWindow: session.provider.config.spec.contextWindow, outputReserve: session.provider.config.maxTokens,
865
- ...(session.config.counter ? { counter: session.config.counter } : {}),
866
- ...(session.config.boundary ? { boundary: session.config.boundary } : {}),
867
- });
868
- app.io.say(app.io.palette.bold(`\ncontext v${detail.version} · node ${session.state.node} · ${detail.policy}`));
869
- for (const [name, tokens] of Object.entries(detail.components)) app.io.say(` ${name.padEnd(18)} ~${tokens} tokens`);
870
- app.io.say(` total ${detail.used} (${detail.countKind}; anchor correction ${detail.anchorCorrection}; measured through ${detail.measuredThrough} messages)`);
871
- app.io.say(` limits ${JSON.stringify(detail.limits)}`);
872
- const sources = detail.sources.flat();
873
- app.io.say(` sources ${sources.length} references: ${sources.slice(-12).join(", ") || "none"}`);
874
- if (sources.length > 12) app.io.say(` earlier sources ${sources.length - 12}; use history(recent=true) or history(event=ID)`);
875
- for (const section of detail.sections.filter(s => s.omitted > 0)) {
876
- app.io.say(` ${section.section} retained ${section.retained}/${section.total}; omitted ${section.omitted}; history(${JSON.stringify(section.recovery)})`);
877
- }
878
- app.io.say(` metadata ${detail.omittedEvents.length} events do not replay as messages; available in the log`);
879
- if (detail.violations.length) app.io.say(` violations ${JSON.stringify(detail.violations)}`);
880
- app.io.say("");
881
- }
882
-
883
- async function printAcceptance(app: App): Promise<void> {
884
- const state = await app.session.acceptanceState();
885
- app.io.say(state ? `\nacceptance ${state.contract_id}: ${state.status}` : "\nacceptance: no declared task contract");
886
- for (const target of state?.targets ?? []) {
887
- app.io.say(` target ${target.id}: ${target.status} · entry ${target.entry ?? "unobserved"} · pid ${target.pid ?? "unknown"} · session ${target.session ?? "none"} · port ${target.port ?? "not declared"}`);
888
- app.io.say(` build ${target.build_event ?? "none"}; instance ${target.binding ?? "unknown"}; observation ${target.observation ?? "none"}; ${target.reason ?? "start the declared target after its build"}`);
889
- }
890
- for (const item of state?.criteria ?? []) {
891
- app.io.say(` ${item.id}${item.kind === "command" && item.guard ? " (guard)" : ""}: ${item.status} · ${item.description}`);
892
- if (item.kind === "command") app.io.say(` command: ${item.command}; evidence: ${item.evidence?.event_id ?? "none"}`);
893
- else if (item.status !== "accepted") app.io.say(` /accept ${item.id} records your acceptance${item.target ? ` of the observed target ${item.target}` : ""}`);
894
- }
895
- }
896
-
897
- function printHotkeys(app: App): void {
898
- const C = app.io.palette;
899
- const map = app.ui?.keymap ?? defaultKeymap();
900
- const enabled = app.session.config.frozen.tools.length;
901
- app.io.say(C.bold("\nhotkeys"));
902
- app.io.say(` tools enabled ${enabled}`);
903
- for (const row of listBindings(map)) {
904
- app.io.say(` ${row.action.padEnd(28)}${row.keys.join(", ")}`);
905
- }
906
- app.io.say("");
907
- }
908
-
909
- async function applySettingsCommand(
910
- app: App,
911
- field: string | undefined,
912
- value: string | undefined,
913
- tui: Ui | null,
914
- ): Promise<Outcome> {
915
- if (field !== undefined && value !== undefined) {
916
- await commitSetting(app, field, value, tui);
917
- return "continue";
918
- }
919
- if (tui) {
920
- tui.openSettings(field);
921
- return "continue";
922
- }
923
- const s = app.ui?.settings;
924
- app.io.say(app.io.palette.bold("\nsettings"));
925
- app.io.say(` theme ${s?.theme ?? "dark"}`);
926
- app.io.say(` thinking ${s?.thinking ?? app.providerConfig.effort}`);
927
- app.io.say(app.io.palette.dim(" /settings <theme|thinking> <value> to change; /settings alone opens every option.\n"));
928
- return "continue";
929
- }
930
-
931
- async function commitSetting(app: App, field: string, value: string, tui: Ui | null): Promise<void> {
932
- const C = app.io.palette;
933
- const key = field.toLowerCase();
934
- if (app.ui === undefined) {
935
- app.io.say(C.yellow(" settings are not available in this session"));
936
- return;
937
- }
938
- if (key === "thinking") {
939
- const effort = asEffort(value);
940
- if (effort) await applyThinking(app, effort, tui);
941
- return;
942
- }
943
- const parsed = settingPatch(field, value);
944
- if (parsed.diagnostics.length) { app.io.say(C.yellow(parsed.diagnostics.join("\n"))); return; }
945
- if (key === "theme") {
946
- const diagnostics: string[] = [];
947
- const theme = await resolveTheme(app.resources ? themeResourcePath(app.resources, value) ?? value : value, diagnostics);
948
- if (diagnostics.length) { app.io.say(C.yellow(diagnostics.join("\n"))); return; }
949
- app.ui.theme = theme;
950
- app.ui.style = colorEnabled() ? createStyle(theme.colors) : IDENTITY;
951
- }
952
- const saved = await commitUserSettings(app, parsed.settings);
953
- if (saved && key === "enableskillcommands") { await reloadAppResources(app); await tui?.refreshResources(); }
954
- if (saved) {
955
- if (tui) tui.toast(`${field} · ${value}`, "success");
956
- else app.io.say(C.dim(` ${field} ${value}`));
957
- }
958
- tui?.applyConfiguration();
959
- if (key === "theme") tui?.followTheme();
960
- tui?.redraw();
961
- }
962
-
963
- const EFFORTS = THINKING_LEVELS;
964
-
965
- /** Explicit user choices are refused; automatic model changes use the pure clamp policy. */
966
- function effortSupported(spec: ModelSpec, level: Effort): boolean {
967
- return supportedThinkingLevels(spec).includes(level);
968
- }
969
-
970
- /**
971
- * Effort is a request parameter on the current provider. Switching it
972
- * must not cut: a cut drops the prefix cache, and thinking blocks stay
973
- * valid because the model did not change.
974
- */
975
- async function applyThinking(app: App, level: Effort, tui: Ui | null, origin: PreferenceOrigin = "user"): Promise<boolean> {
976
- const C = app.io.palette;
977
- if (!effortSupported(app.providerConfig.spec, level)) {
978
- app.io.say(
979
- C.yellow(
980
- ` ${level} is not supported by ${app.providerConfig.spec.id}; thinking unchanged`,
981
- ),
982
- );
983
- return false;
984
- }
985
- app.providerConfig = { ...app.providerConfig, effort: level };
986
- if (app.credential.ok) {
987
- app.session.provider = makeProvider(app, app.providerConfig, app.credential.key);
988
- }
989
- await app.session.setThinking(level);
990
- if (app.ui !== undefined) {
991
- app.ui.settings = { ...app.ui.settings, thinking: level };
992
- }
993
- if (origin === "user") await commitUserSettings(app, thinkingPreferencePatch(app.providerConfig.spec, level));
994
- app.io.say(C.dim(` thinking ${level}`));
995
- tui?.redraw();
996
- return true;
997
- }
998
-
999
- async function applyThinkingCommand(app: App, level: string | undefined, tui: Ui | null, origin: PreferenceOrigin = "user"): Promise<Outcome> {
1000
- if (level !== undefined) {
1001
- const effort = asEffort(level);
1002
- if (effort === undefined) {
1003
- app.io.say(app.io.palette.yellow(" thinking must be " + THINKING_LEVELS.join("|")));
1004
- return "continue";
1005
- }
1006
- await applyThinking(app, effort, tui, origin);
1007
- return "continue";
1008
- }
1009
- if (tui) {
1010
- tui.openThinking();
1011
- return "continue";
1012
- }
1013
- printThinking(app);
1014
- return "continue";
1015
- }
1016
-
1017
- function printThinking(app: App): void {
1018
- const C = app.io.palette;
1019
- const spec = app.providerConfig.spec;
1020
- app.io.say(C.bold("\nthinking"));
1021
- for (const level of EFFORTS) {
1022
- const supported = effortSupported(spec, level);
1023
- const here = level === app.providerConfig.effort;
1024
- const mark = !supported ? C.dim(" -- ") : here ? C.cyan(" now ") : C.green(" ");
1025
- const note = supported ? "" : C.dim(" unsupported");
1026
- app.io.say(` ${mark} ${level}${note}`);
1027
- }
1028
- app.io.say(C.dim("\n /thinking <level> to switch. Shift+Tab cycles supported levels.\n"));
1029
- }
1030
-
1031
- async function cycleThinking(app: App, tui: Ui | null): Promise<void> {
1032
- const supported = EFFORTS.filter((level) => effortSupported(app.providerConfig.spec, level));
1033
- const next = cycle(supported, app.providerConfig.effort, 1);
1034
- if (next === undefined) return;
1035
- await applyThinking(app, next, tui);
1036
- }
1037
-
1038
- async function cycleScopedModel(app: App, dir: 1 | -1): Promise<void> {
1039
- const C = app.io.palette;
1040
- const fromFlag = splitModelPatterns(app.args.models);
1041
- const names = app.args.modelsExplicit || fromFlag.length > 0 ? fromFlag : app.ui?.settings.scopedModels ?? app.catalog.filter(spec => app.credentialOf(spec).ok).map(modelKey);
1042
- if (names.length === 0) {
1043
- app.io.say(C.dim(" no scoped models. Pass --models a,b or set scopedModels."));
1044
- return;
1045
- }
1046
- const scope = scopedModels(names, app.catalog.filter(spec => app.credentialOf(spec).ok));
1047
- for (const message of scope.diagnostics) app.io.say(C.yellow(` ${message}`));
1048
- const ids = scope.entries.map(entry => modelKey(entry.model));
1049
- if (ids.length === 0) {
1050
- app.io.say(C.yellow(" --models matched nothing"));
1051
- return;
1052
- }
1053
- const next = cycle(ids, modelKey(app.providerConfig.spec), dir);
1054
- if (next === undefined) return;
1055
- const selected = scope.entries.find(entry => modelKey(entry.model) === next)!;
1056
- await switchToModel(app, selected.model, selected.thinking);
1057
- }
1058
-
1059
- function printHelp(app: App): void {
1060
- const C = app.io.palette;
1061
- app.io.say(C.bold("\ncommands"));
1062
- for (const command of COMMANDS) {
1063
- const hint = "argumentHint" in command ? ` ${command.argumentHint}` : "";
1064
- app.io.say(` /${(command.name + hint).padEnd(34)} ${command.description}`);
1065
- }
1066
- for (const [name, command] of app.extensions?.commands ?? []) app.io.say(` /${name.padEnd(34)} ${command.description ?? "Extension command"}`);
1067
- app.io.say(' @file or @"file name" attach a project file anywhere in the prompt');
1068
- app.io.say(" !cmd run in the workspace and send stdout/stderr/exit to the model");
1069
- app.io.say(" !!cmd run without sending output");
1070
- app.io.say("");
1071
- }
1072
-
1073
- async function printSession(app: App): Promise<void> {
1074
- const C = app.io.palette;
1075
- const cursor = await readCursor(app.logDir);
1076
- app.io.say(C.bold("\nsession"));
1077
- app.io.say(` id ${basename(app.logDir)}`);
1078
- app.io.say(` name ${cursor?.name ?? "(unnamed)"}`);
1079
- app.io.say(` path ${app.logDir}`);
1080
- app.io.say(` node ${app.session.state.node}`);
1081
- app.io.say(` events ${app.session.state.events.length}`);
1082
- app.io.say(` model ${app.providerConfig.spec.id}`);
1083
- printUsage(app);
1084
- }
1085
-
1086
- async function printSessionList(app: App): Promise<void> {
1087
- const C = app.io.palette;
1088
- const { sessions, truncated } = await listSessions(app.workspace, sessionDirOf(app), Infinity);
1089
- const note = truncationNote(truncated);
1090
- app.io.say(C.bold("\nsessions"));
1091
- if (note) app.io.say(C.yellow(` ${note}`));
1092
- if (sessions.length === 0) {
1093
- app.io.say(C.dim(" none yet -- /new starts one"));
1094
- app.io.say("");
1095
- return;
1096
- }
1097
- for (const s of sessions) {
1098
- if (!s.ok) {
1099
- app.io.say(C.red(` ${s.id} unreadable ${s.error}`));
1100
- continue;
1101
- }
1102
- // A session left without a word is noise here; the current one stays (fix-skip-empty-sessions).
1103
- if (s.path !== app.logDir && !await hasConversation(s.path)) continue;
1104
- const here = s.path === app.logDir ? C.cyan(" now ") : " ";
1105
- const label = (s.name ?? s.id).padEnd(24);
1106
- app.io.say(` ${here} ${label}${C.dim(`${s.events} events ${s.model ?? ""}`)}`);
1107
- }
1108
- app.io.say(C.dim("\n /resume <id> to switch\n"));
1109
- }
1110
-
1111
- /**
1112
- * Lists somewhere to go back to, newest last, numbered for typing.
1113
- *
1114
- * Numbered rather than addressed by node and event id: the address is an
1115
- * implementation detail and nobody can type `e00017`. The numbering is of
1116
- * the list as printed, so it is re-read on every command rather than
1117
- * remembered -- a rewind changes the ancestry and with it the list.
1118
- */
1119
- async function printBack(app: App): Promise<void> {
1120
- const C = app.io.palette;
1121
- const points = await app.session.restorePoints();
1122
- app.io.say(C.bold("\nsomewhere to go back to") + C.dim(" (newest last)"));
1123
- if (points.length === 0) {
1124
- app.io.say(C.dim(" nothing yet -- no exchange has changed a file"));
1125
- }
1126
- points.forEach((p, i) => {
1127
- const n = String(i + 1).padStart(2);
1128
- const files = `${p.changed} file${p.changed === 1 ? "" : "s"}`;
1129
- // Off-path entries are branches this session has left, usually by
1130
- // rewinding. Listed, because a node that survives on disk and cannot be
1131
- // found again is lost in every sense that matters to a person.
1132
- const mark = p.onPath ? " " : C.yellow("↩ ");
1133
- app.io.say(` ${C.bold(n)} ${mark} ${C.dim(files.padEnd(9))}${firstLine(p.prompt, 60)}`);
1134
- });
1135
- if (points.some((p) => !p.onPath)) {
1136
- app.io.say(C.dim(`\n ${C.yellow("↩")} left behind by an earlier /back -- still reachable`));
1137
- }
1138
- app.io.say(C.dim("\n /back <n> move the files and the conversation"));
1139
- app.io.say(C.dim(" /back <n> files move the files, keep what was learned"));
1140
- app.io.say(C.dim(" /back <n> context move the conversation, keep the code"));
1141
- // Restated from the write-ups rather than left for the person to work
1142
- // out, because picking wrong is how a rewind makes things worse: the
1143
- // documented trap is keeping bad code with no memory of writing it.
1144
- app.io.say(C.dim("\n code broken + model confused -> plain /back"));
1145
- app.io.say(C.dim(" code broken, model was right -> files"));
1146
- app.io.say(C.dim(" code fine, model went wrong -> context\n"));
1147
- }
1148
-
1149
- /**
1150
- * Non-tty /tree: the same layout as the live view, committed to scrollback.
1151
- * Jumping still needs the TUI; this is so a pipe can see the structure.
1152
- */
1153
- async function printTree(app: App): Promise<void> {
1154
- const rows = flattenTree(await treeNodesFor(app));
1155
- if (!rows.length) { app.io.say(EMPTY_TREE_MESSAGE); return; }
1156
- for (const row of rows) {
1157
- const mark = row.node.current ? "● " : row.node.onPath ? " " : `${LEFT_BRANCH_MARK} `;
1158
- app.io.say(row.branch + mark + (row.node.label ? `[${row.node.label}] ` : "") + firstLine(row.node.prompt, 72));
1159
- }
1160
- }
1161
-
1162
- async function treeNodesFor(app: App): Promise<TreeNode[]> {
1163
- const parents = await loadNodeParents(app.logDir);
1164
- const manifest = await readManifest(app.logDir);
1165
- const history = await readNodeHistory(app.logDir, app.session.state.node);
1166
- if (!history.ok) throw new Error(history.error);
1167
- const onPath = new Set(history.nodes.flatMap(n => n.events.map(e => `${n.node}:${e.id}`)));
1168
- const points: TreePoint[] = [];
1169
- for (const node of parents.keys()) {
1170
- const { events, corrupt } = await readNode(app.logDir, node);
1171
- if (corrupt.length) throw new Error("Cannot display corrupt session events");
1172
- for (const event of events) {
1173
- const content = event.t === "user" ? [event.text, ...(event.images ?? []).map(i => `[Image: ${i.name}]`)].filter(Boolean).join("\n") :
1174
- event.t === "assistant" ? event.blocks.map(b => b.type === "tool_use" ? `tool · ${b.name} ${JSON.stringify(b.input)}` : `${b.type === "thinking" ? "thinking · " : ""}${b.text}`).join("\n") :
1175
- event.t === "tool_result" ? event.content : event.t === "user_bash" ? `$ ${event.command}\n${event.content}` :
1176
- event.t === "cut" ? `context boundary · ${event.reason}` : JSON.stringify(event);
1177
- const tool = event.t === "tool_result" || event.t === "user_bash" || event.t === "assistant" && event.blocks.every(b => b.type === "tool_use");
1178
- const category = tool ? "tool" : event.t === "user" || event.t === "assistant" ? "message" : "metadata";
1179
- const label = manifest.labels?.[event.id];
1180
- points.push({ node, atEvent: event.id, target: event.id, prompt: `${event.t === "user" ? "" : event.t === "assistant" ? "assistant · " : `${event.t} · `}${firstLine(content, 160)}`,
1181
- onPath: onPath.has(`${node}:${event.id}`), kind: event.t === "user" ? "user" : "other", category, content, at: event.at,
1182
- ...(label ? { label: label.text, labelAt: label.at } : {}) });
1183
- }
1184
- }
1185
- return treeFromPoints(points, parents, app.session.state.node);
1186
- }
1187
-
1188
- async function loadNodeParents(logDir: string): Promise<Map<number, NodeParent>> {
1189
- const map = new Map<number, NodeParent>();
1190
- for (const id of await listNodes(logDir)) {
1191
- const { header } = await readNode(logDir, id);
1192
- if (header === null) continue;
1193
- if (header.parent === null) {
1194
- map.set(id, null);
1195
- continue;
1196
- }
1197
- map.set(
1198
- id,
1199
- header.parent.at_event === undefined
1200
- ? { node: header.parent.node }
1201
- : { node: header.parent.node, at_event: header.parent.at_event },
1202
- );
1203
- }
1204
- return map;
1205
- }
1206
-
1207
- /**
1208
- * Goes back: the files and the conversation move together.
1209
- *
1210
- * Preview first, always. The half of this that nobody asked for is the
1211
- * deletion of everything the target did not have -- including a file a
1212
- * person wrote by hand while the session sat idle -- and that half is
1213
- * invisible in the request.
1214
- */
1215
- async function printRewind(
1216
- app: App,
1217
- point: RestorePoint,
1218
- mode: RewindMode,
1219
- confirmed: boolean,
1220
- ): Promise<void> {
1221
- const C = app.io.palette;
1222
- const label = firstLine(point.prompt, 60);
1223
- const plan = await app.session.previewRewind(point, mode);
1224
- if (!plan.ok) {
1225
- app.io.say(C.red(`\n cannot go back: ${plan.error}\n`));
1226
- return;
1227
- }
1228
-
1229
- const what =
1230
- mode === "both" ? "the files and the conversation"
1231
- : mode === "files" ? "the files only"
1232
- : "the conversation only";
1233
- app.io.say(C.bold("\ngo back to") + ` ${label}` + C.dim(` (${what})`));
1234
-
1235
- if (mode === "context") {
1236
- // Nothing on disk changes, so listing what the target HAD would read
1237
- // like a plan to write it. What matters is the gap the model is about
1238
- // to be told about.
1239
- const extra = plan.willDelete;
1240
- app.io.say(C.dim(` no file is touched`));
1241
- for (const p of extra.slice(0, 20)) app.io.say(C.yellow(` stays ${p} (not in that context)`));
1242
- if (extra.length > 20) app.io.say(C.yellow(` ... ${extra.length - 20} more`));
1243
- } else {
1244
- for (const p of plan.willWrite.slice(0, 20)) app.io.say(C.dim(` restore ${p}`));
1245
- if (plan.willWrite.length > 20) app.io.say(C.dim(` ... ${plan.willWrite.length - 20} more`));
1246
- for (const p of plan.willDelete.slice(0, 20)) app.io.say(C.red(` delete ${p}`));
1247
- if (plan.willDelete.length > 20) app.io.say(C.red(` ... ${plan.willDelete.length - 20} more`));
1248
- }
1249
-
1250
- if (!confirmed) {
1251
- app.io.say(C.yellow('\n nothing changed. add "confirm" to go back.'));
1252
- if (mode === "both") {
1253
- app.io.say(C.dim(" the turns after this point stop being what the model sees."));
1254
- app.io.say(C.dim(" They are not deleted -- this list will still offer them."));
1255
- } else {
1256
- app.io.say(C.dim(" the two will disagree afterwards. The model is told which"));
1257
- app.io.say(C.dim(" paths, and to re-read before editing -- it is not left to"));
1258
- app.io.say(C.dim(" work it out from a context that no longer matches disk."));
1259
- }
1260
- app.io.say("");
1261
- return;
1262
- }
1263
-
1264
- const done = await app.session.rewind(point, mode);
1265
- if (!done.ok) {
1266
- app.io.say(C.red(`\n could not go back: ${done.error}\n`));
1267
- return;
1268
- }
1269
- if (done.unrecoverable.length > 0) {
1270
- app.io.say(C.red(`\n restore not applied: missing content for ${done.unrecoverable.join(", ")}`));
1271
- app.io.say(C.dim(` ${done.note}\n`));
1272
- return;
1273
- }
1274
- app.io.say(
1275
- C.green(`\n back at node ${done.node}`) +
1276
- C.dim(
1277
- mode === "context"
1278
- ? " no file touched"
1279
- : ` ${done.restored.length} restored, ${done.removed.length} removed`,
1280
- ),
1281
- );
1282
- if (done.unrecoverable.length > 0) {
1283
- app.io.say(C.red(` content no longer stored: ${done.unrecoverable.join(", ")}`));
1284
- }
1285
- app.io.say(C.dim(indent(wrap(done.note, 72), " ")));
1286
- app.io.say("");
1287
- }
1288
-
1289
- function printModels(app: App): void {
1290
- const C = app.io.palette;
1291
- app.io.say(C.bold("\nmodels"));
1292
- for (const group of groupedModels(app.catalog)) {
1293
- app.io.say(C.bold(` ${group.provider}`));
1294
- for (const spec of group.models) {
1295
- const got = app.credentialOf(spec);
1296
- const here = modelKey(spec) === modelKey(app.providerConfig.spec);
1297
- const mark = here ? C.cyan(" now ") : got.ok ? C.green("ready") : C.dim(" -- ");
1298
- app.io.say(` ${mark} ${spec.id.padEnd(28)}${C.dim(spec.name.padEnd(28))}${whyNot(spec, got, app.auth, C)}`);
1299
- }
1300
- }
1301
- app.io.say(C.dim("\n /model <id> to switch. Switching cuts this node so the next"));
1302
- app.io.say(C.dim(" model starts from a handoff, not from this model's thinking blocks.\n"));
1303
- }
1304
-
1305
- async function refreshModels(app: App): Promise<void> {
1306
- const C = app.io.palette;
1307
- const result = await runModelsRefresh({
1308
- fetch: globalThis.fetch,
1309
- write: writeCatalogCache,
1310
- });
1311
- if (!result.ok) {
1312
- app.io.say(C.yellow(` ${result.reason}`));
1313
- app.io.say(C.dim(" existing models.json left unchanged\n"));
1314
- return;
1315
- }
1316
- const loaded = loadCatalog(await readModelSources());
1317
- await publishModelCatalog(app, loaded.models);
1318
- for (const warning of loaded.warnings) app.io.say(C.yellow(` ${warning}`));
1319
- app.io.say(
1320
- C.green(` added ${result.added}, changed ${result.changed} (${result.count} total) → ${catalogCachePath()}`),
1321
- );
1322
- app.io.say(C.dim(` ${app.catalog.length} reachable after overlay\n`));
1323
- }
1324
-
1325
- /** Stores a key under a provider, 0600, after saying what is about to happen. */
1326
- async function login(app: App, provider: string, key: string): Promise<void> {
1327
- const C = app.io.palette;
1328
- if (!app.catalog.some((m) => m.provider === provider)) {
1329
- app.io.say(C.red(` unknown provider: ${provider}`));
1330
- app.io.say(C.dim(` known: ${[...new Set(app.catalog.map((m) => m.provider))].join(", ")}\n`));
1331
- return;
1332
- }
1333
- const next = { ...app.auth, [provider]: { type: "api_key" as const, key } };
1334
- await writeAuthFile(next);
1335
- app.auth[provider] = next[provider]!;
1336
- // The editor masked the paste. Printing `key` here would be the only
1337
- // remaining way it reaches scrollback.
1338
- app.io.say(C.green(` stored ${provider} in ${authPath()}`) + C.dim(" (mode 0600)"));
1339
- const ready = app.catalog.filter((m) => m.provider === provider && app.credentialOf(m).ok);
1340
- if (ready.length === 0) {
1341
- app.io.say(C.yellow(" stored, but it still does not resolve -- check the value.\n"));
1342
- return;
1343
- }
1344
- app.io.say(C.dim(` now ready: ${ready.map((m) => m.id).join(", ")}`));
1345
- await adoptCredential(app, ready);
1346
- app.io.say(C.dim(" this session can send now.\n"));
1347
- }
1348
-
1349
- async function logout(app: App, provider: string | undefined): Promise<void> {
1350
- const C = app.io.palette;
1351
- if (provider === undefined) {
1352
- const names = Object.keys(app.auth);
1353
- if (names.length === 0) {
1354
- app.io.say(C.dim(" nothing stored\n"));
1355
- return;
1356
- }
1357
- app.io.say(C.bold("\nlogout"));
1358
- for (const name of names) app.io.say(` ${name}`);
1359
- app.io.say(C.dim("\n /logout <provider>\n"));
1360
- return;
1361
- }
1362
- if (app.auth[provider] === undefined) {
1363
- app.io.say(C.dim(` no stored credential for ${provider}\n`));
1364
- return;
1365
- }
1366
- const next = { ...app.auth };
1367
- delete next[provider];
1368
- await writeAuthFile(next);
1369
- delete app.auth[provider];
1370
- const resolved = app.credentialOf(app.providerConfig.spec);
1371
- app.credential = resolved;
1372
- app.session.provider = makeProvider(app, app.providerConfig, resolved.ok ? resolved.key : "");
1373
- app.io.say(C.green(` removed ${provider} from ${authPath()}\n`));
1374
- }
1375
-
1376
- function providerOAuthOpts(app: App, spec: ModelSpec): { oauth: true } | undefined {
1377
- if (app.args.apiKey.length > 0) return undefined;
1378
- return storedIsOAuth(app.auth, spec.provider) ? { oauth: true } : undefined;
1379
- }
1380
-
1381
- function makeProvider(app: App, config: ProviderConfig, key: string) {
1382
- return createProvider(config, key, retryHandler(app.io), { sessionId: providerSessionId(app.session.config.logDir), ...providerOAuthOpts(app, config.spec), effects: app.effects });
1383
- }
1384
-
1385
- interface PendingOAuth {
1386
- id: SubscriptionId;
1387
- provider: string;
1388
- state: string;
1389
- verifier: string;
1390
- redirectUri: string;
1391
- exchange: "anthropic" | "openai";
1392
- server: CallbackServer;
1393
- done: boolean;
1394
- tui: Ui | null;
1395
- abort: AbortController;
1396
- /** Closes the UI's waiting dialog, if any. */
1397
- close?: () => void;
1398
- }
1399
-
1400
- // Authentication is scoped to its application, independent of the model/session log.
1401
- const pendingLogins = new WeakMap<App, PendingOAuth>();
1402
- const pendingDevices = new WeakMap<App, { abort: AbortController; tui: Ui | null; close?: () => void }>();
1403
- const loginEffects = { fetch: ((input, init) => globalThis.fetch(input, init)) as typeof globalThis.fetch,
1404
- openBrowser: tryOpenBrowser, callback: startCallbackServer, device: loginGitHubCopilot };
1405
-
1406
- async function cancelPendingOAuth(app: App): Promise<void> {
1407
- const pending = pendingLogins.get(app), device = pendingDevices.get(app);
1408
- pendingLogins.delete(app); pendingDevices.delete(app);
1409
- device?.abort.abort(); pending?.abort.abort();
1410
- pending?.close?.(); device?.close?.();
1411
- if (pending) { pending.done = true; await pending.server.close(); }
1412
- }
1413
-
1414
- async function storeOAuth(app: App, provider: string, credential: OAuthCredential): Promise<void> {
1415
- const C = app.io.palette;
1416
- await writeAuthFile({ ...app.auth, [provider]: credential });
1417
- app.auth[provider] = credential;
1418
- app.io.say(C.green(` stored ${provider} in ${authPath()}`) + C.dim(" (mode 0600)"));
1419
- const ready = app.catalog.filter((m) => m.provider === provider && app.credentialOf(m).ok);
1420
- if (ready.length === 0) {
1421
- app.io.say(C.dim(" stored. No catalog model uses this provider yet.\n"));
1422
- return;
1423
- }
1424
- app.io.say(C.dim(` now ready: ${ready.map((m) => m.id).join(", ")}`));
1425
- await adoptCredential(app, ready);
1426
- app.io.say(C.dim(" this session can send now.\n"));
1427
- }
1428
-
1429
- async function finishOAuthCode(app: App, pending: PendingOAuth, code: string): Promise<void> {
1430
- if (pending.done) return;
1431
- pending.done = true;
1432
- const effects = pending.tui?.oauthEffects ?? loginEffects;
1433
- try {
1434
- const credential =
1435
- pending.exchange === "anthropic"
1436
- ? await exchangeAnthropicCode({
1437
- code,
1438
- state: pending.state,
1439
- verifier: pending.verifier,
1440
- redirectUri: pending.redirectUri,
1441
- fetch: effects.fetch,
1442
- signal: pending.abort.signal,
1443
- })
1444
- : await exchangeOpenAICode({
1445
- code,
1446
- verifier: pending.verifier,
1447
- redirectUri: pending.redirectUri,
1448
- fetch: effects.fetch,
1449
- signal: pending.abort.signal,
1450
- });
1451
- if (pending.abort.signal.aborted || pendingLogins.get(app) !== pending) return;
1452
- await storeOAuth(app, pending.provider, credential);
1453
- } catch (err) {
1454
- if (pending.abort.signal.aborted || pendingLogins.get(app) !== pending) return;
1455
- const message = err instanceof Error ? err.message : "token exchange failed";
1456
- app.io.say(app.io.palette.red(` ${message}`));
1457
- app.io.say(app.io.palette.dim(" nothing was stored.\n"));
1458
- } finally {
1459
- if (pendingLogins.get(app) === pending) {
1460
- pendingLogins.delete(app);
1461
- pending.close?.();
1462
- }
1463
- await pending.server.close();
1464
- }
1465
- }
1466
-
1467
- async function completeOAuthPaste(app: App, input: string): Promise<void> {
1468
- const pending = pendingLogins.get(app);
1469
- if (!pending || pending.done) return;
1470
- const parsed = parseAuthorizationInput(input);
1471
- if (parsed.state !== undefined && parsed.state !== pending.state) {
1472
- app.io.say(app.io.palette.red(" OAuth state mismatch"));
1473
- await cancelPendingOAuth(app);
1474
- return;
1475
- }
1476
- if (!parsed.code) {
1477
- app.io.say(app.io.palette.red(" Missing authorization code"));
1478
- await cancelPendingOAuth(app);
1479
- return;
1480
- }
1481
- await finishOAuthCode(app, pending, parsed.code);
1482
- }
1483
-
1484
- function tryOpenBrowser(url: string): void {
1485
- try {
1486
- Bun.spawn(["open", url], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });
1487
- } catch {
1488
- // Printed URL is the fallback.
1489
- }
1490
- }
1491
-
1492
- async function startSubscriptionLogin(app: App, id: string, tui: Ui | null): Promise<Outcome> {
1493
- const C = app.io.palette;
1494
- const sub = subscriptionById(id);
1495
- if (sub === undefined) return "continue";
1496
- await cancelPendingOAuth(app);
1497
- const effects = tui?.oauthEffects ?? loginEffects;
1498
- app.io.say(C.yellow(` ${SUBSCRIPTION_NOTICE}`));
1499
- if (sub.id === "github-copilot" || sub.id === "grok") {
1500
- const pending: { abort: AbortController; tui: Ui | null; close?: () => void } = { abort: new AbortController(), tui };
1501
- pendingDevices.set(app, pending);
1502
- const work = async () => { try {
1503
- const credential = await (sub.id === "grok" ? loginGrok : effects.device)({
1504
- notify: (message) => {
1505
- if (pendingDevices.get(app) !== pending) return;
1506
- if (tui) { pending.close?.(); pending.close = tui.waiting(sub.label, message.split("\n"), () => { void cancelPendingOAuth(app); }); }
1507
- else app.io.say(` ${message}`);
1508
- },
1509
- prompt: async () => "",
1510
- fetch: effects.fetch,
1511
- signal: pending.abort.signal,
1512
- openBrowser: effects.openBrowser,
1513
- });
1514
- if (pending.abort.signal.aborted || pendingDevices.get(app) !== pending) return;
1515
- await storeOAuth(app, sub.provider, credential);
1516
- } catch (err) {
1517
- if (pending.abort.signal.aborted || pendingDevices.get(app) !== pending) return;
1518
- const message = err instanceof Error ? err.message : "login failed";
1519
- app.io.say(C.red(` ${message}\n`));
1520
- } finally {
1521
- if (pendingDevices.get(app) === pending) {
1522
- pendingDevices.delete(app);
1523
- pending.close?.();
1524
- }
1525
- } };
1526
- if (tui) { pending.close = tui.waiting(sub.label, ["Requesting a device code…"], () => { void cancelPendingOAuth(app); }); tui.runExtensionAction(work); return "continue"; }
1527
- await work();
1528
- return "continue";
1529
- }
1530
- const pkce = await generatePKCE();
1531
- const state = generateState();
1532
- const anthropic = sub.id === "claude-pro";
1533
- const url = anthropic
1534
- ? anthropicAuthorizeUrl({ challenge: pkce.challenge, state })
1535
- : openaiCodexAuthorizeUrl({ challenge: pkce.challenge, state });
1536
- let server: CallbackServer;
1537
- try {
1538
- server = await effects.callback({
1539
- expectedState: state,
1540
- host: "127.0.0.1",
1541
- port: anthropic ? ANTHROPIC_CALLBACK_PORT : OPENAI_CALLBACK_PORT,
1542
- path: anthropic ? ANTHROPIC_CALLBACK_PATH : OPENAI_CALLBACK_PATH,
1543
- });
1544
- } catch (err) {
1545
- const message = err instanceof Error ? err.message : "could not bind callback port";
1546
- app.io.say(C.yellow(` ${message}`));
1547
- app.io.say(C.dim(" paste the redirect URL after signing in.\n"));
1548
- // Still allow paste without a server: we just won't get a browser callback.
1549
- server = {
1550
- host: "127.0.0.1",
1551
- port: 0,
1552
- waitForCode: () => new Promise(() => {}),
1553
- close: async () => {},
1554
- };
1555
- }
1556
- if (!tui) {
1557
- app.io.say(` ${url}`);
1558
- app.io.say(C.dim(" complete login in the browser, or paste the redirect URL. It is not shown."));
1559
- }
1560
- effects.openBrowser(url);
1561
- const pending: PendingOAuth = {
1562
- id: sub.id,
1563
- provider: sub.provider,
1564
- state,
1565
- verifier: pkce.verifier,
1566
- redirectUri: anthropic ? ANTHROPIC_REDIRECT_URI : OPENAI_REDIRECT_URI,
1567
- exchange: anthropic ? "anthropic" : "openai",
1568
- server,
1569
- done: false,
1570
- tui,
1571
- abort: new AbortController(),
1572
- };
1573
- pendingLogins.set(app, pending);
1574
- void pending.server.waitForCode().then(async (got) => {
1575
- if (!got.ok || pending.done) return;
1576
- await finishOAuthCode(app, pending, got.code);
1577
- });
1578
- if (tui) {
1579
- // The URL is shown (it is not a secret); the pasted redirect is, so it goes through a masked field.
1580
- pending.close = tui.waiting(sub.label, ["Finish signing in in your browser.", "", url, "", "Or paste the redirect URL here if the browser cannot reach this machine."],
1581
- () => { void cancelPendingOAuth(app); }, "Redirect URL", value => tui.runExtensionAction(() => completeOAuthPaste(app, value)));
1582
- return "continue";
1583
- }
1584
- return { login: OAUTH_PASTE };
1585
- }
1586
-
1587
- /**
1588
- * Make the just-stored key the one the next prompt uses, without a restart.
1589
- *
1590
- * Same model: swap the adapter in place (no thinking blocks to isolate).
1591
- * Placeholder session that never sent: swap onto an unlocked model in place
1592
- * so the first prompt is the first event, not a cut of an empty node.
1593
- * Already talking to a different live model: /model is the cut.
1594
- */
1595
- async function adoptCredential(app: App, ready: readonly ModelSpec[]): Promise<void> {
1596
- const C = app.io.palette;
1597
- const currentGot = app.credentialOf(app.providerConfig.spec);
1598
- if (currentGot.ok) {
1599
- app.session.provider = makeProvider(app, app.providerConfig, currentGot.key);
1600
- app.credential = currentGot;
1601
- return;
1602
- }
1603
- const spec = ready[0];
1604
- if (spec === undefined) return;
1605
- const got = app.credentialOf(spec);
1606
- if (!got.ok) return;
1607
- if (app.credential.ok || app.session.state.handoff !== null || app.session.state.events.some((e) => e.t === "assistant")) {
1608
- await switchToModel(app, spec, undefined, "session");
1609
- return;
1610
- }
1611
- const nextConfig: ProviderConfig = {
1612
- spec,
1613
- maxTokens: outputTokenBudget(spec),
1614
- effort: clampThinkingLevel(spec, app.ui?.settings.modelThinkingLevels?.[modelKey(spec)] ?? app.providerConfig.effort),
1615
- thinkingDisplay: app.providerConfig.thinkingDisplay,
1616
- };
1617
- app.session.provider = makeProvider(app, nextConfig, got.key);
1618
- app.providerConfig = nextConfig;
1619
- app.credential = got;
1620
- const cursor = await readCursor(app.logDir);
1621
- if (cursor !== null) {
1622
- await writeCursor(app.logDir, {
1623
- ...cursor,
1624
- model: spec.id,
1625
- provider: spec.provider,
1626
- thinking: nextConfig.effort,
1627
- updated_at: app.session.clock.now(),
1628
- });
1629
- }
1630
- app.io.say(C.green(` now ${spec.name}`) + C.dim(` ${spec.id}`));
1631
- }
1632
-
1633
- async function switchByPattern(app: App, pattern: string, origin: PreferenceOrigin = "user"): Promise<void> {
1634
- const C = app.io.palette;
1635
- const found = scopedModels([pattern], app.catalog, true), selected = found.entries[0];
1636
- if (!selected) {
1637
- const diagnostic = findModelByPattern(pattern, app.catalog);
1638
- app.io.say(C.red(` ${diagnostic.ok ? found.diagnostics.join("; ") : diagnostic.reason}`));
1639
- return;
1640
- }
1641
- await switchToModel(app, selected.model, selected.thinking, origin);
1642
- }
1643
-
1644
- export async function switchToModel(app: App, spec: ModelSpec, thinking?: Effort, origin: PreferenceOrigin = "user"): Promise<void> {
1645
- const C = app.io.palette;
1646
- const got = app.credentialOf(spec);
1647
- if (!got.ok) {
1648
- app.io.say(C.red(`\n ${got.reason}\n`));
1649
- if (!got.reason.includes("/provider")) {
1650
- app.io.say(C.dim(` ${describeMissingCredential(spec.provider)}\n`));
1651
- }
1652
- return;
1653
- }
1654
- if (spec.id === app.providerConfig.spec.id && spec.provider === app.providerConfig.spec.provider) {
1655
- if (thinking) await applyThinking(app, clampThinkingLevel(spec, thinking), null, "session");
1656
- if (origin === "user") await rememberModelSelection(app, spec, thinking);
1657
- app.io.say(C.dim(` already on ${spec.id}`));
1658
- return;
1659
- }
1660
- const nextConfig: ProviderConfig = {
1661
- spec,
1662
- maxTokens: outputTokenBudget(spec),
1663
- effort: clampThinkingLevel(spec, thinking ?? app.ui?.settings.modelThinkingLevels?.[modelKey(spec)] ?? app.providerConfig.effort),
1664
- thinkingDisplay: app.providerConfig.thinkingDisplay,
1665
- };
1666
- const next = makeProvider(app, nextConfig, got.key);
1667
- try {
1668
- if (await app.session.switchModel(next)) app.io.say(C.dim(" new segment: the next model starts from a handoff, not this model's thinking"));
1669
- } catch (err) {
1670
- app.io.say(C.red(` cannot switch: ${describeError(err)}`));
1671
- return;
1672
- }
1673
- app.providerConfig = nextConfig;
1674
- app.credential = got;
1675
- app.io.say(C.green(` now ${spec.name}`) + C.dim(` ${spec.id}`));
1676
- if (origin === "user") await rememberModelSelection(app, spec, thinking);
1677
- }
1678
-
1679
- /** N.8 must accept the checkpoint before a user choice can own the next startup. */
1680
- async function rememberModelSelection(app: App, spec: ModelSpec, explicitThinking: Effort | undefined): Promise<void> {
1681
- if (!app.ui) return;
1682
- const effort = app.providerConfig.effort;
1683
- const patch = modelPreferencePatch(spec, app.ui.settings.recentModels ?? [], effort);
1684
- await commitUserSettings(app, explicitThinking === undefined ? patch : { ...patch, ...thinkingPreferencePatch(spec, effort) });
1685
- }
1686
-
1687
- function sessionDirOf(app: App): string | undefined {
1688
- return sessionDirFor(app.args, app.workspace);
1689
- }
1690
-
1691
- async function openNewSession(app: App, tui?: Ui): Promise<void> {
1692
- const C = app.io.palette;
1693
- const created = await createSession(app.workspace, undefined, sessionDirOf(app));
1694
- const switched = await adoptLogDir(app, created.path);
1695
- if (!switched) {
1696
- app.io.say(C.red(` cannot start a new session`));
1697
- return;
1698
- }
1699
- if (tui) { tui.sessionName = null; await tui.syncTranscript(); }
1700
- app.io.say(C.green(` new session ${created.id}`) + C.dim(` ${created.path}`));
1701
- }
1702
-
1703
- async function resumeSession(app: App, idOrPath: string, tui?: Ui): Promise<void> {
1704
- const C = app.io.palette;
1705
- const found = await findSessionByPrefix(app.workspace, idOrPath, sessionDirOf(app));
1706
- if (!found.ok) {
1707
- app.io.say(C.red(` ${found.reason}. /resume lists them.`));
1708
- return;
1709
- }
1710
- if (found.session.path === app.logDir) {
1711
- app.io.say(C.dim(" already in this session"));
1712
- return;
1713
- }
1714
- const switched = await adoptLogDir(app, found.session.path);
1715
- if (!switched) {
1716
- app.io.say(C.red(` cannot resume`));
1717
- return;
1718
- }
1719
- if (tui) { tui.sessionName = found.session.name ?? null; await tui.syncTranscript(); }
1720
- app.io.say(C.green(` resumed ${found.session.name ?? found.session.id}`) + C.dim(` ${found.session.path}`));
1721
- }
1722
-
1723
- async function adoptLogDir(app: App, logDir: string): Promise<boolean> {
1724
- try {
1725
- await switchAppSession(app, logDir, (current, target) => openSessionFor({ ...current, logDir: target, fallback: current.providerConfig, interactive: true }));
1726
- } catch (err) {
1727
- app.io.say(app.io.palette.red(` ${describeError(err)}`));
1728
- return false;
1729
- }
1730
- return true;
1731
- }
1732
-
1733
- async function transcriptEvents(app: App): Promise<LogEvent[]> {
1734
- const history = await readNodeHistory(app.logDir, app.session.state.node);
1735
- if (!history.ok) throw new Error(history.error);
1736
- return history.nodes.flatMap((node) => node.events);
1737
- }
1738
-
1739
- async function exportSession(app: App, file?: string): Promise<void> {
1740
- const C = app.io.palette;
1741
- const events = await transcriptEvents(app);
1742
- const defaultPath = join(app.workspace, `${basename(app.logDir)}.html`);
1743
- const dest = file !== undefined && file.length > 0 ? file : defaultPath;
1744
- const abs = dest.startsWith("/") ? dest : join(app.workspace, dest);
1745
- const html = abs.toLowerCase().endsWith(".jsonl") === false;
1746
- try {
1747
- if (html) await writeFile(abs, toHtml(events, {
1748
- title: basename(app.logDir),
1749
- exportedAt: app.session.clock.now(),
1750
- sessionId: basename(app.logDir),
1751
- }), "utf8");
1752
- else await exportSessionArchive(app.logDir, abs, app.blobDir);
1753
- } catch (err) {
1754
- app.io.say(C.red(` cannot write ${abs}: ${describeError(err)}`));
1755
- return;
1756
- }
1757
- app.io.say(C.green(` exported ${abs}`));
1758
- }
1759
-
1760
- async function copyLastReply(app: App, tui?: Ui): Promise<void> {
1761
- const C = app.io.palette;
1762
- const text = lastAssistantText(await transcriptEvents(app));
1763
- if (text === null) {
1764
- app.io.say(C.dim(" no assistant reply to copy yet"));
1765
- return;
1766
- }
1767
- const copied = await (tui?.clipboardWriter ?? writeClipboard)(text);
1768
- if (copied) {
1769
- app.io.say(C.green(" copied last assistant reply"));
1770
- return;
1771
- }
1772
- app.io.say(C.yellow(" Clipboard unavailable. Printing the reply instead:"));
1773
- app.io.say(text);
1774
- }
1775
-
1776
- async function forkSession(app: App, arg?: string, tui?: Ui): Promise<void> {
1777
- const C = app.io.palette;
1778
- const points = await app.session.userMessages();
1779
- if (arg === undefined) {
1780
- app.io.say(C.bold("\nfork from a user turn") + C.dim(" (newest last)"));
1781
- if (points.length === 0) {
1782
- app.io.say(C.dim(" nothing yet -- send a prompt first"));
1783
- app.io.say("");
1784
- return;
1785
- }
1786
- points.forEach((p, i) => {
1787
- const n = String(i + 1).padStart(2);
1788
- app.io.say(` ${C.bold(n)} ${firstLine(p.text, 60)}`);
1789
- });
1790
- app.io.say(C.dim("\n /fork <n> resumes before that user message and restores its draft\n"));
1791
- return;
1792
- }
1793
- const i = Number(arg);
1794
- const point = Number.isInteger(i) ? points[i - 1] : undefined;
1795
- if (!point) {
1796
- app.io.say(C.red(` no such turn: ${arg}. /fork lists them.`));
1797
- return;
1798
- }
1799
- if (await spawnForkedSession(app, point.id, `forked before ${firstLine(point.text, 40)}`, "before")) {
1800
- await tui?.syncTranscript();
1801
- tui?.restoreDraft(point.text, point.images ?? []);
1802
- tui?.redraw();
1803
- }
1804
- }
1805
-
1806
- async function cloneSession(app: App): Promise<void> {
1807
- await spawnForkedSession(app, undefined, "cloned");
1808
- }
1809
-
1810
- async function spawnForkedSession(app: App, untilEvent: string | undefined, label: string, position: "at" | "before" = "at"): Promise<boolean> {
1811
- const C = app.io.palette;
1812
- const created = await createSession(app.workspace, undefined, sessionDirOf(app));
1813
- try {
1814
- await app.session.fork(created.path, untilEvent, position);
1815
- } catch (err) {
1816
- app.io.say(C.red(` cannot fork: ${describeError(err)}`));
1817
- return false;
1818
- }
1819
- const switched = await adoptLogDir(app, created.path);
1820
- if (!switched) return false;
1821
- app.io.say(C.green(` ${label} → ${created.id}`) + C.dim(` ${created.path}`));
1822
- return true;
1823
- }
1824
-
1825
- async function trustProject(app: App, target: TrustChoice, tui?: Ui): Promise<void> {
1826
- const C = app.io.palette;
1827
- const dir = target === "parent" ? dirname(app.workspace) : app.workspace;
1828
- const trusted = await applyTrustChoice(app.workspace, target);
1829
- app.projectTrusted = trusted;
1830
- app.io.say(
1831
- C.green(` ${trusted ? "trusted" : "will not trust"} ${dir}`) +
1832
- C.dim(target === "once" ? " for this session" : ` saved in ${trustPath()}`),
1833
- );
1834
- app.io.say(
1835
- C.dim(" Project resources reloaded. Tool permissions remain separate."),
1836
- );
1837
- await reloadAppResources(app);
1838
- await tui?.refreshResources();
1839
- }
1840
-
1841
- async function nameSession(app: App, name: string, tui?: Ui): Promise<void> {
1842
- const C = app.io.palette;
1843
- if (name.length === 0) {
1844
- app.io.say(C.dim(" /name <name>"));
1845
- return;
1846
- }
1847
- // Name lives on the cursor. sessions.ts has no setter -- writeCursor is
1848
- // the store function that actually holds it, and it keeps a name across
1849
- // the loop's own cursor rewrites.
1850
- const cursor = await readCursor(app.logDir);
1851
- if (cursor === null) {
1852
- app.io.say(C.red(" no cursor to name yet"));
1853
- return;
1854
- }
1855
- await writeCursor(app.logDir, { ...cursor, name });
1856
- if (tui) tui.sessionName = name;
1857
- app.io.say(C.green(` named ${name}`));
1858
- }
1859
-
1860
- /** Runs a prompt. Returns false if the turn failed, having already said why. */
1861
- async function run(app: App, prompt: string, signal?: AbortSignal, tui?: Ui, images: readonly ImageRef[] = [], skill?: Skill, options?: RequestOptions): Promise<boolean> {
1862
- if (app.extensions && !initializedExtensions.has(app.extensions)) await bindExtensions(app, tui);
1863
- for (const result of await app.extensions?.emit("input", { text: prompt, images, source: "interactive" }) ?? []) {
1864
- if (result["action"] === "handled") return true;
1865
- if (result["action"] === "transform" && typeof result["text"] === "string") prompt = result["text"];
1866
- }
1867
- if (prompt.startsWith("!")) {
1868
- const excluded = prompt.startsWith("!!");
1869
- if (tui) tui.beginBash(prompt.slice(excluded ? 2 : 1));
1870
- const result = await app.session.runUserBash(prompt.slice(excluded ? 2 : 1), excluded, signal,
1871
- tui ? chunk => tui.pushBash(chunk) : undefined);
1872
- tui?.observeEvent?.({ kind: "done", outcome: result.outcome });
1873
- if (tui) { await tui.syncTranscript(); tui.commitStream(); }
1874
- else app.io.say(`$ ${result.command}\n${result.content}`);
1875
- return true;
1876
- }
1877
- if (!app.credential.ok) {
1878
- // Must not call session.send: that records a user event before the
1879
- // request, and a refused prompt is not a turn.
1880
- const C = app.io.palette;
1881
- app.io.say(C.yellow(" no credential yet -- this prompt was not sent."));
1882
- app.io.say(C.dim(` ${app.credential.reason}`));
1883
- app.io.say(C.dim(" export an API key, or /provider <provider>.\n"));
1884
- tui?.restoreDraft(prompt, images);
1885
- return false;
1886
- }
1887
-
1888
- const fresh = await ensureFreshCredential(
1889
- app.providerConfig.spec,
1890
- app.auth,
1891
- app.args.apiKey.length > 0 ? app.args.apiKey : undefined,
1892
- app.effects,
1893
- {
1894
- fetch: globalThis.fetch,
1895
- now: () => Date.now(),
1896
- persist: async (next) => {
1897
- const entry = next[app.providerConfig.spec.provider];
1898
- if (entry !== undefined) app.auth[app.providerConfig.spec.provider] = entry;
1899
- await writeAuthFile(app.auth);
1900
- },
1901
- },
1902
- );
1903
- if (!fresh.ok) {
1904
- const C = app.io.palette;
1905
- app.io.say(C.red(` ${fresh.reason}`));
1906
- app.io.say(C.dim(" this prompt was not sent.\n"));
1907
- tui?.restoreDraft(prompt, images);
1908
- return false;
1909
- }
1910
- if (!app.credential.ok || fresh.key !== app.credential.key) {
1911
- app.credential = fresh;
1912
- app.session.provider = makeProvider(app, app.providerConfig, fresh.key);
1913
- }
1914
-
1915
- let input: UserInput;
1916
- try {
1917
- const needsIndex = fileReferences(prompt).length > 0;
1918
- const index: FileIndex = needsIndex
1919
- ? await fileIndexFor(app)
1920
- : { paths: [], truncated: false };
1921
- const expanded = await expandInput(prompt, {
1922
- workspace: app.workspace,
1923
- index,
1924
- say: (text) => app.io.say(text),
1925
- attachImage: path => attachImageFile(path, app.blobDir, app.ui?.settings.images?.autoResize ?? true),
1926
- });
1927
- if (!expanded.send && !images.length) return true;
1928
- const attached = [...images, ...expanded.images ?? []];
1929
- input = attached.length ? { text: expanded.message, images: attached } : expanded.message;
1930
- app.session.config.imageOptions = app.ui?.settings.images ?? {};
1931
- if (attached.length && (app.ui?.settings.images?.blockImages || !app.providerConfig.spec.input?.includes("image"))) {
1932
- app.io.say(" Images are kept in this session; this request uses text placeholders because " +
1933
- (app.ui?.settings.images?.blockImages ? "image transmission is disabled." : "the selected model does not support image input."));
1934
- }
1935
- } catch (error) {
1936
- app.io.say(" Attachment failed: " + describeError(error));
1937
- tui?.restoreDraft(prompt, images);
1938
- return false;
1939
- }
1940
- try {
1941
- await runTurn(app, input, signal, tui, skill, options);
1942
- return true;
1943
- } catch (error) {
1944
- tui?.discardStream();
1945
- // The session survives. Everything up to this turn is on disk, the next
1946
- // prompt starts from it, and killing the process over one failed request
1947
- // would throw away work that is still perfectly good.
1948
- const C = app.io.palette;
1949
- app.io.say(C.red(`\n request failed: ${describeError(error)}`));
1950
- app.io.say(C.dim(" nothing was lost -- the log is intact. Try again, or /usage.\n"));
1951
- return false;
1952
- }
1953
- }
1954
-
1955
- export type { TurnSurface };
1956
-
1957
- export async function runTurn(
1958
- app: Pick<App, "session" | "io"> & { readonly ui?: App["ui"]; readonly args?: App["args"]; readonly extensions?: App["extensions"] },
1959
- prompt: UserInput,
1960
- signal?: AbortSignal,
1961
- tui?: TurnSurface,
1962
- skill?: Skill,
1963
- options?: RequestOptions,
1964
- ): Promise<void> {
1965
- // A pipe cannot unwrite bytes. Non-tty / -p therefore does not stream:
1966
- // deltas and discard are ignored, and the final block prints once.
1967
- let live = false;
1968
- let skipFinal = false;
1969
- const json = app.args?.mode === "json";
1970
- const checkedOutput = app.session.config.validateOutput !== undefined;
1971
- let finalText: string[] = [];
1972
- let completed = false;
1973
- let failure: string | undefined;
1974
- const pull = tui?.pullQueued?.bind(tui);
1975
- const registry = app.extensions;
1976
- const before = await registry?.emit("before_agent_start", { prompt: userInputData(prompt).text, systemPrompt: app.session.config.frozen.system }) ?? [];
1977
- for (const result of before) {
1978
- if (typeof result["systemPrompt"] === "string" && result["systemPrompt"] !== app.session.config.frozen.system)
1979
- await app.session.reloadResources({ ...app.session.config.frozen, system: result["systemPrompt"] });
1980
- }
1981
- await registry?.emit("agent_start");
1982
- await registry?.emit("turn_start", { turnIndex: 0 });
1983
- try { for await (const e of skill ? app.session.sendSkill(skill, prompt, signal) : sessionControl(app.session).send(prompt, signal, pull, options)) {
1984
- // The log retains every response, but a script's final answer must be the
1985
- // accepted last response. A rejected JSON document cannot be unwritten.
1986
- if (e.kind === "turn_start" || e.kind === "discard") finalText = [];
1987
- if (e.kind === "assistant_text") finalText.push(e.text ?? "");
1988
- if (e.kind === "done") {
1989
- completed = e.outcome?.status === "completed";
1990
- if (checkedOutput && !completed) failure = e.reason ?? "output schema validation did not complete";
1991
- }
1992
- tui?.observeEvent?.(e);
1993
- if (e.kind === "text_delta" || e.kind === "thinking_delta") await registry?.emit("message_update", { assistantMessageEvent: { type: e.kind, delta: e.text } });
1994
- if (e.kind === "assistant_text") await registry?.emit("message_end", { message: { role: "assistant", content: [{ type: "text", text: e.text }] } });
1995
- if (e.kind === "done") await registry?.emit("turn_end", { reason: e.reason });
1996
- if (json) {
1997
- const payload = toJsonEvent(e, app.session.clock.now());
1998
- if (payload !== null) process.stdout.write(`${JSON.stringify(payload)}\n`);
1999
- continue;
2000
- }
2001
- if (checkedOutput && !tui && e.kind === "assistant_text") continue;
2002
- if (e.kind === "progress") { if (e.progress) tui?.pushProgress?.(e.progress); continue; }
2003
- if (tui?.syncTranscript) {
2004
- if (e.kind === "usage" && e.ok === false && e.prefix && app.ui?.settings.showCacheMissNotices === true)
2005
- app.io.say(app.io.palette.yellow(` Cache: ${e.prefix}`));
2006
- if (e.kind === "text_delta" || e.kind === "thinking_delta") {
2007
- tui.pushDelta(e.kind === "text_delta" ? "text" : "thinking", e.text ?? "");
2008
- } else {
2009
- if (e.kind === "discard") tui.discardStream();
2010
- await tui.syncTranscript();
2011
- if (["assistant_text", "thinking", "tool_call", "tool_result", "done", "cut"].includes(e.kind)) tui.commitStream();
2012
- if (e.kind === "stop_and_ask") {
2013
- for (const line of formatEvent(e, app.io.palette, Math.max(20, (process.stdout.columns ?? 80) - 1), {}, app.ui?.style)) app.io.say(line);
2014
- }
2015
- }
2016
- continue;
2017
- }
2018
- if (e.kind === "text_delta" || e.kind === "thinking_delta") {
2019
- if (tui !== undefined) {
2020
- live = true;
2021
- tui.pushDelta(e.kind === "thinking_delta" ? "thinking" : "text", e.text ?? "");
2022
- }
2023
- continue;
2024
- }
2025
- if (e.kind === "discard") {
2026
- live = false;
2027
- skipFinal = false;
2028
- tui?.discardStream();
2029
- continue;
2030
- }
2031
- // Per-round, not per-turn: a tool-using turn streams again after each
2032
- // tool_result. A latch that stays set would skip commitStream on later
2033
- // rounds and dropLive would then erase the only copy.
2034
- if (live && tui !== undefined) {
2035
- tui.commitStream();
2036
- live = false;
2037
- skipFinal = true;
2038
- }
2039
- if (skipFinal && (e.kind === "assistant_text" || e.kind === "thinking")) continue;
2040
- if (skipFinal && e.kind !== "usage") skipFinal = false;
2041
- if (e.kind === "assistant_text" && app.io.answer !== undefined) {
2042
- app.io.answer(e.text ?? "");
2043
- continue;
2044
- }
2045
- for (const line of formatEvent(
2046
- e,
2047
- app.io.palette,
2048
- Math.max(20, (process.stdout.columns ?? 80) - 1),
2049
- {},
2050
- app.ui?.style,
2051
- )) {
2052
- app.io.say(line);
2053
- }
2054
- } } finally { await registry?.emit("agent_end", { messages: structuredClone(app.session.state.events) }); }
2055
- // feat-external-effects-summary: one line, in every mode (print and json send it to stderr).
2056
- const host = hostExternalOptions();
2057
- const outside = formatExternalEffects(skill ? requestExternalEffects(app.session, host) : sessionControl(app.session).lastExternalEffects, host);
2058
- if (outside) { if (tui?.notice) tui.notice(outside, "warning"); else app.io.say(app.io.palette.yellow(` ${outside}`)); }
2059
- if (failure) throw Error(failure);
2060
- if (completed) {
2061
- const text = finalText.join("\n");
2062
- if (checkedOutput && !tui && !json) {
2063
- if (app.io.answer) app.io.answer(text); else app.io.say(text);
2064
- }
2065
- if (app.args?.outputLastMessage) await writeFile(resolve(app.args.outputLastMessage), text.endsWith("\n") ? text : text + "\n", "utf8");
2066
- }
2067
- }
2068
-
2069
- // ---------------------------------------------------------------------------
2070
- // TUI
2071
- // ---------------------------------------------------------------------------