@assuredloop/orca-bot-kit 0.26.1 → 0.27.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/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, sessionTrouble, 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
@@ -141,6 +142,9 @@ function leftBeside(bots) {
141
142
  try {
142
143
  for (const session of readBot(botDir(bots, name), name).sessions) {
143
144
  owned.add(promptPath(bots, name, session.name));
145
+ // Where a live session's prompt was before #534: nothing reads it once
146
+ // the session starts again, and its retire removes it.
147
+ owned.add(oldPromptPath(bots, name, session.name));
144
148
  }
145
149
  } catch {
146
150
  known = false;
@@ -148,8 +152,7 @@ function leftBeside(bots) {
148
152
  }
149
153
 
150
154
  if (known) {
151
- for (const file of namesIn(`${bots}.prompts`)) {
152
- const at = path.join(`${bots}.prompts`, file);
155
+ for (const at of promptFilesIn(`${bots}.prompts`)) {
153
156
  if (owned.has(at)) continue;
154
157
  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
158
  }
@@ -196,9 +199,48 @@ function aboutBot(bots, name, setups, sessions) {
196
199
  ...permissionsTrouble(home, bot).map(said('config')),
197
200
  ...skillsTrouble(bots, home, bot).map(said('skill')),
198
201
  ...inOrca(bots, home, bot, setups, sessions),
202
+ ...leftRunning(home, bot),
199
203
  ];
200
204
  }
201
205
 
206
+ /**
207
+ * What still runs in the work dir of a retired session (#537): a retire stops
208
+ * it, so this is one from before that, or one a retire could not stop. The
209
+ * book keeps a retired session's work dir; a temporary session retired before
210
+ * it did worked in `work/<name>`, where `obk temp make` puts it. A work dir a
211
+ * session in bot.yaml still uses is that session's, and not looked at, by its
212
+ * real path, so a link to it is not taken for a retired one (review of PR #543).
213
+ */
214
+ function leftRunning(home, bot) {
215
+ let book;
216
+ try {
217
+ book = readBook(home);
218
+ } catch {
219
+ // A book that cannot be read is said where the book is read.
220
+ return [];
221
+ }
222
+ const real = (dir) => (dir === undefined ? undefined : realpathOf(dir));
223
+ const live = new Set(bot.sessions.map((session) => real(workDirOf(session, home))));
224
+ const dirs = [];
225
+ const seen = new Set();
226
+ for (const entry of Array.isArray(book.retired) ? book.retired : []) {
227
+ const named = typeof entry?.work_dir === 'string' && entry.work_dir !== '' ? entry.work_dir : undefined;
228
+ const dir = workDirOf({ work_dir: named ?? (entry?.temporary === undefined ? undefined : `work/${entry.name}`) }, home);
229
+ // One that is not there holds nothing.
230
+ const at = real(dir);
231
+ if (at === undefined || live.has(at) || seen.has(at)) continue;
232
+ seen.add(at);
233
+ dirs.push({ session: entry.name, dir });
234
+ }
235
+ if (dirs.length === 0) return [];
236
+
237
+ const read = processesIn(dirs);
238
+ if (read.unreadable !== undefined) {
239
+ 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)];
240
+ }
241
+ 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));
242
+ }
243
+
202
244
  /** A session the kit would refuse to start, in the words `up` refuses it with. */
