copilot-hub 0.1.7 → 0.1.9

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/README.md CHANGED
@@ -81,6 +81,7 @@ copilot-hub start
81
81
  ```
82
82
 
83
83
  `start` runs guided setup automatically if required values are missing.
84
+ On interactive terminals, `start` can also offer OS-native service installation when it is not yet configured.
84
85
 
85
86
  ## Quick start from source
86
87
 
@@ -175,6 +176,24 @@ npm run format:check
175
176
  npm run check:apps
176
177
  ```
177
178
 
179
+ Service mode (optional, OS-native):
180
+
181
+ ```bash
182
+ copilot-hub service install
183
+ copilot-hub service status
184
+ copilot-hub service stop
185
+ copilot-hub service start
186
+ copilot-hub service uninstall
187
+ ```
188
+
189
+ Service mode runs a persistent daemon that keeps `agent-engine` and `control-plane` alive and auto-restarts them if one exits.
190
+
191
+ Service backend by OS:
192
+
193
+ - Windows: Task Scheduler (`CopilotHub`) with user-startup fallback if task creation is denied
194
+ - Linux: systemd user service (`copilot-hub.service`)
195
+ - macOS: launchd agent (`com.copilot-hub.service`)
196
+
178
197
  ## npm release (CI)
179
198
 
