@sleep2agi/commhub-server 0.4.3 → 0.5.0-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.4.3",
3
+ "version": "0.5.0-preview.1",
4
4
  "description": "CommHub MCP Server — AI Agent communication hub with SSE push, MCP protocol, and REST API",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/db.ts CHANGED
@@ -58,6 +58,63 @@ db.exec(`
58
58
  );
59
59
  `);
60
60
 
61
+ // ── V2 schema migration (ALTER TABLE, safe to re-run) ──
62
+
63
+ // sessions: add node_id, session_id, config_path, channels, last_seen_at
64
+ for (const col of [
65
+ { name: "node_id", def: "TEXT" },
66
+ { name: "session_id", def: "TEXT" },
67
+ { name: "config_path", def: "TEXT" },
68
+ { name: "channels", def: "TEXT" },
69
+ { name: "last_seen_at", def: "TEXT" },
70
+ ]) {
71
+ try { db.exec(`ALTER TABLE sessions ADD COLUMN ${col.name} ${col.def}`); } catch {}
72
+ }
73
+
74
+ // inbox: add in_reply_to, requires_response, expires_at, scope
75
+ for (const col of [
76
+ { name: "in_reply_to", def: "TEXT" },
77
+ { name: "requires_response", def: "TEXT DEFAULT 'reply'" },
78
+ { name: "expires_at", def: "TEXT" },
79
+ { name: "scope", def: "TEXT DEFAULT 'single'" },
80
+ ]) {
81
+ try { db.exec(`ALTER TABLE inbox ADD COLUMN ${col.name} ${col.def}`); } catch {}
82
+ }
83
+
84
+ // indexes for new columns
85
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_type ON inbox(type)"); } catch {}
86
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_from ON inbox(from_session)"); } catch {}
87
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_reply ON inbox(in_reply_to)"); } catch {}
88
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_node ON sessions(node_id)"); } catch {}
89
+
90
+ // tasks table (V2)
91
+ db.exec(`
92
+ CREATE TABLE IF NOT EXISTS tasks (
93
+ task_id TEXT PRIMARY KEY,
94
+ from_node_id TEXT,
95
+ from_name TEXT NOT NULL DEFAULT 'hub',
96
+ to_node_id TEXT,
97
+ to_name TEXT NOT NULL,
98
+ priority TEXT NOT NULL DEFAULT 'normal',
99
+ status TEXT NOT NULL DEFAULT 'created',
100
+ content TEXT NOT NULL,
101
+ result TEXT,
102
+ in_reply_to TEXT,
103
+ requires_response TEXT DEFAULT 'reply',
104
+ scope TEXT DEFAULT 'single',
105
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
106
+ delivered_at TEXT,
107
+ started_at TEXT,
108
+ completed_at TEXT,
109
+ expires_at TEXT
110
+ );
111
+
112
+ CREATE INDEX IF NOT EXISTS idx_tasks_to ON tasks(to_name);
113
+ CREATE INDEX IF NOT EXISTS idx_tasks_from ON tasks(from_name);
114
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
115
+ CREATE INDEX IF NOT EXISTS idx_tasks_created ON tasks(created_at);
116
+ `);
117
+
61
118
  // Helpers
