@cohortapp/agent-sdk 2.11.5 → 2.11.7

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.
@@ -79,19 +79,21 @@ export const FREEMEM_FLOOR_FRACTION = frac(process.env.GOV_FREEMEM_FLOOR, 0.15);
79
79
  /** Load-average multiplier over cpu count that triggers a DEFER. */
80
80
  export const LOAD_FACTOR = num(process.env.GOV_LOAD_FACTOR, 1.5);
81
81
  /**
82
- * Hard ceiling on dynamicMax regardless of RAM. The binding constraint on a
83
- * subscription-auth seat is NOT host RAM — it is the ONE Max subscription, which
84
- * cannot serve many parallel `claude --print` calls without throttling. Ten
85
- * concurrent backlog sessions starved the reactive quick-reply's claude call
86
- * into a 60s timeout (observed on A016/isla). Default is subscription-safe;
87
- * API/multi-key deployments raise it with GOV_HARD_MAX.
82
+ * Hard ceiling on dynamicMax regardless of RAM. Generous by design: the runtime
83
+ * guards below (free-memory floor, load, projected RSS) throttle DOWN under real
84
+ * pressure, and stale sessions no longer count (see liveClaudeStats + STALE_*),
85
+ * so the ceiling can use available capacity and back off when it isn't there.
86
+ * The earlier 60s quick-reply timeout "at 10 sessions" was really TWO permanent
87
+ * zombie `claude --resume` procs (counted forever) + a log flood, not 8 healthy
88
+ * sessions. With those fixed, more real lanes are safe. GOV_HARD_MAX overrides.
88
89
  */
89
- export const HARD_MAX = num(process.env.GOV_HARD_MAX, 4);
90
+ export const HARD_MAX = num(process.env.GOV_HARD_MAX, 7);
90
91
  /**
91
92
  * Slots the governor holds back from self-directed (backlog/cadence) work so a
92
93
  * human/agent reply always has subscription + host headroom to GENERATE, not
93
94
  * merely a dispatch slot. The dispatcher's RESERVED_INBOX_SLOTS reserves a
94
95
  * dispatch slot; this reserves the scarce resource that actually timed out.
96
+ * With HARD_MAX 7 this leaves ~5 backlog lanes + 2 reactive.
95
97
  */
96
98
  export const REACTIVE_RESERVE = num(process.env.GOV_REACTIVE_RESERVE, 2);
97
99
  /**
@@ -101,9 +103,36 @@ export const REACTIVE_RESERVE = num(process.env.GOV_REACTIVE_RESERVE, 2);
101
103
  * gets near-all capacity.
102
104
  */
103
105
  export const DEGRADED_BACKLOG_MAX = num(process.env.GOV_DEGRADED_BACKLOG_MAX, 1);
106
+ /**
107
+ * A `claude` session older than this is treated as STALE and does NOT count
108
+ * toward the concurrency ceiling — an idle/wedged lane must not hold a slot
109
+ * forever. No healthy cadence/reply session runs anywhere near this long. Two
110
+ * 8-day-old `--resume` zombies once pinned the whole box (owner: "idle lanes
111
+ * shouldn't count"). GOV_STALE_SESSION_MS overrides.
112
+ */
113
+ export const STALE_SESSION_MS = num(process.env.GOV_STALE_SESSION_MS, 30 * 60_000);
114
+ /**
115
+ * A `claude` session older than this is REAPED (killed) by reapStaleClaude —
116
+ * the self-cleaning half. Higher than STALE so a merely-slow session is only
117
+ * discounted, not killed, until it is unambiguously wedged. GOV_REAP_SESSION_MS
118
+ * overrides; set to 0 to disable reaping (discount-only).
119
+ */
120
+ export const REAP_SESSION_MS = num(process.env.GOV_REAP_SESSION_MS, 45 * 60_000);
104
121
  /** Minimum dynamicMax (always allow at least one session). */
105
122
  const MIN_MAX = 1;
106
123
 
