@agenticmail/claudecode 0.1.16 → 0.2.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 +26 -1
- package/dist/chunk-2ESYSVXG.js +48 -0
- package/dist/chunk-B276KPVO.js +14230 -0
- package/dist/chunk-BYXBJQAS.js +0 -0
- package/dist/chunk-DDJNA5HP.js +20313 -0
- package/dist/chunk-RB5MGRT3.js +24666 -0
- package/dist/chunk-UCI2HLHM.js +13315 -0
- package/dist/chunk-XNNC4MIH.js +623 -0
- package/dist/cli.js +1 -0
- package/dist/dispatcher-bin.js +7 -1
- package/dist/dispatcher.d.ts +84 -0
- package/dist/dispatcher.js +7 -1
- package/dist/email-worker-template-BOJPKCVB-3QPP3TCG.js +40 -0
- package/dist/http-routes.js +1 -0
- package/dist/imap-flow-DSPQFUHY.js +6 -0
- package/dist/index.js +7 -1
- package/dist/install.js +1 -0
- package/dist/mailparser-TAVZQM56.js +6 -0
- package/dist/spam-filter-L6KNZ7QI-5RMBDUQG.js +14 -0
- package/dist/status.js +1 -0
- package/dist/uninstall.js +1 -0
- package/package.json +2 -2
- package/dist/chunk-RNKJRBEF.js +0 -916
package/dist/chunk-RNKJRBEF.js
DELETED
|
@@ -1,916 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
listAccounts,
|
|
3
|
-
renderPersonaBody,
|
|
4
|
-
resolveConfig
|
|
5
|
-
} from "./chunk-SBP7MJP2.js";
|
|
6
|
-
|
|
7
|
-
// src/persona-loader.ts
|
|
8
|
-
import { existsSync, readFileSync } from "fs";
|
|
9
|
-
import { join } from "path";
|
|
10
|
-
function sanitizeSubagentName(name) {
|
|
11
|
-
return name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
12
|
-
}
|
|
13
|
-
function stripFrontmatter(raw) {
|
|
14
|
-
const text = raw.replace(/\r\n/g, "\n");
|
|
15
|
-
if (!text.startsWith("---\n")) return text;
|
|
16
|
-
const close = text.indexOf("\n---", 4);
|
|
17
|
-
if (close < 0) return text;
|
|
18
|
-
let cursor = close + 4;
|
|
19
|
-
while (cursor < text.length && (text[cursor] === "\n" || text[cursor] === "\r")) cursor++;
|
|
20
|
-
return text.slice(cursor);
|
|
21
|
-
}
|
|
22
|
-
function loadPersonaForAgent(opts) {
|
|
23
|
-
const { agent, agentsDir, subagentPrefix, mcpServerName } = opts;
|
|
24
|
-
const basename = sanitizeSubagentName(`${subagentPrefix}${agent.name}`);
|
|
25
|
-
const filePath = join(agentsDir, `${basename}.md`);
|
|
26
|
-
if (existsSync(filePath)) {
|
|
27
|
-
try {
|
|
28
|
-
const raw = readFileSync(filePath, "utf-8");
|
|
29
|
-
const body2 = stripFrontmatter(raw).trim();
|
|
30
|
-
if (body2) return { body: body2, source: "file", filePath };
|
|
31
|
-
} catch {
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
const body = renderPersonaBody({ name: basename, agent, mcpServerName });
|
|
35
|
-
return { body, source: "generated" };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// src/dispatcher.ts
|
|
39
|
-
import { mkdirSync, createWriteStream, rmSync } from "fs";
|
|
40
|
-
import { join as join2 } from "path";
|
|
41
|
-
import { homedir } from "os";
|
|
42
|
-
function extractSubject(event) {
|
|
43
|
-
if (typeof event.subject === "string") return event.subject;
|
|
44
|
-
if (event.message && typeof event.message.subject === "string") return event.message.subject;
|
|
45
|
-
return void 0;
|
|
46
|
-
}
|
|
47
|
-
function extractFrom(event) {
|
|
48
|
-
if (typeof event.from === "string") return event.from;
|
|
49
|
-
if (event.message && Array.isArray(event.message.from)) {
|
|
50
|
-
const first = event.message.from[0];
|
|
51
|
-
if (first?.address) return first.address;
|
|
52
|
-
if (first?.name) return first.name;
|
|
53
|
-
}
|
|
54
|
-
return void 0;
|
|
55
|
-
}
|
|
56
|
-
function extractWakeAllowlist(event) {
|
|
57
|
-
const raw = event.wakeAllowlist;
|
|
58
|
-
if (raw === void 0) return void 0;
|
|
59
|
-
if (!Array.isArray(raw)) return void 0;
|
|
60
|
-
return raw.map((x) => String(x).trim().toLowerCase()).filter(Boolean);
|
|
61
|
-
}
|
|
62
|
-
function isAgentOnWakeAllowlist(accountName, list) {
|
|
63
|
-
if (list === void 0) return true;
|
|
64
|
-
if (list.length === 0) return false;
|
|
65
|
-
return list.includes(accountName.trim().toLowerCase());
|
|
66
|
-
}
|
|
67
|
-
var SEEN_CAP = 1024;
|
|
68
|
-
function rememberBounded(set, item) {
|
|
69
|
-
set.add(item);
|
|
70
|
-
if (set.size > SEEN_CAP) {
|
|
71
|
-
const drop = Array.from(set).slice(0, Math.floor(SEEN_CAP / 2));
|
|
72
|
-
for (const x of drop) set.delete(x);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
var DEFAULT_MAX_CONCURRENT = 10;
|
|
76
|
-
var DEFAULT_SYNC_INTERVAL_MS = 3e4;
|
|
77
|
-
var DEFAULT_RECONNECT_BASE_MS = 2e3;
|
|
78
|
-
var DEFAULT_RECONNECT_MAX_MS = 6e4;
|
|
79
|
-
var TASK_MAIL_SUPPRESS_WINDOW_MS = 3e4;
|
|
80
|
-
var TASK_NOTIFICATION_SUBJECT_PREFIXES = ["[RPC]", "[Task]", "[Async-RPC]"];
|
|
81
|
-
function isTaskNotificationSubject(subject) {
|
|
82
|
-
if (!subject) return false;
|
|
83
|
-
const head = subject.trimStart();
|
|
84
|
-
for (const prefix of TASK_NOTIFICATION_SUBJECT_PREFIXES) {
|
|
85
|
-
if (head.toLowerCase().startsWith(prefix.toLowerCase())) return true;
|
|
86
|
-
}
|
|
87
|
-
return false;
|
|
88
|
-
}
|
|
89
|
-
var THREAD_CLOSED_MARKERS = ["[FINAL]", "[DONE]", "[CLOSED]", "[WRAP]"];
|
|
90
|
-
function isThreadClosedSubject(subject) {
|
|
91
|
-
if (!subject) return false;
|
|
92
|
-
const s = subject.toLowerCase();
|
|
93
|
-
return THREAD_CLOSED_MARKERS.some((m) => s.includes(m.toLowerCase()));
|
|
94
|
-
}
|
|
95
|
-
function threadIdFromSubject(subject) {
|
|
96
|
-
if (!subject) return "";
|
|
97
|
-
let s = subject.trim();
|
|
98
|
-
while (true) {
|
|
99
|
-
const next = s.replace(/^(re|fwd?|fw)(\[\d+\])?:\s*/i, "");
|
|
100
|
-
if (next === s) break;
|
|
101
|
-
s = next;
|
|
102
|
-
}
|
|
103
|
-
return s.toLowerCase().trim();
|
|
104
|
-
}
|
|
105
|
-
var DEFAULT_MAX_WAKES_PER_THREAD = 10;
|
|
106
|
-
var DEFAULT_WAKE_WINDOW_MS = 24 * 60 * 60 * 1e3;
|
|
107
|
-
async function runWorker(query, persona, userPrompt, agent, mcpServerName, mcpCommand, mcpArgs, mcpEnv, log, abortSignal, observer, cwd) {
|
|
108
|
-
const opts = {
|
|
109
|
-
systemPrompt: persona,
|
|
110
|
-
mcpServers: {
|
|
111
|
-
[mcpServerName]: {
|
|
112
|
-
command: mcpCommand,
|
|
113
|
-
args: mcpArgs,
|
|
114
|
-
env: mcpEnv
|
|
115
|
-
}
|
|
116
|
-
},
|
|
117
|
-
// No `allowedTools` restriction.
|
|
118
|
-
//
|
|
119
|
-
// Earlier versions of the dispatcher locked workers to MCP-only tools
|
|
120
|
-
// ("you operate an email account, not a developer environment"). That
|
|
121
|
-
// was the wrong design: AgenticMail agents are real Claude Code
|
|
122
|
-
// subagents running under the host's OAuth, and the work humans
|
|
123
|
-
// delegate to them (write code, run tests, do research, edit files)
|
|
124
|
-
// demands the full native toolset (Read, Write, Edit, Bash, Glob,
|
|
125
|
-
// Grep, WebFetch, WebSearch, NotebookEdit, …). Restricting them
|
|
126
|
-
// turned "Zephyr implements the game" into "Zephyr emails source
|
|
127
|
-
// code as plaintext and the human has to copy-paste it" — which
|
|
128
|
-
// defeats the point of having agents in the first place.
|
|
129
|
-
//
|
|
130
|
-
// Omitting allowedTools lets the SDK fall through to its defaults
|
|
131
|
-
// (all built-in tools + every tool exposed by the MCP servers we
|
|
132
|
-
// declare above). Outbound mail is still guarded by AgenticMail's
|
|
133
|
-
// own outbound guard (HIGH-severity sends held for owner approval)
|
|
134
|
-
// and the worker is sandboxed by Claude Code's permission system
|
|
135
|
-
// just like any other subagent.
|
|
136
|
-
permissionMode: "bypassPermissions",
|
|
137
|
-
abortController: abortSignal ? wrapSignal(abortSignal) : void 0
|
|
138
|
-
};
|
|
139
|
-
if (cwd) opts.cwd = cwd;
|
|
140
|
-
const collectedText = [];
|
|
141
|
-
try {
|
|
142
|
-
for await (const msg of query({ prompt: userPrompt, options: opts })) {
|
|
143
|
-
const m = msg;
|
|
144
|
-
if (m.type === "assistant" && Array.isArray(m.message && m.message.content)) {
|
|
145
|
-
for (const block of m.message.content) {
|
|
146
|
-
const b = block;
|
|
147
|
-
if (b.type === "text" && typeof b.text === "string") {
|
|
148
|
-
collectedText.push(b.text);
|
|
149
|
-
if (observer) observer.onMessage("assistant", b.text.slice(0, 240).replace(/\s+/g, " ").trim());
|
|
150
|
-
} else if (b.type === "tool_use" && typeof b.name === "string") {
|
|
151
|
-
const inputSummary = (() => {
|
|
152
|
-
try {
|
|
153
|
-
return JSON.stringify(b.input).slice(0, 200);
|
|
154
|
-
} catch {
|
|
155
|
-
return "(uninspectable input)";
|
|
156
|
-
}
|
|
157
|
-
})();
|
|
158
|
-
if (observer) observer.onMessage("tool_use", `${b.name} ${inputSummary}`);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
} else if (m.type === "user" && Array.isArray(m.message && m.message.content)) {
|
|
162
|
-
for (const block of m.message.content) {
|
|
163
|
-
const b = block;
|
|
164
|
-
if (b.type === "tool_result") {
|
|
165
|
-
const bodyStr = typeof b.content === "string" ? b.content : Array.isArray(b.content) ? b.content.map((c) => c.text ?? "").join(" ") : "";
|
|
166
|
-
if (observer) observer.onMessage("tool_result", bodyStr.slice(0, 240).replace(/\s+/g, " ").trim());
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
if (m.type === "result" && typeof m.result === "string") {
|
|
171
|
-
collectedText.push(m.result);
|
|
172
|
-
if (observer) observer.onMessage("result", m.result.slice(0, 240).replace(/\s+/g, " ").trim());
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
const text = collectedText.join("\n").trim();
|
|
176
|
-
log("info", `[dispatcher] worker for "${agent.name}" finished (${text.length} chars output)`);
|
|
177
|
-
return { ok: true, text };
|
|
178
|
-
} catch (err) {
|
|
179
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
180
|
-
log("error", `[dispatcher] worker for "${agent.name}" failed: ${msg}`);
|
|
181
|
-
if (observer) observer.onMessage("error", msg);
|
|
182
|
-
return { ok: false, error: msg };
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
function wrapSignal(signal) {
|
|
186
|
-
const c = new AbortController();
|
|
187
|
-
if (signal.aborted) c.abort();
|
|
188
|
-
else signal.addEventListener("abort", () => c.abort(), { once: true });
|
|
189
|
-
return c;
|
|
190
|
-
}
|
|
191
|
-
function newMailPrompt(agent, event) {
|
|
192
|
-
const from = extractFrom(event) ?? "unknown sender";
|
|
193
|
-
const subject = extractSubject(event) ?? "(no subject)";
|
|
194
|
-
const uid = event.uid;
|
|
195
|
-
return [
|
|
196
|
-
`You have new mail.`,
|
|
197
|
-
``,
|
|
198
|
-
`- From: ${from}`,
|
|
199
|
-
`- Subject: ${subject}`,
|
|
200
|
-
uid ? `- UID: ${uid}` : "",
|
|
201
|
-
``,
|
|
202
|
-
`## Thread-aware coordination protocol`,
|
|
203
|
-
``,
|
|
204
|
-
`You are ${agent.name}. Multiple agents may be CC'd on the same thread \u2014`,
|
|
205
|
-
`that is intentional: a thread is the shared workspace, and turn-taking is`,
|
|
206
|
-
`implicit from context (who was addressed last, whose stage of the workflow`,
|
|
207
|
-
`is next, who was @mentioned). Follow these steps in order:`,
|
|
208
|
-
``,
|
|
209
|
-
`1. **Read this message.** read_email({ uid: ${uid ?? "<uid>"}, _account: "${agent.name}" }).`,
|
|
210
|
-
``,
|
|
211
|
-
`2. **If this is a reply (Subject starts with "Re:" or an In-Reply-To header is present), load the rest of the thread.**`,
|
|
212
|
-
` Use search_emails({ subject: "<core subject without Re:>", _account: "${agent.name}" })`,
|
|
213
|
-
` to surface earlier messages in the thread, then read_email each prior UID.`,
|
|
214
|
-
` You MUST read the full thread before deciding what to do.`,
|
|
215
|
-
``,
|
|
216
|
-
`3. **CHECK YOUR PRIOR CONTRIBUTIONS to this thread.** When you searched`,
|
|
217
|
-
` in step 2, look at how many of the messages were sent BY YOU`,
|
|
218
|
-
` (from: ${agent.email}). If you have already contributed your work`,
|
|
219
|
-
` to this thread, **do NOT redo it on a new wake**. Redelivering`,
|
|
220
|
-
` identical content when a teammate posts an update is the most`,
|
|
221
|
-
` common multi-agent failure mode \u2014 it triples noise and wastes`,
|
|
222
|
-
` tokens. Only re-contribute if EITHER:`,
|
|
223
|
-
` (a) the latest reply contains a NEW specific ask addressed to`,
|
|
224
|
-
` you by name and you have not yet answered THAT ask, OR`,
|
|
225
|
-
` (b) a teammate's reply genuinely changes the picture and your`,
|
|
226
|
-
` prior work needs an explicit revision (not a re-post).`,
|
|
227
|
-
` Otherwise stay silent.`,
|
|
228
|
-
``,
|
|
229
|
-
`4. **Identify the participants.** Look at To + CC across the thread. Those`,
|
|
230
|
-
` are your collaborators. Their names map to AgenticMail agents at`,
|
|
231
|
-
` <name>@localhost. They will each be woken on every reply-all the same way you were.`,
|
|
232
|
-
``,
|
|
233
|
-
`5. **Decide: is it MY turn?** Yes if any of:`,
|
|
234
|
-
` - The latest message addresses you by name ("Vesper, please \u2026", "@${agent.name} \u2026").`,
|
|
235
|
-
` - The previous-stage handoff is to your role (e.g. designer \u2192 developer, and you are the developer).`,
|
|
236
|
-
` - You were directly asked a question and nobody has answered yet.`,
|
|
237
|
-
` No if:`,
|
|
238
|
-
` - The current ask is targeted at a teammate (their turn, not yours).`,
|
|
239
|
-
` - **A teammate replied within the last 60 seconds.** They are likely`,
|
|
240
|
-
` already handling this turn; jumping in creates simultaneous replies`,
|
|
241
|
-
` and confusion. Assume good faith and stay silent unless their reply`,
|
|
242
|
-
` was clearly off-target.`,
|
|
243
|
-
` - You have nothing substantive to add right now.`,
|
|
244
|
-
` When in doubt, stay silent \u2014 over-replying creates noise. Better to let`,
|
|
245
|
-
` the right teammate take the turn than to step on theirs.`,
|
|
246
|
-
``,
|
|
247
|
-
`6. **If it's your turn \u2014 do the actual work, THEN reply-all about it.**`,
|
|
248
|
-
` You have full native tools: Read, Write, Edit, Bash, Glob, Grep, WebFetch,`,
|
|
249
|
-
` WebSearch, NotebookEdit, etc. If the task is "implement X", write the file`,
|
|
250
|
-
` with Write or Edit and verify with Bash \u2014 do NOT paste source code into an`,
|
|
251
|
-
` email body and call it shipped. The thread is for COORDINATION ("done,`,
|
|
252
|
-
` see ./foo.py, runs with \`python3 foo.py\`"); the filesystem is for`,
|
|
253
|
-
` DELIVERABLES. Then:`,
|
|
254
|
-
` reply_email({ uid: ${uid ?? "<uid>"}, replyAll: true, text: "...", _account: "${agent.name}" })`,
|
|
255
|
-
` Sign with your name. Be substantive but concise. If you are handing off`,
|
|
256
|
-
` to the next teammate, name them explicitly in your reply ("Orion \u2014 over to you, please \u2026").`,
|
|
257
|
-
` **NAME the next actor in the \`wake\` parameter** so the dispatcher only`,
|
|
258
|
-
` gives them a Claude turn \u2014 every other CC'd teammate still receives the`,
|
|
259
|
-
` mail in their inbox but stays asleep, saving the project a lot of tokens.`,
|
|
260
|
-
` Example: \`reply_email({ uid, replyAll: true, text: "Orion \u2014 your turn \u2026",`,
|
|
261
|
-
` wake: ["orion"], _account: "${agent.name}" })\`. If nobody specific is`,
|
|
262
|
-
` next (the work is complete and you're just signing off), pass \`wake: []\``,
|
|
263
|
-
` to deliver silently with zero Claude turns spawned.`,
|
|
264
|
-
``,
|
|
265
|
-
`7. **If you need additional help from a teammate not yet on the thread,**`,
|
|
266
|
-
` include them by CC'ing in your reply-all \u2014 DO NOT spin up a separate`,
|
|
267
|
-
` call_agent / message_agent side-channel. The thread is the workspace;`,
|
|
268
|
-
` everyone stays in context.`,
|
|
269
|
-
``,
|
|
270
|
-
`8. **If it's NOT your turn,** mark the message read with mark_read and return.`,
|
|
271
|
-
` Do not reply just to acknowledge. Silence IS a valid contribution.`,
|
|
272
|
-
``,
|
|
273
|
-
`## How threads end`,
|
|
274
|
-
``,
|
|
275
|
-
`A thread is done when the host (or any participant) sends a wrap-up`,
|
|
276
|
-
`message with one of these markers in the subject: \`[FINAL]\`, \`[DONE]\`,`,
|
|
277
|
-
`\`[CLOSED]\`, \`[WRAP]\`. The dispatcher will stop waking workers on any`,
|
|
278
|
-
`further replies to that thread. If you are sending a wrap-up yourself`,
|
|
279
|
-
`(because the work is complete and no more contributions are needed),`,
|
|
280
|
-
`include one of those markers in your reply subject.`,
|
|
281
|
-
``,
|
|
282
|
-
`When you finish, return a one-line summary of what you did:`,
|
|
283
|
-
` "Contributed: <one-line description>" OR "Stayed silent \u2014 not my turn."`,
|
|
284
|
-
``,
|
|
285
|
-
`## Fallback for non-thread mail`,
|
|
286
|
-
``,
|
|
287
|
-
`If this is a fresh standalone email (not part of a thread, only addressed`,
|
|
288
|
-
`to you), handle it directly: answer the question, do the work, reply.`,
|
|
289
|
-
`Spam: trust the auto-filter unless something obviously slipped through.`
|
|
290
|
-
].filter(Boolean).join("\n");
|
|
291
|
-
}
|
|
292
|
-
function taskPrompt(agent, event) {
|
|
293
|
-
const taskId = event.taskId ?? "(missing taskId)";
|
|
294
|
-
const taskText = event.task ?? "(no task description)";
|
|
295
|
-
const taskType = event.taskType ?? "generic";
|
|
296
|
-
const from = event.from ?? "unknown";
|
|
297
|
-
return [
|
|
298
|
-
`You have a pending task \u2014 handle it now.`,
|
|
299
|
-
``,
|
|
300
|
-
`- Task ID: ${taskId}`,
|
|
301
|
-
`- Type: ${taskType}`,
|
|
302
|
-
`- From: ${from}`,
|
|
303
|
-
`- Task: ${taskText}`,
|
|
304
|
-
``,
|
|
305
|
-
`Workflow:`,
|
|
306
|
-
` 1. Call claim_task({ id: "${taskId}", _account: "${agent.name}" }) to mark yourself as the owner.`,
|
|
307
|
-
` 2. Do the work using whatever pre-loaded or invoke-able MCP tools fit.`,
|
|
308
|
-
` 3. Call submit_result({ id: "${taskId}", result: { ... }, _account: "${agent.name}" }) with structured JSON.`,
|
|
309
|
-
` The caller is waiting on a synchronous long-poll \u2014 submit_result is what wakes them.`,
|
|
310
|
-
``,
|
|
311
|
-
`If you cannot complete the task, submit_result with { status: "failed", reason: "..." }. Never leave it unclaimed \u2014 that strands the caller until timeout.`
|
|
312
|
-
].join("\n");
|
|
313
|
-
}
|
|
314
|
-
var Dispatcher = class {
|
|
315
|
-
cfg;
|
|
316
|
-
maxConcurrent;
|
|
317
|
-
syncIntervalMs;
|
|
318
|
-
reconnectBaseMs;
|
|
319
|
-
reconnectMaxMs;
|
|
320
|
-
query;
|
|
321
|
-
fetchImpl;
|
|
322
|
-
log;
|
|
323
|
-
channels = /* @__PURE__ */ new Map();
|
|
324
|
-
// keyed by account.id
|
|
325
|
-
accountSyncTimer = null;
|
|
326
|
-
systemChannelController = null;
|
|
327
|
-
running = 0;
|
|
328
|
-
waiters = [];
|
|
329
|
-
stopped = false;
|
|
330
|
-
/**
|
|
331
|
-
* Wake-budget store, keyed by `${accountId}::${threadId}`. See the
|
|
332
|
-
* comment block on WakeBudgetEntry for the failure modes this guards.
|
|
333
|
-
* Pruned opportunistically on each lookup — no separate timer.
|
|
334
|
-
*/
|
|
335
|
-
wakeBudget = /* @__PURE__ */ new Map();
|
|
336
|
-
maxWakesPerThread;
|
|
337
|
-
wakeWindowMs;
|
|
338
|
-
now;
|
|
339
|
-
constructor(opts = {}) {
|
|
340
|
-
this.cfg = resolveConfig(opts);
|
|
341
|
-
this.maxConcurrent = opts.maxConcurrentWorkers ?? DEFAULT_MAX_CONCURRENT;
|
|
342
|
-
this.syncIntervalMs = opts.accountSyncIntervalMs ?? DEFAULT_SYNC_INTERVAL_MS;
|
|
343
|
-
this.reconnectBaseMs = opts.sseReconnectBaseMs ?? DEFAULT_RECONNECT_BASE_MS;
|
|
344
|
-
this.reconnectMaxMs = opts.sseReconnectMaxMs ?? DEFAULT_RECONNECT_MAX_MS;
|
|
345
|
-
this.query = opts.querySdk ?? defaultQuery();
|
|
346
|
-
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
347
|
-
this.log = opts.log ?? defaultLog;
|
|
348
|
-
this.maxWakesPerThread = opts.maxWakesPerThread ?? DEFAULT_MAX_WAKES_PER_THREAD;
|
|
349
|
-
this.wakeWindowMs = opts.wakeWindowMs ?? DEFAULT_WAKE_WINDOW_MS;
|
|
350
|
-
this.now = opts.nowMs ?? Date.now;
|
|
351
|
-
if (!this.cfg.masterKey) {
|
|
352
|
-
throw new Error("Dispatcher requires AgenticMail master key. Run `agenticmail setup` first.");
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
/**
|
|
356
|
-
* Charge one wake against the (agent, thread) budget. Returns true
|
|
357
|
-
* if the wake should proceed, false if the circuit breaker is open.
|
|
358
|
-
*
|
|
359
|
-
* Empty threadId means "no thread context" (a fresh standalone email
|
|
360
|
-
* with no Subject — rare); we always allow those since there is no
|
|
361
|
-
* thread to runaway on.
|
|
362
|
-
*/
|
|
363
|
-
chargeWake(accountId, threadId) {
|
|
364
|
-
if (!threadId) return { ok: true };
|
|
365
|
-
const key = `${accountId}::${threadId}`;
|
|
366
|
-
const now = this.now();
|
|
367
|
-
let entry = this.wakeBudget.get(key);
|
|
368
|
-
if (entry && now - entry.firstWakeAtMs >= this.wakeWindowMs) {
|
|
369
|
-
entry = void 0;
|
|
370
|
-
this.wakeBudget.delete(key);
|
|
371
|
-
}
|
|
372
|
-
if (!entry) {
|
|
373
|
-
entry = { count: 1, firstWakeAtMs: now };
|
|
374
|
-
this.wakeBudget.set(key, entry);
|
|
375
|
-
this.maybePruneWakeBudget(now);
|
|
376
|
-
return { ok: true, count: 1 };
|
|
377
|
-
}
|
|
378
|
-
if (entry.count >= this.maxWakesPerThread) {
|
|
379
|
-
return {
|
|
380
|
-
ok: false,
|
|
381
|
-
count: entry.count,
|
|
382
|
-
mutedUntilMs: entry.firstWakeAtMs + this.wakeWindowMs
|
|
383
|
-
};
|
|
384
|
-
}
|
|
385
|
-
entry.count++;
|
|
386
|
-
return { ok: true, count: entry.count };
|
|
387
|
-
}
|
|
388
|
-
/**
|
|
389
|
-
* Drop wake-budget entries that have aged out of their window.
|
|
390
|
-
*
|
|
391
|
-
* Called inline from chargeWake, but at most once per ~1024 inserts so
|
|
392
|
-
* the cost stays bounded. We don't need a separate timer because the
|
|
393
|
-
* Map only grows on real wakes (capped by maxWakesPerThread per pair),
|
|
394
|
-
* and the prune is O(n) over the current entries — cheap enough.
|
|
395
|
-
*/
|
|
396
|
-
wakeBudgetInsertsSinceLastPrune = 0;
|
|
397
|
-
maybePruneWakeBudget(now) {
|
|
398
|
-
this.wakeBudgetInsertsSinceLastPrune++;
|
|
399
|
-
if (this.wakeBudgetInsertsSinceLastPrune < 1024) return;
|
|
400
|
-
this.wakeBudgetInsertsSinceLastPrune = 0;
|
|
401
|
-
for (const [k, v] of this.wakeBudget) {
|
|
402
|
-
if (now - v.firstWakeAtMs >= this.wakeWindowMs) this.wakeBudget.delete(k);
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
async start() {
|
|
406
|
-
this.log("info", `[dispatcher] starting (maxConcurrent=${this.maxConcurrent}, syncEvery=${this.syncIntervalMs}ms)`);
|
|
407
|
-
await this.syncAccounts();
|
|
408
|
-
this.accountSyncTimer = setInterval(() => {
|
|
409
|
-
this.syncAccounts().catch((err) => this.log("warn", `[dispatcher] account sync failed: ${err}`));
|
|
410
|
-
}, this.syncIntervalMs);
|
|
411
|
-
void this.runSystemChannel();
|
|
412
|
-
}
|
|
413
|
-
async stop() {
|
|
414
|
-
this.stopped = true;
|
|
415
|
-
if (this.accountSyncTimer) clearInterval(this.accountSyncTimer);
|
|
416
|
-
this.accountSyncTimer = null;
|
|
417
|
-
if (this.systemChannelController) {
|
|
418
|
-
try {
|
|
419
|
-
this.systemChannelController.abort();
|
|
420
|
-
} catch {
|
|
421
|
-
}
|
|
422
|
-
this.systemChannelController = null;
|
|
423
|
-
}
|
|
424
|
-
for (const ch of this.channels.values()) {
|
|
425
|
-
ch.stopping = true;
|
|
426
|
-
ch.controller?.abort();
|
|
427
|
-
}
|
|
428
|
-
this.channels.clear();
|
|
429
|
-
this.log("info", "[dispatcher] stopped");
|
|
430
|
-
}
|
|
431
|
-
/** Public for tests — directly hand an event to the routing path. */
|
|
432
|
-
async handleEvent(account, event) {
|
|
433
|
-
if (this.stopped) return;
|
|
434
|
-
if (event.type === "new" && typeof event.uid === "number") {
|
|
435
|
-
const ch = this.channels.get(account.id);
|
|
436
|
-
if (ch?.seenUids.has(event.uid)) return;
|
|
437
|
-
const subject = extractSubject(event);
|
|
438
|
-
if (ch && Date.now() < ch.suppressTaskMailUntilMs && isTaskNotificationSubject(subject)) {
|
|
439
|
-
this.log("info", `[dispatcher] suppressed task-notification mail wake for "${account.name}" (uid=${event.uid}, subject="${subject}") \u2014 task event already dispatched`);
|
|
440
|
-
rememberBounded(ch.seenUids, event.uid);
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
if (ch) rememberBounded(ch.seenUids, event.uid);
|
|
444
|
-
if (isThreadClosedSubject(subject)) {
|
|
445
|
-
this.log("info", `[dispatcher] thread closed (subject="${subject ?? ""}") \u2014 skipping wake for "${account.name}" uid=${event.uid}`);
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
const allowlist = extractWakeAllowlist(event);
|
|
449
|
-
if (!isAgentOnWakeAllowlist(account.name, allowlist)) {
|
|
450
|
-
this.log("info", `[dispatcher] wake allowlist excludes "${account.name}" (list=${JSON.stringify(allowlist)}) \u2014 mail delivered, no Claude turn`);
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
const threadId = threadIdFromSubject(subject);
|
|
454
|
-
const verdict = this.chargeWake(account.id, threadId);
|
|
455
|
-
if (!verdict.ok) {
|
|
456
|
-
const minutesUntil = verdict.mutedUntilMs ? Math.max(0, Math.round((verdict.mutedUntilMs - this.now()) / 6e4)) : 0;
|
|
457
|
-
this.log("warn", `[dispatcher] wake-budget exhausted for "${account.name}" on thread "${threadId}" (count=${verdict.count}, cap=${this.maxWakesPerThread}); muted for ~${minutesUntil}min. uid=${event.uid}, subject="${subject ?? ""}"`);
|
|
458
|
-
return;
|
|
459
|
-
}
|
|
460
|
-
await this.spawnWorker(account, newMailPrompt(account, event), {
|
|
461
|
-
kind: "new-mail",
|
|
462
|
-
uid: event.uid,
|
|
463
|
-
subject: extractSubject(event),
|
|
464
|
-
from: extractFrom(event)
|
|
465
|
-
});
|
|
466
|
-
return;
|
|
467
|
-
}
|
|
468
|
-
if (event.type === "task" && typeof event.taskId === "string") {
|
|
469
|
-
if (typeof event.assignee === "string" && event.assignee.toLowerCase() !== account.name.toLowerCase()) return;
|
|
470
|
-
const ch = this.channels.get(account.id);
|
|
471
|
-
if (ch?.seenTaskIds.has(event.taskId)) return;
|
|
472
|
-
if (ch) {
|
|
473
|
-
rememberBounded(ch.seenTaskIds, event.taskId);
|
|
474
|
-
ch.suppressTaskMailUntilMs = Date.now() + TASK_MAIL_SUPPRESS_WINDOW_MS;
|
|
475
|
-
}
|
|
476
|
-
await this.spawnWorker(account, taskPrompt(account, event), {
|
|
477
|
-
kind: "task",
|
|
478
|
-
taskId: event.taskId,
|
|
479
|
-
subject: typeof event.task === "string" ? event.task.slice(0, 120) : void 0,
|
|
480
|
-
from: typeof event.from === "string" ? event.from : void 0
|
|
481
|
-
});
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
/**
|
|
486
|
-
* Should the dispatcher own a wake-channel for this account?
|
|
487
|
-
*
|
|
488
|
-
* We skip the bridge agent (default name "claudecode"). The bridge is
|
|
489
|
-
* the host session's own inbox proxy — when mail lands there, the
|
|
490
|
-
* HOST Claude Code session reads it via MCP (`list_inbox` /
|
|
491
|
-
* `wait_for_email` / `read_email`), NOT via a separately-spawned
|
|
492
|
-
* dispatcher worker. Spawning a worker for the bridge would:
|
|
493
|
-
* 1. Compete with the host (two Claude instances trying to "be"
|
|
494
|
-
* Claude Code, both potentially replying autonomously).
|
|
495
|
-
* 2. Waste tokens — the host is already aware via its MCP polling.
|
|
496
|
-
* 3. Send the bridge into an autonomous loop if it ever replies-all
|
|
497
|
-
* (because that mail would wake it again, ad infinitum).
|
|
498
|
-
*
|
|
499
|
-
* Role="bridge" is also skipped for symmetry with selectExposableAgents
|
|
500
|
-
* in install.ts — anything tagged as a bridge is host-managed.
|
|
501
|
-
*/
|
|
502
|
-
shouldWatch(account) {
|
|
503
|
-
const bridgeName = this.cfg.bridgeAgentName.toLowerCase();
|
|
504
|
-
if (account.name.toLowerCase() === bridgeName) return false;
|
|
505
|
-
if (account.role === "bridge") return false;
|
|
506
|
-
return true;
|
|
507
|
-
}
|
|
508
|
-
/** Re-fetch /accounts; open SSE for new ones, close for vanished ones. */
|
|
509
|
-
async syncAccounts() {
|
|
510
|
-
let accounts;
|
|
511
|
-
try {
|
|
512
|
-
accounts = await listAccounts(this.cfg.apiUrl, this.cfg.masterKey);
|
|
513
|
-
} catch (err) {
|
|
514
|
-
this.log("warn", `[dispatcher] could not list accounts: ${err.message}`);
|
|
515
|
-
return;
|
|
516
|
-
}
|
|
517
|
-
accounts = accounts.filter((a) => this.shouldWatch(a));
|
|
518
|
-
const liveIds = new Set(accounts.map((a) => a.id));
|
|
519
|
-
for (const [id, ch] of this.channels) {
|
|
520
|
-
if (!liveIds.has(id)) {
|
|
521
|
-
ch.stopping = true;
|
|
522
|
-
ch.controller?.abort();
|
|
523
|
-
this.channels.delete(id);
|
|
524
|
-
this.log("info", `[dispatcher] account "${ch.account.name}" removed \u2014 closed SSE channel`);
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
for (const account of accounts) {
|
|
528
|
-
if (this.channels.has(account.id)) {
|
|
529
|
-
this.channels.get(account.id).account = account;
|
|
530
|
-
continue;
|
|
531
|
-
}
|
|
532
|
-
const ch = {
|
|
533
|
-
account,
|
|
534
|
-
controller: null,
|
|
535
|
-
stopping: false,
|
|
536
|
-
backoffMs: this.reconnectBaseMs,
|
|
537
|
-
seenUids: /* @__PURE__ */ new Set(),
|
|
538
|
-
seenTaskIds: /* @__PURE__ */ new Set(),
|
|
539
|
-
suppressTaskMailUntilMs: 0
|
|
540
|
-
};
|
|
541
|
-
this.channels.set(account.id, ch);
|
|
542
|
-
this.log("info", `[dispatcher] opening SSE for "${account.name}" (${account.email})`);
|
|
543
|
-
void this.runChannel(ch);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
/**
|
|
547
|
-
* Subscribe to the API's master-scoped system events SSE.
|
|
548
|
-
*
|
|
549
|
-
* Pushes from /system/events arrive as JSON-per-frame just like the
|
|
550
|
-
* per-account stream:
|
|
551
|
-
* { type: "connected" }
|
|
552
|
-
* { type: "account_created", account: { id, name, email, apiKey, ... } }
|
|
553
|
-
* { type: "account_deleted", accountId, name }
|
|
554
|
-
*
|
|
555
|
-
* On `account_created` we eagerly open a per-account SSE channel using
|
|
556
|
-
* the apiKey carried in the event payload — no extra round trip, the
|
|
557
|
-
* channel is live within milliseconds of the POST /accounts response.
|
|
558
|
-
*
|
|
559
|
-
* Reconnect with the same exponential backoff scheme as per-account
|
|
560
|
-
* channels. If the API is older and doesn't expose /system/events
|
|
561
|
-
* (404), we log once and stop trying — polling-only fallback still
|
|
562
|
-
* works.
|
|
563
|
-
*/
|
|
564
|
-
async runSystemChannel() {
|
|
565
|
-
let backoff = this.reconnectBaseMs;
|
|
566
|
-
let giveUp = false;
|
|
567
|
-
while (!this.stopped && !giveUp) {
|
|
568
|
-
this.systemChannelController = new AbortController();
|
|
569
|
-
try {
|
|
570
|
-
const url = `${this.cfg.apiUrl.replace(/\/$/, "")}/api/agenticmail/system/events`;
|
|
571
|
-
const res = await this.fetchImpl(url, {
|
|
572
|
-
headers: {
|
|
573
|
-
"Authorization": `Bearer ${this.cfg.masterKey}`,
|
|
574
|
-
"Accept": "text/event-stream"
|
|
575
|
-
},
|
|
576
|
-
signal: this.systemChannelController.signal
|
|
577
|
-
});
|
|
578
|
-
if (res.status === 404) {
|
|
579
|
-
this.log("warn", "[dispatcher] /system/events not available on this API \u2014 falling back to polling-only account discovery (please upgrade @agenticmail/api to >=0.7.3)");
|
|
580
|
-
giveUp = true;
|
|
581
|
-
break;
|
|
582
|
-
}
|
|
583
|
-
if (!res.ok || !res.body) {
|
|
584
|
-
throw new Error(`system/events HTTP ${res.status}`);
|
|
585
|
-
}
|
|
586
|
-
backoff = this.reconnectBaseMs;
|
|
587
|
-
const reader = res.body.getReader();
|
|
588
|
-
const decoder = new TextDecoder();
|
|
589
|
-
let buffer = "";
|
|
590
|
-
while (!this.stopped) {
|
|
591
|
-
const { value, done } = await reader.read();
|
|
592
|
-
if (done) break;
|
|
593
|
-
buffer += decoder.decode(value, { stream: true });
|
|
594
|
-
let boundary;
|
|
595
|
-
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
|
|
596
|
-
const frame = buffer.slice(0, boundary);
|
|
597
|
-
buffer = buffer.slice(boundary + 2);
|
|
598
|
-
for (const line of frame.split("\n")) {
|
|
599
|
-
if (!line.startsWith("data: ")) continue;
|
|
600
|
-
try {
|
|
601
|
-
const event = JSON.parse(line.slice(6));
|
|
602
|
-
this.handleSystemEvent(event);
|
|
603
|
-
} catch {
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
} catch (err) {
|
|
609
|
-
if (this.stopped) break;
|
|
610
|
-
this.log("warn", `[dispatcher] system-events stream error: ${err.message}; reconnecting in ${backoff}ms`);
|
|
611
|
-
}
|
|
612
|
-
if (this.stopped || giveUp) break;
|
|
613
|
-
await sleep(backoff);
|
|
614
|
-
backoff = Math.min(backoff * 2, this.reconnectMaxMs);
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
/** Apply an account-lifecycle event from /system/events. */
|
|
618
|
-
handleSystemEvent(event) {
|
|
619
|
-
const type = typeof event.type === "string" ? event.type : "";
|
|
620
|
-
if (type === "account_created" && event.account && typeof event.account === "object") {
|
|
621
|
-
const account = event.account;
|
|
622
|
-
if (!account.id || !account.name || !account.apiKey) {
|
|
623
|
-
this.log("warn", "[dispatcher] account_created event missing required fields; ignoring");
|
|
624
|
-
return;
|
|
625
|
-
}
|
|
626
|
-
if (!this.shouldWatch(account)) {
|
|
627
|
-
this.log("info", `[dispatcher] account_created "${account.name}" \u2014 skipping (bridge/role excluded)`);
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
if (this.channels.has(account.id)) return;
|
|
631
|
-
const ch = {
|
|
632
|
-
account,
|
|
633
|
-
controller: null,
|
|
634
|
-
stopping: false,
|
|
635
|
-
backoffMs: this.reconnectBaseMs,
|
|
636
|
-
seenUids: /* @__PURE__ */ new Set(),
|
|
637
|
-
seenTaskIds: /* @__PURE__ */ new Set(),
|
|
638
|
-
suppressTaskMailUntilMs: 0
|
|
639
|
-
};
|
|
640
|
-
this.channels.set(account.id, ch);
|
|
641
|
-
this.log("info", `[dispatcher] account_created "${account.name}" (${account.email}) \u2014 opening SSE channel immediately`);
|
|
642
|
-
void this.runChannel(ch);
|
|
643
|
-
return;
|
|
644
|
-
}
|
|
645
|
-
if (type === "account_deleted" && typeof event.accountId === "string") {
|
|
646
|
-
const ch = this.channels.get(event.accountId);
|
|
647
|
-
if (!ch) return;
|
|
648
|
-
ch.stopping = true;
|
|
649
|
-
try {
|
|
650
|
-
ch.controller?.abort();
|
|
651
|
-
} catch {
|
|
652
|
-
}
|
|
653
|
-
this.channels.delete(event.accountId);
|
|
654
|
-
this.log("info", `[dispatcher] account_deleted "${ch.account.name}" \u2014 closed SSE channel`);
|
|
655
|
-
return;
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
/** Watch one account's SSE stream forever; reconnect with backoff on drop. */
|
|
659
|
-
async runChannel(ch) {
|
|
660
|
-
while (!ch.stopping && !this.stopped) {
|
|
661
|
-
try {
|
|
662
|
-
ch.controller = new AbortController();
|
|
663
|
-
await this.streamOne(ch);
|
|
664
|
-
if (!ch.stopping) {
|
|
665
|
-
this.log("warn", `[dispatcher] SSE for "${ch.account.name}" ended unexpectedly; reconnecting in ${ch.backoffMs}ms`);
|
|
666
|
-
}
|
|
667
|
-
} catch (err) {
|
|
668
|
-
if (ch.stopping) break;
|
|
669
|
-
this.log("warn", `[dispatcher] SSE error for "${ch.account.name}": ${err.message}; reconnecting in ${ch.backoffMs}ms`);
|
|
670
|
-
}
|
|
671
|
-
if (ch.stopping) break;
|
|
672
|
-
await sleep(ch.backoffMs);
|
|
673
|
-
ch.backoffMs = Math.min(ch.backoffMs * 2, this.reconnectMaxMs);
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
/** Single SSE attach. Returns when the stream closes for any reason. */
|
|
677
|
-
async streamOne(ch) {
|
|
678
|
-
const url = `${this.cfg.apiUrl.replace(/\/$/, "")}/api/agenticmail/events`;
|
|
679
|
-
const res = await this.fetchImpl(url, {
|
|
680
|
-
headers: {
|
|
681
|
-
"Authorization": `Bearer ${ch.account.apiKey}`,
|
|
682
|
-
"Accept": "text/event-stream"
|
|
683
|
-
},
|
|
684
|
-
signal: ch.controller.signal
|
|
685
|
-
});
|
|
686
|
-
if (!res.ok || !res.body) {
|
|
687
|
-
throw new Error(`SSE handshake HTTP ${res.status}`);
|
|
688
|
-
}
|
|
689
|
-
ch.backoffMs = this.reconnectBaseMs;
|
|
690
|
-
const reader = res.body.getReader();
|
|
691
|
-
const decoder = new TextDecoder();
|
|
692
|
-
let buffer = "";
|
|
693
|
-
while (!ch.stopping) {
|
|
694
|
-
const { value, done } = await reader.read();
|
|
695
|
-
if (done) return;
|
|
696
|
-
buffer += decoder.decode(value, { stream: true });
|
|
697
|
-
let boundary;
|
|
698
|
-
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
|
|
699
|
-
const frame = buffer.slice(0, boundary);
|
|
700
|
-
buffer = buffer.slice(boundary + 2);
|
|
701
|
-
for (const line of frame.split("\n")) {
|
|
702
|
-
if (!line.startsWith("data: ")) continue;
|
|
703
|
-
let event;
|
|
704
|
-
try {
|
|
705
|
-
event = JSON.parse(line.slice(6));
|
|
706
|
-
} catch {
|
|
707
|
-
continue;
|
|
708
|
-
}
|
|
709
|
-
this.handleEvent(ch.account, event).catch(
|
|
710
|
-
(err) => this.log("error", `[dispatcher] handleEvent threw for "${ch.account.name}": ${err}`)
|
|
711
|
-
);
|
|
712
|
-
}
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
/** Acquire a concurrency slot, run a worker, release the slot. */
|
|
717
|
-
async spawnWorker(account, prompt, ctx) {
|
|
718
|
-
await this.acquireSlot();
|
|
719
|
-
const workerId = `${account.id}:${ctx.kind}:${ctx.uid ?? ctx.taskId ?? ""}:${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
720
|
-
let workerResult = null;
|
|
721
|
-
this.postActivity("/dispatcher/worker-started", {
|
|
722
|
-
workerId,
|
|
723
|
-
agentName: account.name,
|
|
724
|
-
agentEmail: account.email,
|
|
725
|
-
kind: ctx.kind,
|
|
726
|
-
trigger: { uid: ctx.uid, taskId: ctx.taskId, subject: ctx.subject, from: ctx.from }
|
|
727
|
-
});
|
|
728
|
-
const logsDir = join2(homedir(), ".agenticmail", "worker-logs");
|
|
729
|
-
try {
|
|
730
|
-
mkdirSync(logsDir, { recursive: true });
|
|
731
|
-
} catch {
|
|
732
|
-
}
|
|
733
|
-
const logPath = join2(logsDir, `${sanitizeId(workerId)}.log`);
|
|
734
|
-
let logStream = null;
|
|
735
|
-
try {
|
|
736
|
-
logStream = createWriteStream(logPath, { flags: "a" });
|
|
737
|
-
} catch {
|
|
738
|
-
}
|
|
739
|
-
const writeLog = (line) => {
|
|
740
|
-
try {
|
|
741
|
-
logStream?.write(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${line}
|
|
742
|
-
`);
|
|
743
|
-
} catch {
|
|
744
|
-
}
|
|
745
|
-
};
|
|
746
|
-
writeLog(`worker_started agent=${account.name} kind=${ctx.kind}${ctx.uid ? " uid=" + ctx.uid : ""}${ctx.taskId ? " task=" + ctx.taskId : ""}`);
|
|
747
|
-
const cwdDir = join2(homedir(), ".agenticmail", "worker-cwds", sanitizeId(workerId));
|
|
748
|
-
try {
|
|
749
|
-
mkdirSync(cwdDir, { recursive: true });
|
|
750
|
-
} catch {
|
|
751
|
-
}
|
|
752
|
-
let turnCount = 0;
|
|
753
|
-
let lastTool = "";
|
|
754
|
-
const observer = {
|
|
755
|
-
onMessage: (tag, summary) => {
|
|
756
|
-
writeLog(`${tag} ${summary}`);
|
|
757
|
-
if (tag === "tool_use") {
|
|
758
|
-
lastTool = summary.split(" ")[0];
|
|
759
|
-
turnCount++;
|
|
760
|
-
}
|
|
761
|
-
}
|
|
762
|
-
};
|
|
763
|
-
const heartbeatHandle = setInterval(() => {
|
|
764
|
-
this.postActivity("/dispatcher/worker-heartbeat", {
|
|
765
|
-
workerId,
|
|
766
|
-
agentName: account.name,
|
|
767
|
-
lastTool: lastTool || void 0,
|
|
768
|
-
turnCount
|
|
769
|
-
});
|
|
770
|
-
}, 3e4);
|
|
771
|
-
heartbeatHandle.unref?.();
|
|
772
|
-
try {
|
|
773
|
-
const { body } = loadPersonaForAgent({
|
|
774
|
-
agent: account,
|
|
775
|
-
agentsDir: this.cfg.agentsDir,
|
|
776
|
-
subagentPrefix: this.cfg.subagentPrefix,
|
|
777
|
-
mcpServerName: this.cfg.mcpServerName
|
|
778
|
-
});
|
|
779
|
-
this.log("info", `[dispatcher] waking "${account.name}" \u2014 ${ctx.kind}${ctx.taskId ? " " + ctx.taskId : ctx.uid ? " uid=" + ctx.uid : ""}`);
|
|
780
|
-
const mcpEnv = await this.buildMcpEnv();
|
|
781
|
-
workerResult = await runWorker(
|
|
782
|
-
this.query,
|
|
783
|
-
body,
|
|
784
|
-
prompt,
|
|
785
|
-
account,
|
|
786
|
-
this.cfg.mcpServerName,
|
|
787
|
-
this.cfg.mcpCommand,
|
|
788
|
-
this.cfg.mcpArgs,
|
|
789
|
-
mcpEnv,
|
|
790
|
-
this.log,
|
|
791
|
-
void 0,
|
|
792
|
-
observer,
|
|
793
|
-
cwdDir
|
|
794
|
-
);
|
|
795
|
-
} finally {
|
|
796
|
-
clearInterval(heartbeatHandle);
|
|
797
|
-
this.releaseSlot();
|
|
798
|
-
const ok = workerResult?.ok === true;
|
|
799
|
-
const preview = workerResult?.ok ? workerResult.text : workerResult ? workerResult.error : "worker did not start";
|
|
800
|
-
writeLog(`worker_finished ok=${ok} chars=${preview.length}`);
|
|
801
|
-
try {
|
|
802
|
-
logStream?.end();
|
|
803
|
-
} catch {
|
|
804
|
-
}
|
|
805
|
-
try {
|
|
806
|
-
rmSync(cwdDir, { recursive: true, force: true });
|
|
807
|
-
} catch {
|
|
808
|
-
}
|
|
809
|
-
this.postActivity("/dispatcher/worker-finished", {
|
|
810
|
-
workerId,
|
|
811
|
-
agentName: account.name,
|
|
812
|
-
ok,
|
|
813
|
-
turnCount,
|
|
814
|
-
resultPreview: typeof preview === "string" ? preview.slice(0, 240) : void 0
|
|
815
|
-
});
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
/**
|
|
819
|
-
* Fire-and-forget POST to the API's worker-activity endpoints.
|
|
820
|
-
*
|
|
821
|
-
* Failures are swallowed deliberately — the dispatcher must never
|
|
822
|
-
* block worker spawn or interrupt teardown because the API is briefly
|
|
823
|
-
* unreachable. The activity registry is best-effort observability, not
|
|
824
|
-
* load-bearing state.
|
|
825
|
-
*/
|
|
826
|
-
postActivity(path, body) {
|
|
827
|
-
const url = `${this.cfg.apiUrl.replace(/\/$/, "")}/api/agenticmail${path}`;
|
|
828
|
-
try {
|
|
829
|
-
const result = this.fetchImpl(url, {
|
|
830
|
-
method: "POST",
|
|
831
|
-
headers: {
|
|
832
|
-
"Content-Type": "application/json",
|
|
833
|
-
"Authorization": `Bearer ${this.cfg.masterKey}`
|
|
834
|
-
},
|
|
835
|
-
body: JSON.stringify(body)
|
|
836
|
-
});
|
|
837
|
-
if (result && typeof result.catch === "function") {
|
|
838
|
-
void result.catch(() => {
|
|
839
|
-
});
|
|
840
|
-
}
|
|
841
|
-
} catch {
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
/** Build the env block we pass to the worker's MCP server child process. */
|
|
845
|
-
async buildMcpEnv() {
|
|
846
|
-
return {
|
|
847
|
-
AGENTICMAIL_API_URL: this.cfg.apiUrl,
|
|
848
|
-
AGENTICMAIL_MASTER_KEY: this.cfg.masterKey
|
|
849
|
-
// No AGENTICMAIL_API_KEY: workers should ALWAYS pass `_account`
|
|
850
|
-
// explicitly. Omitting the default key forces that discipline at
|
|
851
|
-
// the MCP-server level (any forgotten `_account` becomes a clear
|
|
852
|
-
// error rather than a silent identity drift).
|
|
853
|
-
};
|
|
854
|
-
}
|
|
855
|
-
acquireSlot() {
|
|
856
|
-
if (this.running < this.maxConcurrent) {
|
|
857
|
-
this.running++;
|
|
858
|
-
return Promise.resolve();
|
|
859
|
-
}
|
|
860
|
-
return new Promise((resolve) => {
|
|
861
|
-
this.waiters.push(() => {
|
|
862
|
-
this.running++;
|
|
863
|
-
resolve();
|
|
864
|
-
});
|
|
865
|
-
});
|
|
866
|
-
}
|
|
867
|
-
releaseSlot() {
|
|
868
|
-
this.running--;
|
|
869
|
-
const next = this.waiters.shift();
|
|
870
|
-
if (next) next();
|
|
871
|
-
}
|
|
872
|
-
};
|
|
873
|
-
function sleep(ms) {
|
|
874
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
875
|
-
}
|
|
876
|
-
function sanitizeId(id) {
|
|
877
|
-
return id.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
878
|
-
}
|
|
879
|
-
function defaultLog(level, msg) {
|
|
880
|
-
const stream = level === "error" ? process.stderr : process.stdout;
|
|
881
|
-
stream.write(`[${(/* @__PURE__ */ new Date()).toISOString()}] [${level}] ${msg}
|
|
882
|
-
`);
|
|
883
|
-
}
|
|
884
|
-
function defaultQuery() {
|
|
885
|
-
return (params) => {
|
|
886
|
-
let inner = null;
|
|
887
|
-
const init = async () => {
|
|
888
|
-
try {
|
|
889
|
-
const mod = await import("@anthropic-ai/claude-agent-sdk");
|
|
890
|
-
return mod.query(params);
|
|
891
|
-
} catch (err) {
|
|
892
|
-
throw new Error(
|
|
893
|
-
`Dispatcher needs @anthropic-ai/claude-agent-sdk installed in the package, but: ${err.message}`
|
|
894
|
-
);
|
|
895
|
-
}
|
|
896
|
-
};
|
|
897
|
-
return {
|
|
898
|
-
[Symbol.asyncIterator]() {
|
|
899
|
-
return {
|
|
900
|
-
async next() {
|
|
901
|
-
if (!inner) inner = await init();
|
|
902
|
-
const it = inner[Symbol.asyncIterator]();
|
|
903
|
-
const self = this;
|
|
904
|
-
self.next = it.next.bind(it);
|
|
905
|
-
return it.next();
|
|
906
|
-
}
|
|
907
|
-
};
|
|
908
|
-
}
|
|
909
|
-
};
|
|
910
|
-
};
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
export {
|
|
914
|
-
loadPersonaForAgent,
|
|
915
|
-
Dispatcher
|
|
916
|
-
};
|