pi-better-subagents 0.1.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.
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Pure helpers that surface #66 health observations on existing list / output /
3
+ * widget text without inventing a parallel health model (issue #67).
4
+ *
5
+ * Healthy/quiet stays low-noise. Degraded/actionable compact facts and durable
6
+ * orphaned/lost statuses are the only additions.
7
+ */
8
+
9
+ /** Statuses that are always actionable on list/output surfaces. */
10
+ const ACTIONABLE_STATUSES = new Set(["orphaned", "lost"]);
11
+
12
+ /** Age suffix from health-observation fmtAge: `10s`, `2m`, `1h5m`. */
13
+ const ORDINARY_TOOL_AGE = /^(?:\d+s|\d+m|\d+h\d+m)$/;
14
+
15
+ /**
16
+ * Ordinary short-running open-tool label from #66: bare tool name, or name plus
17
+ * a fmtAge duration. Degraded facts that merely share the tool-name prefix
18
+ * (e.g. `model error` while a tool named `model` is running) must be kept.
19
+ *
20
+ * @param {string} fact
21
+ * @param {string} toolName
22
+ */
23
+ function isOrdinaryRunningToolFact(fact, toolName) {
24
+ if (fact === toolName) return true;
25
+ const prefix = `${toolName} `;
26
+ if (!fact.startsWith(prefix)) return false;
27
+ return ORDINARY_TOOL_AGE.test(fact.slice(prefix.length));
28
+ }
29
+
30
+ /**
31
+ * #66 emits ordinary open-tool labels (e.g. `bash 10s`) in compactFacts before
32
+ * long_running. Those are not degraded health for #67 surfaces — the widget
33
+ * already shows the active tool via its normal label. Keep long_running and
34
+ * every other compact fact.
35
+ *
36
+ * @param {{ compactFacts?: string[], tool?: { state?: string, active?: { toolName?: string } } }|null|undefined} obs
37
+ * @returns {string[]}
38
+ */
39
+ export function surfaceableCompactFacts(obs) {
40
+ const facts = Array.isArray(obs?.compactFacts) ? obs.compactFacts : [];
41
+ if (!obs || obs.tool?.state !== "running") {
42
+ return facts.filter(Boolean);
43
+ }
44
+ const name = obs.tool?.active?.toolName;
45
+ if (!name) return facts.filter(Boolean);
46
+ // Drop only the ordinary running duration fact (`${name}` / `${name} 10s`).
47
+ // Prefix-sharing degraded facts (`model error`, `model retrying`, …) stay.
48
+ // long_running (`long ${name}…`) is retained because tool.state !== "running".
49
+ return facts.filter((f) => f && !isOrdinaryRunningToolFact(f, name));
50
+ }
51
+
52
+ /**
53
+ * True when the observation should add diagnostics beyond the compact baseline.
54
+ * Quiet is intentionally silent — residual silence without residual stale /
55
+ * phase warnings is not actionable noise for list/widget.
56
+ * Ordinary tool.state === "running" compact facts are not actionable (#67).
57
+ *
58
+ * @param {{ status?: string, activity?: string, compactFacts?: string[], tool?: { state?: string, active?: { toolName?: string } } }|null|undefined} obs
59
+ */
60
+ export function isActionableHealth(obs) {
61
+ if (!obs) return false;
62
+ if (ACTIONABLE_STATUSES.has(obs.status)) return true;
63
+ if (obs.activity === "stale") return true;
64
+ return surfaceableCompactFacts(obs).length > 0;
65
+ }
66
+
67
+ /**
68
+ * Compact fact labels for degraded/actionable health, including process
69
+ * liveness labels for orphaned/lost when compactFacts alone are empty.
70
+ * Ordinary short-running tool labels are omitted (degraded-only contract).
71
+ *
72
+ * @param {{ status?: string, activity?: string, compactFacts?: string[], process?: { liveness?: string }, tool?: { state?: string, active?: { toolName?: string } } }|null|undefined} obs
73
+ * @returns {string[]}
74
+ */
75
+ export function healthSurfaceFacts(obs) {
76
+ if (!obs) return [];
77
+ const facts = [];
78
+ if (obs.status === "orphaned") facts.push("orphaned");
79
+ else if (obs.status === "lost") facts.push("lost");
80
+ for (const f of surfaceableCompactFacts(obs)) {
81
+ if (f && !facts.includes(f)) facts.push(f);
82
+ }
83
+ // Residual stale may already be in compactFacts; ensure it appears when
84
+ // activity is stale and nothing more specific was listed.
85
+ if (obs.activity === "stale" && !facts.some((f) => /\bstale\b/i.test(f))) {
86
+ facts.push("stale");
87
+ }
88
+ return facts.slice(0, 3);
89
+ }
90
+
91
+ /**
92
+ * Format list-row health suffix. Empty string when healthy/quiet so the row
93
+ * stays on today's compact format.
94
+ *
95
+ * @param {{ status?: string, activity?: string, compactFacts?: string[] }|null|undefined} obs
96
+ */
97
+ export function formatListHealthSuffix(obs) {
98
+ if (!isActionableHealth(obs)) return "";
99
+ const facts = healthSurfaceFacts(obs);
100
+ // Orphaned/lost already appear in the status bracket; prefer remaining facts.
101
+ const rest = facts.filter((f) => f !== obs.status);
102
+ if (rest.length === 0) {
103
+ // Still actionable via status alone — no extra suffix needed.
104
+ return "";
105
+ }
106
+ return ` · ${rest.join(", ")}`;
107
+ }
108
+
109
+ /**
110
+ * One-line health diagnostic for subagent_output / result heads.
111
+ * Empty when healthy/quiet running.
112
+ *
113
+ * @param {{ status?: string, activity?: string, compactFacts?: string[] }|null|undefined} obs
114
+ */
115
+ export function formatHealthDiagnosticLine(obs) {
116
+ if (!isActionableHealth(obs)) return "";
117
+ const facts = healthSurfaceFacts(obs);
118
+ if (facts.length === 0) return "";
119
+ return `[health: ${facts.join(", ")}]`;
120
+ }
121
+
122
+ /**
123
+ * Short suffix for a passive widget run line. Empty for healthy/quiet so
124
+ * geometry and text stay unchanged for quiet runs.
125
+ *
126
+ * @param {{ status?: string, activity?: string, compactFacts?: string[] }|null|undefined} obs
127
+ */
128
+ export function formatWidgetHealthSuffix(obs) {
129
+ if (!isActionableHealth(obs)) return "";
130
+ const facts = healthSurfaceFacts(obs)
131
+ // Widget already implies "running"; skip redundant process labels when
132
+ // a more specific compact fact exists, but keep orphaned/lost alone.
133
+ .filter((f) => f !== "running");
134
+ if (facts.length === 0) return "";
135
+ // Keep widget geometry mostly stable: at most two short tokens.
136
+ return ` · ${facts.slice(0, 2).join(", ")}`;
137
+ }
138
+
139
+ /**
140
+ * Append a health diagnostic line to an existing tool body when present.
141
+ *
142
+ * @param {string} body
143
+ * @param {string} healthLine
144
+ */
145
+ export function appendHealthDiagnostic(body, healthLine) {
146
+ if (!healthLine) return body;
147
+ // Place diagnostic just under the head line when body starts with `[id ·`.
148
+ const nl = body.indexOf("\n");
149
+ if (nl === -1) return `${body}\n${healthLine}`;
150
+ return `${body.slice(0, nl)}\n${healthLine}${body.slice(nl)}`;
151
+ }
152
+
153
+ /**
154
+ * Compact health facts for TUI navigator rows (#69).
155
+ * Healthy/quiet stays empty. Durable status is rendered separately, so
156
+ * orphaned/lost labels are not repeated as facts. Cap is two facts.
157
+ *
158
+ * @param {{ status?: string, activity?: string, compactFacts?: string[], tool?: { state?: string, active?: { toolName?: string } } }|null|undefined} obs
159
+ * @returns {string[]}
160
+ */
161
+ export function formatNavigatorHealthFacts(obs) {
162
+ if (!obs || !isActionableHealth(obs)) return [];
163
+ const facts = [];
164
+ for (const f of surfaceableCompactFacts(obs)) {
165
+ if (f && f !== obs.status && !facts.includes(f)) facts.push(f);
166
+ }
167
+ if (obs.activity === "stale" && !facts.some((f) => /\bstale\b/i.test(f))) {
168
+ facts.push("stale");
169
+ }
170
+ // Process-group / log diagnostics when orphaned/lost and no phase facts.
171
+ if (facts.length === 0 && (obs.status === "orphaned" || obs.status === "lost")) {
172
+ const live = obs.process?.liveness;
173
+ if (live && live !== obs.status) facts.push(live);
174
+ const logAge = obs.rawLog?.mtimeMs;
175
+ // raw age is computed by callers into compactFacts when needed; skip here.
176
+ void logAge;
177
+ }
178
+ return facts.slice(0, 2);
179
+ }
180
+
181
+ /**
182
+ * Semantic theme color for a durable/effective run status in the TUI.
183
+ * completed → success; failed/lost → error; killed/orphaned → warning;
184
+ * running → accent; anything else → dim.
185
+ *
186
+ * @param {string|undefined|null} status
187
+ * @returns {string}
188
+ */
189
+ export function statusThemeColor(status) {
190
+ switch (String(status ?? "").toLowerCase()) {
191
+ case "completed":
192
+ return "success";
193
+ case "failed":
194
+ case "lost":
195
+ return "error";
196
+ case "killed":
197
+ case "orphaned":
198
+ return "warning";
199
+ case "running":
200
+ return "accent";
201
+ default:
202
+ return "dim";
203
+ }
204
+ }
205
+
206
+ /** Strip CSI / OSC ANSI sequences for visible-width measurement. */
207
+ const ANSI_RE = new RegExp(
208
+ // eslint-disable-next-line no-control-regex
209
+ "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))",
210
+ "g",
211
+ );
212
+
213
+ /**
214
+ * Visible (cell) width of a string, ignoring ANSI styling sequences.
215
+ * Also strips the lightweight `<color>…</>` test theme markers used in unit stubs.
216
+ *
217
+ * @param {string} s
218
+ * @returns {number}
219
+ */
220
+ export function visibleWidth(s) {
221
+ const plain = String(s ?? "")
222
+ .replace(ANSI_RE, "")
223
+ // Theme stub markers from unit tests: <accent>…</> (closing tag is bare </>).
224
+ .replace(/<\/?[a-zA-Z][\w-]*>/g, "")
225
+ .replace(/<\/>/g, "");
226
+ // Treat combining marks / wide chars as single cells — sufficient for our
227
+ // ASCII status tokens and English labels; pi-tui handles full East-Asian.
228
+ return plain.length;
229
+ }
230
+
231
+ /**
232
+ * Truncate to a maximum visible width while preserving ANSI / theme markers.
233
+ * Prefer the host's `truncateToWidth` when available (index.ts injects pi-tui);
234
+ * this fallback keeps unit tests width-safe when color escapes are present.
235
+ *
236
+ * @param {string} s
237
+ * @param {number} width
238
+ * @returns {string}
239
+ */
240
+ export function truncateToVisibleWidth(s, width) {
241
+ const str = String(s ?? "");
242
+ const max = Math.max(0, Number(width) || 0);
243
+ if (visibleWidth(str) <= max) return str;
244
+
245
+ // Walk code units, skipping ANSI and <tag> markers for the budget.
246
+ let out = "";
247
+ let vis = 0;
248
+ let i = 0;
249
+ while (i < str.length && vis < max) {
250
+ // ANSI CSI/OSC
251
+ if (str[i] === "\u001b" || str[i] === "\u009b") {
252
+ const m = str.slice(i).match(ANSI_RE);
253
+ if (m && m.index === 0) {
254
+ out += m[0];
255
+ i += m[0].length;
256
+ continue;
257
+ }
258
+ }
259
+ // Lightweight theme markers from test stubs: <accent>…</>
260
+ if (str[i] === "<") {
261
+ const close = str.indexOf(">", i);
262
+ if (close !== -1) {
263
+ const tag = str.slice(i, close + 1);
264
+ if (/^<\/?[a-zA-Z][\w-]*>$/.test(tag) || tag === "</>") {
265
+ out += tag;
266
+ i = close + 1;
267
+ continue;
268
+ }
269
+ }
270
+ }
271
+ out += str[i];
272
+ vis += 1;
273
+ i += 1;
274
+ }
275
+ return out;
276
+ }
package/health.ts ADDED
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Subagent health reconciliation (issue #63) — durable supervision statuses.
3
+ *
4
+ * Process-group-only contract (ADR 0002): `reconcileRun` takes run metadata
5
+ * plus a `ProcessProbe` and decides the next durable status. A supervised run
6
+ * stays `running`; a run whose recorded child is gone but whose captured
7
+ * process group still has live members becomes durable non-terminal
8
+ * `orphaned`; a run with no credible process-group evidence becomes durable
9
+ * terminal `lost`. Escaped/reparented descendants are out of contract — this
10
+ * slice never scans the process tree for related work. Nothing here kills
11
+ * processes — reconciliation only writes truth.
12
+ *
13
+ * `realProcessProbe` is the OS-backed probe used in production; unit tests
14
+ * inject fakes so supervised/orphaned/lost/recycled cases are deterministic.
15
+ */
16
+
17
+ import { execFileSync } from "node:child_process";
18
+ import { readFileSync } from "node:fs";
19
+ import { processExists } from "./spawn.ts";
20
+ import { ownedByThisParent, type RunMeta, type RunStatus } from "./registry.ts";
21
+
22
+ /** Consecutive pid-gone ticks required before OLD metadata may be written `lost`. */
23
+ export const OLD_METADATA_LOST_CONFIRM_TICKS = 2;
24
+
25
+ /**
26
+ * Process evidence probe. Every method is best-effort and never throws.
27
+ *
28
+ * #63 deliberately exposes only process-group evidence. There is no
29
+ * `descendants` method: escaped/reparented children are not related work for
30
+ * this slice (see docs/adr/0002-process-group-only-subagent-health.md).
31
+ */
32
+ export interface ProcessProbe {
33
+ /** The pid currently exists (signal 0; EPERM counts as existing). */
34
+ pidExists(pid: number): boolean;
35
+ /**
36
+ * Opaque process-start identity token for a live pid, or undefined when
37
+ * unavailable. Equality is the only supported operation: a DIFFERENT token
38
+ * proves the pid was recycled; an unavailable token proves nothing.
39
+ */
40
+ startToken(pid: number): string | undefined;
41
+ /** Process group id of a live pid, or undefined when unavailable. */
42
+ groupId(pid: number): number | undefined;
43
+ /** Any member of process group `pgid` is alive (EPERM counts as alive). */
44
+ groupAlive(pgid: number): boolean;
45
+ }
46
+
47
+ /** Process identity recorded on a new run's metadata (all best-effort). */
48
+ export interface ProcessIdentity {
49
+ pgid?: number;
50
+ pidStartTime?: string;
51
+ }
52
+
53
+ /** Capture process identity for a freshly spawned child. Never throws. */
54
+ export function captureProcessIdentity(pid: number, probe: ProcessProbe = realProcessProbe): ProcessIdentity {
55
+ const identity: ProcessIdentity = {};
56
+ try {
57
+ const pgid = probe.groupId(pid);
58
+ if (typeof pgid === "number" && pgid > 0) identity.pgid = pgid;
59
+ } catch { /* unavailable */ }
60
+ try {
61
+ const token = probe.startToken(pid);
62
+ if (typeof token === "string" && token !== "") identity.pidStartTime = token;
63
+ } catch { /* unavailable */ }
64
+ return identity;
65
+ }
66
+
67
+ /** The subset of run metadata reconciliation reads and writes. */
68
+ export type ReconcileInput = Pick<
69
+ RunMeta,
70
+ "status" | "pid" | "pgid" | "pidStartTime" | "probeMisses"
71
+ >;
72
+
73
+ export interface ReconcileResult {
74
+ /** Next durable status (unchanged unless `transition`). */
75
+ status: RunStatus;
76
+ /** Meta must be rewritten (status and/or patch fields changed). */
77
+ changed: boolean;
78
+ /** Durable status transitioned (notify-worthy). */
79
+ transition: boolean;
80
+ /** Fields to merge into the persisted meta. */
81
+ patch: Partial<Pick<RunMeta, "probeMisses" | "orphanedAt" | "lostAt" | "endedAt">>;
82
+ /** Machine-readable reason, for diagnostics and tests. */
83
+ reason:
84
+ | "terminal-untouched"
85
+ | "supervised"
86
+ | "orphaned-group-alive"
87
+ | "orphaned-kept"
88
+ | "lost-no-evidence"
89
+ | "lost-confirmed-old-metadata"
90
+ | "lost-suspected-old-metadata";
91
+ }
92
+
93
+ /**
94
+ * Supervised ⇔ the recorded pid exists AND its start-time identity still
95
+ * matches when both sides of the comparison are available. A missing recorded
96
+ * token (old metadata) or an unavailable probe token can disprove nothing.
97
+ */
98
+ function isSupervised(meta: ReconcileInput, probe: ProcessProbe): boolean {
99
+ if (!probe.pidExists(meta.pid)) return false;
100
+ if (meta.pidStartTime === undefined) return true;
101
+ const token = probe.startToken(meta.pid);
102
+ if (token === undefined) return true;
103
+ return token === meta.pidStartTime;
104
+ }
105
+
106
+ /**
107
+ * Related work for #63 is live process-group evidence only.
108
+ *
109
+ * Uses the recorded pgid, falling back to the pid itself — detached children
110
+ * are expected to be process-group leaders (pgid == pid), so the fallback is
111
+ * valid for old metadata too. That inference is conservative: it may delay
112
+ * `lost`, but it never manufactures completion or failure. No process-tree /
113
+ * descendant scan is consulted.
114
+ */
115
+ function hasProcessGroupEvidence(meta: ReconcileInput, probe: ProcessProbe): boolean {
116
+ const pgid = meta.pgid ?? meta.pid;
117
+ return typeof pgid === "number" && pgid > 0 && probe.groupAlive(pgid);
118
+ }
119
+
120
+ /**
121
+ * Reconcile one run's durable status against process reality. Pure: the
122
+ * caller persists. Only `running` and `orphaned` are reconciled — `lost`,
123
+ * `completed`, `failed`, and `killed` are durable terminal and never revert.
124
+ */
125
+ export function reconcileRun(meta: ReconcileInput, probe: ProcessProbe, now: number): ReconcileResult {
126
+ const stay = (reason: ReconcileResult["reason"]): ReconcileResult =>
127
+ ({ status: meta.status, changed: false, transition: false, patch: {}, reason });
128
+
129
+ if (meta.status !== "running" && meta.status !== "orphaned") {
130
+ return stay("terminal-untouched");
131
+ }
132
+
133
+ // Orphaned is durable non-terminal: it never auto-reverts to running. It
134
+ // only advances to lost when the last process-group evidence disappears.
135
+ // A live, identity-matched child is itself conclusive related evidence —
136
+ // an earlier orphaned write based on a transient probe failure must never
137
+ // degrade a supervised-alive child to lost.
138
+ if (meta.status === "orphaned") {
139
+ if (isSupervised(meta, probe) || hasProcessGroupEvidence(meta, probe)) {
140
+ return stay("orphaned-kept");
141
+ }
142
+ return {
143
+ status: "lost", changed: true, transition: true,
144
+ patch: { lostAt: now, endedAt: now, probeMisses: 0 },
145
+ reason: "lost-no-evidence",
146
+ };
147
+ }
148
+
149
+ if (isSupervised(meta, probe)) {
150
+ if ((meta.probeMisses ?? 0) > 0) {
151
+ return {
152
+ status: "running", changed: true, transition: false,
153
+ patch: { probeMisses: 0 }, reason: "supervised",
154
+ };
155
+ }
156
+ return stay("supervised");
157
+ }
158
+
159
+ if (hasProcessGroupEvidence(meta, probe)) {
160
+ return {
161
+ status: "orphaned", changed: true, transition: true,
162
+ patch: { orphanedAt: now, probeMisses: 0 },
163
+ reason: "orphaned-group-alive",
164
+ };
165
+ }
166
+
167
+ // No process-group evidence. Metadata WITH recorded process identity is
168
+ // conclusive on its own; OLD metadata (no pgid, no start token) must
169
+ // confirm the loss across health ticks before the terminal write.
170
+ // Evidence quality — not file age — drives that confirmation.
171
+ const hasIdentity = meta.pgid !== undefined || meta.pidStartTime !== undefined;
172
+ if (hasIdentity) {
173
+ return {
174
+ status: "lost", changed: true, transition: true,
175
+ patch: { lostAt: now, endedAt: now, probeMisses: 0 },
176
+ reason: "lost-no-evidence",
177
+ };
178
+ }
179
+ const misses = (meta.probeMisses ?? 0) + 1;
180
+ if (misses >= OLD_METADATA_LOST_CONFIRM_TICKS) {
181
+ return {
182
+ status: "lost", changed: true, transition: true,
183
+ patch: { lostAt: now, endedAt: now, probeMisses: misses },
184
+ reason: "lost-confirmed-old-metadata",
185
+ };
186
+ }
187
+ return {
188
+ status: "running", changed: true, transition: false,
189
+ patch: { probeMisses: misses },
190
+ reason: "lost-suspected-old-metadata",
191
+ };
192
+ }
193
+
194
+ /**
195
+ * True while any current-parent run still needs the health ticker:
196
+ * - `running` / `orphaned` for process-group reconciliation (#63), or
197
+ * - `lost` without a successful health-callback handoff marker, so durable
198
+ * coordinator recovery can still fire after reload (#65).
199
+ * The scheduler stops when false.
200
+ */
201
+ export function needsMonitoring(
202
+ metas: ReadonlyArray<Pick<RunMeta, "spawnPid" | "status" | "lostCallbackSentAt" | "lostCallbackSuppressedAt">>,
203
+ parentPid: number = process.pid,
204
+ ): boolean {
205
+ return metas.some(
206
+ (m) => ownedByThisParent(m, parentPid) && (
207
+ m.status === "running"
208
+ || m.status === "orphaned"
209
+ || (m.status === "lost" && m.lostCallbackSentAt === undefined && m.lostCallbackSuppressedAt === undefined)
210
+ ),
211
+ );
212
+ }
213
+
214
+ // ---- real OS-backed probe -------------------------------------------------
215
+
216
+ /** Linux: start-time identity from /proc/<pid>/stat field 22 (jiffies since boot). */
217
+ function linuxStartToken(pid: number): string | undefined {
218
+ try {
219
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
220
+ // comm (field 2) may contain spaces/parens; fields resume after ") ".
221
+ const close = stat.lastIndexOf(") ");
222
+ if (close < 0) return undefined;
223
+ const rest = stat.slice(close + 2).split(" ");
224
+ // rest[0] is field 3 (state); field 22 (starttime) is index 19.
225
+ const starttime = rest[19];
226
+ return starttime && /^\d+$/.test(starttime) ? starttime : undefined;
227
+ } catch {
228
+ return undefined;
229
+ }
230
+ }
231
+
232
+ /** Portable fallback: `ps -o lstart=` (locale-pinned, whitespace-normalized). */
233
+ function psStartToken(pid: number): string | undefined {
234
+ try {
235
+ const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
236
+ encoding: "utf-8",
237
+ timeout: 3000,
238
+ env: { ...process.env, LC_ALL: "C" },
239
+ stdio: ["ignore", "pipe", "ignore"],
240
+ }).trim().replace(/\s+/g, " ");
241
+ return out === "" ? undefined : out;
242
+ } catch {
243
+ return undefined;
244
+ }
245
+ }
246
+
247
+ /** Run `ps <args>` and parse a single integer from stdout. undefined on failure. */
248
+ function psNumber(args: string[]): number | undefined {
249
+ try {
250
+ const out = execFileSync("ps", args, {
251
+ encoding: "utf-8",
252
+ timeout: 3000,
253
+ stdio: ["ignore", "pipe", "ignore"],
254
+ }).trim();
255
+ const n = Number(out);
256
+ return Number.isInteger(n) && n > 0 ? n : undefined;
257
+ } catch {
258
+ return undefined;
259
+ }
260
+ }
261
+
262
+ // Re-export observation seam so health consumers can import from one module.
263
+ export {
264
+ DEFAULT_HEALTH_THRESHOLDS,
265
+ extractChildEventFacts,
266
+ extractChildEventFactsFromLog,
267
+ loadHealthThresholdsFromConfig,
268
+ observeRunHealth,
269
+ resolveHealthThresholds,
270
+ } from "./health-observation.ts";
271
+ export type {
272
+ ActivityHealth,
273
+ ChildEventFacts,
274
+ HealthObservation,
275
+ HealthThresholds,
276
+ ObserveRunHealthInput,
277
+ RawLogDiagnostic,
278
+ } from "./health-observation.ts";
279
+
280
+ export const realProcessProbe: ProcessProbe = {
281
+ pidExists: (pid) => processExists(pid),
282
+ startToken: (pid) => linuxStartToken(pid) ?? psStartToken(pid),
283
+ groupId: (pid) => {
284
+ try {
285
+ if (typeof process.getpgid === "function") return process.getpgid(pid);
286
+ } catch {
287
+ return undefined;
288
+ }
289
+ // process.getpgid is missing on some builds (e.g. macOS); fall back to ps.
290
+ return psNumber(["-o", "pgid=", "-p", String(pid)]);
291
+ },
292
+ groupAlive: (pgid) => {
293
+ if (typeof pgid !== "number" || pgid <= 0) return false;
294
+ try {
295
+ process.kill(-pgid, 0);
296
+ return true;
297
+ } catch (err) {
298
+ // EPERM: the group exists but is not ours to signal — still alive,
299
+ // and "alive" is the safe direction (it can only delay `lost`).
300
+ return (err as NodeJS.ErrnoException).code === "EPERM";
301
+ }
302
+ },
303
+ };