@assuredloop/orca-bot-kit 0.26.0 → 0.27.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
@@ -141,15 +141,19 @@ words, `dangerously-skip`
141
141
  ([ADR 0015](docs/adr/0015-three-approval-levels-auto-by-default.md)). Only
142
142
  `obk permission approval` changes it, after your yes: `session add` and
143
143
  `session change` take no approval, and refuse an extra argument that sets one
144
- ([ADR 0037](docs/adr/0037-permission-changes-and-risky-operations-have-commands-of-their-own.md)).
144
+ ([ADR 0041](docs/adr/0041-permission-changes-and-risky-operations-have-commands-of-their-own.md)).
145
145
 
146
146
  Every bot gets the kit's default permission rules, written by the kit with
147
147
  nobody asked: its own commands for this bots folder, reading a long message,
148
148
  `git add` and `git commit`, and Orca's `orca orchestration check --run`
149
149
  ([ADR 0036](docs/adr/0036-the-kit-writes-every-bots-default-permission-rules.md)).
150
- `obk permission`, `obk retire`, `obk pause` and `obk init` are not in the set,
151
- and keep your yes. A rule beyond the set is written by `obk permission allow`,
152
- after your yes to the exact rule.
150
+ `obk permission`, `obk retire`, `obk pause`, `obk restart`, `obk bot create`
151
+ and `obk init` are not in the set, and keep your yes
152
+ ([ADR 0041](docs/adr/0041-permission-changes-and-risky-operations-have-commands-of-their-own.md)).
153
+ A rule beyond the set is written by `obk permission allow`, after your yes to
154
+ the exact rule. A bot that still holds the `obk restart` or `obk bot create`
155
+ rule, which kit 0.26.0 gave every bot, keeps it: `obk rules build` and `obk up`
156
+ name it, with the `obk permission disallow` command that takes it back.
153
157
 
154
158
  Neither command touches Orca. `obk up` is what makes it all real there: an Orca
155
159
  project per bot, a tab per session, each started at the bot home with its own
@@ -361,19 +365,6 @@ address that is none of your bots folder's sessions, the hook tells it so, and
361
365
  names the address `obk message to` would have given. The message goes either
362
366
  way ([ADR 0032](docs/adr/0032-a-native-message-outside-the-fleet-is-warned-about.md)).
363
367
 
364
- And a third: at the end of each turn, it tells the session once about fleet
365
- mail the kit sent it that is still unread, without typing into its tab
366
- ([ADR 0035](docs/adr/0035-messaging-transport.md)). It asks Orca only when the kit
367
- knows of mail for that session.
368
-
369
- A Codex bot gets one more hook too. Inside Codex's sandbox the kit cannot always
370
- tell whether the receiver of a message has its harness in front, as for a Claude
371
- session busy running a command, so `obk message send` leaves that nudge, and the
372
- hook, which Codex runs outside its sandbox right after the command, decides it and
373
- types it, or says why not ([ADR 0034](docs/adr/0034-orca-is-the-host.md)). Codex
374
- asks you to trust a hook it has not seen before, so a Codex bot made before this
375
- hook asks again once.
376
-
377
368
  So: kill a tab, or reboot, and `obk up` brings the session back with the
378
369
  conversation it was having, rather than starting a new one. Clear a session —
379
370
  `/clear` on Claude Code, `/new` on Codex — and the kit writes down the new id,
@@ -473,7 +464,12 @@ same refusals, and one more: it also refuses a tab with only its shell in front
473
464
  whose conversation the book does not name. `obk up` leaves what is paused
474
465
  closed until `obk unpause` brings it back with its conversations. Retiring a
475
466
  session takes it off the bot and keeps its conversations in the book under
476
- `retired`. Retiring a bot closes
467
+ `retired`. Once its tab is closed, it stops what the session left running in
468
+ its work dir: each of your processes whose working folder is there, and what
469
+ they started, with SIGTERM and, after up to 3 seconds, SIGKILL. It names each
470
+ process it stopped, and each it left: one that only shares a process group with
471
+ them, or one it could not stop. Where it cannot read the processes, as inside
472
+ Codex's sandbox, it says so and stops nothing. Retiring a bot closes
477
473
  its tabs, removes its Orca project and moves its folder to `retired/`; it will
478
474
  not touch a bot whose Orca project holds a tab your book does not name. The
479
475
  folder moves only once Orca no longer lists the project; if Orca still lists it,
@@ -553,6 +549,8 @@ What it looks for:
553
549
  `<bot>.<session>` every fleet's sessions had before 0.10.0, which a session of
554
550
  another fleet can write to. Its next start through the kit gives it one of its
555
551
  own, so the finding names the `obk restart … --session` that does it;
