@assuredloop/orca-bot-kit 0.3.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
@@ -263,12 +263,22 @@ name for a directory in `skills/` inside your bots folder, or anything with a
263
263
  `/` in it for a skill directory anywhere on disk. `obk up` links them before it
264
264
  opens a tab, and `bot create` gives a new bot what the lists already name.
265
265
 
266
+ A bot whose sessions are running gets a changed list without a restart.
267
+ When `skills build` links or takes away a skill, it tells each running
268
+ session of that bot. A Claude Code session gets `/reload-skills` typed into
269
+ its tab: Claude Code's own reload, which a busy session runs when its turn
270
+ ends. Codex has no reload command and takes the change at the start of its
271
+ next turn, so nothing is typed there. The kit prints the new skills'
272
+ `SKILL.md` paths, and says that a restart would make a skill appear if it is
273
+ still missing after that turn. A session the kit could not tell is named,
274
+ with the reason.
275
+
266
276
  What lands in the bot is a symlink, never a copy
267
277
  ([ADR 0014](docs/adr/0014-skills-are-linked-never-copied.md)) — into
268
278
  `.claude/skills` and `.agents/skills` in the bot home, which is where each
269
279
  harness reads a project's skills from. So a kit skill is read where npm
270
- installed it, editing a skill is what a running session reads without a
271
- restart, and updating the kit updates every bot at once.
280
+ installed it, a running session reads an edited skill the next time it loads
281
+ it, and updating the kit updates every bot at once.
272
282
 
273
283
  The kit takes away only what it can prove it put there. It writes down every
274
284
  link it makes, in `.obk-skills.yaml` in the bot folder, and when no list names
@@ -404,7 +414,10 @@ same refusals, and `obk up` leaves what is paused closed until `obk unpause`
404
414
  brings it back with its conversations. Retiring a session takes it off the bot
405
415
  and keeps its conversations in the book under `retired`. Retiring a bot closes
406
416
  its tabs, removes its Orca project and moves its folder to `retired/`; it will
407
- not touch a bot whose Orca project holds a tab your book does not name. Bot
417
+ not touch a bot whose Orca project holds a tab your book does not name. The
418
+ folder moves only once Orca no longer lists the project; if Orca still lists it,
419
+ or its list cannot be read, retire says so and leaves the bot where it is, and
420
+ retiring it again finishes the job. Bot
408
421
  Father itself, and its management session `daily`, are never paused or
409
422
  retired; its other sessions are like any bot's.
410
423
 
@@ -430,10 +443,23 @@ What it looks for:
430
443
  go, and a link with nothing at the end of it;
431
444
  - **a session the book knows that Orca does not**: its tab was closed, or the
432
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;
433
452
  - **leftovers no book owns**: an Orca project inside your bots folder with no
434
453
  bot in it, a tab in a bot's project that the book does not name, a start
435
454
  prompt written for a session that has gone, a clone of a skills source you no
436
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;
437
463
  - **Orca's own default launch arguments**. Orca adds these to the agents it
438
464
  launches, relaunches and resumes, so when they carry a permission bypass every
439
465
  session runs in that mode whatever the kit asked for. The kit never sets that
package/SETUP.md CHANGED
@@ -145,8 +145,9 @@ different answers.
145
145
 
146
146
  `obk init` tells you which, for every tab it opened:
147
147
 
148
- - *the harness was typed in and came up* — it is running. It may still be asking
149
- something of its own; that is the table below.
148
+ - *the harness was typed in and is running. The kit cannot see whether a screen
149
+ in it is waiting for an answer* — look at it: Orca does not flag every first-run
150
+ screen, so it may still be asking something of its own. That is the table below.
150
151
  - *the harness was typed in and came up, waiting on: …* — running and blocked on
151
152
  a question. The table below.
152
153
  - *the harness was typed in, and no session came up in the tab* — **the line did
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/orca-bot-kit",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -273,24 +273,39 @@ again and check a session is running before you call the bot ready.
273
273
  ## When the bot is already running
