@ouro.bot/cli 0.1.0-alpha.11 → 0.1.0-alpha.111

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 (129) hide show
  1. package/AdoptionSpecialist.ouro/psyche/SOUL.md +2 -2
  2. package/AdoptionSpecialist.ouro/psyche/identities/monty.md +2 -2
  3. package/README.md +147 -205
  4. package/assets/ouroboros.png +0 -0
  5. package/changelog.json +672 -0
  6. package/dist/heart/active-work.js +622 -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 +68 -23
  12. package/dist/heart/core.js +492 -96
  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/auth-flow.js +430 -0
  16. package/dist/heart/daemon/daemon-cli.js +1711 -208
  17. package/dist/heart/daemon/daemon-entry.js +55 -6
  18. package/dist/heart/daemon/daemon-runtime-sync.js +212 -0
  19. package/dist/heart/daemon/daemon.js +216 -10
  20. package/dist/heart/daemon/hatch-animation.js +10 -3
  21. package/dist/heart/daemon/hatch-flow.js +7 -82
  22. package/dist/heart/daemon/hooks/bundle-meta.js +92 -0
  23. package/dist/heart/daemon/launchd.js +159 -0
  24. package/dist/heart/daemon/log-tailer.js +4 -3
  25. package/dist/heart/daemon/message-router.js +17 -8
  26. package/dist/heart/daemon/ouro-bot-entry.js +0 -0
  27. package/dist/heart/daemon/ouro-bot-global-installer.js +128 -0
  28. package/dist/heart/daemon/ouro-entry.js +0 -0
  29. package/dist/heart/daemon/ouro-path-installer.js +59 -15
  30. package/dist/heart/daemon/ouro-uti.js +11 -2
  31. package/dist/heart/daemon/ouro-version-manager.js +171 -0
  32. package/dist/heart/daemon/process-manager.js +14 -1
  33. package/dist/heart/daemon/run-hooks.js +37 -0
  34. package/dist/heart/daemon/runtime-logging.js +58 -15
  35. package/dist/heart/daemon/runtime-metadata.js +219 -0
  36. package/dist/heart/daemon/runtime-mode.js +67 -0
  37. package/dist/heart/daemon/sense-manager.js +307 -0
  38. package/dist/heart/daemon/skill-management-installer.js +94 -0
  39. package/dist/heart/daemon/socket-client.js +202 -0
  40. package/dist/heart/daemon/specialist-orchestrator.js +37 -94
  41. package/dist/heart/daemon/specialist-prompt.js +50 -12
  42. package/dist/heart/daemon/specialist-tools.js +211 -60
  43. package/dist/heart/daemon/staged-restart.js +114 -0
  44. package/dist/heart/daemon/thoughts.js +507 -0
  45. package/dist/heart/daemon/update-checker.js +111 -0
  46. package/dist/heart/daemon/update-hooks.js +138 -0
  47. package/dist/heart/daemon/wrapper-publish-guard.js +86 -0
  48. package/dist/heart/delegation.js +62 -0
  49. package/dist/heart/identity.js +126 -21
  50. package/dist/heart/kicks.js +1 -19
  51. package/dist/heart/model-capabilities.js +48 -0
  52. package/dist/heart/obligations.js +197 -0
  53. package/dist/heart/progress-story.js +42 -0
  54. package/dist/heart/providers/anthropic.js +74 -9
  55. package/dist/heart/providers/azure.js +87 -8
  56. package/dist/heart/providers/github-copilot.js +149 -0
  57. package/dist/heart/providers/minimax.js +5 -1
  58. package/dist/heart/providers/openai-codex.js +13 -4
  59. package/dist/heart/safe-workspace.js +381 -0
  60. package/dist/heart/sense-truth.js +61 -0
  61. package/dist/heart/session-activity.js +169 -0
  62. package/dist/heart/session-recall.js +216 -0
  63. package/dist/heart/streaming.js +108 -24
  64. package/dist/heart/target-resolution.js +123 -0
  65. package/dist/heart/tool-loop.js +194 -0
  66. package/dist/heart/turn-coordinator.js +28 -0
  67. package/dist/mind/associative-recall.js +14 -2
  68. package/dist/mind/bundle-manifest.js +70 -0
  69. package/dist/mind/context.js +60 -14
  70. package/dist/mind/first-impressions.js +16 -2
  71. package/dist/mind/friends/channel.js +35 -0
  72. package/dist/mind/friends/group-context.js +144 -0
  73. package/dist/mind/friends/store-file.js +19 -0
  74. package/dist/mind/friends/trust-explanation.js +74 -0
  75. package/dist/mind/friends/types.js +8 -0
  76. package/dist/mind/memory.js +27 -26
  77. package/dist/mind/obligation-steering.js +221 -0
  78. package/dist/mind/pending.js +76 -9
  79. package/dist/mind/phrases.js +1 -0
  80. package/dist/mind/prompt.js +527 -77
  81. package/dist/mind/token-estimate.js +8 -12
  82. package/dist/nerves/cli-logging.js +15 -2
  83. package/dist/nerves/coverage/run-artifacts.js +1 -1
  84. package/dist/nerves/index.js +12 -0
  85. package/dist/repertoire/ado-client.js +4 -2
  86. package/dist/repertoire/coding/context-pack.js +254 -0
  87. package/dist/repertoire/coding/feedback.js +301 -0
  88. package/dist/repertoire/coding/index.js +4 -1
  89. package/dist/repertoire/coding/manager.js +210 -4
  90. package/dist/repertoire/coding/spawner.js +39 -9
  91. package/dist/repertoire/coding/tools.js +171 -4
  92. package/dist/repertoire/data/ado-endpoints.json +188 -0
  93. package/dist/repertoire/guardrails.js +290 -0
  94. package/dist/repertoire/mcp-client.js +254 -0
  95. package/dist/repertoire/mcp-manager.js +195 -0
  96. package/dist/repertoire/skills.js +3 -26
  97. package/dist/repertoire/tasks/board.js +12 -0
  98. package/dist/repertoire/tasks/index.js +23 -9
  99. package/dist/repertoire/tasks/transitions.js +1 -2
  100. package/dist/repertoire/tools-base.js +925 -250
  101. package/dist/repertoire/tools-bluebubbles.js +93 -0
  102. package/dist/repertoire/tools-teams.js +58 -25
  103. package/dist/repertoire/tools.js +106 -53
  104. package/dist/senses/bluebubbles-client.js +210 -5
  105. package/dist/senses/bluebubbles-entry.js +2 -0
  106. package/dist/senses/bluebubbles-inbound-log.js +109 -0
  107. package/dist/senses/bluebubbles-media.js +339 -0
  108. package/dist/senses/bluebubbles-model.js +12 -4
  109. package/dist/senses/bluebubbles-mutation-log.js +45 -5
  110. package/dist/senses/bluebubbles-runtime-state.js +109 -0
  111. package/dist/senses/bluebubbles-session-cleanup.js +72 -0
  112. package/dist/senses/bluebubbles.js +900 -45
  113. package/dist/senses/cli-layout.js +187 -0
  114. package/dist/senses/cli.js +448 -170
  115. package/dist/senses/continuity.js +94 -0
  116. package/dist/senses/debug-activity.js +154 -0
  117. package/dist/senses/inner-dialog-worker.js +47 -18
  118. package/dist/senses/inner-dialog.js +388 -83
  119. package/dist/senses/pipeline.js +334 -0
  120. package/dist/senses/teams.js +577 -129
  121. package/dist/senses/trust-gate.js +112 -2
  122. package/package.json +14 -3
  123. package/subagents/README.md +4 -70
  124. package/dist/heart/daemon/specialist-session.js +0 -177
  125. package/dist/heart/daemon/subagent-installer.js +0 -125
  126. package/dist/inner-worker-entry.js +0 -4
  127. package/subagents/work-doer.md +0 -233
  128. package/subagents/work-merger.md +0 -624
  129. package/subagents/work-planner.md +0 -373
