n2n-nexus 0.7.0 → 0.8.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/CHANGELOG.md +24 -0
- package/README.md +33 -8
- package/build/cli/autostart.js +64 -0
- package/build/cli/hook.js +76 -0
- package/build/cli/install.js +180 -0
- package/build/client/nexus-client.js +13 -12
- package/build/daemon/hook-contract.js +68 -0
- package/build/daemon/server.js +261 -71
- package/build/daemon/tool-outputs.js +25 -22
- package/build/daemon/tools.js +54 -49
- package/build/index.js +17 -1
- package/build/server/nexus.js +63 -72
- package/build/storage/agents.js +227 -0
- package/build/storage/migrations.js +2 -1
- package/build/storage/sqlite.js +48 -23
- package/docs/ARCHITECTURE.md +30 -12
- package/docs/ARCHITECTURE_zh.md +31 -13
- package/docs/ASSISTANT_GUIDE.md +8 -5
- package/docs/README_zh.md +38 -8
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/build/storage/direct-messages.js +0 -89
- package/build/storage/sessions.js +0 -77
package/build/daemon/server.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import net from "node:net";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
3
4
|
import { URL } from "node:url";
|
|
4
5
|
import { StorageManager } from "../storage/index.js";
|
|
5
6
|
import { SqliteMeetingStore } from "../storage/sqlite-meeting.js";
|
|
6
7
|
import { GlobalMessageStore } from "../storage/global-messages.js";
|
|
7
|
-
import { SessionStore } from "../storage/sessions.js";
|
|
8
|
-
import { DirectMessageStore } from "../storage/direct-messages.js";
|
|
9
8
|
import { EventStore } from "../storage/events.js";
|
|
10
9
|
import { runMigrations } from "../storage/migrations.js";
|
|
11
10
|
import { createTask, updateTask, getTask, listTasks, cancelTask } from "../storage/tasks.js";
|
|
11
|
+
import { AgentStore, messagePreview } from "../storage/agents.js";
|
|
12
12
|
import { MAX_BODY_BYTES } from "../constants.js";
|
|
13
13
|
import { ADDRESS_LIMIT_FACTOR, RateLimiter, rateLimitFromEnv } from "./rate-limit.js";
|
|
14
14
|
import { ResultContractError, finalizeResult } from "./tool-outputs.js";
|
|
15
|
-
import { TOOL_DEFINITIONS, isToolName, parseToolArgs, taskUpdateSchema,
|
|
15
|
+
import { TOOL_DEFINITIONS, isToolName, parseToolArgs, taskUpdateSchema, callerSchema, hookRequestSchema, ToolValidationError } from "./tools.js";
|
|
16
|
+
import { LISTEN_WAIT_MS, MESSAGE_TRUST_NOTE, NO_AGENT_SESSION_ERROR, NOTICE_PREFIX, NOTIFY_CLOSING, NOTIFY_LIST_LIMIT, STOP_CLOSING, agentSessionId } from "./hook-contract.js";
|
|
16
17
|
import { z } from "zod";
|
|
17
18
|
// ---------------------------------------------------------------------------
|
|
18
19
|
// Storage bootstrap
|
|
@@ -79,20 +80,7 @@ async function toolGetProject(args) {
|
|
|
79
80
|
? { manifest, internalDocs: await StorageManager.getProjectDocs(args.projectId) }
|
|
80
81
|
: { manifest };
|
|
81
82
|
}
|
|
82
|
-
function
|
|
83
|
-
if (!sessionId)
|
|
84
|
-
throw new Error("A registered sessionId is required");
|
|
85
|
-
return SessionStore.require(sessionId, instanceId);
|
|
86
|
-
}
|
|
87
|
-
async function toolSendMessage(args, instanceId, sessionId) {
|
|
88
|
-
if (args.to !== undefined) {
|
|
89
|
-
const sender = requireSession(sessionId, instanceId);
|
|
90
|
-
const saved = DirectMessageStore.send(sender.sessionId, { ...args, to: args.to });
|
|
91
|
-
return {
|
|
92
|
-
ok: true, sentTo: "session", messageId: saved.messageId, status: saved.status,
|
|
93
|
-
to: saved.to, replyTo: saved.replyTo, message: `Direct message ${saved.status}.`
|
|
94
|
-
};
|
|
95
|
-
}
|
|
83
|
+
async function toolSendMessage(args, instanceId) {
|
|
96
84
|
const meetingId = args.meetingId ?? SqliteMeetingStore.getActiveMeetingId();
|
|
97
85
|
if (meetingId) {
|
|
98
86
|
const msg = {
|
|
@@ -221,34 +209,231 @@ async function toolHostDeleteProject(args, instanceId) {
|
|
|
221
209
|
return { ok: true, projectId: args.projectId, message: `Project '${args.projectId}' deleted.` };
|
|
222
210
|
}
|
|
223
211
|
// ---------------------------------------------------------------------------
|
|
224
|
-
//
|
|
212
|
+
// Agent sessions: direct-message tools
|
|
225
213
|
// ---------------------------------------------------------------------------
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
214
|
+
/** Wakes a Stop hook waiting in listen mode; the event name is the session id. */
|
|
215
|
+
const agentWake = new EventEmitter();
|
|
216
|
+
agentWake.setMaxListeners(0);
|
|
217
|
+
function wake(sessionIds) {
|
|
218
|
+
for (const id of sessionIds)
|
|
219
|
+
agentWake.emit(id);
|
|
220
|
+
}
|
|
221
|
+
/** Expire overdue messages and wake the senders that got a notice. */
|
|
222
|
+
function expireMessages(now) {
|
|
223
|
+
wake(AgentStore.expireMessages(now));
|
|
224
|
+
}
|
|
225
|
+
function profileOf(s) {
|
|
226
|
+
return { id: s.id, name: s.name, client: s.client, cwd: s.cwd, note: s.note, status: s.status, lastActiveAt: s.lastActiveAt };
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The agent session behind a direct-message tool call: Claude through the
|
|
230
|
+
* toolUseId its PreToolUse hook bound, Codex through its session id.
|
|
231
|
+
*/
|
|
232
|
+
function resolveAgent(caller) {
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
let id = null;
|
|
235
|
+
if (caller?.client === "claude")
|
|
236
|
+
id = AgentStore.resolveToolUse(caller.toolUseId, now);
|
|
237
|
+
else if (caller?.client === "codex")
|
|
238
|
+
id = agentSessionId("codex", caller.sessionId);
|
|
239
|
+
if (!id || !AgentStore.get(id))
|
|
240
|
+
throw new Error(NO_AGENT_SESSION_ERROR);
|
|
241
|
+
AgentStore.refresh(id, now);
|
|
242
|
+
expireMessages(now);
|
|
243
|
+
return AgentStore.get(id);
|
|
244
|
+
}
|
|
245
|
+
function deliver(from, to, text, replyTo, notifyWhenIdle) {
|
|
246
|
+
if (to.id === from.id)
|
|
247
|
+
throw new Error("You cannot send a message to yourself.");
|
|
248
|
+
const messageId = AgentStore.addMessage(from.id, to.id, text, replyTo);
|
|
249
|
+
if (notifyWhenIdle)
|
|
250
|
+
AgentStore.subscribeIdle(from.id, to.id);
|
|
251
|
+
wake([to.id]);
|
|
252
|
+
return { messageId, to: { id: to.id, name: to.name, status: to.status } };
|
|
253
|
+
}
|
|
254
|
+
async function toolPeers(caller) {
|
|
255
|
+
const self = resolveAgent(caller);
|
|
256
|
+
return { self: profileOf(self), peers: AgentStore.listPeers(self.id).map(profileOf) };
|
|
257
|
+
}
|
|
258
|
+
async function toolSend(args, caller) {
|
|
259
|
+
const self = resolveAgent(caller);
|
|
260
|
+
const to = AgentStore.findRecipient(args.to);
|
|
261
|
+
if (!to)
|
|
262
|
+
throw new Error(`Unknown recipient '${args.to}'. Call peers for session ids and names.`);
|
|
263
|
+
return deliver(self, to, args.text, null, args.notifyWhenIdle);
|
|
264
|
+
}
|
|
265
|
+
async function toolReply(args, caller) {
|
|
266
|
+
const self = resolveAgent(caller);
|
|
267
|
+
const original = AgentStore.getMessage(args.messageId);
|
|
268
|
+
if (!original || original.toId !== self.id)
|
|
269
|
+
throw new Error(`Message '${args.messageId}' was not sent to you.`);
|
|
270
|
+
return deliver(self, AgentStore.get(original.fromId), args.text, original.id, false);
|
|
271
|
+
}
|
|
272
|
+
async function toolRead(args, caller) {
|
|
273
|
+
const self = resolveAgent(caller);
|
|
274
|
+
const { messages, remaining } = AgentStore.readUnread(self.id, args.count);
|
|
275
|
+
return {
|
|
276
|
+
messages: messages.map(m => ({
|
|
277
|
+
messageId: m.id, from: { id: m.fromId, name: m.fromName }, replyTo: m.replyTo, text: m.text, createdAt: m.createdAt
|
|
278
|
+
})),
|
|
279
|
+
remaining
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
async function toolListen(args, caller) {
|
|
283
|
+
const self = resolveAgent(caller);
|
|
284
|
+
AgentStore.setListening(self.id, args.on);
|
|
285
|
+
return { listening: args.on };
|
|
286
|
+
}
|
|
287
|
+
async function toolSetProfile(args, caller) {
|
|
288
|
+
const self = resolveAgent(caller);
|
|
289
|
+
return { profile: profileOf(AgentStore.updateProfile(self.id, args)) };
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Notification text for messages and notices; marks them notified. `closing` ends
|
|
293
|
+
* the message part: NOTIFY_CLOSING, or STOP_CLOSING when it continues a turn that was
|
|
294
|
+
* about to end (a model left to itself there tended to stop after the preview).
|
|
295
|
+
*/
|
|
296
|
+
function notify(messages, notices, label, closing) {
|
|
297
|
+
const lines = [];
|
|
298
|
+
if (messages.length > 0) {
|
|
299
|
+
const n = messages.length;
|
|
300
|
+
lines.push(`${NOTICE_PREFIX} ${n} ${label} direct message${n === 1 ? "" : "s"}:`);
|
|
301
|
+
for (const m of messages.slice(0, NOTIFY_LIST_LIMIT)) {
|
|
302
|
+
lines.push(`- From ${m.fromName} (${m.fromId}) at ${m.createdAt}: ${messagePreview(m.text)}`);
|
|
243
303
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
304
|
+
if (n > NOTIFY_LIST_LIMIT)
|
|
305
|
+
lines.push(`- ...and ${n - NOTIFY_LIST_LIMIT} more`);
|
|
306
|
+
}
|
|
307
|
+
if (notices.length > 0) {
|
|
308
|
+
lines.push(messages.length > 0 ? "Notices:" : `${NOTICE_PREFIX} Notices:`);
|
|
309
|
+
for (const notice of notices)
|
|
310
|
+
lines.push(`- ${notice.text}`);
|
|
311
|
+
}
|
|
312
|
+
if (messages.length > 0)
|
|
313
|
+
lines.push(`${MESSAGE_TRUST_NOTE} ${closing}`);
|
|
314
|
+
AgentStore.markNotified(messages.map(m => m.id), notices.map(n => n.id));
|
|
315
|
+
return lines.join("\n");
|
|
316
|
+
}
|
|
317
|
+
/** Notification for every unread message and pending notice, or null when there are none. */
|
|
318
|
+
function notifyAll(sessionId, closing = NOTIFY_CLOSING) {
|
|
319
|
+
const messages = AgentStore.unread(sessionId);
|
|
320
|
+
const notices = AgentStore.pendingNotices(sessionId);
|
|
321
|
+
return messages.length || notices.length ? notify(messages, notices, "unread", closing) : null;
|
|
322
|
+
}
|
|
323
|
+
/** Notification for what was not notified yet, or null when nothing is new. */
|
|
324
|
+
function notifyFresh(sessionId, closing = NOTIFY_CLOSING) {
|
|
325
|
+
const messages = AgentStore.unread(sessionId, true);
|
|
326
|
+
const notices = AgentStore.pendingNotices(sessionId);
|
|
327
|
+
return messages.length || notices.length ? notify(messages, notices, "new", closing) : null;
|
|
328
|
+
}
|
|
329
|
+
const contextOutput = (hookEventName, text) => text === null ? null : { hookSpecificOutput: { hookEventName, additionalContext: text } };
|
|
330
|
+
const blockOutput = (reason) => ({ decision: "block", reason });
|
|
331
|
+
/**
|
|
332
|
+
* Listen mode: wait until something not notified yet arrives (then block the
|
|
333
|
+
* stop with it), the wait runs out, or the hook's HTTP client goes away.
|
|
334
|
+
*/
|
|
335
|
+
function waitForMessages(sessionId, waitMs, signal) {
|
|
336
|
+
return new Promise((resolve) => {
|
|
337
|
+
let done = false;
|
|
338
|
+
const finish = (output) => {
|
|
339
|
+
if (done)
|
|
340
|
+
return;
|
|
341
|
+
done = true;
|
|
342
|
+
clearTimeout(timer);
|
|
343
|
+
agentWake.off(sessionId, check);
|
|
344
|
+
signal.removeEventListener("abort", stopWaiting);
|
|
345
|
+
resolve(output);
|
|
346
|
+
};
|
|
347
|
+
const stopWaiting = () => {
|
|
348
|
+
if (done)
|
|
349
|
+
return;
|
|
350
|
+
AgentStore.setStatusIf(sessionId, "listening", "idle");
|
|
351
|
+
finish(null);
|
|
352
|
+
};
|
|
353
|
+
function check() {
|
|
354
|
+
if (done)
|
|
355
|
+
return;
|
|
356
|
+
// SessionEnd (or listen off) during the wait: nobody will act on a block any more.
|
|
357
|
+
if (!AgentStore.get(sessionId)?.listening)
|
|
358
|
+
return finish(null);
|
|
359
|
+
const text = notifyFresh(sessionId, STOP_CLOSING);
|
|
360
|
+
if (text === null)
|
|
361
|
+
return;
|
|
362
|
+
AgentStore.setStatus(sessionId, "working");
|
|
363
|
+
finish(blockOutput(text));
|
|
247
364
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
365
|
+
const timer = setTimeout(stopWaiting, waitMs);
|
|
366
|
+
agentWake.on(sessionId, check);
|
|
367
|
+
signal.addEventListener("abort", stopWaiting, { once: true });
|
|
368
|
+
if (signal.aborted)
|
|
369
|
+
stopWaiting();
|
|
370
|
+
else
|
|
371
|
+
check();
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
async function handleStop(sessionId, input, listenWaitMs, signal) {
|
|
375
|
+
if (input.stop_hook_active !== true) {
|
|
376
|
+
const text = notifyAll(sessionId, STOP_CLOSING);
|
|
377
|
+
if (text !== null) {
|
|
378
|
+
AgentStore.setStatus(sessionId, "working");
|
|
379
|
+
return blockOutput(text);
|
|
251
380
|
}
|
|
381
|
+
}
|
|
382
|
+
if (AgentStore.get(sessionId).listening) {
|
|
383
|
+
AgentStore.setStatus(sessionId, "listening");
|
|
384
|
+
wake(AgentStore.fireIdle(sessionId, "idle"));
|
|
385
|
+
return waitForMessages(sessionId, listenWaitMs, signal);
|
|
386
|
+
}
|
|
387
|
+
AgentStore.setStatus(sessionId, "idle");
|
|
388
|
+
wake(AgentStore.fireIdle(sessionId, "idle"));
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
/** One client hook event (POST /api/hook). Returns what the hook prints, or null. */
|
|
392
|
+
async function handleHook(client, input, listenWaitMs, signal) {
|
|
393
|
+
if (typeof input.session_id !== "string" || !input.session_id)
|
|
394
|
+
return null;
|
|
395
|
+
const id = agentSessionId(client, input.session_id);
|
|
396
|
+
const now = Date.now();
|
|
397
|
+
AgentStore.touch(id, client, typeof input.cwd === "string" ? input.cwd : "", now);
|
|
398
|
+
expireMessages(now);
|
|
399
|
+
switch (input.hook_event_name) {
|
|
400
|
+
case "SessionStart":
|
|
401
|
+
AgentStore.setStatus(id, "idle");
|
|
402
|
+
return null;
|
|
403
|
+
case "UserPromptSubmit":
|
|
404
|
+
AgentStore.setStatus(id, "working");
|
|
405
|
+
return contextOutput("UserPromptSubmit", notifyAll(id));
|
|
406
|
+
case "PreToolUse":
|
|
407
|
+
if (typeof input.tool_use_id === "string" && input.tool_use_id)
|
|
408
|
+
AgentStore.bindToolUse(input.tool_use_id, id, now);
|
|
409
|
+
return null;
|
|
410
|
+
case "PostToolUse":
|
|
411
|
+
AgentStore.setStatus(id, "working");
|
|
412
|
+
return contextOutput("PostToolUse", notifyFresh(id));
|
|
413
|
+
case "Stop":
|
|
414
|
+
return handleStop(id, input, listenWaitMs, signal);
|
|
415
|
+
case "SessionEnd":
|
|
416
|
+
AgentStore.setStatus(id, "offline");
|
|
417
|
+
AgentStore.setListening(id, false);
|
|
418
|
+
wake([id, ...AgentStore.fireIdle(id, "offline")]);
|
|
419
|
+
return null;
|
|
420
|
+
default:
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// Tool dispatcher
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
async function dispatchTool(name, rawArgs, instanceId, caller) {
|
|
428
|
+
switch (name) {
|
|
429
|
+
case "peers":
|
|
430
|
+
parseToolArgs(name, rawArgs);
|
|
431
|
+
return toolPeers(caller);
|
|
432
|
+
case "send": return toolSend(parseToolArgs(name, rawArgs), caller);
|
|
433
|
+
case "read": return toolRead(parseToolArgs(name, rawArgs), caller);
|
|
434
|
+
case "reply": return toolReply(parseToolArgs(name, rawArgs), caller);
|
|
435
|
+
case "listen": return toolListen(parseToolArgs(name, rawArgs), caller);
|
|
436
|
+
case "set_profile": return toolSetProfile(parseToolArgs(name, rawArgs), caller);
|
|
252
437
|
case "sync_project_assets": return toolSyncProjectAssets(parseToolArgs(name, rawArgs), instanceId);
|
|
253
438
|
case "upload_project_asset": return toolUploadAsset(parseToolArgs(name, rawArgs), instanceId);
|
|
254
439
|
case "update_project": return toolUpdateProject(parseToolArgs(name, rawArgs), instanceId);
|
|
@@ -256,7 +441,7 @@ async function dispatchTool(name, rawArgs, instanceId, sessionId) {
|
|
|
256
441
|
case "search_projects": return toolSearchProjects(parseToolArgs(name, rawArgs));
|
|
257
442
|
case "get_global_topology": return toolGetGlobalTopology(parseToolArgs(name, rawArgs));
|
|
258
443
|
case "get_project": return toolGetProject(parseToolArgs(name, rawArgs));
|
|
259
|
-
case "send_message": return toolSendMessage(parseToolArgs(name, rawArgs), instanceId
|
|
444
|
+
case "send_message": return toolSendMessage(parseToolArgs(name, rawArgs), instanceId);
|
|
260
445
|
case "read_messages": return toolReadMessages(parseToolArgs(name, rawArgs), instanceId);
|
|
261
446
|
case "list_events": return toolListEvents(parseToolArgs(name, rawArgs));
|
|
262
447
|
case "get_global_strategy":
|
|
@@ -310,9 +495,9 @@ function rateLimited(retryAfterSec, scope) {
|
|
|
310
495
|
* Run a tool for an HTTP request. Validation failures and handler rejections
|
|
311
496
|
* are the caller's fault and surface as 400, not as daemon errors.
|
|
312
497
|
*/
|
|
313
|
-
async function callTool(name, args, instanceId,
|
|
498
|
+
async function callTool(name, args, instanceId, caller) {
|
|
314
499
|
try {
|
|
315
|
-
return finalizeResult(name, await dispatchTool(name, args, instanceId,
|
|
500
|
+
return finalizeResult(name, await dispatchTool(name, args, instanceId, caller));
|
|
316
501
|
}
|
|
317
502
|
catch (e) {
|
|
318
503
|
if (e instanceof HttpError)
|
|
@@ -330,8 +515,10 @@ const UNKNOWN_INSTANCE = "unknown";
|
|
|
330
515
|
function actor(body) {
|
|
331
516
|
return typeof body.instanceId === "string" && body.instanceId ? body.instanceId : UNKNOWN_INSTANCE;
|
|
332
517
|
}
|
|
333
|
-
|
|
334
|
-
|
|
518
|
+
/** A malformed caller names no session: direct-message tools then refuse, every other tool ignores it. */
|
|
519
|
+
function callerOf(body) {
|
|
520
|
+
const parsed = callerSchema.safeParse(body.caller);
|
|
521
|
+
return parsed.success ? parsed.data : undefined;
|
|
335
522
|
}
|
|
336
523
|
/** Query-string parameters as tool arguments; `numeric` names are converted to numbers. */
|
|
337
524
|
function queryArgs(url, numeric) {
|
|
@@ -435,6 +622,7 @@ export async function createDaemonServer(options) {
|
|
|
435
622
|
await initializeStorage();
|
|
436
623
|
const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
|
|
437
624
|
const allowedHosts = options.allowedHosts ?? allowedHostsFromEnv();
|
|
625
|
+
const listenWaitMs = options.listenWaitMs ?? LISTEN_WAIT_MS;
|
|
438
626
|
const limit = options.rateLimit ?? rateLimitFromEnv();
|
|
439
627
|
const instanceLimiter = new RateLimiter(limit.perSecond, limit.burst);
|
|
440
628
|
const addressLimiter = new RateLimiter(limit.perSecond * ADDRESS_LIMIT_FACTOR, limit.burst * ADDRESS_LIMIT_FACTOR);
|
|
@@ -443,13 +631,13 @@ export async function createDaemonServer(options) {
|
|
|
443
631
|
* Requests that name no instance ("unknown") are only limited per address:
|
|
444
632
|
* pooling them in one shared bucket let any client exhaust it for all others.
|
|
445
633
|
*/
|
|
446
|
-
const call = (name, args, instanceId,
|
|
634
|
+
const call = (name, args, instanceId, caller) => {
|
|
447
635
|
if (instanceId !== UNKNOWN_INSTANCE) {
|
|
448
636
|
const wait = instanceLimiter.take(`instance:${instanceId}`);
|
|
449
637
|
if (wait)
|
|
450
638
|
throw rateLimited(wait, `instance '${instanceId}'`);
|
|
451
639
|
}
|
|
452
|
-
return callTool(name, args, instanceId,
|
|
640
|
+
return callTool(name, args, instanceId, caller);
|
|
453
641
|
};
|
|
454
642
|
const server = http.createServer(async (req, res) => {
|
|
455
643
|
const method = req.method || "GET";
|
|
@@ -487,27 +675,6 @@ export async function createDaemonServer(options) {
|
|
|
487
675
|
if (method === "GET" && path === "/api/tools") {
|
|
488
676
|
return jsonResponse(res, 200, { tools: TOOL_DEFINITIONS });
|
|
489
677
|
}
|
|
490
|
-
if (method === "POST" && path === "/api/sessions/register") {
|
|
491
|
-
const input = sessionRegistrationSchema.parse(await parseBody(req));
|
|
492
|
-
let session;
|
|
493
|
-
try {
|
|
494
|
-
session = SessionStore.register(input);
|
|
495
|
-
}
|
|
496
|
-
catch (e) {
|
|
497
|
-
throw new HttpError(400, e instanceof Error ? e.message : String(e));
|
|
498
|
-
}
|
|
499
|
-
return jsonResponse(res, 200, { ok: true, session });
|
|
500
|
-
}
|
|
501
|
-
if (method === "POST" && path === "/api/sessions/disconnect") {
|
|
502
|
-
const input = sessionDisconnectSchema.parse(await parseBody(req));
|
|
503
|
-
try {
|
|
504
|
-
SessionStore.disconnect(input.sessionId, input.instanceId);
|
|
505
|
-
}
|
|
506
|
-
catch (e) {
|
|
507
|
-
throw new HttpError(400, e instanceof Error ? e.message : String(e));
|
|
508
|
-
}
|
|
509
|
-
return jsonResponse(res, 200, { ok: true });
|
|
510
|
-
}
|
|
511
678
|
// Tool call dispatcher ← core endpoint
|
|
512
679
|
if (method === "POST" && path === "/api/tools/call") {
|
|
513
680
|
const body = await parseBody(req);
|
|
@@ -519,12 +686,27 @@ export async function createDaemonServer(options) {
|
|
|
519
686
|
if (!isToolName(toolName)) {
|
|
520
687
|
return jsonResponse(res, 404, { ok: false, error: `Unknown tool: '${toolName}'` });
|
|
521
688
|
}
|
|
522
|
-
return jsonResponse(res, 200, { ok: true, result: await call(toolName, args, instanceId,
|
|
689
|
+
return jsonResponse(res, 200, { ok: true, result: await call(toolName, args, instanceId, callerOf(body)) });
|
|
690
|
+
}
|
|
691
|
+
// Client hook events. Hooks name no instance, so only the per-address limit above applies.
|
|
692
|
+
if (method === "POST" && path === "/api/hook") {
|
|
693
|
+
const body = await parseBody(req);
|
|
694
|
+
const hook = hookRequestSchema.safeParse(body);
|
|
695
|
+
if (!hook.success)
|
|
696
|
+
return jsonResponse(res, 400, { ok: false, error: `Invalid hook request:\n${z.prettifyError(hook.error)}` });
|
|
697
|
+
// A listening Stop hook is held open; the client killing the hook or the daemon closing ends the wait.
|
|
698
|
+
const gone = new AbortController();
|
|
699
|
+
res.on("close", () => { if (!res.writableEnded)
|
|
700
|
+
gone.abort(); });
|
|
701
|
+
const output = await handleHook(hook.data.client, hook.data.input, listenWaitMs, AbortSignal.any([gone.signal, closing.signal]));
|
|
702
|
+
if (gone.signal.aborted)
|
|
703
|
+
return;
|
|
704
|
+
return jsonResponse(res, 200, { output }, closing.signal.aborted ? { Connection: "close" } : {});
|
|
523
705
|
}
|
|
524
706
|
// Messages
|
|
525
707
|
if (method === "POST" && path === "/api/messages/send") {
|
|
526
708
|
const body = await parseBody(req);
|
|
527
|
-
return jsonResponse(res, 200, await call("send_message", body, actor(body)
|
|
709
|
+
return jsonResponse(res, 200, await call("send_message", body, actor(body)));
|
|
528
710
|
}
|
|
529
711
|
if (method === "GET" && path === "/api/messages/unread") {
|
|
530
712
|
const result = await call("read_messages", queryArgs(url, ["count"]), url.searchParams.get("instanceId") || UNKNOWN_INSTANCE);
|
|
@@ -664,5 +846,13 @@ export async function createDaemonServer(options) {
|
|
|
664
846
|
jsonResponse(res, 500, { ok: false, error: message });
|
|
665
847
|
}
|
|
666
848
|
});
|
|
849
|
+
// server.close() waits for in-flight requests, and a listening Stop hook stays open
|
|
850
|
+
// for minutes: release those first so a shutdown is not held up by them.
|
|
851
|
+
const closing = new AbortController();
|
|
852
|
+
const close = server.close.bind(server);
|
|
853
|
+
server.close = (callback) => {
|
|
854
|
+
closing.abort();
|
|
855
|
+
return close(callback);
|
|
856
|
+
};
|
|
667
857
|
return { server };
|
|
668
858
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { MESSAGE_CATEGORIES } from "../types.js";
|
|
3
3
|
import { TASK_STATUSES } from "../storage/tasks.js";
|
|
4
|
+
import { AGENT_STATUSES, HOOK_CLIENTS } from "./hook-contract.js";
|
|
4
5
|
/**
|
|
5
6
|
* Result contracts, one per tool. Every result passes through its contract on
|
|
6
7
|
* the way out of the daemon (`finalizeResult`): undeclared fields are stripped
|
|
@@ -12,19 +13,14 @@ import { TASK_STATUSES } from "../storage/tasks.js";
|
|
|
12
13
|
* 2025-era clients require.
|
|
13
14
|
*/
|
|
14
15
|
const ack = { ok: z.literal(true), message: z.string() };
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
online: z.boolean(), createdAt: z.string(), lastSeenAt: z.string()
|
|
16
|
+
const agentStatus = z.enum(AGENT_STATUSES);
|
|
17
|
+
const agentProfileSchema = z.object({
|
|
18
|
+
id: z.string(), name: z.string(), client: z.enum(HOOK_CLIENTS), cwd: z.string(),
|
|
19
|
+
note: z.string(), status: agentStatus, lastActiveAt: z.string()
|
|
20
20
|
});
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
category: z.enum(MESSAGE_CATEGORIES).optional(), replyTo: z.string().nullable(),
|
|
25
|
-
status: deliveryStatus, createdAt: z.string(), expiresAt: z.string(),
|
|
26
|
-
fetchedAt: z.string().nullable(), acknowledgedAt: z.string().nullable(),
|
|
27
|
-
repliedAt: z.string().nullable(), reason: z.string().nullable()
|
|
21
|
+
const sentDirectSchema = z.object({
|
|
22
|
+
messageId: z.string(),
|
|
23
|
+
to: z.object({ id: z.string(), name: z.string(), status: agentStatus })
|
|
28
24
|
});
|
|
29
25
|
const messageSchema = z.object({
|
|
30
26
|
id: z.number().int().optional(),
|
|
@@ -94,19 +90,26 @@ export const TOOL_OUTPUT_SCHEMAS = {
|
|
|
94
90
|
manifest: storedManifest,
|
|
95
91
|
internalDocs: z.string().optional()
|
|
96
92
|
}),
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
93
|
+
peers: z.object({ self: agentProfileSchema, peers: z.array(agentProfileSchema) }),
|
|
94
|
+
send: sentDirectSchema,
|
|
95
|
+
read: z.object({
|
|
96
|
+
messages: z.array(z.object({
|
|
97
|
+
messageId: z.string(),
|
|
98
|
+
from: z.object({ id: z.string(), name: z.string() }),
|
|
99
|
+
replyTo: z.string().nullable(),
|
|
100
|
+
text: z.string(),
|
|
101
|
+
createdAt: z.string()
|
|
102
|
+
})),
|
|
103
|
+
remaining: z.number().int().describe("Unread messages left after this batch")
|
|
104
|
+
}),
|
|
105
|
+
reply: sentDirectSchema,
|
|
106
|
+
listen: z.object({ listening: z.boolean() }),
|
|
107
|
+
set_profile: z.object({ profile: agentProfileSchema }),
|
|
102
108
|
send_message: z.object({
|
|
103
109
|
...ack,
|
|
104
|
-
sentTo: z.enum(["meeting", "global"
|
|
110
|
+
sentTo: z.enum(["meeting", "global"]),
|
|
105
111
|
meetingId: z.string().optional(),
|
|
106
|
-
messageId: z.
|
|
107
|
-
status: deliveryStatus.optional(),
|
|
108
|
-
to: z.string().optional(),
|
|
109
|
-
replyTo: z.string().nullable().optional()
|
|
112
|
+
messageId: z.number().int().optional()
|
|
110
113
|
}),
|
|
111
114
|
read_messages: z.object({
|
|
112
115
|
source: z.enum(["meeting", "global"]),
|