@dsh-workbench/core 0.3.5 → 0.3.7

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.
Files changed (53) hide show
  1. package/client.js +1372 -1928
  2. package/dist/client/client.body.js +1372 -1928
  3. package/dist/client/index.js +1372 -1928
  4. package/dist/host/index.js +1569 -751
  5. package/dist/types/app/client.d.ts +76 -3355
  6. package/dist/types/app/layouts/env-gate/boot-gate.d.ts +8 -1
  7. package/dist/types/app/layouts/rightbar/market-busy.d.ts +28 -0
  8. package/dist/types/app/layouts/rightbar/market-hint.d.ts +7 -0
  9. package/dist/types/app/layouts/sidebar/DebugFooterAction.d.ts +2 -1
  10. package/dist/types/app/layouts/sidebar/RecordFooterAction.d.ts +2 -1
  11. package/dist/types/app/layouts/sidebar/SessionList.d.ts +3 -1
  12. package/dist/types/app/layouts/sidebar/session-pick.d.ts +2 -0
  13. package/dist/types/app/layouts/sidebar/use-attention.d.ts +7 -1
  14. package/dist/types/modules/panels/client.d.ts +1 -1
  15. package/dist/types/modules/panels/model/approval.d.ts +8 -0
  16. package/dist/types/modules/panels/model/diy-context.d.ts +9 -0
  17. package/dist/types/modules/panels/model/diy-record-beat.test.d.ts +1 -0
  18. package/dist/types/modules/panels/model/diy-record-close.test.d.ts +1 -0
  19. package/dist/types/modules/panels/model/diy-record-hook.test.d.ts +1 -0
  20. package/dist/types/modules/panels/model/diy-record-open.d.ts +28 -0
  21. package/dist/types/modules/panels/model/diy-record-open.test.d.ts +1 -0
  22. package/dist/types/modules/panels/model/diy-record-shot.test.d.ts +1 -0
  23. package/dist/types/modules/panels/model/diy-record-text.d.ts +16 -1
  24. package/dist/types/modules/panels/model/diy-record-tools.d.ts +2 -1
  25. package/dist/types/modules/panels/model/diy-record.d.ts +40 -1
  26. package/dist/types/modules/panels/model/diy-report.d.ts +1 -1
  27. package/dist/types/modules/panels/model/long-task-tools.d.ts +15 -0
  28. package/dist/types/modules/panels/model/long-task-tools.test.d.ts +1 -0
  29. package/dist/types/modules/panels/unit.d.ts +5 -3
  30. package/dist/types/modules/sessions/index.d.ts +1 -1
  31. package/dist/types/modules/sessions/model/attention-long-task.test.d.ts +1 -0
  32. package/dist/types/modules/sessions/model/attention.d.ts +23 -1
  33. package/dist/types/modules/sessions/model/session-node.d.ts +12 -1
  34. package/dist/types/shared/api/host/approval-call.d.ts +16 -0
  35. package/dist/types/shared/api/host/long-task-notice.d.ts +30 -0
  36. package/dist/types/shared/api/host/long-tasks.d.ts +10 -0
  37. package/dist/types/shared/api/host/mount-units.d.ts +3 -1
  38. package/dist/types/shared/api/host/preflight.d.ts +7 -1
  39. package/dist/types/shared/api/host/workshop-root.d.ts +9 -0
  40. package/dist/types/shared/api/long-tasks.d.ts +19 -0
  41. package/dist/types/shared/hooks/panel-state-report.d.ts +1 -1
  42. package/dist/types/shared/hooks/use-diy-debug.d.ts +1 -1
  43. package/dist/types/shared/hooks/use-diy-record.d.ts +1 -1
  44. package/dist/types/shared/hooks/use-long-task.d.ts +8 -2
  45. package/dist/types/shared/hooks/use-shell-long-tasks.d.ts +8 -0
  46. package/package.json +2 -2
  47. package/skills/workbench-module-diy/BUILD.md +1 -1
  48. package/skills/workbench-module-diy/FAMILY.md +1 -1
  49. package/skills/workbench-module-diy/KIT.md +26 -26
  50. package/skills/workbench-module-diy/PANEL.md +1 -1
  51. package/skills/workbench-module-diy/TOOLING.md +5 -1
  52. package/skills/workbench-module-diy/TOOLS.md +22 -2
  53. package/skills/workbench-module-diy/WRITES.md +20 -3
@@ -56,8 +56,12 @@ var VERSION_CHECK_TIMEOUT_MS = 1e4;
56
56
  var PROBE_TTL_MS = 3e4;
57
57
  /** 跑本地 git 命令的超时(毫秒);短于对外的 `OPENCLI_TIMEOUT_MS`。 */
58
58
  var GIT_TIMEOUT_MS = 1e4;
59
- var MARKER = new RegExp(`@工作台:([a-z][a-z0-9-]*:[A-Za-z0-9._~%#/-]+)`, "g");
60
- var TRAILING_PUNCTUATION = /[.,;:!?]+$/;
59
+ //#endregion
60
+ //#region ../shared/dist/api/context-ref.js
61
+ /**
62
+ * 引用标记的固定名字;完整标记是 @<这个名字>:<编码文本>,改它等于作废历史会话里已写下的引用。
63
+ */
64
+ var CONTEXT_MARKER_NAME = "工作台";
61
65
  /**
62
66
  * 把引用拼回标记体里的那一段文本(不含 @ 与标记名)。
63
67
  * @param ref 要拼的引用。
@@ -66,41 +70,6 @@ var TRAILING_PUNCTUATION = /[.,;:!?]+$/;
66
70
  function encodeRef(ref) {
67
71
  return `${ref.kind}:${ref.parts.join("/")}`;
68
72
  }
69
- /**
70
- * 把 kind:part/part 文本读回引用。
71
- * @param text 待解析文本。
72
- * @returns 没冒号、冒号在首尾、种类不是小写字母开头、或冒号后为空时给 undefined;不抛异常。
73
- */
74
- function decodeRef(text) {
75
- const at = text.indexOf(":");
76
- if (at <= 0 || at === text.length - 1) return void 0;
77
- const kind = text.slice(0, at);
78
- if (!/^[a-z][a-z0-9-]*$/.test(kind)) return void 0;
79
- const parts = text.slice(at + 1).split("/").filter((part) => part !== "");
80
- if (parts.length === 0) return void 0;
81
- return {
82
- kind,
83
- parts
84
- };
85
- }
86
- /**
87
- * 从一段文本里按出现顺序捞出所有标记引用,同一个引用只留第一次。
88
- * @param text 任意文本;null / undefined 当空串。
89
- * @returns 解析不出来的标记会被丢掉,所以结果只含合法引用,可能为空数组。
90
- */
91
- function parseMarkers(text) {
92
- const seen = /* @__PURE__ */ new Set();
93
- const refs = [];
94
- for (const match of String(text ?? "").matchAll(MARKER)) {
95
- const ref = decodeRef(match[1].replace(TRAILING_PUNCTUATION, ""));
96
- if (ref === void 0) continue;
97
- const key = encodeRef(ref);
98
- if (seen.has(key)) continue;
99
- seen.add(key);
100
- refs.push(ref);
101
- }
102
- return refs;
103
- }
104
73
  //#endregion
105
74
  //#region ../shared/dist/api/unit.js
106
75
  /**
@@ -111,162 +80,6 @@ function parseMarkers(text) {
111
80
  function hasEnvSupply(manifest) {
112
81
  return (manifest.env?.plugins ?? []).length > 0 || (manifest.env?.libs ?? []).length > 0;
113
82
  }
114
- /**
115
- * 外壳愿意认的协议版本;不在表里的模块当场被拒,不猜语义。
116
- */
117
- var SUPPORTED_API_VERSIONS = [1];
118
- /**
119
- * 协议认得的全部键;写了别的键不会被采纳(外壳静默忽略),所以字段名拼错不会有提示。
120
- */
121
- var DECLARATION_KEYS = [
122
- "apiVersion",
123
- "unit",
124
- "card",
125
- "tools",
126
- "host",
127
- "env"
128
- ];
129
- /**
130
- * 协议要求必写的键;env 不在其中(没有环境需要就不写)。
131
- */
132
- var DECLARATION_REQUIRED_KEYS = [
133
- "apiVersion",
134
- "unit",
135
- "card",
136
- "tools",
137
- "host"
138
- ];
139
- var CARD_KEYS = ["titleKey", "order"];
140
- var TOOL_KEYS = [
141
- "name",
142
- "writes",
143
- "key"
144
- ];
145
- var TOOL_KEY_SHAPE = /^[a-z][a-zA-Z0-9]*$/;
146
- var HOST_KEYS = ["inject", "optional"];
147
- function text$1(value) {
148
- return typeof value === "string" ? value.trim() : "";
149
- }
150
- function isRecord(value) {
151
- return typeof value === "object" && value !== null && !Array.isArray(value);
152
- }
153
- function outsideKeys(value, allowed) {
154
- return Object.keys(value).filter((key) => !allowed.includes(key));
155
- }
156
- function listOf(keys) {
157
- return keys.map((key) => JSON.stringify(key)).join(" / ");
158
- }
159
- function stringArrayProblems(at, value) {
160
- if (!Array.isArray(value)) return [`${at} 要是一串字符串,现在是 ${JSON.stringify(value)}`];
161
- return value.flatMap((item, index) => text$1(item) === "" ? [`${at}[${index}] 不是非空字符串:${JSON.stringify(item)}`] : []);
162
- }
163
- /**
164
- * 查清单里 dsh.workbench 那一段的形状,回一串给人看的问题(中文,带字段路径)。
165
- * @param subject 报错时用的主语,一般是包名。
166
- * @param raw 清单里那段原始值,可能什么都不是。
167
- * @returns 空数组表示形状过得去;版本不对就当场返回(后面的字段无从判起)。
168
- */
169
- function declarationProblemsOf(subject, raw) {
170
- if (!isRecord(raw)) return [`${subject}: dsh.workbench 不是一个对象 —— ${JSON.stringify(raw)}`];
171
- const version = raw.apiVersion;
172
- if (version === void 0) return [`${subject}: dsh.workbench 没有 apiVersion —— 协议从 v1 起,写 \`"apiVersion": 1\`。缺它 = 外壳不认这个模块(ADR-0038 §2.3)`];
173
- if (!Number.isInteger(version) || !SUPPORTED_API_VERSIONS.includes(version)) return [`${subject}: dsh.workbench.apiVersion 是 ${JSON.stringify(version)},这里只认 ${SUPPORTED_API_VERSIONS.join(" / ")}(要整数)`];
174
- const problems = [];
175
- const unknown = outsideKeys(raw, DECLARATION_KEYS);
176
- if (unknown.length > 0) problems.push(`${subject}: dsh.workbench 里有协议 v1 不认识的键 ${listOf(unknown)} —— 只有 ${listOf(DECLARATION_KEYS)}。(拼错的键会被外壳**静默忽略**:那句声明等于没写)`);
177
- for (const key of DECLARATION_REQUIRED_KEYS) if (raw[key] === void 0) problems.push(`${subject}: dsh.workbench 缺 ${key}` + (key === "card" ? "(这个模块没有卡片就写 `\"card\": null`)" : ""));
178
- const card = raw.card;
179
- if (card !== void 0 && card !== null) {
180
- if (!isRecord(card)) problems.push(`${subject}: dsh.workbench.card 要么是对象、要么是 null,现在是 ${JSON.stringify(card)}`);
181
- else {
182
- const extra = outsideKeys(card, CARD_KEYS);
183
- if (extra.length > 0) problems.push(`${subject}: dsh.workbench.card 里有不认的键 ${listOf(extra)} —— card 只有 ${listOf(CARD_KEYS)}(卡片默认显不显示由运行期的面板配置决定,清单里没有这一位)`);
184
- if (text$1(card.titleKey) === "") problems.push(`${subject}: dsh.workbench.card.titleKey 要是非空字符串(面板那一格的文案键)`);
185
- if (typeof card.order !== "number" || !Number.isFinite(card.order)) problems.push(`${subject}: dsh.workbench.card.order 要是数字,现在是 ${JSON.stringify(card.order)}`);
186
- }
187
- }
188
- const tools = raw.tools;
189
- if (tools !== void 0) {
190
- if (!Array.isArray(tools)) problems.push(`${subject}: dsh.workbench.tools 要是一串声明,现在是 ${JSON.stringify(tools)}`);
191
- else {
192
- const seen = /* @__PURE__ */ new Set();
193
- const seenKeys = /* @__PURE__ */ new Set();
194
- tools.forEach((tool, index) => {
195
- const at = `dsh.workbench.tools[${index}]`;
196
- if (!isRecord(tool)) {
197
- problems.push(`${subject}: ${at} 要是对象,现在是 ${JSON.stringify(tool)}`);
198
- return;
199
- }
200
- const extra = outsideKeys(tool, TOOL_KEYS);
201
- if (extra.length > 0) problems.push(`${subject}: ${at} 里有不认的键 ${listOf(extra)} —— tools 只有 ${listOf(TOOL_KEYS)}`);
202
- const name = text$1(tool.name);
203
- if (name === "") problems.push(`${subject}: ${at}.name 要是非空字符串`);
204
- else if (seen.has(name)) problems.push(`${subject}: ${at}.name「${name}」与前面那条撞了 —— 工具名会进 dsh 的工具表,不许重`);
205
- else seen.add(name);
206
- if (typeof tool.writes !== "boolean") problems.push(`${subject}: ${at}.writes 要是 true / false(它是"这会写"的唯一声明,缺了审批无从认领)`);
207
- if (tool.key !== void 0) {
208
- const key = text$1(tool.key);
209
- if (!TOOL_KEY_SHAPE.test(key)) problems.push(`${subject}: ${at}.key 要是 lowerCamelCase 的标识符(如 \`sync\` / \`podLogs\`),现在是 ${JSON.stringify(tool.key)} —— 它会变成生成物里的键名,所以不引号、不许带点或横线`);
210
- else if (seenKeys.has(key)) problems.push(`${subject}: ${at}.key「${key}」与前面那条撞了 —— 生成物里的键只能有一个`);
211
- else seenKeys.add(key);
212
- }
213
- });
214
- }
215
- }
216
- const host = raw.host;
217
- if (host !== void 0) {
218
- if (!isRecord(host)) problems.push(`${subject}: dsh.workbench.host 要是对象,现在是 ${JSON.stringify(host)}`);
219
- else {
220
- const extra = outsideKeys(host, HOST_KEYS);
221
- if (extra.length > 0) problems.push(`${subject}: dsh.workbench.host 里有不认的键 ${listOf(extra)} —— host 只有 ${listOf(HOST_KEYS)}`);
222
- if (host.inject === void 0) problems.push(`${subject}: dsh.workbench.host.inject 没写 —— 它声明这个模块要外壳的哪几个服务,外壳靠它判"能不能挂"。一个都不要也要写 \`"inject": []\``);
223
- else problems.push(...stringArrayProblems(`${subject}: dsh.workbench.host.inject`, host.inject));
224
- if (host.optional !== void 0) problems.push(...stringArrayProblems(`${subject}: dsh.workbench.host.optional`, host.optional));
225
- }
226
- }
227
- if (raw.env !== void 0 && !isRecord(raw.env)) problems.push(`${subject}: dsh.workbench.env 要是对象(command / plugins / libs)—— 语义由门禁的 env 判据查,这里只查形状`);
228
- return problems;
229
- }
230
- /**
231
- * 读一份清单:先查形状,过了再把声明规整成 manifest。
232
- * @param subject 报错时用的主语。
233
- * @param raw 清单里 dsh.workbench 那段原始值。
234
- * @returns 有问题时只有 problems;没问题时 declaration 与 manifest 都给,manifest 里的字符串已 trim。
235
- */
236
- function readDeclaration(subject, raw) {
237
- const problems = declarationProblemsOf(subject, raw);
238
- if (problems.length > 0) return { problems };
239
- const declaration = raw;
240
- const card = declaration.card;
241
- return {
242
- declaration,
243
- problems: [],
244
- manifest: {
245
- id: text$1(declaration.unit),
246
- card: card === null ? null : {
247
- titleKey: text$1(card.titleKey),
248
- order: card.order
249
- },
250
- tools: declaration.tools.map((tool) => ({
251
- name: text$1(tool.name),
252
- writes: tool.writes
253
- })),
254
- host: {
255
- inject: [...declaration.host.inject ?? []],
256
- ...declaration.host.optional === void 0 ? {} : { optional: [...declaration.host.optional] }
257
- },
258
- ...declaration.env === void 0 ? {} : { env: declaration.env }
259
- }
260
- };
261
- }
262
- /**
263
- * 由单元 id 拼出它那一族事实的范围名。
264
- * @param unit 单元 id,不带后缀。
265
- * @returns <unit>* —— 永远是范围名,不是某一条事实。
266
- */
267
- function unitFactScope(unit) {
268
- return `${unit}*`;
269
- }
270
83
  //#endregion
271
84
  //#region ../shared/dist/api/long-task.js
