dsh-plugin-exe 0.1.1 → 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
@@ -61,6 +61,43 @@ exe trust <调用方地址>
61
61
  没有沙箱也没有确认;每次调用都是一次中继往返,能一条命令做完就别拆成十条;
62
62
  **状态为 `lost` 的调用可能已经执行过,不要盲目重试**。
63
63
 
64
+ ## 侧边栏页面
65
+
66
+ 装好后 dsh 的侧边栏会多一行 **eXe**(图标是一台设备的提示符),点开是一页:
67
+
68
+ - **本机调用方地址** — 这台 harness 签名用的那把公钥地址,大字显示、可复制,下面直接给出
69
+ `exe trust <地址>`,抄到任意设备上执行即可。钥匙来源与中继入口也在这一行。
70
+ - **本机作为设备** — 如果这台机器自己也跑着 `exe`(有 `config.toml`),显示它的地址,
71
+ 以及**谁能在这台机器上执行命令**:信任列表逐条可取消,也可以加新地址(要填两遍,防手滑)。
72
+ 设备每 2 秒热读配置,改完即生效,不用重启。
73
+ - **可访问的设备** — 这台 harness 记得的设备(`mcp.toml` 的 `[devices]`),可加可删、**标签可改**;
74
+ 每行一个「测速」,卡片右上角一个「测试全部」。
75
+ 设备那侧还要把上面的调用方地址加进它的信任列表,两边都成立才通。
76
+
77
+ **测速是真实的往返**,不是看中继的脸色:先读一次中继的在线态(离线设备毫秒级失败,
78
+ 不用干等),在线就真的投一封 `shell` 调用(`echo ping`),从交付信封量到结果解封回来,
79
+ 显示的是这一整圈——钥匙、队列、设备的 trust 列表、执行、两端的加密。为此 ping 用 20ms
80
+ 起的紧轮询(普通工具调用仍是 150ms 起),数字才不是调用方自己的轮询间隔。没回应的调用
81
+ 会被取消,不会在设备上留一条跑到过期的任务。
82
+
83
+ **数字的下限由设备侧决定**:设备每秒去中继取一次件,所以哪怕同一台机器上的设备也会显示
84
+ 约 1 秒起。它回答的是「这条路走通要多久」,不是 ICMP 的 RTT——这也是它比 `ping` 更有用的
85
+ 地方:不经过 trust 列表、没有解封成功、设备没真跑,都不会有数字。
86
+
87
+ 页面读写的就是 `exe mcp` 和 `exe` 用的那两个文件(`~/.config/exe/mcp.toml`、
88
+ `~/.config/exe/config.toml`),所以 `exe status`、`exe trust` 与这个页面永远说同一件事。
89
+ 写入是规范化的 TOML 重写(和 CLI 的行为一致,行内注释会丢),旧文件留在 `<文件名>.bak`。
90
+
91
+ **标签**:设备的标签就是 `mcp.toml` 里的键,直接重命名。信任列表在 eXe 里只有地址、没有名字,
92
+ 所以那侧的标签存在配置目录旁边的 `dsh-labels.json` 里——`exe` 不读也不写它,因此不会被
93
+ `exe trust` 的再次写入覆盖掉;地址本身仍然只由 `config.toml` 决定。
94
+
95
+ 技术上它是这个包的第二半:`package.json` 里的 `dsh.client` 声明 + `./client` 导出,
96
+ 由 dsh 的模块系统作为浏览器插件加载;页面数据来自插件在 harness 自己的 web server 上
97
+ 注册的两条只读/单写路由(`/exe/api/state`、`/exe/api/edit`)。这两条路由**只接受同源请求**
98
+ 且**只接受 JSON**,跨源写一律 403——它们写的是你的配置,不是随便一个网页能碰的东西。
99
+ 没有 web server 的 profile(headless/tui)不会注册路由,三个工具照常工作。
100
+
64
101
  ## 配置
65
102
 
66
103
  装好后零配置即可用。需要改的时候,在自己的 profile 的 `cordis.patch.yml` 里覆盖,
