@aria-cli/cli 1.0.57 → 1.0.59

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 (319) hide show
  1. package/bin/aria.mjs +799 -668
  2. package/package.json +17 -76
  3. package/dist/.tsbuildinfo +0 -1
  4. package/dist/attached-local-control-client.js +0 -826
  5. package/dist/bootstrap-local-control-client.js +0 -2
  6. package/dist/capability-aware-method-proxy.js +0 -42
  7. package/dist/cli-context.js +0 -160
  8. package/dist/commands/arions.js +0 -174
  9. package/dist/commands/auth.js +0 -123
  10. package/dist/commands/daemon.js +0 -367
  11. package/dist/commands/definitions.js +0 -176
  12. package/dist/commands/index.js +0 -80
  13. package/dist/commands/login-handler.js +0 -1108
  14. package/dist/commands/logout-handler.js +0 -92
  15. package/dist/commands/memory-handlers.js +0 -89
  16. package/dist/commands/pairing.js +0 -60
  17. package/dist/commands/runtime-cutover-reset-command.js +0 -12
  18. package/dist/commands/runtime-cutover-reset.js +0 -265
  19. package/dist/commands/terminal-setup.js +0 -84
  20. package/dist/config/aria-config.js +0 -238
  21. package/dist/config/index.js +0 -3
  22. package/dist/config/loader.js +0 -97
  23. package/dist/config.js +0 -142
  24. package/dist/daemon-info.js +0 -10
  25. package/dist/ensure-daemon.js +0 -128
  26. package/dist/entrypoints/command-mode.js +0 -5
  27. package/dist/entrypoints/daemon.js +0 -50
  28. package/dist/entrypoints/headless-stdio.js +0 -25
  29. package/dist/entrypoints/interactive.js +0 -80
  30. package/dist/event-loop-watchdog.js +0 -73
  31. package/dist/headless/auth-orchestrator.js +0 -508
  32. package/dist/headless/auth-service.js +0 -43
  33. package/dist/headless/bootstrap-fast-path.js +0 -112
  34. package/dist/headless/call-command.js +0 -143
  35. package/dist/headless/daemon-service.js +0 -318
  36. package/dist/headless/hook-actions.js +0 -235
  37. package/dist/headless/hook-service.js +0 -42
  38. package/dist/headless/kernel-services.js +0 -216
  39. package/dist/headless/kernel.js +0 -785
  40. package/dist/headless/operations/arion.js +0 -119
  41. package/dist/headless/operations/auth.js +0 -45
  42. package/dist/headless/operations/client.js +0 -31
  43. package/dist/headless/operations/config.js +0 -69
  44. package/dist/headless/operations/daemon.js +0 -47
  45. package/dist/headless/operations/hook.js +0 -56
  46. package/dist/headless/operations/index.js +0 -11
  47. package/dist/headless/operations/memory.js +0 -102
  48. package/dist/headless/operations/message.js +0 -279
  49. package/dist/headless/operations/model.js +0 -100
  50. package/dist/headless/operations/peer.js +0 -56
  51. package/dist/headless/operations/run.js +0 -24
  52. package/dist/headless/operations/session.js +0 -90
  53. package/dist/headless/operations/system.js +0 -19
  54. package/dist/headless/operations/utils.js +0 -35
  55. package/dist/headless/run-orchestrator.js +0 -703
  56. package/dist/headless/stdio-server.js +0 -439
  57. package/dist/history/SessionHistory.js +0 -8
  58. package/dist/history/SessionHistoryClient.js +0 -186
  59. package/dist/history/conversation-message.js +0 -112
  60. package/dist/history/index.js +0 -8
  61. package/dist/history/jsonl-replay.js +0 -154
  62. package/dist/history/repair-tool-pairing.js +0 -84
  63. package/dist/history/stall-phase-bridge.js +0 -11
  64. package/dist/history/turn-accumulator.js +0 -427
  65. package/dist/index.js +0 -7
  66. package/dist/ink-repl.js +0 -4183
  67. package/dist/local-control-bootstrap.js +0 -26
  68. package/dist/local-control-client.js +0 -2
  69. package/dist/local-control-error-reporting.js +0 -34
  70. package/dist/local-control-http-client.js +0 -362
  71. package/dist/local-control-lazy-wrapper.js +0 -363
  72. package/dist/local-control-manager.js +0 -146
  73. package/dist/main.js +0 -62
  74. package/dist/network-security.js +0 -62
  75. package/dist/networking-server.js +0 -38
  76. package/dist/peer-identity.js +0 -23
  77. package/dist/polling-subscription.js +0 -34
  78. package/dist/relaunch.js +0 -617
  79. package/dist/release-notes.js +0 -35
  80. package/dist/repl-cleanup.js +0 -47
  81. package/dist/runtime/configure-bun-sqlite.js +0 -3
  82. package/dist/runtime/crash-handlers.js +0 -111
  83. package/dist/runtime/interactive-invocation.js +0 -39
  84. package/dist/runtime/internal-mode.js +0 -14
  85. package/dist/runtime/launch-spec.js +0 -64
  86. package/dist/runtime/owner-lease.js +0 -44
  87. package/dist/runtime/public-mode.js +0 -20
  88. package/dist/runtime/run-internal-mode.js +0 -18
  89. package/dist/runtime/runtime-kind.js +0 -32
  90. package/dist/runtime/spawn-aria.js +0 -38
  91. package/dist/selectable-client.js +0 -2
  92. package/dist/selectable-peer.js +0 -2
  93. package/dist/session.js +0 -203
  94. package/dist/slash-commands.js +0 -80
  95. package/dist/sounds.js +0 -210
  96. package/dist/ui/App.js +0 -526
  97. package/dist/ui/components/AnthropicMethodPicker.js +0 -6
  98. package/dist/ui/components/ArionPrompt.js +0 -15
  99. package/dist/ui/components/AutocompleteDropdown.js +0 -23
  100. package/dist/ui/components/AutonomySelector.js +0 -55
  101. package/dist/ui/components/Banner.js +0 -98
  102. package/dist/ui/components/ConversationHistory.js +0 -175
  103. package/dist/ui/components/CopilotDeviceLoginFlow.js +0 -88
  104. package/dist/ui/components/CopilotSourcePicker.js +0 -50
  105. package/dist/ui/components/Cost.js +0 -10
  106. package/dist/ui/components/CustomSelect/option-map.js +0 -30
  107. package/dist/ui/components/CustomSelect/select-option.js +0 -13
  108. package/dist/ui/components/CustomSelect/select.js +0 -42
  109. package/dist/ui/components/CustomSelect/use-select-state.js +0 -179
  110. package/dist/ui/components/CustomSelect/use-select.js +0 -15
  111. package/dist/ui/components/ErrorDisplay.js +0 -35
  112. package/dist/ui/components/FallbackToolUseRejectedMessage.js +0 -7
  113. package/dist/ui/components/FileEditToolUpdatedMessage.js +0 -57
  114. package/dist/ui/components/HandoffMarker.js +0 -18
  115. package/dist/ui/components/HighlightedCode.js +0 -21
  116. package/dist/ui/components/InputArea.js +0 -187
  117. package/dist/ui/components/Message.js +0 -25
  118. package/dist/ui/components/OAuthLoginFlow.js +0 -113
  119. package/dist/ui/components/OutputTruncation.js +0 -35
  120. package/dist/ui/components/PermissionPrompt.js +0 -79
  121. package/dist/ui/components/PipelineTimingPanel.js +0 -15
  122. package/dist/ui/components/ProviderMethodPicker.js +0 -61
  123. package/dist/ui/components/ProviderPicker.js +0 -63
  124. package/dist/ui/components/RenderItemView.js +0 -71
  125. package/dist/ui/components/Spinner.js +0 -46
  126. package/dist/ui/components/StatusBar.js +0 -95
  127. package/dist/ui/components/StreamingIndicator.js +0 -55
  128. package/dist/ui/components/StructuredDiff.js +0 -168
  129. package/dist/ui/components/TextInputOverlay.js +0 -43
  130. package/dist/ui/components/ThinkingBlock.js +0 -82
  131. package/dist/ui/components/ToolCost.js +0 -17
  132. package/dist/ui/components/ToolExecution.js +0 -61
  133. package/dist/ui/components/ToolHeader.js +0 -51
  134. package/dist/ui/components/ToolRenderLayoutContext.js +0 -14
  135. package/dist/ui/components/ToolResultWrapper.js +0 -6
  136. package/dist/ui/components/ToolUseLoader.js +0 -35
  137. package/dist/ui/components/TraceWaterfall.js +0 -91
  138. package/dist/ui/components/index.js +0 -33
  139. package/dist/ui/components/messages/AssistantTextMessage.js +0 -25
  140. package/dist/ui/components/messages/UserImageMessage.js +0 -12
  141. package/dist/ui/components/messages/UserTextMessage.js +0 -12
  142. package/dist/ui/components/overlays/ArionSelector.js +0 -68
  143. package/dist/ui/components/overlays/ClientSelector.js +0 -62
  144. package/dist/ui/components/overlays/CommandPalette.js +0 -67
  145. package/dist/ui/components/overlays/DaemonControl.js +0 -87
  146. package/dist/ui/components/overlays/InviteShareOverlay.js +0 -15
  147. package/dist/ui/components/overlays/JoinInviteOverlay.js +0 -32
  148. package/dist/ui/components/overlays/MemoryBrowser.js +0 -100
  149. package/dist/ui/components/overlays/MessageSelector.js +0 -123
  150. package/dist/ui/components/overlays/ModelSelector.js +0 -211
  151. package/dist/ui/components/overlays/PairRequestOverlay.js +0 -42
  152. package/dist/ui/components/overlays/PeerSelector.js +0 -84
  153. package/dist/ui/components/overlays/SessionSelector.js +0 -102
  154. package/dist/ui/components/overlays/SoundSelector.js +0 -86
  155. package/dist/ui/components/overlays/ThemeSelector.js +0 -139
  156. package/dist/ui/components/overlays/index.js +0 -15
  157. package/dist/ui/components/permissions/BashPermissionRequest/BashPermissionRequest.js +0 -53
  158. package/dist/ui/components/permissions/FallbackPermissionRequest.js +0 -56
  159. package/dist/ui/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.js +0 -76
  160. package/dist/ui/components/permissions/FileEditPermissionRequest/FileEditToolDiff.js +0 -18
  161. package/dist/ui/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.js +0 -64
  162. package/dist/ui/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.js +0 -26
  163. package/dist/ui/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.js +0 -141
  164. package/dist/ui/components/permissions/PermissionRequest.js +0 -70
  165. package/dist/ui/components/permissions/PermissionRequestTitle.js +0 -41
  166. package/dist/ui/components/permissions/hooks.js +0 -10
  167. package/dist/ui/components/permissions/toolUseOptions.js +0 -68
  168. package/dist/ui/components/permissions/utils.js +0 -10
  169. package/dist/ui/components/text-input/Cursor.js +0 -326
  170. package/dist/ui/components/text-input/TextInput.js +0 -231
  171. package/dist/ui/components/text-input/imagePaste.js +0 -28
  172. package/dist/ui/components/text-input/index.js +0 -6
  173. package/dist/ui/components/text-input/useDoublePress.js +0 -30
  174. package/dist/ui/components/text-input/useTextInput.js +0 -245
  175. package/dist/ui/components/tool-types.js +0 -9
  176. package/dist/ui/constants/figures.js +0 -4
  177. package/dist/ui/constants/index.js +0 -3
  178. package/dist/ui/display-mode.js +0 -93
  179. package/dist/ui/display-policy.js +0 -19
  180. package/dist/ui/hooks/index.js +0 -6
  181. package/dist/ui/hooks/useCommandAutocomplete.js +0 -93
  182. package/dist/ui/hooks/useDoublePress.js +0 -37
  183. package/dist/ui/hooks/useIndicatorState.js +0 -55
  184. package/dist/ui/hooks/useInterval.js +0 -23
  185. package/dist/ui/hooks/useKeyboardShortcuts.js +0 -127
  186. package/dist/ui/hooks/useTerminalSize.js +0 -55
  187. package/dist/ui/hooks/useUnifiedMessages.js +0 -117
  188. package/dist/ui/indicator-state.js +0 -44
  189. package/dist/ui/markdown/highlight.js +0 -44
  190. package/dist/ui/markdown/index.js +0 -1460
  191. package/dist/ui/markdown/tokenizer.js +0 -24
  192. package/dist/ui/render-item.js +0 -5
  193. package/dist/ui/screens/REPL.js +0 -119
  194. package/dist/ui/screens/approval-lifecycle.js +0 -38
  195. package/dist/ui/status-line.js +0 -72
  196. package/dist/ui/theme/index.js +0 -51
  197. package/dist/ui/theme/themes/claude-dark-daltonized.js +0 -51
  198. package/dist/ui/theme/themes/claude-dark.js +0 -50
  199. package/dist/ui/theme/themes/claude-light-daltonized.js +0 -51
  200. package/dist/ui/theme/themes/claude-light.js +0 -50
  201. package/dist/ui/theme/themes/dark-accessible.js +0 -18
  202. package/dist/ui/theme/themes/dark.js +0 -49
  203. package/dist/ui/theme/themes/light-accessible.js +0 -18
  204. package/dist/ui/theme/themes/light.js +0 -49
  205. package/dist/ui/theme/types.js +0 -3
  206. package/dist/ui/theme.js +0 -142
  207. package/dist/ui/to-render-items.js +0 -145
  208. package/dist/ui/tools/AgentTool/index.js +0 -30
  209. package/dist/ui/tools/ArchitectTool/index.js +0 -31
  210. package/dist/ui/tools/AskUserTool/index.js +0 -46
  211. package/dist/ui/tools/BashTool/BashToolResultMessage.js +0 -11
  212. package/dist/ui/tools/BashTool/OutputLine.js +0 -21
  213. package/dist/ui/tools/BashTool/index.js +0 -91
  214. package/dist/ui/tools/BrowseTool/index.js +0 -43
  215. package/dist/ui/tools/BrowserTool/index.js +0 -47
  216. package/dist/ui/tools/CbmTool/index.js +0 -188
  217. package/dist/ui/tools/CheckDelegationTool/index.js +0 -46
  218. package/dist/ui/tools/CheckMessagesTool/index.js +0 -85
  219. package/dist/ui/tools/CreateQuipTool/index.js +0 -30
  220. package/dist/ui/tools/CreateSkillTool/index.js +0 -22
  221. package/dist/ui/tools/CreateToolTool/index.js +0 -31
  222. package/dist/ui/tools/DelegateRemoteTool/index.js +0 -42
  223. package/dist/ui/tools/DeployTool/index.js +0 -47
  224. package/dist/ui/tools/FffTool/index.js +0 -103
  225. package/dist/ui/tools/FileEditTool/index.js +0 -67
  226. package/dist/ui/tools/FileReadTool/index.js +0 -68
  227. package/dist/ui/tools/FileWriteTool/index.js +0 -61
  228. package/dist/ui/tools/ForkTool/index.js +0 -47
  229. package/dist/ui/tools/FrgTool/index.js +0 -96
  230. package/dist/ui/tools/GetThreadTool/index.js +0 -39
  231. package/dist/ui/tools/GlobTool/index.js +0 -50
  232. package/dist/ui/tools/GrepTool/index.js +0 -84
  233. package/dist/ui/tools/HatchArionTool/index.js +0 -36
  234. package/dist/ui/tools/LearnSkillTool/index.js +0 -22
  235. package/dist/ui/tools/LearnTool/index.js +0 -43
  236. package/dist/ui/tools/LearnToolTool/index.js +0 -22
  237. package/dist/ui/tools/ListClientsTool/index.js +0 -39
  238. package/dist/ui/tools/LspTool/index.js +0 -261
  239. package/dist/ui/tools/MCPTool/index.js +0 -33
  240. package/dist/ui/tools/ManageNetworkTool/index.js +0 -53
  241. package/dist/ui/tools/MemoryReadTool/index.js +0 -64
  242. package/dist/ui/tools/MemoryWriteTool/index.js +0 -20
  243. package/dist/ui/tools/NotebookEditTool/index.js +0 -33
  244. package/dist/ui/tools/NotebookReadTool/index.js +0 -25
  245. package/dist/ui/tools/OutlookReadTool/index.js +0 -66
  246. package/dist/ui/tools/OutlookReplyTool/index.js +0 -49
  247. package/dist/ui/tools/OutlookSendTool/index.js +0 -49
  248. package/dist/ui/tools/PauseDelegationTool/index.js +0 -35
  249. package/dist/ui/tools/ProbeTool/index.js +0 -121
  250. package/dist/ui/tools/ProcessTool/index.js +0 -66
  251. package/dist/ui/tools/QuestListTool/index.js +0 -46
  252. package/dist/ui/tools/QuestReportTool/index.js +0 -49
  253. package/dist/ui/tools/QuestUpdateTool/index.js +0 -87
  254. package/dist/ui/tools/QuipCommentTool/index.js +0 -69
  255. package/dist/ui/tools/QuipReadTool/index.js +0 -71
  256. package/dist/ui/tools/RestArionTool/index.js +0 -32
  257. package/dist/ui/tools/RestartTool/index.js +0 -35
  258. package/dist/ui/tools/ResumeDelegationTool/index.js +0 -35
  259. package/dist/ui/tools/RetireArionTool/index.js +0 -32
  260. package/dist/ui/tools/RgTool/index.js +0 -73
  261. package/dist/ui/tools/SearchKnowledgeTool/index.js +0 -43
  262. package/dist/ui/tools/SearchMessagesTool/index.js +0 -43
  263. package/dist/ui/tools/SelfDiagnoseTool/index.js +0 -61
  264. package/dist/ui/tools/SendMessageTool/index.js +0 -45
  265. package/dist/ui/tools/SerenaTool/index.js +0 -124
  266. package/dist/ui/tools/SessionHistoryTool/index.js +0 -52
  267. package/dist/ui/tools/SgTool/index.js +0 -80
  268. package/dist/ui/tools/SlackReactTool/index.js +0 -41
  269. package/dist/ui/tools/SlackReadTool/index.js +0 -48
  270. package/dist/ui/tools/SlackSendTool/index.js +0 -45
  271. package/dist/ui/tools/SpawnWorkerTool/index.js +0 -33
  272. package/dist/ui/tools/StickerRequestTool/index.js +0 -19
  273. package/dist/ui/tools/ThinkTool/index.js +0 -17
  274. package/dist/ui/tools/UgTool/index.js +0 -108
  275. package/dist/ui/tools/UseSkillTool/index.js +0 -22
  276. package/dist/ui/tools/WakeArionTool/index.js +0 -32
  277. package/dist/ui/tools/WebFetchTool/index.js +0 -56
  278. package/dist/ui/tools/WebSearchTool/index.js +0 -44
  279. package/dist/ui/tools/lsTool/index.js +0 -58
  280. package/dist/ui/tools/registry.js +0 -197
  281. package/dist/ui/tools/tool-renderer.js +0 -11
  282. package/dist/ui/tools/truncation.js +0 -35
  283. package/dist/ui/types/anthropic.js +0 -4
  284. package/dist/ui/types/index.js +0 -2
  285. package/dist/ui/types/message.js +0 -3
  286. package/dist/ui/types/tool.js +0 -4
  287. package/dist/ui/utils/array.js +0 -4
  288. package/dist/ui/utils/cursor.js +0 -131
  289. package/dist/ui/utils/diff.js +0 -120
  290. package/dist/ui/utils/format.js +0 -42
  291. package/dist/ui/utils/fuzzy.js +0 -59
  292. package/dist/ui/utils/index.js +0 -11
  293. package/dist/ui/utils/keys.js +0 -8
  294. package/dist/ui/utils/patch.js +0 -17
  295. package/dist/ui/utils/risk.js +0 -114
  296. package/dist/ui/utils/terminal-image.js +0 -70
  297. package/dist/ui/utils/validation.js +0 -48
  298. package/dist/ui/verb-pairs.js +0 -248
  299. package/dist/ui.js +0 -131
  300. package/src/entrypoints/command-mode.ts +0 -5
  301. package/src/entrypoints/daemon.ts +0 -54
  302. package/src/entrypoints/headless-stdio.ts +0 -27
  303. package/src/entrypoints/interactive.ts +0 -112
  304. package/src/main.ts +0 -72
  305. package/src/runtime/configure-bun-sqlite.ts +0 -3
  306. package/src/runtime/crash-handlers.ts +0 -128
  307. package/src/runtime/interactive-invocation.test.ts +0 -42
  308. package/src/runtime/interactive-invocation.ts +0 -51
  309. package/src/runtime/internal-mode.test.ts +0 -19
  310. package/src/runtime/internal-mode.ts +0 -24
  311. package/src/runtime/launch-spec.test.ts +0 -26
  312. package/src/runtime/launch-spec.ts +0 -84
  313. package/src/runtime/owner-lease.ts +0 -52
  314. package/src/runtime/public-mode.test.ts +0 -18
  315. package/src/runtime/public-mode.ts +0 -19
  316. package/src/runtime/run-internal-mode.ts +0 -19
  317. package/src/runtime/runtime-kind.test.ts +0 -23
  318. package/src/runtime/runtime-kind.ts +0 -41
  319. package/src/runtime/spawn-aria.ts +0 -62