203
245
  function sessionSettings(home, bot) {
204
246
  const file = path.join(home, 'bot.yaml');
@@ -461,6 +503,21 @@ function recordIn(home, bot) {
461
503
  }
462
504
 
463
505
  /** What a directory holds, and nothing at all when there is no directory. */
506
+ /**
507
+ * The files in the kit's folder of start prompts: in each bot's own folder
508
+ * there (#534), and flat in the folder itself, where they were before.
509
+ */
510
+ function promptFilesIn(dir) {
511
+ return namesIn(dir).flatMap((name) => {
512
+ const at = path.join(dir, name);
513
+ try {
514
+ return statSync(at).isDirectory() ? namesIn(at).map((file) => path.join(at, file)) : [at];
515
+ } catch {
516
+ return [];
517
+ }
518
+ });
519
+ }
520
+
464
521
  function namesIn(dir) {
465
522
  try {
466
523
  return readdirSync(dir);
package/src/hooks.js CHANGED
@@ -58,36 +58,7 @@ export const sentCommand = (bots, bot, cli = ownCli()) =>
58
58
  `${shellWord(cli)} session sent --bots ${shellWord(bots)} --bot ${shellWord(bot)} 2>/dev/null || true`;
59
59
 
60
60
  /**
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
61
+ * Codex's second kit hook: at each turn end, the session's thread is named after
91
62
  * its bot and session, so its tab says which it is (#480). Async, so the turn
92
63
  * ends without waiting on it: it waits for the session to be idle, up to 30 s,
93
64
  * then types and confirms. A naming took 73 s live, each character waiting on
@@ -123,7 +94,9 @@ export function installHook(home, harness, { bots, bot }) {
123
94
  // longer asks about is taken out, and a hook of the user's beside the kit's
124
95
  // stays where they put it.
125
96
  const started = withKitHook(settings.hooks, file, mine);
126
- const wanted = { ...settings, hooks: toolHooksOf(harness, bots, bot).reduce(withToolHook, started) };
97
+ // The Claude Code mail hook and the Codex nudge hook went with the kit's
98
+ // typed line (#555), so an entry of either is taken out.
99
+ const wanted = { ...settings, hooks: toolHooksOf(harness, bots, bot).reduce(withToolHook, withoutHooks(started, [KIT_MAIL, KIT_NUDGE])) };
127
100
 
128
101
  // Compared as documents, not as text: how the user laid their file out is
129
102
  // theirs, and a run that changes nothing writes nothing.
@@ -171,9 +144,8 @@ export function hookTrouble(home, harness, { bots, bot }) {
171
144
  const program = running ?? ownCli();
172
145
  wanted = { ...settings, hooks: withKitHook(settings.hooks, file, { type: 'command', command: hookCommand(bots, bot, program), timeout: TIMEOUT }) };
173
146
  // 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.
147
+ // may be right while the warning after a native message (ADR 0032) or the
148
+ // naming of a Codex thread (#480) is missing.
177
149
  if (isDeepStrictEqual(settings, wanted)) {
178
150
  for (const tool of toolHooksOf(harness, bots, bot)) {
179
151
  const toolRunning = programIn(settings, tool.pattern);
@@ -267,24 +239,15 @@ function withKitHook(hooks, file, mine) {
267
239
 
268
240
  /** Each harness's kit hooks beside its session hook, as `toolHookOf` gives each. */
269
241
  const toolHooksOf = (harness, bots, bot, cli = ownCli()) =>
270
- (harness === 'claude' ? ['sent', 'mail'] : ['nudge', 'name']).map((kind) => toolHookOf(kind, bots, bot, cli));
242
+ (harness === 'claude' ? ['sent'] : ['name']).map((kind) => toolHookOf(kind, bots, bot, cli));
271
243
 
272
244
  /**
273
245
  * 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.
246
+ * `SendMessage` (`sent`, ADR 0032), and Codex's at a turn end (`name`, #480).
247
+ * The entry, where it goes, how the kit knows its own, and what is lost
248
+ * without it.
277
249
  */
278
250
  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
251
  if (kind === 'name') {
289
252
  return {
290
253
  kind,
@@ -294,26 +257,34 @@ function toolHookOf(kind, bots, bot, cli = ownCli()) {
294
257
  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
258
  };
296
259
  }
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
260
  return {
308
261
  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)",
262
+ event: SENT_EVENT,
263
+ matcher: SENT_MATCHER,
264
+ pattern: KIT_SENT,
265
+ mine: { type: 'command', command: sentCommand(bots, bot, cli), timeout: TIMEOUT },
266
+ missing: 'a session is never warned when its native message goes outside this bots folder (ADR 0032)',
314
267
  };
315
268
  }
316
269
 
270
+ /**
271
+ * The hooks without the kit's entries of these patterns, the hooks it no
272
+ * longer writes, and everything else as the user left it. A group the kit
273
+ * empties goes, and an event left with no groups goes with it.
274
+ */
275
+ function withoutHooks(hooks, patterns) {
276
+ return Object.fromEntries(Object.entries(hooks).flatMap(([event, groups]) => {
277
+ if (!Array.isArray(groups)) return [[event, groups]];
278
+ const kept = groups.flatMap((group) => {
279
+ if (!Array.isArray(group?.hooks)) return [group];
280
+ const entries = group.hooks.filter((hook) => patterns.every((pattern) => programOf(hook?.command, pattern) === undefined));
281
+ if (entries.length === group.hooks.length) return [group];
282
+ return entries.length === 0 ? [] : [{ ...group, hooks: entries }];
283
+ });
284
+ return kept.length === 0 ? [] : [[event, kept]];
285
+ }));
286
+ }
287
+
317
288
  /**
318
289
  * The hooks with the kit's tool hook in a group of its event matched to its
319
290
  * tool (or with no matcher, for one that has none), and everything else as the
@@ -380,6 +351,7 @@ const isKitHook = (hook) => kitProgramOf(hook?.command) !== undefined;
380
351
  const WORD = String.raw`(?:[A-Za-z0-9,._+:@%/=-]+|'(?:[^']|'\\'')*')`;
381
352
  const KIT_HOOK = new RegExp(String.raw`^(${WORD}) session record --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
382
353
  const KIT_SENT = new RegExp(String.raw`^(${WORD}) session sent --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
354
+ /** The hooks the kit wrote before #555, and takes out. */
383
355
  const KIT_NUDGE = new RegExp(String.raw`^(${WORD}) session nudge --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
384
356
  const KIT_MAIL = new RegExp(String.raw`^(${WORD}) session mail --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
385
357
  const KIT_NAME = new RegExp(String.raw`^(${WORD}) session name --bots ${WORD} --bot ${WORD} 2>/dev/null \|\| true$`);
@@ -390,7 +362,7 @@ const KIT_NAME = new RegExp(String.raw`^(${WORD}) session name --bots ${WORD} --
390
362
  * (#506). One that runs some other program, even another `obk`, is not.
391
363
  */
392
364
  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);
365
+ hook?.type === 'command' && [KIT_HOOK, KIT_SENT, KIT_NAME].some((pattern) => programOf(hook.command, pattern) === cli);
394
366
 
395
367
  /** The program a bot made before the kit named itself by path runs. */
396
368
  const BARE = 'obk';
package/src/launch.js CHANGED
@@ -76,7 +76,7 @@ export const harnessOf = (session, botHarness) => (set(session.harness) ? sessio
76
76
 
77
77
  /**
78
78
  * A session's name on Claude Code, which is also the address another Claude
79
- * session writes to (ADR 0035). It goes on the launch line as `-n`, and it is
79
+ * session writes to (ADR 0042). It goes on the launch line as `-n`, and it is
80
80
  * re-applied every time the session is started: a resume keeps the name by
81
81
  * itself (tech notes, section 2), and the kit does not depend on that.
82
82
  *
@@ -231,7 +231,7 @@ const where = (session) => (set(session.name) ? `session ${session.name}` : 'thi
231
231
  * send would land on that list and answer it. A prompt given as an argument is
232
232
  * held by the harness until it is ready for it (tech notes, section 1).
233
233
  */
234
- export function launchCommand(session, { harness, home, workDir, prompt, promptFile: fromFile, resume, address }) {
234
+ export function launchCommand(session, { harness, home, workDir, prompt, promptFile: fromFile, resume, address, kit }) {
235
235
  const trouble = sessionTrouble(session, harness, home);
236
236
  if (trouble !== undefined) throw new Error(trouble);
237
237
 
@@ -249,7 +249,7 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
249
249
 
250
250
  if (harness === 'claude') {
251
251
  // The name is the address other Claude sessions write to, so it goes on
252
- // every launch line, a resume included (ADR 0035).
252
+ // every launch line, a resume included (ADR 0042).
253
253
  if (address !== undefined) words.push('-n', address);
254
254
  // The context window rides on the model name: `sonnet[1m]`.
255
255
  if (set(session.model)) words.push('--model', set(session.context) ? `${session.model}[${session.context}]` : session.model);
@@ -265,6 +265,10 @@ export function launchCommand(session, { harness, home, workDir, prompt, promptF
265
265
  // started in, and nowhere else (ADR 0015), so a work dir outside the bot
266
266
  // home has to be named.
267
267
  if (workDir !== undefined && !inside(home, workDir)) words.push('--add-dir', workDir);
268
+ // And the kit's own folders for this bot beside the bots folder: its long
269
+ // message bodies, its start prompts and its locks (#534). The bot's, and no
270
+ // other bot's: a session never gets to write another bot's mail.
271
+ if (kit !== undefined) for (const folder of Object.values(kit)) words.push('--add-dir', folder);
268
272
  }
269
273
 
270
274
  return [
@@ -339,6 +343,19 @@ const resumeWords = (harness, resume) => {
339
343
  return harness === 'claude' ? ['--resume', quoted(resume)] : [quoted(resume)];
340
344
  };
341
345
 
346
+ /**
347
+ * The kit's own folders for one bot, beside the bots folder: `messages` for
348
+ * the bodies of its long fleet mail, `prompts` for its sessions' start
349
+ * prompts, `locks` for its book's and its sessions' turns. Each is a folder of
350
+ * the bot's own, so that a Codex session, whose sandbox writes only where its
351
+ * launch line says, can be given its bot's and no other's (#534).
352
+ */
353
+ export const kitFolders = (bots, bot) => ({
354
+ messages: path.join(`${bots}.messages`, bot),
355
+ prompts: path.join(`${bots}.prompts`, bot),
356
+ locks: path.join(`${bots}.locks`, bot),
357
+ });
358
+
342
359
  /** Whether `target` is the folder at `home` or something inside it. */
343
360
  const inside = (home, target) => target === home || target.startsWith(home + path.sep);
344
361