copilot-hub 0.1.31 → 0.1.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-hub",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "description": "Copilot Hub CLI and runtime bundle",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -7,6 +7,7 @@ import { createInterface } from "node:readline/promises";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { codexInstallPackageSpec } from "./codex-version.mjs";
9
9
  import { initializeCopilotHubLayout, resolveCopilotHubLayout } from "./install-layout.mjs";
10
+ import { isManagedProcessRunning, isProcessRunning, normalizePid } from "./process-identity.mjs";
10
11
  import { buildCodexCompatibilityError, probeCodexVersion, resolveCodexBinForStart, resolveCompatibleInstalledCodexBin, } from "./codex-runtime.mjs";
11
12
  const __filename = fileURLToPath(import.meta.url);
12
13
  const __dirname = path.dirname(__filename);
@@ -124,6 +125,8 @@ async function runDaemonLoop() {
124
125
  pid: process.pid,
125
126
  startedAt: new Date().toISOString(),
126
127
  command: `${nodeBin} ${daemonScriptPath} run`,
128
+ executablePath: nodeBin,
129
+ entryScript: daemonScriptPath,
127
130
  });
128
131
  const state = { stopping: false, shuttingDown: false };
129
132
  setupSignalHandlers(state);
@@ -191,8 +194,9 @@ async function stopDaemonProcess() {
191
194
  console.log("[daemon] not running.");
192
195
  return;
193
196
  }
197
+ const state = readDaemonState();
194
198
  await terminateProcess(pid);
