@ouro.bot/cli 0.1.0-alpha.3 → 0.1.0-alpha.31

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 (71) 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/AdoptionSpecialist.ouro/psyche/identities/python.md +30 -0
  5. package/assets/ouroboros.png +0 -0
  6. package/changelog.json +87 -0
  7. package/dist/heart/config.js +66 -4
  8. package/dist/heart/core.js +75 -2
  9. package/dist/heart/daemon/agent-discovery.js +81 -0
  10. package/dist/heart/daemon/daemon-cli.js +562 -64
  11. package/dist/heart/daemon/daemon-entry.js +14 -5
  12. package/dist/heart/daemon/daemon-runtime-sync.js +90 -0
  13. package/dist/heart/daemon/daemon.js +87 -9
  14. package/dist/heart/daemon/hatch-animation.js +35 -0
  15. package/dist/heart/daemon/hatch-flow.js +2 -11
  16. package/dist/heart/daemon/hatch-specialist.js +6 -1
  17. package/dist/heart/daemon/hooks/bundle-meta.js +92 -0
  18. package/dist/heart/daemon/launchd.js +134 -0
  19. package/dist/heart/daemon/ouro-bot-global-installer.js +128 -0
  20. package/dist/heart/daemon/ouro-bot-wrapper.js +4 -3
  21. package/dist/heart/daemon/ouro-path-installer.js +178 -0
  22. package/dist/heart/daemon/ouro-uti.js +11 -2
  23. package/dist/heart/daemon/process-manager.js +1 -1
  24. package/dist/heart/daemon/run-hooks.js +37 -0
  25. package/dist/heart/daemon/runtime-logging.js +9 -5
  26. package/dist/heart/daemon/runtime-metadata.js +118 -0
  27. package/dist/heart/daemon/sense-manager.js +266 -0
  28. package/dist/heart/daemon/specialist-orchestrator.js +129 -0
  29. package/dist/heart/daemon/specialist-prompt.js +98 -0
  30. package/dist/heart/daemon/specialist-tools.js +237 -0
  31. package/dist/heart/daemon/staged-restart.js +114 -0
  32. package/dist/heart/daemon/subagent-installer.js +10 -1
  33. package/dist/heart/daemon/update-checker.js +103 -0
  34. package/dist/heart/daemon/update-hooks.js +138 -0
  35. package/dist/heart/daemon/wrapper-publish-guard.js +86 -0
  36. package/dist/heart/identity.js +85 -1
  37. package/dist/heart/providers/anthropic.js +19 -2
  38. package/dist/heart/sense-truth.js +61 -0
  39. package/dist/heart/streaming.js +99 -21
  40. package/dist/mind/bundle-manifest.js +69 -0
  41. package/dist/mind/first-impressions.js +2 -1
  42. package/dist/mind/friends/channel.js +8 -0
  43. package/dist/mind/friends/types.js +1 -1
  44. package/dist/mind/phrases.js +1 -0
  45. package/dist/mind/prompt.js +94 -3
  46. package/dist/nerves/cli-logging.js +15 -2
  47. package/dist/repertoire/ado-client.js +4 -2
  48. package/dist/repertoire/coding/feedback.js +134 -0
  49. package/dist/repertoire/coding/index.js +4 -1
  50. package/dist/repertoire/coding/manager.js +61 -2
  51. package/dist/repertoire/coding/spawner.js +3 -3
  52. package/dist/repertoire/coding/tools.js +41 -2
  53. package/dist/repertoire/data/ado-endpoints.json +188 -0
  54. package/dist/repertoire/tools-base.js +69 -5
  55. package/dist/repertoire/tools-teams.js +57 -4
  56. package/dist/repertoire/tools.js +44 -11
  57. package/dist/senses/bluebubbles-client.js +434 -0
  58. package/dist/senses/bluebubbles-entry.js +11 -0
  59. package/dist/senses/bluebubbles-media.js +338 -0
  60. package/dist/senses/bluebubbles-model.js +251 -0
  61. package/dist/senses/bluebubbles-mutation-log.js +76 -0
  62. package/dist/senses/bluebubbles-session-cleanup.js +72 -0
  63. package/dist/senses/bluebubbles.js +449 -0
  64. package/dist/senses/cli.js +299 -133
  65. package/dist/senses/debug-activity.js +108 -0
  66. package/dist/senses/teams.js +173 -54
  67. package/package.json +15 -6
  68. package/subagents/work-doer.md +26 -24
  69. package/subagents/work-merger.md +24 -30
  70. package/subagents/work-planner.md +34 -25
  71. package/dist/inner-worker-entry.js +0 -4
