@cowwo/dsh-lookout 0.2.0 → 0.5.1

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.
@@ -1,19 +1,23 @@
1
1
  // dsh-notify-p · Host 半(入口)
2
2
  //
3
3
  // 一句话:会话干完活 / 出错 / 卡住 / 等你审批时,发一封邮件到你的邮箱,
4
- // 正文写清"是哪个会话",并附一条可点击直达该会话的链接。
4
+ // 正文写清"是哪个会话"(不带任何链接——"邮件直达会话"这条能力已整块删除)。
5
5
  //
6
6
  // 四条纪律(都来自对同类插件的调研,写在代码里免得以后改回去):
7
7
  // 1. aborted 必须按 cause 细分(重启/热更会以 disposed 结束回合,不是"你主动停止")
8
8
  // 2. 子代理会话默认跳过(一个 workflow 能扇出几十个子会话)
9
- // 3. 一个会话的连续多轮只提醒一次(turn/end 后等 settleMs,期间又开新轮就取消)
9
+ // 3. 一个会话的连续多轮只提醒一次(turn/end 后等 SETTLE_MS,期间又开新轮就取消)
10
10
  // 4. 每个订阅处理器都要自吞异常:cordis 的 emit 是 stop-on-throw,
11
11
  // 一个插件抛错会饿死排在后面的订阅者(含 DSH 自己的持久化/遥测)
12
- //
13
- // 本插件**不实现** `?session=` 的接收端:链接的识别由 dsh-deeplink(或 dsh-session-link)负责,
14
- // 这里只负责把链接拼进邮件正文。
15
12
 
