@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 +1 -1
- package/src/db.ts +77 -0
- package/src/index.ts +55 -3
- package/src/tools.ts +212 -42
package/package.json
CHANGED
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
|
|
92
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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.",
|