@kienntpixon/dieu-phoi 0.4.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 CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog — @kienntpixon/dieu-phoi
2
2
 
3
+ ## 0.5.0
4
+
5
+ Giới hạn ngữ cảnh subagent (đo trên 429 subagent từ 2026-10-09: p50 đỉnh 144k, p90 373k, max 789k; lượt >128k = 83% chi phí; subagent không bao giờ tự nén vì chạy cửa sổ 1M).
6
+
7
+ - **Nâng cấp:** dán lại `sheet/Code.gs` (cột `parent_task_id`, `parent_agent_id`; luật tiếp nối handoff) rồi `dieu-phoi setup` để cài agent `dp-*`.
8
+ - **File bàn giao `dp-out-<task>-<role>-try<n>.md`:** Claude Code chặn cứng Write trong subagent khi tên khớp `/^(REPORT|SUMMARY|FINDINGS|ANALYSIS).*.md$/i` (trước cả permissions, không cấu hình được) nên exec/verify/scout ghi toàn bộ kết quả vào `dp-out-*` và chỉ trả đường dẫn + `STATUS` + ≤ 3 dòng. Hook `SubagentStop`: agent exec/verify/plan trả kết quả mà không có file (Write hỏng, trả inline) → lưu nguyên văn phần trả về thành `<run>/dp-out-<task>-<role>-try<n>.inline.md` (bỏ qua khi đã có `dp-out-*` hoặc `report-*`/`verify-*` cũ của lượt đó, khi agent handoff, khi là helper; run dir lấy từ đường dẫn trong brief, không bao giờ ra ngoài `runsDir()`). Agent `dp-exec`/`dp-verify`/`dp-scout` ghi rõ hợp đồng này.
9
+ - **Loại agent `dp-exec`, `dp-verify`, `dp-scout`, `dp-helper`** (`cli/agents/`, `setup` cài vào `~/.claude/agents/`): `autoCompactWindow: 200000`, không đặt model, chỉ `dp-exec` có Agent; `dp-helper` không Agent/Edit/Write (độ sâu dừng ở 2). Setup idempotent, không bao giờ ghi đè file trùng tên không có dấu `# dieu-phoi-managed:`; `uninstall` gỡ đúng file của mình; `doctor` báo `agents: …=ok|outdated|missing|foreign`.
10
+ - **Watchdog ngữ cảnh** (`hook.cjs --watch`, PostToolUse trong frontmatter agent dp-*): subagent có thẻ `[dp:` vượt 150k rồi 180k token ngữ cảnh → additionalContext một lần mỗi ngưỡng: ghi `handoff-<task_id>.md` trong run dir và dừng với `STATUS: partial — handoff`. Đọc đuôi transcript subagent, ~2 ms tính.
11
+ - **Handoff = `aborted`, không phải fail:** `SubagentStop` ghi verdict `aborted` `reason_code=auto-handoff`; thước đo (CLI + sink, fixture parity thêm ô `refactor`) nối tầng agent của lượt exec đầu sang agent tiếp nối cùng `try`.
12
+ - **Gen-2 có kiểm soát** (`hook.cjs --gate`, PreToolUse(Agent) của `dp-exec`): tối đa 4 `dp-helper`/bước, model haiku|sonnet đặt rõ, thẻ `role=helper|parent=<task>`; sai luật → deny kèm lý do. Thẻ nhận `role=helper` và `parent=`; dispatch ghi `parent_task_id` + `parent_agent_id`. `choose --role helper` kẹp trần sonnet/high; helper không bao giờ là lượt/mẫu.
13
+ - **Sửa sau review:** suất helper của `--gate` là file `data/gate/<task>/slot-1..4.json` tạo độc quyền (`wx`) — gọi Agent song song không vượt 4, không còn ghi đè trạng thái `--watch` (hai file riêng; file watch tạo bằng `wx`). Suất chỉ tính khi helper chạy thật: xác nhận qua `--watch` (helper tự xác nhận, hoặc PostToolUse(Agent) của exec), trả lại qua `--gate-release` (PostToolUseFailure(Agent) mới trong `dp-exec`) hoặc sau 10 phút chưa xác nhận. Watchdog cho `dp-verify`/`dp-scout`/`dp-helper`: trả verdict/tóm tắt ngay, không ghi handoff (chỉ exec handoff). `attempt_*` (ước lượng `choose`) cộng mọi đoạn exec của chuỗi handoff; fixture parity thêm chuỗi 3 đoạn (ô `refactor` n=3). `doctor` so agent với node đã đăng ký trong settings, không báo "outdated" giả khi chạy bằng node khác.
14
+
3
15
  ## 0.4.0
4
16
 
5
17
  Theo bản rà soát telemetry tune0001 (2026-10-12: 886 sự kiện sạch, ~157 task, 8 phiên điều phối).
package/README.md CHANGED
@@ -8,6 +8,8 @@ Hook + CLI cho skill **dieu-phoi**: ghi lại mỗi lần Claude Code giao việ
8
8
 
9
9
  ## Cài đặt
10
10
 
11
+ > Nâng cấp lên 0.5.0: dán lại `sheet/Code.gs` (cột `parent_task_id`, `parent_agent_id`, luật tiếp nối handoff) rồi chạy lại `dieu-phoi setup` để cài agent `dp-*`.
12
+
11
13
  > Nâng cấp lên 0.4.0: **dán lại `sheet/Code.gs` và deploy bản mới TRƯỚC khi nâng CLI** — Code.gs cũ từ chối kind mới `discard` và tính `orch_share` theo cách cũ; sau đó `npm i -g @kienntpixon/dieu-phoi@0.4.0` rồi `dieu-phoi setup`.
