@assuredloop/orca-bot-kit 0.25.2 → 0.25.4

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.
package/src/message.js CHANGED
@@ -1,4 +1,4 @@
1
- // `obk message`: one session writing to another (PRD 6.9, ADR 0030).
1
+ // `obk message`: one session writing to another (PRD 6.9, ADR 0035).
2
2
  //
3
3
  // Two roads, and the bot never picks. Claude to Claude in the same approval
4
4
  // class is the harness's own messaging, which no command line can send for it —
@@ -11,18 +11,23 @@
11
11
  // and the restart, so a session's address is its Run (tech notes, section 1).
12
12
  //
13
13
  // Nothing in a mailbox wakes the session it belongs to — a message addressed to
14
- // a tab leaves the harness in it untouched, proved live — so a send also types
15
- // one line into the receiver's tab. Both harnesses queue a typed line while
16
- // they are busy, which is what PRD 6.9 means by queued and not interrupting.
14
+ // a tab leaves the harness in it untouched, proved live — so each message gets
15
+ // one signal that tells the receiver to look: Orca's own notice first, and the
16
+ // kit's typed line only where that did not come (#509, ADR 0035). A busy
17
+ // Claude session gets nothing typed, since Claude Code holds a line typed into
18
+ // it until its turn ends; its own turn-end hook tells it instead.
17
19
 
18
20
  import { mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
19
21
  import os from 'node:os';
20
22
  import path from 'node:path';
23
+ import { setTimeout as pause } from 'node:timers/promises';
21
24
 
22
25
  import { MAILBOX_WAIT_MS, readBook, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
23
26
  import { botDir, botNames, readBot } from './bot.js';
27
+ import { recordMark, userTurnSince } from './conversations.js';
24
28
  import { harnessOf, isAddressOf, ownCli, reachesMail, SHELL_ENV, shellWord } from './launch.js';
25
- import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, typeIntoTab, useMailbox } from './orca.js';
29
+ import { ackMailbox, coordinatorOf, idleNow, postMessage, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, typeIntoTab, useMailbox } from './orca.js';
30
+ import { forgetUnread, markTold, noteUnread, unreadOf } from './unread.js';
26
31
 
27
32
  /**
28
33
  * How much of a message travels as itself. Above this it is written to a file
@@ -74,6 +79,9 @@ export function findSession(bots, target) {
74
79
  address: isAddressOf(bot.name, session.name, held.address) ? held.address : undefined,
75
80
  mailbox: typeof held.mailbox === 'string' ? held.mailbox : undefined,
76
81
  tab: typeof held.tab === 'string' ? held.tab : undefined,
82
+ // The conversation the book holds for it, whose record says whether Orca's
83
+ // notice reached it (#509).
84
+ conversation: typeof held.session === 'string' ? held.session : undefined,
77
85
  trouble: whyNotReachable(bot.name, session, harness, held),
78
86
  };
79
87
  }
@@ -125,7 +133,7 @@ export function lookUp(bots, { to: target, from: sender, tab }) {
125
133
  * own message. The kit says which address to write to instead, and sends
126
134
  * nothing, which is the whole of "the bot never picks the transport".
127
135
  */
128
- export function sendMessage(bots, { to: target, from: sender, tab, subject, text, textFile, thread }) {
136
+ export async function sendMessage(bots, { to: target, from: sender, tab, subject, text, textFile, thread }) {
129
137
  const from = whoIsWriting(bots, sender, tab);
130
138
  const to = findSession(bots, target);
131
139
  const road = roadBetween(from, to);
@@ -159,6 +167,9 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
159
167
  }
160
168
 
161
169
  const written = bodyOf(bots, { from, to, subject, text, textFile });
170
+ // The receiver's record as it stands before the mail: Orca can type its
171
+ // notice the moment the mail is in.
172
+ const mark = recordMark(to.harness, to.home, to.conversation);
162
173
  const message = postMessage({
163
174
  to: road.address,
164
175
  from: `run:${from.mailbox}`,
@@ -166,6 +177,7 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
166
177
  body: written.body,
167
178
  thread,
168
179
  });
180
+ noteUnread(to.home, to.session, { id: message.id, from: `${from.bot}/${from.session}`, subject, at: message.created_at ?? new Date().toISOString() });
169
181
 
170
182
  return {
171
183
  ...answer,
@@ -173,7 +185,7 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
173
185
  id: message.id,
174
186
  thread: message.thread_id ?? thread,
175
187
  file: written.file,
176
- ...nudge(to, from, subject, tab),
188
+ ...(await nudge(to, from, subject, tab, mark)),
177
189
  };
178
190
  }
179
191
 
@@ -207,7 +219,11 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
207
219
  };
208
220
  }
