@cohortapp/agent-sdk 2.11.9 → 2.11.11

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.11",
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": {
@@ -499,6 +499,43 @@ async function electResponderVerdict(item, deps = {}) {
499
499
  * the in-process quick-reply responder; rung ≥2 requires a spawned session.
500
500
  * @returns {Promise<{ok:boolean, path:string, reason?:string}>}
501
501
  */
502
+ // Work-verbs that mean "do a task", not "answer a question" — a DM containing one
503
+ // needs the full classify → session path (it may be multi-step work), so it is
504
+ // NEVER fast-pathed to a shallow quick reply.
505
+ const FASTPATH_WORK_RE = /\b(analy[sz]e|create|build|writ(e|ing)|draft|prepare|research|review|compile|generate|implement|fix|debug|refactor|deploy|migrat(e|ing)|audit|investigate|summari[sz]e|put together|pull together|set up|look into|go through|work on|take care of|handle|organi[sz]e|plan out|figure out)\b/;
506
+ // A DM that reads like a question / greeting / quick ask — safe to answer directly.
507
+ const FASTPATH_ASK_RE = /(\?|^(what|how|when|where|who|why|which|is |are |was |were |do |did |does |can |could |would |will |have |has |should |hey|hi |hello|thanks|thank you|yo |quick (q|question)|you (there|around|up)))/i;
508
+
509
+ /**
510
+ * Decide whether a directed DM is a "plainly simple question" that can skip the
511
+ * LLM classifier and be answered directly. Returns a default classResult (the
512
+ * quick-reply shape) when it qualifies, else null (→ full classify path).
513
+ * Deliberately CONSERVATIVE: 1:1 DM, short, question-shaped, no work-verbs.
514
+ * `deps.fastPathClassify` can override (tests); `deps.disableFastPath` disables.
515
+ */
516
+ export function fastPathClassify(item, isDm, deps = {}) {
517
+ if (deps && typeof deps.fastPathClassify === "function") return deps.fastPathClassify(item, isDm);
518
+ if (deps && deps.disableFastPath) return null;
519
+ if (process.env.DAEMON_DISABLE_FASTPATH === "1") return null;
520
+ if (!isDm) return null; // 1:1 DMs only (never group/ambient)
521
+ const content = String(item.content || item.subject || "").trim();
522
+ if (!content || content.length > 240) return null; // short only
523
+ if (item.has_attachments || item.attachments) return null;
524
+ const lc = content.toLowerCase();
525
+ if (FASTPATH_WORK_RE.test(lc)) return null; // work-request → full path
526
+ if (!FASTPATH_ASK_RE.test(content)) return null; // must look like a question/greeting
527
+ return {
528
+ priority: "high", // a directed DM is latency-sensitive → preempts backlog
529
+ action: "respond",
530
+ model: "sonnet", // fast model; a simple question does not need opus
531
+ summary: content.slice(0, 80),
532
+ category: "action_required",
533
+ directed_at_agent: true,
534
+ answerable: true, // routes to the in-process quick-reply responder
535
+ fast_path: true,
536
+ };
537
+ }
538
+
502
539
  export async function answerItem(item, service, itemId, trace_id, deps = {}, routed = null) {
503
540
  // Resolve collaborators: real imports by default (production), injected fakes
504
541
  // only when a test supplies them. Names match the imported symbols 1:1.
@@ -523,23 +560,36 @@ export async function answerItem(item, service, itemId, trace_id, deps = {}, rou
523
560
  // session has no idea it is discharging a planned obligation.
524
561
  if (routed) item.execution = routed;
525
562
 
526
- // Classify via Haiku API
527
- const classResult = await _classify({
528
- sender: item.sender || "unknown",
529
- sender_privilege: item.sender_privilege || item.priority_signals?.from_ceo ? "ceo" : "unknown",
530
- channel: item.channel || "unknown",
531
- service,
532
- content: item.content || item.subject || "",
533
- is_reply: item.is_reply || false,
534
- thread_context: item.thread_context || null,
535
- subject: item.subject || "",
536
- is_dm: isDm,
537
- is_group: !isDm && service === "slack",
538
- agent_in_thread: item.agent_in_thread === true,
539
- kind: item.kind || "message",
540
- });
563
+ // FAST PATH: a short, directed, question-shaped DM with no work-verbs does not
564
+ // need a ~5-8s LLM classification to decide "answer this" — skip the classify
565
+ // `claude` call entirely and go straight to a quick reply. This is the single
566
+ // biggest reactive-latency cut after the fetch fix (the two sequential claude
567
+ // calls dominate processItem). CONSERVATIVE by construction: only a plain
568
+ // short question qualifies; work-requests and anything ambiguous fall through
569
+ // to the full classify → route path below, so nothing is shallow-answered.
570
+ let classResult = fastPathClassify(item, isDm, deps);
571
+ if (classResult) {
572
+ recordClassification(true);
573
+ console.log(`[daemon] fast-path DM — skipped classify (${classResult.model}): ${classResult.summary}`);
574
+ } else {
575
+ // Classify via Haiku CLI
576
+ classResult = await _classify({
577
+ sender: item.sender || "unknown",
578
+ sender_privilege: item.sender_privilege || item.priority_signals?.from_ceo ? "ceo" : "unknown",
579
+ channel: item.channel || "unknown",
580
+ service,
581
+ content: item.content || item.subject || "",
582
+ is_reply: item.is_reply || false,
583
+ thread_context: item.thread_context || null,
584
+ subject: item.subject || "",
585
+ is_dm: isDm,
586
+ is_group: !isDm && service === "slack",
587
+ agent_in_thread: item.agent_in_thread === true,
588
+ kind: item.kind || "message",
589
+ });
541
590
 
542
- recordClassification(true);
591
+ recordClassification(true);
592
+ }
543
593
 
544
594
  logEvent("classifications", {
545
595
  item_id: itemId,
@@ -862,3 +862,29 @@ test("resolveSelfMemberId reads the member cuid from the res frame's `result` (o
862
862
  if (pa === undefined) delete process.env.COHORT_AGENT_ID; else process.env.COHORT_AGENT_ID = pa;
863
863
  }
864
864
  });
865
+
866
+ test("fastPathClassify: a short directed question skips the classifier; work-requests do not", async () => {
867
+ const fp = (content, isDm = true, extra = {}) => daemon.fastPathClassify({ content, ...extra }, isDm);
868
+ // Plain short questions → fast-path (quick reply, high priority, sonnet).
869
+ const q = fp("what OS is your machine on?");
870
+ assert.equal(q && q.fast_path, true);
871
+ assert.equal(q.answerable, true);
872
+ assert.equal(q.priority, "high");
873
+ assert.equal(q.action, "respond");
874
+ assert.ok(daemon.fastPathClassify({ content: "hey, you around?" }, true));
875
+ assert.ok(daemon.fastPathClassify({ content: "thanks!" }, true));
876
+ // Work-requests → NOT fast-pathed (fall through to full classify → session).
877
+ assert.equal(fp("can you analyze the Q3 report and send me the findings?"), null);
878
+ assert.equal(fp("please review the PR and fix the failing tests"), null);
879
+ assert.equal(fp("build a dashboard for the sales pipeline"), null);
880
+ // Non-questions, long messages, group, and non-DM → NOT fast-pathed.
881
+ assert.equal(fp("here are the notes from the meeting for your records"), null);
882
+ assert.equal(fp("x".repeat(300)), null);
883
+ assert.equal(fp("what OS?", false), null); // not a DM
884
+ assert.equal(fp(""), null);
885
+ // Attachments → full path.
886
+ assert.equal(daemon.fastPathClassify({ content: "what's this?", has_attachments: true }, true), null);
887
+ // Kill switch (deps.disableFastPath) forces the full classify path.
888
+ assert.ok(daemon.fastPathClassify({ content: "what OS?" }, true));
889
+ assert.equal(daemon.fastPathClassify({ content: "what OS?" }, true, { disableFastPath: true }), null);
890
+ });
@@ -142,7 +142,10 @@ function dm(over = {}) {
142
142
  channel_id: "D77",
143
143
  sender: "Priya Raman",
144
144
  sender_id: "U_PRIYA",
145
- content: "can you confirm the deadline?",
145
+ // A work-request ("review") so this shared fixture takes the full classify →
146
+ // route path — the fast-path (fastPathClassify) deliberately skips the
147
+ // classifier only for PLAIN short questions; that path has its own test below.
148
+ content: "can you review and confirm the deadline for the launch plan?",
146
149
  timestamp: new Date().toISOString(),
147
150
  ...over,
148
151
  };
@@ -190,6 +193,20 @@ test("LADDER: a DM is journalled intake→match→route→outcome AND still quic
190
193
  assert.equal(s.seen.dispatch.length, 0, "no session spawned for a rung-0 reply");
191
194
  });
192
195
 
196
+ test("LADDER: a plain short question DM FAST-PATHS — quick-replies WITHOUT a classifier call", async () => {
197
+ const item = dm({ id: "MSG-DM-FAST", raw_ref: "slack:D91:1", channel_id: "D91", content: "what's the deadline?" });
198
+ writeLive(item, "slack");
199
+ const s = spy(RESPOND);
200
+ await daemon.processItem(item, "slack", s.deps);
201
+
202
+ // The whole point: the ~5-8s Haiku classify call is SKIPPED for a plain question.
203
+ assert.equal(s.seen.classify, 0, "fast-path: a plain directed question skips the classifier");
204
+ // ...but the DM is still answered via the quick-reply path, still journalled.
205
+ assert.deepEqual(s.seen.quick, [{ id: "MSG-DM-FAST", rung: 0 }], "the quick reply still went out at rung 0");
206
+ assert.equal(s.seen.dispatch.length, 0, "no session spawned");
207
+ assert.ok(decisions().some((r) => r.surface === "dm" && r.disposition === "react_now"), "still journalled intake→route→outcome");
208
+ });
209
+
193
210
  // ---------------------------------------------------------------------------
194
211
  // 2) A board task is WORK: it queues, and never reaches the classifier.
195
212
  // ---------------------------------------------------------------------------
@@ -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
  };