@ravefox/space 0.2.1 → 0.4.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.
Files changed (167) hide show
  1. package/LICENSE +21 -205
  2. package/README.md +104 -31
  3. package/bin/space.js +113 -0
  4. package/lib/agents.js +181 -0
  5. package/lib/boardTools.js +89 -0
  6. package/lib/credentials.js +95 -0
  7. package/lib/daemon.js +294 -0
  8. package/lib/harness.js +166 -0
  9. package/lib/http.js +101 -0
  10. package/lib/login.js +68 -0
  11. package/lib/mcp.js +39 -0
  12. package/lib/runtime.js +253 -0
  13. package/lib/store.js +96 -0
  14. package/lib/tools.js +93 -0
  15. package/lib/workspaceSandbox.js +77 -0
  16. package/lib/workspaceTools.js +160 -0
  17. package/lib/workspaceWorker.js +17 -0
  18. package/package.json +27 -46
  19. package/NOTICE +0 -55
  20. package/assets/webhook-sdk/python/bot.py +0 -63
  21. package/assets/webhook-sdk/python/commonly.py +0 -232
  22. package/node_modules/@commonlyai/seats/README.md +0 -25
  23. package/node_modules/@commonlyai/seats/package.json +0 -22
  24. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
  25. package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
  26. package/node_modules/@commonlyai/seats/src/contract.js +0 -48
  27. package/node_modules/@commonlyai/seats/src/failures.js +0 -44
  28. package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
  29. package/node_modules/@commonlyai/seats/src/index.js +0 -22
  30. package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
  31. package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
  32. package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
  33. package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
  34. package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
  35. package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
  36. package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
  37. package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
  38. package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
  39. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
  40. package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
  41. package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
  42. package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
  43. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
  44. package/skills/commonly/SKILL.md +0 -233
  45. package/src/commands/agent.js +0 -6334
  46. package/src/commands/daemon.js +0 -933
  47. package/src/commands/dev.js +0 -153
  48. package/src/commands/doctor.js +0 -141
  49. package/src/commands/inbox.js +0 -330
  50. package/src/commands/login.js +0 -194
  51. package/src/commands/mcp.js +0 -56
  52. package/src/commands/pod.js +0 -120
  53. package/src/commands/task.js +0 -279
  54. package/src/commands/verify.js +0 -48
  55. package/src/index.js +0 -99
  56. package/src/lib/adapter-login.js +0 -76
  57. package/src/lib/adapters/broker-bridge-entry.js +0 -76
  58. package/src/lib/adapters/claude.js +0 -2207
  59. package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
  60. package/src/lib/adapters/codex.js +0 -1898
  61. package/src/lib/adapters/dsh.js +0 -513
  62. package/src/lib/adapters/goose.js +0 -510
  63. package/src/lib/adapters/hermes.js +0 -18
  64. package/src/lib/adapters/index.js +0 -68
  65. package/src/lib/adapters/opencode.js +0 -1681
  66. package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
  67. package/src/lib/adapters/pi-mcp-client.mjs +0 -617
  68. package/src/lib/adapters/pi-shell-env.mjs +0 -41
  69. package/src/lib/adapters/pi.js +0 -1025
  70. package/src/lib/adapters/qwen.js +0 -523
  71. package/src/lib/adapters/seat-model.js +0 -8
  72. package/src/lib/adapters/stub.js +0 -1
  73. package/src/lib/adapters/tested-version.js +0 -106
  74. package/src/lib/api.js +0 -205
  75. package/src/lib/claim-outcome.js +0 -128
  76. package/src/lib/claimed-work-gate.js +0 -73
  77. package/src/lib/cli-hint.js +0 -33
  78. package/src/lib/commit-attribution.js +0 -391
  79. package/src/lib/config.js +0 -155
  80. package/src/lib/content-refusal.js +0 -94
  81. package/src/lib/credential-file.js +0 -208
  82. package/src/lib/daemon-agent-states.js +0 -95
  83. package/src/lib/daemon-hint.js +0 -18
  84. package/src/lib/daemon-logs.js +0 -43
  85. package/src/lib/daemon-service-windows.js +0 -263
  86. package/src/lib/daemon-service.js +0 -486
  87. package/src/lib/daemon-state.js +0 -84
  88. package/src/lib/daemon-store.js +0 -119
  89. package/src/lib/daemon-supervisor.js +0 -629
  90. package/src/lib/daemon-watch.js +0 -115
  91. package/src/lib/declared-mcp-guard.js +0 -284
  92. package/src/lib/default-environment.js +0 -207
  93. package/src/lib/device-login.js +0 -126
  94. package/src/lib/digest-frame.js +0 -151
  95. package/src/lib/doctor.js +0 -291
  96. package/src/lib/egress-proxy.js +0 -160
  97. package/src/lib/enforcement.js +0 -946
  98. package/src/lib/environment.js +0 -681
  99. package/src/lib/first-output-watchdog.js +0 -191
  100. package/src/lib/git-readonly-server.js +0 -138
  101. package/src/lib/git-readonly.js +0 -463
  102. package/src/lib/git-remote-commonly.mjs +0 -100
  103. package/src/lib/grant-broker-guard.js +0 -345
  104. package/src/lib/hooks-config.js +0 -238
  105. package/src/lib/host-gh-broker.js +0 -152
  106. package/src/lib/host-gh-shim.mjs +0 -98
  107. package/src/lib/inbox.js +0 -483
  108. package/src/lib/instance-proxy.js +0 -76
  109. package/src/lib/machine-host.js +0 -29
  110. package/src/lib/mcp-credential-delivery.js +0 -121
  111. package/src/lib/mcp-first-run.js +0 -50
  112. package/src/lib/mcp-home.js +0 -644
  113. package/src/lib/mcp-ready.js +0 -68
  114. package/src/lib/mcp-server-version.js +0 -123
  115. package/src/lib/mcp-token.js +0 -36
  116. package/src/lib/mcp-warm-child.mjs +0 -412
  117. package/src/lib/memory-bridge.js +0 -191
  118. package/src/lib/memory-import.js +0 -156
  119. package/src/lib/missing-harness.js +0 -31
  120. package/src/lib/model-unavailable.js +0 -154
  121. package/src/lib/native-runtime-inventory.js +0 -40
  122. package/src/lib/outsider-grant-mcp.js +0 -82
  123. package/src/lib/own-key-launch.js +0 -42
  124. package/src/lib/pod-catch-up.js +0 -55
  125. package/src/lib/pod-focus.js +0 -224
  126. package/src/lib/pod-tail.js +0 -68
  127. package/src/lib/poll-liveness.js +0 -103
  128. package/src/lib/poll-retry.js +0 -81
  129. package/src/lib/poller.js +0 -200
  130. package/src/lib/provider-proxy.js +0 -42
  131. package/src/lib/public-seat-mcp-flag.js +0 -36
  132. package/src/lib/public-tool-error.js +0 -60
  133. package/src/lib/public-withheld-tools.js +0 -48
  134. package/src/lib/reply-receipt.js +0 -55
  135. package/src/lib/resolve-on-path.js +0 -22
  136. package/src/lib/runtime-inventory.js +0 -56
  137. package/src/lib/runtime-observation.js +0 -37
  138. package/src/lib/sandbox/bwrap.js +0 -275
  139. package/src/lib/sandbox/codex-perimeter.js +0 -16
  140. package/src/lib/sandbox/host-check.js +0 -268
  141. package/src/lib/sandbox/mode.js +0 -49
  142. package/src/lib/sandbox/opencode-loopback.mjs +0 -68
  143. package/src/lib/sandbox/seatbelt.js +0 -684
  144. package/src/lib/sandbox/turn-dir.js +0 -56
  145. package/src/lib/sandbox/windows-unsandboxed.js +0 -183
  146. package/src/lib/sandbox/workspace-git-config.js +0 -243
  147. package/src/lib/sandbox/workspace-pin.js +0 -158
  148. package/src/lib/seat-runtime-controls.js +0 -61
  149. package/src/lib/seat-stop.js +0 -97
  150. package/src/lib/server-skills.js +0 -32
  151. package/src/lib/session-recycle.js +0 -219
  152. package/src/lib/session-store.js +0 -215
  153. package/src/lib/skill-prompt.js +0 -74
  154. package/src/lib/skills-import.js +0 -113
  155. package/src/lib/spawn-credential.js +0 -402
  156. package/src/lib/spawn-retry.js +0 -311
  157. package/src/lib/stable-install.js +0 -176
  158. package/src/lib/supervised-turn.js +0 -352
  159. package/src/lib/task.js +0 -235
  160. package/src/lib/turn-failure-notice.js +0 -177
  161. package/src/lib/unconfined-seat-guard.js +0 -94
  162. package/src/lib/update-check.js +0 -192
  163. package/src/lib/upstream-refusal.js +0 -1
  164. package/src/lib/webhook-server.js +0 -109
  165. package/src/lib/windows-command.js +0 -393
  166. package/src/lib/workspace-git.js +0 -87
  167. package/src/lib/workspace-trust.js +0 -162
