@sleep2agi/commhub-server 0.9.0-preview.56 → 0.9.0-preview.58

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.56",
3
+ "version": "0.9.0-preview.58",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/db.ts CHANGED
@@ -127,6 +127,9 @@ for (const col of [
127
127
  // that answers the rules-file doorbell. Sticky: set by a bound node token, never
128
128
  // cleared by reports that omit it (agent-node status-only reports do).
129
129
  { name: "rules_file_capable", def: "INTEGER NOT NULL DEFAULT 0" },
130
+ // Node skills view — the same session answers skills_list / skill_read on the
131
+ // rules-file doorbell (node-skills.ts). Sticky, bound-token only, like above.
132
+ { name: "skills_capable", def: "INTEGER NOT NULL DEFAULT 0" },
130
133
  ]) {
131
134
  try { db.exec(`ALTER TABLE sessions ADD COLUMN ${col.name} ${col.def}`); } catch {}
132
135
  }
@@ -0,0 +1,209 @@
1
+ // Node skills view — list_node_skills / read_node_skill ride the rules-file
2
+ // queue + doorbell (ops skills_list / skill_read).
3
+ //
4
+ // Hub-side contract verified here:
5
+ // 1. report_status { skills_capable: true } is sticky and set only by the
6
+ // session's own bound node token.
7
+ // 2. list_node_skills / read_node_skill reach a session with no nodes row by
8
+ // alias; the node pulls with its own token; read carries the skill NAME
9
+ // (never a path) in the pulled request; results come back through
10
+ // get_rules_file_result for both ops.
11
+ // 3. Bad skill names are refused before any request row exists.
12
+ // 4. A session that can serve rules files but did not report skills_capable is
13
+ // refused up front for skills ops (skills_target_not_found).
14
+ // 5. Separate single-flight lanes: a pending rules read does not block a skills
15
+ // list and vice versa; a second skills request while one is pending is refused.
16
+ // 6. Network isolation and foreign acks are ignored.
17
+ //
18
+ // 跑法:cd server && COMMHUB_DB=/tmp/skills-session.db bun test src/node-skills-transport.test.ts
19
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
20
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
21
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
22
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
23
+ import { db } from "./db.js";
24
+ import { registerTools } from "./tools.js";
25
+
26
+ const NET = "net_skills_session";
27
+ const OTHER_NET = "net_skills_session_other";
28
+ const USER = "u_skills_session_owner";
29
+ const SESSION_ALIAS = "skills-session-cc";
30
+ const SESSION_RESUME = "cc-skills-session-1";
31
+ const OTHER_ALIAS = "skills-session-peer";
32
+ const NODE_ROW_ALIAS = "skills-session-noderow";
33
+ const NODE_ROW_ID = "node_skills_session_noderow";
34
+ const T_SESSION = "tok_skills_session_cc";
35
+ const T_PEER = "tok_skills_session_peer";
36
+ const T_OTHER_NET_SAME_ALIAS = "tok_skills_session_other_net";
37
+ const T_NODE_ROW = "tok_skills_session_noderow";
38
+
39
+ function cleanup() {
40
+ for (const net of [NET, OTHER_NET]) {
41
+ try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [net]); } catch {}
42
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [net]); } catch {}
43
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [net]); } catch {}
44
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [net]); } catch {}
45
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [net]); } catch {}
46
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
47
+ }
48
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
49
+ }
50
+
51
+ function seedWorld() {
52
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER, USER]);
53
+ for (const net of [NET, OTHER_NET]) {
54
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [net, net, USER]);
55
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, net]);
56
+ }
57
+ const tok = (id: string, net: string, alias: string) => db.run(
58
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
59
+ [id, USER, net, `node:${alias}`, `hash_${id}`],
60
+ );
61
+ tok(T_SESSION, NET, SESSION_ALIAS);
62
+ tok(T_PEER, NET, OTHER_ALIAS);
63
+ tok(T_OTHER_NET_SAME_ALIAS, OTHER_NET, SESSION_ALIAS);
64
+ tok(T_NODE_ROW, NET, NODE_ROW_ALIAS);
65
+ db.run(
66
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
67
+ [NODE_ROW_ID, NODE_ROW_ALIAS, NODE_ROW_ALIAS, NET],
68
+ );
69
+ }
70
+
71
+ type Identity = { net: string; alias: string; isNetworkToken: boolean; tokenId: string };
72
+ const asUser: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_skills_session_user" };
73
+ const asOtherNetUser: Identity = { net: OTHER_NET, alias: USER, isNetworkToken: false, tokenId: "tok_skills_session_user2" };
74
+ const asSession: Identity = { net: NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_SESSION };
75
+ const asPeer: Identity = { net: NET, alias: OTHER_ALIAS, isNetworkToken: true, tokenId: T_PEER };
76
+ const asOtherNetSameAlias: Identity = { net: OTHER_NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_OTHER_NET_SAME_ALIAS };
77
+ const asNodeRow: Identity = { net: NET, alias: NODE_ROW_ALIAS, isNetworkToken: true, tokenId: T_NODE_ROW };
78
+
79
+ async function connect(id: Identity) {
80
+ const server = new McpServer({ name: "skills-session-test", version: "1" });
81
+ registerTools(server, undefined, id.net, USER, id.alias, id.isNetworkToken, id.tokenId);
82
+ const client = new Client({ name: "skills-session-client", version: "1" });
83
+ const [ct, st] = InMemoryTransport.createLinkedPair();
84
+ await server.connect(st);
85
+ await client.connect(ct);
86
+ const call = async (name: string, args: Record<string, unknown>) => {
87
+ const r: any = await client.callTool({ name, arguments: args });
88
+ const first = r.content?.[0];
89
+ if (!first || first.type !== "text") throw new Error(`no text result from ${name}: ${JSON.stringify(r).slice(0, 300)}`);
90
+ return JSON.parse(first.text);
91
+ };
92
+ const close = async () => { await client.close(); await server.close(); };
93
+ return { call, close };
94
+ }
95
+
96
+ const capable = (alias: string, net = NET) =>
97
+ db.get<{ c: number }>("SELECT skills_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, net)?.c;
98
+ const rowCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM node_rules_requests WHERE network_id = ?1", NET)!.n;
99
+
100
+ async function registerSession(id: Identity, alias: string, resume: string, flag: boolean) {
101
+ const c = await connect(id);
102
+ try {
103
+ return await c.call("report_status", { resume_id: resume, alias, status: "idle", agent: "claude-code", ...(flag ? { rules_file_capable: true, skills_capable: true } : {}) });
104
+ } finally { await c.close(); }
105
+ }
106
+
107
+ beforeEach(() => { cleanup(); seedWorld(); });
108
+ afterAll(cleanup);
109
+
110
+ async function reportRulesOnly(id: Identity, alias: string, resume: string) {
111
+ const c = await connect(id);
112
+ try { return await c.call("report_status", { resume_id: resume, alias, status: "idle", agent: "claude-code", rules_file_capable: true }); }
113
+ finally { await c.close(); }
114
+ }
115
+
116
+ describe("skills_capable is set only by the session's own node token", () => {
117
+ test("bound token sets it; a report without it does not clear it; a peer cannot set it", async () => {
118
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
119
+ expect(capable(SESSION_ALIAS)).toBe(1);
120
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, false);
121
+ expect(capable(SESSION_ALIAS)).toBe(1);
122
+ const r = await registerSession(asPeer, SESSION_ALIAS, SESSION_RESUME, true);
123
+ expect(r).toMatchObject({ ok: false });
124
+ }, 20_000);
125
+ });
126
+
127
+ describe("list / read by alias reach a session with no nodes row", () => {
128
+ test("list round trip: enqueue → pull (no content) → ack JSON → result carries it", async () => {
129
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
130
+ const u = await connect(asUser);
131
+ const s = await connect(asSession);
132
+ try {
133
+ const enq = await u.call("list_node_skills", { alias: SESSION_ALIAS });
134
+ expect(enq).toMatchObject({ ok: true, op: "skills_list" });
135
+ const pulled = await s.call("get_rules_file_request", {});
136
+ expect(pulled.request).toEqual({ request_id: enq.request_id, op: "skills_list" });
137
+ const payload = JSON.stringify({ skills: [{ name: "ship", scope: "project", path_rel: ".claude/skills/ship/SKILL.md", description: "d" }] });
138
+ expect(await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "skills", exists: true, content: payload })).toMatchObject({ ok: true, status: "done" });
139
+ const res = await u.call("get_rules_file_result", { request_id: enq.request_id });
140
+ expect(res).toMatchObject({ ok: true, op: "skills_list", status: "done", content: payload });
141
+ } finally { await u.close(); await s.close(); }
142
+ }, 20_000);
143
+
144
+ test("read round trip: the pulled request carries the skill name only", async () => {
145
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
146
+ const u = await connect(asUser);
147
+ const s = await connect(asSession);
148
+ try {
149
+ const enq = await u.call("read_node_skill", { alias: SESSION_ALIAS, name: "ship" });
150
+ expect(enq).toMatchObject({ ok: true, op: "skill_read" });
151
+ const pulled = await s.call("get_rules_file_request", {});
152
+ expect(pulled.request).toEqual({ request_id: enq.request_id, op: "skill_read", content: "ship" });
153
+ const payload = JSON.stringify({ name: "ship", scope: "project", path_rel: ".claude/skills/ship/SKILL.md", description: "d", content: "# ship" });
154
+ await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "ship", exists: true, content: payload });
155
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "done", content: payload });
156
+ } finally { await u.close(); await s.close(); }
157
+ }, 20_000);
158
+
159
+ test("bad names are refused before any row is written", async () => {
160
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
161
+ const u = await connect(asUser);
162
+ try {
163
+ for (const name of ["..", ".", "../CLAUDE.md", "a/b", "a b"]) {
164
+ expect(await u.call("read_node_skill", { alias: SESSION_ALIAS, name })).toMatchObject({ ok: false, error: "invalid_skill_name" });
165
+ }
166
+ expect(rowCount()).toBe(0);
167
+ } finally { await u.close(); }
168
+ }, 20_000);
169
+
170
+ test("rules-capable but not skills-capable → skills_target_not_found, no row", async () => {
171
+ await reportRulesOnly(asSession, SESSION_ALIAS, SESSION_RESUME);
172
+ const u = await connect(asUser);
173
+ try {
174
+ expect(await u.call("list_node_skills", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "skills_target_not_found" });
175
+ expect(rowCount()).toBe(0);
176
+ expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: true });
177
+ } finally { await u.close(); }
178
+ }, 20_000);
179
+ });
180
+
181
+ describe("single-flight lanes", () => {
182
+ test("a pending rules read does not block a skills list; a second skills request is refused", async () => {
183
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
184
+ const u = await connect(asUser);
185
+ try {
186
+ expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: true });
187
+ const list = await u.call("list_node_skills", { alias: SESSION_ALIAS });
188
+ expect(list).toMatchObject({ ok: true });
189
+ expect(await u.call("read_node_skill", { alias: SESSION_ALIAS, name: "ship" })).toMatchObject({ ok: false, error: "request_in_flight", existing_request_id: list.request_id });
190
+ expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "request_in_flight" });
191
+ } finally { await u.close(); }
192
+ }, 20_000);
193
+ });
194
+
195
+ describe("isolation", () => {
196
+ test("same alias in another network neither pulls nor acks; a peer's ack is ignored", async () => {
197
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
198
+ const u = await connect(asUser);
199
+ const other = await connect(asOtherNetSameAlias);
200
+ const peer = await connect(asPeer);
201
+ try {
202
+ const enq = await u.call("list_node_skills", { alias: SESSION_ALIAS });
203
+ expect((await other.call("get_rules_file_request", {})).request).toBeNull();
204
+ expect(await other.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
205
+ expect(await peer.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
206
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
207
+ } finally { await u.close(); await other.close(); await peer.close(); }
208
+ }, 20_000);
209
+ });
@@ -45,7 +45,7 @@ const GOLDEN_RESPONSE_KEYS = {
45
45
  "process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
46
46
  "process_in_flight_count", "network_id", "registered_at", "updated_at",
47
47
  "node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
48
- "external_schedules", "rules_file_capable", "runtime", "host", "process_telemetry",
48
+ "external_schedules", "rules_file_capable", "skills_capable", "runtime", "host", "process_telemetry",
49
49
  ],
50
50
  task: [
51
51
  "task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
@@ -19,7 +19,7 @@ export const SESSION_REST_COLUMNS = [
19
19
  "process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
20
20
  "process_in_flight_count", "network_id", "registered_at", "updated_at",
21
21
  "node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
22
- "external_schedules", "rules_file_capable",
22
+ "external_schedules", "rules_file_capable", "skills_capable",
23
23
  ] as const;
24
24
 
25
25
  export const AUDIT_LOG_REST_COLUMNS = [
@@ -0,0 +1,234 @@
1
+ // Duplicate-alias node rows vs the node pull/ack paths (field report).
2
+ //
3
+ // Re-creating a node under the same alias leaves the old `nodes` row behind, so
4
+ // one alias can own several rows in a network. The pull/ack tools used to
5
+ // resolve the caller by `WHERE alias = ? AND network_id = ?` + `db.get`, i.e.
6
+ // the first row SQLite yields — the stale one. Field shape: the client queued a
7
+ // rules-file read for the live node, the node pulled with its own (bound)
8
+ // token, the hub looked under the stale node_id, answered `request: null`, and
9
+ // the read timed out with only `[rules-file] doorbell received` in the node log.
10
+ //
11
+ // 跑法:cd server && COMMHUB_DB=/tmp/rules-dup.db bun test src/rules-file-duplicate-alias.test.ts
12
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
14
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { db } from "./db.js";
17
+ import { registerTools } from "./tools.js";
18
+
19
+ const NET = "net_rules_dup";
20
+ const USER = "u_rules_dup_owner";
21
+ const ALIAS = "rules-dup-node";
22
+ // Inserted first → the row a plain `db.get` on the alias returns.
23
+ const STALE_ID = "node_rules_dup_stale";
24
+ const LIVE_ID = "node_rules_dup_live";
25
+ const STALE_TOKEN = "tok_rules_dup_stale";
26
+ const LIVE_TOKEN = "tok_rules_dup_live";
27
+ const UNBOUND_TOKEN = "tok_rules_dup_unbound";
28
+
29
+ function cleanup() {
30
+ try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [NET]); } catch {}
31
+ try { db.run("DELETE FROM node_config_updates WHERE network_id = ?1", [NET]); } catch {}
32
+ try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [NET]); } catch {}
33
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [NET]); } catch {}
34
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
35
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET]); } catch {}
36
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
37
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
38
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
39
+ }
40
+
41
+ function seedWorld(opts: { liveSession?: boolean } = {}) {
42
+ db.run(
43
+ `INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
44
+ [USER, USER],
45
+ );
46
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [NET, NET, USER]);
47
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, NET]);
48
+ // Stale row first (older created_at AND lower rowid), live row second.
49
+ db.run(
50
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, '2026-09-16 01:40:40', '2026-09-16 01:40:40')`,
51
+ [STALE_ID, ALIAS, ALIAS, NET],
52
+ );
53
+ db.run(
54
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, '2026-09-16 01:42:46', '2026-09-16 01:42:46')`,
55
+ [LIVE_ID, ALIAS, ALIAS, NET],
56
+ );
57
+ for (const [tok, bound, hash] of [
58
+ [STALE_TOKEN, STALE_ID, "hash_rules_dup_stale"],
59
+ [LIVE_TOKEN, LIVE_ID, "hash_rules_dup_live"],
60
+ [UNBOUND_TOKEN, null, "hash_rules_dup_unbound"],
61
+ ] as const) {
62
+ db.run(
63
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at, bound_node_id) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL, ?6)`,
64
+ [tok, USER, NET, `node:${ALIAS}`, hash, bound],
65
+ );
66
+ }
67
+ if (opts.liveSession !== false) {
68
+ db.run(
69
+ `INSERT INTO sessions (resume_id, alias, network_id, node_id, status, updated_at) VALUES (?1, ?2, ?3, ?4, 'idle', datetime('now'))`,
70
+ [`sdk-${LIVE_ID}`, ALIAS, NET, LIVE_ID],
71
+ );
72
+ }
73
+ }
74
+
75
+ type Identity = { alias: string; isNetworkToken: boolean; tokenId: string };
76
+ const asUser: Identity = { alias: USER, isNetworkToken: false, tokenId: "tok_rules_dup_user" };
77
+ const asLive: Identity = { alias: ALIAS, isNetworkToken: true, tokenId: LIVE_TOKEN };
78
+ const asStale: Identity = { alias: ALIAS, isNetworkToken: true, tokenId: STALE_TOKEN };
79
+ const asUnbound: Identity = { alias: ALIAS, isNetworkToken: true, tokenId: UNBOUND_TOKEN };
80
+
81
+ async function connect(id: Identity) {
82
+ const server = new McpServer({ name: "rules-dup-test", version: "1" });
83
+ registerTools(server, undefined, NET, USER, id.alias, id.isNetworkToken, id.tokenId);
84
+ const client = new Client({ name: "rules-dup-client", version: "1" });
85
+ const [ct, st] = InMemoryTransport.createLinkedPair();
86
+ await server.connect(st);
87
+ await client.connect(ct);
88
+ const call = async (name: string, args: Record<string, unknown>) => {
89
+ const r: any = await client.callTool({ name, arguments: args });
90
+ const first = r.content?.[0];
91
+ if (!first || first.type !== "text") throw new Error(`no text result from ${name}`);
92
+ return JSON.parse(first.text);
93
+ };
94
+ const close = async () => { await client.close(); await server.close(); };
95
+ return { call, close };
96
+ }
97
+
98
+ function queuedNodeId(requestId: string): string | undefined {
99
+ return db.get<{ node_id: string }>("SELECT node_id FROM node_rules_requests WHERE request_id = ?1", requestId)?.node_id;
100
+ }
101
+
102
+ beforeEach(() => { cleanup(); });
103
+ afterAll(cleanup);
104
+
105
+ describe("duplicate alias — rules file pull/ack", () => {
106
+ test("field case: read queued for the live node_id is pulled and acked by the live node's bound token", async () => {
107
+ seedWorld();
108
+ const u = await connect(asUser);
109
+ const n = await connect(asLive);
110
+ try {
111
+ const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
112
+ expect(enq).toMatchObject({ ok: true, op: "read" });
113
+ const pulled = await n.call("get_rules_file_request", {});
114
+ expect(pulled).toEqual({ ok: true, request: { request_id: enq.request_id, op: "read" } });
115
+ const ack = await n.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "AGENTS.md", exists: true, content: "# live" });
116
+ expect(ack).toMatchObject({ ok: true, status: "done" });
117
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "done", content: "# live" });
118
+ } finally { await u.close(); await n.close(); }
119
+ }, 20_000);
120
+
121
+ test("a token bound to the stale row can neither pull nor ack the live node's request", async () => {
122
+ seedWorld();
123
+ const u = await connect(asUser);
124
+ const stale = await connect(asStale);
125
+ try {
126
+ const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
127
+ expect(await stale.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
128
+ expect(await stale.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "# forged" }))
129
+ .toEqual({ ok: true, ignored: "unknown_or_foreign_request" });
130
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
131
+ } finally { await u.close(); await stale.close(); }
132
+ }, 20_000);
133
+
134
+ test("the live token does not pull a request queued for the stale row", async () => {
135
+ seedWorld();
136
+ const u = await connect(asUser);
137
+ const n = await connect(asLive);
138
+ try {
139
+ await u.call("read_node_rules_file", { node_id: STALE_ID });
140
+ expect(await n.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
141
+ } finally { await u.close(); await n.close(); }
142
+ }, 20_000);
143
+
144
+ test("client targeting by alias queues under the row the alias's session points at", async () => {
145
+ seedWorld();
146
+ const u = await connect(asUser);
147
+ try {
148
+ const enq = await u.call("read_node_rules_file", { alias: ALIAS });
149
+ expect(enq).toMatchObject({ ok: true, op: "read" });
150
+ expect(queuedNodeId(enq.request_id)).toBe(LIVE_ID);
151
+ const sk = await u.call("list_node_skills", { alias: ALIAS });
152
+ expect(sk).toMatchObject({ ok: true });
153
+ expect(queuedNodeId(sk.request_id)).toBe(LIVE_ID);
154
+ } finally { await u.close(); }
155
+ }, 20_000);
156
+
157
+ test("without a session row, alias targeting falls back to the newest row", async () => {
158
+ seedWorld({ liveSession: false });
159
+ const u = await connect(asUser);
160
+ try {
161
+ const enq = await u.call("read_node_rules_file", { alias: ALIAS });
162
+ expect(queuedNodeId(enq.request_id)).toBe(LIVE_ID);
163
+ } finally { await u.close(); }
164
+ }, 20_000);
165
+
166
+ test("the session mapping wins over recency: alias targeting picks the older row when the session points at it", async () => {
167
+ seedWorld({ liveSession: false });
168
+ db.run(
169
+ `INSERT INTO sessions (resume_id, alias, network_id, node_id, status, updated_at) VALUES (?1, ?2, ?3, ?4, 'idle', datetime('now'))`,
170
+ [`sdk-${STALE_ID}`, ALIAS, NET, STALE_ID],
171
+ );
172
+ const u = await connect(asUser);
173
+ try {
174
+ const enq = await u.call("read_node_rules_file", { alias: ALIAS });
175
+ expect(queuedNodeId(enq.request_id)).toBe(STALE_ID);
176
+ } finally { await u.close(); }
177
+ }, 20_000);
178
+
179
+ test("a token bound to a node whose row is gone does not fall back to another row with its alias", async () => {
180
+ seedWorld();
181
+ db.run("UPDATE api_tokens SET bound_node_id = 'node_rules_dup_deleted' WHERE token_id = ?1", [STALE_TOKEN]);
182
+ const u = await connect(asUser);
183
+ const stale = await connect(asStale);
184
+ try {
185
+ const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
186
+ expect(await stale.call("get_rules_file_request", {})).toEqual({ ok: true, request: null });
187
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
188
+ } finally { await u.close(); await stale.close(); }
189
+ }, 20_000);
190
+
191
+ test("an unbound legacy token resolves its alias to the session-mapped row", async () => {
192
+ seedWorld();
193
+ const u = await connect(asUser);
194
+ const n = await connect(asUnbound);
195
+ try {
196
+ const enq = await u.call("read_node_rules_file", { node_id: LIVE_ID });
197
+ expect(await n.call("get_rules_file_request", {})).toEqual({ ok: true, request: { request_id: enq.request_id, op: "read" } });
198
+ } finally { await u.close(); await n.close(); }
199
+ }, 20_000);
200
+ });
201
+
202
+ describe("duplicate alias — config update pull/ack", () => {
203
+ function queueUpdate(nodeId: string, updateId: string) {
204
+ db.run(
205
+ `INSERT INTO node_config_updates (update_id, node_id, network_id, patch_json, apply_mode, base_revision, status, created_at, created_by_token) VALUES (?1, ?2, ?3, '{"model":"m"}', 'hot', 0, 'pending', ?4, 'tok_rules_dup_user')`,
206
+ [updateId, nodeId, NET, Date.now()],
207
+ );
208
+ }
209
+
210
+ test("the live node's bound token pulls and acks its own pending update", async () => {
211
+ seedWorld();
212
+ queueUpdate(LIVE_ID, "cu_rules_dup_live");
213
+ const n = await connect(asLive);
214
+ try {
215
+ const pulled = await n.call("get_config_update", {});
216
+ expect(pulled.update?.update_id).toBe("cu_rules_dup_live");
217
+ const ack = await n.call("ack_config_update", { update_id: "cu_rules_dup_live", status: "applied", new_revision: 1 });
218
+ expect(ack.ok).toBe(true);
219
+ expect(ack.ignored).toBeUndefined();
220
+ expect(db.get<{ status: string }>("SELECT status FROM node_config_updates WHERE update_id = 'cu_rules_dup_live'")?.status).toBe("applied");
221
+ } finally { await n.close(); }
222
+ }, 20_000);
223
+
224
+ test("a token bound to the stale row cannot pull or ack the live node's update", async () => {
225
+ seedWorld();
226
+ queueUpdate(LIVE_ID, "cu_rules_dup_live2");
227
+ const stale = await connect(asStale);
228
+ try {
229
+ expect((await stale.call("get_config_update", {})).update).toBeNull();
230
+ await stale.call("ack_config_update", { update_id: "cu_rules_dup_live2", status: "applied", new_revision: 1 });
231
+ expect(db.get<{ status: string }>("SELECT status FROM node_config_updates WHERE update_id = 'cu_rules_dup_live2'")?.status).toBe("pending");
232
+ } finally { await stale.close(); }
233
+ }, 20_000);
234
+ });
package/src/server.ts CHANGED
@@ -1682,6 +1682,7 @@ return Bun.serve({
1682
1682
  external_schedules: externalSchedules,
1683
1683
  // app#225 follow-up — sqlite INTEGER 0/1 → boolean on the wire.
1684
1684
  rules_file_capable: s.rules_file_capable === 1,
1685
+ skills_capable: s.skills_capable === 1,
1685
1686
  model: s.model ?? null,
1686
1687
  runtime: normalizeRuntime(s.agent),
1687
1688
  host: {
@@ -126,6 +126,17 @@ describe("task consumed_at identity and lifecycle", () => {
126
126
  expect([...first.tasks, ...second.tasks].some((task: any) => task.task_id === "foreign-node-row")).toBe(false);
127
127
  expect((await call(handler, { from_node_id: `id_${NODE_B}`, durable_cursor: true, limit: 100 })).error).toBe("from_node_id_identity_mismatch");
128
128
  });
129
+ test("a legacy unbound node token is refused the durable cursor even for its own node", async () => {
130
+ // Pre-RFC-036 tokens carry no immutable node binding. The durable outbox
131
+ // cursor must stay closed to them (no bind-on-first-use); agent-node
132
+ // degrades to inbox-only compensation on this exact error code.
133
+ db.run("UPDATE api_tokens SET bound_node_id = NULL WHERE token_id = ?1", [`token_${NODE_A}`]);
134
+ const handler = toolsFor({ alias: NODE_A, nodeToken: true }).list_tasks;
135
+ expect((await call(handler, { from_node_id: `id_${NODE_A}`, durable_cursor: true, limit: 10 })).error).toBe("from_node_id_identity_mismatch");
136
+ expect((await call(handler, { from_node_id: `id_${NODE_B}`, durable_cursor: true, limit: 10 })).error).toBe("from_node_id_identity_mismatch");
137
+ const bound = db.get<{ bound_node_id: string | null }>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", `token_${NODE_A}`);
138
+ expect(bound?.bound_node_id).toBeNull();
139
+ });
129
140
  test("terminal sequence paginates beyond 2000 and admits late completion of an old task", async () => {
130
141
  db.run(
131
142
  `INSERT INTO tasks (task_id, from_node_id, from_name, to_name, status, content, network_id, created_at)
package/src/tools.ts CHANGED
@@ -719,8 +719,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
719
719
  // no `nodes` row, and config_snapshot is persisted per node_id. Only a
720
720
  // node token bound to this same alias can set it; see the UPDATE below.
721
721
  rules_file_capable: z.literal(true).optional(),
722
+ // Node skills view — same doorbell, ops skills_list / skill_read.
723
+ skills_capable: z.literal(true).optional(),
722
724
  },
723
- async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable }) => {
725
+ async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host, process_telemetry: proc, external_schedules: externalSchedules, config_snapshot: cfgSnap, rules_file_capable: rulesFileCapable, skills_capable: skillsCapable }) => {
724
726
  const effectiveNetId = getNetworkId(netId);
725
727
  const sessionNetId = effectiveNetId ?? "default";
726
728
  if (!callerTokenIsNetwork || !enforceNetworkId) {
@@ -926,6 +928,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
926
928
  if (rulesFileCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
927
929
  db.run("UPDATE sessions SET rules_file_capable = 1 WHERE resume_id = ?1", [resume_id]);
928
930
  }
931
+ if (skillsCapable === true && callerTokenIsNetwork && callerAlias && callerAlias === effectiveAlias) {
932
+ db.run("UPDATE sessions SET skills_capable = 1 WHERE resume_id = ?1", [resume_id]);
933
+ }
929
934
  if (host || proc) {
930
935
  db.run(
931
936
  `INSERT INTO agent_telemetry (id, network_id, resume_id, alias, hostname, ip, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, disk_total_gb, disk_used_gb, disk_avail_gb, process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count, created_at)
@@ -2977,6 +2982,51 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2977
2982
  },
2978
2983
  );
2979
2984
 
2985
+ // One alias can own several `nodes` rows in a network: re-creating a node
2986
+ // under the same alias leaves the old row behind, and `db.get` on
2987
+ // `WHERE alias = ? AND network_id = ?` then returns whichever row SQLite
2988
+ // happens to yield first. A field report showed the result: the client
2989
+ // queued a rules-file read for the live node, the node pulled with its own
2990
+ // token, the hub resolved its alias to the stale row, found nothing pending
2991
+ // and returned `request: null` until the read timed out.
2992
+ //
2993
+ // resolveNodeByAlias: prefer the row the alias's session points at (sessions
2994
+ // are UNIQUE per network+alias and carry the node_id that last reported),
2995
+ // then the newest row. Used where only an alias is known (a client targeting
2996
+ // by alias; unbound legacy node tokens).
2997
+ const resolveNodeByAlias = (alias: string, networkId: string) =>
2998
+ db.get<{ node_id: string; alias: string; network_id: string | null }>(
2999
+ `SELECT n.node_id, n.alias, n.network_id FROM nodes n
3000
+ LEFT JOIN sessions s ON s.alias = n.alias AND s.network_id = n.network_id AND s.node_id = n.node_id
3001
+ WHERE n.alias = ?1 AND n.network_id = ?2
3002
+ ORDER BY (s.node_id IS NOT NULL) DESC, n.created_at DESC, n.rowid DESC
3003
+ LIMIT 1`,
3004
+ alias,
3005
+ networkId,
3006
+ ) ?? null;
3007
+ // resolveCallerNode: the node a network token pulls/acks for. A token bound
3008
+ // to a node resolves to exactly that node (and never to another row that
3009
+ // shares its alias); only unbound legacy tokens fall back to the alias.
3010
+ // Callers have already required callerTokenIsNetwork + enforceNetworkId +
3011
+ // callerAlias.
3012
+ const resolveCallerNode = (): { node_id: string; network_id: string | null } | null => {
3013
+ const bound = callerTokenId
3014
+ ? db.get<{ bound_node_id: string | null }>(
3015
+ "SELECT bound_node_id FROM api_tokens WHERE token_id = ?1 AND network_id = ?2",
3016
+ callerTokenId,
3017
+ enforceNetworkId,
3018
+ )?.bound_node_id ?? null
3019
+ : null;
3020
+ if (bound) {
3021
+ return db.get<{ node_id: string; network_id: string | null }>(
3022
+ "SELECT node_id, network_id FROM nodes WHERE node_id = ?1 AND network_id = ?2",
3023
+ bound,
3024
+ enforceNetworkId,
3025
+ ) ?? null;
3026
+ }
3027
+ return resolveNodeByAlias(callerAlias!, enforceNetworkId!);
3028
+ };
3029
+
2980
3030
  server.tool(
2981
3031
  "get_config_update",
2982
3032
  "Node pulls its pending config update (called from agent-node when SSE config_update doorbell arrives). RFC-024.",
@@ -3000,11 +3050,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3000
3050
  // Unconditional network_id filter (was previously conditional on
3001
3051
  // enforceNetworkId being set; the new ntok guard above guarantees
3002
3052
  // it's non-null so the filter is always applied).
3003
- const node = db.get<any>(
3004
- "SELECT node_id, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
3005
- callerAlias,
3006
- enforceNetworkId,
3007
- );
3053
+ const node = resolveCallerNode();
3008
3054
  if (!node) {
3009
3055
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, update: null }) }] };
3010
3056
  }
