@commonlyai/cli 0.1.209 → 0.1.211
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/package.json +1 -1
- package/src/commands/agent.js +83 -4
- package/src/commands/daemon.js +9 -1
- package/src/commands/doctor.js +135 -0
- package/src/index.js +5 -0
- package/src/lib/daemon-service.js +9 -2
- package/src/lib/doctor.js +272 -0
- package/src/lib/supervised-turn.js +351 -0
- package/src/lib/update-check.js +185 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -88,9 +88,11 @@ import { detectBwrap } from '../lib/sandbox/bwrap.js';
|
|
|
88
88
|
import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
|
|
89
89
|
import { hostSandboxRefusal, windowsSandboxMessage } from '../lib/sandbox/host-check.js';
|
|
90
90
|
import { prepareMcpBeforeFirstPoll } from '../lib/mcp-first-run.js';
|
|
91
|
+
import { installedCliVersion, startUpdateNotice } from '../lib/update-check.js';
|
|
91
92
|
import { createSeatStop } from '../lib/seat-stop.js';
|
|
92
93
|
import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
|
|
93
94
|
import { describeUpstreamRefusal } from '../lib/upstream-refusal.js';
|
|
95
|
+
import { createTurnRunner, supervisedRunPolicy } from '../lib/supervised-turn.js';
|
|
94
96
|
import {
|
|
95
97
|
DEFAULT_HOOK_TIMEOUT_MS,
|
|
96
98
|
clampHookTimeoutMs,
|
|
@@ -534,6 +536,29 @@ export const listLocalAgents = () => {
|
|
|
534
536
|
.filter(Boolean);
|
|
535
537
|
};
|
|
536
538
|
|
|
539
|
+
/**
|
|
540
|
+
* The token files listLocalAgents drops: present, but not readable JSON.
|
|
541
|
+
* `commonly doctor` reports them, because a dropped file otherwise reads as
|
|
542
|
+
* "no agent here" and lets doctor pass on a seat that cannot start.
|
|
543
|
+
* Throws when the directory exists but cannot be read, for doctor to report.
|
|
544
|
+
*/
|
|
545
|
+
export const listUnreadableAgentTokens = () => {
|
|
546
|
+
let files;
|
|
547
|
+
try {
|
|
548
|
+
files = readdirSync(tokensDir());
|
|
549
|
+
} catch (error) {
|
|
550
|
+
// Only a missing directory means no agents. existsSync also answers false
|
|
551
|
+
// for EACCES on an ancestor, which is why this reads instead of checking.
|
|
552
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return [];
|
|
553
|
+
throw error;
|
|
554
|
+
}
|
|
555
|
+
return files
|
|
556
|
+
.filter((f) => f.endsWith('.json'))
|
|
557
|
+
.map((f) => f.slice(0, -'.json'.length))
|
|
558
|
+
.filter((name) => !loadAgentToken(name))
|
|
559
|
+
.sort();
|
|
560
|
+
};
|
|
561
|
+
|
|
537
562
|
// Chat event types whose payload already contains the prompt the wrapper
|
|
538
563
|
// should forward verbatim. Heartbeat and consult events need event-specific
|
|
539
564
|
// framing, so extractPrompt handles them separately below.
|
|
@@ -1356,6 +1381,11 @@ const extractPrompt = (event) => {
|
|
|
1356
1381
|
return null;
|
|
1357
1382
|
};
|
|
1358
1383
|
|
|
1384
|
+
// The supervisor's policy for a run-loop spawn: lib/supervised-turn.js has the
|
|
1385
|
+
// stall windows per adapter (codex 15 min, claude off) and their evidence, the
|
|
1386
|
+
// cancel grace, and the wall-clock backstop past the adapter's own timeout.
|
|
1387
|
+
export { supervisedRunPolicy };
|
|
1388
|
+
|
|
1359
1389
|
/**
|
|
1360
1390
|
* Start the run loop. Polls /events, spawns the adapter per event, posts the
|
|
1361
1391
|
* result into the pod, acks.
|
|
@@ -1431,6 +1461,13 @@ export const performRun = ({
|
|
|
1431
1461
|
resolveOwnProviderKey = null,
|
|
1432
1462
|
runtimeReportClient = null,
|
|
1433
1463
|
refusalHandoff = null,
|
|
1464
|
+
// Off unless COMMONLY_SUPERVISE_RUNS=1: each spawn runs under the seats
|
|
1465
|
+
// supervisor (stall window, wall-clock backstop, a named failure on every
|
|
1466
|
+
// settle). The CLI still talks to its provider directly, and one metadata
|
|
1467
|
+
// line per turn goes to ~/.commonly/state/turns/<agent>.jsonl once
|
|
1468
|
+
// delivery settles. Off, the spawn path is the plain adapter call it has
|
|
1469
|
+
// always been, and nothing is written.
|
|
1470
|
+
superviseRuns = process.env.COMMONLY_SUPERVISE_RUNS === '1',
|
|
1434
1471
|
}) => {
|
|
1435
1472
|
reportRuntime = reportRuntime && WALKED_HARNESSES.includes(adapter?.name);
|
|
1436
1473
|
if (environment?.provider !== undefined && !adapter?.supportsProvider) {
|
|
@@ -1440,6 +1477,12 @@ export const performRun = ({
|
|
|
1440
1477
|
const effectiveTurnTimeoutMs = Number.isFinite(requestedTurnTimeout) && requestedTurnTimeout > 0
|
|
1441
1478
|
? Math.min(requestedTurnTimeout, 2_147_483_647) : 15 * 60 * 1000;
|
|
1442
1479
|
const client = createClient({ instance: instanceUrl, token });
|
|
1480
|
+
// The supervised run keeps its trace in memory, metadata only; no record of
|
|
1481
|
+
// a run is written to disk (lib/supervised-turn.js).
|
|
1482
|
+
const turnRunner = createTurnRunner({
|
|
1483
|
+
seat: agentName, turnTimeoutMs: effectiveTurnTimeoutMs, supervise: superviseRuns, log,
|
|
1484
|
+
});
|
|
1485
|
+
if (superviseRuns) log(turnRunner.describe(adapter?.name));
|
|
1443
1486
|
const runtimeReporter = createRuntimeReporter({
|
|
1444
1487
|
client: runtimeReportClient || { post: async () => {} },
|
|
1445
1488
|
harness: adapter.name, detect: () => adapter.detect(),
|
|
@@ -1852,8 +1895,21 @@ export const performRun = ({
|
|
|
1852
1895
|
|
|
1853
1896
|
// The spawn → post-decision half of a turn, split out so the claim release
|
|
1854
1897
|
// above is a plain finally instead of threading through every return path.
|
|
1855
|
-
|
|
1856
|
-
|
|
1898
|
+
// With superviseRuns, the turn's log line is written once delivery has
|
|
1899
|
+
// settled; anything thrown after a spawn that resolved means the reply never
|
|
1900
|
+
// reached the pod. Off, `turn` writes nothing.
|
|
1901
|
+
const runTurn = async (args) => {
|
|
1902
|
+
const turn = turnRunner.begin({ eventType: args.event.type });
|
|
1903
|
+
try {
|
|
1904
|
+
return await runTurnBody({ ...args, turn });
|
|
1905
|
+
} catch (error) {
|
|
1906
|
+
turn.finish('reply_lost');
|
|
1907
|
+
throw error;
|
|
1908
|
+
}
|
|
1909
|
+
};
|
|
1910
|
+
|
|
1911
|
+
const runTurnBody = async ({
|
|
1912
|
+
event, eventPodId, prompt, preSpawnIds, snapshotMessages, claimKeeper, trigger, turn,
|
|
1857
1913
|
}) => {
|
|
1858
1914
|
const placement = await placeTurn();
|
|
1859
1915
|
// A workspace turn placed public (its pod turned public, or its pods could
|
|
@@ -2076,7 +2132,8 @@ export const performRun = ({
|
|
|
2076
2132
|
reason: 'own-key-requires-scoped-spawn' };
|
|
2077
2133
|
}
|
|
2078
2134
|
}
|
|
2079
|
-
|
|
2135
|
+
const spawnPrompt = frameDecisionForkRule(promptForSpawn);
|
|
2136
|
+
const spawnCtx = {
|
|
2080
2137
|
onSpawn: reportRuntime ? (controls) => {
|
|
2081
2138
|
observedSpawn = true;
|
|
2082
2139
|
return runtimeReporter.spawn(controls);
|
|
@@ -2108,7 +2165,10 @@ export const performRun = ({
|
|
|
2108
2165
|
instanceUrl,
|
|
2109
2166
|
agentName,
|
|
2110
2167
|
metadata: { event },
|
|
2111
|
-
}
|
|
2168
|
+
};
|
|
2169
|
+
// Supervised or plain, a failure is thrown exactly as the adapter's own
|
|
2170
|
+
// would be, so the run loop's classification and retry apply unchanged.
|
|
2171
|
+
result = await turn.run(adapter, spawnPrompt, spawnCtx);
|
|
2112
2172
|
spawnCompleted = true;
|
|
2113
2173
|
} finally {
|
|
2114
2174
|
try {
|
|
@@ -2191,6 +2251,9 @@ export const performRun = ({
|
|
|
2191
2251
|
const upstream = result.upstream || null;
|
|
2192
2252
|
let delivered = agentPostedItself;
|
|
2193
2253
|
let deliveryRefusal = null;
|
|
2254
|
+
// What became of the reply, for the turn line (lib/supervised-turn.js
|
|
2255
|
+
// TURN_OUTCOMES). A throw below is reply_lost, finished by runTurn.
|
|
2256
|
+
let turnOutcome;
|
|
2194
2257
|
|
|
2195
2258
|
if (event.type === 'agent.ask') {
|
|
2196
2259
|
if (silentReply) {
|
|
@@ -2200,6 +2263,7 @@ export const performRun = ({
|
|
|
2200
2263
|
// having nothing to say.
|
|
2201
2264
|
if (upstream) log(`[${event.type}] ${describeUpstreamRefusal(upstream)} — no private response`);
|
|
2202
2265
|
else log(`[${event.type}] no private response (${reason})`);
|
|
2266
|
+
turnOutcome = upstream ? 'upstream_refused' : 'silent';
|
|
2203
2267
|
} else {
|
|
2204
2268
|
try {
|
|
2205
2269
|
await client.post(
|
|
@@ -2207,6 +2271,7 @@ export const performRun = ({
|
|
|
2207
2271
|
{ content: replyText },
|
|
2208
2272
|
);
|
|
2209
2273
|
delivered = true;
|
|
2274
|
+
turnOutcome = 'delivered';
|
|
2210
2275
|
log(`[${event.type}] routed private response (${Buffer.byteLength(replyText)} bytes)`);
|
|
2211
2276
|
} catch (err) {
|
|
2212
2277
|
// A tool-capable agent may have called commonly_respond_to_ask
|
|
@@ -2215,6 +2280,7 @@ export const performRun = ({
|
|
|
2215
2280
|
// the model forever.
|
|
2216
2281
|
if (err?.status === 409 && err?.body?.code === 'already_responded') {
|
|
2217
2282
|
delivered = true;
|
|
2283
|
+
turnOutcome = 'delivered';
|
|
2218
2284
|
log(`[${event.type}] response already routed by agent tool`);
|
|
2219
2285
|
} else {
|
|
2220
2286
|
throw err;
|
|
@@ -2242,6 +2308,7 @@ export const performRun = ({
|
|
|
2242
2308
|
// Report the two cases distinctly. `posted` is the load-bearing word: a
|
|
2243
2309
|
// reader scanning for whether a seat is contributing must not have to
|
|
2244
2310
|
// infer it from the absence of a different line.
|
|
2311
|
+
turnOutcome = agentPostedItself ? 'delivered' : upstream ? 'upstream_refused' : 'silent';
|
|
2245
2312
|
if (agentPostedItself) {
|
|
2246
2313
|
log(
|
|
2247
2314
|
`[${event.type}] posted via tool, then ${reason} — no echo needed `
|
|
@@ -2260,6 +2327,7 @@ export const performRun = ({
|
|
|
2260
2327
|
// Name the message that caused the suppression. A silently dropped reply
|
|
2261
2328
|
// is invisible to everyone; #757 went unnoticed precisely because this
|
|
2262
2329
|
// line said only "posted via tool" with no way to tell whose post it saw.
|
|
2330
|
+
turnOutcome = 'delivered';
|
|
2263
2331
|
log(
|
|
2264
2332
|
`[${event.type}] agent posted via tool this turn — not echoing CLI output `
|
|
2265
2333
|
+ `(avoids double-post; matched message ${suppressedBy.id} by ${suppressedBy.author} `
|
|
@@ -2269,6 +2337,7 @@ export const performRun = ({
|
|
|
2269
2337
|
// ADR-018 D3: the lease lapsed mid-turn and a peer re-won the message —
|
|
2270
2338
|
// that peer owns the conversation now, so posting would recreate the
|
|
2271
2339
|
// two-agents-one-message crossing the claim exists to prevent.
|
|
2340
|
+
turnOutcome = 'claim_lost';
|
|
2272
2341
|
log(
|
|
2273
2342
|
`[${event.type}] stood down — claim lost mid-turn to ${claimKeeper.getHolder()}; `
|
|
2274
2343
|
+ `reply suppressed (${Buffer.byteLength(replyText)} bytes not posted)`,
|
|
@@ -2311,6 +2380,7 @@ export const performRun = ({
|
|
|
2311
2380
|
// it like a refusal: re-posting the same text inside the window would
|
|
2312
2381
|
// only be skipped again.
|
|
2313
2382
|
deliveryRefusal = delivery;
|
|
2383
|
+
turnOutcome = 'server_skipped';
|
|
2314
2384
|
const windowMinutes = delivery.duplicate?.dedupeWindowMinutes;
|
|
2315
2385
|
const detail = [
|
|
2316
2386
|
windowMinutes ? `${windowMinutes} min window` : null,
|
|
@@ -2336,6 +2406,7 @@ export const performRun = ({
|
|
|
2336
2406
|
// guidance expressly says not to retry unchanged), while preserving
|
|
2337
2407
|
// the refusal and its partial-post count for the seat and event ledger.
|
|
2338
2408
|
deliveryRefusal = delivery;
|
|
2409
|
+
turnOutcome = 'server_refused';
|
|
2339
2410
|
const guidance = delivery.guidance || 'The server refused this message; do not retry it unchanged.';
|
|
2340
2411
|
const detail = `after ${delivery.messages}/${delivery.attemptedMessages} message${delivery.attemptedMessages === 1 ? '' : 's'}`;
|
|
2341
2412
|
log(`[${event.type}] wrapper delivery refused ${detail} (${delivery.reason}): ${guidance}`);
|
|
@@ -2351,12 +2422,14 @@ export const performRun = ({
|
|
|
2351
2422
|
));
|
|
2352
2423
|
} else {
|
|
2353
2424
|
delivered = true;
|
|
2425
|
+
turnOutcome = 'delivered';
|
|
2354
2426
|
log(
|
|
2355
2427
|
`[${event.type}] posted ${Buffer.byteLength(replyText)} bytes as `
|
|
2356
2428
|
+ `${delivery.messages} message${delivery.messages === 1 ? '' : 's'} (${delivery.mode})`,
|
|
2357
2429
|
);
|
|
2358
2430
|
}
|
|
2359
2431
|
}
|
|
2432
|
+
turn.finish(turnOutcome);
|
|
2360
2433
|
if (result.memorySummary && !memoryWithheld) {
|
|
2361
2434
|
try {
|
|
2362
2435
|
await syncBack(client, { summary: result.memorySummary });
|
|
@@ -4051,6 +4124,12 @@ Docs:
|
|
|
4051
4124
|
|
|
4052
4125
|
console.log(`${stamp()} [${name}] polling ${record.instanceUrl} for events (ctrl+c to stop)`);
|
|
4053
4126
|
console.log(`${stamp()} [${name}] ${foregroundDaemonHint({ daemonRegistered: hasDaemonRecord() })}`);
|
|
4127
|
+
// TASK-660: one line when this CLI is stale. Fired, not awaited, so a
|
|
4128
|
+
// slow registry never delays the first poll.
|
|
4129
|
+
startUpdateNotice({
|
|
4130
|
+
installedVersion: installedCliVersion(),
|
|
4131
|
+
log: (line) => console.log(`${stamp()} [${name}] ${line}`),
|
|
4132
|
+
});
|
|
4054
4133
|
|
|
4055
4134
|
const { stop } = performRun({
|
|
4056
4135
|
instanceUrl: record.instanceUrl,
|
package/src/commands/daemon.js
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
} from '../lib/daemon-service.js';
|
|
39
39
|
import { daemonLogsDir, daemonSeatLogPath, readLogTail } from '../lib/daemon-logs.js';
|
|
40
40
|
import { loadDaemonState, saveDaemonState } from '../lib/daemon-state.js';
|
|
41
|
+
import { installedCliVersion, startUpdateNotice, UPDATE_CHECK_OPT_OUT_ENV } from '../lib/update-check.js';
|
|
41
42
|
|
|
42
43
|
// One supervised seat, as `commonly daemon status --verbose` names it. Two of these
|
|
43
44
|
// fields are worth reading closely: the seat's `instanceId` (the identity the
|
|
@@ -351,7 +352,11 @@ Examples:
|
|
|
351
352
|
// adapter. The keys are captured here because this is the only moment
|
|
352
353
|
// the operator's shell is in reach; the ones that are missing are named
|
|
353
354
|
// by installDaemonService rather than left to be discovered per seat.
|
|
354
|
-
await installDaemonService({
|
|
355
|
+
await installDaemonService({
|
|
356
|
+
...serviceDeps(),
|
|
357
|
+
providerKeyEnvNames: providerKeyEnvNames(),
|
|
358
|
+
optionalEnvNames: [UPDATE_CHECK_OPT_OUT_ENV],
|
|
359
|
+
});
|
|
355
360
|
console.log('The daemon now starts at login and is kept alive. Uninstall with: commonly daemon uninstall');
|
|
356
361
|
} catch (error) {
|
|
357
362
|
console.error(`Daemon install failed: ${error.message}`);
|
|
@@ -451,6 +456,9 @@ Examples:
|
|
|
451
456
|
for (const name of missingProviderKeys({ names: providerKeyEnvNames() })) {
|
|
452
457
|
stampLog(`WARNING: ${name} is not set in this environment — a seat whose adapter needs it will fail to start. Export it and re-run: commonly daemon install (docs/agents/daemon-service-environment.md)`);
|
|
453
458
|
}
|
|
459
|
+
// TASK-660: the daemon outlives many publishes, so say once at start-up
|
|
460
|
+
// when it is stale. Not awaited: supervision never waits on the registry.
|
|
461
|
+
startUpdateNotice({ installedVersion: installedCliVersion(), log: stampLog });
|
|
454
462
|
|
|
455
463
|
let pollTimer;
|
|
456
464
|
let heartbeatTimer;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commonly doctor — one line per check, exit 1 when any fails (TASK-660).
|
|
3
|
+
* The checks live in ../lib/doctor.js; this file supplies the real host.
|
|
4
|
+
*/
|
|
5
|
+
import { accessSync, constants, existsSync, readFileSync, statSync } from 'fs';
|
|
6
|
+
import { spawnSync } from 'child_process';
|
|
7
|
+
import { homedir } from 'os';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { createClient } from '../lib/api.js';
|
|
10
|
+
import { getToken, resolveInstance, resolveInstanceUrl } from '../lib/config.js';
|
|
11
|
+
import { loadDaemonRecord } from '../lib/daemon-store.js';
|
|
12
|
+
import { servicePaths } from '../lib/daemon-service.js';
|
|
13
|
+
import { getAdapter } from '../lib/adapters/index.js';
|
|
14
|
+
import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
|
|
15
|
+
import { daemonStatusErrorMessage, getDaemonMachineStatus } from './daemon.js';
|
|
16
|
+
import { listLocalAgents, listUnreadableAgentTokens } from './agent.js';
|
|
17
|
+
import { doctorExitCode, formatDoctorLine, runDoctor } from '../lib/doctor.js';
|
|
18
|
+
|
|
19
|
+
const REQUEST_TIMEOUT_MS = 5000;
|
|
20
|
+
|
|
21
|
+
// Every server read doctor makes is bounded, so a hung instance is a fail line, not a hang.
|
|
22
|
+
export const timedClient = (client) => ({
|
|
23
|
+
...client,
|
|
24
|
+
get: (path, params = {}, options = {}) => client.get(path, params, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), ...options }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
28
|
+
|
|
29
|
+
const isExecutable = (path) => {
|
|
30
|
+
try {
|
|
31
|
+
if (!statSync(path).isFile()) return false;
|
|
32
|
+
accessSync(path, constants.X_OK);
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const isFile = (path) => {
|
|
40
|
+
try {
|
|
41
|
+
return statSync(path).isFile();
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Node refuses to spawn a .cmd without a shell (CVE-2024-27980), and npm's
|
|
48
|
+
// Windows shim is a .cmd, so that one case goes through cmd.exe, quoted.
|
|
49
|
+
export const versionCommand = (binary, platform = process.platform) => (
|
|
50
|
+
platform === 'win32' && /\.(cmd|bat)$/i.test(binary)
|
|
51
|
+
? { command: `"${binary}" --version`, args: [], shell: true }
|
|
52
|
+
: { command: binary, args: ['--version'], shell: false }
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const runVersion = (binary) => {
|
|
56
|
+
const { command, args, shell } = versionCommand(binary);
|
|
57
|
+
const res = spawnSync(command, args, { encoding: 'utf8', timeout: REQUEST_TIMEOUT_MS, shell });
|
|
58
|
+
if (res.error || res.status !== 0) return null;
|
|
59
|
+
return (res.stdout || '').trim().split(/\s+/)[0] || null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The instance and token every other command would use for `instanceArg`:
|
|
64
|
+
* createClient's own resolution (api.js), so doctor cannot pass a login that
|
|
65
|
+
* `agent attach` then fails, or fail one that works. COMMONLY_TOKEN wins over
|
|
66
|
+
* the saved token there, so it wins here, and the line says which one it sent.
|
|
67
|
+
*/
|
|
68
|
+
export const loginTarget = ({
|
|
69
|
+
instanceArg = null,
|
|
70
|
+
env = process.env,
|
|
71
|
+
resolve = resolveInstance,
|
|
72
|
+
resolveUrl = resolveInstanceUrl,
|
|
73
|
+
tokenFor = getToken,
|
|
74
|
+
} = {}) => {
|
|
75
|
+
const saved = resolve(instanceArg);
|
|
76
|
+
const token = tokenFor(instanceArg);
|
|
77
|
+
return {
|
|
78
|
+
key: saved?.key || null,
|
|
79
|
+
url: resolveUrl(instanceArg),
|
|
80
|
+
tokenSource: !token ? null : (env.COMMONLY_TOKEN ? 'env' : 'saved'),
|
|
81
|
+
loginHint: instanceArg ? `commonly login --instance ${instanceArg}` : 'commonly login',
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const hostDoctorDeps = ({ instance: instanceArg = null } = {}) => ({
|
|
86
|
+
listAgents: () => listLocalAgents(),
|
|
87
|
+
listUnreadable: () => listUnreadableAgentTokens(),
|
|
88
|
+
getAdapter,
|
|
89
|
+
node: { nodeVersion: process.versions.node, enginesRange: pkg.engines?.node },
|
|
90
|
+
install: {
|
|
91
|
+
pathValue: process.env.PATH,
|
|
92
|
+
platform: process.platform,
|
|
93
|
+
isExecutable,
|
|
94
|
+
runVersion,
|
|
95
|
+
runningVersion: pkg.version,
|
|
96
|
+
},
|
|
97
|
+
login: {
|
|
98
|
+
target: loginTarget({ instanceArg }),
|
|
99
|
+
fetchUser: () => timedClient(createClient({ instance: instanceArg })).get('/api/auth/user'),
|
|
100
|
+
},
|
|
101
|
+
daemon: {
|
|
102
|
+
loadRecord: loadDaemonRecord,
|
|
103
|
+
serviceFile: servicePaths().file,
|
|
104
|
+
fileExists: existsSync,
|
|
105
|
+
fetchMachine: (record) => getDaemonMachineStatus({
|
|
106
|
+
client: timedClient(createClient({ instance: record.instanceUrl, token: record.daemonToken })),
|
|
107
|
+
record,
|
|
108
|
+
}),
|
|
109
|
+
describeError: daemonStatusErrorMessage,
|
|
110
|
+
now: Date.now(),
|
|
111
|
+
},
|
|
112
|
+
macos: {
|
|
113
|
+
platform: process.platform,
|
|
114
|
+
detectSeatbelt,
|
|
115
|
+
isFile,
|
|
116
|
+
loginKeychainPath: join(homedir(), 'Library', 'Keychains', 'login.keychain-db'),
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
export const registerDoctor = (program) => {
|
|
121
|
+
program
|
|
122
|
+
.command('doctor')
|
|
123
|
+
.description('Check this computer: Node, the install, login, the daemon, adapter CLIs, macOS prerequisites')
|
|
124
|
+
.option('--instance <keyOrUrl>', 'Saved instance to check the login of (default: the active one)')
|
|
125
|
+
.action(async (opts) => {
|
|
126
|
+
try {
|
|
127
|
+
const results = await runDoctor(hostDoctorDeps({ instance: opts.instance || null }));
|
|
128
|
+
for (const line of results.map(formatDoctorLine)) console.log(line);
|
|
129
|
+
process.exitCode = doctorExitCode(results);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error(`commonly doctor could not finish: ${error.message}`);
|
|
132
|
+
process.exitCode = 1;
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
};
|
package/src/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { registerDaemon } from './commands/daemon.js';
|
|
|
21
21
|
import { registerPod } from './commands/pod.js';
|
|
22
22
|
import { registerDev } from './commands/dev.js';
|
|
23
23
|
import { registerVerify } from './commands/verify.js';
|
|
24
|
+
import { registerDoctor } from './commands/doctor.js';
|
|
24
25
|
|
|
25
26
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
27
|
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'));
|
|
@@ -54,6 +55,9 @@ registerPod(program);
|
|
|
54
55
|
registerDev(program);
|
|
55
56
|
registerVerify(program);
|
|
56
57
|
|
|
58
|
+
// Install health: one line per check (TASK-660)
|
|
59
|
+
registerDoctor(program);
|
|
60
|
+
|
|
57
61
|
// Each subcommand owns its own `--instance <url>` flag; a program-level
|
|
58
62
|
// duplicate shadowed the subcommand value on commander v12, causing
|
|
59
63
|
// `commonly login --instance …` to silently fall back to the default URL.
|
|
@@ -66,6 +70,7 @@ Quick start:
|
|
|
66
70
|
$ commonly agent detach my-claude # clean uninstall
|
|
67
71
|
$ commonly daemon register --name "My MacBook"
|
|
68
72
|
$ commonly daemon status
|
|
73
|
+
$ commonly doctor # check Node, install, login, daemon, adapters
|
|
69
74
|
$ commonly inbox list --token-file ~/.commonly/bin/ops-token # an operator's own queue
|
|
70
75
|
|
|
71
76
|
Custom Python agent:
|
|
@@ -203,6 +203,10 @@ export const installDaemonService = async ({
|
|
|
203
203
|
cliPath = resolve(process.argv[1]),
|
|
204
204
|
// The provider keys the seats' adapters declare (adapters/index.js).
|
|
205
205
|
providerKeyEnvNames = [],
|
|
206
|
+
// Settings carried only when set, never warned about when absent: the
|
|
207
|
+
// operator's opt-outs (COMMONLY_NO_UPDATE_CHECK), which reach the seats the
|
|
208
|
+
// daemon spawns only through this file.
|
|
209
|
+
optionalEnvNames = [],
|
|
206
210
|
env = process.env,
|
|
207
211
|
writeFile = writeServiceFile,
|
|
208
212
|
mkdirp,
|
|
@@ -221,7 +225,7 @@ export const installDaemonService = async ({
|
|
|
221
225
|
chmod(daemonLogPath(home), 0o600);
|
|
222
226
|
}
|
|
223
227
|
|
|
224
|
-
const providerEnv = providerEnvPairs({ names: providerKeyEnvNames, env });
|
|
228
|
+
const providerEnv = providerEnvPairs({ names: [...new Set([...providerKeyEnvNames, ...optionalEnvNames])], env });
|
|
225
229
|
const missing = missingProviderKeys({ names: providerKeyEnvNames, env });
|
|
226
230
|
|
|
227
231
|
if (target.kind === 'launchd') {
|
|
@@ -237,7 +241,10 @@ export const installDaemonService = async ({
|
|
|
237
241
|
} else {
|
|
238
242
|
writeFile(target.file, systemdUnit({ nodePath, cliPath, providerEnv }));
|
|
239
243
|
await execCmd(['systemctl', '--user', 'daemon-reload']);
|
|
240
|
-
|
|
244
|
+
// restart, not `enable --now`: a unit that is already running must pick up
|
|
245
|
+
// the environment just written, the way launchd's unload/load does.
|
|
246
|
+
await execCmd(['systemctl', '--user', 'enable', SYSTEMD_UNIT]);
|
|
247
|
+
await execCmd(['systemctl', '--user', 'restart', SYSTEMD_UNIT]);
|
|
241
248
|
}
|
|
242
249
|
for (const name of missing) {
|
|
243
250
|
warn(`${name} is not set in this shell, so it is NOT in the service file. A seat whose adapter needs it will fail to start; export it and re-run: commonly daemon install (docs/agents/daemon-service-environment.md)`);
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `commonly doctor` — the checks (TASK-660, D213).
|
|
3
|
+
*
|
|
4
|
+
* D213 keeps the Node daemon and hardens install by making breakage visible.
|
|
5
|
+
* Each check here answers one question a broken seat used to leave to a log
|
|
6
|
+
* autopsy, prints one line (pass, fail or skip) and, when it fails, the command
|
|
7
|
+
* that fixes it. Every outside fact (PATH, the saved login, the server, the
|
|
8
|
+
* daemon record, the adapters, the macOS files) comes in through `deps`, so the
|
|
9
|
+
* tests fake the environment and never touch this host.
|
|
10
|
+
*
|
|
11
|
+
* What a check must never do: print a token, or touch the keychain. The macOS
|
|
12
|
+
* check only stats the login keychain file and `/usr/bin/security`; it never
|
|
13
|
+
* runs `security`, so it cannot prompt, unlock or read a secret.
|
|
14
|
+
*/
|
|
15
|
+
import { delimiter, join } from 'node:path';
|
|
16
|
+
import { parseVersion, versionOlderThan } from './mcp-server-version.js';
|
|
17
|
+
|
|
18
|
+
export const PASS = 'pass';
|
|
19
|
+
export const FAIL = 'fail';
|
|
20
|
+
export const SKIP = 'skip';
|
|
21
|
+
|
|
22
|
+
const result = (id, status, detail, fix = null) => ({ id, status, detail, ...(fix ? { fix } : {}) });
|
|
23
|
+
|
|
24
|
+
/** `>=20`, `>=20.1` or `>=20.1.2` as a triple; null for any other range shape. */
|
|
25
|
+
export const enginesFloor = (range) => {
|
|
26
|
+
const match = /^>=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?\s*$/.exec(String(range || ''));
|
|
27
|
+
return match ? [Number(match[1]), Number(match[2] || 0), Number(match[3] || 0)] : null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// (a) Node against the package's engines floor.
|
|
31
|
+
export const checkNode = ({ nodeVersion, enginesRange }) => {
|
|
32
|
+
const floor = enginesFloor(enginesRange);
|
|
33
|
+
if (!floor) return result('node', SKIP, `Node ${nodeVersion}; engines range '${enginesRange || 'none'}' is not a >= floor`);
|
|
34
|
+
const version = parseVersion(nodeVersion);
|
|
35
|
+
if (!version) return result('node', FAIL, `could not read the Node version '${nodeVersion}'`, `install Node ${enginesRange}`);
|
|
36
|
+
if (versionOlderThan(version, floor)) {
|
|
37
|
+
return result('node', FAIL, `Node ${nodeVersion} is older than the CLI needs (${enginesRange})`, `install Node ${floor[0]} or newer`);
|
|
38
|
+
}
|
|
39
|
+
return result('node', PASS, `Node ${nodeVersion} (needs ${enginesRange})`);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Every `commonly` on PATH, first one first (the one a shell runs). */
|
|
43
|
+
export const findOnPath = ({ command, pathValue, platform, isExecutable }) => {
|
|
44
|
+
// npm also drops a bare sh shim beside the .cmd on Windows; cmd.exe cannot run
|
|
45
|
+
// it, so counting it would report a second install that is not one.
|
|
46
|
+
const names = platform === 'win32' ? [`${command}.cmd`, `${command}.exe`] : [command];
|
|
47
|
+
const found = [];
|
|
48
|
+
for (const dir of String(pathValue || '').split(platform === 'win32' ? ';' : delimiter)) {
|
|
49
|
+
if (!dir) continue;
|
|
50
|
+
for (const name of names) {
|
|
51
|
+
const candidate = join(dir, name);
|
|
52
|
+
if (isExecutable(candidate) && !found.includes(candidate)) found.push(candidate);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return found;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// (b) Which `commonly` a shell runs, and its version.
|
|
59
|
+
export const checkInstall = ({
|
|
60
|
+
pathValue, platform, isExecutable, runVersion, runningVersion,
|
|
61
|
+
}) => {
|
|
62
|
+
const fix = 'npm install -g @commonlyai/cli';
|
|
63
|
+
const found = findOnPath({ command: 'commonly', pathValue, platform, isExecutable });
|
|
64
|
+
if (!found.length) return result('install', FAIL, 'no `commonly` on PATH', fix);
|
|
65
|
+
const [binary] = found;
|
|
66
|
+
const version = runVersion(binary);
|
|
67
|
+
if (!parseVersion(version)) return result('install', FAIL, `${binary} did not answer --version`, `reinstall: ${fix}`);
|
|
68
|
+
const notes = [];
|
|
69
|
+
if (runningVersion && parseVersion(runningVersion) && version !== runningVersion) {
|
|
70
|
+
notes.push(`this doctor is ${runningVersion}`);
|
|
71
|
+
}
|
|
72
|
+
if (found.length > 1) notes.push(`${found.length - 1} more on PATH`);
|
|
73
|
+
return result('install', PASS, `commonly ${version} at ${binary}${notes.length ? ` (${notes.join('; ')})` : ''}`);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// (c) The instance every other command would use, and whose token it sends.
|
|
77
|
+
// The token is sent, never shown. `target` comes from loginTarget (commands/
|
|
78
|
+
// doctor.js), which resolves it the way createClient does, COMMONLY_TOKEN first.
|
|
79
|
+
export const checkLogin = async ({ target, fetchUser }) => {
|
|
80
|
+
const where = target.key ? `${target.key} (${target.url})` : target.url;
|
|
81
|
+
if (!target.tokenSource) {
|
|
82
|
+
return result('login', FAIL, target.key ? `${where} has no saved token` : 'not logged in', target.loginHint);
|
|
83
|
+
}
|
|
84
|
+
const fromEnv = target.tokenSource === 'env';
|
|
85
|
+
const tokenName = fromEnv ? 'the token in COMMONLY_TOKEN' : 'the saved token';
|
|
86
|
+
const refix = fromEnv ? 'replace or unset COMMONLY_TOKEN' : target.loginHint;
|
|
87
|
+
let user;
|
|
88
|
+
try {
|
|
89
|
+
user = await fetchUser();
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if ([401, 403].includes(error?.status)) {
|
|
92
|
+
return result('login', FAIL, `${where} rejected ${tokenName}`, refix);
|
|
93
|
+
}
|
|
94
|
+
return result('login', FAIL, `could not reach ${target.url}: ${error?.message || error}`, 'check the network, then re-run commonly doctor');
|
|
95
|
+
}
|
|
96
|
+
const who = user?.username || user?.email || user?._id;
|
|
97
|
+
if (!who) return result('login', FAIL, `${where} answered without a user`, refix);
|
|
98
|
+
return result('login', PASS, `@${who} on ${where}${fromEnv ? ' (token from COMMONLY_TOKEN)' : ''}`);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const ago = (iso, now) => {
|
|
102
|
+
const at = Date.parse(iso);
|
|
103
|
+
if (!Number.isFinite(at)) return 'never';
|
|
104
|
+
const seconds = Math.max(0, Math.round((now - at) / 1000));
|
|
105
|
+
if (seconds < 120) return `${seconds}s ago`;
|
|
106
|
+
if (seconds < 7200) return `${Math.round(seconds / 60)}m ago`;
|
|
107
|
+
return `${Math.round(seconds / 3600)}h ago`;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// (d) The daemon: registered, service installed, heard from lately.
|
|
111
|
+
export const checkDaemon = async ({
|
|
112
|
+
loadRecord, serviceFile, fileExists, fetchMachine, describeError, now,
|
|
113
|
+
}) => {
|
|
114
|
+
let record;
|
|
115
|
+
try {
|
|
116
|
+
record = loadRecord();
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return result('daemon', FAIL, error?.message || String(error), 'fix the file permissions named above, then re-run');
|
|
119
|
+
}
|
|
120
|
+
if (!record) {
|
|
121
|
+
return result('daemon', SKIP, 'no daemon registered on this computer (optional; agents then run in the foreground)');
|
|
122
|
+
}
|
|
123
|
+
const name = record.machineName || 'this computer';
|
|
124
|
+
if (!fileExists(serviceFile)) {
|
|
125
|
+
return result('daemon', FAIL, `${name} is registered but its login service is not installed (${serviceFile})`, 'commonly daemon install');
|
|
126
|
+
}
|
|
127
|
+
let machine;
|
|
128
|
+
try {
|
|
129
|
+
machine = await fetchMachine(record);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return result('daemon', FAIL, describeError(error), 'commonly daemon status');
|
|
132
|
+
}
|
|
133
|
+
const heard = `last heartbeat ${ago(machine?.lastSeenAt, now)}`;
|
|
134
|
+
if (machine?.status !== 'online') {
|
|
135
|
+
return result('daemon', FAIL, `${machine?.name || name} is ${machine?.status || 'unknown'}, ${heard}`, 'commonly daemon restart, then commonly daemon logs');
|
|
136
|
+
}
|
|
137
|
+
return result('daemon', PASS, `${machine.name || name} online, service installed, ${heard}`);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const MAX_NAMED_AGENTS = 5;
|
|
141
|
+
|
|
142
|
+
const nameList = (names) => {
|
|
143
|
+
const sorted = [...names].sort();
|
|
144
|
+
const shown = sorted.slice(0, MAX_NAMED_AGENTS).join(', ');
|
|
145
|
+
return `${shown}${sorted.length > MAX_NAMED_AGENTS ? ` and ${sorted.length - MAX_NAMED_AGENTS} more` : ''}`;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// (e) Every adapter CLI a configured agent uses, found and answering --version.
|
|
149
|
+
// `unreadable` names token files that exist but are not readable JSON: the
|
|
150
|
+
// agent listing drops them, so without this line they would read as no agent.
|
|
151
|
+
export const checkAdapters = async ({ agents, getAdapter, unreadable = [] }) => {
|
|
152
|
+
const byAdapter = new Map();
|
|
153
|
+
for (const agent of agents) {
|
|
154
|
+
const adapter = agent?.adapter || '?';
|
|
155
|
+
if (!byAdapter.has(adapter)) byAdapter.set(adapter, []);
|
|
156
|
+
byAdapter.get(adapter).push(agent.name);
|
|
157
|
+
}
|
|
158
|
+
const unreadableLine = unreadable.length
|
|
159
|
+
? [result('adapters', FAIL, `the token file of ${nameList(unreadable)} is not readable JSON (~/.commonly/tokens)`, 'commonly agent detach <name> --force, then attach it again')]
|
|
160
|
+
: [];
|
|
161
|
+
if (!byAdapter.size) {
|
|
162
|
+
return unreadableLine.length ? unreadableLine : [result('adapters', SKIP, 'no agents are attached on this computer')];
|
|
163
|
+
}
|
|
164
|
+
const results = [];
|
|
165
|
+
for (const [name, agentNames] of [...byAdapter.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
166
|
+
const id = `adapter:${name}`;
|
|
167
|
+
const used = `used by ${nameList(agentNames)}`;
|
|
168
|
+
if (name === '?') {
|
|
169
|
+
results.push(result(id, FAIL, `no adapter recorded in the token file, ${used}`, 'commonly agent detach <name> --force, then attach it again'));
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const adapter = getAdapter(name);
|
|
173
|
+
if (!adapter) {
|
|
174
|
+
results.push(result(id, FAIL, `unknown adapter '${name}', ${used}`, `commonly agent detach <name> --force, then attach it again`));
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (typeof adapter.detect !== 'function') {
|
|
178
|
+
results.push(result(id, SKIP, `${name} runs no local CLI, ${used}`));
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
let found = null;
|
|
182
|
+
let detectError = null;
|
|
183
|
+
try {
|
|
184
|
+
// eslint-disable-next-line no-await-in-loop
|
|
185
|
+
found = await adapter.detect();
|
|
186
|
+
} catch (error) {
|
|
187
|
+
detectError = error?.message || String(error);
|
|
188
|
+
}
|
|
189
|
+
if (detectError) {
|
|
190
|
+
results.push(result(id, FAIL, `${name} could not be checked (${detectError}), ${used}`, `run the ${name} CLI with --version by hand`));
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (!found) {
|
|
194
|
+
results.push(result(id, FAIL, `${name} is not on PATH or its --version failed, ${used}`, `install the ${name} CLI, or put it on PATH`));
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const at = found.path ? ` at ${found.path}` : '';
|
|
198
|
+
results.push(result(id, PASS, `${name} ${found.version || 'unknown version'}${at}, ${used}`));
|
|
199
|
+
}
|
|
200
|
+
return [...results, ...unreadableLine];
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// (f) macOS: what the claude adapter's confined spawn needs, by file stat only.
|
|
204
|
+
export const checkMacClaude = ({
|
|
205
|
+
platform, agents, detectSeatbelt, isFile, loginKeychainPath, securityPath = '/usr/bin/security',
|
|
206
|
+
}) => {
|
|
207
|
+
if (platform !== 'darwin') return result('macos-claude', SKIP, 'not macOS');
|
|
208
|
+
if (!agents.some((agent) => agent?.adapter === 'claude')) {
|
|
209
|
+
return result('macos-claude', SKIP, 'no agent here uses the claude adapter');
|
|
210
|
+
}
|
|
211
|
+
const missing = [];
|
|
212
|
+
let seatbelt;
|
|
213
|
+
try {
|
|
214
|
+
seatbelt = detectSeatbelt();
|
|
215
|
+
} catch (error) {
|
|
216
|
+
seatbelt = { available: false, error: error?.message || String(error) };
|
|
217
|
+
}
|
|
218
|
+
if (!seatbelt?.available) missing.push(`Seatbelt (${seatbelt?.error || 'sandbox-exec unavailable'})`);
|
|
219
|
+
if (!isFile(loginKeychainPath)) missing.push(`login keychain (${loginKeychainPath})`);
|
|
220
|
+
if (!isFile(securityPath)) missing.push(`${securityPath}`);
|
|
221
|
+
if (missing.length) {
|
|
222
|
+
// A failed preflight most often means doctor itself is inside a sandbox
|
|
223
|
+
// (an agent's turn), where a nested sandbox-exec is refused.
|
|
224
|
+
const fixes = [];
|
|
225
|
+
if (!seatbelt?.available) fixes.push('run commonly doctor from a normal Terminal, not from inside an agent\'s sandbox');
|
|
226
|
+
if (missing.length > (seatbelt?.available ? 0 : 1)) {
|
|
227
|
+
fixes.push('sign in to this Mac as the user the agent runs as, and run claude once to log in');
|
|
228
|
+
}
|
|
229
|
+
const fix = fixes.join('; ');
|
|
230
|
+
return result('macos-claude', FAIL, `missing: ${missing.join('; ')}`, fix);
|
|
231
|
+
}
|
|
232
|
+
return result('macos-claude', PASS, 'Seatbelt runs, the login keychain and /usr/bin/security are present');
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/** Every check, in the order the lines print. */
|
|
236
|
+
export const runDoctor = async (deps) => {
|
|
237
|
+
// An unreadable tokens dir is one fail line, not a doctor that prints nothing.
|
|
238
|
+
let agents = null;
|
|
239
|
+
let unreadable = [];
|
|
240
|
+
let listError = null;
|
|
241
|
+
try {
|
|
242
|
+
agents = deps.listAgents();
|
|
243
|
+
unreadable = deps.listUnreadable ? deps.listUnreadable() : [];
|
|
244
|
+
} catch (error) {
|
|
245
|
+
listError = error?.message || String(error);
|
|
246
|
+
}
|
|
247
|
+
return [
|
|
248
|
+
checkNode(deps.node),
|
|
249
|
+
checkInstall(deps.install),
|
|
250
|
+
await checkLogin(deps.login),
|
|
251
|
+
await checkDaemon(deps.daemon),
|
|
252
|
+
...(listError
|
|
253
|
+
? [
|
|
254
|
+
result('adapters', FAIL, `could not list the attached agents (${listError})`, 'check that ~/.commonly/tokens is a directory you can read'),
|
|
255
|
+
result('macos-claude', SKIP, 'the attached agents could not be listed'),
|
|
256
|
+
]
|
|
257
|
+
: [
|
|
258
|
+
...(await checkAdapters({ agents, getAdapter: deps.getAdapter, unreadable })),
|
|
259
|
+
checkMacClaude({ ...deps.macos, agents }),
|
|
260
|
+
]),
|
|
261
|
+
];
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// One line each, whatever a server or a CLI put in its error text.
|
|
265
|
+
// eslint-disable-next-line no-control-regex -- matching control characters is the point
|
|
266
|
+
const oneLine = (text) => String(text ?? '').replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').trim();
|
|
267
|
+
|
|
268
|
+
export const formatDoctorLine = ({ id, status, detail, fix }) => (
|
|
269
|
+
`${status.padEnd(4)} ${id.padEnd(16)} ${oneLine(detail)}${status === FAIL && fix ? ` — fix: ${oneLine(fix)}` : ''}`
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
export const doctorExitCode = (results) => (results.some((r) => r.status === FAIL) ? 1 : 0);
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One model turn of `commonly agent run`, on one of two paths. The supervised
|
|
3
|
+
* path also writes one metadata line per turn (the turn log), once the
|
|
4
|
+
* turn's outcome has reached the pod or failed to.
|
|
5
|
+
*
|
|
6
|
+
* COMMONLY_SUPERVISE_RUNS=1 (read by performRun as `superviseRuns`)
|
|
7
|
+
* the turn runs under `superviseRun` from @commonlyai/seats: a stall
|
|
8
|
+
* window, a wall-clock backstop, and a guarantee the turn settles with a
|
|
9
|
+
* named failure. No provider relay: the CLI talks to its provider directly,
|
|
10
|
+
* exactly as on the plain path.
|
|
11
|
+
* anything else
|
|
12
|
+
* the plain path: `adapter.spawn(prompt, ctx)` with the caller's ctx object
|
|
13
|
+
* untouched, and nothing written: the flag-off run loop is main's.
|
|
14
|
+
*
|
|
15
|
+
* A turn is ok only when its outcome reached the pod: the reply was delivered
|
|
16
|
+
* or the agent chose silence. The run loop names the outcome after delivery
|
|
17
|
+
* settles (turn.finish); a spawn that threw is `failed` at once.
|
|
18
|
+
*
|
|
19
|
+
* NO RECORD WRITER. The supervisor needs a trace store. The one here
|
|
20
|
+
* (createMetadataStore) is in memory, lives for one turn, and keeps event type
|
|
21
|
+
* names, times and the recovery action word. It drops every event's data, so
|
|
22
|
+
* no agent text, prompt, reply, failure detail or credential is kept, and
|
|
23
|
+
* nothing is written to disk (a redaction review on #2701).
|
|
24
|
+
*
|
|
25
|
+
* Metrics: ~/.commonly/state/turns/<seat>.jsonl (COMMONLY_TURN_LOG_DIR
|
|
26
|
+
* overrides the directory). Whitelisted fields only; see metricLine.
|
|
27
|
+
*/
|
|
28
|
+
import { appendFileSync, mkdirSync } from 'fs';
|
|
29
|
+
import { homedir } from 'os';
|
|
30
|
+
import { dirname, join } from 'path';
|
|
31
|
+
import { superviseRun as defaultSuperviseRun } from '@commonlyai/seats/supervisor';
|
|
32
|
+
import { isEventType } from '@commonlyai/seats/trace/events';
|
|
33
|
+
import { failureKindOf } from '@commonlyai/seats/failures';
|
|
34
|
+
import { classifySpawnFailure } from './spawn-retry.js';
|
|
35
|
+
|
|
36
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Stall windows per adapter, in ms; 0 is off. Unlisted adapters: off.
|
|
40
|
+
*
|
|
41
|
+
* codex: `codex exec --json` writes a JSONL event per item, so its stdout is a
|
|
42
|
+
* real progress signal, and the quiet stretch is the longest single item (a
|
|
43
|
+
* reasoning step or one shell command). Measured 2026-10-25 from per-turn
|
|
44
|
+
* timestamp gaps in October codex rollouts on one host (~/.codex/sessions):
|
|
45
|
+
* 9,703 turns, max quiet gap 162 s, p99 53 s; 9 turns would have tripped the
|
|
46
|
+
* package default of 120 s. 15 min is 5.5x the worst gap seen and still
|
|
47
|
+
* 30 min inside the seats' 45 min turn budget.
|
|
48
|
+
* claude: the adapter runs `claude -p --output-format text`, which writes
|
|
49
|
+
* nothing to stdout until the reply is done. A healthy 14-minute claude turn
|
|
50
|
+
* is 14 minutes of silence to the supervisor, so any window
|
|
51
|
+
* under the turn budget would be a turn-length cap, not a liveness check.
|
|
52
|
+
* Off; claude is guarded by the wall-clock backstop and the cancel ladder.
|
|
53
|
+
*/
|
|
54
|
+
export const STALL_DEFAULTS_MS = Object.freeze({ codex: 15 * 60 * 1000, claude: 0 });
|
|
55
|
+
// How long after the supervisor's abort before it SIGKILLs the child's tree
|
|
56
|
+
// (process-group.js). The claude and codex adapters do not act on the abort,
|
|
57
|
+
// so on a stall the child is alive at the kill and its whole tree goes with it:
|
|
58
|
+
// a SIGTERM to the CLI alone would let it exit first and orphan the MCP
|
|
59
|
+
// servers it started in groups of their own, which the tree kill then skips.
|
|
60
|
+
// An adapter that honours ctx.signal gets this long to settle.
|
|
61
|
+
export const CANCEL_GRACE_MS = 15_000;
|
|
62
|
+
// The wall clock sits past the adapter's own timeout, so the adapter's timeout
|
|
63
|
+
// (the same SIGTERM and error as on the plain path) fires first; the
|
|
64
|
+
// supervisor's only settles a child that outlived it.
|
|
65
|
+
export const WALL_BACKSTOP_MS = 60_000;
|
|
66
|
+
// After a failed turn, how long to wait for the child's exit status before
|
|
67
|
+
// writing the metric line anyway (the kill lands just before settle).
|
|
68
|
+
const EXIT_WAIT_MS = 3_000;
|
|
69
|
+
|
|
70
|
+
const envName = (prefix, adapterName) => `${prefix}_${String(adapterName || '').toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
|
|
71
|
+
|
|
72
|
+
const envMs = (env, name, warnings) => {
|
|
73
|
+
const raw = env?.[name];
|
|
74
|
+
if (raw === undefined || raw === '') return undefined;
|
|
75
|
+
const value = Number(raw);
|
|
76
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
77
|
+
warnings.push(`${name} ignored (want a non-negative integer of ms)`);
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
return Math.min(value, MAX_TIMER_MS);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The supervisor policy for one adapter's turn. Env overrides, most specific
|
|
85
|
+
* first: COMMONLY_SUPERVISE_STALL_MS_<ADAPTER>, COMMONLY_SUPERVISE_STALL_MS,
|
|
86
|
+
* COMMONLY_SUPERVISE_CANCEL_GRACE_MS, COMMONLY_SUPERVISE_WALL_BACKSTOP_MS.
|
|
87
|
+
* 0 turns a window off. No retries: the run loop keeps those.
|
|
88
|
+
*/
|
|
89
|
+
export const supervisedRunPolicy = (turnTimeoutMs, { adapterName = null, env = process.env, warnings = [] } = {}) => {
|
|
90
|
+
const stallMs = envMs(env, envName('COMMONLY_SUPERVISE_STALL_MS', adapterName), warnings)
|
|
91
|
+
?? envMs(env, 'COMMONLY_SUPERVISE_STALL_MS', warnings)
|
|
92
|
+
?? STALL_DEFAULTS_MS[adapterName] ?? 0;
|
|
93
|
+
const cancelGraceMs = envMs(env, 'COMMONLY_SUPERVISE_CANCEL_GRACE_MS', warnings) ?? CANCEL_GRACE_MS;
|
|
94
|
+
const backstopMs = envMs(env, 'COMMONLY_SUPERVISE_WALL_BACKSTOP_MS', warnings) ?? WALL_BACKSTOP_MS;
|
|
95
|
+
return {
|
|
96
|
+
stallMs,
|
|
97
|
+
cancelGraceMs,
|
|
98
|
+
timeoutMs: Math.min(Number(turnTimeoutMs) + backstopMs, MAX_TIMER_MS),
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A trace store that keeps no agent text and writes nothing. superviseRun
|
|
104
|
+
* calls startRun once per turn; each event keeps { type, at, action? }.
|
|
105
|
+
*/
|
|
106
|
+
export const createMetadataStore = ({ now = Date.now } = {}) => {
|
|
107
|
+
let current = null;
|
|
108
|
+
let seq = 0;
|
|
109
|
+
return {
|
|
110
|
+
startRun({ runId } = {}) {
|
|
111
|
+
seq += 1;
|
|
112
|
+
const events = [];
|
|
113
|
+
let settled = false;
|
|
114
|
+
current = {
|
|
115
|
+
runId: runId || `turn-${now().toString(36)}-${seq}`,
|
|
116
|
+
emit(type, data) {
|
|
117
|
+
if (!isEventType(type)) throw new Error(`unknown trace event type: ${type}`);
|
|
118
|
+
if (settled) return null;
|
|
119
|
+
const entry = { type, at: now() };
|
|
120
|
+
if (type === 'liveness.recovery' && typeof data?.action === 'string') entry.action = data.action;
|
|
121
|
+
events.push(entry);
|
|
122
|
+
if (type === 'run.settled') settled = true;
|
|
123
|
+
return entry;
|
|
124
|
+
},
|
|
125
|
+
get settled() { return settled; },
|
|
126
|
+
events: () => events.map((entry) => ({ ...entry })),
|
|
127
|
+
};
|
|
128
|
+
return current;
|
|
129
|
+
},
|
|
130
|
+
lastRun: () => current,
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const metricsPathFor = (seat, env = process.env) => {
|
|
135
|
+
const dir = env?.COMMONLY_TURN_LOG_DIR || join(homedir(), '.commonly', 'state', 'turns');
|
|
136
|
+
const safe = String(seat || 'unknown').replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 64) || 'unknown';
|
|
137
|
+
return join(dir, `${safe}.jsonl`);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
/** A failure's name, never its text. RunFailure kinds are the supervisor's words. */
|
|
141
|
+
export const failureNameOf = (error) => {
|
|
142
|
+
const kind = failureKindOf(error) || (typeof error?.kind === 'string' && /^[a-z_]{1,40}$/.test(error.kind) ? error.kind : null);
|
|
143
|
+
if (kind) return { name: kind, source: 'supervisor' };
|
|
144
|
+
const message = String(error?.message ?? error ?? '');
|
|
145
|
+
if (/\btimed out after \d+ms\b/.test(message)) return { name: 'adapter_timeout', source: 'adapter' };
|
|
146
|
+
if (/\bexited with code\b/.test(message)) return { name: 'adapter_exit', source: 'adapter' };
|
|
147
|
+
return { name: 'adapter_error', source: 'adapter' };
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const failureClassOf = (error) => {
|
|
151
|
+
try { return classifySpawnFailure(error); } catch { return null; }
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// What happened to a turn, named by the run loop once delivery settles.
|
|
155
|
+
export const TURN_OUTCOMES = Object.freeze([
|
|
156
|
+
'delivered', // the reply reached the pod (wrapper post, the agent's own post, or an ask response)
|
|
157
|
+
'silent', // NO_REPLY, a heartbeat control word, or empty output: nothing to post
|
|
158
|
+
'reply_lost', // the model answered and every delivery rung threw
|
|
159
|
+
'claim_lost', // the lease lapsed mid-turn and a peer owns the message; reply suppressed
|
|
160
|
+
'server_skipped', // the server answered and created nothing (a duplicate, a kept heartbeat)
|
|
161
|
+
'server_refused', // the server refused the post (a run cap)
|
|
162
|
+
'upstream_refused', // the model route refused; the CLI exited with nothing to say
|
|
163
|
+
]);
|
|
164
|
+
export const OK_OUTCOMES = Object.freeze(['delivered', 'silent']);
|
|
165
|
+
|
|
166
|
+
const EVENT_TYPE_RE = /^[A-Za-z0-9._-]{1,40}$/;
|
|
167
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
168
|
+
const int = (value) => (Number.isInteger(value) ? value : null);
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The metric line. Every field is listed here, each a number, boolean, null or
|
|
172
|
+
* a short fixed word; nothing from the prompt, the reply, a failure's detail or
|
|
173
|
+
* the environment can reach it.
|
|
174
|
+
*/
|
|
175
|
+
export const metricLine = (f) => {
|
|
176
|
+
const failed = f.outcome === 'failed';
|
|
177
|
+
const outcome = failed || TURN_OUTCOMES.includes(f.outcome) ? f.outcome : 'unknown';
|
|
178
|
+
return {
|
|
179
|
+
v: 1,
|
|
180
|
+
ts: iso(f.settledMs ?? f.endMs),
|
|
181
|
+
seat: String(f.seat || 'unknown').replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 64),
|
|
182
|
+
adapter: String(f.adapter || 'unknown').replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 32),
|
|
183
|
+
path: f.path === 'supervised' ? 'supervised' : 'legacy',
|
|
184
|
+
eventType: EVENT_TYPE_RE.test(String(f.eventType || '')) ? f.eventType : null,
|
|
185
|
+
turnStart: iso(f.startMs),
|
|
186
|
+
turnEnd: iso(f.endMs),
|
|
187
|
+
durationMs: Math.max(0, f.endMs - f.startMs),
|
|
188
|
+
outcome,
|
|
189
|
+
ok: OK_OUTCOMES.includes(outcome),
|
|
190
|
+
failure: failed ? (f.failureName || 'adapter_error') : null,
|
|
191
|
+
failureSource: failed ? (f.failureSource || null) : null,
|
|
192
|
+
failureClass: failed && typeof f.failureClass === 'string' ? f.failureClass : null,
|
|
193
|
+
stalled: !!f.stalled,
|
|
194
|
+
cancelled: !!f.cancelled,
|
|
195
|
+
killed: !!f.killed,
|
|
196
|
+
exitCode: int(f.exitCode),
|
|
197
|
+
signal: typeof f.signal === 'string' && /^SIG[A-Z0-9]+$/.test(f.signal) ? f.signal : null,
|
|
198
|
+
exitObserved: !!f.exitObserved,
|
|
199
|
+
childLeftRunning: typeof f.childLeftRunning === 'boolean' ? f.childLeftRunning : null,
|
|
200
|
+
attempts: int(f.attempts),
|
|
201
|
+
stallMs: int(f.policy?.stallMs),
|
|
202
|
+
cancelGraceMs: int(f.policy?.cancelGraceMs),
|
|
203
|
+
timeoutMs: int(f.policy?.timeoutMs),
|
|
204
|
+
};
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const isChild = (value) => !!value && typeof value.once === 'function' && typeof value.kill === 'function';
|
|
208
|
+
const isAlive = (child) => !!child && child.exitCode === null && child.signalCode === null;
|
|
209
|
+
|
|
210
|
+
const waitForExit = (child, ms) => new Promise((resolve) => {
|
|
211
|
+
if (!isAlive(child)) { resolve(); return; }
|
|
212
|
+
const timer = setTimeout(resolve, ms);
|
|
213
|
+
timer.unref?.();
|
|
214
|
+
child.once('exit', () => { clearTimeout(timer); resolve(); });
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* @param {object} args
|
|
219
|
+
* @param {string} args.seat agent name; names the metrics file
|
|
220
|
+
* @param {number} args.turnTimeoutMs the run loop's effective turn budget
|
|
221
|
+
* @param {boolean} args.supervise performRun's superviseRuns
|
|
222
|
+
* @param {(line: string) => void} [args.log]
|
|
223
|
+
* @param {object} [args.env]
|
|
224
|
+
* @param {Function} [args.superviseRun] injectable for tests
|
|
225
|
+
*/
|
|
226
|
+
export const createTurnRunner = ({
|
|
227
|
+
seat,
|
|
228
|
+
turnTimeoutMs,
|
|
229
|
+
supervise = false,
|
|
230
|
+
log = () => {},
|
|
231
|
+
env = process.env,
|
|
232
|
+
now = Date.now,
|
|
233
|
+
superviseRun = defaultSuperviseRun,
|
|
234
|
+
} = {}) => {
|
|
235
|
+
const metricsPath = metricsPathFor(seat, env);
|
|
236
|
+
let metricsWarned = false;
|
|
237
|
+
|
|
238
|
+
const record = (fields) => {
|
|
239
|
+
try {
|
|
240
|
+
mkdirSync(dirname(metricsPath), { recursive: true, mode: 0o700 });
|
|
241
|
+
appendFileSync(metricsPath, `${JSON.stringify(metricLine({ seat, ...fields }))}\n`, { mode: 0o600 });
|
|
242
|
+
} catch (error) {
|
|
243
|
+
// Metrics never fail a turn. Said once per process, not per turn.
|
|
244
|
+
if (!metricsWarned) {
|
|
245
|
+
metricsWarned = true;
|
|
246
|
+
log(`turn metrics not written (${error?.code || 'error'}); turns are unaffected`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/** The boot line for the supervised path; the plain path logs nothing new. */
|
|
252
|
+
const describe = (adapterName) => {
|
|
253
|
+
const warnings = [];
|
|
254
|
+
const policy = supervisedRunPolicy(turnTimeoutMs, { adapterName, env, warnings });
|
|
255
|
+
return `supervised runs ON for ${adapterName}: stall=${policy.stallMs ? `${policy.stallMs}ms` : 'off'}, `
|
|
256
|
+
+ `cancel grace=${policy.cancelGraceMs}ms, wall=${policy.timeoutMs}ms, `
|
|
257
|
+
+ 'trace=memory (no record written); '
|
|
258
|
+
+ `metrics → ${metricsPath}${warnings.length ? `; ${warnings.join('; ')}` : ''}`;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const runSupervised = async (adapter, prompt, ctx, eventType, hold) => {
|
|
262
|
+
const startMs = now();
|
|
263
|
+
const policy = supervisedRunPolicy(turnTimeoutMs, { adapterName: adapter?.name, env });
|
|
264
|
+
const store = createMetadataStore({ now });
|
|
265
|
+
let child = null;
|
|
266
|
+
let exit = null;
|
|
267
|
+
const callerOnSpawn = ctx?.onSpawn;
|
|
268
|
+
const onSpawn = (...args) => {
|
|
269
|
+
const proc = args.find(isChild);
|
|
270
|
+
if (proc) {
|
|
271
|
+
child = proc;
|
|
272
|
+
exit = null;
|
|
273
|
+
proc.once('exit', (code, signal) => { if (child === proc) exit = { code, signal }; });
|
|
274
|
+
}
|
|
275
|
+
return typeof callerOnSpawn === 'function' ? callerOnSpawn(...args) : undefined;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const supervised = await superviseRun({
|
|
279
|
+
adapter,
|
|
280
|
+
prompt,
|
|
281
|
+
ctx: { ...ctx, onSpawn },
|
|
282
|
+
store,
|
|
283
|
+
policy,
|
|
284
|
+
label: EVENT_TYPE_RE.test(String(eventType || '')) ? eventType : undefined,
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const ok = !supervised.failure;
|
|
288
|
+
if (!ok) await waitForExit(child, EXIT_WAIT_MS);
|
|
289
|
+
const events = store.lastRun()?.events() || [];
|
|
290
|
+
const recovered = (action) => events.some((e) => e.type === 'liveness.recovery' && e.action === action);
|
|
291
|
+
const killed = recovered('kill');
|
|
292
|
+
const named = ok ? null : failureNameOf(supervised.failure);
|
|
293
|
+
const fields = {
|
|
294
|
+
adapter: adapter?.name,
|
|
295
|
+
path: 'supervised',
|
|
296
|
+
eventType,
|
|
297
|
+
startMs,
|
|
298
|
+
endMs: now(),
|
|
299
|
+
outcome: ok ? undefined : 'failed',
|
|
300
|
+
failureName: named?.name,
|
|
301
|
+
failureSource: named?.source,
|
|
302
|
+
failureClass: ok ? null : failureClassOf(supervised.failure),
|
|
303
|
+
stalled: events.some((e) => e.type === 'liveness.stalled'),
|
|
304
|
+
cancelled: recovered('cancel'),
|
|
305
|
+
killed,
|
|
306
|
+
exitCode: exit ? exit.code : null,
|
|
307
|
+
signal: exit ? exit.signal : null,
|
|
308
|
+
exitObserved: !!exit,
|
|
309
|
+
childLeftRunning: child ? isAlive(child) : null,
|
|
310
|
+
attempts: supervised.attempts,
|
|
311
|
+
policy,
|
|
312
|
+
};
|
|
313
|
+
// A spawn that resolved is not yet an ok turn: the line waits for the run
|
|
314
|
+
// loop to say what became of the reply.
|
|
315
|
+
if (ok) { hold(fields); return supervised.result; }
|
|
316
|
+
record(fields);
|
|
317
|
+
if (named.source === 'supervisor') {
|
|
318
|
+
// The name goes in the seat log. The throw takes the road a plain-path
|
|
319
|
+
// failure takes: unacked, the run loop's retry policy, onError.
|
|
320
|
+
log(`[${eventType}] supervised turn failed: ${named.name}${killed ? ' (child killed)' : ''}`
|
|
321
|
+
+ `${isAlive(child) ? ' (child still running)' : ''}`);
|
|
322
|
+
}
|
|
323
|
+
throw supervised.failure;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
supervise,
|
|
328
|
+
metricsPath,
|
|
329
|
+
describe,
|
|
330
|
+
/**
|
|
331
|
+
* One model turn. `run` has adapter.spawn's contract; `finish(outcome)`
|
|
332
|
+
* writes the line once delivery has settled. Only the first finish counts,
|
|
333
|
+
* and a finish with no spawned turn behind it writes nothing.
|
|
334
|
+
*/
|
|
335
|
+
begin({ eventType } = {}) {
|
|
336
|
+
let held = null;
|
|
337
|
+
let done = false;
|
|
338
|
+
return {
|
|
339
|
+
run(adapter, prompt, ctx) {
|
|
340
|
+
if (!supervise) return adapter.spawn(prompt, ctx);
|
|
341
|
+
return runSupervised(adapter, prompt, ctx, eventType, (fields) => { held = fields; });
|
|
342
|
+
},
|
|
343
|
+
finish(outcome) {
|
|
344
|
+
if (done || !held) return;
|
|
345
|
+
done = true;
|
|
346
|
+
record({ ...held, outcome, settledMs: now() });
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Your CLI is N versions behind" — one line at `agent run` and `daemon run`
|
|
3
|
+
* start-up (TASK-660, D213).
|
|
4
|
+
*
|
|
5
|
+
* A seat pinned to an old CLI fails in ways that look like platform bugs, and
|
|
6
|
+
* nothing on the seat said it was old. This prints one line when the installed
|
|
7
|
+
* CLI trails npm latest by UPDATE_NOTICE_MIN_BEHIND or more, and is otherwise
|
|
8
|
+
* silent: offline, a slow registry, a malformed answer and
|
|
9
|
+
* COMMONLY_NO_UPDATE_CHECK=1 all print nothing.
|
|
10
|
+
*
|
|
11
|
+
* It must never slow start-up. `startUpdateNotice` returns at once; the lookup
|
|
12
|
+
* runs beside the poll loop, is capped at UPDATE_TIMEOUT_MS by its own timer
|
|
13
|
+
* (a fetch that ignores its abort signal still cannot hold it), and is cached
|
|
14
|
+
* for UPDATE_CACHE_TTL_MS so a restarting seat asks the registry once a day.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { dirname, join } from 'node:path';
|
|
19
|
+
import { exactVersion, parseVersion } from './mcp-server-version.js';
|
|
20
|
+
|
|
21
|
+
export const CLI_PACKAGE = '@commonlyai/cli';
|
|
22
|
+
export const REGISTRY_LATEST_URL = 'https://registry.npmjs.org/@commonlyai%2fcli/latest';
|
|
23
|
+
export const UPDATE_CHECK_OPT_OUT_ENV = 'COMMONLY_NO_UPDATE_CHECK';
|
|
24
|
+
export const UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
export const UPDATE_TIMEOUT_MS = 2000;
|
|
26
|
+
// The CLI publishes often, so one or two releases behind is the normal gap
|
|
27
|
+
// between a publish and a seat's next restart. Three is where it is stale.
|
|
28
|
+
export const UPDATE_NOTICE_MIN_BEHIND = 3;
|
|
29
|
+
|
|
30
|
+
export const updateCachePath = (home = homedir()) => join(home, '.commonly', 'update-check.json');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* How far `installed` trails `latest`: 0 when equal or ahead, the patch gap
|
|
34
|
+
* within one major.minor, and Infinity when latest is a newer minor or major
|
|
35
|
+
* (the patch numbers of two release lines say nothing about the distance).
|
|
36
|
+
* null when either side is not a version.
|
|
37
|
+
*/
|
|
38
|
+
export const versionsBehind = (installed, latest) => {
|
|
39
|
+
const a = parseVersion(installed);
|
|
40
|
+
const b = parseVersion(latest);
|
|
41
|
+
if (!a || !b) return null;
|
|
42
|
+
if (a[0] !== b[0] || a[1] !== b[1]) {
|
|
43
|
+
return (b[0] > a[0] || (b[0] === a[0] && b[1] > a[1])) ? Infinity : 0;
|
|
44
|
+
}
|
|
45
|
+
return Math.max(0, b[2] - a[2]);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The one line to print, or null when there is nothing worth saying. */
|
|
49
|
+
export const updateNoticeLine = ({ installed, latest, minBehind = UPDATE_NOTICE_MIN_BEHIND }) => {
|
|
50
|
+
const behind = versionsBehind(installed, latest);
|
|
51
|
+
if (behind === null || behind < minBehind) return null;
|
|
52
|
+
// Only the parsed triple is printed: the registry's string never reaches the log.
|
|
53
|
+
const from = exactVersion(installed);
|
|
54
|
+
const to = exactVersion(latest);
|
|
55
|
+
const gap = Number.isFinite(behind) ? `${behind} versions behind` : 'behind a newer release line';
|
|
56
|
+
return `your CLI is ${gap} (installed ${from}, latest ${to}). Update: npm install -g ${CLI_PACKAGE}@latest`
|
|
57
|
+
+ ` (silence with ${UPDATE_CHECK_OPT_OUT_ENV}=1)`;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const defaultReadText = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : null);
|
|
61
|
+
const defaultWriteText = (path, text) => {
|
|
62
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
63
|
+
writeFileSync(path, text, { encoding: 'utf8', mode: 0o600 });
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** The cached latest version when the cache is younger than the TTL, else null. */
|
|
67
|
+
export const readCachedLatest = ({ cachePath, now, readText = defaultReadText, ttlMs = UPDATE_CACHE_TTL_MS }) => {
|
|
68
|
+
let raw;
|
|
69
|
+
try {
|
|
70
|
+
raw = readText(cachePath);
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (!raw) return null;
|
|
75
|
+
try {
|
|
76
|
+
const cache = JSON.parse(raw);
|
|
77
|
+
const checkedAt = Date.parse(cache?.checkedAt);
|
|
78
|
+
const latest = exactVersion(cache?.latest);
|
|
79
|
+
if (!Number.isFinite(checkedAt) || !latest) return null;
|
|
80
|
+
if (now - checkedAt < 0 || now - checkedAt >= ttlMs) return null;
|
|
81
|
+
return latest;
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* npm latest, or null on any failure. The timer is the cap, not the signal:
|
|
89
|
+
* the signal asks fetch to stop, the race guarantees the answer is on time.
|
|
90
|
+
*/
|
|
91
|
+
export const fetchLatestVersion = async ({
|
|
92
|
+
fetchImpl = globalThis.fetch,
|
|
93
|
+
timeoutMs = UPDATE_TIMEOUT_MS,
|
|
94
|
+
url = REGISTRY_LATEST_URL,
|
|
95
|
+
} = {}) => {
|
|
96
|
+
if (typeof fetchImpl !== 'function') return null;
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
let timer;
|
|
99
|
+
const deadline = new Promise((resolve) => {
|
|
100
|
+
timer = setTimeout(() => {
|
|
101
|
+
controller.abort();
|
|
102
|
+
resolve(null);
|
|
103
|
+
}, timeoutMs);
|
|
104
|
+
timer.unref?.();
|
|
105
|
+
});
|
|
106
|
+
const lookup = (async () => {
|
|
107
|
+
const res = await fetchImpl(url, { signal: controller.signal, headers: { accept: 'application/json' } });
|
|
108
|
+
if (!res?.ok) return null;
|
|
109
|
+
const body = await res.json();
|
|
110
|
+
return exactVersion(body?.version);
|
|
111
|
+
})().catch(() => null);
|
|
112
|
+
try {
|
|
113
|
+
return await Promise.race([lookup, deadline]);
|
|
114
|
+
} finally {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** The notice line for this start-up, or null. Never throws. */
|
|
120
|
+
export const checkForCliUpdate = async ({
|
|
121
|
+
installedVersion,
|
|
122
|
+
env = process.env,
|
|
123
|
+
now = Date.now(),
|
|
124
|
+
cachePath = updateCachePath(),
|
|
125
|
+
fetchImpl = globalThis.fetch,
|
|
126
|
+
timeoutMs = UPDATE_TIMEOUT_MS,
|
|
127
|
+
readText = defaultReadText,
|
|
128
|
+
writeText = defaultWriteText,
|
|
129
|
+
minBehind = UPDATE_NOTICE_MIN_BEHIND,
|
|
130
|
+
} = {}) => {
|
|
131
|
+
try {
|
|
132
|
+
if (env?.[UPDATE_CHECK_OPT_OUT_ENV] === '1') return null;
|
|
133
|
+
if (!parseVersion(installedVersion)) return null;
|
|
134
|
+
let latest = readCachedLatest({ cachePath, now, readText });
|
|
135
|
+
if (!latest) {
|
|
136
|
+
latest = await fetchLatestVersion({ fetchImpl, timeoutMs });
|
|
137
|
+
if (!latest) return null;
|
|
138
|
+
try {
|
|
139
|
+
writeText(cachePath, `${JSON.stringify({ checkedAt: new Date(now).toISOString(), latest })}\n`);
|
|
140
|
+
} catch {
|
|
141
|
+
// An unwritable cache costs one lookup per start, nothing more.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return updateNoticeLine({ installed: installedVersion, latest, minBehind });
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Fire the check and return at once. The returned promise (for tests) settles
|
|
152
|
+
* to the line that was logged, or null.
|
|
153
|
+
*/
|
|
154
|
+
export const startUpdateNotice = (params = {}) => Promise.resolve()
|
|
155
|
+
// Everything, the argument destructuring and checkForCliUpdate's default
|
|
156
|
+
// parameters (homedir()) included, runs inside the chain, so the trailing
|
|
157
|
+
// catch is the only exit a failure has. Start-up never sees a throw or an
|
|
158
|
+
// unhandled rejection from here.
|
|
159
|
+
.then(async () => {
|
|
160
|
+
const { log, ...options } = params;
|
|
161
|
+
const line = await checkForCliUpdate(options);
|
|
162
|
+
if (line) {
|
|
163
|
+
try {
|
|
164
|
+
log(line);
|
|
165
|
+
} catch {
|
|
166
|
+
// A closed log sink is not start-up's problem.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return line;
|
|
170
|
+
})
|
|
171
|
+
.catch(() => null);
|
|
172
|
+
|
|
173
|
+
let cachedCliVersion;
|
|
174
|
+
/** This CLI's own version, from the package.json it shipped with. */
|
|
175
|
+
export const installedCliVersion = () => {
|
|
176
|
+
if (cachedCliVersion === undefined) {
|
|
177
|
+
try {
|
|
178
|
+
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
179
|
+
cachedCliVersion = typeof pkg?.version === 'string' ? pkg.version : null;
|
|
180
|
+
} catch {
|
|
181
|
+
cachedCliVersion = null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return cachedCliVersion;
|
|
185
|
+
};
|