@kienntpixon/dieu-phoi 0.1.2 → 0.2.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/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.0';
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'),
@@ -425,6 +426,285 @@ function summarizeTranscript(lines, opts) {
425
426
  };
426
427
  }
427
428
 
429
+ // ------------------------------------------------------------------ orchestrator (main session) usage
430
+
431
+ const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
432
+ const ORCH_CACHE_V = 2; // 2: + compactions
433
+ const ORCH_CHUNK = 1 << 20; // read granularity; memory = chunk + longest line
434
+ const ORCH_SEEN_KEEP = 64; // requestIds kept for last-line-wins dedupe across scans
435
+ const ORCH_HEAD_BYTES = 4096; // fingerprint of the file start (rotation / replacement check)
436
+ const ORCH_TAIL_START = 256 * 1024; // tail window for the cold fast path, doubled while no usage
437
+ const ORCH_EDGE_MAX = 16 << 20; // cap for head/tail probes in the cold fast path
438
+ const HOOK_COLD_MAX_BYTES = 32 << 20; // hook: transcripts larger than this without a cache take the fast path
439
+ const ORCH_CACHE_TTL_MS = 14 * 24 * 3600 * 1000;
440
+ const USAGE_NEEDLE = Buffer.from('"usage"');
441
+ const COMPACT_NEEDLE = Buffer.from('"compact_boundary"');
442
+
443
+ const ORCH_NONE = Object.freeze({
444
+ ctx_tokens: null, ctx_baseline: null, ctx_growth: null, cum_usd: null, requests: null, compactions: null,
445
+ scanned_bytes: 0,
446
+ });
447
+
448
+ /**
449
+ * One main-thread transcript line -> { key, usage, model } for a request, { compact: true } for a
450
+ * compaction marker (`type:"system", subtype:"compact_boundary"`, written by /compact and auto-compact),
451
+ * null for anything else.
452
+ */
453
+ function parseMainLine(text) {
454
+ let l;
455
+ try { l = JSON.parse(text); } catch (_) { return null; }
456
+ if (!l || l.isSidechain === true) return null;
457
+ if (l.type === 'system' && l.subtype === 'compact_boundary') return { compact: true };
458
+ if (l.type !== 'assistant') return null;
459
+ const msg = l.message;
460
+ if (!msg || typeof msg !== 'object' || !msg.usage || msg.model === '<synthetic>') return null;
461
+ const key = l.requestId || msg.id || l.uuid || null;
462
+ return { key, usage: msg.usage, model: msg.model };
463
+ }
464
+
465
+ /** Request lines only. */
466
+ function mainUsageOf(text) {
467
+ const r = parseMainLine(text);
468
+ return r && !r.compact ? r : null;
469
+ }
470
+
471
+ function ctxOfBuckets(b) {
472
+ return b.input + b.cache_write_5m + b.cache_write_1h + b.cache_read + b.output;
473
+ }
474
+
475
+ /**
476
+ * Stream complete lines of fd in [start, end) in chunks; only lines containing "usage" are decoded and
477
+ * passed to onLine (return true to stop). Returns { consumed: byte just past the last complete line
478
+ * handled, read: bytes read }.
479
+ */
480
+ function scanUsageLines(fd, start, end, onLine) {
481
+ const buf = Buffer.allocUnsafe(ORCH_CHUNK);
482
+ let carry = null;
483
+ let pos = start;
484
+ let consumed = start;
485
+ let read = 0;
486
+ while (pos < end) {
487
+ const n = fs.readSync(fd, buf, 0, Math.min(buf.length, end - pos), pos);
488
+ if (n <= 0) break;
489
+ pos += n;
490
+ read += n;
491
+ const data = carry ? Buffer.concat([carry, buf.subarray(0, n)]) : buf.subarray(0, n);
492
+ const base = pos - data.length;
493
+ let s = 0;
494
+ let nl;
495
+ while ((nl = data.indexOf(0x0a, s)) >= 0) {
496
+ const line = data.subarray(s, nl);
497
+ s = nl + 1;
498
+ if ((line.indexOf(USAGE_NEEDLE) >= 0 || line.indexOf(COMPACT_NEEDLE) >= 0)
499
+ && onLine(line.toString('utf8')) === true) {
500
+ return { consumed: base + s, read };
501
+ }
502
+ }
503
+ consumed = base + s;
504
+ carry = s < data.length ? Buffer.from(data.subarray(s)) : null;
505
+ }
506
+ return { consumed, read };
507
+ }
508
+
509
+ function headFingerprint(fd, size) {
510
+ const len = Math.min(ORCH_HEAD_BYTES, size);
511
+ const b = Buffer.alloc(len);
512
+ if (len) fs.readSync(fd, b, 0, len, 0);
513
+ return { head_len: len, head: crypto.createHash('sha256').update(b).digest('hex').slice(0, 16) };
514
+ }
515
+
516
+ function freshOrchState(fp) {
517
+ return {
518
+ v: ORCH_CACHE_V, head_len: fp.head_len, head: fp.head, offset: 0, requests: 0, cost: 0, priced: 0,
519
+ baseline: null, baseline_req: null, last_ctx: null, cum_known: true, compactions: 0, seen: [],
520
+ };
521
+ }
522
+
523
+ /** Fold one main-thread request line into the state (last line of a requestId wins). */
524
+ function applyUsage(st, seen, u, pricing) {
525
+ const b = usageBuckets(u.usage);
526
+ const c = requestCost(pricing, u.model, b);
527
+ const ctx = ctxOfBuckets(b);
528
+ const key = u.key || `anon-${st.requests}-${st.offset}`;
529
+ if (seen.has(key)) {
530
+ const prev = seen.get(key);
531
+ if (prev !== null) { st.cost -= prev; st.priced--; }
532
+ seen.delete(key);
533
+ } else {
534
+ if (st.requests !== null) st.requests++;
535
+ if (st.baseline_req === null && st.baseline === null) st.baseline_req = key;
536
+ }
537
+ if (key === st.baseline_req) st.baseline = ctx;
538
+ if (c !== null) { st.cost += c; st.priced++; }
539
+ seen.set(key, c);
540
+ if (seen.size > ORCH_SEEN_KEEP) seen.delete(seen.keys().next().value);
541
+ st.last_ctx = ctx;
542
+ }
543
+
544
+ /** Cold fast path: baseline from the head, current ctx from the tail; cost unknown. */
545
+ function edgeProbe(fd, size, st) {
546
+ let first = null;
547
+ scanUsageLines(fd, 0, Math.min(size, ORCH_EDGE_MAX), (t) => { first = mainUsageOf(t); return !!first; });
548
+ let last = null;
549
+ let lastNl = -1;
550
+ for (let w = ORCH_TAIL_START; ; w *= 2) {
551
+ const start = Math.max(0, size - w);
552
+ const b = Buffer.allocUnsafe(size - start);
553
+ fs.readSync(fd, b, 0, b.length, start);
554
+ lastNl = b.lastIndexOf(0x0a);
555
+ const from = start === 0 ? 0 : b.indexOf(0x0a) + 1;
556
+ let s = from;
557
+ let nl;
558
+ while (lastNl >= 0 && (nl = b.indexOf(0x0a, s)) >= 0 && nl <= lastNl) {
559
+ const line = b.subarray(s, nl);
560
+ s = nl + 1;
561
+ if (line.indexOf(USAGE_NEEDLE) < 0) continue;
562
+ const u = mainUsageOf(line.toString('utf8'));
563
+ if (u) last = u;
564
+ }
565
+ if (lastNl >= 0) lastNl += start;
566
+ if (last || start === 0 || w >= ORCH_EDGE_MAX) break;
567
+ }
568
+ st.cum_known = false;
569
+ st.requests = null;
570
+ st.compactions = null; // unknown until a full scan
571
+ if (first) {
572
+ st.baseline = ctxOfBuckets(usageBuckets(first.usage));
573
+ st.baseline_req = first.key;
574
+ }
575
+ if (last) st.last_ctx = ctxOfBuckets(usageBuckets(last.usage));
576
+ st.offset = lastNl >= 0 ? lastNl + 1 : 0;
577
+ }
578
+
579
+ /** Drop stale session caches. Only ever touches <data>/orch/*.json (never another directory). */
580
+ function pruneOrchCache(dir) {
581
+ try {
582
+ if (path.basename(dir) !== 'orch') return;
583
+ const now = Date.now();
584
+ for (const f of fs.readdirSync(dir)) {
585
+ if (!/^[A-Za-z0-9_-]{1,140}\.json$/.test(f)) continue;
586
+ const p = path.join(dir, f);
587
+ try { if (now - fs.statSync(p).mtimeMs > ORCH_CACHE_TTL_MS) fs.unlinkSync(p); } catch (_) { /* gone */ }
588
+ }
589
+ } catch (_) { /* no dir */ }
590
+ }
591
+
592
+ /** Cache file for a session (data/orch/<session_id>.json); no session -> hash of the transcript path. */
593
+ function orchCacheFile(paths, sessionId, transcriptPath) {
594
+ if (!paths || !paths.data) return null;
595
+ const name = typeof sessionId === 'string' && SESSION_ID_RE.test(sessionId)
596
+ ? sessionId : `p-${sha8(String(transcriptPath))}`;
597
+ return path.join(paths.data, 'orch', `${name}.json`);
598
+ }
599
+
600
+ /**
601
+ * Size and running cost of the orchestrating (main) session, from its transcript JSONL.
602
+ * Only main-thread requests count: `type:"assistant"` lines with message.usage, minus
603
+ * `isSidechain:true` and `<synthetic>`; deduped per requestId (last line wins).
604
+ * ctx_tokens = input + cache_creation + cache_read + output of the LAST request (= current context)
605
+ * ctx_baseline = the same for the FIRST request of the session (system prompt + tools + first turn)
606
+ * ctx_growth = max(0, ctx_tokens - ctx_baseline)
607
+ * cum_usd = sum of requestCost per request at its own model's price (unpriced requests left out;
608
+ * null when none priced, or when only the cold fast path ran)
609
+ * opts.cacheFile: per-session state ({offset, cost, seen requestIds, baseline, …}); each call then parses
610
+ * only the bytes appended since the last call. A shrunk or replaced file (offset > size or a different
611
+ * head fingerprint) is recomputed from 0.
612
+ * opts.coldMaxBytes: no usable cache and the file is larger -> head/tail probe only (ctx + baseline,
613
+ * cum_usd null); a later call without the limit (`dieu-phoi ctx`) recomputes the full sum.
614
+ * Reads in chunks (memory = chunk + longest line), never the whole file at once. Never throws.
615
+ */
616
+ function orchestratorUsage(transcriptPath, pricing, opts) {
617
+ const o = opts || {};
618
+ let fd = null;
619
+ try {
620
+ if (typeof transcriptPath !== 'string' || !transcriptPath) return Object.assign({}, ORCH_NONE);
621
+ let stat;
622
+ try { stat = fs.statSync(transcriptPath); } catch (_) { return Object.assign({}, ORCH_NONE); }
623
+ if (!stat.isFile()) return Object.assign({}, ORCH_NONE);
624
+ const size = stat.size;
625
+ const pr = pricing || loadPricing(null);
626
+ fd = fs.openSync(transcriptPath, 'r');
627
+ const fp = headFingerprint(fd, size);
628
+ let st = null;
629
+ const cached = o.cacheFile ? readJsonFile(o.cacheFile, null) : null;
630
+ if (cached && cached.v === ORCH_CACHE_V && Number.isFinite(cached.offset) && cached.offset <= size
631
+ && cached.head_len <= size && Array.isArray(cached.seen)
632
+ && (cached.cum_known || o.coldMaxBytes)) {
633
+ const now = cached.head_len === fp.head_len ? fp : (() => {
634
+ const b = Buffer.alloc(cached.head_len);
635
+ if (cached.head_len) fs.readSync(fd, b, 0, cached.head_len, 0);
636
+ return { head_len: cached.head_len, head: crypto.createHash('sha256').update(b).digest('hex').slice(0, 16) };
637
+ })();
638
+ if (now.head === cached.head) st = cached;
639
+ }
640
+ let scanned = 0;
641
+ const startOffset = st ? st.offset : 0;
642
+ if (!st) {
643
+ st = freshOrchState(fp);
644
+ if (o.coldMaxBytes && size > o.coldMaxBytes) {
645
+ edgeProbe(fd, size, st);
646
+ scanned = -1; // probe, not a scan
647
+ }
648
+ }
649
+ if (scanned === 0 && st.offset < size) {
650
+ const seen = new Map(st.seen);
651
+ const r = scanUsageLines(fd, st.offset, size, (t) => {
652
+ const u = parseMainLine(t);
653
+ if (u && u.compact) { if (st.compactions !== null) st.compactions++; } else if (u) applyUsage(st, seen, u, pr);
654
+ return false;
655
+ });
656
+ st.offset = r.consumed;
657
+ st.seen = Array.from(seen.entries());
658
+ scanned = r.read;
659
+ }
660
+ const reused = st === cached;
661
+ if (o.cacheFile && (!reused || st.offset !== startOffset)) {
662
+ try {
663
+ if (!cached) pruneOrchCache(path.dirname(o.cacheFile));
664
+ writeFileAtomic(o.cacheFile, `${JSON.stringify(st)}\n`);
665
+ } catch (_) { /* cache is an optimisation only */ }
666
+ }
667
+ if (st.last_ctx === null) return Object.assign({}, ORCH_NONE, { scanned_bytes: Math.max(0, scanned) });
668
+ const growth = st.baseline === null ? null : Math.max(0, st.last_ctx - st.baseline);
669
+ return {
670
+ ctx_tokens: st.last_ctx,
671
+ ctx_baseline: st.baseline,
672
+ ctx_growth: growth,
673
+ cum_usd: st.cum_known && st.priced > 0 ? Math.round(st.cost * 1e6) / 1e6 : null,
674
+ requests: st.cum_known ? st.requests : null,
675
+ compactions: typeof st.compactions === 'number' ? st.compactions : null,
676
+ scanned_bytes: Math.max(0, scanned),
677
+ };
678
+ } catch (_) {
679
+ return Object.assign({}, ORCH_NONE);
680
+ } finally {
681
+ if (fd !== null) { try { fs.closeSync(fd); } catch (_) { /* closed */ } }
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Main transcript of a session: <claudeDir>/projects/<any project>/<sessionId>.jsonl (newest if
687
+ * several). Returns null when the id is malformed or no file exists.
688
+ */
689
+ function findSessionTranscript(claudeDir, sessionId) {
690
+ try {
691
+ if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId)) return null;
692
+ const root = path.join(claudeDir || defaultClaudeDir(), 'projects');
693
+ let best = null;
694
+ let bestM = -1;
695
+ for (const d of fs.readdirSync(root)) {
696
+ const f = path.join(root, d, `${sessionId}.jsonl`);
697
+ try {
698
+ const st = fs.statSync(f);
699
+ if (st.isFile() && st.mtimeMs > bestM) { best = f; bestM = st.mtimeMs; }
700
+ } catch (_) { /* not in this project */ }
701
+ }
702
+ return best;
703
+ } catch (_) {
704
+ return null;
705
+ }
706
+ }
707
+
428
708
  // ------------------------------------------------------------------ events (append-only)
