@cohortapp/agent-sdk 2.11.9 → 2.11.10

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.
@@ -195,28 +195,56 @@ const LIVE_CACHE_MS = 2_000;
195
195
  *
196
196
  * @returns {{ count:number, rssMB:number }}
197
197
  */
198
+ /** Resolve the agent root used to scope daemon-session detection. */
199
+ function reaperAgentRoot(explicit) {
200
+ return explicit || process.env.AGENT_ROOT || process.env.AGENT_DIR || "";
201
+ }
202
+
203
+ /**
204
+ * Is this `ps` line a DAEMON-SPAWNED claude session — and NEVER a human's
205
+ * interactive Claude Code session? Both the concurrency count and the reaper key
206
+ * on this so they can only ever touch sessions the agent-sdk daemon itself
207
+ * launched.
208
+ *
209
+ * The discriminator is `--print`: the daemon always spawns `claude --print …`
210
+ * (non-interactive batch mode via child_process), while interactive Claude Code
211
+ * (`claude`, `claude --resume`, `claude --continue`) is NEVER `--print`. So a
212
+ * human's session — even a long-running one — is invisible here. When the agent's
213
+ * own root is known we ALSO require the command to reference it (the daemon passes
214
+ * `--mcp-config <AGENT_ROOT>/.mcp.json`), so we never touch a DIFFERENT agent's
215
+ * session either. Requires a FULL command line (`ps -eo … command`), not `comm`.
216
+ */
217
+ export function isDaemonSpawnedClaude(line, agentRoot) {
218
+ if (!/claude/i.test(line)) return false;
219
+ if (/Claude\.app|Claude Helper|watchdog|grep|ps -eo/.test(line)) return false;
220
+ if (!/(^|\s)--print(\s|$)/.test(line)) return false; // interactive Claude Code is never --print
221
+ if (agentRoot && !line.includes(agentRoot)) return false; // scope to THIS agent's own sessions
222
+ return true;
223
+ }
224
+
198
225
  export function liveClaudeStats(opts = {}) {
199
226
  const now = opts.now || Date.now;
200
227
  const t = now();
201
228
  if (!opts.force && _liveCache.value && t - _liveCache.at < LIVE_CACHE_MS) {
202
229
  return _liveCache.value;
203
230
  }
231
+ const agentRoot = reaperAgentRoot(opts.agentRoot);
204
232
  let value = { count: 0, rssMB: 0 };
205
233
  try {
206
- // pid, rss(KB), comm — mirror the watchdog's filter set so the governor's
207
- // view of "live claude" matches what the watchdog would act on.
208
- const out = execFileSync("/bin/ps", ["-eo", "pid,etime,rss,comm"], { encoding: "utf-8", timeout: 4000 });
234
+ // FULL command (not comm) so we can require `--print` + the agent root — i.e.
235
+ // count ONLY this daemon's own sessions, never a human's interactive Claude
236
+ // Code session (which would otherwise inflate the count and get reaped).
237
+ const out = execFileSync("/bin/ps", ["-eo", "pid,etime,rss,command"], { encoding: "utf-8", timeout: 4000 });
209
238
  let count = 0;
210
239
  let rssKb = 0;
211
240
  let stale = 0;
212
241
  for (const line of out.split("\n")) {
213
242
  const l = line.trim();
214
243
  if (!l) continue;
215
- if (!/claude/i.test(l)) continue;
216
- if (/Claude\.app|Claude Helper|watchdog|grep|ps -eo/.test(l)) continue;
244
+ if (!isDaemonSpawnedClaude(l, agentRoot)) continue;
217
245
  const parts = l.split(/\s+/);
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
246
+ // Columns: pid etime rss command... A STALE session (idle/wedged, older
247
+ // than STALE_SESSION_MS) does NOT count toward the ceiling and its RSS is
220
248
  // excluded — an abandoned lane must not hold a slot or RAM headroom forever.
221
249
  const ageMs = etimeToMs(parts[1]);
222
250
  if (STALE_SESSION_MS > 0 && ageMs >= STALE_SESSION_MS) { stale += 1; continue; }
@@ -251,17 +279,20 @@ export function _resetLiveCache() { _liveCache = { at: 0, value: null }; }
251
279
  export function reapStaleClaude(deps = {}) {
252
280
  const reapMs = Number.isFinite(deps.reapMs) ? deps.reapMs : REAP_SESSION_MS;
253
281
  if (!(reapMs > 0)) return { reaped: [], errors: 0 };
282
+ const agentRoot = reaperAgentRoot(deps.agentRoot);
254
283
  const reaped = [];
255
284
  let errors = 0;
256
285
  try {
286
+ // FULL command (not comm) so isDaemonSpawnedClaude can require `--print` +
287
+ // this agent's root — we ONLY ever kill sessions the daemon itself launched,
288
+ // NEVER a human's interactive Claude Code session (which is never --print).
257
289
  const out = typeof deps.psImpl === "function"
258
290
  ? deps.psImpl()
259
- : execFileSync("/bin/ps", ["-eo", "pid,etime,comm"], { encoding: "utf-8", timeout: 4000 });
291
+ : execFileSync("/bin/ps", ["-eo", "pid,etime,command"], { encoding: "utf-8", timeout: 4000 });
260
292
  const kill = typeof deps.killImpl === "function" ? deps.killImpl : (pid) => process.kill(pid, "SIGKILL");
261
293
  for (const line of String(out).split("\n")) {
262
294
  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;
295
+ if (!l || !isDaemonSpawnedClaude(l, agentRoot)) continue;
265
296
  const parts = l.split(/\s+/);
266
297
  const pid = Number(parts[0]);
267
298
  if (!Number.isInteger(pid) || pid <= 1) continue; // never signal pid 0/1
@@ -361,33 +361,49 @@ test("etimeToMs parses ps elapsed-time in all three shapes", () => {
361
361
  assert.equal(etimeToMs(""), 0);
362
362
  });
363
363
 
364
- test("reapStaleClaude kills only claude sessions older than the reap threshold", () => {
364
+ test("reapStaleClaude kills ONLY daemon --print sessions for this agent — never a human's interactive session", () => {
365
+ const AR = "/Users/x/isla";
365
366
  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
367
+ ` 100 08-07:05:20 /usr/local/bin/claude --print --dangerously-skip-permissions --mcp-config ${AR}/.mcp.json old`, // daemon, 8d → reap
368
+ ` 200 00:30 claude --print --mcp-config ${AR}/.mcp.json young`, // daemon, 30s → keep (young)
369
+ ` 300 50:00 claude --print --mcp-config ${AR}/.mcp.json wedged`, // daemon, 50m → reap
370
+ " 310 90:00 claude --dangerously-skip-permissions --resume", // HUMAN interactive (no --print) → NEVER reap
371
+ " 315 99:00 claude", // HUMAN interactive → NEVER reap
372
+ " 320 99:00 claude --print --mcp-config /Users/x/OTHER/.mcp.json x", // a DIFFERENT agent → not this root → keep
373
+ " 400 90:00 node scripts/daemon/maestro-daemon.mjs", // not claude → skip
374
+ " 500 46:00 Claude Helper (Renderer)", // helper → skip
375
+ " 1 99-00:00:00 launchd", // pid 1 → skip
372
376
  ].join("\n");
373
377
  const killed = [];
374
- const r = reapStaleClaude({ psImpl: () => ps, killImpl: (p) => killed.push(p), reapMs: 45 * 60_000 });
378
+ const r = reapStaleClaude({ psImpl: () => ps, killImpl: (p) => killed.push(p), reapMs: 45 * 60_000, agentRoot: AR });
375
379
  assert.deepEqual(r.reaped.sort((a, b) => a - b), [100, 300]);
376
380
  assert.deepEqual(killed.sort((a, b) => a - b), [100, 300]);
377
381
  assert.equal(r.errors, 0);
378
382
  });
379
383
 
384
+ test("reapStaleClaude never touches an interactive Claude Code session even when very old", () => {
385
+ const AR = "/Users/x/isla";
386
+ // A human's `claude --resume` running for 8 DAYS — must survive (no --print).
387
+ const ps = ` 777 08-00:00:00 claude --dangerously-skip-permissions --resume\n`;
388
+ const killed = [];
389
+ const r = reapStaleClaude({ psImpl: () => ps, killImpl: (p) => killed.push(p), reapMs: 45 * 60_000, agentRoot: AR });
390
+ assert.deepEqual(r.reaped, []);
391
+ assert.deepEqual(killed, []);
392
+ });
393
+
380
394
  test("reapStaleClaude is a no-op when reaping is disabled (reapMs=0)", () => {
381
- const ps = " 100 08-07:05:20 claude --resume old\n";
395
+ const AR = "/Users/x/isla";
396
+ const ps = ` 100 08-07:05:20 claude --print --mcp-config ${AR}/.mcp.json old\n`;
382
397
  const killed = [];
383
- const r = reapStaleClaude({ psImpl: () => ps, killImpl: (p) => killed.push(p), reapMs: 0 });
398
+ const r = reapStaleClaude({ psImpl: () => ps, killImpl: (p) => killed.push(p), reapMs: 0, agentRoot: AR });
384
399
  assert.deepEqual(r.reaped, []);
385
400
  assert.deepEqual(killed, []);
386
401
  });
387
402
 
388
403
  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 });
404
+ const AR = "/Users/x/isla";
405
+ const ps = ` 100 08-07:05:20 claude --print --mcp-config ${AR}/.mcp.json old\n`;
406
+ const r = reapStaleClaude({ psImpl: () => ps, killImpl: () => { throw new Error("ESRCH"); }, reapMs: 60_000, agentRoot: AR });
391
407
  assert.deepEqual(r.reaped, []);
392
408
  assert.equal(r.errors, 1);
393
409
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.11.9",
3
+ "version": "2.11.10",
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": {
@@ -127,7 +127,7 @@ try {
127
127
  const { reapStaleClaude } = await import("../../lib/resource-governor.mjs");
128
128
  const sweep = () => {
129
129
  try {
130
- const r = reapStaleClaude();
130
+ const r = reapStaleClaude({ agentRoot: AGENT_DIR });
131
131
  if (r.reaped.length) console.log(`[DAEMON] reaped ${r.reaped.length} stale claude session(s): ${r.reaped.join(", ")}`);
132
132
  } catch { /* best-effort */ }
133
133
  };