copilot-hub 0.1.32 → 0.1.34

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.
@@ -4,13 +4,15 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import process from "node:process";
7
- import { spawnSync } from "node:child_process";
7
+ import { spawn, spawnSync } from "node:child_process";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { initializeCopilotHubLayout, resolveCopilotHubLayout } from "./install-layout.mjs";
10
+ import { isManagedProcessRunning, normalizePid } from "./process-identity.mjs";
10
11
  import {
11
12
  buildWindowsHiddenLauncherCommand,
12
13
  ensureWindowsHiddenLauncher,
13
14
  getWindowsHiddenLauncherScriptPath,
15
+ getWindowsHiddenLauncherStopSignalPath,
14
16
  resolveWindowsScriptHost,
15
17
  } from "./windows-hidden-launcher.mjs";
16
18
 
@@ -21,13 +23,17 @@ const layout = resolveCopilotHubLayout({ repoRoot });
21
23
  initializeCopilotHubLayout({ repoRoot, layout });
22
24
  const nodeBin = process.execPath;
23
25
  const daemonScriptPath = path.join(repoRoot, "scripts", "dist", "daemon.mjs");
26
+ const daemonStatePath = path.join(layout.runtimeDir, "pids", "daemon.json");
24
27
  const windowsLauncherScriptPath = getWindowsHiddenLauncherScriptPath(layout.runtimeDir);
28
+ const windowsLauncherStopSignalPath = getWindowsHiddenLauncherStopSignalPath(layout.runtimeDir);
25
29
 
26
30
  const WINDOWS_TASK_NAME = "CopilotHub";
27
31
  const WINDOWS_RUN_KEY_PATH = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
28
32
  const WINDOWS_RUN_VALUE_NAME = "CopilotHub";
29
33
  const LINUX_UNIT_NAME = "copilot-hub.service";
30
34
  const MACOS_LABEL = "com.copilot-hub.service";
35
+ const WINDOWS_LAUNCHER_STOP_TIMEOUT_MS = 12_000;
36
+ const WINDOWS_LAUNCHER_POLL_INTERVAL_MS = 250;
31
37
 
32
38
  const action = String(process.argv[2] ?? "status")
33
39
  .trim()
