@ouro.bot/cli 0.1.0-alpha.2 → 0.1.0-alpha.200

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 (215) hide show
  1. package/README.md +187 -191
  2. package/SerpentGuide.ouro/agent.json +82 -0
  3. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/SOUL.md +6 -3
  4. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/monty.md +2 -2
  5. package/assets/ouroboros.png +0 -0
  6. package/changelog.json +1248 -0
  7. package/dist/heart/active-work.js +832 -0
  8. package/dist/heart/bridges/manager.js +358 -0
  9. package/dist/heart/bridges/state-machine.js +135 -0
  10. package/dist/heart/bridges/store.js +123 -0
  11. package/dist/heart/cares.js +179 -0
  12. package/dist/heart/commitments.js +111 -0
  13. package/dist/heart/config.js +114 -28
  14. package/dist/heart/core.js +689 -120
  15. package/dist/heart/cross-chat-delivery.js +146 -0
  16. package/dist/heart/daemon/agent-config-check.js +149 -0
  17. package/dist/heart/daemon/agent-discovery.js +81 -0
  18. package/dist/heart/daemon/agent-service.js +360 -0
  19. package/dist/heart/daemon/auth-flow.js +477 -0
  20. package/dist/heart/daemon/cadence.js +70 -0
  21. package/dist/heart/daemon/daemon-cli.js +2897 -193
  22. package/dist/heart/daemon/daemon-entry.js +194 -8
  23. package/dist/heart/daemon/daemon-health.js +136 -0
  24. package/dist/heart/daemon/daemon-runtime-sync.js +231 -0
  25. package/dist/heart/daemon/daemon-tombstone.js +123 -0
  26. package/dist/heart/daemon/daemon.js +488 -13
  27. package/dist/heart/daemon/habit-migration.js +181 -0
  28. package/dist/heart/daemon/habit-parser.js +119 -0
  29. package/dist/heart/daemon/habit-scheduler.js +369 -0
  30. package/dist/heart/daemon/hatch-animation.js +35 -0
  31. package/dist/heart/daemon/hatch-flow.js +32 -122
  32. package/dist/heart/daemon/hatch-specialist.js +7 -2
  33. package/dist/heart/daemon/hooks/agent-config-v2.js +33 -0
  34. package/dist/heart/daemon/hooks/bundle-meta.js +206 -0
  35. package/dist/heart/daemon/inner-status.js +89 -0
  36. package/dist/heart/daemon/launchd.js +165 -0
  37. package/dist/heart/daemon/log-tailer.js +4 -3
  38. package/dist/heart/daemon/mcp-server.js +591 -0
  39. package/dist/heart/daemon/message-router.js +17 -8
  40. package/dist/heart/daemon/os-cron-deps.js +134 -0
  41. package/dist/heart/daemon/ouro-bot-global-installer.js +128 -0
  42. package/dist/heart/daemon/ouro-bot-wrapper.js +4 -3
  43. package/dist/heart/daemon/ouro-path-installer.js +221 -0
  44. package/dist/heart/daemon/ouro-uti.js +11 -2
  45. package/dist/heart/daemon/ouro-version-manager.js +171 -0
  46. package/dist/heart/daemon/outlook-http.js +439 -0
  47. package/dist/heart/daemon/outlook-read.js +1618 -0
  48. package/dist/heart/daemon/outlook-render.js +1032 -0
  49. package/dist/heart/daemon/outlook-types.js +7 -0
  50. package/dist/heart/daemon/outlook-view.js +194 -0
  51. package/dist/heart/daemon/process-manager.js +94 -1
  52. package/dist/heart/daemon/run-hooks.js +39 -0
  53. package/dist/heart/daemon/runtime-logging.js +61 -14
  54. package/dist/heart/daemon/runtime-metadata.js +219 -0
  55. package/dist/heart/daemon/runtime-mode.js +67 -0
  56. package/dist/heart/daemon/safe-mode.js +161 -0
  57. package/dist/heart/daemon/sense-manager.js +314 -0
  58. package/dist/heart/daemon/session-id-resolver.js +131 -0
  59. package/dist/heart/daemon/skill-management-installer.js +94 -0
  60. package/dist/heart/daemon/socket-client.js +202 -0
  61. package/dist/heart/daemon/specialist-orchestrator.js +129 -0
  62. package/dist/heart/daemon/specialist-prompt.js +101 -0
  63. package/dist/heart/daemon/specialist-tools.js +283 -0
  64. package/dist/heart/daemon/staged-restart.js +138 -0
  65. package/dist/heart/daemon/task-scheduler.js +3 -25
  66. package/dist/heart/daemon/thoughts.js +507 -0
  67. package/dist/heart/daemon/update-checker.js +111 -0
  68. package/dist/heart/daemon/update-hooks.js +138 -0
  69. package/dist/heart/daemon/wrapper-publish-guard.js +86 -0
  70. package/dist/heart/delegation.js +62 -0
  71. package/dist/heart/identity.js +220 -52
  72. package/dist/heart/intentions.js +173 -0
  73. package/dist/heart/kicks.js +2 -20
  74. package/dist/heart/migrate-config.js +127 -0
  75. package/dist/heart/model-capabilities.js +48 -0
  76. package/dist/heart/obligations.js +225 -0
  77. package/dist/heart/presence.js +185 -0
  78. package/dist/heart/progress-story.js +42 -0
  79. package/dist/heart/provider-failover.js +88 -0
  80. package/dist/heart/provider-ping.js +178 -0
  81. package/dist/heart/providers/anthropic-token.js +163 -0
  82. package/dist/heart/providers/anthropic.js +199 -36
  83. package/dist/heart/providers/azure.js +116 -10
  84. package/dist/heart/providers/github-copilot.js +157 -0
  85. package/dist/heart/providers/minimax.js +33 -3
  86. package/dist/heart/providers/openai-codex.js +51 -16
  87. package/dist/heart/sense-truth.js +61 -0
  88. package/dist/heart/session-activity.js +172 -0
  89. package/dist/heart/session-recall.js +216 -0
  90. package/dist/heart/start-of-turn-packet.js +232 -0
  91. package/dist/heart/streaming.js +120 -33
  92. package/dist/heart/sync.js +217 -0
  93. package/dist/heart/target-resolution.js +127 -0
  94. package/dist/heart/tempo.js +93 -0
  95. package/dist/heart/temporal-view.js +41 -0
  96. package/dist/heart/tool-activity-callbacks.js +36 -0
  97. package/dist/heart/tool-description.js +135 -0
  98. package/dist/heart/tool-loop.js +200 -0
  99. package/dist/heart/turn-coordinator.js +28 -0
  100. package/dist/mind/associative-recall.js +122 -32
  101. package/dist/mind/bundle-manifest.js +77 -1
  102. package/dist/mind/context.js +125 -14
  103. package/dist/mind/{memory.js → diary.js} +69 -56
  104. package/dist/mind/episodes.js +119 -0
  105. package/dist/mind/file-state.js +179 -0
  106. package/dist/mind/first-impressions.js +16 -2
  107. package/dist/mind/friends/channel.js +64 -0
  108. package/dist/mind/friends/group-context.js +144 -0
  109. package/dist/mind/friends/store-file.js +36 -0
  110. package/dist/mind/friends/trust-explanation.js +74 -0
  111. package/dist/mind/friends/types.js +9 -1
  112. package/dist/mind/journal-index.js +161 -0
  113. package/dist/mind/obligation-steering.js +221 -0
  114. package/dist/mind/obligations.js +138 -0
  115. package/dist/mind/pending.js +76 -9
  116. package/dist/mind/phrases.js +1 -0
  117. package/dist/mind/prompt.js +861 -105
  118. package/dist/mind/scrutiny.js +173 -0
  119. package/dist/mind/token-estimate.js +8 -12
  120. package/dist/nerves/cli-logging.js +15 -2
  121. package/dist/nerves/coverage/audit.js +1 -1
  122. package/dist/nerves/coverage/run-artifacts.js +1 -1
  123. package/dist/nerves/event-buffer.js +111 -0
  124. package/dist/nerves/index.js +63 -3
  125. package/dist/nerves/observation.js +20 -0
  126. package/dist/nerves/runtime.js +5 -1
  127. package/dist/outlook-ui/assets/index-Ck8agNeO.js +61 -0
  128. package/dist/outlook-ui/assets/index-LwChZTgL.css +1 -0
  129. package/dist/outlook-ui/index.html +15 -0
  130. package/dist/repertoire/ado-client.js +4 -2
  131. package/dist/repertoire/ado-semantic.js +11 -5
  132. package/dist/repertoire/coding/codex-jsonl.js +64 -0
  133. package/dist/repertoire/coding/context-pack.js +330 -0
  134. package/dist/repertoire/coding/feedback.js +301 -0
  135. package/dist/repertoire/coding/index.js +4 -1
  136. package/dist/repertoire/coding/manager.js +210 -4
  137. package/dist/repertoire/coding/spawner.js +58 -12
  138. package/dist/repertoire/coding/tools.js +209 -7
  139. package/dist/repertoire/data/ado-endpoints.json +188 -0
  140. package/dist/repertoire/guardrails.js +291 -0
  141. package/dist/repertoire/mcp-client.js +254 -0
  142. package/dist/repertoire/mcp-manager.js +251 -0
  143. package/dist/repertoire/shell-sessions.js +133 -0
  144. package/dist/repertoire/skills.js +14 -23
  145. package/dist/repertoire/tasks/board.js +43 -5
  146. package/dist/repertoire/tasks/fix.js +182 -0
  147. package/dist/repertoire/tasks/index.js +28 -10
  148. package/dist/repertoire/tasks/lifecycle.js +2 -2
  149. package/dist/repertoire/tasks/parser.js +3 -2
  150. package/dist/repertoire/tasks/scanner.js +194 -37
  151. package/dist/repertoire/tasks/transitions.js +11 -28
  152. package/dist/repertoire/tools-base.js +1360 -271
  153. package/dist/repertoire/tools-bluebubbles.js +94 -0
  154. package/dist/repertoire/tools-github.js +1 -6
  155. package/dist/repertoire/tools-teams.js +65 -59
  156. package/dist/repertoire/tools.js +318 -91
  157. package/dist/scripts/claude-code-hook.js +41 -0
  158. package/dist/scripts/claude-code-stop-hook.js +47 -0
  159. package/dist/senses/attention-queue.js +97 -0
  160. package/dist/senses/bluebubbles-client.js +532 -0
  161. package/dist/senses/bluebubbles-entry.js +13 -0
  162. package/dist/senses/bluebubbles-inbound-log.js +109 -0
  163. package/dist/senses/bluebubbles-media.js +339 -0
  164. package/dist/senses/bluebubbles-model.js +261 -0
  165. package/dist/senses/bluebubbles-mutation-log.js +116 -0
  166. package/dist/senses/bluebubbles-runtime-state.js +109 -0
  167. package/dist/senses/bluebubbles-session-cleanup.js +72 -0
  168. package/dist/senses/bluebubbles.js +1327 -0
  169. package/dist/senses/cli/adapter.js +274 -0
  170. package/dist/senses/cli/ink-app.js +156 -0
  171. package/dist/senses/cli/inline-diff.js +64 -0
  172. package/dist/senses/cli/message-list.js +51 -0
  173. package/dist/senses/cli/ouro-tui.js +349 -0
  174. package/dist/senses/cli/spinner-imperative.js +135 -0
  175. package/dist/senses/cli/spinner.js +101 -0
  176. package/dist/senses/cli/status-line.js +60 -0
  177. package/dist/senses/cli/streaming-markdown.js +526 -0
  178. package/dist/senses/cli/tool-display.js +83 -0
  179. package/dist/senses/cli/tool-render.js +85 -0
  180. package/dist/senses/cli/tui-store.js +198 -0
  181. package/dist/senses/cli/virtual-list.js +35 -0
  182. package/dist/senses/cli-entry.js +1 -1
  183. package/dist/senses/cli-layout.js +187 -0
  184. package/dist/senses/cli.js +687 -262
  185. package/dist/senses/commands.js +65 -1
  186. package/dist/senses/continuity.js +94 -0
  187. package/dist/senses/habit-turn-message.js +108 -0
  188. package/dist/senses/inner-dialog-worker.js +112 -19
  189. package/dist/senses/inner-dialog.js +592 -84
  190. package/dist/senses/pipeline.js +693 -0
  191. package/dist/senses/shared-turn.js +196 -0
  192. package/dist/senses/surface-tool.js +95 -0
  193. package/dist/senses/teams.js +817 -151
  194. package/dist/senses/trust-gate.js +112 -2
  195. package/package.json +24 -7
  196. package/skills/configure-dev-tools.md +81 -0
  197. package/subagents/README.md +4 -70
  198. package/AdoptionSpecialist.ouro/agent.json +0 -20
  199. package/dist/heart/daemon/subagent-installer.js +0 -125
  200. package/dist/inner-worker-entry.js +0 -4
  201. package/subagents/work-doer.md +0 -233
  202. package/subagents/work-merger.md +0 -624
  203. package/subagents/work-planner.md +0 -373
  204. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/basilisk.md +0 -0
  205. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jafar.md +0 -0
  206. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jormungandr.md +0 -0
  207. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/kaa.md +0 -0
  208. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/medusa.md +0 -0
  209. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/nagini.md +0 -0
  210. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/ouroboros.md +0 -0
  211. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/python.md +0 -0
  212. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/quetzalcoatl.md +0 -0
  213. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/sir-hiss.md +0 -0
  214. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-serpent.md +0 -0
  215. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-snake.md +0 -0
