dsh-plugin-exe 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -70,13 +70,28 @@ exe trust <调用方地址>
70
70
  - **本机作为设备** — 如果这台机器自己也跑着 `exe`(有 `config.toml`),显示它的地址,
71
71
  以及**谁能在这台机器上执行命令**:信任列表逐条可取消,也可以加新地址(要填两遍,防手滑)。
72
72
  设备每 2 秒热读配置,改完即生效,不用重启。
73
- - **可访问的设备** — 这台 harness 记得的设备(`mcp.toml` 的 `[devices]`),可加可删。
73
+ - **可访问的设备** — 这台 harness 记得的设备(`mcp.toml` 的 `[devices]`),可加可删、**标签可改**;
74
+ 每行一个「测速」,卡片右上角一个「测试全部」。
74
75
  设备那侧还要把上面的调用方地址加进它的信任列表,两边都成立才通。
75
76
 
77
+ **测速是真实的往返**,不是看中继的脸色:先读一次中继的在线态(离线设备毫秒级失败,
78
+ 不用干等),在线就真的投一封 `shell` 调用(`echo ping`),从交付信封量到结果解封回来,
79
+ 显示的是这一整圈——钥匙、队列、设备的 trust 列表、执行、两端的加密。为此 ping 用 20ms
80
+ 起的紧轮询(普通工具调用仍是 150ms 起),数字才不是调用方自己的轮询间隔。没回应的调用
81
+ 会被取消,不会在设备上留一条跑到过期的任务。
82
+
83
+ **数字的下限由设备侧决定**:设备每秒去中继取一次件,所以哪怕同一台机器上的设备也会显示
84
+ 约 1 秒起。它回答的是「这条路走通要多久」,不是 ICMP 的 RTT——这也是它比 `ping` 更有用的
85
+ 地方:不经过 trust 列表、没有解封成功、设备没真跑,都不会有数字。
86
+
76
87
  页面读写的就是 `exe mcp` 和 `exe` 用的那两个文件(`~/.config/exe/mcp.toml`、
77
88
  `~/.config/exe/config.toml`),所以 `exe status`、`exe trust` 与这个页面永远说同一件事。
78
89
  写入是规范化的 TOML 重写(和 CLI 的行为一致,行内注释会丢),旧文件留在 `<文件名>.bak`。
79
90
 
91
+ **标签**:设备的标签就是 `mcp.toml` 里的键,直接重命名。信任列表在 eXe 里只有地址、没有名字,
92
+ 所以那侧的标签存在配置目录旁边的 `dsh-labels.json` 里——`exe` 不读也不写它,因此不会被
93
+ `exe trust` 的再次写入覆盖掉;地址本身仍然只由 `config.toml` 决定。
94
+
80
95
  技术上它是这个包的第二半:`package.json` 里的 `dsh.client` 声明 + `./client` 导出,
81
96
  由 dsh 的模块系统作为浏览器插件加载;页面数据来自插件在 harness 自己的 web server 上
82
97
  注册的两条只读/单写路由(`/exe/api/state`、`/exe/api/edit`)。这两条路由**只接受同源请求**
package/lib/client.js CHANGED
@@ -146,7 +146,22 @@ var s = {
146
146
  padding: "9px 12px",
147
147
  whiteSpace: "pre-wrap"
148
148
  },
149
- badge: { fontSize: 11, opacity: 0.6, fontFamily: mono }
149
+ badge: { fontSize: 11, opacity: 0.6, fontFamily: mono },
150
+ cardHead: { display: "flex", alignItems: "center", gap: 12 },
151
+ cardActions: { marginLeft: "auto", display: "flex", gap: 8 },
152
+ inline: { display: "flex", gap: 6, alignItems: "center", flex: "0 1 auto" },
153
+ inputSmall: {
154
+ minWidth: 110,
155
+ padding: "4px 8px",
156
+ borderRadius: 6,
157
+ border,
158
+ background: "transparent",
159
+ color: "inherit",
160
+ font: "inherit"
161
+ },
162
+ pingOk: { fontFamily: mono, fontSize: 11.5, color: "#3ddc84" },
163
+ pingBad: { fontFamily: mono, fontSize: 11.5, color: "#ff6b6b", cursor: "help" },
164
+ pingPending: { fontFamily: mono, fontSize: 11.5, opacity: 0.55 }
150
165
  };