552
+ - **a process still running in a retired session's work dir**: one from before
553
+ retire stopped them, or one it could not stop, with its pid and command;
556
554
  - **leftovers no book owns**: an Orca project inside your bots folder with no
557
555
  bot in it, a tab in a bot's project that the book does not name, a start
558
556
  prompt written for a session that has gone, a clone of a skills source you no
@@ -576,7 +574,7 @@ verdict: which of these matter, and in what order, is for you or for Bot Father.
576
574
  ## Sessions and bots that talk
577
575
 
578
576
  Ask the kit how to reach a session, and it answers with the road and the
579
- address ([ADR 0035](docs/adr/0035-messaging-transport.md)):
577
+ address ([ADR 0042](docs/adr/0042-messaging-transport.md)):
580
578
 
581
579
  ```sh
582
580
  obk message to --bots /path/to/my-bots --to api-bot/daily
@@ -619,17 +617,17 @@ mailbox where it was and says so, and the harness starts all the same. A check
619
617
  holds its turn for 40 seconds at most: with more mail than it reads in that
620
618
  time, it shows what it read and says the rest is still waiting.
621
619
 
622
- Nothing in a mailbox wakes anybody, so each message gets one signal that tells
623
- the receiver to look. Orca's own notice comes first: Orca types it into an idle
624
- tab. The kit watches an idle receiver for up to 8 seconds, and reads the
625
- receiver's own record of its turns. When Orca's notice is there, the kit types
626
- nothing. When no turn started, the kit types its own line. A Claude session busy
627
- with a turn gets nothing typed: its hook tells it at the end of the turn. A busy
628
- Codex session gets the kit's line, which Codex takes into the turn it is having.
629
- The send says which signal went. A session that is not up is not typed into at
630
- all; its message waits. When a session is retired, the kit says how many
631
- messages sent to it were not read with `obk message check`, and who sent them
632
- ([ADR 0035](docs/adr/0035-messaging-transport.md)).
620
+ Nothing in a mailbox wakes anybody, so each message gets one signal: Orca's
621
+ own notice, which Orca types into the receiver's tab once it is idle. A busy
622
+ receiver gets it a minute or a few after its turn ends. The kit types nothing
623
+ into the receiver's tab, and the send says which road it took. For urgent mail,
624
+ `obk message send --interrupt` presses Escape once in a busy receiver's tab to
625
+ end its turn, and then sends the message, so the notice comes at once. Codex
626
+ keeps a command it was running as a background terminal after an Escape. A
627
+ receiver reads its mail with `obk message check`, which marks it read: a bare
628
+ `orca orchestration check` leaves it unread, and Orca then gives no more
629
+ notices for that mailbox
630
+ ([ADR 0042](docs/adr/0042-messaging-transport.md)).
633
631
 
634
632
  A message longer than 4 KiB is written to a file beside your bots folder and
635
633
  named in the message, so a long one arrives whole without landing a document in
@@ -676,6 +674,8 @@ except the mailbox Runs Orca gives no way to delete, which the run names; it
676
674
  never closes a tab it did not open. The trust its harnesses write into your
677
675
  `~/.codex/config.toml` and `~/.claude.json` for the run's own throwaway folders
678
676
  is taken out again after the run, and named; nothing else in them is changed.
677
+ An Orca project the run leaves in one of its own throwaway folders is named,
678
+ with its setup id, and fails the run; the command removes none itself.
679
679
 
680
680
  They run on the machine the command is typed on, so the command will not start
681
681
  them by itself. On its own it says what it is about to drive — whose machine,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/rules/mail.md CHANGED
@@ -12,12 +12,12 @@ If your harness refuses a message, do not send it again by another road. Tell
12
12
  your maker or your user, with the harness's reason. A refusal can mean a missing
13
13
  permission rule, which they can add through the kit after the user's yes.
14
14
 
15
- Mail is queued, not an interruption. Read it when you finish what you are on.
15
+ Mail is queued, not an interruption. When Orca says you have mail, read it
16
+ with `"${OBK_CLI:-obk}" message check --bots <bots>`, never a bare `orca
17
+ orchestration check`: that leaves it unread, and Orca stops telling you of mail.
16
18
 
17
19
  Ask for a reply when you need one, and send one when you were asked for one.
18
20
 
19
- A message sent is queued, not read or acted on: the reply, or the work done,
20
- is what shows it arrived. Look for one before you send it again.
21
+ A sent message is only queued. Look for the reply or the work before you resend.
21
22
 
22
- Write to the session that needs it, not to several in the hope that one of
23
- them is right.
23
+ Write only to the session that needs it, not to several at once.
@@ -213,8 +213,11 @@ its commits. The kit writes this default set itself, with nobody asked:
213
213
  does not have yet to its `bot.yaml` and its settings, or for Codex its
214
214
  `.codex/rules/obk.rules`, and say which rules they wrote. The set leaves out
215
215
  the commands that keep the user's yes: `obk permission`, `obk retire`, `obk
