@co0ontty/wand 4.55.3 → 4.56.0
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/dist/build-info.json +3 -3
- package/dist/github-connector.d.ts +22 -0
- package/dist/github-connector.js +117 -0
- package/dist/missions.js +42 -9
- package/dist/server-github-routes.d.ts +10 -0
- package/dist/server-github-routes.js +239 -0
- package/dist/server-settings-routes.d.ts +2 -0
- package/dist/server-settings-routes.js +1 -0
- package/dist/server-task-routes.d.ts +4 -0
- package/dist/server-task-routes.js +99 -0
- package/dist/server-workspace-routes.js +70 -27
- package/dist/server.js +25 -0
- package/dist/storage.d.ts +42 -0
- package/dist/storage.js +223 -0
- package/dist/structured-provider-common.js +2 -1
- package/dist/task-types.d.ts +19 -0
- package/dist/task-types.js +1 -0
- package/dist/taskboard-bridge.d.ts +11 -0
- package/dist/taskboard-bridge.js +360 -0
- package/dist/web-ui/content/scripts.js +123 -127
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/embedded-assets.js +2 -2
- package/dist/web-ui/index.js +1 -0
- package/package.json +4 -2
- package/vendor/codex-taskboard/LICENSE +201 -0
- package/vendor/codex-taskboard/server/ai-chat-catalog.mjs +1031 -0
- package/vendor/codex-taskboard/server/ai-chat-process.mjs +462 -0
- package/vendor/codex-taskboard/server/ai-chat.mjs +1237 -0
- package/vendor/codex-taskboard/server/ai-turn-owner.mjs +39 -0
- package/vendor/codex-taskboard/server/app.mjs +3211 -0
- package/vendor/codex-taskboard/server/cloud-config.mjs +188 -0
- package/vendor/codex-taskboard/server/cloud-proxy.mjs +310 -0
- package/vendor/codex-taskboard/server/codex-app-server.mjs +376 -0
- package/vendor/codex-taskboard/server/codex-slash-commands-0.139.0.json +62 -0
- package/vendor/codex-taskboard/server/database.mjs +2845 -0
- package/vendor/codex-taskboard/server/index.mjs +41 -0
- package/vendor/codex-taskboard/server/jira-config.mjs +181 -0
- package/vendor/codex-taskboard/server/jira-integration.mjs +487 -0
- package/vendor/codex-taskboard/server/project-summary.mjs +159 -0
- package/vendor/codex-taskboard/shared/api-fields.mjs +163 -0
- package/vendor/codex-taskboard/shared/codex-environment.mjs +5 -0
- package/vendor/codex-taskboard/shared/codex-executable.mjs +77 -0
- package/vendor/codex-taskboard/shared/codex-permissions.mjs +9 -0
- package/vendor/codex-taskboard/shared/composer-reference.d.mts +19 -0
- package/vendor/codex-taskboard/shared/composer-reference.mjs +90 -0
- package/vendor/codex-taskboard/shared/domain.mjs +34 -0
- package/vendor/codex-taskboard/shared/executable-command.mjs +13 -0
- package/vendor/codex-taskboard/shared/process-tree.mjs +27 -0
- package/vendor/codex-taskboard/shared/task-input.mjs +308 -0
- package/vendor/codex-taskboard/shared/task-records.mjs +199 -0
- package/vendor/codex-taskboard/shared/task-relations.mjs +55 -0
- package/vendor/codex-taskboard/shared/taskboard-automation.mjs +277 -0
- package/vendor/codex-taskboard/skills/manage-taskboard/SKILL.md +45 -0
- package/vendor/codex-taskboard/skills/manage-taskboard/agents/openai.yaml +4 -0
- package/vendor/codex-taskboard/skills/manage-taskboard/references/cli.md +211 -0
|
@@ -0,0 +1,1237 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { signalProcessTree } from "../shared/process-tree.mjs";
|
|
6
|
+
import { resolveCodexPermissions } from "../shared/codex-permissions.mjs";
|
|
7
|
+
import { ApiError } from "../shared/api-fields.mjs";
|
|
8
|
+
import {
|
|
9
|
+
ComposerCatalog,
|
|
10
|
+
discoverAppServerAiCatalog,
|
|
11
|
+
discoverAiCatalog,
|
|
12
|
+
loadSlashCommands,
|
|
13
|
+
resolveAiWorkspace,
|
|
14
|
+
} from "./ai-chat-catalog.mjs";
|
|
15
|
+
import { CodexAppServer, CodexHostAppServer } from "./codex-app-server.mjs";
|
|
16
|
+
import {
|
|
17
|
+
buildCodexArgs,
|
|
18
|
+
buildCodexPrompt,
|
|
19
|
+
normalizeCodexEvent,
|
|
20
|
+
spawnCodexTurn,
|
|
21
|
+
} from "./ai-chat-process.mjs";
|
|
22
|
+
|
|
23
|
+
const SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
|
|
24
|
+
const ERROR_CONTENT_LIMIT = 65_536;
|
|
25
|
+
const AGENT_DISPATCH_PROTOCOL = "taskboard.agent.v1";
|
|
26
|
+
const SKILL_MARKER = "\uFFFC";
|
|
27
|
+
const CODEX_IMAGE_TYPES = new Set([
|
|
28
|
+
"image/gif",
|
|
29
|
+
"image/jpeg",
|
|
30
|
+
"image/png",
|
|
31
|
+
"image/webp",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function cappedError(value) {
|
|
35
|
+
const message = value instanceof Error ? value.message : String(value ?? "");
|
|
36
|
+
return message.slice(0, ERROR_CONTENT_LIMIT);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function agentDispatchText(agent) {
|
|
40
|
+
return [
|
|
41
|
+
`Taskboard private agent dispatch (${AGENT_DISPATCH_PROTOCOL}):`,
|
|
42
|
+
`Use the configured Taskboard agent ${JSON.stringify(agent.name)} (id ${JSON.stringify(agent.id)}) for this request.`,
|
|
43
|
+
"This is Taskboard product-private routing context, not a Codex App Server UserInput type.",
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function signalProcessGroup(child, signal) {
|
|
48
|
+
signalProcessTree(child, signal);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function wait(milliseconds) {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const timer = setTimeout(resolve, milliseconds);
|
|
54
|
+
timer.unref();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function appServerThreadSettings(thread, resolved) {
|
|
59
|
+
const permission = resolveCodexPermissions(thread.sandbox);
|
|
60
|
+
return {
|
|
61
|
+
model: thread.model,
|
|
62
|
+
cwd: resolved.workspacePath,
|
|
63
|
+
runtimeWorkspaceRoots: [resolved.workspacePath, ...resolved.addDirectories],
|
|
64
|
+
approvalPolicy: permission.approvalPolicy,
|
|
65
|
+
...(permission.reviewer ? { approvalsReviewer: permission.reviewer } : {}),
|
|
66
|
+
sandbox: permission.sandbox,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function codexTargetFromOrigin(origin) {
|
|
71
|
+
if (
|
|
72
|
+
origin?.codexProjectKind !== "remote"
|
|
73
|
+
|| !origin.codexProjectId
|
|
74
|
+
|| !origin.codexHostId
|
|
75
|
+
|| !origin.workspacePath
|
|
76
|
+
) return undefined;
|
|
77
|
+
return {
|
|
78
|
+
codexProjectId: origin.codexProjectId,
|
|
79
|
+
codexProjectKind: "remote",
|
|
80
|
+
codexHostId: origin.codexHostId,
|
|
81
|
+
workspacePath: origin.workspacePath,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizedAppServerItem(item) {
|
|
86
|
+
if (!item || typeof item !== "object") return null;
|
|
87
|
+
const itemId = typeof item.id === "string" ? item.id.slice(0, ERROR_CONTENT_LIMIT) : "";
|
|
88
|
+
const baseData = { status: item.status ?? "completed", ...(itemId ? { itemId } : {}) };
|
|
89
|
+
if (item.type === "agentMessage") {
|
|
90
|
+
return {
|
|
91
|
+
type: "agent_message",
|
|
92
|
+
role: "assistant",
|
|
93
|
+
content: cappedError(item.text),
|
|
94
|
+
data: baseData,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (item.type === "commandExecution") {
|
|
98
|
+
return {
|
|
99
|
+
type: "command_execution",
|
|
100
|
+
role: "activity",
|
|
101
|
+
content: cappedError(item.command),
|
|
102
|
+
data: {
|
|
103
|
+
...baseData,
|
|
104
|
+
command: cappedError(item.command),
|
|
105
|
+
...(typeof item.aggregatedOutput === "string"
|
|
106
|
+
? { output: cappedError(item.aggregatedOutput) }
|
|
107
|
+
: {}),
|
|
108
|
+
...(Number.isInteger(item.exitCode) ? { exitCode: item.exitCode } : {}),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (item.type === "fileChange") {
|
|
113
|
+
const files = Array.isArray(item.changes)
|
|
114
|
+
? item.changes.flatMap((change) => (
|
|
115
|
+
typeof change?.path === "string" ? [change.path.slice(0, ERROR_CONTENT_LIMIT)] : []
|
|
116
|
+
))
|
|
117
|
+
: [];
|
|
118
|
+
return {
|
|
119
|
+
type: "file_change",
|
|
120
|
+
role: "activity",
|
|
121
|
+
content: files.join("\n").slice(0, ERROR_CONTENT_LIMIT),
|
|
122
|
+
data: { ...baseData, files },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (item.type === "mcpToolCall") {
|
|
126
|
+
return {
|
|
127
|
+
type: "mcp_tool_call",
|
|
128
|
+
role: item.error ? "error" : "activity",
|
|
129
|
+
content: `${item.server ?? ""}.${item.tool ?? ""}`.replace(/^\.|\.$/g, ""),
|
|
130
|
+
data: {
|
|
131
|
+
...baseData,
|
|
132
|
+
...(typeof item.server === "string" ? { server: item.server } : {}),
|
|
133
|
+
...(typeof item.tool === "string" ? { tool: item.tool } : {}),
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class AiChatService {
|
|
141
|
+
constructor(options) {
|
|
142
|
+
this.database = options.database;
|
|
143
|
+
this.codexExecutable = options.codexExecutable;
|
|
144
|
+
this.codexStatePath = options.codexStatePath;
|
|
145
|
+
this.manageTaskboardSkillPath = options.manageTaskboardSkillPath;
|
|
146
|
+
this.processEnv = options.processEnv ?? process.env;
|
|
147
|
+
this.killGraceMs = options.killGraceMs ?? 1_000;
|
|
148
|
+
this.appServer = options.appServer ?? new CodexAppServer({
|
|
149
|
+
executable: this.codexExecutable,
|
|
150
|
+
processEnv: this.processEnv,
|
|
151
|
+
});
|
|
152
|
+
this.composerCatalog = options.composerCatalog ?? new ComposerCatalog({
|
|
153
|
+
appServer: this.appServer,
|
|
154
|
+
issueSlashCommands: () => loadSlashCommands(),
|
|
155
|
+
});
|
|
156
|
+
this.remoteAppServerFactory = options.remoteAppServerFactory
|
|
157
|
+
?? ((hostId) => new CodexHostAppServer({ hostId }));
|
|
158
|
+
this.remoteRuntimes = new Map();
|
|
159
|
+
this.resolveContext = options.resolveContext ?? (async (projectId, issueId) => {
|
|
160
|
+
const resolved = await resolveAiWorkspace(projectId, this.codexStatePath, this.database);
|
|
161
|
+
let issue;
|
|
162
|
+
if (issueId !== undefined) {
|
|
163
|
+
issue = this.database.getTask(issueId);
|
|
164
|
+
if (!issue || issue.projectId !== projectId || issue.archivedAt != null) {
|
|
165
|
+
throw new ApiError(
|
|
166
|
+
404,
|
|
167
|
+
"AI_CHAT_ISSUE_NOT_FOUND",
|
|
168
|
+
`Task '${issueId}' is not an active task in project '${projectId}'`,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { ...resolved, issue };
|
|
173
|
+
});
|
|
174
|
+
this.active = new Map();
|
|
175
|
+
this.listeners = new Map();
|
|
176
|
+
this.completions = new Map();
|
|
177
|
+
this.unsubscribeAppServer = this.appServer.subscribe((notification, child) => {
|
|
178
|
+
this.#handleAppServerNotification(this.appServer, notification, child);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#runtimeForTarget(target) {
|
|
183
|
+
if (target?.codexProjectKind !== "remote") {
|
|
184
|
+
return { appServer: this.appServer, composerCatalog: this.composerCatalog };
|
|
185
|
+
}
|
|
186
|
+
let runtime = this.remoteRuntimes.get(target.codexHostId);
|
|
187
|
+
if (runtime) return runtime;
|
|
188
|
+
const appServer = this.remoteAppServerFactory(target.codexHostId);
|
|
189
|
+
const composerCatalog = new ComposerCatalog({
|
|
190
|
+
appServer,
|
|
191
|
+
issueSlashCommands: () => loadSlashCommands(),
|
|
192
|
+
configuredAgents: async () => ({ agents: [], available: false }),
|
|
193
|
+
});
|
|
194
|
+
const unsubscribe = appServer.subscribe((notification) => {
|
|
195
|
+
this.#handleAppServerNotification(appServer, notification);
|
|
196
|
+
});
|
|
197
|
+
runtime = { appServer, composerCatalog, unsubscribe };
|
|
198
|
+
this.remoteRuntimes.set(target.codexHostId, runtime);
|
|
199
|
+
return runtime;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#runtimeForThread(thread) {
|
|
203
|
+
return this.#runtimeForTarget(codexTargetFromOrigin(thread.origin));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
listThreads() {
|
|
207
|
+
return this.database.listAiChatThreads();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
getThread(threadId) {
|
|
211
|
+
const thread = this.database.getAiChatThread(threadId);
|
|
212
|
+
if (!thread) {
|
|
213
|
+
throw new ApiError(
|
|
214
|
+
404,
|
|
215
|
+
"AI_CHAT_THREAD_NOT_FOUND",
|
|
216
|
+
`AI chat thread '${threadId}' does not exist`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return thread;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
getThreadSummary(threadId) {
|
|
223
|
+
return {
|
|
224
|
+
thread: this.getThread(threadId),
|
|
225
|
+
runs: this.database.listAiChatRuns(threadId),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
getThreadSnapshot(threadId) {
|
|
230
|
+
const thread = this.getThread(threadId);
|
|
231
|
+
return {
|
|
232
|
+
thread,
|
|
233
|
+
events: this.database.listAiChatEvents(threadId),
|
|
234
|
+
runs: this.database.listAiChatRuns(threadId),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
composerCatalogForThread(thread) {
|
|
239
|
+
return this.#runtimeForThread(thread).composerCatalog;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
getRun(runId) {
|
|
243
|
+
const run = this.database.getAiChatRun(runId);
|
|
244
|
+
if (!run) {
|
|
245
|
+
throw new ApiError(404, "AI_CHAT_RUN_NOT_FOUND", `AI chat run '${runId}' does not exist`);
|
|
246
|
+
}
|
|
247
|
+
return run;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
subscribe(threadId, listener) {
|
|
251
|
+
let listeners = this.listeners.get(threadId);
|
|
252
|
+
if (!listeners) {
|
|
253
|
+
listeners = new Set();
|
|
254
|
+
this.listeners.set(threadId, listeners);
|
|
255
|
+
}
|
|
256
|
+
listeners.add(listener);
|
|
257
|
+
return () => {
|
|
258
|
+
listeners.delete(listener);
|
|
259
|
+
if (listeners.size === 0) this.listeners.delete(threadId);
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async #catalogForWorkspace(workspacePath) {
|
|
264
|
+
return discoverAiCatalog({
|
|
265
|
+
codexExecutable: this.codexExecutable,
|
|
266
|
+
workspacePath,
|
|
267
|
+
processEnv: this.processEnv,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async getCatalog(projectId, resolvedContext, codexTarget) {
|
|
272
|
+
const resolved = resolvedContext ?? await this.resolveContext(projectId, undefined, codexTarget);
|
|
273
|
+
if (resolved.codexProjectKind === "remote") {
|
|
274
|
+
const { appServer } = this.#runtimeForTarget(resolved);
|
|
275
|
+
return discoverAppServerAiCatalog({ appServer, workspacePath: resolved.workspacePath });
|
|
276
|
+
}
|
|
277
|
+
return this.#catalogForWorkspace(resolved.workspacePath);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async getComposerCandidates({
|
|
281
|
+
projectId,
|
|
282
|
+
threadId,
|
|
283
|
+
trigger,
|
|
284
|
+
query,
|
|
285
|
+
codexProjectId,
|
|
286
|
+
codexProjectKind,
|
|
287
|
+
codexHostId,
|
|
288
|
+
workspacePath,
|
|
289
|
+
}) {
|
|
290
|
+
let thread;
|
|
291
|
+
let codexTarget = codexProjectKind === "remote"
|
|
292
|
+
? { codexProjectId, codexProjectKind, codexHostId, workspacePath }
|
|
293
|
+
: undefined;
|
|
294
|
+
if (threadId !== undefined) {
|
|
295
|
+
try {
|
|
296
|
+
thread = this.getThread(threadId);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
if (error instanceof ApiError && error.code === "AI_CHAT_THREAD_NOT_FOUND") {
|
|
299
|
+
throw new ApiError(
|
|
300
|
+
400,
|
|
301
|
+
"INVALID_COMPOSER_QUERY",
|
|
302
|
+
"Composer thread does not exist",
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
if (projectId !== undefined && thread.origin.projectId !== projectId) {
|
|
308
|
+
throw new ApiError(
|
|
309
|
+
400,
|
|
310
|
+
"INVALID_COMPOSER_QUERY",
|
|
311
|
+
"Composer thread does not belong to the selected project",
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
projectId = thread.origin.projectId;
|
|
315
|
+
codexTarget = codexTargetFromOrigin(thread.origin);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (projectId === undefined) {
|
|
319
|
+
const response = await this.composerCatalog.candidates({ workspacePath: null, trigger, query });
|
|
320
|
+
return { ...response, candidates: response.candidates.filter((candidate) => (
|
|
321
|
+
candidate.dispatch?.handlerId !== "compact-conversation"
|
|
322
|
+
)) };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let resolved;
|
|
326
|
+
try {
|
|
327
|
+
resolved = await this.resolveContext(projectId, thread?.origin.issueId, codexTarget);
|
|
328
|
+
} catch (error) {
|
|
329
|
+
if (error instanceof ApiError && ["PROJECT_NOT_FOUND", "AI_CHAT_ISSUE_NOT_FOUND"].includes(error.code)) {
|
|
330
|
+
throw new ApiError(400, "INVALID_COMPOSER_QUERY", "Composer project is invalid");
|
|
331
|
+
}
|
|
332
|
+
throw error;
|
|
333
|
+
}
|
|
334
|
+
if (thread && resolved.workspacePath !== thread.origin.workspacePath) {
|
|
335
|
+
throw new ApiError(
|
|
336
|
+
400,
|
|
337
|
+
"INVALID_COMPOSER_QUERY",
|
|
338
|
+
"Composer thread workspace no longer matches the selected project",
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
const { composerCatalog } = this.#runtimeForTarget(resolved);
|
|
342
|
+
const response = await composerCatalog.candidates({
|
|
343
|
+
workspacePath: resolved.workspacePath,
|
|
344
|
+
trigger,
|
|
345
|
+
query,
|
|
346
|
+
});
|
|
347
|
+
return {
|
|
348
|
+
...response,
|
|
349
|
+
candidates: response.candidates.filter((candidate) => (
|
|
350
|
+
candidate.dispatch?.handlerId !== "compact-conversation"
|
|
351
|
+
|| (thread?.codexThreadId && thread.status !== "running")
|
|
352
|
+
)),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async compactThread(threadId) {
|
|
357
|
+
const thread = this.getThread(threadId);
|
|
358
|
+
if (thread.status === "running") {
|
|
359
|
+
throw new ApiError(409, "AI_CHAT_THREAD_RUNNING", "Cannot compact a running conversation");
|
|
360
|
+
}
|
|
361
|
+
if (!thread.codexThreadId) {
|
|
362
|
+
throw new ApiError(409, "AI_CHAT_THREAD_NOT_STARTED", "Conversation has not started");
|
|
363
|
+
}
|
|
364
|
+
await this.#runtimeForThread(thread).appServer.compactThread(thread.codexThreadId);
|
|
365
|
+
return this.getThread(threadId);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async createThread(input) {
|
|
369
|
+
const codexTarget = input.codexProjectKind === "remote" ? input : undefined;
|
|
370
|
+
const resolved = await this.resolveContext(input.projectId, input.issueId, codexTarget);
|
|
371
|
+
const catalog = await this.getCatalog(input.projectId, resolved, codexTarget);
|
|
372
|
+
const model = this.#resolveModel(catalog, input.model);
|
|
373
|
+
const reasoningEffort = input.reasoningEffort ?? model.defaultReasoningEffort;
|
|
374
|
+
this.#validateReasoningEffort(model, reasoningEffort);
|
|
375
|
+
const sandbox = input.sandbox ?? "workspace-write";
|
|
376
|
+
this.#validateSandbox(sandbox);
|
|
377
|
+
|
|
378
|
+
const issue = resolved.issue;
|
|
379
|
+
|
|
380
|
+
return this.database.createAiChatThread({
|
|
381
|
+
title: input.title ?? issue?.identifier ?? "New conversation",
|
|
382
|
+
origin: {
|
|
383
|
+
projectId: resolved.project.id,
|
|
384
|
+
projectName: resolved.project.name,
|
|
385
|
+
workspacePath: resolved.workspacePath,
|
|
386
|
+
...(resolved.codexProjectKind === "remote" ? {
|
|
387
|
+
codexProjectId: resolved.codexProjectId,
|
|
388
|
+
codexProjectKind: resolved.codexProjectKind,
|
|
389
|
+
codexHostId: resolved.codexHostId,
|
|
390
|
+
} : {}),
|
|
391
|
+
...(issue ? { issueId: issue.id, issueIdentifier: issue.identifier } : {}),
|
|
392
|
+
},
|
|
393
|
+
model: model.slug,
|
|
394
|
+
reasoningEffort,
|
|
395
|
+
sandbox,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async updateThread(threadId, changes) {
|
|
400
|
+
let thread = this.getThread(threadId);
|
|
401
|
+
const changesSettings = ["model", "reasoningEffort", "sandbox"].some(
|
|
402
|
+
(key) => Object.hasOwn(changes, key),
|
|
403
|
+
);
|
|
404
|
+
const wasActive = changesSettings && this.#threadIsActive(thread);
|
|
405
|
+
|
|
406
|
+
if (Object.hasOwn(changes, "sandbox")) this.#validateSandbox(changes.sandbox);
|
|
407
|
+
if (Object.hasOwn(changes, "model") || Object.hasOwn(changes, "reasoningEffort")) {
|
|
408
|
+
const catalog = await this.getCatalog(
|
|
409
|
+
thread.origin.projectId,
|
|
410
|
+
undefined,
|
|
411
|
+
codexTargetFromOrigin(thread.origin),
|
|
412
|
+
);
|
|
413
|
+
thread = this.getThread(threadId);
|
|
414
|
+
const model = this.#resolveModel(catalog, changes.model ?? thread.model);
|
|
415
|
+
const reasoningEffort = changes.reasoningEffort ?? thread.reasoningEffort;
|
|
416
|
+
this.#validateReasoningEffort(model, reasoningEffort);
|
|
417
|
+
}
|
|
418
|
+
if (wasActive || (changesSettings && this.#threadIsActive(thread))) {
|
|
419
|
+
throw new ApiError(
|
|
420
|
+
409,
|
|
421
|
+
"THREAD_BUSY",
|
|
422
|
+
`AI chat thread '${threadId}' has a running turn`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return this.database.updateAiChatThread(threadId, changes);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
deleteThread(threadId) {
|
|
430
|
+
const thread = this.getThread(threadId);
|
|
431
|
+
if (this.#threadIsActive(thread)) {
|
|
432
|
+
throw new ApiError(
|
|
433
|
+
409,
|
|
434
|
+
"THREAD_BUSY",
|
|
435
|
+
`AI chat thread '${threadId}' has a running turn`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
return this.database.deleteAiChatThread(threadId);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async startTurn(threadId, input) {
|
|
442
|
+
let thread = this.getThread(threadId);
|
|
443
|
+
if (this.#threadIsActive(thread)) {
|
|
444
|
+
throw new ApiError(
|
|
445
|
+
409,
|
|
446
|
+
"THREAD_BUSY",
|
|
447
|
+
`AI chat thread '${threadId}' has a running turn`,
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
if (input?.contractVersion === "composer.v1") {
|
|
451
|
+
return this.#startComposerTurn(thread, input);
|
|
452
|
+
}
|
|
453
|
+
this.#validateTurnInput(input);
|
|
454
|
+
if (thread.sandbox === "danger-full-access" && input.dangerFullAccessConfirmed !== true) {
|
|
455
|
+
throw new ApiError(
|
|
456
|
+
400,
|
|
457
|
+
"DANGER_CONFIRMATION_REQUIRED",
|
|
458
|
+
"danger-full-access must be confirmed for every turn",
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const codexTarget = codexTargetFromOrigin(thread.origin);
|
|
463
|
+
const resolved = await this.resolveContext(
|
|
464
|
+
thread.origin.projectId,
|
|
465
|
+
thread.origin.issueId,
|
|
466
|
+
codexTarget,
|
|
467
|
+
);
|
|
468
|
+
const catalog = await this.getCatalog(thread.origin.projectId, resolved, codexTarget);
|
|
469
|
+
|
|
470
|
+
thread = this.getThread(threadId);
|
|
471
|
+
if (this.#threadIsActive(thread)) {
|
|
472
|
+
throw new ApiError(
|
|
473
|
+
409,
|
|
474
|
+
"THREAD_BUSY",
|
|
475
|
+
`AI chat thread '${threadId}' has a running turn`,
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
if (thread.sandbox === "danger-full-access" && input.dangerFullAccessConfirmed !== true) {
|
|
479
|
+
throw new ApiError(
|
|
480
|
+
400,
|
|
481
|
+
"DANGER_CONFIRMATION_REQUIRED",
|
|
482
|
+
"danger-full-access must be confirmed for every turn",
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
const model = this.#resolveModel(catalog, thread.model);
|
|
486
|
+
this.#validateReasoningEffort(model, thread.reasoningEffort);
|
|
487
|
+
if (resolved.workspacePath !== thread.origin.workspacePath) {
|
|
488
|
+
throw new ApiError(
|
|
489
|
+
409,
|
|
490
|
+
"PROJECT_WORKSPACE_CHANGED",
|
|
491
|
+
"The project's device workspace no longer matches this conversation",
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const skillIds = input.skillIds ?? [];
|
|
496
|
+
const availableSkills = new Map(
|
|
497
|
+
catalog.skills
|
|
498
|
+
.filter((skill) => skill.id !== "manage-taskboard")
|
|
499
|
+
.map((skill) => [skill.id, skill]),
|
|
500
|
+
);
|
|
501
|
+
for (const skillId of skillIds) {
|
|
502
|
+
if (!availableSkills.has(skillId)) {
|
|
503
|
+
throw new ApiError(400, "INVALID_SKILL", `Unknown or unavailable skill '${skillId}'`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
const selectedSkills = skillIds.map((skillId) => availableSkills.get(skillId));
|
|
507
|
+
|
|
508
|
+
if (resolved.codexProjectKind === "remote") {
|
|
509
|
+
return this.#startRemoteTurn(thread, input, resolved, selectedSkills);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const attachments = input.attachments ?? [];
|
|
513
|
+
const {
|
|
514
|
+
temporaryDirectory,
|
|
515
|
+
attachmentPaths,
|
|
516
|
+
imagePaths,
|
|
517
|
+
} = await this.#writeTurnAttachments(attachments);
|
|
518
|
+
try {
|
|
519
|
+
const args = buildCodexArgs(thread, resolved.addDirectories, imagePaths);
|
|
520
|
+
const prompt = buildCodexPrompt(
|
|
521
|
+
thread,
|
|
522
|
+
{
|
|
523
|
+
message: input.message,
|
|
524
|
+
skills: selectedSkills,
|
|
525
|
+
attachmentPaths,
|
|
526
|
+
},
|
|
527
|
+
this.manageTaskboardSkillPath,
|
|
528
|
+
);
|
|
529
|
+
const run = this.database.createAiChatRun({ threadId });
|
|
530
|
+
this.#emit(threadId, { type: "ai.run", run });
|
|
531
|
+
const userEventData = {};
|
|
532
|
+
if (skillIds.length > 0) userEventData.skillIds = skillIds;
|
|
533
|
+
if (attachments.length > 0) {
|
|
534
|
+
userEventData.attachments = attachments.map(({ filename, contentType, size }) => ({
|
|
535
|
+
filename,
|
|
536
|
+
contentType,
|
|
537
|
+
size,
|
|
538
|
+
}));
|
|
539
|
+
}
|
|
540
|
+
const userEvent = this.database.insertAiChatEvent({
|
|
541
|
+
threadId,
|
|
542
|
+
runId: run.id,
|
|
543
|
+
type: "user_message",
|
|
544
|
+
role: "user",
|
|
545
|
+
content: input.message,
|
|
546
|
+
data: Object.keys(userEventData).length > 0 ? userEventData : undefined,
|
|
547
|
+
});
|
|
548
|
+
this.#emit(threadId, { type: "ai.event", event: userEvent });
|
|
549
|
+
|
|
550
|
+
const resumingThreadId = thread.codexThreadId;
|
|
551
|
+
let startedThreadId = null;
|
|
552
|
+
let terminalOutcome = null;
|
|
553
|
+
let terminalError = "";
|
|
554
|
+
let pendingError = "";
|
|
555
|
+
const { child, completion } = spawnCodexTurn({
|
|
556
|
+
executable: this.codexExecutable,
|
|
557
|
+
args,
|
|
558
|
+
prompt,
|
|
559
|
+
env: this.processEnv,
|
|
560
|
+
onRawEvent: (raw) => {
|
|
561
|
+
const normalized = normalizeCodexEvent(raw);
|
|
562
|
+
if (!normalized) return;
|
|
563
|
+
if (normalized.kind === "thread.started") {
|
|
564
|
+
if (
|
|
565
|
+
(resumingThreadId && normalized.threadId !== resumingThreadId)
|
|
566
|
+
|| (startedThreadId && normalized.threadId !== startedThreadId)
|
|
567
|
+
) {
|
|
568
|
+
throw new Error("Codex returned an unexpected thread id");
|
|
569
|
+
}
|
|
570
|
+
startedThreadId = normalized.threadId;
|
|
571
|
+
this.database.updateAiChatThread(threadId, { codexThreadId: normalized.threadId });
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const event = this.database.insertAiChatEvent({
|
|
575
|
+
threadId,
|
|
576
|
+
runId: run.id,
|
|
577
|
+
type: normalized.type,
|
|
578
|
+
role: normalized.role,
|
|
579
|
+
content: normalized.content,
|
|
580
|
+
data: normalized.data,
|
|
581
|
+
});
|
|
582
|
+
if (raw.type === "turn.completed" && terminalOutcome === null) {
|
|
583
|
+
terminalOutcome = "completed";
|
|
584
|
+
} else if (raw.type === "turn.failed") {
|
|
585
|
+
terminalOutcome = "failed";
|
|
586
|
+
terminalError ||= normalized.content;
|
|
587
|
+
} else if (raw.type === "error") {
|
|
588
|
+
pendingError ||= normalized.content;
|
|
589
|
+
}
|
|
590
|
+
this.#emit(threadId, { type: "ai.event", event });
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
const active = { child, threadId, interrupted: false, temporaryDirectory };
|
|
595
|
+
this.active.set(run.id, active);
|
|
596
|
+
const finalization = completion.then(
|
|
597
|
+
(result) => this.#finishRun({
|
|
598
|
+
run,
|
|
599
|
+
active,
|
|
600
|
+
result,
|
|
601
|
+
resumingThreadId,
|
|
602
|
+
startedThreadId: () => startedThreadId,
|
|
603
|
+
terminalOutcome: () => terminalOutcome,
|
|
604
|
+
terminalError: () => terminalError,
|
|
605
|
+
pendingError: () => pendingError,
|
|
606
|
+
}),
|
|
607
|
+
(error) => this.#finishRun({
|
|
608
|
+
run,
|
|
609
|
+
active,
|
|
610
|
+
error,
|
|
611
|
+
resumingThreadId,
|
|
612
|
+
startedThreadId: () => startedThreadId,
|
|
613
|
+
terminalOutcome: () => terminalOutcome,
|
|
614
|
+
terminalError: () => terminalError,
|
|
615
|
+
pendingError: () => pendingError,
|
|
616
|
+
}),
|
|
617
|
+
);
|
|
618
|
+
this.completions.set(run.id, finalization);
|
|
619
|
+
void finalization.finally(() => this.completions.delete(run.id)).catch(() => {});
|
|
620
|
+
return run;
|
|
621
|
+
} catch (error) {
|
|
622
|
+
if (temporaryDirectory) {
|
|
623
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
624
|
+
}
|
|
625
|
+
throw error;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async interrupt(runId) {
|
|
630
|
+
let run = this.getRun(runId);
|
|
631
|
+
if (run.status !== "running") return run;
|
|
632
|
+
|
|
633
|
+
const active = this.active.get(runId);
|
|
634
|
+
if (!active) {
|
|
635
|
+
run = this.database.updateAiChatRun(runId, {
|
|
636
|
+
status: "interrupted",
|
|
637
|
+
error: "Interrupted",
|
|
638
|
+
finishedAt: new Date().toISOString(),
|
|
639
|
+
});
|
|
640
|
+
this.#emit(run.threadId, { type: "ai.run", run });
|
|
641
|
+
return run;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
active.interrupted = true;
|
|
645
|
+
if (active.kind === "app-server") {
|
|
646
|
+
if (active.turnId) {
|
|
647
|
+
try {
|
|
648
|
+
await active.appServer.interruptTurn({
|
|
649
|
+
threadId: active.appServerThreadId,
|
|
650
|
+
turnId: active.turnId,
|
|
651
|
+
});
|
|
652
|
+
} catch {}
|
|
653
|
+
}
|
|
654
|
+
const completion = this.completions.get(runId);
|
|
655
|
+
if (completion) {
|
|
656
|
+
await Promise.race([completion.catch(() => {}), wait(this.killGraceMs + 25)]);
|
|
657
|
+
}
|
|
658
|
+
if (this.active.has(runId)) await this.#finishAppServerRun(active, "interrupted");
|
|
659
|
+
return this.getRun(runId);
|
|
660
|
+
}
|
|
661
|
+
signalProcessGroup(active.child, "SIGTERM");
|
|
662
|
+
const timer = setTimeout(() => {
|
|
663
|
+
if (this.active.has(runId)) signalProcessGroup(active.child, "SIGKILL");
|
|
664
|
+
}, this.killGraceMs);
|
|
665
|
+
timer.unref();
|
|
666
|
+
|
|
667
|
+
const completion = this.completions.get(runId);
|
|
668
|
+
if (completion) {
|
|
669
|
+
await Promise.race([completion.catch(() => {}), wait(this.killGraceMs + 25)]);
|
|
670
|
+
}
|
|
671
|
+
return this.getRun(runId);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async close() {
|
|
675
|
+
const entries = [...this.active.entries()];
|
|
676
|
+
for (const [, active] of entries) {
|
|
677
|
+
active.interrupted = true;
|
|
678
|
+
if (active.kind === "app-server") {
|
|
679
|
+
if (active.turnId) {
|
|
680
|
+
void active.appServer.interruptTurn({
|
|
681
|
+
threadId: active.appServerThreadId,
|
|
682
|
+
turnId: active.turnId,
|
|
683
|
+
}).catch(() => {});
|
|
684
|
+
}
|
|
685
|
+
} else {
|
|
686
|
+
signalProcessGroup(active.child, "SIGTERM");
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const completions = entries
|
|
691
|
+
.map(([runId]) => this.completions.get(runId))
|
|
692
|
+
.filter(Boolean);
|
|
693
|
+
if (completions.length > 0) {
|
|
694
|
+
const settled = Promise.allSettled(completions);
|
|
695
|
+
await Promise.race([settled, wait(this.killGraceMs)]);
|
|
696
|
+
for (const [runId, active] of entries) {
|
|
697
|
+
if (active.kind !== "app-server" && this.active.has(runId)) {
|
|
698
|
+
signalProcessGroup(active.child, "SIGKILL");
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
for (const [, active] of entries) {
|
|
702
|
+
if (active.kind === "app-server" && this.active.has(active.run.id)) {
|
|
703
|
+
await this.#finishAppServerRun(active, "interrupted");
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
await settled;
|
|
707
|
+
}
|
|
708
|
+
for (const [, active] of entries) {
|
|
709
|
+
if (active.kind === "app-server" && this.active.has(active.run.id)) {
|
|
710
|
+
await this.#finishAppServerRun(active, "interrupted");
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
this.unsubscribeAppServer();
|
|
714
|
+
this.composerCatalog.close();
|
|
715
|
+
await this.appServer.close();
|
|
716
|
+
for (const runtime of this.remoteRuntimes.values()) {
|
|
717
|
+
runtime.unsubscribe();
|
|
718
|
+
runtime.composerCatalog.close();
|
|
719
|
+
await runtime.appServer.close();
|
|
720
|
+
}
|
|
721
|
+
this.remoteRuntimes.clear();
|
|
722
|
+
this.listeners.clear();
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
#resolveModel(catalog, requestedModel) {
|
|
726
|
+
const model = requestedModel === undefined
|
|
727
|
+
? catalog.models[0]
|
|
728
|
+
: catalog.models.find((candidate) => candidate.slug === requestedModel);
|
|
729
|
+
if (!model) {
|
|
730
|
+
throw new ApiError(
|
|
731
|
+
400,
|
|
732
|
+
"INVALID_MODEL",
|
|
733
|
+
requestedModel === undefined
|
|
734
|
+
? "Codex did not provide an available model"
|
|
735
|
+
: `Unknown model '${requestedModel}'`,
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
return model;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
#validateReasoningEffort(model, reasoningEffort) {
|
|
742
|
+
if (!model.supportedReasoningEfforts.includes(reasoningEffort)) {
|
|
743
|
+
throw new ApiError(
|
|
744
|
+
400,
|
|
745
|
+
"INVALID_REASONING_EFFORT",
|
|
746
|
+
`Reasoning effort '${reasoningEffort}' is not supported by model '${model.slug}'`,
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
#validateSandbox(sandbox) {
|
|
752
|
+
if (!SANDBOXES.has(sandbox)) {
|
|
753
|
+
throw new ApiError(
|
|
754
|
+
400,
|
|
755
|
+
"INVALID_SANDBOX",
|
|
756
|
+
"'sandbox' must be read-only, workspace-write, or danger-full-access",
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
#validateTurnInput(input) {
|
|
762
|
+
if (
|
|
763
|
+
!input
|
|
764
|
+
|| typeof input.message !== "string"
|
|
765
|
+
|| input.message.length > 100_000
|
|
766
|
+
|| (
|
|
767
|
+
input.message.trim() === ""
|
|
768
|
+
&& (!Array.isArray(input.attachments) || input.attachments.length === 0)
|
|
769
|
+
)
|
|
770
|
+
) {
|
|
771
|
+
throw new ApiError(
|
|
772
|
+
400,
|
|
773
|
+
"INVALID_MESSAGE",
|
|
774
|
+
"A message or at least one attachment is required",
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
if (
|
|
778
|
+
input.skillIds !== undefined
|
|
779
|
+
&& (
|
|
780
|
+
!Array.isArray(input.skillIds)
|
|
781
|
+
|| input.skillIds.length > 20
|
|
782
|
+
|| input.skillIds.some((skillId) => typeof skillId !== "string" || !skillId)
|
|
783
|
+
)
|
|
784
|
+
) {
|
|
785
|
+
throw new ApiError(
|
|
786
|
+
400,
|
|
787
|
+
"INVALID_SKILL",
|
|
788
|
+
"'skillIds' must contain at most 20 skill ids",
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async #startAppServerRun({
|
|
794
|
+
thread,
|
|
795
|
+
resolved,
|
|
796
|
+
appServer,
|
|
797
|
+
userInput,
|
|
798
|
+
userEvent,
|
|
799
|
+
temporaryDirectory = null,
|
|
800
|
+
}) {
|
|
801
|
+
const settings = appServerThreadSettings(thread, resolved);
|
|
802
|
+
let appServerThreadId = thread.codexThreadId;
|
|
803
|
+
if (appServerThreadId) {
|
|
804
|
+
const resumed = await appServer.resumeThread({
|
|
805
|
+
threadId: appServerThreadId,
|
|
806
|
+
...settings,
|
|
807
|
+
});
|
|
808
|
+
if (resumed?.thread?.id !== appServerThreadId) {
|
|
809
|
+
throw new Error("Codex returned an unexpected resumed thread id");
|
|
810
|
+
}
|
|
811
|
+
} else {
|
|
812
|
+
const started = await appServer.startThread(settings);
|
|
813
|
+
appServerThreadId = started?.thread?.id;
|
|
814
|
+
if (typeof appServerThreadId !== "string" || !appServerThreadId) {
|
|
815
|
+
throw new Error("Codex did not provide a thread id");
|
|
816
|
+
}
|
|
817
|
+
this.database.updateAiChatThread(thread.id, { codexThreadId: appServerThreadId });
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const run = this.database.createAiChatRun({ threadId: thread.id });
|
|
821
|
+
this.#emit(thread.id, { type: "ai.run", run });
|
|
822
|
+
const storedUserEvent = this.database.insertAiChatEvent({
|
|
823
|
+
threadId: thread.id,
|
|
824
|
+
runId: run.id,
|
|
825
|
+
type: "user_message",
|
|
826
|
+
role: "user",
|
|
827
|
+
...userEvent,
|
|
828
|
+
});
|
|
829
|
+
this.#emit(thread.id, { type: "ai.event", event: storedUserEvent });
|
|
830
|
+
|
|
831
|
+
let resolveCompletion;
|
|
832
|
+
const completion = new Promise((resolve) => { resolveCompletion = resolve; });
|
|
833
|
+
const active = {
|
|
834
|
+
kind: "app-server",
|
|
835
|
+
run,
|
|
836
|
+
threadId: thread.id,
|
|
837
|
+
appServer,
|
|
838
|
+
appServerChild: appServer === this.appServer ? appServer.child : undefined,
|
|
839
|
+
appServerThreadId,
|
|
840
|
+
turnId: null,
|
|
841
|
+
interrupted: false,
|
|
842
|
+
temporaryDirectory,
|
|
843
|
+
resolveCompletion,
|
|
844
|
+
};
|
|
845
|
+
this.active.set(run.id, active);
|
|
846
|
+
const finalization = completion.finally(() => this.completions.delete(run.id));
|
|
847
|
+
this.completions.set(run.id, finalization);
|
|
848
|
+
try {
|
|
849
|
+
const started = await appServer.startTurn({
|
|
850
|
+
threadId: appServerThreadId,
|
|
851
|
+
input: userInput,
|
|
852
|
+
effort: thread.reasoningEffort,
|
|
853
|
+
});
|
|
854
|
+
const turnId = started?.turn?.id;
|
|
855
|
+
if (typeof turnId !== "string" || !turnId) {
|
|
856
|
+
throw new Error("Codex did not provide a turn id");
|
|
857
|
+
}
|
|
858
|
+
active.turnId = turnId;
|
|
859
|
+
} catch (error) {
|
|
860
|
+
await this.#finishAppServerRun(active, "failed", error);
|
|
861
|
+
throw error;
|
|
862
|
+
}
|
|
863
|
+
return run;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
#remoteAttachmentInput(attachment) {
|
|
867
|
+
const url = `data:${attachment.contentType};base64,${attachment.data.toString("base64")}`;
|
|
868
|
+
if (CODEX_IMAGE_TYPES.has(attachment.contentType)) return { type: "image", url };
|
|
869
|
+
if (attachment.contentType.startsWith("audio/")) return { type: "audio", url };
|
|
870
|
+
return {
|
|
871
|
+
type: "text",
|
|
872
|
+
text: `\n\nAttached file ${attachment.filename}: ${url}`,
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
async #startRemoteTurn(thread, input, resolved, selectedSkills) {
|
|
877
|
+
const userInput = [];
|
|
878
|
+
const messageParts = input.message.split(SKILL_MARKER);
|
|
879
|
+
for (const [index, text] of messageParts.entries()) {
|
|
880
|
+
if (text) userInput.push({ type: "text", text });
|
|
881
|
+
const skill = selectedSkills[index];
|
|
882
|
+
if (skill) userInput.push({ type: "skill", name: skill.id, path: skill.path });
|
|
883
|
+
}
|
|
884
|
+
for (const attachment of input.attachments ?? []) {
|
|
885
|
+
userInput.push(this.#remoteAttachmentInput(attachment));
|
|
886
|
+
}
|
|
887
|
+
const userEventData = {};
|
|
888
|
+
if (selectedSkills.length > 0) userEventData.skillIds = selectedSkills.map((skill) => skill.id);
|
|
889
|
+
if ((input.attachments ?? []).length > 0) {
|
|
890
|
+
userEventData.attachments = input.attachments.map(({ filename, contentType, size }) => ({
|
|
891
|
+
filename,
|
|
892
|
+
contentType,
|
|
893
|
+
size,
|
|
894
|
+
}));
|
|
895
|
+
}
|
|
896
|
+
return this.#startAppServerRun({
|
|
897
|
+
thread,
|
|
898
|
+
resolved,
|
|
899
|
+
appServer: this.#runtimeForTarget(resolved).appServer,
|
|
900
|
+
userInput,
|
|
901
|
+
userEvent: {
|
|
902
|
+
content: input.message,
|
|
903
|
+
data: Object.keys(userEventData).length > 0 ? userEventData : undefined,
|
|
904
|
+
},
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async #startComposerTurn(thread, input) {
|
|
909
|
+
if (thread.sandbox === "danger-full-access" && input.dangerFullAccessConfirmed !== true) {
|
|
910
|
+
throw new ApiError(
|
|
911
|
+
400,
|
|
912
|
+
"DANGER_CONFIRMATION_REQUIRED",
|
|
913
|
+
"danger-full-access must be confirmed for every turn",
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const codexTarget = codexTargetFromOrigin(thread.origin);
|
|
918
|
+
const resolved = await this.resolveContext(
|
|
919
|
+
thread.origin.projectId,
|
|
920
|
+
thread.origin.issueId,
|
|
921
|
+
codexTarget,
|
|
922
|
+
);
|
|
923
|
+
const runtime = this.#runtimeForTarget(resolved);
|
|
924
|
+
thread = this.getThread(thread.id);
|
|
925
|
+
if (this.#threadIsActive(thread)) {
|
|
926
|
+
throw new ApiError(409, "THREAD_BUSY", `AI chat thread '${thread.id}' has a running turn`);
|
|
927
|
+
}
|
|
928
|
+
if (resolved.workspacePath !== thread.origin.workspacePath) {
|
|
929
|
+
throw new ApiError(
|
|
930
|
+
409,
|
|
931
|
+
"PROJECT_WORKSPACE_CHANGED",
|
|
932
|
+
"The project's device workspace no longer matches this conversation",
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const nodes = input.document.nodes;
|
|
937
|
+
const unsupportedIndex = nodes.findIndex((node) => !["text", "skill", "agent"].includes(node.type));
|
|
938
|
+
if (unsupportedIndex >= 0) {
|
|
939
|
+
throw new ApiError(
|
|
940
|
+
422,
|
|
941
|
+
"COMPOSER_NODE_UNSUPPORTED",
|
|
942
|
+
`Unsupported composer node at index ${unsupportedIndex}`,
|
|
943
|
+
{ nodeIndex: unsupportedIndex },
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
const resolvedReferences = nodes.some((node) => node.type === "skill" || node.type === "agent")
|
|
947
|
+
? await runtime.composerCatalog.resolveReferences({
|
|
948
|
+
workspacePath: resolved.workspacePath,
|
|
949
|
+
revision: input.revision,
|
|
950
|
+
nodes,
|
|
951
|
+
})
|
|
952
|
+
: nodes.map(() => null);
|
|
953
|
+
const attachments = input.attachments ?? [];
|
|
954
|
+
if (
|
|
955
|
+
nodes.every((node) => node.type !== "text" || node.text.trim() === "")
|
|
956
|
+
&& !nodes.some((node) => node.type === "skill" || node.type === "agent")
|
|
957
|
+
&& attachments.length === 0
|
|
958
|
+
) {
|
|
959
|
+
throw new ApiError(
|
|
960
|
+
400,
|
|
961
|
+
"INVALID_COMPOSER_DOCUMENT",
|
|
962
|
+
"A composer message or at least one attachment is required",
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const { temporaryDirectory, attachmentPaths } = resolved.codexProjectKind === "remote"
|
|
967
|
+
? { temporaryDirectory: null, attachmentPaths: [] }
|
|
968
|
+
: await this.#writeTurnAttachments(attachments);
|
|
969
|
+
try {
|
|
970
|
+
const userInput = nodes.flatMap((node, nodeIndex) => {
|
|
971
|
+
if (node.type === "text") return { type: "text", text: node.text };
|
|
972
|
+
const reference = resolvedReferences[nodeIndex];
|
|
973
|
+
if (node.type === "skill") {
|
|
974
|
+
return [
|
|
975
|
+
{ type: "text", text: `$${reference.name}` },
|
|
976
|
+
{ type: "skill", name: reference.name, path: reference.path },
|
|
977
|
+
];
|
|
978
|
+
}
|
|
979
|
+
return { type: "text", text: agentDispatchText(reference) };
|
|
980
|
+
});
|
|
981
|
+
for (const [index, attachment] of attachments.entries()) {
|
|
982
|
+
if (resolved.codexProjectKind === "remote") {
|
|
983
|
+
userInput.push(this.#remoteAttachmentInput(attachment));
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
const attachmentPath = attachmentPaths[index];
|
|
987
|
+
if (CODEX_IMAGE_TYPES.has(attachment.contentType)) {
|
|
988
|
+
userInput.push({ type: "localImage", path: attachmentPath });
|
|
989
|
+
} else {
|
|
990
|
+
userInput.push({ type: "text", text: `\n\nAttached file: ${attachmentPath}` });
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
const agentDispatches = nodes.flatMap((node, nodeIndex) => {
|
|
995
|
+
if (node.type !== "agent") return [];
|
|
996
|
+
const reference = resolvedReferences[nodeIndex];
|
|
997
|
+
return [{ nodeIndex, id: reference.id, name: reference.name }];
|
|
998
|
+
});
|
|
999
|
+
const run = await this.#startAppServerRun({
|
|
1000
|
+
thread,
|
|
1001
|
+
resolved,
|
|
1002
|
+
appServer: runtime.appServer,
|
|
1003
|
+
userInput,
|
|
1004
|
+
temporaryDirectory,
|
|
1005
|
+
userEvent: {
|
|
1006
|
+
content: nodes.map((node) => (
|
|
1007
|
+
node.type === "text" ? node.text : `@${node.label}`
|
|
1008
|
+
)).join(""),
|
|
1009
|
+
data: {
|
|
1010
|
+
contractVersion: "composer.v1",
|
|
1011
|
+
revision: input.revision,
|
|
1012
|
+
document: input.document,
|
|
1013
|
+
...(agentDispatches.length > 0
|
|
1014
|
+
? {
|
|
1015
|
+
dispatchProtocol: AGENT_DISPATCH_PROTOCOL,
|
|
1016
|
+
agentDispatches,
|
|
1017
|
+
}
|
|
1018
|
+
: {}),
|
|
1019
|
+
...(attachments.length > 0
|
|
1020
|
+
? {
|
|
1021
|
+
attachments: attachments.map(({ filename, contentType, size }) => ({
|
|
1022
|
+
filename,
|
|
1023
|
+
contentType,
|
|
1024
|
+
size,
|
|
1025
|
+
})),
|
|
1026
|
+
}
|
|
1027
|
+
: {}),
|
|
1028
|
+
},
|
|
1029
|
+
},
|
|
1030
|
+
});
|
|
1031
|
+
return run;
|
|
1032
|
+
} catch (error) {
|
|
1033
|
+
if (temporaryDirectory && ![...this.active.values()].some(
|
|
1034
|
+
(active) => active.temporaryDirectory === temporaryDirectory,
|
|
1035
|
+
)) {
|
|
1036
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
1037
|
+
}
|
|
1038
|
+
throw error;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
#handleAppServerNotification(appServer, notification, child) {
|
|
1043
|
+
const params = notification?.params;
|
|
1044
|
+
if (!params || typeof params !== "object") return;
|
|
1045
|
+
if (notification.method === "app-server/terminated") {
|
|
1046
|
+
// A remote bridge disconnect does not establish that the remote turn stopped.
|
|
1047
|
+
if (appServer !== this.appServer || !child) return;
|
|
1048
|
+
for (const active of this.active.values()) {
|
|
1049
|
+
if (
|
|
1050
|
+
active.kind !== "app-server"
|
|
1051
|
+
|| active.appServer !== appServer
|
|
1052
|
+
|| active.appServerChild !== child
|
|
1053
|
+
) continue;
|
|
1054
|
+
void this.#finishAppServerRun(
|
|
1055
|
+
active,
|
|
1056
|
+
active.interrupted ? "interrupted" : "failed",
|
|
1057
|
+
params.message,
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
const active = [...this.active.values()].find((candidate) => (
|
|
1063
|
+
candidate.kind === "app-server"
|
|
1064
|
+
&& candidate.appServer === appServer
|
|
1065
|
+
&& candidate.appServerChild === child
|
|
1066
|
+
&& candidate.appServerThreadId === params.threadId
|
|
1067
|
+
&& (!candidate.turnId || !params.turnId || candidate.turnId === params.turnId)
|
|
1068
|
+
));
|
|
1069
|
+
if (!active) return;
|
|
1070
|
+
|
|
1071
|
+
if (notification.method === "turn/started") {
|
|
1072
|
+
if (typeof params.turn?.id === "string") active.turnId = params.turn.id;
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
if (notification.method === "item/completed") {
|
|
1076
|
+
const normalized = normalizedAppServerItem(params.item);
|
|
1077
|
+
if (!normalized) return;
|
|
1078
|
+
const event = this.database.insertAiChatEvent({
|
|
1079
|
+
threadId: active.threadId,
|
|
1080
|
+
runId: active.run.id,
|
|
1081
|
+
...normalized,
|
|
1082
|
+
});
|
|
1083
|
+
this.#emit(active.threadId, { type: "ai.event", event });
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (notification.method !== "turn/completed") return;
|
|
1087
|
+
const status = params.turn?.status;
|
|
1088
|
+
if (active.interrupted || status === "interrupted") {
|
|
1089
|
+
void this.#finishAppServerRun(active, "interrupted");
|
|
1090
|
+
} else if (status === "completed") {
|
|
1091
|
+
void this.#finishAppServerRun(active, "completed");
|
|
1092
|
+
} else {
|
|
1093
|
+
void this.#finishAppServerRun(
|
|
1094
|
+
active,
|
|
1095
|
+
"failed",
|
|
1096
|
+
params.turn?.error?.message ?? "Codex reported a failed turn",
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
async #finishAppServerRun(active, status, error) {
|
|
1102
|
+
if (!this.active.delete(active.run.id)) return this.getRun(active.run.id);
|
|
1103
|
+
let publicError = null;
|
|
1104
|
+
if (status === "interrupted") publicError = "Interrupted";
|
|
1105
|
+
if (status === "failed") publicError = cappedError(error) || "Codex turn failed";
|
|
1106
|
+
try {
|
|
1107
|
+
if (status === "failed") {
|
|
1108
|
+
const errorEvent = this.database.insertAiChatEvent({
|
|
1109
|
+
threadId: active.threadId,
|
|
1110
|
+
runId: active.run.id,
|
|
1111
|
+
type: "error",
|
|
1112
|
+
role: "error",
|
|
1113
|
+
content: publicError,
|
|
1114
|
+
data: { status: "failed" },
|
|
1115
|
+
});
|
|
1116
|
+
this.#emit(active.threadId, { type: "ai.event", event: errorEvent });
|
|
1117
|
+
}
|
|
1118
|
+
const run = this.database.updateAiChatRun(active.run.id, {
|
|
1119
|
+
status,
|
|
1120
|
+
exitCode: null,
|
|
1121
|
+
error: publicError,
|
|
1122
|
+
finishedAt: new Date().toISOString(),
|
|
1123
|
+
});
|
|
1124
|
+
this.#emit(active.threadId, { type: "ai.run", run });
|
|
1125
|
+
return run;
|
|
1126
|
+
} finally {
|
|
1127
|
+
if (active.temporaryDirectory) {
|
|
1128
|
+
await rm(active.temporaryDirectory, { recursive: true, force: true });
|
|
1129
|
+
}
|
|
1130
|
+
active.resolveCompletion();
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
async #writeTurnAttachments(attachments) {
|
|
1135
|
+
if (attachments.length === 0) {
|
|
1136
|
+
return { temporaryDirectory: null, attachmentPaths: [], imagePaths: [] };
|
|
1137
|
+
}
|
|
1138
|
+
const temporaryDirectory = await mkdtemp(
|
|
1139
|
+
path.join(os.tmpdir(), "codex-taskboard-ai-turn-"),
|
|
1140
|
+
);
|
|
1141
|
+
try {
|
|
1142
|
+
const attachmentPaths = [];
|
|
1143
|
+
const imagePaths = [];
|
|
1144
|
+
for (const [index, attachment] of attachments.entries()) {
|
|
1145
|
+
const attachmentPath = path.join(
|
|
1146
|
+
temporaryDirectory,
|
|
1147
|
+
`attachment-${index + 1}-${attachment.filename}`,
|
|
1148
|
+
);
|
|
1149
|
+
await writeFile(attachmentPath, attachment.data, { flag: "wx", mode: 0o600 });
|
|
1150
|
+
attachmentPaths.push(attachmentPath);
|
|
1151
|
+
if (CODEX_IMAGE_TYPES.has(attachment.contentType)) imagePaths.push(attachmentPath);
|
|
1152
|
+
}
|
|
1153
|
+
return { temporaryDirectory, attachmentPaths, imagePaths };
|
|
1154
|
+
} catch (error) {
|
|
1155
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
1156
|
+
throw error;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
#threadIsActive(thread) {
|
|
1161
|
+
return Boolean(thread.currentRun)
|
|
1162
|
+
|| [...this.active.values()].some((active) => active.threadId === thread.id);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
async #finishRun({
|
|
1166
|
+
run,
|
|
1167
|
+
active,
|
|
1168
|
+
result,
|
|
1169
|
+
error,
|
|
1170
|
+
resumingThreadId,
|
|
1171
|
+
startedThreadId,
|
|
1172
|
+
terminalOutcome,
|
|
1173
|
+
terminalError,
|
|
1174
|
+
pendingError,
|
|
1175
|
+
}) {
|
|
1176
|
+
let status;
|
|
1177
|
+
let publicError = null;
|
|
1178
|
+
if (active.interrupted) {
|
|
1179
|
+
status = "interrupted";
|
|
1180
|
+
publicError = "Interrupted";
|
|
1181
|
+
} else if (error) {
|
|
1182
|
+
status = "failed";
|
|
1183
|
+
publicError = cappedError(error) || "Codex turn failed";
|
|
1184
|
+
} else if (terminalOutcome() === "failed") {
|
|
1185
|
+
status = "failed";
|
|
1186
|
+
publicError = terminalError() || "Codex reported a failed turn";
|
|
1187
|
+
} else if (result.exitCode !== 0) {
|
|
1188
|
+
status = "failed";
|
|
1189
|
+
publicError = result.exitCode === null
|
|
1190
|
+
? `Codex exited due to signal ${result.signal ?? "unknown"}`
|
|
1191
|
+
: `Codex exited with code ${result.exitCode}`;
|
|
1192
|
+
} else if (terminalOutcome() !== "completed") {
|
|
1193
|
+
status = "failed";
|
|
1194
|
+
publicError = pendingError() || "Codex exited without reporting turn completion";
|
|
1195
|
+
} else if (!resumingThreadId && !startedThreadId()) {
|
|
1196
|
+
status = "failed";
|
|
1197
|
+
publicError = "Codex did not provide a thread id";
|
|
1198
|
+
} else {
|
|
1199
|
+
status = "completed";
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
try {
|
|
1203
|
+
if (status === "failed" && terminalOutcome() !== "failed") {
|
|
1204
|
+
const errorEvent = this.database.insertAiChatEvent({
|
|
1205
|
+
threadId: run.threadId,
|
|
1206
|
+
runId: run.id,
|
|
1207
|
+
type: "error",
|
|
1208
|
+
role: "error",
|
|
1209
|
+
content: cappedError(publicError),
|
|
1210
|
+
data: { status: "failed" },
|
|
1211
|
+
});
|
|
1212
|
+
this.#emit(run.threadId, { type: "ai.event", event: errorEvent });
|
|
1213
|
+
}
|
|
1214
|
+
const updated = this.database.updateAiChatRun(run.id, {
|
|
1215
|
+
status,
|
|
1216
|
+
exitCode: result?.exitCode ?? null,
|
|
1217
|
+
error: publicError === null ? null : cappedError(publicError),
|
|
1218
|
+
finishedAt: new Date().toISOString(),
|
|
1219
|
+
});
|
|
1220
|
+
this.#emit(run.threadId, { type: "ai.run", run: updated });
|
|
1221
|
+
return updated;
|
|
1222
|
+
} finally {
|
|
1223
|
+
this.active.delete(run.id);
|
|
1224
|
+
if (active.temporaryDirectory) {
|
|
1225
|
+
await rm(active.temporaryDirectory, { recursive: true, force: true });
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
#emit(threadId, event) {
|
|
1231
|
+
for (const listener of this.listeners.get(threadId) ?? []) {
|
|
1232
|
+
try {
|
|
1233
|
+
listener(event);
|
|
1234
|
+
} catch {}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
}
|