274
274
 
275
275
  Changing a bot whose sessions are up is the ordinary case rather than the
276
- awkward one. Both harnesses read a skill through the link and notice a change
277
- to the skills directory while a session is running, so putting a skill on a
278
- bot, or taking one off, reaches a live session without restarting it.
279
-
280
- Noticing is not the same as confirmed. Check that the session can actually see
281
- the skill before telling anyone it is there, and check by asking that session
282
- rather than by looking again at the disk you just wrote: what you wrote is not
283
- evidence about what another process has loaded. Where it has not appeared, say
284
- so and say what would make it appear. A restart is the user's to ask for, and
285
- taking one because it is the quick way ends a conversation they were in the
286
- middle of.
276
+ awkward one. Putting a skill on a bot or taking one off reaches its running
277
+ sessions without a restart, and `obk skills build` says, session by session,
278
+ how:
279
+
280
+ - Claude Code: the kit types `/reload-skills` into the session's tab. That is
281
+ Claude Code's own reload. A busy session queues it and runs it as a command
282
+ when its turn ends, so it never reaches the model as text.
283
+ - Codex: nothing is typed, because Codex has no reload command. It takes the
284
+ change at the start of its next turn, not in the middle of the one it is on.
285
+
286
+ The kit reports a session it could not tell, and why: not up, or on a screen
287
+ waiting for an answer. A session that is not up reads its skills when it
288
+ starts.
289
+
290
+ Told is not the same as confirmed. Before telling anyone a skill is there,
291
+ check that the session can use it: ask it to load the skill. Do not look
292
+ again at the disk you just wrote, since that says nothing about what another
293
+ process has loaded. Do not ask a Codex session whether the skill is in its
294
+ list: it has answered "none" while its list held the skill. Where a skill has
295
+ not appeared after the next turn, say so. A restart would make it appear, and
296
+ meanwhile the session can read the skill's `SKILL.md` by the path the kit
297
+ printed. A restart is the user's to ask for. Taking one because it is quick
298
+ ends a conversation they were in the middle of.
287
299
 
288
300
  Instructions are not skills. A session is working from the rules it read when
289
301
  it started, so a change to a bot's `AGENTS.md` may not reach one that is
290
302
  already up even when a skill change would, and a session's model, effort or
291
303
  approval only changes when it is started again (`obk restart`, on the user's
292
304
  word, and then read its tabs as above). Say which you changed, and what that
293
- 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
294
309
  them to do it from Bot Father's ops tab.
295
310
 
296
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
@@ -18,7 +18,7 @@ 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, sendMessage } from './message.js';
21
- import { orcaCli, orcaTrouble } from './orca.js';
21
+ import { orcaCli, orcaTrouble, RELOAD_LINE } from './orca.js';
22
22
  import { pauseSessions, unpauseSessions } from './pause.js';
23
23
  import { recordSession, SHELL_ENV, TAB_ENV } from './record.js';
24
24
  import { restartSessions } from './restart.js';
@@ -83,7 +83,8 @@ Usage:
83
83
  a bot: close its tabs, remove its Orca project and
84
84
  move its folder to retired/. It will not retire a
85
85
  bot whose Orca project holds a tab your book does
86
- not name.
86
+ not name, and moves the folder only once Orca no
87
+ longer lists the project.
87
88
  obk rules build --bots <path> [--bot <bot>]
88
89
  Build every bot's AGENTS.md from its charter and
89
90
  the rule units it carries, or just the one you
@@ -103,7 +104,10 @@ Usage:
103
104
  skills folder, or any path, and take away a link
104
105
  it made that no list names any more. What you put
105
106
  in a bot's skills directory yourself is left alone
106
- and shown as yours. It does not touch Orca.
107
+ and shown as yours. A bot whose links changed has
108
+ its running sessions told: Claude Code gets
109
+ /reload-skills typed in, and Codex takes the
110
+ change at its next turn.
107
111
  obk skills fetch --bots <path> [--source <name>]