@@ -33,11 +33,19 @@ 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;
40
46
  exports.createCliCallbacks = createCliCallbacks;
47
+ exports.createDebouncedLines = createDebouncedLines;
48
+ exports.runCliSession = runCliSession;
41
49
  exports.main = main;
42
50
  const readline = __importStar(require("readline"));
43
51
  const os = __importStar(require("os"));
@@ -49,9 +57,9 @@ const format_1 = require("../mind/format");
49
57
  const config_1 = require("../heart/config");
50
58
  const context_1 = require("../mind/context");
51
59
  const pending_1 = require("../mind/pending");
52
- const prompt_refresh_1 = require("../mind/prompt-refresh");
53
60
  const commands_1 = require("./commands");
54
61
  const identity_1 = require("../heart/identity");
62
+ const mcp_manager_1 = require("../repertoire/mcp-manager");
55
63
  const nerves_1 = require("../nerves");
56
64
  const store_file_1 = require("../mind/friends/store-file");
57
65
  const resolver_1 = require("../mind/friends/resolver");
@@ -59,59 +67,63 @@ const tokens_1 = require("../mind/friends/tokens");
59
67
  const cli_logging_1 = require("../nerves/cli-logging");
60
68
  const runtime_1 = require("../nerves/runtime");
61
69
  const trust_gate_1 = require("./trust-gate");