@@ -3057,11 +3103,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3057
3103
  // Cross-tenant guard: the ack-er must own the update being acked.
3058
3104
  // Resolve node by caller's alias under the enforced network.
3059
3105
  // Network filter is unconditional (guard above guarantees non-null).
3060
- const node = db.get<any>(
3061
- "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
3062
- callerAlias,
3063
- enforceNetworkId,
3064
- );
3106
+ const node = resolveCallerNode();
3065
3107
  if (!node) {
3066
3108
  // Silently ignore stale ack — return ok so the node doesn't retry forever.
3067
3109
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, ignored: "alias_unknown" }) }] };
@@ -3113,12 +3155,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3113
3155
  const RULES_FILE_MAX_BYTES = 256 * 1024;
3114
3156
  const RULES_REQUEST_STALE_MS = 60_000;
3115
3157
 
3158
+ // Node skills view — skills_list / skill_read ride the same queue + doorbell.
3159
+ // `content` carries the skill NAME for skill_read (never a path; the node
3160
+ // validates it again against [A-Za-z0-9._-] and resolves it under its own
3161
+ // runtime's skills roots — node-skills.ts).
3162
+ const SKILL_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/;
3163
+ const isSkillsOp = (op: string) => op === "skills_list" || op === "skill_read";
3164
+
3116
3165
  const enqueueRulesFileRequest = (
3117
- op: "read" | "write",
3166
+ op: "read" | "write" | "skills_list" | "skill_read",
3118
3167
  a: { node_id?: string; child_node_id?: string; alias?: string; network_id?: string; content?: string },
3119
3168
  ) => {
3120
3169
  const effectiveNetId = getNetworkId(a.network_id);
3121
- if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, `${op} rules file`);
3170
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, isSkillsOp(op) ? `${op} node skills` : `${op} rules file`);
3171
+ if (op === "skill_read" && (typeof a.content !== "string" || !SKILL_NAME_RE.test(a.content) || a.content === "." || a.content === "..")) {
3172
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "invalid_skill_name", reason: "name must match [A-Za-z0-9._-]{1,64} and not be . or .." }) }] };
3173
+ }
3122
3174
 
