@kienntpixon/dieu-phoi 0.7.1 → 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.1';
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',
@@ -272,6 +272,10 @@ function parseTag(text) {
272
272
  tag.parent_task_id = mm[1]; // core 0.5.0: gen-2 helper -> the dp-exec step that spawned it
273
273
  } else if (p === 'chain_ok') {
274
274
  tag.chain_ok = true; // core 0.7.1 (C1): the user agreed to continue a long handoff chain
275
+ } else if (p === 'session_ok') {
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
275
279
  } else if (p === 'batch') {
276
280
  tag.batch = true; // core 0.7.1 (C5): the one batched verifier of a fast request
277
281
  }
@@ -331,6 +335,9 @@ const TYPE_ALIASES = {
331
335
  'art-fix': 'art-gen', art: 'art-gen',
332
336
  // retro 2026-10-26 (C2): scout/read labels
333
337
  scan: 'search', read: 'search', 'code-read': 'search', explore: 'search', scout: 'search', lookup: 'search',
338
+ // core 0.7.2 (K6, retro 2026-11-05): labels that still arrived as bad-type in the 11-05 logs
339
+ 'test-run': 'test-write', 'test-verify': 'test-write', 'device-qa': 'test-write',
340
+ 'git-ops': 'mechanical-edit', 'release-build': 'mechanical-edit', 'read-scan': 'search',
334
341
  };
335
342
 
336
343
  /** Fixed task types + the profile's extra_task_types (art-gen when no profile is readable). */
