@ouro.bot/cli 0.1.0-alpha.1 → 0.1.0-alpha.2

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.
@@ -48,6 +48,8 @@ const phrases_1 = require("../mind/phrases");
48
48
  const format_1 = require("../mind/format");
49
49
  const config_1 = require("../heart/config");
50
50
  const context_1 = require("../mind/context");
51
+ const pending_1 = require("../mind/pending");
52
+ const prompt_refresh_1 = require("../mind/prompt-refresh");
51
53
  const commands_1 = require("./commands");
52
54
  const identity_1 = require("../heart/identity");
53
55
  const nerves_1 = require("../nerves");
@@ -57,6 +59,7 @@ const tokens_1 = require("../mind/friends/tokens");
57
59
  const cli_logging_1 = require("../nerves/cli-logging");
58
60
  const runtime_1 = require("../nerves/runtime");
59
61
  const trust_gate_1 = require("./trust-gate");
62
+ const session_lock_1 = require("./session-lock");
60
63
  // spinner that only touches stderr, cleans up after itself
61
64
  // exported for direct testability (stop-without-start branch)
62
65
  class Spinner {
@@ -365,7 +368,10 @@ function createCliCallbacks() {
365
368
  },
366
369
  };
367
370
  }
368
- async function main() {
371
+ async function main(agentName, options) {
372
+ if (agentName)
373
+ (0, identity_1.setAgentName)(agentName);
374
+ const pasteDebounceMs = options?.pasteDebounceMs ?? 50;
369
375
  // Fail fast if provider is misconfigured (triggers human-readable error + exit)
370
376
  (0, core_1.getProvider)();
371
377
  const registry = (0, commands_1.createCommandRegistry)();
@@ -388,15 +394,50 @@ async function main() {
388
394
  signin: async () => undefined,
389
395
  context: resolvedContext,
390
396
  friendStore,
397
+ summarize: (0, core_1.createSummarize)(),
391
398
  };
392
399
  const friendId = resolvedContext.friend.id;
393
- (0, cli_logging_1.configureCliRuntimeLogger)(friendId);
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
+ });
394
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)());
409
+ }
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;
415
+ }
416
+ throw error;
417
+ /* v8 ignore stop */
418
+ }
395
419
  // Load existing session or start fresh
396
420
  const existing = (0, context_1.loadSession)(sessPath);
397
421
  const messages = existing?.messages && existing.messages.length > 0
398
422
  ? existing.messages
399
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);
440
+ }
400
441
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
401
442
  const ctrl = new InputController(rl);
402
443
  let currentAbort = null;
@@ -429,8 +470,40 @@ async function main() {
429
470
  rl.close();
430
471
  }
