@ouro.bot/cli 0.1.0-alpha.18 → 0.1.0-alpha.181

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 (185) hide show
  1. package/AdoptionSpecialist.ouro/agent.json +3 -2
  2. package/AdoptionSpecialist.ouro/psyche/SOUL.md +3 -3
  3. package/AdoptionSpecialist.ouro/psyche/identities/monty.md +2 -2
  4. package/README.md +187 -191
  5. package/changelog.json +1219 -0
  6. package/dist/heart/active-work.js +633 -0
  7. package/dist/heart/bridges/manager.js +358 -0
  8. package/dist/heart/bridges/state-machine.js +135 -0
  9. package/dist/heart/bridges/store.js +123 -0
  10. package/dist/heart/commitments.js +105 -0
  11. package/dist/heart/config.js +40 -30
  12. package/dist/heart/core.js +620 -124
  13. package/dist/heart/cross-chat-delivery.js +146 -0
  14. package/dist/heart/daemon/agent-discovery.js +81 -0
  15. package/dist/heart/daemon/agent-service.js +360 -0
  16. package/dist/heart/daemon/auth-flow.js +477 -0
  17. package/dist/heart/daemon/cadence.js +70 -0
  18. package/dist/heart/daemon/daemon-cli.js +2476 -206
  19. package/dist/heart/daemon/daemon-entry.js +157 -4
  20. package/dist/heart/daemon/daemon-health.js +136 -0
  21. package/dist/heart/daemon/daemon-runtime-sync.js +212 -0
  22. package/dist/heart/daemon/daemon-tombstone.js +123 -0
  23. package/dist/heart/daemon/daemon.js +449 -17
  24. package/dist/heart/daemon/habit-migration.js +181 -0
  25. package/dist/heart/daemon/habit-parser.js +119 -0
  26. package/dist/heart/daemon/habit-scheduler.js +369 -0
  27. package/dist/heart/daemon/hatch-animation.js +10 -3
  28. package/dist/heart/daemon/hatch-flow.js +30 -111
  29. package/dist/heart/daemon/hooks/agent-config-v2.js +33 -0
  30. package/dist/heart/daemon/hooks/bundle-meta.js +92 -0
  31. package/dist/heart/daemon/inner-status.js +89 -0
  32. package/dist/heart/daemon/launchd.js +165 -0
  33. package/dist/heart/daemon/log-tailer.js +4 -3
  34. package/dist/heart/daemon/mcp-server.js +591 -0
  35. package/dist/heart/daemon/message-router.js +17 -8
  36. package/dist/heart/daemon/os-cron-deps.js +134 -0
  37. package/dist/heart/daemon/ouro-bot-global-installer.js +128 -0
  38. package/dist/heart/daemon/ouro-path-installer.js +78 -34
  39. package/dist/heart/daemon/ouro-version-manager.js +171 -0
  40. package/dist/heart/daemon/outlook-http.js +399 -0
  41. package/dist/heart/daemon/outlook-read.js +1291 -0
  42. package/dist/heart/daemon/outlook-render.js +1032 -0
  43. package/dist/heart/daemon/outlook-types.js +7 -0
  44. package/dist/heart/daemon/outlook-view.js +194 -0
  45. package/dist/heart/daemon/process-manager.js +75 -0
  46. package/dist/heart/daemon/run-hooks.js +39 -0
  47. package/dist/heart/daemon/runtime-logging.js +58 -15
  48. package/dist/heart/daemon/runtime-metadata.js +219 -0
  49. package/dist/heart/daemon/runtime-mode.js +67 -0
  50. package/dist/heart/daemon/safe-mode.js +161 -0
  51. package/dist/heart/daemon/sense-manager.js +50 -2
  52. package/dist/heart/daemon/session-id-resolver.js +131 -0
  53. package/dist/heart/daemon/skill-management-installer.js +94 -0
  54. package/dist/heart/daemon/socket-client.js +202 -0
  55. package/dist/heart/daemon/specialist-orchestrator.js +2 -2
  56. package/dist/heart/daemon/specialist-prompt.js +13 -9
  57. package/dist/heart/daemon/specialist-tools.js +78 -9
  58. package/dist/heart/daemon/staged-restart.js +114 -0
  59. package/dist/heart/daemon/task-scheduler.js +3 -25
  60. package/dist/heart/daemon/thoughts.js +507 -0
  61. package/dist/heart/daemon/update-checker.js +111 -0
  62. package/dist/heart/daemon/update-hooks.js +138 -0
  63. package/dist/heart/daemon/wrapper-publish-guard.js +86 -0
  64. package/dist/heart/delegation.js +62 -0
  65. package/dist/heart/identity.js +136 -55
  66. package/dist/heart/kicks.js +2 -20
  67. package/dist/heart/migrate-config.js +127 -0
  68. package/dist/heart/model-capabilities.js +48 -0
  69. package/dist/heart/obligations.js +197 -0
  70. package/dist/heart/progress-story.js +42 -0
  71. package/dist/heart/provider-failover.js +88 -0
  72. package/dist/heart/provider-ping.js +172 -0
  73. package/dist/heart/providers/anthropic-token.js +163 -0
  74. package/dist/heart/providers/anthropic.js +196 -36
  75. package/dist/heart/providers/azure.js +116 -10
  76. package/dist/heart/providers/github-copilot.js +157 -0
  77. package/dist/heart/providers/minimax.js +33 -3
  78. package/dist/heart/providers/openai-codex.js +51 -16
  79. package/dist/heart/session-activity.js +172 -0
  80. package/dist/heart/session-recall.js +216 -0
  81. package/dist/heart/streaming.js +62 -32
  82. package/dist/heart/target-resolution.js +127 -0
  83. package/dist/heart/tool-activity-callbacks.js +36 -0
  84. package/dist/heart/tool-description.js +135 -0
  85. package/dist/heart/tool-loop.js +200 -0
  86. package/dist/heart/turn-coordinator.js +28 -0
  87. package/dist/mind/associative-recall.js +122 -32
  88. package/dist/mind/bundle-manifest.js +14 -0
  89. package/dist/mind/context.js +125 -14
  90. package/dist/mind/{memory.js → diary.js} +70 -56
  91. package/dist/mind/file-state.js +179 -0
  92. package/dist/mind/first-impressions.js +16 -2
  93. package/dist/mind/friends/channel.js +56 -0
  94. package/dist/mind/friends/group-context.js +144 -0
  95. package/dist/mind/friends/store-file.js +19 -0
  96. package/dist/mind/friends/trust-explanation.js +74 -0
  97. package/dist/mind/friends/types.js +8 -0
  98. package/dist/mind/journal-index.js +161 -0
  99. package/dist/mind/obligation-steering.js +221 -0
  100. package/dist/mind/obligations.js +134 -0
  101. package/dist/mind/pending.js +76 -9
  102. package/dist/mind/phrases.js +1 -0
  103. package/dist/mind/prompt.js +721 -102
  104. package/dist/mind/scrutiny.js +173 -0
  105. package/dist/mind/token-estimate.js +8 -12
  106. package/dist/nerves/cli-logging.js +15 -2
  107. package/dist/nerves/coverage/audit.js +1 -1
  108. package/dist/nerves/coverage/run-artifacts.js +1 -1
  109. package/dist/nerves/event-buffer.js +111 -0
  110. package/dist/nerves/index.js +63 -3
  111. package/dist/nerves/observation.js +20 -0
  112. package/dist/nerves/runtime.js +5 -1
  113. package/dist/outlook-ui/assets/index-DN6vVYPm.css +1 -0
  114. package/dist/outlook-ui/assets/index-M1l1tDTX.js +61 -0
  115. package/dist/outlook-ui/index.html +15 -0
  116. package/dist/repertoire/ado-semantic.js +11 -5
  117. package/dist/repertoire/coding/codex-jsonl.js +64 -0
  118. package/dist/repertoire/coding/context-pack.js +254 -0
  119. package/dist/repertoire/coding/feedback.js +197 -30
  120. package/dist/repertoire/coding/manager.js +149 -2
  121. package/dist/repertoire/coding/spawner.js +55 -9
  122. package/dist/repertoire/coding/tools.js +170 -7
  123. package/dist/repertoire/guardrails.js +291 -0
  124. package/dist/repertoire/mcp-client.js +254 -0
  125. package/dist/repertoire/mcp-manager.js +198 -0
  126. package/dist/repertoire/shell-sessions.js +133 -0
  127. package/dist/repertoire/skills.js +14 -23
  128. package/dist/repertoire/tasks/board.js +43 -5
  129. package/dist/repertoire/tasks/fix.js +182 -0
  130. package/dist/repertoire/tasks/index.js +28 -10
  131. package/dist/repertoire/tasks/lifecycle.js +2 -2
  132. package/dist/repertoire/tasks/parser.js +3 -2
  133. package/dist/repertoire/tasks/scanner.js +194 -37
  134. package/dist/repertoire/tasks/transitions.js +11 -28
  135. package/dist/repertoire/tools-base.js +1111 -327
  136. package/dist/repertoire/tools-bluebubbles.js +94 -0
  137. package/dist/repertoire/tools-github.js +1 -6
  138. package/dist/repertoire/tools-teams.js +12 -59
  139. package/dist/repertoire/tools.js +318 -124
  140. package/dist/scripts/claude-code-hook.js +41 -0
  141. package/dist/scripts/claude-code-stop-hook.js +47 -0
  142. package/dist/senses/attention-queue.js +97 -0
  143. package/dist/senses/bluebubbles-client.js +102 -3
  144. package/dist/senses/bluebubbles-entry.js +2 -0
  145. package/dist/senses/bluebubbles-inbound-log.js +109 -0
  146. package/dist/senses/bluebubbles-media.js +118 -23
  147. package/dist/senses/bluebubbles-model.js +12 -4
  148. package/dist/senses/bluebubbles-mutation-log.js +45 -5
  149. package/dist/senses/bluebubbles-runtime-state.js +109 -0
  150. package/dist/senses/bluebubbles-session-cleanup.js +72 -0
  151. package/dist/senses/bluebubbles.js +1008 -102
  152. package/dist/senses/cli/adapter.js +274 -0
  153. package/dist/senses/cli/ink-app.js +156 -0
  154. package/dist/senses/cli/inline-diff.js +64 -0
  155. package/dist/senses/cli/message-list.js +51 -0
  156. package/dist/senses/cli/ouro-tui.js +323 -0
  157. package/dist/senses/cli/spinner-imperative.js +135 -0
  158. package/dist/senses/cli/spinner.js +101 -0
  159. package/dist/senses/cli/status-line.js +60 -0
  160. package/dist/senses/cli/streaming-markdown.js +525 -0
  161. package/dist/senses/cli/tool-display.js +83 -0
  162. package/dist/senses/cli/tool-render.js +85 -0
  163. package/dist/senses/cli/tui-store.js +198 -0
  164. package/dist/senses/cli/virtual-list.js +35 -0
  165. package/dist/senses/cli-entry.js +1 -1
  166. package/dist/senses/cli-layout.js +187 -0
  167. package/dist/senses/cli.js +593 -240
  168. package/dist/senses/commands.js +38 -1
  169. package/dist/senses/continuity.js +94 -0
  170. package/dist/senses/habit-turn-message.js +108 -0
  171. package/dist/senses/inner-dialog-worker.js +112 -19
  172. package/dist/senses/inner-dialog.js +592 -84
  173. package/dist/senses/pipeline.js +545 -0
  174. package/dist/senses/shared-turn.js +196 -0
  175. package/dist/senses/surface-tool.js +95 -0
  176. package/dist/senses/teams.js +667 -120
  177. package/dist/senses/trust-gate.js +112 -2
  178. package/package.json +16 -5
  179. package/skills/configure-dev-tools.md +81 -0
  180. package/subagents/README.md +4 -70
  181. package/dist/heart/daemon/subagent-installer.js +0 -134
  182. package/dist/senses/debug-activity.js +0 -107
  183. package/subagents/work-doer.md +0 -235
  184. package/subagents/work-merger.md +0 -618
  185. package/subagents/work-planner.md +0 -382
