dsh-hooks 0.11.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", {
@@ -200,18 +238,22 @@ window.__ModuleLoader__.load({
200
238
  const EVENTS = [
201
239
  "turn/start",
202
240
  "turn/end",
241
+ "tree/settled",
203
242
  "step/end",
204
243
  "tool/call",
205
244
  "tool/result",
206
245
  "user/message",
207
246
  "approval/asked",
247
+ "approval/decided",
208
248
  "session/title",
209
249
  "session/created",
210
250
  "session/disposed",
211
251
  "agent/created",
212
252
  "agent/disposed",
213
253
  "agent/error",
214
- "agent/status"
254
+ "agent/status",
255
+ "hook/failed",
256
+ "usage/daily"
215
257
  ];
216
258
  const TURN_END_REASONS = [
217
259
  "completed",
@@ -313,10 +355,35 @@ window.__ModuleLoader__.load({
313
355
  }
314
356
  });
315
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 ?? "") !== "";
316
377
  const [event, setEvent] = (0, react.useState)("turn/end");
317
378
  const [reason, setReason] = (0, react.useState)("completed");
318
379
  const [tool, setTool] = (0, react.useState)("");
319
380
  const [testResult, setTestResult] = (0, react.useState)(null);
381
+ const [mockFields, setMockFields] = (0, react.useState)({
382
+ runningSubagents: "",
383
+ durationMs: "",
384
+ usageInputTokens: "",
385
+ usageOutputTokens: ""
386
+ });
320
387
  const [notifyChannel, setNotifyChannel] = (0, react.useState)("webhook");
321
388
  const [notifyUrl, setNotifyUrl] = (0, react.useState)("");
322
389
  const [notifySlack, setNotifySlack] = (0, react.useState)(false);
@@ -341,7 +408,7 @@ window.__ModuleLoader__.load({
341
408
  const refresh = (0, react.useCallback)(async () => {
342
409
  const [statusInfo, records, feishuInfo] = await Promise.all([
343
410
  fetchStatus(),
344
- fetchHistory(30),
411
+ fetchHistory(200),
345
412
  fetchFeishuStatus()
346
413
  ]);
347
414
  setStatus(statusInfo);
@@ -375,18 +442,47 @@ window.__ModuleLoader__.load({
375
442
  }, [
376
443
  event,
377
444
  reason,
378
- tool
445
+ tool,
446
+ mockFields
379
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
+ };
380
459
  const runTest = async (execute) => {
381
460
  const result = await postTest({
382
461
  event,
383
462
  reason: event === "turn/end" && reason !== "" ? reason : void 0,
384
463
  tool: tool !== "" ? tool : void 0,
464
+ fields: testFields(),
385
465
  execute
386
466
  });
387
467
  setTestResult(result);
388
468
  if (execute) refresh();
389
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
+ };
390
486
  const sendNotifyTest = async () => {
391
487
  setNotifyResult(null);
392
488
  const result = await postNotifyTest(notifyChannel, notifyUrl.trim() || void 0, notifySlack);
@@ -699,6 +795,46 @@ window.__ModuleLoader__.load({
699
795
  })
700
796
  ]
701
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
+ }),
702
838
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
703
839
  className: "dh-buttons",
704
840
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
@@ -1343,58 +1479,152 @@ window.__ModuleLoader__.load({
1343
1479
  ]
1344
1480
  }, hook.index))
1345
1481
  })] }),
1346
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1347
- className: "dh-section-head",
1348
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("h3", {
1349
- className: "dh-section-title",
1350
- children: ["执行历史(最近 30 条)", status !== null && status.historyCount > 0 ? ` · ${status.historyCount} 条` : ""]
1351
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1352
- type: "button",
1353
- className: "dh-button dh-toggle",
1354
- onClick: () => setHistoryOpen(!historyOpen),
1355
- "aria-expanded": historyOpen,
1356
- children: historyOpen ? "收起 ▲" : "展开 ▼"
1357
- })]
1358
- }), historyOpen && (history === null || history.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1359
- className: "dh-empty",
1360
- children: history === null ? "加载中…" : "暂无记录"
1361
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1362
- className: "dh-timeline",
1363
- children: [...history].reverse().map((record, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1364
- className: "dh-record",
1365
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1366
- className: "dh-record-main",
1367
- children: [
1368
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1369
- className: "dh-record-top",
1370
- children: [
1371
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1372
- className: "dh-record-time",
1373
- children: formatTime(record.ts)
1374
- }),
1375
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1376
- className: "dh-record-event",
1377
- children: record.event
1378
- }),
1379
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1380
- className: `dh-outcome ${outcomeClass(record.outcome)}`,
1381
- children: outcomeLabel(record.outcome)
1382
- })
1383
- ]
1384
- }),
1385
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1386
- className: "dh-record-command",
1387
- title: record.command,
1388
- children: record.command
1389
- }),
1390
- record.error !== void 0 && record.error !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1391
- className: "dh-record-error",
1392
- children: record.error.slice(0, 200)
1393
- })
1394
- ]
1395
- })
1396
- }, `${record.ts}-${index}`))
1397
- }))] })
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
+ ] })
1398
1628
  ]
1399
1629
  });
1400
1630
  }