431
472
  });
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;
478
+ }
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
+ }
501
+ }
502
+ yield lines.join("\n");
503
+ }
504
+ }
432
505
  try {
433
- for await (const input of rl) {
506
+ for await (const input of debouncedLines(rl)) {
434
507
  if (closed)
435
508
  break;
436
509
  if (!input.trim()) {
@@ -477,12 +550,15 @@ async function main() {
477
550
  }
478
551
  }
479
552
  }
480
- // Re-style the echoed input line (readline terminal:true echoes it as "> input")
481
- // Calculate terminal rows the echo occupied (prompt "> " + input, wrapped)
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
482
555
  const cols = process.stdout.columns || 80;
483
- const echoLen = 2 + input.length; // "> " prefix + input
484
- const rows = Math.ceil(echoLen / cols);
485
- process.stdout.write(`\x1b[${rows}A\x1b[K` + `\x1b[1m> ${input}\x1b[0m\n\n`);
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`);
486
562
  messages.push({ role: "user", content: input });
487
563
  addHistory(history, input);
488
564
  currentAbort = new AbortController();
@@ -510,12 +586,17 @@ async function main() {
510
586
  process.stdout.write("\n\n");
511
587
  (0, context_1.postTurn)(messages, sessPath, result?.usage);
512
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);
513
593
  if (closed)
514
594
  break;
515
595
  process.stdout.write("\x1b[36m> \x1b[0m");
516
596
  }
517
597
  }
518
598
  finally {
599
+ sessionLock?.release();
519
600
  rl.close();
520
601
  // eslint-disable-next-line no-console -- terminal UX: goodbye
521
602
  console.log("bye");
@@ -194,6 +194,21 @@ async function runInnerDialogTurn(options) {
194
194
  const instinctPrompt = buildInstinctUserMessage(instincts, reason, state);
195
195
  messages.push({ role: "user", content: instinctPrompt });
196
196
  }
197
+ const inboxMessages = options?.drainInbox?.() ?? [];
198
+ if (inboxMessages.length > 0) {
199
+ const lastUserIdx = messages.length - 1;
200
+ const lastUser = messages[lastUserIdx];
201
+ /* v8 ignore next -- defensive: all code paths push a user message before here @preserve */
202
+ if (lastUser?.role === "user" && typeof lastUser.content === "string") {
203
+ const section = inboxMessages
204
+ .map((msg) => `- **${msg.from}**: ${msg.content}`)
205
+ .join("\n");
206
+ messages[lastUserIdx] = {
207
+ ...lastUser,
208
+ content: `${lastUser.content}\n\n## incoming messages\n${section}`,
209
+ };
210
+ }
211
+ }
197
212
  const callbacks = createInnerDialogCallbacks();
198
213
  const traceId = (0, nerves_1.createTraceId)();
