agent-companion 0.1.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/README.md +401 -0
- package/bridge/defaultState.mjs +504 -0
- package/bridge/directIngest.mjs +712 -0
- package/bridge/server.mjs +2812 -0
- package/package.json +86 -0
- package/relay/server.mjs +2056 -0
- package/scripts/add-pending.mjs +51 -0
- package/scripts/agent-runner.mjs +1475 -0
- package/scripts/background-service.mjs +122 -0
- package/scripts/banner.mjs +36 -0
- package/scripts/cli.mjs +77 -0
- package/scripts/dev-stack.mjs +64 -0
- package/scripts/laptop-companion.mjs +1179 -0
- package/scripts/laptop-service.mjs +282 -0
- package/scripts/repair-codex-resume.mjs +300 -0
- package/scripts/reset-bridge.mjs +19 -0
- package/scripts/start-task.mjs +108 -0
- package/scripts/ui-claude-delegate.mjs +220 -0
- package/wake-proxy/server.mjs +162 -0
|
@@ -0,0 +1,1475 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn, execSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import process from "node:process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const argv = process.argv.slice(2);
|
|
10
|
+
const dividerIndex = argv.indexOf("--");
|
|
11
|
+
const optionArgs = dividerIndex >= 0 ? argv.slice(0, dividerIndex) : argv;
|
|
12
|
+
const command = dividerIndex >= 0 ? argv.slice(dividerIndex + 1) : [];
|
|
13
|
+
|
|
14
|
+
if (command.length === 0) {
|
|
15
|
+
printUsageAndExit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const options = parseOptions(optionArgs);
|
|
19
|
+
const agentType = options.agent === "CLAUDE" ? "CLAUDE" : "CODEX";
|
|
20
|
+
const bridgeUrl = options.bridge || process.env.AGENT_BRIDGE_URL || "http://localhost:8787";
|
|
21
|
+
const sessionId = options.session || `${agentType.toLowerCase()}_${Date.now()}`;
|
|
22
|
+
const runId = String(options.run || "").trim();
|
|
23
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
24
|
+
const __dirname = path.dirname(__filename);
|
|
25
|
+
const backgroundHelperPath = path.resolve(__dirname, "background-service.mjs");
|
|
26
|
+
const launchOptions = {
|
|
27
|
+
fullWorkspaceAccess: parseBooleanEnv(options.fullWorkspaceAccess, false),
|
|
28
|
+
skipPermissions: parseBooleanEnv(options.skipPermissions, false),
|
|
29
|
+
planMode: parseBooleanEnv(options.planMode, false)
|
|
30
|
+
};
|
|
31
|
+
const persistentServerHint = buildPersistentServerHint({
|
|
32
|
+
helperScriptPath: backgroundHelperPath,
|
|
33
|
+
bridgeUrl,
|
|
34
|
+
workspacePath: process.cwd(),
|
|
35
|
+
sessionId
|
|
36
|
+
});
|
|
37
|
+
normalizeCommand(command, {
|
|
38
|
+
agentType,
|
|
39
|
+
fullWorkspaceAccess: launchOptions.fullWorkspaceAccess,
|
|
40
|
+
skipPermissions: launchOptions.skipPermissions,
|
|
41
|
+
planMode: launchOptions.planMode,
|
|
42
|
+
persistentServerHint
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const title = options.title || command.join(" ").slice(0, 120);
|
|
46
|
+
const repo = options.repo || path.basename(process.cwd());
|
|
47
|
+
const branch = options.branch || detectBranch();
|
|
48
|
+
|
|
49
|
+
const usage = {
|
|
50
|
+
promptTokens: safeInt(options.promptTokens, 0),
|
|
51
|
+
completionTokens: safeInt(options.completionTokens, 0),
|
|
52
|
+
totalTokens: safeInt(options.totalTokens, 0),
|
|
53
|
+
costUsd: safeFloat(options.costUsd, 0)
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (usage.totalTokens === 0) {
|
|
57
|
+
usage.totalTokens = usage.promptTokens + usage.completionTokens;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const startTime = Date.now();
|
|
61
|
+
let lastPendingAt = 0;
|
|
62
|
+
let lastUpsertAt = 0;
|
|
63
|
+
let lastToolCallHint = "";
|
|
64
|
+
let lastPendingFingerprint = "";
|
|
65
|
+
let done = false;
|
|
66
|
+
let codexThreadId = "";
|
|
67
|
+
let claudeSessionId = "";
|
|
68
|
+
let codexRolloutPromoted = false;
|
|
69
|
+
let codexPromotionNoticeShown = false;
|
|
70
|
+
const CODEX_GLOBAL_STATE_FILE = path.join(os.homedir(), ".codex", ".codex-global-state.json");
|
|
71
|
+
const CODEX_SESSIONS_ROOT = path.join(os.homedir(), ".codex", "sessions");
|
|
72
|
+
const ENABLE_CODEX_RESUME_PROMOTION = parseBooleanEnv(
|
|
73
|
+
process.env.AGENT_ENABLE_CODEX_RESUME_PROMOTION,
|
|
74
|
+
true
|
|
75
|
+
);
|
|
76
|
+
const ENABLE_CODEX_THREAD_INDEX = parseBooleanEnv(process.env.AGENT_ENABLE_CODEX_THREAD_INDEX, true);
|
|
77
|
+
|
|
78
|
+
const childEnv = {
|
|
79
|
+
...process.env,
|
|
80
|
+
AGENT_COMPANION_BACKGROUND_HELPER: backgroundHelperPath,
|
|
81
|
+
AGENT_COMPANION_BRIDGE_URL: bridgeUrl,
|
|
82
|
+
AGENT_SESSION_ID: sessionId,
|
|
83
|
+
AGENT_WORKSPACE_PATH: process.cwd()
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const child = spawn(command[0], command.slice(1), {
|
|
87
|
+
cwd: process.cwd(),
|
|
88
|
+
env: childEnv,
|
|
89
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let stdoutBuffer = "";
|
|
93
|
+
let stderrBuffer = "";
|
|
94
|
+
|
|
95
|
+
child.stdout.on("data", (chunk) => {
|
|
96
|
+
const text = chunk.toString();
|
|
97
|
+
process.stdout.write(text);
|
|
98
|
+
stdoutBuffer += text;
|
|
99
|
+
stdoutBuffer = consumeBuffer(stdoutBuffer, handleOutputLine);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
child.stderr.on("data", (chunk) => {
|
|
103
|
+
const text = chunk.toString();
|
|
104
|
+
process.stderr.write(text);
|
|
105
|
+
stderrBuffer += text;
|
|
106
|
+
stderrBuffer = consumeBuffer(stderrBuffer, handleOutputLine);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Forward remote action replies (phone approvals/replies) to the running CLI command.
|
|
110
|
+
process.stdin.on("data", (chunk) => {
|
|
111
|
+
if (agentType !== "CODEX") return;
|
|
112
|
+
if (!child.stdin || child.stdin.destroyed || !child.stdin.writable) return;
|
|
113
|
+
child.stdin.write(chunk);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (agentType !== "CODEX" && child.stdin && !child.stdin.destroyed && child.stdin.writable) {
|
|
117
|
+
// Claude print-mode can wait on open stdin; close it to force prompt-argument execution.
|
|
118
|
+
child.stdin.end();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
process.on("SIGINT", () => {
|
|
122
|
+
child.kill("SIGINT");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
process.on("SIGTERM", () => {
|
|
126
|
+
child.kill("SIGTERM");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await safePost("/api/sessions/upsert", {
|
|
130
|
+
id: sessionId,
|
|
131
|
+
agentType,
|
|
132
|
+
title,
|
|
133
|
+
repo,
|
|
134
|
+
branch,
|
|
135
|
+
workspacePath: process.cwd(),
|
|
136
|
+
state: "RUNNING",
|
|
137
|
+
progress: 6,
|
|
138
|
+
lastUpdated: Date.now(),
|
|
139
|
+
tokenUsage: usage,
|
|
140
|
+
codexThreadId,
|
|
141
|
+
claudeSessionId
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await safePost("/api/events/add", {
|
|
145
|
+
sessionId,
|
|
146
|
+
summary: `Session started: ${title}`,
|
|
147
|
+
category: "INFO",
|
|
148
|
+
timestamp: Date.now()
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
console.log(`\n[agent-runner] session=${sessionId} agent=${agentType}`);
|
|
152
|
+
console.log(`[agent-runner] bridge=${bridgeUrl}\n`);
|
|
153
|
+
|
|
154
|
+
const heartbeat = setInterval(async () => {
|
|
155
|
+
if (done) return;
|
|
156
|
+
|
|
157
|
+
const progress = Math.min(95, 6 + Math.floor((Date.now() - startTime) / 6000));
|
|
158
|
+
usage.totalTokens = Math.max(usage.totalTokens, usage.promptTokens + usage.completionTokens);
|
|
159
|
+
if (usage.costUsd === 0 && usage.totalTokens > 0) {
|
|
160
|
+
usage.costUsd = Number((usage.totalTokens * 0.00001).toFixed(2));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
await safePost("/api/sessions/upsert", {
|
|
164
|
+
id: sessionId,
|
|
165
|
+
agentType,
|
|
166
|
+
title,
|
|
167
|
+
repo,
|
|
168
|
+
branch,
|
|
169
|
+
workspacePath: process.cwd(),
|
|
170
|
+
state: "RUNNING",
|
|
171
|
+
progress,
|
|
172
|
+
lastUpdated: Date.now(),
|
|
173
|
+
tokenUsage: usage,
|
|
174
|
+
codexThreadId,
|
|
175
|
+
claudeSessionId
|
|
176
|
+
});
|
|
177
|
+
}, 4000);
|
|
178
|
+
|
|
179
|
+
child.on("close", async (code) => {
|
|
180
|
+
done = true;
|
|
181
|
+
clearInterval(heartbeat);
|
|
182
|
+
|
|
183
|
+
const exitCode = Number.isInteger(code) ? code : 1;
|
|
184
|
+
const completed = exitCode === 0;
|
|
185
|
+
|
|
186
|
+
usage.totalTokens = Math.max(usage.totalTokens, usage.promptTokens + usage.completionTokens);
|
|
187
|
+
if (usage.costUsd === 0 && usage.totalTokens > 0) {
|
|
188
|
+
usage.costUsd = Number((usage.totalTokens * 0.00001).toFixed(2));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
await safePost("/api/sessions/upsert", {
|
|
192
|
+
id: sessionId,
|
|
193
|
+
agentType,
|
|
194
|
+
title,
|
|
195
|
+
repo,
|
|
196
|
+
branch,
|
|
197
|
+
workspacePath: process.cwd(),
|
|
198
|
+
state: completed ? "COMPLETED" : "FAILED",
|
|
199
|
+
progress: completed ? 100 : Math.min(99, 6 + Math.floor((Date.now() - startTime) / 8000)),
|
|
200
|
+
lastUpdated: Date.now(),
|
|
201
|
+
tokenUsage: usage,
|
|
202
|
+
codexThreadId,
|
|
203
|
+
claudeSessionId
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await safePost("/api/events/add", {
|
|
207
|
+
sessionId,
|
|
208
|
+
summary: completed ? "Session completed." : `Session failed with exit code ${exitCode}.`,
|
|
209
|
+
category: completed ? "ACTION" : "ERROR",
|
|
210
|
+
timestamp: Date.now()
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (codexThreadId) {
|
|
214
|
+
if (ENABLE_CODEX_RESUME_PROMOTION && !codexRolloutPromoted) {
|
|
215
|
+
codexRolloutPromoted =
|
|
216
|
+
(await promoteExecRolloutToCliWithRetry(codexThreadId, {
|
|
217
|
+
attempts: 12,
|
|
218
|
+
delayMs: 250
|
|
219
|
+
})) || codexRolloutPromoted;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await safePost("/api/events/add", {
|
|
223
|
+
sessionId,
|
|
224
|
+
summary: `Resume later: codex exec resume ${codexThreadId}`,
|
|
225
|
+
category: "INFO",
|
|
226
|
+
timestamp: Date.now()
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (claudeSessionId) {
|
|
231
|
+
await safePost("/api/events/add", {
|
|
232
|
+
sessionId,
|
|
233
|
+
summary: `Resume later: claude --resume ${claudeSessionId}`,
|
|
234
|
+
category: "INFO",
|
|
235
|
+
timestamp: Date.now()
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
process.exit(exitCode);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
function handleOutputLine(line) {
|
|
243
|
+
if (tryHandleStructuredJsonLine(line)) return;
|
|
244
|
+
parseTokenSignals(line);
|
|
245
|
+
maybeEmitInputRequest(line);
|
|
246
|
+
maybeEmitMilestoneEvent(line);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function tryMarkPendingEmission(kind, prompt) {
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
const normalizedKind = String(kind || "RUNTIME_APPROVAL").trim().toUpperCase();
|
|
252
|
+
const normalizedPrompt = String(prompt || "")
|
|
253
|
+
.toLowerCase()
|
|
254
|
+
.replace(/\s+/g, " ")
|
|
255
|
+
.trim()
|
|
256
|
+
.slice(0, 220);
|
|
257
|
+
const fingerprint = `${normalizedKind}|${normalizedPrompt}`;
|
|
258
|
+
if (fingerprint && fingerprint === lastPendingFingerprint && now - lastPendingAt < 90_000) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
if (now - lastPendingAt < 30_000) return false;
|
|
262
|
+
lastPendingAt = now;
|
|
263
|
+
lastPendingFingerprint = fingerprint;
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function emitPendingRequest({
|
|
268
|
+
prompt,
|
|
269
|
+
kind,
|
|
270
|
+
toolCall = null,
|
|
271
|
+
toolName = null,
|
|
272
|
+
questionRequest = false,
|
|
273
|
+
questionHeader = null,
|
|
274
|
+
questionOptions = null,
|
|
275
|
+
multiSelect = false,
|
|
276
|
+
priority = "HIGH"
|
|
277
|
+
}) {
|
|
278
|
+
const cleanedPrompt = String(prompt || "").trim().slice(0, 220);
|
|
279
|
+
if (!cleanedPrompt) return false;
|
|
280
|
+
const normalizedKind = String(kind || "RUNTIME_APPROVAL").trim().toUpperCase() || "RUNTIME_APPROVAL";
|
|
281
|
+
if (!tryMarkPendingEmission(normalizedKind, cleanedPrompt)) return false;
|
|
282
|
+
|
|
283
|
+
void safePost("/api/pending/add", {
|
|
284
|
+
sessionId,
|
|
285
|
+
prompt: cleanedPrompt,
|
|
286
|
+
priority,
|
|
287
|
+
requestedAt: Date.now(),
|
|
288
|
+
actionable: true,
|
|
289
|
+
source: "RUNNER",
|
|
290
|
+
meta: {
|
|
291
|
+
kind: normalizedKind,
|
|
292
|
+
planMode: launchOptions.planMode,
|
|
293
|
+
agentType,
|
|
294
|
+
runId: runId || null,
|
|
295
|
+
toolCall: toolCall || null,
|
|
296
|
+
toolName: toolName || null,
|
|
297
|
+
questionRequest: Boolean(questionRequest),
|
|
298
|
+
questionHeader: questionHeader ? String(questionHeader).trim().slice(0, 120) : null,
|
|
299
|
+
questionOptions:
|
|
300
|
+
Array.isArray(questionOptions) && questionOptions.length > 0
|
|
301
|
+
? questionOptions
|
|
302
|
+
.slice(0, 6)
|
|
303
|
+
.map((item) => normalizeQuestionOption(item))
|
|
304
|
+
.filter(Boolean)
|
|
305
|
+
: null
|
|
306
|
+
,
|
|
307
|
+
multiSelect: Boolean(multiSelect)
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
void safePost("/api/sessions/upsert", {
|
|
312
|
+
id: sessionId,
|
|
313
|
+
agentType,
|
|
314
|
+
title,
|
|
315
|
+
repo,
|
|
316
|
+
branch,
|
|
317
|
+
workspacePath: process.cwd(),
|
|
318
|
+
state: "WAITING_INPUT",
|
|
319
|
+
progress: Math.min(99, 6 + Math.floor((Date.now() - startTime) / 7000)),
|
|
320
|
+
lastUpdated: Date.now(),
|
|
321
|
+
tokenUsage: usage,
|
|
322
|
+
codexThreadId,
|
|
323
|
+
claudeSessionId
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function tryHandleStructuredJsonLine(line) {
|
|
330
|
+
const trimmed = line.trim();
|
|
331
|
+
if (!trimmed || trimmed[0] !== "{") return false;
|
|
332
|
+
|
|
333
|
+
let payload;
|
|
334
|
+
try {
|
|
335
|
+
payload = JSON.parse(trimmed);
|
|
336
|
+
} catch {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (!payload || typeof payload !== "object") return true;
|
|
341
|
+
let handled = false;
|
|
342
|
+
const toolHint = extractToolHintFromPayload(payload);
|
|
343
|
+
if (toolHint) {
|
|
344
|
+
lastToolCallHint = toolHint;
|
|
345
|
+
handled = true;
|
|
346
|
+
}
|
|
347
|
+
const askUserQuestion = extractAskUserQuestionPayload(payload);
|
|
348
|
+
if (askUserQuestion) {
|
|
349
|
+
const emitted = emitPendingRequest({
|
|
350
|
+
prompt: askUserQuestion.question,
|
|
351
|
+
kind: "QUESTION_REQUEST",
|
|
352
|
+
questionRequest: true,
|
|
353
|
+
questionHeader: askUserQuestion.header,
|
|
354
|
+
questionOptions: askUserQuestion.options,
|
|
355
|
+
multiSelect: askUserQuestion.multiSelect
|
|
356
|
+
});
|
|
357
|
+
handled = handled || emitted;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const payloadSessionId = normalizeUuid(payload.session_id);
|
|
361
|
+
if (payloadSessionId) {
|
|
362
|
+
handled = true;
|
|
363
|
+
if (!claudeSessionId) {
|
|
364
|
+
claudeSessionId = payloadSessionId;
|
|
365
|
+
console.log(`[agent-runner] claude_session=${claudeSessionId}`);
|
|
366
|
+
void safePost("/api/events/add", {
|
|
367
|
+
sessionId,
|
|
368
|
+
summary: `Claude session ready: ${claudeSessionId} (resume: claude --resume ${claudeSessionId})`,
|
|
369
|
+
category: "INFO",
|
|
370
|
+
timestamp: Date.now()
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (payload.type === "thread.started" && typeof payload.thread_id === "string") {
|
|
376
|
+
const threadId = payload.thread_id.trim();
|
|
377
|
+
if (threadId && !codexThreadId) {
|
|
378
|
+
codexThreadId = threadId;
|
|
379
|
+
console.log(`[agent-runner] codex_thread=${threadId}`);
|
|
380
|
+
if (ENABLE_CODEX_THREAD_INDEX) {
|
|
381
|
+
upsertThreadInCodexGlobalState(threadId, title);
|
|
382
|
+
}
|
|
383
|
+
if (ENABLE_CODEX_RESUME_PROMOTION && !codexPromotionNoticeShown) {
|
|
384
|
+
codexPromotionNoticeShown = true;
|
|
385
|
+
console.log(`[agent-runner] will promote resume metadata after run completes: ${threadId}`);
|
|
386
|
+
}
|
|
387
|
+
void safePost("/api/events/add", {
|
|
388
|
+
sessionId,
|
|
389
|
+
summary: `Codex thread ready: ${threadId} (resume: codex exec resume ${threadId})`,
|
|
390
|
+
category: "INFO",
|
|
391
|
+
timestamp: Date.now()
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (payload.type === "turn.completed" && payload.usage && typeof payload.usage === "object") {
|
|
398
|
+
const inputTokens =
|
|
399
|
+
safeInt(payload.usage.input_tokens, 0) + safeInt(payload.usage.cached_input_tokens, 0);
|
|
400
|
+
const outputTokens = safeInt(payload.usage.output_tokens, 0);
|
|
401
|
+
if (inputTokens > 0) usage.promptTokens = inputTokens;
|
|
402
|
+
if (outputTokens > 0) usage.completionTokens = outputTokens;
|
|
403
|
+
if (inputTokens > 0 || outputTokens > 0) {
|
|
404
|
+
usage.totalTokens = usage.promptTokens + usage.completionTokens;
|
|
405
|
+
if (usage.costUsd === 0 && usage.totalTokens > 0) {
|
|
406
|
+
usage.costUsd = Number((usage.totalTokens * 0.00001).toFixed(2));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (payload.type === "item.completed" && payload.item && typeof payload.item === "object") {
|
|
413
|
+
handled = true;
|
|
414
|
+
if (payload.item.type === "agent_message" && typeof payload.item.text === "string") {
|
|
415
|
+
const message = payload.item.text.trim();
|
|
416
|
+
if (message) {
|
|
417
|
+
maybeEmitInputRequest(message);
|
|
418
|
+
maybeEmitMilestoneEvent(message);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (payload.type === "assistant" && payload.message && typeof payload.message === "object") {
|
|
425
|
+
handled = true;
|
|
426
|
+
const message = payload.message;
|
|
427
|
+
const assistantText = extractStructuredText(message.content || message.text);
|
|
428
|
+
if (assistantText) {
|
|
429
|
+
maybeEmitInputRequest(assistantText);
|
|
430
|
+
maybeEmitMilestoneEvent(assistantText);
|
|
431
|
+
}
|
|
432
|
+
applyUsageFromRecord(message.usage);
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (payload.type === "result") {
|
|
437
|
+
handled = true;
|
|
438
|
+
const resultText = typeof payload.result === "string" ? payload.result.trim() : "";
|
|
439
|
+
if (resultText) {
|
|
440
|
+
maybeEmitInputRequest(resultText);
|
|
441
|
+
maybeEmitMilestoneEvent(resultText);
|
|
442
|
+
}
|
|
443
|
+
applyUsageFromRecord(payload.usage);
|
|
444
|
+
if (typeof payload.total_cost_usd === "number" && Number.isFinite(payload.total_cost_usd)) {
|
|
445
|
+
usage.costUsd = Number(payload.total_cost_usd.toFixed(4));
|
|
446
|
+
}
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const fallbackText = extractStructuredText(
|
|
451
|
+
payload.message || payload.result || payload.content || payload.text || payload.payload
|
|
452
|
+
);
|
|
453
|
+
if (fallbackText) {
|
|
454
|
+
maybeEmitInputRequest(fallbackText);
|
|
455
|
+
maybeEmitMilestoneEvent(fallbackText);
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return handled;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function maybeEmitMilestoneEvent(line) {
|
|
463
|
+
const trimmed = line.trim();
|
|
464
|
+
if (!trimmed) return;
|
|
465
|
+
|
|
466
|
+
const shouldEmit = /completed|finished|patched|updated|created|error|failed/i.test(trimmed);
|
|
467
|
+
if (!shouldEmit) return;
|
|
468
|
+
|
|
469
|
+
if (Date.now() - lastUpsertAt < 2500) return;
|
|
470
|
+
lastUpsertAt = Date.now();
|
|
471
|
+
|
|
472
|
+
void safePost("/api/events/add", {
|
|
473
|
+
sessionId,
|
|
474
|
+
summary: trimmed.slice(0, 220),
|
|
475
|
+
category: /error|failed/i.test(trimmed) ? "ERROR" : "INFO",
|
|
476
|
+
timestamp: Date.now()
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function maybeEmitInputRequest(line) {
|
|
481
|
+
const trimmed = line.trim();
|
|
482
|
+
if (!trimmed) return;
|
|
483
|
+
|
|
484
|
+
const needsInput = /input required|needs input|approval[_\s-]*required|awaiting approval|please approve|approve (?:this|the) plan|should i (?:proceed|implement|execute)|would you like me to (?:proceed|implement|execute)|ready to (?:implement|execute)|want me to (?:implement|execute)/i.test(trimmed);
|
|
485
|
+
const needsQuestionAnswer = isClarifyingQuestionRequest(trimmed);
|
|
486
|
+
const toolPermissionHint = /\[request interrupted by user for tool use\]|tool[_\s-]*(?:approval|permission)|allow.*tool/i.test(
|
|
487
|
+
trimmed
|
|
488
|
+
);
|
|
489
|
+
const isToolPermission = toolPermissionHint || (needsInput && Boolean(lastToolCallHint));
|
|
490
|
+
const isQuestionRequest = !isToolPermission && !needsInput && needsQuestionAnswer;
|
|
491
|
+
let questionOptions = isQuestionRequest ? extractQuestionOptions(trimmed) : [];
|
|
492
|
+
if (isQuestionRequest && questionOptions.length === 0) {
|
|
493
|
+
questionOptions = extractQuestionOptionsFromSerializedPayload(trimmed);
|
|
494
|
+
}
|
|
495
|
+
const questionHeader = isQuestionRequest ? extractQuestionHeaderFromSerializedPayload(trimmed) : null;
|
|
496
|
+
|
|
497
|
+
if (!needsInput && !isToolPermission && !isQuestionRequest) return;
|
|
498
|
+
const approvalPrompt = extractApprovalPrompt(trimmed, lastToolCallHint, isQuestionRequest).slice(0, 220);
|
|
499
|
+
const pendingKind = isQuestionRequest
|
|
500
|
+
? "QUESTION_REQUEST"
|
|
501
|
+
: isToolPermission
|
|
502
|
+
? "TOOL_PERMISSION"
|
|
503
|
+
: launchOptions.planMode
|
|
504
|
+
? "PLAN_CONFIRM"
|
|
505
|
+
: "RUNTIME_APPROVAL";
|
|
506
|
+
const toolName = extractToolNameFromHint(lastToolCallHint);
|
|
507
|
+
emitPendingRequest({
|
|
508
|
+
prompt: approvalPrompt || trimmed.slice(0, 220),
|
|
509
|
+
kind: pendingKind,
|
|
510
|
+
toolCall: lastToolCallHint || null,
|
|
511
|
+
toolName: toolName || null,
|
|
512
|
+
questionRequest: isQuestionRequest,
|
|
513
|
+
questionHeader,
|
|
514
|
+
questionOptions: questionOptions.length > 0 ? questionOptions : null
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function extractApprovalPrompt(text, toolHint = "", isQuestionRequest = false) {
|
|
519
|
+
const cleaned = String(text || "").replace(/\r/g, "").trim();
|
|
520
|
+
if (!cleaned) return "";
|
|
521
|
+
|
|
522
|
+
if (/\[request interrupted by user for tool use\]|tool[_\s-]*(?:approval|permission)/i.test(cleaned) && toolHint) {
|
|
523
|
+
return `Approve tool call: ${toolHint}`;
|
|
524
|
+
}
|
|
525
|
+
if (isQuestionRequest) {
|
|
526
|
+
const structuredQuestion = extractQuestionFromSerializedPayload(cleaned);
|
|
527
|
+
if (structuredQuestion) return structuredQuestion;
|
|
528
|
+
const inferredNeedToKnow =
|
|
529
|
+
cleaned.match(/(before i can [^.!?]{0,180}need to know[^.!?]{0,180})/i)?.[1] ||
|
|
530
|
+
cleaned.match(/(i need to know [^.!?]{0,200})/i)?.[1];
|
|
531
|
+
if (inferredNeedToKnow) {
|
|
532
|
+
return inferredNeedToKnow.trim().replace(/\s+/g, " ").slice(0, 220);
|
|
533
|
+
}
|
|
534
|
+
const question = extractBestQuestion(cleaned);
|
|
535
|
+
if (question) return question;
|
|
536
|
+
return cleaned;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const lines = cleaned
|
|
540
|
+
.split("\n")
|
|
541
|
+
.map((line) => line.trim())
|
|
542
|
+
.filter(Boolean);
|
|
543
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
544
|
+
if (isApprovalQuestion(lines[index])) {
|
|
545
|
+
return lines[index];
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const sentences = cleaned
|
|
550
|
+
.split(/(?<=[.?!])\s+/)
|
|
551
|
+
.map((item) => item.trim())
|
|
552
|
+
.filter(Boolean);
|
|
553
|
+
for (let index = sentences.length - 1; index >= 0; index -= 1) {
|
|
554
|
+
if (isApprovalQuestion(sentences[index])) {
|
|
555
|
+
return sentences[index];
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return cleaned;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function isApprovalQuestion(text) {
|
|
563
|
+
const candidate = String(text || "").trim();
|
|
564
|
+
if (!candidate) return false;
|
|
565
|
+
return /approval[_\s-]*required|awaiting approval|please approve|approve (?:this|the) plan|should i (?:proceed|implement|execute)|would you like me to (?:proceed|implement|execute)|ready to (?:implement|execute)|want me to (?:implement|execute)|proceed with implementation/i.test(
|
|
566
|
+
candidate
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function isClarifyingQuestionRequest(text) {
|
|
571
|
+
const candidate = String(text || "").trim();
|
|
572
|
+
if (!candidate) return false;
|
|
573
|
+
if (/input required|approval[_\s-]*required|awaiting approval|please approve/i.test(candidate)) return false;
|
|
574
|
+
if (/askuserquestion|\"question\"\s*:/i.test(candidate)) return true;
|
|
575
|
+
if (/[?]/.test(candidate) && /\b(can you|could you|would you|which|what|where|when|how|do you)\b/i.test(candidate)) {
|
|
576
|
+
return true;
|
|
577
|
+
}
|
|
578
|
+
if (/before i (?:dive|start|proceed)|i have (?:a few|some) questions|need more context|could you clarify|share (?:more|the) details|tell me more/i.test(candidate)) {
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
if (/before i can .*need to know|i need to know what feature|i need to know (?:what|which|where|when|how)|i'?ve asked the question/i.test(candidate)) {
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function extractBestQuestion(text) {
|
|
588
|
+
const cleaned = String(text || "").trim();
|
|
589
|
+
if (!cleaned) return "";
|
|
590
|
+
|
|
591
|
+
const lines = cleaned
|
|
592
|
+
.split("\n")
|
|
593
|
+
.map((line) => line.trim())
|
|
594
|
+
.filter(Boolean);
|
|
595
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
596
|
+
if (/\?$/.test(lines[index])) return lines[index];
|
|
597
|
+
}
|
|
598
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
599
|
+
if (/\b(can you|could you|would you|which|what|where|when|how|do you)\b/i.test(lines[index])) {
|
|
600
|
+
return lines[index];
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const sentences = cleaned
|
|
605
|
+
.split(/(?<=[.?!])\s+/)
|
|
606
|
+
.map((item) => item.trim())
|
|
607
|
+
.filter(Boolean);
|
|
608
|
+
for (let index = sentences.length - 1; index >= 0; index -= 1) {
|
|
609
|
+
if (/\?$/.test(sentences[index])) return sentences[index];
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return "";
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function extractQuestionOptions(text) {
|
|
616
|
+
const cleaned = String(text || "").replace(/\r/g, "").replace(/\\n/g, "\n").trim();
|
|
617
|
+
if (!cleaned) return [];
|
|
618
|
+
|
|
619
|
+
const rawLines = cleaned
|
|
620
|
+
.split("\n")
|
|
621
|
+
.map((line) => line.trim())
|
|
622
|
+
.filter(Boolean);
|
|
623
|
+
|
|
624
|
+
const options = [];
|
|
625
|
+
for (const line of rawLines) {
|
|
626
|
+
const bulletMatch = line.match(/^[-*•]\s+(.{2,180})$/);
|
|
627
|
+
if (bulletMatch?.[1]) {
|
|
628
|
+
options.push(bulletMatch[1].trim());
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const numberedMatch = line.match(/^(?:\d{1,2}[.)]|[A-Da-d][.)])\s+(.{2,180})$/);
|
|
633
|
+
if (numberedMatch?.[1]) {
|
|
634
|
+
options.push(numberedMatch[1].trim());
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const pipeMatch = line.match(/^\s*(?:option\s+)?[A-Da-d]\s*[:\-]\s*(.{2,180})$/i);
|
|
639
|
+
if (pipeMatch?.[1]) {
|
|
640
|
+
options.push(pipeMatch[1].trim());
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const deduped = [];
|
|
645
|
+
const seen = new Set();
|
|
646
|
+
for (const option of options) {
|
|
647
|
+
const normalized = String(option || "").toLowerCase();
|
|
648
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
649
|
+
seen.add(normalized);
|
|
650
|
+
deduped.push(normalizeQuestionOption(option));
|
|
651
|
+
if (deduped.length >= 6) break;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return deduped;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function extractQuestionFromSerializedPayload(text) {
|
|
658
|
+
const candidate = String(text || "");
|
|
659
|
+
if (!candidate) return "";
|
|
660
|
+
const match = candidate.match(/"question"\s*:\s*"([^"]{3,260})"/i);
|
|
661
|
+
if (!match?.[1]) return "";
|
|
662
|
+
return unescapeJsonText(match[1]).slice(0, 220);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function extractQuestionHeaderFromSerializedPayload(text) {
|
|
666
|
+
const candidate = String(text || "");
|
|
667
|
+
if (!candidate) return "";
|
|
668
|
+
const match = candidate.match(/"header"\s*:\s*"([^"]{1,120})"/i);
|
|
669
|
+
if (!match?.[1]) return "";
|
|
670
|
+
return unescapeJsonText(match[1]).slice(0, 120);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function extractQuestionOptionsFromSerializedPayload(text) {
|
|
674
|
+
const candidate = String(text || "");
|
|
675
|
+
if (!candidate) return [];
|
|
676
|
+
const optionBlocks = [...candidate.matchAll(/"label"\s*:\s*"([^"]{1,180})"[\s\S]{0,220}?"description"\s*:\s*"([^"]{1,220})"/gi)];
|
|
677
|
+
if (optionBlocks.length > 0) {
|
|
678
|
+
return optionBlocks
|
|
679
|
+
.slice(0, 6)
|
|
680
|
+
.map((match) =>
|
|
681
|
+
normalizeQuestionOption({
|
|
682
|
+
label: unescapeJsonText(match[1]),
|
|
683
|
+
description: unescapeJsonText(match[2])
|
|
684
|
+
})
|
|
685
|
+
)
|
|
686
|
+
.filter(Boolean);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const labelMatches = [...candidate.matchAll(/"label"\s*:\s*"([^"]{1,180})"/gi)];
|
|
690
|
+
if (!labelMatches.length) return [];
|
|
691
|
+
|
|
692
|
+
const deduped = [];
|
|
693
|
+
const seen = new Set();
|
|
694
|
+
for (const match of labelMatches) {
|
|
695
|
+
const value = unescapeJsonText(String(match[1] || "").trim());
|
|
696
|
+
if (!value) continue;
|
|
697
|
+
const normalized = value.toLowerCase();
|
|
698
|
+
if (seen.has(normalized)) continue;
|
|
699
|
+
seen.add(normalized);
|
|
700
|
+
deduped.push(normalizeQuestionOption(value));
|
|
701
|
+
if (deduped.length >= 6) break;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return deduped;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function normalizeQuestionOption(option) {
|
|
708
|
+
if (typeof option === "string") {
|
|
709
|
+
const label = option.trim().slice(0, 140);
|
|
710
|
+
return label ? { label } : null;
|
|
711
|
+
}
|
|
712
|
+
if (!option || typeof option !== "object") return null;
|
|
713
|
+
const label = String(option.label || option.value || "").trim().slice(0, 140);
|
|
714
|
+
const description = String(option.description || "").trim().slice(0, 220);
|
|
715
|
+
const value = String(option.value || label || "").trim().slice(0, 140);
|
|
716
|
+
if (!label) return null;
|
|
717
|
+
return {
|
|
718
|
+
label,
|
|
719
|
+
description: description || undefined,
|
|
720
|
+
value: value || undefined
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function unescapeJsonText(value) {
|
|
725
|
+
const raw = String(value || "");
|
|
726
|
+
return raw
|
|
727
|
+
.replace(/\\"/g, "\"")
|
|
728
|
+
.replace(/\\n/g, "\n")
|
|
729
|
+
.replace(/\\t/g, "\t")
|
|
730
|
+
.trim();
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function extractAskUserQuestionPayload(payload) {
|
|
734
|
+
if (!payload || typeof payload !== "object") return null;
|
|
735
|
+
|
|
736
|
+
const candidates = [];
|
|
737
|
+
|
|
738
|
+
if (payload.type === "assistant" && payload.message && typeof payload.message === "object") {
|
|
739
|
+
const content = Array.isArray(payload.message.content) ? payload.message.content : [];
|
|
740
|
+
for (const item of content) {
|
|
741
|
+
if (!item || typeof item !== "object") continue;
|
|
742
|
+
if (String(item.type || "").trim().toLowerCase() !== "tool_use") continue;
|
|
743
|
+
if (String(item.name || "").trim() !== "AskUserQuestion") continue;
|
|
744
|
+
candidates.push(item.input || null);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (payload.type === "item.completed" && payload.item && typeof payload.item === "object") {
|
|
749
|
+
const itemType = String(payload.item.type || "").trim().toLowerCase();
|
|
750
|
+
if (itemType === "tool_use" && String(payload.item.name || "").trim() === "AskUserQuestion") {
|
|
751
|
+
candidates.push(payload.item.input || null);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
if (payload.type === "response_item" && payload.payload && typeof payload.payload === "object") {
|
|
756
|
+
const payloadType = String(payload.payload.type || "").trim().toLowerCase();
|
|
757
|
+
if (payloadType === "tool_use" && String(payload.payload.name || "").trim() === "AskUserQuestion") {
|
|
758
|
+
candidates.push(payload.payload.input || null);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (payload.type === "result" && Array.isArray(payload.permission_denials)) {
|
|
763
|
+
for (const denial of payload.permission_denials) {
|
|
764
|
+
if (!denial || typeof denial !== "object") continue;
|
|
765
|
+
if (String(denial.tool_name || "").trim() !== "AskUserQuestion") continue;
|
|
766
|
+
candidates.push(denial.tool_input || null);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
for (const input of candidates) {
|
|
771
|
+
const parsed = parseAskUserQuestionInput(input);
|
|
772
|
+
if (parsed) return parsed;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function parseAskUserQuestionInput(input) {
|
|
779
|
+
if (!input || typeof input !== "object") return null;
|
|
780
|
+
const questions = Array.isArray(input.questions) ? input.questions : [];
|
|
781
|
+
if (questions.length === 0) return null;
|
|
782
|
+
|
|
783
|
+
const first = questions.find((item) => item && typeof item === "object") || null;
|
|
784
|
+
if (!first) return null;
|
|
785
|
+
|
|
786
|
+
const question = String(first.question || first.prompt || "").trim();
|
|
787
|
+
if (!question) return null;
|
|
788
|
+
const header = String(first.header || "").trim().slice(0, 120);
|
|
789
|
+
|
|
790
|
+
const options = Array.isArray(first.options)
|
|
791
|
+
? first.options
|
|
792
|
+
.map((option) => normalizeQuestionOption(option))
|
|
793
|
+
.filter(Boolean)
|
|
794
|
+
.slice(0, 6)
|
|
795
|
+
: [];
|
|
796
|
+
|
|
797
|
+
return {
|
|
798
|
+
question: question.slice(0, 220),
|
|
799
|
+
header: header || null,
|
|
800
|
+
options,
|
|
801
|
+
multiSelect: Boolean(first.multiSelect)
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function extractToolHintFromPayload(payload) {
|
|
806
|
+
if (!payload || typeof payload !== "object") return "";
|
|
807
|
+
const type = String(payload.type || "").trim().toLowerCase();
|
|
808
|
+
|
|
809
|
+
if (type === "item.completed" && payload.item && typeof payload.item === "object") {
|
|
810
|
+
const itemType = String(payload.item.type || "").trim().toLowerCase();
|
|
811
|
+
if (itemType.includes("tool") || itemType.includes("function") || itemType.includes("call")) {
|
|
812
|
+
return formatToolHint(payload.item);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
if (type === "response_item" && payload.payload && typeof payload.payload === "object") {
|
|
817
|
+
const itemType = String(payload.payload.type || "").trim().toLowerCase();
|
|
818
|
+
if (itemType.includes("tool") || itemType.includes("function") || itemType.includes("call")) {
|
|
819
|
+
return formatToolHint(payload.payload);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (type === "event_msg" && payload.payload && typeof payload.payload === "object") {
|
|
824
|
+
const payloadType = String(payload.payload.type || "").trim().toLowerCase();
|
|
825
|
+
if (payloadType.includes("tool") || payloadType.includes("function") || payloadType.includes("call")) {
|
|
826
|
+
return formatToolHint(payload.payload);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
return "";
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function formatToolHint(toolPayload) {
|
|
834
|
+
if (!toolPayload || typeof toolPayload !== "object") return "";
|
|
835
|
+
const toolName =
|
|
836
|
+
String(toolPayload.name || toolPayload.tool_name || toolPayload.toolName || toolPayload.function?.name || "")
|
|
837
|
+
.trim()
|
|
838
|
+
.slice(0, 60);
|
|
839
|
+
const summary = extractStructuredText(toolPayload.text || toolPayload.message || toolPayload.summary || "");
|
|
840
|
+
if (toolName && summary) {
|
|
841
|
+
return `${toolName}: ${summary.slice(0, 120)}`;
|
|
842
|
+
}
|
|
843
|
+
if (toolName) return toolName;
|
|
844
|
+
return summary.slice(0, 120);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function extractToolNameFromHint(hint) {
|
|
848
|
+
const value = String(hint || "").trim();
|
|
849
|
+
if (!value) return "";
|
|
850
|
+
const match = value.match(/^([^:]+):/);
|
|
851
|
+
if (match?.[1]) return match[1].trim();
|
|
852
|
+
return value.slice(0, 60);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function applyUsageFromRecord(record) {
|
|
856
|
+
if (!record || typeof record !== "object") return;
|
|
857
|
+
|
|
858
|
+
const promptTokens =
|
|
859
|
+
safeInt(record.input_tokens, 0) +
|
|
860
|
+
safeInt(record.cached_input_tokens, 0) +
|
|
861
|
+
safeInt(record.cache_read_input_tokens, 0) +
|
|
862
|
+
safeInt(record.cache_creation_input_tokens, 0);
|
|
863
|
+
const completionTokens = safeInt(record.output_tokens, 0);
|
|
864
|
+
|
|
865
|
+
if (promptTokens > 0) {
|
|
866
|
+
usage.promptTokens = promptTokens;
|
|
867
|
+
}
|
|
868
|
+
if (completionTokens > 0) {
|
|
869
|
+
usage.completionTokens = completionTokens;
|
|
870
|
+
}
|
|
871
|
+
if (promptTokens > 0 || completionTokens > 0) {
|
|
872
|
+
usage.totalTokens = usage.promptTokens + usage.completionTokens;
|
|
873
|
+
if (usage.costUsd === 0 && usage.totalTokens > 0) {
|
|
874
|
+
usage.costUsd = Number((usage.totalTokens * 0.00001).toFixed(2));
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function extractStructuredText(value) {
|
|
880
|
+
if (typeof value === "string") {
|
|
881
|
+
return value.trim();
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (Array.isArray(value)) {
|
|
885
|
+
const parts = value
|
|
886
|
+
.flatMap((item) => {
|
|
887
|
+
if (typeof item === "string") return [item.trim()];
|
|
888
|
+
if (!item || typeof item !== "object") return [];
|
|
889
|
+
if (typeof item.text === "string") return [item.text.trim()];
|
|
890
|
+
if (typeof item.message === "string") return [item.message.trim()];
|
|
891
|
+
if (typeof item.content === "string") return [item.content.trim()];
|
|
892
|
+
return [];
|
|
893
|
+
})
|
|
894
|
+
.filter(Boolean);
|
|
895
|
+
return parts.join("\n").trim();
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (value && typeof value === "object") {
|
|
899
|
+
if (typeof value.text === "string") return value.text.trim();
|
|
900
|
+
if (typeof value.message === "string") return value.message.trim();
|
|
901
|
+
if (typeof value.content === "string") return value.content.trim();
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
return "";
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function normalizeUuid(value) {
|
|
908
|
+
const candidate = String(value || "").trim().toLowerCase();
|
|
909
|
+
if (!candidate) return "";
|
|
910
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(candidate)) {
|
|
911
|
+
return "";
|
|
912
|
+
}
|
|
913
|
+
return candidate;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function parseTokenSignals(line) {
|
|
917
|
+
const promptMatch = line.match(/prompt[_\s-]*tokens?\s*[:=]\s*(\d+)/i);
|
|
918
|
+
const completionMatch = line.match(/completion[_\s-]*tokens?\s*[:=]\s*(\d+)/i);
|
|
919
|
+
const totalMatch = line.match(/total[_\s-]*tokens?\s*[:=]\s*(\d+)/i);
|
|
920
|
+
const costMatch = line.match(/cost(?:[_\s-]*usd)?\s*[:=]\s*\$?([0-9]+(?:\.[0-9]+)?)/i);
|
|
921
|
+
|
|
922
|
+
if (promptMatch) usage.promptTokens = safeInt(promptMatch[1], usage.promptTokens);
|
|
923
|
+
if (completionMatch) usage.completionTokens = safeInt(completionMatch[1], usage.completionTokens);
|
|
924
|
+
if (totalMatch) usage.totalTokens = safeInt(totalMatch[1], usage.totalTokens);
|
|
925
|
+
if (costMatch) usage.costUsd = safeFloat(costMatch[1], usage.costUsd);
|
|
926
|
+
|
|
927
|
+
if (!totalMatch && (promptMatch || completionMatch)) {
|
|
928
|
+
usage.totalTokens = usage.promptTokens + usage.completionTokens;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function consumeBuffer(buffer, onLine) {
|
|
933
|
+
const lines = buffer.split(/\r?\n/);
|
|
934
|
+
const trailing = lines.pop() ?? "";
|
|
935
|
+
|
|
936
|
+
for (const line of lines) {
|
|
937
|
+
onLine(line);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return trailing;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async function safePost(pathname, payload) {
|
|
944
|
+
try {
|
|
945
|
+
const response = await fetch(`${bridgeUrl}${pathname}`, {
|
|
946
|
+
method: "POST",
|
|
947
|
+
headers: {
|
|
948
|
+
"Content-Type": "application/json"
|
|
949
|
+
},
|
|
950
|
+
body: JSON.stringify(payload)
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
if (!response.ok) {
|
|
954
|
+
const text = await response.text();
|
|
955
|
+
console.error(`[agent-runner] bridge error ${response.status}: ${text}`);
|
|
956
|
+
}
|
|
957
|
+
} catch (error) {
|
|
958
|
+
console.error(`[agent-runner] unable to reach bridge: ${String(error)}`);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function parseOptions(args) {
|
|
963
|
+
const out = {};
|
|
964
|
+
|
|
965
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
966
|
+
const arg = args[index];
|
|
967
|
+
if (!arg.startsWith("--")) continue;
|
|
968
|
+
|
|
969
|
+
const key = arg.slice(2);
|
|
970
|
+
const next = args[index + 1];
|
|
971
|
+
|
|
972
|
+
if (!next || next.startsWith("--")) {
|
|
973
|
+
out[key] = "true";
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
out[key] = next;
|
|
978
|
+
index += 1;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
return out;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function detectBranch() {
|
|
985
|
+
try {
|
|
986
|
+
return execSync("git rev-parse --abbrev-ref HEAD", { stdio: ["ignore", "pipe", "ignore"] })
|
|
987
|
+
.toString()
|
|
988
|
+
.trim();
|
|
989
|
+
} catch {
|
|
990
|
+
return "main";
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function safeInt(value, fallback) {
|
|
995
|
+
const parsed = Number.parseInt(String(value), 10);
|
|
996
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function safeFloat(value, fallback) {
|
|
1000
|
+
const parsed = Number.parseFloat(String(value));
|
|
1001
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function printUsageAndExit(code) {
|
|
1005
|
+
console.log(`Usage:\n node scripts/agent-runner.mjs [options] -- <command> [args...]\n\nOptions:\n --agent CODEX|CLAUDE\n --session <session-id>\n --run <run-id>\n --title <display-title>\n --repo <repo-name>\n --branch <branch-name>\n --bridge <bridge-url>\n --fullWorkspaceAccess\n --skipPermissions\n --planMode\n --promptTokens <n>\n --completionTokens <n>\n --totalTokens <n>\n --costUsd <n>\n`);
|
|
1006
|
+
process.exit(code);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function normalizeCommand(commandArgs, options = {}) {
|
|
1010
|
+
const fullWorkspaceAccess = Boolean(options.fullWorkspaceAccess);
|
|
1011
|
+
const skipPermissions = Boolean(options.skipPermissions);
|
|
1012
|
+
const planMode = Boolean(options.planMode);
|
|
1013
|
+
const persistentServerHint = String(options.persistentServerHint || "").trim();
|
|
1014
|
+
|
|
1015
|
+
if (commandArgs[0] === "claude") {
|
|
1016
|
+
normalizeClaudeCommand(commandArgs, { fullWorkspaceAccess, skipPermissions, planMode, persistentServerHint });
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
if (commandArgs[0] !== "codex") return;
|
|
1021
|
+
if (commandArgs[1] === "run") {
|
|
1022
|
+
commandArgs[1] = "exec";
|
|
1023
|
+
console.log("[agent-runner] normalized `codex run ...` to `codex exec ...`");
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
const knownSubcommands = new Set([
|
|
1027
|
+
"exec",
|
|
1028
|
+
"review",
|
|
1029
|
+
"login",
|
|
1030
|
+
"logout",
|
|
1031
|
+
"mcp",
|
|
1032
|
+
"mcp-server",
|
|
1033
|
+
"app-server",
|
|
1034
|
+
"app",
|
|
1035
|
+
"completion",
|
|
1036
|
+
"sandbox",
|
|
1037
|
+
"debug",
|
|
1038
|
+
"apply",
|
|
1039
|
+
"resume",
|
|
1040
|
+
"fork",
|
|
1041
|
+
"cloud",
|
|
1042
|
+
"features",
|
|
1043
|
+
"help"
|
|
1044
|
+
]);
|
|
1045
|
+
|
|
1046
|
+
if (commandArgs[1] && !commandArgs[1].startsWith("-") && !knownSubcommands.has(commandArgs[1])) {
|
|
1047
|
+
const prompt = commandArgs.slice(1);
|
|
1048
|
+
commandArgs.splice(1, commandArgs.length - 1, "exec", ...prompt);
|
|
1049
|
+
console.log("[agent-runner] normalized `codex <prompt>` to `codex exec <prompt>`");
|
|
1050
|
+
} else if (!commandArgs[1]) {
|
|
1051
|
+
commandArgs.push("exec");
|
|
1052
|
+
console.log("[agent-runner] normalized bare `codex` to `codex exec`");
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
if (commandArgs[1] !== "exec") {
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
if (!commandArgs.includes("--skip-git-repo-check")) {
|
|
1060
|
+
commandArgs.splice(2, 0, "--skip-git-repo-check");
|
|
1061
|
+
console.log("[agent-runner] added `--skip-git-repo-check` for Codex CLI");
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
if (!commandArgs.includes("--json")) {
|
|
1065
|
+
commandArgs.splice(2, 0, "--json");
|
|
1066
|
+
console.log("[agent-runner] enabled `--json` for Codex session tracking");
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (
|
|
1070
|
+
planMode &&
|
|
1071
|
+
!fullWorkspaceAccess &&
|
|
1072
|
+
!commandArgs.includes("--sandbox") &&
|
|
1073
|
+
!commandArgs.includes("--dangerously-bypass-approvals-and-sandbox")
|
|
1074
|
+
) {
|
|
1075
|
+
commandArgs.splice(2, 0, "--sandbox", "read-only");
|
|
1076
|
+
console.log("[agent-runner] enabled Codex plan mode (`--sandbox read-only`)");
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
if (fullWorkspaceAccess || skipPermissions) {
|
|
1080
|
+
removeFlagWithValue(commandArgs, "--sandbox", "-s");
|
|
1081
|
+
removeFlagWithValue(commandArgs, "--ask-for-approval", "-a");
|
|
1082
|
+
|
|
1083
|
+
if (!commandArgs.includes("--sandbox")) {
|
|
1084
|
+
commandArgs.splice(2, 0, "--sandbox", "danger-full-access");
|
|
1085
|
+
}
|
|
1086
|
+
if (
|
|
1087
|
+
!commandArgs.includes("--dangerously-bypass-approvals-and-sandbox") &&
|
|
1088
|
+
!commandArgs.includes("--yolo")
|
|
1089
|
+
) {
|
|
1090
|
+
commandArgs.splice(2, 0, "--dangerously-bypass-approvals-and-sandbox");
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (fullWorkspaceAccess) {
|
|
1094
|
+
console.log(
|
|
1095
|
+
"[agent-runner] enabled Codex full access (`--sandbox danger-full-access` + dangerous bypass)"
|
|
1096
|
+
);
|
|
1097
|
+
} else {
|
|
1098
|
+
console.log(
|
|
1099
|
+
"[agent-runner] enabled Codex permission bypass (`--sandbox danger-full-access` + dangerous bypass)"
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
if (persistentServerHint) {
|
|
1105
|
+
const promptIndex = findCodexPromptIndex(commandArgs);
|
|
1106
|
+
if (promptIndex >= 0) {
|
|
1107
|
+
commandArgs[promptIndex] = appendPromptHint(commandArgs[promptIndex], persistentServerHint);
|
|
1108
|
+
console.log("[agent-runner] appended managed-server runtime hint to Codex prompt");
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function normalizeClaudeCommand(commandArgs, options = {}) {
|
|
1114
|
+
// `claude code <prompt>` fails in print/non-interactive mode.
|
|
1115
|
+
// Convert to the supported one-shot form `claude -p <prompt>`.
|
|
1116
|
+
if (commandArgs[1] === "code") {
|
|
1117
|
+
const promptParts = commandArgs.slice(2).filter((part) => part !== "--");
|
|
1118
|
+
const prompt = promptParts.join(" ").trim();
|
|
1119
|
+
if (prompt) {
|
|
1120
|
+
commandArgs.splice(1, commandArgs.length - 1, "-p", prompt);
|
|
1121
|
+
console.log("[agent-runner] normalized `claude code <prompt>` to `claude -p <prompt>`");
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if ((options.fullWorkspaceAccess || options.skipPermissions) && !commandArgs.includes("--dangerously-skip-permissions")) {
|
|
1126
|
+
commandArgs.splice(1, 0, "--dangerously-skip-permissions");
|
|
1127
|
+
console.log("[agent-runner] enabled dangerous permission bypass for Claude");
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
if (
|
|
1131
|
+
options.planMode &&
|
|
1132
|
+
!options.fullWorkspaceAccess &&
|
|
1133
|
+
!options.skipPermissions &&
|
|
1134
|
+
!commandArgs.includes("--permission-mode")
|
|
1135
|
+
) {
|
|
1136
|
+
commandArgs.splice(1, 0, "--permission-mode", "plan");
|
|
1137
|
+
console.log("[agent-runner] enabled Claude plan mode (`--permission-mode plan`)");
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const usesPrintMode = commandArgs.includes("-p") || commandArgs.includes("--print");
|
|
1141
|
+
if (usesPrintMode) {
|
|
1142
|
+
if (!commandArgs.includes("--verbose")) {
|
|
1143
|
+
commandArgs.splice(1, 0, "--verbose");
|
|
1144
|
+
console.log("[agent-runner] enabled `--verbose` for Claude stream-json output");
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const outputFormatIndex = commandArgs.findIndex((part) => part === "--output-format");
|
|
1148
|
+
if (outputFormatIndex >= 0) {
|
|
1149
|
+
const current = String(commandArgs[outputFormatIndex + 1] || "").trim().toLowerCase();
|
|
1150
|
+
if (current !== "stream-json") {
|
|
1151
|
+
if (outputFormatIndex + 1 < commandArgs.length && !String(commandArgs[outputFormatIndex + 1]).startsWith("-")) {
|
|
1152
|
+
commandArgs[outputFormatIndex + 1] = "stream-json";
|
|
1153
|
+
} else {
|
|
1154
|
+
commandArgs.splice(outputFormatIndex + 1, 0, "stream-json");
|
|
1155
|
+
}
|
|
1156
|
+
console.log("[agent-runner] forced Claude output to `--output-format stream-json` for session tracking");
|
|
1157
|
+
}
|
|
1158
|
+
} else {
|
|
1159
|
+
commandArgs.splice(1, 0, "--output-format", "stream-json");
|
|
1160
|
+
console.log("[agent-runner] enabled `--output-format stream-json` for Claude session tracking");
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
const persistentServerHint = String(options.persistentServerHint || "").trim();
|
|
1165
|
+
if (persistentServerHint) {
|
|
1166
|
+
const promptIndex = findClaudePromptIndex(commandArgs);
|
|
1167
|
+
if (promptIndex >= 0) {
|
|
1168
|
+
commandArgs[promptIndex] = appendPromptHint(commandArgs[promptIndex], persistentServerHint);
|
|
1169
|
+
console.log("[agent-runner] appended managed-server runtime hint to Claude prompt");
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function findCodexPromptIndex(commandArgs) {
|
|
1175
|
+
if (!Array.isArray(commandArgs) || commandArgs[0] !== "codex" || commandArgs[1] !== "exec") {
|
|
1176
|
+
return -1;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const resumeIndex = commandArgs.findIndex((part, index) => index >= 2 && part === "resume");
|
|
1180
|
+
if (resumeIndex >= 0) {
|
|
1181
|
+
const promptIndex = resumeIndex + 2;
|
|
1182
|
+
if (promptIndex < commandArgs.length) {
|
|
1183
|
+
return promptIndex;
|
|
1184
|
+
}
|
|
1185
|
+
return -1;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
for (let index = commandArgs.length - 1; index >= 2; index -= 1) {
|
|
1189
|
+
const token = String(commandArgs[index] || "");
|
|
1190
|
+
if (!token) continue;
|
|
1191
|
+
if (token.startsWith("-")) continue;
|
|
1192
|
+
return index;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
return -1;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function findClaudePromptIndex(commandArgs) {
|
|
1199
|
+
if (!Array.isArray(commandArgs) || commandArgs[0] !== "claude") return -1;
|
|
1200
|
+
for (let index = 0; index < commandArgs.length; index += 1) {
|
|
1201
|
+
if (commandArgs[index] === "-p" || commandArgs[index] === "--print") {
|
|
1202
|
+
if (index + 1 < commandArgs.length) {
|
|
1203
|
+
return index + 1;
|
|
1204
|
+
}
|
|
1205
|
+
return -1;
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
return -1;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function appendPromptHint(prompt, hint) {
|
|
1212
|
+
const cleanedPrompt = String(prompt || "").trim();
|
|
1213
|
+
if (!cleanedPrompt) return cleanedPrompt;
|
|
1214
|
+
|
|
1215
|
+
const marker = "AGENT_COMPANION_PERSIST_SERVER_HINT_V1";
|
|
1216
|
+
if (cleanedPrompt.includes(marker)) {
|
|
1217
|
+
return cleanedPrompt;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
return `${cleanedPrompt}\n\n${hint}`;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
function buildPersistentServerHint(input = {}) {
|
|
1224
|
+
const helperScriptPath = String(input.helperScriptPath || "").trim();
|
|
1225
|
+
const bridgeUrl = String(input.bridgeUrl || "http://localhost:8787").trim();
|
|
1226
|
+
const workspacePath = String(input.workspacePath || "").trim();
|
|
1227
|
+
const sessionId = String(input.sessionId || "").trim();
|
|
1228
|
+
if (!helperScriptPath) return "";
|
|
1229
|
+
|
|
1230
|
+
const escapedHelper = helperScriptPath.replace(/"/g, '\\"');
|
|
1231
|
+
const escapedBridge = bridgeUrl.replace(/"/g, '\\"');
|
|
1232
|
+
const escapedWorkspace = workspacePath.replace(/"/g, '\\"');
|
|
1233
|
+
const escapedSession = sessionId.replace(/"/g, '\\"');
|
|
1234
|
+
|
|
1235
|
+
return (
|
|
1236
|
+
"AGENT_COMPANION_PERSIST_SERVER_HINT_V1:\n" +
|
|
1237
|
+
"If you need a localhost server to stay alive after this response, do NOT use nohup/setsid/&.\n" +
|
|
1238
|
+
`Use this exact pattern:\nnode "${escapedHelper}" --bridge "${escapedBridge}" --workspace "${escapedWorkspace}" --session "${escapedSession}" --label "dev-server" --port <PORT> -- <server command>\n` +
|
|
1239
|
+
"Then continue with normal progress updates."
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function removeFlagWithValue(args, longFlag, shortFlag) {
|
|
1244
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1245
|
+
const token = args[index];
|
|
1246
|
+
if (token !== longFlag && token !== shortFlag) continue;
|
|
1247
|
+
|
|
1248
|
+
args.splice(index, 1);
|
|
1249
|
+
if (index < args.length && !String(args[index] || "").startsWith("-")) {
|
|
1250
|
+
args.splice(index, 1);
|
|
1251
|
+
}
|
|
1252
|
+
index -= 1;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function upsertThreadInCodexGlobalState(threadId, sessionTitle) {
|
|
1257
|
+
if (!threadId) return;
|
|
1258
|
+
|
|
1259
|
+
try {
|
|
1260
|
+
if (!fs.existsSync(CODEX_GLOBAL_STATE_FILE)) return;
|
|
1261
|
+
|
|
1262
|
+
const raw = fs.readFileSync(CODEX_GLOBAL_STATE_FILE, "utf8");
|
|
1263
|
+
if (!raw.trim()) return;
|
|
1264
|
+
|
|
1265
|
+
const parsed = JSON.parse(raw);
|
|
1266
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
1267
|
+
|
|
1268
|
+
const current = parsed["thread-titles"];
|
|
1269
|
+
const titles =
|
|
1270
|
+
current && typeof current === "object" && current.titles && typeof current.titles === "object"
|
|
1271
|
+
? { ...current.titles }
|
|
1272
|
+
: {};
|
|
1273
|
+
const order =
|
|
1274
|
+
current && typeof current === "object" && Array.isArray(current.order)
|
|
1275
|
+
? current.order.filter((item) => typeof item === "string")
|
|
1276
|
+
: [];
|
|
1277
|
+
|
|
1278
|
+
const cleanedTitle = String(sessionTitle || "")
|
|
1279
|
+
.replace(/\s+/g, " ")
|
|
1280
|
+
.trim()
|
|
1281
|
+
.slice(0, 120);
|
|
1282
|
+
const fallbackTitle = `Phone task ${threadId.slice(0, 8)}`;
|
|
1283
|
+
titles[threadId] = cleanedTitle || titles[threadId] || fallbackTitle;
|
|
1284
|
+
|
|
1285
|
+
const nextOrder = [threadId, ...order.filter((id) => id !== threadId)].slice(0, 600);
|
|
1286
|
+
parsed["thread-titles"] = {
|
|
1287
|
+
...(current && typeof current === "object" ? current : {}),
|
|
1288
|
+
titles,
|
|
1289
|
+
order: nextOrder
|
|
1290
|
+
};
|
|
1291
|
+
|
|
1292
|
+
atomicWriteText(CODEX_GLOBAL_STATE_FILE, JSON.stringify(parsed));
|
|
1293
|
+
console.log(`[agent-runner] indexed thread in Codex resume list: ${threadId}`);
|
|
1294
|
+
} catch (error) {
|
|
1295
|
+
console.error(`[agent-runner] unable to update Codex global state: ${String(error)}`);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
async function promoteExecRolloutToCliWithRetry(threadId, options = {}) {
|
|
1300
|
+
const attempts = Math.max(1, safeInt(options.attempts, 8));
|
|
1301
|
+
const delayMs = Math.max(10, safeInt(options.delayMs, 250));
|
|
1302
|
+
|
|
1303
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
1304
|
+
const promoted = promoteExecRolloutToCli(threadId, { silentMissing: attempt < attempts });
|
|
1305
|
+
if (promoted) return true;
|
|
1306
|
+
if (attempt < attempts) {
|
|
1307
|
+
await sleep(delayMs);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
console.log(`[agent-runner] rollout metadata unchanged for ${threadId} after ${attempts} attempts`);
|
|
1312
|
+
return false;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function promoteExecRolloutToCli(threadId, options = {}) {
|
|
1316
|
+
if (!threadId) return false;
|
|
1317
|
+
const silentMissing = Boolean(options.silentMissing);
|
|
1318
|
+
|
|
1319
|
+
try {
|
|
1320
|
+
const rolloutPath = findRolloutFileByThreadId(threadId);
|
|
1321
|
+
if (!rolloutPath) {
|
|
1322
|
+
if (!silentMissing) {
|
|
1323
|
+
console.log(`[agent-runner] rollout file not found yet for ${threadId} (resume picker metadata unchanged)`);
|
|
1324
|
+
}
|
|
1325
|
+
return false;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const raw = fs.readFileSync(rolloutPath, "utf8");
|
|
1329
|
+
if (!raw.trim()) return false;
|
|
1330
|
+
|
|
1331
|
+
const newlineIndex = raw.indexOf("\n");
|
|
1332
|
+
const firstLine = newlineIndex >= 0 ? raw.slice(0, newlineIndex) : raw;
|
|
1333
|
+
const rest = newlineIndex >= 0 ? raw.slice(newlineIndex) : "";
|
|
1334
|
+
if (!firstLine.trim()) return false;
|
|
1335
|
+
|
|
1336
|
+
let meta = null;
|
|
1337
|
+
try {
|
|
1338
|
+
meta = JSON.parse(firstLine);
|
|
1339
|
+
} catch {
|
|
1340
|
+
console.error(`[agent-runner] unable to parse rollout metadata line for ${threadId}`);
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (!meta || typeof meta !== "object") {
|
|
1345
|
+
return false;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
const topLevelThreadId = typeof meta.thread_id === "string" ? meta.thread_id : "";
|
|
1349
|
+
const payloadThreadId =
|
|
1350
|
+
meta.payload && typeof meta.payload === "object" && typeof meta.payload.id === "string"
|
|
1351
|
+
? meta.payload.id
|
|
1352
|
+
: "";
|
|
1353
|
+
const discoveredThreadId = topLevelThreadId || payloadThreadId;
|
|
1354
|
+
if (discoveredThreadId && discoveredThreadId !== threadId) {
|
|
1355
|
+
return false;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
let changed = false;
|
|
1359
|
+
|
|
1360
|
+
const candidates = [meta];
|
|
1361
|
+
if (meta.payload && typeof meta.payload === "object") {
|
|
1362
|
+
candidates.push(meta.payload);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
for (const candidate of candidates) {
|
|
1366
|
+
if (!candidate || typeof candidate !== "object") continue;
|
|
1367
|
+
const source = typeof candidate.source === "string" ? candidate.source.trim().toLowerCase() : "";
|
|
1368
|
+
if (
|
|
1369
|
+
(source === "exec" || source === "cli") &&
|
|
1370
|
+
(candidate.originator === "codex_exec" || candidate.originator === "Codex Desktop")
|
|
1371
|
+
) {
|
|
1372
|
+
candidate.originator = "codex_cli_rs";
|
|
1373
|
+
changed = true;
|
|
1374
|
+
}
|
|
1375
|
+
if (source === "exec") {
|
|
1376
|
+
candidate.source = "cli";
|
|
1377
|
+
changed = true;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
if (!changed) {
|
|
1382
|
+
return true;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
const patchedLine = JSON.stringify(meta);
|
|
1386
|
+
atomicWriteText(rolloutPath, `${patchedLine}${rest}`);
|
|
1387
|
+
console.log(`[agent-runner] promoted session metadata for resume picker: ${threadId}`);
|
|
1388
|
+
return true;
|
|
1389
|
+
} catch (error) {
|
|
1390
|
+
console.error(`[agent-runner] unable to promote rollout metadata: ${String(error)}`);
|
|
1391
|
+
return false;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function findRolloutFileByThreadId(threadId) {
|
|
1396
|
+
const candidates = candidateSessionDayDirs(8);
|
|
1397
|
+
const suffix = `${threadId}.jsonl`;
|
|
1398
|
+
|
|
1399
|
+
for (const dayDir of candidates) {
|
|
1400
|
+
let files = [];
|
|
1401
|
+
try {
|
|
1402
|
+
files = fs.readdirSync(dayDir, { withFileTypes: true });
|
|
1403
|
+
} catch {
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
for (const file of files) {
|
|
1408
|
+
if (!file.isFile()) continue;
|
|
1409
|
+
if (!file.name.startsWith("rollout-")) continue;
|
|
1410
|
+
if (!file.name.endsWith(suffix)) continue;
|
|
1411
|
+
return path.join(dayDir, file.name);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
return "";
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
function candidateSessionDayDirs(daysBack) {
|
|
1419
|
+
const dirs = [];
|
|
1420
|
+
const seen = new Set();
|
|
1421
|
+
const now = new Date();
|
|
1422
|
+
|
|
1423
|
+
for (let offset = 0; offset <= daysBack; offset += 1) {
|
|
1424
|
+
const date = new Date(now.getTime() - offset * 24 * 60 * 60 * 1000);
|
|
1425
|
+
const year = String(date.getFullYear());
|
|
1426
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
1427
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
1428
|
+
const dir = path.join(CODEX_SESSIONS_ROOT, year, month, day);
|
|
1429
|
+
if (seen.has(dir)) continue;
|
|
1430
|
+
seen.add(dir);
|
|
1431
|
+
dirs.push(dir);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
return dirs;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function atomicWriteText(filePath, content) {
|
|
1438
|
+
const dir = path.dirname(filePath);
|
|
1439
|
+
const base = path.basename(filePath);
|
|
1440
|
+
const tempPath = path.join(dir, `.${base}.tmp-${process.pid}-${Date.now()}`);
|
|
1441
|
+
let mode = undefined;
|
|
1442
|
+
|
|
1443
|
+
try {
|
|
1444
|
+
mode = fs.statSync(filePath).mode;
|
|
1445
|
+
} catch {
|
|
1446
|
+
mode = undefined;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
if (typeof mode === "number") {
|
|
1450
|
+
fs.writeFileSync(tempPath, content, { mode });
|
|
1451
|
+
} else {
|
|
1452
|
+
fs.writeFileSync(tempPath, content);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
fs.renameSync(tempPath, filePath);
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
function parseBooleanEnv(value, fallback) {
|
|
1459
|
+
if (value == null) return fallback;
|
|
1460
|
+
const normalized = String(value).trim().toLowerCase();
|
|
1461
|
+
if (!normalized) return fallback;
|
|
1462
|
+
if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") {
|
|
1463
|
+
return true;
|
|
1464
|
+
}
|
|
1465
|
+
if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") {
|
|
1466
|
+
return false;
|
|
1467
|
+
}
|
|
1468
|
+
return fallback;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function sleep(ms) {
|
|
1472
|
+
return new Promise((resolve) => {
|
|
1473
|
+
setTimeout(resolve, ms);
|
|
1474
|
+
});
|
|
1475
|
+
}
|