@adaptic/maestro 1.1.7 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.claude/commands/init-maestro.md +502 -260
  2. package/README.md +47 -2
  3. package/bin/maestro.mjs +1 -1
  4. package/docs/guides/agents-observe-setup.md +64 -0
  5. package/docs/guides/ccxray-diagnostics.md +65 -0
  6. package/docs/guides/claude-mem-setup.md +79 -0
  7. package/docs/guides/claude-pace-setup.md +56 -0
  8. package/docs/guides/claudraband-sessions.md +98 -0
  9. package/docs/guides/clawteam-swarm.md +116 -0
  10. package/docs/guides/code-review-graph-setup.md +86 -0
  11. package/docs/guides/email-setup.md +399 -0
  12. package/docs/guides/media-generation-setup.md +349 -0
  13. package/docs/guides/outbound-governance-setup.md +438 -0
  14. package/docs/guides/pdf-generation-setup.md +315 -0
  15. package/docs/guides/poller-daemon-setup.md +550 -0
  16. package/docs/guides/rag-context-setup.md +459 -0
  17. package/docs/guides/self-optimization-pattern.md +82 -0
  18. package/docs/guides/slack-setup.md +350 -0
  19. package/docs/guides/twilio-subaccounts-setup.md +223 -0
  20. package/docs/guides/voice-sms-setup.md +698 -0
  21. package/docs/guides/webhook-relay-setup.md +349 -0
  22. package/docs/guides/whatsapp-setup.md +282 -0
  23. package/docs/runbooks/mac-mini-bootstrap.md +21 -0
  24. package/package.json +2 -1
  25. package/plugins/maestro-skills/plugin.json +16 -0
  26. package/plugins/maestro-skills/skills/agents-observe.md +110 -0
  27. package/plugins/maestro-skills/skills/ccxray-diagnostics.md +91 -0
  28. package/plugins/maestro-skills/skills/claude-pace.md +61 -0
  29. package/plugins/maestro-skills/skills/code-review-graph.md +99 -0
  30. package/scaffold/CLAUDE.md +64 -0
  31. package/scaffold/config/agent.ts.example +2 -1
  32. package/scaffold/config/caller-id-map.yaml +46 -0
  33. package/scaffold/config/known-agents.json +35 -0
  34. package/scripts/daemon/classifier.mjs +264 -50
  35. package/scripts/daemon/dispatcher.mjs +109 -5
  36. package/scripts/daemon/launchd-wrapper-generic.sh +96 -0
  37. package/scripts/daemon/launchd-wrapper-slack-events.sh +37 -0
  38. package/scripts/daemon/launchd-wrapper.sh +91 -0
  39. package/scripts/daemon/lib/session-router.mjs +274 -0
  40. package/scripts/daemon/lib/session-router.test.mjs +295 -0
  41. package/scripts/daemon/prompt-builder.mjs +51 -11
  42. package/scripts/daemon/responder.mjs +234 -19
  43. package/scripts/daemon/session-lock.mjs +194 -0
  44. package/scripts/daemon/sophie-daemon.mjs +16 -2
  45. package/scripts/email-signature.html +20 -4
  46. package/scripts/local-triggers/generate-plists.sh +62 -10
  47. package/scripts/media-generation/README.md +2 -0
  48. package/scripts/pdf-generation/README.md +2 -0
  49. package/scripts/poller/imap-client.mjs +4 -2
  50. package/scripts/poller/slack-poller.mjs +126 -59
  51. package/scripts/poller/trigger.mjs +12 -1
  52. package/scripts/setup/init-agent.sh +91 -1
  53. package/scripts/setup/install-dev-tools.sh +150 -0
  54. package/scripts/spawn-session.sh +21 -6
  55. package/workflows/continuous/backlog-executor.yaml +141 -0
  56. package/workflows/daily/evening-wrap.yaml +41 -1
  57. package/workflows/daily/morning-brief.yaml +17 -0
  58. package/workflows/event-driven/agent-failure-investigation.yaml +137 -0
  59. package/workflows/event-driven/pr-review.yaml +104 -0
  60. package/workflows/weekly/engineering-health.yaml +154 -0
@@ -5,28 +5,99 @@
5
5
  * action type, and which Claude model should handle them.
6
6
  *
7
7
  * Classification chain:
8
- * 1. Anthropic Claude Haiku (primary)
8
+ * 1. Claude Haiku via `claude --print` CLI (Max subscription, no API quota)
9
9
  * 2. OpenAI gpt-4o-mini (fallback)
10
10
  * 3. Rule-based heuristics (final fallback)
11
+ *
12
+ * Tier 1 was migrated off `@anthropic-ai/sdk` per CEO directive
13
+ * (Slack DM D099N1JGKRQ, 2026-04-27 09:38Z + 11:33Z): all agent
14
+ * daemon paths must funnel through Claude Code CLI sessions, not
15
+ * the Anthropic API. The Tier 2 OpenAI fallback is retained as a
16
+ * safety net for when the CLI is unavailable; Tier 3 rule-based
17
+ * remains the final fallback.
11
18
  */
