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/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(30),
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: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1351
- className: "dh-section-head",
1352
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h3", {
1353
- className: "dh-section-title",
1354
- children: ["执行历史(最近 30 条)", status !== null && status.historyCount > 0 ? ` · ${status.historyCount} 条` : ""]
1355
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1356
- type: "button",
1357
- className: "dh-button dh-toggle",
1358
- onClick: () => setHistoryOpen(!historyOpen),
1359
- "aria-expanded": historyOpen,
1360
- children: historyOpen ? "收起 ▲" : "展开 ▼"
1361
- })]
1362
- }), historyOpen && (history === null || history.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1363
- className: "dh-empty",
1364
- children: history === null ? "加载中…" : "暂无记录"
1365
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1366
- className: "dh-timeline",
1367
- children: [...history].reverse().map((record, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1368
- className: "dh-record",
1369
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1370
- className: "dh-record-main",
1371
- children: [
1372
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1373
- className: "dh-record-top",
1374
- children: [
1375
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1376
- className: "dh-record-time",
1377
- children: formatTime(record.ts)
1378
- }),
1379
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1380
- className: "dh-record-event",
1381
- children: record.event
1382
- }),
1383
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1384
- className: `dh-outcome ${outcomeClass(record.outcome)}`,
1385
- children: outcomeLabel(record.outcome)
1386
- })
1387
- ]
1388
- }),
1389
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1390
- className: "dh-record-command",
1391
- title: record.command,
1392
- children: record.command
1393
- }),
1394
- record.error !== void 0 && record.error !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1395
- className: "dh-record-error",
1396
- children: record.error.slice(0, 200)
1397
- })
1398
- ]
1399
- })
1400
- }, `${record.ts}-${index}`))
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 non-zero exit codes (default 0: fire-and-forget,
69
- * never retried). Spawn failures and timeouts are never retried.
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('非零退出码的重试次数(默认 0 不重试;spawn 失败与超时不重试)'),
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
- * Load and normalize the dsh-hooks config block from a profile's
21
- * cordis.patch.yml. Runs the block through the Config schema so match
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
- export function loadHooks(profile, paths = {}) {
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
- block = entry;
41
- break;
38
+ return entry;
42
39
  }
43
40
  }
44
- if (block === undefined)
45
- throw new Error('cordis.patch.yml 中没有 id: dsh-hooks 的配置块');
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 ctx = mockContext(options.event, {
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 日志)');