package/lib/client.js ADDED
@@ -0,0 +1,609 @@
1
+ window.__ModuleLoader__.load({ id: "dsh-plugin-exe", factory: (require) => { var module = { exports: {} }; var exports = module.exports;
2
+ "use strict";
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+
21
+ // src/client/index.tsx
22
+ var index_exports = {};
23
+ __export(index_exports, {
24
+ PANEL_ID: () => PANEL_ID,
25
+ apply: () => apply,
26
+ inject: () => inject
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+
30
+ // src/client/icon.tsx
31
+ var import_jsx_runtime = require("react/jsx-runtime");
32
+ function ExeIcon(props) {
33
+ const size = props.size ?? 18;
34
+ const opacity = props.active === false ? 0.72 : 1;
35
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", "aria-hidden": "true", style: { opacity }, children: [
36
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("rect", { x: "3", y: "4.5", width: "18", height: "15", rx: "3", stroke: "currentColor", strokeWidth: "1.6" }),
37
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
38
+ "path",
39
+ {
40
+ d: "M7.5 10l2.6 2.1-2.6 2.1M12.4 14.4h4.1",
41
+ stroke: "currentColor",
42
+ strokeWidth: "1.6",
43
+ strokeLinecap: "round",
44
+ strokeLinejoin: "round"
45
+ }
46
+ )
47
+ ] });
48
+ }
49
+
50
+ // src/client/panel.tsx
51
+ var import_react2 = require("react");
52
+
53
+ // src/client/view.tsx
54
+ var import_react = require("react");
55
+ var import_jsx_runtime2 = require("react/jsx-runtime");
56
+ var mono = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
57
+ var border = "1px solid color-mix(in srgb, currentColor 16%, transparent)";
58
+ var s = {
59
+ page: {
60
+ height: "100%",
61
+ overflowY: "auto",
62
+ padding: "24px 28px 48px",
63
+ fontSize: 13,
64
+ lineHeight: 1.55
65
+ },
66
+ header: {
67
+ display: "flex",
68
+ alignItems: "baseline",
69
+ gap: 12,
70
+ marginBottom: 4
71
+ },
72
+ title: { fontSize: 20, fontWeight: 600, margin: 0 },
73
+ subtitle: { opacity: 0.6 },
74
+ toolbar: { marginLeft: "auto", display: "flex", gap: 8 },
75
+ card: {
76
+ border,
77
+ borderRadius: 10,
78
+ padding: "16px 18px",
79
+ marginTop: 18,
80
+ display: "grid",
81
+ gap: 10
82
+ },
83
+ cardTitle: { fontSize: 13, fontWeight: 600, margin: 0, letterSpacing: "0.01em" },
84
+ hint: { opacity: 0.65, margin: 0 },
85
+ address: { fontFamily: mono, fontSize: 15, wordBreak: "break-all", letterSpacing: "0.01em" },
86
+ code: {
87
+ fontFamily: mono,
88
+ fontSize: 12.5,
89
+ background: "color-mix(in srgb, currentColor 7%, transparent)",
90
+ borderRadius: 6,
91
+ padding: "6px 8px",
92
+ overflowX: "auto",
93
+ whiteSpace: "pre"
94
+ },
95
+ row: {
96
+ display: "flex",
97
+ alignItems: "center",
98
+ gap: 10,
99
+ padding: "6px 0",
100
+ borderTop: "1px solid color-mix(in srgb, currentColor 10%, transparent)"
101
+ },
102
+ label: { minWidth: 96, fontWeight: 500 },
103
+ rowAddress: { fontFamily: mono, fontSize: 12, opacity: 0.85, wordBreak: "break-all", flex: 1 },
104
+ form: { display: "flex", flexWrap: "wrap", gap: 8, marginTop: 2 },
105
+ input: {
106
+ flex: "1 1 160px",
107
+ minWidth: 120,
108
+ padding: "6px 9px",
109
+ borderRadius: 6,
110
+ border,
111
+ background: "transparent",
112
+ color: "inherit",
113
+ font: "inherit"
114
+ },
115
+ button: {
116
+ padding: "5px 11px",
117
+ borderRadius: 6,
118
+ border,
119
+ background: "transparent",
120
+ color: "inherit",
121
+ font: "inherit",
122
+ cursor: "pointer"
123
+ },
124
+ primary: {
125
+ padding: "5px 12px",
126
+ borderRadius: 6,
127
+ border,
128
+ background: "color-mix(in srgb, currentColor 10%, transparent)",
129
+ color: "inherit",
130
+ font: "inherit",
131
+ cursor: "pointer"
132
+ },
133
+ danger: {
134
+ padding: "3px 9px",
135
+ borderRadius: 6,
136
+ border: "1px solid color-mix(in srgb, #d33 45%, transparent)",
137
+ background: "transparent",
138
+ color: "inherit",
139
+ font: "inherit",
140
+ cursor: "pointer"
141
+ },
142
+ banner: {
143
+ marginTop: 14,
144
+ border: "1px solid color-mix(in srgb, #d33 45%, transparent)",
145
+ borderRadius: 8,
146
+ padding: "9px 12px",
147
+ whiteSpace: "pre-wrap"
148
+ },
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 }
165
+ };
166
+ function CopyButton({ value, label = "\u590D\u5236" }) {
167
+ const [copied, setCopied] = (0, import_react.useState)(false);
168
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
169
+ "button",
170
+ {
171
+ type: "button",
172
+ style: s.button,
173
+ onClick: () => {
174
+ void navigator.clipboard?.writeText(value).then(() => {
175
+ setCopied(true);
176
+ setTimeout(() => setCopied(false), 1500);
177
+ }).catch(() => void 0);
178
+ },
179
+ children: copied ? "\u5DF2\u590D\u5236" : label
180
+ }
181
+ );
182
+ }
183
+ function AddForm(props) {
184
+ const [first, setFirst] = (0, import_react.useState)("");
185
+ const [second, setSecond] = (0, import_react.useState)("");
186
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
187
+ "form",
188
+ {
189
+ style: s.form,
190
+ onSubmit: (event) => {
191
+ event.preventDefault();
192
+ if (first.trim().length === 0 || second.trim().length === 0) return;
193
+ props.onSubmit(first.trim(), second.trim());
194
+ setFirst("");
195
+ setSecond("");
196
+ },
197
+ children: [
198
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
199
+ "input",
200
+ {
201
+ style: s.input,
202
+ placeholder: props.first,
203
+ value: first,
204
+ disabled: props.busy,
205
+ onChange: (event) => setFirst(event.target.value),
206
+ "aria-label": props.first
207
+ }
208
+ ),
209
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
210
+ "input",
211
+ {
212
+ style: s.input,
213
+ placeholder: props.second,
214
+ value: second,
215
+ disabled: props.busy,
216
+ onChange: (event) => setSecond(event.target.value),
217
+ "aria-label": props.second
218
+ }
219
+ ),
220
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { style: s.primary, type: "submit", disabled: props.busy, children: props.action })
221
+ ]
222
+ }
223
+ );
224
+ }
225
+ function Address({ value }) {
226
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("code", { style: s.address, children: value });
227
+ }
228
+ function CallerCard(props) {
229
+ const { caller } = props.state;
230
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { style: s.card, children: [
231
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h2", { style: s.cardTitle, children: "\u672C\u673A\u8C03\u7528\u65B9\u5730\u5740" }),
232
+ caller.address === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
233
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { style: s.hint, children: "\u8FD8\u6CA1\u6709\u8C03\u7528\u65B9\u94A5\u5319\uFF0C\u5176\u5B83\u8BBE\u5907\u6682\u65F6\u65E0\u5904\u53EF\u4FE1\u4EFB\u3002" }),
234
+ caller.error !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: s.banner, children: caller.error }),
235
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("pre", { style: s.code, children: "exe mcp init" })
236
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
237
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { style: s.hint, children: "\u628A\u8FD9\u884C\u5730\u5740\u52A0\u5230\u6BCF\u53F0\u8BBE\u5907\u4E0A\uFF08\u5728\u8BBE\u5907\u4E0A\u6267\u884C\uFF09\uFF0C\u5B83\u4EEC\u4FBF\u63A5\u53D7\u8FD9\u53F0 harness \u7684\u6307\u4EE4\u3002" }),
238
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: s.row, children: [
239
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Address, { value: caller.address }),
240
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(CopyButton, { value: caller.address })
241
+ ] }),
242
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("pre", { style: s.code, children: `exe trust ${caller.address}` }),
243
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { style: s.badge, children: [
244
+ "\u94A5\u5319\u6765\u6E90\uFF1A",
245
+ caller.source ?? "\u2014",
246
+ " \xB7 \u4E2D\u7EE7\uFF1A",
247
+ caller.endpoints.join(" , ") || "\u2014"
248
+ ] })
249
+ ] })
250
+ ] });
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
+ }
309
+ function DeviceCard(props) {
310
+ const { device } = props.state;
311
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { style: s.card, children: [
312
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h2", { style: s.cardTitle, children: "\u672C\u673A\u4F5C\u4E3A\u8BBE\u5907" }),
313
+ !device.available ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
314
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("p", { style: s.hint, children: [
315
+ "\u8FD9\u53F0\u673A\u5668\u8FD8\u4E0D\u662F eXe \u8BBE\u5907\uFF08\u6CA1\u6709 ",
316
+ device.path,
317
+ "\uFF09\u3002"
318
+ ] }),
319
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("pre", { style: s.code, children: "exe" })
320
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
321
+ device.address !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
322
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { style: s.hint, children: "\u5B83\u7684\u5730\u5740\u2014\u2014\u5176\u5B83\u8C03\u7528\u65B9\u8981 reach \u5B83\uFF0C\u5C31\u586B\u8FD9\u4E32\u3002" }),
323
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: s.row, children: [
324
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Address, { value: device.address }),
325
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(CopyButton, { value: device.address })
326
+ ] })
327
+ ] }),
328
+ device.error !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: s.banner, children: device.error }),
329
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("p", { style: s.hint, children: [
330
+ "\u4FE1\u4EFB\u7684\u8C03\u7528\u65B9\uFF08",
331
+ device.trust.length,
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"
333
+ ] }),
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" }),
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
+ ),
348
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
349
+ "button",
350
+ {
351
+ type: "button",
352
+ style: s.danger,
353
+ disabled: props.busy,
354
+ onClick: () => props.onEdit({ op: "trust.remove", address: row.address }),
355
+ children: "\u53D6\u6D88\u4FE1\u4EFB"
356
+ }
357
+ )
358
+ ] }, row.address)),
359
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
360
+ AddForm,
361
+ {
362
+ busy: props.busy,
363
+ first: "\u8C03\u7528\u65B9\u5730\u5740\uFF08base58\uFF09",
364
+ second: "\u518D\u586B\u4E00\u6B21\u4EE5\u786E\u8BA4",
365
+ action: "\u4FE1\u4EFB",
366
+ onSubmit: (address, confirm) => {
367
+ if (address !== confirm) return;
368
+ props.onEdit({ op: "trust.add", address });
369
+ }
370
+ }
371
+ )
372
+ ] })
373
+ ] });
374
+ }
375
+ function DevicesCard(props) {
376
+ const { devices } = props.state;
377
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { style: s.card, children: [
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)(
381
+ "button",
382
+ {
383
+ type: "button",
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"
388
+ }
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
+ }),
434
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
435
+ AddForm,
436
+ {
437
+ busy: props.busy,
438
+ first: "\u6807\u7B7E\uFF08\u5982 macmini\uFF09",
439
+ second: "\u8BBE\u5907\u5730\u5740\uFF08base58\uFF09",
440
+ action: "\u6DFB\u52A0",
441
+ onSubmit: (label, address) => props.onEdit({ op: "device.set", label, address })
442
+ }
443
+ )
444
+ ] });
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
+ }
466
+ function ExeView(props) {
467
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: s.page, children: [
468
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("header", { style: s.header, children: [
469
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h1", { style: s.title, children: "eXe" }),
470
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: s.subtitle, children: "\u8FD9\u53F0 harness \u7684\u94A5\u5319\uFF0C\u4E0E\u5B83\u591F\u5F97\u7740\u7684\u8BBE\u5907" }),
471
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: s.toolbar, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { type: "button", style: s.button, onClick: props.onReload, disabled: props.busy, children: props.busy ? "\u5904\u7406\u4E2D\u2026" : "\u5237\u65B0" }) })
472
+ ] }),
473
+ props.error !== null && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { style: s.banner, children: props.error }),
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: [
475
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(CallerCard, { state: props.state, busy: props.busy }),
476
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(DeviceCard, { 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
+ )
488
+ ] })
489
+ ] });
490
+ }
491
+
492
+ // src/client/panel.tsx
493
+ var import_jsx_runtime3 = require("react/jsx-runtime");
494
+ var STATE_URL = "/exe/api/state";
495
+ var EDIT_URL = "/exe/api/edit";
496
+ var PING_URL = "/exe/api/ping";
497
+ function reason(error) {
498
+ return error instanceof Error ? error.message : String(error);
499
+ }
500
+ async function failure(response) {
501
+ try {
502
+ const body = await response.json();
503
+ if (typeof body.error === "string" && body.error.length > 0) return body.error;
504
+ } catch {
505
+ }
506
+ return `\u8BF7\u6C42\u5931\u8D25\uFF08HTTP ${response.status}\uFF09`;
507
+ }
508
+ function ExePanel() {
509
+ const [state, setState] = (0, import_react2.useState)(null);
510
+ const [error, setError] = (0, import_react2.useState)(null);
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);
514
+ const load = (0, import_react2.useCallback)(async () => {
515
+ try {
516
+ const response = await fetch(STATE_URL, { cache: "no-store", headers: { accept: "application/json" } });
517
+ if (!response.ok) throw new Error(await failure(response));
518
+ setState(await response.json());
519
+ setError(null);
520
+ } catch (loadError) {
521
+ setError(reason(loadError));
522
+ }
523
+ }, []);
524
+ (0, import_react2.useEffect)(() => {
525
+ void load();
526
+ }, [load]);
527
+ const edit = (0, import_react2.useCallback)((op) => {
528
+ void (async () => {
529
+ setBusy(true);
530
+ try {
531
+ const response = await fetch(EDIT_URL, {
532
+ method: "POST",
533
+ headers: { "content-type": "application/json", accept: "application/json" },
534
+ body: JSON.stringify(op)
535
+ });
536
+ if (!response.ok) throw new Error(await failure(response));
537
+ setState(await response.json());
538
+ setError(null);
539
+ } catch (editError) {
540
+ setError(reason(editError));
541
+ } finally {
542
+ setBusy(false);
543
+ }
544
+ })();
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]);
582
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
583
+ ExeView,
584
+ {
585
+ state,
586
+ error,
587
+ busy,
588
+ onEdit: edit,
589
+ pings,
590
+ pinging,
591
+ onPing: ping,
592
+ onReload: () => {
593
+ void load();
594
+ }
595
+ }
596
+ );
597
+ }
598
+
599
+ // src/client/index.tsx
600
+ var inject = ["slots"];
601
+ var PANEL_ID = "exe";
602
+ function apply(ctx) {
603
+ ctx.slots.inject("main", () => ctx.slots.register({ name: "main", key: PANEL_ID }, ExePanel));
604
+ ctx.slots.inject(
605
+ "sidebar.panellist",
606
+ () => ctx.slots.register({ name: "sidebar.panellist", id: PANEL_ID, order: 40, label: "eXe" }, ExeIcon)
607
+ );
608
+ }
609
+ return module.exports; } });
package/lib/index.js CHANGED
@@ -765,7 +765,190 @@ function parse(toml, options = {}) {
765
765
  }
