@sleep2agi/commhub-server 0.5.2-preview.0 → 0.5.3-preview.0

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.
Files changed (3) hide show
  1. package/package.json +5 -4
  2. package/src/db.ts +78 -0
  3. package/src/tools.ts +66 -6
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.5.2-preview.0",
4
- "description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 18 MCP tools.",
3
+ "version": "0.5.3-preview.0",
4
+ "description": "CommHub Server AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 18 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "bin": {
@@ -39,6 +39,7 @@
39
39
  "bun": ">=1.2.0"
40
40
  },
41
41
  "dependencies": {
42
- "@modelcontextprotocol/sdk": "^1.12.0"
42
+ "@modelcontextprotocol/sdk": "^1.12.0",
43
+ "bun-types": "^1.3.13"
43
44
  }
44
- }
45
+ }
package/src/db.ts CHANGED
@@ -307,6 +307,13 @@ try { db.exec("CREATE INDEX IF NOT EXISTS idx_inbox_network ON inbox(network_id)
307
307
  try { db.exec("CREATE INDEX IF NOT EXISTS idx_task_events_network ON task_events(network_id)"); } catch {}
308
308
  try { db.exec("CREATE INDEX IF NOT EXISTS idx_completions_network ON completions(network_id)"); } catch {}
309
309
 
310
+ // ── Task lineage: parent_task_id for auto-chaining sub-task replies up the chain.
311
+ // When 主编 (child) replies to a task that 指挥室 sent on behalf of admin, we want
312
+ // admin to see the answer even if 指挥室's own session has died. The hub forwards
313
+ // the reply up the chain via parent_task_id.
314
+ try { db.exec("ALTER TABLE tasks ADD COLUMN parent_task_id TEXT"); } catch {}
315
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id)"); } catch {}
316
+
310
317
  // Helpers
311
318
  export function uuidv4(): string {
312
319
  return crypto.randomUUID();
@@ -345,6 +352,77 @@ export function logAudit(userId: string | null, username: string | null, action:
345
352
  } catch {}
346
353
  }
347
354
 
355
+ // Auto-chain a sub-task's reply up to the parent task lineage so the original
356
+ // requester sees the final answer even if the intermediate session timed out.
357
+ //
358
+ // Why: 链路 admin → 指挥室 → 主编. 主编 finishes 5min later, but 指挥室's
359
+ // session has already ended (replied with 'still working'). Without lineage,
360
+ // 主编's reply just sits in 指挥室's inbox unread and admin never gets the
361
+ // answer.
362
+ //
363
+ // What we do: when a child task with parent_task_id reaches a terminal state,
364
+ // append the child reply to the parent task's result, bump parent status to
365
+ // 'replied' if it's still open, and post an inbox notification to the parent's
366
+ // originator (so an upstream agent or the dashboard sees the chained answer).
367
+ // Recurse up the chain (in case of N hops).
368
+ export function chainReplyToParent(childTaskId: string, replyText: string, replyStatus: "replied" | "failed" | "cancelled" = "replied", maxDepth = 5): void {
369
+ let currentChildId: string | null = childTaskId;
370
+ let currentReply = replyText;
371
+ let depth = 0;
372
+ while (currentChildId && depth < maxDepth) {
373
+ depth++;
374
+ type ChildRow = { parent_task_id: string | null; to_name: string; from_name: string; content: string };
375
+ type ParentRow = { task_id: string; from_name: string; to_name: string; status: string; result: string | null; network_id: string | null; parent_task_id: string | null };
376
+ const child: ChildRow | null = db.get<ChildRow>(
377
+ "SELECT parent_task_id, to_name, from_name, content FROM tasks WHERE task_id = ?1",
378
+ currentChildId
379
+ );
380
+ if (!child?.parent_task_id) return;
381
+ const parent: ParentRow | null = db.get<ParentRow>(
382
+ "SELECT task_id, from_name, to_name, status, result, network_id, parent_task_id FROM tasks WHERE task_id = ?1",
383
+ child.parent_task_id
384
+ );
385
+ if (!parent) return;
386
+
387
+ const childAlias = child.to_name;
388
+ const marker = `\n\n[via ${childAlias} 子任务结果]\n${currentReply}`;
389
+ const newResult = parent.result ? parent.result + marker : `[via ${childAlias} 子任务结果]\n${currentReply}`;
390
+
391
+ // Bump parent status to replied if still open. If it was already replied
392
+ // (e.g. 指挥室 sent an early status update), we still want to update the
393
+ // result so the dashboard can render the final chained answer — but keep
394
+ // status as replied (it was already terminal).
395
+ if (parent.status === "delivered" || parent.status === "acked" || parent.status === "running" || parent.status === "created") {
396
+ db.run(
397
+ "UPDATE tasks SET status = ?1, result = ?2, completed_at = datetime('now') WHERE task_id = ?3",
398
+ [replyStatus, newResult.slice(0, 8000), parent.task_id]
399
+ );
400
+ logTaskEvent(parent.task_id, parent.status, replyStatus, "auto-chain", `from ${childAlias}`);
401
+ } else {
402
+ db.run(
403
+ "UPDATE tasks SET result = ?1, completed_at = datetime('now') WHERE task_id = ?2",
404
+ [newResult.slice(0, 8000), parent.task_id]
405
+ );
406
+ logTaskEvent(parent.task_id, parent.status, parent.status, "auto-chain-append", `from ${childAlias}`);
407
+ }
408
+
409
+ if (parent.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
410
+ try {
411
+ const notifyId = `chain_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
412
+ db.run(
413
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response, network_id)
414
+ VALUES (?1, ?2, 'reply', 'normal', ?3, ?4, ?5, 'none', ?6)`,
415
+ [notifyId, parent.from_name, `[${childAlias} 子任务完成]\n${currentReply.slice(0, 4000)}`, parent.to_name, parent.task_id, parent.network_id ?? null]
416
+ );
417
+ } catch {}
418
+ }
419
+
420
+ // Recurse up the chain.
421
+ currentChildId = parent.task_id;
422
+ currentReply = newResult;
423
+ }
424
+ }
425
+
348
426
  export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
