@assuredloop/orca-bot-kit 0.26.0 → 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.
@@ -24,7 +24,7 @@ import { accessSync, constants, existsSync, mkdirSync, readdirSync, readFileSync
24
24
  import path from 'node:path';
25
25
 
26
26
  import { allowRules, leadsOutside } from './bot.js';
27
- import { DEFAULT_COMMANDS } from './commands.js';
27
+ import { DEFAULT_COMMANDS, NO_LONGER_DEFAULT } from './commands.js';
28
28
  import { readSettings } from './hooks.js';
29
29
  import { harnessOf, ownCli, shellWord } from './launch.js';
30
30
 
@@ -37,7 +37,7 @@ const CODEX_FILE = `${CODEX_RULES}/obk.rules`;
37
37
 
38
38
  /**
39
39
  * The rules every bot is given (ADR 0036): each of the kit's default commands,
40
- * and none kept back for the user's yes (ADR 0037), narrowed to this bots folder and
40
+ * and none kept back for the user's yes (ADR 0041), narrowed to this bots folder and
41
41
  * spelled as the kit prints them, which is what a bot runs; reading a long
42
42
  * message; committing; and the check Orca's mail notice tells a session to
43
43
  * run. `//` is Claude's form for an absolute path.
@@ -54,6 +54,18 @@ export function defaultRules(bots, cli = ownCli()) {
54
54
  ];
55
55
  }
56
56
 
57
+ /**
58
+ * The rules of the commands that were defaults once and are kept back now
59
+ * (#548), spelled as `defaultRules` spelled them, that the bot's `allow`
60
+ * holds. The kit cannot tell its own old entry from a user's yes to the same
61
+ * rule, so it names them and leaves them (ADR 0041).
62
+ */
63
+ export function heldRules(bots, home, bot, cli = ownCli()) {
64
+ const allowed = allowOf(home, bot);
65
+ return NO_LONGER_DEFAULT.map((command) => `Bash(${shellWord(cli)} ${command} --bots ${shellWord(bots)}:*)`)
66
+ .filter((rule) => allowed.includes(rule));
67
+ }
68
+
57
69
  /** Command wrappers: programs that run the command their arguments name. */
58
70
  const WRAPPERS = new Set(['env', 'xargs', 'sudo', 'eval', 'exec', 'nohup', 'timeout', 'nice', 'command', 'time', 'watch']);
59
71
 
@@ -261,26 +273,30 @@ export function refuseNoCodexForm(bot, rules) {
261
273
  * Add the default rules the bot is not allowed yet to its `allow` (ADR 0036),
262
274
  * then write what it is allowed into the files of the harnesses it runs on:
263
275
  * one entry per file, Claude's first, each with `defaults`, the rules this run
264
- * added. The harness files are written first and bot.yaml last, as
276
+ * added, and `held`, the rules of commands no longer in the set that the bot
277
+ * still holds (#548). The harness files are written first and bot.yaml last, as
265
278
  * `permission allow` writes them (#383). With `keepGoing`, a file the kit may
266
279
  * not write is an entry with its `trouble` rather than a throw.
267
280
  */
268
281
  export function writePermissions(bots, home, bot, { keepGoing = false } = {}) {
269
282
  const writers = [[runsOnClaude, FILE], [runsOnCodex, CODEX_FILE]].filter(([runs]) => runs(bot));
270
283
  let defaults = [];
284
+ // Read before anything is written, so a file's trouble still names them.
285
+ let held = [];
271
286
  try {
287
+ held = heldRules(bots, home, bot);
272
288
  defaults = writers.length === 0 ? [] : missingDefaults(bots, home, bot);
273
289
  if (defaults.length > 0) {
274
290
  const { allow } = allowRules(bots, bot.name, defaults, { write: false });
275
291
  const entries = allowIn(bots, home, { ...bot, allow })();
276
292
  allowRules(bots, bot.name, defaults);
277
- return entries.map((entry) => ({ ...entry, defaults }));
293
+ return entries.map((entry) => ({ ...entry, defaults, held }));
278
294
  }
279
295
  return writers.map(([runs]) => (runs === runsOnClaude ? planClaude : planCodex)(bots, home, bot).write())
280
- .map((entry) => ({ ...entry, defaults }));
296
+ .map((entry) => ({ ...entry, defaults, held }));
281
297
  } catch (error) {
282
298
  if (!keepGoing) throw error;
283
- return writers.map(([, file]) => ({ bot: bot.name, file: path.join(home, file), written: [], defaults: [], trouble: error.message }));
299
+ return writers.map(([, file]) => ({ bot: bot.name, file: path.join(home, file), written: [], defaults: [], held, trouble: error.message }));
284
300
  }
285
301
  }
286
302
 
@@ -0,0 +1,392 @@
1
+ // What a retired session left running (#537).
2
+ //
3
+ // A session's harness runs at the bot home, and the commands it runs for its
4
+ // work run in its work dir: a test run, a build, a server. Closing its tab ends
5
+ // the harness, and what it started from there can run on with launchd as its
6
+ // parent, for hours. So a retire stops them, and `obk health` names any that
7
+ // still run in a retired session's work dir.
8
+ //
9
+ // The kit has no record of what a session started. A session's own processes
10
+ // are the user's processes whose working folder is in its work dir, and every
11
+ // process that descends from one of them, wherever it works, as a test run's
12
+ // throwaway folders do. Nothing else is stopped: a process that shares a
13
+ // process group with them and is not their own is named, with its pid, its
14
+ // working folder and its command, and so is anything the kit could not stop.
15
+ // The run that does the stopping, its callers and its own children are never
16
+ // stopped, nor a process of another user.
17
+ //
18
+ // `ps` gives the processes, `lsof` their working folders, and `kill` sends the
19
+ // signals: SIGTERM first, to the whole group where every process in it is the
20
+ // session's own, then up to 3 seconds for them to end, then SIGKILL to each
21
+ // that still runs. Inside Codex's sandbox `ps` does not start (#298), and then
22
+ // nothing is stopped and the answer says it cannot tell. OBK_PS, OBK_LSOF and
23
+ // OBK_KILL override the three, as OBK_ORCA does Orca, so the suite runs on
24
+ // fakes and sends no real signal.
25
+
26
+ import { spawnSync } from 'node:child_process';
27
+ import { realpathSync } from 'node:fs';
28
+ import path from 'node:path';
29
+ import { setTimeout as delay } from 'node:timers/promises';
30
+
31
+ import { lsofCli, psCli } from './orca.js';
32
+
33
+ /** The `kill` this run sends signals with. OBK_KILL overrides it. */
34
+ const killCli = () => process.env.OBK_KILL || '/bin/kill';
35
+
36
+ /** How long one `ps`, `lsof` or `kill` may take. */
37
+ const READ_MS = 10_000;
38
+
39
+ /** How long the processes have to end after SIGTERM, before SIGKILL. */
40
+ const TERM_WAIT_MS = 3000;
41
+
42
+ /** How long after the first SIGTERM a new process of theirs is still looked for. */
43
+ const ADOPT_MS = 2 * TERM_WAIT_MS;
44
+
45
+ /** The end of the whole stop: the last one found has its own wait after SIGTERM, then after SIGKILL. */
46
+ const STOP_MS = ADOPT_MS + 2 * TERM_WAIT_MS;
47
+
48
+ /** How often the table is read again while they end. */
49
+ const POLL_MS = 100;
50
+
51
+ /**
52
+ * Stop the processes the sessions in `dirs` left running: `dirs` is a list of
53
+ * `{ session, dir }`, each a session and its work dir. Returns `{ stopped,
54
+ * left }`: `stopped` holds `{ session, pid, cwd, command, signal }` for each
55
+ * process that ended, with the signal it took; `left` holds `{ session, pid,
56
+ * cwd, command, why }` for each that was named and not stopped. With
57
+ * `unreadable: <why>` beside them, both empty, where the processes could not be
58
+ * read, and then nothing was signalled.
59
+ */
60
+ export async function stopProcesses(dirs) {
61
+ const read = readProcesses();
62
+ if (read.unreadable !== undefined) return { stopped: [], left: [], unreadable: read.unreadable };
63
+ const { table, cwds } = read;
64
+
65
+ const owner = ownersIn(table, cwds, dirs);
66
+ const where = realDirs(dirs);
67
+ const mine = ownRun(table);
68
+ const uid = process.getuid();
69
+ const about = (pid) => ({ session: owner.get(pid), pid, cwd: cwds.get(pid) ?? null, command: table.get(pid).command });
70
+
71
+ const left = [];
72
+ const targets = new Map();
73
+ // The session's own that the kit names and does not signal, by pid with
74
+ // when it started: what they start is the session's own too (R1, review
75
+ // of PR #543).
76
+ const kept = new Map();
77
+ for (const pid of owner.keys()) {
78
+ if (mine.has(pid)) left.push({ ...about(pid), why: 'it is this retire\'s own run, or one that started it' });
79
+ else if (table.get(pid).uid !== uid) {
80
+ left.push({ ...about(pid), why: 'it runs as another user' });
81
+ kept.set(pid, { session: owner.get(pid), pid, started: table.get(pid).started });
82
+ } else targets.set(pid, about(pid));
83
+ }
84
+
85
+ // A group is signalled whole only where every process in it is the
86
+ // session's own. The others in a group with the session's processes are
87
+ // named, and left as they are.
88
+ const theirs = new Set([...targets.keys()].map((pid) => table.get(pid).pgid));
89
+ const groups = new Map();
90
+ for (const [pid, { pgid }] of table) if (theirs.has(pgid)) groups.set(pgid, [...(groups.get(pgid) ?? []), pid]);
91
+ const whole = new Set();
92
+ for (const [pgid, members] of groups) {
93
+ const strangers = members.filter((pid) => !targets.has(pid));
94
+ if (pgid > 1 && strangers.length === 0) whole.add(pgid);
95
+ for (const pid of strangers) {
96
+ if (owner.has(pid) || mine.has(pid)) continue;
97
+ const session = owner.get(members.find((one) => targets.has(one)));
98
+ left.push({ ...about(pid), session, why: `it shares process group ${pgid} with ${session}'s processes, and the kit cannot show it is ${session}'s own` });
99
+ }
100
+ }
101
+ if (targets.size === 0) return { stopped: [], left };
102
+
103
+ // Each process the kit signalled, by pid, with when it started: a pid that
104
+ // later has another start time is another process, and the one signalled
105
+ // has ended. A process can change its own command line; it cannot change
106
+ // when it started.
107
+ const tracked = new Map();
108
+ const refused = new Map();
109
+ const ended = new Set();
110
+ const begun = Date.now();
111
+ /** What is left of the stop, for a read or a signal to take. */
112
+ const budget = () => begun + STOP_MS - Date.now();
113
+ // Every signal is given only what is left of the stop: none once it is
114
+ // spent, and a `kill` that does not return within it leaves the kit unable
115
+ // to say whether that signal went, which the answer says (review of PR #543).
116
+ const send = (name, target) => (budget() <= 0 ? { timeUp: true } : signal(name, target, budget()));
117
+ // Each signal is timed once its `kill` has returned, so a slow one does not
118
+ // eat into the wait it starts.
119
+ const note = (one, sent, name) => {
120
+ if (sent.timeUp) refused.set(one.pid, { ...one, why: `the stop's time ran out before its ${name}, so the kit sent it nothing more` });
121
+ else if (sent.refused !== undefined) refused.set(one.pid, { ...one, why: `kill refused ${name}: ${sent.refused}` });
122
+ else {
123
+ refused.delete(one.pid);
124
+ // `late`: its `kill` was cut off, so whether it went is not known.
125
+ tracked.set(one.pid, { ...one, signal: name, at: Date.now(), late: sent.late === true });
126
+ }
127
+ };
128
+ const first = (pid, one) => ({ ...one, pgid: table.get(pid).pgid, started: table.get(pid).started });
129
+ for (const pgid of whole) {
130
+ const sent = send('TERM', -pgid);
131
+ for (const [pid, one] of targets) if (table.get(pid).pgid === pgid) note(first(pid, one), sent, 'SIGTERM');
132
+ }
133
+ for (const [pid, one] of targets) if (!whole.has(table.get(pid).pgid)) note(first(pid, one), send('TERM', pid), 'SIGTERM');
134
+
135
+ // Each process has TERM_WAIT_MS after its own SIGTERM to end, then SIGKILL,
136
+ // then TERM_WAIT_MS more to be gone. A process a read shows one of them
137
+ // started meanwhile is theirs as much as they are (R1): it is tracked from
138
+ // that read, even after its parent has gone, and is given the same, and so
139
+ // is any new process whose working folder is in the work dir, so each read
140
+ // reads the working folders too; one found after ADOPT_MS, or run by another
141
+ // user, is named and sent nothing. One that only joined a group signalled
142
+ // whole, and works elsewhere, is not shown to be theirs, and is named and
143
+ // never signalled, whoever runs it (R2, review of PR #543).
144
+ // All of it ends by STOP_MS: each read is given only what is left of it, and
145
+ // the clock is asked again after each read and before each signal. Where
146
+ // the table or the working folders can no longer be read, nothing more is
147
+ // sent: the kit cannot see which pid is still theirs. A process a uid other
148
+ // than the user's now runs is sent nothing more either.
149
+ const named = new Set(left.map((one) => one.pid));
150
+ let lost;
151
+ // When the last read that worked was asked: it says nothing of a signal sent after it.
152
+ let lastRead;
153
+ for (;;) {
154
+ const asked = Date.now();
155
+ if (budget() <= 0) break;
156
+ const read = processTable(budget());
157
+ if (read.unreadable !== undefined) {
158
+ lost = read.unreadable;
159
+ break;
160
+ }
161
+ lastRead = asked;
162
+ const now = read.table;
163
+ const live = (one) => !ended.has(one.pid) && now.get(one.pid)?.started === one.started;
164
+ for (const one of tracked.values()) if (!live(one)) ended.add(one.pid);
165
+
166
+ // Every read looks at what is new, whatever the time: what it may still
167
+ // signal waits on the deadline, what it has to name does not.
168
+ // The retire's own run as it is now: the `ps` and `lsof` it starts for
169
+ // each read work where it works, which can be the work dir.
170
+ const ours = ownRun(now);
171
+ const fresh = [...now].filter(([pid]) => !tracked.has(pid) && !refused.has(pid) && !named.has(pid) && !mine.has(pid) && !ours.has(pid));
172
+ // A process whose working folder is in a work dir is that session's
173
+ // own by R1, whatever its group and its parent (review of PR #543).
174
+ let folders = new Map();
175
+ if (fresh.length > 0) {
176
+ const folderRead = workingFolders(budget());
177
+ if (folderRead.unreadable !== undefined) {
178
+ lost = folderRead.unreadable;
179
+ break;
180
+ }
181
+ folders = folderRead.cwds;
182
+ }
183
+ const name = (session, one, why) => {
184
+ named.add(one.pid);
185
+ left.push({ session, ...one, why });
186
+ };
187
+ // One the session owns but the kit does not signal stays a parent for what it starts.
188
+ const keep = (session, one, why, started) => {
189
+ name(session, one, why);
190
+ kept.set(one.pid, { session, pid: one.pid, started });
191
+ };
192
+ for (const [pid, seen] of fresh) {
193
+ const one = { pid, cwd: folders.get(pid) ?? null, command: seen.command };
194
+ const parent = [...ancestorsOf(now, pid)].map((it) => tracked.get(it) ?? kept.get(it)).find((it) => it !== undefined && live(it));
195
+ const home = one.cwd === null ? undefined : where.find(({ real }) => inside(one.cwd, real));
196
+ const theirs = parent ?? home;
197
+ if (theirs !== undefined) {
198
+ if (seen.uid !== uid) keep(theirs.session, one, 'it runs as another user, so the kit sends it nothing', seen.started);
199
+ else if (Date.now() - begun >= ADOPT_MS) keep(theirs.session, one, 'it was found after the time for new processes ran out, so the kit sent it nothing', seen.started);
200
+ else note({ session: theirs.session, ...one, pgid: seen.pgid, started: seen.started }, send('TERM', pid), 'SIGTERM');
201
+ } else if (whole.has(seen.pgid)) {
202
+ const mate = [...tracked.values()].find((it) => it.pgid === seen.pgid);
203
+ name(mate.session, one, `it joined process group ${seen.pgid} of ${mate.session}'s processes, and the kit cannot show it is ${mate.session}'s own`);
204
+ }
205
+ }
206
+
207
+ const running = [...tracked.values()].filter((one) => !refused.has(one.pid) && live(one));
208
+ for (const one of running) {
209
+ if (one.signal !== 'SIGTERM' || Date.now() - one.at < TERM_WAIT_MS) continue;
210
+ const seen = now.get(one.pid);
211
+ if (seen.uid !== uid) refused.set(one.pid, { ...one, why: 'it runs as another user now, so the kit sends it no SIGKILL' });
212
+ else note({ ...one, command: seen.command }, send('KILL', one.pid), 'SIGKILL');
213
+ }
214
+ const waiting = running.filter((one) => !refused.has(one.pid) && Date.now() - tracked.get(one.pid).at < TERM_WAIT_MS);
215
+ if (running.length === 0 || waiting.length === 0 || budget() <= 0) break;
216
+ await delay(Math.max(0, Math.min(POLL_MS, budget())));
217
+ }
218
+
219
+ const stopped = [];
220
+ for (const one of tracked.values()) {
221
+ if (refused.has(one.pid)) continue;
222
+ if (ended.has(one.pid)) stopped.push(shown(one));
223
+ else {
224
+ let why = `it still runs after ${one.signal}`;
225
+ if (lost !== undefined) why = `the kit could not read the processes again after ${one.signal}, so it sent nothing more: ${lost}`;
226
+ else if (lastRead === undefined || lastRead < one.at) why = `the kit could not confirm that its ${one.signal} went and that it ended: the stop's time ran out before a read after it`;
227
+ // A cut-off kill is said whatever else is: both are true (review of PR #543).
228
+ if (one.late) why = `the kit could not confirm that its ${one.signal} went, since kill did not answer in time; ${why}`;
229
+ left.push(shown({ ...one, why }));
230
+ }
231
+ }
232
+ return { stopped, left: [...left, ...[...refused.values()].map(shown)] };
233
+ }
234
+
235
+ /** A process as the answer gives it, without what the kit kept to know it again. */
236
+ const shown = ({ pgid: _, started: __, at: ___, late: ____, signal, why, ...one }) => (why === undefined ? { ...one, signal } : { ...one, why });
237
+
238
+ /**
239
+ * The processes that run in the work dirs in `dirs`, a list of `{ session, dir
240
+ * }`, read only: `{ running: [{ session, dir, pid, command }] }`, or `{
241
+ * unreadable: <why> }`. Only the ones whose working folder is there, and never
242
+ * this run or one that started it.
243
+ */
244
+ export function processesIn(dirs) {
245
+ const read = readProcesses();
246
+ if (read.unreadable !== undefined) return { unreadable: read.unreadable };
247
+ const mine = ownRun(read.table);
248
+ const where = realDirs(dirs);
249
+ const running = [];
250
+ for (const [pid, cwd] of read.cwds) {
251
+ const found = where.find(({ real }) => inside(cwd, real));
252
+ if (found === undefined || mine.has(pid) || !read.table.has(pid)) continue;
253
+ running.push({ session: found.session, dir: found.dir, pid, command: read.table.get(pid).command });
254
+ }
255
+ return { running };
256
+ }
257
+
258
+ /**
259
+ * Every process `ps` lists, `table`: pid to `{ ppid, pgid, uid, command }`, a
260
+ * zombie left out as one that has ended; and `cwds`: pid to the working folder
261
+ * `lsof` gives, for the user's own processes. Or `{ unreadable: <why> }`.
262
+ */
263
+ function readProcesses() {
264
+ const listed = processTable();
265
+ if (listed.unreadable !== undefined) return listed;
266
+ const folders = workingFolders();
267
+ return folders.unreadable !== undefined ? folders : { table: listed.table, cwds: folders.cwds };
268
+ }
269
+
270
+ /**
271
+ * The working folder of each of the user's processes, `cwds`: pid to folder,
272
+ * as `lsof` gives it. Or `{ unreadable: <why> }`. `ms` bounds the read below
273
+ * READ_MS.
274
+ */
275
+ function workingFolders(ms = READ_MS) {
276
+ const asked = spawnSync(lsofCli(), ['-a', '-d', 'cwd', '-u', String(process.getuid()), '-Fpn'], { encoding: 'utf8', timeout: bound(ms), maxBuffer: 64 * 1024 * 1024 });
277
+ if (asked.error?.code === 'ETIMEDOUT') return { unreadable: 'lsof did not answer in time' };
278
+ if (asked.error || asked.status !== 0) return { unreadable: `lsof could not list the processes' working folders${because(asked)}` };
279
+ const cwds = new Map();
280
+ let pid;
281
+ for (const line of asked.stdout.split('\n')) {
282
+ if (line.startsWith('p')) pid = Number(line.slice(1));
283
+ else if (line.startsWith('n') && Number.isInteger(pid)) cwds.set(pid, line.slice(1));
284
+ }
285
+ return { cwds };
286
+ }
287
+
288
+ /**
289
+ * The process table alone, as `readProcesses` gives it, with `started`, when
290
+ * each process started as `ps` gives it, which with its pid is what tells one
291
+ * process from another: a pid can be taken again, and a process can change its
292
+ * own command line (review of PR #543). Or `{ unreadable: <why> }`.
293
+ */
294
+ function processTable(ms = READ_MS) {
295
+ const asked = spawnSync(psCli(), ['-A', '-ww', '-o', 'pid=,ppid=,pgid=,uid=,stat=,lstart=,command='], { encoding: 'utf8', timeout: bound(ms), maxBuffer: 64 * 1024 * 1024 });
296
+ if (asked.error?.code === 'ETIMEDOUT') return { unreadable: 'ps did not answer in time' };
297
+ if (asked.error || asked.status !== 0) return { unreadable: `ps could not read the process table${because(asked)}` };
298
+ const table = new Map();
299
+ for (const line of asked.stdout.split('\n')) {
300
+ // lstart is five words: `Sat Oct 10 14:40:01 2026`.
301
+ const read = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+\s+\S+\s+\d+\s+\d+:\d+:\d+\s+\d+)(?:\s+(.*?))?\s*$/.exec(line);
302
+ if (read === null || read[5].startsWith('Z')) continue;
303
+ table.set(Number(read[1]), { ppid: Number(read[2]), pgid: Number(read[3]), uid: Number(read[4]), started: read[6].replace(/\s+/g, ' '), command: read[7] ?? '' });
304
+ }
305
+ if (table.size === 0) return { unreadable: 'ps listed no processes' };
306
+ return { table };
307
+ }
308
+
309
+ /** A read's time limit: `ms`, but never more than READ_MS, and at least 1. */
310
+ const bound = (ms) => Math.max(1, Math.min(READ_MS, Math.floor(ms)));
311
+
312
+ /** What a failed call said for itself, as the end of a sentence. */
313
+ function because(asked) {
314
+ const said = (asked.error?.message ?? asked.stderr ?? '').trim().split('\n')[0];
315
+ return said === '' ? '' : ` (${said})`;
316
+ }
317
+
318
+ /** The session each of the processes in `dirs` belongs to: pid to session, by working folder and then by descent. */
319
+ function ownersIn(table, cwds, dirs) {
320
+ const where = realDirs(dirs);
321
+ const owner = new Map();
322
+ for (const [pid, cwd] of cwds) {
323
+ const found = where.find(({ real }) => inside(cwd, real));
324
+ if (found !== undefined && table.has(pid)) owner.set(pid, found.session);
325
+ }
326
+ for (const pid of descendantsOf(table, [...owner.keys()])) {
327
+ if (owner.has(pid)) continue;
328
+ owner.set(pid, owner.get([...ancestorsOf(table, pid)].find((one) => owner.has(one))));
329
+ }
330
+ return owner;
331
+ }
332
+
333
+ /** This run, every process that started it, and every process it started: never stopped. */
334
+ function ownRun(table) {
335
+ return new Set([process.pid, ...ancestorsOf(table, process.pid), ...descendantsOf(table, [process.pid])]);
336
+ }
337
+
338
+ /** The parent of `pid`, its parent's parent and on up, nearest first. */
339
+ function* ancestorsOf(table, pid) {
340
+ const seen = new Set([pid]);
341
+ for (let at = table.get(pid)?.ppid; at !== undefined && at > 0 && !seen.has(at); at = table.get(at)?.ppid) {
342
+ seen.add(at);
343
+ yield at;
344
+ }
345
+ }
346
+
347
+ /** Every process below the ones in `pids`, at any depth. */
348
+ function descendantsOf(table, pids) {
349
+ const children = new Map();
350
+ for (const [pid, { ppid }] of table) children.set(ppid, [...(children.get(ppid) ?? []), pid]);
351
+ const found = new Set();
352
+ const queue = [...pids];
353
+ while (queue.length > 0) {
354
+ for (const child of children.get(queue.shift()) ?? []) {
355
+ if (found.has(child) || pids.includes(child)) continue;
356
+ found.add(child);
357
+ queue.push(child);
358
+ }
359
+ }
360
+ return [...found];
361
+ }
362
+
363
+ /** The work dirs in `dirs` with their real paths, as `lsof` gives a working folder; one that is not there holds nothing. */
364
+ function realDirs(dirs) {
365
+ return dirs.flatMap((one) => {
366
+ try {
367
+ return [{ ...one, real: realpathSync(one.dir) }];
368
+ } catch {
369
+ return [];
370
+ }
371
+ });
372
+ }
373
+
374
+ /** Whether the folder `cwd` is `dir` or inside it. */
375
+ const inside = (cwd, dir) => cwd === dir || cwd.startsWith(`${dir}${path.sep}`);
376
+
377
+ /**
378
+ * Send `SIG<name>` to `target`, a pid, or a process group as its negative,
379
+ * within `ms`. `{}` when it was sent or the process was already gone, `{
380
+ * late: true }` when `kill` did not return in time, and `{ refused: <why> }`
381
+ * otherwise. Never to pid 1 or below, or to group 1 or 0: those are every
382
+ * process of the user's (AGENTS.md, 2026-09-20).
383
+ */
384
+ function signal(name, target, ms = READ_MS) {
385
+ if (!Number.isInteger(target) || Math.abs(target) <= 1) return { refused: `the kit does not signal ${target}` };
386
+ const sent = spawnSync(killCli(), ['-s', name, '--', String(target)], { encoding: 'utf8', timeout: bound(ms) });
387
+ // Cut off at its time: whether the signal went is not known.
388
+ if (sent.error?.code === 'ETIMEDOUT') return { late: true };
389
+ if (!sent.error && sent.status === 0) return {};
390
+ if (/No such process/i.test(sent.stderr ?? '')) return {};
391
+ return { refused: (sent.error?.message ?? sent.stderr ?? '').trim() || `kill exited ${sent.status}` };
392
+ }
package/src/retire.js CHANGED
@@ -17,9 +17,10 @@
17
17
  //
18
18
  // Closing a tab here ends the conversation in it on purpose, so unlike a
19
19
  // restart or a pause it does not wait for the book to know which one it was.
20
- // The mailbox Runs stay: Orca has no way to remove one (ADR 0035). What the
21
- // kit knows of mail sent to a session and not read with `obk message check`
22
- // is said, with who sent it, so it is not lost without a word (#509).
20
+ // The mailbox Runs stay: Orca has no way to remove one (ADR 0042).
21
+ //
22
+ // What a session started in its work dir and left running is stopped once its
23
+ // tab is closed, and named (#537): see processes.js.
23
24
 
24
25
  import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
25
26
  import path from 'node:path';
@@ -27,12 +28,12 @@ import path from 'node:path';
27
28
  import { readBook, tabIdsIn, updateBook } from './book.js';
28
29
  import { botDir, dropSession, readBot } from './bot.js';
29
30
  import { deleteProject, findProject, projects, reloadWindow, tabs } from './orca.js';
30
- import { shellWord } from './launch.js';
31
+ import { shellWord, workDirOf } from './launch.js';
31
32
  import { fleetMember } from './pause.js';
33
+ import { stopProcesses } from './processes.js';
32
34
  import { closeTabs, commandLine, tabsToClose } from './restart.js';
33
35
  import { unlinkSkills } from './skills.js';
34
- import { takeUnread } from './unread.js';
35
- import { promptPath, sessionsOf } from './up.js';
36
+ import { oldPromptPath, promptPath, sessionsOf } from './up.js';
36
37
 
37
38
  /** Where retired bots go: beside `bots/`, where nothing the kit runs looks. */
38
39
  export const retiredDir = (bots) => path.join(bots, 'retired');
@@ -41,9 +42,10 @@ export const retiredDir = (bots) => path.join(bots, 'retired');
41
42
  * Retire the session `session` of the bot `bot`, and first the temporary
42
43
  * sessions it made, and theirs (#464, ADR 0033). Returns `{ bot, session,
43
44
  * closed, retiredWith }`, and `promptsLeft` when its prompt file could not be
44
- * removed, and `unread: { count, from }` when the kit knows of mail sent to it
45
- * that it did not read with `obk message check` (#509). `retiredWith` holds one `{ bot, session, maker, closed }` for each
46
- * session that went with it, deepest first, with its own `promptsLeft`.
45
+ * removed, and `processes` when it has a work dir: what `stopProcesses`
46
+ * stopped and left of what it started there (#537). `retiredWith` holds one
47
+ * `{ bot, session, maker, closed }` for each session that went with it,
48
+ * deepest first, with its own `promptsLeft` and `processes`.
47
49
  */
48
50
  export async function retireSession(bots, { bot, session }) {
49
51
  const home = fleetMember(bots, bot, 'retire', session);
@@ -54,6 +56,7 @@ export async function retireSession(bots, { bot, session }) {
54
56
  const book = readBook(home);
55
57
  const made = known.sessions.filter((one) => book.sessions[one.name]?.temporary?.maker === session);
56
58
  let closed;
59
+ let processes;
57
60
  try {
58
61
  for (const { name } of made) {
59
62
  const { retiredWith: theirs, ...gone } = await retireSession(bots, { bot, session: name });
@@ -61,6 +64,9 @@ export async function retireSession(bots, { bot, session }) {
61
64
  }
62
65
 
63
66
  closed = await closeTabs(home, tabsToClose(bots, bot, home, sessions, { keepless: true }), bots, bot, commandLine('retire', bots, bot, session));
67
+ // Only once its tab is closed: its harness starts nothing more.
68
+ const dir = workDirOf(sessions[0], home);
69
+ if (dir !== undefined) processes = await stopProcesses([{ session, dir }]);
64
70
  dropSession(bots, bot, session);
65
71
 
66
72
  const at = new Date().toISOString();
@@ -68,7 +74,8 @@ export async function retireSession(bots, { bot, session }) {
68
74
  const entry = book.sessions[session];
69
75
  if (entry === undefined) return;
70
76
  delete book.sessions[session];
71
- book.retired = [...(Array.isArray(book.retired) ? book.retired : []), { name: session, ...entry, retired: at }];
77
+ // Its work dir, so that `obk health` can look there for what still runs (#537).
78
+ book.retired = [...(Array.isArray(book.retired) ? book.retired : []), { name: session, ...entry, ...(dir === undefined ? {} : { work_dir: sessions[0].work_dir }), retired: at }];
72
79
  });
73
80
  } catch (error) {
74
81
  // What already went stays gone, and is named: a retire run again finds
@@ -76,10 +83,9 @@ export async function retireSession(bots, { bot, session }) {
76
83
  if (retiredWith.length > 0) error.message = `${error.message} ${goneBefore(session, retiredWith)}`;
77
84
  throw error;
78
85
  }
79
- const left = removePrompts([promptPath(bots, bot, session)]);
80
- const unread = takeUnread(home, session);
86
+ const left = removePrompts(promptFiles(bots, bot, session));
81
87
 
82
- return { bot, session, closed, retiredWith, ...left, ...(unread === undefined ? {} : { unread }) };
88
+ return { bot, session, closed, retiredWith, ...left, ...(processes === undefined ? {} : { processes }) };
83
89
  }
84
90
 
85
91
  /**
@@ -90,31 +96,24 @@ function goneBefore(session, retiredWith) {
90
96
  const names = retiredWith.map((gone) => `${gone.bot}/${gone.session}, a temporary session of ${gone.maker}'s`);
91
97
  const left = retiredWith.flatMap((gone) => gone.promptsLeft ?? [])
92
98
  .map(({ file, reason }) => ` ${file} could not be removed (${reason}), and nothing reads it. Remove it with rm ${shellWord(file)}`);
93
- // Their hint of unread mail went with them, so this is the only place it is
94
- // said (#509 review).
95
- const unread = retiredWith.flatMap((gone) => (gone.unread === undefined ? [] : [` ${unreadWords(gone.unread, `${gone.bot}/${gone.session}`)}`]));
96
- return `Retired along with ${session} before that, and still retired: ${names.join('; ')}.${unread.join('')}${left.join('')}`;
99
+ // What they left running is said here too, as the answer would have (#537).
100
+ const processes = retiredWith.flatMap((gone) => [
101
+ ...(gone.processes?.stopped ?? []).map((one) => ` Stopped pid ${one.pid} of ${gone.bot}/${gone.session} with ${one.signal}: ${one.command}.`),
102
+ ...(gone.processes?.left ?? []).map((one) => ` Not stopped, pid ${one.pid} in ${one.cwd ?? 'a folder lsof did not name'}: ${one.command} (${one.why}).`),
103
+ ...(gone.processes?.unreadable === undefined ? [] : [` The kit cannot tell what ${gone.bot}/${gone.session} left running, and stopped nothing: ${gone.processes.unreadable}.`]),
104
+ ]);
105
+ return `Retired along with ${session} before that, and still retired: ${names.join('; ')}.${processes.join('')}${left.join('')}`;
97
106
  }
98
107
 
99
- /**
100
- * The mail a retired session did not read, as far as the kit knows (#509): the
101
- * kit cannot see a read made with Orca's own check, so it says only this.
102
- */
103
- export const unreadWords = (unread, who) =>
104
- `${unread.count} ${unread.count === 1 ? 'message' : 'messages'} sent to ${who} ${unread.count === 1 ? 'was' : 'were'} not read with obk message check, from ${andList(unread.from)}.`;
105
-
106
- /** `a`, `a and b`, `a, b and c`. */
107
- const andList = (items) => (items.length <= 1 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`);
108
-
109
108
  /**
110
109
  * Retire the bot `bot`. Returns `{ bot, closed, project, windowReloaded, moved }`:
111
110
  * the tabs it closed, the Orca project it took away (if it had one), whether
112
111
  * Orca's window was reloaded after that, and where the bot is now; and
113
- * `promptsLeft` when a prompt file could not be removed, and `unread: [{
114
- * session, count, from }]` for its sessions with mail the kit knows was not
115
- * read with `obk message check` (#509).
112
+ * `promptsLeft` when a prompt file could not be removed, and `processes` when
113
+ * one of its sessions has a work dir: what `stopProcesses` stopped and left
114
+ * there (#537).
116
115
  * When Orca does not confirm the project gone, it returns `{ bot, closed,
117
- * project, trouble }` instead, and the bot is left where it was.
116
+ * project, processes, trouble }` instead, and the bot is left where it was.
118
117
  */
119
118
  export async function retireBot(bots, { bot }) {
120
119
  const home = fleetMember(bots, bot, 'retire');
@@ -139,6 +138,12 @@ export async function retireBot(bots, { bot }) {
139
138
  // they are gone.
140
139
  const booked = Object.keys(readBook(home).sessions).map((name) => ({ name }));
141
140
  const closed = await closeTabs(home, tabsToClose(bots, bot, home, booked, { keepless: true }), bots, bot, commandLine('retire', bots, bot));
141
+ // Once their tabs are closed, and before the folder moves (#537).
142
+ const dirs = known.sessions.flatMap((session) => {
143
+ const dir = workDirOf(session, home);
144
+ return dir === undefined ? [] : [{ session: session.name, dir }];
145
+ });
146
+ const processes = dirs.length === 0 ? undefined : await stopProcesses(dirs);
142
147
  let windowReloaded;
143
148
  if (project !== undefined) {
144
149
  deleteProject(project.id);
@@ -150,10 +155,10 @@ export async function retireBot(bots, { bot }) {
150
155
  try {
151
156
  setups = projects();
152
157
  } catch (error) {
153
- return { bot, closed, project: project.id, trouble: `Orca answered the delete of Orca project ${project.id}, and its project list could not be read afterwards, so the removal is not confirmed: ${error.message}. ${bot} was not moved. Once Orca is answering, retire ${bot} again with obk retire: it removes the project if it is still there, then finishes.` };
158
+ return { bot, closed, project: project.id, ...(processes === undefined ? {} : { processes }), trouble: `Orca answered the delete of Orca project ${project.id}, and its project list could not be read afterwards, so the removal is not confirmed: ${error.message}. ${bot} was not moved. Once Orca is answering, retire ${bot} again with obk retire: it removes the project if it is still there, then finishes.` };
154
159
  }
155
160
  if (setups.some((setup) => setup.id === project.id || setup.path === home)) {
156
- return { bot, closed, project: project.id, trouble: `Orca answered the delete of Orca project ${project.id}, and still lists it. ${bot} was not moved. Retire ${bot} again with obk retire; if Orca still lists the project after that, remove it in Orca yourself, then retire ${bot} again.` };
161
+ return { bot, closed, project: project.id, ...(processes === undefined ? {} : { processes }), trouble: `Orca answered the delete of Orca project ${project.id}, and still lists it. ${bot} was not moved. Retire ${bot} again with obk retire; if Orca still lists the project after that, remove it in Orca yourself, then retire ${bot} again.` };
157
162
  }
158
163
  // Only now: Orca's window keeps a removed project in its sidebar until
159
164
  // it is rebuilt (#343).
@@ -161,19 +166,17 @@ export async function retireBot(bots, { bot }) {
161
166
  }
162
167
 
163
168
  const names = new Set([...known.sessions, ...booked].map((session) => session.name));
164
- const left = removePrompts([...names].map((name) => promptPath(bots, bot, name)));
165
- // Before the folder moves: the hint is filed under the bot home's real path.
166
- const unread = [...names].flatMap((name) => {
167
- const taken = takeUnread(home, name);
168
- return taken === undefined ? [] : [{ session: name, ...taken }];
169
- });
169
+ const left = removePrompts([...names].flatMap((name) => promptFiles(bots, bot, name)));
170
170
  unlinkSkills(home);
171
171
  mkdirSync(retiredDir(bots), { recursive: true });
172
172
  renameSync(botDir(bots, bot), moved);
173
173
 
174
- return { bot, closed, ...(project === undefined ? {} : { project: project.id, windowReloaded }), moved, ...left, ...(unread.length === 0 ? {} : { unread }) };
174
+ return { bot, closed, ...(project === undefined ? {} : { project: project.id, windowReloaded }), moved, ...left, ...(processes === undefined ? {} : { processes }) };
175
175
  }
176
176
 
177
+ /** A session's start-prompt files: where the kit writes it, and where it did before #534. */
178
+ const promptFiles = (bots, bot, session) => [promptPath(bots, bot, session), oldPromptPath(bots, bot, session)];
179
+
177
180
  /**
178
181
  * Remove start-prompt files, and say which could not be: `{ promptsLeft: [{
179
182
  * file, reason }] }`, or nothing when every one went. One that is not there is
package/src/skills.js CHANGED
@@ -438,7 +438,7 @@ const LOOK_MS = 2000;
438
438
  * `blocked`) or `unknown` (with `trouble`).
439
439
  *
440
440
  * Claude Code gets `/reload-skills` typed into its tab, through the same gate
441
- * as the mail nudge. A busy session queues it and runs it as a command when
441
+ * as the mail interrupt. A busy session queues it and runs it as a command when
442
442
  * its turn ends, so it never reaches the model as text. Codex has no reload
443
443
  * command and takes the change at the start of its next turn by itself, so
444
444
  * nothing is typed, and the caller is given the SKILL.md of each skill linked