12
19
 
13
- import Anthropic from "@anthropic-ai/sdk";
14
20
  import OpenAI from "openai";
15
21
  import dotenv from "dotenv";
22
+ import { spawn } from "child_process";
16
23
  import { fileURLToPath } from "url";
17
24
  import { dirname, resolve } from "path";
25
+ import { readFileSync } from "fs";
18
26
 
19
27
  // Load .env from project root
20
28
  const __filename = fileURLToPath(import.meta.url);
21
29
  const __dirname = dirname(__filename);
22
30
  dotenv.config({ path: resolve(__dirname, "../../.env") });
23
31
 
32
+ // ── Agent identity + peer agents ──────────────────────────────────────────
33
+ // Loaded from config/known-agents.json and AGENT_DIR to determine which
34
+ // agent is "me" and which are peers. Prevents cross-agent message
35
+ // interception in shared channels (ib-20260418-cross-agent-routing).
36
+
37
+ const AGENT_DIR = process.env.AGENT_DIR || process.env.AGENT_ROOT || resolve(__dirname, "../..");
38
+
39
+ let _agentIdentity = null; // { name, slackId, role }
40
+ let _peerAgents = []; // [{ name, slackId, role }] — all agents EXCEPT me
41
+
42
+ function loadAgentRegistry() {
43
+ if (_agentIdentity) return; // already loaded
44
+
45
+ // 1. Load known-agents.json (try agent repo first, then maestro scaffold)
46
+ let agents = [];
47
+ for (const base of [AGENT_DIR, resolve(process.env.HOME || "", "maestro")]) {
48
+ try {
49
+ const raw = readFileSync(resolve(base, "config/known-agents.json"), "utf-8");
50
+ agents = JSON.parse(raw).agents || [];
51
+ if (agents.length > 0) break;
52
+ } catch { /* try next */ }
53
+ }
54
+
55
+ // 2. Determine which agent is "me" — match by AGENT_DIR basename
56
+ const repoSlug = AGENT_DIR.split("/").pop(); // e.g. "sophie-ai"
57
+ const me = agents.find(a => a.repo === repoSlug);
58
+
59
+ if (me) {
60
+ _agentIdentity = me;
61
+ _peerAgents = agents.filter(a => a.slackId !== me.slackId);
62
+ } else {
63
+ // Fallback: try to extract from agent.ts or CLAUDE.md
64
+ try {
65
+ const agentTs = readFileSync(resolve(AGENT_DIR, "config/agent.ts"), "utf-8");
66
+ const nameMatch = agentTs.match(/firstName:\s*['"](\w+)['"]/);
67
+ const slackMatch = agentTs.match(/slackMemberId:\s*['"]([^'"]+)['"]/);
68
+ _agentIdentity = {
69
+ name: nameMatch ? nameMatch[1] : "Unknown",
70
+ slackId: slackMatch ? slackMatch[1] : "",
71
+ role: "agent",
72
+ };
73
+ _peerAgents = agents.filter(a => a.slackId !== (_agentIdentity.slackId || "NONE"));
74
+ } catch {
75
+ _agentIdentity = { name: "Sophie", slackId: "", role: "agent" };
76
+ _peerAgents = agents;
77
+ }
78
+ }
79
+
80
+ console.log(`[classifier] Agent identity: ${_agentIdentity.name} (${_agentIdentity.slackId || "no slack ID"}), ${_peerAgents.length} peer agents`);
81
+ }
82
+
24
83
  const ANTHROPIC_MODEL = "claude-haiku-4-5-20251001";
25
84
  const OPENAI_MODEL = "gpt-4o-mini";
85
+ const CLAUDE_BIN = process.env.CLAUDE_BIN || "/Users/sophie/.local/bin/claude";
86
+ const CLAUDE_CLI_TIMEOUT_MS = 30_000;
26
87
 
27
88
  // ── System prompt shared by both LLM classifiers ────────────────────────────
28
89
 
29
- const SYSTEM_PROMPT = `You are a message classifier for Sophie Nguyen, Chief of Staff (AI-operated) at Adaptic.ai. Sophie is the autonomous executive command layer for CEO Mehran Granfar.
90
+ function buildSystemPrompt() {
91
+ loadAgentRegistry();
92
+ const agentName = _agentIdentity.name;
93
+ const agentRole = _agentIdentity.role || "agent";
94
+
95
+ // Build peer agents context for the LLM so it knows who the other agents are
96
+ const peerContext = _peerAgents.length > 0
97
+ ? `\nKNOWN PEER AGENTS (other AI agents in the organisation — if a message @-mentions one of these by name or Slack ID, it is NOT directed at ${agentName}):\n${_peerAgents.map(a => `- ${a.name} (${a.role}, Slack: <@${a.slackId}>)`).join("\n")}\n`
98
+ : "";
99
+
100
+ return `You are a message classifier for ${agentName}, ${agentRole} (AI-operated) at Adaptic.ai. ${agentName} is the autonomous executive command layer for CEO Mehran Granfar.
30
101
 
31
102
  Your job: classify each incoming message and return a JSON object with exactly these fields:
32
103
 
@@ -38,7 +109,7 @@ Your job: classify each incoming message and return a JSON object with exactly t
38
109
  "category": "action_required" | "fyi" | "ignore",
39
110
  "directed_at_sophie": true | false
40
111
  }
41
-
112
+ ${peerContext}
42
113
  Classification rules:
43
114
 
44
115
  PRIORITY:
@@ -64,21 +135,22 @@ CATEGORY:
64
135
  - "fyi": Informational, no action needed
65
136
  - "ignore": No value
66
137
 
67
- DIRECTED_AT_SOPHIE (CRITICAL — determines whether Sophie should respond. Default to false in channels/group chats.):
68
- - true: The message is a DM to Sophie (1:1)
69
- - true: The message explicitly mentions Sophie by name or @mention
138
+ DIRECTED_AT_SOPHIE (CRITICAL — determines whether ${agentName} should respond. Default to false in channels/group chats.):
139
+ - true: The message is a DM to ${agentName} (1:1)
140
+ - true: The message explicitly mentions ${agentName} by name or @mention
70
141
  - true: The message is from the CEO in a DM (1:1 conversation)
71
- - true: The message explicitly asks Sophie to do something she's responsible for (scheduling, follow-ups, coordination, research) AND addresses her specifically by name or @mention
72
- - true: In a thread where Sophie previously replied, the message is a DIRECT reply to what Sophie said (e.g., answering her question, responding to her update) — not just any message in the same thread
73
- - false: CEO messages in channels/group chats that don't mention Sophie — the CEO talks to many people, not just Sophie
142
+ - true: The message explicitly asks ${agentName} to do something they're responsible for AND addresses them specifically by name or @mention
143
+ - true: In a thread where ${agentName} previously replied, the message is a DIRECT reply to what ${agentName} said (e.g., answering their question, responding to their update) — not just any message in the same thread
144
+ - **FALSE CROSS-AGENT RULE**: If the message explicitly @-mentions or names a DIFFERENT known AI agent (see KNOWN PEER AGENTS above), it is NOT directed at ${agentName} even if the topic is relevant to ${agentName}'s role. The sender chose a specific agent; respect that routing.
145
+ - false: CEO messages in channels/group chats that don't mention ${agentName} — the CEO talks to many people, not just ${agentName}
74
146
  - false: The message is general channel chatter between other team members
75
- - false: The message is a conversation between two people that doesn't involve Sophie, even if Sophie previously participated in the same thread
76
- - false: The message is a status update, FYI, or announcement not requiring Sophie's response
77
- - false: The message is someone responding to another person (not Sophie) in a thread
78
- - false: Short reactions like "thanks", "nice", "got it", "+1", emoji-only messages — even in threads Sophie is in
147
+ - false: The message is a conversation between two people that doesn't involve ${agentName}, even if ${agentName} previously participated in the same thread
148
+ - false: The message is a status update, FYI, or announcement not requiring ${agentName}'s response
149
+ - false: The message is someone responding to another person (not ${agentName}) in a thread
150
+ - false: Short reactions like "thanks", "nice", "got it", "+1", emoji-only messages — even in threads ${agentName} is in
79
151
  - false: Someone sharing a link, article, or resource with the channel generally
80
- - false: Sophie being in a thread does NOT mean every subsequent message is for her — only direct replies to her messages count
81
- - CRITICAL DEFAULT: When in doubt in any multi-person channel, group chat, or thread, ALWAYS default to false. Sophie must NOT insert herself into conversations she wasn't invited to. It is far worse to respond unnecessarily than to miss a message — someone will @mention Sophie if they need her.
152
+ - false: ${agentName} being in a thread does NOT mean every subsequent message is for them — only direct replies to their messages count
153
+ - CRITICAL DEFAULT: When in doubt in any multi-person channel, group chat, or thread, ALWAYS default to false. ${agentName} must NOT insert themselves into conversations they weren't invited to. It is far worse to respond unnecessarily than to miss a message — someone will @mention ${agentName} if they need them.
82
154
 
83
155
  Context:
84
156
  - sender_privilege "ceo" = Mehran Granfar (always critical, always opus)
@@ -106,8 +178,11 @@ Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Team
106
178
  Input: External email: "Following up on our conversation about the Singapore entity"
107
179
  Output: {"priority":"normal","action":"respond","model":"sonnet","summary":"External follow-up on Singapore entity discussion","category":"action_required","directed_at_sophie":true}
108
180
 
109
- Input: Channel message from Jacob: "@Sophie can you check the engine deployment?"
110
- Output: {"priority":"normal","action":"respond","model":"sonnet","summary":"Jacob asking Sophie to check engine deployment","category":"action_required","directed_at_sophie":true}
181
+ Input: Channel message from Jacob: "@${agentName} can you check the engine deployment?"
182
+ Output: {"priority":"normal","action":"respond","model":"sonnet","summary":"Jacob asking ${agentName} to check engine deployment","category":"action_required","directed_at_sophie":true}
183
+
184
+ Input: Channel message from CEO: "@AnotherAgent can you review the agentic libraries in this channel?"
185
+ Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"CEO asking another agent to review agentic libraries — not directed at ${agentName}","category":"fyi","directed_at_sophie":false}
111
186
 
