@ouro.bot/cli 0.1.0-alpha.25 → 0.1.0-alpha.251

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 (261) hide show
  1. package/README.md +187 -191
  2. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/agent.json +3 -2
  3. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/SOUL.md +1 -1
  4. package/changelog.json +1517 -0
  5. package/dist/arc/attention-types.js +8 -0
  6. package/dist/arc/cares.js +140 -0
  7. package/dist/arc/episodes.js +117 -0
  8. package/dist/arc/intentions.js +133 -0
  9. package/dist/arc/json-store.js +117 -0
  10. package/dist/arc/obligations.js +237 -0
  11. package/dist/arc/presence.js +185 -0
  12. package/dist/heart/active-work.js +832 -0
  13. package/dist/heart/agent-entry.js +37 -2
  14. package/dist/heart/auth/auth-flow.js +447 -0
  15. package/dist/heart/bridges/manager.js +358 -0
  16. package/dist/heart/bridges/state-machine.js +135 -0
  17. package/dist/heart/bridges/store.js +123 -0
  18. package/dist/heart/commitments.js +111 -0
  19. package/dist/heart/config-registry.js +304 -0
  20. package/dist/heart/config.js +63 -30
  21. package/dist/heart/core.js +644 -196
  22. package/dist/heart/cross-chat-delivery.js +131 -0
  23. package/dist/heart/daemon/agent-config-check.js +149 -0
  24. package/dist/heart/daemon/agent-discovery.js +145 -0
  25. package/dist/heart/daemon/agent-service.js +360 -0
  26. package/dist/heart/daemon/cadence.js +70 -0
  27. package/dist/heart/daemon/cli-defaults.js +557 -0
  28. package/dist/heart/daemon/cli-exec.js +1875 -0
  29. package/dist/heart/daemon/cli-parse.js +759 -0
  30. package/dist/heart/daemon/cli-render.js +464 -0
  31. package/dist/heart/daemon/cli-types.js +8 -0
  32. package/dist/heart/daemon/daemon-cli.js +29 -1179
  33. package/dist/heart/daemon/daemon-entry.js +202 -4
  34. package/dist/heart/daemon/daemon-health.js +136 -0
  35. package/dist/heart/daemon/daemon-runtime-sync.js +153 -12
  36. package/dist/heart/daemon/daemon-tombstone.js +123 -0
  37. package/dist/heart/daemon/daemon.js +491 -20
  38. package/dist/heart/daemon/hooks/agent-config-v2.js +33 -0
  39. package/dist/heart/daemon/hooks/bundle-meta.js +115 -1
  40. package/dist/heart/daemon/inner-status.js +89 -0
  41. package/dist/heart/daemon/launchd.js +39 -8
  42. package/dist/heart/daemon/log-tailer.js +4 -3
  43. package/dist/heart/daemon/message-router.js +17 -8
  44. package/dist/heart/daemon/os-cron-deps.js +134 -0
  45. package/dist/heart/daemon/ouro-bot-entry.js +1 -1
  46. package/dist/heart/daemon/process-manager.js +123 -0
  47. package/dist/heart/daemon/run-hooks.js +2 -0
  48. package/dist/heart/daemon/runtime-logging.js +58 -15
  49. package/dist/heart/daemon/runtime-metadata.js +101 -0
  50. package/dist/heart/daemon/runtime-mode.js +67 -0
  51. package/dist/heart/daemon/safe-mode.js +161 -0
  52. package/dist/heart/daemon/sense-manager.js +72 -3
  53. package/dist/heart/daemon/session-id-resolver.js +131 -0
  54. package/dist/heart/daemon/skill-management-installer.js +94 -0
  55. package/dist/heart/daemon/socket-client.js +202 -0
  56. package/dist/heart/daemon/task-scheduler.js +3 -25
  57. package/dist/heart/daemon/thoughts.js +507 -0
  58. package/dist/heart/delegation.js +62 -0
  59. package/dist/heart/habits/habit-migration.js +181 -0
  60. package/dist/heart/habits/habit-parser.js +119 -0
  61. package/dist/heart/habits/habit-scheduler.js +369 -0
  62. package/dist/heart/{daemon → hatch}/hatch-flow.js +30 -120
  63. package/dist/heart/{daemon → hatch}/hatch-specialist.js +3 -3
  64. package/dist/heart/{daemon → hatch}/specialist-prompt.js +10 -7
  65. package/dist/heart/{daemon → hatch}/specialist-tools.js +49 -3
  66. package/dist/heart/identity.js +157 -55
  67. package/dist/heart/kicks.js +2 -20
  68. package/dist/heart/mcp/mcp-server.js +651 -0
  69. package/dist/heart/migrate-config.js +127 -0
  70. package/dist/heart/model-capabilities.js +59 -0
  71. package/dist/heart/outlook/outlook-http.js +439 -0
  72. package/dist/heart/outlook/outlook-read.js +1618 -0
  73. package/dist/heart/outlook/outlook-render.js +1032 -0
  74. package/dist/heart/outlook/outlook-types.js +7 -0
  75. package/dist/heart/outlook/outlook-view.js +194 -0
  76. package/dist/heart/progress-story.js +42 -0
  77. package/dist/heart/provider-failover.js +88 -0
  78. package/dist/heart/provider-ping.js +162 -0
  79. package/dist/heart/providers/anthropic-token.js +163 -0
  80. package/dist/heart/providers/anthropic.js +169 -46
  81. package/dist/heart/providers/azure.js +98 -11
  82. package/dist/heart/providers/error-classification.js +63 -0
  83. package/dist/heart/providers/github-copilot.js +136 -0
  84. package/dist/heart/providers/minimax-vlm.js +167 -0
  85. package/dist/heart/providers/minimax.js +23 -5
  86. package/dist/heart/providers/openai-codex.js +33 -22
  87. package/dist/heart/session-activity.js +172 -0
  88. package/dist/heart/session-recall.js +216 -0
  89. package/dist/heart/start-of-turn-packet.js +330 -0
  90. package/dist/heart/streaming.js +36 -27
  91. package/dist/heart/sync.js +277 -0
  92. package/dist/heart/target-resolution.js +127 -0
  93. package/dist/heart/tempo.js +93 -0
  94. package/dist/heart/temporal-view.js +41 -0
  95. package/dist/heart/tool-activity-callbacks.js +36 -0
  96. package/dist/heart/tool-description.js +135 -0
  97. package/dist/heart/tool-loop.js +200 -0
  98. package/dist/heart/turn-context.js +358 -0
  99. package/dist/heart/turn-coordinator.js +28 -0
  100. package/dist/heart/versioning/ouro-bot-global-installer.js +128 -0
  101. package/dist/heart/{daemon → versioning}/ouro-bot-wrapper.js +1 -1
  102. package/dist/heart/{daemon → versioning}/ouro-path-installer.js +78 -35
  103. package/dist/heart/versioning/ouro-version-manager.js +171 -0
  104. package/dist/heart/{daemon → versioning}/staged-restart.js +30 -6
  105. package/dist/heart/{daemon → versioning}/update-checker.js +9 -1
  106. package/dist/heart/{daemon → versioning}/wrapper-publish-guard.js +41 -3
  107. package/dist/mind/associative-recall.js +126 -66
  108. package/dist/mind/bundle-manifest.js +8 -1
  109. package/dist/mind/context.js +125 -14
  110. package/dist/mind/{memory.js → diary.js} +68 -96
  111. package/dist/mind/embedding-provider.js +60 -0
  112. package/dist/mind/file-state.js +179 -0
  113. package/dist/mind/first-impressions.js +16 -2
  114. package/dist/mind/friends/channel.js +56 -0
  115. package/dist/mind/friends/group-context.js +144 -0
  116. package/dist/mind/friends/resolver.js +37 -0
  117. package/dist/mind/friends/store-file.js +58 -3
  118. package/dist/mind/friends/trust-explanation.js +74 -0
  119. package/dist/mind/friends/types.js +8 -0
  120. package/dist/mind/journal-index.js +161 -0
  121. package/dist/mind/obligation-steering.js +221 -0
  122. package/dist/mind/pending.js +76 -9
  123. package/dist/mind/phrases.js +1 -0
  124. package/dist/mind/prompt.js +765 -109
  125. package/dist/mind/scrutiny.js +173 -0
  126. package/dist/mind/token-estimate.js +8 -12
  127. package/dist/nerves/coverage/audit.js +1 -1
  128. package/dist/nerves/coverage/file-completeness.js +50 -5
  129. package/dist/nerves/coverage/run-artifacts.js +1 -1
  130. package/dist/nerves/event-buffer.js +111 -0
  131. package/dist/nerves/index.js +64 -4
  132. package/dist/nerves/observation.js +20 -0
  133. package/dist/nerves/runtime.js +5 -1
  134. package/dist/outlook-ui/assets/index-Ck8agNeO.js +61 -0
  135. package/dist/outlook-ui/assets/index-LwChZTgL.css +1 -0
  136. package/dist/outlook-ui/index.html +15 -0
  137. package/dist/repertoire/ado-client.js +15 -56
  138. package/dist/repertoire/ado-semantic.js +11 -10
  139. package/dist/repertoire/api-client.js +97 -0
  140. package/dist/repertoire/bitwarden-store.js +319 -0
  141. package/dist/repertoire/bw-installer.js +79 -0
  142. package/dist/repertoire/coding/codex-jsonl.js +64 -0
  143. package/dist/repertoire/coding/context-pack.js +330 -0
  144. package/dist/repertoire/coding/feedback.js +197 -30
  145. package/dist/repertoire/coding/manager.js +149 -2
  146. package/dist/repertoire/coding/spawner.js +55 -9
  147. package/dist/repertoire/coding/tools.js +170 -7
  148. package/dist/repertoire/commerce-errors.js +109 -0
  149. package/dist/repertoire/commerce-self-test.js +156 -0
  150. package/dist/repertoire/credential-access.js +515 -0
  151. package/dist/repertoire/duffel-client.js +185 -0
  152. package/dist/repertoire/github-client.js +14 -55
  153. package/dist/repertoire/graph-client.js +11 -52
  154. package/dist/repertoire/guardrails.js +369 -0
  155. package/dist/repertoire/mcp-client.js +255 -0
  156. package/dist/repertoire/mcp-manager.js +303 -0
  157. package/dist/repertoire/mcp-tools.js +63 -0
  158. package/dist/repertoire/shell-sessions.js +133 -0
  159. package/dist/repertoire/skills.js +14 -23
  160. package/dist/repertoire/stripe-client.js +131 -0
  161. package/dist/repertoire/tasks/board.js +43 -5
  162. package/dist/repertoire/tasks/fix.js +182 -0
  163. package/dist/repertoire/tasks/index.js +28 -10
  164. package/dist/repertoire/tasks/lifecycle.js +2 -2
  165. package/dist/repertoire/tasks/parser.js +3 -2
  166. package/dist/repertoire/tasks/scanner.js +194 -37
  167. package/dist/repertoire/tasks/transitions.js +11 -28
  168. package/dist/repertoire/tools-base.js +43 -771
  169. package/dist/repertoire/tools-bluebubbles.js +201 -0
  170. package/dist/repertoire/tools-bridge.js +141 -0
  171. package/dist/repertoire/tools-config.js +185 -0
  172. package/dist/repertoire/tools-continuity.js +248 -0
  173. package/dist/repertoire/tools-credential.js +182 -0
  174. package/dist/repertoire/tools-files.js +342 -0
  175. package/dist/repertoire/tools-flight.js +224 -0
  176. package/dist/repertoire/tools-flow.js +62 -0
  177. package/dist/repertoire/tools-github.js +1 -7
  178. package/dist/repertoire/tools-memory.js +347 -0
  179. package/dist/repertoire/tools-session.js +739 -0
  180. package/dist/repertoire/tools-shell.js +120 -0
  181. package/dist/repertoire/tools-stripe.js +180 -0
  182. package/dist/repertoire/tools-surface.js +243 -0
  183. package/dist/repertoire/tools-teams.js +12 -62
  184. package/dist/repertoire/tools-travel.js +125 -0
  185. package/dist/repertoire/tools-user-profile.js +144 -0
  186. package/dist/repertoire/tools-vault.js +110 -0
  187. package/dist/repertoire/tools.js +155 -130
  188. package/dist/repertoire/travel-api-client.js +360 -0
  189. package/dist/repertoire/user-profile.js +118 -0
  190. package/dist/repertoire/vault-setup.js +241 -0
  191. package/dist/scripts/claude-code-hook.js +41 -0
  192. package/dist/scripts/claude-code-stop-hook.js +47 -0
  193. package/dist/senses/attention-queue.js +97 -0
  194. package/dist/senses/bluebubbles/attachment-cache.js +53 -0
  195. package/dist/senses/{bluebubbles-client.js → bluebubbles/client.js} +145 -10
  196. package/dist/senses/bluebubbles/entry.js +13 -0
  197. package/dist/senses/bluebubbles/inbound-log.js +110 -0
  198. package/dist/senses/bluebubbles/index.js +1374 -0
  199. package/dist/senses/bluebubbles/media.js +455 -0
  200. package/dist/senses/{bluebubbles-model.js → bluebubbles/model.js} +20 -5
  201. package/dist/senses/{bluebubbles-mutation-log.js → bluebubbles/mutation-log.js} +46 -6
  202. package/dist/senses/bluebubbles/runtime-state.js +109 -0
  203. package/dist/senses/bluebubbles/session-cleanup.js +72 -0
  204. package/dist/senses/cli/ink-app.js +156 -0
  205. package/dist/senses/cli/inline-diff.js +64 -0
  206. package/dist/senses/cli/message-list.js +51 -0
  207. package/dist/senses/cli/ouro-tui.js +393 -0
  208. package/dist/senses/cli/spinner-imperative.js +135 -0
  209. package/dist/senses/cli/spinner.js +101 -0
  210. package/dist/senses/cli/status-line.js +60 -0
  211. package/dist/senses/cli/streaming-markdown.js +526 -0
  212. package/dist/senses/cli/tool-display.js +83 -0
  213. package/dist/senses/cli/tool-render.js +85 -0
  214. package/dist/senses/cli/tui-store.js +236 -0
  215. package/dist/senses/cli/virtual-list.js +35 -0
  216. package/dist/senses/cli-entry.js +1 -1
  217. package/dist/senses/cli-layout.js +187 -0
  218. package/dist/senses/cli.js +550 -246
  219. package/dist/senses/commands.js +65 -1
  220. package/dist/senses/continuity.js +94 -0
  221. package/dist/senses/habit-turn-message.js +108 -0
  222. package/dist/senses/inner-dialog-worker.js +112 -19
  223. package/dist/senses/inner-dialog.js +593 -85
  224. package/dist/senses/pipeline.js +552 -0
  225. package/dist/senses/shared-turn.js +199 -0
  226. package/dist/senses/surface-tool.js +68 -0
  227. package/dist/senses/teams.js +664 -165
  228. package/dist/senses/trust-gate.js +112 -2
  229. package/package.json +14 -7
  230. package/skills/agent-commerce.md +106 -0
  231. package/skills/browser-navigation.md +110 -0
  232. package/skills/commerce-setup-guide.md +116 -0
  233. package/skills/commerce-setup.md +84 -0
  234. package/skills/configure-dev-tools.md +81 -0
  235. package/skills/travel-planning.md +138 -0
  236. package/dist/heart/daemon/subagent-installer.js +0 -134
  237. package/dist/senses/bluebubbles-entry.js +0 -11
  238. package/dist/senses/bluebubbles-media.js +0 -244
  239. package/dist/senses/bluebubbles.js +0 -421
  240. package/dist/senses/debug-activity.js +0 -107
  241. package/subagents/README.md +0 -73
  242. package/subagents/work-doer.md +0 -235
  243. package/subagents/work-merger.md +0 -618
  244. package/subagents/work-planner.md +0 -382
  245. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/basilisk.md +0 -0
  246. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jafar.md +0 -0
  247. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jormungandr.md +0 -0
  248. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/kaa.md +0 -0
  249. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/medusa.md +0 -0
  250. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/monty.md +0 -0
  251. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/nagini.md +0 -0
  252. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/ouroboros.md +0 -0
  253. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/python.md +0 -0
  254. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/quetzalcoatl.md +0 -0
  255. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/sir-hiss.md +0 -0
  256. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-serpent.md +0 -0
  257. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-snake.md +0 -0
  258. /package/dist/heart/{daemon → hatch}/hatch-animation.js +0 -0
  259. /package/dist/heart/{daemon → hatch}/specialist-orchestrator.js +0 -0
  260. /package/dist/heart/{daemon → versioning}/ouro-uti.js +0 -0
  261. /package/dist/heart/{daemon → versioning}/update-hooks.js +0 -0
