@kienntpixon/dieu-phoi 0.7.2 → 0.7.4

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
@@ -6,7 +6,8 @@
6
6
  * This file is copied verbatim to ~/.claude/dieu-phoi/bin/hook.cjs by `dieu-phoi setup`
7
7
  * and run by Claude Code on PostToolUse(Agent), SubagentStop, SessionStart(compact) and UserPromptSubmit; the dp-* agent
8
8
  * files (`dieu-phoi setup` -> ~/.claude/agents) also run it inside those subagents as `--watch` (PostToolUse, context
9
- * handoff watchdog), `--gate` (PreToolUse(Agent), gen-2 helper rules) and `--gate-release`. It must stay
9
+ * handoff watchdog), `--gate` (PreToolUse(Agent), gen-2 helper rules), `--gate-bash` (PreToolUse(Bash), no
10
+ * `knowledge review|merge`) and `--gate-release`. It must stay
10
11
  * self-contained: Node built-ins only, no requires of other package files.
11
12
  *
12
13
  * Hook rules: stdout only for the JSON of SessionStart(compact) / UserPromptSubmit / a PostToolUse(Agent) warning
@@ -19,6 +20,7 @@
19
20
  * node hook.cjs --watch -> dp subagent PostToolUse: context watchdog (additionalContext JSON or nothing)
20
21
  * node hook.cjs --gate -> dp-exec PreToolUse(Agent): allow / deny a gen-2 helper spawn
21
22
  * node hook.cjs --gate-release -> dp-exec PostToolUseFailure(Agent): give a failed spawn's helper slot back
23
+ * node hook.cjs --gate-bash -> dp-* PreToolUse(Bash|PowerShell): deny `knowledge review|merge` in a subagent
22
24
  */
23
25
 
24
26
  const fs = require('fs');
@@ -26,7 +28,7 @@ const path = require('path');
26
28
  const os = require('os');
27
29
  const crypto = require('crypto');
28
30
 
29
- const CORE_VERSION = '0.7.2';
31
+ const CORE_VERSION = '0.7.4';
30
32
  const SCHEMA = 1;