151
166
  function CopyButton({ value, label = "\u590D\u5236" }) {
152
167
  const [copied, setCopied] = (0, import_react.useState)(false);
@@ -234,6 +249,63 @@ function CallerCard(props) {
234
249
  ] })
235
250
  ] });
236
251
  }
252
+ function LabelEditor(props) {
253
+ const [editing, setEditing] = (0, import_react.useState)(false);
254
+ const [draft, setDraft] = (0, import_react.useState)(props.value);
255
+ if (!editing) {
256
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
257
+ "button",
258
+ {
259
+ type: "button",
260
+ style: s.button,
261
+ disabled: props.busy,
262
+ onClick: () => {
263
+ setDraft(props.value);
264
+ setEditing(true);
265
+ },
266
+ children: props.action
267
+ }
268
+ );
269
+ }
270
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
271
+ "form",
272
+ {
273
+ style: s.inline,
274
+ onSubmit: (event) => {
275
+ event.preventDefault();
276
+ props.onCommit(draft.trim());
277
+ setEditing(false);
278
+ },
279
+ children: [
280
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
281
+ "input",
282
+ {
283
+ autoFocus: true,
284
+ style: s.inputSmall,
285
+ value: draft,
286
+ placeholder: props.placeholder,
287
+ "aria-label": props.placeholder,
288
+ disabled: props.busy,
289
+ onChange: (event) => setDraft(event.target.value)
290
+ }
291
+ ),
292
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { style: s.primary, type: "submit", disabled: props.busy, children: "\u4FDD\u5B58" }),
293
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
294
+ "button",
295
+ {
296
+ style: s.button,
297
+ type: "button",
298
+ disabled: props.busy,
299
+ onClick: () => {
300
+ setEditing(false);
301
+ },
302
+ children: "\u53D6\u6D88"
303
+ }
304
+ )
305
+ ]
306
+ }
307
+ );
308
+ }
237
309
  function DeviceCard(props) {
238
310
  const { device } = props.state;
239
311
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { style: s.card, children: [
@@ -260,19 +332,30 @@ function DeviceCard(props) {
260
332
  "\uFF09\uFF1A\u53EA\u6709\u8FD9\u91CC\u7684\u5730\u5740\u80FD\u5728\u8FD9\u53F0\u673A\u5668\u4E0A\u6267\u884C\u547D\u4EE4\u3002\u8BBE\u5907\u6BCF 2 \u79D2\u70ED\u8BFB\u914D\u7F6E\uFF0C\u6539\u5B8C\u5373\u751F\u6548\u3002"
261
333
  ] }),
262
334
  device.trust.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: s.badge, children: "\u8FD8\u6CA1\u6709\u4FE1\u4EFB\u4EFB\u4F55\u8C03\u7528\u65B9" }),
263
- device.trust.map((address) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: s.row, children: [
264
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: s.rowAddress, children: address }),
335
+ device.trust.map((row) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: s.row, children: [
336
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: s.label, children: row.label ?? "\u672A\u547D\u540D" }),
337
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: s.rowAddress, children: row.address }),
338
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
339
+ LabelEditor,
340
+ {
341
+ value: row.label ?? "",
342
+ placeholder: "\u7ED9\u8FD9\u4E2A\u8C03\u7528\u65B9\u8D77\u4E2A\u540D\u5B57",
343
+ action: "\u6539\u6807\u7B7E",
344
+ busy: props.busy,
345
+ onCommit: (label) => props.onEdit({ op: "trust.label", address: row.address, label })
346
+ }
347
+ ),
265
348
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
266
349
  "button",
267
350
  {
268
351
  type: "button",
269
352
  style: s.danger,
270
353
  disabled: props.busy,
271
- onClick: () => props.onEdit({ op: "trust.remove", address }),
354
+ onClick: () => props.onEdit({ op: "trust.remove", address: row.address }),
272
355
  children: "\u53D6\u6D88\u4FE1\u4EFB"
273
356
  }
274
357
  )
275
- ] }, address)),
358
+ ] }, row.address)),
276
359
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
277
360
  AddForm,
