@kienntpixon/dieu-phoi 0.7.2 → 0.7.3
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.
- package/CHANGELOG.md +18 -0
- package/bin/dieu-phoi.cjs +21 -5
- package/hook.cjs +659 -26
- package/lib/args.cjs +1 -1
- package/lib/cbm.cjs +8 -2
- package/lib/commands.cjs +255 -7
- package/lib/deps.cjs +162 -0
- package/lib/load.cjs +332 -0
- package/lib/procs.cjs +200 -1
- package/lib/prune.cjs +10 -1
- package/lib/setup.cjs +28 -5
- package/lib/worktree.cjs +250 -19
- package/package.json +1 -1
- package/profile/lessons.md +2 -0
package/hook.cjs
CHANGED
|
@@ -26,7 +26,7 @@ const path = require('path');
|
|
|
26
26
|
const os = require('os');
|
|
27
27
|
const crypto = require('crypto');
|
|
28
28
|
|
|
29
|
-
const CORE_VERSION = '0.7.
|
|
29
|
+
const CORE_VERSION = '0.7.3';
|
|
30
30
|
const SCHEMA = 1;
|
|
31
31
|
const TASK_TYPES = [
|
|
32
32
|
'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
|
|
@@ -274,6 +274,8 @@ function parseTag(text) {
|
|
|
274
274
|
tag.chain_ok = true; // core 0.7.1 (C1): the user agreed to continue a long handoff chain
|
|
275
275
|
} else if (p === 'session_ok') {
|
|
276
276
|
tag.session_ok = true; // core 0.7.2 (K5): the user agreed to open a new root in an oversized session
|
|
277
|
+
} else if (p === 'parallel_ok') {
|
|
278
|
+
tag.parallel_ok = true; // core 0.7.3 (s1): the user agreed to dispatch past the parallel cap
|
|
277
279
|
} else if (p === 'batch') {
|
|
278
280
|
tag.batch = true; // core 0.7.1 (C5): the one batched verifier of a fast request
|
|
279
281
|
}
|
|
@@ -1715,11 +1717,18 @@ function formatStartBlock(info) {
|
|
|
1715
1717
|
|
|
1716
1718
|
/** UserPromptSubmit: on a dieu-phoi prompt, pre-create the run and inject id/run dir/health/brief. */
|
|
1717
1719
|
function handleUserPromptSubmit(p, ctx) {
|
|
1718
|
-
|
|
1720
|
+
// s6: stuck/orphaned agents of this session → TaskStop + reap reminder, on any prompt (not only dieu-phoi ones)
|
|
1721
|
+
const stuck = typeof p.session_id === 'string' && p.session_id && !watchAgentId(p) ? stuckReminder(ctx, { sessionId: p.session_id }) : null;
|
|
1722
|
+
if (!isDieuPhoiPrompt(p.prompt)) {
|
|
1723
|
+
if (!stuck) return null;
|
|
1724
|
+
return { stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: stuck } }), stuck: true };
|
|
1725
|
+
}
|
|
1719
1726
|
const info = startInfo(ctx, typeof p.cwd === 'string' ? p.cwd : null);
|
|
1727
|
+
const text = stuck ? `${formatStartBlock(info)}
|
|
1728
|
+
${stuck}` : formatStartBlock(info);
|
|
1720
1729
|
return {
|
|
1721
1730
|
stdout: JSON.stringify({
|
|
1722
|
-
hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext:
|
|
1731
|
+
hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text },
|
|
1723
1732
|
}),
|
|
1724
1733
|
info,
|
|
1725
1734
|
};
|
|
@@ -1871,7 +1880,7 @@ function handoffChainMessage(taskId, n) {
|
|
|
1871
1880
|
const MODES = ['fast', 'normal', 'strict'];
|
|
1872
1881
|
const DEFAULT_MODE = 'normal';
|
|
1873
1882
|
/** Event kinds that stay on this machine: runSync does not send them (an older sink would answer bad_kind). */
|
|
1874
|
-
const LOCAL_ONLY_KINDS = new Set(['mode', 'knowledge_merge', 'leftover', 'worktree']);
|
|
1883
|
+
const LOCAL_ONLY_KINDS = new Set(['mode', 'knowledge_merge', 'leftover', 'worktree', 'reap', 'reap_orphans']);
|
|
1875
1884
|
const MODE_ASK_LINE = 'mode: chưa chọn — chỉ hỏi ở cổng 1 khi plan-table báo ≥4 bước s≤6 (fast | normal | strict, mặc định normal); '
|
|
1876
1885
|
+ 'đặt bằng `dieu-phoi mode --root <root_task_id> <mode>`';
|
|
1877
1886
|
|
|
@@ -2900,6 +2909,7 @@ function buildDispatch(p, ctx) {
|
|
|
2900
2909
|
...(tag.type_alias_from ? { type_alias_from: tag.type_alias_from } : {}),
|
|
2901
2910
|
...(tag.chain_ok ? { chain_ok: true } : {}),
|
|
2902
2911
|
...(tag.session_ok ? { session_ok: true } : {}),
|
|
2912
|
+
...(tag.parallel_ok ? { parallel_ok: true } : {}),
|
|
2903
2913
|
...(tag.batch ? { batch: true } : {}),
|
|
2904
2914
|
...(dispatchRun(ti.prompt) ? { run: dispatchRun(ti.prompt) } : {}),
|
|
2905
2915
|
task_type: tag.task_type,
|
|
@@ -2918,7 +2928,7 @@ function buildDispatch(p, ctx) {
|
|
|
2918
2928
|
orch_ctx_baseline: orch.ctx_baseline,
|
|
2919
2929
|
orch_compactions: orch.compactions,
|
|
2920
2930
|
orch_cum_usd: orch.cum_usd,
|
|
2921
|
-
}, tag.parent_task_id ? { parent_task_id: tag.parent_task_id } : {},
|
|
2931
|
+
}, dispatchLiveCounts(ctx, p, tag), tag.parent_task_id ? { parent_task_id: tag.parent_task_id } : {},
|
|
2922
2932
|
projectFields(p),
|
|
2923
2933
|
modeFields(ctx, tag),
|
|
2924
2934
|
tierFields(ctx, tag, normalizeModel(trObj.resolvedModel) || normalizeModel(ti.model) || null, explicitEffort || parentEffort || null),
|
|
@@ -2992,6 +3002,12 @@ function handlePostToolUse(p, ctx) {
|
|
|
2992
3002
|
}
|
|
2993
3003
|
appendEvent(ctx, ev);
|
|
2994
3004
|
if (implicit) appendEvent(ctx, implicit);
|
|
3005
|
+
// s6: the orchestrator just dispatched: remind it of its stuck/orphaned agents (main session only; an exec hears about
|
|
3006
|
+
// its own helpers through --watch)
|
|
3007
|
+
if (!watchAgentId(p) && typeof p.session_id === 'string' && p.session_id) {
|
|
3008
|
+
const stuck = stuckReminder(ctx, { sessionId: p.session_id });
|
|
3009
|
+
if (stuck) notes.push(stuck);
|
|
3010
|
+
}
|
|
2995
3011
|
if (notes.length) {
|
|
2996
3012
|
Object.defineProperty(ev, 'stdout', {
|
|
2997
3013
|
enumerable: false,
|
|
@@ -3286,7 +3302,7 @@ function openBackgroundTasks(lines) {
|
|
|
3286
3302
|
return Array.from(open, ([k, v]) => v || k);
|
|
3287
3303
|
}
|
|
3288
3304
|
|
|
3289
|
-
function handleSubagentStop(p, ctx) {
|
|
3305
|
+
function handleSubagentStop(p, ctx, opt) {
|
|
3290
3306
|
const agentId = typeof p.agent_id === 'string' && p.agent_id ? p.agent_id : null;
|
|
3291
3307
|
if (!agentId) return null;
|
|
3292
3308
|
const prev = loadSegments(ctx, agentId);
|
|
@@ -3385,7 +3401,14 @@ function handleSubagentStop(p, ctx) {
|
|
|
3385
3401
|
}
|
|
3386
3402
|
// s16 (core 0.7.2): processes the agent left behind (report; kill only with config kill_leftovers). Never throws, bounded to ~5 s.
|
|
3387
3403
|
if (tag && tag.task_id && exists) {
|
|
3388
|
-
|
|
3404
|
+
const live = opt && opt.live ? opt.live : null;
|
|
3405
|
+
try {
|
|
3406
|
+
scanLeftoverProcs(ctx, {
|
|
3407
|
+
agentId, taskId: tag.task_id, lines, worktree: live && live.cwd ? live.cwd : null, liveStart: live ? live.start : null,
|
|
3408
|
+
sessionCwd: typeof p.cwd === 'string' ? p.cwd : null,
|
|
3409
|
+
sessionTranscript: typeof p.transcript_path === 'string' ? p.transcript_path : (live && live.session_transcript) || null,
|
|
3410
|
+
});
|
|
3411
|
+
} catch (e) { logError(ctx.home, e, 'leftover-procs'); }
|
|
3389
3412
|
}
|
|
3390
3413
|
return ev;
|
|
3391
3414
|
}
|
|
@@ -3441,15 +3464,19 @@ function otherAgentsRunning(events, agentId, startMs, endMs) {
|
|
|
3441
3464
|
/**
|
|
3442
3465
|
* Looks at the process table after a dp agent stopped. Descendants of this session's Claude Code process created since the
|
|
3443
3466
|
* agent started and still alive (not the hook's own chain, not claude/code-graph) -> one local `leftover` event
|
|
3444
|
-
* {task_id, n, names}.
|
|
3445
|
-
*
|
|
3467
|
+
* {task_id, n, names}. kill_leftovers (procs.killMode: env → config → 'guarded'):
|
|
3468
|
+
* - 'guarded' (default, s7): kill only what procs.guardedSelect attributes to this agent (agent shell under this session's
|
|
3469
|
+
* claude.exe, never-kill list, cmd in its own worktree or no other dp agent meanwhile), each pid re-checked against a
|
|
3470
|
+
* fresh list (same creation time) right before; killed_pids + kill_skips {pid, name, reason} go in the event;
|
|
3471
|
+
* - true: 0.7.2 behaviour, the leftover tree roots (taskkill /T) when no other dp agent ran meanwhile; false: report only.
|
|
3472
|
+
* deps = { procs, now, selfPid, events, kill } for tests. Returns the event or null.
|
|
3446
3473
|
*/
|
|
3447
3474
|
function scanLeftoverProcs(ctx, a, deps) {
|
|
3448
3475
|
const d = deps || {};
|
|
3449
3476
|
if (ctx.config.scan_leftover_procs === false || (!d.procs && process.env.DIEU_PHOI_NO_PROCS === '1')) return null;
|
|
3450
3477
|
const procs = d.procs || loadProcs();
|
|
3451
3478
|
if (!procs) return null;
|
|
3452
|
-
const startMs = transcriptStartMs(a.lines);
|
|
3479
|
+
const startMs = transcriptStartMs(a.lines) || (a.liveStart ? Date.parse(a.liveStart) || null : null);
|
|
3453
3480
|
const now = typeof d.now === 'number' ? d.now : Date.now();
|
|
3454
3481
|
// an agent that started over 24 h ago (a long-resumed or odd transcript) has no meaningful "created since" window
|
|
3455
3482
|
if (startMs === null || now - startMs > LEFTOVER_OVERLAP_MAX_AGE_MS) return null;
|
|
@@ -3464,22 +3491,88 @@ function scanLeftoverProcs(ctx, a, deps) {
|
|
|
3464
3491
|
const counts = new Map();
|
|
3465
3492
|
for (const p of left.all) { const n = String(p.name || '?').toLowerCase(); counts.set(n, (counts.get(n) || 0) + 1); }
|
|
3466
3493
|
const names = Array.from(counts).sort((x, y) => y[1] - x[1] || (x[0] < y[0] ? -1 : 1)).slice(0, 8).map((x) => x[0]);
|
|
3467
|
-
const
|
|
3494
|
+
const mode = typeof procs.killMode === 'function' ? procs.killMode(ctx.config) : (ctx.config.kill_leftovers === true ? 'tree' : 'off');
|
|
3468
3495
|
let killed = 0;
|
|
3469
3496
|
let skipped = null;
|
|
3470
|
-
|
|
3497
|
+
const extra = {};
|
|
3498
|
+
if (mode === 'tree') {
|
|
3471
3499
|
const events = d.events || readEvents(ctx);
|
|
3472
3500
|
if (otherAgentsRunning(events, a.agentId, startMs, now)) skipped = 'other-agent-running';
|
|
3473
3501
|
else killed = procs.killTree(left.roots.map((p) => p.pid), { deadline: t0 + LEFTOVER_BUDGET_MS }).length;
|
|
3502
|
+
} else if (mode === 'guarded') {
|
|
3503
|
+
// s7: kill only what is attributable to THIS agent, after re-checking pid + creation time; pids + reasons go in the event
|
|
3504
|
+
const events = d.events || readEvents(ctx);
|
|
3505
|
+
const otherAgent = otherAgentsRunning(events, a.agentId, startMs, now);
|
|
3506
|
+
const mainShell = !otherAgent && (typeof d.mainShell === 'boolean' ? d.mainShell : mainShellSince(a.sessionTranscript, startMs));
|
|
3507
|
+
const exclusive = !otherAgent && !mainShell;
|
|
3508
|
+
const sel = procs.guardedSelect(list, left.all, { rootPid: root.pid, worktree: ownWorktree(ctx, a, now), exclusive });
|
|
3509
|
+
const res = sel.kill.length
|
|
3510
|
+
? procs.killVerified(sel.kill, { relist: () => procs.listProcesses({ timeoutMs: LEFTOVER_LIST_TIMEOUT_MS }), kill: d.kill, deadline: t0 + LEFTOVER_BUDGET_MS })
|
|
3511
|
+
: { killed: [], skipped: [] };
|
|
3512
|
+
killed = res.killed.length;
|
|
3513
|
+
const skips = sel.skip.concat(res.skipped);
|
|
3514
|
+
if (!exclusive && sel.skip.some((s) => s.reason === 'not-attributable')) skipped = otherAgent ? 'other-agent-running' : 'main-thread-shell';
|
|
3515
|
+
extra.killed_pids = res.killed.slice(0, 20);
|
|
3516
|
+
if (skips.length) extra.kill_skips = skips.slice(0, 20).map((s) => ({ pid: s.pid, name: String(s.name || '').toLowerCase(), reason: s.reason }));
|
|
3474
3517
|
}
|
|
3475
3518
|
const ev = Object.assign({
|
|
3476
3519
|
schema: SCHEMA, kind: 'leftover', ts: nowIso(), agent_id: a.agentId, task_id: a.taskId,
|
|
3477
|
-
n: left.all.length, names, kill_leftovers:
|
|
3478
|
-
}, skipped ? { kill_skipped: skipped } : {}, commonFields(ctx));
|
|
3520
|
+
n: left.all.length, names, kill_leftovers: typeof procs.killModeValue === 'function' ? procs.killModeValue(mode) : mode === 'tree', killed,
|
|
3521
|
+
}, extra, skipped ? { kill_skipped: skipped } : {}, commonFields(ctx));
|
|
3479
3522
|
appendEvent(ctx, ev);
|
|
3480
3523
|
return ev;
|
|
3481
3524
|
}
|
|
3482
3525
|
|
|
3526
|
+
const MAIN_SHELL_TAIL_BYTES = 4 * 1024 * 1024;
|
|
3527
|
+
const MAIN_SHELL_TOOLS = new Set(['Bash', 'PowerShell', 'Monitor']);
|
|
3528
|
+
|
|
3529
|
+
/**
|
|
3530
|
+
* Did the session's main thread start a shell (Bash / PowerShell / Monitor tool_use) at or after startMs? Its processes sit
|
|
3531
|
+
* under the same claude.exe as the agent's, so "no other dp agent ran" is not enough to call the tree the agent's. Reads the
|
|
3532
|
+
* last 4 MB of the session transcript. true = yes OR unknown (unreadable, or the tail does not reach back to startMs).
|
|
3533
|
+
*/
|
|
3534
|
+
function mainShellSince(file, startMs) {
|
|
3535
|
+
if (typeof file !== 'string' || !file || !startMs) return true;
|
|
3536
|
+
let fd;
|
|
3537
|
+
try { fd = fs.openSync(file, 'r'); } catch (_) { return true; }
|
|
3538
|
+
try {
|
|
3539
|
+
const size = fs.fstatSync(fd).size;
|
|
3540
|
+
const from = Math.max(0, size - MAIN_SHELL_TAIL_BYTES);
|
|
3541
|
+
const text = readSlice(fd, from, size - from).toString('utf8');
|
|
3542
|
+
const lines = text.split('\n');
|
|
3543
|
+
if (from > 0) lines.shift(); // partial first line
|
|
3544
|
+
let oldest = Infinity;
|
|
3545
|
+
for (const raw of lines) {
|
|
3546
|
+
if (!raw.trim()) continue;
|
|
3547
|
+
let l;
|
|
3548
|
+
try { l = JSON.parse(raw); } catch (_) { continue; }
|
|
3549
|
+
const t = typeof l.timestamp === 'string' ? Date.parse(l.timestamp) : NaN;
|
|
3550
|
+
if (Number.isFinite(t)) oldest = Math.min(oldest, t);
|
|
3551
|
+
if (!(t >= startMs) || l.isSidechain) continue;
|
|
3552
|
+
const content = l.message && Array.isArray(l.message.content) ? l.message.content : [];
|
|
3553
|
+
if (content.some((c) => c && c.type === 'tool_use' && MAIN_SHELL_TOOLS.has(c.name))) return true;
|
|
3554
|
+
}
|
|
3555
|
+
return from > 0 && !(oldest <= startMs); // tail too short to see the whole window: unknown
|
|
3556
|
+
} catch (_) {
|
|
3557
|
+
return true;
|
|
3558
|
+
} finally {
|
|
3559
|
+
try { fs.closeSync(fd); } catch (_) { /* closed */ }
|
|
3560
|
+
}
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
/**
|
|
3564
|
+
* The agent's own worktree for the guarded kill: its live-record cwd (brief `worktree:` or SubagentStart cwd), only when it is
|
|
3565
|
+
* not the session's own cwd and no other live agent works in it (a shared directory says nothing about whose process it is).
|
|
3566
|
+
*/
|
|
3567
|
+
function ownWorktree(ctx, a, now) {
|
|
3568
|
+
const norm = (s) => String(s || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
3569
|
+
const wt = norm(a.worktree);
|
|
3570
|
+
if (!wt || (a.sessionCwd && norm(a.sessionCwd) === wt)) return null;
|
|
3571
|
+
let others = [];
|
|
3572
|
+
try { others = readLive(ctx, now).agents.filter((r) => r.agent_id !== a.agentId); } catch (_) { others = []; }
|
|
3573
|
+
return others.some((r) => norm(r.cwd) === wt) ? null : a.worktree;
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3483
3576
|
// Exact marker an agent puts on the FIRST line of its final report when it stops at a safe point because of a quota
|
|
3484
3577
|
// / usage limit (brief template A, SKILL.md). Nothing looser: "STATUS: partial" + "quota" can be a real failure
|
|
3485
3578
|
// ("Drive storage quota exceeded", a half-built rate limiter).
|
|
@@ -3869,6 +3962,17 @@ function handleWatch(p, ctx, o) {
|
|
|
3869
3962
|
if (!agentId) return null;
|
|
3870
3963
|
const { st, file, tpath } = watchState(p, ctx, agentId);
|
|
3871
3964
|
if (!st || !st.dp || !tpath) return null;
|
|
3965
|
+
const touched = touchLive(ctx, p, agentId, st); // core 0.7.3 (s1): live register heartbeat
|
|
3966
|
+
// s6: an exec's own stuck helpers (checked at most once per heartbeat, i.e. ≤ 1/30 s)
|
|
3967
|
+
const helperNote = touched && st.role === 'exec' && st.task_id ? stuckReminder(ctx, { parentTask: st.task_id }) : null;
|
|
3968
|
+
const withHelperNote = (r) => {
|
|
3969
|
+
if (!helperNote) return r;
|
|
3970
|
+
if (!r) return { stuck: true, stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: helperNote } }) };
|
|
3971
|
+
const out = JSON.parse(r.stdout);
|
|
3972
|
+
out.hookSpecificOutput.additionalContext += `\n${helperNote}`;
|
|
3973
|
+
r.stdout = JSON.stringify(out);
|
|
3974
|
+
return r;
|
|
3975
|
+
};
|
|
3872
3976
|
// Gate slots: a helper's first hook proves its spawn started; so does the exec's own successful Agent call.
|
|
3873
3977
|
if (st.role === 'helper' && st.parent_task_id && st.task_id) gateConfirm(ctx, st.parent_task_id, st.task_id);
|
|
3874
3978
|
if ((p.tool_name === 'Agent' || p.tool_name === 'Task') && st.role === 'exec') {
|
|
@@ -3877,21 +3981,21 @@ function handleWatch(p, ctx, o) {
|
|
|
3877
3981
|
}
|
|
3878
3982
|
const thresholds = Array.isArray(o && o.thresholds) && o.thresholds.length ? o.thresholds : watchThresholds(ctx);
|
|
3879
3983
|
const tokens = lastContextTokens(tpath);
|
|
3880
|
-
if (tokens === null) return null;
|
|
3984
|
+
if (tokens === null) return withHelperNote(null);
|
|
3881
3985
|
let level = 0;
|
|
3882
3986
|
while (level < thresholds.length && tokens >= thresholds[level]) level++;
|
|
3883
|
-
if (level <= (Number(st.fired) || 0)) return null;
|
|
3987
|
+
if (level <= (Number(st.fired) || 0)) return withHelperNote(null);
|
|
3884
3988
|
st.fired = level;
|
|
3885
3989
|
st.ctx_tokens = tokens;
|
|
3886
3990
|
st.t = Date.now();
|
|
3887
3991
|
writeFileAtomic(file, `${JSON.stringify(st)}\n`);
|
|
3888
|
-
return {
|
|
3992
|
+
return withHelperNote({
|
|
3889
3993
|
level,
|
|
3890
3994
|
ctx_tokens: tokens,
|
|
3891
3995
|
stdout: JSON.stringify({
|
|
3892
3996
|
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: handoffMessage(st, tokens, level, thresholds) },
|
|
3893
3997
|
}),
|
|
3894
|
-
};
|
|
3998
|
+
});
|
|
3895
3999
|
}
|
|
3896
4000
|
|
|
3897
4001
|
/** A reservation that neither the parent's PostToolUse(Agent) nor the helper's own first hook confirmed in this long never started. */
|
|
@@ -3942,6 +4046,514 @@ function gateReserve(dir, child) {
|
|
|
3942
4046
|
return 0;
|
|
3943
4047
|
}
|
|
3944
4048
|
|
|
4049
|
+
// ------------------------------------------------------------------ live agents + parallel cap (core 0.7.3 s1)
|
|
4050
|
+
|
|
4051
|
+
/**
|
|
4052
|
+
* Live-agent register `data/live/<agent_id>.json`: written at SubagentStart, refreshed by --watch (last_activity, at most
|
|
4053
|
+
* once per LIVE_TOUCH_MS), removed at SubagentStop, ignored (and deleted) once idle past LIVE_TTL_MS. Counting is a
|
|
4054
|
+
* readdir of small files, cheap enough for PreToolUse. The SubagentStart payload has no prompt (only session_id,
|
|
4055
|
+
* agent_id, agent_type, cwd — checked 2026-11-06 with a probe session) and the subagent transcript does not exist yet,
|
|
4056
|
+
* so the tag comes from an intent `live/pending/<session8>-<task>-<role>.json` that the allowing PreToolUse(Agent)
|
|
4057
|
+
* (main session) or --gate (gen-2 helper) wrote: SubagentStart claims the oldest unclaimed intent of the same session
|
|
4058
|
+
* and agent_type with an exclusive `.claim` create. No intent → a dp-* agent_type still gets a file (role from the
|
|
4059
|
+
* type, task unknown, filled in by its first --watch); any other subagent (Claude Code internal, untagged) is skipped.
|
|
4060
|
+
*/
|
|
4061
|
+
const LIVE_TTL_MS = 6 * 3600 * 1000;
|
|
4062
|
+
const LIVE_TOUCH_MS = 30 * 1000;
|
|
4063
|
+
/** An unclaimed intent counts towards the cap this long (the spawn it announces has not started yet). */
|
|
4064
|
+
const LIVE_PENDING_MS = 60 * 1000;
|
|
4065
|
+
/** SubagentStart may still claim an intent this old (a permission prompt can sit between PreToolUse and the spawn). */
|
|
4066
|
+
const LIVE_CLAIM_MS = 10 * 60 * 1000;
|
|
4067
|
+
const PARALLEL_DEFAULTS = { max_parallel_session: 4, max_parallel_machine: 8, warn_parallel_machine: 6 };
|
|
4068
|
+
/** Roles the per-session cap counts (gen 1 dispatches of the orchestrator). The machine cap counts every live agent. */
|
|
4069
|
+
const LIVE_SESSION_ROLES = ['exec', 'plan', 'verify'];
|
|
4070
|
+
const AGENT_TYPE_ROLES = { 'dp-exec': 'exec', 'dp-verify': 'verify', 'dp-scout': 'plan', 'dp-helper': 'helper' };
|
|
4071
|
+
|
|
4072
|
+
function liveName(id) {
|
|
4073
|
+
return String(id).replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 80) || 'x';
|
|
4074
|
+
}
|
|
4075
|
+
function liveDir(ctx) { return path.join(ctx.paths.data, 'live'); }
|
|
4076
|
+
function liveFile(ctx, agentId) { return path.join(liveDir(ctx), `${liveName(agentId)}.json`); }
|
|
4077
|
+
function livePendingDir(ctx) { return path.join(liveDir(ctx), 'pending'); }
|
|
4078
|
+
function intentFile(ctx, sessionId, taskId, role) {
|
|
4079
|
+
return path.join(livePendingDir(ctx), `${sha8(String(sessionId || ''))}-${liveName(taskId || 'none')}-${liveName(role || 'exec')}.json`);
|
|
4080
|
+
}
|
|
4081
|
+
|
|
4082
|
+
/** Caps: env DIEU_PHOI_<KEY> → config.json → PARALLEL_DEFAULTS. Integers ≥ 0; 0 turns that cap (or warning) off. */
|
|
4083
|
+
function parallelLimits(ctx) {
|
|
4084
|
+
const cfg = (ctx && ctx.config) || {};
|
|
4085
|
+
const pick = (key) => {
|
|
4086
|
+
for (const v of [process.env[`DIEU_PHOI_${key.toUpperCase()}`], cfg[key]]) {
|
|
4087
|
+
if (v === undefined || v === null || v === '') continue;
|
|
4088
|
+
const n = Number(v);
|
|
4089
|
+
if (Number.isFinite(n) && n >= 0) return Math.floor(n);
|
|
4090
|
+
}
|
|
4091
|
+
return PARALLEL_DEFAULTS[key];
|
|
4092
|
+
};
|
|
4093
|
+
return { session: pick('max_parallel_session'), machine: pick('max_parallel_machine'), warn: pick('warn_parallel_machine') };
|
|
4094
|
+
}
|
|
4095
|
+
|
|
4096
|
+
function liveMs(v) {
|
|
4097
|
+
const n = typeof v === 'number' ? v : Date.parse(v);
|
|
4098
|
+
return Number.isFinite(n) ? n : null;
|
|
4099
|
+
}
|
|
4100
|
+
|
|
4101
|
+
/** Live agents + still-pending intents. Files idle past LIVE_TTL_MS are deleted and not returned. Never throws. */
|
|
4102
|
+
function readLive(ctx, now) {
|
|
4103
|
+
const t = typeof now === 'number' ? now : Date.now();
|
|
4104
|
+
const agents = [];
|
|
4105
|
+
const pending = [];
|
|
4106
|
+
let names = [];
|
|
4107
|
+
try { names = fs.readdirSync(liveDir(ctx)); } catch (_) { return { agents, pending }; }
|
|
4108
|
+
for (const n of names) {
|
|
4109
|
+
if (!n.endsWith('.json')) continue;
|
|
4110
|
+
const f = path.join(liveDir(ctx), n);
|
|
4111
|
+
const r = readJsonFile(f, null);
|
|
4112
|
+
const last = r && typeof r === 'object' ? (liveMs(r.last_activity) || liveMs(r.start)) : null;
|
|
4113
|
+
if (last === null || t - last > LIVE_TTL_MS) {
|
|
4114
|
+
// unreadable: give a concurrent writer a moment before treating it as junk
|
|
4115
|
+
let old = true;
|
|
4116
|
+
if (last === null) { try { old = t - fs.statSync(f).mtimeMs > LIVE_TOUCH_MS; } catch (_) { old = false; } }
|
|
4117
|
+
if (old) { try { fs.unlinkSync(f); } catch (_) { /* gone already */ } }
|
|
4118
|
+
continue;
|
|
4119
|
+
}
|
|
4120
|
+
agents.push(r);
|
|
4121
|
+
}
|
|
4122
|
+
let pnames = [];
|
|
4123
|
+
try { pnames = fs.readdirSync(livePendingDir(ctx)); } catch (_) { pnames = []; }
|
|
4124
|
+
const claimed = new Set(pnames.filter((n) => n.endsWith('.claim')).map((n) => n.slice(0, -6)));
|
|
4125
|
+
for (const n of pnames) {
|
|
4126
|
+
if (!n.endsWith('.json')) continue;
|
|
4127
|
+
const f = path.join(livePendingDir(ctx), n);
|
|
4128
|
+
const r = readJsonFile(f, null);
|
|
4129
|
+
const at = r && typeof r === 'object' ? liveMs(r.t) : null;
|
|
4130
|
+
if (at === null || t - at > LIVE_TTL_MS) {
|
|
4131
|
+
try { fs.unlinkSync(f); } catch (_) { /* ignore */ }
|
|
4132
|
+
try { fs.unlinkSync(`${f.slice(0, -5)}.claim`); } catch (_) { /* ignore */ }
|
|
4133
|
+
continue;
|
|
4134
|
+
}
|
|
4135
|
+
if (claimed.has(n.slice(0, -5)) || t - at > LIVE_PENDING_MS) continue;
|
|
4136
|
+
pending.push(Object.assign({ pending: true }, r));
|
|
4137
|
+
}
|
|
4138
|
+
return { agents, pending };
|
|
4139
|
+
}
|
|
4140
|
+
|
|
4141
|
+
/** session = exec/plan/verify (gen 1) of `sessionId`, live or pending; machine = every live agent + pending intent. */
|
|
4142
|
+
function liveCounts(live, sessionId) {
|
|
4143
|
+
const all = live.agents.concat(live.pending);
|
|
4144
|
+
const mine = all.filter((r) => sessionId && r.session_id === sessionId && LIVE_SESSION_ROLES.includes(r.role) && !(Number(r.gen) > 1));
|
|
4145
|
+
return { session: mine.length, machine: all.length, mine, all };
|
|
4146
|
+
}
|
|
4147
|
+
|
|
4148
|
+
function liveAgeMin(r, now) {
|
|
4149
|
+
const s = liveMs(r.start) || liveMs(r.t);
|
|
4150
|
+
return s === null ? null : Math.max(0, Math.round((now - s) / 60000));
|
|
4151
|
+
}
|
|
4152
|
+
|
|
4153
|
+
function liveLabel(r, now, withSession) {
|
|
4154
|
+
const age = liveAgeMin(r, now);
|
|
4155
|
+
const bits = [r.role || '?'];
|
|
4156
|
+
if (age !== null) bits.push(`${age} phút`);
|
|
4157
|
+
if (r.pending) bits.push('vừa giao');
|
|
4158
|
+
if (withSession && r.session_id) bits.push(`phiên ${sha8(r.session_id)}`);
|
|
4159
|
+
return `${r.task_id || (r.agent_id ? `agent ${r.agent_id}` : '?')} (${bits.join(', ')})`;
|
|
4160
|
+
}
|
|
4161
|
+
|
|
4162
|
+
/** s6: run dir the brief points at (dp-out-/handoff- path or …/runs/<id>), else null — never the made-up fallback. */
|
|
4163
|
+
function briefRunDir(prompt, taskId) {
|
|
4164
|
+
if (typeof prompt !== 'string' || !prompt) return null;
|
|
4165
|
+
const d = resultRunDir(prompt, taskId || 'none');
|
|
4166
|
+
const fallback = path.join(runsDir(), safeName(taskId || 'none'));
|
|
4167
|
+
return d === fallback && !/\/runs\/[A-Za-z0-9_-]/.test(prompt.replace(/\\/g, '/')) ? null : d;
|
|
4168
|
+
}
|
|
4169
|
+
|
|
4170
|
+
/** s6: where Claude Code will write a subagent's transcript, from the parent's transcript_path at SubagentStart. */
|
|
4171
|
+
function liveTranscriptGuess(parentTp, agentId) {
|
|
4172
|
+
if (typeof parentTp !== 'string' || !parentTp || !agentId) return null;
|
|
4173
|
+
const name = `agent-${agentId}.jsonl`;
|
|
4174
|
+
if (path.basename(path.dirname(parentTp)) === 'subagents') return path.join(path.dirname(parentTp), name); // nested spawn
|
|
4175
|
+
return path.join(path.dirname(parentTp), path.basename(parentTp, '.jsonl'), 'subagents', name);
|
|
4176
|
+
}
|
|
4177
|
+
|
|
4178
|
+
/** Absolute path on the brief's `worktree:` line (best effort), else null. */
|
|
4179
|
+
function briefWorktree(prompt) {
|
|
4180
|
+
if (typeof prompt !== 'string') return null;
|
|
4181
|
+
const m = /^\s*worktree:(.*)$/m.exec(prompt);
|
|
4182
|
+
if (!m) return null;
|
|
4183
|
+
const pm = /([A-Za-z]:[\\/][^\s,;`'"()]+|\/[^\s,;`'"()]+)/.exec(m[1]);
|
|
4184
|
+
return pm ? pm[1].replace(/[.)]+$/, '') : null;
|
|
4185
|
+
}
|
|
4186
|
+
|
|
4187
|
+
function writeIntent(ctx, p, tag, extra) {
|
|
4188
|
+
try {
|
|
4189
|
+
const f = intentFile(ctx, p.session_id, tag.task_id, tag.role);
|
|
4190
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
4191
|
+
try { fs.unlinkSync(`${f.slice(0, -5)}.claim`); } catch (_) { /* no earlier claim */ }
|
|
4192
|
+
const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
|
|
4193
|
+
writeFileAtomic(f, `${JSON.stringify(Object.assign({
|
|
4194
|
+
session_id: typeof p.session_id === 'string' ? p.session_id : null,
|
|
4195
|
+
task_id: tag.task_id || null,
|
|
4196
|
+
role: tag.role || 'exec',
|
|
4197
|
+
gen: 1,
|
|
4198
|
+
agent_type: typeof ti.subagent_type === 'string' && ti.subagent_type ? ti.subagent_type : 'general-purpose',
|
|
4199
|
+
parent_task_id: tag.parent_task_id || null,
|
|
4200
|
+
worktree: briefWorktree(ti.prompt),
|
|
4201
|
+
run_dir: briefRunDir(ti.prompt, tag.task_id), // s6: where its dp-out/STATUS lands (stuck after-status)
|
|
4202
|
+
t: Date.now(),
|
|
4203
|
+
}, extra || {}))}\n`);
|
|
4204
|
+
return f;
|
|
4205
|
+
} catch (e) {
|
|
4206
|
+
logError(ctx.home, e, 'live-intent');
|
|
4207
|
+
return null;
|
|
4208
|
+
}
|
|
4209
|
+
}
|
|
4210
|
+
|
|
4211
|
+
/** Oldest unclaimed intent of this session + agent_type; claimed by exclusive create so two parallel starts never share one. */
|
|
4212
|
+
function claimIntent(ctx, sessionId, agentType, agentId, now) {
|
|
4213
|
+
const dir = livePendingDir(ctx);
|
|
4214
|
+
let names = [];
|
|
4215
|
+
try { names = fs.readdirSync(dir); } catch (_) { return null; }
|
|
4216
|
+
const set = new Set(names);
|
|
4217
|
+
const cands = [];
|
|
4218
|
+
for (const n of names) {
|
|
4219
|
+
if (!n.endsWith('.json') || set.has(`${n.slice(0, -5)}.claim`)) continue;
|
|
4220
|
+
const r = readJsonFile(path.join(dir, n), null);
|
|
4221
|
+
if (!r || r.session_id !== sessionId || r.agent_type !== agentType) continue;
|
|
4222
|
+
const at = liveMs(r.t);
|
|
4223
|
+
if (at === null || now - at > LIVE_CLAIM_MS) continue;
|
|
4224
|
+
cands.push({ n, r, at });
|
|
4225
|
+
}
|
|
4226
|
+
cands.sort((a, b) => a.at - b.at);
|
|
4227
|
+
for (const c of cands) {
|
|
4228
|
+
try {
|
|
4229
|
+
fs.writeFileSync(path.join(dir, `${c.n.slice(0, -5)}.claim`), `${agentId}\n`, { flag: 'wx' });
|
|
4230
|
+
return c.r;
|
|
4231
|
+
} catch (_) { /* another start claimed it */ }
|
|
4232
|
+
}
|
|
4233
|
+
return null;
|
|
4234
|
+
}
|
|
4235
|
+
|
|
4236
|
+
/** SubagentStart: register the agent (see the block comment above). Prints nothing. Returns the record or null. */
|
|
4237
|
+
function handleSubagentStart(p, ctx) {
|
|
4238
|
+
const agentId = typeof p.agent_id === 'string' && p.agent_id ? p.agent_id : null;
|
|
4239
|
+
if (!agentId) return null;
|
|
4240
|
+
const type = typeof p.agent_type === 'string' && p.agent_type ? p.agent_type : 'general-purpose';
|
|
4241
|
+
const sid = typeof p.session_id === 'string' && p.session_id ? p.session_id : null;
|
|
4242
|
+
const now = Date.now();
|
|
4243
|
+
const intent = claimIntent(ctx, sid, type, agentId, now);
|
|
4244
|
+
if (!intent && !AGENT_TYPE_ROLES[type]) return null; // internal / untagged subagent: not ours
|
|
4245
|
+
const role = intent ? intent.role : AGENT_TYPE_ROLES[type];
|
|
4246
|
+
const ts = new Date(now).toISOString();
|
|
4247
|
+
const rec = {
|
|
4248
|
+
agent_id: agentId,
|
|
4249
|
+
session_id: sid,
|
|
4250
|
+
task_id: intent ? intent.task_id : null,
|
|
4251
|
+
role,
|
|
4252
|
+
gen: intent ? (Number(intent.gen) || 1) : (role === 'helper' ? 2 : 1),
|
|
4253
|
+
agent_type: type,
|
|
4254
|
+
parent_task_id: intent ? intent.parent_task_id || null : null,
|
|
4255
|
+
start: ts,
|
|
4256
|
+
last_activity: ts,
|
|
4257
|
+
cwd: (intent && intent.worktree) || (typeof p.cwd === 'string' ? p.cwd : null),
|
|
4258
|
+
live_session: intent && typeof intent.live_session === 'number' ? intent.live_session : null,
|
|
4259
|
+
live_machine: intent && typeof intent.live_machine === 'number' ? intent.live_machine : null,
|
|
4260
|
+
// s6: liveness evidence for `reap` / the stuck reminder (the transcript does not exist yet at SubagentStart)
|
|
4261
|
+
transcript: liveTranscriptGuess(p.transcript_path, agentId),
|
|
4262
|
+
session_transcript: typeof p.transcript_path === 'string' && p.transcript_path ? p.transcript_path : null,
|
|
4263
|
+
run_dir: intent && typeof intent.run_dir === 'string' ? intent.run_dir : null,
|
|
4264
|
+
};
|
|
4265
|
+
fs.mkdirSync(liveDir(ctx), { recursive: true });
|
|
4266
|
+
writeFileAtomic(liveFile(ctx, agentId), `${JSON.stringify(rec)}\n`);
|
|
4267
|
+
// core 0.7.3 (s5): one counts-only sample per registered agent (no disk IO, no paths) for `dieu-phoi load report`
|
|
4268
|
+
appendLoadSample(ctx, Object.assign({ ts, src: 'start' }, liveLoadCounts(ctx, now)));
|
|
4269
|
+
return rec;
|
|
4270
|
+
}
|
|
4271
|
+
|
|
4272
|
+
/** core 0.7.3 (s5): data/load.jsonl, one JSON object per line (written by SubagentStart and `dieu-phoi load sample`). */
|
|
4273
|
+
function loadFile(ctx) { return path.join(ctx.paths.data, 'load.jsonl'); }
|
|
4274
|
+
|
|
4275
|
+
/**
|
|
4276
|
+
* Counts for a load sample, read the same way as the cap: per-session exec/plan/verify (gen 1, keyed by sha8 of the
|
|
4277
|
+
* session), every live agent + pending intent, and queue = intents still waiting for their SubagentStart. No IO.
|
|
4278
|
+
*/
|
|
4279
|
+
function liveLoadCounts(ctx, now) {
|
|
4280
|
+
try {
|
|
4281
|
+
const live = readLive(ctx, now);
|
|
4282
|
+
const bySession = {};
|
|
4283
|
+
for (const r of live.agents.concat(live.pending)) {
|
|
4284
|
+
if (!r.session_id || !LIVE_SESSION_ROLES.includes(r.role) || Number(r.gen) > 1) continue;
|
|
4285
|
+
const k = sha8(r.session_id);
|
|
4286
|
+
bySession[k] = (bySession[k] || 0) + 1;
|
|
4287
|
+
}
|
|
4288
|
+
return { live_session_counts: bySession, live_machine: live.agents.length + live.pending.length, queue: live.pending.length };
|
|
4289
|
+
} catch (_) {
|
|
4290
|
+
return { live_session_counts: {}, live_machine: null, queue: null };
|
|
4291
|
+
}
|
|
4292
|
+
}
|
|
4293
|
+
|
|
4294
|
+
/** Appends one sample line to data/load.jsonl. Never throws. */
|
|
4295
|
+
function appendLoadSample(ctx, rec) {
|
|
4296
|
+
try {
|
|
4297
|
+
fs.mkdirSync(ctx.paths.data, { recursive: true });
|
|
4298
|
+
fs.appendFileSync(loadFile(ctx), `${JSON.stringify(rec)}\n`);
|
|
4299
|
+
return true;
|
|
4300
|
+
} catch (e) {
|
|
4301
|
+
logError(ctx.home, e, 'load-sample');
|
|
4302
|
+
return false;
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
/** SubagentStop: the agent is no longer live. */
|
|
4307
|
+
function removeLive(ctx, agentId) {
|
|
4308
|
+
if (!agentId) return false;
|
|
4309
|
+
try { fs.unlinkSync(liveFile(ctx, agentId)); return true; } catch (_) { return false; }
|
|
4310
|
+
}
|
|
4311
|
+
|
|
4312
|
+
/** --watch: refresh last_activity (≤ once per LIVE_TOUCH_MS), fill a missing task from the watch state; create if absent. */
|
|
4313
|
+
function touchLive(ctx, p, agentId, st) {
|
|
4314
|
+
try {
|
|
4315
|
+
const f = liveFile(ctx, agentId);
|
|
4316
|
+
const now = Date.now();
|
|
4317
|
+
const rec = readJsonFile(f, null);
|
|
4318
|
+
if (rec && typeof rec === 'object') {
|
|
4319
|
+
const fill = !rec.task_id && st.task_id;
|
|
4320
|
+
if (!fill && now - (liveMs(rec.last_activity) || 0) < LIVE_TOUCH_MS) return false;
|
|
4321
|
+
rec.last_activity = new Date(now).toISOString();
|
|
4322
|
+
if (!rec.transcript || !fs.existsSync(rec.transcript)) rec.transcript = subagentTranscriptPath(p, agentId) || rec.transcript || null;
|
|
4323
|
+
if (fill) { rec.task_id = st.task_id; if (st.role) rec.role = st.role; if (st.parent_task_id) rec.parent_task_id = st.parent_task_id; }
|
|
4324
|
+
writeFileAtomic(f, `${JSON.stringify(rec)}\n`);
|
|
4325
|
+
return true;
|
|
4326
|
+
}
|
|
4327
|
+
// no SubagentStart record (hook registered after the agent started, or an older setup): register it now
|
|
4328
|
+
const ts = new Date(now).toISOString();
|
|
4329
|
+
fs.mkdirSync(liveDir(ctx), { recursive: true });
|
|
4330
|
+
writeFileAtomic(f, `${JSON.stringify({
|
|
4331
|
+
agent_id: agentId, session_id: typeof p.session_id === 'string' ? p.session_id : null,
|
|
4332
|
+
task_id: st.task_id || null, role: st.role || 'exec', gen: st.role === 'helper' || st.parent_task_id ? 2 : 1,
|
|
4333
|
+
agent_type: null, parent_task_id: st.parent_task_id || null, start: ts, last_activity: ts,
|
|
4334
|
+
cwd: typeof p.cwd === 'string' ? p.cwd : null, live_session: null, live_machine: null, from_watch: true,
|
|
4335
|
+
transcript: subagentTranscriptPath(p, agentId), session_transcript: null, run_dir: null,
|
|
4336
|
+
})}\n`);
|
|
4337
|
+
return true;
|
|
4338
|
+
} catch (e) {
|
|
4339
|
+
logError(ctx.home, e, 'live-touch');
|
|
4340
|
+
return false;
|
|
4341
|
+
}
|
|
4342
|
+
}
|
|
4343
|
+
|
|
4344
|
+
/** Counts for the dispatch event: those recorded when the dispatch was allowed (intent), else the current ones. */
|
|
4345
|
+
function dispatchLiveCounts(ctx, p, tag) {
|
|
4346
|
+
try {
|
|
4347
|
+
if (tag && tag.task_id) {
|
|
4348
|
+
const r = readJsonFile(intentFile(ctx, p.session_id, tag.task_id, tag.role), null);
|
|
4349
|
+
if (r && typeof r.live_session === 'number' && typeof r.live_machine === 'number') {
|
|
4350
|
+
return { live_session: r.live_session, live_machine: r.live_machine };
|
|
4351
|
+
}
|
|
4352
|
+
}
|
|
4353
|
+
const c = liveCounts(readLive(ctx), p.session_id);
|
|
4354
|
+
return { live_session: c.session, live_machine: c.machine };
|
|
4355
|
+
} catch (_) {
|
|
4356
|
+
return { live_session: null, live_machine: null };
|
|
4357
|
+
}
|
|
4358
|
+
}
|
|
4359
|
+
|
|
4360
|
+
function parallelDenyText(scope, c, lim, tag, now) {
|
|
4361
|
+
const list = scope === 'session' ? c.mine : c.all;
|
|
4362
|
+
const shown = list.slice().sort((a, b) => (liveMs(a.start) || liveMs(a.t) || 0) - (liveMs(b.start) || liveMs(b.t) || 0));
|
|
4363
|
+
const names = shown.slice(0, 8).map((r) => liveLabel(r, now, scope === 'machine')).join('; ');
|
|
4364
|
+
const head = scope === 'session'
|
|
4365
|
+
? `dieu-phoi: phiên này đã có ${c.session}/${lim.session} agent exec/plan/verify đang chạy (trần max_parallel_session)`
|
|
4366
|
+
: `dieu-phoi: toàn máy đã có ${c.machine}/${lim.machine} agent dieu-phoi đang chạy, gồm helper (trần max_parallel_machine)`;
|
|
4367
|
+
const first = shown[0] ? (shown[0].task_id || shown[0].agent_id) : null;
|
|
4368
|
+
return `${head} — chưa giao ${tag.role} của task ${tag.task_id}. Đang chạy: ${names}${list.length > 8 ? ` (+${list.length - 8})` : ''}. `
|
|
4369
|
+
+ `Chờ ${first ? `agent ${first}` : 'một agent'} (hoặc agent khác trong danh sách) xong — có thông báo hoàn tất — rồi giao lại đúng lời gọi này; `
|
|
4370
|
+
+ 'trong lúc chờ đừng giao thêm. Agent kẹt thì TaskStop nó trước; agent mồ côi (crash, không có SubagentStop) vẫn giữ chỗ — `dieu-phoi reap` xem, `reap --yes` dọn. '
|
|
4371
|
+
+ 'Chỉ khi người dùng đồng ý vượt trần: thêm `|parallel_ok` vào thẻ (tắt hẳn: max_parallel_session / max_parallel_machine = 0 trong config.json).';
|
|
4372
|
+
}
|
|
4373
|
+
|
|
4374
|
+
/**
|
|
4375
|
+
* Core 0.7.3 (s1): PreToolUse(Agent) of the orchestrating session. A dp dispatch with role exec/plan/verify is denied
|
|
4376
|
+
* (`parallel-cap`) while the session already runs max_parallel_session of them, or the machine (this DIEU_PHOI_HOME)
|
|
4377
|
+
* max_parallel_machine dp agents; `|parallel_ok` passes. An allowed dispatch leaves an intent for SubagentStart, and
|
|
4378
|
+
* at warn_parallel_machine it answers with an additionalContext warning instead of nothing.
|
|
4379
|
+
*/
|
|
4380
|
+
function parallelCapGate(p, ctx, tag) {
|
|
4381
|
+
const now = Date.now();
|
|
4382
|
+
const lim = parallelLimits(ctx);
|
|
4383
|
+
const c = liveCounts(readLive(ctx, now), p.session_id);
|
|
4384
|
+
if (!tag.parallel_ok) {
|
|
4385
|
+
const scope = lim.session > 0 && c.session >= lim.session ? 'session'
|
|
4386
|
+
: (lim.machine > 0 && c.machine >= lim.machine ? 'machine' : null);
|
|
4387
|
+
if (scope) {
|
|
4388
|
+
const ev = logGate(ctx, p, `task:${tag.task_id}`, { task_id: tag.task_id, role: tag.role },
|
|
4389
|
+
{ gate_action: 'deny', reason_code: 'parallel-cap', cap_scope: scope, live_session: c.session, live_machine: c.machine });
|
|
4390
|
+
return { denied: true, event: ev, stdout: preToolDeny(parallelDenyText(scope, c, lim, tag, now)) };
|
|
4391
|
+
}
|
|
4392
|
+
}
|
|
4393
|
+
writeIntent(ctx, p, tag, { live_session: c.session, live_machine: c.machine });
|
|
4394
|
+
if (lim.warn > 0 && c.machine + 1 >= lim.warn) {
|
|
4395
|
+
return {
|
|
4396
|
+
denied: false,
|
|
4397
|
+
stdout: JSON.stringify({
|
|
4398
|
+
hookSpecificOutput: {
|
|
4399
|
+
hookEventName: 'PreToolUse',
|
|
4400
|
+
additionalContext: `dieu-phoi: toàn máy sẽ có ${c.machine + 1} agent dieu-phoi chạy cùng lúc (cảnh báo từ ${lim.warn}, trần ${lim.machine || 'tắt'}); `
|
|
4401
|
+
+ `phiên này ${c.session + 1}/${lim.session || 'tắt'}. Máy chậm dần khi đông agent: ưu tiên chờ bớt trước khi giao tiếp.`,
|
|
4402
|
+
},
|
|
4403
|
+
}),
|
|
4404
|
+
};
|
|
4405
|
+
}
|
|
4406
|
+
return null;
|
|
4407
|
+
}
|
|
4408
|
+
|
|
4409
|
+
/** Counts + per-agent rows for doctor / state. */
|
|
4410
|
+
function liveSummary(ctx, sessionId, now) {
|
|
4411
|
+
const t = typeof now === 'number' ? now : Date.now();
|
|
4412
|
+
const c = liveCounts(readLive(ctx, t), sessionId);
|
|
4413
|
+
return {
|
|
4414
|
+
session: sessionId ? c.session : null,
|
|
4415
|
+
machine: c.machine,
|
|
4416
|
+
limits: parallelLimits(ctx),
|
|
4417
|
+
agents: c.all.map((r) => ({
|
|
4418
|
+
agent_id: r.agent_id || null, task_id: r.task_id || null, role: r.role || null, gen: Number(r.gen) || 1,
|
|
4419
|
+
session: r.session_id ? sha8(r.session_id) : null, age_min: liveAgeMin(r, t), pending: !!r.pending,
|
|
4420
|
+
idle_min: r.pending ? null : Math.max(0, Math.round((t - (liveMs(r.last_activity) || t)) / 60000)),
|
|
4421
|
+
})),
|
|
4422
|
+
labels: c.all.map((r) => liveLabel(r, t, true)),
|
|
4423
|
+
};
|
|
4424
|
+
}
|
|
4425
|
+
|
|
4426
|
+
/*
|
|
4427
|
+
* Core 0.7.3 (s6): stuck / orphaned live agents. done/fail = SubagentStop (removeLive), so whatever is still in data/live
|
|
4428
|
+
* is running, stuck, or orphaned (crashed without SubagentStop). Evidence per record: last_activity (--watch heartbeat),
|
|
4429
|
+
* the subagent transcript mtime, the parent session transcript mtime, and a dp-out-/handoff- file of its task in the run
|
|
4430
|
+
* dir. stuck = idle ≥ stuck_idle_min, OR running ≥ stuck_hard_min, OR its dp-out (STATUS) is on disk and it is still live
|
|
4431
|
+
* stuck_after_status_min later (waiting on a background task). orphan = idle AND the parent session is silent too (or
|
|
4432
|
+
* unknown) — the agent is almost certainly gone and its live file only holds a parallel-cap slot until LIVE_TTL_MS.
|
|
4433
|
+
* The main session is reminded (UserPromptSubmit / PostToolUse(Agent)) to TaskStop + `dieu-phoi reap --yes`; an exec is
|
|
4434
|
+
* reminded about its own helpers through --watch. Nothing here kills or deletes: `reap --yes` does (lib/commands.cjs).
|
|
4435
|
+
*/
|
|
4436
|
+
const STUCK_DEFAULTS = { stuck_idle_min: 20, stuck_hard_min: 120, stuck_after_status_min: 5 };
|
|
4437
|
+
/** One reminder per agent per this long (stored as stuck_notified in its live file). */
|
|
4438
|
+
const STUCK_REMIND_MS = 10 * 60 * 1000;
|
|
4439
|
+
|
|
4440
|
+
/** Thresholds in minutes: env DIEU_PHOI_<KEY> → config.json → STUCK_DEFAULTS. Numbers ≥ 0; 0 turns that rule off. */
|
|
4441
|
+
function stuckLimits(ctx) {
|
|
4442
|
+
const cfg = (ctx && ctx.config) || {};
|
|
4443
|
+
const pick = (key) => {
|
|
4444
|
+
for (const v of [process.env[`DIEU_PHOI_${key.toUpperCase()}`], cfg[key]]) {
|
|
4445
|
+
if (v === undefined || v === null || v === '') continue;
|
|
4446
|
+
const n = Number(v);
|
|
4447
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
4448
|
+
}
|
|
4449
|
+
return STUCK_DEFAULTS[key];
|
|
4450
|
+
};
|
|
4451
|
+
return { idle: pick('stuck_idle_min'), hard: pick('stuck_hard_min'), after_status: pick('stuck_after_status_min') };
|
|
4452
|
+
}
|
|
4453
|
+
|
|
4454
|
+
function mtimeMs(f) {
|
|
4455
|
+
if (typeof f !== 'string' || !f) return null;
|
|
4456
|
+
try { return fs.statSync(f).mtimeMs; } catch (_) { return null; }
|
|
4457
|
+
}
|
|
4458
|
+
|
|
4459
|
+
/** Newest dp-out-<task>-* / handoff-<task>* .md in the run dir written after the agent started, else null. */
|
|
4460
|
+
function statusFileOf(rec, startMs) {
|
|
4461
|
+
if (!rec.run_dir || !rec.task_id) return null;
|
|
4462
|
+
const t = safeName(rec.task_id);
|
|
4463
|
+
let names = [];
|
|
4464
|
+
try { names = fs.readdirSync(rec.run_dir); } catch (_) { return null; }
|
|
4465
|
+
let best = null;
|
|
4466
|
+
for (const n of names) {
|
|
4467
|
+
if (!n.endsWith('.md') || !(n.startsWith(`dp-out-${t}-`) || n.startsWith(`handoff-${t}`))) continue;
|
|
4468
|
+
if (rec.role && n.startsWith(`dp-out-${t}-`) && !n.startsWith(`dp-out-${t}-${rec.role}`)) continue; // exec vs verify of one task
|
|
4469
|
+
const m = mtimeMs(path.join(rec.run_dir, n));
|
|
4470
|
+
if (m !== null && m >= (startMs || 0) - 1000 && (!best || m > best.mtime)) best = { file: path.join(rec.run_dir, n), mtime: m };
|
|
4471
|
+
}
|
|
4472
|
+
return best;
|
|
4473
|
+
}
|
|
4474
|
+
|
|
4475
|
+
/** Verdict on one live record: { state: ok|stuck|orphan, reasons[], idle_min, age_min, … }. Never throws. */
|
|
4476
|
+
function assessLive(rec, now, lim) {
|
|
4477
|
+
const startMs = liveMs(rec.start) || liveMs(rec.t) || now;
|
|
4478
|
+
const tMtime = mtimeMs(rec.transcript);
|
|
4479
|
+
const last = Math.max(liveMs(rec.last_activity) || 0, startMs, tMtime || 0);
|
|
4480
|
+
const idleMin = Math.max(0, (now - last) / 60000);
|
|
4481
|
+
const ageMin = Math.max(0, (now - startMs) / 60000);
|
|
4482
|
+
const reasons = [];
|
|
4483
|
+
if (lim.idle > 0 && idleMin >= lim.idle) reasons.push('idle');
|
|
4484
|
+
if (lim.hard > 0 && ageMin >= lim.hard) reasons.push('hard');
|
|
4485
|
+
const status = lim.after_status > 0 ? statusFileOf(rec, startMs) : null;
|
|
4486
|
+
if (status && (now - status.mtime) / 60000 >= lim.after_status) reasons.push('after-status');
|
|
4487
|
+
let state = reasons.length ? 'stuck' : 'ok';
|
|
4488
|
+
if (reasons.includes('idle')) {
|
|
4489
|
+
const sMtime = mtimeMs(rec.session_transcript);
|
|
4490
|
+
if (sMtime === null || (now - sMtime) / 60000 >= lim.idle) state = 'orphan';
|
|
4491
|
+
}
|
|
4492
|
+
return {
|
|
4493
|
+
agent_id: rec.agent_id || null, task_id: rec.task_id || null, role: rec.role || null, gen: Number(rec.gen) || 1,
|
|
4494
|
+
parent_task_id: rec.parent_task_id || null, session_id: rec.session_id || null, cwd: rec.cwd || null,
|
|
4495
|
+
start_ms: startMs, idle_min: Math.round(idleMin), age_min: Math.round(ageMin), reasons, state,
|
|
4496
|
+
transcript_seen: tMtime !== null, status_file: status ? status.file : null,
|
|
4497
|
+
status_min: status ? Math.round((now - status.mtime) / 60000) : null, notified: liveMs(rec.stuck_notified),
|
|
4498
|
+
};
|
|
4499
|
+
}
|
|
4500
|
+
|
|
4501
|
+
/** Stuck + orphaned live agents (oldest first). `sessionId` filters to one session, `parentTask` to one exec's helpers. */
|
|
4502
|
+
function stuckAgents(ctx, o) {
|
|
4503
|
+
const opt = o || {};
|
|
4504
|
+
const now = typeof opt.now === 'number' ? opt.now : Date.now();
|
|
4505
|
+
const lim = opt.limits || stuckLimits(ctx);
|
|
4506
|
+
const out = [];
|
|
4507
|
+
for (const rec of readLive(ctx, now).agents) {
|
|
4508
|
+
if (opt.sessionId && rec.session_id !== opt.sessionId) continue;
|
|
4509
|
+
if (opt.parentTask && rec.parent_task_id !== opt.parentTask) continue;
|
|
4510
|
+
const a = assessLive(rec, now, lim);
|
|
4511
|
+
if (a.state !== 'ok') out.push(a);
|
|
4512
|
+
}
|
|
4513
|
+
return out.sort((a, b) => a.start_ms - b.start_ms);
|
|
4514
|
+
}
|
|
4515
|
+
|
|
4516
|
+
const STUCK_REASON_TEXT = { idle: 'im lặng', hard: 'chạy quá lâu', 'after-status': 'đã có dp-out/STATUS mà chưa dừng' };
|
|
4517
|
+
|
|
4518
|
+
function stuckReasonText(a) {
|
|
4519
|
+
const bits = a.reasons.map((r) => (r === 'after-status' && a.status_min !== null ? `${STUCK_REASON_TEXT[r]} ${a.status_min} phút` : STUCK_REASON_TEXT[r] || r));
|
|
4520
|
+
if (a.state === 'orphan') bits.push('có vẻ mồ côi — phiên cũng im');
|
|
4521
|
+
return bits.join(', ');
|
|
4522
|
+
}
|
|
4523
|
+
|
|
4524
|
+
/** One reminder line per agent: what is stuck and the exact next step. */
|
|
4525
|
+
function stuckLine(a, forExec) {
|
|
4526
|
+
const who = `agent ${a.agent_id} task ${a.task_id || '?'} (${a.role || '?'}${a.gen > 1 ? `, gen ${a.gen}` : ''})`;
|
|
4527
|
+
const n = a.reasons.includes('idle') ? a.idle_min : a.age_min;
|
|
4528
|
+
const wt = a.cwd ? ` Worktree ${toPosix(a.cwd)} giữ nguyên (còn công việc).` : '';
|
|
4529
|
+
const stop = !forExec && a.gen > 1 && a.parent_task_id
|
|
4530
|
+
? `helper của ${a.parent_task_id}: TaskStop ${a.agent_id} (hoặc nhắc exec cha dừng nó)`
|
|
4531
|
+
: `TaskStop ${a.agent_id}`;
|
|
4532
|
+
return `dieu-phoi: ${who} kẹt ${n} phút [${stuckReasonText(a)}] → ${stop}; xong rồi \`dieu-phoi reap --yes\` (xem trước: \`dieu-phoi reap\`).${wt}`;
|
|
4533
|
+
}
|
|
4534
|
+
|
|
4535
|
+
/**
|
|
4536
|
+
* Reminder text for stuck agents not reminded in the last STUCK_REMIND_MS, or null. Marks them (stuck_notified) so a
|
|
4537
|
+
* busy session is not told the same thing on every prompt. Never throws.
|
|
4538
|
+
*/
|
|
4539
|
+
function stuckReminder(ctx, o) {
|
|
4540
|
+
try {
|
|
4541
|
+
const opt = o || {};
|
|
4542
|
+
const now = typeof opt.now === 'number' ? opt.now : Date.now();
|
|
4543
|
+
const due = stuckAgents(ctx, Object.assign({}, opt, { now })).filter((a) => !(a.notified && now - a.notified < STUCK_REMIND_MS));
|
|
4544
|
+
if (!due.length) return null;
|
|
4545
|
+
for (const a of due) {
|
|
4546
|
+
const f = liveFile(ctx, a.agent_id);
|
|
4547
|
+
const rec = readJsonFile(f, null);
|
|
4548
|
+
if (rec && typeof rec === 'object') { rec.stuck_notified = new Date(now).toISOString(); try { writeFileAtomic(f, `${JSON.stringify(rec)}\n`); } catch (_) { /* best effort */ } }
|
|
4549
|
+
}
|
|
4550
|
+
return due.slice(0, 6).map((a) => stuckLine(a, !!opt.parentTask)).join('\n') + (due.length > 6 ? `\n(+${due.length - 6} agent kẹt khác: \`dieu-phoi reap\`)` : '');
|
|
4551
|
+
} catch (e) {
|
|
4552
|
+
logError(ctx.home, e, 'stuck-remind');
|
|
4553
|
+
return null;
|
|
4554
|
+
}
|
|
4555
|
+
}
|
|
4556
|
+
|
|
3945
4557
|
/**
|
|
3946
4558
|
* Core 0.7.1 (C1): global PreToolUse(Agent) in the orchestrating session. An exec dispatch of a task that already
|
|
3947
4559
|
* handed off HANDOFF_CHAIN_DENY times is denied (the 4th, 5th… segment of one chain cost $15-36 per task in session
|
|
@@ -3953,11 +4565,12 @@ function handleChainGate(p, ctx) {
|
|
|
3953
4565
|
const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
|
|
3954
4566
|
const tag = resolveTag(ti.prompt, knownTaskTypes(ctx));
|
|
3955
4567
|
if (!tag || !tag.task_id || !isValidTaskId(tag.task_id)) return null;
|
|
3956
|
-
|
|
3957
|
-
if (tag.role
|
|
3958
|
-
|
|
3959
|
-
if (
|
|
3960
|
-
|
|
4568
|
+
let r = null;
|
|
4569
|
+
if (tag.role === 'verify') r = verifyOrphanGate(p, ctx, tag);
|
|
4570
|
+
else if (tag.role === 'exec') r = (tag.chain_ok ? null : chainGateDenial(p, ctx, tag)) || sessionCapGate(p, ctx, tag);
|
|
4571
|
+
if (r) return r;
|
|
4572
|
+
// core 0.7.3 (s1): parallel cap last, so a dispatch another gate denies never leaves an intent behind
|
|
4573
|
+
return LIVE_SESSION_ROLES.includes(tag.role) ? parallelCapGate(p, ctx, tag) : null;
|
|
3961
4574
|
}
|
|
3962
4575
|
|
|
3963
4576
|
function preToolDeny(reason) {
|
|
@@ -4096,6 +4709,12 @@ function handleGate(p, ctx) {
|
|
|
4096
4709
|
}),
|
|
4097
4710
|
});
|
|
4098
4711
|
if (why.length) return deny(why, codes.join('+'));
|
|
4712
|
+
// core 0.7.3 (s1): helpers count towards the machine cap
|
|
4713
|
+
const lim = parallelLimits(ctx);
|
|
4714
|
+
const lc = liveCounts(readLive(ctx), p.session_id);
|
|
4715
|
+
if (!tag.parallel_ok && lim.machine > 0 && lc.machine >= lim.machine) {
|
|
4716
|
+
return deny([`toàn máy đã có ${lc.machine}/${lim.machine} agent dieu-phoi đang chạy (trần max_parallel_machine) — tự làm phần này, đừng giao helper`], 'parallel-cap');
|
|
4717
|
+
}
|
|
4099
4718
|
const root = path.join(ctx.paths.data, 'gate');
|
|
4100
4719
|
fs.mkdirSync(root, { recursive: true });
|
|
4101
4720
|
pruneGate(root);
|
|
@@ -4103,6 +4722,13 @@ function handleGate(p, ctx) {
|
|
|
4103
4722
|
const slot = gateReserve(dir, tag.task_id);
|
|
4104
4723
|
if (!slot) return deny([`đã giao đủ ${GATE_MAX_CHILDREN} helper cho bước này — tự làm phần còn lại`], 'max-children');
|
|
4105
4724
|
const children = gateSlots(dir).length;
|
|
4725
|
+
const parentLive = readJsonFile(liveFile(ctx, agentId), null);
|
|
4726
|
+
writeIntent(ctx, p, tag, {
|
|
4727
|
+
gen: (parentLive && Number(parentLive.gen) ? Number(parentLive.gen) : 1) + 1,
|
|
4728
|
+
parent_task_id: tag.parent_task_id || st.task_id || null,
|
|
4729
|
+
worktree: (parentLive && parentLive.cwd) || null,
|
|
4730
|
+
live_session: lc.session, live_machine: lc.machine,
|
|
4731
|
+
});
|
|
4106
4732
|
return { denied: false, slot, children, event: log('allow', { slot, children }) };
|
|
4107
4733
|
}
|
|
4108
4734
|
|
|
@@ -4193,11 +4819,16 @@ function runHook(opts) {
|
|
|
4193
4819
|
if (o.mode === 'watch') return handleWatch(p, ctx, o); // { stdout } or null
|
|
4194
4820
|
if (o.mode === 'gate') return handleGate(p, ctx, o); // { stdout } or null
|
|
4195
4821
|
if (o.mode === 'gate-release') return handleGateRelease(p, ctx); // { released } or null, prints nothing
|
|
4196
|
-
if (name === 'PreToolUse') return handleChainGate(p, ctx); // { stdout } (deny) or null
|
|
4822
|
+
if (name === 'PreToolUse') return handleChainGate(p, ctx); // { stdout } (deny / warn) or null
|
|
4823
|
+
if (name === 'SubagentStart') return handleSubagentStart(p, ctx); // live record or null, prints nothing
|
|
4197
4824
|
if (name === 'SessionStart') return handleSessionStart(p, ctx); // { stdout } or null, never an event
|
|
4198
4825
|
if (name === 'UserPromptSubmit') return handleUserPromptSubmit(p, ctx); // { stdout } or null
|
|
4199
4826
|
if (name === 'SubagentStop') {
|
|
4200
|
-
|
|
4827
|
+
// s7: keep the live record (worktree, start) for the guarded leftover kill before the file goes
|
|
4828
|
+
let live = null;
|
|
4829
|
+
try { live = typeof p.agent_id === 'string' && p.agent_id ? readJsonFile(liveFile(ctx, p.agent_id), null) : null; } catch (_) { live = null; }
|
|
4830
|
+
try { removeLive(ctx, p.agent_id); } catch (e) { logError(home, e, 'live-remove'); }
|
|
4831
|
+
const r = handleSubagentStop(p, ctx, { live });
|
|
4201
4832
|
if (r && typeof r.stdout === 'string') return r; // s15: block decision, printed as the hook's JSON answer
|
|
4202
4833
|
ev = r;
|
|
4203
4834
|
} else if (name === 'PostToolUse' || (!name && p.tool_name)) ev = handlePostToolUse(p, ctx);
|
|
@@ -4363,9 +4994,11 @@ module.exports = {
|
|
|
4363
4994
|
RESULT_ROLES, BLOCKED_RESULT_NAME_RE, resultFileName, findResultFiles, resultRunDir, finalAssistantText, persistInlineResult, orchestratorUsage, countCompactions, orchCacheFile, findSessionTranscript, SESSION_ID_RE, HOOK_COLD_MAX_BYTES, SESSION_CAP_TOKENS, SESSION_CAP_COMPACTIONS, appendEvent, EVENTS_LOCK_WAIT_MS, eventLinesFrom, readEventLines, readEvents, readEventsBuffer, archiveFiles, readArchiveLines,
|
|
4364
4995
|
loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
|
|
4365
4996
|
HANDOFF_MARKER, HANDOFF_THRESHOLDS, watchThresholds, GATE_MAX_CHILDREN, HELPER_AGENT_TYPE, watchAgentId, subagentTranscriptPath, readHeadTag, lastContextTokens, watchState, handoffMessage, handleWatch, handleGate, handleGateRelease, gateDir, gateSlots, gateReserve, gateConfirm, GATE_PENDING_MS,
|
|
4997
|
+
liveLoadCounts, appendLoadSample, loadFile, LIVE_TTL_MS, LIVE_TOUCH_MS, LIVE_PENDING_MS, PARALLEL_DEFAULTS, LIVE_SESSION_ROLES, AGENT_TYPE_ROLES, liveDir, liveFile, livePendingDir, intentFile, parallelLimits, readLive, liveCounts, liveSummary, briefWorktree, writeIntent, claimIntent, handleSubagentStart, removeLive, touchLive, dispatchLiveCounts, parallelCapGate,
|
|
4998
|
+
STUCK_DEFAULTS, STUCK_REMIND_MS, stuckLimits, assessLive, stuckAgents, stuckReminder, stuckLine, stuckReasonText, statusFileOf, briefRunDir, liveTranscriptGuess,
|
|
4366
4999
|
globMatch, pathspecOf, diffShas, dirtyFiles, computeFreshness, addDirty, indexStamp, freshnessCacheFile, readFreshCache, cachedFor, freshCounts, knowledgeSummary, shortFileList, readStoreIndex, relatedStatus, startKnowledge, deltaHash, deltaRepoOf, isBareRepoName, storeCatalog, storeByName, scanUnmergedDeltas, scanUnmergedDeltasFor, unmergedDeltaLines, hasUnmergedDeltas, deltaTagOf, deltaClassifier, dispatchRun, DELTA_WARN_MS, DELTA_WINDOW_MS, FRESH_CACHE_MAX, DIFF_TIMEOUT_MS, HOOK_DIFF_TIMEOUT_MS, DIRTY_TIMEOUT_MS,
|
|
4367
5000
|
projectsDir, findGit, originUrlFromConfig, normalizeRemoteUrl, resolveProject, readHead, repoFromBrief, projectFields, nativePath, isStoreDirName, aliasesFile, loadAliases, keyDirName,
|
|
4368
|
-
extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, openBackgroundTasks, scanLeftoverProcs, otherAgentsRunning, maybeSpawnSync, runHook, runSync,
|
|
5001
|
+
extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, openBackgroundTasks, scanLeftoverProcs, otherAgentsRunning, mainShellSince, ownWorktree, maybeSpawnSync, runHook, runSync,
|
|
4369
5002
|
};
|
|
4370
5003
|
|
|
4371
5004
|
if (require.main === module) {
|