@triflux/remote 10.31.0 → 10.33.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,181 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://triflux.local/schemas/cto/current.schema.json",
4
+ "title": "Triflux CTO Lake Current Authority State",
5
+ "description": "Contract for .triflux/lake/current.json. .triflux/lake is a triflux-owned, namespace-neutral cross-agent rollup for durable artifacts across Claude, Codex/OMX, OMC, AGY, gstack, gbrain, and Triflux state. It reads and aggregates .omx/ultragoal and .omc/ultragoal execution ledgers without replacing or duplicating their authority. ledger.jsonl and sources.json companion contracts are documented in $defs.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": [
9
+ "schema_version",
10
+ "generated_at",
11
+ "repo",
12
+ "sources",
13
+ "summary",
14
+ "ledger_tail"
15
+ ],
16
+ "properties": {
17
+ "schema_version": {
18
+ "type": "string",
19
+ "const": "cto-lake.v1"
20
+ },
21
+ "generated_at": {
22
+ "type": "string",
23
+ "format": "date-time"
24
+ },
25
+ "repo": {
26
+ "type": "object",
27
+ "additionalProperties": false,
28
+ "required": ["root", "branch", "head", "dirty"],
29
+ "properties": {
30
+ "root": {
31
+ "type": "string",
32
+ "minLength": 1
33
+ },
34
+ "branch": {
35
+ "type": ["string", "null"]
36
+ },
37
+ "head": {
38
+ "type": ["string", "null"]
39
+ },
40
+ "dirty": {
41
+ "type": "boolean"
42
+ }
43
+ }
44
+ },
45
+ "sources": {
46
+ "type": "object",
47
+ "description": "Durable artifact source states only. Live Claude /goal state and AGY internal state are not shell-collectable; a source reports available:false when no shell-readable durable artifact exists.",
48
+ "additionalProperties": false,
49
+ "required": [
50
+ "git",
51
+ "tfx_hub",
52
+ "tfx_swarm",
53
+ "tfx_team",
54
+ "tfx_synapse",
55
+ "ultragoal_omx",
56
+ "ultragoal_omc",
57
+ "handoffs",
58
+ "session_vault",
59
+ "agy",
60
+ "gbrain"
61
+ ],
62
+ "properties": {
63
+ "git": {
64
+ "$ref": "#/$defs/sourceState"
65
+ },
66
+ "tfx_hub": {
67
+ "$ref": "#/$defs/sourceState"
68
+ },
69
+ "tfx_swarm": {
70
+ "$ref": "#/$defs/sourceState"
71
+ },
72
+ "tfx_team": {
73
+ "$ref": "#/$defs/sourceState"
74
+ },
75
+ "tfx_synapse": {
76
+ "$ref": "#/$defs/sourceState"
77
+ },
78
+ "ultragoal_omx": {
79
+ "$ref": "#/$defs/sourceState"
80
+ },
81
+ "ultragoal_omc": {
82
+ "$ref": "#/$defs/sourceState"
83
+ },
84
+ "handoffs": {
85
+ "$ref": "#/$defs/sourceState"
86
+ },
87
+ "session_vault": {
88
+ "$ref": "#/$defs/sourceState"
89
+ },
90
+ "agy": {
91
+ "$ref": "#/$defs/sourceState"
92
+ },
93
+ "gbrain": {
94
+ "$ref": "#/$defs/sourceState"
95
+ }
96
+ }
97
+ },
98
+ "summary": {
99
+ "type": "object",
100
+ "description": "Human- and dashboard-oriented synthesized authority summary derived from durable artifacts and live Triflux synapse session status. Phase 1 may add source-derived fields here without changing the top-level contract.",
101
+ "additionalProperties": true
102
+ },
103
+ "ledger_tail": {
104
+ "type": "array",
105
+ "description": "Recent append-only .triflux/lake/ledger.jsonl entries copied into current.json for quick status rendering.",
106
+ "items": {
107
+ "$ref": "#/$defs/ledgerEntry"
108
+ }
109
+ }
110
+ },
111
+ "$defs": {
112
+ "sourceState": {
113
+ "description": "Every collected durable source reports { available, status, detail, collected_at }. available:false means the surface exposes no shell-readable artifact for this collection pass.",
114
+ "type": "object",
115
+ "additionalProperties": false,
116
+ "required": ["available", "status", "detail", "collected_at"],
117
+ "properties": {
118
+ "available": {
119
+ "type": "boolean"
120
+ },
121
+ "status": {
122
+ "type": "string"
123
+ },
124
+ "detail": {
125
+ "type": ["string", "object", "array", "null"]
126
+ },
127
+ "collected_at": {
128
+ "type": ["string", "null"],
129
+ "format": "date-time"
130
+ }
131
+ }
132
+ },
133
+ "ledgerEntry": {
134
+ "description": ".triflux/lake/ledger.jsonl is append-only and single-writer. The collector must acquire .triflux/lake/ledger.jsonl.lock before appending so concurrent collectors cannot interleave or corrupt JSONL lines. Each line is one JSON object with this shape.",
135
+ "type": "object",
136
+ "additionalProperties": false,
137
+ "required": ["ts", "event", "source", "summary", "ref"],
138
+ "properties": {
139
+ "ts": {
140
+ "type": "string",
141
+ "format": "date-time"
142
+ },
143
+ "event": {
144
+ "type": "string"
145
+ },
146
+ "source": {
147
+ "type": "string"
148
+ },
149
+ "summary": {
150
+ "type": "string"
151
+ },
152
+ "ref": {
153
+ "type": ["string", "object", "null"]
154
+ }
155
+ }
156
+ },
157
+ "sourceRegistry": {
158
+ "$comment": ".triflux/lake/sources.json is an array of durable source probe registrations.",
159
+ "type": "array",
160
+ "items": {
161
+ "type": "object",
162
+ "additionalProperties": false,
163
+ "required": ["id", "kind", "probe", "enabled"],
164
+ "properties": {
165
+ "id": {
166
+ "type": "string"
167
+ },
168
+ "kind": {
169
+ "type": "string"
170
+ },
171
+ "probe": {
172
+ "type": ["string", "object"]
173
+ },
174
+ "enabled": {
175
+ "type": "boolean"
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
package/cto/status.mjs ADDED
@@ -0,0 +1,260 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ const SCHEMA_VERSION = "cto-lake.v1";
6
+ const SYNAPSE_TIMEOUT_MS = 1500;
7
+
8
+ function hasFlag(args, flag) {
9
+ return Array.isArray(args) && args.includes(flag);
10
+ }
11
+
12
+ function writeJson(stdout, payload) {
13
+ stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
14
+ }
15
+
16
+ function readJson(filePath) {
17
+ return JSON.parse(readFileSync(filePath, "utf8"));
18
+ }
19
+
20
+ function firstExisting(paths) {
21
+ return paths.filter(Boolean).find((path) => existsSync(path)) || null;
22
+ }
23
+
24
+ function toIsoTime(value) {
25
+ if (typeof value === "string" && value.trim()) return value;
26
+ if (typeof value === "number" && Number.isFinite(value)) {
27
+ return new Date(value).toISOString();
28
+ }
29
+ return null;
30
+ }
31
+
32
+ function normalizeLiveSession(session) {
33
+ return {
34
+ sessionId: String(session?.sessionId || ""),
35
+ host: typeof session?.host === "string" ? session.host : "local",
36
+ agent_id:
37
+ typeof session?.agent_id === "string"
38
+ ? session.agent_id
39
+ : typeof session?.agentId === "string"
40
+ ? session.agentId
41
+ : null,
42
+ phase:
43
+ typeof session?.phase === "string"
44
+ ? session.phase
45
+ : typeof session?.status === "string"
46
+ ? session.status
47
+ : "active",
48
+ started_at: toIsoTime(
49
+ session?.started_at ?? session?.startedAt ?? session?.lastHeartbeat,
50
+ ),
51
+ };
52
+ }
53
+
54
+ function normalizeActiveShard(shard) {
55
+ return {
56
+ shard_name:
57
+ typeof shard?.shard_name === "string"
58
+ ? shard.shard_name
59
+ : typeof shard?.shardName === "string"
60
+ ? shard.shardName
61
+ : typeof shard?.name === "string"
62
+ ? shard.name
63
+ : typeof shard?.id === "string"
64
+ ? shard.id
65
+ : null,
66
+ phase:
67
+ typeof shard?.phase === "string"
68
+ ? shard.phase
69
+ : typeof shard?.status === "string"
70
+ ? shard.status
71
+ : "active",
72
+ members: Array.isArray(shard?.members)
73
+ ? shard.members.map((member) => String(member))
74
+ : [],
75
+ };
76
+ }
77
+
78
+ function normalizeSynapseOverlay(value) {
79
+ const sessions = Array.isArray(value)
80
+ ? value
81
+ : Array.isArray(value?.sessions)
82
+ ? value.sessions
83
+ : Array.isArray(value?.live_sessions)
84
+ ? value.live_sessions
85
+ : [];
86
+ const activeShards = Array.isArray(value?.active_shards)
87
+ ? value.active_shards
88
+ : Array.isArray(value?.activeShards)
89
+ ? value.activeShards
90
+ : [];
91
+
92
+ return {
93
+ live_sessions: sessions
94
+ .map(normalizeLiveSession)
95
+ .filter((session) => session.sessionId),
96
+ active_shards: activeShards.map(normalizeActiveShard),
97
+ };
98
+ }
99
+
100
+ function withTimeout(promise, timeoutMs = SYNAPSE_TIMEOUT_MS) {
101
+ let timer = null;
102
+ const timeout = new Promise((_, reject) => {
103
+ timer = setTimeout(
104
+ () => reject(new Error("synapse read timeout")),
105
+ timeoutMs,
106
+ );
107
+ if (typeof timer.unref === "function") timer.unref();
108
+ });
109
+ return Promise.race([Promise.resolve(promise), timeout]).finally(() => {
110
+ if (timer) clearTimeout(timer);
111
+ });
112
+ }
113
+
114
+ async function readSynapseWithRegistry(opts = {}) {
115
+ const rootDir = opts.rootDir || process.cwd();
116
+ const persistPath = firstExisting([
117
+ opts.synapsePersistPath,
118
+ join(rootDir, ".triflux", "synapse-registry.json"),
119
+ join(rootDir, ".triflux", "synapse", "registry.json"),
120
+ join(homedir(), ".claude", "cache", "tfx-hub", "synapse-sessions.json"),
121
+ join(homedir(), ".claude", "cache", "tfx-hub", "synapse-registry.json"),
122
+ ]);
123
+ if (!persistPath) return { sessions: [], active_shards: [] };
124
+
125
+ const { createSynapseRegistry } = await import(
126
+ "../hub/team/synapse-registry.mjs"
127
+ );
128
+ const registry = createSynapseRegistry({ persistPath });
129
+ return { sessions: registry.getActive(), active_shards: [] };
130
+ }
131
+
132
+ async function readSynapseOverlay(opts) {
133
+ try {
134
+ const reader = opts.synapseReader || readSynapseWithRegistry;
135
+ const raw = await withTimeout(
136
+ reader({
137
+ rootDir: opts.rootDir,
138
+ lakeRoot: opts.lakeRoot,
139
+ synapsePersistPath: opts.synapsePersistPath,
140
+ }),
141
+ opts.synapseTimeoutMs || SYNAPSE_TIMEOUT_MS,
142
+ );
143
+ return normalizeSynapseOverlay(raw);
144
+ } catch {
145
+ return { live_sessions: [], active_shards: [] };
146
+ }
147
+ }
148
+
149
+ // active_shards 의 source 는 live_sessions 와 다르다: live_sessions 는 synapse
150
+ // overlay(실시간 세션)에서, active_shards 는 collect 가 .triflux/swarm-locks.json 을
151
+ // snapshot 한 current.json(sources.tfx_swarm.detail.shards)에서 온다. synapse
152
+ // overlay 가 shard 를 채우는 경로가 생기면 그쪽을 우선하고, 없으면 snapshot 으로
153
+ // fallback 한다(현재 overlay 의 active_shards 는 항상 비어 snapshot 을 쓴다).
154
+ function deriveActiveShards(current, overlay) {
155
+ // overlay(synapse)가 채운 shard 중 식별 가능한 것만 authoritative 로 본다.
156
+ // normalizeSynapseOverlay 가 shard_name 을 null 로 정규화한 무효 행이 snapshot
157
+ // fallback 을 가로채지 않도록 먼저 거른다.
158
+ const overlayShards = Array.isArray(overlay?.active_shards)
159
+ ? overlay.active_shards.filter((shard) => shard?.shard_name)
160
+ : [];
161
+ if (overlayShards.length > 0) return overlayShards;
162
+ const shards = current?.sources?.tfx_swarm?.detail?.shards;
163
+ if (!Array.isArray(shards)) return [];
164
+ return shards.map(normalizeActiveShard).filter((shard) => shard.shard_name);
165
+ }
166
+
167
+ function projectStatus(current, overlay) {
168
+ return {
169
+ schema_version: current?.schema_version || SCHEMA_VERSION,
170
+ generated_at: current?.generated_at || null,
171
+ repo: current?.repo || {},
172
+ sources: current?.sources || {},
173
+ summary: current?.summary || {},
174
+ ledger_tail: Array.isArray(current?.ledger_tail) ? current.ledger_tail : [],
175
+ live_sessions: overlay.live_sessions,
176
+ active_shards: deriveActiveShards(current, overlay),
177
+ };
178
+ }
179
+
180
+ function countAvailableSources(sources) {
181
+ const values = Object.values(sources || {});
182
+ return {
183
+ available: values.filter((source) => source?.available === true).length,
184
+ total: values.length,
185
+ };
186
+ }
187
+
188
+ function countActiveGoals(current) {
189
+ const sources = current?.sources || {};
190
+ return Object.values(sources).reduce((count, source) => {
191
+ const goals = source?.detail?.active_goals;
192
+ return count + (Array.isArray(goals) ? goals.length : 0);
193
+ }, 0);
194
+ }
195
+
196
+ function formatRepo(repo) {
197
+ const branch = repo?.branch || "unknown";
198
+ const head = repo?.head ? String(repo.head).slice(0, 12) : "unknown";
199
+ const dirty = repo?.dirty ? "dirty" : "clean";
200
+ return `${repo?.root || "unknown"} (${branch}@${head}, ${dirty})`;
201
+ }
202
+
203
+ function formatLedgerEntry(entry) {
204
+ const ts = entry?.ts || "?";
205
+ const event = entry?.event || "event";
206
+ const summary = entry?.summary ? ` - ${entry.summary}` : "";
207
+ return `${ts} ${event}${summary}`;
208
+ }
209
+
210
+ function renderHumanStatus(status) {
211
+ const { available, total } = countAvailableSources(status.sources);
212
+ const activeGoals = countActiveGoals(status);
213
+ const recent = Array.isArray(status.ledger_tail)
214
+ ? status.ledger_tail.slice(-2)
215
+ : [];
216
+ const lines = [
217
+ `repo: ${formatRepo(status.repo)}`,
218
+ `sources: ${available}/${total} available`,
219
+ `active: ${status.live_sessions.length} sessions, ${status.active_shards.length} shards, ${activeGoals} goals`,
220
+ ];
221
+ if (recent.length) {
222
+ lines.push("ledger:");
223
+ for (const entry of recent) lines.push(`- ${formatLedgerEntry(entry)}`);
224
+ } else {
225
+ lines.push("ledger: no recent events");
226
+ }
227
+ return `${lines.join("\n")}\n`;
228
+ }
229
+
230
+ export async function runStatus(args = [], opts = {}) {
231
+ const rootDir = opts.rootDir || process.cwd();
232
+ const lakeRoot = opts.lakeRoot || join(rootDir, ".triflux", "lake");
233
+ const stdout = opts.stdout || process.stdout;
234
+ const jsonOut = opts.json === true || hasFlag(args, "--json");
235
+ const currentPath = join(lakeRoot, "current.json");
236
+
237
+ if (!existsSync(currentPath)) {
238
+ const missing = {
239
+ schema_version: SCHEMA_VERSION,
240
+ available: false,
241
+ hint: "run tfx cto collect",
242
+ };
243
+ if (jsonOut) writeJson(stdout, missing);
244
+ else stdout.write("cto status unavailable: run tfx cto collect\n");
245
+ return missing;
246
+ }
247
+
248
+ const current = readJson(currentPath);
249
+ const overlay = await readSynapseOverlay({
250
+ ...opts,
251
+ rootDir,
252
+ lakeRoot,
253
+ });
254
+ const status = projectStatus(current, overlay);
255
+
256
+ if (jsonOut) writeJson(stdout, status);
257
+ else stdout.write(renderHumanStatus(status));
258
+
259
+ return status;
260
+ }
@@ -0,0 +1,242 @@
1
+ // hub/hub-lifecycle.mjs — Hub process lifecycle helpers
2
+
3
+ import { execFileSync } from "node:child_process";
4
+ import { readFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join } from "node:path";
7
+
8
+ export const HUB_DEFAULT_PORT = 27888;
9
+
10
+ const HUB_PID_FILE = join(homedir(), ".claude", "cache", "tfx-hub", "hub.pid");
11
+ const EPHEMERAL_ENV_KEYS = [
12
+ "TFX_WORKER_SANDBOX_SCOPE",
13
+ "TFX_WORKER_INDEX",
14
+ "TFX_TEAM_TASK_ID",
15
+ "TFX_TEAM_AGENT_NAME",
16
+ "TFX_EPHEMERAL",
17
+ ];
18
+
19
+ function parsePositiveInt(value) {
20
+ const parsed = Number.parseInt(String(value ?? ""), 10);
21
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
22
+ }
23
+
24
+ function parsePortFromUrl(value) {
25
+ try {
26
+ return parsePositiveInt(new URL(String(value)).port);
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export function isHubServerCommand(command) {
33
+ return /(^|[\\/,\s])hub[\\/]server\.mjs(?=$|[\s"'`])/i.test(
34
+ String(command || ""),
35
+ );
36
+ }
37
+
38
+ export function isWorktreeOrEphemeralHubContext({
39
+ cwd = process.cwd(),
40
+ env = process.env,
41
+ } = {}) {
42
+ const normalizedCwd = String(cwd || "").replace(/\\/g, "/");
43
+ if (
44
+ normalizedCwd.includes("/.claude/worktrees/") ||
45
+ normalizedCwd.includes("/.worktrees/") ||
46
+ normalizedCwd.includes("/.codex-swarm/wt-") ||
47
+ /(^|\/)wt-[^/]+(?:\/|$)/u.test(normalizedCwd)
48
+ ) {
49
+ return true;
50
+ }
51
+ return EPHEMERAL_ENV_KEYS.some((key) => String(env?.[key] || "").length > 0);
52
+ }
53
+
54
+ export function resolveHubPortForContext({
55
+ port,
56
+ env = process.env,
57
+ cwd = process.cwd(),
58
+ defaultPort = HUB_DEFAULT_PORT,
59
+ } = {}) {
60
+ const envPort = parsePositiveInt(port) ?? parsePositiveInt(env?.TFX_HUB_PORT);
61
+ const urlPort = parsePortFromUrl(env?.TFX_HUB_URL);
62
+ const resolvedPort = envPort ?? urlPort ?? defaultPort;
63
+ if (
64
+ resolvedPort !== defaultPort &&
65
+ isWorktreeOrEphemeralHubContext({ cwd, env })
66
+ ) {
67
+ return defaultPort;
68
+ }
69
+ return resolvedPort;
70
+ }
71
+
72
+ export function collectHubProcesses(
73
+ psOutput = "",
74
+ { currentPid = process.pid } = {},
75
+ ) {
76
+ const ownPid = Number(currentPid);
77
+ return String(psOutput)
78
+ .split(/\r?\n/u)
79
+ .flatMap((line) => {
80
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/u);
81
+ if (!match) return [];
82
+ const pid = Number.parseInt(match[1], 10);
83
+ const ppid = Number.parseInt(match[2], 10);
84
+ const command = match[3].trim();
85
+ if (!Number.isFinite(pid) || pid === ownPid) return [];
86
+ if (!/\bnode(?:\s|$)|\/node(?:\s|$)/u.test(command)) return [];
87
+ if (!isHubServerCommand(command)) return [];
88
+ return [{ pid, ppid, command }];
89
+ });
90
+ }
91
+
92
+ function readPidFilePid({
93
+ pidFilePath = HUB_PID_FILE,
94
+ readFile = readFileSync,
95
+ } = {}) {
96
+ try {
97
+ const info = JSON.parse(readFile(pidFilePath, "utf8"));
98
+ const pid = Number(info?.pid);
99
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ function findListeningPidsForPort(
106
+ port = HUB_DEFAULT_PORT,
107
+ { execFileSyncFn = execFileSync } = {},
108
+ ) {
109
+ try {
110
+ const output = execFileSyncFn(
111
+ "lsof",
112
+ ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"],
113
+ {
114
+ encoding: "utf8",
115
+ timeout: 1000,
116
+ maxBuffer: 1024 * 1024,
117
+ },
118
+ );
119
+ return String(output)
120
+ .split(/\r?\n/u)
121
+ .map((line) => Number.parseInt(line.trim(), 10))
122
+ .filter((pid) => Number.isFinite(pid) && pid > 0);
123
+ } catch {
124
+ return [];
125
+ }
126
+ }
127
+
128
+ async function waitForExit(
129
+ pid,
130
+ { killFn = process.kill, graceMs = 5000, pollMs = 100 } = {},
131
+ ) {
132
+ const deadline = Date.now() + Math.max(0, graceMs);
133
+ while (Date.now() <= deadline) {
134
+ try {
135
+ killFn(pid, 0);
136
+ } catch {
137
+ return true;
138
+ }
139
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
140
+ }
141
+ try {
142
+ killFn(pid, 0);
143
+ return false;
144
+ } catch {
145
+ return true;
146
+ }
147
+ }
148
+
149
+ export async function reapExistingHubProcesses({
150
+ currentPid = process.pid,
151
+ pidFilePath = HUB_PID_FILE,
152
+ execFileSyncFn = execFileSync,
153
+ killFn = process.kill,
154
+ readPidFileFn,
155
+ findListeningPidsForPortFn,
156
+ waitForExitFn,
157
+ graceMs = 5000,
158
+ pollMs = 100,
159
+ } = {}) {
160
+ let output = "";
161
+ try {
162
+ output = execFileSyncFn("ps", ["-axo", "pid,ppid,command"], {
163
+ encoding: "utf8",
164
+ timeout: 1000,
165
+ maxBuffer: 1024 * 1024,
166
+ });
167
+ } catch {
168
+ return {
169
+ candidates: [],
170
+ reaped: [],
171
+ preserved: {
172
+ currentPid: Number(currentPid),
173
+ pidFilePid: null,
174
+ defaultPortPids: [],
175
+ },
176
+ };
177
+ }
178
+
179
+ const current = Number(currentPid);
180
+ const pidFileInfo =
181
+ typeof readPidFileFn === "function"
182
+ ? readPidFileFn()
183
+ : { pid: readPidFilePid({ pidFilePath }) };
184
+ const pidFilePid = parsePositiveInt(pidFileInfo?.pid);
185
+ const defaultPortPids = [
186
+ ...new Set(
187
+ (typeof findListeningPidsForPortFn === "function"
188
+ ? findListeningPidsForPortFn(HUB_DEFAULT_PORT)
189
+ : findListeningPidsForPort(HUB_DEFAULT_PORT, { execFileSyncFn })
190
+ ).map(Number),
191
+ ),
192
+ ].filter((pid) => Number.isFinite(pid) && pid > 0);
193
+
194
+ const preservedPids = new Set(
195
+ [current, pidFilePid, ...defaultPortPids].filter(
196
+ (pid) => Number.isFinite(pid) && pid > 0,
197
+ ),
198
+ );
199
+ const candidates = collectHubProcesses(output, { currentPid: current });
200
+ const reaped = [];
201
+ const failed = [];
202
+
203
+ for (const processInfo of candidates) {
204
+ if (preservedPids.has(processInfo.pid)) continue;
205
+ try {
206
+ killFn(processInfo.pid, "SIGTERM");
207
+ const exited =
208
+ typeof waitForExitFn === "function"
209
+ ? await waitForExitFn(processInfo.pid, { killFn, graceMs, pollMs })
210
+ : await waitForExit(processInfo.pid, { killFn, graceMs, pollMs });
211
+ if (!exited) {
212
+ try {
213
+ killFn(processInfo.pid, "SIGKILL");
214
+ } catch (error) {
215
+ failed.push({
216
+ pid: processInfo.pid,
217
+ signal: "SIGKILL",
218
+ error: String(error?.message || error),
219
+ });
220
+ }
221
+ }
222
+ reaped.push(processInfo);
223
+ } catch (error) {
224
+ failed.push({
225
+ pid: processInfo.pid,
226
+ signal: "SIGTERM",
227
+ error: String(error?.message || error),
228
+ });
229
+ }
230
+ }
231
+
232
+ return {
233
+ candidates,
234
+ reaped,
235
+ failed,
236
+ preserved: {
237
+ currentPid: current,
238
+ pidFilePid,
239
+ defaultPortPids,
240
+ },
241
+ };
242
+ }