278
361
  {
@@ -292,23 +375,62 @@ function DeviceCard(props) {
292
375
  function DevicesCard(props) {
293
376
  const { devices } = props.state;
294
377
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { style: s.card, children: [
295
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h2", { style: s.cardTitle, children: "\u53EF\u8BBF\u95EE\u7684\u8BBE\u5907" }),
296
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { style: s.hint, children: "\u8FD9\u53F0 harness \u8BB0\u5F97\u7684\u8BBE\u5907\uFF08\u5199\u6210\u6807\u7B7E\uFF0C\u6A21\u578B\u8C03\u7528\u65F6\u7528\u5B83\uFF09\u3002\u8BBE\u5907\u90A3\u4FA7\u8FD8\u8981\u628A\u8FD9\u4E2A\u9875\u9762\u4E0A\u7684\u8C03\u7528\u65B9\u5730\u5740\u52A0\u8FDB\u5B83\u7684\u4FE1\u4EFB\u5217\u8868\u3002" }),
297
- devices.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: s.badge, children: "\u8FD8\u6CA1\u6709\u914D\u7F6E\u8BBE\u5907" }),
298
- devices.map((row) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: s.row, children: [
299
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: s.label, children: row.label }),
300
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: s.rowAddress, children: row.address }),
301
- row.source === "config" ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: s.badge, children: "\u6765\u81EA\u63D2\u4EF6\u914D\u7F6E" }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
378
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: s.cardHead, children: [
379
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h2", { style: s.cardTitle, children: "\u53EF\u8BBF\u95EE\u7684\u8BBE\u5907" }),
380
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: s.cardActions, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
302
381
  "button",
303
382
  {
304
383
  type: "button",
305
- style: s.danger,
306
- disabled: props.busy,
307
- onClick: () => props.onEdit({ op: "device.remove", label: row.label }),
308
- children: "\u79FB\u9664"
384
+ style: s.button,
385
+ disabled: props.busy || props.pinging || devices.length === 0,
386
+ onClick: () => props.onPing(),
387
+ children: props.pinging ? "\u6D4B\u901F\u4E2D\u2026" : "\u6D4B\u8BD5\u5168\u90E8"
309
388
  }
310
- )
311
- ] }, row.label)),
389
+ ) })
390
+ ] }),
391
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { style: s.hint, children: "\u8FD9\u53F0 harness \u8BB0\u5F97\u7684\u8BBE\u5907\uFF08\u5199\u6210\u6807\u7B7E\uFF0C\u6A21\u578B\u8C03\u7528\u65F6\u7528\u5B83\uFF09\u3002\u8BBE\u5907\u90A3\u4FA7\u8FD8\u8981\u628A\u8FD9\u4E2A\u9875\u9762\u4E0A\u7684\u8C03\u7528\u65B9\u5730\u5740\u52A0\u8FDB\u5B83\u7684\u4FE1\u4EFB\u5217\u8868\u3002" }),
392
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: s.badge, children: pingSummary(devices, props.pings) }),
393
+ devices.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: s.badge, children: "\u8FD8\u6CA1\u6709\u914D\u7F6E\u8BBE\u5907" }),
394
+ devices.map((row) => {
395
+ const ping = props.pings[row.address];
396
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: s.row, children: [
397
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
398
+ LabelEditor,
399
+ {
400
+ value: row.label,
401
+ placeholder: "\u8BBE\u5907\u6807\u7B7E",
402
+ action: row.label,
403
+ busy: props.busy || row.source === "config",
404
+ onCommit: (to) => {
405
+ if (to.length > 0) props.onEdit({ op: "device.rename", from: row.label, to });
406
+ }
407
+ }
408
+ ),
409
+ row.source === "config" && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: s.badge, children: "\u6765\u81EA\u63D2\u4EF6\u914D\u7F6E" }),
410
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(PingBadge, { ping }),
411
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: s.rowAddress, children: row.address }),
412
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
413
+ "button",
414
+ {
415
+ type: "button",
416
+ style: s.button,
417
+ disabled: props.busy || props.pinging,
418
+ onClick: () => props.onPing([row.label]),
419
+ children: "\u6D4B\u901F"
420
+ }
421
+ ),
422
+ row.source === "config" ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
423
+ "button",
424
+ {
425
+ type: "button",
426
+ style: s.danger,
427
+ disabled: props.busy,
428
+ onClick: () => props.onEdit({ op: "device.remove", label: row.label }),
429
+ children: "\u79FB\u9664"
430
+ }
431
+ )
432
+ ] }, row.label);
433
+ }),
312
434
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
313
435
  AddForm,
314
436
  {
@@ -321,6 +443,26 @@ function DevicesCard(props) {
321
443
  )
322
444
  ] });
323
445
  }