272
85
  /**
@@ -277,25 +90,16 @@ function unitFactScope(unit) {
277
90
  function runsRouteOf(unit) {
278
91
  return `${unit}/runs`;
279
92
  }
280
- //#endregion
281
- //#region ../shared/dist/lib/text.js
282
- /**
283
- * 把一个值裹成 shell 里的单引号字面量(值里的单引号一并转义),保证元字符按字面量传递。
284
- * @param value - 任意值;不是字符串的先 `String()`。
285
- * @returns 可安全拼进 `/bin/sh -c` 的一段文本 —— 含空格 / `$()` / `;` / 通配符的值都不会被当成语法。
286
- */
287
- function quoteShellArg(value) {
288
- return `'${String(value).replace(/'/g, `'\\''`)}'`;
289
- }
290
93
  /**
291
- * 取一段文本的末尾若干行(如命令输出),顺带去掉首尾空白。
292
- * @param text - 原文;`null` / `undefined` 当空串。
293
- * @param lines - 最多留几行,默认 6;0 给全篇、负数从开头砍掉那么多行(都是 `slice` 语义),调用方传正数。
294
- * @returns 末尾若干行、去首尾空行后的文本;原文行数不够就全给,空输入给空串。
94
+ * 停止那条写路由(走 actions 通道的资源名):`<单元>/runs/stop`。
95
+ * @param unit 单元 id。
96
+ * @returns <unit>/runs/stop。
295
97
  */
296
- function tailLines(text, lines = 6) {
297
- return String(text ?? "").trim().split("\n").slice(-lines).join("\n");
98
+ function stopRouteOf(unit) {
99
+ return `${unit}/runs/stop`;
298
100
  }
101
+ //#endregion
102
+ //#region ../shared/dist/lib/text.js
299
103
  function candidateStarts(raw) {
300
104
  const starts = [];
301
105
  for (let index = 0; index < raw.length; index += 1) {
@@ -337,262 +141,227 @@ function parseJsonLoose(text) {
337
141
  var DAY = 864e5;
338
142
  30 * DAY;
339
143
  365 * DAY;
144
+ //#endregion
145
+ //#region ../shared/dist/lib/board-fact.js
340
146
  /**
341
- * 上报文本字段的截断长度(2000 字符;workspace / reason / message 共用)。
147
+ * 判一个路径落不落在这个工作区里(按路径前缀比,不是字符串包含)。
148
+ * @param root - 工作区根路径。
149
+ * @param target - 要判的路径。
150
+ * @returns 同一个路径或在其下给 `true`;`root` 末尾带不带斜杠都认(`/a` 不匹配 `/ab`)。
342
151
  */
343
- var PANEL_STATE_MAX_TEXT = 2e3;
344
- var PHASES = [
345
- "idle",
346
- "loading",
347
- "ready",
348
- "error"
349
- ];
350
- function asRecord$8(value) {
351
- return typeof value === "object" && value !== null ? value : void 0;
352
- }
353
- function textOf$6(value, max) {
354
- if (typeof value !== "string") return "";
355
- const trimmed = value.trim();
356
- return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
357
- }
358
- function atOf(value) {
359
- if (typeof value !== "number" || !Number.isFinite(value)) return null;
360
- if (value < 0) return null;
361
- return Math.trunc(value);
362
- }
363
- function errorOf(raw) {
364
- const record = asRecord$8(raw);
365
- if (record === void 0) return void 0;
366
- const code = textOf$6(record["code"], 120);
367
- const message = textOf$6(record["message"], PANEL_STATE_MAX_TEXT);
368
- if (code === "" && message === "") return void 0;
369
- return {
370
- code: code === "" ? "unknown" : code,
371
- message
372
- };
152
+ function insideWorkshop$1(root, target) {
153
+ if (target === root) return true;
154
+ const base = root.endsWith("/") ? root : `${root}/`;
155
+ return target.startsWith(base);
373
156
  }
374
157
  /**
375
- * 收一批卡片读数上报。
376
- * @param payload - `POST` 的请求体。
377
- * @returns 至少收到一条能认的卡片才 `ok: true`;请求体不是对象、`workspace` 为空、`cards` 不是数组,
378
- * 或一条都认不出(`unit` / `route` / `phase` / `at` 缺一)时 `ok: false` + 中文原因;认不出的单条静默跳过。
158
+ * 把探针读数归成左栏那句人话的档位。
159
+ * @param probe - 探针读数。
160
+ * @param workshop - 当前工作区根路径;`null` 一律 `unknown`。
161
+ * @returns 椅面画着 → 本板 `ok` / 别板 `other-board`;没画着 → 页面已走 `off-workspace`,否则 `panel-closed`。
379
162
  */
380
- function parseCardsReport(payload) {
381
- const record = asRecord$8(payload);
382
- if (record === void 0) return {
383
- ok: false,
384
- message: "请求体要是一个对象"
385
- };
386
- const workspace = textOf$6(record["workspace"], PANEL_STATE_MAX_TEXT);
387
- if (workspace === "") return {
388
- ok: false,
389
- message: "workspace 不能是空的 —— 面板按工作区存"
390
- };
391
- const raw = record["cards"];
392
- if (!Array.isArray(raw)) return {
393
- ok: false,
394
- message: "cards 要是一个数组"
395
- };
396
- const cards = [];
397
- for (const item of raw.slice(0, 32)) {
398
- const card = asRecord$8(item);
399
- if (card === void 0) continue;
400
- const unit = textOf$6(card["unit"], 200);
401
- const route = textOf$6(card["route"], 400);
402
- const phase = textOf$6(card["phase"], 20);
403
- const at = atOf(card["at"]);
404
- if (unit === "" || route === "" || at === null || !PHASES.includes(phase)) continue;
405
- const error = errorOf(card["error"]);
406
- cards.push({
407
- unit,
408
- route,
409
- phase,
410
- at,
411
- ...error === void 0 ? {} : { error }
412
- });
163
+ function boardSituation(probe, workshop) {
164
+ if (!probe.workbench || workshop === null) return "unknown";
165
+ if (probe.bar && probe.open && probe.drawn) {
166
+ if (probe.board === "") return "unknown";
167
+ return insideWorkshop$1(workshop, probe.board) ? "ok" : "other-board";
413
168
  }
414
- if (cards.length === 0) return {
415
- ok: false,
416
- message: "cards 里没有一条能认的(unit / route / phase / at 都要有)"
417
- };
418
- return {
419
- ok: true,
420
- value: {
421
- workspace,
422
- cards
423
- }
424
- };
169
+ if (probe.where !== "" && !insideWorkshop$1(workshop, probe.where)) return "off-workspace";
170
+ return "panel-closed";
425
171
  }
172
+ //#endregion
173
+ //#region ../shared/dist/host/board-fact.js
426
174
  /**
427
- * 收一次卡片崩溃上报。
428
- * @param payload - `POST` 的请求体。
429
- * @returns `workspace` / `unit` / `reason` 都不能空,缺一给 `ok: false` + 中文原因;`at` 缺省或不合法时取当前时间。
175
+ * 把一次探针读数归成椅面归属。
176
+ * @param probe - 探针读数。
177
+ * @param workshop - 当前工作区根路径;`null` 表示还不知道当前是哪个工作区。
178
+ * @returns 外壳不在、或板路径与工作区缺一 → `unknown`;右栏没开或没画出来 → `hidden`;板在本工作区 → `workshop`,否则 `other`。
430
179
  */
431
- function parseCrashReport(payload) {
432
- const record = asRecord$8(payload);
433
- if (record === void 0) return {
434
- ok: false,
435
- message: "请求体要是一个对象"
436
- };
437
- const workspace = textOf$6(record["workspace"], PANEL_STATE_MAX_TEXT);
438
- const unit = textOf$6(record["unit"], 200);
439
- const reason = textOf$6(record["reason"], PANEL_STATE_MAX_TEXT);
440
- if (workspace === "") return {
441
- ok: false,
442
- message: "workspace 不能是空的"
443
- };
444
- if (unit === "") return {
445
- ok: false,
446
- message: "unit 不能是空的"
447
- };
448
- if (reason === "") return {
449
- ok: false,
450
- message: "reason 不能是空的(没有原因的崩溃没法查)"
451
- };
452
- return {
453
- ok: true,
454
- value: {
455
- workspace,
456
- unit,
457
- reason,
458
- at: atOf(record["at"]) ?? Date.now()
459
- }
460
- };
180
+ function boardFactOf(probe, workshop) {
181
+ if (!probe.workbench) return "unknown";
182
+ if (!probe.bar || !probe.open || !probe.drawn) return "hidden";
183
+ if (probe.board === "" || workshop === null) return "unknown";
184
+ return insideWorkshop$1(workshop, probe.board) ? "workshop" : "other";
461
185
  }
186
+ //#endregion
187
+ //#region ../shared/dist/host/shell-text.js
462
188
  /**
463
- * 造一份面板运行态的留存(进程内存、按工作区分桶,带上面那几条上限的 LRU)。
464
- * @returns 留存口:`cards` / `crash` 收上报,`snapshot(workspace)` 取全量;没存过的工作区(含 `null`)给一份空的。
189
+ * 把一个值裹成 shell 里的单引号字面量(值里的单引号一并转义),保证元字符按字面量传递。
190
+ * @param value - 任意值;不是字符串的先 `String()`。
191
+ * @returns 可安全拼进 `/bin/sh -c` 的一段文本 —— 含空格 / `$()` / `;` / 通配符的值都不会被当成语法。
465
192
  */
466
- function createPanelStateStore() {
467
- const workspaces = /* @__PURE__ */ new Map();
468
- function trim(entry, max) {
469
- while (entry.size > max) {
470
- const oldest = entry.keys().next();
471
- if (oldest.done === true) return;
472
- entry.delete(oldest.value);
473
- }
474
- }
475
- function workspaceEntryOf(workspace) {
476
- const existing = workspaces.get(workspace);
477
- if (existing !== void 0) return existing;
478
- const created = {
479
- at: 0,
480
- units: /* @__PURE__ */ new Map()
481
- };
482
- workspaces.set(workspace, created);
483
- trim(workspaces, 64);
484
- return created;
485
- }
486
- function unitEntryOf(entry, unit) {
487
- const existing = entry.units.get(unit);
488
- if (existing !== void 0) return existing;
489
- const created = {
490
- at: 0,
491
- cards: /* @__PURE__ */ new Map()
492
- };
493
- entry.units.set(unit, created);
494
- return created;
495
- }
193
+ function quoteShellArg(value) {
194
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
195
+ }
196
+ /**
197
+ * 取一段文本的末尾若干行(如命令输出),顺带去掉首尾空白。
198
+ * @param text - 原文;`null` / `undefined` 当空串。
199
+ * @param lines - 最多留几行,默认 6;0 给全篇、负数从开头砍掉那么多行(都是 `slice` 语义),调用方传正数。
200
+ * @returns 末尾若干行、去首尾空行后的文本;原文行数不够就全给,空输入给空串。
201
+ */
202
+ function tailLines(text, lines = 6) {
203
+ return String(text ?? "").trim().split("\n").slice(-lines).join("\n");
204
+ }
205
+ //#endregion
206
+ //#region ../shared/dist/host/opencli-window.js
207
+ var OPENCLI_WINDOW_ENV = "OPENCLI_WINDOW=background";
208
+ function backgroundPrefix() {
209
+ return [quoteShellArg("env"), quoteShellArg(OPENCLI_WINDOW_ENV)];
210
+ }
211
+ function backgroundLine(command, argv) {
212
+ return [
213
+ ...backgroundPrefix(),
214
+ quoteShellArg(command),
215
+ ...argv.map(quoteShellArg)
216
+ ].join(" ");
217
+ }
218
+ //#endregion
219
+ //#region ../shared/dist/host/shell.js
220
+ var DEFAULT_MAX_BYTES = 8388608;
221
+ async function runShell(shell, command, options = {}) {
222
+ const { workdir, timeoutMs = GIT_TIMEOUT_MS, signal } = options;
223
+ const spec = shell.resolve({
224
+ command,
225
+ ...workdir === void 0 ? {} : { workdir },
226
+ timeoutMs,
227
+ onExpiry: "kill",
228
+ stdoutMaxBytes: DEFAULT_MAX_BYTES,
229
+ ...signal === void 0 ? {} : { signal }
230
+ });
231
+ const result = await (await shell.execute(spec)).result();
496
232
  return {
497
- cards(report) {
498
- const entry = workspaceEntryOf(report.workspace);
499
- for (const card of report.cards) {
500
- const unit = unitEntryOf(entry, card.unit);
501
- unit.cards.delete(card.route);
502
- unit.cards.set(card.route, card);
503
- trim(unit.cards, 12);
504
- if (card.at > unit.at) unit.at = card.at;
505
- if (card.at > entry.at) entry.at = card.at;
506
- }
507
- },
508
- crash(report) {
509
- const entry = workspaceEntryOf(report.workspace);
510
- const unit = unitEntryOf(entry, report.unit);
511
- const previous = unit.crash;
512
- const counted = previous !== void 0 && previous.reason === report.reason && report.at >= previous.at && report.at - previous.at <= 6e5;
513
- unit.crash = {
514
- unit: report.unit,
515
- reason: report.reason,
516
- at: report.at,
517
- count: counted ? previous.count + 1 : 1
518
- };
519
- if (report.at > unit.at) unit.at = report.at;
520
- if (report.at > entry.at) entry.at = report.at;
521
- },
522
- snapshot(workspace) {
523
- if (workspace === null) return {
524
- workspace: null,
525
- at: 0,
526
- units: []
527
- };
528
- const entry = workspaces.get(workspace);
529
- if (entry === void 0) return {
530
- workspace,
531
- at: 0,
532
- units: []
533
- };
534
- const units = [...entry.units.entries()].map(([unit, one]) => {
535
- const cards = [...one.cards.values()].sort((a, b) => a.route.localeCompare(b.route));
536
- const okAt = cards.reduce((best, card) => card.phase === "ready" && (best === null || card.at > best) ? card.at : best, null);
537
- return {
538
- unit,
539
- at: one.at,
540
- okAt,
541
- cards,
542
- ...one.crash === void 0 ? {} : { crash: one.crash }
543
- };
544
- });
545
- units.sort((a, b) => b.at - a.at || a.unit.localeCompare(b.unit));
546
- return {
547
- workspace,
548
- at: entry.at,
549
- units
233
+ exitCode: result.exitCode,
234
+ timedOut: result.timedOut === true,
235
+ aborted: result.aborted === true,
236
+ stdout: result.stdout.text,
237
+ stderr: result.stderr.text,
238
+ truncated: result.stdout.truncated || result.stderr.truncated
239
+ };
240
+ }
241
+ //#endregion
242
+ //#region ../shared/dist/host/browser-read.js
243
+ var BROWSER_SCREEN_SESSION = "workbench";
244
+ var BROWSER_READ_SESSION = "workbench-read";
245
+ var BROWSER_RECORD_SESSION = "workbench-rec";
246
+ //#endregion
247
+ //#region ../shared/dist/host/cli-error.js
248
+ function unquote(raw) {
249
+ const value = raw.trim();
250
+ if (value.length >= 2 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1);
251
+ return value;
252
+ }
253
+ function joinBlock(previous, line, fold) {
254
+ if (previous === "") return line;
255
+ return fold ? `${previous} ${line}` : `${previous}\n${line}`;
256
+ }
257
+ function parseCliErrorEnvelope(text) {
258
+ if (text === void 0 || text.trim() === "") return void 0;
259
+ const lines = text.split("\n");
260
+ const start = lines.findIndex((line) => /^error:\s*$/.test(line.trim()) || /^error:\s*\S/.test(line.trim()));
261
+ if (start === -1) return void 0;
262
+ const scalars = {};
263
+ let current;
264
+ let block;
265
+ const errorIndent = lines[start]?.search(/\S/) ?? 0;
266
+ for (const line of lines.slice(start + 1)) {
267
+ if (line.trim() === "") continue;
268
+ const indent = line.search(/\S/);
269
+ if (block !== void 0 && indent > block.indent) {
270
+ scalars[block.key] = joinBlock(scalars[block.key] ?? "", line.trim(), block.fold);
271
+ continue;
272
+ }
273
+ block = void 0;
274
+ if (indent <= errorIndent) break;
275
+ const match = /^\s*(code|message|help|exitCode):\s*(.*)$/.exec(line);
276
+ if (match === null) {
277
+ if (current !== void 0) scalars[current] = `${scalars[current] ?? ""} ${line.trim()}`.trim();
278
+ continue;
279
+ }
280
+ const [, key, value] = match;
281
+ if (key === void 0) continue;
282
+ const raw = (value ?? "").trim();
283
+ const marker = /^([|>])[0-9]*([+-]?)$/.exec(raw);
284
+ if (marker !== null) {
285
+ scalars[key] = "";
286
+ block = {
287
+ key,
288
+ fold: marker[1] === ">",
289
+ indent
550
290
  };
291
+ current = key;
292
+ continue;
551
293
  }
294
+ scalars[key] = unquote(raw);
295
+ current = key;
296
+ }
297
+ const code = scalars["code"];
298
+ const message = scalars["message"];
299
+ if (code === void 0 || code === "") return void 0;
300
+ const exitCode = scalars["exitCode"];
301
+ return {
302
+ code,
303
+ message: message ?? code,
304
+ ...scalars["help"] === void 0 || scalars["help"] === "" ? {} : { help: scalars["help"] },
305
+ ...exitCode === void 0 || Number.isNaN(Number(exitCode)) ? {} : { exitCode: Number(exitCode) }
552
306
  };
553
307
  }
554
- //#endregion
555
- //#region ../shared/dist/lib/board-fact.js
556
- /**
557
- * 判一个路径落不落在这个工作区里(按路径前缀比,不是字符串包含)。
558
- * @param root - 工作区根路径。
559
- * @param target - 要判的路径。
560
- * @returns 同一个路径或在其下给 `true`;`root` 末尾带不带斜杠都认(`/a` 不匹配 `/ab`)。
561
- */
562
- function insideWorkshop$1(root, target) {
563
- if (target === root) return true;
564
- const base = root.endsWith("/") ? root : `${root}/`;
565
- return target.startsWith(base);
308
+ function helpUrlOf(envelope) {
309
+ return /https?:\/\/[^\s"'`]+/.exec(envelope.help ?? "")?.[0];
566
310
  }
311
+ //#endregion
312
+ //#region ../shared/dist/host/context-ref.js
313
+ var MARKER = new RegExp(`@${CONTEXT_MARKER_NAME}:([a-z][a-z0-9-]*:[A-Za-z0-9._~%#/-]+)`, "g");
314
+ var TRAILING_PUNCTUATION = /[.,;:!?]+$/;
567
315
  /**
568
- * 把一次探针读数归成椅面归属。
569
- * @param probe - 探针读数。
570
- * @param workshop - 当前工作区根路径;`null` 表示还不知道当前是哪个工作区。
571
- * @returns 外壳不在、或板路径与工作区缺一 → `unknown`;右栏没开或没画出来 → `hidden`;板在本工作区 → `workshop`,否则 `other`。
316
+ * 把 kind:part/part 文本读回引用。
317
+ * @param text 待解析文本。
318
+ * @returns 没冒号、冒号在首尾、种类不是小写字母开头、或冒号后为空时给 undefined;不抛异常。
572
319
  */
