@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/CHANGELOG.md +39 -0
- package/README.md +3 -0
- package/agents/dp-exec.md +2 -0
- package/agents/dp-helper.md +2 -0
- package/agents/dp-scout.md +2 -0
- package/agents/dp-verify.md +2 -0
- package/bin/dieu-phoi.cjs +30 -2
- package/hook.cjs +1218 -53
- package/lib/aggregate.cjs +5 -4
- package/lib/args.cjs +1 -1
- package/lib/cbm.cjs +8 -2
- package/lib/commands.cjs +417 -36
- package/lib/deps.cjs +162 -0
- package/lib/knowledge.cjs +3 -4
- package/lib/load.cjs +332 -0
- package/lib/procs.cjs +422 -0
- package/lib/profile-scan.cjs +85 -0
- package/lib/profile-seed.cjs +147 -0
- package/lib/profile.cjs +141 -1
- package/lib/prune.cjs +66 -9
- package/lib/setup.cjs +59 -12
- package/lib/worktree.cjs +417 -0
- package/package.json +3 -1
- package/profile/lessons.md +85 -0
- package/profile/unity-lanes.md +316 -0
package/hook.cjs
CHANGED
|
@@ -26,7 +26,7 @@ const path = require('path');
|
|
|
26
26
|
const os = require('os');
|
|
27
27
|
const crypto = require('crypto');
|
|
28
28
|
|
|
29
|
-
const CORE_VERSION = '0.7.
|
|
29
|
+
const CORE_VERSION = '0.7.3';
|
|
30
30
|
const SCHEMA = 1;
|
|
31
31
|
const TASK_TYPES = [
|
|
32
32
|
'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
|
|
@@ -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
|
-
|
|
704
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
818
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1582
|
-
if (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
|
-
|
|
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:
|
|
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:
|
|
1754
|
-
+ '
|
|
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
|
|
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
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2594
|
-
|
|
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
|
-
|
|
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))
|
|
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 ||
|
|
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:
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
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')
|
|
3671
|
-
|
|
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
|
-
|
|
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) {
|