@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
|
-
//
|
|
207
|
-
//
|
|
208
|
-
|
|
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 (
|
|
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
|
|
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,
|
|
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 ||
|
|
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
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
"
|
|
370
|
-
"
|
|
371
|
-
"
|
|
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
|
|
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
|
|
390
|
-
const
|
|
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.
|
|
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
|
-
|
|
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
|
};
|