70
+ const pipeline_1 = require("./pipeline");
71
+ const channel_1 = require("../mind/friends/channel");
62
72
  const session_lock_1 = require("./session-lock");
63
- // spinner that only touches stderr, cleans up after itself
64
- // exported for direct testability (stop-without-start branch)
65
- class Spinner {
66
- frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
67
- i = 0;
68
- iv = null;
69
- piv = null;
70
- msg = "";
71
- phrases = null;
72
- lastPhrase = "";
73
- constructor(m = "working", phrases) {
74
- this.msg = m;
75
- if (phrases && phrases.length > 0)
76
- this.phrases = phrases;
77
- }
78
- start() {
79
- process.stderr.write("\r\x1b[K");
80
- this.spin();
81
- this.iv = setInterval(() => this.spin(), 80);
82
- if (this.phrases) {
83
- this.piv = setInterval(() => this.rotatePhrase(), 1500);
84
- }
85
- }
86
- spin() {
87
- process.stderr.write(`\r${this.frames[this.i]} ${this.msg}... `);
88
- this.i = (this.i + 1) % this.frames.length;
89
- }
90
- rotatePhrase() {
91
- const next = (0, phrases_1.pickPhrase)(this.phrases, this.lastPhrase);
92
- this.lastPhrase = next;
93
- this.msg = next;
94
- }
95
- stop(ok) {
96
- if (this.iv) {
97
- clearInterval(this.iv);
98
- this.iv = null;
99
- }
100
- if (this.piv) {
101
- clearInterval(this.piv);
102
- this.piv = null;
103
- }
104
- process.stderr.write("\r\x1b[K");
105
- /* v8 ignore next -- ok parameter currently unused by callers @preserve */
106
- if (ok)
107
- process.stderr.write(`\x1b[32m\u2713\x1b[0m ${ok}\n`);
108
- }
109
- fail(msg) {
110
- this.stop();
111
- process.stderr.write(`\x1b[31m\u2717\x1b[0m ${msg}\n`);
73
+ const update_hooks_1 = require("../heart/daemon/update-hooks");
74
+ const bundle_meta_1 = require("../heart/daemon/hooks/bundle-meta");
75
+ const agent_config_v2_1 = require("../heart/daemon/hooks/agent-config-v2");
76
+ const bundle_manifest_1 = require("../mind/bundle-manifest");
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);
112
114
  }