766
766
 
767
767
  // ../../node_modules/.pnpm/smol-toml@1.9.1/node_modules/smol-toml/dist/stringify.js
768
+ var BARE_KEY = /^[a-z0-9-_]+$/i;
768
769
  var HAS_WELLFORMED = !!"".isWellFormed;
770
+ function extendedTypeOf(obj) {
771
+ let type = typeof obj;
772
+ if (type === "object") {
773
+ if (Array.isArray(obj))
774
+ return "array";
775
+ if (typeof obj.getUTCDate === "function" && obj instanceof Date)
776
+ return "date";
777
+ if (globalThis.Temporal) {
778
+ if (obj.until) {
779
+ if (obj instanceof Temporal.ZonedDateTime)
780
+ return "temporal/tz+uc";
781
+ if (obj instanceof Temporal.PlainDateTime || obj instanceof Temporal.PlainDate)
782
+ return "temporal/uc";
783
+ if (obj instanceof Temporal.PlainTime || obj instanceof Temporal.Instant)
784
+ return "temporal";
785
+ if (obj instanceof Temporal.PlainYearMonth)
786
+ return "temporal/x";
787
+ } else if (obj.toPlainDate && obj instanceof Temporal.PlainMonthDay || obj.negated && obj instanceof Temporal.Duration) {
788
+ return "temporal/x";
789
+ }
790
+ }
791
+ }
792
+ return type;
793
+ }
794
+ function isArrayOfTables(obj) {
795
+ for (let i = 0; i < obj.length; i++) {
796
+ if (extendedTypeOf(obj[i]) !== "object")
797
+ return false;
798
+ }
799
+ return obj.length != 0;
800
+ }
801
+ function formatWellFormedStringUnchecked(s) {
802
+ return JSON.stringify(s).replaceAll("\x7F", "\\u007f");
803
+ }
804
+ function formatString(s) {
805
+ return formatWellFormedStringUnchecked(HAS_WELLFORMED ? s.toWellFormed() : s);
806
+ }
807
+ function formatKey(s) {
808
+ if (BARE_KEY.test(s))
809
+ return s;
810
+ if (HAS_WELLFORMED && !s.isWellFormed())
811
+ throw new RangeError("key contains illegal lone surrogates");
812
+ return formatWellFormedStringUnchecked(s);
813
+ }
814
+ function stringifyValue(val, type, depth, numberAsFloat, strictTemporal) {
815
+ if (depth === 0) {
816
+ throw new Error("Could not stringify the object: maximum object depth exceeded");
817
+ }
818
+ switch (type) {
819
+ // @ts-expect-error -- intentional fallthrough case
820
+ case "number":
821
+ if (isNaN(val))
822
+ return "nan";
823
+ if (val === Infinity)
824
+ return "inf";
825
+ if (val === -Infinity)
826
+ return "-inf";
827
+ if (Number.isInteger(val) && (numberAsFloat || !Number.isSafeInteger(val)))
828
+ return val.toFixed(1);
829
+ case "bigint":
830
+ case "boolean":
831
+ case "temporal":
832
+ return val.toString();
833
+ case "string":
834
+ return formatString(val);
835
+ case "date":
836
+ if (isNaN(val.getTime()))
837
+ throw new TypeError("cannot serialize invalid date");
838
+ return val.toISOString();
839
+ case "object":
840
+ return stringifyInlineTable(val, depth, numberAsFloat, strictTemporal);
841
+ case "array":
842
+ return stringifyArray(val, depth, numberAsFloat, strictTemporal);
843
+ // @ts-expect-error -- intentional fallthrough case
844
+ case "temporal/tz+uc":
845
+ if (strictTemporal) {
846
+ let tz = val.timeZoneId;
847
+ let tzc = tz.charCodeAt(0);
848
+ if (
849
+ // Classic offset
850
+ tzc !== 43 && tzc !== 45 && // Fast pre-check pass; see below for the actually accepted values
851
+ (tzc !== 85 && tzc !== 71 && tzc !== 90 && tzc !== 69 || // UTC and its aliases; Temporal implementations don't all canonicalise unfortunately
852
+ tz !== "UTC" && tz !== "UCT" && tz !== "Universal" && tz !== "Zulu" && // GMT is a TZ but it's for all intents and purposes equivalent to UTC. Safe to downgrade.
853
+ !tz.startsWith("GMT") && tz !== "Greenwich" && // Etc/* are all safe to downgrade to offset (either UTC, GMT, or offset)
854
+ !tz.startsWith("Etc/"))
855
+ ) {
856
+ throw new TypeError("Temporal objects with an IANA timezone are not allowed in Temporal strict mode");
857
+ }
858
+ }
859
+ case "temporal/uc":
860
+ if (strictTemporal && val.calendarId !== "iso8601")
861
+ throw new TypeError("Temporal objects with a non-default calendar are not allowed in Temporal strict mode");
862
+ return val.toString({
863
+ calendarName: "never",
864
+ timeZoneName: "never"
865
+ });
866
+ case "temporal/x":
867
+ throw new TypeError("Unsupported " + val[Symbol.toStringTag]);
868
+ }
869
+ }
870
+ function stringifyInlineTable(obj, depth, numberAsFloat, strictTemporal) {
871
+ let keys = Object.keys(obj);
872
+ if (keys.length === 0)
873
+ return "{}";
874
+ let res = "{ ";
875
+ for (let i = 0; i < keys.length; i++) {
876
+ let k = keys[i];
877
+ if (i)
878
+ res += ", ";
879
+ res += formatKey(k) + " = " + stringifyValue(obj[k], extendedTypeOf(obj[k]), depth - 1, numberAsFloat, strictTemporal);
880
+ }
881
+ return res + " }";
882
+ }
883
+ function stringifyArray(array, depth, numberAsFloat, strictTemporal) {
884
+ if (array.length === 0)
885
+ return "[]";
886
+ let res = "[ ";
887
+ for (let i = 0; i < array.length; i++) {
888
+ if (i)
889
+ res += ", ";
890
+ if (array[i] === null || array[i] === void 0) {
891
+ throw new TypeError("arrays cannot contain null or undefined values");
892
+ }
893
+ res += stringifyValue(array[i], extendedTypeOf(array[i]), depth - 1, numberAsFloat, strictTemporal);
894
+ }
895
+ return res + " ]";
896
+ }
897
+ function stringifyArrayTable(array, key, depth, numberAsFloat, strictTemporal) {
898
+ if (depth === 0) {
899
+ throw new Error("Could not stringify the object: maximum object depth exceeded");
900
+ }
901
+ let res = "";
902
+ for (let i = 0; i < array.length; i++) {
903
+ res += `${res && "\n"}[[${key}]]
904
+ `;
905
+ res += stringifyTable(0, array[i], key, depth, numberAsFloat, strictTemporal);
906
+ }
907
+ return res;
908
+ }
909
+ function stringifyTable(tableKey, obj, prefix, depth, numberAsFloat, strictTemporal) {
910
+ if (depth === 0) {
911
+ throw new Error("Could not stringify the object: maximum object depth exceeded");
912
+ }
913
+ let preamble = "";
914
+ let tables = "";
915
+ let keys = Object.keys(obj);
916
+ for (let i = 0; i < keys.length; i++) {
917
+ let k = keys[i];
918
+ if (obj[k] !== null && obj[k] !== void 0) {
919
+ let type = extendedTypeOf(obj[k]);
920
+ if (type === "symbol" || type === "function") {
921
+ throw new TypeError(`cannot serialize values of type '${type}'`);
922
+ }
923
+ let key = formatKey(k);
924
+ if (type === "array" && isArrayOfTables(obj[k])) {
925
+ tables += (tables && "\n") + stringifyArrayTable(obj[k], prefix ? `${prefix}.${key}` : key, depth - 1, numberAsFloat, strictTemporal);
926
+ } else if (type === "object") {
927
+ let tblKey = prefix ? `${prefix}.${key}` : key;
928
+ tables += (tables && "\n") + stringifyTable(tblKey, obj[k], tblKey, depth - 1, numberAsFloat, strictTemporal);
929
+ } else {
930
+ preamble += key;
931
+ preamble += " = ";
932
+ preamble += stringifyValue(obj[k], type, depth, numberAsFloat, strictTemporal);
933
+ preamble += "\n";
934
+ }
935
+ }
936
+ }
937
+ if (tableKey && (preamble || !tables))
938
+ preamble = preamble ? `[${tableKey}]
939
+ ${preamble}` : `[${tableKey}]`;
940
+ return preamble && tables ? `${preamble}
941
+ ${tables}` : preamble || tables;
942
+ }
943
+ function stringify(obj, { maxDepth = 1e3, numbersAsFloat = false, strictTemporal = false } = {}) {
944
+ if (extendedTypeOf(obj) !== "object") {
945
+ throw new TypeError("stringify can only be called with an object");
946
+ }
947
+ let str = stringifyTable(0, obj, "", maxDepth, numbersAsFloat, strictTemporal);
948
+ if (str[str.length - 1] !== "\n")
949
+ return str + "\n";
950
+ return str;
951
+ }
769
952
 
