@sleep2agi/commhub-server 0.4.4 → 0.5.0-preview.2

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.4",
3
+ "version": "0.5.0-preview.2",
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,83 @@ 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
+
118
+ // nodes table (V2 Sprint 2) — persistent node identity, separate from runtime sessions
119
+ db.exec(`
120
+ CREATE TABLE IF NOT EXISTS nodes (
121
+ node_id TEXT PRIMARY KEY,
122
+ node_name TEXT NOT NULL,
123
+ alias TEXT,
124
+ runtime TEXT,
125
+ model TEXT,
126
+ config_path TEXT,
127
+ channels TEXT,
128
+ server TEXT,
129
+ hostname TEXT,
130
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
131
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
132
+ );
133
+
134
+ CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(node_name);
135
+ CREATE INDEX IF NOT EXISTS idx_nodes_alias ON nodes(alias);
136
+ `);
137
+
61
138
  // Helpers
62
139
  export function uuidv4(): string {
63
140
  return crypto.randomUUID();
package/src/index.ts CHANGED
@@ -74,6 +74,20 @@ function withCors(req: Request, res: Response): Response {
74
74
  const wsTmuxIntervals = new Map<object, ReturnType<typeof setInterval>>();
75
75
 
76
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
+
77
91
  Bun.serve({
78
92
  port: PORT,
79
93
  idleTimeout: 255, // max value: keep SSE connections alive (seconds)
@@ -88,13 +102,16 @@ Bun.serve({
88
102
 
89
103
  // ── WebSocket: tmux terminal ──
90
104
  const wsMatch = url.pathname.match(/^\/ws\/tmux\/([a-zA-Z0-9_-]+)$/);
91
- if (wsMatch && server.upgrade(req, { data: { tmuxName: wsMatch[1] } })) {
92
- 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;
93
109
  }
94
110
 
95
111
  // ── MCP Streamable HTTP endpoint ──
96
- // MCP protocol handles its own auth — skip token check here
97
112
  if (url.pathname === "/mcp") {
113
+ const authErr = requireAuth(req);
114
+ if (authErr) return withCors(req, authErr);
98
115
  const fwd = req.headers.get("x-forwarded-for");
99
116
  const clientIP = fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
100
117
  const transport = new WebStandardStreamableHTTPServerTransport({
@@ -264,6 +281,40 @@ Bun.serve({
264
281
  return withCors(req, Response.json({ ok: true, messages: rows }));
265
282
  }
266
283
 
284
+ // ── REST: nodes table (V2 Sprint 2) ──
285
+ if (url.pathname === "/api/nodes") {
286
+ const nodeId = url.searchParams.get("node_id");
287
+ const alias = url.searchParams.get("alias");
288
+ let sql = "SELECT * FROM nodes WHERE 1=1";
289
+ const params: any[] = [];
290
+ if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
291
+ if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
292
+ sql += " ORDER BY updated_at DESC";
293
+ const rows = db.query(sql).all(...params);
294
+ return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
295
+ }
296
+
297
+ // ── REST: tasks table (V2) ──
298
+ if (url.pathname === "/api/tasks") {
299
+ const taskId = url.searchParams.get("task_id");
300
+ const status = url.searchParams.get("status");
301
+ const toName = url.searchParams.get("to_name");
302
+ const fromName = url.searchParams.get("from_name");
303
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
304
+
305
+ let sql = "SELECT * FROM tasks WHERE 1=1";
306
+ const params: any[] = [];
307
+ if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
308
+ if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
309
+ if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
310
+ if (fromName) { sql += ` AND from_name = ?${params.length + 1}`; params.push(fromName); }
311
+ sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
312
+ params.push(limit);
313
+
314
+ const rows = db.query(sql).all(...params);
315
+ return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length }));
316
+ }
317
+
267
318
  // ── REST: recent completions ──
268
319
  if (url.pathname === "/api/completions") {
269
320
  const since = url.searchParams.get("since") ?? new Date(Date.now() - 86400000).toISOString();
@@ -280,6 +331,7 @@ Endpoints:
280
331
  GET /health - Health check
281
332
  GET /api/status - All sessions ${AUTH_TOKEN ? "(auth required)" : ""}
282
333
  POST /api/task - Send task via REST ${AUTH_TOKEN ? "(auth required)" : ""}
334
+ GET /api/tasks - Tasks table (V2) ${AUTH_TOKEN ? "(auth required)" : ""}
283
335
  GET /api/completions - Recent completions ${AUTH_TOKEN ? "(auth required)" : ""}
284
336
  GET /api/tmux/:name - Capture tmux pane output ${AUTH_TOKEN ? "(auth required)" : ""}
285
337
  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,84 @@ 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"),
37
+ model: z.string().max(200).optional().describe("AI model name"),
32
38
  },
33
- async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux }) => {
39
+ 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 }) => {
34
40
  console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}`);
35
41
  const trimmedOutput = output?.slice(0, 4000);
36
42
 
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
- );
43
+ try {
44
+ db.run("BEGIN IMMEDIATE");
45
+ db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2", [alias, resume_id]);
46
+ db.run(
47
+ `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)
48
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, datetime('now'), datetime('now'))
49
+ ON CONFLICT(resume_id) DO UPDATE SET
50
+ alias = COALESCE(?2, sessions.alias),
51
+ tmux_name = COALESCE(?3, sessions.tmux_name),
52
+ server = COALESCE(?4, sessions.server),
53
+ ip = COALESCE(?5, sessions.ip),
54
+ hostname = COALESCE(?6, sessions.hostname),
55
+ agent = COALESCE(?7, sessions.agent),
56
+ project_dir = COALESCE(?8, sessions.project_dir),
57
+ version = COALESCE(?9, sessions.version),
58
+ status = ?10,
59
+ task = COALESCE(?11, sessions.task),
60
+ output = COALESCE(?12, sessions.output),
61
+ progress = COALESCE(?13, sessions.progress),
62
+ score = COALESCE(?14, sessions.score),
63
+ node_id = COALESCE(?15, sessions.node_id),
64
+ session_id = COALESCE(?16, sessions.session_id),
65
+ config_path = COALESCE(?17, sessions.config_path),
66
+ channels = COALESCE(?18, sessions.channels),
67
+ last_seen_at = datetime('now'),
68
+ updated_at = datetime('now')`,
69
+ [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]
70
+ );
71
+ db.run("COMMIT");
72
+ } catch (e) {
73
+ try { db.run("ROLLBACK"); } catch {}
74
+ throw e;
75
+ }
76
+
77
+ // V2: sync tasks table — report_status(working) → tasks.running
78
+ if (status === "working" && task) {
79
+ try {
80
+ db.run(
81
+ `UPDATE tasks SET status = 'running', started_at = datetime('now')
82
+ WHERE to_name = ?1 AND status IN ('delivered', 'acked') AND content = ?2`,
83
+ [alias, task]
84
+ );
85
+ } catch {}
86
+ }
60
87
 
61
- db.run("COMMIT");
88
+ // V2: upsert nodes table for persistent node identity
89
+ if (node_id) {
90
+ try {
91
+ // Extract runtime from agent field (e.g., "agent-node:codex" → "codex-sdk")
92
+ const nodeRuntime = ag?.includes(":") ? ag.split(":")[1] + "-sdk" : ag ?? null;
93
+ db.run(
94
+ `INSERT INTO nodes (node_id, node_name, alias, runtime, model, config_path, channels, server, hostname, updated_at)
95
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'))
96
+ ON CONFLICT(node_id) DO UPDATE SET
97
+ node_name = COALESCE(?2, nodes.node_name),
98
+ alias = COALESCE(?3, nodes.alias),
99
+ runtime = COALESCE(?4, nodes.runtime),
100
+ model = COALESCE(?5, nodes.model),
101
+ config_path = COALESCE(?6, nodes.config_path),
102
+ channels = COALESCE(?7, nodes.channels),
103
+ server = COALESCE(?8, nodes.server),
104
+ hostname = COALESCE(?9, nodes.hostname),
105
+ updated_at = datetime('now')`,
106
+ [node_id, alias, alias, nodeRuntime, mdl ?? null, config_path ?? null, channels ?? null, srv ?? null, hn ?? null]
107
+ );
108
+ } catch {}
109
+ }
62
110
 
63
111
  // inbox uses alias for routing
64
112
  const row = db.query<{ cnt: number }, [string]>(
@@ -95,17 +143,46 @@ export function registerTools(server: McpServer, clientIP?: string) {
95
143
  async ({ alias, task, result, artifacts, score, duration_minutes }) => {
96
144
  console.log(`[${ts()}] ${alias} → report_completion: ${task.slice(0, 60)}`);
97
145
  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
- );
146
+ try {
147
+ db.run("BEGIN IMMEDIATE");
148
+ db.run(
149
+ `INSERT INTO completions (id, session_name, task, result, artifacts, score, duration_minutes)
150
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`,
151
+ [id, alias, task, result, artifacts ? JSON.stringify(artifacts) : null, score ?? null, duration_minutes ?? null]
152
+ );
103
153
 
104
- db.run(
105
- `UPDATE sessions SET status = 'idle', task = NULL, progress = 0, updated_at = datetime('now')
106
- WHERE alias = ?1`,
107
- [alias]
108
- );
154
+ db.run(
155
+ `UPDATE sessions SET status = 'idle', task = NULL, progress = 0, updated_at = datetime('now')
156
+ WHERE alias = ?1`,
157
+ [alias]
158
+ );
159
+
160
+ // V2: sync tasks table — try by task_id first, then by content
161
+ const taskUpdate = db.run(
162
+ `UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now')
163
+ WHERE task_id = ?2 AND status IN ('delivered', 'acked', 'running')`,
164
+ [result.slice(0, 4000), task]
165
+ );
166
+ if (taskUpdate.changes === 0) {
167
+ // fallback: match most recent task by to_name + content (legacy path)
168
+ const match = db.query<{ task_id: string }, [string, string]>(
169
+ `SELECT task_id FROM tasks WHERE to_name = ?1 AND content = ?2
170
+ AND status IN ('delivered', 'acked', 'running') ORDER BY created_at DESC LIMIT 1`
171
+ ).get(alias, task);
172
+ if (match) {
173
+ db.run(
174
+ `UPDATE tasks SET status = 'replied', result = ?1, completed_at = datetime('now')
175
+ WHERE task_id = ?2`,
176
+ [result.slice(0, 4000), match.task_id]
177
+ );
178
+ }
179
+ }
180
+
181
+ db.run("COMMIT");
182
+ } catch (e) {
183
+ try { db.run("ROLLBACK"); } catch {}
184
+ throw e;
185
+ }
109
186
 
110
187
  return {
111
188
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, completion_id: id }) }],
@@ -154,6 +231,13 @@ export function registerTools(server: McpServer, clientIP?: string) {
154
231
  content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message not found or not yours" }) }],
155
232
  };
156
233
  }
234
+ // V2: sync tasks table — ack_inbox means delivered→acked
235
+ try {
236
+ db.run(
237
+ `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status = 'delivered'`,
238
+ [message_id]
239
+ );
240
+ } catch {}
157
241
  return {
158
242
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true }) }],
159
243
  };
@@ -239,12 +323,24 @@ export function registerTools(server: McpServer, clientIP?: string) {
239
323
  async ({ alias, task, priority, context, from_session }) => {
240
324
  console.log(`[${ts()}] ${from_session} → send_task → ${alias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}`);
241
325
  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
- );
326
+ // 事务:inbox + tasks 双写
327
+ try {
328
+ db.run("BEGIN IMMEDIATE");
329
+ db.run(
330
+ `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response)
331
+ VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply')`,
332
+ [id, alias, priority, task, context ?? null, from_session]
333
+ );
334
+ db.run(
335
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at)
336
+ VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', '+1 hour'))`,
337
+ [id, from_session, alias, priority, task]
338
+ );
339
+ db.run("COMMIT");
340
+ } catch (e) {
341
+ try { db.run("ROLLBACK"); } catch {}
342
+ throw e;
343
+ }
248
344
 
