dsh-session-autarchive 0.1.4 → 0.1.6

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.en.md CHANGED
@@ -11,6 +11,7 @@ Archives sessions that have gone unused for longer than a configurable number of
11
11
  | Where | What it looks like | What it does |
12
12
  |---|---|---|
13
13
  | Session context menu | One row, "Do not auto-archive", next to Pin / Rename / Fork / Archive | Click once to mark, again to unmark |
14
+ | Session row trailing | On hover, a shield button next to the archive icon (hollow when unmarked, filled when marked) | Toggle "do not auto-archive" without opening the overflow menu |
14
15
  | Session row start | A small shield on marked sessions | Shows at a glance that the session is protected |
15
16
  | Hover card | One line, "Not auto-archived" | Same, with text |
16
17
  | General settings | A row at the bottom, "Auto-archive after (days)" | Saved on blur or Enter, writes the same host field |
@@ -70,7 +71,7 @@ Writes are temp-file-then-rename, and reads retry — a half-written file is nev
70
71
  ## How it works
71
72
 
72
73
  - The host half registers four local routes: `GET /dsh-session-autarchive/api/state`, `POST .../protect`, `POST .../config`, `POST .../run`. Loopback addresses only (`127.*`, `::1`, `::ffff:127.*`); anything else gets 403, a wrong method gets 405.
73
- - The browser half mounts four slots (menu row, row start, hover card, settings row). Services are probed with `ctx.get`; when one is missing that entry is skipped and the others still mount.
74
+ - The browser half mounts five slots (menu row, row action, row start, hover card, settings row). Services are probed with `ctx.get`; when one is missing that entry is skipped and the others still mount.
74
75
  - No network access, nothing uploaded. Decisions use local file times only.
75
76
 
76
77
  ## Known limits
@@ -85,7 +86,7 @@ Writes are temp-file-then-rename, and reads retry — a half-written file is nev
85
86
  The source has two halves:
86
87
 
87
88
  - `index.js` — host side (decision, archiving, four routes, the tool)
88
- - `src/entry.jsx` — browser side (menu row, row shield, hover line, settings row, the 5-minute fast path)
89
+ - `src/entry.jsx` — browser side (menu row, row action shield, row shield, hover line, settings row, the 5-minute fast path)
89
90
 
90
91
  Editing `src/entry.jsx` requires rebuilding. `lib/client.js` is a build artifact committed to the repository; without it the UI does not load:
91
92
 
package/README.md CHANGED
@@ -6,11 +6,12 @@
6
6
 
7
7
  把长期没动过的会话自动归档。按会话内最后一条对话结束的时间算天数,默认 5 天;已归档、置顶(可关)、标记「不自动归档」的都跳过。侧栏会话右键菜单给单个会话加标记,通用设置里改天数。
8
8
 
9
- ## 界面上的三处
9
+ ## 界面上的四处
10
10
 
11
11
  | 位置 | 样子 | 作用 |
12
12
  |---|---|---|
13
13
  | 会话右键菜单 | 与「置顶会话/重命名/分叉会话/归档会话」并列的一行「不自动归档」 | 点一次标记,再点一次取消 |
14
+ | 会话行尾 | 悬停时归档图标旁边一个盾牌按钮(未标记空心盾、已标记实心盾) | 不用点开「更多」,直接开/关「不自动归档」 |
14
15
  | 会话行首 | 被标记的会话多一个小盾牌 | 一眼看出这条不会被自动归档 |
15
16
  | 悬停卡片 | 一行「不会被自动归档」 | 同上,带文字说明 |
16
17
  | 通用设置 | 最底一行「自动归档天数」 | 失焦或回车即保存,写的是宿主同一个字段 |
@@ -85,7 +86,7 @@ dsh plugin --profile desktop add dsh-session-autarchive
85
86
  源码分两半:
86
87
 
87
88
  - `index.js` —— 宿主半边(判定、归档、四条路由、工具)
88
- - `src/entry.jsx` —— 浏览器半边(菜单行、行首盾牌、悬停说明、设置行、5 分钟快通道)
89
+ - `src/entry.jsx` —— 浏览器半边(菜单行、行尾盾牌按钮、行首盾牌、悬停说明、设置行、5 分钟快通道)
89
90
 
90
91
  改过 `src/entry.jsx` 必须重新打包。`lib/client.js` 是提交进仓库的构建产物,缺了它界面不会加载:
91
92
 
