@assuredloop/orca-bot-kit 0.10.0 → 0.12.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/cli.js CHANGED
@@ -12,14 +12,14 @@ import path from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { parseArgs } from 'node:util';
14
14
 
15
- import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, leadsOutside, readBot, SESSION_FIELDS } from './bot.js';
15
+ import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, disallowRules, leadsOutside, readBot, SESSION_FIELDS } from './bot.js';
16
16
  import { addCommand, groomCommand, grooming, upCommand } from './groom.js';
17
17
  import { checkHealth, orcaSettingFindings } from './health.js';
18
18
  import { initBots } from './init.js';
19
19
  import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
20
20
  import { checkMail, lookUp, noMailboxYet, sendMessage } from './message.js';
21
21
  import { orcaCli, orcaTrouble, RELOAD_LINE } from './orca.js';
22
- import { allowCommand, writePermissions } from './permissions.js';
22
+ import { allowCommand, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
23
23
  import { pauseSessions, unpauseSessions } from './pause.js';
24
24
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
25
25
  import { restartSessions } from './restart.js';
@@ -56,12 +56,17 @@ Usage:
56
56
  as ours: --prompt='- a bullet', and
57
57
  --extra-arg=--search, once per extra argument.
58
58
  obk bot change --bots <path> --bot <bot> [--charter <text>]
59
- [--allow <rule> ...]
59
+ [--allow <rule> ... | --disallow <rule> ...]
60
60
  Give a bot a new charter and rebuild its AGENTS.md.
61
61
  A running session reads it when it next starts.
62
62
  --allow records a permission rule the user said
63
63
  yes to, once per rule, and writes it into the bot's
64
- Claude settings.
64
+ Claude settings. A broad rule, such as Bash(gh:*),
65
+ is refused and nothing is written.
66
+ --disallow takes back a rule the bot was allowed,
67
+ once the user has said yes to that, from bot.yaml
68
+ and the bot's settings. A rule the user added by
69
+ hand is not the kit's, and is refused.
65
70
  obk session change --bots <path> --bot <bot> --session <session>
66
71
  [--model <m>] [--effort <e>] [--context <c>]
67
72
  [--approval ${APPROVALS.join('|')}]
@@ -183,14 +188,15 @@ Usage:
183
188
  only while its tab is up in Orca.
184
189
  obk usage --bots <path> [--bot <bot>] [--session <name>] [--since <time>] [--until <time>]
185
190
  Say what your sessions have used: the conversations
186
- each one had, their calls and tokens, the models and
187
- efforts they ran at, and how often they were
188
- compacted. --since counts the calls made from that
189
- moment on, and --until the calls made before that
190
- one: a daily run asks from the last run's end up to
191
- its own, and the next starts where it stopped, so no
192
- call is counted twice. It counts tokens and never
193
- money: what a token costs is looked up live by
191
+ each one had, their calls and tokens (their
192
+ subagents' included, and how many were theirs), the
193
+ models and efforts they ran at, and how often they
194
+ were compacted. --since counts the calls made from
195
+ that moment on, and --until the calls made before
196
+ that one: a daily run asks from the last run's end
197
+ up to its own, and the next starts where it stopped,
198
+ so no call is counted twice. It counts tokens and
199
+ never money: what a token costs is looked up live by
194
200
  whoever is asking.
195
201
  obk session record --bots <path> --bot <bot>
196
202
  For the kit's own hook, not for typing: it reads
@@ -306,6 +312,7 @@ async function run(argv) {
306
312
  peek: { type: 'boolean' },
307
313
  charter: { type: 'string' },
308
314
  allow: { type: 'string', multiple: true },
315
+ disallow: { type: 'string', multiple: true },
309
316
  ...Object.fromEntries(SETTINGS.map(([flag]) => [flag, { type: 'string' }])),
310
317
  'extra-arg': { type: 'string', multiple: true },
311
318
  json: { type: 'boolean' },
@@ -578,7 +585,7 @@ const commands = {
578
585
  const skills = [linkSkills(bots, made.home, readBot(made.home))];
579
586
  // Nothing is allowed yet, so this writes nothing: it says which of the
580
587
  // kit's rules wait for the user's yes (#344).
581
- const permissions = [writePermissions(bots, made.home, readBot(made.home))].filter((entry) => entry !== undefined);
588
+ const permissions = writePermissions(bots, made.home, readBot(made.home));
582
589
  const answer = { bots, bot: made.bot, home: made.home, created: made.created, rules, skills, permissions };
583
590
  // A bot whose rules would not build is made but not finished: it has no
584
591
  // instructions, so `up` will not start it, and saying "give it a session"
@@ -603,12 +610,25 @@ const commands = {
603
610
  if (values.harness !== undefined) {
604
611
  throw new Error(`bot change does not change a bot's harness: its sessions' conversations belong to the harness they ran on. To move to ${values.harness}, give it a session on ${values.harness} with obk session add, or retire the bot with obk retire and create a new one.`);
605
612
  }
606
- if (values.charter === undefined && values.allow === undefined) {
607
- throw new Error('bot change needs --charter <text> or --allow <rule>: what to change.');
613
+ if (values.charter === undefined && values.allow === undefined && values.disallow === undefined) {
614
+ throw new Error('bot change needs --charter <text>, --allow <rule> or --disallow <rule>: what to change.');
615
+ }
616
+ if (values.allow !== undefined && values.disallow !== undefined) {
617
+ throw new Error('bot change takes --allow or --disallow, not both at once, so nothing was changed. Run one, then the other.');
608
618
  }
609
619
  // Refused before anything is written, so a bad rule, or a bad list already
610
620
  // there, leaves the charter as it was too.
611
- if (values.allow !== undefined) allowedNow(bots, values.bot, values.allow);
621
+ if (values.allow !== undefined) {
622
+ const { home } = allowedNow(bots, values.bot, values.allow);
623
+ refuseBroad(home, values.allow);
624
+ refuseNoCodexForm(readBot(home, values.bot), values.allow);
625
+ }
626
+ let takeBackFrom;
627
+ if (values.disallow !== undefined) {
628
+ const { home } = allowedNow(bots, values.bot, values.disallow, '--disallow');
629
+ takeBackFrom = takeBack(bots, home, readBot(home, values.bot), values.disallow);
630
+ disallowRules(bots, values.bot, values.disallow, { write: false });
631
+ }
612
632
 
613
633
  const answer = { bots, bot: values.bot, home: botDir(bots, values.bot) };
614
634
  const lines = [];
@@ -627,20 +647,55 @@ const commands = {
627
647
  ? `${changed.bot}'s charter is written, and its rules are not. Settle what the line above says, then: ${shellWord(ownCli())} rules build --bots ${shellWord(bots)} --bot ${changed.bot}`
628
648
  : `${changed.bot}'s charter is changed. A session that is running read the old one when it started; it reads this one when it next starts.`,
629
649
  );
650
+ // The rules a charter grants are the user's to say yes to again, so the
651
+ // change names what is allowed now and writes none of it (#353).
652
+ // An allow list that is not a list is named by rules build and health,
653
+ // and a charter already written is not undone for it.
654
+ const bot = readBot(changed.home, changed.bot);
655
+ if (values.allow === undefined && values.disallow === undefined && (runsOnClaude(bot) || runsOnCodex(bot))) {
656
+ try {
657
+ answer.beyondDefaults = beyondDefaults(bots, changed.home, bot);
658
+ lines.push(...charterRulesLines(bots, bot, answer.beyondDefaults));
659
+ } catch {
660
+ // Nothing to name here.
661
+ }
662
+ }
630
663
  }
631
664
  if (values.allow !== undefined) {
632
665
  const allowed = allowRules(bots, values.bot, values.allow);
633
666
  // Written at once, as the charter's rules are built at once (#344).
634
- const permissions = [writePermissions(bots, allowed.home, readBot(allowed.home))].filter((entry) => entry !== undefined);
667
+ const permissions = writePermissions(bots, allowed.home, readBot(allowed.home));
635
668
  Object.assign(answer, { allow: allowed.allow, permissions });
669
+ const codex = permissions.find((entry) => entry.unwritten !== undefined);
636
670
  lines.push(
637
671
  ...allowed.added.map((rule) => `${'allowed'.padEnd(9)} ${rule}`),
638
672
  ...permissionsLines(permissions, bots),
673
+ // Codex reads its rules when a session starts, not while it runs.
674
+ ...(codex === undefined || codex.written.length === 0 ? [] : [
675
+ `Codex reads ${codex.file} when a session starts: a Codex session of ${allowed.bot} that is running now gets these rules at its next start (obk restart).`,
676
+ ]),
639
677
  allowed.added.length === 0
640
678
  ? `${allowed.bot} was allowed every one of these already.`
641
679
  : `${allowed.bot}'s allow list in ${path.join('bots', allowed.bot, 'bot.yaml')} holds the user's yes.`,
642
680
  );
643
681
  }
682
+ if (values.disallow !== undefined) {
683
+ // The harness's files first, bot.yaml last: a write that fails leaves the
684
+ // rules in `allow`, so the same command can take them back again (#360).
685
+ const permissions = takeBackFrom();
686
+ const taken = disallowRules(bots, values.bot, values.disallow);
687
+ Object.assign(answer, { allow: taken.allow, disallowed: taken.disallowed, permissions });
688
+ const codex = permissions.find((entry) => entry.unwritten !== undefined);
689
+ lines.push(
690
+ ...taken.disallowed.map((rule) => `${'took back'.padEnd(9)} ${rule}`),
691
+ ...permissions.filter((entry) => entry.removed?.length > 0).map((entry) => `${'wrote'.padEnd(9)} ${path.relative(bots, entry.file)} ${entry.removed.length} permission rule${entry.removed.length === 1 ? '' : 's'} taken out`),
692
+ ...permissionsLines(permissions, bots),
693
+ ...(codex === undefined || codex.removed.length === 0 ? [] : [
694
+ `Codex reads ${codex.file} when a session starts: a Codex session of ${taken.bot} that is running now keeps ${codex.removed.length === 1 ? 'this rule' : 'these rules'} until its next start (obk restart).`,
695
+ ]),
696
+ `${taken.bot}'s allow list in ${path.join('bots', taken.bot, 'bot.yaml')} no longer holds ${taken.disallowed.length === 1 ? 'it' : 'them'}.`,
697
+ );
698
+ }
644
699
  return { answer, lines, code: trouble ? 1 : 0 };
645
700
  },
646
701
 
@@ -812,19 +867,22 @@ const commands = {
812
867
  peek: values.peek === true,
813
868
  });
814
869
  const where = `${answer.bot}/${answer.session}`;
815
-
870
+ const messages = answer.messages.flatMap((message) => [
871
+ `${'message'.padEnd(9)} ${message.from} ${message.at}`,
872
+ ` ${message.subject}`,
873
+ ...String(message.body ?? '').split('\n').map((line) => ` ${line}`),
874
+ ]);
875
+
876
+ // Trouble after some mail was read still shows that mail: it is read, and
877
+ // Orca will not hand it over again.
816
878
  if (answer.trouble !== undefined) {
817
- return { answer, lines: [`${'trouble'.padEnd(9)} ${where}`, ` ${answer.trouble}`], code: 1 };
879
+ return { answer, lines: [...messages, `${'trouble'.padEnd(9)} ${where}`, ` ${answer.trouble}`], code: 1 };
818
880
  }
819
881
 
820
882
  return {
821
883
  answer,
822
884
  lines: [
823
- ...answer.messages.flatMap((message) => [
824
- `${'message'.padEnd(9)} ${message.from} ${message.at}`,
825
- ` ${message.subject}`,
826
- ...String(message.body ?? '').split('\n').map((line) => ` ${line}`),
827
- ]),
885
+ ...messages,
828
886
  answer.messages.length === 0
829
887
  ? `Nothing is waiting for ${where}.`
830
888
  // Said plainly, because a peek leaves the same mail there to be found
@@ -1027,7 +1085,7 @@ function usageLines(usage, bots) {
1027
1085
  for (const entry of usage) {
1028
1086
  lines.push(`${'bot'.padEnd(9)} ${entry.bot}`);
1029
1087
  for (const session of entry.sessions) {
1030
- lines.push(`${'session'.padEnd(9)} ${session.name}`);
1088
+ lines.push(`${'session'.padEnd(9)} ${session.name}${'retired' in session ? ` retired ${session.retired}` : ''}`);
1031
1089
  const notCounted = notCountedLine(session.not_counted);
1032
1090
  lines.push(...session.conversations.map(conversationLine));
1033
1091
  if (session.conversations.length === 0 && notCounted.length === 0) lines.push(' nothing on record');
@@ -1067,6 +1125,7 @@ function conversationLine(one) {
1067
1125
  .map(([kind, count]) => `${kind} ${count}`)
1068
1126
  .join(' ');
1069
1127
  return ` ${one.id} ${one.calls} call${one.calls === 1 ? '' : 's'}`
1128
+ + `${one.subagent_calls > 0 ? ` of which subagents: ${one.subagent_calls} call${one.subagent_calls === 1 ? '' : 's'}` : ''}`
1070
1129
  + `${ran === '' ? '' : ` ${ran}`}`
1071
1130
  + `${used === '' ? '' : ` ${used}`}`
1072
1131
  + `${one.compactions > 0 ? ` compacted ${one.compactions}` : ''}`;
@@ -1220,12 +1279,26 @@ function permissionsOf(bots, name) {
1220
1279
  } catch {
1221
1280
  return [];
1222
1281
  }
1223
- try {
1224
- const entry = writePermissions(bots, home, bot);
1225
- return entry === undefined ? [] : [entry];
1226
- } catch (error) {
1227
- return [{ bot: name, file: path.join(home, '.claude', 'settings.json'), written: [], waiting: [], trouble: error.message }];
1228
- }
1282
+ return writePermissions(bots, home, bot, { keepGoing: true });
1283
+ }
1284
+
1285
+ /**
1286
+ * What a charter change says about the permission rules: the ones the bot is
1287
+ * allowed now beyond the kit's defaults, word for word, which stay allowed, and
1288
+ * that a new one is written only after the user's yes to the new charter's.
1289
+ */
1290
+ function charterRulesLines(bots, bot, rules) {
1291
+ const name = bot.name;
1292
+ const until = `No new rule is written until the user answers: list the rules the new charter grants, show them to the user word for word, and allow the ones they say yes to with ${shellWord(ownCli())} bot change --bots ${shellWord(bots)} --bot ${shellWord(name)} --allow <rule>`;
1293
+ if (rules.length === 0) return [`${name} is allowed no permission rules beyond the kit's defaults. ${until}`];
1294
+ const settings = runsOnClaude(bot) ? ` and ${path.join('bots', name, '.claude', 'settings.json')}` : '';
1295
+ // The kit owns the Codex file whole and rewrites it from bot.yaml (#354).
1296
+ const codex = runsOnCodex(bot) ? `, and the kit then rewrites ${path.join(bots, 'bots', name, '.codex', 'rules', 'obk.rules')} without it` : '';
1297
+ return [
1298
+ `${name} is allowed these permission rules beyond the kit's defaults, from before this change:`,
1299
+ ...rules.map((rule) => ` ${rule}`),
1300
+ `They stay allowed, whatever the user answers, until the user says yes to taking one back out of ${path.join('bots', name, 'bot.yaml')}${settings}${codex}. For one the new charter no longer grants, ask the user, and only after their yes run ${shellWord(ownCli())} bot change --bots ${shellWord(bots)} --bot ${shellWord(name)} --disallow <rule>. ${until}`,
1301
+ ];
1229
1302
  }
1230
1303
 
1231
1304
  /**
@@ -1235,12 +1308,19 @@ function permissionsOf(bots, name) {
1235
1308
  * it (#344). Nothing for a bot with nothing written and nothing waiting.
1236
1309
  */
1237
1310
  function permissionsLines(permissions, bots) {
1311
+ // A bot on both harnesses has an entry for each file, and what waits for
1312
+ // Codex is also waiting for Claude: the rules are listed once, with the first.
1313
+ const listed = new Set();
1238
1314
  return permissions.flatMap((entry) => [
1239
1315
  ...(entry.trouble === undefined ? [] : [`${'refused'.padEnd(9)} ${entry.trouble}`]),
1240
1316
  ...(entry.written.length === 0
1241
1317
  ? []
1242
1318
  : [`${'wrote'.padEnd(9)} ${path.relative(bots, entry.file)} ${entry.written.length} permission rule${entry.written.length === 1 ? '' : 's'} the user allowed`]),
1243
- ...(entry.waiting.length === 0 ? [] : [
1319
+ // What the user allowed and Codex has no form for is said at every build,
1320
+ // so a bot given a Codex session later hears of it too (#354). For a bot
1321
+ // only on Codex, `--allow` refused it already.
1322
+ ...(entry.unwritten ?? []).map((one) => `${'not'.padEnd(9)} written for Codex: ${one.rule}. ${one.why}.`),
1323
+ ...(entry.waiting.length === 0 || listed.has(entry.bot) || !listed.add(entry.bot) ? [] : [
1244
1324
  `${'waiting'.padEnd(9)} ${entry.bot}: these permission rules wait for the user's yes, and none of them is written until then:`,
1245
1325
  ...entry.waiting.map((rule) => ` ${rule}`),
1246
1326
  ` Show them to the user. Once they say yes: ${allowCommand(bots, entry.bot, entry.waiting)}`,
@@ -59,6 +59,30 @@ export function hasConversation(harness, home, id) {
59
59
  */
60
60
  export const claudeTranscript = (home, id) => path.join(claudeDir(home), `${id}.jsonl`);
61
61
 
62
+ /**
63
+ * The transcripts of the subagents a Claude Code conversation started, given the
64
+ * file the conversation is in. They sit beside it, in a folder named by its id,
65
+ * one `agent-<name>.jsonl` each, with a `.meta.json` beside each that is not a
66
+ * transcript (tech notes, section 2).
67
+ *
68
+ * No folder is a conversation that started no subagent. A folder that is there
69
+ * and cannot be listed is `unreadable`: what is in it cannot be counted, and
70
+ * taking it as empty would say nothing of that.
71
+ */
72
+ export function claudeSubagentTranscripts(file) {
73
+ const dir = path.join(file.slice(0, -'.jsonl'.length), 'subagents');
74
+ let names;
75
+ try {
76
+ names = readdirSync(dir);
77
+ } catch (error) {
78
+ return { files: [], unreadable: error.code === 'ENOENT' ? 0 : 1 };
79
+ }
80
+ return {
81
+ files: names.filter((name) => name.endsWith('.jsonl')).sort().map((name) => path.join(dir, name)),
82
+ unreadable: 0,
83
+ };
84
+ }
85
+
62
86
  /**
63
87
  * The same conversations, each with the file the harness keeps it in, for a
64
88
  * caller that has to read what is inside one rather than only know it is there,
package/src/message.js CHANGED
@@ -18,10 +18,10 @@
18
18
  import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
19
19
  import path from 'node:path';
20
20
 
21
- import { readBook } from './book.js';
21
+ import { MAILBOX_WAIT_MS, readBook, takeMailboxTurn } from './book.js';
22
22
  import { botDir, botNames, readBot } from './bot.js';
23
23
  import { harnessOf, isAddressOf, ownCli, reachesMail, shellWord } from './launch.js';
24
- import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, typeIntoTab, useMailbox } from './orca.js';
24
+ import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, typeIntoTab, useMailbox } from './orca.js';
25
25
 
26
26
  /**
27
27
  * How much of a message travels as itself. Above this it is written to a file
@@ -186,10 +186,37 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
186
186
  * check hands over all of it (issue #299).
187
187
  */
188
188
  export function checkMail(bots, { bot: botName, session: sessionName, tab, peek = false }) {
189
- const who = sessionName === undefined && botName === undefined
189
+ const asked = sessionName === undefined && botName === undefined
190
190
  ? whoIsWriting(bots, undefined, tab, '--bot <bot> [--session <name>]: whose mail to read')
191
191
  : findSession(bots, sessionName === undefined ? botName : `${botName}/${sessionName}`);
192
192
 
193
+ // The session's turn at its mailbox, so the book does not move to another tab
194
+ // between the bind and the last ack, and the book read again once it is had:
195
+ // a check that waited while `up` wrote a new tab reads nothing from the old
196
+ // one (#321).
197
+ const turn = takeMailboxTurn(asked.home, asked.session);
198
+ if (turn === undefined) {
199
+ return {
200
+ bots,
201
+ bot: asked.bot,
202
+ session: asked.session,
203
+ mailbox: asked.mailbox,
204
+ messages: [],
205
+ trouble: `waited ${MAILBOX_WAIT_MS / 1000} seconds for ${asked.bot}/${asked.session}'s mailbox while another obk was making, binding or reading it, and it did not finish. Nothing was read, and its mail is still waiting.`,
206
+ };
207
+ }
208
+ try {
209
+ return mailOf(bots, findSession(bots, `${asked.bot}/${asked.session}`), peek, Date.now() + HOLD_MS);
210
+ } finally {
211
+ turn.release();
212
+ }
213
+ }
214
+
215
+ /**
216
+ * What `checkMail` does once it has the session's turn, with `who` as the book
217
+ * says now, asking Orca nothing after `until`.
218
+ */
219
+ function mailOf(bots, who, peek, until) {
193
220
  if (who.mailbox === undefined) {
194
221
  return {
195
222
  bots,
@@ -205,7 +232,7 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
205
232
  // every message after (issue #228). A session with no live tab is read as
206
233
  // whatever its mailbox is bound to, a closed tab included, and nothing is
207
234
  // bound: the session's tab is bound again when it is back up (issue #249).
208
- const live = who.tab === undefined ? undefined : tabs(who.home).find((tab) => tab.tabId === who.tab)?.handle;
235
+ const live = who.tab === undefined ? undefined : tabs(who.home, timeLeft(until)).find((tab) => tab.tabId === who.tab)?.handle;
209
236
  // Orca 1.4.210 lets a process in a tab bind and read as that tab and no
210
237
  // other, and refuses the rest with nothing done (#317). So from inside any
211
238
  // other tab, the kit asks nothing and says where the mail can be read.
@@ -220,8 +247,8 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
220
247
  trouble: `${who.bot}/${who.session}'s mail can be read only in its own tab: Orca lets a tab bind and read its own mailbox and no other. Nothing was read, and its mail is still waiting.`,
221
248
  };
222
249
  }
223
- if (live !== undefined) useMailbox(who.mailbox, live);
224
- const handle = live ?? coordinatorOf(who.mailbox);
250
+ if (live !== undefined) useMailbox(who.mailbox, live, timeLeft(until));
251
+ const handle = live ?? coordinatorOf(who.mailbox, timeLeft(until));
225
252
  if (handle === undefined) {
226
253
  return {
227
254
  bots,
@@ -235,15 +262,60 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
235
262
  // Orca hands mail over a batch at a time and replays the oldest until it is
236
263
  // acknowledged; the acknowledgement answers with the next batch. So a read
237
264
  // takes every batch in turn, or newer mail waits behind an older one (#299).
265
+ //
266
+ // A batch is given back once it is acknowledged, and not before: one this
267
+ // check stops short of acknowledging is handed over again to the next.
238
268
  const messages = [];
239
- let found = readMailbox(who.mailbox, { peek, handle });
269
+ const read = { bots, bot: who.bot, session: who.session, mailbox: who.mailbox, read: !peek, messages };
270
+ let found = readMailbox(who.mailbox, { peek, handle }, timeLeft(until));
240
271
  for (;;) {
241
- messages.push(...(found.messages ?? []).map((message) => asMessage(bots, message)));
242
- if (peek || !found.deliveryId || (found.messages ?? []).length === 0) break;
243
- found = ackMailbox(who.mailbox, found.deliveryId, handle);
272
+ const batch = (found.messages ?? []).map((message) => asMessage(bots, message));
273
+ if (peek || !found.deliveryId || batch.length === 0) {
274
+ messages.push(...batch);
275
+ return read;
276
+ }
277
+ const wait = timeLeft(until, false);
278
+ if (wait === undefined) {
279
+ return { ...read, trouble: `${who.bot}/${who.session} has more mail than one check reads in ${HOLD_MS / 1000} seconds, so it stopped there. The rest is still waiting: check again.` };
280
+ }
281
+ try {
282
+ found = ackMailbox(who.mailbox, found.deliveryId, handle, wait);
283
+ } catch (error) {
284
+ // An ack Orca did not answer about may have been taken all the same, and
285
+ // then this batch is never handed over again: it is shown, said to be
286
+ // uncertain, rather than lost (review of PR #368). One Orca refused was
287
+ // not taken, and waits for the next check.
288
+ if (error.code === TIMED_OUT) {
289
+ messages.push(...batch);
290
+ return { ...read, trouble: `${error.message} Orca did not answer whether it took the last ${batch.length === 1 ? 'message' : `${batch.length} messages`} shown above as read, so the next check may show ${batch.length === 1 ? 'it' : 'them'} again. The rest waits for the next check.` };
291
+ }
292
+ // What was acknowledged before this is gone from Orca, and would be lost
293
+ // to everyone if the failure took it down with it.
294
+ if (messages.length === 0) throw error;
295
+ return { ...read, trouble: `${error.message} The mail shown above is read, and the rest waits for the next check.` };
296
+ }
297
+ messages.push(...batch);
244
298
  }
299
+ }
300
+
301
+ /**
302
+ * How long a check holds the session's turn, all its Orca calls together. Well
303
+ * inside the minute `up` waits for the turn before it writes a new tab, so a
304
+ * check in the old tab is done before the book moves, however slow Orca is,
305
+ * and however much mail there is (review of PR #368).
306
+ */
307
+ const HOLD_MS = 40_000;
245
308
 
246
- return { bots, bot: who.bot, session: who.session, mailbox: who.mailbox, read: !peek, messages };
309
+ /**
310
+ * What one Orca call is given: twenty seconds, as the step gives its own, or
311
+ * what is left of the check's hold, in whole seconds. With none left, the
312
+ * call is refused here, or, when `refuse` is false, undefined is answered.
313
+ */
314
+ function timeLeft(until, refuse = true) {
315
+ const left = Math.min(20_000, Math.floor((until - Date.now()) / 1000) * 1000);
316
+ if (left >= 1000) return { timeoutMs: left };
317
+ if (refuse) throw new Error(`the kit gives a mail check ${HOLD_MS / 1000} seconds of Orca's time, and Orca took them all. Nothing more was asked of it, and the mail is still waiting.`);
318
+ return undefined;
247
319
  }
248
320
 
249
321
  /** One message, in the kit's words rather than Orca's. */
package/src/orca.js CHANGED
@@ -277,9 +277,9 @@ const RELOAD_SCRIPT = fileURLToPath(new URL('./orca-reload.applescript', import.
277
277
  * the real id, so an orphaned entry is given that one: the tab id is the key
278
278
  * (PRD 6.2), and this is where Orca is asked for it (tech notes, section 1).
279
279
  */
280
- export const tabs = (home) => orca(['terminal', 'list', '--worktree', `path:${home}`]).terminals
280
+ export const tabs = (home, options) => orca(['terminal', 'list', '--worktree', `path:${home}`], options).terminals
281
281
  .map((tab) => (tab.orphaned === true
282
- ? { ...tab, tabId: orca(['terminal', 'show', '--terminal', tab.handle]).terminal.tabId }
282
+ ? { ...tab, tabId: orca(['terminal', 'show', '--terminal', tab.handle], options).terminal.tabId }
283
283
  : tab));
284
284
 
285
285
  /**
@@ -645,12 +645,12 @@ export function postMessage({ to, from, subject, body, type = 'status', thread }
645
645
  * the terminal `handle` when one is given, which is how a read works from a tab
646
646
  * bound to another Run; otherwise as this process's own terminal.
647
647
  */
648
- export const readMailbox = (id, { peek = false, handle } = {}) =>
649
- orca(['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])]);
648
+ export const readMailbox = (id, { peek = false, handle } = {}, options) =>
649
+ orca(['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])], options);
650
650
 
651
651
  /** Say a batch has been read. Orca answers with the batch after it. */
652
- export const ackMailbox = (id, delivery, handle) =>
653
- orca(['orchestration', 'check', '--run', id, ...as(handle), '--ack', delivery]);
652
+ export const ackMailbox = (id, delivery, handle, options) =>
653
+ orca(['orchestration', 'check', '--run', id, ...as(handle), '--ack', delivery], options);
654
654
 
655
655
  /** The reader of a `check`: the terminal named, or this process's own. */
656
656
  const as = (handle) => (handle === undefined ? [] : ['--terminal', handle]);