doer-agent 0.4.7 → 0.4.8

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 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`이 없습니다. 현재 문서는 실제 파일 구조 기준으로 정리되어 있습니다.
@@ -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,475 @@
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" || row.type === "stdout" || row.type === "stderr" || row.type === "exit" || row.type === "signal" || row.type === "error"
109
+ ? row.type
110
+ : null;
111
+ if (!ts || !type) {
112
+ return null;
113
+ }
114
+ return {
115
+ ts,
116
+ type,
117
+ text: typeof row.text === "string" ? row.text : null,
118
+ pid: typeof row.pid === "number" && Number.isInteger(row.pid) && row.pid > 0 ? row.pid : null,
119
+ code: typeof row.code === "number" && Number.isInteger(row.code) ? row.code : null,
120
+ signal: typeof row.signal === "string" && row.signal.trim() ? row.signal.trim() : null,
121
+ };
122
+ }
123
+ async function readDaemonState(workspaceRoot, daemonId) {
124
+ const raw = await readFile(getDaemonStatePath(workspaceRoot, daemonId), "utf8").catch(() => null);
125
+ if (!raw) {
126
+ return null;
127
+ }
128
+ const parsed = JSON.parse(raw);
129
+ return normalizeStateRecord(parsed);
130
+ }
131
+ async function writeDaemonState(workspaceRoot, state) {
132
+ await mkdir(getDaemonDir(workspaceRoot, state.id), { recursive: true });
133
+ await writeFile(getDaemonStatePath(workspaceRoot, state.id), `${JSON.stringify(state, null, 2)}\n`, "utf8");
134
+ }
135
+ function toDaemonSnapshot(state) {
136
+ const runnerAlive = isPidAlive(state.runnerPid);
137
+ const processAlive = isPidAlive(state.pid);
138
+ const status = runnerAlive || processAlive ? "running" : state.lastExitCode !== null && state.lastExitCode !== 0 ? "failed" : "stopped";
139
+ return {
140
+ ...state,
141
+ status,
142
+ displayName: state.label ?? state.command,
143
+ };
144
+ }
145
+ async function readJsonlTail(filePath, limit) {
146
+ const raw = await readFile(filePath, "utf8").catch(() => "");
147
+ if (!raw) {
148
+ return [];
149
+ }
150
+ return raw
151
+ .split("\n")
152
+ .map((line) => line.trim())
153
+ .filter(Boolean)
154
+ .slice(-limit)
155
+ .map((line) => {
156
+ try {
157
+ return normalizeLogEvent(JSON.parse(line));
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ })
163
+ .filter((event) => Boolean(event));
164
+ }
165
+ function resolveDaemonRunnerEntry(agentProjectDir) {
166
+ const distEntry = path.join(agentProjectDir, "dist", "daemon-log-runner.js");
167
+ const srcEntry = path.join(agentProjectDir, "src", "daemon-log-runner.ts");
168
+ const tsxLoaderPath = path.join(agentProjectDir, "node_modules", "tsx", "dist", "loader.mjs");
169
+ if (existsSync(distEntry)) {
170
+ return {
171
+ command: process.execPath,
172
+ args: [distEntry],
173
+ };
174
+ }
175
+ return {
176
+ command: process.execPath,
177
+ args: ["--import", tsxLoaderPath, srcEntry],
178
+ };
179
+ }
180
+ export async function listAgentDaemonsLocal(workspaceRoot) {
181
+ const root = getDaemonsRoot(workspaceRoot);
182
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
183
+ const states = await Promise.all(entries
184
+ .filter((entry) => entry.isDirectory() && DAEMON_ID_PATTERN.test(entry.name))
185
+ .map(async (entry) => readDaemonState(workspaceRoot, entry.name)));
186
+ return states
187
+ .filter((state) => Boolean(state))
188
+ .map((state) => toDaemonSnapshot(state))
189
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt));
190
+ }
191
+ export async function getAgentDaemonLocal(workspaceRoot, daemonId) {
192
+ const state = await readDaemonState(workspaceRoot, daemonId);
193
+ if (!state) {
194
+ throw new Error("daemon not found");
195
+ }
196
+ return toDaemonSnapshot(state);
197
+ }
198
+ export async function stopAgentDaemonLocal(workspaceRoot, daemonId) {
199
+ const state = await readDaemonState(workspaceRoot, daemonId);
200
+ if (!state) {
201
+ throw new Error("daemon not found");
202
+ }
203
+ const targetPid = isPidAlive(state.runnerPid) ? state.runnerPid : state.pid;
204
+ if (!targetPid) {
205
+ const stopped = {
206
+ ...state,
207
+ pid: null,
208
+ runnerPid: null,
209
+ stoppedAt: state.stoppedAt ?? new Date().toISOString(),
210
+ };
211
+ await writeDaemonState(workspaceRoot, stopped);
212
+ return toDaemonSnapshot(stopped);
213
+ }
214
+ try {
215
+ sendSignalToPid(targetPid, "SIGTERM");
216
+ }
217
+ catch {
218
+ // Ignore and fall through to polling.
219
+ }
220
+ for (let index = 0; index < 30; index += 1) {
221
+ await sleep(100);
222
+ const latest = await readDaemonState(workspaceRoot, daemonId);
223
+ if (!latest || (!isPidAlive(latest.runnerPid) && !isPidAlive(latest.pid))) {
224
+ return latest ? toDaemonSnapshot(latest) : toDaemonSnapshot({
225
+ ...state,
226
+ pid: null,
227
+ runnerPid: null,
228
+ stoppedAt: new Date().toISOString(),
229
+ });
230
+ }
231
+ }
232
+ try {
233
+ sendSignalToPid(targetPid, "SIGKILL");
234
+ }
235
+ catch {
236
+ // noop
237
+ }
238
+ for (let index = 0; index < 15; index += 1) {
239
+ await sleep(100);
240
+ const latest = await readDaemonState(workspaceRoot, daemonId);
241
+ if (!latest || (!isPidAlive(latest.runnerPid) && !isPidAlive(latest.pid))) {
242
+ return latest ? toDaemonSnapshot(latest) : toDaemonSnapshot({
243
+ ...state,
244
+ pid: null,
245
+ runnerPid: null,
246
+ stoppedAt: new Date().toISOString(),
247
+ });
248
+ }
249
+ }
250
+ throw new Error("failed to stop daemon");
251
+ }
252
+ async function spawnDaemonLocal(args) {
253
+ const daemonId = args.daemonId ?? createDaemonId();
254
+ const daemonDir = getDaemonDir(args.workspaceRoot, daemonId);
255
+ const statePath = getDaemonStatePath(args.workspaceRoot, daemonId);
256
+ const eventsPath = getDaemonEventsPath(args.workspaceRoot, daemonId);
257
+ await mkdir(daemonDir, { recursive: true });
258
+ await mkdir(path.join(args.workspaceRoot, ".codex"), { recursive: true });
259
+ const settings = await args.readAgentSettingsConfig({ workspaceRoot: args.workspaceRoot });
260
+ const runtimeBinPath = path.join(args.agentProjectDir, "runtime/bin");
261
+ const baseState = {
262
+ id: daemonId,
263
+ label: args.label,
264
+ command: args.command,
265
+ cwd: args.cwd,
266
+ pid: null,
267
+ runnerPid: null,
268
+ startedAt: new Date().toISOString(),
269
+ stoppedAt: null,
270
+ lastExitCode: null,
271
+ };
272
+ await writeDaemonState(args.workspaceRoot, baseState);
273
+ const env = {
274
+ ...process.env,
275
+ ...buildAgentSettingsEnvPatch(settings),
276
+ ...args.envPatch,
277
+ WORKSPACE: args.cwd,
278
+ CODEX_HOME: path.join(args.workspaceRoot, ".codex"),
279
+ PATH: [runtimeBinPath, process.env.PATH || ""].filter(Boolean).join(path.delimiter),
280
+ DOER_DAEMON_ID: daemonId,
281
+ DOER_DAEMON_COMMAND: args.command,
282
+ DOER_DAEMON_CWD: args.cwd,
283
+ DOER_DAEMON_STATE_PATH: statePath,
284
+ DOER_DAEMON_EVENTS_PATH: eventsPath,
285
+ DOER_DAEMON_SHELL_PATH: args.resolveShellPath(),
286
+ };
287
+ const runner = resolveDaemonRunnerEntry(args.agentProjectDir);
288
+ const child = spawn(runner.command, runner.args, {
289
+ cwd: args.cwd,
290
+ env,
291
+ detached: process.platform !== "win32",
292
+ stdio: "ignore",
293
+ });
294
+ if (typeof child.pid !== "number" || child.pid <= 0) {
295
+ throw new Error("failed to start daemon process");
296
+ }
297
+ child.unref();
298
+ await writeDaemonState(args.workspaceRoot, {
299
+ ...baseState,
300
+ runnerPid: child.pid,
301
+ });
302
+ for (let index = 0; index < 20; index += 1) {
303
+ await sleep(50);
304
+ const latest = await readDaemonState(args.workspaceRoot, daemonId);
305
+ if (latest && (latest.pid || latest.lastExitCode !== null)) {
306
+ return toDaemonSnapshot(latest);
307
+ }
308
+ }
309
+ const latest = await readDaemonState(args.workspaceRoot, daemonId);
310
+ return toDaemonSnapshot(latest ?? { ...baseState, runnerPid: child.pid });
311
+ }
312
+ export async function startAgentDaemonLocal(args) {
313
+ return await spawnDaemonLocal({
314
+ workspaceRoot: args.workspaceRoot,
315
+ agentProjectDir: args.agentProjectDir,
316
+ command: normalizeCommand(args.request.command),
317
+ cwd: args.resolveTaskWorkspace(typeof args.request.cwd === "string" ? args.request.cwd : null),
318
+ label: normalizeLabel(args.request.label),
319
+ envPatch: normalizeEnvPatch(args.request.envPatch),
320
+ resolveShellPath: args.resolveShellPath,
321
+ readAgentSettingsConfig: args.readAgentSettingsConfig,
322
+ });
323
+ }
324
+ export async function restartAgentDaemonLocal(args) {
325
+ const state = await readDaemonState(args.workspaceRoot, args.daemonId);
326
+ if (!state) {
327
+ throw new Error("daemon not found");
328
+ }
329
+ if (isPidAlive(state.runnerPid) || isPidAlive(state.pid)) {
330
+ await stopAgentDaemonLocal(args.workspaceRoot, args.daemonId);
331
+ }
332
+ return await spawnDaemonLocal({
333
+ workspaceRoot: args.workspaceRoot,
334
+ agentProjectDir: args.agentProjectDir,
335
+ daemonId: state.id,
336
+ command: state.command,
337
+ cwd: state.cwd,
338
+ label: state.label,
339
+ envPatch: {},
340
+ resolveShellPath: args.resolveShellPath,
341
+ readAgentSettingsConfig: args.readAgentSettingsConfig,
342
+ });
343
+ }
344
+ export async function deleteAgentDaemonLocal(workspaceRoot, daemonId) {
345
+ const state = await readDaemonState(workspaceRoot, daemonId);
346
+ if (!state) {
347
+ return;
348
+ }
349
+ if (isPidAlive(state.runnerPid) || isPidAlive(state.pid)) {
350
+ await stopAgentDaemonLocal(workspaceRoot, daemonId);
351
+ }
352
+ await rm(getDaemonDir(workspaceRoot, daemonId), { recursive: true, force: true });
353
+ }
354
+ export async function readAgentDaemonLogsLocal(args) {
355
+ const state = await readDaemonState(args.workspaceRoot, args.daemonId);
356
+ if (!state) {
357
+ throw new Error("daemon not found");
358
+ }
359
+ const limit = normalizeLimit(args.limit, 100);
360
+ return {
361
+ daemon: toDaemonSnapshot(state),
362
+ events: await readJsonlTail(getDaemonEventsPath(args.workspaceRoot, args.daemonId), limit),
363
+ };
364
+ }
365
+ async function executeDaemonRpc(args) {
366
+ const action = normalizeAction(args.request.action);
367
+ if (action === "list") {
368
+ return {
369
+ ok: true,
370
+ action,
371
+ daemons: await listAgentDaemonsLocal(args.workspaceRoot),
372
+ };
373
+ }
374
+ if (action === "inspect") {
375
+ const daemonId = normalizeDaemonId(args.request.daemonId);
376
+ return {
377
+ ok: true,
378
+ action,
379
+ daemon: await getAgentDaemonLocal(args.workspaceRoot, daemonId),
380
+ };
381
+ }
382
+ if (action === "start") {
383
+ return {
384
+ ok: true,
385
+ action,
386
+ daemon: await startAgentDaemonLocal({
387
+ workspaceRoot: args.workspaceRoot,
388
+ agentProjectDir: args.agentProjectDir,
389
+ request: args.request,
390
+ resolveShellPath: args.resolveShellPath,
391
+ resolveTaskWorkspace: args.resolveTaskWorkspace,
392
+ readAgentSettingsConfig: args.readAgentSettingsConfig,
393
+ }),
394
+ };
395
+ }
396
+ const daemonId = normalizeDaemonId(args.request.daemonId);
397
+ if (action === "stop") {
398
+ return {
399
+ ok: true,
400
+ action,
401
+ daemon: await stopAgentDaemonLocal(args.workspaceRoot, daemonId),
402
+ };
403
+ }
404
+ if (action === "restart") {
405
+ return {
406
+ ok: true,
407
+ action,
408
+ daemon: await restartAgentDaemonLocal({
409
+ workspaceRoot: args.workspaceRoot,
410
+ agentProjectDir: args.agentProjectDir,
411
+ daemonId,
412
+ resolveShellPath: args.resolveShellPath,
413
+ readAgentSettingsConfig: args.readAgentSettingsConfig,
414
+ }),
415
+ };
416
+ }
417
+ if (action === "delete") {
418
+ await deleteAgentDaemonLocal(args.workspaceRoot, daemonId);
419
+ return {
420
+ ok: true,
421
+ action,
422
+ daemonId,
423
+ };
424
+ }
425
+ const limit = normalizeLimit(args.request.limit, 100);
426
+ return {
427
+ ok: true,
428
+ action,
429
+ ...(await readAgentDaemonLogsLocal({ workspaceRoot: args.workspaceRoot, daemonId, limit })),
430
+ limit,
431
+ };
432
+ }
433
+ export async function handleDaemonRpcMessage(args) {
434
+ let requestId = "unknown";
435
+ try {
436
+ const request = JSON.parse(daemonRpcCodec.decode(args.msg.data));
437
+ requestId = typeof request.requestId === "string" ? request.requestId : "unknown";
438
+ const payload = await executeDaemonRpc({
439
+ workspaceRoot: args.workspaceRoot,
440
+ agentProjectDir: args.agentProjectDir,
441
+ request,
442
+ resolveShellPath: args.resolveShellPath,
443
+ resolveTaskWorkspace: args.resolveTaskWorkspace,
444
+ readAgentSettingsConfig: args.readAgentSettingsConfig,
445
+ });
446
+ args.msg.respond(daemonRpcCodec.encode(JSON.stringify({ requestId, ...payload })));
447
+ }
448
+ catch (error) {
449
+ const message = error instanceof Error ? error.message : String(error);
450
+ args.onError?.(`daemon rpc failed requestId=${requestId} error=${message}`);
451
+ args.msg.respond(daemonRpcCodec.encode(JSON.stringify({ requestId, ok: false, error: message })));
452
+ }
453
+ }
454
+ export function subscribeToDaemonRpc(args) {
455
+ args.nc.subscribe(args.subject, {
456
+ callback: (error, msg) => {
457
+ if (error) {
458
+ const message = error instanceof Error ? error.message : String(error);
459
+ args.onError(`daemon rpc subscription error: ${message}`);
460
+ return;
461
+ }
462
+ void handleDaemonRpcMessage({
463
+ msg,
464
+ nc: args.nc,
465
+ workspaceRoot: args.workspaceRoot,
466
+ agentProjectDir: args.agentProjectDir,
467
+ resolveShellPath: args.resolveShellPath,
468
+ resolveTaskWorkspace: args.resolveTaskWorkspace,
469
+ readAgentSettingsConfig: args.readAgentSettingsConfig,
470
+ onError: args.onError,
471
+ });
472
+ },
473
+ });
474
+ args.onInfo(`daemon rpc subscribed subject=${args.subject}`);
475
+ }
@@ -72,10 +72,68 @@ function buildImmediateRunEvent(task, type) {
72
72
  finishedAt: task.finishedAt,
73
73
  };
74
74
  }
75
- export async function resetRunsDir(workspaceRoot) {
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 rm(dir, { recursive: true, force: true }).catch(() => undefined);
78
- await mkdir(dir, { recursive: true });
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 { buildManagedCodexArgs, createLocalCodexCliTools, normalizeCodexModel, normalizeShellRpcCodexAuthBundle, spawnManagedCodexCommand, } from "./agent-codex-cli.js";
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, releaseRunStartSlot, resetRunsDir, removeRunTask, updateRunStartSlotSession, } from "./agent-run-state.js";
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
- await resetRunsDir(resolveWorkspaceRoot());
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,176 @@
1
+ import { spawn } from "node:child_process";
2
+ import { appendFile, readFile, writeFile } from "node:fs/promises";
3
+ function readRequiredEnv(name) {
4
+ const value = process.env[name]?.trim() || "";
5
+ if (!value) {
6
+ throw new Error(`${name} is required`);
7
+ }
8
+ return value;
9
+ }
10
+ async function readState(statePath) {
11
+ const raw = await readFile(statePath, "utf8");
12
+ return JSON.parse(raw);
13
+ }
14
+ async function writeState(statePath, state) {
15
+ await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
16
+ }
17
+ async function appendEvent(eventsPath, event) {
18
+ const row = {
19
+ ts: new Date().toISOString(),
20
+ type: event.type,
21
+ text: event.text ?? null,
22
+ pid: typeof event.pid === "number" && event.pid > 0 ? event.pid : null,
23
+ code: typeof event.code === "number" ? event.code : null,
24
+ signal: event.signal?.trim() || null,
25
+ };
26
+ await appendFile(eventsPath, `${JSON.stringify(row)}\n`, "utf8");
27
+ }
28
+ function attachLineLogger(stream, type, eventsPath, pid) {
29
+ if (!stream) {
30
+ return;
31
+ }
32
+ stream.setEncoding("utf8");
33
+ let pending = "";
34
+ stream.on("data", (chunk) => {
35
+ pending += chunk;
36
+ const lines = pending.split(/\r\n|\n|\r/);
37
+ pending = lines.pop() ?? "";
38
+ for (const line of lines) {
39
+ void appendEvent(eventsPath, {
40
+ type,
41
+ text: line,
42
+ pid,
43
+ });
44
+ }
45
+ });
46
+ stream.on("end", () => {
47
+ if (!pending) {
48
+ return;
49
+ }
50
+ void appendEvent(eventsPath, {
51
+ type,
52
+ text: pending,
53
+ pid,
54
+ });
55
+ pending = "";
56
+ });
57
+ }
58
+ async function main() {
59
+ const statePath = readRequiredEnv("DOER_DAEMON_STATE_PATH");
60
+ const eventsPath = readRequiredEnv("DOER_DAEMON_EVENTS_PATH");
61
+ const command = readRequiredEnv("DOER_DAEMON_COMMAND");
62
+ const cwd = readRequiredEnv("DOER_DAEMON_CWD");
63
+ const shellPath = readRequiredEnv("DOER_DAEMON_SHELL_PATH");
64
+ const childEnv = { ...process.env };
65
+ delete childEnv.DOER_DAEMON_STATE_PATH;
66
+ delete childEnv.DOER_DAEMON_EVENTS_PATH;
67
+ delete childEnv.DOER_DAEMON_COMMAND;
68
+ delete childEnv.DOER_DAEMON_CWD;
69
+ delete childEnv.DOER_DAEMON_SHELL_PATH;
70
+ let state = await readState(statePath);
71
+ state = {
72
+ ...state,
73
+ runnerPid: process.pid,
74
+ };
75
+ await writeState(statePath, state);
76
+ const child = spawn(command, {
77
+ cwd,
78
+ env: childEnv,
79
+ shell: shellPath,
80
+ detached: false,
81
+ stdio: ["ignore", "pipe", "pipe"],
82
+ });
83
+ if (typeof child.pid !== "number" || child.pid <= 0) {
84
+ throw new Error("failed to spawn daemon child");
85
+ }
86
+ state = {
87
+ ...state,
88
+ pid: child.pid,
89
+ stoppedAt: null,
90
+ lastExitCode: null,
91
+ };
92
+ await writeState(statePath, state);
93
+ await appendEvent(eventsPath, {
94
+ type: "start",
95
+ pid: child.pid,
96
+ });
97
+ attachLineLogger(child.stdout, "stdout", eventsPath, child.pid);
98
+ attachLineLogger(child.stderr, "stderr", eventsPath, child.pid);
99
+ const forwardSignal = (signal) => {
100
+ if (child.exitCode !== null || child.killed) {
101
+ return;
102
+ }
103
+ try {
104
+ child.kill(signal);
105
+ }
106
+ catch {
107
+ // ignore forwarding failures
108
+ }
109
+ };
110
+ const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
111
+ for (const signal of signals) {
112
+ process.on(signal, () => {
113
+ void appendEvent(eventsPath, {
114
+ type: "signal",
115
+ pid: child.pid,
116
+ signal,
117
+ });
118
+ forwardSignal(signal);
119
+ });
120
+ }
121
+ await new Promise((resolve, reject) => {
122
+ child.once("error", reject);
123
+ child.once("exit", async (code, signal) => {
124
+ try {
125
+ const latest = await readState(statePath);
126
+ await writeState(statePath, {
127
+ ...latest,
128
+ pid: null,
129
+ runnerPid: null,
130
+ stoppedAt: new Date().toISOString(),
131
+ lastExitCode: typeof code === "number" ? code : null,
132
+ });
133
+ await appendEvent(eventsPath, {
134
+ type: code === 0 ? "exit" : "error",
135
+ pid: child.pid,
136
+ code,
137
+ signal,
138
+ text: signal ? `process exited due to ${signal}` : code === 0 ? "process exited cleanly" : `process exited with code ${code}`,
139
+ });
140
+ resolve();
141
+ }
142
+ catch (error) {
143
+ reject(error);
144
+ }
145
+ });
146
+ });
147
+ }
148
+ main().catch(async (error) => {
149
+ const eventsPath = process.env.DOER_DAEMON_EVENTS_PATH?.trim();
150
+ const statePath = process.env.DOER_DAEMON_STATE_PATH?.trim();
151
+ const message = error instanceof Error ? error.stack || error.message : String(error);
152
+ if (eventsPath) {
153
+ await appendEvent(eventsPath, {
154
+ type: "error",
155
+ pid: process.pid,
156
+ text: message,
157
+ }).catch(() => undefined);
158
+ }
159
+ if (statePath) {
160
+ try {
161
+ const state = await readState(statePath);
162
+ await writeState(statePath, {
163
+ ...state,
164
+ pid: null,
165
+ runnerPid: null,
166
+ stoppedAt: new Date().toISOString(),
167
+ lastExitCode: state.lastExitCode ?? 1,
168
+ });
169
+ }
170
+ catch {
171
+ // ignore
172
+ }
173
+ }
174
+ process.stderr.write(`${message}\n`);
175
+ process.exit(1);
176
+ });
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doer-agent",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "Reverse-polling agent runtime for doer",
5
5
  "type": "module",
6
6
  "main": "dist/agent.js",
@@ -1,5 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
4
- PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
5
- exec node "$PROJECT_DIR/dist/apply-patch.js" "$@"