@assuredloop/orca-bot-kit 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
@@ -579,10 +587,11 @@ The tests come in two layers, and every test file belongs to one of them.
579
587
 
580
588
  `npm test` is `test/*.test.js`: unit tests and end-to-end runs of the CLI in a
581
589
  throwaway folder, with a fake `orca` on PATH. It needs nothing but Node, so
582
- [GitHub Actions](.github/workflows/ci.yml) runs it on every pull request and on
583
- every push to `main`: on Node 25.8.0, the current line, and again on 24.21.0,
584
- the floor `engines.node` promises users, with only the kit's own dependencies
585
- installed.
590
+ [GitHub Actions](.github/workflows/ci.yml) runs it on every pull request, split
591
+ into shards that run side by side, on Node 25.8.0, the current line. A release
592
+ runs every shard on 25.8.0 and again on 24.21.0, the floor `engines.node`
593
+ promises users, with only the kit's own dependencies installed, before anything
594
+ is published.
586
595
 
587
596
  `npm run test:system` is `test/system/*.test.js`: the real `obk` against the
588
597
  real Orca and the real harnesses on your own machine. No CI runner can do that,
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.12.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,6 +24,7 @@
24
24
  ],
25
25
  "scripts": {
26
26
  "test": "node --test test/*.test.js",
27
+ "test:shard": "node --test --test-shard=$SHARD test/*.test.js",
27
28
  "test:system": "node scripts/test-system.js",
28
29
  "mutate": "node scripts/mutate.js"
29
30
  },
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>` or
22
+ `--disallow <rule>`. Never edit one in or out yourself, Bot Father included.
@@ -201,16 +201,41 @@ 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, show
230
+ each word for word, and ask whether to take it back. Only after their yes run
231
+ `obk bot change --disallow`, once per rule, which takes it out of `bot.yaml`
232
+ and the bot's settings or Codex rules; on a no, run nothing, and it stays
233
+ allowed. A rule the user added to a settings file by hand is theirs to take
234
+ out; the kit refuses it. Keep
235
+ each rule narrow: a program and what it does. The kit refuses a rule that lets
236
+ the bot run any command, a program with any arguments, or a shell or
237
+ interpreter with any arguments; a user who wants one for a bot adds it to that
238
+ bot's settings themselves. Propose no rule for what the charter does not grant.
214
239
 
215
240
  A bot that should learn across its sessions can use its harness's own memory,
216
241
  turned on for that bot alone, whatever the user's own setting is for everything
@@ -256,23 +281,31 @@ otherwise `/Applications/Orca.app/Contents/Resources/bin/orca`. A bare
256
281
  `orca` can fail. Send each answer as one `<orca> terminal send --terminal