@@ -634,7 +641,7 @@ function summarizeTranscript(lines, opts) {
634
641
  // ------------------------------------------------------------------ orchestrator (main session) usage
635
642
 
636
643
  const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
637
- const ORCH_CACHE_V = 2; // 2: + compactions
644
+ const ORCH_CACHE_V = 2; // 2: + compactions (0.7.2: a cold-probe cache with compactions null is re-probed, see orchestratorUsage)
638
645
  const ORCH_CHUNK = 1 << 20; // read granularity; memory = chunk + longest line
639
646
  const ORCH_SEEN_KEEP = 64; // requestIds kept for last-line-wins dedupe across scans
640
647
  const ORCH_HEAD_BYTES = 4096; // fingerprint of the file start (rotation / replacement check)
@@ -682,8 +689,9 @@ function ctxOfBuckets(b) {
682
689
  * passed to onLine (return true to stop). Returns { consumed: byte just past the last complete line
683
690
  * handled, read: bytes read }.
684
691
  */
685
- function scanUsageLines(fd, start, end, onLine) {
692
+ function scanUsageLines(fd, start, end, onLine, needle) {
686
693
  const buf = Buffer.allocUnsafe(ORCH_CHUNK);
694
+ const one = needle || null; // null: usage or compaction lines; else only lines holding `needle`
687
695
  let carry = null;
688
696
  let pos = start;
689
697
  let consumed = start;
@@ -700,8 +708,9 @@ function scanUsageLines(fd, start, end, onLine) {
700
708
  while ((nl = data.indexOf(0x0a, s)) >= 0) {
701
709
  const line = data.subarray(s, nl);
702
710
  s = nl + 1;
703
- if ((line.indexOf(USAGE_NEEDLE) >= 0 || line.indexOf(COMPACT_NEEDLE) >= 0)
704
- && onLine(line.toString('utf8')) === true) {
711
+ const hit = one ? line.indexOf(one) >= 0
712
+ : (line.indexOf(USAGE_NEEDLE) >= 0 || line.indexOf(COMPACT_NEEDLE) >= 0);
713
+ if (hit && onLine(line.toString('utf8')) === true) {
705
714
  return { consumed: base + s, read };
706
715
  }
707
716
  }
@@ -772,13 +781,22 @@ function edgeProbe(fd, size, st) {
772
781
  }
773
782
  st.cum_known = false;
774
783
  st.requests = null;
775
- st.compactions = null; // unknown until a full scan
784
+ st.offset = lastNl >= 0 ? lastNl + 1 : 0;
785
+ // core 0.7.2 (K5): count compactions over [0, offset) too (byte scan, JSON only on hits). The incremental
786
+ // scan takes over from offset, so nothing is counted twice; before 0.7.2 this stayed null for the session.
787
+ st.compactions = countCompactions(fd, st.offset);
776
788
  if (first) {
777
789
  st.baseline = ctxOfBuckets(usageBuckets(first.usage));
778
790
  st.baseline_req = first.key;
779
791
  }
780
792
  if (last) st.last_ctx = ctxOfBuckets(usageBuckets(last.usage));
781
- st.offset = lastNl >= 0 ? lastNl + 1 : 0;
793
+ }
794
+
795
+ /** Main-thread compaction markers in [0, end) (sidechain and look-alike text ignored). Never throws. */
796
+ function countCompactions(fd, end) {
797
+ let n = 0;
798
+ if (end > 0) scanUsageLines(fd, 0, end, (t) => { const u = parseMainLine(t); if (u && u.compact) n++; return false; }, COMPACT_NEEDLE);
799
+ return n;
782
800
  }
783
801
 
784
802
  /** Drop stale session caches. Only ever touches <data>/orch/*.json (never another directory). */
@@ -814,8 +832,9 @@ function orchCacheFile(paths, sessionId, transcriptPath) {
814
832
  * opts.cacheFile: per-session state ({offset, cost, seen requestIds, baseline, …}); each call then parses
815
833
  * only the bytes appended since the last call. A shrunk or replaced file (offset > size or a different
816
834
  * head fingerprint) is recomputed from 0.
817
- * opts.coldMaxBytes: no usable cache and the file is larger -> head/tail probe only (ctx + baseline,
818
- * cum_usd null); a later call without the limit (`dieu-phoi ctx`) recomputes the full sum.
835
+ * opts.coldMaxBytes: no usable cache and the file is larger -> head/tail probe (ctx + baseline, cum_usd null)
836
+ * plus a byte scan of the compaction markers before the probe offset; a later call without the limit
837
+ * (`dieu-phoi ctx`) recomputes the full sum.
819
838
  * Reads in chunks (memory = chunk + longest line), never the whole file at once. Never throws.
820
839
  */
821
840
  function orchestratorUsage(transcriptPath, pricing, opts) {
@@ -834,7 +853,8 @@ function orchestratorUsage(transcriptPath, pricing, opts) {
834
853
  const cached = o.cacheFile ? readJsonFile(o.cacheFile, null) : null;
835
854
  if (cached && cached.v === ORCH_CACHE_V && Number.isFinite(cached.offset) && cached.offset <= size
836
855
  && cached.head_len <= size && Array.isArray(cached.seen)
837
- && (cached.cum_known || o.coldMaxBytes)) {
856
+ && (cached.cum_known || (o.coldMaxBytes && typeof cached.compactions === 'number'))) {
857
+ // a probe state written before 0.7.2 holds compactions null: not reused, so the probe counts it (cum_known kept)
838
858
  const now = cached.head_len === fp.head_len ? fp : (() => {
839
859
  const b = Buffer.alloc(cached.head_len);
840
860
  if (cached.head_len) fs.readSync(fd, b, 0, cached.head_len, 0);
@@ -1184,7 +1204,8 @@ function buildSessionState(events, sessionId, opts) {
1184
1204
  const id = ev.task_id || agentTask.get(ev.agent_id);
1185
1205
  if (per.has(id)) per.get(id).steers.push(ev);
1186
1206
  }
1187
- if (ev.kind === 'override' && per.has(ev.task_id)) per.get(ev.task_id).overrides.push(ev);
1207
+ // core 0.7.2 (K4): an implicit override is a recorded deviation, not the user's choice: it never moves the approved tier
1208
+ if (ev.kind === 'override' && ev.source !== 'implicit' && per.has(ev.task_id)) per.get(ev.task_id).overrides.push(ev);
1188
1209
  }
1189
1210
  const byTime = (a, b) => evMs(a) - evMs(b) || a._i - b._i;
1190
1211
  const tasks = [];
@@ -1288,7 +1309,8 @@ function buildSessionState(events, sessionId, opts) {
1288
1309
  mode_suggest: sug.mode_suggest, mode_suggest_reason: sug.mode_suggest_reason,
1289
1310
  };
1290
1311
  });
1291
- return {
1312
+ const orphans = findOrphanAgents(events, taskIds.filter((id) => !discarded.has(id)), { nowMs: o.nowMs });
1313
+ return Object.assign({
1292
1314
  session_id: sessionId,
1293
1315
  generated_at: nowIso(),
1294
1316
  runs_dir: `${runs}/`,
@@ -1300,7 +1322,85 @@ function buildSessionState(events, sessionId, opts) {
1300
1322
  steps: p.steps.length, run_dir: `${runs}/${p.root_task_id}/`,
1301
1323
  })),
1302
1324
  tasks,
1303
- };
1325
+ }, orphans.length ? { orphans } : {});
1326
+ }
1327
+
1328
+ // retro 2026-11-05 K2: orphan agents. An exec/verify/plan agent whose LAST segment ended without a STATUS:/VERDICT: line
1329
+ // (`complete.ended_without_status`; older events: status "none"), or that never reported ending, and that has no agent
1330
+ // verdict, no newer dispatch of the same task+role and no user/auto accept, is listed so the orchestrator re-dispatches
1331
+ // (try+1) or drops it instead of waiting forever. Pure function of the events; never reads run-dir files.
1332
+ const ORPHAN_MIN_MS = 15 * 60 * 1000;
1333
+ // An agent with no `complete` at all may simply still be running in the background (state is rebuilt right after /compact):
1334
+ // only after this long is it called orphaned, and the line says to ignore it when the agent is still alive.
1335
+ const ORPHAN_NO_COMPLETE_MS = 2 * 3600 * 1000;
1336
+ const ORPHAN_MAX_LINES = 6;
1337
+
1338
+ function findOrphanAgents(events, taskIds, opts) {
1339
+ const now = (opts && opts.nowMs) || Date.now();
1340
+ const want = new Set(taskIds || []);
1341
+ const seenAgent = new Set();
1342
+ const latest = new Map(); // "task|role" -> newest dispatch
1343
+ const lastComplete = new Map();
1344
+ const judged = new Set(); // agent ids with any verdict
1345
+ const accepted = new Set();
1346
+ for (const ev of events) {
1347
+ if (!ev || typeof ev !== 'object') continue;
1348
+ if (ev.kind === 'dispatch' && want.has(ev.task_id) && !seenAgent.has(ev.agent_id)) {
1349
+ const role = ev.role || 'exec';
1350
+ if (!RESULT_ROLES.includes(role)) continue;
1351
+ seenAgent.add(ev.agent_id);
1352
+ latest.set(`${ev.task_id}|${role}`, ev);
1353
+ } else if (ev.kind === 'complete') {
1354
+ lastComplete.set(ev.agent_id, ev);
1355
+ } else if (ev.kind === 'verdict') {
1356
+ if (ev.agent_id) judged.add(ev.agent_id);
1357
+ if (ev.task_id && ev.outcome === 'accept') accepted.add(ev.task_id);
1358
+ else if (ev.task_id && (ev.outcome === 'reject' || ev.outcome === 'rework')) accepted.delete(ev.task_id);
1359
+ }
1360
+ }
1361
+ const out = [];
1362
+ for (const d of latest.values()) {
1363
+ if (judged.has(d.agent_id) || accepted.has(d.task_id)) continue;
1364
+ const c = lastComplete.get(d.agent_id);
1365
+ let reason;
1366
+ let since;
1367
+ if (c) {
1368
+ const ended = c.ended_without_status === true || (c.ended_without_status === undefined && c.status === 'none');
1369
+ if (!ended) continue;
1370
+ since = evMs(c);
1371
+ if (!since || now - since < ORPHAN_MIN_MS) continue;
1372
+ reason = c.inline_result === true ? 'inline' : 'no-status';
1373
+ } else {
1374
+ since = evMs(d);
1375
+ if (!since || now - since < ORPHAN_NO_COMPLETE_MS) continue;
1376
+ reason = 'no-complete';
1377
+ }
1378
+ out.push({
1379
+ task_id: d.task_id, agent_id: d.agent_id, role: d.role || 'exec', try: Number(d.try) || 1, task_type: d.task_type || null,
1380
+ score: Number.isFinite(d.score) ? d.score : null, urgency: d.urgency || 'normal', reason, age_min: Math.round((now - since) / 60000),
1381
+ });
1382
+ }
1383
+ return out;
1384
+ }
1385
+
1386
+ const ORPHAN_REASON_TEXT = {
1387
+ 'no-status': 'kết thúc mà báo cáo cuối không có dòng STATUS:/VERDICT:',
1388
+ inline: 'không ghi file dp-out (hook chỉ lưu .inline.md) và không có dòng STATUS:/VERDICT:',
1389
+ 'no-complete': 'không thấy kết thúc (nếu agent còn chạy nền thì bỏ qua dòng này)',
1390
+ };
1391
+
1392
+ /** Lines telling the orchestrator what to do with each orphan agent (at most ORPHAN_MAX_LINES). [] when none. */
1393
+ function orphanLines(orphans) {
1394
+ const list = Array.isArray(orphans) ? orphans : [];
1395
+ if (!list.length) return [];
1396
+ const lines = [`agent mồ côi: ${list.length} lượt giao chưa có kết quả dùng được, chưa verdict, chưa giao lại — đọc dp-out/.inline.md của bước (nếu có) rồi giao lại hoặc bỏ:`];
1397
+ for (const x of list.slice(0, ORPHAN_MAX_LINES)) {
1398
+ lines.push(` - ${x.task_id} [${x.task_type || '?'}] ${x.role} try${x.try} (${x.agent_id}) — ${ORPHAN_REASON_TEXT[x.reason] || x.reason}, ${x.age_min} phút trước; `
1399
+ + `giao lại: [dp:${x.task_type || '<type>'}|s${x.score === null ? '<n>' : x.score}|${x.urgency}|try${x.try + 1}|task=${x.task_id}|role=${x.role}]; `
1400
+ + `bỏ: dieu-phoi verdict ${x.agent_id} fail --reason ${x.reason === 'no-complete' ? 'no-complete' : 'ended-without-status'}`);
1401
+ }
1402
+ if (list.length > ORPHAN_MAX_LINES) lines.push(` (+${list.length - ORPHAN_MAX_LINES} nữa — xem đủ: dieu-phoi state --json, khoá orphans)`);
1403
+ return lines;
1304
1404
  }
1305
1405
 
1306
1406
  function toPosix(p) {
@@ -1362,7 +1462,8 @@ function stateCounts(state) {
1362
1462
  /** One short Vietnamese line shown to the user after /compact re-injected the state. */
1363
1463
  function compactSystemMessage(state) {
1364
1464
  const c = stateCounts(state);
1365
- return `dieu-phoi: đã nạp lại trạng thái điều phối sau khi nén (${c.tasks} task, ${c.running} agent đang chạy, ${c.pending} bước chưa giao)`;
1465
+ const orph = Array.isArray(state.orphans) && state.orphans.length ? `, ${state.orphans.length} agent mồ côi` : '';
1466
+ return `dieu-phoi: đã nạp lại trạng thái điều phối sau khi nén (${c.tasks} task, ${c.running} agent đang chạy, ${c.pending} bước chưa giao${orph})`;
1366
1467
  }
1367
1468
 
1368
1469
  /** "\nMode: <root>=<mode> …" for the roots of a session (last 6); '' when the state carries none. */
@@ -1393,7 +1494,8 @@ function formatSessionState(state, cap) {
1393
1494
  const render = (omitted) => {
1394
1495
  const body = stateBody(state, kept);
1395
1496
  const tail = omitted ? `\n(bỏ bớt ${omitted} task cũ nhất để vừa ${limit} ký tự; xem đủ: dieu-phoi state --json)` : '';
1396
- return `${header}\n${body.join('\n')}${tail}`;
1497
+ const orph = orphanLines(state.orphans);
1498
+ return `${header}\n${body.join('\n')}${tail}${orph.length ? `\n${orph.join('\n')}` : ''}`;
1397
1499
  };
1398
1500
  let omitted = 0;
1399
1501
  let text = render(0);
@@ -1578,8 +1680,8 @@ function startInfo(ctx, cwd) {
1578
1680
  };
1579
1681
  const k = startKnowledgeOf(cwd);
1580
1682
  if (k) info.knowledge = k;
1581
- const d = scanUnmergedDeltas();
1582
- if (d.count) info.unmerged_deltas = d;
1683
+ const d = scanUnmergedDeltasFor(ctx);
1684
+ if (hasUnmergedDeltas(d)) info.unmerged_deltas = d;
1583
1685
  return info;
1584
1686
  }
1585
1687
 
@@ -1615,11 +1717,18 @@ function formatStartBlock(info) {
1615
1717
 
1616
1718
  /** UserPromptSubmit: on a dieu-phoi prompt, pre-create the run and inject id/run dir/health/brief. */
1617
1719
  function handleUserPromptSubmit(p, ctx) {
1618
- 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
+ }
1619
1726
  const info = startInfo(ctx, typeof p.cwd === 'string' ? p.cwd : null);
1727
+ const text = stuck ? `${formatStartBlock(info)}
1728
+ ${stuck}` : formatStartBlock(info);
1620
1729
  return {
1621
1730
  stdout: JSON.stringify({
1622
- hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: formatStartBlock(info) },
1731
+ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text },
1623
1732
  }),
1624
1733
  info,
1625
1734
  };
@@ -1715,6 +1824,28 @@ const HANDOFF_CHAIN_WARN = 2;
1715
1824
  const HANDOFF_CHAIN_DENY = 3;
1716
1825
 
1717
1826
  /** Number of agents whose `complete` event has handoff:true for this task (substring scan of events.jsonl). */
1827
+ /**
1828
+ * Core 0.7.2 (K3/K5): whether events.jsonl holds a dispatch for any task in `taskIds` (optionally only of `role`).
1829
+ * Same substring scan as countTaskHandoffs: rare path, one read of the file.
1830
+ */
1831
+ function hasDispatchFor(ctx, taskIds, role) {
1832
+ let txt;
1833
+ try { txt = fs.readFileSync(ctx.paths.events, 'utf8'); } catch (_) { return false; }
1834
+ for (const id of taskIds) {
1835
+ if (!id) continue;
1836
+ const needle = `"task_id":${JSON.stringify(id)}`;
1837
+ let i = txt.indexOf(needle);
1838
+ while (i >= 0) {
1839
+ const start = txt.lastIndexOf('\n', i) + 1;
1840
+ const end = txt.indexOf('\n', i);
1841
+ const line = txt.slice(start, end < 0 ? undefined : end);
1842
+ if (line.includes('"kind":"dispatch"') && (!role || line.includes(`"role":${JSON.stringify(role)}`))) return true;
1843
+ i = end < 0 ? -1 : txt.indexOf(needle, end);
1844
+ }
1845
+ }
1846
+ return false;
1847
+ }
1848
+
1718
1849
  function countTaskHandoffs(ctx, taskId) {
1719
1850
  if (!taskId) return 0;
1720
1851
  let txt;
@@ -1749,9 +1880,9 @@ function handoffChainMessage(taskId, n) {
1749
1880
  const MODES = ['fast', 'normal', 'strict'];
1750
1881
  const DEFAULT_MODE = 'normal';
1751
1882
  /** Event kinds that stay on this machine: runSync does not send them (an older sink would answer bad_kind). */
1752
- const LOCAL_ONLY_KINDS = new Set(['mode']);
1753
- const MODE_ASK_LINE = 'mode: HỎI người dùng chọn mode cho yêu cầu này — fast (bỏ verifier riêng ở bước s≤6, test chọn lọc, '
1754
- + 'một verifier gộp) | normal | strict (mặc định normal nếu không trả lời); đặt bằng `dieu-phoi mode --root <root_task_id> <mode>`';
1883
+ const LOCAL_ONLY_KINDS = new Set(['mode', 'knowledge_merge', 'leftover', 'worktree', 'reap', 'reap_orphans']);
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); '
1885
+ + 'đặt bằng `dieu-phoi mode --root <root_task_id> <mode>`';
1755
1886
 
1756
1887
  function isMode(m) {
1757
1888
  return typeof m === 'string' && MODES.includes(m);
@@ -2528,9 +2659,48 @@ function storeByName(name, stores) {
2528
2659
  return { matches, store: matches.length === 1 ? matches[0] : null };
2529
2660
  }
2530
2661
 
2662
+ /** Task id a delta file belongs to: knowledge-delta-<task_id>[-r<n>|-h<n>].md (8 chars a-z0-9); null = untagged. */
2663
+ function deltaTagOf(file) {
2664
+ const m = /^knowledge-delta-([a-z0-9]{8})(?:-[rh]\d+)?\.md$/i.exec(path.basename(String(file)));
2665
+ return m ? m[1].toLowerCase() : null;
2666
+ }
2667
+
2668
+ /**
2669
+ * retro 11-05 (K1): who has judged what, from events + approved plans. `deltaState(run, tag)` is the user's decision on
2670
+ * a delta file in run dir `run` ('accept' | 'reject' | null): a tagged file follows the LAST user verdict of its task;
2671
+ * an untagged one (or one tagged with the run id) follows the tasks living in that run (own id, plan members,
2672
+ * dispatch `run`): accept when any of them was accepted, reject when all were rejected, else null.
2673
+ */
2674
+ function deltaClassifier(events, plansDir) {
2675
+ const last = new Map();
2676
+ const runOf = new Map();
2677
+ for (const e of events || []) {
2678
+ if (!e || !e.task_id) continue;
2679
+ if (e.kind === 'verdict' && e.by === 'user' && (e.outcome === 'accept' || e.outcome === 'reject')) last.set(e.task_id, e.outcome);
2680
+ if (e.kind === 'dispatch' && typeof e.run === 'string' && e.run) runOf.set(e.task_id, e.run);
2681
+ }
2682
+ const planRoot = new Map();
2683
+ for (const p of loadPlans(plansDir)) for (const x of p.steps) if (x && x.task_id) planRoot.set(x.task_id, p.root_task_id);
2684
+ const byRun = new Map();
2685
+ const add = (run, t) => { if (!byRun.has(run)) byRun.set(run, new Set()); byRun.get(run).add(t); };
2686
+ for (const t of last.keys()) { add(t, t); if (planRoot.has(t)) add(planRoot.get(t), t); if (runOf.has(t)) add(runOf.get(t), t); }
2687
+ return {
2688
+ state: (t) => last.get(t) || null,
2689
+ deltaState(run, tag) {
2690
+ if (tag && tag !== run) return last.get(tag) || null;
2691
+ const ts = Array.from(byRun.get(run) || []).map((t) => last.get(t));
2692
+ if (!ts.length) return null;
2693
+ return ts.includes('accept') ? 'accept' : 'reject';
2694
+ },
2695
+ };
2696
+ }
2697
+
2531
2698
  /**
2532
2699
  * Delta files in run dirs that are 24 h .. 30 d old and that no store has applied (hash in meta.json `applied`).
2533
2700
  * { count, runs: [{ run, n, oldest_ms, repos: { name: n } }], truncated } — never throws; count 0 when all is merged.
2701
+ * With opts.events (+ opts.plansDir) the files are split by what the user decided: count/runs = no user verdict yet
2702
+ * (24 h .. 30 d old); `accepted` = { count, runs: [{ run, n, oldest_ms, repos, tasks, untagged }] } = ALREADY ACCEPTED but
2703
+ * not merged (an error: no age floor, only the 30 d window); a task the user rejected is left out of both.
2534
2704
  */
2535
2705
  function scanUnmergedDeltas(opts) {
2536
2706
  const o = opts || {};
@@ -2538,6 +2708,8 @@ function scanUnmergedDeltas(opts) {
2538
2708
  const warnMs = o.warnMs === undefined ? DELTA_WARN_MS : o.warnMs;
2539
2709
  const windowMs = o.windowMs === undefined ? DELTA_WINDOW_MS : o.windowMs;
2540
2710
  const res = { count: 0, runs: [], truncated: false };
2711
+ const cls = Array.isArray(o.events) ? deltaClassifier(o.events, o.plansDir) : null;
2712
+ const acc = { count: 0, runs: [] };
2541
2713
  try {
2542
2714
  const runs = runsDir();
2543
2715
  const applied = new Set();
@@ -2551,26 +2723,36 @@ function scanUnmergedDeltas(opts) {
2551
2723
  let names;
2552
2724
  try { names = fs.readdirSync(dir); } catch (_) { continue; }
2553
2725
  const row = { run, n: 0, oldest_ms: 0, repos: {} };
2726
+ const arow = { run, n: 0, oldest_ms: 0, repos: {}, tasks: [], untagged: false };
2554
2727
  for (const f of names) {
2555
2728
  if (!/^knowledge-delta.*\.md$/i.test(f)) continue;
2556
2729
  const file = path.join(dir, f);
2557
2730
  let fst;
2558
2731
  try { fst = fs.statSync(file); } catch (_) { continue; }
2559
2732
  const age = now - fst.mtimeMs;
2560
- if (age < warnMs || age > windowMs) continue;
2733
+ if (age > windowMs) continue;
2734
+ const tag = deltaTagOf(f);
2735
+ const verdict = cls ? cls.deltaState(run, tag) : null;
2736
+ if (verdict === 'reject') continue;
2737
+ const into = verdict === 'accept' ? arow : row;
2738
+ if (into === row && age < warnMs) continue;
2561
2739
  if (seen++ >= DELTA_SCAN_MAX) { res.truncated = true; break; }
2562
2740
  const text = readTextFile(file);
2563
2741
  if (text == null || applied.has(deltaHash(text))) continue;
2564
2742
  const repo = deltaRepoOf(text);
2565
2743
  const label = repo ? (isBareRepoName(repo) ? repo : path.basename(repo.replace(/[\\/]+$/, ''))) : '?';
2566
- row.n++;
2567
- row.oldest_ms = Math.max(row.oldest_ms, age);
2568
- row.repos[label] = (row.repos[label] || 0) + 1;
2744
+ into.n++;
2745
+ into.oldest_ms = Math.max(into.oldest_ms, age);
2746
+ into.repos[label] = (into.repos[label] || 0) + 1;
2747
+ if (into === arow) { if (tag && tag !== run) { if (!arow.tasks.includes(tag)) arow.tasks.push(tag); } else arow.untagged = true; }
2569
2748
  }
2570
2749
  if (row.n) { res.runs.push(row); res.count += row.n; }
2750
+ if (arow.n) { acc.runs.push(arow); acc.count += arow.n; }
2571
2751
  if (res.truncated) break;
2572
2752
  }
2573
2753
  res.runs.sort((a, b) => b.n - a.n || b.oldest_ms - a.oldest_ms);
2754
+ acc.runs.sort((a, b) => b.n - a.n || b.oldest_ms - a.oldest_ms);
2755
+ if (acc.count) res.accepted = acc;
2574
2756
  } catch (e) {
2575
2757
  res.error = e.message;
2576
2758
  }
@@ -2583,15 +2765,38 @@ function ageText(ms) {
2583
2765
 
2584
2766
  /** One warning line (as an array, empty when nothing is pending) for the start block and `state`. */
2585
2767
  function unmergedDeltaLines(scan) {
2586
- if (!scan || !scan.count) return [];
2768
+ const out = [];
2769
+ const a = scan && scan.accepted;
2770
+ if (a && a.count) {
2771
+ const r0 = a.runs[0];
2772
+ const cmd = `dieu-phoi knowledge merge --run ${r0.run}${r0.tasks.length ? ` --task ${r0.tasks.slice(0, 8).join(',')}` : ''}${r0.untagged ? ' --untagged' : ''}`;
2773
+ const runText = a.runs.slice(0, 3).map((r) => `${r.run} ×${r.n}`).join(', ') + (a.runs.length > 3 ? ` (+${a.runs.length - 3} run)` : '');
2774
+ out.push(`knowledge_delta_accepted: LỖI — ${a.count} file delta của task ĐÃ nghiệm thu mà chưa vào kho (gộp lúc accept thất bại hoặc bị bỏ sót) — run ${runText}; cũ nhất ${ageText(Math.max(...a.runs.map((r) => r.oldest_ms)))}. `
2775
+ + `Gộp: \`${cmd} --dry-run\` rồi bỏ --dry-run.`);
2776
+ }
2777
+ if (!scan || !scan.count) return out;
2587
2778
  const oldest = Math.max(...scan.runs.map((r) => r.oldest_ms));
2588
2779
  const repos = {};
2589
2780
  for (const r of scan.runs) for (const [k, v] of Object.entries(r.repos)) repos[k] = (repos[k] || 0) + v;
2590
2781
  const repoText = Object.entries(repos).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, v]) => `${k} ×${v}`).join(', ');
2591
2782
  const runText = scan.runs.slice(0, 3).map((r) => `${r.run} ×${r.n}`).join(', ') + (scan.runs.length > 3 ? ` (+${scan.runs.length - 3} run)` : '');
2592
2783
  const more = scan.truncated ? ' (chỉ quét một phần)' : '';
2593
- return [`knowledge_delta: ${scan.count} file delta chưa gộp quá 24h${more} — run ${runText}; kho ${repoText}; cũ nhất ${ageText(oldest)}. `
2594
- + `Gộp: \`dieu-phoi knowledge merge --run ${scan.runs[0].run} --dry-run\` rồi bỏ --dry-run (hoặc verdict accept); bỏ qua nếu cố ý không gộp.`];
2784
+ const pending = scan.accepted ? ' (chưa có verdict người dùng)' : '';
2785
+ out.push(`knowledge_delta: ${scan.count} file delta chưa gộp quá 24h${pending}${more} — run ${runText}; kho ${repoText}; cũ nhất ${ageText(oldest)}. `
2786
+ + `Gộp: \`dieu-phoi knowledge merge --run ${scan.runs[0].run} --dry-run\` rồi bỏ --dry-run (hoặc verdict accept); bỏ qua nếu cố ý không gộp.`);
2787
+ return out;
2788
+ }
2789
+
2790
+ /** Whether a scan has anything to show (undecided and/or accepted-but-unmerged deltas). */
2791
+ function hasUnmergedDeltas(scan) {
2792
+ return !!(scan && (scan.count || (scan.accepted && scan.accepted.count)));
2793
+ }
2794
+
2795
+ /** scanUnmergedDeltas with the user's verdicts (events + approved plans of ctx), so accepted-unmerged is told apart. */
2796
+ function scanUnmergedDeltasFor(ctx) {
2797
+ let events = [];
2798
+ try { events = readEvents(ctx); } catch (_) { /* no log: everything stays undecided */ }
2799
+ return scanUnmergedDeltas({ events, plansDir: ctx && ctx.paths && ctx.paths.plans });
2595
2800
  }
2596
2801
 
2597
2802
  /**
@@ -2664,6 +2869,16 @@ function projectFields(p) {
2664
2869
  return {};
2665
2870
  }
2666
2871
 
2872
+ /** Run dir id the brief points at (retro 11-05 K1: accept finds the delta there whatever the plan/root says), else null. */
2873
+ function dispatchRun(prompt) {
2874
+ try {
2875
+ const probe = 'norundir0'; // resultRunDir falls back to runs/<task_id>: the probe id tells "named in the brief" from that fallback
2876
+ const d = resultRunDir(prompt, probe);
2877
+ const id = path.basename(d);
2878
+ return path.dirname(d) === path.resolve(runsDir()) && id !== probe && /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : null;
2879
+ } catch (_) { return null; }
2880
+ }
2881
+
2667
2882
  function buildDispatch(p, ctx) {
2668
2883
  const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
2669
2884
  const tr = p.tool_response;
@@ -2693,7 +2908,10 @@ function buildDispatch(p, ctx) {
2693
2908
  ...(tag.tag_issue ? { tag_issue: tag.tag_issue } : {}),
2694
2909
  ...(tag.type_alias_from ? { type_alias_from: tag.type_alias_from } : {}),
2695
2910
  ...(tag.chain_ok ? { chain_ok: true } : {}),
2911
+ ...(tag.session_ok ? { session_ok: true } : {}),
2912
+ ...(tag.parallel_ok ? { parallel_ok: true } : {}),
2696
2913
  ...(tag.batch ? { batch: true } : {}),
2914
+ ...(dispatchRun(ti.prompt) ? { run: dispatchRun(ti.prompt) } : {}),
2697
2915
  task_type: tag.task_type,
2698
2916
  score: tag.score,
2699
2917
  urgency: tag.urgency,
@@ -2710,7 +2928,7 @@ function buildDispatch(p, ctx) {
2710
2928
  orch_ctx_baseline: orch.ctx_baseline,
2711
2929
  orch_compactions: orch.compactions,
2712
2930
  orch_cum_usd: orch.cum_usd,
2713
- }, 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 } : {},
2714
2932
  projectFields(p),
2715
2933
  modeFields(ctx, tag),
2716
2934
  tierFields(ctx, tag, normalizeModel(trObj.resolvedModel) || normalizeModel(ti.model) || null, explicitEffort || parentEffort || null),
@@ -2775,7 +2993,21 @@ function handlePostToolUse(p, ctx) {
2775
2993
  if (n) ev.task_handoffs = n;
2776
2994
  if (n >= HANDOFF_CHAIN_WARN) notes.push(handoffChainMessage(ev.task_id, n));
2777
2995
  }
2996
+ // core 0.7.2 (K4): an exec with no choose record gets a reminder; a tier that differs from choose's answer without an
2997
+ // override logged for it leaves an implicit override event right after the dispatch.
2998
+ let implicit = null;
2999
+ if (ev.task_id && ev.role === 'exec' && isValidTaskId(ev.task_id)) {
3000
+ if (!ev.suggested_tier) notes.push(noChooseMessage(ev.task_id));
3001
+ else implicit = implicitOverrideEvent(ctx, ev);
3002
+ }
2778
3003
  appendEvent(ctx, ev);
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
+ }
2779
3011
  if (notes.length) {
2780
3012
  Object.defineProperty(ev, 'stdout', {
2781
3013
  enumerable: false,
@@ -2785,6 +3017,51 @@ function handlePostToolUse(p, ctx) {
2785
3017
  return ev;
2786
3018
  }
2787
3019
 
3020
+ /** core 0.7.2 (K4): reminder for an exec whose task has no choose record (data/suggest) when it was dispatched. */
3021
+ function noChooseMessage(taskId) {
3022
+ return `dieu-phoi: exec của task ${taskId} được giao mà chưa có kết quả \`choose\` (không có tier trong data/suggest). Lần giao sau chạy \`dieu-phoi choose --type <task_type> --task ${taskId} --json\` trước khi giao; tier giao khác tier choose trả mà không có --override sẽ được ghi là override implicit.`;
3023
+ }
3024
+
3025
+ /**
3026
+ * core 0.7.2 (K4): the implicit override for an exec dispatch whose tier differs from what choose answered
3027
+ * (choose_tier, else suggested_tier) while no override of this task to that tier is logged yet. Null otherwise.
3028
+ */
3029
+ function implicitOverrideEvent(ctx, ev) {
3030
+ const dispatched = ev.dispatched_tier;
3031
+ const said = ev.choose_tier || ev.suggested_tier;
3032
+ if (!dispatched || dispatched.includes('?') || dispatched === said) return null;
3033
+ if (overrideLoggedFor(ctx, ev.task_id, dispatched)) return null;
3034
+ return Object.assign({
3035
+ schema: SCHEMA,
3036
+ kind: 'override',
3037
+ ts: nowIso(),
3038
+ agent_id: `task:${ev.task_id}`,
3039
+ task_id: ev.task_id,
3040
+ task_type: ev.task_type || null,
3041
+ suggested_tier: said,
3042
+ chosen_tier: dispatched,
3043
+ source: 'implicit',
3044
+ }, commonFields(ctx));
3045
+ }
3046
+
3047
+ /** Whether events.jsonl already holds an override (explicit or implicit) of this task to this tier. */
3048
+ function overrideLoggedFor(ctx, taskId, tier) {
3049
+ let txt;
3050
+ try { txt = fs.readFileSync(ctx.paths.events, 'utf8'); } catch (_) { return false; }
3051
+ const needle = `"task_id":${JSON.stringify(taskId)}`;
3052
+ let i = txt.indexOf('"kind":"override"');
3053
+ while (i >= 0) {
3054
+ const start = txt.lastIndexOf('\n', i) + 1;
3055
+ const end = txt.indexOf('\n', i);
3056
+ const line = txt.slice(start, end < 0 ? undefined : end);
3057
+ if (line.includes(needle)) {
3058
+ try { if (JSON.parse(line).chosen_tier === tier) return true; } catch (_) { /* a junk line never matches */ }
3059
+ }
3060
+ i = end < 0 ? -1 : txt.indexOf('"kind":"override"', end);
3061
+ }
3062
+ return false;
3063
+ }
3064
+
2788
3065
  function hasAssistantUsage(lines) {
2789
3066
  return lines.some((l) => l && l.type === 'assistant' && l.message && l.message.usage
2790
3067
  && l.message.model !== '<synthetic>');
@@ -2930,7 +3207,102 @@ function persistInlineResult(lines, tag, opts) {
2930
3207
  return file;
2931
3208
  }
2932
3209
 
2933
- function handleSubagentStop(p, ctx) {
3210
+ // s15 (core 0.7.2): tools that start background work an agent may leave running, and the tools that stop it.
3211
+ // s16 (core 0.7.2): the formats below were checked against real subagent transcripts (Claude Code, 2026-11):
3212
+ // - Bash/PowerShell result "Command running in background with ID: <id>. Output is being written to: ..." appears for
3213
+ // run_in_background:true and also when a long foreground command is moved to the background by itself (no flag);
3214
+ // - Monitor result "Monitor started (task <id>, timeout ...)" (no "ID:"); a refused Monitor is a tool_result is_error:true;
3215
+ // - TaskStop input {task_id}, the same id;
3216
+ // - task finished = a <task-notification> carrying <status> (+ <task-id>, <tool-use-id>). It is delivered mostly as an
3217
+ // `attachment` line (attachment.type "queued_command", attachment.origin "task-notification", XML in attachment.prompt),
3218
+ // sometimes as a user line with string content. A <task-notification> without <status> is a Monitor progress <event>.
3219
+ const BG_START_TOOLS = new Set(['Bash', 'PowerShell', 'Monitor']);
3220
+ const BG_SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
3221
+ const BG_STOP_TOOLS = new Set(['TaskStop', 'KillShell']);
3222
+ const BG_ID_RE = /\bID:\s*([A-Za-z0-9_-]{2,64})/;
3223
+ const BG_MONITOR_ID_RE = /\(task\s+([A-Za-z0-9_-]{2,64})[,)]/;
3224
+ const BG_AUTO_RE = /^\s*Command running in background with ID:/; // anchored: test/grep output may quote the phrase
3225
+ const NOTIF_BLOCK_RE = /<task-notification>[\s\S]*?<\/task-notification>/g;
3226
+ const TASK_ID_TAG_RE = /<task-id>\s*([A-Za-z0-9_-]{2,64})\s*<\/task-id>/;
3227
+ const TOOL_USE_ID_TAG_RE = /<tool-use-id>\s*([A-Za-z0-9_-]{2,80})\s*<\/tool-use-id>/;
3228
+
3229
+ function textOfContent(c) {
3230
+ if (typeof c === 'string') return c;
3231
+ if (!Array.isArray(c)) return '';
3232
+ return c.map((b) => (b && b.type === 'text' && typeof b.text === 'string' ? b.text : '')).join('\n');
3233
+ }
3234
+
3235
+ /**
3236
+ * s15/s16: background work an agent opened and did not stop, read from its transcript (pure; returns ids, never a command).
3237
+ * Opened = a Bash/PowerShell tool_use with run_in_background:true, or whose result says "Command running in background
3238
+ * with ID:" (a long command moved to the background by itself), or a Monitor tool_use whose result is not an error. The id
3239
+ * comes from the tool_result ("... ID: <id>" / "(task <id>, ..."); without one the tool_use id stands in. Closed = a
3240
+ * TaskStop/KillShell whose input names the id, or a finished-task <task-notification> (one with <status>) naming the id
3241
+ * or the tool_use id, wherever it sits (queued_command attachment, user text). Returns the ids still open, in start order.
3242
+ */
3243
+ function openBackgroundTasks(lines) {
3244
+ const open = new Map(); // tool_use id -> task id, null while no id is known
3245
+ const finished = new Set(); // task ids and tool_use ids named by a stop or a finished notice (may precede the start result)
3246
+ const kinds = new Map(); // tool_use id -> tool name of every candidate start
3247
+ const flagged = new Set(); // tool_use ids that asked for the background or are Monitors
3248
+ const close = (id) => {
3249
+ finished.add(id);
3250
+ for (const [k, v] of open) if (k === id || v === id) open.delete(k);
3251
+ };
3252
+ const closeNotified = (text) => {
3253
+ if (!text || !text.includes('<task-notification>')) return;
3254
+ for (const blk of text.match(NOTIF_BLOCK_RE) || []) {
3255
+ if (!blk.includes('<status>')) continue; // a Monitor <event> notice: the task is still running
3256
+ const t = TASK_ID_TAG_RE.exec(blk);
3257
+ const u = TOOL_USE_ID_TAG_RE.exec(blk);
3258
+ if (t) close(t[1]);
3259
+ if (u) close(u[1]);
3260
+ }
3261
+ };
3262
+ for (const l of Array.isArray(lines) ? lines : []) {
3263
+ if (!l) continue;
3264
+ if (l.type === 'attachment') {
3265
+ const a = l.attachment;
3266
+ if (a && a.type === 'queued_command' && typeof a.prompt === 'string') closeNotified(a.prompt);
3267
+ continue;
3268
+ }
3269
+ const msg = l.message;
3270
+ if (!msg) continue;
3271
+ const isUser = l.type === 'user';
3272
+ if (typeof msg.content === 'string') {
3273
+ if (isUser) closeNotified(msg.content);
3274
+ continue;
3275
+ }
3276
+ if (!Array.isArray(msg.content)) continue;
3277
+ for (const b of msg.content) {
3278
+ if (!b || typeof b !== 'object') continue;
3279
+ if (b.type === 'tool_use' && typeof b.id === 'string') {
3280
+ const input = b.input && typeof b.input === 'object' ? b.input : {};
3281
+ if (BG_START_TOOLS.has(b.name)) {
3282
+ kinds.set(b.id, b.name);
3283
+ if (b.name === 'Monitor' || input.run_in_background === true) flagged.add(b.id);
3284
+ } else if (BG_STOP_TOOLS.has(b.name)) {
3285
+ const id = input.task_id || input.shell_id;
3286
+ if (typeof id === 'string' && id) close(id);
3287
+ }
3288
+ } else if (b.type === 'tool_result' && kinds.has(b.tool_use_id)) {
3289
+ const text = textOfContent(b.content);
3290
+ const kind = kinds.get(b.tool_use_id);
3291
+ const auto = BG_SHELL_TOOLS.has(kind) && BG_AUTO_RE.test(text);
3292
+ if ((!flagged.has(b.tool_use_id) && !auto) || b.is_error === true) continue; // foreground, or refused: nothing started
3293
+ const m = (kind === 'Monitor' ? BG_MONITOR_ID_RE.exec(text) : null) || BG_ID_RE.exec(text);
3294
+ const id = m ? m[1] : null;
3295
+ if (finished.has(b.tool_use_id) || (id && finished.has(id))) continue;
3296
+ open.set(b.tool_use_id, id);
3297
+ } else if (b.type === 'text' && typeof b.text === 'string' && isUser) {
3298
+ closeNotified(b.text);
3299
+ }
3300
+ }
3301
+ }
3302
+ return Array.from(open, ([k, v]) => v || k);
3303
+ }
3304
+
3305
+ function handleSubagentStop(p, ctx, opt) {
2934
3306
  const agentId = typeof p.agent_id === 'string' && p.agent_id ? p.agent_id : null;
2935
3307
  if (!agentId) return null;
2936
3308
  const prev = loadSegments(ctx, agentId);
@@ -2952,6 +3324,14 @@ function handleSubagentStop(p, ctx) {
2952
3324
  sum = summarizeTranscript(lines, { afterUuids: seen, pricing: loadPricing(ctx) });
2953
3325
  tag = tagFromTranscript(lines, knownTaskTypes(ctx));
2954
3326
  }
3327
+ // s15 (core 0.7.2): a tagged dp-* agent that stops while a background task it opened still runs is sent back once
3328
+ // (decision block: TaskStop it, then report). That second stop has stop_hook_active true: recorded, never blocked again.
3329
+ // The blocked stop writes nothing, so the next stop still counts the whole segment.
3330
+ const leftover = tag && tag.task_id ? openBackgroundTasks(lines) : [];
3331
+ if (leftover.length && p.stop_hook_active !== true && ctx.config.block_leftover_tasks !== false) {
3332
+ const reason = `Còn task nền chưa dừng: ${leftover.slice(0, 5).join(', ')}. TaskStop từng cái rồi trả lại STATUS.`;
3333
+ return { stdout: `${JSON.stringify({ decision: 'block', reason })}\n` };
3334
+ }
2955
3335
  const t = tag || UNTAGGED;
2956
3336
  const ev = Object.assign({
2957
3337
  schema: SCHEMA,
@@ -2980,12 +3360,18 @@ function handleSubagentStop(p, ctx) {
2980
3360
  ev.handoff = ev.status === 'handoff';
2981
3361
  ev.peak_ctx = sum ? sum.peak_ctx : null;
2982
3362
  ev.compactions = sum ? sum.compactions : null;
3363
+ // s15: background tasks still open when the segment ended (after the one allowed block): a count, never the commands
3364
+ if (leftover.length) ev.leftover_tasks = leftover.length;
2983
3365
  let inline = null;
2984
3366
  if (exists && tag) {
2985
3367
  try { inline = persistInlineResult(lines, tag, { finalText: p.last_assistant_message }); } catch (_) { /* best effort */ }
2986
3368
  }
2987
3369
  // exec/verify/plan only: true = it returned without writing its dp-out file (the hook saved the .inline.md)
2988
- if (tag && RESULT_ROLES.includes(tag.role)) ev.inline_result = Boolean(inline);
3370
+ if (tag && RESULT_ROLES.includes(tag.role)) {
3371
+ ev.inline_result = Boolean(inline);
3372
+ // retro 2026-11-05 K2: the segment ended with no STATUS:/VERDICT: line (or no final text) and was neither a quota pause nor a handoff
3373
+ ev.ended_without_status = exists && (ev.status === 'none' || ev.status === null);
3374
+ }
2989
3375
  // core 0.7.0 (s2): seconds spent running tests in this segment (transcript; else the dp-out `TEST_S:` line)
2990
3376
  const tt = exists && sum ? testTimeOf(lines.slice(sum.segment_start)) : null;
2991
3377
  ev.test_s = tt ? tt.test_s : null;
@@ -3013,9 +3399,180 @@ function handleSubagentStop(p, ctx) {
3013
3399
  verify: 'none', reason_code: `auto-${why}`, cause: null, round: null,
3014
3400
  }, commonFields(ctx)));
3015
3401
  }
3402
+ // s16 (core 0.7.2): processes the agent left behind (report; kill only with config kill_leftovers). Never throws, bounded to ~5 s.
3403
+ if (tag && tag.task_id && exists) {
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'); }
3412
+ }
3413
+ return ev;
3414
+ }
3415
+
3416
+ // ------------------------------------------------------------------ leftover processes (s16, core 0.7.2)
3417
+
3418
+ // The process-table helpers live in lib/procs.cjs. The installed hook is a single copied file, so `setup` puts the same
3419
+ // module next to it as bin/procs.cjs; the repo layout finds it under ./lib. Missing module = no scan, never an error.
3420
+ function loadProcs() {
3421
+ for (const f of ['./procs.cjs', './lib/procs.cjs']) {
3422
+ try { return require(f); } catch (_) { /* try the next place */ }
3423
+ }
3424
+ return null;
3425
+ }
3426
+
3427
+ const LEFTOVER_BUDGET_MS = 5000;
3428
+ const LEFTOVER_LIST_TIMEOUT_MS = 4000;
3429
+ const LEFTOVER_OVERLAP_MAX_AGE_MS = 24 * 3600 * 1000;
3430
+
3431
+ /** Start of an agent transcript (first line timestamp, epoch ms) or null. */
3432
+ function transcriptStartMs(lines) {
3433
+ for (const l of Array.isArray(lines) ? lines : []) {
3434
+ const t = l && typeof l.timestamp === 'string' ? Date.parse(l.timestamp) : NaN;
3435
+ if (Number.isFinite(t)) return t;
3436
+ }
3437
+ return null;
3438
+ }
3439
+
3440
+ /**
3441
+ * Is another dp agent alive during [startMs, endMs]? From dispatch/complete events: dispatched at or before endMs and not
3442
+ * completed before startMs (a foreground agent's dispatch is logged when it returns, so it counts too: the safe side).
3443
+ * Dispatches older than 24 h without a complete are ignored (crashed agents).
3444
+ */
3445
+ function otherAgentsRunning(events, agentId, startMs, endMs) {
3446
+ const disp = new Map();
3447
+ const done = new Map();
3448
+ for (const ev of Array.isArray(events) ? events : []) {
3449
+ if (!ev || !ev.agent_id || ev.agent_id === agentId) continue;
3450
+ const t = Date.parse(ev.ts);
3451
+ if (!Number.isFinite(t)) continue;
3452
+ if (ev.kind === 'dispatch') disp.set(ev.agent_id, Math.max(disp.get(ev.agent_id) || 0, t));
3453
+ else if (ev.kind === 'complete') done.set(ev.agent_id, Math.max(done.get(ev.agent_id) || 0, t));
3454
+ }
3455
+ for (const [id, d] of disp) {
3456
+ if (d > endMs) continue;
3457
+ const c = done.get(id);
3458
+ if (c === undefined) { if (endMs - d <= LEFTOVER_OVERLAP_MAX_AGE_MS) return true; continue; }
3459
+ if (c >= startMs) return true;
3460
+ }
3461
+ return false;
3462
+ }
3463
+
3464
+ /**
3465
+ * Looks at the process table after a dp agent stopped. Descendants of this session's Claude Code process created since the
3466
+ * agent started and still alive (not the hook's own chain, not claude/code-graph) -> one local `leftover` event
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.
3473
+ */
3474
+ function scanLeftoverProcs(ctx, a, deps) {
3475
+ const d = deps || {};
3476
+ if (ctx.config.scan_leftover_procs === false || (!d.procs && process.env.DIEU_PHOI_NO_PROCS === '1')) return null;
3477
+ const procs = d.procs || loadProcs();
3478
+ if (!procs) return null;
3479
+ const startMs = transcriptStartMs(a.lines) || (a.liveStart ? Date.parse(a.liveStart) || null : null);
3480
+ const now = typeof d.now === 'number' ? d.now : Date.now();
3481
+ // an agent that started over 24 h ago (a long-resumed or odd transcript) has no meaningful "created since" window
3482
+ if (startMs === null || now - startMs > LEFTOVER_OVERLAP_MAX_AGE_MS) return null;
3483
+ const t0 = Date.now();
3484
+ const list = procs.listProcesses({ timeoutMs: LEFTOVER_LIST_TIMEOUT_MS });
3485
+ if (!Array.isArray(list)) return null;
3486
+ const selfPid = d.selfPid || process.pid;
3487
+ const root = procs.claudeAncestor(list, selfPid);
3488
+ if (!root) return null;
3489
+ const left = procs.leftoverProcesses(list, { rootPid: root.pid, startMs, selfPid });
3490
+ if (!left.all.length) return null;
3491
+ const counts = new Map();
3492
+ for (const p of left.all) { const n = String(p.name || '?').toLowerCase(); counts.set(n, (counts.get(n) || 0) + 1); }
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]);
3494
+ const mode = typeof procs.killMode === 'function' ? procs.killMode(ctx.config) : (ctx.config.kill_leftovers === true ? 'tree' : 'off');
3495
+ let killed = 0;
3496
+ let skipped = null;
3497
+ const extra = {};
3498
+ if (mode === 'tree') {
3499
+ const events = d.events || readEvents(ctx);
3500
+ if (otherAgentsRunning(events, a.agentId, startMs, now)) skipped = 'other-agent-running';
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 }));
3517
+ }
3518
+ const ev = Object.assign({
3519
+ schema: SCHEMA, kind: 'leftover', ts: nowIso(), agent_id: a.agentId, task_id: a.taskId,
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));
3522
+ appendEvent(ctx, ev);
3016
3523
  return ev;
3017
3524
  }
3018
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
+
3019
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
3020
3577
  // / usage limit (brief template A, SKILL.md). Nothing looser: "STATUS: partial" + "quota" can be a real failure
3021
3578
  // ("Drive storage quota exceeded", a half-built rate limiter).
@@ -3405,6 +3962,17 @@ function handleWatch(p, ctx, o) {
3405
3962
  if (!agentId) return null;
3406
3963
  const { st, file, tpath } = watchState(p, ctx, agentId);
3407
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
+ };
3408
3976
  // Gate slots: a helper's first hook proves its spawn started; so does the exec's own successful Agent call.
3409
3977
  if (st.role === 'helper' && st.parent_task_id && st.task_id) gateConfirm(ctx, st.parent_task_id, st.task_id);
3410
3978
  if ((p.tool_name === 'Agent' || p.tool_name === 'Task') && st.role === 'exec') {
@@ -3413,21 +3981,21 @@ function handleWatch(p, ctx, o) {
3413
3981
  }
3414
3982
  const thresholds = Array.isArray(o && o.thresholds) && o.thresholds.length ? o.thresholds : watchThresholds(ctx);
3415
3983
  const tokens = lastContextTokens(tpath);
3416
- if (tokens === null) return null;
3984
+ if (tokens === null) return withHelperNote(null);
3417
3985
  let level = 0;
3418
3986
  while (level < thresholds.length && tokens >= thresholds[level]) level++;
3419
- if (level <= (Number(st.fired) || 0)) return null;
3987
+ if (level <= (Number(st.fired) || 0)) return withHelperNote(null);
3420
3988
  st.fired = level;
3421
3989
  st.ctx_tokens = tokens;
3422
3990
  st.t = Date.now();
3423
3991
  writeFileAtomic(file, `${JSON.stringify(st)}\n`);
3424
- return {
3992
+ return withHelperNote({
3425
3993
  level,
3426
3994
  ctx_tokens: tokens,
3427
3995
  stdout: JSON.stringify({
3428
3996
  hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: handoffMessage(st, tokens, level, thresholds) },
3429
3997
  }),
3430
- };
3998
+ });
3431
3999
  }
3432
4000
 
3433
4001
  /** A reservation that neither the parent's PostToolUse(Agent) nor the helper's own first hook confirmed in this long never started. */
@@ -3478,6 +4046,514 @@ function gateReserve(dir, child) {
3478
4046
  return 0;
3479
4047
  }
3480
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
+
3481
4557
  /**
3482
4558
  * Core 0.7.1 (C1): global PreToolUse(Agent) in the orchestrating session. An exec dispatch of a task that already
3483
4559
  * handed off HANDOFF_CHAIN_DENY times is denied (the 4th, 5th… segment of one chain cost $15-36 per task in session
@@ -3488,7 +4564,23 @@ function handleChainGate(p, ctx) {
3488
4564
  if (watchAgentId(p)) return null;
3489
4565
  const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
3490
4566
  const tag = resolveTag(ti.prompt, knownTaskTypes(ctx));
3491
- if (!tag || tag.role !== 'exec' || !tag.task_id || !isValidTaskId(tag.task_id) || tag.chain_ok) return null;
4567
+ if (!tag || !tag.task_id || !isValidTaskId(tag.task_id)) return null;
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;
4574
+ }
4575
+
4576
+ function preToolDeny(reason) {
4577
+ return JSON.stringify({
4578
+ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: reason },
4579
+ });
4580
+ }
4581
+
4582
+ /** Core 0.7.1 (C1): exec continuation of a task that already handed off HANDOFF_CHAIN_DENY times. */
4583
+ function chainGateDenial(p, ctx, tag) {
3492
4584
  const n = countTaskHandoffs(ctx, tag.task_id);
3493
4585
  if (n < HANDOFF_CHAIN_DENY) return null;
3494
4586
  const ev = logGate(ctx, p, `task:${tag.task_id}`, { task_id: tag.task_id, role: 'exec' },
@@ -3496,15 +4588,65 @@ function handleChainGate(p, ctx) {
3496
4588
  return {
3497
4589
  denied: true,
3498
4590
  event: ev,
3499
- stdout: JSON.stringify({
3500
- hookSpecificOutput: {
3501
- hookEventName: 'PreToolUse',
3502
- permissionDecision: 'deny',
3503
- permissionDecisionReason: `dieu-phoi: task ${tag.task_id} đã handoff ${n} lần (trần ${HANDOFF_CHAIN_DENY}) — không giao thêm exec tiếp nối. `
3504
- + `Giao planner (dp-scout) đọc handoff-${tag.task_id}.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\`). `
3505
- + 'Chỉ khi người dùng đồng ý nối tiếp: thêm `|chain_ok` vào thẻ.',
3506
- },
3507
- }),
4591
+ stdout: preToolDeny(`dieu-phoi: task ${tag.task_id} đã handoff ${n} lần (trần ${HANDOFF_CHAIN_DENY}) — không giao thêm exec tiếp nối. `
4592
+ + `Giao planner (dp-scout) đọc handoff-${tag.task_id}.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\`). `
4593
+ + 'Chỉ khi người dùng đồng ý nối tiếp: thêm `|chain_ok` vào thẻ.'),
4594
+ };
4595
+ }
4596
+
4597
+ /**
4598
+ * Core 0.7.2 (K3, retro 2026-11-05): a `role=verify` dispatch whose task has no exec dispatch in events.jsonl and sits
4599
+ * in no approved plan is a verifier for work that was never dispatched -> denied (`verify-orphan`). `|batch` (the one
4600
+ * verifier of a fast request, covering several steps) is exempt.
4601
+ */
4602
+ function verifyOrphanGate(p, ctx, tag) {
4603
+ if (tag.batch) return null;
4604
+ if (hasDispatchFor(ctx, [tag.task_id], 'exec')) return null;
4605
+ if (planOfTask(ctx, tag.task_id)) return null;
4606
+ const ev = logGate(ctx, p, `task:${tag.task_id}`, { task_id: tag.task_id, role: 'verify' },
4607
+ { gate_action: 'deny', reason_code: 'verify-orphan' });
4608
+ return {
4609
+ denied: true,
4610
+ event: ev,
4611
+ 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 — `
4612
+ + '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`.'),
4613
+ };
4614
+ }
4615
+
4616
+ /** Core 0.7.2 (K5): trần phiên điều phối cứng. Vượt một trong hai → exec mở ROOT MỚI bị chặn (session-cap). */
4617
+ const SESSION_CAP_TOKENS = 800000;
4618
+ const SESSION_CAP_COMPACTIONS = 10;
4619
+
4620
+ /**
4621
+ * Core 0.7.2 (K5, retro 2026-11-05): the orchestrating session is past SESSION_CAP_TOKENS of context or
4622
+ * SESSION_CAP_COMPACTIONS compactions. An exec that opens a new root is denied (`session-cap`); an exec of a root that
4623
+ * already has a dispatch (a continuation) passes; `|session_ok` lets the user accept the risk. Main-session only.
4624
+ */
4625
+ function sessionCapGate(p, ctx, tag) {
4626
+ if (tag.session_ok) return null;
4627
+ const plans = loadPlans(ctx.paths.plans);
4628
+ const step = planOfTask(ctx, tag.task_id);
4629
+ const root = step ? step.root : tag.task_id;
4630
+ if (hasDispatchFor(ctx, rootTaskIds(plans, root), null)) return null;
4631
+ const u = orchestratorUsage(p.transcript_path, loadPricing(ctx), {
4632
+ cacheFile: orchCacheFile(ctx.paths, p.session_id, p.transcript_path),
4633
+ coldMaxBytes: HOOK_COLD_MAX_BYTES,
4634
+ });
4635
+ const tokens = typeof u.ctx_tokens === 'number' ? u.ctx_tokens : null;
4636
+ const comp = typeof u.compactions === 'number' ? u.compactions : null;
4637
+ const over = (tokens !== null && tokens > SESSION_CAP_TOKENS) || (comp !== null && comp >= SESSION_CAP_COMPACTIONS);
4638
+ if (!over) return null;
4639
+ const ev = logGate(ctx, p, `task:${tag.task_id}`, { task_id: tag.task_id, role: 'exec' },
4640
+ { gate_action: 'deny', reason_code: 'session-cap' });
4641
+ const sid = p.session_id || '<session>';
4642
+ return {
4643
+ denied: true,
4644
+ event: ev,
4645
+ stdout: preToolDeny(`dieu-phoi: phiên điều phối đã chạm trần cứng (context ${tokens === null ? '?' : tokens} token, `
4646
+ + `${comp === null ? '?' : comp} lần nén; trần ${SESSION_CAP_TOKENS} token hoặc ${SESSION_CAP_COMPACTIONS} lần nén) — `
4647
+ + `không mở root mới trong phiên này. Đừng /compact thêm: ghi trạng thái (\`dieu-phoi state --session ${sid}\` + file trạng thái trong run dir), `
4648
+ + 'trình phần đã xong, rồi MỞ PHIÊN MỚI và nạp lại từ state; giao root mới từ phiên đó. '
4649
+ + 'Chỉ khi người dùng đồng ý giao ngay ở đây: thêm `|session_ok` vào thẻ.'),
3508
4650
  };
3509
4651
  }
3510
4652
 
@@ -3567,6 +4709,12 @@ function handleGate(p, ctx) {
3567
4709
  }),
3568
4710
  });
3569
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
+ }
3570
4718
  const root = path.join(ctx.paths.data, 'gate');
3571
4719
  fs.mkdirSync(root, { recursive: true });
3572
4720
  pruneGate(root);
@@ -3574,6 +4722,13 @@ function handleGate(p, ctx) {
3574
4722
  const slot = gateReserve(dir, tag.task_id);
3575
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');
3576
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
+ });
3577
4732
  return { denied: false, slot, children, event: log('allow', { slot, children }) };
3578
4733
  }
3579
4734
 
@@ -3664,11 +4819,19 @@ function runHook(opts) {
3664
4819
  if (o.mode === 'watch') return handleWatch(p, ctx, o); // { stdout } or null
3665
4820
  if (o.mode === 'gate') return handleGate(p, ctx, o); // { stdout } or null
3666
4821
  if (o.mode === 'gate-release') return handleGateRelease(p, ctx); // { released } or null, prints nothing
3667
- 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
3668
4824
  if (name === 'SessionStart') return handleSessionStart(p, ctx); // { stdout } or null, never an event
3669
4825
  if (name === 'UserPromptSubmit') return handleUserPromptSubmit(p, ctx); // { stdout } or null
3670
- if (name === 'SubagentStop') ev = handleSubagentStop(p, ctx);
3671
- else if (name === 'PostToolUse' || (!name && p.tool_name)) ev = handlePostToolUse(p, ctx);
4826
+ if (name === 'SubagentStop') {
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 });
4832
+ if (r && typeof r.stdout === 'string') return r; // s15: block decision, printed as the hook's JSON answer
4833
+ ev = r;
4834
+ } else if (name === 'PostToolUse' || (!name && p.tool_name)) ev = handlePostToolUse(p, ctx);
3672
4835
  if (ev) {
3673
4836
  try { maybeSpawnSync(ctx); } catch (e) { logError(home, e, 'spawn-sync'); }
3674
4837
  }
@@ -3818,7 +4981,7 @@ async function runSync(ctx, opts) {
3818
4981
  // ------------------------------------------------------------------ exports / main
3819
4982
 
3820
4983
  module.exports = {
3821
- 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, formatSessionState, stateCounts, compactSystemMessage, handleSessionStart, dispatchInSession, STATE_TEXT_CAP, COMPACT_PREFIX,
4984
+ 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,
3822
4985
  modeLine, MODES, DEFAULT_MODE, LOCAL_ONLY_KINDS, MODE_ASK_LINE, isMode, rootModeFromEvents, rootModeFromText, dispatchModeFromEvents, taskModeInfo, planOfTask, modeFields,
3823
4986
  modeSuggestion, modeSuggestLine, fastFailRate, rootTaskIds, FAST_FAIL_WINDOW, FAST_FAIL_MIN_N, FAST_FAIL_RATE,
3824
4987
  testCommandInfo, testTimeOf, testSFromText,
@@ -3828,12 +4991,14 @@ module.exports = {
3828
4991
  recordSuggestion, readSuggestion, suggestFile, SUGGEST_MAX_AGE_MS, countTaskHandoffs, handoffChainMessage, HANDOFF_CHAIN_WARN, HANDOFF_CHAIN_DENY, handleChainGate, tierFields, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
3829
4992
  priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
3830
4993
  firstPromptText, tagFromTranscript, summarizeTranscript,
3831
- RESULT_ROLES, BLOCKED_RESULT_NAME_RE, resultFileName, findResultFiles, resultRunDir, finalAssistantText, persistInlineResult, orchestratorUsage, orchCacheFile, findSessionTranscript, SESSION_ID_RE, HOOK_COLD_MAX_BYTES, appendEvent, EVENTS_LOCK_WAIT_MS, eventLinesFrom, readEventLines, readEvents, readEventsBuffer, archiveFiles, readArchiveLines,
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,
3832
4995
  loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
3833
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,
3834
- globMatch, pathspecOf, diffShas, dirtyFiles, computeFreshness, addDirty, indexStamp, freshnessCacheFile, readFreshCache, cachedFor, freshCounts, knowledgeSummary, shortFileList, readStoreIndex, relatedStatus, startKnowledge, deltaHash, deltaRepoOf, isBareRepoName, storeCatalog, storeByName, scanUnmergedDeltas, unmergedDeltaLines, DELTA_WARN_MS, FRESH_CACHE_MAX, DIFF_TIMEOUT_MS, HOOK_DIFF_TIMEOUT_MS, DIRTY_TIMEOUT_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,
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,
3835
5000
  projectsDir, findGit, originUrlFromConfig, normalizeRemoteUrl, resolveProject, readHead, repoFromBrief, projectFields, nativePath, isStoreDirName, aliasesFile, loadAliases, keyDirName,
3836
- extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
5001
+ extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, openBackgroundTasks, scanLeftoverProcs, otherAgentsRunning, mainShellSince, ownWorktree, maybeSpawnSync, runHook, runSync,
3837
5002
  };
3838
5003
 
3839
5004
  if (require.main === module) {