@assuredloop/orca-bot-kit 0.4.0 → 0.5.1

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
@@ -350,6 +350,11 @@ conversation it was having, rather than starting a new one. Clear a session —
350
350
  keeps the old one, and gives the session its start prompt again, because that
351
351
  prompt is what tells one session's duty from another's.
352
352
 
353
+ A session paused before its first turn can have an id in the book and no
354
+ conversation behind it: the harness has nothing on record for that id. There is
355
+ nothing to resume, so it starts again with its duty, and the book moves the id
356
+ into its history as `no conversation`.
357
+
353
358
  One tab holds one session: the harness the kit started in it. Anything that
354
359
  session runs inside the tab — a `codex exec`, a helper, a subagent's own process
355
360
  — is not the session, and never becomes the conversation the kit brings back.
@@ -443,10 +448,23 @@ What it looks for:
443
448
  go, and a link with nothing at the end of it;
444
449
  - **a session the book knows that Orca does not**: its tab was closed, or the
445
450
  machine was restarted, and `obk up` brings it back;
451
+ - **a session whose tab is open and whose harness is gone**: only the tab's
452
+ shell is in front, because the harness quit or crashed. `obk up` leaves an
453
+ open tab alone, so the finding names the `obk restart … --session` that
454
+ brings it back; when the book names no conversation for it, restart would
455
+ refuse, and the finding says to close the tab in Orca and run
456
+ `obk up … --session`. A tab the kit cannot read is never called down;
446
457
  - **leftovers no book owns**: an Orca project inside your bots folder with no
447
458
  bot in it, a tab in a bot's project that the book does not name, a start
448
459
  prompt written for a session that has gone, a clone of a skills source you no
449
460
  longer list, and the conversations the kit found and would not assign;
461
+ - **a session running on something other than its bot asks for**: a model,
462
+ effort, context or approval level that the harness's own record of the
463
+ conversation says differs from `bot.yaml`, and a session that started before
464
+ its bot's `AGENTS.md` last changed, with what brings it up to date. Beside the
465
+ findings, each running session gets a line saying, for each setting, whether
466
+ it matches, differs, was not asked for, or cannot be told from the record;
467
+ cannot be told is never taken for a match;
450
468
  - **Orca's own default launch arguments**. Orca adds these to the agents it
451
469
  launches, relaunches and resumes, so when they carry a permission bypass every
