doer-agent 0.9.2 → 0.9.4

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,6 +28,9 @@ 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
+ }
31
34
  export function buildAgentDaemonRpcSubject(userId, agentId) {
32
35
  return `doer.agent.daemon.rpc.${sanitizeUserId(userId)}.${agentId.trim()}`;
33
36
  }
@@ -0,0 +1,428 @@
1
+ import crypto from "node:crypto";
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { StringCodec } from "nats";
5
+ import { runCodexTextTask } from "./codex-text-task.js";
6
+ const codec = StringCodec();
7
+ const MAX_THREADS_PER_CLASSIFICATION = 50;
8
+ const MAX_THREADS_PER_REBUILD = 2_000;
9
+ const MAX_TAGS = 12;
10
+ const MIN_TAGS = 2;
11
+ const OTHER_TAG_ID = "other";
12
+ const DEFAULT_TAGS = [
13
+ { id: "deployment", label: "Deployment", description: "Deploy, release, publish, production rollout, or app-store delivery.", color: "#8b5cf6" },
14
+ { id: "bug", label: "Bug · diagnosis", description: "Debugging, errors, failures, regressions, diagnosis, or fixes.", color: "#ef4444" },
15
+ { id: "documentation", label: "Documentation", description: "Documentation, reports, notes, guides, or written content.", color: "#0ea5e9" },
16
+ { id: "research", label: "Research · review", description: "Investigation, audit, comparison, review, analysis, or planning.", color: "#14b8a6" },
17
+ { id: "feature", label: "Feature", description: "Implementation, feature work, UI/UX changes, or integrations.", color: "#22c55e" },
18
+ { id: "operations", label: "Operations", description: "Servers, daemons, infrastructure, databases, or maintenance.", color: "#f59e0b" },
19
+ { id: OTHER_TAG_ID, label: "Other", description: "Threads that do not fit any other configured tag.", color: "#64748b" },
20
+ ];
21
+ let classificationQueue = Promise.resolve();
22
+ function stringValue(value) {
23
+ return typeof value === "string" ? value.trim() : "";
24
+ }
25
+ function nullableString(value) {
26
+ return stringValue(value) || null;
27
+ }
28
+ function recordValue(value) {
29
+ return value && typeof value === "object" && !Array.isArray(value)
30
+ ? value
31
+ : null;
32
+ }
33
+ function sanitizeThreadId(value) {
34
+ const sanitized = value.trim().replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 160);
35
+ if (!sanitized || sanitized === "." || sanitized === "..") {
36
+ throw new Error("Invalid thread id");
37
+ }
38
+ return sanitized;
39
+ }
40
+ function sanitizeTagId(value) {
41
+ return value
42
+ .trim()
43
+ .toLocaleLowerCase()
44
+ .replace(/[^a-z0-9]+/g, "-")
45
+ .replace(/^-+|-+$/g, "")
46
+ .slice(0, 48);
47
+ }
48
+ function normalizedColor(value, fallback) {
49
+ const color = stringValue(value).toLocaleLowerCase();
50
+ return /^#[0-9a-f]{6}$/.test(color) ? color : fallback;
51
+ }
52
+ function tagsFilePath(workspaceRoot, threadId) {
53
+ return path.join(workspaceRoot, ".doer-agent", "threads", sanitizeThreadId(threadId), "tags.json");
54
+ }
55
+ function configFilePath(workspaceRoot) {
56
+ return path.join(workspaceRoot, ".doer-agent", "thread-tags", "config.json");
57
+ }
58
+ function normalizeInputs(value, limit) {
59
+ const rows = Array.isArray(value) ? value.slice(0, limit) : [];
60
+ const seen = new Set();
61
+ const inputs = [];
62
+ for (const rowValue of rows) {
63
+ const row = recordValue(rowValue);
64
+ const threadId = stringValue(row?.threadId).slice(0, 160);
65
+ if (!threadId || seen.has(threadId)) {
66
+ continue;
67
+ }
68
+ sanitizeThreadId(threadId);
69
+ seen.add(threadId);
70
+ inputs.push({
71
+ threadId,
72
+ label: stringValue(row?.label).slice(0, 500),
73
+ preview: nullableString(row?.preview)?.slice(0, 2_000) ?? null,
74
+ cwd: nullableString(row?.cwd)?.slice(0, 1_000) ?? null,
75
+ });
76
+ }
77
+ return inputs;
78
+ }
79
+ function normalizeTagDefinitions(value) {
80
+ const rows = Array.isArray(value) ? value.slice(0, MAX_TAGS) : [];
81
+ const seen = new Set();
82
+ const tags = [];
83
+ for (let index = 0; index < rows.length; index += 1) {
84
+ const row = recordValue(rows[index]);
85
+ const id = sanitizeTagId(stringValue(row?.id) || stringValue(row?.label));
86
+ const label = stringValue(row?.label).slice(0, 60);
87
+ const description = stringValue(row?.description).slice(0, 500);
88
+ if (!id || !label || !description || seen.has(id)) {
89
+ continue;
90
+ }
91
+ seen.add(id);
92
+ tags.push({
93
+ id,
94
+ label,
95
+ description,
96
+ color: normalizedColor(row?.color, DEFAULT_TAGS[index % DEFAULT_TAGS.length]?.color ?? "#64748b"),
97
+ });
98
+ }
99
+ if (!seen.has(OTHER_TAG_ID)) {
100
+ if (tags.length >= MAX_TAGS) {
101
+ tags.pop();
102
+ }
103
+ tags.push({ ...DEFAULT_TAGS.find((tag) => tag.id === OTHER_TAG_ID) });
104
+ }
105
+ if (tags.length < MIN_TAGS) {
106
+ throw new Error(`At least ${MIN_TAGS} AI tags are required`);
107
+ }
108
+ return tags.slice(0, MAX_TAGS);
109
+ }
110
+ function configFingerprint(tags) {
111
+ return crypto.createHash("sha256").update(JSON.stringify(tags.map((tag) => ({
112
+ id: tag.id,
113
+ label: tag.label,
114
+ description: tag.description,
115
+ color: tag.color,
116
+ })))).digest("hex");
117
+ }
118
+ function publicConfig(config) {
119
+ return {
120
+ version: config.version,
121
+ tags: config.tags,
122
+ updatedAt: config.updatedAt,
123
+ source: config.source,
124
+ fingerprint: config.fingerprint,
125
+ };
126
+ }
127
+ async function readConfig(workspaceRoot) {
128
+ try {
129
+ const parsed = recordValue(JSON.parse(await readFile(configFilePath(workspaceRoot), "utf8")));
130
+ const tags = normalizeTagDefinitions(parsed?.tags);
131
+ return {
132
+ version: 1,
133
+ tags,
134
+ updatedAt: stringValue(parsed?.updatedAt) || new Date(0).toISOString(),
135
+ source: "file",
136
+ fingerprint: configFingerprint(tags),
137
+ };
138
+ }
139
+ catch {
140
+ const tags = DEFAULT_TAGS.map((tag) => ({ ...tag }));
141
+ return {
142
+ version: 1,
143
+ tags,
144
+ updatedAt: new Date(0).toISOString(),
145
+ source: "default",
146
+ fingerprint: configFingerprint(tags),
147
+ };
148
+ }
149
+ }
150
+ async function writeConfig(workspaceRoot, value) {
151
+ const tags = normalizeTagDefinitions(value);
152
+ const filePath = configFilePath(workspaceRoot);
153
+ await mkdir(path.dirname(filePath), { recursive: true });
154
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
155
+ const payload = {
156
+ version: 1,
157
+ tags,
158
+ updatedAt: new Date().toISOString(),
159
+ };
160
+ await writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
161
+ await rename(tempPath, filePath);
162
+ return {
163
+ ...payload,
164
+ source: "file",
165
+ fingerprint: configFingerprint(tags),
166
+ };
167
+ }
168
+ function contentFingerprint(input) {
169
+ return crypto.createHash("sha256").update(JSON.stringify({
170
+ label: input.label,
171
+ preview: input.preview ?? "",
172
+ cwd: input.cwd ?? "",
173
+ })).digest("hex");
174
+ }
175
+ function boundedConfidence(value) {
176
+ return typeof value === "number" && Number.isFinite(value)
177
+ ? Math.min(1, Math.max(0, value))
178
+ : 0.5;
179
+ }
180
+ function jsonObjectFromText(text) {
181
+ const trimmed = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "").trim();
182
+ const start = trimmed.indexOf("{");
183
+ const end = trimmed.lastIndexOf("}");
184
+ if (start < 0 || end < start) {
185
+ throw new Error("Thread tag AI task did not return JSON");
186
+ }
187
+ const payload = recordValue(JSON.parse(trimmed.slice(start, end + 1)));
188
+ if (!payload) {
189
+ throw new Error("Thread tag AI task returned an invalid object");
190
+ }
191
+ return payload;
192
+ }
193
+ export function parseThreadTagClassificationResponse(text, requestedThreadIds, allowedTagIds = new Set(DEFAULT_TAGS.map((tag) => tag.id))) {
194
+ const payload = jsonObjectFromText(text);
195
+ const assignments = Array.isArray(payload.assignments) ? payload.assignments : [];
196
+ const seen = new Set();
197
+ const results = [];
198
+ for (const assignmentValue of assignments) {
199
+ const assignment = recordValue(assignmentValue);
200
+ const threadId = stringValue(assignment?.threadId);
201
+ const activityTag = sanitizeTagId(stringValue(assignment?.activityTag));
202
+ if (!threadId ||
203
+ seen.has(threadId) ||
204
+ !requestedThreadIds.has(threadId) ||
205
+ !allowedTagIds.has(activityTag)) {
206
+ continue;
207
+ }
208
+ seen.add(threadId);
209
+ results.push({
210
+ threadId,
211
+ activityTag,
212
+ confidence: boundedConfidence(assignment?.confidence),
213
+ source: "ai",
214
+ });
215
+ }
216
+ return results;
217
+ }
218
+ export function parseSuggestedThreadTags(text) {
219
+ return normalizeTagDefinitions(jsonObjectFromText(text).tags);
220
+ }
221
+ async function readStoredTags(workspaceRoot, input, config) {
222
+ try {
223
+ const row = recordValue(JSON.parse(await readFile(tagsFilePath(workspaceRoot, input.threadId), "utf8")));
224
+ const activityTag = sanitizeTagId(stringValue(row?.activityTag));
225
+ const allowedTagIds = new Set(config.tags.map((tag) => tag.id));
226
+ const isLegacyDefaultCache = row?.version === 1 && config.source === "default";
227
+ const isCurrentCache = row?.version === 2 && stringValue(row.configFingerprint) === config.fingerprint;
228
+ if ((!isLegacyDefaultCache && !isCurrentCache) ||
229
+ stringValue(row?.threadId) !== input.threadId ||
230
+ stringValue(row?.contentFingerprint) !== contentFingerprint(input) ||
231
+ !allowedTagIds.has(activityTag)) {
232
+ return null;
233
+ }
234
+ return {
235
+ threadId: input.threadId,
236
+ activityTag,
237
+ confidence: boundedConfidence(row?.confidence),
238
+ source: "ai",
239
+ };
240
+ }
241
+ catch {
242
+ return null;
243
+ }
244
+ }
245
+ async function writeStoredTags(workspaceRoot, input, classification, config) {
246
+ const filePath = tagsFilePath(workspaceRoot, input.threadId);
247
+ await mkdir(path.dirname(filePath), { recursive: true });
248
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
249
+ const payload = {
250
+ version: 2,
251
+ threadId: input.threadId,
252
+ contentFingerprint: contentFingerprint(input),
253
+ configFingerprint: config.fingerprint,
254
+ activityTag: classification.activityTag,
255
+ confidence: classification.confidence,
256
+ source: "ai",
257
+ updatedAt: new Date().toISOString(),
258
+ };
259
+ await writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
260
+ await rename(tempPath, filePath);
261
+ }
262
+ function configuredTagsPrompt(tags) {
263
+ return tags.map((tag) => `- ${tag.id} (${tag.label}): ${tag.description}`);
264
+ }
265
+ function classifierPrompt(inputs, tags) {
266
+ return [
267
+ "You classify Codex threads inside Doer.",
268
+ "Return only one valid JSON object. Do not use Markdown fences or add explanations.",
269
+ "Classify every input thread into exactly one activityTag from the configured list.",
270
+ "Configured activityTag values:",
271
+ ...configuredTagsPrompt(tags),
272
+ "Use label and preview as the main evidence. Use cwd only as supporting project context.",
273
+ `Output shape: {"assignments":[{"threadId":"...","activityTag":"${tags[0]?.id ?? OTHER_TAG_ID}","confidence":0.9}]}`,
274
+ "",
275
+ JSON.stringify({ threads: inputs }),
276
+ ].join("\n");
277
+ }
278
+ function suggestionPrompt(inputs, candidateTags) {
279
+ return [
280
+ "Design a compact taxonomy for automatically classifying Codex threads inside Doer.",
281
+ "Return only one valid JSON object. Do not use Markdown fences or add explanations.",
282
+ "Create 5 to 9 mutually useful tags based on the supplied threads.",
283
+ "Every tag needs: a stable lowercase kebab-case id, a concise label, a one-sentence classification description, and a distinct #RRGGBB color.",
284
+ "Write labels and descriptions in the predominant language of the supplied threads. Use Korean when the threads are predominantly Korean.",
285
+ `Always include "${OTHER_TAG_ID}" as the final fallback tag.`,
286
+ "Avoid tags for project paths or running status; those are managed separately.",
287
+ "Prefer work-intent categories that will remain useful for future threads.",
288
+ 'Output shape: {"tags":[{"id":"feature","label":"Feature","description":"Implementation and product changes.","color":"#22c55e"}]}',
289
+ candidateTags?.length
290
+ ? `Consolidate these batch candidates into one taxonomy:\n${JSON.stringify(candidateTags)}`
291
+ : "",
292
+ inputs.length ? `Threads to inspect:\n${JSON.stringify({
293
+ threads: inputs.map((input) => ({
294
+ threadId: input.threadId,
295
+ label: input.label.slice(0, 300),
296
+ preview: input.preview?.slice(0, 500) ?? null,
297
+ })),
298
+ })}` : "",
299
+ ].filter(Boolean).join("\n");
300
+ }
301
+ async function classifyThreads(args) {
302
+ const config = await readConfig(args.workspaceRoot);
303
+ const classifications = new Map();
304
+ const staleInputs = [];
305
+ for (const input of args.inputs) {
306
+ const stored = args.force ? null : await readStoredTags(args.workspaceRoot, input, config);
307
+ if (stored) {
308
+ classifications.set(input.threadId, stored);
309
+ }
310
+ else {
311
+ staleInputs.push(input);
312
+ }
313
+ }
314
+ let classificationError = null;
315
+ if (staleInputs.length > 0) {
316
+ try {
317
+ const resultText = await runCodexTextTask({
318
+ manager: args.manager,
319
+ prompt: classifierPrompt(staleInputs, config.tags),
320
+ });
321
+ const resolved = parseThreadTagClassificationResponse(resultText, new Set(staleInputs.map((input) => input.threadId)), new Set(config.tags.map((tag) => tag.id)));
322
+ const inputsById = new Map(staleInputs.map((input) => [input.threadId, input]));
323
+ await Promise.all(resolved.map(async (classification) => {
324
+ const input = inputsById.get(classification.threadId);
325
+ if (!input) {
326
+ return;
327
+ }
328
+ await writeStoredTags(args.workspaceRoot, input, classification, config);
329
+ classifications.set(classification.threadId, classification);
330
+ }));
331
+ if (resolved.length !== staleInputs.length) {
332
+ classificationError = `AI classified ${resolved.length} of ${staleInputs.length} threads`;
333
+ }
334
+ }
335
+ catch (error) {
336
+ classificationError = error instanceof Error ? error.message : String(error);
337
+ }
338
+ }
339
+ return {
340
+ classifications: [...classifications.values()],
341
+ classificationError,
342
+ config: publicConfig(config),
343
+ };
344
+ }
345
+ async function suggestConfig(args) {
346
+ if (args.inputs.length === 0) {
347
+ throw new Error("No threads available for AI tag reconstruction");
348
+ }
349
+ const candidates = [];
350
+ for (let offset = 0; offset < args.inputs.length; offset += 100) {
351
+ const batch = args.inputs.slice(offset, offset + 100);
352
+ const text = await runCodexTextTask({
353
+ manager: args.manager,
354
+ prompt: suggestionPrompt(batch),
355
+ });
356
+ candidates.push(...parseSuggestedThreadTags(text));
357
+ }
358
+ if (args.inputs.length <= 100) {
359
+ return candidates.slice(0, MAX_TAGS);
360
+ }
361
+ const consolidated = await runCodexTextTask({
362
+ manager: args.manager,
363
+ prompt: suggestionPrompt([], candidates),
364
+ });
365
+ return parseSuggestedThreadTags(consolidated);
366
+ }
367
+ async function handleThreadTagsRpcMessage(args) {
368
+ let request = {};
369
+ try {
370
+ request = JSON.parse(codec.decode(args.msg.data));
371
+ if (stringValue(request.agentId) !== args.agentId) {
372
+ throw new Error("Agent id mismatch");
373
+ }
374
+ const action = stringValue(request.action);
375
+ let result;
376
+ if (action === "get-config") {
377
+ result = { config: publicConfig(await readConfig(args.workspaceRoot)) };
378
+ }
379
+ else if (action === "save-config") {
380
+ result = { config: publicConfig(await writeConfig(args.workspaceRoot, request.tags)) };
381
+ }
382
+ else if (action === "suggest-config") {
383
+ result = {
384
+ suggestedTags: await suggestConfig({
385
+ manager: args.manager,
386
+ inputs: normalizeInputs(request.threads, MAX_THREADS_PER_REBUILD),
387
+ }),
388
+ };
389
+ }
390
+ else if (action === "classify") {
391
+ result = await classifyThreads({
392
+ workspaceRoot: args.workspaceRoot,
393
+ manager: args.manager,
394
+ inputs: normalizeInputs(request.threads, MAX_THREADS_PER_CLASSIFICATION),
395
+ force: request.force === true,
396
+ });
397
+ }
398
+ else {
399
+ throw new Error("Unsupported thread tags action");
400
+ }
401
+ args.msg.respond(codec.encode(JSON.stringify({
402
+ requestId: request.requestId,
403
+ ok: true,
404
+ ...result,
405
+ })));
406
+ }
407
+ catch (error) {
408
+ const message = error instanceof Error ? error.message : String(error);
409
+ args.msg.respond(codec.encode(JSON.stringify({
410
+ requestId: request.requestId,
411
+ ok: false,
412
+ error: message,
413
+ })));
414
+ args.onError(`thread tags rpc failed error=${message}`);
415
+ }
416
+ }
417
+ export function subscribeToThreadTagsRpc(args) {
418
+ args.nc.subscribe(args.subject, {
419
+ callback: (error, msg) => {
420
+ if (error) {
421
+ args.onError(`thread tags rpc subscription error=${error.message}`);
422
+ return;
423
+ }
424
+ classificationQueue = classificationQueue.then(() => handleThreadTagsRpcMessage({ ...args, msg }), () => handleThreadTagsRpcMessage({ ...args, msg }));
425
+ },
426
+ });
427
+ args.onInfo(`thread tags rpc subscribed subject=${args.subject}`);
428
+ }
@@ -0,0 +1,52 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { parseSuggestedThreadTags, parseThreadTagClassificationResponse, } from "./agent-thread-tags-rpc.js";
4
+ test("parses only requested valid thread tag assignments", () => {
5
+ const result = parseThreadTagClassificationResponse(JSON.stringify({
6
+ assignments: [
7
+ { threadId: "a", activityTag: "feature", confidence: 0.91 },
8
+ { threadId: "b", activityTag: "invalid", confidence: 0.8 },
9
+ { threadId: "unknown", activityTag: "bug", confidence: 1 },
10
+ ],
11
+ }), new Set(["a", "b"]));
12
+ assert.deepEqual(result, [{
13
+ threadId: "a",
14
+ activityTag: "feature",
15
+ confidence: 0.91,
16
+ source: "ai",
17
+ }]);
18
+ });
19
+ test("accepts fenced JSON and clamps confidence", () => {
20
+ const result = parseThreadTagClassificationResponse("```json\n{\"assignments\":[{\"threadId\":\"a\",\"activityTag\":\"bug\",\"confidence\":2}]}\n```", new Set(["a"]));
21
+ assert.equal(result[0]?.confidence, 1);
22
+ });
23
+ test("accepts classifications from a custom configured range", () => {
24
+ const result = parseThreadTagClassificationResponse("{\"assignments\":[{\"threadId\":\"a\",\"activityTag\":\"customer-request\",\"confidence\":0.8}]}", new Set(["a"]), new Set(["customer-request", "other"]));
25
+ assert.equal(result[0]?.activityTag, "customer-request");
26
+ });
27
+ test("normalizes AI suggested tags and always adds other", () => {
28
+ const result = parseSuggestedThreadTags(JSON.stringify({
29
+ tags: [
30
+ {
31
+ id: "Customer Request",
32
+ label: "고객 요청",
33
+ description: "고객이 요청한 제품 변경",
34
+ color: "#ABCDEF",
35
+ },
36
+ ],
37
+ }));
38
+ assert.deepEqual(result.map((tag) => tag.id), ["customer-request", "other"]);
39
+ assert.equal(result[0]?.color, "#abcdef");
40
+ });
41
+ test("keeps other when an oversized AI proposal omits it", () => {
42
+ const result = parseSuggestedThreadTags(JSON.stringify({
43
+ tags: Array.from({ length: 12 }, (_, index) => ({
44
+ id: `tag-${index}`,
45
+ label: `Tag ${index}`,
46
+ description: `Description ${index}`,
47
+ color: "#123456",
48
+ })),
49
+ }));
50
+ assert.equal(result.length, 12);
51
+ assert.equal(result.at(-1)?.id, "other");
52
+ });
package/dist/agent.js CHANGED
@@ -7,6 +7,7 @@ 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";
10
11
  import { ensureBundledDoerSkills } from "./agent-bundled-skills.js";
