@ouro.bot/cli 0.1.0-alpha.7 → 0.1.0-alpha.71

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