@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 +8 -0
- package/SETUP.md +16 -15
- package/package.json +1 -1
- package/rules/limits.md +3 -3
- package/skills/obk-bot-building/SKILL.md +53 -23
- package/src/book.js +60 -4
- package/src/cli.js +66 -19
- package/src/message.js +83 -11
- package/src/orca.js +6 -6
- package/src/permissions.js +336 -15
- package/src/up.js +50 -16
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`) |
|
|
198
|
-
| Codex's `Hooks need review` |
|
|
199
|
-
| Codex's `/new`: `Where should the new conversation run?` |
|
|
200
|
-
| Codex's update offer, `1. Update now` |
|
|
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?` | `
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
|
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
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
|
|
21
|
-
the user's yes to the exact rules: `obk bot change --allow <rule>` records
|
|
22
|
-
writes them. Never edit them into a
|
|
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
|
|
205
|
-
stop it for what the kit's own rules tell it to do: its
|
|
206
|
-
reading a long message kept beside the bots folder, and
|
|
207
|
-
create`, `obk up` and `obk rules build` list the rules
|
|
208
|
-
and give one command that allows them. Show the user
|
|
209
|
-
and say in a line what each lets the bot do. Run the
|
|
210
|
-
yes: it records the yes in the bot's `bot.yaml` and
|
|
211
|
-
|
|
212
|
-
rules
|
|
213
|
-
|
|
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
|
|
262
|
-
| Codex's `Trust this folder
|
|
263
|
-
| Codex's `Hooks need review` |
|
|
264
|
-
| Codex's
|
|
265
|
-
| Codex's update offer, `1. Update now` |
|
|
266
|
-
| `[oh-my-zsh] Would you like to update?` | `n` | no: the
|
|
267
|
-
| Claude Code's `Teach auto mode
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
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
|
-
|
|
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 = ${
|
|
159
|
+
db.exec(`PRAGMA busy_timeout = ${waitMs}`);
|
|
152
160
|
db.exec('BEGIN IMMEDIATE');
|
|
153
161
|
} catch (error) {
|
|
154
162
|
db.close();
|
|
155
|
-
throw
|
|
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 =
|
|
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)
|
|
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 =
|
|
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
|
-
...
|
|
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
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
if (peek || !found.deliveryId ||
|
|
243
|
-
|
|
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
|
-
|
|
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]);
|
package/src/permissions.js
CHANGED
|
@@ -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
|
-
/**
|
|
62
|
-
export
|
|
63
|
-
|
|
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)
|
|
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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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';
|