@cohortapp/agent-sdk 2.11.8 → 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.8",
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": {
@@ -125,6 +125,9 @@ import { policyFor } from "../../lib/execution/surface-policy.mjs";
125
125
  // ---------------------------------------------------------------------------
126
126
 
127
127
  const POLL_INTERVAL = parseInt(process.env.DAEMON_POLL_INTERVAL || "60000", 10); // 60s (up from 30s to avoid Slack rate limits)
128
+ // Fast reactive scan of the LOCAL cohort inbox only (no API, no rate limit) so an
129
+ // org-push-fetched DM is answered in seconds, not on the 60s poll. See pollCohortFast.
130
+ const COHORT_FAST_INTERVAL = parseInt(process.env.DAEMON_COHORT_POLL_INTERVAL || "2000", 10); // 2s
128
131
  // Backlog sweep cadence — bumped 2min → 10min in 1.10. The dispatcher
129
132
  // applies a per-item post-completion cooldown (4h success, 30min failure)
130
133
  // so we no longer need a tight sweep loop. Operators with high-throughput
@@ -207,44 +210,8 @@ async function poll() {
207
210
  }
208
211
 
209
212
  let totalItems = 0;
210
-
211
213
  for (const svc of services) {
212
- try {
213
- const result = await svc.fn();
214
- // Dedup by raw_ref first: the same Slack message arrives via
215
- // multiple paths (channel scan, thread scan, events-server JSON)
216
- // with different item IDs. Using raw_ref as the lock key ensures
217
- // a single message is only processed once regardless of path.
218
- const seenRefs = new Set();
219
- const newItems = result.items.filter((item) => {
220
- // Skip the agent's own messages — defensive check in case intake filters miss them
221
- const sender = (item.sender || "").toLowerCase();
222
- const me = loadAgent();
223
- const myFirst = (me.firstName || "").toLowerCase();
224
- const myFull = (me.fullName || "").toLowerCase().replace(/\s+/g, "-");
225
- if (myFirst && (sender === myFirst || sender === myFull)) return false;
226
-
227
- const lockKey = item.raw_ref || item.id || `${svc.name}-${Date.now()}`;
228
- if (seenRefs.has(lockKey)) return false;
229
- seenRefs.add(lockKey);
230
- const lock = acquireLock(lockKey, {
231
- sender: item.sender || "unknown",
232
- channel: item.channel || item.channel_id || "unknown",
233
- });
234
- return lock.acquired;
235
- });
236
- totalItems += newItems.length;
237
-
238
- for (const item of newItems) {
239
- await processItem(item, svc.name);
240
- }
241
-
242
- if (result.errors.length > 0) {
243
- console.warn(`[daemon] ${svc.name} errors:`, result.errors.slice(0, 3));
244
- }
245
- } catch (err) {
246
- console.error(`[daemon] ${svc.name} poll failed:`, err.message);
247
- }
214
+ totalItems += await pollService(svc);
248
215
  }
249
216
 
250
217
  recordPoll(totalItems);
@@ -254,6 +221,74 @@ async function poll() {
254
221
 
255
222
  }
256
223
 
224
+ /**
225
+ * Scan + process ONE service's inbox. Factored out of poll() so the reactive
226
+ * cohort path can run on its own fast interval (pollCohortFast) without waiting
227
+ * on the 60s full poll. Returns the count of new items processed. Never throws.
228
+ */
229
+ async function pollService(svc) {
230
+ try {
231
+ const result = await svc.fn();
232
+ // Dedup by raw_ref first: the same Slack message arrives via multiple paths
233
+ // (channel scan, thread scan, events-server JSON) with different item IDs.
234
+ // Using raw_ref as the lock key ensures a single message is only processed
235
+ // once regardless of path.
236
+ const seenRefs = new Set();
237
+ const newItems = result.items.filter((item) => {
238
+ // Skip the agent's own messages — defensive check in case intake filters miss them
239
+ const sender = (item.sender || "").toLowerCase();
240
+ const me = loadAgent();
241
+ const myFirst = (me.firstName || "").toLowerCase();
242
+ const myFull = (me.fullName || "").toLowerCase().replace(/\s+/g, "-");
243
+ if (myFirst && (sender === myFirst || sender === myFull)) return false;
244
+
245
+ const lockKey = item.raw_ref || item.id || `${svc.name}-${Date.now()}`;
246
+ if (seenRefs.has(lockKey)) return false;
247
+ seenRefs.add(lockKey);
248
+ const lock = acquireLock(lockKey, {
249
+ sender: item.sender || "unknown",
250
+ channel: item.channel || item.channel_id || "unknown",
251
+ });
252
+ return lock.acquired;
253
+ });
254
+
255
+ for (const item of newItems) {
256
+ await processItem(item, svc.name);
257
+ }
258
+
259
+ if (result.errors.length > 0) {
260
+ console.warn(`[daemon] ${svc.name} errors:`, result.errors.slice(0, 3));
261
+ }
262
+ return newItems.length;
263
+ } catch (err) {
264
+ console.error(`[daemon] ${svc.name} poll failed:`, err.message);
265
+ return 0;
266
+ }
267
+ }
268
+
269
+ // ── Fast reactive cohort scan ────────────────────────────────────────────────
270
+ // The org-push wake pulls org DMs/@mentions into state/inbox/cohort/ within ~2s,
271
+ // but the full poll() runs only every POLL_INTERVAL (60s, kept slow because it
272
+ // also hits the RATE-LIMITED Slack API) — so a DM sat up to 60s BETWEEN being
273
+ // fetched and being answered (measured: ~24-44s reactive round-trips that were
274
+ // almost entirely this gap, not the model). Cohort items are local YAML with no
275
+ // API call, so we drain that ONE directory on a short interval. This is the
276
+ // single biggest reactive-latency win. Overlap-guarded; the item lock dedups
277
+ // against the 60s poll's own cohort scan (kept as a safety net).
278
+ let _cohortPoller = null;
279
+ let _cohortFastRunning = false;
280
+ async function pollCohortFast() {
281
+ if (_cohortFastRunning) return 0;
282
+ if (!_existsSync(join(AGENT_REPO_DIR, "config/org.yaml"))) return 0;
283
+ if (!_cohortPoller) _cohortPoller = makeInboxScanPoller("cohort", { agentRoot: AGENT_REPO_DIR });
284
+ _cohortFastRunning = true;
285
+ try {
286
+ return await pollService({ name: "cohort", fn: _cohortPoller });
287
+ } finally {
288
+ _cohortFastRunning = false;
289
+ }
290
+ }
291
+
257
292
  // ---------------------------------------------------------------------------
258
293
  // Process a single item
259
294
  // ---------------------------------------------------------------------------
@@ -2143,6 +2178,12 @@ async function main() {
2143
2178
  }
2144
2179
  }, POLL_INTERVAL);
2145
2180
 
2181
+ // Fast reactive cohort scan — drains org-push-fetched DMs in ~2s instead of
2182
+ // waiting up to 60s for the full poll. Local YAML only; no API/rate-limit cost.
2183
+ setInterval(() => {
2184
+ pollCohortFast().catch((err) => console.error("[daemon] cohort fast-poll error:", err.message));
2185
+ }, COHORT_FAST_INTERVAL);
2186
+
2146
2187
  // Backlog sweep
2147
2188
  setInterval(async () => {
2148
2189
  try {
@@ -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
  };