@commonlyai/cli 0.1.197 → 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.
@@ -20,6 +20,6 @@ It is private and is not published on its own. `@commonlyai/cli` depends on it a
20
20
 
21
21
  **Rule:** nothing here imports Commonly code. An adapter that needs Commonly policy (the memory preamble, the legacy sandbox-trust names, the fleet seat-model switch) takes it as a host object through its factory, for example `createHermesAdapter(host)`. The CLI binds that host in `cli/src/lib/adapters/<name>.js`. `cli/__tests__/seats-package.test.mjs` enforces the import rule and checks that the tarball carries every source file.
22
22
 
23
- The extraction plan is D62, extract-in-place.
23
+ The extraction plan is extract-in-place.
24
24
 
25
25
  **Run record.** The trace store writes each run as a versioned, hash-chained JSONL record. It covers who granted which agent what access for which task, who approved each call, what was attempted, and the result. A tool outside this package can read, verify and export it to OTLP. The format is specified in [`docs/record-v1.md`](docs/record-v1.md), and a generated sample is in [`docs/examples/`](docs/examples/).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.197",
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",
@@ -178,8 +178,7 @@ export const NO_AGENT_CLI_CODE = 'no_agent_cli';
178
178
  * workspace, and an environment that is absent or enforces nothing (a record
179
179
  * from before bootstrap applied the default, or one attached without an
180
180
  * environment file). Such a record is told how to pick the default up; it is
181
- * not rewritten here (Cofounder, 2026-10-16: none were found outside our own
182
- * tests).
181
+ * not rewritten here.
183
182
  */
184
183
  export const savedSeatLacksDefaultSandbox = (record) => Boolean(record)
185
184
  && ADAPTERS_WITH_DEFAULT_SANDBOX.has(record.adapter)
@@ -717,7 +716,7 @@ export const assertSandboxDeclaredForPublicPod = async ({
717
716
  );
718
717
  }
719
718
 
720
- // Normalized FIRST (Vera 71715, Wren 71718): the predicate below reads a
719
+ // Normalized FIRST: the predicate below reads a
721
720
  // legacy `internal` record as the `public` it means, the same mapping the
722
721
  // attach gate and both adapters use. Reading it raw refused a record that
723
722
  // means public one call before the gate #1840 fixes — TASK-113 claims
