@assuredloop/orca-bot-kit 0.26.1 → 0.27.0

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 0035).
1
+ // `obk message`: one session writing to another (PRD 6.9, ADR 0042).
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 —
@@ -10,24 +10,18 @@
10
10
  // refuses a send once the pane is gone. A Run survives the tab, the relaunch
11
11
  // and the restart, so a session's address is its Run (tech notes, section 1).
12
12
  //
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 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.
19
-
20
- import { mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
21
- import os from 'node:os';
13
+ // One road and one signal (#555, ADR 0042): the letter goes into the
14
+ // receiver's mailbox, and Orca's own notice tells the receiver to look once it
15
+ // is idle. The kit types nothing into the receiver's tab, except the one
16
+ // Escape a sender asks for with --interrupt.
17
+
18
+ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
22
19
  import path from 'node:path';
23
- import { setTimeout as pause } from 'node:timers/promises';
24
20
 
25
21
  import { MAILBOX_WAIT_MS, readBook, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
26
22
  import { botDir, botNames, readBot } from './bot.js';
27
- import { recordMark, userTurnSince } from './conversations.js';
28
- import { harnessOf, isAddressOf, ownCli, reachesMail, SHELL_ENV, shellWord } from './launch.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';
23
+ import { harnessOf, isAddressOf, kitFolders, ownCli, reachesMail, SHELL_ENV, shellWord } from './launch.js';
24
+ import { ackMailbox, coordinatorOf, postMessage, pressEscape, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, useMailbox } from './orca.js';
31
25
 
32
26
  /**
33
27
  * How much of a message travels as itself. Above this it is written to a file
@@ -41,7 +35,7 @@ import { forgetUnread, markTold, noteUnread, unreadOf } from './unread.js';
41
35
  */
42
36
  export const INLINE_LIMIT = 4 * 1024;
43
37
 
44
- /** How long Orca is given to say whether a tab has a TUI in it to nudge. */
38
+ /** How long Orca is given to say whether a tab has a busy harness in it to interrupt. */
45
39
  const LOOK_MS = 2000;
46
40
 
47
41
  /**
@@ -79,9 +73,6 @@ export function findSession(bots, target) {
79
73
  address: isAddressOf(bot.name, session.name, held.address) ? held.address : undefined,
80
74
  mailbox: typeof held.mailbox === 'string' ? held.mailbox : undefined,
81
75
  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,
85
76
  trouble: whyNotReachable(bot.name, session, harness, held),
86
77
  };
87
78
  }
@@ -127,13 +118,14 @@ export function lookUp(bots, { to: target, from: sender, tab }) {
127
118
  }
128
119
 
129
120
  /**
130
- * Send one message down the Orca road, and tell the receiver's tab to look.
121
+ * Send one message down the Orca road; with `interrupt`, end a busy
122
+ * receiver's turn first.
131
123
  *
132
124
  * A native pair is not carried here: no command line can send a Claude session's
133
125
  * own message. The kit says which address to write to instead, and sends
134
126
  * nothing, which is the whole of "the bot never picks the transport".
135
127
  */
136
- export async function sendMessage(bots, { to: target, from: sender, tab, subject, text, textFile, thread }) {
128
+ export async function sendMessage(bots, { to: target, from: sender, tab, subject, text, textFile, thread, interrupt = false }) {
137
129
  const from = whoIsWriting(bots, sender, tab);
138
130
  const to = findSession(bots, target);
139
131
  const road = roadBetween(from, to);
@@ -141,7 +133,7 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
141
133
  const answer = {
142
134
  bots,
143
135
  from: { bot: from.bot, session: from.session },
144
- to: { bot: to.bot, session: to.session },
136
+ to: { bot: to.bot, session: to.session, harness: to.harness },
145
137
  subject,
146
138
  ...road,
147
139
  };
@@ -167,9 +159,8 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
167
159
  }
168
160
 
169
161
  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
+ // The Escape goes first, so the turn has ended when the letter is in.
163
+ const interrupted = interrupt ? interruptBusy(to) : {};
173
164
  const message = postMessage({
174
165
  to: road.address,
175
166
  from: `run:${from.mailbox}`,
@@ -177,7 +168,6 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
177
168
  body: written.body,
178
169
  thread,
179
170
  });
180
- noteUnread(to.home, to.session, { id: message.id, from: `${from.bot}/${from.session}`, subject, at: message.created_at ?? new Date().toISOString() });
181
171
 
182
172
  return {
183
173
  ...answer,
@@ -185,7 +175,7 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
185
175
  id: message.id,
186
176
  thread: message.thread_id ?? thread,
187
177
  file: written.file,
188
- ...(await nudge(to, from, subject, tab, mark)),
178
+ ...interrupted,
189
179
  };
190
180
  }
191
181
 
@@ -220,10 +210,7 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
220
210
  }
221
211
  try {
222
212
  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;
213
+ return mailOf(bots, who, peek, Date.now() + HOLD_MS);
227
214
  } finally {
228
215
  turn.release();
229
216
  }
@@ -411,6 +398,8 @@ const notTheCodexInTab = (found, tab) =>
411
398
  /**
412
399
  * The message as it will travel: the text itself when it is short enough, and
413
400
  * otherwise a file beside the bots folder with a body that names it.
401
+ * The file is in the sender's bot's own folder there, the one a Codex session
402
+ * of that bot may write from inside its sandbox (#534).
414
403
  *
415
404
  * The file is the kit's own, next to the bots repo the way start prompts are,
416
405
  * so a long message is not written into the user's git status and does not
@@ -424,7 +413,7 @@ function bodyOf(bots, { from, to, subject, text, textFile }) {
424
413
  if (Buffer.byteLength(said, 'utf8') <= INLINE_LIMIT) return { body: said };
425
414
 
426
415
  const file = path.join(
427
- `${bots}.messages`,
416
+ kitFolders(bots, from.bot).messages,
428
417
  `${encodeURIComponent(`${from.bot}.${from.session}`)}.${encodeURIComponent(`${to.bot}.${to.session}`)}.${stamp()}.md`,
429
418
  );
430
419
  mkdirSync(path.dirname(file), { recursive: true });
@@ -446,323 +435,42 @@ function readText(file) {
446
435
  const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '-');
447
436
 
448
437
  /**
449
- * Tell the receiver that mail is waiting, with one signal (#509, ADR 0035).
450
- *
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.
465
- *
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
472
- * into one of those is not a message: it is an answer to whatever question is
473
- * up. That is not a worry, it is a thing that happened — in slice 03 a second
474
- * line went into a tab on Claude Code's folder-trust list, confirmed its
475
- * default, `No, exit`, and the harness quit (tech notes, section 1). And in
476
- * #329 a return took Codex's "Update now". So where Orca says a tab is
477
- * blocked, or the kit sees a numbered choice list of the harness's own on its
478
- * screen, nothing is typed and the mail waits. The gate is passed again right
479
- * before the line, after the watch.
480
- *
481
- * That narrows the case rather than closing it: a question drawn any other way,
482
- * such as Claude Code's unnumbered trust list, is seen by neither. What closes
483
- * it is nobody sending to a session before its first screens are answered,
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.
488
- */
489
- async function nudge(to, from, subject, tab, mark = recordMark(to.harness, to.home, to.conversation)) {
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.
438
+ * Interrupt a busy receiver before an urgent letter (#555): one Escape into its
439
+ * tab ends the turn it is having, and Orca's notice then comes at once rather
440
+ * than when that turn ends. Never by default. Only where the gate every key
441
+ * the kit types goes through finds the receiver's harness there and busy: an
442
+ * idle one gets Orca's notice at once anyway, and an Escape into a question on
443
+ * its screen would answer it. Codex keeps a command it was running as a
444
+ * background terminal after an Escape.
524
445
  */
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
- }
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) {
578
- // A line typed while the kit types a command one key at a time would land
579
- // in it and send it with its own return (#480): so the receiver's turn for
580
- // a line first, for a bounded time, and the mail waits in its mailbox if not.
446
+ function interruptBusy(to) {
447
+ if (to.tab === undefined) return { interrupted: false, interruptTrouble: 'it is not up' };
448
+ // A key typed while the kit types a command one key at a time would land in
449
+ // that command (#480), so the receiver's turn for a line first.
581
450
  let turn;
582
451
  try {
583
452
  turn = takeLineTurn(to.home, to.session, TYPING_WAIT_MS);
584
453
  } catch (error) {
585
- return { nudged: false, nudgeTrouble: error.message };
586
- }
587
- if (turn === undefined) {
588
- return { nudged: false, nudgeTrouble: TYPING_HELD };
454
+ return { interrupted: false, interruptTrouble: error.message };
589
455
  }
456
+ if (turn === undefined) return { interrupted: false, interruptTrouble: TYPING_HELD };
590
457
  try {
591
458
  const found = lookAt(to);
592
- if (found.blocked !== undefined) return { nudged: false, blocked: found.blocked };
593
- // A line that lands in a shell is run there, with the sender's subject in it.
594
- if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure, ...(found.psUnread ? leftForHook(to, from, subject, tab, mark) : {}) };
595
- if (found.handle === undefined) return { nudged: false };
596
- return then(found);
459
+ if (found.blocked !== undefined) return { interrupted: false, interruptTrouble: `its tab has something waiting to be answered (${found.blocked}), so nothing was typed` };
460
+ if (found.unsure !== undefined) return { interrupted: false, interruptTrouble: found.unsure };
461
+ if (found.handle === undefined) return { interrupted: false, interruptTrouble: found.gone ? 'it is not up' : 'no harness is running in its tab' };
462
+ if (found.idle) return { interrupted: false, idle: true };
463
+ pressEscape(found.handle);
464
+ return { interrupted: true };
597
465
  } catch (error) {
598
- // The message is already queued, and it is waiting whatever Orca says
599
- // about the tab. So this is reported rather than thrown: a send that ends
600
- // in an error the caller reads as "it did not go" would be a lie, and a
601
- // silent `false` would read as "the session is not up", which is a
602
- // different thing from "Orca would not say".
603
- return { nudged: false, nudgeTrouble: error.message };
466
+ return { interrupted: false, interruptTrouble: error.message };
604
467
  } finally {
605
468
  turn.release();
606
469
  }
607
470
  }
608
471
 
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
-
627
- /**
628
- * The nudge a Codex sender could not decide, left for its own hook (#350, ADR
629
- * 0034): `{ nudgeLeft: true }`, or nothing when it is not left.
630
- *
631
- * Inside Codex's sandbox `ps` does not start, and for a Claude receiver busy
632
- * with a command Orca's runtime cannot see past it either. Codex runs its
633
- * hooks outside the sandbox, and the kit's `PostToolUse` hook runs right after
634
- * the command that made this send, in the same tab, where `ps` runs. So the
635
- * send leaves the nudge where that hook looks, the system temp folder under the
636
- * tab's id, which the sandbox lets it write and the hook reads. Only for a
637
- * Codex session sending from its own tab, and only where `ps` could not read
638
- * the receiver's tab: anywhere else no hook would see it, or would see no more
639
- * than the send did.
640
- */
641
- function leftForHook(to, from, subject, tab, mark) {
642
- if (tab === undefined || from.tab !== tab || from.harness !== 'codex') return {};
643
- try {
644
- const dir = leftDir(tab);
645
- mkdirSync(dir, { recursive: true });
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 };
650
- // Written under a name the hook does not take, then put in place whole: a
651
- // hook running beside this send never takes a file still being written.
652
- const name = path.join(dir, `${stamp()}.${process.pid}`);
653
- writeFileSync(`${name}.writing`, `${JSON.stringify(left)}\n`);
654
- renameSync(`${name}.writing`, `${name}.json`);
655
- return { nudgeLeft: true };
656
- } catch {
657
- return {};
658
- }
659
- }
660
-
661
- /** Where the nudges left from one tab wait for its hook. */
662
- const leftDir = (tab) => path.join(os.tmpdir(), 'obk-nudges', encodeURIComponent(tab));
663
-
664
- /**
665
- * What the kit's Codex hook does after each shell command (#350): decide every
666
- * nudge a send in this tab left, oldest first, with the gate the send uses, and
667
- * type it or not. Each is taken out before it is looked at, so it is decided
668
- * once, by one hook. Returns `{ to, subject, ...what the nudge came to }` for
669
- * each, as the send's own answer says it.
670
- */
671
- export async function decideLeftNudges(tab) {
672
- if (tab === undefined) return [];
673
- const dir = leftDir(tab);
674
- let names;
675
- try {
676
- names = readdirSync(dir).filter((name) => name.endsWith('.json')).sort();
677
- } catch {
678
- return [];
679
- }
680
- const decided = [];
681
- for (const name of names) {
682
- const taking = path.join(dir, `${name}.taking`);
683
- // Another hook took it first: it is that hook's to decide, and to remove.
684
- try {
685
- renameSync(path.join(dir, name), taking);
686
- } catch {
687
- continue;
688
- }
689
- let left;
690
- try {
691
- left = JSON.parse(readFileSync(taking, 'utf8'));
692
- } catch {
693
- continue;
694
- } finally {
695
- rmSync(taking, { force: true });
696
- }
697
- try {
698
- const to = findSession(left.bots, left.to);
699
- const from = findSession(left.bots, left.from);
700
- decided.push({ to: left.to, subject: left.subject, ...(await nudge(to, from, left.subject, undefined, left.mark ?? null)) });
701
- } catch (error) {
702
- decided.push({ to: left.to, subject: left.subject, nudged: false, nudgeTrouble: error.message });
703
- }
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`;
754
- }
755
-
756
- /** Orca's words about a line it did not see start a turn, or what its receipt says when it gives none. */
757
- function unseen(sent) {
758
- const warnings = Array.isArray(sent?.warnings) ? sent.warnings.filter((warning) => typeof warning === 'string') : [];
759
- if (warnings.length > 0) return warnings.join(' ');
760
- const stages = sent?.send?.prompt?.stages;
761
- return `Orca's receipt shows ${Array.isArray(stages) ? stages.join(', ') : 'no stages'} and no turn start`;
762
- }
763
-
764
472
  /**
765
- * The look before the nudge. Orca now and then refuses a handle it has just
473
+ * The look before the interrupt. Orca now and then refuses a handle it has just
766
474
  * listed as stale, and a fresh listing hands out one that works (tech notes,
767
475
  * section 1), so that refusal earns one more listing and one more look. Only
768
476
  * one: a second refusal is reported as it is (#294).
package/src/name.js CHANGED
@@ -79,7 +79,7 @@ export async function nameSession(bots, bot, said, tabId) {
79
79
  const it = { ...sessionToType(bots, bot, session), conversation: thread };
80
80
  if (it.tabId !== tabId) return undefined;
81
81
  // The session's typing turn, taken once it is idle and not before, so a
82
- // mail nudge waits seconds for it and not the whole wait for idle. Held by
82
+ // mail interrupt waits seconds for it and not the whole wait for idle. Held by
83
83
  // anything else: nothing is typed, and the next turn end tries again.
84
84
  const before = () => {
85
85
  typing = takeTypingTurn(home, session, 0);
package/src/orca.js CHANGED
@@ -426,23 +426,6 @@ export const openTab = (home, title) =>
426
426
  export const retitleTab = (handle, title) =>
427
427
  orca(['terminal', 'rename', '--terminal', handle, '--title', title]).rename;
428
428
 
429
- /**
430
- * One quick look at whether the harness in a tab is at rest, for a caller that
431
- * already knows a harness is there and is watching for a turn to start (#509):
432
- * `idle` when Orca's tui-idle answers ok, `busy` when it times out, and
433
- * `unknown` when Orca refuses or does not answer within `waitMs` and a second
434
- * more. Never throws.
435
- */
436
- export function idleNow(handle, waitMs) {
437
- try {
438
- const answer = ask(['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle', '--timeout-ms', String(waitMs)], waitMs + 1000);
439
- if (answer.ok === true) return answer.result?.wait?.blockedReason === undefined ? 'idle' : 'unknown';
440
- return answer.error?.code === 'timeout' ? 'busy' : 'unknown';
441
- } catch {
442
- return 'unknown';
443
- }
444
- }
445
-
446
429
  /**
447
430
  * What can be learned about the harness in a tab: what Orca says, and who is
448
431
  * in front of the tab's terminal.
@@ -637,14 +620,14 @@ function pointedChoiceAt(rows, at) {
637
620
  * is typed, and the answer says why. `idle` says whether Orca's `tui-idle` wait
638
621
  * answered ok, and `rows` is the screen the gate read, for a caller that must
639
622
  * not type into a busy harness (#391), with the `draft` Orca gave beside it (#510):
640
- * `{}` for a tab with no harness in it (none in the book, none Orca lists, or
641
- * the shell in front), `{ blocked }` for one with something on screen waiting
623
+ * `{}` for a tab with no harness in it (none in the book, or the shell in
624
+ * front), `{ gone: true }` for one Orca does not list, `{ blocked }` for one with something on screen waiting
642
625
  * to be answered, and `{ unsure }`, a sentence, for one the kit cannot tell
643
626
  * about, with `psUnread` where `ps` could not read the tab (#350). Orca
644
627
  * refusing throws, for the caller to report.
645
628
  *
646
629
  * The one gate for every line the kit types into a running session: the mail
647
- * nudge and the skills reload. A busy harness passes it, since both harnesses
630
+ * interrupt and the skills reload. A busy harness passes it, since both harnesses
648
631
  * take a typed line as their next turn.
649
632
  */
650
633
  export function tabToTypeInto(home, tabId, timeoutMs) {
@@ -655,7 +638,7 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
655
638
  let seen;
656
639
  try {
657
640
  live = tabs(home, { timeoutMs: GATE_READ_MS }).find((tab) => tab.tabId === tabId);
658
- if (live === undefined) return {};
641
+ if (live === undefined) return { gone: true };
659
642
  seen = harnessInTab(live.handle, timeoutMs, GATE_READ_MS);
660
643
  } catch (error) {
661
644
  if (error.code !== TIMED_OUT) throw error;
@@ -737,7 +720,7 @@ export function frontOfTab(handle) {
737
720
  }
738
721
 
739
722
  /** The `ps` this run reads. OBK_PS overrides it, as OBK_ORCA does Orca. */
740
- const psCli = () => process.env.OBK_PS || '/bin/ps';
723
+ export const psCli = () => process.env.OBK_PS || '/bin/ps';
741
724
 
742
725
  /**
743
726
  * Who holds the terminal of the tab `handle`, whose pane is `ptyId`:
@@ -834,7 +817,7 @@ function panePid(ptyId, readMs) {
834
817
  }
835
818
 
836
819
  /** The `lsof` and `stty` this run asks. OBK_LSOF and OBK_STTY override them, as OBK_PS does `ps`. */
837
- const lsofCli = () => process.env.OBK_LSOF || '/usr/sbin/lsof';
820
+ export const lsofCli = () => process.env.OBK_LSOF || '/usr/sbin/lsof';
838
821
  const sttyCli = () => process.env.OBK_STTY || '/bin/stty';
839
822
 
840
823
  /**
@@ -968,18 +951,12 @@ export const closeTerminal = (handle) =>
968
951
  * Seen live on 2026-09-21 in a system test run; not reproduced since, including
969
952
  * from a plain shell, long lines and lines sent while the agent was working.
970
953
  *
971
- * The wait is short because the caller is telling a session it has mail, not
972
- * handing it work: mail that has to wait for the next check is a smaller cost
973
- * than a command that hangs for half a minute.
974
- *
975
- * With `watch`, the line is sent with that wait from the start, and Orca's
976
- * receipt says whether it saw the line start a turn (#394). It gives back
977
- * Orca's `result`: the receipt is in its `send.prompt`, and Orca's words about
978
- * a turn it did not see in its `warnings`.
954
+ * The wait is short: a line that has to wait is a smaller cost than a
955
+ * command that hangs for half a minute.
979
956
  */
980
- export function typeIntoTab(handle, text, { watch = false } = {}) {
957
+ export function typeIntoTab(handle, text) {
981
958
  const args = ['terminal', 'send', '--terminal', handle, '--text', text, '--enter'];
982
- const answer = ask(watch ? [...args, '--wait-submit', String(SUBMIT_WAIT_S)] : args);
959
+ const answer = ask(args);
983
960
  if (answer.ok === true) return answer.result;
984
961
 
985
962
  const again = answer.error?.data?.orchestrationRequestId;
@@ -990,11 +967,18 @@ export function typeIntoTab(handle, text, { watch = false } = {}) {
990
967
  }
991
968
 
992
969
  /**
993
- * How long a watched or re-issued line is given to be submitted before Orca
994
- * gives up on it. An idle harness started its turn in about 2 s, live (#394).
970
+ * How long a re-issued line is given to be submitted before Orca gives up on
971
+ * it. An idle harness started its turn in about 2 s, live (#394).
995
972
  */
996
973
  const SUBMIT_WAIT_S = 5;
997
974
 
975
+ /**
976
+ * Press Escape once in a tab, with no return after it: what `obk message send
977
+ * --interrupt` sends to a busy receiver (#555).
978
+ */
979
+ export const pressEscape = (handle) =>
980
+ orca(['terminal', 'send', '--terminal', handle, '--text', '\x1b']);
981
+
998
982
  /** Where Orca names the terminal a process runs in, in every pane it opens. */
999
983
  export const TERMINAL_ENV = 'ORCA_TERMINAL_HANDLE';
1000
984