@kienntpixon/dieu-phoi 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,834 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /*
4
+ * dieu-phoi hook + core helpers.
5
+ *
6
+ * This file is copied verbatim to ~/.claude/dieu-phoi/bin/hook.cjs by `dieu-phoi setup`
7
+ * and run by Claude Code on PostToolUse(Agent) and SubagentStop. It must stay
8
+ * self-contained: Node built-ins only, no requires of other package files.
9
+ *
10
+ * Hook rules: never write to stdout, always exit 0, no synchronous network.
11
+ * Errors go to <home>/hook-errors.log.
12
+ *
13
+ * node hook.cjs -> read hook payload from stdin, append event
14
+ * node hook.cjs --sync -> push unsynced events to the sink (spawned detached)
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+ const crypto = require('crypto');
21
+
22
+ const CORE_VERSION = '0.1.0';
23
+ const SCHEMA = 1;
24
+ const TASK_TYPES = [
25
+ 'search', 'extract', 'mechanical-edit', 'feature-impl', 'debug', 'refactor',
26
+ 'test-write', 'review', 'research-synth', 'content-write', 'plan-design', 'data-analysis',
27
+ ];
28
+ const URGENCIES = ['relaxed', 'normal', 'urgent'];
29
+ const ROLES = ['exec', 'verify', 'plan'];
30
+ const MODEL_ALIASES = {
31
+ opus: 'claude-opus-5-5',
32
+ sonnet: 'claude-sonnet-5-5',
33
+ haiku: 'claude-haiku-5-5',
34
+ fable: 'claude-fable-5-5',
35
+ };
36
+ const SYNC_SPAWN_INTERVAL_MS = 10 * 60 * 1000;
37
+ const MAX_ERRLOG_BYTES = 512 * 1024;
38
+
39
+ // ------------------------------------------------------------------ basics
40
+
41
+ function sha8(s) {
42
+ return crypto.createHash('sha256').update(String(s)).digest('hex').slice(0, 8);
43
+ }
44
+
45
+ function nowIso() {
46
+ return new Date().toISOString();
47
+ }
48
+
49
+ function sleepSync(ms) {
50
+ try {
51
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
52
+ } catch (_) { /* ignore */ }
53
+ }
54
+
55
+ function readJsonFile(file, fallback) {
56
+ try {
57
+ const txt = fs.readFileSync(file, 'utf8').replace(/^/, '');
58
+ return JSON.parse(txt);
59
+ } catch (_) {
60
+ return fallback;
61
+ }
62
+ }
63
+
64
+ function renameWithRetry(from, to) {
65
+ let lastErr;
66
+ for (let i = 0; i < 8; i++) {
67
+ try {
68
+ fs.renameSync(from, to);
69
+ return;
70
+ } catch (e) {
71
+ lastErr = e;
72
+ if (!['EPERM', 'EBUSY', 'EACCES'].includes(e.code)) break;
73
+ sleepSync(25 * (i + 1));
74
+ }
75
+ }
76
+ try { fs.unlinkSync(from); } catch (_) { /* ignore */ }
77
+ throw lastErr;
78
+ }
79
+
80
+ /** Write a file atomically (tmp + rename), LF only. */
81
+ function writeFileAtomic(file, content, mode) {
82
+ fs.mkdirSync(path.dirname(file), { recursive: true });
83
+ const tmp = `${file}.tmp-${process.pid}-${crypto.randomBytes(3).toString('hex')}`;
84
+ fs.writeFileSync(tmp, content, mode ? { mode } : undefined);
85
+ renameWithRetry(tmp, file);
86
+ }
87
+
88
+ // ------------------------------------------------------------------ paths / config
89
+
90
+ /** Claude config dir: CLAUDE_CONFIG_DIR, else ~/.claude. */
91
+ function defaultClaudeDir() {
92
+ if (process.env.CLAUDE_CONFIG_DIR) return path.resolve(process.env.CLAUDE_CONFIG_DIR);
93
+ return path.join(os.homedir(), '.claude');
94
+ }
95
+
96
+ /**
97
+ * Data home (normally ~/.claude/dieu-phoi). Order: explicit arg, DIEU_PHOI_HOME,
98
+ * DIEU_PHOI_CLAUDE_DIR, installed layout (<home>/bin/hook.cjs), CLAUDE_CONFIG_DIR, ~/.claude.
99
+ */
100
+ function resolveHome(explicit) {
101
+ if (explicit) return path.resolve(explicit);
102
+ if (process.env.DIEU_PHOI_HOME) return path.resolve(process.env.DIEU_PHOI_HOME);
103
+ if (process.env.DIEU_PHOI_CLAUDE_DIR) {
104
+ return path.join(path.resolve(process.env.DIEU_PHOI_CLAUDE_DIR), 'dieu-phoi');
105
+ }
106
+ const here = __dirname;
107
+ if (path.basename(here) === 'bin' && path.basename(path.dirname(here)) === 'dieu-phoi') {
108
+ return path.dirname(here);
109
+ }
110
+ return path.join(defaultClaudeDir(), 'dieu-phoi');
111
+ }
112
+
113
+ function pathsFor(home) {
114
+ const data = path.join(home, 'data');
115
+ return {
116
+ home,
117
+ bin: path.join(home, 'bin'),
118
+ hook: path.join(home, 'bin', 'hook.cjs'),
119
+ data,
120
+ events: path.join(data, 'events.jsonl'),
121
+ segments: path.join(data, 'segments'),
122
+ syncOffset: path.join(data, 'sync-offset.json'),
123
+ syncSpawn: path.join(data, 'sync-spawn.json'),
124
+ lastSync: path.join(data, 'last-sync.json'),
125
+ syncRejected: path.join(data, 'sync-rejected.jsonl'),
126
+ lock: path.join(data, 'sync.lock'),
127
+ config: path.join(home, 'config.json'),
128
+ errlog: path.join(home, 'hook-errors.log'),
129
+ };
130
+ }
131
+
132
+ function loadConfig(home) {
133
+ const c = readJsonFile(pathsFor(home).config, {});
134
+ return c && typeof c === 'object' && !Array.isArray(c) ? c : {};
135
+ }
136
+
137
+ function makeCtx(home) {
138
+ const h = resolveHome(home);
139
+ const paths = pathsFor(h);
140
+ const config = loadConfig(h);
141
+ return { home: h, paths, config };
142
+ }
143
+
144
+ function logError(home, err, where) {
145
+ try {
146
+ const p = pathsFor(home || resolveHome());
147
+ fs.mkdirSync(p.home, { recursive: true });
148
+ try {
149
+ const st = fs.statSync(p.errlog);
150
+ if (st.size > MAX_ERRLOG_BYTES) fs.writeFileSync(p.errlog, '');
151
+ } catch (_) { /* no log yet */ }
152
+ const msg = err && err.stack ? err.stack : String(err);
153
+ fs.appendFileSync(p.errlog, `${nowIso()} [${where || 'hook'}] ${msg}\n`);
154
+ } catch (_) { /* nothing left to do */ }
155
+ }
156
+
157
+ // ------------------------------------------------------------------ identity
158
+
159
+ function computeUserHash() {
160
+ let id = null;
161
+ try {
162
+ const cp = require('child_process');
163
+ const out = cp.execFileSync('git', ['config', '--get', 'user.email'], {
164
+ cwd: os.homedir(), timeout: 2000, windowsHide: true,
165
+ stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8',
166
+ });
167
+ if (out && out.trim()) id = out.trim().toLowerCase();
168
+ } catch (_) { /* git missing or unset */ }
169
+ if (!id) {
170
+ try { id = os.userInfo().username; } catch (_) { id = 'unknown'; }
171
+ }
172
+ return sha8(id);
173
+ }
174
+
175
+ function userHash(config) {
176
+ if (config && typeof config.user_hash === 'string' && /^[0-9a-f]{8}$/.test(config.user_hash)) {
177
+ return config.user_hash;
178
+ }
179
+ return computeUserHash();
180
+ }
181
+
182
+ function commonFields(ctx) {
183
+ const c = ctx.config || {};
184
+ return {
185
+ user: userHash(c),
186
+ dept: c.dept || null,
187
+ core_version: CORE_VERSION,
188
+ skill_version: c.skill_version || null,
189
+ synced: false,
190
+ };
191
+ }
192
+
193
+ // ------------------------------------------------------------------ tag
194
+
195
+ const UNTAGGED = Object.freeze({
196
+ task_type: 'untagged', score: null, urgency: null, try: null, task_id: null, role: 'exec',
197
+ });
198
+
199
+ /** Parse `[dp:<type>|s<n>|<urgency>|try<n>|task=<id>|role=<r>]` from the first line. */
200
+ function parseTag(text) {
201
+ if (typeof text !== 'string') return null;
202
+ const first = text.replace(/^/, '').split(/\r?\n/, 1)[0].trim();
203
+ const m = /^\[dp:([^\]]*)\]/.exec(first);
204
+ if (!m) return null;
205
+ const parts = m[1].split('|').map((s) => s.trim());
206
+ const type = (parts.shift() || '').toLowerCase();
207
+ if (!/^[a-z][a-z0-9-]{0,39}$/.test(type)) return null;
208
+ const tag = { task_type: type, score: null, urgency: null, try: null, task_id: null, role: 'exec' };
209
+ for (const p of parts) {
210
+ let mm;
211
+ if ((mm = /^s(\d{1,2})$/.exec(p))) {
212
+ const n = Number(mm[1]);
213
+ if (n <= 12) tag.score = n;
214
+ } else if (URGENCIES.includes(p)) {
215
+ tag.urgency = p;
216
+ } else if ((mm = /^try(\d{1,3})$/.exec(p))) {
217
+ tag.try = Number(mm[1]);
218
+ } else if ((mm = /^task=([A-Za-z0-9_-]{1,32})$/.exec(p))) {
219
+ tag.task_id = mm[1];
220
+ } else if ((mm = /^role=([a-z]+)$/.exec(p)) && ROLES.includes(mm[1])) {
221
+ tag.role = mm[1];
222
+ }
223
+ }
224
+ return tag;
225
+ }
226
+
227
+ // ------------------------------------------------------------------ models / pricing
228
+
229
+ function normalizeModel(m) {
230
+ if (typeof m !== 'string' || !m) return null;
231
+ const s = m.trim().toLowerCase();
232
+ if (MODEL_ALIASES[s]) return MODEL_ALIASES[s];
233
+ return s;
234
+ }
235
+
236
+ function loadPricing(ctx) {
237
+ const candidates = [path.join(__dirname, 'pricing.json')];
238
+ if (ctx && ctx.paths) candidates.push(path.join(ctx.paths.bin, 'pricing.json'));
239
+ let base = null;
240
+ for (const f of candidates) {
241
+ base = readJsonFile(f, null);
242
+ if (base && base.models) break;
243
+ }
244
+ base = base && base.models ? base : { models: {}, cache_write_multipliers: { '5m': 1.25, '1h': 2 } };
245
+ const over = ctx && ctx.config && ctx.config.pricing;
246
+ return {
247
+ cache_write_multipliers: Object.assign({}, base.cache_write_multipliers,
248
+ over && over.cache_write_multipliers),
249
+ models: Object.assign({}, base.models, over && over.models),
250
+ };
251
+ }
252
+
253
+ /** Find the price entry for a model id: exact, else longest key the id starts with. */
254
+ function priceFor(pricing, model) {
255
+ if (!pricing || !model) return null;
256
+ const id = normalizeModel(model);
257
+ if (pricing.models[id]) return pricing.models[id];
258
+ let best = null;
259
+ for (const k of Object.keys(pricing.models)) {
260
+ if (id.startsWith(k) && (!best || k.length > best.length)) best = k;
261
+ }
262
+ return best ? pricing.models[best] : null;
263
+ }
264
+
265
+ /** Split one API usage object into the event token buckets. */
266
+ function usageBuckets(u) {
267
+ const n = (v) => (typeof v === 'number' && isFinite(v) ? v : 0);
268
+ const cc = n(u.cache_creation_input_tokens);
269
+ let cw5 = 0;
270
+ let cw1h = 0;
271
+ if (u.cache_creation && typeof u.cache_creation === 'object') {
272
+ cw5 = n(u.cache_creation.ephemeral_5m_input_tokens);
273
+ cw1h = n(u.cache_creation.ephemeral_1h_input_tokens);
274
+ const rest = cc - cw5 - cw1h;
275
+ if (rest > 0) cw5 += rest;
276
+ } else {
277
+ cw5 = cc;
278
+ }
279
+ return {
280
+ input: n(u.input_tokens),
281
+ output: n(u.output_tokens),
282
+ thinking: n(u.output_tokens_details && u.output_tokens_details.thinking_tokens),
283
+ cache_write_5m: cw5,
284
+ cache_write_1h: cw1h,
285
+ cache_read: n(u.cache_read_input_tokens),
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Cost in USD of one API request. Thinking is already inside output_tokens.
291
+ * Returns null when the model has no price.
292
+ */
293
+ function requestCost(pricing, model, b) {
294
+ const p = priceFor(pricing, model);
295
+ if (!p) return null;
296
+ const prompt = b.input + b.cache_write_5m + b.cache_write_1h + b.cache_read;
297
+ let inP = p.input;
298
+ let outP = p.output;
299
+ let crP = p.cache_read;
300
+ const lc = p.long_context;
301
+ if (lc && typeof lc.threshold_prompt_tokens === 'number' && prompt > lc.threshold_prompt_tokens) {
302
+ if (typeof lc.input === 'number') inP = lc.input;
303
+ if (typeof lc.output === 'number') outP = lc.output;
304
+ if (typeof lc.cache_read === 'number') crP = lc.cache_read;
305
+ }
306
+ const mult = pricing.cache_write_multipliers || {};
307
+ const m5 = typeof mult['5m'] === 'number' ? mult['5m'] : 1.25;
308
+ const m1h = typeof mult['1h'] === 'number' ? mult['1h'] : 2;
309
+ const usd = b.input * inP + b.cache_write_5m * inP * m5 + b.cache_write_1h * inP * m1h
310
+ + b.cache_read * crP + b.output * outP;
311
+ return usd / 1e6;
312
+ }
313
+
314
+ // ------------------------------------------------------------------ transcript
315
+
316
+ function parseJsonl(text) {
317
+ const out = [];
318
+ for (const line of String(text).split('\n')) {
319
+ const t = line.trim();
320
+ if (!t) continue;
321
+ try { out.push(JSON.parse(t)); } catch (_) { /* skip torn line */ }
322
+ }
323
+ return out;
324
+ }
325
+
326
+ function readTranscript(file) {
327
+ return parseJsonl(fs.readFileSync(file, 'utf8'));
328
+ }
329
+
330
+ function lastUuidOf(lines) {
331
+ for (let i = lines.length - 1; i >= 0; i--) {
332
+ if (lines[i] && typeof lines[i].uuid === 'string') return lines[i].uuid;
333
+ }
334
+ return null;
335
+ }
336
+
337
+ /** Tag from the first user message of the transcript (the original prompt). */
338
+ function tagFromTranscript(lines) {
339
+ for (const l of lines) {
340
+ if (!l || l.type !== 'user' || !l.message) continue;
341
+ const c = l.message.content;
342
+ let text = null;
343
+ if (typeof c === 'string') text = c;
344
+ else if (Array.isArray(c)) {
345
+ const b = c.find((x) => x && x.type === 'text' && typeof x.text === 'string');
346
+ if (b) text = b.text;
347
+ }
348
+ if (text === null) continue;
349
+ return parseTag(text);
350
+ }
351
+ return null;
352
+ }
353
+
354
+ /**
355
+ * Summarize a transcript segment. Usage counted once per requestId (last line wins).
356
+ * opts.afterUuids: uuids already counted by earlier segments; the segment starts after
357
+ * the last line carrying one of them.
358
+ */
359
+ function summarizeTranscript(lines, opts) {
360
+ const o = opts || {};
361
+ let start = 0;
362
+ if (o.afterUuids && o.afterUuids.size) {
363
+ for (let i = lines.length - 1; i >= 0; i--) {
364
+ if (lines[i] && o.afterUuids.has(lines[i].uuid)) { start = i + 1; break; }
365
+ }
366
+ }
367
+ const seg = lines.slice(start);
368
+ const byReq = new Map();
369
+ const toolIds = new Set();
370
+ let anonTools = 0;
371
+ let anonReq = 0;
372
+ let firstTs = null;
373
+ let lastTs = null;
374
+ for (const l of seg) {
375
+ if (!l || typeof l !== 'object') continue;
376
+ if (typeof l.timestamp === 'string') {
377
+ const t = Date.parse(l.timestamp);
378
+ if (!isNaN(t)) {
379
+ if (firstTs === null || t < firstTs) firstTs = t;
380
+ if (lastTs === null || t > lastTs) lastTs = t;
381
+ }
382
+ }
383
+ if (l.type !== 'assistant' || !l.message || typeof l.message !== 'object') continue;
384
+ const msg = l.message;
385
+ if (msg.model === '<synthetic>') continue;
386
+ if (Array.isArray(msg.content)) {
387
+ for (const b of msg.content) {
388
+ if (b && b.type === 'tool_use') {
389
+ if (b.id) toolIds.add(b.id); else anonTools++;
390
+ }
391
+ }
392
+ }
393
+ const key = l.requestId || msg.id || l.uuid || `anon-${anonReq++}`;
394
+ if (msg.usage) byReq.set(key, { usage: msg.usage, model: msg.model });
395
+ else if (!byReq.has(key)) byReq.set(key, { usage: null, model: msg.model });
396
+ }
397
+
398
+ const tokens = { input: 0, output: 0, thinking: 0, cache_write_5m: 0, cache_write_1h: 0, cache_read: 0 };
399
+ let cost = 0;
400
+ let costKnown = true;
401
+ const modelCount = new Map();
402
+ for (const { usage, model } of byReq.values()) {
403
+ if (model) modelCount.set(model, (modelCount.get(model) || 0) + 1);
404
+ if (!usage) continue;
405
+ const b = usageBuckets(usage);
406
+ for (const k of Object.keys(tokens)) tokens[k] += b[k];
407
+ const c = o.pricing ? requestCost(o.pricing, model, b) : null;
408
+ if (c === null) costKnown = false; else cost += c;
409
+ }
410
+ let model = null;
411
+ let best = -1;
412
+ for (const [m, n] of modelCount) if (n > best) { best = n; model = m; }
413
+
414
+ return {
415
+ segment_start: start,
416
+ lines: seg.length,
417
+ duration_s: firstTs !== null && lastTs !== null ? Math.round((lastTs - firstTs) / 100) / 10 : null,
418
+ api_calls: byReq.size,
419
+ tool_uses: toolIds.size + anonTools,
420
+ tokens,
421
+ cost_usd: costKnown && byReq.size ? Math.round(cost * 1e8) / 1e8 : (byReq.size ? null : 0),
422
+ model: normalizeModel(model),
423
+ last_uuid: lastUuidOf(seg),
424
+ };
425
+ }
426
+
427
+ // ------------------------------------------------------------------ events (append-only)
428
+
429
+ /** Append one event. events.jsonl is append-only: never rewritten, sync progress lives elsewhere. */
430
+ function appendEvent(ctx, ev) {
431
+ fs.mkdirSync(ctx.paths.data, { recursive: true });
432
+ fs.appendFileSync(ctx.paths.events, `${JSON.stringify(ev)}\n`);
433
+ }
434
+
435
+ function readEventsBuffer(ctx) {
436
+ try { return fs.readFileSync(ctx.paths.events); } catch (_) { return Buffer.alloc(0); }
437
+ }
438
+
439
+ /**
440
+ * Complete lines of events.jsonl starting at byte `from`, with their byte ranges.
441
+ * A trailing line without '\n' (append in progress) is excluded.
442
+ */
443
+ function eventLinesFrom(buf, from) {
444
+ const out = [];
445
+ let pos = Math.max(0, Math.min(from || 0, buf.length));
446
+ for (;;) {
447
+ const nl = buf.indexOf(0x0a, pos);
448
+ if (nl < 0) break;
449
+ const text = buf.toString('utf8', pos, nl).replace(/\r$/, '');
450
+ if (text.trim()) out.push({ text, start: pos, end: nl + 1 });
451
+ pos = nl + 1;
452
+ }
453
+ return out;
454
+ }
455
+
456
+ function readEventLines(ctx) {
457
+ return eventLinesFrom(readEventsBuffer(ctx), 0).map((l) => l.text);
458
+ }
459
+
460
+ function readEvents(ctx) {
461
+ const out = [];
462
+ for (const line of readEventLines(ctx)) {
463
+ try {
464
+ const o = JSON.parse(line);
465
+ if (o && typeof o === 'object') out.push(o);
466
+ } catch (_) { /* skip */ }
467
+ }
468
+ return out;
469
+ }
470
+
471
+ /** Byte offset just past the last event already pushed to the sink. */
472
+ function loadSyncOffset(ctx) {
473
+ const s = readJsonFile(ctx.paths.syncOffset, null);
474
+ const n = s && Number(s.offset);
475
+ return Number.isFinite(n) && n >= 0 ? n : 0;
476
+ }
477
+
478
+ function saveSyncOffset(ctx, offset) {
479
+ writeFileAtomic(ctx.paths.syncOffset, `${JSON.stringify({ offset, ts: nowIso() })}\n`);
480
+ }
481
+
482
+ /** Events written after the sync offset (not yet on the sink). */
483
+ function unsyncedInfo(ctx) {
484
+ const buf = readEventsBuffer(ctx);
485
+ let off = loadSyncOffset(ctx);
486
+ if (off > buf.length) off = 0; // file replaced/truncated
487
+ const lines = eventLinesFrom(buf, off);
488
+ return { offset: off, size: buf.length, lines: lines.length, bytes: buf.length - off };
489
+ }
490
+
491
+ // ------------------------------------------------------------------ small state files
492
+
493
+ function safeName(id) {
494
+ const s = String(id).replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 80);
495
+ return s === String(id) ? s : `${s}-${sha8(id)}`;
496
+ }
497
+
498
+ function segmentFile(ctx, agentId) {
499
+ return path.join(ctx.paths.segments, `${safeName(agentId)}.json`);
500
+ }
501
+
502
+ /** Per-agent segment record: { uuids: [last uuid of each counted segment], n, t }. */
503
+ function loadSegments(ctx, agentId) {
504
+ const s = readJsonFile(segmentFile(ctx, agentId), null);
505
+ if (!s || !Array.isArray(s.uuids)) return { uuids: [], n: 0 };
506
+ return s;
507
+ }
508
+
509
+ function saveSegments(ctx, agentId, rec) {
510
+ writeFileAtomic(segmentFile(ctx, agentId), `${JSON.stringify(rec)}\n`);
511
+ }
512
+
513
+ // ------------------------------------------------------------------ hook handlers
514
+
515
+ function extractAgentId(tr) {
516
+ if (!tr) return null;
517
+ if (typeof tr === 'object' && !Array.isArray(tr)) {
518
+ const v = tr.agentId || tr.agent_id;
519
+ if (typeof v === 'string' && v) return v;
520
+ }
521
+ let s;
522
+ try { s = typeof tr === 'string' ? tr : JSON.stringify(tr); } catch (_) { return null; }
523
+ const m = /agent_?[Ii]d(?:\\?")?\s*[:=]\s*(?:\\?")?([A-Za-z0-9_-]{6,64})/.exec(s || '');
524
+ return m ? m[1] : null;
525
+ }
526
+
527
+ function buildDispatch(p, ctx) {
528
+ const ti = p.tool_input && typeof p.tool_input === 'object' ? p.tool_input : {};
529
+ const tr = p.tool_response;
530
+ const trObj = tr && typeof tr === 'object' && !Array.isArray(tr) ? tr : {};
531
+ const tag = parseTag(ti.prompt) || UNTAGGED;
532
+ const explicitEffort = typeof ti.effort === 'string' && ti.effort ? ti.effort : null;
533
+ const parentEffort = p.effort && typeof p.effort === 'object' ? p.effort.level : null;
534
+ // agent_id must never be empty (the sink keys on it): agentId -> tool_use_id -> random.
535
+ const agentId = extractAgentId(tr)
536
+ || (typeof p.tool_use_id === 'string' && p.tool_use_id ? p.tool_use_id : null)
537
+ || `noid-${crypto.randomBytes(6).toString('hex')}`;
538
+ return Object.assign({
539
+ schema: SCHEMA,
540
+ kind: 'dispatch',
541
+ ts: nowIso(),
542
+ agent_id: agentId,
543
+ session: p.session_id ? sha8(p.session_id) : null,
544
+ task_id: tag.task_id,
545
+ task_type: tag.task_type,
546
+ score: tag.score,
547
+ urgency: tag.urgency,
548
+ try: tag.try,
549
+ role: tag.role,
550
+ model: normalizeModel(trObj.resolvedModel) || normalizeModel(ti.model) || null,
551
+ effort: explicitEffort || parentEffort || null,
552
+ effort_inherited: !explicitEffort,
553
+ subagent_type: typeof ti.subagent_type === 'string' && ti.subagent_type ? ti.subagent_type : 'general-purpose',
554
+ background: ti.run_in_background === true || trObj.isAsync === true,
555
+ }, commonFields(ctx));
556
+ }
557
+
558
+ function handlePostToolUse(p, ctx) {
559
+ if (p.tool_name !== 'Agent' && p.tool_name !== 'Task') return null;
560
+ const ev = buildDispatch(p, ctx);
561
+ appendEvent(ctx, ev);
562
+ return ev;
563
+ }
564
+
565
+ function handleSubagentStop(p, ctx) {
566
+ const agentId = typeof p.agent_id === 'string' && p.agent_id ? p.agent_id : null;
567
+ if (!agentId) return null;
568
+ const prev = loadSegments(ctx, agentId);
569
+ const seen = new Set(prev.uuids || []);
570
+ let sum = null;
571
+ let tag = null;
572
+ const tpath = p.agent_transcript_path;
573
+ if (typeof tpath === 'string' && tpath && fs.existsSync(tpath)) {
574
+ const lines = readTranscript(tpath);
575
+ const last = lastUuidOf(lines);
576
+ if (last && seen.has(last)) return null; // same segment reported twice
577
+ sum = summarizeTranscript(lines, { afterUuids: seen, pricing: loadPricing(ctx) });
578
+ tag = tagFromTranscript(lines);
579
+ }
580
+ const t = tag || UNTAGGED;
581
+ const ev = Object.assign({
582
+ schema: SCHEMA,
583
+ kind: 'complete',
584
+ ts: nowIso(),
585
+ agent_id: agentId,
586
+ session: p.session_id ? sha8(p.session_id) : null,
587
+ segment: (prev.n || 0) + 1,
588
+ task_id: t.task_id,
589
+ task_type: t.task_type,
590
+ try: t.try,
591
+ role: t.role,
592
+ subagent_type: typeof p.agent_type === 'string' ? p.agent_type : null,
593
+ effort: p.effort && typeof p.effort === 'object' && p.effort.level ? p.effort.level : null,
594
+ duration_s: sum ? sum.duration_s : null,
595
+ api_calls: sum ? sum.api_calls : null,
596
+ tool_uses: sum ? sum.tool_uses : null,
597
+ tokens: sum ? sum.tokens : null,
598
+ cost_usd: sum ? sum.cost_usd : null,
599
+ model: sum ? sum.model : null,
600
+ }, commonFields(ctx));
601
+ // Record the segment before appending so a racing duplicate fire sees it.
602
+ if (sum && sum.last_uuid) {
603
+ saveSegments(ctx, agentId, {
604
+ uuids: Array.from(seen).concat(sum.last_uuid).slice(-50),
605
+ n: ev.segment,
606
+ t: Date.now(),
607
+ });
608
+ }
609
+ appendEvent(ctx, ev);
610
+ return ev;
611
+ }
612
+
613
+ function maybeSpawnSync(ctx) {
614
+ if (!ctx.config.sink_url || !ctx.config.sink_token) return false;
615
+ if (process.env.DIEU_PHOI_NO_SPAWN === '1') return false;
616
+ const last = Number((readJsonFile(ctx.paths.syncSpawn, {}) || {}).t) || 0;
617
+ if (Date.now() - last < SYNC_SPAWN_INTERVAL_MS) return false;
618
+ writeFileAtomic(ctx.paths.syncSpawn, `${JSON.stringify({ t: Date.now() })}\n`);
619
+ const cp = require('child_process');
620
+ const child = cp.spawn(process.execPath, [__filename, '--sync'], {
621
+ detached: true,
622
+ stdio: 'ignore',
623
+ windowsHide: true,
624
+ env: Object.assign({}, process.env, { DIEU_PHOI_HOME: ctx.home }),
625
+ });
626
+ child.on('error', (e) => logError(ctx.home, e, 'spawn-sync'));
627
+ child.unref();
628
+ return true;
629
+ }
630
+
631
+ function readStdin() {
632
+ const chunks = [];
633
+ const buf = Buffer.alloc(65536);
634
+ for (;;) {
635
+ let n;
636
+ try {
637
+ n = fs.readSync(0, buf, 0, buf.length, null);
638
+ } catch (e) {
639
+ if (e.code === 'EAGAIN') { sleepSync(5); continue; }
640
+ if (e.code === 'EOF') break;
641
+ throw e;
642
+ }
643
+ if (!n) break;
644
+ chunks.push(Buffer.from(buf.subarray(0, n)));
645
+ }
646
+ return Buffer.concat(chunks).toString('utf8');
647
+ }
648
+
649
+ /** Process one hook payload. Never throws. Returns the written event (or null). */
650
+ function runHook(opts) {
651
+ const o = opts || {};
652
+ let home = null;
653
+ try {
654
+ home = resolveHome(o.home);
655
+ const raw = typeof o.input === 'string' ? o.input : readStdin();
656
+ if (!raw || !raw.trim()) return null;
657
+ const p = JSON.parse(raw.replace(/^/, ''));
658
+ const ctx = makeCtx(home);
659
+ let ev = null;
660
+ const name = p.hook_event_name;
661
+ if (name === 'SubagentStop') ev = handleSubagentStop(p, ctx);
662
+ else if (name === 'PostToolUse' || (!name && p.tool_name)) ev = handlePostToolUse(p, ctx);
663
+ if (ev) {
664
+ try { maybeSpawnSync(ctx); } catch (e) { logError(home, e, 'spawn-sync'); }
665
+ }
666
+ return ev;
667
+ } catch (e) {
668
+ logError(home, e, 'hook');
669
+ return null;
670
+ }
671
+ }
672
+
673
+ // ------------------------------------------------------------------ sync
674
+
675
+ function acquireLock(file) {
676
+ fs.mkdirSync(path.dirname(file), { recursive: true });
677
+ try {
678
+ const st = fs.statSync(file);
679
+ if (Date.now() - st.mtimeMs > 5 * 60 * 1000) fs.unlinkSync(file);
680
+ } catch (_) { /* no lock */ }
681
+ try {
682
+ fs.writeFileSync(file, String(process.pid), { flag: 'wx' });
683
+ return true;
684
+ } catch (_) {
685
+ return false;
686
+ }
687
+ }
688
+
689
+ async function postBatch(url, token, records, o) {
690
+ const fetchImpl = o.fetch || globalThis.fetch;
691
+ let lastErr = null;
692
+ for (let attempt = 0; attempt <= o.retries; attempt++) {
693
+ if (attempt > 0) await new Promise((r) => setTimeout(r, o.backoffMs * 2 ** (attempt - 1)));
694
+ try {
695
+ const res = await fetchImpl(url, {
696
+ method: 'POST',
697
+ headers: { 'content-type': 'application/json' },
698
+ body: JSON.stringify({ token, records }),
699
+ redirect: 'follow',
700
+ signal: AbortSignal.timeout(o.timeoutMs),
701
+ });
702
+ const text = await res.text();
703
+ let body = null;
704
+ try { body = JSON.parse(text); } catch (_) { /* non-JSON */ }
705
+ if (res.ok && body && body.ok === true) return body;
706
+ lastErr = new Error(`sink trả về ${res.status}: ${String(text).slice(0, 200)}`);
707
+ if (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429) break;
708
+ } catch (e) {
709
+ lastErr = e;
710
+ }
711
+ }
712
+ throw lastErr || new Error('sink lỗi không rõ');
713
+ }
714
+
715
+ /**
716
+ * Keep records the sink refused in data/sync-rejected.jsonl (never silently lost).
717
+ * Accepts `rejected_items: [{index, error}]` (or `rejected` as that array), or the count form
718
+ * `rejected: n` + `errors: ["#i: msg", ...]`; rejects that cannot be matched to an index are
719
+ * written as one summary line. Returns the number of rejected records.
720
+ */
721
+ function recordRejected(ctx, records, body) {
722
+ const items = Array.isArray(body.rejected_items) ? body.rejected_items
723
+ : (Array.isArray(body.rejected) ? body.rejected : null);
724
+ const count = items ? items.length : (Number(body.rejected) || 0);
725
+ if (!count) return 0;
726
+ const ts = nowIso();
727
+ const lines = [];
728
+ const seen = new Set();
729
+ const add = (idx, error) => {
730
+ if (!Number.isInteger(idx) || idx < 0 || idx >= records.length || seen.has(idx)) return;
731
+ seen.add(idx);
732
+ lines.push({ ts, error: String(error || 'rejected'), record: records[idx] });
733
+ };
734
+ if (items) {
735
+ for (const it of items) add(Number(it && it.index), it && it.error);
736
+ } else if (Array.isArray(body.errors)) {
737
+ for (const e of body.errors) {
738
+ const m = /^#(\d+):\s*(.*)$/.exec(String(e));
739
+ if (m) add(Number(m[1]), m[2]);
740
+ }
741
+ }
742
+ if (seen.size < count) {
743
+ lines.push({ ts, error: 'sink rejected records without per-record detail', unidentified: count - seen.size,
744
+ errors: Array.isArray(body.errors) ? body.errors.slice(0, 10) : null });
745
+ }
746
+ fs.mkdirSync(ctx.paths.data, { recursive: true });
747
+ fs.appendFileSync(ctx.paths.syncRejected, lines.map((l) => `${JSON.stringify(l)}\n`).join(''));
748
+ return count;
749
+ }
750
+
751
+ /**
752
+ * Push events written after the sync offset, in batches of <= batchSize. events.jsonl is
753
+ * never rewritten; after each accepted batch the offset (byte just past the batch's last
754
+ * line) is saved atomically to data/sync-offset.json. No-op without a sink.
755
+ */
756
+ async function runSync(ctx, opts) {
757
+ const o = Object.assign({
758
+ batchSize: 200, retries: 3, timeoutMs: 20000,
759
+ backoffMs: Number(process.env.DIEU_PHOI_BACKOFF_MS) || 1000,
760
+ }, opts);
761
+ const cfg = ctx.config || {};
762
+ if (!cfg.sink_url || !cfg.sink_token) return { ok: true, skipped: 'no-sink', sent: 0, batches: 0 };
763
+ if (!acquireLock(ctx.paths.lock)) return { ok: true, skipped: 'locked', sent: 0, batches: 0 };
764
+ const result = { ok: true, sent: 0, batches: 0, accepted: 0, duplicates: 0, rejected: 0, error: null };
765
+ try {
766
+ const buf = readEventsBuffer(ctx);
767
+ let offset = loadSyncOffset(ctx);
768
+ if (offset > buf.length) offset = 0;
769
+ const lines = eventLinesFrom(buf, offset);
770
+ for (let i = 0; i < lines.length; i += o.batchSize) {
771
+ const batch = lines.slice(i, i + o.batchSize);
772
+ const records = [];
773
+ for (const l of batch) {
774
+ try {
775
+ const r = JSON.parse(l.text);
776
+ if (r && typeof r === 'object' && !Array.isArray(r)) {
777
+ delete r.synced;
778
+ records.push(r);
779
+ }
780
+ } catch (_) { /* corrupt line: skip, offset still advances */ }
781
+ }
782
+ try {
783
+ if (records.length) {
784
+ const body = await postBatch(cfg.sink_url, cfg.sink_token, records, o);
785
+ result.accepted += Number(body.accepted) || 0;
786
+ result.duplicates += Number(body.duplicates) || 0;
787
+ result.rejected += recordRejected(ctx, records, body);
788
+ }
789
+ saveSyncOffset(ctx, batch[batch.length - 1].end);
790
+ result.batches++;
791
+ result.sent += records.length;
792
+ } catch (e) {
793
+ result.ok = false;
794
+ result.error = String(e && e.message ? e.message : e);
795
+ break;
796
+ }
797
+ }
798
+ } finally {
799
+ try {
800
+ writeFileAtomic(ctx.paths.lastSync, `${JSON.stringify({
801
+ ts: nowIso(), ok: result.ok, sent: result.sent, error: result.error,
802
+ })}\n`);
803
+ } catch (_) { /* best effort */ }
804
+ try { fs.unlinkSync(ctx.paths.lock); } catch (_) { /* gone */ }
805
+ }
806
+ return result;
807
+ }
808
+
809
+ // ------------------------------------------------------------------ exports / main
810
+
811
+ module.exports = {
812
+ CORE_VERSION, SCHEMA, TASK_TYPES, URGENCIES, ROLES, MODEL_ALIASES, UNTAGGED,
813
+ sha8, nowIso, readJsonFile, writeFileAtomic, defaultClaudeDir, resolveHome, pathsFor, loadConfig, makeCtx,
814
+ logError, computeUserHash, userHash, commonFields, parseTag, normalizeModel, loadPricing,
815
+ priceFor, usageBuckets, requestCost, parseJsonl, readTranscript, lastUuidOf,
816
+ tagFromTranscript, summarizeTranscript, appendEvent, eventLinesFrom, readEventLines, readEvents,
817
+ loadSyncOffset, saveSyncOffset, recordRejected, unsyncedInfo, loadSegments, saveSegments, segmentFile,
818
+ extractAgentId, buildDispatch, handlePostToolUse, handleSubagentStop, maybeSpawnSync, runHook, runSync,
819
+ };
820
+
821
+ if (require.main === module) {
822
+ process.on('uncaughtException', (e) => { logError(null, e, 'uncaught'); process.exit(0); });
823
+ process.on('unhandledRejection', (e) => { logError(null, e, 'unhandled'); process.exit(0); });
824
+ if (process.argv.includes('--sync')) {
825
+ const ctx = makeCtx();
826
+ runSync(ctx)
827
+ .then((r) => { if (!r.ok) logError(ctx.home, new Error(r.error), 'sync'); })
828
+ .catch((e) => logError(ctx.home, e, 'sync'))
829
+ .finally(() => process.exit(0));
830
+ } else {
831
+ runHook();
832
+ process.exitCode = 0;
833
+ }
834
+ }