@assuredloop/orca-bot-kit 0.26.1 → 0.28.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,19 @@
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. Orca gives no notice to a tab its
17
+ // window has not loaded, so the send says when the receiver's is one (#579).
18
+
19
+ import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
22
20
  import path from 'node:path';
23
- import { setTimeout as pause } from 'node:timers/promises';
24
21
 
25
22
  import { MAILBOX_WAIT_MS, readBook, takeLineTurn, takeMailboxTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
26
23
  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';
24
+ import { harnessOf, isAddressOf, kitFolders, ownCli, reachesMail, SHELL_ENV, shellWord } from './launch.js';
25
+ import { ackMailbox, coordinatorOf, postMessage, pressEscape, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, useMailbox } from './orca.js';
31
26
 
32
27
  /**
33
28
  * How much of a message travels as itself. Above this it is written to a file
@@ -41,7 +36,7 @@ import { forgetUnread, markTold, noteUnread, unreadOf } from './unread.js';
41
36
  */
42
37
  export const INLINE_LIMIT = 4 * 1024;
43
38
 
44
- /** How long Orca is given to say whether a tab has a TUI in it to nudge. */
39
+ /** How long Orca is given to say whether a tab has a busy harness in it to interrupt. */
45
40
  const LOOK_MS = 2000;
46
41
 
47
42
  /**
@@ -79,9 +74,6 @@ export function findSession(bots, target) {
79
74
  address: isAddressOf(bot.name, session.name, held.address) ? held.address : undefined,
80
75
  mailbox: typeof held.mailbox === 'string' ? held.mailbox : undefined,
81
76
  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
77
  trouble: whyNotReachable(bot.name, session, harness, held),
86
78
  };
87
79
  }
@@ -127,21 +119,22 @@ export function lookUp(bots, { to: target, from: sender, tab }) {
127
119
  }
128
120
 
129
121
  /**
130
- * Send one message down the Orca road, and tell the receiver's tab to look.
122
+ * Send one message down the Orca road; with `interrupt`, end a busy
123
+ * receiver's turn first.
131
124
  *
132
125
  * A native pair is not carried here: no command line can send a Claude session's
133
126
  * own message. The kit says which address to write to instead, and sends
134
127
  * nothing, which is the whole of "the bot never picks the transport".
135
128
  */
136
- export async function sendMessage(bots, { to: target, from: sender, tab, subject, text, textFile, thread }) {
137
- const from = whoIsWriting(bots, sender, tab);
129
+ export async function sendMessage(bots, { to: target, from: sender, tab, subject, text, textFile, thread, interrupt = false }) {
130
+ const from = whoIsSending(bots, sender, tab);
138
131
  const to = findSession(bots, target);
139
132
  const road = roadBetween(from, to);
140
133
 
141
134
  const answer = {
142
135
  bots,
143
136
  from: { bot: from.bot, session: from.session },
144
- to: { bot: to.bot, session: to.session },
137
+ to: { bot: to.bot, session: to.session, harness: to.harness },
145
138
  subject,
146
139
  ...road,
147
140
  };
@@ -167,9 +160,10 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
167
160
  }
168
161
 
169
162
  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);
163
+ // Looked at before the post, which is the send's last call to Orca (PRD 6.9).
164
+ const shown = loaded(to);
165
+ // The Escape goes first, so the turn has ended when the letter is in.
166
+ const interrupted = interrupt ? interruptBusy(to) : {};
173
167
  const message = postMessage({
174
168
  to: road.address,
175
169
  from: `run:${from.mailbox}`,
@@ -177,7 +171,6 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
177
171
  body: written.body,
178
172
  thread,
179
173
  });
180
- noteUnread(to.home, to.session, { id: message.id, from: `${from.bot}/${from.session}`, subject, at: message.created_at ?? new Date().toISOString() });
181
174
 
182
175
  return {
183
176
  ...answer,
@@ -185,10 +178,31 @@ export async function sendMessage(bots, { to: target, from: sender, tab, subject
185
178
  id: message.id,
186
179
  thread: message.thread_id ?? thread,
187
180
  file: written.file,
188
- ...(await nudge(to, from, subject, tab, mark)),
181
+ ...interrupted,
182
+ ...shown,
189
183
  };
190
184
  }
191
185
 
186
+ /**
187
+ * Whether Orca's window has loaded the receiver's tab, looked at just before
188
+ * the letter goes in. Orca gives its notice only to a pane the window has loaded,
189
+ * and lists one it has not as orphaned, as after a window restart (#579; tech
190
+ * notes, section 1). So an orphaned tab is said, with its handle, and a
191
+ * listing that cannot be read is said too, rather than taken for a loaded tab.
192
+ * The look is made once: a tab orphaned after the send is not seen here.
193
+ */
194
+ function loaded(to) {
195
+ if (to.tab === undefined) return {};
196
+ let listed;
197
+ try {
198
+ listed = tabs(to.home, { timeoutMs: LOOK_MS });
199
+ } catch (error) {
200
+ return { loadTrouble: error.message };
201
+ }
202
+ const tab = listed.find((one) => one.tabId === to.tab);
203
+ return tab?.orphaned === true ? { orphaned: true, handle: tab.handle } : {};
204
+ }
205
+
192
206
  /**
193
207
  * What is waiting for a session, and the reading of it.
194
208
  *
@@ -220,10 +234,7 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
220
234
  }
221
235
  try {
222
236
  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;
237
+ return mailOf(bots, who, peek, Date.now() + HOLD_MS);
227
238
  } finally {
228
239
  turn.release();
229
240
  }
@@ -377,6 +388,22 @@ function whoIsWriting(bots, sender, tab, asked = '--from <bot>/<session>: which
377
388
  return found;
378
389
  }
379
390
 
391
+ /**
392
+ * The session that is sending. In one of this fleet's tabs it is the tab's own
393
+ * session, and `--from` may name only that one: otherwise a bot could send as
394
+ * another, and the reply and the receiver's check would take it for that one
395
+ * (#570). Outside the fleet's tabs `--from` says who is writing, as before.
396
+ */
397
+ function whoIsSending(bots, sender, tab) {
398
+ const caller = tab === undefined ? undefined : sessionInTab(bots, tab);
399
+ if (caller === undefined) return whoIsWriting(bots, sender, tab);
400
+ if (sender === undefined) return caller;
401
+
402
+ const named = findSession(bots, sender);
403
+ if (named.bot === caller.bot && named.session === caller.session) return caller;
404
+ throw new Error(`this command runs in ${caller.bot}/${caller.session}'s Orca tab, ${tab}, so it sends as ${caller.bot}/${caller.session} and not as ${named.bot}/${named.session}. Nothing was sent. Leave out --from, or name ${caller.bot}/${caller.session}.`);
405
+ }
406
+
380
407
  /**
381
408
  * The session the book says is in this Orca tab, wherever in the fleet it is,
382
409
  * when the caller is that session.
@@ -411,6 +438,8 @@ const notTheCodexInTab = (found, tab) =>
411
438
  /**
412
439
  * The message as it will travel: the text itself when it is short enough, and
413
440
  * otherwise a file beside the bots folder with a body that names it.
441
+ * The file is in the sender's bot's own folder there, the one a Codex session
442
+ * of that bot may write from inside its sandbox (#534).
414
443
  *
415
444
  * The file is the kit's own, next to the bots repo the way start prompts are,
416
445
  * so a long message is not written into the user's git status and does not
@@ -424,7 +453,7 @@ function bodyOf(bots, { from, to, subject, text, textFile }) {
424
453
  if (Buffer.byteLength(said, 'utf8') <= INLINE_LIMIT) return { body: said };
425
454
 
426
455
  const file = path.join(
427
- `${bots}.messages`,
456
+ kitFolders(bots, from.bot).messages,
428
457
  `${encodeURIComponent(`${from.bot}.${from.session}`)}.${encodeURIComponent(`${to.bot}.${to.session}`)}.${stamp()}.md`,
429
458
  );
430
459
  mkdirSync(path.dirname(file), { recursive: true });
@@ -446,323 +475,42 @@ function readText(file) {
446
475
  const stamp = () => new Date().toISOString().replaceAll(':', '-').replace('.', '-');
447
476
 
448
477
  /**
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.
478
+ * Interrupt a busy receiver before an urgent letter (#555): one Escape into its
479
+ * tab ends the turn it is having, and Orca's notice then comes at once rather
480
+ * than when that turn ends. Never by default. Only where the gate every key
481
+ * the kit types goes through finds the receiver's harness there and busy: an
482
+ * idle one gets Orca's notice at once anyway, and an Escape into a question on
483
+ * its screen would answer it. Codex keeps a command it was running as a
484
+ * background terminal after an Escape.
488
485
  */
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.
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
- }
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.
486
+ function interruptBusy(to) {
487
+ if (to.tab === undefined) return { interrupted: false, interruptTrouble: 'it is not up' };
488
+ // A key typed while the kit types a command one key at a time would land in
489
+ // that command (#480), so the receiver's turn for a line first.
581
490
  let turn;
582
491
  try {
583
492
  turn = takeLineTurn(to.home, to.session, TYPING_WAIT_MS);
584
493
  } catch (error) {
585
- return { nudged: false, nudgeTrouble: error.message };
586
- }
587
- if (turn === undefined) {
588
- return { nudged: false, nudgeTrouble: TYPING_HELD };
494
+ return { interrupted: false, interruptTrouble: error.message };
589
495
  }
496
+ if (turn === undefined) return { interrupted: false, interruptTrouble: TYPING_HELD };
590
497
  try {
591
498
  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);
499
+ if (found.blocked !== undefined) return { interrupted: false, interruptTrouble: `its tab has something waiting to be answered (${found.blocked}), so nothing was typed` };
500
+ if (found.unsure !== undefined) return { interrupted: false, interruptTrouble: found.unsure };
501
+ if (found.handle === undefined) return { interrupted: false, interruptTrouble: found.gone ? 'it is not up' : 'no harness is running in its tab' };
502
+ if (found.idle) return { interrupted: false, idle: true };
503
+ pressEscape(found.handle);
504
+ return { interrupted: true };
597
505
  } 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 };