429
709
 
430
710
  /** Append one event. events.jsonl is append-only: never rewritten, sync progress lives elsewhere. */
@@ -511,6 +791,312 @@ function saveSegments(ctx, agentId, rec) {
511
791
  writeFileAtomic(segmentFile(ctx, agentId), `${JSON.stringify(rec)}\n`);
512
792
  }
513
793
 
794
+ // ------------------------------------------------------------------ session state (for /compact)
795
+
796
+ const STATE_TEXT_CAP = 8000;
797
+ 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):';
798
+ const AGENT_FAIL = { fail: 1, timeout: 1 };
799
+
800
+ /**
801
+ * Run directory for subagent reports: ~/.dieu-phoi/runs (override: DIEU_PHOI_RUNS_DIR, absolute).
802
+ * Deliberately outside ~/.claude (a Claude Code protected path).
803
+ */
804
+ function runsDir() {
805
+ if (process.env.DIEU_PHOI_RUNS_DIR) return path.resolve(process.env.DIEU_PHOI_RUNS_DIR);
806
+ return path.join(os.homedir(), '.dieu-phoi', 'runs');
807
+ }
808
+
809
+ function shortModelName(m) {
810
+ const id = normalizeModel(m);
811
+ if (!id) return null;
812
+ for (const k of ['opus', 'sonnet', 'haiku', 'fable']) if (id.includes(k)) return k;
813
+ return id;
814
+ }
815
+
816
+ function tierOf(model, effort) {
817
+ const m = shortModelName(model);
818
+ return m || effort ? `${m || '?'}/${effort || '?'}` : null;
819
+ }
820
+
821
+ function evMs(ev) {
822
+ const t = Date.parse(ev && ev.ts);
823
+ return isNaN(t) ? 0 : t;
824
+ }
825
+
826
+ /** Verdict layer, same reading as the stats (legacy `rework` = user reject quality). */
827
+ function verdictLayer(ev) {
828
+ if (ev.outcome === 'rework' && !ev.by) return { by: 'user', outcome: 'reject', cause: 'quality' };
829
+ const by = ev.by === 'user' ? 'user' : 'agent';
830
+ let cause = ev.cause || null;
831
+ if (by === 'user' && ev.outcome === 'reject' && cause !== 'scope') cause = 'quality';
832
+ return { by, outcome: ev.outcome, cause };
833
+ }
834
+
835
+ function dispatchInSession(ev, sessionId) {
836
+ if (!ev || ev.kind !== 'dispatch') return false;
837
+ if (typeof ev.session_id === 'string' && ev.session_id) return ev.session_id === sessionId;
838
+ return ev.session === sha8(sessionId); // core < 0.2.0: hashed session only
839
+ }
840
+
841
+ /** Approved-plan records written by `plan-table --approve` (<data>/plans/<root>.json). Never throws. */
842
+ function loadPlans(dir) {
843
+ const out = [];
844
+ if (!dir) return out;
845
+ let names = [];
846
+ try { names = fs.readdirSync(dir); } catch (_) { return out; }
847
+ for (const n of names) {
848
+ if (!/^[A-Za-z0-9_-]{1,64}\.json$/.test(n)) continue;
849
+ const p = readJsonFile(path.join(dir, n), null);
850
+ if (p && typeof p.root_task_id === 'string' && Array.isArray(p.steps)) out.push(p);
851
+ }
852
+ return out;
853
+ }
854
+
855
+ /**
856
+ * Orchestration state of one session, rebuilt from events.jsonl plus the local approved-plan records
857
+ * (CONTRACT "Sau khi nén"). Tasks = task_ids that had a dispatch in this session, plus the root and steps
858
+ * of every approved plan touching those tasks. Never reads run-dir files.
859
+ * opts: { runsDir, plansDir | plans }.
860
+ */
861
+ function buildSessionState(events, sessionId, opts) {
862
+ const o = opts || {};
863
+ const runs = toPosix(o.runsDir || runsDir());
864
+ const taskIds = [];
865
+ const seenTask = new Set();
866
+ for (const ev of events) {
867
+ if (dispatchInSession(ev, sessionId) && typeof ev.task_id === 'string' && ev.task_id && !seenTask.has(ev.task_id)) {
868
+ seenTask.add(ev.task_id);
869
+ taskIds.push(ev.task_id);
870
+ }
871
+ }
872
+ const allPlans = Array.isArray(o.plans) ? o.plans : loadPlans(o.plansDir);
873
+ const plans = allPlans.filter((p) => seenTask.has(p.root_task_id)
874
+ || p.steps.some((s) => s && s.task_id && seenTask.has(s.task_id)));
875
+ const planOfRoot = new Map(plans.map((p) => [p.root_task_id, p]));
876
+ const stepOf = new Map();
877
+ for (const p of plans) {
878
+ 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 });
879
+ }
880
+ for (const id of [...planOfRoot.keys(), ...stepOf.keys()]) {
881
+ if (!seenTask.has(id)) { seenTask.add(id); taskIds.push(id); }
882
+ }
883
+ const per = new Map(taskIds.map((id) => [id, { dispatches: [], user: [], steers: [], overrides: [], agentIds: new Set() }]));
884
+ events.forEach((ev, i) => {
885
+ if (!ev || typeof ev !== 'object') return;
886
+ ev._i = i;
887
+ if (ev.kind === 'dispatch' && per.has(ev.task_id)) {
888
+ const t = per.get(ev.task_id);
889
+ if (!t.agentIds.has(ev.agent_id)) { t.agentIds.add(ev.agent_id); t.dispatches.push(ev); }
890
+ }
891
+ });
892
+ const agentTask = new Map();
893
+ for (const [id, t] of per) for (const a of t.agentIds) agentTask.set(a, id);
894
+ const completes = new Map();
895
+ const agentVerdicts = new Map();
896
+ for (const ev of events) {
897
+ if (!ev || typeof ev !== 'object') continue;
898
+ if (ev.kind === 'complete' && agentTask.has(ev.agent_id)) completes.set(ev.agent_id, (completes.get(ev.agent_id) || 0) + 1);
899
+ if (ev.kind === 'verdict') {
900
+ const v = verdictLayer(ev);
901
+ if (v.by === 'user') {
902
+ const id = ev.task_id || (String(ev.agent_id).startsWith('task:') ? String(ev.agent_id).slice(5) : agentTask.get(ev.agent_id));
903
+ if (per.has(id)) per.get(id).user.push(Object.assign({}, ev, v));
904
+ } else if (agentTask.has(ev.agent_id)) {
905
+ if (!agentVerdicts.has(ev.agent_id)) agentVerdicts.set(ev.agent_id, []);
906
+ agentVerdicts.get(ev.agent_id).push(Object.assign({}, ev, v));
907
+ }
908
+ }
909
+ if (ev.kind === 'steer') {
910
+ const id = ev.task_id || agentTask.get(ev.agent_id);
911
+ if (per.has(id)) per.get(id).steers.push(ev);
912
+ }
913
+ if (ev.kind === 'override' && per.has(ev.task_id)) per.get(ev.task_id).overrides.push(ev);
914
+ }
915
+ const byTime = (a, b) => evMs(a) - evMs(b) || a._i - b._i;
916
+ const tasks = [];
917
+ for (const id of taskIds) {
918
+ const t = per.get(id);
919
+ const ps = stepOf.get(id) || null;
920
+ const plan = ps ? ps.plan : (planOfRoot.get(id) || null);
921
+ const planInfo = {
922
+ root_task_id: plan ? plan.root_task_id : null,
923
+ step_id: ps ? (ps.step.id === undefined ? null : String(ps.step.id)) : null,
924
+ is_root: !!(plan && plan.root_task_id === id && !ps),
925
+ };
926
+ const planDir = plan ? `${runs}/${plan.root_task_id}/` : `${runs}/${id}/`;
927
+ if (!t.dispatches.length) {
928
+ if (!ps) continue; // a plan root without any dispatch: nothing to show
929
+ const pov = t.overrides.slice().sort((a, b) => evMs(a) - evMs(b)).pop();
930
+ const pApproved = pov && evMs(pov) > (Date.parse(ps.plan.approved_at) || 0) ? pov.chosen_tier || null : null;
931
+ tasks.push(Object.assign({
932
+ task_id: id, task_type: ps.step.type || null, gate: 2, pending: true, verify_only: false, round: 0,
933
+ }, planInfo, {
934
+ approved_tier: pApproved || ps.step.tier || null, last_tier: null, attempts: [], user_verdict: null, last_steer: null,
935
+ run_dir: planDir, last_ts: null,
936
+ }));
937
+ continue;
938
+ }
939
+ const attempts = t.dispatches.slice().sort(byTime).map((d) => {
940
+ const vs = (agentVerdicts.get(d.agent_id) || []).slice().sort(byTime);
941
+ const last = vs[vs.length - 1];
942
+ let status;
943
+ if (last) status = last.outcome;
944
+ else status = completes.get(d.agent_id) ? 'done' : 'running';
945
+ return {
946
+ agent_id: d.agent_id, role: d.role || 'exec', try: d.try === undefined ? null : d.try,
947
+ model: shortModelName(d.model), effort: d.effort || null, status, _ms: evMs(d), _i: d._i,
948
+ };
949
+ });
950
+ const user = t.user.slice().sort((a, b) => ((Number(a.round) || 0) - (Number(b.round) || 0)) || byTime(a, b));
951
+ const lastUser = user[user.length - 1] || null;
952
+ let gate;
953
+ let round = lastUser ? (Number(lastUser.round) || user.length) : 0;
954
+ // attempts that count for the current round: after the latest user reject
955
+ const sinceMs = lastUser && lastUser.outcome === 'reject' ? evMs(lastUser) : -Infinity;
956
+ const live = attempts.filter((a) => a._ms > sinceMs || (a._ms === sinceMs && a._i > lastUser._i));
957
+ const execs = live.filter((a) => a.role === 'exec');
958
+ if (lastUser && lastUser.outcome === 'accept') {
959
+ gate = 'done';
960
+ } else if (!execs.length) {
961
+ gate = 1;
962
+ } else {
963
+ const running = live.some((a) => a.status === 'running' || a.status === 'done');
964
+ const lastExec = execs[execs.length - 1];
965
+ const lastPassIdx = execs.map((a) => a.status).lastIndexOf('pass');
966
+ const failAfterPass = execs.some((a, i) => AGENT_FAIL[a.status] && i > lastPassIdx);
967
+ if (running || lastPassIdx < 0 || failAfterPass || lastExec.status !== 'pass') gate = 2;
968
+ else gate = 3;
969
+ }
970
+ if (lastUser && lastUser.outcome === 'reject' && gate !== 'done') round = Number(lastUser.round) || round;
971
+ const lastExecAny = attempts.filter((a) => a.role === 'exec').pop() || null;
972
+ const ov = t.overrides.slice().sort(byTime).pop();
973
+ const st = t.steers.slice().sort(byTime).pop();
974
+ const first = t.dispatches[0] || {};
975
+ const lastMs = Math.max(0, ...attempts.map((a) => a._ms), ...user.map(evMs), ...t.steers.map(evMs));
976
+ // approved tier: the plan record's tier; an override logged after the approval wins; no plan -> override
977
+ const approvedMs = plan ? Date.parse(plan.approved_at) || 0 : 0;
978
+ let approved = null;
979
+ if (ps && ps.step.tier) approved = ps.step.tier;
980
+ if (ov && (!approved || evMs(ov) > approvedMs)) approved = ov.chosen_tier || approved;
981
+ tasks.push(Object.assign({
982
+ task_id: id,
983
+ task_type: (t.dispatches.find((d) => d.role === 'exec') || first).task_type || null,
984
+ gate,
985
+ pending: false,
986
+ verify_only: attempts.length > 0 && attempts.every((a) => a.role === 'verify'),
987
+ round,
988
+ }, planInfo, {
989
+ approved_tier: approved,
990
+ last_tier: lastExecAny ? tierOf(lastExecAny.model, lastExecAny.effort) : null,
991
+ attempts: attempts.map(({ _ms, _i, ...a }) => a),
992
+ user_verdict: lastUser ? { outcome: lastUser.outcome, cause: lastUser.cause || null, round: Number(lastUser.round) || null, ts: lastUser.ts } : null,
993
+ last_steer: st ? st.steer_kind || null : null,
994
+ run_dir: planDir,
995
+ last_ts: lastMs ? new Date(lastMs).toISOString() : null,
996
+ }));
997
+ }
998
+ return {
999
+ session_id: sessionId,
1000
+ generated_at: nowIso(),
1001
+ runs_dir: `${runs}/`,
1002
+ plans: plans.map((p) => ({
1003
+ root_task_id: p.root_task_id,
1004
+ approval: Number.isInteger(p.approval) ? p.approval : (Number.isInteger(p.round) ? p.round : null),
1005
+ approved_at: p.approved_at || null,
1006
+ steps: p.steps.length, run_dir: `${runs}/${p.root_task_id}/`,
1007
+ })),
1008
+ tasks,
1009
+ };
1010
+ }
1011
+
1012
+ function toPosix(p) {
1013
+ return String(p).replace(/\\/g, '/').replace(/\/+$/, '');
1014
+ }
1015
+
1016
+ 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' };
1017
+
1018
+ function gateLabel(t) {
1019
+ if (t.pending) return 'chưa giao (cổng 2)';
1020
+ if (t.is_root && t.gate === 1) return 'gốc kế hoạch';
1021
+ if (t.verify_only && !t.root_task_id && t.gate === 1) return 'chỉ có verify';
1022
+ return GATE_LABEL[t.gate] || String(t.gate);
1023
+ }
1024
+
1025
+ function taskLines(t) {
1026
+ const indent = t.root_task_id ? ' ' : '';
1027
+ const head = `${indent}- ${t.step_id ? `bước ${t.step_id}: ` : ''}${t.task_id} [${t.task_type || '?'}] ${gateLabel(t)}`
1028
+ + `${t.round ? ` · vòng ${t.round}` : ''}${t.approved_tier ? ` · duyệt ${t.approved_tier}` : ''}`
1029
+ + `${t.last_tier && t.last_tier !== t.approved_tier ? ` · tier cuối ${t.last_tier}` : ''}`
1030
+ + `${t.user_verdict ? ` · người dùng: ${t.user_verdict.outcome}${t.user_verdict.cause ? `/${t.user_verdict.cause}` : ''}` : ''}`
1031
+ + `${t.last_steer ? ` · steer: ${t.last_steer}` : ''}`;
1032
+ const att = t.attempts.map((a) => `${a.agent_id} ${a.role}${a.try ? ` try${a.try}` : ''} ${tierOf(a.model, a.effort) || '?'} ${a.status}`);
1033
+ const out = [head];
1034
+ if (att.length) out.push(`${indent} ${att.join(' | ')}`);
1035
+ if (!t.root_task_id) out.push(` → ${t.run_dir}`); // plan members: the run dir is on the plan line
1036
+ return out.join('\n');
1037
+ }
1038
+
1039
+ /** Body lines: one block per approved plan (root first, then steps in plan order), then tasks without a plan. */
1040
+ function stateBody(state, kept) {
1041
+ const lines = [];
1042
+ const shown = state.tasks.filter((t) => kept.has(t));
1043
+ for (const p of state.plans || []) {
1044
+ const mine = shown.filter((t) => t.root_task_id === p.root_task_id);
1045
+ if (!mine.length) continue;
1046
+ 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}`);
1047
+ for (const t of mine.filter((x) => x.is_root).concat(mine.filter((x) => !x.is_root))) lines.push(taskLines(t));
1048
+ }
1049
+ const loose = shown.filter((t) => !t.root_task_id);
1050
+ if (loose.length && lines.length) lines.push('Không thuộc kế hoạch đã duyệt:');
1051
+ for (const t of loose) lines.push(taskLines(t));
1052
+ return lines;
1053
+ }
1054
+
1055
+ /**
1056
+ * Vietnamese text of a session state, at most `cap` chars. When too long, done tasks go first
1057
+ * (oldest first), then the oldest open ones; the count of omitted tasks is stated.
1058
+ */
1059
+ function formatSessionState(state, cap) {
1060
+ const limit = cap || STATE_TEXT_CAP;
1061
+ const open = state.tasks.filter((t) => t.gate !== 'done' && !t.pending);
1062
+ const pending = state.tasks.filter((t) => t.pending).length;
1063
+ const running = state.tasks.reduce((n, t) => n + t.attempts.filter((a) => a.status === 'running').length, 0);
1064
+ 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). `
1065
+ + `Thư mục chạy: ${state.runs_dir} (chỉ trỏ tới, không tự đọc).`;
1066
+ if (!state.tasks.length) return `${header}\nChưa có task nào được giao trong phiên này.`;
1067
+ const byAge = (a, b) => String(a.last_ts || '').localeCompare(String(b.last_ts || ''));
1068
+ const dropOrder = state.tasks.filter((t) => t.gate === 'done').sort(byAge)
1069
+ .concat(open.slice().sort(byAge), state.tasks.filter((t) => t.pending));
1070
+ const kept = new Set(state.tasks);
1071
+ const render = (omitted) => {
1072
+ const body = stateBody(state, kept);
1073
+ const tail = omitted ? `\n(bỏ bớt ${omitted} task cũ nhất để vừa ${limit} ký tự; xem đủ: dieu-phoi state --json)` : '';
1074
+ return `${header}\n${body.join('\n')}${tail}`;
1075
+ };
1076
+ let omitted = 0;
1077
+ let text = render(0);
1078
+ while (text.length > limit && omitted < dropOrder.length) {
1079
+ kept.delete(dropOrder[omitted]);
1080
+ omitted++;
1081
+ text = render(omitted);
1082
+ }
1083
+ return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
1084
+ }
1085
+
1086
+ /** SessionStart(compact): additionalContext with the state, only when the session dispatched via dieu-phoi. */
1087
+ function handleSessionStart(p, ctx) {
1088
+ if (p.source && p.source !== 'compact') return null;
1089
+ const sid = typeof p.session_id === 'string' ? p.session_id : null;
1090
+ if (!sid) return null;
1091
+ const events = readEvents(ctx);
1092
+ if (!events.some((ev) => dispatchInSession(ev, sid))) return null;
1093
+ const state = buildSessionState(events, sid, { plansDir: ctx.paths.plans });
1094
+ const text = `${COMPACT_PREFIX}\n${formatSessionState(state, STATE_TEXT_CAP)}`;
1095
+ return {
1096
+ stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: text } }),
1097
+ };
1098
+ }
1099
+
514
1100
  // ------------------------------------------------------------------ hook handlers