249
345
  const session = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
250
346
 
@@ -305,6 +401,80 @@ export function registerTools(server: McpServer, clientIP?: string) {
305
401
  }
306
402
  );
307
403
 
404
+ // ── V2: send_reply (关联 task_id,不触发 think) ──
405
+ server.tool(
406
+ "send_reply",
407
+ "Send a reply to a task. Linked to task_id via in_reply_to. Does NOT trigger agent processing.",
408
+ {
409
+ alias: z.string().min(1).max(200).describe("Target session alias"),
410
+ text: z.string().min(1).max(10000).describe("Reply content"),
411
+ in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
412
+ status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
413
+ from_session: z.string().max(200).optional().default("hub"),
414
+ },
415
+ async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
416
+ console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
417
+ const id = uuidv4();
418
+ try {
419
+ db.run("BEGIN IMMEDIATE");
420
+ db.run(
421
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response)
422
+ VALUES (?1, ?2, 'reply', 'normal', ?3, ?4, ?5, 'none')`,
423
+ [id, alias, text, from_session, in_reply_to ?? null]
424
+ );
425
+
426
+ // 更新 tasks 表
427
+ if (in_reply_to) {
428
+ const result = db.run(
429
+ `UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now')
430
+ WHERE task_id = ?3 AND status IN ('created', 'delivered', 'acked', 'running')`,
431
+ [replyStatus, text, in_reply_to]
432
+ );
433
+ if (result.changes === 0) {
434
+ console.log(`[${ts()}] ⚠ send_reply: task ${in_reply_to?.slice(0, 8)} not found or already terminal`);
435
+ }
436
+ }
437
+ db.run("COMMIT");
438
+ } catch (e) {
439
+ try { db.run("ROLLBACK"); } catch {}
440
+ throw e;
441
+ }
442
+
443
+ const session = db.query<any, [string]>("SELECT status FROM sessions WHERE alias = ?1").get(alias);
444
+ pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
445
+
446
+ return {
447
+ content: [{
448
+ type: "text" as const,
449
+ text: JSON.stringify({ ok: true, message_id: id, session_status: session?.status ?? "unknown" }),
450
+ }],
451
+ };
452
+ }
453
+ );
454
+
455
+ // ── V2: send_ack (不入 inbox,仅更新状态) ──
456
+ server.tool(
457
+ "send_ack",
458
+ "Acknowledge receipt of a task. Does NOT enter inbox. Updates task status only.",
459
+ {
460
+ task_id: z.string().min(1).max(200).describe("Task ID to acknowledge"),
461
+ from_session: z.string().max(200).optional().default("hub"),
462
+ },
463
+ async ({ task_id, from_session }) => {
464
+ console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
465
+ const result = db.run(
466
+ `UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
467
+ [task_id]
468
+ );
469
+ return {
470
+ content: [{
471
+ type: "text" as const,
472
+ text: JSON.stringify({ ok: result.changes > 0, task_id, updated: result.changes }),
473
+ }],
474
+ };
475
+ }
476
+ );
477
+
308
478
  server.tool(
309
479
  "broadcast",
310
480
  "Send a message to multiple sessions.",