113
115
  }
114
- 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; } });
115
127
  // Input controller: pauses readline during model/tool execution.
116
128
  // Does NOT touch raw mode — readline with terminal:true manages raw mode
117
129
  // internally. Touching it causes ^C to be echoed by the terminal driver.
@@ -279,41 +291,54 @@ function createCliCallbacks() {
279
291
  meta: {},
280
292
  });
281
293
  let currentSpinner = null;
282
- let hadReasoning = false;
294
+ function setSpinner(s) { currentSpinner = s; setActiveSpinner(s); }
283
295
  let hadToolRun = false;
284
296
  let textDirty = false; // true when text/reasoning was written without a trailing newline
285
297
  const streamer = new MarkdownStreamer();
298
+ const wrapper = new cli_layout_1.StreamingWordWrapper();
286
299
  return {
287
300
  onModelStart: () => {
288
301
  currentSpinner?.stop();
289
- currentSpinner = null;
290
- hadReasoning = false;
302
+ setSpinner(null);
291
303
  textDirty = false;
292
304
  streamer.reset();
305
+ wrapper.reset();
293
306
  const phrases = (0, phrases_1.getPhrases)();
294
307
  const pool = hadToolRun ? phrases.followup : phrases.thinking;
295
308
  const first = (0, phrases_1.pickPhrase)(pool);
296
- currentSpinner = new Spinner(first, pool);
309
+ setSpinner(new spinner_imperative_1.ImperativeSpinner(first, pool));
297
310
  currentSpinner.start();
298
311
  },
299
312
  onModelStreamStart: () => {
300
- currentSpinner?.stop();
301
- currentSpinner = null;
313
+ // No-op: content callbacks (onTextChunk, onReasoningChunk) handle
314
+ // stopping the spinner. onModelStreamStart fires too early and
315
+ // doesn't fire at all for settle tool streaming.
316
+ },
317
+ onClearText: () => {
318
+ streamer.reset();
319
+ wrapper.reset();
302
320
  },
303
321
  onTextChunk: (text) => {
304
- if (hadReasoning) {
305
- process.stdout.write("\n\n");
306
- hadReasoning = false;
322
+ // Stop spinner if still running — settle streaming and Anthropic
323
+ // tool-only responses bypass onModelStreamStart, so the spinner would
324
+ // otherwise keep running (and its \r writes overwrite response text).
325
+ if (currentSpinner) {
326
+ currentSpinner.stop();
327
+ setSpinner(null);
307
328
  }
308
329
  const rendered = streamer.push(text);
309
- if (rendered)
310
- process.stdout.write(rendered);
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);
335
+ }
336
+ /* v8 ignore stop */
311
337
  textDirty = text.length > 0 && !text.endsWith("\n");
312
338
  },
313
- onReasoningChunk: (text) => {
314
- hadReasoning = true;
315
- process.stdout.write(`\x1b[2m${text}\x1b[0m`);
316
- textDirty = text.length > 0 && !text.endsWith("\n");
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.
317
342
  },
318
343
  onToolStart: (_name, _args) => {
319
344
  // Stop the model-start spinner: when the model returns only tool calls
@@ -328,31 +353,29 @@ function createCliCallbacks() {
328
353
  }
329
354
  const toolPhrases = (0, phrases_1.getPhrases)().tool;
330
355
  const first = (0, phrases_1.pickPhrase)(toolPhrases);
331
- currentSpinner = new Spinner(first, toolPhrases);
356
+ setSpinner(new spinner_imperative_1.ImperativeSpinner(first, toolPhrases));
332
357
  currentSpinner.start();
333
358
  hadToolRun = true;
334
359
  },
335
360
  onToolEnd: (name, argSummary, success) => {
336
361
  currentSpinner?.stop();
337
- currentSpinner = null;
338
- const msg = (0, format_1.formatToolResult)(name, argSummary, success);
339
- const color = success ? "\x1b[32m" : "\x1b[31m";
340
- process.stderr.write(`${color}${msg}\x1b[0m\n`);
362
+ setSpinner(null);
363
+ (0, tool_display_1.writeToolEnd)(name, argSummary, success);
341
364
  },
342
365
  onError: (error, severity) => {
343
366
  if (severity === "transient") {
344
367
  currentSpinner?.fail(error.message);
345
- currentSpinner = null;
368
+ setSpinner(null);
346
369
  }
347
370
  else {
348
371
  currentSpinner?.stop();
349
- currentSpinner = null;
372
+ setSpinner(null);
350
373
  process.stderr.write(`\x1b[31m${(0, format_1.formatError)(error)}\x1b[0m\n`);
351
374
  }
352
375
  },
