@agenticmail/claudecode 0.2.6 → 0.2.8

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.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  removeUserPromptSubmitHook
3
- } from "./chunk-DKTAW2N5.js";
3
+ } from "./chunk-LO5EQSQA.js";
4
4
  import {
5
5
  removeMcpServer,
6
6
  stopDispatcher
@@ -10,7 +10,7 @@ import {
10
10
  deleteAccount,
11
11
  getAccountByName,
12
12
  resolveConfig
13
- } from "./chunk-SBP7MJP2.js";
13
+ } from "./chunk-4VQP57SO.js";
14
14
 
15
15
  // src/uninstall.ts
16
16
  import { existsSync, readdirSync, readFileSync, unlinkSync } from "fs";
@@ -82,6 +82,33 @@ async function ensureAccount(apiUrl, masterKey, name, role = "assistant") {
82
82
  async function deleteAccount(apiUrl, masterKey, id) {
83
83
  await request(apiUrl, masterKey, `/accounts/${encodeURIComponent(id)}`, { method: "DELETE" });
84
84
  }
85
+ async function listInboxForAgent(apiUrl, agentApiKey, opts = {}) {
86
+ const limit = Math.max(1, Math.min(opts.limit ?? 50, 100));
87
+ const url = `${apiUrl.replace(/\/$/, "")}/api/agenticmail/mail/inbox?limit=${limit}`;
88
+ const res = await fetch(url, {
89
+ headers: { "Authorization": `Bearer ${agentApiKey}` },
90
+ signal: AbortSignal.timeout(15e3)
91
+ });
92
+ if (!res.ok) {
93
+ throw new AgenticMailApiError(res.status, `mail/inbox HTTP ${res.status}`);
94
+ }
95
+ const data = await res.json();
96
+ return data?.messages ?? [];
97
+ }
98
+ async function listPendingTasksForAgent(apiUrl, agentApiKey) {
99
+ const url = `${apiUrl.replace(/\/$/, "")}/api/agenticmail/tasks/pending`;
100
+ const res = await fetch(url, {
101
+ headers: { "Authorization": `Bearer ${agentApiKey}` },
102
+ signal: AbortSignal.timeout(15e3)
103
+ });
104
+ if (!res.ok) {
105
+ if (res.status === 404) return [];
106
+ throw new AgenticMailApiError(res.status, `tasks/pending HTTP ${res.status}`);
107
+ }
108
+ const data = await res.json();
109
+ const rows = Array.isArray(data) ? data : data?.tasks ?? [];
110
+ return rows.filter((t) => (t.status ?? "").toLowerCase() === "pending");
111
+ }
85
112
 
86
113
  // src/config.ts
87
114
  import { readFileSync, existsSync } from "fs";
@@ -255,6 +282,8 @@ export {
255
282
  getAccountByName,
256
283
  ensureAccount,
257
284
  deleteAccount,
285
+ listInboxForAgent,
286
+ listPendingTasksForAgent,
258
287
  resolveConfig,
259
288
  MANAGED_BY_MARKER,
260
289
  renderPersonaBody,
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  listAccounts,
3
+ listInboxForAgent,
4
+ listPendingTasksForAgent,
3
5
  renderPersonaBody,
4
6
  resolveConfig
5
- } from "./chunk-SBP7MJP2.js";
7
+ } from "./chunk-4VQP57SO.js";
6
8
 
7
9
  // src/persona-loader.ts
8
10
  import { existsSync, readFileSync } from "fs";
@@ -35,10 +37,129 @@ function loadPersonaForAgent(opts) {
35
37
  return { body, source: "generated" };
36
38
  }
37
39
 
38
- // src/dispatcher.ts
39
- import { mkdirSync, createWriteStream, rmSync } from "fs";
40
- import { join as join2 } from "path";
40
+ // src/dispatcher-state.ts
41
+ import { readFileSync as readFileSync2, writeFileSync, renameSync, mkdirSync, existsSync as existsSync2 } from "fs";
42
+ import { dirname, join as join2 } from "path";
41
43
  import { homedir } from "os";