216
- pause` and `obk init`. Never write a permission rule into a settings or rules
217
- file by hand.
216
+ pause`, `obk restart`, `obk bot create` and `obk init`. A bot that still holds
217
+ the `obk restart` or `obk bot create` rule from kit 0.26.0 keeps it, and `obk
218
+ rules build` and `obk up` name it with the `obk permission disallow` command
219
+ that takes it back; run that command only after the user's yes. Never write a
220
+ permission rule into a settings or rules file by hand.
218
221
 
219
222
  The charter decides what else the bot needs. What it lets the bot do without
220
223
  asking, such as merging pull requests, auto mode or Codex's sandbox can still
package/src/book.js CHANGED
@@ -6,12 +6,14 @@
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, realpathSync, renameSync, writeFileSync } from 'node:fs';
9
+ import { closeSync, existsSync, mkdirSync, openSync, 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';
13
13
  import { parse, stringify } from 'yaml';
14
14
 
15
+ import { kitFolders } from './launch.js';
16
+
15
17
  const HEADER = `# What Orca calls this bot on this machine, and where each of its sessions
16
18
  # lives. \`obk up\` writes this file; it is committed with the rest of the repo.
17
19
  #
@@ -151,7 +153,7 @@ function takeLock(home) {
151
153
  try {
152
154
  return lockOn(lockFile(home), WAIT_MS);
153
155
  } catch (error) {
154
- throw waitedTooLong(home, error);
156
+ throw error instanceof LockNotWritable ? error : waitedTooLong(home, error);
155
157
  }
156
158
  }
157
159
 
@@ -162,7 +164,21 @@ function takeLock(home) {
162
164
  * `EXCLUSIVE`, alone, once every `SHARED` holder has let go.
163
165
  */
164
166
  function lockOn(file, waitMs, how = 'IMMEDIATE') {
165
- mkdirSync(path.dirname(file), { recursive: true });
167
+ // SQLite opens a file it cannot write read-only, without a word, and a
168
+ // read-only connection's BEGIN passes while another process holds the lock:
169
+ // seen in Codex's sandbox (#534). So the file is opened for writing first,
170
+ // and a lock that cannot be held is an error, never a turn that is not one.
171
+ // Only while this process holds no turn on it: closing any descriptor of a
172
+ // file lets go of every lock the process holds on that file, and SQLite's
173
+ // own are among them. A file a turn is held on was written already.
174
+ if (!heldHere.has(file)) {
175
+ try {
176
+ mkdirSync(path.dirname(file), { recursive: true });
177
+ closeSync(openSync(file, 'a'));
178
+ } catch (error) {
179
+ throw new LockNotWritable(file, error);
180
+ }
181
+ }
166
182
 
167
183
  const db = new DatabaseSync(file);
168
184
  try {
@@ -181,17 +197,24 @@ function lockOn(file, waitMs, how = 'IMMEDIATE') {
181
197
  throw error;
182
198
  }
183
199
 
200
+ heldHere.set(file, (heldHere.get(file) ?? 0) + 1);
184
201
  return {
185
202
  release() {
186
203
  try {
187
204
  db.exec('COMMIT');
188
205
  } finally {
189
206
  db.close();
207
+ const left = heldHere.get(file) - 1;
208
+ if (left > 0) heldHere.set(file, left);
209
+ else heldHere.delete(file);
190
210
  }
191
211
  },
192
212
  };
193
213
  }
194
214
 
215
+ /** How many turns this process holds on each lock file, by its path. */
216
+ const heldHere = new Map();
217
+
195
218
  /**
196
219
  * Where a writer takes its turn: a folder of the kit's own beside the bots repo,
197
220
  * carrying that folder's own name, the way start-prompt files sit beside it
@@ -213,14 +236,31 @@ function lockOn(file, waitMs, how = 'IMMEDIATE') {
213
236
  * the bots folder through a link and another by its real path, and two paths
214
237
  * would be two locks for one book (#375).
215
238
  */
216
- const lockFile = (home) => {
239
+ const lockFile = (home) => path.join(botLocks(home), `${encodeURIComponent(path.basename(realpathSync(home)))}.lock`);
240
+
241
+ /**
242
+ * The bot's own folder of locks, `<bots>.locks/<bot>/`: the one a Codex
243
+ * session of the bot is given to write, beside its mail and its start prompts
244
+ * (#534). One folder per bot, so a bot's sessions can take each other's turns
245
+ * and no other bot's.
246
+ */
247
+ const botLocks = (home) => {
217
248
  const real = realpathSync(home);
218
- return path.join(
219
- `${path.dirname(path.dirname(real))}.locks`,
220
- `${encodeURIComponent(path.basename(real))}.lock`,
221
- );
249
+ return kitFolders(path.dirname(path.dirname(real)), path.basename(real)).locks;
222
250
  };
223
251
 
252
+ /**
253
+ * A lock the kit cannot write. Inside Codex's sandbox that is a session
254
+ * started before the kit gave its bot a folder of locks, so it says what puts
255
+ * it right.
256
+ */
257
+ class LockNotWritable extends Error {
258
+ constructor(file, why) {
259
+ super(`the kit cannot write its lock ${file} (${why.code ?? why.message}), so it did not take the turn, and nothing was done. A Codex session started before this version of the kit cannot write its bot's folder of locks: restart this session with the kit. Elsewhere, make ${path.dirname(file)} writable.`);
260
+ this.code = why.code;
261
+ }
262
+ }
263
+
224
264
  /**
225
265
  * One session's turn at its mailbox (#321): held by `session mailbox` for its
226
266
  * whole step, by `message check` in the session's own tab for its bind, read and
@@ -286,11 +326,11 @@ export function takeTypingTurn(home, session, waitMs) {
286
326
 
287
327
  /**
288
328
  * A session's turn for one whole line, sent with its return in one go, as the
289
- * mail nudge is: through the typing turn's gate, waited for `waitMs` at most,
329
+ * mail interrupt is: through the typing turn's gate, waited for `waitMs` at most,
290
330
  * and then held beside any other line, so whole lines never wait on each
291
331
  * other, only on typing one key at a time. Returns `{ release }`, or undefined
292
332
  * when the gate did not come within `waitMs`. Every whole line the kit types
293
- * takes it: the nudge, the list line, the skills reload and the grooming line (#482).
333
+ * takes it: the mail interrupt, the list line, the skills reload and the grooming line (#482).
294
334
  */
