@commonlyai/cli 0.1.198 → 0.1.199

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.199",
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,57 @@ 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
+ }, stallKillGraceMs);
541
+ stallKill.unref?.();
542
+ },
543
+ });
544
+ proc.on('exit', sweepStallSurvivors);
502
545
 
503
546
  proc.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
504
547
  proc.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
505
548
  proc.on('error', (err) => {
506
549
  clearTimeout(timer);
550
+ clearTimeout(stallKill);
551
+ watchdog.stop();
507
552
  reject(err);
508
553
  });
509
554
  proc.on('close', (code) => {
510
555
  clearTimeout(timer);
556
+ clearTimeout(stallKill);
557
+ watchdog.stop();
558
+ if (stalled) {
559
+ sweepStallSurvivors();
560
+ return reject(stalled);
561
+ }
511
562
  if (timedOut) return reject(new Error(`claude timed out after ${timeoutMs}ms`));
512
563
  if (code !== 0) {
513
564
  // Report stdout too, not just stderr. In `-p` mode claude writes terminal
@@ -1200,6 +1251,13 @@ export default {
1200
1251
  credentials: spawnCredentials(spawnCtx),
1201
1252
  spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }), // test seam only — do not use in production
1202
1253
  ownGroup: wantsOwnProcessGroup(ctx),
1254
+ transcriptPath: claudeTranscriptPath({
1255
+ cwd: physicalPath(ctx.cwd), sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }),
1256
+ }),
1257
+ lockProbe: ctx._lockProbe, // test seams only
1258
+ stallKillGraceMs: ctx._stallKillGraceMs,
1259
+ processTable: ctx._processTable,
1260
+ survivorIo: ctx._survivorIo,
1203
1261
  });
1204
1262
  // TASK-440: the context this turn left, read from the transcript the
1205
1263
  // child wrote under its own env (an isolated HOME for public seats).
@@ -1256,6 +1314,14 @@ export default {
1256
1314
  credentials: spawnCredentials(retryCtx),
1257
1315
  spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(modelArgs, { modelFlag: '--model', effortFlag: '--effort' }),
1258
1316
  ownGroup: wantsOwnProcessGroup(ctx),
1317
+ transcriptPath: claudeTranscriptPath({
1318
+ cwd: physicalPath(ctx.cwd), sessionId: freshId,
1319
+ configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }),
1320
+ }),
1321
+ lockProbe: ctx._lockProbe,
1322
+ stallKillGraceMs: ctx._stallKillGraceMs,
1323
+ processTable: ctx._processTable,
1324
+ survivorIo: ctx._survivorIo,
1259
1325
  });
1260
1326
  const contextTokens = claudeContextTokens({
1261
1327
  cwd: ctx.cwd, sessionId: freshId,
@@ -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,