44
+ var STATE_VERSION = 1;
45
+ var SEEN_UIDS_CAP = 256;
46
+ var FLUSH_INTERVAL_MS = 2e3;
47
+ function emptyState() {
48
+ return { version: STATE_VERSION, savedAtMs: 0, accounts: {} };
49
+ }
50
+ function defaultStatePath() {
51
+ return join2(homedir(), ".agenticmail", "dispatcher-state.json");
52
+ }
53
+ var DispatcherState = class {
54
+ path;
55
+ state = emptyState();
56
+ dirty = false;
57
+ flushTimer = null;
58
+ constructor(opts) {
59
+ this.path = opts?.path ?? defaultStatePath();
60
+ this.load();
61
+ }
62
+ /** Read the state file from disk. Missing / corrupt → empty state. */
63
+ load() {
64
+ try {
65
+ if (!existsSync2(this.path)) return;
66
+ const raw = readFileSync2(this.path, "utf8");
67
+ const parsed = JSON.parse(raw);
68
+ if (parsed && typeof parsed === "object" && parsed.version === STATE_VERSION) {
69
+ const accounts = {};
70
+ for (const [id, cursor] of Object.entries(parsed.accounts ?? {})) {
71
+ if (cursor && typeof cursor === "object" && typeof cursor.lastSeenUid === "number" && Array.isArray(cursor.seenUids)) {
72
+ const c = cursor;
73
+ accounts[id] = {
74
+ lastSeenUid: c.lastSeenUid,
75
+ seenUids: c.seenUids.filter((u) => Number.isFinite(u) && u > 0).slice(-SEEN_UIDS_CAP)
76
+ };
77
+ }
78
+ }
79
+ this.state = { version: STATE_VERSION, savedAtMs: parsed.savedAtMs ?? 0, accounts };
80
+ }
81
+ } catch {
82
+ this.state = emptyState();
83
+ }
84
+ }
85
+ /** Read the cursor for one account. Missing → undefined. */
86
+ getCursor(accountId) {
87
+ return this.state.accounts[accountId];
88
+ }
89
+ /** All known account ids in the persisted state. */
90
+ knownAccounts() {
91
+ return Object.keys(this.state.accounts);
92
+ }
93
+ /**
94
+ * Record that the dispatcher routed UID `uid` for `accountId`.
95
+ * Updates lastSeenUid (monotonic max) and appends to seenUids with
96
+ * bounding. Marks state dirty + schedules a debounced flush.
97
+ */
98
+ markSeen(accountId, uid) {
99
+ if (!Number.isFinite(uid) || uid <= 0) return;
100
+ let cur = this.state.accounts[accountId];
101
+ if (!cur) {
102
+ cur = { lastSeenUid: 0, seenUids: [] };
103
+ this.state.accounts[accountId] = cur;
104
+ }
105
+ if (uid > cur.lastSeenUid) cur.lastSeenUid = uid;
106
+ if (!cur.seenUids.includes(uid)) {
107
+ cur.seenUids.push(uid);
108
+ if (cur.seenUids.length > SEEN_UIDS_CAP) {
109
+ cur.seenUids = cur.seenUids.slice(-SEEN_UIDS_CAP);
110
+ }
111
+ }
112
+ this.scheduleFlush();
113
+ }
114
+ /** Drop the cursor for an account that vanished. */
115
+ forget(accountId) {
116
+ if (this.state.accounts[accountId]) {
117
+ delete this.state.accounts[accountId];
118
+ this.scheduleFlush();
119
+ }
120
+ }
121
+ scheduleFlush() {
122
+ this.dirty = true;
123
+ if (this.flushTimer) return;
124
+ this.flushTimer = setTimeout(() => {
125
+ this.flushTimer = null;
126
+ try {
127
+ this.flushNow();
128
+ } catch {
129
+ }
130
+ }, FLUSH_INTERVAL_MS);
131
+ this.flushTimer.unref?.();
132
+ }
133
+ /**
134
+ * Synchronously write state to disk via atomic rename. Safe to call
135
+ * from a shutdown handler — finishes before the process exits.
136
+ */
137
+ flushNow() {
138
+ if (!this.dirty) return;
139
+ const dir = dirname(this.path);
140
+ try {
141
+ mkdirSync(dir, { recursive: true });
142
+ } catch {
143
+ }
144
+ const out = { ...this.state, savedAtMs: Date.now(), version: STATE_VERSION };
145
+ const tmp = `${this.path}.tmp`;
146
+ writeFileSync(tmp, JSON.stringify(out));
147
+ renameSync(tmp, this.path);
148
+ this.dirty = false;
149
+ }
150
+ /** Cancel the debounce timer (used during shutdown). */
151
+ stop() {
152
+ if (this.flushTimer) {
153
+ clearTimeout(this.flushTimer);
154
+ this.flushTimer = null;
155
+ }
156
+ }
157
+ };
158
+
159
+ // src/dispatcher.ts
160
+ import { mkdirSync as mkdirSync2, createWriteStream, rmSync } from "fs";
161
+ import { join as join3 } from "path";
162
+ import { homedir as homedir2 } from "os";
42
163
  import { ThreadCache, AgentMemoryStore, threadIdFor, normalizeSubject } from "@agenticmail/core";
