@sleep2agi/dsh-anet 0.0.0-stage → 0.1.0-preview.24

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/src/client.js ADDED
@@ -0,0 +1,433 @@
1
+ // Browser half: ANet node manager (Settings card + sidebar launcher).
2
+ // The official client only exposes a fixed settings allowlist, so the UI talks
3
+ // to the plugin's loopback API at 127.0.0.1:9460.
4
+ window.__ModuleLoader__.load({
5
+ id: "@sleep2agi/dsh-anet",
6
+ factory: (require) => {
7
+ const module = { exports: {} };
8
+ const exports = module.exports;
9
+ const React = require("react");
10
+ const h = React.createElement;
11
+ const ENDPOINT = "http://127.0.0.1:9460";
12
+
13
+ const NS = "anet";
14
+ const zh = {
15
+ title: "ANet", subtitle: "把项目/会话变成 Agent Network 节点",
16
+ steps: "① 登录人类账号 ② 创建节点 ③ 在 hub 里给节点派任务",
17
+ nodes: "节点", connected: "已连接", noNodes: "还没有节点,先创建一个。",
18
+ name: "节点名称", nameHint: "hub 上显示的 alias", project: "项目目录", projectHint: "任务在这个项目下运行",
19
+ sessionPick: "Session", newSession: "新建会话", sessionHint: "可换成已有会话,或为节点新建一个",
20
+ loginTitle: "登录人类账号", loginHint: "用 hub 账号登录,创建节点时自动签发 token;登录状态会保存在本机",
21
+ hub: "Hub 地址", username: "用户名", password: "密码", login: "登录", loggingIn: "登录中…", logout: "退出登录",
22
+ loggedIn: "已登录", createTitle: "创建节点", createHint: "节点 token 会由当前账号自动创建",
23
+ create: "创建节点", creating: "创建中…", created: "节点已创建并接入",
24
+ delete: "删除", confirmDelete: "删除这个节点?", switchSession: "切换会话", switched: "已切换会话",
25
+ loading: "正在加载…", offline: "本地服务不可用(127.0.0.1:9460)", failed: "操作失败",
26
+ note: "配置保存在 ~/.anet/dsh/nodes.json(0600)",
27
+ running: "已连接", starting: "连接中", stopped: "未连接", error: "异常",
28
+ network: "Network", projectLabel: "项目", noNetwork: "默认网络",
29
+ };
30
+ const en = {
31
+ title: "ANet", subtitle: "Turn a project/session into an Agent Network node",
32
+ steps: "① Log in ② Create a node ③ Dispatch tasks from the hub",
33
+ nodes: "Nodes", connected: "connected", noNodes: "No nodes yet. Create one below.",
34
+ name: "Node name", nameHint: "Alias shown on the hub", project: "Project directory", projectHint: "Tasks run in this project",
35
+ sessionPick: "Session", newSession: "New session", sessionHint: "Use an existing session, or create one for this node",
36
+ loginTitle: "Human account login", loginHint: "Log in with a hub account; node tokens are minted for you. The session is remembered on this machine.",
37
+ hub: "Hub URL", username: "Username", password: "Password", login: "Log in", loggingIn: "Logging in…", logout: "Log out",
38
+ loggedIn: "Logged in", createTitle: "Create node", createHint: "The node token is minted with the current account",
39
+ create: "Create node", creating: "Creating…", created: "Node created and connected",
40
+ delete: "Delete", confirmDelete: "Delete this node?", switchSession: "Switch session", switched: "Session switched",
41
+ loading: "Loading…", offline: "Local service unavailable (127.0.0.1:9460)", failed: "Failed",
42
+ note: "Stored in ~/.anet/dsh/nodes.json (0600)",
43
+ running: "Connected", starting: "Connecting", stopped: "Disconnected", error: "Error",
44
+ network: "Network", projectLabel: "Project", noNetwork: "default network",
45
+ };
46
+
47
+ const T = {
48
+ text: "var(--dsw-alias-label-primary)", sub: "var(--dsw-alias-label-secondary)", muted: "var(--dsw-alias-label-tertiary)",
49
+ border: "var(--dsw-alias-border-l2)", borderStrong: "var(--dsw-alias-border-l1)",
50
+ bg: "var(--dsw-alias-bg-layer-1)", card: "var(--dsw-alias-bg-layer-2)", field: "var(--dsw-alias-bg-layer-3)",
51
+ brand: "var(--dsw-alias-label-primary)", onBrand: "var(--dsw-alias-bg-layer-1)",
52
+ err: "var(--dsw-alias-label-error)", success: "var(--dsw-alias-state-success-primary, #2e7d32)",
53
+ };
54
+ const inputStyle = {
55
+ width: "100%", boxSizing: "border-box", height: 36, padding: "0 12px", borderRadius: 9,
56
+ border: `1px solid ${T.border}`, background: T.field, color: T.text, font: "inherit", fontSize: 13, outline: "none",
57
+ };
58
+ const labelStyle = { fontSize: 12, fontWeight: 600, color: T.sub, marginBottom: 6, display: "block" };
59
+ const hintStyle = { margin: "5px 0 0", fontSize: 11.5, color: T.muted, lineHeight: 1.5 };
60
+ const primaryButton = (disabled) => ({
61
+ height: 34, padding: "0 16px", borderRadius: 9, border: "none", font: "inherit", fontSize: 13, fontWeight: 600,
62
+ cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.5 : 1, background: T.brand, color: T.onBrand,
63
+ });
64
+ const ghostButton = (disabled) => ({
65
+ height: 30, padding: "0 10px", borderRadius: 8, border: `1px solid ${T.border}`, background: "transparent",
66
+ color: T.sub, font: "inherit", fontSize: 12, cursor: disabled ? "default" : "pointer",
67
+ });
68
+ const cardStyle = { border: `1px solid ${T.border}`, borderRadius: 12, background: T.card, padding: "14px 16px" };
69
+ const sectionTitle = { fontSize: 13, fontWeight: 700, color: T.text };
70
+ const sectionHint = { margin: "4px 0 12px", fontSize: 12, color: T.muted, lineHeight: 1.5 };
71
+ const grid2 = { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 };
72
+
73
+ function Field(props) {
74
+ return h("div", { style: props.style ?? null },
75
+ h("label", { style: labelStyle, htmlFor: props.id }, props.label),
76
+ h("input", {
77
+ id: props.id, style: inputStyle, type: props.type ?? "text", value: props.value, disabled: props.disabled,
78
+ placeholder: props.placeholder, onChange: (event) => props.onChange(event.target.value),
79
+ }),
80
+ props.hint ? h("p", { style: hintStyle }, props.hint) : null,
81
+ );
82
+ }
83
+
84
+ function StatusPill(props) {
85
+ const { status, t } = props;
86
+ const map = { running: [t("running"), T.success], starting: [t("starting"), T.muted], error: [t("error"), T.err] };
87
+ const [label, color] = map[status] ?? [t("stopped"), T.muted];
88
+ return h("span", { style: { display: "inline-flex", alignItems: "center", gap: 6, fontSize: 12, color } },
89
+ h("span", { style: { width: 7, height: 7, borderRadius: 99, background: color, display: "inline-block" } }), label);
90
+ }
91
+
92
+ function Meta(props) {
93
+ return h("div", { style: { minWidth: 0 } },
94
+ h("div", { style: { fontSize: 10.5, textTransform: "uppercase", letterSpacing: 0.4, color: T.muted } }, props.label),
95
+ h("div", { style: { fontSize: 12, color: T.sub, marginTop: 2, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, props.value),
96
+ );
97
+ }
98
+
99
+ function NodeRow(props) {
100
+ const { node, t, onDelete, sessions, onSwitch } = props;
101
+ return h("div", { style: { ...cardStyle, padding: "14px 16px", marginBottom: 10 } },
102
+ h("div", { style: { display: "flex", alignItems: "center", gap: 10 } },
103
+ h("div", { style: { fontSize: 14, fontWeight: 650, color: T.text, flex: 1, minWidth: 0, overflowWrap: "anywhere" } }, node.name),
104
+ h(StatusPill, { status: node.status, t }),
105
+ h("button", { type: "button", style: ghostButton(false), onClick: () => onDelete(node) }, t("delete")),
106
+ ),
107
+ h("div", { style: { display: "grid", gridTemplateColumns: "1.2fr 1fr 1.6fr", gap: 12, marginTop: 10 } },
108
+ h(Meta, { label: "Hub", value: node.hub }),
109
+ h(Meta, { label: "NETWORK", value: node.networkId || t("noNetwork") }),
110
+ h(Meta, { label: "PROJECT", value: node.projectDir }),
111
+ ),
112
+ h("div", { style: { display: "flex", alignItems: "center", gap: 8, marginTop: 10, paddingTop: 10, borderTop: `1px solid ${T.border}` } },
113
+ h("span", { style: { fontSize: 11.5, color: T.muted, flex: "none" } }, t("sessionPick")),
114
+ h("select", {
115
+ value: node.sessionId,
116
+ disabled: !sessions || sessions.length === 0,
117
+ onChange: (event) => onSwitch(node, event.target.value),
118
+ style: { ...inputStyle, height: 30, padding: "0 8px", fontSize: 12, flex: 1, minWidth: 0 },
119
+ },
120
+ (sessions ?? []).some((session) => session.id === node.sessionId) ? null : h("option", { value: node.sessionId }, node.sessionId),
121
+ (sessions ?? []).map((session) => h("option", { key: session.id, value: session.id },
122
+ `${session.id}${session.cwd ? ` — ${session.cwd}` : ""}`)),
123
+ ),
124
+ ),
125
+ node.lastError ? h("div", { style: { fontSize: 11.5, color: T.err, marginTop: 6 } }, node.lastError) : null,
126
+ );
127
+ }
128
+
129
+ function createOpenStore() {
130
+ let open = false;
131
+ const listeners = new Set();
132
+ const emit = () => { for (const listener of listeners) listener(); };
133
+ return {
134
+ get: () => open,
135
+ set: (value) => { if (open === value) return; open = value; emit(); },
136
+ subscribe: (listener) => { listeners.add(listener); return () => listeners.delete(listener); },
137
+ };
138
+ }
139
+ const openStore = createOpenStore();
140
+
141
+ function AnetSidebarAction(props) {
142
+ const open = React.useSyncExternalStore(openStore.subscribe, openStore.get, openStore.get);
143
+ const t = props.t;
144
+ return h(React.Fragment, null,
145
+ h("button", {
146
+ type: "button", title: t("title"), "aria-label": t("title"), onClick: () => openStore.set(!open),
147
+ style: {
148
+ width: props.wide ? "100%" : 32, height: 32, display: "flex", alignItems: "center", justifyContent: "center",
149
+ gap: 7, padding: props.wide ? "0 10px" : 0, marginBottom: 4,
150
+ border: `1px solid ${T.border}`, borderRadius: 8,
151
+ background: open ? T.card : "transparent", color: T.sub, font: "inherit",
152
+ fontSize: props.wide ? 13 : 11, fontWeight: 600, cursor: "pointer",
153
+ },
154
+ },
155
+ h("span", { style: { width: 16, height: 16, borderRadius: 5, background: T.brand, color: T.onBrand, fontSize: 10, fontWeight: 700, display: "inline-flex", alignItems: "center", justifyContent: "center" } }, "A"),
156
+ props.wide ? "ANet" : null,
157
+ ),
158
+ open ? h(AnetOverlay, { t, onClose: () => openStore.set(false) }) : null,
159
+ );
160
+ }
161
+
162
+ function AnetOverlay(props) {
163
+ const t = props.t;
164
+ return h("div", {
165
+ style: {
166
+ position: "fixed", inset: 0, zIndex: 10000, background: "rgba(15,18,24,0.5)", backdropFilter: "blur(3px)",
167
+ display: "flex", alignItems: "center", justifyContent: "center", padding: 24,
168
+ },
169
+ onMouseDown: (event) => { if (event.target === event.currentTarget) props.onClose(); },
170
+ },
171
+ h("div", {
172
+ style: {
173
+ width: "min(880px, 94vw)", maxHeight: "86vh", display: "flex", flexDirection: "column", overflow: "hidden",
174
+ background: T.bg, borderRadius: 16, border: `1px solid ${T.border}`,
175
+ boxShadow: "0 24px 70px rgba(0,0,0,0.4)",
176
+ },
177
+ },
178
+ h("div", { style: { display: "flex", alignItems: "center", gap: 12, padding: "18px 20px", borderBottom: `1px solid ${T.border}` } },
179
+ h("div", { style: { width: 34, height: 34, borderRadius: 10, background: T.brand, color: T.onBrand, fontSize: 16, fontWeight: 800, display: "flex", alignItems: "center", justifyContent: "center" } }, "A"),
180
+ h("div", { style: { flex: 1, minWidth: 0 } },
181
+ h("div", { style: { fontSize: 16, fontWeight: 700, color: T.text } }, t("title")),
182
+ h("div", { style: { fontSize: 12, color: T.muted, marginTop: 2 } }, t("subtitle")),
183
+ ),
184
+ h("button", {
185
+ type: "button", "aria-label": "Close", onClick: props.onClose,
186
+ style: { border: "none", background: "transparent", color: T.muted, font: "inherit", fontSize: 22, lineHeight: 1, cursor: "pointer", padding: 4 },
187
+ }, "×"),
188
+ ),
189
+ h("div", { style: { overflow: "auto", padding: 20 } }, h(AnetCard, { t })),
190
+ ),
191
+ );
192
+ }
193
+
194
+ function AnetCard(props) {
195
+ const t = props.t;
196
+ const [state, setState] = React.useState({ status: "loading", nodes: [] });
197
+ const [session, setSession] = React.useState(null);
198
+ const [loginDraft, setLoginDraft] = React.useState({ hub: "", username: "", password: "" });
199
+ const [draft, setDraft] = React.useState({ name: "", projectDir: "", networkId: "" });
200
+ const [projects, setProjects] = React.useState([]);
201
+ const [sessions, setSessions] = React.useState([]);
202
+ const [sessionChoice, setSessionChoice] = React.useState("__new__");
203
+ const [busy, setBusy] = React.useState(false);
204
+ const [message, setMessage] = React.useState(null);
205
+ const [failed, setFailed] = React.useState(false);
206
+
207
+ const refresh = React.useCallback(async () => {
208
+ try {
209
+ const res = await fetch(`${ENDPOINT}/nodes`, { cache: "no-store" });
210
+ const body = await res.json();
211
+ if (!res.ok || body.ok === false) throw new Error(body.error ?? `HTTP ${res.status}`);
212
+ setState({ status: "ready", nodes: body.nodes ?? [] });
213
+ setLoginDraft((current) => ({ ...current, hub: current.hub || body.defaults?.hub || "" }));
214
+ setDraft((current) => ({ ...current, projectDir: current.projectDir || body.defaults?.projectDir || "" }));
215
+ } catch (error) {
216
+ setState({ status: "error", nodes: [] });
217
+ setFailed(true);
218
+ setMessage(`${t("offline")}: ${error?.message ?? error}`);
219
+ }
220
+ }, []);
221
+
222
+ React.useEffect(() => { void refresh(); }, []);
223
+ React.useEffect(() => {
224
+ fetch(`${ENDPOINT}/account`, { cache: "no-store" })
225
+ .then((res) => res.json())
226
+ .then((body) => {
227
+ if (body?.loggedIn) setSession({ hub: body.hub, username: body.username, networkId: body.networkId ?? "" });
228
+ })
229
+ .catch(() => {});
230
+ }, []);
231
+ React.useEffect(() => {
232
+ fetch(`${ENDPOINT}/sessions`, { cache: "no-store" })
233
+ .then((res) => res.json())
234
+ .then((body) => { if (body?.ok !== false) setSessions(body.sessions ?? []); })
235
+ .catch(() => {});
236
+ fetch(`${ENDPOINT}/projects`, { cache: "no-store" })
237
+ .then((res) => res.json())
238
+ .then((body) => {
239
+ if (body?.ok === false) throw new Error(body.error ?? "projects");
240
+ setProjects(body.projects ?? []);
241
+ setDraft((current) => ({ ...current, projectDir: current.projectDir || body.defaultProject || "" }));
242
+ })
243
+ .catch(() => {});
244
+ }, []);
245
+
246
+ const editLogin = (key) => (value) => setLoginDraft((current) => ({ ...current, [key]: value }));
247
+ const editDraft = (key) => (value) => setDraft((current) => ({ ...current, [key]: value }));
248
+
249
+ const onLogin = async () => {
250
+ setBusy(true); setMessage(null); setFailed(false);
251
+ try {
252
+ const res = await fetch(`${ENDPOINT}/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(loginDraft) });
253
+ const body = await res.json();
254
+ if (!res.ok || body.ok === false) throw new Error(body.error ?? `HTTP ${res.status}`);
255
+ setSession({ hub: body.hub ?? loginDraft.hub, username: body.username ?? loginDraft.username, networkId: body.networkId ?? "" });
256
+ setLoginDraft((current) => ({ ...current, password: "" }));
257
+ setDraft((current) => ({ ...current, networkId: body.networkId ?? "" }));
258
+ setMessage(`${t("loggedIn")}: ${body.username ?? loginDraft.username}`);
259
+ } catch (error) {
260
+ setFailed(true); setMessage(`${t("failed")}: ${error?.message ?? error}`);
261
+ } finally { setBusy(false); }
262
+ };
263
+
264
+ const onCreate = async () => {
265
+ if (!session) { setFailed(true); setMessage(t("loginTitle")); return; }
266
+ setBusy(true); setMessage(null); setFailed(false);
267
+ try {
268
+ const res = await fetch(`${ENDPOINT}/nodes`, {
269
+ method: "POST", headers: { "Content-Type": "application/json" },
270
+ body: JSON.stringify({
271
+ name: draft.name, projectDir: draft.projectDir,
272
+ hub: session.hub, networkId: draft.networkId || session.networkId,
273
+ ...(sessionChoice === "__new__" ? {} : { sessionId: sessionChoice }),
274
+ }),
275
+ });
276
+ const body = await res.json();
277
+ if (!res.ok || body.ok === false) throw new Error(body.error ?? `HTTP ${res.status}`);
278
+ setMessage(t("created"));
279
+ setDraft((current) => ({ ...current, name: "" }));
280
+ setSessionChoice("__new__");
281
+ await refresh();
282
+ } catch (error) {
283
+ setFailed(true); setMessage(`${t("failed")}: ${error?.message ?? error}`);
284
+ } finally { setBusy(false); }
285
+ };
286
+
287
+ const onDelete = async (node) => {
288
+ if (!window.confirm(t("confirmDelete"))) return;
289
+ try {
290
+ const res = await fetch(`${ENDPOINT}/nodes/${encodeURIComponent(node.id)}`, { method: "DELETE" });
291
+ const body = await res.json();
292
+ if (!res.ok || body.ok === false) throw new Error(body.error ?? `HTTP ${res.status}`);
293
+ await refresh();
294
+ } catch (error) {
295
+ setFailed(true); setMessage(`${t("failed")}: ${error?.message ?? error}`);
296
+ }
297
+ };
298
+
299
+ const onSwitch = async (node, sessionId) => {
300
+ if (!sessionId || sessionId === node.sessionId) return;
301
+ if (!window.confirm(t("confirmDelete").replace(/.*/, t("switchSession") + "?"))) return;
302
+ try {
303
+ const res = await fetch(`${ENDPOINT}/nodes/${encodeURIComponent(node.id)}`, {
304
+ method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId }),
305
+ });
306
+ const body = await res.json();
307
+ if (!res.ok || body.ok === false) throw new Error(body.error ?? `HTTP ${res.status}`);
308
+ setMessage(t("switched"));
309
+ await refresh();
310
+ } catch (error) {
311
+ setFailed(true); setMessage(`${t("failed")}: ${error?.message ?? error}`);
312
+ }
313
+ };
314
+
315
+ if (state.status === "loading") {
316
+ return h("div", { style: { padding: 28, color: T.muted, fontSize: 13, textAlign: "center" } }, t("loading"));
317
+ }
318
+
319
+ const online = state.nodes.filter((node) => node.status === "running").length;
320
+ const messageLine = message
321
+ ? h("span", { style: { fontSize: 12, color: failed ? T.err : T.muted } }, message)
322
+ : null;
323
+ const customProject = !projects.some((project) => project.path === draft.projectDir);
324
+
325
+ return h("div", { style: { display: "flex", flexDirection: "column", gap: 14 } },
326
+ h("div", { style: { display: "flex", alignItems: "center", gap: 8, padding: "9px 12px", borderRadius: 10, background: T.card, border: `1px solid ${T.border}`, fontSize: 12, color: T.sub } },
327
+ h("span", { style: { flex: 1 } }, t("steps")),
328
+ h("span", { style: { color: T.muted, flex: "none" } }, `${online}/${state.nodes.length} ${t("connected")}`),
329
+ ),
330
+
331
+ h("div", { style: { ...cardStyle } },
332
+ h("div", { style: sectionTitle }, `${t("nodes")} (${state.nodes.length})`),
333
+ state.nodes.length === 0
334
+ ? h("p", { style: { fontSize: 12, color: T.muted, margin: "10px 0 2px" } }, t("noNodes"))
335
+ : h("div", { style: { marginTop: 12 } }, state.nodes.map((node) =>
336
+ h(NodeRow, { key: node.id, node, t, onDelete, sessions, onSwitch }))),
337
+ ),
338
+
339
+ session
340
+ ? h("div", { style: { ...cardStyle, display: "flex", alignItems: "center", gap: 10 } },
341
+ h("span", { style: { width: 8, height: 8, borderRadius: 99, background: T.success, flex: "none" } }),
342
+ h("div", { style: { flex: 1, minWidth: 0 } },
343
+ h("div", { style: { fontSize: 12.5, color: T.text, fontWeight: 600 } }, `${t("loggedIn")}: ${session.username ?? ""}`),
344
+ h("div", { style: { fontSize: 11.5, color: T.muted, marginTop: 2, overflowWrap: "anywhere" } },
345
+ `${session.hub}${session.networkId ? ` · ${session.networkId}` : ""}`),
346
+ ),
347
+ h("button", {
348
+ type: "button", style: { ...ghostButton(false), border: "none" },
349
+ onClick: async () => { try { await fetch(`${ENDPOINT}/logout`, { method: "POST" }); } catch {} setSession(null); },
350
+ }, t("logout")),
351
+ )
352
+ : h("div", { style: cardStyle },
353
+ h("div", { style: sectionTitle }, t("loginTitle")),
354
+ h("p", { style: sectionHint }, t("loginHint")),
355
+ h(Field, { id: "anet-login-hub", label: t("hub"), value: loginDraft.hub, disabled: busy, onChange: editLogin("hub") }),
356
+ h("div", { style: { ...grid2, marginTop: 12 } },
357
+ h(Field, { id: "anet-login-user", label: t("username"), value: loginDraft.username, disabled: busy, onChange: editLogin("username") }),
358
+ h(Field, { id: "anet-login-pass", label: t("password"), type: "password", value: loginDraft.password, disabled: busy, onChange: editLogin("password") }),
359
+ ),
360
+ h("div", { style: { display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 12, paddingTop: 12 } },
361
+ messageLine,
362
+ h("button", { type: "button", disabled: busy, style: primaryButton(busy), onClick: onLogin }, busy ? t("loggingIn") : t("login")),
363
+ ),
364
+ ),
365
+
366
+ session
367
+ ? h("div", { style: cardStyle },
368
+ h("div", { style: sectionTitle }, t("createTitle")),
369
+ h("p", { style: sectionHint }, t("createHint")),
370
+ h("div", { style: { display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 } },
371
+ h(Field, { id: "anet-name", label: t("name"), hint: t("nameHint"), value: draft.name, disabled: busy, onChange: editDraft("name") }),
372
+ h("div", null,
373
+ h("label", { style: labelStyle, htmlFor: "anet-session" }, t("sessionPick")),
374
+ h("select", {
375
+ id: "anet-session", style: { ...inputStyle, appearance: "auto" }, value: sessionChoice, disabled: busy,
376
+ onChange: (event) => setSessionChoice(event.target.value),
377
+ },
378
+ h("option", { value: "__new__" }, t("newSession")),
379
+ sessions.map((item) => h("option", { key: item.id, value: item.id }, `${item.id}${item.cwd ? ` — ${item.cwd}` : ""}`)),
380
+ ),
381
+ h("p", { style: hintStyle }, t("sessionHint")),
382
+ ),
383
+ ),
384
+ h("div", { style: { marginTop: 12 } },
385
+ h("label", { style: labelStyle, htmlFor: "anet-project" }, t("project")),
386
+ projects.length > 0
387
+ ? h("select", {
388
+ id: "anet-project", style: { ...inputStyle, appearance: "auto" },
389
+ value: customProject ? "__custom__" : draft.projectDir, disabled: busy,
390
+ onChange: (event) => editDraft("projectDir")(event.target.value === "__custom__" ? "" : event.target.value),
391
+ },
392
+ projects.map((project) => h("option", { key: project.path, value: project.path }, `${project.label} — ${project.path}`)),
393
+ h("option", { value: "__custom__" }, t("project") + " (custom)"),
394
+ )
395
+ : null,
396
+ (projects.length === 0 || customProject)
397
+ ? h("input", {
398
+ id: "anet-project-path", style: { ...inputStyle, marginTop: projects.length > 0 ? 8 : 0 },
399
+ value: draft.projectDir, disabled: busy, placeholder: "/path/to/project",
400
+ onChange: (event) => editDraft("projectDir")(event.target.value),
401
+ })
402
+ : null,
403
+ session.networkId ? null : h("div", { style: { marginTop: 12 } },
404
+ h(Field, { id: "anet-network", label: t("network"), value: draft.networkId, disabled: busy, onChange: editDraft("networkId") })),
405
+ h("p", { style: hintStyle }, t("projectHint")),
406
+ ),
407
+ h("div", { style: { display: "flex", justifyContent: "flex-end", alignItems: "center", gap: 12, paddingTop: 14 } },
408
+ messageLine,
409
+ h("button", { type: "button", disabled: busy, style: primaryButton(busy), onClick: onCreate }, busy ? t("creating") : t("create")),
410
+ ),
411
+ )
412
+ : null,
413
+
414
+ h("div", { style: { fontSize: 11, color: T.muted, textAlign: "center" } }, t("note")),
415
+ );
416
+ }
417
+
418
+ function apply(ctx) {
419
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "anet: dictionaries");
420
+ const t = ctx.locale.bind(NS);
421
+ ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
422
+ name: "settings.plugin.item", id: "anet", order: 30, locale: NS, inject: () => ({ t }),
423
+ }, AnetCard));
424
+ ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
425
+ name: "sidebar.footer.action", id: "anet", order: 10, locale: NS, inject: () => ({ t }),
426
+ }, AnetSidebarAction));
427
+ }
428
+
429
+ exports.apply = apply;
430
+ exports.inject = ["slots", "locale"];
431
+ return module.exports;
432
+ },
433
+ });
@@ -0,0 +1,141 @@
1
+ // CommHub node-side client: MCP tool calls over POST /mcp and the per-alias
2
+ // SSE doorbell at GET /events/<alias>. Same wire contract agent-node uses.
3
+ // The node token is held in a closure and never logged or returned.
4
+
5
+ const PROTOCOL = "2025-03-26";
6
+
7
+ export class HubError extends Error {
8
+ constructor(message, { status, tool } = {}) {
9
+ super(message);
10
+ this.name = "HubError";
11
+ this.status = status;
12
+ this.tool = tool;
13
+ }
14
+ }
15
+
16
+ function parseEnvelope(raw) {
17
+ try { return JSON.parse(raw); } catch { /* SSE-framed body */ }
18
+ for (const line of raw.split(/\r?\n/)) {
19
+ const m = line.match(/^data:\s*(\{.*\})\s*$/);
20
+ if (m) return JSON.parse(m[1]);
21
+ }
22
+ return null;
23
+ }
24
+
25
+ /**
26
+ * @param {{hub: string, token: string, networkId?: string, clientVersion?: string,
27
+ * fetch?: typeof fetch, timeoutMs?: number}} opts
28
+ */
29
+ export function createHubClient(opts) {
30
+ const hub = String(opts.hub || "").replace(/\/+$/, "");
31
+ if (!/^https?:\/\//.test(hub)) throw new Error("dsh-commhub: hub must be an http(s) URL");
32
+ if (!opts.token) throw new Error("dsh-commhub: node token is required");
33
+ const token = opts.token;
34
+ const doFetch = opts.fetch ?? fetch;
35
+ const timeoutMs = opts.timeoutMs ?? 30_000;
36
+ let sessionId = null;
37
+ let rpcId = 0;
38
+
39
+ const headers = () => ({
40
+ "Content-Type": "application/json",
41
+ Accept: "application/json, text/event-stream",
42
+ "MCP-Protocol-Version": PROTOCOL,
43
+ Authorization: `Bearer ${token}`,
44
+ ...(sessionId ? { "mcp-session-id": sessionId } : {}),
45
+ });
46
+
47
+ async function initialize() {
48
+ sessionId = null;
49
+ const res = await doFetch(`${hub}/mcp`, {
50
+ method: "POST",
51
+ headers: headers(),
52
+ body: JSON.stringify({
53
+ jsonrpc: "2.0", id: ++rpcId, method: "initialize",
54
+ params: { protocolVersion: PROTOCOL, capabilities: {},
55
+ clientInfo: { name: "dsh-commhub", version: opts.clientVersion ?? "0" } },
56
+ }),
57
+ signal: AbortSignal.timeout(timeoutMs),
58
+ });
59
+ if (!res.ok) throw new HubError(`initialize: HTTP ${res.status}`, { status: res.status });
60
+ sessionId = res.headers.get("mcp-session-id");
61
+ await res.text();
62
+ }
63
+
64
+ async function callOnce(tool, args) {
65
+ const res = await doFetch(`${hub}/mcp`, {
66
+ method: "POST",
67
+ headers: headers(),
68
+ body: JSON.stringify({
69
+ jsonrpc: "2.0", id: ++rpcId, method: "tools/call",
70
+ params: { name: tool, arguments: { ...(opts.networkId ? { network_id: opts.networkId } : {}), ...args } },
71
+ }),
72
+ signal: AbortSignal.timeout(timeoutMs),
73
+ });
74
+ const raw = await res.text();
75
+ return { res, raw };
76
+ }
77
+
78
+ /** Call a hub MCP tool; returns the parsed JSON payload. Throws HubError on ok:false. */
79
+ async function call(tool, args = {}) {
80
+ if (!sessionId) await initialize();
81
+ let { res, raw } = await callOnce(tool, args);
82
+ // A restarted hub forgets MCP sessions: re-initialize once and retry.
83
+ if (res.status === 404 || res.status === 400) {
84
+ await initialize();
85
+ ({ res, raw } = await callOnce(tool, args));
86
+ }
87
+ if (res.status === 401) throw new HubError(`${tool}: unauthorized (node token rejected)`, { status: 401, tool });
88
+ const env = parseEnvelope(raw);
89
+ if (!env) throw new HubError(`${tool}: empty response (HTTP ${res.status})`, { status: res.status, tool });
90
+ if (env.error) throw new HubError(`${tool}: ${String(env.error.message ?? JSON.stringify(env.error)).slice(0, 300)}`, { status: res.status, tool });
91
+ const text = env.result?.content?.[0]?.text;
92
+ let payload = env.result;
93
+ if (typeof text === "string") {
94
+ // MCP-level tool failures arrive as plain text, not JSON; don't mistake
95
+ // them for a successful tool payload.
96
+ if (/^(?:MCP error|Error executing tool)/i.test(text)) {
97
+ throw new HubError(`${tool}: ${text.slice(0, 300)}`, { status: res.status, tool });
98
+ }
99
+ try { payload = JSON.parse(text); } catch { payload = { ok: true, text }; }
100
+ }
101
+ // An offline target is not a failure: the hub answers ok:false + queued:true
102
+ // with a message id, and delivers when the target comes back.
103
+ if (payload && payload.ok === false && payload.queued === true) return { ...payload, ok: true, queued: true };
104
+ if (payload && payload.ok === false) {
105
+ throw new HubError(`${tool}: ${payload.error ?? payload.message ?? "failed"}`, { status: res.status, tool });
106
+ }
107
+ return payload;
108
+ }
109
+
110
+ /**
111
+ * Async iterator over SSE events for `alias`. Ends when the stream closes;
112
+ * the caller owns reconnect/backoff.
113
+ */
114
+ async function* events(alias, signal) {
115
+ const res = await doFetch(`${hub}/events/${encodeURIComponent(alias)}`, {
116
+ headers: { Accept: "text/event-stream", "Cache-Control": "no-cache", Authorization: `Bearer ${token}` },
117
+ signal,
118
+ });
119
+ if (!res.ok || !res.body) throw new HubError(`events: HTTP ${res.status}`, { status: res.status });
120
+ const reader = res.body.getReader();
121
+ const decoder = new TextDecoder();
122
+ let buffer = "";
123
+ try {
124
+ while (true) {
125
+ const { done, value } = await reader.read();
126
+ if (done) return;
127
+ buffer += decoder.decode(value, { stream: true });
128
+ const lines = buffer.split("\n");
129
+ buffer = lines.pop() ?? "";
130
+ for (const line of lines) {
131
+ if (!line.startsWith("data: ")) continue;
132
+ try { yield JSON.parse(line.slice(6)); } catch { /* ignore malformed frame */ }
133
+ }
134
+ }
135
+ } finally {
136
+ try { await reader.cancel(); } catch { /* already closed */ }
137
+ }
138
+ }
139
+
140
+ return { call, events, hub };
141
+ }
package/src/ledger.mjs ADDED
@@ -0,0 +1,31 @@
1
+ // Small durable ledger of task ids this node has handled, so a reconnect, a
2
+ // duplicate doorbell, or a plugin restart never produces a second reply.
3
+ // States: started → answered (reply text kept until the hub accepts it) → done.
4
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
5
+ import { dirname } from "node:path";
6
+
7
+ const MAX_ENTRIES = 500;
8
+
9
+ export function createLedger(path) {
10
+ let entries = {};
11
+ if (path) {
12
+ try { entries = JSON.parse(readFileSync(path, "utf8")).tasks ?? {}; } catch { entries = {}; }
13
+ }
14
+ const persist = () => {
15
+ if (!path) return;
16
+ const ids = Object.keys(entries);
17
+ if (ids.length > MAX_ENTRIES) {
18
+ ids.sort((a, b) => (entries[a].at ?? 0) - (entries[b].at ?? 0));
19
+ for (const id of ids.slice(0, ids.length - MAX_ENTRIES)) delete entries[id];
20
+ }
21
+ mkdirSync(dirname(path), { recursive: true });
22
+ const tmp = `${path}.${process.pid}.tmp`;
23
+ writeFileSync(tmp, JSON.stringify({ tasks: entries }), { mode: 0o600 });
24
+ renameSync(tmp, path);
25
+ };
26
+ return {
27
+ get: (id) => entries[id],
28
+ set(id, value) { entries[id] = { ...value, at: Date.now() }; persist(); },
29
+ snapshot: () => JSON.parse(JSON.stringify(entries)),
30
+ };
31
+ }