copillm 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.
Files changed (38) hide show
  1. package/README.md +52 -0
  2. package/dist/agentconfig/apply.js +53 -0
  3. package/dist/agentconfig/load.js +163 -0
  4. package/dist/agentconfig/markerBlock.js +76 -0
  5. package/dist/agentconfig/render.js +317 -0
  6. package/dist/agentconfig/schema.js +65 -0
  7. package/dist/auth/copilotToken.js +122 -0
  8. package/dist/auth/credentials.js +221 -0
  9. package/dist/auth/deviceFlow.js +89 -0
  10. package/dist/auth/ensureAuthenticated.js +55 -0
  11. package/dist/auth/githubIdentity.js +42 -0
  12. package/dist/auth/interactivePrompt.js +135 -0
  13. package/dist/claude/cache.js +20 -0
  14. package/dist/claude/settingsConflict.js +85 -0
  15. package/dist/cli/agentEnv.js +56 -0
  16. package/dist/cli/configCommands.js +149 -0
  17. package/dist/cli/envBlock.js +43 -0
  18. package/dist/cli/launchAgent.js +59 -0
  19. package/dist/cli/resolveAgent.js +361 -0
  20. package/dist/cli.js +1178 -0
  21. package/dist/codex/init.js +93 -0
  22. package/dist/config/config.js +51 -0
  23. package/dist/config/fsSecurity.js +39 -0
  24. package/dist/config/home.js +62 -0
  25. package/dist/config/logging.js +33 -0
  26. package/dist/config/upstream.js +38 -0
  27. package/dist/models/anthropicDefaults.js +138 -0
  28. package/dist/models/discovery.js +208 -0
  29. package/dist/pi/init.js +174 -0
  30. package/dist/server/anthropicModelsResponse.js +151 -0
  31. package/dist/server/codexSchema.js +100 -0
  32. package/dist/server/debugInfo.js +48 -0
  33. package/dist/server/lock.js +150 -0
  34. package/dist/server/proxy.js +715 -0
  35. package/dist/translation/openaiAnthropic.js +391 -0
  36. package/dist/translation/streamingOpenAIToAnthropic.js +290 -0
  37. package/dist/types/index.js +1 -0
  38. package/package.json +50 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1178 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ import { Command } from "commander";