515
1101
 
516
1102
  function extractAgentId(tr) {
@@ -536,6 +1122,10 @@ function buildDispatch(p, ctx) {
536
1122
  const agentId = extractAgentId(tr)
537
1123
  || (typeof p.tool_use_id === 'string' && p.tool_use_id ? p.tool_use_id : null)
538
1124
  || `noid-${crypto.randomBytes(6).toString('hex')}`;
1125
+ const orch = orchestratorUsage(p.transcript_path, loadPricing(ctx), {
1126
+ cacheFile: orchCacheFile(ctx.paths, p.session_id, p.transcript_path),
1127
+ coldMaxBytes: HOOK_COLD_MAX_BYTES,
1128
+ });
539
1129
  return Object.assign({
540
1130
  schema: SCHEMA,
541
1131
  kind: 'dispatch',
@@ -553,6 +1143,12 @@ function buildDispatch(p, ctx) {
553
1143
  effort_inherited: !explicitEffort,
554
1144
  subagent_type: typeof ti.subagent_type === 'string' && ti.subagent_type ? ti.subagent_type : 'general-purpose',
555
1145
  background: ti.run_in_background === true || trObj.isAsync === true,
1146
+ // Orchestrator (main session) at the moment of this dispatch. Raw session_id joins with `dieu-phoi ctx`.
1147
+ session_id: typeof p.session_id === 'string' && p.session_id ? p.session_id : null,
1148
+ orch_ctx_tokens: orch.ctx_tokens,
1149
+ orch_ctx_baseline: orch.ctx_baseline,
1150
+ orch_compactions: orch.compactions,
1151
+ orch_cum_usd: orch.cum_usd,
556
1152
  }, commonFields(ctx));
557
1153
  }
558
1154
 
@@ -681,7 +1277,10 @@ function readStdin() {
681
1277
  return Buffer.concat(chunks).toString('utf8');
682
1278
  }
683
1279
 
684
- /** Process one hook payload. Never throws. Returns the written event (or null). */
1280
+ /**
1281
+ * Process one hook payload. Never throws. Returns the written event, or for SessionStart { stdout } (the
1282
+ * JSON the caller prints), or null. Nothing is printed here: stdout is written only after success.
1283
+ */
685
1284
  function runHook(opts) {
686
1285
  const o = opts || {};
687
1286
  let home = null;
@@ -693,6 +1292,7 @@ function runHook(opts) {
693
1292
  const ctx = makeCtx(home);
694
1293
  let ev = null;
695
1294
  const name = p.hook_event_name;
1295
+ if (name === 'SessionStart') return handleSessionStart(p, ctx); // { stdout } or null, never an event
696
1296
  if (name === 'SubagentStop') ev = handleSubagentStop(p, ctx);
697
1297
  else if (name === 'PostToolUse' || (!name && p.tool_name)) ev = handlePostToolUse(p, ctx);
698
1298
  if (ev) {
@@ -844,11 +1444,12 @@ async function runSync(ctx, opts) {
844
1444
  // ------------------------------------------------------------------ exports / main
845
1445
 
846
1446
  module.exports = {
1447
+ runsDir, loadPlans, buildSessionState, formatSessionState, handleSessionStart, dispatchInSession, STATE_TEXT_CAP, COMPACT_PREFIX,
847
1448
  CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED,
848
1449
  sha8, nowIso, readJsonFile, writeFileAtomic, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
849
1450
  logError, computeUserHash, userHash, commonFields, parseTag, normalizeModel, loadPricing,
850
1451
  priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
851
- tagFromTranscript, summarizeTranscript, appendEvent, eventLinesFrom, readEventLines, readEvents,
1452
+ tagFromTranscript, summarizeTranscript, orchestratorUsage, orchCacheFile, findSessionTranscript, SESSION_ID_RE, HOOK_COLD_MAX_BYTES, appendEvent, eventLinesFrom, readEventLines, readEvents,
852
1453
  loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
853
1454
  extractAgentId, hasAssistantUsage, hasDispatch, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
854
1455
  };
@@ -863,7 +1464,8 @@ if (require.main === module) {
863
1464
  .catch((e) => logError(ctx.home, e, 'sync'))
864
1465
  .finally(() => process.exit(0));
865
1466
  } else {
866
- runHook();
1467
+ const r = runHook();
1468
+ if (r && typeof r.stdout === 'string') process.stdout.write(r.stdout);
867
1469
  process.exitCode = 0;
868
1470
  }
869
1471
  }