doer-agent 0.4.7 → 0.4.9
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/README.md +1 -1
- package/dist/agent-codex-cli.js +26 -0
- package/dist/agent-daemon-rpc.js +481 -0
- package/dist/agent-run-state.js +61 -3
- package/dist/agent-runtime-utils.js +3 -0
- package/dist/agent.js +21 -4
- package/dist/daemon-log-runner.js +212 -0
- package/dist/daemon-mcp-server.js +166 -0
- package/package.json +1 -1
- package/runtime/bin/apply_patch +0 -5
package/README.md
CHANGED
|
@@ -142,6 +142,6 @@ curl -X POST 'http://localhost:2020/api/users/<userId>/agent/secret' \
|
|
|
142
142
|
|
|
143
143
|
## 참고
|
|
144
144
|
|
|
145
|
-
- `runtime/`에는 실행 보조 스크립트가 들어 있습니다.
|
|
145
|
+
- `runtime/`에는 Git 인증 등 실행 보조 스크립트가 들어 있습니다.
|
|
146
146
|
- Playwright MCP 프록시는 에이전트 상태 디렉터리(`~/.doer-agent`) 아래 소켓을 사용합니다.
|
|
147
147
|
- 이 저장소에는 예전 README에 있던 `scripts/build.sh`, `scripts/publish.sh`, `docker-compose.dev.yml`이 없습니다. 현재 문서는 실제 파일 구조 기준으로 정리되어 있습니다.
|
package/dist/agent-codex-cli.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
2
4
|
const ANSI_RE = /\u001b\[[0-9;]*m/g;
|
|
3
5
|
function shellSingleQuote(value) {
|
|
4
6
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
@@ -6,6 +8,9 @@ function shellSingleQuote(value) {
|
|
|
6
8
|
function toTomlStringLiteral(value) {
|
|
7
9
|
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
8
10
|
}
|
|
11
|
+
function toTomlStringArray(values) {
|
|
12
|
+
return `[${values.map((value) => toTomlStringLiteral(value)).join(", ")}]`;
|
|
13
|
+
}
|
|
9
14
|
function hasDirectCodexBinary() {
|
|
10
15
|
const result = spawnSync("bash", ["-lc", "command -v codex >/dev/null 2>&1"], {
|
|
11
16
|
stdio: "ignore",
|
|
@@ -27,6 +32,7 @@ export function buildManagedCodexArgs(args) {
|
|
|
27
32
|
...(args.modelInstructionsFile
|
|
28
33
|
? ["--config", `model_instructions_file=${toTomlStringLiteral(args.modelInstructionsFile)}`]
|
|
29
34
|
: []),
|
|
35
|
+
...(args.configOverrides ?? []),
|
|
30
36
|
];
|
|
31
37
|
const imageArgs = args.imagePaths.flatMap((imagePath) => ["--image", imagePath]);
|
|
32
38
|
return [
|
|
@@ -39,6 +45,26 @@ export function buildManagedCodexArgs(args) {
|
|
|
39
45
|
: ["exec", ...imageArgs, ...promptArgs]),
|
|
40
46
|
];
|
|
41
47
|
}
|
|
48
|
+
export function buildDaemonMcpConfigArgs(args) {
|
|
49
|
+
const serverName = args.serverName?.trim() || "doer_daemon";
|
|
50
|
+
const distEntry = path.join(args.agentProjectDir, "dist", "daemon-mcp-server.js");
|
|
51
|
+
const srcEntry = path.join(args.agentProjectDir, "src", "daemon-mcp-server.ts");
|
|
52
|
+
const tsxLoaderPath = path.join(args.agentProjectDir, "node_modules", "tsx", "dist", "loader.mjs");
|
|
53
|
+
const command = process.execPath;
|
|
54
|
+
const commandArgs = existsSync(distEntry)
|
|
55
|
+
? [distEntry, "--workspace-root", args.workspaceRoot]
|
|
56
|
+
: ["--import", tsxLoaderPath, srcEntry, "--workspace-root", args.workspaceRoot];
|
|
57
|
+
return [
|
|
58
|
+
"--config",
|
|
59
|
+
`mcp_servers.${serverName}.command=${toTomlStringLiteral(command)}`,
|
|
60
|
+
"--config",
|
|
61
|
+
`mcp_servers.${serverName}.args=${toTomlStringArray(commandArgs)}`,
|
|
62
|
+
"--config",
|
|
63
|
+
`mcp_servers.${serverName}.env.DOER_DAEMON_WORKSPACE_ROOT=${toTomlStringLiteral(args.workspaceRoot)}`,
|
|
64
|
+
"--config",
|
|
65
|
+
`mcp_servers.${serverName}.enabled=true`,
|
|
66
|
+
];
|
|
67
|
+
}
|
|
42
68
|
export function buildLocalCodexCliCommand(args) {
|
|
43
69
|
const quotedArgs = args.map(shellSingleQuote).join(" ");
|
|
44
70
|
const direct = `exec codex ${quotedArgs}`;
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { StringCodec } from "nats";
|
|
7
|
+
import { buildAgentSettingsEnvPatch } from "./agent-settings.js";
|
|
8
|
+
import { normalizeEnvPatch, sleep } from "./agent-runtime-utils.js";
|
|
9
|
+
import { sendSignalToPid } from "./agent-task-execution.js";
|
|
10
|
+
const daemonRpcCodec = StringCodec();
|
|
11
|
+
const DAEMON_ID_PATTERN = /^[A-Za-z0-9_-]{6,32}$/;
|
|
12
|
+
function getDaemonsRoot(workspaceRoot) {
|
|
13
|
+
return path.join(workspaceRoot, ".doer-agent", "daemons");
|
|
14
|
+
}
|
|
15
|
+
function getDaemonDir(workspaceRoot, daemonId) {
|
|
16
|
+
return path.join(getDaemonsRoot(workspaceRoot), daemonId);
|
|
17
|
+
}
|
|
18
|
+
function getDaemonStatePath(workspaceRoot, daemonId) {
|
|
19
|
+
return path.join(getDaemonDir(workspaceRoot, daemonId), "state.json");
|
|
20
|
+
}
|
|
21
|
+
function getDaemonEventsPath(workspaceRoot, daemonId) {
|
|
22
|
+
return path.join(getDaemonDir(workspaceRoot, daemonId), "events.jsonl");
|
|
23
|
+
}
|
|
24
|
+
function isPidAlive(pid) {
|
|
25
|
+
if (!Number.isInteger(pid) || (pid ?? 0) <= 0) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
process.kill(pid, 0);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function createDaemonId() {
|
|
37
|
+
return crypto.randomBytes(9).toString("base64url").slice(0, 12);
|
|
38
|
+
}
|
|
39
|
+
function normalizeAction(value) {
|
|
40
|
+
if (value === "list" || value === "inspect" || value === "start" || value === "stop" || value === "restart" || value === "delete" || value === "logs") {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
throw new Error("unsupported action");
|
|
44
|
+
}
|
|
45
|
+
function normalizeDaemonId(value) {
|
|
46
|
+
const daemonId = typeof value === "string" ? value.trim() : "";
|
|
47
|
+
if (!DAEMON_ID_PATTERN.test(daemonId)) {
|
|
48
|
+
throw new Error("invalid daemonId");
|
|
49
|
+
}
|
|
50
|
+
return daemonId;
|
|
51
|
+
}
|
|
52
|
+
function normalizeCommand(value) {
|
|
53
|
+
const command = typeof value === "string" ? value.trim() : "";
|
|
54
|
+
if (!command) {
|
|
55
|
+
throw new Error("command is required");
|
|
56
|
+
}
|
|
57
|
+
return command;
|
|
58
|
+
}
|
|
59
|
+
function normalizeLabel(value) {
|
|
60
|
+
if (typeof value !== "string") {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const label = value.trim();
|
|
64
|
+
return label ? label.slice(0, 120) : null;
|
|
65
|
+
}
|
|
66
|
+
function normalizeLimit(value, fallback) {
|
|
67
|
+
const numeric = Number(value);
|
|
68
|
+
if (!Number.isFinite(numeric)) {
|
|
69
|
+
return fallback;
|
|
70
|
+
}
|
|
71
|
+
return Math.max(1, Math.min(Math.floor(numeric), 1000));
|
|
72
|
+
}
|
|
73
|
+
function normalizeStateRecord(value) {
|
|
74
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const row = value;
|
|
78
|
+
const id = typeof row.id === "string" ? row.id.trim() : "";
|
|
79
|
+
const command = typeof row.command === "string" ? row.command.trim() : "";
|
|
80
|
+
const cwd = typeof row.cwd === "string" ? row.cwd.trim() : "";
|
|
81
|
+
const startedAt = typeof row.startedAt === "string" ? row.startedAt.trim() : "";
|
|
82
|
+
const label = typeof row.label === "string" && row.label.trim() ? row.label.trim() : null;
|
|
83
|
+
const stoppedAt = typeof row.stoppedAt === "string" && row.stoppedAt.trim() ? row.stoppedAt.trim() : null;
|
|
84
|
+
const pid = typeof row.pid === "number" && Number.isInteger(row.pid) && row.pid > 0 ? row.pid : null;
|
|
85
|
+
const runnerPid = typeof row.runnerPid === "number" && Number.isInteger(row.runnerPid) && row.runnerPid > 0 ? row.runnerPid : null;
|
|
86
|
+
const lastExitCode = typeof row.lastExitCode === "number" && Number.isInteger(row.lastExitCode) ? row.lastExitCode : null;
|
|
87
|
+
if (!DAEMON_ID_PATTERN.test(id) || !command || !cwd || !startedAt) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
id,
|
|
92
|
+
label,
|
|
93
|
+
command,
|
|
94
|
+
cwd,
|
|
95
|
+
pid,
|
|
96
|
+
runnerPid,
|
|
97
|
+
startedAt,
|
|
98
|
+
stoppedAt,
|
|
99
|
+
lastExitCode,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function normalizeLogEvent(value) {
|
|
103
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const row = value;
|
|
107
|
+
const ts = typeof row.ts === "string" ? row.ts.trim() : "";
|
|
108
|
+
const type = row.type === "start" ||
|
|
109
|
+
row.type === "stdout" ||
|
|
110
|
+
row.type === "stderr" ||
|
|
111
|
+
row.type === "heartbeat" ||
|
|
112
|
+
row.type === "exit" ||
|
|
113
|
+
row.type === "signal" ||
|
|
114
|
+
row.type === "error"
|
|
115
|
+
? row.type
|
|
116
|
+
: null;
|
|
117
|
+
if (!ts || !type) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
ts,
|
|
122
|
+
type,
|
|
123
|
+
text: typeof row.text === "string" ? row.text : null,
|
|
124
|
+
pid: typeof row.pid === "number" && Number.isInteger(row.pid) && row.pid > 0 ? row.pid : null,
|
|
125
|
+
code: typeof row.code === "number" && Number.isInteger(row.code) ? row.code : null,
|
|
126
|
+
signal: typeof row.signal === "string" && row.signal.trim() ? row.signal.trim() : null,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
async function readDaemonState(workspaceRoot, daemonId) {
|
|
130
|
+
const raw = await readFile(getDaemonStatePath(workspaceRoot, daemonId), "utf8").catch(() => null);
|
|
131
|
+
if (!raw) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const parsed = JSON.parse(raw);
|
|
135
|
+
return normalizeStateRecord(parsed);
|
|
136
|
+
}
|
|
137
|
+
async function writeDaemonState(workspaceRoot, state) {
|
|
138
|
+
await mkdir(getDaemonDir(workspaceRoot, state.id), { recursive: true });
|
|
139
|
+
await writeFile(getDaemonStatePath(workspaceRoot, state.id), `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
140
|
+
}
|
|
141
|
+
function toDaemonSnapshot(state) {
|
|
142
|
+
const runnerAlive = isPidAlive(state.runnerPid);
|
|
143
|
+
const processAlive = isPidAlive(state.pid);
|
|
144
|
+
const status = runnerAlive || processAlive ? "running" : state.lastExitCode !== null && state.lastExitCode !== 0 ? "failed" : "stopped";
|
|
145
|
+
return {
|
|
146
|
+
...state,
|
|
147
|
+
status,
|
|
148
|
+
displayName: state.label ?? state.command,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
async function readJsonlTail(filePath, limit) {
|
|
152
|
+
const raw = await readFile(filePath, "utf8").catch(() => "");
|
|
153
|
+
if (!raw) {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
return raw
|
|
157
|
+
.split("\n")
|
|
158
|
+
.map((line) => line.trim())
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
.slice(-limit)
|
|
161
|
+
.map((line) => {
|
|
162
|
+
try {
|
|
163
|
+
return normalizeLogEvent(JSON.parse(line));
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
.filter((event) => Boolean(event));
|
|
170
|
+
}
|
|
171
|
+
function resolveDaemonRunnerEntry(agentProjectDir) {
|
|
172
|
+
const distEntry = path.join(agentProjectDir, "dist", "daemon-log-runner.js");
|
|
173
|
+
const srcEntry = path.join(agentProjectDir, "src", "daemon-log-runner.ts");
|
|
174
|
+
const tsxLoaderPath = path.join(agentProjectDir, "node_modules", "tsx", "dist", "loader.mjs");
|
|
175
|
+
if (existsSync(distEntry)) {
|
|
176
|
+
return {
|
|
177
|
+
command: process.execPath,
|
|
178
|
+
args: [distEntry],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
command: process.execPath,
|
|
183
|
+
args: ["--import", tsxLoaderPath, srcEntry],
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
export async function listAgentDaemonsLocal(workspaceRoot) {
|
|
187
|
+
const root = getDaemonsRoot(workspaceRoot);
|
|
188
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
189
|
+
const states = await Promise.all(entries
|
|
190
|
+
.filter((entry) => entry.isDirectory() && DAEMON_ID_PATTERN.test(entry.name))
|
|
191
|
+
.map(async (entry) => readDaemonState(workspaceRoot, entry.name)));
|
|
192
|
+
return states
|
|
193
|
+
.filter((state) => Boolean(state))
|
|
194
|
+
.map((state) => toDaemonSnapshot(state))
|
|
195
|
+
.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
196
|
+
}
|
|
197
|
+
export async function getAgentDaemonLocal(workspaceRoot, daemonId) {
|
|
198
|
+
const state = await readDaemonState(workspaceRoot, daemonId);
|
|
199
|
+
if (!state) {
|
|
200
|
+
throw new Error("daemon not found");
|
|
201
|
+
}
|
|
202
|
+
return toDaemonSnapshot(state);
|
|
203
|
+
}
|
|
204
|
+
export async function stopAgentDaemonLocal(workspaceRoot, daemonId) {
|
|
205
|
+
const state = await readDaemonState(workspaceRoot, daemonId);
|
|
206
|
+
if (!state) {
|
|
207
|
+
throw new Error("daemon not found");
|
|
208
|
+
}
|
|
209
|
+
const targetPid = isPidAlive(state.runnerPid) ? state.runnerPid : state.pid;
|
|
210
|
+
if (!targetPid) {
|
|
211
|
+
const stopped = {
|
|
212
|
+
...state,
|
|
213
|
+
pid: null,
|
|
214
|
+
runnerPid: null,
|
|
215
|
+
stoppedAt: state.stoppedAt ?? new Date().toISOString(),
|
|
216
|
+
};
|
|
217
|
+
await writeDaemonState(workspaceRoot, stopped);
|
|
218
|
+
return toDaemonSnapshot(stopped);
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
sendSignalToPid(targetPid, "SIGTERM");
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// Ignore and fall through to polling.
|
|
225
|
+
}
|
|
226
|
+
for (let index = 0; index < 30; index += 1) {
|
|
227
|
+
await sleep(100);
|
|
228
|
+
const latest = await readDaemonState(workspaceRoot, daemonId);
|
|
229
|
+
if (!latest || (!isPidAlive(latest.runnerPid) && !isPidAlive(latest.pid))) {
|
|
230
|
+
return latest ? toDaemonSnapshot(latest) : toDaemonSnapshot({
|
|
231
|
+
...state,
|
|
232
|
+
pid: null,
|
|
233
|
+
runnerPid: null,
|
|
234
|
+
stoppedAt: new Date().toISOString(),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
try {
|
|
239
|
+
sendSignalToPid(targetPid, "SIGKILL");
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// noop
|
|
243
|
+
}
|
|
244
|
+
for (let index = 0; index < 15; index += 1) {
|
|
245
|
+
await sleep(100);
|
|
246
|
+
const latest = await readDaemonState(workspaceRoot, daemonId);
|
|
247
|
+
if (!latest || (!isPidAlive(latest.runnerPid) && !isPidAlive(latest.pid))) {
|
|
248
|
+
return latest ? toDaemonSnapshot(latest) : toDaemonSnapshot({
|
|
249
|
+
...state,
|
|
250
|
+
pid: null,
|
|
251
|
+
runnerPid: null,
|
|
252
|
+
stoppedAt: new Date().toISOString(),
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
throw new Error("failed to stop daemon");
|
|
257
|
+
}
|
|
258
|
+
async function spawnDaemonLocal(args) {
|
|
259
|
+
const daemonId = args.daemonId ?? createDaemonId();
|
|
260
|
+
const daemonDir = getDaemonDir(args.workspaceRoot, daemonId);
|
|
261
|
+
const statePath = getDaemonStatePath(args.workspaceRoot, daemonId);
|
|
262
|
+
const eventsPath = getDaemonEventsPath(args.workspaceRoot, daemonId);
|
|
263
|
+
await mkdir(daemonDir, { recursive: true });
|
|
264
|
+
await mkdir(path.join(args.workspaceRoot, ".codex"), { recursive: true });
|
|
265
|
+
const settings = await args.readAgentSettingsConfig({ workspaceRoot: args.workspaceRoot });
|
|
266
|
+
const runtimeBinPath = path.join(args.agentProjectDir, "runtime/bin");
|
|
267
|
+
const baseState = {
|
|
268
|
+
id: daemonId,
|
|
269
|
+
label: args.label,
|
|
270
|
+
command: args.command,
|
|
271
|
+
cwd: args.cwd,
|
|
272
|
+
pid: null,
|
|
273
|
+
runnerPid: null,
|
|
274
|
+
startedAt: new Date().toISOString(),
|
|
275
|
+
stoppedAt: null,
|
|
276
|
+
lastExitCode: null,
|
|
277
|
+
};
|
|
278
|
+
await writeDaemonState(args.workspaceRoot, baseState);
|
|
279
|
+
const env = {
|
|
280
|
+
...process.env,
|
|
281
|
+
...buildAgentSettingsEnvPatch(settings),
|
|
282
|
+
...args.envPatch,
|
|
283
|
+
WORKSPACE: args.cwd,
|
|
284
|
+
CODEX_HOME: path.join(args.workspaceRoot, ".codex"),
|
|
285
|
+
PATH: [runtimeBinPath, process.env.PATH || ""].filter(Boolean).join(path.delimiter),
|
|
286
|
+
DOER_DAEMON_ID: daemonId,
|
|
287
|
+
DOER_DAEMON_COMMAND: args.command,
|
|
288
|
+
DOER_DAEMON_CWD: args.cwd,
|
|
289
|
+
DOER_DAEMON_STATE_PATH: statePath,
|
|
290
|
+
DOER_DAEMON_EVENTS_PATH: eventsPath,
|
|
291
|
+
DOER_DAEMON_SHELL_PATH: args.resolveShellPath(),
|
|
292
|
+
};
|
|
293
|
+
const runner = resolveDaemonRunnerEntry(args.agentProjectDir);
|
|
294
|
+
const child = spawn(runner.command, runner.args, {
|
|
295
|
+
cwd: args.cwd,
|
|
296
|
+
env,
|
|
297
|
+
detached: process.platform !== "win32",
|
|
298
|
+
stdio: "ignore",
|
|
299
|
+
});
|
|
300
|
+
if (typeof child.pid !== "number" || child.pid <= 0) {
|
|
301
|
+
throw new Error("failed to start daemon process");
|
|
302
|
+
}
|
|
303
|
+
child.unref();
|
|
304
|
+
await writeDaemonState(args.workspaceRoot, {
|
|
305
|
+
...baseState,
|
|
306
|
+
runnerPid: child.pid,
|
|
307
|
+
});
|
|
308
|
+
for (let index = 0; index < 20; index += 1) {
|
|
309
|
+
await sleep(50);
|
|
310
|
+
const latest = await readDaemonState(args.workspaceRoot, daemonId);
|
|
311
|
+
if (latest && (latest.pid || latest.lastExitCode !== null)) {
|
|
312
|
+
return toDaemonSnapshot(latest);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const latest = await readDaemonState(args.workspaceRoot, daemonId);
|
|
316
|
+
return toDaemonSnapshot(latest ?? { ...baseState, runnerPid: child.pid });
|
|
317
|
+
}
|
|
318
|
+
export async function startAgentDaemonLocal(args) {
|
|
319
|
+
return await spawnDaemonLocal({
|
|
320
|
+
workspaceRoot: args.workspaceRoot,
|
|
321
|
+
agentProjectDir: args.agentProjectDir,
|
|
322
|
+
command: normalizeCommand(args.request.command),
|
|
323
|
+
cwd: args.resolveTaskWorkspace(typeof args.request.cwd === "string" ? args.request.cwd : null),
|
|
324
|
+
label: normalizeLabel(args.request.label),
|
|
325
|
+
envPatch: normalizeEnvPatch(args.request.envPatch),
|
|
326
|
+
resolveShellPath: args.resolveShellPath,
|
|
327
|
+
readAgentSettingsConfig: args.readAgentSettingsConfig,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
export async function restartAgentDaemonLocal(args) {
|
|
331
|
+
const state = await readDaemonState(args.workspaceRoot, args.daemonId);
|
|
332
|
+
if (!state) {
|
|
333
|
+
throw new Error("daemon not found");
|
|
334
|
+
}
|
|
335
|
+
if (isPidAlive(state.runnerPid) || isPidAlive(state.pid)) {
|
|
336
|
+
await stopAgentDaemonLocal(args.workspaceRoot, args.daemonId);
|
|
337
|
+
}
|
|
338
|
+
return await spawnDaemonLocal({
|
|
339
|
+
workspaceRoot: args.workspaceRoot,
|
|
340
|
+
agentProjectDir: args.agentProjectDir,
|
|
341
|
+
daemonId: state.id,
|
|
342
|
+
command: state.command,
|
|
343
|
+
cwd: state.cwd,
|
|
344
|
+
label: state.label,
|
|
345
|
+
envPatch: {},
|
|
346
|
+
resolveShellPath: args.resolveShellPath,
|
|
347
|
+
readAgentSettingsConfig: args.readAgentSettingsConfig,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
export async function deleteAgentDaemonLocal(workspaceRoot, daemonId) {
|
|
351
|
+
const state = await readDaemonState(workspaceRoot, daemonId);
|
|
352
|
+
if (!state) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (isPidAlive(state.runnerPid) || isPidAlive(state.pid)) {
|
|
356
|
+
await stopAgentDaemonLocal(workspaceRoot, daemonId);
|
|
357
|
+
}
|
|
358
|
+
await rm(getDaemonDir(workspaceRoot, daemonId), { recursive: true, force: true });
|
|
359
|
+
}
|
|
360
|
+
export async function readAgentDaemonLogsLocal(args) {
|
|
361
|
+
const state = await readDaemonState(args.workspaceRoot, args.daemonId);
|
|
362
|
+
if (!state) {
|
|
363
|
+
throw new Error("daemon not found");
|
|
364
|
+
}
|
|
365
|
+
const limit = normalizeLimit(args.limit, 100);
|
|
366
|
+
return {
|
|
367
|
+
daemon: toDaemonSnapshot(state),
|
|
368
|
+
events: await readJsonlTail(getDaemonEventsPath(args.workspaceRoot, args.daemonId), limit),
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
async function executeDaemonRpc(args) {
|
|
372
|
+
const action = normalizeAction(args.request.action);
|
|
373
|
+
if (action === "list") {
|
|
374
|
+
return {
|
|
375
|
+
ok: true,
|
|
376
|
+
action,
|
|
377
|
+
daemons: await listAgentDaemonsLocal(args.workspaceRoot),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if (action === "inspect") {
|
|
381
|
+
const daemonId = normalizeDaemonId(args.request.daemonId);
|
|
382
|
+
return {
|
|
383
|
+
ok: true,
|
|
384
|
+
action,
|
|
385
|
+
daemon: await getAgentDaemonLocal(args.workspaceRoot, daemonId),
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
if (action === "start") {
|
|
389
|
+
return {
|
|
390
|
+
ok: true,
|
|
391
|
+
action,
|
|
392
|
+
daemon: await startAgentDaemonLocal({
|
|
393
|
+
workspaceRoot: args.workspaceRoot,
|
|
394
|
+
agentProjectDir: args.agentProjectDir,
|
|
395
|
+
request: args.request,
|
|
396
|
+
resolveShellPath: args.resolveShellPath,
|
|
397
|
+
resolveTaskWorkspace: args.resolveTaskWorkspace,
|
|
398
|
+
readAgentSettingsConfig: args.readAgentSettingsConfig,
|
|
399
|
+
}),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
const daemonId = normalizeDaemonId(args.request.daemonId);
|
|
403
|
+
if (action === "stop") {
|
|
404
|
+
return {
|
|
405
|
+
ok: true,
|
|
406
|
+
action,
|
|
407
|
+
daemon: await stopAgentDaemonLocal(args.workspaceRoot, daemonId),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
if (action === "restart") {
|
|
411
|
+
return {
|
|
412
|
+
ok: true,
|
|
413
|
+
action,
|
|
414
|
+
daemon: await restartAgentDaemonLocal({
|
|
415
|
+
workspaceRoot: args.workspaceRoot,
|
|
416
|
+
agentProjectDir: args.agentProjectDir,
|
|
417
|
+
daemonId,
|
|
418
|
+
resolveShellPath: args.resolveShellPath,
|
|
419
|
+
readAgentSettingsConfig: args.readAgentSettingsConfig,
|
|
420
|
+
}),
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
if (action === "delete") {
|
|
424
|
+
await deleteAgentDaemonLocal(args.workspaceRoot, daemonId);
|
|
425
|
+
return {
|
|
426
|
+
ok: true,
|
|
427
|
+
action,
|
|
428
|
+
daemonId,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
const limit = normalizeLimit(args.request.limit, 100);
|
|
432
|
+
return {
|
|
433
|
+
ok: true,
|
|
434
|
+
action,
|
|
435
|
+
...(await readAgentDaemonLogsLocal({ workspaceRoot: args.workspaceRoot, daemonId, limit })),
|
|
436
|
+
limit,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
export async function handleDaemonRpcMessage(args) {
|
|
440
|
+
let requestId = "unknown";
|
|
441
|
+
try {
|
|
442
|
+
const request = JSON.parse(daemonRpcCodec.decode(args.msg.data));
|
|
443
|
+
requestId = typeof request.requestId === "string" ? request.requestId : "unknown";
|
|
444
|
+
const payload = await executeDaemonRpc({
|
|
445
|
+
workspaceRoot: args.workspaceRoot,
|
|
446
|
+
agentProjectDir: args.agentProjectDir,
|
|
447
|
+
request,
|
|
448
|
+
resolveShellPath: args.resolveShellPath,
|
|
449
|
+
resolveTaskWorkspace: args.resolveTaskWorkspace,
|
|
450
|
+
readAgentSettingsConfig: args.readAgentSettingsConfig,
|
|
451
|
+
});
|
|
452
|
+
args.msg.respond(daemonRpcCodec.encode(JSON.stringify({ requestId, ...payload })));
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
456
|
+
args.onError?.(`daemon rpc failed requestId=${requestId} error=${message}`);
|
|
457
|
+
args.msg.respond(daemonRpcCodec.encode(JSON.stringify({ requestId, ok: false, error: message })));
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
export function subscribeToDaemonRpc(args) {
|
|
461
|
+
args.nc.subscribe(args.subject, {
|
|
462
|
+
callback: (error, msg) => {
|
|
463
|
+
if (error) {
|
|
464
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
465
|
+
args.onError(`daemon rpc subscription error: ${message}`);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
void handleDaemonRpcMessage({
|
|
469
|
+
msg,
|
|
470
|
+
nc: args.nc,
|
|
471
|
+
workspaceRoot: args.workspaceRoot,
|
|
472
|
+
agentProjectDir: args.agentProjectDir,
|
|
473
|
+
resolveShellPath: args.resolveShellPath,
|
|
474
|
+
resolveTaskWorkspace: args.resolveTaskWorkspace,
|
|
475
|
+
readAgentSettingsConfig: args.readAgentSettingsConfig,
|
|
476
|
+
onError: args.onError,
|
|
477
|
+
});
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
args.onInfo(`daemon rpc subscribed subject=${args.subject}`);
|
|
481
|
+
}
|
package/dist/agent-run-state.js
CHANGED
|
@@ -72,10 +72,68 @@ function buildImmediateRunEvent(task, type) {
|
|
|
72
72
|
finishedAt: task.finishedAt,
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
function isPidAlive(pid) {
|
|
76
|
+
if (!Number.isInteger(pid) || (pid ?? 0) <= 0) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
process.kill(pid, 0);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const code = error?.code;
|
|
85
|
+
return code === "EPERM";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
async function removePathIfStale(pathToRemove, pid) {
|
|
89
|
+
if (isPidAlive(pid)) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
await rm(pathToRemove, { recursive: true, force: true }).catch(() => undefined);
|
|
93
|
+
}
|
|
94
|
+
export async function pruneStaleRunsDir(workspaceRoot) {
|
|
76
95
|
const dir = await resolveRunsDir(workspaceRoot);
|
|
77
|
-
await
|
|
78
|
-
|
|
96
|
+
const names = await readdir(dir).catch(() => []);
|
|
97
|
+
for (const name of names) {
|
|
98
|
+
const entryPath = path.join(dir, name);
|
|
99
|
+
if (name === "locks") {
|
|
100
|
+
const lockNames = await readdir(entryPath).catch(() => []);
|
|
101
|
+
for (const lockName of lockNames) {
|
|
102
|
+
const lockPath = path.join(entryPath, lockName);
|
|
103
|
+
const contents = await readFile(lockPath, "utf8").catch(() => null);
|
|
104
|
+
if (!contents) {
|
|
105
|
+
await rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
let pid = null;
|
|
109
|
+
try {
|
|
110
|
+
const parsed = JSON.parse(contents);
|
|
111
|
+
pid = typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0 ? parsed.pid : null;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
pid = null;
|
|
115
|
+
}
|
|
116
|
+
await removePathIfStale(lockPath, pid);
|
|
117
|
+
}
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!name.endsWith(".json")) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const contents = await readFile(entryPath, "utf8").catch(() => null);
|
|
124
|
+
if (!contents) {
|
|
125
|
+
await rm(entryPath, { recursive: true, force: true }).catch(() => undefined);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
let task = null;
|
|
129
|
+
try {
|
|
130
|
+
task = normalizePersistedRunTask(JSON.parse(contents));
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
task = null;
|
|
134
|
+
}
|
|
135
|
+
await removePathIfStale(entryPath, task?.processPid ?? null);
|
|
136
|
+
}
|
|
79
137
|
}
|
|
80
138
|
export async function persistRunTask(workspaceRoot, task) {
|
|
81
139
|
const dir = await resolveRunsDir(workspaceRoot);
|
|
@@ -28,6 +28,9 @@ export function buildAgentSkillRpcSubject(userId, agentId) {
|
|
|
28
28
|
export function buildAgentFsRpcSubject(userId, agentId) {
|
|
29
29
|
return `doer.agent.fs.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
|
|
30
30
|
}
|
|
31
|
+
export function buildAgentDaemonRpcSubject(userId, agentId) {
|
|
32
|
+
return `doer.agent.daemon.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
|
|
33
|
+
}
|
|
31
34
|
export function parseBootstrapTaskConfig(value) {
|
|
32
35
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
33
36
|
return null;
|
package/dist/agent.js
CHANGED
|
@@ -6,17 +6,18 @@ import { buildAgentSettingsEnvPatch, readAgentModelInstructions, readAgentSettin
|
|
|
6
6
|
import { handleFsRpcMessage } from "./agent-fs-rpc.js";
|
|
7
7
|
import { handleGitRpcMessage } from "./agent-git-rpc.js";
|
|
8
8
|
import { subscribeToCodexAuthRpc } from "./agent-codex-auth-rpc.js";
|
|
9
|
-
import {
|
|
9
|
+
import { subscribeToDaemonRpc } from "./agent-daemon-rpc.js";
|
|
10
|
+
import { buildDaemonMcpConfigArgs, buildManagedCodexArgs, createLocalCodexCliTools, normalizeCodexModel, normalizeShellRpcCodexAuthBundle, spawnManagedCodexCommand, } from "./agent-codex-cli.js";
|
|
10
11
|
import { connectBootstrapWithRetry } from "./agent-jetstream.js";
|
|
11
12
|
import { prepareCommandExecution } from "./agent-run-execution.js";
|
|
12
13
|
import { attachManagedRunProcessLifecycle, createPendingRunSessionTracker } from "./agent-run-lifecycle.js";
|
|
13
|
-
import { claimRunStartSlot, cloneRunTask, getStoredRun, listPersistedRunTasks, persistRunTask, publishImmediateRunEvent,
|
|
14
|
+
import { claimRunStartSlot, cloneRunTask, getStoredRun, listPersistedRunTasks, persistRunTask, publishImmediateRunEvent, pruneStaleRunsDir, releaseRunStartSlot, removeRunTask, updateRunStartSlotSession, } from "./agent-run-state.js";
|
|
14
15
|
import { runConnectedAgentSession } from "./agent-session-loop.js";
|
|
15
16
|
import { subscribeToSkillRpc } from "./agent-skill-rpc.js";
|
|
16
17
|
import { prepareCodexAuthBundle, sendSignalToPid, sendSignalToTaskProcess, } from "./agent-task-execution.js";
|
|
17
18
|
import { collectSessionJsonlFiles, detectPendingRunSession, findSessionFilePathBySessionId, stopAllSessionWatchers, subscribeToSessionRpc, } from "./agent-session-rpc.js";
|
|
18
19
|
import { handleNonStartRunRpc, normalizeRunRpcRequest, publishRunRpcResponse, } from "./agent-run-rpc.js";
|
|
19
|
-
import { buildAgentCodexAuthRpcSubject, buildAgentFsRpcSubject, buildAgentGitRpcSubject, buildAgentRunEventsSubject, buildAgentRunRpcSubject, buildAgentSessionRpcSubject, buildAgentSettingsRpcSubject, buildAgentSkillRpcSubject, formatLocalTimestamp, normalizeEnvPatch, filterValidRunImagePaths, normalizeRunImagePaths, parseArgs, resolveAgentVersion, resolveArgOrEnv, resolveContainerReachableServerBaseUrl, sanitizeUserId, sleep, writeRunStatus, writeRunStream, } from "./agent-runtime-utils.js";
|
|
20
|
+
import { buildAgentCodexAuthRpcSubject, buildAgentDaemonRpcSubject, buildAgentFsRpcSubject, buildAgentGitRpcSubject, buildAgentRunEventsSubject, buildAgentRunRpcSubject, buildAgentSessionRpcSubject, buildAgentSettingsRpcSubject, buildAgentSkillRpcSubject, formatLocalTimestamp, normalizeEnvPatch, filterValidRunImagePaths, normalizeRunImagePaths, parseArgs, resolveAgentVersion, resolveArgOrEnv, resolveContainerReachableServerBaseUrl, sanitizeUserId, sleep, writeRunStatus, writeRunStream, } from "./agent-runtime-utils.js";
|
|
20
21
|
import { createRuntimeEnvHelpers } from "./agent-runtime-env.js";
|
|
21
22
|
import { createEventPersistenceHelpers, heartbeatAgentSession, postJson, } from "./agent-runtime-io.js";
|
|
22
23
|
import { handleSettingsRpcMessage } from "./agent-settings-rpc.js";
|
|
@@ -273,6 +274,10 @@ async function handleRunRpcMessage(args) {
|
|
|
273
274
|
model: request.model,
|
|
274
275
|
personality: localAgentSettings.general.personality,
|
|
275
276
|
modelInstructionsFile: customInstructions ? resolveAgentModelInstructionsFilePath(workspaceRoot) : null,
|
|
277
|
+
configOverrides: buildDaemonMcpConfigArgs({
|
|
278
|
+
agentProjectDir: AGENT_PROJECT_DIR,
|
|
279
|
+
workspaceRoot,
|
|
280
|
+
}),
|
|
276
281
|
}),
|
|
277
282
|
cwd: request.cwd,
|
|
278
283
|
runtimeEnvPatch: request.runtimeEnvPatch,
|
|
@@ -406,7 +411,8 @@ async function main() {
|
|
|
406
411
|
process.env.WORKSPACE = startupWorkspaceRoot;
|
|
407
412
|
process.env.CODEX_HOME = path.join(startupWorkspaceRoot, ".codex");
|
|
408
413
|
await mkdir(process.env.CODEX_HOME, { recursive: true }).catch(() => undefined);
|
|
409
|
-
|
|
414
|
+
// Preserve run state for processes that are still alive after an agent restart.
|
|
415
|
+
await pruneStaleRunsDir(resolveWorkspaceRoot());
|
|
410
416
|
const serverBaseUrlRaw = resolveArgOrEnv(args, ["server", "url"], ["DOER_AGENT_SERVER"], DEFAULT_SERVER_BASE_URL);
|
|
411
417
|
const requestedServerBaseUrl = serverBaseUrlRaw.replace(/\/$/, "");
|
|
412
418
|
const serverBaseUrl = resolveContainerReachableServerBaseUrl(requestedServerBaseUrl);
|
|
@@ -472,6 +478,17 @@ async function main() {
|
|
|
472
478
|
agentId: initialAgentId,
|
|
473
479
|
agentToken,
|
|
474
480
|
});
|
|
481
|
+
subscribeToDaemonRpc({
|
|
482
|
+
nc: jetstream.nc,
|
|
483
|
+
subject: buildAgentDaemonRpcSubject(userId, initialAgentId),
|
|
484
|
+
workspaceRoot: resolveWorkspaceRoot(),
|
|
485
|
+
agentProjectDir: AGENT_PROJECT_DIR,
|
|
486
|
+
resolveShellPath: runtimeEnvHelpers.resolveShellPath,
|
|
487
|
+
resolveTaskWorkspace: runtimeEnvHelpers.resolveTaskWorkspace,
|
|
488
|
+
readAgentSettingsConfig,
|
|
489
|
+
onInfo: writeAgentInfo,
|
|
490
|
+
onError: writeAgentError,
|
|
491
|
+
});
|
|
475
492
|
subscribeToSessionRpc({
|
|
476
493
|
nc: jetstream.nc,
|
|
477
494
|
subject: buildAgentSessionRpcSubject(userId, initialAgentId),
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { appendFile, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
const DEFAULT_HEARTBEAT_MS = 15_000;
|
|
4
|
+
function readRequiredEnv(name) {
|
|
5
|
+
const value = process.env[name]?.trim() || "";
|
|
6
|
+
if (!value) {
|
|
7
|
+
throw new Error(`${name} is required`);
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
function readHeartbeatIntervalMs() {
|
|
12
|
+
const raw = process.env.DOER_DAEMON_HEARTBEAT_MS?.trim();
|
|
13
|
+
if (!raw) {
|
|
14
|
+
return DEFAULT_HEARTBEAT_MS;
|
|
15
|
+
}
|
|
16
|
+
const numeric = Number(raw);
|
|
17
|
+
if (!Number.isFinite(numeric) || numeric < 1_000) {
|
|
18
|
+
return DEFAULT_HEARTBEAT_MS;
|
|
19
|
+
}
|
|
20
|
+
return Math.floor(numeric);
|
|
21
|
+
}
|
|
22
|
+
async function readState(statePath) {
|
|
23
|
+
const raw = await readFile(statePath, "utf8");
|
|
24
|
+
return JSON.parse(raw);
|
|
25
|
+
}
|
|
26
|
+
async function writeState(statePath, state) {
|
|
27
|
+
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
28
|
+
}
|
|
29
|
+
async function appendEvent(eventsPath, event) {
|
|
30
|
+
const row = {
|
|
31
|
+
ts: new Date().toISOString(),
|
|
32
|
+
type: event.type,
|
|
33
|
+
text: event.text ?? null,
|
|
34
|
+
pid: typeof event.pid === "number" && event.pid > 0 ? event.pid : null,
|
|
35
|
+
code: typeof event.code === "number" ? event.code : null,
|
|
36
|
+
signal: event.signal?.trim() || null,
|
|
37
|
+
};
|
|
38
|
+
await appendFile(eventsPath, `${JSON.stringify(row)}\n`, "utf8");
|
|
39
|
+
}
|
|
40
|
+
function attachLineLogger(stream, type, eventsPath, pid, onActivity) {
|
|
41
|
+
if (!stream) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
stream.setEncoding("utf8");
|
|
45
|
+
let pending = "";
|
|
46
|
+
stream.on("data", (chunk) => {
|
|
47
|
+
onActivity?.();
|
|
48
|
+
pending += chunk;
|
|
49
|
+
const lines = pending.split(/\r\n|\n|\r/);
|
|
50
|
+
pending = lines.pop() ?? "";
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
void appendEvent(eventsPath, {
|
|
53
|
+
type,
|
|
54
|
+
text: line,
|
|
55
|
+
pid,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
stream.on("end", () => {
|
|
60
|
+
if (!pending) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
void appendEvent(eventsPath, {
|
|
64
|
+
type,
|
|
65
|
+
text: pending,
|
|
66
|
+
pid,
|
|
67
|
+
});
|
|
68
|
+
pending = "";
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
async function main() {
|
|
72
|
+
const statePath = readRequiredEnv("DOER_DAEMON_STATE_PATH");
|
|
73
|
+
const eventsPath = readRequiredEnv("DOER_DAEMON_EVENTS_PATH");
|
|
74
|
+
const command = readRequiredEnv("DOER_DAEMON_COMMAND");
|
|
75
|
+
const cwd = readRequiredEnv("DOER_DAEMON_CWD");
|
|
76
|
+
const shellPath = readRequiredEnv("DOER_DAEMON_SHELL_PATH");
|
|
77
|
+
const heartbeatIntervalMs = readHeartbeatIntervalMs();
|
|
78
|
+
const childEnv = { ...process.env };
|
|
79
|
+
delete childEnv.DOER_DAEMON_STATE_PATH;
|
|
80
|
+
delete childEnv.DOER_DAEMON_EVENTS_PATH;
|
|
81
|
+
delete childEnv.DOER_DAEMON_COMMAND;
|
|
82
|
+
delete childEnv.DOER_DAEMON_CWD;
|
|
83
|
+
delete childEnv.DOER_DAEMON_SHELL_PATH;
|
|
84
|
+
let state = await readState(statePath);
|
|
85
|
+
state = {
|
|
86
|
+
...state,
|
|
87
|
+
runnerPid: process.pid,
|
|
88
|
+
};
|
|
89
|
+
await writeState(statePath, state);
|
|
90
|
+
const child = spawn(command, {
|
|
91
|
+
cwd,
|
|
92
|
+
env: childEnv,
|
|
93
|
+
shell: shellPath,
|
|
94
|
+
detached: false,
|
|
95
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
96
|
+
});
|
|
97
|
+
if (typeof child.pid !== "number" || child.pid <= 0) {
|
|
98
|
+
throw new Error("failed to spawn daemon child");
|
|
99
|
+
}
|
|
100
|
+
state = {
|
|
101
|
+
...state,
|
|
102
|
+
pid: child.pid,
|
|
103
|
+
stoppedAt: null,
|
|
104
|
+
lastExitCode: null,
|
|
105
|
+
};
|
|
106
|
+
await writeState(statePath, state);
|
|
107
|
+
await appendEvent(eventsPath, {
|
|
108
|
+
type: "start",
|
|
109
|
+
pid: child.pid,
|
|
110
|
+
});
|
|
111
|
+
let lastActivityAt = Date.now();
|
|
112
|
+
const markActivity = () => {
|
|
113
|
+
lastActivityAt = Date.now();
|
|
114
|
+
};
|
|
115
|
+
attachLineLogger(child.stdout, "stdout", eventsPath, child.pid, markActivity);
|
|
116
|
+
attachLineLogger(child.stderr, "stderr", eventsPath, child.pid, markActivity);
|
|
117
|
+
const heartbeatTimer = setInterval(() => {
|
|
118
|
+
if (child.exitCode !== null || child.killed) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const idleMs = Date.now() - lastActivityAt;
|
|
122
|
+
if (idleMs < heartbeatIntervalMs) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
lastActivityAt = Date.now();
|
|
126
|
+
void appendEvent(eventsPath, {
|
|
127
|
+
type: "heartbeat",
|
|
128
|
+
pid: child.pid,
|
|
129
|
+
text: `[doer-daemon] heartbeat: process still running without new output for ${Math.max(1, Math.round(idleMs / 1000))}s`,
|
|
130
|
+
});
|
|
131
|
+
}, heartbeatIntervalMs);
|
|
132
|
+
heartbeatTimer.unref?.();
|
|
133
|
+
const forwardSignal = (signal) => {
|
|
134
|
+
if (child.exitCode !== null || child.killed) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
child.kill(signal);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// ignore forwarding failures
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
145
|
+
for (const signal of signals) {
|
|
146
|
+
process.on(signal, () => {
|
|
147
|
+
void appendEvent(eventsPath, {
|
|
148
|
+
type: "signal",
|
|
149
|
+
pid: child.pid,
|
|
150
|
+
signal,
|
|
151
|
+
});
|
|
152
|
+
forwardSignal(signal);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
await new Promise((resolve, reject) => {
|
|
156
|
+
child.once("error", reject);
|
|
157
|
+
child.once("exit", async (code, signal) => {
|
|
158
|
+
try {
|
|
159
|
+
clearInterval(heartbeatTimer);
|
|
160
|
+
const latest = await readState(statePath);
|
|
161
|
+
await writeState(statePath, {
|
|
162
|
+
...latest,
|
|
163
|
+
pid: null,
|
|
164
|
+
runnerPid: null,
|
|
165
|
+
stoppedAt: new Date().toISOString(),
|
|
166
|
+
lastExitCode: typeof code === "number" ? code : null,
|
|
167
|
+
});
|
|
168
|
+
await appendEvent(eventsPath, {
|
|
169
|
+
type: code === 0 ? "exit" : "error",
|
|
170
|
+
pid: child.pid,
|
|
171
|
+
code,
|
|
172
|
+
signal,
|
|
173
|
+
text: signal ? `process exited due to ${signal}` : code === 0 ? "process exited cleanly" : `process exited with code ${code}`,
|
|
174
|
+
});
|
|
175
|
+
resolve();
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
clearInterval(heartbeatTimer);
|
|
179
|
+
reject(error);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
main().catch(async (error) => {
|
|
185
|
+
const eventsPath = process.env.DOER_DAEMON_EVENTS_PATH?.trim();
|
|
186
|
+
const statePath = process.env.DOER_DAEMON_STATE_PATH?.trim();
|
|
187
|
+
const message = error instanceof Error ? error.stack || error.message : String(error);
|
|
188
|
+
if (eventsPath) {
|
|
189
|
+
await appendEvent(eventsPath, {
|
|
190
|
+
type: "error",
|
|
191
|
+
pid: process.pid,
|
|
192
|
+
text: message,
|
|
193
|
+
}).catch(() => undefined);
|
|
194
|
+
}
|
|
195
|
+
if (statePath) {
|
|
196
|
+
try {
|
|
197
|
+
const state = await readState(statePath);
|
|
198
|
+
await writeState(statePath, {
|
|
199
|
+
...state,
|
|
200
|
+
pid: null,
|
|
201
|
+
runnerPid: null,
|
|
202
|
+
stoppedAt: new Date().toISOString(),
|
|
203
|
+
lastExitCode: state.lastExitCode ?? 1,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// ignore
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
process.stderr.write(`${message}\n`);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import * as z from "zod/v4";
|
|
6
|
+
import { deleteAgentDaemonLocal, listAgentDaemonsLocal, readAgentDaemonLogsLocal, restartAgentDaemonLocal, startAgentDaemonLocal, stopAgentDaemonLocal, } from "./agent-daemon-rpc.js";
|
|
7
|
+
import { readAgentSettingsConfig } from "./agent-settings.js";
|
|
8
|
+
import { createRuntimeEnvHelpers } from "./agent-runtime-env.js";
|
|
9
|
+
const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const AGENT_PROJECT_DIR = path.join(MODULE_DIR, "..");
|
|
11
|
+
function parseWorkspaceRoot(argv) {
|
|
12
|
+
const flagIndex = argv.findIndex((token) => token === "--workspace-root");
|
|
13
|
+
const flagValue = flagIndex >= 0 ? argv[flagIndex + 1] : "";
|
|
14
|
+
const envValue = process.env.DOER_DAEMON_WORKSPACE_ROOT?.trim() || process.env.WORKSPACE?.trim() || process.cwd();
|
|
15
|
+
return path.resolve((flagValue || envValue || process.cwd()).trim());
|
|
16
|
+
}
|
|
17
|
+
function formatJson(value) {
|
|
18
|
+
return JSON.stringify(value, null, 2);
|
|
19
|
+
}
|
|
20
|
+
async function main() {
|
|
21
|
+
const workspaceRoot = parseWorkspaceRoot(process.argv.slice(2));
|
|
22
|
+
const runtimeEnvHelpers = createRuntimeEnvHelpers({
|
|
23
|
+
resolveWorkspaceRoot: () => workspaceRoot,
|
|
24
|
+
agentProjectDir: AGENT_PROJECT_DIR,
|
|
25
|
+
});
|
|
26
|
+
const server = new McpServer({
|
|
27
|
+
name: "doer-daemon",
|
|
28
|
+
version: "0.1.0",
|
|
29
|
+
}, {
|
|
30
|
+
capabilities: {
|
|
31
|
+
tools: {},
|
|
32
|
+
},
|
|
33
|
+
instructions: "Manage long-lived workspace daemons. Use these tools to list, start, stop, and inspect daemon logs.",
|
|
34
|
+
});
|
|
35
|
+
server.registerTool("daemon_list", {
|
|
36
|
+
description: "List daemons managed for the current workspace.",
|
|
37
|
+
inputSchema: {},
|
|
38
|
+
}, async () => {
|
|
39
|
+
const daemons = await listAgentDaemonsLocal(workspaceRoot);
|
|
40
|
+
return {
|
|
41
|
+
content: [
|
|
42
|
+
{
|
|
43
|
+
type: "text",
|
|
44
|
+
text: formatJson({ daemons }),
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
structuredContent: { daemons },
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
server.registerTool("daemon_start", {
|
|
51
|
+
description: "Start a new long-lived daemon process for the current workspace.",
|
|
52
|
+
inputSchema: {
|
|
53
|
+
command: z.string().min(1).describe("Shell command to run, such as `npm run dev`."),
|
|
54
|
+
cwd: z.string().optional().describe("Optional working directory relative to the workspace root."),
|
|
55
|
+
label: z.string().optional().describe("Optional UI label for the daemon."),
|
|
56
|
+
},
|
|
57
|
+
}, async ({ command, cwd, label }) => {
|
|
58
|
+
const daemon = await startAgentDaemonLocal({
|
|
59
|
+
workspaceRoot,
|
|
60
|
+
agentProjectDir: AGENT_PROJECT_DIR,
|
|
61
|
+
request: {
|
|
62
|
+
command,
|
|
63
|
+
cwd: cwd ?? ".",
|
|
64
|
+
label,
|
|
65
|
+
},
|
|
66
|
+
resolveShellPath: runtimeEnvHelpers.resolveShellPath,
|
|
67
|
+
resolveTaskWorkspace: runtimeEnvHelpers.resolveTaskWorkspace,
|
|
68
|
+
readAgentSettingsConfig,
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
content: [
|
|
72
|
+
{
|
|
73
|
+
type: "text",
|
|
74
|
+
text: formatJson({ daemon }),
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
structuredContent: { daemon },
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
server.registerTool("daemon_stop", {
|
|
81
|
+
description: "Stop a running daemon by id.",
|
|
82
|
+
inputSchema: {
|
|
83
|
+
id: z.string().min(1).describe("Daemon id returned by daemon_list or daemon_start."),
|
|
84
|
+
},
|
|
85
|
+
}, async ({ id }) => {
|
|
86
|
+
const daemon = await stopAgentDaemonLocal(workspaceRoot, id);
|
|
87
|
+
return {
|
|
88
|
+
content: [
|
|
89
|
+
{
|
|
90
|
+
type: "text",
|
|
91
|
+
text: formatJson({ daemon }),
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
structuredContent: { daemon },
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
server.registerTool("daemon_restart", {
|
|
98
|
+
description: "Restart a daemon by id.",
|
|
99
|
+
inputSchema: {
|
|
100
|
+
id: z.string().min(1).describe("Daemon id returned by daemon_list or daemon_start."),
|
|
101
|
+
},
|
|
102
|
+
}, async ({ id }) => {
|
|
103
|
+
const daemon = await restartAgentDaemonLocal({
|
|
104
|
+
workspaceRoot,
|
|
105
|
+
agentProjectDir: AGENT_PROJECT_DIR,
|
|
106
|
+
daemonId: id,
|
|
107
|
+
resolveShellPath: runtimeEnvHelpers.resolveShellPath,
|
|
108
|
+
readAgentSettingsConfig,
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
content: [
|
|
112
|
+
{
|
|
113
|
+
type: "text",
|
|
114
|
+
text: formatJson({ daemon }),
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
structuredContent: { daemon },
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
server.registerTool("daemon_delete", {
|
|
121
|
+
description: "Delete a daemon by id, stopping it first if needed.",
|
|
122
|
+
inputSchema: {
|
|
123
|
+
id: z.string().min(1).describe("Daemon id returned by daemon_list or daemon_start."),
|
|
124
|
+
},
|
|
125
|
+
}, async ({ id }) => {
|
|
126
|
+
await deleteAgentDaemonLocal(workspaceRoot, id);
|
|
127
|
+
return {
|
|
128
|
+
content: [
|
|
129
|
+
{
|
|
130
|
+
type: "text",
|
|
131
|
+
text: formatJson({ deleted: true, daemonId: id }),
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
structuredContent: { deleted: true, daemonId: id },
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
server.registerTool("daemon_logs", {
|
|
138
|
+
description: "Read recent tail log events for a daemon.",
|
|
139
|
+
inputSchema: {
|
|
140
|
+
id: z.string().min(1).describe("Daemon id returned by daemon_list or daemon_start."),
|
|
141
|
+
limit: z.number().int().min(1).max(1000).optional().describe("Maximum number of recent log lines to read."),
|
|
142
|
+
},
|
|
143
|
+
}, async ({ id, limit }) => {
|
|
144
|
+
const logs = await readAgentDaemonLogsLocal({
|
|
145
|
+
workspaceRoot,
|
|
146
|
+
daemonId: id,
|
|
147
|
+
limit,
|
|
148
|
+
});
|
|
149
|
+
return {
|
|
150
|
+
content: [
|
|
151
|
+
{
|
|
152
|
+
type: "text",
|
|
153
|
+
text: formatJson(logs),
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
structuredContent: logs,
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
const transport = new StdioServerTransport();
|
|
160
|
+
await server.connect(transport);
|
|
161
|
+
}
|
|
162
|
+
main().catch((error) => {
|
|
163
|
+
const message = error instanceof Error ? error.stack || error.message : String(error);
|
|
164
|
+
process.stderr.write(`${message}\n`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
});
|
package/package.json
CHANGED