@sleep2agi/commhub-server 0.9.0-preview.32 → 0.9.0-preview.34

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.32",
3
+ "version": "0.9.0-preview.34",
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",
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { registerTools } from "./tools.js";
4
+
5
+ type ToolHandler = (args: any) => Promise<any> | any;
6
+
7
+ function captureSchemas(): Record<string, Record<string, any>> {
8
+ const server = new McpServer({ name: "test", version: "0" }) as any;
9
+ const schemas: Record<string, Record<string, any>> = {};
10
+ const origTool = server.tool.bind(server);
11
+ server.tool = (name: string, desc: string, schema: any, handler: ToolHandler) => {
12
+ schemas[name] = schema;
13
+ return origTool(name, desc, schema, handler);
14
+ };
15
+ registerTools(server, undefined, null, "u_schema_test", "schema-test", false, null);
16
+ return schemas;
17
+ }
18
+
19
+ describe("create_node MCP schema", () => {
20
+ test("model is optional on daemon create specs but empty strings stay invalid", () => {
21
+ const schemas = captureSchemas();
22
+ const nodeSpec = schemas.create_node.node_spec;
23
+
24
+ expect(nodeSpec.safeParse({
25
+ name: "child",
26
+ runtime: "claude-agent-sdk",
27
+ }).success).toBe(true);
28
+ expect(nodeSpec.safeParse({
29
+ name: "child",
30
+ runtime: "claude-agent-sdk",
31
+ model: null,
32
+ }).success).toBe(true);
33
+ expect(nodeSpec.safeParse({
34
+ name: "child",
35
+ runtime: "claude-agent-sdk",
36
+ model: "",
37
+ }).success).toBe(false);
38
+ });
39
+ });
@@ -169,6 +169,16 @@ describe("buildAnetArgs (§4.2.2 F2 — fully validated argv)", () => {
169
169
  expect(args).toContain("--permission-mode");
170
170
  expect(args[args.indexOf("--permission-mode") + 1]).toBe("plan");
171
171
  });
172
+ test("omitted model is allowed and does not emit --model", () => {
173
+ const args = buildAnetArgs({
174
+ name: "x", runtime: "codex-sdk",
175
+ flags: { permissionMode: "plan" },
176
+ });
177
+ expect(args).toEqual(["node", "create", "x", "--runtime", "codex-sdk", "--permission-mode", "plan"]);
178
+ });
179
+ test("empty model is still rejected", () => {
180
+ expect(() => buildAnetArgs({ name: "x", runtime: "codex-sdk", model: "" })).toThrow(ValidationError);
181
+ });
172
182
  test("rejects bad name with shell metachar", () => {
173
183
  expect(() => buildAnetArgs({ name: ";rm -rf /", runtime: "claude-agent-sdk", model: "x" })).toThrow(ValidationError);
174
184
  });
@@ -46,7 +46,8 @@ export function validateRuntime(s: unknown): asserts s is Runtime {
46
46
  }
47
47
  }
48
48
 
