@assuredloop/orca-bot-kit 0.19.0 → 0.20.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
@@ -615,7 +615,9 @@ real Orca and the real harnesses on your own machine. No CI runner can do that,
615
615
  so these are run by hand before a change that touches Orca or a harness is
616
616
  merged. A system test touches only what it creates and removes it afterwards,
617
617
  except the mailbox Runs Orca gives no way to delete, which the run names; it
618
- never closes a tab it did not open.
618
+ never closes a tab it did not open. The trust its harnesses write into your
619
+ `~/.codex/config.toml` and `~/.claude.json` for the run's own throwaway folders
620
+ is taken out again after the run, and named; nothing else in them is changed.
619
621
 
620
622
  They run on the machine the command is typed on, so the command will not start
621
623
  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.20.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": {
@@ -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
@@ -217,15 +217,15 @@ Usage:
217
217
  obk usage --bots <path> [--bot <bot>] [--session <name>] [--since <time>] [--until <time>]
218
218
  Say what your sessions have used: the conversations
219
219
  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.
220
+ subagents' and Codex's auto-reviews included, and
221
+ how many were whose), the models and efforts they
222
+ ran at, and how often they were compacted. --since
223
+ counts the calls made from that moment on, and
224
+ --until the calls made before that one: a daily run
225
+ asks from the last run's end up to its own, and the
226
+ next starts where it stopped, so no call is counted
227
+ twice. It counts tokens and never money: what a
228
+ token costs is looked up live by whoever is asking.
229
229
  obk session record --bots <path> --bot <bot>
230
230
  For the kit's own hook, not for typing: it reads
231
231
  what the harness says about a session starting on
@@ -1247,6 +1247,7 @@ function conversationLine(one) {
1247
1247
  .join(' ');
1248
1248
  return ` ${one.id} ${one.calls} call${one.calls === 1 ? '' : 's'}`
1249
1249
  + `${one.subagent_calls > 0 ? ` of which subagents: ${one.subagent_calls} call${one.subagent_calls === 1 ? '' : 's'}` : ''}`
1250
+ + `${one.review_calls > 0 ? ` of which Codex's auto-review: ${one.review_calls} call${one.review_calls === 1 ? '' : 's'}` : ''}`
1250
1251
  + `${ran === '' ? '' : ` ${ran}`}`
1251
1252
  + `${used === '' ? '' : ` ${used}`}`
1252
1253
  + `${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/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);