flint-agent 1.14.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 (171) hide show
  1. package/.env.example +108 -0
  2. package/CHANGELOG.md +55 -0
  3. package/FEATURES.md +298 -0
  4. package/LICENSE +21 -0
  5. package/README.md +435 -0
  6. package/bin/flint.js +47 -0
  7. package/config/classifier-prompt.md +218 -0
  8. package/config/models-curated.json +4 -0
  9. package/config/providers.json +74 -0
  10. package/package.json +92 -0
  11. package/patches/ink+6.8.0.patch +78 -0
  12. package/profiles/desktop.md +65 -0
  13. package/profiles/generic.md +20 -0
  14. package/profiles/marketer.md +20 -0
  15. package/profiles/profiles.json +34 -0
  16. package/profiles/ux-reviewer.md +25 -0
  17. package/src/agent/agent.js +1743 -0
  18. package/src/agent/auto.js +346 -0
  19. package/src/agent/backoff.js +143 -0
  20. package/src/agent/compression.js +310 -0
  21. package/src/agent/content-resolver.js +180 -0
  22. package/src/agent/flow-controller.js +309 -0
  23. package/src/agent/intent-manifest.js +231 -0
  24. package/src/agent/intent-timeout.js +46 -0
  25. package/src/agent/intent.js +633 -0
  26. package/src/agent/knowledge.js +114 -0
  27. package/src/agent/learning.js +180 -0
  28. package/src/agent/modes.js +187 -0
  29. package/src/agent/outcome-ask.js +91 -0
  30. package/src/agent/project-context.js +76 -0
  31. package/src/agent/prompt-budget.js +117 -0
  32. package/src/agent/reflection-extractor.js +140 -0
  33. package/src/agent/steering.js +86 -0
  34. package/src/agent/supervisor.js +430 -0
  35. package/src/agent/swap.js +443 -0
  36. package/src/agent/system-prompt.js +446 -0
  37. package/src/agent/time-stamp.js +48 -0
  38. package/src/agent/tool-guard.js +201 -0
  39. package/src/agent/toolcall-text.js +162 -0
  40. package/src/agent/usage.js +297 -0
  41. package/src/agent/vision.js +94 -0
  42. package/src/agent/watchdog.js +139 -0
  43. package/src/agent/workspace-changes.js +177 -0
  44. package/src/api/address.js +14 -0
  45. package/src/api/client.js +280 -0
  46. package/src/api/server.js +535 -0
  47. package/src/api/stream-pipe.js +113 -0
  48. package/src/app-state.js +39 -0
  49. package/src/bootstrap.js +501 -0
  50. package/src/bus/drain-loop.js +497 -0
  51. package/src/bus/index.js +270 -0
  52. package/src/bus/plugins.js +65 -0
  53. package/src/child-idle.js +14 -0
  54. package/src/cli.js +118 -0
  55. package/src/commands/commands.js +1297 -0
  56. package/src/commands/registry.js +132 -0
  57. package/src/components/App.js +491 -0
  58. package/src/components/CarefulMenu.js +145 -0
  59. package/src/components/HistoryWriter.js +86 -0
  60. package/src/components/LineInput.js +69 -0
  61. package/src/components/LiveZone.js +294 -0
  62. package/src/components/OverlayMenu.js +179 -0
  63. package/src/components/SystemPanel.js +156 -0
  64. package/src/components/Table.js +54 -0
  65. package/src/config.js +249 -0
  66. package/src/free-models.js +230 -0
  67. package/src/index.js +1111 -0
  68. package/src/input-handler.js +13 -0
  69. package/src/input-text.js +123 -0
  70. package/src/launcher.js +129 -0
  71. package/src/logging/api-log.js +95 -0
  72. package/src/logging/chat-log-follower.js +113 -0
  73. package/src/logging/chat-log.js +15 -0
  74. package/src/logging/log-collector.js +182 -0
  75. package/src/logging/logger.js +112 -0
  76. package/src/logging/tool-log.js +20 -0
  77. package/src/mcp-client.js +314 -0
  78. package/src/memory/conversation-digest.js +113 -0
  79. package/src/memory/extract-facts.js +98 -0
  80. package/src/memory/facts.js +181 -0
  81. package/src/memory/inbox.js +63 -0
  82. package/src/memory/markdown.js +38 -0
  83. package/src/memory/patterns.js +185 -0
  84. package/src/memory/project.js +66 -0
  85. package/src/memory/reflections.js +74 -0
  86. package/src/memory/retrieval.js +84 -0
  87. package/src/memory/rules.js +105 -0
  88. package/src/memory/session-facts.js +125 -0
  89. package/src/memory/skills.js +191 -0
  90. package/src/memory/sqlite-store.js +653 -0
  91. package/src/memory/store.js +208 -0
  92. package/src/memory/tools.js +196 -0
  93. package/src/memory/user-model.js +86 -0
  94. package/src/message-handler.js +775 -0
  95. package/src/model-check.js +218 -0
  96. package/src/plugins/loader.js +120 -0
  97. package/src/plugins/manager.js +88 -0
  98. package/src/production-env.js +22 -0
  99. package/src/profiles.js +42 -0
  100. package/src/providers/adapters/anthropic.js +270 -0
  101. package/src/providers/adapters/openai.js +120 -0
  102. package/src/providers/keys-dpapi.js +41 -0
  103. package/src/providers/keys-fallback.js +31 -0
  104. package/src/providers/keys.js +132 -0
  105. package/src/providers/models.js +154 -0
  106. package/src/providers/registry.js +56 -0
  107. package/src/providers/state.js +56 -0
  108. package/src/registry.js +96 -0
  109. package/src/restart.js +29 -0
  110. package/src/sandbox/backend.js +130 -0
  111. package/src/security/api-auth.js +132 -0
  112. package/src/security/audit.js +98 -0
  113. package/src/security/child-policy.js +41 -0
  114. package/src/security/command-guard.js +173 -0
  115. package/src/security/content-fence.js +250 -0
  116. package/src/security/content-validator.js +132 -0
  117. package/src/security/index.js +143 -0
  118. package/src/security/network-guard.js +126 -0
  119. package/src/security/pairing.js +180 -0
  120. package/src/security/path-guard.js +140 -0
  121. package/src/security/persona-guard.js +67 -0
  122. package/src/security/policies.js +452 -0
  123. package/src/security/safety-constants.js +34 -0
  124. package/src/security/watchdog.js +107 -0
  125. package/src/sessions.js +130 -0
  126. package/src/spend.js +97 -0
  127. package/src/startup-watchdog.js +59 -0
  128. package/src/stdio/args.js +71 -0
  129. package/src/stdio/guard.js +59 -0
  130. package/src/stdio/protocol.js +167 -0
  131. package/src/stdio/run.js +106 -0
  132. package/src/stdio/session.js +180 -0
  133. package/src/store/agent-slice.js +306 -0
  134. package/src/store/dataset-slice.js +73 -0
  135. package/src/store/index.js +22 -0
  136. package/src/store/process-slice.js +135 -0
  137. package/src/store/session-slice.js +191 -0
  138. package/src/store/ui-slice.js +119 -0
  139. package/src/tasks/db.js +184 -0
  140. package/src/tasks/queries.js +589 -0
  141. package/src/tools/agent-tools.js +473 -0
  142. package/src/tools/checkpoint.js +152 -0
  143. package/src/tools/command-approvals.js +180 -0
  144. package/src/tools/dataset.js +50 -0
  145. package/src/tools/filesystem.js +682 -0
  146. package/src/tools/inbox-tools.js +48 -0
  147. package/src/tools/mesh.js +135 -0
  148. package/src/tools/own-env.js +136 -0
  149. package/src/tools/permissions.js +681 -0
  150. package/src/tools/plugin-tools.js +123 -0
  151. package/src/tools/process-tools.js +595 -0
  152. package/src/tools/registry.js +307 -0
  153. package/src/tools/swap-tools.js +72 -0
  154. package/src/tools/system.js +662 -0
  155. package/src/tools/tasks.js +532 -0
  156. package/src/tools/tool-search.js +171 -0
  157. package/src/ui/header.js +140 -0
  158. package/src/ui/input-cursor.js +23 -0
  159. package/src/ui/last-line.js +25 -0
  160. package/src/ui/line-edit.js +135 -0
  161. package/src/ui/output.js +399 -0
  162. package/src/ui/paste-tokens.js +131 -0
  163. package/src/ui/prompt-attention.js +134 -0
  164. package/src/ui/render-options.js +13 -0
  165. package/src/ui/replay.js +94 -0
  166. package/src/ui/splash.js +49 -0
  167. package/src/ui/status-level.js +36 -0
  168. package/src/ui/tool-ledger.js +203 -0
  169. package/src/ui/window-title.js +150 -0
  170. package/src/update.js +205 -0
  171. package/system.md +63 -0
