doer-agent 0.9.10 → 0.9.12

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.
@@ -190,20 +190,27 @@ async function handleCodexAppRpcMessage(args) {
190
190
  }
191
191
  export function subscribeToCodexAppRpc(args) {
192
192
  const handoffJobs = new CodexThreadHandoffJobManager(async (input, onProgress) => {
193
- try {
194
- const result = await createCodexThreadHandoff({
195
- manager: args.manager,
196
- ...input,
197
- onProgress,
198
- onLog: args.onInfo,
199
- });
200
- args.onInfo(`thread handoff completed sourceThreadId=${input.sourceThreadId} targetThreadId=${result.threadId}`);
201
- return result;
202
- }
203
- catch (error) {
204
- args.onError(`thread handoff failed sourceThreadId=${input.sourceThreadId} error=${error instanceof Error ? error.message : String(error)}`);
205
- throw error;
206
- }
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
+ },
207
214
  });
208
215
  args.nc.closed().finally(() => {
209
216
  void args.manager.stop().catch(() => undefined);
@@ -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
  }
@@ -8,14 +8,16 @@ function copySnapshot(job) {
8
8
  }
9
9
  export class CodexThreadHandoffJobManager {
10
10
  run;
11
- terminalTtlMs;
12
- maxJobs;
13
11
  jobs = new Map();
14
12
  activeJobBySourceThreadId = new Map();
15
- constructor(run, terminalTtlMs = 30 * 60_000, maxJobs = 100) {
13
+ terminalTtlMs;
14
+ maxJobs;
15
+ onStatus;
16
+ constructor(run, options = {}) {
16
17
  this.run = run;
17
- this.terminalTtlMs = terminalTtlMs;
18
- this.maxJobs = maxJobs;
18
+ this.terminalTtlMs = options.terminalTtlMs ?? 30 * 60_000;
19
+ this.maxJobs = options.maxJobs ?? 100;
20
+ this.onStatus = options.onStatus;
19
21
  }
20
22
  start(input) {
21
23
  this.cleanup();
@@ -37,6 +39,7 @@ export class CodexThreadHandoffJobManager {
37
39
  };
38
40
  this.jobs.set(job.jobId, job);
39
41
  this.activeJobBySourceThreadId.set(input.sourceThreadId, job.jobId);
42
+ this.emitStatus(job);
40
43
  setImmediate(() => {
41
44
  void this.execute(job.jobId, input);
42
45
  });
@@ -55,6 +58,7 @@ export class CodexThreadHandoffJobManager {
55
58
  }
56
59
  current.phase = phase;
57
60
  current.updatedAt = new Date().toISOString();
61
+ this.emitStatus(current);
58
62
  };
59
63
  try {
60
64
  const result = await this.run(input, updatePhase);
@@ -63,6 +67,7 @@ export class CodexThreadHandoffJobManager {
63
67
  current.phase = "completed";
64
68
  current.result = result;
65
69
  current.updatedAt = new Date().toISOString();
70
+ this.emitStatus(current);
66
71
  }
67
72
  }
68
73
  catch (error) {
@@ -71,6 +76,7 @@ export class CodexThreadHandoffJobManager {
71
76
  current.phase = "failed";
72
77
  current.error = error instanceof Error ? error.message : String(error);
73
78
  current.updatedAt = new Date().toISOString();
79
+ this.emitStatus(current);
74
80
  }
75
81
  }
76
82
  finally {
@@ -79,6 +85,9 @@ export class CodexThreadHandoffJobManager {
79
85
  }
80
86
  }
81
87
  }
88
+ emitStatus(job) {
89
+ this.onStatus?.(copySnapshot(job));
90
+ }
82
91
  cleanup() {
83
92
  const cutoff = Date.now() - this.terminalTtlMs;
84
93
  for (const [jobId, job] of this.jobs) {
@@ -17,11 +17,14 @@ async function nextTick() {
17
17
  }
18
18
  test("starts handoff work asynchronously and reports progress", async () => {
19
19
  let finish;
20
+ const statuses = [];
20
21
  const manager = new CodexThreadHandoffJobManager(async (input, onProgress) => {
21
22
  onProgress("collecting");
22
23
  return await new Promise((resolve) => {
23
24
  finish = resolve;
24
25
  });
26
+ }, {
27
+ onStatus: (job) => statuses.push(job.phase),
25
28
  });
26
29
  const started = manager.start({ sourceThreadId: "source-thread" });
27
30
  assert.equal(started.job.phase, "queued");
@@ -33,6 +36,7 @@ test("starts handoff work asynchronously and reports progress", async () => {
33
36
  const completed = manager.get(started.job.jobId);
34
37
  assert.equal(completed?.phase, "completed");
35
38
  assert.equal(completed?.result?.threadId, "target-thread");
39
+ assert.deepEqual(statuses, ["queued", "collecting", "completed"]);
36
40
  });
37
41
  test("deduplicates active jobs for the same source thread", () => {
38
42
  const manager = new CodexThreadHandoffJobManager(async () => {
@@ -44,8 +48,11 @@ test("deduplicates active jobs for the same source thread", () => {
44
48
  assert.equal(second.job.jobId, first.job.jobId);
45
49
  });
46
50
  test("keeps a failed job status for polling", async () => {
51
+ const statuses = [];
47
52
  const manager = new CodexThreadHandoffJobManager(async () => {
48
53
  throw new Error("summary failed");
54
+ }, {
55
+ onStatus: (job) => statuses.push({ phase: job.phase, error: job.error }),
49
56
  });
50
57
  const started = manager.start({ sourceThreadId: "source-thread" });
51
58
  await nextTick();
@@ -53,4 +60,8 @@ test("keeps a failed job status for polling", async () => {
53
60
  const failed = manager.get(started.job.jobId);
54
61
  assert.equal(failed?.phase, "failed");
55
62
  assert.equal(failed?.error, "summary failed");
63
+ assert.deepEqual(statuses, [
64
+ { phase: "queued", error: undefined },
65
+ { phase: "failed", error: "summary failed" },
66
+ ]);
56
67
  });
@@ -6,6 +6,50 @@ const MAX_ITEM_TEXT_CHARS = 4_000;
6
6
  const MAX_TURN_TEXT_CHARS = 8_000;
7
7
  const MAX_TRANSCRIPT_CHARS = 160_000;
8
8
  const MAX_HANDOFF_CHARS = 20_000;
9
+ const HANDOFF_USER_MARKER = "Continue from the handoff summary above and wait for my next request.";
10
+ export function buildThreadHandoffInjectionItems(handoff) {
11
+ return [
12
+ {
13
+ type: "message",
14
+ role: "assistant",
15
+ content: [{
16
+ type: "output_text",
17
+ text: handoff,
18
+ }],
19
+ },
20
+ ];
21
+ }
22
+ export function buildThreadHandoffSeedInput() {
23
+ return [{
24
+ type: "text",
25
+ text: HANDOFF_USER_MARKER,
26
+ }];
27
+ }
28
+ async function waitForThreadTurnStarted(manager, threadId, timeoutMs = 1_000) {
29
+ await new Promise((resolve) => {
30
+ let settled = false;
31
+ let cleanup = () => { };
32
+ const finish = () => {
33
+ if (settled) {
34
+ return;
35
+ }
36
+ settled = true;
37
+ clearTimeout(timer);
38
+ cleanup();
39
+ resolve();
40
+ };
41
+ const timer = setTimeout(finish, timeoutMs);
42
+ cleanup = manager.onNotification((method, params) => {
43
+ if (method !== "turn/started") {
44
+ return;
45
+ }
46
+ const event = recordValue(params);
47
+ if (stringValue(event?.threadId) === threadId) {
48
+ finish();
49
+ }
50
+ });
51
+ });
52
+ }
9
53
  function recordValue(value) {
10
54
  return value && typeof value === "object" && !Array.isArray(value)
11
55
  ? value
@@ -319,6 +363,7 @@ export async function createCodexThreadHandoff(args) {
319
363
  turnsCollected: collected.turnCount,
320
364
  turnsOmitted,
321
365
  }),
366
+ onLog: args.onLog,
322
367
  });
323
368
  const handoff = boundedText(handoffRaw, MAX_HANDOFF_CHARS);
324
369
  if (!handoff) {
@@ -338,16 +383,26 @@ export async function createCodexThreadHandoff(args) {
338
383
  }
339
384
  try {
340
385
  args.onProgress?.("injecting");
386
+ // Raw injected items do not make a new thread discoverable through thread/list.
387
+ const seedStarted = waitForThreadTurnStarted(args.manager, threadId);
388
+ const seedResult = recordValue(await args.manager.request("turn/start", {
389
+ threadId,
390
+ input: buildThreadHandoffSeedInput(),
391
+ }, 30_000));
392
+ const seedTurnId = stringValue(recordValue(seedResult?.turn)?.id);
393
+ if (!seedTurnId) {
394
+ throw new Error("Codex app-server did not return a handoff seed turn");
395
+ }
396
+ await seedStarted;
397
+ await args.manager.request("turn/interrupt", {
398
+ threadId,
399
+ turnId: seedTurnId,
400
+ }, 30_000).catch((error) => {
401
+ warnings.push(`Could not interrupt handoff seed turn: ${error instanceof Error ? error.message : String(error)}`);
402
+ });
341
403
  await args.manager.request("thread/inject_items", {
342
404
  threadId,
343
- items: [{
344
- type: "message",
345
- role: "assistant",
346
- content: [{
347
- type: "output_text",
348
- text: handoff,
349
- }],
350
- }],
405
+ items: buildThreadHandoffInjectionItems(handoff),
351
406
  }, 90_000);
352
407
  }
353
408
  catch (error) {
@@ -1,6 +1,24 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { buildBoundedHandoffTranscript, summarizeThreadTurn, } from "./codex-thread-handoff.js";
3
+ import { buildBoundedHandoffTranscript, buildThreadHandoffInjectionItems, buildThreadHandoffSeedInput, summarizeThreadTurn, } from "./codex-thread-handoff.js";
4
+ test("buildThreadHandoffInjectionItems adds the summary to model-visible history", () => {
5
+ assert.deepEqual(buildThreadHandoffInjectionItems("# Thread handoff\n\nContinue the work."), [
6
+ {
7
+ type: "message",
8
+ role: "assistant",
9
+ content: [{
10
+ type: "output_text",
11
+ text: "# Thread handoff\n\nContinue the work.",
12
+ }],
13
+ },
14
+ ]);
15
+ });
16
+ test("buildThreadHandoffSeedInput creates a real user turn for thread indexing", () => {
17
+ assert.deepEqual(buildThreadHandoffSeedInput(), [{
18
+ type: "text",
19
+ text: "Continue from the handoff summary above and wait for my next request.",
20
+ }]);
21
+ });
4
22
  test("summarizeThreadTurn omits raw command output, diffs, images, and MCP results", () => {
5
23
  const summary = summarizeThreadTurn({
6
24
  id: "turn-1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doer-agent",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
4
4
  "description": "Reverse-polling agent runtime for doer",
5
5
  "type": "module",
6
6
  "main": "dist/agent.js",