@assuredloop/orca-bot-kit 0.11.0 → 0.13.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
@@ -587,10 +587,11 @@ The tests come in two layers, and every test file belongs to one of them.
587
587
 
588
588
  `npm test` is `test/*.test.js`: unit tests and end-to-end runs of the CLI in a
589
589
  throwaway folder, with a fake `orca` on PATH. It needs nothing but Node, so
590
- [GitHub Actions](.github/workflows/ci.yml) runs it on every pull request and on
591
- every push to `main`: on Node 25.8.0, the current line, and again on 24.21.0,
592
- the floor `engines.node` promises users, with only the kit's own dependencies
593
- installed.
590
+ [GitHub Actions](.github/workflows/ci.yml) runs it on every pull request, split
591
+ into shards that run side by side, on Node 25.8.0, the current line. A release
592
+ runs every shard on 25.8.0 and again on 24.21.0, the floor `engines.node`
593
+ promises users, with only the kit's own dependencies installed, before anything
594
+ is published.
594
595
 
595
596
  `npm run test:system` is `test/system/*.test.js`: the real `obk` against the
596
597
  real Orca and the real harnesses on your own machine. No CI runner can do that,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,6 +24,7 @@
24
24
  ],
25
25
  "scripts": {
26
26
  "test": "node --test test/*.test.js",
27
+ "test:shard": "node --test --test-shard=$SHARD test/*.test.js",
27
28
  "test:system": "node scripts/test-system.js",
28
29
  "mutate": "node scripts/mutate.js"
29
30
  },
package/rules/limits.md CHANGED
@@ -18,5 +18,5 @@ When something you were told to use is missing (a model, a tool, a file), say
18
18
  so and ask. Quietly using a different one hides the change.
19
19
 
20
20
  Permission rules in a harness's settings or rules files are the kit's to write,
21
- after the user's yes to the exact rules: `obk bot change --allow <rule>` records
22
- and writes them. Never edit them into such a file yourself, Bot Father included.
21
+ after the user's yes to the exact rules: `obk bot change --allow <rule>` or
22
+ `--disallow <rule>`. Never edit one in or out yourself, Bot Father included.
@@ -226,9 +226,12 @@ such as an `Edit` rule or one exact command.
226
226
  Show the user the list word for word, with a line on what each lets the bot do,
227
227
  and only after their yes run `obk bot change --allow`, once per rule they said
228
228
  yes to. On a no, run nothing. `obk bot change --charter` names the rules the
229
- bot is allowed now; say which of them the new charter no longer grants, and
230
- that they stay allowed until the user takes them out, since the kit takes none
231
- out. Keep
229
+ bot is allowed now; say which of them the new charter no longer grants, show
230
+ each word for word, and ask whether to take it back. Only after their yes run
231
+ `obk bot change --disallow`, once per rule, which takes it out of `bot.yaml`
232
+ and the bot's settings or Codex rules; on a no, run nothing, and it stays
233
+ allowed. A rule the user added to a settings file by hand is theirs to take
234
+ out; the kit refuses it. Keep
232
235
  each rule narrow: a program and what it does. The kit refuses a rule that lets
233
236
  the bot run any command, a program with any arguments, or a shell or
234
237
  interpreter with any arguments; a user who wants one for a bot adds it to that