353
376
  onKick: () => {
354
377
  currentSpinner?.stop();
355
- currentSpinner = null;
378
+ setSpinner(null);
356
379
  if (textDirty) {
357
380
  process.stdout.write("\n");
358
381
  textDirty = false;
@@ -361,21 +384,501 @@ function createCliCallbacks() {
361
384
  },
362
385
  flushMarkdown: () => {
363
386
  currentSpinner?.stop();
364
- currentSpinner = null;
387
+ setSpinner(null);
388
+ /* v8 ignore start -- wrapper flush: tested via cli.test.ts flushMarkdown tests @preserve */
365
389
  const remaining = streamer.flush();
366
- if (remaining)
367
- 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 */
399
+ },
400
+ };
401
+ }
402
+ // Debounced line iterator: collects rapid-fire lines (paste) into a single input.
403
+ // When the debounce timeout wins the race, the pending iter.next() is saved
404
+ // and reused in the next iteration to prevent it from silently consuming input.
405
+ async function* createDebouncedLines(source, debounceMs) {
406
+ if (debounceMs <= 0) {
407
+ yield* source;
408
+ return;
409
+ }
410
+ const iter = source[Symbol.asyncIterator]();
411
+ let pending = null;
412
+ while (true) {
413
+ const first = pending ? await pending : await iter.next();
414
+ pending = null;
415
+ if (first.done)
416
+ break;
417
+ const lines = [first.value];
418
+ let more = true;
419
+ while (more) {
420
+ const nextPromise = iter.next();
421
+ const raced = await Promise.race([
422
+ nextPromise.then((r) => ({ kind: "line", result: r })),
423
+ new Promise((r) => setTimeout(() => r({ kind: "timeout" }), debounceMs)),
424
+ ]);
425
+ if (raced.kind === "timeout") {
426
+ pending = nextPromise;
427
+ more = false;
428
+ }
429
+ else if (raced.result.done) {
430
+ more = false;
431
+ }
432
+ else {
433
+ lines.push(raced.result.value);
434
+ }
435
+ }
436
+ yield lines.join("\n");
437
+ }
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;
484
+ async function runCliSession(options) {
485
+ /* v8 ignore start -- integration: runCliSession is interactive, tested via E2E @preserve */
486
+ const registry = (0, commands_1.createCommandRegistry)();
487
+ if (!options.disableCommands) {
488
+ (0, commands_1.registerDefaultCommands)(registry);
489
+ }
490
+ const messages = options.messages
491
+ ?? [{ role: "system", content: await (0, prompt_1.buildSystem)("cli") }];
492
+ // ─── Rendering: TUI (Ink + Static) for TTY, imperative for tests/pipes ───
493
+ const useTui = !options._testInputSource && process.stdin.isTTY === true;
494
+ let currentAbort = null;
495
+ let closed = false;
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
+ }
589
+ }
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();
368
628
  },
369
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
+ };
646
+ // exitOnToolCall machinery: wrap execTool to detect target tool
647
+ let exitToolResult;
648
+ let exitToolFired = false;
649
+ const resolvedExecTool = options.execTool;
650
+ const wrappedExecTool = options.exitOnToolCall && resolvedExecTool
651
+ ? async (name, args, ctx) => {
652
+ const result = await resolvedExecTool(name, args, ctx);
653
+ if (name === options.exitOnToolCall) {
654
+ exitToolResult = result;
655
+ exitToolFired = true;
656
+ currentAbort?.abort();
657
+ }
658
+ return result;
659
+ }
660
+ : resolvedExecTool;
661
+ // Resolve toolChoiceRequired: use explicit option if set, else fall back to toggle
662
+ const getEffectiveToolChoiceRequired = () => options.toolChoiceRequired !== undefined ? options.toolChoiceRequired : (0, commands_1.getToolChoiceRequired)();
663
+ (0, runtime_1.emitNervesEvent)({
664
+ component: "senses",
665
+ event: "senses.cli_session_start",
666
+ message: "runCliSession started",
667
+ meta: { agentName: options.agentName, hasExitOnToolCall: !!options.exitOnToolCall },
668
+ });
669
+ let exitReason = "user_quit";
670
+ // Auto-first-turn: process the last user message immediately so the agent
671
+ // speaks first (e.g. specialist greeting). Only triggers when explicitly opted in.
672
+ if (options.autoFirstTurn && messages.length > 0 && messages[messages.length - 1]?.role === "user") {
673
+ currentAbort = new AbortController();
674
+ const traceId = (0, nerves_1.createTraceId)();
675
+ display.suppressInput();
676
+ let result;
677
+ try {
678
+ result = await (0, core_1.runAgent)(messages, cliCallbacks, options.skipSystemPromptRefresh ? undefined : "cli", currentAbort.signal, {
679
+ toolChoiceRequired: getEffectiveToolChoiceRequired(),
680
+ traceId,
681
+ tools: options.tools,
682
+ execTool: wrappedExecTool,
683
+ toolContext: effectiveToolContext,
684
+ });
685
+ }
686
+ catch (err) {
687
+ if (!(err instanceof DOMException && err.name === "AbortError")) {
688
+ display.error(err instanceof Error ? err.message : String(err));
689
+ }
690
+ }
691
+ cliCallbacks.flushMarkdown();
692
+ display.restoreInput();
693
+ currentAbort = null;
694
+ if (exitToolFired) {
695
+ exitReason = "tool_exit";
696
+ closed = true;
697
+ }
698
+ else {
699
+ const lastMsg = messages[messages.length - 1];
700
+ if (lastMsg?.role === "assistant" && !(typeof lastMsg.content === "string" ? lastMsg.content : "").trim()) {
701
+ display.warn("(empty response)");
702
+ }
703
+ if (options.onTurnEnd) {
704
+ await options.onTurnEnd(messages, result ?? { usage: undefined });
705
+ }
706
+ }
707
+ }
708
+ try {
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) {
728
+ if (closed)
729
+ break;
730
+ if (!input.trim())
731
+ continue;
732
+ // Optional input gate (e.g. trust gate in main)
733
+ if (options.onInput) {
734
+ const gate = options.onInput(input);
735
+ if (!gate.allowed) {
736
+ if (gate.reply) {
737
+ display.text(gate.reply);
738
+ }
739
+ if (closed)
740
+ break;
741
+ continue;
742
+ }
743
+ }
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
+ }
765
+ }
766
+ }
767
+ }
768
+ // Track user message in TUI for display
769
+ if (tuiStore)
770
+ tuiStore.addUserMessage(input);
771
+ currentAbort = new AbortController();
772
+ if (tuiStore)
773
+ tuiStore.suppressInput();
774
+ inputCtrl?.suppress(() => { currentAbort?.abort(); });
775
+ let result;
776
+ try {
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
+ }
812
+ }
813
+ catch (err) {
814
+ if (!(err instanceof DOMException && err.name === "AbortError")) {
815
+ display.error(err instanceof Error ? err.message : String(err));
816
+ }
817
+ }
818
+ cliCallbacks.flushMarkdown();
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();
824
+ currentAbort = null;
825
+ // Check if exit tool was fired during this turn
826
+ if (exitToolFired) {
827
+ exitReason = "tool_exit";
828
+ break;
829
+ }
830
+ // Safety net: never silently swallow an empty response
831
+ const lastMsg = messages[messages.length - 1];
832
+ if (lastMsg?.role === "assistant" && !(typeof lastMsg.content === "string" ? lastMsg.content : "").trim()) {
833
+ display.warn("(empty response)");
834
+ }
835
+ // Post-turn hook (session persistence, pending drain, prompt refresh, etc.)
836
+ if (options.onTurnEnd) {
837
+ await options.onTurnEnd(messages, result ?? { usage: undefined });
838
+ }
839
+ if (closed)
840
+ break;
841
+ }
842
+ }
843
+ finally {
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
+ }
862
+ if (options.banner !== false) {
863
+ // eslint-disable-next-line no-console -- terminal UX: goodbye
864
+ console.log("bye");
865
+ }
866
+ }
867
+ /* v8 ignore stop */
868
+ return { exitReason, toolResult: exitToolResult };
370
869
  }