195
- if (isProcessRunning(pid)) {
199
+ if (isManagedProcessRunning(state)) {
196
200
  throw new Error(`Daemon did not stop cleanly (pid ${pid}).`);
197
201
  }
198
202
  removeDaemonState();
@@ -278,7 +282,7 @@ function getRunningDaemonPid() {
278
282
  if (pid <= 0) {
279
283
  return 0;
280
284
  }
281
- return isProcessRunning(pid) ? pid : 0;
285
+ return isManagedProcessRunning(state) ? pid : 0;
282
286
  }
283
287
  function readDaemonState() {
284
288
  if (!fs.existsSync(daemonStatePath)) {
@@ -301,28 +305,6 @@ function removeDaemonState() {
301
305
  }
302
306
  fs.rmSync(daemonStatePath, { force: true });
303
307
  }
304
- function normalizePid(value) {
305
- const pid = Number.parseInt(String(value ?? ""), 10);
306
- if (!Number.isFinite(pid) || pid <= 0) {
307
- return 0;
308
- }
309
- return pid;
310
- }
311
- function isProcessRunning(pid) {
312
- if (!Number.isInteger(pid) || pid <= 0) {
313
- return false;
314
- }
315
- try {
316
- process.kill(pid, 0);
317
- return true;
318
- }
319
- catch (error) {
320
- if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
321
- return true;
322
- }
323
- return false;
324
- }
325
- }
326
308
  async function terminateProcess(pid) {
327
309
  if (process.platform === "win32") {
328
310
  await killTreeWindows(pid);
@@ -0,0 +1,226 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { spawnSync } from "node:child_process";
4
+ const PROCESS_START_TOLERANCE_MS = 120_000;
5
+ export function normalizePid(value) {
6
+ const pid = Number.parseInt(String(value ?? ""), 10);
7
+ if (!Number.isFinite(pid) || pid <= 0) {
8
+ return 0;
9
+ }
10
+ return pid;
11
+ }
12
+ export function isManagedProcessRunning(state, platform = process.platform) {
13
+ const pid = normalizePid(state?.pid);
14
+ if (pid <= 0) {
15
+ return false;
16
+ }
17
+ if (!isProcessRunning(pid)) {
18
+ return false;
19
+ }
20
+ const identity = inspectProcess(pid, platform);
21
+ if (!identity) {
22
+ return true;
23
+ }
24
+ return matchesManagedProcessState(state, identity, platform);
25
+ }
26
+ export function matchesManagedProcessState(state, identity, platform = process.platform) {
27
+ const pid = normalizePid(state?.pid);
28
+ if (pid <= 0 || !identity) {
29
+ return false;
30
+ }
31
+ if (normalizePid(identity.pid) !== pid) {
32
+ return false;
33
+ }
34
+ const checks = [];
35
+ const expectedScript = normalizeCommandToken(state?.entryScript ?? extractScriptToken(state?.command), platform);
36
+ if (expectedScript) {
37
+ checks.push(commandLineContains(identity.commandLine, expectedScript, platform));
38
+ }
39
+ const expectedExecutablePath = normalizeCommandToken(state?.executablePath ?? extractExecutableToken(state?.command), platform);
40
+ if (expectedExecutablePath) {
41
+ const actualExecutablePath = normalizeCommandToken(identity.executablePath, platform);
42
+ if (actualExecutablePath) {
43
+ checks.push(actualExecutablePath === expectedExecutablePath ||
44
+ pathBasename(actualExecutablePath, platform) ===
45
+ pathBasename(expectedExecutablePath, platform));
46
+ }
47
+ else {
48
+ checks.push(commandLineContains(identity.commandLine, expectedExecutablePath, platform));
49
+ }
50
+ }
51
+ const expectedStartedAt = parseTimestamp(state?.startedAt);
52
+ const actualStartedAt = parseTimestamp(identity.startedAt);
53
+ if (expectedStartedAt !== null && actualStartedAt !== null) {
54
+ checks.push(Math.abs(actualStartedAt - expectedStartedAt) <= PROCESS_START_TOLERANCE_MS);
55
+ }
56
+ if (checks.length === 0) {
57
+ return true;
58
+ }
59
+ return checks.every(Boolean);
60
+ }
61
+ export function isProcessRunning(pid) {
62
+ if (!Number.isInteger(pid) || pid <= 0) {
63
+ return false;
64
+ }
65
+ try {
66
+ process.kill(pid, 0);
67
+ return true;
68
+ }
69
+ catch (error) {
70
+ if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
71
+ return true;
72
+ }
73
+ return false;
74
+ }
75
+ }
76
+ function inspectProcess(pid, platform) {
77
+ if (platform === "win32") {
78
+ return inspectWindowsProcess(pid);
79
+ }
80
+ if (platform === "linux" || platform === "darwin") {
81
+ return inspectPosixProcess(pid);
82
+ }
83
+ return null;
84
+ }
85
+ function inspectWindowsProcess(pid) {
86
+ const script = [
87
+ `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction SilentlyContinue`,
88
+ "if ($null -eq $p) { exit 3 }",
89
+ "$created = ''",
90
+ "if ($p.CreationDate) {",
91
+ " try { $created = $p.CreationDate.ToString('o') } catch { $created = '' }",
92
+ "}",
93
+ "[pscustomobject]@{",
94
+ " pid = $p.ProcessId",
95
+ " commandLine = $p.CommandLine",
96
+ " executablePath = $p.ExecutablePath",
97
+ " startedAt = $created",
98
+ "} | ConvertTo-Json -Compress",
99
+ ].join("\n");
100
+ for (const shell of resolveWindowsPowerShellCandidates()) {
101
+ const result = spawnSync(shell, ["-NoProfile", "-NonInteractive", "-Command", script], {
102
+ encoding: "utf8",
103
+ windowsHide: true,
104
+ shell: false,
105
+ });
106
+ if (result.error || result.status !== 0) {
107
+ continue;
108
+ }
109
+ const stdout = String(result.stdout ?? "").trim();
110
+ if (!stdout) {
111
+ continue;
112
+ }
113
+ try {
114
+ const parsed = JSON.parse(stdout);
115
+ return {
116
+ pid: normalizePid(parsed.pid),
117
+ commandLine: String(parsed.commandLine ?? ""),
118
+ executablePath: normalizeOptionalString(parsed.executablePath),
119
+ startedAt: normalizeOptionalString(parsed.startedAt),
120
+ };
121
+ }
122
+ catch {
123
+ continue;
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+ function inspectPosixProcess(pid) {
129
+ const commandResult = spawnSync("ps", ["-p", String(pid), "-o", "args="], {
130
+ encoding: "utf8",
131
+ shell: false,
132
+ });
133
+ if (commandResult.error || commandResult.status !== 0) {
134
+ return null;
135
+ }
136
+ const commandLine = String(commandResult.stdout ?? "").trim();
137
+ if (!commandLine) {
138
+ return null;
139
+ }
140
+ const startedAtResult = spawnSync("ps", ["-p", String(pid), "-o", "lstart="], {
141
+ encoding: "utf8",
142
+ shell: false,
143
+ });
144
+ const executablePathResult = spawnSync("ps", ["-p", String(pid), "-o", "comm="], {
145
+ encoding: "utf8",
146
+ shell: false,
147
+ });
148
+ return {
149
+ pid,
150
+ commandLine,
151
+ executablePath: normalizeOptionalString(executablePathResult.stdout),
152
+ startedAt: normalizeOptionalString(startedAtResult.stdout),
153
+ };
154
+ }
155
+ function resolveWindowsPowerShellCandidates() {
156
+ const candidates = [
157
+ path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
158
+ "powershell.exe",
159
+ "pwsh.exe",
160
+ ];
161
+ return [...new Set(candidates)];
162
+ }
163
+ function extractScriptToken(value) {
164
+ const tokens = tokenizeCommandLine(value);
165
+ for (const token of tokens) {
166
+ if (/\.(?:[cm]?js|mts)$/i.test(token)) {
167
+ return token;
168
+ }
169
+ }
170
+ return null;
171
+ }
172
+ function extractExecutableToken(value) {
173
+ const [first] = tokenizeCommandLine(value);
174
+ return first ? normalizeOptionalString(first) : null;
175
+ }
176
+ function tokenizeCommandLine(value) {
177
+ const text = String(value ?? "").trim();
178
+ if (!text) {
179
+ return [];
180
+ }
181
+ const tokens = [];
182
+ const pattern = /"([^"]*)"|'([^']*)'|[^\s]+/g;
183
+ let match = null;
184
+ while ((match = pattern.exec(text)) !== null) {
185
+ const token = match[1] ?? match[2] ?? match[0] ?? "";
186
+ const normalized = normalizeOptionalString(token);
187
+ if (normalized) {
188
+ tokens.push(normalized);
189
+ }
190
+ }
191
+ return tokens;
192
+ }
193
+ function parseTimestamp(value) {
194
+ const text = normalizeOptionalString(value);
195
+ if (!text) {
196
+ return null;
197
+ }
198
+ const timestamp = Date.parse(text);
199
+ if (!Number.isFinite(timestamp)) {
200
+ return null;
201
+ }
202
+ return timestamp;
203
+ }
204
+ function normalizeOptionalString(value) {
205
+ const normalized = String(value ?? "").trim();
206
+ return normalized ? normalized : null;
207
+ }
208
+ function normalizeCommandToken(value, platform) {
209
+ const normalized = normalizeOptionalString(value);
210
+ if (!normalized) {
211
+ return null;
212
+ }
213
+ const unquoted = normalized.replace(/^["']+|["']+$/g, "");
214
+ const slashed = platform === "win32" ? unquoted.replace(/\\/g, "/") : unquoted;
215
+ return platform === "win32" ? slashed.toLowerCase() : slashed;
216
+ }
217
+ function commandLineContains(commandLine, expectedToken, platform) {
218
+ const normalizedCommandLine = normalizeCommandToken(commandLine, platform);
219
+ if (!normalizedCommandLine) {
220
+ return false;
221
+ }
222
+ return normalizedCommandLine.includes(expectedToken);
223
+ }
224
+ function pathBasename(value, platform) {
225
+ return (platform === "win32" ? path.win32.basename(value) : path.posix.basename(value)).toLowerCase();
226
+ }
@@ -5,6 +5,7 @@ import process from "node:process";
5
5
  import { spawn } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { initializeCopilotHubLayout, resolveCopilotHubLayout } from "./install-layout.mjs";
8
+ import { isManagedProcessRunning, isProcessRunning, normalizePid } from "./process-identity.mjs";
8
9
  const __filename = fileURLToPath(import.meta.url);
9
10
  const __dirname = path.dirname(__filename);
10
11
  const repoRoot = path.resolve(__dirname, "..", "..");
@@ -106,7 +107,7 @@ function showStatus() {
106
107
  for (const service of SERVICES) {
107
108
  const state = readState(service);
108
109
  const pid = normalizePid(state?.pid);
109
- const running = pid > 0 && isProcessRunning(pid);
110
+ const running = pid > 0 && isManagedProcessRunning(state);
110
111
  if (state && !running) {
111
112
  removeState(service);
112
113
  }
@@ -127,7 +128,7 @@ async function startService(service, options = {}) {
127
128
  const suppressAlreadyRunning = options?.suppressAlreadyRunning === true;
128
129
  const existing = readState(service);
129
130
  const existingPid = normalizePid(existing?.pid);
130
- if (existingPid > 0 && isProcessRunning(existingPid)) {
131
+ if (existingPid > 0 && isManagedProcessRunning(existing)) {
131
132
  if (!suppressAlreadyRunning) {
132
133
  console.log(`[${service.id}] already running (pid ${existingPid})`);
133
134
  }
@@ -163,9 +164,11 @@ async function startService(service, options = {}) {
163
164
  pid,
164
165
  startedAt: new Date().toISOString(),
165
166
  command: `${process.execPath} ${service.entryScript}`,
167
+ executablePath: process.execPath,
168
+ entryScript: service.entryScript,
166
169
  });
167
170
  await sleep(250);
168
- if (!isProcessRunning(pid)) {
171
+ if (!isManagedProcessRunning(readState(service))) {
169
172
  removeState(service);
170
173
  console.error(`[${service.id}] exited immediately. Check logs: ${service.logFile}`);
171
174
  return false;
@@ -185,7 +188,7 @@ async function stopService(service) {
185
188
  console.log(`[${service.id}] removed invalid pid file`);
186
189
  return;
187
190
  }
188
- if (!isProcessRunning(pid)) {
191
+ if (!isManagedProcessRunning(state)) {
189
192
  removeState(service);
190
193
  console.log(`[${service.id}] not running (stale pid ${pid})`);
191
194
  return;
@@ -267,28 +270,6 @@ function removeState(service) {
267
270
  fs.rmSync(service.pidFile, { force: true });
268
271
  }
269
272
  }
270
- function normalizePid(value) {
271
- const pid = Number.parseInt(String(value ?? ""), 10);
272
- if (!Number.isFinite(pid) || pid <= 0) {
273
- return 0;
274
- }
275
- return pid;
276
- }
277
- function isProcessRunning(pid) {
278
- if (!Number.isInteger(pid) || pid <= 0) {
279
- return false;
280
- }
281
- try {
282
- process.kill(pid, 0);
283
- return true;
284
- }
285
- catch (error) {
286
- if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
287
- return true;
288
- }
289
- return false;
290
- }
291
- }
292
273
  function ensureRuntimeDirs() {
293
274
  fs.mkdirSync(runtimeDir, { recursive: true });
294
275
  fs.mkdirSync(pidsDir, { recursive: true });
@@ -7,6 +7,7 @@ import { createInterface } from "node:readline/promises";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { codexInstallPackageSpec } from "./codex-version.mjs";
9
9
  import { initializeCopilotHubLayout, resolveCopilotHubLayout } from "./install-layout.mjs";
10
+ import { isManagedProcessRunning, isProcessRunning, normalizePid } from "./process-identity.mjs";
10
11
  import {
11
12
  buildCodexCompatibilityError,
12
13
  probeCodexVersion,
@@ -147,6 +148,8 @@ async function runDaemonLoop() {
147
148
  pid: process.pid,
148
149
  startedAt: new Date().toISOString(),
149
150
  command: `${nodeBin} ${daemonScriptPath} run`,
151
+ executablePath: nodeBin,
152
+ entryScript: daemonScriptPath,
150
153
  });
151
154
 
152
155
  const state = { stopping: false, shuttingDown: false };
@@ -227,8 +230,9 @@ async function stopDaemonProcess() {
227
230
  return;
228
231
  }
229
232
 
233
+ const state = readDaemonState();
230
234
  await terminateProcess(pid);
231
- if (isProcessRunning(pid)) {
235
+ if (isManagedProcessRunning(state)) {
232
236
  throw new Error(`Daemon did not stop cleanly (pid ${pid}).`);
233
237
  }
234
238
 
@@ -336,7 +340,7 @@ function getRunningDaemonPid() {
336
340
  if (pid <= 0) {
337
341
  return 0;
338
342
  }
339
- return isProcessRunning(pid) ? pid : 0;
343
+ return isManagedProcessRunning(state) ? pid : 0;
340
344
  }
341
345
 
342
346
  function readDaemonState() {
@@ -362,29 +366,6 @@ function removeDaemonState() {
362
366
  fs.rmSync(daemonStatePath, { force: true });
363
367
  }
364
368
 
365
- function normalizePid(value) {
366
- const pid = Number.parseInt(String(value ?? ""), 10);
367
- if (!Number.isFinite(pid) || pid <= 0) {
368
- return 0;
369
- }
370
- return pid;
371
- }
372
-
373
- function isProcessRunning(pid) {
374
- if (!Number.isInteger(pid) || pid <= 0) {
375
- return false;
376
- }
377
- try {
378
- process.kill(pid, 0);
379
- return true;
380
- } catch (error) {
381
- if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
382
- return true;
383
- }
384
- return false;
385
- }
386
- }
387
-
388
369
  async function terminateProcess(pid) {
389
370
  if (process.platform === "win32") {
390
371
  await killTreeWindows(pid);
@@ -0,0 +1,296 @@
1
+ import path from "node:path";
2
+ import process from "node:process";
3
+ import { spawnSync } from "node:child_process";
4
+
5
+ const PROCESS_START_TOLERANCE_MS = 120_000;
6
+
7
+ export type ManagedProcessState = {
8
+ pid?: unknown;
9
+ startedAt?: unknown;
10
+ command?: unknown;
11
+ executablePath?: unknown;
12
+ entryScript?: unknown;
13
+ };
14
+
15
+ type ProcessIdentity = {
16
+ pid: number;
17
+ commandLine: string;
18
+ executablePath: string | null;
19
+ startedAt: string | null;
20
+ };
21
+
22
+ export function normalizePid(value: unknown): number {
23
+ const pid = Number.parseInt(String(value ?? ""), 10);
24
+ if (!Number.isFinite(pid) || pid <= 0) {
25
+ return 0;
26
+ }
27
+ return pid;
28
+ }
29
+
30
+ export function isManagedProcessRunning(
31
+ state: ManagedProcessState | null,
32
+ platform: NodeJS.Platform = process.platform,
33
+ ): boolean {
34
+ const pid = normalizePid(state?.pid);
35
+ if (pid <= 0) {
36
+ return false;
37
+ }
38
+ if (!isProcessRunning(pid)) {
39
+ return false;
40
+ }
41
+
42
+ const identity = inspectProcess(pid, platform);
43
+ if (!identity) {
44
+ return true;
45
+ }
46
+
47
+ return matchesManagedProcessState(state, identity, platform);
48
+ }
49
+
50
+ export function matchesManagedProcessState(
51
+ state: ManagedProcessState | null,
52
+ identity: ProcessIdentity | null,
53
+ platform: NodeJS.Platform = process.platform,
54
+ ): boolean {
55
+ const pid = normalizePid(state?.pid);
56
+ if (pid <= 0 || !identity) {
57
+ return false;
58
+ }
59
+ if (normalizePid(identity.pid) !== pid) {
60
+ return false;
61
+ }
62
+
63
+ const checks: boolean[] = [];
64
+ const expectedScript = normalizeCommandToken(
65
+ state?.entryScript ?? extractScriptToken(state?.command),
66
+ platform,
67
+ );
68
+ if (expectedScript) {
69
+ checks.push(commandLineContains(identity.commandLine, expectedScript, platform));
70
+ }
71
+
72
+ const expectedExecutablePath = normalizeCommandToken(
73
+ state?.executablePath ?? extractExecutableToken(state?.command),
74
+ platform,
75
+ );
76
+ if (expectedExecutablePath) {
77
+ const actualExecutablePath = normalizeCommandToken(identity.executablePath, platform);
78
+ if (actualExecutablePath) {
79
+ checks.push(
80
+ actualExecutablePath === expectedExecutablePath ||
81
+ pathBasename(actualExecutablePath, platform) ===
82
+ pathBasename(expectedExecutablePath, platform),
83
+ );
84
+ } else {
85
+ checks.push(commandLineContains(identity.commandLine, expectedExecutablePath, platform));
86
+ }
87
+ }
88
+
89
+ const expectedStartedAt = parseTimestamp(state?.startedAt);
90
+ const actualStartedAt = parseTimestamp(identity.startedAt);
91
+ if (expectedStartedAt !== null && actualStartedAt !== null) {
92
+ checks.push(Math.abs(actualStartedAt - expectedStartedAt) <= PROCESS_START_TOLERANCE_MS);
93
+ }
94
+
95
+ if (checks.length === 0) {
96
+ return true;
97
+ }
98
+ return checks.every(Boolean);
99
+ }
100
+
101
+ export function isProcessRunning(pid: number): boolean {
102
+ if (!Number.isInteger(pid) || pid <= 0) {
103
+ return false;
104
+ }
105
+
106
+ try {
107
+ process.kill(pid, 0);
108
+ return true;
109
+ } catch (error) {
110
+ if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
111
+ return true;
112
+ }
113
+ return false;
114
+ }
115
+ }
116
+
117
+ function inspectProcess(pid: number, platform: NodeJS.Platform): ProcessIdentity | null {
118
+ if (platform === "win32") {
119
+ return inspectWindowsProcess(pid);
120
+ }
121
+ if (platform === "linux" || platform === "darwin") {
122
+ return inspectPosixProcess(pid);
123
+ }
124
+ return null;
125
+ }
126
+
127
+ function inspectWindowsProcess(pid: number): ProcessIdentity | null {
128
+ const script = [
129
+ `$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}" -ErrorAction SilentlyContinue`,
130
+ "if ($null -eq $p) { exit 3 }",
131
+ "$created = ''",
132
+ "if ($p.CreationDate) {",
133
+ " try { $created = $p.CreationDate.ToString('o') } catch { $created = '' }",
134
+ "}",
135
+ "[pscustomobject]@{",
136
+ " pid = $p.ProcessId",
137
+ " commandLine = $p.CommandLine",
138
+ " executablePath = $p.ExecutablePath",
139
+ " startedAt = $created",
140
+ "} | ConvertTo-Json -Compress",
141
+ ].join("\n");
142
+
143
+ for (const shell of resolveWindowsPowerShellCandidates()) {
144
+ const result = spawnSync(shell, ["-NoProfile", "-NonInteractive", "-Command", script], {
145
+ encoding: "utf8",
146
+ windowsHide: true,
147
+ shell: false,
148
+ });
149
+ if (result.error || result.status !== 0) {
150
+ continue;
151
+ }
152
+ const stdout = String(result.stdout ?? "").trim();
153
+ if (!stdout) {
154
+ continue;
155
+ }
156
+ try {
157
+ const parsed = JSON.parse(stdout) as Record<string, unknown>;
158
+ return {
159
+ pid: normalizePid(parsed.pid),
160
+ commandLine: String(parsed.commandLine ?? ""),
161
+ executablePath: normalizeOptionalString(parsed.executablePath),
162
+ startedAt: normalizeOptionalString(parsed.startedAt),
163
+ };
164
+ } catch {
165
+ continue;
166
+ }
167
+ }
168
+
169
+ return null;
170
+ }
171
+
172
+ function inspectPosixProcess(pid: number): ProcessIdentity | null {
173
+ const commandResult = spawnSync("ps", ["-p", String(pid), "-o", "args="], {
174
+ encoding: "utf8",
175
+ shell: false,
176
+ });
177
+ if (commandResult.error || commandResult.status !== 0) {
178
+ return null;
179
+ }
180
+
181
+ const commandLine = String(commandResult.stdout ?? "").trim();
182
+ if (!commandLine) {
183
+ return null;
184
+ }
185
+
186
+ const startedAtResult = spawnSync("ps", ["-p", String(pid), "-o", "lstart="], {
187
+ encoding: "utf8",
188
+ shell: false,
189
+ });
190
+ const executablePathResult = spawnSync("ps", ["-p", String(pid), "-o", "comm="], {
191
+ encoding: "utf8",
192
+ shell: false,
193
+ });
194
+
195
+ return {
196
+ pid,
197
+ commandLine,
198
+ executablePath: normalizeOptionalString(executablePathResult.stdout),
199
+ startedAt: normalizeOptionalString(startedAtResult.stdout),
200
+ };
201
+ }
202
+
203
+ function resolveWindowsPowerShellCandidates(): string[] {
204
+ const candidates = [
205
+ path.join(
206
+ process.env.SystemRoot ?? "C:\\Windows",
207
+ "System32",
208
+ "WindowsPowerShell",
209
+ "v1.0",
210
+ "powershell.exe",
211
+ ),
212
+ "powershell.exe",
213
+ "pwsh.exe",
214
+ ];
215
+ return [...new Set(candidates)];
216
+ }
217
+
218
+ function extractScriptToken(value: unknown): string | null {
219
+ const tokens = tokenizeCommandLine(value);
220
+ for (const token of tokens) {
221
+ if (/\.(?:[cm]?js|mts)$/i.test(token)) {
222
+ return token;
223
+ }
224
+ }
225
+ return null;
226
+ }
227
+
228
+ function extractExecutableToken(value: unknown): string | null {
229
+ const [first] = tokenizeCommandLine(value);
230
+ return first ? normalizeOptionalString(first) : null;
231
+ }
232
+
233
+ function tokenizeCommandLine(value: unknown): string[] {
234
+ const text = String(value ?? "").trim();
235
+ if (!text) {
236
+ return [];
237
+ }
238
+
239
+ const tokens: string[] = [];
240
+ const pattern = /"([^"]*)"|'([^']*)'|[^\s]+/g;
241
+ let match: RegExpExecArray | null = null;
242
+ while ((match = pattern.exec(text)) !== null) {
243
+ const token = match[1] ?? match[2] ?? match[0] ?? "";
244
+ const normalized = normalizeOptionalString(token);
245
+ if (normalized) {
246
+ tokens.push(normalized);
247
+ }
248
+ }
249
+ return tokens;
250
+ }
251
+
252
+ function parseTimestamp(value: unknown): number | null {
253
+ const text = normalizeOptionalString(value);
254
+ if (!text) {
255
+ return null;
256
+ }
257
+ const timestamp = Date.parse(text);
258
+ if (!Number.isFinite(timestamp)) {
259
+ return null;
260
+ }
261
+ return timestamp;
262
+ }
263
+
264
+ function normalizeOptionalString(value: unknown): string | null {
265
+ const normalized = String(value ?? "").trim();
266
+ return normalized ? normalized : null;
267
+ }
268
+
269
+ function normalizeCommandToken(value: unknown, platform: NodeJS.Platform): string | null {
270
+ const normalized = normalizeOptionalString(value);
271
+ if (!normalized) {
272
+ return null;
273
+ }
274
+
275
+ const unquoted = normalized.replace(/^["']+|["']+$/g, "");
276
+ const slashed = platform === "win32" ? unquoted.replace(/\\/g, "/") : unquoted;
277
+ return platform === "win32" ? slashed.toLowerCase() : slashed;
278
+ }
279
+
280
+ function commandLineContains(
281
+ commandLine: string,
282
+ expectedToken: string,
283
+ platform: NodeJS.Platform,
284
+ ): boolean {
285
+ const normalizedCommandLine = normalizeCommandToken(commandLine, platform);
286
+ if (!normalizedCommandLine) {
287
+ return false;
288
+ }
289
+ return normalizedCommandLine.includes(expectedToken);
290
+ }
291
+
292
+ function pathBasename(value: string, platform: NodeJS.Platform): string {
293
+ return (
294
+ platform === "win32" ? path.win32.basename(value) : path.posix.basename(value)
295
+ ).toLowerCase();
296
+ }
@@ -5,6 +5,7 @@ import process from "node:process";
5
5
  import { spawn } from "node:child_process";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { initializeCopilotHubLayout, resolveCopilotHubLayout } from "./install-layout.mjs";
8
+ import { isManagedProcessRunning, isProcessRunning, normalizePid } from "./process-identity.mjs";
8
9
 
9
10
  const __filename = fileURLToPath(import.meta.url);
10
11
  const __dirname = path.dirname(__filename);
@@ -121,7 +122,7 @@ function showStatus() {
121
122
  for (const service of SERVICES) {
122
123
  const state = readState(service);
123
124
  const pid = normalizePid(state?.pid);
124
- const running = pid > 0 && isProcessRunning(pid);
125
+ const running = pid > 0 && isManagedProcessRunning(state);
125
126
 
126
127
  if (state && !running) {
127
128
  removeState(service);
@@ -147,7 +148,7 @@ async function startService(service, options: { suppressAlreadyRunning?: boolean
147
148
  const suppressAlreadyRunning = options?.suppressAlreadyRunning === true;
148
149
  const existing = readState(service);
149
150
  const existingPid = normalizePid(existing?.pid);
150
- if (existingPid > 0 && isProcessRunning(existingPid)) {
151
+ if (existingPid > 0 && isManagedProcessRunning(existing)) {
151
152
  if (!suppressAlreadyRunning) {
152
153
  console.log(`[${service.id}] already running (pid ${existingPid})`);
153
154
  }
@@ -188,11 +189,13 @@ async function startService(service, options: { suppressAlreadyRunning?: boolean
188
189
  pid,
189
190
  startedAt: new Date().toISOString(),
190
191
  command: `${process.execPath} ${service.entryScript}`,
192
+ executablePath: process.execPath,
193
+ entryScript: service.entryScript,
191
194
  });
192
195
 
193
196
  await sleep(250);
194
197
 
195
- if (!isProcessRunning(pid)) {
198
+ if (!isManagedProcessRunning(readState(service))) {
196
199
  removeState(service);
197
200
  console.error(`[${service.id}] exited immediately. Check logs: ${service.logFile}`);
198
201
  return false;
@@ -216,7 +219,7 @@ async function stopService(service) {
216
219
  return;
217
220
  }
218
221
 
219
- if (!isProcessRunning(pid)) {
222
+ if (!isManagedProcessRunning(state)) {
220
223
  removeState(service);
221
224
  console.log(`[${service.id}] not running (stale pid ${pid})`);
222
225
  return;
@@ -311,30 +314,6 @@ function removeState(service) {
311
314
  }
312
315
  }
313
316
 
314
- function normalizePid(value) {
315
- const pid = Number.parseInt(String(value ?? ""), 10);
316
- if (!Number.isFinite(pid) || pid <= 0) {
317
- return 0;
318
- }
319
- return pid;
320
- }
321
-
322
- function isProcessRunning(pid) {
323
- if (!Number.isInteger(pid) || pid <= 0) {
324
- return false;
325
- }
326
-
327
- try {
328
- process.kill(pid, 0);
329
- return true;
330
- } catch (error) {
331
- if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
332
- return true;
333
- }
334
- return false;
335
- }
336
- }
337
-
338
317
  function ensureRuntimeDirs() {
339
318
  fs.mkdirSync(runtimeDir, { recursive: true });
340
319
  fs.mkdirSync(pidsDir, { recursive: true });
@@ -0,0 +1,61 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { matchesManagedProcessState, normalizePid } from "../dist/process-identity.mjs";
4
+
5
+ test("normalizePid accepts valid positive integers only", () => {
6
+ assert.equal(normalizePid("123"), 123);
7
+ assert.equal(normalizePid(0), 0);
8
+ assert.equal(normalizePid(-1), 0);
9
+ assert.equal(normalizePid("abc"), 0);
10
+ });
11
+
12
+ test("matchesManagedProcessState accepts a matching managed daemon process", () => {
13
+ const state = {
14
+ pid: 2444,
15
+ startedAt: "2026-03-14T16:13:37.301Z",
16
+ executablePath: "C:/Program Files/nodejs/node.exe",
17
+ entryScript: "C:/Users/amine/Desktop/copilot_hub/scripts/dist/daemon.mjs",
18
+ };
19
+ const identity = {
20
+ pid: 2444,
21
+ executablePath: "C:\\Program Files\\nodejs\\node.exe",
22
+ commandLine:
23
+ '"C:\\Program Files\\nodejs\\node.exe" "C:\\Users\\amine\\Desktop\\copilot_hub\\scripts\\dist\\daemon.mjs" run',
24
+ startedAt: "2026-03-14T16:13:37.900Z",
25
+ };
26
+
27
+ assert.equal(matchesManagedProcessState(state, identity, "win32"), true);
28
+ });
29
+
30
+ test("matchesManagedProcessState rejects a reused pid for an unrelated process", () => {
31
+ const state = {
32
+ pid: 2444,
33
+ startedAt: "2026-03-14T16:13:37.301Z",
34
+ command:
35
+ "C:\\Program Files\\nodejs\\node.exe C:\\Users\\amine\\Desktop\\copilot_hub\\scripts\\dist\\daemon.mjs run",
36
+ };
37
+ const identity = {
38
+ pid: 2444,
39
+ executablePath: "C:\\Windows\\System32\\IntelCpHDCPSvc.exe",
40
+ commandLine: "C:\\Windows\\System32\\IntelCpHDCPSvc.exe",
41
+ startedAt: "2026-03-14T16:55:00.000Z",
42
+ };
43
+
44
+ assert.equal(matchesManagedProcessState(state, identity, "win32"), false);
45
+ });
46
+
47
+ test("matchesManagedProcessState supports legacy states that only stored command", () => {
48
+ const state = {
49
+ pid: 17044,
50
+ startedAt: "2026-03-14T12:00:00.000Z",
51
+ command: "/usr/bin/node /opt/copilot-hub/scripts/dist/daemon.mjs run",
52
+ };
53
+ const identity = {
54
+ pid: 17044,
55
+ executablePath: "/usr/bin/node",
56
+ commandLine: "/usr/bin/node /opt/copilot-hub/scripts/dist/daemon.mjs run",
57
+ startedAt: "Fri Mar 14 12:00:00 2026",
58
+ };
59
+
60
+ assert.equal(matchesManagedProcessState(state, identity, "linux"), true);
61
+ });