62
119
  export function uuidv4(): string {
63
120
  return crypto.randomUUID();
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ const TaskSchema = z.object({
34
34
  alias: z.string().min(1).max(200),
35
35
  task: z.string().min(1).max(10000),
36
36
  priority: z.enum(["high", "normal", "low"]).default("normal"),
37
+ from: z.string().max(200).optional(),
37
38
  });
38
39
 
39
40
  const BroadcastSchema = z.object({
@@ -73,6 +74,20 @@ function withCors(req: Request, res: Response): Response {
73
74
  const wsTmuxIntervals = new Map<object, ReturnType<typeof setInterval>>();
74
75
 
75
76
 
77
+ // ── Task expiration patrol (every 5 minutes) ──
78
+ setInterval(() => {
79
+ try {
80
+ const result = db.run(
81
+ `UPDATE tasks SET status = 'expired', completed_at = datetime('now')
82
+ WHERE expires_at IS NOT NULL AND expires_at < datetime('now')
83
+ AND status IN ('created', 'delivered')`
84
+ );
85
+ if (result.changes > 0) {
86
+ console.log(`[patrol] expired ${result.changes} stale task(s)`);
87
+ }
88
+ } catch {}
89
+ }, 5 * 60 * 1000);
90
+
76
91
  Bun.serve({
77
92
  port: PORT,
78
93
  idleTimeout: 255, // max value: keep SSE connections alive (seconds)
@@ -87,13 +102,16 @@ Bun.serve({
87
102
 
88
103
  // ── WebSocket: tmux terminal ──
89
104
  const wsMatch = url.pathname.match(/^\/ws\/tmux\/([a-zA-Z0-9_-]+)$/);
90
- if (wsMatch && server.upgrade(req, { data: { tmuxName: wsMatch[1] } })) {
91
- return; // upgraded
105
+ if (wsMatch) {
106
+ const authErr = requireAuth(req);
107
+ if (authErr) return withCors(req, authErr);
108
+ if (server.upgrade(req, { data: { tmuxName: wsMatch[1] } })) return;
92
109
  }
93
110
 
94
111
  // ── MCP Streamable HTTP endpoint ──
95
- // MCP protocol handles its own auth — skip token check here
96
112
  if (url.pathname === "/mcp") {
113
+ const authErr = requireAuth(req);
114
+ if (authErr) return withCors(req, authErr);
97
115
  const fwd = req.headers.get("x-forwarded-for");
98
116
  const clientIP = fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
99
117
  const transport = new WebStandardStreamableHTTPServerTransport({
@@ -159,16 +177,17 @@ Bun.serve({
159
177
  }
160
178
  const body = parsed.data;
161
179
  const id = crypto.randomUUID();
180
+ const fromSession = body.from || "api";
162
181
  db.run(
163
182
  `INSERT INTO inbox (id, session_name, type, priority, content, from_session)
164
- VALUES (?1, ?2, 'task', ?3, ?4, 'api')`,
165
- [id, body.alias, body.priority, body.task]
183
+ VALUES (?1, ?2, 'task', ?3, ?4, ?5)`,
184
+ [id, body.alias, body.priority, body.task, fromSession]
166
185
  );
167
186
  // SSE push: 秒达
168
187
  const pending = db.query<{ cnt: number }, [string]>(
169
188
  "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
170
189
  ).get(body.alias);
171
- pushEvent(body.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority });
190
+ pushEvent(body.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession });
172
191
  return withCors(req, Response.json({ ok: true, message_id: id }));
173
192
  }
174
193
 
@@ -262,6 +281,27 @@ Bun.serve({
262
281
  return withCors(req, Response.json({ ok: true, messages: rows }));
263
282
  }
264
283
 
284
+ // ── REST: tasks table (V2) ──
285
+ if (url.pathname === "/api/tasks") {
286
+ const taskId = url.searchParams.get("task_id");
287
+ const status = url.searchParams.get("status");
288
+ const toName = url.searchParams.get("to_name");
289
+ const fromName = url.searchParams.get("from_name");
290
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
291
+
292
+ let sql = "SELECT * FROM tasks WHERE 1=1";
293
+ const params: any[] = [];
294
+ if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
295
+ if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
296
+ if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
297
+ if (fromName) { sql += ` AND from_name = ?${params.length + 1}`; params.push(fromName); }
298
+ sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
299
+ params.push(limit);
300
+
301
+ const rows = db.query(sql).all(...params);
302
+ return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length }));
303
+ }
304
+
265
305
  // ── REST: recent completions ──
266
306
  if (url.pathname === "/api/completions") {
267
307
  const since = url.searchParams.get("since") ?? new Date(Date.now() - 86400000).toISOString();
@@ -278,6 +318,7 @@ Endpoints:
278
318
  GET /health - Health check
279
319
  GET /api/status - All sessions ${AUTH_TOKEN ? "(auth required)" : ""}
280
320
  POST /api/task - Send task via REST ${AUTH_TOKEN ? "(auth required)" : ""}
321
+ GET /api/tasks - Tasks table (V2) ${AUTH_TOKEN ? "(auth required)" : ""}
281
322
  GET /api/completions - Recent completions ${AUTH_TOKEN ? "(auth required)" : ""}
282
323
  GET /api/tmux/:name - Capture tmux pane output ${AUTH_TOKEN ? "(auth required)" : ""}
283
324
  POST /api/tmux/:name/send - Send keys to tmux ${AUTH_TOKEN ? "(auth required)" : ""}
package/src/tools.ts CHANGED
@@ -18,7 +18,7 @@ export function registerTools(server: McpServer, clientIP?: string) {
18
18
  {
19
19
  resume_id: z.string().min(1).max(200).describe("Claude Code session UUID (unique per session)"),
20
20
  alias: z.string().min(1).max(200).describe("Human-readable session name for dispatching (e.g. 指挥室/知识哥)"),
21
- status: z.enum(["working", "idle", "blocked", "error", "waiting_input"]),
21
+ status: z.enum(["working", "idle", "blocked", "error", "waiting_input", "offline"]),
22
22
  task: z.string().max(10000).optional().describe("Current task description"),
23
23
  output: z.string().max(50000).optional().describe("Recent output (max 4000 chars stored)"),
24
24
  score: z.number().min(0).max(10).optional().describe("Self-score 1-10"),
@@ -29,36 +29,60 @@ export function registerTools(server: McpServer, clientIP?: string) {
29
29
  project_dir: z.string().max(1000).optional().describe("Agent working directory"),
30
30
  version: z.string().max(100).optional().describe("Agent version"),
31
31
  tmux_name: z.string().max(200).optional().describe("tmux session name"),
32
+ // V2 fields
33
+ node_id: z.string().max(200).optional().describe("Stable node identifier"),
34
+ session_id: z.string().max(200).optional().describe("Runtime session/thread ID"),
35
+ config_path: z.string().max(1000).optional().describe("Config file path"),
36
+ channels: z.string().max(2000).optional().describe("JSON array of channels"),
32
37
  },
33
- async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux }) => {
38
+ 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 }) => {
34
39
  console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}`);
35
40
  const trimmedOutput = output?.slice(0, 4000);
36
41
 
37
- // Wrap DELETE + UPSERT in transaction to prevent race conditions
38
- db.run("BEGIN IMMEDIATE");
39
- db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
40
- db.run(
41
- `INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, updated_at)
42
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, datetime('now'))
43
- ON CONFLICT(resume_id) DO UPDATE SET
44
- alias = COALESCE(?2, sessions.alias),
45
- tmux_name = COALESCE(?3, sessions.tmux_name),
46
- server = COALESCE(?4, sessions.server),
47
- ip = COALESCE(?5, sessions.ip),
48
- hostname = COALESCE(?6, sessions.hostname),
49
- agent = COALESCE(?7, sessions.agent),
50
- project_dir = COALESCE(?8, sessions.project_dir),
51
- version = COALESCE(?9, sessions.version),
52
- status = ?10,
53
- task = COALESCE(?11, sessions.task),
54
- output = COALESCE(?12, sessions.output),
55
- progress = COALESCE(?13, sessions.progress),
56
- score = COALESCE(?14, sessions.score),
57
- updated_at = datetime('now')`,
58
- [resume_id, alias, tmux ?? null, srv ?? null, clientIP ?? null, hn ?? null, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null]
59
- );
42
+ try {
43
+ db.run("BEGIN IMMEDIATE");
44
+ db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
45
+ db.run(
46
+ `INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, node_id, session_id, config_path, channels, last_seen_at, updated_at)
47
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, datetime('now'), datetime('now'))
48
+ ON CONFLICT(resume_id) DO UPDATE SET
49
+ alias = COALESCE(?2, sessions.alias),
50
+ tmux_name = COALESCE(?3, sessions.tmux_name),
51
+ server = COALESCE(?4, sessions.server),
52
+ ip = COALESCE(?5, sessions.ip),
53
+ hostname = COALESCE(?6, sessions.hostname),
54
+ agent = COALESCE(?7, sessions.agent),
55
+ project_dir = COALESCE(?8, sessions.project_dir),
56
+ version = COALESCE(?9, sessions.version),
57
+ status = ?10,
58
+ task = COALESCE(?11, sessions.task),
59
+ output = COALESCE(?12, sessions.output),
60
+ progress = COALESCE(?13, sessions.progress),
61
+ score = COALESCE(?14, sessions.score),
62
+ node_id = COALESCE(?15, sessions.node_id),
63
+ session_id = COALESCE(?16, sessions.session_id),
64
+ config_path = COALESCE(?17, sessions.config_path),
65
+ channels = COALESCE(?18, sessions.channels),
66
+ last_seen_at = datetime('now'),
67
+ updated_at = datetime('now')`,
68
+ [resume_id, alias, tmux ?? null, srv ?? null, clientIP ?? null, hn ?? null, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null]
69
+ );
70
+ db.run("COMMIT");
71
+ } catch (e) {
72
+ try { db.run("ROLLBACK"); } catch {}
73
+ throw e;
74
+ }
60
75
 
61
- db.run("COMMIT");
76
+ // V2: sync tasks table — report_status(working) → tasks.running
77
+ if (status === "working" && task) {
78
+ try {
79
+ db.run(
80
+ `UPDATE tasks SET status = 'running', started_at = datetime('now')
81
+ WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`,
82
+ [alias, task]
83
+ );
84
+ } catch {}
85
+ }
62
86
 
63
87
  // inbox uses alias for routing
64
88
  const row = db.query<{ cnt: number }, [string]>(
@@ -95,17 +119,46 @@ export function registerTools(server: McpServer, clientIP?: string) {
95
119
  async ({ alias, task, result, artifacts, score, duration_minutes }) => {
96
120
  console.log(`[${ts()}] ${alias} → report_completion: ${task.slice(0, 60)}`);
97
121
  const id = uuidv4();
98
- db.run(
99
- `INSERT INTO completions (id, session_name, task, result, artifacts, score, duration_minutes)
100
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
101
- [id, alias, task, result, artifacts ? JSON.stringify(artifacts) : null, score ?? null, duration_minutes ?? null]
102
- );
122
+ try {
123
+ db.run("BEGIN IMMEDIATE");
124
+ db.run(
125
+ `INSERT INTO completions (id, session_name, task, result, artifacts, score, duration_minutes)
126
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
127
+ [id, alias, task, result, artifacts ? JSON.stringify(artifacts) : null, score ?? null, duration_minutes ?? null]
128
+ );
103
129
 
104
- db.run(
105
- `UPDATE sessions SET status = 'idle', task = NULL, progress = 0, updated_at = datetime('now')
106
- WHERE alias = ?1`,
107
- [alias]
108
- );
130
+ db.run(
131
+ `UPDATE sessions SET status = 'idle', task = NULL, progress = 0, updated_at = datetime('now')
132
+ WHERE alias = ?1`,
133
+ [alias]
134
+ );
135
+
136
+ // V2: sync tasks table — try by task_id first, then by content
137
+ const taskUpdate = db.run(
138
+ `UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now')
139
+ WHERE task_id = ?2 AND status IN ('delivered', 'acked', 'running')`,
140
+ [result.slice(0, 4000), task]
141
+ );
142
+ if (taskUpdate.changes === 0) {
143
+ // fallback: match most recent task by to_name + content (legacy path)
144
+ const match = db.query<{ task_id: string }, [string, string]>(
145
+ `SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2
146
+ AND status IN ('delivered', 'acked', 'running') ORDER BY created_at DESC LIMIT 1`
147
+ ).get(alias, task);
148
+ if (match) {
149
+ db.run(
150
+ `UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now')
151
+ WHERE task_id = ?2`,
152
+ [result.slice(0, 4000), match.task_id]
153
+ );
154
+ }
155
+ }
156
+
157
+ db.run("COMMIT");
158
+ } catch (e) {
159
+ try { db.run("ROLLBACK"); } catch {}
160
+ throw e;
161
+ }
109
162
 
110
163
  return {
111
164
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, completion_id: id }) }],
@@ -154,6 +207,13 @@ export function registerTools(server: McpServer, clientIP?: string) {
154
207
  content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or not yours" }) }],
155
208
  };
156
209
  }
210
+ // V2: sync tasks table — ack_inbox means delivered→acked
211
+ try {
212
+ db.run(
213
+ `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'`,
214
+ [message_id]
215
+ );
216
+ } catch {}
157
217
  return {
158
218
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
159
219
  };
@@ -239,12 +299,24 @@ export function registerTools(server: McpServer, clientIP?: string) {
239
299
  async ({ alias, task, priority, context, from_session }) => {
240
300
  console.log(`[${ts()}] ${from_session} → send_task → ${alias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}`);
241
301
  const id = uuidv4();
242
- // inbox.session_name stores alias
243
- db.run(
244
- `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session)
245
- VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6)`,
246
- [id, alias, priority, task, context ?? null, from_session]
247
- );
302
+ // 事务:inbox + tasks 双写
303
+ try {
304
+ db.run("BEGIN IMMEDIATE");
305
+ db.run(
306
+ `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response)
307
+ VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply')`,
308
+ [id, alias, priority, task, context ?? null, from_session]
309
+ );
310
+ db.run(
311
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at)
312
+ VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', '+1 hour'))`,
313
+ [id, from_session, alias, priority, task]
314
+ );
315
+ db.run("COMMIT");
316
+ } catch (e) {
317
+ try { db.run("ROLLBACK"); } catch {}
318
+ throw e;
319
+ }
248
320
 
249
321
  const session = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
250
322
 
@@ -305,6 +377,80 @@ export function registerTools(server: McpServer, clientIP?: string) {
305
377
  }
306
378
  );
307
379
 
380
+ // ── V2: send_reply (关联 task_id,不触发 think) ──
381
+ server.tool(
382
+ "send_reply",
383
+ "Send a reply to a task. Linked to task_id via in_reply_to. Does NOT trigger agent processing.",
384
+ {
385
+ alias: z.string().min(1).max(200).describe("Target session alias"),
386
+ text: z.string().min(1).max(10000).describe("Reply content"),
387
+ in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
388
+ status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
389
+ from_session: z.string().max(200).optional().default("hub"),
390
+ },
391
+ async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
392
+ console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
393
+ const id = uuidv4();
394
+ try {
395
+ db.run("BEGIN IMMEDIATE");
396
+ db.run(
397
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response)
398
+ VALUES (?1, ?2, 'reply', 'normal', ?3, ?4, ?5, 'none')`,
399
+ [id, alias, text, from_session, in_reply_to ?? null]
400
+ );
401
+
402
+ // 更新 tasks 表
403
+ if (in_reply_to) {
404
+ const result = db.run(
405
+ `UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now')
406
+ WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')`,
407
+ [replyStatus, text, in_reply_to]
408
+ );
409
+ if (result.changes === 0) {
410
+ console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
411
+ }
412
+ }
413
+ db.run("COMMIT");
414
+ } catch (e) {
415
+ try { db.run("ROLLBACK"); } catch {}
416
+ throw e;
417
+ }
418
+
419
+ const session = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
420
+ pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
421
+
422
+ return {
423
+ content: [{
424
+ type: "text" as const,
425
+ text: JSON.stringify({ ok: true, message_id: id, session_status: session?.status ?? "unknown" }),
426
+ }],
427
+ };
428
+ }
429
+ );
430
+
431
+ // ── V2: send_ack (不入 inbox,仅更新状态) ──
432
+ server.tool(
433
+ "send_ack",
434
+ "Acknowledge receipt of a task. Does NOT enter inbox. Updates task status only.",
435
+ {
436
+ task_id: z.string().min(1).max(200).describe("Task ID to acknowledge"),
437
+ from_session: z.string().max(200).optional().default("hub"),
438
+ },
439
+ async ({ task_id, from_session }) => {
440
+ console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
441
+ const result = db.run(
442
+ `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
443
+ [task_id]
444
+ );
445
+ return {
446
+ content: [{
447
+ type: "text" as const,
448
+ text: JSON.stringify({ ok: result.changes > 0, task_id, updated: result.changes }),
449
+ }],
450
+ };
451
+ }
452
+ );
453
+
308
454
  server.tool(
309
455
  "broadcast",
310
456
  "Send a message to multiple sessions.",