3123
3175
  // Target: a `nodes` row by node_id (original path), or — app#225 follow-up —
3124
3176
  // an alias. An alias resolves to its `nodes` row when there is one; otherwise
@@ -3140,21 +3192,21 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3140
3192
  node = resolved.row as any;
3141
3193
  } else {
3142
3194
  const scopeNet = effectiveNetId || "default";
3143
- const byAlias = db.get<{ node_id: string; alias: string; network_id: string | null }>(
3144
- "SELECT node_id, alias, network_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
3145
- a.alias,
3146
- scopeNet,
3147
- );
3195
+ const byAlias = resolveNodeByAlias(a.alias, scopeNet);
3148
3196
  if (byAlias) {
3149
3197
  node = byAlias;
3150
3198
  } else {
3151
3199
  const session = db.get<{ alias: string; network_id: string | null }>(
3152
- "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND rules_file_capable = 1 ORDER BY updated_at DESC LIMIT 1",
3200
+ isSkillsOp(op)
3201
+ ? "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND skills_capable = 1 ORDER BY updated_at DESC LIMIT 1"
3202
+ : "SELECT alias, network_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND rules_file_capable = 1 ORDER BY updated_at DESC LIMIT 1",
3153
3203
  a.alias,
3154
3204
  scopeNet,
3155
3205
  );
3156
3206
  if (!session) {
3157
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "rules_file_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve rules files (its channel server may be too old)" }) }] };
3207
+ return isSkillsOp(op)
3208
+ ? { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "skills_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve its skills (its channel server may be too old)" }) }] }
3209
+ : { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "rules_file_target_not_found", alias: a.alias, message: "no node with this alias in the network, and no session with this alias that can serve rules files (its channel server may be too old)" }) }] };
3158
3210
  }
