@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 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 session next starts a new conversation.
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. Two parts of it matter to them:
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 on) is always answered with the current
210
- checkout, which is the bot home. The kit never makes a git worktree, so
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
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. Your bots folder is
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` asks where the new
264
- conversation runs; the answer is always the current checkout, the bot home,
265
- and never `2. New worktree`: the kit never makes a git 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
- // After every removal, whatever the window was told: Orca's window can
555
- // keep a removed project's row even after it re-reads (stablyai/orca#20102).
556
- ...(retired.project === undefined ? [] : [`removed Orca project ${retired.project}`, RELOAD_LINE]),
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
- const answer = { bots, bot: made.bot, home: made.home, created: made.created, rules, skills };
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
- const changed = changeBot(bots, values.bot, { charter: values.charter });
594
- // Rebuilt at once, as bot create builds it, so the charter a session reads
595
- // is the one the bot now has.
596
- const rules = [buildAgents(bots, changed.home, readBot(changed.home))];
597
- const trouble = rules[0].trouble !== undefined;
598
- return {
599
- answer: { bots, bot: changed.bot, home: changed.home, charter: changed.charter, rules },
600
- lines: [
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
- code: trouble ? 1 : 0,
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 trouble = rules.filter((entry) => entry.trouble !== undefined);
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 the kit's session hook goes in it. Fix it or move it aside, then run the command again.`);
246
+ throw new Error(`${file} is not readable as JSON, and ${what} goes in it. Fix it or move it aside, then run the command again.`);
247
247
  }
248
248
  return parsed;
249
249
  }
package/src/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 0023). Orca's CLI
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, renamed or removed a project, whatever `tellWindow` answered. */
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, so this goes through Orca's
194
- * own runtime client out of the installed app, run by Orca's binary the way
195
- * its `bin/orca` runs its CLI (ADR 0023). None of that is Orca's published
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 function tellWindow(projectId) {
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
- [WINDOW_SCRIPT, client, projectId, String(CLIENT_WAIT_MS)],
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
- return asked.error === undefined && asked.status === 0;
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 WINDOW_SCRIPT = fileURLToPath(new URL('./orca-window.cjs', import.meta.url));
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 0023). The same pointer starts the harness's
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 pane `ptyId`: `{ front: 'shell' }`,
408
- * `{ front: 'program', command, pid }` with the name and pid of the process
409
- * leading the group in front, or `{ unreadable: <why> }`.
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 0023).
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 frontOf(ptyId) {
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, realpathSync, renameSync, rmSync } from 'node:fs';
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, tabs, tellWindow } from './orca.js';
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 { BOT_FATHER, promptPath, sessionsOf } from './up.js';
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 }`: the tabs it
57
- * closed, the Orca project it took away (if it had one) and where the bot is now.
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. The list is read once, for
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
- tellWindowOfRemoval(bots, setups);
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) parts.push(`## ${unit.title}`, unit.body);
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
- return { running, rules, skills };
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 under no `-n` at all when the book `held` none of the
381
- // kit's own: a resume keeps whatever name the conversation has, which is
382
- // proven, and whether `-n` renames it is not (#286). Such a session is
383
- // written to through its mailbox until it next starts fresh.
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 === undefined ? addressOf(bot.name, session.name)
386
- : isAddressOf(bot.name, session.name, held) ? held : undefined;
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,
@@ -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());