@@ -0,0 +1,473 @@
1
+ import { spawn } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { config } from "../config.js";
6
+ import { createLogger } from "../logging/logger.js";
7
+ import {
8
+ printChildAgent, printChildSpawn, printChildEvent,
9
+ } from "../ui/output.js";
10
+ import { activeChildren } from "./process-tools.js";
11
+ import { getTask, getTasksByGoalAndStatus, getTaskStats } from "../tasks/queries.js";
12
+ import { apiUrl } from "../api/address.js";
13
+
14
+ const log = createLogger("agents");
15
+
16
+ // ── Child agent registry ──
17
+ const _childAgents = new Map(); // port → { procId, pid, task, port, status, missedPings }
18
+ let _nextChildPort = 3010;
19
+ const HEARTBEAT_INTERVAL = 15000; // 15s
20
+ const MAX_MISSED_PINGS = 3;
21
+ let _heartbeatTimer = null;
22
+
23
+ // Start heartbeat monitoring for child agents
24
+ function startChildHeartbeat(store) {
25
+ if (_heartbeatTimer) return;
26
+ _heartbeatTimer = setInterval(async () => {
27
+ for (const [port, agent] of _childAgents) {
28
+ if (agent.status !== "running" && agent.status !== "starting") continue;
29
+ try {
30
+ const res = await fetch(apiUrl(port, "/status"), {
31
+ signal: AbortSignal.timeout(3000),
32
+ });
33
+ if (res.ok) {
34
+ if (agent.missedPings > 0) {
35
+ // Recovered
36
+ const label = agent.profile !== "generic" ? agent.profile : null;
37
+ printChildEvent(port, "connection restored", label);
38
+ }
39
+ agent.missedPings = 0;
40
+ continue;
41
+ }
42
+ } catch {}
43
+ // Ping failed
44
+ agent.missedPings = (agent.missedPings || 0) + 1;
45
+ const label = agent.profile !== "generic" ? agent.profile : null;
46
+ if (agent.missedPings >= MAX_MISSED_PINGS) {
47
+ agent.status = "lost";
48
+ log.warn(`Agent@${port} connection lost after ${MAX_MISSED_PINGS} missed pings`);
49
+ printChildEvent(port, `connection lost (${MAX_MISSED_PINGS} missed pings)`, label);
50
+ store.getState().finishProcess(agent.procId, null, "lost");
51
+ activeChildren.delete(agent.procId);
52
+ } else {
53
+ log.debug(`Agent@${port} no response (${agent.missedPings}/${MAX_MISSED_PINGS})`);
54
+ printChildEvent(port, `no response (${agent.missedPings}/${MAX_MISSED_PINGS})`, label);
55
+ }
56
+ }
57
+ // Stop heartbeat if no active children
58
+ const hasActive = [..._childAgents.values()].some((a) => a.status === "running" || a.status === "starting");
59
+ if (!hasActive && _heartbeatTimer) {
60
+ clearInterval(_heartbeatTimer);
61
+ _heartbeatTimer = null;
62
+ }
63
+ }, HEARTBEAT_INTERVAL);
64
+ }
65
+
66
+ // ── Tool definitions ──
67
+
68
+ export const agentToolDefs = [
69
+ {
70
+ type: "function",
71
+ function: {
72
+ name: "spawn_agent",
73
+ description: "Spawn a new agent instance in a separate process and give it a task. The child agent runs on its own port and has full tool access. Use for parallel work: testing, refactoring, research, etc.",
74
+ parameters: {
75
+ type: "object",
76
+ properties: {
77
+ task: { type: "string", description: "The task/message to send to the new agent" },
78
+ task_id: { type: "integer", description: "SQLite task ID to assign to the child agent. Child will claim it, update status, and report result. Use with create_plan for coordinated parallel work." },
79
+ port: { type: "integer", description: "Port for the child agent (default: auto-assign from 3010+)" },
80
+ profile: { type: "string", description: "Profile for child agent (default: generic). Options: desktop, generic, marketer" },
81
+ model: { type: "string", description: "Model for child agent (default: same as parent)" },
82
+ visible: { type: "boolean", description: "Open agent in a new visible console window (default: false, runs headless)" },
83
+ },
84
+ required: ["task"],
85
+ },
86
+ },
87
+ },
88
+ {
89
+ type: "function",
90
+ function: {
91
+ name: "ask_agent",
92
+ description: "Send a message to a running child agent and get its response. Use the port from spawn_agent result.",
93
+ parameters: {
94
+ type: "object",
95
+ properties: {
96
+ port: { type: "integer", description: "Port of the child agent" },
97
+ message: { type: "string", description: "Message to send" },
98
+ },
99
+ required: ["port", "message"],
100
+ },
101
+ },
102
+ },
103
+ {
104
+ type: "function",
105
+ function: {
106
+ name: "list_agents",
107
+ description: "List all spawned child agents with their status, port, and task.",
108
+ parameters: { type: "object", properties: {} },
109
+ },
110
+ },
111
+ {
112
+ type: "function",
113
+ function: {
114
+ name: "wait_tasks",
115
+ description: "Wait for all tasks in a goal to complete (done/skipped). Use after spawning child agents with task_id to block until they finish. Returns task results summary.",
116
+ parameters: {
117
+ type: "object",
118
+ properties: {
119
+ goal_id: { type: "integer", description: "Goal ID to monitor (from create_plan result)" },
120
+ timeout: { type: "integer", description: "Max wait time in seconds (default: 300)" },
121
+ },
122
+ required: ["goal_id"],
123
+ },
124
+ },
125
+ },
126
+ ];
127
+
128
+ /**
129
+ * Where a child agent on `port` keeps its data: under the parent's data
130
+ * folder, one folder per port, so no two Flint processes share a queue.
131
+ */
132
+ export function childDataDir(port, parentDir = process.env.FLINT_DATA_DIR || path.join(os.homedir(), ".flint")) {
133
+ return path.join(parentDir, "children", String(port));
134
+ }
135
+
136
+ // ── Handler implementations ──
137
+
138
+ export function createAgentHandlers(store) {
139
+ return {
140
+ async spawn_agent({ task, task_id, port, profile, model, visible }) {
141
+ // Enforce max child agents limit
142
+ const runningChildren = [..._childAgents.values()].filter((a) => a.status === "starting" || a.status === "running").length;
143
+ if (runningChildren >= config.maxChildAgents) {
144
+ return `Error: max child agents limit reached (${config.maxChildAgents}). Use list_agents() to see running agents, or kill some first.`;
145
+ }
146
+
147
+ const assignedPort = port || (_nextChildPort++);
148
+ const childProfile = profile || "generic";
149
+ const isWin = process.platform === "win32";
150
+
151
+ let cmd = `node src/index.js --new --port ${assignedPort} --profile ${childProfile}`;
152
+ if (model) cmd += ` --model ${model}`;
153
+
154
+ // Inherit sandbox + filter API keys for child agents
155
+ const { getDeniedPaths } = await import("./filesystem.js");
156
+ const denied = getDeniedPaths();
157
+ const pairingSecret = crypto.randomBytes(32).toString("hex");
158
+ const childEnv = { ...process.env, AGENT_PORT: String(assignedPort) };
159
+ // Filter all known API key env vars — child gets key only via provider-specific var
160
+ delete childEnv.OPENAI_API_KEY;
161
+ delete childEnv.ANTHROPIC_API_KEY;
162
+ delete childEnv.OPENROUTER_API_KEY;
163
+ // Pass active provider to child agent
164
+ childEnv.FLINT_PROVIDER = config.provider;
165
+ if (config.apiKey) {
166
+ // Pass current provider's key via the appropriate env var
167
+ if (config.provider === "openrouter") childEnv.OPENROUTER_API_KEY = config.apiKey;
168
+ else if (config.provider === "openai") childEnv.OPENAI_API_KEY = config.apiKey;
169
+ else if (config.provider === "anthropic") childEnv.ANTHROPIC_API_KEY = config.apiKey;
170
+ else childEnv.OPENROUTER_API_KEY = config.apiKey; // generic fallback
171
+ }
172
+ // The child's own data folder: its message queue, sessions and memory.
173
+ // Without it parent and child shared one tasks.db queue: either could
174
+ // take the other's messages, and a child's start-up recover() put the
175
+ // parent's own long-running message back in the queue for anyone to run
176
+ // again (found 2026-10-02, before a spawn for a research task).
177
+ childEnv.FLINT_DATA_DIR = childDataDir(assignedPort);
178
+ // Pairing secret for parent↔child auth
179
+ childEnv.AGENT_PAIRING_SECRET = pairingSecret;
180
+ // Parent port for orphan protection heartbeat
181
+ childEnv.AGENT_PARENT_PORT = String(config.port);
182
+ // Auto-exit after idle period (zombie fix)
183
+ childEnv.AGENT_IDLE_TIMEOUT = String(config.childIdleTimeout || 60);
184
+ // Track agent depth for child-policy enforcement
185
+ const currentDepth = parseInt(process.env.AGENT_DEPTH || "0", 10);
186
+ childEnv.AGENT_DEPTH = String(currentDepth + 1);
187
+ // Task-driven child agent: pass task_id so child can claim and update it
188
+ if (task_id) {
189
+ childEnv.AGENT_TASK_ID = String(task_id);
190
+ }
191
+ if (denied.length) {
192
+ childEnv.AGENT_DENIED_PATHS = denied.join(",");
193
+ }
194
+
195
+ // Ensure display server vars survive for visible GUI terminals
196
+ if (visible && !isWin) {
197
+ for (const v of ["DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR"]) {
198
+ if (process.env[v] && !childEnv[v]) childEnv[v] = process.env[v];
199
+ }
200
+ }
201
+
202
+ let child;
203
+ if (visible && isWin) {
204
+ // Write a temp .bat launcher, then open it in a new window via Start-Process
205
+ // This ensures complete process isolation — no shared console with parent
206
+ const os = await import("node:os");
207
+ const fs = await import("node:fs");
208
+ const batPath = path.join(os.tmpdir(), `flint-agent-${assignedPort}.bat`);
209
+ const batLines = [
210
+ "@echo off",
211
+ `title Flint@${assignedPort}`,
212
+ `set "AGENT_PORT=${assignedPort}"`,
213
+ `set "AGENT_PAIRING_SECRET=${pairingSecret}"`,
214
+ `set "AGENT_PARENT_PORT=${config.port}"`,
215
+ `set "AGENT_DEPTH=${childEnv.AGENT_DEPTH}"`,
216
+ `set "FLINT_PROVIDER=${config.provider}"`,
217
+ `set "FLINT_DATA_DIR=${childEnv.FLINT_DATA_DIR}"`,
218
+ ];
219
+ // Pass the correct API key env var in bat file
220
+ if (config.apiKey) {
221
+ if (config.provider === "openrouter") batLines.push(`set "OPENROUTER_API_KEY=${config.apiKey}"`);
222
+ else if (config.provider === "openai") batLines.push(`set "OPENAI_API_KEY=${config.apiKey}"`);
223
+ else if (config.provider === "anthropic") batLines.push(`set "ANTHROPIC_API_KEY=${config.apiKey}"`);
224
+ else batLines.push(`set "OPENROUTER_API_KEY=${config.apiKey}"`);
225
+ }
226
+ if (childEnv.AGENT_DENIED_PATHS) batLines.push(`set "AGENT_DENIED_PATHS=${childEnv.AGENT_DENIED_PATHS}"`);
227
+ if (task_id) batLines.push(`set "AGENT_TASK_ID=${task_id}"`);
228
+ batLines.push(`cd /d "${config.projectRoot}"`);
229
+ batLines.push(cmd);
230
+ fs.writeFileSync(batPath, batLines.join("\r\n") + "\r\n");
231
+
232
+ child = spawn("cmd.exe", ["/c", "start", `"Flint@${assignedPort}"`, batPath], {
233
+ stdio: "ignore",
234
+ detached: true,
235
+ windowsHide: true,
236
+ });
237
+ child.unref();
238
+ } else if (visible && !isWin) {
239
+ // Unix: try tmux/screen (headless servers), then GUI terminals
240
+ const strategies = [
241
+ // tmux — best for servers, creates named session
242
+ ["tmux", ["new-session", "-d", "-s", `flint-${assignedPort}`, "-x", "120", "-y", "40", `cd ${config.projectRoot} && ${cmd}`]],
243
+ // screen — fallback for servers without tmux
244
+ ["screen", ["-dmS", `flint-${assignedPort}`, "bash", "-c", `cd ${config.projectRoot} && ${cmd}`]],
245
+ // GUI terminal emulators
246
+ ["gnome-terminal", ["--", "bash", "-c", `cd ${config.projectRoot} && ${cmd}; exec bash`]],
247
+ ["xterm", ["-e", `cd ${config.projectRoot} && ${cmd}`]],
248
+ ];
249
+ let spawned = false;
250
+ for (const [term, args] of strategies) {
251
+ try {
252
+ child = spawn(term, args, {
253
+ cwd: config.projectRoot,
254
+ env: childEnv,
255
+ stdio: "ignore",
256
+ detached: true,
257
+ });
258
+ child.unref();
259
+ spawned = true;
260
+ printChildEvent(assignedPort, `visible via ${term}`, null);
261
+ break;
262
+ } catch {}
263
+ }
264
+ if (!spawned) {
265
+ // Fallback to headless
266
+ child = spawn("/bin/bash", ["-c", cmd], {
267
+ cwd: config.projectRoot,
268
+ env: childEnv,
269
+ stdio: ["ignore", "pipe", "pipe"],
270
+ detached: false,
271
+ });
272
+ }
273
+ } else {
274
+ // Headless mode (default)
275
+ const shell = isWin ? "cmd.exe" : "/bin/bash";
276
+ const shellArgs = isWin ? ["/c", cmd] : ["-c", cmd];
277
+ child = spawn(shell, shellArgs, {
278
+ cwd: config.projectRoot,
279
+ env: childEnv,
280
+ stdio: ["ignore", "pipe", "pipe"],
281
+ detached: false,
282
+ });
283
+ }
284
+
285
+ const procId = store.getState().addProcess({
286
+ cmd: `agent@${assignedPort}`,
287
+ pid: child.pid,
288
+ });
289
+
290
+ activeChildren.set(procId, child);
291
+ _childAgents.set(assignedPort, { procId, pid: child.pid, task, port: assignedPort, status: "starting", profile: childProfile, token: pairingSecret, visible: !!visible, missedPings: 0 });
292
+
293
+ // Register in TaskRegistry for Escape abort
294
+ const taskRegId = store.getState().registerTask({
295
+ type: "child-agent",
296
+ label: `agent@${assignedPort}`,
297
+ port: assignedPort,
298
+ kill: () => {
299
+ try {
300
+ if (process.platform === "win32" && child.pid) {
301
+ spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore" });
302
+ } else {
303
+ child.kill("SIGTERM");
304
+ }
305
+ } catch {}
306
+ },
307
+ });
308
+
309
+ // Start heartbeat monitoring
310
+ startChildHeartbeat(store);
311
+
312
+ const childLabel = childProfile !== "generic" ? childProfile : null;
313
+ printChildSpawn(assignedPort, child.pid, childLabel);
314
+
315
+ // Only capture output for headless agents
316
+ if (!visible) {
317
+ const onData = (data) => {
318
+ const lines = data.toString().split("\n");
319
+ for (const line of lines) {
320
+ if (line) store.getState().appendProcessOutput(procId, line);
321
+ }
322
+ };
323
+ if (child.stdout) child.stdout.on("data", onData);
324
+ if (child.stderr) child.stderr.on("data", onData);
325
+ }
326
+
327
+ child.on("close", (code) => {
328
+ activeChildren.delete(procId);
329
+ store.getState().unregisterTask(taskRegId);
330
+ store.getState().finishProcess(procId, code);
331
+ const agent = _childAgents.get(assignedPort);
332
+ if (agent) agent.status = "stopped";
333
+ printChildEvent(assignedPort, `exited (code ${code ?? "?"})`, childLabel);
334
+ // Clean up zombie entry after delay so list_agents can still show exit info
335
+ setTimeout(() => _childAgents.delete(assignedPort), config.childCleanupDelay);
336
+ });
337
+
338
+ child.on("error", (err) => {
339
+ store.getState().appendProcessOutput(procId, `Error: ${err.message}`);
340
+ store.getState().finishProcess(procId, 1, "failed");
341
+ const agent = _childAgents.get(assignedPort);
342
+ if (agent) agent.status = "error";
343
+ setTimeout(() => _childAgents.delete(assignedPort), config.childCleanupDelay);
344
+ });
345
+
346
+ // Wait for agent to start, then send task
347
+ const startTime = Date.now();
348
+ const sendTask = async () => {
349
+ for (let i = 0; i < 30; i++) {
350
+ await new Promise((r) => setTimeout(r, 1000));
351
+ try {
352
+ const res = await fetch(apiUrl(assignedPort, "/status"), {
353
+ signal: AbortSignal.timeout(2000),
354
+ });
355
+ if (res.ok) {
356
+ const agent = _childAgents.get(assignedPort);
357
+ if (agent) agent.status = "running";
358
+ // Send the task (authenticated with pairing secret)
359
+ const taskRes = await fetch(apiUrl(assignedPort, "/message"), {
360
+ method: "POST",
361
+ headers: {
362
+ "Content-Type": "application/json",
363
+ "Authorization": `Bearer ${pairingSecret}`,
364
+ },
365
+ body: JSON.stringify({ content: task, name: "parent-agent" }),
366
+ signal: AbortSignal.timeout(120000),
367
+ });
368
+ // Sent without waiting: the child works on it while the parent
369
+ // goes on. This is the child taking the task, not answering it.
370
+ await taskRes.json().catch(() => null);
371
+ printChildEvent(assignedPort, `took its task (${((Date.now() - startTime) / 1000).toFixed(1)}s)`, childLabel);
372
+ return;
373
+ }
374
+ } catch {}
375
+ }
376
+ printChildEvent(assignedPort, "failed to start within 30s", childLabel);
377
+ };
378
+
379
+ // Fire and forget — don't block the parent agent
380
+ sendTask().catch(() => {});
381
+
382
+ return `Agent spawned on port ${assignedPort} (pid ${child.pid}, process #${procId}).\nTask sent: "${task.slice(0, 100)}"\nUse ask_agent(port: ${assignedPort}, message: "...") to communicate.\nUse list_agents() to check status.`;
383
+ },
384
+
385
+ async ask_agent({ port, message }) {
386
+ const agent = _childAgents.get(port);
387
+ if (!agent) return `No agent on port ${port}. Use list_agents() to see available agents.`;
388
+ if (agent.status === "stopped") return `Agent@${port} has stopped. Spawn a new one.`;
389
+
390
+ // Wait for agent to be ready if it's still starting
391
+ if (agent.status === "starting") {
392
+ for (let i = 0; i < 30; i++) {
393
+ await new Promise((r) => setTimeout(r, 1000));
394
+ try {
395
+ const check = await fetch(apiUrl(port, "/status"), { signal: AbortSignal.timeout(2000) });
396
+ if (check.ok) { agent.status = "running"; break; }
397
+ } catch {}
398
+ }
399
+ if (agent.status !== "running") return `Agent@${port} failed to start within 30s.`;
400
+ }
401
+
402
+ try {
403
+ const headers = { "Content-Type": "application/json" };
404
+ if (agent.token) headers["Authorization"] = `Bearer ${agent.token}`;
405
+ const res = await fetch(apiUrl(port, "/message"), {
406
+ method: "POST",
407
+ headers,
408
+ // sync: wait for the answer. Without it the child's API accepts the
409
+ // message (202, a messageId) and returns, and every ask came back
410
+ // "(no response)" though the child answered (2026-10-02). The
411
+ // child's API waits up to 180 s, so this waits a little longer.
412
+ body: JSON.stringify({ content: message, name: "parent-agent", sync: true }),
413
+ signal: AbortSignal.timeout(190000),
414
+ });
415
+ if (!res.ok) return `Agent@${port} returned HTTP ${res.status}`;
416
+ const data = await res.json();
417
+ const response = data.response || "(no response)";
418
+ const agentLabel = agent.profile && agent.profile !== "generic" ? agent.profile : null;
419
+ printChildAgent(port, response, 20, agentLabel);
420
+ return `Agent@${port} response:\n${response}`;
421
+ } catch (err) {
422
+ return `Error communicating with agent@${port}: ${err.message}`;
423
+ }
424
+ },
425
+
426
+ async list_agents() {
427
+ if (!_childAgents.size) return "No child agents spawned.";
428
+ const lines = ["Port | Profile | Status | PID | Task"];
429
+ for (const [port, agent] of _childAgents) {
430
+ const taskShort = agent.task.length > 50 ? agent.task.slice(0, 50) + "..." : agent.task;
431
+ const prof = (agent.profile || "generic").padEnd(8);
432
+ lines.push(`${String(port).padEnd(6)} | ${prof} | ${agent.status.padEnd(8)} | ${String(agent.pid).padEnd(5)} | ${taskShort}`);
433
+ }
434
+ return lines.join("\n");
435
+ },
436
+
437
+ async wait_tasks({ goal_id, timeout }) {
438
+ const maxWait = (timeout || 300) * 1000;
439
+ const pollInterval = 3000; // 3s
440
+ const start = Date.now();
441
+
442
+ while (Date.now() - start < maxWait) {
443
+ const stats = getTaskStats(goal_id);
444
+ if (!stats || stats.total === 0) return `Error: no tasks found for goal #${goal_id}`;
445
+ if (stats.pending === 0 && stats.in_progress === 0) {
446
+ // All done/skipped — collect results
447
+ const tasks = getTasksByGoalAndStatus(goal_id, ["done", "skipped"]);
448
+ const lines = [`All ${stats.total} tasks completed for goal #${goal_id}:`];
449
+ lines.push(` Done: ${stats.done}, Skipped: ${stats.skipped}`);
450
+ for (const t of tasks) {
451
+ const icon = t.status === "done" ? "+" : "-";
452
+ lines.push(` [${icon}] #${t.id} ${t.title}${t.result ? ` — ${t.result}` : ""}`);
453
+ }
454
+ return lines.join("\n");
455
+ }
456
+ // Still in progress
457
+ const elapsed = ((Date.now() - start) / 1000).toFixed(0);
458
+ log.debug(`wait_tasks: goal #${goal_id} — ${stats.done}/${stats.total} done, ${stats.in_progress} in_progress (${elapsed}s)`);
459
+ await new Promise((r) => setTimeout(r, pollInterval));
460
+ }
461
+
462
+ // Timeout — report partial results
463
+ const stats = getTaskStats(goal_id);
464
+ const remaining = getTasksByGoalAndStatus(goal_id, ["pending", "in_progress"]);
465
+ const lines = [`Timeout (${timeout || 300}s) waiting for goal #${goal_id}:`];
466
+ lines.push(` Done: ${stats.done}, In progress: ${stats.in_progress}, Pending: ${stats.pending}`);
467
+ for (const t of remaining) {
468
+ lines.push(` [>] #${t.id} ${t.title} (${t.status})`);
469
+ }
470
+ return lines.join("\n");
471
+ },
472
+ };
473
+ }
@@ -0,0 +1,152 @@
1
+ // Checkpoint/Rewind — snapshot files before modification
2
+ // Stores original content so changes can be undone with /rewind
3
+
4
+ import fs from "node:fs/promises";
5
+ import { readFileSync, existsSync } from "node:fs";
6
+ import path from "node:path";
7
+
8
+ // Stack of snapshots (LIFO) — each entry is { path, content, type, timestamp }
9
+ // content = null means file didn't exist before (was created)
10
+ const stack = [];
11
+
12
+ // Backlog item 12. This stack is never popped in normal use: saveCheckpoint is
13
+ // called before every write_file, edit_file and delete_file, and only /rewind
14
+ // removes entries. So a working day of edits accumulated a copy of every file,
15
+ // before every edit, for as long as the process lived — and /new does not touch
16
+ // it, which is exactly what the owner saw: 2.93 GB, /new at 07:08, 3.05 GB
17
+ // after, i.e. the session's history went and the memory did not.
18
+ //
19
+ // Bounded by DEPTH, not by size. Size alone is the wrong unit: a few very
20
+ // large files pass a byte limit that hundreds of ordinary ones blow through,
21
+ // and the count is what the /rewind command can actually act on ("rewind the
22
+ // last 3 changes"). 50 entries is more than any human undo session — it covers
23
+ // the writes of a long turn and leaves room to go back further than anyone
24
+ // asks — and the oldest fall off, which is the order rewind already works in.
25
+ //
26
+ // The tradeoff is real and worth stating: a rewind past DEPTH can no longer
27
+ // restore a file. That is the price of not dying at 4 GB, and it is the right
28
+ // way round: the alternative is losing the whole session to the heap limit.
29
+ //
30
+ // Exported so the measurement test in checkpoint.test.js asserts against this
31
+ // number rather than a copy of it. A test with its own "50" in it would keep
32
+ // passing if the bound were ever changed, which is the opposite of what it is
33
+ // for.
34
+ export const MAX_DEPTH = 50;
35
+
36
+ function trim() {
37
+ while (stack.length > MAX_DEPTH) stack.shift();
38
+ }
39
+
40
+ /**
41
+ * Save a snapshot of a file before modifying it.
42
+ * Call this BEFORE write_file, edit_file, delete_file.
43
+ */
44
+ export async function saveCheckpoint(filePath, type = "modify") {
45
+ const resolved = path.resolve(filePath);
46
+ let content = null;
47
+ let existed = false;
48
+
49
+ try {
50
+ content = readFileSync(resolved, "utf-8");
51
+ existed = true;
52
+ } catch {
53
+ // File doesn't exist yet — will be created
54
+ existed = false;
55
+ }
56
+
57
+ stack.push({
58
+ path: resolved,
59
+ content: existed ? content : null,
60
+ existed,
61
+ type, // "write", "edit", "delete"
62
+ timestamp: Date.now(),
63
+ });
64
+ trim();
65
+ }
66
+
67
+ /**
68
+ * Rewind last N changes (default 1).
69
+ * Returns array of restored file descriptions.
70
+ */
71
+ export async function rewind(count = 1) {
72
+ const restored = [];
73
+
74
+ for (let i = 0; i < count && stack.length > 0; i++) {
75
+ const snap = stack.pop();
76
+
77
+ if (!snap.existed) {
78
+ // File was created — delete it
79
+ try {
80
+ await fs.unlink(snap.path);
81
+ restored.push({ path: snap.path, action: "deleted (was new)" });
82
+ } catch {
83
+ restored.push({ path: snap.path, action: "already gone" });
84
+ }
85
+ } else {
86
+ // File existed — restore original content
87
+ try {
88
+ await fs.mkdir(path.dirname(snap.path), { recursive: true });
89
+ await fs.writeFile(snap.path, snap.content, "utf-8");
90
+ restored.push({ path: snap.path, action: "restored" });
91
+ } catch (err) {
92
+ restored.push({ path: snap.path, action: `error: ${err.message}` });
93
+ }
94
+ }
95
+ }
96
+
97
+ return restored;
98
+ }
99
+
100
+ /**
101
+ * Rewind all changes in the stack.
102
+ */
103
+ export async function rewindAll() {
104
+ return rewind(stack.length);
105
+ }
106
+
107
+ /**
108
+ * Get stack info without modifying it.
109
+ */
110
+ export function getCheckpointStack() {
111
+ return stack.map((s) => ({
112
+ path: s.path,
113
+ type: s.type,
114
+ existed: s.existed,
115
+ timestamp: s.timestamp,
116
+ }));
117
+ }
118
+
119
+ /**
120
+ * Clear checkpoint stack (e.g. on session reset).
121
+ */
122
+ export function clearCheckpoints() {
123
+ stack.length = 0;
124
+ }
125
+
126
+ /**
127
+ * Number of checkpoints in the stack.
128
+ */
129
+ export function checkpointCount() {
130
+ return stack.length;
131
+ }
132
+
133
+ /**
134
+ * How many bytes of file content the stack is holding right now.
135
+ *
136
+ * A measurement, not a leak of content: it sums the sizes and returns a
137
+ * number, so a test can watch the stack grow without the accessor handing out
138
+ * the file text (getCheckpointStack deliberately maps `content` out — see the
139
+ * security test in checkpoint.test.js).
140
+ *
141
+ * This is the number that matters for memory growth. The owner watched one
142
+ * process climb from 2.43 GB to 3.33 GB overnight and die at ~4 GB on
143
+ * "JavaScript heap out of memory"; /new dropped it only to 3.05 GB, so the
144
+ * message history was not what grew. What grew was state outside the session.
145
+ * This is the part of that state which holds whole files, once per edit, for
146
+ * the life of the process.
147
+ */
148
+ export function checkpointRetainedBytes() {
149
+ let bytes = 0;
150
+ for (const s of stack) bytes += s.content ? Buffer.byteLength(s.content, "utf-8") : 0;
151
+ return bytes;
152
+ }