12
14
  >
13
15
  > Nâng cấp lên 0.2.0: **dán lại `sheet/Code.gs` và deploy bản mới TRƯỚC khi nâng CLI** — Code.gs cũ bỏ qua (làm rơi) các trường mới `session_id, orch_ctx_tokens, orch_cum_usd, orch_ctx_baseline, orch_compactions` của bản ghi được đẩy lên trong lúc chưa cập nhật; sau đó `npm i -g @kienntpixon/dieu-phoi@0.2.0` rồi `dieu-phoi setup`.
@@ -27,6 +29,7 @@ Không cài toàn cục được thì dùng `npx -y @kienntpixon/dieu-phoi <lệ
27
29
  1. chép `hook.cjs` + `pricing.json` vào `~/.claude/dieu-phoi/bin/`;
28
30
  2. sao lưu `~/.claude/settings.json` thành `settings.json.bak-dieu-phoi-<thời gian>` rồi đăng ký `PostToolUse` (matcher `Agent`) và `SubagentStop` chạy `"<đường dẫn node>" "<…>/dieu-phoi/bin/hook.cjs"` (node tuyệt đối, không phụ thuộc PATH). Các hook và khoá khác giữ nguyên, kể cả mảng/đối tượng rỗng. Chạy lại khi hook đã đúng thì không ghi file, không tạo bản sao lưu. Đổi phiên bản node (nvm…) thì chạy lại `setup`;
29
31
  3. ghi `~/.claude/dieu-phoi/config.json` (phòng ban, sink, đường dẫn skill, mã băm người dùng). Thư mục skill tự dò: `<claude>/skills/dieu-phoi`, hoặc thư mục `<claude>/skills/*` có `profile/profile.json` và `SKILL.md` mang `name: dieu-phoi` (fork phòng ban đổi tên thư mục vẫn tìm được).
32
+ 4. (0.5.0) ghi `~/.claude/agents/dp-exec.md`, `dp-verify.md`, `dp-scout.md`, `dp-helper.md` (`autoCompactWindow: 200000`, hook watchdog ngữ cảnh + cổng helper gen-2 trong frontmatter — chỉ chạy khi điều phối viên giao bằng các loại agent này). File trùng tên không phải của dieu-phoi được giữ nguyên.
30
33
 
31
34
  Khởi động lại Claude Code sau khi cài. Kiểm tra bằng `dieu-phoi doctor` (chạy thử chính lệnh hook đã đăng ký).
32
35
 
@@ -0,0 +1,38 @@
1
+ ---
2
+ name: dp-exec
3
+ description: "dieu-phoi only — executor subagent dispatched by the dieu-phoi orchestrator with a [dp:...] brief. Do not pick this agent on your own."
4
+ # dieu-phoi-managed: do not edit (dieu-phoi setup rewrites this file)
5
+ autoCompactWindow: 200000
6
+ hooks:
7
+ PostToolUse:
8
+ - matcher: "*"
9
+ hooks:
10
+ - type: command
11
+ command: '__DP_HOOK__ --watch'
12
+ timeout: 5
13
+ PreToolUse:
14
+ - matcher: "Agent|Task"
15
+ hooks:
16
+ - type: command
17
+ command: '__DP_HOOK__ --gate'
18
+ timeout: 5
19
+ PostToolUseFailure:
20
+ - matcher: "Agent|Task"
21
+ hooks:
22
+ - type: command
23
+ command: '__DP_HOOK__ --gate-release'
24
+ timeout: 5
25
+ ---
26
+ Bạn là agent thực thi (exec) của dieu-phoi. Brief ở tin nhắn đầu (dòng đầu là thẻ `[dp:...]`) là hợp đồng: làm đúng phạm vi, tự kiểm chứng, ghi báo cáo vào run dir và trả về bản tóm tắt ngắn đúng định dạng brief yêu cầu.
27
+
28
+ Bàn giao qua file: ghi TOÀN BỘ kết quả vào `dp-out-<task_id>-exec-try<n>.md` trong run dir (đường dẫn brief chỉ định), rồi chỉ trả: đường dẫn file đó, dòng `STATUS:`, và ≤ 3 dòng ý chính. Không đặt tên file `report*` / `summary*` / `findings*` / `analysis*.md` — Claude Code chặn cứng Write các tên đó trong subagent. File dp-out/handoff mà brief trỏ tới là báo cáo chính thức: đọc thẳng, không cần xin thêm quyền.
29
+
30
+ Helper thế hệ 2 (tuỳ chọn, chỉ khi giúp giữ ngữ cảnh nhỏ): tối đa 4 agent `subagent_type: dp-helper`, `model` đặt rõ `haiku` hoặc `sonnet`, cho đúng hai loại việc — (a) đọc/quét code hoặc tài liệu rồi trả tóm tắt ≤ 15 dòng, (b) chạy test / Unity smoke rồi tóm tắt log. Dòng đầu prompt helper: `[dp:<type>|s<n>|<urgency>|try1|task=<id mới từ dieu-phoi new-id>|role=helper|parent=<task của bạn>]`. Hook từ chối mọi lần giao sai luật. Không giao helper sửa code.
31
+
32
+ Giữ ngữ cảnh nhỏ (mỗi lượt gửi lại toàn bộ ngữ cảnh — số lượt mới là thứ làm phình):
33
+ - Grep/Glob trước, rồi Read đúng đoạn cần bằng offset/limit; không cat/sed -n cả file qua Bash.
34
+ - Không đọc lại file đã đọc — ghi chú điều cần nhớ ngay khi đọc.
35
+ - Lọc output lệnh dài (`| tail -n 40`, `| grep ...`); không dump log/JSON thô.
36
+ - Mỗi ảnh/screenshot xem một lần, ghi nhận xét thành chữ, không mở lại.
37
+ - Không bao giờ đọc transcript phiên (`~/.claude/projects/**/*.jsonl`).
38
+ - Khi hook dieu-phoi báo ngữ cảnh chạm ngưỡng: dừng việc mới, ghi file handoff hook chỉ định (`handoff-<task_id>.md` trong run dir: Đã xong / Đang dở / Bước tiếp theo / File đã động / Bẫy & ghi chú) rồi kết thúc với dòng đầu báo cáo đúng `STATUS: partial — handoff`.
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: dp-helper
3
+ description: "dieu-phoi only — gen-2 helper spawned by a dp-exec agent (read/scan and summarize, or run tests and summarize logs). Do not pick this agent on your own."
4
+ # dieu-phoi-managed: do not edit (dieu-phoi setup rewrites this file)
5
+ disallowedTools: Agent, Task, Edit, Write, NotebookEdit
6
+ autoCompactWindow: 200000
7
+ hooks:
8
+ PostToolUse:
9
+ - matcher: "*"
10
+ hooks:
11
+ - type: command
12
+ command: '__DP_HOOK__ --watch'
13
+ timeout: 5
14
+ ---
15
+ Bạn là helper thế hệ 2 của dieu-phoi, do một agent exec giao (thẻ `[dp:...|role=helper|parent=...]`). Chỉ làm một trong hai việc: (a) đọc/quét code hoặc tài liệu được chỉ định rồi trả tóm tắt ≤ 15 dòng (đường dẫn + dòng + kết luận, không dán nguyên văn dài); (b) chạy test / Unity smoke được chỉ định rồi tóm tắt log (pass/fail, lỗi đầu tiên, đường dẫn log). Không sửa file, không giao việc tiếp. Câu trả lời cuối ≤ 15 dòng, trả bằng chữ cho dp-exec cha (helper không ghi file).
16
+
17
+ Giữ ngữ cảnh nhỏ (mỗi lượt gửi lại toàn bộ ngữ cảnh — số lượt mới là thứ làm phình):
18
+ - Grep/Glob trước, rồi Read đúng đoạn cần bằng offset/limit; không cat/sed -n cả file qua Bash.
19
+ - Không đọc lại file đã đọc — ghi chú điều cần nhớ ngay khi đọc.
20
+ - Lọc output lệnh dài (`| tail -n 40`, `| grep ...`); không dump log/JSON thô.
21
+ - Mỗi ảnh/screenshot xem một lần, ghi nhận xét thành chữ, không mở lại.
22
+ - Không bao giờ đọc transcript phiên (`~/.claude/projects/**/*.jsonl`).
23
+ - Khi hook dieu-phoi báo ngữ cảnh chạm ngưỡng: dừng đọc/chạy thêm, trả bản tóm tắt NGAY với những gì đã có và ghi rõ phần chưa kiểm được. Không ghi file handoff — chỉ agent exec mới handoff.
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: dp-scout
3
+ description: "dieu-phoi only — read-only scout/planner subagent dispatched by the dieu-phoi orchestrator with a [dp:...|role=plan] brief. Do not pick this agent on your own."
4
+ # dieu-phoi-managed: do not edit (dieu-phoi setup rewrites this file)
5
+ disallowedTools: Agent, Task, Edit, NotebookEdit
6
+ autoCompactWindow: 200000
7
+ hooks:
8
+ PostToolUse:
9
+ - matcher: "*"
10
+ hooks:
11
+ - type: command
12
+ command: '__DP_HOOK__ --watch'
13
+ timeout: 5
14
+ ---
15
+ Bạn là scout/planner của dieu-phoi: khảo sát (code, tài liệu, dữ liệu) theo brief ở tin nhắn đầu (thẻ `[dp:...|role=plan]`) và trả về kế hoạch/câu hỏi đúng định dạng brief. Không sửa file của dự án; Write chỉ dùng để ghi báo cáo/kế hoạch vào run dir.
16
+
17
+ Bàn giao qua file: ghi TOÀN BỘ kết quả vào `dp-out-<task_id>-plan-try<n>.md` trong run dir (đường dẫn brief chỉ định), rồi chỉ trả: đường dẫn file đó, dòng `STATUS:`, và ≤ 3 dòng ý chính. Không đặt tên file `report*` / `summary*` / `findings*` / `analysis*.md` — Claude Code chặn cứng Write các tên đó trong subagent. File dp-out/handoff mà brief trỏ tới là báo cáo chính thức: đọc thẳng, không cần xin thêm quyền.
18
+
19
+ Giữ ngữ cảnh nhỏ (mỗi lượt gửi lại toàn bộ ngữ cảnh — số lượt mới là thứ làm phình):
20
+ - Grep/Glob trước, rồi Read đúng đoạn cần bằng offset/limit; không cat/sed -n cả file qua Bash.
21
+ - Không đọc lại file đã đọc — ghi chú điều cần nhớ ngay khi đọc.
22
+ - Lọc output lệnh dài (`| tail -n 40`, `| grep ...`); không dump log/JSON thô.
23
+ - Mỗi ảnh/screenshot xem một lần, ghi nhận xét thành chữ, không mở lại.
24
+ - Không bao giờ đọc transcript phiên (`~/.claude/projects/**/*.jsonl`).
25
+ - Khi hook dieu-phoi báo ngữ cảnh chạm ngưỡng: dừng đọc/chạy thêm, trả bản tóm tắt NGAY với những gì đã có và ghi rõ phần chưa kiểm được. Không ghi file handoff — chỉ agent exec mới handoff.
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: dp-verify
3
+ description: "dieu-phoi only — independent verifier subagent dispatched by the dieu-phoi orchestrator with a [dp:...|role=verify] brief. Do not pick this agent on your own."
4
+ # dieu-phoi-managed: do not edit (dieu-phoi setup rewrites this file)
5
+ disallowedTools: Agent, Task, Edit, NotebookEdit
6
+ autoCompactWindow: 200000
7
+ hooks:
8
+ PostToolUse:
9
+ - matcher: "*"
10
+ hooks:
11
+ - type: command
12
+ command: '__DP_HOOK__ --watch'
13
+ timeout: 5
14
+ ---
15
+ Bạn là agent kiểm chứng (verify) của dieu-phoi: kiểm tra độc lập kết quả của bước exec theo brief ở tin nhắn đầu (thẻ `[dp:...|role=verify]`). Chỉ đọc và chạy lệnh kiểm tra (test, lint, build); không sửa code của dự án. Ghi báo cáo vào run dir (Write chỉ dùng cho báo cáo) và trả về verdict ngắn đúng định dạng brief.
16
+
17
+ Bàn giao qua file: ghi TOÀN BỘ kết quả vào `dp-out-<task_id>-verify-try<n>.md` trong run dir (đường dẫn brief chỉ định), rồi chỉ trả: đường dẫn file đó, dòng `STATUS:`/`VERDICT:`, và ≤ 3 dòng ý chính. Không đặt tên file `report*` / `summary*` / `findings*` / `analysis*.md` — Claude Code chặn cứng Write các tên đó trong subagent. File dp-out/handoff mà brief trỏ tới là báo cáo chính thức: đọc thẳng, không cần xin thêm quyền.
18
+
19
+ Giữ ngữ cảnh nhỏ (mỗi lượt gửi lại toàn bộ ngữ cảnh — số lượt mới là thứ làm phình):
20
+ - Grep/Glob trước, rồi Read đúng đoạn cần bằng offset/limit; không cat/sed -n cả file qua Bash.
21
+ - Không đọc lại file đã đọc — ghi chú điều cần nhớ ngay khi đọc.
22
+ - Lọc output lệnh dài (`| tail -n 40`, `| grep ...`); không dump log/JSON thô.
23
+ - Mỗi ảnh/screenshot xem một lần, ghi nhận xét thành chữ, không mở lại.
24
+ - Không bao giờ đọc transcript phiên (`~/.claude/projects/**/*.jsonl`).
25
+ - Khi hook dieu-phoi báo ngữ cảnh chạm ngưỡng: dừng đọc/chạy thêm, trả verdict (dòng STATUS + các phát hiện) NGAY với những gì đã có và ghi rõ phần chưa kiểm được. Không ghi file handoff — chỉ agent exec mới handoff.
package/bin/dieu-phoi.cjs CHANGED
@@ -27,7 +27,7 @@ Cách dùng: dieu-phoi <lệnh> [tuỳ chọn]
27
27
  stats [--type T] [--json]
28
28
  bảng task_type × model/effort: n, accept%, agent_pass%, chi phí/thời gian (TB, p50, p75), steer
29
29
  choose --type T --score N [--urgency relaxed|normal|urgent] [--reversible y|n]
30
- [--verifiable y|n] [--role exec|verify|plan] [--task ID]
30
+ [--verifiable y|n] [--role exec|verify|plan|helper] [--task ID]
31
31
  [--override model/effort (cần --task) | --approved model/effort] [--skill-dir P] [--seed S] [--json]
32
32
  đề xuất model + effort, lý do, action (dispatch|ask_user), ước tính chi phí/thời gian
33
33
  ctx [--session <id>] [--json]
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. It must stay
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.4.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
- /** Tag from the first user message of the transcript (the original prompt). */
370
- function tagFromTranscript(lines) {
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
- let text = null;
375
- if (typeof c === 'string') text = c;
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) text = b.text;
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
@@ -1398,7 +1408,10 @@ function buildDispatch(p, ctx) {
1398
1408
  orch_ctx_baseline: orch.ctx_baseline,
1399
1409
  orch_compactions: orch.compactions,
1400
1410
  orch_cum_usd: orch.cum_usd,
1401
- }, commonFields(ctx));
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));
1402
1415
  }
1403
1416
 
1404
1417
  function handlePostToolUse(p, ctx) {
@@ -1435,6 +1448,124 @@ function countSkippedInternal(ctx) {
1435
1448
  } catch (_) { /* best effort */ }
1436
1449
  }
1437
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
+
1438
1569
  function handleSubagentStop(p, ctx) {
1439
1570
  const agentId = typeof p.agent_id === 'string' && p.agent_id ? p.agent_id : null;
1440
1571
  if (!agentId) return null;
@@ -1487,6 +1618,9 @@ function handleSubagentStop(p, ctx) {
1487
1618
  });
1488
1619
  }
1489
1620
  appendEvent(ctx, ev);
1621
+ if (exists && tag) {
1622
+ try { persistInlineResult(lines, tag, { finalText: p.last_assistant_message }); } catch (_) { /* best effort */ }
1623
+ }
1490
1624
  // Core 0.4.0: a segment that ended on a usage-limit / quota pause gets an automatic agent verdict
1491
1625
  // `aborted` (never a fail, never counted toward escalation); the real verdict comes after the resume.
1492
1626
  const why = exists ? abortReasonOf(lines) : null;
@@ -1503,6 +1637,8 @@ function handleSubagentStop(p, ctx) {
1503
1637
  // / usage limit (brief template A, SKILL.md). Nothing looser: "STATUS: partial" + "quota" can be a real failure
1504
1638
  // ("Drive storage quota exceeded", a half-built rate limiter).
1505
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';
1506
1642
  // Claude Code's own usage-limit stop text on its synthetic API-error line ("You've hit your session limit · resets 11am").
1507
1643
  const CC_LIMIT_TEXT_RE = /^You['’]ve hit your (?:[\w-]+ )?limit\b/;
1508
1644
 
@@ -1527,7 +1663,338 @@ function abortReasonOf(lines) {
1527
1663
  if (last.error === 'rate_limit' || last.apiErrorStatus === 429 || CC_LIMIT_TEXT_RE.test(text.trim())) return 'usage-limit';
1528
1664
  return null;
1529
1665
  }
1530
- if (text.trimStart().split('\n')[0].trim().startsWith(QUOTA_PAUSE_MARKER)) return 'quota-pause';
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
+ }
1531
1998
  return null;
1532
1999
  }
1533
2000
 
@@ -1582,6 +2049,9 @@ function runHook(opts) {
1582
2049
  const ctx = makeCtx(home);
1583
2050
  let ev = null;
1584
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
1585
2055
  if (name === 'SessionStart') return handleSessionStart(p, ctx); // { stdout } or null, never an event
1586
2056
  if (name === 'UserPromptSubmit') return handleUserPromptSubmit(p, ctx); // { stdout } or null
1587
2057
  if (name === 'SubagentStop') ev = handleSubagentStop(p, ctx);
@@ -1740,8 +2210,10 @@ module.exports = {
1740
2210
  sha8, nowIso, readJsonFile, writeFileAtomic, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
1741
2211
  logError, computeUserHash, userHash, commonFields, parseTag, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
1742
2212
  priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
1743
- tagFromTranscript, summarizeTranscript, orchestratorUsage, orchCacheFile, findSessionTranscript, SESSION_ID_RE, HOOK_COLD_MAX_BYTES, appendEvent, eventLinesFrom, readEventLines, readEvents,
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,
1744
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,
1745
2217
  extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
1746
2218
  };
1747
2219
 
@@ -1755,7 +2227,9 @@ if (require.main === module) {
1755
2227
  .catch((e) => logError(ctx.home, e, 'sync'))
1756
2228
  .finally(() => process.exit(0));
1757
2229
  } else {
1758
- const r = runHook();
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 });
1759
2233
  if (r && typeof r.stdout === 'string') process.stdout.write(r.stdout);
1760
2234
  process.exitCode = 0;
1761
2235
  }
package/lib/aggregate.cjs CHANGED
@@ -15,6 +15,9 @@ const ABORTED = 'aborted';
15
15
  // Exact markers only (anchored): `quota-pause`, `usage-limit`, the hook's `auto-*` codes and the legacy
16
16
  // `paused-for-quota-*` codes the orchestrator wrote before 0.4.0. A reason that merely mentions a rate limit stays a timeout.
17
17
  const QUOTA_REASON_RE = /^(?:quota-pause|usage-limit|auto-quota-pause|auto-usage-limit|paused-for-quota)(?:-|$)/;
18
+ // core 0.5.0: `aborted` with this reason = the agent stopped on the context watchdog and wrote handoff.md. Its
19
+ // continuation (next exec agent of the task with the same try) carries the attempt's agent-layer result.
20
+ const HANDOFF_REASON_RE = /^(?:auto-)?handoff(?:-|$)/;
18
21
 
19
22
  function tsOf(ev) {
20
23
  const t = Date.parse(ev && ev.ts);
@@ -174,6 +177,7 @@ function buildAgents(events) {
174
177
  // every non-aborted agent verdict in time order: a retry by SendMessage to the same agent is a new result
175
178
  verdict_seq: v.map((x) => ({ outcome: x.outcome, ts: tsOf(x), idx: x._idx })),
176
179
  aborted: vAll.length > 0 && vAll[vAll.length - 1].outcome === ABORTED,
180
+ handoff: vAll.some((x) => x.outcome === ABORTED && HANDOFF_REASON_RE.test(String(x.reason_code || ''))),
177
181
  verify: verdict ? verdict.verify : null,
178
182
  reason_code: verdict ? verdict.reason_code : null,
179
183
  steers: a.steers.map((s) => s.steer_kind).filter(Boolean),
@@ -206,10 +210,20 @@ function buildTasks(agents, userVerdicts) {
206
210
  const tasks = [];
207
211
  for (const t of map.values()) {
208
212
  const byTry = (x, y) => (x.try - y.try) || (x.ts - y.ts) || (x._idx - y._idx);
209
- const attempts = t.agents.filter((a) => a.role !== 'verify').sort(byTry);
213
+ // gen-2 helpers (role=helper, own task_id) are never attempts
214
+ const attempts = t.agents.filter((a) => a.role !== 'verify' && a.role !== 'helper').sort(byTry);
210
215
  const verifies = t.agents.filter((a) => a.role === 'verify');
211
216
  // First exec attempt: lowest try among role=exec (ties: earliest). No exec attempt -> no sample.
212
- const first = t.agents.filter((a) => a.role === 'exec').sort(byTry)[0] || null;
217
+ const execs = t.agents.filter((a) => a.role === 'exec').sort(byTry);
218
+ const first = execs[0] || null;
219
+ // core 0.5.0: a first exec that stopped on a context handoff (no result of its own) passes the agent layer to its
220
+ // continuation(s) with the same try; the tier stays the first exec's.
221
+ let firstRes = first;
222
+ const chain = first ? [first] : [];
223
+ for (let i = 1; firstRes && !firstRes.first_outcome && firstRes.handoff && i < execs.length && execs[i].try === first.try; i++) {
224
+ firstRes = execs[i];
225
+ chain.push(firstRes);
226
+ }
213
227
  const user = t.user.slice().sort((x, y) => ((x.round || 0) - (y.round || 0)) || (tsOf(x) - tsOf(y)) || (x._idx - y._idx));
214
228
 
215
229
  // Results for escalation. Each agent verdict of an attempt is one result at its tier (a retry by
@@ -240,8 +254,8 @@ function buildTasks(agents, userVerdicts) {
240
254
  else userOutcome = firstUser.cause === 'scope' ? 'reject_scope' : 'reject_quality';
241
255
  }
242
256
  // Agent layer of the first exec attempt: its first agent verdict.
243
- const agentFirst = first && first.first_outcome && (first.first_outcome === 'pass' || FAIL_OUTCOMES.has(first.first_outcome))
244
- ? first.first_outcome === 'pass' : null;
257
+ const agentFirst = firstRes && firstRes.first_outcome && (firstRes.first_outcome === 'pass' || FAIL_OUTCOMES.has(firstRes.first_outcome))
258
+ ? firstRes.first_outcome === 'pass' : null;
245
259
  let success = null;
246
260
  let source = null;
247
261
  if (userOutcome === 'accept') { success = true; source = 'user'; } else if (userOutcome === 'reject_quality') { success = false; source = 'user'; } else if (!userOutcome && agentFirst !== null) {
@@ -255,6 +269,11 @@ function buildTasks(agents, userVerdicts) {
255
269
  task_id: t.task_id,
256
270
  task_type: first ? first.task_type : (t.agents[0] && t.agents[0].task_type) || 'untagged',
257
271
  first,
272
+ // the first attempt as a whole: its exec segment plus every handoff continuation (time/cost summed over all
273
+ // segments; the result comes from the last one, firstRes)
274
+ first_chain: chain,
275
+ first_cost: chain.length ? sumOrNull(chain.map((a) => a.cost_usd)) : null,
276
+ first_time_s: chain.length ? sumOrNull(chain.map((a) => a.duration_s)) : null,
258
277
  attempts,
259
278
  verifies,
260
279
  user_verdicts: user,
@@ -304,8 +323,9 @@ function buildCells(tasks, opts) {
304
323
  const passes = c.tasks.filter((t) => t.success).length;
305
324
  const costs = c.tasks.map((t) => t.total_cost);
306
325
  const timesMin = c.tasks.map((t) => (t.total_time_s === null ? null : t.total_time_s / 60));
307
- const attemptTimes = c.tasks.map((t) => (t.first.duration_s === null ? null : t.first.duration_s / 60));
308
- const attemptCosts = c.tasks.map((t) => t.first.cost_usd);
326
+ // a handoff chain is one attempt: all its segments count (t.first alone would bill only the first segment)
327
+ const attemptTimes = c.tasks.map((t) => (t.first_time_s === null || t.first_time_s === undefined ? null : t.first_time_s / 60));
328
+ const attemptCosts = c.tasks.map((t) => (t.first_cost === undefined ? null : t.first_cost));
309
329
  const costMean = mean(costs);
310
330
  const timeMean = mean(timesMin);
311
331
  out.push({
@@ -472,6 +492,6 @@ function analyze(events, opts) {
472
492
  }
473
493
 
474
494
  module.exports = {
475
- MIN_N, FAIL_OUTCOMES, HEAVY_STEERS, ABORTED, QUOTA_REASON_RE, discardedTaskIds, withoutDiscarded, CANONICAL_CELL_FIELDS, normalizeVerdict, isInternalComplete, buildAgents, buildTasks,
495
+ MIN_N, FAIL_OUTCOMES, HEAVY_STEERS, ABORTED, QUOTA_REASON_RE, HANDOFF_REASON_RE, discardedTaskIds, withoutDiscarded, CANONICAL_CELL_FIELDS, normalizeVerdict, isInternalComplete, buildAgents, buildTasks,
476
496
  buildCells, buildSteers, canonicalCells, orchestrationSummary, analyze, cellKey, mean, percentile, tsOf, steerIdOf,
477
497
  };
package/lib/choose.cjs CHANGED
@@ -17,6 +17,7 @@ const LADDER = [
17
17
  { model: 'opus', effort: 'medium' },
18
18
  ];
19
19
  const TOP = LADDER.length - 1;
20
+ const HELPER_TOP = 4; // sonnet/high: ceiling for role=helper (gen-2)
20
21
  const EFFORT_RANK = { low: 0, medium: 1, high: 2, xhigh: 3, max: 4 };
21
22
 
22
23
  // Built-in defaults = skill/profile/profile.json for the fixed task types (time caps as tuned 2026-10-12: feature-impl 80,
@@ -418,7 +419,10 @@ function choose(opts) {
418
419
  && shortModel(c.model) === LADDER[i].model && c.effort === LADDER[i].effort) || null;
419
420
 
420
421
  const src = profile.source || 'builtin';
421
- const finish = (idx, reason, explored, extra) => {
422
+ const finish = (idx0, reason0, explored, extra) => {
423
+ // core 0.5.0: a gen-2 helper (spawned by a dp-exec) is haiku|sonnet only — clamp to sonnet/high
424
+ const idx = role === 'helper' ? Math.min(idx0, HELPER_TOP) : idx0;
425
+ const reason = idx === idx0 ? reason0 : `${reason0}; helper: trần ${tierName(HELPER_TOP)}`;
422
426
  const t = LADDER[idx];
423
427
  const cell = cellAt(idx);
424
428
  return Object.assign({
package/lib/commands.cjs CHANGED
@@ -65,6 +65,7 @@ function cmdSetup(opts) {
65
65
  res.settings_changed
66
66
  ? `Đã đăng ký PostToolUse(Agent) + SubagentStop + SessionStart(compact) + UserPromptSubmit trong ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}`
67
67
  : `settings.json đã có hook, không đổi gì (${res.settings})`,
68
+ `Agent dp-*: ${res.agents.map((a) => `${a.name}=${a.action}`).join(', ')}${res.agents.some((a) => a.action === 'skipped-foreign') ? ' (file trùng tên không phải của dieu-phoi: giữ nguyên, đổi tên rồi chạy lại setup)' : ''}`,
68
69
  `Thư mục báo cáo subagent: ${res.runs_dir} (quyền: ${res.permissions.allow.join(', ')} + additionalDirectories)`,
69
70
  `Phòng ban: ${res.config.dept || '(chưa đặt, dùng --dept)'}`,
70
71
  `Sink: ${res.config.sink_url ? res.config.sink_url : '(chưa có — log chỉ lưu cục bộ)'}`,
@@ -80,7 +81,7 @@ function cmdUninstall(opts) {
80
81
  return {
81
82
  code: 0,
82
83
  out: res.removed
83
- ? `Đã gỡ ${res.removed} hook khỏi ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}. Dữ liệu giữ ở ${res.data_kept}.`
84
+ ? `Đã gỡ ${res.removed} hook khỏi ${res.settings}${res.backup ? ` (sao lưu: ${path.basename(res.backup)})` : ''}${res.agents_removed.length ? `, agent ${res.agents_removed.join(', ')}` : ''}. Dữ liệu giữ ở ${res.data_kept}.`
84
85
  : `Không thấy hook dieu-phoi trong ${res.settings}.`,
85
86
  };
86
87
  }
@@ -105,6 +106,11 @@ async function cmdDoctor(opts) {
105
106
  add(permOk, 'run-permissions', permOk
106
107
  ? `${reg.permission_entries.allow.join(', ')} + additionalDirectories ${reg.permission_entries.dir}`
107
108
  : `${!reg.permissions ? `thiếu quyền cho thư mục báo cáo ${reg.permission_entries.dir} (subagent nền sẽ không ghi được)` : (!reg.runs_dir_exists ? `chưa có thư mục ${reg.runs_dir}` : 'còn quyền cũ cho ~/.claude/dieu-phoi/runs (đường dẫn được bảo vệ, không dùng được)')} — chạy dieu-phoi setup`, 'CẢNH BÁO');
109
+ // compare with the node binary setup wrote into settings.json (and the agent files), not this process's node: a doctor run
110
+ // under another node (nvm, upgrade) must not report a false "outdated"
111
+ const agentSt = setupLib.agentStatus(claudeDir, ctx.paths.hook, setupLib.nodePathOf(reg.commands[0]));
112
+ const agentsOk = agentSt.every((a) => a.state === 'ok');
113
+ add(agentsOk, 'agents', `${agentSt.map((a) => `${a.name}=${a.state}`).join(', ')}${agentsOk ? '' : ' — chạy `dieu-phoi setup` (foreign = file trùng tên không phải của dieu-phoi, không bị ghi đè)'}`, 'CẢNH BÁO');
108
114
  const hasCfg = fs.existsSync(ctx.paths.config);
109
115
  add(hasCfg, 'config', hasCfg ? `${ctx.paths.config} dept=${ctx.config.dept || '-'}` : 'chưa có — chạy `dieu-phoi setup`');
110
116
  const sd = ctx.config.skill_dir;
package/lib/setup.cjs CHANGED
@@ -245,6 +245,71 @@ function skillVersionOf(dir) {
245
245
  }
246
246
  }
247
247
 
248
+ // ------------------------------------------------------------------ dp-* agent files (core 0.5.0)
249
+
250
+ const AGENT_NAMES = ['dp-exec', 'dp-verify', 'dp-scout', 'dp-helper'];
251
+ const AGENT_MARKER = '# dieu-phoi-managed:';
252
+ const AGENT_HOOK_PLACEHOLDER = '__DP_HOOK__';
253
+
254
+ function agentsDirOf(claudeDir) {
255
+ return path.join(path.resolve(claudeDir), 'agents');
256
+ }
257
+
258
+ /** Agent file content for this install: template with the absolute hook command (YAML single-quoted). */
259
+ function renderAgent(name, hookPath, nodePath) {
260
+ const tpl = fs.readFileSync(path.join(PKG_ROOT, 'agents', `${name}.md`), 'utf8').replace(/\r\n/g, '\n');
261
+ const cmd = hookCommand(hookPath, nodePath).replace(/'/g, "''");
262
+ return tpl.split(AGENT_HOOK_PLACEHOLDER).join(cmd);
263
+ }
264
+
265
+ /** Node binary of a registered hook command (`"<node>" "<hook>" ...`), or undefined. */
266
+ function nodePathOf(cmd) {
267
+ const m = typeof cmd === 'string' ? /^\s*"([^"]+)"\s+"[^"]+"/.exec(cmd) : null;
268
+ return m ? m[1] : undefined;
269
+ }
270
+
271
+ /**
272
+ * State of each dp-* agent file: ok (ours, identical), outdated (ours, differs), missing, foreign (a file with
273
+ * that name we did not write — never touched).
274
+ */
275
+ function agentStatus(claudeDir, hookPath, nodePath) {
276
+ const dir = agentsDirOf(claudeDir);
277
+ return AGENT_NAMES.map((name) => {
278
+ const file = path.join(dir, `${name}.md`);
279
+ let cur = null;
280
+ try { cur = fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); } catch (_) { /* missing */ }
281
+ let state = 'missing';
282
+ if (cur !== null) {
283
+ if (!cur.includes(AGENT_MARKER)) state = 'foreign';
284
+ else state = cur === renderAgent(name, hookPath, nodePath) ? 'ok' : 'outdated';
285
+ }
286
+ return { name, file, state };
287
+ });
288
+ }
289
+
290
+ /** Write missing/outdated dp-* agent files (LF). Foreign files are reported, never overwritten. */
291
+ function installAgents(claudeDir, hookPath, nodePath) {
292
+ const dir = agentsDirOf(claudeDir);
293
+ fs.mkdirSync(dir, { recursive: true });
294
+ return agentStatus(claudeDir, hookPath, nodePath).map((a) => {
295
+ if (a.state === 'ok' || a.state === 'foreign') return Object.assign({ action: a.state === 'ok' ? 'unchanged' : 'skipped-foreign' }, a);
296
+ core.writeFileAtomic(a.file, renderAgent(a.name, hookPath, nodePath));
297
+ return Object.assign({}, a, { action: a.state === 'missing' ? 'installed' : 'updated', state: 'ok' });
298
+ });
299
+ }
300
+
301
+ /** Remove only our dp-* agent files (marker present). */
302
+ function removeAgents(claudeDir) {
303
+ const removed = [];
304
+ for (const name of AGENT_NAMES) {
305
+ const file = path.join(agentsDirOf(claudeDir), `${name}.md`);
306
+ try {
307
+ if (fs.readFileSync(file, 'utf8').includes(AGENT_MARKER)) { fs.unlinkSync(file); removed.push(name); }
308
+ } catch (_) { /* missing */ }
309
+ }
310
+ return removed;
311
+ }
312
+
248
313
  /**
249
314
  * Install: copy hook + pricing, register hooks, write config.
250
315
  * opts: { claudeDir, dept, sinkUrl, sinkToken, skillDir }
@@ -302,8 +367,11 @@ function setup(opts) {
302
367
  if (legacyRemoved) dirty = true;
303
368
  if (addOurPermissions(obj, perms)) dirty = true;
304
369
  if (dirty) res = writeSettings(settingsFile, before, obj);
370
+ // 5. dp-* agent types (~/.claude/agents): only used when the orchestrator dispatches with subagent_type dp-*
371
+ const agents = installAgents(claudeDir, paths.hook, opts.nodePath);
305
372
 
306
373
  return {
374
+ agents,
307
375
  home,
308
376
  hook: paths.hook,
309
377
  command: cmd,
@@ -329,13 +397,14 @@ function uninstall(opts) {
329
397
  + stripOurPermissions(obj, permissionEntries(legacyRunsDirOf(claudeDir)));
330
398
  let res = { changed: false, backup: null };
331
399
  if (removed || permsRemoved) res = writeSettings(settingsFile, before, obj);
400
+ const agentsRemoved = removeAgents(claudeDir);
332
401
  const hookFile = core.pathsFor(home).hook;
333
402
  let hookRemoved = false;
334
403
  if (opts.purge && fs.existsSync(hookFile)) {
335
404
  fs.unlinkSync(hookFile);
336
405
  hookRemoved = true;
337
406
  }
338
- return { removed, permissions_removed: permsRemoved, settings: settingsFile, backup: res.backup, hook_removed: hookRemoved, data_kept: core.pathsFor(home).data };
407
+ return { removed, agents_removed: agentsRemoved, permissions_removed: permsRemoved, settings: settingsFile, backup: res.backup, hook_removed: hookRemoved, data_kept: core.pathsFor(home).data };
339
408
  }
340
409
 
341
410
  /** Inspect settings for our registration. */
@@ -369,6 +438,6 @@ function registration(claudeDir) {
369
438
  }
370
439
 
371
440
  module.exports = {
372
- MARKER, setup, uninstall, registration, stripOurHooks, addOurHooks, alreadyRegistered, hookCommand,
441
+ MARKER, AGENT_NAMES, AGENT_MARKER, renderAgent, agentStatus, nodePathOf, installAgents, removeAgents, agentsDirOf, setup, uninstall, registration, stripOurHooks, addOurHooks, alreadyRegistered, hookCommand,
373
442
  skillVersionOf, findSkillDir, tsStamp, runsDir, legacyRunsDirOf, permissionEntries, addOurPermissions, stripOurPermissions, hasOurPermissions,
374
443
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kienntpixon/dieu-phoi",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Hook + CLI ghi log subagent của Claude Code và chọn model/effort theo chi phí + thời gian tới lúc nghiệm thu.",
5
5
  "license": "MIT",
6
6
  "author": "kiennt <kiennt@pixon.games>",
@@ -20,6 +20,7 @@
20
20
  "main": "hook.cjs",
21
21
  "files": [
22
22
  "bin/",
23
+ "agents/",
23
24
  "lib/",
24
25
  "hook.cjs",
25
26
  "pricing.json",