@assuredloop/orca-bot-kit 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -344,6 +344,12 @@ settings ([ADR 0022](docs/adr/0022-kit-hooks-live-in-the-bot-folder.md)). Your o
344
344
  settings in those files are kept. Codex asks you to trust a hooks file the first
345
345
  time it sees one; answer its question in the tab.
346
346
 
347
+ A Claude bot gets one more hook there. Claude Code's own messaging reaches every
348
+ Claude session on the machine, so after a session sends a native message to an
349
+ address that is none of your bots folder's sessions, the hook tells it so, and
350
+ names the address `obk message to` would have given. The message goes either
351
+ way ([ADR 0032](docs/adr/0032-a-native-message-outside-the-fleet-is-warned-about.md)).
352
+
347
353
  So: kill a tab, or reboot, and `obk up` brings the session back with the
348
354
  conversation it was having, rather than starting a new one. Clear a session —
349
355
  `/clear` on Claude Code, `/new` on Codex — and the kit writes down the new id,
@@ -615,7 +621,9 @@ real Orca and the real harnesses on your own machine. No CI runner can do that,
615
621
  so these are run by hand before a change that touches Orca or a harness is
616
622
  merged. A system test touches only what it creates and removes it afterwards,
617
623
  except the mailbox Runs Orca gives no way to delete, which the run names; it
618
- never closes a tab it did not open.
624
+ never closes a tab it did not open. The trust its harnesses write into your
625
+ `~/.codex/config.toml` and `~/.claude.json` for the run's own throwaway folders
626
+ is taken out again after the run, and named; nothing else in them is changed.
619
627
 
620
628
  They run on the machine the command is typed on, so the command will not start
621
629
  them by itself. On its own it says what it is about to drive — whose machine,
package/SETUP.md CHANGED
@@ -194,10 +194,10 @@ waiting for:
194
194
  | On screen | Send | Which is | Proven on |
195
195
  |---|---|---|---|
196
196
  | Claude Code's folder trust list | `\x1b[B\r` | down, then return: its selection starts on **No, exit** | Claude Code 2.1.283 |
197
- | Codex's directory trust, `1. Trust and continue` (older: `1. Yes, continue`) | `\r` | return: its selection starts on **Trust and continue** | Codex 0.158.0 |
198
- | Codex's `Hooks need review` | `\x1b[B\r` | down, then return, to **Trust all and continue**; if the selection already starts there, return alone | Codex 0.158.0 |
199
- | Codex's `/new`: `Where should the new conversation run?` | `\r` | return: its selection starts on **Current checkout** (bot home) | Codex 0.158.0 |
200
- | 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 has had no newer release to offer |
197
+ | 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 |
198
+ | Codex's `Hooks need review` | `\x1b[B\r` | down, then return, to **Trust all and continue**; if the selection already starts there, return alone | Codex 0.160.0 |
199
+ | Codex's `/new`: `Where should the new conversation run?` | `\r` | return: its selection starts on **Current checkout** (bot home) | Codex 0.160.0 |
200
+ | 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 |
201
201
  | `[oh-my-zsh] Would you like to update?` | `n` | they update their own shell | oh-my-zsh bf77e35 |
202
202
  | 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 |
203
203
 
@@ -217,7 +217,7 @@ empty book and nothing says why.
217
217
  Codex's trust question applies to the **repository root**, which for a bot means
218
218
  the whole bots folder rather than the one bot. That is what they are agreeing to.
219
219
 
220
- Codex's `/new` question (seen on 0.156.1, 0.157.1 and 0.158.0) is always answered with the current checkout, which is the bot
220
+ Codex's `/new` question (seen on 0.156.1, 0.157.1, 0.158.0 and 0.160.0) is always answered with the current checkout, which is the bot
221
221
  home. The kit never makes a git worktree, so
222
222
  `2. New worktree` is never the answer.
223
223
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -35,7 +35,8 @@
35
35
  "yaml": "2.9.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@stryker-mutator/core": "10.0.0"
38
+ "@stryker-mutator/core": "10.0.0",
39
+ "smol-toml": "1.9.0"
39
40
  },