446
+ function PingBadge(props) {
447
+ if (props.ping === void 0) return null;
448
+ if (props.ping === "pending") return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: s.pingPending, children: "\u2026" });
449
+ if (props.ping.ok) return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { style: s.pingOk, children: [
450
+ props.ping.rttMs,
451
+ " ms"
452
+ ] });
453
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: s.pingBad, title: props.ping.error ?? "\u4E0D\u901A", children: "\u4E0D\u901A" });
454
+ }
455
+ function pingSummary(devices, pings) {
456
+ const results = devices.map((row) => pings[row.address]).filter((ping) => typeof ping === "object" && ping !== null);
457
+ if (results.length === 0) return "\u70B9\u300C\u6D4B\u901F\u300D\u7528\u4E00\u6B21\u771F\u5B9E\u8C03\u7528\u91CF\u5F80\u8FD4\u5EF6\u8FDF\uFF08\u542B\u8BBE\u5907\u4FA7\u7EA6 1 \u79D2\u7684\u53D6\u4EF6\uFF09";
458
+ const answered = results.filter((result) => result.ok && result.rttMs !== void 0);
459
+ const fastest = answered.reduce(
460
+ (best, result) => best === void 0 || (result.rttMs ?? Infinity) < best ? result.rttMs : best,
461
+ void 0
462
+ );
463
+ const failed = results.length - answered.length;
464
+ return `\u6D4B\u8FC7 ${results.length} \u53F0\uFF1A${answered.length} \u901A${fastest === void 0 ? "" : `\uFF08\u6700\u5FEB ${fastest} ms\uFF09`}${failed > 0 ? ` \xB7 ${failed} \u4E0D\u901A` : ""}`;
465
+ }
324
466
  function ExeView(props) {
325
467
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: s.page, children: [
326
468
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("header", { style: s.header, children: [
@@ -332,7 +474,17 @@ function ExeView(props) {
332
474
  props.state === null ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { style: s.hint, children: "\u8F7D\u5165\u4E2D\u2026" }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
333
475
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(CallerCard, { state: props.state, busy: props.busy }),
334
476
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(DeviceCard, { state: props.state, busy: props.busy, onEdit: props.onEdit }),
335
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(DevicesCard, { state: props.state, busy: props.busy, onEdit: props.onEdit })
477
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
478
+ DevicesCard,
479
+ {
480
+ state: props.state,
481
+ busy: props.busy,
482
+ pings: props.pings,
483
+ pinging: props.pinging,
484
+ onEdit: props.onEdit,
485
+ onPing: props.onPing
486
+ }
487
+ )
336
488
  ] })
337
489
  ] });
338
490
  }
