myaiforone 1.0.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.
Files changed (315) hide show
  1. package/README.md +113 -0
  2. package/agents/_template/CLAUDE.md +18 -0
  3. package/agents/_template/agent.json +7 -0
  4. package/agents/platform/agentcreator/CLAUDE.md +300 -0
  5. package/agents/platform/appcreator/CLAUDE.md +158 -0
  6. package/agents/platform/gym/CLAUDE.md +486 -0
  7. package/agents/platform/gym/agent.json +40 -0
  8. package/agents/platform/gym/programs/agent-building/program.json +160 -0
  9. package/agents/platform/gym/programs/automations-mastery/program.json +129 -0
  10. package/agents/platform/gym/programs/getting-started/program.json +124 -0
  11. package/agents/platform/gym/programs/mcp-integrations/program.json +116 -0
  12. package/agents/platform/gym/programs/multi-model-strategy/program.json +115 -0
  13. package/agents/platform/gym/programs/prompt-engineering/program.json +136 -0
  14. package/agents/platform/gym/souls/alex.md +12 -0
  15. package/agents/platform/gym/souls/jordan.md +12 -0
  16. package/agents/platform/gym/souls/morgan.md +12 -0
  17. package/agents/platform/gym/souls/riley.md +12 -0
  18. package/agents/platform/gym/souls/sam.md +12 -0
  19. package/agents/platform/hub/CLAUDE.md +372 -0
  20. package/agents/platform/promptcreator/CLAUDE.md +130 -0
  21. package/agents/platform/skillcreator/CLAUDE.md +163 -0
  22. package/bin/cli.js +566 -0
  23. package/config.example.json +310 -0
  24. package/dist/agent-registry.d.ts +32 -0
  25. package/dist/agent-registry.d.ts.map +1 -0
  26. package/dist/agent-registry.js +144 -0
  27. package/dist/agent-registry.js.map +1 -0
  28. package/dist/channels/discord.d.ts +17 -0
  29. package/dist/channels/discord.d.ts.map +1 -0
  30. package/dist/channels/discord.js +114 -0
  31. package/dist/channels/discord.js.map +1 -0
  32. package/dist/channels/imessage.d.ts +23 -0
  33. package/dist/channels/imessage.d.ts.map +1 -0
  34. package/dist/channels/imessage.js +214 -0
  35. package/dist/channels/imessage.js.map +1 -0
  36. package/dist/channels/slack.d.ts +19 -0
  37. package/dist/channels/slack.d.ts.map +1 -0
  38. package/dist/channels/slack.js +167 -0
  39. package/dist/channels/slack.js.map +1 -0
  40. package/dist/channels/telegram.d.ts +19 -0
  41. package/dist/channels/telegram.d.ts.map +1 -0
  42. package/dist/channels/telegram.js +274 -0
  43. package/dist/channels/telegram.js.map +1 -0
  44. package/dist/channels/types.d.ts +44 -0
  45. package/dist/channels/types.d.ts.map +1 -0
  46. package/dist/channels/types.js +18 -0
  47. package/dist/channels/types.js.map +1 -0
  48. package/dist/channels/whatsapp.d.ts +23 -0
  49. package/dist/channels/whatsapp.d.ts.map +1 -0
  50. package/dist/channels/whatsapp.js +189 -0
  51. package/dist/channels/whatsapp.js.map +1 -0
  52. package/dist/config.d.ts +134 -0
  53. package/dist/config.d.ts.map +1 -0
  54. package/dist/config.js +127 -0
  55. package/dist/config.js.map +1 -0
  56. package/dist/cron.d.ts +8 -0
  57. package/dist/cron.d.ts.map +1 -0
  58. package/dist/cron.js +35 -0
  59. package/dist/cron.js.map +1 -0
  60. package/dist/decrypt-keys.d.ts +7 -0
  61. package/dist/decrypt-keys.d.ts.map +1 -0
  62. package/dist/decrypt-keys.js +53 -0
  63. package/dist/decrypt-keys.js.map +1 -0
  64. package/dist/encrypt-keys.d.ts +8 -0
  65. package/dist/encrypt-keys.d.ts.map +1 -0
  66. package/dist/encrypt-keys.js +62 -0
  67. package/dist/encrypt-keys.js.map +1 -0
  68. package/dist/executor.d.ts +31 -0
  69. package/dist/executor.d.ts.map +1 -0
  70. package/dist/executor.js +2009 -0
  71. package/dist/executor.js.map +1 -0
  72. package/dist/gemini-executor.d.ts +27 -0
  73. package/dist/gemini-executor.d.ts.map +1 -0
  74. package/dist/gemini-executor.js +160 -0
  75. package/dist/gemini-executor.js.map +1 -0
  76. package/dist/goals.d.ts +24 -0
  77. package/dist/goals.d.ts.map +1 -0
  78. package/dist/goals.js +189 -0
  79. package/dist/goals.js.map +1 -0
  80. package/dist/gym/activity-digest.d.ts +30 -0
  81. package/dist/gym/activity-digest.d.ts.map +1 -0
  82. package/dist/gym/activity-digest.js +506 -0
  83. package/dist/gym/activity-digest.js.map +1 -0
  84. package/dist/gym/dimension-scorer.d.ts +76 -0
  85. package/dist/gym/dimension-scorer.d.ts.map +1 -0
  86. package/dist/gym/dimension-scorer.js +236 -0
  87. package/dist/gym/dimension-scorer.js.map +1 -0
  88. package/dist/gym/gym-router.d.ts +7 -0
  89. package/dist/gym/gym-router.d.ts.map +1 -0
  90. package/dist/gym/gym-router.js +718 -0
  91. package/dist/gym/gym-router.js.map +1 -0
  92. package/dist/gym/index.d.ts +11 -0
  93. package/dist/gym/index.d.ts.map +1 -0
  94. package/dist/gym/index.js +11 -0
  95. package/dist/gym/index.js.map +1 -0
  96. package/dist/heartbeat.d.ts +21 -0
  97. package/dist/heartbeat.d.ts.map +1 -0
  98. package/dist/heartbeat.js +163 -0
  99. package/dist/heartbeat.js.map +1 -0
  100. package/dist/index.d.ts +2 -0
  101. package/dist/index.d.ts.map +1 -0
  102. package/dist/index.js +254 -0
  103. package/dist/index.js.map +1 -0
  104. package/dist/keystore.d.ts +22 -0
  105. package/dist/keystore.d.ts.map +1 -0
  106. package/dist/keystore.js +178 -0
  107. package/dist/keystore.js.map +1 -0
  108. package/dist/logger.d.ts +9 -0
  109. package/dist/logger.d.ts.map +1 -0
  110. package/dist/logger.js +45 -0
  111. package/dist/logger.js.map +1 -0
  112. package/dist/memory/daily.d.ts +22 -0
  113. package/dist/memory/daily.d.ts.map +1 -0
  114. package/dist/memory/daily.js +82 -0
  115. package/dist/memory/daily.js.map +1 -0
  116. package/dist/memory/embeddings.d.ts +15 -0
  117. package/dist/memory/embeddings.d.ts.map +1 -0
  118. package/dist/memory/embeddings.js +154 -0
  119. package/dist/memory/embeddings.js.map +1 -0
  120. package/dist/memory/index.d.ts +32 -0
  121. package/dist/memory/index.d.ts.map +1 -0
  122. package/dist/memory/index.js +159 -0
  123. package/dist/memory/index.js.map +1 -0
  124. package/dist/memory/search.d.ts +21 -0
  125. package/dist/memory/search.d.ts.map +1 -0
  126. package/dist/memory/search.js +77 -0
  127. package/dist/memory/search.js.map +1 -0
  128. package/dist/memory/store.d.ts +23 -0
  129. package/dist/memory/store.d.ts.map +1 -0
  130. package/dist/memory/store.js +144 -0
  131. package/dist/memory/store.js.map +1 -0
  132. package/dist/ollama-executor.d.ts +17 -0
  133. package/dist/ollama-executor.d.ts.map +1 -0
  134. package/dist/ollama-executor.js +112 -0
  135. package/dist/ollama-executor.js.map +1 -0
  136. package/dist/openai-executor.d.ts +38 -0
  137. package/dist/openai-executor.d.ts.map +1 -0
  138. package/dist/openai-executor.js +197 -0
  139. package/dist/openai-executor.js.map +1 -0
  140. package/dist/router.d.ts +11 -0
  141. package/dist/router.d.ts.map +1 -0
  142. package/dist/router.js +185 -0
  143. package/dist/router.js.map +1 -0
  144. package/dist/test-message.d.ts +2 -0
  145. package/dist/test-message.d.ts.map +1 -0
  146. package/dist/test-message.js +60 -0
  147. package/dist/test-message.js.map +1 -0
  148. package/dist/utils/imsg-db-reader.d.ts +24 -0
  149. package/dist/utils/imsg-db-reader.d.ts.map +1 -0
  150. package/dist/utils/imsg-db-reader.js +92 -0
  151. package/dist/utils/imsg-db-reader.js.map +1 -0
  152. package/dist/utils/imsg-rpc.d.ts +25 -0
  153. package/dist/utils/imsg-rpc.d.ts.map +1 -0
  154. package/dist/utils/imsg-rpc.js +149 -0
  155. package/dist/utils/imsg-rpc.js.map +1 -0
  156. package/dist/utils/message-formatter.d.ts +3 -0
  157. package/dist/utils/message-formatter.d.ts.map +1 -0
  158. package/dist/utils/message-formatter.js +69 -0
  159. package/dist/utils/message-formatter.js.map +1 -0
  160. package/dist/web-ui.d.ts +12 -0
  161. package/dist/web-ui.d.ts.map +1 -0
  162. package/dist/web-ui.js +5784 -0
  163. package/dist/web-ui.js.map +1 -0
  164. package/dist/whatsapp-chats.d.ts +2 -0
  165. package/dist/whatsapp-chats.d.ts.map +1 -0
  166. package/dist/whatsapp-chats.js +76 -0
  167. package/dist/whatsapp-chats.js.map +1 -0
  168. package/dist/whatsapp-login.d.ts +2 -0
  169. package/dist/whatsapp-login.d.ts.map +1 -0
  170. package/dist/whatsapp-login.js +90 -0
  171. package/dist/whatsapp-login.js.map +1 -0
  172. package/dist/wiki-sync.d.ts +21 -0
  173. package/dist/wiki-sync.d.ts.map +1 -0
  174. package/dist/wiki-sync.js +147 -0
  175. package/dist/wiki-sync.js.map +1 -0
  176. package/docs/AddNewAgentGuide.md +100 -0
  177. package/docs/AddNewMcpGuide.md +72 -0
  178. package/docs/Architecture.md +795 -0
  179. package/docs/CLAUDE-AI-SETUP.md +166 -0
  180. package/docs/Setup.md +297 -0
  181. package/docs/ai-gym-architecture.md +1040 -0
  182. package/docs/ai-gym-build-plan.md +343 -0
  183. package/docs/ai-gym-onboarding.md +122 -0
  184. package/docs/appcreator_plan.md +348 -0
  185. package/docs/platform-mcp-audit.md +320 -0
  186. package/docs/server-deployment-plan.md +503 -0
  187. package/docs/superpowers/plans/2026-03-25-marketplace.md +1281 -0
  188. package/docs/superpowers/specs/2026-03-25-marketplace-design.md +287 -0
  189. package/docs/user-guide.md +2016 -0
  190. package/mcp-catalog.json +628 -0
  191. package/package.json +63 -0
  192. package/public/MyAIforOne-logomark-512.svg +16 -0
  193. package/public/MyAIforOne-logomark-transparent.svg +15 -0
  194. package/public/activity.html +314 -0
  195. package/public/admin.html +1674 -0
  196. package/public/agent-dashboard.html +670 -0
  197. package/public/api-docs.html +1106 -0
  198. package/public/automations.html +722 -0
  199. package/public/canvas.css +223 -0
  200. package/public/canvas.js +588 -0
  201. package/public/changelog.html +231 -0
  202. package/public/gym.html +2766 -0
  203. package/public/home.html +1930 -0
  204. package/public/index.html +2809 -0
  205. package/public/lab.html +1643 -0
  206. package/public/library.html +1442 -0
  207. package/public/marketplace.html +1101 -0
  208. package/public/mcp-docs.html +441 -0
  209. package/public/mini.html +390 -0
  210. package/public/monitor.html +584 -0
  211. package/public/org.html +4304 -0
  212. package/public/projects.html +734 -0
  213. package/public/settings.html +645 -0
  214. package/public/tasks.html +932 -0
  215. package/public/trainers/alex.svg +12 -0
  216. package/public/trainers/jordan.svg +12 -0
  217. package/public/trainers/morgan.svg +12 -0
  218. package/public/trainers/riley.svg +12 -0
  219. package/public/trainers/sam.svg +12 -0
  220. package/public/user-guide.html +218 -0
  221. package/registry/agents.json +3 -0
  222. package/registry/apps.json +20 -0
  223. package/registry/installed-drafts.json +3 -0
  224. package/registry/mcps.json +1084 -0
  225. package/registry/prompts/personal/mcp-test-prompt.md +6 -0
  226. package/registry/prompts/personal/memory-recall.md +6 -0
  227. package/registry/prompts/platform/brainstorm.md +15 -0
  228. package/registry/prompts/platform/code-review.md +16 -0
  229. package/registry/prompts/platform/explain.md +16 -0
  230. package/registry/prompts.json +58 -0
  231. package/registry/skills/external/brainstorming.md +5 -0
  232. package/registry/skills/external/code-review.md +40 -0
  233. package/registry/skills/external/frontend-patterns.md +642 -0
  234. package/registry/skills/external/frontend-slides.md +184 -0
  235. package/registry/skills/external/systematic-debugging.md +5 -0
  236. package/registry/skills/external/tdd.md +328 -0
  237. package/registry/skills/external/verification-before-completion.md +5 -0
  238. package/registry/skills/external/writing-plans.md +5 -0
  239. package/registry/skills/platform/ai41_app_build.md +930 -0
  240. package/registry/skills/platform/ai41_app_deploy.md +168 -0
  241. package/registry/skills/platform/ai41_app_orchestrator.md +239 -0
  242. package/registry/skills/platform/ai41_app_patterns.md +359 -0
  243. package/registry/skills/platform/ai41_app_register.md +85 -0
  244. package/registry/skills/platform/ai41_app_scaffold.md +421 -0
  245. package/registry/skills/platform/ai41_app_verify.md +107 -0
  246. package/registry/skills/platform/opProjectCreate.md +239 -0
  247. package/registry/skills/platform/op_devbrowser.md +136 -0
  248. package/registry/skills/platform/sop_brandguidelines.md +103 -0
  249. package/registry/skills/platform/sop_docx.md +117 -0
  250. package/registry/skills/platform/sop_frontenddesign.md +44 -0
  251. package/registry/skills/platform/sop_frontenddesign_v2.md +659 -0
  252. package/registry/skills/platform/sop_mcpbuilder.md +133 -0
  253. package/registry/skills/platform/sop_pdf.md +172 -0
  254. package/registry/skills/platform/sop_pptx.md +133 -0
  255. package/registry/skills/platform/sop_skillcreator.md +104 -0
  256. package/registry/skills/platform/sop_themefactory.md +128 -0
  257. package/registry/skills/platform/sop_webapptesting.md +75 -0
  258. package/registry/skills/platform/sop_webartifactsbuilder.md +97 -0
  259. package/registry/skills/platform/sop_xlsx.md +134 -0
  260. package/registry/skills.json +1055 -0
  261. package/scripts/discover-chats.sh +11 -0
  262. package/scripts/install-service-windows.ps1 +87 -0
  263. package/scripts/install-service.sh +52 -0
  264. package/scripts/seed-registry.ts +195 -0
  265. package/scripts/test-send.sh +5 -0
  266. package/scripts/tray-indicator.ps1 +35 -0
  267. package/scripts/uninstall-service-windows.ps1 +23 -0
  268. package/scripts/uninstall-service.sh +15 -0
  269. package/scripts/xbar-myagent.5s.sh +32 -0
  270. package/server/mcp-server/dist/index.d.ts +11 -0
  271. package/server/mcp-server/dist/index.js +1332 -0
  272. package/server/mcp-server/dist/lib/api-client.d.ts +165 -0
  273. package/server/mcp-server/dist/lib/api-client.js +241 -0
  274. package/server/mcp-server/index.ts +1545 -0
  275. package/server/mcp-server/lib/api-client.ts +366 -0
  276. package/server/mcp-server/tsconfig.json +14 -0
  277. package/src/agent-registry.ts +180 -0
  278. package/src/channels/discord.ts +129 -0
  279. package/src/channels/imessage.ts +261 -0
  280. package/src/channels/slack.ts +208 -0
  281. package/src/channels/telegram.ts +307 -0
  282. package/src/channels/types.ts +62 -0
  283. package/src/channels/whatsapp.ts +227 -0
  284. package/src/config.ts +281 -0
  285. package/src/cron.ts +43 -0
  286. package/src/decrypt-keys.ts +60 -0
  287. package/src/encrypt-keys.ts +70 -0
  288. package/src/executor.ts +2190 -0
  289. package/src/gemini-executor.ts +212 -0
  290. package/src/goals.ts +240 -0
  291. package/src/gym/activity-digest.ts +546 -0
  292. package/src/gym/dimension-scorer.ts +297 -0
  293. package/src/gym/gym-router.ts +801 -0
  294. package/src/gym/index.ts +19 -0
  295. package/src/heartbeat.ts +220 -0
  296. package/src/index.ts +275 -0
  297. package/src/keystore.ts +190 -0
  298. package/src/logger.ts +51 -0
  299. package/src/memory/daily.ts +101 -0
  300. package/src/memory/embeddings.ts +185 -0
  301. package/src/memory/index.ts +218 -0
  302. package/src/memory/search.ts +124 -0
  303. package/src/memory/store.ts +189 -0
  304. package/src/ollama-executor.ts +126 -0
  305. package/src/openai-executor.ts +259 -0
  306. package/src/router.ts +230 -0
  307. package/src/test-message.ts +72 -0
  308. package/src/utils/imsg-db-reader.ts +109 -0
  309. package/src/utils/imsg-rpc.ts +178 -0
  310. package/src/utils/message-formatter.ts +90 -0
  311. package/src/web-ui.ts +5778 -0
  312. package/src/whatsapp-chats.ts +91 -0
  313. package/src/whatsapp-login.ts +110 -0
  314. package/src/wiki-sync.ts +199 -0
  315. package/tsconfig.json +19 -0
