@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.
- package/README.md +30 -30
- package/package.json +1 -1
- package/rules/mail.md +5 -5
- package/skills/obk-bot-building/SKILL.md +5 -2
- package/src/book.js +55 -16
- package/src/bot.js +2 -2
- package/src/cli.js +53 -110
- package/src/commands.js +17 -9
- package/src/conversations.js +0 -37
- package/src/health.js +63 -6
- package/src/hooks.js +35 -63
- package/src/launch.js +21 -4
- package/src/message.js +42 -334
- package/src/name.js +1 -1
- package/src/orca.js +19 -35
- package/src/permissions.js +22 -6
- package/src/processes.js +392 -0
- package/src/retire.js +43 -40
- package/src/skills.js +1 -1
- package/src/temp.js +1 -1
- package/src/up.js +13 -2
- package/src/unread.js +0 -160
package/src/permissions.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
package/src/processes.js
ADDED
|
@@ -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
|
|
21
|
-
//
|
|
22
|
-
//
|
|
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 {
|
|
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 `
|
|
45
|
-
*
|
|
46
|
-
* session
|
|
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
|
-
|
|
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(
|
|
80
|
-
const unread = takeUnread(home, session);
|
|
86
|
+
const left = removePrompts(promptFiles(bots, bot, session));
|
|
81
87
|
|
|
82
|
-
return { bot, session, closed, retiredWith, ...left, ...(
|
|
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
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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 `
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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].
|
|
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, ...(
|
|
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
|
|
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
|