@@ -341,6 +493,7 @@ function ExeView(props) {
341
493
  var import_jsx_runtime3 = require("react/jsx-runtime");
342
494
  var STATE_URL = "/exe/api/state";
343
495
  var EDIT_URL = "/exe/api/edit";
496
+ var PING_URL = "/exe/api/ping";
344
497
  function reason(error) {
345
498
  return error instanceof Error ? error.message : String(error);
346
499
  }
@@ -356,6 +509,8 @@ function ExePanel() {
356
509
  const [state, setState] = (0, import_react2.useState)(null);
357
510
  const [error, setError] = (0, import_react2.useState)(null);
358
511
  const [busy, setBusy] = (0, import_react2.useState)(false);
512
+ const [pings, setPings] = (0, import_react2.useState)({});
513
+ const [pinging, setPinging] = (0, import_react2.useState)(false);
359
514
  const load = (0, import_react2.useCallback)(async () => {
360
515
  try {
361
516
  const response = await fetch(STATE_URL, { cache: "no-store", headers: { accept: "application/json" } });
@@ -388,6 +543,42 @@ function ExePanel() {
388
543
  }
389
544
  })();
390
545
  }, []);
546
+ const ping = (0, import_react2.useCallback)((labels) => {
547
+ void (async () => {
548
+ const devices = state?.devices ?? [];
549
+ const targets = (labels ?? devices.map((row) => row.label)).map((label) => devices.find((row) => row.label === label)).filter((row) => row !== void 0);
550
+ setPinging(true);
551
+ setPings((current) => {
552
+ const next = { ...current };
553
+ for (const row of targets) next[row.address] = "pending";
554
+ return next;
555
+ });
556
+ try {
557
+ const response = await fetch(PING_URL, {
558
+ method: "POST",
559
+ headers: { "content-type": "application/json", accept: "application/json" },
560
+ body: JSON.stringify(labels === void 0 ? {} : { labels })
561
+ });
562
+ if (!response.ok) throw new Error(await failure(response));
563
+ const body = await response.json();
564
+ setPings((current) => {
565
+ const next = { ...current };
566
+ for (const result of body.results) next[result.address] = result;
567
+ return next;
568
+ });
569
+ setError(null);
570
+ } catch (pingError) {
571
+ setError(reason(pingError));
572
+ setPings((current) => {
573
+ const next = { ...current };
574
+ for (const row of targets) delete next[row.address];
575
+ return next;
576
+ });
577
+ } finally {
578
+ setPinging(false);
579
+ }
580
+ })();
581
+ }, [state]);
391
582
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
392
583
  ExeView,
393
584
  {
@@ -395,6 +586,9 @@ function ExePanel() {
395
586
  error,
396
587
  busy,
397
588
  onEdit: edit,
589
+ pings,
590
+ pinging,
591
+ onPing: ping,
398
592
  onReload: () => {
399
593
  void load();
400
594
  }
package/lib/index.js CHANGED
@@ -4735,16 +4735,17 @@ var Relay = class {
4735
4735
  );
4736
4736
  }
4737
4737
  try {
4738
- return await this.wait(envelope, ttl, request.signal);
4738
+ return await this.wait(envelope, ttl, request.signal, request.poll);
4739
4739
  } catch (error) {
4740
4740
  if (request.signal.aborted) await this.cancel(callId);
4741
4741
  throw error;
4742
4742
  }
4743
4743
  }
4744
- async wait(envelope, ttl, signal) {
4744
+ async wait(envelope, ttl, signal, poll) {
4745
4745
  const detailPath = `/api/calls/${envelope.call_id}`;
4746
4746
  const deadline = Date.now() + (ttl + 30) * 1e3;
4747
- let wait = POLL_START_MS;
4747
+ const ceiling = Math.max(poll?.ceilingMs ?? POLL_CEILING_MS, 10);
4748
+ let wait = Math.min(Math.max(poll?.startMs ?? POLL_START_MS, 5), ceiling);
4748
4749
  for (; ; ) {
4749
4750
  if (signal.aborted) throw signal.reason ?? new Error("cancelled");
4750
4751
  if (Date.now() > deadline) throw new Error("no answer from the device before the call expired");
@@ -4769,7 +4770,7 @@ var Relay = class {
4769
4770
  throw new Error(`unexpected call status \`${status}\``);
4770
4771
  }
4771
4772
  await sleep(wait, signal);
4772
- wait = Math.min(wait * 2, POLL_CEILING_MS);
4773
+ wait = Math.min(wait * 2, ceiling);
4773
4774
  }
4774
4775
  }
4775
4776
  async settle(envelope, detail, signal) {
@@ -4817,6 +4818,59 @@ var Relay = class {
4817
4818
  }
4818
4819
  };
4819
4820
 
4821
+ // src/ping.ts
4822
+ var PING_TIMEOUT_SECONDS = 6;
4823
+ var PING_POLL = { startMs: 20, ceilingMs: 120 };
4824
+ function reason2(error) {
4825
+ return error instanceof Error ? error.message : String(error);
4826
+ }
4827
+ function hint(error) {
4828
+ const text2 = reason2(error);
4829
+ if (/expired|no answer|no result|abort|timed? ?out/i.test(text2)) {
4830
+ return "\u8BBE\u5907\u6CA1\u6709\u56DE\u5E94\uFF1A\u5B83\u53EF\u80FD\u6CA1\u628A\u8FD9\u53F0 harness \u7684\u5730\u5740\u52A0\u8FDB trust\uFF0C\u6216\u8005\u547D\u4EE4\u88AB\u5B83\u7684\u8FDB\u7A0B\u4E22\u6389\u4E86";
4831
+ }
4832
+ return text2;
4833
+ }
4834
+ async function pingDevice(relay, label, address, options = {}) {
4835
+ let registered;
4836
+ let online;
4837
+ try {
4838
+ const status = await relay.device(address);
4839
+ registered = status.registered;
4840
+ online = status.online;
4841
+ } catch (error) {
4842
+ return { label, address, ok: false, error: `\u4E2D\u7EE7\u4E0D\u53EF\u8FBE\uFF1A${reason2(error)}` };
4843
+ }
4844
+ if (!online) {
4845
+ return {
4846
+ label,
4847
+ address,
4848
+ ok: false,
4849
+ registered,
4850
+ online: false,
4851
+ error: registered ? "\u8BBE\u5907\u79BB\u7EBF\uFF1A\u4E2D\u7EE7\u6700\u8FD1\u6CA1\u89C1\u5230\u5B83" : "\u8BBE\u5907\u8FD8\u6CA1\u5728\u4E2D\u7EE7\u767B\u8BB0\uFF08\u5728\u8BBE\u5907\u4E0A\u8DD1\u4E00\u6B21 `exe`\uFF09"
4852
+ };
4853
+ }
4854
+ const budget = options.timeoutMs ?? (PING_TIMEOUT_SECONDS + 2) * 1e3;
4855
+ const started = Date.now();
4856
+ try {
4857
+ await relay.call({
4858
+ device: address,
4859
+ name: "shell",
4860
+ args: { command: "echo ping", timeout_seconds: PING_TIMEOUT_SECONDS },
4861
+ ttlSeconds: PING_TIMEOUT_SECONDS,
4862
+ signal: AbortSignal.timeout(budget),
4863
+ poll: PING_POLL
4864
+ });
4865
+ return { label, address, ok: true, registered, online, rttMs: Date.now() - started };
4866
+ } catch (error) {
4867
+ return { label, address, ok: false, registered, online, error: hint(error) };
4868
+ }
4869
+ }
4870
+ async function pingDevices(relay, targets, options = {}) {
4871
+ return await Promise.all(targets.map((target) => pingDevice(relay, target.label, target.address, options)));
4872
+ }
4873
+
4820
4874
  // src/routes.ts
4821
4875
  var ROUTE_PREFIX = "/exe/api";
4822
4876
  var MAX_BODY_BYTES = 64 * 1024;
@@ -4857,7 +4911,7 @@ async function readJson(req) {
4857
4911
  throw new Error(`body is not JSON: ${error instanceof Error ? error.message : String(error)}`);
4858
4912
  }
4859
4913
  }
4860
- function registerExeRoutes(webServer, store) {
4914
+ function registerExeRoutes(webServer, deps) {
4861
4915
  const handler = async (req, res) => {
4862
4916
  try {
4863
4917
  if (!sameOrigin(req)) {
@@ -4870,7 +4924,7 @@ function registerExeRoutes(webServer, store) {
4870
4924
  send(res, 405, { error: "use GET" });
4871
4925
  return;
4872
4926
  }
4873
- send(res, 200, store.state());
4927
+ send(res, 200, deps.store.state());
4874
4928
  return;
4875
4929
  }
4876
4930
  if (url.pathname === `${ROUTE_PREFIX}/edit`) {
@@ -4887,7 +4941,25 @@ function registerExeRoutes(webServer, store) {
4887
4941
  send(res, 400, { error: "an edit names its `op`" });
4888
4942
  return;
4889
4943
  }
4890
- send(res, 200, store.edit(body));
4944
+ send(res, 200, deps.store.edit(body));
4945
+ return;
4946
+ }
4947
+ if (url.pathname === `${ROUTE_PREFIX}/ping`) {
4948
+ if (req.method !== "POST") {
4949
+ send(res, 405, { error: "use POST" });
4950
+ return;
4951
+ }
4952
+ if (!(req.headers["content-type"] ?? "").includes("application/json")) {
4953
+ send(res, 415, { error: "send application/json" });
4954
+ return;
4955
+ }
4956
+ const body = await readJson(req);
4957
+ const labels = body.labels;
4958
+ if (labels !== void 0 && (!Array.isArray(labels) || labels.some((label) => typeof label !== "string"))) {
4959
+ send(res, 400, { error: "`labels` is a list of device labels" });
4960
+ return;
4961
+ }
4962
+ send(res, 200, { results: await deps.ping(labels) });
4891
4963
  return;
4892
4964
  }
4893
4965
  send(res, 404, { error: `no route for ${req.method} ${url.pathname}` });
@@ -4900,7 +4972,8 @@ function registerExeRoutes(webServer, store) {
4900
4972
 
4901
4973
  // src/state.ts
4902
4974
  import { copyFileSync, existsSync as existsSync2, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
4903
- function reason2(error) {
4975
+ import { join as join2 } from "node:path";
4976
+ function reason3(error) {
4904
4977
  return error instanceof Error ? error.message : String(error);
4905
4978
  }
4906
4979
  function readToml(path) {
@@ -4908,7 +4981,7 @@ function readToml(path) {
4908
4981
  try {
4909
4982
  return parse(readFileSync2(path, "utf8"));
4910
4983
  } catch (error) {
4911
- throw new Error(`${path} is not valid TOML: ${reason2(error)}`);
4984
+ throw new Error(`${path} is not valid TOML: ${reason3(error)}`);
4912
4985
  }
4913
4986
  }
4914
4987
  function writeToml(path, value) {
@@ -4918,6 +4991,28 @@ function writeToml(path, value) {
4918
4991
  if (existsSync2(path)) copyFileSync(path, `${path}.bak`);
4919
4992
  renameSync(temporary, path);
4920
4993
  }
4994
+ var LABELS_FILE = "dsh-labels.json";
4995
+ function readTrustLabels(path) {
4996
+ if (!existsSync2(path)) return {};
4997
+ try {
4998
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
4999
+ const table = parsed.trust;
5000
+ if (typeof table !== "object" || table === null || Array.isArray(table)) return {};
5001
+ return Object.fromEntries(
5002
+ Object.entries(table).filter(
5003
+ (entry) => typeof entry[1] === "string" && entry[1].trim().length > 0
5004
+ )
5005
+ );
5006
+ } catch {
5007
+ return {};
5008
+ }
5009
+ }
5010
+ function writeTrustLabels(path, labels) {
5011
+ const temporary = `${path}.tmp`;
5012
+ writeFileSync(temporary, `${JSON.stringify({ trust: labels }, null, 2)}
5013
+ `, { mode: 384 });
5014
+ renameSync(temporary, path);
5015
+ }
4921
5016
  function labelOf(value) {
4922
5017
  const label = typeof value === "string" ? value.trim() : "";
4923
5018
  if (label.length === 0 || label.length > 64 || /[\u0000-\u001f]/.test(label)) {
@@ -4941,6 +5036,7 @@ function trustOf(file) {
4941
5036
  function createStateStore(options = {}, env = process.env) {
4942
5037
  const callerPath = options.configPath?.trim() || defaultConfigPath(env);
4943
5038
  const devicePath = deviceConfigPath(env);
5039
+ const labelsPath = join2(configDir(env), LABELS_FILE);
4944
5040
  const inline = options.devices ?? {};
4945
5041
  const callerView = () => {
4946
5042
  const view = { endpoints: [], configPath: callerPath };
@@ -4956,7 +5052,7 @@ function createStateStore(options = {}, env = process.env) {
4956
5052
  view.source = inlineSecret === void 0 ? callerPath : "plugin config";
4957
5053
  view.endpoints = splitEndpoints(options.endpoints).length > 0 ? splitEndpoints(options.endpoints) : splitEndpoints(file?.endpoints).length > 0 ? splitEndpoints(file?.endpoints) : splitEndpoints(file?.cloud);
4958
5054
  } catch (error) {
4959
- view.error = reason2(error);
5055
+ view.error = reason3(error);
4960
5056
  }
4961
5057
  return view;
4962
5058
  };
@@ -4988,9 +5084,13 @@ function createStateStore(options = {}, env = process.env) {
4988
5084
  if (secret.length === 0) throw new Error(`${devicePath} has no secret`);
4989
5085
  view.address = addressOf(decodeSecret(secret));
4990
5086
  view.endpoints = splitEndpoints(file?.endpoints).length > 0 ? splitEndpoints(file?.endpoints) : splitEndpoints(file?.cloud);
4991
- view.trust = trustOf(file);
5087
+ const labels = readTrustLabels(labelsPath);
5088
+ view.trust = trustOf(file).map((address) => {
5089
+ const label = labels[address];
5090
+ return label === void 0 ? { address } : { address, label };
5091
+ });
4992
5092
  } catch (error) {
4993
- view.error = reason2(error);
5093
+ view.error = reason3(error);
4994
5094
  }
4995
5095
  return view;
4996
5096
  };
@@ -5010,6 +5110,24 @@ function createStateStore(options = {}, env = process.env) {
5010
5110
  writeToml(callerPath, { ...file, devices });
5011
5111
  break;
5012
5112
  }
5113
+ case "device.rename": {
5114
+ const from = labelOf(op.from);
5115
+ const to = labelOf(op.to);
5116
+ if (from === to) break;
5117
+ if (Object.hasOwn(inline, from) || Object.hasOwn(inline, to)) {
5118
+ throw new Error(`\`${from}\` / \`${to}\` is set in this plugin's own config; change it there, not in ${callerPath}`);
5119
+ }
5120
+ const file = readToml(callerPath);
5121
+ if (file === void 0) throw new Error(`no caller config at ${callerPath}`);
5122
+ const table = file.devices;
5123
+ const existing = typeof table === "object" && table !== null && !Array.isArray(table) ? { ...table } : {};
5124
+ if (!Object.hasOwn(existing, from)) throw new Error(`no device labelled \`${from}\``);
5125
+ if (Object.hasOwn(existing, to)) throw new Error(`\`${to}\` is already a device label`);
5126
+ const renamed = {};
5127
+ for (const [label, address] of Object.entries(existing)) renamed[label === from ? to : label] = address;
5128
+ writeToml(callerPath, { ...file, devices: renamed });
5129
+ break;
5130
+ }
5013
5131
  case "device.remove": {
5014
5132
  const label = labelOf(op.label);
5015
5133
  if (Object.hasOwn(inline, label)) {
@@ -5032,6 +5150,29 @@ function createStateStore(options = {}, env = process.env) {
5032
5150
  const trust = trustOf(file);
5033
5151
  const next = op.op === "trust.add" ? [.../* @__PURE__ */ new Set([...trust, address])] : trust.filter((entry) => entry !== address);
5034
5152
  writeToml(devicePath, { ...file, trust: next });
5153
+ if (op.op === "trust.remove") {
5154
+ const labels = readTrustLabels(labelsPath);
5155
+ if (labels[address] !== void 0) {
5156
+ delete labels[address];
5157
+ writeTrustLabels(labelsPath, labels);
5158
+ }
5159
+ }
5160
+ break;
5161
+ }
5162
+ case "trust.label": {
5163
+ const address = addressOfText(op.address);
5164
+ const file = readToml(devicePath);
5165
+ if (file === void 0) {
5166
+ throw new Error(`this machine is not an eXe device: ${devicePath} does not exist (run \`exe\`)`);
5167
+ }
5168
+ if (!trustOf(file).includes(address)) {
5169
+ throw new Error(`${address} is not trusted by this machine; add it first`);
5170
+ }
5171
+ const labels = readTrustLabels(labelsPath);
5172
+ const label = typeof op.label === "string" ? op.label.trim() : "";
5173
+ if (label.length === 0) delete labels[address];
5174
+ else labels[address] = labelOf(label);
5175
+ writeTrustLabels(labelsPath, labels);
5035
5176
  break;
5036
5177
  }
5037
5178
  default: {
@@ -5144,7 +5285,7 @@ var Config = Schema.object({
5144
5285
  function shQuote(value) {
5145
5286
  return `'${value.replaceAll("'", `'\\''`)}'`;
5146
5287
  }
5147
- function reason3(error) {
5288
+ function reason4(error) {
5148
5289
  return error instanceof Error ? error.message : String(error);
5149
5290
  }
5150
5291
  function mediaTypeOf(bytes, path, allowed) {
@@ -5239,7 +5380,7 @@ function apply(ctx, config) {
5239
5380
  if (status.last_seen_at !== void 0) row.last_seen_at = status.last_seen_at;
5240
5381
  return row;
5241
5382
  } catch (error) {
5242
- return { device: label, address, error: reason3(error) };
5383
+ return { device: label, address, error: reason4(error) };
5243
5384
  }
5244
5385
  })
5245
5386
  );
@@ -5394,7 +5535,12 @@ function apply(ctx, config) {
5394
5535
  ctx.inject(["webServer"], (scoped) => {
5395
5536
  const webServer = scoped.webServer;
5396
5537
  const store = createStateStore(config);
5397
- scoped.effect(() => registerExeRoutes(webServer, store), "dsh-plugin-exe: panel routes");
5538
+ const ping = async (labels) => {
5539
+ const caller = callerOf();
5540
+ const targets = labels === void 0 ? [...caller.devices].map(([label, address]) => ({ label, address })) : labels.map((label) => ({ label, address: addressForDevice(caller, label) }));
5541
+ return await pingDevices(relayOf(), targets);
5542
+ };
5543
+ scoped.effect(() => registerExeRoutes(webServer, { store, ping }), "dsh-plugin-exe: panel routes");
5398
5544
  });
5399
5545
  }
5400
5546
  export {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-exe",
3
- "version": "0.2.0",
4
- "description": "eXe for DeepSeek Harness: shell and screenshots on your own devices, plus a sidebar page that carries the caller key other devices must trust",
3
+ "version": "0.2.1",
4
+ "description": "eXe for DeepSeek Harness: shell and screenshots on your own devices, plus a sidebar page for the caller key, editable labels and one-tap reachability tests",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",