@assuredloop/orca-bot-kit 0.25.3 → 0.25.6
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 -7
- package/SETUP.md +21 -1
- package/package.json +4 -3
- package/rules/mail.md +4 -0
- package/skills/obk-bot-building/SKILL.md +13 -0
- package/src/book.js +3 -0
- package/src/clear.js +25 -19
- package/src/cli.js +45 -3
- package/src/health.js +16 -1
- package/src/hook-trust.js +179 -0
- package/src/hooks.js +11 -0
- package/src/name.js +4 -3
- package/src/orca.js +31 -1
- package/src/temp.js +134 -29
- package/src/up.js +36 -12
package/README.md
CHANGED
|
@@ -643,13 +643,14 @@ npm run mutate # the mutation audit, on what your branch changed
|
|
|
643
643
|
|
|
644
644
|
The tests come in two layers, and every test file belongs to one of them.
|
|
645
645
|
|
|
646
|
-
`npm test` is `test/*.test.js`: unit tests and
|
|
647
|
-
throwaway folder, with a fake `orca` on PATH. It
|
|
648
|
-
[GitHub Actions](.github/workflows/ci.yml) runs it on
|
|
649
|
-
into shards that run side by side, on Node 25.8.0, the
|
|
650
|
-
runs every shard on 25.8.0 and again on 24.21.0, the
|
|
651
|
-
promises users, with only the kit's own dependencies
|
|
652
|
-
is published.
|
|
646
|
+
`npm test` is `test/*.test.js` and `test/dev/*.test.js`: unit tests and
|
|
647
|
+
end-to-end runs of the CLI in a throwaway folder, with a fake `orca` on PATH. It
|
|
648
|
+
needs nothing but Node, so [GitHub Actions](.github/workflows/ci.yml) runs it on
|
|
649
|
+
every pull request, split into shards that run side by side, on Node 25.8.0, the
|
|
650
|
+
current line. A release runs every shard on 25.8.0 and again on 24.21.0, the
|
|
651
|
+
floor `engines.node` promises users, with only the kit's own dependencies
|
|
652
|
+
installed, before anything is published. The floor leaves out `test/dev/`, the
|
|
653
|
+
tests that need a development tool.
|
|
653
654
|
|
|
654
655
|
`npm run test:system` is `test/system/*.test.js`: the real `obk` against the
|
|
655
656
|
real Orca and the real harnesses on your own machine. No CI runner can do that,
|
package/SETUP.md
CHANGED
|
@@ -201,7 +201,7 @@ waiting for:
|
|
|
201
201
|
| Claude Code's folder trust list | `\x1b[B\r` | down, then return: its selection starts on **No, exit** | Claude Code 2.1.283 |
|
|
202
202
|
| Codex's directory trust, `1. Trust and continue` (older: `1. Yes, continue`) | `\r` | return: its selection starts on **Trust and continue** | Codex 0.160.0 |
|
|
203
203
|
| 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.160.0 |
|
|
204
|
-
| Codex's `/new`: `Where should the new conversation run?` | `\r` | return: its selection starts on **Current checkout** (bot home) | Codex 0.160.0 |
|
|
204
|
+
| Codex's `/new`: `Where should the new conversation run?` | `\r` | return: its selection starts on **Current checkout** (bot home); on 0.162.0 it is called **1. Use current Git worktree** (Keep using the current working directory). Never **Create new Git worktree** | Codex 0.160.0; Codex 0.162.0 (#516) |
|
|
205
205
|
| Codex's update offer, `1. Update now` | `\r` | return: its selection starts on **Update now**, which accepts it | Codex 0.156.1; seen on 0.157.1 (offering 0.158.0) with the selection on **Update now**, not pressed; 0.158.0 offered 0.160.0, taken on 2026-10-02 by Bot Father's restarts |
|
|
206
206
|
| `[oh-my-zsh] Would you like to update?` | `n` | they update their own shell | oh-my-zsh bf77e35 |
|
|
207
207
|
| Claude Code's `Teach auto mode about your environment?` | `\x1b` | Esc, which cancels it (**Not now**). On 2.1.283 it is a form, not a list: `How you use Claude here`, `Also scan shell history`, `Also scan your other repos`, `Continue`, and `Enter to continue · Esc to cancel`. **Never send a return**: Enter is **Continue**, which starts a scan of the project, recent Claude sessions and, by default, the machine's shell history | Not yet proven: Esc cancels in Claude Code 2.1.283's code, not yet seen live (#370); the form was seen live twice on 2.1.283, in #261's live test and #239's live run 1 (#416); 2.1.278 showed a numbered list, answered with 2. Not now |
|
|
@@ -248,6 +248,26 @@ Bash(<kit> temp answer:*)
|
|
|
248
248
|
Ask the user, and only after their yes run
|
|
249
249
|
`obk bot change --bots <their path> --bot <bot> --allow 'Bash(<kit> temp answer:*)'`.
|
|
250
250
|
|
|
251
|
+
A long-lived session's two screens go through the kit as well, not through the
|
|
252
|
+
keys. On Codex's hooks review, run
|
|
253
|
+
`obk session trust-hooks --bots <their path> --bot <bot> --session <session>`.
|
|
254
|
+
On Claude Code's `Teach auto mode` screen, run
|
|
255
|
+
`obk session answer --bots <their path> --bot <bot> --session <session>`.
|
|
256
|
+
A long-lived session is Bot Father's, so run them from outside the fleet's tabs,
|
|
257
|
+
as you do here, or from a Bot Father session. A session of any other bot is
|
|
258
|
+
refused. They make the same checks as the temp commands. Before either
|
|
259
|
+
trust-hooks command trusts hooks, the kit checks that the hooks on the review
|
|
260
|
+
are its own: the bot's `.codex/hooks.json` holds only the kit's hooks, and the
|
|
261
|
+
count on the screen is the number of the kit's hooks that Codex does not trust
|
|
262
|
+
yet. When it refuses, it says what it saw: type nothing, and tell them what it
|
|
263
|
+
said. Use the keys in the table for the screens the kit does not answer.
|
|
264
|
+
The kit reads Codex's trust from the caller's `CODEX_HOME`, else `~/.codex`;
|
|
265
|
+
a bot run under another `CODEX_HOME` is outside what the kit can see.
|
|
266
|
+
The kit also records the extra arguments it launched each session with, and
|
|
267
|
+
refuses when they mention hooks, or when there is no record. A Codex that
|
|
268
|
+
someone started again by hand in that tab, with other flags, is outside what
|
|
269
|
+
the kit records.
|
|
270
|
+
|
|
251
271
|
**Anything you do not recognise: type nothing.** Tell them what is on the
|
|
252
272
|
screen and which tab it is in, and wait. A keypress into a menu you have not read
|
|
253
273
|
is how a harness quits back to the shell.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assuredloop/orca-bot-kit",
|
|
3
|
-
"version": "0.25.
|
|
3
|
+
"version": "0.25.6",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
"SETUP.md"
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
|
-
"test": "node --test test/*.test.js",
|
|
27
|
-
"test:shard": "node --test --test-shard=$SHARD test/*.test.js",
|
|
26
|
+
"test": "node --test test/*.test.js test/dev/*.test.js",
|
|
27
|
+
"test:shard": "node --test --test-shard=$SHARD test/*.test.js test/dev/*.test.js",
|
|
28
|
+
"test:floor": "node --test --test-shard=$SHARD test/*.test.js",
|
|
28
29
|
"test:system": "node scripts/test-system.js",
|
|
29
30
|
"mutate": "node scripts/mutate.js"
|
|
30
31
|
},
|
package/rules/mail.md
CHANGED
|
@@ -8,6 +8,10 @@ To reach another session, ask the kit for the road rather than guessing an
|
|
|
8
8
|
address: `"${OBK_CLI:-obk}" message to --bots <bots> --to <bot>/<session>`. It
|
|
9
9
|
answers with the address and the command that carries it.
|
|
10
10
|
|
|
11
|
+
If your harness refuses a message, do not send it again by another road. Tell
|
|
12
|
+
your maker or your user, with the harness's reason. A refusal can mean a missing
|
|
13
|
+
permission rule, which they can add through the kit after the user's yes.
|
|
14
|
+
|
|
11
15
|
Mail is queued, not an interruption. Read it when you finish what you are on.
|
|
12
16
|
|
|
13
17
|
Ask for a reply when you need one, and send one when you were asked for one.
|
|
@@ -335,6 +335,19 @@ only after a second look), refuses any other screen, and checks that it went. It
|
|
|
335
335
|
rule is `Bash(<kit> temp answer:*)`, `<kit>` the kit's CLI path as the
|
|
336
336
|
command prints it; ask the user before `obk bot change --allow` adds it.
|
|
337
337
|
|
|
338
|
+
A long-lived session's two screens go through the kit too: Codex's hooks
|
|
339
|
+
review with `obk session trust-hooks --bots <bots> --bot <bot> --session
|
|
340
|
+
<session>`, and Claude Code's `Teach auto mode` screen with `obk session
|
|
341
|
+
answer --bots <bots> --bot <bot> --session <session>`. A long-lived session is
|
|
342
|
+
Bot Father's: the caller is a Bot Father session or the user, from outside the
|
|
343
|
+
fleet's tabs, and a session of any other bot is refused. They make the same
|
|
344
|
+
checks as the temp commands. Before either trust-hooks command trusts hooks,
|
|
345
|
+
it checks that the hooks on the review are the kit's own: the bot's
|
|
346
|
+
`.codex/hooks.json` holds only the kit's, and the count on the screen is the
|
|
347
|
+
number of the kit's hooks Codex does not trust yet. A refusal says what it
|
|
348
|
+
saw; then type nothing and take it to the user. The keys in the table are for
|
|
349
|
+
the screens the kit does not answer.
|
|
350
|
+
|
|
338
351
|
Where the kit says no session came up, the shell swallowed the launch line,
|
|
339
352
|
usually while it was asking its own question. Answer the shell, then close
|
|
340
353
|
that one tab (`<orca> terminal close --terminal <handle> --tab`) and run
|
package/src/book.js
CHANGED
|
@@ -28,6 +28,9 @@ const HEADER = `# What Orca calls this bot on this machine, and where each of it
|
|
|
28
28
|
# \`rules\` is a stamp of the bot's AGENTS.md as the session last read it, so a
|
|
29
29
|
# change to it after the session started can be told apart.
|
|
30
30
|
#
|
|
31
|
+
# \`launched_with\` is the extra arguments the kit typed on the session's last
|
|
32
|
+
# launch line, as bot.yaml's extra_args gave them then.
|
|
33
|
+
#
|
|
31
34
|
# \`retired\` holds what the book knew about each session \`obk retire\` took off
|
|
32
35
|
# the bot, with when, so the conversations it had are still accounted for.
|
|
33
36
|
#
|
package/src/clear.js
CHANGED
|
@@ -38,9 +38,13 @@ const POINTER = { claude: '❯', codex: '›' };
|
|
|
38
38
|
*/
|
|
39
39
|
const RECORD_LINE = 'obk: this conversation was started by obk session clear, and this line is only so the kit can record it. Reply "ok"; nothing else is asked.';
|
|
40
40
|
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* What Codex asks after `/new`, and the one answer the kit gives it: the bot
|
|
43
|
+
* home it runs in. Codex 0.162.0 calls it by another name, and its whole row
|
|
44
|
+
* has to be as captured, but for the padding before its description (#516).
|
|
45
|
+
*/
|
|
42
46
|
const WHERE_TO_RUN = 'Where should the new conversation run?';
|
|
43
|
-
const CURRENT_CHECKOUT = /^ *› +1\. Current checkout\b
|
|
47
|
+
const CURRENT_CHECKOUT = /^ *› +1\. Current checkout\b|^ *› 1\. Use current Git worktree\s+Keep using the current working directory *$/;
|
|
44
48
|
|
|
45
49
|
/** Codex's empty input line: its pointer alone, or with its placeholder. */
|
|
46
50
|
const CODEX_EMPTY = ['›', '› Ask Codex to do anything'];
|
|
@@ -51,7 +55,8 @@ const CODEX_EMPTY = ['›', '› Ask Codex to do anything'];
|
|
|
51
55
|
* selected row stands for what Return will run: with the menu open, Return
|
|
52
56
|
* runs the selected command, and the menu is narrowed to what was typed
|
|
53
57
|
* (codex-rs/tui/src/bottom_pane/chat_composer.rs, rust-v0.160.0). The
|
|
54
|
-
* architect's ruling on #391, after live run 4.
|
|
58
|
+
* architect's ruling on #391, after live run 4. Only where Orca gives no
|
|
59
|
+
* draft: a draft is the line's text, and has to be the command (#516).
|
|
55
60
|
*/
|
|
56
61
|
const CODEX_BARE_LINE = '0.160.0';
|
|
57
62
|
|
|
@@ -221,14 +226,13 @@ async function enter(it, verb, before = () => {}) {
|
|
|
221
226
|
* when it was typed.
|
|
222
227
|
*/
|
|
223
228
|
export async function typeCommand(it, command, { before = () => {}, check = menuWrong, cannot = () => '', deadline = Infinity } = {}) {
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
229
|
+
// Nothing may be in the input line before: no draft for the command to join.
|
|
230
|
+
// Orca gives a draft in the line beside the screen, and the screen's line
|
|
231
|
+
// then reads the pointer alone (#510, #516). Where it gives none, Codex's
|
|
232
|
+
// line on the screen has to be empty.
|
|
227
233
|
const emptyLine = ({ rows, draft }) => {
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
234
|
+
if (draft !== undefined) throw new Error(`${it.name}: nothing was typed, because its input line holds a draft: it reads "${draft}".${shownEnd(rows)}`);
|
|
235
|
+
if (it.harness !== 'codex') return;
|
|
232
236
|
const line = rows.findLast((row) => /^ *›/.test(row))?.trim();
|
|
233
237
|
if (!CODEX_EMPTY.includes(line)) {
|
|
234
238
|
throw new Error(`${it.name}: nothing was typed, because its input line is not empty: it reads "${line ?? 'nothing'}".${shownEnd(rows)}`);
|
|
@@ -415,10 +419,11 @@ function menuWrong(rows, harness, command, version, draft) {
|
|
|
415
419
|
if (at < 0) return { why: 'its screen shows no input line' };
|
|
416
420
|
// Claude Code 2.1.288 puts a non-breaking space after its pointer (live run 4).
|
|
417
421
|
const line = rows[at].replaceAll('\u00a0', ' ').trim();
|
|
418
|
-
|
|
419
|
-
//
|
|
420
|
-
//
|
|
422
|
+
// Orca 1.4.223 gives the line's text as the draft, and the line on the
|
|
423
|
+
// screen then reads the pointer alone: Claude Code 2.1.296's (#510, probe 4)
|
|
424
|
+
// and Codex 0.162.0's (#516).
|
|
421
425
|
const text = draft === undefined ? line : `${POINTER[harness]} ${draft}`;
|
|
426
|
+
if (harness === 'codex') return codexMenuWrong(rows, at, text, command, version);
|
|
422
427
|
if (text !== `${POINTER[harness]} ${command}`) return { why: `its input line reads "${text}"` };
|
|
423
428
|
return claudeMenuWrong(rows, at, command);
|
|
424
429
|
}
|
|
@@ -445,11 +450,12 @@ function claudeMenuWrong(rows, at, command) {
|
|
|
445
450
|
|
|
446
451
|
/**
|
|
447
452
|
* Codex draws its slash menu above the input line: a row per command, the
|
|
448
|
-
* selected one starting with its pointer, then a blank row
|
|
449
|
-
* line (its own snapshot tests, chat_composer
|
|
450
|
-
* rust-v0.160.0; live run 4). The menu has
|
|
451
|
-
* selected one, naming the command. The input
|
|
452
|
-
*
|
|
453
|
+
* selected one starting with its pointer, then a blank row on 0.160.0 and none
|
|
454
|
+
* on 0.162.0, then the input line (its own snapshot tests, chat_composer
|
|
455
|
+
* slash_popup_*.snap, rust-v0.160.0; live run 4; #516's probe). The menu has
|
|
456
|
+
* to hold one command row, the selected one, naming the command. The input
|
|
457
|
+
* line, Orca's draft where it gives one, reads the command, or, with no draft
|
|
458
|
+
* on the Codex whose line Orca shows bare, its pointer alone.
|
|
453
459
|
*/
|
|
454
460
|
function codexMenuWrong(rows, at, line, command, version) {
|
|
455
461
|
const bare = line === '›' && version === CODEX_BARE_LINE;
|
|
@@ -494,7 +500,7 @@ async function answerWhereToRun(it, handle, command) {
|
|
|
494
500
|
const pointer = rows.findLast((row) => /^ *›/.test(row));
|
|
495
501
|
if (pointer === undefined || !CURRENT_CHECKOUT.test(pointer)) {
|
|
496
502
|
send(handle, '\x1b');
|
|
497
|
-
throw new Error(`${it.name}: after ${command}, Codex asked "${WHERE_TO_RUN}" with its selection on "${pointer?.trim() ?? 'nothing'}" rather than "1. Current checkout", so it was backed out of with Esc and nothing else was typed. Look at its tab.`);
|
|
503
|
+
throw new Error(`${it.name}: after ${command}, Codex asked "${WHERE_TO_RUN}" with its selection on "${pointer?.trim() ?? 'nothing'}" rather than "1. Current checkout" (on Codex 0.162.0, "1. Use current Git worktree"), so it was backed out of with Esc and nothing else was typed. Look at its tab.`);
|
|
498
504
|
}
|
|
499
505
|
send(handle, '\r');
|
|
500
506
|
return;
|
package/src/cli.js
CHANGED
|
@@ -31,7 +31,7 @@ import { readRoster } from './roster.js';
|
|
|
31
31
|
import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
|
|
32
32
|
import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
|
|
33
33
|
import { addSource, fetchSources } from './sources.js';
|
|
34
|
-
import { answerTemp, listRoles, makeTemp, optionsLine, retireTemp, trustHooks } from './temp.js';
|
|
34
|
+
import { answerSession, answerTemp, listRoles, makeTemp, optionsLine, retireTemp, trustHooks, trustSessionHooks } from './temp.js';
|
|
35
35
|
import { readUsage } from './usage.js';
|
|
36
36
|
import { BOT_FATHER, bringUp, ownMailbox } from './up.js';
|
|
37
37
|
|
|
@@ -139,7 +139,8 @@ Usage:
|
|
|
139
139
|
of a Codex temporary session it made with "Trust
|
|
140
140
|
all and continue", and check the review went. It
|
|
141
141
|
refuses anything else on that screen, and types
|
|
142
|
-
nothing then.
|
|
142
|
+
nothing then. It refuses, as obk session
|
|
143
|
+
trust-hooks does, hooks that are not the kit's.
|
|
143
144
|
obk temp answer --bots <path> --name <session>
|
|
144
145
|
Run in the maker's own tab: answer Claude Code's
|
|
145
146
|
"Teach auto mode" screen on a temporary session it
|
|
@@ -266,6 +267,19 @@ Usage:
|
|
|
266
267
|
next starts where it stopped, so no call is counted
|
|
267
268
|
twice. It counts tokens and never money: what a
|
|
268
269
|
token costs is looked up live by whoever is asking.
|
|
270
|
+
obk session trust-hooks --bots <path> --bot <bot> --session <name>
|
|
271
|
+
Run by Bot Father or the user: answer the hooks
|
|
272
|
+
review of a long-lived Codex session with "Trust
|
|
273
|
+
all and continue", as obk temp trust-hooks does,
|
|
274
|
+
and check the review went. It refuses when the
|
|
275
|
+
bot's .codex/hooks.json holds a hook that is not
|
|
276
|
+
the kit's, or when the review counts other hooks
|
|
277
|
+
than the kit's own that Codex does not trust yet.
|
|
278
|
+
obk session answer --bots <path> --bot <bot> --session <name>
|
|
279
|
+
Run by Bot Father or the user: answer Claude Code's
|
|
280
|
+
"Teach auto mode" screen on a long-lived session
|
|
281
|
+
with Not now, as obk temp answer does, and check
|
|
282
|
+
the screen went.
|
|
269
283
|
obk session record --bots <path> --bot <bot>
|
|
270
284
|
For the kit's own hook, not for typing: it reads
|
|
271
285
|
what the harness says about a session starting on
|
|
@@ -337,6 +351,8 @@ const COMMANDS = {
|
|
|
337
351
|
'session mail': ['bots', 'bot'],
|
|
338
352
|
'session name': ['bots', 'bot'],
|
|
339
353
|
'session mailbox': ['bots', 'bot', 'session'],
|
|
354
|
+
'session trust-hooks': ['bots', 'bot', 'session'],
|
|
355
|
+
'session answer': ['bots', 'bot', 'session'],
|
|
340
356
|
'temp make': ['bots', 'name'],
|
|
341
357
|
'temp roles': ['bots'],
|
|
342
358
|
'temp retire': ['bots', 'name'],
|
|
@@ -845,6 +861,26 @@ const commands = {
|
|
|
845
861
|
};
|
|
846
862
|
},
|
|
847
863
|
|
|
864
|
+
async 'session trust-hooks'(bots, values) {
|
|
865
|
+
const tab = callerTab(bots, true);
|
|
866
|
+
refuseWhenOrcaIsDown();
|
|
867
|
+
const trusted = await trustSessionHooks(bots, { tab, bot: values.bot, session: values.session });
|
|
868
|
+
return {
|
|
869
|
+
answer: { bots, ...trusted },
|
|
870
|
+
lines: [`trusted ${trusted.bot} ${trusted.session}'s hooks: chose "Trust all and continue" on its hooks review, and the review has gone`],
|
|
871
|
+
};
|
|
872
|
+
},
|
|
873
|
+
|
|
874
|
+
async 'session answer'(bots, values) {
|
|
875
|
+
const tab = callerTab(bots, true);
|
|
876
|
+
refuseWhenOrcaIsDown();
|
|
877
|
+
const answered = await answerSession(bots, { tab, bot: values.bot, session: values.session });
|
|
878
|
+
return {
|
|
879
|
+
answer: { bots, ...answered },
|
|
880
|
+
lines: [`answered ${answered.bot} ${answered.session}: sent ${answered.sent} to its "Teach auto mode about your environment?" screen, and the screen has gone`],
|
|
881
|
+
};
|
|
882
|
+
},
|
|
883
|
+
|
|
848
884
|
'bot create'(bots, values) {
|
|
849
885
|
const made = createBot(bots, { name: values.name, harness: values.harness, charter: values.charter });
|
|
850
886
|
// The bot's AGENTS.md is the rules build's, here as everywhere else, so
|
|
@@ -1203,6 +1239,7 @@ const commands = {
|
|
|
1203
1239
|
const said = {
|
|
1204
1240
|
made: `${who} has its mailbox ${answer.mailbox}, made in this tab.`,
|
|
1205
1241
|
bound: `${who}'s mailbox ${answer.mailbox} is bound to this tab.`,
|
|
1242
|
+
replaced: `${who}'s mailbox ${answer.replaced} is not one this Orca has, as when the book was written on another machine, so it has a new one, ${answer.mailbox}, made in this tab and written into the book.`,
|
|
1206
1243
|
none: `${who} gets no mailbox: it is a Codex session with its sandbox switch off, which could not read one.`,
|
|
1207
1244
|
};
|
|
1208
1245
|
return { answer, lines: [said[answer.change]] };
|
|
@@ -1261,7 +1298,12 @@ const commands = {
|
|
|
1261
1298
|
function toLines(answer, bots, where) {
|
|
1262
1299
|
if (answer.trouble !== undefined) return [` ${answer.trouble}`];
|
|
1263
1300
|
if (answer.transport === 'native') {
|
|
1264
|
-
|
|
1301
|
+
// A refused call is not re-sent by another road (PRD 6.9), so the answer
|
|
1302
|
+
// says what to do instead, before the bot needs it (#521).
|
|
1303
|
+
return [
|
|
1304
|
+
` Write to ${answer.address} with your own harness's messaging. The kit does not carry that road.`,
|
|
1305
|
+
" If your harness refuses that call, do not send the message again by another road. Tell your maker or your user that it was refused, with the harness's reason. A refusal can mean a missing permission rule, which they can add through the kit after the user's yes.",
|
|
1306
|
+
];
|
|
1265
1307
|
}
|
|
1266
1308
|
return [
|
|
1267
1309
|
...(answer.unnamed === true
|
package/src/health.js
CHANGED
|
@@ -26,7 +26,7 @@ import { botDir, botNames, botsDir, readBot, tempRoles, unknownKeys } from './bo
|
|
|
26
26
|
import { transcriptsIn } from './conversations.js';
|
|
27
27
|
import { hookTrouble } from './hooks.js';
|
|
28
28
|
import { bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, sessionTrouble, SHELL_ENV, shellWord } from './launch.js';
|
|
29
|
-
import { frontOfTab, orcaDefaultArgs, projects, tabs, wordsOfProcess } from './orca.js';
|
|
29
|
+
import { frontOfTab, orcaDefaultArgs, projects, runMissing, tabs, wordsOfProcess } from './orca.js';
|
|
30
30
|
import { permissionsTrouble } from './permissions.js';
|
|
31
31
|
import { TAB_ENV } from './record.js';
|
|
32
32
|
import { agentsTrouble, rulesStamp } from './rules.js';
|
|
@@ -387,6 +387,21 @@ function inOrca(bots, home, bot, setups, sessions) {
|
|
|
387
387
|
found.push(finding('session', entry.tab, `${bot.name}'s session ${name} is in the book with tab ${entry.tab}, and Orca has no tab of that id: the tab was closed, or the machine was restarted. The conversation is in the book, and obk up opens a tab and brings it back.`, bot.name));
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
// A Run Orca does not have, as when the book was written on another
|
|
391
|
+
// machine: mail sent to it is refused. The session's next start makes it
|
|
392
|
+
// a new one (#508).
|
|
393
|
+
if (typeof entry?.mailbox === 'string' && runMissing(entry.mailbox)) {
|
|
394
|
+
// The bot's unpause leaves a session paused on its own as it is, so a
|
|
395
|
+
// session under both marks needs both commands.
|
|
396
|
+
const unpause = `${shellWord(ownCli())} unpause --bots ${shellWord(bots)} --bot ${bot.name}`;
|
|
397
|
+
const steps = [
|
|
398
|
+
...(bot.paused === true ? [unpause] : []),
|
|
399
|
+
...(bot.sessions.some((session) => session.name === name && session.paused === true) ? [`${unpause} --session ${name}`] : []),
|
|
400
|
+
];
|
|
401
|
+
const fix = steps.length > 0 ? steps.join(', then ') : `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${bot.name} --session ${name}`;
|
|
402
|
+
found.push(finding('session', bookFile(home), `${bot.name}'s session ${name} has the mailbox ${entry.mailbox} in the book, and Orca has no Run of that id: it was made by another Orca, as on another machine, so mail sent to it is refused. The session's next start makes it a new one: ${fix}`, bot.name));
|
|
403
|
+
}
|
|
404
|
+
|
|
390
405
|
const unclaimed = (Array.isArray(entry?.unclaimed) ? entry.unclaimed : []).filter((id) => !helpers.has(id));
|
|
391
406
|
if (unclaimed.length > 0) {
|
|
392
407
|
found.push(finding('leftover', bookFile(home), `The book notes conversations under ${bot.name}'s session ${name} that no session of this bot claims: ${unclaimed.join(', ')}. They ran in this bot's folder, and the kit will not say whose they were. To give one back, write it into ${bookFile(home)} under ${name} as session: <id> and run obk up again.`, bot.name));
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Which of a Codex bot's hooks Codex does not trust yet, so that the kit
|
|
2
|
+
// trusts only its own (#506).
|
|
3
|
+
//
|
|
4
|
+
// "Trust all and continue" on Codex's hooks review trusts every hook the
|
|
5
|
+
// review counts, from every source Codex reads: the user's own files, Orca's
|
|
6
|
+
// hooks among them, `-c` flags, plugins, and each `.codex/` folder from the git
|
|
7
|
+
// root down (tech notes, section 3). So the kit answers a review only when the
|
|
8
|
+
// bot's `.codex/hooks.json` holds the kit's hooks and nothing else, and the
|
|
9
|
+
// count on the screen is the number of those that Codex does not trust yet.
|
|
10
|
+
//
|
|
11
|
+
// Codex keeps its trust in the user's config.toml, one table a hook, keyed by
|
|
12
|
+
// the file, the event and the hook's place in it, with a hash of the hook. The
|
|
13
|
+
// kit reads only those tables' headers and their `trusted_hash` lines: the
|
|
14
|
+
// rest of the file can hold the user's secrets, and nothing of it is kept or
|
|
15
|
+
// said. A file it cannot read that way for one of its hooks is one where it
|
|
16
|
+
// cannot tell, and it refuses (the architect's ruling on #506).
|
|
17
|
+
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
|
|
23
|
+
import { hooksFileOf, readSettings, runsThisKit } from './hooks.js';
|
|
24
|
+
|
|
25
|
+
/** The events whose hooks Codex hashes with no matcher, whatever the group says. */
|
|
26
|
+
const NO_MATCHER = new Set(['user_prompt_submit', 'stop', 'interrupt']);
|
|
27
|
+
|
|
28
|
+
/** A hook's timeout when its entry gives none, in seconds, as Codex takes it. */
|
|
29
|
+
const DEFAULT_TIMEOUT = 600;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* How many of the hooks in the bot at `home`'s `.codex/hooks.json` Codex does
|
|
33
|
+
* not trust yet: `{ untrusted, file }`. It throws, with `nothing` at the end,
|
|
34
|
+
* when the file holds a hook that is not the kit's own as this kit writes it,
|
|
35
|
+
* or when the kit cannot tell whether Codex trusts one.
|
|
36
|
+
*/
|
|
37
|
+
export function untrustedKitHooks(home, run, nothing) {
|
|
38
|
+
const file = hooksFileOf(home, 'codex');
|
|
39
|
+
const settings = readSettings(file, "the kit's hooks");
|
|
40
|
+
const hooks = settings.hooks ?? {};
|
|
41
|
+
if (hooks === null || typeof hooks !== 'object' || Array.isArray(hooks)) {
|
|
42
|
+
throw new Error(`${run}: ${file} has a hooks entry that is not a mapping of events, so the kit cannot tell which hooks its review covers. ${nothing}`);
|
|
43
|
+
}
|
|
44
|
+
const ours = [];
|
|
45
|
+
const foreign = [];
|
|
46
|
+
for (const [event, groups] of Object.entries(hooks)) {
|
|
47
|
+
if (!Array.isArray(groups)) {
|
|
48
|
+
foreign.push(`${event}: ${JSON.stringify(groups)}`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
groups.forEach((group, at) => {
|
|
52
|
+
if (!Array.isArray(group?.hooks)) {
|
|
53
|
+
foreign.push(`${event}: ${JSON.stringify(group)}`);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
group.hooks.forEach((hook, handler) => {
|
|
57
|
+
if (runsThisKit(hook)) ours.push({ event: snake(event), at, handler, hash: hashOf(snake(event), group.matcher, hook) });
|
|
58
|
+
else foreign.push(`${event}: ${typeof hook?.command === 'string' ? hook.command : JSON.stringify(hook)}`);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (foreign.length > 0) {
|
|
63
|
+
throw new Error(`${run}: ${file} holds hooks that are not the kit's own as this kit writes them, and "Trust all and continue" would let them run outside the sandbox: ${foreign.join('; ')}. ${nothing}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Codex names the file by the path it found it at; the kit knows the bot
|
|
67
|
+
// home by its real path, and looks under both.
|
|
68
|
+
const files = [...new Set([file, realOr(file)])];
|
|
69
|
+
const keysOf = (hook) => files.map((one) => `${one}:${hook.event}:${hook.at}:${hook.handler}`);
|
|
70
|
+
const trusted = trustedHashes(ours.flatMap(keysOf), run, nothing);
|
|
71
|
+
const untrusted = ours.filter((hook) => !keysOf(hook).some((key) => trusted.get(key) === hook.hash)).length;
|
|
72
|
+
return { untrusted, file };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** An event's name as Codex writes it in a key and a hash: `SessionStart` is `session_start`. */
|
|
76
|
+
const snake = (event) => event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The hash Codex trusts a command hook at: SHA-256, in hex, of its event, its
|
|
80
|
+
* group's matcher and the hook as Codex reads it, as JSON with every key
|
|
81
|
+
* sorted (Codex 0.162.0, tech notes, section 3).
|
|
82
|
+
*/
|
|
83
|
+
function hashOf(event, matcher, hook) {
|
|
84
|
+
const handler = {
|
|
85
|
+
type: 'command',
|
|
86
|
+
command: hook.command,
|
|
87
|
+
timeout: hook.timeout ?? DEFAULT_TIMEOUT,
|
|
88
|
+
async: hook.async === true,
|
|
89
|
+
...(hook.statusMessage === undefined ? {} : { statusMessage: hook.statusMessage }),
|
|
90
|
+
};
|
|
91
|
+
const identity = {
|
|
92
|
+
event_name: event,
|
|
93
|
+
...(matcher === undefined || matcher === null || NO_MATCHER.has(event) ? {} : { matcher }),
|
|
94
|
+
hooks: [handler],
|
|
95
|
+
};
|
|
96
|
+
return `sha256:${createHash('sha256').update(sorted(identity)).digest('hex')}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A value as JSON with every object's keys sorted, and no spaces. */
|
|
100
|
+
const sorted = (value) => {
|
|
101
|
+
if (Array.isArray(value)) return `[${value.map(sorted).join(',')}]`;
|
|
102
|
+
if (value !== null && typeof value === 'object') {
|
|
103
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${sorted(value[key])}`).join(',')}}`;
|
|
104
|
+
}
|
|
105
|
+
return JSON.stringify(value);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const realOr = (file) => {
|
|
109
|
+
try {
|
|
110
|
+
return realpathSync(file);
|
|
111
|
+
} catch {
|
|
112
|
+
return file;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const HEADER = /^\s*\[\s*hooks\.state\."((?:[^"\\]|\\.)*)"\s*\]\s*(?:#.*)?$/;
|
|
117
|
+
const TRUSTED = /^\s*trusted_hash\s*=\s*"(sha256:[0-9a-f]{64})"\s*(?:#.*)?$/;
|
|
118
|
+
const ANY_TABLE = /^\s*\[/;
|
|
119
|
+
|
|
120
|
+
/** The short escapes of a TOML basic string. */
|
|
121
|
+
const SHORT = { b: '\b', t: '\t', n: '\n', f: '\f', r: '\r', e: '\x1b', '"': '"', '\\': '\\' };
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Text with a TOML basic string's escapes decoded, as Codex reads them: a key
|
|
125
|
+
* written `\u002f` is the key with a `/` in it (the review of PR #517). An
|
|
126
|
+
* escape TOML does not have is left as it is.
|
|
127
|
+
*/
|
|
128
|
+
const unescaped = (text) => text.replace(/\\(?:u([0-9A-Fa-f]{4})|U([0-9A-Fa-f]{8})|x([0-9A-Fa-f]{2})|(.))/g, (all, u4, u8, x2, one) => {
|
|
129
|
+
const code = u4 ?? u8 ?? x2;
|
|
130
|
+
if (code === undefined) return SHORT[one] ?? all;
|
|
131
|
+
const point = parseInt(code, 16);
|
|
132
|
+
return point <= 0x10ffff ? String.fromCodePoint(point) : all;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The `trusted_hash` for each of `keys` that has one, by key, from the
|
|
137
|
+
* config.toml the session's Codex uses: `$CODEX_HOME`'s, or else
|
|
138
|
+
* `~/.codex`'s. No file is no trust. A key the file holds in any other form
|
|
139
|
+
* than a `[hooks.state."<key>"]` table with a `trusted_hash` line, or holds
|
|
140
|
+
* twice, is one the kit cannot tell, and it throws.
|
|
141
|
+
*/
|
|
142
|
+
function trustedHashes(keys, run, nothing) {
|
|
143
|
+
const file = path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'config.toml');
|
|
144
|
+
if (!existsSync(file)) return new Map();
|
|
145
|
+
const wanted = new Set(keys);
|
|
146
|
+
const text = readFileSync(file, 'utf8');
|
|
147
|
+
const cannotTell = (key, how) =>
|
|
148
|
+
new Error(`${run}: ${file} holds Codex's trust for the kit's hook ${key} ${how}, so the kit cannot tell whether Codex trusts it. ${nothing}`);
|
|
149
|
+
|
|
150
|
+
const found = new Map();
|
|
151
|
+
const headers = new Set();
|
|
152
|
+
let table;
|
|
153
|
+
for (const line of text.split(/\r?\n/)) {
|
|
154
|
+
const header = HEADER.exec(line);
|
|
155
|
+
if (header !== null) {
|
|
156
|
+
table = unescaped(header[1]);
|
|
157
|
+
if (wanted.has(table)) {
|
|
158
|
+
if (headers.has(table)) throw cannotTell(table, 'twice');
|
|
159
|
+
headers.add(table);
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (ANY_TABLE.test(line)) {
|
|
164
|
+
table = undefined;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const hash = TRUSTED.exec(line);
|
|
168
|
+
if (hash !== null && wanted.has(table)) found.set(table, hash[1]);
|
|
169
|
+
}
|
|
170
|
+
// A key in any other form is one the kit cannot read: as it stands, which
|
|
171
|
+
// a literal string keeps, backslashes and all, or with a basic string's
|
|
172
|
+
// escapes decoded (the review of PR #517).
|
|
173
|
+
const decoded = unescaped(text);
|
|
174
|
+
for (const key of wanted) {
|
|
175
|
+
if (headers.has(key) && !found.has(key)) throw cannotTell(key, 'in a table with no trusted_hash line the kit reads');
|
|
176
|
+
if (!headers.has(key) && (text.includes(key) || decoded.includes(key))) throw cannotTell(key, 'in a form the kit does not read');
|
|
177
|
+
}
|
|
178
|
+
return found;
|
|
179
|
+
}
|
package/src/hooks.js
CHANGED
|
@@ -20,6 +20,9 @@ import { ownCli, shellWord } from './launch.js';
|
|
|
20
20
|
/** Where each harness reads a project's hooks, inside the bot home. */
|
|
21
21
|
const HOOK_FILE = { claude: '.claude/settings.json', codex: '.codex/hooks.json' };
|
|
22
22
|
|
|
23
|
+
/** The hooks file `harness` reads in the bot at `home`. */
|
|
24
|
+
export const hooksFileOf = (home, harness) => path.join(home, HOOK_FILE[harness]);
|
|
25
|
+
|
|
23
26
|
/**
|
|
24
27
|
* The one thing the kit asks to be told: a session beginning. Both harnesses
|
|
25
28
|
* report it whenever a session starts, resumes or is begun again by the user —
|
|
@@ -381,6 +384,14 @@ const KIT_NUDGE = new RegExp(String.raw`^(${WORD}) session nudge --bots ${WORD}
|
|
|
381
384
|
const KIT_MAIL = new RegExp(String.raw`^(${WORD}) session mail --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
382
385
|
const KIT_NAME = new RegExp(String.raw`^(${WORD}) session name --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
|
|
383
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Whether a hook entry is one of the kit's own as this kit writes it: a
|
|
389
|
+
* command of exactly a shape the kit writes, run by `cli`, the kit running now
|
|
390
|
+
* (#506). One that runs some other program, even another `obk`, is not.
|
|
391
|
+
*/
|
|
392
|
+
export const runsThisKit = (hook, cli = ownCli()) =>
|
|
393
|
+
hook?.type === 'command' && [KIT_HOOK, KIT_SENT, KIT_NUDGE, KIT_NAME].some((pattern) => programOf(hook.command, pattern) === cli);
|
|
394
|
+
|
|
384
395
|
/** The program a bot made before the kit named itself by path runs. */
|
|
385
396
|
const BARE = 'obk';
|
|
386
397
|
|
package/src/name.js
CHANGED
|
@@ -125,12 +125,13 @@ function nameOf(thread) {
|
|
|
125
125
|
* pointer and the command exactly, and no row of its slash menu stands above
|
|
126
126
|
* it. With words after the command Codex has closed its menu (chat_composer.rs
|
|
127
127
|
* `sync_command_popup`, rust-v0.160.0), so a menu still open means the line is
|
|
128
|
-
* not what it seems.
|
|
128
|
+
* not what it seems. Where Orca gives a draft, the draft is the line's text:
|
|
129
|
+
* Codex 0.162.0's line on the screen then reads its pointer alone (#516).
|
|
129
130
|
*/
|
|
130
|
-
function renameWrong(rows, _harness, command) {
|
|
131
|
+
function renameWrong(rows, _harness, command, _version, draft) {
|
|
131
132
|
const at = rows.findLastIndex((row) => /^ *›/.test(row));
|
|
132
133
|
if (at < 0) return { why: 'its screen shows no input line' };
|
|
133
|
-
const line = rows[at].replaceAll(' ', ' ').trim()
|
|
134
|
+
const line = draft === undefined ? rows[at].replaceAll(' ', ' ').trim() : `› ${draft}`;
|
|
134
135
|
if (line !== `› ${command}`) return { why: `its input line reads "${line}"` };
|
|
135
136
|
const above = rows.slice(0, at).findLast((row) => row.trim() !== '');
|
|
136
137
|
if (above !== undefined && /^ *(?:› +)?\/\S/.test(above)) return { why: `a menu of commands stands above its input line: "${above.trim()}"` };
|
package/src/orca.js
CHANGED
|
@@ -284,8 +284,22 @@ export function asFolderProject(setupId, title) {
|
|
|
284
284
|
* Take a bot's Orca project away: the setup, the project and the repo record,
|
|
285
285
|
* in one call (tech notes, section 1). Only after its tabs are closed: a
|
|
286
286
|
* project taken away first leaves tabs no command line can reach.
|
|
287
|
+
*
|
|
288
|
+
* With `--force`, because the caller has made those checks itself: Orca's next
|
|
289
|
+
* release refuses a plain delete of a project with saved workspace details,
|
|
290
|
+
* which a bot's project keeps after its tabs close (#528). Orca 1.4.223 does
|
|
291
|
+
* not know the flag and refuses it before it changes anything, so then the
|
|
292
|
+
* plain delete is sent, which it takes with no such guard.
|
|
287
293
|
*/
|
|
288
|
-
export
|
|
294
|
+
export function deleteProject(setupId) {
|
|
295
|
+
const plain = ['project', 'setup-delete', '--setup', setupId];
|
|
296
|
+
try {
|
|
297
|
+
return orca([...plain, '--force']);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (error.code !== 'invalid_argument' || !error.reason?.includes('Unknown flag --force')) throw error;
|
|
300
|
+
return orca(plain);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
289
303
|
|
|
290
304
|
/** Said after a run that made or renamed a project, whatever `tellWindow` answered, and after a removal `reloadWindow` could not follow. */
|
|
291
305
|
export const RELOAD_LINE = "If Orca's sidebar does not show it, reload the window with Cmd+Shift+R.";
|
|
@@ -1017,6 +1031,22 @@ export const useMailbox = (id, handle, options) =>
|
|
|
1017
1031
|
export const coordinatorOf = (id, options) =>
|
|
1018
1032
|
orca(['orchestration', 'run-show', '--id', id], options).run.coordinator_handle ?? undefined;
|
|
1019
1033
|
|
|
1034
|
+
/**
|
|
1035
|
+
* Whether this Orca has no Run of that id at all, as when the book was written
|
|
1036
|
+
* on another machine (#508). Only `run-show` refused `run_not_found` says so:
|
|
1037
|
+
* `run-use` gives a legacy Run the same code and words, and a Run shown, a
|
|
1038
|
+
* time-out or any other refusal is not a Run known to be missing (tech notes,
|
|
1039
|
+
* section 1).
|
|
1040
|
+
*/
|
|
1041
|
+
export function runMissing(id, options) {
|
|
1042
|
+
try {
|
|
1043
|
+
orca(['orchestration', 'run-show', '--id', id], options);
|
|
1044
|
+
return false;
|
|
1045
|
+
} catch (error) {
|
|
1046
|
+
return error.code === 'run_not_found';
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1020
1050
|
/** Queue one message. `to` and `from` are mailboxes, written `run:<id>`. */
|
|
1021
1051
|
export function postMessage({ to, from, subject, body, type = 'status', thread }) {
|
|
1022
1052
|
const args = ['orchestration', 'send', '--to', to, '--subject', subject, '--body', body, '--type', type];
|
package/src/temp.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// `obk temp make` and `obk temp retire`: a session's own temporary sessions
|
|
2
2
|
// (PRD 6.4, #227). And `obk temp trust-hooks` and `obk temp answer`, the
|
|
3
3
|
// answers a maker gives its Codex run's hooks review (#238) and its Claude
|
|
4
|
-
// session's Teach auto mode form (#489).
|
|
4
|
+
// session's Teach auto mode form (#489). And `obk session trust-hooks` and
|
|
5
|
+
// `obk session answer`, the same answers to a long-lived session's screens,
|
|
6
|
+
// given by Bot Father or the user (#506), on the same code.
|
|
5
7
|
//
|
|
6
8
|
// Any long-lived session can make a temporary session of its own bot for a
|
|
7
9
|
// piece of work, and retire it when the work is done, without Bot Father, who
|
|
@@ -31,11 +33,12 @@ import { setTimeout as pause } from 'node:timers/promises';
|
|
|
31
33
|
|
|
32
34
|
import { readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS, updateBook } from './book.js';
|
|
33
35
|
import { addSession, dropSession, NAME, readBot, tempRoles } from './bot.js';
|
|
36
|
+
import { untrustedKitHooks } from './hook-trust.js';
|
|
34
37
|
import { DEFAULT_APPROVAL, harnessOf, isShortPrompt, ownCli, shellWord, startPrompt, workDirOf } from './launch.js';
|
|
35
|
-
import { sessionInTab } from './message.js';
|
|
38
|
+
import { findSession, sessionInTab } from './message.js';
|
|
36
39
|
import { orca, screenRows, tabs } from './orca.js';
|
|
37
40
|
import { retireSession } from './retire.js';
|
|
38
|
-
import { bringUp, promptPath } from './up.js';
|
|
41
|
+
import { BOT_FATHER, bringUp, promptPath } from './up.js';
|
|
39
42
|
|
|
40
43
|
/** The settings a temporary session takes from its maker unless told otherwise. */
|
|
41
44
|
const INHERITED = ['model', 'effort', 'context', 'approval'];
|
|
@@ -342,26 +345,72 @@ const REVIEW_GONE_MS = 5000;
|
|
|
342
345
|
*/
|
|
343
346
|
export async function trustHooks(bots, { tab, name }) {
|
|
344
347
|
const caller = callerIn(bots, tab, 'trust-hooks', "answer a run's hooks review");
|
|
348
|
+
const { bot, session } = ownTemp(caller, name, 'answer for', 'Nothing was typed.');
|
|
349
|
+
await trustAll({ bots, run: `${caller.bot}/${name}`, bot: caller.bot, home: caller.home, session: name, harness: harnessOf(session, bot.harness) }, 'temp');
|
|
350
|
+
return { bot: caller.bot, session: name, maker: caller.session };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Shell text the shell passes on as it is, split at its spaces: what the kit's own launch line leaves unquoted, and spaces. */
|
|
354
|
+
const SHELL_PLAIN = /^[A-Za-z0-9,._+:@%/= -]*$/;
|
|
355
|
+
|
|
356
|
+
/** A count row of Codex's hooks review: `1 hook is new or changed.`, `3 hooks are new or changed.` */
|
|
357
|
+
const HOOKS_COUNT = /^ *(\d+) hooks? (?:is|are) new or changed\. *$/;
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Answer `target`'s hooks review with "Trust all and continue", for `temp
|
|
361
|
+
* trust-hooks` or `session trust-hooks` (`kind`). "Trust all" lets every hook
|
|
362
|
+
* the review counts run outside the sandbox, so first the bot's
|
|
363
|
+
* `.codex/hooks.json` must hold the kit's own hooks alone, and the review's
|
|
364
|
+
* count row must be the number of them Codex does not trust yet (#506, R4).
|
|
365
|
+
* Codex also takes hook trust, and hooks, from the session's own `-c` flags,
|
|
366
|
+
* which its extra_args can carry. What it was started with is the book's
|
|
367
|
+
* `launched_with`, which the kit writes at each launch: when that names hooks
|
|
368
|
+
* at all, or there is none, the kit cannot tell the count, and refuses (the
|
|
369
|
+
* review of PR #517). The arguments are not quoted: they can hold a secret.
|
|
370
|
+
*/
|
|
371
|
+
async function trustAll(target, kind) {
|
|
345
372
|
const nothing = 'Nothing was typed.';
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
373
|
+
if (target.harness !== 'codex') {
|
|
374
|
+
throw new Error(`${target.run} runs on ${target.harness}, and ${kind} trust-hooks answers a Codex session's "${HOOKS_REVIEW}" alone. ${nothing}`);
|
|
375
|
+
}
|
|
376
|
+
const launchedWith = readBook(target.home).sessions[target.session]?.launched_with;
|
|
377
|
+
if (launchedWith === undefined) {
|
|
378
|
+
throw new Error(`${target.run}'s book entry does not say what extra arguments it was launched with: it was started before the kit kept that record, or not by the kit. Codex takes hooks and their trust from a session's own -c flags as well, so the kit cannot tell which hooks its review covers. Start it again with the kit, then run this again: ${shellWord(ownCli())} restart --bots ${shellWord(target.bots)} --bot ${target.bot} --session ${target.session}. ${nothing}`);
|
|
379
|
+
}
|
|
380
|
+
// A list is the arguments themselves. A string is shell text, which the
|
|
381
|
+
// shell reads before Codex does (`'h''ooks'` is `hooks`), so it is read only
|
|
382
|
+
// when it holds nothing the shell would change (the review of PR #517).
|
|
383
|
+
if (typeof launchedWith === 'string' && !SHELL_PLAIN.test(launchedWith)) {
|
|
384
|
+
throw new Error(`${target.run} was launched with extra arguments written as shell text the kit does not read, and Codex takes hooks and their trust from a session's own -c flags as well, so the kit cannot tell which hooks its review covers. ${nothing}`);
|
|
385
|
+
}
|
|
386
|
+
const words = typeof launchedWith === 'string' ? launchedWith.split(' ') : [launchedWith].flat().map(String);
|
|
387
|
+
if (words.some((word) => /hooks/i.test(word))) {
|
|
388
|
+
throw new Error(`${target.run} was launched with extra arguments that mention hooks, and Codex takes hooks and their trust from a session's own -c flags as well, so the kit cannot tell which hooks its review covers. ${nothing}`);
|
|
351
389
|
}
|
|
352
|
-
|
|
390
|
+
// `--profile <name>` (`-p`) adds `<name>.config.toml` from Codex's home as a
|
|
391
|
+
// second user layer, and Codex reads hook trust from it too (codex-rs
|
|
392
|
+
// 0.162.0, cli/src/main.rs:1954-1962, config/src/loader/mod.rs:290-332).
|
|
393
|
+
if (words.some((word) => /profile/i.test(word) || /^-[^-]*p/.test(word))) {
|
|
394
|
+
throw new Error(`${target.run} was launched with a Codex profile, whose own config file can hold hook trust the kit does not read, so the kit cannot tell which hooks its review covers. ${nothing}`);
|
|
395
|
+
}
|
|
396
|
+
const { untrusted, file } = untrustedKitHooks(target.home, target.run, nothing);
|
|
397
|
+
await answerScreen(target, {
|
|
353
398
|
what: 'hooks review',
|
|
354
399
|
mark: HOOKS_REVIEW,
|
|
355
400
|
done: `"${TRUST_ALL}" was chosen on its hooks review`,
|
|
356
401
|
answer(rows, { send }) {
|
|
357
402
|
const keys = keysToTrustAll(rows);
|
|
358
403
|
if (keys === undefined) {
|
|
359
|
-
throw new Error(`${run}'s screen shows no "${HOOKS_REVIEW}" with its choices, so there is nothing for this to answer. It shows: ${shown(rows)}. ${nothing}`);
|
|
404
|
+
throw new Error(`${target.run}'s screen shows no "${HOOKS_REVIEW}" with its choices, so there is nothing for this to answer. It shows: ${shown(rows)}. ${nothing}`);
|
|
405
|
+
}
|
|
406
|
+
const counts = rows.filter((row) => HOOKS_COUNT.test(row));
|
|
407
|
+
if (counts.length !== 1 || Number(HOOKS_COUNT.exec(counts[0])[1]) !== untrusted) {
|
|
408
|
+
const saw = counts.length === 0 ? 'shows no row that says how many hooks it covers' : `says "${counts.map((row) => row.trim()).join('" and "')}"`;
|
|
409
|
+
throw new Error(`${target.run}'s hooks review ${saw}, but ${untrusted} of the kit's own hooks in ${file} are not trusted by Codex yet, so the review may cover hooks that are not the kit's, and "${TRUST_ALL}" would let them run outside the sandbox. ${nothing}`);
|
|
360
410
|
}
|
|
361
411
|
send(keys);
|
|
362
412
|
},
|
|
363
413
|
});
|
|
364
|
-
return { bot: caller.bot, session: name, maker: caller.session };
|
|
365
414
|
}
|
|
366
415
|
|
|
367
416
|
/**
|
|
@@ -386,23 +435,22 @@ function shown(rows) {
|
|
|
386
435
|
}
|
|
387
436
|
|
|
388
437
|
/**
|
|
389
|
-
* Answer a screen of `
|
|
390
|
-
* session's turn for a line (#482),
|
|
391
|
-
* `answer(rows, { send, look })`, which sends
|
|
392
|
-
* screen again with `look`, and throws when the
|
|
393
|
-
* Then wait for `mark` to leave the screen.
|
|
394
|
-
* says what was sent, for the sentences
|
|
395
|
-
* function of what `answer` returned.
|
|
438
|
+
* Answer a screen of the session `target`, `{ run, home, session }`, `run`
|
|
439
|
+
* being its `<bot>/<session>`: under the session's turn for a line (#482),
|
|
440
|
+
* read its screen and hand it to `answer(rows, { send, look })`, which sends
|
|
441
|
+
* keys with `send`, may read the screen again with `look`, and throws when the
|
|
442
|
+
* screen is not its to answer. Then wait for `mark` to leave the screen.
|
|
443
|
+
* `what` names the screen and `done` says what was sent, for the sentences
|
|
444
|
+
* that refuse or fail; `done` may be a function of what `answer` returned.
|
|
396
445
|
*/
|
|
397
|
-
async function answerScreen(
|
|
398
|
-
const run = `${caller.bot}/${name}`;
|
|
446
|
+
async function answerScreen({ run, home, session }, { what, mark, done, answer }) {
|
|
399
447
|
const nothing = 'Nothing was typed.';
|
|
400
|
-
const tabId = readBook(
|
|
401
|
-
const handle = tabId === undefined ? undefined : tabs(
|
|
448
|
+
const tabId = readBook(home).sessions[session]?.tab;
|
|
449
|
+
const handle = tabId === undefined ? undefined : tabs(home).find((one) => one.tabId === tabId)?.handle;
|
|
402
450
|
if (handle === undefined) {
|
|
403
451
|
throw new Error(`${run} has no tab open in Orca, so there is no ${what} of its to answer. ${nothing}`);
|
|
404
452
|
}
|
|
405
|
-
const turn = takeLineTurn(
|
|
453
|
+
const turn = takeLineTurn(home, session, TYPING_WAIT_MS);
|
|
406
454
|
if (turn === undefined) throw new Error(`${run}: ${TYPING_HELD}.`);
|
|
407
455
|
let answered;
|
|
408
456
|
try {
|
|
@@ -540,18 +588,27 @@ const wholeScreen = (rows) => rows.filter((row) => row.trim() !== '').map((row)
|
|
|
540
588
|
*/
|
|
541
589
|
export async function answerTemp(bots, { tab, name }) {
|
|
542
590
|
const caller = callerIn(bots, tab, 'answer', "answer a session's first-run screen");
|
|
591
|
+
ownTemp(caller, name, 'answer for', 'Nothing was typed.');
|
|
592
|
+
const sent = await answerTeach({ run: `${caller.bot}/${name}`, home: caller.home, session: name }, 'temp');
|
|
593
|
+
return { bot: caller.bot, session: name, maker: caller.session, sent };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Answer `target`'s Teach auto mode screen with Not now, for `temp answer` or
|
|
598
|
+
* `session answer` (`kind`). Returns what answered it.
|
|
599
|
+
*/
|
|
600
|
+
function answerTeach(target, kind) {
|
|
601
|
+
const { run } = target;
|
|
543
602
|
const nothing = 'Nothing was typed.';
|
|
544
|
-
|
|
545
|
-
const run = `${caller.bot}/${name}`;
|
|
546
|
-
const sent = await answerScreen(caller, name, {
|
|
603
|
+
return answerScreen(target, {
|
|
547
604
|
what: `"${TEACH_TITLE}"`,
|
|
548
605
|
mark: TEACH_TITLE,
|
|
549
606
|
done: (said) => `${said} was sent to its Teach auto mode screen`,
|
|
550
607
|
answer(rows, { send, look }) {
|
|
551
608
|
const seen = teachScreen(rows);
|
|
552
609
|
if (seen.misfit !== undefined) {
|
|
553
|
-
const hooks = rows.some((row) => row.includes(HOOKS_REVIEW)) ? ` A Codex
|
|
554
|
-
throw new Error(`${run}'s screen is not one
|
|
610
|
+
const hooks = rows.some((row) => row.includes(HOOKS_REVIEW)) ? ` A Codex session's "${HOOKS_REVIEW}" is answered with ${kind} trust-hooks.` : '';
|
|
611
|
+
throw new Error(`${run}'s screen is not one ${kind} answer answers: ${seen.misfit}.${hooks} ${nothing} It shows:\n${wholeScreen(rows)}`);
|
|
555
612
|
}
|
|
556
613
|
if (seen.shape === TEACH_SHAPES[0]) {
|
|
557
614
|
send('\x1b');
|
|
@@ -574,7 +631,55 @@ export async function answerTemp(bots, { tab, name }) {
|
|
|
574
631
|
return `"${NOT_NOW_CHOICE}"`;
|
|
575
632
|
},
|
|
576
633
|
});
|
|
577
|
-
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* `obk session trust-hooks`: answer the hooks review of `bot`/`session`, a
|
|
638
|
+
* long-lived Codex session, as `temp trust-hooks` answers a temporary one's
|
|
639
|
+
* (#506). Returns `{ bot, session }`.
|
|
640
|
+
*/
|
|
641
|
+
export async function trustSessionHooks(bots, { tab, bot, session }) {
|
|
642
|
+
const target = longLivedTarget(bots, { tab, bot, session }, 'trust-hooks');
|
|
643
|
+
await trustAll(target, 'session');
|
|
644
|
+
return { bot: target.bot, session: target.session };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* `obk session answer`: answer the Teach auto mode screen of `bot`/`session`,
|
|
649
|
+
* a long-lived Claude Code session, as `temp answer` answers a temporary
|
|
650
|
+
* one's (#506). Returns `{ bot, session, sent }`.
|
|
651
|
+
*/
|
|
652
|
+
export async function answerSession(bots, { tab, bot, session }) {
|
|
653
|
+
const target = longLivedTarget(bots, { tab, bot, session }, 'answer');
|
|
654
|
+
const sent = await answerTeach(target, 'session');
|
|
655
|
+
return { bot: target.bot, session: target.session, sent };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* The long-lived session `bot`/`session`, whose first-run screen `session
|
|
660
|
+
* <verb>` answers, or a refusal. A long-lived session is Bot Father's, so the
|
|
661
|
+
* caller is a session of Bot Father's, or no session of this fleet: the user,
|
|
662
|
+
* or an assistant of theirs (#506, R2). A temporary session's screen is its
|
|
663
|
+
* maker's to answer, with the temp command.
|
|
664
|
+
*/
|
|
665
|
+
function longLivedTarget(bots, { tab, bot, session }, verb) {
|
|
666
|
+
const nothing = 'Nothing was typed.';
|
|
667
|
+
const caller = tab === undefined ? undefined : sessionInTab(bots, tab);
|
|
668
|
+
if (caller !== undefined && caller.bot !== BOT_FATHER) {
|
|
669
|
+
throw new Error(`session ${verb} runs in ${caller.bot}/${caller.session}'s tab, but a long-lived session is Bot Father's: Bot Father (${BOT_FATHER}) or the user answers its first-run screen. ${nothing}`);
|
|
670
|
+
}
|
|
671
|
+
let found;
|
|
672
|
+
try {
|
|
673
|
+
found = findSession(bots, `${bot}/${session}`);
|
|
674
|
+
} catch (error) {
|
|
675
|
+
throw new Error(`${error.message} ${nothing}`);
|
|
676
|
+
}
|
|
677
|
+
const run = `${found.bot}/${found.session}`;
|
|
678
|
+
const temporary = readBook(found.home).sessions[found.session]?.temporary;
|
|
679
|
+
if (temporary !== undefined) {
|
|
680
|
+
throw new Error(`${run} is a temporary session ${temporary.maker} made, so its first-run screen is ${temporary.maker}'s to answer, with temp ${verb} run in ${temporary.maker}'s tab. ${nothing}`);
|
|
681
|
+
}
|
|
682
|
+
return { bots, run, bot: found.bot, session: found.session, home: found.home, harness: found.harness };
|
|
578
683
|
}
|
|
579
684
|
|
|
580
685
|
/**
|
package/src/up.js
CHANGED
|
@@ -14,7 +14,7 @@ import { conversationsIn, hasConversation, heldAsUserTurn, transcriptsIn } from
|
|
|
14
14
|
import { installHook } from './hooks.js';
|
|
15
15
|
import { writePermissions } from './permissions.js';
|
|
16
16
|
import { addressOf, harnessOf, isAddressOf, isShortPrompt, launchCommand, mailboxStep, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
|
|
17
|
-
import { asFolderProject, coordinatorOf, findProject, harnessInTab, makeMailbox, makeProject, openTab, QUESTION_ON_SCREEN, retitleTab, screenRows, shellInTab, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
|
|
17
|
+
import { asFolderProject, coordinatorOf, findProject, harnessInTab, makeMailbox, makeProject, openTab, QUESTION_ON_SCREEN, retitleTab, runMissing, screenRows, shellInTab, tabs, tabToTypeInto, TERMINAL_ENV, TIMED_OUT, tellWindow, typeIntoTab, useMailbox } from './orca.js';
|
|
18
18
|
import { TAB_ENV } from './record.js';
|
|
19
19
|
import { buildAgents, rulesStamp } from './rules.js';
|
|
20
20
|
import { linkSkills } from './skills.js';
|
|
@@ -309,7 +309,10 @@ async function bringUpSession(bots, home, live, session, bot, title) {
|
|
|
309
309
|
// for a person or Bot Father to settle — added to whatever was already noted,
|
|
310
310
|
// because this run's scan cannot see what an earlier one found. The kit never
|
|
311
311
|
// settles it itself.
|
|
312
|
-
|
|
312
|
+
// The extra arguments typed on this line, as the kit's own record of what
|
|
313
|
+
// this harness was started with: bot.yaml says only what the next launch
|
|
314
|
+
// gets, and trust-hooks asks what this one got (#506).
|
|
315
|
+
let entry = { ...current.sessions[session.name], tab: made.tabId, launched, launched_with: session.extra_args ?? [], rules };
|
|
313
316
|
if (rules === undefined) delete entry.rules;
|
|
314
317
|
held = entry.session;
|
|
315
318
|
// Before the line is typed, so the hook finds no id here and takes the one
|
|
@@ -648,7 +651,8 @@ async function writeAddress(home, session, address) {
|
|
|
648
651
|
* a restart (tech notes, section 1). Orca offers no way to delete one, so this
|
|
649
652
|
* makes one for a session whose book names none, and otherwise binds the one
|
|
650
653
|
* the book names to this tab: after a restart, or a closed tab brought back,
|
|
651
|
-
* it would stay with the tab that is gone (review of PR #248, finding 1).
|
|
654
|
+
* it would stay with the tab that is gone (review of PR #248, finding 1). A
|
|
655
|
+
* Run the book names and this Orca does not have at all is replaced (#508).
|
|
652
656
|
*
|
|
653
657
|
* Only inside an Orca tab. Asked from anywhere else, Orca picks a terminal
|
|
654
658
|
* itself, and seen live that was not the asker's (tech notes, section 1), so
|
|
@@ -705,20 +709,34 @@ async function mailboxInTurn(home, botName, sessionName, who) {
|
|
|
705
709
|
if (error.code === TIMED_OUT) {
|
|
706
710
|
throw new Error(`${error.message}\n${who}'s mailbox ${held} may now be bound to this tab, or may still be bound where it was: Orca did not say which.`);
|
|
707
711
|
}
|
|
708
|
-
|
|
712
|
+
// A Run this Orca has never had, as when the book was written on another
|
|
713
|
+
// machine, can never be bound, so every start would fail the same way.
|
|
714
|
+
// It is replaced as a session with none would be given one (#508).
|
|
715
|
+
if (!runMissing(held, STEP_WAIT)) {
|
|
716
|
+
throw new Error(`${error.message}\n${who}'s mailbox ${held} is unchanged: it is still bound to ${boundTo(held)}.`);
|
|
717
|
+
}
|
|
718
|
+
return newMailbox(home, botName, sessionName, who, here, held);
|
|
709
719
|
}
|
|
710
720
|
return { bot: botName, session: sessionName, mailbox: held, change: 'bound' };
|
|
711
721
|
}
|
|
722
|
+
return newMailbox(home, botName, sessionName, who, here);
|
|
723
|
+
}
|
|
712
724
|
|
|
725
|
+
/**
|
|
726
|
+
* Make a mailbox in this tab and write it into the book, for a session whose
|
|
727
|
+
* book names none, or names `stale`, a Run this Orca does not have.
|
|
728
|
+
*/
|
|
729
|
+
async function newMailbox(home, botName, sessionName, who, here, stale) {
|
|
713
730
|
// Made outside the book's lock, which is held for one read and one write
|
|
714
731
|
// (book.js).
|
|
715
732
|
let made;
|
|
716
733
|
try {
|
|
717
734
|
made = makeMailbox(who, STEP_WAIT);
|
|
718
735
|
} catch (error) {
|
|
736
|
+
const written = stale === undefined ? 'none is written down' : `the book still names ${stale}, which this Orca does not have`;
|
|
719
737
|
const left = error.code === TIMED_OUT
|
|
720
|
-
? `
|
|
721
|
-
: `No mailbox was made for ${who}, and
|
|
738
|
+
? `For ${who}, ${written}; a Run Orca made after the kit stopped waiting is left unused.`
|
|
739
|
+
: `No mailbox was made for ${who}, and ${written}.`;
|
|
722
740
|
throw new Error(`${error.message}\n${left} It gets one the next time the kit starts it.`);
|
|
723
741
|
}
|
|
724
742
|
|
|
@@ -734,10 +752,11 @@ async function mailboxInTurn(home, botName, sessionName, who) {
|
|
|
734
752
|
movedTo = now.tab ?? 'none';
|
|
735
753
|
return undefined;
|
|
736
754
|
}
|
|
737
|
-
// Under the lock, and only if the book still has none
|
|
738
|
-
// would each have made one, and a session
|
|
739
|
-
// half its mail never reaches. The
|
|
740
|
-
|
|
755
|
+
// Under the lock, and only if the book still has none, or still the
|
|
756
|
+
// stale one: two runs at once would each have made one, and a session
|
|
757
|
+
// with two mailboxes is a session half its mail never reaches. The
|
|
758
|
+
// loser's Run is left unused.
|
|
759
|
+
if (typeof now.mailbox !== 'string' || now.mailbox === stale) now.mailbox = made;
|
|
741
760
|
kept = now.mailbox;
|
|
742
761
|
current.sessions[sessionName] = now;
|
|
743
762
|
return undefined;
|
|
@@ -750,8 +769,13 @@ async function mailboxInTurn(home, botName, sessionName, who) {
|
|
|
750
769
|
}
|
|
751
770
|
// One terminal holds one Run, so making the loser took this tab off the one
|
|
752
771
|
// the book kept. It goes back.
|
|
753
|
-
if (kept !== made)
|
|
754
|
-
|
|
772
|
+
if (kept !== made) {
|
|
773
|
+
useMailbox(kept, undefined, STEP_WAIT);
|
|
774
|
+
return { bot: botName, session: sessionName, mailbox: kept, change: 'bound' };
|
|
775
|
+
}
|
|
776
|
+
return stale === undefined
|
|
777
|
+
? { bot: botName, session: sessionName, mailbox: made, change: 'made' }
|
|
778
|
+
: { bot: botName, session: sessionName, mailbox: made, change: 'replaced', replaced: stale };
|
|
755
779
|
}
|
|
756
780
|
|
|
757
781
|
/**
|