257
282
  <handle> --text …`, with the return inside the text and no `--enter`:
258
283
 
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
284
+ | On screen | Send | Which is | Proven on |
285
+ |---|---|---|---|
286
+ | Claude Code's trust list | `\x1b[B\r` | down, return: off **No, exit** | Claude Code 2.1.283 |
287
+ | Codex's `Trust this folder?` | `\r` | return: it starts on yes | Codex 0.157.1 |
288
+ | Codex's `Hooks need review` | `\x1b[B\r` | **Trust all and continue** | Codex 0.157.1 |
289
+ | Codex's `Where should the new conversation run?` | `\r` | **Current checkout** | Codex 0.157.1 |
290
+ | Codex's update offer, `1. Update now` | `\r` | **Update now**, where it starts | Codex 0.156.1 |
291
+ | `[oh-my-zsh] Would you like to update?` | `n` | no: the user updates it | oh-my-zsh bf77e35 |
292
+ | Claude Code's `Teach auto mode…` | `\x1b` | Esc, **Not now** | Not yet proven, below |
293
+
294
+ Each key counts on where the selection starts on the version named. Read the
295
+ screen before you send: if the selection is elsewhere or the options have
296
+ moved, go to the answer with the arrows, then return. Never a digit and then
297
+ return: on Codex's update offer that took the highlighted **Update now**,
298
+ whatever the digit was. Codex's trust said `1. Yes, continue` on older
299
+ versions. Its hooks review sometimes starts on `2` already: then return alone.
300
+ 0.157.1 has had no newer release to offer, so its update offer was proven on
301
+ 0.156.1. Claude Code's `Teach auto mode` has not been seen live on 2.1.283:
302
+ Esc is Not now in its code, and 2.1.278 was answered with `2. Not now`.
303
+ Not now, because Yes writes the user's settings. Codex's hooks
271
304
  question matters most. The kit's hook is how the book learns which
272
305
  conversation the session is running, and until it is answered the
273
306
  conversation has not started. Codex's trust applies to the repository root,
274
307
  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
308
+ conversation runs (0.156.1 and 0.157.1 both did); the answer is
276
309
  always the current checkout, the bot home, and never `2. New worktree`:
277
310
  the kit never makes a git worktree.
278
311
 
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 {
@@ -183,12 +191,65 @@ function takeLock(home) {
183
191
  * is nobody's to sweep, and the user can see it and delete it.
184
192
  *
185
193
  * One folder per bots folder, so two bots folders each holding an api-bot cannot
186
- * take each other's turn.
194
+ * take each other's turn. Resolved first, as the mailbox's is: one writer reaches
195
+ * the bots folder through a link and another by its real path, and two paths
196
+ * would be two locks for one book (#375).
187
197
  */
188
- const lockFile = (home) => path.join(
189
- `${path.dirname(path.dirname(home))}.locks`,
190
- `${encodeURIComponent(path.basename(home))}.lock`,
191
- );
198
+ const lockFile = (home) => {
199
+ const real = realpathSync(home);
200
+ return path.join(
201
+ `${path.dirname(path.dirname(real))}.locks`,
202
+ `${encodeURIComponent(path.basename(real))}.lock`,
203
+ );
204
+ };
205
+
206
+ /**
207
+ * One session's turn at its mailbox (#321): held by `session mailbox` for its
208
+ * whole step, by `message check` in the session's own tab for its bind, read and
209
+ * ack, and by `up` while it writes a new tab for the session into the book. So
210
+ * the book never moves to another tab while a step or a check is at work, and a
211
+ * step or check that waited reads the book again once it has its turn: two
212
+ * starts of one session at once leave its mailbox bound to the tab the book
213
+ * names, and nothing is read from the one it no longer does.
214
+ *
215
+ * A lock of its own, and not the book's: the book's is held for one read and one
216
+ * write, and a session's hook must never wait behind Orca for it. Taken before
217
+ * the book's and never while holding it, so neither waits on the other.
218
+ *
219
+ * Returns `{ release }`, or undefined when the turn did not come in
220
+ * `MAILBOX_WAIT_MS`: the caller says what it left, and a launch line goes on
221
+ * to its harness whatever happened here.
222
+ */
223
+ export function takeMailboxTurn(home, session) {
224
+ try {
225
+ return lockOn(mailboxLockFile(home, session), MAILBOX_WAIT_MS);
226
+ } catch (error) {
227
+ if (error.errcode === SQLITE_BUSY) return undefined;
228
+ throw error;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * How long a step, a check or an `up` waits for a session's turn. Longer than a
234
+ * step can hold it — three Orca calls of twenty seconds each at most — and short
235
+ * enough that a harness behind a stuck one still starts.
236
+ */
237
+ export const MAILBOX_WAIT_MS = 60_000;
238
+
239
+ /** SQLite's own code for a lock another connection holds. */
240
+ const SQLITE_BUSY = 5;
241
+
242
+ /**
243
+ * Beside the book's lock, one file per session. Names are lower-case letters,
244
+ * digits and hyphens, so a dot between the bot's and the session's cannot be
245
+ * read two ways. Resolved first, because the same bot is reached through a
246
+ * link by one command and by its real path by another, and two paths would be
247
+ * two turns.
248
+ */
249
+ const mailboxLockFile = (home, session) => {
250
+ const real = realpathSync(home);
251
+ return path.join(`${path.dirname(path.dirname(real))}.locks`, `${path.basename(real)}.${session}.mailbox.lock`);
252
+ };
192
253
 
193
254
  /**
194
255
  * Something else has been writing the book for longer than this run is prepared
package/src/bot.js CHANGED
@@ -254,14 +254,14 @@ export function changeBot(bots, bot, { charter }) {
254
254
  }
255
255
 
256
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.
257
+ * The bot's `allow` list as it is, after refusing what `allowRules` or
258
+ * `disallowRules` would refuse: an empty rule, or a list already there that is
259
+ * not a list of rules. Asked before anything is written, so a refused `bot
260
+ * change` changes nothing, its charter included.
261
261
  */
262
- export function allowedNow(bots, bot, rules) {
262
+ export function allowedNow(bots, bot, rules, flag = '--allow') {
263
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:*).');
264
+ throw new Error(`${flag} is empty. Give it the exact permission rule the user said yes to, such as Bash(git add:*).`);
265
265
  }
266
266
  const home = existingBot(bots, bot);
267
267
  const was = readBot(home, bot).allow ?? [];
@@ -289,6 +289,21 @@ export function allowRules(bots, bot, rules) {
289
289
  return { bot, home, allow, added };
290
290
  }
291
291
 
292
+ /**
293
+ * Take the permission rules the user said yes to taking back, `rules`, out of
294
+ * the bot's `allow` (#360). Returns { bot, home, allow, disallowed }: the whole
295
+ * list now, and what this call took out. The caller has refused a rule `allow`
296
+ * does not hold, and takes them out of the harness's files first. With `write`
297
+ * false it only refuses what the edit would refuse, and writes nothing.
298
+ */
299
+ export function disallowRules(bots, bot, rules, { write = true } = {}) {
300
+ const { home, was } = allowedNow(bots, bot, rules, '--disallow');
301
+ const disallowed = [...new Set(rules)];
302
+ const allow = was.filter((rule) => !disallowed.includes(rule));
303
+ editBot(bots, bot, `take back ${disallowed.join(', ')} for ${bot}`, (doc) => doc.set('allow', allow), (before) => ({ ...before, allow }), write);
304
+ return { bot, home, allow, disallowed };
305
+ }
306
+
292
307
  /**
293
308
  * Change the settings of the session `name` of the bot `bot`. Returns
294
309
  * { bot, home, session } — the session as it now reads.
@@ -387,9 +402,10 @@ function editSession(bots, bot, index, session, what) {
387
402
 
388
403
  /**
389
404
  * Make one edit to a bot's `bot.yaml` through the YAML library, and write it
390
- * only if exactly the change `expected` describes is what came out.
405
+ * only if exactly the change `expected` describes is what came out, and
406
+ * `write` is not false.
391
407
  */
392
- function editBot(bots, bot, what, edit, expected) {
408
+ function editBot(bots, bot, what, edit, expected, write = true) {
393
409
  const file = path.join(existingBot(bots, bot), BOT_YAML);
394
410
  const source = readFileSync(file, 'utf8');
395
411
  const doc = parseDocument(source);
@@ -398,7 +414,7 @@ function editBot(bots, bot, what, edit, expected) {
398
414
  if (!changesExactly(source, text, expected)) {
399
415
  throw new Error(`${file} cannot be changed to ${what} without changing something else in it, so nothing was written. Make the change by hand.`);
400
416
  }
401
- if (text !== source) writeFileSync(file, text);
417
+ if (write && text !== source) writeFileSync(file, text);
402
418
  }
403
419
 
404
420
  /** A bot that is there, by its home, or the refusal a caller who named another is owed. */