295
335
  export function takeLineTurn(home, session, waitMs) {
296
336
  const gate = turnOn(home, session, 'typing', waitMs);
@@ -312,8 +352,9 @@ export const TYPING_WAIT_MS = 5000;
312
352
  export const TYPING_HELD = `the kit is typing into it, and it was still at it after ${TYPING_WAIT_MS / 1000} s, so nothing was typed`;
313
353
 
314
354
  /**
315
- * How long a typing turn waits for the lines already on their way: a nudge
316
- * holds its line's turn while Orca waits up to 5 s to see it start a turn.
355
+ * How long a typing turn waits for the lines already on their way: a line
356
+ * re-issued to Orca holds its turn while Orca waits up to 5 s to see it start
357
+ * a turn.
317
358
  */
318
359
  const LINES_WAIT_MS = 10_000;
319
360
 
@@ -344,10 +385,8 @@ const SQLITE_BUSY = 5;
344
385
  * link by one command and by its real path by another, and two paths would be
345
386
  * two turns.
346
387
  */
347
- const mailboxLockFile = (home, session) => {
348
- const real = realpathSync(home);
349
- return path.join(`${path.dirname(path.dirname(real))}.locks`, `${path.basename(real)}.${session}.mailbox.lock`);
350
- };
388
+ const mailboxLockFile = (home, session) =>
389
+ path.join(botLocks(home), `${path.basename(realpathSync(home))}.${session}.mailbox.lock`);
351
390
 
352
391
  /**
353
392
  * Something else has been writing the book for longer than this run is prepared
package/src/bot.js CHANGED
@@ -162,7 +162,7 @@ export function readBot(home, name = path.basename(home)) {
162
162
  // The roles its temporary sessions can be made in, judged by tempRoles.
163
163
  ...(bot.temp_roles === undefined ? {} : { temp_roles: bot.temp_roles }),
164
164
  // The widest approval its temporary sessions may be made at, the user's yes
165
- // kept by `permission approval --temps` (ADR 0037).
165
+ // kept by `permission approval --temps` (ADR 0041).
166
166
  ...(bot.temp_approval === undefined ? {} : { temp_approval: bot.temp_approval }),
167
167
  // A paused bot is one `obk up` leaves closed; its sessions keep their book.
168
168
  ...(bot.paused === true ? { paused: true } : {}),
@@ -362,7 +362,7 @@ export function changeSession(bots, bot, name, settings) {
362
362
 
363
363
  /**
364
364
  * Record the widest approval the bot's temporary sessions may be made at,
365
- * `level`, as `temp_approval` (ADR 0037). Returns { bot, home, level }.
365
+ * `level`, as `temp_approval` (ADR 0041). Returns { bot, home, level }.
366
366
  */
367
367
  export function setTempApproval(bots, bot, level) {
368
368
  editBot(bots, bot, `let ${bot}'s temporary sessions be made at ${level}`, (doc) => doc.set('temp_approval', level), (was) => ({ ...was, temp_approval: level }));