40
41
  "overrides": {
41
42
  "typed-rest-client": {
@@ -4,18 +4,17 @@ title: Temporary sessions of your own
4
4
  applies: all
5
5
  ---
6
6
 
7
- For a piece of work that wants a helper, a long-lived session may make a
8
- temporary session of its own bot, without asking Bot Father:
7
+ For a piece of work that wants a helper, a session may make a temporary
8
+ session of its own bot, without asking Bot Father:
9
9
  `"${OBK_CLI:-obk}" temp make --bots <bots> --name <name> --prompt <the task>`,
10
10
  run in your own tab. It takes your settings unless you say otherwise, and gets
11
- a work dir, a tab and a mailbox.
11
+ a work dir, a tab and a mailbox. A temporary session may make one of its own,
12
+ one at a time; one made by a temporary session makes none.
12
13
 
13
14
  A new tab can stop on a first-run screen. Answer it yourself, from the table
14
15
  the command's output points to.
15
16
 
16
- Retire it when its work is done:
17
+ Retire it when its work is done, and what it made goes with it:
17
18
  `"${OBK_CLI:-obk}" temp retire --bots <bots> --name <name>`. Before you make
18
- another, first retire any of yours whose work is done.
19
-
20
- You manage only the temporary sessions you made; a temporary session makes
21
- none of its own. Every other session is Bot Father's to make, change or retire.
19
+ another, first retire any of yours whose work is done. You manage only the
20
+ temporary sessions you made; every other session is Bot Father's.
@@ -288,9 +288,9 @@ otherwise `/Applications/Orca.app/Contents/Resources/bin/orca`. A bare
288
288
  | On screen | Send | Which is | Proven on |
289
289
  |---|---|---|---|
290
290
  | Claude Code's trust list | `\x1b[B\r` | down, return: off **No, exit** | Claude Code 2.1.283 |
291
- | Codex's `Trust this folder?` | `\r` | return: it starts on yes | Codex 0.158.0 |
292
- | Codex's `Hooks need review` | `\x1b[B\r` | **Trust all and continue** | Codex 0.158.0 |
293
- | Codex's `Where should the new conversation run?` | `\r` | **Current checkout** | Codex 0.158.0 |
291
+ | Codex's `Trust this folder?` | `\r` | return: it starts on yes | Codex 0.160.0 |
292
+ | Codex's `Hooks need review` | `\x1b[B\r` | **Trust all and continue** | Codex 0.160.0 |
293
+ | Codex's `Where should the new conversation run?` | `\r` | **Current checkout** | Codex 0.160.0 |
294
294
  | Codex's update offer, `1. Update now` | `\r` | **Update now**, where it starts | Codex 0.156.1 |
295
295
  | `[oh-my-zsh] Would you like to update?` | `n` | no: the user updates it | oh-my-zsh bf77e35 |
296
296
  | Claude Code's `Teach auto mode…` | `\x1b` | Esc: Not now; no return | Not yet proven, below |
@@ -302,7 +302,8 @@ return: on Codex's update offer that took the highlighted **Update now**,
302
302
  whatever the digit was. Codex's trust said `1. Yes, continue` on older
303
303
  versions. Its hooks review sometimes starts on `2` already: then return alone.
304
304
  Its update offer was proven on 0.156.1; 0.157.1 showed it with the selection on
305
- Update now, not pressed, and 0.158.0 has had no newer release to offer.
305
+ Update now, not pressed, and 0.158.0 offered 0.160.0, taken on 2026-10-02 by
306
+ Bot Father's restarts.
306
307
  Claude Code's `Teach auto mode` on 2.1.283 is a form, not a list, ending
307
308
  `Enter to continue · Esc to cancel`: never send it a return, since
308
309
  Enter is Continue, which scans the project, recent sessions and the machine's
@@ -313,7 +314,7 @@ question matters most. The kit's hook is how the book learns which
313
314
  conversation the session is running, and until it is answered the
314
315
  conversation has not started. Codex's trust applies to the repository root,
315
316
  which is the whole bots folder. Codex's `/new` may ask where the new
316
- conversation runs (0.156.1, 0.157.1 and 0.158.0 all did); the answer is
317
+ conversation runs (0.156.1, 0.157.1, 0.158.0 and 0.160.0 all did); the answer is
317
318
  always the current checkout, the bot home, and never `2. New worktree`:
318
319
  the kit never makes a git worktree.
319
320
 
package/src/cli.js CHANGED
@@ -24,6 +24,7 @@ import { pauseSessions, unpauseSessions } from './pause.js';
24
24
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
25
25
  import { restartSessions } from './restart.js';
26
26
  import { retireBot, retireSession } from './retire.js';
27
+ import { sentWarning } from './sent.js';
27
28
  import { readRoster } from './roster.js';
28
29
  import { buildAgents, buildRules, CODEX_CAP } from './rules.js';
29
30
  import { addSkill, buildSkills, linkSkills, removeSkill } from './skills.js';
@@ -90,28 +91,34 @@ Usage:
90
91
  session with the conversation it was having.
91
92
  obk retire --bots <path> --bot <bot> [--session <name>]
92
93
  End a session: close its tab and take it off the
93
- bot, keeping its conversations in the book. Or end
94
- a bot: close its tabs, remove its Orca project and
95
- move its folder to retired/. It will not retire a
96
- bot whose Orca project holds a tab your book does
97
- not name, and moves the folder only once Orca no
98
- longer lists the project.
94
+ bot, keeping its conversations in the book, and
95
+ first do the same for the temporary sessions it
96
+ made, and theirs. Or end a bot: close its tabs,
97
+ remove its Orca project and move its folder to
98
+ retired/. It will not retire a bot whose Orca
99
+ project holds a tab your book does not name, and
100
+ moves the folder only once Orca no longer lists
101
+ the project.
99
102
  obk temp make --bots <path> --name <session>
100
103
  (--prompt <text> | --prompt-file <path>) [--harness claude|codex]
101
104
  [--model <m>] [--effort <e>] [--context <c>]
102
105
  [--approval ${APPROVALS.join('|')}] [--extra-arg=<arg>]...
103
- Run in a long-lived session's own tab: make a
104
- temporary session of that session's bot for a piece
105
- of work, and bring it up. It takes your harness,
106
- model, effort, context and approval unless you say
107
- otherwise; on another harness than yours it takes
108
- only your approval. It works in work/<session>, and
109
- has the task as its start prompt. The book records
110
- it as temporary, made by you.
106
+ Run in a session's own tab: make a temporary
107
+ session of that session's bot for a piece of work,
108
+ and bring it up. A temporary session makes one at
109
+ a time, and one it made makes none. It takes your
110
+ harness, model, effort, context and approval unless
111
+ you say otherwise; on another harness than yours
112
+ it takes only your approval. It works in
113
+ work/<session>, and has the task as its start
114
+ prompt. The book records it as temporary, made by
115
+ you.
111
116
  obk temp retire --bots <path> --name <session>
112
117
  Run in the maker's own tab: retire a temporary
113
118
  session it made, as obk retire does. It refuses a
114
119
  long-lived session, or one another session made.
120
+ Like obk retire, it first retires the temporary
121
+ sessions that session made.
115
122
  obk temp trust-hooks --bots <path> --name <session>
116
123
  Run in the maker's own tab: answer the hooks review
117
124
  of a Codex temporary session it made with "Trust
@@ -217,19 +224,24 @@ Usage:
217
224
  obk usage --bots <path> [--bot <bot>] [--session <name>] [--since <time>] [--until <time>]
218
225
  Say what your sessions have used: the conversations
219
226
  each one had, their calls and tokens (their
220
- subagents' included, and how many were theirs), the
221
- models and efforts they ran at, and how often they
222
- were compacted. --since counts the calls made from
223
- that moment on, and --until the calls made before
224
- that one: a daily run asks from the last run's end
225
- up to its own, and the next starts where it stopped,
226
- so no call is counted twice. It counts tokens and
227
- never money: what a token costs is looked up live by
228
- whoever is asking.
227
+ subagents' and Codex's auto-reviews included, and
228
+ how many were whose), the models and efforts they
229
+ ran at, and how often they were compacted. --since
230
+ counts the calls made from that moment on, and
231
+ --until the calls made before that one: a daily run
232
+ asks from the last run's end up to its own, and the
233
+ next starts where it stopped, so no call is counted
234
+ twice. It counts tokens and never money: what a
235
+ token costs is looked up live by whoever is asking.
229
236
  obk session record --bots <path> --bot <bot>
230
237
  For the kit's own hook, not for typing: it reads
231
238
  what the harness says about a session starting on
232
239
  standard input and writes it into the book.
240
+ obk session sent --bots <path> --bot <bot>
241
+ For the kit's own hook, not for typing: after a
242
+ Claude session's native message, it warns the
243
+ session when the address was none of this bots
244
+ folder's own. It never stops a message.
233
245
  obk session mailbox --bots <path> --bot <bot> --session <name>
234
246
  For the kit's own launch line, not for typing: run
235
247
  in the session's own tab, it gives the session its
@@ -269,6 +281,7 @@ const COMMANDS = {
269
281
  'message send': ['bots', 'to', 'subject'],
270
282
  'message check': ['bots'],
271
283
  'session record': ['bots', 'bot'],
284
+ 'session sent': ['bots', 'bot'],
272
285
  'session mailbox': ['bots', 'bot', 'session'],
273
286
  'temp make': ['bots', 'name'],
274
287
  'temp retire': ['bots', 'name'],
@@ -394,6 +407,10 @@ async function run(argv) {
394
407
  throw new Error(`--harness is ${HARNESSES.join(' or ')}, and got: ${values.harness}`);
395
408
  }
396
409
 
410
+ // The send hook is quiet whatever it is given, a bots folder that is not
411
+ // there included, so it goes before anything that can complain (ADR 0032).
412
+ if (command === SENT) return sent(path.resolve(values.bots));
413
+
397
414
  // One fleet, one identity, whatever spelling of its path was given (#164).
398
415
  const bots = command === 'init' ? path.resolve(values.bots) : sameFleet(path.resolve(values.bots));
399
416
  if (command === RECORD) return record(bots, values.bot);
@@ -457,8 +474,26 @@ const ANSWER_IT = [
457
474
  ' Anything you do not recognise gets no keypress: take it to the user.',
458
475
  ];
459
476
 
460
- /** The one command a harness runs rather than a person: the kit's hook. */
477
+ /** The commands a harness runs rather than a person: the kit's hooks. */
461
478
  const RECORD = 'session record';
479
+ const SENT = 'session sent';
480
+
481
+ /**
482
+ * What the kit's send hook says after a native message: a warning the session
483
+ * reads, as Claude Code's `additionalContext`, or nothing. Never a decision,
484
+ * and never anything but exit 0 (ADR 0032).
485
+ */
486
+ function sent(bots) {
487
+ try {
488
+ const warning = sentWarning(bots, JSON.parse(readFileSync(0, 'utf8')));
489
+ if (warning !== undefined) {
490
+ process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: warning } })}\n`);
491
+ }
492
+ } catch {
493
+ // Nothing: a hook does not disturb the session it runs in.
494
+ }
495
+ return 0;
496
+ }
462
497
 
463
498
  /**
464
499
  * What the kit's hook does with what the harness told it, and what it answers.
@@ -571,6 +606,7 @@ const commands = {
571
606
  return {
572
607
  answer: { bots, ...retired },
573
608
  lines: [
609
+ ...withLines(retired.retiredWith, retired.session),
574
610
  ...closedLines(retired.closed),
575
611
  `retired ${retired.bot} ${retired.session}: off ${path.join('bots', retired.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
576
612
  ...leftLines(retired.promptsLeft),
@@ -633,6 +669,7 @@ const commands = {
633
669
  return {
634
670
  answer: { bots, ...retired },
635
671
  lines: [
672
+ ...withLines(retired.retiredWith, retired.session),
636
673
  ...closedLines(retired.closed),
637
674
  `retired ${retired.bot} ${retired.session}, a temporary session of ${retired.maker}'s: off ${path.join('bots', retired.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
638
675
  ...leftLines(retired.promptsLeft),
@@ -1106,6 +1143,13 @@ function settingsOf(values) {
1106
1143
  /** The tabs a command closed, one line each. */
1107
1144
  const closedLines = (closed) => closed.map((tab) => `closed ${tab.bot} ${tab.name} tab ${tab.tabId} terminal ${tab.terminal}`);
1108
1145
 
1146
+ /** The temporary sessions a retire took along with the one it named, each as it went (#464). */
1147
+ const withLines = (retiredWith, session) => retiredWith.flatMap((gone) => [
1148
+ ...closedLines(gone.closed),
1149
+ `retired ${gone.bot} ${gone.session}, a temporary session of ${gone.maker}'s, along with ${session}: off ${path.join('bots', gone.bot, 'bot.yaml')}, and its conversations kept in the book under retired`,
1150
+ ...leftLines(gone.promptsLeft),
1151
+ ]);
1152
+
1109
1153
  /** What a retire could not remove, and how to: nothing reads these files now (#393). */
1110
1154
  const leftLines = (left = []) => left.flatMap(({ file, reason }) => [
1111
1155
  `left ${file}: it could not be removed (${reason}). Nothing reads it now.`,
@@ -1247,6 +1291,7 @@ function conversationLine(one) {
1247
1291
  .join(' ');
1248
1292
  return ` ${one.id} ${one.calls} call${one.calls === 1 ? '' : 's'}`
1249
1293
  + `${one.subagent_calls > 0 ? ` of which subagents: ${one.subagent_calls} call${one.subagent_calls === 1 ? '' : 's'}` : ''}`
1294
+ + `${one.review_calls > 0 ? ` of which Codex's auto-review: ${one.review_calls} call${one.review_calls === 1 ? '' : 's'}` : ''}`
1250
1295
  + `${ran === '' ? '' : ` ${ran}`}`
1251
1296
  + `${used === '' ? '' : ` ${used}`}`
1252
1297
  + `${one.compactions > 0 ? ` compacted ${one.compactions}` : ''}`;
@@ -115,7 +115,8 @@ export function claudeSubagentTranscripts(file) {
115
115
  /**
116
116
  * The same conversations, each with the file the harness keeps it in, for a
117
117
  * caller that has to read what is inside one rather than only know it is there,
118
- * and whether the harness marks it as a subagent's.
118
+ * whether the harness marks it as a subagent's, and, for a Codex subagent, the
119
+ * conversation it was started for and whether it is Codex's auto-review.
119
120
  */
120
121
  export function transcriptsIn(harness, home, since) {
121
122
  const from = since === undefined ? 0 : Date.parse(since);
@@ -123,7 +124,13 @@ export function transcriptsIn(harness, home, since) {
123
124
  return found
124
125
  .filter((one) => Number.isNaN(from) || one.at >= from)
125
126
  .sort((left, right) => left.at - right.at)
126
- .map((one) => ({ id: one.id, at: new Date(one.at).toISOString(), file: one.file, subagent: one.subagent === true }));
127
+ .map((one) => ({
128
+ id: one.id,
129
+ at: new Date(one.at).toISOString(),
130
+ file: one.file,
131
+ subagent: one.subagent === true,
132
+ ...(one.parent === undefined ? {} : { parent: one.parent, review: one.review }),
133
+ }));
127
134
  }
128
135
 
129
136
  /**
@@ -209,11 +216,29 @@ function codexConversations(home, from) {
209
216
  .flatMap((file) => {
210
217
  const meta = sessionMeta(file);
211
218
  return meta?.cwd === home && typeof meta.id === 'string'
212
- ? [{ id: meta.id, at: Date.parse(meta.timestamp ?? '') || startedAt(file) || 0, file, subagent: isSubagent(meta) }]
219
+ ? [{ id: meta.id, at: Date.parse(meta.timestamp ?? '') || startedAt(file) || 0, file, subagent: isSubagent(meta), ...codexParent(meta) }]
213
220
  : [];
214
221
  });
215
222
  }
216
223
 
224
+ /**
225
+ * Every Codex subagent on record, wherever it ran, with the conversation it was
226
+ * started for: `{ id, file, parent, review }` (#449). Found by the link in its
227
+ * own record, not by folder: a session's worker can run in a folder of its own.
228
+ * Read once per process, since every bot asks the same question of the same
229
+ * files.
230
+ */
231
+ let subagents;
232
+ export function codexSubagents() {
233
+ subagents ??= rollouts(codexDir()).flatMap((file) => {
234
+ const meta = sessionMeta(file);
235
+ if (meta === undefined || typeof meta.id !== 'string') return [];
236
+ const link = codexParent(meta);
237
+ return link.parent === undefined ? [] : [{ id: meta.id, file, ...link }];
238
+ });
239
+ return subagents;
240
+ }
241
+
217
242
  /** Every rollout file under Codex's sessions folder, however deep it files them. */
218
243
  function rollouts(dir, depth = 0) {
219
244
  if (depth > 4) return [];
@@ -243,6 +268,21 @@ function sessionMeta(file) {
243
268
  const isSubagent = (meta) =>
244
269
  meta.source !== null && typeof meta.source === 'object' && 'subagent' in meta.source;
245
270
 
271
+ /**
272
+ * The conversation a Codex subagent was started for, as its own record names
273
+ * it, and whether it is Codex's auto-review: `{ parent, review }`, or nothing
274
+ * for a session's own (#449). A session's spawned subagent names it under
275
+ * `source.subagent.thread_spawn.parent_thread_id`; Codex's auto-review, which
276
+ * is `{ subagent: { other: 'guardian' } }`, names the conversation it reviewed
277
+ * as a top-level `parent_thread_id` (tech notes, section 3).
278
+ */
279
+ function codexParent(meta) {
280
+ if (!isSubagent(meta)) return {};
281
+ const parent = meta.source.subagent?.thread_spawn?.parent_thread_id ?? meta.parent_thread_id;
282
+ if (typeof parent !== 'string' || parent === '') return {};
283
+ return { parent, review: meta.source.subagent?.other === 'guardian' };
284
+ }
285
+
246
286
  /** When a file was last written, for leaving out what is plainly too old. */
247
287
  function lastTouched(file) {
248
288
  try {
package/src/hooks.js CHANGED
@@ -42,6 +42,18 @@ const TIMEOUT = 10;
42
42
  export const hookCommand = (bots, bot, cli = ownCli()) =>
43
43
  `${shellWord(cli)} session record --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
44
44
 
45
+ /**
46
+ * Claude Code's second kit hook: after a native `SendMessage`, a warning when
47
+ * it went outside the bots folder (ADR 0032). Claude Code only, since Codex has
48
+ * no native messaging.
49
+ */
50
+ const SENT_EVENT = 'PostToolUse';
51
+ const SENT_MATCHER = 'SendMessage';
52
+
53
+ /** What the send hook runs, by the kit's own path, as `hookCommand` does. */
54
+ export const sentCommand = (bots, bot, cli = ownCli()) =>
55
+ `${shellWord(cli)} session sent --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
56
+
45
57
  /**
46
58
  * Make sure the bot at `home` has the kit's SessionStart hook for `harness`.
47
59
  * Returns the file it wrote, relative to the bot home, or undefined when the
@@ -63,7 +75,9 @@ export function installHook(home, harness, { bots, bot }) {
63
75
  // path is corrected rather than doubled, an entry under an event the kit no
64
76
  // longer asks about is taken out, and a hook of the user's beside the kit's
65
77
  // stays where they put it.
66
- const wanted = { ...settings, hooks: withKitHook(settings.hooks, file, mine) };
78
+ const started = withKitHook(settings.hooks, file, mine);
79
+ const sentHook = { type: 'command', command: sentCommand(bots, bot), timeout: TIMEOUT };
80
+ const wanted = { ...settings, hooks: harness === 'claude' ? withSentHook(started, sentHook) : started };
67
81
 
68
82
  // Compared as documents, not as text: how the user laid their file out is
69
83
  // theirs, and a run that changes nothing writes nothing.
@@ -110,6 +124,27 @@ export function hookTrouble(home, harness, { bots, bot }) {
110
124
  }
111
125
  const program = running ?? ownCli();
112
126
  wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot, program), timeout: TIMEOUT }) };
127
+ // The send hook is judged on its own: the session hook may be right while
128
+ // the warning after a native message is missing (ADR 0032).
129
+ if (harness === 'claude' && isDeepStrictEqual(settings, wanted)) {
130
+ const sentRunning = sentProgramIn(settings);
131
+ // A send hook that runs a kit no longer there is as quiet as a missing
132
+ // one: `|| true` hides it, and no warning is ever given (#451 review).
133
+ if (sentRunning !== undefined && sentRunning !== BARE && !existsSync(sentRunning)) {
134
+ return {
135
+ where: file,
136
+ says: `${file} holds the kit's PostToolUse hook for SendMessage, but it runs ${sentRunning}, which is not there any more, so a session is never warned when its native message goes outside this bots folder (ADR 0032). obk up puts back one that runs the kit you have.`,
137
+ };
138
+ }
139
+ const sentProgram = sentRunning ?? program;
140
+ const withSent = { ...settings, hooks: withSentHook(settings.hooks, { type: 'command', command: sentCommand(bots, bot, sentProgram), timeout: TIMEOUT }) };
141
+ if (!isDeepStrictEqual(settings, withSent)) {
142
+ return {
143
+ where: file,
144
+ says: `${file} does not hold the kit's PostToolUse hook for SendMessage, which warns a session when its native message went outside this bots folder (ADR 0032). obk up puts it back.`,
145
+ };
146
+ }
147
+ }
113
148
  } catch (error) {
114
149
  return { where: file, says: `${error.message} ${stale}` };
115
150
  }
@@ -180,6 +215,58 @@ function withKitHook(hooks, file, mine) {
180
215
  return { ...events, [EVENT]: [...(events[EVENT] ?? []), { hooks: [mine] }] };
181
216
  }
182
217
 
218
+ /**
219
+ * The hooks with the kit's send hook in a `PostToolUse` group matched to
220
+ * `SendMessage`, and everything else as the user left it, as `withKitHook`
221
+ * keeps the session hook: one entry of the kit's, written in place where it
222
+ * already sits in such a group, taken out of anywhere else, and added in a
223
+ * group of its own when there is none. A group the kit empties goes.
224
+ */
225
+ function withSentHook(hooks, mine) {
226
+ let placed = false;
227
+ const events = Object.fromEntries(Object.entries(hooks).flatMap(([event, groups]) => {
228
+ if (!Array.isArray(groups)) return [[event, groups]];
229
+ const kept = groups.flatMap((group) => {
230
+ if (!Array.isArray(group?.hooks)) return [group];
231
+ const entries = group.hooks.flatMap((hook) => {
232
+ if (!isSentHook(hook)) return [hook];
233
+ if (event !== SENT_EVENT || group.matcher !== SENT_MATCHER || placed) return [];
234
+ placed = true;
235
+ return [mine];
236
+ });
237
+ return entries.length === 0 ? [] : [{ ...group, hooks: entries }];
238
+ });
239
+ return kept.length === 0 ? [] : [[event, kept]];
240
+ }));
241
+ if (placed) return events;
242
+ return { ...events, [SENT_EVENT]: [...(events[SENT_EVENT] ?? []), { matcher: SENT_MATCHER, hooks: [mine] }] };
243
+ }
244
+
245
+
246
+ /** Whether a hook entry is the kit's send hook, run by a kit, for any bots folder and bot. */
247
+ const isSentHook = (hook) => sentProgramOf(hook?.command) !== undefined;
248
+
249
+ function sentProgramOf(command) {
250
+ const found = typeof command === 'string' ? KIT_SENT.exec(command) : null;
251
+ if (found === null) return undefined;
252
+ const program = unquoted(found[1]);
253
+ return isKitCli(program) ? program : undefined;
254
+ }
255
+
256
+ /** What the kit's send hook in these settings runs, or undefined when there is none. */
257
+ function sentProgramIn(settings) {
258
+ for (const groups of Object.values(settings.hooks ?? {})) {
259
+ if (!Array.isArray(groups)) continue;
260
+ for (const group of groups) {
261
+ for (const hook of Array.isArray(group?.hooks) ? group.hooks : []) {
262
+ const program = sentProgramOf(hook?.command);
263
+ if (program !== undefined) return program;
264
+ }
265
+ }
266
+ }
267
+ return undefined;
268
+ }
269
+
183
270
  /**
184
271
  * The kit's own hook entry, wherever it sits and whatever sits beside it: a
185
272
  * command of exactly the shape `hookCommand` writes, for any bots folder and
@@ -194,6 +281,7 @@ const isKitHook = (hook) => kitProgramOf(hook?.command) !== undefined;
194
281
  /** One word as `shellWord` writes it: bare, or single-quoted with `'\''` inside. */
195
282
  const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
196
283
  const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
284
+ const KIT_SENT = new RegExp(String.raw`^(${WORD}) session sent --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
197
285
 
198
286
  /** The program a bot made before the kit named itself by path runs. */
199
287
  const BARE = 'obk';
package/src/retire.js CHANGED
@@ -25,6 +25,7 @@ import path from 'node:path';
25
25
  import { readBook, tabIdsIn, updateBook } from './book.js';
26
26
  import { botDir, dropSession, readBot } from './bot.js';
27
27
  import { deleteProject, findProject, projects, reloadWindow, tabs } from './orca.js';
28
+ import { shellWord } from './launch.js';
28
29
  import { fleetMember } from './pause.js';
29
30
  import { closeTabs, commandLine, tabsToClose } from './restart.js';
30
31
  import { unlinkSkills } from './skills.js';
@@ -34,26 +35,57 @@ import { promptPath, sessionsOf } from './up.js';
34
35
  export const retiredDir = (bots) => path.join(bots, 'retired');
35
36
 
36
37
  /**
37
- * Retire the session `session` of the bot `bot`. Returns `{ bot, session,
38
- * closed }`, and `promptsLeft` when its prompt file could not be removed.
38
+ * Retire the session `session` of the bot `bot`, and first the temporary
39
+ * sessions it made, and theirs (#464, ADR 0033). Returns `{ bot, session,
40
+ * closed, retiredWith }`, and `promptsLeft` when its prompt file could not be
41
+ * removed. `retiredWith` holds one `{ bot, session, maker, closed }` for each
42
+ * session that went with it, deepest first, with its own `promptsLeft`.
39
43
  */
40
44
  export async function retireSession(bots, { bot, session }) {
41
45
  const home = fleetMember(bots, bot, 'retire', session);
42
- const sessions = sessionsOf(readBot(home, bot), session);
46
+ const known = readBot(home, bot);
47
+ const sessions = sessionsOf(known, session);
43
48
 
44
- const closed = await closeTabs(home, tabsToClose(bots, bot, home, sessions, { keepless: true }), bots, bot, commandLine('retire', bots, bot, session));
45
- dropSession(bots, bot, session);
49
+ const retiredWith = [];
50
+ const book = readBook(home);
51
+ const made = known.sessions.filter((one) => book.sessions[one.name]?.temporary?.maker === session);
52
+ let closed;
53
+ try {
54
+ for (const { name } of made) {
55
+ const { retiredWith: theirs, ...gone } = await retireSession(bots, { bot, session: name });
56
+ retiredWith.push(...theirs, { ...gone, maker: session });
57
+ }
46
58
 
47
- const at = new Date().toISOString();
48
- await updateBook(home, (book) => {
49
- const entry = book.sessions[session];
50
- if (entry === undefined) return;
51
- delete book.sessions[session];
52
- book.retired = [...(Array.isArray(book.retired) ? book.retired : []), { name: session, ...entry, retired: at }];
53
- });
59
+ closed = await closeTabs(home, tabsToClose(bots, bot, home, sessions, { keepless: true }), bots, bot, commandLine('retire', bots, bot, session));
60
+ dropSession(bots, bot, session);
61
+
62
+ const at = new Date().toISOString();
63
+ await updateBook(home, (book) => {
64
+ const entry = book.sessions[session];
65
+ if (entry === undefined) return;
66
+ delete book.sessions[session];
67
+ book.retired = [...(Array.isArray(book.retired) ? book.retired : []), { name: session, ...entry, retired: at }];
68
+ });
69
+ } catch (error) {
70
+ // What already went stays gone, and is named: a retire run again finds
71
+ // nothing of it left to name (#464).
72
+ if (retiredWith.length > 0) error.message = `${error.message} ${goneBefore(session, retiredWith)}`;
73
+ throw error;
74
+ }
54
75
  const left = removePrompts([promptPath(bots, bot, session)]);
55
76
 
56
- return { bot, session, closed, ...left };
77
+ return { bot, session, closed, retiredWith, ...left };
78
+ }
79
+
80
+ /**
81
+ * The sessions retired along with `session` before its own retire failed, in
82
+ * words, with any prompt file of theirs left and how to remove it.
83
+ */
84
+ function goneBefore(session, retiredWith) {
85
+ const names = retiredWith.map((gone) => `${gone.bot}/${gone.session}, a temporary session of ${gone.maker}'s`);
86
+ const left = retiredWith.flatMap((gone) => gone.promptsLeft ?? [])
87
+ .map(({ file, reason }) => ` ${file} could not be removed (${reason}), and nothing reads it. Remove it with rm ${shellWord(file)}`);
88
+ return `Retired along with ${session} before that, and still retired: ${names.join('; ')}.${left.join('')}`;
57
89
  }
58
90
 
59
91
  /**
package/src/sent.js ADDED
@@ -0,0 +1,111 @@
1
+ // `obk session sent`: what the kit's PostToolUse hook says after a session's
2
+ // native message (#451, ADR 0032).
3
+ //
4
+ // Claude Code's own messaging reaches every Claude session on the machine, not
5
+ // only the sessions of one bots folder. After a send to another session, this
6
+ // looks at where it went: to a session address none of the fleet's books name,
7
+ // the session is told so, with the road the kit would have given it. The send
8
+ // has gone either way. Nothing here decides anything about it, and anything it
9
+ // cannot read is silence (ADR 0022: a hook never disturbs the session).
10
+ //
11
+ // It reads the send from its fields, never from its wording. Seen on Claude
12
+ // Code 2.1.283: a send to an in-process teammate answers with a `routing`
13
+ // object, and one to another session without it; the input's `to` names the
14
+ // target, as a session's name with an optional ` [xxxxxx]` reference, or as a
15
+ // `uds:` socket path when answering a message, which no book can map.
16
+
17
+ import { readBook } from './book.js';
18
+ import { botDir, botNames } from './bot.js';
19
+ import { isAddressOf, ownCli, shellWord } from './launch.js';
20
+ import { lookUp } from './message.js';
21
+ import { TAB_ENV } from './record.js';
22
+
23
+ /**
24
+ * The warning for a send the hook was told about, or undefined when there is
25
+ * nothing to say. `said` is what Claude Code handed the hook.
26
+ */
27
+ export function sentWarning(bots, said) {
28
+ if (said?.tool_name !== 'SendMessage') return undefined;
29
+ const to = said.tool_input?.to;
30
+ if (typeof to !== 'string') return undefined;
31
+ const result = resultOf(said);
32
+ // A teammate in the same session, or a send that did not go: not this.
33
+ if (result === undefined || result.success !== true || 'routing' in result) return undefined;
34
+ if (/^(uds|bridge):/.test(to)) return undefined;
35
+
36
+ const name = to.replace(/\s*\[[^\]]*\]\s*$/, '').trim();
37
+ if (name === '') return undefined;
38
+ const fleet = addressesIn(bots);
39
+ if (fleet === undefined || fleet.all.has(name)) return undefined;
40
+
41
+ const meant = fleet.byName.get(name.split('.').slice(0, 2).join('.'));
42
+ const ask = (to) => `\`${shellWord(ownCli())} message to --bots ${shellWord(bots)} --to ${to}\``;
43
+ const road = meant === undefined
44
+ ? `${ask('<bot>/<session>')} gives the road to a session of this fleet.`
45
+ : roadTo(bots, meant, ask(`${meant.bot}/${meant.session}`));
46
+ return `That message went to ${name}, which is not one of the sessions of your bots folder, ${bots}: Claude Code's messaging reaches every session on this machine. ${road} Check before you send to it again.`;
47
+ }
48
+
49
+ /**
50
+ * The road to the session the sender probably meant, as `obk message to` would
51
+ * answer it from the sender's own tab: native to its address only between
52
+ * sessions of one approval class, the kit's mailbox otherwise (PRD 6.9). Where
53
+ * the sender cannot be told by its tab, the session's address and the question
54
+ * to ask, without claiming the address is the answer (#451 review).
55
+ */
56
+ function roadTo(bots, meant, ask) {
57
+ let found;
58
+ try {
59
+ found = lookUp(bots, { to: `${meant.bot}/${meant.session}`, tab: process.env[TAB_ENV] });
60
+ } catch {
61
+ found = undefined;
62
+ }
63
+ const who = `${meant.bot}/${meant.session}`;
64
+ if (found?.transport === 'native' && typeof found.address === 'string') {
65
+ return `If you meant ${who}, ${ask} gives the road from your session: write to ${found.address} with your own messaging.`;
66
+ }
67
+ if (found?.transport === 'orca' && typeof found.address === 'string') {
68
+ return `If you meant ${who}, ${ask} gives the road from your session: its mailbox, ${found.address}, by \`${shellWord(ownCli())} message send\`, not your own messaging.`;
69
+ }
70
+ return `If you meant ${who}, its session address is ${meant.address}; ${ask} says which road to take from your session.`;
71
+ }
72
+
73
+ /** What the send answered, as an object: Claude Code hands it over as one, or as its JSON. */
74
+ function resultOf(said) {
75
+ const raw = said.tool_output ?? said.tool_response;
76
+ if (raw !== null && typeof raw === 'object' && !Array.isArray(raw)) return raw;
77
+ if (typeof raw !== 'string') return undefined;
78
+ try {
79
+ const parsed = JSON.parse(raw);
80
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Every session address the fleet's books name, retired sessions' included, and
88
+ * each live one by `<bot>.<session>`: `{ all, byName }`. Undefined when a book
89
+ * cannot be read, since an address missing from it would be warned about wrongly.
90
+ */
91
+ function addressesIn(bots) {
92
+ const all = new Set();
93
+ const byName = new Map();
94
+ try {
95
+ for (const bot of botNames(bots)) {
96
+ const book = readBook(botDir(bots, bot));
97
+ for (const [session, entry] of Object.entries(book.sessions ?? {})) {
98
+ const address = entry?.address;
99
+ if (typeof address !== 'string') continue;
100
+ all.add(address);
101
+ if (isAddressOf(bot, session, address)) byName.set(`${bot}.${session}`, { bot, session, address });
102
+ }
103
+ for (const entry of Array.isArray(book.retired) ? book.retired : []) {
104
+ if (typeof entry?.address === 'string') all.add(entry.address);
105
+ }
106
+ }
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ return { all, byName };
111
+ }
package/src/temp.js CHANGED
@@ -4,7 +4,8 @@
4
4
  //
5
5
  // Any long-lived session can make a temporary session of its own bot for a
6
6
  // piece of work, and retire it when the work is done, without Bot Father, who
7
- // stays the manager of long-lived sessions. A temporary session is an ordinary
7
+ // stays the manager of long-lived sessions. A temporary session can make one
8
+ // of its own, one level deep and one at a time (#464, ADR 0033). A temporary session is an ordinary
8
9
  // session in every other way: in bot.yaml, brought up with a tab and a mailbox,
9
10
  // its conversations kept in the book. What makes it temporary is one field of
10
11
  // its book entry, `temporary: { maker, made }`, and the book is the one place
@@ -44,9 +45,9 @@ const INHERITED = ['model', 'effort', 'context', 'approval'];
44
45
  */
45
46
  export async function makeTemp(bots, { tab, ...given }) {
46
47
  const caller = callerIn(bots, tab, 'make');
47
- if (caller.temporary !== undefined) {
48
- throw new Error(`${caller.bot}/${caller.session} is a temporary session, and only a long-lived session makes one. Nothing was made.`);
49
- }
48
+ // Checked here, before anything is written, and again under the book's lock
49
+ // below, where two makes at once cannot both pass it.
50
+ refuseNested(bots, caller, readBook(caller.home));
50
51
  // The name is a folder under work/ and a file beside the bots folder, so it
51
52
  // is held to the rule a bot's name is: one plain name, never a path.
52
53
  if (!NAME.test(given.name)) {
@@ -77,38 +78,44 @@ export async function makeTemp(bots, { tab, ...given }) {
77
78
  settings.work_dir = `work/${given.name}`;
78
79
 
79
80
  const written = bot.sessions.some((session) => session.name === given.name) ? undefined : writePromptFirst(bots, caller, settings);
80
- // Refuses a name the bot already has, and a setting that will not work,
81
- // before it writes anything; the prompt written first goes with a refusal.
82
- let added;
83
- try {
84
- added = addSession(bots, caller.bot, settings);
85
- } catch (error) {
86
- if (written !== undefined) rmSync(written, { force: true });
87
- throw error;
88
- }
81
+ // bot.yaml and the book are written under the book's lock, so that a second
82
+ // make from the same maker waits, and then sees this one (#464). addSession
83
+ // refuses a name the bot already has, and a setting that will not work,
84
+ // before it writes anything.
89
85
  const made = new Date().toISOString();
86
+ let locked = false;
87
+ let added;
90
88
  try {
91
89
  await updateBook(caller.home, (book) => {
90
+ locked = true;
91
+ refuseNested(bots, caller, book);
92
+ added = addSession(bots, caller.bot, settings);
92
93
  book.sessions[given.name] = { ...book.sessions[given.name], temporary: { maker: caller.session, made } };
93
94
  });
94
95
  } catch (error) {
96
+ // A refusal under the lock has written nothing but the prompt file.
97
+ const refused = locked && added === undefined;
98
+ const failed = `${caller.bot}/${given.name} could not be recorded in the book as temporary (${error.message})`;
95
99
  // A session in bot.yaml that the book does not call temporary would be a
96
100
  // long-lived one to everything that reads it, its maker's retire included,
97
101
  // so it is taken back off rather than left that way.
98
- const failed = `${caller.bot}/${given.name} could not be recorded in the book as temporary (${error.message})`;
99
- try {
100
- dropSession(bots, caller.bot, given.name);
101
- } catch (undo) {
102
- throw new Error(`${failed}, and taking it back off bot.yaml failed too (${undo.message}). It is in bot.yaml as a session the book does not call temporary. Take it off with ${shellWord(ownCli())} retire --bots ${shellWord(bots)} --bot ${caller.bot} --session ${given.name}`);
102
+ if (added !== undefined) {
103
+ try {
104
+ dropSession(bots, caller.bot, given.name);
105
+ } catch (undo) {
106
+ throw new Error(`${failed}, and taking it back off bot.yaml failed too (${undo.message}). It is in bot.yaml as a session the book does not call temporary. Take it off with ${shellWord(ownCli())} retire --bots ${shellWord(bots)} --bot ${caller.bot} --session ${given.name}`);
107
+ }
103
108
  }
104
109
  // Off bot.yaml, nothing is made; a prompt file that stays is only a file,
105
110
  // and nothing reads it (#393).
111
+ const why = refused ? error.message.replace(/ ?Nothing was made\.$/, '') : `${failed}${added === undefined ? '.' : ', so it was taken back off bot.yaml.'}`;
106
112
  try {
107
113
  if (written !== undefined) rmSync(written, { force: true });
108
114
  } catch (left) {
109
- throw new Error(`${failed}, so it was taken back off bot.yaml and nothing was made. Its start prompt ${written} could not be removed (${left.message}), and nothing reads it. Remove it with rm ${shellWord(written)}`);
115
+ throw new Error(`${why} Nothing was made. Its start prompt ${written} could not be removed (${left.message}), and nothing reads it. Remove it with rm ${shellWord(written)}`);
110
116
  }
111
- throw new Error(`${failed}, so it was taken back off bot.yaml and nothing was made. Run this again once the book can be written.`);
117
+ if (refused) throw error;
118
+ throw new Error(`${why} Nothing was made. Run this again once the book can be written.`);
112
119
  }
113
120
 
114
121
  let up;
@@ -121,6 +128,23 @@ export async function makeTemp(bots, { tab, ...given }) {
121
128
  return { bot: caller.bot, session: given.name, maker: caller.session, settings: added.session, up };
122
129
  }
123
130
 
131
+ /**
132
+ * Refuse a make by a temporary session that may not make one now, against
133
+ * `book`: one made by a temporary session makes none, and a temporary session
134
+ * has one of its own open at a time (#464, ADR 0033).
135
+ */
136
+ function refuseNested(bots, caller, book) {
137
+ if (caller.temporary === undefined) return;
138
+ const maker = caller.temporary.maker;
139
+ if (book.sessions[maker]?.temporary !== undefined) {
140
+ throw new Error(`${caller.bot}/${caller.session} is a temporary session ${maker} made, and ${maker} is temporary itself, so it makes none of its own: temporary sessions go one level deep. Nothing was made.`);
141
+ }
142
+ const open = Object.keys(book.sessions).filter((name) => book.sessions[name]?.temporary?.maker === caller.session);
143
+ if (open.length > 0) {
144
+ throw new Error(`${caller.bot}/${caller.session} is a temporary session, and it already has ${open.join(', ')} open; a temporary session has one of its own at a time. Retire it first with ${shellWord(ownCli())} temp retire --bots ${shellWord(bots)} --name ${open[0]} Nothing was made.`);
145
+ }
146
+ }
147
+
124
148
  /**
125
149
  * Write the start prompt's file where bringing the session up will write it,
126
150
  * when its prompt goes to the harness out of a file at all, and say where. A
package/src/usage.js CHANGED
@@ -43,7 +43,7 @@ import { realpathSync } from 'node:fs';
43
43
 
44
44
  import { botDir, botNames, readBot } from './bot.js';
45
45
  import { readBook, sessionIdsIn } from './book.js';
46
- import { claudeSubagentTranscripts, claudeTranscriptAnywhere, codexRollout, transcriptsIn } from './conversations.js';
46
+ import { claudeSubagentTranscripts, claudeTranscriptAnywhere, codexRollout, codexSubagents, transcriptsIn } from './conversations.js';
47
47
  import { eachLine } from './lines.js';
48
48
  import { HARNESSES, harnessOf } from './launch.js';
49
49
 
@@ -91,11 +91,41 @@ export function readUsage(bots, { bot: only, session: onlySession, since, until
91
91
  throw new Error(`--until is before --since, so there is no window between them: ${until} comes before ${since}.`);
92
92
  }
93
93
 
94
- return (only === undefined ? names : [only]).map((name) => forBot(bots, name, onlySession, { from, to }));
94
+ // Who owns a Codex subagent is a question about the whole fleet, asked once:
95
+ // one can run in another bot's folder, or be claimed by another bot's book.
96
+ const fleet = subagentOwners(bots, names);
97
+ return (only === undefined ? names : [only]).map((name) => forBot(bots, name, onlySession, { from, to }, fleet));
98
+ }
99
+
100
+ /**
101
+ * Every conversation any bot's book names, the Codex subagents that hang off
102
+ * each by their own links, and the ones so attached (#449). A subagent a book
103
+ * names is that book's, and is never attached under another: the book's word
104
+ * comes first (ADR 0012). One claimed or attached anywhere is no bot's
105
+ * unclaimed.
106
+ */
107
+ function subagentOwners(bots, names) {
108
+ const claimed = new Set();
109
+ for (const name of names) for (const id of sessionIdsIn(readBook(realHome(botDir(bots, name))))) claimed.add(id);
110
+ const childrenOf = new Map();
111
+ for (const one of codexSubagents()) {
112
+ if (claimed.has(one.id)) continue;
113
+ if (!childrenOf.has(one.parent)) childrenOf.set(one.parent, []);
114
+ childrenOf.get(one.parent).push(one);
115
+ }
116
+ const attached = new Set();
117
+ const waiting = [...claimed].flatMap((id) => childrenOf.get(id) ?? []);
118
+ while (waiting.length > 0) {
119
+ const child = waiting.shift();
120
+ if (attached.has(child.id)) continue;
121
+ attached.add(child.id);
122
+ waiting.push(...(childrenOf.get(child.id) ?? []));
123
+ }
124
+ return { claimed, childrenOf, attached };
95
125
  }
96
126
 
97
127
  /** One bot: what each of its sessions used, and what nobody claims. */
98
- function forBot(bots, name, onlySession, window) {
128
+ function forBot(bots, name, onlySession, window, fleet) {
99
129
  // The folder as the file system knows it, not as the caller spelled it: a
100
130
  // bots folder reached through a symlink is the same fleet, and the harnesses
101
131
  // file their transcripts under the real path (as `restart` and `message` do).
@@ -138,6 +168,31 @@ function forBot(bots, name, onlySession, window) {
138
168
  }
139
169
  }
140
170
 
171
+ // A Codex subagent names the conversation it was started for, and what it
172
+ // spent is that conversation's, at any depth, as a Claude Code subagent's is
173
+ // (#449). It is found by that link wherever it ran, and joined only through
174
+ // links that end at a conversation the book names, as the fleet's owners
175
+ // say: a parent no session claims leaves its children where they were, and
176
+ // nothing is guessed from timing or folder. The book's word is enough
177
+ // without the parent's own file, whose calls are then said to be unreadable.
178
+ const { childrenOf } = fleet;
179
+ const attached = new Set();
180
+ for (const id of claimed) {
181
+ const own = onRecord.get(id);
182
+ if (own !== undefined && own.harness !== 'codex') continue;
183
+ const found = [];
184
+ const waiting = [...(childrenOf.get(id) ?? [])];
185
+ while (waiting.length > 0) {
186
+ const child = waiting.shift();
187
+ if (attached.has(child.id)) continue;
188
+ attached.add(child.id);
189
+ // A review of a subagent is still the harness's approval, not the work.
190
+ found.push({ file: child.file, kind: child.review ? 'review' : 'subagent' });
191
+ waiting.push(...(childrenOf.get(child.id) ?? []));
192
+ }
193
+ if (found.length > 0) onRecord.set(id, { id, harness: 'codex', subagent: false, ...own, children: found });
194
+ }
195
+
141
196
  // Two entries can name the same conversation, one resumed after its session
142
197
  // was retired, and one entry can name it twice, now and in its history: it is
143
198
  // counted once, in the first block asked for that names it.
@@ -155,7 +210,9 @@ function forBot(bots, name, onlySession, window) {
155
210
  .filter(({ name }) => onlySession === undefined || name === onlySession)
156
211
  .map(({ name, entry, ...was }) => ({ name, ...('retired' in was ? { retired: was.retired } : {}), ...block(entry) }));
157
212
 
158
- const unclaimed = all([...onRecord.values()].filter((one) => !claimed.has(one.id)), window);
213
+ // What any bot's book names, or any claim gathered in, is not unclaimed here,
214
+ // wherever its record lies (#449 review).
215
+ const unclaimed = all([...onRecord.values()].filter((one) => !fleet.claimed.has(one.id) && !fleet.attached.has(one.id)), window);
159
216
 
160
217
  return { bot: name, home, sessions, unclaimed: unclaimed.conversations, unclaimed_not_counted: unclaimed.gaps };
161
218
  }
@@ -187,6 +244,7 @@ function counted(one, window) {
187
244
  const tally = {
188
245
  calls: 0,
189
246
  subagentCalls: 0,
247
+ reviewCalls: 0,
190
248
  compactions: 0,
191
249
  tokens: Object.fromEntries(KINDS.map((kind) => [kind, 0])),
192
250
  models: new Set(),
@@ -203,7 +261,10 @@ function counted(one, window) {
203
261
  // own, and what they spent is the conversation's.
204
262
  const subagents = one.harness === 'claude' ? claudeSubagentTranscripts(one.file) : { files: [], unreadable: 0 };
205
263
  tally.gaps.unreadable_transcripts += subagents.unreadable;
206
- const sources = [one.file, ...subagents.files]
264
+ // A Codex conversation the book names, whose own record is gone but whose
265
+ // subagents' are not: its own calls cannot be counted, and that is said (#449).
266
+ if (one.file === undefined) tally.gaps.unreadable_transcripts += 1;
267
+ const sources = [...(one.file === undefined ? [] : [one.file]), ...subagents.files]
207
268
  .map((file, index) => {
208
269
  const { entries, unreadable, broken } = transcript(file, countable);
209
270
  tally.gaps.unreadable_transcripts += unreadable;
@@ -212,8 +273,19 @@ function counted(one, window) {
212
273
  });
213
274
  if (one.harness === 'claude') fromClaude(sources, window, tally);
214
275
  else {
215
- const copied = originOf(sources[0].entries, tally);
216
- if (copied !== null) fromCodex(sources[0].entries, window, tally, copied);
276
+ if (one.file !== undefined) {
277
+ const copied = originOf(sources[0].entries, tally);
278
+ if (copied !== null) fromCodex(sources[0].entries, window, tally, copied);
279
+ }
280
+ // Each subagent's rollout keeps its own running total, so each is counted
281
+ // on its own, from its own records (#449).
282
+ for (const child of one.children ?? []) {
283
+ const { entries, unreadable, broken } = transcript(child.file, countable);
284
+ tally.gaps.unreadable_transcripts += unreadable;
285
+ tally.gaps.broken_lines += broken;
286
+ const itsCopied = originOf(entries, tally);
287
+ if (itsCopied !== null) fromCodex(entries, window, tally, itsCopied, child.kind);
288
+ }
217
289
  }
218
290
  if (tally.calls === 0 && KINDS.every((kind) => tally.tokens[kind] === 0)) return { gaps: tally.gaps };
219
291
 
@@ -221,6 +293,7 @@ function counted(one, window) {
221
293
  id: one.id,
222
294
  calls: tally.calls,
223
295
  subagent_calls: tally.subagentCalls,
296
+ review_calls: tally.reviewCalls,
224
297
  tokens: tally.tokens,
225
298
  by_model: [...tally.byModel].map(([model, its]) => ({ model, calls: its.calls, tokens: its.tokens })),
226
299
  models: [...tally.models],
@@ -309,7 +382,7 @@ function fromClaude(sources, window, tally) {
309
382
  if (!isNew && KINDS.every((kind) => grew[kind] === 0)) continue;
310
383
 
311
384
  const subagent = records.every((record) => record.subagent);
312
- count(tally, grew, atEnd.entry.message?.model, atEnd.entry.effort, isNew ? made : atEnd.when, isNew ? 1 : 0, subagent);
385
+ count(tally, grew, atEnd.entry.message?.model, atEnd.entry.effort, isNew ? made : atEnd.when, isNew ? 1 : 0, subagent ? 'subagent' : undefined);
313
386
  }
314
387
  }
315
388
 
@@ -380,7 +453,7 @@ const CODEX_FIELDS = [
380
453
  * A leading record with a figure missing may be a copy or the fork's own call;
381
454
  * it is not counted, and is said to be.
382
455
  */
383
- function fromCodex(entries, window, tally, copied) {
456
+ function fromCodex(entries, window, tally, copied, kind) {
384
457
  let model;
385
458
  let effort;
386
459
  let running;
@@ -451,7 +524,7 @@ function fromCodex(entries, window, tally, copied) {
451
524
  continue;
452
525
  }
453
526
 
454
- count(tally, used, model, effort, when);
527
+ count(tally, used, model, effort, when, 1, kind);
455
528
  }
456
529
  }
457
530
 
@@ -546,9 +619,10 @@ function kindsOf(raw) {
546
619
  * the total that has to be complete, and a row headed by nothing would be worse
547
620
  * than no row.
548
621
  */
549
- function count(tally, used, model, effort, when, calls = 1, subagent = false) {
622
+ function count(tally, used, model, effort, when, calls = 1, kind = undefined) {
550
623
  tally.calls += calls;
551
- if (subagent) tally.subagentCalls += calls;
624
+ if (kind === 'subagent') tally.subagentCalls += calls;
625
+ if (kind === 'review') tally.reviewCalls += calls;
552
626
  for (const kind of KINDS) tally.tokens[kind] += used[kind];
553
627
  add(tally.models, model);
554
628
  add(tally.efforts, effort);