16
- import { NAMESPACE, MailSchema, resolveConfig, parseRecipients, resolveBaseUrl, recipientIssues } from './config.js';
13
+ import {
14
+ resolveConfig,
15
+ parseRecipients,
16
+ recipientIssues,
17
+ SETTLE_MS,
18
+ MIN_DURATION_MS,
19
+ RATE_PER_MINUTE,
20
+ } from './config.js';
17
21
  import {
18
22
  OUTCOME,
19
23
  classifyTurnEnd,
@@ -26,19 +30,22 @@ import {
26
30
  import { renderMail } from './render.js';
27
31
  import { createSendQueue } from './queue.js';
28
32
  import { sendMail, MailSendError } from './send/index.js';
29
- import { createLedger } from './state.js';
30
33
  import { MailNotifyRemoteService } from './service.js';
31
- import { sendTestMail, toDeliveryView } from './test-send.js';
34
+ import { sendTestMail } from './test-send.js';
32
35
 
33
36
  export const name = 'dsh-notify-p';
34
37
 
35
- /** sessions:扫已有会话补标题;settings:配置命名空间(dsh-base 必挂)。 */
36
- export const inject = ['sessions', 'settings'];
38
+ /**
39
+ * sessions:扫已有会话补标题、订阅会话事件。
40
+ *
41
+ * 这里**不 inject settings**:配置不是从 settings 服务读的,而是 apply 收到的
42
+ * 入口 config(见下方 readConfig),凭据走 credentials 服务(可用时)或环境变量。
43
+ * 代码里没有任何 `ctx.settings` 调用,多挂一个服务只会让人误判依赖。
44
+ */
45
+ export const inject = ['sessions'];
37
46
 
38
47
  /** 去重表上限,防长跑进程无界增长。 */
39
48
  const SENT_LIMIT = 5000;
40
- /** 最近投递结果保留条数(目前只进日志,第二期可挂到卡片上)。 */
41
- const RECENT_LIMIT = 20;
42
49
 
43
50
  /**
44
51
  * 插件入口。
@@ -46,7 +53,7 @@ const RECENT_LIMIT = 20;
46
53
  * 这里是最后一道保险:本插件的任何异常都不允许把 DSH 的启动带崩。
47
54
  * 真正的装配逻辑在 start() 里,外面只负责"兜住并降级"。
48
55
  * @param {import('@deepseek-ai/cordis').Context} ctx 插件上下文
49
- * @param {object} [entryConfig] cordis 行的 config(组合层默认值 = settings 的 base 层)
56
+ * @param {object} [entryConfig] cordis 行的 config(组合层 patch 里本插件 entry 的 config;缺省字段由 MailSchema 兜底)
50
57
  * @returns {void}
51
58
  */
52
59
  export function apply(ctx, entryConfig) {
@@ -69,25 +76,20 @@ export function apply(ctx, entryConfig) {
69
76
  */
70
77
  function start(ctx, entryConfig) {
71
78
  const logger = ctx.logger ?? console;
72
- const entry = resolveConfig(entryConfig);
73
79
 
74
- // ---- 配置:settings 命名空间(可选服务,缺了就退回组合层配置)----
75
- let source = () => entry;
76
- if (typeof ctx.settings?.installSection === 'function') {
77
- try {
78
- ctx.settings.installSection(ctx, NAMESPACE, MailSchema, entry, {
79
- setSource(current) {
80
- if (typeof current === 'function') source = current;
81
- },
82
- onChange() {
83
- // config() 每次发送都重读,这里无需额外动作
84
- },
85
- });
86
- } catch (error) {
87
- logger.warn?.(`dsh-notify-p: settings 命名空间注册失败,退回组合层配置 / settings registration failed: ${String(error)}`);
88
- }
89
- }
90
- const readConfig = () => source();
80
+ // ---- 配置:**每次读活的**,绝不在装配时快照 ----
81
+ //
82
+ // 设置页保存走 cordis-plugin-loader 的 `_commitVolatile`:它把新值**原地**写进
83
+ // apply 收到的那个 config 对象里的 volatile 引用,**不会重跑 apply**。
84
+ // 所以这里若写成 `const entry = resolveConfig(entryConfig)` 再反复用它,
85
+ // 表现就是「开关能点、也存住了,但插件行为一直不变,非要重启才生效」。
86
+ //
87
+ // 同理,这里也**不碰 settings 服务**:本插件不注册命名空间、不读 `ctx.settings`,
88
+ // 配置就是 apply 收到的这份入口 config(schema 由 loader 从入口的 `Config` 导出取,
89
+ // 见 lib/index.js)。所以 inject 里也不需要 settings——它缺席时本插件照常跑。
90
+ //
91
+ // 出参永远是裸值的普通对象:volatile 那层壳由 config.js 的 resolveConfig 拆掉。
92
+ const readConfig = () => resolveConfig(entryConfig);
91
93
 
92
94
  // ---- 运行态 ----
93
95
  /** @type {Map<string, string>} 会话 id → 标题 */
@@ -100,22 +102,13 @@ function start(ctx, entryConfig) {
100
102
  const agentErrors = new Map();
101
103
  /** @type {Set<string>} 已发过的去重键 */
102
104
  const sent = new Set();
103
- /** @type {object[]} 最近投递结果 */
104
- const recent = [];
105
105
 
106
106
  const queue = createSendQueue({
107
107
  logger,
108
- limitPerMinute: () => {
109
- const value = readConfig().ratePerMinute;
110
- return typeof value === 'number' && value > 0 ? value : 10;
111
- },
108
+ limitPerMinute: () => RATE_PER_MINUTE,
112
109
  });
113
110
 
114
- // 投递账本:插件日志默认不落 stdout(cordis 控制台 exporter 只输出 error 级),
115
- // 所以"到底发出去没有"要靠这个文件,`cat` 一下就知道了。
116
- const ledger = createLedger({ path: readConfig().statePath, logger });
117
-
118
- // ---- Remote 服务:设置页的「发送测试邮件」与「最近一次投递」----
111
+ // ---- Remote 服务:设置页的「发送测试邮件」----
119
112
  //
120
113
  // 走 Typert:清单在 lib/typert.host.js,由 dsh-typert-loader 在启动时扫描注册,
121
114
  // 浏览器侧通过 connection.rpc.call('/api', 'mailNotify/test') 调用。
@@ -124,14 +117,6 @@ function start(ctx, entryConfig) {
124
117
  ctx.plugin(MailNotifyRemoteService, {
125
118
  /** 立刻发一封测试邮件(不等合并窗口、不占限流额度)。 */
126
119
  test: async () => runTestDelivery(),
127
- /** 投递账本快照。 */
128
- state: () => {
129
- const snapshot = ledger.snapshot();
130
- return {
131
- counts: { sent: snapshot.counts?.sent ?? 0, failed: snapshot.counts?.failed ?? 0 },
132
- recent: (snapshot.recent ?? []).slice(-10).reverse().map(toDeliveryView),
133
- };
134
- },
135
120
  });
136
121
  } catch (error) {
137
122
  logger.warn?.(`dsh-notify-p: Remote 服务注册失败,「发送测试邮件」不可用 / remote service registration failed: ${String(error)}`);
@@ -140,21 +125,18 @@ function start(ctx, entryConfig) {
140
125
  // ---- 小工具 ----
141
126
 
142
127
  /**
143
- * 按当前配置真的发一封测试邮件,并把结果记进账本。
128
+ * 按当前配置真的发一封测试邮件。
144
129
  *
145
130
  * 实现放在 lib/test-send.js:设置页、命令行(test/send-test.mjs)与单元测试共用同一份,
146
- * 免得"设置页测通了、命令行测不通"。记账留在这一层——命令行不该动正在跑的进程的账本。
131
+ * 免得"设置页测通了、命令行测不通"。返回值仍是同一份扁平结果(toDeliveryView 的形状)。
147
132
  * @returns {Promise<object>} 扁平结果
148
133
  */
149
134
  async function runTestDelivery() {
150
- const result = await sendTestMail({
135
+ return sendTestMail({
151
136
  config: readConfig(),
152
137
  logger,
153
138
  resolveCredential,
154
- webUrl: detectWebUrl(),
155
139
  });
156
- recordResult(result);
157
- return result;
158
140
  }
159
141
 
160
142
  /**
@@ -180,18 +162,6 @@ function start(ctx, entryConfig) {
180
162
  return typeof env === 'string' && env !== '' ? env : undefined;
181
163
  }
182
164
 
183
- /**
184
- * 探测 Web GUI 地址,用于拼直达链接(用户显式配置的 publicBaseUrl 优先,见 resolveBaseUrl)。
185
- * @returns {string}
186
- */
187
- function detectWebUrl() {
188
- const env = process.env.DSH_WEB_URL;
189
- if (typeof env === 'string' && env !== '') return env;
190
- const webServer = typeof ctx.get === 'function' ? ctx.get('webServer') : undefined;
191
- if (webServer && typeof webServer.port === 'number') return `http://127.0.0.1:${webServer.port}`;
192
- return '';
193
- }
194
-
195
165
  /**
196
166
  * 读会话标题:优先标题服务,其次本地折叠表,最后空串(渲染层会降级成 ID 前 8 位)。
197
167
  * @param {object} session 会话
@@ -214,17 +184,6 @@ function start(ctx, entryConfig) {
214
184
  return '';
215
185
  }
216
186
 
217
- /**
218
- * 记录一条投递结果(内存环形缓冲 + 落盘账本)。
219
- * @param {object} record 结果
220
- * @returns {void}
221
- */
222
- function recordResult(record) {
223
- recent.push(record);
224
- while (recent.length > RECENT_LIMIT) recent.shift();
225
- ledger.record(record);
226
- }
227
-
228
187
  /**
229
188
  * 标记已发;返回 false 表示这条已经发过。
230
189
  * 错误类只看 `会话+轮次`(把 turn/end(error) 与 agent/error 折叠成一封)。
@@ -266,7 +225,6 @@ function start(ctx, entryConfig) {
266
225
  * @returns {void}
267
226
  */
268
227
  function schedule(session, payload) {
269
- const cfg = readConfig();
270
228
  const sessionId = session.id;
271
229
  cancelPending(sessionId, 'superseded');
272
230
  const enriched = {
@@ -282,7 +240,7 @@ function start(ctx, entryConfig) {
282
240
  } catch (error) {
283
241
  logger.warn?.(`dsh-notify-p: 提醒发送异常 / fire threw: ${String(error)}`);
284
242
  }
285
- }, Math.max(0, cfg.settleMs));
243
+ }, Math.max(0, SETTLE_MS));
286
244
  timer.unref?.();
287
245
  pending.set(sessionId, { timer, payload: enriched });
288
246
  }
@@ -300,29 +258,6 @@ function start(ctx, entryConfig) {
300
258
  queue.enqueue({
301
259
  label: `${payload.outcome} ${payload.sessionId}`,
302
260
  run: () => deliver(payload),
303
- onSuccess: (attempts, result) => recordResult({
304
- at: Date.now(),
305
- ok: true,
306
- outcome: payload.outcome,
307
- sessionId: payload.sessionId,
308
- subject: result?.subject,
309
- recipients: result?.recipients,
310
- transport: result?.transport,
311
- channels: result?.channels,
312
- attempts,
313
- }),
314
- onFail: (error, attempts) => recordResult({
315
- at: Date.now(),
316
- ok: false,
317
- outcome: payload.outcome,
318
- sessionId: payload.sessionId,
319
- attempts,
320
- errorCode: error?.code,
321
- error: error?.message ?? String(error),
322
- // 逐通道结果:账本里要看得出"哪个通道成了、哪个没成"
323
- transport: error?.transport,
324
- channels: error?.channels,
325
- }),
326
261
  });
327
262
  }
328
263
 
@@ -339,7 +274,7 @@ function start(ctx, entryConfig) {
339
274
  if (invalid.length > 0) {
340
275
  logger.warn?.(`dsh-notify-p: 忽略无法识别的收件人 / ignored invalid recipients: ${invalid.join(', ')}`);
341
276
  }
342
- // 收件人为空要抛出去:让队列把它记成一条 failed 落进账本,
277
+ // 收件人为空要抛出去:让队列把它记成一条失败并打出 warn,
343
278
  // 否则"没配收件人"会静默成功,用户永远不知道。
344
279
  const globalIssues = recipientIssues(valid.length);
345
280
  if (globalIssues.length > 0) {
@@ -359,8 +294,6 @@ function start(ctx, entryConfig) {
359
294
  durationMs: payload.durationMs,
360
295
  errorText: payload.detail,
361
296
  approval: payload.approval,
362
- config: cfg,
363
- baseUrl: resolveBaseUrl(cfg, detectWebUrl()),
364
297
  });
365
298
 
366
299
  // 发信:拿不到地址、缺主机/用户名之类的问题都在 sendMail 里先查一遍,
@@ -374,7 +307,7 @@ function start(ctx, entryConfig) {
374
307
 
375
308
  if (!summary.ok) {
376
309
  const error = new MailSendError(summary.error, { code: 'CHANNEL', retryable: summary.retryable });
377
- // 把逐通道结果挂在错误上:账本要留下"哪个通道成了、哪个没成",而不是一句合并后的黑箱。
310
+ // 把逐通道结果挂在错误上:排错时要看得出"哪个通道成了、哪个没成",而不是一句合并后的黑箱。
378
311
  error.channels = summary.channels;
379
312
  error.transport = summary.transport;
380
313
  throw error;
@@ -407,8 +340,8 @@ function start(ctx, entryConfig) {
407
340
  const durationMs = typeof startedAt === 'number' ? Math.max(0, endedAt - startedAt) : null;
408
341
 
409
342
  // 短任务不打扰只对"完成"生效:出错/卡住/审批永远值得打扰
410
- if (classified.outcome === OUTCOME.COMPLETED && durationMs !== null && durationMs < cfg.minDurationMs) {
411
- logger.debug?.(`dsh-notify-p: 任务过短不打扰 / short task skipped (${durationMs}ms < ${cfg.minDurationMs}ms)`);
343
+ if (classified.outcome === OUTCOME.COMPLETED && durationMs !== null && durationMs < MIN_DURATION_MS) {
344
+ logger.debug?.(`dsh-notify-p: 任务过短不打扰 / short task skipped (${durationMs}ms < ${MIN_DURATION_MS}ms)`);
412
345
  return;
413
346
  }
414
347
 
@@ -576,6 +509,8 @@ function start(ctx, entryConfig) {
576
509
  pending.clear();
577
510
  }, 'dsh-notify-p: pending timers');
578
511
 
579
- logger.info?.(`dsh-notify-p 已加载 / loaded(SMTP ${readConfig().smtpHost === '' ? '未配置' : readConfig().smtpHost},`
580
- + `收件人 ${parseRecipients(readConfig().recipients).valid.length} 个)`);
512
+ // 启动日志读一次就够(readConfig 每次都会重新解析,别在这里连读四遍)
513
+ const initial = readConfig();
514
+ logger.info?.(`dsh-notify-p 已加载 / loaded(SMTP ${initial.smtpHost === '' ? '未配置' : initial.smtpHost},`
515
+ + `收件人 ${parseRecipients(initial.recipients).valid.length} 个)`);
581
516
  }
@@ -1,10 +1,8 @@
1
1
  // dsh-notify-p · 邮件渲染层(纯函数,无 cordis / 无 IO,可直接单测)
2
2
  //
3
3
  // 一封邮件要回答的唯一问题:"是哪个会话、发生了什么、我该不该现在去看"。
4
- // 因此正文固定包含:事件、会话标题、工作区、时间、用时、会话 ID,
5
- // 以及一条可点击的直达链接(接收端由 dsh-deeplink / dsh-session-link 提供)。
6
-
7
- import { sessionLink } from './config.js';
4
+ // 因此正文固定包含:事件、会话标题、工作区、时间、用时、会话 ID。
5
+ // **不带任何链接**:"邮件直达会话"这条能力已整块删除,正文没有 URL,HTML 也没有按钮。
8
6
 
9
7
  /** 结局 → 中英并列文案。 */
10
8
  export const OUTCOME_LABEL = {
@@ -99,10 +97,8 @@ export function displayTitle(title, sessionId) {
99
97
  * @param {number} [input.durationMs] 用时
100
98
  * @param {string} [input.errorText] 失败原因
101
99
  * @param {{toolName?: string, reason?: string, command?: string}} [input.approval] 审批信息
102
- * @param {object} input.config 已解析配置
103
- * @param {string} [input.baseUrl] 链接基地址
104
100
  * @param {(cwd: string, sessionId: string) => string} [input.resolveWorkspace] 可选的 cwd→工作区名覆盖
105
- * @returns {{ subject: string, text: string, html: string, link: string, title: string }}
101
+ * @returns {{ subject: string, text: string, html: string, title: string }}
106
102
  */
107
103
  export function renderMail(input) {
108
104
  const {
@@ -115,8 +111,6 @@ export function renderMail(input) {
115
111
  durationMs,
116
112
  errorText,
117
113
  approval,
118
- config,
119
- baseUrl,
120
114
  resolveWorkspace,
121
115
  } = input;
122
116
 
@@ -128,7 +122,6 @@ export function renderMail(input) {
128
122
  : workspaceName(cwd ?? '');
129
123
  const duration = formatDuration(durationMs);
130
124
  const time = formatTime(typeof endedAt === 'number' ? endedAt : Date.now());
131
- const link = config?.linkEnabled === true ? sessionLink(baseUrl, sessionId) : '';
132
125
 
133
126
  const subject = `[DSH] ${subjectLabel} · ${title}`;
134
127
 
@@ -144,7 +137,6 @@ export function renderMail(input) {
144
137
  }
145
138
  if (typeof turn === 'number') rows.push(['轮次   / Turn', String(turn)]);
146
139
  rows.push(['会话 ID / Session ID', sessionId]);
147
- if (link !== '') rows.push(['直达会话 / Open session', link]);
148
140
 
149
141
  const lines = rows.map(([k, v]) => `${k}:${v}`);
150
142
  if (typeof errorText === 'string' && errorText !== '') {
@@ -182,17 +174,11 @@ export function renderMail(input) {
182
174
  + `<pre style="margin:0;padding:10px;background:#f6f6f6;border-radius:6px;white-space:pre-wrap;font-size:12px">${esc(detail)}</pre>`);
183
175
  }
184
176
  }
185
- const button = link === ''
186
- ? ''
187
- : `<p style="margin:20px 0 0"><a href="${esc(link)}" style="display:inline-block;padding:9px 16px;background:#1a56db;color:#fff;text-decoration:none;border-radius:6px;font-size:14px">打开该会话 / Open session</a></p>`
188
- + `<p style="margin:8px 0 0;color:#888;font-size:12px">${esc(link)}</p>`;
189
-
190
177
  const html = `<!DOCTYPE html><html><body style="margin:0;padding:16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'PingFang SC','Microsoft YaHei',sans-serif;font-size:14px;line-height:1.6;color:#111">`
191
178
  + `<table style="border-collapse:collapse">${htmlRows}</table>`
192
179
  + detailBlocks.join('')
193
- + button
194
180
  + `<p style="margin:20px 0 0;color:#999;font-size:12px">本邮件由 dsh-notify-p 自动发送 / sent by dsh-notify-p</p>`
195
181
  + `</body></html>`;
196
182
 
197
- return { subject, text, html, link, title };
183
+ return { subject, text, html, title };
198
184
  }
@@ -3,11 +3,11 @@
3
3
  // 只有 SMTP 一条通道:send({ from, to, subject, text, html }) → { ok, id }
4
4
  //
5
5
  // 这里曾经有「只记录」与 Resend 两条通道,已删除。结果形状里的 `transport` 与 `channels[]`
6
- // **刻意保留**:它同时是落盘账本的格式、typert 清单里声明的出参、以及设置页「最近一次发送」
7
- // 的渲染依据,删通道不需要动这个契约。
6
+ // **刻意保留**:它同时是 typert 清单里声明的出参、以及设置页「发送测试邮件」结果的
7
+ // 渲染依据,删通道不需要动这个契约。
8
8
 
9
9
  import { createSmtpSender, MailSendError } from './smtp.js';
10
- import { resolveSender, smtpIssues } from '../config.js';
10
+ import { DEFAULT_SENDER_NAME, smtpIssues } from '../config.js';
11
11
 
12
12
  export { MailSendError };
13
13
 
@@ -44,18 +44,15 @@ export async function createSender(config, deps) {
44
44
  /**
45
45
  * 拼发件人字符串。
46
46
  *
47
- * 地址优先用发件人清单里选中的那条;拿不到就退回 SMTP 用户名(这是老配置的行为);
48
- * 两者都没有就只给显示名。
47
+ * 发件人清单已整块删除,只剩一条规则:**地址就是 smtpUser**,显示名是固定常量
48
+ * (DEFAULT_SENDER_NAME)。用户名都没填时只给显示名——SMTP 层随后会为"用户名为空"
49
+ * 报出可读的配置错误,不必在这里再拼一个空地址。
49
50
  * @param {object} config 已解析配置
50
- * @returns {string} "名字 <地址>",或拿不到地址时只返回名字
51
+ * @returns {string} "名字 <smtpUser>",或拿不到用户名时只返回显示名
51
52
  */
52
53
  export function resolveFrom(config) {
53
- const sender = resolveSender(config);
54
- const name = sender.name;
55
- let address = sender.address;
56
- if (address === '') address = String(config.smtpUser ?? '').trim();
57
- if (address === '') return name;
58
- return name === '' ? address : `${name} <${address}>`;
54
+ const address = String(config?.smtpUser ?? '').trim();
55
+ return address === '' ? DEFAULT_SENDER_NAME : `${DEFAULT_SENDER_NAME} <${address}>`;
59
56
  }
60
57
 
61
58
  /**
@@ -75,7 +72,7 @@ export function checkSenderConfig(config, recipientCount) {
75
72
  * 发一封信(走 SMTP)。
76
73
  *
77
74
  * 返回值沿用原来「逐通道结果 + 汇总」的形状,只是 `channels` 现在恒为一项:
78
- * 设置页与账本都按这个形状读,改形状要连带改 typert 清单与已落盘的账本。
75
+ * 设置页按这个形状读,改形状要连带改 typert 清单。
79
76
  * @param {object} config 已解析配置
80
77
  * @param {{to: string[], subject: string, text: string, html?: string}} message 邮件内容(不含 from)
81
78
  * @param {object} deps 依赖(logger / resolveCredential)
@@ -1,8 +1,8 @@
1
1
  // dsh-notify-p · Host 半的 Remote 服务
2
2
  //
3
- // 设置页上的「发送测试邮件」和「最近一次投递」需要一个 Host 动作与一个 Host 读取,
4
- // 这是本插件唯一对浏览器暴露的 API 面。它刻意很窄:只有 test 和 state 两个方法,
5
- // 都不接受参数——设置页要改的东西一律走 settings 命名空间,不走这里。
3
+ // 设置页上的「发送测试邮件」需要一个 Host 动作,
4
+ // 这是本插件唯一对浏览器暴露的 API 面。它刻意很窄:只有 test 一个方法,
5
+ // 不接受参数——设置页要改的东西一律走 settings 命名空间,不走这里。
6
6
  //
7
7
  // 装配方式:宿主在 start() 里 ctx.plugin(MailNotifyRemoteService, runtime),
8
8
  // runtime 由装配逻辑提供(见 lib/index.js),所以本文件不碰任何插件状态。
@@ -17,7 +17,7 @@ import { TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
17
17
  export class MailNotifyRemoteService extends TypertRemoteService {
18
18
  /**
19
19
  * @param {object} ctx cordis 上下文
20
- * @param {object} runtime 装配逻辑给的运行态接口 { test, state }
20
+ * @param {object} runtime 装配逻辑给的运行态接口 { test }
21
21
  */
22
22
  constructor(ctx, runtime) {
23
23
  super(ctx, 'mailNotify');
@@ -36,18 +36,6 @@ export class MailNotifyRemoteService extends TypertRemoteService {
36
36
  }
37
37
  return runtime.test();
38
38
  }
39
-
40
- /**
41
- * 投递账本快照(计数 + 最近若干条)。
42
- * @returns {object} 账本快照
43
- */
44
- async state() {
45
- const runtime = this.runtime ?? {};
46
- if (typeof runtime.state !== 'function') {
47
- throw new Error('mailNotify/state: runtime is not wired');
48
- }
49
- return runtime.state();
50
- }
51
39
  }
52
40
 
53
41
  export default MailNotifyRemoteService;
@@ -5,11 +5,11 @@
5
5
  // 2. `node test/send-test.mjs`(不依赖 DSH 运行、不需要重启)
6
6
  // 3. 单元测试
7
7
  //
8
- // 刻意**不碰投递账本**:记账是调用方的事。命令行尤其不能写账本——正在运行的
9
- // DSH 进程自己持有一份内存账本,两边同时写只会互相覆盖。
8
+ // 投递账本已删除(见 docs/adr/0003):这里只发信、只把结果原样返回给调用方,
9
+ // 不落盘、不留历史,命令行与设置页因此天然不会互相覆盖。
10
10
  import { OUTCOME } from './policy.js';
11
11
  import { renderMail } from './render.js';
12
- import { parseRecipients, resolveBaseUrl, recipientIssues } from './config.js';
12
+ import { parseRecipients, recipientIssues } from './config.js';
13
13
  import { sendMail } from './send/index.js';
14
14
 
15
15
  /** 测试邮件的会话标题(收件人一眼能看出这是插件自己发的)。 */
@@ -19,8 +19,8 @@ export const TEST_TITLE = '测试邮件 / Test email';
19
19
  * 把任意一条投递记录整理成固定形状。
20
20
  *
21
21
  * 形状必须稳定:typert 清单里声明的结果是 strict schema,缺字段会在浏览器侧炸。
22
- * `channels` 是逐通道结果;老账本条目没有这个字段,所以在这里补成空数组,
23
- * 让设置页只需要处理一种形状(空的就退回单条 transport 的说法)。
22
+ * `channels` 恒为一项(只剩 SMTP 一条通道),保留它是为了不改 typert 清单里
23
+ * 声明的 strict schema:设置页只需要处理一种形状。
24
24
  * @param {object} entry 投递记录
25
25
  * @returns {{at: number, ok: boolean, transport: string, recipients: string[], subject: string, ms: number, error: string, channels: object[]}} 扁平结果
26
26
  */
@@ -57,7 +57,6 @@ export function toDeliveryView(entry) {
57
57
  * @param {object} options.config 已解析配置(resolveConfig 的产物)
58
58
  * @param {object} [options.logger] 日志器
59
59
  * @param {(ref: string) => Promise<string|undefined>} [options.resolveCredential] 凭据解析
60
- * @param {string} [options.webUrl] 运行时探测到的 Web 地址(拼链接用)
61
60
  * @returns {Promise<object>} 扁平结果(见 toDeliveryView)
62
61
  */
63
62
  export async function sendTestMail(options) {
@@ -92,8 +91,6 @@ export async function sendTestMail(options) {
92
91
  cwd: '',
93
92
  endedAt: Date.now(),
94
93
  durationMs: 0,
95
- config,
96
- baseUrl: resolveBaseUrl(config, options?.webUrl),
97
94
  });
98
95
 
99
96
  const summary = await sendMail(config, {
@@ -3,9 +3,8 @@
3
3
  // 为什么手写:本插件没有 TypeScript 构建步骤,而 dsh-typert-loader 只要求包导出
4
4
  // `./typert` 且其模块带一个 TYPERT 清单对象。清单里的 codec 由 zod v4 实例提供。
5
5
  //
6
- // 只声明两个方法,都不带参数:
6
+ // 只声明一个方法,不带参数:
7
7
  // mailNotify/test 立刻发一封测试邮件(失败作为结果返回,不抛异常)
8
- // mailNotify/state 投递账本快照
9
8
  //
10
9
  // 结果一律是"扁平 + 全部可选字段都有默认"的形状,方便严格校验:
11
10
  // 失败时 ok=false 且 error 非空,而不是换成另一种结构。
@@ -17,7 +16,7 @@
17
16
  // 所以这里照官方生成物的形状,写成惰性记忆化工厂。
18
17
  import { z } from 'zod';
19
18
 
20
- /** 一个通道这一次的成败(多通道扇出后,账本里要看得出是哪个通道)。 */
19
+ /** 一个通道这一次的成败(多通道扇出后,要看得出是哪个通道)。 */
21
20
  let channelSchema$value;
22
21
  const channelSchema = () => (channelSchema$value ??= z.object({
23
22
  transport: z.string(),
@@ -39,13 +38,6 @@ const deliverySchema = () => (deliverySchema$value ??= z.object({
39
38
  channels: z.array(channelSchema()),
40
39
  }));
41
40
 
42
- /** 账本快照。 */
43
- let stateSchema$value;
44
- const stateSchema = () => (stateSchema$value ??= z.object({
45
- counts: z.object({ sent: z.number(), failed: z.number() }),
46
- recent: z.array(deliverySchema()),
47
- }));
48
-
49
41
  export const TYPERT = {
50
42
  package: 'dsh-notify-p',
51
43
  face: 'host',
@@ -65,20 +57,6 @@ export const TYPERT = {
65
57
  },
66
58
  sourceLocation: { file: 'lib/service.js', line: 1, column: 1 },
67
59
  },
68
- {
69
- id: 'dsh-notify-p#mailNotify/state',
70
- service: 'mailNotify',
71
- namespace: 'mailNotify',
72
- method: 'state',
73
- invocation: { kind: 'direct' },
74
- parameters: [],
75
- result: {
76
- mode: 'strict',
77
- typeSymbol: 'dsh-notify-p#mailNotify/state:result',
78
- create: stateSchema,
79
- },
80
- sourceLocation: { file: 'lib/service.js', line: 1, column: 1 },
81
- },
82
60
  ],
83
61
  model: {
84
62
  services: [],
@@ -150,7 +150,7 @@ const setIconResultSchema = statusSchema
150
150
 
151
151
 
152
152
 
153
- /** 一个通道这一次的成败(多通道扇出后,账本里要看得出是哪个通道)。 */
153
+ /** 一个通道这一次的成败(多通道扇出后,要看得出是哪个通道)。 */
154
154
  let channelSchema$value;
155
155
  const channelSchema = () => (channelSchema$value ??= z.object({
156
156
  transport: z.string(),
@@ -172,13 +172,6 @@ const deliverySchema = () => (deliverySchema$value ??= z.object({
172
172
  channels: z.array(channelSchema()),
173
173
  }));
174
174
 
175
- /** 账本快照。 */
176
- let stateSchema$value;
177
- const stateSchema = () => (stateSchema$value ??= z.object({
178
- counts: z.object({ sent: z.number(), failed: z.number() }),
179
- recent: z.array(deliverySchema()),
180
- }));
181
-
182
175
 
183
176
 
184
177
  /** 合并后的 service 模型:A 的 iconCustom + B 的 mailNotify(B 的 model.services 为空)。 */
@@ -304,7 +297,7 @@ const services = [
304
297
  }
305
298
  ]
306
299
 
307
- /** 合并后的宿主端点:A 的 12 条 iconCustom/* + B 的 2 条 mailNotify/*。 */
300
+ /** 合并后的宿主端点:A 的 12 条 iconCustom/* + B 的 1 条 mailNotify/*。 */
308
301
  const invocations = [
309
302
  {
310
303
  id: 'dsh-lookout#iconCustom/getStatus',
@@ -565,20 +558,6 @@ const invocations = [
565
558
  },
566
559
  sourceLocation: { file: 'lib/service.js', line: 1, column: 1 },
567
560
  },
568
- {
569
- id: 'dsh-lookout#mailNotify/state',
570
- service: 'mailNotify',
571
- namespace: 'mailNotify',
572
- method: 'state',
573
- invocation: { kind: 'direct' },
574
- parameters: [],
575
- result: {
576
- mode: 'strict',
577
- typeSymbol: 'dsh-lookout#mailNotify/state:result',
578
- create: stateSchema,
579
- },
580
- sourceLocation: { file: 'lib/service.js', line: 1, column: 1 },
581
- },
582
561
  ]
583
562
 
584
563
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@cowwo/dsh-lookout",
3
3
  "description": "瞭望哨:盯着你的每个会话——谁跑完了、谁出错了、谁卡住了、谁在等你审批,用浏览器图标角标、红点/黄点和邮件通知你;顺带显示当前会话 ID。· Lookout: watches every session — who finished, who errored, who is stuck, and who is waiting for your approval — and notifies you through the browser favicon badge, red/yellow dots, and email, while also showing the current session ID.",
4
- "version": "0.2.0",
4
+ "version": "0.5.1",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",