@@ -33,7 +33,13 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.MarkdownStreamer = exports.InputController = exports.Spinner = void 0;
36
+ exports.InputQueue = exports.MarkdownStreamer = exports.InputController = exports.Spinner = exports.StreamingWordWrapper = exports.wrapCliText = exports.formatEchoedInputSummary = void 0;
37
+ exports.formatPendingPrefix = formatPendingPrefix;
38
+ exports.getCliContinuityIngressTexts = getCliContinuityIngressTexts;
39
+ exports.writeCliAsyncAssistantMessage = writeCliAsyncAssistantMessage;
40
+ exports.pauseActiveSpinner = pauseActiveSpinner;
41
+ exports.resumeActiveSpinner = resumeActiveSpinner;
42
+ exports.setActiveSpinner = setActiveSpinner;
37
43
  exports.handleSigint = handleSigint;
38
44
  exports.addHistory = addHistory;
39
45
  exports.renderMarkdown = renderMarkdown;
@@ -51,9 +57,9 @@ const format_1 = require("../mind/format");
51
57
  const config_1 = require("../heart/config");
52
58
  const context_1 = require("../mind/context");
53
59
  const pending_1 = require("../mind/pending");
54
- const prompt_refresh_1 = require("../mind/prompt-refresh");
55
60
  const commands_1 = require("./commands");