112
187
  Input: Channel message from Hootan to Nima: "Nima, did you file the DIFC response yet?"
113
188
  Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Hootan asking Nima about DIFC response filing","category":"fyi","directed_at_sophie":false}
@@ -115,11 +190,11 @@ Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Hoot
115
190
  Input: Channel thread — Jacob: "pushed the fix" / Hootan: "nice, looks good"
116
191
  Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Team exchange about code fix","category":"fyi","directed_at_sophie":false}
117
192
 
118
- Input: Thread where Sophie previously replied — Jacob: "yeah that makes sense, I'll handle it" (responding to Hootan, not Sophie)
193
+ Input: Thread where ${agentName} previously replied — Jacob: "yeah that makes sense, I'll handle it" (responding to Hootan, not ${agentName})
119
194
  Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Jacob acknowledging Hootan in thread","category":"fyi","directed_at_sophie":false}
120
195
 
121
- Input: Thread where Sophie previously replied — Hootan: "Sophie, can you send the updated doc?"
122
- Output: {"priority":"high","action":"respond","model":"sonnet","summary":"Hootan asking Sophie for updated document","category":"action_required","directed_at_sophie":true}
196
+ Input: Thread where ${agentName} previously replied — Hootan: "${agentName}, can you send the updated doc?"
197
+ Output: {"priority":"high","action":"respond","model":"sonnet","summary":"Hootan asking ${agentName} for updated document","category":"action_required","directed_at_sophie":true}
123
198
 