@@ -0,0 +1,2190 @@
1
+ import { spawn, execSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, mkdirSync, readdirSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { resolve, join } from "node:path";
6
+ import type { McpServerConfig, McpServerHttp } from "./config.js";
7
+ import { getPersonalAgentsDir } from "./config.js";
8
+ import type { InboundMessage } from "./channels/types.js";
9
+ import type { ResolvedRoute } from "./router.js";
10
+ import { formatMessage } from "./utils/message-formatter.js";
11
+ import { createMemoryManager, type MemoryManager } from "./memory/index.js";
12
+ import { loadMcpKeysWithDecryption } from "./keystore.js";
13
+ import { buildAgentRegistry, buildGroupAgentPrompt } from "./agent-registry.js";
14
+ import { log } from "./logger.js";
15
+
16
+ // Module-level config reference for group agent registry
17
+ import type { AppConfig } from "./config.js";
18
+ let _appConfig: AppConfig | null = null;
19
+ export function setAppConfig(config: AppConfig): void { _appConfig = config; }
20
+
21
+ // Cache memory managers per agent to avoid re-creating on every message
22
+ const memoryManagers = new Map<string, MemoryManager>();
23
+
24
+ async function getMemoryManager(agentId: string, memoryDir: string): Promise<MemoryManager> {
25
+ if (memoryManagers.has(agentId)) return memoryManagers.get(agentId)!;
26
+ const mgr = await createMemoryManager(memoryDir);
27
+ memoryManagers.set(agentId, mgr);
28
+ return mgr;
29
+ }
30
+
31
+ // ─── Types ───────────────────────────────────────────────────────────
32
+
33
+ interface ContentBlock {
34
+ type: "text" | "image";
35
+ text?: string;
36
+ source?: {
37
+ type: "base64";
38
+ media_type: string;
39
+ data: string;
40
+ };
41
+ }
42
+
43
+ interface SessionState {
44
+ sessionId: string;
45
+ createdAt: string;
46
+ messageCount: number;
47
+ }
48
+
49
+ interface ClaudeJsonResult {
50
+ type: string;
51
+ result: string;
52
+ session_id: string;
53
+ total_cost_usd: number;
54
+ duration_ms: number;
55
+ }
56
+
57
+ // ─── Intercepted commands ────────────────────────────────────────────
58
+
59
+ const RESET_PATTERN = /^\s*\/opreset\b/i;
60
+ const COMPACT_PATTERN = /^\s*\/opcompact\b/i;
61
+ const RELOGIN_PATTERN = /^\s*\/relogin(?:\s+(\S+))?\s*$/i;
62
+ const PARALLEL_PATTERN = /^\s*\/parallel\s*\n/i;
63
+ const TASK_PATTERN = /^\s*\/task\b/i;
64
+ const MODEL_PATTERN = /^\s*\/model(?:\s+(\S+))?\s*$/i;
65
+ const COST_PATTERN = /^\s*\/cost\b/i;
66
+
67
+ // ─── Task helpers ─────────────────────────────────────────────────
68
+
69
+ interface TaskHistoryEntry {
70
+ ts: string;
71
+ action: string;
72
+ by: string;
73
+ from?: string;
74
+ to?: string;
75
+ note?: string;
76
+ }
77
+
78
+ interface TaskData {
79
+ agentId: string;
80
+ projects: Array<{ id: string; name: string; color: string }>;
81
+ tasks: Array<{
82
+ id: string; title: string; description: string; project: string;
83
+ priority: string; status: string; owner: string; assignedBy: string;
84
+ assignmentType: string; dueDate: string | null; context: string;
85
+ result: string; createdAt: string; updatedAt: string;
86
+ source?: string;
87
+ assignedTo?: string;
88
+ history?: TaskHistoryEntry[];
89
+ }>;
90
+ }
91
+
92
+ function loadTasksFromAgent(agentHome: string, agentId: string): TaskData {
93
+ const p = join(agentHome, "tasks.json");
94
+ if (existsSync(p)) {
95
+ try { return JSON.parse(readFileSync(p, "utf-8")); } catch { /* ignore */ }
96
+ }
97
+ return { agentId, projects: [{ id: "general", name: "General", color: "#6b7280" }], tasks: [] };
98
+ }
99
+
100
+ function saveTasksToAgent(agentHome: string, data: TaskData): void {
101
+ const p = join(agentHome, "tasks.json");
102
+ mkdirSync(agentHome, { recursive: true });
103
+ writeFileSync(p, JSON.stringify(data, null, 2));
104
+ }
105
+
106
+ function buildTaskContextBlock(agentHome: string, agentId: string): string {
107
+ const data = loadTasksFromAgent(agentHome, agentId);
108
+ const active = (data.tasks || []).filter(t => ["proposed", "approved", "in_progress", "review"].includes(t.status));
109
+ if (active.length === 0) return "";
110
+
111
+ const lines = ["\n[Active Tasks]"];
112
+ for (const t of active) {
113
+ let line = `- ${t.id}: ${t.title} (${t.status}, ${t.priority} priority`;
114
+ if (t.dueDate) line += `, due ${t.dueDate}`;
115
+ if (t.source) line += `, source: ${t.source}`;
116
+ if (t.assignedTo && t.assignedTo !== agentId) line += `, assigned to: ${t.assignedTo}`;
117
+ line += ")";
118
+ lines.push(line);
119
+ }
120
+ lines.push("[/Active Tasks]\n");
121
+ return lines.join("\n");
122
+ }
123
+
124
+ function handleTaskCommand(
125
+ text: string,
126
+ agentId: string,
127
+ agentConfig: any,
128
+ allAgents: Record<string, any>,
129
+ ): string | null {
130
+ if (!TASK_PATTERN.test(text)) return null;
131
+
132
+ const home = homedir();
133
+ const resolveTilde = (p: string) => p.startsWith("~") ? p.replace("~", home) : p;
134
+ const agentHome = agentConfig.agentHome ? resolveTilde(agentConfig.agentHome) : resolve(agentConfig.memoryDir, "..");
135
+
136
+ const parts = text.trim().split(/\s+/);
137
+ const subCommand = parts[1]?.toLowerCase();
138
+
139
+ // /task list
140
+ if (subCommand === "list") {
141
+ const data = loadTasksFromAgent(agentHome, agentId);
142
+ if (data.tasks.length === 0) return "No tasks found.";
143
+
144
+ const byStatus: Record<string, string[]> = {};
145
+ for (const t of data.tasks) {
146
+ if (!byStatus[t.status]) byStatus[t.status] = [];
147
+ byStatus[t.status].push(` - [${t.priority}] ${t.title} (${t.id})${t.dueDate ? ` due ${t.dueDate}` : ""}`);
148
+ }
149
+
150
+ const lines = ["**Tasks:**"];
151
+ for (const [status, items] of Object.entries(byStatus)) {
152
+ lines.push(`\n**${status.toUpperCase()}:**`);
153
+ lines.push(...items);
154
+ }
155
+ return lines.join("\n");
156
+ }
157
+
158
+ // /task add @target title --priority high --project general
159
+ if (subCommand === "add") {
160
+ const rest = parts.slice(2);
161
+ if (rest.length === 0) return "Usage: /task add @target Task title --priority high --project general";
162
+
163
+ let targetAlias = rest[0];
164
+ let titleParts: string[] = [];
165
+ let priority = "medium";
166
+ let project = "general";
167
+
168
+ // Parse flags
169
+ for (let i = 1; i < rest.length; i++) {
170
+ if (rest[i] === "--priority" && rest[i + 1]) {
171
+ priority = rest[++i];
172
+ } else if (rest[i] === "--project" && rest[i + 1]) {
173
+ project = rest[++i];
174
+ } else {
175
+ titleParts.push(rest[i]);
176
+ }
177
+ }
178
+
179
+ const title = titleParts.join(" ");
180
+ if (!title) return "Missing task title.";
181
+
182
+ // Resolve target agent
183
+ let targetId: string | null = null;
184
+ let targetConfig: any = null;
185
+ const normalTarget = targetAlias.startsWith("@") ? targetAlias : `@${targetAlias}`;
186
+ for (const [id, ag] of Object.entries(allAgents)) {
187
+ const aliases = ag.mentionAliases || [];
188
+ if (aliases.includes(normalTarget) || id === targetAlias) {
189
+ targetId = id;
190
+ targetConfig = ag;
191
+ break;
192
+ }
193
+ }
194
+
195
+ if (!targetId || !targetConfig) return `Agent "${targetAlias}" not found.`;
196
+
197
+ const targetHome = targetConfig.agentHome ? resolveTilde(targetConfig.agentHome) : resolve(targetConfig.memoryDir, "..");
198
+ const data = loadTasksFromAgent(targetHome, targetId);
199
+
200
+ // Determine hierarchy
201
+ const assignerAlias = agentConfig.mentionAliases?.[0] || agentId;
202
+ let assignmentType = "proposal";
203
+ let status = "proposed";
204
+
205
+ // Check if assigner is a superior
206
+ if (targetConfig.org) {
207
+ for (const orgEntry of targetConfig.org) {
208
+ if (orgEntry.reportsTo) {
209
+ const reportsToNorm = orgEntry.reportsTo.startsWith("@") ? orgEntry.reportsTo : `@${orgEntry.reportsTo}`;
210
+ const assignerAliases = agentConfig.mentionAliases || [];
211
+ if (assignerAliases.includes(reportsToNorm) || agentId === orgEntry.reportsTo) {
212
+ assignmentType = "direct";
213
+ status = "approved";
214
+ break;
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ const now = new Date().toISOString();
221
+ const task = {
222
+ id: `${targetId}_${Date.now()}`,
223
+ title,
224
+ description: "",
225
+ project,
226
+ priority,
227
+ status,
228
+ owner: targetConfig.mentionAliases?.[0] || targetId,
229
+ assignedBy: assignerAlias,
230
+ assignmentType,
231
+ dueDate: null,
232
+ context: "",
233
+ result: "",
234
+ createdAt: now,
235
+ updatedAt: now,
236
+ };
237
+
238
+ data.tasks.push(task);
239
+ saveTasksToAgent(targetHome, data);
240
+ return `Task created for ${targetAlias}: "${title}" [${status}/${assignmentType}, ${priority} priority]`;
241
+ }
242
+
243
+ // /task done taskId "result note"
244
+ if (subCommand === "done") {
245
+ const taskId = parts[2];
246
+ if (!taskId) return "Usage: /task done <taskId> \"result note\"";
247
+
248
+ const resultNote = text.replace(/^\s*\/task\s+done\s+\S+\s*/, "").replace(/^["']|["']$/g, "").trim();
249
+
250
+ const data = loadTasksFromAgent(agentHome, agentId);
251
+ const task = data.tasks.find(t => t.id === taskId);
252
+ if (!task) return `Task "${taskId}" not found.`;
253
+
254
+ task.status = "done";
255
+ task.result = resultNote || task.result;
256
+ task.updatedAt = new Date().toISOString();
257
+ saveTasksToAgent(agentHome, data);
258
+ return `Task ${taskId} marked as done.${resultNote ? ` Result: ${resultNote}` : ""}`;
259
+ }
260
+
261
+ return "Unknown /task subcommand. Use: /task list, /task add @target title, /task done taskId \"note\"";
262
+ }
263
+
264
+ /**
265
+ * Check if the message is an intercepted command.
266
+ * Returns a response string if handled, or null to continue normal execution.
267
+ */
268
+ function handleInterceptedCommand(
269
+ text: string,
270
+ agentId: string,
271
+ memoryDir: string,
272
+ senderId?: string,
273
+ ): string | null {
274
+ const sessionPath = join(memoryDir, sessionFileName(senderId));
275
+
276
+ // ── /opreset ──
277
+ if (RESET_PATTERN.test(text)) {
278
+ if (existsSync(sessionPath)) {
279
+ try {
280
+ const state = JSON.parse(readFileSync(sessionPath, "utf-8")) as SessionState;
281
+ unlinkSync(sessionPath);
282
+ log.info(`Session reset for ${agentId} (was ${state.sessionId}, ${state.messageCount} messages)`);
283
+ return `Session reset. Had ${state.messageCount} messages. Next message starts a fresh conversation.\n\nTip: Use /opcompact before /opreset to save important context.`;
284
+ } catch {
285
+ unlinkSync(sessionPath);
286
+ return `Session reset. Next message starts fresh.`;
287
+ }
288
+ }
289
+ return `No active session to reset. Next message will start a new one.`;
290
+ }
291
+
292
+ // ── /model ──
293
+ const modelMatch = MODEL_PATTERN.exec(text);
294
+ if (modelMatch) {
295
+ const arg = modelMatch[1]?.toLowerCase();
296
+ if (!arg || arg === "show" || arg === "current") {
297
+ const current = loadModelOverride(memoryDir);
298
+ return current
299
+ ? `Current model override: **${current}**\n\nUse \`/model default\` to reset.`
300
+ : `No model override set. Using agent default.\n\nOptions: \`/model sonnet\`, \`/model opus\`, \`/model haiku\`, or any full model ID.`;
301
+ }
302
+ if (arg === "default" || arg === "reset") {
303
+ clearModelOverride(memoryDir);
304
+ return `Model override cleared. Using agent default.`;
305
+ }
306
+ const resolved = MODEL_ALIASES[arg] || arg;
307
+ saveModelOverride(memoryDir, resolved);
308
+ return `Model set to **${resolved}**.\n\nThis applies to all future messages until you use \`/model default\`.`;
309
+ }
310
+
311
+ // ── /cost ──
312
+ if (COST_PATTERN.test(text)) {
313
+ const logPath = join(memoryDir, "conversation_log.jsonl");
314
+ if (!existsSync(logPath)) return "No conversation history yet — no cost data.";
315
+ try {
316
+ const entries = readFileSync(logPath, "utf-8").trim().split("\n").filter(Boolean)
317
+ .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
318
+ const sum = (arr: any[]) => arr.reduce((s, e) => s + (e.cost || 0), 0);
319
+ const today = new Date().toISOString().slice(0, 10);
320
+ const weekAgo = new Date(Date.now() - 7 * 86400_000).toISOString();
321
+ const todayEntries = entries.filter((e: any) => e.ts?.startsWith(today));
322
+ const weekEntries = entries.filter((e: any) => e.ts >= weekAgo);
323
+ const totalCost = sum(entries);
324
+ const todayCost = sum(todayEntries);
325
+ const weekCost = sum(weekEntries);
326
+ if (totalCost === 0) return `No cost data recorded yet.\n\n(Cost tracking applies to new messages going forward.)`;
327
+ return `**Cost Summary**\n\nToday: $${todayCost.toFixed(4)}\nLast 7 days: $${weekCost.toFixed(4)}\nAll time: $${totalCost.toFixed(4)}\n\nTotal messages: ${entries.length}`;
328
+ } catch {
329
+ return "Could not read cost data.";
330
+ }
331
+ }
332
+
333
+ return null;
334
+ }
335
+
336
+ // ─── Model override helpers ──────────────────────────────────────────
337
+
338
+ const MODEL_ALIASES: Record<string, string> = {
339
+ opus: "claude-opus-4-6",
340
+ sonnet: "claude-sonnet-4-6",
341
+ haiku: "claude-haiku-4-5-20251001",
342
+ "opus-4": "claude-opus-4-6",
343
+ "sonnet-4": "claude-sonnet-4-6",
344
+ };
345
+
346
+ function loadModelOverride(memoryDir: string): string | null {
347
+ const p = join(memoryDir, "model-override.json");
348
+ if (!existsSync(p)) return null;
349
+ try { return (JSON.parse(readFileSync(p, "utf-8")) as any).model || null; } catch { return null; }
350
+ }
351
+
352
+ function saveModelOverride(memoryDir: string, model: string): void {
353
+ writeFileSync(join(memoryDir, "model-override.json"), JSON.stringify({ model }));
354
+ }
355
+
356
+ function clearModelOverride(memoryDir: string): void {
357
+ const p = join(memoryDir, "model-override.json");
358
+ if (existsSync(p)) try { unlinkSync(p); } catch { /* ignore */ }
359
+ }
360
+
361
+ // ─── Re-login handler ───────────────────────────────────────────────
362
+
363
+ export function handleRelogin(accountName: string, configDir?: string): string {
364
+ try {
365
+ const env = { ...process.env };
366
+ delete env.CLAUDECODE;
367
+ delete env.CLAUDE_CODE_ENTRYPOINT;
368
+ if (configDir) env.CLAUDE_CONFIG_DIR = configDir;
369
+
370
+ // Try `claude auth status` first to check current state
371
+ let statusOutput = "";
372
+ try {
373
+ statusOutput = execSync("claude auth status 2>&1", { env, timeout: 10_000 }).toString().trim();
374
+ } catch {
375
+ // auth status may fail if not logged in — that's fine
376
+ }
377
+
378
+ // Try to get a login URL via `claude auth login`
379
+ let loginOutput = "";
380
+ try {
381
+ loginOutput = execSync("claude auth login 2>&1", { env, timeout: 15_000 }).toString().trim();
382
+ } catch (err: any) {
383
+ loginOutput = err?.stdout?.toString() || err?.stderr?.toString() || String(err);
384
+ }
385
+
386
+ // Look for a URL in the output
387
+ const urlMatch = loginOutput.match(/https?:\/\/\S+/);
388
+ if (urlMatch) {
389
+ log.info(`Re-login URL generated for account "${accountName}": ${urlMatch[0]}`);
390
+ return `Re-login needed for account "${accountName}".\n\nOpen this URL in your browser:\n${urlMatch[0]}`;
391
+ }
392
+
393
+ // If already logged in or no URL found
394
+ if (statusOutput.toLowerCase().includes("logged in") || statusOutput.toLowerCase().includes("authenticated")) {
395
+ return `Account "${accountName}" appears to be already logged in.\n\nStatus: ${statusOutput}`;
396
+ }
397
+
398
+ return `Re-login for account "${accountName}": Could not obtain a login URL.\n\nAuth status: ${statusOutput || "unknown"}\nLogin output: ${loginOutput || "empty"}`;
399
+ } catch (err) {
400
+ log.error(`Re-login failed for account "${accountName}": ${err}`);
401
+ return `Re-login failed for account "${accountName}": ${err}`;
402
+ }
403
+ }
404
+
405
+ // ─── Parallel executor ──────────────────────────────────────────────
406
+
407
+ interface ParallelTask {
408
+ prompt: string;
409
+ index: number;
410
+ }
411
+
412
+ function parseParallelTasks(text: string): ParallelTask[] {
413
+ const lines = text.split("\n").slice(1); // skip the /parallel line
414
+ const tasks: ParallelTask[] = [];
415
+ let index = 0;
416
+ for (const line of lines) {
417
+ const trimmed = line.replace(/^[-*•]\s*/, "").trim();
418
+ if (trimmed) {
419
+ tasks.push({ prompt: trimmed, index: index++ });
420
+ }
421
+ }
422
+ return tasks;
423
+ }
424
+
425
+ async function executeParallel(
426
+ tasks: ParallelTask[],
427
+ agentConfig: any,
428
+ workspace: string,
429
+ systemPrompt: string,
430
+ baseDir: string,
431
+ mcpRegistry?: Record<string, McpServerConfig>,
432
+ claudeConfigDir?: string,
433
+ ): Promise<string> {
434
+ const home = homedir();
435
+
436
+ log.info(`[Parallel] Spawning ${tasks.length} workers...`);
437
+
438
+ // Build shared args (no session — each worker is independent)
439
+ const buildArgs = (taskPrompt: string): string[] => {
440
+ const args = ["-p", "-", "--system-prompt", systemPrompt, "--output-format", "text", "--add-dir", workspace];
441
+
442
+ // Tools
443
+ const allowedTools = [...(agentConfig.allowedTools || [])];
444
+ if (agentConfig.mcps?.length) {
445
+ for (const mcpName of agentConfig.mcps) allowedTools.push(`mcp__${mcpName}__*`);
446
+ }
447
+ if (allowedTools.length > 0) args.push("--allowedTools", allowedTools.join(","));
448
+
449
+ // MCPs
450
+ if (agentConfig.mcps?.length && mcpRegistry) {
451
+ const memoryDir = agentConfig.memoryDir?.startsWith("~")
452
+ ? agentConfig.memoryDir.replace("~", home) : agentConfig.memoryDir;
453
+ const mcpConfigPath = buildMcpConfigFile(`parallel-${Date.now()}`, agentConfig.mcps, mcpRegistry, baseDir, memoryDir);
454
+ args.push("--mcp-config", mcpConfigPath, "--strict-mcp-config");
455
+ }
456
+
457
+ // Use bypassPermissions when agent has MCPs (headless can't approve MCP tool prompts)
458
+ args.push("--permission-mode", agentConfig.mcps?.length ? "bypassPermissions" : "acceptEdits");
459
+ return args;
460
+ };
461
+
462
+ const timeout = agentConfig.timeout ?? 14_400_000;
463
+
464
+ // Spawn all workers in parallel
465
+ const results = await Promise.allSettled(
466
+ tasks.map(async (task) => {
467
+ const args = buildArgs(task.prompt);
468
+ log.info(`[Parallel] Worker ${task.index + 1}: "${task.prompt.slice(0, 60)}..."`);
469
+ try {
470
+ const result = await spawnClaude(args, workspace, timeout, task.prompt, claudeConfigDir);
471
+ log.info(`[Parallel] Worker ${task.index + 1}: done`);
472
+ return { index: task.index, prompt: task.prompt, result: result.trim() };
473
+ } catch (err) {
474
+ log.warn(`[Parallel] Worker ${task.index + 1}: failed — ${err}`);
475
+ return { index: task.index, prompt: task.prompt, result: `Error: ${err}` };
476
+ }
477
+ })
478
+ );
479
+
480
+ // Format results
481
+ const lines = [`**${tasks.length} parallel tasks completed:**\n`];
482
+ for (const r of results) {
483
+ if (r.status === "fulfilled") {
484
+ const { index, prompt, result } = r.value;
485
+ lines.push(`### Task ${index + 1}: ${prompt}`);
486
+ lines.push(result);
487
+ lines.push("");
488
+ } else {
489
+ lines.push(`### Task: Failed`);
490
+ lines.push(`Error: ${r.reason}`);
491
+ lines.push("");
492
+ }
493
+ }
494
+
495
+ return lines.join("\n");
496
+ }
497
+
498
+ // ─── Skill index builder ─────────────────────────────────────────────
499
+
500
+ function findRegistrySkill(name: string, baseDir: string): string | null {
501
+ const registryRoot = join(baseDir, "registry", "skills");
502
+ if (!existsSync(registryRoot)) return null;
503
+ // Check root first, then each subdirectory
504
+ const direct = join(registryRoot, `${name}.md`);
505
+ if (existsSync(direct)) return direct;
506
+ try {
507
+ for (const entry of readdirSync(registryRoot, { withFileTypes: true })) {
508
+ if (!entry.isDirectory()) continue;
509
+ const candidate = join(registryRoot, entry.name, `${name}.md`);
510
+ if (existsSync(candidate)) return candidate;
511
+ }
512
+ } catch { /* ignore */ }
513
+ return null;
514
+ }
515
+
516
+ function buildSkillIndex(
517
+ sharedSkillNames: string[],
518
+ agentSkillNames: string[],
519
+ agentMemoryDir: string,
520
+ orgNames?: string[],
521
+ baseDir?: string,
522
+ ): string {
523
+ const home = homedir();
524
+ const claudeDir = join(home, ".claude", "commands");
525
+ const personalDir = join(getPersonalAgentsDir(), "skills");
526
+ const agentSkillsDir = join(agentMemoryDir, "..", "skills");
527
+
528
+ // Helper: extract scripts dir from frontmatter if present
529
+ const getScriptsDir = (content: string, mdPath: string): string => {
530
+ const m = content.match(/scripts:\s*(.+)/);
531
+ if (!m) return "";
532
+ const rel = m[1].trim().replace(/\/$/, "");
533
+ const dir = join(mdPath, "..", rel);
534
+ return existsSync(dir) ? ` · scripts: \`${dir}\`` : "";
535
+ };
536
+
537
+ const lines: string[] = [
538
+ "\n## Available Skills",
539
+ "You have skills available as markdown files. When a task matches a skill, use the Read tool to read it from the path shown, then follow its instructions.\n",
540
+ "| Skill | Description | Path |",
541
+ "|-------|-------------|------|",
542
+ ];
543
+
544
+ // Shared skills — check 4 locations in order: personalAgents/skills > ~/.claude/commands > registry/skills
545
+ for (const name of sharedSkillNames) {
546
+ let filePath = "";
547
+ const personalPath = join(personalDir, `${name}.md`);
548
+ const claudePath = join(claudeDir, `${name}.md`);
549
+ if (existsSync(personalPath)) {
550
+ filePath = personalPath;
551
+ } else if (existsSync(claudePath)) {
552
+ filePath = claudePath;
553
+ } else if (baseDir) {
554
+ filePath = findRegistrySkill(name, baseDir) || "";
555
+ }
556
+
557
+ if (!filePath) {
558
+ log.warn(`Shared skill not found: ${name}`);
559
+ continue;
560
+ }
561
+ try {
562
+ const content = readFileSync(filePath, "utf-8");
563
+ const descMatch = content.match(/description:\s*(.+)/);
564
+ const desc = descMatch ? descMatch[1].trim() : "No description";
565
+ lines.push(`| ${name} | ${desc} | \`${filePath}\`${getScriptsDir(content, filePath)} |`);
566
+ } catch {
567
+ lines.push(`| ${name} | (could not read) | \`${filePath}\` |`);
568
+ }
569
+ }
570
+
571
+ // Org-scoped skills — auto-discovered from personalAgents/[OrgName]/skills/
572
+ if (orgNames?.length) {
573
+ const seen = new Set<string>();
574
+ for (const orgName of orgNames) {
575
+ const orgSkillsDir = join(getPersonalAgentsDir(), orgName, "skills");
576
+ if (!existsSync(orgSkillsDir)) continue;
577
+ try {
578
+ const mdFiles = readdirSync(orgSkillsDir).filter((f: string) => f.endsWith(".md"));
579
+ for (const file of mdFiles) {
580
+ const name = file.replace(".md", "");
581
+ if (seen.has(name)) continue;
582
+ seen.add(name);
583
+ const filePath = join(orgSkillsDir, file);
584
+ try {
585
+ const content = readFileSync(filePath, "utf-8");
586
+ const descMatch = content.match(/description:\s*(.+)/);
587
+ const desc = descMatch ? descMatch[1].trim() : "No description";
588
+ lines.push(`| ${name} ◆ | ${desc} | \`${filePath}\`${getScriptsDir(content, filePath)} |`);
589
+ } catch {
590
+ lines.push(`| ${name} ◆ | (could not read) | \`${filePath}\` |`);
591
+ }
592
+ }
593
+ } catch { /* org skills dir not readable */ }
594
+ }
595
+ }
596
+
597
+ // Agent-specific skills from agent/skills/
598
+ for (const name of agentSkillNames) {
599
+ const filePath = join(agentSkillsDir, `${name}.md`);
600
+ if (!existsSync(filePath)) {
601
+ log.warn(`Agent skill not found: ${filePath}`);
602
+ continue;
603
+ }
604
+ try {
605
+ const content = readFileSync(filePath, "utf-8");
606
+ const descMatch = content.match(/description:\s*(.+)/);
607
+ const desc = descMatch ? descMatch[1].trim() : "No description";
608
+ lines.push(`| ${name} ★ | ${desc} | \`${filePath}\`${getScriptsDir(content, filePath)} |`);
609
+ } catch {
610
+ lines.push(`| ${name} ★ | (could not read) | \`${filePath}\` |`);
611
+ }
612
+ }
613
+
614
+ if (lines.length <= 4) return "";
615
+
616
+ lines.push("");
617
+ lines.push("Skills marked with ★ are specific to this agent. Skills marked with ◆ are shared across your org. To use a skill: Read the file at the path shown, then follow its instructions.");
618
+ return lines.join("\n");
619
+ }
620
+
621
+ // ─── Prompt template helpers ─────────────────────────────────────────
622
+
623
+ function getPromptsDir(): string {
624
+ const home = homedir();
625
+ const personalDir = join(getPersonalAgentsDir(), "prompts");
626
+ return personalDir;
627
+ }
628
+
629
+ function findPromptFile(name: string, baseDir: string): string | null {
630
+ const personalDir = getPromptsDir();
631
+ const registryDir = join(baseDir, "registry", "prompts", "platform");
632
+
633
+ const candidates = [
634
+ join(personalDir, `${name}.md`),
635
+ join(registryDir, `${name}.md`),
636
+ ];
637
+ for (const p of candidates) {
638
+ if (existsSync(p)) return p;
639
+ }
640
+ return null;
641
+ }
642
+
643
+ function buildPromptIndex(promptNames: string[], baseDir: string, trigger: string): string {
644
+ const lines: string[] = [
645
+ `\n## Available Prompt Templates`,
646
+ `You have prompt templates available. When a user message starts with \`${trigger}name\`, load the matching template and apply its instructions to frame your response.\n`,
647
+ "| Trigger | Description | Path |",
648
+ "|---------|-------------|------|",
649
+ ];
650
+
651
+ for (const name of promptNames) {
652
+ const filePath = findPromptFile(name, baseDir);
653
+ if (!filePath) continue;
654
+ try {
655
+ const content = readFileSync(filePath, "utf-8");
656
+ const descMatch = content.match(/description:\s*(.+)/);
657
+ const desc = descMatch ? descMatch[1].trim() : "No description";
658
+ lines.push(`| \`${trigger}${name}\` | ${desc} | \`${filePath}\` |`);
659
+ } catch {
660
+ lines.push(`| \`${trigger}${name}\` | (could not read) | \`${filePath}\` |`);
661
+ }
662
+ }
663
+
664
+ if (lines.length <= 4) return "";
665
+ lines.push("");
666
+ return lines.join("\n");
667
+ }
668
+
669
+ function resolvePromptTrigger(msg: string, effectivePrompts: string[], baseDir: string, trigger: string): { promptContent: string; userText: string } | null {
670
+ if (!msg.startsWith(trigger) || !effectivePrompts.length) return null;
671
+ const after = msg.slice(trigger.length);
672
+ const spaceIdx = after.indexOf(" ");
673
+ const promptName = spaceIdx === -1 ? after : after.slice(0, spaceIdx);
674
+ const userText = spaceIdx === -1 ? "" : after.slice(spaceIdx + 1).trim();
675
+
676
+ if (!effectivePrompts.includes(promptName)) return null;
677
+ const filePath = findPromptFile(promptName, baseDir);
678
+ if (!filePath) return null;
679
+
680
+ try {
681
+ const content = readFileSync(filePath, "utf-8");
682
+ // Strip frontmatter
683
+ const body = content.replace(/^---[\s\S]*?---\s*/m, "").trim();
684
+ return { promptContent: body, userText };
685
+ } catch {
686
+ return null;
687
+ }
688
+ }
689
+
690
+ // ─── MCP key loader ──────────────────────────────────────────────────
691
+ // Dual-level: agent-specific keys override shared keys.
692
+ // Supports encrypted .env.enc files (decrypted with MYAGENT_MASTER_PASSWORD).
693
+
694
+ const masterPassword = process.env.MYAGENT_MASTER_PASSWORD || undefined;
695
+
696
+ function loadMcpKeys(baseDir: string, mcpName: string, agentMemoryDir?: string): Record<string, string> {
697
+ const sharedDir = join(baseDir, "data", "mcp-keys");
698
+ return loadMcpKeysWithDecryption(sharedDir, agentMemoryDir || null, mcpName, masterPassword);
699
+ }
700
+
701
+ // ─── MCP config builder ─────────────────────────────────────────────
702
+
703
+ function buildMcpConfigFile(
704
+ agentId: string,
705
+ mcpNames: string[],
706
+ mcpRegistry: Record<string, McpServerConfig>,
707
+ baseDir: string,
708
+ agentMemoryDir?: string,
709
+ ): string {
710
+ const home = homedir();
711
+ const mcpServers: Record<string, any> = {};
712
+
713
+ // Discover named connection key files for auto-expansion
714
+ // e.g., gmail-agenticledger.env, gmail-bst.env under the agent's mcp-keys/
715
+ function discoverNamedConnections(baseMcpName: string): string[] {
716
+ const connections: string[] = [];
717
+ if (!agentMemoryDir) return connections;
718
+ const keysDir = join(agentMemoryDir, "..", "mcp-keys");
719
+ try {
720
+ const files = readdirSync(keysDir);
721
+ for (const f of files) {
722
+ if (f.startsWith(baseMcpName + "-") && f.endsWith(".env")) {
723
+ const instanceName = f.replace(".env", "");
724
+ connections.push(instanceName);
725
+ }
726
+ }
727
+ } catch { /* dir doesn't exist */ }
728
+ return connections;
729
+ }
730
+
731
+ for (const name of mcpNames) {
732
+ const def = mcpRegistry[name];
733
+ if (!def) continue;
734
+
735
+ if (def.type === "stdio") {
736
+ const args = (def.args || []).map((a) =>
737
+ a.startsWith("~") ? a.replace("~", home) : a,
738
+ );
739
+
740
+ // Merge env: config.json values < .env file values (file overrides)
741
+ const configEnv = { ...(def.env || {}) };
742
+ const fileEnv = loadMcpKeys(baseDir, name, agentMemoryDir);
743
+
744
+ // Remove empty-string values from config (they're just templates)
745
+ const mergedEnv: Record<string, string> = {};
746
+ for (const [k, v] of Object.entries(configEnv)) {
747
+ if (v) mergedEnv[k] = v;
748
+ }
749
+ // File keys override config keys
750
+ Object.assign(mergedEnv, fileEnv);
751
+
752
+ mcpServers[name] = {
753
+ command: def.command,
754
+ args,
755
+ env: mergedEnv,
756
+ };
757
+ } else {
758
+ const httpDef = def as McpServerHttp;
759
+
760
+ // For HTTP MCPs, check if headers have ${VAR} references and resolve from .env
761
+ const headers = { ...(httpDef.headers || {}) };
762
+ const fileEnv = loadMcpKeys(baseDir, name, agentMemoryDir);
763
+ for (const [hk, hv] of Object.entries(headers)) {
764
+ if (typeof hv === "string" && hv.includes("${")) {
765
+ headers[hk] = hv.replace(/\$\{(\w+)\}/g, (_, varName) => {
766
+ return fileEnv[varName] || process.env[varName] || "";
767
+ });
768
+ }
769
+ }
770
+
771
+ // Auto-expand named connections: create additional MCP server entries
772
+ // e.g., gmail-agenticledger, gmail-bst each get their own server entry
773
+ // using the same URL but with their specific Bearer token
774
+ const namedConns = discoverNamedConnections(name);
775
+
776
+ // Only create the base entry if there are no named connections
777
+ // (otherwise the base has no token and just fails)
778
+ if (namedConns.length === 0) {
779
+ mcpServers[name] = {
780
+ type: def.type,
781
+ url: httpDef.url,
782
+ headers,
783
+ };
784
+ }
785
+ for (const instanceName of namedConns) {
786
+ const instanceEnv = loadMcpKeys(baseDir, instanceName, agentMemoryDir);
787
+ // Use the first key value directly as the Bearer token
788
+ const tokenValue = Object.values(instanceEnv)[0] || "";
789
+ mcpServers[instanceName] = {
790
+ type: def.type,
791
+ url: httpDef.url,
792
+ headers: tokenValue ? { Authorization: `Bearer ${tokenValue}` } : {},
793
+ };
794
+ }
795
+ }
796
+ }
797
+
798
+ const tmpDir = resolve(baseDir, "tmp", "mcp-configs");
799
+ mkdirSync(tmpDir, { recursive: true });
800
+
801
+ const filePath = join(tmpDir, `${agentId}-${Date.now()}.json`);
802
+ writeFileSync(filePath, JSON.stringify({ mcpServers }, null, 2));
803
+
804
+ return filePath;
805
+ }
806
+
807
+ // ─── Session management ──────────────────────────────────────────────
808
+
809
+ function sessionFileName(senderId?: string): string {
810
+ // Feature 3: Per-sender sessions use sender-specific files
811
+ return senderId ? `session-${senderId}.json` : "session.json";
812
+ }
813
+
814
+ function loadSession(memoryDir: string, senderId?: string): SessionState | null {
815
+ const sessionPath = join(memoryDir, sessionFileName(senderId));
816
+ if (!existsSync(sessionPath)) return null;
817
+ try {
818
+ return JSON.parse(readFileSync(sessionPath, "utf-8")) as SessionState;
819
+ } catch {
820
+ return null;
821
+ }
822
+ }
823
+
824
+ function saveSession(memoryDir: string, state: SessionState, senderId?: string): void {
825
+ const sessionPath = join(memoryDir, sessionFileName(senderId));
826
+ writeFileSync(sessionPath, JSON.stringify(state, null, 2));
827
+ }
828
+
829
+ // ─── Main executor ──────────────────────────────────────────────────
830
+
831
+ export async function executeAgent(
832
+ route: ResolvedRoute,
833
+ msg: InboundMessage,
834
+ baseDir: string,
835
+ mcpRegistry?: Record<string, McpServerConfig>,
836
+ claudeAccounts?: Record<string, string>,
837
+ globalDefaults?: { skills?: string[]; mcps?: string[]; prompts?: string[]; promptTrigger?: string },
838
+ ): Promise<string> {
839
+ const { agentId, agentConfig } = route;
840
+ const effectiveSkills = [...new Set([...(agentConfig.skills || []), ...(globalDefaults?.skills || [])])];
841
+ const effectiveMcps = [...new Set([...(agentConfig.mcps || []), ...(globalDefaults?.mcps || [])])];
842
+ const effectivePrompts = [...new Set([...(agentConfig.prompts || []), ...(globalDefaults?.prompts || [])])];
843
+ const _home = homedir();
844
+ const expandTilde = (p: string) => p.startsWith("~") ? p.replace("~", _home) : p;
845
+ const workspace = resolve(expandTilde(agentConfig.workspace));
846
+ const claudeMdPath = resolve(baseDir, expandTilde(agentConfig.claudeMd));
847
+ const memoryDir = resolve(baseDir, expandTilde(agentConfig.memoryDir));
848
+ const contextPath = join(memoryDir, "context.md");
849
+ const learnedPath = join(memoryDir, "learned.md");
850
+ const logPath = join(memoryDir, "conversation_log.jsonl");
851
+ const isPersistent = agentConfig.persistent ?? false;
852
+ const perSender = agentConfig.perSenderSessions ?? false;
853
+ const senderSessionKey = (isPersistent && perSender) ? msg.sender : undefined;
854
+ const useAdvancedMemory = agentConfig.advancedMemory ?? false;
855
+ const useWiki = agentConfig.wiki ?? false;
856
+
857
+ // ── Resolve Claude account config dir ──
858
+ const home = homedir();
859
+ let claudeConfigDir: string | undefined;
860
+ const effectiveAccount = agentConfig.claudeAccount || (claudeAccounts as any)?._defaultAccount;
861
+ if (effectiveAccount && claudeAccounts) {
862
+ const dir = claudeAccounts[effectiveAccount];
863
+ if (dir) claudeConfigDir = dir.startsWith("~") ? dir.replace("~", home) : dir;
864
+ }
865
+
866
+ // ── Check for /relogin command ──
867
+ const reloginMatch = RELOGIN_PATTERN.exec(msg.text);
868
+ if (reloginMatch) {
869
+ const accountName = reloginMatch[1] || effectiveAccount || "default";
870
+ let reloginDir: string | undefined;
871
+ if (claudeAccounts && claudeAccounts[accountName]) {
872
+ const d = claudeAccounts[accountName];
873
+ reloginDir = d.startsWith("~") ? d.replace("~", home) : d;
874
+ }
875
+ return handleRelogin(accountName, reloginDir);
876
+ }
877
+
878
+ // ── Check for /parallel command ──
879
+ if (PARALLEL_PATTERN.test(msg.text)) {
880
+ const tasks = parseParallelTasks(msg.text);
881
+ if (tasks.length === 0) return "No tasks found. Format:\n/parallel\n- task 1\n- task 2\n- task 3";
882
+
883
+ // Load system prompt for workers
884
+ let workerPrompt: string;
885
+ try {
886
+ workerPrompt = readFileSync(resolve(baseDir, expandTilde(agentConfig.claudeMd)), "utf-8");
887
+ } catch {
888
+ workerPrompt = `You are ${agentConfig.name}. ${agentConfig.description}`;
889
+ }
890
+
891
+ const result = await executeParallel(
892
+ tasks, agentConfig, workspace, workerPrompt, baseDir, mcpRegistry, claudeConfigDir
893
+ );
894
+
895
+ // Log the parallel execution
896
+ try {
897
+ appendFileSync(logPath, JSON.stringify({
898
+ ts: new Date().toISOString(), from: msg.sender, text: msg.text,
899
+ response: result.slice(0, 2000), agentId, channel: msg.channel,
900
+ parallel: tasks.length,
901
+ }) + "\n");
902
+ } catch { /* ignore */ }
903
+
904
+ return result;
905
+ }
906
+
907
+ // ── Check for /task command ──
908
+ if (TASK_PATTERN.test(msg.text)) {
909
+ // Load all agents from config.json for cross-agent task operations
910
+ let allAgents: Record<string, any> = {};
911
+ try {
912
+ const configPath = join(baseDir, "config.json");
913
+ const rawConfig = JSON.parse(readFileSync(configPath, "utf-8"));
914
+ // Resolve tilde in paths for all agents
915
+ for (const [aid, ag] of Object.entries(rawConfig.agents) as any[]) {
916
+ if (ag.agentHome) ag.agentHome = ag.agentHome.startsWith("~") ? ag.agentHome.replace("~", home) : ag.agentHome;
917
+ if (ag.memoryDir) ag.memoryDir = ag.memoryDir.startsWith("~") ? ag.memoryDir.replace("~", home) : ag.memoryDir;
918
+ }
919
+ allAgents = rawConfig.agents;
920
+ } catch { /* fallback to just current agent */ }
921
+
922
+ const taskResp = handleTaskCommand(msg.text, agentId, agentConfig, allAgents);
923
+ if (taskResp !== null) {
924
+ try {
925
+ appendFileSync(logPath, JSON.stringify({
926
+ ts: new Date().toISOString(), from: msg.sender, text: msg.text,
927
+ response: taskResp, agentId, channel: msg.channel,
928
+ }) + "\n");
929
+ } catch { /* ignore */ }
930
+ return taskResp;
931
+ }
932
+ }
933
+
934
+ // ── Check for intercepted commands ──
935
+ const intercepted = handleInterceptedCommand(msg.text, agentId, memoryDir, senderSessionKey);
936
+ if (intercepted !== null) {
937
+ try {
938
+ const entry = {
939
+ ts: new Date().toISOString(),
940
+ from: msg.sender,
941
+ text: msg.text,
942
+ response: intercepted,
943
+ agentId,
944
+ channel: msg.channel,
945
+ };
946
+ appendFileSync(logPath, JSON.stringify(entry) + "\n");
947
+ } catch { /* ignore */ }
948
+ return intercepted;
949
+ }
950
+
951
+ // ── Advanced memory: search for relevant context ──
952
+ let memoryContext = "";
953
+ let memoryMgr: MemoryManager | null = null;
954
+ if (useAdvancedMemory) {
955
+ try {
956
+ memoryMgr = await getMemoryManager(agentId, memoryDir);
957
+ // Search for memories relevant to the user's message
958
+ const searchResults = await memoryMgr.searchFormatted(msg.text, 5);
959
+ if (searchResults) memoryContext += searchResults + "\n\n";
960
+ // Load today + yesterday daily logs
961
+ const daily = memoryMgr.loadDailyContext();
962
+ if (daily) memoryContext += daily + "\n\n";
963
+ } catch (err) {
964
+ log.warn(`Advanced memory search failed for ${agentId}: ${err}`);
965
+ }
966
+ }
967
+
968
+ // ── Load system prompt ──
969
+ let systemPrompt: string;
970
+ try {
971
+ systemPrompt = readFileSync(claudeMdPath, "utf-8");
972
+ } catch (err) {
973
+ log.error(`Failed to read CLAUDE.md for ${agentId}: ${err}`);
974
+ return `Error: Could not load agent configuration for ${agentId}.`;
975
+ }
976
+
977
+ // ── Prepend soul.md for gym agents (trainer personality layer) ──
978
+ if (agentConfig.agentClass === "gym") {
979
+ try {
980
+ const profilePath = join(memoryDir, "learner-profile.json");
981
+ if (existsSync(profilePath)) {
982
+ const profile = JSON.parse(readFileSync(profilePath, "utf-8"));
983
+ const trainer = profile.selectedTrainer;
984
+ if (trainer) {
985
+ const soulPath = join(memoryDir, "..", "souls", `${trainer}.md`);
986
+ if (existsSync(soulPath)) {
987
+ const soul = readFileSync(soulPath, "utf-8");
988
+ systemPrompt = soul + "\n\n" + systemPrompt;
989
+ log.info(`Gym agent: loaded soul.md for trainer "${trainer}"`);
990
+ }
991
+ }
992
+ }
993
+ } catch (err) {
994
+ log.warn(`Failed to load gym soul.md: ${err}`);
995
+ }
996
+ }
997
+
998
+ // ── Append memory context to system prompt for persistent sessions ──
999
+ // (In persistent mode, context.md is injected into the system prompt
1000
+ // so the agent has standing context even after a session reset)
1001
+ if (isPersistent && existsSync(contextPath)) {
1002
+ try {
1003
+ const context = readFileSync(contextPath, "utf-8").trim();
1004
+ if (context) {
1005
+ systemPrompt += `\n\n## Agent Memory\n${context}\n`;
1006
+ }
1007
+ } catch { /* ignore */ }
1008
+ }
1009
+
1010
+ // ── Append skill index if configured ──
1011
+ const agentOrgNames = (agentConfig.org || []).map((o: any) => o.organization).filter(Boolean);
1012
+ const hasOrgSkills = agentOrgNames.some((org: string) => existsSync(join(getPersonalAgentsDir(), org, "skills")));
1013
+ const hasSkills = effectiveSkills.length > 0 || (agentConfig.agentSkills?.length || 0) > 0 || hasOrgSkills;
1014
+ if (hasSkills) {
1015
+ systemPrompt += buildSkillIndex(effectiveSkills, agentConfig.agentSkills || [], memoryDir, agentOrgNames, baseDir);
1016
+ }
1017
+
1018
+ // ── Append prompt template index if configured ──
1019
+ if (effectivePrompts.length > 0) {
1020
+ const trigger = globalDefaults?.promptTrigger || "!";
1021
+ systemPrompt += buildPromptIndex(effectivePrompts, baseDir, trigger);
1022
+ }
1023
+
1024
+ // ── Append MCP account mapping (multi-account) ──
1025
+ {
1026
+ const agentHome = agentConfig.agentHome || resolve(memoryDir, "..");
1027
+ const resolvedHome = agentHome.startsWith("~") ? agentHome.replace("~", home) : agentHome;
1028
+ const accountsPath = join(resolvedHome, "mcp-accounts.json");
1029
+ if (existsSync(accountsPath)) {
1030
+ try {
1031
+ const accounts = JSON.parse(readFileSync(accountsPath, "utf-8")) as Record<string, { label: string; baseMcp: string; description?: string }>;
1032
+ if (Object.keys(accounts).length > 0) {
1033
+ const lines = ["\n\n## MCP Account Mapping", "You have multiple accounts connected for some services. Use the correct MCP instance based on which account the user is asking about.\n"];
1034
+ lines.push("| MCP Instance | Label | Service | Description |");
1035
+ lines.push("|---|---|---|---|");
1036
+ for (const [name, info] of Object.entries(accounts)) {
1037
+ lines.push(`| ${name} | ${info.label} | ${info.baseMcp} | ${info.description || ""} |`);
1038
+ }
1039
+ lines.push("\nWhen the user asks about a specific account (e.g., \"check my work email\"), use the matching MCP instance. If unclear, check all connected accounts.");
1040
+ systemPrompt += lines.join("\n");
1041
+ }
1042
+ } catch { /* ignore */ }
1043
+ }
1044
+ }
1045
+
1046
+ // ── Append group agent delegation (sub-agents) ──
1047
+ if (agentConfig.subAgents && _appConfig) {
1048
+ const registry = buildAgentRegistry(_appConfig, agentConfig.subAgents);
1049
+ if (registry.length > 0) {
1050
+ systemPrompt += buildGroupAgentPrompt(registry, msg.text);
1051
+ }
1052
+ }
1053
+
1054
+ // ── Append active tasks context ──
1055
+ {
1056
+ const agentHomeForTasks = agentConfig.agentHome || resolve(memoryDir, "..");
1057
+ const taskBlock = buildTaskContextBlock(agentHomeForTasks, agentId);
1058
+ if (taskBlock) systemPrompt += taskBlock;
1059
+ }
1060
+
1061
+ // ── Append advanced memory context ──
1062
+ if (useAdvancedMemory && memoryContext) {
1063
+ systemPrompt += `\n\n${memoryContext}`;
1064
+ }
1065
+
1066
+ // ── Auto-compaction check for advanced memory agents ──
1067
+ if (useAdvancedMemory && isPersistent && memoryMgr) {
1068
+ // Read current session to get message count
1069
+ const currentSession = loadSession(memoryDir, senderSessionKey);
1070
+ if (currentSession) {
1071
+ const compactionPrompt = memoryMgr.getCompactionPrompt(currentSession.messageCount);
1072
+ if (compactionPrompt) {
1073
+ systemPrompt += `\n\n${compactionPrompt}`;
1074
+ }
1075
+ }
1076
+ }
1077
+
1078
+ // ── Append compact/reset instructions for persistent agents ──
1079
+ if (isPersistent) {
1080
+ systemPrompt += `\n\n## Session Commands
1081
+ - When the user sends \`/opcompact\` followed by instructions, save the specified information to \`${contextPath}\` using the Write tool. This context survives session resets. Preserve any existing content that is still relevant — append or merge, don't overwrite blindly.
1082
+ - \`/opreset\` is handled automatically by the gateway (you won't see it).
1083
+ `;
1084
+ }
1085
+
1086
+ // ── Wiki learning mode ──
1087
+ if (useWiki) {
1088
+ systemPrompt += `\n\n## Wiki Learning Mode
1089
+ After each conversation exchange, evaluate whether you learned any NEW facts, corrections, or important insights from this interaction. If you did, append them to \`${learnedPath}\` using the Write tool (read the file first and append — do not overwrite).
1090
+
1091
+ Format for each entry:
1092
+ \`\`\`
1093
+ ### [YYYY-MM-DD] — [Brief topic]
1094
+ - Fact or correction learned
1095
+ - Source: [who said it / where it came from]
1096
+ \`\`\`
1097
+
1098
+ Rules:
1099
+ - Only save genuinely NEW information not already in \`${contextPath}\` or \`${learnedPath}\`
1100
+ - Include the source (who told you, which conversation)
1101
+ - Do NOT save opinions, small talk, or ephemeral info
1102
+ - When the user says "update context from learned", read \`${learnedPath}\`, cross-check against \`${contextPath}\`, merge verified facts into context.md, and note what was merged
1103
+ `;
1104
+ }
1105
+
1106
+ // ── Resolve prompt template trigger ──
1107
+ const promptTrigger = globalDefaults?.promptTrigger || "!";
1108
+ let promptInjection = "";
1109
+ let msgText = msg.text;
1110
+ if (effectivePrompts.length > 0 && msg.text.startsWith(promptTrigger)) {
1111
+ const resolved = resolvePromptTrigger(msg.text, effectivePrompts, baseDir, promptTrigger);
1112
+ if (resolved) {
1113
+ promptInjection = `[PROMPT TEMPLATE ACTIVE]\n${resolved.promptContent}\n[END PROMPT TEMPLATE]\n\n`;
1114
+ msgText = resolved.userText || msg.text.slice(promptTrigger.length + (msg.text.slice(promptTrigger.length).indexOf(" ") + 1 || 0));
1115
+ log.debug(`[${agentId}] Prompt template injected`);
1116
+ }
1117
+ }
1118
+ const effectiveMsg = msgText !== msg.text ? { ...msg, text: msgText } : msg;
1119
+
1120
+ // ── Format message ──
1121
+ // For persistent sessions: skip conversation history injection (Claude manages its own)
1122
+ // Still inject memory context for non-persistent sessions
1123
+ let formattedMessage: string;
1124
+ if (isPersistent) {
1125
+ formattedMessage = promptInjection + formatMessage(effectiveMsg);
1126
+ } else {
1127
+ formattedMessage = promptInjection + formatMessage(
1128
+ effectiveMsg,
1129
+ existsSync(contextPath) ? contextPath : undefined,
1130
+ existsSync(logPath) ? logPath : undefined,
1131
+ );
1132
+ }
1133
+
1134
+ // ── Build stdin payload ──
1135
+ const hasImages = msg.attachments && msg.attachments.length > 0;
1136
+ let stdinPayload: string;
1137
+
1138
+ if (hasImages) {
1139
+ const contentBlocks: ContentBlock[] = [
1140
+ { type: "text", text: formattedMessage },
1141
+ ];
1142
+
1143
+ for (const att of msg.attachments!) {
1144
+ try {
1145
+ const imgBuffer = readFileSync(att.path);
1146
+ const mimeType = att.mimeType || guessMimeType(att.path);
1147
+ if (imgBuffer.length > 10_000_000) {
1148
+ log.warn(`Skipping oversized image: ${att.path} (${imgBuffer.length} bytes)`);
1149
+ continue;
1150
+ }
1151
+ contentBlocks.push({
1152
+ type: "image",
1153
+ source: {
1154
+ type: "base64",
1155
+ media_type: mimeType,
1156
+ data: imgBuffer.toString("base64"),
1157
+ },
1158
+ });
1159
+ log.debug(`Attached image: ${att.path} (${mimeType}, ${imgBuffer.length} bytes)`);
1160
+ } catch (err) {
1161
+ log.warn(`Failed to read attachment ${att.path}: ${err}`);
1162
+ }
1163
+ }
1164
+
1165
+ stdinPayload = JSON.stringify([{ role: "user", content: contentBlocks }]);
1166
+ log.debug(`Executing ${agentId} with ${contentBlocks.length - 1} image(s): ${formattedMessage.slice(0, 200)}`);
1167
+ } else {
1168
+ stdinPayload = formattedMessage;
1169
+ log.debug(`Executing ${agentId}: ${formattedMessage.slice(0, 200)}`);
1170
+ }
1171
+
1172
+ // ── Build claude -p args ──
1173
+ const args: string[] = ["-p", "-"];
1174
+
1175
+ // Model override (from /model command)
1176
+ const modelOverride = loadModelOverride(memoryDir);
1177
+ if (modelOverride) args.push("--model", modelOverride);
1178
+
1179
+ // Session management for persistent agents
1180
+ let session: SessionState | null = null;
1181
+ if (isPersistent) {
1182
+ session = loadSession(memoryDir, senderSessionKey);
1183
+ if (session) {
1184
+ // Resume existing session
1185
+ args.push("--resume", session.sessionId);
1186
+ log.info(`Resuming session ${session.sessionId} for ${agentId} (msg #${session.messageCount + 1})`);
1187
+ } else {
1188
+ // First message: create new session
1189
+ const newId = randomUUID();
1190
+ session = { sessionId: newId, createdAt: new Date().toISOString(), messageCount: 0 };
1191
+ args.push("--session-id", newId);
1192
+ args.push("--system-prompt", systemPrompt);
1193
+ log.info(`Starting new session ${newId} for ${agentId}`);
1194
+ }
1195
+
1196
+ // Use JSON output to get structured metadata
1197
+ args.push("--output-format", "json");
1198
+ } else {
1199
+ // Non-persistent: always pass system prompt, text output
1200
+ args.push("--system-prompt", systemPrompt);
1201
+ args.push("--output-format", "text");
1202
+ }
1203
+
1204
+ // Workspace
1205
+ args.push("--add-dir", workspace);
1206
+
1207
+ // Skills directory (so agent can Read skill files)
1208
+ if (hasSkills) {
1209
+ const home = homedir();
1210
+ const claudeSkillsDir = join(home, ".claude", "commands");
1211
+ const personalSkillsDir = join(getPersonalAgentsDir(), "skills");
1212
+ const agentSkillsDir = join(memoryDir, "..", "skills");
1213
+
1214
+ if (existsSync(claudeSkillsDir) && effectiveSkills.length) {
1215
+ args.push("--add-dir", claudeSkillsDir);
1216
+ }
1217
+ if (existsSync(personalSkillsDir) && effectiveSkills.length) {
1218
+ args.push("--add-dir", personalSkillsDir);
1219
+ }
1220
+ if (existsSync(agentSkillsDir) && agentConfig.agentSkills?.length) {
1221
+ args.push("--add-dir", agentSkillsDir);
1222
+ }
1223
+ // Org-scoped skills dirs
1224
+ for (const orgName of agentOrgNames) {
1225
+ const orgSkillsDir = join(getPersonalAgentsDir(), orgName, "skills");
1226
+ if (existsSync(orgSkillsDir)) args.push("--add-dir", orgSkillsDir);
1227
+ }
1228
+ }
1229
+
1230
+ // Allowed tools — include MCP tool patterns
1231
+ const allowedTools = [...agentConfig.allowedTools];
1232
+ if (effectiveMcps.length > 0) {
1233
+ for (const mcpName of effectiveMcps) {
1234
+ allowedTools.push(`mcp__${mcpName}__*`);
1235
+ }
1236
+ }
1237
+ if (allowedTools.length > 0) {
1238
+ args.push("--allowedTools", allowedTools.join(","));
1239
+ }
1240
+
1241
+ // MCP servers
1242
+ let mcpConfigPath: string | null = null;
1243
+ if (effectiveMcps.length > 0 && mcpRegistry) {
1244
+ mcpConfigPath = buildMcpConfigFile(agentId, effectiveMcps, mcpRegistry, baseDir, memoryDir);
1245
+ args.push("--mcp-config", mcpConfigPath, "--strict-mcp-config");
1246
+ log.debug(`MCP config for ${agentId}: ${mcpConfigPath} (servers: ${effectiveMcps.join(", ")})`);
1247
+ }
1248
+
1249
+ // Permission mode: bypassPermissions when agent has MCPs (headless can't approve tool prompts)
1250
+ if (isPersistent) {
1251
+ args.push("--permission-mode", effectiveMcps.length ? "bypassPermissions" : "acceptEdits");
1252
+ }
1253
+
1254
+ // ── Multi-model dispatch ──
1255
+ const multiModelEnabled = _appConfig?.service?.multiModelEnabled ?? false;
1256
+ const effectiveExecutor = agentConfig.executor
1257
+ || (multiModelEnabled ? (_appConfig?.service?.platformDefaultExecutor || "claude") : "claude");
1258
+
1259
+ if (multiModelEnabled && effectiveExecutor !== "claude" && effectiveExecutor.includes(":")) {
1260
+ const [prefix, ...rest] = effectiveExecutor.split(":");
1261
+ const modelName = rest.join(":");
1262
+
1263
+ // Helper to log + memory for alternative model responses
1264
+ const logAltResponse = (response: string) => {
1265
+ try {
1266
+ appendFileSync(logPath, JSON.stringify({
1267
+ ts: new Date().toISOString(), from: msg.sender, text: msg.text,
1268
+ response: response.slice(0, 2000), agentId, channel: msg.channel, executor: effectiveExecutor,
1269
+ }) + "\n");
1270
+ } catch { /* ignore */ }
1271
+ if (useAdvancedMemory && memoryMgr) {
1272
+ memoryMgr.indexExchange(msg.text, response, msg.sender).catch(() => {});
1273
+ }
1274
+ };
1275
+
1276
+ try {
1277
+ let altResponse: string;
1278
+
1279
+ if (prefix === "ollama") {
1280
+ const { executeOllama } = await import("./ollama-executor.js");
1281
+ altResponse = await executeOllama({
1282
+ model: modelName, systemPrompt, message: formattedMessage,
1283
+ baseUrl: _appConfig?.service?.ollamaBaseUrl || "http://localhost:11434",
1284
+ timeout: agentConfig.timeout ?? 300_000,
1285
+ });
1286
+ } else if (prefix === "gemini") {
1287
+ const providerKeys = (_appConfig?.service as any)?.providerKeys || {};
1288
+ const apiKey = providerKeys.google;
1289
+ if (!apiKey) return "Error: No Google API key configured. Add it in Admin → Settings → Provider Keys.";
1290
+ const { executeGemini } = await import("./gemini-executor.js");
1291
+ altResponse = await executeGemini({
1292
+ model: modelName, apiKey, systemPrompt, message: formattedMessage,
1293
+ timeout: agentConfig.timeout ?? 300_000,
1294
+ });
1295
+ } else {
1296
+ // OpenAI-compatible providers (openai, grok, groq, together, mistral)
1297
+ const { resolveProvider, executeOpenAICompat } = await import("./openai-executor.js");
1298
+ const provider = resolveProvider(prefix);
1299
+ if (!provider) return `Error: Unknown model provider "${prefix}". Supported: ollama, openai, grok, groq, together, mistral, gemini.`;
1300
+ const providerKeys = (_appConfig?.service as any)?.providerKeys || {};
1301
+ const apiKey = providerKeys[provider.keyField];
1302
+ if (!apiKey) return `Error: No API key configured for ${provider.name}. Add it in Admin → Settings → Provider Keys.`;
1303
+ altResponse = await executeOpenAICompat({
1304
+ provider: prefix, model: modelName, apiKey, systemPrompt, message: formattedMessage,
1305
+ timeout: agentConfig.timeout ?? 300_000,
1306
+ });
1307
+ }
1308
+
1309
+ logAltResponse(altResponse);
1310
+ return altResponse;
1311
+ } catch (err) {
1312
+ log.error(`[${prefix}] Agent ${agentId} execution failed: ${err}`);
1313
+ return `Sorry, I ran into an error with ${effectiveExecutor}: ${err instanceof Error ? err.message : err}`;
1314
+ }
1315
+ }
1316
+
1317
+ // ── Spawn claude ──
1318
+ const timeout = agentConfig.timeout ?? 14_400_000;
1319
+ let rawOutput: string;
1320
+
1321
+ try {
1322
+ rawOutput = await spawnClaude(args, workspace, timeout, stdinPayload, claudeConfigDir);
1323
+ } catch (err: any) {
1324
+ const errStr = String(err);
1325
+ // Detect stale session — retry with a fresh session
1326
+ if (session && isPersistent && (errStr.includes("No conversation found") || errStr.includes("exited with code 1"))) {
1327
+ log.warn(`Stale session for ${agentId} (${session.sessionId}) — retrying with fresh session`);
1328
+ try { unlinkSync(join(memoryDir, senderSessionKey ? `session-${senderSessionKey}.json` : "session.json")); } catch { /* ignore */ }
1329
+ const newId = randomUUID();
1330
+ session = { sessionId: newId, createdAt: new Date().toISOString(), messageCount: 0 };
1331
+ const retryArgs = args.filter(a => a !== "--resume" && a !== session!.sessionId)
1332
+ .filter((a, i, arr) => !(a === "--resume" && i + 1 < arr.length));
1333
+ // Remove old --resume and its value, add --session-id + --system-prompt
1334
+ const cleanArgs: string[] = [];
1335
+ for (let i = 0; i < args.length; i++) {
1336
+ if (args[i] === "--resume") { i++; continue; } // skip --resume and its value
1337
+ cleanArgs.push(args[i]);
1338
+ }
1339
+ cleanArgs.push("--session-id", newId, "--system-prompt", systemPrompt);
1340
+ try {
1341
+ rawOutput = await spawnClaude(cleanArgs, workspace, timeout, stdinPayload, claudeConfigDir);
1342
+ saveSession(memoryDir, session, senderSessionKey);
1343
+ log.info(`Fresh session ${newId} created for ${agentId}`);
1344
+ } catch (retryErr) {
1345
+ log.error(`Agent ${agentId} execution failed on retry: ${retryErr}`);
1346
+ return `Sorry, I ran into an error processing that request.`;
1347
+ }
1348
+ } else {
1349
+ log.error(`Agent ${agentId} execution failed: ${err}`);
1350
+ return `Sorry, I ran into an error processing that request.`;
1351
+ }
1352
+ } finally {
1353
+ if (mcpConfigPath) {
1354
+ try { unlinkSync(mcpConfigPath); } catch { /* ignore */ }
1355
+ }
1356
+ }
1357
+
1358
+ // ── Parse response ──
1359
+ let response: string;
1360
+ let costUsd: number | undefined;
1361
+ if (isPersistent) {
1362
+ // Parse JSON output
1363
+ try {
1364
+ const result = JSON.parse(rawOutput) as ClaudeJsonResult;
1365
+ response = result.result;
1366
+ costUsd = result.total_cost_usd;
1367
+ log.debug(`Session ${result.session_id}: cost=$${result.total_cost_usd.toFixed(4)}, duration=${result.duration_ms}ms`);
1368
+
1369
+ // Update session state
1370
+ if (session) {
1371
+ session.messageCount += 1;
1372
+ saveSession(memoryDir, session, senderSessionKey);
1373
+ }
1374
+ } catch (err) {
1375
+ // Fallback: treat as plain text if JSON parse fails
1376
+ log.warn(`Failed to parse JSON output for ${agentId}, using raw: ${err}`);
1377
+ response = rawOutput.trim();
1378
+ if (session) {
1379
+ session.messageCount += 1;
1380
+ saveSession(memoryDir, session, senderSessionKey);
1381
+ }
1382
+ }
1383
+ } else {
1384
+ response = rawOutput.trim();
1385
+ }
1386
+
1387
+ // Auto-commit if enabled
1388
+ if (agentConfig.autoCommit) {
1389
+ await autoCommit(workspace, agentId, response, agentConfig.autoCommitBranch);
1390
+ }
1391
+
1392
+ // Log to conversation history (audit trail)
1393
+ try {
1394
+ const entry = {
1395
+ ts: new Date().toISOString(),
1396
+ from: msg.sender,
1397
+ text: msg.text,
1398
+ response: response.slice(0, 2000),
1399
+ agentId,
1400
+ channel: msg.channel,
1401
+ ...(session ? { sessionId: session.sessionId, messageNum: session.messageCount } : {}),
1402
+ ...(costUsd !== undefined ? { cost: costUsd } : {}),
1403
+ };
1404
+ appendFileSync(logPath, JSON.stringify(entry) + "\n");
1405
+ } catch (err) {
1406
+ log.warn(`Failed to write conversation log: ${err}`);
1407
+ }
1408
+
1409
+ // ── Advanced memory: index this exchange ──
1410
+ if (useAdvancedMemory && memoryMgr) {
1411
+ try {
1412
+ await memoryMgr.indexExchange(msg.text, response, msg.senderName || msg.sender);
1413
+ } catch (err) {
1414
+ log.warn(`Failed to index exchange for ${agentId}: ${err}`);
1415
+ }
1416
+ }
1417
+
1418
+ return response;
1419
+ }
1420
+
1421
+ // ─── Claude process spawner ──────────────────────────────────────────
1422
+
1423
+ function spawnClaude(args: string[], cwd: string, timeout: number, stdinData?: string, claudeConfigDir?: string): Promise<string> {
1424
+ return new Promise((resolve, reject) => {
1425
+ // Remove env vars that trigger Claude Code nesting detection
1426
+ const env = { ...process.env };
1427
+ delete env.CLAUDECODE;
1428
+ delete env.CLAUDE_CODE_ENTRYPOINT;
1429
+ if (claudeConfigDir) env.CLAUDE_CONFIG_DIR = claudeConfigDir;
1430
+
1431
+ const proc = spawn("claude", args, {
1432
+ cwd,
1433
+ stdio: ["pipe", "pipe", "pipe"],
1434
+ env,
1435
+ });
1436
+
1437
+ if (stdinData && proc.stdin) {
1438
+ proc.stdin.write(stdinData);
1439
+ proc.stdin.end();
1440
+ }
1441
+
1442
+ let stdout = "";
1443
+ let stderr = "";
1444
+
1445
+ proc.stdout.on("data", (data: Buffer) => {
1446
+ stdout += data.toString();
1447
+ });
1448
+
1449
+ proc.stderr.on("data", (data: Buffer) => {
1450
+ stderr += data.toString();
1451
+ });
1452
+
1453
+ const timer = setTimeout(() => {
1454
+ proc.kill("SIGTERM");
1455
+ reject(new Error(`claude -p timed out after ${timeout}ms`));
1456
+ }, timeout);
1457
+
1458
+ proc.on("close", (code) => {
1459
+ clearTimeout(timer);
1460
+ if (code !== 0) {
1461
+ log.warn(`claude -p exited with code ${code} stderr: ${stderr.slice(0, 500)} stdout: ${stdout.slice(0, 500)}`);
1462
+ const errLower = stderr.toLowerCase();
1463
+ const isAuth = errLower.includes("not authenticated") || errLower.includes("please log in") ||
1464
+ errLower.includes("unauthorized") || errLower.includes("expired") ||
1465
+ errLower.includes("auth") || errLower.includes("login required");
1466
+ if (isAuth) {
1467
+ reject(new Error(`Claude account session has expired. Go to /settings to re-authorize.`));
1468
+ } else {
1469
+ reject(new Error(`claude -p exited with code ${code}`));
1470
+ }
1471
+ } else {
1472
+ resolve(stdout.trim());
1473
+ }
1474
+ });
1475
+
1476
+ proc.on("error", (err) => {
1477
+ clearTimeout(timer);
1478
+ reject(err);
1479
+ });
1480
+ });
1481
+ }
1482
+
1483
+ // ─── Streaming executor ──────────────────────────────────────────────
1484
+
1485
+ export interface StreamEvent {
1486
+ type: "status" | "text" | "done" | "error" | "tool";
1487
+ data: string;
1488
+ tool?: { name: string; input?: any };
1489
+ }
1490
+
1491
+ /**
1492
+ * Execute agent with streaming — yields events as Claude processes.
1493
+ * Used by Web UI (SSE) and phone channels (status messages).
1494
+ */
1495
+ export async function* executeAgentStreaming(
1496
+ route: ResolvedRoute,
1497
+ msg: InboundMessage,
1498
+ baseDir: string,
1499
+ mcpRegistry?: Record<string, McpServerConfig>,
1500
+ claudeAccounts?: Record<string, string>,
1501
+ onRawLine?: (line: string) => void,
1502
+ globalDefaults?: { skills?: string[]; mcps?: string[]; prompts?: string[]; promptTrigger?: string },
1503
+ signal?: AbortSignal,
1504
+ ): AsyncGenerator<StreamEvent> {
1505
+ const { agentId, agentConfig } = route;
1506
+ const effectiveSkills = [...new Set([...(agentConfig.skills || []), ...(globalDefaults?.skills || [])])];
1507
+ const effectiveMcps = [...new Set([...(agentConfig.mcps || []), ...(globalDefaults?.mcps || [])])];
1508
+ const effectivePrompts = [...new Set([...(agentConfig.prompts || []), ...(globalDefaults?.prompts || [])])];
1509
+ const _home = homedir();
1510
+ const expandTilde = (p: string) => p.startsWith("~") ? p.replace("~", _home) : p;
1511
+ const workspace = resolve(expandTilde(agentConfig.workspace));
1512
+ const claudeMdPath = resolve(baseDir, expandTilde(agentConfig.claudeMd));
1513
+ const memoryDir = resolve(baseDir, expandTilde(agentConfig.memoryDir));
1514
+ const contextPath = join(memoryDir, "context.md");
1515
+ const learnedPath = join(memoryDir, "learned.md");
1516
+ const logPath = join(memoryDir, "conversation_log.jsonl");
1517
+ const isPersistent = agentConfig.persistent ?? false;
1518
+ const perSender = agentConfig.perSenderSessions ?? false;
1519
+ const senderSessionKey = (isPersistent && perSender) ? msg.sender : undefined;
1520
+ const useAdvancedMemory = agentConfig.advancedMemory ?? false;
1521
+ const useWiki = agentConfig.wiki ?? false;
1522
+
1523
+ // ── Resolve Claude account config dir ──
1524
+ const home = homedir();
1525
+ let claudeConfigDir: string | undefined;
1526
+ const effectiveAccount = agentConfig.claudeAccount || (claudeAccounts as any)?._defaultAccount;
1527
+ if (effectiveAccount && claudeAccounts) {
1528
+ const dir = claudeAccounts[effectiveAccount];
1529
+ if (dir) claudeConfigDir = dir.startsWith("~") ? dir.replace("~", home) : dir;
1530
+ }
1531
+
1532
+ // ── Check for /relogin command ──
1533
+ const reloginMatch = RELOGIN_PATTERN.exec(msg.text);
1534
+ if (reloginMatch) {
1535
+ const accountName = reloginMatch[1] || effectiveAccount || "default";
1536
+ let reloginDir: string | undefined;
1537
+ if (claudeAccounts && claudeAccounts[accountName]) {
1538
+ const d = claudeAccounts[accountName];
1539
+ reloginDir = d.startsWith("~") ? d.replace("~", home) : d;
1540
+ }
1541
+ const reloginResult = handleRelogin(accountName, reloginDir);
1542
+ yield { type: "text", data: reloginResult };
1543
+ yield { type: "done", data: reloginResult };
1544
+ return;
1545
+ }
1546
+
1547
+ // ── Check for /parallel command ──
1548
+ if (PARALLEL_PATTERN.test(msg.text)) {
1549
+ const tasks = parseParallelTasks(msg.text);
1550
+ if (tasks.length === 0) {
1551
+ yield { type: "text", data: "No tasks found. Format:\n/parallel\n- task 1\n- task 2" };
1552
+ yield { type: "done", data: "" };
1553
+ return;
1554
+ }
1555
+
1556
+ yield { type: "status", data: `Spawning ${tasks.length} parallel workers...` };
1557
+
1558
+ let workerPrompt: string;
1559
+ try {
1560
+ workerPrompt = readFileSync(resolve(baseDir, expandTilde(agentConfig.claudeMd)), "utf-8");
1561
+ } catch {
1562
+ workerPrompt = `You are ${agentConfig.name}. ${agentConfig.description}`;
1563
+ }
1564
+
1565
+ const result = await executeParallel(
1566
+ tasks, agentConfig, workspace, workerPrompt, baseDir, mcpRegistry, claudeConfigDir
1567
+ );
1568
+
1569
+ try {
1570
+ appendFileSync(logPath, JSON.stringify({
1571
+ ts: new Date().toISOString(), from: msg.sender, text: msg.text,
1572
+ response: result.slice(0, 2000), agentId, channel: msg.channel,
1573
+ parallel: tasks.length,
1574
+ }) + "\n");
1575
+ } catch { /* ignore */ }
1576
+
1577
+ yield { type: "text", data: result };
1578
+ yield { type: "done", data: result };
1579
+ return;
1580
+ }
1581
+
1582
+ // ── Check for /task command ──
1583
+ if (TASK_PATTERN.test(msg.text)) {
1584
+ let allAgents: Record<string, any> = {};
1585
+ try {
1586
+ const configPath = join(baseDir, "config.json");
1587
+ const rawConfig = JSON.parse(readFileSync(configPath, "utf-8"));
1588
+ for (const [aid, ag] of Object.entries(rawConfig.agents) as any[]) {
1589
+ if (ag.agentHome) ag.agentHome = ag.agentHome.startsWith("~") ? ag.agentHome.replace("~", home) : ag.agentHome;
1590
+ if (ag.memoryDir) ag.memoryDir = ag.memoryDir.startsWith("~") ? ag.memoryDir.replace("~", home) : ag.memoryDir;
1591
+ }
1592
+ allAgents = rawConfig.agents;
1593
+ } catch { /* fallback */ }
1594
+
1595
+ const taskResp = handleTaskCommand(msg.text, agentId, agentConfig, allAgents);
1596
+ if (taskResp !== null) {
1597
+ try {
1598
+ appendFileSync(logPath, JSON.stringify({
1599
+ ts: new Date().toISOString(), from: msg.sender, text: msg.text,
1600
+ response: taskResp, agentId, channel: msg.channel,
1601
+ }) + "\n");
1602
+ } catch { /* ignore */ }
1603
+ yield { type: "text", data: taskResp };
1604
+ yield { type: "done", data: taskResp };
1605
+ return;
1606
+ }
1607
+ }
1608
+
1609
+ // Check intercepted commands
1610
+ const intercepted = handleInterceptedCommand(msg.text, agentId, memoryDir, senderSessionKey);
1611
+ if (intercepted !== null) {
1612
+ try {
1613
+ appendFileSync(logPath, JSON.stringify({
1614
+ ts: new Date().toISOString(), from: msg.sender, text: msg.text,
1615
+ response: intercepted, agentId, channel: msg.channel,
1616
+ }) + "\n");
1617
+ } catch { /* ignore */ }
1618
+ yield { type: "text", data: intercepted };
1619
+ yield { type: "done", data: intercepted };
1620
+ return;
1621
+ }
1622
+
1623
+ // Advanced memory: search for relevant context
1624
+ let memoryContext = "";
1625
+ let memoryMgr: MemoryManager | null = null;
1626
+ if (useAdvancedMemory) {
1627
+ try {
1628
+ memoryMgr = await getMemoryManager(agentId, memoryDir);
1629
+ const searchResults = await memoryMgr.searchFormatted(msg.text, 5);
1630
+ if (searchResults) memoryContext += searchResults + "\n\n";
1631
+ const daily = memoryMgr.loadDailyContext();
1632
+ if (daily) memoryContext += daily + "\n\n";
1633
+ } catch (err) {
1634
+ log.warn(`Advanced memory search failed for ${agentId}: ${err}`);
1635
+ }
1636
+ }
1637
+
1638
+ // Load system prompt
1639
+ let systemPrompt: string;
1640
+ try {
1641
+ systemPrompt = readFileSync(claudeMdPath, "utf-8");
1642
+ } catch (err) {
1643
+ yield { type: "error", data: `Could not load agent configuration for ${agentId}.` };
1644
+ return;
1645
+ }
1646
+
1647
+ // Prepend soul.md for gym agents (trainer personality layer)
1648
+ if (agentConfig.agentClass === "gym") {
1649
+ try {
1650
+ const profilePath = join(memoryDir, "learner-profile.json");
1651
+ if (existsSync(profilePath)) {
1652
+ const profile = JSON.parse(readFileSync(profilePath, "utf-8"));
1653
+ const trainer = profile.selectedTrainer;
1654
+ if (trainer) {
1655
+ const soulPath = join(memoryDir, "..", "souls", `${trainer}.md`);
1656
+ if (existsSync(soulPath)) {
1657
+ const soul = readFileSync(soulPath, "utf-8");
1658
+ systemPrompt = soul + "\n\n" + systemPrompt;
1659
+ log.info(`Gym agent: loaded soul.md for trainer "${trainer}"`);
1660
+ }
1661
+ }
1662
+ }
1663
+ } catch (err) {
1664
+ log.warn(`Failed to load gym soul.md: ${err}`);
1665
+ }
1666
+ }
1667
+
1668
+ if (isPersistent && existsSync(contextPath)) {
1669
+ try {
1670
+ const context = readFileSync(contextPath, "utf-8").trim();
1671
+ if (context) systemPrompt += `\n\n## Agent Memory\n${context}\n`;
1672
+ } catch { /* ignore */ }
1673
+ }
1674
+
1675
+ const streamOrgNames = (agentConfig.org || []).map((o: any) => o.organization).filter(Boolean);
1676
+ const streamHasOrgSkills = streamOrgNames.some((org: string) => existsSync(join(getPersonalAgentsDir(), org, "skills")));
1677
+ const hasSkills = effectiveSkills.length > 0 || (agentConfig.agentSkills?.length || 0) > 0 || streamHasOrgSkills;
1678
+ if (hasSkills) {
1679
+ systemPrompt += buildSkillIndex(effectiveSkills, agentConfig.agentSkills || [], memoryDir, streamOrgNames, baseDir);
1680
+ }
1681
+
1682
+ // ── Append prompt template index if configured ──
1683
+ if (effectivePrompts.length > 0) {
1684
+ const trigger = globalDefaults?.promptTrigger || "!";
1685
+ systemPrompt += buildPromptIndex(effectivePrompts, baseDir, trigger);
1686
+ }
1687
+
1688
+ // ── Append MCP account mapping (multi-account) ──
1689
+ {
1690
+ const agentHome = agentConfig.agentHome || resolve(memoryDir, "..");
1691
+ const resolvedHome = agentHome.startsWith("~") ? agentHome.replace("~", home) : agentHome;
1692
+ const accountsPath = join(resolvedHome, "mcp-accounts.json");
1693
+ if (existsSync(accountsPath)) {
1694
+ try {
1695
+ const accounts = JSON.parse(readFileSync(accountsPath, "utf-8")) as Record<string, { label: string; baseMcp: string; description?: string }>;
1696
+ if (Object.keys(accounts).length > 0) {
1697
+ const lines = ["\n\n## MCP Account Mapping", "You have multiple accounts connected for some services. Use the correct MCP instance based on which account the user is asking about.\n"];
1698
+ lines.push("| MCP Instance | Label | Service | Description |");
1699
+ lines.push("|---|---|---|---|");
1700
+ for (const [name, info] of Object.entries(accounts)) {
1701
+ lines.push(`| ${name} | ${info.label} | ${info.baseMcp} | ${info.description || ""} |`);
1702
+ }
1703
+ lines.push("\nWhen the user asks about a specific account (e.g., \"check my work email\"), use the matching MCP instance. If unclear, check all connected accounts.");
1704
+ systemPrompt += lines.join("\n");
1705
+ }
1706
+ } catch { /* ignore */ }
1707
+ }
1708
+ }
1709
+
1710
+ // ── Append group agent delegation (sub-agents) ──
1711
+ if (agentConfig.subAgents && _appConfig) {
1712
+ const registry = buildAgentRegistry(_appConfig, agentConfig.subAgents);
1713
+ if (registry.length > 0) {
1714
+ systemPrompt += buildGroupAgentPrompt(registry, msg.text);
1715
+ }
1716
+ }
1717
+
1718
+ // ── Append active tasks context ──
1719
+ {
1720
+ const agentHomeForTasks = agentConfig.agentHome || resolve(memoryDir, "..");
1721
+ const taskBlock = buildTaskContextBlock(agentHomeForTasks, agentId);
1722
+ if (taskBlock) systemPrompt += taskBlock;
1723
+ }
1724
+
1725
+ if (useAdvancedMemory && memoryContext) {
1726
+ systemPrompt += `\n\n${memoryContext}`;
1727
+ }
1728
+
1729
+ if (useAdvancedMemory && isPersistent && memoryMgr) {
1730
+ const currentSession = loadSession(memoryDir, senderSessionKey);
1731
+ if (currentSession) {
1732
+ const compactionPrompt = memoryMgr.getCompactionPrompt(currentSession.messageCount);
1733
+ if (compactionPrompt) systemPrompt += `\n\n${compactionPrompt}`;
1734
+ }
1735
+ }
1736
+
1737
+ if (isPersistent) {
1738
+ systemPrompt += `\n\n## Session Commands\n- When the user sends \`/opcompact\` followed by instructions, save the specified information to \`${contextPath}\` using the Write tool.\n- \`/opreset\` is handled automatically by the gateway.\n`;
1739
+ }
1740
+
1741
+ // ── Wiki learning mode ──
1742
+ if (useWiki) {
1743
+ systemPrompt += `\n\n## Wiki Learning Mode\nAfter each conversation exchange, evaluate whether you learned any NEW facts, corrections, or important insights. If you did, append them to \`${learnedPath}\` using the Write tool (read first, then append — do not overwrite).\n\nFormat: \`### [YYYY-MM-DD] — [Brief topic]\` followed by bullet points with facts and source.\n\nRules:\n- Only save genuinely NEW information not already in \`${contextPath}\` or \`${learnedPath}\`\n- Include the source (who said it)\n- Do NOT save opinions, small talk, or ephemeral info\n- When user says "update context from learned", merge verified facts from \`${learnedPath}\` into \`${contextPath}\`\n`;
1744
+ }
1745
+
1746
+ // ── Resolve prompt template trigger ──
1747
+ const promptTriggerStr = globalDefaults?.promptTrigger || "!";
1748
+ let promptInjectionStr = "";
1749
+ let streamMsgText = msg.text;
1750
+ if (effectivePrompts.length > 0 && msg.text.startsWith(promptTriggerStr)) {
1751
+ const resolved = resolvePromptTrigger(msg.text, effectivePrompts, baseDir, promptTriggerStr);
1752
+ if (resolved) {
1753
+ promptInjectionStr = `[PROMPT TEMPLATE ACTIVE]\n${resolved.promptContent}\n[END PROMPT TEMPLATE]\n\n`;
1754
+ streamMsgText = resolved.userText || msg.text.slice(promptTriggerStr.length + (msg.text.slice(promptTriggerStr.length).indexOf(" ") + 1 || 0));
1755
+ }
1756
+ }
1757
+ const effectiveStreamMsg = streamMsgText !== msg.text ? { ...msg, text: streamMsgText } : msg;
1758
+
1759
+ let formattedMessage: string;
1760
+ if (isPersistent) {
1761
+ formattedMessage = promptInjectionStr + formatMessage(effectiveStreamMsg);
1762
+ } else {
1763
+ formattedMessage = promptInjectionStr + formatMessage(
1764
+ effectiveStreamMsg,
1765
+ existsSync(contextPath) ? contextPath : undefined,
1766
+ existsSync(logPath) ? logPath : undefined,
1767
+ );
1768
+ }
1769
+
1770
+ const hasImages = msg.attachments && msg.attachments.length > 0;
1771
+ let stdinPayload: string;
1772
+ if (hasImages) {
1773
+ const contentBlocks: ContentBlock[] = [{ type: "text", text: formattedMessage }];
1774
+ for (const att of msg.attachments!) {
1775
+ try {
1776
+ const imgBuffer = readFileSync(att.path);
1777
+ const mimeType = att.mimeType || guessMimeType(att.path);
1778
+ if (imgBuffer.length > 10_000_000) continue;
1779
+ contentBlocks.push({ type: "image", source: { type: "base64", media_type: mimeType, data: imgBuffer.toString("base64") } });
1780
+ } catch { /* skip */ }
1781
+ }
1782
+ stdinPayload = JSON.stringify([{ role: "user", content: contentBlocks }]);
1783
+ } else {
1784
+ stdinPayload = formattedMessage;
1785
+ }
1786
+
1787
+ // Build args with stream-json output
1788
+ const args: string[] = ["-p", "-"];
1789
+
1790
+ // Model override (from /model command)
1791
+ const modelOverride = loadModelOverride(memoryDir);
1792
+ if (modelOverride) args.push("--model", modelOverride);
1793
+
1794
+ let session: SessionState | null = null;
1795
+ const forceNewSession = (agentConfig as any).forceNewSession ?? false;
1796
+ if (isPersistent) {
1797
+ session = forceNewSession ? null : loadSession(memoryDir, senderSessionKey);
1798
+ if (session) {
1799
+ args.push("--resume", session.sessionId);
1800
+ } else {
1801
+ const newId = randomUUID();
1802
+ session = { sessionId: newId, createdAt: new Date().toISOString(), messageCount: 0 };
1803
+ args.push("--session-id", newId);
1804
+ args.push("--system-prompt", systemPrompt);
1805
+ }
1806
+ } else {
1807
+ args.push("--system-prompt", systemPrompt);
1808
+ }
1809
+
1810
+ // Key difference: stream-json output (requires --verbose)
1811
+ // --include-partial-messages enables token-level streaming (content_block_delta events)
1812
+ args.push("--output-format", "stream-json", "--verbose", "--include-partial-messages");
1813
+ args.push("--add-dir", workspace);
1814
+
1815
+ if (effectiveSkills.length > 0 || streamHasOrgSkills) {
1816
+ const skillsDir = join(homedir(), ".claude", "commands");
1817
+ if (existsSync(skillsDir) && effectiveSkills.length) args.push("--add-dir", skillsDir);
1818
+ // Org-scoped skills dirs
1819
+ for (const orgName of streamOrgNames) {
1820
+ const orgSkillsDir = join(getPersonalAgentsDir(), orgName, "skills");
1821
+ if (existsSync(orgSkillsDir)) args.push("--add-dir", orgSkillsDir);
1822
+ }
1823
+ }
1824
+
1825
+ const allowedTools = [...agentConfig.allowedTools];
1826
+ if (effectiveMcps.length > 0) {
1827
+ for (const mcpName of effectiveMcps) allowedTools.push(`mcp__${mcpName}__*`);
1828
+ }
1829
+ if (allowedTools.length > 0) args.push("--allowedTools", allowedTools.join(","));
1830
+
1831
+ let mcpConfigPath: string | null = null;
1832
+ if (effectiveMcps.length > 0 && mcpRegistry) {
1833
+ mcpConfigPath = buildMcpConfigFile(agentId, effectiveMcps, mcpRegistry, baseDir, memoryDir);
1834
+ args.push("--mcp-config", mcpConfigPath, "--strict-mcp-config");
1835
+ }
1836
+
1837
+ if (isPersistent) args.push("--permission-mode", effectiveMcps.length ? "bypassPermissions" : "acceptEdits");
1838
+
1839
+ const timeout = agentConfig.timeout ?? 14_400_000;
1840
+
1841
+ // ── Multi-model dispatch (streaming) ──
1842
+ const multiModelEnabled = _appConfig?.service?.multiModelEnabled ?? false;
1843
+ const effectiveExecutor = agentConfig.executor
1844
+ || (multiModelEnabled ? (_appConfig?.service?.platformDefaultExecutor || "claude") : "claude");
1845
+
1846
+ if (multiModelEnabled && effectiveExecutor !== "claude" && effectiveExecutor.includes(":")) {
1847
+ const [prefix, ...rest] = effectiveExecutor.split(":");
1848
+ const modelName = rest.join(":");
1849
+
1850
+ try {
1851
+ let streamGen: AsyncGenerator<string>;
1852
+
1853
+ if (prefix === "ollama") {
1854
+ const { streamOllama } = await import("./ollama-executor.js");
1855
+ streamGen = streamOllama({
1856
+ model: modelName, systemPrompt, message: formattedMessage,
1857
+ baseUrl: _appConfig?.service?.ollamaBaseUrl || "http://localhost:11434",
1858
+ timeout: agentConfig.timeout ?? 300_000,
1859
+ });
1860
+ } else if (prefix === "gemini") {
1861
+ const providerKeys = (_appConfig?.service as any)?.providerKeys || {};
1862
+ const apiKey = providerKeys.google;
1863
+ if (!apiKey) { yield { type: "error", data: "No Google API key configured." } as StreamEvent; return; }
1864
+ const { streamGemini } = await import("./gemini-executor.js");
1865
+ streamGen = streamGemini({
1866
+ model: modelName, apiKey, systemPrompt, message: formattedMessage,
1867
+ timeout: agentConfig.timeout ?? 300_000,
1868
+ });
1869
+ } else {
1870
+ const { resolveProvider, streamOpenAICompat } = await import("./openai-executor.js");
1871
+ const provider = resolveProvider(prefix);
1872
+ if (!provider) { yield { type: "error", data: `Unknown provider "${prefix}".` } as StreamEvent; return; }
1873
+ const providerKeys = (_appConfig?.service as any)?.providerKeys || {};
1874
+ const apiKey = providerKeys[provider.keyField];
1875
+ if (!apiKey) { yield { type: "error", data: `No API key for ${provider.name}.` } as StreamEvent; return; }
1876
+ streamGen = streamOpenAICompat({
1877
+ provider: prefix, model: modelName, apiKey, systemPrompt, message: formattedMessage,
1878
+ timeout: agentConfig.timeout ?? 300_000,
1879
+ });
1880
+ }
1881
+
1882
+ let fullResponse = "";
1883
+ for await (const chunk of streamGen) {
1884
+ fullResponse += chunk;
1885
+ yield { type: "text", data: chunk } as StreamEvent;
1886
+ }
1887
+ yield { type: "done", data: fullResponse } as StreamEvent;
1888
+
1889
+ try {
1890
+ appendFileSync(logPath, JSON.stringify({
1891
+ ts: new Date().toISOString(), from: msg.sender, text: msg.text,
1892
+ response: fullResponse.slice(0, 2000), agentId, channel: msg.channel, executor: effectiveExecutor,
1893
+ }) + "\n");
1894
+ } catch { /* ignore */ }
1895
+
1896
+ if (useAdvancedMemory && memoryMgr) {
1897
+ memoryMgr.indexExchange(msg.text, fullResponse, msg.sender).catch(() => {});
1898
+ }
1899
+
1900
+ return;
1901
+ } catch (err) {
1902
+ log.error(`[${prefix}] Streaming failed for ${agentId}: ${err}`);
1903
+ yield { type: "error", data: `Error with ${effectiveExecutor}: ${err instanceof Error ? err.message : err}` } as StreamEvent;
1904
+ return;
1905
+ }
1906
+ }
1907
+
1908
+ // Spawn claude and stream output
1909
+ yield { type: "status", data: "Starting..." };
1910
+
1911
+ const env = { ...process.env };
1912
+ delete env.CLAUDECODE;
1913
+ delete env.CLAUDE_CODE_ENTRYPOINT;
1914
+ if (claudeConfigDir) env.CLAUDE_CONFIG_DIR = claudeConfigDir;
1915
+
1916
+ const proc = spawn("claude", args, { cwd: workspace, stdio: ["pipe", "pipe", "pipe"], env });
1917
+
1918
+ if (stdinPayload && proc.stdin) {
1919
+ proc.stdin.write(stdinPayload);
1920
+ proc.stdin.end();
1921
+ }
1922
+
1923
+ // Kill the child process if the abort signal fires (e.g. user clicked Stop)
1924
+ if (signal) {
1925
+ if (signal.aborted) { proc.kill("SIGTERM"); }
1926
+ else { signal.addEventListener("abort", () => proc.kill("SIGTERM"), { once: true }); }
1927
+ }
1928
+
1929
+ const timer = setTimeout(() => {
1930
+ proc.kill("SIGTERM");
1931
+ }, timeout);
1932
+
1933
+ let fullResponse = "";
1934
+ let buffer = "";
1935
+ let lastCostUsd: number | undefined;
1936
+
1937
+ // Process stream-json output line by line
1938
+ // With --include-partial-messages, events come wrapped as:
1939
+ // {"type":"stream_event","event":{"type":"content_block_delta","delta":{"type":"text_delta","text":"..."}}}
1940
+ let streamedText = false; // track if we got token-level deltas
1941
+ const pendingToolNames = new Set<string>(); // track tools already emitted from content_block_start
1942
+ const processLine = function*(line: string): Generator<StreamEvent> {
1943
+ if (!line.trim()) return;
1944
+ try {
1945
+ let event = JSON.parse(line);
1946
+
1947
+ // Unwrap stream_event wrapper from --include-partial-messages
1948
+ if (event.type === "stream_event" && event.event) {
1949
+ event = event.event;
1950
+ }
1951
+
1952
+ // Token-level streaming via content_block_delta
1953
+ if (event.type === "content_block_delta") {
1954
+ const text = event.delta?.text;
1955
+ if (text) {
1956
+ fullResponse += text;
1957
+ streamedText = true;
1958
+ yield { type: "text", data: text } as StreamEvent;
1959
+ }
1960
+ } else if (event.type === "content_block_start") {
1961
+ if (event.content_block?.type === "tool_use") {
1962
+ const toolName = event.content_block.name || "tool";
1963
+ const toolId = event.content_block.id || "";
1964
+ pendingToolNames.add(toolId);
1965
+ yield { type: "tool", data: `Using ${toolName}...`, tool: { name: toolName, input: {} } } as StreamEvent;
1966
+ }
1967
+ } else if (event.type === "assistant" && event.message?.content) {
1968
+ // Full assistant message — extract tool details with complete input
1969
+ for (const block of event.message.content) {
1970
+ if (block.type === "text" && block.text && !streamedText) {
1971
+ fullResponse += block.text;
1972
+ yield { type: "text", data: block.text } as StreamEvent;
1973
+ } else if (block.type === "tool_use" && block.name && pendingToolNames.has(block.id)) {
1974
+ // Re-emit tool event with full input (replaces the empty-input one from content_block_start)
1975
+ pendingToolNames.delete(block.id);
1976
+ yield { type: "tool", data: `Using ${block.name}...`, tool: { name: block.name, input: block.input || {} } } as StreamEvent;
1977
+ }
1978
+ }
1979
+ // Reset for next turn (after tool use)
1980
+ streamedText = false;
1981
+ } else if (event.type === "tool_use") {
1982
+ const toolName = event.tool_name || event.name || "tool";
1983
+ const toolInput = event.input || event.tool_input;
1984
+ yield { type: "tool", data: `Using ${toolName}...`, tool: { name: toolName, input: toolInput } } as StreamEvent;
1985
+ } else if (event.type === "tool_result") {
1986
+ yield { type: "status", data: "Processing result..." } as StreamEvent;
1987
+ } else if (event.type === "result") {
1988
+ // Final result event
1989
+ if (event.result && !fullResponse) {
1990
+ fullResponse = event.result;
1991
+ yield { type: "text", data: event.result } as StreamEvent;
1992
+ }
1993
+ if (typeof event.total_cost_usd === "number") lastCostUsd = event.total_cost_usd;
1994
+ if (event.session_id && session) {
1995
+ session.messageCount += 1;
1996
+ saveSession(memoryDir, session, senderSessionKey);
1997
+ }
1998
+ }
1999
+ } catch {
2000
+ // Not JSON — might be partial line, ignore
2001
+ }
2002
+ };
2003
+
2004
+ // Real-time streaming: process stdout lines as they arrive using an async queue
2005
+ const eventQueue: Array<StreamEvent | { type: "__done"; code: number | null }> = [];
2006
+ let queueResolve: (() => void) | null = null;
2007
+
2008
+ function pushEvent(event: StreamEvent | { type: "__done"; code: number | null }) {
2009
+ eventQueue.push(event);
2010
+ if (queueResolve) {
2011
+ queueResolve();
2012
+ queueResolve = null;
2013
+ }
2014
+ }
2015
+
2016
+ function waitForEvent(): Promise<void> {
2017
+ if (eventQueue.length > 0) return Promise.resolve();
2018
+ return new Promise(r => { queueResolve = r; });
2019
+ }
2020
+
2021
+ // Buffer partial lines from stdout
2022
+ let lineBuffer = "";
2023
+ proc.stdout.on("data", (data: Buffer) => {
2024
+ lineBuffer += data.toString();
2025
+ const lines = lineBuffer.split("\n");
2026
+ lineBuffer = lines.pop() || ""; // keep incomplete last line in buffer
2027
+
2028
+ for (const line of lines) {
2029
+ if (!line.trim()) continue;
2030
+ if (onRawLine) onRawLine(line);
2031
+ for (const event of processLine(line)) {
2032
+ pushEvent(event);
2033
+ }
2034
+ }
2035
+ });
2036
+
2037
+ // Also capture stderr as raw lines
2038
+ let stderrBuf = "";
2039
+ proc.stderr.on("data", (data: Buffer) => {
2040
+ const text = data.toString();
2041
+ stderrBuf += text;
2042
+ for (const line of text.split("\n")) {
2043
+ if (line.trim() && onRawLine) onRawLine(`[stderr] ${line}`);
2044
+ }
2045
+ });
2046
+
2047
+ proc.on("close", (code) => {
2048
+ clearTimeout(timer);
2049
+ // Process any remaining buffer
2050
+ if (lineBuffer.trim()) {
2051
+ for (const event of processLine(lineBuffer)) {
2052
+ pushEvent(event);
2053
+ }
2054
+ }
2055
+ pushEvent({ type: "__done", code });
2056
+ });
2057
+
2058
+ proc.on("error", () => {
2059
+ clearTimeout(timer);
2060
+ pushEvent({ type: "__done", code: 1 });
2061
+ });
2062
+
2063
+ // Consume events as they arrive — this yields in real-time
2064
+ let done = false;
2065
+ let exitCode: number | null = 0;
2066
+ while (!done) {
2067
+ await waitForEvent();
2068
+ while (eventQueue.length > 0) {
2069
+ const event = eventQueue.shift()!;
2070
+ if (event.type === "__done") {
2071
+ exitCode = (event as any).code;
2072
+ done = true;
2073
+ break;
2074
+ }
2075
+ yield event as StreamEvent;
2076
+ }
2077
+ }
2078
+
2079
+ // Clean up MCP config
2080
+ if (mcpConfigPath) {
2081
+ try { unlinkSync(mcpConfigPath); } catch { /* ignore */ }
2082
+ }
2083
+
2084
+ if (exitCode !== 0 && !fullResponse) {
2085
+ const accountName = agentConfig.claudeAccount || "default";
2086
+ const combinedErr = stderrBuf.toLowerCase();
2087
+ const isAuthError = combinedErr.includes("not authenticated") ||
2088
+ combinedErr.includes("please log in") ||
2089
+ combinedErr.includes("unauthorized") ||
2090
+ combinedErr.includes("invalid api key") ||
2091
+ combinedErr.includes("expired") ||
2092
+ combinedErr.includes("auth") ||
2093
+ combinedErr.includes("login required");
2094
+ const isStaleSession = combinedErr.includes("no conversation found") ||
2095
+ (isPersistent && session && stderrBuf.includes("exited with code 1"));
2096
+
2097
+ if (isStaleSession && session) {
2098
+ // Stale session — clear it so next message creates a fresh one
2099
+ log.warn(`Stale session for ${agentId} (${session.sessionId}) — clearing for next retry`);
2100
+ try { unlinkSync(join(memoryDir, senderSessionKey ? `session-${senderSessionKey}.json` : "session.json")); } catch { /* ignore */ }
2101
+ yield { type: "error", data: "Session expired — please send your message again." };
2102
+ return;
2103
+ }
2104
+
2105
+ if (isAuthError) {
2106
+ yield { type: "error", data: `Account "${accountName}" session has expired. Go to /settings to re-authorize this account.` };
2107
+ } else {
2108
+ yield { type: "error", data: "Agent execution failed." };
2109
+ }
2110
+ return;
2111
+ }
2112
+
2113
+ // Auto-commit
2114
+ if (agentConfig.autoCommit) {
2115
+ await autoCommit(workspace, agentId, fullResponse, agentConfig.autoCommitBranch);
2116
+ }
2117
+
2118
+ // Log
2119
+ try {
2120
+ appendFileSync(logPath, JSON.stringify({
2121
+ ts: new Date().toISOString(), from: msg.sender, text: msg.text,
2122
+ response: fullResponse.slice(0, 2000), agentId, channel: msg.channel,
2123
+ ...(session ? { sessionId: session.sessionId, messageNum: session.messageCount } : {}),
2124
+ ...(lastCostUsd !== undefined ? { cost: lastCostUsd } : {}),
2125
+ }) + "\n");
2126
+ } catch { /* ignore */ }
2127
+
2128
+ // Advanced memory: index this exchange
2129
+ if (useAdvancedMemory && memoryMgr && fullResponse) {
2130
+ try {
2131
+ await memoryMgr.indexExchange(msg.text, fullResponse, msg.senderName || msg.sender);
2132
+ } catch (err) {
2133
+ log.warn(`Failed to index exchange for ${agentId}: ${err}`);
2134
+ }
2135
+ }
2136
+
2137
+ yield { type: "done", data: fullResponse };
2138
+ }
2139
+
2140
+ // ─── Auto-commit ─────────────────────────────────────────────────────
2141
+
2142
+ async function autoCommit(
2143
+ workspace: string,
2144
+ agentId: string,
2145
+ response: string,
2146
+ branch: string,
2147
+ ): Promise<void> {
2148
+ try {
2149
+ const status = await runGit(workspace, ["status", "--porcelain"]);
2150
+ if (!status.trim()) return;
2151
+
2152
+ const summary = response.split("\n")[0].slice(0, 72);
2153
+ await runGit(workspace, ["add", "-A"]);
2154
+ await runGit(workspace, ["commit", "-m", `Agent(${agentId}): ${summary}`]);
2155
+ await runGit(workspace, ["push", "origin", branch]);
2156
+ log.info(`Auto-committed and pushed for ${agentId}`);
2157
+ } catch (err) {
2158
+ log.warn(`Auto-commit failed for ${agentId}: ${err}`);
2159
+ }
2160
+ }
2161
+
2162
+ // ─── Utilities ───────────────────────────────────────────────────────
2163
+
2164
+ function guessMimeType(path: string): string {
2165
+ const ext = path.split(".").pop()?.toLowerCase();
2166
+ const map: Record<string, string> = {
2167
+ png: "image/png",
2168
+ jpg: "image/jpeg",
2169
+ jpeg: "image/jpeg",
2170
+ gif: "image/gif",
2171
+ webp: "image/webp",
2172
+ heic: "image/heic",
2173
+ };
2174
+ return map[ext || ""] || "image/png";
2175
+ }
2176
+
2177
+ function runGit(cwd: string, args: string[]): Promise<string> {
2178
+ return new Promise((resolve, reject) => {
2179
+ const proc = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
2180
+ let stdout = "";
2181
+ proc.stdout.on("data", (data: Buffer) => {
2182
+ stdout += data.toString();
2183
+ });
2184
+ proc.on("close", (code) => {
2185
+ if (code !== 0) reject(new Error(`git ${args[0]} failed (code ${code})`));
2186
+ else resolve(stdout);
2187
+ });
2188
+ proc.on("error", reject);
2189
+ });
2190
+ }