573
- function boardFactOf(probe, workshop) {
574
- if (!probe.workbench) return "unknown";
575
- if (!probe.bar || !probe.open || !probe.drawn) return "hidden";
576
- if (probe.board === "" || workshop === null) return "unknown";
577
- return insideWorkshop$1(workshop, probe.board) ? "workshop" : "other";
320
+ function decodeRef(text) {
321
+ const at = text.indexOf(":");
322
+ if (at <= 0 || at === text.length - 1) return void 0;
323
+ const kind = text.slice(0, at);
324
+ if (!/^[a-z][a-z0-9-]*$/.test(kind)) return void 0;
325
+ const parts = text.slice(at + 1).split("/").filter((part) => part !== "");
326
+ if (parts.length === 0) return void 0;
327
+ return {
328
+ kind,
329
+ parts
330
+ };
578
331
  }
579
332
  /**
580
- * 把探针读数归成左栏那句人话的档位。
581
- * @param probe - 探针读数。
582
- * @param workshop - 当前工作区根路径;`null` 一律 `unknown`。
583
- * @returns 椅面画着 → 本板 `ok` / 别板 `other-board`;没画着 → 页面已走 `off-workspace`,否则 `panel-closed`。
333
+ * 从一段文本里按出现顺序捞出所有标记引用,同一个引用只留第一次。
334
+ * @param text 任意文本;null / undefined 当空串。
335
+ * @returns 解析不出来的标记会被丢掉,所以结果只含合法引用,可能为空数组。
584
336
  */
585
- function boardSituation(probe, workshop) {
586
- if (!probe.workbench || workshop === null) return "unknown";
587
- if (probe.bar && probe.open && probe.drawn) {
588
- if (probe.board === "") return "unknown";
589
- return insideWorkshop$1(workshop, probe.board) ? "ok" : "other-board";
337
+ function parseMarkers(text) {
338
+ const seen = /* @__PURE__ */ new Set();
339
+ const refs = [];
340
+ for (const match of String(text ?? "").matchAll(MARKER)) {
341
+ const ref = decodeRef(match[1].replace(TRAILING_PUNCTUATION, ""));
342
+ if (ref === void 0) continue;
343
+ const key = encodeRef(ref);
344
+ if (seen.has(key)) continue;
345
+ seen.add(key);
346
+ refs.push(ref);
590
347
  }
591
- if (probe.where !== "" && !insideWorkshop$1(workshop, probe.where)) return "off-workspace";
592
- return "panel-closed";
348
+ return refs;
593
349
  }
594
350
  //#endregion
595
- //#region ../shared/dist/lib/diy-debug.js
351
+ //#region ../shared/dist/host/data-root.js
352
+ function dshHome() {
353
+ const configured = process.env.DSH_HOME?.trim();
354
+ if (configured !== void 0 && configured !== "") return configured;
355
+ return `${process.env.HOME ?? ""}/.dsh`;
356
+ }
357
+ function workbenchRoot() {
358
+ return `${dshHome()}/workbench`;
359
+ }
360
+ function workspacesRoot() {
361
+ return `${workbenchRoot()}/workspaces`;
362
+ }
363
+ //#endregion
364
+ //#region ../shared/dist/host/diy-debug.js
596
365
  /**
597
366
  * 把工具入参收成人话动作。
598
367
  * @param value - 任意入参值。
@@ -706,6 +475,19 @@ function createDebugStore() {
706
475
  function parseRecordIntent(raw) {
707
476
  return raw === "peek" || raw === "halt" || raw === "stop" ? raw : null;
708
477
  }
478
+ /**
479
+ * 这一拍该不该落图:页面计数涨过 `shotsSeen` 才算;同一个计数已经试满就先放下,等他自己再按。
480
+ * @param state - 当前有效的那份约定。
481
+ * @param pageShots - 页面浮条上那个「已拍」计数。
482
+ * @returns 该落的那个计数;不该落给 `null`(没涨 / 试满了 / 不是个正经数)。
483
+ */
484
+ function shotDueOf(state, pageShots) {
485
+ if (!Number.isFinite(pageShots)) return null;
486
+ const n = Math.floor(pageShots);
487
+ if (n <= state.shotsSeen) return null;
488
+ if (state.shotTries !== null && state.shotTries.n === n && state.shotTries.tries >= 2) return null;
489
+ return n;
490
+ }
709
491
  function alive(state, now) {
710
492
  if (state === null) return null;
711
493
  return now - state.at <= 9e5 ? state : null;
@@ -722,6 +504,26 @@ function ownerOf(state, sessionId, now) {
722
504
  function createRecordStore() {
723
505
  let held = null;
724
506
  const live = (now) => alive(held, now);
507
+ const withShot = (found, shot, now, error) => {
508
+ const row = error === void 0 ? shot : {
509
+ n: shot.n,
510
+ at: shot.at,
511
+ path: shot.path,
512
+ error
513
+ };
514
+ const kept = found.shots.filter((one) => one.n !== shot.n);
515
+ const tries = found.shotTries !== null && found.shotTries.n === shot.n ? found.shotTries.tries : 0;
516
+ return {
517
+ ...found,
518
+ shots: [...kept, row].sort((one, two) => one.n - two.n),
519
+ shotsSeen: error === void 0 ? Math.max(found.shotsSeen, shot.n) : found.shotsSeen,
520
+ shotTries: error === void 0 ? null : {
521
+ n: shot.n,
522
+ tries: tries + 1
523
+ },
524
+ at: now
525
+ };
526
+ };
725
527
  return {
726
528
  live,
727
529
  begin(pact) {
@@ -744,6 +546,11 @@ function createRecordStore() {
744
546
  startedAt: pact.now,
745
547
  t0: null,
746
548
  t1: null,
549
+ shots: [],
550
+ shotsSeen: 0,
551
+ shotTries: null,
552
+ beats: 0,
553
+ beatMs: 0,
747
554
  at: pact.now
748
555
  };
749
556
  held = state;
@@ -800,139 +607,64 @@ function createRecordStore() {
800
607
  state
801
608
  };
802
609
  },
803
- stop(sessionId, now) {
610
+ shotTaken(sessionId, shot, now) {
804
611
  const found = ownerOf(held, sessionId, now);
805
612
  if (typeof found === "string") return {
806
613
  ok: false,
807
614
  code: found
808
615
  };
809
- held = null;
616
+ const state = withShot(found, shot, now, void 0);
617
+ held = state;
810
618
  return {
811
619
  ok: true,
812
- state: found
620
+ state
813
621
  };
814
- }
815
- };
816
- }
817
- //#endregion
818
- //#region ../shared/dist/host/opencli-window.js
819
- var OPENCLI_WINDOW_ENV = "OPENCLI_WINDOW=background";
820
- function backgroundPrefix() {
821
- return [quoteShellArg("env"), quoteShellArg(OPENCLI_WINDOW_ENV)];
822
- }
823
- function backgroundLine(command, argv) {
824
- return [
825
- ...backgroundPrefix(),
826
- quoteShellArg(command),
827
- ...argv.map(quoteShellArg)
828
- ].join(" ");
829
- }
830
- //#endregion
831
- //#region ../shared/dist/host/shell.js
832
- var DEFAULT_MAX_BYTES = 8388608;
833
- async function runShell(shell, command, options = {}) {
834
- const { workdir, timeoutMs = GIT_TIMEOUT_MS, signal } = options;
835
- const spec = shell.resolve({
836
- command,
837
- ...workdir === void 0 ? {} : { workdir },
838
- timeoutMs,
839
- onExpiry: "kill",
840
- stdoutMaxBytes: DEFAULT_MAX_BYTES,
841
- ...signal === void 0 ? {} : { signal }
842
- });
843
- const result = await (await shell.execute(spec)).result();
844
- return {
845
- exitCode: result.exitCode,
846
- timedOut: result.timedOut === true,
847
- aborted: result.aborted === true,
848
- stdout: result.stdout.text,
849
- stderr: result.stderr.text,
850
- truncated: result.stdout.truncated || result.stderr.truncated
851
- };
852
- }
853
- //#endregion
854
- //#region ../shared/dist/host/browser-read.js
855
- var BROWSER_SCREEN_SESSION = "workbench";
856
- var BROWSER_READ_SESSION = "workbench-read";
857
- var BROWSER_RECORD_SESSION = "workbench-rec";
858
- //#endregion
859
- //#region ../shared/dist/host/cli-error.js
860
- function unquote(raw) {
861
- const value = raw.trim();
862
- if (value.length >= 2 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1);
863
- return value;
864
- }
865
- function joinBlock(previous, line, fold) {
866
- if (previous === "") return line;
867
- return fold ? `${previous} ${line}` : `${previous}\n${line}`;
868
- }
869
- function parseCliErrorEnvelope(text) {
870
- if (text === void 0 || text.trim() === "") return void 0;
871
- const lines = text.split("\n");
872
- const start = lines.findIndex((line) => /^error:\s*$/.test(line.trim()) || /^error:\s*\S/.test(line.trim()));
873
- if (start === -1) return void 0;
874
- const scalars = {};
875
- let current;
876
- let block;
877
- const errorIndent = lines[start]?.search(/\S/) ?? 0;
878
- for (const line of lines.slice(start + 1)) {
879
- if (line.trim() === "") continue;
880
- const indent = line.search(/\S/);
881
- if (block !== void 0 && indent > block.indent) {
882
- scalars[block.key] = joinBlock(scalars[block.key] ?? "", line.trim(), block.fold);
883
- continue;
884
- }
885
- block = void 0;
886
- if (indent <= errorIndent) break;
887
- const match = /^\s*(code|message|help|exitCode):\s*(.*)$/.exec(line);
888
- if (match === null) {
889
- if (current !== void 0) scalars[current] = `${scalars[current] ?? ""} ${line.trim()}`.trim();
890
- continue;
891
- }
892
- const [, key, value] = match;
893
- if (key === void 0) continue;
894
- const raw = (value ?? "").trim();
895
- const marker = /^([|>])[0-9]*([+-]?)$/.exec(raw);
896
- if (marker !== null) {
897
- scalars[key] = "";
898
- block = {
899
- key,
900
- fold: marker[1] === ">",
901
- indent
622
+ },
623
+ shotFailed(sessionId, shot, now) {
624
+ const found = ownerOf(held, sessionId, now);
625
+ if (typeof found === "string") return {
626
+ ok: false,
627
+ code: found
628
+ };
629
+ const state = withShot(found, shot, now, shot.error === void 0 ? "落图失败" : shot.error);
630
+ held = state;
631
+ return {
632
+ ok: true,
633
+ state
634
+ };
635
+ },
636
+ beat(sessionId, ms, now) {
637
+ const found = ownerOf(held, sessionId, now);
638
+ if (typeof found === "string") return {
639
+ ok: false,
640
+ code: found
641
+ };
642
+ const state = {
643
+ ...found,
644
+ beats: found.beats + 1,
645
+ beatMs: Math.max(0, Math.floor(ms)),
646
+ at: now
647
+ };
648
+ held = state;
649
+ return {
650
+ ok: true,
651
+ state
652
+ };
653
+ },
654
+ stop(sessionId, now) {
655
+ const found = ownerOf(held, sessionId, now);
656
+ if (typeof found === "string") return {
657
+ ok: false,
658
+ code: found
659
+ };
660
+ held = null;
661
+ return {
662
+ ok: true,
663
+ state: found
902
664
  };
903
- current = key;
904
- continue;
905
665
  }
906
- scalars[key] = unquote(raw);
907
- current = key;
908
- }
909
- const code = scalars["code"];
910
- const message = scalars["message"];
911
- if (code === void 0 || code === "") return void 0;
912
- const exitCode = scalars["exitCode"];
913
- return {
914
- code,
915
- message: message ?? code,
916
- ...scalars["help"] === void 0 || scalars["help"] === "" ? {} : { help: scalars["help"] },
917
- ...exitCode === void 0 || Number.isNaN(Number(exitCode)) ? {} : { exitCode: Number(exitCode) }
918
666
  };
919
667
  }
920
- function helpUrlOf(envelope) {
921
- return /https?:\/\/[^\s"'`]+/.exec(envelope.help ?? "")?.[0];
922
- }
923
- //#endregion
924
- //#region ../shared/dist/host/data-root.js
925
- function dshHome() {
926
- const configured = process.env.DSH_HOME?.trim();
927
- if (configured !== void 0 && configured !== "") return configured;
928
- return `${process.env.HOME ?? ""}/.dsh`;
929
- }
930
- function workbenchRoot() {
931
- return `${dshHome()}/workbench`;
932
- }
933
- function workspacesRoot() {
934
- return `${workbenchRoot()}/workspaces`;
935
- }
936
668
  //#endregion
937
669
  //#region ../shared/dist/host/facts.js
938
670
  function createFactLog(options = {}) {
@@ -970,18 +702,240 @@ function createFactLog(options = {}) {
970
702
  }
971
703
  };
972
704
  }
973
- function toolUnits(manifests) {
974
- const map = /* @__PURE__ */ new Map();
975
- for (const manifest of manifests) for (const tool of manifest.tools ?? []) map.set(tool.name, {
976
- unit: manifest.id,
977
- writes: tool.writes
978
- });
979
- return map;
980
- }
981
- function wroteNow(result) {
982
- const wrote = (result?.value)?.wrote;
983
- return typeof wrote === "boolean" ? wrote : void 0;
984
- }
705
+ function toolUnits(manifests) {
706
+ const map = /* @__PURE__ */ new Map();
707
+ for (const manifest of manifests) for (const tool of manifest.tools ?? []) map.set(tool.name, {
708
+ unit: manifest.id,
709
+ writes: tool.writes
710
+ });
711
+ return map;
712
+ }
713
+ function wroteNow(result) {
714
+ const wrote = (result?.value)?.wrote;
715
+ return typeof wrote === "boolean" ? wrote : void 0;
716
+ }
717
+ /**
718
+ * 由单元 id 拼出它那一族事实的范围名。
719
+ * @param unit 单元 id,不带后缀。
720
+ * @returns <unit>* —— 永远是范围名,不是某一条事实。
721
+ */
722
+ function unitFactScope(unit) {
723
+ return `${unit}*`;
724
+ }
725
+ /**
726
+ * 上报文本字段的截断长度(2000 字符;workspace / reason / message 共用)。
727
+ */
728
+ var PANEL_STATE_MAX_TEXT = 2e3;
729
+ var PHASES = [
730
+ "idle",
731
+ "loading",
732
+ "ready",
733
+ "error"
734
+ ];
735
+ function asRecord$8(value) {
736
+ return typeof value === "object" && value !== null ? value : void 0;
737
+ }
738
+ function textOf$7(value, max) {
739
+ if (typeof value !== "string") return "";
740
+ const trimmed = value.trim();
741
+ return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
742
+ }
743
+ function atOf(value) {
744
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
745
+ if (value < 0) return null;
746
+ return Math.trunc(value);
747
+ }
748
+ function errorOf(raw) {
749
+ const record = asRecord$8(raw);
750
+ if (record === void 0) return void 0;
751
+ const code = textOf$7(record["code"], 120);
752
+ const message = textOf$7(record["message"], PANEL_STATE_MAX_TEXT);
753
+ if (code === "" && message === "") return void 0;
754
+ return {
755
+ code: code === "" ? "unknown" : code,
756
+ message
757
+ };
758
+ }
759
+ /**
760
+ * 收一批卡片读数上报。
761
+ * @param payload - `POST` 的请求体。
762
+ * @returns 至少收到一条能认的卡片才 `ok: true`;请求体不是对象、`workspace` 为空、`cards` 不是数组,
763
+ * 或一条都认不出(`unit` / `route` / `phase` / `at` 缺一)时 `ok: false` + 中文原因;认不出的单条静默跳过。
764
+ */
765
+ function parseCardsReport(payload) {
766
+ const record = asRecord$8(payload);
767
+ if (record === void 0) return {
768
+ ok: false,
769
+ message: "请求体要是一个对象"
770
+ };
771
+ const workspace = textOf$7(record["workspace"], PANEL_STATE_MAX_TEXT);
772
+ if (workspace === "") return {
773
+ ok: false,
774
+ message: "workspace 不能是空的 —— 面板按工作区存"
775
+ };
776
+ const raw = record["cards"];
777
+ if (!Array.isArray(raw)) return {
778
+ ok: false,
779
+ message: "cards 要是一个数组"
780
+ };
781
+ const cards = [];
782
+ for (const item of raw.slice(0, 32)) {
783
+ const card = asRecord$8(item);
784
+ if (card === void 0) continue;
785
+ const unit = textOf$7(card["unit"], 200);
786
+ const route = textOf$7(card["route"], 400);
787
+ const phase = textOf$7(card["phase"], 20);
788
+ const at = atOf(card["at"]);
789
+ if (unit === "" || route === "" || at === null || !PHASES.includes(phase)) continue;
790
+ const error = errorOf(card["error"]);
791
+ cards.push({
792
+ unit,
793
+ route,
794
+ phase,
795
+ at,
796
+ ...error === void 0 ? {} : { error }
797
+ });
798
+ }
799
+ if (cards.length === 0) return {
800
+ ok: false,
801
+ message: "cards 里没有一条能认的(unit / route / phase / at 都要有)"
802
+ };
803
+ return {
804
+ ok: true,
805
+ value: {
806
+ workspace,
807
+ cards
808
+ }
809
+ };
810
+ }
811
+ /**
812
+ * 收一次卡片崩溃上报。
813
+ * @param payload - `POST` 的请求体。
814
+ * @returns `workspace` / `unit` / `reason` 都不能空,缺一给 `ok: false` + 中文原因;`at` 缺省或不合法时取当前时间。
815
+ */
816
+ function parseCrashReport(payload) {
817
+ const record = asRecord$8(payload);
818
+ if (record === void 0) return {
819
+ ok: false,
820
+ message: "请求体要是一个对象"
821
+ };
822
+ const workspace = textOf$7(record["workspace"], PANEL_STATE_MAX_TEXT);
823
+ const unit = textOf$7(record["unit"], 200);
824
+ const reason = textOf$7(record["reason"], PANEL_STATE_MAX_TEXT);
825
+ if (workspace === "") return {
826
+ ok: false,
827
+ message: "workspace 不能是空的"
828
+ };
829
+ if (unit === "") return {
830
+ ok: false,
831
+ message: "unit 不能是空的"
832
+ };
833
+ if (reason === "") return {
834
+ ok: false,
835
+ message: "reason 不能是空的(没有原因的崩溃没法查)"
836
+ };
837
+ return {
838
+ ok: true,
839
+ value: {
840
+ workspace,
841
+ unit,
842
+ reason,
843
+ at: atOf(record["at"]) ?? Date.now()
844
+ }
845
+ };
846
+ }
847
+ /**
848
+ * 造一份面板运行态的留存(进程内存、按工作区分桶,带上面那几条上限的 LRU)。
849
+ * @returns 留存口:`cards` / `crash` 收上报,`snapshot(workspace)` 取全量;没存过的工作区(含 `null`)给一份空的。
850
+ */
851
+ function createPanelStateStore() {
852
+ const workspaces = /* @__PURE__ */ new Map();
853
+ function trim(entry, max) {
854
+ while (entry.size > max) {
855
+ const oldest = entry.keys().next();
856
+ if (oldest.done === true) return;
857
+ entry.delete(oldest.value);
858
+ }
859
+ }
860
+ function workspaceEntryOf(workspace) {
861
+ const existing = workspaces.get(workspace);
862
+ if (existing !== void 0) return existing;
863
+ const created = {
864
+ at: 0,
865
+ units: /* @__PURE__ */ new Map()
866
+ };
867
+ workspaces.set(workspace, created);
868
+ trim(workspaces, 64);
869
+ return created;
870
+ }
871
+ function unitEntryOf(entry, unit) {
872
+ const existing = entry.units.get(unit);
873
+ if (existing !== void 0) return existing;
874
+ const created = {
875
+ at: 0,
876
+ cards: /* @__PURE__ */ new Map()
877
+ };
878
+ entry.units.set(unit, created);
879
+ return created;
880
+ }
881
+ return {
882
+ cards(report) {
883
+ const entry = workspaceEntryOf(report.workspace);
884
+ for (const card of report.cards) {
885
+ const unit = unitEntryOf(entry, card.unit);
886
+ unit.cards.delete(card.route);
887
+ unit.cards.set(card.route, card);
888
+ trim(unit.cards, 12);
889
+ if (card.at > unit.at) unit.at = card.at;
890
+ if (card.at > entry.at) entry.at = card.at;
891
+ }
892
+ },
893
+ crash(report) {
894
+ const entry = workspaceEntryOf(report.workspace);
895
+ const unit = unitEntryOf(entry, report.unit);
896
+ const previous = unit.crash;
897
+ const counted = previous !== void 0 && previous.reason === report.reason && report.at >= previous.at && report.at - previous.at <= 6e5;
898
+ unit.crash = {
899
+ unit: report.unit,
900
+ reason: report.reason,
901
+ at: report.at,
902
+ count: counted ? previous.count + 1 : 1
903
+ };
904
+ if (report.at > unit.at) unit.at = report.at;
905
+ if (report.at > entry.at) entry.at = report.at;
906
+ },
907
+ snapshot(workspace) {
908
+ if (workspace === null) return {
909
+ workspace: null,
910
+ at: 0,
911
+ units: []
912
+ };
913
+ const entry = workspaces.get(workspace);
914
+ if (entry === void 0) return {
915
+ workspace,
916
+ at: 0,
917
+ units: []
918
+ };
919
+ const units = [...entry.units.entries()].map(([unit, one]) => {
920
+ const cards = [...one.cards.values()].sort((a, b) => a.route.localeCompare(b.route));
921
+ const okAt = cards.reduce((best, card) => card.phase === "ready" && (best === null || card.at > best) ? card.at : best, null);
922
+ return {
923
+ unit,
924
+ at: one.at,
925
+ okAt,
926
+ cards,
927
+ ...one.crash === void 0 ? {} : { crash: one.crash }
928
+ };
929
+ });
930
+ units.sort((a, b) => b.at - a.at || a.unit.localeCompare(b.unit));
931
+ return {
932
+ workspace,
933
+ at: entry.at,
934
+ units
935
+ };
936
+ }
937
+ };
938
+ }
985
939
  //#endregion
986
940
  //#region ../shared/dist/host/provision.js
987
941
  var PROVISION_ACTIONS = [
@@ -1480,7 +1434,7 @@ function changesHandler(log) {
1480
1434
  }
1481
1435
  function preflightHandler(preflight) {
1482
1436
  return (search) => {
1483
- preflight.run({ force: flag(search, "force") });
1437
+ preflight.run({ hard: flag(search, "hard") });
1484
1438
  return {
1485
1439
  ok: true,
1486
1440
  ...preflight.snapshot()
@@ -1596,11 +1550,17 @@ var LIVE = /* @__PURE__ */ new Set(["running", "needs-login"]);
1596
1550
  var RECENT_MS = 6e5;
1597
1551
  function createLongTaskRegistry(deps) {
1598
1552
  const runs = /* @__PURE__ */ new Map();
1553
+ const controls = /* @__PURE__ */ new Map();
1599
1554
  const now = deps.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
1600
1555
  let counter = 0;
1601
1556
  const nextId = deps.nextId ?? (() => `run-${Date.now()}-${counter += 1}`);
1602
1557
  const recentMs = deps.recentMs ?? RECENT_MS;
1603
1558
  const listeners = /* @__PURE__ */ new Set();
1559
+ const live = (id) => {
1560
+ const current = runs.get(id);
1561
+ if (current === void 0 || !LIVE.has(current.status)) return null;
1562
+ return current;
1563
+ };
1604
1564
  const notify = (change) => {
1605
1565
  for (const listener of [...listeners]) try {
1606
1566
  listener(change);
@@ -1635,7 +1595,13 @@ function createLongTaskRegistry(deps) {
1635
1595
  if (snapshot.workspace !== workspace) continue;
1636
1596
  newest.set(snapshot.target, snapshot);
1637
1597
  }
1638
- return [...newest.values()].filter((snapshot) => LIVE.has(snapshot.status) || Date.parse(snapshot.at) >= floor).reverse().map((snapshot) => deps.view(snapshot));
1598
+ return [...newest.values()].filter((snapshot) => LIVE.has(snapshot.status) || Date.parse(snapshot.at) >= floor).reverse().map((snapshot) => ({
1599
+ ...deps.view(snapshot),
1600
+ id: snapshot.id
1601
+ }));
1602
+ },
1603
+ live() {
1604
+ return [...runs.values()].filter((snapshot) => LIVE.has(snapshot.status));
1639
1605
  },
1640
1606
  start(options) {
1641
1607
  const running = this.active(options.workspace, options.target);
@@ -1650,15 +1616,16 @@ function createLongTaskRegistry(deps) {
1650
1616
  step: "",
1651
1617
  steps: [],
1652
1618
  startedAt,
1653
- at: startedAt
1619
+ at: startedAt,
1620
+ ...options.session === void 0 ? {} : { session: options.session }
1654
1621
  });
1655
1622
  notify({
1656
1623
  kind: "start",
1657
1624
  snapshot: runs.get(id)
1658
1625
  });
1659
1626
  const onStep = (step) => {
1660
- const current = runs.get(id);
1661
- if (current === void 0) return;
1627
+ const current = live(id);
1628
+ if (current === null) return;
1662
1629
  const steps = [...current.steps, `${step.kind === "fail" ? "✗ " : ""}${step.text}`].slice(-20);
1663
1630
  const extra = deps.step?.(step, current) ?? {};
1664
1631
  if (step.loginUrl !== void 0) {
@@ -1685,17 +1652,24 @@ function createLongTaskRegistry(deps) {
1685
1652
  step
1686
1653
  });
1687
1654
  };
1655
+ const control = new AbortController();
1656
+ controls.set(id, control);
1688
1657
  deps.run({
1689
1658
  payload: options.payload,
1690
- onStep
1659
+ onStep,
1660
+ signal: control.signal
1691
1661
  }).then((result) => {
1692
- const current = runs.get(id);
1662
+ const current = live(id);
1663
+ if (current === null) return;
1664
+ controls.delete(id);
1693
1665
  patch(id, {
1694
1666
  status: "ok",
1695
1667
  ...deps.outcome?.(result, current) ?? {},
1696
1668
  loginUrl: void 0
1697
1669
  }, { kind: "settle" });
1698
1670
  }).catch((error) => {
1671
+ if (live(id) === null) return;
1672
+ controls.delete(id);
1699
1673
  patch(id, {
1700
1674
  status: "failed",
1701
1675
  why: error instanceof Error ? error.message : String(error),
@@ -1704,6 +1678,21 @@ function createLongTaskRegistry(deps) {
1704
1678
  });
1705
1679
  return runs.get(id);
1706
1680
  },
1681
+ stop(id, by) {
1682
+ const current = live(id);
1683
+ if (current === null) return false;
1684
+ controls.get(id)?.abort();
1685
+ controls.delete(id);
1686
+ patch(id, {
1687
+ ...deps.stopped?.(current) ?? {},
1688
+ status: "stopped",
1689
+ loginUrl: void 0
1690
+ }, {
1691
+ kind: "settle",
1692
+ by
1693
+ });
1694
+ return true;
1695
+ },
1707
1696
  snapshot(id) {
1708
1697
  return runs.get(id) ?? null;
1709
1698
  },
@@ -1736,6 +1725,7 @@ function createLongTaskHost(options) {
1736
1725
  offs.push(registry.onChange((change) => {
1737
1726
  const named = [runsRouteOf(options.unit), ...facts?.(change) ?? []];
1738
1727
  options.bump([...new Set(named)]);
1728
+ options.notify?.(change);
1739
1729
  }));
1740
1730
  return registry;
1741
1731
  } },
@@ -1802,7 +1792,7 @@ var PLUGIN_LEDGER_FILE = "plugins.json";
1802
1792
  function ledgerKeyOf(entry) {
1803
1793
  return `${entry.opencliHome}\u0000${entry.package}`;
1804
1794
  }
1805
- function textOf$5(value) {
1795
+ function textOf$6(value) {
1806
1796
  if (typeof value !== "string") return void 0;
1807
1797
  const trimmed = value.trim();
1808
1798
  return trimmed === "" ? void 0 : trimmed;
@@ -1810,13 +1800,13 @@ function textOf$5(value) {
1810
1800
  function entryOf$1(value) {
1811
1801
  if (typeof value !== "object" || value === null) return void 0;
1812
1802
  const box = value;
1813
- const opencliHome = textOf$5(box["opencliHome"]);
1814
- const pkg = textOf$5(box["package"]);
1815
- const installedAt = textOf$5(box["installedAt"]);
1803
+ const opencliHome = textOf$6(box["opencliHome"]);
1804
+ const pkg = textOf$6(box["package"]);
1805
+ const installedAt = textOf$6(box["installedAt"]);
1816
1806
  if (opencliHome === void 0 || pkg === void 0 || installedAt === void 0) return void 0;
1817
- const name = textOf$5(box["name"]);
1818
- const registry = textOf$5(box["registry"]);
1819
- const releasedAt = textOf$5(box["releasedAt"]);
1807
+ const name = textOf$6(box["name"]);
1808
+ const registry = textOf$6(box["registry"]);
1809
+ const releasedAt = textOf$6(box["releasedAt"]);
1820
1810
  return {
1821
1811
  opencliHome,
1822
1812
  package: pkg,
@@ -2098,6 +2088,154 @@ var TEXT_OUTPUT = {
2098
2088
  function jsonText(value) {
2099
2089
  return JSON.stringify(value);
2100
2090
  }
2091
+ /**
2092
+ * 外壳愿意认的协议版本;不在表里的模块当场被拒,不猜语义。
2093
+ */
2094
+ var SUPPORTED_API_VERSIONS = [1];
2095
+ /**
2096
+ * 协议认得的全部键;写了别的键不会被采纳(外壳静默忽略),所以字段名拼错不会有提示。
2097
+ */
2098
+ var DECLARATION_KEYS = [
2099
+ "apiVersion",
2100
+ "unit",
2101
+ "card",
2102
+ "tools",
2103
+ "host",
2104
+ "env"
2105
+ ];
2106
+ /**
2107
+ * 协议要求必写的键;env 不在其中(没有环境需要就不写)。
2108
+ */
2109
+ var DECLARATION_REQUIRED_KEYS = [
2110
+ "apiVersion",
2111
+ "unit",
2112
+ "card",
2113
+ "tools",
2114
+ "host"
2115
+ ];
2116
+ var CARD_KEYS = ["titleKey", "order"];
2117
+ var TOOL_KEYS = [
2118
+ "name",
2119
+ "writes",
2120
+ "key"
2121
+ ];
2122
+ var TOOL_KEY_SHAPE = /^[a-z][a-zA-Z0-9]*$/;
2123
+ var HOST_KEYS = ["inject", "optional"];
2124
+ function text$1(value) {
2125
+ return typeof value === "string" ? value.trim() : "";
2126
+ }
2127
+ function isRecord(value) {
2128
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2129
+ }
2130
+ function outsideKeys(value, allowed) {
2131
+ return Object.keys(value).filter((key) => !allowed.includes(key));
2132
+ }
2133
+ function listOf(keys) {
2134
+ return keys.map((key) => JSON.stringify(key)).join(" / ");
2135
+ }
2136
+ function stringArrayProblems(at, value) {
2137
+ if (!Array.isArray(value)) return [`${at} 要是一串字符串,现在是 ${JSON.stringify(value)}`];
2138
+ return value.flatMap((item, index) => text$1(item) === "" ? [`${at}[${index}] 不是非空字符串:${JSON.stringify(item)}`] : []);
2139
+ }
2140
+ /**
2141
+ * 查清单里 dsh.workbench 那一段的形状,回一串给人看的问题(中文,带字段路径)。
2142
+ * @param subject 报错时用的主语,一般是包名。
2143
+ * @param raw 清单里那段原始值,可能什么都不是。
2144
+ * @returns 空数组表示形状过得去;版本不对就当场返回(后面的字段无从判起)。
2145
+ */
2146
+ function declarationProblemsOf(subject, raw) {
2147
+ if (!isRecord(raw)) return [`${subject}: dsh.workbench 不是一个对象 —— ${JSON.stringify(raw)}`];
2148
+ const version = raw.apiVersion;
2149
+ if (version === void 0) return [`${subject}: dsh.workbench 没有 apiVersion —— 协议从 v1 起,写 \`"apiVersion": 1\`。缺它 = 外壳不认这个模块(ADR-0038 §2.3)`];
2150
+ if (!Number.isInteger(version) || !SUPPORTED_API_VERSIONS.includes(version)) return [`${subject}: dsh.workbench.apiVersion 是 ${JSON.stringify(version)},这里只认 ${SUPPORTED_API_VERSIONS.join(" / ")}(要整数)`];
2151
+ const problems = [];
2152
+ const unknown = outsideKeys(raw, DECLARATION_KEYS);
2153
+ if (unknown.length > 0) problems.push(`${subject}: dsh.workbench 里有协议 v1 不认识的键 ${listOf(unknown)} —— 只有 ${listOf(DECLARATION_KEYS)}。(拼错的键会被外壳**静默忽略**:那句声明等于没写)`);
2154
+ for (const key of DECLARATION_REQUIRED_KEYS) if (raw[key] === void 0) problems.push(`${subject}: dsh.workbench 缺 ${key}` + (key === "card" ? "(这个模块没有卡片就写 `\"card\": null`)" : ""));
2155
+ const card = raw.card;
2156
+ if (card !== void 0 && card !== null) {
2157
+ if (!isRecord(card)) problems.push(`${subject}: dsh.workbench.card 要么是对象、要么是 null,现在是 ${JSON.stringify(card)}`);
2158
+ else {
2159
+ const extra = outsideKeys(card, CARD_KEYS);
2160
+ if (extra.length > 0) problems.push(`${subject}: dsh.workbench.card 里有不认的键 ${listOf(extra)} —— card 只有 ${listOf(CARD_KEYS)}(卡片默认显不显示由运行期的面板配置决定,清单里没有这一位)`);
2161
+ if (text$1(card.titleKey) === "") problems.push(`${subject}: dsh.workbench.card.titleKey 要是非空字符串(面板那一格的文案键)`);
2162
+ if (typeof card.order !== "number" || !Number.isFinite(card.order)) problems.push(`${subject}: dsh.workbench.card.order 要是数字,现在是 ${JSON.stringify(card.order)}`);
2163
+ }
2164
+ }
2165
+ const tools = raw.tools;
2166
+ if (tools !== void 0) {
2167
+ if (!Array.isArray(tools)) problems.push(`${subject}: dsh.workbench.tools 要是一串声明,现在是 ${JSON.stringify(tools)}`);
2168
+ else {
2169
+ const seen = /* @__PURE__ */ new Set();
2170
+ const seenKeys = /* @__PURE__ */ new Set();
2171
+ tools.forEach((tool, index) => {
2172
+ const at = `dsh.workbench.tools[${index}]`;
2173
+ if (!isRecord(tool)) {
2174
+ problems.push(`${subject}: ${at} 要是对象,现在是 ${JSON.stringify(tool)}`);
2175
+ return;
2176
+ }
2177
+ const extra = outsideKeys(tool, TOOL_KEYS);
2178
+ if (extra.length > 0) problems.push(`${subject}: ${at} 里有不认的键 ${listOf(extra)} —— tools 只有 ${listOf(TOOL_KEYS)}`);
2179
+ const name = text$1(tool.name);
2180
+ if (name === "") problems.push(`${subject}: ${at}.name 要是非空字符串`);
2181
+ else if (seen.has(name)) problems.push(`${subject}: ${at}.name「${name}」与前面那条撞了 —— 工具名会进 dsh 的工具表,不许重`);
2182
+ else seen.add(name);
2183
+ if (typeof tool.writes !== "boolean") problems.push(`${subject}: ${at}.writes 要是 true / false(它是"这会写"的唯一声明,缺了审批无从认领)`);
2184
+ if (tool.key !== void 0) {
2185
+ const key = text$1(tool.key);
2186
+ if (!TOOL_KEY_SHAPE.test(key)) problems.push(`${subject}: ${at}.key 要是 lowerCamelCase 的标识符(如 \`sync\` / \`podLogs\`),现在是 ${JSON.stringify(tool.key)} —— 它会变成生成物里的键名,所以不引号、不许带点或横线`);
2187
+ else if (seenKeys.has(key)) problems.push(`${subject}: ${at}.key「${key}」与前面那条撞了 —— 生成物里的键只能有一个`);
2188
+ else seenKeys.add(key);
2189
+ }
2190
+ });
2191
+ }
2192
+ }
2193
+ const host = raw.host;
2194
+ if (host !== void 0) {
2195
+ if (!isRecord(host)) problems.push(`${subject}: dsh.workbench.host 要是对象,现在是 ${JSON.stringify(host)}`);
2196
+ else {
2197
+ const extra = outsideKeys(host, HOST_KEYS);
2198
+ if (extra.length > 0) problems.push(`${subject}: dsh.workbench.host 里有不认的键 ${listOf(extra)} —— host 只有 ${listOf(HOST_KEYS)}`);
2199
+ if (host.inject === void 0) problems.push(`${subject}: dsh.workbench.host.inject 没写 —— 它声明这个模块要外壳的哪几个服务,外壳靠它判"能不能挂"。一个都不要也要写 \`"inject": []\``);
2200
+ else problems.push(...stringArrayProblems(`${subject}: dsh.workbench.host.inject`, host.inject));
2201
+ if (host.optional !== void 0) problems.push(...stringArrayProblems(`${subject}: dsh.workbench.host.optional`, host.optional));
2202
+ }
2203
+ }
2204
+ if (raw.env !== void 0 && !isRecord(raw.env)) problems.push(`${subject}: dsh.workbench.env 要是对象(command / plugins / libs)—— 语义由门禁的 env 判据查,这里只查形状`);
2205
+ return problems;
2206
+ }
2207
+ /**
2208
+ * 读一份清单:先查形状,过了再把声明规整成 manifest。
2209
+ * @param subject 报错时用的主语。
2210
+ * @param raw 清单里 dsh.workbench 那段原始值。
2211
+ * @returns 有问题时只有 problems;没问题时 declaration 与 manifest 都给,manifest 里的字符串已 trim。
2212
+ */
2213
+ function readDeclaration(subject, raw) {
2214
+ const problems = declarationProblemsOf(subject, raw);
2215
+ if (problems.length > 0) return { problems };
2216
+ const declaration = raw;
2217
+ const card = declaration.card;
2218
+ return {
2219
+ declaration,
2220
+ problems: [],
2221
+ manifest: {
2222
+ id: text$1(declaration.unit),
2223
+ card: card === null ? null : {
2224
+ titleKey: text$1(card.titleKey),
2225
+ order: card.order
2226
+ },
2227
+ tools: declaration.tools.map((tool) => ({
2228
+ name: text$1(tool.name),
2229
+ writes: tool.writes
2230
+ })),
2231
+ host: {
2232
+ inject: [...declaration.host.inject ?? []],
2233
+ ...declaration.host.optional === void 0 ? {} : { optional: [...declaration.host.optional] }
2234
+ },
2235
+ ...declaration.env === void 0 ? {} : { env: declaration.env }
2236
+ }
2237
+ };
2238
+ }
2101
2239
  //#endregion
2102
2240
  //#region ../shared/dist/host/value-handoff.js
2103
2241
  function handoffId(entry) {
@@ -2789,6 +2927,8 @@ var DIY_RECORD_PREPARE_TOOL = "workbench_diy_record_prepare";
2789
2927
  var DIY_RECORD_READ_TOOL = "workbench_diy_record_read";
2790
2928
  var DIY_RECORD_DETAIL_TOOL = "workbench_diy_record_detail";
2791
2929
  var DIY_RECORD_STOP_TOOL = "workbench_diy_record_stop";
2930
+ var LONG_TASKS_LIST_TOOL = "workbench_long_tasks_list";
2931
+ var LONG_TASKS_STOP_TOOL = "workbench_long_tasks_stop";
2792
2932
  var DIY_DEBUG_START_TOOL = "workbench_diy_debug_start";
2793
2933
  var DIY_DEBUG_PAUSE_TOOL = "workbench_diy_debug_pause";
2794
2934
  var DIY_DEBUG_RESUME_TOOL = "workbench_diy_debug_resume";
@@ -2821,7 +2961,8 @@ var READ_TOOLS = [
2821
2961
  DIY_LOGS_STOP_TOOL,
2822
2962
  DIY_RECORD_READ_TOOL,
2823
2963
  DIY_RECORD_DETAIL_TOOL,
2824
- DIY_RECORD_STOP_TOOL
2964
+ DIY_RECORD_STOP_TOOL,
2965
+ LONG_TASKS_LIST_TOOL
2825
2966
  ];
2826
2967
  var WRITE_TOOLS = [
2827
2968
  PANELS_SHOW_TOOL,
@@ -2843,7 +2984,8 @@ var WRITE_TOOLS = [
2843
2984
  DIY_DEBUG_START_TOOL,
2844
2985
  DIY_DEBUG_PAUSE_TOOL,
2845
2986
  DIY_DEBUG_RESUME_TOOL,
2846
- DIY_DEBUG_STOP_TOOL
2987
+ DIY_DEBUG_STOP_TOOL,
2988
+ LONG_TASKS_STOP_TOOL
2847
2989
  ];
2848
2990
  var DIY_TOOLS = [
2849
2991
  DIY_LIST_TOOL,
@@ -2902,6 +3044,7 @@ var panelsUnit = {
2902
3044
  //#endregion
2903
3045
  //#region src/modules/panels/model/approval.ts
2904
3046
  var SPEC_LIMIT = 80;
3047
+ var TARGET_LIMIT = 20;
2905
3048
  function missing(id) {
2906
3049
  return id === void 0 || id === "";
2907
3050
  }
@@ -2976,7 +3119,32 @@ function askOf(reason, question, fallback) {
2976
3119
  fallback
2977
3120
  };
2978
3121
  }
3122
+ function textOf$5(raw, key) {
3123
+ const value = raw[key];
3124
+ return typeof value === "string" ? value.trim() : "";
3125
+ }
3126
+ function clipped(text) {
3127
+ const chars = [...text];
3128
+ return chars.length > TARGET_LIMIT ? `${chars.slice(0, TARGET_LIMIT).join("")}…` : text;
3129
+ }
3130
+ function stopVerdict(call, runs) {
3131
+ const raw = argsOf$2(call);
3132
+ const unit = textOf$5(raw, "unit");
3133
+ const id = textOf$5(raw, "id");
3134
+ if (unit === "" || id === "") return { kind: "allow" };
3135
+ const run = runs?.().get(unit)?.snapshot(id) ?? null;
3136
+ if (runs !== void 0 && run === null) return { kind: "allow" };
3137
+ const session = run?.session ?? "";
3138
+ if (session !== "" && session === call.session) return { kind: "allow" };
3139
+ const target = run === null ? "" : run.target.trim();
3140
+ const where = target === "" ? `工作台 [${unit}] 那一趟长任务` : `工作台 [${unit}] 那一趟「${clipped(target)}」长任务`;
3141
+ const whereEn = target === "" ? `a long-running workbench job [${unit}]` : `a long-running workbench job [${unit}] "${clipped(target)}"`;
3142
+ const origin = session === "" ? "用户自己按的" : "别的会话起的";
3143
+ const originEn = session === "" ? "the user started" : "another session started";
3144
+ return askOf(`${LONG_TASKS_STOP_TOOL} ${unit} stops a long run someone else started`, pair(`会停掉${where},${origin},允许吗?`, `it stops ${whereEn} ${originEn}. Allow?`), pair(`agent 想停掉${where},${origin},允许吗?`, `The agent wants to stop ${whereEn} ${originEn}. Allow?`));
3145
+ }
2979
3146
  var panelsApproval = (call) => {
3147
+ if (call.name === "workbench_long_tasks_stop") return stopVerdict(call, void 0);
2980
3148
  if (call.name === "workbench_diy_debug_start" || call.name === "workbench_diy_debug_resume") {
2981
3149
  const starting = call.name === DIY_DEBUG_START_TOOL;
2982
3150
  const unit = argsOf$2(call)["unit"];
@@ -3048,6 +3216,15 @@ var panelsApproval = (call) => {
3048
3216
  default: return;
3049
3217
  }
3050
3218
  };
3219
+ /**
3220
+ * 面板族的审批策略:`panelsApproval` 再认长任务那一档(写成功要用户点头)。
3221
+ * `longTaskRuns` 用来查"这一趟是谁起的" —— 自己起的免问,别的会话起的 / 用户按的要问。
3222
+ * @param longTaskRuns 当前挂着的单元各自那把长任务注册表;没给就按"别人的"问(保守的那一侧)。
3223
+ * @returns 审批策略:认领的写工具回 ask / allow,不是本族的回 undefined。
3224
+ */
3225
+ function panelsApprovalFor(longTaskRuns) {
3226
+ return (call) => call.name === "workbench_long_tasks_stop" ? stopVerdict(call, longTaskRuns) : panelsApproval(call);
3227
+ }
3051
3228
  //#endregion
3052
3229
  //#region src/modules/panels/model/config.ts
3053
3230
  var EMPTY_BOARD = {
@@ -3162,40 +3339,225 @@ function added(list, id) {
3162
3339
  function dropped(list, id) {
3163
3340
  return list.filter((item) => item !== id);
3164
3341
  }
3165
- function withShown(board, id, visible) {
3166
- const next = {
3167
- shown: added(board.shown, id),
3168
- hidden: dropped(board.hidden, id)
3169
- };
3170
- return visible === void 0 ? next : {
3171
- ...next,
3172
- order: added(board.order ?? visible, id)
3173
- };
3342
+ function withShown(board, id, visible) {
3343
+ const next = {
3344
+ shown: added(board.shown, id),
3345
+ hidden: dropped(board.hidden, id)
3346
+ };
3347
+ return visible === void 0 ? next : {
3348
+ ...next,
3349
+ order: added(board.order ?? visible, id)
3350
+ };
3351
+ }
3352
+ function withHidden(board, id) {
3353
+ const order = dropped(board.order ?? [], id);
3354
+ return {
3355
+ shown: dropped(board.shown, id),
3356
+ hidden: added(board.hidden, id),
3357
+ ...order.length === 0 ? {} : { order }
3358
+ };
3359
+ }
3360
+ function withoutPanel(board, id) {
3361
+ return {
3362
+ shown: dropped(board.shown, id),
3363
+ hidden: dropped(board.hidden, id)
3364
+ };
3365
+ }
3366
+ function withMoved(board, id, index, visible) {
3367
+ const current = visible.includes(id) ? [...visible] : [...visible, id];
3368
+ const from = current.indexOf(id);
3369
+ const to = Math.max(0, Math.min(current.length - 1, Math.trunc(index)));
3370
+ if (from !== to) current.splice(to, 0, ...current.splice(from, 1));
3371
+ return {
3372
+ shown: added(board.shown, id),
3373
+ hidden: dropped(board.hidden, id),
3374
+ order: current
3375
+ };
3376
+ }
3377
+ //#endregion
3378
+ //#region src/modules/panels/model/long-task-tools.ts
3379
+ var STATUS_WORDS = {
3380
+ running: "进行中",
3381
+ "needs-login": "要登录",
3382
+ stopped: "被停了",
3383
+ ok: "已完成",
3384
+ failed: "出错"
3385
+ };
3386
+ var NO_WORKSPACE = "不知道当前会话在哪个工作区 —— 长任务按工作区记,这条读不了。";
3387
+ var NO_RUNS = "这个工作区现在没有长任务在跑(也没有十分钟内刚收尾的)。";
3388
+ var NO_RUN = "没有这一趟 —— unit/id 对不上。";
3389
+ var STALE_REGISTRY = "这一份外壳还是上一版的注册表,停不了 —— 重新起一下工作台就能停";
3390
+ var LIST_OUTPUT = {
3391
+ schema: {
3392
+ type: "object",
3393
+ properties: {
3394
+ text: { type: "string" },
3395
+ runs: {
3396
+ type: "array",
3397
+ items: {
3398
+ type: "object",
3399
+ properties: {
3400
+ unit: { type: "string" },
3401
+ id: { type: "string" },
3402
+ target: { type: "string" },
3403
+ status: { type: "string" }
3404
+ },
3405
+ additionalProperties: true
3406
+ }
3407
+ }
3408
+ },
3409
+ additionalProperties: true
3410
+ },
3411
+ render: (_args, value) => [{
3412
+ type: "text",
3413
+ text: typeof value?.text === "string" ? value.text : JSON.stringify(value)
3414
+ }]
3415
+ };
3416
+ var STOP_OUTPUT = {
3417
+ schema: {
3418
+ type: "object",
3419
+ properties: {
3420
+ text: { type: "string" },
3421
+ stopped: { type: "boolean" }
3422
+ },
3423
+ additionalProperties: true
3424
+ },
3425
+ render: (_args, value) => [{
3426
+ type: "text",
3427
+ text: typeof value?.text === "string" ? value.text : JSON.stringify(value)
3428
+ }]
3429
+ };
3430
+ function str(value) {
3431
+ return typeof value === "string" ? value.trim() : "";
3432
+ }
3433
+ function arg(raw, key) {
3434
+ return str(typeof raw === "object" && raw !== null ? raw[key] : void 0);
3435
+ }
3436
+ function statusWord(status) {
3437
+ return STATUS_WORDS[status] ?? status;
3438
+ }
3439
+ function originOf(session, caller) {
3440
+ if (session === "") return "用户按的";
3441
+ return session === caller ? "我起的" : "别的会话起的";
3442
+ }
3443
+ function listed$1(runs, workspace) {
3444
+ const out = [];
3445
+ for (const [unit, registry] of runs?.() ?? []) for (const row of registry.current(workspace)) {
3446
+ const id = str(row["id"]);
3447
+ const run = id === "" ? null : registry.snapshot(id);
3448
+ out.push({
3449
+ unit,
3450
+ id,
3451
+ target: str(row["target"]) || str(run?.target),
3452
+ status: str(row["status"]) || str(run?.status),
3453
+ step: str(row["step"]) || str(row["why"]) || str(run?.step) || str(run?.why),
3454
+ session: str(row["session"]) || str(run?.session)
3455
+ });
3456
+ }
3457
+ return out;
3458
+ }
3459
+ function lineOf(run, index, caller) {
3460
+ const parts = [
3461
+ run.unit,
3462
+ run.target,
3463
+ statusWord(run.status),
3464
+ run.step,
3465
+ run.id === "" ? "" : `id=${run.id}`,
3466
+ originOf(run.session, caller)
3467
+ ];
3468
+ return `${String(index + 1)}. ${parts.filter((part) => part !== "").join(" · ")}`;
3174
3469
  }
3175
- function withHidden(board, id) {
3176
- const order = dropped(board.order ?? [], id);
3177
- return {
3178
- shown: dropped(board.shown, id),
3179
- hidden: added(board.hidden, id),
3180
- ...order.length === 0 ? {} : { order }
3181
- };
3470
+ function renderList$1(runs, workspace, caller) {
3471
+ if (runs.length === 0) return NO_RUNS;
3472
+ return [`当前工作区:${workspace}`, ...runs.map((run, index) => lineOf(run, index, caller))].join("\n");
3182
3473
  }
3183
- function withoutPanel(board, id) {
3184
- return {
3185
- shown: dropped(board.shown, id),
3186
- hidden: dropped(board.hidden, id)
3474
+ /**
3475
+ * 注册长任务那两把外壳工具:看得见(`workbench_long_tasks_list`)与停得了任何单元的一趟
3476
+ * (`workbench_long_tasks_stop`)。`longTaskRuns` 是晚绑定的取值器:两把都从它取注册表,
3477
+ * 所以单元晚挂上 / 被摘掉都跟得上;没给的话停那把一律回"没有这一趟"。
3478
+ * @param ctx 宿主上下文,`tools` 服务不在时什么都不注册。
3479
+ * @param longTaskRuns 当前挂着的单元各自那把长任务注册表。
3480
+ * @returns 注销函数(没有 tools 服务就是空数组)。
3481
+ */
3482
+ function registerLongTaskTools(ctx, longTaskRuns) {
3483
+ const tools = ctx.get("tools");
3484
+ if (tools === void 0) {
3485
+ ctx.logger?.info?.("[dsh-workbench] 没有 tools 服务,跳过外壳长任务工具注册");
3486
+ return [];
3487
+ }
3488
+ const listTool = {
3489
+ name: LONG_TASKS_LIST_TOOL,
3490
+ description: "看这个工作区在跑(或十分钟内刚收尾)的长任务:每行是 单元 · 目标 · 状态 · 当前一步 · id · 谁起的;要停哪一趟用 workbench_long_tasks_stop。",
3491
+ parameters: {
3492
+ type: "object",
3493
+ properties: {},
3494
+ additionalProperties: false
3495
+ },
3496
+ output: LIST_OUTPUT,
3497
+ async execute(_raw, exec) {
3498
+ const workspace = sessionWorkspace(exec);
3499
+ if (workspace === null) return {
3500
+ runs: [],
3501
+ text: NO_WORKSPACE
3502
+ };
3503
+ const runs = listed$1(longTaskRuns, workspace);
3504
+ return {
3505
+ runs,
3506
+ text: renderList$1(runs, workspace, sessionIdOf(exec))
3507
+ };
3508
+ }
3187
3509
  };
3188
- }
3189
- function withMoved(board, id, index, visible) {
3190
- const current = visible.includes(id) ? [...visible] : [...visible, id];
3191
- const from = current.indexOf(id);
3192
- const to = Math.max(0, Math.min(current.length - 1, Math.trunc(index)));
3193
- if (from !== to) current.splice(to, 0, ...current.splice(from, 1));
3194
- return {
3195
- shown: added(board.shown, id),
3196
- hidden: dropped(board.hidden, id),
3197
- order: current
3510
+ const stopTool = {
3511
+ name: LONG_TASKS_STOP_TOOL,
3512
+ description: "停掉某一趟长任务(unit 与 id 从 workbench_long_tasks_list 拿)。只是让外壳给它发中断信号,平台侧那趟活儿的收尾看模块自己。",
3513
+ parameters: {
3514
+ type: "object",
3515
+ properties: {
3516
+ unit: {
3517
+ type: "string",
3518
+ description: "那一趟属于的单元 id(如 cmdb)。"
3519
+ },
3520
+ id: {
3521
+ type: "string",
3522
+ description: "那一趟的 id(列表里 id= 后面那串)。"
3523
+ }
3524
+ },
3525
+ required: ["unit", "id"],
3526
+ additionalProperties: false
3527
+ },
3528
+ output: STOP_OUTPUT,
3529
+ async execute(raw, exec) {
3530
+ const unit = arg(raw, "unit");
3531
+ const id = arg(raw, "id");
3532
+ if (unit === "" || id === "") return {
3533
+ text: NO_RUN,
3534
+ stopped: false
3535
+ };
3536
+ const registry = longTaskRuns?.().get(unit);
3537
+ if (registry === void 0 || typeof registry.stop !== "function") return {
3538
+ text: registry === void 0 ? NO_RUN : STALE_REGISTRY,
3539
+ stopped: false
3540
+ };
3541
+ const run = registry.snapshot(id);
3542
+ if (run === null) return {
3543
+ text: NO_RUN,
3544
+ stopped: false
3545
+ };
3546
+ if (run.workspace !== sessionWorkspace(exec)) return {
3547
+ text: `这一趟不在你当前的工作区(它在 ${run.workspace})—— 没动它。`,
3548
+ stopped: false
3549
+ };
3550
+ if (!registry.stop(id, sessionIdOf(exec) ?? void 0)) return {
3551
+ text: `这一趟已经不在跑了(${statusWord(run.status)})—— 没动它。`,
3552
+ stopped: false
3553
+ };
3554
+ return {
3555
+ text: `停了:${unit} · ${run.target}(${id})。这一趟的活儿收到中断信号 —— 它认不认、停在哪一步看它自己。`,
3556
+ stopped: true
3557
+ };
3558
+ }
3198
3559
  };
3560
+ return [tools.register(listTool), tools.register(stopTool)];
3199
3561
  }
3200
3562
  //#endregion
3201
3563
  //#region src/shared/api/host/diy-components.ts
@@ -3658,6 +4020,18 @@ async function resolveAt(registry, dir) {
3658
4020
  function looksLikeWorkshop(dir) {
3659
4021
  return existsSync(join(dir, "packages")) || existsSync(join(dir, "pnpm-workspace.yaml"));
3660
4022
  }
4023
+ /**
4024
+ * 工坊**自己的家**(`$DSH_HOME/workbench/diy`)—— 宿主启动时就是按它把工坊登记成工作区的。
4025
+ * DIY 会话的工坊根取这一份,**不随会话 cwd 漂**:cwd 一离开工坊(用户把自己的项目设成工作区),
4026
+ * 从 cwd 往上找会撞上任意一个"带 `packages/` 的仓库",于是模块列表、`shots/` 落点、装机名单全跟着变样。
4027
+ * 这一档拿不到(没有 `dshHomePath` 服务)时才退回 cwd 那套。
4028
+ * @param ctx - 宿主上下文(要从里面取 `dshHomePath`)。
4029
+ * @returns 工坊的家;取不到给 `null`。
4030
+ */
4031
+ function workshopHomeOf(ctx) {
4032
+ const path = workshopPathOf(ctx);
4033
+ return path === null ? null : rootAt(path, WORKSHOP_TITLE);
4034
+ }
3661
4035
  async function workshopRootOf(ctx, workspace) {
3662
4036
  const start = typeof workspace === "string" ? workspace.trim() : "";
3663
4037
  if (start === "") return null;
@@ -3750,10 +4124,19 @@ function showOrReason(panels, unit, workspace) {
3750
4124
  };
3751
4125
  }
3752
4126
  }
4127
+ /**
4128
+ * 这一次 DIY 调用落在哪个工坊上:**先认工坊自己的家**(`$DSH_HOME/workbench/diy`,宿主就是按它把工坊
4129
+ * 登记成工作区的),**不跟着会话 cwd 走** —— 用户把自己的项目设成工作区时,cwd 往上找会撞上任意一个
4130
+ * 带 `packages/` 的仓库,模块列表与 `shots/` 落点会一起变样(真机踩过:录制落点跑到仓库根、截图 EPERM)。
4131
+ * 只有拿不到工坊的家时,才退回"从会话工作区往上找像工坊的目录"。
4132
+ * @param ctx - 宿主上下文。
4133
+ * @param exec - 这次工具调用(退回那一档要读它的会话工作区)。
4134
+ * @returns 工坊的 packages 目录、根路径与标题。
4135
+ */
3753
4136
  async function workshopOf(ctx, exec) {
3754
4137
  const session = sessionWorkspace(exec);
3755
- const root = await workshopRootOf(ctx, session);
3756
- if (root === null) throw new Error(`这个工作区没有模块包可落地:DIY 工具把模块放在会话工作区的 packages/ 下(这个会话的工作区 ${session ?? "(不知道)"})—— 换到一个有模块包的工作区,或者按工程域在仓库里用 dsh-wb-module 新建一个模块包`);
4138
+ const root = workshopHomeOf(ctx) ?? await workshopRootOf(ctx, session);
4139
+ if (root === null) throw new Error(`这台机器上没有工坊可落地(既没有 \$DSH_HOME/workbench/diy,会话工作区也没有模块包)(这个会话的工作区 ${session ?? "(不知道)"})—— 先在工坊里造一个模块,或者按工程域在仓库里用 dsh-wb-module 新建一个模块包`);
3757
4140
  return {
3758
4141
  packagesDir: root.packagesDir,
3759
4142
  workspace: root.root,
@@ -4275,7 +4658,7 @@ async function stopNetworkExact(shell, file, timeoutMs = SWEEP_TIMEOUT_MS) {
4275
4658
  `[ -n "$p" ] && kill "$p" 2>/dev/null`,
4276
4659
  `[ -n "$r" ] && pkill -P "$r" 2>/dev/null`,
4277
4660
  `[ -n "$r" ] && kill "$r" 2>/dev/null`,
4278
- `if [ "$(cat ${pidFile} 2>/dev/null)" = "$p" ]; then rm -f ${quoteShellArg(file)} ${pidFile} ${reaperFile}; fi`,
4661
+ `if [ "$(cat ${pidFile} 2>/dev/null)" = "$p" ]; then rm -f ${pidFile} ${reaperFile}; fi`,
4279
4662
  "true"
4280
4663
  ].join("; "), { timeoutMs })).exitCode === 0;
4281
4664
  }
@@ -6344,7 +6727,7 @@ var RECORD_ARM_SCRIPT = `(() => {
6344
6727
  const OFF = ${JSON.stringify(RECORD_OFF_KEY)};
6345
6728
  const CAP = ${String(50)};
6346
6729
  const BODY_MAX = ${String(16384)};
6347
- const blank = () => ({ phase: 'armed', t0: null, t1: null, href: location.href, dropped: 0, calls: [] });
6730
+ const blank = () => ({ phase: 'armed', t0: null, t1: null, href: location.href, dropped: 0, calls: [], shots: 0, shotsAt: null });
6348
6731
  const readKeep = () => {
6349
6732
  try {
6350
6733
  const raw = sessionStorage.getItem(KEEP);
@@ -6379,23 +6762,64 @@ var RECORD_ARM_SCRIPT = `(() => {
6379
6762
  const repaint = () => {
6380
6763
  if (typeof state.__repaint === 'function') state.__repaint();
6381
6764
  };
6382
- const record = (method, url, body, status, ct, response) => {
6383
- if (state.phase !== 'recording') return;
6384
- state.calls.push({
6385
- method: String(method === '' || method === undefined ? 'GET' : method).toUpperCase(),
6386
- url: String(url === undefined || url === null ? '' : url),
6765
+ const beatOf = (kind) => {
6766
+ try {
6767
+ state.beatN = (typeof state.beatN === 'number' ? state.beatN : 0) + 1;
6768
+ state.beatKind = kind;
6769
+ state.beatAt = Date.now();
6770
+ keep();
6771
+ if (typeof window.__dshBeatWake === 'function') {
6772
+ const wake = window.__dshBeatWake;
6773
+ window.__dshBeatWake = null;
6774
+ wake();
6775
+ }
6776
+ } catch (error) {}
6777
+ };
6778
+ const pending = [];
6779
+ const begin = (method, url, body) => {
6780
+ if (state.phase !== 'recording') return null;
6781
+ const target = String(url === undefined || url === null ? '' : url);
6782
+ const verb = String(method === '' || method === undefined ? 'GET' : method).toUpperCase();
6783
+ const row = {
6784
+ method: verb,
6785
+ url: target,
6387
6786
  body: slice(body === undefined || body === null ? '' : body),
6388
- status: typeof status === 'number' ? status : 0,
6389
- ct: String(ct === undefined || ct === null ? '' : ct),
6390
- response: slice(response === undefined || response === null ? '' : response),
6787
+ status: 0,
6788
+ ct: '',
6789
+ response: '',
6391
6790
  at: Date.now(),
6392
- });
6791
+ };
6792
+ state.calls.push(row);
6393
6793
  if (state.calls.length > CAP) {
6394
6794
  state.dropped = (typeof state.dropped === 'number' ? state.dropped : 0) + (state.calls.length - CAP);
6395
6795
  state.calls.splice(0, state.calls.length - CAP);
6396
6796
  }
6797
+ pending.push(row);
6397
6798
  keep();
6398
6799
  repaint();
6800
+ return row;
6801
+ };
6802
+ const complete = (row, status, ct, response) => {
6803
+ row.status = typeof status === 'number' ? status : 0;
6804
+ row.ct = String(ct === undefined || ct === null ? '' : ct);
6805
+ row.response = slice(response === undefined || response === null ? '' : response);
6806
+ };
6807
+ const record = (method, url, body, status, ct, response) => {
6808
+ const row = begin(method, url, body);
6809
+ if (row === null) return;
6810
+ complete(row, status, ct, response);
6811
+ keep();
6812
+ };
6813
+ const finish = (row, method, url, body, status, ct, response) => {
6814
+ const at = row === null || row === undefined ? -1 : pending.indexOf(row);
6815
+ if (at >= 0) pending.splice(at, 1);
6816
+ if (at >= 0 && state.calls.indexOf(row) >= 0) {
6817
+ complete(row, status, ct, response);
6818
+ keep();
6819
+ repaint();
6820
+ return;
6821
+ }
6822
+ record(method, url, body, status, ct, response);
6399
6823
  };
6400
6824
  const fresh = typeof window[OFF] !== 'function';
6401
6825
  if (fresh) {
@@ -6426,6 +6850,7 @@ var RECORD_ARM_SCRIPT = `(() => {
6426
6850
  const url = typeof asked === 'string' ? asked : asked !== null && typeof asked === 'object' ? asked.url : '';
6427
6851
  const method = init.method !== undefined ? init.method : asked !== null && typeof asked === 'object' ? asked.method : 'GET';
6428
6852
  const body = init.body === undefined ? '' : init.body;
6853
+ const row = begin(method, url, body);
6429
6854
  const done = realFetch.apply(this, args);
6430
6855
  try {
6431
6856
  done.then((response) => {
@@ -6433,11 +6858,11 @@ var RECORD_ARM_SCRIPT = `(() => {
6433
6858
  const ct = response.headers.get('content-type') || '';
6434
6859
  const clone = response.clone();
6435
6860
  const target = response.url || url;
6436
- clone.text().then((text) => record(method, target, body, response.status, ct, text)).catch(() => record(method, target, body, response.status, ct, ''));
6861
+ clone.text().then((text) => finish(row, method, target, body, response.status, ct, text)).catch(() => finish(row, method, target, body, response.status, ct, ''));
6437
6862
  } catch (error) {}
6438
6863
  }).catch((error) => {
6439
6864
  try {
6440
- record(method, url, body, 0, 'fetch-error', String(error !== null && typeof error === 'object' && error.message !== undefined ? error.message : error));
6865
+ finish(row, method, url, body, 0, 'fetch-error', String(error !== null && typeof error === 'object' && error.message !== undefined ? error.message : error));
6441
6866
  } catch (inner) {}
6442
6867
  });
6443
6868
  } catch (error) {}
@@ -6455,9 +6880,10 @@ var RECORD_ARM_SCRIPT = `(() => {
6455
6880
  xhr.removeEventListener('loadend', xhr.__dshLoadEnd);
6456
6881
  } catch (error) {}
6457
6882
  }
6883
+ const row = begin(xhr.__dshMethod, xhr.__dshUrl, body);
6458
6884
  const onEnd = () => {
6459
6885
  try {
6460
- record(xhr.__dshMethod, xhr.__dshUrl, body, xhr.status, ctOf(xhr), textOf(xhr));
6886
+ finish(row, xhr.__dshMethod, xhr.__dshUrl, body, xhr.status, ctOf(xhr), textOf(xhr));
6461
6887
  } catch (error) {}
6462
6888
  };
6463
6889
  xhr.__dshLoadEnd = onEnd;
@@ -6467,7 +6893,7 @@ var RECORD_ARM_SCRIPT = `(() => {
6467
6893
  if (hasBeacon) {
6468
6894
  navigator.sendBeacon = function (url, data) {
6469
6895
  try {
6470
- record('POST', url, data === undefined || data === null ? '' : data, 0, 'beacon', '');
6896
+ begin('POST', url, data === undefined || data === null ? '' : data);
6471
6897
  } catch (error) {}
6472
6898
  return realBeacon.apply(navigator, arguments);
6473
6899
  };
@@ -6536,23 +6962,31 @@ var RECORD_ARM_SCRIPT = `(() => {
6536
6962
  const label = document.createElement('span');
6537
6963
  label.setAttribute('style', 'flex:0 0 auto;white-space:nowrap;font-variant-numeric:tabular-nums');
6538
6964
  const start = document.createElement('button');
6965
+ const shot = document.createElement('button');
6539
6966
  const end = document.createElement('button');
6540
6967
  start.type = 'button';
6541
6968
  start.setAttribute('title', '开始录制');
6542
6969
  start.setAttribute('aria-label', '开始录制');
6543
6970
  start.innerHTML = '<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="4" width="9" height="8" rx="2"></rect><path d="M10.5 7.4 14.5 5v6l-4-2.4z"></path></svg>开始';
6971
+ shot.type = 'button';
6972
+ shot.setAttribute('title', '拍一张当前页面的截图');
6973
+ shot.setAttribute('aria-label', '拍一张当前页面的截图');
6974
+ shot.innerHTML = '<svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M1.5 5V1.5H5M11 1.5h3.5V5M14.5 11v3.5H11M5 14.5H1.5V11"></path><rect x="6" y="6" width="4" height="4" rx="1"></rect></svg>截图';
6544
6975
  end.type = 'button';
6545
6976
  end.setAttribute('title', '结束录制');
6546
6977
  end.setAttribute('aria-label', '结束录制');
6547
6978
  end.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden="true" focusable="false"><rect x="2" y="2" width="8" height="8" rx="1.5" fill="currentColor"></rect></svg>结束';
6548
- const look = (on) => 'display:inline-flex;align-items:center;gap:4px;height:24px;padding:0 8px;border:0;border-radius:6px;font-size:12px;line-height:1;font-family:inherit;white-space:nowrap;cursor:' + (on ? 'pointer' : 'default') + ';color:#fff;background:' + (on ? '#3b82f6' : '#334155');
6979
+ const look = (on) => 'display:inline-flex;align-items:center;gap:4px;height:24px;padding:0 8px;border:0;border-radius:6px;font-size:12px;line-height:1;font-family:inherit;white-space:nowrap;cursor:' + (on ? 'pointer' : 'default') + ';color:' + (on ? '#fff' : 'rgba(255,255,255,.45)') + ';background:' + (on ? '#3b82f6' : '#2b3444');
6549
6980
  const paint = () => {
6550
6981
  const phase = state.phase;
6551
- label.textContent = (phase === 'recording' ? '录制中 · ' : phase === 'finished' ? '录制完成 · ' : '录制 · 等开始 · ') + String(state.calls.length) + ' 条';
6982
+ const taken = typeof state.shots === 'number' ? state.shots : 0;
6983
+ label.textContent = (phase === 'recording' ? '录制中 · ' : phase === 'finished' ? '录制完成 · ' : '录制 · 等开始 · ') + String(state.calls.length) + ' 条' + (taken > 0 ? ' · 已拍 ' + String(taken) + ' 张' : '');
6552
6984
  dot.style.display = phase === 'recording' ? 'inline-block' : 'none';
6553
6985
  start.disabled = phase !== 'armed';
6986
+ shot.disabled = phase !== 'recording';
6554
6987
  end.disabled = phase !== 'recording';
6555
6988
  start.setAttribute('style', look(phase === 'armed'));
6989
+ shot.setAttribute('style', look(phase === 'recording'));
6556
6990
  end.setAttribute('style', look(phase === 'recording'));
6557
6991
  };
6558
6992
  state.__repaint = paint;
@@ -6604,12 +7038,22 @@ var RECORD_ARM_SCRIPT = `(() => {
6604
7038
  state.href = location.href;
6605
7039
  state.calls = [];
6606
7040
  state.dropped = 0;
7041
+ beatOf('start');
7042
+ keep();
7043
+ paint();
7044
+ };
7045
+ shot.onclick = () => {
7046
+ if (state.phase !== 'recording') return;
7047
+ state.shots = (typeof state.shots === 'number' ? state.shots : 0) + 1;
7048
+ state.shotsAt = Date.now();
7049
+ beatOf('shot');
6607
7050
  keep();
6608
7051
  paint();
6609
7052
  };
6610
7053
  end.onclick = () => {
6611
7054
  state.phase = 'finished';
6612
7055
  state.t1 = Date.now();
7056
+ beatOf('stop');
6613
7057
  keep();
6614
7058
  paint();
6615
7059
  };
@@ -6617,15 +7061,17 @@ var RECORD_ARM_SCRIPT = `(() => {
6617
7061
  bar.appendChild(grip);
6618
7062
  bar.appendChild(dot);
6619
7063
  bar.appendChild(label);
7064
+ bar.appendChild(shot);
6620
7065
  bar.appendChild(start);
6621
7066
  bar.appendChild(end);
6622
7067
  if (typeof state.barX === 'number' && typeof state.barY === 'number') place(state.barX, state.barY);
6623
7068
  paint();
6624
- return { ok: true, fresh, phase: state.phase, t0: state.t0 === undefined ? null : state.t0, t1: state.t1 === undefined ? null : state.t1, href: state.href || location.href, size: state.calls.length, dropped: typeof state.dropped === 'number' ? state.dropped : 0 };
7069
+ return { ok: true, fresh, phase: state.phase, t0: state.t0 === undefined ? null : state.t0, t1: state.t1 === undefined ? null : state.t1, href: state.href || location.href, size: state.calls.length, shots: typeof state.shots === 'number' ? state.shots : 0, shotsAt: typeof state.shotsAt === 'number' ? state.shotsAt : null, dropped: typeof state.dropped === 'number' ? state.dropped : 0 };
6625
7070
  })()`;
6626
- var RECORD_READ_SCRIPT = `(() => {
7071
+ var RECORD_READ_SCRIPT = `(async () => {
6627
7072
  const KEY = ${JSON.stringify(RECORD_KEY)};
6628
7073
  const KEEP = ${JSON.stringify(RECORD_KEEP_KEY)};
7074
+ const BEAT_WAIT = 3000;
6629
7075
  let state = window[KEY] !== null && typeof window[KEY] === 'object' ? window[KEY] : null;
6630
7076
  if (state === null) {
6631
7077
  try {
@@ -6635,8 +7081,29 @@ var RECORD_READ_SCRIPT = `(() => {
6635
7081
  state = null;
6636
7082
  }
6637
7083
  }
6638
- if (state === null || typeof state !== 'object') return { ok: false, code: 'not-armed', phase: 'none', calls: [] };
6639
- return { ok: true, phase: typeof state.phase === 'string' ? state.phase : 'armed', t0: state.t0 === undefined ? null : state.t0, t1: state.t1 === undefined ? null : state.t1, href: typeof state.href === 'string' ? state.href : '', here: location.href, dropped: typeof state.dropped === 'number' ? state.dropped : 0, calls: Array.isArray(state.calls) ? state.calls : [] };
7084
+ if (state === null || typeof state !== 'object') return { ok: false, code: 'not-armed', phase: 'none', calls: [], shots: 0, shotsAt: null, beat: false, beatKind: '' };
7085
+ const from = typeof state.beatSeen === 'number' ? state.beatSeen : 0;
7086
+ let resolveWake = null;
7087
+ const woken = new Promise((resolve) => {
7088
+ resolveWake = resolve;
7089
+ });
7090
+ const onBeat = () => {
7091
+ const go = resolveWake;
7092
+ resolveWake = null;
7093
+ if (go !== null) go();
7094
+ };
7095
+ window.__dshBeatWake = onBeat;
7096
+ const changed = () => (typeof state.beatN === 'number' ? state.beatN : 0) !== from;
7097
+ if (!changed()) {
7098
+ let timer = null;
7099
+ await Promise.race([woken, new Promise((resolve) => { timer = setTimeout(resolve, BEAT_WAIT); })]);
7100
+ if (timer !== null) clearTimeout(timer);
7101
+ }
7102
+ if (window.__dshBeatWake === onBeat) window.__dshBeatWake = null;
7103
+ const after = typeof state.beatN === 'number' ? state.beatN : 0;
7104
+ const beat = after !== from;
7105
+ state.beatSeen = after;
7106
+ return { ok: true, phase: typeof state.phase === 'string' ? state.phase : 'armed', t0: state.t0 === undefined ? null : state.t0, t1: state.t1 === undefined ? null : state.t1, href: typeof state.href === 'string' ? state.href : '', here: location.href, dropped: typeof state.dropped === 'number' ? state.dropped : 0, shots: typeof state.shots === 'number' ? state.shots : 0, shotsAt: typeof state.shotsAt === 'number' ? state.shotsAt : null, beat, beatKind: beat && typeof state.beatKind === 'string' ? state.beatKind : '', calls: Array.isArray(state.calls) ? state.calls : [] };
6640
7107
  })()`;
6641
7108
  var RECORD_STOP_SCRIPT = `(() => {
6642
7109
  const KEY = ${JSON.stringify(RECORD_KEY)};
@@ -6895,6 +7362,10 @@ function recordPageOf(payload) {
6895
7362
  here: typeof raw["here"] === "string" ? raw["here"] : "",
6896
7363
  dropped: typeof raw["dropped"] === "number" ? raw["dropped"] : 0,
6897
7364
  unhooked: raw["unhooked"] === 1 || raw["unhooked"] === true,
7365
+ shots: typeof raw["shots"] === "number" ? raw["shots"] : 0,
7366
+ shotsAt: typeof raw["shotsAt"] === "number" ? raw["shotsAt"] : null,
7367
+ beat: raw["beat"] === true,
7368
+ beatKind: typeof raw["beatKind"] === "string" ? raw["beatKind"] : "",
6898
7369
  calls
6899
7370
  };
6900
7371
  }
@@ -6975,21 +7446,43 @@ function sizeText(bytes) {
6975
7446
  function rowTextOf(one) {
6976
7447
  const mark = one.marked ? " ⭐" : " ";
6977
7448
  const where = one.source === "capture" ? "捕获" : "钩子";
6978
- return `· [${String(one.index)}]${mark} ${one.method} ${one.url}(${where} · 入参 ${one.hasRequest ? "有" : "无"} · 出参 ${sizeText(one.size)})`;
7449
+ const identity = one.key === "" ? `${one.method} ${one.url}` : one.key;
7450
+ const how = one.key === "" ? "无 key" : `at ${String(one.at)}`;
7451
+ return `· [${String(one.index)}]${mark} ${identity}(${where} · 入参 ${one.hasRequest ? "有" : "无"} · 出参 ${sizeText(one.size)} · ${how})`;
7452
+ }
7453
+ function shortPath(path) {
7454
+ const at = path.lastIndexOf("shots/");
7455
+ return at >= 0 ? path.slice(at) : path;
7456
+ }
7457
+ function shotFaultLines(shots) {
7458
+ return shots.filter((one) => one.error !== void 0).map((one) => `(他按了「截图」,但有一张图没落下来:${shortPath(one.path)} —— ${one.error ?? ""})`);
7459
+ }
7460
+ function beatLineOf(beats, beatMs) {
7461
+ return beats > 0 ? `门铃:${String(beats)} 次(最近 ${String(beatMs)} 毫秒)` : "";
7462
+ }
7463
+ function evidenceTailOf(shots, beats, beatMs) {
7464
+ const parts = [];
7465
+ const landed = shots.filter((one) => one.error === void 0).length;
7466
+ if (landed > 0) parts.push(`图 ${String(landed)} 张`);
7467
+ const beat = beatLineOf(beats, beatMs);
7468
+ if (beat !== "") parts.push(beat);
7469
+ return parts.join(" · ");
6979
7470
  }
6980
7471
  function renderRecordRead(input) {
7472
+ const shots = input.shots ?? [];
7473
+ const tail = evidenceTailOf(shots, input.beats ?? 0, input.beatMs ?? 0);
7474
+ const sep = tail === "" ? "" : ` · ${tail}`;
7475
+ const faults = shotFaultLines(shots);
6981
7476
  if (!input.armed) return `那一页上还没有录制条(${DIY_RECORD_PREPARE_TOOL} 起的那一场可能被导航冲掉了)—— 先 ${DIY_RECORD_PREPARE_TOOL},或让他回到那一页再 ${DIY_RECORD_READ_TOOL}。`;
6982
7477
  if (input.phase === "armed") return "他还没按「开始录制」—— 等他按了再读。";
6983
- if (input.phase === "recording") return `还在录(页面钩子上已经攒了 ${String(input.hookCaught)} 条)—— 等他按「结束录制」。`;
6984
- const hook = String(input.hookCaught);
6985
- const inside = String(input.capturedInSegment);
6986
- const markShown = input.rows.filter((one) => one.marked).slice(0, CALLS_SHOWN);
7478
+ if (input.phase === "recording") return [`还在录(页面钩子上已经攒了 ${String(input.hookCaught)} 条)—— 等他按「结束录制」。${sep}`, ...faults].join("\n");
7479
+ const marked = input.rows.filter((one) => one.marked);
7480
+ const markShown = marked.slice(0, CALLS_SHOWN);
6987
7481
  const restShown = input.rows.filter((one) => !one.marked).slice(-Math.max(0, CALLS_SHOWN - markShown.length));
6988
7482
  const shown = [...markShown, ...restShown];
6989
- const lines = [];
6990
- if (input.rows.length > 0) lines.push(`录完了:这一段一共 ${String(input.merged)} 条请求(页面钩子 ${hook} 条 · 会话捕获落在这个时间段里 ${inside} 条,合并去重后 ${String(input.merged)} 条)。下面 ⭐ 都列上、其余按时间取最近几条:`, ...shown.map(rowTextOf));
6991
- else lines.push(input.capturedTruncated ? `录完了:这一段(就解析到的那部分而言)一条请求都没抓到(页面钩子 0 条 · 解析到的会话捕获里落在这个时间段里 0 条)—— 但捕获文件被截过,见下一行。` : "录完了:这一段一条请求都没抓到(页面钩子 0 条 · 会话捕获落在这个时间段里 0 条,两边都是 0 —— 不是筛掉的)。");
6992
- if (input.capturedTruncated) lines.push(input.capturedTotalLines > input.capturedTailLimit ? `(捕获文件这一场一共落了 ${String(input.capturedTotalLines)} 行,这里只解析了最后 ${String(input.capturedTailLimit)} 行 —— 更早的没看;上面那几个"有几条 / 没落"的捕获数只对最后这段成立)` : `(捕获文件这一场一共落了 ${String(input.capturedTotalLines)} 行(没顶到 ${String(input.capturedTailLimit)} 行那顶帽),是**这一读本身被输出上限截了** —— 读到的只是最近一段,上面那几个捕获数只对读到的这段成立)`);
7483
+ const lines = [...input.capturedTruncated ? [input.capturedTotalLines > input.capturedTailLimit ? `(捕获文件这一场一共落了 ${String(input.capturedTotalLines)} 行,这里只解析了最后 ${String(input.capturedTailLimit)} 行 —— 更早的没看;下面的清单只对最后这段成立)` : `(捕获文件这一场一共落了 ${String(input.capturedTotalLines)} 行(没顶到 ${String(input.capturedTailLimit)} 行那顶帽),是**这一读本身被输出上限截了** —— 读到的只是最近一段,下面的清单只对读到的这段成立)`] : [], ...faults];
7484
+ if (input.rows.length > 0) lines.push(`录完了 · ${String(input.merged)} 条(⭐ ${String(marked.length)})${sep}`, ...shown.map(rowTextOf));
7485
+ else lines.push(input.capturedTruncated ? `录完了:这一段(就解析到的那部分而言)一条请求都没抓到(页面钩子 0 条 · 解析到的会话捕获里落在这个时间段里 0 条)—— 但捕获文件被截过,见上面那行。${sep}` : `录完了:这一段一条请求都没抓到(页面钩子 0 条 · 会话捕获落在这个时间段里 0 条,两边都是 0 —— 不是筛掉的)。${sep}`);
6993
7486
  if (input.dropped > 0) lines.push(`(缓冲区满了,最早丢了 ${String(input.dropped)} 条)`);
6994
7487
  if (input.captureRound === "fail") lines.push("(会话捕获最近一轮没跑成(opencli 那条报了错)—— 这一场可能只有页面钩子。)");
6995
7488
  else if (input.captureRound === "") lines.push("(会话捕获还没回过一轮 —— 再等一拍再看,别急着下\"没抓到\"的结论。)");
@@ -7010,6 +7503,7 @@ function clip(text, maxBytes) {
7010
7503
  function sizeLabel(size, exact) {
7011
7504
  return exact ? `${String(size)} 字节` : `${String(size)} 字符(opencli 没给 body_full_size,按字符数算)`;
7012
7505
  }
7506
+ /** 把字段证据表插进详情正文:接在出参那一块之后、`请求头` 那一行(或第一条附注)之前;没有锚点就接在末尾。空串原样回。 */
7013
7507
  function renderRecordDetail(input) {
7014
7508
  if (!input.found) return [`没找到这一条:${input.note}`, `可用的取法:② 还在这场录制里时用清单里的 key 或 index;③ 这一场已经收工的话,给 unit 让我去读 shots/rec-<unit>.jsonl.last;④ 都不行就让他打开 DevTools,把那次请求复制成 curl。`].join("\n");
7015
7509
  const req = clip(input.request, input.maxBytes);
@@ -7064,7 +7558,10 @@ var POLL_MS = 1e3;
7064
7558
  var CAPTURE_TAIL_LINES = 2e4;
7065
7559
  var CAPTURE_POLL_S = 3;
7066
7560
  var CAPTURE_ARM_S = 2;
7067
- var STATIC_URL_PATTERN = "^(data:)|(\\.(js|css|png|jpe?g|gif|svg|woff2?|ico|map|webp|mp4)(\\?|$))";
7561
+ /**
7562
+ * 捕获那一层要滤掉的 URL:静态资源(`data:` 与常见后缀)。用法 `new RegExp(...).test(url)`,命中即丢。
7563
+ */
7564
+ var STATIC_URL_PATTERN = `^(data:)|(\\.(js|css|png|jpe?g|gif|svg|woff2?|ico|map|webp|mp4)(\\?|$))`;
7068
7565
  var RECORD_WATCH_MS = 3e3;
7069
7566
  var RECORD_WATCH_TRIES = 5;
7070
7567
  var DROP_STATIC_JS = "let s=\"\";process.stdin.on(\"data\",(d)=>s+=d).on(\"end\",()=>{const re=new RegExp(process.argv[1],\"i\");for(const line of s.split(\"\\n\")){const t=line.trim();if(t===\"\")continue;let one;try{one=JSON.parse(t)}catch{process.stdout.write(t+\"\\n\");continue}if(Array.isArray(one.entries)){one.entries=one.entries.filter((e)=>!re.test(String((e||{}).url||\"\")));if(one.entries.length===0)continue}process.stdout.write(JSON.stringify(one)+\"\\n\")}});";
@@ -7089,6 +7586,48 @@ function captureFileOf(packagesDir, unit) {
7089
7586
  function lastCaptureFileOf(file) {
7090
7587
  return `${file}.last`;
7091
7588
  }
7589
+ /**
7590
+ * 这一族自己的落图路径:跟 `rec-<unit>.jsonl` 同目录、`rec-shot-` 前缀(工坊那一页的图另有名字)。
7591
+ * @param captureFile - 这一场捕获文件;图就落在它旁边。@param unit - 单元名。@param n - 页面「已拍」计数。
7592
+ * @returns 形如 `…/shots/rec-shot-<unit>-<n>.png` 的路径。
7593
+ */
7594
+ function shotFileOf(captureFile, unit, n) {
7595
+ return join(dirname(captureFile), `rec-shot-${unit}-${n}.png`);
7596
+ }
7597
+ /**
7598
+ * 把这一张现场图落进 `shots/`:argv **只有 `screenshot <path>`,一个视口参数都不给** —— `--full-page` 走
7599
+ * `captureBeyondViewport`、`--width` / `--height` 走 `setDeviceMetricsOverride`:真机上按一下页面**看得见地闪**
7600
+ * (2026-10-21 两次报过)、还可能让填了一半的表单跳位;调试族那把平时也是这一档(先 `eval` 量盒子再本地 `clip`)。
7601
+ * 真机上这个平台整页高与视口一样(1512×698)—— `--full-page` 一点没多给,白闪。
7602
+ * @param input - 看护那一拍的依赖(shell / 命令 / 会话 / 台账)。
7603
+ * @param held - 当前那份约定(路径按它的 `unit` 拼)。@param n - 页面浮条上那个「已拍」计数。
7604
+ */
7605
+ async function takeShotOf(input, held, n) {
7606
+ const path = shotFileOf(input.captureFile, held.unit, n);
7607
+ const at = Date.now();
7608
+ try {
7609
+ const step = await actBrowser(input.shell, input.command, BROWSER_RECORD_SESSION, ["screenshot", path]);
7610
+ if (step.ok) input.store.shotTaken(input.sessionId, {
7611
+ n,
7612
+ at,
7613
+ path
7614
+ }, Date.now());
7615
+ else input.store.shotFailed(input.sessionId, {
7616
+ n,
7617
+ at,
7618
+ path,
7619
+ error: actErrorOf(step)
7620
+ }, Date.now());
7621
+ } catch (error) {
7622
+ const text = error instanceof Error ? error.message : String(error);
7623
+ input.store.shotFailed(input.sessionId, {
7624
+ n,
7625
+ at,
7626
+ path,
7627
+ error: text
7628
+ }, Date.now());
7629
+ }
7630
+ }
7092
7631
  async function startRecordCapture(shell, command, session, file) {
7093
7632
  const bin = command === "" ? "opencli" : command;
7094
7633
  const arm = backgroundLine(bin, [
@@ -7128,6 +7667,30 @@ async function releaseLeftoversOf(input) {
7128
7667
  captureFile: input.captureFile
7129
7668
  });
7130
7669
  }
7670
+ async function stopCaptureQuiet(input) {
7671
+ try {
7672
+ return await stopNetworkExact(input.shell, input.captureFile);
7673
+ } catch {
7674
+ return false;
7675
+ }
7676
+ }
7677
+ async function closeRecordTabQuiet(input) {
7678
+ try {
7679
+ return await closeRecordTab(input.shell, input.command, BROWSER_RECORD_SESSION, input.url);
7680
+ } catch {
7681
+ return false;
7682
+ }
7683
+ }
7684
+ /**
7685
+ * 看护的一拍(= 一圈循环):读数那一条 `eval` **自己在页面里最多等 `RECORD_WATCH_MS`**(浮条在他按
7686
+ * 「开始 / 截图 / 结束」时会唤醒挂在页面上的那个 waiter ⇒ 一按就回来),所以这一轮 = **恰好一条 `eval`**:
7687
+ * 命中就立刻做那一拍,没人按就照旧 3 秒一拍 —— 兜底周期一字未变。该落图就落图;认到「结束」就封口、
7688
+ * **叫醒 agent**、**关掉录制窗**、收工(关窗排在叫醒之后:关窗慢 / 失败不许拖住叫醒,失败也不抛 ——
7689
+ * 那一扇是我们为录制借来的,ADR-0064 补记)。
7690
+ * ⚠️ 读数那条**不许**改成 opencli 的 `wait xhr`:它**读一次清一次**那只捕获环(`cdp.js:346-353`),而 reaper
7691
+ * 也读同一只环才拿得到 body ⇒ 会把 `rec-<unit>.jsonl` 吃空。理由与实测见 `.scratch/diy-write-record/issues/06-doorbell.md` §1.1。
7692
+ * @param input - 看护那一拍的依赖。@returns `stop` = 这条循环可以收了;`delivered` = 这一拍叫醒过 agent。
7693
+ */
7131
7694
  async function watchFinishStepOf(input) {
7132
7695
  const live = input.store.live(Date.now());
7133
7696
  if (live === null) {
@@ -7141,11 +7704,15 @@ async function watchFinishStepOf(input) {
7141
7704
  stop: true,
7142
7705
  delivered: false
7143
7706
  };
7144
- if (live.phase === "finished") return {
7145
- stop: false,
7146
- delivered: false
7147
- };
7707
+ if (live.phase === "finished") {
7708
+ await closeRecordTabQuiet(input);
7709
+ return {
7710
+ stop: true,
7711
+ delivered: false
7712
+ };
7713
+ }
7148
7714
  let page;
7715
+ const started = Date.now();
7149
7716
  try {
7150
7717
  page = recordPageOf((await actBrowser(input.shell, input.command, BROWSER_RECORD_SESSION, ["eval", RECORD_READ_SCRIPT])).payload);
7151
7718
  } catch {
@@ -7154,6 +7721,7 @@ async function watchFinishStepOf(input) {
7154
7721
  delivered: false
7155
7722
  };
7156
7723
  }
7724
+ if (page.beat) input.store.beat(input.sessionId, Math.max(0, Date.now() - started), Date.now());
7157
7725
  const held = input.store.live(Date.now());
7158
7726
  if (held === null) {
7159
7727
  await releaseLeftoversOf(input);
@@ -7166,22 +7734,31 @@ async function watchFinishStepOf(input) {
7166
7734
  stop: true,
7167
7735
  delivered: false
7168
7736
  };
7169
- if (held.phase === "finished") return {
7170
- stop: false,
7171
- delivered: false
7172
- };
7737
+ if (held.phase === "finished") {
7738
+ await stopCaptureQuiet(input);
7739
+ await closeRecordTabQuiet(input);
7740
+ return {
7741
+ stop: true,
7742
+ delivered: false
7743
+ };
7744
+ }
7173
7745
  if (!page.ok) return {
7174
7746
  stop: false,
7175
7747
  delivered: false
7176
7748
  };
7749
+ const due = shotDueOf(held, page.shots);
7750
+ if (due !== null) await takeShotOf(input, held, due);
7177
7751
  if (page.phase === "finished" && page.t1 !== null) {
7178
7752
  const now = Date.now();
7179
- if (held.phase === "recording") input.store.finished(input.sessionId, page.t1, now);
7753
+ if (held.phase === "armed") input.store.started(input.sessionId, page.t0 ?? 0, now);
7754
+ if (input.store.live(Date.now())?.phase === "recording") input.store.finished(input.sessionId, page.t1, now);
7180
7755
  const caught = page.calls.length;
7181
7756
  const summary = `工作台 DIY:他按了「结束录制」(${String(caught)} 条)`;
7182
7757
  deliverDebugNotice(input.ctx.get("agents"), input.sessionId, noticeMessage(`他按了「结束录制」(页面钩子上 ${String(caught)} 条)—— 去 ${DIY_RECORD_READ_TOOL} 取这一段清单,要某一条原文用 ${DIY_RECORD_DETAIL_TOOL}。`, summary), "wake");
7758
+ await stopCaptureQuiet(input);
7759
+ await closeRecordTabQuiet(input);
7183
7760
  return {
7184
- stop: false,
7761
+ stop: true,
7185
7762
  delivered: true
7186
7763
  };
7187
7764
  }
@@ -7191,18 +7768,25 @@ async function watchFinishStepOf(input) {
7191
7768
  delivered: false
7192
7769
  };
7193
7770
  }
7771
+ /**
7772
+ * 看护的一轮(就是 `watchFinishStepOf`,留着这个名字是给测试与调用点一个稳定的形)。
7773
+ * @param input - 看护那一拍的依赖。@returns 收不收工、有没有叫醒。
7774
+ */
7775
+ async function watchRoundOf(input) {
7776
+ return watchFinishStepOf(input);
7777
+ }
7194
7778
  function watchRecordFinish(input) {
7195
7779
  let misses = 0;
7196
7780
  const tick = () => {
7197
- watchFinishStepOf(input).then((out) => {
7781
+ watchRoundOf(input).then((out) => {
7198
7782
  misses = 0;
7199
- if (!out.stop) setTimeout(tick, RECORD_WATCH_MS);
7783
+ if (!out.stop) setTimeout(tick, 0);
7200
7784
  }).catch(() => {
7201
7785
  misses += 1;
7202
7786
  if (misses < RECORD_WATCH_TRIES) setTimeout(tick, RECORD_WATCH_MS);
7203
7787
  });
7204
7788
  };
7205
- setTimeout(tick, RECORD_WATCH_MS);
7789
+ setTimeout(tick, 0);
7206
7790
  }
7207
7791
  async function captureLinesOf(shell, file, timeoutMs = START_TIMEOUT_MS) {
7208
7792
  const step = await runShell(shell, `wc -l < ${quoteShellArg(file)} 2>/dev/null || echo 0`, { timeoutMs });
@@ -7284,30 +7868,12 @@ function detailFactsOf(input) {
7284
7868
  responseSizeExact: input.responseSizeExact,
7285
7869
  ambiguous: input.ambiguous,
7286
7870
  source: input.source,
7287
- text: renderRecordDetail({
7288
- found: true,
7289
- where: input.where,
7290
- method: input.method,
7291
- url: input.url,
7292
- status: input.status,
7293
- ct: input.ct,
7294
- at: input.at,
7295
- request: input.request,
7296
- response: input.response,
7297
- headers: input.headers,
7298
- requestFullSize: input.requestFullSize,
7299
- responseFullSize: input.responseFullSize,
7300
- requestSizeExact: input.requestSizeExact,
7301
- responseSizeExact: input.responseSizeExact,
7302
- ambiguous: input.ambiguous,
7303
- source: input.source,
7304
- maxBytes: input.maxBytes,
7305
- note: ""
7306
- })
7871
+ text: renderRecordDetail(input)
7307
7872
  });
7308
7873
  }
7309
7874
  function detailOfEntry(entry, maxBytes, ambiguous = 1) {
7310
7875
  return detailFactsOf({
7876
+ found: true,
7311
7877
  where: DETAIL_WHERE_CAPTURE,
7312
7878
  key: entry.key,
7313
7879
  method: entry.method,
@@ -7324,11 +7890,13 @@ function detailOfEntry(entry, maxBytes, ambiguous = 1) {
7324
7890
  responseSizeExact: entry.responseSizeExact,
7325
7891
  ambiguous,
7326
7892
  source: "capture",
7327
- maxBytes
7893
+ maxBytes,
7894
+ note: ""
7328
7895
  });
7329
7896
  }
7330
7897
  function detailOfCall(call, maxBytes) {
7331
7898
  return detailFactsOf({
7899
+ found: true,
7332
7900
  where: DETAIL_WHERE_HOOK,
7333
7901
  key: call.key,
7334
7902
  method: call.method,
@@ -7345,31 +7913,45 @@ function detailOfCall(call, maxBytes) {
7345
7913
  responseSizeExact: false,
7346
7914
  ambiguous: 0,
7347
7915
  source: "hook",
7348
- maxBytes
7916
+ maxBytes,
7917
+ note: ""
7349
7918
  });
7350
7919
  }
7920
+ function blanksOf(pages, keep) {
7921
+ if (pages === null) return [];
7922
+ return pages.filter((one) => typeof one["url"] === "string" && (one["url"] === "" || one["url"] === "about:blank")).map((one) => typeof one["page"] === "string" ? one["page"] : "").filter((one) => one !== "" && one !== keep);
7923
+ }
7351
7924
  function tabsOf(raw) {
7352
7925
  const start = raw.indexOf("[");
7353
7926
  const end = raw.lastIndexOf("]");
7354
- if (start === -1 || end <= start) return [];
7927
+ if (start === -1 || end <= start) return null;
7355
7928
  try {
7356
7929
  const parsed = JSON.parse(raw.slice(start, end + 1));
7357
- if (!Array.isArray(parsed)) return [];
7930
+ if (!Array.isArray(parsed)) return null;
7358
7931
  return parsed.filter((one) => typeof one === "object" && one !== null);
7359
7932
  } catch {
7360
- return [];
7933
+ return null;
7361
7934
  }
7362
7935
  }
7363
7936
  async function closeRecordTab(shell, command, session, url) {
7364
7937
  const pages = tabsOf((await actBrowser(shell, command, session, ["tab", "list"])).stdout);
7365
- const pick = pages.find((one) => typeof one["url"] === "string" && one["url"] === url) ?? pages[0];
7938
+ if (pages !== null && pages.length === 0) return true;
7939
+ const pick = pages?.find((one) => typeof one["url"] === "string" && one["url"] === url) ?? pages?.[0];
7366
7940
  const page = pick === void 0 ? "" : typeof pick["page"] === "string" ? pick["page"] : "";
7367
7941
  if (page !== "") {
7368
7942
  if ((await actBrowser(shell, command, session, [
7369
7943
  "tab",
7370
7944
  "close",
7371
7945
  page
7372
- ])).ok) return true;
7946
+ ])).ok) {
7947
+ for (const one of blanksOf(pages, page)) await actBrowser(shell, command, session, [
7948
+ "tab",
7949
+ "close",
7950
+ one
7951
+ ]);
7952
+ await actBrowser(shell, command, session, ["close"]);
7953
+ return true;
7954
+ }
7373
7955
  }
7374
7956
  return (await actBrowser(shell, command, session, ["close"])).ok;
7375
7957
  }
@@ -7391,6 +7973,81 @@ async function releaseQuietly(input) {
7391
7973
  }
7392
7974
  }
7393
7975
  //#endregion
7976
+ //#region src/modules/panels/model/diy-record-open.ts
7977
+ var PAGE_HREF_SCRIPT = "JSON.stringify({ href: location.href })";
7978
+ /**
7979
+ * 两个地址是不是"同一页":比之前先把查询串 / 锚点 / 末尾斜杠抹掉 —— `open` 导航之后
7980
+ * 地址栏常常多一个斜杠或一串 hash,那不算开错了页。
7981
+ * @param here - 那一页现在真实所在的地址(`location.href`)。@param want - 我们要它去的地址。@returns 同一页为 `true`。
7982
+ */
7983
+ function samePageOf(here, want) {
7984
+ const norm = (one) => one.replace(/[?#].*$/, "").replace(/\/+$/, "");
7985
+ return here !== "" && norm(here) === norm(want);
7986
+ }
7987
+ async function whereAtOf(input) {
7988
+ const step = await actBrowser(input.shell, input.command, input.session, ["eval", PAGE_HREF_SCRIPT]);
7989
+ const href = step.payload === void 0 ? "" : step.payload["href"];
7990
+ return typeof href === "string" ? href : "";
7991
+ }
7992
+ async function settleOf(input) {
7993
+ await runBrowserStep(input.shell, input.command, [
7994
+ "browser",
7995
+ input.session,
7996
+ "wait",
7997
+ "time",
7998
+ String(SETTLE_MS / 1e3)
7999
+ ], OPEN_TIMEOUT_MS);
8000
+ }
8001
+ /**
8002
+ * 把录制那一页开起来,并且**确认它真的开起来了**才回来。
8003
+ *
8004
+ * 先收掉这条会话的残留(否则新页会开进上一次留下的那扇旧窗里,不顶前台、看上去"根本没弹窗");
8005
+ * 再 `tab new --window foreground` 开我们的页;**然后读一次 `location.href` 核实** —— 开出来是空白页
8006
+ * 或别的地址时,回落到 opencli 的 `open`(它落在会话租约那个标签上)再核一次;两次都不对就
8007
+ * `ok: false`,让调用方把这句话原样报给 agent,**不许在没开起来的页上装浮条说"已开始录制"**。
8008
+ * @param input - 这条会话的名字 / 目标地址 / 捕获文件(收残留那一拍要用)。
8009
+ * @returns `ok: false` 时 `at` 是那一页现在真实的地址(`''` = 空白页),`why` 是给 agent 看的一句话。
8010
+ */
8011
+ async function openRecordPage(input) {
8012
+ await releaseQuietly(input);
8013
+ const opened = await runBrowserStep(input.shell, input.command, [
8014
+ "browser",
8015
+ input.session,
8016
+ "tab",
8017
+ "new",
8018
+ input.url,
8019
+ "--window",
8020
+ "foreground"
8021
+ ], OPEN_TIMEOUT_MS);
8022
+ if (!opened.ok) return {
8023
+ ok: false,
8024
+ at: "",
8025
+ why: `开不了录制窗:${opened.stderr || opened.stdout || "opencli 报了个错"}。先让用户把工作台那一页切到前台(第一次要这样),或跑 opencli doctor 看扩展连没连上。`
8026
+ };
8027
+ await settleOf(input);
8028
+ let at = await whereAtOf(input);
8029
+ if (!samePageOf(at, input.url)) {
8030
+ await runBrowserStep(input.shell, input.command, [
8031
+ "browser",
8032
+ input.session,
8033
+ "open",
8034
+ input.url
8035
+ ], OPEN_TIMEOUT_MS);
8036
+ await settleOf(input);
8037
+ at = await whereAtOf(input);
8038
+ }
8039
+ if (!samePageOf(at, input.url)) return {
8040
+ ok: false,
8041
+ at,
8042
+ why: `那一页没开起来(现在开的是 ${at === "" ? "一张空白页" : at})—— 再看一眼 Chrome 里那扇录制窗,或跑 opencli doctor 看扩展连没连上。`
8043
+ };
8044
+ return {
8045
+ ok: true,
8046
+ at,
8047
+ why: ""
8048
+ };
8049
+ }
8050
+ //#endregion
7394
8051
  //#region src/modules/panels/model/diy-record-tools.ts
7395
8052
  function registerRecordTools(ctx, store) {
7396
8053
  const tools = ctx.get("tools");
@@ -7400,7 +8057,7 @@ function registerRecordTools(ctx, store) {
7400
8057
  }
7401
8058
  const prepareTool = {
7402
8059
  name: DIY_RECORD_PREPARE_TOOL,
7403
- description: `开一扇浏览器窗口并顶到最前,让他自己录一次真实的写(开窗要点头一次);他按开始/结束后用 ${DIY_RECORD_READ_TOOL} 取那一段、${DIY_RECORD_STOP_TOOL} 收工。`,
8060
+ description: `开一扇浏览器窗口并顶到最前,让他自己录一次真实的写(开窗要点头一次);他按开始/结束后用 ${DIY_RECORD_READ_TOOL} 取那一段、${DIY_RECORD_STOP_TOOL} 收工。他按浮条上的「截图」不用另外点头(同一场同一屏,屏幕在开窗那一拍已经借过)—— 那张图与提交那一刻的快照一起进 ${DIY_RECORD_DETAIL_TOOL} 的字段证据表。`,
7404
8061
  parameters: {
7405
8062
  type: "object",
7406
8063
  properties: {
@@ -7479,23 +8136,14 @@ function registerRecordTools(ctx, store) {
7479
8136
  now: Date.now()
7480
8137
  }), held);
7481
8138
  try {
7482
- const opened = await runBrowserStep(shell, command, [
7483
- "browser",
7484
- BROWSER_RECORD_SESSION,
7485
- "tab",
7486
- "new",
8139
+ const opened = await openRecordPage({
8140
+ shell,
8141
+ command,
8142
+ session: BROWSER_RECORD_SESSION,
7487
8143
  url,
7488
- "--window",
7489
- "foreground"
7490
- ], OPEN_TIMEOUT_MS);
7491
- if (!opened.ok) throw new Error(`开不了录制窗:${opened.stderr || opened.stdout || "opencli 报了个错"}。先让用户把工作台那一页切到前台(第一次要这样),或跑 opencli doctor 看扩展连没连上。`);
7492
- await runBrowserStep(shell, command, [
7493
- "browser",
7494
- BROWSER_RECORD_SESSION,
7495
- "wait",
7496
- "time",
7497
- String(SETTLE_MS / 1e3)
7498
- ], OPEN_TIMEOUT_MS);
8144
+ captureFile
8145
+ });
8146
+ if (!opened.ok) throw new Error(opened.why);
7499
8147
  const armed = await actBrowser(shell, command, BROWSER_RECORD_SESSION, ["eval", RECORD_ARM_SCRIPT]);
7500
8148
  const page = recordPageOf(armed.payload);
7501
8149
  if (!armed.ok || !page.ok) throw new Error(`浮条没装上:${armed.stderr || armed.stdout || "那一页还在加载"} —— 等他那一页站稳了再试。`);
@@ -7573,32 +8221,39 @@ function registerRecordTools(ctx, store) {
7573
8221
  if (store.live(Date.now())?.phase === "recording") store.finished(sessionId, page.t1, Date.now());
7574
8222
  }
7575
8223
  };
7576
- await actBrowser(shell, command, BROWSER_RECORD_SESSION, ["eval", RECORD_ARM_SCRIPT]);
7577
- let step = await actBrowser(shell, command, BROWSER_RECORD_SESSION, ["eval", RECORD_READ_SCRIPT]);
7578
- let page = recordPageOf(step.payload);
7579
- sync(page);
7580
- while (budget > 0 && page.phase !== "finished" && Date.now() < deadline) {
7581
- await pauseFor(Math.min(POLL_MS, Math.max(0, deadline - Date.now())));
7582
- step = await actBrowser(shell, command, BROWSER_RECORD_SESSION, ["eval", RECORD_READ_SCRIPT]);
7583
- const next = recordPageOf(step.payload);
7584
- if (next.ok) page = next;
8224
+ const over = held.phase === "finished";
8225
+ let stepOk = true;
8226
+ let page = recordPageOf(void 0);
8227
+ if (!over) {
8228
+ await actBrowser(shell, command, BROWSER_RECORD_SESSION, ["eval", RECORD_ARM_SCRIPT]);
8229
+ let step = await actBrowser(shell, command, BROWSER_RECORD_SESSION, ["eval", RECORD_READ_SCRIPT]);
8230
+ page = recordPageOf(step.payload);
8231
+ stepOk = step.ok;
7585
8232
  sync(page);
8233
+ while (budget > 0 && page.phase !== "finished" && Date.now() < deadline) {
8234
+ await pauseFor(Math.min(POLL_MS, Math.max(0, deadline - Date.now())));
8235
+ step = await actBrowser(shell, command, BROWSER_RECORD_SESSION, ["eval", RECORD_READ_SCRIPT]);
8236
+ const next = recordPageOf(step.payload);
8237
+ if (next.ok) page = next;
8238
+ sync(page);
8239
+ }
7586
8240
  }
7587
- const timedOut = budget > 0 && page.phase !== "finished";
8241
+ const timedOut = !over && budget > 0 && page.phase !== "finished";
7588
8242
  const waitedMs = opened === 0 ? 0 : Date.now() - opened;
7589
8243
  if (timedOut) {
7590
8244
  const waited = String(Math.round(budget / 1e3));
7591
8245
  holdNotice(sessionId, noticeMessage(`录制还没结束(等了约 ${waited} 秒):他按了「结束」再 read 一次。`, `工作台 DIY:录制还没结束(等了约 ${waited} 秒)`));
7592
8246
  }
7593
8247
  const state = store.live(Date.now()) ?? held;
8248
+ const ledger = state.shots;
7594
8249
  const capture = await captureReadOf(shell, state.captureFile);
7595
8250
  const bundle = bundleOf(capture, page, state);
7596
8251
  const hookCaught = page.calls.length;
7597
8252
  const nav = page.href !== "" && page.here !== "" && page.href !== page.here;
7598
8253
  const noBody = bodyMissingOf(bundle.merged);
7599
- const text = step.ok ? [renderRecordRead({
7600
- phase: page.phase,
7601
- armed: page.ok,
8254
+ const readText = renderRecordRead({
8255
+ phase: stepOk ? page.ok ? page.phase : state.phase : state.phase,
8256
+ armed: over ? true : stepOk ? page.ok : true,
7602
8257
  hookCaught,
7603
8258
  rows: bundle.rows,
7604
8259
  merged: bundle.merged.length,
@@ -7611,11 +8266,17 @@ function registerRecordTools(ctx, store) {
7611
8266
  dropped: page.dropped,
7612
8267
  nav,
7613
8268
  filter: filterTextOf(state.expect),
7614
- captureRound: bundle.capture.round
7615
- }), timedOut ? `还在录 —— 等他按「结束」(要等就带 waitMs,帽 ${String(WAIT_CAP_MS / 6e4)} 分钟)。` : ""].filter((one) => one !== "").join("\n") : `读不到那一页上的录制条:${step.stderr || step.stdout || "opencli 报了个错"}`;
8269
+ captureRound: bundle.capture.round,
8270
+ shots: ledger,
8271
+ beats: state.beats,
8272
+ beatMs: state.beatMs
8273
+ });
8274
+ const note = over ? ["他按「结束」时那一扇录制窗就收掉了 —— 下面是磁盘上(捕获文件)这一段的清单。"] : [];
8275
+ const said = stepOk ? [readText, timedOut ? `还在录 —— 等他按「结束」(要等就带 waitMs,帽 ${String(WAIT_CAP_MS / 6e4)} 分钟)。` : ""] : ["那一页上读不到了(那一扇录制窗可能已经关了)—— 台账与捕获文件这一段的清单照样在:", readText];
8276
+ const text = [...note, ...said].filter((one) => one !== "").join("\n");
7616
8277
  return plainJson({
7617
8278
  workshop: packagesDir,
7618
- ok: step.ok && page.ok,
8279
+ ok: stepOk && page.ok,
7619
8280
  phase: state.phase,
7620
8281
  t0: state.t0,
7621
8282
  t1: state.t1,
@@ -7629,6 +8290,9 @@ function registerRecordTools(ctx, store) {
7629
8290
  hookCaught,
7630
8291
  noBody,
7631
8292
  nav,
8293
+ shots: ledger,
8294
+ beats: state.beats,
8295
+ beatMs: state.beatMs,
7632
8296
  waitedMs,
7633
8297
  timedOut,
7634
8298
  text
@@ -7706,7 +8370,9 @@ function registerRecordTools(ctx, store) {
7706
8370
  const candidates = pickAllOf(captureEntriesOf(await captureLinesWithKeyOf(shell, file, key)), key);
7707
8371
  const fromCapture = pickOf(candidates, key, at);
7708
8372
  if (fromCapture !== null) return detailOfEntry(fromCapture, maxBytes, candidates.length);
7709
- const call = hookCallOf(page ?? recordPageOf((await actBrowser(shell, command, "workbench-rec", ["eval", RECORD_READ_SCRIPT])).payload), key);
8373
+ const over = held === null || held.phase === "finished";
8374
+ const livePage = page !== null || over ? page : recordPageOf((await actBrowser(shell, command, BROWSER_RECORD_SESSION, ["eval", RECORD_READ_SCRIPT])).payload);
8375
+ const call = livePage === null ? null : hookCallOf(livePage, key);
7710
8376
  if (call !== null) return detailOfCall(call, maxBytes);
7711
8377
  return plainJson({
7712
8378
  workshop: packagesDir,
@@ -7731,7 +8397,7 @@ function registerRecordTools(ctx, store) {
7731
8397
  ambiguous: 0,
7732
8398
  source: "",
7733
8399
  maxBytes,
7734
- note: `「${key}」既不在这一场的快照原文(${file})里,也不在这一页的钩子那一份里`
8400
+ note: over ? `「${key}」不在这一场的快照原文(${file})里 —— 这一场已经结束 / 收工,那一页也收掉了,钩子那一份问不到` : `「${key}」既不在这一场的快照原文(${file})里,也不在这一页的钩子那一份里`
7735
8401
  })
7736
8402
  });
7737
8403
  }
@@ -9618,8 +10284,21 @@ function panelsHandoffEntry(target = {}) {
9618
10284
  key: `path:${path}`
9619
10285
  };
9620
10286
  }
10287
+ function listedRunUnits(result) {
10288
+ const outer = typeof result === "object" && result !== null ? result : {};
10289
+ const unwrapped = outer["value"];
10290
+ const runs = (typeof unwrapped === "object" && unwrapped !== null ? unwrapped : outer)["runs"];
10291
+ if (!Array.isArray(runs)) return [];
10292
+ const units = /* @__PURE__ */ new Set();
10293
+ for (const row of runs) {
10294
+ const unit = typeof row === "object" && row !== null ? row.unit : void 0;
10295
+ if (typeof unit === "string" && unit !== "") units.add(unit);
10296
+ }
10297
+ return [...units];
10298
+ }
9621
10299
  function panelsFactsOf(call, manifests) {
9622
10300
  if (call.source === "workbench_panels_list") return [PANELS_FACT];
10301
+ if (call.source === "workbench_long_tasks_list") return listedRunUnits(call.result).map(runsRouteOf);
9623
10302
  if (call.source === "workbench_modules_list") return [PANELS_FACT, CAPABILITY_FACT];
9624
10303
  if (call.source === "workbench_modules_update_workbench") return [PANELS_FACT, CAPABILITY_FACT];
9625
10304
  if (call.source === "workbench_diy_list") return [PANELS_FACT];
@@ -10183,7 +10862,7 @@ function registerPanelTools(ctx, panels, values) {
10183
10862
  var OWNED = [{
10184
10863
  unit: panelsUnit.id,
10185
10864
  manifest: panelsUnit,
10186
- mount({ ctx, logger, manifests, ownedUnits, installable, opencliCommand, probe, store, values }) {
10865
+ mount({ ctx, logger, manifests, ownedUnits, installable, opencliCommand, probe, store, values, longTaskRuns }) {
10187
10866
  const ledger = createPluginLedger({
10188
10867
  store,
10189
10868
  home: isolatedHome()
@@ -10271,10 +10950,11 @@ var OWNED = [{
10271
10950
  ...registerPanelTools(ctx, panels, values),
10272
10951
  ...registerMarketTools(ctx, market),
10273
10952
  ...registerDiyTools(ctx, market, panels, debug),
10274
- ...registerRecordTools(ctx, record)
10953
+ ...registerRecordTools(ctx, record),
10954
+ ...registerLongTaskTools(ctx, longTaskRuns)
10275
10955
  ],
10276
10956
  factsOf: (call) => panelsFactsOf(call, manifests()),
10277
- needsApproval: panelsApproval,
10957
+ needsApproval: panelsApprovalFor(longTaskRuns),
10278
10958
  gate: {
10279
10959
  deniedTools: (context) => {
10280
10960
  const boards = panels.boards(context.workspace);
@@ -10331,7 +11011,29 @@ function withRunsRoute(result, unit) {
10331
11011
  runs: path === "" ? [] : runs.current(path)
10332
11012
  };
10333
11013
  }
10334
- }
11014
+ },
11015
+ actions: [...result.actions ?? [], {
11016
+ resource: stopRouteOf(unit),
11017
+ handle(payload) {
11018
+ if (typeof runs.stop !== "function") return {
11019
+ ok: false,
11020
+ text: "这一份外壳还是上一版的注册表,停不了 —— 重新起一下工作台就能停"
11021
+ };
11022
+ const id = payload?.id;
11023
+ if (typeof id !== "string" || id.trim() === "") return {
11024
+ ok: false,
11025
+ text: "要停哪一趟?给一个 id 才成"
11026
+ };
11027
+ if (runs.snapshot(id) === null) return {
11028
+ ok: false,
11029
+ text: "没有这一趟 —— id 对不上,没什么可停的"
11030
+ };
11031
+ return {
11032
+ ok: true,
11033
+ stopped: runs.stop(id)
11034
+ };
11035
+ }
11036
+ }]
10335
11037
  };
10336
11038
  }
10337
11039
  function declaresNote(definition) {
@@ -10500,6 +11202,7 @@ function mountUnits(units, deps) {
10500
11202
  const declaredNotes = /* @__PURE__ */ new Set();
10501
11203
  const skipped = [];
10502
11204
  const longTaskDisposers = /* @__PURE__ */ new Map();
11205
+ const longTaskRuns = deps.longTaskRuns ?? /* @__PURE__ */ new Map();
10503
11206
  const ledger = {
10504
11207
  failed: /* @__PURE__ */ new Set(),
10505
11208
  failures: []
@@ -10520,7 +11223,8 @@ function mountUnits(units, deps) {
10520
11223
  const longTasks = deps.longTasks === void 0 ? void 0 : createLongTaskHost({
10521
11224
  scope: deps.longTasks.scope,
10522
11225
  unit,
10523
- bump: deps.longTasks.bump
11226
+ bump: deps.longTasks.bump,
11227
+ ...deps.longTasks.notify === void 0 ? {} : { notify: deps.longTasks.notify }
10524
11228
  });
10525
11229
  let result;
10526
11230
  try {
@@ -10535,6 +11239,7 @@ function mountUnits(units, deps) {
10535
11239
  ...deps.probe === void 0 ? {} : { probe: deps.probe },
10536
11240
  ...deps.values === void 0 ? {} : { values: deps.values },
10537
11241
  ...longTasks === void 0 ? {} : { longTasks: longTasks.host },
11242
+ longTaskRuns: () => longTaskRuns,
10538
11243
  store
10539
11244
  });
10540
11245
  } catch (error) {
@@ -10555,6 +11260,8 @@ function mountUnits(units, deps) {
10555
11260
  };
10556
11261
  mounted.push(item);
10557
11262
  if (longTasks !== void 0) longTaskDisposers.set(item, longTasks.dispose);
11263
+ const runs = item.result.longTasks;
11264
+ if (runs !== void 0) longTaskRuns.set(unit, runs);
10558
11265
  }
10559
11266
  const collected = collectRoutes(mounted, ledger);
10560
11267
  const prefs = collectNamed(mounted, ledger, (item) => item.result.prefs === void 0 ? [] : [item.result.prefs], "偏好资源");
@@ -10575,6 +11282,8 @@ function mountUnits(units, deps) {
10575
11282
  if (!ledger.failed.has(item)) continue;
10576
11283
  longTaskDisposers.get(item)?.();
10577
11284
  longTaskDisposers.delete(item);
11285
+ const runs = item.result.longTasks;
11286
+ if (runs !== void 0 && longTaskRuns.get(item.host.manifest.id) === runs) longTaskRuns.delete(item.host.manifest.id);
10578
11287
  }
10579
11288
  return {
10580
11289
  mounted: alive,
@@ -10594,6 +11303,10 @@ function mountUnits(units, deps) {
10594
11303
  disposeLongTasks: () => {
10595
11304
  for (const dispose of [...longTaskDisposers.values()]) dispose();
10596
11305
  longTaskDisposers.clear();
11306
+ for (const item of mounted) {
11307
+ const runs = item.result.longTasks;
11308
+ if (runs !== void 0 && longTaskRuns.get(item.host.manifest.id) === runs) longTaskRuns.delete(item.host.manifest.id);
11309
+ }
10597
11310
  }
10598
11311
  };
10599
11312
  }
@@ -11079,6 +11792,37 @@ var DSH_PLUGINS_ROUTE = "dsh-plugins";
11079
11792
  //#region src/shared/api/unit-manifests.ts
11080
11793
  var UNIT_MANIFESTS_ROUTE = "unit-manifests";
11081
11794
  //#endregion
11795
+ //#region src/shared/api/long-tasks.ts
11796
+ /**
11797
+ * 外壳自己的长任务读口:一次把**全进程还在跑的**长任务交出来(不分单元、不分工作区)——
11798
+ * 左栏拿它点会话行与项目下拉里那颗 loading 点。
11799
+ */
11800
+ var LONG_TASKS_ROUTE = "long-tasks";
11801
+ //#endregion
11802
+ //#region src/shared/api/host/long-tasks.ts
11803
+ /**
11804
+ * 把「单元 → 它那把长任务注册表」收成一条条**还在跑**的行(`live()` 已经只给活着的那几趟,
11805
+ * 收尾的是面板那一行的事);注册表是上一版、没有 `live()` 就跳过这个单元 —— 与停止那条动作
11806
+ * 同一个理由:那一版里没有这份读口,不是这一版坏了。
11807
+ * @param runs 当前挂着的单元各自那把长任务注册表(外壳进程里就这一份)。
11808
+ * @returns 正在跑的长任务(含 `needs-login` 那种),每条带上是哪个单元起的。
11809
+ */
11810
+ function liveLongTaskRows(runs) {
11811
+ const rows = [];
11812
+ for (const [unit, registry] of runs) {
11813
+ if (typeof registry.live !== "function") continue;
11814
+ for (const snapshot of registry.live()) rows.push({
11815
+ unit,
11816
+ id: snapshot.id,
11817
+ workspace: snapshot.workspace,
11818
+ target: snapshot.target,
11819
+ status: snapshot.status,
11820
+ ...snapshot.session === void 0 ? {} : { session: snapshot.session }
11821
+ });
11822
+ }
11823
+ return rows;
11824
+ }
11825
+ //#endregion
11082
11826
  //#region src/shared/api/host/context-inject.ts
11083
11827
  var CONTEXT_SOURCE_KIND = "workbench";
11084
11828
  function collectContextRefs(messages) {
@@ -11217,8 +11961,6 @@ function createPreflight(options) {
11217
11961
  at: Date.now()
11218
11962
  };
11219
11963
  let running = null;
11220
- let latest = null;
11221
- let rerun = false;
11222
11964
  function set(next) {
11223
11965
  current = {
11224
11966
  ...current,
@@ -11413,21 +12155,16 @@ function createPreflight(options) {
11413
12155
  ...settled.ok ? {} : { bridgeIssues: settled.issues }
11414
12156
  });
11415
12157
  }
12158
+ function freshReady() {
12159
+ return current.phase === "ready" && Date.now() - current.at < 3e4;
12160
+ }
11416
12161
  async function start(runOptions = {}) {
11417
- if (running !== null) {
11418
- if (runOptions.force === true) rerun = true;
11419
- return running;
11420
- }
11421
- if (runOptions.force !== true && latest !== null) return latest;
12162
+ if (running !== null) return running;
12163
+ if (runOptions.hard !== true && freshReady()) return current;
11422
12164
  const inflight = doRun();
11423
- latest = inflight;
11424
12165
  running = inflight;
11425
12166
  await inflight.catch(() => void 0);
11426
12167
  running = null;
11427
- if (rerun) {
11428
- rerun = false;
11429
- return start({ force: true });
11430
- }
11431
12168
  return inflight;
11432
12169
  }
11433
12170
  return {
@@ -11695,10 +12432,31 @@ async function probeReleases(units, options) {
11695
12432
  }
11696
12433
  return found;
11697
12434
  }
12435
+ var pendingProbes = /* @__PURE__ */ new Map();
11698
12436
  async function probeUnits(options) {
11699
12437
  const { shell, command = "opencli", units, force = false } = options;
11700
12438
  const hits = units.map((unit) => unitCache.get(`${command}:${unit.unit}`));
11701
12439
  if (!force && hits.every((hit) => hit !== void 0)) return hits;
12440
+ const key = `${command}:${units.map((unit) => unit.unit).join("|")}`;
12441
+ if (!force) {
12442
+ const shared = pendingProbes.get(key);
12443
+ if (shared !== void 0) return shared;
12444
+ }
12445
+ const task = loadCapabilities({
12446
+ shell,
12447
+ command,
12448
+ units,
12449
+ force
12450
+ });
12451
+ pendingProbes.set(key, task);
12452
+ try {
12453
+ return await task;
12454
+ } finally {
12455
+ if (pendingProbes.get(key) === task) pendingProbes.delete(key);
12456
+ }
12457
+ }
12458
+ async function loadCapabilities(options) {
12459
+ const { shell, command, units, force } = options;
11702
12460
  const result = await runShell(shell, `${command} plugin list -f json`, { timeoutMs: OPENCLI_TIMEOUT_MS }).then((value) => ({
11703
12461
  kind: "ok",
11704
12462
  stdout: value.exitCode === 0 ? value.stdout : "",
@@ -12905,6 +13663,64 @@ function warn(ctx, message) {
12905
13663
  ctx.logger?.warn?.(`[dsh-workbench] ${message}`);
12906
13664
  }
12907
13665
  //#endregion
13666
+ //#region src/shared/api/host/approval-call.ts
13667
+ /**
13668
+ * 把 dsh 交来的那次工具调用折成审批策略要看的那份 call:工作区与发起会话都从 `exec` 现取,取不到就不写那个键。
13669
+ * @param exec dsh 交来的执行上下文;没有就只有实参这一格。
13670
+ * @param name 工具名;`undefined` = 这次调用没名字,不写这个键。
13671
+ * @returns 交给 `approvalFor` 的 `ApprovalCall`。
13672
+ */
13673
+ function approvalCallOf(exec, name) {
13674
+ const workspace = sessionWorkspace(exec) ?? void 0;
13675
+ const session = sessionIdOf(exec) ?? void 0;
13676
+ return {
13677
+ ...name === void 0 ? {} : { name },
13678
+ arguments: exec?.arguments,
13679
+ ...workspace === void 0 ? {} : { workspace },
13680
+ ...session === void 0 ? {} : { session }
13681
+ };
13682
+ }
13683
+ //#endregion
13684
+ //#region src/shared/api/host/long-task-notice.ts
13685
+ /**
13686
+ * 这条 notice 的来源标识(`source.kind`)—— 长任务自己一格,与调试 / 录制分开。
13687
+ */
13688
+ var LONG_TASK_NOTICE_KIND = "workbench-long-task";
13689
+ /**
13690
+ * 判这一趟被停要不要回流给发起它的那个会话,并把话写好。
13691
+ * 只有"结算 + `stopped` + 这一趟记得会话 + 不是它自己停的"才回一条;跑完 / 失败 / 界面起的(没记会话)/ 自己停的回 `null`。
13692
+ * @param unit 这一趟属于哪个单元(进文案)。
13693
+ * @param change 长任务那一次变更;只有 `kind: 'settle'` 且 `snapshot.status === 'stopped'` 才可能成。
13694
+ * @returns 该发的那条(带收件会话),或者 `null`(不发)。
13695
+ */
13696
+ function longTaskStopNotice(unit, change) {
13697
+ if (change.kind !== "settle") return null;
13698
+ const snapshot = change.snapshot;
13699
+ if (snapshot.status !== "stopped") return null;
13700
+ const session = snapshot.session;
13701
+ if (session === void 0 || session === "") return null;
13702
+ if (change.by === session) return null;
13703
+ const target = snapshot.target;
13704
+ const summary = `长任务:${unit} 的「${target}」被停了`;
13705
+ return {
13706
+ session,
13707
+ summary,
13708
+ message: noticeMessage(`[长任务 · ${unit}]「${target}」那一趟被${change.by === void 0 ? "用户" : "别的会话"}停了 —— 别再把它当"还在跑";要接着做就重新起一趟(workbench_long_tasks_list 看当前这几趟)。`, summary, LONG_TASK_NOTICE_KIND)
13709
+ };
13710
+ }
13711
+ /**
13712
+ * 把"这一趟被停了"送出去:正在跑的会话 inject 一条、闲着的跳过、没有这个 agent 就当没发生(模式 `drop`)。
13713
+ * @param agents 会话表(宿主从 `ctx.get('agents')` 拿);拿不到就什么都不发。
13714
+ * @param unit 这一趟属于哪个单元。
13715
+ * @param change 长任务那一次变更。
13716
+ * @returns 送出去的结果;判据说不该发(`longTaskStopNotice` 回 `null`)就回 `null`。
13717
+ */
13718
+ function notifyLongTaskStop(agents, unit, change) {
13719
+ const notice = longTaskStopNotice(unit, change);
13720
+ if (notice === null) return null;
13721
+ return deliverDebugNotice(agents, notice.session, notice.message, "drop");
13722
+ }
13723
+ //#endregion
12908
13724
  //#region src/app/host.ts
12909
13725
  var name = "dsh-workbench";
12910
13726
  var inject = ["webServer"];
@@ -13024,9 +13840,14 @@ function apply(ctx) {
13024
13840
  });
13025
13841
  const mounted = /* @__PURE__ */ new Map();
13026
13842
  const mountFailures = /* @__PURE__ */ new Map();
13843
+ const longTaskRuns = /* @__PURE__ */ new Map();
13027
13844
  const factsOfMap = /* @__PURE__ */ new Map();
13028
13845
  const routesOfUnitMap = /* @__PURE__ */ new Map();
13029
13846
  const ownersMap = /* @__PURE__ */ new Map();
13847
+ routes[LONG_TASKS_ROUTE] = () => ({
13848
+ ok: true,
13849
+ runs: liveLongTaskRows(longTaskRuns)
13850
+ });
13030
13851
  const factsOfUnit = (unit) => [...routesOfUnitMap.get(unit) ?? [], "capability"];
13031
13852
  const callFacts = (unit, call) => factsOfMap.get(unit)?.(call) ?? factsOfUnit(unit);
13032
13853
  const deniedNow = (context) => [...new Set([...mounted.values()].flatMap((item) => item.plan.gate?.deniedTools(context) ?? []))];
@@ -13076,8 +13897,10 @@ function apply(ctx) {
13076
13897
  values,
13077
13898
  longTasks: {
13078
13899
  scope: process.env["DSH_HOME"] ?? "",
13079
- bump: (names) => facts.bump(names)
13080
- }
13900
+ bump: (names) => facts.bump([...names, LONG_TASKS_ROUTE]),
13901
+ notify: (change) => notifyLongTaskStop(ctx.get("agents"), host.unit, change)
13902
+ },
13903
+ longTaskRuns
13081
13904
  });
13082
13905
  } catch (error) {
13083
13906
  mountFailures.set(id, {
@@ -13255,12 +14078,7 @@ function apply(ctx) {
13255
14078
  const tool = name === void 0 ? void 0 : unitsOfTool().get(name);
13256
14079
  const owner = tool === void 0 ? void 0 : mounted.get(tool.unit)?.result;
13257
14080
  const detailOf = owner === void 0 ? void 0 : (call) => owner.approvalDetail?.(call);
13258
- const workspace = sessionWorkspace(exec) ?? void 0;
13259
- const call = {
13260
- ...name === void 0 ? {} : { name },
13261
- arguments: exec.arguments,
13262
- ...workspace === void 0 ? {} : { workspace }
13263
- };
14081
+ const call = approvalCallOf(exec, name);
13264
14082
  const policy = owner?.needsApproval;
13265
14083
  const declaredNote = name !== void 0 && declaredNoteTools().has(name);
13266
14084
  return await approvalFor(call, {