349
427
  try {
350
428
  db.run(
package/src/tools.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod/v4";
3
- import { db, uuidv4, logTaskEvent } from "./db.js";
3
+ import { db, uuidv4, logTaskEvent, chainReplyToParent } from "./db.js";
4
4
  import { pushEvent, pushBroadcast } from "./push.js";
5
5
  import { getUserNetworkRole } from "./auth.js";
6
6
 
@@ -230,6 +230,28 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
230
230
  // Log event after transaction
231
231
  if (updatedTaskId) logTaskEvent(updatedTaskId, null, "replied", alias, "report_completion");
232
232
 
233
+ // Auto-chain to parent lineage (mirror of send_reply path).
234
+ if (updatedTaskId) {
235
+ try {
236
+ chainReplyToParent(updatedTaskId, result, "replied");
237
+ const parentChain = db.get<{ parent_task_id: string | null }>(
238
+ "SELECT parent_task_id FROM tasks WHERE task_id = ?1",
239
+ [updatedTaskId]
240
+ );
241
+ if (parentChain?.parent_task_id) {
242
+ const parent = db.get<{ from_name: string; task_id: string }>(
243
+ "SELECT from_name, task_id FROM tasks WHERE task_id = ?1",
244
+ [parentChain.parent_task_id]
245
+ );
246
+ if (parent?.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
247
+ pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: updatedTaskId, child_alias: alias });
248
+ }
249
+ }
250
+ } catch (e: any) {
251
+ console.log(`[${ts()}] ⚠ chainReplyToParent (completion) failed: ${e.message}`);
252
+ }
253
+ }
254
+
233
255
  return {
234
256
  content: [{ type: "text" as const, text: JSON.stringify({ ok: true, completion_id: id }) }],
235
257
  };