124
+ /**
125
+ * Parse a `ps` etime string ("[[DD-]HH:]MM:SS") to milliseconds. Returns 0 on
126
+ * anything unparseable (fail-safe: an unparseable age is treated as young, so we
127
+ * never discount/kill a session we cannot age).
128
+ */
129
+ export function etimeToMs(etime) {
130
+ const m = String(etime || "").trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/);
131
+ if (!m) return 0;
132
+ const [, d, h, mm, ss] = m;
133
+ return ((((Number(d || 0) * 24) + Number(h || 0)) * 60 + Number(mm || 0)) * 60 + Number(ss || 0)) * 1000;
134
+ }
135
+
107
136
  function num(v, dflt) {
108
137
  const n = Number(v);
109
138
  return Number.isFinite(n) && n > 0 ? n : dflt;
@@ -176,20 +205,26 @@ export function liveClaudeStats(opts = {}) {
176
205
  try {
177
206
  // pid, rss(KB), comm — mirror the watchdog's filter set so the governor's
178
207
  // view of "live claude" matches what the watchdog would act on.
179
- const out = execFileSync("/bin/ps", ["-eo", "pid,rss,comm"], { encoding: "utf-8", timeout: 4000 });
208
+ const out = execFileSync("/bin/ps", ["-eo", "pid,etime,rss,comm"], { encoding: "utf-8", timeout: 4000 });
180
209
  let count = 0;
181
210
  let rssKb = 0;
211
+ let stale = 0;
182
212
  for (const line of out.split("\n")) {
183
213
  const l = line.trim();
184
214
  if (!l) continue;
185
215
  if (!/claude/i.test(l)) continue;
186
216
  if (/Claude\.app|Claude Helper|watchdog|grep|ps -eo/.test(l)) continue;
187
217
  const parts = l.split(/\s+/);
188
- const rss = Number(parts[1]);
218
+ // Columns: pid etime rss comm... A STALE session (idle/wedged, older than
219
+ // STALE_SESSION_MS) does NOT count toward the ceiling and its RSS is
220
+ // excluded — an abandoned lane must not hold a slot or RAM headroom forever.
221
+ const ageMs = etimeToMs(parts[1]);
222
+ if (STALE_SESSION_MS > 0 && ageMs >= STALE_SESSION_MS) { stale += 1; continue; }
223
+ const rss = Number(parts[2]);
189
224
  if (Number.isFinite(rss)) rssKb += rss;
190
225
  count += 1;
191
226
  }
192
- value = { count, rssMB: Math.round(rssKb / 1024) };
227
+ value = { count, rssMB: Math.round(rssKb / 1024), stale };
193
228
  } catch {
194
229
  value = { count: 0, rssMB: 0 };
195
230
  }
@@ -200,6 +235,43 @@ export function liveClaudeStats(opts = {}) {
200
235
  /** Reset the live-stats cache (tests). */
201
236
  export function _resetLiveCache() { _liveCache = { at: 0, value: null }; }
202
237
 
238
+ /**
239
+ * Self-cleaning: KILL `claude` sessions older than REAP_SESSION_MS. A session
240
+ * that has run this long is unambiguously wedged — no healthy cadence or reply
241
+ * session runs anywhere near this long — and a wedged session holds a dispatch
242
+ * slot, RAM, and (worst) a share of the single Max subscription. Two 8-day-old
243
+ * `--resume` zombies once deferred every cadence on a seat, silencing its inbox.
244
+ *
245
+ * Side-effecting BY DESIGN, so it is NOT called from the pure admit() path — the
246
+ * daemon calls it on a timer. Best-effort: a ps/kill failure never throws.
247
+ * `deps` ({ psImpl, killImpl, reapMs }) is injected by tests.
248
+ *
249
+ * @returns {{ reaped:number[], errors:number }}
250
+ */
251
+ export function reapStaleClaude(deps = {}) {
252
+ const reapMs = Number.isFinite(deps.reapMs) ? deps.reapMs : REAP_SESSION_MS;
253
+ if (!(reapMs > 0)) return { reaped: [], errors: 0 };
254
+ const reaped = [];
255
+ let errors = 0;
256
+ try {
257
+ const out = typeof deps.psImpl === "function"
258
+ ? deps.psImpl()
259
+ : execFileSync("/bin/ps", ["-eo", "pid,etime,comm"], { encoding: "utf-8", timeout: 4000 });
260
+ const kill = typeof deps.killImpl === "function" ? deps.killImpl : (pid) => process.kill(pid, "SIGKILL");
261
+ for (const line of String(out).split("\n")) {
262
+ const l = line.trim();
263
+ if (!l || !/claude/i.test(l)) continue;
264
+ if (/Claude\.app|Claude Helper|watchdog|grep|ps -eo/.test(l)) continue;
265
+ const parts = l.split(/\s+/);
266
+ const pid = Number(parts[0]);
267
+ if (!Number.isInteger(pid) || pid <= 1) continue; // never signal pid 0/1
268
+ if (etimeToMs(parts[1]) < reapMs) continue;
269
+ try { kill(pid); reaped.push(pid); } catch { errors += 1; }
270
+ }
271
+ } catch { /* best-effort — a ps failure must never crash the daemon */ }
272
+ return { reaped, errors };
273
+ }
274
+
203
275
  // ---------------------------------------------------------------------------
204
276
  // Default deps snapshot
205
277
  // ---------------------------------------------------------------------------
@@ -23,6 +23,8 @@ import {
23
23
  DECISIONS,
24
24
  availableMemoryBytes,
25
25
  FREEMEM_FLOOR_FRACTION,
26
+ etimeToMs,
27
+ reapStaleClaude,
26
28
  } from "./resource-governor.mjs";
27
29
 
28
30
  test("availableMemoryBytes counts reclaimable pages, not just free ones", () => {
@@ -68,15 +70,14 @@ function deps(over = {}) {
68
70
  // ---------------------------------------------------------------------------
69
71
 
70
72
  test("dynamicMax sizes by RAM, clamped to min(HARD_MAX, cpus)", () => {
71
- // HARD_MAX is 4 (subscription-safe): the single Max subscription, not host RAM,
72
- // is the binding constraint, so anything with a few GB clamps to 4.
73
+ // HARD_MAX is 7 (generous; runtime guards + stale-reaping keep it safe).
73
74
  // 4GB: byRam=floor(4*0.6/0.75)=3 → RAM-bound, below HARD_MAX → 3.
74
75
  assert.equal(dynamicMax({ totalmem: 4 * GB, cpus: 8 }), 3);
75
- // 8GB: byRam=floor(8*0.6/0.75)=6 → clamp to min(4,8)=4 → HARD_MAX-bound.
76
- assert.equal(dynamicMax({ totalmem: 8 * GB, cpus: 8 }), 4);
77
- // 16GB with 16 cpus: byRam=12 → clamp to min(4,16)=4.
78
- assert.equal(dynamicMax({ totalmem: 16 * GB, cpus: 16 }), 4);
79
- // cpu-bound below HARD_MAX: 16GB with 2 cpus → min(4,2)=2.
76
+ // 8GB: byRam=floor(8*0.6/0.75)=6 → RAM-bound, below HARD_MAX → 6.
77
+ assert.equal(dynamicMax({ totalmem: 8 * GB, cpus: 8 }), 6);
78
+ // 16GB with 16 cpus: byRam=12 → clamp to min(7,16)=7 (HARD_MAX-bound).
79
+ assert.equal(dynamicMax({ totalmem: 16 * GB, cpus: 16 }), 7);
80
+ // cpu-bound below HARD_MAX: 16GB with 2 cpus → min(7,2)=2.
80
81
  assert.equal(dynamicMax({ totalmem: 16 * GB, cpus: 2 }), 2);
81
82
  // Tiny box always yields at least 1.
82
83
  assert.equal(dynamicMax({ totalmem: 1 * GB, cpus: 1 }), 1);
@@ -93,8 +94,8 @@ test("dynamicMax never returns 0 even on a starved box", () => {
93
94
  test("admit: healthy box with spare capacity → ADMIT", () => {
94
95
  const r = admit({ source: "backlog" }, deps());
95
96
  assert.equal(r.decision, DECISIONS.ADMIT);
96
- assert.equal(r.snapshot.dynamicMax, 4);
97
- assert.equal(r.snapshot.effectiveMax, 4);
97
+ assert.equal(r.snapshot.dynamicMax, 7);
98
+ assert.equal(r.snapshot.effectiveMax, 7);
98
99
  });
99
100
 
100
101
  test("admit: inbox on a healthy box → ADMIT", () => {
@@ -107,22 +108,22 @@ test("admit: inbox on a healthy box → ADMIT", () => {
107
108
  // ---------------------------------------------------------------------------
108
109
 
109
110
  test("admit: backlog stops REACTIVE_RESERVE slots short of the ceiling → QUEUE", () => {
110
- // effectiveMax = 4; self-directed backlog may use effectiveMax - REACTIVE_RESERVE(2)
111
- // = 2. With 2 already live, backlog QUEUEs — holding 2 slots for the inbox so a
111
+ // effectiveMax = 7; self-directed backlog may use effectiveMax - REACTIVE_RESERVE(2)
112
+ // = 5. With 5 already live, backlog QUEUEs — holding 2 slots for the inbox so a
112
113
  // reply can always be GENERATED (subscription + host headroom, not just a slot).
113
- const r = admit({ source: "backlog" }, deps({ liveClaude: { count: 2, rssMB: 500 } }));
114
+ const r = admit({ source: "backlog" }, deps({ liveClaude: { count: 5, rssMB: 1200 } }));
114
115
  assert.equal(r.decision, DECISIONS.QUEUE);
115
116
  assert.match(r.reason, /yields 2 slots to the inbox/);
116
117
  });
117
118
 
118
119
  test("admit: inbox may use the whole envelope; the reactive reserve is for it", () => {
119
- // 2 live (backlog's reserved cap) but INBOX still ADMITs — the reserve is its.
120
+ // 5 live (backlog's reserved cap) but INBOX still ADMITs — the reserve is its.
120
121
  assert.equal(
121
- admit({ source: "inbox" }, deps({ liveClaude: { count: 2, rssMB: 500 } })).decision,
122
+ admit({ source: "inbox" }, deps({ liveClaude: { count: 5, rssMB: 1200 } })).decision,
122
123
  DECISIONS.ADMIT,
123
124
  );
124
- // Only at the full envelope (effectiveMax=4 live) does even the inbox QUEUE.
125
- const r = admit({ source: "inbox" }, deps({ liveClaude: { count: 4, rssMB: 1000 } }));
125
+ // Only at the full envelope (effectiveMax=7 live) does even the inbox QUEUE.
126
+ const r = admit({ source: "inbox" }, deps({ liveClaude: { count: 7, rssMB: 1800 } }));
126
127
  assert.equal(r.decision, DECISIONS.QUEUE);
127
128
  assert.match(r.reason, /concurrency ceiling/);
128
129
  });
@@ -347,3 +348,46 @@ test("an unrecognised budget mode is treated as normal, never as something stric
347
348
  assert.equal(admit({ source: "backlog", mode: "catastrophic" }, deps()).decision, "ADMIT");
348
349
  assert.equal(admit({ source: "backlog", mode: "normal" }, deps()).decision, "ADMIT");
349
350
  });
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // Stale-session accounting + reaping (idle lanes must not count / self-clean)
354
+ // ---------------------------------------------------------------------------
355
+
356
+ test("etimeToMs parses ps elapsed-time in all three shapes", () => {
357
+ assert.equal(etimeToMs("05:20"), (5 * 60 + 20) * 1000); // MM:SS
358
+ assert.equal(etimeToMs("07:05:20"), (7 * 3600 + 5 * 60 + 20) * 1000); // HH:MM:SS
359
+ assert.equal(etimeToMs("08-07:05:20"), (8 * 86400 + 7 * 3600 + 5 * 60 + 20) * 1000); // DD-HH:MM:SS
360
+ assert.equal(etimeToMs("garbage"), 0); // fail-safe: unparseable → young
361
+ assert.equal(etimeToMs(""), 0);
362
+ });
363
+
364
+ test("reapStaleClaude kills only claude sessions older than the reap threshold", () => {
365
+ const ps = [
366
+ " 100 08-07:05:20 claude --dangerously-skip-permissions --resume old", // 8 days → reap
367
+ " 200 00:30 claude --print young", // 30s → keep
368
+ " 300 50:00 claude --resume wedged", // 50 min → reap
369
+ " 400 90:00 node scripts/daemon/maestro-daemon.mjs", // not claude → skip
370
+ " 500 46:00 Claude Helper (Renderer)", // helper → skip
371
+ " 1 99-00:00:00 launchd", // pid 1 / not claude → skip
372
+ ].join("\n");
373
+ const killed = [];
374
+ const r = reapStaleClaude({ psImpl: () => ps, killImpl: (p) => killed.push(p), reapMs: 45 * 60_000 });
375
+ assert.deepEqual(r.reaped.sort((a, b) => a - b), [100, 300]);
376
+ assert.deepEqual(killed.sort((a, b) => a - b), [100, 300]);
377
+ assert.equal(r.errors, 0);
378
+ });
379
+
380
+ test("reapStaleClaude is a no-op when reaping is disabled (reapMs=0)", () => {
381
+ const ps = " 100 08-07:05:20 claude --resume old\n";
382
+ const killed = [];
383
+ const r = reapStaleClaude({ psImpl: () => ps, killImpl: (p) => killed.push(p), reapMs: 0 });
384
+ assert.deepEqual(r.reaped, []);
385
+ assert.deepEqual(killed, []);
386
+ });
387
+
388
+ test("reapStaleClaude counts kill failures without throwing", () => {
389
+ const ps = " 100 08-07:05:20 claude --resume old\n";
390
+ const r = reapStaleClaude({ psImpl: () => ps, killImpl: () => { throw new Error("ESRCH"); }, reapMs: 60_000 });
391
+ assert.deepEqual(r.reaped, []);
392
+ assert.equal(r.errors, 1);
393
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.11.5",
3
+ "version": "2.11.7",
4
4
  "description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -84,7 +84,7 @@ const RUNG_MECHANISM = Object.fromEntries(RUNGS.map((r) => [r.id, r.mechanism]))
84
84
  // work and land it in the org's shared, ACL'd store so the fleet's memory
85
85
  // compounds. Fail-open + best-effort — never blocks or throws into the hot path;
86
86
  // local SQLite/interaction logs remain the fail-open cache.
87
- import { isEnabled as orgEnabled, loadOrgConfig, configFromAgent, electResponder } from "../../lib/org/client.mjs";
87
+ import { isEnabled as orgEnabled, loadOrgConfig, configFromAgent, electResponder, fetchSelfProfile } from "../../lib/org/client.mjs";
88
88
  import { remember as orgRemember } from "../../lib/org/knowledge.mjs";
89
89
  import { sweepSessionOutcomes, resultTextFromStdout } from "./session-outcomes.mjs";
90
90
  import { recordWorkStep as orgRecordWorkStep, cohortChannelId, cohortMessageId } from "../../lib/org/work-ledger.mjs";
@@ -1085,8 +1085,70 @@ export function _resetLadderCaches() {
1085
1085
  _ladderDisabledWarned = false;
1086
1086
  }
1087
1087
 
1088
+ // The seat's own member id (cuid), resolved once. This is the `actor` on every
1089
+ // message/event the seat sends, so the own-echo guard needs it — NOT the slug
1090
+ // (e.g. "A016"), which never matches a cuid actor. COHORT_AGENT_ID is a slug, so
1091
+ // using it as `me` (the old behaviour) left own-echo permanently broken: the seat
1092
+ // re-ingested its own replies and held a conversation with itself.
1093
+ let _selfMemberId = null;
1094
+
1095
+ /** Read a previously-persisted memberId (cuid) from config/agent.json. */
1096
+ function persistedSelfMemberId() {
1097
+ try {
1098
+ const c = JSON.parse(readFileSync(join(AGENT_REPO_DIR, "config", "agent.json"), "utf-8"));
1099
+ const mid = c && typeof c.memberId === "string" ? c.memberId : "";
1100
+ // Guard against a slug ever being persisted here — the field MUST be a cuid.
1101
+ return mid && mid !== process.env.COHORT_AGENT_ID ? mid : "";
1102
+ } catch {
1103
+ return "";
1104
+ }
1105
+ }
1106
+
1107
+ /**
1108
+ * Resolve + cache the seat's own member cuid via /whoami (org/members/<slug>),
1109
+ * persisting it to config/agent.json so subsequent boots — and every own-echo
1110
+ * check — have it instantly. Best-effort and idempotent; safe to await on the
1111
+ * hot path (cached after the first success). Requires hq to expose `memberId`
1112
+ * on the profile DTO (org-api whoami/members).
1113
+ */
1114
+ export async function resolveSelfMemberId(deps = {}) {
1115
+ if (_selfMemberId) return _selfMemberId;
1116
+ const fromDisk = persistedSelfMemberId();
1117
+ if (fromDisk) { _selfMemberId = fromDisk; return _selfMemberId; }
1118
+ try {
1119
+ const cfg = daemonOrgCfg();
1120
+ const conn = configFromAgent(cfg) || {};
1121
+ const apiKey = conn.apiKey || process.env.COHORT_API_KEY || "";
1122
+ const orgId = conn.orgId || process.env.COHORT_ORG_ID || "";
1123
+ const agentId = conn.agentId || process.env.COHORT_AGENT_ID || "";
1124
+ if (!apiKey || !orgId) return _selfMemberId;
1125
+ const fetchSelf = deps.fetchSelfProfileImpl || fetchSelfProfile;
1126
+ const res = await fetchSelf({ base: conn.base, apiKey, orgId, agentId });
1127
+ const mid = res && res.ok && res.member && res.member.memberId ? String(res.member.memberId) : "";
1128
+ if (mid) {
1129
+ _selfMemberId = mid;
1130
+ try {
1131
+ const cfgPath = join(AGENT_REPO_DIR, "config", "agent.json");
1132
+ const c = JSON.parse(readFileSync(cfgPath, "utf-8"));
1133
+ c.memberId = mid;
1134
+ writeFileSync(cfgPath, JSON.stringify(c, null, 2) + "\n");
1135
+ } catch { /* best-effort persist — resolution still cached in-memory */ }
1136
+ }
1137
+ } catch { /* best-effort — falls back to slug until hq exposes memberId */ }
1138
+ return _selfMemberId;
1139
+ }
1140
+
1141
+ /** For tests: reset the cached self member id. */
1142
+ export function _resetSelfMemberId() { _selfMemberId = null; }
1143
+
1088
1144
  /** The seat's org member id — the `me` every directedness join is relative to. */
1089
1145
  function ladderMemberId() {
1146
+ // Prefer the resolved member cuid (the actor on events). Fall back to the slug
1147
+ // only until resolution completes — the slug can't power own-echo, but it is no
1148
+ // worse than the historical behaviour.
1149
+ if (_selfMemberId) return _selfMemberId;
1150
+ const disk = persistedSelfMemberId();
1151
+ if (disk) { _selfMemberId = disk; return disk; }
1090
1152
  const cfg = daemonOrgCfg();
1091
1153
  return (
1092
1154
  process.env.COHORT_AGENT_ID ||
@@ -1315,6 +1377,10 @@ export async function runExecutionLadder(item, service, itemId, trace_id, deps =
1315
1377
 
1316
1378
  let answered = null;
1317
1379
  try {
1380
+ // Ensure the seat's own member cuid is resolved BEFORE directedness runs, so
1381
+ // the own-echo guard can recognize this seat's own messages (the actor on an
1382
+ // event is a cuid, not the slug). Cached after the first resolve.
1383
+ await resolveSelfMemberId(deps);
1318
1384
  const me = ladderMemberId();
1319
1385
  const obligations = await ladderObligations(log);
1320
1386
 
@@ -113,6 +113,31 @@ try {
113
113
  // Reactive daemon continues. Doctor / healthcheck will surface this.
114
114
  }
115
115
 
116
+ // ---------------------------------------------------------------------------
117
+ // 3a. Stale-session reaper (self-cleaning)
118
+ // ---------------------------------------------------------------------------
119
+ // A wedged `claude` session (crash mid-stream, an orphaned `--resume`) otherwise
120
+ // lingers for DAYS. The governor counts it as "live" via ps, so it holds a
121
+ // dispatch slot, RAM, and — worst — a share of the single Max subscription, until
122
+ // the seat defers every cadence and its inbox goes silent (observed on A016: two
123
+ // 8-day-old zombies deferred 6704 ticks). Reap anything older than
124
+ // REAP_SESSION_MS: once at boot (any old session is a leftover) and on a timer so
125
+ // idle lanes self-clean. Best-effort — never takes the daemon down.
126
+ try {
127
+ const { reapStaleClaude } = await import("../../lib/resource-governor.mjs");
128
+ const sweep = () => {
129
+ try {
130
+ const r = reapStaleClaude();
131
+ if (r.reaped.length) console.log(`[DAEMON] reaped ${r.reaped.length} stale claude session(s): ${r.reaped.join(", ")}`);
132
+ } catch { /* best-effort */ }
133
+ };
134
+ sweep();
135
+ const reapTimer = setInterval(sweep, 5 * 60_000);
136
+ if (reapTimer && typeof reapTimer.unref === "function") reapTimer.unref();
137
+ } catch (err) {
138
+ console.error(`[DAEMON] stale-session reaper failed to start: ${err && err.message}`);
139
+ }
140
+
116
141
  // ---------------------------------------------------------------------------
117
142
  // 3b. Channel bus (1.12.0+; generic platform loop in 1.15.0/WS2)
118
143
  // ---------------------------------------------------------------------------