@@ -70,7 +76,7 @@ async function installService() {
70
76
  ensureDaemonScript();
71
77
 
72
78
  if (process.platform === "win32") {
73
- const mode = installWindowsAutoStart();
79
+ const mode = await installWindowsAutoStart();
74
80
  if (mode === "task") {
75
81
  console.log("Service installed (Windows Task Scheduler).");
76
82
  } else {
@@ -96,7 +102,7 @@ async function installService() {
96
102
 
97
103
  async function uninstallService() {
98
104
  if (process.platform === "win32") {
99
- const removed = uninstallWindowsAutoStart();
105
+ const removed = await uninstallWindowsAutoStart();
100
106
  if (!removed) {
101
107
  console.log("Service auto-start is already absent.");
102
108
  return;
@@ -141,7 +147,7 @@ async function showStatus() {
141
147
 
142
148
  async function startService() {
143
149
  if (process.platform === "win32") {
144
- const mode = startWindowsAutoStart();
150
+ const mode = await startWindowsAutoStart();
145
151
  if (mode === "run-key") {
146
152
  console.log("Service started in background (Windows startup registry entry).");
147
153
  } else {
@@ -166,7 +172,7 @@ async function startService() {
166
172
 
167
173
  async function stopService() {
168
174
  if (process.platform === "win32") {
169
- runDaemon("stop");
175
+ await stopWindowsAutoStart();
170
176
  return;
171
177
  }
172
178
 
@@ -184,7 +190,7 @@ async function stopService() {
184
190
  throw new Error(`Unsupported platform: ${process.platform}`);
185
191
  }
186
192
 
187
- function installWindowsAutoStart() {
193
+ async function installWindowsAutoStart() {
188
194
  ensureCommandAvailable("schtasks", ["/?"], "Windows Task Scheduler is not available.");
189
195
  ensureCommandAvailable(
190
196
  "reg",
@@ -199,7 +205,8 @@ function installWindowsAutoStart() {
199
205
  { allowFailure: true },
200
206
  );
201
207
  if (taskCreate.ok) {
202
- runWindowsTask();
208
+ clearWindowsLauncherStopRequest();
209
+ await ensureWindowsSessionRunning("task");
203
210
  return "task";
204
211
  }
205
212
 
@@ -208,18 +215,25 @@ function installWindowsAutoStart() {
208
215
  }
209
216
 
210
217
  installWindowsRunKey(command);
211
- runWindowsHiddenLauncher();
218
+ clearWindowsLauncherStopRequest();
219
+ await ensureWindowsSessionRunning("run-key");
212
220
  return "run-key";
213
221
  }
214
222
 
215
- function uninstallWindowsAutoStart() {
223
+ async function uninstallWindowsAutoStart() {
216
224
  ensureCommandAvailable("schtasks", ["/?"], "Windows Task Scheduler is not available.");
217
225
  ensureCommandAvailable(
218
226
  "reg",
219
227
  ["query", WINDOWS_RUN_KEY_PATH],
220
228
  "Windows registry tools are not available.",
221
229
  );
222
- runDaemon("stop", { allowFailure: true });
230
+ requestWindowsLauncherStop();
231
+ try {
232
+ runDaemon("stop", { allowFailure: true });
233
+ await waitForWindowsLauncherStopAck();
234
+ } finally {
235
+ clearWindowsLauncherStopRequest();
236
+ }
223
237
 
224
238
  let removed = false;
225
239
 
@@ -296,12 +310,13 @@ function runWindowsTask() {
296
310
  }
297
311
  }
298
312
 
299
- function startWindowsAutoStart() {
313
+ async function startWindowsAutoStart() {
300
314
  const command = buildWindowsLaunchCommand();
301
315
  const runKey = queryWindowsRunKey();
302
316
  if (runKey.installed) {
303
317
  installWindowsRunKey(command);
304
- runWindowsHiddenLauncher();
318
+ clearWindowsLauncherStopRequest();
319
+ await ensureWindowsSessionRunning("run-key");
305
320
  return "run-key";
306
321
  }
307
322
  const task = queryWindowsTask();
@@ -309,7 +324,8 @@ function startWindowsAutoStart() {
309
324
  throw new Error("Service is not installed. Run 'copilot-hub service install' first.");
310
325
  }
311
326
  ensureTaskSchedulerAutoStart(command);
312
- runWindowsTask();
327
+ clearWindowsLauncherStopRequest();
328
+ await ensureWindowsSessionRunning("task");
313
329
  return "task";
314
330
  }
315
331
 
@@ -562,12 +578,19 @@ function runWindowsHiddenLauncher() {
562
578
  if (!fs.existsSync(scriptHost)) {
563
579
  throw new Error("Windows Script Host is not available.");
564
580
  }
565
- const result = runChecked(scriptHost, ["//B", "//Nologo", launcherScriptPath], {
566
- allowFailure: true,
581
+ const child = spawn(scriptHost, ["//B", "//Nologo", launcherScriptPath], {
582
+ cwd: layout.runtimeDir,
583
+ detached: true,
584
+ stdio: "ignore",
585
+ windowsHide: true,
586
+ shell: false,
587
+ env: process.env,
567
588
  });
568
- if (!result.ok) {
569
- throw new Error(result.combinedOutput || "Failed to launch hidden Windows service starter.");
589
+ const pid = normalizePid(child?.pid);
590
+ if (pid <= 0) {
591
+ throw new Error("Failed to launch hidden Windows service starter.");
570
592
  }
593
+ child.unref();
571
594
  }
572
595
 
573
596
  function ensureSystemctl() {
@@ -594,6 +617,7 @@ function runDaemon(actionValue, { allowFailure = false } = {}) {
594
617
  if (!result.ok && !allowFailure) {
595
618
  throw new Error(result.combinedOutput || `Failed to execute daemon action '${actionValue}'.`);
596
619
  }
620
+ return result;
597
621
  }
598
622
 
599
623
  function runChecked(command, args, { stdio = "pipe", allowFailure = false } = {}) {
@@ -709,6 +733,171 @@ function ensureWindowsLauncherScript() {
709
733
  });
710
734
  }
711
735
 
736
+ async function ensureWindowsSessionRunning(mode) {
737
+ if (isWindowsHiddenLauncherRunning()) {
738
+ return;
739
+ }
740
+
741
+ if (mode === "task") {
742
+ runWindowsTask();
743
+ } else {
744
+ runWindowsHiddenLauncher();
745
+ }
746
+
747
+ const ready = await waitForWindowsSessionStart();
748
+ if (!ready) {
749
+ throw new Error("Windows background service did not start cleanly.");
750
+ }
751
+ }
752
+
753
+ async function stopWindowsAutoStart() {
754
+ requestWindowsLauncherStop();
755
+ try {
756
+ runDaemon("stop", { allowFailure: true });
757
+ await waitForWindowsLauncherStopAck();
758
+ } finally {
759
+ clearWindowsLauncherStopRequest();
760
+ }
761
+ }
762
+
763
+ async function waitForWindowsSessionStart(timeoutMs = WINDOWS_LAUNCHER_STOP_TIMEOUT_MS) {
764
+ const deadline = Date.now() + timeoutMs;
765
+ while (Date.now() < deadline) {
766
+ if (getRunningDaemonPid() > 0 || isWindowsHiddenLauncherRunning()) {
767
+ return true;
768
+ }
769
+ await sleep(WINDOWS_LAUNCHER_POLL_INTERVAL_MS);
770
+ }
771
+ return getRunningDaemonPid() > 0 || isWindowsHiddenLauncherRunning();
772
+ }
773
+
774
+ async function waitForWindowsLauncherStopAck(timeoutMs = WINDOWS_LAUNCHER_STOP_TIMEOUT_MS) {
775
+ const deadline = Date.now() + timeoutMs;
776
+ while (Date.now() < deadline) {
777
+ const daemonRunning = getRunningDaemonPid() > 0;
778
+ const launcherRunning = isWindowsHiddenLauncherRunning();
779
+ const stopRequested = fs.existsSync(windowsLauncherStopSignalPath);
780
+ if (!daemonRunning && !launcherRunning && !stopRequested) {
781
+ return true;
782
+ }
783
+ await sleep(WINDOWS_LAUNCHER_POLL_INTERVAL_MS);
784
+ }
785
+ return getRunningDaemonPid() <= 0 && !isWindowsHiddenLauncherRunning();
786
+ }
787
+
788
+ function requestWindowsLauncherStop() {
789
+ fs.mkdirSync(path.dirname(windowsLauncherStopSignalPath), { recursive: true });
790
+ fs.writeFileSync(windowsLauncherStopSignalPath, `${new Date().toISOString()}\n`, "utf8");
791
+ }
792
+
793
+ function clearWindowsLauncherStopRequest() {
794
+ if (!fs.existsSync(windowsLauncherStopSignalPath)) {
795
+ return;
796
+ }
797
+ fs.rmSync(windowsLauncherStopSignalPath, { force: true });
798
+ }
799
+
800
+ function getRunningDaemonPid() {
801
+ const state = readManagedState(daemonStatePath);
802
+ const pid = normalizePid(state?.pid);
803
+ if (pid <= 0) {
804
+ return 0;
805
+ }
806
+ if (!isManagedProcessRunning(state)) {
807
+ try {
808
+ fs.rmSync(daemonStatePath, { force: true });
809
+ } catch {
810
+ // Best effort cleanup only.
811
+ }
812
+ return 0;
813
+ }
814
+ return pid;
815
+ }
816
+
817
+ function readManagedState(filePath) {
818
+ if (!fs.existsSync(filePath)) {
819
+ return null;
820
+ }
821
+ try {
822
+ const raw = fs.readFileSync(filePath, "utf8");
823
+ return JSON.parse(raw);
824
+ } catch {
825
+ return null;
826
+ }
827
+ }
828
+
829
+ function isWindowsHiddenLauncherRunning() {
830
+ return listWindowsHiddenLauncherPids().length > 0;
831
+ }
832
+
833
+ function listWindowsHiddenLauncherPids() {
834
+ if (process.platform !== "win32") {
835
+ return [];
836
+ }
837
+
838
+ const targetScriptPath = windowsLauncherScriptPath.toLowerCase();
839
+ const script = [
840
+ `$target = '${escapePowerShellSingleQuoted(targetScriptPath)}'`,
841
+ "$matches = @(Get-CimInstance Win32_Process -Filter \"Name = 'wscript.exe'\" -ErrorAction SilentlyContinue | Where-Object {",
842
+ " $cmd = [string]$_.CommandLine",
843
+ " -not [string]::IsNullOrWhiteSpace($cmd) -and $cmd.ToLower().Contains($target)",
844
+ "} | ForEach-Object { [int]$_.ProcessId })",
845
+ "$matches | ConvertTo-Json -Compress",
846
+ ].join("\n");
847
+
848
+ for (const shell of resolveWindowsPowerShellCandidates()) {
849
+ const result = spawnSync(
850
+ shell,
851
+ ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script],
852
+ {
853
+ cwd: layout.runtimeDir,
854
+ shell: false,
855
+ windowsHide: true,
856
+ encoding: "utf8",
857
+ env: process.env,
858
+ },
859
+ );
860
+ if (result.error || result.status !== 0) {
861
+ continue;
862
+ }
863
+ return parsePidListJson(result.stdout);
864
+ }
865
+
866
+ return [];
867
+ }
868
+
869
+ function resolveWindowsPowerShellCandidates() {
870
+ const systemRoot = String(process.env.SystemRoot ?? process.env.SYSTEMROOT ?? "C:\\Windows");
871
+ return [
872
+ path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
873
+ "powershell.exe",
874
+ ];
875
+ }
876
+
877
+ function parsePidListJson(value) {
878
+ const text = String(value ?? "").trim();
879
+ if (!text) {
880
+ return [];
881
+ }
882
+ try {
883
+ const parsed = JSON.parse(text);
884
+ const values = Array.isArray(parsed) ? parsed : [parsed];
885
+ return values.map((entry) => normalizePid(entry)).filter((pid) => pid > 0);
886
+ } catch {
887
+ return [];
888
+ }
889
+ }
890
+
891
+ function escapePowerShellSingleQuoted(value) {
892
+ return String(value ?? "").replace(/'/g, "''");
893
+ }
894
+
895
+ function sleep(ms) {
896
+ return new Promise((resolve) => {
897
+ setTimeout(resolve, ms);
898
+ });
899
+ }
900
+
712
901
  function escapeXml(value) {
713
902
  return String(value ?? "")
714
903
  .replace(/&/g, "&amp;")
@@ -1,6 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
+ export const WINDOWS_HIDDEN_LAUNCHER_RESTART_DELAY_MS = 5_000;
5
+
4
6
  export function resolveWindowsScriptHost(env: NodeJS.ProcessEnv = process.env): string {
5
7
  const systemRoot = String(env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows").trim();
6
8
  const baseDir = systemRoot || "C:\\Windows";
@@ -11,6 +13,10 @@ export function getWindowsHiddenLauncherScriptPath(runtimeDir: string): string {
11
13
  return path.win32.join(runtimeDir, "windows-daemon-launcher.vbs");
12
14
  }
13
15
 
16
+ export function getWindowsHiddenLauncherStopSignalPath(runtimeDir: string): string {
17
+ return path.win32.join(runtimeDir, "windows-daemon-launcher.stop");
18
+ }
19
+
14
20
  export function ensureWindowsHiddenLauncher({
15
21
  scriptPath,
16
22
  nodeBin,
@@ -60,13 +66,34 @@ export function buildWindowsHiddenLauncherContent({
60
66
  daemonScriptPath: string;
61
67
  runtimeDir: string;
62
68
  }): string {
63
- const command = buildWindowsCommandLine([nodeBin, daemonScriptPath, "start"]);
69
+ const command = buildWindowsCommandLine([nodeBin, daemonScriptPath, "run"]);
70
+ const stopSignalPath = getWindowsHiddenLauncherStopSignalPath(runtimeDir);
64
71
  return [
65
72
  "Option Explicit",
66
- "Dim shell",
73
+ "Dim shell, fso, command, stopSignalPath, restartDelayMs",
67
74
  'Set shell = CreateObject("WScript.Shell")',
75
+ 'Set fso = CreateObject("Scripting.FileSystemObject")',
68
76
  `shell.CurrentDirectory = "${escapeVbsString(runtimeDir)}"`,
69
- `shell.Run "${escapeVbsString(command)}", 0, False`,
77
+ `command = "${escapeVbsString(command)}"`,
78
+ `stopSignalPath = "${escapeVbsString(stopSignalPath)}"`,
79
+ `restartDelayMs = ${String(WINDOWS_HIDDEN_LAUNCHER_RESTART_DELAY_MS)}`,
80
+ "Do",
81
+ " If fso.FileExists(stopSignalPath) Then",
82
+ " On Error Resume Next",
83
+ " fso.DeleteFile stopSignalPath, True",
84
+ " On Error GoTo 0",
85
+ " Exit Do",
86
+ " End If",
87
+ " shell.Run command, 0, True",
88
+ " If fso.FileExists(stopSignalPath) Then",
89
+ " On Error Resume Next",
90
+ " fso.DeleteFile stopSignalPath, True",
91
+ " On Error GoTo 0",
92
+ " Exit Do",
93
+ " End If",
94
+ " WScript.Sleep restartDelayMs",
95
+ "Loop",
96
+ "Set fso = Nothing",
70
97
  "Set shell = Nothing",
71
98
  "",
72
99
  ].join("\r\n");
@@ -1,17 +1,33 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { compareSemver, isCodexVersionCompatible } from "../dist/codex-version.mjs";
3
+ import {
4
+ codexInstallPackageSpec,
5
+ compareSemver,
6
+ isCodexVersionCompatible,
7
+ preferredCodexVersion,
8
+ } from "../dist/codex-version.mjs";
4
9
 
5
- test("isCodexVersionCompatible accepts only validated stable 0.113.x releases", () => {
10
+ test("isCodexVersionCompatible accepts validated stable 0.113.x through 0.117.x releases", () => {
6
11
  assert.equal(isCodexVersionCompatible("0.113.0"), true);
7
- assert.equal(isCodexVersionCompatible("0.113.9"), true);
12
+ assert.equal(isCodexVersionCompatible("0.114.0"), true);
13
+ assert.equal(isCodexVersionCompatible("0.115.3"), true);
14
+ assert.equal(isCodexVersionCompatible("0.116.9"), true);
15
+ assert.equal(isCodexVersionCompatible("0.117.0"), true);
8
16
  assert.equal(isCodexVersionCompatible("0.112.9"), false);
9
- assert.equal(isCodexVersionCompatible("0.114.0"), false);
17
+ assert.equal(isCodexVersionCompatible("0.118.0"), false);
10
18
  });
11
19
 
12
20
  test("isCodexVersionCompatible rejects prerelease builds outside the validated lane", () => {
13
21
  assert.equal(isCodexVersionCompatible("0.113.1-alpha.1"), false);
14
- assert.equal(isCodexVersionCompatible("0.114.0-alpha.1"), false);
22
+ assert.equal(isCodexVersionCompatible("0.116.0-alpha.1"), false);
23
+ assert.equal(isCodexVersionCompatible("0.117.0-alpha.1"), false);
24
+ assert.equal(isCodexVersionCompatible("0.118.0-alpha.1"), false);
25
+ });
26
+
27
+ test("preferred install target stays inside the validated compatibility lane", () => {
28
+ assert.equal(preferredCodexVersion, "0.117.0");
29
+ assert.equal(codexInstallPackageSpec, "@openai/codex@0.117.0");
30
+ assert.equal(isCodexVersionCompatible(preferredCodexVersion), true);
15
31
  });
16
32
 
17
33
  test("compareSemver keeps prerelease ordering stable", () => {
@@ -7,6 +7,7 @@ import {
7
7
  buildWindowsHiddenLauncherCommand,
8
8
  buildWindowsHiddenLauncherContent,
9
9
  ensureWindowsHiddenLauncher,
10
+ getWindowsHiddenLauncherStopSignalPath,
10
11
  resolveWindowsScriptHost,
11
12
  } from "../dist/windows-hidden-launcher.mjs";
12
13
 
@@ -33,9 +34,13 @@ test("buildWindowsHiddenLauncherContent starts daemon in hidden mode", () => {
33
34
  });
34
35
 
35
36
  assert.match(content, /CreateObject\("WScript\.Shell"\)/);
37
+ assert.match(content, /CreateObject\("Scripting\.FileSystemObject"\)/);
36
38
  assert.match(content, /shell\.Run/);
37
- assert.match(content, /, 0, False/);
38
- assert.match(content, /"start"/);
39
+ assert.match(content, /, 0, True/);
40
+ assert.match(content, /restartDelayMs = 5000/);
41
+ assert.match(content, /WScript\.Sleep restartDelayMs/);
42
+ assert.match(content, /"run"/);
43
+ assert.match(content, /windows-daemon-launcher\.stop/);
39
44
  });
40
45
 
41
46
  test("ensureWindowsHiddenLauncher writes and preserves launcher content", () => {
@@ -64,3 +69,13 @@ test("ensureWindowsHiddenLauncher writes and preserves launcher content", () =>
64
69
  fs.rmSync(tempDir, { recursive: true, force: true });
65
70
  }
66
71
  });
72
+
73
+ test("getWindowsHiddenLauncherStopSignalPath uses the runtime directory", () => {
74
+ const actual = getWindowsHiddenLauncherStopSignalPath(
75
+ "C:\\Users\\amine\\AppData\\Roaming\\copilot-hub\\runtime",
76
+ );
77
+ assert.equal(
78
+ actual,
79
+ "C:\\Users\\amine\\AppData\\Roaming\\copilot-hub\\runtime\\windows-daemon-launcher.stop",
80
+ );
81
+ });