@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/README.md +13 -4
- package/SETUP.md +16 -15
- package/package.json +2 -1
- package/rules/limits.md +3 -3
- package/skills/obk-bot-building/SKILL.md +56 -23
- package/src/book.js +70 -9
- package/src/bot.js +25 -9
- package/src/cli.js +112 -32
- package/src/conversations.js +24 -0
- package/src/message.js +83 -11
- package/src/orca.js +6 -6
- package/src/permissions.js +430 -16
- package/src/up.js +50 -16
- package/src/usage.js +79 -39
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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 =
|
|
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 --
|
|
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)
|
|
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 =
|
|
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
|
-
...
|
|
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
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
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)}`,
|
package/src/conversations.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
if (peek || !found.deliveryId ||
|
|
243
|
-
|
|
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
|
-
|
|
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]);
|