@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/README.md CHANGED
@@ -350,6 +350,11 @@ address that is none of your bots folder's sessions, the hook tells it so, and
350
350
  names the address `obk message to` would have given. The message goes either
351
351
  way ([ADR 0032](docs/adr/0032-a-native-message-outside-the-fleet-is-warned-about.md)).
352
352
 
353
+ And a third: at the end of each turn, it tells the session once about fleet
354
+ mail the kit sent it that is still unread, without typing into its tab
355
+ ([ADR 0035](docs/adr/0035-messaging-transport.md)). It asks Orca only when the kit
356
+ knows of mail for that session.
357
+
353
358
  A Codex bot gets one more hook too. Inside Codex's sandbox the kit cannot always
354
359
  tell whether the receiver of a message has its harness in front, as for a Claude
355
360
  session busy running a command, so `obk message send` leaves that nudge, and the
@@ -555,7 +560,7 @@ verdict: which of these matter, and in what order, is for you or for Bot Father.
555
560
  ## Sessions and bots that talk
556
561
 
557
562
  Ask the kit how to reach a session, and it answers with the road and the
558
- address ([ADR 0030](docs/adr/0030-messaging-transport.md)):
563
+ address ([ADR 0035](docs/adr/0035-messaging-transport.md)):
559
564
 
