@commonlyai/cli 0.1.198 → 0.1.200

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.198",
3
+ "version": "0.1.200",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -1,6 +1,11 @@
1
1
  import { claudeRuntimeInventory } from '../native-runtime-inventory.js';
2
2
  import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
3
- import { spawnInOwnGroup, wantsOwnProcessGroup } from '@commonlyai/seats/process-group';
3
+ import {
4
+ firstOutputWindowMs, killStallSurvivors, physicalPath, spawnStalledError, STALL_KILL_GRACE_MS, watchFirstOutput,
5
+ } from '../first-output-watchdog.js';
6
+ import {
7
+ descendantsOf, killProcessTree, readProcessTable, spawnInOwnGroup, wantsOwnProcessGroup,
8
+ } from '@commonlyai/seats/process-group';
4
9
  /**
5
10
  * claude adapter — wraps the local `claude` CLI as a Commonly agent.
6
11
  *
@@ -88,7 +93,7 @@ import {
88
93
  prepareMcpSpawn, PUBLIC_MCP_UNAVAILABLE_CODE, publicMcpUnavailableError, selectedMcpVersionDir,
89
94
  } from '../mcp-home.js';
90
95
  import { buildMemoryPreamble, ROW_SLICING_CUE } from '../memory-bridge.js';
91
- import { claudeConfigDirFor, claudeContextTokens } from '../session-recycle.js';
96
+ import { claudeConfigDirFor, claudeContextTokens, claudeTranscriptPath } from '../session-recycle.js';
92
97
  import { skillsAsText } from '../skill-prompt.js';
93
98
  import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
94
99
 
@@ -484,7 +489,11 @@ const publicSkillPromptArgs = async (skillSources) => {
484
489
 
485
490
  // `ownGroup` is set only under the run supervisor, which kills the child's
486
491
  // whole group; unsupervised, the spawn options are exactly what they were.
487
- const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false }) => new Promise((resolve, reject) => {
492
+ const runClaude = ({
493
+ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false,
494
+ transcriptPath = null, firstOutputMs = firstOutputWindowMs(), lockProbe, stallKillGraceMs = STALL_KILL_GRACE_MS,
495
+ processTable = readProcessTable, survivorIo,
496
+ }) => new Promise((resolve, reject) => {
488
497
  const proc = spawnInOwnGroup(spawnImpl, cmd, args, {
489
498
  cwd,
490
499
  env,
@@ -499,15 +508,62 @@ const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl
499
508
  timedOut = true;
500
509
  proc.kill('SIGTERM');
501
510
  }, timeoutMs);
511
+ // A turn that never starts is killed long before the run timeout.
512
+ let stalled = null;
513
+ let stallKill = null;
514
+ let stallSnapshot = [];
515
+ // SIGKILL snapshotted descendants that are still alive. Safe to repeat. It
516
+ // runs when the spawn exits, at the grace deadline and on close: a survivor
517
+ // still holding the spawn's stdout or stderr keeps close from ever firing,
518
+ // and once the spawn is reaped killProcessTree can no longer reach it.
519
+ const sweepStallSurvivors = () => {
520
+ if (stalled) killStallSurvivors(stallSnapshot, survivorIo);
521
+ };
522
+ const treeIo = (table) => ({ table, ...(survivorIo?.read ? { read: survivorIo.read } : {}) });
523
+ const watchdog = watchFirstOutput({
524
+ proc,
525
+ windowMs: firstOutputMs,
526
+ transcriptPath,
527
+ lockProbe,
528
+ onStall: (reason) => {
529
+ stalled = spawnStalledError({ label: 'claude', windowMs: firstOutputMs, reason });
530
+ // Snapshot the descendants first: once the spawn exits they are
531
+ // reparented and can no longer be found from it.
532
+ const table = Number.isInteger(proc.pid) ? processTable() : null;
533
+ if (table) stallSnapshot = descendantsOf(table, proc.pid);
534
+ killProcessTree(proc, 'SIGTERM', treeIo(() => table));
535
+ // A process parked in a system call may not act on SIGTERM. SIGKILL the
536
+ // child, its group when it leads one, and its descendants.
537
+ stallKill = setTimeout(() => {
538
+ killProcessTree(proc, 'SIGKILL', treeIo(processTable));
539
+ sweepStallSurvivors();
540
+ // A pipe holder the snapshot missed (ps failed, or it was reparented
541
+ // before the stall) would still keep close from firing. Dropping our
542
+ // ends of the pipes bounds every stalled turn at window plus grace.
543
+ proc.stdout?.destroy?.();
544
+ proc.stderr?.destroy?.();
545
+ }, stallKillGraceMs);
546
+ stallKill.unref?.();
547
+ },
548
+ });
549
+ proc.on('exit', sweepStallSurvivors);
502
550
 
503
551
  proc.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
504
552
  proc.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
505
553
  proc.on('error', (err) => {
506
554
  clearTimeout(timer);
555
+ clearTimeout(stallKill);
556
+ watchdog.stop();
507
557
  reject(err);
508
558
  });
509
559
  proc.on('close', (code) => {
510
560
  clearTimeout(timer);
561
+ clearTimeout(stallKill);
562
+ watchdog.stop();
563
+ if (stalled) {
564
+ sweepStallSurvivors();
565
+ return reject(stalled);
566
+ }
511
567
  if (timedOut) return reject(new Error(`claude timed out after ${timeoutMs}ms`));
512
568
  if (code !== 0) {
513
569
  // Report stdout too, not just stderr. In `-p` mode claude writes terminal
@@ -1200,11 +1256,18 @@ export default {
1200
1256
  credentials: spawnCredentials(spawnCtx),
1201
1257
  spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }), // test seam only — do not use in production
1202
1258
  ownGroup: wantsOwnProcessGroup(ctx),
1259
+ transcriptPath: claudeTranscriptPath({
1260
+ cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
1261
+ }),
1262
+ lockProbe: ctx._lockProbe, // test seams only
1263
+ stallKillGraceMs: ctx._stallKillGraceMs,
1264
+ processTable: ctx._processTable,
1265
+ survivorIo: ctx._survivorIo,
1203
1266
  });
1204
1267
  // TASK-440: the context this turn left, read from the transcript the
1205
1268
  // child wrote under its own env (an isolated HOME for public seats).
1206
1269
  const contextTokens = claudeContextTokens({
1207
- cwd: ctx.cwd, sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
1270
+ cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
1208
1271
  });
1209
1272
  return { text: stdout.trim(), newSessionId: sessionId, contextTokens };
1210
1273
  } catch (err) {
@@ -1256,9 +1319,17 @@ export default {
1256
1319
  credentials: spawnCredentials(retryCtx),
1257
1320
  spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }),
1258
1321
  ownGroup: wantsOwnProcessGroup(ctx),
1322
+ transcriptPath: claudeTranscriptPath({
1323
+ cwd: physicalPath(ctx.cwd), sessionId: freshId,
1324
+ configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }),
1325
+ }),
1326
+ lockProbe: ctx._lockProbe,
1327
+ stallKillGraceMs: ctx._stallKillGraceMs,
1328
+ processTable: ctx._processTable,
1329
+ survivorIo: ctx._survivorIo,
1259
1330
  });
1260
1331
  const contextTokens = claudeContextTokens({
1261
- cwd: ctx.cwd, sessionId: freshId,
1332
+ cwd: physicalPath(ctx.cwd), sessionId: freshId,
1262
1333
  configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }),
1263
1334
  });
1264
1335
  return { text: stdout.trim(), newSessionId: freshId, contextTokens };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * First-output watchdog for a seat's model CLI spawn.
3
+ *
4
+ * On 2026-10-30 every launchd seat's `claude -p` turn parked while the Mac's
5
+ * console was locked: no transcript row, no stdout, no stderr, for the full
6
+ * 45-minute run timeout. Each one held its delivery the whole time, the retry
7
+ * respawned it into the same hang, and the log said only "claude timed out
8
+ * after 2700000ms", which names nothing.
9
+ *
10
+ * The signal is not stdout. A seat runs `claude -p --output-format text`,
11
+ * which writes stdout once, at the end, so a healthy 20-minute turn has zero
12
+ * stdout bytes for 20 minutes. What a healthy turn does write is its session
13
+ * transcript: measured on the dart seat, the first row landed 0.7 s after the
14
+ * spawn, and the hung spawns that night wrote no row at all. So a spawn counts
15
+ * as started when it writes any stdout or stderr byte OR its transcript grows.
16
+ * A spawn that does neither inside the window is killed with a reason. When
17
+ * the transcript is missing altogether, the path may simply be wrong, so the
18
+ * spawn is killed only if the console is also locked.
19
+ */
20
+ import { realpathSync, statSync } from 'fs';
21
+ import { readProcess } from '@commonlyai/seats/process-group';
22
+
23
+ export const DEFAULT_FIRST_OUTPUT_WINDOW_MS = 180_000;
24
+
25
+ // 0 turns the watchdog off; anything unparseable keeps the default.
26
+ export const firstOutputWindowMs = (env = process.env) => {
27
+ const raw = env?.COMMONLY_FIRST_OUTPUT_WINDOW_MS;
28
+ if (raw === undefined || raw === '') return DEFAULT_FIRST_OUTPUT_WINDOW_MS;
29
+ const parsed = Number(raw);
30
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_FIRST_OUTPUT_WINDOW_MS;
31
+ };
32
+
33
+ export const SPAWN_STALLED_CODE = 'spawn_stalled';
34
+
35
+ // The message carries the class word spawn-retry.js matches, so the stall gets
36
+ // its own backoff rather than RUNTIME's 5-second retry.
37
+ export const spawnStalledError = ({ label, windowMs, reason }) => {
38
+ const error = new Error(
39
+ `${label} produced no output in ${Math.round(windowMs / 1000)}s (${reason}); killed before the run timeout`,
40
+ );
41
+ error.code = SPAWN_STALLED_CODE;
42
+ error.stallReason = reason;
43
+ return error;
44
+ };
45
+
46
+ // `ioreg -n Root -d1` prints `"IOConsoleLocked" = Yes` while the screen is
47
+ // locked. True, false, or null when it cannot tell (not darwin, ioreg failed).
48
+ // child_process is loaded on demand: suites that mock it with only `spawn`
49
+ // would otherwise fail to import every adapter that uses this module.
50
+ export const consoleLocked = async ({ platform = process.platform, execFileImpl } = {}) => {
51
+ if (platform !== 'darwin') return null;
52
+ const run = execFileImpl || (await import('child_process')).execFile;
53
+ if (typeof run !== 'function') return null;
54
+ return new Promise((resolve) => {
55
+ run('/usr/sbin/ioreg', ['-n', 'Root', '-d1'], { timeout: 5000 }, (error, stdout) => {
56
+ if (error) return resolve(null);
57
+ const match = String(stdout).match(/"IOConsoleLocked"\s*=\s*(Yes|No)/);
58
+ return resolve(match ? match[1] === 'Yes' : null);
59
+ });
60
+ });
61
+ };
62
+
63
+ // Claude names its project folder after process.cwd(), which on macOS is the
64
+ // physical path (/tmp is /private/tmp), so the transcript is looked for there.
65
+ export const physicalPath = (dir) => {
66
+ if (!dir) return dir;
67
+ try {
68
+ return realpathSync(dir);
69
+ } catch {
70
+ return dir;
71
+ }
72
+ };
73
+
74
+ // How long a stalled spawn gets to exit after SIGTERM before SIGKILL.
75
+ export const STALL_KILL_GRACE_MS = 10_000;
76
+
77
+ /**
78
+ * SIGKILL each snapshotted descendant of a stalled spawn that is still alive
79
+ * once the spawn itself has exited. Its parent may have exited, so it may now
80
+ * belong to launchd: only the pid and start time are matched, which still
81
+ * rules out a reused pid. Returns the pids signalled.
82
+ */
83
+ export const killStallSurvivors = (snapshot, { read = readProcess, kill = process.kill.bind(process) } = {}) => {
84
+ const killed = [];
85
+ for (const row of snapshot || []) {
86
+ const now = read(row.pid);
87
+ if (!now || now.started !== row.started) continue;
88
+ try { kill(row.pid, 'SIGKILL'); killed.push(row.pid); } catch { /* gone */ }
89
+ }
90
+ return killed;
91
+ };
92
+
93
+ const sizeOf = (file) => {
94
+ if (!file) return -1;
95
+ try {
96
+ return statSync(file).size;
97
+ } catch {
98
+ return -1;
99
+ }
100
+ };
101
+
102
+ /**
103
+ * Watch `proc` for its first sign of life. Returns `{ stop }`; call it when the
104
+ * process closes. `onStall(reason)` fires at most once, after the caller should
105
+ * kill the process.
106
+ */
107
+ export const watchFirstOutput = ({
108
+ proc,
109
+ windowMs,
110
+ transcriptPath = null,
111
+ onStall,
112
+ lockProbe = consoleLocked,
113
+ setTimeoutImpl = setTimeout,
114
+ clearTimeoutImpl = clearTimeout,
115
+ }) => {
116
+ if (!windowMs) return { stop: () => {} };
117
+ let started = false;
118
+ let stopped = false;
119
+ const markStarted = () => { started = true; };
120
+ proc.stdout?.on('data', markStarted);
121
+ proc.stderr?.on('data', markStarted);
122
+ // A missing file counts as empty, so creating an empty one is not growth.
123
+ const baseline = Math.max(0, sizeOf(transcriptPath));
124
+ const timer = setTimeoutImpl(async () => {
125
+ const size = sizeOf(transcriptPath);
126
+ if (started || size > baseline) return;
127
+ const locked = await lockProbe().catch(() => null);
128
+ if (stopped) return;
129
+ // No transcript where one was expected may only mean the path is wrong, so
130
+ // that alone never kills a turn: it takes a locked console as well.
131
+ if (size < 0 && locked !== true) return;
132
+ onStall(locked ? 'console locked' : 'no transcript, stdout or stderr');
133
+ }, windowMs);
134
+ timer?.unref?.();
135
+ return {
136
+ stop: () => {
137
+ stopped = true;
138
+ clearTimeoutImpl(timer);
139
+ },
140
+ };
141
+ };
@@ -16,8 +16,16 @@ export const SPAWN_FAILURE_CLASS = Object.freeze({
16
16
  RATE_LIMIT: 'rate_limit',
17
17
  CONFIGURATION: 'configuration',
18
18
  RUNTIME: 'runtime',
19
+ // The spawn never started: no transcript row, stdout or stderr inside the
20
+ // first-output window (lib/first-output-watchdog.js). A host condition such as
21
+ // a locked console, so the circuit opens at once and the probe waits.
22
+ STALLED: 'stalled',
19
23
  });