770
953
  // ../../node_modules/.pnpm/@noble+hashes@2.4.0/node_modules/@noble/hashes/_u64.js
771
954
  var U32_MASK64 = /* @__PURE__ */ (() => BigInt(2 ** 32 - 1))();
@@ -4322,12 +4505,18 @@ function openResult(input) {
4322
4505
 
4323
4506
  // src/config.ts
4324
4507
  var DEFAULT_ENDPOINTS = ["https://exe.ntnl.io", "https://exe.ntnl.top"];
4325
- function defaultConfigPath(env = process.env) {
4508
+ function configDir(env = process.env) {
4326
4509
  const override = env.EXE_CONFIG_DIR?.trim();
4327
- if (override) return join(override, "mcp.toml");
4328
- if (process.platform === "win32" && env.APPDATA?.trim()) return join(env.APPDATA.trim(), "exe", "mcp.toml");
4510
+ if (override) return override;
4511
+ if (process.platform === "win32" && env.APPDATA?.trim()) return join(env.APPDATA.trim(), "exe");
4329
4512
  const home = env.HOME?.trim() || env.USERPROFILE?.trim() || homedir();
4330
- return join(home, ".config", "exe", "mcp.toml");
4513
+ return join(home, ".config", "exe");
4514
+ }
4515
+ function defaultConfigPath(env = process.env) {
4516
+ return join(configDir(env), "mcp.toml");
4517
+ }
4518
+ function deviceConfigPath(env = process.env) {
4519
+ return join(configDir(env), "config.toml");
4331
4520
  }
4332
4521
  function splitEndpoints(value) {
4333
4522
  const entries = Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
@@ -4546,16 +4735,17 @@ var Relay = class {
4546
4735
  );
4547
4736
  }
4548
4737
  try {
4549
- return await this.wait(envelope, ttl, request.signal);
4738
+ return await this.wait(envelope, ttl, request.signal, request.poll);
4550
4739
  } catch (error) {
4551
4740
  if (request.signal.aborted) await this.cancel(callId);
4552
4741
  throw error;
4553
4742
  }
4554
4743
  }
4555
- async wait(envelope, ttl, signal) {
4744
+ async wait(envelope, ttl, signal, poll) {
4556
4745
  const detailPath = `/api/calls/${envelope.call_id}`;
4557
4746
  const deadline = Date.now() + (ttl + 30) * 1e3;
4558
- 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);
4559
4749
  for (; ; ) {
4560
4750
  if (signal.aborted) throw signal.reason ?? new Error("cancelled");
4561
4751
  if (Date.now() > deadline) throw new Error("no answer from the device before the call expired");
@@ -4580,7 +4770,7 @@ var Relay = class {
4580
4770
  throw new Error(`unexpected call status \`${status}\``);
4581
4771
  }
4582
4772
  await sleep(wait, signal);
4583
- wait = Math.min(wait * 2, POLL_CEILING_MS);
4773
+ wait = Math.min(wait * 2, ceiling);
4584
4774
  }
4585
4775
  }
4586
4776
  async settle(envelope, detail, signal) {
@@ -4628,6 +4818,373 @@ var Relay = class {
4628
4818
  }
4629
4819
  };
4630
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
+
4874
+ // src/routes.ts
4875
+ var ROUTE_PREFIX = "/exe/api";
4876
+ var MAX_BODY_BYTES = 64 * 1024;
4877
+ function send(res, status, body) {
4878
+ const text2 = JSON.stringify(body);
4879
+ res.writeHead(status, {
4880
+ "content-type": "application/json; charset=utf-8",
4881
+ "content-length": Buffer.byteLength(text2),
4882
+ "cache-control": "no-store"
4883
+ });
4884
+ res.end(text2);
4885
+ }
4886
+ function sameOrigin(req) {
4887
+ const origin = req.headers.origin;
4888
+ if (origin === void 0) return true;
4889
+ const host = req.headers.host;
4890
+ if (host === void 0) return false;
4891
+ try {
4892
+ return new URL(origin).host === host;
4893
+ } catch {
4894
+ return false;
4895
+ }
4896
+ }
4897
+ async function readJson(req) {
4898
+ const chunks = [];
4899
+ let size = 0;
4900
+ for await (const chunk of req) {
4901
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
4902
+ size += buffer.length;
4903
+ if (size > MAX_BODY_BYTES) throw new Error("request body is too large");
4904
+ chunks.push(buffer);
4905
+ }
4906
+ const text2 = Buffer.concat(chunks).toString("utf8").trim();
4907
+ if (text2.length === 0) throw new Error("empty request body");
4908
+ try {
4909
+ return JSON.parse(text2);
4910
+ } catch (error) {
4911
+ throw new Error(`body is not JSON: ${error instanceof Error ? error.message : String(error)}`);
4912
+ }
4913
+ }
4914
+ function registerExeRoutes(webServer, deps) {
4915
+ const handler = async (req, res) => {
4916
+ try {
4917
+ if (!sameOrigin(req)) {
4918
+ send(res, 403, { error: "cross-origin request refused" });
4919
+ return;
4920
+ }
4921
+ const url = new URL(req.url ?? "/", "http://localhost");
4922
+ if (url.pathname === `${ROUTE_PREFIX}/state`) {
4923
+ if (req.method !== "GET") {
4924
+ send(res, 405, { error: "use GET" });
4925
+ return;
4926
+ }
4927
+ send(res, 200, deps.store.state());
4928
+ return;
4929
+ }
4930
+ if (url.pathname === `${ROUTE_PREFIX}/edit`) {
4931
+ if (req.method !== "POST") {
4932
+ send(res, 405, { error: "use POST" });
4933
+ return;
4934
+ }
4935
+ if (!(req.headers["content-type"] ?? "").includes("application/json")) {
4936
+ send(res, 415, { error: "send application/json" });
4937
+ return;
4938
+ }
4939
+ const body = await readJson(req);
4940
+ if (typeof body !== "object" || body === null || typeof body.op !== "string") {
4941
+ send(res, 400, { error: "an edit names its `op`" });
4942
+ return;
4943
+ }
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) });
4963
+ return;
4964
+ }
4965
+ send(res, 404, { error: `no route for ${req.method} ${url.pathname}` });
4966
+ } catch (error) {
4967
+ send(res, 400, { error: error instanceof Error ? error.message : String(error) });
4968
+ }
4969
+ };
4970
+ return webServer.register({ kind: "prefix", path: ROUTE_PREFIX, handler });
4971
+ }
4972
+
4973
+ // src/state.ts
4974
+ import { copyFileSync, existsSync as existsSync2, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
4975
+ import { join as join2 } from "node:path";
4976
+ function reason3(error) {
4977
+ return error instanceof Error ? error.message : String(error);
4978
+ }
4979
+ function readToml(path) {
4980
+ if (!existsSync2(path)) return void 0;
4981
+ try {
4982
+ return parse(readFileSync2(path, "utf8"));
4983
+ } catch (error) {
4984
+ throw new Error(`${path} is not valid TOML: ${reason3(error)}`);
4985
+ }
4986
+ }
4987
+ function writeToml(path, value) {
4988
+ const text2 = stringify(value);
4989
+ const temporary = `${path}.tmp`;
4990
+ writeFileSync(temporary, text2, { mode: 384 });
4991
+ if (existsSync2(path)) copyFileSync(path, `${path}.bak`);
4992
+ renameSync(temporary, path);
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
+ }
5016
+ function labelOf(value) {
5017
+ const label = typeof value === "string" ? value.trim() : "";
5018
+ if (label.length === 0 || label.length > 64 || /[\u0000-\u001f]/.test(label)) {
5019
+ throw new Error("a device label is 1 to 64 printable characters");
5020
+ }
5021
+ return label;
5022
+ }
5023
+ function addressOfText(value) {
5024
+ const address = typeof value === "string" ? value.trim() : "";
5025
+ if (!isAddress(address)) {
5026
+ throw new Error(`not an eXe address: ${JSON.stringify(address)}`);
5027
+ }
5028
+ return address;
5029
+ }
5030
+ function trustOf(file) {
5031
+ const trust = file?.trust;
5032
+ if (trust === void 0) return [];
5033
+ if (!Array.isArray(trust)) throw new Error("`trust` in the device config must be a list of addresses");
5034
+ return trust.filter((entry) => typeof entry === "string");
5035
+ }
5036
+ function createStateStore(options = {}, env = process.env) {
5037
+ const callerPath = options.configPath?.trim() || defaultConfigPath(env);
5038
+ const devicePath = deviceConfigPath(env);
5039
+ const labelsPath = join2(configDir(env), LABELS_FILE);
5040
+ const inline = options.devices ?? {};
5041
+ const callerView = () => {
5042
+ const view = { endpoints: [], configPath: callerPath };
5043
+ try {
5044
+ const file = readToml(callerPath);
5045
+ const inlineSecret = options.secret?.trim();
5046
+ const secret = inlineSecret ?? (typeof file?.secret === "string" ? file.secret.trim() : void 0);
5047
+ if (secret === void 0 || secret.length === 0) {
5048
+ view.error = `no caller key yet: run \`exe mcp init\`, or set \`secret\` in this plugin's config`;
5049
+ return view;
5050
+ }
5051
+ view.address = addressOf(decodeSecret(secret));
5052
+ view.source = inlineSecret === void 0 ? callerPath : "plugin config";
5053
+ view.endpoints = splitEndpoints(options.endpoints).length > 0 ? splitEndpoints(options.endpoints) : splitEndpoints(file?.endpoints).length > 0 ? splitEndpoints(file?.endpoints) : splitEndpoints(file?.cloud);
5054
+ } catch (error) {
5055
+ view.error = reason3(error);
5056
+ }
5057
+ return view;
5058
+ };
5059
+ const devicesView = () => {
5060
+ const rows = [];
5061
+ try {
5062
+ const table = readToml(callerPath)?.devices;
5063
+ if (table !== void 0 && (typeof table !== "object" || table === null || Array.isArray(table))) {
5064
+ throw new Error("`devices` in the caller config must be a table of label = address");
5065
+ }
5066
+ for (const [label, address] of Object.entries(table ?? {})) {
5067
+ if (typeof address === "string") rows.push({ label, address, source: "file" });
5068
+ }
5069
+ } catch {
5070
+ }
5071
+ for (const [label, address] of Object.entries(inline)) {
5072
+ if (rows.some((row) => row.label === label)) continue;
5073
+ rows.push({ label, address, source: "config" });
5074
+ }
5075
+ return rows.sort((left, right) => left.label.localeCompare(right.label));
5076
+ };
5077
+ const deviceView = () => {
5078
+ const view = { available: false, path: devicePath, endpoints: [], trust: [] };
5079
+ if (!existsSync2(devicePath)) return view;
5080
+ view.available = true;
5081
+ try {
5082
+ const file = readToml(devicePath);
5083
+ const secret = typeof file?.secret === "string" ? file.secret.trim() : "";
5084
+ if (secret.length === 0) throw new Error(`${devicePath} has no secret`);
5085
+ view.address = addressOf(decodeSecret(secret));
5086
+ view.endpoints = splitEndpoints(file?.endpoints).length > 0 ? splitEndpoints(file?.endpoints) : splitEndpoints(file?.cloud);
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
+ });
5092
+ } catch (error) {
5093
+ view.error = reason3(error);
5094
+ }
5095
+ return view;
5096
+ };
5097
+ const state = () => ({ caller: callerView(), devices: devicesView(), device: deviceView() });
5098
+ const edit = (op) => {
5099
+ switch (op.op) {
5100
+ case "device.set": {
5101
+ const label = labelOf(op.label);
5102
+ const address = addressOfText(op.address);
5103
+ if (Object.hasOwn(inline, label)) {
5104
+ throw new Error(`\`${label}\` is set in this plugin's own config; change it there, not in ${callerPath}`);
5105
+ }
5106
+ const file = readToml(callerPath) ?? {};
5107
+ const table = file.devices;
5108
+ const devices = typeof table === "object" && table !== null && !Array.isArray(table) ? { ...table } : {};
5109
+ devices[label] = address;
5110
+ writeToml(callerPath, { ...file, devices });
5111
+ break;
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
+ }
5131
+ case "device.remove": {
5132
+ const label = labelOf(op.label);
5133
+ if (Object.hasOwn(inline, label)) {
5134
+ throw new Error(`\`${label}\` is set in this plugin's own config; remove it there, not in ${callerPath}`);
5135
+ }
5136
+ const file = readToml(callerPath);
5137
+ if (file === void 0) throw new Error(`no caller config at ${callerPath}`);
5138
+ const devices = { ...file.devices ?? {} };
5139
+ delete devices[label];
5140
+ writeToml(callerPath, { ...file, devices });
5141
+ break;
5142
+ }
5143
+ case "trust.add":
5144
+ case "trust.remove": {
5145
+ const address = addressOfText(op.address);
5146
+ const file = readToml(devicePath);
5147
+ if (file === void 0) {
5148
+ throw new Error(`this machine is not an eXe device: ${devicePath} does not exist (run \`exe\`)`);
5149
+ }
5150
+ const trust = trustOf(file);
5151
+ const next = op.op === "trust.add" ? [.../* @__PURE__ */ new Set([...trust, address])] : trust.filter((entry) => entry !== address);
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);
5176
+ break;
5177
+ }
5178
+ default: {
5179
+ const never = op;
5180
+ throw new Error(`unknown edit ${JSON.stringify(never)}`);
5181
+ }
5182
+ }
5183
+ return state();
5184
+ };
5185
+ return { state, edit };
5186
+ }
5187
+
4631
5188
  // src/answers.ts
