@suzuke/agend 0.0.1 → 1.0.2

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 (157) hide show
  1. package/README.md +78 -0
  2. package/README.zh-TW.md +79 -0
  3. package/dist/access-path.d.ts +7 -0
  4. package/dist/access-path.js +12 -0
  5. package/dist/access-path.js.map +1 -0
  6. package/dist/backend/claude-code.d.ts +13 -0
  7. package/dist/backend/claude-code.js +114 -0
  8. package/dist/backend/claude-code.js.map +1 -0
  9. package/dist/backend/codex.d.ts +10 -0
  10. package/dist/backend/codex.js +58 -0
  11. package/dist/backend/codex.js.map +1 -0
  12. package/dist/backend/factory.d.ts +2 -0
  13. package/dist/backend/factory.js +19 -0
  14. package/dist/backend/factory.js.map +1 -0
  15. package/dist/backend/gemini-cli.d.ts +10 -0
  16. package/dist/backend/gemini-cli.js +68 -0
  17. package/dist/backend/gemini-cli.js.map +1 -0
  18. package/dist/backend/index.d.ts +6 -0
  19. package/dist/backend/index.js +6 -0
  20. package/dist/backend/index.js.map +1 -0
  21. package/dist/backend/opencode.d.ts +10 -0
  22. package/dist/backend/opencode.js +63 -0
  23. package/dist/backend/opencode.js.map +1 -0
  24. package/dist/backend/types.d.ts +26 -0
  25. package/dist/backend/types.js +2 -0
  26. package/dist/backend/types.js.map +1 -0
  27. package/dist/channel/access-manager.d.ts +18 -0
  28. package/dist/channel/access-manager.js +149 -0
  29. package/dist/channel/access-manager.js.map +1 -0
  30. package/dist/channel/adapters/discord.d.ts +45 -0
  31. package/dist/channel/adapters/discord.js +366 -0
  32. package/dist/channel/adapters/discord.js.map +1 -0
  33. package/dist/channel/adapters/telegram.d.ts +58 -0
  34. package/dist/channel/adapters/telegram.js +569 -0
  35. package/dist/channel/adapters/telegram.js.map +1 -0
  36. package/dist/channel/attachment-handler.d.ts +15 -0
  37. package/dist/channel/attachment-handler.js +55 -0
  38. package/dist/channel/attachment-handler.js.map +1 -0
  39. package/dist/channel/factory.d.ts +12 -0
  40. package/dist/channel/factory.js +38 -0
  41. package/dist/channel/factory.js.map +1 -0
  42. package/dist/channel/ipc-bridge.d.ts +26 -0
  43. package/dist/channel/ipc-bridge.js +170 -0
  44. package/dist/channel/ipc-bridge.js.map +1 -0
  45. package/dist/channel/mcp-server.d.ts +10 -0
  46. package/dist/channel/mcp-server.js +196 -0
  47. package/dist/channel/mcp-server.js.map +1 -0
  48. package/dist/channel/mcp-tools.d.ts +909 -0
  49. package/dist/channel/mcp-tools.js +346 -0
  50. package/dist/channel/mcp-tools.js.map +1 -0
  51. package/dist/channel/message-bus.d.ts +17 -0
  52. package/dist/channel/message-bus.js +86 -0
  53. package/dist/channel/message-bus.js.map +1 -0
  54. package/dist/channel/message-queue.d.ts +39 -0
  55. package/dist/channel/message-queue.js +248 -0
  56. package/dist/channel/message-queue.js.map +1 -0
  57. package/dist/channel/tool-router.d.ts +6 -0
  58. package/dist/channel/tool-router.js +69 -0
  59. package/dist/channel/tool-router.js.map +1 -0
  60. package/dist/channel/tool-tracker.d.ts +13 -0
  61. package/dist/channel/tool-tracker.js +58 -0
  62. package/dist/channel/tool-tracker.js.map +1 -0
  63. package/dist/channel/types.d.ts +116 -0
  64. package/dist/channel/types.js +2 -0
  65. package/dist/channel/types.js.map +1 -0
  66. package/dist/cli.d.ts +2 -0
  67. package/dist/cli.js +782 -0
  68. package/dist/cli.js.map +1 -0
  69. package/dist/config.d.ts +8 -0
  70. package/dist/config.js +85 -0
  71. package/dist/config.js.map +1 -0
  72. package/dist/context-guardian.d.ts +29 -0
  73. package/dist/context-guardian.js +123 -0
  74. package/dist/context-guardian.js.map +1 -0
  75. package/dist/cost-guard.d.ts +21 -0
  76. package/dist/cost-guard.js +113 -0
  77. package/dist/cost-guard.js.map +1 -0
  78. package/dist/daemon-entry.d.ts +1 -0
  79. package/dist/daemon-entry.js +29 -0
  80. package/dist/daemon-entry.js.map +1 -0
  81. package/dist/daemon.d.ts +88 -0
  82. package/dist/daemon.js +821 -0
  83. package/dist/daemon.js.map +1 -0
  84. package/dist/daily-summary.d.ts +13 -0
  85. package/dist/daily-summary.js +55 -0
  86. package/dist/daily-summary.js.map +1 -0
  87. package/dist/event-log.d.ts +22 -0
  88. package/dist/event-log.js +66 -0
  89. package/dist/event-log.js.map +1 -0
  90. package/dist/export-import.d.ts +2 -0
  91. package/dist/export-import.js +110 -0
  92. package/dist/export-import.js.map +1 -0
  93. package/dist/fleet-context.d.ts +36 -0
  94. package/dist/fleet-context.js +4 -0
  95. package/dist/fleet-context.js.map +1 -0
  96. package/dist/fleet-manager.d.ts +115 -0
  97. package/dist/fleet-manager.js +1739 -0
  98. package/dist/fleet-manager.js.map +1 -0
  99. package/dist/fleet-system-prompt.d.ts +11 -0
  100. package/dist/fleet-system-prompt.js +60 -0
  101. package/dist/fleet-system-prompt.js.map +1 -0
  102. package/dist/hang-detector.d.ts +16 -0
  103. package/dist/hang-detector.js +53 -0
  104. package/dist/hang-detector.js.map +1 -0
  105. package/dist/index.d.ts +8 -0
  106. package/dist/index.js +6 -0
  107. package/dist/index.js.map +1 -0
  108. package/dist/logger.d.ts +3 -0
  109. package/dist/logger.js +63 -0
  110. package/dist/logger.js.map +1 -0
  111. package/dist/plugin/agend/.claude-plugin/plugin.json +5 -0
  112. package/dist/scheduler/db.d.ts +16 -0
  113. package/dist/scheduler/db.js +132 -0
  114. package/dist/scheduler/db.js.map +1 -0
  115. package/dist/scheduler/db.test.d.ts +1 -0
  116. package/dist/scheduler/db.test.js +92 -0
  117. package/dist/scheduler/db.test.js.map +1 -0
  118. package/dist/scheduler/index.d.ts +4 -0
  119. package/dist/scheduler/index.js +4 -0
  120. package/dist/scheduler/index.js.map +1 -0
  121. package/dist/scheduler/scheduler.d.ts +25 -0
  122. package/dist/scheduler/scheduler.js +119 -0
  123. package/dist/scheduler/scheduler.js.map +1 -0
  124. package/dist/scheduler/scheduler.test.d.ts +1 -0
  125. package/dist/scheduler/scheduler.test.js +119 -0
  126. package/dist/scheduler/scheduler.test.js.map +1 -0
  127. package/dist/scheduler/types.d.ts +47 -0
  128. package/dist/scheduler/types.js +7 -0
  129. package/dist/scheduler/types.js.map +1 -0
  130. package/dist/service-installer.d.ts +14 -0
  131. package/dist/service-installer.js +91 -0
  132. package/dist/service-installer.js.map +1 -0
  133. package/dist/setup-wizard.d.ts +14 -0
  134. package/dist/setup-wizard.js +517 -0
  135. package/dist/setup-wizard.js.map +1 -0
  136. package/dist/stt.d.ts +10 -0
  137. package/dist/stt.js +33 -0
  138. package/dist/stt.js.map +1 -0
  139. package/dist/tmux-manager.d.ts +22 -0
  140. package/dist/tmux-manager.js +132 -0
  141. package/dist/tmux-manager.js.map +1 -0
  142. package/dist/topic-commands.d.ts +22 -0
  143. package/dist/topic-commands.js +176 -0
  144. package/dist/topic-commands.js.map +1 -0
  145. package/dist/transcript-monitor.d.ts +21 -0
  146. package/dist/transcript-monitor.js +149 -0
  147. package/dist/transcript-monitor.js.map +1 -0
  148. package/dist/types.d.ts +153 -0
  149. package/dist/types.js +2 -0
  150. package/dist/types.js.map +1 -0
  151. package/dist/webhook-emitter.d.ts +15 -0
  152. package/dist/webhook-emitter.js +41 -0
  153. package/dist/webhook-emitter.js.map +1 -0
  154. package/package.json +58 -4
  155. package/templates/launchd.plist.ejs +29 -0
  156. package/templates/systemd.service.ejs +15 -0
  157. package/index.js +0 -1