506
+ return { interrupted: false, interruptTrouble: error.message };
604
507
  } finally {
605
508
  turn.release();
606
509
  }
607
510
  }
608
511
 
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
512
  /**
765
- * The look before the nudge. Orca now and then refuses a handle it has just
513
+ * The look before the interrupt. Orca now and then refuses a handle it has just
766
514
  * listed as stale, and a fresh listing hands out one that works (tech notes,
767
515
  * section 1), so that refusal earns one more listing and one more look. Only
768
516
  * 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);
@@ -1,5 +1,5 @@
1
1
  -- Run by the kit as `osascript orca-reload.applescript <Orca.app path>`, after
2
- -- a project was removed (#343, ADR 0034).
2
+ -- a project was removed (#343, ADR 0046).
3
3
  --
4
4
  -- Orca's window keeps a removed project in its sidebar until the window is
5
5
  -- rebuilt (stablyai/orca#23224; once a release fixes it, this can go behind a
@@ -2,7 +2,7 @@
2
2
  // CLI: `ELECTRON_RUN_AS_NODE=1 <Orca> orca-runtime.cjs <client> <method> <params> <wait>`.
3
3
  //
4
4
  // It makes one call to Orca's runtime, through Orca's own client, for a method
5
- // Orca's CLI does not offer (ADR 0034): `project.update`, which makes Orca's
5
+ // Orca's CLI does not offer (ADR 0046): `project.update`, which makes Orca's
6
6
  // window read its projects again (#224), and `terminal.inspectProcess`, which
7
7
  // says who is in front of a tab where `ps` cannot (#298). `params` is JSON. It
8
8
  // prints the runtime's answer as JSON and exits 0 only when the runtime took