@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/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.2';
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
- if (!isDieuPhoiPrompt(p.prompt)) return null;
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: formatStartBlock(info) },
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
- try { scanLeftoverProcs(ctx, { agentId, taskId: tag.task_id, lines }); } catch (e) { logError(ctx.home, e, 'leftover-procs'); }
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}. They are only reported, unless config kill_leftovers is true AND no other dp agent ran in the
3445
- * meantime (then the leftover trees are killed). deps = { procs, now, selfPid, events } for tests. Returns the event or null.
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 killOn = ctx.config.kill_leftovers === true;
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
- if (killOn) {
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: killOn, killed,
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
- if (tag.role === 'verify') return verifyOrphanGate(p, ctx, tag);
3957
- if (tag.role !== 'exec') return null;
3958
- const chain = tag.chain_ok ? null : chainGateDenial(p, ctx, tag);
3959
- if (chain) return chain;
3960
- return sessionCapGate(p, ctx, tag);
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
- const r = handleSubagentStop(p, ctx);
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) {