@dsh-workbench/core 0.3.6 → 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 (43) hide show
  1. package/client.js +1260 -1928
  2. package/dist/client/client.body.js +1260 -1928
  3. package/dist/client/index.js +1260 -1928
  4. package/dist/host/index.js +1240 -790
  5. package/dist/types/app/client.d.ts +76 -3388
  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/model/diy-context.d.ts +9 -0
  15. package/dist/types/modules/panels/model/diy-record-beat.test.d.ts +1 -0
  16. package/dist/types/modules/panels/model/diy-record-close.test.d.ts +1 -0
  17. package/dist/types/modules/panels/model/diy-record-hook.test.d.ts +1 -0
  18. package/dist/types/modules/panels/model/diy-record-open.d.ts +28 -0
  19. package/dist/types/modules/panels/model/diy-record-open.test.d.ts +1 -0
  20. package/dist/types/modules/panels/model/diy-record-shot.test.d.ts +1 -0
  21. package/dist/types/modules/panels/model/diy-record-text.d.ts +16 -1
  22. package/dist/types/modules/panels/model/diy-record-tools.d.ts +2 -1
  23. package/dist/types/modules/panels/model/diy-record.d.ts +40 -1
  24. package/dist/types/modules/panels/model/diy-report.d.ts +1 -1
  25. package/dist/types/modules/sessions/index.d.ts +1 -1
  26. package/dist/types/modules/sessions/model/attention-long-task.test.d.ts +1 -0
  27. package/dist/types/modules/sessions/model/attention.d.ts +23 -1
  28. package/dist/types/modules/sessions/model/session-node.d.ts +12 -1
  29. package/dist/types/shared/api/host/long-tasks.d.ts +10 -0
  30. package/dist/types/shared/api/host/preflight.d.ts +7 -1
  31. package/dist/types/shared/api/host/workshop-root.d.ts +9 -0
  32. package/dist/types/shared/api/long-tasks.d.ts +19 -0
  33. package/dist/types/shared/hooks/panel-state-report.d.ts +1 -1
  34. package/dist/types/shared/hooks/use-diy-debug.d.ts +1 -1
  35. package/dist/types/shared/hooks/use-diy-record.d.ts +1 -1
  36. package/dist/types/shared/hooks/use-shell-long-tasks.d.ts +8 -0
  37. package/package.json +2 -2
  38. package/skills/workbench-module-diy/BUILD.md +1 -1
  39. package/skills/workbench-module-diy/FAMILY.md +1 -1
  40. package/skills/workbench-module-diy/KIT.md +23 -23
  41. package/skills/workbench-module-diy/PANEL.md +1 -1
  42. package/skills/workbench-module-diy/TOOLS.md +3 -1
  43. 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
  /**
@@ -287,23 +100,6 @@ function stopRouteOf(unit) {
287
100
  }
288
101
  //#endregion
289
102
  //#region ../shared/dist/lib/text.js
290
- /**
291
- * 把一个值裹成 shell 里的单引号字面量(值里的单引号一并转义),保证元字符按字面量传递。
292
- * @param value - 任意值;不是字符串的先 `String()`。
293
- * @returns 可安全拼进 `/bin/sh -c` 的一段文本 —— 含空格 / `$()` / `;` / 通配符的值都不会被当成语法。
294
- */
295
- function quoteShellArg(value) {
296
- return `'${String(value).replace(/'/g, `'\\''`)}'`;
297
- }
298
- /**
299
- * 取一段文本的末尾若干行(如命令输出),顺带去掉首尾空白。
300
- * @param text - 原文;`null` / `undefined` 当空串。
301
- * @param lines - 最多留几行,默认 6;0 给全篇、负数从开头砍掉那么多行(都是 `slice` 语义),调用方传正数。
302
- * @returns 末尾若干行、去首尾空行后的文本;原文行数不够就全给,空输入给空串。
303
- */
304
- function tailLines(text, lines = 6) {
305
- return String(text ?? "").trim().split("\n").slice(-lines).join("\n");
306
- }
307
103
  function candidateStarts(raw) {
308
104
  const starts = [];
309
105
  for (let index = 0; index < raw.length; index += 1) {
@@ -345,233 +141,36 @@ function parseJsonLoose(text) {
345
141
  var DAY = 864e5;
346
142
  30 * DAY;
347
143
  365 * DAY;
144
+ //#endregion
145
+ //#region ../shared/dist/lib/board-fact.js
348
146
  /**
349
- * 上报文本字段的截断长度(2000 字符;workspace / reason / message 共用)。
147
+ * 判一个路径落不落在这个工作区里(按路径前缀比,不是字符串包含)。
148
+ * @param root - 工作区根路径。
149
+ * @param target - 要判的路径。
150
+ * @returns 同一个路径或在其下给 `true`;`root` 末尾带不带斜杠都认(`/a` 不匹配 `/ab`)。
350
151
  */
351
- var PANEL_STATE_MAX_TEXT = 2e3;
352
- var PHASES = [
353
- "idle",
354
- "loading",
355
- "ready",
356
- "error"
357
- ];
358
- function asRecord$8(value) {
359
- return typeof value === "object" && value !== null ? value : void 0;
360
- }
361
- function textOf$7(value, max) {
362
- if (typeof value !== "string") return "";
363
- const trimmed = value.trim();
364
- return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
365
- }
366
- function atOf(value) {
367
- if (typeof value !== "number" || !Number.isFinite(value)) return null;
368
- if (value < 0) return null;
369
- return Math.trunc(value);
370
- }
371
- function errorOf(raw) {
372
- const record = asRecord$8(raw);
373
- if (record === void 0) return void 0;
374
- const code = textOf$7(record["code"], 120);
375
- const message = textOf$7(record["message"], PANEL_STATE_MAX_TEXT);
376
- if (code === "" && message === "") return void 0;
377
- return {
378
- code: code === "" ? "unknown" : code,
379
- message
380
- };
152
+ function insideWorkshop$1(root, target) {
153
+ if (target === root) return true;
154
+ const base = root.endsWith("/") ? root : `${root}/`;
155
+ return target.startsWith(base);
381
156
  }
382
157
  /**
383
- * 收一批卡片读数上报。
384
- * @param payload - `POST` 的请求体。
385
- * @returns 至少收到一条能认的卡片才 `ok: true`;请求体不是对象、`workspace` 为空、`cards` 不是数组,
386
- * 或一条都认不出(`unit` / `route` / `phase` / `at` 缺一)时 `ok: false` + 中文原因;认不出的单条静默跳过。
387
- */
388
- function parseCardsReport(payload) {
389
- const record = asRecord$8(payload);
390
- if (record === void 0) return {
391
- ok: false,
392
- message: "请求体要是一个对象"
393
- };
394
- const workspace = textOf$7(record["workspace"], PANEL_STATE_MAX_TEXT);
395
- if (workspace === "") return {
396
- ok: false,
397
- message: "workspace 不能是空的 —— 面板按工作区存"
398
- };
399
- const raw = record["cards"];
400
- if (!Array.isArray(raw)) return {
401
- ok: false,
402
- message: "cards 要是一个数组"
403
- };
404
- const cards = [];
405
- for (const item of raw.slice(0, 32)) {
406
- const card = asRecord$8(item);
407
- if (card === void 0) continue;
408
- const unit = textOf$7(card["unit"], 200);
409
- const route = textOf$7(card["route"], 400);
410
- const phase = textOf$7(card["phase"], 20);
411
- const at = atOf(card["at"]);
412
- if (unit === "" || route === "" || at === null || !PHASES.includes(phase)) continue;
413
- const error = errorOf(card["error"]);
414
- cards.push({
415
- unit,
416
- route,
417
- phase,
418
- at,
419
- ...error === void 0 ? {} : { error }
420
- });
421
- }
422
- if (cards.length === 0) return {
423
- ok: false,
424
- message: "cards 里没有一条能认的(unit / route / phase / at 都要有)"
425
- };
426
- return {
427
- ok: true,
428
- value: {
429
- workspace,
430
- cards
431
- }
432
- };
433
- }
434
- /**
435
- * 收一次卡片崩溃上报。
436
- * @param payload - `POST` 的请求体。
437
- * @returns `workspace` / `unit` / `reason` 都不能空,缺一给 `ok: false` + 中文原因;`at` 缺省或不合法时取当前时间。
438
- */
439
- function parseCrashReport(payload) {
440
- const record = asRecord$8(payload);
441
- if (record === void 0) return {
442
- ok: false,
443
- message: "请求体要是一个对象"
444
- };
445
- const workspace = textOf$7(record["workspace"], PANEL_STATE_MAX_TEXT);
446
- const unit = textOf$7(record["unit"], 200);
447
- const reason = textOf$7(record["reason"], PANEL_STATE_MAX_TEXT);
448
- if (workspace === "") return {
449
- ok: false,
450
- message: "workspace 不能是空的"
451
- };
452
- if (unit === "") return {
453
- ok: false,
454
- message: "unit 不能是空的"
455
- };
456
- if (reason === "") return {
457
- ok: false,
458
- message: "reason 不能是空的(没有原因的崩溃没法查)"
459
- };
460
- return {
461
- ok: true,
462
- value: {
463
- workspace,
464
- unit,
465
- reason,
466
- at: atOf(record["at"]) ?? Date.now()
467
- }
468
- };
469
- }
470
- /**
471
- * 造一份面板运行态的留存(进程内存、按工作区分桶,带上面那几条上限的 LRU)。
472
- * @returns 留存口:`cards` / `crash` 收上报,`snapshot(workspace)` 取全量;没存过的工作区(含 `null`)给一份空的。
158
+ * 把探针读数归成左栏那句人话的档位。
159
+ * @param probe - 探针读数。
160
+ * @param workshop - 当前工作区根路径;`null` 一律 `unknown`。
161
+ * @returns 椅面画着 → 本板 `ok` / 别板 `other-board`;没画着 → 页面已走 `off-workspace`,否则 `panel-closed`。
473
162
  */
474
- function createPanelStateStore() {
475
- const workspaces = /* @__PURE__ */ new Map();
476
- function trim(entry, max) {
477
- while (entry.size > max) {
478
- const oldest = entry.keys().next();
479
- if (oldest.done === true) return;
480
- entry.delete(oldest.value);
481
- }
482
- }
483
- function workspaceEntryOf(workspace) {
484
- const existing = workspaces.get(workspace);
485
- if (existing !== void 0) return existing;
486
- const created = {
487
- at: 0,
488
- units: /* @__PURE__ */ new Map()
489
- };
490
- workspaces.set(workspace, created);
491
- trim(workspaces, 64);
492
- return created;
493
- }
494
- function unitEntryOf(entry, unit) {
495
- const existing = entry.units.get(unit);
496
- if (existing !== void 0) return existing;
497
- const created = {
498
- at: 0,
499
- cards: /* @__PURE__ */ new Map()
500
- };
501
- entry.units.set(unit, created);
502
- return created;
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";
503
168
  }
504
- return {
505
- cards(report) {
506
- const entry = workspaceEntryOf(report.workspace);
507
- for (const card of report.cards) {
508
- const unit = unitEntryOf(entry, card.unit);
509
- unit.cards.delete(card.route);
510
- unit.cards.set(card.route, card);
511
- trim(unit.cards, 12);
512
- if (card.at > unit.at) unit.at = card.at;
513
- if (card.at > entry.at) entry.at = card.at;
514
- }
515
- },
516
- crash(report) {
517
- const entry = workspaceEntryOf(report.workspace);
518
- const unit = unitEntryOf(entry, report.unit);
519
- const previous = unit.crash;
520
- const counted = previous !== void 0 && previous.reason === report.reason && report.at >= previous.at && report.at - previous.at <= 6e5;
521
- unit.crash = {
522
- unit: report.unit,
523
- reason: report.reason,
524
- at: report.at,
525
- count: counted ? previous.count + 1 : 1
526
- };
527
- if (report.at > unit.at) unit.at = report.at;
528
- if (report.at > entry.at) entry.at = report.at;
529
- },
530
- snapshot(workspace) {
531
- if (workspace === null) return {
532
- workspace: null,
533
- at: 0,
534
- units: []
535
- };
536
- const entry = workspaces.get(workspace);
537
- if (entry === void 0) return {
538
- workspace,
539
- at: 0,
540
- units: []
541
- };
542
- const units = [...entry.units.entries()].map(([unit, one]) => {
543
- const cards = [...one.cards.values()].sort((a, b) => a.route.localeCompare(b.route));
544
- const okAt = cards.reduce((best, card) => card.phase === "ready" && (best === null || card.at > best) ? card.at : best, null);
545
- return {
546
- unit,
547
- at: one.at,
548
- okAt,
549
- cards,
550
- ...one.crash === void 0 ? {} : { crash: one.crash }
551
- };
552
- });
553
- units.sort((a, b) => b.at - a.at || a.unit.localeCompare(b.unit));
554
- return {
555
- workspace,
556
- at: entry.at,
557
- units
558
- };
559
- }
560
- };
169
+ if (probe.where !== "" && !insideWorkshop$1(workshop, probe.where)) return "off-workspace";
170
+ return "panel-closed";
561
171
  }
562
172
  //#endregion
563
- //#region ../shared/dist/lib/board-fact.js
564
- /**
565
- * 判一个路径落不落在这个工作区里(按路径前缀比,不是字符串包含)。
566
- * @param root - 工作区根路径。
567
- * @param target - 要判的路径。
568
- * @returns 同一个路径或在其下给 `true`;`root` 末尾带不带斜杠都认(`/a` 不匹配 `/ab`)。
569
- */
570
- function insideWorkshop$1(root, target) {
571
- if (target === root) return true;
572
- const base = root.endsWith("/") ? root : `${root}/`;
573
- return target.startsWith(base);
574
- }
173
+ //#region ../shared/dist/host/board-fact.js
575
174
  /**
576
175
  * 把一次探针读数归成椅面归属。
577
176
  * @param probe - 探针读数。
@@ -584,52 +183,214 @@ function boardFactOf(probe, workshop) {
584
183
  if (probe.board === "" || workshop === null) return "unknown";
585
184
  return insideWorkshop$1(workshop, probe.board) ? "workshop" : "other";
586
185
  }
186
+ //#endregion
187
+ //#region ../shared/dist/host/shell-text.js
587
188
  /**
588
- * 把探针读数归成左栏那句人话的档位。
589
- * @param probe - 探针读数。
590
- * @param workshop - 当前工作区根路径;`null` 一律 `unknown`。
591
- * @returns 椅面画着 → 本板 `ok` / 别板 `other-board`;没画着 → 页面已走 `off-workspace`,否则 `panel-closed`。
189
+ * 把一个值裹成 shell 里的单引号字面量(值里的单引号一并转义),保证元字符按字面量传递。
190
+ * @param value - 任意值;不是字符串的先 `String()`。
191
+ * @returns 可安全拼进 `/bin/sh -c` 的一段文本 —— 含空格 / `$()` / `;` / 通配符的值都不会被当成语法。
592
192
  */
593
- function boardSituation(probe, workshop) {
594
- if (!probe.workbench || workshop === null) return "unknown";
595
- if (probe.bar && probe.open && probe.drawn) {
596
- if (probe.board === "") return "unknown";
597
- return insideWorkshop$1(workshop, probe.board) ? "ok" : "other-board";
598
- }
599
- if (probe.where !== "" && !insideWorkshop$1(workshop, probe.where)) return "off-workspace";
600
- return "panel-closed";
193
+ function quoteShellArg(value) {
194
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
601
195
  }
602
- //#endregion
603
- //#region ../shared/dist/lib/diy-debug.js
604
196
  /**
605
- * 把工具入参收成人话动作。
606
- * @param value - 任意入参值。
607
- * @returns `'pause'` / `'resume'` / `'stop'` 原样;别的值(含 `undefined`、多余空白、大小写不同)给 `null`,由调用方拒。
197
+ * 取一段文本的末尾若干行(如命令输出),顺带去掉首尾空白。
198
+ * @param text - 原文;`null` / `undefined` 当空串。
199
+ * @param lines - 最多留几行,默认 6;0 给全篇、负数从开头砍掉那么多行(都是 `slice` 语义),调用方传正数。
200
+ * @returns 末尾若干行、去首尾空行后的文本;原文行数不够就全给,空输入给空串。
608
201
  */
609
- function parseDebugIntent(value) {
610
- return value === "pause" || value === "resume" || value === "stop" ? value : null;
202
+ function tailLines(text, lines = 6) {
203
+ return String(text ?? "").trim().split("\n").slice(-lines).join("\n");
611
204
  }
612
- function alive$1(state, now) {
613
- if (state === null) return null;
614
- return now - state.at <= 144e5 ? state : null;
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)];
615
210
  }
616
- function ownerOf$1(state, sessionId, now) {
617
- const held = alive$1(state, now);
618
- if (held === null) return "none";
619
- return held.sessionId === sessionId ? held : "not-owner";
211
+ function backgroundLine(command, argv) {
212
+ return [
213
+ ...backgroundPrefix(),
214
+ quoteShellArg(command),
215
+ ...argv.map(quoteShellArg)
216
+ ].join(" ");
620
217
  }
621
- /**
622
- * 造一份新的调试约定存储(进程内存,同一时刻只可能有一份有效约定)。
623
- * @returns 存取口;动作成不成都在返回的 `DebugMove` 里,不抛异常。
624
- */
625
- function createDebugStore() {
626
- let held = null;
627
- const live = (now) => alive$1(held, now);
628
- return {
629
- live,
630
- begin(pact) {
631
- const current = alive$1(held, pact.now);
632
- if (current !== null) return {
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();
232
+ return {
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
290
+ };
291
+ current = key;
292
+ continue;
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) }
306
+ };
307
+ }
308
+ function helpUrlOf(envelope) {
309
+ return /https?:\/\/[^\s"'`]+/.exec(envelope.help ?? "")?.[0];
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 = /[.,;:!?]+$/;
315
+ /**
316
+ * 把 kind:part/part 文本读回引用。
317
+ * @param text 待解析文本。
318
+ * @returns 没冒号、冒号在首尾、种类不是小写字母开头、或冒号后为空时给 undefined;不抛异常。
319
+ */
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
+ };
331
+ }
332
+ /**
333
+ * 从一段文本里按出现顺序捞出所有标记引用,同一个引用只留第一次。
334
+ * @param text 任意文本;null / undefined 当空串。
335
+ * @returns 解析不出来的标记会被丢掉,所以结果只含合法引用,可能为空数组。
336
+ */
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);
347
+ }
348
+ return refs;
349
+ }
350
+ //#endregion
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
365
+ /**
366
+ * 把工具入参收成人话动作。
367
+ * @param value - 任意入参值。
368
+ * @returns `'pause'` / `'resume'` / `'stop'` 原样;别的值(含 `undefined`、多余空白、大小写不同)给 `null`,由调用方拒。
369
+ */
370
+ function parseDebugIntent(value) {
371
+ return value === "pause" || value === "resume" || value === "stop" ? value : null;
372
+ }
373
+ function alive$1(state, now) {
374
+ if (state === null) return null;
375
+ return now - state.at <= 144e5 ? state : null;
376
+ }
377
+ function ownerOf$1(state, sessionId, now) {
378
+ const held = alive$1(state, now);
379
+ if (held === null) return "none";
380
+ return held.sessionId === sessionId ? held : "not-owner";
381
+ }
382
+ /**
383
+ * 造一份新的调试约定存储(进程内存,同一时刻只可能有一份有效约定)。
384
+ * @returns 存取口;动作成不成都在返回的 `DebugMove` 里,不抛异常。
385
+ */
386
+ function createDebugStore() {
387
+ let held = null;
388
+ const live = (now) => alive$1(held, now);
389
+ return {
390
+ live,
391
+ begin(pact) {
392
+ const current = alive$1(held, pact.now);
393
+ if (current !== null) return {
633
394
  ok: false,
634
395
  code: current.sessionId === pact.sessionId ? "already-held" : "held-by-other"
635
396
  };
@@ -714,6 +475,19 @@ function createDebugStore() {
714
475
  function parseRecordIntent(raw) {
715
476
  return raw === "peek" || raw === "halt" || raw === "stop" ? raw : null;
716
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
+ }
717
491
  function alive(state, now) {
718
492
  if (state === null) return null;
719
493
  return now - state.at <= 9e5 ? state : null;
@@ -730,6 +504,26 @@ function ownerOf(state, sessionId, now) {
730
504
  function createRecordStore() {
731
505
  let held = null;
732
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
+ };
733
527
  return {
734
528
  live,
735
529
  begin(pact) {
@@ -752,6 +546,11 @@ function createRecordStore() {
752
546
  startedAt: pact.now,
753
547
  t0: null,
754
548
  t1: null,
549
+ shots: [],
550
+ shotsSeen: 0,
551
+ shotTries: null,
552
+ beats: 0,
553
+ beatMs: 0,
755
554
  at: pact.now
756
555
  };
757
556
  held = state;
@@ -808,6 +607,50 @@ function createRecordStore() {
808
607
  state
809
608
  };
810
609
  },
610
+ shotTaken(sessionId, shot, now) {
611
+ const found = ownerOf(held, sessionId, now);
612
+ if (typeof found === "string") return {
613
+ ok: false,
614
+ code: found
615
+ };
616
+ const state = withShot(found, shot, now, void 0);
617
+ held = state;
618
+ return {
619
+ ok: true,
620
+ state
621
+ };
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
+ },
811
654
  stop(sessionId, now) {
812
655
  const found = ownerOf(held, sessionId, now);
813
656
  if (typeof found === "string") return {
@@ -823,173 +666,276 @@ function createRecordStore() {
823
666
  };
824
667
  }
825
668
  //#endregion
826
- //#region ../shared/dist/host/opencli-window.js
827
- var OPENCLI_WINDOW_ENV = "OPENCLI_WINDOW=background";
828
- function backgroundPrefix() {
829
- return [quoteShellArg("env"), quoteShellArg(OPENCLI_WINDOW_ENV)];
830
- }
831
- function backgroundLine(command, argv) {
832
- return [
833
- ...backgroundPrefix(),
834
- quoteShellArg(command),
835
- ...argv.map(quoteShellArg)
836
- ].join(" ");
837
- }
838
- //#endregion
839
- //#region ../shared/dist/host/shell.js
840
- var DEFAULT_MAX_BYTES = 8388608;
841
- async function runShell(shell, command, options = {}) {
842
- const { workdir, timeoutMs = GIT_TIMEOUT_MS, signal } = options;
843
- const spec = shell.resolve({
844
- command,
845
- ...workdir === void 0 ? {} : { workdir },
846
- timeoutMs,
847
- onExpiry: "kill",
848
- stdoutMaxBytes: DEFAULT_MAX_BYTES,
849
- ...signal === void 0 ? {} : { signal }
850
- });
851
- const result = await (await shell.execute(spec)).result();
669
+ //#region ../shared/dist/host/facts.js
670
+ function createFactLog(options = {}) {
671
+ const epoch = options.epoch ?? `e${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
672
+ let rev = 0;
673
+ const last = /* @__PURE__ */ new Map();
674
+ const listeners = /* @__PURE__ */ new Set();
852
675
  return {
853
- exitCode: result.exitCode,
854
- timedOut: result.timedOut === true,
855
- aborted: result.aborted === true,
856
- stdout: result.stdout.text,
857
- stderr: result.stderr.text,
858
- truncated: result.stdout.truncated || result.stderr.truncated
676
+ epoch: () => epoch,
677
+ revision: () => rev,
678
+ bump(facts) {
679
+ rev += 1;
680
+ const named = [...new Set(facts.filter((name) => name !== ""))];
681
+ for (const name of named) last.set(name, rev);
682
+ const change = {
683
+ rev,
684
+ facts: named
685
+ };
686
+ for (const listener of [...listeners]) listener(change);
687
+ return change;
688
+ },
689
+ since(seen) {
690
+ const facts = [...last.entries()].filter(([, at]) => at > seen).map(([name]) => name);
691
+ return {
692
+ epoch,
693
+ rev,
694
+ facts
695
+ };
696
+ },
697
+ subscribe(listener) {
698
+ listeners.add(listener);
699
+ return () => {
700
+ listeners.delete(listener);
701
+ };
702
+ }
859
703
  };
860
704
  }
861
- //#endregion
862
- //#region ../shared/dist/host/browser-read.js
863
- var BROWSER_SCREEN_SESSION = "workbench";
864
- var BROWSER_READ_SESSION = "workbench-read";
865
- var BROWSER_RECORD_SESSION = "workbench-rec";
866
- //#endregion
867
- //#region ../shared/dist/host/cli-error.js
868
- function unquote(raw) {
869
- const value = raw.trim();
870
- if (value.length >= 2 && (value.startsWith("\"") && value.endsWith("\"") || value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1);
871
- return value;
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;
872
712
  }
873
- function joinBlock(previous, line, fold) {
874
- if (previous === "") return line;
875
- return fold ? `${previous} ${line}` : `${previous}\n${line}`;
713
+ function wroteNow(result) {
714
+ const wrote = (result?.value)?.wrote;
715
+ return typeof wrote === "boolean" ? wrote : void 0;
876
716
  }
877
- function parseCliErrorEnvelope(text) {
878
- if (text === void 0 || text.trim() === "") return void 0;
879
- const lines = text.split("\n");
880
- const start = lines.findIndex((line) => /^error:\s*$/.test(line.trim()) || /^error:\s*\S/.test(line.trim()));
881
- if (start === -1) return void 0;
882
- const scalars = {};
883
- let current;
884
- let block;
885
- const errorIndent = lines[start]?.search(/\S/) ?? 0;
886
- for (const line of lines.slice(start + 1)) {
887
- if (line.trim() === "") continue;
888
- const indent = line.search(/\S/);
889
- if (block !== void 0 && indent > block.indent) {
890
- scalars[block.key] = joinBlock(scalars[block.key] ?? "", line.trim(), block.fold);
891
- continue;
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
892
808
  }
893
- block = void 0;
894
- if (indent <= errorIndent) break;
895
- const match = /^\s*(code|message|help|exitCode):\s*(.*)$/.exec(line);
896
- if (match === null) {
897
- if (current !== void 0) scalars[current] = `${scalars[current] ?? ""} ${line.trim()}`.trim();
898
- continue;
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()
899
844
  }
900
- const [, key, value] = match;
901
- if (key === void 0) continue;
902
- const raw = (value ?? "").trim();
903
- const marker = /^([|>])[0-9]*([+-]?)$/.exec(raw);
904
- if (marker !== null) {
905
- scalars[key] = "";
906
- block = {
907
- key,
908
- fold: marker[1] === ">",
909
- indent
910
- };
911
- current = key;
912
- continue;
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);
913
858
  }
914
- scalars[key] = unquote(raw);
915
- current = key;
916
859
  }
917
- const code = scalars["code"];
918
- const message = scalars["message"];
919
- if (code === void 0 || code === "") return void 0;
920
- const exitCode = scalars["exitCode"];
921
- return {
922
- code,
923
- message: message ?? code,
924
- ...scalars["help"] === void 0 || scalars["help"] === "" ? {} : { help: scalars["help"] },
925
- ...exitCode === void 0 || Number.isNaN(Number(exitCode)) ? {} : { exitCode: Number(exitCode) }
926
- };
927
- }
928
- function helpUrlOf(envelope) {
929
- return /https?:\/\/[^\s"'`]+/.exec(envelope.help ?? "")?.[0];
930
- }
931
- //#endregion
932
- //#region ../shared/dist/host/data-root.js
933
- function dshHome() {
934
- const configured = process.env.DSH_HOME?.trim();
935
- if (configured !== void 0 && configured !== "") return configured;
936
- return `${process.env.HOME ?? ""}/.dsh`;
937
- }
938
- function workbenchRoot() {
939
- return `${dshHome()}/workbench`;
940
- }
941
- function workspacesRoot() {
942
- return `${workbenchRoot()}/workspaces`;
943
- }
944
- //#endregion
945
- //#region ../shared/dist/host/facts.js
946
- function createFactLog(options = {}) {
947
- const epoch = options.epoch ?? `e${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
948
- let rev = 0;
949
- const last = /* @__PURE__ */ new Map();
950
- const listeners = /* @__PURE__ */ new Set();
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
+ }
951
881
  return {
952
- epoch: () => epoch,
953
- revision: () => rev,
954
- bump(facts) {
955
- rev += 1;
956
- const named = [...new Set(facts.filter((name) => name !== ""))];
957
- for (const name of named) last.set(name, rev);
958
- const change = {
959
- rev,
960
- facts: named
961
- };
962
- for (const listener of [...listeners]) listener(change);
963
- return change;
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
+ }
964
892
  },
965
- since(seen) {
966
- const facts = [...last.entries()].filter(([, at]) => at > seen).map(([name]) => name);
967
- return {
968
- epoch,
969
- rev,
970
- facts
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
971
903
  };
904
+ if (report.at > unit.at) unit.at = report.at;
905
+ if (report.at > entry.at) entry.at = report.at;
972
906
  },
973
- subscribe(listener) {
974
- listeners.add(listener);
975
- return () => {
976
- listeners.delete(listener);
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
977
935
  };
978
936
  }
979
937
  };
980
938
  }
981
- function toolUnits(manifests) {
982
- const map = /* @__PURE__ */ new Map();
983
- for (const manifest of manifests) for (const tool of manifest.tools ?? []) map.set(tool.name, {
984
- unit: manifest.id,
985
- writes: tool.writes
986
- });
987
- return map;
988
- }
989
- function wroteNow(result) {
990
- const wrote = (result?.value)?.wrote;
991
- return typeof wrote === "boolean" ? wrote : void 0;
992
- }
993
939
  //#endregion
994
940
  //#region ../shared/dist/host/provision.js
995
941
  var PROVISION_ACTIONS = [
@@ -1488,7 +1434,7 @@ function changesHandler(log) {
1488
1434
  }
1489
1435
  function preflightHandler(preflight) {
1490
1436
  return (search) => {
1491
- preflight.run({ force: flag(search, "force") });
1437
+ preflight.run({ hard: flag(search, "hard") });
1492
1438
  return {
1493
1439
  ok: true,
1494
1440
  ...preflight.snapshot()
@@ -1654,6 +1600,9 @@ function createLongTaskRegistry(deps) {
1654
1600
  id: snapshot.id
1655
1601
  }));
1656
1602
  },
1603
+ live() {
1604
+ return [...runs.values()].filter((snapshot) => LIVE.has(snapshot.status));
1605
+ },
1657
1606
  start(options) {
1658
1607
  const running = this.active(options.workspace, options.target);
1659
1608
  if (running !== null) return running;
@@ -2084,62 +2033,210 @@ async function approvalFor(call, context = {}) {
2084
2033
  reason: `问"要不要审批"的时候套件的策略抛了错,已经停下:${messageOf$1(error)}`
2085
2034
  };
2086
2035
  }
2087
- if (verdict?.kind === "read" || verdict?.kind === "allow") return void 0;
2088
- if (verdict?.kind !== "ask") return {
2089
- kind: "deny",
2090
- reason: `${call.name ?? "这把工具"} 在清单里声明会写东西,但它的套件没认领这次调用 —— 停下:在套件的 needsApproval 里补一条审批(含"这次会发生什么"),别让写静默过去`
2091
- };
2092
- let facts;
2093
- try {
2094
- facts = await context.detailOf?.(call);
2095
- } catch (error) {
2096
- return {
2097
- kind: "deny",
2098
- reason: `拿不到"这次会发生什么",已经停下:${messageOf$1(error)}`
2099
- };
2036
+ if (verdict?.kind === "read" || verdict?.kind === "allow") return void 0;
2037
+ if (verdict?.kind !== "ask") return {
2038
+ kind: "deny",
2039
+ reason: `${call.name ?? "这把工具"} 在清单里声明会写东西,但它的套件没认领这次调用 —— 停下:在套件的 needsApproval 里补一条审批(含"这次会发生什么"),别让写静默过去`
2040
+ };
2041
+ let facts;
2042
+ try {
2043
+ facts = await context.detailOf?.(call);
2044
+ } catch (error) {
2045
+ return {
2046
+ kind: "deny",
2047
+ reason: `拿不到"这次会发生什么",已经停下:${messageOf$1(error)}`
2048
+ };
2049
+ }
2050
+ if (facts !== void 0 && "blocked" in facts) return {
2051
+ kind: "deny",
2052
+ reason: facts.blocked
2053
+ };
2054
+ return {
2055
+ kind: "ask",
2056
+ reason: verdict.reason,
2057
+ displayReason: compose(facts, verdict, context.declaredNote === true ? approvalNoteOf(call) : void 0)
2058
+ };
2059
+ }
2060
+ //#endregion
2061
+ //#region ../shared/dist/host/tool-kit.js
2062
+ function sessionWorkspace(exec) {
2063
+ const cwd = exec?.agent?.session?.header?.cwd;
2064
+ return typeof cwd === "string" && cwd.trim() !== "" ? cwd.trim() : null;
2065
+ }
2066
+ function sessionIdOf(exec) {
2067
+ const id = exec?.agent?.session?.id;
2068
+ return typeof id === "string" && id.trim() !== "" ? id.trim() : null;
2069
+ }
2070
+ function textOutput(text) {
2071
+ return [{
2072
+ type: "text",
2073
+ text
2074
+ }];
2075
+ }
2076
+ function textRender(_args, value) {
2077
+ const text = value?.text;
2078
+ return textOutput(typeof text === "string" ? text : JSON.stringify(value));
2079
+ }
2080
+ var TEXT_OUTPUT = {
2081
+ schema: {
2082
+ type: "object",
2083
+ properties: {},
2084
+ additionalProperties: true
2085
+ },
2086
+ render: textRender
2087
+ };
2088
+ function jsonText(value) {
2089
+ return JSON.stringify(value);
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
+ }
2100
2203
  }
2101
- if (facts !== void 0 && "blocked" in facts) return {
2102
- kind: "deny",
2103
- reason: facts.blocked
2104
- };
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;
2105
2218
  return {
2106
- kind: "ask",
2107
- reason: verdict.reason,
2108
- displayReason: compose(facts, verdict, context.declaredNote === true ? approvalNoteOf(call) : void 0)
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
+ }
2109
2237
  };
2110
2238
  }
2111
2239
  //#endregion
2112
- //#region ../shared/dist/host/tool-kit.js
2113
- function sessionWorkspace(exec) {
2114
- const cwd = exec?.agent?.session?.header?.cwd;
2115
- return typeof cwd === "string" && cwd.trim() !== "" ? cwd.trim() : null;
2116
- }
2117
- function sessionIdOf(exec) {
2118
- const id = exec?.agent?.session?.id;
2119
- return typeof id === "string" && id.trim() !== "" ? id.trim() : null;
2120
- }
2121
- function textOutput(text) {
2122
- return [{
2123
- type: "text",
2124
- text
2125
- }];
2126
- }
2127
- function textRender(_args, value) {
2128
- const text = value?.text;
2129
- return textOutput(typeof text === "string" ? text : JSON.stringify(value));
2130
- }
2131
- var TEXT_OUTPUT = {
2132
- schema: {
2133
- type: "object",
2134
- properties: {},
2135
- additionalProperties: true
2136
- },
2137
- render: textRender
2138
- };
2139
- function jsonText(value) {
2140
- return JSON.stringify(value);
2141
- }
2142
- //#endregion
2143
2240
  //#region ../shared/dist/host/value-handoff.js
2144
2241
  function handoffId(entry) {
2145
2242
  return `${String(entry.fact.length)}:${entry.fact}${entry.key}`;
@@ -3923,6 +4020,18 @@ async function resolveAt(registry, dir) {
3923
4020
  function looksLikeWorkshop(dir) {
3924
4021
  return existsSync(join(dir, "packages")) || existsSync(join(dir, "pnpm-workspace.yaml"));
3925
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
+ }
3926
4035
  async function workshopRootOf(ctx, workspace) {
3927
4036
  const start = typeof workspace === "string" ? workspace.trim() : "";
3928
4037
  if (start === "") return null;
@@ -4015,10 +4124,19 @@ function showOrReason(panels, unit, workspace) {
4015
4124
  };
4016
4125
  }
4017
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
+ */
4018
4136
  async function workshopOf(ctx, exec) {
4019
4137
  const session = sessionWorkspace(exec);
4020
- const root = await workshopRootOf(ctx, session);
4021
- 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 新建一个模块包`);
4022
4140
  return {
4023
4141
  packagesDir: root.packagesDir,
4024
4142
  workspace: root.root,
@@ -4540,7 +4658,7 @@ async function stopNetworkExact(shell, file, timeoutMs = SWEEP_TIMEOUT_MS) {
4540
4658
  `[ -n "$p" ] && kill "$p" 2>/dev/null`,
4541
4659
  `[ -n "$r" ] && pkill -P "$r" 2>/dev/null`,
4542
4660
  `[ -n "$r" ] && kill "$r" 2>/dev/null`,
4543
- `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`,
4544
4662
  "true"
4545
4663
  ].join("; "), { timeoutMs })).exitCode === 0;
4546
4664
  }
@@ -6609,7 +6727,7 @@ var RECORD_ARM_SCRIPT = `(() => {
6609
6727
  const OFF = ${JSON.stringify(RECORD_OFF_KEY)};
6610
6728
  const CAP = ${String(50)};
6611
6729
  const BODY_MAX = ${String(16384)};
6612
- 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 });
6613
6731
  const readKeep = () => {
6614
6732
  try {
6615
6733
  const raw = sessionStorage.getItem(KEEP);
@@ -6644,23 +6762,64 @@ var RECORD_ARM_SCRIPT = `(() => {
6644
6762
  const repaint = () => {
6645
6763
  if (typeof state.__repaint === 'function') state.__repaint();
6646
6764
  };
6647
- const record = (method, url, body, status, ct, response) => {
6648
- if (state.phase !== 'recording') return;
6649
- state.calls.push({
6650
- method: String(method === '' || method === undefined ? 'GET' : method).toUpperCase(),
6651
- 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,
6652
6786
  body: slice(body === undefined || body === null ? '' : body),
6653
- status: typeof status === 'number' ? status : 0,
6654
- ct: String(ct === undefined || ct === null ? '' : ct),
6655
- response: slice(response === undefined || response === null ? '' : response),
6787
+ status: 0,
6788
+ ct: '',
6789
+ response: '',
6656
6790
  at: Date.now(),
6657
- });
6791
+ };
6792
+ state.calls.push(row);
6658
6793
  if (state.calls.length > CAP) {
6659
6794
  state.dropped = (typeof state.dropped === 'number' ? state.dropped : 0) + (state.calls.length - CAP);
6660
6795
  state.calls.splice(0, state.calls.length - CAP);
6661
6796
  }
6797
+ pending.push(row);
6662
6798
  keep();
6663
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);
6664
6823
  };
6665
6824
  const fresh = typeof window[OFF] !== 'function';
6666
6825
  if (fresh) {
@@ -6691,6 +6850,7 @@ var RECORD_ARM_SCRIPT = `(() => {
6691
6850
  const url = typeof asked === 'string' ? asked : asked !== null && typeof asked === 'object' ? asked.url : '';
6692
6851
  const method = init.method !== undefined ? init.method : asked !== null && typeof asked === 'object' ? asked.method : 'GET';
6693
6852
  const body = init.body === undefined ? '' : init.body;
6853
+ const row = begin(method, url, body);
6694
6854
  const done = realFetch.apply(this, args);
6695
6855
  try {
6696
6856
  done.then((response) => {
@@ -6698,11 +6858,11 @@ var RECORD_ARM_SCRIPT = `(() => {
6698
6858
  const ct = response.headers.get('content-type') || '';
6699
6859
  const clone = response.clone();
6700
6860
  const target = response.url || url;
6701
- 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, ''));
6702
6862
  } catch (error) {}
6703
6863
  }).catch((error) => {
6704
6864
  try {
6705
- 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));
6706
6866
  } catch (inner) {}
6707
6867
  });
6708
6868
  } catch (error) {}
@@ -6720,9 +6880,10 @@ var RECORD_ARM_SCRIPT = `(() => {
6720
6880
  xhr.removeEventListener('loadend', xhr.__dshLoadEnd);
6721
6881
  } catch (error) {}
6722
6882
  }
6883
+ const row = begin(xhr.__dshMethod, xhr.__dshUrl, body);
6723
6884
  const onEnd = () => {
6724
6885
  try {
6725
- 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));
6726
6887
  } catch (error) {}
6727
6888
  };
6728
6889
  xhr.__dshLoadEnd = onEnd;
@@ -6732,7 +6893,7 @@ var RECORD_ARM_SCRIPT = `(() => {
6732
6893
  if (hasBeacon) {
6733
6894
  navigator.sendBeacon = function (url, data) {
6734
6895
  try {
6735
- record('POST', url, data === undefined || data === null ? '' : data, 0, 'beacon', '');
6896
+ begin('POST', url, data === undefined || data === null ? '' : data);
6736
6897
  } catch (error) {}
6737
6898
  return realBeacon.apply(navigator, arguments);
6738
6899
  };
@@ -6801,23 +6962,31 @@ var RECORD_ARM_SCRIPT = `(() => {
6801
6962
  const label = document.createElement('span');
6802
6963
  label.setAttribute('style', 'flex:0 0 auto;white-space:nowrap;font-variant-numeric:tabular-nums');
6803
6964
  const start = document.createElement('button');
6965
+ const shot = document.createElement('button');
6804
6966
  const end = document.createElement('button');
6805
6967
  start.type = 'button';
6806
6968
  start.setAttribute('title', '开始录制');
6807
6969
  start.setAttribute('aria-label', '开始录制');
6808
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>截图';
6809
6975
  end.type = 'button';
6810
6976
  end.setAttribute('title', '结束录制');
6811
6977
  end.setAttribute('aria-label', '结束录制');
6812
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>结束';
6813
- 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');
6814
6980
  const paint = () => {
6815
6981
  const phase = state.phase;
6816
- 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) + ' 张' : '');
6817
6984
  dot.style.display = phase === 'recording' ? 'inline-block' : 'none';
6818
6985
  start.disabled = phase !== 'armed';
6986
+ shot.disabled = phase !== 'recording';
6819
6987
  end.disabled = phase !== 'recording';
6820
6988
  start.setAttribute('style', look(phase === 'armed'));
6989
+ shot.setAttribute('style', look(phase === 'recording'));
6821
6990
  end.setAttribute('style', look(phase === 'recording'));
6822
6991
  };
6823
6992
  state.__repaint = paint;
@@ -6869,12 +7038,22 @@ var RECORD_ARM_SCRIPT = `(() => {
6869
7038
  state.href = location.href;
6870
7039
  state.calls = [];
6871
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');
6872
7050
  keep();
6873
7051
  paint();
6874
7052
  };
6875
7053
  end.onclick = () => {
6876
7054
  state.phase = 'finished';
6877
7055
  state.t1 = Date.now();
7056
+ beatOf('stop');
6878
7057
  keep();
6879
7058
  paint();
6880
7059
  };
@@ -6882,15 +7061,17 @@ var RECORD_ARM_SCRIPT = `(() => {
6882
7061
  bar.appendChild(grip);
6883
7062
  bar.appendChild(dot);
6884
7063
  bar.appendChild(label);
7064
+ bar.appendChild(shot);
6885
7065
  bar.appendChild(start);
6886
7066
  bar.appendChild(end);
6887
7067
  if (typeof state.barX === 'number' && typeof state.barY === 'number') place(state.barX, state.barY);
6888
7068
  paint();
6889
- 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 };
6890
7070
  })()`;
6891
- var RECORD_READ_SCRIPT = `(() => {
7071
+ var RECORD_READ_SCRIPT = `(async () => {
6892
7072
  const KEY = ${JSON.stringify(RECORD_KEY)};
6893
7073
  const KEEP = ${JSON.stringify(RECORD_KEEP_KEY)};
7074
+ const BEAT_WAIT = 3000;
6894
7075
  let state = window[KEY] !== null && typeof window[KEY] === 'object' ? window[KEY] : null;
6895
7076
  if (state === null) {
6896
7077
  try {
@@ -6900,8 +7081,29 @@ var RECORD_READ_SCRIPT = `(() => {
6900
7081
  state = null;
6901
7082
  }
6902
7083
  }
6903
- if (state === null || typeof state !== 'object') return { ok: false, code: 'not-armed', phase: 'none', calls: [] };
6904
- 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 : [] };
6905
7107
  })()`;
6906
7108
  var RECORD_STOP_SCRIPT = `(() => {
6907
7109
  const KEY = ${JSON.stringify(RECORD_KEY)};
@@ -7160,6 +7362,10 @@ function recordPageOf(payload) {
7160
7362
  here: typeof raw["here"] === "string" ? raw["here"] : "",
7161
7363
  dropped: typeof raw["dropped"] === "number" ? raw["dropped"] : 0,
7162
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"] : "",
7163
7369
  calls
7164
7370
  };
7165
7371
  }
@@ -7240,21 +7446,43 @@ function sizeText(bytes) {
7240
7446
  function rowTextOf(one) {
7241
7447
  const mark = one.marked ? " ⭐" : " ";
7242
7448
  const where = one.source === "capture" ? "捕获" : "钩子";
7243
- 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(" · ");
7244
7470
  }
7245
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);
7246
7476
  if (!input.armed) return `那一页上还没有录制条(${DIY_RECORD_PREPARE_TOOL} 起的那一场可能被导航冲掉了)—— 先 ${DIY_RECORD_PREPARE_TOOL},或让他回到那一页再 ${DIY_RECORD_READ_TOOL}。`;
7247
7477
  if (input.phase === "armed") return "他还没按「开始录制」—— 等他按了再读。";
7248
- if (input.phase === "recording") return `还在录(页面钩子上已经攒了 ${String(input.hookCaught)} 条)—— 等他按「结束录制」。`;
7249
- const hook = String(input.hookCaught);
7250
- const inside = String(input.capturedInSegment);
7251
- 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);
7252
7481
  const restShown = input.rows.filter((one) => !one.marked).slice(-Math.max(0, CALLS_SHOWN - markShown.length));
7253
7482
  const shown = [...markShown, ...restShown];
7254
- const lines = [];
7255
- if (input.rows.length > 0) lines.push(`录完了:这一段一共 ${String(input.merged)} 条请求(页面钩子 ${hook} 条 · 会话捕获落在这个时间段里 ${inside} 条,合并去重后 ${String(input.merged)} 条)。下面 ⭐ 都列上、其余按时间取最近几条:`, ...shown.map(rowTextOf));
7256
- else lines.push(input.capturedTruncated ? `录完了:这一段(就解析到的那部分而言)一条请求都没抓到(页面钩子 0 条 · 解析到的会话捕获里落在这个时间段里 0 条)—— 但捕获文件被截过,见下一行。` : "录完了:这一段一条请求都没抓到(页面钩子 0 条 · 会话捕获落在这个时间段里 0 条,两边都是 0 —— 不是筛掉的)。");
7257
- 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}`);
7258
7486
  if (input.dropped > 0) lines.push(`(缓冲区满了,最早丢了 ${String(input.dropped)} 条)`);
7259
7487
  if (input.captureRound === "fail") lines.push("(会话捕获最近一轮没跑成(opencli 那条报了错)—— 这一场可能只有页面钩子。)");
7260
7488
  else if (input.captureRound === "") lines.push("(会话捕获还没回过一轮 —— 再等一拍再看,别急着下\"没抓到\"的结论。)");
@@ -7275,6 +7503,7 @@ function clip(text, maxBytes) {
7275
7503
  function sizeLabel(size, exact) {
7276
7504
  return exact ? `${String(size)} 字节` : `${String(size)} 字符(opencli 没给 body_full_size,按字符数算)`;
7277
7505
  }
7506
+ /** 把字段证据表插进详情正文:接在出参那一块之后、`请求头` 那一行(或第一条附注)之前;没有锚点就接在末尾。空串原样回。 */
7278
7507
  function renderRecordDetail(input) {
7279
7508
  if (!input.found) return [`没找到这一条:${input.note}`, `可用的取法:② 还在这场录制里时用清单里的 key 或 index;③ 这一场已经收工的话,给 unit 让我去读 shots/rec-<unit>.jsonl.last;④ 都不行就让他打开 DevTools,把那次请求复制成 curl。`].join("\n");
7280
7509
  const req = clip(input.request, input.maxBytes);
@@ -7329,7 +7558,10 @@ var POLL_MS = 1e3;
7329
7558
  var CAPTURE_TAIL_LINES = 2e4;
7330
7559
  var CAPTURE_POLL_S = 3;
7331
7560
  var CAPTURE_ARM_S = 2;
7332
- 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)(\\?|$))`;
7333
7565
  var RECORD_WATCH_MS = 3e3;
7334
7566
  var RECORD_WATCH_TRIES = 5;
7335
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\")}});";
@@ -7354,6 +7586,48 @@ function captureFileOf(packagesDir, unit) {
7354
7586
  function lastCaptureFileOf(file) {
7355
7587
  return `${file}.last`;
7356
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
+ }
7357
7631
  async function startRecordCapture(shell, command, session, file) {
7358
7632
  const bin = command === "" ? "opencli" : command;
7359
7633
  const arm = backgroundLine(bin, [
@@ -7393,6 +7667,30 @@ async function releaseLeftoversOf(input) {
7393
7667
  captureFile: input.captureFile
7394
7668
  });
7395
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
+ */
7396
7694
  async function watchFinishStepOf(input) {
7397
7695
  const live = input.store.live(Date.now());
7398
7696
  if (live === null) {
@@ -7406,11 +7704,15 @@ async function watchFinishStepOf(input) {
7406
7704
  stop: true,
7407
7705
  delivered: false
7408
7706
  };
7409
- if (live.phase === "finished") return {
7410
- stop: false,
7411
- delivered: false
7412
- };
7707
+ if (live.phase === "finished") {
7708
+ await closeRecordTabQuiet(input);
7709
+ return {
7710
+ stop: true,
7711
+ delivered: false
7712
+ };
7713
+ }
7413
7714
  let page;
7715
+ const started = Date.now();
7414
7716
  try {
7415
7717
  page = recordPageOf((await actBrowser(input.shell, input.command, BROWSER_RECORD_SESSION, ["eval", RECORD_READ_SCRIPT])).payload);
7416
7718
  } catch {
@@ -7419,6 +7721,7 @@ async function watchFinishStepOf(input) {
7419
7721
  delivered: false
7420
7722
  };
7421
7723
  }
7724
+ if (page.beat) input.store.beat(input.sessionId, Math.max(0, Date.now() - started), Date.now());
7422
7725
  const held = input.store.live(Date.now());
7423
7726
  if (held === null) {
7424
7727
  await releaseLeftoversOf(input);
@@ -7431,22 +7734,31 @@ async function watchFinishStepOf(input) {
7431
7734
  stop: true,
7432
7735
  delivered: false
7433
7736
  };
7434
- if (held.phase === "finished") return {
7435
- stop: false,
7436
- delivered: false
7437
- };
7737
+ if (held.phase === "finished") {
7738
+ await stopCaptureQuiet(input);
7739
+ await closeRecordTabQuiet(input);
7740
+ return {
7741
+ stop: true,
7742
+ delivered: false
7743
+ };
7744
+ }
7438
7745
  if (!page.ok) return {
7439
7746
  stop: false,
7440
7747
  delivered: false
7441
7748
  };
7749
+ const due = shotDueOf(held, page.shots);
7750
+ if (due !== null) await takeShotOf(input, held, due);
7442
7751
  if (page.phase === "finished" && page.t1 !== null) {
7443
7752
  const now = Date.now();
7444
- 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);
7445
7755
  const caught = page.calls.length;
7446
7756
  const summary = `工作台 DIY:他按了「结束录制」(${String(caught)} 条)`;
7447
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);
7448
7760
  return {
7449
- stop: false,
7761
+ stop: true,
7450
7762
  delivered: true
7451
7763
  };
7452
7764
  }
@@ -7456,18 +7768,25 @@ async function watchFinishStepOf(input) {
7456
7768
  delivered: false
7457
7769
  };
7458
7770
  }
7771
+ /**
7772
+ * 看护的一轮(就是 `watchFinishStepOf`,留着这个名字是给测试与调用点一个稳定的形)。
7773
+ * @param input - 看护那一拍的依赖。@returns 收不收工、有没有叫醒。
7774
+ */
7775
+ async function watchRoundOf(input) {
7776
+ return watchFinishStepOf(input);
7777
+ }
7459
7778
  function watchRecordFinish(input) {
7460
7779
  let misses = 0;
7461
7780
  const tick = () => {
7462
- watchFinishStepOf(input).then((out) => {
7781
+ watchRoundOf(input).then((out) => {
7463
7782
  misses = 0;
7464
- if (!out.stop) setTimeout(tick, RECORD_WATCH_MS);
7783
+ if (!out.stop) setTimeout(tick, 0);
7465
7784
  }).catch(() => {
7466
7785
  misses += 1;
7467
7786
  if (misses < RECORD_WATCH_TRIES) setTimeout(tick, RECORD_WATCH_MS);
7468
7787
  });
7469
7788
  };
7470
- setTimeout(tick, RECORD_WATCH_MS);
7789
+ setTimeout(tick, 0);
7471
7790
  }
7472
7791
  async function captureLinesOf(shell, file, timeoutMs = START_TIMEOUT_MS) {
7473
7792
  const step = await runShell(shell, `wc -l < ${quoteShellArg(file)} 2>/dev/null || echo 0`, { timeoutMs });
@@ -7549,30 +7868,12 @@ function detailFactsOf(input) {
7549
7868
  responseSizeExact: input.responseSizeExact,
7550
7869
  ambiguous: input.ambiguous,
7551
7870
  source: input.source,
7552
- text: renderRecordDetail({
7553
- found: true,
7554
- where: input.where,
7555
- method: input.method,
7556
- url: input.url,
7557
- status: input.status,
7558
- ct: input.ct,
7559
- at: input.at,
7560
- request: input.request,
7561
- response: input.response,
7562
- headers: input.headers,
7563
- requestFullSize: input.requestFullSize,
7564
- responseFullSize: input.responseFullSize,
7565
- requestSizeExact: input.requestSizeExact,
7566
- responseSizeExact: input.responseSizeExact,
7567
- ambiguous: input.ambiguous,
7568
- source: input.source,
7569
- maxBytes: input.maxBytes,
7570
- note: ""
7571
- })
7871
+ text: renderRecordDetail(input)
7572
7872
  });
7573
7873
  }
7574
7874
  function detailOfEntry(entry, maxBytes, ambiguous = 1) {
7575
7875
  return detailFactsOf({
7876
+ found: true,
7576
7877
  where: DETAIL_WHERE_CAPTURE,
7577
7878
  key: entry.key,
7578
7879
  method: entry.method,
@@ -7589,11 +7890,13 @@ function detailOfEntry(entry, maxBytes, ambiguous = 1) {
7589
7890
  responseSizeExact: entry.responseSizeExact,
7590
7891
  ambiguous,
7591
7892
  source: "capture",
7592
- maxBytes
7893
+ maxBytes,
7894
+ note: ""
7593
7895
  });
7594
7896
  }
7595
7897
  function detailOfCall(call, maxBytes) {
7596
7898
  return detailFactsOf({
7899
+ found: true,
7597
7900
  where: DETAIL_WHERE_HOOK,
7598
7901
  key: call.key,
7599
7902
  method: call.method,
@@ -7610,31 +7913,45 @@ function detailOfCall(call, maxBytes) {
7610
7913
  responseSizeExact: false,
7611
7914
  ambiguous: 0,
7612
7915
  source: "hook",
7613
- maxBytes
7916
+ maxBytes,
7917
+ note: ""
7614
7918
  });
7615
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
+ }
7616
7924
  function tabsOf(raw) {
7617
7925
  const start = raw.indexOf("[");
7618
7926
  const end = raw.lastIndexOf("]");
7619
- if (start === -1 || end <= start) return [];
7927
+ if (start === -1 || end <= start) return null;
7620
7928
  try {
7621
7929
  const parsed = JSON.parse(raw.slice(start, end + 1));
7622
- if (!Array.isArray(parsed)) return [];
7930
+ if (!Array.isArray(parsed)) return null;
7623
7931
  return parsed.filter((one) => typeof one === "object" && one !== null);
7624
7932
  } catch {
7625
- return [];
7933
+ return null;
7626
7934
  }
7627
7935
  }
7628
7936
  async function closeRecordTab(shell, command, session, url) {
7629
7937
  const pages = tabsOf((await actBrowser(shell, command, session, ["tab", "list"])).stdout);
7630
- 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];
7631
7940
  const page = pick === void 0 ? "" : typeof pick["page"] === "string" ? pick["page"] : "";
7632
7941
  if (page !== "") {
7633
7942
  if ((await actBrowser(shell, command, session, [
7634
7943
  "tab",
7635
7944
  "close",
7636
7945
  page
7637
- ])).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
+ }
7638
7955
  }
7639
7956
  return (await actBrowser(shell, command, session, ["close"])).ok;
7640
7957
  }
@@ -7656,6 +7973,81 @@ async function releaseQuietly(input) {
7656
7973
  }
7657
7974
  }
7658
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
7659
8051
  //#region src/modules/panels/model/diy-record-tools.ts
7660
8052
  function registerRecordTools(ctx, store) {
7661
8053
  const tools = ctx.get("tools");
@@ -7665,7 +8057,7 @@ function registerRecordTools(ctx, store) {
7665
8057
  }
7666
8058
  const prepareTool = {
7667
8059
  name: DIY_RECORD_PREPARE_TOOL,
7668
- description: `开一扇浏览器窗口并顶到最前,让他自己录一次真实的写(开窗要点头一次);他按开始/结束后用 ${DIY_RECORD_READ_TOOL} 取那一段、${DIY_RECORD_STOP_TOOL} 收工。`,
8060
+ description: `开一扇浏览器窗口并顶到最前,让他自己录一次真实的写(开窗要点头一次);他按开始/结束后用 ${DIY_RECORD_READ_TOOL} 取那一段、${DIY_RECORD_STOP_TOOL} 收工。他按浮条上的「截图」不用另外点头(同一场同一屏,屏幕在开窗那一拍已经借过)—— 那张图与提交那一刻的快照一起进 ${DIY_RECORD_DETAIL_TOOL} 的字段证据表。`,
7669
8061
  parameters: {
7670
8062
  type: "object",
7671
8063
  properties: {
@@ -7744,23 +8136,14 @@ function registerRecordTools(ctx, store) {
7744
8136
  now: Date.now()
7745
8137
  }), held);
7746
8138
  try {
7747
- const opened = await runBrowserStep(shell, command, [
7748
- "browser",
7749
- BROWSER_RECORD_SESSION,
7750
- "tab",
7751
- "new",
8139
+ const opened = await openRecordPage({
8140
+ shell,
8141
+ command,
8142
+ session: BROWSER_RECORD_SESSION,
7752
8143
  url,
7753
- "--window",
7754
- "foreground"
7755
- ], OPEN_TIMEOUT_MS);
7756
- if (!opened.ok) throw new Error(`开不了录制窗:${opened.stderr || opened.stdout || "opencli 报了个错"}。先让用户把工作台那一页切到前台(第一次要这样),或跑 opencli doctor 看扩展连没连上。`);
7757
- await runBrowserStep(shell, command, [
7758
- "browser",
7759
- BROWSER_RECORD_SESSION,
7760
- "wait",
7761
- "time",
7762
- String(SETTLE_MS / 1e3)
7763
- ], OPEN_TIMEOUT_MS);
8144
+ captureFile
8145
+ });
8146
+ if (!opened.ok) throw new Error(opened.why);
7764
8147
  const armed = await actBrowser(shell, command, BROWSER_RECORD_SESSION, ["eval", RECORD_ARM_SCRIPT]);
7765
8148
  const page = recordPageOf(armed.payload);
7766
8149
  if (!armed.ok || !page.ok) throw new Error(`浮条没装上:${armed.stderr || armed.stdout || "那一页还在加载"} —— 等他那一页站稳了再试。`);
@@ -7838,32 +8221,39 @@ function registerRecordTools(ctx, store) {
7838
8221
  if (store.live(Date.now())?.phase === "recording") store.finished(sessionId, page.t1, Date.now());
7839
8222
  }
7840
8223
  };
7841
- await actBrowser(shell, command, BROWSER_RECORD_SESSION, ["eval", RECORD_ARM_SCRIPT]);
7842
- let step = await actBrowser(shell, command, BROWSER_RECORD_SESSION, ["eval", RECORD_READ_SCRIPT]);
7843
- let page = recordPageOf(step.payload);
7844
- sync(page);
7845
- while (budget > 0 && page.phase !== "finished" && Date.now() < deadline) {
7846
- await pauseFor(Math.min(POLL_MS, Math.max(0, deadline - Date.now())));
7847
- step = await actBrowser(shell, command, BROWSER_RECORD_SESSION, ["eval", RECORD_READ_SCRIPT]);
7848
- const next = recordPageOf(step.payload);
7849
- 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;
7850
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
+ }
7851
8240
  }
7852
- const timedOut = budget > 0 && page.phase !== "finished";
8241
+ const timedOut = !over && budget > 0 && page.phase !== "finished";
7853
8242
  const waitedMs = opened === 0 ? 0 : Date.now() - opened;
7854
8243
  if (timedOut) {
7855
8244
  const waited = String(Math.round(budget / 1e3));
7856
8245
  holdNotice(sessionId, noticeMessage(`录制还没结束(等了约 ${waited} 秒):他按了「结束」再 read 一次。`, `工作台 DIY:录制还没结束(等了约 ${waited} 秒)`));
7857
8246
  }
7858
8247
  const state = store.live(Date.now()) ?? held;
8248
+ const ledger = state.shots;
7859
8249
  const capture = await captureReadOf(shell, state.captureFile);
7860
8250
  const bundle = bundleOf(capture, page, state);
7861
8251
  const hookCaught = page.calls.length;
7862
8252
  const nav = page.href !== "" && page.here !== "" && page.href !== page.here;
7863
8253
  const noBody = bodyMissingOf(bundle.merged);
7864
- const text = step.ok ? [renderRecordRead({
7865
- phase: page.phase,
7866
- 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,
7867
8257
  hookCaught,
7868
8258
  rows: bundle.rows,
7869
8259
  merged: bundle.merged.length,
@@ -7876,11 +8266,17 @@ function registerRecordTools(ctx, store) {
7876
8266
  dropped: page.dropped,
7877
8267
  nav,
7878
8268
  filter: filterTextOf(state.expect),
7879
- captureRound: bundle.capture.round
7880
- }), 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");
7881
8277
  return plainJson({
7882
8278
  workshop: packagesDir,
7883
- ok: step.ok && page.ok,
8279
+ ok: stepOk && page.ok,
7884
8280
  phase: state.phase,
7885
8281
  t0: state.t0,
7886
8282
  t1: state.t1,
@@ -7894,6 +8290,9 @@ function registerRecordTools(ctx, store) {
7894
8290
  hookCaught,
7895
8291
  noBody,
7896
8292
  nav,
8293
+ shots: ledger,
8294
+ beats: state.beats,
8295
+ beatMs: state.beatMs,
7897
8296
  waitedMs,
7898
8297
  timedOut,
7899
8298
  text
@@ -7971,7 +8370,9 @@ function registerRecordTools(ctx, store) {
7971
8370
  const candidates = pickAllOf(captureEntriesOf(await captureLinesWithKeyOf(shell, file, key)), key);
7972
8371
  const fromCapture = pickOf(candidates, key, at);
7973
8372
  if (fromCapture !== null) return detailOfEntry(fromCapture, maxBytes, candidates.length);
7974
- 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);
7975
8376
  if (call !== null) return detailOfCall(call, maxBytes);
7976
8377
  return plainJson({
7977
8378
  workshop: packagesDir,
@@ -7996,7 +8397,7 @@ function registerRecordTools(ctx, store) {
7996
8397
  ambiguous: 0,
7997
8398
  source: "",
7998
8399
  maxBytes,
7999
- note: `「${key}」既不在这一场的快照原文(${file})里,也不在这一页的钩子那一份里`
8400
+ note: over ? `「${key}」不在这一场的快照原文(${file})里 —— 这一场已经结束 / 收工,那一页也收掉了,钩子那一份问不到` : `「${key}」既不在这一场的快照原文(${file})里,也不在这一页的钩子那一份里`
8000
8401
  })
8001
8402
  });
8002
8403
  }
@@ -11391,6 +11792,37 @@ var DSH_PLUGINS_ROUTE = "dsh-plugins";
11391
11792
  //#region src/shared/api/unit-manifests.ts
11392
11793
  var UNIT_MANIFESTS_ROUTE = "unit-manifests";
11393
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
11394
11826
  //#region src/shared/api/host/context-inject.ts
11395
11827
  var CONTEXT_SOURCE_KIND = "workbench";
11396
11828
  function collectContextRefs(messages) {
@@ -11529,8 +11961,6 @@ function createPreflight(options) {
11529
11961
  at: Date.now()
11530
11962
  };
11531
11963
  let running = null;
11532
- let latest = null;
11533
- let rerun = false;
11534
11964
  function set(next) {
11535
11965
  current = {
11536
11966
  ...current,
@@ -11725,21 +12155,16 @@ function createPreflight(options) {
11725
12155
  ...settled.ok ? {} : { bridgeIssues: settled.issues }
11726
12156
  });
11727
12157
  }
12158
+ function freshReady() {
12159
+ return current.phase === "ready" && Date.now() - current.at < 3e4;
12160
+ }
11728
12161
  async function start(runOptions = {}) {
11729
- if (running !== null) {
11730
- if (runOptions.force === true) rerun = true;
11731
- return running;
11732
- }
11733
- if (runOptions.force !== true && latest !== null) return latest;
12162
+ if (running !== null) return running;
12163
+ if (runOptions.hard !== true && freshReady()) return current;
11734
12164
  const inflight = doRun();
11735
- latest = inflight;
11736
12165
  running = inflight;
11737
12166
  await inflight.catch(() => void 0);
11738
12167
  running = null;
11739
- if (rerun) {
11740
- rerun = false;
11741
- return start({ force: true });
11742
- }
11743
12168
  return inflight;
11744
12169
  }
11745
12170
  return {
@@ -12007,10 +12432,31 @@ async function probeReleases(units, options) {
12007
12432
  }
12008
12433
  return found;
12009
12434
  }
12435
+ var pendingProbes = /* @__PURE__ */ new Map();
12010
12436
  async function probeUnits(options) {
12011
12437
  const { shell, command = "opencli", units, force = false } = options;
12012
12438
  const hits = units.map((unit) => unitCache.get(`${command}:${unit.unit}`));
12013
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;
12014
12460
  const result = await runShell(shell, `${command} plugin list -f json`, { timeoutMs: OPENCLI_TIMEOUT_MS }).then((value) => ({
12015
12461
  kind: "ok",
12016
12462
  stdout: value.exitCode === 0 ? value.stdout : "",
@@ -13398,6 +13844,10 @@ function apply(ctx) {
13398
13844
  const factsOfMap = /* @__PURE__ */ new Map();
13399
13845
  const routesOfUnitMap = /* @__PURE__ */ new Map();
13400
13846
  const ownersMap = /* @__PURE__ */ new Map();
13847
+ routes[LONG_TASKS_ROUTE] = () => ({
13848
+ ok: true,
13849
+ runs: liveLongTaskRows(longTaskRuns)
13850
+ });
13401
13851
  const factsOfUnit = (unit) => [...routesOfUnitMap.get(unit) ?? [], "capability"];
13402
13852
  const callFacts = (unit, call) => factsOfMap.get(unit)?.(call) ?? factsOfUnit(unit);
13403
13853
  const deniedNow = (context) => [...new Set([...mounted.values()].flatMap((item) => item.plan.gate?.deniedTools(context) ?? []))];
@@ -13447,7 +13897,7 @@ function apply(ctx) {
13447
13897
  values,
13448
13898
  longTasks: {
13449
13899
  scope: process.env["DSH_HOME"] ?? "",
13450
- bump: (names) => facts.bump(names),
13900
+ bump: (names) => facts.bump([...names, LONG_TASKS_ROUTE]),
13451
13901
  notify: (change) => notifyLongTaskStop(ctx.get("agents"), host.unit, change)
13452
13902
  },
13453
13903
  longTaskRuns