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.
- package/.env.example +108 -0
- package/CHANGELOG.md +55 -0
- package/FEATURES.md +298 -0
- package/LICENSE +21 -0
- package/README.md +435 -0
- package/bin/flint.js +47 -0
- package/config/classifier-prompt.md +218 -0
- package/config/models-curated.json +4 -0
- package/config/providers.json +74 -0
- package/package.json +92 -0
- package/patches/ink+6.8.0.patch +78 -0
- package/profiles/desktop.md +65 -0
- package/profiles/generic.md +20 -0
- package/profiles/marketer.md +20 -0
- package/profiles/profiles.json +34 -0
- package/profiles/ux-reviewer.md +25 -0
- package/src/agent/agent.js +1743 -0
- package/src/agent/auto.js +346 -0
- package/src/agent/backoff.js +143 -0
- package/src/agent/compression.js +310 -0
- package/src/agent/content-resolver.js +180 -0
- package/src/agent/flow-controller.js +309 -0
- package/src/agent/intent-manifest.js +231 -0
- package/src/agent/intent-timeout.js +46 -0
- package/src/agent/intent.js +633 -0
- package/src/agent/knowledge.js +114 -0
- package/src/agent/learning.js +180 -0
- package/src/agent/modes.js +187 -0
- package/src/agent/outcome-ask.js +91 -0
- package/src/agent/project-context.js +76 -0
- package/src/agent/prompt-budget.js +117 -0
- package/src/agent/reflection-extractor.js +140 -0
- package/src/agent/steering.js +86 -0
- package/src/agent/supervisor.js +430 -0
- package/src/agent/swap.js +443 -0
- package/src/agent/system-prompt.js +446 -0
- package/src/agent/time-stamp.js +48 -0
- package/src/agent/tool-guard.js +201 -0
- package/src/agent/toolcall-text.js +162 -0
- package/src/agent/usage.js +297 -0
- package/src/agent/vision.js +94 -0
- package/src/agent/watchdog.js +139 -0
- package/src/agent/workspace-changes.js +177 -0
- package/src/api/address.js +14 -0
- package/src/api/client.js +280 -0
- package/src/api/server.js +535 -0
- package/src/api/stream-pipe.js +113 -0
- package/src/app-state.js +39 -0
- package/src/bootstrap.js +501 -0
- package/src/bus/drain-loop.js +497 -0
- package/src/bus/index.js +270 -0
- package/src/bus/plugins.js +65 -0
- package/src/child-idle.js +14 -0
- package/src/cli.js +118 -0
- package/src/commands/commands.js +1297 -0
- package/src/commands/registry.js +132 -0
- package/src/components/App.js +491 -0
- package/src/components/CarefulMenu.js +145 -0
- package/src/components/HistoryWriter.js +86 -0
- package/src/components/LineInput.js +69 -0
- package/src/components/LiveZone.js +294 -0
- package/src/components/OverlayMenu.js +179 -0
- package/src/components/SystemPanel.js +156 -0
- package/src/components/Table.js +54 -0
- package/src/config.js +249 -0
- package/src/free-models.js +230 -0
- package/src/index.js +1111 -0
- package/src/input-handler.js +13 -0
- package/src/input-text.js +123 -0
- package/src/launcher.js +129 -0
- package/src/logging/api-log.js +95 -0
- package/src/logging/chat-log-follower.js +113 -0
- package/src/logging/chat-log.js +15 -0
- package/src/logging/log-collector.js +182 -0
- package/src/logging/logger.js +112 -0
- package/src/logging/tool-log.js +20 -0
- package/src/mcp-client.js +314 -0
- package/src/memory/conversation-digest.js +113 -0
- package/src/memory/extract-facts.js +98 -0
- package/src/memory/facts.js +181 -0
- package/src/memory/inbox.js +63 -0
- package/src/memory/markdown.js +38 -0
- package/src/memory/patterns.js +185 -0
- package/src/memory/project.js +66 -0
- package/src/memory/reflections.js +74 -0
- package/src/memory/retrieval.js +84 -0
- package/src/memory/rules.js +105 -0
- package/src/memory/session-facts.js +125 -0
- package/src/memory/skills.js +191 -0
- package/src/memory/sqlite-store.js +653 -0
- package/src/memory/store.js +208 -0
- package/src/memory/tools.js +196 -0
- package/src/memory/user-model.js +86 -0
- package/src/message-handler.js +775 -0
- package/src/model-check.js +218 -0
- package/src/plugins/loader.js +120 -0
- package/src/plugins/manager.js +88 -0
- package/src/production-env.js +22 -0
- package/src/profiles.js +42 -0
- package/src/providers/adapters/anthropic.js +270 -0
- package/src/providers/adapters/openai.js +120 -0
- package/src/providers/keys-dpapi.js +41 -0
- package/src/providers/keys-fallback.js +31 -0
- package/src/providers/keys.js +132 -0
- package/src/providers/models.js +154 -0
- package/src/providers/registry.js +56 -0
- package/src/providers/state.js +56 -0
- package/src/registry.js +96 -0
- package/src/restart.js +29 -0
- package/src/sandbox/backend.js +130 -0
- package/src/security/api-auth.js +132 -0
- package/src/security/audit.js +98 -0
- package/src/security/child-policy.js +41 -0
- package/src/security/command-guard.js +173 -0
- package/src/security/content-fence.js +250 -0
- package/src/security/content-validator.js +132 -0
- package/src/security/index.js +143 -0
- package/src/security/network-guard.js +126 -0
- package/src/security/pairing.js +180 -0
- package/src/security/path-guard.js +140 -0
- package/src/security/persona-guard.js +67 -0
- package/src/security/policies.js +452 -0
- package/src/security/safety-constants.js +34 -0
- package/src/security/watchdog.js +107 -0
- package/src/sessions.js +130 -0
- package/src/spend.js +97 -0
- package/src/startup-watchdog.js +59 -0
- package/src/stdio/args.js +71 -0
- package/src/stdio/guard.js +59 -0
- package/src/stdio/protocol.js +167 -0
- package/src/stdio/run.js +106 -0
- package/src/stdio/session.js +180 -0
- package/src/store/agent-slice.js +306 -0
- package/src/store/dataset-slice.js +73 -0
- package/src/store/index.js +22 -0
- package/src/store/process-slice.js +135 -0
- package/src/store/session-slice.js +191 -0
- package/src/store/ui-slice.js +119 -0
- package/src/tasks/db.js +184 -0
- package/src/tasks/queries.js +589 -0
- package/src/tools/agent-tools.js +473 -0
- package/src/tools/checkpoint.js +152 -0
- package/src/tools/command-approvals.js +180 -0
- package/src/tools/dataset.js +50 -0
- package/src/tools/filesystem.js +682 -0
- package/src/tools/inbox-tools.js +48 -0
- package/src/tools/mesh.js +135 -0
- package/src/tools/own-env.js +136 -0
- package/src/tools/permissions.js +681 -0
- package/src/tools/plugin-tools.js +123 -0
- package/src/tools/process-tools.js +595 -0
- package/src/tools/registry.js +307 -0
- package/src/tools/swap-tools.js +72 -0
- package/src/tools/system.js +662 -0
- package/src/tools/tasks.js +532 -0
- package/src/tools/tool-search.js +171 -0
- package/src/ui/header.js +140 -0
- package/src/ui/input-cursor.js +23 -0
- package/src/ui/last-line.js +25 -0
- package/src/ui/line-edit.js +135 -0
- package/src/ui/output.js +399 -0
- package/src/ui/paste-tokens.js +131 -0
- package/src/ui/prompt-attention.js +134 -0
- package/src/ui/render-options.js +13 -0
- package/src/ui/replay.js +94 -0
- package/src/ui/splash.js +49 -0
- package/src/ui/status-level.js +36 -0
- package/src/ui/tool-ledger.js +203 -0
- package/src/ui/window-title.js +150 -0
- package/src/update.js +205 -0
- 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
|
+
}
|