@@ -33,11 +33,18 @@ 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;
41
48
  exports.runCliSession = runCliSession;
42
49
  exports.main = main;
43
50
  const readline = __importStar(require("readline"));
@@ -50,9 +57,9 @@ const format_1 = require("../mind/format");
50
57
  const config_1 = require("../heart/config");
51
58
  const context_1 = require("../mind/context");
52
59
  const pending_1 = require("../mind/pending");
53
- const prompt_refresh_1 = require("../mind/prompt-refresh");
54
60
  const commands_1 = require("./commands");
55
61
  const identity_1 = require("../heart/identity");
62
+ const mcp_manager_1 = require("../repertoire/mcp-manager");
56
63
  const nerves_1 = require("../nerves");
57
64
  const store_file_1 = require("../mind/friends/store-file");
58
65
  const resolver_1 = require("../mind/friends/resolver");
@@ -60,59 +67,63 @@ const tokens_1 = require("../mind/friends/tokens");
60
67
  const cli_logging_1 = require("../nerves/cli-logging");
61
68
  const runtime_1 = require("../nerves/runtime");
62
69
  const trust_gate_1 = require("./trust-gate");
70
+ const pipeline_1 = require("./pipeline");
71
+ const channel_1 = require("../mind/friends/channel");
63
72
  const session_lock_1 = require("./session-lock");
