@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/README.md +8 -0
- package/SETUP.md +22 -16
- package/package.json +1 -1
- package/rules/limits.md +4 -0
- package/rules/mail.md +1 -2
- package/skills/obk-bot-building/SKILL.md +54 -13
- package/src/book.js +60 -4
- package/src/bot.js +40 -1
- package/src/cli.js +161 -34
- package/src/health.js +2 -0
- package/src/hooks.js +2 -2
- package/src/message.js +83 -11
- package/src/orca-reload.applescript +45 -0
- package/src/orca-runtime.cjs +28 -0
- package/src/orca.js +123 -26
- package/src/permissions.js +485 -0
- package/src/retire.js +12 -25
- package/src/rules.js +16 -1
- package/src/up.js +59 -18
- package/src/orca-window.cjs +0 -21
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
|
-
//
|
|
555
|
-
//
|
|
556
|
-
...(retired.project === undefined ? [] : [
|
|
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
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
...
|
|
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
|
|
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
|
|
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. */
|
|
@@ -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());
|