@assuredloop/orca-bot-kit 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -543,6 +543,14 @@ runs `obk session mailbox` before the harness. For the same reason a session's
543
543
  mail is read in its own tab. `message check` from any other tab reads nothing,
544
544
  binds nothing, and says so; the mail waits.
545
545
 
546
+ For one session, that step, a `message check`, and `up` writing a new tab for it
547
+ into the book take turns. So two starts of the same session at once leave its
548
+ mailbox bound to the tab the book names, and no mail is read in the other one.
549
+ Each waits a minute at most for its turn. A step that waits it out leaves the
550
+ mailbox where it was and says so, and the harness starts all the same. A check
551
+ holds its turn for 40 seconds at most: with more mail than it reads in that
552
+ time, it shows what it read and says the rest is still waiting.
553
+
546
554
  Nothing in a mailbox wakes anybody, so `message send` also types one line into
547
555
  the receiver's tab telling it to look. Both harnesses take a typed line as the
548
556
  next thing to do rather than cutting into what they are doing, which is the
package/SETUP.md CHANGED
@@ -191,19 +191,21 @@ What you will see, and the usual answer. Each goes as one
191
191
  text and **no** `--enter`, because a menu takes a return as the keypress it is
192
192
  waiting for:
193
193
 
194
- | On screen | Send | Which is |
195
- |---|---|---|
196
- | Claude Code's folder trust list | `\x1b[B\r` | down, then return: its selection starts on **No, exit** |
197
- | Codex's directory trust, `1. Trust and continue` (older: `1. Yes, continue`) | `1\r` | yes |
198
- | Codex's `Hooks need review` | `2\r` | trust all and continue |
199
- | Codex's `/new`: `Where should the new conversation run?` | `1\r` | current checkout (bot home) |
200
- | Codex's update offer, `1. Update now` | `1\r` | accept it |
201
- | `[oh-my-zsh] Would you like to update?` | `n` | they update their own shell |
202
- | Claude Code's `Teach auto mode about your environment?` | `2\r` | **Not now**: it would write settings of theirs |
203
-
204
- The numbers there are what these menus showed when this was written. Read them
205
- off the screen in front of you rather than trusting the table: a harness that has
206
- added an option since has moved them all down one.
194
+ | On screen | Send | Which is | Proven on |
195
+ |---|---|---|---|
196
+ | Claude Code's folder trust list | `\x1b[B\r` | down, then return: its selection starts on **No, exit** | Claude Code 2.1.283 |
197
+ | Codex's directory trust, `1. Trust and continue` (older: `1. Yes, continue`) | `\r` | return: its selection starts on **Trust and continue** | Codex 0.157.1 |
198
+ | Codex's `Hooks need review` | `\x1b[B\r` | down, then return, to **Trust all and continue**; if the selection already starts there, return alone | Codex 0.157.1 |
199
+ | Codex's `/new`: `Where should the new conversation run?` | `\r` | return: its selection starts on **Current checkout** (bot home) | Codex 0.157.1 |
200
+ | Codex's update offer, `1. Update now` | `\r` | return: its selection starts on **Update now**, which accepts it | Codex 0.156.1; 0.157.1 has had no newer release to offer |
201
+ | `[oh-my-zsh] Would you like to update?` | `n` | they update their own shell | oh-my-zsh bf77e35 |
202
+ | Claude Code's `Teach auto mode about your environment?` | `\x1b` | Esc, which is **Not now**: it would write settings of theirs | Not yet proven: Esc is Not now in Claude Code 2.1.283's code; last seen live on 2.1.278, answered with 2. Not now |
203
+
204
+ Each key counts on where the selection starts, as it did on the version named.
205
+ Read the screen in front of you before you send: if the selection is somewhere
206
+ else, or the options have moved, go to the answer with the arrows and then
207
+ return. Never send a digit and then return: on Codex's update offer that took
208
+ the highlighted **Update now**, whatever the digit was.
207
209
 
208
210
  Three things to know rather than guess at.
209
211
 
@@ -215,8 +217,7 @@ empty book and nothing says why.
215
217
  Codex's trust question applies to the **repository root**, which for a bot means
216
218
  the whole bots folder rather than the one bot. That is what they are agreeing to.
217
219
 
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
+ Codex's `/new` question (seen on 0.156.1 and 0.157.1) is always answered with the current checkout, which is the bot
220
221
  home. The kit never makes a git worktree, so
221
222
  `2. New worktree` is never the answer.
222
223
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/rules/limits.md CHANGED
@@ -17,6 +17,6 @@ publishing, releasing, spending money, anything another person receives.
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
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.
20
+ Permission rules in a harness's settings or rules files are the kit's to write,
21
+ after the user's yes to the exact rules: `obk bot change --allow <rule>` records
22
+ and writes them. Never edit them into such a file yourself, Bot Father included.
@@ -201,16 +201,38 @@ 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.
204
+ A bot needs a few permission rules so that auto mode, or Codex's sandbox and
205
+ reviewer, does not stop it for what the kit's own rules tell it to do: its
206
+ mail through the kit, reading a long message kept beside the bots folder, and
207
+ its commits. `obk bot create`, `obk up` and `obk rules build` list the rules
208
+ still waiting for a bot and give one command that allows them. Show the user
209
+ those rules word for word and say in a line what each lets the bot do. Run the
210
+ command only after their yes: it records the yes in the bot's `bot.yaml` and
211
+ writes the rules into its settings, or for Codex into its
212
+ `.codex/rules/obk.rules`. On a no, run nothing, and nothing is written. When
213
+ the kit lists new rules for a bot later, ask again. Never write a permission
214
+ rule into a settings or rules file by hand.
215
+
216
+ The charter decides what else the bot needs. What it lets the bot do without
217
+ asking, such as merging pull requests, auto mode or Codex's sandbox can still
218
+ stop, so each grant needs its own rule. When the charter is written, and again
219
+ whenever it changes, list the exact rules it grants: `Bash(gh pr merge:*)` for
220
+ a bot that merges pull requests, `Bash(gh issue close:*)` for one that closes
221
+ issues. Spell each command as the bot will run it, the kit by the path the
222
+ bot's rules give it and every folder absolute, or the rule will not match.
223
+ Spell them in Claude Code's form for a Codex bot too, `Bash(<command>:*)`: the
224
+ kit writes Codex's own form of each, and says which ones Codex has none for,
225
+ such as an `Edit` rule or one exact command.
226
+ Show the user the list word for word, with a line on what each lets the bot do,
227
+ and only after their yes run `obk bot change --allow`, once per rule they said
228
+ yes to. On a no, run nothing. `obk bot change --charter` names the rules the
229
+ bot is allowed now; say which of them the new charter no longer grants, and
230
+ that they stay allowed until the user takes them out, since the kit takes none
231
+ out. Keep
232
+ each rule narrow: a program and what it does. The kit refuses a rule that lets
233
+ the bot run any command, a program with any arguments, or a shell or
234
+ interpreter with any arguments; a user who wants one for a bot adds it to that
235
+ bot's settings themselves. Propose no rule for what the charter does not grant.
214
236
 
215
237
  A bot that should learn across its sessions can use its harness's own memory,
216
238
  turned on for that bot alone, whatever the user's own setting is for everything
@@ -256,23 +278,31 @@ otherwise `/Applications/Orca.app/Contents/Resources/bin/orca`. A bare
256
278
  `orca` can fail. Send each answer as one `<orca> terminal send --terminal
257
279
  <handle> --text …`, with the return inside the text and no `--enter`:
258
280
 