64
- // spinner that only touches stderr, cleans up after itself
65
- // exported for direct testability (stop-without-start branch)
66
- class Spinner {
67
- frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
68
- i = 0;
69
- iv = null;
70
- piv = null;
71
- msg = "";
72
- phrases = null;
73
- lastPhrase = "";
74
- constructor(m = "working", phrases) {
75
- this.msg = m;
76
- if (phrases && phrases.length > 0)
77
- this.phrases = phrases;
78
- }
79
- start() {
80
- process.stderr.write("\r\x1b[K");
81
- this.spin();
82
- this.iv = setInterval(() => this.spin(), 80);
83
- if (this.phrases) {
84
- this.piv = setInterval(() => this.rotatePhrase(), 1500);
85
- }
86
- }
87
- spin() {
88
- process.stderr.write(`\r${this.frames[this.i]} ${this.msg}... `);
89
- this.i = (this.i + 1) % this.frames.length;
90
- }
91
- rotatePhrase() {
92
- const next = (0, phrases_1.pickPhrase)(this.phrases, this.lastPhrase);
93
- this.lastPhrase = next;
94
- this.msg = next;
95
- }
96
- stop(ok) {
97
- if (this.iv) {
98
- clearInterval(this.iv);
99
- this.iv = null;
100
- }
101
- if (this.piv) {
102
- clearInterval(this.piv);
103
- this.piv = null;
104
- }
105
- process.stderr.write("\r\x1b[K");
106
- /* v8 ignore next -- ok parameter currently unused by callers @preserve */
107
- if (ok)
108
- process.stderr.write(`\x1b[32m\u2713\x1b[0m ${ok}\n`);
109
- }
110
- fail(msg) {
111
- this.stop();
112
- 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);
113
114
  }
114
115
  }
115
- 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; } });
116
127
  // Input controller: pauses readline during model/tool execution.
117
128
  // Does NOT touch raw mode — readline with terminal:true manages raw mode
118
129
  // internally. Touching it causes ^C to be echoed by the terminal driver.
@@ -280,41 +291,54 @@ function createCliCallbacks() {
280
291
  meta: {},
281
292
  });
282
293
  let currentSpinner = null;
283
- let hadReasoning = false;
294
+ function setSpinner(s) { currentSpinner = s; setActiveSpinner(s); }
284
295
  let hadToolRun = false;
285
296
  let textDirty = false; // true when text/reasoning was written without a trailing newline
286
297
  const streamer = new MarkdownStreamer();
298
+ const wrapper = new cli_layout_1.StreamingWordWrapper();
287
299
  return {
288
300
  onModelStart: () => {
289
301
  currentSpinner?.stop();
290
- currentSpinner = null;
291
- hadReasoning = false;
302
+ setSpinner(null);
292
303
  textDirty = false;
293
304
  streamer.reset();
305
+ wrapper.reset();
294
306
  const phrases = (0, phrases_1.getPhrases)();
295
307
  const pool = hadToolRun ? phrases.followup : phrases.thinking;
296
308
  const first = (0, phrases_1.pickPhrase)(pool);
297
- currentSpinner = new Spinner(first, pool);
309
+ setSpinner(new spinner_imperative_1.ImperativeSpinner(first, pool));
298
310
  currentSpinner.start();
299
311
  },
300
312
  onModelStreamStart: () => {
301
- currentSpinner?.stop();
302
- 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();
303
320
  },
304
321
  onTextChunk: (text) => {
305
- if (hadReasoning) {
306
- process.stdout.write("\n\n");
307
- 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);
308
328
  }
309
329
  const rendered = streamer.push(text);
310
- if (rendered)
311
- 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 */
312
337
  textDirty = text.length > 0 && !text.endsWith("\n");
313
338
  },
314
- onReasoningChunk: (text) => {
315
- hadReasoning = true;
316
- process.stdout.write(`\x1b[2m${text}\x1b[0m`);
317
- 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.
318
342
  },
319
343
  onToolStart: (_name, _args) => {
320
344
  // Stop the model-start spinner: when the model returns only tool calls
@@ -329,31 +353,29 @@ function createCliCallbacks() {
329
353
  }
330
354
  const toolPhrases = (0, phrases_1.getPhrases)().tool;
331
355
  const first = (0, phrases_1.pickPhrase)(toolPhrases);
332
- currentSpinner = new Spinner(first, toolPhrases);
356
+ setSpinner(new spinner_imperative_1.ImperativeSpinner(first, toolPhrases));
333
357
  currentSpinner.start();
334
358
  hadToolRun = true;
335
359
  },
336
360
  onToolEnd: (name, argSummary, success) => {
337
361
  currentSpinner?.stop();
338
- currentSpinner = null;
339
- const msg = (0, format_1.formatToolResult)(name, argSummary, success);
340
- const color = success ? "\x1b[32m" : "\x1b[31m";
341
- process.stderr.write(`${color}${msg}\x1b[0m\n`);
362
+ setSpinner(null);
363
+ (0, tool_display_1.writeToolEnd)(name, argSummary, success);
342
364
  },