11
12
  import { subscribeToCodexAppRpc } from "./agent-codex-app-rpc.js";
12
13
  import { createCodexAppServerManager } from "./codex-app-server-manager.js";
@@ -18,7 +19,7 @@ import { subscribeToSkillRpc } from "./agent-skill-rpc.js";
18
19
  import { subscribeToMaintenanceRpc } from "./agent-maintenance-rpc.js";
19
20
  import { subscribeToHttpProxyRpc } from "./agent-http-proxy-rpc.js";
20
21
  import { sendSignalToTaskProcess } from "./agent-task-execution.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";
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";
22
23
  import { createRuntimeEnvHelpers } from "./agent-runtime-env.js";
23
24
  import { createEventPersistenceHelpers, heartbeatAgentSession, postJson, } from "./agent-runtime-io.js";
24
25
  import { handleSettingsRpcMessage } from "./agent-settings-rpc.js";
@@ -309,6 +310,15 @@ async function main() {
309
310
  agentId: initialAgentId,
310
311
  codexAppServerManager,
311
312
  });
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
+ });
312
322
  subscribeToDaemonRpc({
313
323
  nc: jetstream.nc,
314
324
  subject: buildAgentDaemonRpcSubject(userId, initialAgentId),
@@ -0,0 +1,110 @@
1
+ const DEFAULT_TIMEOUT_MS = 180_000;
2
+ function stringValue(value) {
3
+ return typeof value === "string" ? value : "";
4
+ }
5
+ function recordValue(value) {
6
+ return value && typeof value === "object" && !Array.isArray(value)
7
+ ? value
8
+ : null;
9
+ }
10
+ function threadIdFromParams(params) {
11
+ const record = recordValue(params);
12
+ return stringValue(record?.threadId) || stringValue(recordValue(record?.thread)?.id);
13
+ }
14
+ function turnIdFromParams(params) {
15
+ const record = recordValue(params);
16
+ return stringValue(record?.turnId) || stringValue(recordValue(record?.turn)?.id);
17
+ }
18
+ function terminalErrorFromParams(params) {
19
+ const record = recordValue(params);
20
+ const error = recordValue(record?.error);
21
+ return stringValue(record?.message) || stringValue(error?.message) || stringValue(record?.reason);
22
+ }
23
+ function isTerminalTurnMethod(method) {
24
+ return method === "turn/completed" ||
25
+ method === "turn/failed" ||
26
+ method === "turn/error" ||
27
+ method === "turn/cancelled" ||
28
+ method === "turn/canceled" ||
29
+ method === "turn/interrupted" ||
30
+ method === "turn/aborted";
31
+ }
32
+ function buildCodexUserInput(prompt) {
33
+ return [{
34
+ type: "text",
35
+ text: prompt,
36
+ text_elements: [],
37
+ }];
38
+ }
39
+ export async function runCodexTextTask(args) {
40
+ let threadId = "";
41
+ let turnId = "";
42
+ let output = "";
43
+ let settled = false;
44
+ let cleanupNotification = () => { };
45
+ let settle = (_callback) => { };
46
+ const completed = new Promise((resolve, reject) => {
47
+ const timeout = setTimeout(() => {
48
+ settle(() => reject(new Error("Timed out while waiting for Codex text task")));
49
+ }, args.timeoutMs ?? DEFAULT_TIMEOUT_MS);
50
+ settle = (callback) => {
51
+ if (settled) {
52
+ return;
53
+ }
54
+ settled = true;
55
+ clearTimeout(timeout);
56
+ cleanupNotification();
57
+ callback();
58
+ };
59
+ cleanupNotification = args.manager.onNotification((method, params) => {
60
+ const eventThreadId = threadIdFromParams(params);
61
+ const eventTurnId = turnIdFromParams(params);
62
+ if ((!threadId || eventThreadId !== threadId) &&
63
+ (!turnId || eventTurnId !== turnId)) {
64
+ return;
65
+ }
66
+ if (turnId && eventTurnId && eventTurnId !== turnId) {
67
+ return;
68
+ }
69
+ if (method === "item/agentMessage/delta") {
70
+ const record = recordValue(params);
71
+ output += stringValue(record?.delta) || stringValue(record?.text);
72
+ return;
73
+ }
74
+ if (!isTerminalTurnMethod(method)) {
75
+ return;
76
+ }
77
+ if (method === "turn/completed") {
78
+ settle(resolve);
79
+ return;
80
+ }
81
+ const message = terminalErrorFromParams(params) || `Codex text task ended with ${method}`;
82
+ settle(() => reject(new Error(message)));
83
+ });
84
+ });
85
+ try {
86
+ const threadResult = recordValue(await args.manager.request("thread/start", {
87
+ cwd: null,
88
+ ephemeral: true,
89
+ sessionStartSource: "clear",
90
+ }, 30_000));
91
+ threadId = stringValue(recordValue(threadResult?.thread)?.id);
92
+ if (!threadId) {
93
+ throw new Error("Codex app-server did not return a text task thread id");
94
+ }
95
+ const turnResult = recordValue(await args.manager.request("turn/start", {
96
+ threadId,
97
+ input: buildCodexUserInput(args.prompt),
98
+ }, 30_000));
99
+ turnId = stringValue(recordValue(turnResult?.turn)?.id);
100
+ if (!turnId) {
101
+ throw new Error("Codex app-server did not return a text task turn id");
102
+ }
103
+ await completed.finally(() => cleanupNotification());
104
+ return output;
105
+ }
106
+ catch (error) {
107
+ settle(() => { });
108
+ throw error;
109
+ }
110
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doer-agent",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "Reverse-polling agent runtime for doer",
5
5
  "type": "module",
6
6
  "main": "dist/agent.js",
@@ -26,8 +26,8 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@modelcontextprotocol/sdk": "^1.29.0",
29
- "@openai/codex": "^0.144.5",
30
- "@openai/codex-sdk": "^0.144.5",
29
+ "@openai/codex": "^0.145.0",
30
+ "@openai/codex-sdk": "^0.145.0",
31
31
  "diff": "^9.0.0",
32
32
  "nats": "^2.29.3",
33
33
  "tar": "^7.5.15"