259
- | On screen | Send | Which is |
260
- |---|---|---|
261
- | Claude Code's folder trust list | `\x1b[B\r` | down, return: it starts on **No, exit** |
262
- | Codex's `Trust this folder?`, `1. Trust and continue` | `1\r` | yes (older: `1. Yes, continue`) |
263
- | Codex's `Hooks need review` | `2\r` | trust all and continue |
264
- | Codex's `/new`: `Where should the new conversation run?` | `1\r` | current checkout (bot home) |
265
- | Codex's update offer, `1. Update now` | `1\r` | accept it |
266
- | `[oh-my-zsh] Would you like to update?` | `n` | no: the shell is the user's to update |
267
- | Claude Code's `Teach auto mode about…` | `2\r` | **Not now**: it writes the user's settings |
268
-
269
- Read the numbers off the screen in front of you rather than trusting the
270
- table: a harness that has added an option has moved them. Codex's hooks
281
+ | On screen | Send | Which is | Proven on |
282
+ |---|---|---|---|
283
+ | Claude Code's trust list | `\x1b[B\r` | down, return: off **No, exit** | Claude Code 2.1.283 |
284
+ | Codex's `Trust this folder?` | `\r` | return: it starts on yes | Codex 0.157.1 |
285
+ | Codex's `Hooks need review` | `\x1b[B\r` | **Trust all and continue** | Codex 0.157.1 |
286
+ | Codex's `Where should the new conversation run?` | `\r` | **Current checkout** | Codex 0.157.1 |
287
+ | Codex's update offer, `1. Update now` | `\r` | **Update now**, where it starts | Codex 0.156.1 |
288
+ | `[oh-my-zsh] Would you like to update?` | `n` | no: the user updates it | oh-my-zsh bf77e35 |
289
+ | Claude Code's `Teach auto mode…` | `\x1b` | Esc, **Not now** | Not yet proven, below |
290
+
291
+ Each key counts on where the selection starts on the version named. Read the
292
+ screen before you send: if the selection is elsewhere or the options have
293
+ moved, go to the answer with the arrows, then return. Never a digit and then
294
+ return: on Codex's update offer that took the highlighted **Update now**,
295
+ whatever the digit was. Codex's trust said `1. Yes, continue` on older
296
+ versions. Its hooks review sometimes starts on `2` already: then return alone.
297
+ 0.157.1 has had no newer release to offer, so its update offer was proven on
298
+ 0.156.1. Claude Code's `Teach auto mode` has not been seen live on 2.1.283:
299
+ Esc is Not now in its code, and 2.1.278 was answered with `2. Not now`.
300
+ Not now, because Yes writes the user's settings. Codex's hooks
271
301
  question matters most. The kit's hook is how the book learns which
272
302
  conversation the session is running, and until it is answered the
273
303
  conversation has not started. Codex's trust applies to the repository root,
274
304
  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
305
+ conversation runs (0.156.1 and 0.157.1 both did); the answer is
276
306
  always the current checkout, the bot home, and never `2. New worktree`:
277
307
  the kit never makes a git worktree.
278
308
 
package/src/book.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // theirs. Beside each session's tab it holds the harness session id that
7
7
  // session is running under, and every id it ran under before.
8
8
 
9
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
9
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { DatabaseSync } from 'node:sqlite';
12
12
  import { isDeepStrictEqual } from 'node:util';