package/dist/ink-repl.js DELETED
@@ -1,4183 +0,0 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
2
- import { useState, useCallback, useEffect, useRef, useMemo } from "react";
3
- import { render, useApp } from "ink";
4
- import { REPL } from "./ui/screens/REPL.js";
5
- import { useTerminalSize } from "./ui/hooks/useTerminalSize.js";
6
- import { pickRandomVerbPair } from "./ui/verb-pairs.js";
7
- import * as sounds from "./sounds.js";
8
- import { COMMANDS } from "./commands/definitions.js";
9
- import { Banner, } from "./ui/components/index.js";
10
- import { CLI_VERSION, WHATS_NEW } from "./release-notes.js";
11
- import { autonomyToConfirmation, AUTONOMY_LEVELS, DEFAULT_SAFETY_CONFIG, isPrivateLanIP, } from "@aria-cli/aria";
12
- import { mergeDiscoveredPeersByIdentity } from "./peer-identity.js";
13
- import { deriveDisplayOverrides, nextDisplayMode, resolveDisplayConfig, } from "./ui/display-mode.js";
14
- import { setTheme, getTheme, getAvailableThemes, getThemeDefinition } from "./ui/theme/index.js";
15
- import { parseMessage, recoverCrashedSessions, NoopOTELAdapter, HttpOTELAdapter, JsonlEventLogger, } from "@aria-cli/aria";
16
- import { resolveTrustedRuntimeErrorMessage, } from "@aria-cli/tools";
17
- import { getCliModels, getCliSelectableModels, getDefaultModelByTier, getModelByShortName, MODEL_REGISTRY, selectRunnableModelVariant, toShortName, } from "@aria-cli/models";
18
- import { MemoriaPool } from "@aria-cli/aria";
19
- import { getErrorMessage, log } from "@aria-cli/types";
20
- import { repairToolCallPairing, fromModelMessages, toModelMessages, createUserMessage, createSystemMessage, createErrorMessage, createIncomingMessagePair, TurnAccumulator, findJsonlForSession, mergeWithJsonlRecovery, } from "./history/index.js";
21
- import { SessionHistoryClient } from "./history/SessionHistoryClient.js";
22
- import { loadConfig, saveConfig } from "./config.js";
23
- import { DEFAULT_EVENT_LOOP_WATCHDOG_THRESHOLD_MS, startEventLoopWatchdog, getStallPhase, setStallPhase, clearStallPhase, } from "./event-loop-watchdog.js";
24
- import { getPendingResumeSessionId, setPendingResumeSessionId, setPendingArionName, writeRelaunchMarker, } from "./relaunch.js";
25
- import { loadAriaDisplayConfig } from "./config/index.js";
26
- import { randomUUID } from "crypto";
27
- import { parseSlashCommand } from "./slash-commands.js";
28
- import { HeadlessKernel } from "./headless/kernel.js";
29
- import { createHeadlessCapabilityServices } from "./headless/kernel-services.js";
30
- import { runTerminalSetup, isTerminalSetupSupported } from "./commands/terminal-setup.js";
31
- import { LocalControlManager } from "./local-control-manager.js";
32
- import { useUnifiedMessages } from "./ui/hooks/useUnifiedMessages.js";
33
- /** Detect ARIA_SESSION_EXPIRED errors thrown by withReattach after exhausting retries. */
34
- function isSessionExpiredError(err) {
35
- return (err != null &&
36
- typeof err === "object" &&
37
- "code" in err &&
38
- err.code === "ARIA_SESSION_EXPIRED");
39
- }
40
- function formatApprovalInput(input) {
41
- try {
42
- const raw = typeof input === "string" ? input : JSON.stringify(input);
43
- if (!raw)
44
- return "";
45
- return raw.length > 160 ? `${raw.slice(0, 160)}...` : raw;
46
- }
47
- catch {
48
- return "";
49
- }
50
- }
51
- function hasDirectClientInboxControl(localControl) {
52
- return (!!localControl &&
53
- "listDirectClientInbox" in localControl &&
54
- typeof localControl.listDirectClientInbox ===
55
- "function");
56
- }
57
- function logEventLoopStallToStderr(stallMs) {
58
- const phase = getStallPhase();
59
- const phaseInfo = phase
60
- ? `phase="${phase.label}" (active for ${phase.ageMs.toFixed(0)}ms)`
61
- : "phase=unknown (no phase set)";
62
- const message = `[InkREPL][WARN] Event loop stall detected: ${stallMs.toFixed(0)}ms ` +
63
- `(threshold ${DEFAULT_EVENT_LOOP_WATCHDOG_THRESHOLD_MS}ms) ${phaseInfo}\n`;
64
- const stack = new Error("[InkREPL] Event loop watchdog detection stack").stack ?? "[stack unavailable]";
65
- try {
66
- process.stderr.write(message);
67
- process.stderr.write(`${stack}\n`);
68
- }
69
- catch {
70
- // Best effort only: the watchdog must never crash the TUI while reporting.
71
- }
72
- }
73
- function logSystemSleepToStderr(sleepMs) {
74
- if (!process.env.DEBUG)
75
- return;
76
- try {
77
- const secs = (sleepMs / 1000).toFixed(1);
78
- process.stderr.write(`[InkREPL][DEBUG] System sleep/suspend detected: ${secs}s\n`);
79
- }
80
- catch {
81
- // Best effort only.
82
- }
83
- }
84
- function toApprovalRiskLevel(riskLevel) {
85
- if (riskLevel === "high" || riskLevel === "dangerous") {
86
- return "high";
87
- }
88
- if (riskLevel === "moderate") {
89
- return "moderate";
90
- }
91
- return "low";
92
- }
93
- function resolvePausedToolApprovalRequest(state, pendingApproval) {
94
- if (pendingApproval) {
95
- return pendingApproval;
96
- }
97
- const pendingTool = state.pendingToolCalls[0];
98
- if (!pendingTool) {
99
- return null;
100
- }
101
- const extraPendingCount = Math.max(state.pendingToolCalls.length - 1, 0);
102
- return {
103
- toolName: extraPendingCount > 0 ? `${pendingTool.name} (+${extraPendingCount} more)` : pendingTool.name,
104
- input: pendingTool.arguments,
105
- riskLevel: toApprovalRiskLevel(typeof pendingTool.estimatedRisk === "string"
106
- ? pendingTool.estimatedRisk
107
- : pendingTool.riskLevel),
108
- };
109
- }
110
- function extractLatestAssistantText(messages) {
111
- for (let i = messages.length - 1; i >= 0; i--) {
112
- if (messages[i]?.role === "assistant") {
113
- return messages[i].content;
114
- }
115
- }
116
- return "";
117
- }
118
- function toProviderQualifiedModelValue(model) {
119
- return `${model.provider}/${model.shortName}`;
120
- }
121
- function resolveModelFromCommandArg(modelArg, availableModels, credentialHints) {
122
- const normalized = modelArg.trim().toLowerCase();
123
- if (!normalized)
124
- return undefined;
125
- const sorted = [...availableModels].sort((a, b) => a.shortName.localeCompare(b.shortName));
126
- // 0. Exact provider-qualified match (e.g., github-copilot/gpt-4.1)
127
- const byQualifiedExact = sorted.find((m) => {
128
- const qualified = `${m.provider}/${m.shortName}`.toLowerCase();
129
- return qualified === normalized;
130
- });
131
- if (byQualifiedExact)
132
- return byQualifiedExact;
133
- // 1. Exact shortName match
134
- const exact = sorted.find((m) => m.shortName.toLowerCase() === normalized);
135
- if (exact)
136
- return selectRunnableModelVariant(exact, credentialHints);
137
- // 2. Prefix shortName match
138
- const byPrefix = sorted.find((m) => m.shortName.toLowerCase().startsWith(normalized));
139
- if (byPrefix)
140
- return selectRunnableModelVariant(byPrefix, credentialHints);
141
- // 2b. Prefix provider-qualified match
142
- const byQualifiedPrefix = sorted.find((m) => {
143
- const qualified = `${m.provider}/${m.shortName}`.toLowerCase();
144
- return qualified.startsWith(normalized);
145
- });
146
- if (byQualifiedPrefix)
147
- return byQualifiedPrefix;
148
- // 3. Tier match
149
- const byTier = sorted.find((m) => m.tier.toLowerCase() === normalized);
150
- if (byTier)
151
- return selectRunnableModelVariant(byTier, credentialHints);
152
- // 4. Display name contains query
153
- const byDisplay = sorted.find((m) => m.displayName.toLowerCase().includes(normalized));
154
- if (byDisplay)
155
- return selectRunnableModelVariant(byDisplay, credentialHints);
156
- // 5. Provider contains query
157
- const byProvider = sorted.find((m) => m.provider.toLowerCase().includes(normalized));
158
- return byProvider ? selectRunnableModelVariant(byProvider, credentialHints) : undefined;
159
- }
160
- // Execute tools preserving original order; read-only contiguous runs are parallelized.
161
- export async function executeToolCallsInOrder(toolCalls, registry, executeSingleTool) {
162
- const toolResults = new Array(toolCalls.length);
163
- let readOnlyBatch = [];
164
- const flushReadOnlyBatch = async () => {
165
- if (readOnlyBatch.length === 0)
166
- return;
167
- const settled = await Promise.allSettled(readOnlyBatch.map((b) => executeSingleTool(b.call)));
168
- const results = settled.map((s, i) => s.status === "fulfilled"
169
- ? s.value
170
- : {
171
- id: readOnlyBatch[i].call.id,
172
- result: `Tool error: ${s.reason?.message || "Unknown error"}`,
173
- isError: true,
174
- });
175
- for (let i = 0; i < readOnlyBatch.length; i++) {
176
- toolResults[readOnlyBatch[i].index] = results[i];
177
- }
178
- readOnlyBatch = [];
179
- };
180
- for (let i = 0; i < toolCalls.length; i++) {
181
- const tc = toolCalls[i];
182
- const tool = registry.get(tc.name);
183
- if (tool?.isReadOnly) {
184
- readOnlyBatch.push({ index: i, call: tc });
185
- }
186
- else {
187
- await flushReadOnlyBatch();
188
- toolResults[i] = await executeSingleTool(tc);
189
- }
190
- }
191
- await flushReadOnlyBatch();
192
- return toolResults;
193
- }
194
- /**
195
- * Module-level shutdown state — populated by InkRepl component, consumed by
196
- * startInkRepl after waitUntilExit(). This bridges the React component
197
- * lifecycle with the outer async function that owns process exit.
198
- */
199
- const _shutdownState = {
200
- sessionId: null,
201
- sessionHistory: null,
202
- memoriaSession: null,
203
- memoriaFactory: null,
204
- jsonlLogger: null,
205
- };
206
- function formatAskUserResumePrompt(question, index, total) {
207
- const header = total > 1
208
- ? `Run paused: answer question ${index + 1} of ${total} to continue.`
209
- : "Run paused: answer the question below to continue.";
210
- const options = Array.isArray(question.options) && question.options.length > 0
211
- ? `\nOptions: ${question.options.join(" | ")}`
212
- : "";
213
- return `${header}\n${question.question}${options}`;
214
- }
215
- function InkRepl({ session, manager, router, aria, localControlManager, cliContext, sessionHistory, initialModel = "sonnet-4.5", inputHistory = [], onSaveInput, initialMessage, resumeSessionId, cachedUserName, initialAvailableModels = getCliModels(), refreshAvailableModels, credentialHints, authResolver, }) {
216
- const { exit } = useApp();
217
- // Unified message state — single array + streaming cursor replaces the old
218
- // dual messages/previewMessages pattern that had race conditions on paired setState calls.
219
- const { committedItems: messages, streamingItems: previewMessages, isStreaming: unifiedIsStreaming, dispatch: msgDispatch, setItems: setMessages, } = useUnifiedMessages();
220
- const config = loadConfig();
221
- const preferredTier = config.preferredTier || "balanced";
222
- const preferredModel = getDefaultModelByTier(preferredTier, credentialHints);
223
- const resolvedModel = preferredModel?.shortName || initialModel;
224
- const [model, setModel] = useState(resolvedModel);
225
- const modelRef = useRef(resolvedModel);
226
- const [effortLevel, setEffortLevel] = useState(config.effortLevel ?? "high");
227
- const isStreaming = unifiedIsStreaming;
228
- const isStreamingRef = useRef(false);
229
- // When true, the next appendUserConversationInput skips setMessages (display already handled).
230
- const skipNextUserDisplayRef = useRef(false);
231
- const initialMessageFired = useRef(false);
232
- const resumeSessionFired = useRef(false);
233
- const resumeRestoreReadyRef = useRef(Promise.resolve());
234
- const queuedMessageRef = useRef(null);
235
- const [queuedMessage, setQueuedMessage] = useState(null);
236
- const [responseTime, setResponseTime] = useState();
237
- const [arions, setArions] = useState([]);
238
- const [userName, setUserName] = useState(cachedUserName ?? null);
239
- const [memoryCount, setMemoryCount] = useState(0);
240
- const [memories, setMemories] = useState([]);
241
- const [memoryBrowserMode, setMemoryBrowserMode] = useState("browse");
242
- const [isLoadingMemories, setIsLoadingMemories] = useState(false);
243
- const [sessions, setSessions] = useState([]);
244
- const [availableModels, setAvailableModels] = useState(initialAvailableModels);
245
- useEffect(() => {
246
- const control = localControlManager.getControl();
247
- if (!control)
248
- return;
249
- let active = true;
250
- void control
251
- .getModelSnapshot?.()
252
- .then((snapshot) => {
253
- if (!active)
254
- return;
255
- setAvailableModels(snapshot.models);
256
- if (snapshot.currentModel) {
257
- setModel(snapshot.currentModel);
258
- modelRef.current = snapshot.currentModel;
259
- }
260
- })
261
- .catch(() => {
262
- // Keep initial snapshot if daemon model catalog is unavailable.
263
- });
264
- return () => {
265
- active = false;
266
- };
267
- }, [localControlManager]);
268
- const [openSessionOverlaySignal, setOpenSessionOverlaySignal] = useState(undefined);
269
- const [openThemeOverlaySignal, setOpenThemeOverlaySignal] = useState(undefined);
270
- const [openSoundOverlaySignal, setOpenSoundOverlaySignal] = useState(undefined);
271
- const [openDaemonOverlaySignal, setOpenDaemonOverlaySignal] = useState(undefined);
272
- const [daemonStatus, setDaemonStatus] = useState({ running: false });
273
- const [daemonActionStatus, setDaemonActionStatus] = useState(null);
274
- const [connectionState, setConnectionState] = useState("connected");
275
- // Startup grace: suppress transport churn in the StatusBar before the first
276
- // stable connection so the user never sees a flash of "⚡ offline" on boot.
277
- const bootGraceUntilRef = useRef(Date.now() + 15_000);
278
- const sawStableConnectedRef = useRef(false);
279
- // Unsub handle for the current client's state listener.
280
- const connectionStateUnsubRef = useRef(null);
281
- const sessionPageRef = useRef(0);
282
- const sessionSearchQueryRef = useRef("");
283
- const sessionSearchSeqRef = useRef(0);
284
- const sessionSearchDebounceRef = useRef(null);
285
- const SESSION_PAGE_SIZE = 100;
286
- // Autonomy level — ref for closure stability (onApprovalNeeded reads it), state for re-renders.
287
- // Both are always updated together via setAutonomyLevel helper below.
288
- const initialAutonomy = config.autonomy ?? DEFAULT_SAFETY_CONFIG.autonomy;
289
- const autonomyLevelRef = useRef(initialAutonomy);
290
- const [autonomyLevelState, setAutonomyLevelState] = useState(initialAutonomy);
291
- /** Update autonomy level — keeps ref + state in sync, persists to config */
292
- const setAutonomyLevel = useCallback((level) => {
293
- autonomyLevelRef.current = level;
294
- setAutonomyLevelState(level);
295
- saveConfig({ ...loadConfig(), autonomy: level });
296
- }, []);
297
- // Whether to show the interactive AutonomySelector panel
298
- const [showAutonomySelector, setShowAutonomySelector] = useState(false);
299
- // Login provider picker and OAuth flow state
300
- const [loginPickerProviders, setLoginPickerProviders] = useState(null);
301
- const [copilotSourceOptions, setCopilotSourceOptions] = useState(null);
302
- const [oauthProvider, setOAuthProvider] = useState(null);
303
- const [oauthAuthorizeUrl, setOAuthAuthorizeUrl] = useState(null);
304
- const [oauthExpectedState, setOAuthExpectedState] = useState(null);
305
- const [oauthFieldKey, setOAuthFieldKey] = useState(null);
306
- const [copilotDeviceProvider, setCopilotDeviceProvider] = useState(null);
307
- const [copilotDeviceProfileLabel, setCopilotDeviceProfileLabel] = useState(null);
308
- const [copilotDeviceVerificationUri, setCopilotDeviceVerificationUri] = useState(null);
309
- const [copilotDeviceUserCode, setCopilotDeviceUserCode] = useState(null);
310
- // Anthropic method picker, key input, and setup-token input state
311
- const [anthropicMethodOptions, setAnthropicMethodOptions] = useState(null);
312
- const [anthropicKeyInputVisible, setAnthropicKeyInputVisible] = useState(false);
313
- const [anthropicSetupTokenVisible, setAnthropicSetupTokenVisible] = useState(false);
314
- // OpenAI method picker and key input state
315
- const [openaiMethodOptions, setOpenAIMethodOptions] = useState(null);
316
- const [openaiKeyInputVisible, setOpenAIKeyInputVisible] = useState(false);
317
- // Google method picker and key input state
318
- const [googleMethodOptions, setGoogleMethodOptions] = useState(null);
319
- const [googleKeyInputVisible, setGoogleKeyInputVisible] = useState(false);
320
- const [pendingAuthInteraction, setPendingAuthInteraction] = useState(null);
321
- const [authInteractionOptions, setAuthInteractionOptions] = useState(null);
322
- const [authInteractionTitle, setAuthInteractionTitle] = useState(null);
323
- const [authInteractionInput, setAuthInteractionInput] = useState(null);
324
- // Peer discovery state
325
- const [nearbyPeers, setNearbyPeers] = useState([]);
326
- const [localClients, setLocalClients] = useState([]);
327
- const [openPeersOverlaySignal, setOpenPeersOverlaySignal] = useState(undefined);
328
- const [openClientsOverlaySignal, setOpenClientsOverlaySignal] = useState(undefined);
329
- // InviteShareOverlay and JoinInviteOverlay are rendered by App from these props.
330
- const [inviteShare, setInviteShare] = useState(null);
331
- const [openJoinInviteOverlaySignal, setOpenJoinInviteOverlaySignal] = useState(undefined);
332
- const [joinInviteError, setJoinInviteError] = useState(null);
333
- const [incomingPairRequest, setIncomingPairRequest] = useState(null);
334
- const pairPollRef = useRef(null);
335
- // Tool approval dialog state — each request gets a unique ID so React
336
- // remounts the PermissionPrompt when back-to-back approvals arrive
337
- // without an intermediate null (React batching).
338
- const approvalIdRef = useRef(0);
339
- const [approvalRequest, setApprovalRequest] = useState(null);
340
- useEffect(() => {
341
- modelRef.current = model;
342
- }, [model]);
343
- useEffect(() => {
344
- activeArionRef.current = session.getPrimary()?.name || config.activeArion || "ARIA";
345
- }, [session, config.activeArion]);
346
- const [pendingAskUserResume, setPendingAskUserResume] = useState(null);
347
- const requestToolApproval = useCallback((request) => new Promise((resolve) => {
348
- approvalIdRef.current += 1;
349
- setApprovalRequest({
350
- id: approvalIdRef.current,
351
- toolName: request.toolName,
352
- input: request.input,
353
- riskLevel: request.riskLevel,
354
- ...(request.issues ? { issues: request.issues } : {}),
355
- resolve,
356
- });
357
- }), []);
358
- const appendSessionHistoryText = useCallback((message) => {
359
- session.addToHistory(message);
360
- }, [session]);
361
- const appendUserConversationInput = useCallback((input) => {
362
- const userConvMsg = createUserMessage(input);
363
- if (sessionHistory && sessionIdRef.current) {
364
- sessionHistory.persistMessagesNonBlocking(sessionIdRef.current, [userConvMsg]);
365
- }
366
- _shutdownState.jsonlLogger?.log({
367
- type: "user_message",
368
- content: input,
369
- id: userConvMsg.id,
370
- });
371
- // Skip display append if already rendered (e.g. incoming agent message).
372
- if (skipNextUserDisplayRef.current) {
373
- skipNextUserDisplayRef.current = false;
374
- }
375
- else {
376
- setMessages((prev) => [...prev, userConvMsg]);
377
- }
378
- appendSessionHistoryText({ role: "user", content: input });
379
- }, [appendSessionHistoryText, sessionHistory]);
380
- const logInternalRunFailure = useCallback((error) => {
381
- const diagnostic = error && typeof error === "object" && "diagnostic" in error
382
- ? error.diagnostic
383
- : undefined;
384
- _shutdownState.jsonlLogger?.log({
385
- type: "error",
386
- error: {
387
- message: resolveTrustedRuntimeErrorMessage(getErrorMessage(error), diagnostic) ??
388
- getErrorMessage(error),
389
- ...(diagnostic === undefined ? {} : { diagnostic }),
390
- },
391
- });
392
- }, []);
393
- const appendAssistantConversationHistory = useCallback((messages, arion) => {
394
- const content = extractLatestAssistantText(messages).trim();
395
- if (!content) {
396
- return;
397
- }
398
- appendSessionHistoryText({
399
- role: "assistant",
400
- content,
401
- ...(arion ? { arion } : {}),
402
- });
403
- }, [appendSessionHistoryText]);
404
- // Observability metrics for the StatusBar
405
- const [metrics, setMetrics] = useState({
406
- turnCount: 0,
407
- totalTokens: 0,
408
- estimatedCost: 0,
409
- wallTimeSeconds: 0,
410
- contextTokens: 0,
411
- contextStale: false,
412
- });
413
- // Unread mesh message count for StatusBar indicator
414
- const [meshMessageCount, setMeshMessageCount] = useState(0);
415
- // Runtime socket path for real-time peer message subscription.
416
- // Resolved asynchronously to avoid blocking the render thread with a
417
- // synchronous require("@aria-cli/server") (the full server module graph).
418
- const [runtimeSocket, setRuntimeSocket] = useState(null);
419
- useEffect(() => {
420
- let cancelled = false;
421
- (async () => {
422
- try {
423
- const socket = await localControlManager.resolveRuntimeSocket();
424
- if (!cancelled)
425
- setRuntimeSocket(socket);
426
- }
427
- catch {
428
- // Non-critical — runtimeSocket is optional.
429
- }
430
- })();
431
- return () => {
432
- cancelled = true;
433
- };
434
- }, [localControlManager]);
435
- // Display mode state — initialized from ~/.aria/config.yaml (4-layer merge: preset -> file)
436
- const [ariaDisplayConfig] = useState(() => {
437
- const cfg = loadAriaDisplayConfig();
438
- // Theme: single source of truth is config.json (/theme command writes here).
439
- // On first run, migrate from config.yaml so existing manual edits are honored once.
440
- const jsonConfig = loadConfig();
441
- let themeName = jsonConfig.theme;
442
- if (!themeName && cfg.display.theme) {
443
- // One-time migration: YAML → JSON. After this, config.json owns theme.
444
- themeName = cfg.display.theme;
445
- try {
446
- saveConfig({ ...jsonConfig, theme: themeName });
447
- }
448
- catch (e) {
449
- log.warn("[Theme] YAML→JSON migration failed (non-fatal):", e.message);
450
- }
451
- }
452
- if (themeName) {
453
- const validThemes = getAvailableThemes();
454
- if (validThemes.includes(themeName)) {
455
- setTheme(themeName);
456
- }
457
- else {
458
- log.warn(`[Theme] Invalid theme "${themeName}" in config, using default`);
459
- }
460
- }
461
- return cfg;
462
- });
463
- const otelAdapterRef = useRef(ariaDisplayConfig.persistence.otelEndpoint
464
- ? new HttpOTELAdapter(ariaDisplayConfig.persistence.otelEndpoint)
465
- : new NoopOTELAdapter());
466
- const [displayMode, setDisplayMode] = useState(ariaDisplayConfig.display.mode);
467
- const [displayConfig, setDisplayConfig] = useState(() => {
468
- const { mode: _, syntaxHighlighting: _sh, maxToolOutputLines: _mt, maxThinkingPreview: _mp, ...dc } = ariaDisplayConfig.display;
469
- return dc;
470
- });
471
- const [pipelineTiming, setPipelineTiming] = useState(undefined);
472
- // Trace spans for TraceWaterfall visualization
473
- const [spans, setSpans] = useState([]);
474
- // Force remount of App's <Static> renderer when replacing transcript wholesale
475
- // (e.g. /resume, /clear). Ink <Static> is append-only and keeps internal
476
- // render cursor state; remounting avoids "resume works once" behavior.
477
- const [staticRenderEpoch, setStaticRenderEpoch] = useState(0);
478
- // Active arion name for the current run (displayed in StatusBar)
479
- const [activeArion, setActiveArion] = useState();
480
- const activeArionRef = useRef(session.getPrimary()?.name || config.activeArion || "ARIA");
481
- // ObservabilityContext for the current run — passed to App for the indicator state machine hook
482
- const [obsCtx, setObsCtx] = useState(null);
483
- const runStartTimeRef = useRef(0);
484
- const wallTimeSecondsRef = useRef(0);
485
- const wallTimeIntervalRef = useRef(null);
486
- const abortController = useRef(null);
487
- useEffect(() => {
488
- // Init sound from config (default: on)
489
- const soundConfig = loadConfig().soundEnabled;
490
- sounds.setEnabled(soundConfig !== false);
491
- sounds.onSessionStart();
492
- return () => {
493
- sounds.onSessionEnd();
494
- };
495
- }, []);
496
- useEffect(() => {
497
- return startEventLoopWatchdog({
498
- thresholdMs: DEFAULT_EVENT_LOOP_WATCHDOG_THRESHOLD_MS,
499
- onStall: logEventLoopStallToStderr,
500
- onSleep: logSystemSleepToStderr,
501
- });
502
- }, []);
503
- // Accumulated conversation messages across turns. Updated from RunResult.messages
504
- // (via messages_snapshot events) after each runner invocation completes.
505
- const sessionMessagesRef = useRef([]);
506
- // SQLite session ID — created lazily on first submit
507
- const sessionIdRef = useRef(null);
508
- // Concrete Memoria ref for session lifecycle (endSession on cleanup)
509
- const memoriaSessionRef = useRef(null);
510
- // Shared MemoriaFactory via MemoriaPool — replaces inline caching.
511
- const memoriaFactoryRef = useRef(null);
512
- if (!memoriaFactoryRef.current) {
513
- const ariaDir = process.env.ARIA_HOME ?? `${process.env.HOME}/.aria`;
514
- const pool = new MemoriaPool(ariaDir, router, () => sessionIdRef.current);
515
- memoriaFactoryRef.current = pool.toFactory();
516
- }
517
- // Cleanup MemoriaPool on unmount: end Memoria session, then flush WAL connections
518
- useEffect(() => {
519
- return () => {
520
- const sessionId = sessionIdRef.current;
521
- const cleanup = async () => {
522
- if (sessionId && sessionHistory) {
523
- try {
524
- sessionHistory.markCompleted(sessionId);
525
- }
526
- catch (err) {
527
- log.warn("[InkREPL] SessionHistory completion mark failed:", err);
528
- }
529
- }
530
- // End the Memoria session so the sessions table records ended_at and memory_count
531
- if (sessionId && memoriaSessionRef.current) {
532
- try {
533
- await memoriaSessionRef.current.endSession(sessionId);
534
- }
535
- catch (err) {
536
- log.warn("[InkREPL] Memoria session end failed (non-critical):", err);
537
- }
538
- }
539
- // Flush SQLite WAL connections
540
- await memoriaFactoryRef.current?.closeAll();
541
- };
542
- cleanup().catch((err) => {
543
- log.warn("[InkREPL] MemoriaPool cleanup failed:", err?.message);
544
- });
545
- };
546
- }, []);
547
- // ── Local runtime control ───────────────────────────────────────────────
548
- // The TUI is a pure local client. It never owns networking primitives
549
- // directly and never starts an embedded server through a CLI-local path.
550
- const localControlManagerRef = useRef(null);
551
- // Synchronous bootstrap: HeadlessKernel is constructed during render via
552
- // useMemo, so the shared LocalControlManager must already exist before then.
553
- if (!localControlManagerRef.current) {
554
- localControlManagerRef.current = localControlManager;
555
- }
556
- const waitForLocalControlReady = useCallback(async (signal) => {
557
- const deadline = Date.now() + 20_000;
558
- while (Date.now() < deadline) {
559
- if (signal?.aborted) {
560
- return null;
561
- }
562
- const control = localControlManagerRef.current?.getControl() ?? null;
563
- if (control) {
564
- return control;
565
- }
566
- await new Promise((resolve) => setTimeout(resolve, 250));
567
- }
568
- return localControlManagerRef.current?.getControl() ?? null;
569
- }, []);
570
- const getLocalControlClient = useCallback(async () => {
571
- return (await waitForLocalControlReady()) ?? null;
572
- }, [waitForLocalControlReady]);
573
- const getLocalControlClientSync = useCallback(() => {
574
- return localControlManagerRef.current?.getControl() ?? null;
575
- }, []);
576
- // Bind the shared manager and write agent-bridge.json for external agents.
577
- useEffect(() => {
578
- localControlManagerRef.current = localControlManager;
579
- const attached = localControlManager.getAttached();
580
- if (attached) {
581
- log.debug(`[InkREPL] Bound shared local-control manager to runtime ${attached.runtimeId} on port ${attached.port} (${attached.ownership}, node ${attached.nodeId})`);
582
- }
583
- void (async () => {
584
- try {
585
- const bridgeDir = `${process.env.ARIA_HOME ?? `${process.env.HOME}/.aria`}/run`;
586
- const bridgePath = `${bridgeDir}/agent-bridge.json`;
587
- const tmpPath = `${bridgePath}.${process.pid}.tmp`;
588
- const fs = require("fs");
589
- const socketPath = runtimeSocket ?? (await localControlManager.resolveRuntimeSocket());
590
- fs.mkdirSync(bridgeDir, { recursive: true });
591
- fs.writeFileSync(tmpPath, JSON.stringify({
592
- socketPath,
593
- clientId: attached?.attachedClientId ?? null,
594
- nodeId: attached?.nodeId ?? null,
595
- pid: process.pid,
596
- updatedAt: new Date().toISOString(),
597
- }, null, 2));
598
- fs.renameSync(tmpPath, bridgePath);
599
- log.debug(`[InkREPL] Wrote agent-bridge.json (clientId=${attached?.attachedClientId ?? "n/a"}, socket=${socketPath})`);
600
- }
601
- catch (err) {
602
- log.debug(`[InkREPL] Failed to write agent-bridge.json: ${err instanceof Error ? err.message : String(err)}`);
603
- }
604
- })();
605
- return () => {
606
- localControlManagerRef.current = null;
607
- };
608
- }, [localControlManager, runtimeSocket]);
609
- const headlessRunKernel = useMemo(() => {
610
- if (!sessionHistory || !cliContext) {
611
- return null;
612
- }
613
- const memoriaProxy = new Proxy({}, {
614
- get(_target, prop) {
615
- return async (...args) => {
616
- const primary = session.getPrimary();
617
- const currentMemoria = primary && typeof session.getArionMemoria === "function"
618
- ? await session.getArionMemoria(primary)
619
- : null;
620
- const memoria = currentMemoria ?? aria.components.memoria;
621
- const memoriaRecord = memoria;
622
- const method = memoriaRecord[prop];
623
- if (typeof method !== "function") {
624
- throw new Error(`Memoria method ${String(prop)} is unavailable`);
625
- }
626
- return method.apply(memoria, args);
627
- };
628
- },
629
- });
630
- const localControlProxy = localControlManagerRef.current.getControl();
631
- return new HeadlessKernel({
632
- cli: cliContext,
633
- localControl: localControlProxy,
634
- sessionLedger: sessionHistory,
635
- resolveRuntimeSocket: () => localControlManager.resolveRuntimeSocket(),
636
- ...createHeadlessCapabilityServices({
637
- cli: cliContext,
638
- activeArionName: session.getPrimary()?.name ?? cliContext.config.activeArion ?? "ARIA",
639
- overrides: {
640
- memoria: memoriaProxy,
641
- arionManager: {
642
- list: () => manager.list(),
643
- hatch: (input) => manager.hatch(input),
644
- get: async (name) => {
645
- const arions = await manager.list();
646
- return (arions.find((arion) => arion.name.toLowerCase() === name.toLowerCase()) ?? null);
647
- },
648
- wake: (name) => manager.wake(name),
649
- rest: (name) => session.dismiss(name),
650
- },
651
- config: {
652
- async getTheme() {
653
- return getTheme();
654
- },
655
- async setTheme(theme) {
656
- const validThemes = getAvailableThemes();
657
- if (!validThemes.includes(theme)) {
658
- throw new Error(`Unknown theme: ${theme}`);
659
- }
660
- setTheme(theme);
661
- const config = loadConfig();
662
- config.theme = theme;
663
- saveConfig(config);
664
- return { theme };
665
- },
666
- async getAutonomy() {
667
- return autonomyLevelRef.current;
668
- },
669
- async setAutonomy(autonomy) {
670
- if (!AUTONOMY_LEVELS.includes(autonomy)) {
671
- throw new Error(`Invalid autonomy level: ${autonomy}`);
672
- }
673
- setAutonomyLevel(autonomy);
674
- return { autonomy };
675
- },
676
- async getActiveArion() {
677
- return (activeArionRef.current ??
678
- session.getPrimary()?.name ??
679
- cliContext.config.activeArion);
680
- },
681
- async setActiveArion(name) {
682
- activeArionRef.current = name;
683
- const config = loadConfig();
684
- config.activeArion = name;
685
- saveConfig(config);
686
- },
687
- },
688
- modelDiscovery: {
689
- async listAvailable() {
690
- return availableModels;
691
- },
692
- async refresh() {
693
- return availableModels;
694
- },
695
- async getCurrentModel() {
696
- return model;
697
- },
698
- async setCurrentModel(nextModel) {
699
- const normalized = nextModel.trim();
700
- if (!normalized) {
701
- throw new Error("Model name is required");
702
- }
703
- setModel(normalized);
704
- modelRef.current = normalized;
705
- const primary = session.getPrimary();
706
- if (primary) {
707
- await manager.setPreferredModel(primary.name, normalized);
708
- }
709
- return {
710
- currentModel: normalized,
711
- };
712
- },
713
- },
714
- },
715
- }),
716
- system: {
717
- async restart(input) {
718
- const reason = typeof input.reason === "string" &&
719
- input.reason.trim().length > 0
720
- ? input.reason.trim()
721
- : "Restart requested";
722
- return { accepted: true, reason };
723
- },
724
- async terminalSetup() {
725
- if (!isTerminalSetupSupported()) {
726
- return {
727
- supported: false,
728
- message: "terminal-setup is supported only in iTerm2 (macOS) and VSCode terminal.",
729
- };
730
- }
731
- return {
732
- supported: true,
733
- output: runTerminalSetup().trim(),
734
- };
735
- },
736
- },
737
- });
738
- }, [
739
- aria,
740
- availableModels,
741
- cliContext,
742
- manager,
743
- model,
744
- session,
745
- sessionHistory,
746
- setAutonomyLevel,
747
- ]);
748
- // Connection state is shown live in the StatusBar — no system messages needed.
749
- useEffect(() => {
750
- if (!headlessRunKernel)
751
- return;
752
- connectionStateUnsubRef.current?.();
753
- connectionStateUnsubRef.current = null;
754
- setConnectionState(headlessRunKernel.getConnectionState());
755
- connectionStateUnsubRef.current = headlessRunKernel.onConnectionStateChange((nextState) => {
756
- const inBootGrace = Date.now() < bootGraceUntilRef.current;
757
- if (inBootGrace && !sawStableConnectedRef.current && nextState !== "connected") {
758
- // Suppress startup transport churn before first stable connection.
759
- return;
760
- }
761
- if (nextState === "connected") {
762
- sawStableConnectedRef.current = true;
763
- }
764
- setConnectionState(nextState);
765
- });
766
- return () => {
767
- connectionStateUnsubRef.current?.();
768
- connectionStateUnsubRef.current = null;
769
- };
770
- }, [headlessRunKernel]);
771
- const dispatchHeadlessCommand = useCallback(async (op, input) => {
772
- if (!headlessRunKernel) {
773
- throw new Error(`Headless kernel unavailable for ${op}`);
774
- }
775
- let interactionFrame = null;
776
- let resultFrame = null;
777
- for await (const frame of headlessRunKernel.dispatch({
778
- kind: "request",
779
- requestId: `${op}:${randomUUID()}`,
780
- op,
781
- input,
782
- })) {
783
- if (frame.kind === "interaction.required") {
784
- interactionFrame = frame;
785
- }
786
- else if (frame.kind === "result") {
787
- resultFrame = frame;
788
- }
789
- }
790
- return {
791
- interactionFrame,
792
- resultFrame,
793
- };
794
- }, [headlessRunKernel]);
795
- useEffect(() => {
796
- let cancelled = false;
797
- const refreshUnreadMeshCount = async () => {
798
- try {
799
- // Query the client inbox (client-targeted messages) + node inbox
800
- // (broadcasts) via the direct-client endpoint for a complete count.
801
- const clientInbox = await dispatchHeadlessCommand("client.inbox.list", {
802
- limit: 100,
803
- unreadOnly: true,
804
- }).catch(() => null);
805
- const fallbackInbox = clientInbox
806
- ? null
807
- : await dispatchHeadlessCommand("message.inbox.list", {
808
- limit: 100,
809
- unreadOnly: true,
810
- });
811
- const inbox = clientInbox ?? fallbackInbox;
812
- const unread = inbox?.resultFrame?.ok &&
813
- Array.isArray(inbox.resultFrame.result.events)
814
- ? inbox.resultFrame.result.events
815
- : [];
816
- const unreadCount = unread.length;
817
- if (!cancelled) {
818
- setMeshMessageCount(unreadCount);
819
- }
820
- }
821
- catch (err) {
822
- // Best-effort only — unread count refresh must never break the REPL.
823
- if (isSessionExpiredError(err)) {
824
- cancelled = true;
825
- clearInterval(unreadPollInterval);
826
- return;
827
- }
828
- if (!cancelled) {
829
- setMeshMessageCount(0);
830
- }
831
- }
832
- };
833
- void refreshUnreadMeshCount();
834
- const unreadPollInterval = setInterval(() => {
835
- if (!cancelled)
836
- void refreshUnreadMeshCount();
837
- }, 3_000);
838
- return () => {
839
- cancelled = true;
840
- clearInterval(unreadPollInterval);
841
- };
842
- }, [dispatchHeadlessCommand]);
843
- // Real-time push: subscribe to incoming messages and inject into the agent loop.
844
- // Dedup: suppress identical messages from the same sender within a 30s window
845
- // to avoid flooding the REPL (e.g. detached CLI agents sending repeated status updates).
846
- useEffect(() => {
847
- let cancelled = false;
848
- const control = headlessRunKernel;
849
- if (!control?.subscribeDirectClientInbox)
850
- return;
851
- const activeAttached = localControlManagerRef.current?.getAttached();
852
- const localNodeId = activeAttached?.nodeId;
853
- const localClientId = activeAttached?.attachedClientId;
854
- // Track recent messages for content-based dedup: "sender\0content" → { timestamp, count }
855
- const recentMessages = new Map();
856
- const DEDUP_WINDOW_MS = 30_000;
857
- const run = async () => {
858
- // Retry loop: reconnect the subscription after daemon restarts.
859
- while (!cancelled) {
860
- try {
861
- const stream = control.subscribeDirectClientInbox({
862
- afterCreatedAt: Date.now(),
863
- });
864
- for await (const event of stream) {
865
- if (cancelled)
866
- break;
867
- // Skip messages from remote peers on this node.
868
- if (event.senderNodeId === localNodeId)
869
- continue;
870
- // Skip self-sent messages (e.g. replies to arion land in node
871
- // inbox and are visible to all same-node clients). With PID-stable
872
- // clientIds, senderClientId reliably identifies the originating client.
873
- if (event.senderClientId && localClientId && event.senderClientId === localClientId)
874
- continue;
875
- // Skip messages addressed to a different client on this node.
876
- if (event.recipientClientId &&
877
- localClientId &&
878
- event.recipientClientId !== localClientId)
879
- continue;
880
- // Content-based dedup: skip identical messages from the same sender within window.
881
- const dedupKey = `${event.senderNodeId}\0${event.content}`;
882
- const now = Date.now();
883
- const existing = recentMessages.get(dedupKey);
884
- if (existing && now - existing.ts < DEDUP_WINDOW_MS) {
885
- existing.count++;
886
- existing.ts = now;
887
- continue; // suppress duplicate
888
- }
889
- recentMessages.set(dedupKey, { ts: now, count: 1 });
890
- // Evict stale entries to prevent unbounded growth.
891
- if (recentMessages.size > 200) {
892
- for (const [key, entry] of recentMessages) {
893
- if (now - entry.ts >= DEDUP_WINDOW_MS)
894
- recentMessages.delete(key);
895
- }
896
- }
897
- const baseName = event.senderDisplayNameSnapshot?.trim() || event.senderNodeId || "Unknown";
898
- // Include PID from senderClientId (e.g. "client-pid-12345") to
899
- // distinguish multiple ARIA instances in the UI.
900
- const senderPid = event.senderClientId?.match(/^client-pid-(\d+)$/)?.[1];
901
- const senderName = senderPid ? `${baseName} (pid ${senderPid})` : baseName;
902
- const content = typeof event.content === "string" ? event.content : JSON.stringify(event.content);
903
- // The reply address: prefer display name (send_message accepts @name),
904
- // fall back to nodeId for remote peers.
905
- const senderAddress = senderName !== "Unknown" ? senderName : event.senderNodeId || "unknown";
906
- // Synthesize a check_messages tool call + result pair.
907
- // The LLM sees this as "I checked my inbox and got a message" —
908
- // the most natural prompt to reply via send_message.
909
- const [syntheticCall, syntheticResult] = createIncomingMessagePair(senderName, senderAddress, content, event.id);
910
- // Inject the synthetic tool pair into conversation + display.
911
- // toRenderItems detects synthetic_inbox_ IDs and renders as incoming-message.
912
- if (sessionHistory && sessionIdRef.current) {
913
- sessionHistory.persistMessagesNonBlocking(sessionIdRef.current, [
914
- syntheticCall,
915
- syntheticResult,
916
- ]);
917
- }
918
- setMessages((prev) => [...prev, syntheticCall, syntheticResult]);
919
- if (!isStreamingRef.current) {
920
- // Idle: trigger a new agent turn. Include the full message so the
921
- // LLM has everything it needs — no reason to call check_messages.
922
- skipNextUserDisplayRef.current = true;
923
- handleSubmitRef.current(`[Incoming message from "${senderName}" (${senderAddress})]\n\n${content}\n\n[Reply using send_message with to="${senderAddress}"]`);
924
- }
925
- // Streaming: synthetic pair is already in history — LLM sees it on next turn.
926
- }
927
- }
928
- catch {
929
- // Stream broke (daemon restart, socket disconnect). Retry after delay.
930
- }
931
- // Wait before reconnecting to avoid tight retry loops.
932
- if (!cancelled) {
933
- await new Promise((r) => setTimeout(r, 2_000));
934
- }
935
- } // end while(!cancelled)
936
- };
937
- void run();
938
- return () => {
939
- cancelled = true;
940
- };
941
- }, [headlessRunKernel, localControlManager]);
942
- // Cleanup wall-time interval on unmount
943
- useEffect(() => {
944
- return () => {
945
- if (wallTimeIntervalRef.current) {
946
- clearInterval(wallTimeIntervalRef.current);
947
- }
948
- };
949
- }, []);
950
- // Sync module-level shutdown state so startInkRepl can run graceful
951
- // shutdown after the Ink app exits. Updated once on mount and whenever
952
- // sessionHistory changes. Session ID and Memoria session refs are synced
953
- // at their assignment sites (handleSubmit, handleSelectSession, /clear).
954
- useEffect(() => {
955
- _shutdownState.sessionHistory = sessionHistory ?? null;
956
- _shutdownState.memoriaFactory = memoriaFactoryRef.current;
957
- }, [sessionHistory]);
958
- const handleCommandRef = useRef(undefined);
959
- const continuePausedRunRef = useRef(null);
960
- const handleSelectModelRef = useRef(undefined);
961
- // The REPL no longer owns a per-submit RunSession or runner loop.
962
- useEffect(() => {
963
- let cancelled = false;
964
- (async () => {
965
- try {
966
- const name = await aria.recallUserName();
967
- if (cancelled)
968
- return;
969
- setUserName(name);
970
- const primary = session.getPrimary();
971
- let memoria;
972
- if (primary) {
973
- const arionMem = await session.getArionMemoria(primary);
974
- memoria = arionMem ?? aria.components.memoria;
975
- if (primary.preferredModel)
976
- setModel(primary.preferredModel);
977
- }
978
- else {
979
- memoria = aria.components.memoria;
980
- }
981
- const count = await memoria.count();
982
- if (cancelled)
983
- return;
984
- setMemoryCount(count);
985
- }
986
- catch {
987
- /* user name is optional */
988
- }
989
- })();
990
- return () => {
991
- cancelled = true;
992
- };
993
- }, [aria, session]);
994
- useEffect(() => {
995
- let cancelled = false;
996
- (async () => {
997
- try {
998
- const { resultFrame } = await dispatchHeadlessCommand("arion.list", {});
999
- if (!resultFrame) {
1000
- throw new Error("arion.list produced no result frame");
1001
- }
1002
- if (!resultFrame.ok) {
1003
- throw new Error(resultFrame.error.message);
1004
- }
1005
- const list = resultFrame.result.arions ?? [];
1006
- if (cancelled)
1007
- return;
1008
- setArions(list.map((a) => ({
1009
- name: a.name,
1010
- emoji: a.emoji,
1011
- color: a.color,
1012
- description: a.personality?.traits?.slice(0, 2).join(", ") || "",
1013
- isActive: session.isActive(a.name),
1014
- isResting: a.status === "resting",
1015
- })));
1016
- }
1017
- catch (error) {
1018
- if (cancelled)
1019
- return;
1020
- setMessages((prev) => [
1021
- ...prev,
1022
- createErrorMessage(`Failed to load arions: ${error.message}`),
1023
- ]);
1024
- }
1025
- })();
1026
- return () => {
1027
- cancelled = true;
1028
- };
1029
- }, [dispatchHeadlessCommand, session]);
1030
- // ── Runtime-owned peer snapshot ───────────────────────────────────────────
1031
- // The TUI is a pure client. Nearby LAN discovery and connected peer state
1032
- // both come from LocalControlApi, not from client-owned discovery services.
1033
- useEffect(() => {
1034
- let active = true;
1035
- let interval = null;
1036
- const dispatchSnapshot = async (op, input) => {
1037
- if (!headlessRunKernel) {
1038
- return null;
1039
- }
1040
- let resultFrame = null;
1041
- for await (const frame of headlessRunKernel.dispatch({
1042
- kind: "request",
1043
- requestId: `${op}:${randomUUID()}`,
1044
- op,
1045
- input,
1046
- })) {
1047
- if (frame.kind === "result") {
1048
- resultFrame = frame;
1049
- }
1050
- }
1051
- return resultFrame;
1052
- };
1053
- const startPolling = async () => {
1054
- const poll = async () => {
1055
- try {
1056
- const [peerResult, nearbyResult, clientResult] = await Promise.all([
1057
- dispatchSnapshot("peer.list", {}),
1058
- dispatchSnapshot("peer.list_nearby", {}),
1059
- dispatchSnapshot("client.list", {}),
1060
- ]);
1061
- if (!active || !peerResult?.ok || !nearbyResult?.ok || !clientResult?.ok)
1062
- return;
1063
- const peerViews = (peerResult.result.peers ?? []);
1064
- const nearbyPeerViews = (nearbyResult.result.peers ?? []);
1065
- const attachedClientViews = (clientResult.result.clients ??
1066
- []);
1067
- const lanPeers = nearbyPeerViews.map((peer) => ({
1068
- displayNameSnapshot: peer.displayNameSnapshot,
1069
- nodeId: peer.nodeId,
1070
- host: peer.host,
1071
- port: peer.port,
1072
- principalFingerprint: peer.principalFingerprint,
1073
- version: peer.version,
1074
- tlsCaFingerprint: peer.tlsCaFingerprint,
1075
- transport: peer.transport,
1076
- status: peer.status,
1077
- }));
1078
- const wanPeers = peerViews
1079
- .filter((peer) => peer.transportState === "connected")
1080
- .map((peer) => ({
1081
- displayNameSnapshot: peer.displayNameSnapshot ?? peer.nodeId,
1082
- nodeId: peer.nodeId,
1083
- version: "runtime",
1084
- transport: "wan",
1085
- status: "connected",
1086
- }));
1087
- setNearbyPeers(mergeDiscoveredPeersByIdentity(lanPeers, wanPeers));
1088
- setLocalClients(attachedClientViews.map((client) => ({
1089
- clientId: client.clientId,
1090
- clientKind: client.clientKind,
1091
- displayLabel: client.displayLabel,
1092
- self: client.self,
1093
- })));
1094
- }
1095
- catch (err) {
1096
- // Local control or runtime peer snapshot unavailable — retry next interval
1097
- if (isSessionExpiredError(err)) {
1098
- active = false;
1099
- if (interval)
1100
- clearInterval(interval);
1101
- interval = null;
1102
- return;
1103
- }
1104
- }
1105
- };
1106
- await poll(); // Initial poll
1107
- interval = setInterval(poll, 30_000); // Poll every 30s
1108
- if (!active && interval) {
1109
- clearInterval(interval);
1110
- interval = null;
1111
- }
1112
- };
1113
- startPolling();
1114
- return () => {
1115
- active = false;
1116
- if (interval)
1117
- clearInterval(interval);
1118
- };
1119
- }, [headlessRunKernel]);
1120
- // Do not probe provider APIs on TUI startup.
1121
- // Interactive sessions should render immediately from the static registry
1122
- // and only refresh discovery on explicit user action.
1123
- const knownModelKeys = useMemo(() => new Set(MODEL_REGISTRY.map((m) => `${m.provider}:${m.id}`)), []);
1124
- const selectableModels = useMemo(() => getCliSelectableModels(availableModels), [availableModels]);
1125
- const models = selectableModels.map((m) => ({
1126
- name: m.shortName,
1127
- value: toProviderQualifiedModelValue(m),
1128
- description: m.description || "",
1129
- provider: m.provider,
1130
- isCurrent: m.shortName === model,
1131
- knownModel: knownModelKeys.has(`${m.provider}:${m.id}`),
1132
- }));
1133
- const refreshArionsList = useCallback(async () => {
1134
- try {
1135
- const { resultFrame } = await dispatchHeadlessCommand("arion.list", {});
1136
- if (!resultFrame) {
1137
- throw new Error("arion.list produced no result frame");
1138
- }
1139
- if (!resultFrame.ok) {
1140
- throw new Error(resultFrame.error.message);
1141
- }
1142
- const list = resultFrame.result.arions ?? [];
1143
- setArions(list.map((a) => ({
1144
- name: a.name,
1145
- emoji: a.emoji,
1146
- color: a.color,
1147
- description: a.personality?.traits?.slice(0, 2).join(", ") || "",
1148
- isActive: session.isActive(a.name),
1149
- isResting: a.status === "resting",
1150
- })));
1151
- }
1152
- catch (error) {
1153
- log.warn("Failed to refresh arions list:", error.message);
1154
- }
1155
- }, [dispatchHeadlessCommand, session]);
1156
- const getCurrentMemoria = useCallback(async () => {
1157
- const primary = session.getPrimary();
1158
- if (primary) {
1159
- const arionMemoria = await session.getArionMemoria(primary);
1160
- if (arionMemoria)
1161
- return arionMemoria;
1162
- }
1163
- return aria.components.memoria;
1164
- }, [session, aria]);
1165
- const loadMemories = useCallback(async () => {
1166
- setIsLoadingMemories(true);
1167
- try {
1168
- const memoria = await getCurrentMemoria();
1169
- const recentMemories = await memoria.list({ limit: 100 });
1170
- setMemories(recentMemories.map((m) => ({
1171
- id: m.id,
1172
- content: m.content,
1173
- network: m.network,
1174
- createdAt: m.createdAt ?? new Date(),
1175
- importance: m.importance,
1176
- })));
1177
- const count = await memoria.count();
1178
- setMemoryCount(count);
1179
- }
1180
- catch (error) {
1181
- setMemories([]);
1182
- setMessages((prev) => [
1183
- ...prev,
1184
- createErrorMessage(`Failed to load memories: ${error.message}`),
1185
- ]);
1186
- }
1187
- finally {
1188
- setIsLoadingMemories(false);
1189
- }
1190
- }, [getCurrentMemoria]);
1191
- const loadSessions = useCallback(() => {
1192
- if (!sessionHistory)
1193
- return;
1194
- try {
1195
- const page = sessionPageRef.current;
1196
- const offset = page * SESSION_PAGE_SIZE;
1197
- const list = sessionHistory.listSessions(SESSION_PAGE_SIZE, offset);
1198
- setSessions(list);
1199
- }
1200
- catch (error) {
1201
- log.warn("Failed to load sessions:", error);
1202
- }
1203
- }, [sessionHistory]);
1204
- const handleSearchSessions = useCallback((query) => {
1205
- if (!sessionHistory)
1206
- return;
1207
- try {
1208
- const trimmed = query.trim();
1209
- sessionSearchQueryRef.current = trimmed;
1210
- sessionPageRef.current = 0;
1211
- // Cancel any in-flight scheduled search to keep typing responsive.
1212
- if (sessionSearchDebounceRef.current) {
1213
- clearTimeout(sessionSearchDebounceRef.current);
1214
- sessionSearchDebounceRef.current = null;
1215
- }
1216
- // Empty query should feel instant.
1217
- if (!trimmed) {
1218
- setSessions(sessionHistory.listSessions(SESSION_PAGE_SIZE, 0));
1219
- return;
1220
- }
1221
- // Deep FTS search is more expensive than metadata LIKE.
1222
- // Run it asynchronously with a short debounce so keypress handling stays snappy.
1223
- const seq = ++sessionSearchSeqRef.current;
1224
- sessionSearchDebounceRef.current = setTimeout(() => {
1225
- try {
1226
- const next = sessionHistory.searchSessionsFts(trimmed, SESSION_PAGE_SIZE, 0);
1227
- // Ignore stale results if user kept typing.
1228
- if (seq !== sessionSearchSeqRef.current)
1229
- return;
1230
- if (sessionSearchQueryRef.current !== trimmed)
1231
- return;
1232
- setSessions(next);
1233
- }
1234
- catch (error) {
1235
- log.warn("Failed to execute scheduled session search:", error);
1236
- }
1237
- }, 120);
1238
- }
1239
- catch (error) {
1240
- log.warn("Failed to search sessions:", error);
1241
- }
1242
- }, [sessionHistory]);
1243
- useEffect(() => {
1244
- return () => {
1245
- if (sessionSearchDebounceRef.current) {
1246
- clearTimeout(sessionSearchDebounceRef.current);
1247
- sessionSearchDebounceRef.current = null;
1248
- }
1249
- };
1250
- }, []);
1251
- const handleLoadMoreSessions = useCallback((direction) => {
1252
- if (!sessionHistory)
1253
- return;
1254
- try {
1255
- const nextPage = direction === "next"
1256
- ? sessionPageRef.current + 1
1257
- : Math.max(0, sessionPageRef.current - 1);
1258
- const offset = nextPage * SESSION_PAGE_SIZE;
1259
- const query = sessionSearchQueryRef.current;
1260
- const next = query
1261
- ? sessionHistory.searchSessionsFts(query, SESSION_PAGE_SIZE, offset)
1262
- : sessionHistory.listSessions(SESSION_PAGE_SIZE, offset);
1263
- if (direction === "next" && next.length === 0)
1264
- return;
1265
- sessionPageRef.current = nextPage;
1266
- setSessions(next);
1267
- }
1268
- catch (error) {
1269
- log.warn("Failed to paginate sessions:", error);
1270
- }
1271
- }, [sessionHistory]);
1272
- // Crash recovery — process incomplete sessions from previous runs.
1273
- // Delegates to recoverCrashedSessions() which handles extraction for incomplete sessions.
1274
- useEffect(() => {
1275
- const recoveryController = new AbortController();
1276
- const runRecovery = async () => {
1277
- if (!sessionHistory)
1278
- return;
1279
- try {
1280
- const memoria = await getCurrentMemoria();
1281
- if (recoveryController.signal.aborted)
1282
- return;
1283
- const recovery = await recoverCrashedSessions({
1284
- sessionHistory,
1285
- memoria: memoria,
1286
- router,
1287
- signal: recoveryController.signal,
1288
- });
1289
- if (!recoveryController.signal.aborted && recovery.recovered > 0) {
1290
- log.debug(`[InkREPL] Recovered learning from ${recovery.recovered} incomplete session${recovery.recovered === 1 ? "" : "s"}.`);
1291
- }
1292
- }
1293
- catch (err) {
1294
- log.warn("[InkREPL] Crash recovery failed:", err);
1295
- }
1296
- };
1297
- void runRecovery();
1298
- return () => {
1299
- recoveryController.abort();
1300
- };
1301
- }, [sessionHistory, getCurrentMemoria, router]);
1302
- const bindMemoriaSession = useCallback(async (sessionId) => {
1303
- try {
1304
- const mem = await getCurrentMemoria();
1305
- if ("session" in mem && typeof mem.session === "function") {
1306
- mem.session(sessionId);
1307
- }
1308
- if ("endSession" in mem && typeof mem.endSession === "function") {
1309
- memoriaSessionRef.current = mem;
1310
- _shutdownState.memoriaSession = memoriaSessionRef.current;
1311
- }
1312
- }
1313
- catch (err) {
1314
- log.warn("[InkREPL] Memoria session start failed (non-critical):", err);
1315
- }
1316
- }, [getCurrentMemoria]);
1317
- const endMemoriaSession = useCallback(async (sessionId) => {
1318
- if (!sessionId || !memoriaSessionRef.current)
1319
- return;
1320
- try {
1321
- await memoriaSessionRef.current.endSession(sessionId);
1322
- }
1323
- catch (err) {
1324
- log.warn("[InkREPL] Memoria session end failed (non-critical):", err);
1325
- }
1326
- }, []);
1327
- const setSessionMessagesFromConversation = useCallback((messages) => {
1328
- sessionMessagesRef.current = repairToolCallPairing(toModelMessages(messages).filter((m) => m.role !== "system"));
1329
- }, []);
1330
- const refreshSessionMessagesFromStorage = useCallback((sessionId) => {
1331
- if (!sessionHistory)
1332
- return;
1333
- const loaded = sessionHistory.loadSessionMessages(sessionId);
1334
- if (!loaded)
1335
- return;
1336
- setSessionMessagesFromConversation(loaded.messages);
1337
- }, [sessionHistory, setSessionMessagesFromConversation]);
1338
- const resetStaticRenderer = useCallback(() => {
1339
- setStaticRenderEpoch((n) => n + 1);
1340
- }, []);
1341
- // Clear terminal and remount <Static> after resize settles (Gemini-CLI approach).
1342
- // During drag-resize, Ink's live zone output wraps at intermediate widths and
1343
- // eraseLines() can't track the visual row count accurately, leaving ghost lines.
1344
- // Height-only shrinks have the same problem: the cursor/footer can wind up
1345
- // below the visible viewport unless we force a full redraw at the new size.
1346
- // After the resize stabilises (100ms debounce) we wipe the screen and force
1347
- // <Static> to re-render all committed history at the correct new dimensions.
1348
- const { columns: terminalColumns, rows: terminalRows } = useTerminalSize();
1349
- const isFirstResize = useRef(true);
1350
- useEffect(() => {
1351
- if (isFirstResize.current) {
1352
- isFirstResize.current = false;
1353
- return;
1354
- }
1355
- const timer = setTimeout(() => {
1356
- process.stdout.write("\x1b[2J\x1b[H");
1357
- resetStaticRenderer();
1358
- }, 100);
1359
- return () => clearTimeout(timer);
1360
- }, [terminalColumns, terminalRows, resetStaticRenderer]);
1361
- const publishResumeMarker = useCallback((sessionId, arionName) => {
1362
- try {
1363
- writeRelaunchMarker({
1364
- sessionId,
1365
- arionName,
1366
- pid: process.ppid || process.pid,
1367
- timestamp: new Date().toISOString(),
1368
- });
1369
- }
1370
- catch (error) {
1371
- log.debug(`[InkREPL] Failed to publish relaunch marker: ${error instanceof Error ? error.message : String(error)}`);
1372
- }
1373
- }, []);
1374
- const handleSelectSession = useCallback(async (sessionId) => {
1375
- if (!sessionHistory)
1376
- return;
1377
- // Block session restore while streaming to prevent data corruption (R2-C2 fix)
1378
- if (isStreamingRef.current)
1379
- return;
1380
- try {
1381
- setStallPhase("ink-repl:resume:session.load");
1382
- const { resultFrame } = await dispatchHeadlessCommand("session.load", { sessionId });
1383
- clearStallPhase();
1384
- if (!resultFrame) {
1385
- throw new Error("session.load produced no result frame");
1386
- }
1387
- if (!resultFrame.ok) {
1388
- setMessages((prev) => [
1389
- ...prev,
1390
- createErrorMessage(resultFrame.error.message || `Session not found: ${sessionId}`),
1391
- ]);
1392
- return;
1393
- }
1394
- const loadedState = resultFrame.result;
1395
- const loaded = loadedState.session;
1396
- if (!loaded) {
1397
- setMessages((prev) => [...prev, createErrorMessage(`Session not found: ${sessionId}`)]);
1398
- return;
1399
- }
1400
- // SQLite first (fast, has user + assistant + tool messages).
1401
- // JSONL fills gaps only on crash recovery — skip the expensive JSONL replay
1402
- // (30MB+ sync read) when the session completed normally (no data loss).
1403
- let messages = loaded.messages;
1404
- const runtimeState = sessionHistory.getSessionRuntimeState(sessionId);
1405
- const needsCrashRecovery = !runtimeState || runtimeState.stateStatus !== "completed";
1406
- if (needsCrashRecovery) {
1407
- setStallPhase("ink-repl:resume:jsonlCrashRecovery");
1408
- const ariaDir = process.env.ARIA_HOME ?? `${process.env.HOME}/.aria`;
1409
- const arionName = loaded.arion || session.getPrimary()?.name || "ARIA";
1410
- const jsonlPath = findJsonlForSession(ariaDir, arionName, sessionId);
1411
- const { messages: merged, backfillMessages } = mergeWithJsonlRecovery(loaded.messages, jsonlPath);
1412
- messages = merged;
1413
- // Backfill SQLite so the index catches up with JSONL source of truth
1414
- if (backfillMessages.length > 0) {
1415
- sessionHistory.persistMessagesNonBlocking(sessionId, backfillMessages);
1416
- log.debug(`[InkREPL] JSONL→SQLite backfill: ${backfillMessages.length} messages recovered`);
1417
- }
1418
- clearStallPhase();
1419
- }
1420
- // Session identity is pull-based: Memoria reads sessionIdRef via
1421
- // the sessionResolver passed to MemoriaPool. No explicit bind/end
1422
- // calls needed — setting the ref is sufficient. endMemoriaSession
1423
- // only runs at process shutdown (embedding flush before exit).
1424
- session.clearHistory();
1425
- sessionIdRef.current = sessionId;
1426
- setPendingResumeSessionId(sessionId);
1427
- setPendingArionName(loaded.arion || session.getPrimary()?.name || "ARIA");
1428
- publishResumeMarker(sessionId, loaded.arion || session.getPrimary()?.name || "ARIA");
1429
- _shutdownState.sessionId = sessionId;
1430
- // Yield to event loop so Ink renders any pending frames before
1431
- // heavy synchronous post-processing (toModelMessages, repairToolCallPairing).
1432
- await new Promise((r) => setTimeout(r, 0));
1433
- // Reconstruct sessionMessagesRef from ConversationMessage[] via toModelMessages,
1434
- // repairing any interrupted tool chains from crashed sessions (A-2 review fix)
1435
- setStallPhase("ink-repl:resume:setSessionMessages");
1436
- setSessionMessagesFromConversation(messages);
1437
- clearStallPhase();
1438
- // Reset static renderer before replacing conversation list so every
1439
- // resume renders, not just the first one.
1440
- resetStaticRenderer();
1441
- setMessages([
1442
- createSystemMessage(`Resumed session (${messages.length} messages from ${loaded.arion})`),
1443
- ...messages,
1444
- ]);
1445
- setSpans([]);
1446
- setPipelineTiming(undefined);
1447
- // Only add user/assistant/system to ArionSession history (consistent
1448
- // with normal operation which never adds tool messages — A-4 review fix).
1449
- // For assistant messages with toolCalls AND content, preserve the content
1450
- // portion so contribution gate sees it (R2-C4 fix).
1451
- setStallPhase("ink-repl:resume:appendSessionHistory");
1452
- for (const msg of messages) {
1453
- if (msg.role === "tool")
1454
- continue;
1455
- const textContent = msg.content
1456
- .filter((b) => b.type === "text")
1457
- .map((b) => b.text)
1458
- .join("");
1459
- if (msg.role === "assistant" && !textContent)
1460
- continue; // skip tool-only assistant messages
1461
- appendSessionHistoryText({
1462
- role: msg.role,
1463
- content: textContent,
1464
- arion: msg.arion?.name,
1465
- });
1466
- }
1467
- clearStallPhase();
1468
- if (loadedState.pendingInteraction) {
1469
- await continuePausedRunRef.current?.({
1470
- pendingInteraction: {
1471
- interactionId: loadedState.pendingInteraction.interactionId,
1472
- source: loadedState.pendingInteraction.source,
1473
- interaction: loadedState.pendingInteraction.prompt,
1474
- },
1475
- startedAt: Date.now(),
1476
- abortSignal: new AbortController().signal,
1477
- });
1478
- }
1479
- }
1480
- catch (error) {
1481
- clearStallPhase();
1482
- setMessages((prev) => [
1483
- ...prev,
1484
- createErrorMessage(`Error resuming session: ${getErrorMessage(error)}`),
1485
- ]);
1486
- }
1487
- }, [
1488
- sessionHistory,
1489
- session,
1490
- endMemoriaSession,
1491
- bindMemoriaSession,
1492
- dispatchHeadlessCommand,
1493
- publishResumeMarker,
1494
- appendSessionHistoryText,
1495
- setSessionMessagesFromConversation,
1496
- resetStaticRenderer,
1497
- ]);
1498
- const persistResumedMessages = useCallback(async (input) => {
1499
- const nextMessages = Array.isArray(input.result.messages)
1500
- ? repairToolCallPairing(structuredClone(input.result.messages.filter((msg) => msg.role !== "system")))
1501
- : sessionMessagesRef.current;
1502
- const previousCount = sessionMessagesRef.current.length;
1503
- const deltaMessages = nextMessages.slice(previousCount);
1504
- if (deltaMessages.length > 0) {
1505
- const deltaConversation = fromModelMessages(deltaMessages, {
1506
- arion: input.respondingArionName
1507
- ? {
1508
- name: input.respondingArionName,
1509
- emoji: "🦋",
1510
- }
1511
- : undefined,
1512
- });
1513
- const sid = sessionIdRef.current;
1514
- if (sessionHistory && sid) {
1515
- sessionHistory.persistMessagesNonBlocking(sid, deltaConversation);
1516
- }
1517
- setMessages((prev) => [...prev, ...deltaConversation]);
1518
- }
1519
- sessionMessagesRef.current = nextMessages;
1520
- const usage = input.result.usage;
1521
- setMetrics((prev) => ({
1522
- ...prev,
1523
- ...(typeof input.result.turnCount === "number"
1524
- ? { turnCount: input.result.turnCount }
1525
- : {}),
1526
- ...(typeof usage?.totalTokens === "number" ? { totalTokens: usage.totalTokens } : {}),
1527
- ...(typeof usage?.estimatedCost === "number" ? { estimatedCost: usage.estimatedCost } : {}),
1528
- }));
1529
- if (input.result.state) {
1530
- return input.result.state;
1531
- }
1532
- if (!input.result.success) {
1533
- throw Object.assign(new Error(resolveTrustedRuntimeErrorMessage(input.result.error ?? "Run resume failed", input.result.diagnostic) ??
1534
- input.result.error ??
1535
- "Run resume failed"), {
1536
- ...(input.result.diagnostic === undefined
1537
- ? {}
1538
- : { diagnostic: input.result.diagnostic }),
1539
- });
1540
- }
1541
- appendAssistantConversationHistory(nextMessages, input.respondingArionName);
1542
- setResponseTime((Date.now() - input.startedAt) / 1000);
1543
- try {
1544
- const memoria = await getCurrentMemoria();
1545
- const count = await memoria.count();
1546
- setMemoryCount(count);
1547
- }
1548
- catch (memErr) {
1549
- log.warn("Memory finalization failed:", memErr);
1550
- }
1551
- await refreshArionsList();
1552
- return null;
1553
- }, [appendAssistantConversationHistory, getCurrentMemoria, refreshArionsList, sessionHistory]);
1554
- const respondToHeadlessRunInteraction = useCallback(async (input) => {
1555
- if (!headlessRunKernel) {
1556
- throw new Error("Headless run kernel unavailable for REPL resume");
1557
- }
1558
- const localControl = await waitForLocalControlReady(input.abortSignal);
1559
- if (!localControl) {
1560
- throw new Error("Local runtime control unavailable for REPL resume");
1561
- }
1562
- let nextInteraction = null;
1563
- let resultFrame = null;
1564
- for await (const frame of headlessRunKernel.dispatch({
1565
- kind: "interaction.respond",
1566
- requestId: `${sessionIdRef.current ?? "session"}:interaction:${randomUUID()}`,
1567
- interactionId: input.interactionId,
1568
- response: input.response,
1569
- }, { signal: input.abortSignal })) {
1570
- if (input.abortSignal?.aborted)
1571
- break;
1572
- if (frame.kind === "interaction.required") {
1573
- nextInteraction = {
1574
- interactionId: frame.interactionId,
1575
- source: frame.source,
1576
- interaction: frame.interaction,
1577
- };
1578
- }
1579
- else if (frame.kind === "result") {
1580
- resultFrame = frame;
1581
- }
1582
- }
1583
- if (input.abortSignal?.aborted) {
1584
- return nextInteraction;
1585
- }
1586
- if (!resultFrame) {
1587
- throw new Error("Headless run interaction produced no result frame");
1588
- }
1589
- if (resultFrame.ok) {
1590
- await persistResumedMessages({
1591
- result: resultFrame.result,
1592
- respondingArionName: input.respondingArionName,
1593
- startedAt: input.startedAt,
1594
- });
1595
- return null;
1596
- }
1597
- if (resultFrame.error.code === "INTERACTION_REQUIRED") {
1598
- return nextInteraction;
1599
- }
1600
- throw Object.assign(new Error(resultFrame.error.message), {
1601
- ...(resultFrame.error.details === undefined
1602
- ? {}
1603
- : { diagnostic: resultFrame.error.details }),
1604
- });
1605
- }, [headlessRunKernel, persistResumedMessages, waitForLocalControlReady]);
1606
- const continuePausedRun = useCallback(async (input) => {
1607
- let pendingInteraction = input.pendingInteraction;
1608
- while (pendingInteraction?.interaction.kind === "tool_approval" &&
1609
- !input.abortSignal.aborted) {
1610
- const approved = await requestToolApproval({
1611
- toolName: pendingInteraction.interaction.toolName,
1612
- input: pendingInteraction.interaction.toolInput,
1613
- riskLevel: toApprovalRiskLevel(pendingInteraction.interaction.riskLevel),
1614
- ...(pendingInteraction.interaction.issues?.length
1615
- ? { issues: pendingInteraction.interaction.issues }
1616
- : {}),
1617
- });
1618
- pendingInteraction = await respondToHeadlessRunInteraction({
1619
- interactionId: pendingInteraction.interactionId,
1620
- response: { kind: "confirm", approved },
1621
- respondingArionName: input.respondingArionName,
1622
- startedAt: input.startedAt,
1623
- abortSignal: input.abortSignal,
1624
- });
1625
- }
1626
- if (input.abortSignal.aborted) {
1627
- return;
1628
- }
1629
- if (pendingInteraction?.interaction.kind === "questionnaire") {
1630
- const questions = pendingInteraction.interaction.questions;
1631
- setPendingAskUserResume({
1632
- interactionId: pendingInteraction.interactionId,
1633
- questions,
1634
- answers: [],
1635
- ...(input.respondingArionName ? { respondingArionName: input.respondingArionName } : {}),
1636
- startedAt: input.startedAt,
1637
- });
1638
- setMessages((prev) => [
1639
- ...prev,
1640
- createSystemMessage(formatAskUserResumePrompt(questions[0], 0, questions.length)),
1641
- ]);
1642
- setSpans([]);
1643
- await refreshArionsList();
1644
- return;
1645
- }
1646
- if (pendingInteraction) {
1647
- const pausedMessage = pendingInteraction.interaction.kind === "tool_approval"
1648
- ? `Run paused: approval required for ${pendingInteraction.interaction.toolName}${formatApprovalInput(pendingInteraction.interaction.toolInput)
1649
- ? ` (${formatApprovalInput(pendingInteraction.interaction.toolInput)})`
1650
- : ""}.`
1651
- : "Run paused and requires interaction to continue.";
1652
- setMessages((prev) => [...prev, createSystemMessage(pausedMessage)]);
1653
- setSpans([]);
1654
- await refreshArionsList();
1655
- }
1656
- }, [refreshArionsList, requestToolApproval, respondToHeadlessRunInteraction]);
1657
- continuePausedRunRef.current = continuePausedRun;
1658
- // handleSubmit is SYNCHRONOUS — it validates, sets guards, and fires the
1659
- // async turn as a detached promise. This ensures Ink's render/input loop
1660
- // is NEVER blocked by model calls, tool execution, or cleanup work.
1661
- // All async work lives in the body below; errors are caught internally.
1662
- const handleSubmit = useCallback((input) => {
1663
- if (isStreamingRef.current) {
1664
- // Queue message to send after current stream ends.
1665
- // Combine with existing queued message instead of overwriting.
1666
- // Multiple submissions during streaming merge into one message.
1667
- queuedMessageRef.current = queuedMessageRef.current
1668
- ? queuedMessageRef.current + "\n\n" + input
1669
- : input;
1670
- setQueuedMessage(queuedMessageRef.current);
1671
- return;
1672
- }
1673
- // Fire-and-forget: the entire async turn runs detached from Ink's
1674
- // render loop. Errors are caught internally — never an unhandled rejection.
1675
- void (async () => {
1676
- if (pendingAskUserResume) {
1677
- appendUserConversationInput(input);
1678
- const nextAnswers = [...pendingAskUserResume.answers, input];
1679
- if (nextAnswers.length < pendingAskUserResume.questions.length) {
1680
- setPendingAskUserResume({
1681
- ...pendingAskUserResume,
1682
- answers: nextAnswers,
1683
- });
1684
- setMessages((prev) => [
1685
- ...prev,
1686
- createSystemMessage(formatAskUserResumePrompt(pendingAskUserResume.questions[nextAnswers.length], nextAnswers.length, pendingAskUserResume.questions.length)),
1687
- ]);
1688
- return;
1689
- }
1690
- abortController.current = new AbortController();
1691
- const resumeAbortSignal = abortController.current.signal;
1692
- isStreamingRef.current = true;
1693
- msgDispatch({ type: "START_TURN" });
1694
- setPendingAskUserResume(null);
1695
- try {
1696
- const nextInteraction = await respondToHeadlessRunInteraction({
1697
- interactionId: pendingAskUserResume.interactionId,
1698
- response: {
1699
- kind: "questionnaire",
1700
- answers: nextAnswers,
1701
- },
1702
- respondingArionName: pendingAskUserResume.respondingArionName,
1703
- startedAt: pendingAskUserResume.startedAt,
1704
- abortSignal: resumeAbortSignal,
1705
- });
1706
- await continuePausedRun({
1707
- pendingInteraction: nextInteraction,
1708
- respondingArionName: pendingAskUserResume.respondingArionName,
1709
- startedAt: pendingAskUserResume.startedAt,
1710
- abortSignal: resumeAbortSignal,
1711
- });
1712
- }
1713
- catch (error) {
1714
- logInternalRunFailure(error);
1715
- setMessages((prev) => [
1716
- ...prev,
1717
- createErrorMessage(`Error: ${error.message}`),
1718
- ]);
1719
- }
1720
- finally {
1721
- abortController.current = null;
1722
- isStreamingRef.current = false;
1723
- msgDispatch({ type: "DISCARD_STREAMING" });
1724
- setSpans([]);
1725
- }
1726
- return;
1727
- }
1728
- const slashCmd = parseSlashCommand(input);
1729
- if (slashCmd && handleCommandRef.current) {
1730
- await handleCommandRef.current(slashCmd.command, slashCmd.args);
1731
- return;
1732
- }
1733
- sounds.onPromptSubmit();
1734
- // Create AbortController BEFORE setting streaming state so that
1735
- // handleCancel() always finds a non-null controller if isStreaming is true.
1736
- // Closes the race window where ESC could fire between setIsStreaming(true)
1737
- // and controller creation, resulting in a no-op cancel.
1738
- abortController.current = new AbortController();
1739
- const abortSignal = abortController.current.signal;
1740
- // Set streaming guard after controller exists to prevent
1741
- // reentrancy from rapid double-submit (R2-C8 fix)
1742
- isStreamingRef.current = true;
1743
- msgDispatch({ type: "START_TURN" });
1744
- const parsed = parseMessage(input);
1745
- // Lazily create SQLite session on first submit (FK constraint)
1746
- if (sessionHistory && !sessionIdRef.current) {
1747
- try {
1748
- const primary = session.getPrimary();
1749
- const arionName = primary?.name || "ARIA";
1750
- sessionIdRef.current = sessionHistory.createSession(arionName, model);
1751
- setPendingResumeSessionId(sessionIdRef.current);
1752
- setPendingArionName(arionName);
1753
- publishResumeMarker(sessionIdRef.current, arionName);
1754
- _shutdownState.sessionId = sessionIdRef.current;
1755
- // Notify supervisor of session ID immediately for crash recovery.
1756
- // If the child crashes, the supervisor knows which session to resume.
1757
- try {
1758
- const send = process.send;
1759
- if (typeof send === "function") {
1760
- send({
1761
- type: "aria-relaunch",
1762
- resumeSessionId: sessionIdRef.current,
1763
- arionName,
1764
- });
1765
- }
1766
- }
1767
- catch {
1768
- // No IPC channel — not supervised
1769
- }
1770
- // Start a Memoria session with the same ID so memories extracted during
1771
- // this CLI session are tagged with source.session for consolidation grouping.
1772
- await bindMemoriaSession(sessionIdRef.current);
1773
- }
1774
- catch (err) {
1775
- log.warn("[InkREPL] SQLite session creation failed (non-fatal):", err);
1776
- }
1777
- }
1778
- // Lazily create JSONL event logger on first submit (one file per CLI session).
1779
- // Uses the same sessionId as SQLite history for cross-referencing.
1780
- if (!_shutdownState.jsonlLogger && ariaDisplayConfig.persistence.jsonlEnabled) {
1781
- const activeArionName = session.getPrimary()?.name || "ARIA";
1782
- const ariaHome = process.env.ARIA_HOME ?? `${process.env.HOME}/.aria`;
1783
- const logDir = `${ariaHome}/arions/${activeArionName}/logs`;
1784
- _shutdownState.jsonlLogger = new JsonlEventLogger({
1785
- logDir,
1786
- sessionId: sessionIdRef.current || randomUUID(),
1787
- retentionDays: ariaDisplayConfig.persistence.jsonlRetentionDays,
1788
- maxSizeMb: ariaDisplayConfig.persistence.jsonlMaxSizeMb,
1789
- });
1790
- void _shutdownState.jsonlLogger.rotate().catch((err) => {
1791
- log.warn("[InkREPL] JSONL log rotation failed:", err);
1792
- });
1793
- }
1794
- for (const mention of parsed.mentions) {
1795
- if (!session.isActive(mention)) {
1796
- try {
1797
- await session.activate(mention);
1798
- }
1799
- catch (error) {
1800
- const msg = error.message?.toLowerCase() || "";
1801
- if (!msg.includes("not found") && !msg.includes("unknown")) {
1802
- log.warn(`Failed to activate @${mention}:`, error.message);
1803
- }
1804
- }
1805
- }
1806
- }
1807
- const addressedArions = [];
1808
- for (const mention of parsed.mentions) {
1809
- const arion = session
1810
- .getActiveArions()
1811
- .find((a) => a.name.toLowerCase() === mention.toLowerCase());
1812
- if (arion)
1813
- addressedArions.push(arion);
1814
- }
1815
- if (addressedArions.length === 0) {
1816
- const primary = session.getPrimary();
1817
- if (primary)
1818
- addressedArions.push(primary);
1819
- }
1820
- // Persist + render user message BEFORE loop (crash safety).
1821
- appendUserConversationInput(input);
1822
- // Reset spans for this run
1823
- setSpans([]);
1824
- setPipelineTiming(undefined);
1825
- const startTime = Date.now();
1826
- // Reset mesh message count — user is now interacting, messages will be in context
1827
- setMeshMessageCount(0);
1828
- // Reset observability metrics for this run and start wall-time timer
1829
- runStartTimeRef.current = startTime;
1830
- setMetrics({
1831
- turnCount: 0,
1832
- totalTokens: 0,
1833
- estimatedCost: 0,
1834
- wallTimeSeconds: 0,
1835
- contextTokens: 0,
1836
- contextStale: false,
1837
- });
1838
- // Wall-time tracking — merged into the render scheduler below (no separate timer).
1839
- // A single timer for ALL streaming UI updates prevents independent render triggers.
1840
- if (wallTimeIntervalRef.current)
1841
- clearInterval(wallTimeIntervalRef.current);
1842
- wallTimeSecondsRef.current = 0;
1843
- wallTimeIntervalRef.current = null; // No separate timer — render scheduler handles it
1844
- // TurnAccumulator replaces localTimeline, localTools, fullContent, liveThinkingContent,
1845
- // toolArgsAccumulator — it ingests RunEvents and flushes ConversationMessages at turn boundaries.
1846
- const accumulator = new TurnAccumulator();
1847
- // --- Render scheduler (SINGLE source of renders during streaming) ---
1848
- // ALL streaming UI updates happen here: preview content, wall-time elapsed,
1849
- // and animation frame advancement. No other timers trigger renders.
1850
- // This ensures ONE atomic terminal write per tick — no flickering.
1851
- const PREVIEW_RENDER_INTERVAL_MS = 100; // ~10 fps — smooth without flicker
1852
- let previewDirty = false;
1853
- const previewRenderTimer = setInterval(() => {
1854
- // --- All streaming UI state updates happen HERE, in one callback ---
1855
- // React batches setState calls within the same synchronous callback,
1856
- // producing exactly ONE Ink render per tick — ONE atomic terminal write.
1857
- // 1. Wall-time elapsed (was separate 500ms timer)
1858
- const newSeconds = Math.floor((Date.now() - runStartTimeRef.current) / 1000);
1859
- if (newSeconds !== wallTimeSecondsRef.current) {
1860
- wallTimeSecondsRef.current = newSeconds;
1861
- pendingMetrics = { ...(pendingMetrics ?? {}), wallTimeSeconds: newSeconds };
1862
- }
1863
- // 2. Accumulated metrics from events (usage_update, turn_complete, tool_result)
1864
- if (pendingMetrics) {
1865
- setMetrics((prev) => ({ ...prev, ...pendingMetrics }));
1866
- pendingMetrics = null;
1867
- }
1868
- // 3. Preview content from accumulator snapshot
1869
- if (previewDirty && accumulator.hasPendingContent()) {
1870
- msgDispatch({ type: "UPDATE_STREAM", snapshot: [...accumulator.snapshot()] });
1871
- previewDirty = false;
1872
- }
1873
- }, PREVIEW_RENDER_INTERVAL_MS);
1874
- let respondingArion;
1875
- let runHistory = [];
1876
- // The composing verb is shown in the indicator during TTFB wait. When the
1877
- // first thinking block arrives, it inherits this verb so the user sees
1878
- // continuity (same verb, accumulated time) rather than a jarring switch.
1879
- const composingVerb = pickRandomVerbPair();
1880
- // Pending metrics — accumulated during the event loop, flushed by the render scheduler.
1881
- // This prevents usage_update/turn_complete/tool_result events from triggering
1882
- // independent React renders that bypass the single-timer architecture.
1883
- let pendingMetrics = null;
1884
- try {
1885
- const respondingArions = await session.getRespondingArions(input, addressedArions, []);
1886
- respondingArion = respondingArions[0]?.arion || session.getPrimary();
1887
- // Track active arion for StatusBar display
1888
- setActiveArion(respondingArion?.name);
1889
- activeArionRef.current = respondingArion?.name;
1890
- // Initialize accumulator with arion + verb context
1891
- if (respondingArion) {
1892
- accumulator.setArion({
1893
- name: respondingArion.name,
1894
- emoji: respondingArion.emoji ?? "🦋",
1895
- color: respondingArion.color ?? undefined,
1896
- });
1897
- }
1898
- accumulator.setVerb(composingVerb.present);
1899
- // Build a runtime-owned RunRequest for the attached local client.
1900
- const rsConfig = loadConfig();
1901
- if (!headlessRunKernel) {
1902
- throw new Error("Headless run kernel unavailable for REPL submit");
1903
- }
1904
- const readyLocalControl = await waitForLocalControlReady(abortSignal);
1905
- if (abortSignal.aborted)
1906
- return;
1907
- if (!readyLocalControl) {
1908
- throw new Error("Local runtime control unavailable for REPL submit");
1909
- }
1910
- // Pass the full accumulated session history to the runner. Context window
1911
- // management is handled by ContextGuard.beforeModelCall() inside the runner
1912
- // (intelligent token-based compaction: masking → LLM summarization).
1913
- //
1914
- // Previously this hard-truncated to 20 user turns (MAX_TURNS), which was
1915
- // redundant with ContextGuard AND destructive: since sessionMessagesRef is
1916
- // replaced by the runner's messages_snapshot after each turn, truncated
1917
- // messages were permanently lost from the ref (compounding shrinkage).
1918
- // IMPORTANT: Build BEFORE the runner adds the current user input (runner.ts:876).
1919
- runHistory = repairToolCallPairing([...sessionMessagesRef.current]);
1920
- const sessionId = sessionIdRef.current;
1921
- let pendingInteraction = null;
1922
- let finalRunResult = null;
1923
- const approvalMode = autonomyToConfirmation(autonomyLevelRef.current).length > 0 ? "pause" : "approve";
1924
- const runRequest = {
1925
- task: input,
1926
- arion: activeArionRef.current || respondingArion?.name || rsConfig.activeArion || "ARIA",
1927
- cwd: process.cwd(),
1928
- ...(runHistory.length > 0 ? { history: runHistory } : {}),
1929
- ...(modelRef.current ? { requestedModel: modelRef.current } : {}),
1930
- ...(rsConfig.preferredTier
1931
- ? { preferredTier: rsConfig.preferredTier }
1932
- : {}),
1933
- autonomy: autonomyLevelRef.current,
1934
- approvalMode,
1935
- };
1936
- for await (const frame of headlessRunKernel.dispatch({
1937
- kind: "request",
1938
- requestId: sessionId ?? randomUUID(),
1939
- op: "run.start",
1940
- input: runRequest,
1941
- }, { signal: abortSignal })) {
1942
- // Check abort at top of loop — break immediately so the
1943
- // generator's finally{} block can run cleanup in the orchestrator.
1944
- if (abortSignal.aborted)
1945
- break;
1946
- if (frame.kind === "interaction.required") {
1947
- pendingInteraction = {
1948
- interactionId: frame.interactionId,
1949
- source: frame.source,
1950
- interaction: frame.interaction,
1951
- };
1952
- continue;
1953
- }
1954
- if (frame.kind === "result") {
1955
- finalRunResult = frame;
1956
- continue;
1957
- }
1958
- const event = frame.event;
1959
- // Persist every RunEvent to JSONL for debugging/replay
1960
- _shutdownState.jsonlLogger?.log(event);
1961
- // Warcraft sound notifications
1962
- sounds.onRunEvent(event);
1963
- // Track handoff events for accumulator arion context
1964
- if (event.type === "handoff_start" || event.type === "handoff_result") {
1965
- // Update respondingArion when a handoff completes
1966
- if (event.type === "handoff_result") {
1967
- const handoffTarget = session
1968
- .getActiveArions()
1969
- .find((a) => a.name.toLowerCase() === event.target.toLowerCase());
1970
- if (handoffTarget) {
1971
- respondingArion = handoffTarget;
1972
- accumulator.setArion({
1973
- name: handoffTarget.name,
1974
- emoji: handoffTarget.emoji ?? "🦋",
1975
- color: handoffTarget.color ?? undefined,
1976
- });
1977
- }
1978
- }
1979
- }
1980
- // Direct handlers (non-content events):
1981
- if (event.type === "span_start") {
1982
- const parentSpanId = "parentSpanId" in event && typeof event.parentSpanId === "string"
1983
- ? event.parentSpanId
1984
- : undefined;
1985
- setSpans((prev) => [
1986
- ...prev,
1987
- {
1988
- spanId: event.spanId,
1989
- spanType: event.spanType,
1990
- name: event.name,
1991
- startTime: Date.now(),
1992
- ...(parentSpanId ? { parentSpanId } : {}),
1993
- },
1994
- ]);
1995
- }
1996
- if (event.type === "span_end") {
1997
- setSpans((prev) => prev.map((s) => s.spanId === event.spanId
1998
- ? { ...s, endTime: Date.now(), durationMs: event.durationMs }
1999
- : s));
2000
- }
2001
- if (event.type === "pipeline_timing") {
2002
- setPipelineTiming(event.report);
2003
- }
2004
- if (event.type === "error") {
2005
- throw new Error(resolveTrustedRuntimeErrorMessage(event.error.message, "diagnostic" in event.error ? event.error.diagnostic : undefined) ?? event.error.message);
2006
- }
2007
- if (event.type === "usage_update") {
2008
- const usage = event.usage;
2009
- if (typeof usage.inputTokens === "number" && typeof usage.outputTokens === "number") {
2010
- accumulator.setTokenUsage({
2011
- input: usage.inputTokens,
2012
- output: usage.outputTokens,
2013
- });
2014
- }
2015
- // Accumulate — render scheduler flushes in one batched render
2016
- pendingMetrics = {
2017
- ...(pendingMetrics ?? {}),
2018
- ...(typeof usage.totalTokens === "number"
2019
- ? { totalTokens: usage.totalTokens }
2020
- : {}),
2021
- ...(typeof usage.estimatedCost === "number"
2022
- ? { estimatedCost: usage.estimatedCost }
2023
- : {}),
2024
- // Per-call inputTokens = current context window usage (how full it is)
2025
- ...(typeof usage.inputTokens === "number"
2026
- ? { contextTokens: usage.inputTokens }
2027
- : {}),
2028
- contextStale: false,
2029
- };
2030
- // Update status bar to reflect the actual model used by the router.
2031
- // This handles cases where the configured model failed and the router
2032
- // escalated/fell back to a different provider (e.g., OpenAI → Bedrock).
2033
- if ("model" in event && typeof event.model === "string") {
2034
- const actualShortName = toShortName(event.model);
2035
- if (actualShortName !== model) {
2036
- setModel(actualShortName);
2037
- modelRef.current = actualShortName;
2038
- }
2039
- }
2040
- }
2041
- if (event.type === "turn_complete") {
2042
- pendingMetrics = { ...(pendingMetrics ?? {}), turnCount: event.turnNumber };
2043
- }
2044
- if (event.type === "tool_result") {
2045
- pendingMetrics = { ...(pendingMetrics ?? {}), contextStale: true };
2046
- }
2047
- // Content events → accumulator (data only — no render scheduling here)
2048
- const signal = accumulator.ingest(event);
2049
- if (signal === "flush") {
2050
- const turnMessages = accumulator.flush();
2051
- const sessionId = sessionIdRef.current;
2052
- // Persist all flushed messages in a SINGLE transaction to avoid
2053
- // N separate lock acquisitions. With multiple concurrent ARIA
2054
- // processes sharing the same WAL-mode history.db, each BEGIN
2055
- // IMMEDIATE can block on busy_timeout waiting for the write lock.
2056
- // Batching reduces lock contention from N×100ms to 1×100ms.
2057
- if (sessionHistory && sessionId) {
2058
- sessionHistory.persistMessagesNonBlocking(sessionId, turnMessages);
2059
- }
2060
- // Atomic: commit flushed turn messages and clear streaming zone in one dispatch
2061
- msgDispatch({ type: "COMMIT_TURN", flushed: turnMessages });
2062
- // Mark clean so scheduler doesn't re-render stale state
2063
- previewDirty = false;
2064
- }
2065
- else {
2066
- // Any data event marks the preview as dirty for the render scheduler
2067
- previewDirty = true;
2068
- }
2069
- }
2070
- setObsCtx(null);
2071
- sounds.onResponseComplete();
2072
- // Stop render scheduler — streaming is complete, no more preview updates
2073
- clearInterval(previewRenderTimer);
2074
- // Flush one final render if dirty — ensures the last accumulated content
2075
- // (e.g. final text tokens or tool result) is visible before commit.
2076
- if (previewDirty && accumulator.hasPendingContent()) {
2077
- msgDispatch({ type: "UPDATE_STREAM", snapshot: [...accumulator.snapshot()] });
2078
- previewDirty = false;
2079
- }
2080
- // Stop wall-time timer now that the stream is complete
2081
- if (wallTimeIntervalRef.current) {
2082
- clearInterval(wallTimeIntervalRef.current);
2083
- wallTimeIntervalRef.current = null;
2084
- }
2085
- // Set final wall time
2086
- setMetrics((prev) => ({
2087
- ...prev,
2088
- wallTimeSeconds: (Date.now() - runStartTimeRef.current) / 1000,
2089
- }));
2090
- // CRITICAL: Reset streaming UI state IMMEDIATELY so React can render
2091
- // the input area while heavy post-processing runs. isStreamingRef stays
2092
- // true to guard handleSubmit against reentrancy — queued messages are
2093
- // drained at the bottom of this function after post-processing completes.
2094
- //
2095
- // Use COMMIT_AND_END (not DISCARD_STREAMING) to atomically commit any
2096
- // remaining content AND set streamCursor=null in a single reducer step.
2097
- // DISCARD_STREAMING slices items at streamCursor, which races with
2098
- // COMMIT_TURN — if React batches both dispatches or a timer-based
2099
- // UPDATE_STREAM sneaks in between, the final streamed text is lost.
2100
- if (accumulator.hasPendingContent()) {
2101
- const finalMessages = accumulator.flush();
2102
- const sid = sessionIdRef.current;
2103
- if (sessionHistory && sid) {
2104
- sessionHistory.persistMessagesNonBlocking(sid, finalMessages);
2105
- }
2106
- msgDispatch({ type: "COMMIT_AND_END", flushed: finalMessages });
2107
- }
2108
- else {
2109
- msgDispatch({ type: "COMMIT_AND_END", flushed: [] });
2110
- }
2111
- setSpans([]);
2112
- setResponseTime((Date.now() - startTime) / 1000);
2113
- // Yield to the event loop so Ink renders the prompt (input area visible,
2114
- // streaming indicator hidden) BEFORE heavy post-processing blocks the thread.
2115
- // Without this yield, structuredClone + repairToolCallPairing + SQLite reads
2116
- // all run synchronously and the UI appears frozen at end of turn.
2117
- await new Promise((r) => setTimeout(r, 0));
2118
- // --- Heavy post-processing (UI already responsive) ---
2119
- // Capture sessionMessagesRef from final snapshot (AFTER loop, not in flush block).
2120
- // messages_snapshot fires at end of run. The runner maintains its own internal
2121
- // message array across turns — sessionMessagesRef is only needed for the NEXT user submission.
2122
- const snapshot = accumulator.getSnapshotMessages();
2123
- const snapshotFiltered = snapshot?.filter((m) => m.role !== "system");
2124
- // Only update sessionMessagesRef when the snapshot has real messages.
2125
- // Empty snapshots (from error/guardrail paths) must NOT wipe prior history —
2126
- // fall through to refreshSessionMessagesFromStorage instead.
2127
- const hasSnapshot = !!(snapshotFiltered && snapshotFiltered.length > 0);
2128
- if (hasSnapshot) {
2129
- // Defensive copy via structuredClone so mutations in one run
2130
- // cannot corrupt the next run's history through shared references.
2131
- const ppT0 = performance.now();
2132
- setStallPhase("ink-repl:structuredClone");
2133
- const cloned = structuredClone(snapshotFiltered);
2134
- const ppT1 = performance.now();
2135
- setStallPhase("ink-repl:repairToolCallPairing");
2136
- sessionMessagesRef.current = repairToolCallPairing(cloned);
2137
- const ppT2 = performance.now();
2138
- clearStallPhase();
2139
- const cloneMs = ppT1 - ppT0;
2140
- const repairMs = ppT2 - ppT1;
2141
- if (process.env.DEBUG && cloneMs + repairMs > 100) {
2142
- try {
2143
- process.stderr.write(`[InkREPL][DIAG] post-processing: structuredClone=${cloneMs.toFixed(0)}ms ` +
2144
- `repairToolCallPairing=${repairMs.toFixed(0)}ms ` +
2145
- `(${snapshotFiltered.length} msgs)\n`);
2146
- }
2147
- catch {
2148
- /* best effort */
2149
- }
2150
- }
2151
- }
2152
- else if (sessionHistory && sessionIdRef.current) {
2153
- // If no snapshot was emitted (e.g. interrupted run paths), rehydrate
2154
- // from persisted ConversationMessage rows so next turn history is correct.
2155
- setStallPhase("ink-repl:refreshSessionMessagesFromStorage");
2156
- refreshSessionMessagesFromStorage(sessionIdRef.current);
2157
- clearStallPhase();
2158
- }
2159
- if (abortSignal.aborted) {
2160
- if (accumulator.hasPendingContent()) {
2161
- const abortedMessages = accumulator.flush();
2162
- const sid = sessionIdRef.current;
2163
- if (sessionHistory && sid) {
2164
- sessionHistory.persistMessagesNonBlocking(sid, abortedMessages);
2165
- }
2166
- // Atomic: commit aborted content and end streaming in one dispatch
2167
- msgDispatch({ type: "COMMIT_AND_END", flushed: abortedMessages });
2168
- }
2169
- else {
2170
- msgDispatch({ type: "DISCARD_STREAMING" });
2171
- }
2172
- // Rehydrate session messages from storage so next turn has
2173
- // correct history even after abort (same as the non-snapshot path).
2174
- if (sessionHistory && sessionIdRef.current) {
2175
- refreshSessionMessagesFromStorage(sessionIdRef.current);
2176
- }
2177
- setSpans([]);
2178
- isStreamingRef.current = false;
2179
- abortController.current = null;
2180
- if (wallTimeIntervalRef.current) {
2181
- clearInterval(wallTimeIntervalRef.current);
2182
- wallTimeIntervalRef.current = null;
2183
- }
2184
- // DO NOT return — fall through to the drain block below so queued
2185
- // messages submitted during streaming are dispatched (or cleared).
2186
- // Previously this was `return`, silently dropping queued messages.
2187
- }
2188
- if (!abortSignal.aborted) {
2189
- isStreamingRef.current = false;
2190
- // isStreaming is now derived from streamCursor — already false from
2191
- // DISCARD_STREAMING dispatched above.
2192
- // Persistence is now handled per-turn by the accumulator flush inside the event loop.
2193
- // No persistSnapshotMessages needed — messages are already in SQLite.
2194
- if (!finalRunResult) {
2195
- throw new Error("Headless run produced no result frame");
2196
- }
2197
- if (pendingInteraction) {
2198
- // Flush any pending content from the paused run before interacting with resume flows.
2199
- if (accumulator.hasPendingContent()) {
2200
- const pausedMessages = accumulator.flush();
2201
- const sid = sessionIdRef.current;
2202
- if (sessionHistory && sid) {
2203
- sessionHistory.persistMessagesNonBlocking(sid, pausedMessages);
2204
- }
2205
- // Already not streaming (DISCARD_STREAMING above), so append as committed
2206
- setMessages((prev) => [...prev, ...pausedMessages]);
2207
- // Only reload from SQLite when no snapshot set sessionMessagesRef —
2208
- // the snapshot already has the complete model-level conversation.
2209
- if (!hasSnapshot && sid)
2210
- refreshSessionMessagesFromStorage(sid);
2211
- }
2212
- await continuePausedRun({
2213
- pendingInteraction,
2214
- ...(respondingArion?.name ? { respondingArionName: respondingArion.name } : {}),
2215
- startedAt: startTime,
2216
- abortSignal,
2217
- });
2218
- }
2219
- else {
2220
- if (!finalRunResult.ok) {
2221
- throw Object.assign(new Error(finalRunResult.error.message), {
2222
- ...(finalRunResult.error.details === undefined
2223
- ? {}
2224
- : { diagnostic: finalRunResult.error.details }),
2225
- });
2226
- }
2227
- // Flush any remaining content (e.g., final turn without turn_complete)
2228
- if (accumulator.hasPendingContent()) {
2229
- const remaining = accumulator.flush();
2230
- const sid = sessionIdRef.current;
2231
- if (sessionHistory && sid) {
2232
- sessionHistory.persistMessagesNonBlocking(sid, remaining);
2233
- }
2234
- // Already not streaming (DISCARD_STREAMING above), so append as committed
2235
- setMessages((prev) => [...prev, ...remaining]);
2236
- // Only reload from SQLite when no snapshot set sessionMessagesRef —
2237
- // the snapshot already has the complete model-level conversation.
2238
- if (!hasSnapshot && sid)
2239
- refreshSessionMessagesFromStorage(sid);
2240
- }
2241
- appendAssistantConversationHistory(sessionMessagesRef.current, respondingArion?.name);
2242
- // Fire-and-forget: post-turn bookkeeping must NEVER block returning to the prompt.
2243
- // These update memory counts and arion lists — background state the user doesn't
2244
- // need before typing their next message. Awaiting them was the root cause of
2245
- // the end-of-turn freeze: async DB calls blocked handleSubmit, which blocked
2246
- // the React render cycle, which blocked Ink's stdout.write, which blocked stdin.
2247
- void getCurrentMemoria()
2248
- .then((m) => m.count())
2249
- .then((count) => setMemoryCount(count))
2250
- .catch((err) => log.warn("Memory finalization failed:", err));
2251
- void refreshArionsList().catch((err) => log.warn("Arion list refresh failed:", err));
2252
- }
2253
- } // end if (!abortSignal.aborted)
2254
- }
2255
- catch (error) {
2256
- logInternalRunFailure(error);
2257
- // Stop render scheduler on error path
2258
- clearInterval(previewRenderTimer);
2259
- // Flush any pending content from the accumulator on abort/error path
2260
- // so partial results are persisted and visible to the user.
2261
- try {
2262
- if (accumulator.hasPendingContent()) {
2263
- const partial = accumulator.flush();
2264
- const sessionId = sessionIdRef.current;
2265
- if (sessionHistory && sessionId) {
2266
- sessionHistory.persistMessagesNonBlocking(sessionId, partial);
2267
- refreshSessionMessagesFromStorage(sessionId);
2268
- }
2269
- // Atomic: commit partial content and end streaming
2270
- msgDispatch({ type: "COMMIT_AND_END", flushed: partial });
2271
- }
2272
- else {
2273
- msgDispatch({ type: "DISCARD_STREAMING" });
2274
- }
2275
- }
2276
- catch (persistErr) {
2277
- log.warn("[InkREPL] Failed to persist messages on error path:", persistErr?.message);
2278
- }
2279
- setObsCtx(null);
2280
- // Stop wall-time timer on error
2281
- if (wallTimeIntervalRef.current) {
2282
- clearInterval(wallTimeIntervalRef.current);
2283
- wallTimeIntervalRef.current = null;
2284
- }
2285
- isStreamingRef.current = false;
2286
- // isStreaming already false from COMMIT_AND_END or DISCARD_STREAMING above
2287
- setSpans([]);
2288
- setMessages((prev) => [
2289
- ...prev,
2290
- createErrorMessage(`Error: ${error.message}`),
2291
- ]);
2292
- }
2293
- // Drain queued message (sent while streaming)
2294
- const queued = queuedMessageRef.current;
2295
- if (queued) {
2296
- queuedMessageRef.current = null;
2297
- setQueuedMessage(null);
2298
- // Use setTimeout to let React finish the current render cycle
2299
- setTimeout(() => handleSubmit(queued), 0);
2300
- }
2301
- })().catch((err) => {
2302
- // Safety net: catch any unhandled error from the detached async turn.
2303
- // All known error paths have their own try/catch inside, but this
2304
- // prevents unhandled rejections if a new code path is added without one.
2305
- logInternalRunFailure(err);
2306
- isStreamingRef.current = false;
2307
- msgDispatch({ type: "DISCARD_STREAMING" });
2308
- setMessages((prev) => [...prev, createErrorMessage(`Error: ${err.message}`)]);
2309
- // Drain queued message — same logic as the normal path.
2310
- // Without this, messages queued during a fatally-failed turn are silently lost.
2311
- const queued = queuedMessageRef.current;
2312
- if (queued) {
2313
- queuedMessageRef.current = null;
2314
- setQueuedMessage(null);
2315
- setTimeout(() => handleSubmit(queued), 0);
2316
- }
2317
- });
2318
- }, [
2319
- router,
2320
- session,
2321
- manager,
2322
- userName,
2323
- refreshArionsList,
2324
- aria,
2325
- getCurrentMemoria,
2326
- appendAssistantConversationHistory,
2327
- bindMemoriaSession,
2328
- continuePausedRun,
2329
- headlessRunKernel,
2330
- publishResumeMarker,
2331
- logInternalRunFailure,
2332
- model,
2333
- respondToHeadlessRunInteraction,
2334
- sessionHistory,
2335
- refreshSessionMessagesFromStorage,
2336
- authResolver,
2337
- waitForLocalControlReady,
2338
- ]);
2339
- // Stable ref so the inbox subscription can call handleSubmit without stale closures.
2340
- const handleSubmitRef = useRef(handleSubmit);
2341
- handleSubmitRef.current = handleSubmit;
2342
- // Auto-restore session on first render (from -r / -c CLI flags).
2343
- // Must fire BEFORE initialMessage so crash resume loads history first.
2344
- useEffect(() => {
2345
- const requestedResume = resumeSessionId || getPendingResumeSessionId();
2346
- if (!requestedResume) {
2347
- resumeRestoreReadyRef.current = Promise.resolve();
2348
- return;
2349
- }
2350
- if (requestedResume && !resumeSessionFired.current) {
2351
- resumeSessionFired.current = true;
2352
- resumeRestoreReadyRef.current = handleSelectSession(requestedResume);
2353
- }
2354
- }, [resumeSessionId, handleSelectSession]);
2355
- // Auto-submit initialMessage on first render (CLI arg support).
2356
- // Fires AFTER session resume so crash recovery loads history first.
2357
- useEffect(() => {
2358
- if (!initialMessage || initialMessageFired.current)
2359
- return;
2360
- let cancelled = false;
2361
- const submitInitialMessage = async () => {
2362
- const resumeRequested = resumeSessionId || getPendingResumeSessionId();
2363
- if (resumeRequested) {
2364
- await resumeRestoreReadyRef.current;
2365
- }
2366
- if (cancelled || initialMessageFired.current) {
2367
- return;
2368
- }
2369
- initialMessageFired.current = true;
2370
- await handleSubmit(initialMessage);
2371
- };
2372
- void submitInitialMessage();
2373
- return () => {
2374
- cancelled = true;
2375
- };
2376
- }, [initialMessage, handleSubmit, resumeSessionId]);
2377
- const handleCancel = useCallback(() => {
2378
- if (abortController.current) {
2379
- abortController.current.abort();
2380
- abortController.current = null;
2381
- isStreamingRef.current = false;
2382
- msgDispatch({ type: "DISCARD_STREAMING" });
2383
- // Stop wall-time timer on cancel
2384
- if (wallTimeIntervalRef.current) {
2385
- clearInterval(wallTimeIntervalRef.current);
2386
- wallTimeIntervalRef.current = null;
2387
- }
2388
- }
2389
- }, []);
2390
- // Cancel a queued message and move its text back to the input box for editing.
2391
- // Called when user presses Escape during streaming while a queued message is displayed.
2392
- // "undo last message" UX: the message is not sent,
2393
- // its text is placed in the input, and the user can edit and re-submit.
2394
- const handleCancelQueuedMessage = useCallback(() => {
2395
- const text = queuedMessageRef.current;
2396
- if (!text)
2397
- return;
2398
- queuedMessageRef.current = null;
2399
- setQueuedMessage(null);
2400
- setPrefillInput(text);
2401
- }, []);
2402
- const handleCycleDisplayMode = useCallback(() => {
2403
- setDisplayMode((prevMode) => {
2404
- const nextMode = nextDisplayMode(prevMode);
2405
- setDisplayConfig((prevConfig) => {
2406
- const overrides = deriveDisplayOverrides(prevMode, prevConfig);
2407
- return resolveDisplayConfig(nextMode, overrides);
2408
- });
2409
- return nextMode;
2410
- });
2411
- }, []);
2412
- const handleToggleThinking = useCallback(() => {
2413
- setDisplayConfig((prev) => ({ ...prev, showThinking: !prev.showThinking }));
2414
- }, []);
2415
- const handleToggleCosts = useCallback(() => {
2416
- setDisplayConfig((prev) => ({ ...prev, showCosts: !prev.showCosts }));
2417
- }, []);
2418
- const handleToggleTraces = useCallback(() => {
2419
- setDisplayConfig((prev) => ({ ...prev, showTraces: !prev.showTraces }));
2420
- }, []);
2421
- const handleCommand = useCallback(async (cmd, _args) => {
2422
- switch (cmd) {
2423
- case "help": {
2424
- const helpText = COMMANDS.map((c) => `/${c.name} - ${c.description}`).join("\n");
2425
- setMessages((prev) => [...prev, createSystemMessage(helpText)]);
2426
- break;
2427
- }
2428
- case "arions": {
2429
- try {
2430
- const { resultFrame } = await dispatchHeadlessCommand("arion.list", {});
2431
- if (!resultFrame) {
2432
- throw new Error("arion.list produced no result frame");
2433
- }
2434
- if (!resultFrame.ok) {
2435
- throw new Error(resultFrame.error.message);
2436
- }
2437
- const list = resultFrame.result.arions ?? [];
2438
- const arionText = list.map((a) => `${a.emoji} ${a.name} (${a.status})`).join("\n");
2439
- setMessages((prev) => [...prev, createSystemMessage(arionText || "No arions found")]);
2440
- }
2441
- catch (error) {
2442
- setMessages((prev) => [
2443
- ...prev,
2444
- createErrorMessage(`Failed to list arions: ${error.message}`),
2445
- ]);
2446
- }
2447
- break;
2448
- }
2449
- case "sound": {
2450
- const arg = _args?.trim().toLowerCase();
2451
- if (arg === "on" || arg === "off") {
2452
- // Quick inline toggle still works for scripting/power users
2453
- const on = arg === "on";
2454
- sounds.setEnabled(on);
2455
- const cfg = loadConfig();
2456
- cfg.soundEnabled = on;
2457
- saveConfig(cfg);
2458
- setMessages((prev) => [
2459
- ...prev,
2460
- createSystemMessage(`Sound notifications ${on ? "enabled 🔊" : "disabled 🔇"}.`),
2461
- ]);
2462
- }
2463
- else {
2464
- // Open the polished sound overlay
2465
- setOpenSoundOverlaySignal((prev) => (prev ?? 0) + 1);
2466
- }
2467
- break;
2468
- }
2469
- case "exit": {
2470
- // Use Ink's exit() instead of process.exit(0) so the graceful
2471
- // shutdown path in startInkRepl runs (markCompleted, endSession).
2472
- exit();
2473
- break;
2474
- }
2475
- case "hatch": {
2476
- const hatchName = _args?.trim();
2477
- if (!hatchName) {
2478
- setMessages((prev) => [
2479
- ...prev,
2480
- createSystemMessage("Usage: /hatch <name> [description]\nExample: /hatch Luna a dreamy, creative assistant\n\nDescribe the arion you want to create and ARIA will help design and hatch them."),
2481
- ]);
2482
- break;
2483
- }
2484
- try {
2485
- const parts = hatchName.split(/\s+/);
2486
- const name = parts[0];
2487
- const description = parts.slice(1).join(" ");
2488
- const { resultFrame } = await dispatchHeadlessCommand("arion.hatch", {
2489
- name,
2490
- ...(description ? { description } : {}),
2491
- });
2492
- if (!resultFrame) {
2493
- throw new Error("arion.hatch produced no result frame");
2494
- }
2495
- if (!resultFrame.ok) {
2496
- throw new Error(resultFrame.error.message);
2497
- }
2498
- const prompt = resultFrame.result.prompt;
2499
- if (!prompt) {
2500
- throw new Error("arion.hatch did not return a prompt");
2501
- }
2502
- await handleSubmit(prompt);
2503
- }
2504
- catch (error) {
2505
- setMessages((prev) => [
2506
- ...prev,
2507
- createErrorMessage(`Error: ${error.message}`),
2508
- ]);
2509
- }
2510
- break;
2511
- }
2512
- case "remember": {
2513
- try {
2514
- const { resultFrame } = await dispatchHeadlessCommand("memory.remember", {
2515
- text: _args ?? "",
2516
- });
2517
- if (!resultFrame) {
2518
- throw new Error("memory.remember produced no result frame");
2519
- }
2520
- if (!resultFrame.ok) {
2521
- throw new Error(resultFrame.error.message);
2522
- }
2523
- const result = resultFrame.result;
2524
- setMessages((prev) => [
2525
- ...prev,
2526
- createSystemMessage(String(result.message ?? "Memory saved.")),
2527
- ]);
2528
- const count = result.data?.count;
2529
- if (typeof count === "number") {
2530
- setMemoryCount(count);
2531
- }
2532
- }
2533
- catch (error) {
2534
- setMessages((prev) => [
2535
- ...prev,
2536
- createErrorMessage(`Error remembering: ${error.message}`),
2537
- ]);
2538
- }
2539
- break;
2540
- }
2541
- case "recall": {
2542
- try {
2543
- const { resultFrame } = await dispatchHeadlessCommand("memory.recall", {
2544
- query: _args ?? "",
2545
- });
2546
- if (!resultFrame) {
2547
- throw new Error("memory.recall produced no result frame");
2548
- }
2549
- if (!resultFrame.ok) {
2550
- throw new Error(resultFrame.error.message);
2551
- }
2552
- const result = resultFrame.result;
2553
- const recalled = result.memories;
2554
- let content = String(result.message ?? "No memories found.");
2555
- if (Array.isArray(recalled) && recalled.length > 0) {
2556
- const resultText = recalled
2557
- .map((raw, i) => {
2558
- const item = raw;
2559
- const text = typeof item.content === "string" ? item.content : String(raw);
2560
- const preview = text.length > 80 ? text.slice(0, 80) + "..." : text;
2561
- const score = typeof item.confidence === "number"
2562
- ? `(${Math.round(item.confidence * 100)}%) `
2563
- : "";
2564
- const createdAt = item.createdAt instanceof Date
2565
- ? item.createdAt.toISOString().slice(0, 10)
2566
- : String(item.createdAt ?? "").slice(0, 10);
2567
- const idText = typeof item.id === "string" ? item.id.slice(0, 8) : "unknown";
2568
- const network = typeof item.network === "string" && item.network.trim().length > 0
2569
- ? item.network
2570
- : "default";
2571
- return `[${i + 1}] ${score}${preview} [${network}] ${createdAt} (${idText})`;
2572
- })
2573
- .join("\n");
2574
- content = `Found ${recalled.length} memories:\n${resultText}`;
2575
- }
2576
- setMessages((prev) => [...prev, createSystemMessage(content)]);
2577
- }
2578
- catch (error) {
2579
- setMessages((prev) => [
2580
- ...prev,
2581
- createErrorMessage(`Error recalling: ${error.message}`),
2582
- ]);
2583
- }
2584
- break;
2585
- }
2586
- case "recall_knowledge": {
2587
- if (_args?.trim()) {
2588
- try {
2589
- const { resultFrame } = await dispatchHeadlessCommand("memory.recall_knowledge", {
2590
- topic: _args.trim(),
2591
- limit: 5,
2592
- });
2593
- if (!resultFrame) {
2594
- throw new Error("memory.recall_knowledge produced no result frame");
2595
- }
2596
- if (!resultFrame.ok) {
2597
- throw new Error(resultFrame.error.message);
2598
- }
2599
- const result = resultFrame.result;
2600
- const results = Array.isArray(result.tools) ? result.tools : [];
2601
- if (results.length === 0) {
2602
- setMessages((prev) => [
2603
- ...prev,
2604
- createSystemMessage(`No tools found for: "${_args.trim()}"`),
2605
- ]);
2606
- }
2607
- else {
2608
- const resultText = results
2609
- .map((k, i) => {
2610
- const desc = k.description.length > 80
2611
- ? k.description.slice(0, 80) + "..."
2612
- : k.description;
2613
- return `[${i + 1}] ${k.name} - ${desc}`;
2614
- })
2615
- .join("\n");
2616
- setMessages((prev) => [
2617
- ...prev,
2618
- createSystemMessage(`Found ${results.length} tool items:\n${resultText}`),
2619
- ]);
2620
- }
2621
- }
2622
- catch (error) {
2623
- setMessages((prev) => [
2624
- ...prev,
2625
- createErrorMessage(`Error recalling tools: ${error.message}`),
2626
- ]);
2627
- }
2628
- }
2629
- else {
2630
- setMessages((prev) => [
2631
- ...prev,
2632
- createSystemMessage("Usage: /recall_knowledge <topic>\nSearches stored tools (skills, procedures, techniques) by topic."),
2633
- ]);
2634
- }
2635
- break;
2636
- }
2637
- case "send": {
2638
- try {
2639
- const senderName = session.getPrimary()?.name ?? "ARIA";
2640
- const { resultFrame } = await dispatchHeadlessCommand("message.send", {
2641
- args: _args ?? "",
2642
- senderName,
2643
- });
2644
- if (!resultFrame) {
2645
- throw new Error("message.send produced no result frame");
2646
- }
2647
- const result = resultFrame.ok
2648
- ? resultFrame.result
2649
- : { message: resultFrame.error.message };
2650
- setMessages((prev) => [
2651
- ...prev,
2652
- resultFrame.ok
2653
- ? createSystemMessage(result.message ?? "Message sent.")
2654
- : createErrorMessage(result.message ?? "Failed to send message."),
2655
- ]);
2656
- }
2657
- catch (error) {
2658
- setMessages((prev) => [
2659
- ...prev,
2660
- createErrorMessage(`Failed to send peer message: ${error.message}`),
2661
- ]);
2662
- }
2663
- break;
2664
- }
2665
- case "clear": {
2666
- // Block clear while streaming to prevent data corruption (R2-C2 fix)
2667
- if (isStreamingRef.current)
2668
- break;
2669
- const existingSessionId = sessionIdRef.current;
2670
- if (existingSessionId) {
2671
- await endMemoriaSession(existingSessionId);
2672
- }
2673
- session.clearHistory();
2674
- sessionMessagesRef.current = [];
2675
- sessionIdRef.current = null;
2676
- setPendingResumeSessionId(null);
2677
- _shutdownState.sessionId = null;
2678
- // Close JSONL logger so next submit creates a fresh one for the new session
2679
- if (_shutdownState.jsonlLogger) {
2680
- void _shutdownState.jsonlLogger.close().catch((err) => {
2681
- log.warn("[InkREPL] JSONL logger close failed:", err?.message ?? err);
2682
- });
2683
- _shutdownState.jsonlLogger = null;
2684
- }
2685
- setSpans([]);
2686
- setPipelineTiming(undefined);
2687
- // Clear the terminal screen — Ink's <Static> can't remove already-rendered
2688
- // items from scrollback, so we clear the terminal directly.
2689
- process.stdout.write("\x1b[2J\x1b[H");
2690
- resetStaticRenderer();
2691
- setMessages([createSystemMessage("Conversation cleared.")]);
2692
- break;
2693
- }
2694
- case "resume": {
2695
- if (_args?.trim()) {
2696
- await handleSelectSession(_args.trim());
2697
- }
2698
- else {
2699
- setOpenSessionOverlaySignal((n) => (n ?? 0) + 1);
2700
- }
2701
- break;
2702
- }
2703
- case "fork": {
2704
- // Block fork while streaming to prevent data corruption (R2-C2 pattern)
2705
- if (isStreamingRef.current)
2706
- break;
2707
- const forkParts = _args?.trim().split(/\s+/).filter(Boolean) ?? [];
2708
- const forkStay = forkParts.includes("--stay");
2709
- const forkFiltered = forkParts.filter((p) => p !== "--stay");
2710
- let forkSourceId = sessionIdRef.current;
2711
- let forkMessageLimit;
2712
- // Parse args: /fork, /fork <n>, /fork <id>, /fork <id> <n>
2713
- if (forkFiltered.length === 1 && /^\d+$/.test(forkFiltered[0])) {
2714
- forkMessageLimit = parseInt(forkFiltered[0], 10);
2715
- }
2716
- else if (forkFiltered.length >= 1 && forkFiltered[0]) {
2717
- forkSourceId = forkFiltered[0];
2718
- if (forkFiltered[1] && /^\d+$/.test(forkFiltered[1])) {
2719
- forkMessageLimit = parseInt(forkFiltered[1], 10);
2720
- }
2721
- }
2722
- if (!forkSourceId) {
2723
- setMessages((prev) => [
2724
- ...prev,
2725
- createSystemMessage("No active session to fork. Start a conversation first or specify a session ID: /fork <session-id>"),
2726
- ]);
2727
- break;
2728
- }
2729
- try {
2730
- const { resultFrame } = await dispatchHeadlessCommand("session.fork", {
2731
- sessionId: forkSourceId,
2732
- ...(forkMessageLimit !== undefined ? { messageLimit: forkMessageLimit } : {}),
2733
- });
2734
- if (!resultFrame) {
2735
- throw new Error("session.fork produced no result frame");
2736
- }
2737
- if (!resultFrame.ok) {
2738
- throw new Error(resultFrame.error.message);
2739
- }
2740
- const forkResult = resultFrame.result;
2741
- if (forkStay) {
2742
- setMessages((prev) => [
2743
- ...prev,
2744
- createSystemMessage(`🍴 Forked → ${forkResult.newSessionId.slice(0, 8)} (${forkResult.messagesCopied} msgs). Resume it later with /resume ${forkResult.newSessionId.slice(0, 8)}`),
2745
- ]);
2746
- }
2747
- else {
2748
- setMessages((prev) => [
2749
- ...prev,
2750
- createSystemMessage(`🍴 Forked ${forkResult.sourceSessionId.slice(0, 8)}→${forkResult.newSessionId.slice(0, 8)} (${forkResult.messagesCopied} msgs)`),
2751
- ]);
2752
- await handleSelectSession(forkResult.newSessionId);
2753
- }
2754
- }
2755
- catch (error) {
2756
- setMessages((prev) => [
2757
- ...prev,
2758
- createErrorMessage(`Fork failed: ${error.message}`),
2759
- ]);
2760
- }
2761
- break;
2762
- }
2763
- case "rest": {
2764
- const restName = _args?.trim();
2765
- if (!restName) {
2766
- setMessages((prev) => [...prev, createSystemMessage("Usage: /rest <arion-name>")]);
2767
- break;
2768
- }
2769
- try {
2770
- const { resultFrame } = await dispatchHeadlessCommand("arion.rest", { name: restName });
2771
- if (!resultFrame) {
2772
- throw new Error("arion.rest produced no result frame");
2773
- }
2774
- if (!resultFrame.ok) {
2775
- throw new Error(resultFrame.error.message);
2776
- }
2777
- setMessages((prev) => [...prev, createSystemMessage(`${restName} is now resting.`)]);
2778
- await refreshArionsList();
2779
- }
2780
- catch (e) {
2781
- setMessages((prev) => [
2782
- ...prev,
2783
- createErrorMessage(`Cannot rest ${restName}: ${e.message}`),
2784
- ]);
2785
- }
2786
- break;
2787
- }
2788
- case "wake": {
2789
- const wakeName = _args?.trim();
2790
- if (!wakeName) {
2791
- setMessages((prev) => [...prev, createSystemMessage("Usage: /wake <arion-name>")]);
2792
- break;
2793
- }
2794
- try {
2795
- const { resultFrame } = await dispatchHeadlessCommand("arion.wake", { name: wakeName });
2796
- if (!resultFrame) {
2797
- throw new Error("arion.wake produced no result frame");
2798
- }
2799
- if (!resultFrame.ok) {
2800
- throw new Error(resultFrame.error.message);
2801
- }
2802
- setMessages((prev) => [...prev, createSystemMessage(`${wakeName} is now awake.`)]);
2803
- await refreshArionsList();
2804
- }
2805
- catch (e) {
2806
- setMessages((prev) => [
2807
- ...prev,
2808
- createErrorMessage(`Cannot wake ${wakeName}: ${e.message}`),
2809
- ]);
2810
- }
2811
- break;
2812
- }
2813
- case "become": {
2814
- const becomeName = _args?.trim();
2815
- if (!becomeName) {
2816
- setMessages((prev) => [...prev, createSystemMessage("Usage: /become <arion-name>")]);
2817
- break;
2818
- }
2819
- try {
2820
- const { resultFrame } = await dispatchHeadlessCommand("arion.become", {
2821
- name: becomeName,
2822
- });
2823
- if (!resultFrame) {
2824
- throw new Error("arion.become produced no result frame");
2825
- }
2826
- if (!resultFrame.ok) {
2827
- throw new Error(resultFrame.error.message);
2828
- }
2829
- const arion = resultFrame.result.arion;
2830
- if (!arion) {
2831
- throw new Error(`Arion ${becomeName} not found`);
2832
- }
2833
- const config = loadConfig();
2834
- const becomeTier = config.preferredTier || "balanced";
2835
- const becomeTierModel = getDefaultModelByTier(becomeTier, credentialHints);
2836
- const becomeModel = arion.preferredModel || becomeTierModel?.shortName || initialModel;
2837
- activeArionRef.current = arion.name;
2838
- setActiveArion(arion.name);
2839
- setModel(becomeModel);
2840
- modelRef.current = becomeModel;
2841
- // RunSession is now per-submit — no need to recreate here
2842
- setMessages((prev) => [
2843
- ...prev,
2844
- createSystemMessage(`Switched to ${arion.name} (model: ${becomeModel})`),
2845
- ]);
2846
- const memoria = await getCurrentMemoria();
2847
- const count = await memoria.count();
2848
- setMemoryCount(count);
2849
- await refreshArionsList();
2850
- }
2851
- catch (e) {
2852
- setMessages((prev) => [
2853
- ...prev,
2854
- createErrorMessage(`Cannot become ${becomeName}: ${e.message}`),
2855
- ]);
2856
- }
2857
- break;
2858
- }
2859
- case "model": {
2860
- const modelArg = _args?.trim() || "";
2861
- const providerMatches = [...modelArg.matchAll(/--provider\s+([^\s]+)/gi)]
2862
- .map((match) => match[1]?.trim())
2863
- .filter(Boolean);
2864
- const useEnv = /--use-env\b/i.test(modelArg);
2865
- const sanitizedModelArg = modelArg
2866
- .replace(/--provider\s+([^\s]+)/gi, "")
2867
- .replace(/--use-env\b/gi, "")
2868
- .trim();
2869
- const normalizedModelArg = sanitizedModelArg.toLowerCase();
2870
- if (normalizedModelArg === "refresh") {
2871
- setMessages((prev) => [
2872
- ...prev,
2873
- createSystemMessage(useEnv
2874
- ? "Importing env credentials and discovering models..."
2875
- : "Discovering models from all providers..."),
2876
- ]);
2877
- try {
2878
- if (useEnv) {
2879
- await dispatchHeadlessCommand("auth.import_env_session", {
2880
- ...(providerMatches.length ? { providers: providerMatches } : {}),
2881
- });
2882
- }
2883
- const { resultFrame } = await dispatchHeadlessCommand("model.refresh", providerMatches.length ? { providers: providerMatches } : {});
2884
- if (!resultFrame) {
2885
- throw new Error("model.refresh produced no result frame");
2886
- }
2887
- if (!resultFrame.ok) {
2888
- throw new Error(resultFrame.error.message);
2889
- }
2890
- const discovered = resultFrame.result.models ??
2891
- availableModels;
2892
- const refreshedSelectable = getCliSelectableModels(discovered);
2893
- setMessages((prev) => [
2894
- ...prev,
2895
- createSystemMessage(`Updated model catalog (${refreshedSelectable.length} selectable models)`),
2896
- ]);
2897
- }
2898
- catch (err) {
2899
- setMessages((prev) => [
2900
- ...prev,
2901
- createErrorMessage(`Discovery failed: ${err.message}`),
2902
- ]);
2903
- }
2904
- finally {
2905
- if (useEnv) {
2906
- await dispatchHeadlessCommand("auth.clear_session", {}).catch(() => { });
2907
- }
2908
- }
2909
- break;
2910
- }
2911
- if (!normalizedModelArg || normalizedModelArg === "list") {
2912
- try {
2913
- const { resultFrame } = await dispatchHeadlessCommand("model.list", providerMatches.length ? { providers: providerMatches } : {});
2914
- if (!resultFrame) {
2915
- throw new Error("model.list produced no result frame");
2916
- }
2917
- if (!resultFrame.ok) {
2918
- throw new Error(resultFrame.error.message);
2919
- }
2920
- const listedModels = resultFrame.result.models ?? availableModels;
2921
- const currentModel = resultFrame.result.currentModel ?? model;
2922
- const listedSelectable = getCliSelectableModels(listedModels);
2923
- const modelList = listedSelectable
2924
- .map((m) => ` ${m.shortName.toLowerCase() === currentModel.toLowerCase() ? "\u2192 " : " "}${m.provider}/${m.shortName} (${m.tier})`)
2925
- .join("\n");
2926
- setMessages((prev) => [
2927
- ...prev,
2928
- createSystemMessage(`Current model: ${currentModel}\n\nAvailable models:\n${modelList}\n\nUse /model <name> or /model <provider>/<name> to switch.`),
2929
- ]);
2930
- }
2931
- catch (error) {
2932
- setMessages((prev) => [
2933
- ...prev,
2934
- createErrorMessage(`Model listing failed: ${error.message}`),
2935
- ]);
2936
- }
2937
- }
2938
- else {
2939
- try {
2940
- const { resultFrame } = await dispatchHeadlessCommand("model.set", {
2941
- model: sanitizedModelArg,
2942
- });
2943
- if (!resultFrame) {
2944
- throw new Error("model.set produced no result frame");
2945
- }
2946
- if (!resultFrame.ok) {
2947
- throw new Error(resultFrame.error.message);
2948
- }
2949
- const currentModel = resultFrame.result.currentModel ?? sanitizedModelArg;
2950
- setModel(currentModel);
2951
- modelRef.current = currentModel;
2952
- setMessages((prev) => [
2953
- ...prev,
2954
- createSystemMessage(`Switched model to ${currentModel}`),
2955
- ]);
2956
- }
2957
- catch (error) {
2958
- setMessages((prev) => [
2959
- ...prev,
2960
- createErrorMessage(`Unknown model: ${modelArg}. Use /model list to see available models. ${error.message}`),
2961
- ]);
2962
- }
2963
- }
2964
- break;
2965
- }
2966
- case "auth": {
2967
- const authArgs = _args?.trim() || "";
2968
- const [subcommand, ...rest] = authArgs.length > 0 ? authArgs.split(/\s+/) : [];
2969
- const normalizedSubcommand = (subcommand ?? "").toLowerCase();
2970
- if (normalizedSubcommand === "import-env") {
2971
- try {
2972
- const providers = rest.filter((value) => value.trim().length > 0);
2973
- const { resultFrame } = await dispatchHeadlessCommand("auth.import_env_session", {
2974
- ...(providers.length ? { providers } : {}),
2975
- });
2976
- if (!resultFrame) {
2977
- throw new Error("auth.import_env_session produced no result frame");
2978
- }
2979
- if (!resultFrame.ok) {
2980
- throw new Error(resultFrame.error.message);
2981
- }
2982
- const imported = (resultFrame.result.providers ??
2983
- []);
2984
- setMessages((prev) => [
2985
- ...prev,
2986
- createSystemMessage(imported.length > 0
2987
- ? `Imported session credentials for: ${imported.join(", ")}`
2988
- : "No matching env credentials found to import into session scope."),
2989
- ]);
2990
- }
2991
- catch (error) {
2992
- setMessages((prev) => [
2993
- ...prev,
2994
- createErrorMessage(`Auth import failed: ${error.message}`),
2995
- ]);
2996
- }
2997
- break;
2998
- }
2999
- if (normalizedSubcommand === "clear-session") {
3000
- try {
3001
- const { resultFrame } = await dispatchHeadlessCommand("auth.clear_session", {});
3002
- if (!resultFrame) {
3003
- throw new Error("auth.clear_session produced no result frame");
3004
- }
3005
- if (!resultFrame.ok) {
3006
- throw new Error(resultFrame.error.message);
3007
- }
3008
- setMessages((prev) => [
3009
- ...prev,
3010
- createSystemMessage("Cleared session credential overlay."),
3011
- ]);
3012
- }
3013
- catch (error) {
3014
- setMessages((prev) => [
3015
- ...prev,
3016
- createErrorMessage(`Auth clear failed: ${error.message}`),
3017
- ]);
3018
- }
3019
- break;
3020
- }
3021
- if (normalizedSubcommand === "status-session") {
3022
- try {
3023
- const { resultFrame } = await dispatchHeadlessCommand("auth.status", {});
3024
- if (!resultFrame) {
3025
- throw new Error("auth.status produced no result frame");
3026
- }
3027
- if (!resultFrame.ok) {
3028
- throw new Error(resultFrame.error.message);
3029
- }
3030
- const providers = (resultFrame.result
3031
- .sessionProviders ?? []);
3032
- setMessages((prev) => [
3033
- ...prev,
3034
- createSystemMessage(providers.length > 0
3035
- ? `Session credential overlay providers: ${providers.join(", ")}`
3036
- : "No session credential overlay providers are currently set."),
3037
- ]);
3038
- }
3039
- catch (error) {
3040
- setMessages((prev) => [
3041
- ...prev,
3042
- createErrorMessage(`Auth status failed: ${error.message}`),
3043
- ]);
3044
- }
3045
- break;
3046
- }
3047
- setMessages((prev) => [
3048
- ...prev,
3049
- createSystemMessage("Usage: /auth import-env [provider ...] | /auth clear-session | /auth status-session"),
3050
- ]);
3051
- break;
3052
- }
3053
- case "autonomy": {
3054
- if (_args && _args.trim()) {
3055
- try {
3056
- const level = _args.trim().toLowerCase();
3057
- const { resultFrame } = await dispatchHeadlessCommand("config.autonomy.set", {
3058
- autonomy: level,
3059
- });
3060
- if (!resultFrame) {
3061
- throw new Error("config.autonomy.set produced no result frame");
3062
- }
3063
- if (!resultFrame.ok) {
3064
- throw new Error(resultFrame.error.message);
3065
- }
3066
- setMessages((prev) => [
3067
- ...prev,
3068
- createSystemMessage(`Autonomy level set to: ${level}`),
3069
- ]);
3070
- }
3071
- catch (error) {
3072
- setMessages((prev) => [
3073
- ...prev,
3074
- createErrorMessage(`Invalid autonomy level: ${_args.trim().toLowerCase()}. ${error.message}`),
3075
- ]);
3076
- }
3077
- }
3078
- else {
3079
- // No args — show interactive selector
3080
- setShowAutonomySelector(true);
3081
- }
3082
- break;
3083
- }
3084
- case "memories": {
3085
- try {
3086
- const { resultFrame } = await dispatchHeadlessCommand("memory.list", {
3087
- limit: 20,
3088
- });
3089
- if (!resultFrame) {
3090
- throw new Error("memory.list produced no result frame");
3091
- }
3092
- if (!resultFrame.ok) {
3093
- throw new Error(resultFrame.error.message);
3094
- }
3095
- const result = resultFrame.result;
3096
- const count = typeof result.count === "number" ? result.count : 0;
3097
- if (count === 0) {
3098
- setMessages((prev) => [
3099
- ...prev,
3100
- createSystemMessage("No memories stored. Use /remember to store memories."),
3101
- ]);
3102
- }
3103
- else {
3104
- const memories = Array.isArray(result.memories) ? result.memories : [];
3105
- const memoryList = memories
3106
- .map((m) => ` [${m.id.slice(0, 8)}] ${m.content.slice(0, 80)}${m.content.length > 80 ? "..." : ""}`)
3107
- .join("\n");
3108
- setMessages((prev) => [
3109
- ...prev,
3110
- createSystemMessage(`Memories (${count} total):\n${memoryList}`),
3111
- ]);
3112
- }
3113
- }
3114
- catch (e) {
3115
- setMessages((prev) => [
3116
- ...prev,
3117
- createErrorMessage(`Failed to list memories: ${e.message}`),
3118
- ]);
3119
- }
3120
- break;
3121
- }
3122
- case "forget": {
3123
- try {
3124
- const { resultFrame } = await dispatchHeadlessCommand("memory.forget", {
3125
- id: _args ?? "",
3126
- });
3127
- if (!resultFrame) {
3128
- throw new Error("memory.forget produced no result frame");
3129
- }
3130
- if (!resultFrame.ok) {
3131
- throw new Error(resultFrame.error.message);
3132
- }
3133
- const result = resultFrame.result;
3134
- setMessages((prev) => [
3135
- ...prev,
3136
- createSystemMessage(String(result.message ?? "Memory deleted.")),
3137
- ]);
3138
- if (typeof result.count === "number") {
3139
- setMemoryCount(result.count);
3140
- }
3141
- }
3142
- catch (e) {
3143
- setMessages((prev) => [
3144
- ...prev,
3145
- createErrorMessage(`Failed to delete memory: ${e.message}`),
3146
- ]);
3147
- }
3148
- break;
3149
- }
3150
- case "daemon": {
3151
- const action = (_args ?? "").trim().toLowerCase();
3152
- if (action === "start" || action === "stop" || action === "restart") {
3153
- await handleDaemonAction(action);
3154
- break;
3155
- }
3156
- if (action.length > 0) {
3157
- setMessages((prev) => [
3158
- ...prev,
3159
- createSystemMessage("Usage: /daemon [start|stop|restart]"),
3160
- ]);
3161
- break;
3162
- }
3163
- void (async () => {
3164
- await refreshDaemonStatus();
3165
- setDaemonActionStatus(null);
3166
- setOpenDaemonOverlaySignal((prev) => (prev ?? 0) + 1);
3167
- })();
3168
- break;
3169
- }
3170
- case "terminal-setup": {
3171
- try {
3172
- const { resultFrame } = await dispatchHeadlessCommand("system.terminal_setup", {});
3173
- if (!resultFrame) {
3174
- throw new Error("system.terminal_setup produced no result frame");
3175
- }
3176
- if (!resultFrame.ok) {
3177
- throw new Error(resultFrame.error.message);
3178
- }
3179
- const result = resultFrame.result;
3180
- setMessages((prev) => [
3181
- ...prev,
3182
- createSystemMessage(result.supported === false
3183
- ? (result.message ?? "terminal-setup is unavailable.")
3184
- : (result.output ?? "")),
3185
- ]);
3186
- }
3187
- catch (error) {
3188
- setMessages((prev) => [
3189
- ...prev,
3190
- createErrorMessage(`terminal-setup failed: ${error.message}`),
3191
- ]);
3192
- }
3193
- break;
3194
- }
3195
- case "login": {
3196
- try {
3197
- await dispatchAuthCommand("auth.login", _args ?? "");
3198
- }
3199
- catch (error) {
3200
- setMessages((prev) => [
3201
- ...prev,
3202
- createErrorMessage(`Login failed: ${error.message}`),
3203
- ]);
3204
- }
3205
- break;
3206
- }
3207
- case "logout": {
3208
- try {
3209
- await dispatchAuthCommand("auth.logout", _args ?? "");
3210
- }
3211
- catch (error) {
3212
- setMessages((prev) => [
3213
- ...prev,
3214
- createErrorMessage(`Logout failed: ${error.message}`),
3215
- ]);
3216
- }
3217
- break;
3218
- }
3219
- case "theme": {
3220
- const themeArg = (_args ?? "").trim().toLowerCase();
3221
- if (!themeArg) {
3222
- // No argument: open interactive theme selector overlay
3223
- setOpenThemeOverlaySignal((n) => (n ?? 0) + 1);
3224
- break;
3225
- }
3226
- try {
3227
- const { resultFrame } = await dispatchHeadlessCommand("config.theme.set", {
3228
- theme: themeArg,
3229
- });
3230
- if (!resultFrame) {
3231
- throw new Error("config.theme.set produced no result frame");
3232
- }
3233
- if (!resultFrame.ok) {
3234
- throw new Error(resultFrame.error.message);
3235
- }
3236
- setMessages((prev) => [
3237
- ...prev,
3238
- createSystemMessage(`Theme changed to ${getThemeDefinition(themeArg)?.displayName ?? themeArg} (${themeArg})`),
3239
- ]);
3240
- }
3241
- catch (error) {
3242
- const available = getAvailableThemes();
3243
- setMessages((prev) => [
3244
- ...prev,
3245
- createErrorMessage(`Unknown theme "${themeArg}". Available: ${available.join(", ")}. ${error.message}`),
3246
- ]);
3247
- }
3248
- break;
3249
- }
3250
- case "invite": {
3251
- await handleCreateInvite(_args?.trim());
3252
- break;
3253
- }
3254
- case "join": {
3255
- const inviteToken = _args?.trim();
3256
- if (inviteToken) {
3257
- await handleJoinInviteToken(inviteToken);
3258
- }
3259
- else {
3260
- setJoinInviteError(null);
3261
- setOpenJoinInviteOverlaySignal((n) => (n ?? 0) + 1);
3262
- }
3263
- break;
3264
- }
3265
- case "peers": {
3266
- setOpenPeersOverlaySignal((n) => (n ?? 0) + 1);
3267
- break;
3268
- }
3269
- case "clients": {
3270
- setOpenClientsOverlaySignal((n) => (n ?? 0) + 1);
3271
- break;
3272
- }
3273
- default: {
3274
- setMessages((prev) => [
3275
- ...prev,
3276
- createSystemMessage(`Unknown command: /${cmd}. Type /help for available commands.`),
3277
- ]);
3278
- break;
3279
- }
3280
- }
3281
- }, [
3282
- manager,
3283
- router,
3284
- aria,
3285
- model,
3286
- availableModels,
3287
- selectableModels,
3288
- credentialHints,
3289
- dispatchHeadlessCommand,
3290
- exit,
3291
- getCurrentMemoria,
3292
- handleSubmit,
3293
- handleSelectSession,
3294
- endMemoriaSession,
3295
- initialModel,
3296
- refreshArionsList,
3297
- session,
3298
- resetStaticRenderer,
3299
- ]);
3300
- handleCommandRef.current = handleCommand;
3301
- const handleSelectArion = useCallback(async (name, mode) => {
3302
- if (mode === "mention")
3303
- return;
3304
- try {
3305
- if (mode === "become") {
3306
- const { resultFrame } = await dispatchHeadlessCommand("arion.become", { name });
3307
- if (!resultFrame) {
3308
- throw new Error("arion.become produced no result frame");
3309
- }
3310
- if (!resultFrame.ok) {
3311
- throw new Error(resultFrame.error.message);
3312
- }
3313
- const arion = resultFrame.result.arion;
3314
- if (!arion) {
3315
- throw new Error(`Arion ${name} not found`);
3316
- }
3317
- const config = loadConfig();
3318
- const tierToUse = config.preferredTier || "balanced";
3319
- const tierModel = getDefaultModelByTier(tierToUse, credentialHints);
3320
- const preferredModel = arion.preferredModel
3321
- ? getModelByShortName(arion.preferredModel)
3322
- : undefined;
3323
- const effectiveModel = (preferredModel
3324
- ? selectRunnableModelVariant(preferredModel, credentialHints).shortName
3325
- : undefined) ||
3326
- tierModel?.shortName ||
3327
- initialModel;
3328
- setModel(effectiveModel);
3329
- modelRef.current = effectiveModel;
3330
- // RunSession is now per-submit — no need to recreate here
3331
- setMessages((prev) => [
3332
- ...prev,
3333
- createSystemMessage(`Switched to ${arion.name} (model: ${effectiveModel})`),
3334
- ]);
3335
- const memoria = await getCurrentMemoria();
3336
- const count = await memoria.count();
3337
- setMemoryCount(count);
3338
- }
3339
- else if (mode === "rest") {
3340
- const { resultFrame } = await dispatchHeadlessCommand("arion.rest", { name });
3341
- if (!resultFrame) {
3342
- throw new Error("arion.rest produced no result frame");
3343
- }
3344
- if (!resultFrame.ok) {
3345
- throw new Error(resultFrame.error.message);
3346
- }
3347
- setMessages((prev) => [...prev, createSystemMessage(`${name} is now resting`)]);
3348
- }
3349
- else if (mode === "wake") {
3350
- const { resultFrame } = await dispatchHeadlessCommand("arion.wake", { name });
3351
- if (!resultFrame) {
3352
- throw new Error("arion.wake produced no result frame");
3353
- }
3354
- if (!resultFrame.ok) {
3355
- throw new Error(resultFrame.error.message);
3356
- }
3357
- setMessages((prev) => [...prev, createSystemMessage(`${name} is now awake`)]);
3358
- }
3359
- await refreshArionsList();
3360
- }
3361
- catch (error) {
3362
- setMessages((prev) => [...prev, createErrorMessage(`Error: ${error.message}`)]);
3363
- }
3364
- }, [credentialHints, dispatchHeadlessCommand, getCurrentMemoria, initialModel, refreshArionsList]);
3365
- const handleSelectModel = useCallback(async (name, effort) => {
3366
- const { resultFrame } = await dispatchHeadlessCommand("model.set", {
3367
- model: name,
3368
- });
3369
- if (!resultFrame) {
3370
- throw new Error("model.set produced no result frame");
3371
- }
3372
- if (!resultFrame.ok) {
3373
- throw new Error(resultFrame.error.message);
3374
- }
3375
- const currentModel = resultFrame.result.currentModel ?? name;
3376
- setModel(currentModel);
3377
- modelRef.current = currentModel;
3378
- if (effort) {
3379
- setEffortLevel(effort);
3380
- const cfg = loadConfig();
3381
- cfg.effortLevel = effort;
3382
- saveConfig(cfg);
3383
- }
3384
- setMessages((prev) => [...prev, createSystemMessage(`Switched to ${currentModel}`)]);
3385
- }, [dispatchHeadlessCommand]);
3386
- handleSelectModelRef.current = handleSelectModel;
3387
- /** Apply theme + persist to config.json. Single source of truth for theme changes. */
3388
- const handleSelectTheme = useCallback(async (theme) => {
3389
- try {
3390
- const { resultFrame } = await dispatchHeadlessCommand("config.theme.set", {
3391
- theme: theme.name,
3392
- });
3393
- if (!resultFrame) {
3394
- throw new Error("config.theme.set produced no result frame");
3395
- }
3396
- if (!resultFrame.ok) {
3397
- throw new Error(resultFrame.error.message);
3398
- }
3399
- setMessages((prev) => [
3400
- ...prev,
3401
- createSystemMessage(`Theme changed to ${theme.displayName} (${theme.name})`),
3402
- ]);
3403
- }
3404
- catch (error) {
3405
- setMessages((prev) => [
3406
- ...prev,
3407
- createErrorMessage(`Theme change failed: ${error.message}`),
3408
- ]);
3409
- }
3410
- }, [dispatchHeadlessCommand]);
3411
- const handleSelectThemeRef = useRef(handleSelectTheme);
3412
- handleSelectThemeRef.current = handleSelectTheme;
3413
- const refreshDaemonStatus = useCallback(async () => {
3414
- try {
3415
- const { resultFrame } = await dispatchHeadlessCommand("daemon.status", {});
3416
- if (resultFrame?.ok) {
3417
- const data = (resultFrame.result ?? {});
3418
- const loop = (data.autonomousLoop ?? {});
3419
- setDaemonStatus({
3420
- running: true,
3421
- port: data.port,
3422
- nodeId: data.nodeId,
3423
- runtimeId: data.runtimeId,
3424
- loopStatus: loop.status,
3425
- clients: data.attachedClients,
3426
- });
3427
- return true;
3428
- }
3429
- }
3430
- catch {
3431
- // Status call failed — daemon is unreachable (crashed or stopped).
3432
- }
3433
- setDaemonStatus({ running: false });
3434
- return false;
3435
- }, [dispatchHeadlessCommand]);
3436
- /** Handle daemon control actions (start / stop / restart) from the DaemonControl overlay. */
3437
- const handleDaemonAction = useCallback(async (action) => {
3438
- const labels = {
3439
- start: "Starting daemon…",
3440
- stop: "Stopping daemon…",
3441
- restart: "Restarting daemon…",
3442
- };
3443
- setDaemonActionStatus(labels[action] ?? `${action}…`);
3444
- try {
3445
- const command = action === "restart"
3446
- ? "daemon.restart"
3447
- : action === "start"
3448
- ? "daemon.start"
3449
- : "daemon.stop";
3450
- const { resultFrame } = await dispatchHeadlessCommand(command, {});
3451
- if (resultFrame && !resultFrame.ok) {
3452
- throw new Error(resultFrame.error?.message ?? `${action} failed`);
3453
- }
3454
- if (action === "stop") {
3455
- setDaemonStatus({ running: false });
3456
- setMessages((prev) => [...prev, createSystemMessage("■ Daemon stopped.")]);
3457
- }
3458
- else {
3459
- await refreshDaemonStatus();
3460
- setMessages((prev) => [
3461
- ...prev,
3462
- createSystemMessage(action === "restart" ? "↻ Daemon restarted." : "▶ Daemon started."),
3463
- ]);
3464
- }
3465
- }
3466
- catch (error) {
3467
- setMessages((prev) => [
3468
- ...prev,
3469
- createErrorMessage(`Daemon ${action} failed: ${error.message}`),
3470
- ]);
3471
- }
3472
- finally {
3473
- setDaemonActionStatus(null);
3474
- }
3475
- }, [dispatchHeadlessCommand, refreshDaemonStatus]);
3476
- // ── Peer pairing handlers ──────────────────────────────────────
3477
- const handleSelectPeer = useCallback(async (peer) => {
3478
- // Same-identity peers are auto-connected — no pairing needed
3479
- if (peer.status === "connected") {
3480
- try {
3481
- }
3482
- catch { }
3483
- setMessages((prev) => [
3484
- ...prev,
3485
- createSystemMessage(`✓ Already connected to ${peer.displayNameSnapshot} (same identity, shared server on port ${peer.port}).`),
3486
- ]);
3487
- return;
3488
- }
3489
- const isLanPeer = peer.host && isPrivateLanIP(peer.host);
3490
- const isWanPeer = peer.transport === "wan";
3491
- // SSRF protection: LAN peers must have private IPs, WAN peers go through relay
3492
- if (!isWanPeer && peer.host && !isLanPeer) {
3493
- setMessages((prev) => [
3494
- ...prev,
3495
- createErrorMessage(`Rejected: ${peer.host} is not a private LAN address.`),
3496
- ]);
3497
- return;
3498
- }
3499
- try {
3500
- if (!peer.principalFingerprint) {
3501
- throw new Error(`${peer.displayNameSnapshot} did not advertise a principal fingerprint`);
3502
- }
3503
- if (!peer.tlsCaFingerprint) {
3504
- throw new Error(`${peer.displayNameSnapshot} did not advertise a TLS fingerprint`);
3505
- }
3506
- if (!peer.host || !peer.port) {
3507
- throw new Error(`${peer.displayNameSnapshot} did not advertise a control endpoint`);
3508
- }
3509
- const { resultFrame } = await dispatchHeadlessCommand("peer.connect", {
3510
- nodeId: peer.nodeId,
3511
- displayName: peer.displayNameSnapshot,
3512
- principalFingerprint: peer.principalFingerprint,
3513
- controlEndpoint: {
3514
- host: peer.host,
3515
- port: peer.port,
3516
- tlsCaFingerprint: peer.tlsCaFingerprint,
3517
- tlsServerIdentity: peer.principalFingerprint,
3518
- protocolVersion: 1,
3519
- },
3520
- transport: peer.transport,
3521
- });
3522
- if (!resultFrame) {
3523
- throw new Error("peer.connect produced no result frame");
3524
- }
3525
- if (!resultFrame.ok) {
3526
- throw new Error(resultFrame.error.message);
3527
- }
3528
- const inviteResult = resultFrame.result.invite;
3529
- if (!inviteResult) {
3530
- throw new Error("peer.connect returned no invite payload");
3531
- }
3532
- setMessages((prev) => [
3533
- ...prev,
3534
- createSystemMessage(inviteResult.mode === "wan_pair"
3535
- ? `Paired with ${inviteResult.displayNameSnapshot ?? inviteResult.nodeId}. Awaiting verified ingress before the runtime marks the peer active.`
3536
- : `Paired with ${inviteResult.displayNameSnapshot ?? inviteResult.nodeId}. WG tunnel will connect when accepted.`),
3537
- ]);
3538
- }
3539
- catch (error) {
3540
- setMessages((prev) => [
3541
- ...prev,
3542
- createErrorMessage(`Failed to connect to ${peer.displayNameSnapshot}: ${error.message}`),
3543
- ]);
3544
- }
3545
- }, [dispatchHeadlessCommand]);
3546
- const handleAcceptPairRequest = useCallback(async () => {
3547
- if (!incomingPairRequest)
3548
- return;
3549
- const { id, nodeId, displayNameSnapshot } = incomingPairRequest;
3550
- const sourceLabel = displayNameSnapshot ?? nodeId;
3551
- setIncomingPairRequest(null);
3552
- setMessages((prev) => [
3553
- ...prev,
3554
- createSystemMessage(`Accepting connection from ${sourceLabel}...`),
3555
- ]);
3556
- try {
3557
- const { resultFrame } = await dispatchHeadlessCommand("peer.pending.respond", {
3558
- requestId: id,
3559
- accepted: true,
3560
- });
3561
- if (!resultFrame) {
3562
- throw new Error("peer.pending.respond produced no result frame");
3563
- }
3564
- if (!resultFrame.ok) {
3565
- throw new Error(resultFrame.error.message);
3566
- }
3567
- const data = (resultFrame.result.response ?? {});
3568
- if (data.error) {
3569
- throw new Error(data.error);
3570
- }
3571
- if (data.accepted) {
3572
- setMessages((prev) => [
3573
- ...prev,
3574
- createSystemMessage(`Connected to ${sourceLabel}. Tunnel established.`),
3575
- ]);
3576
- }
3577
- }
3578
- catch (error) {
3579
- setMessages((prev) => [
3580
- ...prev,
3581
- createErrorMessage(`Failed to accept connection: ${error.message}`),
3582
- ]);
3583
- }
3584
- }, [dispatchHeadlessCommand, incomingPairRequest]);
3585
- const handleRejectPairRequest = useCallback(() => {
3586
- if (!incomingPairRequest)
3587
- return;
3588
- const { id, nodeId, displayNameSnapshot } = incomingPairRequest;
3589
- const sourceLabel = displayNameSnapshot ?? nodeId;
3590
- setIncomingPairRequest(null);
3591
- setMessages((prev) => [
3592
- ...prev,
3593
- createSystemMessage(`Rejected connection from ${sourceLabel}.`),
3594
- ]);
3595
- void dispatchHeadlessCommand("peer.pending.respond", {
3596
- requestId: id,
3597
- accepted: false,
3598
- }).catch(() => { });
3599
- }, [dispatchHeadlessCommand, incomingPairRequest]);
3600
- const handleCreateInvite = useCallback(async (inviteLabel) => {
3601
- try {
3602
- const { resultFrame } = await dispatchHeadlessCommand("peer.invite", {
3603
- ...(inviteLabel?.trim() ? { inviteLabel: inviteLabel.trim() } : {}),
3604
- });
3605
- if (!resultFrame) {
3606
- throw new Error("peer.invite produced no result frame");
3607
- }
3608
- if (!resultFrame.ok) {
3609
- throw new Error(resultFrame.error.message);
3610
- }
3611
- const invite = resultFrame.result.invite;
3612
- if (!invite) {
3613
- throw new Error("peer.invite returned no invite payload");
3614
- }
3615
- setInviteShare(invite);
3616
- }
3617
- catch (error) {
3618
- setMessages((prev) => [
3619
- ...prev,
3620
- createErrorMessage(`Failed to create invite: ${error.message}`),
3621
- ]);
3622
- }
3623
- }, [dispatchHeadlessCommand]);
3624
- const handleJoinInviteToken = useCallback(async (inviteToken) => {
3625
- try {
3626
- const { resultFrame } = await dispatchHeadlessCommand("peer.accept_invite", {
3627
- inviteToken,
3628
- });
3629
- if (!resultFrame) {
3630
- throw new Error("peer.accept_invite produced no result frame");
3631
- }
3632
- if (!resultFrame.ok) {
3633
- throw new Error(resultFrame.error.message);
3634
- }
3635
- const result = resultFrame.result.accepted;
3636
- if (!result) {
3637
- throw new Error("peer.accept_invite returned no accepted payload");
3638
- }
3639
- setJoinInviteError(null);
3640
- setMessages((prev) => [
3641
- ...prev,
3642
- createSystemMessage(`Paired with ${result.displayNameSnapshot ?? result.nodeId}.`),
3643
- ]);
3644
- }
3645
- catch (error) {
3646
- const message = `Failed to accept invite: ${error.message}`;
3647
- setJoinInviteError(message);
3648
- setMessages((prev) => [...prev, createErrorMessage(message)]);
3649
- }
3650
- }, [dispatchHeadlessCommand]);
3651
- // Poll for incoming pair requests — uses embedded server or external daemon.
3652
- // Guard: check `active` both before AND after setInterval to prevent leak on unmount.
3653
- useEffect(() => {
3654
- let active = true;
3655
- let interval = null;
3656
- const startPolling = async () => {
3657
- const poll = async () => {
3658
- try {
3659
- const { resultFrame } = await dispatchHeadlessCommand("peer.pending.list", {});
3660
- if (!active || !resultFrame?.ok)
3661
- return; // Server not ready yet — keep polling
3662
- const requests = (resultFrame.result.requests ?? []);
3663
- if (requests.length && !incomingPairRequest) {
3664
- setIncomingPairRequest(requests[0]);
3665
- }
3666
- }
3667
- catch (err) {
3668
- // Server unreachable — will retry next interval
3669
- if (isSessionExpiredError(err)) {
3670
- if (interval)
3671
- clearInterval(interval);
3672
- pairPollRef.current = null;
3673
- return;
3674
- }
3675
- }
3676
- };
3677
- interval = setInterval(poll, 5_000);
3678
- pairPollRef.current = interval;
3679
- // Double-check: if unmount happened during local attach resolution, clean up immediately
3680
- if (!active) {
3681
- clearInterval(interval);
3682
- interval = null;
3683
- pairPollRef.current = null;
3684
- }
3685
- };
3686
- startPolling();
3687
- return () => {
3688
- active = false;
3689
- if (interval)
3690
- clearInterval(interval);
3691
- pairPollRef.current = null;
3692
- };
3693
- }, [dispatchHeadlessCommand, incomingPairRequest]);
3694
- const handleOpenMemoryBrowser = useCallback(async (browserMode) => {
3695
- setMemoryBrowserMode(browserMode);
3696
- await loadMemories();
3697
- }, [loadMemories]);
3698
- const handleSelectMemory = useCallback(async (memory) => {
3699
- if (memoryBrowserMode === "forget") {
3700
- try {
3701
- const { resultFrame } = await dispatchHeadlessCommand("memory.forget", {
3702
- id: memory.id,
3703
- });
3704
- if (!resultFrame) {
3705
- throw new Error("memory.forget produced no result frame");
3706
- }
3707
- if (!resultFrame.ok) {
3708
- throw new Error(resultFrame.error.message);
3709
- }
3710
- const preview = memory.content.length > 50 ? memory.content.slice(0, 50) + "..." : memory.content;
3711
- setMessages((prev) => [...prev, createSystemMessage(`Forgot: "${preview}"`)]);
3712
- await loadMemories();
3713
- }
3714
- catch (error) {
3715
- setMessages((prev) => [
3716
- ...prev,
3717
- createErrorMessage(`Error forgetting: ${error.message}`),
3718
- ]);
3719
- }
3720
- }
3721
- }, [dispatchHeadlessCommand, memoryBrowserMode, loadMemories]);
3722
- const clearAuthInteractionUi = useCallback(() => {
3723
- setLoginPickerProviders(null);
3724
- setCopilotSourceOptions(null);
3725
- setOAuthProvider(null);
3726
- setOAuthAuthorizeUrl(null);
3727
- setOAuthExpectedState(null);
3728
- setOAuthFieldKey(null);
3729
- setCopilotDeviceProvider(null);
3730
- setCopilotDeviceProfileLabel(null);
3731
- setCopilotDeviceVerificationUri(null);
3732
- setCopilotDeviceUserCode(null);
3733
- setAnthropicMethodOptions(null);
3734
- setAnthropicKeyInputVisible(false);
3735
- setAnthropicSetupTokenVisible(false);
3736
- setOpenAIMethodOptions(null);
3737
- setOpenAIKeyInputVisible(false);
3738
- setGoogleMethodOptions(null);
3739
- setGoogleKeyInputVisible(false);
3740
- setPendingAuthInteraction(null);
3741
- setAuthInteractionOptions(null);
3742
- setAuthInteractionTitle(null);
3743
- setAuthInteractionInput(null);
3744
- }, []);
3745
- const applyAuthCommandResult = useCallback((result) => {
3746
- const authResult = result;
3747
- const success = authResult.success !== false;
3748
- const message = authResult.message ?? (success ? "Authentication updated." : "Auth failed.");
3749
- setMessages((prev) => [
3750
- ...prev,
3751
- success ? createSystemMessage(message) : createErrorMessage(message),
3752
- ]);
3753
- }, []);
3754
- const showAuthInteraction = useCallback(async (frame) => {
3755
- clearAuthInteractionUi();
3756
- if (frame.interaction.kind === "selection") {
3757
- setPendingAuthInteraction({
3758
- interactionId: frame.interactionId,
3759
- interaction: frame.interaction,
3760
- });
3761
- setAuthInteractionTitle(frame.interaction.prompt);
3762
- setAuthInteractionOptions(frame.interaction.options.map((option) => ({
3763
- id: option.id,
3764
- label: option.label,
3765
- description: option.description,
3766
- method: option.id,
3767
- status: option.description?.includes("connected")
3768
- ? "connected"
3769
- : option.description?.includes("available")
3770
- ? "available"
3771
- : "none",
3772
- })));
3773
- return;
3774
- }
3775
- if (frame.interaction.kind === "credential_input") {
3776
- const field = frame.interaction.fields[0];
3777
- if (!field) {
3778
- throw new Error("Auth interaction requires at least one credential field");
3779
- }
3780
- if (frame.interaction.mode === "oauth_authorization_code" &&
3781
- frame.interaction.authorizeUrl &&
3782
- frame.interaction.provider) {
3783
- setPendingAuthInteraction({
3784
- interactionId: frame.interactionId,
3785
- interaction: frame.interaction,
3786
- });
3787
- setOAuthProvider(frame.interaction.provider);
3788
- setOAuthAuthorizeUrl(frame.interaction.authorizeUrl);
3789
- setOAuthExpectedState(frame.interaction.expectedState ?? null);
3790
- setOAuthFieldKey(field.key);
3791
- return;
3792
- }
3793
- setPendingAuthInteraction({
3794
- interactionId: frame.interactionId,
3795
- interaction: frame.interaction,
3796
- });
3797
- setAuthInteractionInput({
3798
- title: field.label,
3799
- hint: frame.interaction.prompt,
3800
- fieldKey: field.key,
3801
- });
3802
- return;
3803
- }
3804
- if (frame.interaction.kind === "oauth_device") {
3805
- setPendingAuthInteraction({
3806
- interactionId: frame.interactionId,
3807
- interaction: frame.interaction,
3808
- });
3809
- setCopilotDeviceProvider(frame.interaction.provider ?? "github-copilot");
3810
- setCopilotDeviceProfileLabel(frame.interaction.profileLabel ?? null);
3811
- setCopilotDeviceVerificationUri(frame.interaction.verificationUri);
3812
- setCopilotDeviceUserCode(frame.interaction.userCode);
3813
- return;
3814
- }
3815
- }, [applyAuthCommandResult, clearAuthInteractionUi, headlessRunKernel]);
3816
- const dispatchAuthCommand = useCallback(async (op, args) => {
3817
- clearAuthInteractionUi();
3818
- const { interactionFrame, resultFrame } = await dispatchHeadlessCommand(op, { args });
3819
- if (interactionFrame && interactionFrame.source === "auth") {
3820
- await showAuthInteraction(interactionFrame);
3821
- return;
3822
- }
3823
- if (!resultFrame) {
3824
- throw new Error(`${op} produced no result frame`);
3825
- }
3826
- if (resultFrame.ok) {
3827
- applyAuthCommandResult(resultFrame.result);
3828
- return;
3829
- }
3830
- throw new Error(resultFrame.error.message);
3831
- }, [applyAuthCommandResult, clearAuthInteractionUi, dispatchHeadlessCommand, showAuthInteraction]);
3832
- const respondToPendingAuthInteraction = useCallback(async (response) => {
3833
- if (!pendingAuthInteraction) {
3834
- throw new Error("No pending auth interaction to respond to");
3835
- }
3836
- const { interactionId } = pendingAuthInteraction;
3837
- let nextInteraction = null;
3838
- let resultFrame = null;
3839
- for await (const frame of headlessRunKernel.dispatch({
3840
- kind: "interaction.respond",
3841
- requestId: `auth:${randomUUID()}`,
3842
- interactionId,
3843
- response,
3844
- })) {
3845
- if (frame.kind === "interaction.required" && frame.source === "auth") {
3846
- nextInteraction = frame;
3847
- }
3848
- else if (frame.kind === "result") {
3849
- resultFrame = frame;
3850
- }
3851
- }
3852
- if (nextInteraction) {
3853
- await showAuthInteraction(nextInteraction);
3854
- return;
3855
- }
3856
- clearAuthInteractionUi();
3857
- if (!resultFrame) {
3858
- throw new Error("auth interaction produced no result frame");
3859
- }
3860
- if (resultFrame.ok) {
3861
- applyAuthCommandResult(resultFrame.result);
3862
- return;
3863
- }
3864
- if (resultFrame.error.code === "INTERACTION_REQUIRED") {
3865
- return;
3866
- }
3867
- setMessages((prev) => [
3868
- ...prev,
3869
- createErrorMessage(`Login failed: ${resultFrame.error.message}`),
3870
- ]);
3871
- }, [
3872
- applyAuthCommandResult,
3873
- clearAuthInteractionUi,
3874
- headlessRunKernel,
3875
- pendingAuthInteraction,
3876
- showAuthInteraction,
3877
- ]);
3878
- const maxContextTokens = useMemo(() => getModelByShortName(model)?.maxContextTokens, [model]);
3879
- // --- Message edit overlay: build entries for all conversation messages ---
3880
- const editableMessages = useMemo(() => {
3881
- return messages
3882
- .map((msg, index) => {
3883
- const textParts = (Array.isArray(msg.content) ? msg.content : [msg.content])
3884
- .filter((b) => typeof b === "object" && b !== null && "type" in b && b.type === "text")
3885
- .map((b) => b.text);
3886
- const text = textParts.join("").trim();
3887
- // Skip empty messages and system messages (they're internal)
3888
- if (!text || msg.role === "system")
3889
- return null;
3890
- return {
3891
- index,
3892
- role: msg.role,
3893
- text,
3894
- arion: msg.arion?.name,
3895
- createdAt: msg.createdAt,
3896
- };
3897
- })
3898
- .filter((e) => e !== null);
3899
- }, [messages]);
3900
- // Handle edit-message selection from the overlay:
3901
- // Fork the session at that message point, switch to the fork (like resume),
3902
- // then put the message text into the input for editing.
3903
- const [openMessageEditOverlaySignal, setOpenMessageEditOverlaySignal] = useState(undefined);
3904
- const [prefillInput, setPrefillInput] = useState(undefined);
3905
- const handleEditMessage = useCallback(async (messageIndex, messageText) => {
3906
- if (!sessionIdRef.current || isStreamingRef.current)
3907
- return;
3908
- // messageIndex is the index in the TUI messages[] array, which may include
3909
- // system messages (e.g. "Resumed session...") that are NOT in the database.
3910
- // We need to count only non-system messages before the selected index,
3911
- // because those correspond 1:1 with the DB rows ordered by id ASC.
3912
- let dbMessageCount = 0;
3913
- for (let i = 0; i < messageIndex; i++) {
3914
- const msg = messages[i];
3915
- if (msg && msg.role !== "system") {
3916
- dbMessageCount++;
3917
- }
3918
- }
3919
- const messageLimit = dbMessageCount;
3920
- try {
3921
- const { resultFrame } = await dispatchHeadlessCommand("session.fork", {
3922
- sessionId: sessionIdRef.current,
3923
- messageLimit,
3924
- });
3925
- if (!resultFrame?.ok) {
3926
- const errorMsg = resultFrame?.error?.message ?? "Fork failed";
3927
- setMessages((prev) => [...prev, createErrorMessage(`Edit failed: ${errorMsg}`)]);
3928
- return;
3929
- }
3930
- const forkResult = resultFrame.result;
3931
- // Switch to the forked session (like resume — shows messages up to the fork point)
3932
- await handleSelectSession(forkResult.newSessionId);
3933
- // Put the message text into the input box for editing.
3934
- // The REPL screen's App component exposes onSetInput via the inputRef,
3935
- // but we can set it via the return value which triggers a re-render.
3936
- setPrefillInput(messageText);
3937
- }
3938
- catch (error) {
3939
- setMessages((prev) => [
3940
- ...prev,
3941
- createErrorMessage(`Edit failed: ${error.message}`),
3942
- ]);
3943
- }
3944
- }, [messages, dispatchHeadlessCommand, handleSelectSession]);
3945
- return (_jsx(REPL, { staticRenderEpoch: staticRenderEpoch, session: session, model: model, maxContextTokens: maxContextTokens, banner: _jsx(Banner, { onComplete: () => { }, skipAnimation: true, version: CLI_VERSION, whatsNew: WHATS_NEW }), messages: messages, previewMessages: previewMessages, isStreaming: isStreaming, queuedMessage: queuedMessage, onCancelQueuedMessage: handleCancelQueuedMessage, responseTime: responseTime, commands: COMMANDS, arions: arions, models: models, memories: memories, memoryBrowserMode: memoryBrowserMode, isLoadingMemories: isLoadingMemories, userName: userName, memoryCount: memoryCount, metrics: metrics, displayMode: displayMode, displayConfig: displayConfig, showThinking: displayConfig.showThinking, showCosts: displayConfig.showCosts, showTraces: displayConfig.showTraces, pipelineTiming: pipelineTiming, spans: spans, activeArion: activeArion, onSubmit: handleSubmit, onCommand: handleCommand, onSelectArion: handleSelectArion, onSelectModel: handleSelectModel, onSelectMemory: handleSelectMemory, onOpenMemoryBrowser: handleOpenMemoryBrowser, onToggleThinking: handleToggleThinking, onToggleCosts: handleToggleCosts, onToggleTraces: handleToggleTraces, onCycleDisplayMode: handleCycleDisplayMode, sessions: sessions, onSelectSession: handleSelectSession, onLoadSessions: loadSessions, onSearchSessions: handleSearchSessions, onLoadMoreSessions: handleLoadMoreSessions, openSessionOverlaySignal: openSessionOverlaySignal, openThemeOverlaySignal: openThemeOverlaySignal, openSoundOverlaySignal: openSoundOverlaySignal, openDaemonOverlaySignal: openDaemonOverlaySignal, daemonStatus: daemonStatus, daemonActionStatus: daemonActionStatus, onDaemonAction: handleDaemonAction, connectionState: connectionState, inviteShare: inviteShare
3946
- ? {
3947
- inviteToken: inviteShare.inviteToken,
3948
- inviteLabel: inviteShare.pendingInvite.inviteLabel,
3949
- expiresAt: inviteShare.pendingInvite.expiresAt ?? null,
3950
- }
3951
- : null, onInviteShareClose: () => {
3952
- setInviteShare(null);
3953
- }, openJoinInviteOverlaySignal: openJoinInviteOverlaySignal, onJoinInviteSubmit: handleJoinInviteToken, onJoinInviteCancel: () => {
3954
- setJoinInviteError(null);
3955
- }, joinInviteError: joinInviteError, onSelectTheme: handleSelectTheme, inputHistory: inputHistory, onSaveInput: onSaveInput, openMessageEditOverlaySignal: openMessageEditOverlaySignal, editableMessages: editableMessages, onEditMessage: handleEditMessage, prefillInput: prefillInput, onPrefillConsumed: () => setPrefillInput(undefined), obsCtx: obsCtx, onCancel: handleCancel, approvalRequest: approvalRequest, onApprovalChoice: (approved) => {
3956
- if (approvalRequest) {
3957
- approvalRequest.resolve(approved);
3958
- setApprovalRequest(null);
3959
- }
3960
- }, effortLevel: effortLevel, showAutonomySelector: showAutonomySelector, autonomyLevel: autonomyLevelState, onAutonomySelect: (level) => {
3961
- setAutonomyLevel(level);
3962
- setShowAutonomySelector(false);
3963
- setMessages((prev) => [...prev, createSystemMessage(`Autonomy level set to: ${level}`)]);
3964
- }, onCycleAutonomy: () => {
3965
- const idx = AUTONOMY_LEVELS.indexOf(autonomyLevelRef.current);
3966
- const next = AUTONOMY_LEVELS[(idx + 1) % AUTONOMY_LEVELS.length];
3967
- setAutonomyLevel(next);
3968
- }, onCycleEffort: () => {
3969
- const levels = ["low", "medium", "high", "max"];
3970
- const idx = levels.indexOf(effortLevel);
3971
- const next = levels[(idx + 1) % levels.length];
3972
- setEffortLevel(next);
3973
- const cfg = loadConfig();
3974
- cfg.effortLevel = next;
3975
- saveConfig(cfg);
3976
- }, onAutonomyCancel: () => {
3977
- setShowAutonomySelector(false);
3978
- }, authInteractionOptions: authInteractionOptions, authInteractionTitle: authInteractionTitle, onAuthInteractionSelect: async (optionId) => {
3979
- await respondToPendingAuthInteraction({ kind: "selection", selected: optionId });
3980
- }, onAuthInteractionCancel: async () => {
3981
- await respondToPendingAuthInteraction({ kind: "cancel" });
3982
- }, authInteractionInput: authInteractionInput
3983
- ? {
3984
- title: authInteractionInput.title,
3985
- ...(authInteractionInput.hint ? { hint: authInteractionInput.hint } : {}),
3986
- }
3987
- : null, onAuthInteractionInputSubmit: async (value) => {
3988
- if (!authInteractionInput) {
3989
- return;
3990
- }
3991
- await respondToPendingAuthInteraction({
3992
- kind: "credential_input",
3993
- values: {
3994
- [authInteractionInput.fieldKey]: value,
3995
- },
3996
- });
3997
- }, onAuthInteractionInputCancel: async () => {
3998
- await respondToPendingAuthInteraction({ kind: "cancel" });
3999
- }, loginPickerProviders: loginPickerProviders, onLoginProviderSelect: async (provider) => {
4000
- setLoginPickerProviders(null);
4001
- await dispatchAuthCommand("auth.login", provider.id);
4002
- }, onLoginPickerCancel: () => {
4003
- setLoginPickerProviders(null);
4004
- }, anthropicMethodOptions: anthropicMethodOptions, onAnthropicMethodSelect: async (option) => {
4005
- setAnthropicMethodOptions(null);
4006
- await dispatchAuthCommand("auth.login", `anthropic --method ${option.id}`);
4007
- }, onAnthropicMethodCancel: () => setAnthropicMethodOptions(null), anthropicKeyInputVisible: anthropicKeyInputVisible, onAnthropicKeySubmit: async (apiKey) => {
4008
- setAnthropicKeyInputVisible(false);
4009
- await dispatchAuthCommand("auth.login", `anthropic ${apiKey}`);
4010
- }, onAnthropicKeyCancel: () => setAnthropicKeyInputVisible(false), anthropicSetupTokenVisible: anthropicSetupTokenVisible, onAnthropicSetupTokenSubmit: async (token) => {
4011
- setAnthropicSetupTokenVisible(false);
4012
- await dispatchAuthCommand("auth.login", `anthropic --setup-token ${token}`);
4013
- }, onAnthropicSetupTokenCancel: () => setAnthropicSetupTokenVisible(false), openaiMethodOptions: openaiMethodOptions, onOpenAIMethodSelect: async (option) => {
4014
- setOpenAIMethodOptions(null);
4015
- await dispatchAuthCommand("auth.login", `openai --method ${option.id}`);
4016
- }, onOpenAIMethodCancel: () => setOpenAIMethodOptions(null), openaiKeyInputVisible: openaiKeyInputVisible, onOpenAIKeySubmit: async (apiKey) => {
4017
- setOpenAIKeyInputVisible(false);
4018
- await dispatchAuthCommand("auth.login", `openai ${apiKey}`);
4019
- }, onOpenAIKeyCancel: () => setOpenAIKeyInputVisible(false), googleMethodOptions: googleMethodOptions, onGoogleMethodSelect: async (option) => {
4020
- setGoogleMethodOptions(null);
4021
- await dispatchAuthCommand("auth.login", `google --method ${option.id}`);
4022
- }, onGoogleMethodCancel: () => setGoogleMethodOptions(null), googleKeyInputVisible: googleKeyInputVisible, onGoogleKeySubmit: async (apiKey) => {
4023
- setGoogleKeyInputVisible(false);
4024
- await dispatchAuthCommand("auth.login", `google ${apiKey}`);
4025
- }, onGoogleKeyCancel: () => setGoogleKeyInputVisible(false), copilotSourceOptions: copilotSourceOptions, onCopilotSourceSelect: async (source) => {
4026
- setCopilotSourceOptions(null);
4027
- await dispatchAuthCommand("auth.login", `github-copilot --from ${source.id}`);
4028
- }, onCopilotSourceCancel: () => setCopilotSourceOptions(null), oauthProvider: oauthProvider, oauthAuthorizeUrl: oauthAuthorizeUrl, oauthExpectedState: oauthExpectedState, onOAuthComplete: (result) => {
4029
- setOAuthProvider(null);
4030
- setOAuthAuthorizeUrl(null);
4031
- setOAuthExpectedState(null);
4032
- setOAuthFieldKey(null);
4033
- setMessages((prev) => [
4034
- ...prev,
4035
- result.success ? createSystemMessage(result.message) : createErrorMessage(result.message),
4036
- ]);
4037
- }, onOAuthCodeSubmit: async (code) => {
4038
- if (!oauthFieldKey) {
4039
- throw new Error("No pending OAuth field key");
4040
- }
4041
- await respondToPendingAuthInteraction({
4042
- kind: "credential_input",
4043
- values: {
4044
- [oauthFieldKey]: code,
4045
- },
4046
- });
4047
- }, onOAuthCancel: async () => {
4048
- await respondToPendingAuthInteraction({ kind: "cancel" });
4049
- }, copilotDeviceProvider: copilotDeviceProvider, copilotDeviceProfileLabel: copilotDeviceProfileLabel, copilotDeviceVerificationUri: copilotDeviceVerificationUri, copilotDeviceUserCode: copilotDeviceUserCode, onCopilotDeviceComplete: (result) => {
4050
- setCopilotDeviceProvider(null);
4051
- setCopilotDeviceProfileLabel(null);
4052
- setCopilotDeviceVerificationUri(null);
4053
- setCopilotDeviceUserCode(null);
4054
- setMessages((prev) => [
4055
- ...prev,
4056
- result.success ? createSystemMessage(result.message) : createErrorMessage(result.message),
4057
- ]);
4058
- }, onCopilotDeviceApprove: async () => {
4059
- await respondToPendingAuthInteraction({ kind: "oauth_device", acknowledged: true });
4060
- }, onCopilotDeviceCancel: async () => {
4061
- await respondToPendingAuthInteraction({ kind: "cancel" });
4062
- }, nearbyPeers: nearbyPeers, localClients: localClients, onSelectPeer: handleSelectPeer, onSelectClient: () => { }, onPeerCancel: () => { }, onClientsCancel: () => { }, openPeersOverlaySignal: openPeersOverlaySignal, openClientsOverlaySignal: openClientsOverlaySignal, incomingPairRequest: incomingPairRequest, onAcceptPairRequest: handleAcceptPairRequest, onRejectPairRequest: handleRejectPairRequest, onCancelPairing: undefined, meshMessageCount: meshMessageCount, runtimeSocket: runtimeSocket, clientId: headlessRunKernel?.getAttachedCredentials()?.clientId ?? null, clientAuthToken: headlessRunKernel?.getAttachedCredentials()?.clientAuthToken ?? null, resolveCredentials: async () => {
4063
- try {
4064
- return headlessRunKernel?.getAttachedCredentials() ?? null;
4065
- }
4066
- catch {
4067
- return null;
4068
- }
4069
- } }));
4070
- }
4071
- export async function startInkRepl(session, manager, router, aria, attachedLocalControl, cliContext, inputHistory = [], onSaveInput, sessionHistory, initialMessage, cachedUserName, resumeSessionId, initialAvailableModels, refreshAvailableModels, credentialHints, authResolver) {
4072
- // When the daemon supports session write IPC, wrap sessionHistory in a
4073
- // client that routes writes over IPC (fire-and-forget, zero event-loop
4074
- // blocking) while keeping reads local (instant WAL reads).
4075
- const localControlManager = new LocalControlManager(attachedLocalControl);
4076
- let effectiveHistory = sessionHistory;
4077
- const control = localControlManager.getControl();
4078
- if (sessionHistory && !(sessionHistory instanceof SessionHistoryClient) && control != null) {
4079
- effectiveHistory = new SessionHistoryClient(sessionHistory, control);
4080
- }
4081
- const { waitUntilExit, unmount } = render(_jsx(InkRepl, { session: session, manager: manager, router: router, aria: aria, localControlManager: localControlManager, cliContext: cliContext, sessionHistory: effectiveHistory, inputHistory: inputHistory, onSaveInput: onSaveInput, initialMessage: initialMessage, resumeSessionId: resumeSessionId, cachedUserName: cachedUserName, initialAvailableModels: initialAvailableModels, refreshAvailableModels: refreshAvailableModels, credentialHints: credentialHints, authResolver: authResolver }), { exitOnCtrlC: false, patchConsole: true });
4082
- let forceExitHandler;
4083
- let shutdownTimer;
4084
- let sigintRequested = false;
4085
- const armForceExitWindow = () => {
4086
- if (!forceExitHandler) {
4087
- forceExitHandler = () => {
4088
- process.exit(0);
4089
- };
4090
- process.on("SIGINT", forceExitHandler);
4091
- }
4092
- if (!shutdownTimer) {
4093
- shutdownTimer = setTimeout(() => {
4094
- log.debug("[InkREPL] Graceful shutdown timed out after 5s — forcing exit");
4095
- process.exit(0);
4096
- }, 5000);
4097
- shutdownTimer.unref?.();
4098
- }
4099
- };
4100
- const onSigint = () => {
4101
- if (sigintRequested) {
4102
- return;
4103
- }
4104
- sigintRequested = true;
4105
- process.removeListener("SIGINT", onSigint);
4106
- armForceExitWindow();
4107
- void localControlManager.release().catch((error) => {
4108
- log.warn("[InkREPL] Local-control release during SIGINT failed:", error?.message ?? error);
4109
- });
4110
- unmount();
4111
- };
4112
- process.on("SIGINT", onSigint);
4113
- try {
4114
- await waitUntilExit();
4115
- // Graceful shutdown — mark session completed and end Memoria session before
4116
- // process exit. The useEffect cleanup in InkRepl fires async (React doesn't
4117
- // await it), so this is the reliable path. Second Ctrl+C forces immediate exit.
4118
- armForceExitWindow();
4119
- try {
4120
- const { sessionId, sessionHistory: sh, memoriaSession, memoriaFactory } = _shutdownState;
4121
- // Mark session as completed in SQLite history
4122
- if (sh && sessionId) {
4123
- try {
4124
- sh.markCompleted(sessionId);
4125
- }
4126
- catch (err) {
4127
- log.warn("[InkREPL] Failed to mark session completed:", err?.message ?? err);
4128
- }
4129
- }
4130
- // End the Memoria session so sessions table records ended_at
4131
- if (memoriaSession && sessionId) {
4132
- try {
4133
- await memoriaSession.endSession(sessionId);
4134
- }
4135
- catch (err) {
4136
- log.warn("[InkREPL] Failed to end Memoria session:", err?.message ?? err);
4137
- }
4138
- }
4139
- // Flush SQLite WAL connections
4140
- if (memoriaFactory) {
4141
- try {
4142
- await memoriaFactory.closeAll();
4143
- }
4144
- catch (err) {
4145
- log.warn("[InkREPL] Failed to close Memoria factory:", err?.message ?? err);
4146
- }
4147
- }
4148
- // Flush and close JSONL event logger
4149
- if (_shutdownState.jsonlLogger) {
4150
- try {
4151
- await _shutdownState.jsonlLogger.close();
4152
- }
4153
- catch (err) {
4154
- log.warn("[InkREPL] Failed to close JSONL logger:", err?.message ?? err);
4155
- }
4156
- _shutdownState.jsonlLogger = null;
4157
- }
4158
- }
4159
- finally {
4160
- if (shutdownTimer) {
4161
- clearTimeout(shutdownTimer);
4162
- shutdownTimer = undefined;
4163
- }
4164
- }
4165
- }
4166
- finally {
4167
- process.removeListener("SIGINT", onSigint);
4168
- if (forceExitHandler) {
4169
- process.removeListener("SIGINT", forceExitHandler);
4170
- }
4171
- await localControlManager.release().catch((err) => {
4172
- log.warn("[InkREPL] Local-control release on shutdown failed:", err?.message ?? err);
4173
- });
4174
- }
4175
- // Close Aria's Memoria and any manager-cached instances
4176
- try {
4177
- await aria.shutdown();
4178
- }
4179
- catch (err) {
4180
- log.warn("[InkREPL] Shutdown failed:", err?.message);
4181
- }
4182
- }
4183
- //# sourceMappingURL=ink-repl.js.map