@assuredloop/orca-bot-kit 0.9.0 → 0.11.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,13 +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, changeBot, changeSession, createBot, leadsOutside, readBot, SESSION_FIELDS } from './bot.js';
15
+ import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, 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, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, writePermissions } from './permissions.js';
22
23
  import { pauseSessions, unpauseSessions } from './pause.js';
23
24
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
24
25
  import { restartSessions } from './restart.js';
@@ -54,9 +55,14 @@ Usage:
54
55
  given glued to its flag, so its dashes are not read
55
56
  as ours: --prompt='- a bullet', and
56
57
  --extra-arg=--search, once per extra argument.
57
- obk bot change --bots <path> --bot <bot> --charter <text>
58
+ obk bot change --bots <path> --bot <bot> [--charter <text>]
59
+ [--allow <rule> ...]
58
60
  Give a bot a new charter and rebuild its AGENTS.md.
59
61
  A running session reads it when it next starts.
62
+ --allow records a permission rule the user said
63
+ yes to, once per rule, and writes it into the bot's
64
+ Claude settings. A broad rule, such as Bash(gh:*),
65
+ is refused and nothing is written.
60
66
  obk session change --bots <path> --bot <bot> --session <session>
61
67
  [--model <m>] [--effort <e>] [--context <c>]
62
68
  [--approval ${APPROVALS.join('|')}]
