@assuredloop/orca-bot-kit 0.26.1 → 0.28.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.
@@ -6,11 +6,11 @@
6
6
  // the kit never recorded must not be mistaken for a session that never had one,
7
7
  // so when the book cannot say, the harness is asked instead.
8
8
  //
9
- // Both harnesses keep their own record of every conversation, per working
9
+ // Every harness keeps its own record of every conversation, per working
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 { existsSync, readdirSync, statSync } from 'node:fs';
13
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
14
14
  import { homedir } from 'node:os';
15
15
  import path from 'node:path';
16
16
 
@@ -22,6 +22,38 @@ const claudeDir = (home) => path.join(homedir(), '.claude', 'projects', slug(hom
22
22
  /** The folder's name is the working directory with everything else turned into a dash. */
23
23
  const slug = (home) => home.replaceAll(/[^A-Za-z0-9]/g, '-');
24
24
 
25
+ /**
26
+ * Pi's sessions: one folder per working directory, named from its real path
27
+ * with the leading `/` taken off and `/`, `\` and `:` turned into dashes,
28
+ * between `--`; one `<time>_<id>.jsonl` per conversation (Pi 1.1.0,
29
+ * `getDefaultSessionDirPath`, tech notes, section 6).
30
+ */
31
+ const piDir = (home) => path.join(homedir(), '.pi', 'agent', 'sessions', `--${realHome(home).replace(/^[/\\]/, '').replaceAll(/[/\\:]/g, '-')}--`);
32
+
33
+ /** The folder as Pi sees it from inside: the real path, when there is one. */
34
+ function realHome(home) {
35
+ try {
36
+ return realpathSync(home);
37
+ } catch {
38
+ return home;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * A Pi session file's name, `<time>_<id>.jsonl`: the time is Pi's ISO time
44
+ * with `:` and `.` turned into dashes, and the id may hold a `_` of its own.
45
+ */
46
+ const PI_FILE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z_(.+)\.jsonl$/;
47
+
48
+ /** The id a Pi session file's name carries, or undefined for a file that is not one. */
49
+ const piIdOf = (name) => PI_FILE.exec(name)?.[1];
50
+
51
+ /** Where Pi keeps the conversation `id` of this bot home, or undefined when it has none yet. */
52
+ const piSession = (home, id) => {
53
+ const name = files(piDir(home)).find((one) => piIdOf(one) === id);
54
+ return name === undefined ? undefined : path.join(piDir(home), name);
55
+ };
56
+
25
57
  /** Codex's rollouts: a file per conversation, filed by the day it started. */
26
58
  const codexDir = () => path.join(homedir(), '.codex', 'sessions');
27
59
 
@@ -52,6 +84,7 @@ export function conversationsIn(harness, home, since) {
52
84
  */
53
85
  export function hasConversation(harness, home, id) {
54
86
  if (harness === 'claude') return existsSync(claudeTranscript(home, id));
87
+ if (harness === 'pi') return piSession(home, id) !== undefined;
55
88
  return codexRollout(id) !== undefined;
56
89
  }
57
90
 
@@ -120,7 +153,9 @@ export function claudeSubagentTranscripts(file) {
120
153
  */
121
154
  export function transcriptsIn(harness, home, since) {
122
155
  const from = since === undefined ? 0 : Date.parse(since);
123
- const found = harness === 'claude' ? claudeConversations(home) : codexConversations(home, from);
156
+ const found = harness === 'claude' ? claudeConversations(home)
157
+ : harness === 'pi' ? piConversations(home)
158
+ : codexConversations(home, from);
124
159
  return found
125
160
  .filter((one) => Number.isNaN(from) || one.at >= from)
126
161
  .sort((left, right) => left.at - right.at)
@@ -159,43 +194,6 @@ export function heldAsUserTurn(harness, file, text) {
159
194
  return held;
160
195
  }
161
196
 
162
- /**
163
- * Where the harness keeps the conversation `id` of this bot home, and how long
164
- * that record is now: `{ harness, file, size }`, so a later look reads only
165
- * what was written after this moment. Undefined when there is no id, no
166
- * record, or it cannot be read (#509).
167
- */
168
- export function recordMark(harness, home, id) {
169
- if (typeof id !== 'string' || id === '') return undefined;
170
- try {
171
- const file = harness === 'codex' ? codexRollout(id) : claudeTranscript(home, id);
172
- if (file === undefined) return undefined;
173
- const found = statSync(file);
174
- return found.isFile() ? { harness, file, size: found.size } : undefined;
175
- } catch {
176
- return undefined;
177
- }
178
- }
179
-
180
- /**
181
- * Whether a turn of the user's written after `mark` holds `text` anywhere in
182
- * it (#509). A record that cannot be read says no: not seeing a thing is not
183
- * seeing it.
184
- */
185
- export function userTurnSince(mark, text) {
186
- if (mark === undefined || mark === null) return false;
187
- let held = false;
188
- try {
189
- eachLine(mark.file, (line) => {
190
- held = line !== undefined && userTexts(mark.harness, line).some((said) => said.includes(text));
191
- return held;
192
- }, { from: mark.size });
193
- } catch {
194
- return false;
195
- }
196
- return held;
197
- }
198
-
199
197
  /** The texts of one record line, when it is a turn of the user's. */
200
198
  function userTexts(harness, line) {
201
199
  let parsed;
@@ -210,6 +208,14 @@ function userTexts(harness, line) {
210
208
  if (typeof content === 'string') return [content];
211
209
  return Array.isArray(content) ? content.filter((block) => block?.type === 'text').map((block) => String(block.text)) : [];
212
210
  }
211
+ if (harness === 'pi') {
212
+ // A turn is a `message` line, and the user's are role `user` (Pi 1.1.0,
213
+ // docs/session-format.md).
214
+ const message = parsed?.type === 'message' ? parsed.message : undefined;
215
+ if (message?.role !== 'user') return [];
216
+ if (typeof message.content === 'string') return [message.content];
217
+ return Array.isArray(message.content) ? message.content.filter((block) => block?.type === 'text').map((block) => String(block.text)) : [];
218
+ }
213
219
  const item = parsed?.type === 'response_item' ? parsed.payload : undefined;
214
220
  if (item?.type !== 'message' || item.role !== 'user' || !Array.isArray(item.content)) return [];
215
221
  return item.content.filter((block) => block?.type === 'input_text').map((block) => String(block.text));
@@ -228,6 +234,33 @@ function claudeConversations(home) {
228
234
  });
229
235
  }
230
236
 
237
+ /**
238
+ * Pi's conversations in this bot home. The first line of each is its header,
239
+ * `{ type: 'session', id, timestamp, cwd }`, and says which conversation it is
240
+ * and when it began; the file's name says the id too.
241
+ */
242
+ function piConversations(home) {
243
+ return files(piDir(home))
244
+ .filter((name) => name.endsWith('.jsonl'))
245
+ .flatMap((name) => {
246
+ const file = path.join(piDir(home), name);
247
+ const header = piHeader(file);
248
+ const id = typeof header?.id === 'string' ? header.id : piIdOf(name);
249
+ const at = Date.parse(header?.timestamp ?? '') || startedAt(file);
250
+ return at === undefined || id === undefined || id === '' ? [] : [{ id, at, file }];
251
+ });
252
+ }
253
+
254
+ /** A Pi session's first line, when it is the header. */
255
+ function piHeader(file) {
256
+ try {
257
+ const parsed = JSON.parse(firstLine(file) ?? '');
258
+ return parsed?.type === 'session' ? parsed : undefined;
259
+ } catch {
260
+ return undefined;
261
+ }
262
+ }
263
+
231
264
  /** When a transcript's first line says the conversation was, if it says at all. */
232
265
  function said(file) {
233
266
  try {
package/src/groom.js CHANGED
@@ -44,7 +44,7 @@ import path from 'node:path';
44
44
  import { botDir, readBot, requireBotsFolder } from './bot.js';
45
45
  import { readBook, takeLineTurn, TYPING_HELD, TYPING_WAIT_MS } from './book.js';
46
46
  import { claudeTranscript } from './conversations.js';
47
- import { harnessOf, ownCli, shellWord } from './launch.js';
47
+ import { harnessOf, ownCli, promptFile, shellWord, startPrompt } from './launch.js';
48
48
  import { tabs, tabToTypeInto, typeIntoTab } from './orca.js';
49
49
  import { BOT_FATHER, promptPath } from './up.js';
50
50
  import { lines } from './usage.js';
@@ -123,7 +123,7 @@ export function grooming(bots, { at, ask, run } = {}) {
123
123
  ? groomingJobs(ofThisProcess(home, entry), markerOf(bots), Date.now())
124
124
  : [];
125
125
 
126
- const line = ask === undefined ? undefined : lineFor(ask, { bots, session, jobs, at, run });
126
+ const line = ask === undefined ? undefined : lineFor(ask, { bots, home, found, session, jobs, at, run });
127
127
  if (line !== undefined) typeInto(bots, home, session, tab, line);
128
128
 
129
129
  // A move is turning it on at another time, and is typed as one.
@@ -135,7 +135,7 @@ export function grooming(bots, { at, ask, run } = {}) {
135
135
  * nothing to ask. Everything that makes the ask impossible refuses here, before
136
136
  * Orca is asked for anything.
137
137
  */
138
- function lineFor(ask, { bots, session, jobs, at, run }) {
138
+ function lineFor(ask, { bots, home, found, session, jobs, at, run }) {
139
139
  // Nothing to turn off is an answer, unless a session is there to be told: an
140
140
  // `--on` typed a moment ago may still be waiting in its tab.
141
141
  if (ask === 'off' && jobs.length === 0 && !(session?.harness === 'claude' && session.up)) return undefined;
@@ -165,7 +165,8 @@ function lineFor(ask, { bots, session, jobs, at, run }) {
165
165
  // A move keeps what the runs are on, when every job there says the same.
166
166
  const runs = [...new Set(jobs.map((job) => JSON.stringify(job.run ?? null)))];
167
167
  const keep = ask === 'move' && runs.length === 1 ? JSON.parse(runs[0]) ?? undefined : undefined;
168
- return onLine(bots, cronOf(when), jobs, run ?? keep);
168
+ const onRun = run ?? keep;
169
+ return onLine(bots, cronOf(when), jobs, onRun === undefined ? undefined : { ...onRun, task: writeRunTask(bots, ownPrompt(home, found)) });
169
170
  }
170
171
 
171
172
  /**
@@ -367,7 +368,7 @@ function runElsewhere(bots, onRun) {
367
368
  ...(onRun.model === undefined ? [] : [`--model ${shellWord(onRun.model)}`]),
368
369
  ...(onRun.effort === undefined ? [] : [`--effort ${shellWord(onRun.effort)}`]),
369
370
  ...(onRun.extra_args ?? []).map((arg) => `--extra-arg=${shellWord(arg)}`),
370
- `--prompt-file ${shellWord(writeRunTask(bots))}`,
371
+ `--prompt-file ${shellWord(onRun.task)}`,
371
372
  ].join(' ');
372
373
  return `${markerOf(bots)}: the daily grooming for this fleet, run on ${onRun.harness} as a temporary session of yours `
373
374
  + `that does the work and reports back to you. First, if any of your temporary sessions whose names start with groom- `
@@ -390,13 +391,29 @@ function readReport(bots) {
390
391
  + `Then retire the run with ${retire}, its name in place of ${RUN_NAME}. Schedule nothing then: the job renewed itself when it fired.`;
391
392
  }
392
393
 
394
+ /**
395
+ * The grooming session's own prompt, as the user wrote it, or undefined when it
396
+ * has none: the standing duties a run on Codex carries too (#566). Without the
397
+ * work-dir note the session is launched with, since a run has a work dir of its
398
+ * own. Read when the line is typed, so a prompt changed since reaches the runs
399
+ * at the next `--on`.
400
+ */
401
+ function ownPrompt(home, session) {
402
+ try {
403
+ return startPrompt(session, { home });
404
+ } catch (error) {
405
+ throw new Error(`${BOT_FATHER}'s ${GROOMING} session's prompt file ${promptFile(session, home)} cannot be read (${error.code ?? error.message}), and each grooming run on Codex carries that prompt. Nothing was typed. Put the file back, or change the session's prompt, then run this again.`);
406
+ }
407
+ }
408
+
393
409
  /**
394
410
  * Write the task a Codex run is started with, and answer where: do the daily
395
- * grooming with the obk-grooming skill, send the report to the grooming session
396
- * by the kit's mail, and stop. It schedules nothing: the schedule is the
397
- * grooming session's.
411
+ * grooming with the obk-grooming skill, and the grooming session's own duties
412
+ * when its prompt has any, send the report to the grooming session by the
413
+ * kit's mail, and stop. It schedules nothing: the schedule is the grooming
414
+ * session's.
398
415
  */
399
- function writeRunTask(bots) {
416
+ function writeRunTask(bots, own) {
400
417
  const real = realHome(bots);
401
418
  const kit = shellWord(ownCli());
402
419
  const file = path.resolve(promptPath(real, BOT_FATHER, `${GROOMING}-run`));
@@ -405,6 +422,7 @@ function writeRunTask(bots) {
405
422
  + `since the last run's end up to it, counting with ${kit} usage --since <last end> --until <this end>, and write this end down for the next run. `
406
423
  + 'Work out what it cost with obk-finops, and keep each bot\'s profile notes. '
407
424
  + `The kit here is ${kit}: run it wherever a skill says obk. The bots folder is ${real}. `
425
+ + (own === undefined ? '' : `The ${GROOMING} session's own prompt is this run's too. It follows, word for word as the user wrote it:\n\n${own}\n\n`)
408
426
  + `When the grooming is done, send one short report to ${BOT_FATHER}/${GROOMING} by the kit's mail: `
409
427
  + `ask for the road with ${kit} message to --bots ${shellWord(real)} --to ${BOT_FATHER}/${GROOMING}, then send it with the message send command its answer names. `
410
428
  + 'Then stop: schedule nothing, make no session, and say nothing more.\n';
package/src/health.js CHANGED
@@ -18,22 +18,23 @@
18
18
  // is, the one thing to go and look at, and a sentence naming it — so that
19
19
  // somebody who cannot read code knows what was found and where.
20
20
 
21
- import { existsSync, readdirSync, realpathSync } from 'node:fs';
21
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
22
22
  import path from 'node:path';
23
23
 
24
24
  import { bookFile, readBook, sessionIdsIn, tabIdsIn } from './book.js';
25
25
  import { botDir, botNames, botsDir, readBot, tempRoles, unknownKeys } from './bot.js';
26
26
  import { transcriptsIn } from './conversations.js';
27
27
  import { hookTrouble } from './hooks.js';
28
- import { APPROVALS, bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, sessionTrouble, SHELL_ENV, shellWord } from './launch.js';
28
+ import { APPROVALS, bypassFlags, harnessOf, HARNESSES, isAddressOf, ownCli, promptFile, sessionTrouble, set, SHELL_ENV, shellWord, workDirOf } from './launch.js';
29
29
  import { frontOfTab, orcaDefaultArgs, projects, runMissing, tabs, wordsOfProcess } from './orca.js';
30
30
  import { permissionsTrouble } from './permissions.js';
31
+ import { processesIn } from './processes.js';
31
32
  import { TAB_ENV } from './record.js';
32
33
  import { agentsTrouble, rulesStamp } from './rules.js';
33
34
  import { settingsInUse } from './settings.js';
34
35
  import { skillsTrouble } from './skills.js';
35
36
  import { readSources, sourcesDir } from './sources.js';
36
- import { BOT_FATHER, botsNamed, promptPath } from './up.js';
37
+ import { BOT_FATHER, botsNamed, oldPromptPath, promptPath } from './up.js';
37
38
 
38
39
  /**
39
40
  * Everything wrong with the setup at `bots`, in one list: what Orca's own
@@ -139,8 +140,20 @@ function leftBeside(bots) {
139
140
  let known = true;
140
141
  for (const name of botNames(bots)) {
141
142
  try {
142
- for (const session of readBot(botDir(bots, name), name).sessions) {
143
+ const home = botDir(bots, name);
144
+ for (const session of readBot(home, name).sessions) {
143
145
  owned.add(promptPath(bots, name, session.name));
146
+ // The file a session names as its own prompt, which the kit reads to
147
+ // start it, and the file a link there leads to (#577).
148
+ if (set(session.prompt_file)) {
149
+ const file = promptFile(session, home);
150
+ owned.add(file);
151
+ const real = realpathOf(file);
152
+ if (real !== undefined) owned.add(real);
153
+ }
154
+ // Where a live session's prompt was before #534: nothing reads it once
155
+ // the session starts again, and its retire removes it.
156
+ owned.add(oldPromptPath(bots, name, session.name));
144
157
  }
145
158
  } catch {
146
159
  known = false;
@@ -148,9 +161,8 @@ function leftBeside(bots) {
148
161
  }
149
162
 
150
163
  if (known) {
151
- for (const file of namesIn(`${bots}.prompts`)) {
152
- const at = path.join(`${bots}.prompts`, file);
153
- if (owned.has(at)) continue;
164
+ for (const at of promptFilesIn(`${bots}.prompts`)) {
165
+ if (owned.has(at) || owned.has(realpathOf(at))) continue;
154
166
  found.push(finding('leftover', at, `${at} is a start prompt the kit wrote beside your bots folder, and no session of any bot answers to it now. Nothing reads it.`));
155
167
  }
156
168
  }
@@ -196,9 +208,48 @@ function aboutBot(bots, name, setups, sessions) {
196
208
  ...permissionsTrouble(home, bot).map(said('config')),
197
209
  ...skillsTrouble(bots, home, bot).map(said('skill')),
198
210
  ...inOrca(bots, home, bot, setups, sessions),
211
+ ...leftRunning(home, bot),
199
212
  ];
200
213
  }
201
214
 
215
+ /**
216
+ * What still runs in the work dir of a retired session (#537): a retire stops
217
+ * it, so this is one from before that, or one a retire could not stop. The
218
+ * book keeps a retired session's work dir; a temporary session retired before
219
+ * it did worked in `work/<name>`, where `obk temp make` puts it. A work dir a
220
+ * session in bot.yaml still uses is that session's, and not looked at, by its
221
+ * real path, so a link to it is not taken for a retired one (review of PR #543).
222
+ */
223
+ function leftRunning(home, bot) {
224
+ let book;
225
+ try {
226
+ book = readBook(home);
227
+ } catch {
228
+ // A book that cannot be read is said where the book is read.
229
+ return [];
230
+ }
231
+ const real = (dir) => (dir === undefined ? undefined : realpathOf(dir));
232
+ const live = new Set(bot.sessions.map((session) => real(workDirOf(session, home))));
233
+ const dirs = [];
234
+ const seen = new Set();
235
+ for (const entry of Array.isArray(book.retired) ? book.retired : []) {
236
+ const named = typeof entry?.work_dir === 'string' && entry.work_dir !== '' ? entry.work_dir : undefined;
237
+ const dir = workDirOf({ work_dir: named ?? (entry?.temporary === undefined ? undefined : `work/${entry.name}`) }, home);
238
+ // One that is not there holds nothing.
239
+ const at = real(dir);
240
+ if (at === undefined || live.has(at) || seen.has(at)) continue;
241
+ seen.add(at);
242
+ dirs.push({ session: entry.name, dir });
243
+ }
244
+ if (dirs.length === 0) return [];
245
+
246
+ const read = processesIn(dirs);
247
+ if (read.unreadable !== undefined) {
248
+ return [finding('process', bookFile(home), `The kit cannot tell whether anything still runs in the work dirs of ${bot.name}'s retired sessions: ${read.unreadable}.`, bot.name)];
249
+ }
250
+ return read.running.map((one) => finding('process', one.dir, `pid ${one.pid} still runs in the work dir of ${bot.name}/${one.session}, which was retired: ${one.command}. Nothing of the kit's stops it now. If it is not wanted, stop it yourself: kill ${one.pid}`, bot.name));
251
+ }
252
+
202
253
  /** A session the kit would refuse to start, in the words `up` refuses it with. */
203
254
  function sessionSettings(home, bot) {
204
255
  const file = path.join(home, 'bot.yaml');
@@ -213,7 +264,7 @@ function sessionSettings(home, bot) {
213
264
  /** What is wrong with the roles the bot's temporary sessions are made in (#465). */
214
265
  function rolesTrouble(home, bot) {
215
266
  const where = path.join(home, 'bot.yaml');
216
- // The widest approval they may be made at, the user's yes (ADR 0041).
267
+ // The widest approval they may be made at, the user's yes (ADR 0051).
217
268
  const level = bot.temp_approval;
218
269
  const approval = level === undefined || APPROVALS.includes(level) ? [] : [{
219
270
  where,
@@ -382,7 +433,7 @@ function inOrca(bots, home, bot, setups, sessions) {
382
433
 
383
434
  // What the harness has on record in this bot's folder, read once: it is a walk
384
435
  // through every conversation on the machine.
385
- const onRecord = recordIn(real ?? home, bot);
436
+ const onRecord = recordIn(real ?? home, bot, book);
386
437
  // A conversation the harness marks as a subagent's was never a session's, so a
387
438
  // note that holds one is not a loose end for it (#287). The note stays as it is.
388
439
  const helpers = new Set(onRecord.filter((one) => one.subagent).map((one) => one.id));
@@ -451,16 +502,33 @@ function offTheBook(home, bot, book, onRecord) {
451
502
  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)];
452
503
  }
453
504
 
454
- /** Every conversation on record in the bot's folder, from every harness any of its sessions runs on, as `obk usage` reads them. */
455
- function recordIn(home, bot) {
505
+ /** Every conversation on record in the bot's folder, from every harness any of its sessions runs on or a retired one may have, as `obk usage` reads them. */
506
+ function recordIn(home, bot, book) {
456
507
  const harnesses = new Set(bot.sessions.map((session) => harnessOf(session, bot.harness)));
457
508
  if (harnesses.size === 0) harnesses.add(bot.harness);
509
+ // The book does not say which harness a retired session ran on, so any is (#573).
510
+ if (Array.isArray(book.retired) && book.retired.length > 0) for (const harness of HARNESSES) harnesses.add(harness);
458
511
  return [...harnesses]
459
512
  .filter((harness) => HARNESSES.includes(harness))
460
513
  .flatMap((harness) => transcriptsIn(harness, home));
461
514
  }
462
515
 
463
516
  /** What a directory holds, and nothing at all when there is no directory. */
517
+ /**
518
+ * The files in the kit's folder of start prompts: in each bot's own folder
519
+ * there (#534), and flat in the folder itself, where they were before.
520
+ */
521
+ function promptFilesIn(dir) {
522
+ return namesIn(dir).flatMap((name) => {
523
+ const at = path.join(dir, name);
524
+ try {
525
+ return statSync(at).isDirectory() ? namesIn(at).map((file) => path.join(at, file)) : [at];
526
+ } catch {
527
+ return [];
528
+ }
529
+ });
530
+ }
531
+
464
532
  function namesIn(dir) {
465
533
  try {
466
534
  return readdirSync(dir);
package/src/hooks.js CHANGED
@@ -17,7 +17,11 @@ import { isDeepStrictEqual } from 'node:util';
17
17
  import { leadsOutside } from './bot.js';
18
18
  import { ownCli, shellWord } from './launch.js';
19
19
 
20
- /** Where each harness reads a project's hooks, inside the bot home. */
20
+ /**
21
+ * Where each harness reads a project's hooks, inside the bot home. Pi has no
22
+ * hook of the kit's: the kit names a Pi session's id itself, on its launch
23
+ * line (#578).
24
+ */
21
25
  const HOOK_FILE = { claude: '.claude/settings.json', codex: '.codex/hooks.json' };
22
26
 
23
27
  /** The hooks file `harness` reads in the bot at `home`. */
@@ -58,36 +62,7 @@ export const sentCommand = (bots, bot, cli = ownCli()) =>
58
62
  `${shellWord(cli)} session sent --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
59
63
 
60
64
  /**
61
- * Claude Code's third kit hook: at each turn end, it tells the session once
62
- * about fleet mail the kit sent it that is still unread (#509, ADR 0035).
63
- * Claude Code only: on Codex a new hook entry puts every session on "Hooks
64
- * need review", and that waits for #511. It asks Orca only when the kit's hint
65
- * holds mail for the session, and gives Orca at most ten seconds of its thirty.
66
- */
67
- const MAIL_EVENT = 'Stop';
68
- const MAIL_TIMEOUT = 30;
69
-
70
- /** What the mail hook runs, by the kit's own path, as `hookCommand` does. */
71
- export const mailCommand = (bots, bot, cli = ownCli()) =>
72
- `${shellWord(cli)} session mail --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
73
-
74
- /**
75
- * Codex's second kit hook: after each command its shell tool ran, outside
76
- * Codex's sandbox, it decides the mail nudges a send in that command could not
77
- * (#350, ADR 0034). Codex only, since only Codex runs its commands where `ps`
78
- * does not start. It types into another session's tab, which takes Orca a few
79
- * seconds a nudge, so it is given longer than the others.
80
- */
81
- const NUDGE_EVENT = 'PostToolUse';
82
- const NUDGE_MATCHER = 'Bash';
83
- const NUDGE_TIMEOUT = 30;
84
-
85
- /** What the nudge hook runs, by the kit's own path, as `hookCommand` does. */
86
- export const nudgeCommand = (bots, bot, cli = ownCli()) =>
87
- `${shellWord(cli)} session nudge --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
88
-
89
- /**
90
- * Codex's third kit hook: at each turn end, the session's thread is named after
65
+ * Codex's second kit hook: at each turn end, the session's thread is named after
91
66
  * its bot and session, so its tab says which it is (#480). Async, so the turn
92
67
  * ends without waiting on it: it waits for the session to be idle, up to 30 s,
93
68
  * then types and confirms. A naming took 73 s live, each character waiting on
@@ -111,6 +86,7 @@ export const nameCommand = (bots, bot, cli = ownCli()) =>
111
86
  * the kit's own entry is written (PRD 6.3).
112
87
  */
113
88
  export function installHook(home, harness, { bots, bot }) {
89
+ if (HOOK_FILE[harness] === undefined) return;
114
90
  const file = path.join(home, HOOK_FILE[harness]);
115
91
  const away = outside(home, file);
116
92
  if (away !== undefined) throw new Error(away);
@@ -123,7 +99,9 @@ export function installHook(home, harness, { bots, bot }) {
123
99
  // longer asks about is taken out, and a hook of the user's beside the kit's
124
100
  // stays where they put it.
125
101
  const started = withKitHook(settings.hooks, file, mine);
126
- const wanted = { ...settings, hooks: toolHooksOf(harness, bots, bot).reduce(withToolHook, started) };
102
+ // The Claude Code mail hook and the Codex nudge hook went with the kit's
103
+ // typed line (#555), so an entry of either is taken out.
104
+ const wanted = { ...settings, hooks: toolHooksOf(harness, bots, bot).reduce(withToolHook, withoutHooks(started, [KIT_MAIL, KIT_NUDGE])) };
127
105
 
128
106
  // Compared as documents, not as text: how the user laid their file out is
129
107
  // theirs, and a run that changes nothing writes nothing.
@@ -144,6 +122,7 @@ export function installHook(home, harness, { bots, bot }) {
144
122
  * nothing else says so — the book simply stops being true.
145
123
  */
146
124
  export function hookTrouble(home, harness, { bots, bot }) {
125
+ if (HOOK_FILE[harness] === undefined) return undefined;
147
126
  const file = path.join(home, HOOK_FILE[harness]);
148
127
  const stale = `Until it is there, nothing tells the book which conversation this bot's ${harness} sessions are running as, and the book goes stale.`;
149
128
 
@@ -171,9 +150,8 @@ export function hookTrouble(home, harness, { bots, bot }) {
171
150
  const program = running ?? ownCli();
172
151
  wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot, program), timeout: TIMEOUT }) };
173
152
  // The harness's other hooks are each judged on their own: the session hook
174
- // may be right while the warning after a native message (ADR 0032), the
175
- // nudge a Codex sender left (ADR 0034), or the naming of a Codex thread
176
- // (#480) is missing.
153
+ // may be right while the warning after a native message (ADR 0032) or the
154
+ // naming of a Codex thread (#480) is missing.
177
155
  if (isDeepStrictEqual(settings, wanted)) {
178
156
  for (const tool of toolHooksOf(harness, bots, bot)) {
179
157
  const toolRunning = programIn(settings, tool.pattern);
@@ -267,24 +245,15 @@ function withKitHook(hooks, file, mine) {
267
245
 
268
246
  /** Each harness's kit hooks beside its session hook, as `toolHookOf` gives each. */
269
247
  const toolHooksOf = (harness, bots, bot, cli = ownCli()) =>
270
- (harness === 'claude' ? ['sent', 'mail'] : ['nudge', 'name']).map((kind) => toolHookOf(kind, bots, bot, cli));
248
+ (harness === 'claude' ? ['sent'] : ['name']).map((kind) => toolHookOf(kind, bots, bot, cli));
271
249
 
272
250
  /**
273
251
  * One of the kit's hooks beside its session hook: Claude Code's after a native
274
- * `SendMessage` (`sent`, ADR 0032), Codex's after its shell tool (`nudge`,
275
- * ADR 0034), and Codex's at a turn end (`name`, #480). The entry, where it
276
- * goes, how the kit knows its own, and what is lost without it.
252
+ * `SendMessage` (`sent`, ADR 0032), and Codex's at a turn end (`name`, #480).
253
+ * The entry, where it goes, how the kit knows its own, and what is lost
254
+ * without it.
277
255
  */
278
256
  function toolHookOf(kind, bots, bot, cli = ownCli()) {
279
- if (kind === 'mail') {
280
- return {
281
- kind,
282
- event: MAIL_EVENT,
283
- pattern: KIT_MAIL,
284
- mine: { type: 'command', command: mailCommand(bots, bot, cli), timeout: MAIL_TIMEOUT },
285
- missing: 'a Claude session busy when fleet mail came is never told about it at the end of its turn, and nothing is typed into its tab either (#509)',
286
- };
287
- }
288
257
  if (kind === 'name') {
289
258
  return {
290
259
  kind,
@@ -294,26 +263,34 @@ function toolHookOf(kind, bots, bot, cli = ownCli()) {
294
263
  missing: "a Codex session's thread is never named after its bot and session, and its tab shows a topic Codex made up instead (#480)",
295
264
  };
296
265
  }
297
- if (kind === 'sent') {
298
- return {
299
- kind,
300
- event: SENT_EVENT,
301
- matcher: SENT_MATCHER,
302
- pattern: KIT_SENT,
303
- mine: { type: 'command', command: sentCommand(bots, bot, cli), timeout: TIMEOUT },
304
- missing: 'a session is never warned when its native message goes outside this bots folder (ADR 0032)',
305
- };
306
- }
307
266
  return {
308
267
  kind,
309
- event: NUDGE_EVENT,
310
- matcher: NUDGE_MATCHER,
311
- pattern: KIT_NUDGE,
312
- mine: { type: 'command', command: nudgeCommand(bots, bot, cli), timeout: NUDGE_TIMEOUT },
313
- missing: "mail a session sends from inside Codex's sandbox never tells a receiver the kit could not see from there, such as a Claude session busy with a command (ADR 0034)",
268
+ event: SENT_EVENT,
269
+ matcher: SENT_MATCHER,
270
+ pattern: KIT_SENT,
271
+ mine: { type: 'command', command: sentCommand(bots, bot, cli), timeout: TIMEOUT },
272
+ missing: 'a session is never warned when its native message goes outside this bots folder (ADR 0032)',
314
273
  };
315
274
  }
316
275
 
276
+ /**
277
+ * The hooks without the kit's entries of these patterns, the hooks it no
278
+ * longer writes, and everything else as the user left it. A group the kit
279
+ * empties goes, and an event left with no groups goes with it.
280
+ */
281
+ function withoutHooks(hooks, patterns) {
282
+ return Object.fromEntries(Object.entries(hooks).flatMap(([event, groups]) => {
283
+ if (!Array.isArray(groups)) return [[event, groups]];
284
+ const kept = groups.flatMap((group) => {
285
+ if (!Array.isArray(group?.hooks)) return [group];
286
+ const entries = group.hooks.filter((hook) => patterns.every((pattern) => programOf(hook?.command, pattern) === undefined));
287
+ if (entries.length === group.hooks.length) return [group];
288
+ return entries.length === 0 ? [] : [{ ...group, hooks: entries }];
289
+ });
290
+ return kept.length === 0 ? [] : [[event, kept]];
291
+ }));
292
+ }
293
+
317
294
  /**
318
295
  * The hooks with the kit's tool hook in a group of its event matched to its
319
296
  * tool (or with no matcher, for one that has none), and everything else as the
@@ -380,6 +357,7 @@ const isKitHook = (hook) => kitProgramOf(hook?.command) !== undefined;
380
357
  const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
381
358
  const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
382
359
  const KIT_SENT = new RegExp(String.raw`^(${WORD}) session sent --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
360
+ /** The hooks the kit wrote before #555, and takes out. */
383
361
  const KIT_NUDGE = new RegExp(String.raw`^(${WORD}) session nudge --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
384
362
  const KIT_MAIL = new RegExp(String.raw`^(${WORD}) session mail --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
385
363
  const KIT_NAME = new RegExp(String.raw`^(${WORD}) session name --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
@@ -390,7 +368,7 @@ const KIT_NAME = new RegExp(String.raw`^(${WORD}) session name --bots ${WORD} --
390
368
  * (#506). One that runs some other program, even another `obk`, is not.
391
369
  */
392
370
  export const runsThisKit = (hook, cli = ownCli()) =>
393
- hook?.type === 'command' && [KIT_HOOK, KIT_SENT, KIT_NUDGE, KIT_NAME].some((pattern) => programOf(hook.command, pattern) === cli);
371
+ hook?.type === 'command' && [KIT_HOOK, KIT_SENT, KIT_NAME].some((pattern) => programOf(hook.command, pattern) === cli);
394
372
 
395
373
  /** The program a bot made before the kit named itself by path runs. */
396
374
  const BARE = 'obk';