copillm 0.2.8 → 0.3.0-beta.1

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 (50) hide show
  1. package/README.md +70 -2
  2. package/dist/agentconfig/load.js +9 -1
  3. package/dist/agentconfig/render.js +8 -5
  4. package/dist/agentconfig/schema.js +6 -0
  5. package/dist/auth/accountManager.js +118 -0
  6. package/dist/auth/accounts.js +161 -0
  7. package/dist/auth/copilotToken.js +92 -23
  8. package/dist/auth/credentials.js +216 -40
  9. package/dist/auth/deviceFlow.js +110 -23
  10. package/dist/auth/githubIdentity.js +14 -10
  11. package/dist/cli/agentEnv.js +15 -9
  12. package/dist/cli/auth/runAuth.js +206 -9
  13. package/dist/cli/commands/agents/claude.js +22 -2
  14. package/dist/cli/commands/agents/codex.js +22 -2
  15. package/dist/cli/commands/agents/copilot.js +25 -4
  16. package/dist/cli/commands/agents/pi.js +22 -2
  17. package/dist/cli/commands/agents/shared.js +57 -0
  18. package/dist/cli/commands/auth.js +58 -7
  19. package/dist/cli/commands/daemon.js +79 -17
  20. package/dist/cli/commands/models.js +0 -5
  21. package/dist/cli/copillmFlags.js +8 -0
  22. package/dist/cli/daemon/lifecycle.js +26 -0
  23. package/dist/cli/daemon/probes.js +99 -33
  24. package/dist/cli/daemon/runDaemon.js +21 -2
  25. package/dist/cli/index.js +12 -0
  26. package/dist/cli/integrations/claudeExport.js +6 -4
  27. package/dist/cli/integrations/refreshCodex.js +5 -2
  28. package/dist/cli/integrations/refreshPi.js +5 -2
  29. package/dist/cli/packageInfo.js +1 -1
  30. package/dist/cli/shared/devMode.js +98 -0
  31. package/dist/config/accountId.js +44 -0
  32. package/dist/config/config.js +13 -2
  33. package/dist/config/home.js +69 -0
  34. package/dist/integrations/claude/cache.js +5 -2
  35. package/dist/integrations/claude/settingsConflict.js +5 -2
  36. package/dist/integrations/codex/init.js +31 -10
  37. package/dist/integrations/pi/init.js +8 -17
  38. package/dist/models/anthropicDefaults.js +13 -4
  39. package/dist/models/discovery.js +141 -15
  40. package/dist/server/accountResolver.js +85 -0
  41. package/dist/server/debugInfo.js +69 -24
  42. package/dist/server/errors.js +18 -0
  43. package/dist/server/proxy.js +40 -8
  44. package/dist/server/routes/debug.js +11 -1
  45. package/dist/server/routes/models.js +12 -6
  46. package/dist/server/routes/proxyForward.js +3 -3
  47. package/dist/server/routes/shared.js +66 -21
  48. package/dist/server/upstream/copilotClient.js +1 -30
  49. package/dist/server/upstream/retryPolicy.js +99 -0
  50. package/package.json +4 -1
@@ -1,20 +1,23 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { inspectStoredCredential } from "../../auth/credentials.js";
3
3
  import { loadConfig } from "../../config/config.js";
4
+ import { getCopillmHome } from "../../config/home.js";
4
5
  import { clearClaudeGatewayCache } from "../../integrations/claude/cache.js";
6
+ import { resolveStartContext } from "../../integrations/codex/init.js";
5
7
  import { inspectLock, releaseLock } from "../../server/lock.js";
6
8
  import { buildCodexEnvBundle } from "../agentEnv.js";
7
9
  import { ensureAuthenticatedInteractive } from "../auth/ensure.js";
8
- import { computeUptimeSeconds, stopByPid } from "../daemon/lifecycle.js";
10
+ import { computeUptimeSeconds, formatUptime, stopByPid } from "../daemon/lifecycle.js";
9
11
  import { probeHealth, readLiveLock, waitForDaemonReady, warnIfDebugRequestedButInactive } from "../daemon/probes.js";
10
12
  import { runDaemon } from "../daemon/runDaemon.js";
11
13
  import { daemonSpawnEnv } from "../daemon/spawnEnv.js";
12
14
  import { buildClaudeExportCommand } from "../integrations/claudeExport.js";