@@ -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.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,7 +67,57 @@ 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");
73
+ const update_hooks_1 = require("../heart/daemon/update-hooks");
74
+ const bundle_meta_1 = require("../heart/daemon/hooks/bundle-meta");
75
+ const bundle_manifest_1 = require("../mind/bundle-manifest");
76
+ const cli_layout_1 = require("./cli-layout");
77
+ var cli_layout_2 = require("./cli-layout");
78
+ Object.defineProperty(exports, "formatEchoedInputSummary", { enumerable: true, get: function () { return cli_layout_2.formatEchoedInputSummary; } });
79
+ Object.defineProperty(exports, "wrapCliText", { enumerable: true, get: function () { return cli_layout_2.wrapCliText; } });
80
+ Object.defineProperty(exports, "StreamingWordWrapper", { enumerable: true, get: function () { return cli_layout_2.StreamingWordWrapper; } });
81
+ /**
82
+ * Format pending messages as content-prefix strings for injection into
83
+ * the next user message. Self-messages (from === agentName) become
84
+ * `[inner thought: {content}]`, inter-agent messages become
85
+ * `[message from {name}: {content}]`.
86
+ */
87
+ function formatPendingPrefix(messages, agentName) {
88
+ return messages
89
+ .map((msg) => msg.from === agentName
90
+ ? `[inner thought: ${msg.content}]`
91
+ : `[message from ${msg.from}: ${msg.content}]`)
92
+ .join("\n");
93
+ }
94
+ function getCliContinuityIngressTexts(input) {
95
+ const trimmed = input.trim();
96
+ return trimmed ? [trimmed] : [];
97
+ }
98
+ const CLI_PROMPT = "\x1b[36m> \x1b[0m";
99
+ function writeCliAsyncAssistantMessage(rl, message, stdout = process.stdout) {
100
+ const rlInt = rl;
101
+ const currentLine = rlInt.line ?? "";
102
+ const currentCursor = rlInt.cursor ?? currentLine.length;
103
+ stdout.write("\r\x1b[K");
104
+ stdout.write(`${renderMarkdown(message)}\n`);
105
+ stdout.write(CLI_PROMPT);
106
+ if (!currentLine)
107
+ return;
108
+ stdout.write(currentLine);
109
+ if (currentCursor < currentLine.length) {
110
+ readline.cursorTo(process.stdout, 2 + currentCursor);
111
+ }
112
+ }
113
+ // Module-level active spinner for log coordination.
114
+ // The terminal log sink calls these to avoid interleaving with spinner output.
115
+ let _activeSpinner = null;
116
+ /* v8 ignore start -- spinner coordination: exercised at runtime, not unit-testable without real terminal @preserve */
117
+ function pauseActiveSpinner() { _activeSpinner?.pause(); }
118
+ function resumeActiveSpinner() { _activeSpinner?.resume(); }
119
+ /* v8 ignore stop */
120
+ function setActiveSpinner(s) { _activeSpinner = s; }
63
121
  // spinner that only touches stderr, cleans up after itself