49
- export function validateModel(s: unknown): asserts s is string {
49
+ export function validateModel(s: unknown): asserts s is string | undefined | null {
50
+ if (s === undefined || s === null) return;
50
51
  if (typeof s !== "string" || s.length === 0 || s.length > 100 || !MODEL_RE.test(s)) {
51
52
  throw new ValidationError("model_invalid", { value: typeof s === "string" ? s.slice(0, 80) : typeof s });
52
53
  }
@@ -173,7 +174,7 @@ export function validateChannelsP1(channels: unknown): void {
173
174
  export interface NodeSpec {
174
175
  name: string;
175
176
  runtime: Runtime;
176
- model: string;
177
+ model?: string | null;
177
178
  flags?: Record<string, unknown>;
178
179
  channels?: unknown;
179
180
  }
@@ -192,7 +193,8 @@ export function buildAnetArgs(spec: NodeSpec): string[] {
192
193
  validateModel(spec.model);
193
194
  validateChannelsP1(spec.channels);
194
195
 
195
- const args: string[] = ["node", "create", spec.name, "--runtime", spec.runtime, "--model", spec.model];
196
+ const args: string[] = ["node", "create", spec.name, "--runtime", spec.runtime];
197
+ if (spec.model) args.push("--model", spec.model);
196
198
  if (spec.flags && typeof spec.flags === "object") {
197
199
  for (const [k, v] of Object.entries(spec.flags)) {
198
200
  if (!(FLAG_KEYS as readonly string[]).includes(k)) {
package/src/db.ts CHANGED
@@ -200,6 +200,8 @@ db.exec(`
200
200
  started_at TEXT,
201
201
  runtime_submitted_at TEXT,
202
202
  consumed_at TEXT,
203
+ thread_id TEXT,
204
+ turn_id TEXT,
203
205
  completed_at TEXT,
204
206
  expires_at TEXT
205
207
  );
@@ -543,7 +545,7 @@ db.exec(`
543
545
  child_name TEXT NOT NULL,
544
546
  network_id TEXT NOT NULL,
545
547
  runtime TEXT NOT NULL,
546
- model TEXT NOT NULL,
548
+ model TEXT,
547
549
  flags_json TEXT NOT NULL,
548
550
  env_keys TEXT NOT NULL,
549
551
  status TEXT NOT NULL,
@@ -560,6 +562,63 @@ db.exec(`
560
562
  CREATE INDEX IF NOT EXISTS idx_ncr_network ON node_create_requests(network_id);
561
563
  CREATE INDEX IF NOT EXISTS idx_ncr_child_name ON node_create_requests(child_name);
562
564
  `);
565
+ function migrateNodeCreateRequestsModelNullable() {
566
+ if (db.dialect === "postgres") {
567
+ try { db.exec("ALTER TABLE node_create_requests ALTER COLUMN model DROP NOT NULL"); } catch {}
568
+ return;
569
+ }
570
+
571
+ let needsRebuild = false;
572
+ try {
573
+ const createSql = db.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'node_create_requests'");
574
+ needsRebuild = !!createSql?.sql && /\bmodel\s+TEXT\s+NOT\s+NULL\b/i.test(createSql.sql);
575
+ } catch {}
576
+ if (!needsRebuild) return;
577
+
578
+ db.transaction(() => {
579
+ db.exec("DROP TABLE IF EXISTS node_create_requests_migrated");
580
+ db.exec(`
581
+ CREATE TABLE node_create_requests_migrated (
582
+ request_id TEXT PRIMARY KEY,
583
+ daemon_node_id TEXT NOT NULL,
584
+ child_name TEXT NOT NULL,
585
+ network_id TEXT NOT NULL,
586
+ runtime TEXT NOT NULL,
587
+ model TEXT,
588
+ flags_json TEXT NOT NULL,
589
+ env_keys TEXT NOT NULL,
590
+ status TEXT NOT NULL,
591
+ error TEXT,
592
+ child_token_id TEXT,
593
+ created_at INTEGER NOT NULL,
594
+ created_by_token TEXT NOT NULL,
595
+ delivered_at INTEGER,
596
+ acked_at INTEGER,
597
+ child_node_id TEXT
598
+ )
599
+ `);
600
+ db.exec(`
601
+ INSERT INTO node_create_requests_migrated (
602
+ request_id, daemon_node_id, child_name, network_id, runtime, model,
603
+ flags_json, env_keys, status, error, child_token_id, created_at,
604
+ created_by_token, delivered_at, acked_at, child_node_id
605
+ )
606
+ SELECT
607
+ request_id, daemon_node_id, child_name, network_id, runtime, model,
608
+ flags_json, env_keys, status, error, child_token_id, created_at,
609
+ created_by_token, delivered_at, acked_at, child_node_id
610
+ FROM node_create_requests
611
+ `);
612
+ db.exec("DROP TABLE node_create_requests");
613
+ db.exec("ALTER TABLE node_create_requests_migrated RENAME TO node_create_requests");
614
+ });
615
+ }
616
+ migrateNodeCreateRequestsModelNullable();
617
+ db.exec(`
618
+ CREATE INDEX IF NOT EXISTS idx_ncr_daemon_status ON node_create_requests(daemon_node_id, status);
619
+ CREATE INDEX IF NOT EXISTS idx_ncr_network ON node_create_requests(network_id);
620
+ CREATE INDEX IF NOT EXISTS idx_ncr_child_name ON node_create_requests(child_name);
621
+ `);
563
622
  try {
564
623
  // Single-flight per (daemon, child_name) — prevents racing dashboard
565
624
  // create on same name. Mirrors RFC-024 uniq_ncu_node_inflight.
@@ -1100,6 +1159,11 @@ try { db.exec("ALTER TABLE tasks ADD COLUMN meta_json TEXT"); } catch {}
1100
1159
  // started_at (legacy report_status/content matching). Old binaries ignore them.
1101
1160
  try { db.exec("ALTER TABLE tasks ADD COLUMN runtime_submitted_at TEXT"); } catch {}
1102
1161
  try { db.exec("ALTER TABLE tasks ADD COLUMN consumed_at TEXT"); } catch {}
1162
+ // Exact vendor-runtime boundary for SideThread-capable clients. These values
1163
+ // are written only by the token-bound target node after attributable runtime
1164
+ // evidence; the Hub never derives them from task text or caller input.
1165
+ try { db.exec("ALTER TABLE tasks ADD COLUMN thread_id TEXT"); } catch {}
1166
+ try { db.exec("ALTER TABLE tasks ADD COLUMN turn_id TEXT"); } catch {}
1103
1167
 
1104
1168
  // #1181 durable outbound terminal cursor. A task's created_at cannot be used
1105
1169
  // as a recovery watermark because an old task may become terminal after newer
@@ -0,0 +1,222 @@
1
+ // Desktop user push contract.
2
+ //
3
+ // Pins the new user-keyed SSE path and send_desktop_message MCP tool:
4
+ // - user streams are keyed by networkId:userId, never alias
5
+ // - ntok callers cannot cross network by supplying a foreign to_user_id/network_id
6
+ // - target ambiguity/miss is fail-closed before audit/push
7
+
8
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { db } from "./db.js";
11
+ import { registerTools } from "./tools.js";
12
+ import {
13
+ __resetSSEClientsForTest,
14
+ createUserEventStream,
15
+ pushUserEvent,
16
+ } from "./push.js";
17
+
18
+ const NET_A = "net_desktop_a";
19
+ const NET_B = "net_desktop_b";
20
+ const NODE_USER = "u_desktop_node_owner";
21
+ const SENDER_USER = "u_desktop_sender";
22
+ const TARGET_A = "u_desktop_target_a";
23
+ const TARGET_B = "u_desktop_target_b";
24
+ const TARGET_BOTH = "u_desktop_target_both";
25
+ const ALL_USERS = [NODE_USER, SENDER_USER, TARGET_A, TARGET_B, TARGET_BOTH];
26
+
27
+ type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
28
+
29
+ function cleanup() {
30
+ try { db.run("DELETE FROM audit_log WHERE action = 'send_desktop_message' OR network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
31
+ try { db.run("DELETE FROM network_members WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
32
+ try { db.run("DELETE FROM networks WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
33
+ for (const u of ALL_USERS) {
34
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {}
35
+ }
36
+ }
37
+
38
+ function seed() {
39
+ for (const u of ALL_USERS) {
40
+ db.run(
41
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
42
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
43
+ [u, `${u}_name`],
44
+ );
45
+ }
46
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_A, SENDER_USER]);
47
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_B, TARGET_B]);
48
+ const member = (userId: string, networkId: string, role: string) =>
49
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [userId, networkId, role]);
50
+ member(NODE_USER, NET_A, "member");
51
+ member(SENDER_USER, NET_A, "owner");
52
+ member(TARGET_A, NET_A, "member");
53
+ member(TARGET_B, NET_B, "member");
54
+ member(TARGET_BOTH, NET_A, "member");
55
+ member(TARGET_BOTH, NET_B, "member");
56
+ }
57
+
58
+ beforeEach(() => { cleanup(); seed(); });
59
+ afterEach(() => { __resetSSEClientsForTest(); });
60
+ afterAll(cleanup);
61
+
62
+ function buildHandlers(opts: { netId?: string | null; userId?: string | null; alias?: string | null; isNetwork?: boolean }) {
63
+ const server = new McpServer({ name: "test", version: "0" }) as any;
64
+ const tools: Record<string, ToolHandler> = {};
65
+ const origTool = server.tool.bind(server);
66
+ server.tool = (name: string, _desc: string, schema: any, handler: ToolHandler) => {
67
+ tools[name] = handler;
68
+ return origTool(name, _desc, schema, handler);
69
+ };
70
+ registerTools(
71
+ server,
72
+ undefined,
73
+ opts.netId ?? null,
74
+ opts.userId ?? null,
75
+ opts.alias ?? opts.userId ?? null,
76
+ opts.isNetwork ?? false,
77
+ "tok_desktop_test",
78
+ );
79
+ return tools;
80
+ }
81
+
82
+ async function call(handler: ToolHandler, args: any): Promise<Record<string, any>> {
83
+ const r = await handler(args);
84
+ return JSON.parse(r.content[0].text);
85
+ }
86
+
87
+ async function readFrame(
88
+ reader: ReadableStreamDefaultReader<Uint8Array>,
89
+ timeoutMs = 2_000,
90
+ ): Promise<Record<string, any>> {
91
+ const decoder = new TextDecoder();
92
+ let buf = "";
93
+ const deadline = Date.now() + timeoutMs;
94
+ while (Date.now() < deadline) {
95
+ const result = await Promise.race([
96
+ reader.read(),
97
+ new Promise<never>((_, rej) =>
98
+ setTimeout(() => rej(new Error(`no SSE frame within ${timeoutMs}ms`)), deadline - Date.now()),
99
+ ),
100
+ ]);
101
+ if (result.done) throw new Error("SSE stream ended unexpectedly");
102
+ buf += decoder.decode(result.value, { stream: true });
103
+ const sep = buf.indexOf("\n\n");
104
+ if (sep === -1) continue;
105
+ const rawFrame = buf.slice(0, sep);
106
+ buf = buf.slice(sep + 2);
107
+ const dataLine = rawFrame.split("\n").find((l) => l.startsWith("data: "));
108
+ if (!dataLine) continue;
109
+ return JSON.parse(dataLine.slice(6));
110
+ }
111
+ throw new Error(`no SSE frame within ${timeoutMs}ms`);
112
+ }
113
+
114
+ function auditCount() {
115
+ return db.get<{ n: number }>("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'send_desktop_message'")?.n ?? 0;
116
+ }
117
+
118
+ describe("send_desktop_message", () => {
119
+ test("ntok caller can push to a user in the same bound network", async () => {
120
+ const stream = createUserEventStream(NET_A, TARGET_A);
121
+ const reader = stream.body!.getReader();
122
+ await readFrame(reader);
123
+
124
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
125
+ const result = await call(tools.send_desktop_message, {
126
+ to_user_id: TARGET_A,
127
+ message: "desktop hello",
128
+ severity: "success",
129
+ });
130
+
131
+ expect(result.ok).toBe(true);
132
+ expect(result.message_id).toStartWith("dm_");
133
+ expect(auditCount()).toBe(1);
134
+ const evt = await readFrame(reader);
135
+ expect(evt.type).toBe("desktop_message");
136
+ expect(evt.message_id).toBe(result.message_id);
137
+ expect(evt.message).toBe("desktop hello");
138
+ expect(evt.from).toBe("node-a");
139
+ expect(evt.network_id).toBe(NET_A);
140
+ expect(evt.user_id).toBe(TARGET_A);
141
+ expect(evt.scope).toBe("user");
142
+ await reader.cancel();
143
+ });
144
+
145
+ test("utok caller can push with explicit network_id to a member user", async () => {
146
+ const stream = createUserEventStream(NET_A, TARGET_A);
147
+ const reader = stream.body!.getReader();
148
+ await readFrame(reader);
149
+
150
+ const tools = buildHandlers({ userId: SENDER_USER, alias: "sender-user" });
151
+ const result = await call(tools.send_desktop_message, {
152
+ to_username: `${TARGET_A}_name`,
153
+ network_id: NET_A,
154
+ title: "Heads up",
155
+ message: "from user token",
156
+ });
157
+
158
+ expect(result.ok).toBe(true);
159
+ const evt = await readFrame(reader);
160
+ expect(evt.title).toBe("Heads up");
161
+ expect(evt.message).toBe("from user token");
162
+ expect(evt.network_id).toBe(NET_A);
163
+ await reader.cancel();
164
+ });
165
+
166
+ test("ntok caller cannot cross network with foreign network_id and to_user_id; no audit or push happens first", async () => {
167
+ const stream = createUserEventStream(NET_B, TARGET_B);
168
+ const reader = stream.body!.getReader();
169
+ await readFrame(reader);
170
+
171
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
172
+ const result = await call(tools.send_desktop_message, {
173
+ to_user_id: TARGET_B,
174
+ network_id: NET_B,
175
+ message: "must not cross",
176
+ });
177
+
178
+ expect(result).toEqual({ ok: false, error: "desktop_target_not_in_network", network_id: NET_A });
179
+ expect(auditCount()).toBe(0);
180
+
181
+ pushUserEvent(NET_B, TARGET_B, { type: "desktop_message", message_id: "marker_after_denied" });
182
+ expect((await readFrame(reader)).message_id).toBe("marker_after_denied");
183
+ await reader.cancel();
184
+ });
185
+
186
+ test("missing target is fail-closed before audit", async () => {
187
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
188
+ const result = await call(tools.send_desktop_message, { message: "nobody" });
189
+ expect(result).toEqual({
190
+ ok: false,
191
+ error: "desktop_target_required",
192
+ message: "to_user_id or to_username is required",
193
+ });
194
+ expect(auditCount()).toBe(0);
195
+ });
196
+
197
+ test("conflicting to_user_id and to_username is fail-closed before audit", async () => {
198
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
199
+ const result = await call(tools.send_desktop_message, {
200
+ to_user_id: TARGET_A,
201
+ to_username: `${TARGET_BOTH}_name`,
202
+ message: "conflict",
203
+ });
204
+ expect(result).toEqual({
205
+ ok: false,
206
+ error: "desktop_target_mismatch",
207
+ to_user_id: TARGET_A,
208
+ to_username: `${TARGET_BOTH}_name`,
209
+ });
210
+ expect(auditCount()).toBe(0);
211
+ });
212
+
213
+ test("unknown target is fail-closed before audit", async () => {
214
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
215
+ const result = await call(tools.send_desktop_message, {
216
+ to_username: "not_a_real_user",
217
+ message: "lost",
218
+ });
219
+ expect(result).toEqual({ ok: false, error: "desktop_target_not_found", field: "to_username" });
220
+ expect(auditCount()).toBe(0);
221
+ });
222
+ });
@@ -187,6 +187,81 @@ describe("#461 GET /events/network/:id — auth", () => {
187
187
  });
188
188
  });
189
189
 
190
+ describe("GET /events/users/me — Desktop user stream auth", () => {
191
+ test("anonymous → 401", async () => {
192
+ const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(memberNetworkId)}`);
193
+ expect(res.status).toBe(401);
194
+ });
195
+
196
+ test("ntok is refused for user stream", async () => {
197
+ const minted = createNetworkTokenForNode(memberUserId, memberNetworkId, "desktop-ntok-probe");
198
+ expect(minted.ok).toBe(true);
199
+ const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(memberNetworkId)}`, { headers: auth(minted.token!) });
200
+ expect(res.status).toBe(403);
201
+ const body = await res.json() as any;
202
+ expect(body.error).toBe("user_token_required");
203
+ });
204
+
205
+ test("utok without network_id → 400", async () => {
206
+ const res = await fetch(`${BASE}/events/users/me`, { headers: auth(memberToken) });
207
+ expect(res.status).toBe(400);
208
+ const body = await res.json() as any;
209
+ expect(body.error).toBe("network_id required");
210
+ });
211
+
212
+ test("non-member utok → 403", async () => {
213
+ const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(memberNetworkId)}`, { headers: auth(outsiderToken) });
214
+ expect(res.status).toBe(403);
215
+ const body = await res.json() as any;
216
+ expect(body.error).toBe("not a member of this network");
217
+ });
218
+
219
+ test("member utok → user stream connected frame keyed to authenticated user", async () => {
220
+ const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(memberNetworkId)}`, { headers: auth(memberToken) });
221
+ expect(res.status).toBe(200);
222
+ expect(res.headers.get("content-type") || "").toContain("text/event-stream");
223
+ const reader = res.body!.getReader();
224
+ const connected = await readFrame(reader);
225
+ expect(connected.type).toBe("connected");
226
+ expect(connected.user).toBe(true);
227
+ expect(connected.network_id).toBe(memberNetworkId);
228
+ expect(connected.user_id).toBe(memberUserId);
229
+ await reader.cancel();
230
+ });
231
+ });
232
+
233
+ describe("GET /api/messages — client inbox count", () => {
234
+ test("alias filter returns only that inbox and pending_count for unacked rows", async () => {
235
+ const selfAlias = `user-${memberUserId}`;
236
+ db.run(
237
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, acked, network_id, created_at)
238
+ VALUES (?1, ?2, 'task', 'normal', 'unread self', 'agent-a', 0, ?3, datetime('now'))`,
239
+ [`msg_self_unread_${Date.now()}`, selfAlias, memberNetworkId],
240
+ );
241
+ db.run(
242
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, acked, network_id, created_at)
243
+ VALUES (?1, ?2, 'task', 'normal', 'read self', 'agent-a', 1, ?3, datetime('now'))`,
244
+ [`msg_self_read_${Date.now()}`, selfAlias, memberNetworkId],
245
+ );
246
+ db.run(
247
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, acked, network_id, created_at)
248
+ VALUES (?1, 'other-alias', 'task', 'normal', 'other inbox', 'agent-b', 0, ?2, datetime('now'))`,
249
+ [`msg_other_${Date.now()}`, memberNetworkId],
250
+ );
251
+
252
+ const res = await fetch(`${BASE}/api/messages?alias=${encodeURIComponent(selfAlias)}&network_id=${encodeURIComponent(memberNetworkId)}&limit=20`, {
253
+ headers: auth(memberToken),
254
+ });
255
+ expect(res.status).toBe(200);
256
+ const body = await res.json() as any;
257
+ expect(body.ok).toBe(true);
258
+ expect(body.pending_count).toBe(1);
259
+ expect(body.messages.length).toBeGreaterThanOrEqual(2);
260
+ expect(body.messages.every((m: any) => m.to_alias === selfAlias)).toBe(true);
261
+ expect(body.messages.some((m: any) => m.content === "other inbox")).toBe(false);
262
+ });
263
+ });
264
+
190
265
  describe("#461 observer receives third-party traffic summaries", () => {
191
266
  test("REST /api/task dispatch → observer gets new_task summary, no content", async () => {
192
267
  const res = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(memberToken) });
@@ -11,8 +11,10 @@ import {
11
11
  __resetSSEClientsForTest,
12
12
  createNetworkObserverStream,
13
13
  createSSEStream,
14
+ createUserEventStream,
14
15
  pushEvent,
15
16
  pushNetworkObserverEvent,
17
+ pushUserEvent,
16
18
  } from "./push";
17
19
 
18
20
  afterEach(() => {
@@ -174,3 +176,77 @@ describe("#461 network observer stream", () => {
174
176
  await reader2.cancel();
175
177
  });
176
178
  });
179
+
180
+ describe("Desktop user SSE stream", () => {
181
+ test("user stream receives connected frame, then desktop_message", async () => {
182
+ const res = createUserEventStream("net_user_a", "u_user_a");
183
+ const reader = res.body!.getReader();
184
+
185
+ const connected = await readFrame(reader);
186
+ expect(connected.type).toBe("connected");
187
+ expect(connected.user).toBe(true);
188
+ expect(connected.network_id).toBe("net_user_a");
189
+ expect(connected.user_id).toBe("u_user_a");
190
+
191
+ pushUserEvent("net_user_a", "u_user_a", {
192
+ type: "desktop_message",
193
+ message_id: "dm_one",
194
+ from: "agent-a",
195
+ message: "hello",
196
+ });
197
+
198
+ const evt = await readFrame(reader);
199
+ expect(evt.type).toBe("desktop_message");
200
+ expect(evt.message_id).toBe("dm_one");
201
+ expect(evt.from).toBe("agent-a");
202
+ expect(evt.message).toBe("hello");
203
+ expect(evt.network_id).toBe("net_user_a");
204
+ expect(evt.user_id).toBe("u_user_a");
205
+ expect(evt.scope).toBe("user");
206
+ await reader.cancel();
207
+ });
208
+
209
+ test("user stream is isolated from alias and observer key spaces", async () => {
210
+ const userRes = createUserEventStream("net_user_iso", "u_iso");
211
+ const userReader = userRes.body!.getReader();
212
+ await readFrame(userReader);
213
+
214
+ const sessionRes = createSSEStream("u_iso", "net_user_iso");
215
+ const sessionReader = sessionRes.body!.getReader();
216
+ await readFrame(sessionReader);
217
+
218
+ const observerRes = createNetworkObserverStream("net_user_iso");
219
+ const observerReader = observerRes.body!.getReader();
220
+ await readFrame(observerReader);
221
+
222
+ pushEvent("u_iso", { type: "alias_marker", marker: "alias" }, "net_user_iso");
223
+ pushNetworkObserverEvent("net_user_iso", { type: "observer_marker", marker: "observer" });
224
+ pushUserEvent("net_user_iso", "u_iso", { type: "desktop_message", marker: "user" });
225
+
226
+ expect((await readFrame(sessionReader)).marker).toBe("alias");
227
+ expect((await readFrame(observerReader)).marker).toBe("observer");
228
+ expect((await readFrame(userReader)).marker).toBe("user");
229
+
230
+ await userReader.cancel();
231
+ await sessionReader.cancel();
232
+ await observerReader.cancel();
233
+ });
234
+
235
+ test("same user and network fan out to multiple live clients; cancelling one leaves the other live", async () => {
236
+ const r1 = createUserEventStream("net_user_multi", "u_multi");
237
+ const r2 = createUserEventStream("net_user_multi", "u_multi");
238
+ const reader1 = r1.body!.getReader();
239
+ const reader2 = r2.body!.getReader();
240
+ await readFrame(reader1);
241
+ await readFrame(reader2);
242
+
243
+ pushUserEvent("net_user_multi", "u_multi", { type: "desktop_message", message_id: "dm_fanout_1" });
244
+ expect((await readFrame(reader1)).message_id).toBe("dm_fanout_1");
245
+ expect((await readFrame(reader2)).message_id).toBe("dm_fanout_1");
246
+
247
+ await reader1.cancel();
248
+ pushUserEvent("net_user_multi", "u_multi", { type: "desktop_message", message_id: "dm_fanout_2" });
249
+ expect((await readFrame(reader2)).message_id).toBe("dm_fanout_2");
250
+ await reader2.cancel();
251
+ });
252
+ });
package/src/push.ts CHANGED
@@ -109,6 +109,7 @@ function clientKey(sessionName: string, networkId?: string | null): string {
109
109
  // master-token caller could even inject a NUL into network_id via the
110
110
  // legacy ?network_id= path, and master token is root already.
111
111
  const OBSERVER_KEY_PREFIX = "\u0000netobs:";
112
+ const USER_KEY_PREFIX = "\u0000user:";
112
113
 
113
114
  /** Printable form of `OBSERVER_KEY_PREFIX` — the shape emitted by
114
115
  * `printableKey()` (below) and therefore the shape callers see via
@@ -123,6 +124,10 @@ function observerKey(networkId: string): string {
123
124
  return `${OBSERVER_KEY_PREFIX}${networkId}`;
124
125
  }
125
126
 
127
+ function userKey(networkId: string, userId: string): string {
128
+ return `${USER_KEY_PREFIX}${networkId}:${userId}`;
129
+ }
130
+
126
131
  /** Render a client key for logs / stats. Observer keys embed a raw NUL
127
132
  * byte (collision guard above); printing that verbatim makes grep treat
128
133
  * hub logs as binary and breaks log shippers, and getSSEStats feeds
@@ -265,6 +270,15 @@ export function createNetworkObserverStream(networkId: string): Response {
265
270
  );
266
271
  }
267
272
 
273
+ /** Desktop/user client SSE stream. Keyed by authenticated user_id, not
274
+ * alias/session, so Desktop does not need to mint a pseudo node token. */
275
+ export function createUserEventStream(networkId: string, userId: string): Response {
276
+ return createStreamForKey(
277
+ userKey(networkId, userId),
278
+ { type: "connected", user: true, network_id: networkId, user_id: userId },
279
+ );
280
+ }
281
+
268
282
  function createStreamForKey(key: string, initialEvent: Record<string, unknown>): Response {
269
283
  const encoder = new TextEncoder();
270
284
  let client: SSEClient;
@@ -447,6 +461,34 @@ export function pushNetworkObserverEvent(
447
461
  if (needPrune) pruneClosed(key);
448
462
  }
449
463
 
464
+ /** Push an event to every active Desktop/web client for one user in one
465
+ * network. No-op for falsy network/user ids; auth must be enforced by
466
+ * the caller before invoking this helper. */
467
+ export function pushUserEvent(
468
+ networkId: string | null | undefined,
469
+ userId: string | null | undefined,
470
+ event: Record<string, unknown>,
471
+ ): void {
472
+ if (!networkId || !userId) return;
473
+ const key = userKey(networkId, userId);
474
+ const arr = clients.get(key);
475
+ if (!arr || arr.length === 0) return;
476
+
477
+ const data = `data: ${JSON.stringify({ ...event, network_id: networkId, user_id: userId, scope: "user" })}\n\n`;
478
+ let needPrune = false;
479
+
480
+ for (const c of arr) {
481
+ if (c.closed) {
482
+ needPrune = true;
483
+ continue;
484
+ }
485
+ const result = tryEnqueueBytes(c, c.encoder.encode(data));
486
+ if (result === "dead") needPrune = true;
487
+ }
488
+
489
+ if (needPrune) pruneClosed(key);
490
+ }
491
+
450
492
  export function __resetSSEClientsForTest(): void {
451
493
  for (const arr of clients.values()) {
452
494
  for (const c of arr) closeClient(c, "reset-for-test");
@@ -52,6 +52,7 @@ const GOLDEN_RESPONSE_KEYS = {
52
52
  "status", "content", "result", "in_reply_to", "requires_response", "scope",
53
53
  "created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at",
54
54
  "completed_at", "expires_at", "network_id", "parent_task_id", "meta_json",
55
+ "thread_id", "turn_id",
55
56
  ],
56
57
  audit: [
57
58
  "id", "user_id", "username", "action", "target_type", "target_id", "detail",
@@ -36,7 +36,7 @@ export const TASK_REST_COLUMNS = [
36
36
  "task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
37
37
  "status", "content", "result", "in_reply_to", "requires_response", "scope",
38
38
  "created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at", "completed_at", "expires_at",
39
- "network_id", "parent_task_id", "meta_json",
39
+ "network_id", "parent_task_id", "meta_json", "thread_id", "turn_id",
40
40
  ] as const;
41
41
 
42
42
  export const COMPLETION_REST_COLUMNS = [
package/src/server.ts CHANGED
@@ -6,7 +6,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
6
6
  import { z } from "zod/v4";
7
7
  import { registerTools } from "./tools.js";
8
8
  import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
9
- import { createSSEStream, createNetworkObserverStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX } from "./push.js";
9
+ import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX } from "./push.js";
10
10
  import { assertNodeActive } from "./lifecycle-guard.js";
11
11
  import { addNetworkScope, canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
12
12
  import { validateAvatarUrl } from "./avatar-validate.js";
@@ -871,6 +871,32 @@ return Bun.serve({
871
871
  return createNetworkObserverStream(observedNetId);
872
872
  }
873
873
 
874
+ // GET /events/users/me?network_id=... → Desktop/user-client stream.
875
+ // This is intentionally keyed by authenticated user_id, not alias.
876
+ // Desktop must use its utok_; ntok_ is a node credential and cannot
877
+ // subscribe to user channels.
878
+ if (url.pathname === "/events/users/me" && req.method === "GET") {
879
+ const authErr = requireAuth(req);
880
+ if (authErr) return authErr;
881
+ const token = requestToken(req);
882
+ if (!token?.startsWith("utok_")) {
883
+ return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
884
+ }
885
+ const authCtx = resolveRequestAuth(req);
886
+ if (!authCtx) {
887
+ return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 403 }));
888
+ }
889
+ const networkId = url.searchParams.get("network_id");
890
+ if (!networkId) {
891
+ return withCors(req, Response.json({ ok: false, error: "network_id required" }, { status: 400 }));
892
+ }
893
+ const role = getUserNetworkRole(authCtx.userId, networkId);
894
+ if (!role) {
895
+ return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
896
+ }
897
+ return createUserEventStream(networkId, authCtx.userId);
898
+ }
899
+
874
900
  // ── SSE push: Agent 实时接收任务推送 ──
875
901
  // GET /events/知识哥 → 保持长连接,send_task 时秒推
876
902
  const eventsMatch = url.pathname.match(/^\/events\/(.+)$/);
@@ -2599,13 +2625,27 @@ return Bun.serve({
2599
2625
  if (url.pathname === "/api/messages") {
2600
2626
  const limit = Math.min(Number(url.searchParams.get("limit")) || 100, 500);
2601
2627
  const since = url.searchParams.get("since") ?? new Date(Date.now() - 3600000).toISOString().replace("T", " ").slice(0, 19);
2628
+ const alias = url.searchParams.get("alias")?.trim() || null;
2602
2629
  const params: any[] = [since];
2603
- let sql = "SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, created_at, network_id FROM inbox WHERE created_at >= ?1";
2630
+ let sql = "SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, acked, created_at, network_id FROM inbox WHERE created_at >= ?1";
2631
+ if (alias) {
2632
+ params.push(alias);
2633
+ sql += ` AND session_name = ?${params.length}`;
2634
+ }
2604
2635
  sql = addNetworkScope(sql, params, restScope);
2605
2636
  sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
2606
2637
  params.push(limit);
2607
2638
  const rows = db.all(sql, ...params);
2608
- return withCors(req, Response.json({ ok: true, messages: rows }));
2639
+
2640
+ let pendingCount: number | undefined;
2641
+ if (alias) {
2642
+ const countParams: any[] = [alias];
2643
+ let countSql = "SELECT COUNT(*) AS cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
2644
+ countSql = addNetworkScope(countSql, countParams, restScope);
2645
+ pendingCount = db.get<{ cnt: number }>(countSql, ...countParams)?.cnt ?? 0;
2646
+ }
2647
+
2648
+ return withCors(req, Response.json({ ok: true, messages: rows, ...(alias ? { pending_count: pendingCount ?? 0 } : {}) }));
2609
2649
  }
2610
2650
 
2611
2651
  // ── REST: stats summary ──
@@ -92,6 +92,13 @@ function runtimeSubmittedAt(taskId: string): string | null {
92
92
  )?.runtime_submitted_at ?? null;
93
93
  }
94
94
 
95
+ function runtimeContext(taskId: string): { thread_id: string | null; turn_id: string | null } {
96
+ return db.get<{ thread_id: string | null; turn_id: string | null }>(
97
+ "SELECT thread_id, turn_id FROM tasks WHERE task_id = ?1",
98
+ taskId,
99
+ )!;
100
+ }
101
+
95
102
  beforeEach(() => { cleanup(); seed(); });
96
103
  afterAll(cleanup);
97
104
 
@@ -218,6 +225,72 @@ describe("task consumed_at identity and lifecycle", () => {
218
225
  expect(listedTask?.consumed_at).toBe(timestamp);
219
226
  });
220
227
 
228
+ test("token-bound consumed evidence persists and exposes an exact BTW boundary", async () => {
229
+ const taskId = await createTask("runtime emitted exact Codex context");
230
+ const handler = toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed;
231
+ const context = { task_id: taskId, thread_id: "thread_btw_exact", turn_id: "turn_btw_exact" };
232
+
233
+ const first = await call(handler, { task_ids: [taskId], task_contexts: [context] });
234
+ expect(first.ok).toBe(true);
235
+ expect(first.tasks[0]).toMatchObject(context);
236
+ expect(runtimeContext(taskId)).toEqual({
237
+ thread_id: context.thread_id,
238
+ turn_id: context.turn_id,
239
+ });
240
+
241
+ const listed = await call(toolsFor().list_tasks, { alias: NODE_A, limit: 20 });
242
+ expect(listed.tasks.find((task: any) => task.task_id === taskId)).toMatchObject(context);
243
+
244
+ const replay = await call(handler, { task_ids: [taskId], task_contexts: [context] });
245
+ expect(replay.ok).toBe(true);
246
+ const conflict = await call(handler, {
247
+ task_ids: [taskId],
248
+ task_contexts: [{ ...context, turn_id: "turn_sibling" }],
249
+ });
250
+ expect(conflict).toEqual({
251
+ ok: false,
252
+ error: "task_runtime_context_conflict",
253
+ task_id: taskId,
254
+ });
255
+ expect(runtimeContext(taskId)).toEqual({
256
+ thread_id: context.thread_id,
257
+ turn_id: context.turn_id,
258
+ });
259
+ });
260
+
261
+ test("runtime context rejects user tokens, foreign tasks, and unrequested ids without partial writes", async () => {
262
+ const ownTask = await createTask("owned context", NODE_A);
263
+ const foreignTask = await createTask("foreign context", NODE_B);
264
+ const ownContext = { task_id: ownTask, thread_id: "thread_owned", turn_id: "turn_owned" };
265
+ const foreignContext = { task_id: foreignTask, thread_id: "thread_foreign", turn_id: "turn_foreign" };
266
+
267
+ expect(await call(toolsFor().mark_tasks_consumed, {
268
+ task_ids: [ownTask], task_contexts: [ownContext],
269
+ })).toEqual({ ok: false, error: "node_token_required" });
270
+ expect(await call(toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed, {
271
+ task_ids: [ownTask, foreignTask], task_contexts: [ownContext, foreignContext],
272
+ })).toEqual({ ok: false, error: "task_not_owned", task_id: foreignTask });
273
+ expect(await call(toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_consumed, {
274
+ task_ids: [ownTask], task_contexts: [foreignContext],
275
+ })).toEqual({ ok: false, error: "task_context_not_requested", task_id: foreignTask });
276
+ expect(runtimeContext(ownTask)).toEqual({ thread_id: null, turn_id: null });
277
+ expect(runtimeContext(foreignTask)).toEqual({ thread_id: null, turn_id: null });
278
+ });
279
+
280
+ test("submitted evidence cannot smuggle a runtime context", async () => {
281
+ const taskId = await createTask("submission has no authoritative turn yet");
282
+ const result = await call(
283
+ toolsFor({ alias: NODE_A, nodeToken: true }).mark_tasks_runtime_submitted,
284
+ {
285
+ task_ids: [taskId],
286
+ task_contexts: [{ task_id: taskId, thread_id: "thread_early", turn_id: "turn_early" }],
287
+ },
288
+ );
289
+ expect(result).toEqual({ ok: false, error: "runtime_context_requires_consumed" });
290
+ expect(runtimeSubmittedAt(taskId)).toBeNull();
291
+ expect(runtimeContext(taskId)).toEqual({ thread_id: null, turn_id: null });
292
+ });
293
+
221
294
  test("one runtime wake can mark an exact batch of owned tasks", async () => {
222
295
  const firstTask = await createTask("batched wake first");
223
296
  const secondTask = await createTask("batched wake second");
package/src/tools.ts CHANGED
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
4
4
  import { parseAliasFilter } from "./alias-filter.js";
5
5
  import { createHash } from "node:crypto";
6
6
  import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, generateNetworkToken, syncScheduledRunForTask } from "./db.js";
7
- import { getSSEStats, hasSubscribers, pushEvent, pushNetworkObserverEvent } from "./push.js";
7
+ import { getSSEStats, hasSubscribers, pushEvent, pushNetworkObserverEvent, pushUserEvent } from "./push.js";
8
8
  import { assertNodeActive } from "./lifecycle-guard.js";
9
9
  import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
10
10
  import { canRestWriteNetwork, getUserNetworkIds, singleNetworkId } from "./network-scope.js";
@@ -991,6 +991,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
991
991
  const markTaskRuntimeEvidence = (
992
992
  taskIds: string[],
993
993
  level: "submitted" | "consumed",
994
+ taskContexts: Array<{ task_id: string; thread_id: string; turn_id: string }> = [],
994
995
  ) => {
995
996
  const task_ids = taskIds;
996
997
  if (!callerTokenIsNetwork || !callerAlias || !enforceNetworkId) {
@@ -1017,6 +1018,24 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1017
1018
  };
1018
1019
  }
1019
1020
 
1021
+ if (level !== "consumed" && taskContexts.length > 0) {
1022
+ return {
1023
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "runtime_context_requires_consumed" }) }],
1024
+ };
1025
+ }
1026
+ const contextByTaskId = new Map(taskContexts.map((context) => [context.task_id, context]));
1027
+ if (contextByTaskId.size !== taskContexts.length) {
1028
+ return {
1029
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "duplicate_task_context" }) }],
1030
+ };
1031
+ }
1032
+ const foreignContext = taskContexts.find((context) => !uniqueTaskIds.includes(context.task_id));
1033
+ if (foreignContext) {
1034
+ return {
1035
+ content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task_context_not_requested", task_id: foreignContext.task_id }) }],
1036
+ };
1037
+ }
1038
+
1020
1039
  const placeholders = uniqueTaskIds.map((_, i) => `?${i + 2}`).join(", ");
1021
1040
 
1022
1041
  // Keep ownership preflight and every stamp in one SQLite transaction.
@@ -1031,8 +1050,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1031
1050
  enforceNetworkId,
1032
1051
  canonicalCaller,
1033
1052
  );
1034
- const rows = db.all<{ task_id: string; to_node_id: string | null; to_name: string }>(
1035
- `SELECT task_id, to_node_id, to_name FROM tasks
1053
+ const rows = db.all<{ task_id: string; to_node_id: string | null; to_name: string; thread_id: string | null; turn_id: string | null }>(
1054
+ `SELECT task_id, to_node_id, to_name, thread_id, turn_id FROM tasks
1036
1055
  WHERE network_id = ?1 AND task_id IN (${placeholders})`,
1037
1056
  enforceNetworkId,
1038
1057
  ...uniqueTaskIds,
@@ -1050,6 +1069,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1050
1069
  if (rejectedTaskId) {
1051
1070
  return { ok: false as const, error: "task_not_owned", task_id: rejectedTaskId };
1052
1071
  }
1072
+ const conflictingContext = uniqueTaskIds.find((taskId) => {
1073
+ const context = contextByTaskId.get(taskId);
1074
+ if (!context) return false;
1075
+ const row = owned.get(taskId)!;
1076
+ return (row.thread_id !== null && row.thread_id !== context.thread_id)
1077
+ || (row.turn_id !== null && row.turn_id !== context.turn_id);
1078
+ });
1079
+ if (conflictingContext) {
1080
+ return { ok: false as const, error: "task_runtime_context_conflict", task_id: conflictingContext };
1081
+ }
1053
1082
 
1054
1083
  for (const taskId of uniqueTaskIds) {
1055
1084
  const row = owned.get(taskId)!;
@@ -1059,12 +1088,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1059
1088
  const ownerValue = row.to_node_id ?? row.to_name;
1060
1089
  let updateResult;
1061
1090
  if (level === "consumed") {
1091
+ const context = contextByTaskId.get(taskId);
1062
1092
  updateResult = db.run(
1063
1093
  `UPDATE tasks SET
1064
1094
  runtime_submitted_at = COALESCE(runtime_submitted_at, datetime('now')),
1065
- consumed_at = COALESCE(consumed_at, datetime('now'))
1095
+ consumed_at = COALESCE(consumed_at, datetime('now')),
1096
+ thread_id = COALESCE(thread_id, ?4),
1097
+ turn_id = COALESCE(turn_id, ?5)
1066
1098
  WHERE network_id = ?1 AND task_id = ?2 AND ${ownershipSql}`,
1067
- [enforceNetworkId, taskId, ownerValue],
1099
+ [enforceNetworkId, taskId, ownerValue, context?.thread_id ?? null, context?.turn_id ?? null],
1068
1100
  );
1069
1101
  } else {
1070
1102
  updateResult = db.run(
@@ -1083,8 +1115,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1083
1115
  task_id: string;
1084
1116
  runtime_submitted_at: string;
1085
1117
  consumed_at: string | null;
1118
+ thread_id: string | null;
1119
+ turn_id: string | null;
1086
1120
  }>(
1087
- `SELECT task_id, runtime_submitted_at, consumed_at FROM tasks
1121
+ `SELECT task_id, runtime_submitted_at, consumed_at, thread_id, turn_id FROM tasks
1088
1122
  WHERE network_id = ?1 AND task_id IN (${placeholders})`,
1089
1123
  enforceNetworkId,
1090
1124
  ...uniqueTaskIds,
@@ -1102,20 +1136,25 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1102
1136
 
1103
1137
  const taskRuntimeEvidenceSchema = {
1104
1138
  task_ids: z.array(z.string().min(1).max(200)).min(1).max(100),
1139
+ task_contexts: z.array(z.object({
1140
+ task_id: z.string().min(1).max(200),
1141
+ thread_id: z.string().min(1).max(500),
1142
+ turn_id: z.string().min(1).max(500),
1143
+ }).strict()).max(100).optional(),
1105
1144
  };
1106
1145
 
1107
1146
  server.tool(
1108
1147
  "mark_tasks_runtime_submitted",
1109
1148
  "Internal agent-node signal: exact task bodies were submitted to this token-bound node's vendor runtime.",
1110
1149
  taskRuntimeEvidenceSchema,
1111
- async ({ task_ids }) => markTaskRuntimeEvidence(task_ids, "submitted"),
1150
+ async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "submitted", task_contexts),
1112
1151
  );
1113
1152
 
1114
1153
  server.tool(
1115
1154
  "mark_tasks_consumed",
1116
1155
  "Internal agent-node signal: exact tasks produced attributable turn-start/activity evidence in this token-bound node's runtime.",
1117
1156
  taskRuntimeEvidenceSchema,
1118
- async ({ task_ids }) => markTaskRuntimeEvidence(task_ids, "consumed"),
1157
+ async ({ task_ids, task_contexts }) => markTaskRuntimeEvidence(task_ids, "consumed", task_contexts),
1119
1158
  );
1120
1159
 
1121
1160
  // ═══════════════════════════════════════════
@@ -2031,7 +2070,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2031
2070
  const tasks = db.all(
2032
2071
  `SELECT t.task_id, t.from_node_id, t.from_name, t.to_node_id, t.to_name, t.priority,
2033
2072
  t.status, t.content, t.result, t.created_at, t.runtime_submitted_at,
2034
- t.consumed_at, t.completed_at, e.terminal_seq
2073
+ t.consumed_at, t.thread_id, t.turn_id, t.completed_at, e.terminal_seq
2035
2074
  FROM task_terminal_events e
2036
2075
  JOIN tasks t ON t.task_id = e.task_id
2037
2076
  WHERE COALESCE(e.network_id, 'default') = COALESCE(?1, 'default')
@@ -2048,7 +2087,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2048
2087
  has_more: tasks.length === limit,
2049
2088
  }) }] };
2050
2089
  }
2051
- let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, runtime_submitted_at, consumed_at, completed_at FROM tasks WHERE 1=1";
2090
+ let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, runtime_submitted_at, consumed_at, thread_id, turn_id, completed_at FROM tasks WHERE 1=1";
2052
2091
  const params: any[] = [];
2053
2092
  sql = addReadScope(sql, params, readScope);
2054
2093
  if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
@@ -2181,6 +2220,97 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2181
2220
  }
2182
2221
  );
2183
2222
 
2223
+ server.tool(
2224
+ "send_desktop_message",
2225
+ "Push a message to a user's active Desktop/web clients in one network. Targets user identity, not node alias.",
2226
+ {
2227
+ to_user_id: z.string().min(1).max(200).optional().describe("Target user_id. Mutually checked with to_username when both are supplied."),
2228
+ to_username: z.string().min(1).max(50).optional().describe("Target username. Mutually checked with to_user_id when both are supplied."),
2229
+ title: z.string().max(200).optional(),
2230
+ message: z.string().min(1).max(10000),
2231
+ severity: z.enum(["info", "success", "warning", "error"]).optional().default("info"),
2232
+ kind: z.string().min(1).max(80).optional().default("agent_message"),
2233
+ meta: z.record(z.unknown()).optional(),
2234
+ from_session: z.string().max(200).optional(),
2235
+ network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens; ntok stays bound to its network)."),
2236
+ },
2237
+ async ({ to_user_id, to_username, title, message, severity, kind, meta, from_session: _fromIn, network_id: netId }) => {
2238
+ const fromMismatch = fromIdentityMismatchReply(_fromIn);
2239
+ if (fromMismatch) return fromMismatch;
2240
+ const from_session = defaultFrom(_fromIn);
2241
+ const effectiveNetId = getNetworkId(netId);
2242
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId, "write");
2243
+ if (!effectiveNetId) return writeDeniedReply(effectiveNetId, "write");
2244
+
2245
+ if (!to_user_id && !to_username) {
2246
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_required", message: "to_user_id or to_username is required" }) }] };
2247
+ }
2248
+
2249
+ const byId = to_user_id
2250
+ ? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE user_id = ?1", to_user_id)
2251
+ : null;
2252
+ const byName = to_username
2253
+ ? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE username = ?1", to_username)
2254
+ : null;
2255
+
2256
+ if (to_user_id && !byId) {
2257
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_user_id" }) }] };
2258
+ }
2259
+ if (to_username && !byName) {
2260
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found", field: "to_username" }) }] };
2261
+ }
2262
+ if (byId && byName && byId.user_id !== byName.user_id) {
2263
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_mismatch", to_user_id, to_username }) }] };
2264
+ }
2265
+
2266
+ const target = byId ?? byName;
2267
+ if (!target) {
2268
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_found" }) }] };
2269
+ }
2270
+
2271
+ const targetRole = getUserNetworkRole(target.user_id, effectiveNetId);
2272
+ if (!targetRole) {
2273
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "desktop_target_not_in_network", network_id: effectiveNetId }) }] };
2274
+ }
2275
+
2276
+ const messageId = `dm_${uuidv4()}`;
2277
+ const event = {
2278
+ type: "desktop_message",
2279
+ message_id: messageId,
2280
+ kind,
2281
+ from: from_session,
2282
+ title: title || null,
2283
+ message,
2284
+ severity,
2285
+ created_at: new Date().toISOString(),
2286
+ ...(meta && Object.keys(meta).length ? { meta } : {}),
2287
+ };
2288
+
2289
+ db.run(
2290
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
2291
+ VALUES (?1, ?2, 'send_desktop_message', 'user', ?3, ?4, ?5)`,
2292
+ [
2293
+ enforceUserId || null,
2294
+ callerAlias || null,
2295
+ target.user_id,
2296
+ JSON.stringify({
2297
+ message_id: messageId,
2298
+ to_username: target.username,
2299
+ from: from_session,
2300
+ severity,
2301
+ kind,
2302
+ title: title || null,
2303
+ meta_keys: meta && typeof meta === "object" ? Object.keys(meta) : [],
2304
+ }),
2305
+ effectiveNetId,
2306
+ ],
2307
+ );
2308
+ pushUserEvent(effectiveNetId, target.user_id, event);
2309
+
2310
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, message_id: messageId, delivered_to_user_id: target.user_id, network_id: effectiveNetId }) }] };
2311
+ },
2312
+ );
2313
+
2184
2314
  server.tool(
2185
2315
  "broadcast",
2186
2316
  "Send a message to multiple sessions.",
@@ -2886,7 +3016,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2886
3016
  node_spec: z.object({
2887
3017
  name: z.string().min(1).max(64),
2888
3018
  runtime: z.string().min(1).max(64),
2889
- model: z.string().min(1).max(100),
3019
+ model: z.string().min(1).max(100).optional().nullable(),
2890
3020
  flags: z.record(z.unknown()).optional(),
2891
3021
  env_refs: z.array(z.string().max(64)).optional(),
2892
3022
  channels: z.array(z.unknown()).optional(),
@@ -3042,7 +3172,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3042
3172
  VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11)`,
3043
3173
  [
3044
3174
  requestId, daemon_node_id, node_spec.name, networkIdForChild,
3045
- node_spec.runtime, node_spec.model, JSON.stringify(node_spec.flags || {}),
3175
+ node_spec.runtime, node_spec.model ?? null, JSON.stringify(node_spec.flags || {}),
3046
3176
  JSON.stringify(envKeys), childTokenId, Date.now(), callerTokenId || "unknown",
3047
3177
  ],
3048
3178
  );
@@ -3062,7 +3192,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3062
3192
  daemon_node_id,
3063
3193
  child_name: node_spec.name,
3064
3194
  runtime: node_spec.runtime,
3065
- model: node_spec.model,
3195
+ model: node_spec.model ?? null,
3066
3196
  flag_keys: Object.keys(node_spec.flags || {}),
3067
3197
  env_keys: envKeys,
3068
3198
  },
@@ -3126,7 +3256,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3126
3256
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "env_blob_unavailable" }) }] };
3127
3257
  }
3128
3258
  // Hydrate spec from row.
3129
- const specRow = db.get<{ child_name: string; runtime: string; model: string; flags_json: string }>(
3259
+ const specRow = db.get<{ child_name: string; runtime: string; model: string | null; flags_json: string }>(
3130
3260
  `SELECT child_name, runtime, model, flags_json FROM node_create_requests WHERE request_id = ?1`,
3131
3261
  request_id,
3132
3262
  );