@@ -904,7 +903,7 @@ export const updateAgentConfiguration = async ({
904
903
  * The sandbox an attach will actually run under — derived, then gated.
905
904
  *
906
905
  * The trust is normalized ONCE, before it is used for anything. A stored
907
- * `internal` means `public` (environment.js, Wren 69585), and resolving the mode
906
+ * `internal` means `public` (environment.js), and resolving the mode
908
907
  * off the raw value made this gate disagree with the adapters in BOTH
909
908
  * directions: `{ trust: 'internal' }` with no declared mode left the attach-time
910
909
  * mode at `'none'` and refused nothing here, and `internal` beside
@@ -917,8 +916,7 @@ export const updateAgentConfiguration = async ({
917
916
  * either way; what was wrong is that this gate's verdict contradicted theirs, and
918
917
  * its refusal for the `mode: 'none'` shape landed later, at adapter spawn, after
919
918
  * attach had already published and minted. Normalizing here makes the two agree
920
- * and puts the refusal where the user can see it (Kai, TASK-113; re-measured at
921
- * source after Vera 71664).
919
+ * and puts the refusal where the user can see it (TASK-113).
922
920
  *
923
921
  * Whether the resolved mode is AVAILABLE here — bwrap installed, macOS for
924
922
  * Seatbelt, the codex permission-profile version — is deliberately not this
@@ -932,7 +930,7 @@ export const updateAgentConfiguration = async ({
932
930
  * codex.js:546) and passed the normalized object on, which is why their suites
933
931
  * confined a legacy record all along; the grant-broker guard normalized for its
934
932
  * own trust check and then handed the resolver the raw object, so its symptom was
935
- * a false `sandbox_mode_unenforceable` refusal (Vera 71676). Both are settled on
933
+ * a false `sandbox_mode_unenforceable` refusal. Both are settled on
936
934
  * main now — the resolver reads the effective trust (mode.js:38) and the guard
937
935
  * imports the same reader from environment.js (grant-broker-guard.js:186) — so
938
936
  * this helper needs no coupling to them: it normalizes once, here, before its own
@@ -944,7 +942,7 @@ export const resolveAttachSandbox = ({
944
942
  // An adapter that enforces one confined shape of its own (dsh: Seatbelt with a
945
943
  // restricted network and NO trust) says so here, and its check is the one that
946
944
  // decides: the trust-keyed gates below cannot express that shape, so before
947
- // this they refused the only environment dsh accepts (D106). The adapter's
945
+ // this they refused the only environment dsh accepts. The adapter's
948
946
  // check throws on every other shape, so this admits nothing it would refuse.
949
947
  if (typeof adapter?.confinesOwnSandbox === 'function' && adapter.confinesOwnSandbox(environment)) {
950
948
  return { mode: environment.sandbox.mode, trust: environment.sandbox.trust };
@@ -967,7 +965,7 @@ export const resolveAttachSandbox = ({
967
965
  `sandbox.trust=${trust} requires an enforced sandbox mode; refusing to attach unsandboxed`,
968
966
  );
969
967
  }
970
- // Moved in from the call site (Vera 71675): this is the check the raw compare
968
+ // Moved in from the call site: this is the check the raw compare
971
969
  // used to fail closed on, refusing `internal` beside `mode: 'workspace'`. It is
972
970
  // pure — a mode and an adapter name — so it belongs with the derivation, and
973
971
  // its witnesses live beside it.
@@ -1490,7 +1488,7 @@ export const performRun = ({
1490
1488
  // (`daemon-supervisor.js`); this is the same predicate at the only other site
1491
1489
  // that can see the record — same code, so one refusal with two emitters.
1492
1490
  //
1493
- // IN MEMORY, RECORD UNTOUCHED (wren 71641): the withheld environment is what
1491
+ // IN MEMORY, RECORD UNTOUCHED: the withheld environment is what
1494
1492
  // the adapter spawns with, and the stored record keeps its broker, so the
1495
1493
  // daemon stays the layer that judges a host it knows. Applied once for the
1496
1494
  // run rather than per turn: `agent run` reads its record once at boot.
@@ -1903,7 +1901,7 @@ export const performRun = ({
1903
1901
  const memoryWithheld = Boolean(placement.reason);
1904
1902
  const memoryLongTerm = memoryWithheld ? undefined : await readLongTerm(client, { onError });
1905
1903
 
1906
- // Sharpen TASK-129: focus is a turn-start read, not an enqueue-time
1904
+ // TASK-129: focus is a turn-start read, not an enqueue-time
1907
1905
  // snapshot. Read through the authorized runtime context route immediately
1908
1906
  // before spawn so queued events observe the current revision. The helper
1909
1907
  // disables pod-skill synthesis and throws on failure; the surrounding
@@ -1997,8 +1995,8 @@ export const performRun = ({
1997
1995
  try {
1998
1996
  opened = await lease.open();
1999
1997
  } catch (err) {
2000
- // A VERDICT IS NOT A CRASH, AND IT IS NOT A NO-OP (wren 72153, vera
2001
- // 72157). Letting it escape as a bare throw left `turnResult` undefined,
1998
+ // A VERDICT IS NOT A CRASH, AND IT IS NOT A NO-OP.
1999
+ // Letting it escape as a bare throw left `turnResult` undefined,
2002
2000
  // so `claimReleaseFor` returned the legacy holder-only DELETE — right for
2003
2001
  // a transient failure, wrong for a refusal that recurs on every
2004
2002
  // redelivery: the wake loops forever with one local log line as its only
@@ -2409,7 +2407,7 @@ export const performRun = ({
2409
2407
  // its batch and chose silence; one that printed nothing may have crashed,
2410
2408
  // timed out or lost its route without saying so. The bare `no_action`
2411
2409
  // stays the sentinel's alone, because a digest counts as read only on
2412
- // that (sable, #2753). Release, hand-off and spawn counting treat
2410
+ // that (#2753). Release, hand-off and spawn counting treat
2413
2411
  // `empty-output` exactly as they treat a bare decline.
2414
2412
  if (!delivered && !replyText) return { outcome: 'no_action', reason: EMPTY_OUTPUT };
2415
2413
  return { outcome: delivered ? 'posted' : 'no_action' };
@@ -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
 
@@ -105,8 +110,8 @@ const DEFAULT_TIMEOUT_MS = (() => {
105
110
 
106
111
  const buildPrompt = buildMemoryPreamble;
107
112
 
108
- // The working habits a private claude seat is taught on a fresh session (D48,
109
- // TASK-545). Parallel reads live here because only this CLI has a subagent
113
+ // The working habits a private claude seat is taught on a fresh session
114
+ // (TASK-545). Parallel reads live here because only this CLI has a subagent
110
115
  // tool; codex is passed row slicing alone. A public seat is passed neither: its
111
116
  // argv allows Read and the MCP under dontAsk, so it has no subagent to spawn,
112
117
  // and it answers strangers rather than working board rows.
@@ -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
@@ -841,7 +892,7 @@ const prepareArgv = async (innerArgv, ctx) => {
841
892
  // A public trust is ONE contract on both platforms: the jail bounds the
842
893
  // filesystem and the policy bounds the tools. The bwrap branch below used to
843
894
  // get the jail only — a derived Linux seat kept Bash/Write/WebFetch and ran on
844
- // the operator's own Claude settings inside the namespace (Vera 69578).
895
+ // the operator's own Claude settings inside the namespace.
845
896
  const publicBwrapSandbox = sandboxTrust === 'public' && sandboxMode === 'bwrap';
846
897
  // A workspace seat on Linux: the same jail, with the workspace tier's policy
847
898
  // and a HOME of its own outside the project.
@@ -867,7 +918,7 @@ const prepareArgv = async (innerArgv, ctx) => {
867
918
  // above; anything else that lands here (the literal 'none', a typo, a
868
919
  // non-string) used to fall through to the bare `claude` spawn below — an
869
920
  // unconfined seat with a public record, which is how a record can claim
870
- // confinement it never applies (Vera 69548).
921
+ // confinement it never applies.
871
922
  if (isConfinedTrust(sandboxTrust) && !publicNativeSandbox && sandboxMode !== 'bwrap') {
872
923
  throw new Error(
873
924
  `${sandboxTrust} Claude agents require an enforced sandbox mode `
@@ -929,7 +980,7 @@ const prepareArgv = async (innerArgv, ctx) => {
929
980
  if (publicBwrapSandbox || workspaceBwrapSandbox) {
930
981
  // The mode is chosen here, so the mechanism is verified here: a missing
931
982
  // bwrap must fail the seat's derivation with an actionable message, not
932
- // every one of its spawns with a bare ENOENT (Wren 69586). On a non-Linux
983
+ // every one of its spawns with a bare ENOENT. On a non-Linux
933
984
  // host this reports the macOS-only message, same as the wrapper would.
934
985
  const bwrap = (ctx._detectBwrap || detectBwrap)();
935
986
  if (!bwrap.available) {
@@ -1043,7 +1094,7 @@ export default {
1043
1094
  const seatModel = requireSeatModel(rawCtx.environment, 'claude');
1044
1095
  // `sandbox.trust: 'internal'` is refused for new declarations and read as
1045
1096
  // `public` for a record that already carries it; such a seat is confined
1046
- // where it can be and refuses to derive where it cannot (Wren 69585).
1097
+ // where it can be and refuses to derive where it cannot.
1047
1098
  // Resolved ONCE here and threaded to BOTH consumers — the public-state
1048
1099
  // preparation below and the argv builder — because two independent reads of
1049
1100
  // one declaration is exactly how this seat crashed with "public Claude
@@ -1091,9 +1142,9 @@ export default {
1091
1142
  // present in one but not the other means a retry silently runs a different
1092
1143
  // model than the turn it is replacing — the same drifting-copy shape that
1093
1144
  // has bitten this codebase repeatedly.
1094
- // `effort` rides in the same array for the same reason: Sam's 2026-09-01
1095
- // order ("flip all fable agents into fable 5.1 with high or above
1096
- // effort") needs both facts to reach every spawn, including the
1145
+ // `effort` rides in the same array for the same reason: an order
1146
+ // like "flip all fable agents into fable 5.1 with high or above
1147
+ // effort" needs both facts to reach every spawn, including the
1097
1148
  // session-recovery retry, or a retry runs at a different effort than the
1098
1149
  // turn it replaces.
1099
1150
  const modelArgs = [
@@ -1133,7 +1184,7 @@ export default {
1133
1184
  // Trust is normalized at entry (legacy `internal` reads as public). The
1134
1185
  // MCP policy follows trust alone, whatever confines the seat: a public
1135
1186
  // bwrap seat needs a host-prepared build exactly as a Seatbelt one does
1136
- // (Rhea on #2247).
1187
+ // (#2247).
1137
1188
  if (publicNativeSandbox) {
1138
1189
  publicClaudeState = await preparePublicClaudeState(ctx);
1139
1190
  } else if (workspaceBwrapSandbox) {
@@ -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).
@@ -1210,7 +1268,7 @@ export default {
1210
1268
  } catch (err) {
1211
1269
  // A public seat's refusal is not a session-store failure, whatever its
1212
1270
  // message happens to contain: it must reach the run loop, never the
1213
- // fresh-session retry below (Rhea on #2247).
1271
+ // fresh-session retry below (#2247).
1214
1272
  if (err?.code === PUBLIC_MCP_UNAVAILABLE_CODE) throw err;
1215
1273
  // Self-heal against a corrupted session store. If the persisted id is
1216
1274
  // already in use (or claude has forgotten about it), discard it and
@@ -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,
@@ -115,7 +115,7 @@ const buildPrompt = buildMemoryPreamble;
115
115
  // when they also carry a `command`. `auditDeclaredMcp` classifies by
116
116
  // `transport` and never judges the command of an entry that declared an
117
117
  // http one, so emitting it here executes a command the guard did not
118
- // approve — the case Vera measured on 2026-09-18 (Connectors 69774).
118
+ // approve — a case measured on 2026-09-18.
119
119
  // - Token-bearing values ride through `env_vars`, never a `-c ...env=...`
120
120
  // argv override. Command lines are visible to other same-user processes
121
121
  // unless the OS sandbox blocks process inspection; keeping bearer tokens
@@ -661,7 +661,7 @@ export default {
661
661
  // undefined and '' as absence itself; it does not need a guard, it needs
662
662
  // the value.
663
663
  //
664
- // Codex has no subagent tool, so of D48's two habits (TASK-545) it is
664
+ // Codex has no subagent tool, so of the two habits (TASK-545) it is
665
665
  // taught row slicing alone; claude.js adds parallel reads. A public seat
666
666
  // answers strangers rather than working board rows, so it is taught neither.
667
667
  const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, {
@@ -59,7 +59,7 @@ export const assertPrivateDsh = (environment = {}) => { isConfinedDsh(environmen
59
59
  * The macOS developer tools a confined writer needs for git, granted read-only.
60
60
  * /usr/bin/git is an xcrun shim: inside the sandbox it cannot read the tools it
61
61
  * forwards to, so `git` fails with "No developer tools were found" and a writer
62
- * cannot commit (D106). Returns null when no tools are installed; the seat then
62
+ * cannot commit. Returns null when no tools are installed; the seat then
63
63
  * runs without git rather than failing.
64
64
  *
65
65
  * DEVELOPER_DIR and xcode-select are both settable by whoever starts the seat,
@@ -333,7 +333,7 @@ export default {
333
333
  requiresModel: true,
334
334
  name: 'dsh', runtimeType: 'dsh', supportsProvider: true,
335
335
 
336
- // The attach gate defers to this for dsh's one confined shape (D106).
336
+ // The attach gate defers to this for dsh's one confined shape.
337
337
  confinesOwnSandbox: (environment) => isConfinedDsh(environment),
338
338
 
339
339
  async validateEnvironment(environment, workspacePath) {
@@ -100,7 +100,7 @@ const substitutePlaceholders = (value, ctx) => {
100
100
  * deliberately not friendlier than the guard's — a normalized `'HTTP'` or a
101
101
  * padded `' http '` is a shape the guard refuses as an unknown transport, and an
102
102
  * adapter that accepted one would be running something the guard never judged
103
- * (Vera, Connectors 69776). The schema admits `http`/`stdio`/`sse`
103
+ * The schema admits `http`/`stdio`/`sse`
104
104
  * (environment.js:236) and admits an ABSENT transport, which the guard reads as
105
105
  * stdio; that is what this reads too.
106
106
  */
@@ -129,8 +129,7 @@ const originOf = (value) => {
129
129
  * environment is not usable for this: the kernel keeps the copy the process was
130
130
  * started with, so a same-user child could read the token back with
131
131
  * `ps eww $PPID` / `/proc/$PPID/environ` even after the bridge deleted its own
132
- * copy — which is what the previous channel did, and the row this closes
133
- * (Vera, Connectors).
132
+ * copy — which is what the previous channel did, and the row this closes.
134
133
  *
135
134
  * WHICH shape an entry becomes is decided by `transport` — the same field, read
136
135
  * with the same default and the same exact comparison the daemon's
@@ -141,7 +140,7 @@ const originOf = (value) => {
141
140
  * `${COMMONLY_AGENT_TOKEN}` in the command is one of the known placeholders), and
142
141
  * a presence-classifier then ran that command as stdio with the real token
143
142
  * substituted. The guard's judgement and the adapter's disagreed, and the adapter
144
- * is what executes (Vera, Connectors 69774).
143
+ * is what executes.
145
144
  *
146
145
  * The http half also enforces the guard's own origin rule, so the HTTP half does
147
146
  * not depend on a guard that may not be on the machine: `auditDeclaredMcp`
@@ -153,7 +152,7 @@ const originOf = (value) => {
153
152
  *
154
153
  * The grant broker is refused outright, by PATH rather than by entry name: it is
155
154
  * the one http entry that carries authority rather than data, and pi confines on
156
- * no host. wren's ruling for TASK-063 (`isGrantBrokerUrl` in pi-mcp-client.mjs),
155
+ * no host. The ruling for TASK-063 (`isGrantBrokerUrl` in pi-mcp-client.mjs),
157
156
  * the daemon-side half of the same refusal the server makes at the projection —
158
157
  * this is the half that holds on deploy skew, when the row names no adapter for
159
158
  * the server to key on, and in any backend older than the refusal.
@@ -162,7 +161,7 @@ const originOf = (value) => {
162
161
  * a declared stdio command is executed with no allowlist check, here and in both
163
162
  * sibling adapters, so it depends entirely on the guard — `auditDeclaredMcp`
164
163
  * admits only the shipped commonly MCP server or a command already present in the
165
- * local record, and that rule exists nowhere else (Vera, 69778; TASK-069).
164
+ * local record, and that rule exists nowhere else (TASK-069).
166
165
  */
167
166
  export const resolveMcpServers = (mcpServers, ctx = {}) => {
168
167
  const carried = [];
@@ -295,7 +294,7 @@ export const sessionExists = async (sessionDir, sessionId) => {
295
294
  * one does not start.
296
295
  *
297
296
  * The trust is read THROUGH the legacy table (`effectiveSandboxTrust`), not
298
- * raw. A stored `internal` means `public` (Vera 69592, Wren 69585) and every
297
+ * raw. A stored `internal` means `public` and every
299
298
  * other gate in this package resolves it that way: the attach path
300
299
  * (`agent.js`), the mode resolver (`sandbox/mode.js`) and the grant-broker
301
300
  * guard. Reading it raw here made this gate the one site that failed OPEN — a
@@ -238,7 +238,7 @@ export const createDaemonSupervisor = ({
238
238
  // seat RUNS under, and `withholdGrantBroker` asks the same question the
239
239
  // server asks (`can this seat confine a granter's authority?`) for the cases
240
240
  // the server cannot see — a row naming no adapter, a backend older than the
241
- // refusal, a record written by hand. Entry-level per wren 69829: the broker
241
+ // refusal, a record written by hand. Entry-level: the broker
242
242
  // entry is withheld and the seat still starts, because it was never promised
243
243
  // confinement. `record.instanceUrl` is what makes the injected url
244
244
  // identifiable at all — the record holds the UNRESOLVED
@@ -635,7 +635,7 @@ export const splitForChat = (text, { limit = 400 } = {}) => {
635
635
  * text and post one message — the reply's
636
636
  * own opening plus the file card.
637
637
  *
638
- * The thread rung is new (Sam 57691) and it replaces attachment as the answer
638
+ * The thread rung is new and it replaces attachment as the answer
639
639
  * for PROSE overflow. Before threads existed, a long analysis had nowhere to
640
640
  * go but a file, and that was the right workaround. It is now the wrong one:
641
641
  * an attachment is un-quotable, un-followable, and an all-or-nothing read,
@@ -678,7 +678,7 @@ export const deliverChatReply = async ({
678
678
  //
679
679
  // TASK-535 — the send-time freshness hold. The backend answers 409
680
680
  // `unseen_messages` when other members posted after this seat's last
681
- // recorded tail read. A turn that ran ~43 minutes (rhea, 2026-10-25 10:01Z)
681
+ // recorded tail read. A turn that ran ~43 minutes
682
682
  // hit it on the thread headline, the attach card and the split fallback in
683
683
  // turn, so the reply was lost and its 10 events re-ran. A retry without an
684
684
  // override cannot succeed — every rung posts to the same guarded route.
@@ -771,7 +771,7 @@ export const deliverChatReply = async ({
771
771
  // its rendering. The tone contract's own rule covers it: over ~800 chars of
772
772
  // ONE indivisible thing is a document, not a message — attach it. Without
773
773
  // this check a 659-char fence rode the single/split branches straight past
774
- // the gate (found by the fleet's implementation audit, Sharpen msg 53018).
774
+ // the gate (found by the fleet's implementation audit).
775
775
  const hasIndivisibleOversize = chunks.some((c) => c.length > attachThreshold);
776
776
  if (chunks.length <= 1 && !hasIndivisibleOversize) {
777
777
  const response = await postMessage({ content: chunks[0] ?? text });
@@ -75,9 +75,9 @@ const findInlineProviderCredentialField = (value, path) => {
75
75
  // daemon path it was silently inert — claude fell through to a bare unconfined
76
76
  // spawn and codex took `--dangerously-bypass-approvals-and-sandbox` — so a
77
77
  // declaration that reads as "confine me, not as a public agent" meant the
78
- // opposite of what it said (Vera 69592). It is refused for new declarations
78
+ // opposite of what it said. It is refused for new declarations
79
79
  // above, and a record that already carries it is resolved TOWARD confinement,
80
- // never toward the bare spawn (Wren 69585): `internal` is read as `public`, so
80
+ // never toward the bare spawn: `internal` is read as `public`, so
81
81
  // such a seat is confined where it can be and refuses to derive where it
82
82
  // cannot. A real middle trust arrives as its own adapter that reads it — and
83
83
  // `workspace` (above) is that trust, which is why it is NOT a legacy alias.
@@ -100,7 +100,7 @@ export const normalizeSandboxTrust = (sandbox) => (
100
100
  * compares the trust and must not write anything back. The server spells the
101
101
  * same predicate `effectiveSandboxTrust` (`grantBrokerConfinement.ts`) — the two
102
102
  * sides have to read `internal` the same way or one of them confines a seat the
103
- * other withholds from (Wren 71662).
103
+ * other withholds from.
104
104
  */
105
105
  export const effectiveSandboxTrust = (trust) => (
106
106
  typeof trust === 'string' && Object.prototype.hasOwnProperty.call(LEGACY_SANDBOX_TRUST, trust)
@@ -112,7 +112,7 @@ export const effectiveSandboxTrust = (trust) => (
112
112
  * Throw when an adapter that does not implement the `workspace` tier is handed
113
113
  * one (TASK-294). Only claude reads it. Every other adapter branches on
114
114
  * `trust === 'public'`, so an unread `workspace` would fall into its
115
- * non-public branch — the same silent widening `internal` was (Vera 69592).
115
+ * non-public branch — the same silent widening `internal` was.
116
116
  * Called at each adapter's spawn entry, before any other sandbox read.
117
117
  */
118
118
  export const refuseWorkspaceTrust = (sandbox, adapterLabel) => {
@@ -391,8 +391,8 @@ export const validateEnvironmentSpec = (spec) => {
391
391
  }
392
392
  }
393
393
 
394
- // A TRANSPORT DECIDES THE ENTRY, so the fields must agree with it (TASK-071,
395
- // Vera's ruling 2026-09-19). This block is MIRRORED, not shared: the other
394
+ // A TRANSPORT DECIDES THE ENTRY, so the fields must agree with it (TASK-071).
395
+ // This block is MIRRORED, not shared: the other
396
396
  // writer-side check is the backend's, in
397
397
  // backend/utils/environmentSpecValidation.ts, called from the
398
398
  // `PATCH /api/registry/pods/:podId/agents/:name` handler that stores
@@ -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
+ };
@@ -23,7 +23,7 @@
23
23
  export const SOURCE_RUNTIME = 'local-cli';
24
24
 
25
25
  /**
26
- * Row slicing, the runtime-agnostic half of D48 (TASK-545). A row too large
26
+ * Row slicing, the runtime-agnostic half of TASK-545. A row too large
27
27
  * for one turn drifts when one seat carries it whole, so the seat splits it on
28
28
  * the board before any code. It names the exact call and field for the reason
29
29
  * the memory cue below names `long_term`: a seat adopts a habit through a
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Which failed Commonly tool calls a public seat's model may answer in plain
3
- * words. Everything else withholds the turn (#2374, Vera 79595, Wren 79596).
3
+ * words. Everything else withholds the turn (#2374).
4
4
  *
5
5
  * The rule is inverted on purpose. Matching known connection failures let an
6
6
  * API 5xx through: the shipped MCP turns a non-2xx into a tool error carrying
@@ -5,15 +5,14 @@
5
5
  * derives `sandbox: { trust: 'public' }` with NO mode (lib/default-environment.js)
6
6
  * because `mode: 'workspace'` is Seatbelt on macOS and is refused on Linux,
7
7
  * while `mode: 'bwrap'` is meaningless on macOS — writing either into the row
8
- * moves a host fact into the database and breaks the day the seat is re-homed
9
- * (Wren 69545).
8
+ * moves a host fact into the database and breaks the day the seat is re-homed.
10
9
  *
11
10
  * So the mode is resolved here, at the point that knows the host:
12
11
  * darwin → 'workspace' (the claude adapter maps it to Seatbelt)
13
12
  * else → 'bwrap' (bubblewrap; attach checks it is installed)
14
13
  *
15
14
  * Resolution is PUBLIC-only on purpose, and PUBLIC IS THE EFFECTIVE TRUST: a
16
- * legacy `internal` declaration reads as `public` (environment.js, Wren 69585),
15
+ * legacy `internal` declaration reads as `public` (environment.js),
17
16
  * so the mapping is applied before this comparison. Comparing the raw value
18
17
  * made `{ trust: 'internal' }` resolve to no mode at all — the mapping was
19
18
  * applied in `confinementReason` and not here, which is half a rule, and the
@@ -22,7 +21,7 @@
22
21
  * whatever mode it wrote, and an adapter that cannot enforce it is expected to
23
22
  * refuse rather than quietly spawn unconfined. An explicit mode on a public
24
23
  * declaration still wins — including a wrong one, which the caller then refuses
25
- * (Vera 69548: an unresolvable public mode must not fall through to a bare,
24
+ * (an unresolvable public mode must not fall through to a bare,
26
25
  * unconfined spawn).
27
26
  *
28
27
  * NOT used by the codex adapter. Codex's `mode` is read-vs-write access, not a
@@ -301,7 +301,7 @@ const OPEN_NETWORK = String.raw`
301
301
  // Resolver is constructed. Denied, that lookup does not always fail: at
302
302
  // times it blocks the main thread forever. dsh builds a Resolver as it loads,
303
303
  // so confined turns ran to their 900 s timeout without sending a request
304
- // (D106). Admitted, resolution fails fast, because mDNSResponder and port 53
304
+ // Admitted, resolution fails fast, because mDNSResponder and port 53
305
305
  // stay unreachable in a loopback profile. The service returns configuration
306
306
  // and opens no channel out.
307
307
  const RESOLVER_CONFIG = String.raw`
@@ -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.
@@ -124,7 +133,7 @@ export const classifySpawnFailure = (error) => {
124
133
  // and "limit", and its `(?!rate[ -]?limit)` lookahead only guards the word
125
134
  // IMMEDIATELY after "your" — so "you've hit your API rate limit" walked past
126
135
  // it into the 15-minute circuit, where main correctly gives the 60-second
127
- // rate-limit probe (otto, gate on #2491; it was a regression, not a gap).
136
+ // rate-limit probe (gate on #2491; it was a regression, not a gap).
128
137
  // A throttle wording anywhere in the text therefore wins over the shape.
129
138
  // QUOTA_RE is still tested first, so an explicit quota word keeps QUOTA even
130
139
  // when the same message also mentions rate limits.
@@ -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,
@@ -73,7 +73,7 @@ export const startWebhookServer = ({
73
73
  * The handler `agent connect` serves on its own port. It only logs what
74
74
  * arrives, and no agent has read the event, so it states no outcome a read
75
75
  * would count as: the wake still settles, and the pod digest stays unread
76
- * (Sable, #2753).
76
+ * (#2753).
77
77
  */
78
78
  export const logOnlyHandler = async (event) => {
79
79
  console.log(`[local] ${event.type} from pod ${event.podId}`);
@@ -99,7 +99,7 @@ export const forwardToLocalWebhook = async (event, webhookUrl, secret = null) =>
99
99
  if (!res.ok) throw new Error(`Local webhook returned HTTP ${res.status}`);
100
100
  // Only a stated outcome counts. An empty or non-JSON body, or one without
101
101
  // an outcome, is a handler that said nothing, the same as a CLI turn that
102
- // printed nothing, so it must not read the pod digest (Rhea, #2753).
102
+ // printed nothing, so it must not read the pod digest (#2753).
103
103
  let body = null;
104
104
  try { body = await res.json(); } catch { body = null; }
105
105
  if (!body || typeof body !== 'object' || typeof body.outcome !== 'string') {