43
164
  function extractSubject(event) {
44
165
  if (typeof event.subject === "string") return event.subject;
@@ -450,6 +571,21 @@ var Dispatcher = class {
450
571
  */
451
572
  threadCache;
452
573
  agentMemory;
574
+ /**
575
+ * Persistent dispatcher state — per-account `{ lastSeenUid, seenUids[] }`
576
+ * that survives a restart. On `start()` we use it to seed each
577
+ * channel's `seenUids` (so IMAP IDLE replays of old UIDs stay
578
+ * deduped) and to drive the catch-up scan (anything strictly
579
+ * newer than `lastSeenUid` got missed during downtime — route it
580
+ * through handleEvent like a synthetic SSE 'new' event).
581
+ *
582
+ * Writes are debounced inside the state module; we just call
583
+ * `markSeen(accountId, uid)` everywhere we decide on a UID.
584
+ */
585
+ state;
586
+ /** Tracks which accounts have already gone through catch-up + pending-task scan
587
+ * so reconnects don't replay the same backlog. */
588
+ caughtUp = /* @__PURE__ */ new Set();
453
589
  /**
454
590
  * Coalesced wake queue. Keyed by `${accountId}::${threadId}`,
455
591
  * each entry holds the pending events + the timer that will
@@ -488,10 +624,13 @@ var Dispatcher = class {
488
624
  this.threadCache = new ThreadCache({ cacheDir: opts.threadCacheDir });
489
625
  this.agentMemory = new AgentMemoryStore({ memoryDir: opts.agentMemoryDir });
490
626
  this.wakeCoalesceMs = opts.wakeCoalesceMs ?? DEFAULT_WAKE_COALESCE_MS;
627
+ this.state = new DispatcherState({ path: opts.stateFilePath });
628
+ this.disableCatchupScan = !!opts.disableCatchupScan;
491
629
  if (!this.cfg.masterKey) {
492
630
  throw new Error("Dispatcher requires AgenticMail master key. Run `agenticmail setup` first.");
493
631
  }
494
632
  }
633
+ disableCatchupScan = false;
495
634
  /**
496
635
  * Charge one wake against the (agent, thread) budget. Returns true
497
636
  * if the wake should proceed, false if the circuit breaker is open.
@@ -590,6 +729,12 @@ var Dispatcher = class {
590
729
  this.channels.clear();
591
730
  for (const entry of this.wakeCoalesce.values()) clearTimeout(entry.timer);
592
731
  this.wakeCoalesce.clear();
732
+ try {
733
+ this.state.stop();
734
+ this.state.flushNow();
735
+ } catch (err) {
736
+ this.log("warn", `[dispatcher] could not flush state on stop: ${err.message}`);
737
+ }
593
738
  this.log("info", "[dispatcher] stopped");
594
739
  }
595
740
  /** Public for tests — directly hand an event to the routing path. */
@@ -620,9 +765,11 @@ var Dispatcher = class {
620
765
  if (ch && Date.now() < ch.suppressTaskMailUntilMs && isTaskNotificationSubject(subject)) {
621
766
  this.log("info", `[dispatcher] suppressed task-notification mail wake for "${account.name}" (uid=${event.uid}, subject="${subject}") \u2014 task event already dispatched`);
622
767
  rememberBounded(ch.seenUids, event.uid);
768
+ this.state.markSeen(account.id, event.uid);
623
769
  return;
624
770
  }
625
771
  if (ch) rememberBounded(ch.seenUids, event.uid);
772
+ this.state.markSeen(account.id, event.uid);
626
773
  if (isThreadClosedSubject(subject)) {
627
774
  this.log("info", `[dispatcher] thread closed (subject="${subject ?? ""}") \u2014 skipping wake for "${account.name}" uid=${event.uid}`);
628
775
  this.postSkipped(account, event, "thread-closed", `subject contains a thread-close marker: "${subject ?? ""}"`);
@@ -711,6 +858,8 @@ var Dispatcher = class {
711
858
  ch.stopping = true;
712
859
  ch.controller?.abort();
713
860
  this.channels.delete(id);
861
+ this.state.forget(id);
862
+ this.caughtUp.delete(id);
714
863
  this.log("info", `[dispatcher] account "${ch.account.name}" removed \u2014 closed SSE channel`);
715
864
  }
716
865
  }
@@ -719,17 +868,19 @@ var Dispatcher = class {
719
868
  this.channels.get(account.id).account = account;
720
869
  continue;
721
870
  }
871
+ const persistedCursor = this.state.getCursor(account.id);
872
+ const seenUids = new Set(persistedCursor?.seenUids ?? []);
722
873
  const ch = {
723
874
  account,
724
875
  controller: null,
725
876
  stopping: false,
726
877
  backoffMs: this.reconnectBaseMs,
727
- seenUids: /* @__PURE__ */ new Set(),
878
+ seenUids,
728
879
  seenTaskIds: /* @__PURE__ */ new Set(),
729
880
  suppressTaskMailUntilMs: 0
730
881
  };
731
882
  this.channels.set(account.id, ch);
732
- this.log("info", `[dispatcher] opening SSE for "${account.name}" (${account.email})`);
883
+ this.log("info", `[dispatcher] opening SSE for "${account.name}" (${account.email})` + (persistedCursor ? ` (restored ${seenUids.size} seen UIDs, lastSeenUid=${persistedCursor.lastSeenUid})` : ""));
733
884
  void this.runChannel(ch);
734
885
  }
735
886
  }
@@ -863,6 +1014,88 @@ var Dispatcher = class {
863
1014
  ch.backoffMs = Math.min(ch.backoffMs * 2, this.reconnectMaxMs);
864
1015
  }
865
1016
  }