64
122
  // exported for direct testability (stop-without-start branch)
65
123
  class Spinner {
@@ -70,12 +128,14 @@ class Spinner {
70
128
  msg = "";
71
129
  phrases = null;
72
130
  lastPhrase = "";
131
+ stopped = false;
73
132
  constructor(m = "working", phrases) {
74
133
  this.msg = m;
75
134
  if (phrases && phrases.length > 0)
76
135
  this.phrases = phrases;
77
136
  }
78
137
  start() {
138
+ this.stopped = false;
79
139
  process.stderr.write("\r\x1b[K");
80
140
  this.spin();
81
141
  this.iv = setInterval(() => this.spin(), 80);
@@ -84,15 +144,37 @@ class Spinner {
84
144
  }
85
145
  }
86
146
  spin() {
87
- process.stderr.write(`\r${this.frames[this.i]} ${this.msg}... `);
147
+ // Guard: clearInterval can't prevent already-dequeued callbacks
148
+ /* v8 ignore next -- race guard: timer callback fires after stop() @preserve */
149
+ if (this.stopped)
150
+ return;
151
+ process.stderr.write(`\r\x1b[K${this.frames[this.i]} ${this.msg}... `);
88
152
  this.i = (this.i + 1) % this.frames.length;
89
153
  }
90
154
  rotatePhrase() {
155
+ /* v8 ignore next -- race guard: timer callback fires after stop() @preserve */
156
+ if (this.stopped)
157
+ return;
91
158
  const next = (0, phrases_1.pickPhrase)(this.phrases, this.lastPhrase);
92
159
  this.lastPhrase = next;
93
160
  this.msg = next;
94
161
  }
162
+ /* v8 ignore start -- pause/resume: exercised at runtime via log sink coordination @preserve */
163
+ /** Clear the spinner line temporarily so other output can print cleanly. */
164
+ pause() {
165
+ if (this.stopped)
166
+ return;
167
+ process.stderr.write("\r\x1b[K");
168
+ }
169
+ /** Restore the spinner line after a pause. */
170
+ resume() {
171
+ if (this.stopped)
172
+ return;
173
+ this.spin();
174
+ }
175
+ /* v8 ignore stop */
95
176
  stop(ok) {
177
+ this.stopped = true;
96
178
  if (this.iv) {
97
179
  clearInterval(this.iv);
98
180
  this.iv = null;
@@ -279,41 +361,54 @@ function createCliCallbacks() {
279
361
  meta: {},
280
362
  });
281
363
  let currentSpinner = null;
282
- let hadReasoning = false;
364
+ function setSpinner(s) { currentSpinner = s; setActiveSpinner(s); }
283
365
  let hadToolRun = false;
284
366
  let textDirty = false; // true when text/reasoning was written without a trailing newline
285
367
  const streamer = new MarkdownStreamer();
368
+ const wrapper = new cli_layout_1.StreamingWordWrapper();
286
369
  return {
287
370
  onModelStart: () => {
288
371
  currentSpinner?.stop();
289
- currentSpinner = null;
290
- hadReasoning = false;
372
+ setSpinner(null);
291
373
  textDirty = false;
292
374
  streamer.reset();
375
+ wrapper.reset();
293
376
  const phrases = (0, phrases_1.getPhrases)();
294
377
  const pool = hadToolRun ? phrases.followup : phrases.thinking;
295
378
  const first = (0, phrases_1.pickPhrase)(pool);
296
- currentSpinner = new Spinner(first, pool);
379
+ setSpinner(new Spinner(first, pool));
297
380
  currentSpinner.start();
298
381
  },
299
382
  onModelStreamStart: () => {
300
- currentSpinner?.stop();
301
- currentSpinner = null;
383
+ // No-op: content callbacks (onTextChunk, onReasoningChunk) handle
384
+ // stopping the spinner. onModelStreamStart fires too early and
385
+ // doesn't fire at all for final_answer tool streaming.
386
+ },
387
+ onClearText: () => {
388
+ streamer.reset();
389
+ wrapper.reset();
302
390
  },
303
391
  onTextChunk: (text) => {
304
- if (hadReasoning) {
305
- process.stdout.write("\n\n");
306
- hadReasoning = false;
392
+ // Stop spinner if still running — final_answer streaming and Anthropic
393
+ // tool-only responses bypass onModelStreamStart, so the spinner would
394
+ // otherwise keep running (and its \r writes overwrite response text).
395
+ if (currentSpinner) {
396
+ currentSpinner.stop();
397
+ setSpinner(null);
307
398
  }
308
399
  const rendered = streamer.push(text);
309
- if (rendered)
310
- process.stdout.write(rendered);
400
+ /* v8 ignore start -- wrapper integration: tested via cli.test.ts onTextChunk tests @preserve */
401
+ if (rendered) {
402
+ const wrapped = wrapper.push(rendered);
403
+ if (wrapped)
404
+ process.stdout.write(wrapped);
405
+ }
406
+ /* v8 ignore stop */
311
407
  textDirty = text.length > 0 && !text.endsWith("\n");
312
408
  },
313
- onReasoningChunk: (text) => {
314
- hadReasoning = true;
315
- process.stdout.write(`\x1b[2m${text}\x1b[0m`);
316
- textDirty = text.length > 0 && !text.endsWith("\n");
409
+ onReasoningChunk: (_text) => {
410
+ // Keep reasoning private in the CLI surface. The spinner continues to
411
+ // represent active thinking until actual tool or answer output arrives.
317
412
  },
318
413
  onToolStart: (_name, _args) => {
319
414
  // Stop the model-start spinner: when the model returns only tool calls
@@ -328,13 +423,13 @@ function createCliCallbacks() {
328
423
  }
329
424
  const toolPhrases = (0, phrases_1.getPhrases)().tool;
330
425
  const first = (0, phrases_1.pickPhrase)(toolPhrases);
331
- currentSpinner = new Spinner(first, toolPhrases);
426
+ setSpinner(new Spinner(first, toolPhrases));
332
427
  currentSpinner.start();
333
428
  hadToolRun = true;
334
429
  },
335
430
  onToolEnd: (name, argSummary, success) => {
336
431
  currentSpinner?.stop();
337
- currentSpinner = null;
432
+ setSpinner(null);
338
433
  const msg = (0, format_1.formatToolResult)(name, argSummary, success);
339
434
  const color = success ? "\x1b[32m" : "\x1b[31m";
340
435
  process.stderr.write(`${color}${msg}\x1b[0m\n`);
@@ -342,17 +437,17 @@ function createCliCallbacks() {
342
437
  onError: (error, severity) => {
343
438
  if (severity === "transient") {
344
439
  currentSpinner?.fail(error.message);
345
- currentSpinner = null;
440
+ setSpinner(null);
346
441
  }
347
442
  else {
348
443
  currentSpinner?.stop();
349
- currentSpinner = null;
444
+ setSpinner(null);
350
445
  process.stderr.write(`\x1b[31m${(0, format_1.formatError)(error)}\x1b[0m\n`);
351
446
  }
352
447
  },
353
448
  onKick: () => {
354
449
  currentSpinner?.stop();
355
- currentSpinner = null;
450
+ setSpinner(null);
356
451
  if (textDirty) {
357
452
  process.stdout.write("\n");
358
453
  textDirty = false;
@@ -361,95 +456,117 @@ function createCliCallbacks() {
361
456
  },
362
457
  flushMarkdown: () => {
363
458
  currentSpinner?.stop();
364
- currentSpinner = null;
459
+ setSpinner(null);
460
+ /* v8 ignore start -- wrapper flush: tested via cli.test.ts flushMarkdown tests @preserve */
365
461
  const remaining = streamer.flush();
366
- if (remaining)
367
- process.stdout.write(remaining);
462
+ if (remaining) {
463
+ const wrapped = wrapper.push(remaining);
464
+ if (wrapped)
465
+ process.stdout.write(wrapped);
466
+ }
467
+ const tail = wrapper.flush();
468
+ if (tail)
469
+ process.stdout.write(tail);
470
+ /* v8 ignore stop */
368
471
  },
369
472
  };
370
473
  }
371
- async function main(agentName, options) {
372
- if (agentName)
373
- (0, identity_1.setAgentName)(agentName);
374
- const pasteDebounceMs = options?.pasteDebounceMs ?? 50;
375
- // 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);
379
- // Resolve context kernel (identity + channel) for CLI
380
- const friendsPath = path.join((0, identity_1.getAgentRoot)(), "friends");
381
- const friendStore = new store_file_1.FileFriendStore(friendsPath);
382
- const username = os.userInfo().username;
383
- const hostname = os.hostname();
384
- const localExternalId = `${username}@${hostname}`;
385
- const resolver = new resolver_1.FriendResolver(friendStore, {
386
- provider: "local",
387
- externalId: localExternalId,
388
- displayName: username,
389
- channel: "cli",
390
- });
391
- 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
- const friendId = resolvedContext.friend.id;
400
- const agentConfig = (0, identity_1.loadAgentConfig)();
401
- (0, cli_logging_1.configureCliRuntimeLogger)(friendId, {
402
- level: agentConfig.logging?.level,
403
- sinks: agentConfig.logging?.sinks,
404
- });
405
- const sessPath = (0, config_1.sessionPath)(friendId, "cli", "session");
406
- let sessionLock = null;
407
- try {
408
- sessionLock = (0, session_lock_1.acquireSessionLock)(`${sessPath}.lock`, (0, identity_1.getAgentName)());
474
+ // Debounced line iterator: collects rapid-fire lines (paste) into a single input.
475
+ // When the debounce timeout wins the race, the pending iter.next() is saved
476
+ // and reused in the next iteration to prevent it from silently consuming input.
477
+ async function* createDebouncedLines(source, debounceMs) {
478
+ if (debounceMs <= 0) {
479
+ yield* source;
480
+ return;
409
481
  }
410
- catch (error) {
411
- /* v8 ignore start -- integration: main() is interactive, lock tested in session-lock.test.ts @preserve */
412
- if (error instanceof session_lock_1.SessionLockError) {
413
- process.stderr.write(`${error.message}\n`);
414
- return;
482
+ const iter = source[Symbol.asyncIterator]();
483
+ let pending = null;
484
+ while (true) {
485
+ const first = pending ? await pending : await iter.next();
486
+ pending = null;
487
+ if (first.done)
488
+ break;
489
+ const lines = [first.value];
490
+ let more = true;
491
+ while (more) {
492
+ const nextPromise = iter.next();
493
+ const raced = await Promise.race([
494
+ nextPromise.then((r) => ({ kind: "line", result: r })),
495
+ new Promise((r) => setTimeout(() => r({ kind: "timeout" }), debounceMs)),
496
+ ]);
497
+ if (raced.kind === "timeout") {
498
+ pending = nextPromise;
499
+ more = false;
500
+ }
501
+ else if (raced.result.done) {
502
+ more = false;
503
+ }
504
+ else {
505
+ lines.push(raced.result.value);
506
+ }
415
507
  }
416
- throw error;
417
- /* v8 ignore stop */
508
+ yield lines.join("\n");
418
509
  }
419
- // Load existing session or start fresh
420
- const existing = (0, context_1.loadSession)(sessPath);
421
- const messages = existing?.messages && existing.messages.length > 0
422
- ? 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);
510
+ }
511
+ async function runCliSession(options) {
512
+ /* v8 ignore start -- integration: runCliSession is interactive, tested via E2E @preserve */
513
+ const pasteDebounceMs = options.pasteDebounceMs ?? 50;
514
+ const registry = (0, commands_1.createCommandRegistry)();
515
+ if (!options.disableCommands) {
516
+ (0, commands_1.registerDefaultCommands)(registry);
440
517
  }
518
+ const messages = options.messages
519
+ ?? [{ role: "system", content: await (0, prompt_1.buildSystem)("cli") }];
441
520
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
442
521
  const ctrl = new InputController(rl);
443
522
  let currentAbort = null;
444
523
  const history = [];
445
524
  let closed = false;
446
525
  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`);
526
+ if (options.banner !== false) {
527
+ const bannerText = typeof options.banner === "string"
528
+ ? options.banner
529
+ : `${options.agentName} (type /commands for help)`;
530
+ // eslint-disable-next-line no-console -- terminal UX: startup banner
531
+ console.log(`\n${bannerText}\n`);
532
+ }
449
533
  const cliCallbacks = createCliCallbacks();
450
- process.stdout.write("\x1b[36m> \x1b[0m");
534
+ const effectiveToolContext = {
535
+ signin: options.toolContext?.signin ?? (async () => undefined),
536
+ ...options.toolContext,
537
+ codingFeedback: {
538
+ send: async (message) => {
539
+ const assistantMessage = {
540
+ role: "assistant",
541
+ content: message,
542
+ };
543
+ messages.push(assistantMessage);
544
+ await options.onAsyncAssistantMessage?.(messages, assistantMessage);
545
+ writeCliAsyncAssistantMessage(rl, message);
546
+ await options.toolContext?.codingFeedback?.send(message);
547
+ },
548
+ },
549
+ };
550
+ // exitOnToolCall machinery: wrap execTool to detect target tool
551
+ let exitToolResult;
552
+ let exitToolFired = false;
553
+ const resolvedExecTool = options.execTool;
554
+ const wrappedExecTool = options.exitOnToolCall && resolvedExecTool
555
+ ? async (name, args, ctx) => {
556
+ const result = await resolvedExecTool(name, args, ctx);
557
+ if (name === options.exitOnToolCall) {
558
+ exitToolResult = result;
559
+ exitToolFired = true;
560
+ // Abort immediately so the model doesn't generate more output
561
+ // (e.g. reasoning about calling final_answer after complete_adoption)
562
+ currentAbort?.abort();
563
+ }
564
+ return result;
565
+ }
566
+ : resolvedExecTool;
567
+ // Resolve toolChoiceRequired: use explicit option if set, else fall back to toggle
568
+ const getEffectiveToolChoiceRequired = () => options.toolChoiceRequired !== undefined ? options.toolChoiceRequired : (0, commands_1.getToolChoiceRequired)();
451
569
  // 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
570
  rl.on("SIGINT", () => {
454
571
  const rlInt = rl;
455
572
  const currentLine = rlInt.line || "";
@@ -457,73 +574,91 @@ async function main(agentName, options) {
457
574
  if (result === "clear") {
458
575
  rlInt.line = "";
459
576
  rlInt.cursor = 0;
460
- process.stdout.write("\r\x1b[K\x1b[36m> \x1b[0m");
577
+ process.stdout.write(`\r\x1b[K${CLI_PROMPT}`);
461
578
  }
462
579
  else if (result === "warn") {
463
580
  rlInt.line = "";
464
581
  rlInt.cursor = 0;
465
582
  process.stdout.write("\r\x1b[K");
466
583
  process.stderr.write("press Ctrl-C again to exit\n");
467
- process.stdout.write("\x1b[36m> \x1b[0m");
584
+ process.stdout.write(CLI_PROMPT);
468
585
  }
469
586
  else {
470
587
  rl.close();
471
588
  }
472
589
  });
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;
590
+ const debouncedLines = (source) => createDebouncedLines(source, pasteDebounceMs);
591
+ (0, runtime_1.emitNervesEvent)({
592
+ component: "senses",
593
+ event: "senses.cli_session_start",
594
+ message: "runCliSession started",
595
+ meta: { agentName: options.agentName, hasExitOnToolCall: !!options.exitOnToolCall },
596
+ });
597
+ let exitReason = "user_quit";
598
+ // Auto-first-turn: process the last user message immediately so the agent
599
+ // speaks first (e.g. specialist greeting). Only triggers when explicitly opted in.
600
+ if (options.autoFirstTurn && messages.length > 0 && messages[messages.length - 1]?.role === "user") {
601
+ currentAbort = new AbortController();
602
+ const traceId = (0, nerves_1.createTraceId)();
603
+ ctrl.suppress(() => currentAbort.abort());
604
+ let result;
605
+ try {
606
+ result = await (0, core_1.runAgent)(messages, cliCallbacks, options.skipSystemPromptRefresh ? undefined : "cli", currentAbort.signal, {
607
+ toolChoiceRequired: getEffectiveToolChoiceRequired(),
608
+ traceId,
609
+ tools: options.tools,
610
+ execTool: wrappedExecTool,
611
+ toolContext: effectiveToolContext,
612
+ });
478
613
  }
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
- }
614
+ catch (err) {
615
+ // AbortError (Ctrl-C) -- silently continue to prompt
616
+ // All other errors: show the user what happened
617
+ if (!(err instanceof DOMException && err.name === "AbortError")) {
618
+ process.stderr.write(`\x1b[31m${err instanceof Error ? err.message : String(err)}\x1b[0m\n`);
501
619
  }
502
- yield lines.join("\n");
503
620
  }
621
+ cliCallbacks.flushMarkdown();
622
+ ctrl.restore();
623
+ currentAbort = null;
624
+ if (exitToolFired) {
625
+ exitReason = "tool_exit";
626
+ rl.close();
627
+ }
628
+ else {
629
+ const lastMsg = messages[messages.length - 1];
630
+ if (lastMsg?.role === "assistant" && !(typeof lastMsg.content === "string" ? lastMsg.content : "").trim()) {
631
+ process.stderr.write("\x1b[33m(empty response)\x1b[0m\n");
632
+ }
633
+ process.stdout.write("\n\n");
634
+ if (options.onTurnEnd) {
635
+ await options.onTurnEnd(messages, result ?? { usage: undefined });
636
+ }
637
+ }
638
+ }
639
+ if (!exitToolFired) {
640
+ process.stdout.write(CLI_PROMPT);
504
641
  }
505
642
  try {
506
643
  for await (const input of debouncedLines(rl)) {
507
644
  if (closed)
508
645
  break;
509
646
  if (!input.trim()) {
510
- process.stdout.write("\x1b[36m> \x1b[0m");
647
+ process.stdout.write(CLI_PROMPT);
511
648
  continue;
512
649
  }
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`);
650
+ // Optional input gate (e.g. trust gate in main)
651
+ if (options.onInput) {
652
+ const gate = options.onInput(input);
653
+ if (!gate.allowed) {
654
+ if (gate.reply) {
655
+ process.stdout.write(`${gate.reply}\n`);
656
+ }
657
+ if (closed)
658
+ break;
659
+ process.stdout.write(CLI_PROMPT);
660
+ continue;
522
661
  }
523
- if (closed)
524
- break;
525
- process.stdout.write("\x1b[36m> \x1b[0m");
526
- continue;
527
662
  }
528
663
  // Check for slash commands
529
664
  const parsed = (0, commands_1.parseSlashCommand)(input);
@@ -536,69 +671,212 @@ async function main(agentName, options) {
536
671
  else if (dispatchResult.result.action === "new") {
537
672
  messages.length = 0;
538
673
  messages.push({ role: "system", content: await (0, prompt_1.buildSystem)("cli") });
539
- (0, context_1.deleteSession)(sessPath);
674
+ await options.onNewSession?.();
540
675
  // eslint-disable-next-line no-console -- terminal UX: session cleared
541
676
  console.log("session cleared");
542
- process.stdout.write("\x1b[36m> \x1b[0m");
677
+ process.stdout.write(CLI_PROMPT);
543
678
  continue;
544
679
  }
545
680
  else if (dispatchResult.result.action === "response") {
546
681
  // eslint-disable-next-line no-console -- terminal UX: command dispatch result
547
682
  console.log(dispatchResult.result.message || "");
548
- process.stdout.write("\x1b[36m> \x1b[0m");
683
+ process.stdout.write(CLI_PROMPT);
549
684
  continue;
550
685
  }
551
686
  }
552
687
  }
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
688
+ // Re-style the echoed input lines without leaving wrapped paste remnants behind.
555
689
  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 });
690
+ process.stdout.write((0, cli_layout_1.formatEchoedInputSummary)(input, cols));
563
691
  addHistory(history, input);
564
692
  currentAbort = new AbortController();
565
- const traceId = (0, nerves_1.createTraceId)();
566
693
  ctrl.suppress(() => currentAbort.abort());
567
694
  let result;
568
695
  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
- });
696
+ if (options.runTurn) {
697
+ // Pipeline-based turn: the runTurn callback handles user message assembly,
698
+ // pending drain, trust gate, runAgent, postTurn, and token accumulation.
699
+ result = await options.runTurn(messages, input, cliCallbacks, currentAbort.signal, effectiveToolContext);
700
+ }
701
+ else {
702
+ // Legacy path: inline runAgent (used by adoption specialist and tests)
703
+ const prefix = options.getContentPrefix?.();
704
+ messages.push({ role: "user", content: prefix ? `${prefix}\n\n${input}` : input });
705
+ const traceId = (0, nerves_1.createTraceId)();
706
+ result = await (0, core_1.runAgent)(messages, cliCallbacks, options.skipSystemPromptRefresh ? undefined : "cli", currentAbort.signal, {
707
+ toolChoiceRequired: getEffectiveToolChoiceRequired(),
708
+ traceId,
709
+ tools: options.tools,
710
+ execTool: wrappedExecTool,
711
+ toolContext: effectiveToolContext,
712
+ });
713
+ }
574
714
  }
575
- catch {
576
- // AbortError silently return to prompt
715
+ catch (err) {
716
+ // AbortError (Ctrl-C) -- silently return to prompt
717
+ // All other errors: show the user what happened
718
+ if (!(err instanceof DOMException && err.name === "AbortError")) {
719
+ process.stderr.write(`\x1b[31m${err instanceof Error ? err.message : String(err)}\x1b[0m\n`);
720
+ }
577
721
  }
578
722
  cliCallbacks.flushMarkdown();
579
723
  ctrl.restore();
580
724
  currentAbort = null;
725
+ // Check if exit tool was fired during this turn
726
+ if (exitToolFired) {
727
+ exitReason = "tool_exit";
728
+ break;
729
+ }
581
730
  // Safety net: never silently swallow an empty response
582
731
  const lastMsg = messages[messages.length - 1];
583
732
  if (lastMsg?.role === "assistant" && !(typeof lastMsg.content === "string" ? lastMsg.content : "").trim()) {
584
733
  process.stderr.write("\x1b[33m(empty response)\x1b[0m\n");
585
734
  }
586
735
  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);
736
+ // Post-turn hook (session persistence, pending drain, prompt refresh, etc.)
737
+ if (options.onTurnEnd) {
738
+ await options.onTurnEnd(messages, result ?? { usage: undefined });
739
+ }
593
740
  if (closed)
594
741
  break;
595
- process.stdout.write("\x1b[36m> \x1b[0m");
742
+ process.stdout.write(CLI_PROMPT);
596
743
  }
597
744
  }
598
745
  finally {
599
- sessionLock?.release();
600
746
  rl.close();
601
- // eslint-disable-next-line no-console -- terminal UX: goodbye
602
- console.log("bye");
747
+ if (options.banner !== false) {
748
+ // eslint-disable-next-line no-console -- terminal UX: goodbye
749
+ console.log("bye");
750
+ }
751
+ }
752
+ /* v8 ignore stop */
753
+ return { exitReason, toolResult: exitToolResult };
754
+ }
755
+ async function main(agentName, options) {
756
+ if (agentName)
757
+ (0, identity_1.setAgentName)(agentName);
758
+ const pasteDebounceMs = options?.pasteDebounceMs ?? 50;
759
+ // Register spinner hooks so log output clears the spinner before printing
760
+ (0, nerves_1.registerSpinnerHooks)(pauseActiveSpinner, resumeActiveSpinner);
761
+ // Fallback: apply pending updates for daemon-less direct CLI usage
762
+ (0, update_hooks_1.registerUpdateHook)(bundle_meta_1.bundleMetaHook);
763
+ await (0, update_hooks_1.applyPendingUpdates)((0, identity_1.getAgentBundlesRoot)(), (0, bundle_manifest_1.getPackageVersion)());
764
+ // Fail fast if provider is misconfigured (triggers human-readable error + exit)
765
+ (0, core_1.getProvider)();
766
+ // Resolve context kernel (identity + channel) for CLI
767
+ const friendsPath = path.join((0, identity_1.getAgentRoot)(), "friends");
768
+ const friendStore = new store_file_1.FileFriendStore(friendsPath);
769
+ const username = os.userInfo().username;
770
+ const hostname = os.hostname();
771
+ const localExternalId = `${username}@${hostname}`;
772
+ const resolver = new resolver_1.FriendResolver(friendStore, {
773
+ provider: "local",
774
+ externalId: localExternalId,
775
+ displayName: username,
776
+ channel: "cli",
777
+ });
778
+ const resolvedContext = await resolver.resolve();
779
+ const friendId = resolvedContext.friend.id;
780
+ const agentConfig = (0, identity_1.loadAgentConfig)();
781
+ (0, cli_logging_1.configureCliRuntimeLogger)(friendId, {
782
+ level: agentConfig.logging?.level,
783
+ sinks: agentConfig.logging?.sinks,
784
+ });
785
+ const sessPath = (0, config_1.sessionPath)(friendId, "cli", "session");
786
+ let sessionLock = null;
787
+ try {
788
+ sessionLock = (0, session_lock_1.acquireSessionLock)(`${sessPath}.lock`, (0, identity_1.getAgentName)());
789
+ }
790
+ catch (error) {
791
+ /* v8 ignore start -- integration: main() is interactive, lock tested in session-lock.test.ts @preserve */
792
+ if (error instanceof session_lock_1.SessionLockError) {
793
+ process.stderr.write(`${error.message}\n`);
794
+ return;
795
+ }
796
+ throw error;
797
+ /* v8 ignore stop */
798
+ }
799
+ // Load existing session or start fresh
800
+ const existing = (0, context_1.loadSession)(sessPath);
801
+ let sessionState = existing?.state;
802
+ const mcpManager = await (0, mcp_manager_1.getSharedMcpManager)() ?? undefined;
803
+ const sessionMessages = existing?.messages && existing.messages.length > 0
804
+ ? existing.messages
805
+ : [{ role: "system", content: await (0, prompt_1.buildSystem)("cli", { mcpManager }, resolvedContext) }];
806
+ // Per-turn pipeline input: CLI capabilities and pending dir
807
+ const cliCapabilities = (0, channel_1.getChannelCapabilities)("cli");
808
+ const currentAgentName = (0, identity_1.getAgentName)();
809
+ const pendingDir = (0, pending_1.getPendingDir)(currentAgentName, friendId, "cli", "session");
810
+ const summarize = (0, core_1.createSummarize)();
811
+ try {
812
+ await runCliSession({
813
+ agentName: currentAgentName,
814
+ pasteDebounceMs,
815
+ messages: sessionMessages,
816
+ onAsyncAssistantMessage: async (messages, _assistantMessage) => {
817
+ (0, context_1.postTurn)(messages, sessPath, undefined, undefined, sessionState);
818
+ },
819
+ runTurn: async (messages, userInput, callbacks, signal, toolContext) => {
820
+ // Run the full per-turn pipeline: resolve -> gate -> session -> drain -> runAgent -> postTurn -> tokens
821
+ // User message passed via input.messages so the pipeline can prepend pending messages to it.
822
+ const result = await (0, pipeline_1.handleInboundTurn)({
823
+ channel: "cli",
824
+ sessionKey: "session",
825
+ capabilities: cliCapabilities,
826
+ messages: [{ role: "user", content: userInput }],
827
+ continuityIngressTexts: getCliContinuityIngressTexts(userInput),
828
+ callbacks,
829
+ friendResolver: { resolve: () => Promise.resolve(resolvedContext) },
830
+ sessionLoader: {
831
+ loadOrCreate: () => Promise.resolve({
832
+ messages,
833
+ sessionPath: sessPath,
834
+ state: sessionState,
835
+ }),
836
+ },
837
+ pendingDir,
838
+ friendStore,
839
+ provider: "local",
840
+ externalId: localExternalId,
841
+ enforceTrustGate: trust_gate_1.enforceTrustGate,
842
+ drainPending: pending_1.drainPending,
843
+ drainDeferredReturns: (deferredFriendId) => (0, pending_1.drainDeferredReturns)(currentAgentName, deferredFriendId),
844
+ runAgent: (msgs, cb, channel, sig, opts) => (0, core_1.runAgent)(msgs, cb, channel, sig, {
845
+ ...opts,
846
+ toolContext: {
847
+ /* v8 ignore next -- default no-op signin; pipeline provides the real one @preserve */
848
+ signin: async () => undefined,
849
+ ...opts?.toolContext,
850
+ summarize,
851
+ },
852
+ }),
853
+ postTurn: (turnMessages, sessionPathArg, usage, hooks, state) => {
854
+ (0, context_1.postTurn)(turnMessages, sessionPathArg, usage, hooks, state);
855
+ sessionState = state;
856
+ },
857
+ accumulateFriendTokens: tokens_1.accumulateFriendTokens,
858
+ signal,
859
+ runAgentOptions: {
860
+ toolChoiceRequired: (0, commands_1.getToolChoiceRequired)(),
861
+ traceId: (0, nerves_1.createTraceId)(),
862
+ mcpManager,
863
+ toolContext,
864
+ },
865
+ });
866
+ // Handle gate rejection: display auto-reply if present
867
+ if (!result.gateResult.allowed) {
868
+ if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
869
+ process.stdout.write(`${result.gateResult.autoReply}\n`);
870
+ }
871
+ }
872
+ return { usage: result.usage };
873
+ },
874
+ onNewSession: () => {
875
+ (0, context_1.deleteSession)(sessPath);
876
+ },
877
+ });
878
+ }
879
+ finally {
880
+ sessionLock?.release();
603
881
  }
604
882
  }