209
221
  try {
210
- return mailOf(bots, findSession(bots, `${asked.bot}/${asked.session}`), peek, Date.now() + HOLD_MS);
222
+ const who = findSession(bots, `${asked.bot}/${asked.session}`);
223
+ const read = mailOf(bots, who, peek, Date.now() + HOLD_MS);
224
+ // What was read is out of the kit's hint of unread mail (#509); a peek reads nothing.
225
+ if (!peek && read.messages.length > 0) forgetUnread(who.home, who.session, read.messages.map((message) => message.id));
226
+ return read;
211
227
  } finally {
212
228
  turn.release();
213
229
  }
@@ -430,32 +446,135 @@ function readText(file) {
430
446
  const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '-');
431
447
 
432
448
  /**
433
- * Tell the receiver's tab that mail is waiting: one line, typed in.
449
+ * Tell the receiver that mail is waiting, with one signal (#509, ADR 0035).
434
450
  *
435
- * Nothing in the mailbox reaches a running harness by itself, and a typed line
436
- * is taken as the next turn by a busy session rather than cutting into the one
437
- * it is having. A tab with no harness in it is not typed into at all — there is
438
- * nobody there to read it, and the message waits in the mailbox until the
439
- * session is up. Nor is one the kit cannot tell about. A tab the book does not
440
- * hold is never typed into on any road.
451
+ * Orca's own notice is the first signal: it types it into the receiver's tab
452
+ * once the tab is at rest. The kit's line is the fallback, for a receiver the
453
+ * notice did not reach. So an idle receiver is watched for up to WATCH_MS, and
454
+ * its own record of its turns says whether the notice came: a turn of the
455
+ * user's, written after the send, that names its mailbox. Then nothing is
456
+ * typed. When no turn started, the kit types its line, which an idle harness
457
+ * takes at once. A Claude session busy with a turn, from the first look or
458
+ * from a turn of other work in the watch, gets nothing typed: Claude Code
459
+ * holds a line typed during a turn in its input box until the turn ends,
460
+ * which can be after the mail was read, or never, and the session's own
461
+ * turn-end hook tells it about mail still unread. A busy Codex session gets the
462
+ * line: Codex takes it into the running turn at once, and it is what ends
463
+ * Codex's sleep tool (#432). A record that cannot be found or read is a notice
464
+ * not seen.
441
465
  *
442
- * Nor is a tab with something on screen waiting to be answered. A line typed
466
+ * Nothing is typed into a tab with no harness in it — there is nobody there to
467
+ * read it, and the message waits in the mailbox until the session is up. Nor
468
+ * into one the kit cannot tell about. A tab the book does not hold is never
469
+ * typed into on any road.
470
+ *
471
+ * Nor into a tab with something on screen waiting to be answered. A line typed
443
472
  * into one of those is not a message: it is an answer to whatever question is
444
473
  * up. That is not a worry, it is a thing that happened — in slice 03 a second
445
474
  * line went into a tab on Claude Code's folder-trust list, confirmed its
446
475
  * default, `No, exit`, and the harness quit (tech notes, section 1). And in
447
476
  * #329 a return took Codex's "Update now". So where Orca says a tab is
448
477
  * blocked, or the kit sees a numbered choice list of the harness's own on its
449
- * screen, nothing is typed and the mail waits.
478
+ * screen, nothing is typed and the mail waits. The gate is passed again right
479
+ * before the line, after the watch.
450
480
  *
451
481
  * That narrows the case rather than closing it: a question drawn any other way,
452
482
  * such as Claude Code's unnumbered trust list, is seen by neither. What closes
453
483
  * it is nobody sending to a session before its first screens are answered,
454
484
  * which is the caller's work either way.
485
+ *
486
+ * What is left open: a turn that starts between the gate's last look and the
487
+ * line meets the line, which then waits for that turn to end.
455
488
  */