13
- import { formatStartBanner, formatStopHumanLine } from "../integrations/banner.js";
15
+ import { formatStartBanner, formatStopHumanLine, displayHomePath } from "../integrations/banner.js";
14
16
  import { refreshCodexHome } from "../integrations/refreshCodex.js";
15
17
  import { refreshPiHome } from "../integrations/refreshPi.js";
16
18
  import { writeAuthStatusLine } from "../shared/backends.js";
17
19
  import { currentDebugLogPath, enableRuntimeDebug, getRootLogger, resolveCopillmDebug } from "../shared/debug.js";
20
+ import { isDevModeActive } from "../shared/devMode.js";
18
21
  import { writeCommandOutput, writeHealthOutput } from "../shared/output.js";
19
22
  import { buildSelfSpawnCommand } from "../daemon/selfSpawn.js";
20
23
  export function register(program) {
@@ -25,11 +28,14 @@ export function register(program) {
25
28
  .option("--debug", "Enable debug endpoints (e.g. /_debug)")
26
29
  .option("--no-codex", "Skip generating ~/.copillm/codex/ for Codex CLI")
27
30
  .option("--codex-model <id>", "Default Codex model slug")
28
- .option("--no-pi", "Skip generating ~/.pi/agent/models.json for pi coding agent")
31
+ .option("--no-pi", "Skip generating the copillm-owned pi models.json for pi coding agent")
29
32
  .option("--json", "JSON output")
30
33
  .action(async (opts) => {
31
34
  const debug = resolveCopillmDebug(opts.debug);
32
35
  enableRuntimeDebug(debug);
36
+ if (isDevModeActive()) {
37
+ process.stderr.write(`dev mode: isolated COPILLM_HOME ${displayHomePath(getCopillmHome())}\n`);
38
+ }
33
39
  if (opts.detach) {
34
40
  // Fail fast on missing credentials rather than letting the detached
35
41
  // child die silently and surface as a generic "start timed out" error.
@@ -40,8 +46,9 @@ export function register(program) {
40
46
  const existingLock = await readLiveLock();
41
47
  if (existingLock) {
42
48
  const activeDebug = await warnIfDebugRequestedButInactive(debug, existingLock.port);
43
- const codex = opts.codex === false ? null : await refreshCodexHome(existingLock.port, opts.codexModel ?? null);
44
- const pi = opts.pi === false ? null : await refreshPiHome(existingLock.port);
49
+ const shared = await loadSharedStartContextIfNeeded(opts);
50
+ const codex = opts.codex === false ? null : await refreshCodexHome(existingLock.port, opts.codexModel ?? null, shared);
51
+ const pi = opts.pi === false ? null : await refreshPiHome(existingLock.port, shared);
45
52
  const claude = buildClaudeExportCommand(existingLock.port, null);
46
53
  const banner = formatStartBanner({
47
54
  port: existingLock.port,
@@ -86,8 +93,9 @@ export function register(program) {
86
93
  if (!started) {
87
94
  throw new Error("Detached daemon start timed out.");
88
95
  }
89
- const codex = opts.codex === false ? null : await refreshCodexHome(started.port, opts.codexModel ?? null);
90
- const pi = opts.pi === false ? null : await refreshPiHome(started.port);
96
+ const sharedDetached = await loadSharedStartContextIfNeeded(opts);
97
+ const codex = opts.codex === false ? null : await refreshCodexHome(started.port, opts.codexModel ?? null, sharedDetached);
98
+ const pi = opts.pi === false ? null : await refreshPiHome(started.port, sharedDetached);
91
99
  const claude = buildClaudeExportCommand(started.port, null);
92
100
  const banner = formatStartBanner({
93
101
  port: started.port,
@@ -127,8 +135,9 @@ export function register(program) {
127
135
  const started = await runDaemon({ debug });
128
136
  if (started.kind === "already_running") {
129
137
  const activeDebug = await warnIfDebugRequestedButInactive(debug, started.lock.port);
130
- const codex = opts.codex === false ? null : await refreshCodexHome(started.lock.port, opts.codexModel ?? null);
131
- const pi = opts.pi === false ? null : await refreshPiHome(started.lock.port);
138
+ const sharedAlready = await loadSharedStartContextIfNeeded(opts);
139
+ const codex = opts.codex === false ? null : await refreshCodexHome(started.lock.port, opts.codexModel ?? null, sharedAlready);
140
+ const pi = opts.pi === false ? null : await refreshPiHome(started.lock.port, sharedAlready);
132
141
  const claude = buildClaudeExportCommand(started.lock.port, null);
133
142
  const banner = formatStartBanner({
134
143
  port: started.lock.port,
@@ -159,8 +168,9 @@ export function register(program) {
159
168
  });
160
169
  return;
161
170
  }
162
- const codex = opts.codex === false ? null : await refreshCodexHome(started.port, opts.codexModel ?? null);
163
- const pi = opts.pi === false ? null : await refreshPiHome(started.port);
171
+ const sharedForeground = await loadSharedStartContextIfNeeded(opts);
172
+ const codex = opts.codex === false ? null : await refreshCodexHome(started.port, opts.codexModel ?? null, sharedForeground);
173
+ const pi = opts.pi === false ? null : await refreshPiHome(started.port, sharedForeground);
164
174
  const claude = buildClaudeExportCommand(started.port, started.callerSecret);
165
175
  const banner = formatStartBanner({
166
176
  port: started.port,
@@ -271,10 +281,13 @@ export function register(program) {
271
281
  const status = {
272
282
  running: lockState.state === "running",
273
283
  stale: lockState.state === "stale",
284
+ copillm_home: getCopillmHome(),
285
+ dev_mode: isDevModeActive(),
274
286
  pid: lockState.state === "running" ? lockState.lock.pid : null,
275
287
  port: lockState.state === "running" ? lockState.lock.port : null,
276
288
  started_at_iso: lockState.state === "running" ? lockState.lock.started_at_iso : null,
277
289
  uptime_seconds: uptimeSeconds,
290
+ uptime_human: uptimeSeconds === null ? null : formatUptime(uptimeSeconds),
278
291
  url: lockState.state === "running" ? `http://127.0.0.1:${lockState.lock.port}` : null,
279
292
  require_caller_secret: config.requireCallerSecret,
280
293
  account_type: config.accountType,
@@ -300,6 +313,7 @@ export function register(program) {
300
313
  process.stdout.write(JSON.stringify(status, null, 2) + "\n");
301
314
  return;
302
315
  }
316
+ process.stdout.write(`home: ${displayHomePath(status.copillm_home)}${status.dev_mode ? " (dev)" : ""}\n`);
303
317
  if (lockState.state === "running") {
304
318
  process.stdout.write(`running (pid ${lockState.lock.pid}, port ${lockState.lock.port})\n`);
305
319
  process.stdout.write(`health: ${status.health_status}`);
@@ -317,7 +331,7 @@ export function register(program) {
317
331
  process.stdout.write(`bearer_ttl_seconds: ${status.bearer_ttl_seconds}\n`);
318
332
  }
319
333
  if (status.uptime_seconds !== null) {
320
- process.stdout.write(`uptime_seconds: ${status.uptime_seconds}\n`);
334
+ process.stdout.write(`uptime: ${formatUptime(status.uptime_seconds)} (${status.uptime_seconds}s)\n`);
321
335
  }
322
336
  writeAuthStatusLine(authInfo);
323
337
  process.stdout.write(`checked_at: ${status.checked_at_iso}\n`);
@@ -347,12 +361,60 @@ export function register(program) {
347
361
  process.exitCode = 1;
348
362
  return;
349
363
  }
350
- const response = await fetch(`http://127.0.0.1:${lockState.lock.port}/healthz`, { signal: AbortSignal.timeout(2_000) });
351
- const payload = (await response.json());
352
- const output = { ok: response.ok, status_code: response.status, ...payload };
353
- writeHealthOutput(opts, output);
354
- if (!response.ok) {
364
+ // Route the /healthz check through `probeHealth` so we inherit retry
365
+ // on transient transport errors AND a try/catch that turns a transient
366
+ // ECONNRESET into a structured `health_probe_failed` result instead of
367
+ // a raw stack trace. The previous bare `fetch` had no try/catch and
368
+ // would crash `copillm health` if the daemon had just been killed
369
+ // between `inspectLock` and the request.
370
+ const health = await probeHealth(lockState.lock.port);
371
+ const payload = {
372
+ ok: health.ok,
373
+ status_code: health.statusCode
374
+ };
375
+ if (health.status !== null)
376
+ payload.status = health.status;
377
+ if (health.error !== null)
378
+ payload.error = health.error;
379
+ if (health.bearerTtlSeconds !== null)
380
+ payload.bearer_ttl_seconds = health.bearerTtlSeconds;
381
+ writeHealthOutput(opts, payload);
382
+ if (!health.ok) {
355
383
  process.exitCode = 1;
356
384
  }
357
385
  });
358
386
  }
387
+ /**
388
+ * Load the shared credential/config/discovery context for `copillm start`'s
389
+ * codex + pi init steps, ONLY when at least one of them is going to run.
390
+ *
391
+ * Without sharing, each step independently re-reads the OS keychain, re-parses
392
+ * the YAML config, and re-fetches the upstream `/models` catalog. With this
393
+ * helper, the work happens once and both steps see the same snapshot.
394
+ *
395
+ * When both `--no-codex` and `--no-pi` are passed (or both wrappers will skip
396
+ * for some other reason), there's no consumer for the context, so we skip
397
+ * the loads entirely — important for `copillm start --no-codex --no-pi` to
398
+ * stay fast and not surface a credential error if the user genuinely just
399
+ * wants the proxy daemon up.
400
+ *
401
+ * Returning `undefined` (not `null`) so it composes naturally with the
402
+ * `precomputed?: PrecomputedStartContext` optional parameter on both
403
+ * `refreshCodexHome` and `refreshPiHome`.
404
+ */
405
+ async function loadSharedStartContextIfNeeded(opts) {
406
+ if (opts.codex === false && opts.pi === false) {
407
+ return undefined;
408
+ }
409
+ try {
410
+ return await resolveStartContext();
411
+ }
412
+ catch {
413
+ // If the load fails (e.g. no credentials, model discovery down), fall
414
+ // back to per-wrapper loads so each one can fail loudly with its own
415
+ // wrapper-specific warning. The wrappers already have try/catch that
416
+ // emit `warning: failed to generate ...` lines — preserving that
417
+ // surface keeps the user-visible behaviour unchanged from before.
418
+ return undefined;
419
+ }
420
+ }
@@ -1,5 +1,4 @@
1
1
  import { loadStoredCredential } from "../../auth/credentials.js";
2
- import { CopilotTokenManager } from "../../auth/copilotToken.js";
3
2
  import { loadConfig, saveConfig } from "../../config/config.js";
4
3
  import { listModels, resolveModelSelections } from "../../models/discovery.js";
5
4
  import { writeCommandOutput } from "../shared/output.js";
@@ -15,8 +14,6 @@ export function register(program) {
15
14
  if (!creds) {
16
15
  throw new Error("Not authenticated. Run `copillm login`.");
17
16
  }
18
- const tokenManager = new CopilotTokenManager(creds.token);
19
- await tokenManager.ensureToken(false);
20
17
  const result = await listModels(config.accountType, creds.token);
21
18
  if (opts.json) {
22
19
  process.stdout.write(JSON.stringify({
@@ -53,8 +50,6 @@ export function register(program) {
53
50
  if (!creds) {
54
51
  throw new Error("Not authenticated. Run `copillm login`.");
55
52
  }
56
- const tokenManager = new CopilotTokenManager(creds.token);
57
- await tokenManager.ensureToken(false);
58
53
  const discovery = await listModels(config.accountType, creds.token);
59
54
  const resolution = resolveModelSelections(requested, discovery.models);
60
55
  if (resolution.unresolved.length > 0) {
@@ -40,6 +40,14 @@ export const COPILLM_FLAGS = [
40
40
  kind: "swallow",
41
41
  description: "Override active profile for this launch"
42
42
  },
43
+ {
44
+ flag: "--copillm-account",
45
+ aliases: ["--account"],
46
+ takesValue: true,
47
+ dest: "copillmAccount",
48
+ kind: "swallow",
49
+ description: "Route this launch at a specific copillm account"
50
+ },
43
51
  {
44
52
  flag: "--copillm-no-config",
45
53
  aliases: ["--no-config"],
@@ -59,3 +59,29 @@ export function computeUptimeSeconds(startedAtIso) {
59
59
  }
60
60
  return Math.max(0, Math.floor((Date.now() - startedMs) / 1000));
61
61
  }
62
+ /**
63
+ * Render an uptime duration (in seconds) as a compact human-readable string
64
+ * broken down into days, hours, minutes, and seconds — e.g. `2d 3h 15m 9s`.
65
+ *
66
+ * Leading zero-value units are dropped so short uptimes stay terse
67
+ * (`45s`, `5m 2s`). Sub-minute and zero durations fall back to a seconds
68
+ * component so the result is never empty (`0s`). Negative or non-finite
69
+ * inputs clamp to `0s`.
70
+ */
71
+ export function formatUptime(totalSeconds) {
72
+ const seconds = Number.isFinite(totalSeconds) ? Math.max(0, Math.floor(totalSeconds)) : 0;
73
+ const days = Math.floor(seconds / 86_400);
74
+ const hours = Math.floor((seconds % 86_400) / 3_600);
75
+ const minutes = Math.floor((seconds % 3_600) / 60);
76
+ const secs = seconds % 60;
77
+ const parts = [];
78
+ if (days > 0)
79
+ parts.push(`${days}d`);
80
+ if (hours > 0)
81
+ parts.push(`${hours}h`);
82
+ if (minutes > 0)
83
+ parts.push(`${minutes}m`);
84
+ if (secs > 0 || parts.length === 0)
85
+ parts.push(`${secs}s`);
86
+ return parts.join(" ");
87
+ }
@@ -1,23 +1,77 @@
1
- import { setTimeout as sleep } from "node:timers/promises";
1
+ import { setTimeout as defaultSleep } from "node:timers/promises";
2
2
  import { inspectLock } from "../../server/lock.js";
3
3
  import { isPidAlive } from "./lifecycle.js";
4
- export async function probeLivez(port) {
5
- try {
6
- const response = await fetch(`http://127.0.0.1:${port}/livez`, { signal: AbortSignal.timeout(800) });
7
- return response.ok;
8
- }
9
- catch {
10
- return false;
4
+ /**
5
+ * Retry helper for loopback fetches. Localhost RTT is sub-millisecond on a
6
+ * healthy daemon, so a 100ms inter-attempt sleep is enough to give a freshly
7
+ * spawned process time to bind, while still keeping total wall-clock for a
8
+ * "probe + 2 retries" sequence well under a second.
9
+ *
10
+ * Only AbortError + transport-class errors trigger retries — a 4xx/5xx
11
+ * response from the daemon is a real signal (the route exists but
12
+ * disagrees with us), and retrying just delays the answer the caller
13
+ * needs to surface. The set is narrow on purpose: we don't want to retry
14
+ * ECONNREFUSED forever on a daemon that genuinely isn't running.
15
+ */
16
+ const LOOPBACK_PROBE_BACKOFF_MS = 100;
17
+ async function probeWithRetry(attempt, options = {}) {
18
+ const maxAttempts = Math.max(1, options.attempts ?? 3);
19
+ const sleepImpl = options.sleepImpl ?? ((ms) => defaultSleep(ms));
20
+ const backoffMs = options.backoffMs ?? LOOPBACK_PROBE_BACKOFF_MS;
21
+ let last = { ok: null, failed: true, error: undefined };
22
+ for (let i = 0; i < maxAttempts; i += 1) {
23
+ const result = await attempt();
24
+ if (!result.failed) {
25
+ return result;
26
+ }
27
+ last = result;
28
+ if (!isRetryableProbeError(result.error)) {
29
+ return result;
30
+ }
31
+ if (i < maxAttempts - 1) {
32
+ await sleepImpl(backoffMs);
33
+ }
11
34
  }
35
+ return last;
12
36
  }
13
- export async function probeDebugEndpoint(port) {
14
- try {
15
- const response = await fetch(`http://127.0.0.1:${port}/_debug`, { signal: AbortSignal.timeout(1_200) });
16
- return response.ok;
17
- }
18
- catch {
37
+ function isRetryableProbeError(error) {
38
+ if (!error || typeof error !== "object")
19
39
  return false;
20
- }
40
+ const typed = error;
41
+ if (typed.name === "AbortError" || typed.name === "TimeoutError")
42
+ return true;
43
+ const code = typed.code?.toUpperCase() ?? typed.cause?.code?.toUpperCase();
44
+ // ECONNREFUSED *is* retried here even though it usually means "nothing
45
+ // listening": a daemon racing to bind right after spawn returns
46
+ // ECONNREFUSED for a tiny window, and probes do call us from spawn-adjacent
47
+ // paths (`waitForDaemonReady` polls; `acquireLock`'s isRunning callback
48
+ // checks an existing lock). The 100ms inter-attempt sleep is enough for
49
+ // the bind race to settle.
50
+ return code === "ECONNRESET" || code === "ECONNREFUSED" || code === "ETIMEDOUT";
51
+ }
52
+ export async function probeLivez(port, options) {
53
+ const result = await probeWithRetry(async () => {
54
+ try {
55
+ const response = await fetch(`http://127.0.0.1:${port}/livez`, { signal: AbortSignal.timeout(800) });
56
+ return { ok: response.ok, failed: false };
57
+ }
58
+ catch (error) {
59
+ return { ok: null, failed: true, error };
60
+ }
61
+ }, options);
62
+ return result.failed ? false : result.ok;
63
+ }
64
+ export async function probeDebugEndpoint(port, options) {
65
+ const result = await probeWithRetry(async () => {
66
+ try {
67
+ const response = await fetch(`http://127.0.0.1:${port}/_debug`, { signal: AbortSignal.timeout(1_200) });
68
+ return { ok: response.ok, failed: false };
69
+ }
70
+ catch (error) {
71
+ return { ok: null, failed: true, error };
72
+ }
73
+ }, options);
74
+ return result.failed ? false : result.ok;
21
75
  }
22
76
  export async function warnIfDebugRequestedButInactive(debugRequested, port) {
23
77
  if (!debugRequested) {
@@ -29,21 +83,29 @@ export async function warnIfDebugRequestedButInactive(debugRequested, port) {
29
83
  }
30
84
  return active;
31
85
  }
32
- export async function probeHealth(port) {
33
- try {
34
- const response = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(1_500) });
35
- const payload = (await response.json());
36
- return {
37
- ok: response.ok,
38
- statusCode: response.status,
39
- status: typeof payload.status === "string" ? payload.status : null,
40
- error: typeof payload.error === "string" ? payload.error : null,
41
- bearerTtlSeconds: response.ok && typeof payload.bearer_ttl_seconds === "number" ? payload.bearer_ttl_seconds : null
42
- };
43
- }
44
- catch {
45
- return { ok: false, bearerTtlSeconds: null, statusCode: null, status: null, error: "health_probe_failed" };
46
- }
86
+ export async function probeHealth(port, options) {
87
+ const result = await probeWithRetry(async () => {
88
+ try {
89
+ const response = await fetch(`http://127.0.0.1:${port}/healthz`, { signal: AbortSignal.timeout(1_500) });
90
+ const payload = (await response.json());
91
+ return {
92
+ ok: {
93
+ ok: response.ok,
94
+ statusCode: response.status,
95
+ status: typeof payload.status === "string" ? payload.status : null,
96
+ error: typeof payload.error === "string" ? payload.error : null,
97
+ bearerTtlSeconds: response.ok && typeof payload.bearer_ttl_seconds === "number" ? payload.bearer_ttl_seconds : null
98
+ },
99
+ failed: false
100
+ };
101
+ }
102
+ catch (error) {
103
+ return { ok: null, failed: true, error };
104
+ }
105
+ }, options);
106
+ return result.failed
107
+ ? { ok: false, bearerTtlSeconds: null, statusCode: null, status: null, error: "health_probe_failed" }
108
+ : result.ok;
47
109
  }
48
110
  export async function readLiveLock() {
49
111
  const lockState = inspectLock();
@@ -52,17 +114,21 @@ export async function readLiveLock() {
52
114
  }
53
115
  return (await probeLivez(lockState.lock.port)) ? lockState.lock : null;
54
116
  }
55
- export async function waitForDaemonReady(pid, timeoutMs) {
117
+ export async function waitForDaemonReady(pid, timeoutMs, options) {
118
+ const sleepImpl = options?.sleepImpl ?? ((ms) => defaultSleep(ms));
56
119
  const startedAt = Date.now();
57
120
  while (Date.now() - startedAt <= timeoutMs) {
58
121
  const lockState = inspectLock();
59
- if (lockState.state === "running" && (await probeLivez(lockState.lock.port))) {
122
+ // Use a single-attempt probe inside this poll loop — the outer 150ms
123
+ // loop already retries naturally. Letting `probeLivez` retry internally
124
+ // here would compound delays.
125
+ if (lockState.state === "running" && (await probeLivez(lockState.lock.port, { attempts: 1 }))) {
60
126
  return { pid: lockState.lock.pid, port: lockState.lock.port };
61
127
  }
62
128
  if (pid !== null && !isPidAlive(pid)) {
63
129
  return null;
64
130
  }
65
- await sleep(150);
131
+ await sleepImpl(150);
66
132
  }
67
133
  return null;
68
134
  }
@@ -1,9 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { loadStoredCredential } from "../../auth/credentials.js";
3
+ import { readAccountsIndex } from "../../auth/accounts.js";
3
4
  import { CopilotTokenManager } from "../../auth/copilotToken.js";
4
5
  import { loadConfig } from "../../config/config.js";
5
6
  import { acquireLock, LockAlreadyRunningError, releaseLock } from "../../server/lock.js";
6
7
  import { startProxyServer } from "../../server/proxy.js";
8
+ import { DaemonAccountResolver } from "../../server/accountResolver.js";
7
9
  import { installProcessSafetyNet } from "../processSafetyNet.js";
8
10
  import { getRootLogger } from "../shared/debug.js";
9
11
  import { withTimeout } from "./lifecycle.js";
@@ -30,6 +32,22 @@ export async function runDaemon(options) {
30
32
  }
31
33
  const tokenManager = new CopilotTokenManager(creds.token);
32
34
  await tokenManager.ensureToken(false);
35
+ // Build the default account's resolved identity. With no accounts index this
36
+ // is the legacy single account (accountId null, legacy model cache). With an
37
+ // index, it reflects the configured default account's id, plan type, and
38
+ // storage scheme — so model discovery and the cache key stay correct.
39
+ const accountsIndex = readAccountsIndex();
40
+ const defaultRecord = accountsIndex
41
+ ? accountsIndex.accounts.find((account) => account.id === accountsIndex.defaultAccount) ?? null
42
+ : null;
43
+ const defaultAccount = {
44
+ accountId: defaultRecord?.id ?? null,
45
+ githubToken: creds.token,
46
+ tokenManager,
47
+ accountType: defaultRecord?.accountType ?? config.accountType,
48
+ cacheId: defaultRecord && defaultRecord.storage === "namespaced" ? defaultRecord.id : undefined
49
+ };
50
+ const accountResolver = new DaemonAccountResolver({ default: defaultAccount });
33
51
  const callerSecret = config.requireCallerSecret ? randomUUID() : null;
34
52
  if (callerSecret) {
35
53
  process.stdout.write(`Caller secret: ${callerSecret}\n`);
@@ -53,6 +71,7 @@ export async function runDaemon(options) {
53
71
  port,
54
72
  config,
55
73
  tokenManager,
74
+ accountResolver,
56
75
  callerSecret,
57
76
  logger,
58
77
  debug: Boolean(options?.debug),
@@ -70,7 +89,7 @@ export async function runDaemon(options) {
70
89
  }
71
90
  }
72
91
  if (!server || selectedPort === null) {
73
- tokenManager.clear();
92
+ accountResolver.clearAll();
74
93
  throw new Error(`No available port in configured range (${ports[0]}-${ports[ports.length - 1]}).`);
75
94
  }
76
95
  installProcessSafetyNet(logger);
@@ -87,7 +106,7 @@ export async function runDaemon(options) {
87
106
  logger.warn({ err: error }, "graceful shutdown timed out");
88
107
  }
89
108
  finally {
90
- tokenManager.clear();
109
+ accountResolver.clearAll();
91
110
  releaseLock();
92
111
  process.exit(0);
93
112
  }
package/dist/cli/index.js CHANGED
@@ -10,18 +10,30 @@ import * as claudeCmd from "./commands/agents/claude.js";
10
10
  import * as piCmd from "./commands/agents/pi.js";
11
11
  import * as copilotCmd from "./commands/agents/copilot.js";
12
12
  import { setRootLogger, setRootProgram } from "./shared/debug.js";
13
+ import { applyDevModeEnv } from "./shared/devMode.js";
13
14
  import { getPackageInfo } from "./packageInfo.js";
14
15
  import { maybeNotifyAboutUpdate } from "./updateNotifier.js";
15
16
  const logger = createLogger();
16
17
  const program = new Command();
17
18
  setRootProgram(program);
18
19
  setRootLogger(logger);
20
+ // Honor COPILLM_DEV before anything reads COPILLM_HOME (e.g. the update
21
+ // notifier). The `--dev` flag form is applied later, in the preAction hook,
22
+ // once commander has parsed global options.
23
+ applyDevModeEnv();
19
24
  const pkg = getPackageInfo();
20
25
  await maybeNotifyAboutUpdate({ packageInfo: pkg });
21
26
  program.name("copillm").description("Local Copilot proxy").version(pkg.version);
22
27
  program.enablePositionalOptions();
23
28
  program.option("--debug", "Enable copillm debug mode (debug endpoint plus verbose daemon diagnostics)");
29
+ program.option("--dev", "Run against an isolated dev home (COPILLM_HOME=~/.copillm-dev, port 4142) so the dev daemon never conflicts with a production copillm");
24
30
  program.option("--no-update-notifier", "Skip the npm registry update check for this run");
31
+ // Apply the `--dev` flag as soon as global options are parsed and before any
32
+ // subcommand action resolves COPILLM_HOME. Idempotent with the env-based call
33
+ // above.
34
+ program.hook("preAction", () => {
35
+ applyDevModeEnv(Boolean(program.opts().dev));
36
+ });
25
37
  authCmd.register(program);
26
38
  daemonCmd.register(program);
27
39
  modelsCmd.register(program);
@@ -1,14 +1,16 @@
1
1
  import { buildClaudeEnvBundle } from "../agentEnv.js";
2
2
  import { buildClaudeExportCommand as buildClaudeExport, computeAnthropicDefaults, readModelIdsFromCache } from "../../models/anthropicDefaults.js";
3
- export function buildClaudeExportCommand(port, callerSecret) {
4
- const modelIds = readModelIdsFromCache();
3
+ export function buildClaudeExportCommand(port, callerSecret, opts) {
4
+ const pathPrefix = opts?.pathPrefix ?? "";
5
+ const modelIds = readModelIdsFromCache(opts?.cacheId);
5
6
  const defaults = computeAnthropicDefaults(modelIds);
6
7
  const command = buildClaudeExport({
7
8
  port,
8
9
  callerSecret,
9
10
  defaults,
10
- enableGatewayDiscovery: true
11
+ enableGatewayDiscovery: true,
12
+ pathPrefix
11
13
  });
12
- const bundle = buildClaudeEnvBundle({ port, callerSecret, defaults, enableGatewayDiscovery: true });
14
+ const bundle = buildClaudeEnvBundle({ port, callerSecret, defaults, enableGatewayDiscovery: true, pathPrefix });
13
15
  return { command, defaults, bundle };
14
16
  }
@@ -1,6 +1,6 @@
1
1
  import { getCopillmHome } from "../../config/home.js";
2
2
  import { defaultOutputDir, generateCodexHome } from "../../integrations/codex/init.js";
3
- export async function refreshCodexHome(port, model) {
3
+ export async function refreshCodexHome(port, model, precomputed, opts) {
4
4
  try {
5
5
  const home = getCopillmHome();
6
6
  return await generateCodexHome({
@@ -8,7 +8,10 @@ export async function refreshCodexHome(port, model) {
8
8
  model,
9
9
  port,
10
10
  providerId: "copillm",
11
- reasoningEffort: null
11
+ reasoningEffort: null,
12
+ precomputed,
13
+ pathPrefix: opts?.pathPrefix,
14
+ account: opts?.account
12
15
  });
13
16
  }
14
17
  catch (error) {
@@ -1,12 +1,15 @@
1
1
  import { getCopillmHome } from "../../config/home.js";
2
2
  import { defaultOutputDir as defaultPiOutputDir, generatePiHome } from "../../integrations/pi/init.js";
3
- export async function refreshPiHome(port) {
3
+ export async function refreshPiHome(port, precomputed, opts) {
4
4
  try {
5
5
  const home = getCopillmHome();
6
6
  return await generatePiHome({
7
7
  outDir: defaultPiOutputDir(home),
8
8
  port,
9
- providerId: "copillm"
9
+ providerId: "copillm",
10
+ precomputed,
11
+ pathPrefix: opts?.pathPrefix,
12
+ account: opts?.account
10
13
  });
11
14
  }
12
15
  catch (error) {
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  const FALLBACK_PACKAGE_INFO = {
3
3
  name: "copillm",
4
- version: "0.2.8"
4
+ version: "0.3.0-beta.1"
5
5
  };
6
6
  export function getPackageInfo() {
7
7
  const envName = cleanPackageValue(process.env.COPILLM_PACKAGE_NAME);