@assuredloop/orca-bot-kit 0.8.0 → 0.10.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 +5 -2
- package/SETUP.md +14 -4
- package/package.json +1 -1
- package/rules/limits.md +4 -0
- package/rules/mail.md +1 -2
- package/skills/obk-bot-building/SKILL.md +15 -3
- package/src/bot.js +40 -1
- package/src/cli.js +107 -27
- package/src/health.js +2 -0
- package/src/hooks.js +2 -2
- package/src/launch.js +47 -0
- package/src/orca-reload.applescript +45 -0
- package/src/orca-runtime.cjs +28 -0
- package/src/orca.js +117 -20
- package/src/permissions.js +164 -0
- package/src/retire.js +12 -25
- package/src/rules.js +16 -1
- package/src/up.js +16 -9
- package/src/orca-window.cjs +0 -21
package/README.md
CHANGED
|
@@ -529,7 +529,8 @@ name. Everything else goes through Orca's mailbox, which the kit does carry.
|
|
|
529
529
|
|
|
530
530
|
That includes a Claude session still running under a name from before the
|
|
531
531
|
token, `<bot>.<session>`: `message to` answers the mailbox road for it, never
|
|
532
|
-
that name, until the
|
|
532
|
+
that name, until the kit's launch line next starts it (`obk restart`, or
|
|
533
|
+
`obk up` after its tab is gone), which gives it a token name of its own.
|
|
533
534
|
|
|
534
535
|
A session's mailbox is made the first time the kit starts it, and written in
|
|
535
536
|
the book beside its tab. It is an Orca Run rather than the session's tab,
|
|
@@ -598,7 +599,9 @@ cannot be read as one that passed. `-- --yes` is how you say you meant it. When
|
|
|
598
599
|
Orca is not answering, the command says which Orca it asked and skips, rather
|
|
599
600
|
than report a kit that is not broken. Name files after it to run only those;
|
|
600
601
|
each must be one of the `*.test.js` files under `test/system/`, links followed,
|
|
601
|
-
and a name that is not is refused before anything runs.
|
|
602
|
+
and a name that is not is refused before anything runs. This command is the
|
|
603
|
+
only way in: a system test loaded any other way, with `node --test` or an
|
|
604
|
+
editor's runner, skips and says how to run it.
|
|
602
605
|
|
|
603
606
|
`npm run mutate` runs [StrykerJS](https://stryker-mutator.io) over the
|
|
604
607
|
JavaScript this branch changed against `main` — committed, still in the working
|
package/SETUP.md
CHANGED
|
@@ -37,7 +37,7 @@ node --version # 24.21.0 or newer
|
|
|
37
37
|
git --version
|
|
38
38
|
/Applications/Orca.app/Contents/Resources/bin/orca status --json
|
|
39
39
|
claude --version # at least one of these
|
|
40
|
-
codex --version
|
|
40
|
+
codex --version # 0.156.1 or newer
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
The Orca line must come back with `ok: true` and `result.runtime.reachable:
|
|
@@ -55,6 +55,10 @@ first release measured here that loads it without an experimental warning.
|
|
|
55
55
|
If neither harness is installed, say so and stop. If only one is, that is fine,
|
|
56
56
|
and it is the one Bot Father will run on.
|
|
57
57
|
|
|
58
|
+
If Codex is older than 0.156.1, say so and ask them to update it before a bot
|
|
59
|
+
runs on Codex. The kit starts Codex with `--no-daemon`, and older Codex refuses
|
|
60
|
+
that flag.
|
|
61
|
+
|
|
58
62
|
## 2. Make `obk` runnable
|
|
59
63
|
|
|
60
64
|
**First ask whether it already is:**
|
|
@@ -121,9 +125,14 @@ This makes the folder a git repository, seeds it, writes Bot Father, and opens
|
|
|
121
125
|
its two tabs in Orca: the management tab where they will talk to it, and an ops
|
|
122
126
|
tab, which is a plain shell for work across the whole fleet.
|
|
123
127
|
|
|
124
|
-
Read everything it prints.
|
|
128
|
+
Read everything it prints. Three parts of it matter to them:
|
|
125
129
|
|
|
126
130
|
- **what it created** — say it in a sentence, not as a file listing;
|
|
131
|
+
- **the permission rules it says wait for their yes**, when Bot Father runs on
|
|
132
|
+
Claude Code: its mail through the kit, reading a long message beside the
|
|
133
|
+
bots folder, and its commits, so auto mode does not stop it for those. Show
|
|
134
|
+
them the rules word for word and ask. Only on a yes, run the one command
|
|
135
|
+
`init` printed for them; on a no, run nothing, and nothing is written;
|
|
127
136
|
- **anything it says about Orca's own default launch arguments.** Orca adds
|
|
128
137
|
these to every agent it launches, relaunches and resumes. When they carry a
|
|
129
138
|
permission bypass, every session runs in that mode whatever approval level the
|
|
@@ -206,8 +215,9 @@ empty book and nothing says why.
|
|
|
206
215
|
Codex's trust question applies to the **repository root**, which for a bot means
|
|
207
216
|
the whole bots folder rather than the one bot. That is what they are agreeing to.
|
|
208
217
|
|
|
209
|
-
Codex's `/new` question (0.156.1
|
|
210
|
-
|
|
218
|
+
Codex's `/new` question (seen on 0.156.1; 0.157.1 did not show it in the runs
|
|
219
|
+
seen so far) is always answered with the current checkout, which is the bot
|
|
220
|
+
home. The kit never makes a git worktree, so
|
|
211
221
|
`2. New worktree` is never the answer.
|
|
212
222
|
|
|
213
223
|
**Anything you do not recognise: type nothing.** Tell them what is on the
|
package/package.json
CHANGED
package/rules/limits.md
CHANGED
|
@@ -16,3 +16,7 @@ publishing, releasing, spending money, anything another person receives.
|
|
|
16
16
|
|
|
17
17
|
When something you were told to use is missing (a model, a tool, a file), say
|
|
18
18
|
so and ask. Quietly using a different one hides the change.
|
|
19
|
+
|
|
20
|
+
Permission rules in a harness's settings file are the kit's to write, after
|
|
21
|
+
the user's yes to the exact rules: `obk bot change --allow <rule>` records and
|
|
22
|
+
writes them. Never edit them into a settings file yourself, Bot Father included.
|
package/rules/mail.md
CHANGED
|
@@ -6,8 +6,7 @@ applies: all
|
|
|
6
6
|
|
|
7
7
|
To reach another session, ask the kit for the road rather than guessing an
|
|
8
8
|
address: `"${OBK_CLI:-obk}" message to --bots <bots> --to <bot>/<session>`. It
|
|
9
|
-
answers with the address and the command that carries it.
|
|
10
|
-
two levels above the folder you start in.
|
|
9
|
+
answers with the address and the command that carries it.
|
|
11
10
|
|
|
12
11
|
Mail is queued, not an interruption. Read it when you finish what you are on.
|
|
13
12
|
|
|
@@ -201,6 +201,17 @@ say that you edited it by hand.
|
|
|
201
201
|
Run `obk --help` for the flags of the version actually installed rather than
|
|
202
202
|
trusting a line you remember.
|
|
203
203
|
|
|
204
|
+
A bot on Claude Code needs a few permission rules so that auto mode does not
|
|
205
|
+
stop it for what the kit's own rules tell it to do: its mail through the kit,
|
|
206
|
+
reading a long message kept beside the bots folder, and its commits. `obk bot
|
|
207
|
+
create`, `obk up` and `obk rules build` list the rules still waiting for a bot
|
|
208
|
+
and give one command that allows them. Show the user those rules word for word
|
|
209
|
+
and say in a line what each lets the bot do. Run the command only after their
|
|
210
|
+
yes: it records the yes in the bot's `bot.yaml` and writes the rules into its
|
|
211
|
+
settings. On a no, run nothing, and nothing is written. When the kit lists new
|
|
212
|
+
rules for a bot later, ask again. Never write a permission rule into a settings
|
|
213
|
+
file by hand.
|
|
214
|
+
|
|
204
215
|
A bot that should learn across its sessions can use its harness's own memory,
|
|
205
216
|
turned on for that bot alone, whatever the user's own setting is for everything
|
|
206
217
|
else. The harness writes it, loads it and keeps it tidy itself. On Claude Code
|
|
@@ -260,9 +271,10 @@ table: a harness that has added an option has moved them. Codex's hooks
|
|
|
260
271
|
question matters most. The kit's hook is how the book learns which
|
|
261
272
|
conversation the session is running, and until it is answered the
|
|
262
273
|
conversation has not started. Codex's trust applies to the repository root,
|
|
263
|
-
which is the whole bots folder. Codex's `/new`
|
|
264
|
-
conversation runs
|
|
265
|
-
and never `2. New worktree`:
|
|
274
|
+
which is the whole bots folder. Codex's `/new` may ask where the new
|
|
275
|
+
conversation runs (0.156.1 did; 0.157.1 has not so far); the answer is
|
|
276
|
+
always the current checkout, the bot home, and never `2. New worktree`:
|
|
277
|
+
the kit never makes a git worktree.
|
|
266
278
|
|
|
267
279
|
Where the kit says no session came up, the shell swallowed the launch line,
|
|
268
280
|
usually while it was asking its own question. Answer the shell, then close
|
package/src/bot.js
CHANGED
|
@@ -136,6 +136,9 @@ export function readBot(home, name = path.basename(home)) {
|
|
|
136
136
|
// and a list the build cannot follow is that build's to report (PRD 6.6).
|
|
137
137
|
charter: bot.charter,
|
|
138
138
|
rules: bot.rules,
|
|
139
|
+
// The permission rules the user said yes to, judged where they are written
|
|
140
|
+
// (src/permissions.js), as `rules` is judged by the build.
|
|
141
|
+
allow: bot.allow,
|
|
139
142
|
// A paused bot is one `obk up` leaves closed; its sessions keep their book.
|
|
140
143
|
...(bot.paused === true ? { paused: true } : {}),
|
|
141
144
|
sessions: sessions.map((session) => {
|
|
@@ -250,6 +253,42 @@ export function changeBot(bots, bot, { charter }) {
|
|
|
250
253
|
return { bot, home: botDir(bots, bot), charter: text };
|
|
251
254
|
}
|
|
252
255
|
|
|
256
|
+
/**
|
|
257
|
+
* The bot's `allow` list as it is, after refusing what `allowRules` would refuse:
|
|
258
|
+
* an empty rule, or a list already there that is not a list of rules. Asked
|
|
259
|
+
* before anything is written, so a refused `bot change` changes nothing, its
|
|
260
|
+
* charter included.
|
|
261
|
+
*/
|
|
262
|
+
export function allowedNow(bots, bot, rules) {
|
|
263
|
+
if (rules.some((rule) => rule.trim() === '')) {
|
|
264
|
+
throw new Error('--allow is empty. Give it the exact permission rule the user said yes to, such as Bash(git add:*).');
|
|
265
|
+
}
|
|
266
|
+
const home = existingBot(bots, bot);
|
|
267
|
+
const was = readBot(home, bot).allow ?? [];
|
|
268
|
+
if (!Array.isArray(was) || was.some((rule) => typeof rule !== 'string' || rule.trim() === '')) {
|
|
269
|
+
throw new Error(`the allow entry in ${path.join(home, BOT_YAML)} is not a list of permission rules, so nothing was written. Fix it, then run the command again.`);
|
|
270
|
+
}
|
|
271
|
+
return { home, was };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Add the permission rules the user said yes to, `rules`, to the bot's `allow`,
|
|
276
|
+
* after the ones already there and never twice. Returns { bot, home, allow,
|
|
277
|
+
* added }: the whole list now, and what this call put in it.
|
|
278
|
+
*
|
|
279
|
+
* Only the list: the rules are written into the harness's settings by the
|
|
280
|
+
* caller, as the rules build is run by the caller of `changeBot`.
|
|
281
|
+
*/
|
|
282
|
+
export function allowRules(bots, bot, rules) {
|
|
283
|
+
const { home, was } = allowedNow(bots, bot, rules);
|
|
284
|
+
const added = [...new Set(rules)].filter((rule) => !was.includes(rule));
|
|
285
|
+
const allow = [...was, ...added];
|
|
286
|
+
if (added.length > 0) {
|
|
287
|
+
editBot(bots, bot, `allow ${added.join(', ')} for ${bot}`, (doc) => doc.set('allow', allow), (before) => ({ ...before, allow }));
|
|
288
|
+
}
|
|
289
|
+
return { bot, home, allow, added };
|
|
290
|
+
}
|
|
291
|
+
|
|
253
292
|
/**
|
|
254
293
|
* Change the settings of the session `name` of the bot `bot`. Returns
|
|
255
294
|
* { bot, home, session } — the session as it now reads.
|
|
@@ -414,7 +453,7 @@ export const SESSION_FIELDS = [
|
|
|
414
453
|
];
|
|
415
454
|
|
|
416
455
|
/** Everything the top of a bot's own file can hold. */
|
|
417
|
-
const BOT_FIELDS = ['name', 'harness', 'charter', 'rules', 'skills', 'sessions', 'paused'];
|
|
456
|
+
const BOT_FIELDS = ['name', 'harness', 'charter', 'rules', 'skills', 'allow', 'sessions', 'paused'];
|
|
418
457
|
|
|
419
458
|
/**
|
|
420
459
|
* The keys in a bot's file that the kit does not know, and so that nothing
|
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, 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,13 @@ 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.
|
|
60
65
|
obk session change --bots <path> --bot <bot> --session <session>
|
|
61
66
|
[--model <m>] [--effort <e>] [--context <c>]
|
|
62
67
|
[--approval ${APPROVALS.join('|')}]
|
|
@@ -300,6 +305,7 @@ async function run(argv) {
|
|
|
300
305
|
thread: { type: 'string' },
|
|
301
306
|
peek: { type: 'boolean' },
|
|
302
307
|
charter: { type: 'string' },
|
|
308
|
+
allow: { type: 'string', multiple: true },
|
|
303
309
|
...Object.fromEntries(SETTINGS.map(([flag]) => [flag, { type: 'string' }])),
|
|
304
310
|
'extra-arg': { type: 'string', multiple: true },
|
|
305
311
|
json: { type: 'boolean' },
|
|
@@ -441,19 +447,19 @@ const commands = {
|
|
|
441
447
|
const seeded = initBots(bots, values.harness);
|
|
442
448
|
// The typed path was for making the folder and naming it in a refusal; from
|
|
443
449
|
// 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 });
|
|
450
|
+
const { tabs, rules, skills, permissions, paused, projects } = await bringUp(sameFleet(seeded.bots), { bot: BOT_FATHER });
|
|
445
451
|
// Setup is the other place the PRD asks for Orca's own launch arguments to
|
|
446
452
|
// be looked at (6.5), and the one where the user is still standing in front
|
|
447
453
|
// of the fleet they are making. Only that one check: a folder init has just
|
|
448
454
|
// 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() };
|
|
455
|
+
const answer = { bots: seeded.bots, created: seeded.created, completed: seeded.completed, rules, skills, permissions, tabs, paused, projects, found: orcaSettingFindings() };
|
|
450
456
|
return { answer, lines: tabLines(answer, `Bot Father is up in Orca. Your bots folder: ${seeded.bots}`) };
|
|
451
457
|
},
|
|
452
458
|
|
|
453
459
|
async up(bots, values) {
|
|
454
460
|
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 };
|
|
461
|
+
const { tabs, rules, skills, permissions, paused, projects } = await bringUp(bots, { bot: values.bot, session: values.session });
|
|
462
|
+
const answer = { bots, created: [], completed: [], rules, skills, permissions, tabs, paused, projects };
|
|
457
463
|
const up = [...new Set(tabs.map((tab) => tab.bot))];
|
|
458
464
|
const summary = up.length === 0
|
|
459
465
|
? `Nothing was brought up in Orca. Your bots folder: ${bots}`
|
|
@@ -481,8 +487,8 @@ const commands = {
|
|
|
481
487
|
|
|
482
488
|
async restart(bots, values) {
|
|
483
489
|
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 };
|
|
490
|
+
const { closed, tabs, rules, skills, permissions, paused, projects } = await restartSessions(bots, { bot: values.bot, session: values.session });
|
|
491
|
+
const answer = { bots, created: [], completed: [], rules, skills, permissions, tabs, paused, projects, closed };
|
|
486
492
|
const what = values.session === undefined ? values.bot : `${values.bot} ${values.session}`;
|
|
487
493
|
return {
|
|
488
494
|
answer,
|
|
@@ -551,9 +557,12 @@ const commands = {
|
|
|
551
557
|
answer: { bots, ...retired },
|
|
552
558
|
lines: [
|
|
553
559
|
...closedLines(retired.closed),
|
|
554
|
-
//
|
|
555
|
-
//
|
|
556
|
-
...(retired.project === undefined ? [] : [
|
|
560
|
+
// Orca's window keeps a removed project's row until it is rebuilt
|
|
561
|
+
// (#343): the user reloads it by hand only when the kit could not.
|
|
562
|
+
...(retired.project === undefined ? [] : [
|
|
563
|
+
`removed Orca project ${retired.project}`,
|
|
564
|
+
retired.windowReloaded ? "reloaded Orca's window, so its sidebar no longer shows the project" : RELOAD_LINE,
|
|
565
|
+
]),
|
|
557
566
|
`retired ${retired.bot}: moved to ${path.relative(bots, retired.moved)}, with its book, charter and memory`,
|
|
558
567
|
],
|
|
559
568
|
};
|
|
@@ -567,7 +576,10 @@ const commands = {
|
|
|
567
576
|
// A new bot is given what the lists already name, so it is whole before
|
|
568
577
|
// anybody opens a tab on it.
|
|
569
578
|
const skills = [linkSkills(bots, made.home, readBot(made.home))];
|
|
570
|
-
|
|
579
|
+
// Nothing is allowed yet, so this writes nothing: it says which of the
|
|
580
|
+
// kit's rules wait for the user's yes (#344).
|
|
581
|
+
const permissions = [writePermissions(bots, made.home, readBot(made.home))].filter((entry) => entry !== undefined);
|
|
582
|
+
const answer = { bots, bot: made.bot, home: made.home, created: made.created, rules, skills, permissions };
|
|
571
583
|
// A bot whose rules would not build is made but not finished: it has no
|
|
572
584
|
// instructions, so `up` will not start it, and saying "give it a session"
|
|
573
585
|
// would send the caller past the thing that needs settling first.
|
|
@@ -578,6 +590,7 @@ const commands = {
|
|
|
578
590
|
...made.created.map((entry) => `created ${entry}`),
|
|
579
591
|
...rulesLines(rules, bots),
|
|
580
592
|
...skillsLines(skills),
|
|
593
|
+
...permissionsLines(permissions, bots),
|
|
581
594
|
trouble
|
|
582
595
|
? `${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
596
|
: `${made.bot} is written. Give it a session: ${shellWord(ownCli())} session add --bots ${shellWord(bots)} --bot ${made.bot} --name <name>`,
|
|
@@ -590,22 +603,45 @@ const commands = {
|
|
|
590
603
|
if (values.harness !== undefined) {
|
|
591
604
|
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
605
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
606
|
+
if (values.charter === undefined && values.allow === undefined) {
|
|
607
|
+
throw new Error('bot change needs --charter <text> or --allow <rule>: what to change.');
|
|
608
|
+
}
|
|
609
|
+
// Refused before anything is written, so a bad rule, or a bad list already
|
|
610
|
+
// there, leaves the charter as it was too.
|
|
611
|
+
if (values.allow !== undefined) allowedNow(bots, values.bot, values.allow);
|
|
612
|
+
|
|
613
|
+
const answer = { bots, bot: values.bot, home: botDir(bots, values.bot) };
|
|
614
|
+
const lines = [];
|
|
615
|
+
let trouble = false;
|
|
616
|
+
if (values.charter !== undefined) {
|
|
617
|
+
const changed = changeBot(bots, values.bot, { charter: values.charter });
|
|
618
|
+
// Rebuilt at once, as bot create builds it, so the charter a session reads
|
|
619
|
+
// is the one the bot now has.
|
|
620
|
+
const rules = [buildAgents(bots, changed.home, readBot(changed.home))];
|
|
621
|
+
trouble = rules[0].trouble !== undefined;
|
|
622
|
+
Object.assign(answer, { charter: changed.charter, rules });
|
|
623
|
+
lines.push(
|
|
601
624
|
`changed the charter in ${path.join('bots', changed.bot, 'bot.yaml')}`,
|
|
602
625
|
...rulesLines(rules, bots),
|
|
603
626
|
trouble
|
|
604
627
|
? `${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
628
|
: `${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
|
-
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
if (values.allow !== undefined) {
|
|
632
|
+
const allowed = allowRules(bots, values.bot, values.allow);
|
|
633
|
+
// Written at once, as the charter's rules are built at once (#344).
|
|
634
|
+
const permissions = [writePermissions(bots, allowed.home, readBot(allowed.home))].filter((entry) => entry !== undefined);
|
|
635
|
+
Object.assign(answer, { allow: allowed.allow, permissions });
|
|
636
|
+
lines.push(
|
|
637
|
+
...allowed.added.map((rule) => `${'allowed'.padEnd(9)} ${rule}`),
|
|
638
|
+
...permissionsLines(permissions, bots),
|
|
639
|
+
allowed.added.length === 0
|
|
640
|
+
? `${allowed.bot} was allowed every one of these already.`
|
|
641
|
+
: `${allowed.bot}'s allow list in ${path.join('bots', allowed.bot, 'bot.yaml')} holds the user's yes.`,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
return { answer, lines, code: trouble ? 1 : 0 };
|
|
609
645
|
},
|
|
610
646
|
|
|
611
647
|
'skills build'(bots, values) {
|
|
@@ -698,17 +734,19 @@ const commands = {
|
|
|
698
734
|
|
|
699
735
|
'rules build'(bots, values) {
|
|
700
736
|
const rules = buildRules(bots, { bot: values.bot });
|
|
701
|
-
const
|
|
737
|
+
const permissions = rules.flatMap((entry) => permissionsOf(bots, entry.bot));
|
|
738
|
+
const trouble = [...rules, ...permissions].filter((entry) => entry.trouble !== undefined);
|
|
702
739
|
return {
|
|
703
|
-
answer: { bots, rules },
|
|
740
|
+
answer: { bots, rules, permissions },
|
|
704
741
|
lines: [
|
|
705
742
|
...rulesLines(rules, bots),
|
|
743
|
+
...permissionsLines(permissions, bots),
|
|
706
744
|
trouble.length === 0
|
|
707
745
|
? `Rules are built. Your bots folder: ${bots}`
|
|
708
746
|
// Not "not built": the file itself may be fine and the trouble be
|
|
709
747
|
// the CLAUDE.md beside it, which is a bot whose rules still do not
|
|
710
748
|
// 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.`,
|
|
749
|
+
: `${[...new Set(trouble.map((entry) => entry.bot))].join(', ')}: the rules are not in place. Settle what the lines above say, then build again.`,
|
|
712
750
|
],
|
|
713
751
|
// The build is the whole of this command, so a build it could not make is
|
|
714
752
|
// what the command ends in. `up` answers for its tabs and is not held to
|
|
@@ -1169,6 +1207,47 @@ function sessionLines(session) {
|
|
|
1169
1207
|
];
|
|
1170
1208
|
}
|
|
1171
1209
|
|
|
1210
|
+
/**
|
|
1211
|
+
* The permission rules of one bot, written as `rules build` writes them: its
|
|
1212
|
+
* entry, one with the trouble that stopped it, or none for a bot that does not
|
|
1213
|
+
* run on Claude. A bot.yaml that cannot be read is the rules build's to report.
|
|
1214
|
+
*/
|
|
1215
|
+
function permissionsOf(bots, name) {
|
|
1216
|
+
const home = botDir(bots, name);
|
|
1217
|
+
let bot;
|
|
1218
|
+
try {
|
|
1219
|
+
bot = readBot(home, name);
|
|
1220
|
+
} catch {
|
|
1221
|
+
return [];
|
|
1222
|
+
}
|
|
1223
|
+
try {
|
|
1224
|
+
const entry = writePermissions(bots, home, bot);
|
|
1225
|
+
return entry === undefined ? [] : [entry];
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
return [{ bot: name, file: path.join(home, '.claude', 'settings.json'), written: [], waiting: [], trouble: error.message }];
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* What became of each bot's permission rules: the file the allowed ones were
|
|
1233
|
+
* written into, and the kit's rules that still wait for the user's yes, each
|
|
1234
|
+
* word for word, with the one command that allows them once the user has said
|
|
1235
|
+
* it (#344). Nothing for a bot with nothing written and nothing waiting.
|
|
1236
|
+
*/
|
|
1237
|
+
function permissionsLines(permissions, bots) {
|
|
1238
|
+
return permissions.flatMap((entry) => [
|
|
1239
|
+
...(entry.trouble === undefined ? [] : [`${'refused'.padEnd(9)} ${entry.trouble}`]),
|
|
1240
|
+
...(entry.written.length === 0
|
|
1241
|
+
? []
|
|
1242
|
+
: [`${'wrote'.padEnd(9)} ${path.relative(bots, entry.file)} ${entry.written.length} permission rule${entry.written.length === 1 ? '' : 's'} the user allowed`]),
|
|
1243
|
+
...(entry.waiting.length === 0 ? [] : [
|
|
1244
|
+
`${'waiting'.padEnd(9)} ${entry.bot}: these permission rules wait for the user's yes, and none of them is written until then:`,
|
|
1245
|
+
...entry.waiting.map((rule) => ` ${rule}`),
|
|
1246
|
+
` Show them to the user. Once they say yes: ${allowCommand(bots, entry.bot, entry.waiting)}`,
|
|
1247
|
+
]),
|
|
1248
|
+
]);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1172
1251
|
/**
|
|
1173
1252
|
* What became of each bot's `AGENTS.md`, in the same column as the rest of the
|
|
1174
1253
|
* report: what the build did, the file it did it to, and what it carries. A
|
|
@@ -1252,12 +1331,13 @@ function settingsLine({ bot, session, running, settings, rules }) {
|
|
|
1252
1331
|
return `${word.padEnd(9)} ${who} ${each.join(' ')} rules ${rules.state}`;
|
|
1253
1332
|
}
|
|
1254
1333
|
|
|
1255
|
-
function tabLines({ bots, created, completed, rules, skills, tabs, paused = [], projects = [], found = [] }, summary) {
|
|
1334
|
+
function tabLines({ bots, created, completed, rules, skills, permissions = [], tabs, paused = [], projects = [], found = [] }, summary) {
|
|
1256
1335
|
const lines = [
|
|
1257
1336
|
...created.map((entry) => `created ${entry}`),
|
|
1258
1337
|
...completed.map((entry) => `completed ${entry}`),
|
|
1259
1338
|
...rulesLines(rules, bots),
|
|
1260
1339
|
...skillsLines(skills),
|
|
1340
|
+
...permissionsLines(permissions, bots),
|
|
1261
1341
|
];
|
|
1262
1342
|
|
|
1263
1343
|
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/launch.js
CHANGED
|
@@ -99,6 +99,48 @@ export const setsNetworkAccess = (session) =>
|
|
|
99
99
|
export const reachesMail = (session, harness) =>
|
|
100
100
|
harness !== 'codex' || !turnedOff(session);
|
|
101
101
|
|
|
102
|
+
/** Codex's flag for running without its shared background server (0.156.1 on). */
|
|
103
|
+
const NO_DAEMON = '--no-daemon';
|
|
104
|
+
|
|
105
|
+
// A string is shell text, so it is read as the shell will split it: `'--no-daemon'`
|
|
106
|
+
// is the flag, and `'/tmp/a --no-daemon b'` is one path that merely says it.
|
|
107
|
+
const setsNoDaemon = (session) => {
|
|
108
|
+
const extra = session.extra_args;
|
|
109
|
+
const words = Array.isArray(extra) ? extra.map(String) : set(extra) ? shellWords(String(extra)) : [];
|
|
110
|
+
return words.includes(NO_DAEMON);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The words a shell makes of `text`: split on blanks outside quotes, quotes
|
|
115
|
+
* removed, a backslash keeping the next character. Nothing is expanded.
|
|
116
|
+
*/
|
|
117
|
+
function shellWords(text) {
|
|
118
|
+
const words = [];
|
|
119
|
+
let word;
|
|
120
|
+
let quote;
|
|
121
|
+
for (let at = 0; at < text.length; at += 1) {
|
|
122
|
+
const char = text[at];
|
|
123
|
+
if (quote === "'") {
|
|
124
|
+
if (char === "'") quote = undefined;
|
|
125
|
+
else word += char;
|
|
126
|
+
} else if (quote === '"') {
|
|
127
|
+
if (char === '"') quote = undefined;
|
|
128
|
+
else if (char === '\\' && '"\\$`'.includes(text[at + 1] ?? ' ')) word += text[(at += 1)];
|
|
129
|
+
else word += char;
|
|
130
|
+
} else if (/\s/.test(char)) {
|
|
131
|
+
if (word !== undefined) words.push(word);
|
|
132
|
+
word = undefined;
|
|
133
|
+
} else {
|
|
134
|
+
word ??= '';
|
|
135
|
+
if (char === "'" || char === '"') quote = char;
|
|
136
|
+
else if (char === '\\') word += text[(at += 1)] ?? '';
|
|
137
|
+
else word += char;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (word !== undefined) words.push(word);
|
|
141
|
+
return words;
|
|
142
|
+
}
|
|
143
|
+
|
|
102
144
|
const turnedOff = (session) =>
|
|
103
145
|
extraWords(session.extra_args).some((word) => word.includes(`${NETWORK_ACCESS}=false`));
|
|
104
146
|
|
|
@@ -174,6 +216,11 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
|
|
|
174
216
|
// take every other flag exactly as a fresh session does.
|
|
175
217
|
if (resume !== undefined && harness === 'codex') words.push('resume');
|
|
176
218
|
words.push(...APPROVAL[harness][set(session.approval) ? session.approval : DEFAULT_APPROVAL]);
|
|
219
|
+
// Codex 0.157 shares one background server between sessions by default, and
|
|
220
|
+
// a resume through it was seen to fail; without it Codex runs as 0.156 did
|
|
221
|
+
// (tech notes, section 3, #330). Codex refuses the flag twice, so the user's
|
|
222
|
+
// own wins.
|
|
223
|
+
if (harness === 'codex' && !setsNoDaemon(session)) words.push(NO_DAEMON);
|
|
177
224
|
|
|
178
225
|
if (harness === 'claude') {
|
|
179
226
|
// The name is the address other Claude sessions write to, so it goes on
|
|
@@ -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());
|
package/src/orca.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Every call the kit makes to Orca goes through here (ADR
|
|
1
|
+
// Every call the kit makes to Orca goes through here (ADR 0025). Orca's CLI
|
|
2
2
|
// changes often, so the kit reads `--json` and never the human text, and keeps
|
|
3
3
|
// the parsing in one place.
|
|
4
4
|
//
|
|
@@ -177,7 +177,7 @@ export function asFolderProject(setupId, title) {
|
|
|
177
177
|
*/
|
|
178
178
|
export const deleteProject = (setupId) => orca(['project', 'setup-delete', '--setup', setupId]);
|
|
179
179
|
|
|
180
|
-
/** Said after a run that made
|
|
180
|
+
/** Said after a run that made or renamed a project, whatever `tellWindow` answered, and after a removal `reloadWindow` could not follow. */
|
|
181
181
|
export const RELOAD_LINE = "If Orca's sidebar does not show it, reload the window with Cmd+Shift+R.";
|
|
182
182
|
|
|
183
183
|
/** How long Orca's client waits for its runtime, and how long the kit waits for the client. */
|
|
@@ -190,13 +190,22 @@ const CLIENT_KILL_MS = 3000;
|
|
|
190
190
|
*
|
|
191
191
|
* Orca's window re-reads only when its runtime says the projects changed, and
|
|
192
192
|
* `setup-update` and `setup-delete` do not say so. `project.update` with no
|
|
193
|
-
* changes does, and Orca's CLI does not offer it
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* interface, so anything that goes wrong is a quiet false: it never throws and
|
|
197
|
-
* is never tried twice, and the caller prints `RELOAD_LINE` either way.
|
|
193
|
+
* changes does, and Orca's CLI does not offer it (ADR 0025). The caller prints
|
|
194
|
+
* `RELOAD_LINE` either way. It does not take a removed project out of the
|
|
195
|
+
* sidebar: `reloadWindow` does.
|
|
198
196
|
*/
|
|
199
|
-
export
|
|
197
|
+
export const tellWindow = (projectId) => askRuntime('project.update', { projectId, updates: {} }) !== undefined;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* One call to Orca's runtime for a method its CLI does not offer, and the
|
|
201
|
+
* runtime's answer, or undefined when there is none to be had.
|
|
202
|
+
*
|
|
203
|
+
* It goes through Orca's own runtime client out of the installed app, run by
|
|
204
|
+
* Orca's binary the way its `bin/orca` runs its CLI (ADR 0025). None of that is
|
|
205
|
+
* Orca's published interface, so anything that goes wrong is a quiet
|
|
206
|
+
* undefined: it never throws and is never tried twice.
|
|
207
|
+
*/
|
|
208
|
+
function askRuntime(method, params) {
|
|
200
209
|
try {
|
|
201
210
|
// The CLI sits at <Orca.app>/Contents/Resources/bin/orca, often reached
|
|
202
211
|
// through a link.
|
|
@@ -209,16 +218,55 @@ export function tellWindow(projectId) {
|
|
|
209
218
|
|
|
210
219
|
const asked = spawnSync(
|
|
211
220
|
path.join(contents, 'MacOS', 'Orca'),
|
|
212
|
-
[
|
|
213
|
-
{ env, stdio: 'ignore', timeout: CLIENT_KILL_MS },
|
|
221
|
+
[RUNTIME_SCRIPT, client, method, JSON.stringify(params), String(CLIENT_WAIT_MS)],
|
|
222
|
+
{ env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: CLIENT_KILL_MS },
|
|
214
223
|
);
|
|
215
|
-
|
|
224
|
+
if (asked.error !== undefined || asked.status !== 0) return undefined;
|
|
225
|
+
return parse(asked.stdout) ?? undefined;
|
|
226
|
+
} catch {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const RUNTIME_SCRIPT = fileURLToPath(new URL('./orca-runtime.cjs', import.meta.url));
|
|
232
|
+
|
|
233
|
+
/** The `osascript` this run asks. OBK_OSASCRIPT overrides it, as OBK_PS does `ps`. */
|
|
234
|
+
const osascriptCli = () => process.env.OBK_OSASCRIPT || '/usr/bin/osascript';
|
|
235
|
+
|
|
236
|
+
/** How long macOS is given to click the menu item. */
|
|
237
|
+
const RELOAD_KILL_MS = 5000;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Have Orca's window force-reload itself, after a project was removed: true
|
|
241
|
+
* only when it did (#343, ADR 0025).
|
|
242
|
+
*
|
|
243
|
+
* The window keeps a removed project in its sidebar until it is rebuilt, and
|
|
244
|
+
* `tellWindow` only relabels the row "Unknown" (stablyai/orca#23224; once a
|
|
245
|
+
* release fixes it, this can go behind a version check). Orca's own menu item
|
|
246
|
+
* Force Reload rebuilds it and keeps every terminal. So on macOS the kit has
|
|
247
|
+
* System Events click that item, in the Orca app its CLI belongs to and no
|
|
248
|
+
* other, and only while that Orca is the front app: a click on a background
|
|
249
|
+
* Orca is taken and does nothing, and the kit does not take the user's screen. None of that is Orca's published interface, and macOS may refuse it,
|
|
250
|
+
* so anything that goes wrong is a quiet false, as with `tellWindow`: it never
|
|
251
|
+
* throws and is never tried twice, and the caller prints `RELOAD_LINE`.
|
|
252
|
+
*/
|
|
253
|
+
export function reloadWindow() {
|
|
254
|
+
if (process.platform !== 'darwin') return false;
|
|
255
|
+
try {
|
|
256
|
+
// <Orca.app>/Contents/Resources/bin/orca, often reached through a link.
|
|
257
|
+
const app = path.resolve(realpathSync(orcaCli()), '..', '..', '..', '..');
|
|
258
|
+
const asked = spawnSync(osascriptCli(), [RELOAD_SCRIPT, app], {
|
|
259
|
+
encoding: 'utf8',
|
|
260
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
261
|
+
timeout: RELOAD_KILL_MS,
|
|
262
|
+
});
|
|
263
|
+
return asked.error === undefined && asked.status === 0 && asked.stdout.trim() === 'reloaded';
|
|
216
264
|
} catch {
|
|
217
265
|
return false;
|
|
218
266
|
}
|
|
219
267
|
}
|
|
220
268
|
|
|
221
|
-
const
|
|
269
|
+
const RELOAD_SCRIPT = fileURLToPath(new URL('./orca-reload.applescript', import.meta.url));
|
|
222
270
|
|
|
223
271
|
/**
|
|
224
272
|
* The live tabs of the Orca project at `home`, each under its own tab id.
|
|
@@ -282,11 +330,14 @@ export function harnessInTab(handle, timeoutMs) {
|
|
|
282
330
|
answered: answer.ok === true,
|
|
283
331
|
blockedReason: answer.result?.wait?.blockedReason,
|
|
284
332
|
agent,
|
|
285
|
-
...frontOf(shown?.ptyId),
|
|
333
|
+
...frontOf(handle, shown?.ptyId),
|
|
286
334
|
...screenOf(handle),
|
|
287
335
|
};
|
|
288
336
|
}
|
|
289
337
|
|
|
338
|
+
/** Orca's reason for a folder-trust screen, which it can go on giving once that is answered (#342). */
|
|
339
|
+
const TRUST_REASON = 'agent-trust-workspace';
|
|
340
|
+
|
|
290
341
|
/** The kit's word for a tab whose screen shows a question of its harness's own (#329). */
|
|
291
342
|
export const QUESTION_ON_SCREEN = 'question-on-screen';
|
|
292
343
|
|
|
@@ -320,7 +371,7 @@ const ON_A_CHOICE = /^( *[›❯] +)\d+\. /;
|
|
|
320
371
|
* Whether the rows of a rendered screen hold a question of the harness's own.
|
|
321
372
|
*
|
|
322
373
|
* Every one seen is a numbered list of choices with the harness's pointer on
|
|
323
|
-
* one (tech notes, section 1; ADR
|
|
374
|
+
* one (tech notes, section 1; ADR 0025). The same pointer starts the harness's
|
|
324
375
|
* input line and its echo of the user's past turns, and the input line is the
|
|
325
376
|
* lowest of them whenever it is on screen, so only the lowest pointer row is
|
|
326
377
|
* asked about: a question counts while it stands in the input line's place.
|
|
@@ -359,6 +410,14 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
|
|
|
359
410
|
// A line typed into a screen waiting for an answer is that answer: once, it
|
|
360
411
|
// confirmed Claude Code's folder-trust default, `No, exit` (tech notes,
|
|
361
412
|
// section 1).
|
|
413
|
+
// Except where Orca still reports Codex's trust screen after it was
|
|
414
|
+
// answered, over an idle screen (#342). Nothing is typed then either, but
|
|
415
|
+
// there is nothing to answer, so the kit says what it saw. Claude Code's
|
|
416
|
+
// trust list is not numbered and the screen read cannot see it, so this is
|
|
417
|
+
// for Codex alone.
|
|
418
|
+
if (seen.blockedReason === TRUST_REASON && seen.agent === 'codex' && seen.question === false) {
|
|
419
|
+
return { unsure: `Orca still reports ${TRUST_REASON} in it, and its screen shows no question: Orca can go on reporting the trust screen after it is answered, and refuse a typed line while it does, so nothing was typed` };
|
|
420
|
+
}
|
|
362
421
|
if (seen.blockedReason !== undefined) return { blocked: seen.blockedReason };
|
|
363
422
|
// The shell in front is a tab with no harness, whatever a stale
|
|
364
423
|
// `agentIdentity` says.
|
|
@@ -397,25 +456,63 @@ export function frontOfTab(handle) {
|
|
|
397
456
|
} catch (error) {
|
|
398
457
|
return { unreadable: `Orca would not show the tab: ${error.message}` };
|
|
399
458
|
}
|
|
400
|
-
return frontOf(shown?.ptyId);
|
|
459
|
+
return frontOf(handle, shown?.ptyId);
|
|
401
460
|
}
|
|
402
461
|
|
|
403
462
|
/** The `ps` this run reads. OBK_PS overrides it, as OBK_ORCA does Orca. */
|
|
404
463
|
const psCli = () => process.env.OBK_PS || '/bin/ps';
|
|
405
464
|
|
|
406
465
|
/**
|
|
407
|
-
* Who holds the terminal of the
|
|
408
|
-
* `{ front: 'program', command, pid }` with the name and
|
|
409
|
-
* leading the group in front, or
|
|
466
|
+
* Who holds the terminal of the tab `handle`, whose pane is `ptyId`:
|
|
467
|
+
* `{ front: 'shell' }`, `{ front: 'program', command, pid }` with the name and,
|
|
468
|
+
* where `ps` gave it, the pid of the process leading the group in front, or
|
|
469
|
+
* `{ unreadable: <why> }`.
|
|
470
|
+
*
|
|
471
|
+
* `ps` is asked first. Where it cannot read the tab, as inside Codex's
|
|
472
|
+
* sandbox, where it does not start at all, Orca's runtime is asked instead
|
|
473
|
+
* (#298, ADR 0025).
|
|
474
|
+
*/
|
|
475
|
+
function frontOf(handle, ptyId) {
|
|
476
|
+
const read = frontByPs(ptyId);
|
|
477
|
+
if (read.unreadable === undefined) return read;
|
|
478
|
+
const asked = frontByOrca(handle);
|
|
479
|
+
return asked.unreadable === undefined ? asked : { unreadable: `${read.unreadable}, and ${asked.unreadable}` };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Who holds the tab's terminal as Orca's runtime says it, through
|
|
484
|
+
* `terminal.inspectProcess`: Orca's own daemon reads the process table from
|
|
485
|
+
* outside any sandbox of the kit's, and names what leads the terminal's
|
|
486
|
+
* foreground group. Only a `live` answer counts. A process named there is the
|
|
487
|
+
* program in front; none named and no child in front is the shell. Anything
|
|
488
|
+
* else is "cannot tell": for a moment after a harness quits, Orca can name
|
|
489
|
+
* nothing and still see a child in front (read in the Orca 1.4.212 bundle).
|
|
490
|
+
*/
|
|
491
|
+
function frontByOrca(handle) {
|
|
492
|
+
const seen = askRuntime('terminal.inspectProcess', { terminal: handle })?.result?.process;
|
|
493
|
+
const evidence = seen?.foregroundProcessEvidence;
|
|
494
|
+
if (evidence?.verdict !== 'live') {
|
|
495
|
+
const why = typeof evidence?.reason === 'string' ? `: ${evidence.reason}` : '';
|
|
496
|
+
return { unreadable: `Orca's runtime could not say who is in front of it${why}` };
|
|
497
|
+
}
|
|
498
|
+
const name = (word) => typeof word === 'string' && word !== '';
|
|
499
|
+
if (name(evidence.processName)) return { front: 'program', command: evidence.processName };
|
|
500
|
+
if (evidence.processName === null && name(seen.foregroundProcess)) return { front: 'program', command: seen.foregroundProcess };
|
|
501
|
+
if (evidence.processName === null && seen.foregroundProcess === null && seen.hasChildProcesses === false) return { front: 'shell' };
|
|
502
|
+
return { unreadable: "Orca's runtime gave no answer the kit can read about who is in front of it" };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Who holds the terminal of the pane `ptyId`, as `ps` says it.
|
|
410
507
|
*
|
|
411
508
|
* Orca gives the pane's pid in `diagnostics memory` and nowhere else, and `ps`
|
|
412
|
-
* gives that pid's terminal's foreground process group (ADR
|
|
509
|
+
* gives that pid's terminal's foreground process group (ADR 0025).
|
|
413
510
|
* On macOS the pane is `login` with the shell as its child, so the shell is in
|
|
414
511
|
* front when the group is the pane's own or that of a child of a `login` pane.
|
|
415
512
|
* `diagnostics memory` is a diagnostics command and may change, so everything
|
|
416
513
|
* here ends in "cannot tell" rather than in an error or a guess.
|
|
417
514
|
*/
|
|
418
|
-
function
|
|
515
|
+
function frontByPs(ptyId) {
|
|
419
516
|
let pane;
|
|
420
517
|
try {
|
|
421
518
|
pane = orca(['diagnostics', 'memory']).worktrees
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The permission rules a bot may run with, written into its own Claude
|
|
2
|
+
// settings, and only the ones its user said yes to (#344).
|
|
3
|
+
//
|
|
4
|
+
// A bot in auto mode is stopped by the harness's check for things the kit's own
|
|
5
|
+
// rules tell every bot to do: read and send its mail, read a long message's
|
|
6
|
+
// body beside the bots folder, commit. A matching allow rule is settled before
|
|
7
|
+
// that check (Claude Code's permissions docs), so the kit offers every Claude
|
|
8
|
+
// bot a small default set, spelled with its real CLI and bots folder. The user
|
|
9
|
+
// sees the exact rules and says yes; `bot change --allow` keeps that yes in
|
|
10
|
+
// bot.yaml's `allow`, and the kit's code writes what `allow` holds into
|
|
11
|
+
// `.claude/settings.json` in the bot folder, beside its hook (ADR 0022). A model
|
|
12
|
+
// editing the file by hand may get the format wrong; code does not.
|
|
13
|
+
//
|
|
14
|
+
// The kit owns only the entries `allow` holds. Any other entry in the file is
|
|
15
|
+
// left where it is, and health names it.
|
|
16
|
+
|
|
17
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { leadsOutside } from './bot.js';
|
|
21
|
+
import { readSettings } from './hooks.js';
|
|
22
|
+
import { harnessOf, ownCli, shellWord } from './launch.js';
|
|
23
|
+
|
|
24
|
+
/** Where Claude Code reads a project's settings, inside the bot home. */
|
|
25
|
+
const FILE = '.claude/settings.json';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The rules every Claude bot is offered, in this order. The mail commands are
|
|
29
|
+
* narrowed to this bots folder and spelled as the kit prints them in its
|
|
30
|
+
* nudge and in `message to`'s answer, which is what a bot runs. `//` is Claude's
|
|
31
|
+
* form for an absolute path.
|
|
32
|
+
*/
|
|
33
|
+
export function defaultRules(bots, cli = ownCli()) {
|
|
34
|
+
const kit = shellWord(cli);
|
|
35
|
+
const folder = shellWord(bots);
|
|
36
|
+
return [
|
|
37
|
+
`Bash(${kit} message check --bots ${folder}:*)`,
|
|
38
|
+
`Bash(${kit} message send --bots ${folder}:*)`,
|
|
39
|
+
`Bash(${kit} message to --bots ${folder}:*)`,
|
|
40
|
+
`Read(/${bots}.messages/**)`,
|
|
41
|
+
'Bash(git add:*)',
|
|
42
|
+
'Bash(git commit:*)',
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether any of a bot's sessions, or the bot itself, runs on Claude Code. */
|
|
47
|
+
export const runsOnClaude = (bot) => bot.harness === 'claude'
|
|
48
|
+
|| bot.sessions.some((session) => harnessOf(session, bot.harness) === 'claude');
|
|
49
|
+
|
|
50
|
+
/** The rules the user allowed this bot, read from its bot.yaml, or a refusal naming the file. */
|
|
51
|
+
export function allowOf(home, bot) {
|
|
52
|
+
const value = bot.allow;
|
|
53
|
+
if (value === undefined || value === null) return [];
|
|
54
|
+
const file = path.join(home, 'bot.yaml');
|
|
55
|
+
if (!Array.isArray(value) || value.some((rule) => typeof rule !== 'string' || rule.trim() === '')) {
|
|
56
|
+
throw new Error(`the allow entry in ${file} is not a list of permission rules, such as Bash(git add:*), so none of it is written. Fix it, then run the command again.`);
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The default rules this bot has not been allowed yet, in the default order. */
|
|
62
|
+
export function waitingFor(bots, home, bot) {
|
|
63
|
+
if (!runsOnClaude(bot)) return [];
|
|
64
|
+
const allowed = allowOf(home, bot);
|
|
65
|
+
return defaultRules(bots).filter((rule) => !allowed.includes(rule));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The one command that allows `rules` for `bot`, as the user can run it. */
|
|
69
|
+
export const allowCommand = (bots, bot, rules) => [
|
|
70
|
+
shellWord(ownCli()), 'bot', 'change', '--bots', shellWord(bots), '--bot', shellWord(bot),
|
|
71
|
+
...rules.flatMap((rule) => ['--allow', shellWord(rule)]),
|
|
72
|
+
].join(' ');
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Write what the bot is allowed into its Claude settings, and say what waits:
|
|
76
|
+
* `{ bot, file, written, waiting }`, with `written` the rules this run added.
|
|
77
|
+
* Nothing for a bot that does not run on Claude.
|
|
78
|
+
*
|
|
79
|
+
* Only rules `allow` holds are written. Everything else in the file stays as it
|
|
80
|
+
* is, the user's own entries and their order included; a rule missing from the
|
|
81
|
+
* file goes after them, and a run with nothing to add writes nothing.
|
|
82
|
+
*/
|
|
83
|
+
export function writePermissions(bots, home, bot) {
|
|
84
|
+
if (!runsOnClaude(bot)) return undefined;
|
|
85
|
+
const file = path.join(home, FILE);
|
|
86
|
+
const allowed = allowOf(home, bot);
|
|
87
|
+
const waiting = waitingFor(bots, home, bot);
|
|
88
|
+
const answer = { bot: bot.name, file, written: [], waiting };
|
|
89
|
+
if (allowed.length === 0) return answer;
|
|
90
|
+
|
|
91
|
+
refuseOutside(home, file);
|
|
92
|
+
const settings = readSettings(file, 'the permission rules the user allowed');
|
|
93
|
+
const present = presentIn(settings, file);
|
|
94
|
+
const missing = [...new Set(allowed)].filter((rule) => !present.includes(rule));
|
|
95
|
+
if (missing.length === 0) return answer;
|
|
96
|
+
|
|
97
|
+
const wanted = { ...settings, permissions: { ...settings.permissions, allow: [...present, ...missing] } };
|
|
98
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
99
|
+
writeFileSync(file, `${JSON.stringify(wanted, null, 2)}\n`);
|
|
100
|
+
return { ...answer, written: missing };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What health has to say about a bot's permission rules: `{ where, says }` for
|
|
105
|
+
* each entry in the file that `allow` does not hold, and each rule `allow`
|
|
106
|
+
* holds that the file lacks. Read only: an entry the kit did not write is named,
|
|
107
|
+
* never taken out.
|
|
108
|
+
*/
|
|
109
|
+
export function permissionsTrouble(home, bot) {
|
|
110
|
+
if (!runsOnClaude(bot)) return [];
|
|
111
|
+
const file = path.join(home, FILE);
|
|
112
|
+
|
|
113
|
+
let allowed;
|
|
114
|
+
try {
|
|
115
|
+
allowed = allowOf(home, bot);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
return [{ where: path.join(home, 'bot.yaml'), says: error.message }];
|
|
118
|
+
}
|
|
119
|
+
if (!existsSync(file) && allowed.length === 0) return [];
|
|
120
|
+
|
|
121
|
+
// A link out of the bot folder is named by the hook's check already; here it
|
|
122
|
+
// matters only when there are rules the kit would write through it.
|
|
123
|
+
if (allowed.length === 0 && leadsOutside(home, file) !== undefined) return [];
|
|
124
|
+
|
|
125
|
+
let present;
|
|
126
|
+
try {
|
|
127
|
+
refuseOutside(home, file);
|
|
128
|
+
present = presentIn(readSettings(file, 'the permission rules the user allowed'), file);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return [{ where: file, says: error.message }];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return [
|
|
134
|
+
...present.filter((rule) => !allowed.includes(rule)).map((rule) => ({
|
|
135
|
+
where: file,
|
|
136
|
+
says: `${file} allows ${rule}, and ${bot.name}'s bot.yaml does not: the kit did not write it. It stays where it is. If the user wants it, record their yes with obk bot change --allow; if not, take it out of the file.`,
|
|
137
|
+
})),
|
|
138
|
+
...allowed.filter((rule) => !present.includes(rule)).map((rule) => ({
|
|
139
|
+
where: file,
|
|
140
|
+
says: `${file} does not hold ${rule}, which ${bot.name}'s bot.yaml allows, so ${bot.name}'s Claude sessions are asked about it. obk up writes it.`,
|
|
141
|
+
})),
|
|
142
|
+
];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The file's `permissions.allow`, or a refusal when it is there and is not a list. */
|
|
146
|
+
function presentIn(settings, file) {
|
|
147
|
+
const permissions = settings.permissions;
|
|
148
|
+
if (permissions === undefined) return [];
|
|
149
|
+
if (permissions === null || typeof permissions !== 'object' || Array.isArray(permissions)) {
|
|
150
|
+
throw new Error(`${file} has a permissions entry that is not a mapping, and the permission rules the user allowed go in it. Fix it or move it aside, then run the command again.`);
|
|
151
|
+
}
|
|
152
|
+
if (permissions.allow === undefined) return [];
|
|
153
|
+
if (!Array.isArray(permissions.allow)) {
|
|
154
|
+
throw new Error(`${file} has a permissions.allow entry that is not a list, and the permission rules the user allowed go in it. Fix it or move it aside, then run the command again.`);
|
|
155
|
+
}
|
|
156
|
+
return permissions.allow;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The kit writes a bot's settings in the bot folder and nowhere else (ADR 0022). */
|
|
160
|
+
function refuseOutside(home, file) {
|
|
161
|
+
const real = leadsOutside(home, file);
|
|
162
|
+
if (real === undefined) return;
|
|
163
|
+
throw new Error(`${file} leads outside the bot folder, to ${real}, through a link, and the kit writes permission rules only inside the bot folder. Replace the link with a file of the bot's own, then run the command again.`);
|
|
164
|
+
}
|
package/src/retire.js
CHANGED
|
@@ -16,16 +16,16 @@
|
|
|
16
16
|
// restart or a pause it does not wait for the book to know which one it was.
|
|
17
17
|
// The mailbox Runs stay: Orca has no way to remove one (ADR 0018).
|
|
18
18
|
|
|
19
|
-
import { existsSync, mkdirSync,
|
|
19
|
+
import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
20
20
|
import path from 'node:path';
|
|
21
21
|
|
|
22
22
|
import { readBook, tabIdsIn, updateBook } from './book.js';
|
|
23
23
|
import { botDir, dropSession, readBot } from './bot.js';
|
|
24
|
-
import { deleteProject, findProject, projects,
|
|
24
|
+
import { deleteProject, findProject, projects, reloadWindow, tabs } from './orca.js';
|
|
25
25
|
import { fleetMember } from './pause.js';
|
|
26
26
|
import { closeTabs, tabsToClose } from './restart.js';
|
|
27
27
|
import { unlinkSkills } from './skills.js';
|
|
28
|
-
import {
|
|
28
|
+
import { promptPath, sessionsOf } from './up.js';
|
|
29
29
|
|
|
30
30
|
/** Where retired bots go: beside `bots/`, where nothing the kit runs looks. */
|
|
31
31
|
export const retiredDir = (bots) => path.join(bots, 'retired');
|
|
@@ -53,8 +53,9 @@ export async function retireSession(bots, { bot, session }) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
* Retire the bot `bot`. Returns `{ bot, closed, project, moved }`:
|
|
57
|
-
* closed, the Orca project it took away (if it had one)
|
|
56
|
+
* Retire the bot `bot`. Returns `{ bot, closed, project, windowReloaded, moved }`:
|
|
57
|
+
* the tabs it closed, the Orca project it took away (if it had one), whether
|
|
58
|
+
* Orca's window was reloaded after that, and where the bot is now.
|
|
58
59
|
* When Orca does not confirm the project gone, it returns `{ bot, closed,
|
|
59
60
|
* project, trouble }` instead, and the bot is left where it was.
|
|
60
61
|
*/
|
|
@@ -81,13 +82,13 @@ export async function retireBot(bots, { bot }) {
|
|
|
81
82
|
// they are gone.
|
|
82
83
|
const booked = Object.keys(readBook(home).sessions).map((name) => ({ name }));
|
|
83
84
|
const closed = await closeTabs(home, tabsToClose(bots, bot, home, booked, { keepless: true }), bots, bot);
|
|
85
|
+
let windowReloaded;
|
|
84
86
|
if (project !== undefined) {
|
|
85
87
|
deleteProject(project.id);
|
|
86
88
|
// Orca's answer to the delete is not the same as the project being gone
|
|
87
89
|
// (#282): its list afterwards is. Until that list is read and no longer
|
|
88
90
|
// has the project, the bot stays where it is, so that retiring it again
|
|
89
|
-
// takes the project away and then finishes.
|
|
90
|
-
// this and for the window both.
|
|
91
|
+
// takes the project away and then finishes.
|
|
91
92
|
let setups;
|
|
92
93
|
try {
|
|
93
94
|
setups = projects();
|
|
@@ -97,7 +98,9 @@ export async function retireBot(bots, { bot }) {
|
|
|
97
98
|
if (setups.some((setup) => setup.id === project.id || setup.path === home)) {
|
|
98
99
|
return { bot, closed, project: project.id, trouble: `Orca answered the delete of Orca project ${project.id}, and still lists it. ${bot} was not moved. Retire ${bot} again with obk retire; if Orca still lists the project after that, remove it in Orca yourself, then retire ${bot} again.` };
|
|
99
100
|
}
|
|
100
|
-
|
|
101
|
+
// Only now: Orca's window keeps a removed project in its sidebar until
|
|
102
|
+
// it is rebuilt (#343).
|
|
103
|
+
windowReloaded = reloadWindow();
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
for (const name of new Set([...known.sessions, ...booked].map((session) => session.name))) {
|
|
@@ -107,21 +110,5 @@ export async function retireBot(bots, { bot }) {
|
|
|
107
110
|
mkdirSync(retiredDir(bots), { recursive: true });
|
|
108
111
|
renameSync(botDir(bots, bot), moved);
|
|
109
112
|
|
|
110
|
-
return { bot, closed, ...(project === undefined ? {} : { project: project.id }), moved };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Tell Orca's window a project went (#224). The call names a project that is
|
|
115
|
-
* still there, and Bot Father's is the one that always is; when Orca has none
|
|
116
|
-
* for it in `setups`, Orca's list after the delete, there is nothing to call
|
|
117
|
-
* on. Finding it is part of the workaround, so it fails as quietly as the call.
|
|
118
|
-
*/
|
|
119
|
-
function tellWindowOfRemoval(bots, setups) {
|
|
120
|
-
try {
|
|
121
|
-
const home = botDir(bots, BOT_FATHER);
|
|
122
|
-
const father = existsSync(home) ? setups.find((setup) => setup.path === realpathSync(home)) : undefined;
|
|
123
|
-
if (father !== undefined) tellWindow(father.projectId);
|
|
124
|
-
} catch {
|
|
125
|
-
// Nothing: see above.
|
|
126
|
-
}
|
|
113
|
+
return { bot, closed, ...(project === undefined ? {} : { project: project.id, windowReloaded }), moved };
|
|
127
114
|
}
|
package/src/rules.js
CHANGED
|
@@ -21,10 +21,23 @@ import { fileURLToPath } from 'node:url';
|
|
|
21
21
|
import { parse } from 'yaml';
|
|
22
22
|
|
|
23
23
|
import { botDir, botNames, displayName, readBot } from './bot.js';
|
|
24
|
+
import { ownCli, shellWord } from './launch.js';
|
|
24
25
|
|
|
25
26
|
/** The kit's own units, inside the installed package. */
|
|
26
27
|
const KIT_RULES = fileURLToPath(new URL('../rules', import.meta.url));
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* How a rule unit names the kit, and what the build writes in its place: the
|
|
31
|
+
* CLI that builds the file, by its own path. A bot runs the command as the file
|
|
32
|
+
* gives it, and Claude Code matches an allow rule against the command as
|
|
33
|
+
* written, not after the shell expands a variable, so a bot told to run
|
|
34
|
+
* `"${OBK_CLI:-obk}"` would never match the kit's own mail rule (#344).
|
|
35
|
+
*/
|
|
36
|
+
const KIT_WORD = '"${OBK_CLI:-obk}"';
|
|
37
|
+
|
|
38
|
+
/** How a rule unit names the bots folder, which the build writes out for the same reason. */
|
|
39
|
+
const BOTS_WORD = '<bots>';
|
|
40
|
+
|
|
28
41
|
/** What a name in a list carries to mean one of the kit's rather than one of the user's. */
|
|
29
42
|
const KIT = 'kit:';
|
|
30
43
|
|
|
@@ -116,7 +129,9 @@ export function blockFor(bots, bot) {
|
|
|
116
129
|
|
|
117
130
|
const charter = typeof bot.charter === 'string' ? bot.charter.trim() : '';
|
|
118
131
|
if (charter !== '') parts.push('## Charter', charter);
|
|
119
|
-
for (const unit of units)
|
|
132
|
+
for (const unit of units) {
|
|
133
|
+
parts.push(`## ${unit.title}`, unit.body.replaceAll(KIT_WORD, shellWord(ownCli())).replaceAll(BOTS_WORD, shellWord(bots)));
|
|
134
|
+
}
|
|
120
135
|
|
|
121
136
|
return { body: parts.join('\n\n'), units: units.map((unit) => unit.name) };
|
|
122
137
|
}
|
package/src/up.js
CHANGED
|
@@ -12,6 +12,7 @@ import { forgetClaimed, forgetSession, readBook, sessionIdsIn, tabIdsIn, updateB
|
|
|
12
12
|
import { botDir, botNames, displayName, readBot } from './bot.js';
|
|
13
13
|
import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from './conversations.js';
|
|
14
14
|
import { installHook } from './hooks.js';
|
|
15
|
+
import { writePermissions } from './permissions.js';
|
|
15
16
|
import { addressOf, harnessOf, isAddressOf, isShortPrompt, launchCommand, mailboxStep, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
|
|
16
17
|
import { asFolderProject, coordinatorOf, findProject, harnessInTab, makeMailbox, makeProject, openTab, QUESTION_ON_SCREEN, retitleTab, tabs, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
|
|
17
18
|
import { TAB_ENV } from './record.js';
|
|
@@ -58,7 +59,7 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
|
|
|
58
59
|
return false;
|
|
59
60
|
});
|
|
60
61
|
|
|
61
|
-
const { running, rules, skills } = prepareBots(bots, names, onlySession);
|
|
62
|
+
const { running, rules, skills, permissions } = prepareBots(bots, names, onlySession);
|
|
62
63
|
|
|
63
64
|
const report = [];
|
|
64
65
|
const projects = [];
|
|
@@ -83,6 +84,7 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
|
|
|
83
84
|
trouble: `${entry.trouble} Its sessions were not started: a bot comes up with its rules or not at all.`,
|
|
84
85
|
})),
|
|
85
86
|
skills,
|
|
87
|
+
permissions,
|
|
86
88
|
paused,
|
|
87
89
|
projects,
|
|
88
90
|
};
|
|
@@ -90,7 +92,7 @@ export async function bringUp(bots, { bot: onlyBot, session: onlySession } = {})
|
|
|
90
92
|
|
|
91
93
|
/**
|
|
92
94
|
* Everything the bots need in place before a session starts, and which of them
|
|
93
|
-
* can be started at all: `{ running, rules, skills }`.
|
|
95
|
+
* can be started at all: `{ running, rules, skills, permissions }`.
|
|
94
96
|
*
|
|
95
97
|
* Separate from the tabs because the order matters twice over. A session reads
|
|
96
98
|
* its rules, its skills and its hooks as it comes up, so all three have to be
|
|
@@ -150,7 +152,12 @@ export function prepareBots(bots, names, onlySession) {
|
|
|
150
152
|
}
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
|
|
155
|
+
// And the permission rules the user allowed, beside the hook and for the same
|
|
156
|
+
// reason: a session that comes up before its rules are there is asked about
|
|
157
|
+
// what the user already said yes to (#344).
|
|
158
|
+
const permissions = running.map(({ bot, home }) => writePermissions(bots, home, bot)).filter((entry) => entry !== undefined);
|
|
159
|
+
|
|
160
|
+
return { running, rules, skills, permissions };
|
|
154
161
|
}
|
|
155
162
|
|
|
156
163
|
/**
|
|
@@ -377,13 +384,13 @@ function launchFor(bots, bot, session, harness, home, workDir, resume, held) {
|
|
|
377
384
|
// through the tab's shell a character at a time.
|
|
378
385
|
const promptFile = prompt === undefined || isShortPrompt(prompt) ? undefined : promptPath(bots, bot.name, session.name);
|
|
379
386
|
// A new conversation is given a new name. A resume goes on under the one the
|
|
380
|
-
// kit gave it, and
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
387
|
+
// kit gave it, and gets a new one when the book `held` none of the kit's own:
|
|
388
|
+
// `--resume <id> -n <name>` renames the conversation, proven live (#319), so
|
|
389
|
+
// a session still under the shared `<bot>.<session>` moves to an address of
|
|
390
|
+
// its own here.
|
|
384
391
|
const address = harness !== 'claude' ? undefined
|
|
385
|
-
: resume
|
|
386
|
-
:
|
|
392
|
+
: resume !== undefined && isAddressOf(bot.name, session.name, held) ? held
|
|
393
|
+
: addressOf(bot.name, session.name);
|
|
387
394
|
const line = launchCommand(session, {
|
|
388
395
|
harness,
|
|
389
396
|
home,
|
package/src/orca-window.cjs
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
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-window.cjs <client> <projectId> <wait>`.
|
|
3
|
-
//
|
|
4
|
-
// It asks Orca's runtime, through Orca's own client, for `project.update` with
|
|
5
|
-
// no changes on one project. That is the call that makes Orca's window read its
|
|
6
|
-
// projects again, and Orca's CLI does not offer it (#224, ADR 0023). It exits 0
|
|
7
|
-
// only when the runtime took the call; anything else is a non-zero exit, and
|
|
8
|
-
// the kit reads every non-zero exit the same way.
|
|
9
|
-
|
|
10
|
-
'use strict';
|
|
11
|
-
|
|
12
|
-
const [client, projectId, wait] = process.argv.slice(2);
|
|
13
|
-
|
|
14
|
-
process.exitCode = 1;
|
|
15
|
-
const { RuntimeClient } = require(client);
|
|
16
|
-
new RuntimeClient(undefined, Number(wait))
|
|
17
|
-
.call('project.update', { projectId, updates: {} })
|
|
18
|
-
.then((answer) => {
|
|
19
|
-
if (answer?.ok === true) process.exitCode = 0;
|
|
20
|
-
}, () => {})
|
|
21
|
-
.finally(() => process.exit());
|