doer-agent 0.9.7 → 0.9.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.
@@ -28,9 +28,6 @@ export function buildAgentNotesRpcSubject(userId, agentId) {
28
28
  export function buildAgentNotesAiRpcSubject(userId, agentId) {
29
29
  return `doer.agent.notes.ai.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
30
30
  }
31
- export function buildAgentThreadTagsRpcSubject(userId, agentId) {
32
- return `doer.agent.thread.tags.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
33
- }
34
31
  export function buildAgentDaemonRpcSubject(userId, agentId) {
35
32
  return `doer.agent.daemon.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
36
33
  }
package/dist/agent.js CHANGED
@@ -7,7 +7,6 @@ import { handleFsRpcMessage } from "./agent-fs-rpc.js";
7
7
  import { handleGitRpcMessage } from "./agent-git-rpc.js";
8
8
  import { handleNotesRpcMessage } from "./agent-notes-rpc.js";
9
9
  import { subscribeToNotesAiRpc } from "./agent-notes-ai-rpc.js";
10
- import { subscribeToThreadTagsRpc } from "./agent-thread-tags-rpc.js";
11
10
  import { ensureBundledDoerSkills } from "./agent-bundled-skills.js";
12
11
  import { subscribeToCodexAppRpc } from "./agent-codex-app-rpc.js";
13
12
  import { createCodexAppServerManager } from "./codex-app-server-manager.js";
@@ -19,7 +18,7 @@ import { subscribeToSkillRpc } from "./agent-skill-rpc.js";
19
18
  import { subscribeToMaintenanceRpc } from "./agent-maintenance-rpc.js";
20
19
  import { subscribeToHttpProxyRpc } from "./agent-http-proxy-rpc.js";
21
20
  import { sendSignalToTaskProcess } from "./agent-task-execution.js";
22
- import { buildAgentCodexAppEventsSubject, buildAgentCodexAppRpcSubject, buildAgentDaemonRpcSubject, buildAgentFsRpcSubject, buildAgentGitRpcSubject, buildAgentHttpProxyRpcSubject, buildAgentMaintenanceRpcSubject, buildAgentNotesRpcSubject, buildAgentNotesAiRpcSubject, buildAgentThreadTagsRpcSubject, buildAgentSettingsRpcSubject, buildAgentSkillRpcSubject, formatLocalTimestamp, parseArgs, resolveAgentVersion, resolveArgOrEnv, resolveContainerReachableServerBaseUrl, sanitizeUserId, sleep, } from "./agent-runtime-utils.js";
21
+ import { buildAgentCodexAppEventsSubject, buildAgentCodexAppRpcSubject, buildAgentDaemonRpcSubject, buildAgentFsRpcSubject, buildAgentGitRpcSubject, buildAgentHttpProxyRpcSubject, buildAgentMaintenanceRpcSubject, buildAgentNotesRpcSubject, buildAgentNotesAiRpcSubject, buildAgentSettingsRpcSubject, buildAgentSkillRpcSubject, formatLocalTimestamp, parseArgs, resolveAgentVersion, resolveArgOrEnv, resolveContainerReachableServerBaseUrl, sanitizeUserId, sleep, } from "./agent-runtime-utils.js";
23
22
  import { createRuntimeEnvHelpers } from "./agent-runtime-env.js";
24
23
  import { createEventPersistenceHelpers, heartbeatAgentSession, postJson, } from "./agent-runtime-io.js";
25
24
  import { handleSettingsRpcMessage } from "./agent-settings-rpc.js";
@@ -310,15 +309,6 @@ async function main() {
310
309
  agentId: initialAgentId,
311
310
  codexAppServerManager,
312
311
  });
313
- subscribeToThreadTagsRpc({
314
- nc: jetstream.nc,
315
- subject: buildAgentThreadTagsRpcSubject(userId, initialAgentId),
316
- agentId: initialAgentId,
317
- workspaceRoot: resolveWorkspaceRoot(),
318
- manager: codexAppServerManager,
319
- onInfo: writeAgentInfo,
320
- onError: writeAgentError,
321
- });
322
312
  subscribeToDaemonRpc({
323
313
  nc: jetstream.nc,
324
314
  subject: buildAgentDaemonRpcSubject(userId, initialAgentId),
@@ -12,6 +12,16 @@ function resolveCodexCliBinPath() {
12
12
  }
13
13
  return path.resolve(path.dirname(packageJsonPath), codexBin);
14
14
  }
15
+ export class CodexAppServerRequestError extends Error {
16
+ code;
17
+ data;
18
+ constructor(message, code = -32603, data) {
19
+ super(message);
20
+ this.code = code;
21
+ this.data = data;
22
+ this.name = "CodexAppServerRequestError";
23
+ }
24
+ }
15
25
  export class CodexAppServerClient {
16
26
  options;
17
27
  child = null;
@@ -20,10 +30,15 @@ export class CodexAppServerClient {
20
30
  startPromise = null;
21
31
  stopPromise = null;
22
32
  pending = new Map();
33
+ activeTurns = new Map();
34
+ activeTurnsChanged = new Set();
23
35
  constructor(options) {
24
36
  this.options = options;
25
37
  }
26
38
  async request(method, params, timeoutMs) {
39
+ if (this.stopPromise) {
40
+ throw new Error("Codex app-server is stopping");
41
+ }
27
42
  await this.start();
28
43
  return await this.requestStarted(method, params, timeoutMs);
29
44
  }
@@ -68,7 +83,9 @@ export class CodexAppServerClient {
68
83
  }
69
84
  }
70
85
  async startInner() {
71
- this.child = spawn(process.execPath, [resolveCodexCliBinPath(), ...this.options.args], {
86
+ const executable = this.options.executable ?? process.execPath;
87
+ const executableArgs = this.options.executableArgs ?? [resolveCodexCliBinPath()];
88
+ this.child = spawn(executable, [...executableArgs, ...this.options.args], {
72
89
  cwd: this.options.cwd,
73
90
  detached: process.platform !== "win32",
74
91
  env: this.options.env,
@@ -91,6 +108,8 @@ export class CodexAppServerClient {
91
108
  removeExitHooks();
92
109
  this.signalProcessGroup(childPid, "SIGTERM");
93
110
  this.rejectPending(new Error("Codex app-server exited"));
111
+ this.activeTurns.clear();
112
+ this.notifyActiveTurnsChanged();
94
113
  this.stdoutLines?.close();
95
114
  this.stdoutLines = null;
96
115
  this.child = null;
@@ -144,6 +163,16 @@ export class CodexAppServerClient {
144
163
  return;
145
164
  }
146
165
  const record = message;
166
+ if (typeof record.method === "string") {
167
+ if (this.isRequestId(record.id)) {
168
+ void this.handleServerRequest(record.id, record.method, record.params);
169
+ }
170
+ else {
171
+ this.trackTurnNotification(record.method, record.params);
172
+ this.options.onNotification?.(record.method, record.params);
173
+ }
174
+ return;
175
+ }
147
176
  if (record.id !== undefined) {
148
177
  const id = Number(record.id);
149
178
  const pending = Number.isInteger(id) ? this.pending.get(id) : null;
@@ -161,8 +190,71 @@ export class CodexAppServerClient {
161
190
  }
162
191
  return;
163
192
  }
164
- if (typeof record.method === "string") {
165
- this.options.onNotification?.(record.method, record.params);
193
+ }
194
+ isRequestId(value) {
195
+ return (typeof value === "string" && value.length > 0)
196
+ || (typeof value === "number" && Number.isFinite(value));
197
+ }
198
+ async handleServerRequest(id, method, params) {
199
+ try {
200
+ if (!this.options.onServerRequest) {
201
+ throw new CodexAppServerRequestError(`Unsupported Codex app-server request: ${method}`, -32601);
202
+ }
203
+ const result = await this.options.onServerRequest(method, params);
204
+ this.writeMessage({ id, result });
205
+ }
206
+ catch (error) {
207
+ const requestError = error instanceof CodexAppServerRequestError
208
+ ? error
209
+ : new CodexAppServerRequestError(error instanceof Error ? error.message : String(error));
210
+ this.options.onLog?.(`[codex-app-server] server request failed method=${method} error=${requestError.message}`);
211
+ this.writeMessage({
212
+ id,
213
+ error: {
214
+ code: requestError.code,
215
+ message: requestError.message,
216
+ ...(requestError.data === undefined ? {} : { data: requestError.data }),
217
+ },
218
+ });
219
+ }
220
+ }
221
+ writeMessage(message) {
222
+ const child = this.child;
223
+ if (!child || child.killed || child.stdin.destroyed) {
224
+ return;
225
+ }
226
+ child.stdin.write(`${JSON.stringify(message)}\n`, "utf8");
227
+ }
228
+ trackTurnNotification(method, params) {
229
+ if (method !== "turn/started" && method !== "turn/completed" && method !== "turn/interrupted") {
230
+ return;
231
+ }
232
+ const record = params && typeof params === "object" && !Array.isArray(params)
233
+ ? params
234
+ : null;
235
+ const turn = record?.turn && typeof record.turn === "object" && !Array.isArray(record.turn)
236
+ ? record.turn
237
+ : null;
238
+ const threadId = typeof record?.threadId === "string" ? record.threadId : "";
239
+ const turnId = typeof turn?.id === "string"
240
+ ? turn.id
241
+ : typeof record?.turnId === "string"
242
+ ? record.turnId
243
+ : "";
244
+ if (!threadId || !turnId) {
245
+ return;
246
+ }
247
+ if (method === "turn/started") {
248
+ this.activeTurns.set(threadId, turnId);
249
+ }
250
+ else if (this.activeTurns.get(threadId) === turnId) {
251
+ this.activeTurns.delete(threadId);
252
+ }
253
+ this.notifyActiveTurnsChanged();
254
+ }
255
+ notifyActiveTurnsChanged() {
256
+ for (const listener of this.activeTurnsChanged) {
257
+ listener();
166
258
  }
167
259
  }
168
260
  rejectPending(error) {
@@ -173,6 +265,7 @@ export class CodexAppServerClient {
173
265
  }
174
266
  }
175
267
  async stopChild(child) {
268
+ await this.interruptActiveTurns();
176
269
  const pid = child.pid;
177
270
  if (!pid) {
178
271
  child.kill("SIGTERM");
@@ -189,6 +282,42 @@ export class CodexAppServerClient {
189
282
  this.signalProcessGroup(pid, "SIGTERM");
190
283
  }
191
284
  }
285
+ async interruptActiveTurns() {
286
+ const turns = [...this.activeTurns.entries()].map(([threadId, turnId]) => ({ threadId, turnId }));
287
+ if (turns.length === 0) {
288
+ return;
289
+ }
290
+ this.options.onLog?.(`[codex-app-server] interrupting ${turns.length} active turn(s) before shutdown`);
291
+ await Promise.allSettled(turns.map(({ threadId, turnId }) => this.requestStarted("turn/interrupt", { threadId, turnId }, 5_000)));
292
+ const timeoutMs = this.options.gracefulShutdownTimeoutMs ?? 10_000;
293
+ const drained = await this.waitForTurnsToFinish(turns, timeoutMs);
294
+ if (!drained) {
295
+ this.options.onLog?.(`[codex-app-server] timed out waiting for active turns to finish before shutdown`);
296
+ }
297
+ }
298
+ async waitForTurnsToFinish(turns, timeoutMs) {
299
+ const isFinished = () => turns.every(({ threadId, turnId }) => this.activeTurns.get(threadId) !== turnId);
300
+ if (isFinished()) {
301
+ return true;
302
+ }
303
+ return await new Promise((resolve) => {
304
+ const onChanged = () => {
305
+ if (isFinished()) {
306
+ cleanup();
307
+ resolve(true);
308
+ }
309
+ };
310
+ const timer = setTimeout(() => {
311
+ cleanup();
312
+ resolve(false);
313
+ }, timeoutMs);
314
+ const cleanup = () => {
315
+ clearTimeout(timer);
316
+ this.activeTurnsChanged.delete(onChanged);
317
+ };
318
+ this.activeTurnsChanged.add(onChanged);
319
+ });
320
+ }
192
321
  registerProcessExitHooks(pid) {
193
322
  if (!pid || process.platform === "win32") {
194
323
  return () => { };
@@ -211,14 +340,15 @@ export class CodexAppServerClient {
211
340
  process.once("exit", cleanup);
212
341
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
213
342
  const handler = () => {
214
- cleanup();
215
- remove();
216
- try {
217
- process.kill(process.pid, signal);
218
- }
219
- catch {
220
- process.exitCode = 1;
221
- }
343
+ void this.stop().finally(() => {
344
+ remove();
345
+ try {
346
+ process.kill(process.pid, signal);
347
+ }
348
+ catch {
349
+ process.exitCode = 1;
350
+ }
351
+ });
222
352
  };
223
353
  signalHandlers.set(signal, handler);
224
354
  process.once(signal, handler);
@@ -0,0 +1,91 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import { CodexAppServerClient } from "./codex-app-server-client.js";
7
+ const fixtureSource = String.raw `
8
+ const fs = require("node:fs");
9
+ const readline = require("node:readline");
10
+ const tracePath = process.env.DOER_CODEX_FIXTURE_TRACE;
11
+ const trace = (value) => fs.appendFileSync(tracePath, value + "\n");
12
+ const send = (value) => process.stdout.write(JSON.stringify(value) + "\n");
13
+ let waitingRequestId = null;
14
+ readline.createInterface({ input: process.stdin }).on("line", (line) => {
15
+ const message = JSON.parse(line);
16
+ if (message.method === "initialize") {
17
+ send({ id: message.id, result: {} });
18
+ return;
19
+ }
20
+ if (message.method === "test/server-request") {
21
+ waitingRequestId = message.id;
22
+ send({
23
+ id: "server-request-1",
24
+ method: "item/tool/call",
25
+ params: { tool: "fixture", arguments: {} },
26
+ });
27
+ return;
28
+ }
29
+ if (message.id === "server-request-1") {
30
+ trace("server-request-response");
31
+ send({ id: waitingRequestId, result: message.result });
32
+ return;
33
+ }
34
+ if (message.method === "test/start-turn") {
35
+ send({
36
+ method: "turn/started",
37
+ params: {
38
+ threadId: "thread-1",
39
+ turn: { id: "turn-1", status: "inProgress" },
40
+ },
41
+ });
42
+ send({ id: message.id, result: {} });
43
+ return;
44
+ }
45
+ if (message.method === "turn/interrupt") {
46
+ trace("turn-interrupt");
47
+ send({ id: message.id, result: {} });
48
+ send({
49
+ method: "turn/completed",
50
+ params: {
51
+ threadId: "thread-1",
52
+ turn: { id: "turn-1", status: "interrupted" },
53
+ },
54
+ });
55
+ }
56
+ });
57
+ process.on("SIGTERM", () => {
58
+ trace("sigterm");
59
+ process.exit(0);
60
+ });
61
+ `;
62
+ test("handles server requests and drains active turns before shutdown", async () => {
63
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "doer-app-server-client-"));
64
+ const tracePath = path.join(tempDir, "trace.log");
65
+ const client = new CodexAppServerClient({
66
+ cwd: tempDir,
67
+ args: [],
68
+ executable: process.execPath,
69
+ executableArgs: ["--eval", fixtureSource],
70
+ env: {
71
+ ...process.env,
72
+ DOER_CODEX_FIXTURE_TRACE: tracePath,
73
+ },
74
+ gracefulShutdownTimeoutMs: 2_000,
75
+ onServerRequest: async (method, params) => {
76
+ assert.equal(method, "item/tool/call");
77
+ assert.deepEqual(params, { tool: "fixture", arguments: {} });
78
+ return { contentItems: [], success: true };
79
+ },
80
+ });
81
+ const serverRequestResult = await client.request("test/server-request");
82
+ assert.deepEqual(serverRequestResult, { contentItems: [], success: true });
83
+ await client.request("test/start-turn");
84
+ await client.stop();
85
+ const trace = (await readFile(tracePath, "utf8")).trim().split("\n");
86
+ assert.deepEqual(trace, [
87
+ "server-request-response",
88
+ "turn-interrupt",
89
+ "sigterm",
90
+ ]);
91
+ });
@@ -1,6 +1,6 @@
1
1
  import { buildAgentSettingsEnvPatch, readAgentModelInstructions, resolveAgentModelInstructionsFilePath, } from "./agent-settings.js";
2
2
  import { buildCustomMcpConfigArgs, buildDaemonMcpConfigArgs, buildMobileMcpConfigArgs, buildThreadsMcpConfigArgs, spawnManagedCodexCommand, } from "./agent-codex-cli.js";
3
- import { CodexAppServerClient } from "./codex-app-server-client.js";
3
+ import { CodexAppServerClient, CodexAppServerRequestError, } from "./codex-app-server-client.js";
4
4
  import { startCodexChatBridge } from "./codex-chat-bridge.js";
5
5
  import { allocateMcpOauthCallbackPort, buildMcpOauthCallbackBaseUrl, relayMcpOauthCallbackToLocalListener, } from "./mcp-oauth-relay.js";
6
6
  function toTomlStringLiteral(value) {
@@ -12,6 +12,35 @@ function buildConfigArg(key, tomlValue) {
12
12
  function buildFeatureArg(enabled, name) {
13
13
  return [enabled ? "--enable" : "--disable", name];
14
14
  }
15
+ function safeServerRequestResponse(method) {
16
+ switch (method) {
17
+ case "item/commandExecution/requestApproval":
18
+ case "item/fileChange/requestApproval":
19
+ return { decision: "decline" };
20
+ case "item/tool/requestUserInput":
21
+ return { answers: {} };
22
+ case "mcpServer/elicitation/request":
23
+ return { action: "decline", content: null, _meta: null };
24
+ case "item/permissions/requestApproval":
25
+ return { permissions: {}, scope: "turn" };
26
+ case "item/tool/call":
27
+ return {
28
+ contentItems: [{
29
+ type: "inputText",
30
+ text: "This client does not provide a handler for dynamic tools.",
31
+ }],
32
+ success: false,
33
+ };
34
+ case "applyPatchApproval":
35
+ case "execCommandApproval":
36
+ return { decision: { denied: { rejection: "Client approval UI is unavailable." } } };
37
+ case "account/chatgptAuthTokens/refresh":
38
+ case "attestation/generate":
39
+ throw new CodexAppServerRequestError(`Unsupported Codex app-server request: ${method}`, -32601);
40
+ default:
41
+ throw new CodexAppServerRequestError(`Unknown Codex app-server request: ${method}`, -32601);
42
+ }
43
+ }
15
44
  function resolveCodexModel(settings) {
16
45
  const providerKey = settings.codex.modelProvider || "openai";
17
46
  if (providerKey === "zai") {
@@ -143,6 +172,7 @@ export function createCodexAppServerManager(args) {
143
172
  let client = null;
144
173
  let providerProxy = null;
145
174
  let createPromise = null;
175
+ let lifecyclePromise = null;
146
176
  let mcpOauthCallbackPortPromise = null;
147
177
  let generation = 0;
148
178
  const notificationListeners = new Set();
@@ -189,6 +219,10 @@ export function createCodexAppServerManager(args) {
189
219
  args: appServerArgs,
190
220
  env,
191
221
  onLog: args.onLog,
222
+ onServerRequest: async (method) => {
223
+ args.onLog?.(`codex app-server server request method=${method} handled=safe-fallback`);
224
+ return safeServerRequestResponse(method);
225
+ },
192
226
  onNotification: (method, params) => {
193
227
  for (const listener of notificationListeners) {
194
228
  listener(method, params);
@@ -198,6 +232,9 @@ export function createCodexAppServerManager(args) {
198
232
  });
199
233
  };
200
234
  const getClient = async () => {
235
+ if (lifecyclePromise) {
236
+ await lifecyclePromise;
237
+ }
201
238
  if (client) {
202
239
  return client;
203
240
  }
@@ -238,6 +275,21 @@ export function createCodexAppServerManager(args) {
238
275
  args.onLog?.(`failed to stop provider proxy: ${error instanceof Error ? error.message : String(error)}`);
239
276
  });
240
277
  };
278
+ const scheduleRestart = async (reason) => {
279
+ if (lifecyclePromise) {
280
+ return await lifecyclePromise;
281
+ }
282
+ const pendingRestart = restartClient(reason);
283
+ lifecyclePromise = pendingRestart;
284
+ try {
285
+ await pendingRestart;
286
+ }
287
+ finally {
288
+ if (lifecyclePromise === pendingRestart) {
289
+ lifecyclePromise = null;
290
+ }
291
+ }
292
+ };
241
293
  const runMcpLogout = async (name) => {
242
294
  const settings = await args.readAgentSettingsConfig({ workspaceRoot: args.workspaceRoot });
243
295
  const server = settings.mcp.servers.find((candidate) => candidate.name === name && candidate.transport === "streamable_http");
@@ -304,7 +356,7 @@ export function createCodexAppServerManager(args) {
304
356
  await runMcpLogout(normalizedName);
305
357
  },
306
358
  async restart(reason) {
307
- await restartClient(reason);
359
+ await scheduleRestart(reason);
308
360
  },
309
361
  async stop() {
310
362
  const activeClient = client;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doer-agent",
3
- "version": "0.9.7",
3
+ "version": "0.9.8",
4
4
  "description": "Reverse-polling agent runtime for doer",
5
5
  "type": "module",
6
6
  "main": "dist/agent.js",