package/dist/daemon.js ADDED
@@ -0,0 +1,821 @@
1
+ import { join, dirname } from "node:path";
2
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { EventEmitter } from "node:events";
5
+ import { createLogger } from "./logger.js";
6
+ import { TmuxManager } from "./tmux-manager.js";
7
+ import { TranscriptMonitor } from "./transcript-monitor.js";
8
+ import { ContextGuardian } from "./context-guardian.js";
9
+ import { IpcServer } from "./channel/ipc-bridge.js";
10
+ import { MessageBus } from "./channel/message-bus.js";
11
+ import { routeToolCall } from "./channel/tool-router.js";
12
+ import { generateFleetSystemPrompt } from "./fleet-system-prompt.js";
13
+ import { HangDetector } from "./hang-detector.js";
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = dirname(__filename);
16
+ export class Daemon extends EventEmitter {
17
+ name;
18
+ config;
19
+ instanceDir;
20
+ topicMode;
21
+ backend;
22
+ logger;
23
+ tmux = null;
24
+ ipcServer = null;
25
+ messageBus;
26
+ transcriptMonitor = null;
27
+ toolTracker = null;
28
+ guardian = null;
29
+ adapter = null;
30
+ pendingIpcRequests = new Map();
31
+ // Track chatId/threadId from inbound messages for automatic outbound routing
32
+ lastChatId;
33
+ lastThreadId;
34
+ // Pending ack: react 🫡 on first transcript activity after receiving a message
35
+ pendingAckMessage = null;
36
+ // Tool status tracking for Telegram
37
+ toolStatusMessageId = null;
38
+ toolStatusLines = [];
39
+ toolStatusDebounce = null;
40
+ // Session identity: map IPC socket → sessionName (from mcp_ready)
41
+ socketSessionNames = new Map();
42
+ // Crash recovery
43
+ healthCheckTimer = null;
44
+ crashCount = 0;
45
+ lastCrashAt = 0;
46
+ lastSpawnAt = 0;
47
+ rapidCrashCount = 0;
48
+ healthCheckPaused = false;
49
+ spawning = false;
50
+ // Context rotation quality tracking
51
+ rotationStartedAt = 0;
52
+ preRotationContextPct = 0;
53
+ hangDetector = null;
54
+ // Model failover: override model on next spawn when rate-limited
55
+ modelOverride;
56
+ // Context rotation v3: ring buffers for daemon-side snapshot
57
+ recentUserMessages = [];
58
+ recentEvents = [];
59
+ recentToolActivity = [];
60
+ constructor(name, config, instanceDir, topicMode = false, backend) {
61
+ super();
62
+ this.name = name;
63
+ this.config = config;
64
+ this.instanceDir = instanceDir;
65
+ this.topicMode = topicMode;
66
+ this.backend = backend;
67
+ this.logger = createLogger(config.log_level);
68
+ this.messageBus = new MessageBus();
69
+ this.messageBus.setLogger(this.logger);
70
+ }
71
+ async start() {
72
+ mkdirSync(this.instanceDir, { recursive: true });
73
+ writeFileSync(join(this.instanceDir, "daemon.pid"), String(process.pid));
74
+ this.logger.info(`Starting ${this.name}`);
75
+ // 1. IPC server — bridge between MCP server (Claude's child) and daemon
76
+ const sockPath = join(this.instanceDir, "channel.sock");
77
+ this.ipcServer = new IpcServer(sockPath, this.logger);
78
+ await this.ipcServer.listen();
79
+ // Permanent IPC dispatcher: routes responses to pending requests by type+id key
80
+ this.ipcServer.on("message", (msg) => {
81
+ const type = msg.type;
82
+ if (!type)
83
+ return;
84
+ // Build lookup key matching the pattern used when registering
85
+ let key;
86
+ if ((type === "fleet_schedule_response" || type === "fleet_outbound_response") && msg.fleetRequestId) {
87
+ key = String(msg.fleetRequestId);
88
+ }
89
+ else if (type === "fleet_outbound_response" && msg.requestId != null) {
90
+ key = `fleet_out_${msg.requestId}`;
91
+ }
92
+ if (key && this.pendingIpcRequests.has(key)) {
93
+ const handler = this.pendingIpcRequests.get(key);
94
+ this.pendingIpcRequests.delete(key);
95
+ handler(msg);
96
+ }
97
+ });
98
+ // IPC message relay: when daemon wants to push a channel message to Claude,
99
+ // it broadcasts to all IPC clients (the MCP server is one of them).
100
+ // When MCP server sends a tool_call, daemon handles it via the messageBus.
101
+ this.ipcServer.on("message", (msg, socket) => {
102
+ if (msg.type === "tool_call") {
103
+ // MCP server forwarding a Claude tool call (reply, react, edit, download)
104
+ this.handleToolCall(msg, socket);
105
+ }
106
+ else if (msg.type === "mcp_ready") {
107
+ const sessionName = msg.sessionName;
108
+ if (sessionName) {
109
+ this.socketSessionNames.set(socket, sessionName);
110
+ socket.on("close", () => {
111
+ this.socketSessionNames.delete(socket);
112
+ // Notify fleet manager so it can clean up sessionRegistry
113
+ if (sessionName !== this.name) {
114
+ this.ipcServer?.broadcast({ type: "session_disconnected", sessionName });
115
+ }
116
+ });
117
+ }
118
+ this.logger.debug({ sessionName }, "MCP channel server connected and ready");
119
+ // Notify FleetManager's IPC client that MCP is ready
120
+ this.ipcServer?.broadcast({ type: "mcp_ready", sessionName });
121
+ }
122
+ else if (msg.type === "query_sessions") {
123
+ // Fleet manager asks for all registered session names (catches sessions
124
+ // that sent mcp_ready before fleet manager connected).
125
+ const sessions = [];
126
+ for (const [s, sessionName] of this.socketSessionNames) {
127
+ if (!s.destroyed && sessionName !== this.name) {
128
+ // Individual mcp_ready for initial registration path
129
+ this.ipcServer?.send(socket, { type: "mcp_ready", sessionName });
130
+ sessions.push(sessionName);
131
+ }
132
+ }
133
+ // Batch response for prune path
134
+ this.ipcServer?.send(socket, { type: "query_sessions_response", sessions });
135
+ }
136
+ else if (msg.type === "fleet_inbound") {
137
+ // Fleet manager routed a message to us (topic mode)
138
+ const meta = msg.meta;
139
+ const targetSession = msg.targetSession;
140
+ // Only update lastChatId/lastThreadId from real Telegram messages (non-empty chat_id).
141
+ // Cross-instance messages have empty chat_id and must not overwrite these.
142
+ if (meta.chat_id)
143
+ this.lastChatId = meta.chat_id;
144
+ if (meta.chat_id && meta.thread_id)
145
+ this.lastThreadId = meta.thread_id;
146
+ this.pushChannelMessage(msg.content, meta, targetSession);
147
+ }
148
+ else if (msg.type === "fleet_schedule_trigger") {
149
+ const payload = msg.payload;
150
+ const meta = msg.meta;
151
+ this.lastChatId = meta.chat_id;
152
+ this.lastThreadId = meta.thread_id;
153
+ this.pushChannelMessage(payload.message, meta);
154
+ }
155
+ else if (msg.type === "fleet_tool_status_ack") {
156
+ // Fleet manager sent us the messageId for our tool status message
157
+ this.toolStatusMessageId = msg.messageId;
158
+ }
159
+ });
160
+ // 2. Tmux — ensure session, create window if not alive
161
+ const sessionName = "agend";
162
+ await TmuxManager.ensureSession(sessionName);
163
+ this.tmux = new TmuxManager(sessionName, "");
164
+ // Strategy A: always start fresh Claude window (MCP server has no reconnection)
165
+ // Kill any existing window from previous run
166
+ const windowIdFile = join(this.instanceDir, "window-id");
167
+ if (existsSync(windowIdFile)) {
168
+ const savedId = readFileSync(windowIdFile, "utf-8").trim();
169
+ if (savedId) {
170
+ const oldTmux = new TmuxManager(sessionName, savedId);
171
+ if (await oldTmux.isWindowAlive()) {
172
+ this.saveSessionId();
173
+ await oldTmux.killWindow();
174
+ this.logger.info({ savedId }, "Killed old tmux window for fresh start");
175
+ }
176
+ }
177
+ }
178
+ await this.spawnClaudeWindow();
179
+ if (!this.config.lightweight) {
180
+ // 3. Pipe-pane for prompt detection
181
+ const outputLog = join(this.instanceDir, "output.log");
182
+ await this.tmux.pipeOutput(outputLog).catch(() => { });
183
+ // 4. Transcript monitor
184
+ this.transcriptMonitor = new TranscriptMonitor(this.instanceDir, this.logger);
185
+ // 5. Wire transcript events
186
+ const ackIfPending = () => {
187
+ if (!this.pendingAckMessage || !this.adapter)
188
+ return;
189
+ const { chatId, messageId } = this.pendingAckMessage;
190
+ this.pendingAckMessage = null;
191
+ this.adapter.react(chatId, messageId, "🫡")
192
+ .catch(e => this.logger.debug({ err: e.message }, "Ack react failed"));
193
+ };
194
+ this.transcriptMonitor.on("tool_use", (name, input) => {
195
+ this.logger.debug({ tool: name }, "Tool use");
196
+ ackIfPending();
197
+ this.hangDetector?.recordActivity();
198
+ this.recordRecentEvent({ type: "tool_use", name, preview: this.summarizeTool(name, input) });
199
+ this.recordRecentToolActivity(this.summarizeTool(name, input));
200
+ });
201
+ this.transcriptMonitor.on("tool_result", (name, _output) => {
202
+ this.hangDetector?.recordActivity();
203
+ this.recordRecentEvent({ type: "tool_result", name });
204
+ });
205
+ this.transcriptMonitor.on("assistant_text", (text) => {
206
+ this.logger.debug({ text: text.slice(0, 200) }, "Claude response");
207
+ ackIfPending();
208
+ this.hangDetector?.recordActivity();
209
+ this.recordRecentEvent({ type: "assistant_text", preview: text.slice(0, 100) });
210
+ });
211
+ this.transcriptMonitor.startPolling();
212
+ // Hang detector
213
+ this.hangDetector = new HangDetector(15);
214
+ this.hangDetector.start();
215
+ // 8. Context guardian
216
+ const statusFile = join(this.instanceDir, "statusline.json");
217
+ this.guardian = new ContextGuardian(this.config.context_guardian, this.logger, statusFile);
218
+ this.guardian.startWatching();
219
+ this.guardian.startTimer();
220
+ this.guardian.on("status_update", () => {
221
+ this.saveSessionId();
222
+ this.hangDetector?.recordStatuslineUpdate();
223
+ });
224
+ // v3: daemon-driven restart — no handover prompt, no validation
225
+ this.guardian.on("restart_requested", async (reason) => {
226
+ this.rotationStartedAt = Date.now();
227
+ this.preRotationContextPct = this.readContextPercentage();
228
+ this.logger.info({ reason, context_pct: this.preRotationContextPct }, "Restart requested");
229
+ // Minimal idle barrier: let current step settle (best-effort, not a handover wait)
230
+ await this.waitForIdle(5000);
231
+ // Collect and write daemon-side snapshot
232
+ const snapshot = this.writeRotationSnapshot(reason);
233
+ // Save session id, kill and respawn
234
+ this.saveSessionId();
235
+ await this.tmux?.killWindow();
236
+ this.transcriptMonitor?.resetOffset();
237
+ // Clear ring buffers for new session
238
+ this.recentUserMessages = [];
239
+ this.recentEvents = [];
240
+ this.recentToolActivity = [];
241
+ await this.spawnClaudeWindow();
242
+ // Track restart metrics
243
+ const durationMs = Date.now() - this.rotationStartedAt;
244
+ this.emit("restart_complete", {
245
+ instance: this.name,
246
+ reason,
247
+ pre_restart_context_pct: this.preRotationContextPct,
248
+ restart_duration_ms: durationMs,
249
+ snapshot_user_message_count: snapshot.recent_user_messages?.length ?? 0,
250
+ snapshot_event_count: snapshot.recent_events?.length ?? 0,
251
+ });
252
+ this.guardian?.markRestartComplete();
253
+ this.logger.info({ reason, duration_ms: durationMs }, "Restart complete — fresh Claude session started");
254
+ });
255
+ }
256
+ // Set AGEND_SOCKET_PATH env for MCP server
257
+ process.env.AGEND_SOCKET_PATH = sockPath;
258
+ // 10. Health check — detect crashed tmux window and respawn
259
+ if (!this.config.lightweight) {
260
+ // Health check disabled — Claude Code handles its own crash recovery.
261
+ // The daemon-level respawn was causing orphan tmux windows and stale
262
+ // window-id mismatches. If the CLI exits, it stays down until the
263
+ // next fleet restart or manual intervention.
264
+ }
265
+ this.logger.info(`${this.name} ready`);
266
+ }
267
+ startHealthCheck() {
268
+ const { max_retries, backoff, reset_after } = this.config.restart_policy;
269
+ if (max_retries <= 0)
270
+ return; // restart disabled
271
+ const scheduleNext = () => {
272
+ this.healthCheckTimer = setTimeout(async () => {
273
+ if (!this.tmux || this.guardian?.state === "RESTARTING" || this.spawning || this.healthCheckPaused) {
274
+ scheduleNext();
275
+ return;
276
+ }
277
+ const alive = await this.tmux.isWindowAlive();
278
+ if (alive) {
279
+ scheduleNext();
280
+ return;
281
+ }
282
+ // Detect rapid crash: window died within 60s of spawn
283
+ if (this.lastSpawnAt > 0 && Date.now() - this.lastSpawnAt < 60_000) {
284
+ this.rapidCrashCount++;
285
+ }
286
+ else {
287
+ this.rapidCrashCount = 0;
288
+ }
289
+ if (this.rapidCrashCount >= 3) {
290
+ this.healthCheckPaused = true;
291
+ this.logger.error({ rapidCrashCount: this.rapidCrashCount }, "Claude keeps crashing shortly after launch (possible rate limit) — pausing respawn");
292
+ this.emit("crash_loop", this.name);
293
+ return; // don't schedule next — paused
294
+ }
295
+ // Reset crash count if enough time has passed
296
+ if (reset_after > 0 && Date.now() - this.lastCrashAt > reset_after) {
297
+ this.crashCount = 0;
298
+ }
299
+ this.crashCount++;
300
+ this.lastCrashAt = Date.now();
301
+ if (this.crashCount > max_retries) {
302
+ this.logger.error({ crashCount: this.crashCount, maxRetries: max_retries }, "Max crash retries exceeded — not respawning");
303
+ return; // don't schedule next — given up
304
+ }
305
+ // Calculate backoff delay
306
+ const delay = backoff === "exponential"
307
+ ? Math.min(1000 * Math.pow(2, this.crashCount - 1), 60_000)
308
+ : 1000 * this.crashCount;
309
+ this.logger.warn({ crashCount: this.crashCount, delay }, "Claude window died — respawning after backoff");
310
+ await new Promise(r => setTimeout(r, delay));
311
+ try {
312
+ this.saveSessionId();
313
+ this.transcriptMonitor?.resetOffset();
314
+ // Clear stale session-id so respawn doesn't --resume a dead session
315
+ const sidFile = join(this.instanceDir, "session-id");
316
+ try {
317
+ unlinkSync(sidFile);
318
+ }
319
+ catch { /* may not exist */ }
320
+ // Kill any same-name windows before respawn to prevent orphans
321
+ const windows = await TmuxManager.listWindows("agend");
322
+ for (const w of windows) {
323
+ if (w.name === this.name) {
324
+ const tm = new TmuxManager("agend", w.id);
325
+ await tm.killWindow();
326
+ }
327
+ }
328
+ await this.spawnClaudeWindow();
329
+ this.logger.info("Respawned Claude window after crash");
330
+ }
331
+ catch (err) {
332
+ this.logger.error({ err }, "Failed to respawn Claude window");
333
+ }
334
+ scheduleNext();
335
+ }, 30_000);
336
+ };
337
+ scheduleNext();
338
+ }
339
+ async stop() {
340
+ this.logger.info("Stopping daemon instance");
341
+ if (this.healthCheckTimer) {
342
+ clearTimeout(this.healthCheckTimer);
343
+ this.healthCheckTimer = null;
344
+ }
345
+ if (this.toolStatusDebounce) {
346
+ clearTimeout(this.toolStatusDebounce);
347
+ this.toolStatusDebounce = null;
348
+ }
349
+ this.pendingIpcRequests.clear();
350
+ this.hangDetector?.stop();
351
+ this.transcriptMonitor?.stop();
352
+ this.guardian?.stop();
353
+ if (this.adapter)
354
+ await this.adapter.stop();
355
+ await this.ipcServer?.close();
356
+ // Strategy A: kill window on stop, resume via --resume on next start
357
+ // MCP server has no reconnection → keeping window alive would leave
358
+ // Claude without channel/approval connectivity
359
+ if (this.tmux) {
360
+ this.saveSessionId();
361
+ await this.tmux.killWindow();
362
+ const windowIdFile = join(this.instanceDir, "window-id");
363
+ try {
364
+ unlinkSync(windowIdFile);
365
+ }
366
+ catch (e) {
367
+ this.logger.debug({ err: e }, "Failed to remove window-id file");
368
+ }
369
+ }
370
+ // Clean up backend config files
371
+ if (this.backend?.cleanup) {
372
+ this.backend.cleanup(this.buildBackendConfig());
373
+ }
374
+ const pidPath = join(this.instanceDir, "daemon.pid");
375
+ try {
376
+ unlinkSync(pidPath);
377
+ }
378
+ catch (e) {
379
+ this.logger.debug({ err: e }, "Failed to remove PID file");
380
+ }
381
+ }
382
+ getHangDetector() {
383
+ return this.hangDetector;
384
+ }
385
+ getMessageBus() {
386
+ return this.messageBus;
387
+ }
388
+ // ── Tool status tracking ──────────────────────────────────────
389
+ summarizeTool(name, input) {
390
+ const inp = input;
391
+ if (!inp)
392
+ return name;
393
+ if (name === "Read")
394
+ return `Read ${inp.file_path ?? ""}`;
395
+ if (name === "Edit")
396
+ return `Edit ${inp.file_path ?? ""}`;
397
+ if (name === "Write")
398
+ return `Write ${inp.file_path ?? ""}`;
399
+ if (name === "Bash")
400
+ return `$ ${String(inp.command ?? "").slice(0, 50)}`;
401
+ if (name === "Glob")
402
+ return `Glob ${inp.pattern ?? ""}`;
403
+ if (name === "Grep")
404
+ return `Grep ${inp.pattern ?? ""}`;
405
+ if (name === "Agent")
406
+ return "Agent (subagent)";
407
+ if (name.startsWith("mcp__agend__"))
408
+ return ""; // skip channel tools
409
+ return name;
410
+ }
411
+ addToolStatus(name, input, state) {
412
+ const summary = this.summarizeTool(name, input);
413
+ if (!summary)
414
+ return; // skip empty (e.g., channel tools)
415
+ if (state === "running") {
416
+ this.toolStatusLines.push(`⏳ ${summary}`);
417
+ }
418
+ else {
419
+ // Mark the last matching tool as done
420
+ for (let i = this.toolStatusLines.length - 1; i >= 0; i--) {
421
+ if (this.toolStatusLines[i].includes(name) && this.toolStatusLines[i].startsWith("⏳")) {
422
+ this.toolStatusLines[i] = this.toolStatusLines[i].replace("⏳", "✅");
423
+ break;
424
+ }
425
+ }
426
+ }
427
+ this.debouncedSendToolStatus();
428
+ }
429
+ /** Debounce tool status updates to avoid Telegram rate limits */
430
+ debouncedSendToolStatus() {
431
+ if (this.toolStatusDebounce)
432
+ clearTimeout(this.toolStatusDebounce);
433
+ this.toolStatusDebounce = setTimeout(() => this.sendToolStatus(), 500);
434
+ }
435
+ sendToolStatus() {
436
+ const text = this.toolStatusLines.join("\n");
437
+ if (!text)
438
+ return;
439
+ this.ipcServer?.broadcast({
440
+ type: "fleet_tool_status",
441
+ instanceName: this.name,
442
+ text,
443
+ editMessageId: this.toolStatusMessageId,
444
+ });
445
+ }
446
+ /** Called by fleet manager when tool status message is sent (returns messageId) */
447
+ setToolStatusMessageId(messageId) {
448
+ this.toolStatusMessageId = messageId;
449
+ }
450
+ /**
451
+ * Push an inbound channel message to a specific MCP session.
452
+ * If targetSession is provided, only send to the matching socket.
453
+ * Otherwise send to the instance's own session (this.name).
454
+ */
455
+ pushChannelMessage(content, meta, _targetSession) {
456
+ if (!this.tmux) {
457
+ this.logger.warn("Cannot push channel message: tmux not running");
458
+ return;
459
+ }
460
+ this.hangDetector?.recordInbound();
461
+ // v3: record user messages for rotation snapshot
462
+ this.recordRecentUserMessage(content, meta);
463
+ // Format message with metadata prefix for the agent
464
+ const user = meta.user || "unknown";
465
+ const fromInstance = meta.from_instance;
466
+ let formatted;
467
+ if (fromInstance) {
468
+ formatted = `[from:${fromInstance}] ${content}\n(Reply using send_to_instance tool, NOT direct text)`;
469
+ }
470
+ else {
471
+ const chatId = meta.chat_id || "";
472
+ const threadId = meta.thread_id || "";
473
+ formatted = `[user:${user} chat_id:${chatId} thread_id:${threadId}] ${content}\n(Reply using the reply tool with chat_id="${chatId}" — do NOT respond with direct text)`;
474
+ }
475
+ this.tmux.pasteText(formatted).catch(async (err) => {
476
+ // Window ID may be stale after crash/respawn — try to find by name
477
+ this.logger.warn({ err }, "pasteText failed, looking up window by name");
478
+ try {
479
+ const windows = await TmuxManager.listWindows("agend");
480
+ const match = windows.find(w => w.name === this.name);
481
+ if (match) {
482
+ this.tmux = new TmuxManager("agend", match.id);
483
+ writeFileSync(join(this.instanceDir, "window-id"), match.id);
484
+ await this.tmux.pasteText(formatted);
485
+ this.logger.info({ windowId: match.id }, "Recovered window ID and delivered message");
486
+ }
487
+ }
488
+ catch (retryErr) {
489
+ this.logger.error({ err: retryErr }, "Failed to recover window for message delivery");
490
+ }
491
+ });
492
+ this.logger.debug({ user: meta.user, text: content.slice(0, 100) }, "Pushed channel message via tmux");
493
+ }
494
+ /** Find the IPC socket for a given sessionName */
495
+ findSocketBySession(sessionName) {
496
+ for (const [socket, name] of this.socketSessionNames) {
497
+ if (name === sessionName && !socket.destroyed)
498
+ return socket;
499
+ }
500
+ return undefined;
501
+ }
502
+ /**
503
+ * Handle a tool call from the MCP server (forwarded by Claude).
504
+ * Routes to the channel adapter via MessageBus.
505
+ */
506
+ handleToolCall(msg, socket) {
507
+ const tool = msg.tool;
508
+ const args = (msg.args ?? {});
509
+ const requestId = msg.requestId;
510
+ this.logger.debug({ tool, requestId }, "Tool call from MCP server");
511
+ // For now, log and respond. Full adapter routing will be wired in fleet manager.
512
+ const respond = (result, error) => {
513
+ this.ipcServer?.send(socket, { requestId, result, error });
514
+ };
515
+ // Schedule tools → route to fleet manager
516
+ const CROSS_INSTANCE_TOOLS = new Set(["send_to_instance", "list_instances", "start_instance", "create_instance", "delete_instance", "request_information", "delegate_task", "report_result", "describe_instance"]);
517
+ const SCHEDULE_TOOLS = new Set(["create_schedule", "list_schedules", "update_schedule", "delete_schedule"]);
518
+ if (SCHEDULE_TOOLS.has(tool)) {
519
+ const typeMap = {
520
+ create_schedule: "fleet_schedule_create",
521
+ list_schedules: "fleet_schedule_list",
522
+ update_schedule: "fleet_schedule_update",
523
+ delete_schedule: "fleet_schedule_delete",
524
+ };
525
+ // Use fleetRequestId (not requestId) to avoid MCP server resolving the
526
+ // pending tool call prematurely when it receives the broadcast.
527
+ const fleetReqId = `sched_${requestId}`;
528
+ this.ipcServer?.broadcast({
529
+ type: typeMap[tool],
530
+ payload: args,
531
+ meta: { chat_id: this.lastChatId, thread_id: this.lastThreadId, instance_name: this.name },
532
+ fleetRequestId: fleetReqId,
533
+ });
534
+ // Wait for fleet_schedule_response via pending request map
535
+ const timeout = setTimeout(() => {
536
+ this.pendingIpcRequests.delete(fleetReqId);
537
+ respond(null, "Schedule operation timed out after 30s");
538
+ }, 30_000);
539
+ this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
540
+ clearTimeout(timeout);
541
+ respond(respMsg.result, respMsg.error);
542
+ });
543
+ return;
544
+ }
545
+ if (CROSS_INSTANCE_TOOLS.has(tool)) {
546
+ // Route to fleet manager via IPC (topic mode only)
547
+ if (this.topicMode && this.ipcServer) {
548
+ // Use fleetRequestId (not requestId) to avoid MCP server resolving the
549
+ // pending tool call prematurely when it receives the broadcast.
550
+ const fleetReqId = `xmsg_${requestId}`;
551
+ const senderSessionName = this.socketSessionNames.get(socket);
552
+ this.ipcServer.broadcast({
553
+ type: "fleet_outbound",
554
+ tool,
555
+ args,
556
+ fleetRequestId: fleetReqId,
557
+ senderSessionName,
558
+ });
559
+ const crossTimeoutMs = (tool === "start_instance" || tool === "create_instance") ? 60_000 : 30_000;
560
+ const timeout = setTimeout(() => {
561
+ this.pendingIpcRequests.delete(fleetReqId);
562
+ respond(null, `Cross-instance operation timed out after ${crossTimeoutMs / 1000}s`);
563
+ }, crossTimeoutMs);
564
+ this.pendingIpcRequests.set(fleetReqId, (respMsg) => {
565
+ clearTimeout(timeout);
566
+ respond(respMsg.result, respMsg.error);
567
+ });
568
+ }
569
+ else {
570
+ respond(null, "Cross-instance messaging requires topic mode");
571
+ }
572
+ return;
573
+ }
574
+ // Route to adapter via MessageBus
575
+ const adapters = this.messageBus.getAllAdapters();
576
+ if (adapters.length === 0) {
577
+ // Topic mode: forward to fleet manager via IPC (fleet manager connected as IPC client)
578
+ // The fleet manager's IPC client receives this and routes to shared adapter.
579
+ // Use fleetRequestId (not requestId) to avoid other MCP sessions on this daemon
580
+ // from prematurely resolving their pending requests when they receive the broadcast.
581
+ const fleetReqId = `tool_${requestId}`;
582
+ const outboundKey = fleetReqId;
583
+ this.ipcServer?.broadcast({ type: "fleet_outbound", tool, args, fleetRequestId: fleetReqId });
584
+ const timeout = setTimeout(() => {
585
+ this.pendingIpcRequests.delete(outboundKey);
586
+ respond(null, "Fleet outbound timed out after 30s");
587
+ }, 30_000);
588
+ this.pendingIpcRequests.set(outboundKey, (respMsg) => {
589
+ clearTimeout(timeout);
590
+ respond(respMsg.result, respMsg.error);
591
+ });
592
+ return;
593
+ }
594
+ const adapter = adapters[0];
595
+ if (!routeToolCall(adapter, tool, args, this.lastThreadId, respond)) {
596
+ respond(null, `Unknown tool: ${tool}`);
597
+ }
598
+ }
599
+ /** Build config object for the CLI backend */
600
+ buildBackendConfig() {
601
+ const sockPath = join(this.instanceDir, "channel.sock");
602
+ let serverJs = join(__dirname, "channel", "mcp-server.js");
603
+ if (!existsSync(serverJs)) {
604
+ serverJs = join(__dirname, "..", "dist", "channel", "mcp-server.js");
605
+ }
606
+ return {
607
+ workingDirectory: this.config.working_directory,
608
+ instanceDir: this.instanceDir,
609
+ instanceName: this.name,
610
+ mcpServers: {
611
+ "agend": {
612
+ command: "node",
613
+ args: [serverJs],
614
+ env: { AGEND_SOCKET_PATH: sockPath },
615
+ },
616
+ },
617
+ systemPrompt: this.buildSystemPrompt(),
618
+ skipPermissions: this.config.skipPermissions,
619
+ model: this.modelOverride ?? this.config.model,
620
+ };
621
+ }
622
+ /** Combine fleet context with user-configured system prompt + previous session snapshot */
623
+ buildSystemPrompt() {
624
+ const fleetContext = generateFleetSystemPrompt({
625
+ instanceName: this.name,
626
+ workingDirectory: this.config.working_directory,
627
+ });
628
+ let prompt = fleetContext;
629
+ if (this.config.systemPrompt) {
630
+ prompt += "\n\n" + this.config.systemPrompt;
631
+ }
632
+ // v3: inject previous session snapshot
633
+ const snapshotBlock = this.buildSnapshotPrompt();
634
+ if (snapshotBlock) {
635
+ prompt += "\n\n" + snapshotBlock;
636
+ }
637
+ return prompt;
638
+ }
639
+ /** Spawn (or respawn) a Claude window in tmux */
640
+ async spawnClaudeWindow() {
641
+ this.spawning = true;
642
+ try {
643
+ // Clear tool status from previous session
644
+ this.toolStatusLines = [];
645
+ this.toolStatusMessageId = null;
646
+ if (!this.backend) {
647
+ throw new Error("No backend configured — cannot spawn Claude window");
648
+ }
649
+ const backendConfig = this.buildBackendConfig();
650
+ this.backend.writeConfig(backendConfig);
651
+ // Inject AGEND_INSTANCE_NAME via shell env (not .mcp.json) so internal sessions
652
+ // are distinguishable from external sessions sharing the same .mcp.json
653
+ let claudeCmd = `AGEND_INSTANCE_NAME=${this.name} ` + this.backend.buildCommand(backendConfig);
654
+ const windowId = await this.tmux.createWindow(claudeCmd, this.config.working_directory, this.name);
655
+ const windowIdFile = join(this.instanceDir, "window-id");
656
+ writeFileSync(windowIdFile, windowId);
657
+ // Fixed grace period — smart detection was unreliable across different CLIs.
658
+ // Wait for CLI to fully initialize, then press Enter to dismiss any prompts.
659
+ await new Promise(r => setTimeout(r, 10_000));
660
+ try {
661
+ await this.tmux.sendSpecialKey("Enter");
662
+ }
663
+ catch { /* window may have exited */ }
664
+ this.lastSpawnAt = Date.now();
665
+ }
666
+ finally {
667
+ this.spawning = false;
668
+ }
669
+ }
670
+ saveSessionId() {
671
+ const sid = this.backend?.getSessionId();
672
+ if (sid) {
673
+ writeFileSync(join(this.instanceDir, "session-id"), sid);
674
+ }
675
+ }
676
+ readContextPercentage() {
677
+ return this.backend?.getContextUsage() ?? 0;
678
+ }
679
+ /** Set a model override for next spawn (used by failover logic) */
680
+ setModelOverride(model) {
681
+ this.modelOverride = model;
682
+ }
683
+ /** Get the currently active model override */
684
+ getModelOverride() {
685
+ return this.modelOverride;
686
+ }
687
+ /** Public wrapper for graceful restart — wait for instance to be idle. */
688
+ waitForIdle(quietMs = 5000) {
689
+ return new Promise((resolve) => {
690
+ const events = ["tool_use", "tool_result", "assistant_text"];
691
+ let timer;
692
+ const done = () => {
693
+ events.forEach(e => this.transcriptMonitor?.removeListener(e, reset));
694
+ resolve();
695
+ };
696
+ const reset = () => {
697
+ clearTimeout(timer);
698
+ timer = setTimeout(done, quietMs);
699
+ };
700
+ timer = setTimeout(done, quietMs);
701
+ events.forEach(e => this.transcriptMonitor?.on(e, reset));
702
+ });
703
+ }
704
+ // ── Context Rotation v3: Ring buffers ─────────────────────────
705
+ recordRecentUserMessage(content, meta) {
706
+ // Only record real user messages, not cross-instance messages
707
+ if (!meta.user || meta.user.startsWith("instance:"))
708
+ return;
709
+ this.recentUserMessages.push({
710
+ text: content.slice(0, 200),
711
+ ts: meta.ts ?? new Date().toISOString(),
712
+ });
713
+ if (this.recentUserMessages.length > 10)
714
+ this.recentUserMessages.shift();
715
+ }
716
+ recordRecentEvent(event) {
717
+ this.recentEvents.push(event);
718
+ if (this.recentEvents.length > 15)
719
+ this.recentEvents.shift();
720
+ }
721
+ recordRecentToolActivity(summary) {
722
+ if (!summary)
723
+ return;
724
+ this.recentToolActivity.push(summary);
725
+ if (this.recentToolActivity.length > 10)
726
+ this.recentToolActivity.shift();
727
+ }
728
+ // ── Context Rotation v3: Snapshot writer ──────────────────────
729
+ writeRotationSnapshot(reason) {
730
+ const statusline = this.readStatuslineData();
731
+ const snapshot = {
732
+ instance: this.name,
733
+ reason,
734
+ created_at: new Date().toISOString(),
735
+ working_directory: this.config.working_directory,
736
+ session_id: this.backend?.getSessionId() ?? null,
737
+ context_pct: this.readContextPercentage(),
738
+ recent_user_messages: [...this.recentUserMessages],
739
+ recent_events: [...this.recentEvents],
740
+ recent_tool_activity: [...this.recentToolActivity],
741
+ last_statusline: statusline ? {
742
+ model: statusline.model?.display_name,
743
+ cost_usd: statusline.cost?.total_cost_usd,
744
+ five_hour_pct: statusline.rate_limits?.five_hour?.used_percentage,
745
+ seven_day_pct: statusline.rate_limits?.seven_day?.used_percentage,
746
+ } : undefined,
747
+ };
748
+ const snapshotPath = join(this.instanceDir, "rotation-state.json");
749
+ writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2));
750
+ this.logger.info({
751
+ reason,
752
+ context_pct: snapshot.context_pct,
753
+ user_msg_count: snapshot.recent_user_messages?.length ?? 0,
754
+ event_count: snapshot.recent_events?.length ?? 0,
755
+ }, "Snapshot written");
756
+ return snapshot;
757
+ }
758
+ readStatuslineData() {
759
+ try {
760
+ const sf = join(this.instanceDir, "statusline.json");
761
+ return JSON.parse(readFileSync(sf, "utf-8"));
762
+ }
763
+ catch {
764
+ return null;
765
+ }
766
+ }
767
+ // ── Context Rotation v3: Prompt injection ─────────────────────
768
+ buildSnapshotPrompt() {
769
+ const snapshotPath = join(this.instanceDir, "rotation-state.json");
770
+ try {
771
+ if (!existsSync(snapshotPath))
772
+ return null;
773
+ const snapshot = JSON.parse(readFileSync(snapshotPath, "utf-8"));
774
+ // Single-consume: delete after reading so it's not re-injected on
775
+ // crash respawn, manual restart, or future rotations.
776
+ try {
777
+ unlinkSync(snapshotPath);
778
+ }
779
+ catch { /* best-effort */ }
780
+ const lines = ["## Previous Session Snapshot", ""];
781
+ lines.push(`Restart reason: ${snapshot.reason}`);
782
+ if (snapshot.context_pct != null)
783
+ lines.push(`Previous context usage: ${snapshot.context_pct}%`);
784
+ if (snapshot.session_id)
785
+ lines.push(`Previous session id: ${snapshot.session_id}`);
786
+ lines.push(`Working directory: ${snapshot.working_directory}`);
787
+ lines.push("");
788
+ if (snapshot.recent_user_messages && snapshot.recent_user_messages.length > 0) {
789
+ lines.push("Recent user messages:");
790
+ for (const msg of snapshot.recent_user_messages) {
791
+ lines.push(`- ${msg.text}`);
792
+ }
793
+ lines.push("");
794
+ }
795
+ if (snapshot.recent_events && snapshot.recent_events.length > 0) {
796
+ lines.push("Recent activity:");
797
+ for (const ev of snapshot.recent_events) {
798
+ if (ev.type === "assistant_text") {
799
+ lines.push(`- Assistant: ${ev.preview}`);
800
+ }
801
+ else {
802
+ lines.push(`- ${ev.name}${ev.preview ? `: ${ev.preview}` : ""}`);
803
+ }
804
+ }
805
+ lines.push("");
806
+ }
807
+ lines.push("Instruction:");
808
+ lines.push("Resume work from this snapshot when relevant. Do not assume anything not stated here.");
809
+ // Enforce 2000-char budget
810
+ let result = lines.join("\n");
811
+ if (result.length > 2000) {
812
+ result = result.slice(0, 1997) + "...";
813
+ }
814
+ return result;
815
+ }
816
+ catch {
817
+ return null;
818
+ }
819
+ }
820
+ }
821
+ //# sourceMappingURL=daemon.js.map