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/CHANGELOG.md +199 -2
- package/CHECKLIST.md +26 -0
- package/MAINTENANCE.md +35 -0
- package/README.md +30 -8
- package/UPDATING.md +43 -0
- package/client.js +12 -2
- package/lib/index.js +125 -31
- package/package.json +6 -6
- package/scripts/port-official-ui.mjs +0 -40
- package/scripts/preview-orbit.mjs +0 -136
- package/scripts/render-preview.mjs +0 -213
- package/scripts/sync-profile.mjs +0 -129
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
'
|
|
1073
|
-
'
|
|
1074
|
-
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
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, '"')}"`)
|
|
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')
|