dsh-harmonyos-hiboard 0.7.1 → 0.8.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 CHANGED
@@ -223,8 +223,34 @@ L2 抢救"承诺了却没调用"、L3 按错误类别重试并自动补 `schedul
223
223
  - `retry-ok`:工具真的调用了但失败,按错误类别重试后成功
224
224
  - `rescue-ok`:根本没调用工具(写成纯文本 / 只承诺没做),兜底代为补推
225
225
 
226
+ **关掉兜底后面板不会消失**,而是改显示「**漏掉未推送 N 次**」——
227
+ 兜底关闭期间它不再补推,但**继续记账**(记成 `missed`):本该补推的(写成纯文本 /
228
+ 只承诺没调用)以及调用失败却没重试的,都会被记下来。
229
+ 关掉它的代价应该看得见,而不是等你发现卡片少了几张。
230
+
226
231
  这个面板的意义是:以前"保险丝到底有没有干活"只能靠猜,现在直接列给你看。
227
232
 
233
+ 4. **常驻推送规则**(默认开启)—— **保存授权码时,会把「每轮回答都要推送」自动写进
234
+ 全局指令文件 `$DSH_HOME/AGENTS.md`**,这样「一键安装 + 粘一次授权码」之后就不必再
235
+ 手改任何文件,新会话立刻按规则推送。
236
+
237
+ > ⚠️ **这是本插件唯一会改动你已有文件的行为,且明确声明在此。** 具体约束:
238
+ > - 规则被 **BEGIN/END 标记**包裹,插件**只改标记之间那一段**,标记之外一个字节都不动;
239
+ > - **幂等**:重复保存不会产生第二份规则;
240
+ > - **首次改动前自动备份** `AGENTS.md.bak-hiboard-<时间戳>`(只备份一次);
241
+ > - **检测到你已手写过推送规则就完全跳过**,并如实提示,而不是硬塞第二份;
242
+ > - 卡片上那颗开关可以**一键移除**该段(其余内容原样保留),关掉后再保存授权码也不会重新写入。
243
+
244
+ **为什么必须写这个文件**:dsh 的 `dsh-agent-instructions` 每个会话都加载
245
+ `$DSH_HOME/AGENTS.md`,而且**只认这一个全局文件** —— `.local` 叠加(`AGENTS.local.md`)
246
+ 只在**项目目录**的祖先链里生效,全局那段代码写死了只探测 `AGENTS.md`。
247
+ (`agent/pre-step` 注入要拼宿主内部消息形状,在预览版上太脆,所以没有采用。)
248
+
249
+ > **授权码仍然必须手动粘一次**,这一点无法自动化:它是你在手机上
250
+ > 「负一屏 → 我的 → 动态管理 → 关联账号 → Claw 智能体」取到的**个人凭据**,
251
+ > 任何插件都不可能凭空得到它。所以准确说法是:安装后规则自动就位,
252
+ > 你只需**在卡片里粘一次授权码**。
253
+
228
254
  配置写在 `<用户目录>/.dsh/hiboard-push/config.json`(0600,先写临时文件再原子替换)。
229
255
  该文件属于当前系统用户,本机所有 dsh profile 共用一份。
230
256
 
@@ -383,7 +409,7 @@ npm test
383
409
  |---|---|---|
384
410
  | 1 | 装插件:`dsh plugin --profile web add dsh-harmonyos-hiboard` | 没有工具 |
385
411
  | 2 | 配授权码(设置卡片,或 profile 补丁层) | 调用时报「未配置授权码」 |
386
- | 3 | **把推送规则写进 `~/.dsh/AGENTS.md`** | 功能在,但**没人调用它** —— 每轮都不会推 |
412
+ | 3 | 保存授权码 —— 插件会把推送规则**自动**写进 `~/.dsh/AGENTS.md`(默认开启、可一键关掉) | 功能在,但**没人调用它** —— 每轮都不会推 |
387
413
  | 4 | 重启 dsh | 新装/升级的插件不生效(升级不会热替换已挂载插件) |
388
414
 
389
415
  第 3 步的现成模板见 **[`docs/AGENTS-rule.md`](docs/AGENTS-rule.md)**:dsh 由
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-harmonyos-hiboard",
3
- "version": "0.7.1",
4
- "description": "把DSH的任务结果,以服务卡片形式推送到华为鸿蒙负一屏\nPush DSH task results to the Huawei HarmonyOS assistant-today screen as service cards.",
3
+ "version": "0.8.0",
4
+ "description": "把DSH的任务结果,以服务卡片形式推送到华为鸿蒙负一屏 | Push DSH task results to the Huawei HarmonyOS assistant-today screen as service cards.",
5
5
  "author": "Jim Chen",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -0,0 +1,145 @@
1
+ /**
2
+ * 常驻推送规则:把「每轮回答都要推送」写进 dsh 的全局指令文件。
3
+ *
4
+ * ## 为什么必须动这个文件
5
+ *
6
+ * dsh 的 `dsh-agent-instructions` 在每个会话自动加载 `$DSH_HOME/AGENTS.md`。
7
+ * 它**只认这一个全局文件** —— `.local` 叠加(`AGENTS.local.md`)只在**项目目录**的
8
+ * 祖先链里生效,全局那段代码写死了只探测 `AGENTS.md`(见 dsh-agent-instructions
9
+ * 的 `discoverInstructionFiles`)。所以想让规则对所有会话、所有项目生效,
10
+ * 除了写这个文件没有第二条路(`agent/pre-step` 注入要拼宿主内部消息形状,预览版上太脆)。
11
+ *
12
+ * ## 安全约定(每一条都是必须的)
13
+ *
14
+ * 1. **只改自己那一段**:规则块由 BEGIN/END 标记包裹,标记之外一个字节都不碰。
15
+ * 2. **幂等**:标记已存在就原地替换;重复调用不会产生第二份规则。
16
+ * 3. **不覆盖用户手写的规则**:文件里已有等价规则(非标记块)时**直接跳过**,
17
+ * 并如实报告,而不是硬塞一份重复的。
18
+ * 4. **首次改动前备份**:`AGENTS.md.bak-hiboard-<时间戳>`,且只备份一次,不刷屏。
19
+ * 5. **可一键移除**:`removeRule()` 把标记块整段删掉,其余内容原样保留。
20
+ */
21
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, readdirSync } from "node:fs";
22
+ import { basename, dirname } from "node:path";
23
+
24
+ /** 规则块的起止标记。改这里等于改契约,不要动。 */
25
+ export const RULE_BEGIN = "<!-- BEGIN dsh-harmonyos-hiboard (managed block; the plugin rewrites this region) -->";
26
+ export const RULE_END = "<!-- END dsh-harmonyos-hiboard -->";
27
+
28
+ /** 规则块正文。中英并列:dsh 的全局文件对所有会话生效,读者可能是任意语言。 */
29
+ export function buildRuleBlock() {
30
+ return [
31
+ RULE_BEGIN,
32
+ "## 强制要求:每轮回答都要推送到手机负一屏",
33
+ "",
34
+ "1. 给出最终回答时,调用 `hiboard_push` 推送本轮的详细结论。",
35
+ "2. **必须传 `schedule_id`**(例如 `dsh_worklog`)—— 它为空时卡片只有一行标题、正文不渲染、也点不开。",
36
+ "3. `content` 用 Markdown,**上限 5000 字符**;超长就推精简版,并在开头注明被截断,完整内容留在对话里。",
37
+ "4. `result` 是状态标签,默认「任务已完成」。",
38
+ "5. 本轮确实没有实质输出时,也推一张简短卡片,不要跳过。",
39
+ "6. 推送失败要写明原因(未配置授权码 / 接口不通),不要静默略过。",
40
+ "",
41
+ "> Every turn must push: call `hiboard_push` with a non-empty `schedule_id`; Markdown body is",
42
+ "> capped at 5000 characters; push a short card even on a thin turn; never fail silently.",
43
+ "",
44
+ "(本段由 dsh-harmonyos-hiboard 维护,可在插件的设置卡片里一键关闭与移除。)",
45
+ RULE_END
46
+ ].join("\n");
47
+ }
48
+
49
+ /** 判定文件里是否已存在「等价规则」(非本插件标记块)。 */
50
+ export function looksLikePushRule(text) {
51
+ if (!/hiboard_push/.test(text)) return false;
52
+ return /(每轮|每一轮|每次|每一条|必须)[^\n]{0,24}(推|发送|推送)/.test(text) || /every\s+(turn|reply|response)/i.test(text);
53
+ }
54
+
55
+ function readText(file) {
56
+ try {
57
+ return readFileSync(file, "utf8");
58
+ } catch {
59
+ return "";
60
+ }
61
+ }
62
+
63
+ function hasBackup(file) {
64
+ try {
65
+ return readdirSync(dirname(file)).some((name) => name.startsWith(`${basename(file)}.bak-hiboard-`));
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ /** 原子写入;`backup` 为真且尚无备份时,先留一份原件。 */
72
+ function writeText(file, text, { backup = false } = {}) {
73
+ mkdirSync(dirname(file), { recursive: true });
74
+ if (backup && existsSync(file) && !hasBackup(file)) {
75
+ try {
76
+ copyFileSync(file, `${file}.bak-hiboard-${new Date().toISOString().replace(/[-:T]/g, "").slice(0, 15)}`);
77
+ } catch {
78
+ /* 备份失败不阻止写入:用户要的是规则生效,不是备份成功 */
79
+ }
80
+ }
81
+ const tmp = `${file}.${process.pid}.tmp`;
82
+ writeFileSync(tmp, text, "utf8");
83
+ renameSync(tmp, file);
84
+ }
85
+
86
+ /** 用新块替换旧块(保留前后一切内容)。 */
87
+ function replaceBlock(text, block) {
88
+ const start = text.indexOf(RULE_BEGIN);
89
+ const end = text.indexOf(RULE_END);
90
+ if (start < 0 || end < 0 || end < start) return text;
91
+ return `${text.slice(0, start)}${block}${text.slice(end + RULE_END.length)}`;
92
+ }
93
+
94
+ /** 状态:文件在哪、有没有我们的块、有没有用户自己写的等价规则。 */
95
+ export function ruleStatus(file) {
96
+ const text = readText(file);
97
+ const hasBlock = text.includes(RULE_BEGIN) && text.includes(RULE_END);
98
+ return {
99
+ file,
100
+ exists: existsSync(file),
101
+ block: hasBlock,
102
+ customRule: !hasBlock && looksLikePushRule(text)
103
+ };
104
+ }
105
+
106
+ /**
107
+ * 确保规则就位(幂等)。
108
+ * @returns {{action: 'added'|'updated'|'unchanged'|'skipped-custom', status: object}}
109
+ */
110
+ export function ensureRule(file) {
111
+ const block = buildRuleBlock();
112
+ const before = readText(file);
113
+ const hasBlock = before.includes(RULE_BEGIN) && before.includes(RULE_END);
114
+
115
+ if (hasBlock) {
116
+ const next = replaceBlock(before, block);
117
+ if (next === before) return { action: "unchanged", status: ruleStatus(file) };
118
+ writeText(file, next);
119
+ return { action: "updated", status: ruleStatus(file) };
120
+ }
121
+
122
+ // 用户自己已经写过等价规则 → 不硬塞第二份
123
+ if (looksLikePushRule(before)) return { action: "skipped-custom", status: ruleStatus(file) };
124
+
125
+ const body = before.replace(/\s*$/, "");
126
+ const next = body ? `${body}\n\n${block}\n` : `${block}\n`;
127
+ writeText(file, next, { backup: true });
128
+ return { action: "added", status: ruleStatus(file) };
129
+ }
130
+
131
+ /**
132
+ * 移除规则块(只删标记之间那一段)。
133
+ * @returns {{action: 'removed'|'absent', status: object}}
134
+ */
135
+ export function removeRule(file) {
136
+ const before = readText(file);
137
+ if (!before.includes(RULE_BEGIN)) return { action: "absent", status: ruleStatus(file) };
138
+ const start = before.indexOf(RULE_BEGIN);
139
+ const end = before.indexOf(RULE_END);
140
+ let next = `${before.slice(0, start)}${before.slice(end + RULE_END.length)}`;
141
+ // 清掉移除后留下的空行堆,但不动其它内容
142
+ next = next.replace(/\n{3,}/g, "\n\n").replace(/^\s*\n/, "");
143
+ writeText(file, next);
144
+ return { action: "removed", status: ruleStatus(file) };
145
+ }
package/src/lib/client.js CHANGED
@@ -95,14 +95,25 @@ window.__ModuleLoader__.load({
95
95
  guardBudget: function (g) {
96
96
  return '重试预算:确定未送达 ' + g.retryCount + ' 次 / 可能已送达 ' + g.ambiguousRetryCount + ' 次 · schedule_id 兜底:' + g.defaultScheduleId;
97
97
  },
98
+ ruleTitle: '常驻推送规则',
99
+ ruleOn: '已启用',
100
+ ruleOff: '未启用',
101
+ ruleHint: '保存授权码时,会自动把「每轮都要推送」写进全局指令文件 AGENTS.md(带标记、可随时移除),新会话即生效。',
102
+ ruleCustom: '检测到你已手写过推送规则 —— 插件不会改动你的 AGENTS.md。',
103
+ ruleBlockPresent: 'AGENTS.md 里已有本插件维护的规则段(带标记;关掉即可移除)。',
98
104
  debugTitle: '兜底战绩',
99
105
  debugSummary: function (c) {
100
106
  return '已救回 ' + (c.retryOk + c.rescueOk) + ' 次(重试 ' + c.retryOk + ' · 补推 ' + c.rescueOk + ')';
101
107
  },
102
108
  debugEmpty: '还没有需要兜底的记录 —— 每次推送都成功调用了工具。',
103
109
  debugNone: '兜底还没有运行记录。',
110
+ debugMissedSummary: function (c) {
111
+ return '兜底已关闭 —— 期间漏掉未推送 ' + c.missed + ' 次';
112
+ },
113
+ debugMissedEmpty: '兜底关闭期间还没有漏推记录。',
104
114
  kindRetry: '重试',
105
115
  kindRescue: '补推',
116
+ kindMissed: '漏推',
106
117
  loadFailed: '读取配置失败',
107
118
  saveFailed: '保存失败',
108
119
  unavailable: '设置路由不可用(宿主未提供)。'
@@ -133,14 +144,25 @@ window.__ModuleLoader__.load({
133
144
  guardBudget: function (g) {
134
145
  return 'Retry budget: definitely-undelivered ' + g.retryCount + ' / possibly-delivered ' + g.ambiguousRetryCount + ' · schedule_id fallback: ' + g.defaultScheduleId;
135
146
  },
147
+ ruleTitle: 'Always-push rule',
148
+ ruleOn: 'enabled',
149
+ ruleOff: 'disabled',
150
+ ruleHint: 'Saving the authCode also writes an "every turn must push" rule into your global instruction file AGENTS.md (marker-delimited, removable); new sessions pick it up.',
151
+ ruleCustom: 'You already wrote a push rule by hand, so this plugin leaves AGENTS.md untouched.',
152
+ ruleBlockPresent: "AGENTS.md already carries this plugin's rule block (marker-delimited; switch this off to remove it).",
136
153
  debugTitle: 'Rescues so far',
137
154
  debugSummary: function (c) {
138
155
  return 'Saved ' + (c.retryOk + c.rescueOk) + ' (retries ' + c.retryOk + ', pushes ' + c.rescueOk + ')';
139
156
  },
140
157
  debugEmpty: 'Nothing needed rescuing yet — every push called the tool successfully.',
141
158
  debugNone: 'The safety net has no run records yet.',
159
+ debugMissedSummary: function (c) {
160
+ return 'Safety net is off — ' + c.missed + ' pushes have gone missing since';
161
+ },
162
+ debugMissedEmpty: 'Nothing has gone missing while the net was off.',
142
163
  kindRetry: 'retry',
143
164
  kindRescue: 'rescue',
165
+ kindMissed: 'missed',
144
166
  loadFailed: 'Could not load the configuration',
145
167
  saveFailed: 'Could not save',
146
168
  unavailable: 'The settings route is unavailable (host did not provide it).'
@@ -254,6 +276,7 @@ window.__ModuleLoader__.load({
254
276
  var info = summary.data && summary.data.authCode;
255
277
  var guard = summary.data && summary.data.guard;
256
278
  var audit = summary.data && summary.data.audit;
279
+ var rule = summary.data && summary.data.rule;
257
280
 
258
281
  var onSave = function () {
259
282
  if (!draft.trim()) { setNote(t.emptyErr); return; }
@@ -277,6 +300,14 @@ window.__ModuleLoader__.load({
277
300
  setNote((data.notes && data.notes[0]) || '');
278
301
  });
279
302
  };
303
+ var onToggleRule = function (next) {
304
+ setBusy('rule');
305
+ setNote('');
306
+ post({ ruleEnabled: next }).then(function (data) {
307
+ setBusy('');
308
+ if (data && data.notes && data.notes[0]) setNote(data.notes[0]);
309
+ });
310
+ };
280
311
  var onToggleGuard = function (next) {
281
312
  setBusy('guard');
282
313
  setNote('');
@@ -323,26 +354,48 @@ window.__ModuleLoader__.load({
323
354
  preview = h('div', { style: { marginTop: '6px', fontSize: '12px', color: V.danger } }, t.invalid);
324
355
  }
325
356
 
326
- // ---- 兜底战绩:把「原本没推出去、但兜底救回来了」摆出来 ----
357
+ // ---- 战绩面板 ----
358
+ // 兜底**开着**:显示「救回了几次」。
359
+ // 兜底**关掉**:面板不消失,改显示「漏掉了几张」—— 关掉它的代价要看得见,
360
+ // 否则用户只会觉得"关了就关了",直到发现卡片少了几张。
361
+ var guardOn = Boolean(guard && guard.enabled);
362
+ var itemRows = function (items, kindOf) {
363
+ return (items || []).map(function (item, index) {
364
+ return h(
365
+ 'div',
366
+ { key: String(index), style: { fontSize: '12px', lineHeight: '1.7', color: V.sub } },
367
+ '· ' + shortTime(item.at) + ' [' + kindOf(item) + '] ' + (item.label || '-') + ' — ' + item.detail
368
+ );
369
+ });
370
+ };
371
+ var boldLine = function (text) {
372
+ return h('div', { style: { fontSize: '12px', fontWeight: 600, marginBottom: '4px' } }, text);
373
+ };
327
374
  var debugBody;
328
375
  if (!audit) {
329
376
  debugBody = muted(t.debugNone);
330
- } else if (!audit.exists || audit.counts.retryOk + audit.counts.rescueOk === 0) {
331
- debugBody = muted(t.debugEmpty);
377
+ } else if (guardOn) {
378
+ debugBody =
379
+ !audit.exists || audit.counts.retryOk + audit.counts.rescueOk === 0
380
+ ? muted(t.debugEmpty)
381
+ : h(
382
+ 'div',
383
+ null,
384
+ boldLine(t.debugSummary(audit.counts)),
385
+ itemRows(audit.recent, function (item) {
386
+ return item.kind === 'retry-ok' ? t.kindRetry : t.kindRescue;
387
+ })
388
+ );
332
389
  } else {
333
- debugBody = h(
334
- 'div',
335
- null,
336
- h('div', { style: { fontSize: '12px', fontWeight: 600, marginBottom: '4px' } }, t.debugSummary(audit.counts)),
337
- (audit.recent || []).map(function (item, index) {
338
- return h(
339
- 'div',
340
- { key: String(index), style: { fontSize: '12px', lineHeight: '1.7', color: V.sub } },
341
- '· ' + shortTime(item.at) + ' [' + (item.kind === 'retry-ok' ? t.kindRetry : t.kindRescue) + '] ' +
342
- (item.label || '-') + ' — ' + item.detail
343
- );
344
- })
345
- );
390
+ debugBody =
391
+ !audit.exists || audit.counts.missed === 0
392
+ ? muted(t.debugMissedEmpty)
393
+ : h(
394
+ 'div',
395
+ null,
396
+ boldLine(t.debugMissedSummary(audit.counts)),
397
+ itemRows(audit.missedRecent, function () { return t.kindMissed; })
398
+ );
346
399
  }
347
400
 
348
401
  return h(
@@ -429,6 +482,26 @@ window.__ModuleLoader__.load({
429
482
  ),
430
483
  showDebug ? h('div', { style: { marginTop: '4px' } }, debugBody) : null
431
484
  )
485
+ ),
486
+
487
+ // ---- 常驻推送规则:保存授权码时自动写进全局指令文件 ----
488
+ h(
489
+ 'div',
490
+ { style: { marginTop: '14px', paddingTop: '12px', borderTop: '1px solid ' + V.border } },
491
+ row([
492
+ h('span', { style: { fontWeight: 600 } }, t.ruleTitle),
493
+ h('span', { style: { color: V.sub } }, rule && rule.enabled ? t.ruleOn : t.ruleOff),
494
+ h(Switch, {
495
+ checked: Boolean(rule && rule.enabled),
496
+ onChange: onToggleRule,
497
+ label: t.ruleTitle,
498
+ disabled: busy === 'rule' || !rule
499
+ })
500
+ ]),
501
+ muted(
502
+ rule && rule.customRule ? t.ruleCustom : rule && rule.block ? t.ruleBlockPresent : t.ruleHint,
503
+ { marginTop: '6px' }
504
+ )
432
505
  )
433
506
  );
434
507
  };
package/src/lib/index.js CHANGED
@@ -34,6 +34,7 @@ import { join } from "node:path";
34
34
  import { defineTool } from "@deepseek-ai/dsh-tools";
35
35
  import z from "@deepseek-ai/schemastery";
36
36
  import { maskAuthCode, parseAuthCodeInput } from "./authcode.js";
37
+ import { ensureRule, removeRule, ruleStatus } from "./agents-rule.js";
37
38
  import { configPath, readStore, writeStore } from "./config-store.js";
38
39
  import { apply as applyPushGuard } from "./push-guard.js";
39
40
 
@@ -74,6 +75,22 @@ const GUARD_STATE_DIR = join(homedir(), ".dsh", "lan", "push-guard");
74
75
  /** 调试图最多回溯多少条审计(文件本身有大小上限,读全量没必要)。 */
75
76
  const AUDIT_MAX_LINES = 4000;
76
77
 
78
+ /**
79
+ * dsh 的全局指令文件。`dsh-agent-instructions` 每个会话都加载它 ——
80
+ * 而且**只认这一个全局文件**:`.local` 叠加(`AGENTS.local.md`)只在**项目目录**的
81
+ * 祖先链里生效,全局那段代码写死了只探测 `AGENTS.md`。所以「每轮都推」这条规则
82
+ * 想对所有会话、所有项目生效,只能写在这里。
83
+ */
84
+ const AGENTS_FILE = join(process.env.DSH_HOME || join(homedir(), ".dsh"), "AGENTS.md");
85
+
86
+ /** 规则写入的结果 → 卡片上的提示语。 */
87
+ const RULE_NOTE = {
88
+ added: "已把「每轮都要推送」写进全局指令文件 AGENTS.md,新会话即生效。",
89
+ updated: "已更新 AGENTS.md 里的推送规则。",
90
+ unchanged: "AGENTS.md 里的推送规则已是最新。",
91
+ "skipped-custom": "检测到你已手写过推送规则,未改动 AGENTS.md。"
92
+ };
93
+
77
94
  /** Success codes accepted by the official client (string or numeric zero). */
78
95
  const SUCCESS_CODES = new Set(["0000000000", "0"]);
79
96
 
@@ -118,7 +135,14 @@ const Config = z.object({
118
135
  /** hiboard_push 没带有效 schedule_id 时自动补的值(为空则卡片点不开)。 */
119
136
  defaultScheduleId: z.string().default("dsh_worklog"),
120
137
  /** 设置页路由开关;关掉后 `/hiboard-push/config` 不注册,浏览器半边也停手。 */
121
- settingsCard: z.boolean().default(true)
138
+ settingsCard: z.boolean().default(true),
139
+ /**
140
+ * 保存授权码时,是否顺带把「每轮都要推送」的规则写进全局指令文件
141
+ * (`$DSH_HOME/AGENTS.md` 里的一个标记块)。**默认开启** —— 这样一键安装后
142
+ * 只要粘一次授权码,规则就自动就位,不必再手改 AGENTS.md。
143
+ * 关掉它(卡片上的按钮)会把已写入的那一段一起移除。
144
+ */
145
+ autoRule: z.boolean().default(true)
122
146
  });
123
147
 
124
148
  const entryDefaults = {
@@ -136,7 +160,8 @@ const entryDefaults = {
136
160
  guardAmbiguousRetryCount: 1,
137
161
  guardRetryDelayMs: 3000,
138
162
  defaultScheduleId: "dsh_worklog",
139
- settingsCard: true
163
+ settingsCard: true,
164
+ autoRule: true
140
165
  };
141
166
 
142
167
  // ---------------------------------------------------------------- payload
@@ -446,7 +471,7 @@ function buildConfigRoute({ authCodeInfo, settings, storeFile }) {
446
471
  */
447
472
  const auditSummary = () => {
448
473
  const file = join(GUARD_STATE_DIR, "audit.jsonl");
449
- const empty = { file, exists: false, counts: { retryOk: 0, rescueOk: 0, failed: 0 }, recent: [] };
474
+ const empty = { file, exists: false, counts: { retryOk: 0, rescueOk: 0, failed: 0, missed: 0 }, recent: [], missedRecent: [] };
450
475
  let text;
451
476
  try {
452
477
  text = readFileSync(file, "utf8");
@@ -455,8 +480,16 @@ function buildConfigRoute({ authCodeInfo, settings, storeFile }) {
455
480
  return empty;
456
481
  }
457
482
  const lines = text.split("\n").filter(Boolean).slice(-AUDIT_MAX_LINES);
458
- const counts = { retryOk: 0, rescueOk: 0, failed: 0 };
483
+ const counts = { retryOk: 0, rescueOk: 0, failed: 0, missed: 0 };
459
484
  const recent = [];
485
+ const missedRecent = [];
486
+ /** missed 的原因码 → 面板上的说法。 */
487
+ const MISSED_REASON = {
488
+ "literal-text-unrescued": "模型把调用写成了纯文本(兜底已关闭,未补推)",
489
+ "broken-promise-unrescued": "模型承诺了推送却没调用工具(兜底已关闭,未补推)",
490
+ "failed-retryable-unretried": "调用失败,本可重试(兜底已关闭,未重试)",
491
+ "failed-permanent": "调用永久失败(兜底不会重试这类错误)"
492
+ };
460
493
  for (const line of lines) {
461
494
  let row;
462
495
  try {
@@ -484,13 +517,24 @@ function buildConfigRoute({ authCodeInfo, settings, storeFile }) {
484
517
  label: row.name ?? "",
485
518
  detail: row.level === "L1" ? "模型把调用写成了纯文本" : "模型承诺了推送却没调用工具"
486
519
  });
520
+ } else if (row?.kind === "missed") {
521
+ // 兜底关闭期间"本该补推 / 本该重试"的记录:这是关掉它的代价,要看得到
522
+ counts.missed += 1;
523
+ missedRecent.push({
524
+ kind: "missed",
525
+ at: row.at ?? "",
526
+ level: row.level ?? "",
527
+ label: row.name ?? "",
528
+ detail: MISSED_REASON[row.reason] ?? String(row.reason ?? "未推送")
529
+ });
487
530
  } else if (row?.kind === "push-failed" || row?.kind === "rescue-failed" || row?.kind === "rescue-error") {
488
531
  counts.failed += 1;
489
532
  }
490
533
  }
491
534
  // 文件是追加写的,末尾最新;面板要「最近几次」,所以倒序后取前几条
492
535
  recent.reverse();
493
- return { file, exists: true, counts, recent: recent.slice(0, 5) };
536
+ missedRecent.reverse();
537
+ return { file, exists: true, counts, recent: recent.slice(0, 5), missedRecent: missedRecent.slice(0, 5) };
494
538
  };
495
539
 
496
540
  const summary = () => {
@@ -515,7 +559,9 @@ function buildConfigRoute({ authCodeInfo, settings, storeFile }) {
515
559
  endpoint: s.pushServiceUrl,
516
560
  storeFile,
517
561
  // 调试图:兜底到底把多少次「原本失败的推送」救了回来
518
- audit: auditSummary()
562
+ audit: auditSummary(),
563
+ // 常驻规则:是否开启、有没有已写入的块、有没有用户自己写的等价规则
564
+ rule: Object.assign(ruleStatus(AGENTS_FILE), { enabled: s.autoRule })
519
565
  };
520
566
  };
521
567
 
@@ -555,6 +601,10 @@ function buildConfigRoute({ authCodeInfo, settings, storeFile }) {
555
601
  const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
556
602
  const patch = {};
557
603
  const notes = [];
604
+ /** 本次请求是否真的写入了授权码(清除不算)——用它触发"顺带写规则"。 */
605
+ let authCodeTouched = false;
606
+ /** 卡片上的规则开关:true 写入 / false 移除 / undefined 不表态。 */
607
+ let ruleRequest;
558
608
 
559
609
  // ---- 授权码:完整指令或裸码都行;空串表示"清除覆盖" ----
560
610
  if (Object.prototype.hasOwnProperty.call(body, "authCode")) {
@@ -570,10 +620,17 @@ function buildConfigRoute({ authCodeInfo, settings, storeFile }) {
570
620
  return;
571
621
  }
572
622
  patch.authCode = parsed.code;
623
+ authCodeTouched = true;
573
624
  notes.push(`${parsed.note}(${maskAuthCode(parsed.code)})`);
574
625
  }
575
626
  }
576
627
 
628
+ // ---- 常驻规则开关 ----
629
+ if (Object.prototype.hasOwnProperty.call(body, "ruleEnabled")) {
630
+ patch.autoRule = body.ruleEnabled === true;
631
+ ruleRequest = body.ruleEnabled === true;
632
+ }
633
+
577
634
  // ---- 兜底开关:布尔直接落盘,立即生效 ----
578
635
  for (const key of ["guardEnabled", "guardRescueEnabled", "guardPromiseRescueEnabled", "guardRetryEnabled"]) {
579
636
  if (Object.prototype.hasOwnProperty.call(body, key)) patch[key] = body[key] === true;
@@ -588,6 +645,27 @@ function buildConfigRoute({ authCodeInfo, settings, storeFile }) {
588
645
  return;
589
646
  }
590
647
  writeStore(patch, storeFile);
648
+
649
+ // ---- 常规规则动作:放在落盘之后,读到的才是"最终"的 autoRule ----
650
+ // 保存授权码时**顺带**把「每轮都要推送」写进全局指令文件:用户此刻的意图
651
+ // 就是"让它开始工作",不该再要求他手改 AGENTS.md。默认开启,卡片可关。
652
+ try {
653
+ if (ruleRequest === false) {
654
+ const r = removeRule(AGENTS_FILE);
655
+ notes.push(r.action === "removed" ? "已移除全局指令文件里的推送规则。" : "全局推送规则本来就不存在,未改动。");
656
+ } else if (ruleRequest === true) {
657
+ const r = ensureRule(AGENTS_FILE);
658
+ notes.push(RULE_NOTE[r.action] ?? "已同步全局推送规则。");
659
+ } else if (authCodeTouched && settings().autoRule) {
660
+ const r = ensureRule(AGENTS_FILE);
661
+ // unchanged 不提示:每次保存都重复一句"已是最新"只是噪音
662
+ if (r.action !== "unchanged") notes.push(RULE_NOTE[r.action] ?? "已同步全局推送规则。");
663
+ }
664
+ } catch (error) {
665
+ // 规则写不进去不该让"授权码已保存"这件事看起来失败
666
+ notes.push(`授权码已保存,但写入全局规则失败:${String(error?.message ?? error)}`);
667
+ }
668
+
591
669
  send(200, { ...summary(), notes });
592
670
  } catch (error) {
593
671
  send(400, { error: String(error?.message ?? error) });
@@ -626,7 +704,8 @@ function apply(ctx, rawConfig) {
626
704
  guardAmbiguousRetryCount: Number(resolved.guardAmbiguousRetryCount) >= 0 ? Number(resolved.guardAmbiguousRetryCount) : 1,
627
705
  guardRetryDelayMs: Number(resolved.guardRetryDelayMs) > 0 ? Number(resolved.guardRetryDelayMs) : 3000,
628
706
  defaultScheduleId: String(resolved.defaultScheduleId ?? "").trim() || "dsh_worklog",
629
- settingsCard: resolved.settingsCard !== false
707
+ settingsCard: resolved.settingsCard !== false,
708
+ autoRule: resolved.autoRule !== false
630
709
  };
631
710
  };
632
711
 
@@ -546,8 +546,9 @@ export function apply(ctx, config) {
546
546
  ctx.effect(() => {
547
547
  const onEvent = (session, event) => {
548
548
  try {
549
- // 设置页关掉后,兜底立即停止(不必重启)
550
- if (guardOff()) return;
549
+ // 注意:**关掉兜底后这个处理器仍要继续跑**。它不再补推,但仍要记账
550
+ // (见下面的 missed)——「关掉期间漏掉多少张卡片」只有继续观察才数得出来,
551
+ // 否则设置页那一栏永远是空的。
551
552
  if (!event || typeof event !== "object") return;
552
553
  const sessionId = session?.id ?? "?";
553
554
 
@@ -584,6 +585,25 @@ export function apply(ctx, config) {
584
585
  }
585
586
  return;
586
587
  }
588
+
589
+ // 兜底被关掉时:**不补推,但照样记账**。
590
+ // 记成 missed 而不是 rescue-ok —— 设置页据此显示「关掉期间漏掉 N 张」。
591
+ // 关掉兜底的代价应该是看得见的,而不是悄悄少几张卡片。
592
+ if (guardOff()) {
593
+ audit({
594
+ kind: "missed",
595
+ level: card.level,
596
+ name: card.name,
597
+ sessionId,
598
+ turn: current.turn,
599
+ reason: card.level === "L1" ? "literal-text-unrescued" : "broken-promise-unrescued"
600
+ });
601
+ ctx.logger?.warn?.(
602
+ "[push-guard] ⚠️ %s 本该补推(会话 %s 第 %s 轮):%s —— 但兜底已关闭,仅记账未推送",
603
+ card.level, sessionId, current.turn ?? "?", card.name
604
+ );
605
+ return;
606
+ }
587
607
  const fingerprint = `${card.name}\n${card.content.length}\n${card.content.slice(0, 80)}`;
588
608
  if (lastRescued.get(sessionId) === fingerprint) return;
589
609
  lastRescued.set(sessionId, fingerprint);
@@ -639,8 +659,31 @@ export function apply(ctx, config) {
639
659
  const ambiguousAttempts = ambiguousBudget;
640
660
  const fallbackScheduleId = String(settings.defaultScheduleId || DEFAULTS.defaultScheduleId).trim() || DEFAULTS.defaultScheduleId;
641
661
  const wrapped = async (args, exec) => {
642
- // 设置页关掉后:不补参、不重试,按原样调用(关闭必须真的关闭)
643
- if (guardOff()) return original(args, exec);
662
+ // 设置页关掉后:不补参、不重试,按原样调用(关闭必须真的关闭)。
663
+ // 但**照样给失败记账**:调用失败了却没重试,就是"漏掉一张"。
664
+ if (guardOff()) {
665
+ const outcome = await original(args, exec);
666
+ try {
667
+ const verdict = classifyPush(outcome);
668
+ if (!verdict.ok) {
669
+ audit({
670
+ kind: "missed",
671
+ level: "L3",
672
+ name: String(args?.name ?? ""),
673
+ reason: verdict.retryable ? "failed-retryable-unretried" : "failed-permanent",
674
+ code: verdict.code ?? "",
675
+ message: String(verdict.message ?? "").slice(0, 160)
676
+ });
677
+ ctx.logger?.warn?.(
678
+ "[push-guard] ⚠️ hiboard_push 失败(%s,%s)—— 兜底已关闭,未重试,仅记账",
679
+ verdict.code || "-", verdict.retryable ? "本可重试" : "永久错误"
680
+ );
681
+ }
682
+ } catch {
683
+ /* 记账失败不影响工具本身的返回 */
684
+ }
685
+ return outcome;
686
+ }
644
687
  // ---- 先补 schedule_id,再让工具校验/发送 ----
645
688
  // 为什么必须补:scheduleTaskId 为空时负一屏**只显示一行标题**,
646
689
  // 正文不渲染、详情页点不开(2026-09-27 用户实际遇到)。