180
199
  Publishing is automated from GitHub Actions on tags (`v*`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-hub",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Copilot Hub CLI and runtime bundle",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,6 +9,8 @@ const __filename = fileURLToPath(import.meta.url);
9
9
  const __dirname = path.dirname(__filename);
10
10
  const repoRoot = path.resolve(__dirname, "..", "..");
11
11
  const packageJsonPath = path.join(repoRoot, "package.json");
12
+ const runtimeDir = path.join(repoRoot, ".copilot-hub");
13
+ const servicePromptStatePath = path.join(runtimeDir, "service-onboarding.json");
12
14
  const nodeBin = process.execPath;
13
15
  const agentEngineEnvPath = path.join(repoRoot, "apps", "agent-engine", ".env");
14
16
  const controlPlaneEnvPath = path.join(repoRoot, "apps", "control-plane", ".env");
@@ -39,19 +41,37 @@ async function main() {
39
41
  runNode(["scripts/dist/configure.mjs", "--required-only"]);
40
42
  runNode(["scripts/dist/ensure-shared-build.mjs"]);
41
43
  await ensureCodexLogin();
44
+ await maybeOfferServiceInstall();
45
+ if (isServiceAlreadyInstalled()) {
46
+ runNode(["scripts/dist/service.mjs", "start"]);
47
+ return;
48
+ }
42
49
  runNode(["scripts/dist/supervisor.mjs", "up"]);
43
50
  return;
44
51
  }
45
52
  case "stop": {
53
+ if (isServiceAlreadyInstalled()) {
54
+ runNode(["scripts/dist/service.mjs", "stop"]);
55
+ return;
56
+ }
46
57
  runNode(["scripts/dist/supervisor.mjs", "down"]);
47
58
  return;
48
59
  }
49
60
  case "restart": {
50
61
  runNode(["scripts/dist/ensure-shared-build.mjs"]);
62
+ if (isServiceAlreadyInstalled()) {
63
+ runNode(["scripts/dist/service.mjs", "stop"]);
64
+ runNode(["scripts/dist/service.mjs", "start"]);
65
+ return;
66
+ }
51
67
  runNode(["scripts/dist/supervisor.mjs", "restart"]);
52
68
  return;
53
69
  }
54
70
  case "status": {
71
+ if (isServiceAlreadyInstalled()) {
72
+ runNode(["scripts/dist/daemon.mjs", "status"]);
73
+ return;
74
+ }
55
75
  runNode(["scripts/dist/supervisor.mjs", "status"]);
56
76
  return;
57
77
  }
@@ -63,6 +83,10 @@ async function main() {
63
83
  runNode(["scripts/dist/configure.mjs"]);
64
84
  return;
65
85
  }
86
+ case "service": {
87
+ runNode(["scripts/dist/service.mjs", ...rawArgs.slice(1)]);
88
+ return;
89
+ }
66
90
  default: {
67
91
  printUsage();
68
92
  process.exit(1);
@@ -70,16 +94,33 @@ async function main() {
70
94
  }
71
95
  }
72
96
  function runNode(scriptArgs) {
73
- const result = spawnSync(nodeBin, scriptArgs, {
74
- cwd: repoRoot,
75
- stdio: "inherit",
76
- shell: false,
77
- });
97
+ const result = runNodeCapture(scriptArgs, "inherit");
78
98
  const code = Number.isInteger(result.status) ? result.status : 1;
79
99
  if (code !== 0) {
80
100
  process.exit(code);
81
101
  }
82
102
  }
103
+ function runNodeCapture(scriptArgs, stdioMode = "pipe") {
104
+ const stdio = stdioMode === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"];
105
+ const result = spawnSync(nodeBin, scriptArgs, {
106
+ cwd: repoRoot,
107
+ stdio,
108
+ shell: false,
109
+ encoding: "utf8",
110
+ });
111
+ const stdout = String(result.stdout ?? "").trim();
112
+ const stderr = String(result.stderr ?? "").trim();
113
+ const combinedOutput = [stdout, stderr].filter(Boolean).join("\n").trim();
114
+ const ok = !result.error && result.status === 0;
115
+ return {
116
+ ok,
117
+ status: result.status,
118
+ stdout,
119
+ stderr,
120
+ combinedOutput,
121
+ error: result.error,
122
+ };
123
+ }
83
124
  async function ensureCodexLogin() {
84
125
  const resolved = resolveCodexBinForStart();
85
126
  let codexBin = resolved.bin;
@@ -149,6 +190,81 @@ async function ensureCodexLogin() {
149
190
  }
150
191
  console.log("Codex login configured.");
151
192
  }
193
+ async function maybeOfferServiceInstall() {
194
+ if (!process.stdin.isTTY) {
195
+ return;
196
+ }
197
+ if (!isServiceSupportedOnCurrentPlatform()) {
198
+ return;
199
+ }
200
+ if (isServiceAlreadyInstalled()) {
201
+ return;
202
+ }
203
+ const state = readServicePromptState();
204
+ if (state?.decision === "declined") {
205
+ return;
206
+ }
207
+ const rl = createInterface({ input, output });
208
+ let shouldInstall = false;
209
+ try {
210
+ shouldInstall = await askYesNo(rl, "Enable OS-native auto-start service now? (recommended for reliability)", false);
211
+ }
212
+ finally {
213
+ rl.close();
214
+ }
215
+ if (!shouldInstall) {
216
+ writeServicePromptState("declined");
217
+ console.log("Service setup skipped. You can run 'copilot-hub service install' anytime.");
218
+ return;
219
+ }
220
+ const install = runNodeCapture(["scripts/dist/service.mjs", "install"], "inherit");
221
+ if (!install.ok) {
222
+ console.log("Service install failed. Continuing in local mode.");
223
+ return;
224
+ }
225
+ writeServicePromptState("accepted");
226
+ }
227
+ function isServiceSupportedOnCurrentPlatform() {
228
+ return (process.platform === "win32" || process.platform === "linux" || process.platform === "darwin");
229
+ }
230
+ function isServiceAlreadyInstalled() {
231
+ const status = runNodeCapture(["scripts/dist/service.mjs", "status"], "pipe");
232
+ const message = String(status.combinedOutput ?? "").toLowerCase();
233
+ if (message.includes("service not installed")) {
234
+ return false;
235
+ }
236
+ if (message.includes("not installed")) {
237
+ return false;
238
+ }
239
+ return status.ok;
240
+ }
241
+ function readServicePromptState() {
242
+ if (!fs.existsSync(servicePromptStatePath)) {
243
+ return null;
244
+ }
245
+ try {
246
+ const raw = fs.readFileSync(servicePromptStatePath, "utf8");
247
+ const parsed = JSON.parse(raw);
248
+ return parsed && typeof parsed === "object" ? parsed : null;
249
+ }
250
+ catch {
251
+ return null;
252
+ }
253
+ }
254
+ function writeServicePromptState(decision) {
255
+ try {
256
+ fs.mkdirSync(runtimeDir, { recursive: true });
257
+ fs.writeFileSync(servicePromptStatePath, `${JSON.stringify({
258
+ decision: String(decision ?? "")
259
+ .trim()
260
+ .toLowerCase(),
261
+ updatedAt: new Date().toISOString(),
262
+ }, null, 2)}\n`, "utf8");
263
+ }
264
+ catch {
265
+ // Non-critical state cache only.
266
+ }
267
+ }
152
268
  async function recoverCodexBinary({ resolved, status }) {
153
269
  const detected = findDetectedCodexBin();
154
270
  if (detected && detected !== resolved.bin) {
@@ -469,7 +585,11 @@ function spawnNpm(args, options) {
469
585
  });
470
586
  }
471
587
  function printUsage() {
472
- console.log("Usage: node scripts/dist/cli.mjs <start|stop|restart|status|logs|configure|version|help>");
588
+ console.log([
589
+ "Usage: node scripts/dist/cli.mjs <start|stop|restart|status|logs|configure|service|version|help>",
590
+ "Service management:",
591
+ " node scripts/dist/cli.mjs service <install|uninstall|status|start|stop|help>",
592
+ ].join("\n"));
473
593
  }
474
594
  function readPackageVersion() {
475
595
  try {
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+ const repoRoot = path.resolve(__dirname, "..", "..");
10
+ const runtimeDir = path.join(repoRoot, ".copilot-hub");
11
+ const pidsDir = path.join(runtimeDir, "pids");
12
+ const logsDir = path.join(repoRoot, "logs");
13
+ const daemonStatePath = path.join(pidsDir, "daemon.json");
14
+ const daemonLogPath = path.join(logsDir, "service-daemon.log");
15
+ const daemonScriptPath = path.join(repoRoot, "scripts", "dist", "daemon.mjs");
16
+ const supervisorScriptPath = path.join(repoRoot, "scripts", "dist", "supervisor.mjs");
17
+ const nodeBin = process.execPath;
18
+ const BASE_CHECK_MS = 5000;
19
+ const MAX_BACKOFF_MS = 60000;
20
+ const action = String(process.argv[2] ?? "status")
21
+ .trim()
22
+ .toLowerCase();
23
+ try {
24
+ await main();
25
+ }
26
+ catch (error) {
27
+ console.error(getErrorMessage(error));
28
+ process.exit(1);
29
+ }
30
+ async function main() {
31
+ switch (action) {
32
+ case "start":
33
+ await startDaemonProcess();
34
+ return;
35
+ case "run":
36
+ await runDaemonLoop();
37
+ return;
38
+ case "stop":
39
+ await stopDaemonProcess();
40
+ return;
41
+ case "status":
42
+ showDaemonStatus();
43
+ return;
44
+ case "help":
45
+ printUsage();
46
+ return;
47
+ default:
48
+ printUsage();
49
+ process.exit(1);
50
+ }
51
+ }
52
+ async function startDaemonProcess() {
53
+ ensureScripts();
54
+ ensureRuntimeDirs();
55
+ const existingPid = getRunningDaemonPid();
56
+ if (existingPid > 0) {
57
+ console.log(`[daemon] already running (pid ${existingPid})`);
58
+ return;
59
+ }
60
+ removeDaemonState();
61
+ const logFd = fs.openSync(daemonLogPath, "a");
62
+ let child;
63
+ try {
64
+ child = spawn(nodeBin, [daemonScriptPath, "run"], {
65
+ cwd: repoRoot,
66
+ detached: true,
67
+ stdio: ["ignore", logFd, logFd],
68
+ windowsHide: true,
69
+ shell: false,
70
+ env: process.env,
71
+ });
72
+ }
73
+ finally {
74
+ fs.closeSync(logFd);
75
+ }
76
+ const pid = normalizePid(child?.pid);
77
+ if (pid <= 0) {
78
+ throw new Error("Failed to spawn daemon process.");
79
+ }
80
+ child.unref();
81
+ const ready = await waitForExit(pid, 250, false);
82
+ if (ready) {
83
+ throw new Error(`Daemon process exited immediately (pid ${pid}). Check logs: ${daemonLogPath}`);
84
+ }
85
+ console.log(`[daemon] started (pid ${pid})`);
86
+ }
87
+ async function runDaemonLoop() {
88
+ ensureScripts();
89
+ ensureRuntimeDirs();
90
+ const existingPid = getRunningDaemonPid();
91
+ if (existingPid > 0 && existingPid !== process.pid) {
92
+ console.log(`[daemon] already running (pid ${existingPid})`);
93
+ return;
94
+ }
95
+ writeDaemonState({
96
+ pid: process.pid,
97
+ startedAt: new Date().toISOString(),
98
+ command: `${nodeBin} ${daemonScriptPath} run`,
99
+ });
100
+ const state = { stopping: false, shuttingDown: false };
101
+ setupSignalHandlers(state);
102
+ console.log(`[daemon] running (pid ${process.pid})`);
103
+ let failureCount = 0;
104
+ while (!state.stopping) {
105
+ const ensureResult = runSupervisor("ensure", { allowFailure: true });
106
+ if (ensureResult.ok) {
107
+ if (failureCount > 0) {
108
+ console.log("[daemon] workers recovered.");
109
+ }
110
+ failureCount = 0;
111
+ await sleepInterruptible(BASE_CHECK_MS, () => state.stopping);
112
+ continue;
113
+ }
114
+ failureCount += 1;
115
+ const delay = computeBackoffDelay(failureCount);
116
+ const reason = firstLine(ensureResult.combinedOutput) ||
117
+ `supervisor ensure exited with code ${String(ensureResult.status ?? "unknown")}`;
118
+ console.error(`[daemon] worker health check failed: ${reason}. Retrying in ${Math.ceil(delay / 1000)}s.`);
119
+ await sleepInterruptible(delay, () => state.stopping);
120
+ }
121
+ await shutdownDaemon(state, { reason: "stop-request", exitCode: 0 });
122
+ }
123
+ async function stopDaemonProcess() {
124
+ ensureRuntimeDirs();
125
+ const pid = getRunningDaemonPid();
126
+ if (pid <= 0) {
127
+ removeDaemonState();
128
+ runSupervisor("down", { allowFailure: true });
129
+ console.log("[daemon] not running.");
130
+ return;
131
+ }
132
+ await terminateProcess(pid);
133
+ if (isProcessRunning(pid)) {
134
+ throw new Error(`Daemon did not stop cleanly (pid ${pid}).`);
135
+ }
136
+ removeDaemonState();
137
+ runSupervisor("down", { allowFailure: true });
138
+ console.log("[daemon] stopped.");
139
+ }
140
+ function showDaemonStatus() {
141
+ ensureRuntimeDirs();
142
+ const pid = getRunningDaemonPid();
143
+ const running = pid > 0;
144
+ if (!running) {
145
+ removeDaemonState();
146
+ }
147
+ console.log("\n=== daemon ===");
148
+ console.log(`running: ${running ? "yes" : "no"}`);
149
+ console.log(`pid: ${running ? String(pid) : "-"}`);
150
+ console.log(`logFile: ${daemonLogPath}`);
151
+ if (!fs.existsSync(supervisorScriptPath)) {
152
+ console.log("\n(worker status unavailable: supervisor script missing)");
153
+ return;
154
+ }
155
+ console.log("\n=== workers ===");
156
+ runSupervisor("status", { allowFailure: true, stdio: "inherit" });
157
+ }
158
+ function setupSignalHandlers(state) {
159
+ const requestStop = () => {
160
+ state.stopping = true;
161
+ };
162
+ process.on("SIGINT", requestStop);
163
+ process.on("SIGTERM", requestStop);
164
+ process.on("SIGHUP", requestStop);
165
+ process.on("uncaughtException", (error) => {
166
+ if (!state.shuttingDown) {
167
+ console.error(`[daemon] uncaught exception: ${getErrorMessage(error)}`);
168
+ }
169
+ state.stopping = true;
170
+ void shutdownDaemon(state, { reason: "uncaught-exception", exitCode: 1 });
171
+ });
172
+ process.on("unhandledRejection", (reason) => {
173
+ if (!state.shuttingDown) {
174
+ console.error(`[daemon] unhandled rejection: ${getErrorMessage(reason)}`);
175
+ }
176
+ state.stopping = true;
177
+ void shutdownDaemon(state, { reason: "unhandled-rejection", exitCode: 1 });
178
+ });
179
+ }
180
+ async function shutdownDaemon(state, { reason, exitCode }) {
181
+ if (state.shuttingDown) {
182
+ return;
183
+ }
184
+ state.shuttingDown = true;
185
+ console.log(`[daemon] stopping (${reason})...`);
186
+ runSupervisor("down", { allowFailure: true });
187
+ removeDaemonState();
188
+ process.exit(exitCode);
189
+ }
190
+ function ensureScripts() {
191
+ if (!fs.existsSync(supervisorScriptPath)) {
192
+ throw new Error([
193
+ "Supervisor script is missing.",
194
+ "Run 'npm run build:scripts' (or reinstall package) and retry.",
195
+ ].join("\n"));
196
+ }
197
+ if (!fs.existsSync(daemonScriptPath)) {
198
+ throw new Error([
199
+ "Daemon script is missing.",
200
+ "Run 'npm run build:scripts' (or reinstall package) and retry.",
201
+ ].join("\n"));
202
+ }
203
+ }
204
+ function ensureRuntimeDirs() {
205
+ fs.mkdirSync(runtimeDir, { recursive: true });
206
+ fs.mkdirSync(pidsDir, { recursive: true });
207
+ fs.mkdirSync(logsDir, { recursive: true });
208
+ }
209
+ function getRunningDaemonPid() {
210
+ const state = readDaemonState();
211
+ const pid = normalizePid(state?.pid);
212
+ if (pid <= 0) {
213
+ return 0;
214
+ }
215
+ return isProcessRunning(pid) ? pid : 0;
216
+ }
217
+ function readDaemonState() {
218
+ if (!fs.existsSync(daemonStatePath)) {
219
+ return null;
220
+ }
221
+ try {
222
+ return JSON.parse(fs.readFileSync(daemonStatePath, "utf8"));
223
+ }
224
+ catch {
225
+ return null;
226
+ }
227
+ }
228
+ function writeDaemonState(value) {
229
+ fs.mkdirSync(path.dirname(daemonStatePath), { recursive: true });
230
+ fs.writeFileSync(daemonStatePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
231
+ }
232
+ function removeDaemonState() {
233
+ if (!fs.existsSync(daemonStatePath)) {
234
+ return;
235
+ }
236
+ fs.rmSync(daemonStatePath, { force: true });
237
+ }
238
+ function normalizePid(value) {
239
+ const pid = Number.parseInt(String(value ?? ""), 10);
240
+ if (!Number.isFinite(pid) || pid <= 0) {
241
+ return 0;
242
+ }
243
+ return pid;
244
+ }
245
+ function isProcessRunning(pid) {
246
+ if (!Number.isInteger(pid) || pid <= 0) {
247
+ return false;
248
+ }
249
+ try {
250
+ process.kill(pid, 0);
251
+ return true;
252
+ }
253
+ catch (error) {
254
+ if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
255
+ return true;
256
+ }
257
+ return false;
258
+ }
259
+ }
260
+ async function terminateProcess(pid) {
261
+ if (process.platform === "win32") {
262
+ await killTreeWindows(pid);
263
+ if (!(await waitForExit(pid, 7000))) {
264
+ await killTreeWindows(pid);
265
+ }
266
+ return;
267
+ }
268
+ sendSignal(pid, "SIGTERM");
269
+ if (await waitForExit(pid, 7000)) {
270
+ return;
271
+ }
272
+ sendSignal(pid, "SIGKILL");
273
+ await waitForExit(pid, 2000);
274
+ }
275
+ function killTreeWindows(pid) {
276
+ return new Promise((resolve) => {
277
+ const child = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {
278
+ stdio: "ignore",
279
+ shell: false,
280
+ windowsHide: true,
281
+ });
282
+ child.once("error", () => resolve());
283
+ child.once("exit", () => resolve());
284
+ });
285
+ }
286
+ function sendSignal(pid, signal) {
287
+ try {
288
+ process.kill(-pid, signal);
289
+ return;
290
+ }
291
+ catch {
292
+ // continue
293
+ }
294
+ try {
295
+ process.kill(pid, signal);
296
+ }
297
+ catch {
298
+ // ignore
299
+ }
300
+ }
301
+ async function waitForExit(pid, timeoutMs, expectExit = true) {
302
+ const deadline = Date.now() + timeoutMs;
303
+ while (Date.now() < deadline) {
304
+ const running = isProcessRunning(pid);
305
+ if (expectExit && !running) {
306
+ return true;
307
+ }
308
+ if (!expectExit && running) {
309
+ return false;
310
+ }
311
+ await sleep(100);
312
+ }
313
+ const stillRunning = isProcessRunning(pid);
314
+ return expectExit ? !stillRunning : stillRunning === false;
315
+ }
316
+ function runSupervisor(actionValue, { allowFailure = false, stdio = "pipe" } = {}) {
317
+ return runChecked(nodeBin, [supervisorScriptPath, String(actionValue ?? "").trim()], {
318
+ allowFailure,
319
+ stdio,
320
+ });
321
+ }
322
+ function runChecked(command, args, { stdio = "pipe", allowFailure = false } = {}) {
323
+ const spawnStdio = stdio;
324
+ const result = spawnSync(command, args, {
325
+ cwd: repoRoot,
326
+ shell: false,
327
+ stdio: spawnStdio,
328
+ encoding: "utf8",
329
+ env: process.env,
330
+ });
331
+ const stdout = String(result.stdout ?? "").trim();
332
+ const stderr = String(result.stderr ?? "").trim();
333
+ const combinedOutput = [stdout, stderr].filter(Boolean).join("\n").trim();
334
+ const spawnErrorCode = String(result.error?.code ?? "")
335
+ .trim()
336
+ .toUpperCase();
337
+ const ok = !result.error && result.status === 0;
338
+ if (!ok && !allowFailure) {
339
+ const errorMessage = result.error && spawnErrorCode
340
+ ? `${command} failed (${spawnErrorCode}).`
341
+ : combinedOutput || `${command} exited with code ${String(result.status ?? "unknown")}.`;
342
+ throw new Error(errorMessage);
343
+ }
344
+ return {
345
+ ok,
346
+ status: result.status,
347
+ stdout,
348
+ stderr,
349
+ combinedOutput,
350
+ spawnErrorCode,
351
+ };
352
+ }
353
+ function computeBackoffDelay(failureCount) {
354
+ const power = Math.max(0, failureCount - 1);
355
+ const calculated = BASE_CHECK_MS * 2 ** power;
356
+ return Math.min(calculated, MAX_BACKOFF_MS);
357
+ }
358
+ async function sleepInterruptible(ms, shouldStop) {
359
+ const deadline = Date.now() + ms;
360
+ while (Date.now() < deadline) {
361
+ if (shouldStop()) {
362
+ return;
363
+ }
364
+ await sleep(Math.min(250, Math.max(1, deadline - Date.now())));
365
+ }
366
+ }
367
+ function sleep(ms) {
368
+ return new Promise((resolve) => {
369
+ setTimeout(resolve, ms);
370
+ });
371
+ }
372
+ function firstLine(value) {
373
+ const text = String(value ?? "").trim();
374
+ if (!text) {
375
+ return "";
376
+ }
377
+ const [line] = text.split(/\r?\n/, 1);
378
+ return String(line ?? "").trim();
379
+ }
380
+ function getErrorMessage(error) {
381
+ if (error instanceof Error && error.message) {
382
+ return error.message;
383
+ }
384
+ return String(error ?? "Unknown error.");
385
+ }
386
+ function printUsage() {
387
+ console.log("Usage: node scripts/dist/daemon.mjs <start|run|stop|status|help>");
388
+ }