dsh-hooks 0.12.0 → 0.13.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/README.md +29 -4
- package/README.zh.md +31 -4
- package/bin/dsh-hooks.mjs +110 -10
- package/lib/client.js +281 -55
- package/lib/config.d.ts +4 -2
- package/lib/config.js +1 -1
- package/lib/dry-run.d.ts +29 -0
- package/lib/dry-run.js +93 -13
- package/lib/index.d.ts +1 -1
- package/lib/index.js +7 -2
- package/lib/notify.d.ts +32 -6
- package/lib/notify.js +55 -19
- package/lib/server.js +21 -4
- package/lib/tail.d.ts +44 -0
- package/lib/tail.js +154 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -31,6 +31,44 @@ window.__ModuleLoader__.load({
|
|
|
31
31
|
async function fetchHistory(n = 50, fetchFn = fetch) {
|
|
32
32
|
return getJson(`/dsh-hooks/history?n=${Math.max(1, Math.min(500, Math.floor(n)))}`, fetchFn);
|
|
33
33
|
}
|
|
34
|
+
/** Outcomes a hook run can end in (kept in sync with the host record type). */
|
|
35
|
+
const HISTORY_OUTCOMES = [
|
|
36
|
+
"spawned",
|
|
37
|
+
"exit-0",
|
|
38
|
+
"exit-nonzero",
|
|
39
|
+
"timeout",
|
|
40
|
+
"spawn-failed",
|
|
41
|
+
"skipped",
|
|
42
|
+
"sent",
|
|
43
|
+
"send-failed"
|
|
44
|
+
];
|
|
45
|
+
/**
|
|
46
|
+
* Apply the panel's filters to a batch of records. The three conditions are
|
|
47
|
+
* AND-ed; an empty filter keeps everything (the timeline's default view).
|
|
48
|
+
*/
|
|
49
|
+
function filterHistory(records, filter) {
|
|
50
|
+
const session = filter.session?.trim().toLowerCase();
|
|
51
|
+
return records.filter((record) => {
|
|
52
|
+
if (filter.event !== void 0 && filter.event !== "" && record.event !== filter.event) return false;
|
|
53
|
+
if (filter.outcome !== void 0 && filter.outcome !== "" && record.outcome !== filter.outcome) return false;
|
|
54
|
+
if (session !== void 0 && session !== "") {
|
|
55
|
+
const id = record.sessionId?.toLowerCase() ?? "";
|
|
56
|
+
const name = record.sessionName?.toLowerCase() ?? "";
|
|
57
|
+
if (!id.includes(session) && !name.includes(session)) return false;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/** Serialize records as JSONL — byte-compatible with the on-disk history log. */
|
|
63
|
+
function historyToJsonl(records) {
|
|
64
|
+
if (records.length === 0) return "";
|
|
65
|
+
return `${records.map((record) => JSON.stringify(record)).join("\n")}\n`;
|
|
66
|
+
}
|
|
67
|
+
/** Download file name for a history export, stamped with local time. */
|
|
68
|
+
function historyExportName(now = /* @__PURE__ */ new Date()) {
|
|
69
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
70
|
+
return `dsh-hooks-history-${`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`}.jsonl`;
|
|
71
|
+
}
|
|
34
72
|
async function postTest(body, fetchFn = fetch) {
|
|
35
73
|
try {
|
|
36
74
|
const response = await fetchFn("/dsh-hooks/test", {
|
|
@@ -317,10 +355,35 @@ window.__ModuleLoader__.load({
|
|
|
317
355
|
}
|
|
318
356
|
});
|
|
319
357
|
const [loadError, setLoadError] = (0, react.useState)(false);
|
|
358
|
+
const [historyFilter, setHistoryFilterState] = (0, react.useState)(() => ({
|
|
359
|
+
event: loadStored("dsh-hooks.historyEvent", ""),
|
|
360
|
+
outcome: loadStored("dsh-hooks.historyOutcome", ""),
|
|
361
|
+
session: loadStored("dsh-hooks.historySession", "")
|
|
362
|
+
}));
|
|
363
|
+
const setHistoryFilter = (patch) => {
|
|
364
|
+
setHistoryFilterState((current) => {
|
|
365
|
+
const next = {
|
|
366
|
+
...current,
|
|
367
|
+
...patch
|
|
368
|
+
};
|
|
369
|
+
storeValue("dsh-hooks.historyEvent", next.event ?? "");
|
|
370
|
+
storeValue("dsh-hooks.historyOutcome", next.outcome ?? "");
|
|
371
|
+
storeValue("dsh-hooks.historySession", next.session ?? "");
|
|
372
|
+
return next;
|
|
373
|
+
});
|
|
374
|
+
};
|
|
375
|
+
const visibleHistory = history === null ? null : filterHistory(history, historyFilter);
|
|
376
|
+
const historyFiltered = (historyFilter.event ?? "") !== "" || (historyFilter.outcome ?? "") !== "" || (historyFilter.session ?? "") !== "";
|
|
320
377
|
const [event, setEvent] = (0, react.useState)("turn/end");
|
|
321
378
|
const [reason, setReason] = (0, react.useState)("completed");
|
|
322
379
|
const [tool, setTool] = (0, react.useState)("");
|
|
323
380
|
const [testResult, setTestResult] = (0, react.useState)(null);
|
|
381
|
+
const [mockFields, setMockFields] = (0, react.useState)({
|
|
382
|
+
runningSubagents: "",
|
|
383
|
+
durationMs: "",
|
|
384
|
+
usageInputTokens: "",
|
|
385
|
+
usageOutputTokens: ""
|
|
386
|
+
});
|
|
324
387
|
const [notifyChannel, setNotifyChannel] = (0, react.useState)("webhook");
|
|
325
388
|
const [notifyUrl, setNotifyUrl] = (0, react.useState)("");
|
|
326
389
|
const [notifySlack, setNotifySlack] = (0, react.useState)(false);
|
|
@@ -345,7 +408,7 @@ window.__ModuleLoader__.load({
|
|
|
345
408
|
const refresh = (0, react.useCallback)(async () => {
|
|
346
409
|
const [statusInfo, records, feishuInfo] = await Promise.all([
|
|
347
410
|
fetchStatus(),
|
|
348
|
-
fetchHistory(
|
|
411
|
+
fetchHistory(200),
|
|
349
412
|
fetchFeishuStatus()
|
|
350
413
|
]);
|
|
351
414
|
setStatus(statusInfo);
|
|
@@ -379,18 +442,47 @@ window.__ModuleLoader__.load({
|
|
|
379
442
|
}, [
|
|
380
443
|
event,
|
|
381
444
|
reason,
|
|
382
|
-
tool
|
|
445
|
+
tool,
|
|
446
|
+
mockFields
|
|
383
447
|
]);
|
|
448
|
+
/** Numeric overrides the user actually filled in (empty inputs are skipped). */
|
|
449
|
+
const testFields = () => {
|
|
450
|
+
const fields = {};
|
|
451
|
+
for (const [key, raw] of Object.entries(mockFields)) {
|
|
452
|
+
const text = raw.trim();
|
|
453
|
+
if (text === "") continue;
|
|
454
|
+
const value = Number(text);
|
|
455
|
+
if (Number.isFinite(value)) fields[key] = value;
|
|
456
|
+
}
|
|
457
|
+
return Object.keys(fields).length > 0 ? fields : void 0;
|
|
458
|
+
};
|
|
384
459
|
const runTest = async (execute) => {
|
|
385
460
|
const result = await postTest({
|
|
386
461
|
event,
|
|
387
462
|
reason: event === "turn/end" && reason !== "" ? reason : void 0,
|
|
388
463
|
tool: tool !== "" ? tool : void 0,
|
|
464
|
+
fields: testFields(),
|
|
389
465
|
execute
|
|
390
466
|
});
|
|
391
467
|
setTestResult(result);
|
|
392
468
|
if (execute) refresh();
|
|
393
469
|
};
|
|
470
|
+
/** Download the filtered timeline as JSONL (same shape as the on-disk log). */
|
|
471
|
+
const exportHistory = () => {
|
|
472
|
+
const records = visibleHistory ?? [];
|
|
473
|
+
if (records.length === 0) return;
|
|
474
|
+
const name = historyExportName();
|
|
475
|
+
const blob = new Blob([historyToJsonl(records)], { type: "application/x-ndjson" });
|
|
476
|
+
const url = URL.createObjectURL(blob);
|
|
477
|
+
try {
|
|
478
|
+
const anchor = document.createElement("a");
|
|
479
|
+
anchor.href = url;
|
|
480
|
+
anchor.download = name;
|
|
481
|
+
anchor.click();
|
|
482
|
+
} finally {
|
|
483
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
484
|
+
}
|
|
485
|
+
};
|
|
394
486
|
const sendNotifyTest = async () => {
|
|
395
487
|
setNotifyResult(null);
|
|
396
488
|
const result = await postNotifyTest(notifyChannel, notifyUrl.trim() || void 0, notifySlack);
|
|
@@ -703,6 +795,46 @@ window.__ModuleLoader__.load({
|
|
|
703
795
|
})
|
|
704
796
|
]
|
|
705
797
|
}),
|
|
798
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
799
|
+
className: "dh-test-row",
|
|
800
|
+
children: [
|
|
801
|
+
[
|
|
802
|
+
"runningSubagents",
|
|
803
|
+
"runningSubagents",
|
|
804
|
+
"0"
|
|
805
|
+
],
|
|
806
|
+
[
|
|
807
|
+
"durationMs",
|
|
808
|
+
"durationMs",
|
|
809
|
+
"1200"
|
|
810
|
+
],
|
|
811
|
+
[
|
|
812
|
+
"usageInputTokens",
|
|
813
|
+
"usage 输入",
|
|
814
|
+
"120000"
|
|
815
|
+
],
|
|
816
|
+
[
|
|
817
|
+
"usageOutputTokens",
|
|
818
|
+
"usage 输出",
|
|
819
|
+
"45000"
|
|
820
|
+
]
|
|
821
|
+
].map(([key, label, placeholder]) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
822
|
+
className: "dh-field",
|
|
823
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
824
|
+
className: "dh-field-label",
|
|
825
|
+
children: [label, "(可选)"]
|
|
826
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
827
|
+
className: "dh-input",
|
|
828
|
+
value: mockFields[key],
|
|
829
|
+
inputMode: "numeric",
|
|
830
|
+
onChange: (e) => setMockFields((current) => ({
|
|
831
|
+
...current,
|
|
832
|
+
[key]: e.target.value
|
|
833
|
+
})),
|
|
834
|
+
placeholder
|
|
835
|
+
})]
|
|
836
|
+
}, key))
|
|
837
|
+
}),
|
|
706
838
|
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
707
839
|
className: "dh-buttons",
|
|
708
840
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
@@ -1347,58 +1479,152 @@ window.__ModuleLoader__.load({
|
|
|
1347
1479
|
]
|
|
1348
1480
|
}, hook.index))
|
|
1349
1481
|
})] }),
|
|
1350
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("
|
|
1373
|
-
className: "dh-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1482
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [
|
|
1483
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1484
|
+
className: "dh-section-head",
|
|
1485
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h3", {
|
|
1486
|
+
className: "dh-section-title",
|
|
1487
|
+
children: ["执行历史(最近 30 条)", status !== null && status.historyCount > 0 ? ` · ${status.historyCount} 条` : ""]
|
|
1488
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1489
|
+
type: "button",
|
|
1490
|
+
className: "dh-button dh-toggle",
|
|
1491
|
+
onClick: () => setHistoryOpen(!historyOpen),
|
|
1492
|
+
"aria-expanded": historyOpen,
|
|
1493
|
+
children: historyOpen ? "收起 ▲" : "展开 ▼"
|
|
1494
|
+
})]
|
|
1495
|
+
}),
|
|
1496
|
+
historyOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1497
|
+
className: "dh-test-row dh-history-filters",
|
|
1498
|
+
children: [
|
|
1499
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
1500
|
+
className: "dh-field",
|
|
1501
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1502
|
+
className: "dh-field-label",
|
|
1503
|
+
children: "事件"
|
|
1504
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
|
|
1505
|
+
className: "dh-select",
|
|
1506
|
+
value: historyFilter.event ?? "",
|
|
1507
|
+
onChange: (e) => setHistoryFilter({ event: e.target.value }),
|
|
1508
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
1509
|
+
value: "",
|
|
1510
|
+
children: "全部"
|
|
1511
|
+
}), EVENTS.map((name) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
1512
|
+
value: name,
|
|
1513
|
+
children: name
|
|
1514
|
+
}, name))]
|
|
1515
|
+
})]
|
|
1516
|
+
}),
|
|
1517
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
1518
|
+
className: "dh-field",
|
|
1519
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1520
|
+
className: "dh-field-label",
|
|
1521
|
+
children: "结果"
|
|
1522
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("select", {
|
|
1523
|
+
className: "dh-select",
|
|
1524
|
+
value: historyFilter.outcome ?? "",
|
|
1525
|
+
onChange: (e) => setHistoryFilter({ outcome: e.target.value }),
|
|
1526
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
1527
|
+
value: "",
|
|
1528
|
+
children: "全部"
|
|
1529
|
+
}), HISTORY_OUTCOMES.map((name) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("option", {
|
|
1530
|
+
value: name,
|
|
1531
|
+
children: outcomeLabel(name)
|
|
1532
|
+
}, name))]
|
|
1533
|
+
})]
|
|
1534
|
+
}),
|
|
1535
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
|
|
1536
|
+
className: "dh-field",
|
|
1537
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1538
|
+
className: "dh-field-label",
|
|
1539
|
+
children: "会话(可选)"
|
|
1540
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
1541
|
+
className: "dh-input",
|
|
1542
|
+
value: historyFilter.session ?? "",
|
|
1543
|
+
onChange: (e) => setHistoryFilter({ session: e.target.value }),
|
|
1544
|
+
placeholder: "id 或名称片段"
|
|
1545
|
+
})]
|
|
1546
|
+
}),
|
|
1547
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1548
|
+
className: "dh-buttons",
|
|
1549
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1550
|
+
type: "button",
|
|
1551
|
+
className: "dh-button",
|
|
1552
|
+
disabled: !historyFiltered,
|
|
1553
|
+
onClick: () => setHistoryFilter({
|
|
1554
|
+
event: "",
|
|
1555
|
+
outcome: "",
|
|
1556
|
+
session: ""
|
|
1557
|
+
}),
|
|
1558
|
+
children: "清空过滤"
|
|
1559
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
1560
|
+
type: "button",
|
|
1561
|
+
className: "dh-button",
|
|
1562
|
+
disabled: (visibleHistory ?? []).length === 0,
|
|
1563
|
+
onClick: exportHistory,
|
|
1564
|
+
children: "导出 JSONL"
|
|
1565
|
+
})]
|
|
1566
|
+
})
|
|
1567
|
+
]
|
|
1568
|
+
}),
|
|
1569
|
+
historyOpen && history !== null && history.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1570
|
+
className: "dh-history-count",
|
|
1571
|
+
children: [
|
|
1572
|
+
"显示 ",
|
|
1573
|
+
visibleHistory?.length ?? 0,
|
|
1574
|
+
" / 共 ",
|
|
1575
|
+
history.length,
|
|
1576
|
+
" 条(最近 200 条内)",
|
|
1577
|
+
historyFiltered ? " · 已过滤" : ""
|
|
1578
|
+
]
|
|
1579
|
+
}),
|
|
1580
|
+
historyOpen && (history === null || history.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1581
|
+
className: "dh-empty",
|
|
1582
|
+
children: history === null ? "加载中…" : "暂无记录"
|
|
1583
|
+
}) : (visibleHistory ?? []).length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1584
|
+
className: "dh-empty",
|
|
1585
|
+
children: "没有符合过滤条件的记录"
|
|
1586
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1587
|
+
className: "dh-timeline",
|
|
1588
|
+
children: [...visibleHistory ?? []].reverse().map((record, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1589
|
+
className: "dh-record",
|
|
1590
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1591
|
+
className: "dh-record-main",
|
|
1592
|
+
children: [
|
|
1593
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1594
|
+
className: "dh-record-top",
|
|
1595
|
+
children: [
|
|
1596
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1597
|
+
className: "dh-record-time",
|
|
1598
|
+
children: formatTime(record.ts)
|
|
1599
|
+
}),
|
|
1600
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1601
|
+
className: "dh-record-event",
|
|
1602
|
+
children: record.event
|
|
1603
|
+
}),
|
|
1604
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1605
|
+
className: `dh-outcome ${outcomeClass(record.outcome)}`,
|
|
1606
|
+
children: outcomeLabel(record.outcome)
|
|
1607
|
+
})
|
|
1608
|
+
]
|
|
1609
|
+
}),
|
|
1610
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1611
|
+
className: "dh-record-command",
|
|
1612
|
+
title: record.command,
|
|
1613
|
+
children: record.command
|
|
1614
|
+
}),
|
|
1615
|
+
record.sessionName !== void 0 && record.sessionName !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1616
|
+
className: "dh-record-session",
|
|
1617
|
+
children: record.sessionName
|
|
1618
|
+
}),
|
|
1619
|
+
record.error !== void 0 && record.error !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1620
|
+
className: "dh-record-error",
|
|
1621
|
+
children: record.error.slice(0, 200)
|
|
1622
|
+
})
|
|
1623
|
+
]
|
|
1624
|
+
})
|
|
1625
|
+
}, `${record.ts}-${index}`))
|
|
1626
|
+
}))
|
|
1627
|
+
] })
|
|
1402
1628
|
]
|
|
1403
1629
|
});
|
|
1404
1630
|
}
|
|
@@ -1417,7 +1643,7 @@ window.__ModuleLoader__.load({
|
|
|
1417
1643
|
}
|
|
1418
1644
|
//#endregion
|
|
1419
1645
|
//#region src/client/settings-card.module.css?inline
|
|
1420
|
-
var settings_card_module_default = ":root {\n --dsh-hooks-border: #80849038;\n --dsh-hooks-muted: #767c85;\n --dsh-hooks-accent: #4d8df7;\n --dsh-hooks-ok: #3fb56b;\n --dsh-hooks-bad: #e5534b;\n --dsh-hooks-warn: #d9a13c;\n}\n\n.dh-card {\n flex-direction: column;\n gap: 14px;\n padding: 12px 4px;\n font-size: 13px;\n line-height: 1.5;\n display: flex;\n}\n\n.dh-card-head {\n align-items: center;\n gap: 10px;\n display: flex;\n}\n\n.dh-card-title {\n font-size: 14px;\n font-weight: 600;\n}\n\n.dh-badges {\n gap: 6px;\n display: flex;\n}\n\n.dh-badge {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n background: #80849029;\n border-radius: 9px;\n padding: 1px 7px;\n font-size: 11px;\n}\n\n.dh-section-title {\n color: var(--dsh-hooks-muted);\n text-transform: uppercase;\n letter-spacing: .04em;\n margin: 0 0 8px;\n font-size: 12px;\n font-weight: 600;\n}\n\n.dh-section-head {\n justify-content: space-between;\n align-items: center;\n gap: 8px;\n margin: 0 0 8px;\n display: flex;\n}\n\n.dh-section-head .dh-section-title {\n margin: 0;\n}\n\n.dh-toggle {\n padding: 2px 10px;\n font-size: 11px;\n}\n\n.dh-field-narrow {\n flex: 0 0 150px;\n}\n\n.dh-feishu-row {\n align-items: flex-end;\n gap: 8px;\n display: flex;\n}\n\n.dh-timeline {\n flex-direction: column;\n gap: 6px;\n display: flex;\n}\n\n.dh-record {\n border: 1px solid var(--dsh-hooks-border);\n border-radius: 6px;\n gap: 8px;\n padding: 7px 9px;\n display: flex;\n}\n\n.dh-record-main {\n flex: 1;\n min-width: 0;\n}\n\n.dh-record-top {\n align-items: baseline;\n gap: 6px;\n display: flex;\n}\n\n.dh-record-time {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n font-size: 11px;\n}\n\n.dh-record-event {\n white-space: nowrap;\n text-overflow: ellipsis;\n font-weight: 600;\n overflow: hidden;\n}\n\n.dh-record-command {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n text-overflow: ellipsis;\n text-align: left;\n direction: rtl;\n font-size: 12px;\n overflow: hidden;\n}\n\n.dh-outcome {\n white-space: nowrap;\n border-radius: 9px;\n align-self: flex-start;\n padding: 1px 7px;\n font-size: 11px;\n}\n\n.dh-outcome-ok {\n color: var(--dsh-hooks-ok);\n background: #3fb56b29;\n}\n\n.dh-outcome-bad {\n color: var(--dsh-hooks-bad);\n background: #e5534b29;\n}\n\n.dh-outcome-warn {\n color: var(--dsh-hooks-warn);\n background: #d9a13c29;\n}\n\n.dh-outcome-neutral {\n color: var(--dsh-hooks-muted);\n background: #80849029;\n}\n\n.dh-record-error {\n color: var(--dsh-hooks-bad);\n white-space: pre-wrap;\n word-break: break-all;\n margin-top: 4px;\n font-size: 11px;\n}\n\n.dh-empty {\n color: var(--dsh-hooks-muted);\n padding: 6px 2px;\n font-size: 12px;\n}\n\n.dh-test-form {\n flex-direction: column;\n gap: 8px;\n display: flex;\n}\n\n.dh-test-row {\n gap: 8px;\n display: flex;\n}\n\n.dh-field {\n flex-direction: column;\n flex: 1;\n gap: 3px;\n min-width: 0;\n display: flex;\n}\n\n.dh-field-label {\n color: var(--dsh-hooks-muted);\n font-size: 11px;\n}\n\n.dh-input, .dh-select {\n border: 1px solid var(--dsh-hooks-border);\n color: inherit;\n box-sizing: border-box;\n background: #8084901f;\n border-radius: 5px;\n outline: none;\n width: 100%;\n padding: 5px 8px;\n font-size: 12px;\n}\n\nbody[data-ds-dark-theme] .dh-select {\n color-scheme: dark;\n}\n\n.dh-select option {\n background-color: var(--dsw-specific-menu, transparent);\n color: inherit;\n}\n\n.dh-input:focus, .dh-select:focus {\n border-color: var(--dsh-hooks-accent);\n}\n\n.dh-buttons {\n gap: 8px;\n display: flex;\n}\n\n.dh-button {\n border: 1px solid var(--dsh-hooks-border);\n color: inherit;\n cursor: pointer;\n background: #8084901f;\n border-radius: 5px;\n padding: 5px 12px;\n font-size: 12px;\n}\n\n.dh-button:hover {\n background: #80849038;\n}\n\n.dh-button-primary {\n background: var(--dsh-hooks-accent);\n border-color: var(--dsh-hooks-accent);\n color: #fff;\n}\n\n.dh-button-primary:hover {\n background: #3c7de8;\n}\n\n.dh-test-results {\n flex-direction: column;\n gap: 4px;\n display: flex;\n}\n\n.dh-test-line {\n word-break: break-all;\n border-radius: 5px;\n padding: 4px 8px;\n font-size: 12px;\n}\n\n.dh-test-line-match {\n color: var(--dsh-hooks-ok);\n background: #3fb56b24;\n}\n\n.dh-test-line-skip {\n color: var(--dsh-hooks-muted);\n background: #8084901a;\n}\n\n.dh-error-banner {\n color: var(--dsh-hooks-bad);\n background: #e5534b1f;\n border: 1px solid #e5534b66;\n border-radius: 6px;\n padding: 8px 10px;\n font-size: 12px;\n}\n\n.dh-feishu {\n flex-direction: column;\n gap: 8px;\n display: flex;\n}\n\n.dh-feishu-form, .dh-feishu-status, .dh-feishu-qr {\n flex-direction: column;\n align-items: flex-start;\n gap: 8px;\n display: flex;\n}\n\n.dh-feishu-qr-img {\n border: 1px solid var(--dsh-hooks-border);\n box-sizing: border-box;\n background: #fff;\n border-radius: 8px;\n width: 220px;\n height: 220px;\n padding: 6px;\n}\n\n.dh-feishu-line {\n font-size: 12px;\n}\n\n.dh-feishu-ok {\n color: var(--dsh-hooks-ok);\n}\n\n.dh-feishu-error {\n color: var(--dsh-hooks-bad);\n word-break: break-all;\n font-size: 12px;\n}\n\n.dh-feishu-hint {\n color: var(--dsh-hooks-muted);\n font-size: 11px;\n}\n\n.dh-feishu-link {\n color: var(--dsh-hooks-accent);\n font-size: 12px;\n}\n\n.dh-badge-bad {\n color: var(--dsh-hooks-bad);\n background: #e5534b29;\n}\n\n.dh-error-retry {\n flex-shrink: 0;\n margin-left: 8px;\n padding: 2px 10px;\n font-size: 11px;\n}\n\n.dh-check {\n color: var(--dsh-hooks-muted);\n align-items: center;\n gap: 6px;\n font-size: 12px;\n display: flex;\n}\n\n.dh-button-small {\n padding: 2px 8px;\n font-size: 11px;\n}\n\n.dh-button-danger {\n color: var(--dsh-hooks-bad);\n border-color: #e5534b80;\n}\n\n.dh-button-danger:hover {\n background: #e5534b24;\n}\n\n.dh-hook-list, .dh-hook-editor {\n flex-direction: column;\n gap: 6px;\n display: flex;\n}\n\n.dh-hook {\n border: 1px solid var(--dsh-hooks-border);\n border-radius: 6px;\n flex-direction: column;\n gap: 4px;\n padding: 7px 9px;\n display: flex;\n}\n\n.dh-hook-editing {\n border-color: #4d8df766;\n gap: 8px;\n padding: 9px;\n}\n\n.dh-hook-head {\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n display: flex;\n}\n\n.dh-hook-event {\n white-space: nowrap;\n font-size: 12px;\n font-weight: 600;\n}\n\n.dh-hook-action {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n text-overflow: ellipsis;\n text-align: left;\n direction: rtl;\n flex: 1;\n min-width: 0;\n font-size: 12px;\n overflow: hidden;\n}\n\n.dh-hook-match {\n color: var(--dsh-hooks-accent);\n word-break: break-all;\n font-size: 11px;\n}\n\n.dh-hook-meta {\n align-items: center;\n gap: 8px;\n display: flex;\n}\n\n.dh-match-key {\n flex: 0 0 130px;\n}\n\n.dh-feishu-preview {\n color: var(--dsh-hooks-muted);\n border-left: 2px solid var(--dsh-hooks-border);\n word-break: break-all;\n max-height: 120px;\n padding: 4px 8px;\n font-size: 11px;\n overflow: hidden;\n}\n";
|
|
1646
|
+
var settings_card_module_default = ":root {\n --dsh-hooks-border: #80849038;\n --dsh-hooks-muted: #767c85;\n --dsh-hooks-accent: #4d8df7;\n --dsh-hooks-ok: #3fb56b;\n --dsh-hooks-bad: #e5534b;\n --dsh-hooks-warn: #d9a13c;\n}\n\n.dh-card {\n flex-direction: column;\n gap: 14px;\n padding: 12px 4px;\n font-size: 13px;\n line-height: 1.5;\n display: flex;\n}\n\n.dh-card-head {\n align-items: center;\n gap: 10px;\n display: flex;\n}\n\n.dh-card-title {\n font-size: 14px;\n font-weight: 600;\n}\n\n.dh-badges {\n gap: 6px;\n display: flex;\n}\n\n.dh-badge {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n background: #80849029;\n border-radius: 9px;\n padding: 1px 7px;\n font-size: 11px;\n}\n\n.dh-section-title {\n color: var(--dsh-hooks-muted);\n text-transform: uppercase;\n letter-spacing: .04em;\n margin: 0 0 8px;\n font-size: 12px;\n font-weight: 600;\n}\n\n.dh-section-head {\n justify-content: space-between;\n align-items: center;\n gap: 8px;\n margin: 0 0 8px;\n display: flex;\n}\n\n.dh-section-head .dh-section-title {\n margin: 0;\n}\n\n.dh-toggle {\n padding: 2px 10px;\n font-size: 11px;\n}\n\n.dh-field-narrow {\n flex: 0 0 150px;\n}\n\n.dh-feishu-row {\n align-items: flex-end;\n gap: 8px;\n display: flex;\n}\n\n.dh-timeline {\n flex-direction: column;\n gap: 6px;\n display: flex;\n}\n\n.dh-record {\n border: 1px solid var(--dsh-hooks-border);\n border-radius: 6px;\n gap: 8px;\n padding: 7px 9px;\n display: flex;\n}\n\n.dh-record-main {\n flex: 1;\n min-width: 0;\n}\n\n.dh-record-top {\n align-items: baseline;\n gap: 6px;\n display: flex;\n}\n\n.dh-record-time {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n font-size: 11px;\n}\n\n.dh-record-event {\n white-space: nowrap;\n text-overflow: ellipsis;\n font-weight: 600;\n overflow: hidden;\n}\n\n.dh-record-command {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n text-overflow: ellipsis;\n text-align: left;\n direction: rtl;\n font-size: 12px;\n overflow: hidden;\n}\n\n.dh-outcome {\n white-space: nowrap;\n border-radius: 9px;\n align-self: flex-start;\n padding: 1px 7px;\n font-size: 11px;\n}\n\n.dh-outcome-ok {\n color: var(--dsh-hooks-ok);\n background: #3fb56b29;\n}\n\n.dh-outcome-bad {\n color: var(--dsh-hooks-bad);\n background: #e5534b29;\n}\n\n.dh-outcome-warn {\n color: var(--dsh-hooks-warn);\n background: #d9a13c29;\n}\n\n.dh-outcome-neutral {\n color: var(--dsh-hooks-muted);\n background: #80849029;\n}\n\n.dh-record-error {\n color: var(--dsh-hooks-bad);\n white-space: pre-wrap;\n word-break: break-all;\n margin-top: 4px;\n font-size: 11px;\n}\n\n.dh-empty {\n color: var(--dsh-hooks-muted);\n padding: 6px 2px;\n font-size: 12px;\n}\n\n.dh-history-filters {\n flex-wrap: wrap;\n align-items: flex-end;\n margin-bottom: 6px;\n}\n\n.dh-history-filters .dh-buttons {\n flex: none;\n}\n\n.dh-history-count {\n color: var(--dsh-hooks-muted);\n padding: 0 2px 6px;\n font-size: 11px;\n}\n\n.dh-record-session {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n text-overflow: ellipsis;\n font-size: 11px;\n overflow: hidden;\n}\n\n.dh-test-form {\n flex-direction: column;\n gap: 8px;\n display: flex;\n}\n\n.dh-test-row {\n gap: 8px;\n display: flex;\n}\n\n.dh-field {\n flex-direction: column;\n flex: 1;\n gap: 3px;\n min-width: 0;\n display: flex;\n}\n\n.dh-field-label {\n color: var(--dsh-hooks-muted);\n font-size: 11px;\n}\n\n.dh-input, .dh-select {\n border: 1px solid var(--dsh-hooks-border);\n color: inherit;\n box-sizing: border-box;\n background: #8084901f;\n border-radius: 5px;\n outline: none;\n width: 100%;\n padding: 5px 8px;\n font-size: 12px;\n}\n\nbody[data-ds-dark-theme] .dh-select {\n color-scheme: dark;\n}\n\n.dh-select option {\n background-color: var(--dsw-specific-menu, transparent);\n color: inherit;\n}\n\n.dh-input:focus, .dh-select:focus {\n border-color: var(--dsh-hooks-accent);\n}\n\n.dh-buttons {\n gap: 8px;\n display: flex;\n}\n\n.dh-button {\n border: 1px solid var(--dsh-hooks-border);\n color: inherit;\n cursor: pointer;\n background: #8084901f;\n border-radius: 5px;\n padding: 5px 12px;\n font-size: 12px;\n}\n\n.dh-button:hover {\n background: #80849038;\n}\n\n.dh-button-primary {\n background: var(--dsh-hooks-accent);\n border-color: var(--dsh-hooks-accent);\n color: #fff;\n}\n\n.dh-button-primary:hover {\n background: #3c7de8;\n}\n\n.dh-test-results {\n flex-direction: column;\n gap: 4px;\n display: flex;\n}\n\n.dh-test-line {\n word-break: break-all;\n border-radius: 5px;\n padding: 4px 8px;\n font-size: 12px;\n}\n\n.dh-test-line-match {\n color: var(--dsh-hooks-ok);\n background: #3fb56b24;\n}\n\n.dh-test-line-skip {\n color: var(--dsh-hooks-muted);\n background: #8084901a;\n}\n\n.dh-error-banner {\n color: var(--dsh-hooks-bad);\n background: #e5534b1f;\n border: 1px solid #e5534b66;\n border-radius: 6px;\n padding: 8px 10px;\n font-size: 12px;\n}\n\n.dh-feishu {\n flex-direction: column;\n gap: 8px;\n display: flex;\n}\n\n.dh-feishu-form, .dh-feishu-status, .dh-feishu-qr {\n flex-direction: column;\n align-items: flex-start;\n gap: 8px;\n display: flex;\n}\n\n.dh-feishu-qr-img {\n border: 1px solid var(--dsh-hooks-border);\n box-sizing: border-box;\n background: #fff;\n border-radius: 8px;\n width: 220px;\n height: 220px;\n padding: 6px;\n}\n\n.dh-feishu-line {\n font-size: 12px;\n}\n\n.dh-feishu-ok {\n color: var(--dsh-hooks-ok);\n}\n\n.dh-feishu-error {\n color: var(--dsh-hooks-bad);\n word-break: break-all;\n font-size: 12px;\n}\n\n.dh-feishu-hint {\n color: var(--dsh-hooks-muted);\n font-size: 11px;\n}\n\n.dh-feishu-link {\n color: var(--dsh-hooks-accent);\n font-size: 12px;\n}\n\n.dh-badge-bad {\n color: var(--dsh-hooks-bad);\n background: #e5534b29;\n}\n\n.dh-error-retry {\n flex-shrink: 0;\n margin-left: 8px;\n padding: 2px 10px;\n font-size: 11px;\n}\n\n.dh-check {\n color: var(--dsh-hooks-muted);\n align-items: center;\n gap: 6px;\n font-size: 12px;\n display: flex;\n}\n\n.dh-button-small {\n padding: 2px 8px;\n font-size: 11px;\n}\n\n.dh-button-danger {\n color: var(--dsh-hooks-bad);\n border-color: #e5534b80;\n}\n\n.dh-button-danger:hover {\n background: #e5534b24;\n}\n\n.dh-hook-list, .dh-hook-editor {\n flex-direction: column;\n gap: 6px;\n display: flex;\n}\n\n.dh-hook {\n border: 1px solid var(--dsh-hooks-border);\n border-radius: 6px;\n flex-direction: column;\n gap: 4px;\n padding: 7px 9px;\n display: flex;\n}\n\n.dh-hook-editing {\n border-color: #4d8df766;\n gap: 8px;\n padding: 9px;\n}\n\n.dh-hook-head {\n align-items: baseline;\n gap: 8px;\n min-width: 0;\n display: flex;\n}\n\n.dh-hook-event {\n white-space: nowrap;\n font-size: 12px;\n font-weight: 600;\n}\n\n.dh-hook-action {\n color: var(--dsh-hooks-muted);\n white-space: nowrap;\n text-overflow: ellipsis;\n text-align: left;\n direction: rtl;\n flex: 1;\n min-width: 0;\n font-size: 12px;\n overflow: hidden;\n}\n\n.dh-hook-match {\n color: var(--dsh-hooks-accent);\n word-break: break-all;\n font-size: 11px;\n}\n\n.dh-hook-meta {\n align-items: center;\n gap: 8px;\n display: flex;\n}\n\n.dh-match-key {\n flex: 0 0 130px;\n}\n\n.dh-feishu-preview {\n color: var(--dsh-hooks-muted);\n border-left: 2px solid var(--dsh-hooks-border);\n word-break: break-all;\n max-height: 120px;\n padding: 4px 8px;\n font-size: 11px;\n overflow: hidden;\n}\n";
|
|
1421
1647
|
//#endregion
|
|
1422
1648
|
//#region src/client/index.ts
|
|
1423
1649
|
const name = "dsh-hooks";
|
package/lib/config.d.ts
CHANGED
|
@@ -65,8 +65,10 @@ export interface HookSpec {
|
|
|
65
65
|
/** Per-hook timeout in milliseconds. Defaults to 10000. */
|
|
66
66
|
timeoutMs?: number;
|
|
67
67
|
/**
|
|
68
|
-
* Retry count for
|
|
69
|
-
*
|
|
68
|
+
* Retry count for retryable failures (default 0: fire-and-forget, never
|
|
69
|
+
* retried). `run`: non-zero exit codes only — spawn failures and timeouts
|
|
70
|
+
* are never retried. `notify` (webhook): transport failures and HTTP
|
|
71
|
+
* 408/429/5xx; the desktop channel never retries.
|
|
70
72
|
*/
|
|
71
73
|
retries?: number;
|
|
72
74
|
/** Base delay between retries in milliseconds; doubles per attempt. Defaults to 500. */
|
package/lib/config.js
CHANGED
|
@@ -64,7 +64,7 @@ export const Config = Schema.object({
|
|
|
64
64
|
.default('env')
|
|
65
65
|
.description('上下文传递方式:env 只传 DSH_HOOK_* 环境变量(默认);stdin 额外把完整上下文 JSON 写入命令标准输入'),
|
|
66
66
|
timeoutMs: Schema.number().default(10000).description('单次执行超时(毫秒)'),
|
|
67
|
-
retries: Schema.natural().default(0).description('
|
|
67
|
+
retries: Schema.natural().default(0).description('重试次数(默认 0 不重试):run 重试非零退出码(spawn 失败与超时不重试);notify 的 webhook 渠道重试传输失败与 HTTP 408/429/5xx(desktop 渠道不重试)'),
|
|
68
68
|
retryDelayMs: Schema.natural().default(500).description('重试基础间隔(毫秒),每次翻倍'),
|
|
69
69
|
enabled: Schema.boolean()
|
|
70
70
|
.default(true)
|
package/lib/dry-run.d.ts
CHANGED
|
@@ -13,6 +13,33 @@ export declare function loadHooks(profile: string, paths?: {
|
|
|
13
13
|
hooks: HookSpec[];
|
|
14
14
|
source: string;
|
|
15
15
|
};
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the JSONL path a profile's dsh-hooks config writes history to
|
|
18
|
+
* (`config.history.path`, else the plugin default). Deliberately lenient:
|
|
19
|
+
* `tail` must keep working while the config file is missing or mid-edit.
|
|
20
|
+
*/
|
|
21
|
+
export declare function loadHistoryPath(profile: string, paths?: {
|
|
22
|
+
patchFile?: string;
|
|
23
|
+
}): string;
|
|
24
|
+
/**
|
|
25
|
+
* Numeric context fields a simulated event may override — the ones a `match`
|
|
26
|
+
* comparison can meaningfully target. Strings keep their dedicated CLI flag /
|
|
27
|
+
* tester input (`--tool`, `--session-name`, …) and the mock defaults.
|
|
28
|
+
*/
|
|
29
|
+
export declare const MOCK_NUMERIC_FIELDS: readonly ['turn', 'step', 'durationMs', 'toolDurationMs', 'runningSubagents', 'totalSubagents', 'treeDurationMs', 'usageTurns', 'usageSessions', 'usageInputTokens', 'usageOutputTokens', 'usageCacheReadTokens', 'usageCacheWriteTokens', 'usageReasoningTokens'];
|
|
30
|
+
export type MockNumericField = (typeof MOCK_NUMERIC_FIELDS)[number];
|
|
31
|
+
export interface MockFieldsResult {
|
|
32
|
+
ctx: HookContext;
|
|
33
|
+
/** Keys that were dropped: unknown field names or non-finite numbers. */
|
|
34
|
+
ignored: string[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Apply explicit numeric overrides to a simulated context. Values must be
|
|
38
|
+
* finite numbers; anything else (unknown field, string, NaN) is reported in
|
|
39
|
+
* `ignored` instead of being silently coerced — a tester must never "pass"
|
|
40
|
+
* because a filter was fed the wrong type.
|
|
41
|
+
*/
|
|
42
|
+
export declare function applyMockFields(ctx: HookContext, fields: Record<string, unknown> | undefined): MockFieldsResult;
|
|
16
43
|
/** A synthetic context for the simulated event, overridable per field. */
|
|
17
44
|
export declare function mockContext(event: string, overrides?: Partial<HookContext>): HookContext;
|
|
18
45
|
export interface DryRunLine {
|
|
@@ -34,6 +61,8 @@ export interface DryRunOptions {
|
|
|
34
61
|
reason?: TurnEndReasonKind;
|
|
35
62
|
tool?: string;
|
|
36
63
|
sessionName?: string;
|
|
64
|
+
/** Explicit numeric context overrides (see {@link MOCK_NUMERIC_FIELDS}). */
|
|
65
|
+
fields?: Record<string, unknown>;
|
|
37
66
|
/** Actually run the matching hooks (real side effects!). */
|
|
38
67
|
execute?: boolean;
|
|
39
68
|
print?: (line: string) => void;
|
package/lib/dry-run.js
CHANGED
|
@@ -9,6 +9,7 @@ import { join } from 'node:path';
|
|
|
9
9
|
import YAML from 'yaml';
|
|
10
10
|
import { Config } from './config.js';
|
|
11
11
|
import { matchFilters } from './events.js';
|
|
12
|
+
import { DEFAULT_HISTORY_PATH } from './history.js';
|
|
12
13
|
import { localDayKey } from './usage.js';
|
|
13
14
|
import { createHookRunner } from './runner.js';
|
|
14
15
|
import { fireNotify } from './notify.js';
|
|
@@ -17,12 +18,10 @@ export function patchFilePath(profile) {
|
|
|
17
18
|
return join(homedir(), '.dsh', 'profiles', profile, 'cordis.patch.yml');
|
|
18
19
|
}
|
|
19
20
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* regexes compile and invalid entries fail loudly.
|
|
21
|
+
* Raw `dsh-hooks` config block from a profile's cordis.patch.yml. Throws on a
|
|
22
|
+
* missing/unreadable file — callers that must stay lenient catch it.
|
|
23
23
|
*/
|
|
24
|
-
|
|
25
|
-
const file = paths.patchFile ?? patchFilePath(profile);
|
|
24
|
+
function readConfigBlock(file) {
|
|
26
25
|
if (!existsSync(file))
|
|
27
26
|
throw new Error(`未找到 ${file}(profile 不存在或没有 cordis.patch.yml)`);
|
|
28
27
|
let entries;
|
|
@@ -34,19 +33,85 @@ export function loadHooks(profile, paths = {}) {
|
|
|
34
33
|
}
|
|
35
34
|
if (!Array.isArray(entries))
|
|
36
35
|
throw new Error('cordis.patch.yml 顶层必须是 YAML 数组');
|
|
37
|
-
let block;
|
|
38
36
|
for (const entry of entries) {
|
|
39
37
|
if (entry !== null && typeof entry === 'object' && entry.id === 'dsh-hooks') {
|
|
40
|
-
|
|
41
|
-
break;
|
|
38
|
+
return entry;
|
|
42
39
|
}
|
|
43
40
|
}
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
throw new Error('cordis.patch.yml 中没有 id: dsh-hooks 的配置块');
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Load and normalize the dsh-hooks config block from a profile's
|
|
45
|
+
* cordis.patch.yml. Runs the block through the Config schema so match
|
|
46
|
+
* regexes compile and invalid entries fail loudly.
|
|
47
|
+
*/
|
|
48
|
+
export function loadHooks(profile, paths = {}) {
|
|
49
|
+
const file = paths.patchFile ?? patchFilePath(profile);
|
|
50
|
+
const block = readConfigBlock(file);
|
|
46
51
|
const rawHooks = block.config?.hooks;
|
|
47
52
|
const config = Config({ hooks: (Array.isArray(rawHooks) ? rawHooks : []) });
|
|
48
53
|
return { hooks: config.hooks ?? [], source: file };
|
|
49
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the JSONL path a profile's dsh-hooks config writes history to
|
|
57
|
+
* (`config.history.path`, else the plugin default). Deliberately lenient:
|
|
58
|
+
* `tail` must keep working while the config file is missing or mid-edit.
|
|
59
|
+
*/
|
|
60
|
+
export function loadHistoryPath(profile, paths = {}) {
|
|
61
|
+
const file = paths.patchFile ?? patchFilePath(profile);
|
|
62
|
+
try {
|
|
63
|
+
const block = readConfigBlock(file);
|
|
64
|
+
const configured = block.config?.history?.path;
|
|
65
|
+
if (typeof configured === 'string' && configured.trim() !== '')
|
|
66
|
+
return configured;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Missing/broken config: fall through to the plugin default.
|
|
70
|
+
}
|
|
71
|
+
return DEFAULT_HISTORY_PATH;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Numeric context fields a simulated event may override — the ones a `match`
|
|
75
|
+
* comparison can meaningfully target. Strings keep their dedicated CLI flag /
|
|
76
|
+
* tester input (`--tool`, `--session-name`, …) and the mock defaults.
|
|
77
|
+
*/
|
|
78
|
+
export const MOCK_NUMERIC_FIELDS = [
|
|
79
|
+
'turn',
|
|
80
|
+
'step',
|
|
81
|
+
'durationMs',
|
|
82
|
+
'toolDurationMs',
|
|
83
|
+
'runningSubagents',
|
|
84
|
+
'totalSubagents',
|
|
85
|
+
'treeDurationMs',
|
|
86
|
+
'usageTurns',
|
|
87
|
+
'usageSessions',
|
|
88
|
+
'usageInputTokens',
|
|
89
|
+
'usageOutputTokens',
|
|
90
|
+
'usageCacheReadTokens',
|
|
91
|
+
'usageCacheWriteTokens',
|
|
92
|
+
'usageReasoningTokens',
|
|
93
|
+
];
|
|
94
|
+
/**
|
|
95
|
+
* Apply explicit numeric overrides to a simulated context. Values must be
|
|
96
|
+
* finite numbers; anything else (unknown field, string, NaN) is reported in
|
|
97
|
+
* `ignored` instead of being silently coerced — a tester must never "pass"
|
|
98
|
+
* because a filter was fed the wrong type.
|
|
99
|
+
*/
|
|
100
|
+
export function applyMockFields(ctx, fields) {
|
|
101
|
+
if (fields === undefined)
|
|
102
|
+
return { ctx, ignored: [] };
|
|
103
|
+
const next = { ...ctx };
|
|
104
|
+
const ignored = [];
|
|
105
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
106
|
+
if (!MOCK_NUMERIC_FIELDS.includes(key) || typeof value !== 'number' || !Number.isFinite(value)) {
|
|
107
|
+
ignored.push(key);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
;
|
|
111
|
+
next[key] = value;
|
|
112
|
+
}
|
|
113
|
+
return { ctx: next, ignored };
|
|
114
|
+
}
|
|
50
115
|
/** A synthetic context for the simulated event, overridable per field. */
|
|
51
116
|
export function mockContext(event, overrides = {}) {
|
|
52
117
|
const ctx = {
|
|
@@ -61,6 +126,12 @@ export function mockContext(event, overrides = {}) {
|
|
|
61
126
|
content: 'dry-run 模拟内容',
|
|
62
127
|
timestamp: new Date().toISOString(),
|
|
63
128
|
};
|
|
129
|
+
if (event === 'turn/end') {
|
|
130
|
+
// The real turn/end context ALWAYS carries the live subagent count (0 when
|
|
131
|
+
// none are running), so the mock must too — otherwise the documented
|
|
132
|
+
// `match: { runningSubagents: '^0$' }` pattern could never match here.
|
|
133
|
+
ctx.runningSubagents = 0;
|
|
134
|
+
}
|
|
64
135
|
if (event === 'usage/daily') {
|
|
65
136
|
// A daily report always describes a day that already ended, and the
|
|
66
137
|
// simulated numbers must be non-zero so `match` filters on them (e.g.
|
|
@@ -125,14 +196,23 @@ export async function runDryRun(options) {
|
|
|
125
196
|
const print = options.print ?? console.log;
|
|
126
197
|
const { hooks, source } = loadHooks(profile, options.paths);
|
|
127
198
|
const reasonKind = options.reason;
|
|
128
|
-
const
|
|
199
|
+
const simulated = applyMockFields(mockContext(options.event, {
|
|
129
200
|
reason: reasonKind,
|
|
130
201
|
tool: options.tool,
|
|
131
202
|
sessionName: options.sessionName,
|
|
132
|
-
});
|
|
203
|
+
}), options.fields);
|
|
204
|
+
const ctx = simulated.ctx;
|
|
133
205
|
print('dsh-hooks dry-run');
|
|
134
206
|
print(`配置来源:${source}(${hooks.length} 个 hook)`);
|
|
135
207
|
print(`模拟事件:${options.event}${reasonKind ? `(reason=${reasonKind})` : ''}`);
|
|
208
|
+
if (options.fields !== undefined && Object.keys(options.fields).length > 0) {
|
|
209
|
+
const applied = Object.keys(options.fields).filter((key) => !simulated.ignored.includes(key));
|
|
210
|
+
if (applied.length > 0)
|
|
211
|
+
print(`模拟字段:${applied.map((key) => `${key}=${String(options.fields?.[key])}`).join(', ')}`);
|
|
212
|
+
}
|
|
213
|
+
if (simulated.ignored.length > 0) {
|
|
214
|
+
print(`⚠ 已忽略无法模拟的字段:${simulated.ignored.join(', ')}(可用:${MOCK_NUMERIC_FIELDS.join(' / ')})`);
|
|
215
|
+
}
|
|
136
216
|
const lines = evaluateHooks(hooks, options.event, ctx, reasonKind);
|
|
137
217
|
for (const line of lines) {
|
|
138
218
|
print(line.matched ? `✅ [${line.index}] ${line.summary}` : `⏭ [${line.index}] ${line.summary} —— ${line.why}`);
|
|
@@ -153,7 +233,7 @@ export async function runDryRun(options) {
|
|
|
153
233
|
}
|
|
154
234
|
else if (hook.notify) {
|
|
155
235
|
print(`▶ 发送 [${line.index}] notify:${hook.notify.channel}`);
|
|
156
|
-
await fireNotify(hook.notify, ctx);
|
|
236
|
+
await fireNotify(hook.notify, ctx, undefined, { retries: hook.retries, retryDelayMs: hook.retryDelayMs });
|
|
157
237
|
}
|
|
158
238
|
}
|
|
159
239
|
print('(run 命令 fire-and-forget:执行结果见 dsh 日志)');
|