343
365
  onError: (error, severity) => {
344
366
  if (severity === "transient") {
345
367
  currentSpinner?.fail(error.message);
346
- currentSpinner = null;
368
+ setSpinner(null);
347
369
  }
348
370
  else {
349
371
  currentSpinner?.stop();
350
- currentSpinner = null;
372
+ setSpinner(null);
351
373
  process.stderr.write(`\x1b[31m${(0, format_1.formatError)(error)}\x1b[0m\n`);
352
374
  }
353
375
  },
354
376
  onKick: () => {
355
377
  currentSpinner?.stop();
356
- currentSpinner = null;
378
+ setSpinner(null);
357
379
  if (textDirty) {
358
380
  process.stdout.write("\n");
359
381
  textDirty = false;
@@ -362,28 +384,259 @@ function createCliCallbacks() {
362
384
  },
363
385
  flushMarkdown: () => {
364
386
  currentSpinner?.stop();
365
- currentSpinner = null;
387
+ setSpinner(null);
388
+ /* v8 ignore start -- wrapper flush: tested via cli.test.ts flushMarkdown tests @preserve */
366
389
  const remaining = streamer.flush();
367
- if (remaining)
368
- 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 */
369
399
  },
370
400
  };
371
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;
372
484
  async function runCliSession(options) {
373
- const pasteDebounceMs = options.pasteDebounceMs ?? 50;
485
+ /* v8 ignore start -- integration: runCliSession is interactive, tested via E2E @preserve */
374
486
  const registry = (0, commands_1.createCommandRegistry)();
375
- (0, commands_1.registerDefaultCommands)(registry);
487
+ if (!options.disableCommands) {
488
+ (0, commands_1.registerDefaultCommands)(registry);
489
+ }
376
490
  const messages = options.messages
377
491
  ?? [{ role: "system", content: await (0, prompt_1.buildSystem)("cli") }];
378
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
379
- 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;
380
494
  let currentAbort = null;
381
- const history = [];
382
495
  let closed = false;
383
- rl.on("close", () => { closed = true; });
384
- // eslint-disable-next-line no-console -- terminal UX: startup banner
385
- console.log(`\n${options.agentName} (type /commands for help)\n`);
386
- const cliCallbacks = createCliCallbacks();
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
+ const handleCtrlC = (hasInput) => {
525
+ if (currentAbort) {
526
+ // During generation: abort the request
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
+ closed = true;
538
+ inputQueue.close();
539
+ return "exit";
540
+ }
541
+ ctrlCWarned = true;
542
+ return "warn";
543
+ };
544
+ // TUI root: subscribes to store, passes props to OuroTui
545
+ // Elapsed timer is local React state (no store.notify overhead)
546
+ const storeRef = tuiStore;
547
+ function TuiRoot() {
548
+ const [, forceUpdate] = React.useState(0);
549
+ const [elapsed, setElapsed] = React.useState(0);
550
+ React.useEffect(() => storeRef.subscribe(() => {
551
+ forceUpdate((n) => n + 1);
552
+ // Reset ctrlC warning on any state change (new turn, etc.)
553
+ }), []);
554
+ React.useEffect(() => {
555
+ const iv = setInterval(() => setElapsed(storeRef.getElapsed()), 1000);
556
+ return () => clearInterval(iv);
557
+ }, []);
558
+ return React.createElement(OuroTui, {
559
+ agentName: options.agentName,
560
+ model: (0, identity_1.loadAgentConfig)().humanFacing?.model ?? "",
561
+ completedMessages: storeRef.completedMessages,
562
+ inputHistory: storeRef.inputHistory,
563
+ live: storeRef.live,
564
+ elapsedSeconds: elapsed,
565
+ contextPercent: 0,
566
+ onSubmit: (text) => { ctrlCWarned = false; inputQueue.push(text); },
567
+ onCtrlC: handleCtrlC,
568
+ headerShown: storeRef.headerShown,
569
+ cwd: process.cwd().replace(process.env.HOME ?? "", "~"),
570
+ });
571
+ }
572
+ inkRef = ink.render(React.createElement(TuiRoot), { exitOnCtrlC: false, patchConsole: false });
573
+ }
574
+ catch (err) {
575
+ // Ink failed to load (CJS compat, missing deps, etc.) — fall through to imperative
576
+ (0, runtime_1.emitNervesEvent)({
577
+ component: "senses",
578
+ event: "senses.tui_fallback",
579
+ message: `TUI failed to load, falling back to imperative: ${err instanceof Error ? err.message : String(err)}`,
580
+ meta: {},
581
+ });
582
+ }
583
+ }
584
+ // Fallback to imperative callbacks if TUI didn't initialize
585
+ if (!tuiStore) {
586
+ cliCallbacks = createCliCallbacks();
587
+ if (options.banner !== false) {
588
+ const bannerText = typeof options.banner === "string"
589
+ ? options.banner
590
+ : `${options.agentName} (type /commands for help)`;
591
+ // eslint-disable-next-line no-console -- terminal UX: startup banner
592
+ console.log(`\n${bannerText}\n`);
593
+ }
594
+ }
595
+ // Display helpers: route to TUI store or imperative stderr/stdout
596
+ const display = {
597
+ error: (msg) => {
598
+ if (tuiStore)
599
+ tuiStore.setError(msg);
600
+ else
601
+ process.stderr.write(`\x1b[31m${msg}\x1b[0m\n`);
602
+ },
603
+ warn: (msg) => {
604
+ if (tuiStore)
605
+ tuiStore.setError(msg); // TUI shows warnings as errors (amber color handled in component)
606
+ else
607
+ process.stderr.write(`\x1b[33m${msg}\x1b[0m\n`);
608
+ },
609
+ text: (msg) => {
610
+ if (tuiStore)
611
+ tuiStore.appendText(msg);
612
+ else
613
+ process.stdout.write(`${msg}\n`);
614
+ },
615
+ suppressInput: () => {
616
+ if (tuiStore)
617
+ tuiStore.suppressInput();
618
+ },
619
+ restoreInput: () => {
620
+ if (tuiStore)
621
+ tuiStore.restoreInput();
622
+ },
623
+ };
624
+ const effectiveToolContext = {
625
+ signin: options.toolContext?.signin ?? (async () => undefined),
626
+ ...options.toolContext,
627
+ codingFeedback: {
628
+ send: async (message) => {
629
+ const assistantMessage = {
630
+ role: "assistant",
631
+ content: message,
632
+ };
633
+ messages.push(assistantMessage);
634
+ await options.onAsyncAssistantMessage?.(messages, assistantMessage);
635
+ display.text(message);
636
+ await options.toolContext?.codingFeedback?.send(message);
637
+ },
638
+ },
639
+ };
387
640
  // exitOnToolCall machinery: wrap execTool to detect target tool
