@kienntpixon/dieu-phoi 0.3.0 → 0.5.0
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/README.md +10 -3
- package/agents/dp-exec.md +38 -0
- package/agents/dp-helper.md +23 -0
- package/agents/dp-scout.md +25 -0
- package/agents/dp-verify.md +25 -0
- package/bin/dieu-phoi.cjs +8 -3
- package/hook.cjs +600 -23
- package/lib/aggregate.cjs +98 -19
- package/lib/choose.cjs +79 -19
- package/lib/commands.cjs +206 -23
- package/lib/setup.cjs +71 -2
- package/package.json +2 -1
package/hook.cjs
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* dieu-phoi hook + core helpers.
|
|
5
5
|
*
|
|
6
6
|
* This file is copied verbatim to ~/.claude/dieu-phoi/bin/hook.cjs by `dieu-phoi setup`
|
|
7
|
-
* and run by Claude Code on PostToolUse(Agent), SubagentStop, SessionStart(compact) and UserPromptSubmit
|
|
7
|
+
* and run by Claude Code on PostToolUse(Agent), SubagentStop, SessionStart(compact) and UserPromptSubmit; the dp-* agent
|
|
8
|
+
* files (`dieu-phoi setup` -> ~/.claude/agents) also run it inside those subagents as `--watch` (PostToolUse, context
|
|
9
|
+
* handoff watchdog), `--gate` (PreToolUse(Agent), gen-2 helper rules) and `--gate-release`. It must stay
|
|
8
10
|
* self-contained: Node built-ins only, no requires of other package files.
|
|
9
11
|
*
|
|
10
12
|
* Hook rules: stdout only for the JSON of SessionStart(compact) / UserPromptSubmit, always exit 0,
|
|
@@ -13,6 +15,9 @@
|
|
|
13
15
|
*
|
|
14
16
|
* node hook.cjs -> read hook payload from stdin, append event
|
|
15
17
|
* node hook.cjs --sync -> push unsynced events to the sink (spawned detached)
|
|
18
|
+
* node hook.cjs --watch -> dp subagent PostToolUse: context watchdog (additionalContext JSON or nothing)
|
|
19
|
+
* node hook.cjs --gate -> dp-exec PreToolUse(Agent): allow / deny a gen-2 helper spawn
|
|
20
|
+
* node hook.cjs --gate-release -> dp-exec PostToolUseFailure(Agent): give a failed spawn's helper slot back
|
|
16
21
|
*/
|
|
17
22
|
|
|
18
23
|
const fs = require('fs');
|
|
@@ -20,14 +25,14 @@ const path = require('path');
|
|
|
20
25
|
const os = require('os');
|
|
21
26
|
const crypto = require('crypto');
|
|
22
27
|
|
|
23
|
-
const CORE_VERSION = '0.
|
|
28
|
+
const CORE_VERSION = '0.5.0';
|
|
24
29
|
const SCHEMA = 1;
|
|
25
30
|
const TASK_TYPES = [
|
|
26
31
|
'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
|
|
27
32
|
'test-write', 'review', 'research-synth', 'content-write', 'plan-design', 'data-analysis',
|
|
28
33
|
];
|
|
29
34
|
const URGENCIES = ['relaxed', 'normal', 'urgent'];
|
|
30
|
-
const ROLES = ['exec', 'verify', 'plan'];
|
|
35
|
+
const ROLES = ['exec', 'verify', 'plan', 'helper'];
|
|
31
36
|
const MODEL_ALIASES = {
|
|
32
37
|
opus: 'claude-opus-5-5',
|
|
33
38
|
sonnet: 'claude-sonnet-5-5',
|
|
@@ -200,7 +205,7 @@ const UNTAGGED = Object.freeze({
|
|
|
200
205
|
task_type: 'untagged', score: null, urgency: null, try: null, task_id: null, role: 'exec',
|
|
201
206
|
});
|
|
202
207
|
|
|
203
|
-
/** Parse `[dp:<type>|s<n>|<urgency>|try<n>|task=<id>|role=<r>]` from the first line. */
|
|
208
|
+
/** Parse `[dp:<type>|s<n>|<urgency>|try<n>|task=<id>|role=<r>|parent=<id>]` from the first line. */
|
|
204
209
|
function parseTag(text) {
|
|
205
210
|
if (typeof text !== 'string') return null;
|
|
206
211
|
const first = text.replace(/^/, '').split(/\r?\n/, 1)[0].trim();
|
|
@@ -223,6 +228,8 @@ function parseTag(text) {
|
|
|
223
228
|
tag.task_id = mm[1];
|
|
224
229
|
} else if ((mm = /^role=([a-z]+)$/.exec(p)) && ROLES.includes(mm[1])) {
|
|
225
230
|
tag.role = mm[1];
|
|
231
|
+
} else if ((mm = /^parent=([A-Za-z0-9_-]{1,32})$/.exec(p))) {
|
|
232
|
+
tag.parent_task_id = mm[1]; // core 0.5.0: gen-2 helper -> the dp-exec step that spawned it
|
|
226
233
|
}
|
|
227
234
|
}
|
|
228
235
|
return tag;
|
|
@@ -366,23 +373,26 @@ function lastUuidOf(lines) {
|
|
|
366
373
|
return null;
|
|
367
374
|
}
|
|
368
375
|
|
|
369
|
-
/**
|
|
370
|
-
function
|
|
376
|
+
/** Text of the first user message of the transcript (the original prompt / brief), or null. */
|
|
377
|
+
function firstPromptText(lines) {
|
|
371
378
|
for (const l of lines) {
|
|
372
379
|
if (!l || l.type !== 'user' || !l.message) continue;
|
|
373
380
|
const c = l.message.content;
|
|
374
|
-
|
|
375
|
-
if (
|
|
376
|
-
else if (Array.isArray(c)) {
|
|
381
|
+
if (typeof c === 'string') return c;
|
|
382
|
+
if (Array.isArray(c)) {
|
|
377
383
|
const b = c.find((x) => x && x.type === 'text' && typeof x.text === 'string');
|
|
378
|
-
if (b)
|
|
384
|
+
if (b) return b.text;
|
|
379
385
|
}
|
|
380
|
-
if (text === null) continue;
|
|
381
|
-
return parseTag(text);
|
|
382
386
|
}
|
|
383
387
|
return null;
|
|
384
388
|
}
|
|
385
389
|
|
|
390
|
+
/** Tag from the first user message of the transcript (the original prompt). */
|
|
391
|
+
function tagFromTranscript(lines) {
|
|
392
|
+
const text = firstPromptText(lines);
|
|
393
|
+
return text === null ? null : parseTag(text);
|
|
394
|
+
}
|
|
395
|
+
|
|
386
396
|
/**
|
|
387
397
|
* Summarize a transcript segment. Usage counted once per requestId (last line wins).
|
|
388
398
|
* opts.afterUuids: uuids already counted by earlier segments; the segment starts after
|
|
@@ -737,10 +747,30 @@ function findSessionTranscript(claudeDir, sessionId) {
|
|
|
737
747
|
|
|
738
748
|
// ------------------------------------------------------------------ events (append-only)
|
|
739
749
|
|
|
740
|
-
/**
|
|
750
|
+
/**
|
|
751
|
+
* Append one event. events.jsonl is append-only: never rewritten, sync progress lives elsewhere.
|
|
752
|
+
* Core 0.4.0: one O_APPEND write of the whole line (hooks of parallel agents may append at the same time:
|
|
753
|
+
* an O_APPEND write lands as one unit, so lines never interleave). If the file does not
|
|
754
|
+
* end with '\n' (a writer died mid-line), a '\n' is written first so the torn fragment cannot swallow this
|
|
755
|
+
* event — readers skip the fragment as one unparsable line. Withdrawing a task = `dieu-phoi discard`, never an
|
|
756
|
+
* edit of this file.
|
|
757
|
+
*/
|
|
741
758
|
function appendEvent(ctx, ev) {
|
|
742
759
|
fs.mkdirSync(ctx.paths.data, { recursive: true });
|
|
743
|
-
fs.
|
|
760
|
+
const fd = fs.openSync(ctx.paths.events, 'a+');
|
|
761
|
+
try {
|
|
762
|
+
let lead = '';
|
|
763
|
+
const size = fs.fstatSync(fd).size;
|
|
764
|
+
if (size > 0) {
|
|
765
|
+
const last = Buffer.alloc(1);
|
|
766
|
+
if (fs.readSync(fd, last, 0, 1, size - 1) === 1 && last[0] !== 0x0a) lead = '\n';
|
|
767
|
+
}
|
|
768
|
+
const buf = Buffer.from(`${lead}${JSON.stringify(ev)}\n`, 'utf8');
|
|
769
|
+
let off = 0;
|
|
770
|
+
while (off < buf.length) off += fs.writeSync(fd, buf, off, buf.length - off);
|
|
771
|
+
} finally {
|
|
772
|
+
fs.closeSync(fd);
|
|
773
|
+
}
|
|
744
774
|
}
|
|
745
775
|
|
|
746
776
|
function readEventsBuffer(ctx) {
|
|
@@ -859,7 +889,10 @@ function verdictLayer(ev) {
|
|
|
859
889
|
const by = ev.by === 'user' ? 'user' : 'agent';
|
|
860
890
|
let cause = ev.cause || null;
|
|
861
891
|
if (by === 'user' && ev.outcome === 'reject' && cause !== 'scope') cause = 'quality';
|
|
862
|
-
|
|
892
|
+
// legacy quota pause recorded as timeout → aborted (same reading as the stats)
|
|
893
|
+
const outcome = by === 'agent' && ev.outcome === 'timeout' && /^(?:quota-pause|usage-limit|auto-quota-pause|auto-usage-limit|paused-for-quota)(?:-|$)/.test(String(ev.reason_code || ''))
|
|
894
|
+
? 'aborted' : ev.outcome;
|
|
895
|
+
return { by, outcome, cause };
|
|
863
896
|
}
|
|
864
897
|
|
|
865
898
|
function dispatchInSession(ev, sessionId) {
|
|
@@ -893,7 +926,9 @@ function buildSessionState(events, sessionId, opts) {
|
|
|
893
926
|
const runs = toPosix(o.runsDir || runsDir());
|
|
894
927
|
const taskIds = [];
|
|
895
928
|
const seenTask = new Set();
|
|
929
|
+
const discarded = new Set(events.filter((ev) => ev && ev.kind === 'discard' && ev.task_id).map((ev) => ev.task_id));
|
|
896
930
|
for (const ev of events) {
|
|
931
|
+
if (ev && discarded.has(ev.task_id)) continue;
|
|
897
932
|
if (dispatchInSession(ev, sessionId) && typeof ev.task_id === 'string' && ev.task_id && !seenTask.has(ev.task_id)) {
|
|
898
933
|
seenTask.add(ev.task_id);
|
|
899
934
|
taskIds.push(ev.task_id);
|
|
@@ -908,7 +943,7 @@ function buildSessionState(events, sessionId, opts) {
|
|
|
908
943
|
for (const s of p.steps) if (s && s.task_id && !stepOf.has(s.task_id)) stepOf.set(s.task_id, { plan: p, step: s });
|
|
909
944
|
}
|
|
910
945
|
for (const id of [...planOfRoot.keys(), ...stepOf.keys()]) {
|
|
911
|
-
if (!seenTask.has(id)) { seenTask.add(id); taskIds.push(id); }
|
|
946
|
+
if (!seenTask.has(id) && !discarded.has(id)) { seenTask.add(id); taskIds.push(id); }
|
|
912
947
|
}
|
|
913
948
|
const per = new Map(taskIds.map((id) => [id, { dispatches: [], user: [], steers: [], overrides: [], agentIds: new Set() }]));
|
|
914
949
|
events.forEach((ev, i) => {
|
|
@@ -925,7 +960,7 @@ function buildSessionState(events, sessionId, opts) {
|
|
|
925
960
|
const agentVerdicts = new Map();
|
|
926
961
|
for (const ev of events) {
|
|
927
962
|
if (!ev || typeof ev !== 'object') continue;
|
|
928
|
-
if (ev.kind === 'complete' && agentTask.has(ev.agent_id)) completes.set(ev.agent_id, (completes.get(ev.agent_id) ||
|
|
963
|
+
if (ev.kind === 'complete' && agentTask.has(ev.agent_id)) completes.set(ev.agent_id, Math.max(completes.get(ev.agent_id) || 1, evMs(ev) || 1));
|
|
929
964
|
if (ev.kind === 'verdict') {
|
|
930
965
|
const v = verdictLayer(ev);
|
|
931
966
|
if (v.by === 'user') {
|
|
@@ -970,7 +1005,9 @@ function buildSessionState(events, sessionId, opts) {
|
|
|
970
1005
|
const vs = (agentVerdicts.get(d.agent_id) || []).slice().sort(byTime);
|
|
971
1006
|
const last = vs[vs.length - 1];
|
|
972
1007
|
let status;
|
|
973
|
-
|
|
1008
|
+
// aborted (quota pause) then resumed and completed again, no verdict yet → done (waiting for a verdict)
|
|
1009
|
+
if (last && last.outcome === 'aborted' && (completes.get(d.agent_id) || 0) > evMs(last)) status = 'done';
|
|
1010
|
+
else if (last) status = last.outcome;
|
|
974
1011
|
else status = completes.get(d.agent_id) ? 'done' : 'running';
|
|
975
1012
|
return {
|
|
976
1013
|
agent_id: d.agent_id, role: d.role || 'exec', try: d.try === undefined ? null : d.try,
|
|
@@ -1209,6 +1246,32 @@ function scoutBriefPath(ctx) {
|
|
|
1209
1246
|
return fs.existsSync(f) ? toPosix(f) : null;
|
|
1210
1247
|
}
|
|
1211
1248
|
|
|
1249
|
+
const ORCH_LESSON_RE = /\[(?:orch|orchestrator)\]/i;
|
|
1250
|
+
const ORCH_LESSONS_MAX = 5;
|
|
1251
|
+
const ORCH_LESSON_CHARS = 240;
|
|
1252
|
+
const LESSONS_MAX_BYTES = 256 * 1024;
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Lessons addressed to the orchestrator (core 0.4.0): list lines of <skill_dir>/profile/lessons.md that carry the
|
|
1256
|
+
* tag `[orch]` (legacy `[orchestrator]`), the last ORCH_LESSONS_MAX of them, each cut to ORCH_LESSON_CHARS chars.
|
|
1257
|
+
* The orchestrator never reads lessons.md itself; `start` and the UserPromptSubmit block surface these lines.
|
|
1258
|
+
*/
|
|
1259
|
+
function orchLessons(ctx) {
|
|
1260
|
+
const sd = ctx.config && ctx.config.skill_dir;
|
|
1261
|
+
if (!sd) return [];
|
|
1262
|
+
let txt;
|
|
1263
|
+
try {
|
|
1264
|
+
const f = path.join(sd, 'profile', 'lessons.md');
|
|
1265
|
+
if (fs.statSync(f).size > LESSONS_MAX_BYTES) return [];
|
|
1266
|
+
txt = fs.readFileSync(f, 'utf8');
|
|
1267
|
+
} catch (_) { return []; }
|
|
1268
|
+
const out = txt.split(/\r?\n/).map((l) => l.trim())
|
|
1269
|
+
.filter((l) => /^[-*] /.test(l) && ORCH_LESSON_RE.test(l))
|
|
1270
|
+
.map((l) => l.replace(/^[-*] +/, ''))
|
|
1271
|
+
.map((l) => (l.length > ORCH_LESSON_CHARS ? `${l.slice(0, ORCH_LESSON_CHARS - 1)}…` : l));
|
|
1272
|
+
return out.slice(-ORCH_LESSONS_MAX);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1212
1275
|
/** Local, network-free health: config, profile, run dir, cached sink status. */
|
|
1213
1276
|
function quickHealth(ctx, runs) {
|
|
1214
1277
|
const problems = [];
|
|
@@ -1256,7 +1319,15 @@ function stepIds(root, n) {
|
|
|
1256
1319
|
function startInfo(ctx) {
|
|
1257
1320
|
const run = startRun();
|
|
1258
1321
|
const health = quickHealth(ctx, run.runs);
|
|
1259
|
-
return {
|
|
1322
|
+
return {
|
|
1323
|
+
id: run.id, run_dir: run.run_dir, step_ids: stepIds(run.id, START_STEP_IDS), health, scout_brief: scoutBriefPath(ctx),
|
|
1324
|
+
orch_lessons: orchLessons(ctx),
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
function orchLessonLines(info) {
|
|
1329
|
+
const ls = (info && info.orch_lessons) || [];
|
|
1330
|
+
return ls.length ? ['bài học cho điều phối viên (profile/lessons.md, [orch]):'].concat(ls.map((l) => ` - ${l}`)) : [];
|
|
1260
1331
|
}
|
|
1261
1332
|
|
|
1262
1333
|
function formatStartBlock(info) {
|
|
@@ -1267,6 +1338,7 @@ function formatStartBlock(info) {
|
|
|
1267
1338
|
`step_ids: ${info.step_ids.join(' ')}`,
|
|
1268
1339
|
`health: ${healthLine(info.health)}`,
|
|
1269
1340
|
`scout_brief: ${info.scout_brief || '(không thấy — dùng references/brief-template.md mục E)'}`,
|
|
1341
|
+
...orchLessonLines(info),
|
|
1270
1342
|
'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.',
|
|
1271
1343
|
].join('\n');
|
|
1272
1344
|
}
|
|
@@ -1336,7 +1408,10 @@ function buildDispatch(p, ctx) {
|
|
|
1336
1408
|
orch_ctx_baseline: orch.ctx_baseline,
|
|
1337
1409
|
orch_compactions: orch.compactions,
|
|
1338
1410
|
orch_cum_usd: orch.cum_usd,
|
|
1339
|
-
},
|
|
1411
|
+
}, tag.parent_task_id ? { parent_task_id: tag.parent_task_id } : {},
|
|
1412
|
+
// core 0.5.0: an Agent call made inside a subagent (gen-2) carries the caller's agent_id
|
|
1413
|
+
typeof p.agent_id === 'string' && p.agent_id ? { parent_agent_id: p.agent_id } : {},
|
|
1414
|
+
commonFields(ctx));
|
|
1340
1415
|
}
|
|
1341
1416
|
|
|
1342
1417
|
function handlePostToolUse(p, ctx) {
|
|
@@ -1373,6 +1448,124 @@ function countSkippedInternal(ctx) {
|
|
|
1373
1448
|
} catch (_) { /* best effort */ }
|
|
1374
1449
|
}
|
|
1375
1450
|
|
|
1451
|
+
// ------------------------------------------------------------------ core 0.5.0: hand-over files
|
|
1452
|
+
//
|
|
1453
|
+
// Claude Code denies Write inside any subagent when the basename matches BLOCKED_RESULT_NAME_RE ("Subagents should
|
|
1454
|
+
// return findings as text, not write report files" — hardcoded, before permissions). So exec / verify / plan agents
|
|
1455
|
+
// write their full result to dp-out-<task>-<role>-try<n>.md and return only path + STATUS + a few lines. Run dirs
|
|
1456
|
+
// from before 0.5.0 hold report-<task>-try<n>.md / verify-<task>-try<n>.md: still recognised as results.
|
|
1457
|
+
|
|
1458
|
+
const RESULT_ROLES = ['exec', 'verify', 'plan'];
|
|
1459
|
+
const BLOCKED_RESULT_NAME_RE = /^(REPORT|SUMMARY|FINDINGS|ANALYSIS).*\.md$/i;
|
|
1460
|
+
const INLINE_MAX_BYTES = 256 * 1024;
|
|
1461
|
+
|
|
1462
|
+
function escRe(s) {
|
|
1463
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/** Canonical hand-over file name of one attempt: dp-out-<task>-<role>-try<n>.md. */
|
|
1467
|
+
function resultFileName(taskId, role, tryN) {
|
|
1468
|
+
return `dp-out-${safeName(taskId)}-${role}-try${Number(tryN) || 1}.md`;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
/**
|
|
1472
|
+
* Result files already in runDir for (task, role, try), sorted: dp-out-* (with -2 / -spot / .inline suffixes) and the
|
|
1473
|
+
* legacy report-<task>-try<n>*.md (exec) / verify-<task>-try<n>*.md (verify). Never throws.
|
|
1474
|
+
*/
|
|
1475
|
+
function findResultFiles(runDir, taskId, role, tryN) {
|
|
1476
|
+
let names;
|
|
1477
|
+
try { names = fs.readdirSync(runDir); } catch (_) { return []; }
|
|
1478
|
+
const t = escRe(safeName(taskId));
|
|
1479
|
+
const n = Number(tryN) || 1;
|
|
1480
|
+
const pats = [new RegExp(`^dp-out-${t}-${escRe(role)}-try${n}(?:[-.][A-Za-z0-9.-]*)?\\.md$`)];
|
|
1481
|
+
if (role === 'exec') pats.push(new RegExp(`^report-${t}-try${n}(?:-[A-Za-z0-9-]*)?\\.md$`));
|
|
1482
|
+
if (role === 'verify') pats.push(new RegExp(`^verify-${t}-try${n}(?:-[A-Za-z0-9-]*)?\\.md$`));
|
|
1483
|
+
return names.filter((x) => pats.some((re) => re.test(x))).sort().map((x) => path.join(runDir, x));
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
function insideRunsDir(d) {
|
|
1487
|
+
const r = path.relative(runsDir(), d);
|
|
1488
|
+
return !!r && !r.startsWith('..') && !path.isAbsolute(r);
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
function expandHome(s) {
|
|
1492
|
+
return s === '~' || s.startsWith('~/') ? path.join(os.homedir(), s.slice(1)) : s;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Run dir an agent's brief points at (never outside runsDir()): the folder of a dp-out-/handoff- path named in the
|
|
1497
|
+
* brief, else a `…/runs/<id>` path (the one named after the task first), else runsDir()/<task_id>.
|
|
1498
|
+
*/
|
|
1499
|
+
function resultRunDir(prompt, taskId) {
|
|
1500
|
+
const text = String(prompt || '').replace(/\\/g, '/');
|
|
1501
|
+
const PATH = "(?:[A-Za-z]:)?~?[^\\s`'\"<>()|*?]*";
|
|
1502
|
+
for (const m of text.matchAll(new RegExp(`(${PATH}/)(?:dp-out-|handoff-)[A-Za-z0-9_.-]*\\.md`, 'g'))) {
|
|
1503
|
+
const d = path.resolve(expandHome(m[1]));
|
|
1504
|
+
if (insideRunsDir(d)) return d;
|
|
1505
|
+
}
|
|
1506
|
+
const dirs = [];
|
|
1507
|
+
for (const m of text.matchAll(new RegExp(`(${PATH}/runs/[A-Za-z0-9_-]{1,64})(?![A-Za-z0-9_-])`, 'g'))) {
|
|
1508
|
+
const d = path.resolve(expandHome(m[1]));
|
|
1509
|
+
if (insideRunsDir(d) && path.dirname(d) === path.resolve(runsDir())) dirs.push(d);
|
|
1510
|
+
}
|
|
1511
|
+
const own = dirs.find((d) => path.basename(d) === safeName(taskId));
|
|
1512
|
+
if (own || dirs.length) return own || dirs[0];
|
|
1513
|
+
return path.join(runsDir(), safeName(taskId));
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/** Final text the agent returned: the payload's last_assistant_message, else the last assistant text in the transcript. */
|
|
1517
|
+
function finalAssistantText(lines, given) {
|
|
1518
|
+
if (typeof given === 'string' && given.trim()) return given;
|
|
1519
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1520
|
+
const l = lines[i];
|
|
1521
|
+
if (!l || l.type !== 'assistant' || !l.message) continue;
|
|
1522
|
+
if (l.isApiErrorMessage === true) return null;
|
|
1523
|
+
const c = l.message.content;
|
|
1524
|
+
const text = typeof c === 'string' ? c
|
|
1525
|
+
: (Array.isArray(c) ? c.filter((x) => x && x.type === 'text' && typeof x.text === 'string').map((x) => x.text).join('\n') : '');
|
|
1526
|
+
if (text.trim()) return text;
|
|
1527
|
+
}
|
|
1528
|
+
return null;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/** Whether the returned text names an existing dp-out-/handoff-/report-/verify-*.md under runsDir() (written under another name). */
|
|
1532
|
+
function namesExistingRunFile(text) {
|
|
1533
|
+
const t = String(text).replace(/\\/g, '/');
|
|
1534
|
+
for (const m of t.matchAll(/((?:[A-Za-z]:)?~?[^\s`'"<>()|*?]*\/(?:dp-out|handoff|report|verify)-[A-Za-z0-9_.-]+\.md)/g)) {
|
|
1535
|
+
const f = path.resolve(expandHome(m[1]));
|
|
1536
|
+
if (insideRunsDir(f) && fs.existsSync(f)) return true;
|
|
1537
|
+
}
|
|
1538
|
+
return false;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
/**
|
|
1542
|
+
* SubagentStop fallback: an exec / verify / plan agent that came back without its hand-over file (Write denied, or it
|
|
1543
|
+
* answered inline) gets its returned text saved as <run>/dp-out-<task>-<role>-try<n>.inline.md so the next step can
|
|
1544
|
+
* still read a file. Skipped when a result file for that attempt exists (dp-out or legacy), when the text names an
|
|
1545
|
+
* existing run-dir .md file, and for handoffs (the handoff file is the hand-over). Returns the path written or null.
|
|
1546
|
+
*/
|
|
1547
|
+
function persistInlineResult(lines, tag, opts) {
|
|
1548
|
+
const o = opts || {};
|
|
1549
|
+
if (!tag || !RESULT_ROLES.includes(tag.role) || !tag.task_id) return null;
|
|
1550
|
+
const text = finalAssistantText(lines, o.finalText);
|
|
1551
|
+
if (!text) return null;
|
|
1552
|
+
if (text.trimStart().startsWith(HANDOFF_MARKER)) return null;
|
|
1553
|
+
const dir = resultRunDir(firstPromptText(lines), tag.task_id);
|
|
1554
|
+
if (findResultFiles(dir, tag.task_id, tag.role, tag.try).length) return null;
|
|
1555
|
+
if (namesExistingRunFile(text)) return null;
|
|
1556
|
+
const file = path.join(dir, resultFileName(tag.task_id, tag.role, tag.try).replace(/\.md$/, '.inline.md'));
|
|
1557
|
+
let body = text.replace(/\r\n/g, '\n');
|
|
1558
|
+
if (Buffer.byteLength(body) > INLINE_MAX_BYTES) body = `${Buffer.from(body).subarray(0, INLINE_MAX_BYTES).toString('utf8')}\n\n[… cắt bớt]`;
|
|
1559
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1560
|
+
try {
|
|
1561
|
+
fs.writeFileSync(file, `<!-- dieu-phoi: agent ${tag.role} không ghi file kết quả; hook SubagentStop lưu nguyên văn phần trả về -->\n\n${body}\n`, { flag: 'wx' });
|
|
1562
|
+
} catch (e) {
|
|
1563
|
+
if (e && e.code === 'EEXIST') return null;
|
|
1564
|
+
throw e;
|
|
1565
|
+
}
|
|
1566
|
+
return file;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1376
1569
|
function handleSubagentStop(p, ctx) {
|
|
1377
1570
|
const agentId = typeof p.agent_id === 'string' && p.agent_id ? p.agent_id : null;
|
|
1378
1571
|
if (!agentId) return null;
|
|
@@ -1425,9 +1618,386 @@ function handleSubagentStop(p, ctx) {
|
|
|
1425
1618
|
});
|
|
1426
1619
|
}
|
|
1427
1620
|
appendEvent(ctx, ev);
|
|
1621
|
+
if (exists && tag) {
|
|
1622
|
+
try { persistInlineResult(lines, tag, { finalText: p.last_assistant_message }); } catch (_) { /* best effort */ }
|
|
1623
|
+
}
|
|
1624
|
+
// Core 0.4.0: a segment that ended on a usage-limit / quota pause gets an automatic agent verdict
|
|
1625
|
+
// `aborted` (never a fail, never counted toward escalation); the real verdict comes after the resume.
|
|
1626
|
+
const why = exists ? abortReasonOf(lines) : null;
|
|
1627
|
+
if (why) {
|
|
1628
|
+
appendEvent(ctx, Object.assign({
|
|
1629
|
+
schema: SCHEMA, kind: 'verdict', ts: nowIso(), agent_id: agentId, task_id: t.task_id, by: 'agent', outcome: 'aborted',
|
|
1630
|
+
verify: 'none', reason_code: `auto-${why}`, cause: null, round: null,
|
|
1631
|
+
}, commonFields(ctx)));
|
|
1632
|
+
}
|
|
1428
1633
|
return ev;
|
|
1429
1634
|
}
|
|
1430
1635
|
|
|
1636
|
+
// Exact marker an agent puts on the FIRST line of its final report when it stops at a safe point because of a quota
|
|
1637
|
+
// / usage limit (brief template A, SKILL.md). Nothing looser: "STATUS: partial" + "quota" can be a real failure
|
|
1638
|
+
// ("Drive storage quota exceeded", a half-built rate limiter).
|
|
1639
|
+
const QUOTA_PAUSE_MARKER = 'STATUS: partial — quota-pause';
|
|
1640
|
+
// Core 0.5.0: exact marker of an agent that stopped on the context watchdog and wrote handoff.md (continuation, not a fail).
|
|
1641
|
+
const HANDOFF_MARKER = 'STATUS: partial — handoff';
|
|
1642
|
+
// Claude Code's own usage-limit stop text on its synthetic API-error line ("You've hit your session limit · resets 11am").
|
|
1643
|
+
const CC_LIMIT_TEXT_RE = /^You['’]ve hit your (?:[\w-]+ )?limit\b/;
|
|
1644
|
+
|
|
1645
|
+
/**
|
|
1646
|
+
* Why the agent's last segment stopped on a usage limit, or null. Reads only the LAST assistant line:
|
|
1647
|
+
* - Claude Code's synthetic API-error line (`model:"<synthetic>"` + `isApiErrorMessage:true`) whose `error` is
|
|
1648
|
+
* "rate_limit", or HTTP 429, or whose text starts "You've hit your … limit" → "usage-limit";
|
|
1649
|
+
* - the agent's final report whose first line starts with the exact marker QUOTA_PAUSE_MARKER → "quota-pause".
|
|
1650
|
+
* Only a short label is returned; no text is stored.
|
|
1651
|
+
*/
|
|
1652
|
+
function abortReasonOf(lines) {
|
|
1653
|
+
let last = null;
|
|
1654
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1655
|
+
const l = lines[i];
|
|
1656
|
+
if (l && l.type === 'assistant' && l.message) { last = l; break; }
|
|
1657
|
+
}
|
|
1658
|
+
if (!last) return null;
|
|
1659
|
+
const content = Array.isArray(last.message.content) ? last.message.content : [];
|
|
1660
|
+
const text = content.filter((c) => c && c.type === 'text' && typeof c.text === 'string').map((c) => c.text).join('\n').slice(0, 2000);
|
|
1661
|
+
if (last.isApiErrorMessage === true) {
|
|
1662
|
+
if (last.message.model !== '<synthetic>') return null;
|
|
1663
|
+
if (last.error === 'rate_limit' || last.apiErrorStatus === 429 || CC_LIMIT_TEXT_RE.test(text.trim())) return 'usage-limit';
|
|
1664
|
+
return null;
|
|
1665
|
+
}
|
|
1666
|
+
const first = text.trimStart().split('\n')[0].trim();
|
|
1667
|
+
if (first.startsWith(QUOTA_PAUSE_MARKER)) return 'quota-pause';
|
|
1668
|
+
if (first.startsWith(HANDOFF_MARKER)) return 'handoff';
|
|
1669
|
+
return null;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// ------------------------------------------------------------------ core 0.5.0: context watchdog + gen-2 gate
|
|
1673
|
+
//
|
|
1674
|
+
// Both run only from the hooks of the dp-* agent files (~/.claude/agents, installed by `dieu-phoi setup`), so a session
|
|
1675
|
+
// that never uses dieu-phoi never runs them. They still check that the subagent's first prompt carries a `[dp:` tag
|
|
1676
|
+
// (state cached per agent in data/watch/<agent_id>.json) and do nothing on the main thread (no agent_id).
|
|
1677
|
+
|
|
1678
|
+
const HANDOFF_THRESHOLDS = Object.freeze([150000, 180000]);
|
|
1679
|
+
const WATCH_HEAD_BYTES = 64 * 1024;
|
|
1680
|
+
const WATCH_HEAD_MAX = 2 << 20;
|
|
1681
|
+
const WATCH_TAIL_BYTES = 256 * 1024;
|
|
1682
|
+
const WATCH_TAIL_MAX = 8 << 20;
|
|
1683
|
+
const WATCH_TTL_MS = 14 * 24 * 3600 * 1000;
|
|
1684
|
+
const GATE_MAX_CHILDREN = 4;
|
|
1685
|
+
const HELPER_AGENT_TYPE = 'dp-helper';
|
|
1686
|
+
const HELPER_MODELS = ['haiku', 'sonnet'];
|
|
1687
|
+
|
|
1688
|
+
/** The subagent the hook fired in: payload agent_id, else the `agent-<id>.jsonl` transcript name; null on the main thread. */
|
|
1689
|
+
function watchAgentId(p) {
|
|
1690
|
+
if (typeof p.agent_id === 'string' && p.agent_id) return p.agent_id;
|
|
1691
|
+
for (const k of ['agent_transcript_path', 'transcript_path']) {
|
|
1692
|
+
const m = typeof p[k] === 'string' ? /agent-([A-Za-z0-9_-]{6,64})\.jsonl$/.exec(p[k]) : null;
|
|
1693
|
+
if (m) return m[1];
|
|
1694
|
+
}
|
|
1695
|
+
return null;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* The subagent's own transcript. Claude Code layout: <project>/<session_id>.jsonl (main) and
|
|
1700
|
+
* <project>/<session_id>/subagents/agent-<agent_id>.jsonl. Uses a payload path when it already names the agent's file.
|
|
1701
|
+
*/
|
|
1702
|
+
function subagentTranscriptPath(p, agentId) {
|
|
1703
|
+
const name = `agent-${agentId}.jsonl`;
|
|
1704
|
+
for (const k of ['agent_transcript_path', 'transcript_path']) {
|
|
1705
|
+
if (typeof p[k] === 'string' && p[k] && path.basename(p[k]) === name) return p[k];
|
|
1706
|
+
}
|
|
1707
|
+
const tp = p.transcript_path;
|
|
1708
|
+
if (typeof tp !== 'string' || !tp) return null;
|
|
1709
|
+
const guesses = [
|
|
1710
|
+
path.join(path.dirname(tp), path.basename(tp, '.jsonl'), 'subagents', name),
|
|
1711
|
+
path.join(path.dirname(tp), name), // transcript_path already inside a subagents dir (nested spawn)
|
|
1712
|
+
];
|
|
1713
|
+
for (const g of guesses) if (fs.existsSync(g)) return g;
|
|
1714
|
+
return null;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
function readSlice(fd, start, len) {
|
|
1718
|
+
const buf = Buffer.allocUnsafe(len);
|
|
1719
|
+
const n = fs.readSync(fd, buf, 0, len, start);
|
|
1720
|
+
return buf.subarray(0, Math.max(0, n));
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
/**
|
|
1724
|
+
* Tag of the subagent's first prompt, read from the head of its transcript only.
|
|
1725
|
+
* Returns { found: false } while no user line is on disk yet (transcript written async), else { found: true, tag|null }.
|
|
1726
|
+
*/
|
|
1727
|
+
function readHeadTag(file) {
|
|
1728
|
+
let fd;
|
|
1729
|
+
try { fd = fs.openSync(file, 'r'); } catch (_) { return { found: false, tag: null }; }
|
|
1730
|
+
try {
|
|
1731
|
+
const size = fs.fstatSync(fd).size;
|
|
1732
|
+
for (let want = WATCH_HEAD_BYTES; ; want *= 2) {
|
|
1733
|
+
const len = Math.min(want, size, WATCH_HEAD_MAX);
|
|
1734
|
+
const text = readSlice(fd, 0, len).toString('utf8');
|
|
1735
|
+
const complete = len === size ? text : text.slice(0, text.lastIndexOf('\n') + 1);
|
|
1736
|
+
const lines = parseJsonl(complete);
|
|
1737
|
+
if (lines.some((l) => l && l.type === 'user' && l.message)) return { found: true, tag: tagFromTranscript(lines), prompt: firstPromptText(lines) };
|
|
1738
|
+
if (len >= size || len >= WATCH_HEAD_MAX) return { found: false, tag: null };
|
|
1739
|
+
}
|
|
1740
|
+
} finally {
|
|
1741
|
+
try { fs.closeSync(fd); } catch (_) { /* ignore */ }
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/** Context of the subagent's latest API request = input + cache_read + cache_creation (tail of the transcript only). */
|
|
1746
|
+
function lastContextTokens(file) {
|
|
1747
|
+
let fd;
|
|
1748
|
+
try { fd = fs.openSync(file, 'r'); } catch (_) { return null; }
|
|
1749
|
+
try {
|
|
1750
|
+
const size = fs.fstatSync(fd).size;
|
|
1751
|
+
for (let win = WATCH_TAIL_BYTES; ; win *= 2) {
|
|
1752
|
+
const start = Math.max(0, size - win);
|
|
1753
|
+
const buf = readSlice(fd, start, size - start);
|
|
1754
|
+
let end = buf.length;
|
|
1755
|
+
while (end > 0) {
|
|
1756
|
+
const nl = buf.lastIndexOf(0x0a, end - 1);
|
|
1757
|
+
const line = buf.subarray(nl + 1, end);
|
|
1758
|
+
end = nl < 0 ? 0 : nl;
|
|
1759
|
+
if (nl < 0 && start > 0) break; // first partial line of the window
|
|
1760
|
+
if (line.indexOf(USAGE_NEEDLE) < 0) continue;
|
|
1761
|
+
let l;
|
|
1762
|
+
try { l = JSON.parse(line.toString('utf8')); } catch (_) { continue; }
|
|
1763
|
+
const msg = l && l.type === 'assistant' ? l.message : null;
|
|
1764
|
+
if (!msg || !msg.usage || msg.model === '<synthetic>') continue;
|
|
1765
|
+
const u = msg.usage;
|
|
1766
|
+
return (Number(u.input_tokens) || 0) + (Number(u.cache_read_input_tokens) || 0) + (Number(u.cache_creation_input_tokens) || 0);
|
|
1767
|
+
}
|
|
1768
|
+
if (start === 0 || win >= WATCH_TAIL_MAX) return null;
|
|
1769
|
+
}
|
|
1770
|
+
} finally {
|
|
1771
|
+
try { fs.closeSync(fd); } catch (_) { /* ignore */ }
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
function watchFile(ctx, agentId) {
|
|
1776
|
+
return path.join(ctx.paths.data, 'watch', `${safeName(agentId)}.json`);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
function pruneWatch(dir) {
|
|
1780
|
+
let names = [];
|
|
1781
|
+
try { names = fs.readdirSync(dir); } catch (_) { return; }
|
|
1782
|
+
const cutoff = Date.now() - WATCH_TTL_MS;
|
|
1783
|
+
for (const n of names) {
|
|
1784
|
+
const f = path.join(dir, n);
|
|
1785
|
+
try { if (fs.statSync(f).mtimeMs < cutoff) fs.unlinkSync(f); } catch (_) { /* ignore */ }
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* Per-agent watch state { dp, task_id, role, parent_task_id, fired, t }, created on the agent's first hook from the
|
|
1791
|
+
* head of its transcript with an exclusive create (two hooks racing to create it never clobber a later update). Only
|
|
1792
|
+
* --watch updates it; the gate keeps its own slot files (gateDir). Returns { st, file, tpath }; st is null while the
|
|
1793
|
+
* first prompt is not on disk yet.
|
|
1794
|
+
*/
|
|
1795
|
+
/** Name of the run dir a brief points at (dp-out-/handoff- folder or …/runs/<id>), else null. */
|
|
1796
|
+
function runIdFromPrompt(prompt) {
|
|
1797
|
+
const d = resultRunDir(prompt, '');
|
|
1798
|
+
return path.dirname(d) === path.resolve(runsDir()) ? path.basename(d) : null;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
function watchState(p, ctx, agentId) {
|
|
1802
|
+
const file = watchFile(ctx, agentId);
|
|
1803
|
+
const tpath = subagentTranscriptPath(p, agentId);
|
|
1804
|
+
let st = readJsonFile(file, null);
|
|
1805
|
+
if (!st || typeof st !== 'object') {
|
|
1806
|
+
st = null;
|
|
1807
|
+
if (tpath) {
|
|
1808
|
+
const h = readHeadTag(tpath);
|
|
1809
|
+
if (h.found) {
|
|
1810
|
+
st = h.tag
|
|
1811
|
+
? { dp: true, task_id: h.tag.task_id, run_id: h.tag.task_id ? null : runIdFromPrompt(h.prompt), role: h.tag.role, parent_task_id: h.tag.parent_task_id || null, fired: 0, t: Date.now() }
|
|
1812
|
+
: { dp: false, t: Date.now() };
|
|
1813
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1814
|
+
pruneWatch(path.dirname(file));
|
|
1815
|
+
try {
|
|
1816
|
+
fs.writeFileSync(file, `${JSON.stringify(st)}\n`, { flag: 'wx' });
|
|
1817
|
+
} catch (_) {
|
|
1818
|
+
st = readJsonFile(file, st); // another hook created it first: its copy wins
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
return { st, file, tpath };
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
function handoffMessage(st, tokens, level, thresholds) {
|
|
1827
|
+
if (st.role && st.role !== 'exec') {
|
|
1828
|
+
// verify / scout / helper: no continuation agent exists, so no handoff file — wrap up with what is there.
|
|
1829
|
+
const what = st.role === 'verify' ? 'verdict (dòng STATUS + các phát hiện)' : 'bản tóm tắt';
|
|
1830
|
+
const last = level >= thresholds.length ? ' NHẮC LẦN CUỐI.' : '';
|
|
1831
|
+
return `dieu-phoi: ngữ cảnh của bạn đã ~${Math.round(tokens / 1000)}k token.${last} Dừng đọc/chạy thêm: trả ${what} NGAY với những gì đã có, ghi rõ phần nào chưa kiểm được. Không ghi file handoff — chỉ agent exec mới handoff.`;
|
|
1832
|
+
}
|
|
1833
|
+
const tid = safeName(st.task_id || st.run_id || 'task');
|
|
1834
|
+
const file = `handoff-${tid}.md`;
|
|
1835
|
+
const where = `\`${file}\` trong run dir của brief (đã có thì thêm -2, -3; brief không có run dir → ${toPosix(path.join(runsDir(), tid))}/)`;
|
|
1836
|
+
const k = Math.round(tokens / 1000);
|
|
1837
|
+
if (level >= thresholds.length) {
|
|
1838
|
+
return `dieu-phoi: NHẮC LẦN CUỐI — ngữ cảnh của bạn đã ~${k}k token. Không làm thêm việc gì: ghi ${where} ngay (nếu chưa ghi) rồi kết thúc, dòng đầu báo cáo đúng \`${HANDOFF_MARKER}\` kèm đường dẫn file handoff.`;
|
|
1839
|
+
}
|
|
1840
|
+
return `dieu-phoi: ngữ cảnh của bạn đã ~${k}k token (ngưỡng ${Math.round(thresholds[level - 1] / 1000)}k). Dừng nhận việc mới: làm nốt thao tác đang dở tới điểm an toàn (không để file sửa dở, test đang chạy thì chờ kết quả), rồi ghi ${where} gồm 5 mục: Đã xong / Đang dở / Bước tiếp theo / File đã động / Bẫy & ghi chú (lệnh test, đường dẫn, quyết định đã chốt). Sau đó kết thúc: dòng đầu báo cáo đúng \`${HANDOFF_MARKER}\` kèm đường dẫn file handoff. Điều phối viên sẽ giao một agent tiếp nối cùng tier đọc file đó — đây không phải thất bại.`;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
/** `--watch` (PostToolUse inside a dp-* subagent): additionalContext once per threshold crossing, else nothing. */
|
|
1844
|
+
function handleWatch(p, ctx, o) {
|
|
1845
|
+
const agentId = watchAgentId(p);
|
|
1846
|
+
if (!agentId) return null;
|
|
1847
|
+
const { st, file, tpath } = watchState(p, ctx, agentId);
|
|
1848
|
+
if (!st || !st.dp || !tpath) return null;
|
|
1849
|
+
// Gate slots: a helper's first hook proves its spawn started; so does the exec's own successful Agent call.
|
|
1850
|
+
if (st.role === 'helper' && st.parent_task_id && st.task_id) gateConfirm(ctx, st.parent_task_id, st.task_id);
|
|
1851
|
+
if ((p.tool_name === 'Agent' || p.tool_name === 'Task') && st.role === 'exec') {
|
|
1852
|
+
const ct = parseTag(p.tool_input && p.tool_input.prompt);
|
|
1853
|
+
if (ct && ct.task_id) gateConfirm(ctx, st.task_id || agentId, ct.task_id);
|
|
1854
|
+
}
|
|
1855
|
+
const thresholds = Array.isArray(o && o.thresholds) && o.thresholds.length ? o.thresholds : HANDOFF_THRESHOLDS;
|
|
1856
|
+
const tokens = lastContextTokens(tpath);
|
|
1857
|
+
if (tokens === null) return null;
|
|
1858
|
+
let level = 0;
|
|
1859
|
+
while (level < thresholds.length && tokens >= thresholds[level]) level++;
|
|
1860
|
+
if (level <= (Number(st.fired) || 0)) return null;
|
|
1861
|
+
st.fired = level;
|
|
1862
|
+
st.ctx_tokens = tokens;
|
|
1863
|
+
st.t = Date.now();
|
|
1864
|
+
writeFileAtomic(file, `${JSON.stringify(st)}\n`);
|
|
1865
|
+
return {
|
|
1866
|
+
level,
|
|
1867
|
+
ctx_tokens: tokens,
|
|
1868
|
+
stdout: JSON.stringify({
|
|
1869
|
+
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: handoffMessage(st, tokens, level, thresholds) },
|
|
1870
|
+
}),
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/** A reservation that neither the parent's PostToolUse(Agent) nor the helper's own first hook confirmed in this long never started. */
|
|
1875
|
+
const GATE_PENDING_MS = 10 * 60 * 1000;
|
|
1876
|
+
|
|
1877
|
+
/** Per-step gate dir: one `slot-<n>.json` file per reserved helper (n = 1..GATE_MAX_CHILDREN), keyed by the exec's task. */
|
|
1878
|
+
function gateDir(ctx, key) {
|
|
1879
|
+
return path.join(ctx.paths.data, 'gate', safeName(key));
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
function pruneGate(root) {
|
|
1883
|
+
let names = [];
|
|
1884
|
+
try { names = fs.readdirSync(root); } catch (_) { return; }
|
|
1885
|
+
const cutoff = Date.now() - WATCH_TTL_MS;
|
|
1886
|
+
for (const n of names) {
|
|
1887
|
+
const d = path.join(root, n);
|
|
1888
|
+
try { if (fs.statSync(d).mtimeMs < cutoff) fs.rmSync(d, { recursive: true, force: true }); } catch (_) { /* ignore */ }
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
/** Slots in use: a confirmed one always counts; an unconfirmed one counts until GATE_PENDING_MS, then it is released. */
|
|
1893
|
+
function gateSlots(dir) {
|
|
1894
|
+
const out = [];
|
|
1895
|
+
for (let i = 1; i <= GATE_MAX_CHILDREN; i++) {
|
|
1896
|
+
const f = path.join(dir, `slot-${i}.json`);
|
|
1897
|
+
const r = readJsonFile(f, null);
|
|
1898
|
+
if (!r) continue;
|
|
1899
|
+
if (!r.started && Date.now() - (Number(r.t) || 0) > GATE_PENDING_MS) {
|
|
1900
|
+
try { fs.unlinkSync(f); } catch (_) { /* someone else released it */ }
|
|
1901
|
+
continue;
|
|
1902
|
+
}
|
|
1903
|
+
out.push(Object.assign({ file: f, slot: i }, r));
|
|
1904
|
+
}
|
|
1905
|
+
return out;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
/** Reserve a free slot with an exclusive create (atomic on Windows and POSIX): parallel calls never share a slot. */
|
|
1909
|
+
function gateReserve(dir, child) {
|
|
1910
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1911
|
+
gateSlots(dir); // drop stale reservations first
|
|
1912
|
+
for (let i = 1; i <= GATE_MAX_CHILDREN; i++) {
|
|
1913
|
+
const f = path.join(dir, `slot-${i}.json`);
|
|
1914
|
+
try {
|
|
1915
|
+
fs.writeFileSync(f, `${JSON.stringify({ child, t: Date.now(), started: false })}\n`, { flag: 'wx' });
|
|
1916
|
+
return i;
|
|
1917
|
+
} catch (_) { /* taken */ }
|
|
1918
|
+
}
|
|
1919
|
+
return 0;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
/** Mark the reservation of `child` as a real spawn. Returns true when its slot was found. */
|
|
1923
|
+
function gateConfirm(ctx, key, child) {
|
|
1924
|
+
if (!key || !child) return false;
|
|
1925
|
+
const dir = gateDir(ctx, key);
|
|
1926
|
+
if (!fs.existsSync(dir)) return false;
|
|
1927
|
+
for (const r of gateSlots(dir)) {
|
|
1928
|
+
if (r.child !== child) continue;
|
|
1929
|
+
if (r.started) return true;
|
|
1930
|
+
try {
|
|
1931
|
+
writeFileAtomic(r.file, `${JSON.stringify({ child, t: r.t, started: true, started_t: Date.now() })}\n`);
|
|
1932
|
+
return true;
|
|
1933
|
+
} catch (_) { return false; }
|
|
1934
|
+
}
|
|
1935
|
+
return false;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/**
|
|
1939
|
+
* `--gate` (PreToolUse(Agent) inside dp-exec): a dp-tagged exec may run at most GATE_MAX_CHILDREN gen-2 children per
|
|
1940
|
+
* step, each `dp-helper`, model haiku|sonnet set explicitly, prompt tagged `role=helper|parent=<own task>|task=<new id>`.
|
|
1941
|
+
* Anything else is denied with the reason; an allowed spawn prints nothing (normal permission flow). The budget is a set
|
|
1942
|
+
* of slot files (gateReserve, exclusive create), separate from the watch state. A slot is confirmed by the parent's
|
|
1943
|
+
* PostToolUse(Agent) or the helper's own first --watch, released by --gate-release (PostToolUseFailure) or, if never
|
|
1944
|
+
* confirmed, after GATE_PENDING_MS — so only spawns that really start use the budget.
|
|
1945
|
+
*/
|
|
1946
|
+
function handleGate(p, ctx) {
|
|
1947
|
+
if (p.tool_name !== 'Agent' && p.tool_name !== 'Task') return null;
|
|
1948
|
+
const agentId = watchAgentId(p);
|
|
1949
|
+
if (!agentId) return null;
|
|
1950
|
+
const { st } = watchState(p, ctx, agentId);
|
|
1951
|
+
if (!st || !st.dp) return null;
|
|
1952
|
+
const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
|
|
1953
|
+
const why = [];
|
|
1954
|
+
if (st.role && st.role !== 'exec') why.push(`chỉ agent role=exec được giao helper (bạn là role=${st.role})`);
|
|
1955
|
+
if (ti.subagent_type !== HELPER_AGENT_TYPE) why.push(`subagent_type phải là ${HELPER_AGENT_TYPE} (đang là ${ti.subagent_type || 'mặc định'})`);
|
|
1956
|
+
if (!HELPER_MODELS.includes(shortModelName(ti.model))) why.push(`model phải đặt rõ haiku hoặc sonnet (đang là ${ti.model || 'không đặt = kế thừa'})`);
|
|
1957
|
+
const tag = parseTag(ti.prompt);
|
|
1958
|
+
const parentHint = st.task_id || '<task của bạn>';
|
|
1959
|
+
if (!tag || tag.role !== 'helper' || !tag.task_id || !tag.parent_task_id) {
|
|
1960
|
+
why.push(`dòng đầu prompt phải là [dp:<type>|s<n>|<urgency>|try1|task=<id mới từ dieu-phoi new-id>|role=helper|parent=${parentHint}]`);
|
|
1961
|
+
} else if (st.task_id && tag.parent_task_id !== st.task_id) {
|
|
1962
|
+
why.push(`parent= phải là task của bạn (${st.task_id})`);
|
|
1963
|
+
}
|
|
1964
|
+
const deny = (list) => ({
|
|
1965
|
+
denied: true,
|
|
1966
|
+
stdout: JSON.stringify({
|
|
1967
|
+
hookSpecificOutput: {
|
|
1968
|
+
hookEventName: 'PreToolUse',
|
|
1969
|
+
permissionDecision: 'deny',
|
|
1970
|
+
permissionDecisionReason: `dieu-phoi gen-2: ${list.join('; ')}.`,
|
|
1971
|
+
},
|
|
1972
|
+
}),
|
|
1973
|
+
});
|
|
1974
|
+
if (why.length) return deny(why);
|
|
1975
|
+
const root = path.join(ctx.paths.data, 'gate');
|
|
1976
|
+
fs.mkdirSync(root, { recursive: true });
|
|
1977
|
+
pruneGate(root);
|
|
1978
|
+
const dir = gateDir(ctx, st.task_id || agentId);
|
|
1979
|
+
const slot = gateReserve(dir, tag.task_id);
|
|
1980
|
+
if (!slot) return deny([`đã giao đủ ${GATE_MAX_CHILDREN} helper cho bước này — tự làm phần còn lại`]);
|
|
1981
|
+
return { denied: false, slot, children: gateSlots(dir).length };
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
/** `--gate-release` (PostToolUseFailure(Agent) inside dp-exec): the spawn failed, give its unconfirmed slot back. */
|
|
1985
|
+
function handleGateRelease(p, ctx) {
|
|
1986
|
+
if (p.tool_name !== 'Agent' && p.tool_name !== 'Task') return null;
|
|
1987
|
+
const agentId = watchAgentId(p);
|
|
1988
|
+
if (!agentId) return null;
|
|
1989
|
+
const { st } = watchState(p, ctx, agentId);
|
|
1990
|
+
if (!st || !st.dp) return null;
|
|
1991
|
+
const tag = parseTag(p.tool_input && p.tool_input.prompt);
|
|
1992
|
+
if (!tag || !tag.task_id) return null;
|
|
1993
|
+
for (const r of gateSlots(gateDir(ctx, st.task_id || agentId))) {
|
|
1994
|
+
if (r.child !== tag.task_id || r.started) continue;
|
|
1995
|
+
try { fs.unlinkSync(r.file); } catch (_) { /* already gone */ }
|
|
1996
|
+
return { released: r.slot };
|
|
1997
|
+
}
|
|
1998
|
+
return null;
|
|
1999
|
+
}
|
|
2000
|
+
|
|
1431
2001
|
function maybeSpawnSync(ctx) {
|
|
1432
2002
|
if (!ctx.config.sink_url || !ctx.config.sink_token) return false;
|
|
1433
2003
|
if (process.env.DIEU_PHOI_NO_SPAWN === '1') return false;
|
|
@@ -1479,6 +2049,9 @@ function runHook(opts) {
|
|
|
1479
2049
|
const ctx = makeCtx(home);
|
|
1480
2050
|
let ev = null;
|
|
1481
2051
|
const name = p.hook_event_name;
|
|
2052
|
+
if (o.mode === 'watch') return handleWatch(p, ctx, o); // { stdout } or null
|
|
2053
|
+
if (o.mode === 'gate') return handleGate(p, ctx, o); // { stdout } or null
|
|
2054
|
+
if (o.mode === 'gate-release') return handleGateRelease(p, ctx); // { released } or null, prints nothing
|
|
1482
2055
|
if (name === 'SessionStart') return handleSessionStart(p, ctx); // { stdout } or null, never an event
|
|
1483
2056
|
if (name === 'UserPromptSubmit') return handleUserPromptSubmit(p, ctx); // { stdout } or null
|
|
1484
2057
|
if (name === 'SubagentStop') ev = handleSubagentStop(p, ctx);
|
|
@@ -1632,13 +2205,15 @@ async function runSync(ctx, opts) {
|
|
|
1632
2205
|
// ------------------------------------------------------------------ exports / main
|
|
1633
2206
|
|
|
1634
2207
|
module.exports = {
|
|
1635
|
-
runsDir, loadPlans, isDieuPhoiPrompt, foldPrompt, sinkStatusCached, sinkUrlKey, SINK_CACHE_TTL_MS, scoutBriefPath, quickHealth, startRun, startInfo, stepIds, START_STEP_IDS, healthLine, formatStartBlock, handleUserPromptSubmit, START_PREFIX, buildSessionState, formatSessionState, stateCounts, compactSystemMessage, handleSessionStart, dispatchInSession, STATE_TEXT_CAP, COMPACT_PREFIX,
|
|
2208
|
+
runsDir, loadPlans, isDieuPhoiPrompt, foldPrompt, sinkStatusCached, sinkUrlKey, SINK_CACHE_TTL_MS, scoutBriefPath, quickHealth, startRun, startInfo, orchLessons, orchLessonLines, abortReasonOf, QUOTA_PAUSE_MARKER, stepIds, START_STEP_IDS, healthLine, formatStartBlock, handleUserPromptSubmit, START_PREFIX, buildSessionState, formatSessionState, stateCounts, compactSystemMessage, handleSessionStart, dispatchInSession, STATE_TEXT_CAP, COMPACT_PREFIX,
|
|
1636
2209
|
CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED,
|
|
1637
2210
|
sha8, nowIso, readJsonFile, writeFileAtomic, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
|
|
1638
2211
|
logError, computeUserHash, userHash, commonFields, parseTag, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
|
|
1639
2212
|
priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
|
|
1640
|
-
tagFromTranscript, summarizeTranscript,
|
|
2213
|
+
firstPromptText, tagFromTranscript, summarizeTranscript,
|
|
2214
|
+
RESULT_ROLES, BLOCKED_RESULT_NAME_RE, resultFileName, findResultFiles, resultRunDir, finalAssistantText, persistInlineResult, orchestratorUsage, orchCacheFile, findSessionTranscript, SESSION_ID_RE, HOOK_COLD_MAX_BYTES, appendEvent, eventLinesFrom, readEventLines, readEvents,
|
|
1641
2215
|
loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
|
|
2216
|
+
HANDOFF_MARKER, HANDOFF_THRESHOLDS, GATE_MAX_CHILDREN, HELPER_AGENT_TYPE, watchAgentId, subagentTranscriptPath, readHeadTag, lastContextTokens, watchState, handoffMessage, handleWatch, handleGate, handleGateRelease, gateDir, gateSlots, gateReserve, gateConfirm, GATE_PENDING_MS,
|
|
1642
2217
|
extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
|
|
1643
2218
|
};
|
|
1644
2219
|
|
|
@@ -1652,7 +2227,9 @@ if (require.main === module) {
|
|
|
1652
2227
|
.catch((e) => logError(ctx.home, e, 'sync'))
|
|
1653
2228
|
.finally(() => process.exit(0));
|
|
1654
2229
|
} else {
|
|
1655
|
-
const
|
|
2230
|
+
const mode = process.argv.includes('--watch') ? 'watch'
|
|
2231
|
+
: (process.argv.includes('--gate-release') ? 'gate-release' : (process.argv.includes('--gate') ? 'gate' : null));
|
|
2232
|
+
const r = runHook({ mode });
|
|
1656
2233
|
if (r && typeof r.stdout === 'string') process.stdout.write(r.stdout);
|
|
1657
2234
|
process.exitCode = 0;
|
|
1658
2235
|
}
|