124
199
  Input: Channel message: "FYI — the Cayman entity docs are signed and filed"
125
200
  Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"FYI: Cayman entity docs signed and filed","category":"fyi","directed_at_sophie":false}
@@ -128,6 +203,7 @@ Input: Group chat — Nima: "Hootan, can we sync on the DFSA gaps tomorrow?"
128
203
  Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Nima asking Hootan to sync on DFSA gaps","category":"fyi","directed_at_sophie":false}
129
204
 
130
205
  Return ONLY the JSON object. No explanation, no markdown fencing, no extra text.`;
206
+ }
131
207
 
132
208
  // ── Format the inbox item into a human-readable prompt ──────────────────────
133
209
 
@@ -158,7 +234,20 @@ function formatItemPrompt(item) {
158
234
  function parseClassification(text) {
159
235
  // Strip markdown code fences if present
160
236
  const cleaned = text.replace(/^```(?:json)?\s*/m, "").replace(/\s*```$/m, "").trim();
161
- const parsed = JSON.parse(cleaned);
237
+ let parsed;
238
+ try {
239
+ parsed = JSON.parse(cleaned);
240
+ } catch (_) {
241
+ // CLI output may include prose around the JSON. Try regex-extract the
242
+ // first top-level {...} block. (Greedy match up to the last closing
243
+ // brace works for our flat schema; nested objects would need a real
244
+ // parser, but our classification objects are flat.)
245
+ const match = cleaned.match(/\{[\s\S]*\}/);
246
+ if (!match) {
247
+ throw new Error(`classifier: no JSON object found in output: ${cleaned.slice(0, 200)}`);
248
+ }
249
+ parsed = JSON.parse(match[0]);
250
+ }
162
251
 
163
252
  // Validate and coerce fields
164
253
  const validPriorities = ["critical", "high", "normal", "ignore"];
@@ -176,31 +265,109 @@ function parseClassification(text) {
176
265
  };
177
266
  }
178
267
 
179
- // ── Primary: Anthropic Claude Haiku ─────────────────────────────────────────
268
+ // ── Primary: Claude Haiku via `claude --print` CLI ──────────────────────────
269
+ //
270
+ // We invoke the `claude` binary (Max subscription, Claude Code CLI) rather
271
+ // than the Anthropic API SDK, per CEO directive (Slack DM D099N1JGKRQ,
272
+ // 2026-04-27 09:38Z + 11:33Z): the daemon must NOT consume Anthropic API
273
+ // quota; all model calls must funnel through `claude --print` sessions.
274
+ //
275
+ // Implementation notes:
276
+ // • child_process.spawn (not exec) — avoids shell-escape injection on
277
+ // potentially-hostile user message content.
278
+ // • System prompt rides on --append-system-prompt; the user prompt is
279
+ // written to stdin and the pipe is closed.
280
+ // • Output is parsed via parseClassification() which already handles
281
+ // ```json``` fences and bare JSON.
282
+ // • Any failure (non-zero exit, timeout, unparseable output) throws so
283
+ // the OpenAI Tier 2 fallback engages exactly as before.
284
+
285
+ async function runClaudeCLI(systemPrompt, userPrompt) {
286
+ return new Promise((resolvePromise, rejectPromise) => {
287
+ const args = [
288
+ "--print",
289
+ "--dangerously-skip-permissions",
290
+ "--model", ANTHROPIC_MODEL,
291
+ "--append-system-prompt", systemPrompt,
292
+ ];
293
+
294
+ const proc = spawn(CLAUDE_BIN, args, {
295
+ stdio: ["pipe", "pipe", "pipe"],
296
+ // Force claude CLI onto keychain OAuth (Max subscription); strip any
297
+ // stale ANTHROPIC_API_KEY/AUTH_TOKEN inherited from the daemon env.
298
+ env: { ...process.env, ANTHROPIC_API_KEY: "", ANTHROPIC_AUTH_TOKEN: "" },
299
+ });
300
+
301
+ let stdout = "";
302
+ let stderr = "";
303
+ let settled = false;
304
+
305
+ const timer = setTimeout(() => {
306
+ if (settled) return;
307
+ settled = true;
308
+ try { proc.kill("SIGTERM"); } catch (_) { /* noop */ }
309
+ setTimeout(() => { try { if (!proc.killed) proc.kill("SIGKILL"); } catch (_) { /* noop */ } }, 2000);
310
+ rejectPromise(new Error(`claude CLI timed out after ${CLAUDE_CLI_TIMEOUT_MS}ms`));
311
+ }, CLAUDE_CLI_TIMEOUT_MS);
312
+
313
+ proc.stdout.on("data", (chunk) => { stdout += chunk.toString(); });
314
+ proc.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
315
+
316
+ proc.on("error", (err) => {
317
+ if (settled) return;
318
+ settled = true;
319
+ clearTimeout(timer);
320
+ rejectPromise(new Error(`claude CLI spawn error: ${err.message}`));
321
+ });
322
+
323
+ proc.on("close", (code) => {
324
+ if (settled) return;
325
+ settled = true;
326
+ clearTimeout(timer);
327
+ if (code !== 0) {
328
+ const tail = (stderr || "").trim().slice(-500);
329
+ rejectPromise(new Error(`claude CLI exited ${code}: ${tail || "no stderr"}`));
330
+ return;
331
+ }
332
+ resolvePromise(stdout);
333
+ });
334
+
335
+ // Write the user prompt to stdin and close.
336
+ try {
337
+ proc.stdin.end(userPrompt, "utf8");
338
+ } catch (err) {
339
+ if (settled) return;
340
+ settled = true;
341
+ clearTimeout(timer);
342
+ rejectPromise(new Error(`claude CLI stdin write error: ${err.message}`));
343
+ }
344
+ });
345
+ }
180
346
 
181
347
  async function classifyWithAnthropic(item) {
182
- const client = new Anthropic();
183
- const response = await client.messages.create({
184
- model: ANTHROPIC_MODEL,
185
- max_tokens: 256,
186
- system: SYSTEM_PROMPT,
187
- messages: [
188
- { role: "user", content: formatItemPrompt(item) },
189
- ],
190
- });
191
- const text = response.content[0].text;
192
- return parseClassification(text);
348
+ const systemPrompt = buildSystemPrompt();
349
+ const userPrompt =
350
+ `${formatItemPrompt(item)}\n\n` +
351
+ `Respond ONLY with the JSON object specified in the system prompt. ` +
352
+ `No markdown fences, no commentary, no preamble — JSON only.`;
353
+
354
+ const stdout = await runClaudeCLI(systemPrompt, userPrompt);
355
+ if (!stdout || !stdout.trim()) {
356
+ throw new Error("claude CLI returned empty stdout");
357
+ }
358
+ return parseClassification(stdout);
193
359
  }
194
360
 
195
361
  // ── Fallback 1: OpenAI gpt-4o-mini ─────────────────────────────────────────
196
362
 
197
363
  async function classifyWithOpenAI(item) {
364
+ const systemPrompt = buildSystemPrompt();
198
365
  const client = new OpenAI();
199
366
  const response = await client.chat.completions.create({
200
367
  model: OPENAI_MODEL,
201
368
  max_tokens: 256,
202
369
  messages: [
203
- { role: "system", content: SYSTEM_PROMPT },
370
+ { role: "system", content: systemPrompt },
204
371
  { role: "user", content: formatItemPrompt(item) },
205
372
  ],
206
373
  });
@@ -211,42 +378,89 @@ async function classifyWithOpenAI(item) {
211
378
  // ── Fallback 2: Rule-based classification ───────────────────────────────────
212
379
 
213
380
  /**
214
- * Rule-based check for whether a message is directed at Sophie.
381
+ * Check if the message explicitly @-mentions a DIFFERENT known AI agent.
382
+ * If it does AND does NOT also mention the current agent, the message
383
+ * should be skipped — the sender chose a specific agent.
384
+ *
385
+ * Added in ib-20260418-cross-agent-routing to fix the recurring issue
386
+ * where Lucas's agent intercepted messages tagged for Sophie in #dev-tooling.
387
+ */
388
+ function mentionsOtherAgent(item) {
389
+ loadAgentRegistry();
390
+ if (_peerAgents.length === 0) return false;
391
+
392
+ const content = (item.content || "").toLowerCase();
393
+ const myName = (_agentIdentity.name || "").toLowerCase();
394
+ const mySlackId = (_agentIdentity.slackId || "").toLowerCase();
395
+
396
+ // Check if the message mentions the CURRENT agent (by name or Slack ID)
397
+ const mentionsMe = (myName && content.includes(myName))
398
+ || (mySlackId && content.includes(mySlackId.toLowerCase()));
399
+
400
+ // Check if the message mentions any PEER agent
401
+ const mentionedPeer = _peerAgents.find(peer => {
402
+ const peerName = (peer.name || "").toLowerCase();
403
+ const peerSlackId = (peer.slackId || "").toLowerCase();
404
+ return (peerName && content.includes(peerName))
405
+ || (peerSlackId && content.includes(peerSlackId.toLowerCase()));
406
+ });
407
+
408
+ // If a peer is mentioned but I am NOT mentioned, this is directed at them
409
+ if (mentionedPeer && !mentionsMe) {
410
+ return true;
411
+ }
412
+
413
+ return false;
414
+ }
415
+
416
+ /**
417
+ * Rule-based check for whether a message is directed at the current agent.
215
418
  * Used by both the rule-based classifier fallback and as a pre-check
216
419
  * that can override the LLM's assessment in clear-cut cases.
420
+ *
421
+ * Backward-compatible export name: isDirectedAtSophie (used by sophie-daemon.mjs).
217
422
  */
218
423
  function isDirectedAtSophie(item) {
219
- // DMs are always directed at Sophie
424
+ loadAgentRegistry();
425
+
426
+ // ── Cross-agent gate (ib-20260418-cross-agent-routing) ──────────────────
427
+ // If the message explicitly @-mentions a different known agent and does
428
+ // NOT mention the current agent, skip it — the sender chose someone else.
429
+ if (mentionsOtherAgent(item)) return false;
430
+
431
+ // DMs are always directed at the current agent
220
432
  if (item.is_dm) return true;
221
433
  if ((item.channel || "").startsWith("dm/")) return true;
222
434
 
223
- // CEO DMs are always directed at Sophie, but in channels/group chats
224
- // the CEO may be talking to other people — don't auto-insert Sophie.
225
- // CEO channel messages only count as directed if they mention Sophie.
435
+ // CEO DMs are always directed at the current agent
226
436
  if (item.sender_privilege === "ceo" && item.is_dm) return true;
227
437
 
228
438
  // Explicit @mention or name mention in the message content
229
439
  const content = (item.content || "").toLowerCase();
230
- if (content.includes("sophie") || content.includes("<@u09") || content.includes("<@u0a")) return true; // Sophie user ID prefixes
440
+ const myName = (_agentIdentity.name || "sophie").toLowerCase();
441
+ const mySlackId = (_agentIdentity.slackId || "").toLowerCase();
442
+
443
+ if (content.includes(myName)) return true;
444
+ if (mySlackId && content.includes(mySlackId.toLowerCase())) return true;
231
445
 
232
- // Gmail/calendar messages are directed at Sophie (they're in her inbox)
446
+ // Gmail/calendar messages are directed at the current agent (they're in its inbox)
233
447
  if (item.service === "gmail" || item.service === "calendar") return true;
234
448
 
235
- // Sophie being in a thread is NOT enough on its own — in group chats,
449
+ // Agent being in a thread is NOT enough on its own — in group chats,
236
450
  // other people may be talking to each other. Only treat it as directed
237
451
  // if the message also contains a question, request keyword, or is a
238
- // direct follow-up to Sophie's last message in the thread.
239
- if (item.sophie_in_thread || (item.thread_context && /^Sophie:/m.test(item.thread_context))) {
240
- // Check if Sophie was the last speaker in the thread (sender is replying to her)
452
+ // direct follow-up to the agent's last message in the thread.
453
+ const agentNameRegex = new RegExp(`^${myName}:`, "im");
454
+ if (item.sophie_in_thread || (item.thread_context && agentNameRegex.test(item.thread_context))) {
455
+ // Check if the agent was the last speaker in the thread
241
456
  if (item.thread_context) {
242
457
  const lines = item.thread_context.trim().split("\n").filter(Boolean);
243
458
  const lastLine = lines[lines.length - 1] || "";
244
- if (/^Sophie:/i.test(lastLine)) return true;
459
+ if (agentNameRegex.test(lastLine)) return true;
245
460
  }
246
- // Check for question marks or request-like language directed at Sophie
461
+ // Check for question marks or request-like language
247
462
  if (/\?/.test(content)) return true;
248
463
  if (/(?:can you|could you|please|would you|do you|let me know|update|status|any update)/i.test(content)) return true;
249
- // Otherwise, don't assume the message is for Sophie just because she's in the thread
250
464
  }
251
465
 
252
466
  return false;
@@ -323,7 +537,7 @@ function classifyWithRules(item) {
323
537
  }
324
538
 
325
539
  // Export for use in daemon's pre-classification gate
326
- export { isDirectedAtSophie };
540
+ export { isDirectedAtSophie, mentionsOtherAgent, loadAgentRegistry };
327
541
 
328
542
  // ── Main export ─────────────────────────────────────────────────────────────
329
543
 
@@ -5,7 +5,8 @@
5
5
  import { spawn } from "child_process";
6
6
  import { appendFileSync, mkdirSync, writeFileSync, readFileSync, renameSync } from "fs";
7
7
  import { join } from "path";
8
- import { releaseLock, releaseThreadLock, releaseRequestClaim } from "./session-lock.mjs";
8
+ import { releaseLock, releaseThreadLock, releaseRequestClaim, claimItem, releaseItemClaim } from "./session-lock.mjs";
9
+ import { recordSession } from "./health.mjs";
9
10
 
10
11
  const SOPHIE_AI_DIR = join(new URL(".", import.meta.url).pathname, "../..");
11
12
  const CLAUDE_BIN = process.env.CLAUDE_BIN || "/Users/sophie/.local/bin/claude";
@@ -27,6 +28,12 @@ const priorityQueue = []; // critical/high items
27
28
  const normalQueue = []; // normal items
28
29
  let sessionCounter = 0;
29
30
 
31
+ // Tracks sessions whose proc.on("error") handler has already fired.
32
+ // Prevents double-counting + double-cleanup when a spawn failure (ENOENT,
33
+ // EACCES, ETIMEDOUT) triggers both "error" and a trailing "close" event.
34
+ // See ib-20260416-daemon-etimedout-failed-event + cycle 135 memo.
35
+ const spawnErrorHandled = new Set();
36
+
30
37
  // Backlog dedup: track which items have active sessions to prevent retry storms
31
38
  const activeBacklogKeys = new Set(); // backlog item key -> true (while session is running)
32
39
  const backlogRetryCount = new Map(); // backlog item key -> number of times dispatched
@@ -196,6 +203,26 @@ export function dispatch(prompt, item, classResult, source = "inbox") {
196
203
  }
197
204
  }
198
205
 
206
+ // Item-claim acquisition: file-based claim visible across daemon restarts
207
+ // and concurrent launchd triggers. Complements the in-memory activeBacklogKeys.
208
+ // (ib-20260407-001b: concurrent session coordination)
209
+ if (source === "backlog" && item.id) {
210
+ const sessionId = `s-${Date.now()}-${sessionCounter + 1}`;
211
+ const claim = claimItem(item.id, {
212
+ session_id: sessionId,
213
+ agent_description: classResult.summary || item.title || "",
214
+ ttl_minutes: classResult.model === "opus" ? 120 : 30,
215
+ source: "backlog",
216
+ queue_file: item.source_file || "",
217
+ pid: process.pid, // daemon PID; child PID not yet known
218
+ });
219
+ if (!claim.claimed) {
220
+ console.log(`[dispatcher] Item claim denied for ${item.id}: ${claim.reason} (holder: ${claim.holder || "unknown"})`);
221
+ logSession({ event: "skipped", reason: `item_claim_denied: ${claim.reason}`, summary: classResult.summary, holder: claim.holder });
222
+ return;
223
+ }
224
+ }
225
+
199
226
  // Backlog items respect the reserved slot cap
200
227
  if (source === "backlog") {
201
228
  const backlogCount = countBySource("backlog");
@@ -250,9 +277,13 @@ function spawnSession(entry) {
250
277
  prompt,
251
278
  ];
252
279
 
280
+ // Strip Anthropic API credentials from spawn env so claude CLI falls through
281
+ // to the keychain OAuth (Max subscription) per CEO directive 2026-04-27.
282
+ // A stale ANTHROPIC_API_KEY in the daemon's inherited env will otherwise
283
+ // override the OAuth token and cause "Invalid API key" failures.
253
284
  const proc = spawn(CLAUDE_BIN, args, {
254
285
  cwd: SOPHIE_AI_DIR,
255
- env: { ...process.env },
286
+ env: { ...process.env, ANTHROPIC_API_KEY: "", ANTHROPIC_AUTH_TOKEN: "" },
256
287
  stdio: ["ignore", "pipe", "pipe"],
257
288
  });
258
289
 
@@ -287,8 +318,17 @@ function spawnSession(entry) {
287
318
 
288
319
  proc.on("close", (code) => {
289
320
  clearTimeout(timer);
321
+ // If proc.on("error") already fired for this session (spawn failure path
322
+ // — ENOENT, EACCES, ETIMEDOUT), cleanup + metric + lock release already
323
+ // happened. Skip to avoid double-count and double-release.
324
+ if (spawnErrorHandled.has(sessionId)) {
325
+ spawnErrorHandled.delete(sessionId);
326
+ drainQueue();
327
+ return;
328
+ }
290
329
  activeSessions.delete(sessionId);
291
330
  removeActiveSession(sessionId);
331
+ recordSession(true, code === 0);
292
332
  const duration = ((Date.now() - startTime) / 1000).toFixed(1);
293
333
 
294
334
  // Release item lock — MUST use same key order as acquireLock in daemon
@@ -312,12 +352,15 @@ function spawnSession(entry) {
312
352
  });
313
353
  }
314
354
 
315
- // Release backlog tracking
355
+ // Release backlog tracking + item claim
316
356
  if (source === "backlog") {
317
357
  const key = backlogKey(item);
318
358
  activeBacklogKeys.delete(key);
319
359
  const retries = backlogRetryCount.get(key) || 0;
320
360
 
361
+ // Release file-based item claim (ib-20260407-001b)
362
+ if (item.id) releaseItemClaim(item.id);
363
+
321
364
  // If session timed out (143=SIGTERM) and hit retry limit, log it
322
365
  if (code === 143 && retries >= MAX_BACKLOG_RETRIES) {
323
366
  console.warn(`[dispatcher] Backlog item "${classResult.summary}" exhausted ${MAX_BACKLOG_RETRIES} retries — will not retry`);
@@ -347,9 +390,70 @@ function spawnSession(entry) {
347
390
 
348
391
  proc.on("error", (err) => {
349
392
  clearTimeout(timer);
393
+ // Mark so the trailing proc.on("close") doesn't double-process.
394
+ spawnErrorHandled.add(sessionId);
350
395
  activeSessions.delete(sessionId);
351
- console.error(`[dispatcher] Session ${sessionId} error: ${err.message}`);
352
- logSession({ event: "error", sessionId, error: err.message });
396
+ removeActiveSession(sessionId);
397
+ recordSession(true, false);
398
+ const duration = ((Date.now() - startTime) / 1000).toFixed(1);
399
+ const errorCode = err.code || "unknown";
400
+
401
+ // Release item lock — mirror of close-handler logic.
402
+ const itemId = item.raw_ref || item.id || item.title;
403
+ if (itemId) releaseLock(itemId);
404
+
405
+ // Release thread lock so new messages in this thread can be processed.
406
+ if (item.thread_id) {
407
+ const channel = item.channel_id || (item.raw_ref ? (item.raw_ref.match(/slack:([^:]+):/) || [])[1] : null) || item.channel;
408
+ if (channel) releaseThreadLock(channel, item.thread_id);
409
+ }
410
+
411
+ // Release request claim + emit explicit claim_released event so
412
+ // reconciliation audits (cycle 124 Agent C pattern) can distinguish
413
+ // genuine in-flight sessions from silent-exit ETIMEDOUT failures
414
+ // without cross-referencing logs/daemon/responses.jsonl.
415
+ let claimReleased = false;
416
+ if (classResult && classResult.summary) {
417
+ releaseRequestClaim({
418
+ recipient: item.channel_id || item.channel || item.sender || "unknown",
419
+ subject: classResult.summary || item.subject || "",
420
+ action_type: classResult.action || "respond",
421
+ });
422
+ claimReleased = true;
423
+ }
424
+
425
+ // Release backlog tracking + item claim.
426
+ if (source === "backlog") {
427
+ const key = backlogKey(item);
428
+ activeBacklogKeys.delete(key);
429
+ // Release file-based item claim (ib-20260407-001b)
430
+ if (item.id) releaseItemClaim(item.id);
431
+ }
432
+
433
+ console.error(`[dispatcher] Session ${sessionId} failed: ${errorCode} (${err.message})`);
434
+
435
+ // Rich "failed" event — see ib-20260416-daemon-etimedout-failed-event.
436
+ logSession({
437
+ event: "failed",
438
+ sessionId,
439
+ error: errorCode,
440
+ error_message: err.message,
441
+ model,
442
+ source,
443
+ priority: classResult?.priority,
444
+ summary: classResult?.summary,
445
+ duration_s: parseFloat(duration),
446
+ active_count: activeSessions.size,
447
+ });
448
+
449
+ if (claimReleased) {
450
+ logSession({
451
+ event: "claim_released",
452
+ sessionId,
453
+ reason: `spawn_failed_${errorCode}`,
454
+ });
455
+ }
456
+
353
457
  drainQueue();
354
458
  });
355
459
  }