@@ -394,9 +416,23 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
394
416
  from_session: z.string().max(200).optional(),
395
417
  ttl_seconds: z.number().min(1).max(86400).optional().describe("Task TTL in seconds (default: 3600)"),
396
418
  network_id: z.string().max(200).optional().describe("Network scope"),
419
+ parent_task_id: z.string().max(200).optional().describe("Parent task this dispatch is on behalf of. When the child task replies the hub will auto-chain the answer to the parent task's originator, so the user sees the final result even if the intermediate session ends."),
397
420
  },
398
- async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId }) => { const from_session = defaultFrom(_fromIn);
421
+ async ({ alias, task, priority, context, from_session: _fromIn, ttl_seconds, network_id: netId, parent_task_id: parentIn }) => { const from_session = defaultFrom(_fromIn);
399
422
  const effectiveNetId = getNetworkId(netId);
423
+ // Resolve parent_task_id: explicit > inferred (caller's most recent
424
+ // delivered/started inbox task that's still open). Inference is the
425
+ // safety net for when the LLM forgets to pass parent_task_id.
426
+ let parentTaskId: string | null = parentIn ?? null;
427
+ if (!parentTaskId && from_session && from_session !== "hub" && from_session !== "api") {
428
+ try {
429
+ const recent = db.get<{ task_id: string }>(
430
+ "SELECT task_id FROM tasks WHERE to_name = ?1 AND status IN ('delivered','started') ORDER BY created_at DESC LIMIT 1",
431
+ [from_session]
432
+ );
433
+ if (recent?.task_id) parentTaskId = recent.task_id;
434
+ } catch {}
435
+ }
400
436
 
401
437
  // Role check: viewer cannot send tasks
402
438
  if (!canWrite(effectiveNetId)) {
@@ -425,12 +461,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
425
461
  [id, alias, priority, task, context ?? null, from_session, effectiveNetId ?? null]
426
462
  );
427
463
  db.run(
428
- `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id)
429
- VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7)`,
430
- [id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null]
464
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, parent_task_id)
465
+ VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7, ?8)`,
466
+ [id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null, parentTaskId]
431
467
  );
432
468
  });
433
- logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
469
+ logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${alias} (parent=${parentTaskId.slice(0,8)})` : `→ ${alias}`);
434
470
 
435
471
  const session = scopedSessionStatus(alias, effectiveNetId);
436
472
 
@@ -542,6 +578,30 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
542
578
  // Log event after commit (outside transaction)
543
579
  if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
544
580
 
581
+ // Auto-chain reply up to parent task lineage so admin sees the final
582
+ // answer even if the intermediate session has died.
583
+ if (replyLogged && in_reply_to) {
584
+ try {
585
+ chainReplyToParent(in_reply_to, text, replyStatus);
586
+ // Push SSE event for parent originator if there is a chain.
587
+ const parentChain = db.get<{ parent_task_id: string | null; from_name: string }>(
588
+ "SELECT parent_task_id, from_name FROM tasks WHERE task_id = ?1",
589
+ [in_reply_to]
590
+ );
591
+ if (parentChain?.parent_task_id) {
592
+ const parent = db.get<{ from_name: string; task_id: string }>(
593
+ "SELECT from_name, task_id FROM tasks WHERE task_id = ?1",
594
+ [parentChain.parent_task_id]
595
+ );
596
+ if (parent?.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
597
+ pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: in_reply_to, child_alias: alias });
598
+ }
599
+ }
600
+ } catch (e: any) {
601
+ console.log(`[${ts()}] ⚠ chainReplyToParent failed: ${e.message}`);
602
+ }
603
+ }
604
+
545
605
  const session = scopedSessionStatus(alias, effectiveNetId);
546
606
  pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
547
607