@kienntpixon/dieu-phoi 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/hook.cjs CHANGED
@@ -19,7 +19,7 @@ const path = require('path');
19
19
  const os = require('os');
20
20
  const crypto = require('crypto');
21
21
 
22
- const CORE_VERSION = '0.1.2';
22
+ const CORE_VERSION = '0.2.1';
23
23
  const SCHEMA = 1;
24
24
  const TASK_TYPES = [
25
25
  'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
@@ -119,6 +119,7 @@ function pathsFor(home) {
119
119
  data,
120
120
  events: path.join(data, 'events.jsonl'),
121
121
  segments: path.join(data, 'segments'),
122
+ plans: path.join(data, 'plans'),
122
123
  skippedInternal: path.join(data, 'skipped-internal.json'),
123
124
  syncOffset: path.join(data, 'sync-offset.json'),
124
125
  syncSpawn: path.join(data, 'sync-spawn.json'),
@@ -225,6 +226,34 @@ function parseTag(text) {
225
226
  return tag;
226
227
  }
227
228
 
229
+ // ------------------------------------------------------------------ task_id
230
+
231
+ // CONTRACT: task_id = exactly 8 chars [a-z0-9], obtained from `dieu-phoi new-id` (never invented).
232
+ // The tag parser still accepts the legacy 1–32 [A-Za-z0-9_-] form so old logs/sessions parse; such
233
+ // dispatches are flagged `task_id_invalid: true` instead of being dropped.
234
+ const TASK_ID_RE = /^[a-z0-9]{8}$/;
235
+ const TASK_ID_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
236
+
237
+ function isValidTaskId(s) {
238
+ return typeof s === 'string' && TASK_ID_RE.test(s);
239
+ }
240
+
241
+ /** Fresh random task_id: 8 chars [a-z0-9], unbiased (rejection sampling on crypto bytes). */
242
+ function newTaskId() {
243
+ let out = '';
244
+ while (out.length < 8) {
245
+ for (const b of crypto.randomBytes(16)) {
246
+ if (b < 252 && out.length < 8) out += TASK_ID_ALPHABET[b % 36];
247
+ }
248
+ }
249
+ return out;
250
+ }
251
+
252
+ /** Vietnamese error for a bad id, with a freshly generated valid suggestion. */
253
+ function taskIdError(id) {
254
+ return `task_id "${id}" không hợp lệ: cần đúng 8 ký tự a-z0-9 (vd. ${newTaskId()}) — lấy id bằng \`dieu-phoi new-id\``;
255
+ }
256
+
228
257
  // ------------------------------------------------------------------ models / pricing
229
258
 
230
259
  function normalizeModel(m) {
@@ -425,6 +454,285 @@ function summarizeTranscript(lines, opts) {
425
454
  };
426
455
  }
427
456
 
457
+ // ------------------------------------------------------------------ orchestrator (main session) usage
458
+
459
+ const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
460
+ const ORCH_CACHE_V = 2; // 2: + compactions
461
+ const ORCH_CHUNK = 1 << 20; // read granularity; memory = chunk + longest line
462
+ const ORCH_SEEN_KEEP = 64; // requestIds kept for last-line-wins dedupe across scans
463
+ const ORCH_HEAD_BYTES = 4096; // fingerprint of the file start (rotation / replacement check)
464
+ const ORCH_TAIL_START = 256 * 1024; // tail window for the cold fast path, doubled while no usage
465
+ const ORCH_EDGE_MAX = 16 << 20; // cap for head/tail probes in the cold fast path
466
+ const HOOK_COLD_MAX_BYTES = 32 << 20; // hook: transcripts larger than this without a cache take the fast path
467
+ const ORCH_CACHE_TTL_MS = 14 * 24 * 3600 * 1000;
468
+ const USAGE_NEEDLE = Buffer.from('"usage"');
469
+ const COMPACT_NEEDLE = Buffer.from('"compact_boundary"');
470
+
471
+ const ORCH_NONE = Object.freeze({
472
+ ctx_tokens: null, ctx_baseline: null, ctx_growth: null, cum_usd: null, requests: null, compactions: null,
473
+ scanned_bytes: 0,
474
+ });
475
+
476
+ /**
477
+ * One main-thread transcript line -> { key, usage, model } for a request, { compact: true } for a
478
+ * compaction marker (`type:"system", subtype:"compact_boundary"`, written by /compact and auto-compact),
479
+ * null for anything else.
480
+ */
481
+ function parseMainLine(text) {
482
+ let l;
483
+ try { l = JSON.parse(text); } catch (_) { return null; }
484
+ if (!l || l.isSidechain === true) return null;
485
+ if (l.type === 'system' && l.subtype === 'compact_boundary') return { compact: true };
486
+ if (l.type !== 'assistant') return null;
487
+ const msg = l.message;
488
+ if (!msg || typeof msg !== 'object' || !msg.usage || msg.model === '<synthetic>') return null;
489
+ const key = l.requestId || msg.id || l.uuid || null;
490
+ return { key, usage: msg.usage, model: msg.model };
491
+ }
492
+
493
+ /** Request lines only. */
494
+ function mainUsageOf(text) {
495
+ const r = parseMainLine(text);
496
+ return r && !r.compact ? r : null;
497
+ }
498
+
499
+ function ctxOfBuckets(b) {
500
+ return b.input + b.cache_write_5m + b.cache_write_1h + b.cache_read + b.output;
501
+ }
502
+
503
+ /**
504
+ * Stream complete lines of fd in [start, end) in chunks; only lines containing "usage" are decoded and
505
+ * passed to onLine (return true to stop). Returns { consumed: byte just past the last complete line
506
+ * handled, read: bytes read }.
507
+ */
508
+ function scanUsageLines(fd, start, end, onLine) {
509
+ const buf = Buffer.allocUnsafe(ORCH_CHUNK);
510
+ let carry = null;
511
+ let pos = start;
512
+ let consumed = start;
513
+ let read = 0;
514
+ while (pos < end) {
515
+ const n = fs.readSync(fd, buf, 0, Math.min(buf.length, end - pos), pos);
516
+ if (n <= 0) break;
517
+ pos += n;
518
+ read += n;
519
+ const data = carry ? Buffer.concat([carry, buf.subarray(0, n)]) : buf.subarray(0, n);
520
+ const base = pos - data.length;
521
+ let s = 0;
522
+ let nl;
523
+ while ((nl = data.indexOf(0x0a, s)) >= 0) {
524
+ const line = data.subarray(s, nl);
525
+ s = nl + 1;
526
+ if ((line.indexOf(USAGE_NEEDLE) >= 0 || line.indexOf(COMPACT_NEEDLE) >= 0)
527
+ && onLine(line.toString('utf8')) === true) {
528
+ return { consumed: base + s, read };
529
+ }
530
+ }
531
+ consumed = base + s;
532
+ carry = s < data.length ? Buffer.from(data.subarray(s)) : null;
533
+ }
534
+ return { consumed, read };
535
+ }
536
+
537
+ function headFingerprint(fd, size) {
538
+ const len = Math.min(ORCH_HEAD_BYTES, size);
539
+ const b = Buffer.alloc(len);
540
+ if (len) fs.readSync(fd, b, 0, len, 0);
541
+ return { head_len: len, head: crypto.createHash('sha256').update(b).digest('hex').slice(0, 16) };
542
+ }
543
+
544
+ function freshOrchState(fp) {
545
+ return {
546
+ v: ORCH_CACHE_V, head_len: fp.head_len, head: fp.head, offset: 0, requests: 0, cost: 0, priced: 0,
547
+ baseline: null, baseline_req: null, last_ctx: null, cum_known: true, compactions: 0, seen: [],
548
+ };
549
+ }
550
+
551
+ /** Fold one main-thread request line into the state (last line of a requestId wins). */
552
+ function applyUsage(st, seen, u, pricing) {
553
+ const b = usageBuckets(u.usage);
554
+ const c = requestCost(pricing, u.model, b);
555
+ const ctx = ctxOfBuckets(b);
556
+ const key = u.key || `anon-${st.requests}-${st.offset}`;
557
+ if (seen.has(key)) {
558
+ const prev = seen.get(key);
559
+ if (prev !== null) { st.cost -= prev; st.priced--; }
560
+ seen.delete(key);
561
+ } else {
562
+ if (st.requests !== null) st.requests++;
563
+ if (st.baseline_req === null && st.baseline === null) st.baseline_req = key;
564
+ }
565
+ if (key === st.baseline_req) st.baseline = ctx;
566
+ if (c !== null) { st.cost += c; st.priced++; }
567
+ seen.set(key, c);
568
+ if (seen.size > ORCH_SEEN_KEEP) seen.delete(seen.keys().next().value);
569
+ st.last_ctx = ctx;
570
+ }
571
+
572
+ /** Cold fast path: baseline from the head, current ctx from the tail; cost unknown. */
573
+ function edgeProbe(fd, size, st) {
574
+ let first = null;
575
+ scanUsageLines(fd, 0, Math.min(size, ORCH_EDGE_MAX), (t) => { first = mainUsageOf(t); return !!first; });
576
+ let last = null;
577
+ let lastNl = -1;
578
+ for (let w = ORCH_TAIL_START; ; w *= 2) {
579
+ const start = Math.max(0, size - w);
580
+ const b = Buffer.allocUnsafe(size - start);
581
+ fs.readSync(fd, b, 0, b.length, start);
582
+ lastNl = b.lastIndexOf(0x0a);
583
+ const from = start === 0 ? 0 : b.indexOf(0x0a) + 1;
584
+ let s = from;
585
+ let nl;
586
+ while (lastNl >= 0 && (nl = b.indexOf(0x0a, s)) >= 0 && nl <= lastNl) {
587
+ const line = b.subarray(s, nl);
588
+ s = nl + 1;
589
+ if (line.indexOf(USAGE_NEEDLE) < 0) continue;
590
+ const u = mainUsageOf(line.toString('utf8'));
591
+ if (u) last = u;
592
+ }
593
+ if (lastNl >= 0) lastNl += start;
594
+ if (last || start === 0 || w >= ORCH_EDGE_MAX) break;
595
+ }
596
+ st.cum_known = false;
597
+ st.requests = null;
598
+ st.compactions = null; // unknown until a full scan
599
+ if (first) {
600
+ st.baseline = ctxOfBuckets(usageBuckets(first.usage));
601
+ st.baseline_req = first.key;
602
+ }
603
+ if (last) st.last_ctx = ctxOfBuckets(usageBuckets(last.usage));
604
+ st.offset = lastNl >= 0 ? lastNl + 1 : 0;
605
+ }
606
+
607
+ /** Drop stale session caches. Only ever touches <data>/orch/*.json (never another directory). */
608
+ function pruneOrchCache(dir) {
609
+ try {
610
+ if (path.basename(dir) !== 'orch') return;
611
+ const now = Date.now();
612
+ for (const f of fs.readdirSync(dir)) {
613
+ if (!/^[A-Za-z0-9_-]{1,140}\.json$/.test(f)) continue;
614
+ const p = path.join(dir, f);
615
+ try { if (now - fs.statSync(p).mtimeMs > ORCH_CACHE_TTL_MS) fs.unlinkSync(p); } catch (_) { /* gone */ }
616
+ }
617
+ } catch (_) { /* no dir */ }
618
+ }
619
+
620
+ /** Cache file for a session (data/orch/<session_id>.json); no session -> hash of the transcript path. */
621
+ function orchCacheFile(paths, sessionId, transcriptPath) {
622
+ if (!paths || !paths.data) return null;
623
+ const name = typeof sessionId === 'string' && SESSION_ID_RE.test(sessionId)
624
+ ? sessionId : `p-${sha8(String(transcriptPath))}`;
625
+ return path.join(paths.data, 'orch', `${name}.json`);
626
+ }
627
+
628
+ /**
629
+ * Size and running cost of the orchestrating (main) session, from its transcript JSONL.
630
+ * Only main-thread requests count: `type:"assistant"` lines with message.usage, minus
631
+ * `isSidechain:true` and `<synthetic>`; deduped per requestId (last line wins).
632
+ * ctx_tokens = input + cache_creation + cache_read + output of the LAST request (= current context)
633
+ * ctx_baseline = the same for the FIRST request of the session (system prompt + tools + first turn)
634
+ * ctx_growth = max(0, ctx_tokens - ctx_baseline)
635
+ * cum_usd = sum of requestCost per request at its own model's price (unpriced requests left out;
636
+ * null when none priced, or when only the cold fast path ran)
637
+ * opts.cacheFile: per-session state ({offset, cost, seen requestIds, baseline, …}); each call then parses
638
+ * only the bytes appended since the last call. A shrunk or replaced file (offset > size or a different
639
+ * head fingerprint) is recomputed from 0.
640
+ * opts.coldMaxBytes: no usable cache and the file is larger -> head/tail probe only (ctx + baseline,
641
+ * cum_usd null); a later call without the limit (`dieu-phoi ctx`) recomputes the full sum.
642
+ * Reads in chunks (memory = chunk + longest line), never the whole file at once. Never throws.
643
+ */
644
+ function orchestratorUsage(transcriptPath, pricing, opts) {
645
+ const o = opts || {};
646
+ let fd = null;
647
+ try {
648
+ if (typeof transcriptPath !== 'string' || !transcriptPath) return Object.assign({}, ORCH_NONE);
649
+ let stat;
650
+ try { stat = fs.statSync(transcriptPath); } catch (_) { return Object.assign({}, ORCH_NONE); }
651
+ if (!stat.isFile()) return Object.assign({}, ORCH_NONE);
652
+ const size = stat.size;
653
+ const pr = pricing || loadPricing(null);
654
+ fd = fs.openSync(transcriptPath, 'r');
655
+ const fp = headFingerprint(fd, size);
656
+ let st = null;
657
+ const cached = o.cacheFile ? readJsonFile(o.cacheFile, null) : null;
658
+ if (cached && cached.v === ORCH_CACHE_V && Number.isFinite(cached.offset) && cached.offset <= size
659
+ && cached.head_len <= size && Array.isArray(cached.seen)
660
+ && (cached.cum_known || o.coldMaxBytes)) {
661
+ const now = cached.head_len === fp.head_len ? fp : (() => {
662
+ const b = Buffer.alloc(cached.head_len);
663
+ if (cached.head_len) fs.readSync(fd, b, 0, cached.head_len, 0);
664
+ return { head_len: cached.head_len, head: crypto.createHash('sha256').update(b).digest('hex').slice(0, 16) };
665
+ })();
666
+ if (now.head === cached.head) st = cached;
667
+ }
668
+ let scanned = 0;
669
+ const startOffset = st ? st.offset : 0;
670
+ if (!st) {
671
+ st = freshOrchState(fp);
672
+ if (o.coldMaxBytes && size > o.coldMaxBytes) {
673
+ edgeProbe(fd, size, st);
674
+ scanned = -1; // probe, not a scan
675
+ }
676
+ }
677
+ if (scanned === 0 && st.offset < size) {
678
+ const seen = new Map(st.seen);
679
+ const r = scanUsageLines(fd, st.offset, size, (t) => {
680
+ const u = parseMainLine(t);
681
+ if (u && u.compact) { if (st.compactions !== null) st.compactions++; } else if (u) applyUsage(st, seen, u, pr);
682
+ return false;
683
+ });
684
+ st.offset = r.consumed;
685
+ st.seen = Array.from(seen.entries());
686
+ scanned = r.read;
687
+ }
688
+ const reused = st === cached;
689
+ if (o.cacheFile && (!reused || st.offset !== startOffset)) {
690
+ try {
691
+ if (!cached) pruneOrchCache(path.dirname(o.cacheFile));
692
+ writeFileAtomic(o.cacheFile, `${JSON.stringify(st)}\n`);
693
+ } catch (_) { /* cache is an optimisation only */ }
694
+ }
695
+ if (st.last_ctx === null) return Object.assign({}, ORCH_NONE, { scanned_bytes: Math.max(0, scanned) });
696
+ const growth = st.baseline === null ? null : Math.max(0, st.last_ctx - st.baseline);
697
+ return {
698
+ ctx_tokens: st.last_ctx,
699
+ ctx_baseline: st.baseline,
700
+ ctx_growth: growth,
701
+ cum_usd: st.cum_known && st.priced > 0 ? Math.round(st.cost * 1e6) / 1e6 : null,
702
+ requests: st.cum_known ? st.requests : null,
703
+ compactions: typeof st.compactions === 'number' ? st.compactions : null,
704
+ scanned_bytes: Math.max(0, scanned),
705
+ };
706
+ } catch (_) {
707
+ return Object.assign({}, ORCH_NONE);
708
+ } finally {
709
+ if (fd !== null) { try { fs.closeSync(fd); } catch (_) { /* closed */ } }
710
+ }
711
+ }
712
+
713
+ /**
714
+ * Main transcript of a session: <claudeDir>/projects/<any project>/<sessionId>.jsonl (newest if
715
+ * several). Returns null when the id is malformed or no file exists.
716
+ */
717
+ function findSessionTranscript(claudeDir, sessionId) {
718
+ try {
719
+ if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) return null;
720
+ const root = path.join(claudeDir || defaultClaudeDir(), 'projects');
721
+ let best = null;
722
+ let bestM = -1;
723
+ for (const d of fs.readdirSync(root)) {
724
+ const f = path.join(root, d, `${sessionId}.jsonl`);
725
+ try {
726
+ const st = fs.statSync(f);
727
+ if (st.isFile() && st.mtimeMs > bestM) { best = f; bestM = st.mtimeMs; }
728
+ } catch (_) { /* not in this project */ }
729
+ }
730
+ return best;
731
+ } catch (_) {
732
+ return null;
733
+ }
734
+ }
735
+
428
736
  // ------------------------------------------------------------------ events (append-only)
429
737
 
430
738
  /** Append one event. events.jsonl is append-only: never rewritten, sync progress lives elsewhere. */
@@ -511,6 +819,327 @@ function saveSegments(ctx, agentId, rec) {
511
819
  writeFileAtomic(segmentFile(ctx, agentId), `${JSON.stringify(rec)}\n`);
512
820
  }
513
821
 
822
+ // ------------------------------------------------------------------ session state (for /compact)
823
+
824
+ const STATE_TEXT_CAP = 8000;
825
+ const COMPACT_PREFIX = 'dieu-phoi: ngữ cảnh vừa được nén; trạng thái điều phối hiện tại (dựng từ log):';
826
+ const AGENT_FAIL = { fail: 1, timeout: 1 };
827
+
828
+ /**
829
+ * Run directory for subagent reports: ~/.dieu-phoi/runs (override: DIEU_PHOI_RUNS_DIR, absolute).
830
+ * Deliberately outside ~/.claude (a Claude Code protected path).
831
+ */
832
+ function runsDir() {
833
+ if (process.env.DIEU_PHOI_RUNS_DIR) return path.resolve(process.env.DIEU_PHOI_RUNS_DIR);
834
+ return path.join(os.homedir(), '.dieu-phoi', 'runs');
835
+ }
836
+
837
+ function shortModelName(m) {
838
+ const id = normalizeModel(m);
839
+ if (!id) return null;
840
+ for (const k of ['opus', 'sonnet', 'haiku', 'fable']) if (id.includes(k)) return k;
841
+ return id;
842
+ }
843
+
844
+ function tierOf(model, effort) {
845
+ const m = shortModelName(model);
846
+ return m || effort ? `${m || '?'}/${effort || '?'}` : null;
847
+ }
848
+
849
+ function evMs(ev) {
850
+ const t = Date.parse(ev && ev.ts);
851
+ return isNaN(t) ? 0 : t;
852
+ }
853
+
854
+ /** Verdict layer, same reading as the stats (legacy `rework` = user reject quality). */
855
+ function verdictLayer(ev) {
856
+ if (ev.outcome === 'rework' && !ev.by) return { by: 'user', outcome: 'reject', cause: 'quality' };
857
+ const by = ev.by === 'user' ? 'user' : 'agent';
858
+ let cause = ev.cause || null;
859
+ if (by === 'user' && ev.outcome === 'reject' && cause !== 'scope') cause = 'quality';
860
+ return { by, outcome: ev.outcome, cause };
861
+ }
862
+
863
+ function dispatchInSession(ev, sessionId) {
864
+ if (!ev || ev.kind !== 'dispatch') return false;
865
+ if (typeof ev.session_id === 'string' && ev.session_id) return ev.session_id === sessionId;
866
+ return ev.session === sha8(sessionId); // core < 0.2.0: hashed session only
867
+ }
868
+
869
+ /** Approved-plan records written by `plan-table --approve` (<data>/plans/<root>.json). Never throws. */
870
+ function loadPlans(dir) {
871
+ const out = [];
872
+ if (!dir) return out;
873
+ let names = [];
874
+ try { names = fs.readdirSync(dir); } catch (_) { return out; }
875
+ for (const n of names) {
876
+ if (!/^[A-Za-z0-9_-]{1,64}\.json$/.test(n)) continue;
877
+ const p = readJsonFile(path.join(dir, n), null);
878
+ if (p && typeof p.root_task_id === 'string' && Array.isArray(p.steps)) out.push(p);
879
+ }
880
+ return out;
881
+ }
882
+
883
+ /**
884
+ * Orchestration state of one session, rebuilt from events.jsonl plus the local approved-plan records
885
+ * (CONTRACT "Sau khi nén"). Tasks = task_ids that had a dispatch in this session, plus the root and steps
886
+ * of every approved plan touching those tasks. Never reads run-dir files.
887
+ * opts: { runsDir, plansDir | plans }.
888
+ */
889
+ function buildSessionState(events, sessionId, opts) {
890
+ const o = opts || {};
891
+ const runs = toPosix(o.runsDir || runsDir());
892
+ const taskIds = [];
893
+ const seenTask = new Set();
894
+ for (const ev of events) {
895
+ if (dispatchInSession(ev, sessionId) && typeof ev.task_id === 'string' && ev.task_id && !seenTask.has(ev.task_id)) {
896
+ seenTask.add(ev.task_id);
897
+ taskIds.push(ev.task_id);
898
+ }
899
+ }
900
+ const allPlans = Array.isArray(o.plans) ? o.plans : loadPlans(o.plansDir);
901
+ const plans = allPlans.filter((p) => seenTask.has(p.root_task_id)
902
+ || p.steps.some((s) => s && s.task_id && seenTask.has(s.task_id)));
903
+ const planOfRoot = new Map(plans.map((p) => [p.root_task_id, p]));
904
+ const stepOf = new Map();
905
+ for (const p of plans) {
906
+ 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 });
907
+ }
908
+ for (const id of [...planOfRoot.keys(), ...stepOf.keys()]) {
909
+ if (!seenTask.has(id)) { seenTask.add(id); taskIds.push(id); }
910
+ }
911
+ const per = new Map(taskIds.map((id) => [id, { dispatches: [], user: [], steers: [], overrides: [], agentIds: new Set() }]));
912
+ events.forEach((ev, i) => {
913
+ if (!ev || typeof ev !== 'object') return;
914
+ ev._i = i;
915
+ if (ev.kind === 'dispatch' && per.has(ev.task_id)) {
916
+ const t = per.get(ev.task_id);
917
+ if (!t.agentIds.has(ev.agent_id)) { t.agentIds.add(ev.agent_id); t.dispatches.push(ev); }
918
+ }
919
+ });
920
+ const agentTask = new Map();
921
+ for (const [id, t] of per) for (const a of t.agentIds) agentTask.set(a, id);
922
+ const completes = new Map();
923
+ const agentVerdicts = new Map();
924
+ for (const ev of events) {
925
+ if (!ev || typeof ev !== 'object') continue;
926
+ if (ev.kind === 'complete' && agentTask.has(ev.agent_id)) completes.set(ev.agent_id, (completes.get(ev.agent_id) || 0) + 1);
927
+ if (ev.kind === 'verdict') {
928
+ const v = verdictLayer(ev);
929
+ if (v.by === 'user') {
930
+ const id = ev.task_id || (String(ev.agent_id).startsWith('task:') ? String(ev.agent_id).slice(5) : agentTask.get(ev.agent_id));
931
+ if (per.has(id)) per.get(id).user.push(Object.assign({}, ev, v));
932
+ } else if (agentTask.has(ev.agent_id)) {
933
+ if (!agentVerdicts.has(ev.agent_id)) agentVerdicts.set(ev.agent_id, []);
934
+ agentVerdicts.get(ev.agent_id).push(Object.assign({}, ev, v));
935
+ }
936
+ }
937
+ if (ev.kind === 'steer') {
938
+ const id = ev.task_id || agentTask.get(ev.agent_id);
939
+ if (per.has(id)) per.get(id).steers.push(ev);
940
+ }
941
+ if (ev.kind === 'override' && per.has(ev.task_id)) per.get(ev.task_id).overrides.push(ev);
942
+ }
943
+ const byTime = (a, b) => evMs(a) - evMs(b) || a._i - b._i;
944
+ const tasks = [];
945
+ for (const id of taskIds) {
946
+ const t = per.get(id);
947
+ const ps = stepOf.get(id) || null;
948
+ const plan = ps ? ps.plan : (planOfRoot.get(id) || null);
949
+ const planInfo = {
950
+ root_task_id: plan ? plan.root_task_id : null,
951
+ step_id: ps ? (ps.step.id === undefined ? null : String(ps.step.id)) : null,
952
+ is_root: !!(plan && plan.root_task_id === id && !ps),
953
+ };
954
+ const planDir = plan ? `${runs}/${plan.root_task_id}/` : `${runs}/${id}/`;
955
+ if (!t.dispatches.length) {
956
+ if (!ps) continue; // a plan root without any dispatch: nothing to show
957
+ const pov = t.overrides.slice().sort((a, b) => evMs(a) - evMs(b)).pop();
958
+ const pApproved = pov && evMs(pov) > (Date.parse(ps.plan.approved_at) || 0) ? pov.chosen_tier || null : null;
959
+ tasks.push(Object.assign({
960
+ task_id: id, task_type: ps.step.type || null, gate: 2, pending: true, verify_only: false, round: 0,
961
+ }, planInfo, {
962
+ approved_tier: pApproved || ps.step.tier || null, last_tier: null, attempts: [], user_verdict: null, last_steer: null,
963
+ run_dir: planDir, last_ts: null,
964
+ }));
965
+ continue;
966
+ }
967
+ const attempts = t.dispatches.slice().sort(byTime).map((d) => {
968
+ const vs = (agentVerdicts.get(d.agent_id) || []).slice().sort(byTime);
969
+ const last = vs[vs.length - 1];
970
+ let status;
971
+ if (last) status = last.outcome;
972
+ else status = completes.get(d.agent_id) ? 'done' : 'running';
973
+ return {
974
+ agent_id: d.agent_id, role: d.role || 'exec', try: d.try === undefined ? null : d.try,
975
+ model: shortModelName(d.model), effort: d.effort || null, status, _ms: evMs(d), _i: d._i,
976
+ };
977
+ });
978
+ const user = t.user.slice().sort((a, b) => ((Number(a.round) || 0) - (Number(b.round) || 0)) || byTime(a, b));
979
+ const lastUser = user[user.length - 1] || null;
980
+ let gate;
981
+ let round = lastUser ? (Number(lastUser.round) || user.length) : 0;
982
+ // attempts that count for the current round: after the latest user reject
983
+ const sinceMs = lastUser && lastUser.outcome === 'reject' ? evMs(lastUser) : -Infinity;
984
+ const live = attempts.filter((a) => a._ms > sinceMs || (a._ms === sinceMs && a._i > lastUser._i));
985
+ const execs = live.filter((a) => a.role === 'exec');
986
+ if (lastUser && lastUser.outcome === 'accept') {
987
+ gate = 'done';
988
+ } else if (!execs.length) {
989
+ gate = 1;
990
+ } else {
991
+ const running = live.some((a) => a.status === 'running' || a.status === 'done');
992
+ const lastExec = execs[execs.length - 1];
993
+ const lastPassIdx = execs.map((a) => a.status).lastIndexOf('pass');
994
+ const failAfterPass = execs.some((a, i) => AGENT_FAIL[a.status] && i > lastPassIdx);
995
+ if (running || lastPassIdx < 0 || failAfterPass || lastExec.status !== 'pass') gate = 2;
996
+ else gate = 3;
997
+ }
998
+ if (lastUser && lastUser.outcome === 'reject' && gate !== 'done') round = Number(lastUser.round) || round;
999
+ const lastExecAny = attempts.filter((a) => a.role === 'exec').pop() || null;
1000
+ const ov = t.overrides.slice().sort(byTime).pop();
1001
+ const st = t.steers.slice().sort(byTime).pop();
1002
+ const first = t.dispatches[0] || {};
1003
+ const lastMs = Math.max(0, ...attempts.map((a) => a._ms), ...user.map(evMs), ...t.steers.map(evMs));
1004
+ // approved tier: the plan record's tier; an override logged after the approval wins; no plan -> override
1005
+ const approvedMs = plan ? Date.parse(plan.approved_at) || 0 : 0;
1006
+ let approved = null;
1007
+ if (ps && ps.step.tier) approved = ps.step.tier;
1008
+ if (ov && (!approved || evMs(ov) > approvedMs)) approved = ov.chosen_tier || approved;
1009
+ tasks.push(Object.assign({
1010
+ task_id: id,
1011
+ task_type: (t.dispatches.find((d) => d.role === 'exec') || first).task_type || null,
1012
+ gate,
1013
+ pending: false,
1014
+ verify_only: attempts.length > 0 && attempts.every((a) => a.role === 'verify'),
1015
+ round,
1016
+ }, planInfo, {
1017
+ approved_tier: approved,
1018
+ last_tier: lastExecAny ? tierOf(lastExecAny.model, lastExecAny.effort) : null,
1019
+ attempts: attempts.map(({ _ms, _i, ...a }) => a),
1020
+ user_verdict: lastUser ? { outcome: lastUser.outcome, cause: lastUser.cause || null, round: Number(lastUser.round) || null, ts: lastUser.ts } : null,
1021
+ last_steer: st ? st.steer_kind || null : null,
1022
+ run_dir: planDir,
1023
+ last_ts: lastMs ? new Date(lastMs).toISOString() : null,
1024
+ }));
1025
+ }
1026
+ return {
1027
+ session_id: sessionId,
1028
+ generated_at: nowIso(),
1029
+ runs_dir: `${runs}/`,
1030
+ plans: plans.map((p) => ({
1031
+ root_task_id: p.root_task_id,
1032
+ approval: Number.isInteger(p.approval) ? p.approval : (Number.isInteger(p.round) ? p.round : null),
1033
+ approved_at: p.approved_at || null,
1034
+ steps: p.steps.length, run_dir: `${runs}/${p.root_task_id}/`,
1035
+ })),
1036
+ tasks,
1037
+ };
1038
+ }
1039
+
1040
+ function toPosix(p) {
1041
+ return String(p).replace(/\\/g, '/').replace(/\/+$/, '');
1042
+ }
1043
+
1044
+ const GATE_LABEL = { 1: 'cổng 1 (kế hoạch/duyệt)', 2: 'cổng 2 (đang thực thi)', 3: 'cổng 3 (chờ nghiệm thu)', done: 'xong' };
1045
+
1046
+ function gateLabel(t) {
1047
+ if (t.pending) return 'chưa giao (cổng 2)';
1048
+ if (t.is_root && t.gate === 1) return 'gốc kế hoạch';
1049
+ if (t.verify_only && !t.root_task_id && t.gate === 1) return 'chỉ có verify';
1050
+ return GATE_LABEL[t.gate] || String(t.gate);
1051
+ }
1052
+
1053
+ function taskLines(t) {
1054
+ const indent = t.root_task_id ? ' ' : '';
1055
+ const head = `${indent}- ${t.step_id ? `bước ${t.step_id}: ` : ''}${t.task_id} [${t.task_type || '?'}] ${gateLabel(t)}`
1056
+ + `${t.round ? ` · vòng ${t.round}` : ''}${t.approved_tier ? ` · duyệt ${t.approved_tier}` : ''}`
1057
+ + `${t.last_tier && t.last_tier !== t.approved_tier ? ` · tier cuối ${t.last_tier}` : ''}`
1058
+ + `${t.user_verdict ? ` · người dùng: ${t.user_verdict.outcome}${t.user_verdict.cause ? `/${t.user_verdict.cause}` : ''}` : ''}`
1059
+ + `${t.last_steer ? ` · steer: ${t.last_steer}` : ''}`;
1060
+ const att = t.attempts.map((a) => `${a.agent_id} ${a.role}${a.try ? ` try${a.try}` : ''} ${tierOf(a.model, a.effort) || '?'} ${a.status}`);
1061
+ const out = [head];
1062
+ if (att.length) out.push(`${indent} ${att.join(' | ')}`);
1063
+ if (!t.root_task_id) out.push(` → ${t.run_dir}`); // plan members: the run dir is on the plan line
1064
+ return out.join('\n');
1065
+ }
1066
+
1067
+ /** Body lines: one block per approved plan (root first, then steps in plan order), then tasks without a plan. */
1068
+ function stateBody(state, kept) {
1069
+ const lines = [];
1070
+ const shown = state.tasks.filter((t) => kept.has(t));
1071
+ for (const p of state.plans || []) {
1072
+ const mine = shown.filter((t) => t.root_task_id === p.root_task_id);
1073
+ if (!mine.length) continue;
1074
+ lines.push(`Kế hoạch ${p.root_task_id} (lần duyệt ${p.approval === null ? '?' : p.approval}, lúc ${p.approved_at || '?'}, ${p.steps} bước) → ${p.run_dir}`);
1075
+ for (const t of mine.filter((x) => x.is_root).concat(mine.filter((x) => !x.is_root))) lines.push(taskLines(t));
1076
+ }
1077
+ const loose = shown.filter((t) => !t.root_task_id);
1078
+ if (loose.length && lines.length) lines.push('Không thuộc kế hoạch đã duyệt:');
1079
+ for (const t of loose) lines.push(taskLines(t));
1080
+ return lines;
1081
+ }
1082
+
1083
+ /**
1084
+ * Vietnamese text of a session state, at most `cap` chars. When too long, done tasks go first
1085
+ * (oldest first), then the oldest open ones; the count of omitted tasks is stated.
1086
+ */
1087
+ /** Counts shared by the state header and the SessionStart systemMessage. */
1088
+ function stateCounts(state) {
1089
+ const open = state.tasks.filter((t) => t.gate !== 'done' && !t.pending);
1090
+ const pending = state.tasks.filter((t) => t.pending).length;
1091
+ const running = state.tasks.reduce((n, t) => n + t.attempts.filter((a) => a.status === 'running').length, 0);
1092
+ return { tasks: state.tasks.length, open, pending, running };
1093
+ }
1094
+
1095
+ /** One short Vietnamese line shown to the user after /compact re-injected the state. */
1096
+ function compactSystemMessage(state) {
1097
+ const c = stateCounts(state);
1098
+ return `dieu-phoi: đã nạp lại trạng thái điều phối sau khi nén (${c.tasks} task, ${c.running} agent đang chạy, ${c.pending} bước chưa giao)`;
1099
+ }
1100
+
1101
+ function formatSessionState(state, cap) {
1102
+ const limit = cap || STATE_TEXT_CAP;
1103
+ const { open, pending, running } = stateCounts(state);
1104
+ const header = `Phiên ${state.session_id}: ${state.tasks.length} task (${open.length} chưa xong, ${pending} bước chưa giao, ${running} agent đang chạy). `
1105
+ + `Thư mục chạy: ${state.runs_dir} (chỉ trỏ tới, không tự đọc).`;
1106
+ if (!state.tasks.length) return `${header}\nChưa có task nào được giao trong phiên này.`;
1107
+ const byAge = (a, b) => String(a.last_ts || '').localeCompare(String(b.last_ts || ''));
1108
+ const dropOrder = state.tasks.filter((t) => t.gate === 'done').sort(byAge)
1109
+ .concat(open.slice().sort(byAge), state.tasks.filter((t) => t.pending));
1110
+ const kept = new Set(state.tasks);
1111
+ const render = (omitted) => {
1112
+ const body = stateBody(state, kept);
1113
+ const tail = omitted ? `\n(bỏ bớt ${omitted} task cũ nhất để vừa ${limit} ký tự; xem đủ: dieu-phoi state --json)` : '';
1114
+ return `${header}\n${body.join('\n')}${tail}`;
1115
+ };
1116
+ let omitted = 0;
1117
+ let text = render(0);
1118
+ while (text.length > limit && omitted < dropOrder.length) {
1119
+ kept.delete(dropOrder[omitted]);
1120
+ omitted++;
1121
+ text = render(omitted);
1122
+ }
1123
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
1124
+ }
1125
+
1126
+ /** SessionStart(compact): additionalContext with the state, only when the session dispatched via dieu-phoi. */
1127
+ function handleSessionStart(p, ctx) {
1128
+ if (p.source && p.source !== 'compact') return null;
1129
+ const sid = typeof p.session_id === 'string' ? p.session_id : null;
1130
+ if (!sid) return null;
1131
+ const events = readEvents(ctx);
1132
+ if (!events.some((ev) => dispatchInSession(ev, sid))) return null;
1133
+ const state = buildSessionState(events, sid, { plansDir: ctx.paths.plans });
1134
+ const text = `${COMPACT_PREFIX}\n${formatSessionState(state, STATE_TEXT_CAP)}`;
1135
+ return {
1136
+ stdout: JSON.stringify({
1137
+ systemMessage: compactSystemMessage(state),
1138
+ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: text },
1139
+ }),
1140
+ };
1141
+ }
1142
+
514
1143
  // ------------------------------------------------------------------ hook handlers