452
470
  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.1",
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
  `;
@@ -266,3 +269,13 @@ export function rememberSession(entry = {}, id, ended) {
266
269
  const was = { session: entry.session, ended: ended ?? 'replaced', at: new Date().toISOString() };
267
270
  return { ...entry, session: id, history: [...(entry.history ?? []), was] };
268
271
  }
272
+
273
+ /**
274
+ * The session entry once the id it held has turned out to have no conversation
275
+ * behind it: the id goes into the history with that reason, and the entry holds
276
+ * none until the harness names the new one.
277
+ */
278
+ export function forgetSession(entry, ended) {
279
+ const { session, ...rest } = entry;
280
+ return { ...rest, history: [...(entry.history ?? []), { session, ended, at: new Date().toISOString() }] };
281
+ }
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}`,
@@ -891,9 +892,14 @@ function refuseWhenOrcaIsDown() {
891
892
 
892
893
  /** The same facts as `--json`, as lines, for a person reading along. */
893
894
  /** The one line that says which conversation this tab was given, and from where. */
894
- const howLine = (tab) => (tab.resumed === true
895
- ? ' it was told to resume the session the book holds, with its conversation.'
896
- : ' it was told to start a new session: the book holds none for this one yet.');
895
+ const howLines = (tab) => {
896
+ if (tab.resumed === true) return [' it was told to resume the session the book holds, with its conversation.'];
897
+ if (tab.noConversation === undefined) return [' it was told to start a new session: the book holds none for this one yet.'];
898
+ return [
899
+ ` it was told to start a new session: the book's conversation ${tab.noConversation} had nothing on record`,
900
+ " behind it, so it went into the session's history.",
901
+ ];
902
+ };
897
903
 
898
904
  /**
899
905
  * What the harness still has in this bot's folder that no session claims. The
@@ -956,12 +962,15 @@ function usageLines(usage, bots) {
956
962
  lines.push(`${'bot'.padEnd(9)} ${entry.bot}`);
957
963
  for (const session of entry.sessions) {
958
964
  lines.push(`${'session'.padEnd(9)} ${session.name}`);
965
+ const notCounted = notCountedLine(session.not_counted);
959
966
  lines.push(...session.conversations.map(conversationLine));
960
- if (session.conversations.length === 0) lines.push(' nothing on record');
967
+ if (session.conversations.length === 0 && notCounted.length === 0) lines.push(' nothing on record');
968
+ lines.push(...notCounted);
961
969
  }
962
- if (entry.unclaimed.length > 0) {
970
+ if (entry.unclaimed.length > 0 || notCountedLine(entry.unclaimed_not_counted).length > 0) {
963
971
  lines.push(`${'unclaimed'.padEnd(9)} ${entry.bot}: no session of this bot claims these`);
964
972
  lines.push(...entry.unclaimed.map(conversationLine));
973
+ lines.push(...notCountedLine(entry.unclaimed_not_counted));
965
974
  }
966
975
  }
967
976
 
@@ -971,6 +980,19 @@ function usageLines(usage, bots) {
971
980
  return lines;
972
981
  }
973
982
 
983
+ /** What could not be counted, as one line, or no line when everything was. */
984
+ function notCountedLine(gaps) {
985
+ const said = [
986
+ [gaps.unreadable_transcripts, 'transcript', 'transcripts', 'unreadable'],
987
+ [gaps.broken_lines, 'line', 'lines', 'broken'],
988
+ [gaps.records_without_numbers, 'record', 'records', 'with no usable number'],
989
+ [gaps.records_without_time, 'record', 'records', 'with no time'],
990
+ ]
991
+ .filter(([count]) => count > 0)
992
+ .map(([count, one, many, why]) => `${count} ${count === 1 ? one : many} ${why}`);
993
+ return said.length === 0 ? [] : [` not counted: ${said.join(', ')}`];
994
+ }
995
+
974
996
  /** One conversation: what it is, what it ran as, and what it used. */
975
997
  function conversationLine(one) {
976
998
  const ran = [...one.models, ...one.efforts].join(' ');
@@ -1128,6 +1150,25 @@ function toldLine(bot, { session, harness, state, read, blocked, trouble }) {
1128
1150
  const foundLines = (found) =>
1129
1151
  found.flatMap((one) => [`${one.kind.padEnd(9)} ${one.where}`, ` ${one.says}`]);
1130
1152
 
1153
+ /**
1154
+ * What one running session runs on, in a line: each setting's state, with what
1155
+ * was asked for and what the harness recorded, and its rules.
1156
+ */
1157
+ function settingsLine({ bot, session, running, settings, rules }) {
1158
+ const each = Object.entries(settings).map(([name, one]) => {
1159
+ const values = [
1160
+ ...(one.configured === undefined ? [] : [`asked ${one.configured}`]),
1161
+ ...(one.observed === undefined ? [] : [`runs ${one.observed}`]),
1162
+ ];
1163
+ return `${name} ${one.state}${values.length === 0 ? '' : ` (${values.join(', ')})`}`;
1164
+ });
1165
+ // A session whose harness the kit cannot see in its tab is not called running.
1166
+ const [word, who] = running === 'yes'
1167
+ ? ['running', `${bot} ${session}`]
1168
+ : ['unsure', `${bot} ${session} cannot tell whether its harness is running:`];
1169
+ return `${word.padEnd(9)} ${who} ${each.join(' ')} rules ${rules.state}`;
1170
+ }
1171
+
1131
1172
  function tabLines({ bots, created, completed, rules, skills, tabs, paused = [], projects = [], found = [] }, summary) {
1132
1173
  const lines = [
1133
1174
  ...created.map((entry) => `created ${entry}`),
@@ -1169,7 +1210,7 @@ function harnessLines(tab, bots) {
1169
1210
  // What the line that was typed in asked for, and where the kit got it: the
1170
1211
  // session the book holds, one the harness itself still had on record, or a new
1171
1212
  // one — and if a new one, whether anything was known about an older one.
1172
- const how = [howLine(tab)];
1213
+ const how = howLines(tab);
1173
1214
 
1174
1215
  if (!tab.harnessStarted) {
1175
1216
  return [
@@ -1177,6 +1218,7 @@ function harnessLines(tab, bots) {
1177
1218
  ' the harness was typed in, and no session came up in the tab.',
1178
1219
  ` Look at it: ${lookAt(tab.terminal)}`,
1179
1220
  ...ANSWER_IT,
1221
+ ...promptLines(tab),
1180
1222
  ];
1181
1223
  }
1182
1224
 
@@ -1198,14 +1240,21 @@ function harnessLines(tab, bots) {
1198
1240
  ...ANSWER_IT,
1199
1241
  ];
1200
1242
 
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
- }
1243
+ lines.push(...promptLines(tab));
1205
1244
  if (tab.unclaimed !== undefined) lines.push(...unclaimedLines(tab, bots));
1206
1245
  return lines;
1207
1246
  }
1208
1247
 
1248
+ /**
1249
+ * Whether the session got its start prompt, as its own record has it (#274):
1250
+ * received, or not confirmed yet. Nothing for a session that was told nothing.
1251
+ */
1252
+ function promptLines(tab) {
1253
+ if (tab.promptReceived === true) return [' the start prompt was received: the session\'s own record holds it.'];
1254
+ if (tab.promptReceived === false) return [' the start prompt is not confirmed: the session\'s own record does not hold it yet.'];
1255
+ return [];
1256
+ }
1257
+
1209
1258
  try {
1210
1259
  process.exitCode = await run(process.argv.slice(2));
1211
1260
  } catch (error) {
@@ -10,7 +10,7 @@
10
10
  // directory, and every session starts at its bot home. Read only, never
11
11
  // written: these are the harness's files (tech notes, sections 2 and 3).
12
12
 
13
- import { readdirSync, readFileSync, statSync } from 'node:fs';
13
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
14
14
  import { homedir } from 'node:os';
15
15
  import path from 'node:path';
16
16
 
@@ -32,12 +32,31 @@ 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 }));
40
+ }
41
+
42
+ /**
43
+ * Whether the harness has a record of the conversation `id` for this bot home,
44
+ * which is what its own resume looks for. A session paused before its first
45
+ * turn has an id the hook reported and nothing written behind it, and Claude
46
+ * Code answers a resume of it with "No conversation found" (#295).
47
+ *
48
+ * Asked of the file names alone, which both harnesses make from the id: nothing
49
+ * is opened, however many conversations the machine has.
50
+ */
51
+ export function hasConversation(harness, home, id) {
52
+ if (harness === 'claude') return existsSync(path.join(claudeDir(home), `${id}.jsonl`));
53
+ return rollouts(codexDir()).some((file) => path.basename(file).endsWith(`-${id}.jsonl`));
36
54
  }
37
55
 
38
56
  /**
39
57
  * 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.
58
+ * caller that has to read what is inside one rather than only know it is there,
59
+ * and whether the harness marks it as a subagent's.
41
60
  */
42
61
  export function transcriptsIn(harness, home, since) {
43
62
  const from = since === undefined ? 0 : Date.parse(since);
@@ -45,7 +64,47 @@ export function transcriptsIn(harness, home, since) {
45
64
  return found
46
65
  .filter((one) => Number.isNaN(from) || one.at >= from)
47
66
  .sort((left, right) => left.at - right.at)
48
- .map((one) => ({ id: one.id, at: new Date(one.at).toISOString(), file: one.file }));
67
+ .map((one) => ({ id: one.id, at: new Date(one.at).toISOString(), file: one.file, subagent: one.subagent === true }));
68
+ }
69
+
70
+ /**
71
+ * Whether a conversation's own record holds `text` as a turn of the user's:
72
+ * what the harness itself wrote down as said to it, not a screen that was up.
73
+ *
74
+ * Only the user's turns count. Claude Code writes them as `user` lines, and its
75
+ * own meta lines and tool results in that same shape are not the user's. Codex
76
+ * writes each as a `user` message item; its `user_message` event is missing
77
+ * from many rollouts, and the item is in every one (tech notes, section 3).
78
+ * Its AGENTS.md goes in as a `user` item too, and cannot be the text asked about.
79
+ */
80
+ export function heldAsUserTurn(harness, file, text) {
81
+ let lines;
82
+ try {
83
+ lines = readFileSync(file, 'utf8').split('\n');
84
+ } catch {
85
+ return false;
86
+ }
87
+ const wanted = text.trim();
88
+ return lines.some((line) => userTexts(harness, line).some((said) => said.trim() === wanted));
89
+ }
90
+
91
+ /** The texts of one record line, when it is a turn of the user's. */
92
+ function userTexts(harness, line) {
93
+ let parsed;
94
+ try {
95
+ parsed = JSON.parse(line);
96
+ } catch {
97
+ return [];
98
+ }
99
+ if (harness === 'claude') {
100
+ if (parsed?.type !== 'user' || parsed.isMeta === true) return [];
101
+ const content = parsed.message?.content;
102
+ if (typeof content === 'string') return [content];
103
+ return Array.isArray(content) ? content.filter((block) => block?.type === 'text').map((block) => String(block.text)) : [];
104
+ }
105
+ const item = parsed?.type === 'response_item' ? parsed.payload : undefined;
106
+ if (item?.type !== 'message' || item.role !== 'user' || !Array.isArray(item.content)) return [];
107
+ return item.content.filter((block) => block?.type === 'input_text').map((block) => String(block.text));
49
108
  }
50
109
 
51
110
  function claudeConversations(home) {
@@ -87,7 +146,7 @@ function codexConversations(home, from) {
87
146
  .flatMap((file) => {
88
147
  const meta = sessionMeta(file);
89
148
  return meta?.cwd === home && typeof meta.id === 'string'
90
- ? [{ id: meta.id, at: Date.parse(meta.timestamp ?? '') || startedAt(file) || 0, file }]
149
+ ? [{ id: meta.id, at: Date.parse(meta.timestamp ?? '') || startedAt(file) || 0, file, subagent: isSubagent(meta) }]
91
150
  : [];
92
151
  });
93
152
  }
@@ -113,6 +172,15 @@ function sessionMeta(file) {
113
172
  }
114
173
  }
115
174
 
175
+ /**
176
+ * Codex marks a conversation it ran as a subagent under `source`: its own
177
+ * auto-review is `{ subagent: { other: 'guardian' } }`, and one a session spawned
178
+ * is `{ subagent: { thread_spawn: … } }`. A session's own is a plain word, such as
179
+ * `cli` or `exec` (tech notes, section 3).
180
+ */
181
+ const isSubagent = (meta) =>
182
+ meta.source !== null && typeof meta.source === 'object' && 'subagent' in meta.source;
183
+
116
184
  /** When a file was last written, for leaving out what is plainly too old. */
117
185
  function lastTouched(file) {
118
186
  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/message.js CHANGED
@@ -176,8 +176,8 @@ export function sendMessage(bots, { to: target, from: sender, tab, subject, text
176
176
  * Orca fences a Run to one reader, its coordinator. A session whose tab is live
177
177
  * is bound to that tab and read as it; one whose tab is down is read as its
178
178
  * Run's coordinator, and nothing is bound (issue #249). A read that
179
- * is not a peek acknowledges the batch it read: that is what makes the next
180
- * check bring the next one rather than the same again.
179
+ * is not a peek acknowledges every batch waiting, one after another, so one
180
+ * check hands over all of it (issue #299).
181
181
  */
182
182
  export function checkMail(bots, { bot: botName, session: sessionName, tab, peek = false }) {
183
183
  const who = sessionName === undefined && botName === undefined
@@ -212,9 +212,16 @@ export function checkMail(bots, { bot: botName, session: sessionName, tab, peek
212
212
  trouble: `${who.bot}/${who.session} is not up, and its mailbox is bound to no tab, so there is nothing to read it as. Its mail waits until its tab is back: \`up\` brings it back, or \`unpause\` when it is paused.`,
213
213
  };
214
214
  }
215
- const found = readMailbox(who.mailbox, { peek, handle });
216
- const messages = (found.messages ?? []).map((message) => asMessage(bots, message));
217
- if (!peek && found.deliveryId !== undefined && messages.length > 0) ackMailbox(who.mailbox, found.deliveryId, handle);
215
+ // Orca hands mail over a batch at a time and replays the oldest until it is
216
+ // acknowledged; the acknowledgement answers with the next batch. So a read
217
+ // takes every batch in turn, or newer mail waits behind an older one (#299).
218
+ const messages = [];
219
+ let found = readMailbox(who.mailbox, { peek, handle });
220
+ for (;;) {
221
+ messages.push(...(found.messages ?? []).map((message) => asMessage(bots, message)));
222
+ if (peek || !found.deliveryId || (found.messages ?? []).length === 0) break;
223
+ found = ackMailbox(who.mailbox, found.deliveryId, handle);
224
+ }
218
225
 
219
226
  return { bots, bot: who.bot, session: who.session, mailbox: who.mailbox, read: !peek, messages };
220
227
  }
@@ -335,7 +342,7 @@ function nudge(to, from, subject) {
335
342
  if (to.tab === undefined) return { nudged: false };
336
343
 
337
344
  try {
338
- const found = tabToTypeInto(to.home, to.tab, LOOK_MS);
345
+ const found = lookAt(to);
339
346
  if (found.blocked !== undefined) return { nudged: false, blocked: found.blocked };
340
347
  // A line that lands in a shell is run there, with the sender's subject in it.
341
348
  if (found.unsure !== undefined) return { nudged: false, nudgeTrouble: found.unsure };
@@ -356,6 +363,21 @@ function nudge(to, from, subject) {
356
363
  }
357
364
  }
358
365
 
366
+ /**
367
+ * The look before the nudge. Orca now and then refuses a handle it has just
368
+ * listed as stale, and a fresh listing hands out one that works (tech notes,
369
+ * section 1), so that refusal earns one more listing and one more look. Only
370
+ * one: a second refusal is reported as it is (#294).
371
+ */
372
+ function lookAt(to) {
373
+ try {
374
+ return tabToTypeInto(to.home, to.tab, LOOK_MS);
375
+ } catch (error) {
376
+ if (error.code !== 'terminal_handle_stale') throw error;
377
+ return tabToTypeInto(to.home, to.tab, LOOK_MS);
378
+ }
379
+ }
380
+
359
381
  /** `<bot>/<session>`, or a bot on its own when it has only the one session. */
360
382
  function splitTarget(target) {
361
383
  const parts = target.split('/');