@assuredloop/orca-bot-kit 0.4.0 → 0.5.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
@@ -443,10 +443,23 @@ What it looks for:
443
443
  go, and a link with nothing at the end of it;
444
444
  - **a session the book knows that Orca does not**: its tab was closed, or the
445
445
  machine was restarted, and `obk up` brings it back;
446
+ - **a session whose tab is open and whose harness is gone**: only the tab's
447
+ shell is in front, because the harness quit or crashed. `obk up` leaves an
448
+ open tab alone, so the finding names the `obk restart … --session` that
449
+ brings it back; when the book names no conversation for it, restart would
450
+ refuse, and the finding says to close the tab in Orca and run
451
+ `obk up … --session`. A tab the kit cannot read is never called down;
446
452
  - **leftovers no book owns**: an Orca project inside your bots folder with no
447
453
  bot in it, a tab in a bot's project that the book does not name, a start
448
454
  prompt written for a session that has gone, a clone of a skills source you no
449
455
  longer list, and the conversations the kit found and would not assign;
456
+ - **a session running on something other than its bot asks for**: a model,
457
+ effort, context or approval level that the harness's own record of the
458
+ conversation says differs from `bot.yaml`, and a session that started before
459
+ its bot's `AGENTS.md` last changed, with what brings it up to date. Beside the
460
+ findings, each running session gets a line saying, for each setting, whether
461
+ it matches, differs, was not asked for, or cannot be told from the record;
462
+ cannot be told is never taken for a match;
450
463
  - **Orca's own default launch arguments**. Orca adds these to the agents it
451
464
  launches, relaunches and resumes, so when they carry a permission bypass every
452
465
  session runs in that mode whatever the kit asked for. The kit never sets that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -302,7 +302,10 @@ it started, so a change to a bot's `AGENTS.md` may not reach one that is
302
302
  already up even when a skill change would, and a session's model, effort or
303
303
  approval only changes when it is started again (`obk restart`, on the user's
304
304
  word, and then read its tabs as above). Say which you changed, and what that
305
- means for the sessions running now. Where everything needs restarting, remind
305
+ means for the sessions running now. `obk health` names each running session
306
+ still on the older rules or on other settings, and what brings it up to date: on
307
+ Claude Code a `/clear` reads the rules again, and a start does on both harnesses.
308
+ Where everything needs restarting, remind
306
309
  them to do it from Bot Father's ops tab.
307
310
 
308
311
  And tell them. A bot's sessions share its rules and its skills, so a change
package/src/book.js CHANGED
@@ -25,6 +25,9 @@ const HEADER = `# What Orca calls this bot on this machine, and where each of it
25
25
  # and kept, because an address that is a tab dies with the tab. \`address\` is what
26
26
  # a Claude session is called, which is what another Claude session writes to.
27
27
  #
28
+ # \`rules\` is a stamp of the bot's AGENTS.md as the session last read it, so a
29
+ # change to it after the session started can be told apart.
30
+ #
28
31
  # \`retired\` holds what the book knew about each session \`obk retire\` took off
29
32
  # the bot, with when, so the conversations it had are still accounted for.
30
33
  `;