1017
+ /**
1018
+ * One-shot backlog scan after a (re)connect: route unprocessed mail
1019
+ * + pending tasks that arrived while the dispatcher was unreachable.
1020
+ *
1021
+ * Mail path: pull the newest 50 envelopes from `/mail/inbox`. For
1022
+ * each UID strictly greater than the persisted `lastSeenUid` (and
1023
+ * not already in the channel's `seenUids`), synthesise an SSE
1024
+ * `new` event and hand it to `handleEvent`. The wake-budget
1025
+ * circuit breaker still applies, so a runaway thread that hit
1026
+ * the cap pre-restart STAYS muted — restart isn't a free reset.
1027
+ *
1028
+ * Tasks path: fetch `/tasks/pending`. Anything not in the
1029
+ * channel's `seenTaskIds` becomes a synthetic task SSE event.
1030
+ *
1031
+ * Failures here are NEVER fatal — they're "best effort". The
1032
+ * dispatcher continues processing live SSE traffic regardless.
1033
+ */
1034
+ async runCatchUp(ch) {
1035
+ const account = ch.account;
1036
+ try {
1037
+ const envelopes = await listInboxForAgent(this.cfg.apiUrl, account.apiKey, { limit: 50 });
1038
+ const cursor = this.state.getCursor(account.id);
1039
+ if (!cursor) {
1040
+ if (envelopes.length > 0) {
1041
+ let maxUid = 0;
1042
+ for (const e of envelopes) {
1043
+ if (Number.isFinite(e.uid) && e.uid > 0) {
1044
+ ch.seenUids.add(e.uid);
1045
+ if (e.uid > maxUid) maxUid = e.uid;
1046
+ }
1047
+ }
1048
+ if (maxUid > 0) this.state.markSeen(account.id, maxUid);
1049
+ this.log("info", `[dispatcher] catch-up for "${account.name}": first run, seeded cursor at uid=${maxUid} (skipping ${envelopes.length} pre-existing messages)`);
1050
+ }
1051
+ } else {
1052
+ const lastSeenUid = cursor.lastSeenUid;
1053
+ const sorted = envelopes.filter((e) => Number.isFinite(e.uid) && e.uid > lastSeenUid && !ch.seenUids.has(e.uid)).sort((a, b) => a.uid - b.uid);
1054
+ if (sorted.length > 0) {
1055
+ this.log("info", `[dispatcher] catch-up for "${account.name}": replaying ${sorted.length} unprocessed UIDs (lastSeenUid=${lastSeenUid})`);
1056
+ for (const env of sorted) {
1057
+ const event = {
1058
+ type: "new",
1059
+ uid: env.uid,
1060
+ subject: env.subject,
1061
+ // Format `from` to match what extractFrom expects
1062
+ // (top-level string OR nested under message). Use the
1063
+ // first sender's address — that's what the live SSE
1064
+ // path delivers.
1065
+ from: env.from?.[0]?.address,
1066
+ message: {
1067
+ subject: env.subject,
1068
+ from: env.from,
1069
+ to: env.to
1070
+ }
1071
+ };
1072
+ await this.handleEvent(account, event);
1073
+ }
1074
+ }
1075
+ }
1076
+ } catch (err) {
1077
+ this.log("warn", `[dispatcher] catch-up mail scan failed for "${account.name}": ${err.message}`);
1078
+ }
1079
+ try {
1080
+ const tasks = await listPendingTasksForAgent(this.cfg.apiUrl, account.apiKey);
1081
+ const fresh = tasks.filter((t) => t.id && !ch.seenTaskIds.has(t.id));
1082
+ if (fresh.length > 0) {
1083
+ this.log("info", `[dispatcher] catch-up for "${account.name}": replaying ${fresh.length} pending tasks`);
1084
+ for (const t of fresh) {
1085
+ const event = {
1086
+ type: "task",
1087
+ taskId: t.id,
1088
+ taskType: t.task_type ?? t.type,
1089
+ task: t.description ?? t.task ?? "",
1090
+ assignee: account.name
1091
+ };
1092
+ await this.handleEvent(account, event);
1093
+ }
1094
+ }
1095
+ } catch (err) {
1096
+ this.log("warn", `[dispatcher] catch-up task scan failed for "${account.name}": ${err.message}`);
1097
+ }
1098
+ }
866
1099
  /** Single SSE attach. Returns when the stream closes for any reason. */
867
1100
  async streamOne(ch) {
868
1101
  const url = `${this.cfg.apiUrl.replace(/\/$/, "")}/api/agenticmail/events`;
@@ -877,6 +1110,12 @@ var Dispatcher = class {
877
1110
  throw new Error(`SSE handshake HTTP ${res.status}`);
878
1111
  }
879
1112
  ch.backoffMs = this.reconnectBaseMs;
1113
+ if (!this.caughtUp.has(ch.account.id) && !this.disableCatchupScan) {
1114
+ this.caughtUp.add(ch.account.id);
1115
+ void this.runCatchUp(ch).catch(
1116
+ (err) => this.log("warn", `[dispatcher] catch-up scan failed for "${ch.account.name}": ${err.message}`)
1117
+ );
1118
+ }
880
1119
  const reader = res.body.getReader();
881
1120
  const decoder = new TextDecoder();
882
1121
  let buffer = "";
@@ -1078,12 +1317,12 @@ var Dispatcher = class {
1078
1317
  kind: ctx.kind,
1079
1318
  trigger: { uid: ctx.uid, taskId: ctx.taskId, subject: ctx.subject, from: ctx.from }
1080
1319
  });
1081
- const logsDir = join2(homedir(), ".agenticmail", "worker-logs");
1320
+ const logsDir = join3(homedir2(), ".agenticmail", "worker-logs");
1082
1321
  try {
1083
- mkdirSync(logsDir, { recursive: true });
1322
+ mkdirSync2(logsDir, { recursive: true });
1084
1323
  } catch {
1085
1324
  }
1086
- const logPath = join2(logsDir, `${sanitizeId(workerId)}.log`);
1325
+ const logPath = join3(logsDir, `${sanitizeId(workerId)}.log`);
1087
1326
  let logStream = null;
1088
1327
  try {
1089
1328
  logStream = createWriteStream(logPath, { flags: "a" });
@@ -1097,9 +1336,9 @@ var Dispatcher = class {
1097
1336
  }
1098
1337
  };
1099
1338
  writeLog(`worker_started agent=${account.name} kind=${ctx.kind}${ctx.uid ? " uid=" + ctx.uid : ""}${ctx.taskId ? " task=" + ctx.taskId : ""}`);
