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