@@ -38,6 +38,8 @@ exports.handleSigint = handleSigint;
38
38
  exports.addHistory = addHistory;
39
39
  exports.renderMarkdown = renderMarkdown;
40
40
  exports.createCliCallbacks = createCliCallbacks;
41
+ exports.createDebouncedLines = createDebouncedLines;
42
+ exports.runCliSession = runCliSession;
41
43
  exports.main = main;
42
44
  const readline = __importStar(require("readline"));
43
45
  const os = __importStar(require("os"));
@@ -60,6 +62,9 @@ const cli_logging_1 = require("../nerves/cli-logging");
60
62
  const runtime_1 = require("../nerves/runtime");
61
63
  const trust_gate_1 = require("./trust-gate");
62
64
  const session_lock_1 = require("./session-lock");
65
+ const update_hooks_1 = require("../heart/daemon/update-hooks");
66
+ const bundle_meta_1 = require("../heart/daemon/hooks/bundle-meta");
67
+ const bundle_manifest_1 = require("../mind/bundle-manifest");
63
68
  // spinner that only touches stderr, cleans up after itself
64
69
  // exported for direct testability (stop-without-start branch)
65
70
  class Spinner {
@@ -70,12 +75,14 @@ class Spinner {
70
75
  msg = "";
71
76
  phrases = null;
72
77
  lastPhrase = "";
78
+ stopped = false;
73
79
  constructor(m = "working", phrases) {
74
80
  this.msg = m;
75
81
  if (phrases && phrases.length > 0)
76
82
  this.phrases = phrases;
77
83
  }
78
84
  start() {
85
+ this.stopped = false;
79
86
  process.stderr.write("\r\x1b[K");
80
87
  this.spin();
81
88
  this.iv = setInterval(() => this.spin(), 80);
@@ -84,15 +91,23 @@ class Spinner {
84
91
  }
85
92
  }
86
93
  spin() {
87
- process.stderr.write(`\r${this.frames[this.i]} ${this.msg}... `);
94
+ // Guard: clearInterval can't prevent already-dequeued callbacks
95
+ /* v8 ignore next -- race guard: timer callback fires after stop() @preserve */
96
+ if (this.stopped)
97
+ return;
98
+ process.stderr.write(`\r\x1b[K${this.frames[this.i]} ${this.msg}... `);
88
99
  this.i = (this.i + 1) % this.frames.length;
89
100
  }
90
101
  rotatePhrase() {
102
+ /* v8 ignore next -- race guard: timer callback fires after stop() @preserve */
103
+ if (this.stopped)
104
+ return;
91
105
  const next = (0, phrases_1.pickPhrase)(this.phrases, this.lastPhrase);
92
106
  this.lastPhrase = next;
93
107
  this.msg = next;
94
108
  }
95
109
  stop(ok) {
110
+ this.stopped = true;
96
111
  if (this.iv) {
97
112
  clearInterval(this.iv);
98
113
  this.iv = null;
@@ -297,12 +312,25 @@ function createCliCallbacks() {
297
312
  currentSpinner.start();
298
313
  },
299
314
  onModelStreamStart: () => {
300
- currentSpinner?.stop();
301
- currentSpinner = null;
315
+ // No-op: content callbacks (onTextChunk, onReasoningChunk) handle
316
+ // stopping the spinner. onModelStreamStart fires too early and
317
+ // doesn't fire at all for final_answer tool streaming.
318
+ },
319
+ onClearText: () => {
320
+ streamer.reset();
302
321
  },
303
322
  onTextChunk: (text) => {
323
+ // Stop spinner if still running — final_answer streaming and Anthropic
324
+ // tool-only responses bypass onModelStreamStart, so the spinner would
325
+ // otherwise keep running (and its \r writes overwrite response text).
326
+ if (currentSpinner) {
327
+ currentSpinner.stop();
328
+ currentSpinner = null;
329
+ }
304
330
  if (hadReasoning) {
305
- process.stdout.write("\n\n");
331
+ // Single newline to separate reasoning from reply — reasoning
332
+ // output often ends with its own trailing newline(s)
333
+ process.stdout.write("\n");
306
334
  hadReasoning = false;
307
335
  }
308
336
  const rendered = streamer.push(text);
@@ -311,6 +339,10 @@ function createCliCallbacks() {
311
339
  textDirty = text.length > 0 && !text.endsWith("\n");
312
340
  },
313
341
  onReasoningChunk: (text) => {
342
+ if (currentSpinner) {
343
+ currentSpinner.stop();
344
+ currentSpinner = null;
345
+ }
314
346
  hadReasoning = true;
315
347
  process.stdout.write(`\x1b[2m${text}\x1b[0m`);
316
348
  textDirty = text.length > 0 && !text.endsWith("\n");
@@ -368,88 +400,86 @@ function createCliCallbacks() {
368
400
  },
369
401
  };
370
402
  }
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)());
403
+ // Debounced line iterator: collects rapid-fire lines (paste) into a single input.
404
+ // When the debounce timeout wins the race, the pending iter.next() is saved
405
+ // and reused in the next iteration to prevent it from silently consuming input.
406
+ async function* createDebouncedLines(source, debounceMs) {
407
+ if (debounceMs <= 0) {
408
+ yield* source;
409
+ return;
409
410
  }
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;
411
+ const iter = source[Symbol.asyncIterator]();
412
+ let pending = null;
413
+ while (true) {
414
+ const first = pending ? await pending : await iter.next();
415
+ pending = null;
416
+ if (first.done)
417
+ break;
418
+ const lines = [first.value];
419
+ let more = true;
420
+ while (more) {
421
+ const nextPromise = iter.next();
422
+ const raced = await Promise.race([
423
+ nextPromise.then((r) => ({ kind: "line", result: r })),
424
+ new Promise((r) => setTimeout(() => r({ kind: "timeout" }), debounceMs)),
425
+ ]);
426
+ if (raced.kind === "timeout") {
427
+ pending = nextPromise;
428
+ more = false;
429
+ }
430
+ else if (raced.result.done) {
431
+ more = false;
432
+ }
433
+ else {
434
+ lines.push(raced.result.value);
435
+ }
415
436
  }
416
- throw error;
417
- /* v8 ignore stop */
437
+ yield lines.join("\n");
418
438
  }
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);
439
+ }
440
+ async function runCliSession(options) {
441
+ /* v8 ignore start -- integration: runCliSession is interactive, tested via E2E @preserve */
442
+ const pasteDebounceMs = options.pasteDebounceMs ?? 50;
443
+ const registry = (0, commands_1.createCommandRegistry)();
444
+ if (!options.disableCommands) {
445
+ (0, commands_1.registerDefaultCommands)(registry);
440
446
  }
447
+ const messages = options.messages
448
+ ?? [{ role: "system", content: await (0, prompt_1.buildSystem)("cli") }];
441
449
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
442
450
  const ctrl = new InputController(rl);
443
451
  let currentAbort = null;
444
452
  const history = [];
445
453
  let closed = false;
446
454
  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`);
455
+ if (options.banner !== false) {
456
+ const bannerText = typeof options.banner === "string"
457
+ ? options.banner
458
+ : `${options.agentName} (type /commands for help)`;
459
+ // eslint-disable-next-line no-console -- terminal UX: startup banner
460
+ console.log(`\n${bannerText}\n`);
461
+ }
449
462
  const cliCallbacks = createCliCallbacks();
450
- process.stdout.write("\x1b[36m> \x1b[0m");
463
+ // exitOnToolCall machinery: wrap execTool to detect target tool
464
+ let exitToolResult;
465
+ let exitToolFired = false;
466
+ const resolvedExecTool = options.execTool;
467
+ const wrappedExecTool = options.exitOnToolCall && resolvedExecTool
468
+ ? async (name, args, ctx) => {
469
+ const result = await resolvedExecTool(name, args, ctx);
470
+ if (name === options.exitOnToolCall) {
471
+ exitToolResult = result;
472
+ exitToolFired = true;
473
+ // Abort immediately so the model doesn't generate more output
474
+ // (e.g. reasoning about calling final_answer after complete_adoption)
475
+ currentAbort?.abort();
476
+ }
477
+ return result;
478
+ }
479
+ : resolvedExecTool;
480
+ // Resolve toolChoiceRequired: use explicit option if set, else fall back to toggle
481
+ const getEffectiveToolChoiceRequired = () => options.toolChoiceRequired !== undefined ? options.toolChoiceRequired : (0, commands_1.getToolChoiceRequired)();
451
482
  // 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
483
  rl.on("SIGINT", () => {
454
484
  const rlInt = rl;
455
485
  const currentLine = rlInt.line || "";
@@ -470,38 +500,58 @@ async function main(agentName, options) {
470
500
  rl.close();
471
501
  }
472
502
  });
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;
503
+ const debouncedLines = (source) => createDebouncedLines(source, pasteDebounceMs);
504
+ (0, runtime_1.emitNervesEvent)({
505
+ component: "senses",
506
+ event: "senses.cli_session_start",
507
+ message: "runCliSession started",
508
+ meta: { agentName: options.agentName, hasExitOnToolCall: !!options.exitOnToolCall },
509
+ });
510
+ let exitReason = "user_quit";
511
+ // Auto-first-turn: process the last user message immediately so the agent
512
+ // speaks first (e.g. specialist greeting). Only triggers when explicitly opted in.
513
+ if (options.autoFirstTurn && messages.length > 0 && messages[messages.length - 1]?.role === "user") {
514
+ currentAbort = new AbortController();
515
+ const traceId = (0, nerves_1.createTraceId)();
516
+ ctrl.suppress(() => currentAbort.abort());
517
+ let result;
518
+ try {
519
+ result = await (0, core_1.runAgent)(messages, cliCallbacks, options.skipSystemPromptRefresh ? undefined : "cli", currentAbort.signal, {
520
+ toolChoiceRequired: getEffectiveToolChoiceRequired(),
521
+ traceId,
522
+ tools: options.tools,
523
+ execTool: wrappedExecTool,
524
+ toolContext: options.toolContext,
525
+ });
478
526
  }
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
- }
527
+ catch (err) {
528
+ // AbortError (Ctrl-C) -- silently continue to prompt
529
+ // All other errors: show the user what happened
530
+ if (!(err instanceof DOMException && err.name === "AbortError")) {
531
+ process.stderr.write(`\x1b[31m${err instanceof Error ? err.message : String(err)}\x1b[0m\n`);
532
+ }
533
+ }
534
+ cliCallbacks.flushMarkdown();
535
+ ctrl.restore();
536
+ currentAbort = null;
537
+ if (exitToolFired) {
538
+ exitReason = "tool_exit";
539
+ rl.close();
540
+ }
541
+ else {
542
+ const lastMsg = messages[messages.length - 1];
543
+ if (lastMsg?.role === "assistant" && !(typeof lastMsg.content === "string" ? lastMsg.content : "").trim()) {
544
+ process.stderr.write("\x1b[33m(empty response)\x1b[0m\n");
545
+ }
546
+ process.stdout.write("\n\n");
547
+ if (options.onTurnEnd) {
548
+ await options.onTurnEnd(messages, result ?? { usage: undefined });
501
549
  }
502
- yield lines.join("\n");
503
550
  }
504
551
  }
552
+ if (!exitToolFired) {
553
+ process.stdout.write("\x1b[36m> \x1b[0m");
554
+ }
505
555
  try {
506
556
  for await (const input of debouncedLines(rl)) {
507
557
  if (closed)
@@ -510,20 +560,18 @@ async function main(agentName, options) {
510
560
  process.stdout.write("\x1b[36m> \x1b[0m");
511
561
  continue;
512
562
  }
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`);
563
+ // Optional input gate (e.g. trust gate in main)
564
+ if (options.onInput) {
565
+ const gate = options.onInput(input);
566
+ if (!gate.allowed) {
567
+ if (gate.reply) {
568
+ process.stdout.write(`${gate.reply}\n`);
569
+ }
570
+ if (closed)
571
+ break;
572
+ process.stdout.write("\x1b[36m> \x1b[0m");
573
+ continue;
522
574
  }
523
- if (closed)
524
- break;
525
- process.stdout.write("\x1b[36m> \x1b[0m");
526
- continue;
527
575
  }
528
576
  // Check for slash commands
529
577
  const parsed = (0, commands_1.parseSlashCommand)(input);
@@ -536,7 +584,7 @@ async function main(agentName, options) {
536
584
  else if (dispatchResult.result.action === "new") {
537
585
  messages.length = 0;
538
586
  messages.push({ role: "system", content: await (0, prompt_1.buildSystem)("cli") });
539
- (0, context_1.deleteSession)(sessPath);
587
+ await options.onNewSession?.();
540
588
  // eslint-disable-next-line no-console -- terminal UX: session cleared
541
589
  console.log("session cleared");
542
590
  process.stdout.write("\x1b[36m> \x1b[0m");
@@ -550,13 +598,12 @@ async function main(agentName, options) {
550
598
  }
551
599
  }
552
600
  }
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
601
+ // Re-style the echoed input lines
555
602
  const cols = process.stdout.columns || 80;
556
603
  const inputLines = input.split("\n");
557
604
  let echoRows = 0;
558
605
  for (const line of inputLines) {
559
- echoRows += Math.ceil((2 + line.length) / cols); // "> " prefix + line content
606
+ echoRows += Math.ceil((2 + line.length) / cols);
560
607
  }
561
608
  process.stdout.write(`\x1b[${echoRows}A\x1b[K` + `\x1b[1m> ${inputLines[0]}${inputLines.length > 1 ? ` (+${inputLines.length - 1} lines)` : ""}\x1b[0m\n\n`);
562
609
  messages.push({ role: "user", content: input });
@@ -566,39 +613,158 @@ async function main(agentName, options) {
566
613
  ctrl.suppress(() => currentAbort.abort());
567
614
  let result;
568
615
  try {
569
- result = await (0, core_1.runAgent)(messages, cliCallbacks, "cli", currentAbort.signal, {
570
- toolChoiceRequired: (0, commands_1.getToolChoiceRequired)(),
571
- toolContext: cliToolContext,
616
+ result = await (0, core_1.runAgent)(messages, cliCallbacks, options.skipSystemPromptRefresh ? undefined : "cli", currentAbort.signal, {
617
+ toolChoiceRequired: getEffectiveToolChoiceRequired(),
572
618
  traceId,
619
+ tools: options.tools,
620
+ execTool: wrappedExecTool,
621
+ toolContext: options.toolContext,
573
622
  });
574
623
  }
575
- catch {
576
- // AbortError silently return to prompt
624
+ catch (err) {
625
+ // AbortError (Ctrl-C) -- silently return to prompt
626
+ // All other errors: show the user what happened
627
+ if (!(err instanceof DOMException && err.name === "AbortError")) {
628
+ process.stderr.write(`\x1b[31m${err instanceof Error ? err.message : String(err)}\x1b[0m\n`);
629
+ }
577
630
  }
578
631
  cliCallbacks.flushMarkdown();
579
632
  ctrl.restore();
580
633
  currentAbort = null;
634
+ // Check if exit tool was fired during this turn
635
+ if (exitToolFired) {
636
+ exitReason = "tool_exit";
637
+ break;
638
+ }
581
639
  // Safety net: never silently swallow an empty response
582
640
  const lastMsg = messages[messages.length - 1];
583
641
  if (lastMsg?.role === "assistant" && !(typeof lastMsg.content === "string" ? lastMsg.content : "").trim()) {
584
642
  process.stderr.write("\x1b[33m(empty response)\x1b[0m\n");
585
643
  }
586
644
  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);
645
+ // Post-turn hook (session persistence, pending drain, prompt refresh, etc.)
646
+ if (options.onTurnEnd) {
647
+ await options.onTurnEnd(messages, result ?? { usage: undefined });
648
+ }
593
649
  if (closed)
594
650
  break;
595
651
  process.stdout.write("\x1b[36m> \x1b[0m");
596
652
  }
597
653
  }
598
654
  finally {
599
- sessionLock?.release();
600
655
  rl.close();
601
- // eslint-disable-next-line no-console -- terminal UX: goodbye
602
- console.log("bye");
656
+ if (options.banner !== false) {
657
+ // eslint-disable-next-line no-console -- terminal UX: goodbye
658
+ console.log("bye");
659
+ }
660
+ }
661
+ /* v8 ignore stop */
662
+ return { exitReason, toolResult: exitToolResult };
663
+ }
664
+ async function main(agentName, options) {
665
+ if (agentName)
666
+ (0, identity_1.setAgentName)(agentName);
667
+ const pasteDebounceMs = options?.pasteDebounceMs ?? 50;
668
+ // Fallback: apply pending updates for daemon-less direct CLI usage
669
+ (0, update_hooks_1.registerUpdateHook)(bundle_meta_1.bundleMetaHook);
670
+ await (0, update_hooks_1.applyPendingUpdates)((0, identity_1.getAgentBundlesRoot)(), (0, bundle_manifest_1.getPackageVersion)());
671
+ // Fail fast if provider is misconfigured (triggers human-readable error + exit)
672
+ (0, core_1.getProvider)();
673
+ // Resolve context kernel (identity + channel) for CLI
674
+ const friendsPath = path.join((0, identity_1.getAgentRoot)(), "friends");
675
+ const friendStore = new store_file_1.FileFriendStore(friendsPath);
676
+ const username = os.userInfo().username;
677
+ const hostname = os.hostname();
678
+ const localExternalId = `${username}@${hostname}`;
679
+ const resolver = new resolver_1.FriendResolver(friendStore, {
680
+ provider: "local",
681
+ externalId: localExternalId,
682
+ displayName: username,
683
+ channel: "cli",
684
+ });
685
+ const resolvedContext = await resolver.resolve();
686
+ const cliToolContext = {
687
+ /* v8 ignore next -- CLI has no OAuth sign-in; this no-op satisfies the interface @preserve */
688
+ signin: async () => undefined,
689
+ context: resolvedContext,
690
+ friendStore,
691
+ summarize: (0, core_1.createSummarize)(),
692
+ };
693
+ const friendId = resolvedContext.friend.id;
694
+ const agentConfig = (0, identity_1.loadAgentConfig)();
695
+ (0, cli_logging_1.configureCliRuntimeLogger)(friendId, {
696
+ level: agentConfig.logging?.level,
697
+ sinks: agentConfig.logging?.sinks,
698
+ });
699
+ const sessPath = (0, config_1.sessionPath)(friendId, "cli", "session");
700
+ let sessionLock = null;
701
+ try {
702
+ sessionLock = (0, session_lock_1.acquireSessionLock)(`${sessPath}.lock`, (0, identity_1.getAgentName)());
703
+ }
704
+ catch (error) {
705
+ /* v8 ignore start -- integration: main() is interactive, lock tested in session-lock.test.ts @preserve */
706
+ if (error instanceof session_lock_1.SessionLockError) {
707
+ process.stderr.write(`${error.message}\n`);
708
+ return;
709
+ }
710
+ throw error;
711
+ /* v8 ignore stop */
712
+ }
713
+ // Load existing session or start fresh
714
+ const existing = (0, context_1.loadSession)(sessPath);
715
+ const sessionMessages = existing?.messages && existing.messages.length > 0
716
+ ? existing.messages
717
+ : [{ role: "system", content: await (0, prompt_1.buildSystem)("cli", undefined, resolvedContext) }];
718
+ // Pending queue drain: inject pending messages as harness-context + assistant-content pairs
719
+ const pendingDir = (0, pending_1.getPendingDir)((0, identity_1.getAgentName)(), friendId, "cli", "session");
720
+ const drainToMessages = () => {
721
+ const pending = (0, pending_1.drainPending)(pendingDir);
722
+ if (pending.length === 0)
723
+ return 0;
724
+ for (const msg of pending) {
725
+ sessionMessages.push({ role: "user", name: "harness", content: `[proactive message from ${msg.from}]` });
726
+ sessionMessages.push({ role: "assistant", content: msg.content });
727
+ }
728
+ return pending.length;
729
+ };
730
+ // Startup drain: deliver offline messages
731
+ const startupCount = drainToMessages();
732
+ if (startupCount > 0) {
733
+ (0, context_1.saveSession)(sessPath, sessionMessages);
734
+ }
735
+ try {
736
+ await runCliSession({
737
+ agentName: (0, identity_1.getAgentName)(),
738
+ pasteDebounceMs,
739
+ messages: sessionMessages,
740
+ toolContext: cliToolContext,
741
+ onInput: () => {
742
+ const trustGate = (0, trust_gate_1.enforceTrustGate)({
743
+ friend: resolvedContext.friend,
744
+ provider: "local",
745
+ externalId: localExternalId,
746
+ channel: "cli",
747
+ });
748
+ if (!trustGate.allowed) {
749
+ return {
750
+ allowed: false,
751
+ reply: trustGate.reason === "stranger_first_reply" ? trustGate.autoReply : undefined,
752
+ };
753
+ }
754
+ return { allowed: true };
755
+ },
756
+ onTurnEnd: async (msgs, result) => {
757
+ (0, context_1.postTurn)(msgs, sessPath, result.usage);
758
+ await (0, tokens_1.accumulateFriendTokens)(friendStore, resolvedContext.friend.id, result.usage);
759
+ drainToMessages();
760
+ await (0, prompt_refresh_1.refreshSystemPrompt)(msgs, "cli", undefined, resolvedContext);
761
+ },
762
+ onNewSession: () => {
763
+ (0, context_1.deleteSession)(sessPath);
764
+ },
765
+ });
766
+ }
767
+ finally {
768
+ sessionLock?.release();
603
769
  }
604
770
  }