package/src/cli.js CHANGED
@@ -451,11 +451,12 @@ const commands = {
451
451
 
452
452
  health(bots, values) {
453
453
  refuseWhenOrcaIsDown();
454
- const found = checkHealth(bots, { bot: values.bot });
454
+ const { found, sessions } = checkHealth(bots, { bot: values.bot });
455
455
  return {
456
- answer: { bots, found },
456
+ answer: { bots, found, sessions },
457
457
  lines: [
458
458
  ...foundLines(found),
459
+ ...sessions.map(settingsLine),
459
460
  found.length === 0
460
461
  ? `Nothing to report: everything the kit keeps is where it should be. Your bots folder: ${bots}`
461
462
  : `${found.length} thing${found.length === 1 ? '' : 's'} to look at above. What to do about each is yours to decide. Your bots folder: ${bots}`,
@@ -956,12 +957,15 @@ function usageLines(usage, bots) {
956
957
  lines.push(`${'bot'.padEnd(9)} ${entry.bot}`);
957
958
  for (const session of entry.sessions) {
958
959
  lines.push(`${'session'.padEnd(9)} ${session.name}`);
960
+ const notCounted = notCountedLine(session.not_counted);
959
961
  lines.push(...session.conversations.map(conversationLine));
960
- if (session.conversations.length === 0) lines.push(' nothing on record');
962
+ if (session.conversations.length === 0 && notCounted.length === 0) lines.push(' nothing on record');
963
+ lines.push(...notCounted);
961
964
  }
962
- if (entry.unclaimed.length > 0) {
965
+ if (entry.unclaimed.length > 0 || notCountedLine(entry.unclaimed_not_counted).length > 0) {
963
966
  lines.push(`${'unclaimed'.padEnd(9)} ${entry.bot}: no session of this bot claims these`);
964
967
  lines.push(...entry.unclaimed.map(conversationLine));
968
+ lines.push(...notCountedLine(entry.unclaimed_not_counted));
965
969
  }
966
970
  }
967
971
 
@@ -971,6 +975,19 @@ function usageLines(usage, bots) {
971
975
  return lines;
972
976
  }
973
977
 
978
+ /** What could not be counted, as one line, or no line when everything was. */
979
+ function notCountedLine(gaps) {
980
+ const said = [
981
+ [gaps.unreadable_transcripts, 'transcript', 'transcripts', 'unreadable'],
982
+ [gaps.broken_lines, 'line', 'lines', 'broken'],
983
+ [gaps.records_without_numbers, 'record', 'records', 'with no usable number'],
984
+ [gaps.records_without_time, 'record', 'records', 'with no time'],
985
+ ]
986
+ .filter(([count]) => count > 0)
987
+ .map(([count, one, many, why]) => `${count} ${count === 1 ? one : many} ${why}`);
988
+ return said.length === 0 ? [] : [` not counted: ${said.join(', ')}`];
989
+ }
990
+
974
991
  /** One conversation: what it is, what it ran as, and what it used. */
975
992
  function conversationLine(one) {
976
993
  const ran = [...one.models, ...one.efforts].join(' ');
@@ -1128,6 +1145,25 @@ function toldLine(bot, { session, harness, state, read, blocked, trouble }) {
1128
1145
  const foundLines = (found) =>
1129
1146
  found.flatMap((one) => [`${one.kind.padEnd(9)} ${one.where}`, ` ${one.says}`]);
1130
1147
 
1148
+ /**
1149
+ * What one running session runs on, in a line: each setting's state, with what
1150
+ * was asked for and what the harness recorded, and its rules.
1151
+ */
1152
+ function settingsLine({ bot, session, running, settings, rules }) {
1153
+ const each = Object.entries(settings).map(([name, one]) => {
1154
+ const values = [
1155
+ ...(one.configured === undefined ? [] : [`asked ${one.configured}`]),
1156
+ ...(one.observed === undefined ? [] : [`runs ${one.observed}`]),
1157
+ ];
1158
+ return `${name} ${one.state}${values.length === 0 ? '' : ` (${values.join(', ')})`}`;
1159
+ });
1160
+ // A session whose harness the kit cannot see in its tab is not called running.
1161
+ const [word, who] = running === 'yes'
1162
+ ? ['running', `${bot} ${session}`]
1163
+ : ['unsure', `${bot} ${session} cannot tell whether its harness is running:`];
1164
+ return `${word.padEnd(9)} ${who} ${each.join(' ')} rules ${rules.state}`;
1165
+ }
1166
+
1131
1167
  function tabLines({ bots, created, completed, rules, skills, tabs, paused = [], projects = [], found = [] }, summary) {
1132
1168
  const lines = [
1133
1169
  ...created.map((entry) => `created ${entry}`),
@@ -1177,6 +1213,7 @@ function harnessLines(tab, bots) {
1177
1213
  ' the harness was typed in, and no session came up in the tab.',
1178
1214
  ` Look at it: ${lookAt(tab.terminal)}`,
1179
1215
  ...ANSWER_IT,
1216
+ ...promptLines(tab),
1180
1217
  ];
1181
1218
  }
1182
1219
 
@@ -1198,14 +1235,21 @@ function harnessLines(tab, bots) {
1198
1235
  ...ANSWER_IT,
1199
1236
  ];
1200
1237
 
1201
- if (tab.promptSent === true) lines.push(' the start prompt was typed in.');
1202
- if (tab.promptSent === false) {
1203
- lines.push(' the start prompt was not typed in: the tab was not ready for it.');
1204
- }
1238
+ lines.push(...promptLines(tab));
1205
1239
  if (tab.unclaimed !== undefined) lines.push(...unclaimedLines(tab, bots));
1206
1240
  return lines;
1207
1241
  }
1208
1242
 
1243
+ /**
1244
+ * Whether the session got its start prompt, as its own record has it (#274):
1245
+ * received, or not confirmed yet. Nothing for a session that was told nothing.
1246
+ */
1247
+ function promptLines(tab) {
1248
+ if (tab.promptReceived === true) return [' the start prompt was received: the session\'s own record holds it.'];
1249
+ if (tab.promptReceived === false) return [' the start prompt is not confirmed: the session\'s own record does not hold it yet.'];
1250
+ return [];
1251
+ }
1252
+
1209
1253
  try {
1210
1254
  process.exitCode = await run(process.argv.slice(2));
1211
1255
  } catch (error) {
@@ -32,12 +32,17 @@ const codexDir = () => path.join(homedir(), '.codex', 'sessions');
32
32
  * then that it does not know, which is the honest one.
33
33
  */
34
34
  export function conversationsIn(harness, home, since) {
35
- return transcriptsIn(harness, home, since).map((one) => ({ id: one.id, at: one.at }));
35
+ // A subagent's conversation ran in the folder too, but no session had it: the
36
+ // harness says it is a helper's, so it is never offered to one to claim.
37
+ return transcriptsIn(harness, home, since)
38
+ .filter((one) => !one.subagent)
39
+ .map((one) => ({ id: one.id, at: one.at }));
36
40
  }
37
41
 
38
42
  /**
39
43
  * The same conversations, each with the file the harness keeps it in, for a
40
- * caller that has to read what is inside one rather than only know it is there.
44
+ * caller that has to read what is inside one rather than only know it is there,
45
+ * and whether the harness marks it as a subagent's.
41
46
  */
42
47
  export function transcriptsIn(harness, home, since) {
43
48
  const from = since === undefined ? 0 : Date.parse(since);
@@ -45,7 +50,47 @@ export function transcriptsIn(harness, home, since) {
45
50
  return found
46
51
  .filter((one) => Number.isNaN(from) || one.at >= from)
47
52
  .sort((left, right) => left.at - right.at)
48
- .map((one) => ({ id: one.id, at: new Date(one.at).toISOString(), file: one.file }));
53
+ .map((one) => ({ id: one.id, at: new Date(one.at).toISOString(), file: one.file, subagent: one.subagent === true }));
54
+ }
55
+
56
+ /**
57
+ * Whether a conversation's own record holds `text` as a turn of the user's:
58
+ * what the harness itself wrote down as said to it, not a screen that was up.
59
+ *
60
+ * Only the user's turns count. Claude Code writes them as `user` lines, and its
61
+ * own meta lines and tool results in that same shape are not the user's. Codex
62
+ * writes each as a `user` message item; its `user_message` event is missing
63
+ * from many rollouts, and the item is in every one (tech notes, section 3).
64
+ * Its AGENTS.md goes in as a `user` item too, and cannot be the text asked about.
65
+ */
66
+ export function heldAsUserTurn(harness, file, text) {
67
+ let lines;
68
+ try {
69
+ lines = readFileSync(file, 'utf8').split('\n');
70
+ } catch {
71
+ return false;
72
+ }
73
+ const wanted = text.trim();
74
+ return lines.some((line) => userTexts(harness, line).some((said) => said.trim() === wanted));
75
+ }
76
+
77
+ /** The texts of one record line, when it is a turn of the user's. */
78
+ function userTexts(harness, line) {
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(line);
82
+ } catch {
83
+ return [];
84
+ }
85
+ if (harness === 'claude') {
86
+ if (parsed?.type !== 'user' || parsed.isMeta === true) return [];
87
+ const content = parsed.message?.content;
88
+ if (typeof content === 'string') return [content];
89
+ return Array.isArray(content) ? content.filter((block) => block?.type === 'text').map((block) => String(block.text)) : [];
90
+ }
91
+ const item = parsed?.type === 'response_item' ? parsed.payload : undefined;
92
+ if (item?.type !== 'message' || item.role !== 'user' || !Array.isArray(item.content)) return [];
93
+ return item.content.filter((block) => block?.type === 'input_text').map((block) => String(block.text));
49
94
  }
50
95
 
51
96
  function claudeConversations(home) {
@@ -87,7 +132,7 @@ function codexConversations(home, from) {
87
132
  .flatMap((file) => {
88
133
  const meta = sessionMeta(file);
89
134
  return meta?.cwd === home && typeof meta.id === 'string'
90
- ? [{ id: meta.id, at: Date.parse(meta.timestamp ?? '') || startedAt(file) || 0, file }]
135
+ ? [{ id: meta.id, at: Date.parse(meta.timestamp ?? '') || startedAt(file) || 0, file, subagent: isSubagent(meta) }]
91
136
  : [];
92
137
  });
93
138
  }
@@ -113,6 +158,15 @@ function sessionMeta(file) {
113
158
  }
114
159
  }
115
160
 
161
+ /**
162
+ * Codex marks a conversation it ran as a subagent under `source`: its own
163
+ * auto-review is `{ subagent: { other: 'guardian' } }`, and one a session spawned
164
+ * is `{ subagent: { thread_spawn: … } }`. A session's own is a plain word, such as
165
+ * `cli` or `exec` (tech notes, section 3).
166
+ */
167
+ const isSubagent = (meta) =>
168
+ meta.source !== null && typeof meta.source === 'object' && 'subagent' in meta.source;
169
+
116
170
  /** When a file was last written, for leaving out what is plainly too old. */
117
171
  function lastTouched(file) {
118
172
  try {
package/src/health.js CHANGED
@@ -21,13 +21,14 @@
21
21
  import { existsSync, readdirSync, realpathSync } from 'node:fs';
22
22
  import path from 'node:path';
23
23
 
24
- import { bookFile, readBook, tabIdsIn } from './book.js';
24
+ import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
25
25
  import { botDir, botNames, botsDir, readBot } from './bot.js';
26
26
  import { transcriptsIn } from './conversations.js';
27
27
  import { hookTrouble } from './hooks.js';
28
- import { bypassFlags, harnessOf, HARNESSES, sessionTrouble } from './launch.js';
29
- import { orcaDefaultArgs, projects, tabs } from './orca.js';
30
- import { agentsTrouble } from './rules.js';
28
+ import { bypassFlags, harnessOf, HARNESSES, ownCli, sessionTrouble, shellWord } from './launch.js';
29
+ import { frontOfTab, orcaDefaultArgs, projects, tabs } from './orca.js';
30
+ import { agentsTrouble, rulesStamp } from './rules.js';
31
+ import { settingsInUse } from './settings.js';
31
32
  import { skillsTrouble } from './skills.js';
32
33
  import { readSources, sourcesDir } from './sources.js';
33
34
  import { BOT_FATHER, botsNamed, promptPath } from './up.js';
@@ -37,14 +38,18 @@ import { BOT_FATHER, botsNamed, promptPath } from './up.js';
37
38
  * settings do to every session, what is lying about that no book owns, and then
38
39
  * each bot in name order. `bot` narrows the per-bot checks to one; what the
39
40
  * whole fleet lives under is reported whichever bot was named.
41
+ *
42
+ * Beside the list, `sessions`: each running session the book knows, with what
43
+ * it runs on against what its bot asks for now, good news included.
40
44
  */
41
45
  export function checkHealth(bots, { bot: onlyBot } = {}) {
42
46
  const names = botsNamed(bots, onlyBot);
43
47
  const setups = projects();
44
48
 
45
49
  const found = [...orcaSettingFindings(), ...leftInOrca(bots, setups), ...leftBeside(bots)];
46
- for (const name of names) found.push(...aboutBot(bots, name, setups));
47
- return found;
50
+ const sessions = [];
51
+ for (const name of names) found.push(...aboutBot(bots, name, setups, sessions));
52
+ return { found, sessions };
48
53
  }
49
54
 
50
55
  /** One finding, as the report and the `--json` answer carry it. */
@@ -165,8 +170,8 @@ function leftBeside(bots) {
165
170
  return found;
166
171
  }
167
172
 
168
- /** Everything wrong with one bot. */
169
- function aboutBot(bots, name, setups) {
173
+ /** Everything wrong with one bot. Its running sessions are added to `sessions`. */
174
+ function aboutBot(bots, name, setups, sessions) {
170
175
  const home = botDir(bots, name);
171
176
 
172
177
  let bot;
@@ -185,7 +190,7 @@ function aboutBot(bots, name, setups) {
185
190
  ...agentsTrouble(bots, home, bot).map(said('config')),
186
191
  ...hooksOf(bots, home, bot).map(said('config')),
187
192
  ...skillsTrouble(bots, home, bot).map(said('skill')),
188
- ...inOrca(home, bot, setups),
193
+ ...inOrca(bots, home, bot, setups, sessions),
189
194
  ];
190
195
  }
191
196
 
@@ -206,12 +211,97 @@ const hooksOf = (bots, home, bot) => [...new Set(bot.sessions.map((session) => h
206
211
  .map((harness) => hookTrouble(home, harness, { bots, bot: bot.name }))
207
212
  .filter((trouble) => trouble !== undefined);
208
213
 
214
+ /**
215
+ * Each session of this bot running in a tab Orca has, with what it runs on set
216
+ * beside what the bot asks for now, added to `sessions`; a finding for each
217
+ * one that runs on something else (#271, #272); and a finding for each one
218
+ * whose tab is open with its harness gone from it (#300).
219
+ *
220
+ * Two questions, each answered from where the answer is written. The settings
221
+ * from the harness's own record of the conversation the session is in now,
222
+ * which is what the harness really used. The rules from the stamp the kit noted
223
+ * in the book when the session last read them, against `AGENTS.md` now: nothing
224
+ * the harness writes says which instructions it read.
225
+ *
226
+ * Only a mismatch and older rules are findings. What cannot be read is said as
227
+ * unknown in `sessions`, and never taken for agreement.
228
+ */
229
+ function runningOn(bots, home, bot, book, handles, sessions) {
230
+ const real = realpathOf(home) ?? home;
231
+ const records = new Map();
232
+ const recordOf = (harness, id) => {
233
+ if (!HARNESSES.includes(harness) || id === null) return undefined;
234
+ if (!records.has(harness)) records.set(harness, new Map(transcriptsIn(harness, real).map((one) => [one.id, one.file])));
235
+ return records.get(harness).get(id);
236
+ };
237
+
238
+ const stamp = rulesStamp(home);
239
+ const found = [];
240
+ for (const session of bot.sessions) {
241
+ const entry = book.sessions[session.name];
242
+ // Running is a tab Orca still has, with the session's harness in front of
243
+ // it. A session whose tab is gone is not running whatever its record says,
244
+ // and the missing tab is said already.
245
+ if (bot.paused === true || session.paused === true || entry === null || typeof entry !== 'object') continue;
246
+ const handle = handles.get(entry.tab);
247
+ if (handle === undefined) continue;
248
+
249
+ // A harness can quit to the tab's shell and leave the tab open (#232), so
250
+ // the tab alone is not the answer: who holds its terminal is, asked the way
251
+ // the kit asks before it types into a tab. The shell in front is a session
252
+ // that is not running, and a finding: `up` finds the tab open and types
253
+ // nothing into it, so only a restart brings it back (#300). Anything else
254
+ // that is not its own harness, or a front that cannot be read, leaves it
255
+ // unsaid, and nothing unsaid is a finding.
256
+ const restart = `${shellWord(ownCli())} restart --bots ${shellWord(bots)} --bot ${bot.name} --session ${session.name}`;
257
+ const front = frontOfTab(handle);
258
+ if (front.front === 'shell') {
259
+ // Restart refuses a live tab whose conversation the book cannot name, and
260
+ // there may be none to name: the harness can quit before it has one. Its
261
+ // harness is gone, so closing the tab ends nothing, and `up` then starts
262
+ // the session again as it does any closed tab with no conversation.
263
+ const up = `${shellWord(ownCli())} up --bots ${shellWord(bots)} --bot ${bot.name} --session ${session.name}`;
264
+ const back = typeof entry.session === 'string'
265
+ ? `${restart} does`
266
+ : `nor does obk restart, which refuses to close a tab whose conversation the book does not name, and the book names none for it. Its harness has already quit, so close the tab in Orca yourself; then ${up} starts it again, with a new conversation and its duty. Any conversation it left in this bot's folder is noted in the book as unclaimed, for health to list`;
267
+ found.push(finding('session', entry.tab, `${bot.name}'s session ${session.name} is not running: its tab ${entry.tab} is open with only the tab's shell in front, so its harness quit or crashed. obk up finds the tab open and types nothing into it, so it does not bring the session back; ${back}.`, bot.name));
268
+ continue;
269
+ }
270
+ const harness = harnessOf(session, bot.harness);
271
+ const running = front.front === 'program' && front.command === harness ? 'yes' : 'unknown';
272
+ const conversation = typeof entry.session === 'string' ? entry.session : null;
273
+ const file = recordOf(harness, conversation);
274
+ const settings = settingsInUse(harness, session, file, typeof entry.launched === 'string' ? entry.launched : undefined);
275
+ const rules = { state: typeof entry.rules !== 'string' ? 'unknown' : entry.rules === stamp ? 'current' : 'older' };
276
+ sessions.push({ bot: bot.name, session: session.name, harness, running, conversation, settings, rules });
277
+ if (running !== 'yes') continue;
278
+
279
+ const off = Object.entries(settings).filter(([, one]) => one.state === 'mismatch');
280
+ if (off.length > 0) {
281
+ const parts = off.map(([name, one]) => `${name}: bot.yaml asks for ${one.configured}, and it runs on ${one.observed}`).join('; ');
282
+ found.push(finding('session', file, `${bot.name}'s session ${session.name} does not run on what ${path.join(home, 'bot.yaml')} asks for. ${parts}. That is what the harness's own record of its conversation ${conversation} says. A session takes these when it starts, so if bot.yaml changed after it started, ${restart} starts it on them; if not, something else set them, such as a default of the harness's own or a change made inside the session.`, bot.name));
283
+ }
284
+
285
+ if (rules.state === 'older') {
286
+ const agents = path.join(home, 'AGENTS.md');
287
+ // A clear on Claude Code reads the instructions again, as a start does
288
+ // (tech notes, section 2); whether a new conversation on Codex does is
289
+ // not established, so a start is all that is offered there.
290
+ const how = harness === 'claude'
291
+ ? `A Claude Code session reads it again at a /clear in its tab, or when it starts: ${restart}`
292
+ : `A Codex session reads it when it starts: ${restart}`;
293
+ found.push(finding('session', agents, `${bot.name}'s session ${session.name} is running on older rules: ${agents} has changed since the kit noted which version this session read. ${how}`, bot.name));
294
+ }
295
+ }
296
+ return found;
297
+ }
298
+
209
299
  /**
210
300
  * What Orca has of this bot against what the book says it should have: a
211
301
  * session whose tab is gone, a conversation nobody claims, and a tab in the
212
302
  * bot's project that the book does not name.
213
303
  */
214
- function inOrca(home, bot, setups) {
304
+ function inOrca(bots, home, bot, setups, sessions) {
215
305
  // The book is read where it is read, and a book nothing can parse is a
216
306
  // finding like any other: it says which tab each session is in and which
217
307
  // conversation it is running, so without it nothing else here can be asked —
@@ -236,19 +326,26 @@ function inOrca(home, bot, setups) {
236
326
  // what paused means rather than something lost.
237
327
  const closed = new Set(bot.sessions.filter((session) => bot.paused === true || session.paused === true).map((session) => session.name));
238
328
 
329
+ // What the harness has on record in this bot's folder, read once: it is a walk
330
+ // through every conversation on the machine.
331
+ const onRecord = recordIn(real ?? home, bot);
332
+ // A conversation the harness marks as a subagent's was never a session's, so a
333
+ // note that holds one is not a loose end for it (#287). The note stays as it is.
334
+ const helpers = new Set(onRecord.filter((one) => one.subagent).map((one) => one.id));
335
+
239
336
  const found = [];
240
337
  for (const [name, entry] of Object.entries(book.sessions)) {
241
338
  if (typeof entry?.tab === 'string' && !there.has(entry.tab) && !closed.has(name)) {
242
339
  found.push(finding('session', entry.tab, `${bot.name}'s session ${name} is in the book with tab ${entry.tab}, and Orca has no tab of that id: the tab was closed, or the machine was restarted. The conversation is in the book, and obk up opens a tab and brings it back.`, bot.name));
243
340
  }
244
341
 
245
- const unclaimed = Array.isArray(entry?.unclaimed) ? entry.unclaimed : [];
342
+ const unclaimed = (Array.isArray(entry?.unclaimed) ? entry.unclaimed : []).filter((id) => !helpers.has(id));
246
343
  if (unclaimed.length > 0) {
247
344
  found.push(finding('leftover', bookFile(home), `The book notes conversations under ${bot.name}'s session ${name} that no session of this bot claims: ${unclaimed.join(', ')}. They ran in this bot's folder, and the kit will not say whose they were. To give one back, write it into ${bookFile(home)} under ${name} as session: <id> and run obk up again.`, bot.name));
248
345
  }
249
346
  }
250
347
 
251
- found.push(...offTheBook(real ?? home, bot, book));
348
+ found.push(...offTheBook(real ?? home, bot, book, onRecord));
252
349
 
253
350
  // A tab outside the book is somebody else's, and in Bot Father's project it
254
351
  // is the ops tab: the one tab the kit deliberately keeps no record of, so it
@@ -260,6 +357,7 @@ function inOrca(home, bot, setups) {
260
357
  }
261
358
  }
262
359
 
360
+ found.push(...runningOn(bots, home, bot, book, new Map(live.map((tab) => [tab.tabId, tab.handle])), sessions));
263
361
  return found;
264
362
  }
265
363
 
@@ -269,27 +367,30 @@ function inOrca(home, bot, setups) {
269
367
  * fails it fails quietly (ADR 0020), so this is where a stale book shows: the
270
368
  * harness's own record set beside it (ADR 0012).
271
369
  */
272
- function offTheBook(home, bot, book) {
273
- const named = new Set();
370
+ function offTheBook(home, bot, book, onRecord) {
371
+ // A retired session's conversations are in the book too, under `retired`.
372
+ const named = sessionIdsIn(book);
274
373
  for (const entry of Object.values(book.sessions)) {
275
- if (typeof entry?.session === 'string') named.add(entry.session);
276
- for (const was of Array.isArray(entry?.history) ? entry.history : []) named.add(was?.session);
277
374
  for (const id of Array.isArray(entry?.unclaimed) ? entry.unclaimed : []) named.add(id);
278
375
  }
279
376
 
280
- // Every harness any of its sessions runs on, as `obk usage` reads them.
281
- const harnesses = new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)));
282
- if (harnesses.size === 0) harnesses.add(bot.harness);
283
- const stray = [...harnesses]
284
- .filter((harness) => HARNESSES.includes(harness))
285
- .flatMap((harness) => transcriptsIn(harness, home))
286
- .filter((one) => !named.has(one.id));
377
+ // A subagent's conversation is no session's, so the book has no reason to name it.
378
+ const stray = onRecord.filter((one) => !one.subagent && !named.has(one.id));
287
379
  if (stray.length === 0) return [];
288
380
 
289
381
  const listed = stray.map((one) => `${one.id} (begun ${one.at})`).join(', ');
290
382
  return [finding('session', bookFile(home), `The harness has conversations on record in ${bot.name}'s folder that the book does not name: ${listed}. The kit's hook may have missed them: a clear it did not record, or a Codex hooks file trusted late. To give one to a session, write it into ${bookFile(home)} under that session as session: <id> and run obk up again.`, bot.name)];
291
383
  }
292
384
 
385
+ /** Every conversation on record in the bot's folder, from every harness any of its sessions runs on, as `obk usage` reads them. */
386
+ function recordIn(home, bot) {
387
+ const harnesses = new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)));
388
+ if (harnesses.size === 0) harnesses.add(bot.harness);
389
+ return [...harnesses]
390
+ .filter((harness) => HARNESSES.includes(harness))
391
+ .flatMap((harness) => transcriptsIn(harness, home));
392
+ }
393
+
293
394
  /** What a directory holds, and nothing at all when there is no directory. */
294
395
  function namesIn(dir) {
295
396
  try {
package/src/orca.js CHANGED
@@ -312,6 +312,21 @@ export function tabToTypeInto(home, tabId, timeoutMs) {
312
312
  return { handle: live.handle, agent: seen.agent };
313
313
  }
314
314
 
315
+ /**
316
+ * Who holds the terminal of the tab `handle`, as `frontOf` answers, without
317
+ * waiting on the tab: for a caller that asks about every tab and has nothing
318
+ * to type. Orca refusing is an answer that cannot be read, not an error.
319
+ */
320
+ export function frontOfTab(handle) {
321
+ let shown;
322
+ try {
323
+ shown = orca(['terminal', 'show', '--terminal', handle]).terminal;
324
+ } catch (error) {
325
+ return { unreadable: `Orca would not show the tab: ${error.message}` };
326
+ }
327
+ return frontOf(shown?.ptyId);
328
+ }
329
+
315
330
  /** The `ps` this run reads. OBK_PS overrides it, as OBK_ORCA does Orca. */
316
331
  const psCli = () => process.env.OBK_PS || '/bin/ps';
317
332
 
package/src/record.js CHANGED
@@ -17,6 +17,7 @@ import { forgetClaimed, readBook, rememberSession, sessionIdsIn, updateBook, wit
17
17
  import { botDir, readBot } from './bot.js';
18
18
  import { conversationsIn } from './conversations.js';
19
19
  import { harnessOf, SHELL_ENV, startPrompt, workDirOf } from './launch.js';
20
+ import { rulesStamp } from './rules.js';
20
21
 
21
22
  /** Where Orca says which tab a program is running in (tech notes, section 1). */
22
23
  export const TAB_ENV = 'ORCA_TAB_ID';
@@ -79,6 +80,11 @@ export async function recordSession(bots, name, said, tabId, shellPid) {
79
80
  // this bot's folder, which every session of the bot shares — so it can say
80
81
  // that a conversation nobody claims exists, and never whose it is.
81
82
  const unclaimed = unclaimedFor(readBook(home), home, bot, tabId, id);
83
+ // A clear on Claude Code reads the bot's instructions again, as a start does
84
+ // (tech notes, section 2), so the book notes which ones it read. Codex has no
85
+ // word for its clear, and whether its new conversation reads them again is
86
+ // not established, so nothing is noted for it.
87
+ const rules = said.source === CLEARED ? rulesStamp(home) : undefined;
82
88
 
83
89
  let cleared = false;
84
90
  let told;
@@ -100,7 +106,8 @@ export async function recordSession(bots, name, said, tabId, shellPid) {
100
106
  const uncertain = was.session === undefined && unclaimed.length > 0;
101
107
  told = session;
102
108
 
103
- book.sessions[session] = withUnclaimed(rememberSession(was, id, said.source), unclaimed);
109
+ const now = rememberSession(was, id, said.source);
110
+ book.sessions[session] = withUnclaimed(rules === undefined ? now : { ...now, rules }, unclaimed);
104
111
  forgetClaimed(book);
105
112
  if (uncertain) cleared = true;
106
113
  return book;
package/src/rules.js CHANGED
@@ -406,3 +406,18 @@ function sameFile(one, other) {
406
406
  return false;
407
407
  }
408
408
  }
409
+
410
+ /**
411
+ * A stamp of a bot's `AGENTS.md` as it is now, or undefined when there is none
412
+ * to read. It is what a session read when it started, noted in the book at that
413
+ * moment, so that health can tell a session running on older rules from one on
414
+ * the current ones. The whole file, not only the build's block: what the user
415
+ * wrote below the end marker is part of what the session reads.
416
+ */
417
+ export function rulesStamp(home) {
418
+ try {
419
+ return checksum(readFileSync(path.join(home, AGENTS), 'utf8'));
420
+ } catch {
421
+ return undefined;
422
+ }
423
+ }
@@ -0,0 +1,158 @@
1
+ // What a running session really runs with, set beside what its bot.yaml asks for.
2
+ //
3
+ // A session can run on settings other than the ones bot.yaml asks for: it
4
+ // started before a change, a default of the user's own leaked in, or a flag was
5
+ // dropped. Each harness writes down what it used (tech notes, sections 2 and 3),
6
+ // so the two can be put side by side. Read only: these are the harness's files.
7
+ //
8
+ // Four answers, and only one of them is good news. A record that cannot be read,
9
+ // or that does not carry the setting, is not agreement: it is `unknown`. A
10
+ // setting the session never asked for is not a disagreement either, whatever the
11
+ // harness chose: it is `not-asked-for`.
12
+
13
+ import { DEFAULT_APPROVAL } from './launch.js';
14
+ import { lines } from './usage.js';
15
+
16
+ export const SETTINGS = ['model', 'effort', 'context', 'approval'];
17
+
18
+ /**
19
+ * Each setting of `session` as `{ state, configured?, observed? }`, judged
20
+ * against `file`, the harness's record of the conversation the session is in
21
+ * now, from `since`, when the kit last started it. No file is no record, and
22
+ * every setting asked for is then unknown.
23
+ */
24
+ export function settingsInUse(harness, session, file, since) {
25
+ const used = file === undefined ? {} : (harness === 'claude' ? fromClaude : fromCodex)(sinceStart(lines(file), since));
26
+ return Object.fromEntries(SETTINGS.map((name) => [name, judged(harness, name, asked(session, name), used[name])]));
27
+ }
28
+
29
+ /**
30
+ * What the record says since the kit last started the session. A start resumes
31
+ * the conversation it had, so the file still holds what the process before it
32
+ * wrote, and that says what that process ran with, not this one. Everything
33
+ * from the first line written at or after the start counts, a line with no time
34
+ * of its own included. With no start to go by, no line can be shown to be this
35
+ * process's, so none counts.
36
+ */
37
+ function sinceStart(entries, since) {
38
+ const from = Date.parse(since ?? '');
39
+ if (Number.isNaN(from)) return [];
40
+ const first = entries.findIndex((entry) => Date.parse(entry.timestamp ?? '') >= from);
41
+ return first === -1 ? [] : entries.slice(first);
42
+ }
43
+
44
+ /** What the session asks for, as bot.yaml has it, or undefined when it asks for nothing. */
45
+ function asked(session, name) {
46
+ const value = session[name];
47
+ const set = value !== undefined && value !== null && String(value).trim() !== '';
48
+ // The kit puts an approval level on every launch line, so a session always
49
+ // asks for one: the default, when it names none (ADR 0015).
50
+ if (name === 'approval') return set ? String(value).trim() : DEFAULT_APPROVAL;
51
+ return set ? String(value).trim() : undefined;
52
+ }
53
+
54
+ function judged(harness, name, configured, observed) {
55
+ const shown = observed === undefined ? {} : { observed: said(name, observed) };
56
+ if (configured === undefined) return { state: 'not-asked-for', ...shown };
57
+ const verdict = same(harness, name, configured, observed);
58
+ if (verdict === undefined) return { state: 'unknown', configured, ...shown };
59
+ return { state: verdict ? 'match' : 'mismatch', configured, ...shown };
60
+ }
61
+
62
+ /** Whether the record says what was asked for; undefined when it cannot say. */
63
+ function same(harness, name, configured, observed) {
64
+ if (observed === undefined) return undefined;
65
+ if (name === 'model') return sameModel(configured, observed);
66
+ // Codex reports the window it was given at 95%, rounded down (tech notes,
67
+ // section 3): 200000 comes back as 190000.
68
+ if (name === 'context') return Number(observed) === Math.floor(Number(configured) * 0.95);
69
+ if (name === 'approval') return harness === 'claude' ? observed === CLAUDE_MODES[configured] : sameCodexApproval(configured, observed);
70
+ return observed === configured;
71
+ }
72
+
73
+ /**
74
+ * A model named without a dash is an alias, `sonnet`, and the harness writes the
75
+ * id it stands for, `claude-sonnet-5`: it matches an id it is one part of. A
76
+ * model with a dash is an id already, and has to be the one that ran. The
77
+ * context window Claude Code carries on the model name, `sonnet[1m]`, is not
78
+ * part of what it writes down.
79
+ */
80
+ function sameModel(configured, observed) {
81
+ const model = configured.replace(/\[[^\]]*\]$/, '');
82
+ return model.includes('-') ? observed === model : observed.split('-').includes(model);
83
+ }
84
+
85
+ /** The permission mode Claude Code records for each level (tech notes, section 2). */
86
+ const CLAUDE_MODES = { auto: 'auto', ask: 'default', 'dangerously-skip': 'bypassPermissions' };
87
+
88
+ /**
89
+ * What Codex records for each level, and only the words that level decides: at
90
+ * `ask` the kit passes only the approval policy, so the sandbox is the user's
91
+ * own (ADR 0015), and at `dangerously-skip` nothing is reviewed (tech notes,
92
+ * section 3).
93
+ */
94
+ const CODEX_WORDS = {
95
+ auto: { policy: 'on-request', reviewer: 'auto_review', sandbox: 'workspace-write' },
96
+ ask: { policy: 'on-request', reviewer: 'user' },
97
+ 'dangerously-skip': { policy: 'never', sandbox: 'danger-full-access' },
98
+ };
99
+
100
+ function sameCodexApproval(configured, observed) {
101
+ const wanted = CODEX_WORDS[configured];
102
+ if (wanted === undefined) return false;
103
+ const pairs = Object.entries(wanted);
104
+ // A word the level decides and the record does not carry leaves it unsaid.
105
+ if (pairs.some(([key]) => observed[key] === undefined)) return undefined;
106
+ return pairs.every(([key, word]) => observed[key] === word);
107
+ }
108
+
109
+ /** A value the record carried, as the report shows it. */
110
+ function said(name, observed) {
111
+ if (name !== 'approval' || typeof observed === 'string') return String(observed);
112
+ return [
113
+ `approval_policy ${observed.policy}`,
114
+ ...(observed.reviewer === undefined ? [] : [`approvals_reviewer ${observed.reviewer}`]),
115
+ ...(observed.sandbox === undefined ? [] : [`sandbox ${observed.sandbox}`]),
116
+ ].join(', ');
117
+ }
118
+
119
+ /**
120
+ * What a Claude Code transcript says it runs with, the latest of each: the
121
+ * model and effort on its replies, the permission mode wherever it is noted.
122
+ * Claude Code writes nothing that shows the context window.
123
+ */
124
+ function fromClaude(entries) {
125
+ const used = {};
126
+ for (const entry of entries) {
127
+ if (entry.type === 'assistant' && entry.isSidechain !== true) {
128
+ const model = entry.message?.model;
129
+ if (typeof model === 'string' && model !== '<synthetic>') used.model = model;
130
+ if (typeof entry.effort === 'string') used.effort = entry.effort;
131
+ }
132
+ if (typeof entry.permissionMode === 'string') used.approval = entry.permissionMode;
133
+ }
134
+ return used;
135
+ }
136
+
137
+ /** What a Codex rollout says it runs with: its latest turn, and its latest count. */
138
+ function fromCodex(entries) {
139
+ const used = {};
140
+ for (const entry of entries) {
141
+ const payload = entry.payload;
142
+ if (payload === null || typeof payload !== 'object') continue;
143
+ if (entry.type === 'turn_context') {
144
+ if (typeof payload.model === 'string') used.model = payload.model;
145
+ if (typeof payload.effort === 'string') used.effort = payload.effort;
146
+ if (typeof payload.approval_policy === 'string') {
147
+ used.approval = {
148
+ policy: payload.approval_policy,
149
+ reviewer: typeof payload.approvals_reviewer === 'string' ? payload.approvals_reviewer : undefined,
150
+ sandbox: typeof payload.sandbox_policy?.type === 'string' ? payload.sandbox_policy.type : undefined,
151
+ };
152
+ }
153
+ }
154
+ const window = payload.info?.model_context_window;
155
+ if (entry.type === 'event_msg' && payload.type === 'token_count' && typeof window === 'number') used.context = window;
156
+ }
157
+ return used;
158
+ }
package/src/up.js CHANGED
@@ -6,14 +6,15 @@
6
6
 
7
7
  import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
8
8
  import path from 'node:path';
9
+ import { setTimeout as pause } from 'node:timers/promises';
9
10
 
10
11
  import { forgetClaimed, readBook, sessionIdsIn, tabIdsIn, updateBook, withUnclaimed } from './book.js';
11
12
  import { botDir, botNames, displayName, readBot } from './bot.js';
12
- import { conversationsIn } from './conversations.js';
13
+ import { conversationsIn, heldAsUserTurn, transcriptsIn } from './conversations.js';
13
14
  import { installHook } from './hooks.js';
14
15
  import { addressOf, harnessOf, isShortPrompt, launchCommand, reachesMail, sessionTrouble, startPrompt, workDirOf } from './launch.js';
15
16
  import { asFolderProject, findProject, harnessInTab, makeMailbox, makeProject, openTab, retitleTab, tabs, tellWindow, typeIntoTab, useMailbox } from './orca.js';
16
- import { buildAgents } from './rules.js';
17
+ import { buildAgents, rulesStamp } from './rules.js';
17
18
  import { linkSkills } from './skills.js';
18
19
 
19
20
  /** The one bot with a tab beside its sessions: the ops tab (PRD 6.2). */
@@ -300,12 +301,17 @@ async function bringUpSession(bots, home, live, session, bot, title) {
300
301
  // started a harness in it, and a conversation taken over from the harness's
301
302
  // own record are new.
302
303
  const launched = new Date().toISOString();
304
+ // Which instructions it is about to read: the AGENTS.md this run has just
305
+ // built, noted so that health can say when the file moves on and the session
306
+ // does not (#272).
307
+ const rules = rulesStamp(home);
303
308
  await updateBook(home, (current) => {
304
309
  // What the harness has in this folder that nobody claims goes on the record,
305
310
  // for a person or Bot Father to settle — added to whatever was already noted,
306
311
  // because this run's scan cannot see what an earlier one found. The kit never
307
312
  // settles it itself.
308
- const entry = { ...current.sessions[session.name], tab: made.tabId, launched };
313
+ const entry = { ...current.sessions[session.name], tab: made.tabId, launched, rules };
314
+ if (rules === undefined) delete entry.rules;
309
315
  current.sessions[session.name] = withUnclaimed(entry, which.unclaimed ?? []);
310
316
  forgetClaimed(current);
311
317
  });
@@ -334,24 +340,54 @@ async function bringUpSession(bots, home, live, session, bot, title) {
334
340
  const tui = first.running ? lookFor(made.handle, SECOND_LOOK_MS) : first;
335
341
 
336
342
  // The start prompt went in with that line, as the harness's own prompt
337
- // argument, so it is the harness that holds it until it is ready — through
338
- // its folder-trust question and its update offer. What is still worth saying
339
- // is whether the line took at all: a shell that swallowed it swallowed the
340
- // duty with it, and nobody has been told anything.
341
- const promptSent = prompt === undefined ? undefined : tui.running === true;
343
+ // argument, and a harness running in the tab is not yet a session told its
344
+ // duty: one held on a first-run screen, or one that refused the argument, has
345
+ // been told nothing. So it is received only when the session's own record
346
+ // holds it as a user turn, and otherwise not confirmed (#274).
347
+ const promptReceived = prompt === undefined
348
+ ? undefined
349
+ : await heldInRecord(home, session.name, harness, prompt, { launched, running: tui.running === true });
342
350
 
343
351
  return entry(made, {
344
352
  bot: bot.name,
345
353
  name: session.name,
346
354
  created: true,
347
355
  ...tui,
348
- promptSent,
356
+ promptReceived,
349
357
  promptFile,
350
358
  resumed: resume !== undefined,
351
359
  unclaimed: which.unclaimed,
352
360
  });
353
361
  }
354
362
 
363
+ /**
364
+ * How long a running harness's own record is given to show the start prompt.
365
+ * Seen live (Claude Code 2.1.282, a trusted folder): the hook's id and the
366
+ * user turn landed about as the second look ended, so one read there comes a
367
+ * moment too early. A harness held on a first-run screen costs the whole wait.
368
+ */
369
+ const RECORD_MS = 5000;
370
+ const RECORD_ASK_MS = 250;
371
+
372
+ /**
373
+ * Whether the conversation the book names for this session holds `prompt` as a
374
+ * user turn in the harness's own record. The name comes from the session's own
375
+ * hook, so a harness that has not reported one — held on a first-run screen,
376
+ * say — has no record to be read, and the answer is that it is not confirmed.
377
+ * Another conversation in the same folder is never asked: it may be a sister
378
+ * session told the same words.
379
+ */
380
+ async function heldInRecord(home, name, harness, prompt, { launched, running }) {
381
+ const until = Date.now() + (running ? RECORD_MS : 0);
382
+ for (;;) {
383
+ const id = readBook(home).sessions[name]?.session;
384
+ const record = typeof id === 'string' ? transcriptsIn(harness, home, launched).find((one) => one.id === id) : undefined;
385
+ if (record !== undefined && heldAsUserTurn(harness, record.file, prompt)) return true;
386
+ if (Date.now() >= until) return false;
387
+ await pause(RECORD_ASK_MS);
388
+ }
389
+ }
390
+
355
391
  /**
356
392
  * One look at a tab the kit just typed a launch line into: whether a harness
357
393
  * is running there, and what it is waiting on if Orca says.
@@ -490,7 +526,7 @@ const ids = (setup, change) => ({ project: setup.projectId, setup: setup.id, cha
490
526
  export const promptPath = (bots, bot, session) =>
491
527
  path.join(`${bots}.prompts`, `${encodeURIComponent(bot)}.${encodeURIComponent(session)}.txt`);
492
528
 
493
- function entry(tab, { bot, name, created, running = false, blockedReason, promptSent, promptFile, resumed, unclaimed }) {
529
+ function entry(tab, { bot, name, created, running = false, blockedReason, promptReceived, promptFile, resumed, unclaimed }) {
494
530
  const made = { bot, name, title: tab.title, tabId: tab.tabId, terminal: tab.handle, created, harnessStarted: running };
495
531
  // Whether this run picked the session up where it was or started a new one.
496
532
  // Only for a tab this run opened: a tab that was already there was left alone.
@@ -502,7 +538,7 @@ function entry(tab, { bot, name, created, running = false, blockedReason, prompt
502
538
  // claims: the caller is told, because the kit will not pick one.
503
539
  if (unclaimed !== undefined && unclaimed.length > 0) made.unclaimed = unclaimed;
504
540
  // Only for a session this run started that had something to be told.
505
- if (promptSent !== undefined) made.promptSent = promptSent;
541
+ if (promptReceived !== undefined) made.promptReceived = promptReceived;
506
542
  // And the file it was told it out of, when it was too long for the line.
507
543
  if (promptFile !== undefined) made.promptFile = promptFile;
508
544
  return made;
package/src/usage.js CHANGED
@@ -41,6 +41,21 @@ import { harnessOf } from './launch.js';
41
41
  /** The kinds a call's tokens are reported in, the same on either harness. */
42
42
  const KINDS = ['input', 'output', 'cache_read', 'cache_write', 'reasoning'];
43
43
 
44
+ /**
45
+ * What could not be counted, by why. A figure that quietly leaves something out
46
+ * reads as complete, so what is left out is said, and the totals cover only what
47
+ * was counted: nothing missing is guessed at, nor taken as zero, nor placed in a
48
+ * window it has no time to be placed in.
49
+ */
50
+ const GAPS = ['unreadable_transcripts', 'broken_lines', 'records_without_numbers', 'records_without_time'];
51
+
52
+ const noGaps = () => Object.fromEntries(GAPS.map((gap) => [gap, 0]));
53
+
54
+ const addGaps = (into, more) => {
55
+ for (const gap of GAPS) into[gap] += more[gap];
56
+ return into;
57
+ };
58
+
44
59
  /**
45
60
  * What each bot's sessions have used. `since` counts the calls made at or after
46
61
  * that moment rather than the conversations begun after it: a conversation that
@@ -99,20 +114,28 @@ function forBot(bots, name, onlySession, window) {
99
114
 
100
115
  const sessions = bot.sessions
101
116
  .filter((session) => onlySession === undefined || session.name === onlySession)
102
- .map((session) => ({
103
- name: session.name,
104
- conversations: idsIn(book.sessions[session.name] ?? {})
105
- .filter((id) => onRecord.has(id))
106
- .map((id) => counted(onRecord.get(id), window))
107
- .filter((one) => one !== undefined),
108
- }));
109
-
110
- const unclaimed = [...onRecord.values()]
111
- .filter((one) => !claimed.has(one.id))
112
- .map((one) => counted(one, window))
113
- .filter((one) => one !== undefined);
114
-
115
- return { bot: name, home, sessions, unclaimed };
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
+ });
124
+
125
+ const unclaimed = all([...onRecord.values()].filter((one) => !claimed.has(one.id)), window);
126
+
127
+ return { bot: name, home, sessions, unclaimed: unclaimed.conversations, unclaimed_not_counted: unclaimed.gaps };
128
+ }
129
+
130
+ /** Some conversations counted, and what all of them together could not count. */
131
+ function all(transcripts, window) {
132
+ const gaps = noGaps();
133
+ const conversations = transcripts.flatMap((one) => {
134
+ const { row, gaps: its } = counted(one, window);
135
+ addGaps(gaps, its);
136
+ return row === undefined ? [] : [row];
137
+ });
138
+ return { conversations, gaps };
116
139
  }
117
140
 
118
141
  /** The conversations a book entry names: the one it is in, then the ones before. */
@@ -122,9 +145,10 @@ const idsIn = (entry) => [
122
145
  ];
123
146
 
124
147
  /**
125
- * One conversation, counted. A conversation that spent nothing in the window is
126
- * left out rather than reported as a row of zeroes: it is not part of what has
127
- * happened since, and a page of zeroes is harder to read than a shorter page.
148
+ * One conversation, counted, and what in it could not be. A conversation that
149
+ * spent nothing in the window has no row rather than a row of zeroes: it is not
150
+ * part of what has happened since, and a page of zeroes is harder to read than a
151
+ * shorter page. What it could not count is still said.
128
152
  */
129
153
  function counted(one, window) {
130
154
  const read = one.harness === 'claude' ? fromClaude : fromCodex;
@@ -139,12 +163,16 @@ function counted(one, window) {
139
163
  byModel: new Map(),
140
164
  first: undefined,
141
165
  last: undefined,
166
+ gaps: noGaps(),
142
167
  };
143
168
 
144
- read(lines(one.file), window, tally);
145
- if (tally.calls === 0 && KINDS.every((kind) => tally.tokens[kind] === 0)) return undefined;
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);
173
+ if (tally.calls === 0 && KINDS.every((kind) => tally.tokens[kind] === 0)) return { gaps: tally.gaps };
146
174
 
147
- return {
175
+ return { gaps: tally.gaps, row: {
148
176
  id: one.id,
149
177
  calls: tally.calls,
150
178
  tokens: tally.tokens,
@@ -154,7 +182,7 @@ function counted(one, window) {
154
182
  compactions: tally.compactions,
155
183
  first: new Date(tally.first).toISOString(),
156
184
  last: new Date(tally.last).toISOString(),
157
- };
185
+ } };
158
186
  }
159
187
 
160
188
  /**
@@ -175,6 +203,13 @@ function counted(one, window) {
175
203
  * start. A transcript only grows, so one run's end is the next run's start and
176
204
  * the runs add up to the whole, with a call still being written at a boundary
177
205
  * charged once, part to each side (#169).
206
+ *
207
+ * A record with no time, or with a figure missing, is left out whole and said to
208
+ * be; the call it belongs to is counted from its other records, if it has any.
209
+ * But when the record last written before the window's start is one of those,
210
+ * what the call had grown to by then is not known, and what it grew by inside
211
+ * the window cannot be told apart from what it grew by before. So the call is
212
+ * not counted in that window, and is said to be.
178
213
  */
179
214
  function fromClaude(entries, window, tally) {
180
215
  const byCall = new Map();
@@ -182,25 +217,34 @@ function fromClaude(entries, window, tally) {
182
217
  for (const entry of entries) {
183
218
  const when = Date.parse(entry.timestamp ?? '');
184
219
  if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
185
- if (inside(when, window)) tally.compactions += 1;
220
+ if (Number.isNaN(when)) tally.gaps.records_without_time += 1;
221
+ else if (inside(when, window)) tally.compactions += 1;
186
222
  continue;
187
223
  }
188
224
  if (entry.type !== 'assistant') continue;
189
225
 
190
226
  const usage = entry.message?.usage;
191
227
  if (usage === undefined || usage === null) continue;
228
+ if (Number.isNaN(when)) {
229
+ tally.gaps.records_without_time += 1;
230
+ continue;
231
+ }
232
+ const broken = !complete(usage, CLAUDE_FIELDS);
233
+ if (broken && inside(when, window)) tally.gaps.records_without_numbers += 1;
192
234
  const call = `${entry.requestId}\u0000${entry.message?.id}`;
193
235
  if (!byCall.has(call)) byCall.set(call, []);
194
- byCall.get(call).push({ entry, when });
236
+ byCall.get(call).push({ entry, when, broken });
195
237
  }
196
238
 
197
239
  for (const records of byCall.values()) {
198
240
  const made = records[0].when;
199
- // A record with no time of its own says nothing about being outside, as in
200
- // `inside`: it counts as written before the end and not before the start.
201
- const atEnd = records.findLast(({ when }) => Number.isNaN(when) || when < window.to);
241
+ const atEnd = records.findLast(({ when, broken }) => !broken && when < window.to);
202
242
  if (atEnd === undefined) continue;
203
- const atStart = records.findLast(({ when }) => !Number.isNaN(when) && when < window.from);
243
+ const atStart = records.findLast(({ when }) => when < window.from);
244
+ if (atStart?.broken) {
245
+ if (inside(atEnd.when, window)) tally.gaps.records_without_numbers += 1;
246
+ continue;
247
+ }
204
248
 
205
249
  const now = figures(atEnd.entry);
206
250
  const was = atStart === undefined ? undefined : figures(atStart.entry);
@@ -212,6 +256,9 @@ function fromClaude(entries, window, tally) {
212
256
  }
213
257
  }
214
258
 
259
+ /** The figures Claude Code writes on every call, all of which a record needs to be counted. */
260
+ const CLAUDE_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'];
261
+
215
262
  /** What one Claude Code record says its call used so far. */
216
263
  function figures(entry) {
217
264
  const usage = entry.message.usage;
@@ -252,11 +299,23 @@ const CODEX_FIELDS = [
252
299
  *
253
300
  * The running total is followed through events outside the window as well, since
254
301
  * what a call added can only be measured against the event before it.
302
+ *
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.
255
312
  */
256
313
  function fromCodex(entries, window, tally) {
257
314
  let model;
258
315
  let effort;
259
316
  let running;
317
+ // Whether a running total with something missing came since the last complete one.
318
+ let broken = false;
260
319
 
261
320
  for (const entry of entries) {
262
321
  const when = Date.parse(entry.timestamp ?? '');
@@ -269,48 +328,73 @@ function fromCodex(entries, window, tally) {
269
328
  continue;
270
329
  }
271
330
  if (entry.type === 'compacted') {
272
- if (inside(when, window)) tally.compactions += 1;
331
+ if (Number.isNaN(when)) tally.gaps.records_without_time += 1;
332
+ else if (inside(when, window)) tally.compactions += 1;
273
333
  continue;
274
334
  }
275
335
  if (entry.type !== 'event_msg' || entry.payload?.type !== 'token_count') continue;
276
336
 
337
+ // No `info` is a note about rate limits, not a record of usage.
277
338
  const info = entry.payload?.info;
278
339
  if (info === undefined || info === null) continue;
279
340
 
280
- const used = spent(running, info);
281
- if (info.total_token_usage !== undefined && info.total_token_usage !== null) {
282
- running = info.total_token_usage;
341
+ const total = info.total_token_usage ?? undefined;
342
+ 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);
348
+ } else if (!complete(total, CODEX_READ)) {
349
+ used = null;
350
+ broken = true;
351
+ } else {
352
+ used = broken ? null : spent(running, total, info);
353
+ running = total;
354
+ broken = false;
283
355
  }
356
+
284
357
  // A repeat is not a call, whether or not it is inside the window.
285
358
  if (used === undefined) continue;
359
+ if (Number.isNaN(when)) {
360
+ tally.gaps.records_without_time += 1;
361
+ continue;
362
+ }
286
363
  if (!inside(when, window)) continue;
364
+ if (used === null) {
365
+ tally.gaps.records_without_numbers += 1;
366
+ continue;
367
+ }
287
368
 
288
369
  count(tally, used, model, effort, when);
289
370
  }
290
371
  }
291
372
 
373
+ /** The fields Codex itself writes; it has no cache writes to report. */
374
+ const CODEX_READ = CODEX_FIELDS.filter((field) => field !== 'cache_write_input_tokens').concat('total_tokens');
375
+
292
376
  /**
293
- * What one Codex event says its call used, measured against the event before it.
294
- * `undefined` where the event is the one before written down again.
377
+ * What one Codex event says its call used, measured against the running total
378
+ * before it: `undefined` where the event is the one before written down again,
379
+ * and `null` where what it used cannot be known.
295
380
  */
296
- function spent(running, info) {
297
- const total = info.total_token_usage;
298
- if (total === undefined || total === null) {
299
- // Nothing to measure against: the per-call figure is all there is.
300
- return info.last_token_usage === undefined ? undefined : kindsOf(info.last_token_usage);
301
- }
381
+ function spent(running, total, info) {
302
382
  if (running === undefined) return kindsOf(total);
303
383
 
304
- const moved = number(total.total_tokens) - number(running.total_tokens);
384
+ const moved = total.total_tokens - running.total_tokens;
305
385
  if (moved === 0) return undefined;
306
386
  // A fall is a new window, and the running total is counting again from there.
307
- if (moved < 0) return kindsOf(info.last_token_usage ?? total);
387
+ if (moved < 0) return perCall(info);
308
388
 
309
389
  return kindsOf(Object.fromEntries(
310
390
  CODEX_FIELDS.map((field) => [field, number(total[field]) - number(running[field])]),
311
391
  ));
312
392
  }
313
393
 
394
+ /** A Codex event's own per-call figure, or `null` when something in it is missing. */
395
+ const perCall = (info) =>
396
+ complete(info.last_token_usage, CODEX_READ) ? kindsOf(info.last_token_usage) : null;
397
+
314
398
  /**
315
399
  * One Codex figure in the kinds this command reports. The cached tokens sit
316
400
  * inside `input_tokens` here and outside it on Claude Code, so they come out of
@@ -351,24 +435,32 @@ function count(tally, used, model, effort, when, calls = 1) {
351
435
  }
352
436
 
353
437
  /** The lines of a transcript that are readable JSON; the rest say nothing. */
354
- function lines(file) {
438
+ export const lines = (file) => transcript(file).entries;
439
+
440
+ /**
441
+ * The lines of a transcript that are JSON records, and how many were not. A
442
+ * transcript that cannot be read is said to be, rather than a run that stops.
443
+ */
444
+ function transcript(file) {
355
445
  let text;
356
446
  try {
357
447
  text = readFileSync(file, 'utf8');
358
448
  } catch {
359
- // A transcript that cannot be read is a conversation the kit knows nothing
360
- // about, which is the honest answer rather than a run that stops.
361
- return [];
449
+ return { entries: [], unreadable: 1, broken: 0 };
362
450
  }
363
- return text.split('\n').flatMap((line) => {
451
+ let broken = 0;
452
+ const entries = text.split('\n').flatMap((line) => {
364
453
  if (line.trim() === '') return [];
365
454
  try {
366
455
  const entry = JSON.parse(line);
367
- return entry !== null && typeof entry === 'object' ? [entry] : [];
456
+ if (entry !== null && typeof entry === 'object' && !Array.isArray(entry)) return [entry];
368
457
  } catch {
369
- return [];
458
+ // Counted below with any other line that is not a record.
370
459
  }
460
+ broken += 1;
461
+ return [];
371
462
  });
463
+ return { entries, unreadable: 0, broken };
372
464
  }
373
465
 
374
466
  /** A bot home as the file system knows it, or as it was given when it is not there. */
@@ -382,20 +474,23 @@ function realHome(home) {
382
474
 
383
475
  const number = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
384
476
 
477
+ /** Whether a figure has every field it should, each a number. */
478
+ const complete = (figure, fields) =>
479
+ figure !== undefined && figure !== null
480
+ && fields.every((field) => typeof figure[field] === 'number' && Number.isFinite(figure[field]));
481
+
385
482
  const add = (set, value) => {
386
483
  if (typeof value === 'string' && value !== '') set.add(value);
387
484
  };
388
485
 
389
486
  /**
390
487
  * Whether a moment is in the window: at or after its start, before its end. A
391
- * record with no time of its own is counted, as it always has been: nothing
392
- * says it is outside.
488
+ * record with no time of its own is in no window; it is left out and said to be.
393
489
  */
394
- const inside = (when, { from, to }) => Number.isNaN(when) || (when >= from && when < to);
490
+ const inside = (when, { from, to }) => when >= from && when < to;
395
491
 
396
492
  /** When the first and the last counted call of this conversation were. */
397
493
  function mark(tally, when) {
398
- if (Number.isNaN(when)) return;
399
494
  if (tally.first === undefined || when < tally.first) tally.first = when;
400
495
  if (tally.last === undefined || when > tally.last) tally.last = when;
401
496
  }