1100
- const cwdDir = join2(homedir(), ".agenticmail", "worker-cwds", sanitizeId(workerId));
1339
+ const cwdDir = join3(homedir2(), ".agenticmail", "worker-cwds", sanitizeId(workerId));
1101
1340
  try {
1102
- mkdirSync(cwdDir, { recursive: true });
1341
+ mkdirSync2(cwdDir, { recursive: true });
1103
1342
  } catch {
1104
1343
  }
1105
1344
  let turnCount = 0;
@@ -1174,7 +1413,12 @@ var Dispatcher = class {
1174
1413
  }
1175
1414
  }
1176
1415
  const ch = this.channels.get(account.id);
1177
- if (ch) for (const uid of digestedUids) rememberBounded(ch.seenUids, uid);
1416
+ if (ch) {
1417
+ for (const uid of digestedUids) {
1418
+ rememberBounded(ch.seenUids, uid);
1419
+ this.state.markSeen(account.id, uid);
1420
+ }
1421
+ }
1178
1422
  }
1179
1423
  try {
1180
1424
  releaseAgentLock();
@@ -5,8 +5,8 @@ function isAgenticMailHookCommand(command) {
5
5
  if (typeof command !== "string") return false;
6
6
  return command.includes("agenticmail-mail-hook") || command.includes("mail-hook.js");
7
7
  }
8
- var HOOK_EVENTS_TO_REGISTER = ["UserPromptSubmit", "Stop"];
9
- var HOOK_EVENTS_TO_REMOVE = ["UserPromptSubmit", "Stop", "PreToolUse"];
8
+ var HOOK_EVENTS_TO_REGISTER = ["UserPromptSubmit", "Stop", "SessionStart"];
9
+ var HOOK_EVENTS_TO_REMOVE = ["UserPromptSubmit", "Stop", "PreToolUse", "SessionStart"];
10
10
  function readSettings(path) {
11
11
  if (!existsSync(path)) return {};
12
12
  const raw = readFileSync(path, "utf-8");
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  upsertUserPromptSubmitHook
3
- } from "./chunk-DKTAW2N5.js";
3
+ } from "./chunk-LO5EQSQA.js";
4
4
  import {
5
5
  startDispatcher,
6
6
  upsertMcpServer
@@ -12,7 +12,7 @@ import {
12
12
  listAccounts,
13
13
  renderSubagentMarkdown,
14
14
  resolveConfig
15
- } from "./chunk-SBP7MJP2.js";
15
+ } from "./chunk-4VQP57SO.js";
16
16
 
17
17
  // src/install.ts
18
18
  import { existsSync, mkdirSync, readdirSync, writeFileSync, readFileSync, unlinkSync } from "fs";
@@ -7,7 +7,7 @@ import {
7
7
  MANAGED_BY_MARKER,
8
8
  getAccountByName,
9
9
  resolveConfig
10
- } from "./chunk-SBP7MJP2.js";
10
+ } from "./chunk-4VQP57SO.js";
11
11
 
12
12
  // src/status.ts
13
13
  import { existsSync, readFileSync, readdirSync } from "fs";
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  uninstall
3
- } from "./chunk-2GPBHK2M.js";
3
+ } from "./chunk-2RVJ2Q3W.js";
4
4
  import {
5
5
  install
6
- } from "./chunk-ZTSWLNUP.js";
6
+ } from "./chunk-OUYPF4ER.js";
7
7
  import {
8
8
  status
9
- } from "./chunk-O4H76K3B.js";
9
+ } from "./chunk-Q5BA2J2C.js";
10
10
  import {
11
11
  AgenticMailApiError
12
- } from "./chunk-SBP7MJP2.js";
12
+ } from "./chunk-4VQP57SO.js";
13
13
 
14
14
  // src/http-routes.ts
15
15
  import { Router } from "express";
package/dist/cli.js CHANGED
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  uninstall
4
- } from "./chunk-2GPBHK2M.js";
4
+ } from "./chunk-2RVJ2Q3W.js";
5
5
  import {
6
6
  install
7
- } from "./chunk-ZTSWLNUP.js";
8
- import "./chunk-DKTAW2N5.js";
7
+ } from "./chunk-OUYPF4ER.js";
8
+ import "./chunk-LO5EQSQA.js";
9
9
  import {
10
10
  status
11
- } from "./chunk-O4H76K3B.js";
11
+ } from "./chunk-Q5BA2J2C.js";
12
12
  import "./chunk-US5FT2UB.js";
13
13
  import {
14
14
  AgenticMailApiError
15
- } from "./chunk-SBP7MJP2.js";
15
+ } from "./chunk-4VQP57SO.js";
16
16
 
17
17
  // src/cli.ts
18
18
  var GREEN = (s) => `\x1B[32m${s}\x1B[0m`;
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  Dispatcher
4
- } from "./chunk-QSR32ZGW.js";
5
- import "./chunk-SBP7MJP2.js";
4
+ } from "./chunk-JOK76WRC.js";
5
+ import "./chunk-4VQP57SO.js";
6
6
 
7
7
  // src/dispatcher-bin.ts