388
641
  let exitToolResult;
389
642
  let exitToolFired = false;
@@ -394,65 +647,13 @@ async function runCliSession(options) {
394
647
  if (name === options.exitOnToolCall) {
395
648
  exitToolResult = result;
396
649
  exitToolFired = true;
650
+ currentAbort?.abort();
397
651
  }
398
652
  return result;
399
653
  }
400
654
  : resolvedExecTool;
401
655
  // Resolve toolChoiceRequired: use explicit option if set, else fall back to toggle
402
656
  const getEffectiveToolChoiceRequired = () => options.toolChoiceRequired !== undefined ? options.toolChoiceRequired : (0, commands_1.getToolChoiceRequired)();
403
- process.stdout.write("\x1b[36m> \x1b[0m");
404
- // Ctrl-C at the input prompt: clear line or warn/exit
405
- rl.on("SIGINT", () => {
406
- const rlInt = rl;
407
- const currentLine = rlInt.line || "";
408
- const result = handleSigint(rl, currentLine);
409
- if (result === "clear") {
410
- rlInt.line = "";
411
- rlInt.cursor = 0;
412
- process.stdout.write("\r\x1b[K\x1b[36m> \x1b[0m");
413
- }
414
- else if (result === "warn") {
415
- rlInt.line = "";
416
- rlInt.cursor = 0;
417
- process.stdout.write("\r\x1b[K");
418
- process.stderr.write("press Ctrl-C again to exit\n");
419
- process.stdout.write("\x1b[36m> \x1b[0m");
420
- }
421
- else {
422
- rl.close();
423
- }
424
- });
425
- // Debounced line iterator: collects rapid-fire lines (paste) into a single input
426
- async function* debouncedLines(source) {
427
- if (pasteDebounceMs <= 0) {
428
- yield* source;
429
- return;
430
- }
431
- const iter = source[Symbol.asyncIterator]();
432
- while (true) {
433
- const first = await iter.next();
434
- if (first.done)
435
- break;
436
- const lines = [first.value];
437
- let more = true;
438
- while (more) {
439
- const raced = await Promise.race([
440
- iter.next().then((r) => ({ kind: "line", result: r })),
441
- new Promise((r) => setTimeout(() => r({ kind: "timeout" }), pasteDebounceMs)),
442
- ]);
443
- if (raced.kind === "timeout") {
444
- more = false;
445
- }
446
- else if (raced.result.done) {
447
- more = false;
448
- }
449
- else {
450
- lines.push(raced.result.value);
451
- }
452
- }
453
- yield lines.join("\n");
454
- }
455
- }
456
657
  (0, runtime_1.emitNervesEvent)({
457
658
  component: "senses",
458
659
  event: "senses.cli_session_start",
@@ -460,80 +661,160 @@ async function runCliSession(options) {
460
661
  meta: { agentName: options.agentName, hasExitOnToolCall: !!options.exitOnToolCall },
461
662
  });
462
663
  let exitReason = "user_quit";
664
+ // Auto-first-turn: process the last user message immediately so the agent
665
+ // speaks first (e.g. specialist greeting). Only triggers when explicitly opted in.
666
+ if (options.autoFirstTurn && messages.length > 0 && messages[messages.length - 1]?.role === "user") {
667
+ currentAbort = new AbortController();
668
+ const traceId = (0, nerves_1.createTraceId)();
669
+ display.suppressInput();
670
+ let result;
671
+ try {
672
+ result = await (0, core_1.runAgent)(messages, cliCallbacks, options.skipSystemPromptRefresh ? undefined : "cli", currentAbort.signal, {
673
+ toolChoiceRequired: getEffectiveToolChoiceRequired(),
674
+ traceId,
675
+ tools: options.tools,
676
+ execTool: wrappedExecTool,
677
+ toolContext: effectiveToolContext,
678
+ });
679
+ }
680
+ catch (err) {
681
+ if (!(err instanceof DOMException && err.name === "AbortError")) {
682
+ display.error(err instanceof Error ? err.message : String(err));
683
+ }
684
+ }
685
+ cliCallbacks.flushMarkdown();
686
+ display.restoreInput();
687
+ currentAbort = null;
688
+ if (exitToolFired) {
689
+ exitReason = "tool_exit";
690
+ closed = true;
691
+ }
692
+ else {
693
+ const lastMsg = messages[messages.length - 1];
694
+ if (lastMsg?.role === "assistant" && !(typeof lastMsg.content === "string" ? lastMsg.content : "").trim()) {
695
+ display.warn("(empty response)");
696
+ }
697
+ if (options.onTurnEnd) {
698
+ await options.onTurnEnd(messages, result ?? { usage: undefined });
699
+ }
700
+ }
701
+ }
463
702
  try {
464
- for await (const input of debouncedLines(rl)) {
703
+ // Input source: TUI queue for Ink, test source for tests, readline fallback
704
+ let inputSource;
705
+ let inputCtrl = null;
706
+ if (tuiStore && inputQueue) {
707
+ // TUI path: input comes from Ink's onSubmit → InputQueue
708
+ inputSource = inputQueue;
709
+ }
710
+ else if (options._testInputSource) {
711
+ inputSource = options._testInputSource;
712
+ }
713
+ else {
714
+ // Imperative fallback: readline
715
+ const isTTY = process.stdin.isTTY === true;
716
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: isTTY });
717
+ inputCtrl = new InputController(rl);
718
+ inputSource = rl;
719
+ process.stdout.write(CLI_PROMPT);
720
+ }
721
+ for await (const input of inputSource) {
465
722
  if (closed)
466
723
  break;
467
- if (!input.trim()) {
468
- process.stdout.write("\x1b[36m> \x1b[0m");
724
+ if (!input.trim())
469
725
  continue;
470
- }
471
726
  // Optional input gate (e.g. trust gate in main)
472
727
  if (options.onInput) {
473
728
  const gate = options.onInput(input);
474
729
  if (!gate.allowed) {
475
730
  if (gate.reply) {
476
- process.stdout.write(`${gate.reply}\n`);
731
+ display.text(gate.reply);
477
732
  }
478
733
  if (closed)
479
734
  break;
480
- process.stdout.write("\x1b[36m> \x1b[0m");
481
735
  continue;
482
736
  }
483
737
  }
484
- // Check for slash commands
485
- const parsed = (0, commands_1.parseSlashCommand)(input);
486
- if (parsed) {
487
- const dispatchResult = registry.dispatch(parsed.command, { channel: "cli" });
488
- if (dispatchResult.handled && dispatchResult.result) {
489
- if (dispatchResult.result.action === "exit") {
490
- break;
491
- }
492
- else if (dispatchResult.result.action === "new") {
493
- messages.length = 0;
494
- messages.push({ role: "system", content: await (0, prompt_1.buildSystem)("cli") });
495
- await options.onNewSession?.();
496
- // eslint-disable-next-line no-console -- terminal UX: session cleared
497
- console.log("session cleared");
498
- process.stdout.write("\x1b[36m> \x1b[0m");
499
- continue;
500
- }
501
- else if (dispatchResult.result.action === "response") {
502
- // eslint-disable-next-line no-console -- terminal UX: command dispatch result
503
- console.log(dispatchResult.result.message || "");
504
- process.stdout.write("\x1b[36m> \x1b[0m");
505
- continue;
738
+ // Check for slash commands (legacy path only — pipeline handles commands for runTurn path)
739
+ if (!options.runTurn) {
740
+ const parsed = (0, commands_1.parseSlashCommand)(input);
741
+ if (parsed) {
742
+ const dispatchResult = registry.dispatch(parsed.command, { channel: "cli" });
743
+ if (dispatchResult.handled && dispatchResult.result) {
744
+ if (dispatchResult.result.action === "exit") {
745
+ break;
746
+ }
747
+ else if (dispatchResult.result.action === "new") {
748
+ messages.length = 0;
749
+ messages.push({ role: "system", content: await (0, prompt_1.buildSystem)("cli") });
750
+ await options.onNewSession?.();
751
+ // eslint-disable-next-line no-console -- terminal UX: session cleared
752
+ console.log("session cleared");
753
+ continue;
754
+ }
755
+ else if (dispatchResult.result.action === "response") {
756
+ display.text(dispatchResult.result.message || "");
757
+ continue;
758
+ }
506
759
  }
507
760
  }
508
761
  }
509
- // Re-style the echoed input lines
510
- const cols = process.stdout.columns || 80;
511
- const inputLines = input.split("\n");
512
- let echoRows = 0;
513
- for (const line of inputLines) {
514
- echoRows += Math.ceil((2 + line.length) / cols);
515
- }
516
- process.stdout.write(`\x1b[${echoRows}A\x1b[K` + `\x1b[1m> ${inputLines[0]}${inputLines.length > 1 ? ` (+${inputLines.length - 1} lines)` : ""}\x1b[0m\n\n`);
517
- messages.push({ role: "user", content: input });
518
- addHistory(history, input);
762
+ // Track user message in TUI for display
763
+ if (tuiStore)
764
+ tuiStore.addUserMessage(input);
519
765
  currentAbort = new AbortController();
520
- const traceId = (0, nerves_1.createTraceId)();
521
- ctrl.suppress(() => currentAbort.abort());
766
+ if (tuiStore)
767
+ tuiStore.suppressInput();
768
+ inputCtrl?.suppress(() => { currentAbort?.abort(); });
522
769
  let result;
523
770
  try {
524
- result = await (0, core_1.runAgent)(messages, cliCallbacks, "cli", currentAbort.signal, {
525
- toolChoiceRequired: getEffectiveToolChoiceRequired(),
526
- traceId,
527
- tools: options.tools,
528
- execTool: wrappedExecTool,
529
- toolContext: options.toolContext,
530
- });
771
+ if (options.runTurn) {
772
+ // Pipeline-based turn: the runTurn callback handles user message assembly,
773
+ // pending drain, trust gate, runAgent, postTurn, and token accumulation.
774
+ result = await options.runTurn(messages, input, cliCallbacks, currentAbort.signal, effectiveToolContext);
775
+ // Handle pipeline-intercepted commands with loop-control side effects
776
+ if (result?.turnOutcome === "command") {
777
+ if (result.commandAction === "exit") {
778
+ break;
779
+ }
780
+ else if (result.commandAction === "new") {
781
+ messages.length = 0;
782
+ messages.push({ role: "system", content: await (0, prompt_1.buildSystem)("cli") });
783
+ await options.onNewSession?.();
784
+ // eslint-disable-next-line no-console -- terminal UX: session cleared
785
+ console.log("session cleared");
786
+ continue;
787
+ }
788
+ // For "response" commands: the pipeline already emitted the response via onTextChunk
789
+ cliCallbacks.flushMarkdown();
790
+ continue;
791
+ }
792
+ }
793
+ else {
794
+ // Legacy path: inline runAgent (used by adoption specialist and tests)
795
+ const prefix = options.getContentPrefix?.();
796
+ messages.push({ role: "user", content: prefix ? `${prefix}\n\n${input}` : input });
797
+ const traceId = (0, nerves_1.createTraceId)();
798
+ result = await (0, core_1.runAgent)(messages, cliCallbacks, options.skipSystemPromptRefresh ? undefined : "cli", currentAbort.signal, {
799
+ toolChoiceRequired: getEffectiveToolChoiceRequired(),
800
+ traceId,
801
+ tools: options.tools,
802
+ execTool: wrappedExecTool,
803
+ toolContext: effectiveToolContext,
804
+ });
805
+ }
531
806
  }
532
- catch {
533
- // AbortError -- silently return to prompt
807
+ catch (err) {
808
+ if (!(err instanceof DOMException && err.name === "AbortError")) {
809
+ display.error(err instanceof Error ? err.message : String(err));
810
+ }
534
811
  }
535
812
  cliCallbacks.flushMarkdown();
536
- ctrl.restore();
813
+ if (!tuiStore)
814
+ process.stdout.write("\n"); // ensure response ends with newline before prompt (imperative only)
815
+ if (tuiStore)
816
+ tuiStore.restoreInput();
817
+ inputCtrl?.restore();
537
818
  currentAbort = null;
538
819
  // Check if exit tool was fired during this turn
539
820
  if (exitToolFired) {
@@ -543,31 +824,53 @@ async function runCliSession(options) {
543
824
  // Safety net: never silently swallow an empty response
544
825
  const lastMsg = messages[messages.length - 1];
545
826
  if (lastMsg?.role === "assistant" && !(typeof lastMsg.content === "string" ? lastMsg.content : "").trim()) {
546
- process.stderr.write("\x1b[33m(empty response)\x1b[0m\n");
827
+ display.warn("(empty response)");
547
828
  }
548
- process.stdout.write("\n\n");
549
829
  // Post-turn hook (session persistence, pending drain, prompt refresh, etc.)
550
830
  if (options.onTurnEnd) {
551
831
  await options.onTurnEnd(messages, result ?? { usage: undefined });
552
832
  }
553
833
  if (closed)
554
834
  break;
555
- process.stdout.write("\x1b[36m> \x1b[0m");
556
835
  }
557
836
  }
558
837
  finally {
559
- rl.close();
560
- // eslint-disable-next-line no-console -- terminal UX: goodbye
561
- console.log("bye");
838
+ rl?.close();
839
+ if (inkRef) {
840
+ // Suppress React "state update on unmounted component" warnings during cleanup.
841
+ // Ink's useInput hook fires after unmount — this is harmless but noisy.
842
+ // eslint-disable-next-line no-console -- intentional console.warn/error override for cleanup
843
+ const origWarn = console.warn;
844
+ const origError = console.error; // eslint-disable-line no-console
845
+ // eslint-disable-next-line no-console -- suppress React unmount warnings
846
+ console.warn = (...args) => { if (typeof args[0] === "string" && args[0].includes("Can't perform a React state update"))
847
+ return; origWarn.apply(console, args); };
848
+ // eslint-disable-next-line no-console -- suppress React unmount warnings
849
+ console.error = (...args) => { if (typeof args[0] === "string" && args[0].includes("Can't perform a React state update"))
850
+ return; origError.apply(console, args); };
851
+ inkRef.unmount();
852
+ setTimeout(() => { console.warn = origWarn; console.error = origError; }, 100); // eslint-disable-line no-console
853
+ }
854
+ if (options.banner !== false) {
855
+ // eslint-disable-next-line no-console -- terminal UX: goodbye
856
+ console.log("bye");
857
+ }
562
858
  }
859
+ /* v8 ignore stop */
563
860
  return { exitReason, toolResult: exitToolResult };
564
861
  }
565
862
  async function main(agentName, options) {
566
863
  if (agentName)
567
864
  (0, identity_1.setAgentName)(agentName);
568
865
  const pasteDebounceMs = options?.pasteDebounceMs ?? 50;
866
+ // Register spinner hooks so log output clears the spinner before printing
867
+ (0, nerves_1.registerSpinnerHooks)(pauseActiveSpinner, resumeActiveSpinner);
868
+ // Fallback: apply pending updates for daemon-less direct CLI usage
869
+ (0, update_hooks_1.registerUpdateHook)(bundle_meta_1.bundleMetaHook);
870
+ (0, update_hooks_1.registerUpdateHook)(agent_config_v2_1.agentConfigV2Hook);
871
+ await (0, update_hooks_1.applyPendingUpdates)((0, identity_1.getAgentBundlesRoot)(), (0, bundle_manifest_1.getPackageVersion)());
569
872
  // Fail fast if provider is misconfigured (triggers human-readable error + exit)
570
- (0, core_1.getProvider)();
873
+ (0, core_1.getProvider)("human");
571
874
  // Resolve context kernel (identity + channel) for CLI
572
875
  const friendsPath = path.join((0, identity_1.getAgentRoot)(), "friends");
573
876
  const friendStore = new store_file_1.FileFriendStore(friendsPath);
@@ -581,13 +884,6 @@ async function main(agentName, options) {
581
884
  channel: "cli",
582
885
  });
583
886
  const resolvedContext = await resolver.resolve();
584
- const cliToolContext = {
585
- /* v8 ignore next -- CLI has no OAuth sign-in; this no-op satisfies the interface @preserve */
586
- signin: async () => undefined,
587
- context: resolvedContext,
588
- friendStore,
589
- summarize: (0, core_1.createSummarize)(),
590
- };
591
887
  const friendId = resolvedContext.friend.id;
592
888
  const agentConfig = (0, identity_1.loadAgentConfig)();
593
889
  (0, cli_logging_1.configureCliRuntimeLogger)(friendId, {
@@ -610,52 +906,108 @@ async function main(agentName, options) {
610
906
  }
611
907
  // Load existing session or start fresh
612
908
  const existing = (0, context_1.loadSession)(sessPath);
909
+ let sessionState = existing?.state;
910
+ const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
613
911
  const sessionMessages = existing?.messages && existing.messages.length > 0
614
912
  ? existing.messages
615
- : [{ role: "system", content: await (0, prompt_1.buildSystem)("cli", undefined, resolvedContext) }];
616
- // Pending queue drain: inject pending messages as harness-context + assistant-content pairs
617
- const pendingDir = (0, pending_1.getPendingDir)((0, identity_1.getAgentName)(), friendId, "cli", "session");
618
- const drainToMessages = () => {
619
- const pending = (0, pending_1.drainPending)(pendingDir);
620
- if (pending.length === 0)
621
- return 0;
622
- for (const msg of pending) {
623
- sessionMessages.push({ role: "user", name: "harness", content: `[proactive message from ${msg.from}]` });
624
- sessionMessages.push({ role: "assistant", content: msg.content });
625
- }
626
- return pending.length;
627
- };
628
- // Startup drain: deliver offline messages
629
- const startupCount = drainToMessages();
630
- if (startupCount > 0) {
631
- (0, context_1.saveSession)(sessPath, sessionMessages);
632
- }
913
+ : [{ role: "system", content: await (0, prompt_1.buildSystem)("cli", { mcpManager }, resolvedContext) }];
914
+ // Per-turn pipeline input: CLI capabilities and pending dir
915
+ const cliCapabilities = (0, channel_1.getChannelCapabilities)("cli");
916
+ const currentAgentName = (0, identity_1.getAgentName)();
917
+ const pendingDir = (0, pending_1.getPendingDir)(currentAgentName, friendId, "cli", "session");
918
+ const summarize = (0, core_1.createSummarize)("human");
919
+ const cliFailoverState = { pending: null };
633
920
  try {
634
921
  await runCliSession({
635
- agentName: (0, identity_1.getAgentName)(),
922
+ agentName: currentAgentName,
636
923
  pasteDebounceMs,
637
924
  messages: sessionMessages,
638
- toolContext: cliToolContext,
639
- onInput: () => {
640
- const trustGate = (0, trust_gate_1.enforceTrustGate)({
641
- friend: resolvedContext.friend,
925
+ _testInputSource: options?._testInputSource,
926
+ onAsyncAssistantMessage: async (messages, _assistantMessage) => {
927
+ (0, context_1.postTurn)(messages, sessPath, undefined, undefined, sessionState);
928
+ },
929
+ runTurn: async (messages, userInput, callbacks, signal, toolContext) => {
930
+ // Run the full per-turn pipeline: resolve -> gate -> session -> drain -> runAgent -> postTurn -> tokens
931
+ // User message passed via input.messages so the pipeline can prepend pending messages to it.
932
+ const failoverState = cliFailoverState;
933
+ // Capture terminal errors instead of displaying immediately — the failover
934
+ // message replaces the raw error if failover triggers successfully.
935
+ let capturedTerminalError = null;
936
+ /* v8 ignore start -- failover-aware callback wrapper: tested via pipeline integration @preserve */
937
+ const failoverAwareCallbacks = {
938
+ ...callbacks,
939
+ onError: (error, severity) => {
940
+ if (severity === "terminal" && failoverState) {
941
+ capturedTerminalError = error;
942
+ callbacks.onError(new Error(""), "transient");
943
+ return;
944
+ }
945
+ callbacks.onError(error, severity);
946
+ },
947
+ };
948
+ /* v8 ignore stop */
949
+ const result = await (0, pipeline_1.handleInboundTurn)({
950
+ channel: "cli",
951
+ sessionKey: "session",
952
+ capabilities: cliCapabilities,
953
+ messages: [{ role: "user", content: userInput }],
954
+ continuityIngressTexts: getCliContinuityIngressTexts(userInput),
955
+ callbacks: failoverAwareCallbacks,
956
+ friendResolver: { resolve: () => Promise.resolve(resolvedContext) },
957
+ sessionLoader: {
958
+ loadOrCreate: () => Promise.resolve({
959
+ messages,
960
+ sessionPath: sessPath,
961
+ state: sessionState,
962
+ }),
963
+ },
964
+ pendingDir,
965
+ friendStore,
642
966
  provider: "local",
643
967
  externalId: localExternalId,
644
- channel: "cli",
968
+ enforceTrustGate: trust_gate_1.enforceTrustGate,
969
+ drainPending: pending_1.drainPending,
970
+ drainDeferredReturns: (deferredFriendId) => (0, pending_1.drainDeferredReturns)(currentAgentName, deferredFriendId),
971
+ runAgent: (msgs, cb, channel, sig, opts) => (0, core_1.runAgent)(msgs, cb, channel, sig, {
972
+ ...opts,
973
+ toolContext: {
974
+ /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
975
+ signin: async () => undefined,
976
+ ...opts?.toolContext,
977
+ summarize,
978
+ },
979
+ }),
980
+ postTurn: (turnMessages, sessionPathArg, usage, hooks, state) => {
981
+ (0, context_1.postTurn)(turnMessages, sessionPathArg, usage, hooks, state);
982
+ sessionState = state;
983
+ },
984
+ accumulateFriendTokens: tokens_1.accumulateFriendTokens,
985
+ signal,
986
+ runAgentOptions: {
987
+ toolChoiceRequired: (0, commands_1.getToolChoiceRequired)(),
988
+ traceId: (0, nerves_1.createTraceId)(),
989
+ mcpManager,
990
+ toolContext,
991
+ },
992
+ failoverState,
645
993
  });
646
- if (!trustGate.allowed) {
647
- return {
648
- allowed: false,
649
- reply: trustGate.reason === "stranger_first_reply" ? trustGate.autoReply : undefined,
650
- };
994
+ /* v8 ignore start -- failover display: tested via pipeline integration tests @preserve */
995
+ if (result.failoverMessage) {
996
+ // Failover handled it — show the actionable message instead of the raw error
997
+ process.stdout.write(`\x1b[33m${result.failoverMessage}\x1b[0m\n`);
651
998
  }
652
- return { allowed: true };
653
- },
654
- onTurnEnd: async (msgs, result) => {
655
- (0, context_1.postTurn)(msgs, sessPath, result.usage);
656
- await (0, tokens_1.accumulateFriendTokens)(friendStore, resolvedContext.friend.id, result.usage);
657
- drainToMessages();
658
- await (0, prompt_refresh_1.refreshSystemPrompt)(msgs, "cli", undefined, resolvedContext);
999
+ else if (capturedTerminalError) {
1000
+ // Failover didn't trigger (no failoverState, or sequence failed) — show the raw error
1001
+ process.stderr.write(`\x1b[31m${(0, format_1.formatError)(capturedTerminalError)}\x1b[0m\n`);
1002
+ }
1003
+ /* v8 ignore stop */
1004
+ // Handle gate rejection: display auto-reply if present
1005
+ if (!result.gateResult.allowed) {
1006
+ if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
1007
+ process.stdout.write(`${result.gateResult.autoReply}\n`);
1008
+ }
1009
+ }
1010
+ return { usage: result.usage, turnOutcome: result.turnOutcome, commandAction: result.commandAction };
659
1011
  },
660
1012
  onNewSession: () => {
661
1013
  (0, context_1.deleteSession)(sessPath);
@@ -666,3 +1018,4 @@ async function main(agentName, options) {
666
1018
  sessionLock?.release();
667
1019
  }
668
1020
  }
1021
+ // CI trigger