560
565
  ```sh
561
566
  obk message to --bots /path/to/my-bots --to api-bot/daily
@@ -598,11 +603,17 @@ mailbox where it was and says so, and the harness starts all the same. A check
598
603
  holds its turn for 40 seconds at most: with more mail than it reads in that
599
604
  time, it shows what it read and says the rest is still waiting.
600
605
 
601
- Nothing in a mailbox wakes anybody, so `message send` also types one line into
602
- the receiver's tab telling it to look. Both harnesses take a typed line as the
603
- next thing to do rather than cutting into what they are doing, which is the
604
- whole of "queued, not interrupting". A session that is not up is not typed
605
- into at all; its message waits.
606
+ Nothing in a mailbox wakes anybody, so each message gets one signal that tells
607
+ the receiver to look. Orca's own notice comes first: Orca types it into an idle
608
+ tab. The kit watches an idle receiver for up to 8 seconds, and reads the
609
+ receiver's own record of its turns. When Orca's notice is there, the kit types
610
+ nothing. When no turn started, the kit types its own line. A Claude session busy
611
+ with a turn gets nothing typed: its hook tells it at the end of the turn. A busy
612
+ Codex session gets the kit's line, which Codex takes into the turn it is having.
613
+ The send says which signal went. A session that is not up is not typed into at
614
+ all; its message waits. When a session is retired, the kit says how many
615
+ messages sent to it were not read with `obk message check`, and who sent them
616
+ ([ADR 0035](docs/adr/0035-messaging-transport.md)).
606
617
 
607
618
  A message longer than 4 KiB is written to a file beside your bots folder and
608
619
  named in the message, so a long one arrives whole without landing a document in
package/SETUP.md CHANGED
@@ -201,7 +201,7 @@ waiting for:
201
201
  | Claude Code's folder trust list | `\x1b[B\r` | down, then return: its selection starts on **No, exit** | Claude Code 2.1.283 |
202
202
  | Codex's directory trust, `1. Trust and continue` (older: `1. Yes, continue`) | `\r` | return: its selection starts on **Trust and continue** | Codex 0.160.0 |
203
203
  | Codex's `Hooks need review` | `\x1b[B\r` | down, then return, to **Trust all and continue**; if the selection already starts there, return alone | Codex 0.160.0 |
204
- | Codex's `/new`: `Where should the new conversation run?` | `\r` | return: its selection starts on **Current checkout** (bot home) | Codex 0.160.0 |
204
+ | Codex's `/new`: `Where should the new conversation run?` | `\r` | return: its selection starts on **Current checkout** (bot home); on 0.162.0 it is called **1. Use current Git worktree** (Keep using the current working directory). Never **Create new Git worktree** | Codex 0.160.0; Codex 0.162.0 (#516) |
205
205
  | Codex's update offer, `1. Update now` | `\r` | return: its selection starts on **Update now**, which accepts it | Codex 0.156.1; seen on 0.157.1 (offering 0.158.0) with the selection on **Update now**, not pressed; 0.158.0 offered 0.160.0, taken on 2026-10-02 by Bot Father's restarts |
206
206
  | `[oh-my-zsh] Would you like to update?` | `n` | they update their own shell | oh-my-zsh bf77e35 |
207
207
  | Claude Code's `Teach auto mode about your environment?` | `\x1b` | Esc, which cancels it (**Not now**). On 2.1.283 it is a form, not a list: `How you use Claude here`, `Also scan shell history`, `Also scan your other repos`, `Continue`, and `Enter to continue · Esc to cancel`. **Never send a return**: Enter is **Continue**, which starts a scan of the project, recent Claude sessions and, by default, the machine's shell history | Not yet proven: Esc cancels in Claude Code 2.1.283's code, not yet seen live (#370); the form was seen live twice on 2.1.283, in #261's live test and #239's live run 1 (#416); 2.1.278 showed a numbered list, answered with 2. Not now |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.25.2",
3
+ "version": "0.25.4",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/clear.js CHANGED
@@ -38,9 +38,13 @@ const POINTER = { claude: '❯', codex: '›' };
38
38
  */
39
39
  const RECORD_LINE = 'obk: this conversation was started by obk session clear, and this line is only so the kit can record it. Reply "ok"; nothing else is asked.';
40
40
 
41
- /** What Codex asks after `/new`, and the one answer the kit gives it: the bot home it runs in. */
41
+ /**
42
+ * What Codex asks after `/new`, and the one answer the kit gives it: the bot
43
+ * home it runs in. Codex 0.162.0 calls it by another name, and its whole row
44
+ * has to be as captured, but for the padding before its description (#516).
45
+ */
42
46
  const WHERE_TO_RUN = 'Where should the new conversation run?';
43
- const CURRENT_CHECKOUT = /^ *› +1\. Current checkout\b/;
47
+ const CURRENT_CHECKOUT = /^ *› +1\. Current checkout\b|^ *› 1\. Use current Git worktree\s+Keep using the current working directory *$/;
44
48
 
45
49
  /** Codex's empty input line: its pointer alone, or with its placeholder. */
46
50
  const CODEX_EMPTY = ['›', '› Ask Codex to do anything'];
@@ -51,7 +55,8 @@ const CODEX_EMPTY = ['›', '› Ask Codex to do anything'];
51
55
  * selected row stands for what Return will run: with the menu open, Return
52
56
  * runs the selected command, and the menu is narrowed to what was typed
53
57
  * (codex-rs/tui/src/bottom_pane/chat_composer.rs, rust-v0.160.0). The
54
- * architect's ruling on #391, after live run 4.
58
+ * architect's ruling on #391, after live run 4. Only where Orca gives no
59
+ * draft: a draft is the line's text, and has to be the command (#516).
55
60
  */
56
61
  const CODEX_BARE_LINE = '0.160.0';
57
62
 
@@ -221,16 +226,19 @@ async function enter(it, verb, before = () => {}) {
221
226
  * when it was typed.
222
227
  */
223
228
  export async function typeCommand(it, command, { before = () => {}, check = menuWrong, cannot = () => '', deadline = Infinity } = {}) {
224
- // On Codex, what is typed cannot be read back once its menu is open, so
225
- // nothing may be in its input line before: no draft for the command to join.
226
- const emptyLine = (rows) => {
229
+ // Nothing may be in the input line before: no draft for the command to join.
230
+ // Orca gives a draft in the line beside the screen, and the screen's line
231
+ // then reads the pointer alone (#510, #516). Where it gives none, Codex's
232
+ // line on the screen has to be empty.
233
+ const emptyLine = ({ rows, draft }) => {
234
+ if (draft !== undefined) throw new Error(`${it.name}: nothing was typed, because its input line holds a draft: it reads "${draft}".${shownEnd(rows)}`);
227
235
  if (it.harness !== 'codex') return;
228
236
  const line = rows.findLast((row) => /^ *›/.test(row))?.trim();
229
237
  if (!CODEX_EMPTY.includes(line)) {
230
238
  throw new Error(`${it.name}: nothing was typed, because its input line is not empty: it reads "${line ?? 'nothing'}".${shownEnd(rows)}`);
231
239
  }
232
240
  };
233
- emptyLine((await idleTab(it)).rows);
241
+ emptyLine(await idleTab(it));
234
242
  const version = it.harness === 'codex' ? codexVersion(readBook(it.home).sessions[it.session]?.session) : undefined;
235
243
 
236
244
  before();
@@ -238,7 +246,7 @@ export async function typeCommand(it, command, { before = () => {}, check = menu
238
246
  // with no wait, before the first key (#480 review).
239
247
  const ready = lookAt(it);
240
248
  if (ready.why !== undefined) throw new Error(`${it.name}: nothing was typed, because ${ready.why}.`);
241
- emptyLine(ready.rows);
249
+ emptyLine(ready);
242
250
  const { handle } = ready;
243
251
  const typedAt = Date.now();
244
252
  // One character a send, each after a look through the gate, so nothing goes
@@ -329,7 +337,7 @@ async function idleTab(it) {
329
337
  }
330
338
 
331
339
  /**
332
- * One look through the typing gate at the session's tab: `{ handle }` when
340
+ * One look through the typing gate at the session's tab: `{ handle, rows, draft }` when
333
341
  * nothing on its screen asks a question and nothing says it is at work, and,
334
342
  * with `idle`, Orca's tui-idle answered ok; or `{ why }`, naming the signal,
335
343
  * with `moved` when the book holds another tab for the session now, or, where
@@ -364,7 +372,7 @@ function lookAt(it, { idle = true } = {}) {
364
372
  const signal = signalIn(found.rows);
365
373
  if (signal !== undefined) return { why: signal };
366
374
  if (idle && !found.idle) return { why: 'it is busy with a turn: Orca\'s tui-idle did not answer ok' };
367
- return { handle: found.handle, rows: found.rows };
375
+ return { handle: found.handle, rows: found.rows, draft: found.draft };
368
376
  }
369
377
 
370
378
  /**
@@ -384,7 +392,7 @@ async function typedWrong(handle, harness, command, version, check) {
384
392
  // a question that shows on it stops it at once (review of c1e5ba4).
385
393
  const signal = seen.rows === undefined ? undefined : signalIn(seen.rows);
386
394
  if (signal !== undefined) return { why: signal, rows: seen.rows };
387
- const wrong = seen.rows === undefined ? { why: `its screen could not be read (${seen.unreadable})` } : check(seen.rows, harness, command, version);
395
+ const wrong = seen.rows === undefined ? { why: `its screen could not be read (${seen.unreadable})` } : check(seen.rows, harness, command, version, seen.draft);
388
396
  if (wrong === undefined || Date.now() >= until) return wrong && { ...wrong, rows: seen.rows };
389
397
  await pause(ASK_MS);
390
398
  }
@@ -406,13 +414,17 @@ function signalIn(rows) {
406
414
  return undefined;
407
415
  }
408
416
 
409
- function menuWrong(rows, harness, command, version) {
417
+ function menuWrong(rows, harness, command, version, draft) {
410
418
  const at = rows.findLastIndex((row) => /^ *[›❯]/.test(row));
411
419
  if (at < 0) return { why: 'its screen shows no input line' };
412
420
  // Claude Code 2.1.288 puts a non-breaking space after its pointer (live run 4).
413
421
  const line = rows[at].replaceAll('\u00a0', ' ').trim();
414
- if (harness === 'codex') return codexMenuWrong(rows, at, line, command, version);
415
- if (line !== `${POINTER[harness]} ${command}`) return { why: `its input line reads "${line}"` };
422
+ // Orca 1.4.223 gives the line's text as the draft, and the line on the
423
+ // screen then reads the pointer alone: Claude Code 2.1.296's (#510, probe 4)
424
+ // and Codex 0.162.0's (#516).
425
+ const text = draft === undefined ? line : `${POINTER[harness]} ${draft}`;
426
+ if (harness === 'codex') return codexMenuWrong(rows, at, text, command, version);
427
+ if (text !== `${POINTER[harness]} ${command}`) return { why: `its input line reads "${text}"` };
416
428
  return claudeMenuWrong(rows, at, command);
417
429
  }
418
430
 
@@ -420,25 +432,30 @@ function menuWrong(rows, harness, command, version) {
420
432
  * Claude Code 2.1.288 draws its slash menu above its input box's top rule, a
421
433
  * row of ─: a row per command, starting with it, its description wrapped onto
422
434
  * rows set far in (live run 4). No pointer marks a selection, so the first
423
- * command row there has to be the command.
435
+ * command row there has to be the command. Claude Code 2.1.296 starts the
436
+ * selected row with its pointer, ` ❯ /compact`, and that row has to be the
437
+ * command (#510, probes 1 and 2). The menu's pointer is above the rule, the
438
+ * input line's below it, so the one is never taken for the other.
424
439
  */
425
440
  function claudeMenuWrong(rows, at, command) {
426
441
  const rule = rows.slice(0, at).findLastIndex((row) => /^\s*─{3}/.test(row));
427
442
  const menu = [];
428
- for (let up = rule - 1; up >= 0 && (/^ {1,4}\/\S/.test(rows[up]) || /^ {20,}\S/.test(rows[up])); up -= 1) menu.unshift(rows[up]);
429
- const first = menu.find((row) => /^ {1,4}\//.test(row));
430
- if (rule < 0 || first === undefined) return { why: 'no menu of commands came up above its input box', menu: true };
431
- if (first.trim().split(/\s+/)[0] !== command) return { why: `the first row of its menu is "${first.trim()}"`, menu: true };
443
+ for (let up = rule - 1; up >= 0 && (/^ {1,4}(?:❯ +)?\/\S/.test(rows[up]) || /^ {20,}\S/.test(rows[up])); up -= 1) menu.unshift(rows[up]);
444
+ const pointed = menu.find((row) => /^ {1,4}❯ +\//.test(row));
445
+ const selected = pointed === undefined ? menu.find((row) => /^ {1,4}\//.test(row)) : pointed.replace(/^ *❯ +/, '');
446
+ if (rule < 0 || selected === undefined) return { why: 'no menu of commands came up above its input box', menu: true };
447
+ if (selected.trim().split(/\s+/)[0] !== command) return { why: `the ${pointed === undefined ? 'first' : 'selected'} row of its menu is "${selected.trim()}"`, menu: true };
432
448
  return undefined;
433
449
  }
434
450
 
435
451
  /**
436
452
  * Codex draws its slash menu above the input line: a row per command, the
437
- * selected one starting with its pointer, then a blank row, then the input
438
- * line (its own snapshot tests, chat_composer slash_popup_*.snap,
439
- * rust-v0.160.0; live run 4). The menu has to hold one command row, the
440
- * selected one, naming the command. The input line reads the command, or, on
441
- * the Codex whose line Orca shows bare, its pointer alone.
453
+ * selected one starting with its pointer, then a blank row on 0.160.0 and none
454
+ * on 0.162.0, then the input line (its own snapshot tests, chat_composer
455
+ * slash_popup_*.snap, rust-v0.160.0; live run 4; #516's probe). The menu has
456
+ * to hold one command row, the selected one, naming the command. The input
457
+ * line, Orca's draft where it gives one, reads the command, or, with no draft
458
+ * on the Codex whose line Orca shows bare, its pointer alone.
442
459
  */
443
460
  function codexMenuWrong(rows, at, line, command, version) {
444
461
  const bare = line === '›' && version === CODEX_BARE_LINE;
@@ -483,7 +500,7 @@ async function answerWhereToRun(it, handle, command) {
483
500
  const pointer = rows.findLast((row) => /^ *›/.test(row));
484
501
  if (pointer === undefined || !CURRENT_CHECKOUT.test(pointer)) {
485
502
  send(handle, '\x1b');
486
- throw new Error(`${it.name}: after ${command}, Codex asked "${WHERE_TO_RUN}" with its selection on "${pointer?.trim() ?? 'nothing'}" rather than "1. Current checkout", so it was backed out of with Esc and nothing else was typed. Look at its tab.`);
503
+ throw new Error(`${it.name}: after ${command}, Codex asked "${WHERE_TO_RUN}" with its selection on "${pointer?.trim() ?? 'nothing'}" rather than "1. Current checkout" (on Codex 0.162.0, "1. Use current Git worktree"), so it was backed out of with Esc and nothing else was typed. Look at its tab.`);
487
504
  }
488
505
  send(handle, '\r');
489
506
  return;
package/src/cli.js CHANGED
@@ -18,14 +18,14 @@ import { checkHealth, orcaSettingFindings } from './health.js';
18
18
  import { initBots } from './init.js';
19
19
  import { nameSession } from './name.js';
20
20
  import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
21
- import { checkMail, decideLeftNudges, lookUp, noMailboxYet, sendMessage, sessionInTab } from './message.js';
21
+ import { checkMail, decideLeftNudges, lookUp, noMailboxYet, sendMessage, sessionInTab, stillUnread, WATCH_MS } from './message.js';
22
22
  import { orcaCli, orcaTrouble, RELOAD_LINE, TERMINAL_ENV } from './orca.js';
23
23
  import { allowCommand, allowIn, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
24
24
  import { pauseSessions, unpauseSessions } from './pause.js';
25
25
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
26
26
  import { restartSessions } from './restart.js';
27
27
  import { clearSession, compactSession } from './clear.js';
28
- import { retireBot, retireSession } from './retire.js';
28
+ import { retireBot, retireSession, unreadWords } from './retire.js';
29
29
  import { sentWarning } from './sent.js';
30
30
  import { readRoster } from './roster.js';
31
31
  import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
@@ -281,6 +281,11 @@ Usage:
281
281
  nudges a send there left because it could not
282
282
  tell from inside Codex's sandbox, and types each
283
283
  one or says why not.
284
+ obk session mail --bots <path> --bot <bot>
285
+ For the kit's own hook, not for typing: at the end
286
+ of a Claude session's turn, it tells the session
287
+ once about each fleet mail the kit sent it that is
288
+ still unread. It types nothing into the tab.
284
289
  obk session name --bots <path> --bot <bot>
285
290
  For the kit's own hook, not for typing: after a
286
291
  Codex session's turn, it names the session's thread
@@ -329,6 +334,7 @@ const COMMANDS = {
329
334
  'session record': ['bots', 'bot'],
330
335
  'session sent': ['bots', 'bot'],
331
336
  'session nudge': ['bots', 'bot'],
337
+ 'session mail': ['bots', 'bot'],
332
338
  'session name': ['bots', 'bot'],
333
339
  'session mailbox': ['bots', 'bot', 'session'],
334
340
  'temp make': ['bots', 'name'],
@@ -463,6 +469,7 @@ async function run(argv) {
463
469
  // there included, so it goes before anything that can complain (ADR 0032).
464
470
  if (command === SENT) return sent(path.resolve(values.bots));
465
471
  if (command === NUDGE) return nudgeLeft();
472
+ if (command === MAIL) return mailHook(path.resolve(values.bots), values.bot);
466
473
  if (command === NAME) return nameThread(path.resolve(values.bots), values.bot);
467
474
 
468
475
  // One fleet, one identity, whatever spelling of its path was given (#164).
@@ -534,6 +541,7 @@ const ANSWER_IT = [
534
541
  const RECORD = 'session record';
535
542
  const SENT = 'session sent';
536
543
  const NUDGE = 'session nudge';
544
+ const MAIL = 'session mail';
537
545
  const NAME = 'session name';
538
546
 
539
547
  /**
@@ -559,11 +567,11 @@ function sent(bots) {
559
567
  * what became of each, as Codex's `additionalContext` (#350, ADR 0034). Silent
560
568
  * when nothing was left, never a decision, and never anything but exit 0.
561
569
  */
562
- function nudgeLeft() {
570
+ async function nudgeLeft() {
563
571
  try {
564
572
  const said = JSON.parse(readFileSync(0, 'utf8'));
565
573
  if (said?.hook_event_name !== 'PostToolUse') return 0;
566
- const decided = decideLeftNudges(process.env[TAB_ENV]);
574
+ const decided = await decideLeftNudges(process.env[TAB_ENV]);
567
575
  if (decided.length > 0) {
568
576
  const words = decided.map((one) => `Your mail "${one.subject}" to ${one.to}: ${one.nudged ? "the kit's hook nudged its tab, from outside Codex's sandbox:" : "the kit's hook looked at its tab from outside Codex's sandbox, and"} ${nudgeLine(one, one.to).trim()}`);
569
577
  process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: words.join('\n') } })}\n`);
@@ -574,6 +582,24 @@ function nudgeLeft() {
574
582
  return 0;
575
583
  }
576
584
 
585
+ /**
586
+ * What the kit's Claude Code Stop hook does at a turn end (#509, ADR 0035): tell
587
+ * the session once about fleet mail still unread, as the Stop event's
588
+ * additional context, which the session goes on with. Not a block: Claude Code
589
+ * 2.1.296 draws a block's reason in red as "Stop hook error", and its additional
590
+ * context as "Stop hook feedback". Nothing typed into the tab; never anything
591
+ * but exit 0; silent whenever it cannot be sure.
592
+ */
593
+ function mailHook(bots, bot) {
594
+ try {
595
+ const reason = stillUnread(sameFleet(bots), bot, JSON.parse(readFileSync(0, 'utf8')), process.env[TAB_ENV], process.env[TERMINAL_ENV]);
596
+ if (reason !== undefined) process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'Stop', additionalContext: reason } })}\n`);
597
+ } catch {
598
+ // Nothing: a hook does not disturb the session it runs in.
599
+ }
600
+ return 0;
601
+ }
602
+
577
603
  /**
578
604
  * What the kit's Codex Stop hook does at a turn end: name the session's thread
579
605
  * after its bot and session, once it is idle (#480). Silent, never a decision,
@@ -703,6 +729,7 @@ const commands = {
703
729
  ...withLines(retired.retiredWith, retired.session),
704
730
  ...closedLines(retired.closed),
705
731
  `retired ${retired.bot} ${retired.session}: off ${path.join('bots', retired.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
732
+ ...unreadLines(retired.unread),
706
733
  ...leftLines(retired.promptsLeft),
707
734
  ],
708
735
  };
