dsh-session-superagent 0.8.16 → 0.8.27

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/index.js CHANGED
@@ -182,6 +182,22 @@ function ghEnv() {
182
182
  return { ...process.env, GH_TOKEN: String(s.token) }
183
183
  }
184
184
 
185
+ /**
186
+ * 安全地取 agents 服务。
187
+ * 定时器回调跑在「非活跃上下文」里,直接读 `ctx.agents` 会抛
188
+ * `cannot get required service "agents" in inactive context`(2026-11-02 真机复验踩到);
189
+ * `ctx.get(name)` 不抛,取不到就返回 undefined。
190
+ */
191
+ function agentsService(ctx) {
192
+ try {
193
+ if (typeof ctx.get === 'function') {
194
+ const viaGet = ctx.get('agents')
195
+ if (viaGet) return viaGet
196
+ }
197
+ } catch {}
198
+ try { return ctx.agents } catch { return undefined }
199
+ }
200
+
185
201
  /** gh CLI 可用性探针:5 分钟缓存,别让面板每 5 秒都 spawn 一次 gh。 */
186
202
  const ghHealthCache = { at: 0, value: null }
187
203
  async function ghHealth() {
@@ -339,7 +355,8 @@ function defineTool(options) {
339
355
  * inbox alone only parks the message (the client shows it as a pending draft).
340
356
  */
341
357
  function wake(ctx, sessionId, text) {
342
- const agent = ctx.agents && typeof ctx.agents.get === 'function' ? ctx.agents.get(sessionId) : undefined
358
+ const agents = agentsService(ctx)
359
+ const agent = agents && typeof agents.get === 'function' ? agents.get(sessionId) : undefined
343
360
  if (!agent) return { ok: false, reason: 'session not live' }
344
361
 
345
362
  const allowed = decideWake(agent.status)
@@ -468,7 +485,8 @@ async function pollOnce(ctx, entry, baseMs) {
468
485
  return
469
486
  }
470
487
  // 会话忙就别开隔离区了:白建一个没人用的 worktree
471
- const liveAgent = ctx.agents && typeof ctx.agents.get === 'function' ? ctx.agents.get(entry.sessionId) : undefined
488
+ const liveAgents = agentsService(ctx)
489
+ const liveAgent = liveAgents && typeof liveAgents.get === 'function' ? liveAgents.get(entry.sessionId) : undefined
472
490
  if (!liveAgent) {
473
491
  // 会话已经不在了(被删/换机器):别开隔离区,也别假装在盯。
474
492
  // 必须走 bumpFailure —— 否则这条会每 intervalSec 打一次 GitHub,永不退避(第四轮审核指出)。
@@ -540,7 +558,10 @@ export function apply(ctx) {
540
558
  try {
541
559
  await pollOnce(ctx, entry, every)
542
560
  } catch (error) {
561
+ // 未预期的异常(比如宿主上下文/服务不可用)也必须退避:
562
+ // 之前只写 lastError,于是坏掉的 watcher 会按 intervalSec 无限重试(真机踩到过)。
543
563
  entry.lastError = String((error && error.message) || error)
564
+ bumpFailure(entry, every)
544
565
  } finally {
545
566
  inFlight.delete(entry.id)
546
567
  persistEntry(entry)
@@ -569,6 +590,17 @@ export function apply(ctx) {
569
590
  const registerRoutes = (httpServer) => {
570
591
  if (!httpServer || typeof httpServer.register !== 'function') return
571
592
  const routes = []
593
+ // 宿主热重载时旧实例的路由可能还没 dispose;一条重复会让整个 try 崩掉,
594
+ // 面板和所有接口一起 404(2026-11-02 真机踩到 webserver: duplicate exact route)。
595
+ // 改成每条路由单独容错:重复的那条记下来,其余照常注册。
596
+ const failedRoutes = []
597
+ const addRoute = (options) => {
598
+ try {
599
+ routes.push(httpServer['register'](options))
600
+ } catch (error) {
601
+ failedRoutes.push(String((error && error.message) || error).slice(0, 120))
602
+ }
603
+ }
572
604
  const record = (kind, detail) => {
573
605
  const state = readState()
574
606
  state.client = state.client || {}
@@ -576,7 +608,7 @@ export function apply(ctx) {
576
608
  writeState(state)
577
609
  }
578
610
  try {
579
- routes.push(httpServer.register({
611
+ addRoute({
580
612
  kind: 'exact',
581
613
  path: '/session-superagent/state',
582
614
  handler: async (req, res) => {
@@ -627,8 +659,8 @@ export function apply(ctx) {
627
659
  watchers: watchers.map((w) => ({ ...w, goal: goalView(ctx, w.sessionId) })),
628
660
  }))
629
661
  },
630
- }))
631
- routes.push(httpServer.register({
662
+ })
663
+ addRoute({
632
664
  kind: 'exact',
633
665
  path: '/session-superagent/client-note',
634
666
  handler: (req, res) => {
@@ -640,12 +672,12 @@ export function apply(ctx) {
640
672
  }
641
673
  res.statusCode = 204; res.end()
642
674
  },
643
- }))
644
- routes.push(httpServer.register({
675
+ })
676
+ addRoute({
645
677
  kind: 'exact',
646
678
  path: '/session-superagent/client-error',
647
679
  handler: (req, res) => { record('error', String(req.url).slice(0, 400)); res.statusCode = 204; res.end() },
648
- }))
680
+ })
649
681
  const readBody = (req) => new Promise((resolve) => {
650
682
  const chunks = []
651
683
  req.on('data', (chunk) => chunks.push(chunk))
@@ -666,7 +698,7 @@ export function apply(ctx) {
666
698
 
667
699
  // Create or update a watcher from the panel. The panel is session-scoped,
668
700
  // so it must send the session id it is bound to.
669
- routes.push(httpServer.register({
701
+ addRoute({
670
702
  kind: 'exact',
671
703
  path: '/session-superagent/watch',
672
704
  handler: async (req, res) => {
@@ -747,11 +779,11 @@ export function apply(ctx) {
747
779
  }
748
780
  json(res, 200, { ok: true, id, repo, intervalSec, enabled: entry.enabled })
749
781
  },
750
- }))
782
+ })
751
783
 
752
784
  // Credentials typed by the operator. Stored 0600 under the state dir and
753
785
  // never echoed back; the answer carries only a masked summary.
754
- routes.push(httpServer.register({
786
+ addRoute({
755
787
  kind: 'exact',
756
788
  path: '/session-superagent/credentials',
757
789
  handler: async (req, res) => {
@@ -775,12 +807,12 @@ export function apply(ctx) {
775
807
  writeSecrets(next)
776
808
  json(res, 200, { credentials: secretsSummary() })
777
809
  },
778
- }))
810
+ })
779
811
 
780
812
  // Set / edit / clear the session's REAL goal through ctx.goals — the same
781
813
  // service the /goal command drives, so the panel and the conversation are
782
814
  // one source of truth (polling reads it back).
783
- routes.push(httpServer.register({
815
+ addRoute({
784
816
  kind: 'exact',
785
817
  path: '/session-superagent/goal',
786
818
  handler: async (req, res) => {
@@ -812,23 +844,29 @@ export function apply(ctx) {
812
844
  return json(res, 400, { error: String((error && error.message) || error) })
813
845
  }
814
846
  },
815
- }))
847
+ })
816
848
 
817
849
  const state = readState()
818
- state.server = { at: new Date().toISOString(), routes: routes.length }
850
+ state.server = { at: new Date().toISOString(), routes: routes.length, failed: failedRoutes }
819
851
  writeState(state)
820
852
  } catch (error) {
821
853
  const state = readState()
822
- state.server = { at: new Date().toISOString(), error: String((error && error.message) || error).slice(0, 300) }
854
+ state.server = { at: new Date().toISOString(), routes: routes.length, failed: failedRoutes, error: String((error && error.message) || error).slice(0, 300) }
823
855
  writeState(state)
824
856
  }
825
- ctx.on('dispose', () => {
857
+ const teardown = () => {
826
858
  for (const dispose of routes) { try { dispose() } catch {} }
859
+ routes.length = 0
827
860
  // 插件卸载/热重载后旧定时器不能继续跑:它们还持有已 dispose 的 ctx
828
861
  for (const handle of timers.values()) { try { clearInterval(handle) } catch {} }
829
862
  timers.clear()
830
863
  try { clearInterval(sweepHandle) } catch {}
831
- })
864
+ }
865
+ // 两条路都挂上:ctx.effect 的清理函数在 effect 作用域销毁时一定被调用;
866
+ // ctx.on('dispose') 覆盖宿主走事件广播的卸载方式。真机踩过「路由没 dispose →
867
+ // 重载后重复路由 → 六条接口全灭」,多挂一条成本很低。
868
+ try { ctx.effect(() => teardown) } catch {}
869
+ ctx.on('dispose', teardown)
832
870
  }
833
871
 
834
872
  // Routes for the panel. `webServer` is the host's route registry (the same
@@ -1018,10 +1056,22 @@ export function apply(ctx) {
1018
1056
  description: 'Watch a GitHub repository and wake THIS session when its issues or pull requests change. Idempotent per (repo, session). Default interval 60 s, minimum 30 s.',
1019
1057
  parameters: {
1020
1058
  repo: { type: 'string', description: 'owner/name. Omit to infer from the current working directory.' },
1021
- intervalSec: { type: 'number', description: 'Polling interval in seconds, default 60.' },
1059
+ intervalSec: { type: 'number', description: 'Polling interval in seconds, clamped to 30-3600, default 60.' },
1060
+ labels: { type: 'array', items: { type: 'string' }, description: 'Only handle items carrying one of these labels (whitelist).' },
1061
+ ignoreLabels: { type: 'array', items: { type: 'string' }, description: 'Skip items carrying any of these labels.' },
1062
+ onlyAssigned: { type: 'boolean', description: 'Only handle items assigned to someone (or to `assignee`).' },
1063
+ assignee: { type: 'string', description: 'With onlyAssigned: require this login.' },
1064
+ observe: { type: 'boolean', description: 'Observe only: record what WOULD be woken, never wake.' },
1065
+ isolated: { type: 'boolean', description: 'Run each wake in a throwaway git worktree.' },
1066
+ workdir: { type: 'string', description: 'Repository used for isolation worktrees (absolute path).' },
1067
+ activeHours: { type: 'string', description: 'Wake only inside this window, e.g. "09:00-23:00" (supports overnight).' },
1068
+ batchLimit: { type: 'number', description: 'Max items listed per wake (1-20, default 5).' },
1069
+ maxWakesPerHour: { type: 'number', description: 'Max wakes per hour (1-60, default 6).' },
1070
+ goal: { type: 'string', description: 'Optional per-watcher goal appended to the wake message.' },
1022
1071
  },
1023
1072
  async execute(args) {
1024
- const agent = ctx.agents && typeof ctx.agents.currentInitiator === 'function' ? ctx.agents.currentInitiator() : undefined
1073
+ const toolAgents = agentsService(ctx)
1074
+ const agent = toolAgents && typeof toolAgents.currentInitiator === 'function' ? toolAgents.currentInitiator() : undefined
1025
1075
  const sessionId = agent && agent.id
1026
1076
  if (!sessionId) return '[error: no live session — watch_start must be called from a session turn]'
1027
1077
 
@@ -1044,14 +1094,46 @@ export function apply(ctx) {
1044
1094
  enabled: true,
1045
1095
  createdAt: new Date().toISOString(),
1046
1096
  }
1047
- if (typeof args.intervalSec === 'number' && args.intervalSec > 0) entry.intervalSec = args.intervalSec
1097
+ if (typeof args.intervalSec === 'number' && args.intervalSec > 0) {
1098
+ // 之前只判断 >0,能把 5 秒存进状态文件(调度时会按 30 秒跑,但状态与实际不一致)
1099
+ entry.intervalSec = Math.max(30, Math.min(3600, Math.round(args.intervalSec)))
1100
+ }
1101
+ const patch = {}
1102
+ if (args.labels !== undefined) patch.labels = args.labels
1103
+ if (args.ignoreLabels !== undefined) patch.ignoreLabels = args.ignoreLabels
1104
+ if (args.onlyAssigned !== undefined) patch.onlyAssigned = args.onlyAssigned
1105
+ if (args.assignee !== undefined) patch.assignee = args.assignee
1106
+ if (Object.keys(patch).length > 0) entry.filters = mergeFilters(entry.filters, patch)
1107
+ if (typeof args.goal === 'string') entry.goal = args.goal.slice(0, 500)
1108
+ if (typeof args.observe === 'boolean') entry.observe = args.observe
1109
+ if (typeof args.isolated === 'boolean') entry.isolated = args.isolated
1110
+ if (typeof args.workdir === 'string' && args.workdir.trim()) {
1111
+ const workdir = args.workdir.trim()
1112
+ if (!isAbsolute(workdir)) return '[error: workdir must be an absolute path]'
1113
+ if (!existsSync(workdir)) return '[error: workdir does not exist: ' + workdir + ']'
1114
+ entry.workdir = workdir
1115
+ }
1116
+ if (typeof args.activeHours === 'string') entry.activeHours = args.activeHours.trim() || null
1117
+ if (typeof args.batchLimit === 'number') entry.batchLimit = Math.max(1, Math.min(20, Math.round(args.batchLimit)))
1118
+ if (typeof args.maxWakesPerHour === 'number') entry.maxWakesPerHour = Math.max(1, Math.min(60, Math.round(args.maxWakesPerHour)))
1048
1119
  entry.enabled = true
1049
1120
  state.watchers = state.watchers || {}
1050
1121
  state.watchers[id] = entry
1051
1122
  writeState(state)
1052
1123
  stopWatcher(id)
1053
1124
  startWatcher(entry)
1054
- return `watching ${repo} → session ${sessionId} every ${entry.intervalSec}s${existing ? ' (already existed)' : ''}`
1125
+ const flags = []
1126
+ if (entry.observe === true) flags.push('observe-only(只记录不唤醒)')
1127
+ if (entry.isolated === true) flags.push('isolated')
1128
+ if (entry.activeHours) flags.push('activeHours=' + entry.activeHours)
1129
+ if (entry.filters && entry.filters.labels && entry.filters.labels.length) flags.push('labels=' + entry.filters.labels.join(','))
1130
+ if (entry.filters && entry.filters.onlyAssigned) flags.push('onlyAssigned' + (entry.filters.assignee ? '(' + entry.filters.assignee + ')' : ''))
1131
+ if (entry.batchLimit) flags.push('batch=' + entry.batchLimit)
1132
+ if (entry.maxWakesPerHour) flags.push('perHour=' + entry.maxWakesPerHour)
1133
+ if (entry.goal) flags.push('goal=' + JSON.stringify(entry.goal.slice(0, 40)))
1134
+ return `watching ${repo} → session ${sessionId} every ${entry.intervalSec}s` +
1135
+ (flags.length ? ' [' + flags.join(' ') + ']' : '') +
1136
+ (existing ? ' (updated existing watcher)' : '')
1055
1137
  },
1056
1138
  }))
1057
1139
 
@@ -1064,14 +1146,25 @@ export function apply(ctx) {
1064
1146
  const list = Object.values(state.watchers || {})
1065
1147
  if (!list.length) return '(no watchers)'
1066
1148
  return list
1067
- .map((w) => [
1068
- w.repo, '→', w.sessionId,
1069
- '| every ' + w.intervalSec + 's',
1070
- '| cursor=' + (w.cursor || '-'),
1071
- '| lastPoll=' + (w.lastPoll || '-'),
1072
- '| lastWake=' + (w.lastWake || '-'),
1073
- '| err=' + (w.lastError || '-'),
1074
- ].join(' '))
1149
+ .map((w) => {
1150
+ const flags = []
1151
+ if (w.observe === true) flags.push('observe')
1152
+ if (w.isolated === true) flags.push('isolated')
1153
+ if (w.enabled === false) flags.push('paused')
1154
+ if (w.activeHours) flags.push('hours=' + w.activeHours)
1155
+ if (w.filters && w.filters.labels && w.filters.labels.length) flags.push('labels=' + w.filters.labels.join(','))
1156
+ if (w.filters && w.filters.onlyAssigned) flags.push('onlyAssigned')
1157
+ if (w.lastDeferred) flags.push('deferred=' + JSON.stringify(String(w.lastDeferred).slice(0, 40)))
1158
+ return [
1159
+ w.repo, '→', w.sessionId,
1160
+ '| every ' + w.intervalSec + 's',
1161
+ flags.length ? '| ' + flags.join(' ') : '',
1162
+ '| cursor=' + (w.cursor || '-'),
1163
+ '| lastPoll=' + (w.lastPoll || '-'),
1164
+ '| lastWake=' + (w.lastWake || '-'),
1165
+ '| err=' + (w.lastError || '-'),
1166
+ ].filter(Boolean).join(' ')
1167
+ })
1075
1168
  .join('\n')
1076
1169
  },
1077
1170
  }))
@@ -1081,7 +1174,8 @@ export function apply(ctx) {
1081
1174
  description: 'Stop watching a repository for the current session.',
1082
1175
  parameters: { repo: { type: 'string', required: true, description: 'owner/name' } },
1083
1176
  async execute(args) {
1084
- const agent = ctx.agents && typeof ctx.agents.currentInitiator === 'function' ? ctx.agents.currentInitiator() : undefined
1177
+ const toolAgents = agentsService(ctx)
1178
+ const agent = toolAgents && typeof toolAgents.currentInitiator === 'function' ? toolAgents.currentInitiator() : undefined
1085
1179
  const sessionId = agent && agent.id
1086
1180
  const id = `${args.repo}#${sessionId}`
1087
1181
  const state = readState()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-superagent",
3
- "version": "0.8.16",
3
+ "version": "0.8.27",
4
4
  "description": "Session-level superagent for DeepSeek Harness: watch a GitHub repository and wake a session when something needs doing. Credentials never reach the model.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -18,10 +18,9 @@
18
18
  "CHANGELOG.md",
19
19
  "LICENSE",
20
20
  "client.js",
21
- "scripts/preview-orbit.mjs",
22
- "scripts/port-official-ui.mjs",
23
- "scripts/sync-profile.mjs",
24
- "scripts/render-preview.mjs"
21
+ "UPDATING.md",
22
+ "CHECKLIST.md",
23
+ "MAINTENANCE.md"
25
24
  ],
26
25
  "engines": {
27
26
  "node": ">=22.19.0",
@@ -35,7 +34,8 @@
35
34
  "smoke:observe": "node scripts/smoke-observe.mjs",
36
35
  "sync:profile:check": "node scripts/sync-profile.mjs --check",
37
36
  "sync:profile:repair": "node scripts/sync-profile.mjs --repair",
38
- "preview": "node scripts/render-preview.mjs"
37
+ "preview": "node scripts/render-preview.mjs",
38
+ "preflight": "node scripts/preflight.mjs"
39
39
  },
40
40
  "keywords": [
41
41
  "dsh",
@@ -1,40 +0,0 @@
1
- // 把官方 dsh-superagent 的 GUI 样式表移植进 client.js(可重复执行,幂等)。
2
- //
3
- // 官方包的 CSS 在编译产物里是 `var client_default = "<JSON 转义的 CSS>";`,
4
- // 这里用 JSONDecoder 的等价物(JSON.parse 的 raw 扫描)取出来,再以 JSON 字符串
5
- // 塞进本仓库 client.js 的 `const PORTED_CSS = "…";` 行。
6
- //
7
- // 用法: node scripts/port-official-ui.mjs [官方包路径]
8
- import { readFileSync, writeFileSync } from 'node:fs'
9
-
10
- const OFFICIAL = process.argv[2] ?? 'D:/主工作区/projects/dsh-superagent-prototype/lib/client.js'
11
- const TARGET = new URL('../client.js', import.meta.url)
12
-
13
- const official = readFileSync(OFFICIAL, 'utf8')
14
- const marker = 'var client_default = '
15
- const at = official.indexOf(marker)
16
- if (at < 0) throw new Error('官方产物里找不到 client_default(CSS)常量,路径对吗?' + OFFICIAL)
17
-
18
- // 从引号开始扫到匹配的收尾引号(处理反斜杠转义),再 JSON.parse 还原
19
- let i = at + marker.length
20
- if (official[i] !== '"') throw new Error('CSS 常量不是字符串字面量')
21
- let j = i + 1
22
- while (j < official.length) {
23
- if (official[j] === '\\') j += 2
24
- else if (official[j] === '"') break
25
- else j += 1
26
- }
27
- const css = JSON.parse(official.slice(i, j + 1))
28
-
29
- const client = readFileSync(TARGET, 'utf8')
30
- const linePrefix = ' const PORTED_CSS = '
31
- const start = client.indexOf(linePrefix)
32
- if (start < 0) throw new Error('client.js 里没有 PORTED_CSS 占位行')
33
- const end = client.indexOf('\n', start)
34
- if (end < 0) throw new Error('PORTED_CSS 行没有换行结尾')
35
-
36
- const next = client.slice(0, start) + linePrefix + JSON.stringify(css) + ';' + client.slice(end)
37
- writeFileSync(TARGET, next)
38
-
39
- console.log(`官方 CSS: ${css.length} 字符 / ${css.split('\n').length} 行`)
40
- console.log(`client.js: ${client.length} -> ${next.length} 字节`)
@@ -1,136 +0,0 @@
1
- // 用【真实的 client.js】渲染出 SVG,再拼成 standalone 预览页 —— 预览与线上同一份代码
2
- import { readFileSync, writeFileSync } from 'node:fs'
3
- import vm from 'node:vm'
4
-
5
- const SRC = readFileSync(new URL('../client.js', import.meta.url), 'utf8')
6
-
7
- let mod = null
8
- const sandbox = {
9
- window: {
10
- __ModuleLoader__: { load(m) { mod = m } },
11
- innerWidth: 1440, innerHeight: 900,
12
- addEventListener() {}, removeEventListener() {},
13
- },
14
- document: {
15
- getElementById: () => null,
16
- createElement: () => ({ id: '', textContent: '' }),
17
- head: { appendChild() {} },
18
- addEventListener() {}, removeEventListener() {},
19
- },
20
- fetch: () => Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('{}') }),
21
- setInterval: () => 0, clearInterval: () => {},
22
- console,
23
- }
24
- vm.createContext(sandbox)
25
- vm.runInContext(SRC, sandbox)
26
-
27
- let queue = [], index = 0
28
- const React = {
29
- Fragment: Symbol('Fragment'),
30
- createElement(type, props, ...children) {
31
- return { type, props: { ...(props || {}), children: children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false) } }
32
- },
33
- memo: (fn) => fn,
34
- useState(init) {
35
- const v = index < queue.length ? queue[index] : (typeof init === 'function' ? init() : init)
36
- index += 1
37
- return [v, () => {}]
38
- },
39
- useEffect() {},
40
- useCallback: (fn) => fn,
41
- useRef: (v) => ({ current: v }),
42
- }
43
- const regs = []
44
- mod.factory((n) => { if (n === 'react') return React; throw new Error(n) }).apply({
45
- effect: (fn) => fn(),
46
- slots: { inject: (_n, fn) => fn(), register: (o, C) => { regs.push({ o, C }); return () => {} } },
47
- })
48
- const orbit = regs.find((r) => r.o.id === 'session-superagent-orbit')
49
-
50
- const render = (watchers) => { queue = [watchers]; index = 0; return orbit.C({}) }
51
-
52
- function renderNode(node) {
53
- if (node === null || node === undefined || node === false) return ''
54
- if (typeof node === 'string' || typeof node === 'number') return String(node)
55
- if (typeof node.type === 'function') return renderNode(node.type(node.props || {}))
56
- const attrs = []
57
- for (const [k, v] of Object.entries(node.props || {})) {
58
- if (k === 'children' || v === null || v === undefined || v === false) continue
59
- if (k === 'key' || k === 'ref') continue
60
- if (k === 'style') {
61
- const css = Object.entries(v).map(([sk, sv]) => sk.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase()) + ':' + sv).join(';')
62
- attrs.push(`style="${css}"`)
63
- continue
64
- }
65
- const KEEP = new Set(['viewBox', 'attributeName', 'attributeType', 'repeatCount', 'repeatDur', 'preserveAspectRatio', 'gradientUnits', 'patternUnits', 'spreadMethod', 'startOffset', 'markerWidth', 'markerHeight', 'refX', 'refY', 'baseFrequency', 'numOctaves', 'stdDeviation', 'textLength', 'lengthAdjust'])
66
- const name = KEEP.has(k) ? k : k.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase())
67
- attrs.push(`${name}="${String(v).replace(/"/g, '&quot;')}"`)
68
- }
69
- const kids = (node.props?.children || []).map(renderNode).join('')
70
- return `<${node.type}${attrs.length ? ' ' + attrs.join(' ') : ''}>${kids}</${node.type}>`
71
- }
72
-
73
- const svgOf = (watchers) => {
74
- const tree = render(watchers)
75
- if (!tree) return ''
76
- // 找到 svg 节点(button 的唯一子元素)
77
- const find = (n) => {
78
- if (!n || typeof n !== 'object') return null
79
- if (typeof n.type === 'function') return find(n.type(n.props || {}))
80
- if (n.type === 'svg') return n
81
- for (const c of n.props?.children || []) { const hit = find(c); if (hit) return hit }
82
- return null
83
- }
84
- return renderNode(find(tree))
85
- }
86
-
87
- const w = (repo, extra = {}) => ({ repo, sessionId: 'session-' + repo, intervalSec: 30, enabled: true, lastPoll: null, lastWake: null, lastError: null, title: repo, ...extra })
88
- const cases = [
89
- ['0 个 · 空态(灰环占位,无卫星)', []],
90
- ['1 个 · 值守中', [w('MYCF711/dsh-GOkifu-compact')]],
91
- ['3 个 · 全部值守中', [w('a/b'), w('c/d'), w('e/f')]],
92
- ['3 个 · 混合(绿=值守中 / 琥珀=已暂停 / 红=轮询失败)', [w('a/b'), w('c/d', { enabled: false }), w('e/f', { lastError: 'boom' })]],
93
- ['2 个 · 全部已暂停', [w('a/b', { enabled: false }), w('c/d', { enabled: false })]],
94
- ['6 个 · 上限', [w('a/b'), w('c/d'), w('e/f'), w('g/h'), w('i/j'), w('k/l')]],
95
- ]
96
-
97
- const rows = cases.map(([label, list]) => {
98
- const svg = svgOf(list)
99
- return `<tr><td class="lb">${label}</td>
100
- <td class="big">${svg}</td>
101
- <td class="real">${svg}</td></tr>`
102
- }).join('\n')
103
-
104
- const OUT = process.argv[2] || 'E:/Documents/deepseek-harness/default-workspace/orbit-preview.html'
105
- writeFileSync(OUT, `<!doctype html>
106
- <html lang="zh-CN"><head><meta charset="utf-8"><title>轨道环预览 · dsh-session-superagent</title>
107
- <style>
108
- :root { color-scheme: dark }
109
- body { margin:0; padding:28px 34px; background:#1b1b1f; color:#e6e6e6;
110
- font:13px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif }
111
- h1 { font-size:15px; font-weight:600; margin:0 0 4px }
112
- p.sub { margin:0 0 22px; opacity:.55 }
113
- table { border-collapse:collapse }
114
- td { padding:10px 22px 10px 0; vertical-align:middle; border-bottom:1px solid #ffffff14 }
115
- td.lb { opacity:.8; white-space:nowrap; padding-right:34px }
116
- td.big svg { width:44px; height:44px } /* 放大看形状 */
117
- td.real svg { width:16px; height:16px } /* 线上就是这个大小 */
118
- th { text-align:left; font-weight:500; opacity:.45; font-size:12px; padding-bottom:6px }
119
- .note { margin-top:22px; opacity:.5; font-size:12px }
120
- code { background:#ffffff12; padding:1px 5px; border-radius:4px }
121
- </style></head>
122
- <body>
123
- <h1>轨道环 · 真实组件渲染预览</h1>
124
- <p class="sub">这份 HTML 由 <code>client.js</code> 本身渲染生成(不是另画一份),所以线上长什么样这里就是什么样。</p>
125
- <table>
126
- <tr><th>状态</th><th>放大 44px</th><th>实际 16px</th></tr>
127
- ${rows}
128
- </table>
129
- <div class="note">
130
- 值守中 = 卫星沿环公转(6s/圈)+ 环本身呼吸(2.6s);已暂停 = 虚线环、不转;轮询失败 = 红点呼吸(1.6s)。<br>
131
- 已验证动画走 SVG 自带的 SMIL,不依赖外部样式表 —— 上一版引用了不存在的 <code>.sa-sat-spin</code> 规则,所以是死的。
132
- </div>
133
- </body></html>
134
- `, 'utf8')
135
- console.log('wrote', OUT)
136
- console.log('SMIL in preview:', (readFileSync(OUT, 'utf8').match(/animateTransform/g) || []).length, 'animateTransform tags')