@ouro.bot/cli 0.1.0-alpha.24 → 0.1.0-alpha.241

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