199
214
  const result = await (0, core_1.runAgent)(messages, callbacks, "cli", options?.signal, {
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.SessionLockError = void 0;
37
+ exports.acquireSessionLock = acquireSessionLock;
38
+ const fs = __importStar(require("fs"));
39
+ const runtime_1 = require("../nerves/runtime");
40
+ class SessionLockError extends Error {
41
+ agentName;
42
+ constructor(agentName) {
43
+ super(`already chatting with ${agentName} in another terminal`);
44
+ this.name = "SessionLockError";
45
+ this.agentName = agentName;
46
+ }
47
+ }
48
+ exports.SessionLockError = SessionLockError;
49
+ function defaultIsProcessAlive(pid) {
50
+ try {
51
+ process.kill(pid, 0);
52
+ return true;
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ const defaultDeps = {
59
+ writeFileSync: (p, d, o) => fs.writeFileSync(p, d, o),
60
+ readFileSync: (p, e) => fs.readFileSync(p, e),
61
+ unlinkSync: (p) => fs.unlinkSync(p),
62
+ pid: process.pid,
63
+ isProcessAlive: defaultIsProcessAlive,
64
+ };
65
+ function acquireSessionLock(lockPath, agentName, deps = defaultDeps) {
66
+ const tryWrite = () => {
67
+ try {
68
+ deps.writeFileSync(lockPath, String(deps.pid), { flag: "wx" });
69
+ return true;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ };
75
+ if (!tryWrite()) {
76
+ // Lock file exists — check if stale
77
+ let existingPid;
78
+ try {
79
+ existingPid = parseInt(deps.readFileSync(lockPath, "utf-8").trim(), 10);
80
+ }
81
+ catch {
82
+ // Can't read lock file — try removing and retrying
83
+ try {
84
+ deps.unlinkSync(lockPath);
85
+ }
86
+ catch { /* ignore */ }
87
+ if (!tryWrite())
88
+ throw new SessionLockError(agentName);
89
+ return makeRelease(lockPath, deps);
90
+ }
91
+ if (Number.isNaN(existingPid) || !deps.isProcessAlive(existingPid)) {
92
+ // Stale lock — remove and retry
93
+ try {
94
+ deps.unlinkSync(lockPath);
95
+ }
96
+ catch { /* ignore */ }
97
+ if (!tryWrite())
98
+ throw new SessionLockError(agentName);
99
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.session_lock_stolen", message: "stole stale session lock from dead process", meta: { agentName, existingPid } });
100
+ return makeRelease(lockPath, deps);
101
+ }
102
+ throw new SessionLockError(agentName);
103
+ }
104
+ return makeRelease(lockPath, deps);
105
+ }
106
+ function makeRelease(lockPath, deps) {
107
+ let released = false;
108
+ return {
109
+ release: () => {
110
+ if (released)
111
+ return;
112
+ released = true;
113
+ try {
114
+ deps.unlinkSync(lockPath);
115
+ }
116
+ catch { /* ignore */ }
117
+ },
118
+ };
119
+ }
@@ -444,6 +444,7 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
444
444
  githubToken: teamsContext.githubToken,
445
445
  signin: teamsContext.signin,
446
446
  friendStore: store,
447
+ summarize: (0, core_1.createSummarize)(),
447
448
  } : undefined;
448
449
  if (toolContext) {
449
450
  const resolver = new resolver_1.FriendResolver(store, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.1",
3
+ "version": "0.1.0-alpha.2",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "ouro": "dist/heart/daemon/ouro-entry.js",
@@ -22,12 +22,14 @@ For tools that support skills but not Claude sub-agents, install these as skills
22
22
  ```bash
23
23
  mkdir -p ~/.codex/skills/work-planner ~/.codex/skills/work-doer ~/.codex/skills/work-merger
24
24
 
25
- # Recommended: hard-link to keep one source of truth and avoid stale copies
25
+ # Hard-link to keep one source of truth
26
26
  ln -f "$(pwd)/subagents/work-planner.md" ~/.codex/skills/work-planner/SKILL.md
27
27
  ln -f "$(pwd)/subagents/work-doer.md" ~/.codex/skills/work-doer/SKILL.md
28
28
  ln -f "$(pwd)/subagents/work-merger.md" ~/.codex/skills/work-merger/SKILL.md
29
29
  ```
30
30
 
31
+ **Important:** Hard-links break when editors save by replacing the file (new inode). After editing any `subagents/*.md` file, re-run the `ln -f` command for that file to restore the link. You can verify with `stat -f '%i'` — both files should share the same inode.
32
+
31
33
  Optional UI metadata:
32
34
 
33
35
  ```bash
@@ -327,12 +327,43 @@ done
327
327
  ### Step 4: Handle CI result
328
328
 
329
329
  **CI passes:**
330
- - Proceed to Step 5 (merge).
330
+ - Proceed to Step 5 (pre-merge sanity check).
331
331
 
332
332
  **CI fails:**
333
333
  - Proceed to **CI Failure Self-Repair**.
334
334
 
335
- ### Step 5: Merge the PR
335
+ ### Step 5: Pre-merge sanity check
336
+
337
+ Before merging, verify the PR delivers what the planning/doing doc intended. This is a lightweight review, not a full audit.
338
+
339
+ 1. Re-read the doing doc (already available from On Startup)
340
+ 2. Review the PR diff: `gh pr diff "${BRANCH}"`
341
+ 3. Check that:
342
+ - All completion criteria from the doing doc are addressed
343
+ - No unrelated changes slipped in
344
+ - The PR title and body accurately describe what shipped
345
+ 4. Post findings as a PR comment:
346
+
347
+ ```bash
348
+ gh pr comment "${BRANCH}" --body "$(cat <<'REVIEW'
349
+ ## Pre-merge sanity check
350
+
351
+ Checked PR against doing doc: `<doing-doc-path>`
352
+
353
+ - [ ] All completion criteria addressed
354
+ - [ ] No unrelated changes
355
+ - [ ] PR description accurate
356
+
357
+ <any notes or concerns>
358
+
359
+ Proceeding to merge.
360
+ REVIEW
361
+ )"
362
+ ```
363
+
364
+ If the check reveals a genuine gap (missing criteria, wrong files included), fix it before merging. If everything looks good, proceed to Step 6.
365
+
366
+ ### Step 6: Merge the PR
336
367
 
337
368
  ```bash
338
369
  gh pr merge "${BRANCH}" --merge --delete-branch