4632
5189
  function whole(outcome) {
4633
5190
  if (outcome.attachment !== void 0) {
@@ -4728,7 +5285,7 @@ var Config = Schema.object({
4728
5285
  function shQuote(value) {
4729
5286
  return `'${value.replaceAll("'", `'\\''`)}'`;
4730
5287
  }
4731
- function reason2(error) {
5288
+ function reason4(error) {
4732
5289
  return error instanceof Error ? error.message : String(error);
4733
5290
  }
4734
5291
  function mediaTypeOf(bytes, path, allowed) {
@@ -4823,7 +5380,7 @@ function apply(ctx, config) {
4823
5380
  if (status.last_seen_at !== void 0) row.last_seen_at = status.last_seen_at;
4824
5381
  return row;
4825
5382
  } catch (error) {
4826
- return { device: label, address, error: reason2(error) };
5383
+ return { device: label, address, error: reason4(error) };
4827
5384
  }
4828
5385
  })
4829
5386
  );
@@ -4975,6 +5532,16 @@ function apply(ctx, config) {
4975
5532
  }
4976
5533
  })
4977
5534
  );
5535
+ ctx.inject(["webServer"], (scoped) => {
5536
+ const webServer = scoped.webServer;
5537
+ const store = createStateStore(config);
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");
5544
+ });
4978
5545
  }
4979
5546
  export {
4980
5547
  Config,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-plugin-exe",
3
- "version": "0.1.1",
4
- "description": "Operate your own devices from DeepSeek Harness through eXe: a shell on remote machines, screenshots as images",
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",
@@ -18,16 +18,22 @@
18
18
  "directory": "plugins/dsh"
19
19
  },
20
20
  "exports": {
21
- ".": "./lib/index.js"
21
+ ".": "./lib/index.js",
22
+ "./client": "./lib/client.js"
22
23
  },
23
24
  "files": [
24
25
  "lib/index.js",
26
+ "lib/client.js",
25
27
  "cordis.patch.yml",
26
28
  "README.md"
27
29
  ],
28
30
  "dsh": {
29
31
  "bundle": {
30
32
  "patch": "./cordis.patch.yml"
33
+ },
34
+ "client": {
35
+ "platform": "web",
36
+ "inject": []
31
37
  }
32
38
  },
33
39
  "engines": {
@@ -35,7 +41,7 @@
35
41
  },
36
42
  "scripts": {
37
43
  "build": "node scripts/build.mjs",
38
- "typecheck": "tsc --noEmit -p tsconfig.json",
44
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json",
39
45
  "test": "node --experimental-strip-types --test tests/*.test.ts",
40
46
  "check": "pnpm build && pnpm typecheck && pnpm test"
41
47
  },
@@ -58,7 +64,11 @@
58
64
  "@noble/curves": "^2.4.0",
59
65
  "@noble/hashes": "^2.4.0",
60
66
  "@types/node": "^24.0.0",
67
+ "@types/react": "^18.3.0",
68
+ "@types/react-dom": "^18.3.0",
61
69
  "esbuild": "^0.28.0",
70
+ "react": "^18.3.1",
71
+ "react-dom": "^18.3.1",
62
72
  "smol-toml": "^1.9.1",
63
73
  "typescript": "^7.0.2"
64
74
  }