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