20
24
 
25
+ // A stalled probe costs one first-output window, so the wait starts short and
26
+ // doubles to the usual ceiling: 2, 4, 8, then 15 minutes.
27
+ export const SPAWN_STALL_RETRY_BASE_MS = 2 * 60 * 1000;
28
+
21
29
  export const SPAWN_CIRCUIT_THRESHOLD = 3;
22
30
  // Base ceiling before the stable per-agent 0–20% anti-herd offset.
23
31
  export const SPAWN_RETRY_MAX_MS = 15 * 60 * 1000;
@@ -116,6 +124,7 @@ export const classifySpawnFailure = (error) => {
116
124
  // First: the refusal text carries none of the words below today, but a
117
125
  // refusal must never be read as a transient condition and retried.
118
126
  if (CONTENT_REFUSED_RE.test(text)) return SPAWN_FAILURE_CLASS.CONTENT_REFUSED;
127
+ if (error?.code === 'spawn_stalled') return SPAWN_FAILURE_CLASS.STALLED;
119
128
  // Provider APIs commonly report an exhausted account quota as HTTP 429.
120
129
  // Prefer the more specific body/message over the generic status code so a
121
130
  // hard quota failure gets the long cooldown rather than a one-minute probe.
@@ -194,6 +203,17 @@ export const spawnRetryPolicy = ({
194
203
  };
195
204
  }
196
205
 
206
+ if (failureClass === SPAWN_FAILURE_CLASS.STALLED) {
207
+ return {
208
+ failureClass,
209
+ circuitOpen: true,
210
+ delayMs: applyJitter(
211
+ Math.min(SPAWN_RETRY_MAX_MS, SPAWN_STALL_RETRY_BASE_MS * (2 ** (failureCount - 1))),
212
+ jitterRatio,
213
+ ),
214
+ };
215
+ }
216
+
197
217
  if (failureClass === SPAWN_FAILURE_CLASS.RATE_LIMIT) {
198
218
  return {
199
219
  failureClass,