3159
3211
  node = { node_id: `session:${session.alias}`, alias: session.alias, network_id: session.network_id };
3160
3212
  }
@@ -3172,8 +3224,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3172
3224
 
3173
3225
  // 单飞 + 陈旧回收,同 update_node_config 的 F-B:节点掉线时旧行永远
3174
3226
  // 非终态,不回收就把这个节点锁死。
3227
+ // Separate single-flight lanes: the client opens the rules file and the
3228
+ // skills list together; one must not block the other.
3175
3229
  const inFlight = db.get<{ request_id: string; created_at: number; pulled_at: number | null }>(
3176
- "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') ORDER BY created_at DESC LIMIT 1",
3230
+ isSkillsOp(op)
3231
+ ? "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('skills_list', 'skill_read') ORDER BY created_at DESC LIMIT 1"
3232
+ : "SELECT request_id, created_at, pulled_at FROM node_rules_requests WHERE node_id = ?1 AND status IN ('pending', 'in_progress') AND op IN ('read', 'write') ORDER BY created_at DESC LIMIT 1",
3177
3233
  nodeId,
3178
3234
  );
3179
3235
  if (inFlight) {
@@ -3191,7 +3247,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3191
3247
  const networkId = node.network_id || "default";
3192
3248
  db.run(
3193
3249
  `INSERT INTO node_rules_requests (request_id, node_id, network_id, op, content, status, created_at, created_by_token) VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)`,
3194
- [requestId, nodeId, networkId, op, op === "write" ? a.content! : null, Date.now(), callerTokenId || "unknown"],
3250
+ [requestId, nodeId, networkId, op, op === "write" || op === "skill_read" ? a.content! : null, Date.now(), callerTokenId || "unknown"],
3195
3251
  );
3196
3252
  pushEvent(node.alias, { type: "rules_file", request_id: requestId }, networkId);
3197
3253
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, request_id: requestId, op }) }] };
@@ -3220,6 +3276,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3220
3276
  async ({ node_id, child_node_id, alias, content, network_id }) => enqueueRulesFileRequest("write", { node_id, child_node_id, alias, content, network_id }),
