doer-agent 0.9.9 → 0.9.11

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.
@@ -1,5 +1,6 @@
1
1
  import { StringCodec } from "nats";
2
2
  import { createCodexThreadHandoff, } from "./codex-thread-handoff.js";
3
+ import { CodexThreadHandoffJobManager } from "./codex-thread-handoff-jobs.js";
3
4
  const codexAppRpcCodec = StringCodec();
4
5
  const codexAppRpcOmitRules = [
5
6
  {
@@ -84,7 +85,7 @@ function normalizeCodexAppRpcRequest(args) {
84
85
  }
85
86
  return { requestId, action: "mcp-oauth-logout", name };
86
87
  }
87
- if (actionRaw === "thread-handoff") {
88
+ if (actionRaw === "thread-handoff" || actionRaw === "thread-handoff-start") {
88
89
  const sourceThreadId = typeof args.request.sourceThreadId === "string" ? args.request.sourceThreadId.trim() : "";
89
90
  const targetName = typeof args.request.targetName === "string" ? args.request.targetName.trim() : "";
90
91
  const activeTurnId = typeof args.request.activeTurnId === "string" ? args.request.activeTurnId.trim() : "";
@@ -102,7 +103,7 @@ function normalizeCodexAppRpcRequest(args) {
102
103
  }
103
104
  return {
104
105
  requestId,
105
- action: "thread-handoff",
106
+ action: actionRaw,
106
107
  sourceThreadId,
107
108
  targetName,
108
109
  activeTurnId,
@@ -110,6 +111,13 @@ function normalizeCodexAppRpcRequest(args) {
110
111
  sourceGoal,
111
112
  };
112
113
  }
114
+ if (actionRaw === "thread-handoff-status") {
115
+ const jobId = typeof args.request.jobId === "string" ? args.request.jobId.trim() : "";
116
+ if (!jobId) {
117
+ throw new Error("invalid thread handoff status request");
118
+ }
119
+ return { requestId, action: "thread-handoff-status", jobId };
120
+ }
113
121
  if (actionRaw !== "request" || !method) {
114
122
  throw new Error("invalid codex app rpc request");
115
123
  }
@@ -127,21 +135,43 @@ async function handleCodexAppRpcMessage(args) {
127
135
  const payload = JSON.parse(codexAppRpcCodec.decode(args.msg.data));
128
136
  const request = normalizeCodexAppRpcRequest({ request: payload, agentId: args.agentId });
129
137
  requestId = request.requestId;
130
- const result = request.action === "request"
131
- ? applyCodexAppRpcOmitRules(request.method, await args.manager.request(request.method, request.params, request.timeoutMs))
132
- : request.action === "mcp-oauth-callback"
133
- ? await args.manager.relayMcpOauthCallback(request.path, request.search)
134
- : request.action === "mcp-oauth-logout"
135
- ? await args.manager.logoutMcpServer(request.name)
136
- : await createCodexThreadHandoff({
137
- manager: args.manager,
138
- sourceThreadId: request.sourceThreadId,
139
- targetName: request.targetName,
140
- activeTurnId: request.activeTurnId,
141
- latestUserText: request.latestUserText,
142
- sourceGoal: request.sourceGoal,
143
- onLog: args.onInfo,
144
- });
138
+ let result;
139
+ if (request.action === "request") {
140
+ result = applyCodexAppRpcOmitRules(request.method, await args.manager.request(request.method, request.params, request.timeoutMs));
141
+ }
142
+ else if (request.action === "mcp-oauth-callback") {
143
+ result = await args.manager.relayMcpOauthCallback(request.path, request.search);
144
+ }
145
+ else if (request.action === "mcp-oauth-logout") {
146
+ result = await args.manager.logoutMcpServer(request.name);
147
+ }
148
+ else if (request.action === "thread-handoff-status") {
149
+ const job = args.handoffJobs.get(request.jobId);
150
+ if (!job) {
151
+ throw new Error("thread handoff job not found");
152
+ }
153
+ result = job;
154
+ }
155
+ else if (request.action === "thread-handoff-start") {
156
+ result = args.handoffJobs.start({
157
+ sourceThreadId: request.sourceThreadId,
158
+ targetName: request.targetName,
159
+ activeTurnId: request.activeTurnId,
160
+ latestUserText: request.latestUserText,
161
+ sourceGoal: request.sourceGoal,
162
+ });
163
+ }
164
+ else {
165
+ result = await createCodexThreadHandoff({
166
+ manager: args.manager,
167
+ sourceThreadId: request.sourceThreadId,
168
+ targetName: request.targetName,
169
+ activeTurnId: request.activeTurnId,
170
+ latestUserText: request.latestUserText,
171
+ sourceGoal: request.sourceGoal,
172
+ onLog: args.onInfo,
173
+ });
174
+ }
145
175
  args.msg.respond(codexAppRpcCodec.encode(JSON.stringify({
146
176
  requestId,
147
177
  ok: true,
@@ -159,6 +189,29 @@ async function handleCodexAppRpcMessage(args) {
159
189
  }
160
190
  }
161
191
  export function subscribeToCodexAppRpc(args) {
192
+ const handoffJobs = new CodexThreadHandoffJobManager(async (input, onProgress) => {
193
+ return await createCodexThreadHandoff({
194
+ manager: args.manager,
195
+ ...input,
196
+ onProgress,
197
+ onLog: args.onInfo,
198
+ });
199
+ }, {
200
+ onStatus: (job) => {
201
+ const elapsedMs = Math.max(0, Date.parse(job.updatedAt) - Date.parse(job.createdAt));
202
+ const target = job.result?.threadId ? ` targetThreadId=${job.result.threadId}` : "";
203
+ const error = job.error
204
+ ? ` error=${JSON.stringify(job.error.replace(/\s+/g, " ").slice(0, 500))}`
205
+ : "";
206
+ const message = `thread handoff job status jobId=${job.jobId} sourceThreadId=${job.sourceThreadId} phase=${job.phase} elapsedMs=${elapsedMs}${target}${error}`;
207
+ if (job.phase === "failed") {
208
+ args.onError(message);
209
+ }
210
+ else {
211
+ args.onInfo(message);
212
+ }
213
+ },
214
+ });
162
215
  args.nc.closed().finally(() => {
163
216
  void args.manager.stop().catch(() => undefined);
164
217
  });
@@ -174,6 +227,7 @@ export function subscribeToCodexAppRpc(args) {
174
227
  nc: args.nc,
175
228
  agentId: args.agentId,
176
229
  manager: args.manager,
230
+ handoffJobs,
177
231
  onInfo: args.onInfo,
178
232
  onError: args.onError,
179
233
  });
@@ -37,14 +37,17 @@ function buildCodexUserInput(prompt) {
37
37
  }];
38
38
  }
39
39
  export async function runCodexTextTask(args) {
40
+ const startedAt = Date.now();
40
41
  let threadId = "";
41
42
  let turnId = "";
42
43
  let output = "";
44
+ let outputStarted = false;
43
45
  let settled = false;
44
46
  let cleanupNotification = () => { };
45
47
  let settle = (_callback) => { };
46
48
  const completed = new Promise((resolve, reject) => {
47
49
  const timeout = setTimeout(() => {
50
+ args.onLog?.(`codex text task status phase=timed_out threadId=${threadId || "pending"} turnId=${turnId || "pending"} elapsedMs=${Date.now() - startedAt} outputChars=${output.length}`);
48
51
  settle(() => reject(new Error("Timed out while waiting for Codex text task")));
49
52
  }, args.timeoutMs ?? DEFAULT_TIMEOUT_MS);
50
53
  settle = (callback) => {
@@ -69,11 +72,16 @@ export async function runCodexTextTask(args) {
69
72
  if (method === "item/agentMessage/delta") {
70
73
  const record = recordValue(params);
71
74
  output += stringValue(record?.delta) || stringValue(record?.text);
75
+ if (!outputStarted && output) {
76
+ outputStarted = true;
77
+ args.onLog?.(`codex text task status phase=responding threadId=${threadId} turnId=${turnId || eventTurnId || "pending"} elapsedMs=${Date.now() - startedAt}`);
78
+ }
72
79
  return;
73
80
  }
74
81
  if (!isTerminalTurnMethod(method)) {
75
82
  return;
76
83
  }
84
+ args.onLog?.(`codex text task status phase=terminal_notification method=${method} threadId=${threadId} turnId=${turnId || eventTurnId || "pending"} elapsedMs=${Date.now() - startedAt} outputChars=${output.length}`);
77
85
  if (method === "turn/completed") {
78
86
  settle(resolve);
79
87
  return;
@@ -92,6 +100,7 @@ export async function runCodexTextTask(args) {
92
100
  if (!threadId) {
93
101
  throw new Error("Codex app-server did not return a text task thread id");
94
102
  }
103
+ args.onLog?.(`codex text task status phase=thread_started threadId=${threadId} elapsedMs=${Date.now() - startedAt}`);
95
104
  const turnResult = recordValue(await args.manager.request("turn/start", {
96
105
  threadId,
97
106
  input: buildCodexUserInput(args.prompt),
@@ -100,16 +109,25 @@ export async function runCodexTextTask(args) {
100
109
  if (!turnId) {
101
110
  throw new Error("Codex app-server did not return a text task turn id");
102
111
  }
112
+ args.onLog?.(`codex text task status phase=turn_started threadId=${threadId} turnId=${turnId} elapsedMs=${Date.now() - startedAt}`);
103
113
  await completed.finally(() => cleanupNotification());
114
+ args.onLog?.(`codex text task status phase=completed threadId=${threadId} turnId=${turnId} elapsedMs=${Date.now() - startedAt} outputChars=${output.length}`);
104
115
  return output;
105
116
  }
106
117
  catch (error) {
107
118
  settle(() => { });
119
+ args.onLog?.(`codex text task status phase=failed threadId=${threadId || "pending"} turnId=${turnId || "pending"} elapsedMs=${Date.now() - startedAt} error=${JSON.stringify((error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").slice(0, 500))}`);
108
120
  throw error;
109
121
  }
110
122
  finally {
111
123
  if (threadId) {
112
- await args.manager.request("thread/unsubscribe", { threadId }, 30_000).catch(() => undefined);
124
+ await args.manager.request("thread/unsubscribe", { threadId }, 30_000)
125
+ .then(() => {
126
+ args.onLog?.(`codex text task status phase=unsubscribed threadId=${threadId} turnId=${turnId || "pending"} elapsedMs=${Date.now() - startedAt}`);
127
+ })
128
+ .catch((error) => {
129
+ args.onLog?.(`codex text task status phase=unsubscribe_failed threadId=${threadId} turnId=${turnId || "pending"} elapsedMs=${Date.now() - startedAt} error=${JSON.stringify((error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").slice(0, 500))}`);
130
+ });
113
131
  }
114
132
  }
115
133
  }
@@ -0,0 +1,111 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const TERMINAL_PHASES = new Set(["completed", "failed"]);
3
+ function copySnapshot(job) {
4
+ return {
5
+ ...job,
6
+ ...(job.result ? { result: { ...job.result } } : {}),
7
+ };
8
+ }
9
+ export class CodexThreadHandoffJobManager {
10
+ run;
11
+ jobs = new Map();
12
+ activeJobBySourceThreadId = new Map();
13
+ terminalTtlMs;
14
+ maxJobs;
15
+ onStatus;
16
+ constructor(run, options = {}) {
17
+ this.run = run;
18
+ this.terminalTtlMs = options.terminalTtlMs ?? 30 * 60_000;
19
+ this.maxJobs = options.maxJobs ?? 100;
20
+ this.onStatus = options.onStatus;
21
+ }
22
+ start(input) {
23
+ this.cleanup();
24
+ const existingJobId = this.activeJobBySourceThreadId.get(input.sourceThreadId);
25
+ const existing = existingJobId ? this.jobs.get(existingJobId) : undefined;
26
+ if (existing && !TERMINAL_PHASES.has(existing.phase)) {
27
+ return { job: copySnapshot(existing), deduplicated: true };
28
+ }
29
+ if (this.jobs.size >= this.maxJobs) {
30
+ throw new Error("too many thread handoff jobs");
31
+ }
32
+ const now = new Date().toISOString();
33
+ const job = {
34
+ jobId: randomUUID(),
35
+ sourceThreadId: input.sourceThreadId,
36
+ phase: "queued",
37
+ createdAt: now,
38
+ updatedAt: now,
39
+ };
40
+ this.jobs.set(job.jobId, job);
41
+ this.activeJobBySourceThreadId.set(input.sourceThreadId, job.jobId);
42
+ this.emitStatus(job);
43
+ setImmediate(() => {
44
+ void this.execute(job.jobId, input);
45
+ });
46
+ return { job: copySnapshot(job), deduplicated: false };
47
+ }
48
+ get(jobId) {
49
+ this.cleanup();
50
+ const job = this.jobs.get(jobId);
51
+ return job ? copySnapshot(job) : null;
52
+ }
53
+ async execute(jobId, input) {
54
+ const updatePhase = (phase) => {
55
+ const current = this.jobs.get(jobId);
56
+ if (!current || TERMINAL_PHASES.has(current.phase)) {
57
+ return;
58
+ }
59
+ current.phase = phase;
60
+ current.updatedAt = new Date().toISOString();
61
+ this.emitStatus(current);
62
+ };
63
+ try {
64
+ const result = await this.run(input, updatePhase);
65
+ const current = this.jobs.get(jobId);
66
+ if (current) {
67
+ current.phase = "completed";
68
+ current.result = result;
69
+ current.updatedAt = new Date().toISOString();
70
+ this.emitStatus(current);
71
+ }
72
+ }
73
+ catch (error) {
74
+ const current = this.jobs.get(jobId);
75
+ if (current) {
76
+ current.phase = "failed";
77
+ current.error = error instanceof Error ? error.message : String(error);
78
+ current.updatedAt = new Date().toISOString();
79
+ this.emitStatus(current);
80
+ }
81
+ }
82
+ finally {
83
+ if (this.activeJobBySourceThreadId.get(input.sourceThreadId) === jobId) {
84
+ this.activeJobBySourceThreadId.delete(input.sourceThreadId);
85
+ }
86
+ }
87
+ }
88
+ emitStatus(job) {
89
+ this.onStatus?.(copySnapshot(job));
90
+ }
91
+ cleanup() {
92
+ const cutoff = Date.now() - this.terminalTtlMs;
93
+ for (const [jobId, job] of this.jobs) {
94
+ if (TERMINAL_PHASES.has(job.phase) && Date.parse(job.updatedAt) < cutoff) {
95
+ this.jobs.delete(jobId);
96
+ }
97
+ }
98
+ if (this.jobs.size < this.maxJobs) {
99
+ return;
100
+ }
101
+ const terminalJobs = [...this.jobs.values()]
102
+ .filter((job) => TERMINAL_PHASES.has(job.phase))
103
+ .sort((left, right) => Date.parse(left.updatedAt) - Date.parse(right.updatedAt));
104
+ while (this.jobs.size >= this.maxJobs && terminalJobs.length > 0) {
105
+ const oldest = terminalJobs.shift();
106
+ if (oldest) {
107
+ this.jobs.delete(oldest.jobId);
108
+ }
109
+ }
110
+ }
111
+ }
@@ -0,0 +1,67 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { CodexThreadHandoffJobManager } from "./codex-thread-handoff-jobs.js";
4
+ function handoffResult(sourceThreadId) {
5
+ return {
6
+ sourceThreadId,
7
+ thread: { id: "target-thread" },
8
+ threadId: "target-thread",
9
+ handoff: "summary",
10
+ turnsCollected: 2,
11
+ turnsOmitted: 0,
12
+ warnings: [],
13
+ };
14
+ }
15
+ async function nextTick() {
16
+ await new Promise((resolve) => setImmediate(resolve));
17
+ }
18
+ test("starts handoff work asynchronously and reports progress", async () => {
19
+ let finish;
20
+ const statuses = [];
21
+ const manager = new CodexThreadHandoffJobManager(async (input, onProgress) => {
22
+ onProgress("collecting");
23
+ return await new Promise((resolve) => {
24
+ finish = resolve;
25
+ });
26
+ }, {
27
+ onStatus: (job) => statuses.push(job.phase),
28
+ });
29
+ const started = manager.start({ sourceThreadId: "source-thread" });
30
+ assert.equal(started.job.phase, "queued");
31
+ assert.equal(started.deduplicated, false);
32
+ await nextTick();
33
+ assert.equal(manager.get(started.job.jobId)?.phase, "collecting");
34
+ finish?.(handoffResult("source-thread"));
35
+ await nextTick();
36
+ const completed = manager.get(started.job.jobId);
37
+ assert.equal(completed?.phase, "completed");
38
+ assert.equal(completed?.result?.threadId, "target-thread");
39
+ assert.deepEqual(statuses, ["queued", "collecting", "completed"]);
40
+ });
41
+ test("deduplicates active jobs for the same source thread", () => {
42
+ const manager = new CodexThreadHandoffJobManager(async () => {
43
+ return await new Promise(() => undefined);
44
+ });
45
+ const first = manager.start({ sourceThreadId: "source-thread" });
46
+ const second = manager.start({ sourceThreadId: "source-thread" });
47
+ assert.equal(second.deduplicated, true);
48
+ assert.equal(second.job.jobId, first.job.jobId);
49
+ });
50
+ test("keeps a failed job status for polling", async () => {
51
+ const statuses = [];
52
+ const manager = new CodexThreadHandoffJobManager(async () => {
53
+ throw new Error("summary failed");
54
+ }, {
55
+ onStatus: (job) => statuses.push({ phase: job.phase, error: job.error }),
56
+ });
57
+ const started = manager.start({ sourceThreadId: "source-thread" });
58
+ await nextTick();
59
+ await nextTick();
60
+ const failed = manager.get(started.job.jobId);
61
+ assert.equal(failed?.phase, "failed");
62
+ assert.equal(failed?.error, "summary failed");
63
+ assert.deepEqual(statuses, [
64
+ { phase: "queued", error: undefined },
65
+ { phase: "failed", error: "summary failed" },
66
+ ]);
67
+ });
@@ -265,6 +265,7 @@ export async function createCodexThreadHandoff(args) {
265
265
  }
266
266
  const warnings = [];
267
267
  const sourceGoal = normalizeGoal(args.sourceGoal);
268
+ args.onProgress?.("preparing");
268
269
  if (sourceGoal?.status === "active") {
269
270
  await args.manager.request("thread/goal/set", {
270
271
  threadId: sourceThreadId,
@@ -282,6 +283,7 @@ export async function createCodexThreadHandoff(args) {
282
283
  warnings.push(`Could not interrupt source turn: ${error instanceof Error ? error.message : String(error)}`);
283
284
  });
284
285
  }
286
+ args.onProgress?.("collecting");
285
287
  args.onLog?.(`collecting handoff history sourceThreadId=${sourceThreadId}`);
286
288
  const sourceRead = recordValue(await args.manager.request("thread/read", {
287
289
  threadId: sourceThreadId,
@@ -304,6 +306,7 @@ export async function createCodexThreadHandoff(args) {
304
306
  }
305
307
  const turnsOmitted = Math.max(0, collected.turnCount - bounded.includedTurns)
306
308
  + (collected.truncated ? 1 : 0);
309
+ args.onProgress?.("summarizing");
307
310
  args.onLog?.(`summarizing handoff sourceThreadId=${sourceThreadId} turns=${collected.turnCount}`);
308
311
  const handoffRaw = await runCodexTextTask({
309
312
  manager: args.manager,
@@ -316,11 +319,13 @@ export async function createCodexThreadHandoff(args) {
316
319
  turnsCollected: collected.turnCount,
317
320
  turnsOmitted,
318
321
  }),
322
+ onLog: args.onLog,
319
323
  });
320
324
  const handoff = boundedText(handoffRaw, MAX_HANDOFF_CHARS);
321
325
  if (!handoff) {
322
326
  throw new Error("Codex did not return a thread handoff summary");
323
327
  }
328
+ args.onProgress?.("creating");
324
329
  args.onLog?.(`creating handoff target sourceThreadId=${sourceThreadId}`);
325
330
  const startResult = recordValue(await args.manager.request("thread/start", {
326
331
  cwd: sourceCwd || null,
@@ -333,6 +338,7 @@ export async function createCodexThreadHandoff(args) {
333
338
  throw new Error("Codex app-server did not return a handoff target thread");
334
339
  }
335
340
  try {
341
+ args.onProgress?.("injecting");
336
342
  await args.manager.request("thread/inject_items", {
337
343
  threadId,
338
344
  items: [{
@@ -349,6 +355,7 @@ export async function createCodexThreadHandoff(args) {
349
355
  await args.manager.request("thread/delete", { threadId }, 30_000).catch(() => undefined);
350
356
  throw error;
351
357
  }
358
+ args.onProgress?.("finalizing");
352
359
  const targetName = stringValue(args.targetName).slice(0, 200);
353
360
  if (targetName) {
354
361
  await args.manager.request("thread/name/set", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doer-agent",
3
- "version": "0.9.9",
3
+ "version": "0.9.11",
4
4
  "description": "Reverse-polling agent runtime for doer",
5
5
  "type": "module",
6
6
  "main": "dist/agent.js",