31
33
  const TASK_TYPES = [
32
34
  'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
@@ -274,6 +276,8 @@ function parseTag(text) {
274
276
  tag.chain_ok = true; // core 0.7.1 (C1): the user agreed to continue a long handoff chain
275
277
  } else if (p === 'session_ok') {
276
278
  tag.session_ok = true; // core 0.7.2 (K5): the user agreed to open a new root in an oversized session
279
+ } else if (p === 'parallel_ok') {
280
+ tag.parallel_ok = true; // core 0.7.3 (s1): the user agreed to dispatch past the parallel cap
277
281
  } else if (p === 'batch') {
278
282
  tag.batch = true; // core 0.7.1 (C5): the one batched verifier of a fast request
279
283
  }
@@ -1170,8 +1174,8 @@ function buildSessionState(events, sessionId, opts) {
1170
1174
  if (!seenTask.has(id) && !discarded.has(id)) { seenTask.add(id); taskIds.push(id); }
1171
1175
  }
1172
1176
  const modeEvents = new Map();
1173
- for (const ev of events) if (ev && ev.kind === 'mode' && isMode(ev.mode) && typeof ev.root_task_id === 'string') modeEvents.set(ev.root_task_id, ev);
1174
- const modeOfRoot = (root) => (modeEvents.has(root) ? modeEvents.get(root).mode : DEFAULT_MODE);
1177
+ for (const ev of events) if (ev && ev.kind === 'mode' && normMode(ev.mode) && typeof ev.root_task_id === 'string') modeEvents.set(ev.root_task_id, ev);
1178
+ const modeOfRoot = (root) => (modeEvents.has(root) ? normMode(modeEvents.get(root).mode) : DEFAULT_MODE);
1175
1179
  const per = new Map(taskIds.map((id) => [id, { dispatches: [], user: [], steers: [], overrides: [], agentIds: new Set() }]));
1176
1180
  events.forEach((ev, i) => {
1177
1181
  if (!ev || typeof ev !== 'object') return;
@@ -1223,7 +1227,7 @@ function buildSessionState(events, sessionId, opts) {
1223
1227
  const pApproved = pov && evMs(pov) > (Date.parse(ps.plan.approved_at) || 0) ? pov.chosen_tier || null : null;
1224
1228
  tasks.push(Object.assign({
1225
1229
  task_id: id, task_type: ps.step.type || null, gate: 2, pending: true, verify_only: false, round: 0,
1226
- mode: isMode(ps.step.mode) ? ps.step.mode : modeOfRoot(ps.plan.root_task_id),
1230
+ mode: normMode(ps.step.mode) || modeOfRoot(ps.plan.root_task_id),
1227
1231
  }, planInfo, {
1228
1232
  approved_tier: pApproved || ps.step.tier || null, last_tier: null, attempts: [], user_verdict: null, last_steer: null,
1229
1233
  run_dir: planDir, last_ts: null,
@@ -1281,7 +1285,7 @@ function buildSessionState(events, sessionId, opts) {
1281
1285
  pending: false,
1282
1286
  verify_only: attempts.length > 0 && attempts.every((a) => a.role === 'verify'),
1283
1287
  round,
1284
- mode: dispatchModeFromEvents(t.dispatches, id) || (ps && isMode(ps.step.mode) ? ps.step.mode : modeOfRoot(plan ? plan.root_task_id : id)),
1288
+ mode: dispatchModeFromEvents(t.dispatches, id) || (ps && normMode(ps.step.mode)) || modeOfRoot(plan ? plan.root_task_id : id),
1285
1289
  }, planInfo, {
1286
1290
  approved_tier: approved,
1287
1291
  last_tier: lastExecAny ? tierOf(lastExecAny.model, lastExecAny.effort) : null,
@@ -1299,12 +1303,9 @@ function buildSessionState(events, sessionId, opts) {
1299
1303
  for (const [root, ev] of modeEvents) if (ev.session_id === sessionId || ev.session === sidHash) modeRoots.add(root);
1300
1304
  const modes = Array.from(modeRoots).map((root) => {
1301
1305
  const ev = modeEvents.get(root);
1302
- const mode = ev ? ev.mode : DEFAULT_MODE;
1303
- // core 0.7.0 (s2): suggestion only — the mode itself never changes here
1304
- const sug = modeSuggestion(events, root, { mode, taskIds: rootTaskIds(allPlans, root) });
1306
+ const mode = ev ? normMode(ev.mode) : DEFAULT_MODE;
1305
1307
  return {
1306
1308
  root_task_id: root, mode, since: ev ? ev.ts : null, source: ev ? ev.source || null : 'default', reason_code: ev ? ev.reason_code || null : null,
1307
- mode_suggest: sug.mode_suggest, mode_suggest_reason: sug.mode_suggest_reason,
1308
1309
  };
1309
1310
  });
1310
1311
  const orphans = findOrphanAgents(events, taskIds.filter((id) => !discarded.has(id)), { nowMs: o.nowMs });
@@ -1468,14 +1469,7 @@ function compactSystemMessage(state) {
1468
1469
  function modesLine(state) {
1469
1470
  const ms = Array.isArray(state.modes) ? state.modes : [];
1470
1471
  if (!ms.length) return '';
1471
- const sug = ms.slice(-6).filter((m) => m.mode_suggest).map((m) => `\n${modeSuggestLine(m.root_task_id, m.mode, m.mode_suggest_reason)}`).join('');
1472
- return `\nMode (theo yêu cầu, đổi bằng \`dieu-phoi mode --root <id> <mode>\`): ${ms.slice(-6).map((m) => `${m.root_task_id}=${m.mode}`).join(', ')}.${sug}`;
1473
- }
1474
-
1475
- /** One line telling the orchestrator to ASK the user about returning to normal (never switch by itself). */
1476
- function modeSuggestLine(root, mode, reason) {
1477
- return `GỢI Ý mode: ${root ? `${root} đang ${mode}` : `đang ${mode}`} — hỏi người dùng có muốn quay về normal không (${reason}); `
1478
- + `chỉ đổi khi người dùng đồng ý: \`dieu-phoi mode --root ${root || '<root>'} normal\`. KHÔNG tự đổi mode.`;
1472
+ return `\nMode (theo yêu cầu, đổi bằng \`dieu-phoi mode --root <id> <mode>\`): ${ms.slice(-6).map((m) => `${m.root_task_id}=${m.mode}`).join(', ')}.`;
1479
1473
  }
1480
1474
 
1481
1475
  function formatSessionState(state, cap) {
@@ -1693,9 +1687,10 @@ function orchLessonLines(info) {
1693
1687
  return ls.length ? ['bài học cho điều phối viên (profile/lessons.md, [orch]):'].concat(ls.map((l) => ` - ${l}`)) : [];
1694
1688
  }
1695
1689
 
1696
- /** Gate-1 mode line: the chosen mode, or the reminder to ASK the user (default normal). */
1690
+ /** Gate-1 mode line: only when a mode was chosen (`start --mode fast`); '' otherwise (no mode question, default normal). */
1697
1691
  function modeLine(info) {
1698
- return info && isMode(info.mode) ? `mode: ${info.mode} (đã ghi cho root ${info.id}; đổi: dieu-phoi mode --root ${info.id} <mode>)` : MODE_ASK_LINE;
1692
+ const m = info && normMode(info.mode);
1693
+ return m ? `mode: ${m} (đã ghi cho root ${info.id}; đổi: dieu-phoi mode --root ${info.id} <mode>)` : '';
1699
1694
  }
1700
1695
 
1701
1696
  function formatStartBlock(info) {
@@ -1710,16 +1705,23 @@ function formatStartBlock(info) {
1710
1705
  ...orchLessonLines(info),
1711
1706
  ...knowledgeLines(info),
1712
1707
  'Dùng khối này cho yêu cầu MỚI ở cổng 1 (không dùng nếu đang giữa một lần điều phối). Không chạy `dieu-phoi doctor` / `new-id` / mkdir: lượt đầu tiên là Agent scout + câu hỏi cổng 1 cho người dùng.',
1713
- ].join('\n');
1708
+ ].filter((l) => l !== '').join('\n');
1714
1709
  }
1715
1710
 
1716
1711
  /** UserPromptSubmit: on a dieu-phoi prompt, pre-create the run and inject id/run dir/health/brief. */
1717
1712
  function handleUserPromptSubmit(p, ctx) {
1718
- if (!isDieuPhoiPrompt(p.prompt)) return null;
1713
+ // s6: stuck/orphaned agents of this session → TaskStop + reap reminder, on any prompt (not only dieu-phoi ones)
1714
+ const stuck = typeof p.session_id === 'string' && p.session_id && !watchAgentId(p) ? stuckReminder(ctx, { sessionId: p.session_id }) : null;
1715
+ if (!isDieuPhoiPrompt(p.prompt)) {
1716
+ if (!stuck) return null;
1717
+ return { stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: stuck } }), stuck: true };
1718
+ }
1719
1719
  const info = startInfo(ctx, typeof p.cwd === 'string' ? p.cwd : null);
1720
+ const text = stuck ? `${formatStartBlock(info)}
1721
+ ${stuck}` : formatStartBlock(info);
1720
1722
  return {
1721
1723
  stdout: JSON.stringify({
1722
- hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: formatStartBlock(info) },
1724
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text },
1723
1725
  }),
1724
1726
  info,
1725
1727
  };
@@ -1861,29 +1863,36 @@ function handoffChainMessage(taskId, n) {
1861
1863
  return `dieu-phoi: task ${taskId} đã handoff ${n} lần (cảnh báo từ ${HANDOFF_CHAIN_WARN}; từ ${HANDOFF_CHAIN_DENY} hook chặn lượt exec tiếp nối, trừ thẻ có \`chain_ok\` khi người dùng đồng ý). Đừng nối tiếp thêm: nếu agent vừa giao là agent tiếp nối, cân nhắc dừng nó và giao planner (dp-scout) đọc handoff-${taskId}.md rồi chia phần còn lại thành bước mới (task_id mới từ \`dieu-phoi new-id\`).`;
1862
1864
  }
1863
1865
 
1864
- // ------------------------------------------------------------------ core 0.7.0: mode per request (fast | normal | strict)
1866
+ // ------------------------------------------------------------------ core 0.7.0: mode per request (fast | normal; 0.7.4: strict removed)
1865
1867
  //
1866
1868
  // A request (root_task_id) has a mode; the user picks it at gate 1 (`start --mode`, `plan-table` file, or
1867
1869
  // `dieu-phoi mode --root R <mode>` at any time). Every change is one local-only event kind=mode. A step not yet
1868
1870
  // dispatched follows the root's current mode (or its own `mode` override in the approved plan); a step already
1869
1871
  // dispatched keeps the mode its first exec dispatch recorded. Nothing here ever changes a mode by itself.
1870
1872
 
1871
- const MODES = ['fast', 'normal', 'strict'];
1873
+ const MODES = ['fast', 'normal'];
1872
1874
  const DEFAULT_MODE = 'normal';
1875
+ /** Removed in 0.7.4: old events/plans/configs may still carry it; every reader treats it as normal. */
1876
+ const LEGACY_MODE = 'strict';
1873
1877
  /** 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']);
1875
- 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
- + 'đặt bằng `dieu-phoi mode --root <root_task_id> <mode>`';
1877
-
1878
+ const LOCAL_ONLY_KINDS = new Set(['mode', 'knowledge_merge', 'leftover', 'worktree', 'reap', 'reap_orphans']);
1879
+ /** A mode the user may choose now (fast | normal). */
1878
1880
  function isMode(m) {
1879
1881
  return typeof m === 'string' && MODES.includes(m);
1880
1882
  }
1881
1883
 
1884
+ /** Mode as stored/read: fast | normal; legacy 'strict' reads as normal; anything else null. */
1885
+ function normMode(m) {
1886
+ if (typeof m !== 'string') return null;
1887
+ if (m === LEGACY_MODE) return DEFAULT_MODE;
1888
+ return MODES.includes(m) ? m : null;
1889
+ }
1890
+
1882
1891
  /** Last mode event of `root` in an events array (null when none). */
1883
1892
  function rootModeFromEvents(events, root) {
1884
1893
  let last = null;
1885
1894
  for (const ev of events) {
1886
- if (ev && ev.kind === 'mode' && ev.root_task_id === root && isMode(ev.mode)) last = ev;
1895
+ if (ev && ev.kind === 'mode' && ev.root_task_id === root && normMode(ev.mode)) last = ev.mode === normMode(ev.mode) ? ev : Object.assign({}, ev, { mode: normMode(ev.mode) });
1887
1896
  }
1888
1897
  return last;
1889
1898
  }
@@ -1901,7 +1910,7 @@ function rootModeFromText(txt, root) {
1901
1910
  if (line.includes(rootNeedle)) {
1902
1911
  try {
1903
1912
  const ev = JSON.parse(line);
1904
- if (ev && ev.kind === 'mode' && isMode(ev.mode)) last = ev;
1913
+ if (ev && ev.kind === 'mode' && normMode(ev.mode)) last = ev.mode === normMode(ev.mode) ? ev : Object.assign({}, ev, { mode: normMode(ev.mode) });
1905
1914
  } catch (_) { /* corrupt line */ }
1906
1915
  }
1907
1916
  i = end < 0 ? -1 : txt.indexOf(needle, end);
@@ -1912,7 +1921,7 @@ function rootModeFromText(txt, root) {
1912
1921
  /** Mode the first exec dispatch of `taskId` recorded (a dispatched step keeps its mode), from an events array. */
1913
1922
  function dispatchModeFromEvents(events, taskId) {
1914
1923
  for (const ev of events) {
1915
- if (ev && ev.kind === 'dispatch' && ev.task_id === taskId && (ev.role || 'exec') === 'exec' && isMode(ev.mode)) return ev.mode;
1924
+ if (ev && ev.kind === 'dispatch' && ev.task_id === taskId && (ev.role || 'exec') === 'exec' && normMode(ev.mode)) return normMode(ev.mode);
1916
1925
  }
1917
1926
  return null;
1918
1927
  }
@@ -1926,7 +1935,7 @@ function dispatchModeFromText(txt, taskId) {
1926
1935
  const line = txt.slice(start, end < 0 ? undefined : end);
1927
1936
  if (line.includes('"kind":"dispatch"') && !line.includes('"role":"verify"') && !line.includes('"role":"plan"') && !line.includes('"role":"helper"')) {
1928
1937
  const m = /"mode":"(fast|normal|strict)"/.exec(line);
1929
- if (m) return m[1];
1938
+ if (m) return normMode(m[1]);
1930
1939
  }
1931
1940
  i = end < 0 ? -1 : txt.indexOf(needle, end);
1932
1941
  }
@@ -1957,7 +1966,7 @@ function taskModeInfo(ctx, taskId, events) {
1957
1966
  const locked = events ? dispatchModeFromEvents(events, taskId) : dispatchModeFromText(txt, taskId);
1958
1967
  if (locked) return { mode: locked, source: 'dispatched', root };
1959
1968
  }
1960
- if (pl && pl.step && isMode(pl.step.mode)) return { mode: pl.step.mode, source: 'step', root };
1969
+ if (pl && pl.step && normMode(pl.step.mode)) return { mode: normMode(pl.step.mode), source: 'step', root };
1961
1970
  if (root) {
1962
1971
  const ev = events ? rootModeFromEvents(events, root) : rootModeFromText(txt, root);
1963
1972
  if (ev) return { mode: ev.mode, source: 'root', root };
@@ -1965,90 +1974,6 @@ function taskModeInfo(ctx, taskId, events) {
1965
1974
  return { mode: DEFAULT_MODE, source: 'default', root };
1966
1975
  }
1967
1976
 
1968
- // core 0.7.0 (s2): signals for SUGGESTING a return to normal. The CLI never switches the mode itself; only the
1969
- // user's `dieu-phoi mode` does. A suggestion is computed only while the root is in fast.
1970
- const FAST_FAIL_WINDOW = 10; // last N fast exec steps (all roots, file order of their first exec dispatch)
1971
- const FAST_FAIL_MIN_N = 5; // fewer fast steps than this -> no rate signal yet
1972
- const FAST_FAIL_RATE = 0.15; // >= this share failed -> suggest normal
1973
- const BATCH_VERIFY_RE = /^batch-verify(?:-|$)/;
1974
- const QUOTA_VERDICT_RE = /^(?:quota-pause|usage-limit|auto-quota-pause|auto-usage-limit|paused-for-quota)(?:-|$)/;
1975
-
1976
- /** A user reject that counts as a quality signal (legacy `rework` too); scope rejects (request changed) do not. */
1977
- function isQualityReject(ev) {
1978
- if (ev.outcome === 'rework' && !ev.by) return true;
1979
- return ev.by === 'user' && ev.outcome === 'reject' && ev.cause !== 'scope';
1980
- }
1981
-
1982
- /** Agent-layer fail (fail / timeout, never a quota pause or aborted). */
1983
- function isAgentFail(ev) {
1984
- if (ev.by === 'user' || ev.by === 'auto') return false;
1985
- if (ev.outcome === 'fail') return true;
1986
- return ev.outcome === 'timeout' && !QUOTA_VERDICT_RE.test(String(ev.reason_code || ''));
1987
- }
1988
-
1989
- /**
1990
- * Fail rate over the last FAST_FAIL_WINDOW fast exec steps (any root): a step is a task whose first exec dispatch
1991
- * recorded mode=fast; it failed when it has an agent-layer fail or a quality reject. Discarded tasks are left out.
1992
- * Returns { n, failed, rate } (rate null when n = 0).
1993
- */
1994
- function fastFailRate(events) {
1995
- const gone = new Set();
1996
- for (const ev of events) if (ev && ev.kind === 'discard' && typeof ev.task_id === 'string') gone.add(ev.task_id);
1997
- const order = [];
1998
- const seen = new Set();
1999
- for (const ev of events) {
2000
- if (!ev || ev.kind !== 'dispatch' || typeof ev.task_id !== 'string' || !ev.task_id) continue;
2001
- if ((ev.role || 'exec') !== 'exec' || seen.has(ev.task_id)) continue;
2002
- seen.add(ev.task_id);
2003
- if (ev.mode === 'fast' && !gone.has(ev.task_id)) order.push(ev.task_id);
2004
- }
2005
- const win = new Set(order.slice(-FAST_FAIL_WINDOW));
2006
- const failed = new Set();
2007
- for (const ev of events) {
2008
- if (ev && ev.kind === 'verdict' && win.has(ev.task_id) && (isAgentFail(ev) || isQualityReject(ev))) failed.add(ev.task_id);
2009
- }
2010
- return { n: win.size, failed: failed.size, rate: win.size ? failed.size / win.size : null };
2011
- }
2012
-
2013
- /**
2014
- * Should the orchestrator SUGGEST (never apply) a return to normal for `root`? Only while `mode` (the root's current
2015
- * mode) is fast. Signals, each with a short Vietnamese reason:
2016
- * - batch-verify: an agent verdict fail with reason batch-verify on a task of the root, after the root's last mode event;
2017
- * - reject: a quality reject (user) on a task of the root, after that mode event;
2018
- * - fail-rate: >= 15% failed among the last 10 fast steps (all roots; needs >= 5 steps).
2019
- * `taskIds` = the root and its plan's step task ids. Returns { mode_suggest: 'normal'|null, mode_suggest_reason,
2020
- * mode_suggest_signals[] }.
2021
- */
2022
- function modeSuggestion(events, root, o) {
2023
- const opts = o || {};
2024
- const none = { mode_suggest: null, mode_suggest_reason: null, mode_suggest_signals: [] };
2025
- if (opts.mode !== 'fast') return none;
2026
- const ids = new Set(opts.taskIds || []);
2027
- if (root) ids.add(root);
2028
- const last = root ? rootModeFromEvents(events, root) : null;
2029
- const since = last ? (Date.parse(last.ts) || 0) : 0;
2030
- const signals = [];
2031
- const reasons = [];
2032
- let batch = null;
2033
- let reject = null;
2034
- for (const ev of events) {
2035
- if (!ev || ev.kind !== 'verdict' || !ids.has(ev.task_id)) continue;
2036
- const t = Date.parse(ev.ts) || 0;
2037
- if (t < since) continue;
2038
- if (!batch && ev.outcome === 'fail' && BATCH_VERIFY_RE.test(String(ev.reason_code || ''))) batch = ev.task_id;
2039
- if (!reject && isQualityReject(ev)) reject = ev.task_id;
2040
- }
2041
- if (batch) { signals.push('batch-verify'); reasons.push(`verifier gộp báo fail (task ${batch})`); }
2042
- if (reject) { signals.push('reject'); reasons.push(`người dùng reject (task ${reject})`); }
2043
- const fr = fastFailRate(events);
2044
- if (fr.n >= FAST_FAIL_MIN_N && fr.rate >= FAST_FAIL_RATE) {
2045
- signals.push('fail-rate');
2046
- reasons.push(`${fr.failed}/${fr.n} bước fast gần nhất fail (${Math.round(fr.rate * 100)}% ≥ ${Math.round(FAST_FAIL_RATE * 100)}%)`);
2047
- }
2048
- if (!signals.length) return none;
2049
- return { mode_suggest: 'normal', mode_suggest_reason: reasons.join('; '), mode_suggest_signals: signals };
2050
- }
2051
-
2052
1977
  /** Task ids of a root: the root itself plus the step task ids of its approved plan (if any). */
2053
1978
  function rootTaskIds(plans, root) {
2054
1979
  const out = new Set(root ? [root] : []);
@@ -2321,8 +2246,10 @@ function readHead(start) {
2321
2246
 
2322
2247
  // ------------------------------------------------------------------ core 0.6.0, s3: knowledge freshness
2323
2248
  //
2324
- // A knowledge section is stale when a file matching its `paths` globs differs between the commit it was merged at
2325
- // (`sha`) and the HEAD of the worktree asking. Used by the start block here and by `knowledge get/list/status`
2249
+ // A knowledge section is stale when a file named by one of its plain (non-glob) `paths` entries differs between the
2250
+ // commit it was merged at (`sha`) and the HEAD of the worktree asking. A file that only matches a glob entry (`**`,
2251
+ // `*`, `?`) gives state `drift`: shown lightly, never counted as stale (rule 2, 0.7.4 s5; cache entries carry `rule`).
2252
+ // Used by the start block here and by `knowledge get/list/status`
2326
2253
  // (lib/knowledge.cjs). Lives in hook.cjs because the installed hook is a single file (builtins only). Rules, from
2327
2254
  // measurements on the pixon-fast-forward repos (contract r1-2 §2):
2328
2255
  // - ONE git process per check, whatever the number of distinct section shas: `git diff-tree --stdin` reads one
@@ -2341,8 +2268,69 @@ const FRESH_CACHE_MAX = 12; // HEADs remembered per store (worktrees of one repo
2341
2268
  const DIFF_TIMEOUT_MS = 10000;
2342
2269
  const HOOK_DIFF_TIMEOUT_MS = 1500;
2343
2270
  const DIRTY_TIMEOUT_MS = 2000;
2271
+ // Tests only: DIEU_PHOI_GIT_STATUS_TIMEOUT_MS overrides the `git status` bound of --dirty (a busy Windows box overruns 2 s);
2272
+ // invalid or non-positive values are ignored, the product default stays DIRTY_TIMEOUT_MS.
2273
+ function dirtyTimeoutMs() {
2274
+ const v = Number(process.env.DIEU_PHOI_GIT_STATUS_TIMEOUT_MS);
2275
+ return Number.isFinite(v) && v > 0 ? v : DIRTY_TIMEOUT_MS;
2276
+ }
2344
2277
  const PATHSPEC_MAX_CHARS = 6000; // beyond this: no pathspec (Windows command line limit), filter in JS only
2345
2278
  const STALE_FILES_KEEP = 50; // stale files kept per section (the count stays exact)
2279
+ const FRESH_RULE = 4; // 4 (0.7.4 s9): rule 3 + identifiers compared as nameKey (kebab/camel match), >= 3 chars; cache of rule 3 dropped
2280
+ const GLOB_CHARS_RE = /[*?]/;
2281
+ const LARGE_FILE_BYTES = 30000; // above this a plain path counts only when a changed line names one of the entry's identifiers
2282
+ // A changed line that is only a version bump (package.json, SKILL.md frontmatter, a `const CORE_VERSION = '…'` in code) or blank: routine, never stale.
2283
+ const ROUTINE_LINE_RES = [/^\s*$/, /^\s*"version"\s*:\s*"[^"]*",?\s*$/, /^\s*version\s*:\s*[^\s#]+\s*$/,
2284
+ /^\s*(?:const|let|var)\s+\w*VERSION\w*\s*=\s*(['"])[^'"]*\1;?\s*$/];
2285
+ // Words too common in code to tell an entry's identifiers from ordinary lines.
2286
+ const NAME_STOPWORDS = new Set(['const', 'return', 'function', 'string', 'number', 'object', 'boolean', 'undefined',
2287
+ 'files', 'file', 'json', 'path', 'paths', 'value', 'values', 'state', 'null', 'true', 'false', 'test', 'tests', 'node', 'cli']);
2288
+
2289
+ function plainPathsOf(s) { return (s.paths || []).filter((g) => !GLOB_CHARS_RE.test(g)); }
2290
+ function isChangelogPath(f) { return /(^|\/)CHANGELOG[^/]*$/i.test(f); }
2291
+ function entryAboutChangelog(s) { return [s.id, s.title, ...(s.tags || [])].some((x) => /changelog/i.test(String(x || ''))); }
2292
+ function isRoutineLine(t) { return ROUTINE_LINE_RES.some((re) => re.test(t)); }
2293
+ function isLargeFile(root, f) {
2294
+ try { return fs.statSync(path.join(root, f)).size > LARGE_FILE_BYTES; } catch (_) { return false; }
2295
+ }
2296
+ function readSectionBody(dir, id) {
2297
+ try { return fs.readFileSync(path.join(dir, 'sections', `${id}.md`), 'utf8'); } catch (_) { return ''; }
2298
+ }
2299
+ /** Comparable form of an identifier: lower-case letters and digits only (`plan-table` ~ `cmdPlanTable` ~ `plantable`). */
2300
+ function nameKey(t) { return String(t || '').toLowerCase().replace(/[^a-z0-9]/g, ''); }
2301
+ /** Identifiers an entry names: the pieces of its backtick spans and the words of its id (nameKey form, >= 3 chars). */
2302
+ function namesOf(s, body) {
2303
+ const out = new Set();
2304
+ for (const m of String(body || '').matchAll(/`([^`\n]{2,200})`/g)) {
2305
+ for (const t of m[1].split(/[\s(),;:=<>[\]{}'"|]+/)) {
2306
+ const low = nameKey(t);
2307
+ if (low.length >= 3 && /[a-z]/.test(low) && !NAME_STOPWORDS.has(low)) out.add(low);
2308
+ }
2309
+ }
2310
+ for (const p of String(s.id || '').split('-')) if (p.length >= 5) out.add(nameKey(p));
2311
+ return Array.from(out);
2312
+ }
2313
+ /**
2314
+ * Changed lines (added and removed text, no diff markers) of `files` between sha and head, one git diff with -U0.
2315
+ * Returns { byFile: Map<file, string[]> } or null when git fails.
2316
+ */
2317
+ function changedLinesOf(root, sha, head, files, timeoutMs) {
2318
+ const r = runGit(root, ['--literal-pathspecs', 'diff', '-U0', '--no-renames', '--no-color', '--no-ext-diff', sha, head, '--', ...files], undefined, timeoutMs);
2319
+ if (!r.ok) return null;
2320
+ const byFile = new Map();
2321
+ let cur = null;
2322
+ let inHunk = false;
2323
+ for (const raw of r.stdout.split('\n')) {
2324
+ const line = raw.replace(/\r$/, '');
2325
+ if (line.startsWith('diff --git ')) {
2326
+ const m = / b\/(.*)$/.exec(line);
2327
+ cur = m ? m[1] : null; inHunk = false;
2328
+ if (cur && !byFile.has(cur)) byFile.set(cur, []);
2329
+ } else if (line.startsWith('@@')) inHunk = true;
2330
+ else if (inHunk && cur && (line.startsWith('+') || line.startsWith('-'))) byFile.get(cur).push(line.slice(1));
2331
+ }
2332
+ return { byFile };
2333
+ }
2346
2334
  const FULL_SHA_RE = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
2347
2335
 
2348
2336
  /**
@@ -2435,7 +2423,7 @@ function diffShas(root, shas, head, globs, timeoutMs) {
2435
2423
  function dirtyFiles(root, globs, timeoutMs) {
2436
2424
  const spec = pathspecOf(globs);
2437
2425
  const base = ['status', '--porcelain=v1', '-z', '-uno', '--no-renames'];
2438
- const r = runGit(root, spec ? base.concat('--', spec) : base, undefined, timeoutMs || DIRTY_TIMEOUT_MS);
2426
+ const r = runGit(root, spec ? base.concat('--', spec) : base, undefined, timeoutMs || dirtyTimeoutMs());
2439
2427
  if (!r.ok) return { error: r.error, timedOut: !!r.timedOut, ms: r.ms };
2440
2428
  const files = r.stdout.split('\0').filter((x) => x.length > 3).map((x) => x.slice(3));
2441
2429
  return { files, ms: r.ms };
@@ -2461,7 +2449,7 @@ function readFreshCache(dir) {
2461
2449
 
2462
2450
  function cachedFor(dir, head, stamp) {
2463
2451
  if (!head || !stamp) return null;
2464
- return readFreshCache(dir).entries.find((e) => e.head === head && e.index_mtime === stamp.mtime && e.index_size === stamp.size) || null;
2452
+ return readFreshCache(dir).entries.find((e) => e.rule === FRESH_RULE && e.head === head && e.index_mtime === stamp.mtime && e.index_size === stamp.size) || null;
2465
2453
  }
2466
2454
 
2467
2455
  /** Best effort (tmp+rename, last writer wins); a store that vanished or is read-only only loses the cache. */
@@ -2473,7 +2461,7 @@ function writeFreshCache(dir, entry) {
2473
2461
  }
2474
2462
 
2475
2463
  function freshCounts(sections) {
2476
- const c = { total: 0, fresh: 0, stale: 0, unknown: 0 };
2464
+ const c = { total: 0, fresh: 0, stale: 0, drift: 0, unknown: 0 };
2477
2465
  for (const s of Object.values(sections)) { c.total++; c[s.state] = (c[s.state] || 0) + 1; }
2478
2466
  return c;
2479
2467
  }
@@ -2500,7 +2488,8 @@ function computeFreshness(o) {
2500
2488
  if (!head) why = o.head && o.head.unsupported ? `HEAD không đọc được từ file (${o.head.unsupported})` : 'không đọc được HEAD (repo chưa có commit?)';
2501
2489
  else if (!o.root) why = 'repo không có thư mục làm việc (bare)';
2502
2490
  for (const s of idx.sections) {
2503
- const sha = String(s.sha || '').toLowerCase();
2491
+ // review mark (s6): an entry reviewed at reviewed_sha (full sha) is judged from that sha on; a merge that replaces the entry drops the mark
2492
+ const sha = String(FULL_SHA_RE.test(String(s.reviewed_sha || '')) ? s.reviewed_sha : (s.sha || '')).toLowerCase();
2504
2493
  if (why) sections[s.id] = { state: 'unknown', why };
2505
2494
  else if (!sha || sha === 'none') sections[s.id] = { state: 'unknown', why: 'mục chưa có sha' };
2506
2495
  else if (sha === head) sections[s.id] = { state: 'fresh' };
@@ -2514,19 +2503,65 @@ function computeFreshness(o) {
2514
2503
  const d = diffShas(o.root, Array.from(pending.keys()), head, globs, o.timeoutMs || DIFF_TIMEOUT_MS);
2515
2504
  out.ms_git = d.ms || 0;
2516
2505
  if (d.error) error = d.error;
2506
+ // rule 3 (0.7.4 s5b): read the changed lines of the plain-path files that could make an entry stale (one git
2507
+ // diff per distinct sha, only on a cache miss); CHANGELOG files an entry is not about are never read
2508
+ const timeout = o.timeoutMs || DIFF_TIMEOUT_MS;
2509
+ const want = new Map(); // sha -> Set(file)
2510
+ for (const [sha, list] of pending) {
2511
+ if (d.error || d.missing.has(sha)) continue;
2512
+ for (const s of list) {
2513
+ const plain = plainPathsOf(s);
2514
+ for (const f of d.bySha.get(sha) || []) {
2515
+ if (!plain.some((g) => globMatch(g, f))) continue;
2516
+ if (isChangelogPath(f) && !entryAboutChangelog(s)) continue;
2517
+ if (!want.has(sha)) want.set(sha, new Set());
2518
+ want.get(sha).add(f);
2519
+ }
2520
+ }
2521
+ }
2522
+ const lines = new Map(); // `${sha}\0${file}` -> changed texts, or null when git could not say
2523
+ for (const [sha, set] of want) {
2524
+ const r = changedLinesOf(o.root, sha, head, Array.from(set), timeout);
2525
+ for (const f of set) lines.set(`${sha}\0${f}`, r ? (r.byFile.get(f) || []) : null);
2526
+ }
2527
+ const names = new Map(); // section id -> identifiers it names (read lazily)
2528
+ const namesFor = (s) => {
2529
+ if (!names.has(s.id)) names.set(s.id, namesOf(s, readSectionBody(o.dir, s.id)));
2530
+ return names.get(s.id);
2531
+ };
2532
+ const counts = (sha, s, f) => { // does this changed plain-path file make the entry stale?
2533
+ if (isChangelogPath(f) && !entryAboutChangelog(s)) return false;
2534
+ const texts = lines.get(`${sha}\0${f}`);
2535
+ if (!texts) return true; // content unknown: count the change, never hide it
2536
+ const real = texts.filter((t) => !isRoutineLine(t));
2537
+ if (!real.length) return false; // only version bumps / blank lines
2538
+ if (!isLargeFile(o.root, f)) return true;
2539
+ const nm = namesFor(s);
2540
+ return real.some((t) => { const low = nameKey(t); return nm.some((x) => low.includes(x)); });
2541
+ };
2517
2542
  for (const [sha, list] of pending) {
2518
2543
  for (const s of list) {
2519
2544
  if (d.error) { sections[s.id] = { state: 'unknown', why: `git diff-tree lỗi: ${d.error}` }; continue; }
2520
2545
  if (d.missing.has(sha)) { sections[s.id] = { state: 'unknown', why: `commit ${sha.slice(0, 7)} không có trong repo này (clone/nhánh khác chưa fetch?)` }; continue; }
2521
- const files = d.bySha.get(sha).filter((f) => (s.paths || []).some((g) => globMatch(g, f)));
2522
- sections[s.id] = files.length ? { state: 'stale', files: files.slice(0, STALE_FILES_KEEP), n_files: files.length } : { state: 'fresh' };
2546
+ const changed = d.bySha.get(sha) || [];
2547
+ const plain = plainPathsOf(s);
2548
+ const globPaths = (s.paths || []).filter((g) => GLOB_CHARS_RE.test(g));
2549
+ const named = [];
2550
+ const soft = []; // changed, but not counted as stale: glob-only, routine, CHANGELOG, lines the entry does not name
2551
+ for (const f of changed) {
2552
+ if (plain.some((g) => globMatch(g, f))) (counts(sha, s, f) ? named : soft).push(f);
2553
+ else if (globPaths.some((g) => globMatch(g, f))) soft.push(f);
2554
+ }
2555
+ if (named.length) sections[s.id] = { state: 'stale', files: named.slice(0, STALE_FILES_KEEP), n_files: named.length };
2556
+ else if (soft.length) sections[s.id] = { state: 'drift', files: soft.slice(0, STALE_FILES_KEEP), n_files: soft.length };
2557
+ else sections[s.id] = { state: 'fresh' };
2523
2558
  }
2524
2559
  }
2525
2560
  }
2526
2561
  Object.assign(out, { computed: new Date().toISOString(), sections, counts: freshCounts(sections) });
2527
2562
  if (error) out.error = error;
2528
2563
  if (head && !error && o.cache !== false && stamp) {
2529
- writeFreshCache(o.dir, { head, index_mtime: stamp.mtime, index_size: stamp.size, computed: out.computed, sections });
2564
+ writeFreshCache(o.dir, { rule: FRESH_RULE, head, index_mtime: stamp.mtime, index_size: stamp.size, computed: out.computed, sections });
2530
2565
  }
2531
2566
  return out;
2532
2567
  }
@@ -2551,12 +2586,14 @@ function shortFileList(files, n) {
2551
2586
  return f.slice(0, k).join(', ') + (f.length > k ? ` (+${f.length - k})` : '');
2552
2587
  }
2553
2588
 
2554
- /** "N mục, k stale (ids), u không xác định (ids)". */
2589
+ /** "N mục, k stale (ids), d drift (ids), u không xác định (ids)"; drift only when present (it is not stale). */
2555
2590
  function knowledgeSummary(fr, n) {
2556
2591
  const ids = (st) => Object.keys(fr.sections).filter((id) => fr.sections[id].state === st);
2557
2592
  const stale = ids('stale');
2593
+ const drift = ids('drift');
2558
2594
  const unk = ids('unknown');
2559
2595
  const parts = [`${n} mục`, `${stale.length} stale${stale.length ? ` (${shortFileList(stale, 6)})` : ''}`];
2596
+ if (drift.length) parts.push(`${drift.length} drift (${shortFileList(drift, 4)})`);
2560
2597
  if (unk.length) parts.push(`${unk.length} không xác định (${shortFileList(unk, 4)})`);
2561
2598
  return parts.join(', ');
2562
2599
  }
@@ -2900,6 +2937,7 @@ function buildDispatch(p, ctx) {
2900
2937
  ...(tag.type_alias_from ? { type_alias_from: tag.type_alias_from } : {}),
2901
2938
  ...(tag.chain_ok ? { chain_ok: true } : {}),
2902
2939
  ...(tag.session_ok ? { session_ok: true } : {}),
2940
+ ...(tag.parallel_ok ? { parallel_ok: true } : {}),
2903
2941
  ...(tag.batch ? { batch: true } : {}),
2904
2942
  ...(dispatchRun(ti.prompt) ? { run: dispatchRun(ti.prompt) } : {}),
2905
2943
  task_type: tag.task_type,
@@ -2918,7 +2956,7 @@ function buildDispatch(p, ctx) {
2918
2956
  orch_ctx_baseline: orch.ctx_baseline,
2919
2957
  orch_compactions: orch.compactions,
2920
2958
  orch_cum_usd: orch.cum_usd,
2921
- }, tag.parent_task_id ? { parent_task_id: tag.parent_task_id } : {},
2959
+ }, dispatchLiveCounts(ctx, p, tag), tag.parent_task_id ? { parent_task_id: tag.parent_task_id } : {},
2922
2960
  projectFields(p),
2923
2961
  modeFields(ctx, tag),
2924
2962
  tierFields(ctx, tag, normalizeModel(trObj.resolvedModel) || normalizeModel(ti.model) || null, explicitEffort || parentEffort || null),
@@ -2992,6 +3030,12 @@ function handlePostToolUse(p, ctx) {
2992
3030
  }
2993
3031
  appendEvent(ctx, ev);
2994
3032
  if (implicit) appendEvent(ctx, implicit);
3033
+ // s6: the orchestrator just dispatched: remind it of its stuck/orphaned agents (main session only; an exec hears about
3034
+ // its own helpers through --watch)
3035
+ if (!watchAgentId(p) && typeof p.session_id === 'string' && p.session_id) {
3036
+ const stuck = stuckReminder(ctx, { sessionId: p.session_id });
3037
+ if (stuck) notes.push(stuck);
3038
+ }
2995
3039
  if (notes.length) {
2996
3040
  Object.defineProperty(ev, 'stdout', {
2997
3041
  enumerable: false,
@@ -3286,7 +3330,7 @@ function openBackgroundTasks(lines) {
3286
3330
  return Array.from(open, ([k, v]) => v || k);
3287
3331
  }
3288
3332
 
3289
- function handleSubagentStop(p, ctx) {
3333
+ function handleSubagentStop(p, ctx, opt) {
3290
3334
  const agentId = typeof p.agent_id === 'string' && p.agent_id ? p.agent_id : null;
3291
3335
  if (!agentId) return null;
3292
3336
  const prev = loadSegments(ctx, agentId);
@@ -3385,7 +3429,14 @@ function handleSubagentStop(p, ctx) {
3385
3429
  }
3386
3430
  // s16 (core 0.7.2): processes the agent left behind (report; kill only with config kill_leftovers). Never throws, bounded to ~5 s.
3387
3431
  if (tag && tag.task_id && exists) {
3388
- try { scanLeftoverProcs(ctx, { agentId, taskId: tag.task_id, lines }); } catch (e) { logError(ctx.home, e, 'leftover-procs'); }
3432
+ const live = opt && opt.live ? opt.live : null;
3433
+ try {
3434
+ scanLeftoverProcs(ctx, {
3435
+ agentId, taskId: tag.task_id, lines, worktree: live && live.cwd ? live.cwd : null, liveStart: live ? live.start : null,
3436
+ sessionCwd: typeof p.cwd === 'string' ? p.cwd : null,
3437
+ sessionTranscript: typeof p.transcript_path === 'string' ? p.transcript_path : (live && live.session_transcript) || null,
3438
+ });
3439
+ } catch (e) { logError(ctx.home, e, 'leftover-procs'); }
3389
3440
  }
3390
3441
  return ev;
3391
3442
  }
@@ -3441,15 +3492,19 @@ function otherAgentsRunning(events, agentId, startMs, endMs) {
3441
3492
  /**
3442
3493
  * Looks at the process table after a dp agent stopped. Descendants of this session's Claude Code process created since the
3443
3494
  * 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.
3495
+ * {task_id, n, names}. kill_leftovers (procs.killMode: env → config → 'guarded'):
3496
+ * - 'guarded' (default, s7): kill only what procs.guardedSelect attributes to this agent (agent shell under this session's
3497
+ * claude.exe, never-kill list, cmd in its own worktree or no other dp agent meanwhile), each pid re-checked against a
3498
+ * fresh list (same creation time) right before; killed_pids + kill_skips {pid, name, reason} go in the event;
3499
+ * - true: 0.7.2 behaviour, the leftover tree roots (taskkill /T) when no other dp agent ran meanwhile; false: report only.
3500
+ * deps = { procs, now, selfPid, events, kill } for tests. Returns the event or null.
3446
3501
  */
3447
3502
  function scanLeftoverProcs(ctx, a, deps) {
3448
3503
  const d = deps || {};
3449
3504
  if (ctx.config.scan_leftover_procs === false || (!d.procs && process.env.DIEU_PHOI_NO_PROCS === '1')) return null;
3450
3505
  const procs = d.procs || loadProcs();
3451
3506
  if (!procs) return null;
3452
- const startMs = transcriptStartMs(a.lines);
3507
+ const startMs = transcriptStartMs(a.lines) || (a.liveStart ? Date.parse(a.liveStart) || null : null);
3453
3508
  const now = typeof d.now === 'number' ? d.now : Date.now();
3454
3509
  // an agent that started over 24 h ago (a long-resumed or odd transcript) has no meaningful "created since" window
3455
3510
  if (startMs === null || now - startMs > LEFTOVER_OVERLAP_MAX_AGE_MS) return null;
@@ -3464,22 +3519,88 @@ function scanLeftoverProcs(ctx, a, deps) {
3464
3519
  const counts = new Map();
3465
3520
  for (const p of left.all) { const n = String(p.name || '?').toLowerCase(); counts.set(n, (counts.get(n) || 0) + 1); }
3466
3521
  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;
3522
+ const mode = typeof procs.killMode === 'function' ? procs.killMode(ctx.config) : (ctx.config.kill_leftovers === true ? 'tree' : 'off');
3468
3523
  let killed = 0;
3469
3524
  let skipped = null;
3470
- if (killOn) {
3525
+ const extra = {};
3526
+ if (mode === 'tree') {
3471
3527
  const events = d.events || readEvents(ctx);
3472
3528
  if (otherAgentsRunning(events, a.agentId, startMs, now)) skipped = 'other-agent-running';
3473
3529
  else killed = procs.killTree(left.roots.map((p) => p.pid), { deadline: t0 + LEFTOVER_BUDGET_MS }).length;
3530
+ } else if (mode === 'guarded') {
3531
+ // s7: kill only what is attributable to THIS agent, after re-checking pid + creation time; pids + reasons go in the event
3532
+ const events = d.events || readEvents(ctx);
3533
+ const otherAgent = otherAgentsRunning(events, a.agentId, startMs, now);
3534
+ const mainShell = !otherAgent && (typeof d.mainShell === 'boolean' ? d.mainShell : mainShellSince(a.sessionTranscript, startMs));
3535
+ const exclusive = !otherAgent && !mainShell;
3536
+ const sel = procs.guardedSelect(list, left.all, { rootPid: root.pid, worktree: ownWorktree(ctx, a, now), exclusive });
3537
+ const res = sel.kill.length
3538
+ ? procs.killVerified(sel.kill, { relist: () => procs.listProcesses({ timeoutMs: LEFTOVER_LIST_TIMEOUT_MS }), kill: d.kill, deadline: t0 + LEFTOVER_BUDGET_MS })
3539
+ : { killed: [], skipped: [] };
3540
+ killed = res.killed.length;
3541
+ const skips = sel.skip.concat(res.skipped);
3542
+ if (!exclusive && sel.skip.some((s) => s.reason === 'not-attributable')) skipped = otherAgent ? 'other-agent-running' : 'main-thread-shell';
3543
+ extra.killed_pids = res.killed.slice(0, 20);
3544
+ if (skips.length) extra.kill_skips = skips.slice(0, 20).map((s) => ({ pid: s.pid, name: String(s.name || '').toLowerCase(), reason: s.reason }));
3474
3545
  }
3475
3546
  const ev = Object.assign({
3476
3547
  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));
3548
+ n: left.all.length, names, kill_leftovers: typeof procs.killModeValue === 'function' ? procs.killModeValue(mode) : mode === 'tree', killed,
3549
+ }, extra, skipped ? { kill_skipped: skipped } : {}, commonFields(ctx));
3479
3550
  appendEvent(ctx, ev);
3480
3551
  return ev;
3481
3552
  }
3482
3553
 
3554
+ const MAIN_SHELL_TAIL_BYTES = 4 * 1024 * 1024;
3555
+ const MAIN_SHELL_TOOLS = new Set(['Bash', 'PowerShell', 'Monitor']);
3556
+
3557
+ /**
3558
+ * Did the session's main thread start a shell (Bash / PowerShell / Monitor tool_use) at or after startMs? Its processes sit
3559
+ * 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
3560
+ * last 4 MB of the session transcript. true = yes OR unknown (unreadable, or the tail does not reach back to startMs).
3561
+ */
3562
+ function mainShellSince(file, startMs) {
3563
+ if (typeof file !== 'string' || !file || !startMs) return true;
3564
+ let fd;
3565
+ try { fd = fs.openSync(file, 'r'); } catch (_) { return true; }
3566
+ try {
3567
+ const size = fs.fstatSync(fd).size;
3568
+ const from = Math.max(0, size - MAIN_SHELL_TAIL_BYTES);
3569
+ const text = readSlice(fd, from, size - from).toString('utf8');
3570
+ const lines = text.split('\n');
3571
+ if (from > 0) lines.shift(); // partial first line
3572
+ let oldest = Infinity;
3573
+ for (const raw of lines) {
3574
+ if (!raw.trim()) continue;
3575
+ let l;
3576
+ try { l = JSON.parse(raw); } catch (_) { continue; }
3577
+ const t = typeof l.timestamp === 'string' ? Date.parse(l.timestamp) : NaN;
3578
+ if (Number.isFinite(t)) oldest = Math.min(oldest, t);
3579
+ if (!(t >= startMs) || l.isSidechain) continue;
3580
+ const content = l.message && Array.isArray(l.message.content) ? l.message.content : [];
3581
+ if (content.some((c) => c && c.type === 'tool_use' && MAIN_SHELL_TOOLS.has(c.name))) return true;
3582
+ }
3583
+ return from > 0 && !(oldest <= startMs); // tail too short to see the whole window: unknown
3584
+ } catch (_) {
3585
+ return true;
3586
+ } finally {
3587
+ try { fs.closeSync(fd); } catch (_) { /* closed */ }
3588
+ }
3589
+ }
3590
+
3591
+ /**
3592
+ * The agent's own worktree for the guarded kill: its live-record cwd (brief `worktree:` or SubagentStart cwd), only when it is
3593
+ * not the session's own cwd and no other live agent works in it (a shared directory says nothing about whose process it is).
3594
+ */
3595
+ function ownWorktree(ctx, a, now) {
3596
+ const norm = (s) => String(s || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
3597
+ const wt = norm(a.worktree);
3598
+ if (!wt || (a.sessionCwd && norm(a.sessionCwd) === wt)) return null;
3599
+ let others = [];
3600
+ try { others = readLive(ctx, now).agents.filter((r) => r.agent_id !== a.agentId); } catch (_) { others = []; }
3601
+ return others.some((r) => norm(r.cwd) === wt) ? null : a.worktree;
3602
+ }
3603
+
3483
3604
  // 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
3605
  // / usage limit (brief template A, SKILL.md). Nothing looser: "STATUS: partial" + "quota" can be a real failure
3485
3606
  // ("Drive storage quota exceeded", a half-built rate limiter).
@@ -3869,6 +3990,17 @@ function handleWatch(p, ctx, o) {
3869
3990
  if (!agentId) return null;
3870
3991
  const { st, file, tpath } = watchState(p, ctx, agentId);
3871
3992
  if (!st || !st.dp || !tpath) return null;
3993
+ const touched = touchLive(ctx, p, agentId, st); // core 0.7.3 (s1): live register heartbeat
3994
+ // s6: an exec's own stuck helpers (checked at most once per heartbeat, i.e. ≤ 1/30 s)
3995
+ const helperNote = touched && st.role === 'exec' && st.task_id ? stuckReminder(ctx, { parentTask: st.task_id }) : null;
3996
+ const withHelperNote = (r) => {
3997
+ if (!helperNote) return r;
3998
+ if (!r) return { stuck: true, stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: helperNote } }) };
3999
+ const out = JSON.parse(r.stdout);
4000
+ out.hookSpecificOutput.additionalContext += `\n${helperNote}`;
4001
+ r.stdout = JSON.stringify(out);
4002
+ return r;
4003
+ };
3872
4004
  // Gate slots: a helper's first hook proves its spawn started; so does the exec's own successful Agent call.
3873
4005
  if (st.role === 'helper' && st.parent_task_id && st.task_id) gateConfirm(ctx, st.parent_task_id, st.task_id);
3874
4006
  if ((p.tool_name === 'Agent' || p.tool_name === 'Task') && st.role === 'exec') {
@@ -3877,21 +4009,21 @@ function handleWatch(p, ctx, o) {
3877
4009
  }
3878
4010
  const thresholds = Array.isArray(o && o.thresholds) && o.thresholds.length ? o.thresholds : watchThresholds(ctx);
3879
4011
  const tokens = lastContextTokens(tpath);
3880
- if (tokens === null) return null;
4012
+ if (tokens === null) return withHelperNote(null);
3881
4013
  let level = 0;
3882
4014
  while (level < thresholds.length && tokens >= thresholds[level]) level++;
3883
- if (level <= (Number(st.fired) || 0)) return null;
4015
+ if (level <= (Number(st.fired) || 0)) return withHelperNote(null);
3884
4016
  st.fired = level;
3885
4017
  st.ctx_tokens = tokens;
3886
4018
  st.t = Date.now();
3887
4019
  writeFileAtomic(file, `${JSON.stringify(st)}\n`);
3888
- return {
4020
+ return withHelperNote({
3889
4021
  level,
3890
4022
  ctx_tokens: tokens,
3891
4023
  stdout: JSON.stringify({
3892
4024
  hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: handoffMessage(st, tokens, level, thresholds) },
3893
4025
  }),
3894
- };
4026
+ });
3895
4027
  }
3896
4028
 
3897
4029
  /** A reservation that neither the parent's PostToolUse(Agent) nor the helper's own first hook confirmed in this long never started. */
@@ -3942,6 +4074,514 @@ function gateReserve(dir, child) {
3942
4074
  return 0;
3943
4075
  }
3944
4076
 
4077
+ // ------------------------------------------------------------------ live agents + parallel cap (core 0.7.3 s1)
4078
+
4079
+ /**
4080
+ * Live-agent register `data/live/<agent_id>.json`: written at SubagentStart, refreshed by --watch (last_activity, at most
4081
+ * once per LIVE_TOUCH_MS), removed at SubagentStop, ignored (and deleted) once idle past LIVE_TTL_MS. Counting is a
4082
+ * readdir of small files, cheap enough for PreToolUse. The SubagentStart payload has no prompt (only session_id,
4083
+ * agent_id, agent_type, cwd — checked 2026-11-06 with a probe session) and the subagent transcript does not exist yet,
4084
+ * so the tag comes from an intent `live/pending/<session8>-<task>-<role>.json` that the allowing PreToolUse(Agent)
4085
+ * (main session) or --gate (gen-2 helper) wrote: SubagentStart claims the oldest unclaimed intent of the same session
4086
+ * and agent_type with an exclusive `.claim` create. No intent → a dp-* agent_type still gets a file (role from the
4087
+ * type, task unknown, filled in by its first --watch); any other subagent (Claude Code internal, untagged) is skipped.
4088
+ */
4089
+ const LIVE_TTL_MS = 6 * 3600 * 1000;
4090
+ const LIVE_TOUCH_MS = 30 * 1000;
4091
+ /** An unclaimed intent counts towards the cap this long (the spawn it announces has not started yet). */
4092
+ const LIVE_PENDING_MS = 60 * 1000;
4093
+ /** SubagentStart may still claim an intent this old (a permission prompt can sit between PreToolUse and the spawn). */
4094
+ const LIVE_CLAIM_MS = 10 * 60 * 1000;
4095
+ const PARALLEL_DEFAULTS = { max_parallel_session: 4, max_parallel_machine: 8, warn_parallel_machine: 6 };
4096
+ /** Roles the per-session cap counts (gen 1 dispatches of the orchestrator). The machine cap counts every live agent. */
4097
+ const LIVE_SESSION_ROLES = ['exec', 'plan', 'verify'];
4098
+ const AGENT_TYPE_ROLES = { 'dp-exec': 'exec', 'dp-verify': 'verify', 'dp-scout': 'plan', 'dp-helper': 'helper' };
4099
+
4100
+ function liveName(id) {
4101
+ return String(id).replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 80) || 'x';
4102
+ }
4103
+ function liveDir(ctx) { return path.join(ctx.paths.data, 'live'); }
4104
+ function liveFile(ctx, agentId) { return path.join(liveDir(ctx), `${liveName(agentId)}.json`); }
4105
+ function livePendingDir(ctx) { return path.join(liveDir(ctx), 'pending'); }
4106
+ function intentFile(ctx, sessionId, taskId, role) {
4107
+ return path.join(livePendingDir(ctx), `${sha8(String(sessionId || ''))}-${liveName(taskId || 'none')}-${liveName(role || 'exec')}.json`);
4108
+ }
4109
+
4110
+ /** Caps: env DIEU_PHOI_<KEY> → config.json → PARALLEL_DEFAULTS. Integers ≥ 0; 0 turns that cap (or warning) off. */
4111
+ function parallelLimits(ctx) {
4112
+ const cfg = (ctx && ctx.config) || {};
4113
+ const pick = (key) => {
4114
+ for (const v of [process.env[`DIEU_PHOI_${key.toUpperCase()}`], cfg[key]]) {
4115
+ if (v === undefined || v === null || v === '') continue;
4116
+ const n = Number(v);
4117
+ if (Number.isFinite(n) && n >= 0) return Math.floor(n);
4118
+ }
4119
+ return PARALLEL_DEFAULTS[key];
4120
+ };
4121
+ return { session: pick('max_parallel_session'), machine: pick('max_parallel_machine'), warn: pick('warn_parallel_machine') };
4122
+ }
4123
+
4124
+ function liveMs(v) {
4125
+ const n = typeof v === 'number' ? v : Date.parse(v);
4126
+ return Number.isFinite(n) ? n : null;
4127
+ }
4128
+
4129
+ /** Live agents + still-pending intents. Files idle past LIVE_TTL_MS are deleted and not returned. Never throws. */
4130
+ function readLive(ctx, now) {
4131
+ const t = typeof now === 'number' ? now : Date.now();
4132
+ const agents = [];
4133
+ const pending = [];
4134
+ let names = [];
4135
+ try { names = fs.readdirSync(liveDir(ctx)); } catch (_) { return { agents, pending }; }
4136
+ for (const n of names) {
4137
+ if (!n.endsWith('.json')) continue;
4138
+ const f = path.join(liveDir(ctx), n);
4139
+ const r = readJsonFile(f, null);
4140
+ const last = r && typeof r === 'object' ? (liveMs(r.last_activity) || liveMs(r.start)) : null;
4141
+ if (last === null || t - last > LIVE_TTL_MS) {
4142
+ // unreadable: give a concurrent writer a moment before treating it as junk
4143
+ let old = true;
4144
+ if (last === null) { try { old = t - fs.statSync(f).mtimeMs > LIVE_TOUCH_MS; } catch (_) { old = false; } }
4145
+ if (old) { try { fs.unlinkSync(f); } catch (_) { /* gone already */ } }
4146
+ continue;
4147
+ }
4148
+ agents.push(r);
4149
+ }
4150
+ let pnames = [];
4151
+ try { pnames = fs.readdirSync(livePendingDir(ctx)); } catch (_) { pnames = []; }
4152
+ const claimed = new Set(pnames.filter((n) => n.endsWith('.claim')).map((n) => n.slice(0, -6)));
4153
+ for (const n of pnames) {
4154
+ if (!n.endsWith('.json')) continue;
4155
+ const f = path.join(livePendingDir(ctx), n);
4156
+ const r = readJsonFile(f, null);
4157
+ const at = r && typeof r === 'object' ? liveMs(r.t) : null;
4158
+ if (at === null || t - at > LIVE_TTL_MS) {
4159
+ try { fs.unlinkSync(f); } catch (_) { /* ignore */ }
4160
+ try { fs.unlinkSync(`${f.slice(0, -5)}.claim`); } catch (_) { /* ignore */ }
4161
+ continue;
4162
+ }
4163
+ if (claimed.has(n.slice(0, -5)) || t - at > LIVE_PENDING_MS) continue;
4164
+ pending.push(Object.assign({ pending: true }, r));
4165
+ }
4166
+ return { agents, pending };
4167
+ }
4168
+
4169
+ /** session = exec/plan/verify (gen 1) of `sessionId`, live or pending; machine = every live agent + pending intent. */
4170
+ function liveCounts(live, sessionId) {
4171
+ const all = live.agents.concat(live.pending);
4172
+ const mine = all.filter((r) => sessionId && r.session_id === sessionId && LIVE_SESSION_ROLES.includes(r.role) && !(Number(r.gen) > 1));
4173
+ return { session: mine.length, machine: all.length, mine, all };
4174
+ }
4175
+
4176
+ function liveAgeMin(r, now) {
4177
+ const s = liveMs(r.start) || liveMs(r.t);
4178
+ return s === null ? null : Math.max(0, Math.round((now - s) / 60000));
4179
+ }
4180
+
4181
+ function liveLabel(r, now, withSession) {
4182
+ const age = liveAgeMin(r, now);
4183
+ const bits = [r.role || '?'];
4184
+ if (age !== null) bits.push(`${age} phút`);
4185
+ if (r.pending) bits.push('vừa giao');
4186
+ if (withSession && r.session_id) bits.push(`phiên ${sha8(r.session_id)}`);
4187
+ return `${r.task_id || (r.agent_id ? `agent ${r.agent_id}` : '?')} (${bits.join(', ')})`;
4188
+ }
4189
+
4190
+ /** s6: run dir the brief points at (dp-out-/handoff- path or …/runs/<id>), else null — never the made-up fallback. */
4191
+ function briefRunDir(prompt, taskId) {
4192
+ if (typeof prompt !== 'string' || !prompt) return null;
4193
+ const d = resultRunDir(prompt, taskId || 'none');
4194
+ const fallback = path.join(runsDir(), safeName(taskId || 'none'));
4195
+ return d === fallback && !/\/runs\/[A-Za-z0-9_-]/.test(prompt.replace(/\\/g, '/')) ? null : d;
4196
+ }
4197
+
4198
+ /** s6: where Claude Code will write a subagent's transcript, from the parent's transcript_path at SubagentStart. */
4199
+ function liveTranscriptGuess(parentTp, agentId) {
4200
+ if (typeof parentTp !== 'string' || !parentTp || !agentId) return null;
4201
+ const name = `agent-${agentId}.jsonl`;
4202
+ if (path.basename(path.dirname(parentTp)) === 'subagents') return path.join(path.dirname(parentTp), name); // nested spawn
4203
+ return path.join(path.dirname(parentTp), path.basename(parentTp, '.jsonl'), 'subagents', name);
4204
+ }
4205
+
4206
+ /** Absolute path on the brief's `worktree:` line (best effort), else null. */
4207
+ function briefWorktree(prompt) {
4208
+ if (typeof prompt !== 'string') return null;
4209
+ const m = /^\s*worktree:(.*)$/m.exec(prompt);
4210
+ if (!m) return null;
4211
+ const pm = /([A-Za-z]:[\\/][^\s,;`'"()]+|\/[^\s,;`'"()]+)/.exec(m[1]);
4212
+ return pm ? pm[1].replace(/[.)]+$/, '') : null;
4213
+ }
4214
+
4215
+ function writeIntent(ctx, p, tag, extra) {
4216
+ try {
4217
+ const f = intentFile(ctx, p.session_id, tag.task_id, tag.role);
4218
+ fs.mkdirSync(path.dirname(f), { recursive: true });
4219
+ try { fs.unlinkSync(`${f.slice(0, -5)}.claim`); } catch (_) { /* no earlier claim */ }
4220
+ const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
4221
+ writeFileAtomic(f, `${JSON.stringify(Object.assign({
4222
+ session_id: typeof p.session_id === 'string' ? p.session_id : null,
4223
+ task_id: tag.task_id || null,
4224
+ role: tag.role || 'exec',
4225
+ gen: 1,
4226
+ agent_type: typeof ti.subagent_type === 'string' && ti.subagent_type ? ti.subagent_type : 'general-purpose',
4227
+ parent_task_id: tag.parent_task_id || null,
4228
+ worktree: briefWorktree(ti.prompt),
4229
+ run_dir: briefRunDir(ti.prompt, tag.task_id), // s6: where its dp-out/STATUS lands (stuck after-status)
4230
+ t: Date.now(),
4231
+ }, extra || {}))}\n`);
4232
+ return f;
4233
+ } catch (e) {
4234
+ logError(ctx.home, e, 'live-intent');
4235
+ return null;
4236
+ }
4237
+ }
4238
+
4239
+ /** Oldest unclaimed intent of this session + agent_type; claimed by exclusive create so two parallel starts never share one. */
4240
+ function claimIntent(ctx, sessionId, agentType, agentId, now) {
4241
+ const dir = livePendingDir(ctx);
4242
+ let names = [];
4243
+ try { names = fs.readdirSync(dir); } catch (_) { return null; }
4244
+ const set = new Set(names);
4245
+ const cands = [];
4246
+ for (const n of names) {
4247
+ if (!n.endsWith('.json') || set.has(`${n.slice(0, -5)}.claim`)) continue;
4248
+ const r = readJsonFile(path.join(dir, n), null);
4249
+ if (!r || r.session_id !== sessionId || r.agent_type !== agentType) continue;
4250
+ const at = liveMs(r.t);
4251
+ if (at === null || now - at > LIVE_CLAIM_MS) continue;
4252
+ cands.push({ n, r, at });
4253
+ }
4254
+ cands.sort((a, b) => a.at - b.at);
4255
+ for (const c of cands) {
4256
+ try {
4257
+ fs.writeFileSync(path.join(dir, `${c.n.slice(0, -5)}.claim`), `${agentId}\n`, { flag: 'wx' });
4258
+ return c.r;
4259
+ } catch (_) { /* another start claimed it */ }
4260
+ }
4261
+ return null;
4262
+ }
4263
+
4264
+ /** SubagentStart: register the agent (see the block comment above). Prints nothing. Returns the record or null. */
4265
+ function handleSubagentStart(p, ctx) {
4266
+ const agentId = typeof p.agent_id === 'string' && p.agent_id ? p.agent_id : null;
4267
+ if (!agentId) return null;
4268
+ const type = typeof p.agent_type === 'string' && p.agent_type ? p.agent_type : 'general-purpose';
4269
+ const sid = typeof p.session_id === 'string' && p.session_id ? p.session_id : null;
4270
+ const now = Date.now();
4271
+ const intent = claimIntent(ctx, sid, type, agentId, now);
4272
+ if (!intent && !AGENT_TYPE_ROLES[type]) return null; // internal / untagged subagent: not ours
4273
+ const role = intent ? intent.role : AGENT_TYPE_ROLES[type];
4274
+ const ts = new Date(now).toISOString();
4275
+ const rec = {
4276
+ agent_id: agentId,
4277
+ session_id: sid,
4278
+ task_id: intent ? intent.task_id : null,
4279
+ role,
4280
+ gen: intent ? (Number(intent.gen) || 1) : (role === 'helper' ? 2 : 1),
4281
+ agent_type: type,
4282
+ parent_task_id: intent ? intent.parent_task_id || null : null,
4283
+ start: ts,
4284
+ last_activity: ts,
4285
+ cwd: (intent && intent.worktree) || (typeof p.cwd === 'string' ? p.cwd : null),
4286
+ live_session: intent && typeof intent.live_session === 'number' ? intent.live_session : null,
4287
+ live_machine: intent && typeof intent.live_machine === 'number' ? intent.live_machine : null,
4288
+ // s6: liveness evidence for `reap` / the stuck reminder (the transcript does not exist yet at SubagentStart)
4289
+ transcript: liveTranscriptGuess(p.transcript_path, agentId),
4290
+ session_transcript: typeof p.transcript_path === 'string' && p.transcript_path ? p.transcript_path : null,
4291
+ run_dir: intent && typeof intent.run_dir === 'string' ? intent.run_dir : null,
4292
+ };
4293
+ fs.mkdirSync(liveDir(ctx), { recursive: true });
4294
+ writeFileAtomic(liveFile(ctx, agentId), `${JSON.stringify(rec)}\n`);
4295
+ // core 0.7.3 (s5): one counts-only sample per registered agent (no disk IO, no paths) for `dieu-phoi load report`
4296
+ appendLoadSample(ctx, Object.assign({ ts, src: 'start' }, liveLoadCounts(ctx, now)));
4297
+ return rec;
4298
+ }
4299
+
4300
+ /** core 0.7.3 (s5): data/load.jsonl, one JSON object per line (written by SubagentStart and `dieu-phoi load sample`). */
4301
+ function loadFile(ctx) { return path.join(ctx.paths.data, 'load.jsonl'); }
4302
+
4303
+ /**
4304
+ * Counts for a load sample, read the same way as the cap: per-session exec/plan/verify (gen 1, keyed by sha8 of the
4305
+ * session), every live agent + pending intent, and queue = intents still waiting for their SubagentStart. No IO.
4306
+ */
4307
+ function liveLoadCounts(ctx, now) {
4308
+ try {
4309
+ const live = readLive(ctx, now);
4310
+ const bySession = {};
4311
+ for (const r of live.agents.concat(live.pending)) {
4312
+ if (!r.session_id || !LIVE_SESSION_ROLES.includes(r.role) || Number(r.gen) > 1) continue;
4313
+ const k = sha8(r.session_id);
4314
+ bySession[k] = (bySession[k] || 0) + 1;
4315
+ }
4316
+ return { live_session_counts: bySession, live_machine: live.agents.length + live.pending.length, queue: live.pending.length };
4317
+ } catch (_) {
4318
+ return { live_session_counts: {}, live_machine: null, queue: null };
4319
+ }
4320
+ }
4321
+
4322
+ /** Appends one sample line to data/load.jsonl. Never throws. */
4323
+ function appendLoadSample(ctx, rec) {
4324
+ try {
4325
+ fs.mkdirSync(ctx.paths.data, { recursive: true });
4326
+ fs.appendFileSync(loadFile(ctx), `${JSON.stringify(rec)}\n`);
4327
+ return true;
4328
+ } catch (e) {
4329
+ logError(ctx.home, e, 'load-sample');
4330
+ return false;
4331
+ }
4332
+ }
4333
+
4334
+ /** SubagentStop: the agent is no longer live. */
4335
+ function removeLive(ctx, agentId) {
4336
+ if (!agentId) return false;
4337
+ try { fs.unlinkSync(liveFile(ctx, agentId)); return true; } catch (_) { return false; }
4338
+ }
4339
+
4340
+ /** --watch: refresh last_activity (≤ once per LIVE_TOUCH_MS), fill a missing task from the watch state; create if absent. */
4341
+ function touchLive(ctx, p, agentId, st) {
4342
+ try {
4343
+ const f = liveFile(ctx, agentId);
4344
+ const now = Date.now();
4345
+ const rec = readJsonFile(f, null);
4346
+ if (rec && typeof rec === 'object') {
4347
+ const fill = !rec.task_id && st.task_id;
4348
+ if (!fill && now - (liveMs(rec.last_activity) || 0) < LIVE_TOUCH_MS) return false;
4349
+ rec.last_activity = new Date(now).toISOString();
4350
+ if (!rec.transcript || !fs.existsSync(rec.transcript)) rec.transcript = subagentTranscriptPath(p, agentId) || rec.transcript || null;
4351
+ 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; }
4352
+ writeFileAtomic(f, `${JSON.stringify(rec)}\n`);
4353
+ return true;
4354
+ }
4355
+ // no SubagentStart record (hook registered after the agent started, or an older setup): register it now
4356
+ const ts = new Date(now).toISOString();
4357
+ fs.mkdirSync(liveDir(ctx), { recursive: true });
4358
+ writeFileAtomic(f, `${JSON.stringify({
4359
+ agent_id: agentId, session_id: typeof p.session_id === 'string' ? p.session_id : null,
4360
+ task_id: st.task_id || null, role: st.role || 'exec', gen: st.role === 'helper' || st.parent_task_id ? 2 : 1,
4361
+ agent_type: null, parent_task_id: st.parent_task_id || null, start: ts, last_activity: ts,
4362
+ cwd: typeof p.cwd === 'string' ? p.cwd : null, live_session: null, live_machine: null, from_watch: true,
4363
+ transcript: subagentTranscriptPath(p, agentId), session_transcript: null, run_dir: null,
4364
+ })}\n`);
4365
+ return true;
4366
+ } catch (e) {
4367
+ logError(ctx.home, e, 'live-touch');
4368
+ return false;
4369
+ }
4370
+ }
4371
+
4372
+ /** Counts for the dispatch event: those recorded when the dispatch was allowed (intent), else the current ones. */
4373
+ function dispatchLiveCounts(ctx, p, tag) {
4374
+ try {
4375
+ if (tag && tag.task_id) {
4376
+ const r = readJsonFile(intentFile(ctx, p.session_id, tag.task_id, tag.role), null);
4377
+ if (r && typeof r.live_session === 'number' && typeof r.live_machine === 'number') {
4378
+ return { live_session: r.live_session, live_machine: r.live_machine };
4379
+ }
4380
+ }
4381
+ const c = liveCounts(readLive(ctx), p.session_id);
4382
+ return { live_session: c.session, live_machine: c.machine };
4383
+ } catch (_) {
4384
+ return { live_session: null, live_machine: null };
4385
+ }
4386
+ }
4387
+
4388
+ function parallelDenyText(scope, c, lim, tag, now) {
4389
+ const list = scope === 'session' ? c.mine : c.all;
4390
+ const shown = list.slice().sort((a, b) => (liveMs(a.start) || liveMs(a.t) || 0) - (liveMs(b.start) || liveMs(b.t) || 0));
4391
+ const names = shown.slice(0, 8).map((r) => liveLabel(r, now, scope === 'machine')).join('; ');
4392
+ const head = scope === 'session'
4393
+ ? `dieu-phoi: phiên này đã có ${c.session}/${lim.session} agent exec/plan/verify đang chạy (trần max_parallel_session)`
4394
+ : `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)`;
4395
+ const first = shown[0] ? (shown[0].task_id || shown[0].agent_id) : null;
4396
+ return `${head} — chưa giao ${tag.role} của task ${tag.task_id}. Đang chạy: ${names}${list.length > 8 ? ` (+${list.length - 8})` : ''}. `
4397
+ + `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; `
4398
+ + '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. '
4399
+ + '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).';
4400
+ }
4401
+
4402
+ /**
4403
+ * Core 0.7.3 (s1): PreToolUse(Agent) of the orchestrating session. A dp dispatch with role exec/plan/verify is denied
4404
+ * (`parallel-cap`) while the session already runs max_parallel_session of them, or the machine (this DIEU_PHOI_HOME)
4405
+ * max_parallel_machine dp agents; `|parallel_ok` passes. An allowed dispatch leaves an intent for SubagentStart, and
4406
+ * at warn_parallel_machine it answers with an additionalContext warning instead of nothing.
4407
+ */
4408
+ function parallelCapGate(p, ctx, tag) {
4409
+ const now = Date.now();
4410
+ const lim = parallelLimits(ctx);
4411
+ const c = liveCounts(readLive(ctx, now), p.session_id);
4412
+ if (!tag.parallel_ok) {
4413
+ const scope = lim.session > 0 && c.session >= lim.session ? 'session'
4414
+ : (lim.machine > 0 && c.machine >= lim.machine ? 'machine' : null);
4415
+ if (scope) {
4416
+ const ev = logGate(ctx, p, `task:${tag.task_id}`, { task_id: tag.task_id, role: tag.role },
4417
+ { gate_action: 'deny', reason_code: 'parallel-cap', cap_scope: scope, live_session: c.session, live_machine: c.machine });
4418
+ return { denied: true, event: ev, stdout: preToolDeny(parallelDenyText(scope, c, lim, tag, now)) };
4419
+ }
4420
+ }
4421
+ writeIntent(ctx, p, tag, { live_session: c.session, live_machine: c.machine });
4422
+ if (lim.warn > 0 && c.machine + 1 >= lim.warn) {
4423
+ return {
4424
+ denied: false,
4425
+ stdout: JSON.stringify({
4426
+ hookSpecificOutput: {
4427
+ hookEventName: 'PreToolUse',
4428
+ 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'}); `
4429
+ + `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.`,
4430
+ },
4431
+ }),
4432
+ };
4433
+ }
4434
+ return null;
4435
+ }
4436
+
4437
+ /** Counts + per-agent rows for doctor / state. */
4438
+ function liveSummary(ctx, sessionId, now) {
4439
+ const t = typeof now === 'number' ? now : Date.now();
4440
+ const c = liveCounts(readLive(ctx, t), sessionId);
4441
+ return {
4442
+ session: sessionId ? c.session : null,
4443
+ machine: c.machine,
4444
+ limits: parallelLimits(ctx),
4445
+ agents: c.all.map((r) => ({
4446
+ agent_id: r.agent_id || null, task_id: r.task_id || null, role: r.role || null, gen: Number(r.gen) || 1,
4447
+ session: r.session_id ? sha8(r.session_id) : null, age_min: liveAgeMin(r, t), pending: !!r.pending,
4448
+ idle_min: r.pending ? null : Math.max(0, Math.round((t - (liveMs(r.last_activity) || t)) / 60000)),
4449
+ })),
4450
+ labels: c.all.map((r) => liveLabel(r, t, true)),
4451
+ };
4452
+ }
4453
+
4454
+ /*
4455
+ * Core 0.7.3 (s6): stuck / orphaned live agents. done/fail = SubagentStop (removeLive), so whatever is still in data/live
4456
+ * is running, stuck, or orphaned (crashed without SubagentStop). Evidence per record: last_activity (--watch heartbeat),
4457
+ * the subagent transcript mtime, the parent session transcript mtime, and a dp-out-/handoff- file of its task in the run
4458
+ * dir. stuck = idle ≥ stuck_idle_min, OR running ≥ stuck_hard_min, OR its dp-out (STATUS) is on disk and it is still live
4459
+ * stuck_after_status_min later (waiting on a background task). orphan = idle AND the parent session is silent too (or
4460
+ * unknown) — the agent is almost certainly gone and its live file only holds a parallel-cap slot until LIVE_TTL_MS.
4461
+ * The main session is reminded (UserPromptSubmit / PostToolUse(Agent)) to TaskStop + `dieu-phoi reap --yes`; an exec is
4462
+ * reminded about its own helpers through --watch. Nothing here kills or deletes: `reap --yes` does (lib/commands.cjs).
4463
+ */
4464
+ const STUCK_DEFAULTS = { stuck_idle_min: 20, stuck_hard_min: 120, stuck_after_status_min: 5 };
4465
+ /** One reminder per agent per this long (stored as stuck_notified in its live file). */
4466
+ const STUCK_REMIND_MS = 10 * 60 * 1000;
4467
+
4468
+ /** Thresholds in minutes: env DIEU_PHOI_<KEY> → config.json → STUCK_DEFAULTS. Numbers ≥ 0; 0 turns that rule off. */
4469
+ function stuckLimits(ctx) {
4470
+ const cfg = (ctx && ctx.config) || {};
4471
+ const pick = (key) => {
4472
+ for (const v of [process.env[`DIEU_PHOI_${key.toUpperCase()}`], cfg[key]]) {
4473
+ if (v === undefined || v === null || v === '') continue;
4474
+ const n = Number(v);
4475
+ if (Number.isFinite(n) && n >= 0) return n;
4476
+ }
4477
+ return STUCK_DEFAULTS[key];
4478
+ };
4479
+ return { idle: pick('stuck_idle_min'), hard: pick('stuck_hard_min'), after_status: pick('stuck_after_status_min') };
4480
+ }
4481
+
4482
+ function mtimeMs(f) {
4483
+ if (typeof f !== 'string' || !f) return null;
4484
+ try { return fs.statSync(f).mtimeMs; } catch (_) { return null; }
4485
+ }
4486
+
4487
+ /** Newest dp-out-<task>-* / handoff-<task>* .md in the run dir written after the agent started, else null. */
4488
+ function statusFileOf(rec, startMs) {
4489
+ if (!rec.run_dir || !rec.task_id) return null;
4490
+ const t = safeName(rec.task_id);
4491
+ let names = [];
4492
+ try { names = fs.readdirSync(rec.run_dir); } catch (_) { return null; }
4493
+ let best = null;
4494
+ for (const n of names) {
4495
+ if (!n.endsWith('.md') || !(n.startsWith(`dp-out-${t}-`) || n.startsWith(`handoff-${t}`))) continue;
4496
+ if (rec.role && n.startsWith(`dp-out-${t}-`) && !n.startsWith(`dp-out-${t}-${rec.role}`)) continue; // exec vs verify of one task
4497
+ const m = mtimeMs(path.join(rec.run_dir, n));
4498
+ if (m !== null && m >= (startMs || 0) - 1000 && (!best || m > best.mtime)) best = { file: path.join(rec.run_dir, n), mtime: m };
4499
+ }
4500
+ return best;
4501
+ }
4502
+
4503
+ /** Verdict on one live record: { state: ok|stuck|orphan, reasons[], idle_min, age_min, … }. Never throws. */
4504
+ function assessLive(rec, now, lim) {
4505
+ const startMs = liveMs(rec.start) || liveMs(rec.t) || now;
4506
+ const tMtime = mtimeMs(rec.transcript);
4507
+ const last = Math.max(liveMs(rec.last_activity) || 0, startMs, tMtime || 0);
4508
+ const idleMin = Math.max(0, (now - last) / 60000);
4509
+ const ageMin = Math.max(0, (now - startMs) / 60000);
4510
+ const reasons = [];
4511
+ if (lim.idle > 0 && idleMin >= lim.idle) reasons.push('idle');
4512
+ if (lim.hard > 0 && ageMin >= lim.hard) reasons.push('hard');
4513
+ const status = lim.after_status > 0 ? statusFileOf(rec, startMs) : null;
4514
+ if (status && (now - status.mtime) / 60000 >= lim.after_status) reasons.push('after-status');
4515
+ let state = reasons.length ? 'stuck' : 'ok';
4516
+ if (reasons.includes('idle')) {
4517
+ const sMtime = mtimeMs(rec.session_transcript);
4518
+ if (sMtime === null || (now - sMtime) / 60000 >= lim.idle) state = 'orphan';
4519
+ }
4520
+ return {
4521
+ agent_id: rec.agent_id || null, task_id: rec.task_id || null, role: rec.role || null, gen: Number(rec.gen) || 1,
4522
+ parent_task_id: rec.parent_task_id || null, session_id: rec.session_id || null, cwd: rec.cwd || null,
4523
+ start_ms: startMs, idle_min: Math.round(idleMin), age_min: Math.round(ageMin), reasons, state,
4524
+ transcript_seen: tMtime !== null, status_file: status ? status.file : null,
4525
+ status_min: status ? Math.round((now - status.mtime) / 60000) : null, notified: liveMs(rec.stuck_notified),
4526
+ };
4527
+ }
4528
+
4529
+ /** Stuck + orphaned live agents (oldest first). `sessionId` filters to one session, `parentTask` to one exec's helpers. */
4530
+ function stuckAgents(ctx, o) {
4531
+ const opt = o || {};
4532
+ const now = typeof opt.now === 'number' ? opt.now : Date.now();
4533
+ const lim = opt.limits || stuckLimits(ctx);
4534
+ const out = [];
4535
+ for (const rec of readLive(ctx, now).agents) {
4536
+ if (opt.sessionId && rec.session_id !== opt.sessionId) continue;
4537
+ if (opt.parentTask && rec.parent_task_id !== opt.parentTask) continue;
4538
+ const a = assessLive(rec, now, lim);
4539
+ if (a.state !== 'ok') out.push(a);
4540
+ }
4541
+ return out.sort((a, b) => a.start_ms - b.start_ms);
4542
+ }
4543
+
4544
+ 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' };
4545
+
4546
+ function stuckReasonText(a) {
4547
+ 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));
4548
+ if (a.state === 'orphan') bits.push('có vẻ mồ côi — phiên cũng im');
4549
+ return bits.join(', ');
4550
+ }
4551
+
4552
+ /** One reminder line per agent: what is stuck and the exact next step. */
4553
+ function stuckLine(a, forExec) {
4554
+ const who = `agent ${a.agent_id} task ${a.task_id || '?'} (${a.role || '?'}${a.gen > 1 ? `, gen ${a.gen}` : ''})`;
4555
+ const n = a.reasons.includes('idle') ? a.idle_min : a.age_min;
4556
+ const wt = a.cwd ? ` Worktree ${toPosix(a.cwd)} giữ nguyên (còn công việc).` : '';
4557
+ const stop = !forExec && a.gen > 1 && a.parent_task_id
4558
+ ? `helper của ${a.parent_task_id}: TaskStop ${a.agent_id} (hoặc nhắc exec cha dừng nó)`
4559
+ : `TaskStop ${a.agent_id}`;
4560
+ 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}`;
4561
+ }
4562
+
4563
+ /**
4564
+ * Reminder text for stuck agents not reminded in the last STUCK_REMIND_MS, or null. Marks them (stuck_notified) so a
4565
+ * busy session is not told the same thing on every prompt. Never throws.
4566
+ */
4567
+ function stuckReminder(ctx, o) {
4568
+ try {
4569
+ const opt = o || {};
4570
+ const now = typeof opt.now === 'number' ? opt.now : Date.now();
4571
+ const due = stuckAgents(ctx, Object.assign({}, opt, { now })).filter((a) => !(a.notified && now - a.notified < STUCK_REMIND_MS));
4572
+ if (!due.length) return null;
4573
+ for (const a of due) {
4574
+ const f = liveFile(ctx, a.agent_id);
4575
+ const rec = readJsonFile(f, null);
4576
+ if (rec && typeof rec === 'object') { rec.stuck_notified = new Date(now).toISOString(); try { writeFileAtomic(f, `${JSON.stringify(rec)}\n`); } catch (_) { /* best effort */ } }
4577
+ }
4578
+ 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\`)` : '');
4579
+ } catch (e) {
4580
+ logError(ctx.home, e, 'stuck-remind');
4581
+ return null;
4582
+ }
4583
+ }
4584
+
3945
4585
  /**
3946
4586
  * Core 0.7.1 (C1): global PreToolUse(Agent) in the orchestrating session. An exec dispatch of a task that already
3947
4587
  * handed off HANDOFF_CHAIN_DENY times is denied (the 4th, 5th… segment of one chain cost $15-36 per task in session
@@ -3953,11 +4593,12 @@ function handleChainGate(p, ctx) {
3953
4593
  const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
3954
4594
  const tag = resolveTag(ti.prompt, knownTaskTypes(ctx));
3955
4595
  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);
4596
+ let r = null;
4597
+ if (tag.role === 'verify') r = verifyOrphanGate(p, ctx, tag);
4598
+ else if (tag.role === 'exec') r = (tag.chain_ok ? null : chainGateDenial(p, ctx, tag)) || sessionCapGate(p, ctx, tag);
4599
+ if (r) return r;
4600
+ // core 0.7.3 (s1): parallel cap last, so a dispatch another gate denies never leaves an intent behind
4601
+ return LIVE_SESSION_ROLES.includes(tag.role) ? parallelCapGate(p, ctx, tag) : null;
3961
4602
  }
3962
4603
 
3963
4604
  function preToolDeny(reason) {
@@ -3996,7 +4637,7 @@ function verifyOrphanGate(p, ctx, tag) {
3996
4637
  denied: true,
3997
4638
  event: ev,
3998
4639
  stdout: preToolDeny(`dieu-phoi: verifier của task ${tag.task_id} không có lượt exec nào trong phiên này và không nằm trong kế hoạch đã duyệt — `
3999
- + 'không chạy verifier cho việc chưa giao. Giao exec của task trước; verifier gộp của mode fast dùng thẻ `|batch`.'),
4640
+ + 'không chạy verifier cho việc chưa giao. Giao exec của task trước; verifier gộp (fast, normal s≤4) dùng thẻ `|batch`.'),
4000
4641
  };
4001
4642
  }
4002
4643
 
@@ -4096,6 +4737,12 @@ function handleGate(p, ctx) {
4096
4737
  }),
4097
4738
  });
4098
4739
  if (why.length) return deny(why, codes.join('+'));
4740
+ // core 0.7.3 (s1): helpers count towards the machine cap
4741
+ const lim = parallelLimits(ctx);
4742
+ const lc = liveCounts(readLive(ctx), p.session_id);
4743
+ if (!tag.parallel_ok && lim.machine > 0 && lc.machine >= lim.machine) {
4744
+ 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');
4745
+ }
4099
4746
  const root = path.join(ctx.paths.data, 'gate');
4100
4747
  fs.mkdirSync(root, { recursive: true });
4101
4748
  pruneGate(root);
@@ -4103,6 +4750,13 @@ function handleGate(p, ctx) {
4103
4750
  const slot = gateReserve(dir, tag.task_id);
4104
4751
  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
4752
  const children = gateSlots(dir).length;
4753
+ const parentLive = readJsonFile(liveFile(ctx, agentId), null);
4754
+ writeIntent(ctx, p, tag, {
4755
+ gen: (parentLive && Number(parentLive.gen) ? Number(parentLive.gen) : 1) + 1,
4756
+ parent_task_id: tag.parent_task_id || st.task_id || null,
4757
+ worktree: (parentLive && parentLive.cwd) || null,
4758
+ live_session: lc.session, live_machine: lc.machine,
4759
+ });
4106
4760
  return { denied: false, slot, children, event: log('allow', { slot, children }) };
4107
4761
  }
4108
4762
 
@@ -4121,6 +4775,43 @@ function logGate(ctx, p, agentId, st, fields) {
4121
4775
  return ev;
4122
4776
  }
4123
4777
 
4778
+ /**
4779
+ * `--gate-bash` (PreToolUse(Bash|PowerShell) inside dp-* subagents): `knowledge review|merge` writes knowledge
4780
+ * outside the accept gate (a subagent could clear a STALE mark on its own). Deny it; the coordinator runs it.
4781
+ * Matching (0.7.4 S4a): split on `| ; & newline`. In a segment, a token naming the CLI (`dieu-phoi`, `dieu-phoi.cjs|.cmd|.js`,
4782
+ * optional `@version`, optional path: `node D:/x/dieu-phoi.cjs`, `npx @kienntpixon/dieu-phoi@0.7.4`) comes before a `knowledge`
4783
+ * token; between `knowledge` and the subcommand only flags are allowed (`--repo x`, `--repo=x`, before or after `knowledge`,
4784
+ * a flag's one value skipped), then `review|merge`. Known limit: a shell variable or alias hiding the CLI name
4785
+ * (`$DP knowledge review`) is not seen; this gate is a guard for subagents, not a sandbox.
4786
+ */
4787
+ const KNOWLEDGE_WRITE_SUBS = new Set(['review', 'merge']);
4788
+ const DP_CLI_TOKEN_RE = /(?:^|[\/\\])dieu-phoi(?:\.cjs|\.cmd|\.js)?(?:@\S*)?$/;
4789
+ function isKnowledgeWriteCall(cmd) {
4790
+ for (const seg of cmd.split(/[|;&\n]/)) {
4791
+ const toks = (seg.match(/"[^"]*"|'[^']*'|\S+/g) || []).map((t) => t.replace(/^["']|["']$/g, ''));
4792
+ for (let i = 0; i < toks.length; i++) {
4793
+ if (!DP_CLI_TOKEN_RE.test(toks[i])) continue;
4794
+ for (let j = i + 1; j < toks.length; j++) {
4795
+ if (toks[j] !== 'knowledge') continue;
4796
+ let flagValue = false; // previous token was a flag without `=`, so this word may be its value
4797
+ for (let k = j + 1; k < toks.length; k++) {
4798
+ const t = toks[k];
4799
+ if (KNOWLEDGE_WRITE_SUBS.has(t)) return true;
4800
+ if (t.startsWith('-')) { flagValue = !t.includes('='); continue; }
4801
+ if (!flagValue) break;
4802
+ flagValue = false;
4803
+ }
4804
+ }
4805
+ }
4806
+ }
4807
+ return false;
4808
+ }
4809
+ function handleGateBash(p) {
4810
+ const cmd = p.tool_input && typeof p.tool_input.command === 'string' ? p.tool_input.command : '';
4811
+ if (!isKnowledgeWriteCall(cmd)) return null;
4812
+ return { stdout: preToolDeny('dp-*: subagent không được chạy `knowledge review|merge` (đường ghi knowledge ngoài cổng accept). Báo điều phối viên thay vì tự chạy.') };
4813
+ }
4814
+
4124
4815
  /** `--gate-release` (PostToolUseFailure(Agent) inside dp-exec): the spawn failed, give its unconfirmed slot back. */
4125
4816
  function handleGateRelease(p, ctx) {
4126
4817
  if (p.tool_name !== 'Agent' && p.tool_name !== 'Task') return null;
@@ -4193,11 +4884,17 @@ function runHook(opts) {
4193
4884
  if (o.mode === 'watch') return handleWatch(p, ctx, o); // { stdout } or null
4194
4885
  if (o.mode === 'gate') return handleGate(p, ctx, o); // { stdout } or null
4195
4886
  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
4887
+ if (o.mode === 'gate-bash') return handleGateBash(p); // { stdout } (deny) or null
4888
+ if (name === 'PreToolUse') return handleChainGate(p, ctx); // { stdout } (deny / warn) or null
4889
+ if (name === 'SubagentStart') return handleSubagentStart(p, ctx); // live record or null, prints nothing
4197
4890
  if (name === 'SessionStart') return handleSessionStart(p, ctx); // { stdout } or null, never an event
4198
4891
  if (name === 'UserPromptSubmit') return handleUserPromptSubmit(p, ctx); // { stdout } or null
4199
4892
  if (name === 'SubagentStop') {
4200
- const r = handleSubagentStop(p, ctx);
4893
+ // s7: keep the live record (worktree, start) for the guarded leftover kill before the file goes
4894
+ let live = null;
4895
+ try { live = typeof p.agent_id === 'string' && p.agent_id ? readJsonFile(liveFile(ctx, p.agent_id), null) : null; } catch (_) { live = null; }
4896
+ try { removeLive(ctx, p.agent_id); } catch (e) { logError(home, e, 'live-remove'); }
4897
+ const r = handleSubagentStop(p, ctx, { live });
4201
4898
  if (r && typeof r.stdout === 'string') return r; // s15: block decision, printed as the hook's JSON answer
4202
4899
  ev = r;
4203
4900
  } else if (name === 'PostToolUse' || (!name && p.tool_name)) ev = handlePostToolUse(p, ctx);
@@ -4351,8 +5048,8 @@ async function runSync(ctx, opts) {
4351
5048
 
4352
5049
  module.exports = {
4353
5050
  runsDir, loadPlans, isDieuPhoiPrompt, foldPrompt, sinkStatusCached, sinkUrlKey, SINK_CACHE_TTL_MS, scoutBriefPath, quickHealth, startRun, startInfo, startKnowledgeOf, knowledgeLines, orchLessons, orchLessonLines, abortReasonOf, completionStatus, logGate, QUOTA_PAUSE_MARKER, stepIds, START_STEP_IDS, healthLine, formatStartBlock, handleUserPromptSubmit, START_PREFIX, buildSessionState, findOrphanAgents, orphanLines, ORPHAN_MIN_MS, ORPHAN_NO_COMPLETE_MS, formatSessionState, stateCounts, compactSystemMessage, handleSessionStart, dispatchInSession, STATE_TEXT_CAP, COMPACT_PREFIX,
4354
- modeLine, MODES, DEFAULT_MODE, LOCAL_ONLY_KINDS, MODE_ASK_LINE, isMode, rootModeFromEvents, rootModeFromText, dispatchModeFromEvents, taskModeInfo, planOfTask, modeFields,
4355
- modeSuggestion, modeSuggestLine, fastFailRate, rootTaskIds, FAST_FAIL_WINDOW, FAST_FAIL_MIN_N, FAST_FAIL_RATE,
5051
+ modeLine, MODES, DEFAULT_MODE, LOCAL_ONLY_KINDS, isMode, normMode, LEGACY_MODE, rootModeFromEvents, rootModeFromText, dispatchModeFromEvents, taskModeInfo, planOfTask, modeFields,
5052
+ rootTaskIds,
4356
5053
  testCommandInfo, testTimeOf, testSFromText,
4357
5054
  CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED, TYPE_ALIASES,
4358
5055
  sha8, nowIso, readJsonFile, writeFileAtomic, renameWithRetry, acquireLock, acquireFileLock, withFileLock, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
@@ -4363,9 +5060,11 @@ module.exports = {
4363
5060
  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
5061
  loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
4365
5062
  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,
5063
+ 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,
5064
+ STUCK_DEFAULTS, STUCK_REMIND_MS, stuckLimits, assessLive, stuckAgents, stuckReminder, stuckLine, stuckReasonText, statusFileOf, briefRunDir, liveTranscriptGuess,
4366
5065
  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
5066
  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,
5067
+ extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, openBackgroundTasks, scanLeftoverProcs, otherAgentsRunning, mainShellSince, ownWorktree, maybeSpawnSync, runHook, runSync,
4369
5068
  };
4370
5069
 
4371
5070
  if (require.main === module) {
@@ -4379,7 +5078,8 @@ if (require.main === module) {
4379
5078
  .finally(() => process.exit(0));
4380
5079
  } else {
4381
5080
  const mode = process.argv.includes('--watch') ? 'watch'
4382
- : (process.argv.includes('--gate-release') ? 'gate-release' : (process.argv.includes('--gate') ? 'gate' : null));
5081
+ : (process.argv.includes('--gate-release') ? 'gate-release'
5082
+ : (process.argv.includes('--gate-bash') ? 'gate-bash' : (process.argv.includes('--gate') ? 'gate' : null)));
4383
5083
  const r = runHook({ mode });
4384
5084
  if (r && typeof r.stdout === 'string') process.stdout.write(r.stdout);
4385
5085
  process.exitCode = 0;