copillm 0.2.7 → 0.2.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.
@@ -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) {
@@ -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
  }
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,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) {
4
4
  try {
5
5
  const home = getCopillmHome();
6
6
  return await generateCodexHome({
@@ -8,7 +8,8 @@ export async function refreshCodexHome(port, model) {
8
8
  model,
9
9
  port,
10
10
  providerId: "copillm",
11
- reasoningEffort: null
11
+ reasoningEffort: null,
12
+ precomputed
12
13
  });
13
14
  }
14
15
  catch (error) {
@@ -1,12 +1,13 @@
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) {
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
10
11
  });
11
12
  }
12
13
  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.7"
4
+ version: "0.2.9"
5
5
  };
6
6
  export function getPackageInfo() {
7
7
  const envName = cleanPackageValue(process.env.COPILLM_PACKAGE_NAME);
@@ -0,0 +1,98 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ /**
4
+ * Dev-mode isolation.
5
+ *
6
+ * Running a locally-built copillm against the SAME `~/.copillm` home and port as
7
+ * a globally-installed production daemon is a footgun: `stop` reads
8
+ * `~/.copillm/copillm.pid` and would kill the production daemon, and `start`
9
+ * sees the production lock and reports "already running" instead of launching
10
+ * your dev code.
11
+ *
12
+ * Dev mode redirects the runtime onto a separate `COPILLM_HOME` (and a distinct
13
+ * default port) so a dev daemon and a production daemon can run side by side
14
+ * without ever touching each other's lock, config, model cache, or port. This
15
+ * is the mechanism that lets you develop copillm WHILE using copillm.
16
+ *
17
+ * The override is implemented by setting the same `COPILLM_HOME` / `COPILLM_PORT`
18
+ * env vars the rest of the codebase already reads (see `src/config/home.ts` and
19
+ * `src/config/config.ts`). Because detached daemons and spawned agents inherit
20
+ * `process.env`, the isolation propagates to every child process for free.
21
+ *
22
+ * Activated by the global `--dev` flag or by exporting `COPILLM_DEV=1`. The
23
+ * concrete locations are overridable via `COPILLM_DEV_HOME` / `COPILLM_DEV_PORT`.
24
+ * An explicitly-set `COPILLM_HOME` / `COPILLM_PORT` always wins — dev mode never
25
+ * clobbers a home or port the user pinned on purpose.
26
+ */
27
+ export const DEV_HOME_DIRNAME = ".copillm-dev";
28
+ export const DEFAULT_DEV_PORT = 4142;
29
+ // Set once dev mode has been applied for this process, so command surfaces
30
+ // (start banner, status) can annotate output without re-deriving intent.
31
+ let devModeActive = false;
32
+ /** Whether dev mode has been applied to this process. */
33
+ export function isDevModeActive() {
34
+ return devModeActive;
35
+ }
36
+ function isTruthyEnv(value) {
37
+ if (value === undefined) {
38
+ return false;
39
+ }
40
+ return /^(1|true|yes|on)$/i.test(value.trim());
41
+ }
42
+ function nonEmptyEnv(value) {
43
+ if (value === undefined) {
44
+ return null;
45
+ }
46
+ const trimmed = value.trim();
47
+ return trimmed.length > 0 ? trimmed : null;
48
+ }
49
+ /**
50
+ * Whether dev mode was requested, via the `--dev` flag (passed in) or the
51
+ * `COPILLM_DEV` env var.
52
+ */
53
+ export function isDevModeRequested(flag) {
54
+ return Boolean(flag) || isTruthyEnv(process.env.COPILLM_DEV);
55
+ }
56
+ /** The isolated dev home: `COPILLM_DEV_HOME` if set, else `~/.copillm-dev`. */
57
+ export function resolveDevHome() {
58
+ const override = nonEmptyEnv(process.env.COPILLM_DEV_HOME);
59
+ if (override) {
60
+ return path.resolve(override);
61
+ }
62
+ return path.join(os.homedir(), DEV_HOME_DIRNAME);
63
+ }
64
+ /** The isolated dev port: `COPILLM_DEV_PORT` if set, else `4142`. */
65
+ export function resolveDevPort() {
66
+ const override = nonEmptyEnv(process.env.COPILLM_DEV_PORT);
67
+ if (override) {
68
+ return override;
69
+ }
70
+ return String(DEFAULT_DEV_PORT);
71
+ }
72
+ /**
73
+ * Apply dev-mode isolation to `process.env` when requested. Idempotent and
74
+ * safe to call multiple times.
75
+ *
76
+ * - No-op when dev mode is not requested.
77
+ * - Sets `COPILLM_HOME` to the dev home ONLY when it is not already set, so an
78
+ * explicit `COPILLM_HOME` always wins.
79
+ * - Sets `COPILLM_PORT` to the dev port ONLY when it is not already set, so an
80
+ * explicit `COPILLM_PORT` always wins.
81
+ */
82
+ export function applyDevModeEnv(flag) {
83
+ if (!isDevModeRequested(flag)) {
84
+ return { active: false, home: null, port: null };
85
+ }
86
+ if (!nonEmptyEnv(process.env.COPILLM_HOME)) {
87
+ process.env.COPILLM_HOME = resolveDevHome();
88
+ }
89
+ if (!nonEmptyEnv(process.env.COPILLM_PORT)) {
90
+ process.env.COPILLM_PORT = resolveDevPort();
91
+ }
92
+ devModeActive = true;
93
+ return {
94
+ active: true,
95
+ home: process.env.COPILLM_HOME ?? null,
96
+ port: process.env.COPILLM_PORT ?? null
97
+ };
98
+ }
@@ -23,7 +23,7 @@ export function loadConfig() {
23
23
  const file = configReadPath();
24
24
  if (!fs.existsSync(file)) {
25
25
  saveConfig(DEFAULT_CONFIG);
26
- return DEFAULT_CONFIG;
26
+ return applyEnvOverrides(DEFAULT_CONFIG);
27
27
  }
28
28
  const raw = readFileSync(file, "utf8");
29
29
  let parsed;
@@ -33,7 +33,7 @@ export function loadConfig() {
33
33
  catch (error) {
34
34
  throw new Error(`Invalid YAML in config file: ${file}`, { cause: error });
35
35
  }
36
- return parseConfigValue(parsed, file);
36
+ return applyEnvOverrides(parseConfigValue(parsed, file));
37
37
  }
38
38
  export function saveConfig(config) {
39
39
  ensureAppHome();
@@ -49,3 +49,14 @@ function parseConfigValue(value, source) {
49
49
  const issues = result.error.issues.map((issue) => `${issue.path.join(".") || "<root>"}: ${issue.message}`).join("; ");
50
50
  throw new Error(`Invalid config schema in ${source}: ${issues}`);
51
51
  }
52
+ function applyEnvOverrides(config) {
53
+ const port = process.env.COPILLM_PORT;
54
+ if (port === undefined || port.trim().length === 0) {
55
+ return config;
56
+ }
57
+ const parsedPort = Number(port.trim());
58
+ if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535) {
59
+ throw new Error("Invalid COPILLM_PORT: expected an integer between 1 and 65535.");
60
+ }
61
+ return { ...config, preferredPort: parsedPort };
62
+ }
@@ -35,6 +35,40 @@ export function modelsCacheReadPath() {
35
35
  export function debugLogPath() {
36
36
  return path.join(getCopillmHome(), "debug.log");
37
37
  }
38
+ /**
39
+ * The directory pi (`@earendil-works/pi-coding-agent`) reads its config from.
40
+ *
41
+ * pi exposes this via the `PI_CODING_AGENT_DIR` env var — its own `getAgentDir()`
42
+ * treats the value as the agent dir directly (equivalent to `~/.pi/agent`).
43
+ * copillm owns this path: it defaults to `<COPILLM_HOME>/pi/agent` so copillm
44
+ * never writes into the user's real `~/.pi`, and dev mode relocates it for free
45
+ * via COPILLM_HOME. An explicitly-set `PI_CODING_AGENT_DIR` always wins.
46
+ */
47
+ export function piAgentDir() {
48
+ const overridden = process.env.PI_CODING_AGENT_DIR;
49
+ if (overridden && overridden.trim().length > 0) {
50
+ return path.resolve(overridden.trim());
51
+ }
52
+ return path.join(getCopillmHome(), "pi", "agent");
53
+ }
54
+ /**
55
+ * The config home Claude Code reads (its `~/.claude` equivalent), exposed by
56
+ * Claude Code as the `CLAUDE_CONFIG_DIR` env var.
57
+ *
58
+ * copillm owns this path: it defaults to `<COPILLM_HOME>/claude/home` and copillm
59
+ * exports `CLAUDE_CONFIG_DIR` to it when launching Claude (see
60
+ * `buildClaudeEnvBundle`). This keeps copillm out of the user's real `~/.claude`
61
+ * — copillm-launched Claude gets a deterministic, copillm-owned config home, and
62
+ * dev mode relocates it for free via COPILLM_HOME. An explicitly-set
63
+ * `CLAUDE_CONFIG_DIR` always wins.
64
+ */
65
+ export function claudeConfigDir() {
66
+ const overridden = process.env.CLAUDE_CONFIG_DIR;
67
+ if (overridden && overridden.trim().length > 0) {
68
+ return path.resolve(overridden.trim());
69
+ }
70
+ return path.join(getCopillmHome(), "claude", "home");
71
+ }
38
72
  function resolveReadablePath(fileName) {
39
73
  const canonical = path.join(getCopillmHome(), fileName);
40
74
  if (fs.existsSync(canonical)) {