@@ -1413,7 +1643,7 @@ window.__ModuleLoader__.load({
1413
1643
  }
1414
1644
  //#endregion
1415
1645
  //#region src/client/settings-card.module.css?inline
1416
- 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";
1417
1647
  //#endregion
1418
1648
  //#region src/client/index.ts
1419
1649
  const name = "dsh-hooks";
package/lib/config.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Hookable event kinds. v1 is emit-only: no waterfall/interception events. */
2
- export declare const HOOK_EVENTS: readonly ['turn/start', 'turn/end', 'tree/settled', 'step/end', 'tool/call', 'tool/result', 'user/message', 'approval/asked', 'approval/decided', 'session/title', 'session/created', 'session/disposed', 'agent/created', 'agent/disposed', 'agent/error', 'agent/status', 'hook/failed'];
2
+ export declare const HOOK_EVENTS: readonly ['turn/start', 'turn/end', 'tree/settled', 'step/end', 'tool/call', 'tool/result', 'user/message', 'approval/asked', 'approval/decided', 'session/title', 'session/created', 'session/disposed', 'agent/created', 'agent/disposed', 'agent/error', 'agent/status', 'hook/failed', 'usage/daily'];
3
3
  export type HookEvent = (typeof HOOK_EVENTS)[number];
4
4
  /** `turn/end` reason kinds (from @deepseek-ai/dsh-session TurnEndReasonMap). */
5
5
  export declare const TURN_END_REASONS: readonly ['completed', 'error', 'aborted', 'blocked', 'max-tokens', 'interrupted'];
@@ -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
@@ -18,6 +18,7 @@ export const HOOK_EVENTS = [
18
18
  'agent/error',
19
19
  'agent/status',
20
20
  'hook/failed',
21
+ 'usage/daily',
21
22
  ];
22
23
  /** `turn/end` reason kinds (from @deepseek-ai/dsh-session TurnEndReasonMap). */
23
24
  export const TURN_END_REASONS = [
@@ -34,7 +35,7 @@ export const TURN_END_REASONS = [
34
35
  // declaration self-contained.
35
36
  export const Config = Schema.object({
36
37
  hooks: Schema.array(Schema.object({
37
- on: Schema.union([...HOOK_EVENTS]).description('触发事件:turn/start | turn/end | tree/settled | step/end | tool/call | tool/result | user/message | approval/asked | approval/decided | session/title | session/created | session/disposed | agent/created | agent/disposed | agent/error | agent/status | hook/failed'),
38
+ on: Schema.union([...HOOK_EVENTS]).description('触发事件:turn/start | turn/end | tree/settled | step/end | tool/call | tool/result | user/message | approval/asked | approval/decided | session/title | session/created | session/disposed | agent/created | agent/disposed | agent/error | agent/status | hook/failed | usage/daily'),
38
39
  when: Schema.union([...TURN_END_REASONS]).description('可选过滤:对 turn/end 匹配结束原因(completed/error/aborted/blocked/max-tokens/interrupted);其他事件忽略该字段'),
39
40
  match: Schema.dict(Schema.union([
40
41
  Schema.regExp(),
@@ -63,7 +64,7 @@ export const Config = Schema.object({
63
64
  .default('env')
64
65
  .description('上下文传递方式:env 只传 DSH_HOOK_* 环境变量(默认);stdin 额外把完整上下文 JSON 写入命令标准输入'),
65
66
  timeoutMs: Schema.number().default(10000).description('单次执行超时(毫秒)'),
66
- 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 渠道不重试)'),
67
68
  retryDelayMs: Schema.natural().default(500).description('重试基础间隔(毫秒),每次翻倍'),
68
69
  enabled: Schema.boolean()
69
70
  .default(true)
package/lib/context.d.ts CHANGED
@@ -35,7 +35,7 @@ export interface HookContext {
35
35
  * pairing tool/call was never seen, e.g. after a plugin restart).
36
36
  */
37
37
  toolDurationMs?: number;
38
- /** Aggregated token usage of the turn (turn/end), when reported. */
38
+ /** Aggregated token usage of the turn (turn/end), or of the day (usage/daily). */
39
39
  usageInputTokens?: number;
40
40
  usageOutputTokens?: number;
41
41
  usageCacheReadTokens?: number;
@@ -69,6 +69,15 @@ export interface HookContext {
69
69
  hookFailedHook?: string;
70
70
  /** Consecutive failure count when the alert fired (hook/failed). */
71
71
  hookFailures?: number;
72
+ /**
73
+ * Local calendar day (`YYYY-MM-DD`) the token totals below cover
74
+ * (usage/daily only; turn/end carries a single turn, not a day).
75
+ */
76
+ usageDay?: string;
77
+ /** Turns with reported accounting that day (usage/daily). */
78
+ usageTurns?: number;
79
+ /** Distinct sessions that contributed usage that day (usage/daily). */
80
+ usageSessions?: number;
72
81
  timestamp: string;
73
82
  }
74
83
  export declare function toEnv(ctx: HookContext): Record<string, string>;
package/lib/context.js CHANGED
@@ -71,6 +71,12 @@ export function toEnv(ctx) {
71
71
  env.DSH_HOOK_FAILED_HOOK = ctx.hookFailedHook;
72
72
  if (ctx.hookFailures !== undefined)
73
73
  env.DSH_HOOK_FAILURES = String(ctx.hookFailures);
74
+ if (ctx.usageDay !== undefined)
75
+ env.DSH_HOOK_USAGE_DAY = ctx.usageDay;
76
+ if (ctx.usageTurns !== undefined)
77
+ env.DSH_HOOK_USAGE_TURNS = String(ctx.usageTurns);
78
+ if (ctx.usageSessions !== undefined)
79
+ env.DSH_HOOK_USAGE_SESSIONS = String(ctx.usageSessions);
74
80
  return env;
75
81
  }
76
82
  /** Render `{{DSH_HOOK_*}}` placeholders from the context map. */
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;