108
112
  Clone the online sources skills.yaml lists, beside
109
113
  your bots folder and never inside it, each at the
@@ -425,19 +429,19 @@ const commands = {
425
429
  const seeded = initBots(bots, values.harness);
426
430
  // The typed path was for making the folder and naming it in a refusal; from
427
431
  // here on it is the fleet, and the fleet is its real path (#164).
428
- const { tabs, rules, skills, paused } = await bringUp(sameFleet(seeded.bots), { bot: BOT_FATHER });
432
+ const { tabs, rules, skills, paused, projects } = await bringUp(sameFleet(seeded.bots), { bot: BOT_FATHER });
429
433
  // Setup is the other place the PRD asks for Orca's own launch arguments to
430
434
  // be looked at (6.5), and the one where the user is still standing in front
431
435
  // of the fleet they are making. Only that one check: a folder init has just
432
436
  // made has nothing else to say about itself.
433
- const answer = { bots: seeded.bots, created: seeded.created, completed: seeded.completed, rules, skills, tabs, paused, found: orcaSettingFindings() };
437
+ const answer = { bots: seeded.bots, created: seeded.created, completed: seeded.completed, rules, skills, tabs, paused, projects, found: orcaSettingFindings() };
434
438
  return { answer, lines: tabLines(answer, `Bot Father is up in Orca. Your bots folder: ${seeded.bots}`) };
435
439
  },
436
440
 
