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

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.
@@ -0,0 +1,185 @@
1
+ // The CommHub node loop, independent of DSH: heartbeat, SSE doorbell with
2
+ // reconnect/backoff, inbox drain, exactly-once reply per task id.
3
+ import { randomUUID } from "node:crypto";
4
+ import { hostname } from "node:os";
5
+
6
+ export const REPLY_MAX_CHARS = 10_000; // hub send_reply text limit
7
+
8
+ export function truncateReply(text) {
9
+ const s = String(text ?? "");
10
+ if (s.length <= REPLY_MAX_CHARS) return s;
11
+ const note = "\n\n[dsh-commhub: reply truncated to the hub's 10000-character limit]";
12
+ return s.slice(0, REPLY_MAX_CHARS - note.length) + note;
13
+ }
14
+
15
+ const isTerminalAlready = (err) => /reply_task_terminal|already terminal/i.test(String(err?.message ?? err));
16
+ // The inbox row outlived its task/message record (expired, purged, or already
17
+ // deleted). There is nothing left to address, so retrying forever is wrong.
18
+ const isReplyTargetGone = (err) => /reply_target_unresolved|task not found|message not found/i.test(String(err?.message ?? err));
19
+
20
+ /**
21
+ * @param {{client: ReturnType<import('./hub-client.mjs').createHubClient>, alias: string,
22
+ * runTurn: (prompt: string, task: object) => Promise<string>, ledger: ReturnType<import('./ledger.mjs').createLedger>,
23
+ * log?: (m: string) => void, heartbeatMs?: number, pollMs?: number, turnTimeoutMs?: number,
24
+ * backoff?: {initialMs: number, maxMs: number}, version?: string}} opts
25
+ */
26
+ export function createCommhubNode(opts) {
27
+ const { client, alias, runTurn, ledger } = opts;
28
+ const log = opts.log ?? (() => {});
29
+ const resumeId = `dsh-${randomUUID()}`;
30
+ const heartbeatMs = opts.heartbeatMs ?? 30_000;
31
+ const pollMs = opts.pollMs ?? 60_000;
32
+ const turnTimeoutMs = opts.turnTimeoutMs ?? 30 * 60_000;
33
+ const backoff = opts.backoff ?? { initialMs: 1_000, maxMs: 30_000 };
34
+ let stopped = true;
35
+ let sseAbort = null;
36
+ let timers = [];
37
+ let draining = null;
38
+ let drainAgain = false;
39
+ let status = "idle";
40
+ let currentTask;
41
+ const stats = { connects: 0, drains: 0, replies: 0, failures: 0 };
42
+
43
+ const heartbeat = (next, task) => {
44
+ if (next) status = next;
45
+ currentTask = task;
46
+ return client.call("report_status", {
47
+ resume_id: resumeId, alias, status, ...(task ? { task: task.slice(0, 200) } : {}),
48
+ agent: "dsh", hostname: hostname(), project_dir: process.cwd(), version: opts.version ?? "dsh-commhub",
49
+ }).catch((e) => log(`report_status failed: ${e.message}`));
50
+ };
51
+
52
+ async function sendReply(id, text, replyStatus) {
53
+ try {
54
+ await client.call("send_reply", { in_reply_to: id, text: truncateReply(text), status: replyStatus, from_session: alias });
55
+ } catch (e) {
56
+ if (isTerminalAlready(e)) {
57
+ log(`task ${id.slice(0, 8)} already terminal on the hub; not replying again`);
58
+ } else if (isReplyTargetGone(e)) {
59
+ log(`task ${id.slice(0, 8)} no longer exists on the hub; dropping the stale inbox row`);
60
+ } else {
61
+ throw e;
62
+ }
63
+ }
64
+ ledger.set(id, { state: "done", status: replyStatus });
65
+ replyStatus === "replied" ? stats.replies++ : stats.failures++;
66
+ }
67
+
68
+ async function withTimeout(promise, ms) {
69
+ let t;
70
+ try {
71
+ return await Promise.race([promise, new Promise((_, rej) => { t = setTimeout(() => rej(new Error(`turn exceeded ${Math.round(ms / 1000)}s`)), ms); })]);
72
+ } finally { clearTimeout(t); }
73
+ }
74
+
75
+ async function handleTask(msg) {
76
+ const id = String(msg.task_id || msg.id);
77
+ const seen = ledger.get(id);
78
+ if (seen?.state === "done") { log(`task ${id.slice(0, 8)} already replied; ack only`); return; }
79
+ if (seen?.state === "answered") {
80
+ log(`task ${id.slice(0, 8)} answered earlier but reply not delivered; resending`);
81
+ await sendReply(id, seen.text, seen.status);
82
+ return;
83
+ }
84
+ if (seen?.state === "started") {
85
+ // The previous plugin process died mid-turn. Say so instead of silently re-running.
86
+ await sendReply(id, "dsh-commhub: the node restarted before this task finished; nothing was returned. Please resend the task.", "failed");
87
+ return;
88
+ }
89
+ ledger.set(id, { state: "started" });
90
+ const content = String(msg.content ?? "");
91
+ log(`task ${id.slice(0, 8)} from ${msg.from_session ?? "?"}: ${content.slice(0, 60)}`);
92
+ await heartbeat("working", content);
93
+ let text; let replyStatus = "replied";
94
+ try {
95
+ text = await withTimeout(runTurn(`[来自 ${msg.from_session ?? "unknown"}] ${content}`, msg), turnTimeoutMs);
96
+ if (!String(text ?? "").trim()) { text = "dsh-commhub: the agent finished without any text answer."; replyStatus = "failed"; }
97
+ } catch (e) {
98
+ text = `dsh-commhub: the agent turn failed: ${e?.message ?? e}`;
99
+ replyStatus = "failed";
100
+ }
101
+ ledger.set(id, { state: "answered", text: String(text), status: replyStatus });
102
+ await sendReply(id, String(text), replyStatus);
103
+ log(`task ${id.slice(0, 8)} → ${replyStatus} (${String(text).length} chars)`);
104
+ }
105
+
106
+ async function drainOnce() {
107
+ stats.drains++;
108
+ const { messages = [] } = await client.call("get_inbox", { alias, limit: 10 });
109
+ for (const msg of messages) {
110
+ if (stopped) return;
111
+ try {
112
+ if (msg.type === "task") await handleTask(msg);
113
+ else log(`skip ${msg.type ?? "message"} ${String(msg.id).slice(0, 8)} from ${msg.from_session ?? "?"}`);
114
+ await client.call("ack_inbox", { alias, message_id: msg.id });
115
+ } catch (e) {
116
+ // Leave the row unacked so the next drain retries it; the ledger prevents a double reply.
117
+ log(`inbox row ${String(msg.id).slice(0, 8)} not finished: ${e.message}`);
118
+ }
119
+ }
120
+ if (messages.length > 0) await heartbeat("idle");
121
+ }
122
+
123
+ function drain() {
124
+ if (stopped) return Promise.resolve();
125
+ if (draining) { drainAgain = true; return draining; }
126
+ draining = (async () => {
127
+ do {
128
+ drainAgain = false;
129
+ try { await drainOnce(); } catch (e) { log(`drain failed: ${e.message}`); }
130
+ } while (drainAgain && !stopped);
131
+ })().finally(() => { draining = null; });
132
+ return draining;
133
+ }
134
+
135
+ async function sseLoop() {
136
+ let delay = backoff.initialMs;
137
+ while (!stopped) {
138
+ sseAbort = new AbortController();
139
+ try {
140
+ for await (const ev of client.events(alias, sseAbort.signal)) {
141
+ if (ev.type === "connected") {
142
+ stats.connects++;
143
+ delay = backoff.initialMs;
144
+ log(stats.connects === 1 ? "SSE connected" : "SSE reconnected; re-registering and draining");
145
+ if (stats.connects > 1) void heartbeat();
146
+ void drain();
147
+ } else if (ev.type === "new_task" || ev.type === "new_message") {
148
+ void drain();
149
+ }
150
+ }
151
+ if (!stopped) log("SSE stream ended");
152
+ } catch (e) {
153
+ if (stopped) return;
154
+ log(`SSE error: ${e.message}`);
155
+ }
156
+ if (stopped) return;
157
+ await new Promise((r) => { const t = setTimeout(r, delay); timers.push(t); });
158
+ delay = Math.min(delay * 2, backoff.maxMs);
159
+ }
160
+ }
161
+
162
+ return {
163
+ resumeId,
164
+ stats,
165
+ drain,
166
+ async start() {
167
+ if (!stopped) return;
168
+ stopped = false;
169
+ await heartbeat("idle");
170
+ timers.push(setInterval(() => void heartbeat(undefined, currentTask), heartbeatMs));
171
+ timers.push(setInterval(() => void drain(), pollMs));
172
+ void sseLoop();
173
+ log(`node started alias=${alias}`);
174
+ },
175
+ async stop() {
176
+ if (stopped) return;
177
+ stopped = true;
178
+ sseAbort?.abort();
179
+ for (const t of timers) { clearInterval(t); clearTimeout(t); }
180
+ timers = [];
181
+ if (draining) await draining.catch(() => {});
182
+ await heartbeat("offline");
183
+ },
184
+ };
185
+ }
package/src/nodes.mjs ADDED
@@ -0,0 +1,107 @@
1
+ // Durable node registry: one record per ANet node (name/alias + hub + token +
2
+ // project directory + dedicated DSH session). Stored 0600 next to the legacy
3
+ // single-node config so existing installs migrate on first load.
4
+ import { randomUUID } from "node:crypto";
5
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join, resolve } from "node:path";
8
+
9
+ export function registryPath(env = process.env) {
10
+ return expandHome(env.DSH_ANET_NODES ?? join(homedir(), ".anet", "dsh", "nodes.json"));
11
+ }
12
+
13
+ export function legacyConfigPath(env = process.env) {
14
+ return expandHome(env.DSH_COMMHUB_CONFIG ?? join(homedir(), ".anet", "dsh", "config.json"));
15
+ }
16
+
17
+ /** Pre-rename locations, read once during migration. */
18
+ function oldRegistryPaths(env) {
19
+ return [
20
+ expandHome(env.DSH_COMMHUB_CONFIG_REGISTRY ?? join(homedir(), ".dsh-commhub", "nodes.json")),
21
+ expandHome(join(homedir(), ".dsh-anet", "nodes.json")),
22
+ ];
23
+ }
24
+
25
+ function oldConfigPaths(env) {
26
+ return [
27
+ expandHome(join(homedir(), ".dsh-commhub", "config.json")),
28
+ expandHome(join(homedir(), ".dsh-commhub", "nodes.json")),
29
+ expandHome(join(homedir(), ".dsh-anet", "config.json")),
30
+ ];
31
+ }
32
+
33
+ export function expandHome(path) {
34
+ return path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
35
+ }
36
+
37
+ const slug = (name) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 24) || "node";
38
+
39
+ export function newNodeId() {
40
+ return `node_${randomUUID().slice(0, 10)}`;
41
+ }
42
+
43
+ export function newSessionId(name) {
44
+ return `anet-${slug(name)}-${randomUUID().slice(0, 8)}`;
45
+ }
46
+
47
+ export function normalizeNode(draft, { id, sessionId, projectDir } = {}) {
48
+ const name = String(draft.name ?? draft.alias ?? "").trim();
49
+ return {
50
+ id: id ?? newNodeId(),
51
+ name,
52
+ hub: String(draft.hub ?? "").trim().replace(/\/+$/, ""),
53
+ networkId: String(draft.networkId ?? "").trim(),
54
+ token: String(draft.token ?? "").trim(),
55
+ projectDir: resolve(projectDir ?? draft.projectDir ?? process.cwd()),
56
+ sessionId: sessionId ?? draft.sessionId ?? newSessionId(name),
57
+ createdAt: draft.createdAt ?? new Date().toISOString(),
58
+ };
59
+ }
60
+
61
+ export function validateNode(node, nodes = []) {
62
+ if (!node.name) return "节点名称不能为空";
63
+ if (node.name.length > 200) return "节点名称过长";
64
+ if (nodes.some((other) => other.name === node.name && other.id !== node.id)) return `节点名称 "${node.name}" 已存在`;
65
+ if (!/^https?:\/\//.test(node.hub)) return "Hub 必须是 http(s) 地址";
66
+ if (!/^ntok_/.test(node.token)) return "Node Token 必须是 ntok_ 开头";
67
+ if (!node.projectDir || !node.projectDir.startsWith("/")) return "项目目录必须是绝对路径";
68
+ return undefined;
69
+ }
70
+
71
+ /** Load nodes, migrating the pre-UI single config once. */
72
+ export function loadNodes(env = process.env) {
73
+ const path = registryPath(env);
74
+ for (const candidate of [path, ...oldRegistryPaths(env)]) {
75
+ if (!existsSync(candidate)) continue;
76
+ try {
77
+ const parsed = JSON.parse(readFileSync(candidate, "utf8"));
78
+ if (Array.isArray(parsed?.nodes)) {
79
+ const nodes = parsed.nodes.map((n) => normalizeNode(n, { id: n.id, sessionId: n.sessionId, projectDir: n.projectDir }));
80
+ if (candidate !== path) saveNodes(nodes, { path });
81
+ return { path, nodes };
82
+ }
83
+ } catch { /* try the next candidate */ }
84
+ }
85
+ let nodes = [];
86
+ for (const candidate of [legacyConfigPath(env), ...oldConfigPaths(env)]) {
87
+ try {
88
+ const legacy = JSON.parse(readFileSync(candidate, "utf8"));
89
+ if (legacy?.alias && legacy?.hub && legacy?.token) {
90
+ nodes = [normalizeNode({ ...legacy, name: legacy.alias, projectDir: process.cwd() })];
91
+ break;
92
+ }
93
+ } catch { /* no legacy config here */ }
94
+ }
95
+ saveNodes(nodes, { path });
96
+ return { path, nodes };
97
+ }
98
+
99
+ export function saveNodes(nodes, { path = registryPath(), env = process.env } = {}) {
100
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
101
+ writeFileSync(path, `${JSON.stringify({ version: 1, nodes }, null, 2)}\n`, { mode: 0o600 });
102
+ chmodSync(path, 0o600);
103
+ }
104
+
105
+ export function findNodeBySession(nodes, sessionId) {
106
+ return nodes.find((node) => node.sessionId === sessionId);
107
+ }
package/src/plugin.mjs ADDED
@@ -0,0 +1,324 @@
1
+ // DSH plugin: exposes one or more DSH sessions as Agent Network (CommHub) nodes.
2
+ // Each node has its own name, hub, token, project directory and dedicated
3
+ // session; tasks for a node run in that node's session so context is preserved.
4
+ import { randomUUID } from "node:crypto";
5
+ import { chmodSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { basename, dirname, join } from "node:path";
8
+ import { installModelSelection } from "@deepseek-ai/dsh-agent";
9
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
10
+ import { defineTool } from "@deepseek-ai/dsh-tools";
11
+ import { createHubClient } from "./hub-client.mjs";
12
+ import { createLedger } from "./ledger.mjs";
13
+ import { createCommhubNode } from "./node-loop.mjs";
14
+ import { commhubToolSpecs } from "./tools.mjs";
15
+ import { summarizeTurn, turnFailure } from "./turn.mjs";
16
+ import { findNodeBySession, loadNodes, normalizeNode, registryPath, saveNodes, validateNode } from "./nodes.mjs";
17
+ import { startConfigServer } from "./ui-server.mjs";
18
+
19
+ export { summarizeTurn } from "./turn.mjs";
20
+
21
+ export const name = "anet";
22
+ export const inject = ["agents", "agentDefaultModel", "sessions", "tools"];
23
+
24
+ const VERSION = "dsh-anet@0.1.0-preview.26";
25
+
26
+ function agentSetup(ctx) {
27
+ const selection = ctx.agentDefaultModel.currentSelection();
28
+ return {
29
+ selection,
30
+ agentOptions: { provider: selection.provider, model: selection.model },
31
+ setup: (agentCtx) => { installModelSelection(agentCtx, { current: selection, assembled: void 0 }); },
32
+ };
33
+ }
34
+
35
+ async function ensureAgent(ctx, node) {
36
+ const live = ctx.agents.get(node.sessionId);
37
+ if (live !== undefined) { await live.whenIdle(); return live; }
38
+ const { agentOptions, setup } = agentSetup(ctx);
39
+ const persistence = ctx.get("sessionPersistence");
40
+ let stored;
41
+ if (persistence !== undefined) {
42
+ try { stored = (await persistence.list()).find((header) => header.id === node.sessionId); } catch { /* no persistence yet */ }
43
+ }
44
+ const handle = stored !== undefined
45
+ ? await ctx.agents.resume({ resumeSessionId: node.sessionId, agentOptions, setup })
46
+ : await ctx.agents.create({ sessionId: node.sessionId, meta: { cwd: node.projectDir }, agentOptions, setup });
47
+ await handle.agent.whenIdle();
48
+ return handle.agent;
49
+ }
50
+
51
+ async function runNodeTurn(ctx, node, prompt) {
52
+ const agent = await ensureAgent(ctx, node);
53
+ const firstSeq = agent.session.seq;
54
+ agent.followup(createUserMessage({ content: [{ type: "text", text: prompt }], source: { kind: "user" } }));
55
+ await agent.whenIdle();
56
+ await ctx.sessions.flush(agent.session);
57
+ const { text, reason, errors } = summarizeTurn(agent.session, firstSeq);
58
+ if (!text && reason && typeof reason === "object" && reason.kind && reason.kind !== "completed") {
59
+ throw new Error(turnFailure(reason, errors));
60
+ }
61
+ return text;
62
+ }
63
+
64
+ export function apply(ctx, config) {
65
+ const log = (m) => console.log(`[anet] ${new Date().toISOString().slice(11, 19)} ${m}`);
66
+ const registry = loadNodes();
67
+ const nodes = registry.nodes;
68
+ const runtimes = new Map();
69
+ let timer = null;
70
+
71
+ if (config?.alias && nodes.length === 0) {
72
+ nodes.push(normalizeNode({ ...config, name: config.alias, projectDir: config.projectDir ?? process.cwd() }));
73
+ saveNodes(nodes, { path: registry.path });
74
+ }
75
+
76
+ function publicNode(node) {
77
+ return {
78
+ id: node.id, name: node.name, hub: node.hub, networkId: node.networkId, token: node.token,
79
+ projectDir: node.projectDir, sessionId: node.sessionId, createdAt: node.createdAt,
80
+ status: runtimes.has(node.id) ? runtimes.get(node.id).status : "stopped",
81
+ lastError: runtimes.get(node.id)?.lastError ?? null,
82
+ };
83
+ }
84
+
85
+ function stopNode(id) {
86
+ const runtime = runtimes.get(id);
87
+ if (!runtime) return;
88
+ runtimes.delete(id);
89
+ void runtime.loop.stop().catch(() => {});
90
+ }
91
+
92
+ function startNode(node) {
93
+ stopNode(node.id);
94
+ const client = createHubClient({ hub: node.hub, token: node.token, networkId: node.networkId || undefined, clientVersion: VERSION });
95
+ const ledgerPath = join(dirname(registry.path), "nodes", node.id, "ledger.json");
96
+ const runtime = { node, client, status: "starting", lastError: null };
97
+ const loop = createCommhubNode({
98
+ client, alias: node.name, ledger: createLedger(ledgerPath), log,
99
+ version: VERSION,
100
+ runTurn: (prompt) => runNodeTurn(ctx, node, prompt),
101
+ });
102
+ runtime.loop = loop;
103
+ runtimes.set(node.id, runtime);
104
+ loop.start().then(() => { runtime.status = "running"; log(`node ${node.name} connected to ${node.hub}`); },
105
+ (error) => { runtime.status = "error"; runtime.lastError = error?.message ?? String(error); log(`node ${node.name} failed: ${runtime.lastError}`); });
106
+ log(`node ${node.name} started → ${node.hub}`);
107
+ }
108
+
109
+ function restartAll() {
110
+ for (const id of [...runtimes.keys()]) stopNode(id);
111
+ for (const node of nodes) startNode(node);
112
+ }
113
+
114
+ function requireNode(id) {
115
+ const node = nodes.find((candidate) => candidate.id === id);
116
+ if (!node) throw new Error(`节点不存在: ${id}`);
117
+ return node;
118
+ }
119
+
120
+ function writeNode(next) {
121
+ const existing = nodes.find((candidate) => candidate.id === next.id);
122
+ const failure = validateNode(next, nodes);
123
+ if (failure) throw new Error(failure);
124
+ if (existing) Object.assign(existing, next);
125
+ else nodes.push(next);
126
+ saveNodes(nodes, { path: registry.path });
127
+ startNode(existing ?? next);
128
+ return publicNode(existing ?? next);
129
+ }
130
+
131
+ async function mintNodeToken(hub, userToken, networkId, name) {
132
+ const res = await fetch(`${hub}/api/auth/node-token`, {
133
+ method: "POST",
134
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${userToken}` },
135
+ body: JSON.stringify({ network_id: networkId, node_name: name }),
136
+ signal: AbortSignal.timeout(30_000),
137
+ });
138
+ const body = await res.json().catch(() => ({}));
139
+ if (!res.ok || body.ok === false || !body.token) throw new Error(body.error ?? `创建节点 token 失败 (HTTP ${res.status})`);
140
+ if (!String(body.token).startsWith("ntok_")) throw new Error("hub 返回了非 ntok_ token");
141
+ return String(body.token);
142
+ }
143
+
144
+ async function login(draft) {
145
+ const hub = String(draft.hub ?? "").trim().replace(/\/+$/, "");
146
+ if (!/^https?:\/\//.test(hub)) throw new Error("Hub 必须是 http(s) 地址");
147
+ if (!draft.username || !draft.password) throw new Error("用户名和密码不能为空");
148
+ const res = await fetch(`${hub}/api/auth/login`, {
149
+ method: "POST",
150
+ headers: { "Content-Type": "application/json" },
151
+ body: JSON.stringify({ username: draft.username, password: draft.password, client_label: "dsh-anet" }),
152
+ signal: AbortSignal.timeout(30_000),
153
+ });
154
+ const body = await res.json().catch(() => ({}));
155
+ if (!res.ok || body.ok === false || !body.token) throw new Error(body.error ?? `登录失败 (HTTP ${res.status})`);
156
+ account = { hub, userToken: String(body.token), user: body.user ?? null, networkId: body.network_id ?? "" };
157
+ saveAccount(account);
158
+ return publicAccount();
159
+ }
160
+
161
+ const accountPath = (process.env.DSH_ANET_ACCOUNT ?? join(homedir(), ".anet", "dsh", "account.json"));
162
+ function loadAccount() {
163
+ try { return JSON.parse(readFileSync(accountPath, "utf8")); } catch { return null; }
164
+ }
165
+ function saveAccount(value) {
166
+ mkdirSync(dirname(accountPath), { recursive: true, mode: 0o700 });
167
+ writeFileSync(accountPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
168
+ chmodSync(accountPath, 0o600);
169
+ }
170
+ let account = loadAccount();
171
+ function publicAccount() {
172
+ return account
173
+ ? { loggedIn: true, hub: account.hub, username: account.user?.username ?? account.username ?? "", networkId: account.networkId ?? "" }
174
+ : { loggedIn: false };
175
+ }
176
+
177
+ async function listProjects() {
178
+ const seen = new Map();
179
+ const add = (path, label) => {
180
+ if (!path || typeof path !== "string" || seen.has(path)) return;
181
+ seen.set(path, { path, label: label || basename(path) || path });
182
+ };
183
+ for (const home of [process.env.DSH_HOME, join(homedir(), "Library", "Application Support", "DeepSeek Harness Desktop", "kernel-home")]) {
184
+ if (!home) continue;
185
+ try {
186
+ const stored = JSON.parse(readFileSync(join(home, "storages", "workspace.json"), "utf8"));
187
+ for (const workspace of Object.values(stored?.tables?.workspaces ?? {})) add(workspace.path, workspace.title);
188
+ } catch { /* no workspace storage here */ }
189
+ }
190
+ const persistence = ctx.get("sessionPersistence");
191
+ if (persistence !== undefined) {
192
+ try { for (const header of await persistence.list()) add(header.cwd ?? header.meta?.cwd); } catch { /* no sessions yet */ }
193
+ }
194
+ add(process.cwd(), "当前目录");
195
+ const projects = [...seen.values()].slice(0, 50);
196
+ return { projects, defaultProject: projects[0]?.path ?? process.cwd() };
197
+ }
198
+
199
+ async function listSessions() {
200
+ const persistence = ctx.get("sessionPersistence");
201
+ const sessions = [];
202
+ if (persistence !== undefined) {
203
+ try {
204
+ for (const header of await persistence.list()) {
205
+ sessions.push({ id: header.id, cwd: header.cwd ?? header.meta?.cwd ?? "", createdAt: header.createdAt ?? null });
206
+ }
207
+ } catch { /* no persisted sessions */ }
208
+ }
209
+ sessions.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
210
+ return { sessions: sessions.slice(0, 200) };
211
+ }
212
+
213
+ async function sessionById(id) {
214
+ return (await listSessions()).sessions.find((session) => session.id === id);
215
+ }
216
+
217
+ function logout() {
218
+ account = null;
219
+ try { unlinkSync(accountPath); } catch { /* already gone */ }
220
+ return { loggedIn: false };
221
+ }
222
+
223
+ async function createNode(draft) {
224
+ let projectDir = draft.projectDir;
225
+ if (draft.sessionId) {
226
+ const session = await sessionById(String(draft.sessionId));
227
+ if (!session) throw new Error(`session 不存在: ${draft.sessionId}`);
228
+ if (nodes.some((node) => node.sessionId === session.id)) throw new Error("这个 session 已经是另一个节点了");
229
+ if (session.cwd) projectDir = session.cwd;
230
+ }
231
+ const base = normalizeNode({ ...draft, projectDir, token: draft.token ?? "" });
232
+ if (!base.token) {
233
+ const userToken = draft.userToken || (account && account.hub === base.hub ? account.userToken : undefined);
234
+ if (!userToken) throw new Error("请先登录人类账号,或提供 Node Token");
235
+ base.token = await mintNodeToken(base.hub, userToken, base.networkId, base.name);
236
+ }
237
+ mkdirSync(base.projectDir, { recursive: true });
238
+ return writeNode(base);
239
+ }
240
+
241
+ async function updateNode(id, draft) {
242
+ const node = requireNode(id);
243
+ let sessionId = node.sessionId;
244
+ let projectDir = String(draft.projectDir ?? node.projectDir);
245
+ if (draft.sessionId !== undefined && String(draft.sessionId) !== node.sessionId) {
246
+ const session = await sessionById(String(draft.sessionId));
247
+ if (!session) throw new Error(`session 不存在: ${draft.sessionId}`);
248
+ if (nodes.some((other) => other.id !== id && other.sessionId === session.id)) throw new Error("这个 session 已经是另一个节点了");
249
+ sessionId = session.id;
250
+ if (session.cwd) projectDir = session.cwd;
251
+ }
252
+ return writeNode({
253
+ ...node, ...draft, id: node.id,
254
+ name: String(draft.name ?? node.name).trim(),
255
+ hub: String(draft.hub ?? node.hub).trim().replace(/\/+$/, ""),
256
+ networkId: String(draft.networkId ?? node.networkId).trim(),
257
+ token: String(draft.token ?? node.token).trim(),
258
+ projectDir,
259
+ sessionId,
260
+ });
261
+ }
262
+
263
+ async function deleteNode(id) {
264
+ const node = requireNode(id);
265
+ const runtime = runtimes.get(id);
266
+ runtimes.delete(id);
267
+ if (runtime) await runtime.loop.stop().catch(() => {});
268
+ nodes.splice(nodes.indexOf(node), 1);
269
+ saveNodes(nodes, { path: registry.path });
270
+ return { nodes: nodes.map(publicNode) };
271
+ }
272
+
273
+ function callerNode() {
274
+ const initiator = ctx.agents.currentInitiator?.();
275
+ const sessionId = initiator?.session?.id;
276
+ const bound = sessionId === undefined ? undefined : findNodeBySession(nodes, sessionId);
277
+ return bound ?? nodes[0];
278
+ }
279
+
280
+ for (const spec of commhubToolSpecs(
281
+ () => { const node = callerNode(); return node ? runtimes.get(node.id)?.client ?? null : null; },
282
+ () => callerNode()?.name ?? "",
283
+ )) {
284
+ ctx.tools.register(defineTool({
285
+ name: spec.name,
286
+ description: spec.description,
287
+ parameters: spec.parameters,
288
+ output: {
289
+ schema: { type: "object", additionalProperties: true, properties: {
290
+ ok: { type: "boolean", required: true }, id: { type: "string", required: true } } },
291
+ render: (_args, value) => [{ type: "text", text: spec.render(value) }],
292
+ },
293
+ async execute(args) {
294
+ const out = await spec.handler(args);
295
+ log(`tool ${spec.name}${args?.alias ? ` → ${args.alias}` : ""}: ${out.id}`);
296
+ return out;
297
+ },
298
+ }));
299
+ }
300
+
301
+ const ui = startConfigServer({
302
+ listNodes: () => ({ nodes: nodes.map(publicNode) }),
303
+ createNode,
304
+ updateNode,
305
+ deleteNode,
306
+ login,
307
+ logout,
308
+ account: publicAccount,
309
+ projects: listProjects,
310
+ sessions: listSessions,
311
+ defaults: () => ({ projectDir: process.cwd(), hub: nodes[0]?.hub ?? "http://127.0.0.1:9200" }),
312
+ log,
313
+ });
314
+
315
+ ctx.effect(() => {
316
+ log(`plugin loaded (${nodes.length} node${nodes.length === 1 ? "" : "s"})`);
317
+ timer = setTimeout(() => { timer = null; restartAll(); }, 300);
318
+ return () => {
319
+ if (timer !== null) clearTimeout(timer);
320
+ for (const id of [...runtimes.keys()]) stopNode(id);
321
+ void ui.close();
322
+ };
323
+ }, "anet.lifecycle");
324
+ }
package/src/tools.mjs ADDED
@@ -0,0 +1,52 @@
1
+ // Tool specs the plugin exposes to the DSH agent. Kept DSH-independent so the
2
+ // handlers can be unit-tested; plugin.mjs wraps them with defineTool().
3
+
4
+ export function commhubToolSpecs(clientOrGet, aliasOrGet) {
5
+ const getClient = typeof clientOrGet === "function" ? clientOrGet : () => clientOrGet;
6
+ const getAlias = typeof aliasOrGet === "function" ? aliasOrGet : () => aliasOrGet;
7
+ const client = () => {
8
+ const c = getClient();
9
+ if (!c) throw new Error("ANet 未配置:请在 Settings → Plugins → ANet 中填写 Hub、Alias 和 Node Token");
10
+ return c;
11
+ };
12
+ return [
13
+ {
14
+ name: "commhub_send_task",
15
+ description: "Send a task to another agent on the Agent Network (CommHub) by alias. The target will reply later; returns the task id.",
16
+ parameters: {
17
+ alias: { type: "string", required: true, description: "Target agent alias (use commhub_get_all_status to list)" },
18
+ task: { type: "string", required: true, description: "Task text" },
19
+ },
20
+ async handler(args) {
21
+ const r = await client().call("send_task", { alias: args.alias, task: args.task, priority: "normal", from_session: getAlias() });
22
+ return { ok: true, id: String(r.message_id ?? r.task_id ?? ""), queued: r.queued === true };
23
+ },
24
+ render: (v) => (v.queued ? `task queued ${v.id} (target offline; delivered when it reconnects)` : `task sent ${v.id}`),
25
+ },
26
+ {
27
+ name: "commhub_send_message",
28
+ description: "Send an informational message to another agent by alias (no reply expected). Returns the message id.",
29
+ parameters: {
30
+ alias: { type: "string", required: true, description: "Target agent alias" },
31
+ message: { type: "string", required: true, description: "Message text" },
32
+ },
33
+ async handler(args) {
34
+ const r = await client().call("send_message", { alias: args.alias, message: args.message, from_session: getAlias() });
35
+ return { ok: true, id: String(r.message_id ?? r.id ?? ""), queued: r.queued === true };
36
+ },
37
+ render: (v) => (v.queued ? `message queued ${v.id} (target offline; delivered when it reconnects)` : `message sent ${v.id}`),
38
+ },
39
+ {
40
+ name: "commhub_get_all_status",
41
+ description: "List agents on the Agent Network with their status, so you can pick a real alias before sending.",
42
+ parameters: {},
43
+ async handler() {
44
+ const r = await client().call("get_all_status", {});
45
+ const rows = Array.isArray(r) ? r : (r.sessions ?? r.agents ?? []);
46
+ const agents = rows.slice(0, 200).map((s) => ({ alias: String(s.alias ?? ""), status: String(s.status ?? ""), agent: String(s.agent ?? "") }));
47
+ return { ok: true, id: `${agents.length}`, agents };
48
+ },
49
+ render: (v) => (v.agents ?? []).map((a) => `${a.alias} (${a.status})`).join("\n") || "no agents",
50
+ },
51
+ ];
52
+ }