@ouro.bot/cli 0.1.0-alpha.9 → 0.1.0-alpha.90

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