371
870
  async function main(agentName, options) {
372
871
  if (agentName)
373
872
  (0, identity_1.setAgentName)(agentName);
374
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);
876
+ // Fallback: apply pending updates for daemon-less direct CLI usage
877
+ (0, update_hooks_1.registerUpdateHook)(bundle_meta_1.bundleMetaHook);
878
+ (0, update_hooks_1.registerUpdateHook)(agent_config_v2_1.agentConfigV2Hook);
879
+ await (0, update_hooks_1.applyPendingUpdates)((0, identity_1.getAgentBundlesRoot)(), (0, bundle_manifest_1.getPackageVersion)());
375
880
  // Fail fast if provider is misconfigured (triggers human-readable error + exit)
376
- (0, core_1.getProvider)();
377
- const registry = (0, commands_1.createCommandRegistry)();
378
- (0, commands_1.registerDefaultCommands)(registry);
881
+ (0, core_1.getProvider)("human");
379
882
  // Resolve context kernel (identity + channel) for CLI
380
883
  const friendsPath = path.join((0, identity_1.getAgentRoot)(), "friends");
381
884
  const friendStore = new store_file_1.FileFriendStore(friendsPath);
@@ -389,13 +892,6 @@ async function main(agentName, options) {
389
892
  channel: "cli",
390
893
  });
391
894
  const resolvedContext = await resolver.resolve();
392
- const cliToolContext = {
393
- /* v8 ignore next -- CLI has no OAuth sign-in; this no-op satisfies the interface @preserve */
394
- signin: async () => undefined,
395
- context: resolvedContext,
396
- friendStore,
397
- summarize: (0, core_1.createSummarize)(),
398
- };
399
895
  const friendId = resolvedContext.friend.id;
400
896
  const agentConfig = (0, identity_1.loadAgentConfig)();
