@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 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 end-to-end runs of the CLI in a
647
- throwaway folder, with a fake `orca` on PATH. It needs nothing but Node, so
648
- [GitHub Actions](.github/workflows/ci.yml) runs it on every pull request, split
649
- into shards that run side by side, on Node 25.8.0, the current line. A release
650
- runs every shard on 25.8.0 and again on 24.21.0, the floor `engines.node`
651
- promises users, with only the kit's own dependencies installed, before anything
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",
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
- /** What Codex asks after `/new`, and the one answer the kit gives it: the bot home it runs in. */
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
- // On Codex, what is typed cannot be read back once its menu is open, so
225
- // nothing may be in its input line before: no draft for the command to join.
226
- // On Claude Code, Orca gives a draft in the line beside the screen (#510).
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 (it.harness !== 'codex') {
229
- if (draft !== undefined) throw new Error(`${it.name}: nothing was typed, because its input line holds a draft: it reads "${draft}".${shownEnd(rows)}`);
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
- if (harness === 'codex') return codexMenuWrong(rows, at, line, command, version);
419
- // Orca 1.4.223 gives the line's text as the draft, and Claude Code 2.1.296's
420
- // line on the screen then reads its pointer alone (#510, probe 4).
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, then the input
449
- * line (its own snapshot tests, chat_composer slash_popup_*.snap,
450
- * rust-v0.160.0; live run 4). The menu has to hold one command row, the
451
- * selected one, naming the command. The input line reads the command, or, on
452
- * the Codex whose line Orca shows bare, its pointer alone.
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
- return [` Write to ${answer.address} with your own harness's messaging. The kit does not carry that road.`];
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 const deleteProject = (setupId) => orca(['project', 'setup-delete', '--setup', setupId]);
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
- const { bot, session } = ownTemp(caller, name, 'answer for', nothing);
347
- const run = `${caller.bot}/${name}`;
348
- const harness = harnessOf(session, bot.harness);
349
- if (harness !== 'codex') {
350
- throw new Error(`${run} runs on ${harness}, and temp trust-hooks answers a Codex run's "${HOOKS_REVIEW}" alone. ${nothing}`);
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
- await answerScreen(caller, name, {
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 `name`, a temporary session the caller made: under the
390
- * session's turn for a line (#482), read its screen and hand it to
391
- * `answer(rows, { send, look })`, which sends keys with `send`, may read the
392
- * screen again with `look`, and throws when the screen is not its to answer.
393
- * Then wait for `mark` to leave the screen. `what` names the screen and `done`
394
- * says what was sent, for the sentences that refuse or fail; `done` may be a
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(caller, name, { what, mark, done, answer }) {
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(caller.home).sessions[name]?.tab;
401
- const handle = tabId === undefined ? undefined : tabs(caller.home).find((one) => one.tabId === tabId)?.handle;
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(caller.home, name, TYPING_WAIT_MS);
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
- ownTemp(caller, name, 'answer for', nothing);
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 run's "${HOOKS_REVIEW}" is answered with temp trust-hooks.` : '';
554
- throw new Error(`${run}'s screen is not one temp answer answers: ${seen.misfit}.${hooks} ${nothing} It shows:\n${wholeScreen(rows)}`);
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
- return { bot: caller.bot, session: name, maker: caller.session, sent };
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
- let entry = { ...current.sessions[session.name], tab: made.tabId, launched, rules };
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
- throw new Error(`${error.message}\n${who}'s mailbox ${held} is unchanged: it is still bound to ${boundTo(held)}.`);
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
- ? `None is written down for ${who}; a Run Orca made after the kit stopped waiting is left unused.`
721
- : `No mailbox was made for ${who}, and none is written down.`;
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: two runs at once
738
- // would each have made one, and a session with two mailboxes is a session
739
- // half its mail never reaches. The loser's Run is left unused.
740
- if (typeof now.mailbox !== 'string') now.mailbox = made;
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) useMailbox(kept, undefined, STEP_WAIT);
754
- return { bot: botName, session: sessionName, mailbox: kept, change: kept === made ? 'made' : 'bound' };
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
  /**