package/src/book.js CHANGED
@@ -191,12 +191,17 @@ function lockOn(file, waitMs) {
191
191
  * is nobody's to sweep, and the user can see it and delete it.
192
192
  *
193
193
  * One folder per bots folder, so two bots folders each holding an api-bot cannot
194
- * take each other's turn.
194
+ * take each other's turn. Resolved first, as the mailbox's is: one writer reaches
195
+ * the bots folder through a link and another by its real path, and two paths
196
+ * would be two locks for one book (#375).
195
197
  */
196
- const lockFile = (home) => path.join(
197
- `${path.dirname(path.dirname(home))}.locks`,
198
- `${encodeURIComponent(path.basename(home))}.lock`,
199
- );
198
+ const lockFile = (home) => {
199
+ const real = realpathSync(home);
200
+ return path.join(
201
+ `${path.dirname(path.dirname(real))}.locks`,
202
+ `${encodeURIComponent(path.basename(real))}.lock`,
203
+ );
204
+ };
200
205
 
201
206
  /**
202
207
  * One session's turn at its mailbox (#321): held by `session mailbox` for its
package/src/bot.js CHANGED
@@ -254,14 +254,14 @@ export function changeBot(bots, bot, { charter }) {
254
254
  }
255
255
 
256
256
  /**
257
- * The bot's `allow` list as it is, after refusing what `allowRules` would refuse:
258
- * an empty rule, or a list already there that is not a list of rules. Asked
259
- * before anything is written, so a refused `bot change` changes nothing, its
260
- * charter included.
257
+ * The bot's `allow` list as it is, after refusing what `allowRules` or
258
+ * `disallowRules` would refuse: an empty rule, or a list already there that is
259
+ * not a list of rules. Asked before anything is written, so a refused `bot
260
+ * change` changes nothing, its charter included.
261
261
  */
262
- export function allowedNow(bots, bot, rules) {
262
+ export function allowedNow(bots, bot, rules, flag = '--allow') {
263
263
  if (rules.some((rule) => rule.trim() === '')) {
264
- throw new Error('--allow is empty. Give it the exact permission rule the user said yes to, such as Bash(git add:*).');
264
+ throw new Error(`${flag} is empty. Give it the exact permission rule the user said yes to, such as Bash(git add:*).`);
265
265
  }
266
266
  const home = existingBot(bots, bot);
267
267
  const was = readBot(home, bot).allow ?? [];
@@ -277,18 +277,34 @@ export function allowedNow(bots, bot, rules) {
277
277
  * added }: the whole list now, and what this call put in it.
278
278
  *
279
279
  * Only the list: the rules are written into the harness's settings by the
280
- * caller, as the rules build is run by the caller of `changeBot`.
280
+ * caller, as the rules build is run by the caller of `changeBot`. With `write`
281
+ * false it only refuses what the edit would refuse, and writes nothing.
281
282
  */
282
- export function allowRules(bots, bot, rules) {
283
+ export function allowRules(bots, bot, rules, { write = true } = {}) {
283
284
  const { home, was } = allowedNow(bots, bot, rules);
284
285
  const added = [...new Set(rules)].filter((rule) => !was.includes(rule));
285
286
  const allow = [...was, ...added];
286
287
  if (added.length > 0) {
287
- editBot(bots, bot, `allow ${added.join(', ')} for ${bot}`, (doc) => doc.set('allow', allow), (before) => ({ ...before, allow }));
288
+ editBot(bots, bot, `allow ${added.join(', ')} for ${bot}`, (doc) => doc.set('allow', allow), (before) => ({ ...before, allow }), write);
288
289
  }
289
290
  return { bot, home, allow, added };
290
291
  }
291
292
 
293
+ /**
294
+ * Take the permission rules the user said yes to taking back, `rules`, out of
295
+ * the bot's `allow` (#360). Returns { bot, home, allow, disallowed }: the whole
296
+ * list now, and what this call took out. The caller has refused a rule `allow`
297
+ * does not hold, and takes them out of the harness's files first. With `write`
298
+ * false it only refuses what the edit would refuse, and writes nothing.
299
+ */
300
+ export function disallowRules(bots, bot, rules, { write = true } = {}) {
301
+ const { home, was } = allowedNow(bots, bot, rules, '--disallow');
302
+ const disallowed = [...new Set(rules)];
303
+ const allow = was.filter((rule) => !disallowed.includes(rule));
304
+ editBot(bots, bot, `take back ${disallowed.join(', ')} for ${bot}`, (doc) => doc.set('allow', allow), (before) => ({ ...before, allow }), write);
305
+ return { bot, home, allow, disallowed };
306
+ }
307
+
292
308
  /**
293
309
  * Change the settings of the session `name` of the bot `bot`. Returns
294
310
  * { bot, home, session } — the session as it now reads.
@@ -387,18 +403,24 @@ function editSession(bots, bot, index, session, what) {
387
403
 
388
404
  /**
389
405
  * Make one edit to a bot's `bot.yaml` through the YAML library, and write it
390
- * only if exactly the change `expected` describes is what came out.
406
+ * only if exactly the change `expected` describes is what came out, and
407
+ * `write` is not false.
391
408
  */
392
- function editBot(bots, bot, what, edit, expected) {
409
+ function editBot(bots, bot, what, edit, expected, write = true) {
393
410
  const file = path.join(existingBot(bots, bot), BOT_YAML);
394
411
  const source = readFileSync(file, 'utf8');
395
412
  const doc = parseDocument(source);
396
413
  edit(doc);
397
- const text = doc.toString(YAML_OUT);
398
- if (!changesExactly(source, text, expected)) {
399
- throw new Error(`${file} cannot be changed to ${what} without changing something else in it, so nothing was written. Make the change by hand.`);
414
+ const refusal = new Error(`${file} cannot be changed to ${what} without changing something else in it, so nothing was written. Make the change by hand.`);
415
+ let text;
416
+ try {
417
+ // An edit that leaves an alias with no anchor cannot be written at all.
418
+ text = doc.toString(YAML_OUT);
419
+ } catch {
420
+ throw refusal;
400
421
  }
401
- if (text !== source) writeFileSync(file, text);
422
+ if (!changesExactly(source, text, expected)) throw refusal;
423
+ if (write && text !== source) writeFileSync(file, text);
402
424
  }
403
425
 
404
426
  /** A bot that is there, by its home, or the refusal a caller who named another is owed. */
package/src/cli.js CHANGED
@@ -12,14 +12,14 @@ import path from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { parseArgs } from 'node:util';
14
14
 
15
- import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, leadsOutside, readBot, SESSION_FIELDS } from './bot.js';
15
+ import { addSession, allowedNow, allowRules, botDir, changeBot, changeSession, createBot, disallowRules, leadsOutside, readBot, SESSION_FIELDS } from './bot.js';
16
16
  import { addCommand, groomCommand, grooming, upCommand } from './groom.js';
17
17
  import { checkHealth, orcaSettingFindings } from './health.js';
18
18
  import { initBots } from './init.js';
19
19
  import { APPROVALS, HARNESSES, ownCli, shellWord, workDirOf } from './launch.js';
20
20
  import { checkMail, lookUp, noMailboxYet, sendMessage } from './message.js';
21
21
  import { orcaCli, orcaTrouble, RELOAD_LINE } from './orca.js';
22
- import { allowCommand, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, writePermissions } from './permissions.js';
22
+ import { allowCommand, allowIn, beyondDefaults, refuseBroad, refuseNoCodexForm, runsOnClaude, runsOnCodex, takeBack, writePermissions } from './permissions.js';
23
23
  import { pauseSessions, unpauseSessions } from './pause.js';
24
24
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
25
25
  import { restartSessions } from './restart.js';
@@ -56,13 +56,17 @@ Usage:
56
56
  as ours: --prompt='- a bullet', and
57
57
  --extra-arg=--search, once per extra argument.
58
58
  obk bot change --bots <path> --bot <bot> [--charter <text>]
59
- [--allow <rule> ...]
59
+ [--allow <rule> ... | --disallow <rule> ...]
60
60
  Give a bot a new charter and rebuild its AGENTS.md.
61
61
  A running session reads it when it next starts.
62
62
  --allow records a permission rule the user said
63
63
  yes to, once per rule, and writes it into the bot's
64
64
  Claude settings. A broad rule, such as Bash(gh:*),
65
65
  is refused and nothing is written.
66
+ --disallow takes back a rule the bot was allowed,
67
+ once the user has said yes to that, from bot.yaml
68
+ and the bot's settings. A rule the user added by
69
+ hand is not the kit's, and is refused.
66
70
  obk session change --bots <path> --bot <bot> --session <session>
67
71
  [--model <m>] [--effort <e>] [--context <c>]
68
72
  [--approval ${APPROVALS.join('|')}]
@@ -184,14 +188,15 @@ Usage:
184
188
  only while its tab is up in Orca.
185
189
  obk usage --bots <path> [--bot <bot>] [--session <name>] [--since <time>] [--until <time>]
186
190
  Say what your sessions have used: the conversations
187
- each one had, their calls and tokens, the models and
188
- efforts they ran at, and how often they were
189
- compacted. --since counts the calls made from that
190
- moment on, and --until the calls made before that
191
- one: a daily run asks from the last run's end up to
192
- its own, and the next starts where it stopped, so no
193
- call is counted twice. It counts tokens and never
194
- money: what a token costs is looked up live by
191
+ each one had, their calls and tokens (their
192
+ subagents' included, and how many were theirs), the
193
+ models and efforts they ran at, and how often they
194
+ were compacted. --since counts the calls made from
195
+ that moment on, and --until the calls made before
196
+ that one: a daily run asks from the last run's end
197
+ up to its own, and the next starts where it stopped,
198
+ so no call is counted twice. It counts tokens and
199
+ never money: what a token costs is looked up live by
195
200
  whoever is asking.
196
201
  obk session record --bots <path> --bot <bot>
197
202
  For the kit's own hook, not for typing: it reads
@@ -307,6 +312,7 @@ async function run(argv) {
307
312
  peek: { type: 'boolean' },
308
313
  charter: { type: 'string' },
309
314
  allow: { type: 'string', multiple: true },
315
+ disallow: { type: 'string', multiple: true },
310
316
  ...Object.fromEntries(SETTINGS.map(([flag]) => [flag, { type: 'string' }])),
311
317
  'extra-arg': { type: 'string', multiple: true },
312
318
  json: { type: 'boolean' },
@@ -604,15 +610,28 @@ const commands = {
604
610
  if (values.harness !== undefined) {
605
611
  throw new Error(`bot change does not change a bot's harness: its sessions' conversations belong to the harness they ran on. To move to ${values.harness}, give it a session on ${values.harness} with obk session add, or retire the bot with obk retire and create a new one.`);
606
612
  }
607
- if (values.charter === undefined && values.allow === undefined) {
608
- throw new Error('bot change needs --charter <text> or --allow <rule>: what to change.');
613
+ if (values.charter === undefined && values.allow === undefined && values.disallow === undefined) {
614
+ throw new Error('bot change needs --charter <text>, --allow <rule> or --disallow <rule>: what to change.');
615
+ }
616
+ if (values.allow !== undefined && values.disallow !== undefined) {
617
+ throw new Error('bot change takes --allow or --disallow, not both at once, so nothing was changed. Run one, then the other.');
609
618
  }
610
619
  // Refused before anything is written, so a bad rule, or a bad list already
611
620
  // there, leaves the charter as it was too.
621
+ let allowInto;
612
622
  if (values.allow !== undefined) {
613
623
  const { home } = allowedNow(bots, values.bot, values.allow);
614
624
  refuseBroad(home, values.allow);
615
- refuseNoCodexForm(readBot(home, values.bot), values.allow);
625
+ const bot = readBot(home, values.bot);
626
+ refuseNoCodexForm(bot, values.allow);
627
+ const { allow } = allowRules(bots, values.bot, values.allow, { write: false });
628
+ allowInto = allowIn(bots, home, { ...bot, allow });
629
+ }
630
+ let takeBackFrom;
631
+ if (values.disallow !== undefined) {
632
+ const { home } = allowedNow(bots, values.bot, values.disallow, '--disallow');
633
+ takeBackFrom = takeBack(bots, home, readBot(home, values.bot), values.disallow);
634
+ disallowRules(bots, values.bot, values.disallow, { write: false });
616
635
  }
617
636
 
618
637
  const answer = { bots, bot: values.bot, home: botDir(bots, values.bot) };
@@ -637,7 +656,7 @@ const commands = {
637
656
  // An allow list that is not a list is named by rules build and health,
638
657
  // and a charter already written is not undone for it.
639
658
  const bot = readBot(changed.home, changed.bot);
640
- if (values.allow === undefined && (runsOnClaude(bot) || runsOnCodex(bot))) {
659
+ if (values.allow === undefined && values.disallow === undefined && (runsOnClaude(bot) || runsOnCodex(bot))) {
641
660
  try {
642
661
  answer.beyondDefaults = beyondDefaults(bots, changed.home, bot);
643
662
  lines.push(...charterRulesLines(bots, bot, answer.beyondDefaults));
@@ -647,9 +666,11 @@ const commands = {
647
666
  }
648
667
  }
649
668
  if (values.allow !== undefined) {
669
+ // Written at once, as the charter's rules are built at once (#344): the
670
+ // harness's files first, bot.yaml last, so a write that fails leaves
671
+ // `allow` as it was, and the same command can run again (#383).
672
+ const permissions = allowInto();
650
673
  const allowed = allowRules(bots, values.bot, values.allow);
651
- // Written at once, as the charter's rules are built at once (#344).
652
- const permissions = writePermissions(bots, allowed.home, readBot(allowed.home));
653
674
  Object.assign(answer, { allow: allowed.allow, permissions });
654
675
  const codex = permissions.find((entry) => entry.unwritten !== undefined);
655
676
  lines.push(
@@ -664,6 +685,23 @@ const commands = {
664
685
  : `${allowed.bot}'s allow list in ${path.join('bots', allowed.bot, 'bot.yaml')} holds the user's yes.`,
665
686
  );
666
687
  }
688
+ if (values.disallow !== undefined) {
689
+ // The harness's files first, bot.yaml last: a write that fails leaves the
690
+ // rules in `allow`, so the same command can take them back again (#360).
691
+ const permissions = takeBackFrom();
692
+ const taken = disallowRules(bots, values.bot, values.disallow);
693
+ Object.assign(answer, { allow: taken.allow, disallowed: taken.disallowed, permissions });
694
+ const codex = permissions.find((entry) => entry.unwritten !== undefined);
695
+ lines.push(
696
+ ...taken.disallowed.map((rule) => `${'took back'.padEnd(9)} ${rule}`),
697
+ ...permissions.filter((entry) => entry.removed?.length > 0).map((entry) => `${'wrote'.padEnd(9)} ${path.relative(bots, entry.file)} ${entry.removed.length} permission rule${entry.removed.length === 1 ? '' : 's'} taken out`),
698
+ ...permissionsLines(permissions, bots),
699
+ ...(codex === undefined || codex.removed.length === 0 ? [] : [
700
+ `Codex reads ${codex.file} when a session starts: a Codex session of ${taken.bot} that is running now keeps ${codex.removed.length === 1 ? 'this rule' : 'these rules'} until its next start (obk restart).`,
701
+ ]),
702
+ `${taken.bot}'s allow list in ${path.join('bots', taken.bot, 'bot.yaml')} no longer holds ${taken.disallowed.length === 1 ? 'it' : 'them'}.`,
703
+ );
704
+ }
667
705
  return { answer, lines, code: trouble ? 1 : 0 };
668
706
  },
669
707
 
@@ -1053,7 +1091,7 @@ function usageLines(usage, bots) {
1053
1091
  for (const entry of usage) {
1054
1092
  lines.push(`${'bot'.padEnd(9)} ${entry.bot}`);
1055
1093
  for (const session of entry.sessions) {
1056
- lines.push(`${'session'.padEnd(9)} ${session.name}`);
1094
+ lines.push(`${'session'.padEnd(9)} ${session.name}${'retired' in session ? ` retired ${session.retired}` : ''}`);
1057
1095
  const notCounted = notCountedLine(session.not_counted);
1058
1096
  lines.push(...session.conversations.map(conversationLine));
1059
1097
  if (session.conversations.length === 0 && notCounted.length === 0) lines.push(' nothing on record');
@@ -1093,6 +1131,7 @@ function conversationLine(one) {
1093
1131
  .map(([kind, count]) => `${kind} ${count}`)
1094
1132
  .join(' ');
1095
1133
  return ` ${one.id} ${one.calls} call${one.calls === 1 ? '' : 's'}`
1134
+ + `${one.subagent_calls > 0 ? ` of which subagents: ${one.subagent_calls} call${one.subagent_calls === 1 ? '' : 's'}` : ''}`
1096
1135
  + `${ran === '' ? '' : ` ${ran}`}`
1097
1136
  + `${used === '' ? '' : ` ${used}`}`
1098
1137
  + `${one.compactions > 0 ? ` compacted ${one.compactions}` : ''}`;
@@ -1264,7 +1303,7 @@ function charterRulesLines(bots, bot, rules) {
1264
1303
  return [
1265
1304
  `${name} is allowed these permission rules beyond the kit's defaults, from before this change:`,
1266
1305
  ...rules.map((rule) => ` ${rule}`),
1267
- `They stay allowed, whatever the user answers, until the user takes one out of ${path.join('bots', name, 'bot.yaml')}${settings}${codex}; the kit takes none out. ${until}`,
1306
+ `They stay allowed, whatever the user answers, until the user says yes to taking one back out of ${path.join('bots', name, 'bot.yaml')}${settings}${codex}. For one the new charter no longer grants, ask the user, and only after their yes run ${shellWord(ownCli())} bot change --bots ${shellWord(bots)} --bot ${shellWord(name)} --disallow <rule>. ${until}`,
1268
1307
  ];
1269
1308
  }
1270
1309
 
@@ -50,15 +50,46 @@ export function conversationsIn(harness, home, since) {
50
50
  */
51
51
  export function hasConversation(harness, home, id) {
52
52
  if (harness === 'claude') return existsSync(claudeTranscript(home, id));
53
- return rollouts(codexDir()).some((file) => path.basename(file).endsWith(`-${id}.jsonl`));
53
+ return codexRollout(id) !== undefined;
54
54
  }
55
55
 
56
+ /**
57
+ * Where Codex keeps the conversation `id`, whatever folder it ran in, found by
58
+ * the file name Codex makes from the id; `undefined` when it has none.
59
+ */
60
+ export const codexRollout = (id) =>
61
+ rollouts(codexDir()).find((file) => path.basename(file).endsWith(`-${id}.jsonl`));
62
+
56
63
  /**
57
64
  * Where Claude Code keeps the conversation `id` of this bot home, whether or
58
65
  * not it has written it yet.
59
66
  */
60
67
  export const claudeTranscript = (home, id) => path.join(claudeDir(home), `${id}.jsonl`);
61
68
 
69
+ /**
70
+ * The transcripts of the subagents a Claude Code conversation started, given the
71
+ * file the conversation is in. They sit beside it, in a folder named by its id,
72
+ * one `agent-<name>.jsonl` each, with a `.meta.json` beside each that is not a
73
+ * transcript (tech notes, section 2).
74
+ *
75
+ * No folder is a conversation that started no subagent. A folder that is there
76
+ * and cannot be listed is `unreadable`: what is in it cannot be counted, and
77
+ * taking it as empty would say nothing of that.
78
+ */
79
+ export function claudeSubagentTranscripts(file) {
80
+ const dir = path.join(file.slice(0, -'.jsonl'.length), 'subagents');
81
+ let names;
82
+ try {
83
+ names = readdirSync(dir);
84
+ } catch (error) {
85
+ return { files: [], unreadable: error.code === 'ENOENT' ? 0 : 1 };
86
+ }
87
+ return {
88
+ files: names.filter((name) => name.endsWith('.jsonl')).sort().map((name) => path.join(dir, name)),
89
+ unreadable: 0,
90
+ };
91
+ }
92
+
62
93
  /**
63
94
  * The same conversations, each with the file the harness keeps it in, for a
64
95
  * caller that has to read what is inside one rather than only know it is there,
package/src/orca.js CHANGED
@@ -180,7 +180,12 @@ export const deleteProject = (setupId) => orca(['project', 'setup-delete', '--se
180
180
  /** Said after a run that made or renamed a project, whatever `tellWindow` answered, and after a removal `reloadWindow` could not follow. */
181
181
  export const RELOAD_LINE = "If Orca's sidebar does not show it, reload the window with Cmd+Shift+R.";
182
182
 
183
- /** How long Orca's client waits for its runtime, and how long the kit waits for the client. */
183
+ /**
184
+ * How long Orca's client waits for its runtime, and how long the kit waits for
185
+ * the client. Measured beside the full unit suite on Orca 1.4.214, the whole
186
+ * call took at most 721 ms and the call inside the client at most 447 ms
187
+ * (tech notes, #384).
188
+ */
184
189
  const CLIENT_WAIT_MS = 2000;
185
190
  const CLIENT_KILL_MS = 3000;
186
191
 
@@ -12,13 +12,14 @@
12
12
  // editing the file by hand may get the format wrong; code does not.
13
13
  //
14
14
  // The kit owns only the entries `allow` holds. Any other entry in the file is
15
- // left where it is, and health names it.
15
+ // left where it is, and health names it. A rule the user says yes to taking
16
+ // back leaves `allow` and the files through `bot change --disallow` (#360).
16
17
  //
17
18
  // A bot on Codex gets the same yes in Codex's own form (#354, ADR 0028): each
18
19
  // `Bash(<words>:*)` becomes a `prefix_rule` of those words in
19
20
  // `.codex/rules/obk.rules`, a file the kit owns whole and rewrites from `allow`.
20
21
 
21
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
22
+ import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
22
23
  import path from 'node:path';
23
24
 
24
25
  import { leadsOutside } from './bot.js';
@@ -266,10 +267,10 @@ export const allowCommand = (bots, bot, rules) => [
266
267
  * the kit may not write is an entry with its `trouble` rather than a throw.
267
268
  */
268
269
  export function writePermissions(bots, home, bot, { keepGoing = false } = {}) {
269
- const writers = [[runsOnClaude, FILE, writeClaude], [runsOnCodex, CODEX_FILE, writeCodex]];
270
- return writers.filter(([runs]) => runs(bot)).map(([, file, write]) => {
270
+ const writers = [[runsOnClaude, FILE, planClaude], [runsOnCodex, CODEX_FILE, planCodex]];
271
+ return writers.filter(([runs]) => runs(bot)).map(([, file, plan]) => {
271
272
  try {
272
- return write(bots, home, bot);
273
+ return plan(bots, home, bot).write();
273
274
  } catch (error) {
274
275
  if (!keepGoing) throw error;
275
276
  return { bot: bot.name, file: path.join(home, file), written: [], waiting: [], trouble: error.message };
@@ -278,30 +279,155 @@ export function writePermissions(bots, home, bot, { keepGoing = false } = {}) {
278
279
  }
279
280
 
280
281
  /**
281
- * Write what the bot is allowed into its Claude settings: `{ bot, file,
282
- * written, waiting }`, with `written` the rules this run added.
282
+ * Check every file `bot change --allow` writes before any is written (#383):
283
+ * bot.yaml, and the files of the harnesses the bot runs on, read and found
284
+ * writable. `bot` is the bot as it will be, with the new rules in `allow`.
285
+ * What comes back writes the harness files, one entry per file, Claude's
286
+ * first; the caller writes bot.yaml after, so a write that fails leaves
287
+ * `allow` as it was, for the same command to run again.
288
+ */
289
+ export function allowIn(bots, home, bot) {
290
+ refuseUnwritable(path.join(home, 'bot.yaml'));
291
+ const plans = [[runsOnClaude, planClaude], [runsOnCodex, planCodex]]
292
+ .filter(([runs]) => runs(bot)).map(([, plan]) => plan(bots, home, bot));
293
+ for (const plan of plans) if (plan.writes) refuseUnwritable(plan.answer.file);
294
+ return () => plans.map((plan) => plan.write());
295
+ }
296
+
297
+ /**
298
+ * What writing the bot's allowed rules into its Claude settings would do,
299
+ * read and refused before anything is written: `{ answer, writes, write }`.
300
+ * `write()` gives `{ bot, file, written, waiting }`, with `written` the rules
301
+ * it added.
283
302
  *
284
303
  * Only rules `allow` holds are written. Everything else in the file stays as it
285
304
  * is, the user's own entries and their order included; a rule missing from the
286
305
  * file goes after them, and a run with nothing to add writes nothing.
287
306
  */
288
- function writeClaude(bots, home, bot) {
307
+ function planClaude(bots, home, bot) {
289
308
  const file = path.join(home, FILE);
290
309
  const allowed = allowOf(home, bot);
291
310
  const waiting = waitingFor(bots, home, bot, 'claude');
292
311
  const answer = { bot: bot.name, file, written: [], waiting };
293
- if (allowed.length === 0) return answer;
312
+ const none = { answer, writes: false, write: () => answer };
313
+ if (allowed.length === 0) return none;
294
314
 
295
315
  refuseOutside(home, file);
296
316
  const settings = readSettings(file, 'the permission rules the user allowed');
297
317
  const present = presentIn(settings, file);
298
318
  const missing = [...new Set(allowed)].filter((rule) => !present.includes(rule));
299
- if (missing.length === 0) return answer;
319
+ if (missing.length === 0) return none;
300
320
 
301
321
  const wanted = { ...settings, permissions: { ...settings.permissions, allow: [...present, ...missing] } };
302
- mkdirSync(path.dirname(file), { recursive: true });
303
- writeFileSync(file, `${JSON.stringify(wanted, null, 2)}\n`);
304
- return { ...answer, written: missing };
322
+ return {
323
+ answer,
324
+ writes: true,
325
+ write: () => {
326
+ mkdirSync(path.dirname(file), { recursive: true });
327
+ writeFileSync(file, `${JSON.stringify(wanted, null, 2)}\n`);
328
+ return { ...answer, written: missing };
329
+ },
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Take the rules the user said yes to taking back out of the files of the
335
+ * harnesses the bot runs on (#360): from its Claude settings every entry of
336
+ * that exact text and nothing else, and its Codex rules rewritten from what
337
+ * `allow` still holds. Only rules `allow` holds are taken back; one it does not
338
+ * hold is refused, since the kit did not write it (ADR 0029).
339
+ *
340
+ * Everything is checked before anything is written, a file the kit could not
341
+ * write included, bot.yaml too. What comes back writes the files: one entry per file,
342
+ * Claude's first, with `removed` the rules taken out of it. The caller takes
343
+ * them out of `allow` after, so a write that fails leaves them there to take
344
+ * back again.
345
+ */
346
+ export function takeBack(bots, home, bot, rules) {
347
+ const allowed = allowOf(home, bot);
348
+ const foreign = rules.find((rule) => !allowed.includes(rule));
349
+ if (foreign !== undefined) throw new Error(notAllowed(home, bot, foreign));
350
+ const after = { ...bot, allow: allowed.filter((rule) => !rules.includes(rule)) };
351
+ const taken = [...new Set(rules)];
352
+ // bot.yaml is written last, and a harness file taken out of step with it
353
+ // would leave nothing to say a running Codex session still has the rule.
354
+ refuseUnwritable(path.join(home, 'bot.yaml'));
355
+
356
+ const file = path.join(home, FILE);
357
+ let claude;
358
+ if (runsOnClaude(bot)) {
359
+ refuseOutside(home, file);
360
+ const settings = readSettings(file, 'the permission rules the user allowed');
361
+ const present = presentIn(settings, file);
362
+ claude = { settings, present, removed: taken.filter((rule) => present.includes(rule)) };
363
+ if (claude.removed.length > 0) refuseUnwritable(file);
364
+ }
365
+ const codex = path.join(home, CODEX_FILE);
366
+ let before = [];
367
+ if (runsOnCodex(bot)) {
368
+ refuseOutside(home, codex);
369
+ if (existsSync(codex)) {
370
+ before = ruleLines(readFileSync(codex, 'utf8'));
371
+ refuseUnwritable(codex);
372
+ }
373
+ }
374
+
375
+ return () => {
376
+ const entries = [];
377
+ if (claude !== undefined) {
378
+ const { settings, present, removed } = claude;
379
+ if (removed.length > 0) {
380
+ const allow = present.filter((rule) => !removed.includes(rule));
381
+ writeFileSync(file, `${JSON.stringify({ ...settings, permissions: { ...settings.permissions, allow } }, null, 2)}\n`);
382
+ }
383
+ entries.push({ bot: bot.name, file, written: [], removed, waiting: waitingFor(bots, home, after, 'claude') });
384
+ }
385
+ if (runsOnCodex(after)) {
386
+ const entry = planCodex(bots, home, after).write();
387
+ // A line goes only when no rule still allowed gives it.
388
+ const kept = [...codexOf(home, after).lines.keys()];
389
+ const removed = taken.filter((rule) => {
390
+ const { line } = codexForm(rule);
391
+ return line !== undefined && before.includes(line) && !kept.includes(line);
392
+ });
393
+ entries.push({ ...entry, written: [], removed });
394
+ }
395
+ return entries;
396
+ };
397
+ }
398
+
399
+ /**
400
+ * A refusal, before anything is written, of a file the kit could not write: one
401
+ * not there yet is made in the nearest folder that is, which the kit must be
402
+ * able to enter and write in, and which must be a folder.
403
+ */
404
+ function refuseUnwritable(file) {
405
+ let where = file;
406
+ while (!existsSync(where) && path.dirname(where) !== where) where = path.dirname(where);
407
+ try {
408
+ if (where !== file && !statSync(where).isDirectory()) throw Object.assign(new Error('not a folder'), { code: 'ENOTDIR' });
409
+ accessSync(where, where === file ? constants.W_OK : constants.W_OK | constants.X_OK);
410
+ } catch (error) {
411
+ const at = where === file ? '' : ` at ${where}`;
412
+ throw new Error(`${file} cannot be written (${error.code ?? error.message}${at}), so nothing was changed. Let the kit write it, then run the command again.`);
413
+ }
414
+ }
415
+
416
+ /** Why `--disallow` refuses a rule `allow` does not hold, and where one of that text is. */
417
+ function notAllowed(home, bot, rule) {
418
+ const yaml = path.join(home, 'bot.yaml');
419
+ const file = path.join(home, FILE);
420
+ let byHand = false;
421
+ try {
422
+ byHand = runsOnClaude(bot) && leadsOutside(home, file) === undefined
423
+ && presentIn(readSettings(file), file).includes(rule);
424
+ } catch {
425
+ // A file the kit cannot read holds nothing it would take out.
426
+ }
427
+ const where = byHand
428
+ ? ` ${file} allows it, which the user added by hand, not the kit: it stays theirs, to take out themselves.`
429
+ : '';
430
+ return `--disallow ${rule} is not in the allow list in ${yaml}, so it is not the kit's to take back, and nothing was changed.${where} The kit takes back only rules that list holds, spelled exactly as they are there.`;
305
431
  }
306
432
 
307
433
  /**
@@ -324,16 +450,18 @@ function codexOf(home, bot) {
324
450
  const ruleLines = (text) => text.split('\n').map((line) => line.trim()).filter((line) => line !== '' && !line.startsWith('#'));
325
451
 
326
452
  /**
327
- * Write the Codex form of what the bot is allowed into `.codex/rules/obk.rules`:
453
+ * What writing the Codex form of what the bot is allowed into
454
+ * `.codex/rules/obk.rules` would do, as `planClaude` gives it. `write()` gives
328
455
  * `{ bot, file, written, waiting, unwritten }`, with `written` every rule in
329
- * the file when this run wrote it. The kit owns the file whole and writes it
330
- * only when it would change; with nothing to write and no file, it makes none.
456
+ * the file when it wrote it. The kit owns the file whole and writes it only
457
+ * when it would change; with nothing to write and no file, it makes none.
331
458
  */
332
- function writeCodex(bots, home, bot) {
459
+ function planCodex(bots, home, bot) {
333
460
  const file = path.join(home, CODEX_FILE);
334
461
  const { lines, unwritten } = codexOf(home, bot);
335
462
  const answer = { bot: bot.name, file, written: [], waiting: waitingFor(bots, home, bot, 'codex'), unwritten };
336
- if (lines.size === 0 && !existsSync(file)) return answer;
463
+ const none = { answer, writes: false, write: () => answer };
464
+ if (lines.size === 0 && !existsSync(file)) return none;
337
465
 
338
466
  refuseOutside(home, file);
339
467
  const text = [
@@ -341,10 +469,16 @@ function writeCodex(bots, home, bot) {
341
469
  '# obk rewrites this whole file: put rules of your own in another file in this folder.',
342
470
  ...lines.keys(),
343
471
  ].join('\n');
344
- if (existsSync(file) && readFileSync(file, 'utf8') === `${text}\n`) return answer;
345
- mkdirSync(path.dirname(file), { recursive: true });
346
- writeFileSync(file, `${text}\n`);
347
- return { ...answer, written: [...lines.values()].flat() };
472
+ if (existsSync(file) && readFileSync(file, 'utf8') === `${text}\n`) return none;
473
+ return {
474
+ answer,
475
+ writes: true,
476
+ write: () => {
477
+ mkdirSync(path.dirname(file), { recursive: true });
478
+ writeFileSync(file, `${text}\n`);
479
+ return { ...answer, written: [...lines.values()].flat() };
480
+ },
481
+ };
348
482
  }
349
483
 
350
484
  /**
package/src/usage.js CHANGED
@@ -9,7 +9,9 @@
9
9
  //
10
10
  // Which conversation belongs to which session is the book's to say and is never
11
11
  // guessed, as everywhere else in the kit (ADR 0012). A session's conversations
12
- // are the one the book names for it now and the ones in its history. One in the
12
+ // are the one the book names for it now and the ones in its history. A session
13
+ // since retired keeps a block of its own, marked retired, so a window that spans
14
+ // a retirement loses nothing (#379). One in the
13
15
  // bot's folder that no session claims is reported under the bot instead, with
14
16
  // its figures, so that what it spent is visible rather than quietly dropped.
15
17
  //
@@ -23,20 +25,26 @@
23
25
  // `last_token_usage` counts a call written down twice twice. So a call is
24
26
  // the difference in the running total from the event before: none is a
25
27
  // repeat, and a fall is a new window, whose own `last_token_usage` counts.
28
+ // A rollout's first record counts its own figure too: a child's running
29
+ // total can start with its parent's inside it, and a fork can begin with
30
+ // its origin's records copied in, which are the origin's calls (#376).
26
31
  // 2. The two harnesses do not mean the same thing by `input_tokens`. Claude
27
32
  // Code leaves the cache reads out of it; Codex counts them inside it. So
28
33
  // the Codex figure has its cached tokens taken back out, and `input` means
29
34
  // uncached input on both. Taken as written, one is many times the other for
30
35
  // the same work, and the cached tokens get priced at full rate.
31
36
  // 3. Claude Code writes the same call down more than once, so a call counts
32
- // once per `requestId` and `message.id`.
37
+ // once per `requestId` and `message.id`. A subagent's calls are in a file
38
+ // of their own beside the conversation that started it, and belong to it;
39
+ // the same call can be in more than one of those files, so a call counts
40
+ // once across all of a conversation's files together (#371).
33
41
 
34
42
  import { readFileSync, realpathSync } from 'node:fs';
35
43
 
36
44
  import { botDir, botNames, readBot } from './bot.js';
37
45
  import { readBook, sessionIdsIn } from './book.js';
38
- import { transcriptsIn } from './conversations.js';
39
- import { harnessOf } from './launch.js';
46
+ import { claudeSubagentTranscripts, codexRollout, transcriptsIn } from './conversations.js';
47
+ import { HARNESSES, harnessOf } from './launch.js';
40
48
 
41
49
  /** The kinds a call's tokens are reported in, the same on either harness. */
42
50
  const KINDS = ['input', 'output', 'cache_read', 'cache_write', 'reasoning'];
@@ -98,6 +106,9 @@ function forBot(bots, name, onlySession, window) {
98
106
  // them runs on is asked. The bot's own is the answer when it has no sessions.
99
107
  const harnesses = new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)));
100
108
  if (harnesses.size === 0) harnesses.add(bot.harness);
109
+ // The book does not say which harness a retired session ran on, so any is.
110
+ const retired = Array.isArray(book.retired) ? book.retired : [];
111
+ if (retired.length > 0) for (const harness of HARNESSES) harnesses.add(harness);
101
112
 
102
113
  const onRecord = new Map();
103
114
  for (const harness of harnesses) {
@@ -112,15 +123,22 @@ function forBot(bots, name, onlySession, window) {
112
123
 
113
124
  const claimed = sessionIdsIn(book);
114
125
 
115
- const sessions = bot.sessions
116
- .filter((session) => onlySession === undefined || session.name === onlySession)
117
- .map((session) => {
118
- const { conversations, gaps } = all(
119
- idsIn(book.sessions[session.name] ?? {}).filter((id) => onRecord.has(id)).map((id) => onRecord.get(id)),
120
- window,
121
- );
122
- return { name: session.name, conversations, not_counted: gaps };
123
- });
126
+ // Two entries can name the same conversation, one resumed after its session
127
+ // was retired, and one entry can name it twice, now and in its history: it is
128
+ // counted once, in the first block asked for that names it.
129
+ const taken = new Set();
130
+ const block = (entry) => {
131
+ const ids = [...new Set(idsIn(entry))].filter((id) => onRecord.has(id) && !taken.has(id));
132
+ for (const id of ids) taken.add(id);
133
+ const { conversations, gaps } = all(ids.map((id) => onRecord.get(id)), window);
134
+ return { conversations, not_counted: gaps };
135
+ };
136
+ const sessions = [
137
+ ...bot.sessions.map((session) => ({ name: session.name, entry: book.sessions[session.name] ?? {} })),
138
+ ...retired.map((entry) => ({ name: entry?.name, retired: entry?.retired, entry: entry ?? {} })),
139
+ ]
140
+ .filter(({ name }) => onlySession === undefined || name === onlySession)
141
+ .map(({ name, entry, ...was }) => ({ name, ...('retired' in was ? { retired: was.retired } : {}), ...block(entry) }));
124
142
 
125
143
  const unclaimed = all([...onRecord.values()].filter((one) => !claimed.has(one.id)), window);
126
144
 
@@ -151,9 +169,9 @@ const idsIn = (entry) => [
151
169
  * shorter page. What it could not count is still said.
152
170
  */
153
171
  function counted(one, window) {
154
- const read = one.harness === 'claude' ? fromClaude : fromCodex;
155
172
  const tally = {
156
173
  calls: 0,
174
+ subagentCalls: 0,
157
175
  compactions: 0,
158
176
  tokens: Object.fromEntries(KINDS.map((kind) => [kind, 0])),
159
177
  models: new Set(),
@@ -166,15 +184,28 @@ function counted(one, window) {
166
184
  gaps: noGaps(),
167
185
  };
168
186
 
169
- const { entries, unreadable, broken } = transcript(one.file);
170
- tally.gaps.unreadable_transcripts = unreadable;
171
- tally.gaps.broken_lines = broken;
172
- read(entries, window, tally);
187
+ // A Claude Code conversation's subagents wrote their calls to files of their
188
+ // own, and what they spent is the conversation's.
189
+ const subagents = one.harness === 'claude' ? claudeSubagentTranscripts(one.file) : { files: [], unreadable: 0 };
190
+ tally.gaps.unreadable_transcripts += subagents.unreadable;
191
+ const sources = [one.file, ...subagents.files]
192
+ .map((file, index) => {
193
+ const { entries, unreadable, broken } = transcript(file);
194
+ tally.gaps.unreadable_transcripts += unreadable;
195
+ tally.gaps.broken_lines += broken;
196
+ return { entries, subagent: index > 0 };
197
+ });
198
+ if (one.harness === 'claude') fromClaude(sources, window, tally);
199
+ else {
200
+ const copied = originOf(sources[0].entries, tally);
201
+ if (copied !== null) fromCodex(sources[0].entries, window, tally, copied);
202
+ }
173
203
  if (tally.calls === 0 && KINDS.every((kind) => tally.tokens[kind] === 0)) return { gaps: tally.gaps };
174
204
 
175
205
  return { gaps: tally.gaps, row: {
176
206
  id: one.id,
177
207
  calls: tally.calls,
208
+ subagent_calls: tally.subagentCalls,
178
209
  tokens: tally.tokens,
179
210
  by_model: [...tally.byModel].map(([model, its]) => ({ model, calls: its.calls, tokens: its.tokens })),
180
211
  models: [...tally.models],
@@ -204,6 +235,15 @@ function counted(one, window) {
204
235
  * the runs add up to the whole, with a call still being written at a boundary
205
236
  * charged once, part to each side (#169).
206
237
  *
238
+ * A call is one `requestId` and `message.id` across the main transcript and its
239
+ * subagents' files together, since the same call can be in more than one of
240
+ * them, and its records are taken in the order of their times. Copies written
241
+ * at the very same moment are taken smallest first, since a call's figures only
242
+ * grow: the largest is the latest. One with a figure missing may be the latest
243
+ * of them, so it is taken as that, and what it hides is said rather than
244
+ * measured past it. It is a subagent's when the main transcript has no record
245
+ * of it.
246
+ *
207
247
  * A record with no time, or with a figure missing, is left out whole and said to
208
248
  * be; the call it belongs to is counted from its other records, if it has any.
209
249
  * But when the record last written before the window's start is one of those,
@@ -211,10 +251,10 @@ function counted(one, window) {
211
251
  * the window cannot be told apart from what it grew by before. So the call is
212
252
  * not counted in that window, and is said to be.
213
253
  */
214
- function fromClaude(entries, window, tally) {
254
+ function fromClaude(sources, window, tally) {
215
255
  const byCall = new Map();
216
256
 
217
- for (const entry of entries) {
257
+ for (const { entries, subagent } of sources) for (const entry of entries) {
218
258
  const when = Date.parse(entry.timestamp ?? '');
219
259
  if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
220
260
  if (Number.isNaN(when)) tally.gaps.records_without_time += 1;
@@ -233,10 +273,11 @@ function fromClaude(entries, window, tally) {
233
273
  if (broken && inside(when, window)) tally.gaps.records_without_numbers += 1;
234
274
  const call = `${entry.requestId}\u0000${entry.message?.id}`;
235
275
  if (!byCall.has(call)) byCall.set(call, []);
236
- byCall.get(call).push({ entry, when, broken });
276
+ byCall.get(call).push({ entry, when, broken, subagent });
237
277
  }
238
278
 
239
279
  for (const records of byCall.values()) {
280
+ records.sort((one, other) => one.when - other.when || one.broken - other.broken || size(one.entry) - size(other.entry));
240
281
  const made = records[0].when;
241
282
  const atEnd = records.findLast(({ when, broken }) => !broken && when < window.to);
242
283
  if (atEnd === undefined) continue;
@@ -252,10 +293,17 @@ function fromClaude(entries, window, tally) {
252
293
  const isNew = inside(made, window);
253
294
  if (!isNew && KINDS.every((kind) => grew[kind] === 0)) continue;
254
295
 
255
- count(tally, grew, atEnd.entry.message?.model, atEnd.entry.effort, isNew ? made : atEnd.when, isNew ? 1 : 0);
296
+ const subagent = records.every((record) => record.subagent);
297
+ count(tally, grew, atEnd.entry.message?.model, atEnd.entry.effort, isNew ? made : atEnd.when, isNew ? 1 : 0, subagent);
256
298
  }
257
299
  }
258
300
 
301
+ /** How much one Claude Code record says its call has used so far, all kinds together. */
302
+ const size = (entry) => {
303
+ const now = figures(entry);
304
+ return KINDS.reduce((sum, kind) => sum + now[kind], 0);
305
+ };
306
+
259
307
  /** The figures Claude Code writes on every call, all of which a record needs to be counted. */
260
308
  const CLAUDE_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'];
261
309
 
@@ -300,22 +348,31 @@ const CODEX_FIELDS = [
300
348
  * The running total is followed through events outside the window as well, since
301
349
  * what a call added can only be measured against the event before it.
302
350
  *
303
- * An event whose running total has a figure missing is left out and said to be,
304
- * and after it the running total is not known, so neither is what the next
305
- * complete event added. Measured against the event before the broken one, it
306
- * would take in what the broken one used, at its own time and perhaps in
307
- * another window; taken by its own per-call figure, it may be the broken one
308
- * written down again. Telling those apart is guessing, so it is not counted
309
- * either, and is said to be; the running total follows on from it. A call whose
310
- * figure is needed and has something missing is not counted, and is said to
311
- * be; nothing missing is taken as zero.
351
+ * An event whose running total has a figure missing, or has none at all, is left
352
+ * out and said to be, and after it the running total is not known, so neither
353
+ * is what the next complete event added. Measured against the event before the
354
+ * broken one, it would take in what the broken one used, at its own time and
355
+ * perhaps in another window; taken by its own per-call figure, it may be the
356
+ * broken one written down again. Telling those apart is guessing, so it is not
357
+ * counted either, and is said to be; the running total follows on from it. A
358
+ * call whose figure is needed and has something missing is not counted, and is
359
+ * said to be; nothing missing is taken as zero.
360
+ *
361
+ * A fork begins with the records of the conversation it was forked from, copied
362
+ * in and stamped at the fork's start, or all at one time in a file Codex rebuilt.
363
+ * Those are the origin's calls, counted with the origin: the leading records
364
+ * that are in `copied`, both figures, are followed and not counted (#376).
365
+ * A leading record with a figure missing may be a copy or the fork's own call;
366
+ * it is not counted, and is said to be.
312
367
  */
313
- function fromCodex(entries, window, tally) {
368
+ function fromCodex(entries, window, tally, copied) {
314
369
  let model;
315
370
  let effort;
316
371
  let running;
317
372
  // Whether a running total with something missing came since the last complete one.
318
373
  let broken = false;
374
+ // Whether the records so far are all the origin's, copied into a fork.
375
+ let copying = copied !== undefined;
319
376
 
320
377
  for (const entry of entries) {
321
378
  const when = Date.parse(entry.timestamp ?? '');
@@ -338,13 +395,26 @@ function fromCodex(entries, window, tally) {
338
395
  const info = entry.payload?.info;
339
396
  if (info === undefined || info === null) continue;
340
397
 
341
- const total = info.total_token_usage ?? undefined;
398
+ const total = info.total_token_usage;
399
+ const whole = complete(total, CODEX_READ) && complete(info.last_token_usage, CODEX_READ);
400
+ if (copying && whole && copied.has(key(info))) {
401
+ running = total;
402
+ broken = false;
403
+ continue;
404
+ }
405
+ // A record with a figure missing cannot be told from a copy while copies
406
+ // may still be coming: it is not counted and is said to be, and the next
407
+ // record is matched as before.
408
+ const unknown = copying && !whole;
409
+ if (!unknown) copying = false;
410
+
342
411
  let used;
343
- if (total === undefined) {
344
- // Nothing to measure against: the per-call figure is all there is. After a
345
- // broken running total it may be the broken one written again, so it is
346
- // not counted either until a complete running total says where things are.
347
- used = broken ? null : perCall(info);
412
+ // No running total at all is a running total with every figure missing:
413
+ // the next one's rise would take this call in again (#302).
414
+ if (unknown && complete(total, CODEX_READ)) {
415
+ used = null;
416
+ running = total;
417
+ broken = false;
348
418
  } else if (!complete(total, CODEX_READ)) {
349
419
  used = null;
350
420
  broken = true;
@@ -373,13 +443,56 @@ function fromCodex(entries, window, tally) {
373
443
  /** The fields Codex itself writes; it has no cache writes to report. */
374
444
  const CODEX_READ = CODEX_FIELDS.filter((field) => field !== 'cache_write_input_tokens').concat('total_tokens');
375
445
 
446
+ /**
447
+ * A usage record as one value, both its figures: a call of the child's own can
448
+ * bring the running total back to one the origin once had, but not with the same
449
+ * figure of its own.
450
+ */
451
+ const key = (info) => [info.total_token_usage, info.last_token_usage]
452
+ .map((figure) => CODEX_READ.map((field) => figure[field]).join('/')).join('|');
453
+
454
+ /**
455
+ * The usage records of the conversation a Codex rollout was forked from, which
456
+ * it may begin with copies of (tech notes, section 3). `undefined` when it names
457
+ * no origin. When the origin cannot be read whole, a line of it broken or a
458
+ * figure missing from a record, where the copies end cannot be told, so none of
459
+ * the fork is counted: `null`, and said to be.
460
+ */
461
+ function originOf(entries, tally) {
462
+ const id = entries.find((entry) => entry.type === 'session_meta')?.payload?.forked_from_id;
463
+ if (typeof id !== 'string' || id === '') return undefined;
464
+
465
+ const file = codexRollout(id);
466
+ const origin = file === undefined ? { unreadable: 1 } : transcript(file);
467
+ // No `info` is a note about rate limits, not a record of usage.
468
+ const records = origin.unreadable > 0 ? [] : origin.entries
469
+ .filter((entry) => entry.type === 'event_msg' && entry.payload?.type === 'token_count')
470
+ .map((entry) => entry.payload.info)
471
+ .filter((info) => info !== undefined && info !== null);
472
+ const whole = origin.unreadable === 0 && origin.broken === 0 && records.every((info) =>
473
+ complete(info.total_token_usage, CODEX_READ) && complete(info.last_token_usage, CODEX_READ));
474
+ if (!whole) {
475
+ tally.gaps.unreadable_transcripts += 1;
476
+ return null;
477
+ }
478
+ return new Set(records.map(key));
479
+ }
480
+
376
481
  /**
377
482
  * What one Codex event says its call used, measured against the running total
378
483
  * before it: `undefined` where the event is the one before written down again,
379
- * and `null` where what it used cannot be known.
484
+ * and `null` where what it used cannot be known. The first has nothing before
485
+ * it, and its running total can hold what a parent spent before this rollout
486
+ * began, so its own figure is what it used. An own figure of nothing in every
487
+ * field is a note of where the running total stands, not a call: wherever else
488
+ * Codex writes one, the running total does not move (tech notes, section 3).
380
489
  */
381
490
  function spent(running, total, info) {
382
- if (running === undefined) return kindsOf(total);
491
+ if (running === undefined) {
492
+ const own = info.last_token_usage;
493
+ if (complete(own, CODEX_READ) && CODEX_FIELDS.every((field) => number(own[field]) === 0)) return undefined;
494
+ return perCall(info);
495
+ }
383
496
 
384
497
  const moved = total.total_tokens - running.total_tokens;
385
498
  if (moved === 0) return undefined;
@@ -418,8 +531,9 @@ function kindsOf(raw) {
418
531
  * the total that has to be complete, and a row headed by nothing would be worse
419
532
  * than no row.
420
533
  */
421
- function count(tally, used, model, effort, when, calls = 1) {
534
+ function count(tally, used, model, effort, when, calls = 1, subagent = false) {
422
535
  tally.calls += calls;
536
+ if (subagent) tally.subagentCalls += calls;
423
537
  for (const kind of KINDS) tally.tokens[kind] += used[kind];
424
538
  add(tally.models, model);
425
539
  add(tally.efforts, effort);