456
- function nudge(to, from, subject, tab) {
489
+ async function nudge(to, from, subject, tab, mark = recordMark(to.harness, to.home, to.conversation)) {
457
490
  if (to.tab === undefined) return { nudged: false };
491
+ const notice = `orchestration check --run ${to.mailbox}`;
492
+
493
+ const first = atTheGate(to, from, subject, tab, mark, (found) => (found.idle ? { watch: found.handle } : { busy: true }));
494
+ if (first.busy) {
495
+ // Busy at the first look may be Orca's notice, typed the moment the mail
496
+ // was in: an idle Codex took it as a turn before the kit looked, live.
497
+ if (await noticeShows(mark, notice)) return { nudged: false, signal: 'orca' };
498
+ if (to.harness === 'claude') return { nudged: false, signal: 'hook', because: 'busy' };
499
+ return atTheGate(to, from, subject, tab, mark, (found) => (userTurnSince(mark, notice)
500
+ ? { nudged: false, signal: 'orca' }
501
+ : line(found.handle, to, from, subject, 'busy')));
502
+ }
503
+ if (first.watch === undefined) return first;
504
+
505
+ const watched = await watch(first.watch, mark, notice);
506
+ const watchedMs = watched.ms;
507
+ if (watched.notice) return { nudged: false, signal: 'orca', watchedMs };
508
+ if (watched.turn && to.harness === 'claude') return { nudged: false, signal: 'hook', because: 'other-turn', watchedMs };
509
+
510
+ const last = atTheGate(to, from, subject, tab, mark, (found) => {
511
+ // What came while the gate looked, said as it was.
512
+ if (userTurnSince(mark, notice)) return { nudged: false, signal: 'orca' };
513
+ const because = watched.turn || !found.idle ? 'other-turn' : 'no-turn';
514
+ if (!found.idle && to.harness === 'claude') return { nudged: false, signal: 'hook', because };
515
+ return line(found.handle, to, from, subject, because);
516
+ });
517
+ return { ...last, watchedMs };
518
+ }
519
+
520
+ /**
521
+ * How long an idle receiver is watched for Orca's notice before the kit types
522
+ * its line, the architect's ruling for #509. The send returns as soon as the
523
+ * notice shows, so only a notice that did not come costs the whole of it.
524
+ */
525
+ export const WATCH_MS = 8000;
526
+
527
+ /** How long one look in the watch asks Orca to wait for an idle tab, and the pause after an idle one. */
528
+ const WATCH_LOOK_MS = 500;
529
+
530
+ /**
531
+ * How long the record is read once a turn has started, for the notice that
532
+ * started it: the harness writes the turn as it takes it, and a look can see
533
+ * the turn a moment before the record does.
534
+ */
535
+ const RECORD_GRACE_MS = 1500;
536
+
537
+ /** How often the record is read in that time. */
538
+ const RECORD_ASK_MS = 250;
539
+
540
+ /**
541
+ * Whether the receiver's record shows Orca's notice within RECORD_GRACE_MS, for
542
+ * a tab seen busy: the most a send to a receiver busy with other work waits.
543
+ */
544
+ async function noticeShows(mark, notice) {
545
+ for (const end = Date.now() + RECORD_GRACE_MS; ; await pause(RECORD_ASK_MS)) {
546
+ if (userTurnSince(mark, notice)) return true;
547
+ if (Date.now() >= end) return false;
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Watch an idle receiver for up to WATCH_MS: `{ notice: true }` once its record
553
+ * shows Orca's notice, `{ turn: true }` once a turn started that the record
554
+ * does not show as the notice, or `{}` when it stayed at rest; with `ms`, how
555
+ * long it watched. A look Orca does not answer says nothing, and the watch
556
+ * goes on to its end.
557
+ */
558
+ async function watch(handle, mark, notice) {
559
+ const started = Date.now();
560
+ const until = started + WATCH_MS;
561
+ const ms = () => Date.now() - started;
562
+ for (;;) {
563
+ if (userTurnSince(mark, notice)) return { notice: true, ms: ms() };
564
+ const left = until - Date.now();
565
+ if (left <= 0) return { ms: ms() };
566
+ const seen = idleNow(handle, Math.min(WATCH_LOOK_MS, left));
567
+ if (seen === 'busy') return (await noticeShows(mark, notice)) ? { notice: true, ms: ms() } : { turn: true, ms: ms() };
568
+ if (seen === 'idle') await pause(Math.min(WATCH_LOOK_MS, Math.max(until - Date.now(), 0)));
569
+ }
570
+ }
458
571
 
572
+ /**
573
+ * Look at the receiver's tab through the gate every typed line goes through,
574
+ * holding its turn for a line, and answer what `then` makes of a tab that
575
+ * passed; a tab that did not pass is answered as it was before #509.
576
+ */
577
+ function atTheGate(to, from, subject, tab, mark, then) {
459
578
  // A line typed while the kit types a command one key at a time would land
460
579
  // in it and send it with its own return (#480): so the receiver's turn for
461
580
  // a line first, for a bounded time, and the mail waits in its mailbox if not.
@@ -472,22 +591,9 @@ function nudge(to, from, subject, tab) {
472
591
  const found = lookAt(to);
473
592
  if (found.blocked !== undefined) return { nudged: false, blocked: found.blocked };
474
593
  // A line that lands in a shell is run there, with the sender's subject in it.
475
- if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure, ...(found.psUnread ? leftForHook(to, from, subject, tab) : {}) };
594
+ if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure, ...(found.psUnread ? leftForHook(to, from, subject, tab, mark) : {}) };
476
595
  if (found.handle === undefined) return { nudged: false };
477
-
478
- const sent = typeIntoTab(
479
- found.handle,
480
- `Fleet mail from ${from.bot}/${from.session}: ${subject}. Read it with ${shellWord(ownCli())} message check --bots ${shellWord(to.bots)} --bot ${shellWord(to.bot)} --session ${shellWord(to.session)}`,
481
- { watch: true },
482
- );
483
- // Typed is not taken. A harness busy with a turn queues the line and gives
484
- // it no turn of its own, and a line can be lost, and Orca's receipt looks
485
- // the same for both: only a turn start it saw says the line landed (#394).
486
- if (sent?.send?.prompt?.stages?.includes('turn_started')) return { nudged: true };
487
- // Where Orca did not watch the line at all, as when its own notice has
488
- // just started a turn in the tab, it saw nothing either way, and says so.
489
- if (sent?.send?.prompt?.observation === 'unsupported') return { nudged: true, nudgeUnseen: unseen(sent), nudgeWatched: false };
490
- return { nudged: true, nudgeUnseen: unseen(sent) };
596
+ return then(found);
491
597
  } catch (error) {
492
598
  // The message is already queued, and it is waiting whatever Orca says
493
599
  // about the tab. So this is reported rather than thrown: a send that ends
@@ -500,6 +606,24 @@ function nudge(to, from, subject, tab) {
500
606
  }
501
607
  }
502
608
 
609
+ /** Type the kit's line into the receiver's tab, and say what Orca saw of it. */
610
+ function line(handle, to, from, subject, because) {
611
+ const sent = typeIntoTab(
612
+ handle,
613
+ `Fleet mail from ${from.bot}/${from.session}: ${subject}. Read it with ${shellWord(ownCli())} message check --bots ${shellWord(to.bots)} --bot ${shellWord(to.bot)} --session ${shellWord(to.session)}`,
614
+ { watch: true },
615
+ );
616
+ const said = { nudged: true, signal: 'line', because };
617
+ // Typed is not taken. A harness busy with a turn queues the line and gives
618
+ // it no turn of its own, and a line can be lost, and Orca's receipt looks
619
+ // the same for both: only a turn start it saw says the line landed (#394).
620
+ if (sent?.send?.prompt?.stages?.includes('turn_started')) return said;
621
+ // Where Orca did not watch the line at all, as when its own notice has
622
+ // just started a turn in the tab, it saw nothing either way, and says so.
623
+ if (sent?.send?.prompt?.observation === 'unsupported') return { ...said, nudgeUnseen: unseen(sent), nudgeWatched: false };
624
+ return { ...said, nudgeUnseen: unseen(sent) };
625
+ }
626
+
503
627
  /**
504
628
  * The nudge a Codex sender could not decide, left for its own hook (#350, ADR
505
629
  * 0034): `{ nudgeLeft: true }`, or nothing when it is not left.
@@ -514,12 +638,15 @@ function nudge(to, from, subject, tab) {
514
638
  * the receiver's tab: anywhere else no hook would see it, or would see no more
515
639
  * than the send did.
516
640
  */
517
- function leftForHook(to, from, subject, tab) {
641
+ function leftForHook(to, from, subject, tab, mark) {
518
642
  if (tab === undefined || from.tab !== tab || from.harness !== 'codex') return {};
519
643
  try {
520
644
  const dir = leftDir(tab);
521
645
  mkdirSync(dir, { recursive: true });
522
- const left = { bots: to.bots, to: `${to.bot}/${to.session}`, from: `${from.bot}/${from.session}`, subject };
646
+ // The receiver's record as it stood before the post, so the hook reads
647
+ // what came after the post and not after itself (#509 review): Orca's
648
+ // notice can land before the hook runs. None stays none.
649
+ const left = { bots: to.bots, to: `${to.bot}/${to.session}`, from: `${from.bot}/${from.session}`, subject, mark: mark ?? null };
523
650
  // Written under a name the hook does not take, then put in place whole: a
524
651
  // hook running beside this send never takes a file still being written.
525
652
  const name = path.join(dir, `${stamp()}.${process.pid}`);
@@ -541,7 +668,7 @@ const leftDir = (tab) => path.join(os.tmpdir(), 'obk-nudges', encodeURIComponent
541
668
  * once, by one hook. Returns `{ to, subject, ...what the nudge came to }` for
542
669
  * each, as the send's own answer says it.
543
670
  */
544
- export function decideLeftNudges(tab) {
671
+ export async function decideLeftNudges(tab) {
545
672
  if (tab === undefined) return [];
546
673
  const dir = leftDir(tab);
547
674
  let names;
@@ -550,30 +677,80 @@ export function decideLeftNudges(tab) {
550
677
  } catch {
551
678
  return [];
552
679
  }
553
- return names.flatMap((name) => {
680
+ const decided = [];
681
+ for (const name of names) {
554
682
  const taking = path.join(dir, `${name}.taking`);
555
683
  // Another hook took it first: it is that hook's to decide, and to remove.
556
684
  try {
557
685
  renameSync(path.join(dir, name), taking);
558
686
  } catch {
559
- return [];
687
+ continue;
560
688
  }
561
689
  let left;
562
690
  try {
563
691
  left = JSON.parse(readFileSync(taking, 'utf8'));
564
692
  } catch {
565
- return [];
693
+ continue;
566
694
  } finally {
567
695
  rmSync(taking, { force: true });
568
696
  }
569
697
  try {
570
698
  const to = findSession(left.bots, left.to);
571
699
  const from = findSession(left.bots, left.from);
572
- return [{ to: left.to, subject: left.subject, ...nudge(to, from, left.subject) }];
700
+ decided.push({ to: left.to, subject: left.subject, ...(await nudge(to, from, left.subject, undefined, left.mark ?? null)) });
573
701
  } catch (error) {
574
- return [{ to: left.to, subject: left.subject, nudged: false, nudgeTrouble: error.message }];
702
+ decided.push({ to: left.to, subject: left.subject, nudged: false, nudgeTrouble: error.message });
575
703
  }
576
- });
704
+ }
705
+ return decided;
706
+ }
707
+
708
+ /**
709
+ * What the kit's Claude Code hook says at the end of a session's turn (#509,
710
+ * ADR 0035): the reason the turn goes on with, naming each message the kit sent
711
+ * this session that Orca still lists as unread, once for each; or undefined,
712
+ * and the turn ends as it would have.
713
+ *
714
+ * It must never hold a session. So it says nothing when Claude Code says a stop
715
+ * hook is already active, when the kit's hint holds nothing new for the
716
+ * session (and then it asks Orca nothing), when it cannot ask Orca or read the
717
+ * hint, and when it cannot first mark what it tells as told: a message is told
718
+ * once, or not at all. It reads the mailbox only by a peek, as the session's
719
+ * own tab, so nothing is taken as read.
720
+ */
721
+ export function stillUnread(bots, bot, said, tab, handle) {
722
+ if (said?.hook_event_name !== 'Stop' || said.stop_hook_active === true) return undefined;
723
+ if (tab === undefined || handle === undefined) return undefined;
724
+ const who = sessionInTab(bots, tab);
725
+ if (who === undefined || who.bot !== bot || who.mailbox === undefined) return undefined;
726
+ // Only the conversation the book holds for this tab: a harness started
727
+ // inside the session runs the same hook in the same tab, and would take the
728
+ // session's one telling (#509 review), as the naming hook guards (#480).
729
+ if (typeof said.session_id !== 'string' || said.session_id !== who.conversation) return undefined;
730
+ const untold = unreadOf(who.home, who.session).filter((entry) => !entry.told);
731
+ if (untold.length === 0) return undefined;
732
+
733
+ const found = readMailbox(who.mailbox, { peek: true, handle }, { timeoutMs: HOOK_ASK_MS });
734
+ const waiting = new Map((found.messages ?? []).map((message) => [message.id, message]));
735
+ const tell = untold.filter((entry) => waiting.has(entry.id));
736
+ if (tell.length === 0) return undefined;
737
+ markTold(who.home, who.session, tell.map((entry) => entry.id));
738
+
739
+ const lines = tell.map((entry) => `- from ${entry.from}: "${entry.subject}", which came at ${clockOf(waiting.get(entry.id).created_at ?? entry.at)}`);
740
+ return [
741
+ `${tell.length === 1 ? 'A fleet mail sent to you is' : `${tell.length} fleet mails sent to you are`} still unread. This is not new mail; it is told once:`,
742
+ ...lines,
743
+ `Read it with ${shellWord(ownCli())} message check --bots ${shellWord(who.bots)} --bot ${shellWord(who.bot)} --session ${shellWord(who.session)}`,
744
+ ].join('\n');
745
+ }
746
+
747
+ /** How long the turn-end hook gives Orca, well inside the 30 s the harness gives the hook. */
748
+ const HOOK_ASK_MS = 10_000;
749
+
750
+ /** A time as a clock reads it, in UTC; what cannot be read is said as it is. */
751
+ function clockOf(at) {
752
+ const when = new Date(typeof at === 'string' && /^\d{4}-\d\d-\d\d \d/.test(at) ? `${at.replace(' ', 'T')}Z` : at);
753
+ return Number.isNaN(when.getTime()) ? String(at) : `${when.toISOString().slice(11, 16)} UTC`;
577
754
  }
578
755
 
579
756
  /** Orca's words about a line it did not see start a turn, or what its receipt says when it gives none. */
package/src/name.js CHANGED
@@ -125,12 +125,13 @@ function nameOf(thread) {
125
125
  * pointer and the command exactly, and no row of its slash menu stands above
126
126
  * it. With words after the command Codex has closed its menu (chat_composer.rs
127
127
  * `sync_command_popup`, rust-v0.160.0), so a menu still open means the line is
128
- * not what it seems.
128
+ * not what it seems. Where Orca gives a draft, the draft is the line's text:
129
+ * Codex 0.162.0's line on the screen then reads its pointer alone (#516).
129
130
  */
130
- function renameWrong(rows, _harness, command) {
131
+ function renameWrong(rows, _harness, command, _version, draft) {
131
132
  const at = rows.findLastIndex((row) => /^ *›/.test(row));
132
133
  if (at < 0) return { why: 'its screen shows no input line' };
133
- const line = rows[at].replaceAll(' ', ' ').trim();
134
+ const line = draft === undefined ? rows[at].replaceAll(' ', ' ').trim() : `› ${draft}`;
134
135
  if (line !== `› ${command}`) return { why: `its input line reads "${line}"` };
135
136
  const above = rows.slice(0, at).findLast((row) => row.trim() !== '');
136
137
  if (above !== undefined && /^ *(?:› +)?\/\S/.test(above)) return { why: `a menu of commands stands above its input line: "${above.trim()}"` };
package/src/orca.js CHANGED
@@ -8,9 +8,10 @@
8
8
  // handed to Orca on the command line.
9
9
 
10
10
  import { spawnSync } from 'node:child_process';
11
- import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
12
- import { homedir } from 'node:os';
11
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
12
+ import { homedir, tmpdir } from 'node:os';
13
13
  import path from 'node:path';
14
+ import { DatabaseSync } from 'node:sqlite';
14
15
  import { fileURLToPath } from 'node:url';
15
16
 
16
17
  import { SHELL_ENV } from './launch.js';
@@ -135,7 +136,14 @@ export const profilesDir = () => path.join(homedir(), 'Library', 'Application Su
135
136
  *
136
137
  * Orca adds these to the agents it launches, relaunches and resumes itself, so
137
138
  * they override the approval level a session was started with (PRD 6.5). The
138
- * kit reads the file and never writes it.
139
+ * kit reads Orca's files and never writes them.
140
+ *
141
+ * Orca 1.4.223 keeps them in `profile-state.db`, and older Orca in
142
+ * `orca-data.json` (tech notes, section 1). Orca's CLI has no command that
143
+ * prints them. A profile is read from its db when the db holds a settings
144
+ * document, and from the json file otherwise. A db that cannot be read is not
145
+ * passed over for the json beside it, which an upgraded Orca no longer keeps up
146
+ * to date.
139
147
  *
140
148
  * A mapping with no entry for an agent is not "no arguments": Orca falls back
141
149
  * to its own built-in default, which is the permission bypass itself (tech
@@ -153,17 +161,98 @@ export function orcaDefaultArgs() {
153
161
 
154
162
  return {
155
163
  dir,
156
- profiles: profiles
157
- .map((name) => path.join(dir, name, 'orca-data.json'))
158
- .filter((file) => existsSync(file))
159
- .map((file) => ({ file, args: argsIn(file) })),
164
+ profiles: profiles.map((name) => argsOfProfile(path.join(dir, name))).filter((one) => one !== undefined),
160
165
  };
161
166
  }
162
167
 
163
- /** What one profile file says about the agents' default arguments. */
164
- function argsIn(file) {
168
+ /** What one profile says about the agents' default arguments, or undefined when it keeps no settings. */
169
+ function argsOfProfile(profile) {
170
+ const db = path.join(profile, 'profile-state.db');
171
+ const json = path.join(profile, 'orca-data.json');
172
+
173
+ if (existsSync(db)) {
174
+ let payload;
175
+ try {
176
+ payload = settingsPayloadIn(db);
177
+ } catch {
178
+ return { file: db, args: undefined };
179
+ }
180
+ if (payload !== null) return { file: db, args: argsIn(() => JSON.parse(payload)) };
181
+ }
182
+ if (existsSync(json)) return { file: json, args: argsIn(() => JSON.parse(readFileSync(json, 'utf8'))?.settings) };
183
+ // A db with no settings document and no older file beside it: a store the
184
+ // kit does not know how to read, which it says rather than pass over.
185
+ return existsSync(db) ? { file: db, args: undefined } : undefined;
186
+ }
187
+
188
+ /**
189
+ * The `settings` document in Orca's `profile-state.db`, as text, or null when
190
+ * the db has none. Throws when the db cannot be read.
191
+ *
192
+ * Orca holds the db open in WAL mode, and its current settings may be in the
193
+ * `-wal` alone. A SQLite open of Orca's own file, read-only included, writes
194
+ * reader marks into its `-shm` and is refused while Orca holds the db in
195
+ * exclusive mode. So the kit reads a copy: the `-wal` and then the db, into a
196
+ * private folder of its own (0700, as mkdtemp makes it), removed straight after
197
+ * on every path.
198
+ *
199
+ * A copy Orca changed under it is thrown away: a part of the `-wal` laid over a
200
+ * db Orca has just checkpointed reads as older settings than Orca's own. So the
201
+ * two files are noted before and after, and a copy is read only when neither
202
+ * moved; after COPY_TRIES copies that all moved, the db counts as unreadable.
203
+ * The bytes are read whole and written out rather than copied with
204
+ * `copyFileSync`, which on macOS never returns when its source is truncated
205
+ * under it, as a checkpoint truncates the `-wal` (#507).
206
+ */
207
+ function settingsPayloadIn(db) {
208
+ const copyDir = mkdtempSync(path.join(tmpdir(), 'obk-orca-'));
209
+ try {
210
+ const copy = path.join(copyDir, path.basename(db));
211
+ for (let tries = 0; tries < COPY_TRIES; tries += 1) {
212
+ const before = stateOf(db);
213
+ const wal = bytesIfThere(`${db}-wal`);
214
+ rmSync(`${copy}-wal`, { force: true });
215
+ if (wal !== undefined) writeFileSync(`${copy}-wal`, wal);
216
+ writeFileSync(copy, readFileSync(db));
217
+ if (stateOf(db) !== before) continue;
218
+
219
+ const store = new DatabaseSync(copy);
220
+ try {
221
+ const row = store.prepare("SELECT payload FROM profile_state_documents WHERE domain = 'settings'").get();
222
+ return row === undefined ? null : row.payload;
223
+ } finally {
224
+ store.close();
225
+ }
226
+ }
227
+ throw new Error(`${db} changed during each of ${COPY_TRIES} copies`);
228
+ } finally {
229
+ rmSync(copyDir, { recursive: true, force: true });
230
+ }
231
+ }
232
+
233
+ /** How many copies of Orca's db the kit makes before it gives up on one that keeps changing. */
234
+ const COPY_TRIES = 3;
235
+
236
+ /** Where Orca's db and its `-wal` stand: one string that changes whenever either file does. */
237
+ const stateOf = (db) => [db, `${db}-wal`].map((file) => {
238
+ const stat = statSync(file, { bigint: true, throwIfNoEntry: false });
239
+ return stat === undefined ? 'none' : `${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
240
+ }).join('|');
241
+
242
+ /** The bytes of `file`, or undefined when there is no such file. */
243
+ function bytesIfThere(file) {
165
244
  try {
166
- const settings = JSON.parse(readFileSync(file, 'utf8'))?.settings?.agentDefaultArgs;
245
+ return readFileSync(file);
246
+ } catch (error) {
247
+ if (error.code === 'ENOENT') return undefined;
248
+ throw error;
249
+ }
250
+ }
251
+
252
+ /** What one settings document, given by `read`, says about the agents' default arguments. */
253
+ function argsIn(read) {
254
+ try {
255
+ const settings = read()?.agentDefaultArgs;
167
256
  // Anything that is not a mapping of agents is a mapping with nothing in it,
168
257
  // which is Orca falling back to its own defaults for every agent.
169
258
  return settings !== null && typeof settings === 'object' && !Array.isArray(settings) ? settings : {};
@@ -323,6 +412,23 @@ export const openTab = (home, title) =>
323
412
  export const retitleTab = (handle, title) =>
324
413
  orca(['terminal', 'rename', '--terminal', handle, '--title', title]).rename;
325
414
 
415
+ /**
416
+ * One quick look at whether the harness in a tab is at rest, for a caller that
417
+ * already knows a harness is there and is watching for a turn to start (#509):
418
+ * `idle` when Orca's tui-idle answers ok, `busy` when it times out, and
419
+ * `unknown` when Orca refuses or does not answer within `waitMs` and a second
420
+ * more. Never throws.
421
+ */
422
+ export function idleNow(handle, waitMs) {
423
+ try {
424
+ const answer = ask(['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle', '--timeout-ms', String(waitMs)], waitMs + 1000);
425
+ if (answer.ok === true) return answer.result?.wait?.blockedReason === undefined ? 'idle' : 'unknown';
426
+ return answer.error?.code === 'timeout' ? 'busy' : 'unknown';
427
+ } catch {
428
+ return 'unknown';
429
+ }
430
+ }
431
+
326
432
  /**
327
433
  * What can be learned about the harness in a tab: what Orca says, and who is
328
434
  * in front of the tab's terminal.
@@ -384,13 +490,16 @@ export const QUESTION_ON_SCREEN = 'question-on-screen';
384
490
  */
385
491
  function screenOf(handle, readMs) {
386
492
  const read = screenRows(handle, readMs);
387
- return read.rows === undefined ? { screenUnreadable: read.unreadable } : { question: questionIn(read.rows), rows: read.rows };
493
+ return read.rows === undefined ? { screenUnreadable: read.unreadable } : { question: questionIn(read.rows), rows: read.rows, draft: read.draft };
388
494
  }
389
495
 
390
496
  /**
391
- * The rows the tab `handle` renders, `{ rows }`, or `{ unreadable: <why> }`.
497
+ * The rows the tab `handle` renders, `{ rows, draft }`, or `{ unreadable: <why> }`.
392
498
  * Orca refusing, or answering with anything but the rendered screen, is a
393
- * screen that cannot be read, not an error.
499
+ * screen that cannot be read, not an error. `draft` is the text in the
500
+ * harness's input line, undefined when there is none: Orca 1.4.223 gives it
501
+ * beside the rows, and Claude Code 2.1.296's input line in the rows then reads
502
+ * its pointer alone (#510, probe 4).
394
503
  */
395
504
  export function screenRows(handle, readMs) {
396
505
  let read;
@@ -402,7 +511,7 @@ export function screenRows(handle, readMs) {
402
511
  if (read?.source !== 'screen' || !Array.isArray(read.tail)) {
403
512
  return { unreadable: `Orca gave no rendered screen for it (source: ${read?.source ?? 'none'})` };
404
513
  }
405
- return { rows: read.tail };
514
+ return { rows: read.tail, draft: typeof read.draft === 'string' && read.draft !== '' ? read.draft : undefined };
406
515
  }
407
516
 
408
517
  /** A row the harness starts with its selection pointer: `›` on Codex, `❯` on Claude Code. */
@@ -513,7 +622,7 @@ function pointedChoiceAt(rows, at) {
513
622
  * may: `{ handle, agent }`, with the agent Orca names there. Otherwise nothing
514
623
  * is typed, and the answer says why. `idle` says whether Orca's `tui-idle` wait
515
624
  * answered ok, and `rows` is the screen the gate read, for a caller that must
516
- * not type into a busy harness (#391):
625
+ * not type into a busy harness (#391), with the `draft` Orca gave beside it (#510):
517
626
  * `{}` for a tab with no harness in it (none in the book, none Orca lists, or
518
627
  * the shell in front), `{ blocked }` for one with something on screen waiting
519
628
  * to be answered, and `{ unsure }`, a sentence, for one the kit cannot tell
@@ -576,7 +685,7 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
576
685
  if (seen.question === undefined) {
577
686
  return { unsure: `the kit could not tell whether a question is waiting on its screen (${seen.screenUnreadable}), so nothing was typed` };
578
687
  }
579
- return { handle: live.handle, agent: seen.agent, idle: seen.answered, rows: seen.rows };
688
+ return { handle: live.handle, agent: seen.agent, idle: seen.answered, rows: seen.rows, draft: seen.draft };
580
689
  }
581
690
 
582
691
  /**
@@ -908,6 +1017,22 @@ export const useMailbox = (id, handle, options) =>
908
1017
  export const coordinatorOf = (id, options) =>
909
1018
  orca(['orchestration', 'run-show', '--id', id], options).run.coordinator_handle ?? undefined;
910
1019
 
1020
+ /**
1021
+ * Whether this Orca has no Run of that id at all, as when the book was written
1022
+ * on another machine (#508). Only `run-show` refused `run_not_found` says so:
1023
+ * `run-use` gives a legacy Run the same code and words, and a Run shown, a
1024
+ * time-out or any other refusal is not a Run known to be missing (tech notes,
1025
+ * section 1).
1026
+ */
1027
+ export function runMissing(id, options) {
1028
+ try {
1029
+ orca(['orchestration', 'run-show', '--id', id], options);
1030
+ return false;
1031
+ } catch (error) {
1032
+ return error.code === 'run_not_found';
1033
+ }
1034
+ }
1035
+
911
1036
  /** Queue one message. `to` and `from` are mailboxes, written `run:<id>`. */
912
1037
  export function postMessage({ to, from, subject, body, type = 'status', thread }) {
913
1038
  const args = ['orchestration', 'send', '--to', to, '--subject', subject, '--body', body, '--type', type];