@commonlyai/cli 0.1.210 → 0.1.212

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.210",
3
+ "version": "0.1.212",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -88,6 +88,7 @@ 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';
@@ -535,6 +536,29 @@ export const listLocalAgents = () => {
535
536
  .filter(Boolean);
536
537
  };
537
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
+
538
562
  // Chat event types whose payload already contains the prompt the wrapper
539
563
  // should forward verbatim. Heartbeat and consult events need event-specific
540
564
  // framing, so extractPrompt handles them separately below.
@@ -1362,6 +1386,15 @@ const extractPrompt = (event) => {
1362
1386
  // cancel grace, and the wall-clock backstop past the adapter's own timeout.
1363
1387
  export { supervisedRunPolicy };
1364
1388
 
1389
+ // Codex turns are supervised by default (D211): a turn that stops making
1390
+ // progress is named and killed instead of holding the seat. COMMONLY_SUPERVISE_RUNS=0
1391
+ // opts out. Every other adapter stays off unless COMMONLY_SUPERVISE_RUNS=1.
1392
+ export const superviseRunsByDefault = (adapterName, env = process.env) => {
1393
+ if (env.COMMONLY_SUPERVISE_RUNS === '0') return false;
1394
+ if (adapterName === 'codex') return true;
1395
+ return env.COMMONLY_SUPERVISE_RUNS === '1';
1396
+ };
1397
+
1365
1398
  /**
1366
1399
  * Start the run loop. Polls /events, spawns the adapter per event, posts the
1367
1400
  * result into the pod, acks.
@@ -1437,14 +1470,15 @@ export const performRun = ({
1437
1470
  resolveOwnProviderKey = null,
1438
1471
  runtimeReportClient = null,
1439
1472
  refusalHandoff = null,
1440
- // Off unless COMMONLY_SUPERVISE_RUNS=1: each spawn runs under the seats
1441
- // supervisor (stall window, wall-clock backstop, a named failure on every
1442
- // settle). The CLI still talks to its provider directly, and one metadata
1443
- // line per turn goes to ~/.commonly/state/turns/<agent>.jsonl once
1444
- // delivery settles. Off, the spawn path is the plain adapter call it has
1445
- // always been, and nothing is written.
1446
- superviseRuns = process.env.COMMONLY_SUPERVISE_RUNS === '1',
1473
+ // Each spawn runs under the seats supervisor (stall window, wall-clock
1474
+ // backstop, a named failure on every settle) when superviseRuns is true.
1475
+ // Unset, the default comes from superviseRunsByDefault. The CLI still talks
1476
+ // to its provider directly, and one metadata line per turn goes to
1477
+ // ~/.commonly/state/turns/<agent>.jsonl once delivery settles. Off, the spawn
1478
+ // path is the plain adapter call it has always been, and nothing is written.
1479
+ superviseRuns,
1447
1480
  }) => {
1481
+ const supervising = superviseRuns ?? superviseRunsByDefault(adapter?.name);
1448
1482
  reportRuntime = reportRuntime && WALKED_HARNESSES.includes(adapter?.name);
1449
1483
  if (environment?.provider !== undefined && !adapter?.supportsProvider) {
1450
1484
  throw new Error('environment.provider requires a provider-capable adapter');
@@ -1456,9 +1490,9 @@ export const performRun = ({
1456
1490
  // The supervised run keeps its trace in memory, metadata only; no record of
1457
1491
  // a run is written to disk (lib/supervised-turn.js).
1458
1492
  const turnRunner = createTurnRunner({
1459
- seat: agentName, turnTimeoutMs: effectiveTurnTimeoutMs, supervise: superviseRuns, log,
1493
+ seat: agentName, turnTimeoutMs: effectiveTurnTimeoutMs, supervise: supervising, log,
1460
1494
  });
1461
- if (superviseRuns) log(turnRunner.describe(adapter?.name));
1495
+ if (supervising) log(turnRunner.describe(adapter?.name));
1462
1496
  const runtimeReporter = createRuntimeReporter({
1463
1497
  client: runtimeReportClient || { post: async () => {} },
1464
1498
  harness: adapter.name, detect: () => adapter.detect(),
@@ -1871,7 +1905,7 @@ export const performRun = ({
1871
1905
 
1872
1906
  // The spawn → post-decision half of a turn, split out so the claim release
1873
1907
  // above is a plain finally instead of threading through every return path.
1874
- // With superviseRuns, the turn's log line is written once delivery has
1908
+ // With supervising, the turn's log line is written once delivery has
1875
1909
  // settled; anything thrown after a spawn that resolved means the reply never
1876
1910
  // reached the pod. Off, `turn` writes nothing.
1877
1911
  const runTurn = async (args) => {
@@ -4100,6 +4134,12 @@ Docs:
4100
4134
 
4101
4135
  console.log(`${stamp()} [${name}] polling ${record.instanceUrl} for events (ctrl+c to stop)`);
4102
4136
  console.log(`${stamp()} [${name}] ${foregroundDaemonHint({ daemonRegistered: hasDaemonRecord() })}`);
4137
+ // TASK-660: one line when this CLI is stale. Fired, not awaited, so a
4138
+ // slow registry never delays the first poll.
4139
+ startUpdateNotice({
4140
+ installedVersion: installedCliVersion(),
4141
+ log: (line) => console.log(`${stamp()} [${name}] ${line}`),
4142
+ });
4103
4143
 
4104
4144
  const { stop } = performRun({
4105
4145
  instanceUrl: record.instanceUrl,
@@ -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({ ...serviceDeps(), providerKeyEnvNames: providerKeyEnvNames() });
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:
@@ -897,6 +897,13 @@ export default {
897
897
  await writePublicCodexInstructions(childEnv.CODEX_HOME, ctx);
898
898
  }
899
899
 
900
+ // The awaits above can outlast a supervisor stall window. Once it has
901
+ // aborted, the attempt has already settled and a retry may be running,
902
+ // so no process is created for this one. Checked synchronously before
903
+ // runCodex, so the spawn itself cannot race the abort.
904
+ if (ctx.signal?.aborted) {
905
+ throw ctx.signal.reason || new Error('codex spawn cancelled before the process started');
906
+ }
900
907
  const { threadId } = await runCodex({
901
908
  args,
902
909
  cwd: ctx.cwd,
@@ -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
- await execCmd(['systemctl', '--user', 'enable', '--now', SYSTEMD_UNIT]);
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);
@@ -3,7 +3,8 @@
3
3
  * path also writes one metadata line per turn (the turn log), once the
4
4
  * turn's outcome has reached the pod or failed to.
5
5
  *
6
- * COMMONLY_SUPERVISE_RUNS=1 (read by performRun as `superviseRuns`)
6
+ * codex by default, or COMMONLY_SUPERVISE_RUNS=1 (superviseRunsByDefault in
7
+ * commands/agent.js; COMMONLY_SUPERVISE_RUNS=0 opts codex out)
7
8
  * the turn runs under `superviseRun` from @commonlyai/seats: a stall
8
9
  * window, a wall-clock backstop, and a guarantee the turn settles with a
9
10
  * named failure. No provider relay: the CLI talks to its provider directly,
@@ -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
+ };