@@ -141,18 +141,26 @@ export async function updateBook(home, change) {
141
141
  * empty and leaves no journal beside it.
142
142
  */
143
143
  function takeLock(home) {
144
- const file = lockFile(home);
144
+ try {
145
+ return lockOn(lockFile(home), WAIT_MS);
146
+ } catch (error) {
147
+ throw waitedTooLong(home, error);
148
+ }
149
+ }
150
+
151
+ /** The lock on `file`, waited for `waitMs` at most: see `takeLock` for why it is this one. */
152
+ function lockOn(file, waitMs) {
145
153
  mkdirSync(path.dirname(file), { recursive: true });
146
154
 
147
155
  const db = new DatabaseSync(file);
148
156
  try {
149
157
  // A writer that arrives while another is working waits for it rather than
150
158
  // failing at once, and gives up saying so rather than waiting for ever.
151
- db.exec(`PRAGMA busy_timeout = ${WAIT_MS}`);
159
+ db.exec(`PRAGMA busy_timeout = ${waitMs}`);
152
160
  db.exec('BEGIN IMMEDIATE');
153
161
  } catch (error) {
154
162
  db.close();
155
- throw waitedTooLong(home, error);
163
+ throw error;
156
164
  }
157
165
 
158
166
  return {
@@ -190,6 +198,54 @@ const lockFile = (home) => path.join(
190
198
  `${encodeURIComponent(path.basename(home))}.lock`,
191
199
  );
192
200
 
201
+ /**
202
+ * One session's turn at its mailbox (#321): held by `session mailbox` for its
203
+ * whole step, by `message check` in the session's own tab for its bind, read and
204
+ * ack, and by `up` while it writes a new tab for the session into the book. So
205
+ * the book never moves to another tab while a step or a check is at work, and a
206
+ * step or check that waited reads the book again once it has its turn: two
207
+ * starts of one session at once leave its mailbox bound to the tab the book
208
+ * names, and nothing is read from the one it no longer does.
209
+ *
210
+ * A lock of its own, and not the book's: the book's is held for one read and one
211
+ * write, and a session's hook must never wait behind Orca for it. Taken before
212
+ * the book's and never while holding it, so neither waits on the other.
213
+ *
214
+ * Returns `{ release }`, or undefined when the turn did not come in
215
+ * `MAILBOX_WAIT_MS`: the caller says what it left, and a launch line goes on
216
+ * to its harness whatever happened here.
217
+ */
218
+ export function takeMailboxTurn(home, session) {
219
+ try {
220
+ return lockOn(mailboxLockFile(home, session), MAILBOX_WAIT_MS);
221
+ } catch (error) {
222
+ if (error.errcode === SQLITE_BUSY) return undefined;
223
+ throw error;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * How long a step, a check or an `up` waits for a session's turn. Longer than a
229
+ * step can hold it — three Orca calls of twenty seconds each at most — and short
230
+ * enough that a harness behind a stuck one still starts.
231
+ */
232
+ export const MAILBOX_WAIT_MS = 60_000;
233
+
234
+ /** SQLite's own code for a lock another connection holds. */
235
+ const SQLITE_BUSY = 5;
236
+
237
+ /**
238
+ * Beside the book's lock, one file per session. Names are lower-case letters,
239
+ * digits and hyphens, so a dot between the bot's and the session's cannot be
240
+ * read two ways. Resolved first, because the same bot is reached through a
241
+ * link by one command and by its real path by another, and two paths would be
242
+ * two turns.
243
+ */
244
+ const mailboxLockFile = (home, session) => {
245
+ const real = realpathSync(home);
246
+ return path.join(`${path.dirname(path.dirname(real))}.locks`, `${path.basename(real)}.${session}.mailbox.lock`);
247
+ };
248
+
193
249
  /**
194
250
  * Something else has been writing the book for longer than this run is prepared
195
251
  * to wait. Every writer holds its turn for one read and one write, so this is a
package/src/cli.js CHANGED
@@ -19,7 +19,7 @@ import { initBots } from './init.js';
19
19
  import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
20
20
  import { checkMail, lookUp, noMailboxYet, sendMessage } from './message.js';
21
21
  import { orcaCli, orcaTrouble, RELOAD_LINE } from './orca.js';
22
- import { allowCommand, writePermissions } from './permissions.js';
22
+ import { allowCommand, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, writePermissions } from './permissions.js';
23
23
  import { pauseSessions, unpauseSessions } from './pause.js';
24
24
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
25
25
  import { restartSessions } from './restart.js';
@@ -61,7 +61,8 @@ Usage:
61
61
  A running session reads it when it next starts.
62
62
  --allow records a permission rule the user said
63
63
  yes to, once per rule, and writes it into the bot's
64
- Claude settings.
64
+ Claude settings. A broad rule, such as Bash(gh:*),
65
+ is refused and nothing is written.
65
66
  obk session change --bots <path> --bot <bot> --session <session>
66
67
  [--model <m>] [--effort <e>] [--context <c>]
67
68
  [--approval ${APPROVALS.join('|')}]
@@ -578,7 +579,7 @@ const commands = {
578
579
  const skills = [linkSkills(bots, made.home, readBot(made.home))];
579
580
  // Nothing is allowed yet, so this writes nothing: it says which of the
580
581
  // 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 permissions = writePermissions(bots, made.home, readBot(made.home));
582
583
  const answer = { bots, bot: made.bot, home: made.home, created: made.created, rules, skills, permissions };
583
584
  // A bot whose rules would not build is made but not finished: it has no
584
585
  // instructions, so `up` will not start it, and saying "give it a session"
@@ -608,7 +609,11 @@ const commands = {
608
609
  }
609
610
  // Refused before anything is written, so a bad rule, or a bad list already
610
611
  // there, leaves the charter as it was too.
611
- if (values.allow !== undefined) allowedNow(bots, values.bot, values.allow);
612
+ if (values.allow !== undefined) {
613
+ const { home } = allowedNow(bots, values.bot, values.allow);
614
+ refuseBroad(home, values.allow);
615
+ refuseNoCodexForm(readBot(home, values.bot), values.allow);
616
+ }
612
617
 
613
618
  const answer = { bots, bot: values.bot, home: botDir(bots, values.bot) };
614
619
  const lines = [];
@@ -627,15 +632,33 @@ const commands = {
627
632
  ? `${changed.bot}'s charter is written, and its rules are not. Settle what the line above says, then: ${shellWord(ownCli())} rules build --bots ${shellWord(bots)} --bot ${changed.bot}`
628
633
  : `${changed.bot}'s charter is changed. A session that is running read the old one when it started; it reads this one when it next starts.`,
629
634
  );
635
+ // The rules a charter grants are the user's to say yes to again, so the
636
+ // change names what is allowed now and writes none of it (#353).
637
+ // An allow list that is not a list is named by rules build and health,
638
+ // and a charter already written is not undone for it.
639
+ const bot = readBot(changed.home, changed.bot);
640
+ if (values.allow === undefined && (runsOnClaude(bot) || runsOnCodex(bot))) {
641
+ try {
642
+ answer.beyondDefaults = beyondDefaults(bots, changed.home, bot);
643
+ lines.push(...charterRulesLines(bots, bot, answer.beyondDefaults));
644
+ } catch {
645
+ // Nothing to name here.
646
+ }
647
+ }
630
648
  }
631
649
  if (values.allow !== undefined) {
632
650
  const allowed = allowRules(bots, values.bot, values.allow);
633
651
  // 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);
652
+ const permissions = writePermissions(bots, allowed.home, readBot(allowed.home));
635
653
  Object.assign(answer, { allow: allowed.allow, permissions });
654
+ const codex = permissions.find((entry) => entry.unwritten !== undefined);
636
655
  lines.push(
637
656
  ...allowed.added.map((rule) => `${'allowed'.padEnd(9)} ${rule}`),
638
657
  ...permissionsLines(permissions, bots),
658
+ // Codex reads its rules when a session starts, not while it runs.
659
+ ...(codex === undefined || codex.written.length === 0 ? [] : [
660
+ `Codex reads ${codex.file} when a session starts: a Codex session of ${allowed.bot} that is running now gets these rules at its next start (obk restart).`,
661
+ ]),
639
662
  allowed.added.length === 0
640
663
  ? `${allowed.bot} was allowed every one of these already.`
641
664
  : `${allowed.bot}'s allow list in ${path.join('bots', allowed.bot, 'bot.yaml')} holds the user's yes.`,
@@ -812,19 +835,22 @@ const commands = {
812
835
  peek: values.peek === true,
813
836
  });
814
837
  const where = `${answer.bot}/${answer.session}`;
815
-
838
+ const messages = answer.messages.flatMap((message) => [
839
+ `${'message'.padEnd(9)} ${message.from} ${message.at}`,
840
+ ` ${message.subject}`,
841
+ ...String(message.body ?? '').split('\n').map((line) => ` ${line}`),
842
+ ]);
843
+
844
+ // Trouble after some mail was read still shows that mail: it is read, and
845
+ // Orca will not hand it over again.
816
846
  if (answer.trouble !== undefined) {
817
- return { answer, lines: [`${'trouble'.padEnd(9)} ${where}`, ` ${answer.trouble}`], code: 1 };
847
+ return { answer, lines: [...messages, `${'trouble'.padEnd(9)} ${where}`, ` ${answer.trouble}`], code: 1 };
818
848
  }
819
849
 
820
850
  return {
821
851
  answer,
822
852
  lines: [
823
- ...answer.messages.flatMap((message) => [
824
- `${'message'.padEnd(9)} ${message.from} ${message.at}`,
825
- ` ${message.subject}`,
826
- ...String(message.body ?? '').split('\n').map((line) => ` ${line}`),
827
- ]),
853
+ ...messages,
828
854
  answer.messages.length === 0
829
855
  ? `Nothing is waiting for ${where}.`
830
856
  // Said plainly, because a peek leaves the same mail there to be found
@@ -1220,12 +1246,26 @@ function permissionsOf(bots, name) {
1220
1246
  } catch {
1221
1247
  return [];
1222
1248
  }
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
- }
1249
+ return writePermissions(bots, home, bot, { keepGoing: true });
1250
+ }
1251
+
1252
+ /**
1253
+ * What a charter change says about the permission rules: the ones the bot is
1254
+ * allowed now beyond the kit's defaults, word for word, which stay allowed, and
1255
+ * that a new one is written only after the user's yes to the new charter's.
1256
+ */
1257
+ function charterRulesLines(bots, bot, rules) {
1258
+ const name = bot.name;
1259
+ const until = `No new rule is written until the user answers: list the rules the new charter grants, show them to the user word for word, and allow the ones they say yes to with ${shellWord(ownCli())} bot change --bots ${shellWord(bots)} --bot ${shellWord(name)} --allow <rule>`;
1260
+ if (rules.length === 0) return [`${name} is allowed no permission rules beyond the kit's defaults. ${until}`];
1261
+ const settings = runsOnClaude(bot) ? ` and ${path.join('bots', name, '.claude', 'settings.json')}` : '';
1262
+ // The kit owns the Codex file whole and rewrites it from bot.yaml (#354).
1263
+ const codex = runsOnCodex(bot) ? `, and the kit then rewrites ${path.join(bots, 'bots', name, '.codex', 'rules', 'obk.rules')} without it` : '';
1264
+ return [
1265
+ `${name} is allowed these permission rules beyond the kit's defaults, from before this change:`,
1266
+ ...rules.map((rule) => ` ${rule}`),
1267
+ `They stay allowed, whatever the user answers, until the user takes one out of ${path.join('bots', name, 'bot.yaml')}${settings}${codex}; the kit takes none out. ${until}`,
1268
+ ];
1229
1269
  }
1230
1270
 
1231
1271
  /**
@@ -1235,12 +1275,19 @@ function permissionsOf(bots, name) {
1235
1275
  * it (#344). Nothing for a bot with nothing written and nothing waiting.
1236
1276
  */
1237
1277
  function permissionsLines(permissions, bots) {
1278
+ // A bot on both harnesses has an entry for each file, and what waits for
1279
+ // Codex is also waiting for Claude: the rules are listed once, with the first.
1280
+ const listed = new Set();
1238
1281
  return permissions.flatMap((entry) => [
1239
1282
  ...(entry.trouble === undefined ? [] : [`${'refused'.padEnd(9)} ${entry.trouble}`]),
1240
1283
  ...(entry.written.length === 0
1241
1284
  ? []
1242
1285
  : [`${'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 ? [] : [
1286
+ // What the user allowed and Codex has no form for is said at every build,
1287
+ // so a bot given a Codex session later hears of it too (#354). For a bot
1288
+ // only on Codex, `--allow` refused it already.
1289
+ ...(entry.unwritten ?? []).map((one) => `${'not'.padEnd(9)} written for Codex: ${one.rule}. ${one.why}.`),
1290
+ ...(entry.waiting.length === 0 || listed.has(entry.bot) || !listed.add(entry.bot) ? [] : [
1244
1291
  `${'waiting'.padEnd(9)} ${entry.bot}: these permission rules wait for the user's yes, and none of them is written until then:`,
1245
1292
  ...entry.waiting.map((rule) => ` ${rule}`),
1246
1293
  ` Show them to the user. Once they say yes: ${allowCommand(bots, entry.bot, entry.waiting)}`,
package/src/message.js CHANGED
@@ -18,10 +18,10 @@
18
18
  import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
19
19
  import path from 'node:path';
20
20
 
21
- import { readBook } from './book.js';
21
+ import { MAILBOX_WAIT_MS, readBook, takeMailboxTurn } from './book.js';
22
22
  import { botDir, botNames, readBot } from './bot.js';
23
23
  import { harnessOf, isAddressOf, ownCli, reachesMail, shellWord } from './launch.js';
24
- import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, typeIntoTab, useMailbox } from './orca.js';
24
+ import { ackMailbox, coordinatorOf, postMessage, readMailbox, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, typeIntoTab, useMailbox } from './orca.js';
25
25
 
26
26
  /**
27
27
  * How much of a message travels as itself. Above this it is written to a file
@@ -186,10 +186,37 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
186
186
  * check hands over all of it (issue #299).
187
187
  */
188
188
  export function checkMail(bots, { bot: botName, session: sessionName, tab, peek = false }) {
189
- const who = sessionName === undefined && botName === undefined
189
+ const asked = sessionName === undefined && botName === undefined
190
190
  ? whoIsWriting(bots, undefined, tab, '--bot <bot> [--session <name>]: whose mail to read')
191
191
  : findSession(bots, sessionName === undefined ? botName : `${botName}/${sessionName}`);
192
192
 
193
+ // The session's turn at its mailbox, so the book does not move to another tab
194
+ // between the bind and the last ack, and the book read again once it is had:
195
+ // a check that waited while `up` wrote a new tab reads nothing from the old
196
+ // one (#321).
197
+ const turn = takeMailboxTurn(asked.home, asked.session);
198
+ if (turn === undefined) {
199
+ return {
200
+ bots,
201
+ bot: asked.bot,
202
+ session: asked.session,
203
+ mailbox: asked.mailbox,
204
+ messages: [],
205
+ trouble: `waited ${MAILBOX_WAIT_MS / 1000} seconds for ${asked.bot}/${asked.session}'s mailbox while another obk was making, binding or reading it, and it did not finish. Nothing was read, and its mail is still waiting.`,
206
+ };
207
+ }
208
+ try {
209
+ return mailOf(bots, findSession(bots, `${asked.bot}/${asked.session}`), peek, Date.now() + HOLD_MS);
210
+ } finally {
211
+ turn.release();
212
+ }
213
+ }
214
+
215
+ /**
216
+ * What `checkMail` does once it has the session's turn, with `who` as the book
217
+ * says now, asking Orca nothing after `until`.
218
+ */
219
+ function mailOf(bots, who, peek, until) {
193
220
  if (who.mailbox === undefined) {
194
221
  return {
195
222
  bots,
@@ -205,7 +232,7 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
205
232
  // every message after (issue #228). A session with no live tab is read as
206
233
  // whatever its mailbox is bound to, a closed tab included, and nothing is
207
234
  // bound: the session's tab is bound again when it is back up (issue #249).
208
- const live = who.tab === undefined ? undefined : tabs(who.home).find((tab) => tab.tabId === who.tab)?.handle;
235
+ const live = who.tab === undefined ? undefined : tabs(who.home, timeLeft(until)).find((tab) => tab.tabId === who.tab)?.handle;
209
236
  // Orca 1.4.210 lets a process in a tab bind and read as that tab and no
210
237
  // other, and refuses the rest with nothing done (#317). So from inside any
211
238
  // other tab, the kit asks nothing and says where the mail can be read.
@@ -220,8 +247,8 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
220
247
  trouble: `${who.bot}/${who.session}'s mail can be read only in its own tab: Orca lets a tab bind and read its own mailbox and no other. Nothing was read, and its mail is still waiting.`,
221
248
  };
222
249
  }
223
- if (live !== undefined) useMailbox(who.mailbox, live);
224
- const handle = live ?? coordinatorOf(who.mailbox);
250
+ if (live !== undefined) useMailbox(who.mailbox, live, timeLeft(until));
251
+ const handle = live ?? coordinatorOf(who.mailbox, timeLeft(until));
225
252
  if (handle === undefined) {
226
253
  return {
227
254
  bots,
@@ -235,15 +262,60 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
235
262
  // Orca hands mail over a batch at a time and replays the oldest until it is
236
263
  // acknowledged; the acknowledgement answers with the next batch. So a read
237
264
  // takes every batch in turn, or newer mail waits behind an older one (#299).
265
+ //
266
+ // A batch is given back once it is acknowledged, and not before: one this
267
+ // check stops short of acknowledging is handed over again to the next.
238
268
  const messages = [];
239
- let found = readMailbox(who.mailbox, { peek, handle });
269
+ const read = { bots, bot: who.bot, session: who.session, mailbox: who.mailbox, read: !peek, messages };
270
+ let found = readMailbox(who.mailbox, { peek, handle }, timeLeft(until));
240
271
  for (;;) {
241
- messages.push(...(found.messages ?? []).map((message) => asMessage(bots, message)));
242
- if (peek || !found.deliveryId || (found.messages ?? []).length === 0) break;
243
- found = ackMailbox(who.mailbox, found.deliveryId, handle);
272
+ const batch = (found.messages ?? []).map((message) => asMessage(bots, message));
273
+ if (peek || !found.deliveryId || batch.length === 0) {
274
+ messages.push(...batch);
275
+ return read;
276
+ }
277
+ const wait = timeLeft(until, false);
278
+ if (wait === undefined) {
279
+ return { ...read, trouble: `${who.bot}/${who.session} has more mail than one check reads in ${HOLD_MS / 1000} seconds, so it stopped there. The rest is still waiting: check again.` };
280
+ }
281
+ try {
282
+ found = ackMailbox(who.mailbox, found.deliveryId, handle, wait);
283
+ } catch (error) {
284
+ // An ack Orca did not answer about may have been taken all the same, and
285
+ // then this batch is never handed over again: it is shown, said to be
286
+ // uncertain, rather than lost (review of PR #368). One Orca refused was
287
+ // not taken, and waits for the next check.
288
+ if (error.code === TIMED_OUT) {
289
+ messages.push(...batch);
290
+ return { ...read, trouble: `${error.message} Orca did not answer whether it took the last ${batch.length === 1 ? 'message' : `${batch.length} messages`} shown above as read, so the next check may show ${batch.length === 1 ? 'it' : 'them'} again. The rest waits for the next check.` };
291
+ }
292
+ // What was acknowledged before this is gone from Orca, and would be lost
293
+ // to everyone if the failure took it down with it.
294
+ if (messages.length === 0) throw error;
295
+ return { ...read, trouble: `${error.message} The mail shown above is read, and the rest waits for the next check.` };
296
+ }
297
+ messages.push(...batch);
244
298
  }
299
+ }
300
+
301
+ /**
302
+ * How long a check holds the session's turn, all its Orca calls together. Well
303
+ * inside the minute `up` waits for the turn before it writes a new tab, so a
304
+ * check in the old tab is done before the book moves, however slow Orca is,
305
+ * and however much mail there is (review of PR #368).
306
+ */
307
+ const HOLD_MS = 40_000;
245
308
 
246
- return { bots, bot: who.bot, session: who.session, mailbox: who.mailbox, read: !peek, messages };
309
+ /**
310
+ * What one Orca call is given: twenty seconds, as the step gives its own, or
311
+ * what is left of the check's hold, in whole seconds. With none left, the
312
+ * call is refused here, or, when `refuse` is false, undefined is answered.
313
+ */
314
+ function timeLeft(until, refuse = true) {
315
+ const left = Math.min(20_000, Math.floor((until - Date.now()) / 1000) * 1000);
316
+ if (left >= 1000) return { timeoutMs: left };
317
+ if (refuse) throw new Error(`the kit gives a mail check ${HOLD_MS / 1000} seconds of Orca's time, and Orca took them all. Nothing more was asked of it, and the mail is still waiting.`);
318
+ return undefined;
247
319
  }
248
320
 
249
321
  /** One message, in the kit's words rather than Orca's. */
package/src/orca.js CHANGED
@@ -277,9 +277,9 @@ const RELOAD_SCRIPT = fileURLToPath(new URL('./orca-reload.applescript', import.
277
277
  * the real id, so an orphaned entry is given that one: the tab id is the key
278
278
  * (PRD 6.2), and this is where Orca is asked for it (tech notes, section 1).
279
279
  */
280
- export const tabs = (home) => orca(['terminal', 'list', '--worktree', `path:${home}`]).terminals
280
+ export const tabs = (home, options) => orca(['terminal', 'list', '--worktree', `path:${home}`], options).terminals
281
281
  .map((tab) => (tab.orphaned === true
282
- ? { ...tab, tabId: orca(['terminal', 'show', '--terminal', tab.handle]).terminal.tabId }
282
+ ? { ...tab, tabId: orca(['terminal', 'show', '--terminal', tab.handle], options).terminal.tabId }
283
283
  : tab));
284
284
 
285
285
  /**
@@ -645,12 +645,12 @@ export function postMessage({ to, from, subject, body, type = 'status', thread }
645
645
  * the terminal `handle` when one is given, which is how a read works from a tab
646
646
  * bound to another Run; otherwise as this process's own terminal.
647
647
  */
648
- export const readMailbox = (id, { peek = false, handle } = {}) =>
649
- orca(['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])]);
648
+ export const readMailbox = (id, { peek = false, handle } = {}, options) =>
649
+ orca(['orchestration', 'check', '--run', id, ...as(handle), ...(peek ? ['--peek'] : [])], options);
650
650
 
651
651
  /** Say a batch has been read. Orca answers with the batch after it. */
652
- export const ackMailbox = (id, delivery, handle) =>
653
- orca(['orchestration', 'check', '--run', id, ...as(handle), '--ack', delivery]);
652
+ export const ackMailbox = (id, delivery, handle, options) =>
653
+ orca(['orchestration', 'check', '--run', id, ...as(handle), '--ack', delivery], options);
654
654
 
655
655
  /** The reader of a `check`: the terminal named, or this process's own. */
656
656
  const as = (handle) => (handle === undefined ? [] : ['--terminal', handle]);
@@ -13,8 +13,12 @@
13
13
  //
14
14
  // The kit owns only the entries `allow` holds. Any other entry in the file is
15
15
  // left where it is, and health names it.
16
+ //
17
+ // A bot on Codex gets the same yes in Codex's own form (#354, ADR 0028): each
18
+ // `Bash(<words>:*)` becomes a `prefix_rule` of those words in
19
+ // `.codex/rules/obk.rules`, a file the kit owns whole and rewrites from `allow`.
16
20
 
17
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
21
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
18
22
  import path from 'node:path';
19
23
 
20
24
  import { leadsOutside } from './bot.js';
@@ -24,6 +28,10 @@ import { harnessOf, ownCli, shellWord } from './launch.js';
24
28
  /** Where Claude Code reads a project's settings, inside the bot home. */
25
29
  const FILE = '.claude/settings.json';
26
30
 
31
+ /** Where Codex reads a trusted project's rules, and the one file of them the kit owns. */
32
+ const CODEX_RULES = '.codex/rules';
33
+ const CODEX_FILE = `${CODEX_RULES}/obk.rules`;
34
+
27
35
  /**
28
36
  * The rules every Claude bot is offered, in this order. The mail commands are
29
37
  * narrowed to this bots folder and spelled as the kit prints them in its
@@ -43,6 +51,134 @@ export function defaultRules(bots, cli = ownCli()) {
43
51
  ];
44
52
  }
45
53
 
54
+ /** Command wrappers: programs that run the command their arguments name. */
55
+ const WRAPPERS = new Set(['env', 'xargs', 'sudo', 'eval', 'exec', 'nohup', 'timeout', 'nice', 'command', 'time', 'watch']);
56
+
57
+ /**
58
+ * Programs that run whatever their arguments tell them to: shells, interpreters
59
+ * and the wrappers above. With a wildcard after one of them, a rule lets the bot
60
+ * run any command, unless the next word fixes what runs by its absolute path.
61
+ */
62
+ const RUNNERS = new Set([
63
+ 'sh', 'bash', 'zsh', 'fish', 'dash', 'ksh', 'csh', 'tcsh',
64
+ 'python', 'python2', 'python3', 'node', 'deno', 'bun', 'ruby', 'perl', 'php', 'osascript',
65
+ 'npx', 'pnpx', 'bunx', 'uvx',
66
+ ...WRAPPERS,
67
+ ]);
68
+
69
+ const ANY = 'it lets the bot run any command';
70
+
71
+ /**
72
+ * Why a rule is broad, as the end of a sentence, or undefined for a narrow one
73
+ * (#353). A charter's grants are written as narrow, exact rules only; one that
74
+ * lets the bot run any command, a program with any arguments, or any file on
75
+ * the disk or in the home is the user's to add by hand (ADR 0027).
76
+ */
77
+ export function broadness(rule) {
78
+ if (typeof rule !== 'string') return undefined;
79
+ const file = /^(Read|Edit|Write)(?:\((.*)\))?$/s.exec(rule);
80
+ if (file !== null) {
81
+ const spec = file[2] ?? '';
82
+ return spec === '' || /^(\/\/|~\/)[*/]*$/.test(spec) ? `it lets the bot ${file[1].toLowerCase()} any file` : undefined;
83
+ }
84
+ const bash = /^Bash(?:\((.*)\))?$/s.exec(rule);
85
+ if (bash === null) return undefined;
86
+ const { words, odd } = shellWords(bash[1] ?? '');
87
+ if (odd !== undefined) return `it holds ${odd}, which the shell reads specially, so the kit cannot tell what it runs`;
88
+ return commandBroadness(words);
89
+ }
90
+
91
+ /** What a word may hold outside quotes; anything else the shell reads specially. */
92
+ const PLAIN = /[A-Za-z0-9\-_./:=@%+,^*]/;
93
+
94
+ /**
95
+ * A Bash rule's command as the shell words it is, quotes taken off, or the
96
+ * first thing in it the shell reads specially: `{ words }` or `{ odd }`. Only
97
+ * plain words are judged, so an escape, an expansion or a second command can
98
+ * never hide which program runs.
99
+ */
100
+ function shellWords(text) {
101
+ const words = [];
102
+ let word;
103
+ for (let at = 0; at < text.length; at += 1) {
104
+ const char = text[at];
105
+ if (char === ' ' || char === '\t') {
106
+ if (word !== undefined) words.push(word);
107
+ word = undefined;
108
+ continue;
109
+ }
110
+ word ??= '';
111
+ if (char === "'" || char === '"') {
112
+ const end = text.indexOf(char, at + 1);
113
+ if (end === -1) return { odd: `a ${char} that does not close` };
114
+ const inside = text.slice(at + 1, end);
115
+ const special = char === '"' ? /[$`\\]/.exec(inside) : null;
116
+ if (special !== null) return { odd: `the character ${special[0]}` };
117
+ word += inside;
118
+ at = end;
119
+ } else if (char === '\\' && text[at + 1] === "'") {
120
+ // An apostrophe outside quotes, the form shellWord gives one in a path.
121
+ word += "'";
122
+ at += 1;
123
+ } else if (PLAIN.test(char) || (char === '~' && word === '')) {
124
+ word += char;
125
+ } else {
126
+ return { odd: char === '\n' ? 'a newline' : `the character ${char}` };
127
+ }
128
+ }
129
+ if (word !== undefined) words.push(word);
130
+ return { words };
131
+ }
132
+
133
+ /** The shell or interpreter a program name is, with a version on its name or not. */
134
+ const runnerOf = (name) => [name, name.replace(/[\d.]+$/, '')].find((one) => RUNNERS.has(one));
135
+
136
+ /** Why a Bash rule's command, as shell words, is broad, or undefined. */
137
+ function commandBroadness(all) {
138
+ // The program is the first word after any shell assignments in front of it.
139
+ let words = all;
140
+ while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words = words.slice(1);
141
+ if (words.length === 0) return ANY;
142
+ const star = words.findIndex((word) => word.includes('*'));
143
+ if (star === -1) return undefined;
144
+
145
+ const program = path.basename(words[0].replace(/:?\*.*$/, ''));
146
+ if (runnerOf(program) !== undefined) {
147
+ // Only what runs, fixed by its absolute path, narrows it. A wrapper's
148
+ // program is then judged by itself; an interpreter's script is the end.
149
+ const next = (words[1] ?? '').replace(/:\*$/, '');
150
+ const fixed = next.startsWith('/') && !next.includes('*');
151
+ if (fixed && WRAPPERS.has(program)) return commandBroadness(words.slice(1));
152
+ if (fixed && runnerOf(path.basename(next)) === undefined) return undefined;
153
+ return `${program} runs whatever its arguments say, so ${ANY}`;
154
+ }
155
+
156
+ // Two words at least before the first wildcard: a program and what it does.
157
+ const part = words[star].slice(0, words[star].indexOf('*')).replace(/:$/, '');
158
+ const before = star + (part === '' ? 0 : 1);
159
+ if (before === 0) return ANY;
160
+ if (before < 2) return `it lets the bot run ${words[0].replace(/:?\*.*$/, '')} with any arguments`;
161
+ return undefined;
162
+ }
163
+
164
+ /**
165
+ * Refuse the rules `--allow` was given when any is broad, before anything is
166
+ * written, naming the file the user can add it to themselves.
167
+ */
168
+ export function refuseBroad(home, rules) {
169
+ for (const rule of rules) {
170
+ const why = broadness(rule);
171
+ if (why === undefined) continue;
172
+ throw new Error(`--allow ${rule} is broad: ${why}. The kit writes only narrow, exact rules, such as Bash(gh pr merge:*), so nothing was written. If the user wants this rule for ${path.basename(home)}, they add it to ${path.join(home, FILE)} themselves.`);
173
+ }
174
+ }
175
+
176
+ /** The rules the bot is allowed beyond the kit's defaults, in `allow`'s order. */
177
+ export const beyondDefaults = (bots, home, bot) => {
178
+ const defaults = defaultRules(bots);
179
+ return allowOf(home, bot).filter((rule) => !defaults.includes(rule));
180
+ };
181
+
46
182
  /** Whether any of a bot's sessions, or the bot itself, runs on Claude Code. */
47
183
  export const runsOnClaude = (bot) => bot.harness === 'claude'
48
184
  || bot.sessions.some((session) => harnessOf(session, bot.harness) === 'claude');
@@ -58,11 +194,64 @@ export function allowOf(home, bot) {
58
194
  return value;
59
195
  }
60
196
 
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 [];
197
+ /** Whether any of a bot's sessions, or the bot itself, runs on Codex. */
198
+ export const runsOnCodex = (bot) => bot.harness === 'codex'
199
+ || bot.sessions.some((session) => harnessOf(session, bot.harness) === 'codex');
200
+
201
+ /**
202
+ * The default rules this bot has not been allowed yet for one harness's file,
203
+ * in the default order. Codex is not offered the Read rule: its sandbox reads
204
+ * every file already.
205
+ */
206
+ function waitingFor(bots, home, bot, harness) {
64
207
  const allowed = allowOf(home, bot);
65
- return defaultRules(bots).filter((rule) => !allowed.includes(rule));
208
+ return defaultRules(bots)
209
+ .filter((rule) => harness === 'claude' || codexForm(rule).line !== undefined)
210
+ .filter((rule) => !allowed.includes(rule));
211
+ }
212
+
213
+ /**
214
+ * A rule's Codex form: `{ line }`, `{ needless: true }` for a Read rule, which
215
+ * Codex's sandbox does not need, or `{ why }` for a rule Codex has none for.
216
+ * Only a prefix of plain words has one; a prefix rule for one exact command
217
+ * would let the bot add any arguments the user did not say yes to.
218
+ */
219
+ export function codexForm(rule) {
220
+ if (/^Read(\(.*\))?$/s.test(rule)) return { needless: true };
221
+ const bash = /^Bash\((.*)\)$/s.exec(rule);
222
+ if (bash === null) return { why: 'Codex has no rule for anything but a command, so its sandbox decides it' };
223
+ const { words, odd } = shellWords(bash[1]);
224
+ if (odd !== undefined) return { why: `Codex has no rule for it: it holds ${odd}` };
225
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0] ?? '')) {
226
+ return { why: 'Codex has no rule for a command with a variable set in front of it' };
227
+ }
228
+ if (!words.some((word) => word.includes('*'))) {
229
+ return { why: 'Codex has no rule for one exact command: its rules match a command\'s first words, whatever comes after them' };
230
+ }
231
+ // Only the wildcard comes off, ` *` or `:*` at the very end: every word
232
+ // before it stays, an empty quoted one included, or the rule would be looser
233
+ // than the one the user said yes to.
234
+ const text = bash[1].trimEnd();
235
+ const end = /(?: |:)\*$/.exec(text);
236
+ const prefix = end === null ? [] : shellWords(text.slice(0, end.index)).words;
237
+ if (prefix.length === 0 || prefix.some((word) => word.includes('*'))) {
238
+ return { why: 'Codex has no rule for a wildcard anywhere but at the end, as a word of its own or after a colon' };
239
+ }
240
+ return { line: `prefix_rule(pattern=[${prefix.map((word) => JSON.stringify(word)).join(', ')}], decision="allow")` };
241
+ }
242
+
243
+ /**
244
+ * Refuse the rules `--allow` was given for a bot that runs only on Codex when
245
+ * Codex has no form for one, before anything is written: recorded, it would
246
+ * let the bot do nothing.
247
+ */
248
+ export function refuseNoCodexForm(bot, rules) {
249
+ if (runsOnClaude(bot) || !runsOnCodex(bot)) return;
250
+ for (const rule of rules) {
251
+ const { why } = codexForm(rule);
252
+ if (why === undefined) continue;
253
+ throw new Error(`--allow ${rule} is not for ${bot.name}: ${why}, and ${bot.name} runs only on Codex, so nothing was written.`);
254
+ }
66
255
  }
67
256
 
68
257
  /** The one command that allows `rules` for `bot`, as the user can run it. */
@@ -72,19 +261,34 @@ export const allowCommand = (bots, bot, rules) => [
72
261
  ].join(' ');
73
262
 
74
263
  /**
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.
264
+ * Write what the bot is allowed into the files of the harnesses it runs on, and
265
+ * say what waits: one entry per file, Claude's first. With `keepGoing`, a file
266
+ * the kit may not write is an entry with its `trouble` rather than a throw.
267
+ */
268
+ export function writePermissions(bots, home, bot, { keepGoing = false } = {}) {
269
+ const writers = [[runsOnClaude, FILE, writeClaude], [runsOnCodex, CODEX_FILE, writeCodex]];
270
+ return writers.filter(([runs]) => runs(bot)).map(([, file, write]) => {
271
+ try {
272
+ return write(bots, home, bot);
273
+ } catch (error) {
274
+ if (!keepGoing) throw error;
275
+ return { bot: bot.name, file: path.join(home, file), written: [], waiting: [], trouble: error.message };
276
+ }
277
+ });
278
+ }
279
+
280
+ /**
281
+ * Write what the bot is allowed into its Claude settings: `{ bot, file,
282
+ * written, waiting }`, with `written` the rules this run added.
78
283
  *
79
284
  * Only rules `allow` holds are written. Everything else in the file stays as it
80
285
  * is, the user's own entries and their order included; a rule missing from the
81
286
  * file goes after them, and a run with nothing to add writes nothing.
82
287
  */
83
- export function writePermissions(bots, home, bot) {
84
- if (!runsOnClaude(bot)) return undefined;
288
+ function writeClaude(bots, home, bot) {
85
289
  const file = path.join(home, FILE);
86
290
  const allowed = allowOf(home, bot);
87
- const waiting = waitingFor(bots, home, bot);
291
+ const waiting = waitingFor(bots, home, bot, 'claude');
88
292
  const answer = { bot: bot.name, file, written: [], waiting };
89
293
  if (allowed.length === 0) return answer;
90
294
 
@@ -100,6 +304,49 @@ export function writePermissions(bots, home, bot) {
100
304
  return { ...answer, written: missing };
101
305
  }
102
306
 
307
+ /**
308
+ * The Codex form of what the bot is allowed: the file's rule lines, each once,
309
+ * with the rules that gave them, and the rules Codex has no form for.
310
+ */
311
+ function codexOf(home, bot) {
312
+ const lines = new Map();
313
+ const unwritten = [];
314
+ for (const rule of allowOf(home, bot)) {
315
+ const form = codexForm(rule);
316
+ if (form.why !== undefined) unwritten.push({ rule, why: form.why });
317
+ if (form.line === undefined) continue;
318
+ lines.set(form.line, [...(lines.get(form.line) ?? []), rule]);
319
+ }
320
+ return { lines, unwritten };
321
+ }
322
+
323
+ /** The lines of a rules file that are rules: not blank, not a comment. */
324
+ const ruleLines = (text) => text.split('\n').map((line) => line.trim()).filter((line) => line !== '' && !line.startsWith('#'));
325
+
326
+ /**
327
+ * Write the Codex form of what the bot is allowed into `.codex/rules/obk.rules`:
328
+ * `{ bot, file, written, waiting, unwritten }`, with `written` every rule in
329
+ * the file when this run wrote it. The kit owns the file whole and writes it
330
+ * only when it would change; with nothing to write and no file, it makes none.
331
+ */
332
+ function writeCodex(bots, home, bot) {
333
+ const file = path.join(home, CODEX_FILE);
334
+ const { lines, unwritten } = codexOf(home, bot);
335
+ const answer = { bot: bot.name, file, written: [], waiting: waitingFor(bots, home, bot, 'codex'), unwritten };
336
+ if (lines.size === 0 && !existsSync(file)) return answer;
337
+
338
+ refuseOutside(home, file);
339
+ const text = [
340
+ `# Written by obk from ${bot.name}'s bot.yaml, where the user's yes to each rule is kept.`,
341
+ '# obk rewrites this whole file: put rules of your own in another file in this folder.',
342
+ ...lines.keys(),
343
+ ].join('\n');
344
+ if (existsSync(file) && readFileSync(file, 'utf8') === `${text}\n`) return answer;
345
+ mkdirSync(path.dirname(file), { recursive: true });
346
+ writeFileSync(file, `${text}\n`);
347
+ return { ...answer, written: [...lines.values()].flat() };
348
+ }
349
+
103
350
  /**
104
351
  * What health has to say about a bot's permission rules: `{ where, says }` for
105
352
  * each entry in the file that `allow` does not hold, and each rule `allow`
@@ -107,15 +354,20 @@ export function writePermissions(bots, home, bot) {
107
354
  * never taken out.
108
355
  */
109
356
  export function permissionsTrouble(home, bot) {
110
- if (!runsOnClaude(bot)) return [];
111
- const file = path.join(home, FILE);
112
-
113
357
  let allowed;
114
358
  try {
115
359
  allowed = allowOf(home, bot);
116
360
  } catch (error) {
361
+ if (!runsOnClaude(bot) && !runsOnCodex(bot)) return [];
117
362
  return [{ where: path.join(home, 'bot.yaml'), says: error.message }];
118
363
  }
364
+ return [...claudeTrouble(home, bot, allowed), ...codexTrouble(home, bot)];
365
+ }
366
+
367
+ /** Health's findings in the bot's Claude settings. */
368
+ function claudeTrouble(home, bot, allowed) {
369
+ if (!runsOnClaude(bot)) return [];
370
+ const file = path.join(home, FILE);
119
371
  if (!existsSync(file) && allowed.length === 0) return [];
120
372
 
121
373
  // A link out of the bot folder is named by the hook's check already; here it
@@ -133,7 +385,11 @@ export function permissionsTrouble(home, bot) {
133
385
  return [
134
386
  ...present.filter((rule) => !allowed.includes(rule)).map((rule) => ({
135
387
  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.`,
388
+ // One `--allow` would refuse was added by the user by hand, which the
389
+ // boundary leaves to them: named, in neutral words, and left alone.
390
+ says: broadness(rule) === undefined
391
+ ? `${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.`
392
+ : `${file} allows ${rule}, which the user added by hand, not the kit. It stays where it is.`,
137
393
  })),
138
394
  ...allowed.filter((rule) => !present.includes(rule)).map((rule) => ({
139
395
  where: file,
@@ -142,6 +398,71 @@ export function permissionsTrouble(home, bot) {
142
398
  ];
143
399
  }
144
400
 
401
+ /**
402
+ * Health's findings in the bot's Codex rules: each rule in a file of the
403
+ * user's beside obk.rules, named and left alone, and an obk.rules that is not
404
+ * what the kit writes from `allow`.
405
+ */
406
+ function codexTrouble(home, bot) {
407
+ if (!runsOnCodex(bot)) return [];
408
+ const folder = path.join(home, CODEX_RULES);
409
+ const file = path.join(home, CODEX_FILE);
410
+ const { lines } = codexOf(home, bot);
411
+ if (leadsOutside(home, file) !== undefined) {
412
+ if (lines.size === 0) return [];
413
+ try {
414
+ refuseOutside(home, file);
415
+ } catch (error) {
416
+ return [{ where: file, says: error.message }];
417
+ }
418
+ }
419
+
420
+ // A file health cannot read is one finding of its own, and the rest of the
421
+ // fleet's health still comes out.
422
+ const unread = (where, error) => [{ where, says: `${where} could not be read (${error.code ?? error.message}), so health cannot say what it holds. It stays where it is.` }];
423
+ let names = [];
424
+ try {
425
+ names = existsSync(folder) ? readdirSync(folder) : [];
426
+ } catch (error) {
427
+ return unread(folder, error);
428
+ }
429
+ const theirs = names
430
+ .filter((name) => name.endsWith('.rules') && name !== path.basename(file))
431
+ .sort()
432
+ .flatMap((name) => {
433
+ const where = path.join(folder, name);
434
+ let text;
435
+ try {
436
+ text = readFileSync(where, 'utf8');
437
+ } catch (error) {
438
+ return unread(where, error);
439
+ }
440
+ return ruleLines(text).map((line) => ({
441
+ where,
442
+ says: `${where} holds ${line}, which the user added, not the kit. It stays where it is.`,
443
+ }));
444
+ });
445
+
446
+ const wanted = [...lines.keys()];
447
+ let present = [];
448
+ try {
449
+ present = existsSync(file) ? ruleLines(readFileSync(file, 'utf8')) : [];
450
+ } catch (error) {
451
+ return [...theirs, ...unread(file, error)];
452
+ }
453
+ const missing = wanted.filter((line) => !present.includes(line));
454
+ const extra = present.filter((line) => !wanted.includes(line));
455
+ if (missing.length === 0 && extra.length === 0) return theirs;
456
+ const what = [
457
+ ...(missing.length === 0 ? [] : [`it lacks ${missing.join(' and ')}, which ${bot.name}'s bot.yaml allows`]),
458
+ ...(extra.length === 0 ? [] : [`it holds ${extra.join(' and ')}, which the kit did not write`]),
459
+ ].join(', and ');
460
+ return [...theirs, {
461
+ where: file,
462
+ says: `${file} is not what the kit writes from ${bot.name}'s bot.yaml: ${what}. The kit owns this file, and obk up rewrites it; a rule of the user's own goes in another file in ${folder}.`,
463
+ }];
464
+ }
465
+
145
466
  /** The file's `permissions.allow`, or a refusal when it is there and is not a list. */
146
467
  function presentIn(settings, file) {
147
468
  const permissions = settings.permissions;
package/src/up.js CHANGED
@@ -8,7 +8,7 @@ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { setTimeout as pause } from 'node:timers/promises';
10
10
 
11
- import { forgetClaimed, forgetSession, readBook, sessionIdsIn, tabIdsIn, updateBook, withUnclaimed } from './book.js';
11
+ import { forgetClaimed, forgetSession, MAILBOX_WAIT_MS, readBook, sessionIdsIn, tabIdsIn, takeMailboxTurn, updateBook, withUnclaimed } from './book.js';
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';
@@ -155,7 +155,7 @@ export function prepareBots(bots, names, onlySession) {
155
155
  // And the permission rules the user allowed, beside the hook and for the same
156
156
  // reason: a session that comes up before its rules are there is asked about
157
157
  // what the user already said yes to (#344).
158
- const permissions = running.map(({ bot, home }) => writePermissions(bots, home, bot)).filter((entry) => entry !== undefined);
158
+ const permissions = running.flatMap(({ bot, home }) => writePermissions(bots, home, bot));
159
159
 
160
160
  return { running, rules, skills, permissions };
161
161
  }
@@ -296,20 +296,31 @@ async function bringUpSession(bots, home, live, session, bot, title) {
296
296
  // does not (#272).
297
297
  const rules = rulesStamp(home);
298
298
  let held;
299
- await updateBook(home, (current) => {
300
- // What the harness has in this folder that nobody claims goes on the record,
301
- // for a person or Bot Father to settle — added to whatever was already noted,
302
- // because this run's scan cannot see what an earlier one found. The kit never
303
- // settles it itself.
304
- let entry = { ...current.sessions[session.name], tab: made.tabId, launched, rules };
305
- if (rules === undefined) delete entry.rules;
306
- held = entry.session;
307
- // Before the line is typed, so the hook finds no id here and takes the one
308
- // it reports for a start rather than a clear, which would tell the duty twice.
309
- if (which.noConversation !== undefined && held === which.noConversation) entry = forgetSession(entry, 'no conversation');
310
- current.sessions[session.name] = withUnclaimed(entry, which.unclaimed ?? []);
311
- forgetClaimed(current);
312
- });
299
+ // Not while the session's mailbox step or a check in its old tab is at work:
300
+ // the book moving under one leaves the mailbox bound to, or read in, a tab the
301
+ // book no longer names (#321). Waited for a bounded time and then written all
302
+ // the same, because this tab is open and its harness has to start; the step
303
+ // in it then waits its own turn and says what it left. Let go before the line
304
+ // is typed, since that step takes it.
305
+ const turn = takeMailboxTurn(home, session.name);
306
+ try {
307
+ await updateBook(home, (current) => {
308
+ // What the harness has in this folder that nobody claims goes on the record,
309
+ // for a person or Bot Father to settle — added to whatever was already noted,
310
+ // because this run's scan cannot see what an earlier one found. The kit never
311
+ // settles it itself.
312
+ let entry = { ...current.sessions[session.name], tab: made.tabId, launched, rules };
313
+ if (rules === undefined) delete entry.rules;
314
+ held = entry.session;
315
+ // Before the line is typed, so the hook finds no id here and takes the one
316
+ // it reports for a start rather than a clear, which would tell the duty twice.
317
+ if (which.noConversation !== undefined && held === which.noConversation) entry = forgetSession(entry, 'no conversation');
318
+ current.sessions[session.name] = withUnclaimed(entry, which.unclaimed ?? []);
319
+ forgetClaimed(current);
320
+ });
321
+ } finally {
322
+ turn?.release();
323
+ }
313
324
 
314
325
  // The old tab's hook can name another conversation while the tab is being
315
326
  // opened. A fresh start chosen before that is chosen again from the id the
@@ -524,6 +535,20 @@ export async function ownMailbox(bots, botName, sessionName) {
524
535
  throw new Error(`session mailbox binds ${who}'s mailbox to the Orca tab it runs in, and this is not one: ${TERMINAL_ENV} is not set. The kit runs it in the session's own tab when it starts the session.`);
525
536
  }
526
537
 
538
+ // One step, check or tab write for this session at a time, and the book read
539
+ // once the turn is had (#321). A step that does not get it leaves everything
540
+ // as it is and says so, and the launch line goes on to the harness.
541
+ const turn = takeMailboxTurn(home, sessionName);
542
+ if (turn === undefined) throw noTurn(home, who, sessionName);
543
+ try {
544
+ return await mailboxInTurn(home, botName, sessionName, who);
545
+ } finally {
546
+ turn.release();
547
+ }
548
+ }
549
+
550
+ /** What `ownMailbox` does once it has the session's turn. */
551
+ async function mailboxInTurn(home, botName, sessionName, who) {
527
552
  // Only in the tab the book names for the session. Two runs of `up` at once
528
553
  // can leave a session two tabs, and the book one of them: a mailbox made or
529
554
  // bound in the other would be bound to a tab no message is meant for.
@@ -600,6 +625,15 @@ export async function ownMailbox(bots, botName, sessionName) {
600
625
  */
601
626
  const STEP_WAIT = { timeoutMs: 20_000 };
602
627
 
628
+ /** A step that waited its turn out: nothing was made or bound, and where the mailbox was left. */
629
+ function noTurn(home, who, sessionName) {
630
+ const held = readBook(home).sessions[sessionName]?.mailbox;
631
+ const left = typeof held === 'string'
632
+ ? `${who}'s mailbox ${held} is left bound to ${boundTo(held)}; an \`obk message check\` in ${who}'s own tab binds it there.`
633
+ : `None is written down for ${who}; it gets one the next time the kit starts it.`;
634
+ return new Error(`waited ${MAILBOX_WAIT_MS / 1000} seconds for ${who}'s mailbox while another obk was making, binding or reading it, and it did not finish. Nothing was made or bound in this tab. ${left}`);
635
+ }
636
+
603
637
  /** Whether a session that can have a mailbox has none in its book. */
604
638
  const lacksMailbox = (home, session, harness) => reachesMail(session, harness)
605
639
  && typeof readBook(home).sessions[session.name]?.mailbox !== 'string';