@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 +30 -30
- package/package.json +1 -1
- package/rules/mail.md +5 -5
- package/skills/obk-bot-building/SKILL.md +5 -2
- package/src/book.js +55 -16
- package/src/bot.js +2 -2
- package/src/cli.js +53 -110
- package/src/commands.js +17 -9
- package/src/conversations.js +0 -37
- package/src/health.js +63 -6
- package/src/hooks.js +35 -63
- package/src/launch.js +21 -4
- package/src/message.js +42 -334
- package/src/name.js +1 -1
- package/src/orca.js +19 -35
- package/src/permissions.js +22 -6
- package/src/processes.js +392 -0
- package/src/retire.js +43 -40
- package/src/skills.js +1 -1
- package/src/temp.js +1 -1
- package/src/up.js +13 -2
- package/src/unread.js +0 -160
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
|
|
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`
|
|
151
|
-
and
|
|
152
|
-
|
|
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`.
|
|
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
|
|
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
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
receiver's
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
([ADR
|
|
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
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.
|
|
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
|
|
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
|
|
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`
|
|
217
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
316
|
-
* holds its
|
|
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
|
-
|
|
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
|
|
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
|
|
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 }));
|