3221
3277
  );
3222
3278
 
3279
+ server.tool(
3280
+ "list_node_skills",
3281
+ "Ask a node to list the skills its runtime loads (name, scope project|user|system, display path, frontmatter description). Read-only; no path argument. Poll get_rules_file_result — content is JSON {skills:[…]}.",
3282
+ {
3283
+ ...NODE_ID_ALIAS_FIELDS,
3284
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported skills_capable."),
3285
+ network_id: z.string().max(200).optional(),
3286
+ },
3287
+ async ({ node_id, child_node_id, alias, network_id }) => enqueueRulesFileRequest("skills_list", { node_id, child_node_id, alias, network_id }),
3288
+ );
3289
+
3290
+ server.tool(
3291
+ "read_node_skill",
3292
+ "Ask a node for one skill's SKILL.md by name. Read-only; the node resolves the name under its own runtime's skills roots — no path argument. Poll get_rules_file_result — content is JSON {name, scope, path_rel, description, content}.",
3293
+ {
3294
+ ...NODE_ID_ALIAS_FIELDS,
3295
+ alias: z.string().min(1).max(200).optional().describe("Target by alias instead of node_id — resolves to the node row, or to a session that reported skills_capable."),
3296
+ name: z.string().min(1).max(64).describe("Skill directory name, [A-Za-z0-9._-]."),
3297
+ network_id: z.string().max(200).optional(),
3298
+ },
3299
+ async ({ node_id, child_node_id, alias, name, network_id }) => enqueueRulesFileRequest("skill_read", { node_id, child_node_id, alias, content: name, network_id }),
3300
+ );
3301
+
3223
3302
  server.tool(
3224
3303
  "get_rules_file_request",
3225
3304
  "Node pulls its oldest pending rules-file request (called from agent-node when the SSE rules_file doorbell arrives). app#225.",
@@ -3232,11 +3311,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3232
3311
  if (!callerAlias) {
3233
3312
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
3234
3313
  }
3235
- const node = db.get<any>(
3236
- "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
3237
- callerAlias,
3238
- enforceNetworkId,
3239
- );
3314
+ const node = resolveCallerNode();
3240
3315
  // app#225 follow-up — no `nodes` row: this is a session-only target
3241
3316
  // (claude-code). Its queue key is `session:<alias>`, always paired with the
3242
3317
  // caller's network so a same-named alias in another network never matches.
@@ -3261,7 +3336,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3261
3336
  request: {
3262
3337
  request_id: req.request_id,
3263
3338
  op: req.op,
3264
- ...(req.op === "write" ? { content: req.content ?? "" } : {}),
3339
+ ...(req.op === "write" || req.op === "skill_read" ? { content: req.content ?? "" } : {}),
3265
3340
  },
3266
3341
  }),
3267
3342
  }],
@@ -3287,11 +3362,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3287
3362
  if (!callerAlias) {
3288
3363
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "alias_required" }) }] };
3289
3364
  }
3290
- const node = db.get<any>(
3291
- "SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2",
3292
- callerAlias,
3293
- enforceNetworkId,
3294
- );
3365
+ const node = resolveCallerNode();
3295
3366
  const queueKey: string = node ? node.node_id : `session:${callerAlias}`;
3296
3367
  const req = db.get<any>(
3297
3368
  "SELECT request_id, node_id, network_id, status FROM node_rules_requests WHERE request_id = ?1",
@@ -3356,7 +3427,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3356
3427
  status,
3357
3428
  file_name: row.file_name ?? null,
3358
3429
  exists: row.file_exists === null || row.file_exists === undefined ? null : row.file_exists === 1,
3359
- ...(status === "done" && row.op === "read" ? { content: row.result_content ?? "" } : {}),
3430
+ ...(status === "done" && row.op !== "write" ? { content: row.result_content ?? "" } : {}),
3360
3431
  error: row.error ?? null,
3361
3432
  age_ms: ageMs,
3362
3433
  }),