@@ -732,6 +759,7 @@ const commands = {
732
759
  retired.windowReloaded ? "reloaded Orca's window, so its sidebar no longer shows the project" : RELOAD_LINE,
733
760
  ]),
734
761
  `retired ${retired.bot}: moved to ${path.relative(bots, retired.moved)}, with its book, charter and memory`,
762
+ ...(retired.unread ?? []).flatMap((one) => unreadLines(one, `${retired.bot}/${one.session}`)),
735
763
  ...leftLines(retired.promptsLeft),
736
764
  ],
737
765
  };
@@ -788,6 +816,7 @@ const commands = {
788
816
  ...withLines(retired.retiredWith, retired.session),
789
817
  ...closedLines(retired.closed),
790
818
  `retired ${retired.bot} ${retired.session}, a temporary session of ${retired.maker}'s: off ${path.join('bots', retired.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
819
+ ...unreadLines(retired.unread),
791
820
  ...leftLines(retired.promptsLeft),
792
821
  ],
793
822
  };
@@ -1081,10 +1110,10 @@ const commands = {
1081
1110
  };
1082
1111
  },
1083
1112
 
1084
- 'message send'(bots, values) {
1113
+ async 'message send'(bots, values) {
1085
1114
  const tab = callerTab(bots, values.from === undefined);
1086
1115
  refuseWhenOrcaIsDown();
1087
- const answer = sendMessage(bots, {
1116
+ const answer = await sendMessage(bots, {
1088
1117
  to: values.to,
1089
1118
  from: values.from,
1090
1119
  tab,
@@ -1174,6 +1203,7 @@ const commands = {
1174
1203
  const said = {
1175
1204
  made: `${who} has its mailbox ${answer.mailbox}, made in this tab.`,
1176
1205
  bound: `${who}'s mailbox ${answer.mailbox} is bound to this tab.`,
1206
+ replaced: `${who}'s mailbox ${answer.replaced} is not one this Orca has, as when the book was written on another machine, so it has a new one, ${answer.mailbox}, made in this tab and written into the book.`,
1177
1207
  none: `${who} gets no mailbox: it is a Codex session with its sandbox switch off, which could not read one.`,
1178
1208
  };