@@ -1,276 +0,0 @@
1
- /**
2
- * Append-only trace store on the local filesystem.
3
- *
4
- * <root>/runs/<runId>.jsonl the run record (docs/record-v1.md): one
5
- * canonical, hash-chained entry per line
6
- * <root>/index.jsonl one line per started run, one per settled run;
7
- * a store convenience, not part of the format
8
- *
9
- * Files are 0600 and directories 0700: a trace holds agent output.
10
- */
11
- import { randomUUID } from 'node:crypto';
12
- import { appendFileSync, mkdirSync, readFileSync, existsSync, writeFileSync } from 'node:fs';
13
- import { join } from 'node:path';
14
- import {
15
- appendEntry, hashLine, kindOfType, readRecord, traceTypeOf, validateActor, verifyRecord, BOARD_ACTOR,
16
- } from '../record/format.js';
17
-
18
- const RUN_ID = /^[A-Za-z0-9_-]{6,64}$/;
19
- const readJsonl = (path) => (existsSync(path)
20
- ? readFileSync(path, 'utf8').split('\n').filter(Boolean).flatMap((line) => {
21
- try { return [JSON.parse(line)]; } catch { return []; }
22
- })
23
- : []);
24
-
25
- /**
26
- * Known credential values never reach a line, even inside agent text. Every
27
- * registered value is replaced, whatever its length: the eight-character
28
- * floor in redactKnownCredentials belongs to the refusal-text reader, not to
29
- * the record. `secrets` is kept longest first, so a value that contains a
30
- * shorter one is replaced whole rather than in pieces.
31
- */
32
- const scrubText = (text, secrets) => {
33
- let out = text;
34
- for (const value of secrets) out = out.split(value).join('[redacted]');
35
- return out;
36
- };
37
-
38
- const scrub = (value, secrets) => {
39
- if (secrets.length === 0) return value;
40
- if (typeof value === 'string') return scrubText(value, secrets);
41
- if (Array.isArray(value)) return value.map((item) => scrub(item, secrets));
42
- if (value && typeof value === 'object') {
43
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, scrub(item, secrets)]));
44
- }
45
- return value;
46
- };
47
-
48
- /**
49
- * Streamed agent text arrives in deltas, so a value can be split across two or
50
- * more of them and match none on its own. Each stream (kind + actor) is
51
- * scrubbed over a rolling window instead: the held tail is joined to the next
52
- * delta, the join is scrubbed, and the longest suffix of the result that
53
- * could still begin a known value is held back again. What is written is the
54
- * rest.
55
- *
56
- * Why that is enough: an occurrence that started inside written text and ran
57
- * past its end would make the written part plus the held tail a proper prefix
58
- * of that value, a longer such suffix than the one held. So no value spans a
59
- * written boundary, and each written piece is itself scrubbed. The tail is
60
- * shorter than the longest value, and only as long as an actual prefix match.
61
- */
62
- const STREAMED_KINDS = new Set(['agent.message_chunk', 'agent.thought_chunk']);
63
- // The held text is written out before these, so it stays in its own attempt
64
- // and is never lost at settle, whichever path (ok, failure, cancel, kill) got there.
65
- const FLUSH_BEFORE = new Set(['attempt.started', 'attempt.settled', 'run.settled']);
66
-
67
- const heldLength = (text, secrets) => {
68
- const longest = secrets.length ? secrets[0].length : 0;
69
- for (let start = Math.max(0, text.length - longest + 1); start < text.length; start += 1) {
70
- const tail = text.slice(start);
71
- if (secrets.some((value) => value.length > tail.length && value.startsWith(tail))) return text.length - start;
72
- }
73
- return 0;
74
- };
75
-
76
- /** What readRun has always returned, plus the envelope: `type` is the trace name. */
77
- const asEvent = (entry) => (entry.kind ? { ...entry, type: traceTypeOf(entry.kind) } : entry);
78
-
79
- export const createTraceStore = ({ root, now = () => new Date() } = {}) => {
80
- if (!root) throw new Error('createTraceStore needs a root directory');
81
- const runsDir = join(root, 'runs');
82
- mkdirSync(runsDir, { recursive: true, mode: 0o700 });
83
- const append = (path, value) => appendFileSync(path, `${JSON.stringify(value)}\n`, { mode: 0o600 });
84
- const indexPath = join(root, 'index.jsonl');
85
-
86
- /**
87
- * startRun({ runId?, adapter?, label?, taskId?, podId?, actor? })
88
- * → { runId, emit, redact, revokeGrant, expireGrants, openGrants, settled }
89
- *
90
- * `taskId` is the board's task id verbatim and needs `podId`, since task
91
- * ids repeat across pods. `actor` is the run's own party, normally
92
- * {type: 'agent', id: <user id>, owner: <user id>}.
93
- */
94
- const startRun = (meta = {}) => {
95
- const runId = meta.runId || randomUUID();
96
- if (!RUN_ID.test(runId)) throw new Error('runId must be 6-64 characters of [A-Za-z0-9_-]');
97
- const taskId = meta.taskId || undefined;
98
- const podId = meta.podId || undefined;
99
- if (taskId && !podId) throw new Error('a taskId needs a podId: task ids repeat across pods');
100
- const runActor = meta.actor === undefined ? null : validateActor(meta.actor);
101
- const path = join(runsDir, `${runId}.jsonl`);
102
- try {
103
- writeFileSync(path, '', { mode: 0o600, flag: 'wx' });
104
- } catch (error) {
105
- if (error.code === 'EEXIST') throw new Error(`run ${runId} already exists`);
106
- throw error;
107
- }
108
- let last = null; // the previous line, which the next one hashes
109
- let seq = 0;
110
- let settled = false;
111
- let broken = false;
112
- const secrets = []; // longest first; see scrubText
113
- const streams = new Map(); // stream key → { kind, actor, held }
114
- const grants = new Map(); // open grants: grantId → { parentGrantId, binding, expiresAt }
115
- const startedAt = now();
116
-
117
- const writeLine = (kind, data, actor) => {
118
- const line = appendEntry(last, {
119
- runId, taskId, podId, ts: now().toISOString(), kind, data: scrub(data, secrets), actor,
120
- }, { runActor });
121
- try {
122
- appendFileSync(path, `${line}\n`, { mode: 0o600 });
123
- } catch (error) {
124
- // Some bytes may have landed; a chain cannot continue past an unknown tail.
125
- broken = true;
126
- throw error;
127
- }
128
- last = line;
129
- seq += 1;
130
- const entry = JSON.parse(line);
131
- if (kind === 'grant.issued') {
132
- grants.set(entry.data.grantId, {
133
- parentGrantId: entry.data.parentGrantId ?? null, binding: entry.data.binding || null, expiresAt: entry.data.expiresAt || null,
134
- });
135
- }
136
- if (kind === 'grant.revoked') for (const id of entry.data.grantIds) grants.delete(id);
137
- return asEvent(entry);
138
- };
139
-
140
- /** A streamed delta's text, less a tail that could begin a known value. */
141
- const windowed = (kind, data, actor) => {
142
- if (!STREAMED_KINDS.has(kind) || typeof data?.text !== 'string') return data;
143
- const key = JSON.stringify([kind, actor ?? null]);
144
- const joined = scrubText(`${streams.get(key)?.held ?? ''}${data.text}`, secrets);
145
- const cut = joined.length - heldLength(joined, secrets);
146
- if (cut < joined.length) streams.set(key, { kind, actor, held: joined.slice(cut) });
147
- else streams.delete(key);
148
- return { ...data, text: joined.slice(0, cut) };
149
- };
150
-
151
- const write = (kind, data, actor) => writeLine(kind, windowed(kind, data, actor), actor);
152
-
153
- /**
154
- * The stream has ended, so each held tail can no longer complete a value.
155
- * A tail is held only while it is a prefix of a known value, so it is part
156
- * of a credential: write the marker in its place, never the prefix.
157
- */
158
- const flushStreams = () => {
159
- for (const [key, { kind, actor }] of streams) {
160
- streams.delete(key);
161
- writeLine(kind, { text: '[redacted]' }, actor);
162
- }
163
- };
164
-
165
- const revoke = (grantIds, reason, actor) => (grantIds.length ? write('grant.revoked', { grantIds, reason }, actor) : null);
166
-
167
- const emit = (type, data = {}, options = {}) => {
168
- const kind = kindOfType(type);
169
- if (!kind) throw new Error(`unknown trace event type: ${type}`);
170
- if (settled) return null;
171
- if (broken) throw new Error(`run ${runId}: the record is broken after a failed write`);
172
- if (FLUSH_BEFORE.has(kind)) flushStreams();
173
- const event = write(kind, data, options?.actor);
174
- if (kind === 'task.done') {
175
- // The board closed the task: every grant bound to it ends, in one write.
176
- const bound = [...grants].filter(([, g]) => g.binding?.endsOn === 'task.done').map(([id]) => id);
177
- revoke(bound, 'task.done', BOARD_ACTOR);
178
- }
179
- if (kind === 'run.settled') {
180
- settled = true;
181
- append(indexPath, {
182
- runId,
183
- settledAt: event.ts,
184
- outcome: event.data.outcome,
185
- failure: event.data.failure || null,
186
- durationMs: event.data.durationMs ?? null,
187
- entries: seq,
188
- head: hashLine(last),
189
- });
190
- }
191
- return event;
192
- };
193
-
194
- /** Revoke a grant and every descendant this run knows of, in one entry. */
195
- const revokeGrant = (grantId, { actor } = {}) => {
196
- if (settled || !grants.has(grantId)) return null;
197
- const lineage = [grantId];
198
- for (let i = 0; i < lineage.length; i += 1) {
199
- for (const [id, g] of grants) if (g.parentGrantId === lineage[i] && !lineage.includes(id)) lineage.push(id);
200
- }
201
- return revoke(lineage, lineage.length > 1 ? 'cascade' : 'manual', actor);
202
- };
203
-
204
- /** Record the end of every open grant whose expiresAt has passed. */
205
- const expireGrants = (at = now()) => {
206
- if (settled) return null;
207
- const cutoff = at.getTime();
208
- return revoke([...grants].filter(([, g]) => g.expiresAt && Date.parse(g.expiresAt) <= cutoff).map(([id]) => id), 'expired');
209
- };
210
-
211
- /** Every non-empty string is scrubbed from here on, whatever its length. */
212
- const redact = (values = []) => {
213
- for (const value of values) if (typeof value === 'string' && value && !secrets.includes(value)) secrets.push(value);
214
- secrets.sort((a, b) => b.length - a.length);
215
- };
216
-
217
- append(indexPath, {
218
- runId,
219
- startedAt: startedAt.toISOString(),
220
- adapter: meta.adapter || null,
221
- label: meta.label || null,
222
- ...(taskId ? { taskId, podId } : podId ? { podId } : {}),
223
- ...(runActor ? { actor: runActor } : {}),
224
- });
225
- return {
226
- runId,
227
- taskId: taskId ?? null,
228
- podId: podId ?? null,
229
- emit,
230
- redact,
231
- revokeGrant,
232
- expireGrants,
233
- openGrants: () => [...grants.keys()],
234
- get settled() { return settled; },
235
- };
236
- };
237
-
238
- const listRuns = ({ limit = 50 } = {}) => {
239
- const byId = new Map();
240
- for (const line of readJsonl(indexPath)) {
241
- const row = byId.get(line.runId) || { runId: line.runId, outcome: 'running' };
242
- Object.assign(row, line);
243
- byId.set(line.runId, row);
244
- }
245
- return [...byId.values()].sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt))).slice(0, limit);
246
- };
247
-
248
- const runPath = (runId) => (RUN_ID.test(String(runId)) ? join(runsDir, `${runId}.jsonl`) : null);
249
-
250
- const readRun = (runId) => {
251
- const path = runPath(runId);
252
- return path && existsSync(path) ? readRecord(readFileSync(path, 'utf8')).map(asEvent) : null;
253
- };
254
-
255
- /**
256
- * verifyRun(runId) → { ok, errors: [{seq, reason, detail?}], entries, head, settled } | null
257
- *
258
- * The chain check from verifyRecord, plus the last line against the head the
259
- * index recorded at settle (head_mismatch, count_mismatch). The index sits
260
- * beside the record, so that check catches accidents, not an editor.
261
- */
262
- const verifyRun = (runId) => {
263
- const path = runPath(runId);
264
- if (!path || !existsSync(path)) return null;
265
- const result = verifyRecord(readFileSync(path, 'utf8'));
266
- const sealed = readJsonl(indexPath).filter((row) => row.runId === runId && row.head).pop();
267
- if (sealed) {
268
- if (sealed.entries !== result.entries) result.errors.push({ seq: sealed.entries, reason: 'count_mismatch', detail: `index ${sealed.entries}, file ${result.entries}` });
269
- else if (sealed.head !== result.head) result.errors.push({ seq: sealed.entries, reason: 'head_mismatch' });
270
- result.ok = result.errors.length === 0;
271
- }
272
- return result;
273
- };
274
-
275
- return { root, startRun, listRuns, readRun, verifyRun };
276
- };
@@ -1,226 +0,0 @@
1
- /**
2
- * Processes a turn leaves running.
3
- *
4
- * A turn can start work that outlives it. A dev server backgrounded from a
5
- * shell tool (`npm exec vite &`) is orphaned the moment that shell exits and
6
- * is reparented to launchd, where no group kill or descendant walk from the
7
- * turn's child can reach it. Six such trees, one to four days old, took the
8
- * fleet host's swap to 12 GB (SRE, 2026-11-01).
9
- *
10
- * So a turn's child is started with a fresh marker in its environment, which
11
- * every process it starts inherits however it detaches. When the child has
12
- * closed, the processes still carrying the marker are stopped: SIGTERM, then
13
- * SIGKILL after a grace for any still alive. That includes a lasting server
14
- * the turn started on first use, such as tmux, and a seat it restarted. A
15
- * process started with COMMONLY_KEEP_PROCESS=1 is left running, and so is
16
- * anything it starts, even one that clears the setting; the turn's child itself
17
- * never inherits it. A confined seat's turn does not honour the setting: what
18
- * a confined turn leaves running is stopped whatever its environment says,
19
- * because a process that outlives the turn would keep running with the
20
- * turn's grants after the host has moved on to preparing the next one.
21
- * COMMONLY_TURN_REAP=0 turns the whole check off.
22
- *
23
- * Only processes started since the turn began are read, and each one's
24
- * environment is only compared against the marker and the keep setting. Each
25
- * pid is re-read just before it is signalled, and is signalled only while its
26
- * start time is still the one recorded, so a reused pid is left alone.
27
- *
28
- * What it does not catch: on macOS `ps -E` cannot read the environment of an
29
- * Apple platform binary (sh, bash, sleep), so one of those is stopped only as
30
- * a descendant of a marked process. A process that clears its environment
31
- * loses the marker. Both are left running. The same unreadable environment
32
- * hides a keep setting: a kept Apple binary is not seen as kept, so a process
33
- * it starts is kept only if that process keeps the setting itself.
34
- */
35
-
36
- import { randomUUID } from 'node:crypto';
37
- import { readFileSync } from 'node:fs';
38
- import { spawnSync } from 'node:child_process';
39
- import {
40
- PROCESS_GROUPS_SUPPORTED, readProcesses, readProcessTable, startedMs,
41
- } from './process-group.js';
42
-
43
- export const TURN_MARK_ENV = 'COMMONLY_TURN_ID';
44
- export const KEEP_PROCESS_ENV = 'COMMONLY_KEEP_PROCESS';
45
- export const TURN_REAP_ENV = 'COMMONLY_TURN_REAP';
46
- export const TURN_REAP_GRACE_MS = 5_000;
47
- // How far before the turn a process's recorded start may read and still be
48
- // looked at. lstart has one-second resolution, and on Linux ps derives it from
49
- // the boot time, which drifts from the wall clock by a second or more. The
50
- // window only bounds what is read; the marker decides.
51
- export const TURN_START_SLACK_MS = 60_000;
52
-
53
- export const turnReapEnabled = (env = process.env) => PROCESS_GROUPS_SUPPORTED && env?.[TURN_REAP_ENV] !== '0';
54
-
55
- const PS_ENV = { ...process.env, LC_ALL: 'C' };
56
-
57
- // pid -> the set of tokens its environment reads as. A pid whose environment
58
- // could not be read has no entry, or a set without NAME=value pairs.
59
- const readEnvironmentsDarwin = (pids) => {
60
- const out = new Map();
61
- const res = spawnSync('ps', ['-E', '-ww', '-o', 'pid=,command=', '-p', pids.join(',')], {
62
- encoding: 'utf8', env: PS_ENV, timeout: 5000, maxBuffer: 64 * 1024 * 1024,
63
- });
64
- if (res.error) return out;
65
- for (const line of String(res.stdout || '').split('\n')) {
66
- const match = /^\s*(\d+)\s+(.*)$/.exec(line);
67
- if (match) out.set(Number(match[1]), new Set(match[2].split(/\s+/)));
68
- }
69
- return out;
70
- };
71
-
72
- const readEnvironmentsLinux = (pids) => {
73
- const out = new Map();
74
- for (const pid of pids) {
75
- try {
76
- out.set(pid, new Set(readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0')));
77
- } catch { /* gone, or not readable */ }
78
- }
79
- return out;
80
- };
81
-
82
- export const readEnvironments = (pids) => {
83
- if (!pids.length || !PROCESS_GROUPS_SUPPORTED) return new Map();
84
- return process.platform === 'linux' ? readEnvironmentsLinux(pids) : readEnvironmentsDarwin(pids);
85
- };
86
-
87
- const selfAndAncestors = (table, pid) => {
88
- const byPid = new Map(table.map((row) => [row.pid, row]));
89
- const out = new Set([pid]);
90
- for (let row = byPid.get(pid); row && row.ppid > 0 && !out.has(row.ppid); row = byPid.get(row.ppid)) {
91
- out.add(row.ppid);
92
- }
93
- return out;
94
- };
95
-
96
- /**
97
- * The processes still carrying `mark`, as { pid, ppid, started }: started at
98
- * or after `since` (ms), neither this process nor an ancestor of it, and
99
- * neither kept nor under a kept one. Each one brings its descendants, except a
100
- * kept one and everything
101
- * under it, whose environments may be unreadable. The start-time window only
102
- * limits what is read; the marker is what decides. With `honorKeep: false`
103
- * (a confined turn) nothing is kept, and a row whose environment carried the
104
- * keep setting is returned with `keepIgnored: true`. `io` is a test seam
105
- * ({ table, envs, self }).
106
- */
107
- export const findTurnProcesses = (mark, since, io = {}, { honorKeep = true } = {}) => {
108
- const { table = readProcessTable, envs = readEnvironments, self = process.pid } = io;
109
- if (!mark) return [];
110
- const rows = table();
111
- if (!rows) return [];
112
- const skip = selfAndAncestors(rows, self);
113
- const floor = Number(since) - TURN_START_SLACK_MS;
114
- const recent = rows.filter((row) => !skip.has(row.pid) && startedMs(row.started) >= floor);
115
- const environments = envs(recent.map((row) => row.pid));
116
- const marked = `${TURN_MARK_ENV}=${mark}`;
117
- const keep = `${KEEP_PROCESS_ENV}=1`;
118
- const askedToKeep = (pid) => environments.get(pid)?.has(keep) === true;
119
- const kept = (pid) => honorKeep && askedToKeep(pid);
120
- const byPid = new Map(rows.map((row) => [row.pid, row]));
121
- // A kept process keeps what it starts even when that drops the setting.
122
- const underKept = (row) => {
123
- for (let up = byPid.get(row.ppid), seen = new Set(); up && !seen.has(up.pid); up = byPid.get(up.ppid)) {
124
- if (kept(up.pid)) return true;
125
- seen.add(up.pid);
126
- }
127
- return false;
128
- };
129
- const found = new Map();
130
- for (const row of recent) {
131
- if (environments.get(row.pid)?.has(marked) && !kept(row.pid) && !underKept(row)) found.set(row.pid, row);
132
- }
133
- const byParent = new Map();
134
- for (const row of rows) {
135
- if (row.pid === row.ppid) continue;
136
- if (!byParent.has(row.ppid)) byParent.set(row.ppid, []);
137
- byParent.get(row.ppid).push(row);
138
- }
139
- for (const stack = [...found.keys()]; stack.length;) {
140
- for (const child of byParent.get(stack.pop()) || []) {
141
- if (found.has(child.pid) || skip.has(child.pid) || kept(child.pid)) continue;
142
- found.set(child.pid, child);
143
- stack.push(child.pid);
144
- }
145
- }
146
- const out = [...found.values()];
147
- return honorKeep ? out : out.map((row) => (askedToKeep(row.pid) ? { ...row, keepIgnored: true } : row));
148
- };
149
-
150
- /**
151
- * Stop what `findTurnProcesses` returns. SIGTERM is sent before this returns;
152
- * `done` settles after `graceMs`, once anything still alive has had SIGKILL.
153
- * A pid is signalled only while its start time is the one recorded, read for
154
- * all of them in one ps call per signal. The grace timer holds the process
155
- * open until SIGKILL has gone out, so a seat stopped mid-turn still escalates.
156
- * `io` adds `read` (pids -> rows) to the find seam.
157
- */
158
- export const reapTurnProcesses = (mark, since, { graceMs = TURN_REAP_GRACE_MS, io = {}, honorKeep = true } = {}) => {
159
- const read = io.read || readProcesses;
160
- const signal = (rows, name) => {
161
- const now = new Map((read(rows.map((row) => row.pid)) || []).map((row) => [row.pid, row]));
162
- return rows.filter((row) => {
163
- if (now.get(row.pid)?.started !== row.started) return false;
164
- try { process.kill(row.pid, name); return true; } catch { return false; }
165
- });
166
- };
167
- const termed = signal(findTurnProcesses(mark, since, io, { honorKeep }), 'SIGTERM');
168
- const done = termed.length === 0
169
- ? Promise.resolve({ killed: [] })
170
- : new Promise((resolve) => {
171
- setTimeout(() => resolve({ killed: signal(termed, 'SIGKILL').map((row) => row.pid) }), graceMs);
172
- });
173
- return {
174
- termed: termed.map((row) => row.pid),
175
- keepIgnored: termed.filter((row) => row.keepIgnored).map((row) => row.pid),
176
- honorKeep,
177
- done,
178
- };
179
- };
180
-
181
- /**
182
- * One turn's marker. `env` is the child's environment with the marker added;
183
- * `reap(child)` runs once the child has closed, and returns what
184
- * `reapTurnProcesses` does, or null when the check is off, the child never had
185
- * a pid, or the turn was already reaped. `confined` marks a confined seat's
186
- * turn, whose reap does not honour the keep setting.
187
- */
188
- export const markTurn = (env, {
189
- enabled = turnReapEnabled(), now = Date.now, graceMs, confined = false,
190
- } = {}) => {
191
- if (!enabled) return { env, mark: null, reap: () => null };
192
- const mark = randomUUID();
193
- const since = now();
194
- // A keep setting the seat itself was started with would keep every turn.
195
- const childEnv = { ...env, [TURN_MARK_ENV]: mark };
196
- delete childEnv[KEEP_PROCESS_ENV];
197
- let reaped = false;
198
- return {
199
- env: childEnv,
200
- mark,
201
- reap: (child, { io } = {}) => {
202
- if (reaped || !Number.isInteger(child?.pid) || child.pid <= 0) return null;
203
- reaped = true;
204
- try {
205
- return reapTurnProcesses(mark, since, { graceMs, io, honorKeep: !confined });
206
- } catch { return null; }
207
- },
208
- };
209
- };
210
-
211
- /**
212
- * The log line for a reap that stopped something, or null. A confined turn's
213
- * line offers no keep setting, and says once when one was set and not
214
- * honoured.
215
- */
216
- export const describeTurnReap = (result) => {
217
- if (!result?.termed?.length) return null;
218
- const n = result.termed.length;
219
- const stopped = `stopped ${n} process${n === 1 ? '' : 'es'} this turn left running (pid ${result.termed.join(', ')})`;
220
- if (result.honorKeep !== false) {
221
- return `${stopped}; start one with ${KEEP_PROCESS_ENV}=1 to keep it across turns`;
222
- }
223
- if (!result.keepIgnored?.length) return stopped;
224
- return `${stopped}; ${KEEP_PROCESS_ENV}=1 was set on pid ${result.keepIgnored.join(', ')} `
225
- + 'and is not honoured on a confined seat';
226
- };