515
1144
 
516
1145
  function extractAgentId(tr) {
@@ -536,6 +1165,10 @@ function buildDispatch(p, ctx) {
536
1165
  const agentId = extractAgentId(tr)
537
1166
  || (typeof p.tool_use_id === 'string' && p.tool_use_id ? p.tool_use_id : null)
538
1167
  || `noid-${crypto.randomBytes(6).toString('hex')}`;
1168
+ const orch = orchestratorUsage(p.transcript_path, loadPricing(ctx), {
1169
+ cacheFile: orchCacheFile(ctx.paths, p.session_id, p.transcript_path),
1170
+ coldMaxBytes: HOOK_COLD_MAX_BYTES,
1171
+ });
539
1172
  return Object.assign({
540
1173
  schema: SCHEMA,
541
1174
  kind: 'dispatch',
@@ -543,6 +1176,7 @@ function buildDispatch(p, ctx) {
543
1176
  agent_id: agentId,
544
1177
  session: p.session_id ? sha8(p.session_id) : null,
545
1178
  task_id: tag.task_id,
1179
+ ...(tag.task_id && !isValidTaskId(tag.task_id) ? { task_id_invalid: true } : {}),
546
1180
  task_type: tag.task_type,
547
1181
  score: tag.score,
548
1182
  urgency: tag.urgency,
@@ -553,6 +1187,12 @@ function buildDispatch(p, ctx) {
553
1187
  effort_inherited: !explicitEffort,
554
1188
  subagent_type: typeof ti.subagent_type === 'string' && ti.subagent_type ? ti.subagent_type : 'general-purpose',
555
1189
  background: ti.run_in_background === true || trObj.isAsync === true,
1190
+ // Orchestrator (main session) at the moment of this dispatch. Raw session_id joins with `dieu-phoi ctx`.
1191
+ session_id: typeof p.session_id === 'string' && p.session_id ? p.session_id : null,
1192
+ orch_ctx_tokens: orch.ctx_tokens,
1193
+ orch_ctx_baseline: orch.ctx_baseline,
1194
+ orch_compactions: orch.compactions,
1195
+ orch_cum_usd: orch.cum_usd,
556
1196
  }, commonFields(ctx));
557
1197
  }
558
1198
 
@@ -681,7 +1321,10 @@ function readStdin() {
681
1321
  return Buffer.concat(chunks).toString('utf8');
682
1322
  }
683
1323
 
684
- /** Process one hook payload. Never throws. Returns the written event (or null). */
1324
+ /**
1325
+ * Process one hook payload. Never throws. Returns the written event, or for SessionStart { stdout } (the
1326
+ * JSON the caller prints), or null. Nothing is printed here: stdout is written only after success.
1327
+ */
685
1328
  function runHook(opts) {
686
1329
  const o = opts || {};
687
1330
  let home = null;
@@ -693,6 +1336,7 @@ function runHook(opts) {
693
1336
  const ctx = makeCtx(home);
694
1337
  let ev = null;
695
1338
  const name = p.hook_event_name;
1339
+ if (name === 'SessionStart') return handleSessionStart(p, ctx); // { stdout } or null, never an event
696
1340
  if (name === 'SubagentStop') ev = handleSubagentStop(p, ctx);
697
1341
  else if (name === 'PostToolUse' || (!name && p.tool_name)) ev = handlePostToolUse(p, ctx);
698
1342
  if (ev) {
@@ -844,11 +1488,12 @@ async function runSync(ctx, opts) {
844
1488
  // ------------------------------------------------------------------ exports / main
845
1489
 
846
1490
  module.exports = {
1491
+ runsDir, loadPlans, buildSessionState, formatSessionState, stateCounts, compactSystemMessage, handleSessionStart, dispatchInSession, STATE_TEXT_CAP, COMPACT_PREFIX,
847
1492
  CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED,
848
1493
  sha8, nowIso, readJsonFile, writeFileAtomic, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
849
- logError, computeUserHash, userHash, commonFields, parseTag, normalizeModel, loadPricing,
1494
+ logError, computeUserHash, userHash, commonFields, parseTag, TASK_ID_RE, isValidTaskId, newTaskId, taskIdError, normalizeModel, loadPricing,
850
1495
  priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
851
- tagFromTranscript, summarizeTranscript, appendEvent, eventLinesFrom, readEventLines, readEvents,
1496
+ tagFromTranscript, summarizeTranscript, orchestratorUsage, orchCacheFile, findSessionTranscript, SESSION_ID_RE, HOOK_COLD_MAX_BYTES, appendEvent, eventLinesFrom, readEventLines, readEvents,
852
1497
  loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
853
1498
  extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
854
1499
  };
@@ -863,7 +1508,8 @@ if (require.main === module) {
863
1508
  .catch((e) => logError(ctx.home, e, 'sync'))
864
1509
  .finally(() => process.exit(0));
865
1510
  } else {
866
- runHook();
1511
+ const r = runHook();
1512
+ if (r && typeof r.stdout === 'string') process.stdout.write(r.stdout);
867
1513
  process.exitCode = 0;
868
1514
  }
869
1515
  }