@assuredloop/orca-bot-kit 0.25.1 → 0.25.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.25.1",
3
+ "version": "0.25.3",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/clear.js CHANGED
@@ -19,7 +19,7 @@ import { botDir, readBot } from './bot.js';
19
19
  import { claudeTranscript, codexRollout } from './conversations.js';
20
20
  import { harnessOf, ownCli, shellWord } from './launch.js';
21
21
  import { eachLine, firstLine } from './lines.js';
22
- import { findProject, orca, QUESTION_ON_SCREEN, questionIn, screenRows, tabs, tabToTypeInto, TIMED_OUT } from './orca.js';
22
+ import { FEEDBACK_PANEL, feedbackPanelIn, findProject, orca, QUESTION_ON_SCREEN, questionIn, screenRows, tabs, tabToTypeInto, TIMED_OUT } from './orca.js';
23
23
  import { botsNamed, sessionsOf, typeListLine } from './up.js';
24
24
 
25
25
  /** The harness's own command for each, as typed into its input line. Codex's clear is `/new` (tech notes, section 3). */
@@ -201,7 +201,7 @@ async function enter(it, verb, before = () => {}) {
201
201
  if (typing === undefined) throw new Error(`${it.name}: ${TYPING_HELD}. Run this again in a moment.`);
202
202
  before();
203
203
  },
204
- cannot: (wrong) => (verb === 'compact' && wrong.menu ? ` ${harnessName(it.harness)} here cannot compact: its menu does not offer ${COMMANDS[verb][it.harness]}.` : ''),
204
+ cannot: (wrong) => (verb === 'compact' && wrong.menu && it.harness === 'codex' ? ` ${harnessName(it.harness)} here cannot compact: its menu does not offer ${COMMANDS[verb][it.harness]}.` : ''),
205
205
  });
206
206
  return { ...typed, typing };
