@sleep2agi/commhub-server 0.5.2-preview.0 → 0.5.4-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.
- package/README.md +10 -11
- package/package.json +5 -4
- package/src/db.ts +78 -0
- package/src/index.ts +26 -5
- package/src/tools.ts +74 -7
package/README.md
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
CommHub: MCP Streamable HTTP + SSE push + REST API for an AI agent network. Single-process Bun server, SQLite-backed, zero config.
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**Current preview line.** The supported path is to install the `anet` CLI (`@sleep2agi/agent-network` 2.0.3-preview.4) and run `anet hub start`, which wires up port, the server token, the default account, and local config for you.
|
|
6
6
|
|
|
7
7
|
## Quick start (verified)
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
# Recommended — through the anet CLI
|
|
11
|
-
npm install -g @sleep2agi/agent-network
|
|
11
|
+
npm install -g @sleep2agi/agent-network@preview
|
|
12
12
|
anet hub start
|
|
13
|
-
# • http://127.0.0.1:9200
|
|
13
|
+
# • http://127.0.0.1:9200 by default
|
|
14
14
|
# • SQLite at ~/.commhub/commhub.db
|
|
15
15
|
# • Default admin account auto-created: admin / anethub
|
|
16
16
|
# • Reset hint printed in the launch banner
|
|
@@ -35,9 +35,9 @@ Once running:
|
|
|
35
35
|
|
|
36
36
|
| Package | Version |
|
|
37
37
|
|---|---|
|
|
38
|
-
| [`@sleep2agi/agent-network`](https://www.npmjs.com/package/@sleep2agi/agent-network) | 2.0.
|
|
39
|
-
| [`@sleep2agi/agent-network-dashboard`](https://www.npmjs.com/package/@sleep2agi/agent-network-dashboard) | 0.1.
|
|
40
|
-
| [`@sleep2agi/agent-node`](https://www.npmjs.com/package/@sleep2agi/agent-node) | 2.
|
|
38
|
+
| [`@sleep2agi/agent-network`](https://www.npmjs.com/package/@sleep2agi/agent-network) | 2.0.3-preview.4 |
|
|
39
|
+
| [`@sleep2agi/agent-network-dashboard`](https://www.npmjs.com/package/@sleep2agi/agent-network-dashboard) | 0.2.1-preview.1 |
|
|
40
|
+
| [`@sleep2agi/agent-node`](https://www.npmjs.com/package/@sleep2agi/agent-node) | 2.2.0-preview.1 |
|
|
41
41
|
|
|
42
42
|
## MCP tools (18)
|
|
43
43
|
|
|
@@ -87,7 +87,7 @@ The server exposes ~33 endpoints across health, auth, networks, and observabilit
|
|
|
87
87
|
| GET | `/api/stats` | Aggregate stats |
|
|
88
88
|
| GET | `/api/audit-log` | Audit trail |
|
|
89
89
|
|
|
90
|
-
Network-management endpoints (`/api/networks…`) and `/api/license[…]`
|
|
90
|
+
Network-management endpoints (`/api/networks…`) are present and used by the current CLI. `/api/license[…]` is present but remains a placeholder.
|
|
91
91
|
|
|
92
92
|
Auth: `Authorization: Bearer <token>` header, or `?token=<token>` query.
|
|
93
93
|
|
|
@@ -123,7 +123,7 @@ delivered/acked/running → reassign → delivered (new agent)
|
|
|
123
123
|
|
|
124
124
|
## PostgreSQL (experimental)
|
|
125
125
|
|
|
126
|
-
Set `DATABASE_URL` to switch to PostgreSQL — the SQL layer auto-translates SQLite-isms (datetime, parameter placeholders) so application code is unchanged. Requires `bun add pg`.
|
|
126
|
+
Set `DATABASE_URL` to switch to PostgreSQL — the SQL layer auto-translates SQLite-isms (datetime, parameter placeholders) so application code is unchanged. Requires `bun add pg`. PostgreSQL remains experimental.
|
|
127
127
|
|
|
128
128
|
```bash
|
|
129
129
|
DATABASE_URL=postgres://user:pass@host:5432/commhub bunx @sleep2agi/commhub-server
|
|
@@ -134,7 +134,7 @@ DATABASE_URL=postgres://user:pass@host:5432/commhub bunx @sleep2agi/commhub-serv
|
|
|
134
134
|
| Variable | Default | Notes |
|
|
135
135
|
|---|---|---|
|
|
136
136
|
| `PORT` | `9200` | listen port |
|
|
137
|
-
| `HOST` | `0.0.0.0` | listen address |
|
|
137
|
+
| `HOST` | `0.0.0.0` in the server package, `127.0.0.1` when launched by `anet hub start` | listen address |
|
|
138
138
|
| `COMMHUB_AUTH_TOKEN` | (none) | Bearer token gate (legacy) |
|
|
139
139
|
| `COMMHUB_DB` | `~/.commhub/commhub.db` | SQLite path |
|
|
140
140
|
| `DATABASE_URL` | (none) | switches to PostgreSQL when set (unverified) |
|
|
@@ -148,10 +148,9 @@ DATABASE_URL=postgres://user:pass@host:5432/commhub bunx @sleep2agi/commhub-serv
|
|
|
148
148
|
|
|
149
149
|
## Not verified
|
|
150
150
|
|
|
151
|
-
- `/api/networks*` (multi-network create / invite / member management) — code present, not E2E regressed.
|
|
152
151
|
- `/api/license*` — placeholder for a future paid tier.
|
|
153
152
|
- PostgreSQL backend — translation layer exists, no E2E run.
|
|
154
|
-
- Telegram / WeChat / Feishu channel endpoints —
|
|
153
|
+
- Telegram / WeChat / Feishu channel endpoints — channel code exists, but only Telegram-oriented agent-node paths are actively exercised.
|
|
155
154
|
|
|
156
155
|
## License
|
|
157
156
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "CommHub Server
|
|
3
|
+
"version": "0.5.4-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/index.ts
CHANGED
|
@@ -699,11 +699,32 @@ Bun.serve({
|
|
|
699
699
|
}
|
|
700
700
|
const id = crypto.randomUUID();
|
|
701
701
|
const fromSession = body.from || "api";
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
702
|
+
const ttlSeconds = (body as any).ttl_seconds || 3600;
|
|
703
|
+
// Mirror send_task MCP: write inbox + tasks rows in a single
|
|
704
|
+
// transaction so the dispatch is visible to dashboard's Tasks page
|
|
705
|
+
// and the parent_task_id lineage chain. Previously this endpoint
|
|
706
|
+
// only wrote inbox, leaving GET /api/tasks empty for any task
|
|
707
|
+
// dispatched via REST (anet demo, dashboard Dispatch button, etc.).
|
|
708
|
+
db.transaction(() => {
|
|
709
|
+
db.run(
|
|
710
|
+
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id)
|
|
711
|
+
VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6)`,
|
|
712
|
+
[id, body.alias, body.priority, body.task, fromSession, taskNetId]
|
|
713
|
+
);
|
|
714
|
+
db.run(
|
|
715
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id)
|
|
716
|
+
VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7)`,
|
|
717
|
+
[id, fromSession, body.alias, body.priority, body.task, `+${ttlSeconds} seconds`, taskNetId]
|
|
718
|
+
);
|
|
719
|
+
// Touch session row so the dashboard reflects "task in flight"
|
|
720
|
+
// immediately, without waiting for the agent's report_status to
|
|
721
|
+
// arrive. Updating both `task` and `updated_at` is enough — we
|
|
722
|
+
// leave `status` to the agent (idle → working → idle).
|
|
723
|
+
const touchParams: any[] = [body.task.slice(0, 200), body.alias];
|
|
724
|
+
let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
|
|
725
|
+
if (taskNetId) { touchSql += " AND network_id = ?3"; touchParams.push(taskNetId); }
|
|
726
|
+
db.run(touchSql, touchParams);
|
|
727
|
+
});
|
|
707
728
|
// SSE push: 秒达
|
|
708
729
|
const pendingParams: any[] = [body.alias];
|
|
709
730
|
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
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)) {
|
|
@@ -417,7 +453,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
417
453
|
|
|
418
454
|
console.log(`[${ts()}] ${from_session} → send_task → ${alias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}`);
|
|
419
455
|
const id = uuidv4();
|
|
420
|
-
// 事务:inbox + tasks 双写
|
|
456
|
+
// 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
|
|
457
|
+
// dashboard 在派任务一刻就反映出"任务已下发",不再等 agent 的
|
|
458
|
+
// report_status 心跳;status 字段交给 agent,避免与 working/idle
|
|
459
|
+
// 报告冲突)。
|
|
421
460
|
db.transaction(() => {
|
|
422
461
|
db.run(
|
|
423
462
|
`INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id)
|
|
@@ -425,12 +464,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
425
464
|
[id, alias, priority, task, context ?? null, from_session, effectiveNetId ?? null]
|
|
426
465
|
);
|
|
427
466
|
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]
|
|
467
|
+
`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)
|
|
468
|
+
VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7, ?8)`,
|
|
469
|
+
[id, from_session, alias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null, parentTaskId]
|
|
431
470
|
);
|
|
471
|
+
const touchParams: any[] = [task.slice(0, 200), alias];
|
|
472
|
+
let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
|
|
473
|
+
touchSql = addScope(touchSql, touchParams, effectiveNetId);
|
|
474
|
+
db.run(touchSql, touchParams);
|
|
432
475
|
});
|
|
433
|
-
logTaskEvent(id, null, "delivered", from_session, `→ ${alias}`);
|
|
476
|
+
logTaskEvent(id, null, "delivered", from_session, parentTaskId ? `→ ${alias} (parent=${parentTaskId.slice(0,8)})` : `→ ${alias}`);
|
|
434
477
|
|
|
435
478
|
const session = scopedSessionStatus(alias, effectiveNetId);
|
|
436
479
|
|
|
@@ -542,6 +585,30 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
542
585
|
// Log event after commit (outside transaction)
|
|
543
586
|
if (replyLogged && in_reply_to) logTaskEvent(in_reply_to, null, replyStatus, from_session, text.slice(0, 200));
|
|
544
587
|
|
|
588
|
+
// Auto-chain reply up to parent task lineage so admin sees the final
|
|
589
|
+
// answer even if the intermediate session has died.
|
|
590
|
+
if (replyLogged && in_reply_to) {
|
|
591
|
+
try {
|
|
592
|
+
chainReplyToParent(in_reply_to, text, replyStatus);
|
|
593
|
+
// Push SSE event for parent originator if there is a chain.
|
|
594
|
+
const parentChain = db.get<{ parent_task_id: string | null; from_name: string }>(
|
|
595
|
+
"SELECT parent_task_id, from_name FROM tasks WHERE task_id = ?1",
|
|
596
|
+
[in_reply_to]
|
|
597
|
+
);
|
|
598
|
+
if (parentChain?.parent_task_id) {
|
|
599
|
+
const parent = db.get<{ from_name: string; task_id: string }>(
|
|
600
|
+
"SELECT from_name, task_id FROM tasks WHERE task_id = ?1",
|
|
601
|
+
[parentChain.parent_task_id]
|
|
602
|
+
);
|
|
603
|
+
if (parent?.from_name && parent.from_name !== "hub" && parent.from_name !== "api") {
|
|
604
|
+
pushEvent(parent.from_name, { type: "chained_reply", parent_task_id: parent.task_id, child_task_id: in_reply_to, child_alias: alias });
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
} catch (e: any) {
|
|
608
|
+
console.log(`[${ts()}] ⚠ chainReplyToParent failed: ${e.message}`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
545
612
|
const session = scopedSessionStatus(alias, effectiveNetId);
|
|
546
613
|
pushEvent(alias, { type: "new_reply", from: from_session, message_id: id, in_reply_to, status: replyStatus });
|
|
547
614
|
|