package/index.js CHANGED
@@ -549,87 +549,99 @@ function registerRoutes(ctx) {
549
549
 
550
550
  /** 注册工具与路由。 */
551
551
  export function apply(ctx) {
552
- ctx.tools.register({
553
- name: 'dsh-session-autarchive',
554
- description: '超期会话自动归档的开关台:status 看配置、保护名单与当前候选;run 立刻归档候选;protect/unprotect 标记单个会话「不自动归档」;config 改阈值与开关。判定口径=会话内最后一条对话结束事件(turn/end)的时间——只是被打开/恢复过(会追写开场提示)不算活动;读不到事件时退回文件最后写入时间。已归档、保护名单内、置顶(可关)都跳过。浏览器半边每 5 分钟按同一份清单自动执行,侧栏右键菜单的「不自动归档」也写这份名单。',
555
- parameters: {
556
- type: 'object',
557
- properties: {
558
- action: { type: 'string', description: '要做什么:status(默认,看状态)|run(立刻归档候选)|protect(标记不自动归档)|unprotect(取消标记)|config(改阈值/开关)' },
559
- sessionId: { type: 'string', description: 'protect/unprotect 用:会话 id,形如 session-xxxxxxxx-…' },
560
- dryRun: { type: 'boolean', description: 'run 用:true 时只列候选、不动手' },
561
- days: { type: 'number', description: 'config 用:多少天没动就归档,默认 5' },
562
- enabled: { type: 'boolean', description: 'config 用:是否启用自动归档' },
563
- exemptPinned: { type: 'boolean', description: 'config 用:置顶会话是否也豁免(默认豁免)' },
564
- },
565
- required: [],
566
- additionalProperties: false,
567
- },
568
- output: {
569
- schema: {
552
+ // 工具注册失败(重名、DSH 升级后格式漂移)不该拖垮路由与兜底定时:
553
+ // 单独圈起来,失败只留一行警告,归档主功能照常。
554
+ try {
555
+ ctx.tools.register({
556
+ name: 'dsh-session-autarchive',
557
+ description: '超期会话自动归档的开关台:status 看配置、保护名单与当前候选;run 立刻归档候选;protect/unprotect 标记单个会话「不自动归档」;config 改阈值与开关。判定口径=会话内最后一条对话结束事件(turn/end)的时间——只是被打开/恢复过(会追写开场提示)不算活动;读不到事件时退回文件最后写入时间。已归档、保护名单内、置顶(可关)都跳过。浏览器半边每 5 分钟按同一份清单自动执行,侧栏右键菜单的「不自动归档」也写这份名单。',
558
+ parameters: {
570
559
  type: 'object',
571
- properties: { ok: { type: 'boolean' }, text: { type: 'string' } },
572
- required: ['ok', 'text'],
560
+ properties: {
561
+ action: { type: 'string', description: '要做什么:status(默认,看状态)|run(立刻归档候选)|protect(标记不自动归档)|unprotect(取消标记)|config(改阈值/开关)' },
562
+ sessionId: { type: 'string', description: 'protect/unprotect 用:会话 id,形如 session-xxxxxxxx-…' },
563
+ dryRun: { type: 'boolean', description: 'run 用:true 时只列候选、不动手' },
564
+ days: { type: 'number', description: 'config 用:多少天没动就归档,默认 5' },
565
+ enabled: { type: 'boolean', description: 'config 用:是否启用自动归档' },
566
+ exemptPinned: { type: 'boolean', description: 'config 用:置顶会话是否也豁免(默认豁免)' },
567
+ },
568
+ required: [],
573
569
  additionalProperties: false,
574
570
  },
575
- render: (_args, value) => [{ type: 'text', text: value.text }],
576
- },
577
- async execute(args) {
578
- const action = String((args && args.action) || 'status')
579
- let state = readState()
580
-
581
- if (action === 'status') {
582
- return { ok: true, text: statusText(ctx, state) }
583
- }
584
-
585
- if (action === 'run') {
586
- const result = await runArchive(ctx, state, args && args.dryRun === true)
587
- const head = args && args.dryRun === true
588
- ? '干跑:候选 ' + result.candidates.length + ' 个,未动手'
589
- : '已归档 ' + result.archived.length + ' 个' + (result.failed.length > 0 ? ',失败 ' + result.failed.length + ' 个' : '')
590
- const lines = [head]
591
- for (const id of result.archived) lines.push(' ✓ ' + id.slice(8, 16))
592
- for (const line of result.failed) lines.push(' ✗ ' + line)
593
- if (args && args.dryRun === true) {
594
- for (const c of result.candidates) lines.push(' · ' + c.id.slice(8, 16) + ' 最后对话 ' + stamp(c.last))
571
+ output: {
572
+ schema: {
573
+ type: 'object',
574
+ properties: { ok: { type: 'boolean' }, text: { type: 'string' } },
575
+ required: ['ok', 'text'],
576
+ additionalProperties: false,
577
+ },
578
+ render: (_args, value) => [{ type: 'text', text: value.text }],
579
+ },
580
+ async execute(args) {
581
+ const action = String((args && args.action) || 'status')
582
+ let state = readState()
583
+
584
+ if (action === 'status') {
585
+ return { ok: true, text: statusText(ctx, state) }
595
586
  }
596
- return { ok: true, text: lines.join('\n') }
597
- }
598
-
599
- if (action === 'protect' || action === 'unprotect') {
600
- const sessionId = String((args && args.sessionId) || '')
601
- if (!/^session-/.test(sessionId)) return { ok: false, text: 'protect/unprotect 需要 sessionId(形如 session-xxxxxxxx-…)' }
602
- const keep = new Set(state.protected)
603
- if (action === 'protect') keep.add(sessionId)
604
- else keep.delete(sessionId)
605
- state = { ...state, protected: [...keep] }
606
- writeProtected(state.protected)
607
- audit((action === 'protect' ? 'protect ' : 'unprotect ') + sessionId + ',名单现有 ' + state.protected.length + ' 个')
608
- return {
609
- ok: true,
610
- text: (action === 'protect' ? '已标记不自动归档:' : '已取消标记:') + sessionId
611
- + '\n保护名单现有 ' + state.protected.length + ' 个',
587
+
588
+ if (action === 'run') {
589
+ const result = await runArchive(ctx, state, args && args.dryRun === true)
590
+ const head = args && args.dryRun === true
591
+ ? '干跑:候选 ' + result.candidates.length + ' 个,未动手'
592
+ : '已归档 ' + result.archived.length + ' 个' + (result.failed.length > 0 ? ',失败 ' + result.failed.length + ' 个' : '')
593
+ const lines = [head]
594
+ for (const id of result.archived) lines.push(' ✓ ' + id.slice(8, 16))
595
+ for (const line of result.failed) lines.push(' ✗ ' + line)
596
+ if (args && args.dryRun === true) {
597
+ for (const c of result.candidates) lines.push(' · ' + c.id.slice(8, 16) + ' 最后对话 ' + stamp(c.last))
598
+ }
599
+ return { ok: true, text: lines.join('\n') }
612
600
  }
613
- }
614
-
615
- if (action === 'config') {
616
- const next = { ...state }
617
- if (args.days !== undefined) next.days = Number(args.days) > 0 ? Number(args.days) : next.days
618
- if (args.enabled !== undefined) next.enabled = args.enabled === true
619
- if (args.exemptPinned !== undefined) next.exemptPinned = args.exemptPinned === true
620
- writeState(next)
621
- return { ok: true, text: '已更新:' + describe(next) }
622
- }
623
-
624
- return { ok: false, text: '不认识的 action:' + action + '(可用 status/run/protect/unprotect/config)' }
625
- },
626
- })
601
+
602
+ if (action === 'protect' || action === 'unprotect') {
603
+ const sessionId = String((args && args.sessionId) || '')
604
+ if (!/^session-/.test(sessionId)) return { ok: false, text: 'protect/unprotect 需要 sessionId(形如 session-xxxxxxxx-…)' }
605
+ const keep = new Set(state.protected)
606
+ if (action === 'protect') keep.add(sessionId)
607
+ else keep.delete(sessionId)
608
+ state = { ...state, protected: [...keep] }
609
+ writeProtected(state.protected)
610
+ audit((action === 'protect' ? 'protect ' : 'unprotect ') + sessionId + ',名单现有 ' + state.protected.length + ' 个')
611
+ return {
612
+ ok: true,
613
+ text: (action === 'protect' ? '已标记不自动归档:' : '已取消标记:') + sessionId
614
+ + '\n保护名单现有 ' + state.protected.length + ' 个',
615
+ }
616
+ }
617
+
618
+ if (action === 'config') {
619
+ const next = { ...state }
620
+ if (args.days !== undefined) next.days = Number(args.days) > 0 ? Number(args.days) : next.days
621
+ if (args.enabled !== undefined) next.enabled = args.enabled === true
622
+ if (args.exemptPinned !== undefined) next.exemptPinned = args.exemptPinned === true
623
+ writeState(next)
624
+ return { ok: true, text: '已更新:' + describe(next) }
625
+ }
626
+
627
+ return { ok: false, text: '不认识的 action:' + action + '(可用 status/run/protect/unprotect/config)' }
628
+ },
629
+ })
630
+ } catch (error) {
631
+ console.warn('[dsh-session-autarchive] 工具未注册:' + (error && error.message ? error.message : error))
632
+ }
627
633
 
628
634
  registerRoutes(ctx)
629
635
 
630
636
  // 宿主侧兜底:浏览器窗口没开着也照常归档(客户端那条是让侧栏当场刷新的快通道)。
631
637
  // 启动 60 秒后第一次(避开启动竞争),之后每 10 分钟。
632
- const tick = () => { void runArchive(ctx, readState(), false).catch(() => { /* 这轮不行,下一轮再来 */ }) }
638
+ // 开关每次现读:关掉自动归档后这条定时链要立刻停,不能等重启。
639
+ // 手动执行(工具/路由的 run)不在这里拦,那是用户主动点的,开关只管自动。
640
+ const tick = () => {
641
+ const state = readState()
642
+ if (!state.enabled) return
643
+ void runArchive(ctx, state, false).catch(() => { /* 这轮不行,下一轮再来 */ })
644
+ }
633
645
  const first = setTimeout(tick, 60 * 1000)
634
646
  const timer = setInterval(tick, 10 * 60 * 1000)
635
647
  if (typeof ctx.effect === 'function') {
package/lib/client.js CHANGED
@@ -26,9 +26,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
26
26
  var entry_exports = {};
27
27
  __export(entry_exports, {
28
28
  AutoArchiveRow: () => AutoArchiveRow,
29
+ AutoArchiveToggleRow: () => AutoArchiveToggleRow,
29
30
  ProtectHover: () => ProtectHover,
30
31
  ProtectLeading: () => ProtectLeading,
31
32
  ProtectMenuItem: () => ProtectMenuItem,
33
+ ProtectRowAction: () => ProtectRowAction,
32
34
  apply: () => apply,
33
35
  inject: () => inject
34
36
  });
@@ -139,6 +141,35 @@ function ShieldIcon({ size = 14 }) {
139
141
  }
140
142
  );
141
143
  }
144
+ function ShieldFilledIcon({ size = 14 }) {
145
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
146
+ "svg",
147
+ {
148
+ width: size,
149
+ height: size,
150
+ viewBox: "0 0 24 24",
151
+ "aria-hidden": "true",
152
+ focusable: "false",
153
+ fill: "currentColor",
154
+ stroke: "none",
155
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M12 3l7 2.6v5.2c0 4.3-3 8.2-7 9.4-4-1.2-7-5.1-7-9.4V5.6L12 3z" })
156
+ }
157
+ );
158
+ }
159
+ var ROW_ACTION = {
160
+ flex: "none",
161
+ display: "inline-flex",
162
+ alignItems: "center",
163
+ justifyContent: "center",
164
+ width: "16px",
165
+ height: "16px",
166
+ border: "none",
167
+ borderRadius: "var(--dsw-radius-xs)",
168
+ padding: 0,
169
+ background: "transparent",
170
+ cursor: "pointer",
171
+ color: "var(--dsw-alias-label-tertiary)"
172
+ };
142
173
  var ITEM = {
143
174
  display: "flex",
144
175
  alignItems: "center",
@@ -202,6 +233,39 @@ function ProtectMenuItem(props) {
202
233
  }
203
234
  );
204
235
  }
236
+ function ProtectRowAction(props) {
237
+ const { sessionId } = props;
238
+ const state = useStore();
239
+ const [hover, setHover] = (0, import_react.useState)(false);
240
+ const [busy, setBusy] = (0, import_react.useState)(false);
241
+ const on = state.protected.has(sessionId);
242
+ const label = on ? "\u53D6\u6D88\u300C\u4E0D\u81EA\u52A8\u5F52\u6863\u300D" : "\u4E0D\u81EA\u52A8\u5F52\u6863";
243
+ const click = () => {
244
+ if (busy) return;
245
+ setBusy(true);
246
+ void toggleProtect(sessionId, !on).catch(() => {
247
+ }).finally(() => setBusy(false));
248
+ };
249
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
250
+ "button",
251
+ {
252
+ type: "button",
253
+ title: label,
254
+ "aria-label": label,
255
+ "aria-pressed": on,
256
+ disabled: busy,
257
+ onClick: click,
258
+ onMouseEnter: () => setHover(true),
259
+ onMouseLeave: () => setHover(false),
260
+ style: {
261
+ ...ROW_ACTION,
262
+ color: hover && !busy ? "var(--dsw-alias-label-primary)" : "var(--dsw-alias-label-tertiary)",
263
+ opacity: busy ? 0.4 : 1
264
+ },
265
+ children: on ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ShieldFilledIcon, {}) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ShieldIcon, {})
266
+ }
267
+ );
268
+ }
205
269
  function ProtectLeading(props) {
206
270
  const state = useStore();
207
271
  if (!state.protected.has(props.sessionId)) return null;
@@ -223,6 +287,70 @@ var ROW_CSS = `
223
287
  .zsa-unit { font-size:12px; color:var(--dsw-alias-label-secondary); }
224
288
  .zsa-error { font-size:12px; line-height:18px; color:var(--dsw-alias-state-error-primary); }
225
289
  `;
290
+ function Switch({ on, disabled, onChange }) {
291
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
292
+ "button",
293
+ {
294
+ type: "button",
295
+ role: "switch",
296
+ "aria-checked": on,
297
+ "aria-label": on ? "\u5173\u95ED\u81EA\u52A8\u5F52\u6863" : "\u5F00\u542F\u81EA\u52A8\u5F52\u6863",
298
+ disabled,
299
+ onClick: () => onChange(!on),
300
+ style: {
301
+ width: 36,
302
+ height: 20,
303
+ flex: "none",
304
+ border: 0,
305
+ borderRadius: 10,
306
+ padding: 0,
307
+ cursor: disabled ? "default" : "pointer",
308
+ position: "relative",
309
+ background: on ? "var(--dsw-alias-brand-primary)" : "var(--dsw-alias-border-l1)",
310
+ opacity: disabled ? 0.5 : 1,
311
+ transition: "background .15s"
312
+ },
313
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: {
314
+ position: "absolute",
315
+ top: 2,
316
+ left: on ? 18 : 2,
317
+ width: 16,
318
+ height: 16,
319
+ borderRadius: 8,
320
+ background: "#fff",
321
+ transition: "left .15s"
322
+ } })
323
+ }
324
+ );
325
+ }
326
+ function AutoArchiveToggleRow() {
327
+ const state = useStore();
328
+ const [busy, setBusy] = (0, import_react.useState)(false);
329
+ const [error, setError] = (0, import_react.useState)(null);
330
+ const on = state.enabled !== false;
331
+ const toggle = async (next) => {
332
+ if (busy) return;
333
+ setBusy(true);
334
+ setError(null);
335
+ try {
336
+ const data = await call("POST", ROUTE + "/config", { enabled: next });
337
+ patchStore({ enabled: data.enabled !== false });
338
+ } catch (err) {
339
+ setError("\u6CA1\u4FDD\u5B58\u4E0A\uFF1A" + String(err && err.message || err));
340
+ } finally {
341
+ setBusy(false);
342
+ }
343
+ };
344
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "zsa-item", children: [
345
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("style", { children: ROW_CSS }),
346
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "zsa-text", children: [
347
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "zsa-title", children: "\u81EA\u52A8\u5F52\u6863" }),
348
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "zsa-desc", children: "\u5173\u95ED\u540E\u540E\u53F0\u4E0D\u518D\u81EA\u52A8\u5F52\u6863\uFF1B\u624B\u52A8\u6267\u884C\u4ECD\u7136\u53EF\u7528\u3002\u5F53\u524D\u5019\u9009 " + state.candidates.length + " \u4E2A\u3002" }),
349
+ error ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "zsa-error", role: "alert", children: error }) : null
350
+ ] }),
351
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "zsa-field", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Switch, { on, disabled: busy, onChange: (next) => void toggle(next) }) })
352
+ ] });
353
+ }
226
354
  function AutoArchiveRow() {
227
355
  const state = useStore();
228
356
  const [draft, setDraft] = (0, import_react.useState)(null);
@@ -277,7 +405,7 @@ function AutoArchiveRow() {
277
405
  max: 3650,
278
406
  step: 1,
279
407
  value,
280
- disabled: busy,
408
+ disabled: busy || state.enabled === false,
281
409
  "aria-label": "\u81EA\u52A8\u5F52\u6863\u5929\u6570",
282
410
  onChange: (event) => {
283
411
  setDraft(event.target.value);
@@ -315,9 +443,15 @@ function apply(ctx) {
315
443
  } catch {
316
444
  }
317
445
  place(ctx, "sidebar.workspaces.session.menu.item", 450, ProtectMenuItem);
446
+ place(ctx, "sidebar.workspaces.session.row.action", 150, ProtectRowAction);
318
447
  place(ctx, "sidebar.session.row.leading", 100, ProtectLeading);
319
448
  place(ctx, "sidebar.session.row.hover", 100, ProtectHover);
320
449
  try {
450
+ ctx.slots.inject("settings.general.item", () => ctx.slots.register({
451
+ name: "settings.general.item",
452
+ id: "session-autarchive-enabled",
453
+ order: 94
454
+ }, AutoArchiveToggleRow));
321
455
  ctx.slots.inject("settings.general.item", () => ctx.slots.register({
322
456
  name: "settings.general.item",
323
457
  id: "session-autarchive-days",
package/package.json CHANGED
@@ -1,18 +1,16 @@
1
1
  {
2
2
  "name": "dsh-session-autarchive",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "把长期没动过的会话自动归档(默认 5 天),单个会话可以标成不归档。",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
8
8
  ".": "./index.js",
9
- "./invariant": "./invariant.js",
10
9
  "./client": "./lib/client.js",
11
10
  "./package.json": "./package.json"
12
11
  },
13
12
  "files": [
14
13
  "index.js",
15
- "invariant.js",
16
14
  "cordis.patch.yml",
17
15
  "src",
18
16
  "lib/client.js",
@@ -24,7 +22,7 @@
24
22
  ],
25
23
  "engines": {
26
24
  "node": "^22.19.0 || >=24.0.0",
27
- "dsh": ">=0.2.1-0"
25
+ "dsh": ">=0.2.1-alpha.3"
28
26
  },
29
27
  "dsh": {
30
28
  "client": {
@@ -48,7 +46,7 @@
48
46
  "build:client": "node scripts/build-client.mjs",
49
47
  "check:client": "node scripts/build-client.mjs --check",
50
48
  "smoke": "node scripts/smoke.mjs",
51
- "check": "node --check index.js && node --check invariant.js"
49
+ "check": "node --check index.js"
52
50
  },
53
51
  "devDependencies": {
54
52
  "esbuild": "^0.28.1"
package/scripts/smoke.mjs CHANGED
@@ -2,20 +2,21 @@
2
2
  /**
3
3
  * 本机冒烟:不起应用、不发网络请求、不碰真实会话数据。
4
4
  *
5
- * 六关:
5
+ * 七关:
6
6
  * 1. 包结构 —— files 声明的必需文件都在(含已提交的 lib/client.js)
7
7
  * 2. 组合声明 —— dsh.bundle.patch 指向的文件在,且里面的 insert id 等于包名
8
8
  * 3. 宿主半边 —— 桩 ctx 跑 apply:注册 1 个工具、4 条路由,路径与包名一致
9
9
  * 4. 路由鉴权 —— 非回环 403、非 POST 405、坏会话 id 400
10
10
  * 5. 状态读写 —— 指向临时 DSH_HOME:改天数、加名单后回读一致
11
- * 6. 客户端产物 —— 外壳 id == 包名,产物里带上了路由与四个 slot 名
11
+ * 6. 开关生效 —— enabled=false 时兜底定时不归档,手动 run 仍归档
12
+ * 7. 客户端产物 —— 外壳 id == 包名,产物里带上了路由与四个 slot 名
12
13
  *
13
14
  * 用法:node scripts/smoke.mjs (任一条失败退出码 1)
14
15
  *
15
16
  * ⚠️ 全程使用临时 DSH_HOME,跑完删掉;不会读到或改到你自己的会话数据。
16
17
  */
17
18
 
18
- import { readFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'
19
+ import { readFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
19
20
  import { tmpdir } from 'node:os'
20
21
  import { dirname, join, resolve } from 'node:path'
21
22
  import { fileURLToPath, pathToFileURL } from 'node:url'
@@ -33,7 +34,6 @@ const fail = (label, why) => {
33
34
  /** 1. 包结构 */
34
35
  const REQUIRED = [
35
36
  'index.js',
36
- 'invariant.js',
37
37
  'cordis.patch.yml',
38
38
  'src/entry.jsx',
39
39
  'lib/client.js',
@@ -184,11 +184,79 @@ if (routes.length === wanted.length) {
184
184
  }
185
185
  }
186
186
 
187
- /** 6. 客户端产物 */
187
+ /** 6. 开关生效:enabled=false 时兜底定时不归档;手动 run 不受限 */
188
+ {
189
+ // 造一个 30 天前的会话目录(档里没有 turn/end,判定退回文件写入时间)
190
+ const wsDir = join(home, 'sessions', 'ws-smoke')
191
+ const sessDir = join(wsDir, 'session-smoke-old')
192
+ mkdirSync(sessDir, { recursive: true })
193
+ const oldFile = join(sessDir, 'session.v4.jsonl.zstd')
194
+ writeFileSync(oldFile, '')
195
+ const oldStamp = new Date(Date.now() - 30 * 86400000)
196
+ utimesSync(oldFile, oldStamp, oldStamp)
197
+ utimesSync(sessDir, oldStamp, oldStamp)
198
+
199
+ // 用一份新的桩 ctx 跑 apply,并把 60 秒的首次定时立即点一次
200
+ const tickOnce = async (enabled) => {
201
+ await call(routeOf('/config'), { method: 'POST', body: { enabled } })
202
+ const archived = []
203
+ const registry = {
204
+ archivedSessionIds: [],
205
+ pinnedSessionIds: [],
206
+ list: () => [{ sessionIds: ['session-smoke-old'] }],
207
+ archiveSession: async (id) => { archived.push(id) },
208
+ }
209
+ const hostTools = []
210
+ const hostCtx = {
211
+ tools: { register: (tool) => { hostTools.push(tool) } },
212
+ get: (name) => (name === 'webServer' ? webServer : name === 'workspaceRegistry' ? registry : undefined),
213
+ effect: (fn) => {
214
+ const cleanup = fn()
215
+ if (typeof cleanup === 'function') cleanups.push(cleanup)
216
+ },
217
+ }
218
+ const realSetTimeout = globalThis.setTimeout
219
+ globalThis.setTimeout = (fn, ms) => {
220
+ if (ms === 60 * 1000) { fn(); return { unref() {} } }
221
+ return realSetTimeout(fn, ms)
222
+ }
223
+ try {
224
+ mod.apply(hostCtx)
225
+ await new Promise((resolve) => setImmediate(resolve))
226
+ await new Promise((resolve) => setImmediate(resolve))
227
+ } finally {
228
+ globalThis.setTimeout = realSetTimeout
229
+ }
230
+ return { archived, tools: hostTools }
231
+ }
232
+
233
+ const off = await tickOnce(false)
234
+ // 先记下定时链这一轮的归档数:手动 run 会往同一个数组里记,不能等到 run 之后再判
235
+ const tickArchivedWhileOff = off.archived.length
236
+ const manualTool = off.tools[0]
237
+ let manualArchived = false
238
+ if (manualTool) {
239
+ const result = await manualTool.execute({ action: 'run' })
240
+ manualArchived = result && result.ok === true && /已归档 1 个/.test(result.text)
241
+ }
242
+ const on = await tickOnce(true)
243
+ if (tickArchivedWhileOff !== 0) {
244
+ fail('开关生效', 'enabled=false 时兜底定时仍归档了 ' + tickArchivedWhileOff + ' 个')
245
+ } else if (!manualArchived) {
246
+ fail('开关生效', 'enabled=false 时手动 run 没有归档候选')
247
+ } else if (on.archived.length !== 1) {
248
+ fail('开关生效', 'enabled=true 时兜底定时归档 ' + on.archived.length + ' 个,应为 1')
249
+ } else {
250
+ ok('开关生效', '关闭时定时不归档、手动照常、打开后恢复')
251
+ }
252
+ }
253
+
254
+ /** 7. 客户端产物 */
188
255
  const client = readFileSync(join(root, 'lib', 'client.js'), 'utf8')
189
256
  const idMatch = /^\s*id:\s*"([^"]+)"/m.exec(client)
190
257
  const SLOTS = [
191
258
  'sidebar.workspaces.session.menu.item',
259
+ 'sidebar.workspaces.session.row.action',
192
260
  'sidebar.session.row.leading',
193
261
  'sidebar.session.row.hover',
194
262
  'settings.general.item',
@@ -199,9 +267,32 @@ else if (!client.includes(pkg.name + '/api')) fail('客户端产物', '产物里
199
267
  else {
200
268
  const absent = SLOTS.filter((slot) => !client.includes(slot))
201
269
  if (absent.length > 0) fail('客户端产物', '产物里缺 slot:' + absent.join('、'))
270
+ else if (!client.includes('session-autarchive-enabled')) fail('客户端产物', '产物里没有设置开关行(源码改了但没重新构建)')
202
271
  else ok('客户端产物', 'id=' + pkg.name + ',' + Buffer.byteLength(client, 'utf8') + ' 字节')
203
272
  }
204
273
 
274
+ /** 8. 失败隔离:工具注册抛错时,路由与定时器照常 */
275
+ {
276
+ const routesAfter = []
277
+ let effects = 0
278
+ const hostWeb = { port: 0, register: (route) => { routesAfter.push(route); return () => {} } }
279
+ const throwingCtx = {
280
+ tools: { register: () => { throw new Error('simulated duplicate tool') } },
281
+ get: (name) => (name === 'webServer' ? hostWeb : undefined),
282
+ effect: (fn) => {
283
+ effects += 1
284
+ const cleanup = fn()
285
+ if (typeof cleanup === 'function') cleanups.push(cleanup)
286
+ },
287
+ }
288
+ let threw = null
289
+ try { mod.apply(throwingCtx) } catch (error) { threw = error }
290
+ if (threw) fail('失败隔离', '工具注册抛错后 apply 整体抛出:' + threw.message)
291
+ else if (routesAfter.length !== 4) fail('失败隔离', '工具注册抛错后注册了 ' + routesAfter.length + ' 条路由,应为 4')
292
+ else if (effects !== 5) fail('失败隔离', '工具注册抛错后 effect 登记 ' + effects + ' 个,应为 5(4 条路由 + 1 个定时器)')
293
+ else ok('失败隔离', '工具注册抛错,路由 4 条与定时器照常')
294
+ }
295
+
205
296
  /** 收尾:清掉宿主半边的定时器与临时目录 */
206
297
  for (const cleanup of cleanups) {
207
298
  try {
package/src/entry.jsx CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * 超期会话自动归档(浏览器半边源码)。
3
3
  *
4
- * 会话列表上三处,都只在「点开菜单」或「被标记」之后才出现:
4
+ * 会话列表上四处:
5
5
  * 1. 会话菜单里平铺一行「不自动归档」——与官方「置顶会话/重命名/分叉会话/归档会话」同层并列,不另造「更多」;
6
- * 2. 被标记的会话行首一个小盾牌;
7
- * 3. 悬停卡片里一行说明。
8
- * 行尾那排悬停图标按钮**不放东西**(不与归档/置顶图标并排)。
6
+ * 2. 行尾悬停图标组里、归档图标紧旁边一个盾牌按钮(order 150,落在归档 100 与置顶 200 之间),
7
+ * 未保护空心盾、已保护实心盾,点它开/关「不自动归档」——不用点开「更多」;
8
+ * 3. 被标记的会话行首一个小盾牌;
9
+ * 4. 悬停卡片里一行说明。
9
10
  * 设置里一行:通用设置最底「自动归档天数」(`settings.general.item`),失焦或回车即落盘。
10
11
  * 外加每 5 分钟拉一次宿主的候选清单:拿得到官方归档通道就顺手归档,拿不到就只刷新名单——
11
12
  * 判定在宿主,宿主自己有兜底定时(窗口没开也照常归档)。
@@ -140,6 +141,28 @@ function ShieldIcon({ size = 14 }) {
140
141
  )
141
142
  }
142
143
 
144
+ /**
145
+ * 「已标记」实心盾:与描边版同一个轮廓,换成填充,被保护的会话一眼可辨。
146
+ * @param props.size - 边长,默认 14。
147
+ * @returns 一个实心盾牌。
148
+ */
149
+ function ShieldFilledIcon({ size = 14 }) {
150
+ return (
151
+ <svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true" focusable="false"
152
+ fill="currentColor" stroke="none">
153
+ <path d="M12 3l7 2.6v5.2c0 4.3-3 8.2-7 9.4-4-1.2-7-5.1-7-9.4V5.6L12 3z" />
154
+ </svg>
155
+ )
156
+ }
157
+
158
+ /** 行尾图标按钮:数值照 Rows.module.css 的 `.iconButton`(16px 格、14px 字形、tertiary → hover primary)。 */
159
+ const ROW_ACTION = {
160
+ flex: 'none', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
161
+ width: '16px', height: '16px', border: 'none', borderRadius: 'var(--dsw-radius-xs)',
162
+ padding: 0, background: 'transparent', cursor: 'pointer',
163
+ color: 'var(--dsw-alias-label-tertiary)',
164
+ }
165
+
143
166
  /** 菜单行:照 ui-primitives Menu.module.css 的 `.item`。 */
144
167
  const ITEM = {
145
168
  display: 'flex', alignItems: 'center', gap: '6px', width: '100%', minHeight: '34px',
@@ -201,6 +224,48 @@ export function ProtectMenuItem(props) {
201
224
  )
202
225
  }
203
226
 
227
+ /**
228
+ * 行尾悬停按钮(order 150,紧挨归档图标):盾牌一档,点它开/关「不自动归档」。
229
+ * 未保护=空心盾,已保护=实心盾;标题与 aria-label 写明当前状态要发生什么。
230
+ * 与官方置顶/归档按钮同一排同一套样式;点击不打开会话行(宿主 rowActions 已截住冒泡)。
231
+ * @param props - 行身份。
232
+ * @returns 一个图标按钮。
233
+ */
234
+ export function ProtectRowAction(props) {
235
+ const { sessionId } = props
236
+ const state = useStore()
237
+ const [hover, setHover] = useState(false)
238
+ const [busy, setBusy] = useState(false)
239
+ const on = state.protected.has(sessionId)
240
+ const label = on ? '取消「不自动归档」' : '不自动归档'
241
+ const click = () => {
242
+ if (busy) return
243
+ setBusy(true)
244
+ void toggleProtect(sessionId, !on)
245
+ .catch(() => { /* 路由没挂或写盘失败:不改本地状态,界面保持原样 */ })
246
+ .finally(() => setBusy(false))
247
+ }
248
+ return (
249
+ <button
250
+ type="button"
251
+ title={label}
252
+ aria-label={label}
253
+ aria-pressed={on}
254
+ disabled={busy}
255
+ onClick={click}
256
+ onMouseEnter={() => setHover(true)}
257
+ onMouseLeave={() => setHover(false)}
258
+ style={{
259
+ ...ROW_ACTION,
260
+ color: hover && !busy ? 'var(--dsw-alias-label-primary)' : 'var(--dsw-alias-label-tertiary)',
261
+ opacity: busy ? 0.4 : 1,
262
+ }}
263
+ >
264
+ {on ? <ShieldFilledIcon /> : <ShieldIcon />}
265
+ </button>
266
+ )
267
+ }
268
+
204
269
  /**
205
270
  * 会话行首标记:被保护的会话显示盾牌(该格只在行处于空闲态时才渲染)。
206
271
  * @param props - 行身份。
@@ -242,6 +307,73 @@ const ROW_CSS = `
242
307
  .zsa-error { font-size:12px; line-height:18px; color:var(--dsw-alias-state-error-primary); }
243
308
  `
244
309
 
310
+ /** 开关:纯内联样式,不 import 宿主组件包。 */
311
+ function Switch({ on, disabled, onChange }) {
312
+ return (
313
+ <button
314
+ type="button"
315
+ role="switch"
316
+ aria-checked={on}
317
+ aria-label={on ? '关闭自动归档' : '开启自动归档'}
318
+ disabled={disabled}
319
+ onClick={() => onChange(!on)}
320
+ style={{
321
+ width: 36, height: 20, flex: 'none', border: 0, borderRadius: 10, padding: 0,
322
+ cursor: disabled ? 'default' : 'pointer', position: 'relative',
323
+ background: on ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-border-l1)',
324
+ opacity: disabled ? 0.5 : 1, transition: 'background .15s',
325
+ }}
326
+ >
327
+ <span style={{
328
+ position: 'absolute', top: 2, left: on ? 18 : 2, width: 16, height: 16,
329
+ borderRadius: 8, background: '#fff', transition: 'left .15s',
330
+ }} />
331
+ </button>
332
+ )
333
+ }
334
+
335
+ /**
336
+ * 通用设置里的一行:自动归档开关。
337
+ * 关掉后宿主定时链与浏览器侧都不再自动归档;手动 run 不受影响(宿主那边也这么判)。
338
+ * @returns 一行设置。
339
+ */
340
+ export function AutoArchiveToggleRow() {
341
+ const state = useStore()
342
+ const [busy, setBusy] = useState(false)
343
+ const [error, setError] = useState(null)
344
+ const on = state.enabled !== false
345
+
346
+ const toggle = async (next) => {
347
+ if (busy) return
348
+ setBusy(true)
349
+ setError(null)
350
+ try {
351
+ const data = await call('POST', ROUTE + '/config', { enabled: next })
352
+ patchStore({ enabled: data.enabled !== false })
353
+ } catch (err) {
354
+ setError('没保存上:' + String((err && err.message) || err))
355
+ } finally {
356
+ setBusy(false)
357
+ }
358
+ }
359
+
360
+ return (
361
+ <div className="zsa-item">
362
+ <style>{ROW_CSS}</style>
363
+ <div className="zsa-text">
364
+ <div className="zsa-title">自动归档</div>
365
+ <div className="zsa-desc">
366
+ {'关闭后后台不再自动归档;手动执行仍然可用。当前候选 ' + state.candidates.length + ' 个。'}
367
+ </div>
368
+ {error ? <div className="zsa-error" role="alert">{error}</div> : null}
369
+ </div>
370
+ <div className="zsa-field">
371
+ <Switch on={on} disabled={busy} onChange={(next) => void toggle(next)} />
372
+ </div>
373
+ </div>
374
+ )
375
+ }
376
+
245
377
  /**
246
378
  * 通用设置里的一行:自动归档天数。
247
379
  * 失焦或回车即落盘(与设置里其它行一样即时生效),写的是宿主同一个 days 字段。
@@ -301,7 +433,7 @@ export function AutoArchiveRow() {
301
433
  max={3650}
302
434
  step={1}
303
435
  value={value}
304
- disabled={busy}
436
+ disabled={busy || state.enabled === false}
305
437
  aria-label="自动归档天数"
306
438
  onChange={(event) => { setDraft(event.target.value); setError(null) }}
307
439
  onBlur={() => { void commit() }}
@@ -334,13 +466,21 @@ export function apply(ctx) {
334
466
  } catch { /* 清理不了也只是多一个定时器 */ }
335
467
  } catch { /* 见上 */ }
336
468
 
337
- // 三处:菜单里并列一行 + 行首标记 + 悬停说明。行尾那排图标按钮不放东西。
469
+ // 四处:菜单里并列一行 + 行尾悬停组里归档旁的盾牌按钮 + 行首标记 + 悬停说明。
470
+ // 行尾按钮 order 150:归档是 100、置顶是 200,插在两者之间,紧挨归档。
338
471
  place(ctx, 'sidebar.workspaces.session.menu.item', 450, ProtectMenuItem)
472
+ place(ctx, 'sidebar.workspaces.session.row.action', 150, ProtectRowAction)
339
473
  place(ctx, 'sidebar.session.row.leading', 100, ProtectLeading)
340
474
  place(ctx, 'sidebar.session.row.hover', 100, ProtectHover)
341
475
 
342
- // 通用设置里一行,排在「当前版本」上面(order 95,当前版本是 100):自动归档天数。
476
+ // 通用设置里两行,排在「当前版本」上面(order 94/95,当前版本是 100):
477
+ // 「自动归档」开关与「自动归档天数」。先开关后天数,与页面从上到下的阅读顺序一致。
343
478
  try {
479
+ ctx.slots.inject('settings.general.item', () => ctx.slots.register({
480
+ name: 'settings.general.item',
481
+ id: 'session-autarchive-enabled',
482
+ order: 94,
483
+ }, AutoArchiveToggleRow))
344
484
  ctx.slots.inject('settings.general.item', () => ctx.slots.register({
345
485
  name: 'settings.general.item',
346
486
  id: 'session-autarchive-days',
package/invariant.js DELETED
@@ -1,10 +0,0 @@
1
- const PACKAGE_NAME = 'dsh-session-autarchive'
2
-
3
- /** Cordis companion plugin name. */
4
- export const name = 'dsh-session-autarchive-invariant'
5
- /** Service required before the companion can reserve package ownership. */
6
- export const inject = ['invariants']
7
-
8
- const install = () => {}
9
-
10
- export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))