6
+ import { clearStoredCredential, inspectStoredCredential, loadStoredCredential, saveStoredCredential } from "./auth/credentials.js";
7
+ import { inspectGithubIdentity } from "./auth/githubIdentity.js";
8
+ import { ensureAuthenticatedInteractive as ensureAuthenticatedInteractiveImpl } from "./auth/ensureAuthenticated.js";
9
+ import { loginViaDeviceFlow } from "./auth/deviceFlow.js";
10
+ import { CopilotTokenManager } from "./auth/copilotToken.js";
11
+ import { confirm, choose } from "./auth/interactivePrompt.js";
12
+ import { loadConfig, saveConfig } from "./config/config.js";
13
+ import { createLogger } from "./config/logging.js";
14
+ import { listModels, resolveModelSelections } from "./models/discovery.js";
15
+ import { acquireLock, inspectLock, LockAlreadyRunningError, releaseLock } from "./server/lock.js";
16
+ import { startProxyServer } from "./server/proxy.js";
17
+ import { defaultOutputDir, generateCodexHome } from "./codex/init.js";
18
+ import { defaultOutputDir as defaultPiOutputDir, generatePiHome } from "./pi/init.js";
19
+ import { getCopillmHome } from "./config/home.js";
20
+ import { clearClaudeGatewayCache } from "./claude/cache.js";
21
+ import { detectClaudeSettingsConflicts, formatSettingsConflictWarning } from "./claude/settingsConflict.js";
22
+ import { buildClaudeExportCommand as buildClaudeExport, computeAnthropicDefaults, readModelIdsFromCache } from "./models/anthropicDefaults.js";
23
+ import { isShellSyntax, renderEnvBlock } from "./cli/envBlock.js";
24
+ import { buildClaudeEnvBundle, buildCodexEnvBundle, buildPiEnvBundle } from "./cli/agentEnv.js";
25
+ import { launchAgent } from "./cli/launchAgent.js";
26
+ import { applyAgentConfig, formatApplyNotes } from "./agentconfig/apply.js";
27
+ import { registerConfigCommands } from "./cli/configCommands.js";
28
+ const logger = createLogger();
29
+ const program = new Command();
30
+ program.name("copillm").description("Local Copilot proxy").version("0.1.0");
31
+ program.enablePositionalOptions();
32
+ program
33
+ .command("login")
34
+ .description("[deprecated] Use `copillm auth login`")
35
+ .option("--json", "JSON output")
36
+ .action(async (opts) => {
37
+ emitDeprecation(opts, "login", "auth login");
38
+ await runAuthLogin(opts, { forceSession: false });
39
+ });
40
+ program
41
+ .command("logout")
42
+ .description("[deprecated] Use `copillm auth logout`")
43
+ .option("--json", "JSON output")
44
+ .action(async (opts) => {
45
+ emitDeprecation(opts, "logout", "auth logout");
46
+ await runAuthLogout(opts);
47
+ });
48
+ const auth = program.command("auth").description("Authentication commands");
49
+ auth
50
+ .command("login")
51
+ .description("Authenticate with GitHub")
52
+ .option("--json", "JSON output")
53
+ // Undocumented test seam: force the session-only backend regardless of
54
+ // whether the OS keychain is available. Equivalent to setting
55
+ // COPILLM_FORCE_SESSION_BACKEND=1 for the duration of this command.
56
+ .option("--force-session", "(test-only) force the session-only backend", false)
57
+ .action(async (opts) => {
58
+ await runAuthLogin(opts, { forceSession: Boolean(opts.forceSession) });
59
+ });
60
+ auth
61
+ .command("logout")
62
+ .description("Clear credentials and stop running daemon")
63
+ .option("--json", "JSON output")
64
+ .action(async (opts) => {
65
+ await runAuthLogout(opts);
66
+ });
67
+ auth
68
+ .command("status")
69
+ .description("Report whether a credential is stored (token is never printed)")
70
+ .option("--json", "JSON output")
71
+ .option("--no-user", "Skip the GitHub /user lookup that fetches the login name")
72
+ .action(async (opts) => {
73
+ let info;
74
+ try {
75
+ info = await inspectStoredCredential();
76
+ }
77
+ catch (error) {
78
+ const message = error instanceof Error ? error.message : "unknown_error";
79
+ if (opts.json) {
80
+ process.stdout.write(JSON.stringify({ status: "error", error: message }, null, 2) + "\n");
81
+ }
82
+ else {
83
+ process.stderr.write(`auth status error: ${message}\n`);
84
+ }
85
+ process.exit(1);
86
+ }
87
+ // commander's --no-user toggles opts.user to false; when the flag is
88
+ // omitted opts.user is undefined and we treat that as "fetch by default".
89
+ const userLookupEnabled = info.stored && opts.user !== false;
90
+ let identity = null;
91
+ if (userLookupEnabled) {
92
+ // inspectGithubIdentity is designed to return null on any failure, but
93
+ // we wrap defensively at the CLI level too: a regression in the wrapper,
94
+ // or a platform-specific fetch error path (e.g. Node 22 on macOS has
95
+ // surfaced uncaught socket rejections from privileged-port ECONNREFUSED),
96
+ // must never break the auth-status command. Status output should always
97
+ // succeed even when the network is broken.
98
+ try {
99
+ identity = await inspectGithubIdentity();
100
+ }
101
+ catch {
102
+ identity = null;
103
+ }
104
+ }
105
+ if (opts.json) {
106
+ process.stdout.write(JSON.stringify({
107
+ status: info.stored ? "logged_in" : "logged_out",
108
+ stored: info.stored,
109
+ backend: info.backend,
110
+ user: identity
111
+ }, null, 2) + "\n");
112
+ }
113
+ else if (info.stored) {
114
+ process.stdout.write(`${formatHumanAuthStatusLine(info.backend, identity)}\n`);
115
+ }
116
+ else {
117
+ process.stdout.write("not logged in\n");
118
+ }
119
+ process.exit(info.stored ? 0 : 2);
120
+ });
121
+ program
122
+ .command("start")
123
+ .description("Start proxy")
124
+ .option("--detach", "Run in detached mode")
125
+ .option("--debug", "Enable debug endpoints (e.g. /_debug)")
126
+ .option("--no-codex", "Skip generating ~/.copillm/codex/ for Codex CLI")
127
+ .option("--codex-model <id>", "Default Codex model slug")
128
+ .option("--no-pi", "Skip generating ~/.pi/agent/models.json for pi coding agent")
129
+ .option("--json", "JSON output")
130
+ .action(async (opts) => {
131
+ if (opts.detach) {
132
+ // Fail fast on missing credentials rather than letting the detached
133
+ // child die silently and surface as a generic "start timed out" error.
134
+ const authState = await inspectStoredCredential();
135
+ if (!authState.stored) {
136
+ throw new Error("Not authenticated. Run `copillm auth login` first, or start without --detach to log in interactively.");
137
+ }
138
+ const existingLock = await readLiveLock();
139
+ if (existingLock) {
140
+ const codex = opts.codex === false ? null : await refreshCodexHome(existingLock.port, opts.codexModel ?? null);
141
+ const pi = opts.pi === false ? null : await refreshPiHome(existingLock.port);
142
+ const claude = buildClaudeExportCommand(existingLock.port, null);
143
+ const banner = formatStartBanner({
144
+ port: existingLock.port,
145
+ pid: existingLock.pid,
146
+ mode: "already_running",
147
+ debug: false,
148
+ codex,
149
+ pi
150
+ });
151
+ writeCommandOutput(opts, banner, {
152
+ status: "already_running",
153
+ pid: existingLock.pid,
154
+ port: existingLock.port,
155
+ url: `http://127.0.0.1:${existingLock.port}`,
156
+ codex_home: codex?.outDir ?? null,
157
+ codex_export_command: codex?.exportCommand ?? null,
158
+ codex_env: codex ? buildCodexEnvBundle(codex.outDir).env : null,
159
+ pi_home: pi?.outDir ?? null,
160
+ pi_config_path: pi?.configPath ?? null,
161
+ pi_mirror_path: pi?.mirrorPath ?? null,
162
+ pi_backup_path: pi?.backupPath ?? null,
163
+ pi_model_count: pi?.modelCount ?? null,
164
+ claude_export_command: claude.command,
165
+ claude_env: claude.bundle.env,
166
+ claude_defaults: claude.defaults
167
+ });
168
+ return;
169
+ }
170
+ const daemonArgs = [process.argv[1], "daemon"];
171
+ if (opts.debug) {
172
+ daemonArgs.push("--debug");
173
+ }
174
+ const child = spawn(process.execPath, daemonArgs, {
175
+ detached: true,
176
+ stdio: "ignore"
177
+ });
178
+ child.unref();
179
+ const started = await waitForDaemonReady(child.pid ?? null, 8_000);
180
+ if (!started) {
181
+ throw new Error("Detached daemon start timed out.");
182
+ }
183
+ const codex = opts.codex === false ? null : await refreshCodexHome(started.port, opts.codexModel ?? null);
184
+ const pi = opts.pi === false ? null : await refreshPiHome(started.port);
185
+ const claude = buildClaudeExportCommand(started.port, null);
186
+ const banner = formatStartBanner({
187
+ port: started.port,
188
+ pid: started.pid,
189
+ mode: "detached",
190
+ debug: Boolean(opts.debug),
191
+ codex,
192
+ pi
193
+ });
194
+ writeCommandOutput(opts, banner, {
195
+ status: "ok",
196
+ mode: "detached",
197
+ pid: started.pid,
198
+ port: started.port,
199
+ debug: Boolean(opts.debug),
200
+ url: `http://127.0.0.1:${started.port}`,
201
+ codex_home: codex?.outDir ?? null,
202
+ codex_export_command: codex?.exportCommand ?? null,
203
+ codex_env: codex ? buildCodexEnvBundle(codex.outDir).env : null,
204
+ codex_default_model: codex?.defaultModel ?? null,
205
+ codex_model_count: codex?.modelCount ?? null,
206
+ pi_home: pi?.outDir ?? null,
207
+ pi_config_path: pi?.configPath ?? null,
208
+ pi_mirror_path: pi?.mirrorPath ?? null,
209
+ pi_backup_path: pi?.backupPath ?? null,
210
+ pi_model_count: pi?.modelCount ?? null,
211
+ claude_export_command: claude.command,
212
+ claude_env: claude.bundle.env,
213
+ claude_defaults: claude.defaults
214
+ });
215
+ return;
216
+ }
217
+ // Foreground path: interactively prompt for login if needed.
218
+ await ensureAuthenticatedInteractive();
219
+ const started = await runDaemon({ debug: Boolean(opts.debug) });
220
+ if (started.kind === "already_running") {
221
+ const codex = opts.codex === false ? null : await refreshCodexHome(started.lock.port, opts.codexModel ?? null);
222
+ const pi = opts.pi === false ? null : await refreshPiHome(started.lock.port);
223
+ const claude = buildClaudeExportCommand(started.lock.port, null);
224
+ const banner = formatStartBanner({
225
+ port: started.lock.port,
226
+ pid: started.lock.pid,
227
+ mode: "already_running",
228
+ debug: false,
229
+ codex,
230
+ pi
231
+ });
232
+ writeCommandOutput(opts, banner, {
233
+ status: "already_running",
234
+ pid: started.lock.pid,
235
+ port: started.lock.port,
236
+ url: `http://127.0.0.1:${started.lock.port}`,
237
+ codex_home: codex?.outDir ?? null,
238
+ codex_export_command: codex?.exportCommand ?? null,
239
+ codex_env: codex ? buildCodexEnvBundle(codex.outDir).env : null,
240
+ pi_home: pi?.outDir ?? null,
241
+ pi_config_path: pi?.configPath ?? null,
242
+ pi_mirror_path: pi?.mirrorPath ?? null,
243
+ pi_backup_path: pi?.backupPath ?? null,
244
+ pi_model_count: pi?.modelCount ?? null,
245
+ claude_export_command: claude.command,
246
+ claude_env: claude.bundle.env,
247
+ claude_defaults: claude.defaults
248
+ });
249
+ return;
250
+ }
251
+ const codex = opts.codex === false ? null : await refreshCodexHome(started.port, opts.codexModel ?? null);
252
+ const pi = opts.pi === false ? null : await refreshPiHome(started.port);
253
+ const claude = buildClaudeExportCommand(started.port, started.callerSecret);
254
+ const banner = formatStartBanner({
255
+ port: started.port,
256
+ pid: process.pid,
257
+ mode: "foreground",
258
+ debug: Boolean(opts.debug),
259
+ codex,
260
+ pi
261
+ });
262
+ writeCommandOutput(opts, banner, {
263
+ status: "ok",
264
+ mode: "foreground",
265
+ pid: process.pid,
266
+ port: started.port,
267
+ debug: Boolean(opts.debug),
268
+ url: `http://127.0.0.1:${started.port}`,
269
+ caller_secret: started.callerSecret,
270
+ codex_home: codex?.outDir ?? null,
271
+ codex_export_command: codex?.exportCommand ?? null,
272
+ codex_env: codex ? buildCodexEnvBundle(codex.outDir).env : null,
273
+ codex_default_model: codex?.defaultModel ?? null,
274
+ codex_model_count: codex?.modelCount ?? null,
275
+ pi_home: pi?.outDir ?? null,
276
+ pi_config_path: pi?.configPath ?? null,
277
+ pi_mirror_path: pi?.mirrorPath ?? null,
278
+ pi_backup_path: pi?.backupPath ?? null,
279
+ pi_model_count: pi?.modelCount ?? null,
280
+ claude_export_command: claude.command,
281
+ claude_env: claude.bundle.env,
282
+ claude_defaults: claude.defaults
283
+ });
284
+ });
285
+ program
286
+ .command("daemon")
287
+ .description("Internal background command")
288
+ .option("--debug", "Enable debug endpoints")
289
+ .action(async (opts) => {
290
+ const started = await runDaemon({ debug: Boolean(opts.debug) });
291
+ if (started.kind === "already_running") {
292
+ process.exit(0);
293
+ }
294
+ process.stdout.write(`copillm listening on http://127.0.0.1:${started.port}${opts.debug ? " [debug]" : ""}\n`);
295
+ });
296
+ program
297
+ .command("stop")
298
+ .description("Stop detached daemon")
299
+ .option("--json", "JSON output")
300
+ .action(async (opts) => {
301
+ const lockState = inspectLock();
302
+ if (lockState.state === "missing") {
303
+ const cache = clearClaudeGatewayCache();
304
+ writeCommandOutput(opts, formatStopHumanLine("Not running.", cache), {
305
+ status: "not_running",
306
+ claude_cache: cache
307
+ });
308
+ return;
309
+ }
310
+ if (lockState.state === "stale") {
311
+ releaseLock();
312
+ const cache = clearClaudeGatewayCache();
313
+ writeCommandOutput(opts, formatStopHumanLine("Removed stale lock.", cache), {
314
+ status: "stale_lock_removed",
315
+ reason: lockState.reason,
316
+ claude_cache: cache
317
+ });
318
+ return;
319
+ }
320
+ await stopByPid(lockState.lock.pid);
321
+ const cache = clearClaudeGatewayCache();
322
+ writeCommandOutput(opts, formatStopHumanLine("Stopped.", cache), {
323
+ status: "ok",
324
+ pid: lockState.lock.pid,
325
+ claude_cache: cache
326
+ });
327
+ });
328
+ program
329
+ .command("status")
330
+ .description("Show daemon status")
331
+ .option("--json", "JSON output")
332
+ .action(async (opts) => {
333
+ const config = loadConfig();
334
+ const lockState = inspectLock();
335
+ const checkedAtIso = new Date().toISOString();
336
+ const uptimeSeconds = lockState.state === "running" ? computeUptimeSeconds(lockState.lock.started_at_iso) : null;
337
+ // inspectStoredCredential never returns the token itself, so it's safe to
338
+ // include the result in the status payload.
339
+ let authInfo;
340
+ try {
341
+ const info = await inspectStoredCredential();
342
+ authInfo = { stored: info.stored, backend: info.backend, error: null };
343
+ }
344
+ catch (error) {
345
+ const message = error instanceof Error ? error.message : "unknown_error";
346
+ authInfo = { stored: false, backend: null, error: message };
347
+ }
348
+ const status = {
349
+ running: lockState.state === "running",
350
+ stale: lockState.state === "stale",
351
+ pid: lockState.state === "running" ? lockState.lock.pid : null,
352
+ port: lockState.state === "running" ? lockState.lock.port : null,
353
+ started_at_iso: lockState.state === "running" ? lockState.lock.started_at_iso : null,
354
+ uptime_seconds: uptimeSeconds,
355
+ url: lockState.state === "running" ? `http://127.0.0.1:${lockState.lock.port}` : null,
356
+ require_caller_secret: config.requireCallerSecret,
357
+ account_type: config.accountType,
358
+ selected_models: config.selectedModels,
359
+ auth: authInfo,
360
+ bearer_ttl_seconds: null,
361
+ health_check_status_code: null,
362
+ health_state: null,
363
+ health_error: null,
364
+ health_status: "unknown",
365
+ checked_at_iso: checkedAtIso,
366
+ stale_reason: lockState.state === "stale" ? lockState.reason : null
367
+ };
368
+ if (lockState.state === "running") {
369
+ const health = await probeHealth(lockState.lock.port);
370
+ status.health_status = health.ok ? "ok" : "degraded";
371
+ status.bearer_ttl_seconds = health.bearerTtlSeconds;
372
+ status.health_check_status_code = health.statusCode;
373
+ status.health_state = health.status;
374
+ status.health_error = health.error;
375
+ }
376
+ if (opts.json) {
377
+ process.stdout.write(JSON.stringify(status, null, 2) + "\n");
378
+ return;
379
+ }
380
+ if (lockState.state === "running") {
381
+ process.stdout.write(`running (pid ${lockState.lock.pid}, port ${lockState.lock.port})\n`);
382
+ process.stdout.write(`health: ${status.health_status}`);
383
+ if (status.health_state) {
384
+ process.stdout.write(` (${status.health_state})`);
385
+ }
386
+ if (status.health_check_status_code !== null) {
387
+ process.stdout.write(` [http ${status.health_check_status_code}]`);
388
+ }
389
+ if (status.health_error) {
390
+ process.stdout.write(` error=${status.health_error}`);
391
+ }
392
+ process.stdout.write("\n");
393
+ if (status.bearer_ttl_seconds !== null) {
394
+ process.stdout.write(`bearer_ttl_seconds: ${status.bearer_ttl_seconds}\n`);
395
+ }
396
+ if (status.uptime_seconds !== null) {
397
+ process.stdout.write(`uptime_seconds: ${status.uptime_seconds}\n`);
398
+ }
399
+ writeAuthStatusLine(authInfo);
400
+ process.stdout.write(`checked_at: ${status.checked_at_iso}\n`);
401
+ return;
402
+ }
403
+ if (lockState.state === "stale") {
404
+ process.stdout.write(`stale lock (${lockState.reason})\n`);
405
+ writeAuthStatusLine(authInfo);
406
+ return;
407
+ }
408
+ process.stdout.write("not running\n");
409
+ writeAuthStatusLine(authInfo);
410
+ });
411
+ function writeAuthStatusLine(authInfo) {
412
+ if (authInfo.error) {
413
+ process.stdout.write(`auth: error (${authInfo.error})\n`);
414
+ return;
415
+ }
416
+ if (authInfo.stored) {
417
+ process.stdout.write(`auth: logged in (${describeBackend(authInfo.backend)})\n`);
418
+ }
419
+ else {
420
+ process.stdout.write("auth: not logged in\n");
421
+ }
422
+ }
423
+ program
424
+ .command("health")
425
+ .description("Check health endpoint")
426
+ .option("--json", "JSON output")
427
+ .action(async (opts) => {
428
+ const lockState = inspectLock();
429
+ if (lockState.state !== "running") {
430
+ const payload = {
431
+ ok: false,
432
+ status: lockState.state === "stale" ? "stale_lock" : "not_running",
433
+ detail: lockState.state === "stale" ? lockState.reason : "Daemon is not running."
434
+ };
435
+ writeHealthOutput(opts, payload);
436
+ process.exitCode = 1;
437
+ return;
438
+ }
439
+ const response = await fetch(`http://127.0.0.1:${lockState.lock.port}/healthz`, { signal: AbortSignal.timeout(2_000) });
440
+ const payload = (await response.json());
441
+ const output = { ok: response.ok, status_code: response.status, ...payload };
442
+ writeHealthOutput(opts, output);
443
+ if (!response.ok) {
444
+ process.exitCode = 1;
445
+ }
446
+ });
447
+ const models = program.command("models").description("Model commands");
448
+ models
449
+ .command("list")
450
+ .description("List entitled models")
451
+ .option("--json", "JSON output")
452
+ .action(async (opts) => {
453
+ const config = loadConfig();
454
+ const creds = await loadStoredCredential();
455
+ if (!creds) {
456
+ throw new Error("Not authenticated. Run `copillm login`.");
457
+ }
458
+ const tokenManager = new CopilotTokenManager(creds.token);
459
+ await tokenManager.ensureToken(false);
460
+ const result = await listModels(config.accountType, creds.token);
461
+ if (opts.json) {
462
+ process.stdout.write(JSON.stringify({
463
+ models: result.models,
464
+ discovery: {
465
+ source: result.source,
466
+ stale: result.stale,
467
+ cache_age_seconds: result.cacheAgeSeconds,
468
+ warning: result.warning
469
+ }
470
+ }, null, 2) + "\n");
471
+ return;
472
+ }
473
+ process.stdout.write(result.models.map((model) => model.id).join("\n") + "\n");
474
+ if (result.stale) {
475
+ process.stdout.write("⚠ using stale model snapshot (upstream discovery unavailable)\n");
476
+ }
477
+ });
478
+ models
479
+ .command("select")
480
+ .requiredOption("--models <ids>", "Comma-separated model ids")
481
+ .description("Select exposed models")
482
+ .option("--json", "JSON output")
483
+ .action(async (opts) => {
484
+ const config = loadConfig();
485
+ const requested = Array.from(new Set(opts.models
486
+ .split(",")
487
+ .map((value) => value.trim())
488
+ .filter((value) => value.length > 0)));
489
+ if (requested.length === 0) {
490
+ throw new Error("At least one model must be selected.");
491
+ }
492
+ const creds = await loadStoredCredential();
493
+ if (!creds) {
494
+ throw new Error("Not authenticated. Run `copillm login`.");
495
+ }
496
+ const tokenManager = new CopilotTokenManager(creds.token);
497
+ await tokenManager.ensureToken(false);
498
+ const discovery = await listModels(config.accountType, creds.token);
499
+ const resolution = resolveModelSelections(requested, discovery.models);
500
+ if (resolution.unresolved.length > 0) {
501
+ const available = discovery.models.map((model) => model.id).join(", ");
502
+ throw new Error(`Unknown model selection(s): ${resolution.unresolved.join(", ")}. Available models: ${available}`);
503
+ }
504
+ const resolvedSelected = Array.from(new Set(resolution.resolved.map((entry) => entry.resolvedId)));
505
+ saveConfig({ ...config, selectedModels: resolvedSelected });
506
+ const usedAlias = resolution.resolved.some((entry) => entry.input !== entry.resolvedId);
507
+ writeCommandOutput(opts, `Selected ${resolvedSelected.length} model(s)${usedAlias ? " (resolved aliases)." : "."}${discovery.stale ? " Using stale snapshot." : ""}`, {
508
+ status: "ok",
509
+ selected_models: resolvedSelected,
510
+ requested_models: requested,
511
+ resolutions: resolution.resolved,
512
+ discovery: {
513
+ source: discovery.source,
514
+ stale: discovery.stale,
515
+ cache_age_seconds: discovery.cacheAgeSeconds,
516
+ warning: discovery.warning
517
+ }
518
+ });
519
+ });
520
+ program
521
+ .command("env <agent>")
522
+ .description("Print env vars to launch codex, claude, or pi against copillm")
523
+ .option("--shell <shell>", "Shell syntax: sh|fish|powershell", "sh")
524
+ .option("--json", "JSON output")
525
+ .option("--inline", "Single-line legacy export form (claude only)")
526
+ .action(async (agentRaw, opts) => {
527
+ const agent = parseAgentName(agentRaw);
528
+ if (!isShellSyntax(opts.shell)) {
529
+ throw new Error(`Unsupported --shell value: ${opts.shell}. Use sh, fish, or powershell.`);
530
+ }
531
+ const shell = opts.shell;
532
+ const lockState = inspectLock();
533
+ if (lockState.state !== "running") {
534
+ const message = lockState.state === "stale"
535
+ ? `copillm has a stale lock (${lockState.reason}). Run \`copillm stop\` then \`copillm start --detach\`.`
536
+ : "copillm is not running. Run `copillm start --detach` first.";
537
+ if (opts.json) {
538
+ process.stdout.write(JSON.stringify({ status: "not_running", agent, error: message }, null, 2) + "\n");
539
+ }
540
+ else {
541
+ process.stderr.write(`${message}\n`);
542
+ }
543
+ process.exit(2);
544
+ return;
545
+ }
546
+ if (agent === "codex") {
547
+ const codex = await refreshCodexHome(lockState.lock.port, null);
548
+ if (!codex) {
549
+ throw new Error("Failed to prepare Codex home (see warning above).");
550
+ }
551
+ const bundle = buildCodexEnvBundle(codex.outDir);
552
+ const block = renderEnvBlock({
553
+ agent: "codex",
554
+ env: bundle.env,
555
+ shell,
556
+ inlineComments: bundle.inlineComments,
557
+ trailingNotes: bundle.trailingNotes
558
+ });
559
+ if (opts.json) {
560
+ process.stdout.write(JSON.stringify({
561
+ agent: "codex",
562
+ package: "@openai/codex",
563
+ shell,
564
+ env: bundle.env,
565
+ shell_block: block
566
+ }, null, 2) + "\n");
567
+ }
568
+ else {
569
+ process.stdout.write(`${block}\n`);
570
+ }
571
+ process.exit(0);
572
+ }
573
+ if (agent === "pi") {
574
+ const pi = await refreshPiHome(lockState.lock.port);
575
+ if (!pi) {
576
+ throw new Error("Failed to prepare pi models.json (see warning above).");
577
+ }
578
+ const bundle = buildPiEnvBundle(pi.outDir);
579
+ const block = renderEnvBlock({
580
+ agent: "pi",
581
+ env: bundle.env,
582
+ shell,
583
+ inlineComments: bundle.inlineComments,
584
+ trailingNotes: bundle.trailingNotes
585
+ });
586
+ if (opts.json) {
587
+ process.stdout.write(JSON.stringify({
588
+ agent: "pi",
589
+ package: "@earendil-works/pi-coding-agent",
590
+ shell,
591
+ env: bundle.env,
592
+ shell_block: block,
593
+ pi_home: pi.outDir,
594
+ pi_config_path: pi.configPath,
595
+ pi_mirror_path: pi.mirrorPath,
596
+ pi_backup_path: pi.backupPath,
597
+ pi_model_count: pi.modelCount
598
+ }, null, 2) + "\n");
599
+ }
600
+ else {
601
+ process.stdout.write(`${block}\n`);
602
+ }
603
+ process.exit(0);
604
+ }
605
+ const claude = buildClaudeExportCommand(lockState.lock.port, null);
606
+ const settingsConflicts = detectClaudeSettingsConflicts(claude.bundle.env);
607
+ if (opts.inline) {
608
+ if (opts.json) {
609
+ process.stdout.write(JSON.stringify({ agent: "claude", inline: claude.command }, null, 2) + "\n");
610
+ }
611
+ else {
612
+ process.stdout.write(`${claude.command}\n`);
613
+ }
614
+ for (const line of formatSettingsConflictWarning(settingsConflicts)) {
615
+ process.stderr.write(`${line}\n`);
616
+ }
617
+ process.exit(0);
618
+ }
619
+ const block = renderEnvBlock({
620
+ agent: "claude",
621
+ env: claude.bundle.env,
622
+ shell,
623
+ inlineComments: claude.bundle.inlineComments,
624
+ trailingNotes: claude.bundle.trailingNotes
625
+ });
626
+ if (opts.json) {
627
+ process.stdout.write(JSON.stringify({
628
+ agent: "claude",
629
+ package: "@anthropic-ai/claude-code",
630
+ shell,
631
+ env: claude.bundle.env,
632
+ shell_block: block,
633
+ defaults: claude.defaults,
634
+ settings_conflicts: settingsConflicts.conflicts
635
+ }, null, 2) + "\n");
636
+ }
637
+ else {
638
+ process.stdout.write(`${block}\n`);
639
+ }
640
+ for (const line of formatSettingsConflictWarning(settingsConflicts)) {
641
+ process.stderr.write(`${line}\n`);
642
+ }
643
+ process.exit(0);
644
+ });
645
+ program
646
+ .command("codex")
647
+ .description("Launch Codex CLI against copillm (auto-starts daemon, downloads codex if missing)")
648
+ .option("--copillm-use <spec>", "Pin codex package version (e.g. 1.4.7 or @openai/codex@1.4.7)")
649
+ .option("--copillm-debug", "Enable debug endpoints when auto-starting daemon")
650
+ .option("--copillm-profile <name>", "Override active profile from ~/.copillm/agent.toml for this launch")
651
+ .option("--copillm-no-config", "Skip agent.toml fan-out for this launch", false)
652
+ .allowUnknownOption(true)
653
+ .passThroughOptions()
654
+ .helpOption(false)
655
+ .argument("[args...]", "Args forwarded to codex")
656
+ .action(async (forwardedArgs, opts) => {
657
+ const lock = await ensureDaemonRunningForLauncher({ debug: Boolean(opts.copillmDebug) });
658
+ const codex = await refreshCodexHome(lock.port, null);
659
+ if (!codex) {
660
+ throw new Error("Failed to prepare Codex home (see warning above).");
661
+ }
662
+ const bundle = buildCodexEnvBundle(codex.outDir);
663
+ const pinnedSpec = opts.copillmUse ?? process.env.COPILLM_CODEX_VERSION ?? undefined;
664
+ const applyResult = applyAgentConfig({
665
+ agent: "codex",
666
+ cwd: process.cwd(),
667
+ codexHomeDir: codex.outDir,
668
+ profileOverride: opts.copillmProfile ?? process.env.COPILLM_PROFILE ?? null,
669
+ skip: Boolean(opts.copillmNoConfig)
670
+ });
671
+ for (const line of formatApplyNotes(applyResult, "codex")) {
672
+ process.stderr.write(`${line}\n`);
673
+ }
674
+ const env = { ...bundle.env, ...applyResult.envOverlay };
675
+ const exitCode = await launchAgent({
676
+ agent: "codex",
677
+ args: [...(forwardedArgs ?? []), ...applyResult.cliArgs],
678
+ env,
679
+ pinnedSpec
680
+ });
681
+ process.exit(exitCode);
682
+ });
683
+ program
684
+ .command("claude")
685
+ .description("Launch Claude Code against copillm (auto-starts daemon, downloads claude if missing)")
686
+ .option("--copillm-use <spec>", "Pin claude package version (e.g. 1.0.0 or @anthropic-ai/claude-code@1.0.0)")
687
+ .option("--copillm-debug", "Enable debug endpoints when auto-starting daemon")
688
+ .option("--copillm-profile <name>", "Override active profile from ~/.copillm/agent.toml for this launch")
689
+ .option("--copillm-no-config", "Skip agent.toml fan-out for this launch", false)
690
+ .allowUnknownOption(true)
691
+ .passThroughOptions()
692
+ .helpOption(false)
693
+ .argument("[args...]", "Args forwarded to claude")
694
+ .action(async (forwardedArgs, opts) => {
695
+ const lock = await ensureDaemonRunningForLauncher({ debug: Boolean(opts.copillmDebug) });
696
+ const claude = buildClaudeExportCommand(lock.port, null);
697
+ const pinnedSpec = opts.copillmUse ?? process.env.COPILLM_CLAUDE_VERSION ?? undefined;
698
+ const conflicts = detectClaudeSettingsConflicts(claude.bundle.env);
699
+ for (const line of formatSettingsConflictWarning(conflicts)) {
700
+ process.stderr.write(`${line}\n`);
701
+ }
702
+ const applyResult = applyAgentConfig({
703
+ agent: "claude",
704
+ cwd: process.cwd(),
705
+ profileOverride: opts.copillmProfile ?? process.env.COPILLM_PROFILE ?? null,
706
+ skip: Boolean(opts.copillmNoConfig)
707
+ });
708
+ for (const line of formatApplyNotes(applyResult, "claude")) {
709
+ process.stderr.write(`${line}\n`);
710
+ }
711
+ const env = { ...claude.bundle.env, ...applyResult.envOverlay };
712
+ const exitCode = await launchAgent({
713
+ agent: "claude",
714
+ args: [...(forwardedArgs ?? []), ...applyResult.cliArgs],
715
+ env,
716
+ pinnedSpec
717
+ });
718
+ process.exit(exitCode);
719
+ });
720
+ program
721
+ .command("pi")
722
+ .description("Launch pi coding agent against copillm (auto-starts daemon, downloads pi if missing)")
723
+ .option("--copillm-use <spec>", "Pin pi package version (e.g. 0.75.4 or @earendil-works/pi-coding-agent@0.75.4)")
724
+ .option("--copillm-debug", "Enable debug endpoints when auto-starting daemon")
725
+ .option("--copillm-profile <name>", "Override active profile from ~/.copillm/agent.toml for this launch")
726
+ .option("--copillm-no-config", "Skip agent.toml fan-out for this launch", false)
727
+ .allowUnknownOption(true)
728
+ .passThroughOptions()
729
+ .helpOption(false)
730
+ .argument("[args...]", "Args forwarded to pi")
731
+ .action(async (forwardedArgs, opts) => {
732
+ const lock = await ensureDaemonRunningForLauncher({ debug: Boolean(opts.copillmDebug) });
733
+ const pi = await refreshPiHome(lock.port);
734
+ if (!pi) {
735
+ throw new Error("Failed to prepare pi models.json (see warning above).");
736
+ }
737
+ const bundle = buildPiEnvBundle(pi.outDir);
738
+ const pinnedSpec = opts.copillmUse ?? process.env.COPILLM_PI_VERSION ?? undefined;
739
+ const applyResult = applyAgentConfig({
740
+ agent: "pi",
741
+ cwd: process.cwd(),
742
+ profileOverride: opts.copillmProfile ?? process.env.COPILLM_PROFILE ?? null,
743
+ skip: Boolean(opts.copillmNoConfig)
744
+ });
745
+ for (const line of formatApplyNotes(applyResult, "pi")) {
746
+ process.stderr.write(`${line}\n`);
747
+ }
748
+ const env = { ...bundle.env, ...applyResult.envOverlay };
749
+ const exitCode = await launchAgent({
750
+ agent: "pi",
751
+ args: [...(forwardedArgs ?? []), ...applyResult.cliArgs],
752
+ env,
753
+ pinnedSpec
754
+ });
755
+ process.exit(exitCode);
756
+ });
757
+ registerConfigCommands(program);
758
+ program.parseAsync(process.argv).catch((error) => {
759
+ if (error instanceof Error) {
760
+ logger.error({ err: error }, error.message);
761
+ process.stderr.write(`${error.message}\n`);
762
+ process.exit(1);
763
+ }
764
+ throw error;
765
+ });
766
+ async function runDaemon(options) {
767
+ const config = loadConfig();
768
+ const creds = await loadStoredCredential();
769
+ if (!creds) {
770
+ throw new Error("Not authenticated. Run `copillm login` first.");
771
+ }
772
+ const tokenManager = new CopilotTokenManager(creds.token);
773
+ await tokenManager.ensureToken(false);
774
+ const callerSecret = config.requireCallerSecret ? randomUUID() : null;
775
+ if (callerSecret) {
776
+ process.stdout.write(`Caller secret: ${callerSecret}\n`);
777
+ }
778
+ const ports = candidatePorts(config.preferredPort);
779
+ let server = null;
780
+ let selectedPort = null;
781
+ for (const port of ports) {
782
+ try {
783
+ await acquireLock(port, { isRunning: async (lock) => probeLivez(lock.port) });
784
+ }
785
+ catch (error) {
786
+ if (error instanceof LockAlreadyRunningError) {
787
+ tokenManager.clear();
788
+ return { kind: "already_running", lock: error.lock };
789
+ }
790
+ throw error;
791
+ }
792
+ try {
793
+ server = await startProxyServer({
794
+ port,
795
+ config,
796
+ tokenManager,
797
+ callerSecret,
798
+ logger,
799
+ debug: Boolean(options?.debug),
800
+ githubToken: creds.token
801
+ });
802
+ selectedPort = port;
803
+ break;
804
+ }
805
+ catch (error) {
806
+ releaseLock();
807
+ if (isAddrInUse(error)) {
808
+ continue;
809
+ }
810
+ throw error;
811
+ }
812
+ }
813
+ if (!server || selectedPort === null) {
814
+ tokenManager.clear();
815
+ throw new Error(`No available port in configured range (${ports[0]}-${ports[ports.length - 1]}).`);
816
+ }
817
+ let shuttingDown = false;
818
+ const shutdown = async () => {
819
+ if (shuttingDown) {
820
+ return;
821
+ }
822
+ shuttingDown = true;
823
+ try {
824
+ await withTimeout(server.close(), 5_000, "Timed out while draining requests.");
825
+ }
826
+ catch (error) {
827
+ logger.warn({ err: error }, "graceful shutdown timed out");
828
+ }
829
+ finally {
830
+ tokenManager.clear();
831
+ releaseLock();
832
+ process.exit(0);
833
+ }
834
+ };
835
+ process.once("SIGINT", () => {
836
+ void shutdown();
837
+ });
838
+ process.once("SIGTERM", () => {
839
+ void shutdown();
840
+ });
841
+ return { kind: "started", port: selectedPort, callerSecret };
842
+ }
843
+ function candidatePorts(preferredPort) {
844
+ const ports = [];
845
+ for (let offset = 0; offset < 10; offset += 1) {
846
+ const port = preferredPort + offset;
847
+ if (port <= 65535) {
848
+ ports.push(port);
849
+ }
850
+ }
851
+ return ports;
852
+ }
853
+ function describeBackend(backend) {
854
+ switch (backend) {
855
+ case "keytar":
856
+ return "OS keychain";
857
+ case "file":
858
+ return "credentials file";
859
+ case "session":
860
+ return "in-memory (session only)";
861
+ default:
862
+ return "no backend";
863
+ }
864
+ }
865
+ function formatHumanAuthStatusLine(backend, identity) {
866
+ if (!identity) {
867
+ return `logged in (${describeBackend(backend)})`;
868
+ }
869
+ const nameSuffix = identity.name && identity.name !== identity.login ? ` (${identity.name})` : "";
870
+ return `logged in as @${identity.login}${nameSuffix} (${describeBackend(backend)})`;
871
+ }
872
+ function emitDeprecation(opts, oldCmd, newCmd) {
873
+ if (opts.json) {
874
+ // Keep stdout pristine for JSON consumers; deprecation goes to stderr.
875
+ process.stderr.write(`note: \`copillm ${oldCmd}\` is deprecated; use \`copillm ${newCmd}\`\n`);
876
+ }
877
+ else {
878
+ process.stderr.write(`note: \`copillm ${oldCmd}\` is deprecated; use \`copillm ${newCmd}\`\n`);
879
+ }
880
+ }
881
+ async function runAuthLogin(opts, options) {
882
+ if (options.forceSession) {
883
+ process.env.COPILLM_FORCE_SESSION_BACKEND = "1";
884
+ }
885
+ const config = loadConfig();
886
+ const token = await loginViaDeviceFlow();
887
+ const saveMode = options.forceSession ? "session" : "auto";
888
+ const backend = await saveStoredCredential(token, config.accountType, { mode: saveMode });
889
+ writeCommandOutput(opts, `Login succeeded. Credentials stored via ${describeBackend(backend)}.`, {
890
+ status: "ok",
891
+ action: "login",
892
+ credential_backend: backend
893
+ });
894
+ }
895
+ async function runAuthLogout(opts) {
896
+ const result = await clearStoredCredential();
897
+ const lockState = inspectLock();
898
+ if (lockState.state === "running") {
899
+ await stopByPid(lockState.lock.pid);
900
+ }
901
+ else if (lockState.state === "stale") {
902
+ releaseLock();
903
+ }
904
+ const credentialStatus = result.removed ? "removed" : "not present";
905
+ writeCommandOutput(opts, `Logged out. Credentials ${credentialStatus} from ${describeBackend(result.backend)}.`, {
906
+ status: "ok",
907
+ action: "logout",
908
+ credential_backend: result.backend,
909
+ credential_removed: result.removed
910
+ });
911
+ }
912
+ /**
913
+ * Build the default dependency bundle for ensureAuthenticatedInteractive.
914
+ * Lives here (rather than inside the auth module) so the auth module stays
915
+ * UI-framework-agnostic and tests can supply alternative implementations.
916
+ */
917
+ function defaultEnsureAuthDeps() {
918
+ return {
919
+ inspectStoredCredential,
920
+ isTty: () => process.stdin.isTTY === true,
921
+ confirm,
922
+ choose,
923
+ loginViaDeviceFlow,
924
+ loadAccountType: () => loadConfig().accountType,
925
+ saveStoredCredential,
926
+ describeBackend,
927
+ print: (line) => process.stdout.write(line),
928
+ setEnv: (key, value) => {
929
+ process.env[key] = value;
930
+ }
931
+ };
932
+ }
933
+ async function ensureAuthenticatedInteractive() {
934
+ return ensureAuthenticatedInteractiveImpl(defaultEnsureAuthDeps());
935
+ }
936
+ function writeCommandOutput(opts, humanLine, payload) {
937
+ if (opts.json) {
938
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
939
+ return;
940
+ }
941
+ process.stdout.write(`${humanLine}\n`);
942
+ }
943
+ function formatStopHumanLine(primary, cache) {
944
+ if (cache.cleared) {
945
+ return `${primary} Cleared Claude Code gateway cache.`;
946
+ }
947
+ if (cache.reason === "not_present") {
948
+ return primary;
949
+ }
950
+ return `${primary} Could not clear Claude Code gateway cache: ${cache.reason ?? "unknown error"}.`;
951
+ }
952
+ function writeHealthOutput(opts, payload) {
953
+ if (opts.json) {
954
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
955
+ return;
956
+ }
957
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
958
+ }
959
+ function isAddrInUse(error) {
960
+ return error instanceof Error && "code" in error && error.code === "EADDRINUSE";
961
+ }
962
+ async function refreshCodexHome(port, model) {
963
+ try {
964
+ const home = getCopillmHome();
965
+ return await generateCodexHome({
966
+ outDir: defaultOutputDir(home),
967
+ model,
968
+ port,
969
+ providerId: "copillm",
970
+ reasoningEffort: null
971
+ });
972
+ }
973
+ catch (error) {
974
+ const message = error instanceof Error ? error.message : "unknown_error";
975
+ process.stderr.write(`warning: failed to generate ~/.copillm/codex/ — ${message}\n`);
976
+ return null;
977
+ }
978
+ }
979
+ async function refreshPiHome(port) {
980
+ try {
981
+ const home = getCopillmHome();
982
+ return await generatePiHome({
983
+ outDir: defaultPiOutputDir(home),
984
+ port,
985
+ providerId: "copillm"
986
+ });
987
+ }
988
+ catch (error) {
989
+ const message = error instanceof Error ? error.message : "unknown_error";
990
+ process.stderr.write(`warning: failed to generate pi models.json — ${message}\n`);
991
+ return null;
992
+ }
993
+ }
994
+ function buildClaudeExportCommand(port, callerSecret) {
995
+ const modelIds = readModelIdsFromCache();
996
+ const defaults = computeAnthropicDefaults(modelIds);
997
+ const command = buildClaudeExport({
998
+ port,
999
+ callerSecret,
1000
+ defaults,
1001
+ enableGatewayDiscovery: true
1002
+ });
1003
+ const bundle = buildClaudeEnvBundle({ port, callerSecret, defaults, enableGatewayDiscovery: true });
1004
+ return { command, defaults, bundle };
1005
+ }
1006
+ function formatStartBanner(input) {
1007
+ const verb = input.mode === "foreground" ? "listening on" : "running on";
1008
+ const lines = [];
1009
+ const debugSuffix = input.debug ? " [debug]" : "";
1010
+ const modeSuffix = input.mode === "already_running" ? " (already running)" : "";
1011
+ lines.push(`\u25CF copillm ${verb} http://127.0.0.1:${input.port} (pid ${input.pid})${debugSuffix}${modeSuffix}`);
1012
+ if (input.codex) {
1013
+ lines.push(` ${input.codex.modelCount} Copilot models discovered \u00B7 default: ${input.codex.defaultModel}`);
1014
+ }
1015
+ if (input.pi) {
1016
+ lines.push(` pi: wrote ${input.pi.modelCount} models to ${displayHomePath(input.pi.configPath)}${input.pi.backupPath ? ` (backed up prior config to ${displayHomePath(input.pi.backupPath)})` : ""}`);
1017
+ }
1018
+ lines.push(``);
1019
+ lines.push(`Launch an agent against copillm:`);
1020
+ if (input.codex) {
1021
+ lines.push(` copillm codex # starts Codex CLI, preconfigured`);
1022
+ }
1023
+ lines.push(` copillm claude # starts Claude Code, preconfigured`);
1024
+ if (input.pi) {
1025
+ lines.push(` copillm pi # starts pi coding agent, preconfigured`);
1026
+ }
1027
+ lines.push(``);
1028
+ lines.push(`Or print env vars to use yourself:`);
1029
+ if (input.codex) {
1030
+ lines.push(` copillm env codex`);
1031
+ }
1032
+ lines.push(` copillm env claude`);
1033
+ if (input.pi) {
1034
+ lines.push(` copillm env pi`);
1035
+ }
1036
+ return lines.join("\n");
1037
+ }
1038
+ function displayHomePath(p) {
1039
+ const home = process.env.HOME ?? process.env.USERPROFILE;
1040
+ if (home && p.startsWith(home)) {
1041
+ return p.replace(home, "~");
1042
+ }
1043
+ return p;
1044
+ }
1045
+ async function probeLivez(port) {
1046
+ try {
1047
+ const response = await fetch(`http://127.0.0.1:${port}/livez`, { signal: AbortSignal.timeout(800) });
1048
+ return response.ok;
1049
+ }
1050
+ catch {
1051
+ return false;
1052
+ }
1053
+ }
1054
+ async function probeHealth(port) {
1055
+ try {
1056
+ const response = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(1_500) });
1057
+ const payload = (await response.json());
1058
+ return {
1059
+ ok: response.ok,
1060
+ statusCode: response.status,
1061
+ status: typeof payload.status === "string" ? payload.status : null,
1062
+ error: typeof payload.error === "string" ? payload.error : null,
1063
+ bearerTtlSeconds: response.ok && typeof payload.bearer_ttl_seconds === "number" ? payload.bearer_ttl_seconds : null
1064
+ };
1065
+ }
1066
+ catch {
1067
+ return { ok: false, bearerTtlSeconds: null, statusCode: null, status: null, error: "health_probe_failed" };
1068
+ }
1069
+ }
1070
+ async function waitForDaemonReady(pid, timeoutMs) {
1071
+ const startedAt = Date.now();
1072
+ while (Date.now() - startedAt <= timeoutMs) {
1073
+ const lockState = inspectLock();
1074
+ if (lockState.state === "running" && (await probeLivez(lockState.lock.port))) {
1075
+ return { pid: lockState.lock.pid, port: lockState.lock.port };
1076
+ }
1077
+ if (pid !== null && !isPidAlive(pid)) {
1078
+ return null;
1079
+ }
1080
+ await sleep(150);
1081
+ }
1082
+ return null;
1083
+ }
1084
+ function isPidAlive(pid) {
1085
+ try {
1086
+ process.kill(pid, 0);
1087
+ return true;
1088
+ }
1089
+ catch {
1090
+ return false;
1091
+ }
1092
+ }
1093
+ async function stopByPid(pid) {
1094
+ if (!sendSignalIfAlive(pid, "SIGTERM")) {
1095
+ return;
1096
+ }
1097
+ const stopDeadline = Date.now() + 8_000;
1098
+ while (Date.now() < stopDeadline) {
1099
+ const lockState = inspectLock();
1100
+ if (lockState.state !== "running" || lockState.lock.pid !== pid) {
1101
+ return;
1102
+ }
1103
+ await sleep(150);
1104
+ }
1105
+ if (!sendSignalIfAlive(pid, "SIGKILL")) {
1106
+ return;
1107
+ }
1108
+ const killDeadline = Date.now() + 2_000;
1109
+ while (Date.now() < killDeadline) {
1110
+ const lockState = inspectLock();
1111
+ if (lockState.state !== "running" || lockState.lock.pid !== pid) {
1112
+ return;
1113
+ }
1114
+ await sleep(100);
1115
+ }
1116
+ throw new Error(`Failed to stop daemon pid ${pid}.`);
1117
+ }
1118
+ async function withTimeout(promise, timeoutMs, message) {
1119
+ const timeoutPromise = sleep(timeoutMs).then(() => {
1120
+ throw new Error(message);
1121
+ });
1122
+ return Promise.race([promise, timeoutPromise]);
1123
+ }
1124
+ function sendSignalIfAlive(pid, signal) {
1125
+ try {
1126
+ process.kill(pid, signal);
1127
+ return true;
1128
+ }
1129
+ catch (error) {
1130
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") {
1131
+ return false;
1132
+ }
1133
+ throw error;
1134
+ }
1135
+ }
1136
+ async function readLiveLock() {
1137
+ const lockState = inspectLock();
1138
+ if (lockState.state !== "running") {
1139
+ return null;
1140
+ }
1141
+ return (await probeLivez(lockState.lock.port)) ? lockState.lock : null;
1142
+ }
1143
+ function computeUptimeSeconds(startedAtIso) {
1144
+ const startedMs = Date.parse(startedAtIso);
1145
+ if (!Number.isFinite(startedMs)) {
1146
+ return null;
1147
+ }
1148
+ return Math.max(0, Math.floor((Date.now() - startedMs) / 1000));
1149
+ }
1150
+ function parseAgentName(raw) {
1151
+ const v = raw.trim().toLowerCase();
1152
+ if (v === "codex" || v === "claude" || v === "pi")
1153
+ return v;
1154
+ throw new Error(`Unknown agent: ${raw}. Expected "codex", "claude", or "pi".`);
1155
+ }
1156
+ async function ensureDaemonRunningForLauncher(opts) {
1157
+ const live = await readLiveLock();
1158
+ if (live)
1159
+ return live;
1160
+ process.stderr.write(`Starting copillm in background...\n`);
1161
+ const daemonArgs = [process.argv[1], "daemon"];
1162
+ if (opts.debug)
1163
+ daemonArgs.push("--debug");
1164
+ const child = spawn(process.execPath, daemonArgs, {
1165
+ detached: true,
1166
+ stdio: "ignore"
1167
+ });
1168
+ child.unref();
1169
+ const started = await waitForDaemonReady(child.pid ?? null, 10_000);
1170
+ if (!started) {
1171
+ throw new Error("Auto-start of copillm daemon timed out.");
1172
+ }
1173
+ const inspection = inspectLock();
1174
+ if (inspection.state !== "running") {
1175
+ throw new Error("copillm daemon failed to register a lock after auto-start.");
1176
+ }
1177
+ return inspection.lock;
1178
+ }