@@ -300,6 +306,7 @@ async function run(argv) {
300
306
  thread: { type: 'string' },
301
307
  peek: { type: 'boolean' },
302
308
  charter: { type: 'string' },
309
+ allow: { type: 'string', multiple: true },
303
310
  ...Object.fromEntries(SETTINGS.map(([flag]) => [flag, { type: 'string' }])),
304
311
  'extra-arg': { type: 'string', multiple: true },
305
312
  json: { type: 'boolean' },
@@ -441,19 +448,19 @@ const commands = {
441
448
  const seeded = initBots(bots, values.harness);
442
449
  // The typed path was for making the folder and naming it in a refusal; from
443
450
  // here on it is the fleet, and the fleet is its real path (#164).
444
- const { tabs, rules, skills, paused, projects } = await bringUp(sameFleet(seeded.bots), { bot: BOT_FATHER });
451
+ const { tabs, rules, skills, permissions, paused, projects } = await bringUp(sameFleet(seeded.bots), { bot: BOT_FATHER });
445
452
  // Setup is the other place the PRD asks for Orca's own launch arguments to
446
453
  // be looked at (6.5), and the one where the user is still standing in front
447
454
  // of the fleet they are making. Only that one check: a folder init has just
448
455
  // made has nothing else to say about itself.
449
- const answer = { bots: seeded.bots, created: seeded.created, completed: seeded.completed, rules, skills, tabs, paused, projects, found: orcaSettingFindings() };
456
+ const answer = { bots: seeded.bots, created: seeded.created, completed: seeded.completed, rules, skills, permissions, tabs, paused, projects, found: orcaSettingFindings() };
450
457
  return { answer, lines: tabLines(answer, `Bot Father is up in Orca. Your bots folder: ${seeded.bots}`) };
451
458
  },
452
459
 
453
460
  async up(bots, values) {
454
461
  refuseWhenOrcaIsDown();
455
- const { tabs, rules, skills, paused, projects } = await bringUp(bots, { bot: values.bot, session: values.session });
456
- const answer = { bots, created: [], completed: [], rules, skills, tabs, paused, projects };
462
+ const { tabs, rules, skills, permissions, paused, projects } = await bringUp(bots, { bot: values.bot, session: values.session });
463
+ const answer = { bots, created: [], completed: [], rules, skills, permissions, tabs, paused, projects };
457
464
  const up = [...new Set(tabs.map((tab) => tab.bot))];
458
465
  const summary = up.length === 0
459
466
  ? `Nothing was brought up in Orca. Your bots folder: ${bots}`
@@ -481,8 +488,8 @@ const commands = {
481
488
 
482
489
  async restart(bots, values) {
483
490
  refuseWhenOrcaIsDown();
484
- const { closed, tabs, rules, skills, paused, projects } = await restartSessions(bots, { bot: values.bot, session: values.session });
485
- const answer = { bots, created: [], completed: [], rules, skills, tabs, paused, projects, closed };
491
+ const { closed, tabs, rules, skills, permissions, paused, projects } = await restartSessions(bots, { bot: values.bot, session: values.session });
492
+ const answer = { bots, created: [], completed: [], rules, skills, permissions, tabs, paused, projects, closed };
486
493
  const what = values.session === undefined ? values.bot : `${values.bot} ${values.session}`;
487
494
  return {
488
495
  answer,
@@ -551,9 +558,12 @@ const commands = {
551
558
  answer: { bots, ...retired },
552
559
  lines: [
553
560
  ...closedLines(retired.closed),
554
- // After every removal, whatever the window was told: Orca's window can
555
- // keep a removed project's row even after it re-reads (stablyai/orca#20102).
556
- ...(retired.project === undefined ? [] : [`removed Orca project ${retired.project}`, RELOAD_LINE]),
561
+ // Orca's window keeps a removed project's row until it is rebuilt
562
+ // (#343): the user reloads it by hand only when the kit could not.
563
+ ...(retired.project === undefined ? [] : [
564
+ `removed Orca project ${retired.project}`,
565
+ retired.windowReloaded ? "reloaded Orca's window, so its sidebar no longer shows the project" : RELOAD_LINE,
566
+ ]),
557
567
  `retired ${retired.bot}: moved to ${path.relative(bots, retired.moved)}, with its book, charter and memory`,
558
568
  ],
559
569
  };
@@ -567,7 +577,10 @@ const commands = {
567
577
  // A new bot is given what the lists already name, so it is whole before
568
578
  // anybody opens a tab on it.
569
579
  const skills = [linkSkills(bots, made.home, readBot(made.home))];
570
- const answer = { bots, bot: made.bot, home: made.home, created: made.created, rules, skills };
580
+ // Nothing is allowed yet, so this writes nothing: it says which of the
581
+ // kit's rules wait for the user's yes (#344).
582
+ const permissions = writePermissions(bots, made.home, readBot(made.home));
583
+ const answer = { bots, bot: made.bot, home: made.home, created: made.created, rules, skills, permissions };
571
584
  // A bot whose rules would not build is made but not finished: it has no
572
585
  // instructions, so `up` will not start it, and saying "give it a session"
573
586
  // would send the caller past the thing that needs settling first.
@@ -578,6 +591,7 @@ const commands = {
578
591
  ...made.created.map((entry) => `created ${entry}`),
579
592
  ...rulesLines(rules, bots),
580
593
  ...skillsLines(skills),
594
+ ...permissionsLines(permissions, bots),
581
595
  trouble
582
596
  ? `${made.bot} is written, and its rules are not. Settle what the line above says, then: ${shellWord(ownCli())} rules build --bots ${shellWord(bots)} --bot ${made.bot}`
583
597
  : `${made.bot} is written. Give it a session: ${shellWord(ownCli())} session add --bots ${shellWord(bots)} --bot ${made.bot} --name <name>`,
@@ -590,22 +604,67 @@ const commands = {
590
604
  if (values.harness !== undefined) {
591
605
  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.`);
592
606
  }
593
- const changed = changeBot(bots, values.bot, { charter: values.charter });
594
- // Rebuilt at once, as bot create builds it, so the charter a session reads
595
- // is the one the bot now has.
596
- const rules = [buildAgents(bots, changed.home, readBot(changed.home))];
597
- const trouble = rules[0].trouble !== undefined;
598
- return {
599
- answer: { bots, bot: changed.bot, home: changed.home, charter: changed.charter, rules },
600
- lines: [
607
+ if (values.charter === undefined && values.allow === undefined) {
608
+ throw new Error('bot change needs --charter <text> or --allow <rule>: what to change.');
609
+ }
610
+ // Refused before anything is written, so a bad rule, or a bad list already
611
+ // there, leaves the charter as it was too.
612
+ if (values.allow !== undefined) {
613
+ const { home } = allowedNow(bots, values.bot, values.allow);
614
+ refuseBroad(home, values.allow);
615
+ refuseNoCodexForm(readBot(home, values.bot), values.allow);
616
+ }
617
+
618
+ const answer = { bots, bot: values.bot, home: botDir(bots, values.bot) };
619
+ const lines = [];
620
+ let trouble = false;
621
+ if (values.charter !== undefined) {
622
+ const changed = changeBot(bots, values.bot, { charter: values.charter });
623
+ // Rebuilt at once, as bot create builds it, so the charter a session reads
624
+ // is the one the bot now has.
625
+ const rules = [buildAgents(bots, changed.home, readBot(changed.home))];
626
+ trouble = rules[0].trouble !== undefined;
627
+ Object.assign(answer, { charter: changed.charter, rules });
628
+ lines.push(
601
629
  `changed the charter in ${path.join('bots', changed.bot, 'bot.yaml')}`,
602
630
  ...rulesLines(rules, bots),
603
631
  trouble
604
632
  ? `${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}`
605
633
  : `${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.`,
606
- ],
607
- code: trouble ? 1 : 0,
608
- };
634
+ );
635
+ // The rules a charter grants are the user's to say yes to again, so the
636
+ // change names what is allowed now and writes none of it (#353).
637
+ // An allow list that is not a list is named by rules build and health,
638
+ // and a charter already written is not undone for it.
639
+ const bot = readBot(changed.home, changed.bot);
640
+ if (values.allow === undefined && (runsOnClaude(bot) || runsOnCodex(bot))) {
641
+ try {
642
+ answer.beyondDefaults = beyondDefaults(bots, changed.home, bot);
643
+ lines.push(...charterRulesLines(bots, bot, answer.beyondDefaults));
644
+ } catch {
645
+ // Nothing to name here.
646
+ }
647
+ }
648
+ }
649
+ if (values.allow !== undefined) {
650
+ const allowed = allowRules(bots, values.bot, values.allow);
651
+ // Written at once, as the charter's rules are built at once (#344).
652
+ const permissions = writePermissions(bots, allowed.home, readBot(allowed.home));
653
+ Object.assign(answer, { allow: allowed.allow, permissions });
654
+ const codex = permissions.find((entry) => entry.unwritten !== undefined);
655
+ lines.push(
656
+ ...allowed.added.map((rule) => `${'allowed'.padEnd(9)} ${rule}`),
657
+ ...permissionsLines(permissions, bots),
658
+ // Codex reads its rules when a session starts, not while it runs.
659
+ ...(codex === undefined || codex.written.length === 0 ? [] : [
660
+ `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).`,
661
+ ]),
662
+ allowed.added.length === 0
663
+ ? `${allowed.bot} was allowed every one of these already.`
664
+ : `${allowed.bot}'s allow list in ${path.join('bots', allowed.bot, 'bot.yaml')} holds the user's yes.`,
665
+ );
666
+ }
667
+ return { answer, lines, code: trouble ? 1 : 0 };
609
668
  },
610
669
 
611
670
  'skills build'(bots, values) {
@@ -698,17 +757,19 @@ const commands = {
698
757
 
699
758
  'rules build'(bots, values) {
700
759
  const rules = buildRules(bots, { bot: values.bot });
701
- const trouble = rules.filter((entry) => entry.trouble !== undefined);
760
+ const permissions = rules.flatMap((entry) => permissionsOf(bots, entry.bot));
761
+ const trouble = [...rules, ...permissions].filter((entry) => entry.trouble !== undefined);
702
762
  return {
703
- answer: { bots, rules },
763
+ answer: { bots, rules, permissions },
704
764
  lines: [
705
765
  ...rulesLines(rules, bots),
766
+ ...permissionsLines(permissions, bots),
706
767
  trouble.length === 0
707
768
  ? `Rules are built. Your bots folder: ${bots}`
708
769
  // Not "not built": the file itself may be fine and the trouble be
709
770
  // the CLAUDE.md beside it, which is a bot whose rules still do not
710
771
  // reach both harnesses.
711
- : `${trouble.map((entry) => entry.bot).join(', ')}: the rules are not in place. Settle what the lines above say, then build again.`,
772
+ : `${[...new Set(trouble.map((entry) => entry.bot))].join(', ')}: the rules are not in place. Settle what the lines above say, then build again.`,
712
773
  ],
713
774
  // The build is the whole of this command, so a build it could not make is
714
775
  // what the command ends in. `up` answers for its tabs and is not held to
@@ -774,19 +835,22 @@ const commands = {
774
835
  peek: values.peek === true,
775
836
  });
776
837
  const where = `${answer.bot}/${answer.session}`;
777
-
838
+ const messages = answer.messages.flatMap((message) => [
839
+ `${'message'.padEnd(9)} ${message.from} ${message.at}`,
840
+ ` ${message.subject}`,
841
+ ...String(message.body ?? '').split('\n').map((line) => ` ${line}`),
842
+ ]);
843
+
844
+ // Trouble after some mail was read still shows that mail: it is read, and
845
+ // Orca will not hand it over again.
778
846
  if (answer.trouble !== undefined) {
779
- return { answer, lines: [`${'trouble'.padEnd(9)} ${where}`, ` ${answer.trouble}`], code: 1 };
847
+ return { answer, lines: [...messages, `${'trouble'.padEnd(9)} ${where}`, ` ${answer.trouble}`], code: 1 };
780
848
  }
781
849
 
782
850
  return {
783
851
  answer,
784
852
  lines: [
785
- ...answer.messages.flatMap((message) => [
786
- `${'message'.padEnd(9)} ${message.from} ${message.at}`,
787
- ` ${message.subject}`,
788
- ...String(message.body ?? '').split('\n').map((line) => ` ${line}`),
789
- ]),
853
+ ...messages,
790
854
  answer.messages.length === 0
791
855
  ? `Nothing is waiting for ${where}.`
792
856
  // Said plainly, because a peek leaves the same mail there to be found
@@ -1169,6 +1233,68 @@ function sessionLines(session) {
1169
1233
  ];
1170
1234
  }
1171
1235
 
1236
+ /**
1237
+ * The permission rules of one bot, written as `rules build` writes them: its
1238
+ * entry, one with the trouble that stopped it, or none for a bot that does not
1239
+ * run on Claude. A bot.yaml that cannot be read is the rules build's to report.
1240
+ */
1241
+ function permissionsOf(bots, name) {
1242
+ const home = botDir(bots, name);
1243
+ let bot;
1244
+ try {
1245
+ bot = readBot(home, name);
1246
+ } catch {
1247
+ return [];
1248
+ }
1249
+ return writePermissions(bots, home, bot, { keepGoing: true });
1250
+ }
1251
+
1252
+ /**
1253
+ * What a charter change says about the permission rules: the ones the bot is
1254
+ * allowed now beyond the kit's defaults, word for word, which stay allowed, and
1255
+ * that a new one is written only after the user's yes to the new charter's.
1256
+ */
1257
+ function charterRulesLines(bots, bot, rules) {
1258
+ const name = bot.name;
1259
+ 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>`;
1260
+ if (rules.length === 0) return [`${name} is allowed no permission rules beyond the kit's defaults. ${until}`];
1261
+ const settings = runsOnClaude(bot) ? ` and ${path.join('bots', name, '.claude', 'settings.json')}` : '';
1262
+ // The kit owns the Codex file whole and rewrites it from bot.yaml (#354).
1263
+ const codex = runsOnCodex(bot) ? `, and the kit then rewrites ${path.join(bots, 'bots', name, '.codex', 'rules', 'obk.rules')} without it` : '';
1264
+ return [
1265
+ `${name} is allowed these permission rules beyond the kit's defaults, from before this change:`,
1266
+ ...rules.map((rule) => ` ${rule}`),
1267
+ `They stay allowed, whatever the user answers, until the user takes one out of ${path.join('bots', name, 'bot.yaml')}${settings}${codex}; the kit takes none out. ${until}`,
1268
+ ];
1269
+ }
1270
+
1271
+ /**
1272
+ * What became of each bot's permission rules: the file the allowed ones were
1273
+ * written into, and the kit's rules that still wait for the user's yes, each
1274
+ * word for word, with the one command that allows them once the user has said
1275
+ * it (#344). Nothing for a bot with nothing written and nothing waiting.
1276
+ */
1277
+ function permissionsLines(permissions, bots) {
1278
+ // A bot on both harnesses has an entry for each file, and what waits for
1279
+ // Codex is also waiting for Claude: the rules are listed once, with the first.
1280
+ const listed = new Set();
1281
+ return permissions.flatMap((entry) => [
1282
+ ...(entry.trouble === undefined ? [] : [`${'refused'.padEnd(9)} ${entry.trouble}`]),
1283
+ ...(entry.written.length === 0
1284
+ ? []
1285
+ : [`${'wrote'.padEnd(9)} ${path.relative(bots, entry.file)} ${entry.written.length} permission rule${entry.written.length === 1 ? '' : 's'} the user allowed`]),
1286
+ // What the user allowed and Codex has no form for is said at every build,
1287
+ // so a bot given a Codex session later hears of it too (#354). For a bot
1288
+ // only on Codex, `--allow` refused it already.
1289
+ ...(entry.unwritten ?? []).map((one) => `${'not'.padEnd(9)} written for Codex: ${one.rule}. ${one.why}.`),
1290
+ ...(entry.waiting.length === 0 || listed.has(entry.bot) || !listed.add(entry.bot) ? [] : [
1291
+ `${'waiting'.padEnd(9)} ${entry.bot}: these permission rules wait for the user's yes, and none of them is written until then:`,
1292
+ ...entry.waiting.map((rule) => ` ${rule}`),
1293
+ ` Show them to the user. Once they say yes: ${allowCommand(bots, entry.bot, entry.waiting)}`,
1294
+ ]),
1295
+ ]);
1296
+ }
1297
+
1172
1298
  /**
1173
1299
  * What became of each bot's `AGENTS.md`, in the same column as the rest of the
1174
1300
  * report: what the build did, the file it did it to, and what it carries. A
@@ -1252,12 +1378,13 @@ function settingsLine({ bot, session, running, settings, rules }) {
1252
1378
  return `${word.padEnd(9)} ${who} ${each.join(' ')} rules ${rules.state}`;
1253
1379
  }
1254
1380
 
1255
- function tabLines({ bots, created, completed, rules, skills, tabs, paused = [], projects = [], found = [] }, summary) {
1381
+ function tabLines({ bots, created, completed, rules, skills, permissions = [], tabs, paused = [], projects = [], found = [] }, summary) {
1256
1382
  const lines = [
1257
1383
  ...created.map((entry) => `created ${entry}`),
1258
1384
  ...completed.map((entry) => `completed ${entry}`),
1259
1385
  ...rulesLines(rules, bots),
1260
1386
  ...skillsLines(skills),
1387
+ ...permissionsLines(permissions, bots),
1261
1388
  ];
1262
1389
 
1263
1390
  for (const tab of tabs) {
package/src/health.js CHANGED
@@ -27,6 +27,7 @@ import { transcriptsIn } from './conversations.js';
27
27
  import { hookTrouble } from './hooks.js';
28
28
  import { bypassFlags, harnessOf, HARNESSES, ownCli, sessionTrouble, SHELL_ENV, shellWord } from './launch.js';
29
29
  import { frontOfTab, orcaDefaultArgs, projects, tabs, wordsOfProcess } from './orca.js';
30
+ import { permissionsTrouble } from './permissions.js';
30
31
  import { TAB_ENV } from './record.js';
31
32
  import { agentsTrouble, rulesStamp } from './rules.js';
32
33
  import { settingsInUse } from './settings.js';
@@ -191,6 +192,7 @@ function aboutBot(bots, name, setups, sessions) {
191
192
  ...sessionSettings(home, bot).map(said('config')),
192
193
  ...agentsTrouble(bots, home, bot).map(said('config')),
193
194
  ...hooksOf(bots, home, bot).map(said('config')),
195
+ ...permissionsTrouble(home, bot).map(said('config')),
194
196
  ...skillsTrouble(bots, home, bot).map(said('skill')),
195
197
  ...inOrca(bots, home, bot, setups, sessions),
196
198
  ];
package/src/hooks.js CHANGED
@@ -238,12 +238,12 @@ const unquoted = (word) => (word.startsWith("'") ? word.slice(1, -1).replaceAll(
238
238
  * cannot read as JSON is the user's, and guessing at it would lose what they
239
239
  * wrote, so the command says so and writes nothing.
240
240
  */
241
- function readSettings(file) {
241
+ export function readSettings(file, what = "the kit's session hook") {
242
242
  if (!existsSync(file)) return {};
243
243
 
244
244
  const parsed = parse(readFileSync(file, 'utf8'));
245
245
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
246
- throw new Error(`${file} is not readable as JSON, and the kit's session hook goes in it. Fix it or move it aside, then run the command again.`);
246
+ throw new Error(`${file} is not readable as JSON, and ${what} goes in it. Fix it or move it aside, then run the command again.`);
247
247
  }
248
248
  return parsed;
249
249
  }
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. */
@@ -0,0 +1,45 @@
1
+ -- Run by the kit as `osascript orca-reload.applescript <Orca.app path>`, after
2
+ -- a project was removed (#343, ADR 0025).
3
+ --
4
+ -- Orca's window keeps a removed project in its sidebar until the window is
5
+ -- rebuilt (stablyai/orca#23224; once a release fixes it, this can go behind a
6
+ -- version check), and its menu item Force Reload rebuilds it. This has System Events
7
+ -- click that item in the Orca at <Orca.app path>, and in no other app. The
8
+ -- item's name is localized, so it is also known by the shortcut the menu draws
9
+ -- after a tab, Orca's own ⌘⇧R. Only when that Orca is the front app: macOS
10
+ -- takes a click on a background Orca's menu and Orca does nothing, and the
11
+ -- kit never brings Orca to the front, where the user's typing would land. It
12
+ -- prints `reloaded` only when it clicked; no such Orca, Orca not in front or
13
+ -- no such item prints something else, and anything macOS refuses is an
14
+ -- error. The kit reads all of those the same way.
15
+
16
+ on run argv
17
+ set appPath to item 1 of argv
18
+ tell application "System Events"
19
+ repeat with candidate in (every application process whose name is "Orca")
20
+ if POSIX path of application file of candidate is appPath then
21
+ tell candidate
22
+ -- Menu 1 is Apple's own.
23
+ repeat with b from 2 to count of menu bar items of menu bar 1
24
+ set m to menu 1 of menu bar item b of menu bar 1
25
+ set labels to name of menu items of m
26
+ repeat with i from 1 to count of labels
27
+ set label to item i of labels
28
+ if label is not missing value then
29
+ if label is "Force Reload" or label starts with ("Force Reload" & tab) or label ends with (tab & "⌘⇧R") then
30
+ -- Asked last, right before the click, so Orca has had no time to
31
+ -- lose the front since.
32
+ if frontmost of candidate is false then return "Orca is not the front app"
33
+ click menu item i of m
34
+ return "reloaded"
35
+ end if
36
+ end if
37
+ end repeat
38
+ end repeat
39
+ end tell
40
+ return "no Force Reload in Orca's menus"
41
+ end if
42
+ end repeat
43
+ end tell
44
+ return "no Orca running from " & appPath
45
+ end run
@@ -0,0 +1,28 @@
1
+ // Run by Orca's own binary as plain Node, the way Orca's `bin/orca` runs its
2
+ // CLI: `ELECTRON_RUN_AS_NODE=1 <Orca> orca-runtime.cjs <client> <method> <params> <wait>`.
3
+ //
4
+ // It makes one call to Orca's runtime, through Orca's own client, for a method
5
+ // Orca's CLI does not offer (ADR 0025): `project.update`, which makes Orca's
6
+ // window read its projects again (#224), and `terminal.inspectProcess`, which
7
+ // says who is in front of a tab where `ps` cannot (#298). `params` is JSON. It
8
+ // prints the runtime's answer as JSON and exits 0 only when the runtime took
9
+ // the call; anything else is a non-zero exit, and the kit reads every non-zero
10
+ // exit the same way.
11
+
12
+ 'use strict';
13
+
14
+ const [client, method, params, wait] = process.argv.slice(2);
15
+
16
+ process.exitCode = 1;
17
+ const { RuntimeClient } = require(client);
18
+ new RuntimeClient(undefined, Number(wait))
19
+ .call(method, JSON.parse(params))
20
+ // A write to a pipe is not done when it returns on macOS, so the exit waits
21
+ // for it: an answer cut short would read as no answer.
22
+ .then((answer) => answer?.ok === true && new Promise((done) => {
23
+ process.stdout.write(JSON.stringify(answer), () => {
24
+ process.exitCode = 0;
25
+ done();
26
+ });
27
+ }), () => {})
28
+ .finally(() => process.exit());