@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/CHANGELOG.md +20 -0
- package/README.md +14 -9
- package/bin/dieu-phoi.cjs +11 -2
- package/hook.cjs +606 -4
- package/lib/aggregate.cjs +68 -2
- package/lib/args.cjs +1 -1
- package/lib/choose.cjs +13 -1
- package/lib/commands.cjs +186 -22
- package/lib/setup.cjs +111 -7
- package/package.json +1 -1
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.
|
|
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
|
-
/**
|
|
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
|
}
|