@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.209",
3
+ "version": "0.1.211",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -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
- const runTurn = async ({
1856
- event, eventPodId, prompt, preSpawnIds, snapshotMessages, claimKeeper, trigger,
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
- result = await adapter.spawn(frameDecisionForkRule(promptForSpawn), {
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,
@@ -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:
@@ -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);
@@ -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
+ };