207
207
  } catch (error) {
@@ -223,14 +223,18 @@ async function enter(it, verb, before = () => {}) {
223
223
  export async function typeCommand(it, command, { before = () => {}, check = menuWrong, cannot = () => '', deadline = Infinity } = {}) {
224
224
  // On Codex, what is typed cannot be read back once its menu is open, so
225
225
  // nothing may be in its input line before: no draft for the command to join.
226
- const emptyLine = (rows) => {
227
- if (it.harness !== 'codex') return;
226
+ // On Claude Code, Orca gives a draft in the line beside the screen (#510).
227
+ const emptyLine = ({ rows, draft }) => {
228
+ if (it.harness !== 'codex') {
229
+ if (draft !== undefined) throw new Error(`${it.name}: nothing was typed, because its input line holds a draft: it reads "${draft}".${shownEnd(rows)}`);
230
+ return;
231
+ }
228
232
  const line = rows.findLast((row) => /^ *›/.test(row))?.trim();
229
233
  if (!CODEX_EMPTY.includes(line)) {
230
234
  throw new Error(`${it.name}: nothing was typed, because its input line is not empty: it reads "${line ?? 'nothing'}".${shownEnd(rows)}`);
231
235
  }
232
236
  };
233
- emptyLine((await idleTab(it)).rows);
237
+ emptyLine(await idleTab(it));
234
238
  const version = it.harness === 'codex' ? codexVersion(readBook(it.home).sessions[it.session]?.session) : undefined;
235
239
 
236
240
  before();
@@ -238,7 +242,7 @@ export async function typeCommand(it, command, { before = () => {}, check = menu
238
242
  // with no wait, before the first key (#480 review).
239
243
  const ready = lookAt(it);
240
244
  if (ready.why !== undefined) throw new Error(`${it.name}: nothing was typed, because ${ready.why}.`);
241
- emptyLine(ready.rows);
245
+ emptyLine(ready);
242
246
  const { handle } = ready;
243
247
  const typedAt = Date.now();
244
248
  // One character a send, each after a look through the gate, so nothing goes
@@ -329,7 +333,7 @@ async function idleTab(it) {
329
333
  }
330
334
 
331
335
  /**
332
- * One look through the typing gate at the session's tab: `{ handle }` when
336
+ * One look through the typing gate at the session's tab: `{ handle, rows, draft }` when
333
337
  * nothing on its screen asks a question and nothing says it is at work, and,
334
338
  * with `idle`, Orca's tui-idle answered ok; or `{ why }`, naming the signal,
335
339
  * with `moved` when the book holds another tab for the session now, or, where
@@ -350,8 +354,8 @@ function lookAt(it, { idle = true } = {}) {
350
354
  const found = tabToTypeInto(it.home, it.tabId, idle ? LOOK_MS : CHAR_LOOK_MS);
351
355
  if (found.blocked !== undefined) {
352
356
  return {
353
- why: found.blocked === QUESTION_ON_SCREEN
354
- ? 'a question of its harness\'s own is on its screen'
357
+ why: found.blocked === FEEDBACK_PANEL ? PANEL_WHY
358
+ : found.blocked === QUESTION_ON_SCREEN ? 'a question of its harness\'s own is on its screen'
355
359
  : `a question is waiting on its screen: Orca says ${found.blocked}`,
356
360
  };
357
361
  }
@@ -364,7 +368,7 @@ function lookAt(it, { idle = true } = {}) {
364
368
  const signal = signalIn(found.rows);
365
369
  if (signal !== undefined) return { why: signal };
366
370
  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 };
371
+ return { handle: found.handle, rows: found.rows, draft: found.draft };
368
372
  }
369
373
 
370
374
  /**
@@ -384,27 +388,38 @@ async function typedWrong(handle, harness, command, version, check) {
384
388
  // a question that shows on it stops it at once (review of c1e5ba4).
385
389
  const signal = seen.rows === undefined ? undefined : signalIn(seen.rows);
386
390
  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);
391
+ const wrong = seen.rows === undefined ? { why: `its screen could not be read (${seen.unreadable})` } : check(seen.rows, harness, command, version, seen.draft);
388
392
  if (wrong === undefined || Date.now() >= until) return wrong && { ...wrong, rows: seen.rows };
389
393
  await pause(ASK_MS);
390
394
  }
391
395
  }
392
396
 
397
+ /**
398
+ * Why nothing goes in while Claude Code's panel of feedback drafts is open
399
+ * (#502): it reads single keys from the input line, and one sends a draft to
400
+ * Anthropic, so only the user answers it.
401
+ */
402
+ const PANEL_WHY = 'its panel of feedback drafts is open above its input box, and the kit does not answer it, since a draft sent from it goes to Anthropic: it clears once the user reviews, sends or dismisses the drafts in that tab';
403
+
393
404
  /** Why `rows` say not to type now, as the look says it, or undefined: a turn at work, or a question. */
394
405
  function signalIn(rows) {
395
406
  const working = rows.find((row) => AT_WORK.some((marker) => marker.test(row)));
396
407
  if (working !== undefined) return `it is busy with a turn: its screen shows "${working.trim()}"`;
408
+ if (feedbackPanelIn(rows)) return PANEL_WHY;
397
409
  if (questionIn(rows)) return 'a question of its harness\'s own is on its screen';
398
410
  return undefined;
399
411
  }
400
412
 
401
- function menuWrong(rows, harness, command, version) {
413
+ function menuWrong(rows, harness, command, version, draft) {
402
414
  const at = rows.findLastIndex((row) => /^ *[›❯]/.test(row));
403
415
  if (at < 0) return { why: 'its screen shows no input line' };
404
416
  // Claude Code 2.1.288 puts a non-breaking space after its pointer (live run 4).
405
417
  const line = rows[at].replaceAll('\u00a0', ' ').trim();
406
418
  if (harness === 'codex') return codexMenuWrong(rows, at, line, command, version);
407
- if (line !== `${POINTER[harness]} ${command}`) return { why: `its input line reads "${line}"` };
419
+ // Orca 1.4.223 gives the line's text as the draft, and Claude Code 2.1.296's
420
+ // line on the screen then reads its pointer alone (#510, probe 4).
421
+ const text = draft === undefined ? line : `${POINTER[harness]} ${draft}`;
422
+ if (text !== `${POINTER[harness]} ${command}`) return { why: `its input line reads "${text}"` };
408
423
  return claudeMenuWrong(rows, at, command);
409
424
  }
410
425
 
@@ -412,15 +427,19 @@ function menuWrong(rows, harness, command, version) {
412
427
  * Claude Code 2.1.288 draws its slash menu above its input box's top rule, a
413
428
  * row of ─: a row per command, starting with it, its description wrapped onto
414
429
  * rows set far in (live run 4). No pointer marks a selection, so the first
415
- * command row there has to be the command.
430
+ * command row there has to be the command. Claude Code 2.1.296 starts the
431
+ * selected row with its pointer, ` ❯ /compact`, and that row has to be the
432
+ * command (#510, probes 1 and 2). The menu's pointer is above the rule, the
433
+ * input line's below it, so the one is never taken for the other.
416
434
  */
417
435
  function claudeMenuWrong(rows, at, command) {
418
436
  const rule = rows.slice(0, at).findLastIndex((row) => /^\s*─{3}/.test(row));
419
437
  const menu = [];
420
- for (let up = rule - 1; up >= 0 && (/^ {1,4}\/\S/.test(rows[up]) || /^ {20,}\S/.test(rows[up])); up -= 1) menu.unshift(rows[up]);
421
- const first = menu.find((row) => /^ {1,4}\//.test(row));
422
- if (rule < 0 || first === undefined) return { why: 'no menu of commands came up above its input box', menu: true };
423
- if (first.trim().split(/\s+/)[0] !== command) return { why: `the first row of its menu is "${first.trim()}"`, menu: true };
438
+ for (let up = rule - 1; up >= 0 && (/^ {1,4}(?:❯ +)?\/\S/.test(rows[up]) || /^ {20,}\S/.test(rows[up])); up -= 1) menu.unshift(rows[up]);
439
+ const pointed = menu.find((row) => /^ {1,4}❯ +\//.test(row));
440
+ const selected = pointed === undefined ? menu.find((row) => /^ {1,4}\//.test(row)) : pointed.replace(/^ *❯ +/, '');
441
+ if (rule < 0 || selected === undefined) return { why: 'no menu of commands came up above its input box', menu: true };
442
+ if (selected.trim().split(/\s+/)[0] !== command) return { why: `the ${pointed === undefined ? 'first' : 'selected'} row of its menu is "${selected.trim()}"`, menu: true };
424
443
  return undefined;
425
444
  }
426
445
 
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,
@@ -1248,13 +1277,26 @@ function toLines(answer, bots, where) {
1248
1277
  * about the message.
1249
1278
  */
1250
1279
  function nudgeLine(answer, where) {
1280
+ const seconds = answer.watchedMs === undefined ? '' : ` in ${Math.max(Math.round(answer.watchedMs / 1000), 1)} s`;
1281
+ if (answer.signal === 'orca') return ` Orca's notice reached it${seconds}, so no line was typed: it reads the mail from there.`;
1282
+ if (answer.signal === 'hook') {
1283
+ return answer.because === 'busy'
1284
+ ? " 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."
1285
+ : ` 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.`;
1286
+ }
1287
+ const why = {
1288
+ 'no-turn': `no turn started in its tab in ${WATCH_SECONDS} s, so the kit's line was typed`,
1289
+ 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",
1290
+ 'other-turn': "a turn of other work started in its tab, not Orca's notice, so the kit's line was typed",
1291
+ }[answer.because];
1292
+ const head = why === undefined ? ' ' : ` ${why}: `;
1251
1293
  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.`;
1294
+ 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
1295
  }
1254
1296
  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.';
1297
+ 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
1298
  }
1257
- if (answer.nudged) return ' its tab was told to look; it will read it when it is done with what it is doing.';
1299
+ 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
1300
  if (answer.blocked !== undefined) {
1259
1301
  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
1302
  }
@@ -1267,6 +1309,9 @@ function nudgeLine(answer, where) {
1267
1309
  return ` ${where} is not up, so nothing was typed anywhere: the message waits in its mailbox.`;
1268
1310
  }
1269
1311
 
1312
+ /** The watch before the kit's line, as the send says it (#509, ADR 0035). */
1313
+ const WATCH_SECONDS = WATCH_MS / 1000;
1314
+
1270
1315
  /**
1271
1316
  * What there is to say about the work dir this command was given: one finding
1272
1317
  * when it leads out of the bot home, through `..` or a link or as a path
@@ -1301,9 +1346,13 @@ const closedLines = (closed) => closed.map((tab) => `closed ${tab.bot} ${tab
1301
1346
  const withLines = (retiredWith, session) => retiredWith.flatMap((gone) => [
1302
1347
  ...closedLines(gone.closed),
1303
1348
  `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`,
1349
+ ...unreadLines(gone.unread),
1304
1350
  ...leftLines(gone.promptsLeft),
1305
1351
  ]);
1306
1352
 
1353
+ /** The mail a retired session did not read, as far as the kit knows (#509). */
1354
+ const unreadLines = (unread, who = 'it') => (unread === undefined ? [] : [`unread ${unreadWords(unread, who)}`]);
1355
+
1307
1356
  /** What a retire could not remove, and how to: nothing reads these files now (#393). */
1308
1357
  const leftLines = (left = []) => left.flatMap(({ file, reason }) => [
1309
1358
  `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
@@ -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. */
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);