437
441
  async up(bots, values) {
438
442
  refuseWhenOrcaIsDown();
439
- const { tabs, rules, skills, paused } = await bringUp(bots, { bot: values.bot, session: values.session });
440
- const answer = { bots, created: [], completed: [], rules, skills, tabs, paused };
443
+ const { tabs, rules, skills, paused, projects } = await bringUp(bots, { bot: values.bot, session: values.session });
444
+ const answer = { bots, created: [], completed: [], rules, skills, tabs, paused, projects };
441
445
  const up = [...new Set(tabs.map((tab) => tab.bot))];
442
446
  const summary = up.length === 0
443
447
  ? `Nothing was brought up in Orca. Your bots folder: ${bots}`
@@ -447,11 +451,12 @@ const commands = {
447
451
 
448
452
  health(bots, values) {
449
453
  refuseWhenOrcaIsDown();
450
- const found = checkHealth(bots, { bot: values.bot });
454
+ const { found, sessions } = checkHealth(bots, { bot: values.bot });
451
455
  return {
452
- answer: { bots, found },
456
+ answer: { bots, found, sessions },
453
457
  lines: [
454
458
  ...foundLines(found),
459
+ ...sessions.map(settingsLine),
455
460
  found.length === 0
456
461
  ? `Nothing to report: everything the kit keeps is where it should be. Your bots folder: ${bots}`
457
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}`,
@@ -464,8 +469,8 @@ const commands = {
464
469
 
465
470
  async restart(bots, values) {
466
471
  refuseWhenOrcaIsDown();
467
- const { closed, tabs, rules, skills, paused } = await restartSessions(bots, { bot: values.bot, session: values.session });
468
- const answer = { bots, created: [], completed: [], rules, skills, tabs, paused, closed };
472
+ const { closed, tabs, rules, skills, paused, projects } = await restartSessions(bots, { bot: values.bot, session: values.session });
473
+ const answer = { bots, created: [], completed: [], rules, skills, tabs, paused, projects, closed };
469
474
  const what = values.session === undefined ? values.bot : `${values.bot} ${values.session}`;
470
475
  return {
471
476
  answer,
@@ -518,11 +523,25 @@ const commands = {
518
523
  }
519
524
 
520
525
  const retired = await retireBot(bots, { bot: values.bot });
526
+ if (retired.trouble !== undefined) {
527
+ return {
528
+ answer: { bots, ...retired },
529
+ lines: [
530
+ ...closedLines(retired.closed),
531
+ `${'trouble'.padEnd(9)} Orca project ${retired.project}`,
532
+ ` ${retired.trouble}`,
533
+ `Retire it again: ${shellWord(ownCli())} retire --bots ${shellWord(bots)} --bot ${retired.bot}`,
534
+ ],
535
+ code: 1,
536
+ };
537
+ }
521
538
  return {
522
539
  answer: { bots, ...retired },
523
540
  lines: [
524
541
  ...closedLines(retired.closed),
525
- ...(retired.project === undefined ? [] : [`removed Orca project ${retired.project}`]),
542
+ // After every removal, whatever the window was told: Orca's window can
543
+ // keep a removed project's row even after it re-reads (stablyai/orca#20102).
544
+ ...(retired.project === undefined ? [] : [`removed Orca project ${retired.project}`, RELOAD_LINE]),
526
545
  `retired ${retired.bot}: moved to ${path.relative(bots, retired.moved)}, with its book, charter and memory`,
527
546
  ],
528
547
  };
@@ -938,12 +957,15 @@ function usageLines(usage, bots) {
938
957
  lines.push(`${'bot'.padEnd(9)} ${entry.bot}`);
939
958
  for (const session of entry.sessions) {
940
959
  lines.push(`${'session'.padEnd(9)} ${session.name}`);
960
+ const notCounted = notCountedLine(session.not_counted);
941
961
  lines.push(...session.conversations.map(conversationLine));
942
- 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);
943
964
  }
944
- if (entry.unclaimed.length > 0) {
965
+ if (entry.unclaimed.length > 0 || notCountedLine(entry.unclaimed_not_counted).length > 0) {
945
966
  lines.push(`${'unclaimed'.padEnd(9)} ${entry.bot}: no session of this bot claims these`);
946
967
  lines.push(...entry.unclaimed.map(conversationLine));
968
+ lines.push(...notCountedLine(entry.unclaimed_not_counted));
947
969
  }
948
970
  }
949
971
 
@@ -953,6 +975,19 @@ function usageLines(usage, bots) {
953
975
  return lines;
954
976
  }
955
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
+
956
991
  /** One conversation: what it is, what it ran as, and what it used. */
957
992
  function conversationLine(one) {
958
993
  const ran = [...one.models, ...one.efforts].join(' ');
@@ -1084,9 +1119,24 @@ function skillsLines(skills) {
1084
1119
  ...(entry.trouble === undefined ? [] : [` ${'trouble'.padEnd(9)} ${entry.trouble}`]),
1085
1120
  ...entry.skills.map((skill) => ` ${(skill.managed ? 'linked' : 'yours').padEnd(9)} ${skill.name.padEnd(24)} ${skill.from ?? 'no list names it; the kit leaves it alone'}`),
1086
1121
  ...(entry.removed ?? []).map((name) => ` ${'removed'.padEnd(9)} ${name.padEnd(24)} no list names it now`),
1122
+ ...(entry.sessions ?? []).map((one) => toldLine(entry.bot, one)),
1087
1123
  ]);
1088
1124
  }
1089
1125
 
1126
+ /** What `skills build` did about one running session of a bot whose skills changed. */
1127
+ function toldLine(bot, { session, harness, state, read, blocked, trouble }) {
1128
+ const who = `${bot}/${session}`;
1129
+ const after = harness === 'claude' ? 'Once that is settled, type /reload-skills in its tab.' : 'Its next turn takes the change by itself.';
1130
+ const [word, says] = {
1131
+ reloaded: ['reloaded', '/reload-skills was typed into its tab; a busy session runs it when its turn ends'],
1132
+ 'next-turn': ['next turn', `Codex takes the change at the start of its next turn. If a skill is not there then, a restart makes it appear${read?.length > 0 ? `; meanwhile it can read ${read.join(', ')}` : ''}`],
1133
+ 'not-up': ['not told', 'not up; it reads its skills when it starts'],
1134
+ blocked: ['not told', `its tab is waiting for an answer (${blocked}), so nothing was typed. ${after}`],
1135
+ unknown: ['not told', `${trouble}. ${after}`],
1136
+ }[state];
1137
+ return ` ${word.padEnd(9)} ${who.padEnd(24)} ${says}`;
1138
+ }
1139
+
1090
1140
  /**
1091
1141
  * What a check found, two lines each: what kind of trouble it is and the one
1092
1142
  * thing to go and look at, then the sentence about it. The same shape wherever
@@ -1095,7 +1145,26 @@ function skillsLines(skills) {
1095
1145
  const foundLines = (found) =>
1096
1146
  found.flatMap((one) => [`${one.kind.padEnd(9)} ${one.where}`, ` ${one.says}`]);
1097
1147
 
1098
- function tabLines({ bots, created, completed, rules, skills, tabs, paused = [], found = [] }, summary) {
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
+
1167
+ function tabLines({ bots, created, completed, rules, skills, tabs, paused = [], projects = [], found = [] }, summary) {
1099
1168
  const lines = [
1100
1169
  ...created.map((entry) => `created ${entry}`),
1101
1170
  ...completed.map((entry) => `completed ${entry}`),
@@ -1115,7 +1184,10 @@ function tabLines({ bots, created, completed, rules, skills, tabs, paused = [],
1115
1184
 
1116
1185
  // Last before the summary, because what a check found is about the setup the
1117
1186
  // run has just left behind rather than about any one thing it did.
1118
- lines.push(...foundLines(found), summary);
1187
+ lines.push(...foundLines(found));
1188
+ // Hedged, because nothing here can see whether the window was told (#224).
1189
+ if (projects.length > 0) lines.push(RELOAD_LINE);
1190
+ lines.push(summary);
1119
1191
  return lines;
1120
1192
  }
1121
1193
 
@@ -1141,11 +1213,21 @@ function harnessLines(tab, bots) {
1141
1213
  ' the harness was typed in, and no session came up in the tab.',
1142
1214
  ` Look at it: ${lookAt(tab.terminal)}`,
1143
1215
  ...ANSWER_IT,
1216
+ ...promptLines(tab),
1144
1217
  ];
1145
1218
  }
1146
1219
 
1220
+ // Orca naming no reason does not mean nothing is on screen: on 1.4.209 it
1221
+ // names none for Codex's trust and hooks screens, nor for Claude Code's trust
1222
+ // list (tech notes, section 1, #288). So the kit says what it cannot see.
1147
1223
  const lines = tab.blockedReason === undefined
1148
- ? [...how, ' the harness was typed in and came up.']
1224
+ ? [
1225
+ ...how,
1226
+ ' the harness was typed in and is running. The kit cannot see whether a screen',
1227
+ ' in it is waiting for an answer.',
1228
+ ` Look at it: ${lookAt(tab.terminal)}`,
1229
+ ...ANSWER_IT,
1230
+ ]
1149
1231
  : [
1150
1232
  ...how,
1151
1233
  ` the harness was typed in and came up, waiting on: ${tab.blockedReason}`,
@@ -1153,14 +1235,21 @@ function harnessLines(tab, bots) {
1153
1235
  ...ANSWER_IT,
1154
1236
  ];
1155
1237
 
1156
- if (tab.promptSent === true) lines.push(' the start prompt was typed in.');
1157
- if (tab.promptSent === false) {
1158
- lines.push(' the start prompt was not typed in: the tab was not ready for it.');
1159
- }
1238
+ lines.push(...promptLines(tab));
1160
1239
  if (tab.unclaimed !== undefined) lines.push(...unclaimedLines(tab, bots));
1161
1240
  return lines;
1162
1241
  }
1163
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
+
1164
1253
  try {
1165
1254
  process.exitCode = await run(process.argv.slice(2));
1166
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 {