copillm 0.2.8 → 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.
- package/README.md +1 -1
- package/dist/agentconfig/render.js +8 -5
- package/dist/auth/copilotToken.js +92 -23
- package/dist/auth/credentials.js +47 -0
- package/dist/auth/deviceFlow.js +110 -23
- package/dist/auth/githubIdentity.js +14 -10
- package/dist/cli/agentEnv.js +13 -8
- package/dist/cli/commands/auth.js +31 -6
- package/dist/cli/commands/daemon.js +79 -17
- package/dist/cli/commands/models.js +0 -5
- package/dist/cli/daemon/lifecycle.js +26 -0
- package/dist/cli/daemon/probes.js +99 -33
- package/dist/cli/index.js +12 -0
- package/dist/cli/integrations/refreshCodex.js +3 -2
- package/dist/cli/integrations/refreshPi.js +3 -2
- package/dist/cli/packageInfo.js +1 -1
- package/dist/cli/shared/devMode.js +98 -0
- package/dist/config/config.js +13 -2
- package/dist/config/home.js +34 -0
- package/dist/integrations/claude/cache.js +5 -2
- package/dist/integrations/claude/settingsConflict.js +5 -2
- package/dist/integrations/codex/init.js +21 -9
- package/dist/integrations/pi/init.js +5 -15
- package/dist/models/discovery.js +112 -8
- package/dist/server/debugInfo.js +69 -24
- package/dist/server/errors.js +18 -0
- package/dist/server/routes/debug.js +4 -1
- package/dist/server/routes/models.js +7 -1
- package/dist/server/upstream/copilotClient.js +1 -30
- package/dist/server/upstream/retryPolicy.js +99 -0
- 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
|
|
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
|
|
44
|
-
const
|
|
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
|
|
90
|
-
const
|
|
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
|
|
131
|
-
const
|
|
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
|
|
163
|
-
const
|
|
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(`
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|
package/dist/cli/packageInfo.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/config/config.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/config/home.js
CHANGED
|
@@ -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)) {
|