56
61
  const identity_1 = require("../heart/identity");
62
+ const mcp_manager_1 = require("../repertoire/mcp-manager");
57
63
  const nerves_1 = require("../nerves");
58
64
  const store_file_1 = require("../mind/friends/store-file");
59
65
  const resolver_1 = require("../mind/friends/resolver");
@@ -61,72 +67,77 @@ const tokens_1 = require("../mind/friends/tokens");
61
67
  const cli_logging_1 = require("../nerves/cli-logging");
62
68
  const runtime_1 = require("../nerves/runtime");
63
69
  const trust_gate_1 = require("./trust-gate");
70
+ const pipeline_1 = require("./pipeline");
71
+ const channel_1 = require("../mind/friends/channel");
64
72
  const session_lock_1 = require("./session-lock");
65
- const update_hooks_1 = require("../heart/daemon/update-hooks");
73
+ const update_hooks_1 = require("../heart/versioning/update-hooks");
66
74
  const bundle_meta_1 = require("../heart/daemon/hooks/bundle-meta");
75
+ const agent_config_v2_1 = require("../heart/daemon/hooks/agent-config-v2");
67
76
  const bundle_manifest_1 = require("../mind/bundle-manifest");
68
- // spinner that only touches stderr, cleans up after itself
69
- // exported for direct testability (stop-without-start branch)
70
- class Spinner {
71
- frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
72
- i = 0;
73
- iv = null;
74
- piv = null;
75
- msg = "";
76
- phrases = null;
77
- lastPhrase = "";
78
- stopped = false;
79
- constructor(m = "working", phrases) {
80
- this.msg = m;
81
- if (phrases && phrases.length > 0)
82
- this.phrases = phrases;
83
- }
84
- start() {
85
- this.stopped = false;
86
- process.stderr.write("\r\x1b[K");
87
- this.spin();
88
- this.iv = setInterval(() => this.spin(), 80);
89
- if (this.phrases) {
90
- this.piv = setInterval(() => this.rotatePhrase(), 1500);
91
- }
92
- }
93
- spin() {
94
- // Guard: clearInterval can't prevent already-dequeued callbacks
95
- /* v8 ignore next -- race guard: timer callback fires after stop() @preserve */
96
- if (this.stopped)
97
- return;
98
- process.stderr.write(`\r\x1b[K${this.frames[this.i]} ${this.msg}... `);
99
- this.i = (this.i + 1) % this.frames.length;
100
- }
101
- rotatePhrase() {
102
- /* v8 ignore next -- race guard: timer callback fires after stop() @preserve */
103
- if (this.stopped)
104
- return;
105
- const next = (0, phrases_1.pickPhrase)(this.phrases, this.lastPhrase);
106
- this.lastPhrase = next;
107
- this.msg = next;
108
- }
109
- stop(ok) {
110
- this.stopped = true;
111
- if (this.iv) {
112
- clearInterval(this.iv);
113
- this.iv = null;
114
- }
115
- if (this.piv) {
116
- clearInterval(this.piv);
117
- this.piv = null;
118
- }
119
- process.stderr.write("\r\x1b[K");
120
- /* v8 ignore next -- ok parameter currently unused by callers @preserve */
121
- if (ok)
122
- process.stderr.write(`\x1b[32m\u2713\x1b[0m ${ok}\n`);
123
- }
124
- fail(msg) {
125
- this.stop();
126
- process.stderr.write(`\x1b[31m\u2717\x1b[0m ${msg}\n`);
77
+ const cli_layout_1 = require("./cli-layout");
78
+ const spinner_imperative_1 = require("./cli/spinner-imperative");
79
+ const tool_display_1 = require("./cli/tool-display");
80
+ var cli_layout_2 = require("./cli-layout");
81
+ Object.defineProperty(exports, "formatEchoedInputSummary", { enumerable: true, get: function () { return cli_layout_2.formatEchoedInputSummary; } });
82
+ Object.defineProperty(exports, "wrapCliText", { enumerable: true, get: function () { return cli_layout_2.wrapCliText; } });
83
+ Object.defineProperty(exports, "StreamingWordWrapper", { enumerable: true, get: function () { return cli_layout_2.StreamingWordWrapper; } });
84
+ /**
85
+ * Format pending messages as content-prefix strings for injection into
86
+ * the next user message. Self-messages (from === agentName) become
87
+ * `[inner thought: {content}]`, inter-agent messages become
88
+ * `[message from {name}: {content}]`.
89
+ */
90
+ function formatPendingPrefix(messages, agentName) {
91
+ return messages
92
+ .map((msg) => msg.from === agentName
93
+ ? `[inner thought: ${msg.content}]`
94
+ : `[message from ${msg.from}: ${msg.content}]`)
95
+ .join("\n");
96
+ }
97
+ function getCliContinuityIngressTexts(input) {
98
+ const trimmed = input.trim();
99
+ return trimmed ? [trimmed] : [];
100
+ }
101
+ /* v8 ignore start -- cosmetic time formatting @preserve */
102
+ function formatTimeAgo(date) {
103
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
104
+ if (seconds < 60)
105
+ return "just now";
106
+ const minutes = Math.floor(seconds / 60);
107
+ if (minutes < 60)
108
+ return `${minutes}m ago`;
109
+ const hours = Math.floor(minutes / 60);
110
+ if (hours < 24)
111
+ return `${hours}h ago`;
112
+ const days = Math.floor(hours / 24);
113
+ return `${days}d ago`;
114
+ }
115
+ const CLI_PROMPT = "\x1b[36m) \x1b[0m";
116
+ function writeCliAsyncAssistantMessage(rl, message, stdout = process.stdout) {
117
+ const rlInt = rl;
118
+ const currentLine = rlInt.line ?? "";
119
+ const currentCursor = rlInt.cursor ?? currentLine.length;
120
+ stdout.write("\r\x1b[K");
121
+ stdout.write(`${renderMarkdown(message)}\n`);
122
+ stdout.write(CLI_PROMPT);
123
+ if (!currentLine)
124
+ return;
125
+ stdout.write(currentLine);
126
+ if (currentCursor < currentLine.length) {
127
+ readline.cursorTo(process.stdout, 2 + currentCursor);
127
128
  }
128
129
  }
129
- exports.Spinner = Spinner;
130
+ // Module-level active spinner for log coordination.
131
+ // The terminal log sink calls these to avoid interleaving with spinner output.
132
+ let _activeSpinner = null;
133
+ /* v8 ignore start -- spinner coordination: exercised at runtime, not unit-testable without real terminal @preserve */
134
+ function pauseActiveSpinner() { _activeSpinner?.pause(); }
135
+ function resumeActiveSpinner() { _activeSpinner?.resume(); }
136
+ /* v8 ignore stop */
137
+ function setActiveSpinner(s) { _activeSpinner = s; }
138
+ // Re-export ImperativeSpinner as Spinner for backward compatibility
139
+ var spinner_imperative_2 = require("./cli/spinner-imperative");
140
+ Object.defineProperty(exports, "Spinner", { enumerable: true, get: function () { return spinner_imperative_2.ImperativeSpinner; } });
130
141
  // Input controller: pauses readline during model/tool execution.
131
142
  // Does NOT touch raw mode — readline with terminal:true manages raw mode
132
143
  // internally. Touching it causes ^C to be echoed by the terminal driver.
@@ -294,59 +305,55 @@ function createCliCallbacks() {
294
305
  meta: {},
295
306
  });
296
307
  let currentSpinner = null;
297
- let hadReasoning = false;
308
+ function setSpinner(s) { currentSpinner = s; setActiveSpinner(s); }
298
309
  let hadToolRun = false;
299
310
  let textDirty = false; // true when text/reasoning was written without a trailing newline
300
311
  const streamer = new MarkdownStreamer();
312
+ const wrapper = new cli_layout_1.StreamingWordWrapper();
301
313
  return {
302
314
  onModelStart: () => {
303
315
  currentSpinner?.stop();
304
- currentSpinner = null;
305
- hadReasoning = false;
316
+ setSpinner(null);
306
317
  textDirty = false;
307
318
  streamer.reset();
319
+ wrapper.reset();
308
320
  const phrases = (0, phrases_1.getPhrases)();
309
321
  const pool = hadToolRun ? phrases.followup : phrases.thinking;
310
322
  const first = (0, phrases_1.pickPhrase)(pool);
311
- currentSpinner = new Spinner(first, pool);
323
+ setSpinner(new spinner_imperative_1.ImperativeSpinner(first, pool));
312
324
  currentSpinner.start();
313
325
  },
314
326
  onModelStreamStart: () => {
315
327
  // No-op: content callbacks (onTextChunk, onReasoningChunk) handle
316
328
  // stopping the spinner. onModelStreamStart fires too early and
317
- // doesn't fire at all for final_answer tool streaming.
329
+ // doesn't fire at all for settle tool streaming.
318
330
  },
319
331
  onClearText: () => {
320
332
  streamer.reset();
333
+ wrapper.reset();
321
334
  },
322
335
  onTextChunk: (text) => {
323
- // Stop spinner if still running — final_answer streaming and Anthropic
336
+ // Stop spinner if still running — settle streaming and Anthropic
324
337
  // tool-only responses bypass onModelStreamStart, so the spinner would
325
338
  // otherwise keep running (and its \r writes overwrite response text).
326
339
  if (currentSpinner) {
327
340
  currentSpinner.stop();
328
- currentSpinner = null;
329
- }
330
- if (hadReasoning) {
331
- // Single newline to separate reasoning from reply — reasoning
332
- // output often ends with its own trailing newline(s)
333
- process.stdout.write("\n");
334
- hadReasoning = false;
341
+ setSpinner(null);
335
342
  }
336
343
  const rendered = streamer.push(text);
337
- if (rendered)
338
- process.stdout.write(rendered);
339
- textDirty = text.length > 0 && !text.endsWith("\n");
340
- },
341
- onReasoningChunk: (text) => {
342
- if (currentSpinner) {
343
- currentSpinner.stop();
344
- currentSpinner = null;
344
+ /* v8 ignore start -- wrapper integration: tested via cli.test.ts onTextChunk tests @preserve */
345
+ if (rendered) {
346
+ const wrapped = wrapper.push(rendered);
347
+ if (wrapped)
348
+ process.stdout.write(wrapped);
345
349
  }
346
- hadReasoning = true;
347
- process.stdout.write(`\x1b[2m${text}\x1b[0m`);
350
+ /* v8 ignore stop */
348
351
  textDirty = text.length > 0 && !text.endsWith("\n");
349
352
  },
353
+ onReasoningChunk: (_text) => {
354
+ // Keep reasoning private in the CLI surface. The spinner continues to
355
+ // represent active thinking until actual tool or answer output arrives.
356
+ },
350
357
  onToolStart: (_name, _args) => {
351
358
  // Stop the model-start spinner: when the model returns only tool calls
352
359
  // (no content/reasoning), onModelStreamStart never fires, so the old
@@ -360,31 +367,29 @@ function createCliCallbacks() {
360
367
  }
361
368
  const toolPhrases = (0, phrases_1.getPhrases)().tool;
362
369
  const first = (0, phrases_1.pickPhrase)(toolPhrases);
363
- currentSpinner = new Spinner(first, toolPhrases);
370
+ setSpinner(new spinner_imperative_1.ImperativeSpinner(first, toolPhrases));
364
371
  currentSpinner.start();
365
372
  hadToolRun = true;
366
373
  },
367
374
  onToolEnd: (name, argSummary, success) => {
368
375
  currentSpinner?.stop();
369
- currentSpinner = null;
370
- const msg = (0, format_1.formatToolResult)(name, argSummary, success);
371
- const color = success ? "\x1b[32m" : "\x1b[31m";
372
- process.stderr.write(`${color}${msg}\x1b[0m\n`);
376
+ setSpinner(null);
377
+ (0, tool_display_1.writeToolEnd)(name, argSummary, success);
373
378
  },
374
379
  onError: (error, severity) => {
375
380
  if (severity === "transient") {
376
381
  currentSpinner?.fail(error.message);
377
- currentSpinner = null;
382
+ setSpinner(null);
378
383
  }
379
384
  else {
380
385
  currentSpinner?.stop();
381
- currentSpinner = null;
386
+ setSpinner(null);
382
387
  process.stderr.write(`\x1b[31m${(0, format_1.formatError)(error)}\x1b[0m\n`);
383
388
  }
384
389
  },
385
390
  onKick: () => {
386
391
  currentSpinner?.stop();
387
- currentSpinner = null;
392
+ setSpinner(null);
388
393
  if (textDirty) {
389
394
  process.stdout.write("\n");
390
395
  textDirty = false;
@@ -393,10 +398,18 @@ function createCliCallbacks() {
393
398
  },
394
399
  flushMarkdown: () => {
395
400
  currentSpinner?.stop();
396
- currentSpinner = null;
401
+ setSpinner(null);
402
+ /* v8 ignore start -- wrapper flush: tested via cli.test.ts flushMarkdown tests @preserve */
397
403
  const remaining = streamer.flush();
398
- if (remaining)
399
- process.stdout.write(remaining);
404
+ if (remaining) {
405
+ const wrapped = wrapper.push(remaining);
406
+ if (wrapped)
407
+ process.stdout.write(wrapped);
408
+ }
409
+ const tail = wrapper.flush();
410
+ if (tail)
411
+ process.stdout.write(tail);
412
+ /* v8 ignore stop */
400
413
  },
401
414
  };
402
415
  }
@@ -437,29 +450,236 @@ async function* createDebouncedLines(source, debounceMs) {
437
450
  yield lines.join("\n");
438
451
  }
439
452
  }
453
+ /**
454
+ * Async queue that bridges push-based Ink input to pull-based async iteration.
455
+ * Input from Ink's onSubmit callback is pushed; the business logic loop awaits via for-await.
456
+ */
457
+ class InputQueue {
458
+ queue = [];
459
+ resolve = null;
460
+ done = false;
461
+ push(input) {
462
+ if (this.done)
463
+ return;
464
+ if (this.resolve) {
465
+ const r = this.resolve;
466
+ this.resolve = null;
467
+ r({ value: input, done: false });
468
+ }
469
+ else {
470
+ this.queue.push(input);
471
+ }
472
+ }
473
+ close() {
474
+ this.done = true;
475
+ if (this.resolve) {
476
+ const r = this.resolve;
477
+ this.resolve = null;
478
+ r({ value: undefined, done: true });
479
+ }
480
+ }
481
+ /** Drain all buffered items, leaving any pending async awaiter untouched. */
482
+ drainAll() {
483
+ const items = [...this.queue];
484
+ this.queue = [];
485
+ return items;
486
+ }
487
+ [Symbol.asyncIterator]() {
488
+ return {
489
+ next: () => {
490
+ if (this.queue.length > 0) {
491
+ return Promise.resolve({ value: this.queue.shift(), done: false });
492
+ }
493
+ if (this.done) {
494
+ return Promise.resolve({ value: undefined, done: true });
495
+ }
496
+ return new Promise((resolve) => {
497
+ this.resolve = resolve;
498
+ });
499
+ },
500
+ };
501
+ }
502
+ }
503
+ exports.InputQueue = InputQueue;
440
504
  async function runCliSession(options) {
441
505
  /* v8 ignore start -- integration: runCliSession is interactive, tested via E2E @preserve */
442
- const pasteDebounceMs = options.pasteDebounceMs ?? 50;
443
506
  const registry = (0, commands_1.createCommandRegistry)();
444
507
  if (!options.disableCommands) {
445
508
  (0, commands_1.registerDefaultCommands)(registry);
446
509
  }
447
510
  const messages = options.messages
448
511
  ?? [{ role: "system", content: await (0, prompt_1.buildSystem)("cli") }];
449
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
450
- const ctrl = new InputController(rl);
512
+ // ─── Rendering: TUI (Ink + Static) for TTY, imperative for tests/pipes ───
513
+ const useTui = !options._testInputSource && process.stdin.isTTY === true;
451
514
  let currentAbort = null;
452
- const history = [];
453
515
  let closed = false;
454
- rl.on("close", () => { closed = true; });
455
- if (options.banner !== false) {
456
- const bannerText = typeof options.banner === "string"
457
- ? options.banner
458
- : `${options.agentName} (type /commands for help)`;
459
- // eslint-disable-next-line no-console -- terminal UX: startup banner
460
- console.log(`\n${bannerText}\n`);
516
+ let cliCallbacks;
517
+ let tuiStore = null;
518
+ let inkRef = null;
519
+ const inputQueue = useTui ? new InputQueue() : null;
520
+ let rl = null;
521
+ if (useTui) {
522
+ try {
523
+ const [ink, React, tuiMod, storeMod] = await Promise.all([
524
+ Promise.resolve().then(() => __importStar(require("ink"))),
525
+ Promise.resolve().then(() => __importStar(require("react"))),
526
+ Promise.resolve().then(() => __importStar(require("./cli/ouro-tui"))),
527
+ Promise.resolve().then(() => __importStar(require("./cli/tui-store"))),
528
+ ]);
529
+ const { OuroTui } = tuiMod;
530
+ const { TuiStore, createTuiCallbacks } = storeMod;
531
+ tuiStore = new TuiStore();
532
+ cliCallbacks = createTuiCallbacks(tuiStore);
533
+ // Seed input history from previous session (for up/down arrows) — NOT display
534
+ const prevUserMsgs = messages
535
+ .filter((msg) => msg.role === "user" && typeof msg.content === "string")
536
+ .map(msg => msg.content);
537
+ tuiStore.seedHistory(prevUserMsgs);
538
+ // Show session resume context: summary + last few exchanges (dimmed)
539
+ /* v8 ignore start -- session resume display: integration-only, tested visually @preserve */
540
+ if (messages.length > 1) {
541
+ const userAssistantMsgs = messages.filter((m) => (m.role === "user" || m.role === "assistant") && typeof m.content === "string" && m.content.trim().length > 0);
542
+ // Extract last 2 exchanges (up to 4 messages)
543
+ const lastExchanges = [];
544
+ for (let i = userAssistantMsgs.length - 1; i >= 0 && lastExchanges.length < 4; i--) {
545
+ lastExchanges.unshift({ role: userAssistantMsgs[i].role, content: userAssistantMsgs[i].content });
546
+ }
547
+ const msgCount = messages.filter(m => m.role === "user" || m.role === "assistant").length;
548
+ const timeAgo = options.lastActivityAt ? formatTimeAgo(new Date(options.lastActivityAt)) : null;
549
+ const summary = ` resuming session (${msgCount} messages${timeAgo ? ` · last active ${timeAgo}` : ""})`;
550
+ tuiStore.addSessionHistory(summary, lastExchanges);
551
+ }
552
+ /* v8 ignore stop */
553
+ // Ctrl-C state machine (Claude Code behavior):
554
+ // During generation: abort current request
555
+ // Idle with text: clear input
556
+ // Idle empty (first): warn
557
+ // Idle empty (second): exit
558
+ let ctrlCWarned = false;
559
+ let ctrlCTimer = null;
560
+ const handleCtrlC = (hasInput) => {
561
+ if (currentAbort) {
562
+ currentAbort.abort();
563
+ ctrlCWarned = false;
564
+ return "abort";
565
+ }
566
+ if (hasInput) {
567
+ ctrlCWarned = false;
568
+ return "clear";
569
+ }
570
+ if (ctrlCWarned) {
571
+ ctrlCWarned = false;
572
+ if (ctrlCTimer) {
573
+ clearTimeout(ctrlCTimer);
574
+ ctrlCTimer = null;
575
+ }
576
+ closed = true;
577
+ inputQueue.close();
578
+ return "exit";
579
+ }
580
+ ctrlCWarned = true;
581
+ // Reset after 2 seconds — must press twice within window
582
+ ctrlCTimer = setTimeout(() => { ctrlCWarned = false; }, 2000);
583
+ return "warn";
584
+ };
585
+ // TUI root: subscribes to store, passes props to OuroTui
586
+ // Elapsed timer is local React state (no store.notify overhead)
587
+ const storeRef = tuiStore;
588
+ function TuiRoot() {
589
+ const [, forceUpdate] = React.useState(0);
590
+ const [elapsed, setElapsed] = React.useState(0);
591
+ React.useEffect(() => storeRef.subscribe(() => {
592
+ forceUpdate((n) => n + 1);
593
+ // Reset ctrlC warning on any state change (new turn, etc.)
594
+ }), []);
595
+ React.useEffect(() => {
596
+ const iv = setInterval(() => setElapsed(storeRef.getElapsed()), 1000);
597
+ return () => clearInterval(iv);
598
+ }, []);
599
+ return React.createElement(OuroTui, {
600
+ agentName: options.agentName,
601
+ model: (0, identity_1.loadAgentConfig)().humanFacing?.model ?? "",
602
+ completedMessages: storeRef.completedMessages,
603
+ inputHistory: storeRef.inputHistory,
604
+ queuedInputs: storeRef.queuedInputs,
605
+ live: storeRef.live,
606
+ elapsedSeconds: elapsed,
607
+ contextPercent: 0,
608
+ onSubmit: (text) => { ctrlCWarned = false; inputQueue.push(text); storeRef.enqueueInput(text); },
609
+ onCtrlC: handleCtrlC,
610
+ onPopQueue: () => { const items = storeRef.popAllQueuedForEditing(); inputQueue.drainAll(); return items; },
611
+ headerShown: storeRef.headerShown,
612
+ cwd: process.cwd().replace(process.env.HOME ?? "", "~"),
613
+ });
614
+ }
615
+ inkRef = ink.render(React.createElement(TuiRoot), { exitOnCtrlC: false, patchConsole: false });
616
+ }
617
+ catch (err) {
618
+ // Ink failed to load (CJS compat, missing deps, etc.) — fall through to imperative
619
+ (0, runtime_1.emitNervesEvent)({
620
+ component: "senses",
621
+ event: "senses.tui_fallback",
622
+ message: `TUI failed to load, falling back to imperative: ${err instanceof Error ? err.message : String(err)}`,
623
+ meta: {},
624
+ });
625
+ }
626
+ }
627
+ // Fallback to imperative callbacks if TUI didn't initialize
628
+ if (!tuiStore) {
629
+ cliCallbacks = createCliCallbacks();
630
+ if (options.banner !== false) {
631
+ const bannerText = typeof options.banner === "string"
632
+ ? options.banner
633
+ : `${options.agentName} (type /commands for help)`;
634
+ // eslint-disable-next-line no-console -- terminal UX: startup banner
635
+ console.log(`\n${bannerText}\n`);
636
+ }
461
637
  }
462
- const cliCallbacks = createCliCallbacks();
638
+ // Display helpers: route to TUI store or imperative stderr/stdout
639
+ const display = {
640
+ error: (msg) => {
641
+ if (tuiStore)
642
+ tuiStore.setError(msg);
643
+ else
644
+ process.stderr.write(`\x1b[31m${msg}\x1b[0m\n`);
645
+ },
646
+ warn: (msg) => {
647
+ if (tuiStore)
648
+ tuiStore.setError(msg); // TUI shows warnings as errors (amber color handled in component)
649
+ else
650
+ process.stderr.write(`\x1b[33m${msg}\x1b[0m\n`);
651
+ },
652
+ text: (msg) => {
653
+ if (tuiStore)
654
+ tuiStore.appendText(msg);
655
+ else
656
+ process.stdout.write(`${msg}\n`);
657
+ },
658
+ suppressInput: () => {
659
+ if (tuiStore)
660
+ tuiStore.suppressInput();
661
+ },
662
+ restoreInput: () => {
663
+ if (tuiStore)
664
+ tuiStore.restoreInput();
665
+ },
666
+ };
667
+ const effectiveToolContext = {
668
+ signin: options.toolContext?.signin ?? (async () => undefined),
669
+ ...options.toolContext,
670
+ codingFeedback: {
671
+ send: async (message) => {
672
+ const assistantMessage = {
673
+ role: "assistant",
674
+ content: message,
675
+ };
676
+ messages.push(assistantMessage);
677
+ await options.onAsyncAssistantMessage?.(messages, assistantMessage);
678
+ display.text(message);
679
+ await options.toolContext?.codingFeedback?.send(message);
680
+ },
681
+ },
682
+ };
463
683
  // exitOnToolCall machinery: wrap execTool to detect target tool
464
684
  let exitToolResult;
465
685
  let exitToolFired = false;
@@ -470,8 +690,6 @@ async function runCliSession(options) {
470
690
  if (name === options.exitOnToolCall) {
471
691
  exitToolResult = result;
472
692
  exitToolFired = true;
473
- // Abort immediately so the model doesn't generate more output
474
- // (e.g. reasoning about calling final_answer after complete_adoption)
475
693
  currentAbort?.abort();
476
694
  }
477
695
  return result;
@@ -479,28 +697,6 @@ async function runCliSession(options) {
479
697
  : resolvedExecTool;
480
698
  // Resolve toolChoiceRequired: use explicit option if set, else fall back to toggle
481
699
  const getEffectiveToolChoiceRequired = () => options.toolChoiceRequired !== undefined ? options.toolChoiceRequired : (0, commands_1.getToolChoiceRequired)();
482
- // Ctrl-C at the input prompt: clear line or warn/exit
483
- rl.on("SIGINT", () => {
484
- const rlInt = rl;
485
- const currentLine = rlInt.line || "";
486
- const result = handleSigint(rl, currentLine);
487
- if (result === "clear") {
488
- rlInt.line = "";
489
- rlInt.cursor = 0;
490
- process.stdout.write("\r\x1b[K\x1b[36m> \x1b[0m");
491
- }
492
- else if (result === "warn") {
493
- rlInt.line = "";
494
- rlInt.cursor = 0;
495
- process.stdout.write("\r\x1b[K");
496
- process.stderr.write("press Ctrl-C again to exit\n");
497
- process.stdout.write("\x1b[36m> \x1b[0m");
498
- }
499
- else {
500
- rl.close();
501
- }
502
- });
503
- const debouncedLines = (source) => createDebouncedLines(source, pasteDebounceMs);
504
700
  (0, runtime_1.emitNervesEvent)({
505
701
  component: "senses",
506
702
  event: "senses.cli_session_start",
@@ -513,7 +709,7 @@ async function runCliSession(options) {
513
709
  if (options.autoFirstTurn && messages.length > 0 && messages[messages.length - 1]?.role === "user") {
514
710
  currentAbort = new AbortController();
515
711
  const traceId = (0, nerves_1.createTraceId)();
516
- ctrl.suppress(() => currentAbort.abort());
712
+ display.suppressInput();
517
713
  let result;
518
714
  try {
519
715
  result = await (0, core_1.runAgent)(messages, cliCallbacks, options.skipSystemPromptRefresh ? undefined : "cli", currentAbort.signal, {
@@ -521,115 +717,149 @@ async function runCliSession(options) {
521
717
  traceId,
522
718
  tools: options.tools,
523
719
  execTool: wrappedExecTool,
524
- toolContext: options.toolContext,
720
+ toolContext: effectiveToolContext,
525
721
  });
526
722
  }
527
723
  catch (err) {
528
- // AbortError (Ctrl-C) -- silently continue to prompt
529
- // All other errors: show the user what happened
530
724
  if (!(err instanceof DOMException && err.name === "AbortError")) {
531
- process.stderr.write(`\x1b[31m${err instanceof Error ? err.message : String(err)}\x1b[0m\n`);
725
+ display.error(err instanceof Error ? err.message : String(err));
532
726
  }
533
727
  }
534
728
  cliCallbacks.flushMarkdown();
535
- ctrl.restore();
729
+ display.restoreInput();
536
730
  currentAbort = null;
537
731
  if (exitToolFired) {
538
732
  exitReason = "tool_exit";
539
- rl.close();
733
+ closed = true;
540
734
  }
541
735
  else {
542
736
  const lastMsg = messages[messages.length - 1];
543
737
  if (lastMsg?.role === "assistant" && !(typeof lastMsg.content === "string" ? lastMsg.content : "").trim()) {
544
- process.stderr.write("\x1b[33m(empty response)\x1b[0m\n");
738
+ display.warn("(empty response)");
545
739
  }
546
- process.stdout.write("\n\n");
547
740
  if (options.onTurnEnd) {
548
741
  await options.onTurnEnd(messages, result ?? { usage: undefined });
549
742
  }
550
743
  }
551
744
  }
552
- if (!exitToolFired) {
553
- process.stdout.write("\x1b[36m> \x1b[0m");
554
- }
555
745
  try {
556
- for await (const input of debouncedLines(rl)) {
746
+ // Input source: TUI queue for Ink, test source for tests, readline fallback
747
+ let inputSource;
748
+ let inputCtrl = null;
749
+ if (tuiStore && inputQueue) {
750
+ // TUI path: input comes from Ink's onSubmit → InputQueue
751
+ inputSource = inputQueue;
752
+ }
753
+ else if (options._testInputSource) {
754
+ inputSource = options._testInputSource;
755
+ }
756
+ else {
757
+ // Imperative fallback: readline
758
+ const isTTY = process.stdin.isTTY === true;
759
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: isTTY });
760
+ inputCtrl = new InputController(rl);
761
+ inputSource = rl;
762
+ process.stdout.write(CLI_PROMPT);
763
+ }
764
+ for await (const input of inputSource) {
557
765
  if (closed)
558
766
  break;
559
- if (!input.trim()) {
560
- process.stdout.write("\x1b[36m> \x1b[0m");
767
+ if (!input.trim())
561
768
  continue;
562
- }
769
+ // Remove from TUI queue display as the agent picks up this message
770
+ tuiStore?.dequeueInput(input);
563
771
  // Optional input gate (e.g. trust gate in main)
564
772
  if (options.onInput) {
565
773
  const gate = options.onInput(input);
566
774
  if (!gate.allowed) {
567
775
  if (gate.reply) {
568
- process.stdout.write(`${gate.reply}\n`);
776
+ display.text(gate.reply);
569
777
  }
570
778
  if (closed)
571
779
  break;
572
- process.stdout.write("\x1b[36m> \x1b[0m");
573
780
  continue;
574
781
  }
575
782
  }
576
- // Check for slash commands
577
- const parsed = (0, commands_1.parseSlashCommand)(input);
578
- if (parsed) {
579
- const dispatchResult = registry.dispatch(parsed.command, { channel: "cli" });
580
- if (dispatchResult.handled && dispatchResult.result) {
581
- if (dispatchResult.result.action === "exit") {
582
- break;
583
- }
584
- else if (dispatchResult.result.action === "new") {
585
- messages.length = 0;
586
- messages.push({ role: "system", content: await (0, prompt_1.buildSystem)("cli") });
587
- await options.onNewSession?.();
588
- // eslint-disable-next-line no-console -- terminal UX: session cleared
589
- console.log("session cleared");
590
- process.stdout.write("\x1b[36m> \x1b[0m");
591
- continue;
592
- }
593
- else if (dispatchResult.result.action === "response") {
594
- // eslint-disable-next-line no-console -- terminal UX: command dispatch result
595
- console.log(dispatchResult.result.message || "");
596
- process.stdout.write("\x1b[36m> \x1b[0m");
597
- continue;
783
+ // Check for slash commands (legacy path only — pipeline handles commands for runTurn path)
784
+ if (!options.runTurn) {
785
+ const parsed = (0, commands_1.parseSlashCommand)(input);
786
+ if (parsed) {
787
+ const dispatchResult = registry.dispatch(parsed.command, { channel: "cli" });
788
+ if (dispatchResult.handled && dispatchResult.result) {
789
+ if (dispatchResult.result.action === "exit") {
790
+ break;
791
+ }
792
+ else if (dispatchResult.result.action === "new") {
793
+ messages.length = 0;
794
+ messages.push({ role: "system", content: await (0, prompt_1.buildSystem)("cli") });
795
+ await options.onNewSession?.();
796
+ // eslint-disable-next-line no-console -- terminal UX: session cleared
797
+ console.log("session cleared");
798
+ continue;
799
+ }
800
+ else if (dispatchResult.result.action === "response") {
801
+ display.text(dispatchResult.result.message || "");
802
+ continue;
803
+ }
598
804
  }
599
805
  }
600
806
  }
601
- // Re-style the echoed input lines
602
- const cols = process.stdout.columns || 80;
603
- const inputLines = input.split("\n");
604
- let echoRows = 0;
605
- for (const line of inputLines) {
606
- echoRows += Math.ceil((2 + line.length) / cols);
607
- }
608
- process.stdout.write(`\x1b[${echoRows}A\x1b[K` + `\x1b[1m> ${inputLines[0]}${inputLines.length > 1 ? ` (+${inputLines.length - 1} lines)` : ""}\x1b[0m\n\n`);
609
- messages.push({ role: "user", content: input });
610
- addHistory(history, input);
807
+ // Track user message in TUI for display
808
+ if (tuiStore)
809
+ tuiStore.addUserMessage(input);
611
810
  currentAbort = new AbortController();
612
- const traceId = (0, nerves_1.createTraceId)();
613
- ctrl.suppress(() => currentAbort.abort());
811
+ if (tuiStore)
812
+ tuiStore.suppressInput();
813
+ inputCtrl?.suppress(() => { currentAbort?.abort(); });
614
814
  let result;
615
815
  try {
616
- result = await (0, core_1.runAgent)(messages, cliCallbacks, options.skipSystemPromptRefresh ? undefined : "cli", currentAbort.signal, {
617
- toolChoiceRequired: getEffectiveToolChoiceRequired(),
618
- traceId,
619
- tools: options.tools,
620
- execTool: wrappedExecTool,
621
- toolContext: options.toolContext,
622
- });
816
+ if (options.runTurn) {
817
+ // Pipeline-based turn: the runTurn callback handles user message assembly,
818
+ // pending drain, trust gate, runAgent, postTurn, and token accumulation.
819
+ result = await options.runTurn(messages, input, cliCallbacks, currentAbort.signal, effectiveToolContext);
820
+ // Handle pipeline-intercepted commands with loop-control side effects
821
+ if (result?.turnOutcome === "command") {
822
+ if (result.commandAction === "exit") {
823
+ break;
824
+ }
825
+ else if (result.commandAction === "new") {
826
+ messages.length = 0;
827
+ messages.push({ role: "system", content: await (0, prompt_1.buildSystem)("cli") });
828
+ await options.onNewSession?.();
829
+ // eslint-disable-next-line no-console -- terminal UX: session cleared
830
+ console.log("session cleared");
831
+ continue;
832
+ }
833
+ // For "response" commands: the pipeline already emitted the response via onTextChunk
834
+ cliCallbacks.flushMarkdown();
835
+ continue;
836
+ }
837
+ }
838
+ else {
839
+ // Legacy path: inline runAgent (used by serpent guide and tests)
840
+ const prefix = options.getContentPrefix?.();
841
+ messages.push({ role: "user", content: prefix ? `${prefix}\n\n${input}` : input });
842
+ const traceId = (0, nerves_1.createTraceId)();
843
+ result = await (0, core_1.runAgent)(messages, cliCallbacks, options.skipSystemPromptRefresh ? undefined : "cli", currentAbort.signal, {
844
+ toolChoiceRequired: getEffectiveToolChoiceRequired(),
845
+ traceId,
846
+ tools: options.tools,
847
+ execTool: wrappedExecTool,
848
+ toolContext: effectiveToolContext,
849
+ });
850
+ }
623
851
  }
624
852
  catch (err) {
625
- // AbortError (Ctrl-C) -- silently return to prompt
626
- // All other errors: show the user what happened
627
853
  if (!(err instanceof DOMException && err.name === "AbortError")) {
628
- process.stderr.write(`\x1b[31m${err instanceof Error ? err.message : String(err)}\x1b[0m\n`);
854
+ display.error(err instanceof Error ? err.message : String(err));
629
855
  }
630
856
  }
631
857
  cliCallbacks.flushMarkdown();
632
- ctrl.restore();
858
+ if (!tuiStore)
859
+ process.stdout.write("\n"); // ensure response ends with newline before prompt (imperative only)
860
+ if (tuiStore)
861
+ tuiStore.restoreInput();
862
+ inputCtrl?.restore();
633
863
  currentAbort = null;
634
864
  // Check if exit tool was fired during this turn
635
865
  if (exitToolFired) {
@@ -639,20 +869,35 @@ async function runCliSession(options) {
639
869
  // Safety net: never silently swallow an empty response
640
870
  const lastMsg = messages[messages.length - 1];
641
871
  if (lastMsg?.role === "assistant" && !(typeof lastMsg.content === "string" ? lastMsg.content : "").trim()) {
642
- process.stderr.write("\x1b[33m(empty response)\x1b[0m\n");
872
+ display.warn("(empty response)");
643
873
  }
644
- process.stdout.write("\n\n");
645
874
  // Post-turn hook (session persistence, pending drain, prompt refresh, etc.)
646
875
  if (options.onTurnEnd) {
647
876
  await options.onTurnEnd(messages, result ?? { usage: undefined });
648
877
  }
649
878
  if (closed)
650
879
  break;
651
- process.stdout.write("\x1b[36m> \x1b[0m");
652
880
  }
653
881
  }
654
882
  finally {
655
- rl.close();
883
+ rl?.close();
884
+ if (inkRef) {
885
+ // Suppress React "state update on unmounted component" warnings during cleanup.
886
+ // Ink's useInput hook fires after unmount — this is harmless but noisy.
887
+ // This also covers the SerpentGuide exitOnToolCall path, which aborts the
888
+ // current request and breaks out of the loop into this finally block.
889
+ // eslint-disable-next-line no-console -- intentional console.warn/error override for cleanup
890
+ const origWarn = console.warn;
891
+ const origError = console.error; // eslint-disable-line no-console
892
+ // eslint-disable-next-line no-console -- suppress React unmount warnings
893
+ console.warn = (...args) => { if (typeof args[0] === "string" && args[0].includes("Can't perform a React state update"))
894
+ return; origWarn.apply(console, args); };
895
+ // eslint-disable-next-line no-console -- suppress React unmount warnings
896
+ console.error = (...args) => { if (typeof args[0] === "string" && args[0].includes("Can't perform a React state update"))
897
+ return; origError.apply(console, args); };
898
+ inkRef.unmount();
899
+ setTimeout(() => { console.warn = origWarn; console.error = origError; }, 100); // eslint-disable-line no-console
900
+ }
656
901
  if (options.banner !== false) {
657
902
  // eslint-disable-next-line no-console -- terminal UX: goodbye
658
903
  console.log("bye");
@@ -665,17 +910,19 @@ async function main(agentName, options) {
665
910
  if (agentName)
666
911
  (0, identity_1.setAgentName)(agentName);
667
912
  const pasteDebounceMs = options?.pasteDebounceMs ?? 50;
913
+ // Register spinner hooks so log output clears the spinner before printing
914
+ (0, nerves_1.registerSpinnerHooks)(pauseActiveSpinner, resumeActiveSpinner);
668
915
  // Fallback: apply pending updates for daemon-less direct CLI usage
669
916
  (0, update_hooks_1.registerUpdateHook)(bundle_meta_1.bundleMetaHook);
917
+ (0, update_hooks_1.registerUpdateHook)(agent_config_v2_1.agentConfigV2Hook);
670
918
  await (0, update_hooks_1.applyPendingUpdates)((0, identity_1.getAgentBundlesRoot)(), (0, bundle_manifest_1.getPackageVersion)());
671
919
  // Fail fast if provider is misconfigured (triggers human-readable error + exit)
672
- (0, core_1.getProvider)();
920
+ (0, core_1.getProvider)("human");
673
921
  // Resolve context kernel (identity + channel) for CLI
674
922
  const friendsPath = path.join((0, identity_1.getAgentRoot)(), "friends");
675
923
  const friendStore = new store_file_1.FileFriendStore(friendsPath);
676
924
  const username = os.userInfo().username;
677
- const hostname = os.hostname();
678
- const localExternalId = `${username}@${hostname}`;
925
+ const localExternalId = username;
679
926
  const resolver = new resolver_1.FriendResolver(friendStore, {
680
927
  provider: "local",
681
928
  externalId: localExternalId,
@@ -683,13 +930,6 @@ async function main(agentName, options) {
683
930
  channel: "cli",
684
931
  });
685
932
  const resolvedContext = await resolver.resolve();
686
- const cliToolContext = {
687
- /* v8 ignore next -- CLI has no OAuth sign-in; this no-op satisfies the interface @preserve */
688
- signin: async () => undefined,
689
- context: resolvedContext,
690
- friendStore,
691
- summarize: (0, core_1.createSummarize)(),
692
- };
693
933
  const friendId = resolvedContext.friend.id;
694
934
  const agentConfig = (0, identity_1.loadAgentConfig)();
695
935
  (0, cli_logging_1.configureCliRuntimeLogger)(friendId, {
@@ -712,52 +952,109 @@ async function main(agentName, options) {
712
952
  }
713
953
  // Load existing session or start fresh
714
954
  const existing = (0, context_1.loadSession)(sessPath);
955
+ let sessionState = existing?.state;
956
+ const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
715
957
  const sessionMessages = existing?.messages && existing.messages.length > 0
716
958
  ? existing.messages
717
- : [{ role: "system", content: await (0, prompt_1.buildSystem)("cli", undefined, resolvedContext) }];
718
- // Pending queue drain: inject pending messages as harness-context + assistant-content pairs
719
- const pendingDir = (0, pending_1.getPendingDir)((0, identity_1.getAgentName)(), friendId, "cli", "session");
720
- const drainToMessages = () => {
721
- const pending = (0, pending_1.drainPending)(pendingDir);
722
- if (pending.length === 0)
723
- return 0;
724
- for (const msg of pending) {
725
- sessionMessages.push({ role: "user", name: "harness", content: `[proactive message from ${msg.from}]` });
726
- sessionMessages.push({ role: "assistant", content: msg.content });
727
- }
728
- return pending.length;
729
- };
730
- // Startup drain: deliver offline messages
731
- const startupCount = drainToMessages();
732
- if (startupCount > 0) {
733
- (0, context_1.saveSession)(sessPath, sessionMessages);
734
- }
959
+ : [{ role: "system", content: await (0, prompt_1.buildSystem)("cli", {}, resolvedContext) }];
960
+ // Per-turn pipeline input: CLI capabilities and pending dir
961
+ const cliCapabilities = (0, channel_1.getChannelCapabilities)("cli");
962
+ const currentAgentName = (0, identity_1.getAgentName)();
963
+ const pendingDir = (0, pending_1.getPendingDir)(currentAgentName, friendId, "cli", "session");
964
+ const summarize = (0, core_1.createSummarize)("human");
965
+ const cliFailoverState = { pending: null };
735
966
  try {
736
967
  await runCliSession({
737
- agentName: (0, identity_1.getAgentName)(),
968
+ agentName: currentAgentName,
738
969
  pasteDebounceMs,
739
970
  messages: sessionMessages,
740
- toolContext: cliToolContext,
741
- onInput: () => {
742
- const trustGate = (0, trust_gate_1.enforceTrustGate)({
743
- friend: resolvedContext.friend,
971
+ lastActivityAt: sessionState?.lastFriendActivityAt,
972
+ _testInputSource: options?._testInputSource,
973
+ onAsyncAssistantMessage: async (messages, _assistantMessage) => {
974
+ (0, context_1.postTurn)(messages, sessPath, undefined, undefined, sessionState);
975
+ },
976
+ runTurn: async (messages, userInput, callbacks, signal, toolContext) => {
977
+ // Run the full per-turn pipeline: resolve -> gate -> session -> drain -> runAgent -> postTurn -> tokens
978
+ // User message passed via input.messages so the pipeline can prepend pending messages to it.
979
+ const failoverState = cliFailoverState;
980
+ // Capture terminal errors instead of displaying immediately — the failover
981
+ // message replaces the raw error if failover triggers successfully.
982
+ let capturedTerminalError = null;
983
+ /* v8 ignore start -- failover-aware callback wrapper: tested via pipeline integration @preserve */
984
+ const failoverAwareCallbacks = {
985
+ ...callbacks,
986
+ onError: (error, severity) => {
987
+ if (severity === "terminal" && failoverState) {
988
+ capturedTerminalError = error;
989
+ callbacks.onError(new Error(""), "transient");
990
+ return;
991
+ }
992
+ callbacks.onError(error, severity);
993
+ },
994
+ };
995
+ /* v8 ignore stop */
996
+ const result = await (0, pipeline_1.handleInboundTurn)({
997
+ channel: "cli",
998
+ sessionKey: "session",
999
+ capabilities: cliCapabilities,
1000
+ messages: [{ role: "user", content: userInput }],
1001
+ continuityIngressTexts: getCliContinuityIngressTexts(userInput),
1002
+ callbacks: failoverAwareCallbacks,
1003
+ friendResolver: { resolve: () => Promise.resolve(resolvedContext) },
1004
+ sessionLoader: {
1005
+ loadOrCreate: () => Promise.resolve({
1006
+ messages,
1007
+ sessionPath: sessPath,
1008
+ state: sessionState,
1009
+ }),
1010
+ },
1011
+ pendingDir,
1012
+ friendStore,
744
1013
  provider: "local",
745
1014
  externalId: localExternalId,
746
- channel: "cli",
1015
+ enforceTrustGate: trust_gate_1.enforceTrustGate,
1016
+ drainPending: pending_1.drainPending,
1017
+ drainDeferredReturns: (deferredFriendId) => (0, pending_1.drainDeferredReturns)(currentAgentName, deferredFriendId),
1018
+ runAgent: (msgs, cb, channel, sig, opts) => (0, core_1.runAgent)(msgs, cb, channel, sig, {
1019
+ ...opts,
1020
+ toolContext: {
1021
+ /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
1022
+ signin: async () => undefined,
1023
+ ...opts?.toolContext,
1024
+ summarize,
1025
+ },
1026
+ }),
1027
+ postTurn: (turnMessages, sessionPathArg, usage, hooks, state) => {
1028
+ (0, context_1.postTurn)(turnMessages, sessionPathArg, usage, hooks, state);
1029
+ sessionState = state;
1030
+ },
1031
+ accumulateFriendTokens: tokens_1.accumulateFriendTokens,
1032
+ signal,
1033
+ runAgentOptions: {
1034
+ toolChoiceRequired: (0, commands_1.getToolChoiceRequired)(),
1035
+ traceId: (0, nerves_1.createTraceId)(),
1036
+ mcpManager,
1037
+ toolContext,
1038
+ },
1039
+ failoverState,
747
1040
  });
748
- if (!trustGate.allowed) {
749
- return {
750
- allowed: false,
751
- reply: trustGate.reason === "stranger_first_reply" ? trustGate.autoReply : undefined,
752
- };
1041
+ /* v8 ignore start -- failover display: tested via pipeline integration tests @preserve */
1042
+ if (result.failoverMessage) {
1043
+ // Failover handled it — show the actionable message instead of the raw error
1044
+ process.stdout.write(`\x1b[33m${result.failoverMessage}\x1b[0m\n`);
753
1045
  }
754
- return { allowed: true };
755
- },
756
- onTurnEnd: async (msgs, result) => {
757
- (0, context_1.postTurn)(msgs, sessPath, result.usage);
758
- await (0, tokens_1.accumulateFriendTokens)(friendStore, resolvedContext.friend.id, result.usage);
759
- drainToMessages();
760
- await (0, prompt_refresh_1.refreshSystemPrompt)(msgs, "cli", undefined, resolvedContext);
1046
+ else if (capturedTerminalError) {
1047
+ // Failover didn't trigger (no failoverState, or sequence failed) — show the raw error
1048
+ process.stderr.write(`\x1b[31m${(0, format_1.formatError)(capturedTerminalError)}\x1b[0m\n`);
1049
+ }
1050
+ /* v8 ignore stop */
1051
+ // Handle gate rejection: display auto-reply if present
1052
+ if (!result.gateResult.allowed) {
1053
+ if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
1054
+ process.stdout.write(`${result.gateResult.autoReply}\n`);
1055
+ }
1056
+ }
1057
+ return { usage: result.usage, turnOutcome: result.turnOutcome, commandAction: result.commandAction };
761
1058
  },
762
1059
  onNewSession: () => {
763
1060
  (0, context_1.deleteSession)(sessPath);
@@ -767,4 +1064,11 @@ async function main(agentName, options) {
767
1064
  finally {
768
1065
  sessionLock?.release();
769
1066
  }
1067
+ // Force exit: lingering handles (Ink cleanup timers, MCP connections) keep the
1068
+ // event loop alive after the interactive session ends. This is safe because all
1069
+ // session persistence has already completed in the finally block above.
1070
+ /* v8 ignore next -- process.exit not callable in vitest @preserve */
1071
+ if (!options?._testInputSource)
1072
+ process.exit(0);
770
1073
  }
1074
+ // CI trigger