401
897
  (0, cli_logging_1.configureCliRuntimeLogger)(friendId, {
@@ -418,187 +914,116 @@ async function main(agentName, options) {
418
914
  }
419
915
  // Load existing session or start fresh
420
916
  const existing = (0, context_1.loadSession)(sessPath);
421
- const messages = existing?.messages && existing.messages.length > 0
917
+ let sessionState = existing?.state;
918
+ const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
919
+ const sessionMessages = existing?.messages && existing.messages.length > 0
422
920
  ? existing.messages
423
- : [{ role: "system", content: await (0, prompt_1.buildSystem)("cli", undefined, resolvedContext) }];
424
- // Pending queue drain: inject pending messages as harness-context + assistant-content pairs
425
- const pendingDir = (0, pending_1.getPendingDir)((0, identity_1.getAgentName)(), friendId, "cli", "session");
426
- const drainToMessages = () => {
427
- const pending = (0, pending_1.drainPending)(pendingDir);
428
- if (pending.length === 0)
429
- return 0;
430
- for (const msg of pending) {
431
- messages.push({ role: "user", name: "harness", content: `[proactive message from ${msg.from}]` });
432
- messages.push({ role: "assistant", content: msg.content });
433
- }
434
- return pending.length;
435
- };
436
- // Startup drain: deliver offline messages
437
- const startupCount = drainToMessages();
438
- if (startupCount > 0) {
439
- (0, context_1.saveSession)(sessPath, messages);
440
- }
441
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
442
- const ctrl = new InputController(rl);
443
- let currentAbort = null;
444
- const history = [];
445
- let closed = false;
446
- rl.on("close", () => { closed = true; });
447
- // eslint-disable-next-line no-console -- terminal UX: startup banner
448
- console.log(`\n${(0, identity_1.getAgentName)()} (type /commands for help)\n`);
449
- const cliCallbacks = createCliCallbacks();
450
- process.stdout.write("\x1b[36m> \x1b[0m");
451
- // Ctrl-C at the input prompt: clear line or warn/exit
452
- // readline with terminal:true catches Ctrl-C in raw mode (no ^C echo)
453
- rl.on("SIGINT", () => {
454
- const rlInt = rl;
455
- const currentLine = rlInt.line || "";
456
- const result = handleSigint(rl, currentLine);
457
- if (result === "clear") {
458
- rlInt.line = "";
459
- rlInt.cursor = 0;
460
- process.stdout.write("\r\x1b[K\x1b[36m> \x1b[0m");
461
- }
462
- else if (result === "warn") {
463
- rlInt.line = "";
464
- rlInt.cursor = 0;
465
- process.stdout.write("\r\x1b[K");
466
- process.stderr.write("press Ctrl-C again to exit\n");
467
- process.stdout.write("\x1b[36m> \x1b[0m");
468
- }
469
- else {
470
- rl.close();
471
- }
472
- });
473
- // Debounced line iterator: collects rapid-fire lines (paste) into a single input
474
- async function* debouncedLines(source) {
475
- if (pasteDebounceMs <= 0) {
476
- yield* source;
477
- return;
478
- }
479
- const iter = source[Symbol.asyncIterator]();
480
- while (true) {
481
- const first = await iter.next();
482
- if (first.done)
483
- break;
484
- // Collect any lines that arrive within the debounce window (paste detection)
485
- const lines = [first.value];
486
- let more = true;
487
- while (more) {
488
- const raced = await Promise.race([
489
- iter.next().then((r) => ({ kind: "line", result: r })),
490
- new Promise((r) => setTimeout(() => r({ kind: "timeout" }), pasteDebounceMs)),
491
- ]);
492
- if (raced.kind === "timeout") {
493
- more = false;
494
- }
495
- else if (raced.result.done) {
496
- more = false;
497
- }
498
- else {
499
- lines.push(raced.result.value);
500
- }
501
- }
502
- yield lines.join("\n");
503
- }
504
- }
921
+ : [{ role: "system", content: await (0, prompt_1.buildSystem)("cli", { mcpManager }, resolvedContext) }];
922
+ // Per-turn pipeline input: CLI capabilities and pending dir
923
+ const cliCapabilities = (0, channel_1.getChannelCapabilities)("cli");
924
+ const currentAgentName = (0, identity_1.getAgentName)();
925
+ const pendingDir = (0, pending_1.getPendingDir)(currentAgentName, friendId, "cli", "session");
926
+ const summarize = (0, core_1.createSummarize)("human");
927
+ const cliFailoverState = { pending: null };
505
928
  try {
506
- for await (const input of debouncedLines(rl)) {
507
- if (closed)
508
- break;
509
- if (!input.trim()) {
510
- process.stdout.write("\x1b[36m> \x1b[0m");
511
- continue;
512
- }
513
- const trustGate = (0, trust_gate_1.enforceTrustGate)({
514
- friend: resolvedContext.friend,
515
- provider: "local",
516
- externalId: localExternalId,
517
- channel: "cli",
518
- });
519
- if (!trustGate.allowed) {
520
- if (trustGate.reason === "stranger_first_reply") {
521
- process.stdout.write(`${trustGate.autoReply}\n`);
929
+ await runCliSession({
930
+ agentName: currentAgentName,
931
+ pasteDebounceMs,
932
+ messages: sessionMessages,
933
+ _testInputSource: options?._testInputSource,
934
+ onAsyncAssistantMessage: async (messages, _assistantMessage) => {
935
+ (0, context_1.postTurn)(messages, sessPath, undefined, undefined, sessionState);
936
+ },
937
+ runTurn: async (messages, userInput, callbacks, signal, toolContext) => {
938
+ // Run the full per-turn pipeline: resolve -> gate -> session -> drain -> runAgent -> postTurn -> tokens
939
+ // User message passed via input.messages so the pipeline can prepend pending messages to it.
940
+ const failoverState = cliFailoverState;
941
+ // Capture terminal errors instead of displaying immediately — the failover
942
+ // message replaces the raw error if failover triggers successfully.
943
+ let capturedTerminalError = null;
944
+ /* v8 ignore start -- failover-aware callback wrapper: tested via pipeline integration @preserve */
945
+ const failoverAwareCallbacks = {
946
+ ...callbacks,
947
+ onError: (error, severity) => {
948
+ if (severity === "terminal" && failoverState) {
949
+ capturedTerminalError = error;
950
+ callbacks.onError(new Error(""), "transient");
951
+ return;
952
+ }
953
+ callbacks.onError(error, severity);
954
+ },
955
+ };
956
+ /* v8 ignore stop */
957
+ const result = await (0, pipeline_1.handleInboundTurn)({
958
+ channel: "cli",
959
+ sessionKey: "session",
960
+ capabilities: cliCapabilities,
961
+ messages: [{ role: "user", content: userInput }],
962
+ continuityIngressTexts: getCliContinuityIngressTexts(userInput),
963
+ callbacks: failoverAwareCallbacks,
964
+ friendResolver: { resolve: () => Promise.resolve(resolvedContext) },
965
+ sessionLoader: {
966
+ loadOrCreate: () => Promise.resolve({
967
+ messages,
968
+ sessionPath: sessPath,
969
+ state: sessionState,
970
+ }),
971
+ },
972
+ pendingDir,
973
+ friendStore,
974
+ provider: "local",
975
+ externalId: localExternalId,
976
+ enforceTrustGate: trust_gate_1.enforceTrustGate,
977
+ drainPending: pending_1.drainPending,
978
+ drainDeferredReturns: (deferredFriendId) => (0, pending_1.drainDeferredReturns)(currentAgentName, deferredFriendId),
979
+ runAgent: (msgs, cb, channel, sig, opts) => (0, core_1.runAgent)(msgs, cb, channel, sig, {
980
+ ...opts,
981
+ toolContext: {
982
+ /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
983
+ signin: async () => undefined,
984
+ ...opts?.toolContext,
985
+ summarize,
986
+ },
987
+ }),
988
+ postTurn: (turnMessages, sessionPathArg, usage, hooks, state) => {
989
+ (0, context_1.postTurn)(turnMessages, sessionPathArg, usage, hooks, state);
990
+ sessionState = state;
991
+ },
992
+ accumulateFriendTokens: tokens_1.accumulateFriendTokens,
993
+ signal,
994
+ runAgentOptions: {
995
+ toolChoiceRequired: (0, commands_1.getToolChoiceRequired)(),
996
+ traceId: (0, nerves_1.createTraceId)(),
997
+ mcpManager,
998
+ toolContext,
999
+ },
1000
+ failoverState,
1001
+ });
1002
+ /* v8 ignore start -- failover display: tested via pipeline integration tests @preserve */
1003
+ if (result.failoverMessage) {
1004
+ // Failover handled it — show the actionable message instead of the raw error
1005
+ process.stdout.write(`\x1b[33m${result.failoverMessage}\x1b[0m\n`);
522
1006
  }
523
- if (closed)
524
- break;
525
- process.stdout.write("\x1b[36m> \x1b[0m");
526
- continue;
527
- }
528
- // Check for slash commands
529
- const parsed = (0, commands_1.parseSlashCommand)(input);
530
- if (parsed) {
531
- const dispatchResult = registry.dispatch(parsed.command, { channel: "cli" });
532
- if (dispatchResult.handled && dispatchResult.result) {
533
- if (dispatchResult.result.action === "exit") {
534
- break;
535
- }
536
- else if (dispatchResult.result.action === "new") {
537
- messages.length = 0;
538
- messages.push({ role: "system", content: await (0, prompt_1.buildSystem)("cli") });
539
- (0, context_1.deleteSession)(sessPath);
540
- // eslint-disable-next-line no-console -- terminal UX: session cleared
541
- console.log("session cleared");
542
- process.stdout.write("\x1b[36m> \x1b[0m");
543
- continue;
544
- }
545
- else if (dispatchResult.result.action === "response") {
546
- // eslint-disable-next-line no-console -- terminal UX: command dispatch result
547
- console.log(dispatchResult.result.message || "");
548
- process.stdout.write("\x1b[36m> \x1b[0m");
549
- continue;
1007
+ else if (capturedTerminalError) {
1008
+ // Failover didn't trigger (no failoverState, or sequence failed) — show the raw error
1009
+ process.stderr.write(`\x1b[31m${(0, format_1.formatError)(capturedTerminalError)}\x1b[0m\n`);
1010
+ }
1011
+ /* v8 ignore stop */
1012
+ // Handle gate rejection: display auto-reply if present
1013
+ if (!result.gateResult.allowed) {
1014
+ if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
1015
+ process.stdout.write(`${result.gateResult.autoReply}\n`);
550
1016
  }
551
1017
  }
552
- }
553
- // Re-style the echoed input lines (readline terminal:true echoes each line)
554
- // For multiline paste, each line was echoed separately — erase them all
555
- const cols = process.stdout.columns || 80;
556
- const inputLines = input.split("\n");
557
- let echoRows = 0;
558
- for (const line of inputLines) {
559
- echoRows += Math.ceil((2 + line.length) / cols); // "> " prefix + line content
560
- }
561
- process.stdout.write(`\x1b[${echoRows}A\x1b[K` + `\x1b[1m> ${inputLines[0]}${inputLines.length > 1 ? ` (+${inputLines.length - 1} lines)` : ""}\x1b[0m\n\n`);
562
- messages.push({ role: "user", content: input });
563
- addHistory(history, input);
564
- currentAbort = new AbortController();
565
- const traceId = (0, nerves_1.createTraceId)();
566
- ctrl.suppress(() => currentAbort.abort());
567
- let result;
568
- try {
569
- result = await (0, core_1.runAgent)(messages, cliCallbacks, "cli", currentAbort.signal, {
570
- toolChoiceRequired: (0, commands_1.getToolChoiceRequired)(),
571
- toolContext: cliToolContext,
572
- traceId,
573
- });
574
- }
575
- catch {
576
- // AbortError — silently return to prompt
577
- }
578
- cliCallbacks.flushMarkdown();
579
- ctrl.restore();
580
- currentAbort = null;
581
- // Safety net: never silently swallow an empty response
582
- const lastMsg = messages[messages.length - 1];
583
- if (lastMsg?.role === "assistant" && !(typeof lastMsg.content === "string" ? lastMsg.content : "").trim()) {
584
- process.stderr.write("\x1b[33m(empty response)\x1b[0m\n");
585
- }
586
- process.stdout.write("\n\n");
587
- (0, context_1.postTurn)(messages, sessPath, result?.usage);
588
- await (0, tokens_1.accumulateFriendTokens)(friendStore, resolvedContext.friend.id, result?.usage);
589
- // Post-turn: drain any pending messages that arrived during runAgent
590
- drainToMessages();
591
- // Post-turn: refresh system prompt so active sessions metadata is current
592
- await (0, prompt_refresh_1.refreshSystemPrompt)(messages, "cli", undefined, resolvedContext);
593
- if (closed)
594
- break;
595
- process.stdout.write("\x1b[36m> \x1b[0m");
596
- }
1018
+ return { usage: result.usage, turnOutcome: result.turnOutcome, commandAction: result.commandAction };
1019
+ },
1020
+ onNewSession: () => {
1021
+ (0, context_1.deleteSession)(sessPath);
1022
+ },
1023
+ });
597
1024
  }
598
1025
  finally {
599
1026
  sessionLock?.release();
600
- rl.close();
601
- // eslint-disable-next-line no-console -- terminal UX: goodbye
602
- console.log("bye");
603
1027
  }
604
1028
  }
1029
+ // CI trigger