8
8
  async function main() {
@@ -119,6 +119,17 @@ interface DispatcherOptions extends ResolveConfigOptions {
119
119
  /** Override the AgentMemoryStore disk root. Same rationale as
120
120
  * threadCacheDir — only tests should set this. */
121
121
  agentMemoryDir?: string;
122
+ /** Override the dispatcher state file (per-account cursors used
123
+ * for restart recovery). Tests use a tmpdir; production runs
124
+ * against ~/.agenticmail/dispatcher-state.json. */
125
+ stateFilePath?: string;
126
+ /**
127
+ * Disable catch-up scan + pending-task scan on channel open.
128
+ * Default false. Tests that don't want the dispatcher hitting the
129
+ * inbox/tasks endpoints on first connect set this true. Has no
130
+ * effect on the persisted seenUids restore — that's always on.
131
+ */
132
+ disableCatchupScan?: boolean;
122
133
  }
123
134
  /** Minimal Claude Agent SDK query signature we use. */
124
135
  interface QueryFn {
@@ -180,6 +191,21 @@ declare class Dispatcher {
180
191
  */
181
192
  private threadCache;
182
193
  private agentMemory;
194
+ /**
195
+ * Persistent dispatcher state — per-account `{ lastSeenUid, seenUids[] }`
196
+ * that survives a restart. On `start()` we use it to seed each
197
+ * channel's `seenUids` (so IMAP IDLE replays of old UIDs stay
198
+ * deduped) and to drive the catch-up scan (anything strictly
199
+ * newer than `lastSeenUid` got missed during downtime — route it
200
+ * through handleEvent like a synthetic SSE 'new' event).
201
+ *
202
+ * Writes are debounced inside the state module; we just call
203
+ * `markSeen(accountId, uid)` everywhere we decide on a UID.
204
+ */
205
+ private state;
206
+ /** Tracks which accounts have already gone through catch-up + pending-task scan
207
+ * so reconnects don't replay the same backlog. */
208
+ private caughtUp;
183
209
  /**
184
210
  * Coalesced wake queue. Keyed by `${accountId}::${threadId}`,
185
211
  * each entry holds the pending events + the timer that will
@@ -204,6 +230,7 @@ declare class Dispatcher {
204
230
  * events to wake on" — the host has no liveness signal. */
205
231
  private processHeartbeatTimer;
206
232
  constructor(opts?: DispatcherOptions);
233
+ private disableCatchupScan;
207
234
  /**
208
235
  * Charge one wake against the (agent, thread) budget. Returns true
209
236
  * if the wake should proceed, false if the circuit breaker is open.
@@ -270,6 +297,24 @@ declare class Dispatcher {
270
297
  private handleSystemEvent;
271
298
  /** Watch one account's SSE stream forever; reconnect with backoff on drop. */
272
299
  private runChannel;
300
+ /**
301
+ * One-shot backlog scan after a (re)connect: route unprocessed mail
302
+ * + pending tasks that arrived while the dispatcher was unreachable.
303
+ *
304
+ * Mail path: pull the newest 50 envelopes from `/mail/inbox`. For
305
+ * each UID strictly greater than the persisted `lastSeenUid` (and
306
+ * not already in the channel's `seenUids`), synthesise an SSE
307
+ * `new` event and hand it to `handleEvent`. The wake-budget
308
+ * circuit breaker still applies, so a runaway thread that hit
309
+ * the cap pre-restart STAYS muted — restart isn't a free reset.
310
+ *
311
+ * Tasks path: fetch `/tasks/pending`. Anything not in the
312
+ * channel's `seenTaskIds` becomes a synthetic task SSE event.
313
+ *
314
+ * Failures here are NEVER fatal — they're "best effort". The
315
+ * dispatcher continues processing live SSE traffic regardless.
316
+ */
317
+ private runCatchUp;
273
318
  /** Single SSE attach. Returns when the stream closes for any reason. */
274
319
  private streamOne;
275
320
  /**
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  Dispatcher
3
- } from "./chunk-QSR32ZGW.js";
4
- import "./chunk-SBP7MJP2.js";
3
+ } from "./chunk-JOK76WRC.js";
4
+ import "./chunk-4VQP57SO.js";
5
5
  export {
6
6
  Dispatcher
7
7
  };
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  createIntegrationRoutes
3
- } from "./chunk-PTLX7SQV.js";
4
- import "./chunk-2GPBHK2M.js";
5
- import "./chunk-ZTSWLNUP.js";
6
- import "./chunk-DKTAW2N5.js";
7
- import "./chunk-O4H76K3B.js";
3
+ } from "./chunk-TV7VG7YM.js";
4
+ import "./chunk-2RVJ2Q3W.js";
5
+ import "./chunk-OUYPF4ER.js";
6
+ import "./chunk-LO5EQSQA.js";
7
+ import "./chunk-Q5BA2J2C.js";
8
8
  import "./chunk-US5FT2UB.js";
9
- import "./chunk-SBP7MJP2.js";
9
+ import "./chunk-4VQP57SO.js";
10
10
  export {
11
11
  createIntegrationRoutes
12
12
  };
package/dist/index.js CHANGED
@@ -1,20 +1,20 @@
1
1
  import {
2
2
  Dispatcher,
3
3
  loadPersonaForAgent
4
- } from "./chunk-QSR32ZGW.js";
4
+ } from "./chunk-JOK76WRC.js";
5
5
  import {
6
6
  createIntegrationRoutes
7
- } from "./chunk-PTLX7SQV.js";
7
+ } from "./chunk-TV7VG7YM.js";
8
8
  import {
9
9
  uninstall
10
- } from "./chunk-2GPBHK2M.js";
10
+ } from "./chunk-2RVJ2Q3W.js";
11
11
  import {
12
12
  install
13
- } from "./chunk-ZTSWLNUP.js";
14
- import "./chunk-DKTAW2N5.js";
13
+ } from "./chunk-OUYPF4ER.js";
14
+ import "./chunk-LO5EQSQA.js";
15
15
  import {
16
16
  status
17
- } from "./chunk-O4H76K3B.js";
17
+ } from "./chunk-Q5BA2J2C.js";
18
18
  import "./chunk-US5FT2UB.js";
19
19
  import {
20
20
  AgenticMailApiError,
@@ -27,7 +27,7 @@ import {
27
27
  renderPersonaBody,
28
28
  renderSubagentMarkdown,
29
29
  resolveConfig
30
- } from "./chunk-SBP7MJP2.js";
30
+ } from "./chunk-4VQP57SO.js";
31
31
  export {
32
32
  AgenticMailApiError,
33
33
  Dispatcher,
package/dist/install.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  install,
3
3
  selectExposableAgents
4
- } from "./chunk-ZTSWLNUP.js";
5
- import "./chunk-DKTAW2N5.js";
4
+ } from "./chunk-OUYPF4ER.js";
5
+ import "./chunk-LO5EQSQA.js";
6
6
  import "./chunk-US5FT2UB.js";
7
- import "./chunk-SBP7MJP2.js";
7
+ import "./chunk-4VQP57SO.js";
8
8
  export {
9
9
  install,
10
10
  selectExposableAgents
package/dist/mail-hook.js CHANGED
@@ -7,6 +7,8 @@ import { join, dirname } from "path";
7
7
  var AGENTICMAIL_DIR = join(homedir(), ".agenticmail");
8
8
  var CONFIG_PATH = join(AGENTICMAIL_DIR, "config.json");
9
9
  var CURSOR_PATH = join(AGENTICMAIL_DIR, "claudecode-hook-cursor.json");
10
+ var SESSIONS_PATH = join(AGENTICMAIL_DIR, "claudecode-hook-sessions.json");
11
+ var SESSIONS_CAP = 100;
10
12
  var HOOK_VERSION = "1";
11
13
  var HTTP_TIMEOUT_MS = 800;
12
14
  var GLOBAL_TIMEOUT_MS = 1500;
@@ -47,17 +49,95 @@ async function readStdinJson() {
47
49
  setTimeout(() => finish(() => null), 200).unref();
48
50
  });
49
51
  }
52
+ function capabilitiesBlurb() {
53
+ return [
54
+ "\u{1F380} AgenticMail is available via MCP (mcp__agenticmail__*).",
55
+ "",
56
+ "When to reach for it:",
57
+ " \xB7 Building anything that benefits from MULTIPLE specialized roles working in parallel \u2014 designer + developer + reviewer, researcher + writer + fact-checker, planner + executor + critic.",
58
+ " \xB7 Coordinating long-running async work that survives across Claude Code restarts (email threads are durable; in-memory state is not).",
59
+ " \xB7 Anywhere you would otherwise spawn the Task tool repeatedly for sub-tasks \u2014 prefer AgenticMail when those sub-tasks need to talk to EACH OTHER, not just back to you.",
60
+ "",
61
+ "High-leverage tools:",
62
+ " \xB7 mcp__agenticmail__create_account({ name, role }) \u2014 spawn a fresh persistent agent identity with its own inbox + API key.",
63
+ ' \xB7 mcp__agenticmail__send_email({ to, cc, subject, text, wake }) \u2014 kick off the work. CC the whole team; use `wake: ["alice"]` to give exactly one agent the next turn while others stay informed.',
64
+ " \xB7 mcp__agenticmail__call_agent({ agent, message }) / mcp__agenticmail__wait_for_email \u2014 blocking RPC when you need a specific answer back before continuing your own turn.",
65
+ "",
66
+ "Coordination pattern: one email thread = the shared workspace. Reply-all keeps the audit trail. Use `wake` to control whose turn it is.",
67
+ "",
68
+ "Other tools cover: inbox/folder management, drafts, templates, tasks, contacts, signatures, SMS, voice. Full list under mcp__agenticmail__* \u2014 discover on demand, don't front-load them all."
69
+ ].join("\n");
70
+ }
71
+ function loadSeenSessions() {
72
+ if (!existsSync(SESSIONS_PATH)) return [];
73
+ try {
74
+ const parsed = JSON.parse(readFileSync(SESSIONS_PATH, "utf-8"));
75
+ const arr = Array.isArray(parsed?.seen) ? parsed.seen : [];
76
+ return arr.filter((s) => typeof s === "string");
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+ function rememberSession(sessionId, seen) {
82
+ const next = seen.filter((s) => s !== sessionId);
83
+ next.push(sessionId);
84
+ while (next.length > SESSIONS_CAP) next.shift();
85
+ try {
86
+ if (!existsSync(dirname(SESSIONS_PATH))) mkdirSync(dirname(SESSIONS_PATH), { recursive: true });
87
+ writeFileSync(SESSIONS_PATH, JSON.stringify({ seen: next, hookVersion: HOOK_VERSION }, null, 2));
88
+ } catch {
89
+ }
90
+ }
50
91
  async function main() {
51
92
  const input = await readStdinJson();
52
93
  const eventName = input?.hook_event_name ?? "UserPromptSubmit";
53
- if (!existsSync(CONFIG_PATH)) return;
94
+ const sessionId = typeof input?.session_id === "string" ? input.session_id : "";
95
+ if (eventName === "SessionStart") {
96
+ process.stdout.write(JSON.stringify({
97
+ hookSpecificOutput: {
98
+ hookEventName: "SessionStart",
99
+ additionalContext: capabilitiesBlurb()
100
+ }
101
+ }));
102
+ return;
103
+ }
104
+ let blurbContext = "";
105
+ if (eventName === "UserPromptSubmit" && sessionId) {
106
+ const seen = loadSeenSessions();
107
+ if (!seen.includes(sessionId)) {
108
+ blurbContext = capabilitiesBlurb();
109
+ rememberSession(sessionId, seen);
110
+ }
111
+ }
112
+ const emitAndExit = (mailContext) => {
113
+ const combined = [blurbContext, mailContext].filter(Boolean).join("\n\n");
114
+ if (!combined) return;
115
+ if (eventName === "Stop") {
116
+ process.stdout.write(JSON.stringify({ decision: "block", reason: combined }));
117
+ } else {
118
+ process.stdout.write(JSON.stringify({
119
+ hookSpecificOutput: {
120
+ hookEventName: eventName,
121
+ additionalContext: combined
122
+ }
123
+ }));
124
+ }
125
+ };
126
+ if (!existsSync(CONFIG_PATH)) {
127
+ emitAndExit("");
128
+ return;
129
+ }
54
130
  let cfg;
55
131
  try {
56
132
  cfg = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
57
133
  } catch {
134
+ emitAndExit("");
135
+ return;
136
+ }
137
+ if (!cfg.masterKey) {
138
+ emitAndExit("");
58
139
  return;
59
140
  }
60
- if (!cfg.masterKey) return;
61
141
  const apiHost = cfg.api?.host ?? "127.0.0.1";
62
142
  const apiPort = cfg.api?.port ?? 3829;
63
143
  const apiUrl = `http://${apiHost}:${apiPort}`;
@@ -67,15 +147,22 @@ async function main() {
67
147
  headers: { Authorization: `Bearer ${cfg.masterKey}` },
68
148
  signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
69
149
  });
70
- if (!r.ok) return;
150
+ if (!r.ok) {
151
+ emitAndExit("");
152
+ return;
153
+ }
71
154
  const data = await r.json();
72
155
  bridge = (data.agents ?? []).find(
73
156
  (a) => a.name === "claudecode" || a.name === "claude" || a.role === "bridge"
74
157
  );
75
158
  } catch {
159
+ emitAndExit("");
160
+ return;
161
+ }
162
+ if (!bridge?.apiKey) {
163
+ emitAndExit("");
76
164
  return;
77
165
  }
78
- if (!bridge?.apiKey) return;
79
166
  let cursorMs = 0;
80
167
  let lastCheckedMs = 0;
81
168
  if (existsSync(CURSOR_PATH)) {
@@ -96,10 +183,14 @@ async function main() {
96
183
  headers: { Authorization: `Bearer ${bridge.apiKey}` },
97
184
  signal: AbortSignal.timeout(HTTP_TIMEOUT_MS)
98
185
  });
99
- if (!r.ok) return;
186
+ if (!r.ok) {
187
+ emitAndExit("");
188
+ return;
189
+ }
100
190
  const data = await r.json();
101
191
  messages = data.messages ?? [];
102
192
  } catch {
193
+ emitAndExit("");
103
194
  return;
104
195
  }
105
196
  const newOnes = messages.filter((m) => {
@@ -118,6 +209,7 @@ async function main() {
118
209
  } catch {
119
210
  }
120
211
  }
212
+ emitAndExit("");
121
213
  return;
122
214
  }
123
215
  newOnes.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
@@ -149,19 +241,7 @@ async function main() {
149
241
  );
150
242
  } catch {
151
243
  }
152
- if (eventName === "Stop") {
153
- process.stdout.write(JSON.stringify({
154
- decision: "block",
155
- reason: lines.join("\n")
156
- }));
157
- } else {
158
- process.stdout.write(JSON.stringify({
159
- hookSpecificOutput: {
160
- hookEventName: eventName,
161
- additionalContext: lines.join("\n")
162
- }
163
- }));
164
- }
244
+ emitAndExit(lines.join("\n"));
165
245
  }
166
246
  var globalTimeout = new Promise((resolve) => {
167
247
  setTimeout(() => resolve(), GLOBAL_TIMEOUT_MS).unref();
package/dist/status.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  status
3
- } from "./chunk-O4H76K3B.js";
3
+ } from "./chunk-Q5BA2J2C.js";
4
4
  import "./chunk-US5FT2UB.js";
5
- import "./chunk-SBP7MJP2.js";
5
+ import "./chunk-4VQP57SO.js";
6
6
  export {
7
7
  status
8
8
  };
package/dist/uninstall.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  uninstall
3
- } from "./chunk-2GPBHK2M.js";
4
- import "./chunk-DKTAW2N5.js";
3
+ } from "./chunk-2RVJ2Q3W.js";
4
+ import "./chunk-LO5EQSQA.js";
5
5
  import "./chunk-US5FT2UB.js";
6
- import "./chunk-SBP7MJP2.js";
6
+ import "./chunk-4VQP57SO.js";
7
7
  export {
8
8
  uninstall
9
9
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agenticmail/claudecode",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Claude Code integration for AgenticMail — surfaces every AgenticMail agent as a native Claude Code subagent so any Claude Code session can delegate to them with the Agent tool",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",