1179
1209
  return { answer, lines: [said[answer.change]] };
@@ -1248,13 +1278,26 @@ function toLines(answer, bots, where) {
1248
1278
  * about the message.
1249
1279
  */
1250
1280
  function nudgeLine(answer, where) {
1281
+ const seconds = answer.watchedMs === undefined ? '' : ` in ${Math.max(Math.round(answer.watchedMs / 1000), 1)} s`;
1282
+ if (answer.signal === 'orca') return ` Orca's notice reached it${seconds}, so no line was typed: it reads the mail from there.`;
1283
+ if (answer.signal === 'hook') {
1284
+ return answer.because === 'busy'
1285
+ ? " it is busy with a turn, so nothing was typed into its tab: its own hook tells it about the mail when that turn ends."
1286
+ : ` a turn of other work started in its tab${seconds}, not Orca's notice, so nothing was typed: its own hook tells it about the mail when that turn ends.`;
1287
+ }
1288
+ const why = {
1289
+ 'no-turn': `no turn started in its tab in ${WATCH_SECONDS} s, so the kit's line was typed`,
1290
+ busy: "it is busy with a turn, and Codex takes a line into the turn it is having, so the kit's line was typed",
1291
+ 'other-turn': "a turn of other work started in its tab, not Orca's notice, so the kit's line was typed",
1292
+ }[answer.because];
1293
+ const head = why === undefined ? ' ' : ` ${why}: `;
1251
1294
  if (answer.nudged && answer.nudgeWatched === false) {
1252
- return ` its tab was typed into, and Orca could not say whether it was taken: ${answer.nudgeUnseen} The message waits in its mailbox for its next check either way.`;
1295
+ return `${head}its tab was typed into, and Orca could not say whether it was taken: ${answer.nudgeUnseen} The message waits in its mailbox for its next check either way.`;
1253
1296
  }
1254
1297
  if (answer.nudged && answer.nudgeUnseen !== undefined) {
1255
- return ' its tab was typed into, but Orca did not see the line start a turn: it may be queued behind the work in hand, or lost. The message waits in its mailbox for its next check either way.';
1298
+ return `${head}its tab was typed into, but Orca did not see the line start a turn: it may be queued behind the work in hand, or lost. The message waits in its mailbox for its next check either way.`;
1256
1299
  }
1257
- if (answer.nudged) return ' its tab was told to look; it will read it when it is done with what it is doing.';
1300
+ if (answer.nudged) return `${head}its tab was told to look; it will read it when it is done with what it is doing.`;
1258
1301
  if (answer.blocked !== undefined) {
1259
1302
  return ` its tab has something waiting to be answered (${answer.blocked}), so nothing was typed into it. Settle that, and the mail is there.`;
1260
1303
  }
@@ -1267,6 +1310,9 @@ function nudgeLine(answer, where) {
1267
1310
  return ` ${where} is not up, so nothing was typed anywhere: the message waits in its mailbox.`;
1268
1311
  }
1269
1312
 
1313
+ /** The watch before the kit's line, as the send says it (#509, ADR 0035). */
1314
+ const WATCH_SECONDS = WATCH_MS / 1000;
1315
+
1270
1316
  /**
1271
1317
  * What there is to say about the work dir this command was given: one finding
1272
1318
  * when it leads out of the bot home, through `..` or a link or as a path
@@ -1301,9 +1347,13 @@ const closedLines = (closed) => closed.map((tab) => `closed ${tab.bot} ${tab
1301
1347
  const withLines = (retiredWith, session) => retiredWith.flatMap((gone) => [
1302
1348
  ...closedLines(gone.closed),
1303
1349
  `retired ${gone.bot} ${gone.session}, a temporary session of ${gone.maker}'s, along with ${session}: off ${path.join('bots', gone.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
1350
+ ...unreadLines(gone.unread),
1304
1351
  ...leftLines(gone.promptsLeft),
1305
1352
  ]);
1306
1353
 
1354
+ /** The mail a retired session did not read, as far as the kit knows (#509). */
1355
+ const unreadLines = (unread, who = 'it') => (unread === undefined ? [] : [`unread ${unreadWords(unread, who)}`]);
1356
+
1307
1357
  /** What a retire could not remove, and how to: nothing reads these files now (#393). */
1308
1358
  const leftLines = (left = []) => left.flatMap(({ file, reason }) => [
1309
1359
  `left ${file}: it could not be removed (${reason}). Nothing reads it now.`,
@@ -159,6 +159,43 @@ export function heldAsUserTurn(harness, file, text) {
159
159
  return held;
160
160
  }
161
161
 
162
+ /**
163
+ * Where the harness keeps the conversation `id` of this bot home, and how long
164
+ * that record is now: `{ harness, file, size }`, so a later look reads only
165
+ * what was written after this moment. Undefined when there is no id, no
166
+ * record, or it cannot be read (#509).
167
+ */
168
+ export function recordMark(harness, home, id) {
169
+ if (typeof id !== 'string' || id === '') return undefined;
170
+ try {
171
+ const file = harness === 'codex' ? codexRollout(id) : claudeTranscript(home, id);
172
+ if (file === undefined) return undefined;
173
+ const found = statSync(file);
174
+ return found.isFile() ? { harness, file, size: found.size } : undefined;
175
+ } catch {
176
+ return undefined;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Whether a turn of the user's written after `mark` holds `text` anywhere in
182
+ * it (#509). A record that cannot be read says no: not seeing a thing is not
183
+ * seeing it.
184
+ */
185
+ export function userTurnSince(mark, text) {
186
+ if (mark === undefined || mark === null) return false;
187
+ let held = false;
188
+ try {
189
+ eachLine(mark.file, (line) => {
190
+ held = line !== undefined && userTexts(mark.harness, line).some((said) => said.includes(text));
191
+ return held;
192
+ }, { from: mark.size });
193
+ } catch {
194
+ return false;
195
+ }
196
+ return held;
197
+ }
198
+
162
199
  /** The texts of one record line, when it is a turn of the user's. */
163
200
  function userTexts(harness, line) {
164
201
  let parsed;
package/src/health.js CHANGED
@@ -26,7 +26,7 @@ import { botDir, botNames, botsDir, readBot, tempRoles, unknownKeys } from './bo
26
26
  import { transcriptsIn } from './conversations.js';
27
27
  import { hookTrouble } from './hooks.js';
28
28
  import { bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, sessionTrouble, SHELL_ENV, shellWord } from './launch.js';
29
- import { frontOfTab, orcaDefaultArgs, projects, tabs, wordsOfProcess } from './orca.js';
29
+ import { frontOfTab, orcaDefaultArgs, projects, runMissing, tabs, wordsOfProcess } from './orca.js';
30
30
  import { permissionsTrouble } from './permissions.js';
31
31
  import { TAB_ENV } from './record.js';
32
32
  import { agentsTrouble, rulesStamp } from './rules.js';
@@ -96,7 +96,7 @@ export function orcaSettingFindings() {
96
96
  const unreadableSettings = (where) => finding(
97
97
  'orca',
98
98
  where,
99
- `The kit could not read Orca's own settings at ${where}, so it cannot say whether Orca adds default launch arguments of its own. When those carry a permission bypass, every session Orca launches, relaunches or resumes runs in that mode whatever approval level the kit asked for.`,
99
+ `The kit could not read Orca's own settings at ${where}, so it cannot say whether Orca adds default launch arguments of its own. It looks for them in profile-state.db in each Orca profile, or in orca-data.json for an older Orca. When those carry a permission bypass, every session Orca launches, relaunches or resumes runs in that mode whatever approval level the kit asked for.`,
100
100
  );
101
101
 
102
102
  /** Whether a line of arguments carries `flag` as a word of its own. */
@@ -387,6 +387,21 @@ function inOrca(bots, home, bot, setups, sessions) {
387
387
  found.push(finding('session', entry.tab, `${bot.name}'s session ${name} is in the book with tab ${entry.tab}, and Orca has no tab of that id: the tab was closed, or the machine was restarted. The conversation is in the book, and obk up opens a tab and brings it back.`, bot.name));
388
388
  }
389
389
 
390
+ // A Run Orca does not have, as when the book was written on another
391
+ // machine: mail sent to it is refused. The session's next start makes it
392
+ // a new one (#508).
393
+ if (typeof entry?.mailbox === 'string' && runMissing(entry.mailbox)) {
394
+ // The bot's unpause leaves a session paused on its own as it is, so a
395
+ // session under both marks needs both commands.
396
+ const unpause = `${shellWord(ownCli())} unpause --bots ${shellWord(bots)} --bot ${bot.name}`;
397
+ const steps = [
398
+ ...(bot.paused === true ? [unpause] : []),
399
+ ...(bot.sessions.some((session) => session.name === name && session.paused === true) ? [`${unpause} --session ${name}`] : []),
400
+ ];
401
+ const fix = steps.length > 0 ? steps.join(', then ') : `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${bot.name} --session ${name}`;
402
+ found.push(finding('session', bookFile(home), `${bot.name}'s session ${name} has the mailbox ${entry.mailbox} in the book, and Orca has no Run of that id: it was made by another Orca, as on another machine, so mail sent to it is refused. The session's next start makes it a new one: ${fix}`, bot.name));
403
+ }
404
+
390
405
  const unclaimed = (Array.isArray(entry?.unclaimed) ? entry.unclaimed : []).filter((id) => !helpers.has(id));
391
406
  if (unclaimed.length > 0) {
392
407
  found.push(finding('leftover', bookFile(home), `The book notes conversations under ${bot.name}'s session ${name} that no session of this bot claims: ${unclaimed.join(', ')}. They ran in this bot's folder, and the kit will not say whose they were. To give one back, write it into ${bookFile(home)} under ${name} as session: <id> and run obk up again.`, bot.name));
package/src/hooks.js CHANGED
@@ -54,6 +54,20 @@ const SENT_MATCHER = 'SendMessage';
54
54
  export const sentCommand = (bots, bot, cli = ownCli()) =>
55
55
  `${shellWord(cli)} session sent --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
56
56
 
57
+ /**
58
+ * Claude Code's third kit hook: at each turn end, it tells the session once
59
+ * about fleet mail the kit sent it that is still unread (#509, ADR 0035).
60
+ * Claude Code only: on Codex a new hook entry puts every session on "Hooks
61
+ * need review", and that waits for #511. It asks Orca only when the kit's hint
62
+ * holds mail for the session, and gives Orca at most ten seconds of its thirty.
63
+ */
64
+ const MAIL_EVENT = 'Stop';
65
+ const MAIL_TIMEOUT = 30;
66
+
67
+ /** What the mail hook runs, by the kit's own path, as `hookCommand` does. */
68
+ export const mailCommand = (bots, bot, cli = ownCli()) =>
69
+ `${shellWord(cli)} session mail --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
70
+
57
71
  /**
58
72
  * Codex's second kit hook: after each command its shell tool ran, outside
59
73
  * Codex's sandbox, it decides the mail nudges a send in that command could not
@@ -250,7 +264,7 @@ function withKitHook(hooks, file, mine) {
250
264
 
251
265
  /** Each harness's kit hooks beside its session hook, as `toolHookOf` gives each. */
252
266
  const toolHooksOf = (harness, bots, bot, cli = ownCli()) =>
253
- (harness === 'claude' ? ['sent'] : ['nudge', 'name']).map((kind) => toolHookOf(kind, bots, bot, cli));
267
+ (harness === 'claude' ? ['sent', 'mail'] : ['nudge', 'name']).map((kind) => toolHookOf(kind, bots, bot, cli));
254
268
 
255
269
  /**
256
270
  * One of the kit's hooks beside its session hook: Claude Code's after a native
@@ -259,6 +273,15 @@ const toolHooksOf = (harness, bots, bot, cli = ownCli()) =>
259
273
  * goes, how the kit knows its own, and what is lost without it.
260
274
  */
261
275
  function toolHookOf(kind, bots, bot, cli = ownCli()) {
276
+ if (kind === 'mail') {
277
+ return {
278
+ kind,
279
+ event: MAIL_EVENT,
280
+ pattern: KIT_MAIL,
281
+ mine: { type: 'command', command: mailCommand(bots, bot, cli), timeout: MAIL_TIMEOUT },
282
+ missing: 'a Claude session busy when fleet mail came is never told about it at the end of its turn, and nothing is typed into its tab either (#509)',
283
+ };
284
+ }
262
285
  if (kind === 'name') {
263
286
  return {
264
287
  kind,
@@ -355,6 +378,7 @@ const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
355
378
  const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
356
379
  const KIT_SENT = new RegExp(String.raw`^(${WORD}) session sent --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
357
380
  const KIT_NUDGE = new RegExp(String.raw`^(${WORD}) session nudge --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
381
+ const KIT_MAIL = new RegExp(String.raw`^(${WORD}) session mail --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
358
382
  const KIT_NAME = new RegExp(String.raw`^(${WORD}) session name --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
359
383
 
360
384
  /** The program a bot made before the kit named itself by path runs. */
package/src/launch.js CHANGED
@@ -51,7 +51,7 @@ export const harnessOf = (session, botHarness) => (set(session.harness) ? sessio
51
51
 
52
52
  /**
53
53
  * A session's name on Claude Code, which is also the address another Claude
54
- * session writes to (ADR 0030). It goes on the launch line as `-n`, and it is
54
+ * session writes to (ADR 0035). It goes on the launch line as `-n`, and it is
55
55
  * re-applied every time the session is started: a resume keeps the name by
56
56
  * itself (tech notes, section 2), and the kit does not depend on that.
57
57
  *
@@ -224,7 +224,7 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
224
224
 
225
225
  if (harness === 'claude') {
226
226
  // The name is the address other Claude sessions write to, so it goes on
227
- // every launch line, a resume included (ADR 0030).
227
+ // every launch line, a resume included (ADR 0035).
228
228
  if (address !== undefined) words.push('-n', address);
229
229
  // The context window rides on the model name: `sonnet[1m]`.
230
230
  if (set(session.model)) words.push('--model', set(session.context) ? `${session.model}[${session.context}]` : session.model);