pikiloop 0.4.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/LICENSE +21 -0
- package/README.md +353 -0
- package/README.v2.md +287 -0
- package/README.zh-CN.md +352 -0
- package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
- package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
- package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
- package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
- package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
- package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
- package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
- package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
- package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
- package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
- package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
- package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
- package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
- package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
- package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
- package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
- package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
- package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
- package/dashboard/dist/assets/index-reSbuley.css +1 -0
- package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
- package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
- package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
- package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
- package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
- package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
- package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
- package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
- package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
- package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
- package/dashboard/dist/favicon.svg +28 -0
- package/dashboard/dist/index.html +17 -0
- package/dist/agent/acp-client.js +261 -0
- package/dist/agent/auto-update.js +432 -0
- package/dist/agent/await-resume.js +50 -0
- package/dist/agent/cli/auth.js +325 -0
- package/dist/agent/cli/catalog.js +40 -0
- package/dist/agent/cli/detector.js +136 -0
- package/dist/agent/cli/index.js +7 -0
- package/dist/agent/cli/registry.js +33 -0
- package/dist/agent/driver.js +39 -0
- package/dist/agent/drivers/claude-tui.js +2297 -0
- package/dist/agent/drivers/claude.js +2689 -0
- package/dist/agent/drivers/codex.js +2210 -0
- package/dist/agent/drivers/gemini.js +1059 -0
- package/dist/agent/drivers/hermes.js +795 -0
- package/dist/agent/goal.js +274 -0
- package/dist/agent/handover.js +130 -0
- package/dist/agent/images.js +355 -0
- package/dist/agent/index.js +50 -0
- package/dist/agent/mcp/bridge.js +791 -0
- package/dist/agent/mcp/extensions.js +637 -0
- package/dist/agent/mcp/oauth.js +353 -0
- package/dist/agent/mcp/registry.js +119 -0
- package/dist/agent/mcp/session-server.js +229 -0
- package/dist/agent/mcp/tools/ask-user.js +113 -0
- package/dist/agent/mcp/tools/await-resume.js +77 -0
- package/dist/agent/mcp/tools/goal.js +144 -0
- package/dist/agent/mcp/tools/types.js +12 -0
- package/dist/agent/mcp/tools/workspace.js +212 -0
- package/dist/agent/npm.js +31 -0
- package/dist/agent/session.js +1206 -0
- package/dist/agent/skill-installer.js +160 -0
- package/dist/agent/skills.js +257 -0
- package/dist/agent/stream.js +743 -0
- package/dist/agent/types.js +13 -0
- package/dist/agent/utils.js +687 -0
- package/dist/bot/bot.js +2499 -0
- package/dist/bot/command-ui.js +633 -0
- package/dist/bot/commands.js +513 -0
- package/dist/bot/headless-bot.js +36 -0
- package/dist/bot/host.js +192 -0
- package/dist/bot/human-loop.js +168 -0
- package/dist/bot/menu.js +48 -0
- package/dist/bot/orchestration.js +79 -0
- package/dist/bot/render-shared.js +309 -0
- package/dist/bot/session-hub.js +361 -0
- package/dist/bot/session-status.js +55 -0
- package/dist/bot/streaming.js +309 -0
- package/dist/browser-profile.js +579 -0
- package/dist/browser-supervisor.js +249 -0
- package/dist/catalog/cli-tools.js +421 -0
- package/dist/catalog/index.js +21 -0
- package/dist/catalog/local-models.js +94 -0
- package/dist/catalog/mcp-servers.js +315 -0
- package/dist/catalog/skill-repos.js +173 -0
- package/dist/channels/base.js +55 -0
- package/dist/channels/dingtalk/bot.js +549 -0
- package/dist/channels/dingtalk/channel.js +268 -0
- package/dist/channels/discord/bot.js +552 -0
- package/dist/channels/discord/channel.js +245 -0
- package/dist/channels/feishu/bot.js +1275 -0
- package/dist/channels/feishu/channel.js +911 -0
- package/dist/channels/feishu/markdown.js +91 -0
- package/dist/channels/feishu/render.js +619 -0
- package/dist/channels/health.js +109 -0
- package/dist/channels/slack/bot.js +554 -0
- package/dist/channels/slack/channel.js +283 -0
- package/dist/channels/states.js +6 -0
- package/dist/channels/telegram/bot.js +1310 -0
- package/dist/channels/telegram/channel.js +820 -0
- package/dist/channels/telegram/directory.js +111 -0
- package/dist/channels/telegram/live-preview.js +220 -0
- package/dist/channels/telegram/render.js +384 -0
- package/dist/channels/wecom/bot.js +558 -0
- package/dist/channels/wecom/channel.js +479 -0
- package/dist/channels/weixin/api.js +520 -0
- package/dist/channels/weixin/bot.js +1000 -0
- package/dist/channels/weixin/channel.js +222 -0
- package/dist/cli/autostart.js +262 -0
- package/dist/cli/channel-supervisor.js +313 -0
- package/dist/cli/channels.js +54 -0
- package/dist/cli/main.js +726 -0
- package/dist/cli/onboarding.js +227 -0
- package/dist/cli/run.js +308 -0
- package/dist/cli/setup-wizard.js +235 -0
- package/dist/core/config/runtime-config.js +201 -0
- package/dist/core/config/user-config.js +510 -0
- package/dist/core/config/validation.js +521 -0
- package/dist/core/constants.js +400 -0
- package/dist/core/git.js +145 -0
- package/dist/core/legacy-compat.js +60 -0
- package/dist/core/logging.js +101 -0
- package/dist/core/platform.js +59 -0
- package/dist/core/process-control.js +315 -0
- package/dist/core/secrets/index.js +42 -0
- package/dist/core/secrets/inline-seal.js +60 -0
- package/dist/core/secrets/ref.js +33 -0
- package/dist/core/secrets/resolver.js +65 -0
- package/dist/core/secrets/store.js +63 -0
- package/dist/core/utils.js +233 -0
- package/dist/core/version.js +15 -0
- package/dist/dashboard/platform.js +219 -0
- package/dist/dashboard/routes/agents.js +450 -0
- package/dist/dashboard/routes/cli.js +174 -0
- package/dist/dashboard/routes/config.js +523 -0
- package/dist/dashboard/routes/extensions.js +745 -0
- package/dist/dashboard/routes/local-models.js +290 -0
- package/dist/dashboard/routes/models.js +324 -0
- package/dist/dashboard/routes/sessions.js +838 -0
- package/dist/dashboard/runtime.js +410 -0
- package/dist/dashboard/server.js +237 -0
- package/dist/dashboard/session-control.js +347 -0
- package/dist/model/catalog.js +104 -0
- package/dist/model/index.js +20 -0
- package/dist/model/injector.js +272 -0
- package/dist/model/provider-models.js +112 -0
- package/dist/model/store.js +212 -0
- package/dist/model/types.js +13 -0
- package/dist/model/validation.js +203 -0
- package/package.json +82 -0
|
@@ -0,0 +1,2210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex CLI driver: HTTP server management, streaming, human-in-the-loop.
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { execSync, spawn } from 'node:child_process';
|
|
7
|
+
import { registerDriver } from '../driver.js';
|
|
8
|
+
import { terminateProcessTree } from '../../core/process-control.js';
|
|
9
|
+
import { mimeForExt,
|
|
10
|
+
// shared helpers
|
|
11
|
+
agentLog, agentWarn, buildStreamPreviewMeta, pushRecentActivity, normalizeActivityLine, firstNonEmptyLine, shortValue, numberOrNull, normalizeStreamPreviewPlan, IMAGE_EXTS, listPikiloopSessions, findPikiloopSession, mergeManagedAndNativeSessions, stripInjectedPrompts, sanitizeSessionUserPreviewText, computeContext, readTailLines, applyTurnWindow, roundPercent, toIsoFromEpochSeconds, labelFromWindowMinutes, usageWindowFromRateLimit, parseJsonTail, emptyUsage, attachAgentImage, codexHome, Q, } from '../index.js';
|
|
12
|
+
import { CODEX_APPSERVER_SPAWN_TIMEOUT_MS as _CODEX_APPSERVER_SPAWN_TIMEOUT_MS, CODEX_STREAM_HARD_KILL_GRACE_MS, SESSION_RUNNING_THRESHOLD_MS, } from '../../core/constants.js';
|
|
13
|
+
import { getHome } from '../../core/platform.js';
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// App-server JSON-RPC client
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
const CODEX_APPSERVER_SPAWN_TIMEOUT_MS = _CODEX_APPSERVER_SPAWN_TIMEOUT_MS;
|
|
18
|
+
export class CodexAppServer {
|
|
19
|
+
proc = null;
|
|
20
|
+
buf = '';
|
|
21
|
+
nextId = 1;
|
|
22
|
+
pending = new Map();
|
|
23
|
+
notificationHandlers = new Set();
|
|
24
|
+
requestHandlers = new Set();
|
|
25
|
+
ready = false;
|
|
26
|
+
startPromise = null;
|
|
27
|
+
configOverrides = [];
|
|
28
|
+
extraEnv;
|
|
29
|
+
async ensureRunning(extraConfig, extraEnv) {
|
|
30
|
+
if (this.ready && this.proc && !this.proc.killed)
|
|
31
|
+
return true;
|
|
32
|
+
if (this.startPromise)
|
|
33
|
+
return this.startPromise;
|
|
34
|
+
this.configOverrides = extraConfig ?? [];
|
|
35
|
+
this.extraEnv = extraEnv;
|
|
36
|
+
this.startPromise = this._start();
|
|
37
|
+
const ok = await this.startPromise;
|
|
38
|
+
this.startPromise = null;
|
|
39
|
+
return ok;
|
|
40
|
+
}
|
|
41
|
+
_start() {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const timer = setTimeout(() => { this.kill(); resolve(false); }, CODEX_APPSERVER_SPAWN_TIMEOUT_MS);
|
|
44
|
+
const args = ['app-server'];
|
|
45
|
+
// Always enable codex's native /goal feature so pikiloop can route through
|
|
46
|
+
// codex's own `thread/goal/*` RPC + continuation engine. User-supplied -c
|
|
47
|
+
// overrides win.
|
|
48
|
+
const overrides = this.configOverrides.some(entry => /^features\.goals\s*=/.test(entry))
|
|
49
|
+
? this.configOverrides
|
|
50
|
+
: [...this.configOverrides, 'features.goals=true'];
|
|
51
|
+
for (const c of overrides)
|
|
52
|
+
args.push('-c', c);
|
|
53
|
+
agentLog(`[codex-rpc] spawning: codex ${args.join(' ')}`);
|
|
54
|
+
const proc = spawn('codex', args, {
|
|
55
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
56
|
+
shell: true,
|
|
57
|
+
detached: process.platform !== 'win32',
|
|
58
|
+
env: this.extraEnv ? { ...process.env, ...this.extraEnv } : process.env,
|
|
59
|
+
});
|
|
60
|
+
this.proc = proc;
|
|
61
|
+
this.buf = '';
|
|
62
|
+
this.nextId = 1;
|
|
63
|
+
this.pending.clear();
|
|
64
|
+
this.ready = false;
|
|
65
|
+
proc.stderr?.on('data', (c) => { agentLog(`[codex-rpc][stderr] ${c.toString().trim().slice(0, 200)}`); });
|
|
66
|
+
proc.stdout.on('data', (chunk) => {
|
|
67
|
+
this.buf += chunk.toString('utf-8');
|
|
68
|
+
const lines = this.buf.split('\n');
|
|
69
|
+
this.buf = lines.pop() || '';
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
if (!line.trim())
|
|
72
|
+
continue;
|
|
73
|
+
let msg;
|
|
74
|
+
try {
|
|
75
|
+
msg = JSON.parse(line);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (msg.method && msg.id != null) {
|
|
81
|
+
const handlers = [...this.requestHandlers];
|
|
82
|
+
if (!handlers.length) {
|
|
83
|
+
this.respond(msg.id, {});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const [handler] = handlers;
|
|
87
|
+
Promise.resolve(handler(msg.method, msg.params ?? {}, String(msg.id)))
|
|
88
|
+
.then(result => this.respond(msg.id, result ?? {}))
|
|
89
|
+
.catch(error => {
|
|
90
|
+
agentWarn(`[codex-rpc] request handler error method=${msg.method} error=${error?.message || error}`);
|
|
91
|
+
this.respond(msg.id, {});
|
|
92
|
+
});
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (msg.id != null) {
|
|
96
|
+
const cb = this.pending.get(msg.id);
|
|
97
|
+
if (cb) {
|
|
98
|
+
this.pending.delete(msg.id);
|
|
99
|
+
cb(msg);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (msg.method && msg.id == null) {
|
|
103
|
+
for (const handler of [...this.notificationHandlers])
|
|
104
|
+
handler(msg.method, msg.params ?? {});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
proc.on('error', () => { clearTimeout(timer); this.ready = false; resolve(false); });
|
|
109
|
+
proc.on('close', () => {
|
|
110
|
+
this.ready = false;
|
|
111
|
+
this.proc = null;
|
|
112
|
+
// Resolve any pending RPC calls so callers don't hang forever
|
|
113
|
+
for (const [id, cb] of this.pending) {
|
|
114
|
+
cb({ error: { message: 'process exited before responding' } });
|
|
115
|
+
}
|
|
116
|
+
this.pending.clear();
|
|
117
|
+
});
|
|
118
|
+
// Declare experimentalApi so `thread/goal/*` is reachable. Codex 0.130+
|
|
119
|
+
// gates these RPCs behind that capability — without it, every goal call
|
|
120
|
+
// returns "requires experimentalApi capability".
|
|
121
|
+
this.call('initialize', {
|
|
122
|
+
clientInfo: { name: 'pikiloop', version: '0.2.0' },
|
|
123
|
+
capabilities: { experimentalApi: true },
|
|
124
|
+
})
|
|
125
|
+
.then(resp => {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
if (resp.error) {
|
|
128
|
+
agentWarn(`[codex-rpc] init error: ${resp.error.message}`);
|
|
129
|
+
resolve(false);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.ready = true;
|
|
133
|
+
agentLog(`[codex-rpc] initialized`);
|
|
134
|
+
resolve(true);
|
|
135
|
+
})
|
|
136
|
+
.catch(() => { clearTimeout(timer); resolve(false); });
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
call(method, params, timeoutMs) {
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
if (!this.proc || this.proc.killed) {
|
|
142
|
+
resolve({ error: { message: 'not connected' } });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const id = this.nextId++;
|
|
146
|
+
const wrappedResolve = (result) => {
|
|
147
|
+
if (timer)
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
this.pending.delete(id);
|
|
150
|
+
resolve(result);
|
|
151
|
+
};
|
|
152
|
+
const timer = timeoutMs ? setTimeout(() => {
|
|
153
|
+
this.pending.delete(id);
|
|
154
|
+
resolve({ error: { message: `RPC call '${method}' timed out after ${timeoutMs}ms` } });
|
|
155
|
+
}, timeoutMs) : null;
|
|
156
|
+
this.pending.set(id, wrappedResolve);
|
|
157
|
+
const msg = { jsonrpc: '2.0', id, method };
|
|
158
|
+
if (params !== undefined)
|
|
159
|
+
msg.params = params;
|
|
160
|
+
try {
|
|
161
|
+
this.proc.stdin.write(JSON.stringify(msg) + '\n');
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
if (timer)
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
this.pending.delete(id);
|
|
167
|
+
resolve({ error: { message: 'write failed' } });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
notify(method, params) {
|
|
172
|
+
if (!this.proc || this.proc.killed)
|
|
173
|
+
return;
|
|
174
|
+
const msg = { jsonrpc: '2.0', method };
|
|
175
|
+
if (params !== undefined)
|
|
176
|
+
msg.params = params;
|
|
177
|
+
try {
|
|
178
|
+
this.proc.stdin.write(JSON.stringify(msg) + '\n');
|
|
179
|
+
}
|
|
180
|
+
catch { }
|
|
181
|
+
}
|
|
182
|
+
onNotification(handler) {
|
|
183
|
+
this.notificationHandlers.add(handler);
|
|
184
|
+
return () => { this.notificationHandlers.delete(handler); };
|
|
185
|
+
}
|
|
186
|
+
offNotification(handler) {
|
|
187
|
+
if (!handler) {
|
|
188
|
+
this.notificationHandlers.clear();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
this.notificationHandlers.delete(handler);
|
|
192
|
+
}
|
|
193
|
+
kill() {
|
|
194
|
+
terminateProcessTree(this.proc, { signal: 'SIGTERM', forceSignal: 'SIGKILL', forceAfterMs: 2000 });
|
|
195
|
+
this.proc = null;
|
|
196
|
+
this.ready = false;
|
|
197
|
+
for (const cb of this.pending.values())
|
|
198
|
+
cb({ error: { message: 'app-server terminated' } });
|
|
199
|
+
this.pending.clear();
|
|
200
|
+
this.notificationHandlers.clear();
|
|
201
|
+
}
|
|
202
|
+
get isRunning() { return this.ready && !!this.proc && !this.proc.killed; }
|
|
203
|
+
onRequest(handler) {
|
|
204
|
+
this.requestHandlers.add(handler);
|
|
205
|
+
return () => { this.requestHandlers.delete(handler); };
|
|
206
|
+
}
|
|
207
|
+
respond(id, result) {
|
|
208
|
+
if (!this.proc || this.proc.killed)
|
|
209
|
+
return;
|
|
210
|
+
try {
|
|
211
|
+
this.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
|
|
212
|
+
}
|
|
213
|
+
catch { }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/** Singleton app-server for shared operations (sessions, models, usage). */
|
|
217
|
+
let _sharedServer = null;
|
|
218
|
+
function getSharedServer() {
|
|
219
|
+
if (!_sharedServer)
|
|
220
|
+
_sharedServer = new CodexAppServer();
|
|
221
|
+
return _sharedServer;
|
|
222
|
+
}
|
|
223
|
+
export function shutdownCodexServer() {
|
|
224
|
+
_sharedServer?.kill();
|
|
225
|
+
_sharedServer = null;
|
|
226
|
+
}
|
|
227
|
+
const CODEX_GOAL_RPC_TIMEOUT_MS = 15_000;
|
|
228
|
+
async function ensureSharedServerForGoal() {
|
|
229
|
+
const srv = getSharedServer();
|
|
230
|
+
if (!(await srv.ensureRunning()))
|
|
231
|
+
return null;
|
|
232
|
+
return srv;
|
|
233
|
+
}
|
|
234
|
+
function unwrapGoal(raw) {
|
|
235
|
+
const g = raw?.goal ?? raw;
|
|
236
|
+
if (!g || typeof g !== 'object')
|
|
237
|
+
return null;
|
|
238
|
+
if (typeof g.threadId !== 'string')
|
|
239
|
+
return null;
|
|
240
|
+
return {
|
|
241
|
+
threadId: g.threadId,
|
|
242
|
+
objective: String(g.objective ?? ''),
|
|
243
|
+
status: g.status || 'active',
|
|
244
|
+
tokenBudget: typeof g.tokenBudget === 'number' ? g.tokenBudget : null,
|
|
245
|
+
tokensUsed: typeof g.tokensUsed === 'number' ? g.tokensUsed : 0,
|
|
246
|
+
timeUsedSeconds: typeof g.timeUsedSeconds === 'number' ? g.timeUsedSeconds : 0,
|
|
247
|
+
createdAt: typeof g.createdAt === 'number' ? g.createdAt : 0,
|
|
248
|
+
updatedAt: typeof g.updatedAt === 'number' ? g.updatedAt : 0,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/** Set / replace the active goal on a codex thread. Codex auto-starts a continuation turn if it is idle. */
|
|
252
|
+
export async function setCodexGoal(opts) {
|
|
253
|
+
const srv = await ensureSharedServerForGoal();
|
|
254
|
+
if (!srv)
|
|
255
|
+
return { ok: false, error: 'codex app-server unavailable' };
|
|
256
|
+
const params = { threadId: opts.threadId };
|
|
257
|
+
if (typeof opts.objective === 'string')
|
|
258
|
+
params.objective = opts.objective;
|
|
259
|
+
if (opts.status)
|
|
260
|
+
params.status = opts.status;
|
|
261
|
+
if (opts.tokenBudget !== undefined)
|
|
262
|
+
params.tokenBudget = opts.tokenBudget;
|
|
263
|
+
const resp = await srv.call('thread/goal/set', params, CODEX_GOAL_RPC_TIMEOUT_MS);
|
|
264
|
+
if (resp?.error)
|
|
265
|
+
return { ok: false, error: String(resp.error.message || 'thread/goal/set failed') };
|
|
266
|
+
return { ok: true, goal: unwrapGoal(resp?.result) };
|
|
267
|
+
}
|
|
268
|
+
export async function getCodexGoal(threadId) {
|
|
269
|
+
const srv = await ensureSharedServerForGoal();
|
|
270
|
+
if (!srv)
|
|
271
|
+
return null;
|
|
272
|
+
const resp = await srv.call('thread/goal/get', { threadId }, CODEX_GOAL_RPC_TIMEOUT_MS);
|
|
273
|
+
if (resp?.error) {
|
|
274
|
+
agentWarn(`[codex-rpc] thread/goal/get error: ${resp.error.message || resp.error}`);
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
return unwrapGoal(resp?.result);
|
|
278
|
+
}
|
|
279
|
+
export async function clearCodexGoal(threadId) {
|
|
280
|
+
const srv = await ensureSharedServerForGoal();
|
|
281
|
+
if (!srv)
|
|
282
|
+
return { ok: false, error: 'codex app-server unavailable' };
|
|
283
|
+
const resp = await srv.call('thread/goal/clear', { threadId }, CODEX_GOAL_RPC_TIMEOUT_MS);
|
|
284
|
+
if (resp?.error)
|
|
285
|
+
return { ok: false, error: String(resp.error.message || 'thread/goal/clear failed') };
|
|
286
|
+
return { ok: true };
|
|
287
|
+
}
|
|
288
|
+
export async function pauseCodexGoal(threadId) {
|
|
289
|
+
return setCodexGoal({ threadId, status: 'paused' });
|
|
290
|
+
}
|
|
291
|
+
export async function resumeCodexGoal(threadId) {
|
|
292
|
+
return setCodexGoal({ threadId, status: 'active' });
|
|
293
|
+
}
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// Effort mapping
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
const EFFORT_MAP = {
|
|
298
|
+
low: 'low', medium: 'medium', high: 'high', min: 'minimal', max: 'xhigh',
|
|
299
|
+
};
|
|
300
|
+
function mapEffort(effort) { return EFFORT_MAP[effort] ?? effort; }
|
|
301
|
+
function isCodexToolCallItem(item) {
|
|
302
|
+
return item?.type === 'dynamicToolCall' || item?.type === 'mcpToolCall' || item?.type === 'collabAgentToolCall';
|
|
303
|
+
}
|
|
304
|
+
function codexToolKind(name) {
|
|
305
|
+
const raw = typeof name === 'string' ? name.trim() : '';
|
|
306
|
+
if (!raw)
|
|
307
|
+
return 'tool';
|
|
308
|
+
const parts = raw.split('.');
|
|
309
|
+
return parts[parts.length - 1] || raw;
|
|
310
|
+
}
|
|
311
|
+
function codexToolName(item) {
|
|
312
|
+
return typeof item?.tool === 'string' && item.tool.trim()
|
|
313
|
+
? item.tool.trim()
|
|
314
|
+
: (typeof item?.name === 'string' ? item.name.trim() : '');
|
|
315
|
+
}
|
|
316
|
+
function codexToolArgs(item) {
|
|
317
|
+
return item?.arguments ?? item?.input ?? item?.args ?? item?.parameters ?? item?.params ?? item?.call?.arguments ?? null;
|
|
318
|
+
}
|
|
319
|
+
function commandPreview(command, max = 160) {
|
|
320
|
+
const raw = typeof command === 'string' ? command.trim() : '';
|
|
321
|
+
if (!raw)
|
|
322
|
+
return '';
|
|
323
|
+
const oneLine = raw.split('\n').map(line => line.trim()).find(Boolean) || raw;
|
|
324
|
+
return shortValue(oneLine, max);
|
|
325
|
+
}
|
|
326
|
+
function summarizeCodexCommand(command) {
|
|
327
|
+
const preview = commandPreview(command);
|
|
328
|
+
return preview ? `Bash: ${preview}` : 'Bash';
|
|
329
|
+
}
|
|
330
|
+
function compactPathTarget(value, max = 80) {
|
|
331
|
+
const raw = typeof value === 'string' ? value.trim() : '';
|
|
332
|
+
if (!raw)
|
|
333
|
+
return '';
|
|
334
|
+
const normalized = raw.replace(/\\/g, '/');
|
|
335
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
336
|
+
const compact = parts.length >= 2 ? parts.slice(-2).join('/') : normalized;
|
|
337
|
+
if (compact.length <= max)
|
|
338
|
+
return compact;
|
|
339
|
+
return `...${compact.slice(-(max - 3))}`;
|
|
340
|
+
}
|
|
341
|
+
function summarizeCodexToolCall(item) {
|
|
342
|
+
const rawName = codexToolName(item);
|
|
343
|
+
const kind = codexToolKind(rawName);
|
|
344
|
+
const args = parseCodexArguments(codexToolArgs(item));
|
|
345
|
+
switch (kind) {
|
|
346
|
+
case 'apply_patch': return { kind, summary: 'Edit files' };
|
|
347
|
+
case 'exec_command': {
|
|
348
|
+
const command = args && typeof args === 'object' && !Array.isArray(args) ? args.cmd : null;
|
|
349
|
+
const preview = commandPreview(command);
|
|
350
|
+
return { kind, summary: preview ? `Bash: ${preview}` : 'Bash' };
|
|
351
|
+
}
|
|
352
|
+
case 'update_plan': return { kind, summary: 'Update plan' };
|
|
353
|
+
case 'request_user_input': return { kind, summary: 'Request user input' };
|
|
354
|
+
case 'view_image': return { kind, summary: 'Inspect image' };
|
|
355
|
+
case 'parallel': return { kind, summary: 'Run multiple tools' };
|
|
356
|
+
default: {
|
|
357
|
+
const label = shortValue(kind.replace(/_/g, ' '), 80);
|
|
358
|
+
return label ? { kind, summary: `Use ${label}` } : null;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
function summarizeCodexFileChange(item) {
|
|
363
|
+
const changes = Array.isArray(item?.changes) ? item.changes : [];
|
|
364
|
+
const paths = changes.map((c) => compactPathTarget(c?.path, 90)).filter(Boolean);
|
|
365
|
+
if (paths.length === 1)
|
|
366
|
+
return `Updated ${paths[0]}`;
|
|
367
|
+
if (paths.length > 1)
|
|
368
|
+
return `Updated ${paths.length} files`;
|
|
369
|
+
return 'Updated files';
|
|
370
|
+
}
|
|
371
|
+
function summarizeCodexRawResponseItem(item) {
|
|
372
|
+
if (!item || typeof item !== 'object')
|
|
373
|
+
return null;
|
|
374
|
+
switch (item.type) {
|
|
375
|
+
case 'function_call': {
|
|
376
|
+
const name = typeof item.name === 'string' ? item.name.trim() : '';
|
|
377
|
+
if (!name)
|
|
378
|
+
return null;
|
|
379
|
+
const tool = summarizeCodexToolCall({
|
|
380
|
+
name,
|
|
381
|
+
arguments: item.arguments,
|
|
382
|
+
});
|
|
383
|
+
return tool?.summary || shortValue(name, 120);
|
|
384
|
+
}
|
|
385
|
+
case 'function_call_output': {
|
|
386
|
+
const output = formatCodexArguments(item.output).trim();
|
|
387
|
+
if (!output || output === 'Plan updated')
|
|
388
|
+
return null;
|
|
389
|
+
const firstLine = firstNonEmptyLine(output);
|
|
390
|
+
return firstLine ? `Result: ${shortValue(firstLine, 140)}` : null;
|
|
391
|
+
}
|
|
392
|
+
case 'web_search_call': {
|
|
393
|
+
const action = item.action || {};
|
|
394
|
+
if (action.type === 'search') {
|
|
395
|
+
const query = shortValue(action.query, 120);
|
|
396
|
+
return query ? `Search web: ${query}` : 'Search web';
|
|
397
|
+
}
|
|
398
|
+
if (action.type === 'open_page') {
|
|
399
|
+
const url = shortValue(action.url, 120);
|
|
400
|
+
return url ? `Open ${url}` : 'Open web page';
|
|
401
|
+
}
|
|
402
|
+
return 'Search web';
|
|
403
|
+
}
|
|
404
|
+
case 'custom_tool_call': {
|
|
405
|
+
const name = shortValue(item.name, 80);
|
|
406
|
+
return name ? `Use ${name}` : 'Use tool';
|
|
407
|
+
}
|
|
408
|
+
case 'local_shell_call': {
|
|
409
|
+
return summarizeCodexCommand(item.action?.command || item.action?.cmd);
|
|
410
|
+
}
|
|
411
|
+
default:
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
function extractCodexMessageText(content) {
|
|
416
|
+
if (typeof content === 'string')
|
|
417
|
+
return content.trim();
|
|
418
|
+
if (!Array.isArray(content))
|
|
419
|
+
return '';
|
|
420
|
+
return content
|
|
421
|
+
.map((entry) => {
|
|
422
|
+
if (!entry || typeof entry !== 'object')
|
|
423
|
+
return '';
|
|
424
|
+
if ((entry.type === 'output_text' || entry.type === 'input_text' || entry.type === 'text') && typeof entry.text === 'string') {
|
|
425
|
+
return entry.text.trim();
|
|
426
|
+
}
|
|
427
|
+
return '';
|
|
428
|
+
})
|
|
429
|
+
.filter(Boolean)
|
|
430
|
+
.join('\n\n')
|
|
431
|
+
.trim();
|
|
432
|
+
}
|
|
433
|
+
function extractCodexReasoningText(payload) {
|
|
434
|
+
const fromSummary = Array.isArray(payload?.summary)
|
|
435
|
+
? payload.summary
|
|
436
|
+
.map((entry) => typeof entry === 'string' ? entry : (typeof entry?.text === 'string' ? entry.text : ''))
|
|
437
|
+
.filter(Boolean)
|
|
438
|
+
.join('\n')
|
|
439
|
+
.trim()
|
|
440
|
+
: '';
|
|
441
|
+
if (fromSummary)
|
|
442
|
+
return fromSummary;
|
|
443
|
+
if (Array.isArray(payload?.content)) {
|
|
444
|
+
return payload.content
|
|
445
|
+
.map((entry) => typeof entry === 'string' ? entry : (typeof entry?.text === 'string' ? entry.text : ''))
|
|
446
|
+
.filter(Boolean)
|
|
447
|
+
.join('\n')
|
|
448
|
+
.trim();
|
|
449
|
+
}
|
|
450
|
+
return typeof payload?.content === 'string' ? payload.content.trim() : '';
|
|
451
|
+
}
|
|
452
|
+
function parseCodexArguments(raw) {
|
|
453
|
+
if (typeof raw !== 'string')
|
|
454
|
+
return raw;
|
|
455
|
+
const text = raw.trim();
|
|
456
|
+
if (!text)
|
|
457
|
+
return null;
|
|
458
|
+
try {
|
|
459
|
+
return JSON.parse(text);
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
return raw;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function formatCodexArguments(raw) {
|
|
466
|
+
const parsed = parseCodexArguments(raw);
|
|
467
|
+
if (parsed == null)
|
|
468
|
+
return '';
|
|
469
|
+
if (typeof parsed === 'string')
|
|
470
|
+
return parsed.trim();
|
|
471
|
+
try {
|
|
472
|
+
return JSON.stringify(parsed, null, 2);
|
|
473
|
+
}
|
|
474
|
+
catch { }
|
|
475
|
+
return String(parsed);
|
|
476
|
+
}
|
|
477
|
+
function formatCodexPlanSummary(plan) {
|
|
478
|
+
const lines = [];
|
|
479
|
+
if (plan.explanation?.trim())
|
|
480
|
+
lines.push(plan.explanation.trim());
|
|
481
|
+
for (const step of plan.steps)
|
|
482
|
+
lines.push(`[${step.status}] ${step.step}`);
|
|
483
|
+
return lines.join('\n').trim();
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Resolve the on-disk path Codex writes generated images to. Format:
|
|
487
|
+
* `$CODEX_HOME/generated_images/<sessionId>/<call_id>.png`
|
|
488
|
+
*
|
|
489
|
+
* The developer-message Codex injects when its built-in `image_gen` tool fires
|
|
490
|
+
* documents this convention (`Generated images are saved to … as …/<id>.png`).
|
|
491
|
+
* We honour `$CODEX_HOME`; the SKILL.md prescribes `.png` as the only output
|
|
492
|
+
* format for the built-in tool.
|
|
493
|
+
*/
|
|
494
|
+
function codexImagePathFor(sessionId, callId) {
|
|
495
|
+
return path.join(codexHome(), 'generated_images', sessionId, `${callId}.png`);
|
|
496
|
+
}
|
|
497
|
+
/** Build an image MessageBlock from a Codex `image_generation_call` payload. */
|
|
498
|
+
function buildCodexImageBlock(sessionId, payload, phase) {
|
|
499
|
+
const callId = typeof payload?.id === 'string' ? payload.id
|
|
500
|
+
: typeof payload?.call_id === 'string' ? payload.call_id
|
|
501
|
+
: '';
|
|
502
|
+
if (!callId)
|
|
503
|
+
return null;
|
|
504
|
+
const filePath = codexImagePathFor(sessionId, callId);
|
|
505
|
+
const caption = typeof payload?.revised_prompt === 'string' ? payload.revised_prompt : undefined;
|
|
506
|
+
return attachAgentImage({ imagePath: filePath, caption, phase });
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Idempotently push the image MessageBlock for a Codex `image_gen` call to the
|
|
510
|
+
* stream state. Returns true if a block was emitted on this invocation.
|
|
511
|
+
*
|
|
512
|
+
* Codex emits image_generation_call across several inconsistent paths depending
|
|
513
|
+
* on the app-server build: `item/started`, `item/completed`, and
|
|
514
|
+
* `rawResponseItem/completed` may all fire — or some may be skipped (we've seen
|
|
515
|
+
* runs where only `image_generation_end` lands and the response item is frozen
|
|
516
|
+
* at status="generating", so no completion notification ever arrives). This
|
|
517
|
+
* helper lets every code path call into one place; the pendingImageGen map is
|
|
518
|
+
* the source of truth for "not yet emitted." On success we drop the pending
|
|
519
|
+
* entry and decrement the in-flight counter; on miss (file not yet on disk) we
|
|
520
|
+
* leave the entry so a later event — or the turn-end drain — can retry.
|
|
521
|
+
*/
|
|
522
|
+
function tryEmitCodexImageBlock(s, callId, revisedPrompt) {
|
|
523
|
+
if (!callId || !s.sessionId)
|
|
524
|
+
return false;
|
|
525
|
+
const pending = s.pendingImageGen.get(callId);
|
|
526
|
+
if (!pending)
|
|
527
|
+
return false;
|
|
528
|
+
const prompt = revisedPrompt ?? pending.revisedPrompt;
|
|
529
|
+
const block = buildCodexImageBlock(s.sessionId, { id: callId, revised_prompt: prompt });
|
|
530
|
+
if (!block)
|
|
531
|
+
return false;
|
|
532
|
+
s.pendingImageGen.delete(callId);
|
|
533
|
+
if (s.generatingImages > 0)
|
|
534
|
+
s.generatingImages--;
|
|
535
|
+
s.imageBlocks.push(block);
|
|
536
|
+
pushRecentActivity(s.recentNarrative, 'Image ready');
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
function buildCodexAssistantText(blocks) {
|
|
540
|
+
const finalText = blocks
|
|
541
|
+
.filter(block => block.type === 'text' && block.phase === 'final_answer' && block.content.trim())
|
|
542
|
+
.map(block => block.content.trim())
|
|
543
|
+
.join('\n\n')
|
|
544
|
+
.trim();
|
|
545
|
+
if (finalText)
|
|
546
|
+
return finalText;
|
|
547
|
+
const commentaryText = blocks
|
|
548
|
+
.filter(block => block.type === 'text' && block.content.trim())
|
|
549
|
+
.map(block => block.content.trim())
|
|
550
|
+
.join('\n\n')
|
|
551
|
+
.trim();
|
|
552
|
+
if (commentaryText)
|
|
553
|
+
return commentaryText;
|
|
554
|
+
const latestPlan = [...blocks].reverse().find(block => block.type === 'plan' && block.plan?.steps?.length);
|
|
555
|
+
if (latestPlan?.content.trim())
|
|
556
|
+
return latestPlan.content.trim();
|
|
557
|
+
const thinking = blocks.find(block => block.type === 'thinking' && block.content.trim())?.content.trim();
|
|
558
|
+
if (thinking)
|
|
559
|
+
return thinking;
|
|
560
|
+
const toolNames = blocks
|
|
561
|
+
.filter(block => block.type === 'tool_use')
|
|
562
|
+
.map(block => block.toolName?.trim() || '')
|
|
563
|
+
.filter(Boolean);
|
|
564
|
+
if (toolNames.length)
|
|
565
|
+
return toolNames.join(', ');
|
|
566
|
+
return blocks.find(block => block.type === 'tool_result' && block.content.trim())?.content.trim() || '';
|
|
567
|
+
}
|
|
568
|
+
function overlayCodexManagedPreview(workdir, sessionId, richMessages) {
|
|
569
|
+
const managed = findPikiloopSession(workdir, 'codex', sessionId);
|
|
570
|
+
if (!managed)
|
|
571
|
+
return richMessages;
|
|
572
|
+
const assistantIndex = [...richMessages]
|
|
573
|
+
.map((message, index) => ({ message, index }))
|
|
574
|
+
.reverse()
|
|
575
|
+
.find(entry => entry.message.role === 'assistant')?.index ?? -1;
|
|
576
|
+
if (assistantIndex < 0)
|
|
577
|
+
return richMessages;
|
|
578
|
+
const current = richMessages[assistantIndex];
|
|
579
|
+
const blocks = [...current.blocks];
|
|
580
|
+
let changed = false;
|
|
581
|
+
if (managed.lastThinking?.trim() && !blocks.some(block => block.type === 'thinking' && block.content.trim())) {
|
|
582
|
+
const thinkingBlock = { type: 'thinking', content: managed.lastThinking.trim() };
|
|
583
|
+
const insertIndex = blocks.findIndex(block => block.type === 'text' && block.phase === 'final_answer');
|
|
584
|
+
if (insertIndex >= 0)
|
|
585
|
+
blocks.splice(insertIndex, 0, thinkingBlock);
|
|
586
|
+
else
|
|
587
|
+
blocks.push(thinkingBlock);
|
|
588
|
+
changed = true;
|
|
589
|
+
}
|
|
590
|
+
if (managed.lastPlan?.steps?.length && !blocks.some(block => block.type === 'plan' && block.plan?.steps?.length)) {
|
|
591
|
+
const planBlock = {
|
|
592
|
+
type: 'plan',
|
|
593
|
+
content: formatCodexPlanSummary(managed.lastPlan),
|
|
594
|
+
plan: managed.lastPlan,
|
|
595
|
+
};
|
|
596
|
+
const insertIndex = blocks.findIndex(block => block.type === 'text' && block.phase === 'final_answer');
|
|
597
|
+
if (insertIndex >= 0)
|
|
598
|
+
blocks.splice(insertIndex, 0, planBlock);
|
|
599
|
+
else
|
|
600
|
+
blocks.push(planBlock);
|
|
601
|
+
changed = true;
|
|
602
|
+
}
|
|
603
|
+
if (!changed)
|
|
604
|
+
return richMessages;
|
|
605
|
+
const merged = [...richMessages];
|
|
606
|
+
merged[assistantIndex] = {
|
|
607
|
+
...current,
|
|
608
|
+
text: buildCodexAssistantText(blocks) || current.text,
|
|
609
|
+
blocks,
|
|
610
|
+
};
|
|
611
|
+
return merged;
|
|
612
|
+
}
|
|
613
|
+
function toAgentInteraction(method, params, requestId) {
|
|
614
|
+
if (method === 'item/tool/requestUserInput') {
|
|
615
|
+
const raw = Array.isArray(params?.questions) ? params.questions : [];
|
|
616
|
+
const questions = raw
|
|
617
|
+
.map((q) => ({
|
|
618
|
+
id: String(q?.id || ''),
|
|
619
|
+
header: String(q?.header || '') || 'Question',
|
|
620
|
+
prompt: String(q?.question || ''),
|
|
621
|
+
options: Array.isArray(q?.options)
|
|
622
|
+
? q.options.map((o) => ({
|
|
623
|
+
label: String(o?.label || ''),
|
|
624
|
+
description: String(o?.description || ''),
|
|
625
|
+
value: String(o?.label || ''),
|
|
626
|
+
}))
|
|
627
|
+
: null,
|
|
628
|
+
allowFreeform: !!q?.isOther || !Array.isArray(q?.options) || !q.options.length,
|
|
629
|
+
secret: !!q?.isSecret,
|
|
630
|
+
allowEmpty: true,
|
|
631
|
+
}))
|
|
632
|
+
.filter((q) => q.id && q.prompt);
|
|
633
|
+
return {
|
|
634
|
+
kind: 'user-input',
|
|
635
|
+
id: requestId,
|
|
636
|
+
title: 'User Input Required',
|
|
637
|
+
hint: 'Use the buttons when available. Reply with text when prompted.',
|
|
638
|
+
questions,
|
|
639
|
+
resolveWith: (answers) => ({
|
|
640
|
+
answers: Object.fromEntries(Object.entries(answers).map(([id, vals]) => [id, { answers: vals }])),
|
|
641
|
+
}),
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
return null;
|
|
645
|
+
}
|
|
646
|
+
function defaultAgentInteractionResponse(interaction) {
|
|
647
|
+
const answers = {};
|
|
648
|
+
for (const q of interaction.questions)
|
|
649
|
+
answers[q.id] = { answers: [] };
|
|
650
|
+
return { answers };
|
|
651
|
+
}
|
|
652
|
+
function defaultCodexServerRequestResponse(method) {
|
|
653
|
+
if (method === 'item/commandExecution/requestApproval')
|
|
654
|
+
return { decision: 'accept' };
|
|
655
|
+
if (method === 'item/fileChange/requestApproval')
|
|
656
|
+
return { decision: 'accept' };
|
|
657
|
+
if (method === 'item/permissions/requestApproval')
|
|
658
|
+
return { permissions: {}, scope: 'turn' };
|
|
659
|
+
if (method === 'item/tool/requestUserInput')
|
|
660
|
+
return { answers: {} };
|
|
661
|
+
return {};
|
|
662
|
+
}
|
|
663
|
+
function isCodexToolCallFailure(item) {
|
|
664
|
+
if (!item || !isCodexToolCallItem(item))
|
|
665
|
+
return false;
|
|
666
|
+
return item.success === false || !!item.error || item.status === 'failed' || item.status === 'error';
|
|
667
|
+
}
|
|
668
|
+
function buildCodexActivityPreview(s, opts = {}) {
|
|
669
|
+
const commentaryLines = opts.includeCommentary === false
|
|
670
|
+
? new Set(s.commentaryParts.map(text => normalizeActivityLine(text)).filter(Boolean))
|
|
671
|
+
: null;
|
|
672
|
+
const lines = commentaryLines
|
|
673
|
+
? s.recentNarrative.filter(line => !commentaryLines.has(line))
|
|
674
|
+
: [...s.recentNarrative];
|
|
675
|
+
if (opts.includeCommentary !== false) {
|
|
676
|
+
for (const text of s.commentaryByItem.values()) {
|
|
677
|
+
const cleaned = normalizeActivityLine(text);
|
|
678
|
+
if (cleaned && lines[lines.length - 1] !== cleaned)
|
|
679
|
+
lines.push(cleaned);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
for (const failure of s.recentFailures) {
|
|
683
|
+
if (lines[lines.length - 1] !== failure)
|
|
684
|
+
lines.push(failure);
|
|
685
|
+
}
|
|
686
|
+
if (s.completedCommands > 0)
|
|
687
|
+
lines.push(s.completedCommands === 1 ? 'Executed 1 command.' : `Executed ${s.completedCommands} commands.`);
|
|
688
|
+
for (const summary of s.activeCommands.values()) {
|
|
689
|
+
const running = summary.endsWith('...') ? summary : `${summary}...`;
|
|
690
|
+
if (lines[lines.length - 1] !== running)
|
|
691
|
+
lines.push(running);
|
|
692
|
+
}
|
|
693
|
+
for (const tool of s.activeToolCalls.values()) {
|
|
694
|
+
const running = tool.summary.endsWith('...') ? tool.summary : `${tool.summary}...`;
|
|
695
|
+
if (lines[lines.length - 1] !== running)
|
|
696
|
+
lines.push(running);
|
|
697
|
+
}
|
|
698
|
+
return lines.join('\n');
|
|
699
|
+
}
|
|
700
|
+
function buildCodexPreviewText(s) {
|
|
701
|
+
const commentary = [
|
|
702
|
+
...s.commentaryParts,
|
|
703
|
+
...s.commentaryByItem.values(),
|
|
704
|
+
]
|
|
705
|
+
.map(text => text.trim())
|
|
706
|
+
.filter(Boolean)
|
|
707
|
+
.join('\n\n')
|
|
708
|
+
.trim();
|
|
709
|
+
const finalText = s.text.trim();
|
|
710
|
+
if (commentary && finalText)
|
|
711
|
+
return `${commentary}\n\n${finalText}`;
|
|
712
|
+
return commentary || finalText;
|
|
713
|
+
}
|
|
714
|
+
// ---------------------------------------------------------------------------
|
|
715
|
+
// Token usage
|
|
716
|
+
// ---------------------------------------------------------------------------
|
|
717
|
+
function buildCodexCumulativeUsage(raw) {
|
|
718
|
+
if (!raw || typeof raw !== 'object')
|
|
719
|
+
return null;
|
|
720
|
+
const input = numberOrNull(raw.inputTokens, raw.input_tokens);
|
|
721
|
+
const output = numberOrNull(raw.outputTokens, raw.output_tokens);
|
|
722
|
+
const cached = numberOrNull(raw.cachedInputTokens, raw.cached_input_tokens);
|
|
723
|
+
if (input == null && output == null && cached == null)
|
|
724
|
+
return null;
|
|
725
|
+
return { input: input ?? 0, output: output ?? 0, cached: cached ?? 0 };
|
|
726
|
+
}
|
|
727
|
+
function buildCodexContextUsage(raw) {
|
|
728
|
+
if (!raw || typeof raw !== 'object')
|
|
729
|
+
return null;
|
|
730
|
+
const total = numberOrNull(raw.totalTokens, raw.total_tokens);
|
|
731
|
+
if (total != null && total >= 0)
|
|
732
|
+
return total;
|
|
733
|
+
const input = numberOrNull(raw.inputTokens, raw.input_tokens);
|
|
734
|
+
const output = numberOrNull(raw.outputTokens, raw.output_tokens);
|
|
735
|
+
if (input != null && output != null)
|
|
736
|
+
return input + output;
|
|
737
|
+
if (input != null)
|
|
738
|
+
return input;
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
function applyCodexTokenUsage(s, rawUsage, prev) {
|
|
742
|
+
if (!rawUsage || typeof rawUsage !== 'object')
|
|
743
|
+
return;
|
|
744
|
+
const info = rawUsage.info && typeof rawUsage.info === 'object' ? rawUsage.info : rawUsage;
|
|
745
|
+
const last = info.last ?? info.lastTokenUsage ?? info.last_token_usage ?? rawUsage.last;
|
|
746
|
+
const lastInput = numberOrNull(last?.inputTokens, last?.input_tokens);
|
|
747
|
+
const lastOutput = numberOrNull(last?.outputTokens, last?.output_tokens);
|
|
748
|
+
const lastCached = numberOrNull(last?.cachedInputTokens, last?.cached_input_tokens);
|
|
749
|
+
const lastCacheCreation = numberOrNull(last?.cacheCreationInputTokens, last?.cache_creation_input_tokens);
|
|
750
|
+
if (lastInput != null)
|
|
751
|
+
s.inputTokens = lastInput;
|
|
752
|
+
if (lastOutput != null)
|
|
753
|
+
s.outputTokens = lastOutput;
|
|
754
|
+
if (lastCached != null)
|
|
755
|
+
s.cachedInputTokens = lastCached;
|
|
756
|
+
if (lastCacheCreation != null)
|
|
757
|
+
s.cacheCreationInputTokens = lastCacheCreation;
|
|
758
|
+
const lastContextUsage = buildCodexContextUsage(last);
|
|
759
|
+
if (lastContextUsage != null)
|
|
760
|
+
s.contextUsedTokens = lastContextUsage;
|
|
761
|
+
const totalUsage = info.total ?? info.totalTokenUsage ?? info.total_token_usage ?? rawUsage.total ?? rawUsage;
|
|
762
|
+
const total = buildCodexCumulativeUsage(totalUsage);
|
|
763
|
+
if (total) {
|
|
764
|
+
s.codexCumulative = total;
|
|
765
|
+
if (lastInput == null)
|
|
766
|
+
s.inputTokens = prev ? Math.max(0, total.input - prev.input) : total.input;
|
|
767
|
+
if (lastOutput == null)
|
|
768
|
+
s.outputTokens = prev ? Math.max(0, total.output - prev.output) : total.output;
|
|
769
|
+
if (lastCached == null)
|
|
770
|
+
s.cachedInputTokens = prev ? Math.max(0, total.cached - prev.cached) : total.cached;
|
|
771
|
+
}
|
|
772
|
+
// NOTE: do NOT set s.contextUsedTokens from cumulative totals —
|
|
773
|
+
// those counters span the full thread, not the current turn. Use the per-turn
|
|
774
|
+
// `last` usage only. `cached_input_tokens` is already a subset of
|
|
775
|
+
// `input_tokens`, so adding it again inflates the context percentage.
|
|
776
|
+
if (!s.byokContextWindow) {
|
|
777
|
+
const contextWindow = numberOrNull(info.modelContextWindow, info.model_context_window, rawUsage.modelContextWindow, rawUsage.model_context_window);
|
|
778
|
+
if (contextWindow != null && contextWindow > 0)
|
|
779
|
+
s.contextWindow = contextWindow;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
// ---------------------------------------------------------------------------
|
|
783
|
+
// Turn input
|
|
784
|
+
// ---------------------------------------------------------------------------
|
|
785
|
+
export function buildCodexTurnInput(prompt, attachments) {
|
|
786
|
+
const input = [];
|
|
787
|
+
for (const filePath of attachments) {
|
|
788
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
789
|
+
if (IMAGE_EXTS.has(ext)) {
|
|
790
|
+
input.push({ type: 'localImage', path: filePath });
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
input.push({ type: 'text', text: `[Attached file: ${filePath}]` });
|
|
794
|
+
}
|
|
795
|
+
input.push({ type: 'text', text: prompt });
|
|
796
|
+
return input;
|
|
797
|
+
}
|
|
798
|
+
function createCodexStreamState(opts) {
|
|
799
|
+
// BYOK: lock in the provider-cached context window so codex's own (often
|
|
800
|
+
// wrong, model-dependent) `model_context_window` reports get ignored later.
|
|
801
|
+
const byokWindow = opts.byokContextWindow && opts.byokContextWindow > 0
|
|
802
|
+
? opts.byokContextWindow
|
|
803
|
+
: null;
|
|
804
|
+
const byokProvider = opts.byokProviderName || null;
|
|
805
|
+
return {
|
|
806
|
+
sessionId: opts.sessionId,
|
|
807
|
+
text: '', thinking: '', activity: '', msgs: [], thinkParts: [],
|
|
808
|
+
model: opts.model, thinkingEffort: opts.thinkingEffort,
|
|
809
|
+
inputTokens: null, outputTokens: null,
|
|
810
|
+
cachedInputTokens: null, cacheCreationInputTokens: null,
|
|
811
|
+
contextWindow: byokWindow, contextUsedTokens: null,
|
|
812
|
+
byokContextWindow: byokWindow,
|
|
813
|
+
byokProviderName: byokProvider,
|
|
814
|
+
codexCumulative: null,
|
|
815
|
+
turnId: null, turnStatus: null, turnError: null,
|
|
816
|
+
messagePhases: new Map(),
|
|
817
|
+
deltaSeenForItem: new Set(),
|
|
818
|
+
commentaryByItem: new Map(),
|
|
819
|
+
commentaryParts: [],
|
|
820
|
+
activeCommands: new Map(),
|
|
821
|
+
activeToolCalls: new Map(),
|
|
822
|
+
recentNarrative: [], recentFailures: [],
|
|
823
|
+
completedCommands: 0,
|
|
824
|
+
plan: null,
|
|
825
|
+
imageBlocks: [],
|
|
826
|
+
pendingImageGen: new Map(),
|
|
827
|
+
generatingImages: 0,
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
function codexErrorResult(error, start, sessionId, model, thinkingEffort) {
|
|
831
|
+
return {
|
|
832
|
+
ok: false, message: error, thinking: null,
|
|
833
|
+
plan: null,
|
|
834
|
+
sessionId, workspacePath: null,
|
|
835
|
+
model, thinkingEffort,
|
|
836
|
+
elapsedS: (Date.now() - start) / 1000, inputTokens: null, outputTokens: null,
|
|
837
|
+
cachedInputTokens: null, cacheCreationInputTokens: null, contextWindow: null,
|
|
838
|
+
contextUsedTokens: null, contextPercent: null, error,
|
|
839
|
+
codexCumulative: null, stopReason: null, incomplete: true, activity: null,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
// ---------------------------------------------------------------------------
|
|
843
|
+
// Stream notification handler (extracted from doCodexStream)
|
|
844
|
+
// ---------------------------------------------------------------------------
|
|
845
|
+
function handleCodexNotification(method, params, s, opts, deadline, emit, hardTimer, settleTurnDone, publishTurnControl) {
|
|
846
|
+
if (Date.now() > deadline)
|
|
847
|
+
return;
|
|
848
|
+
if (params.threadId !== s.sessionId) {
|
|
849
|
+
// Only turn/started and model/rerouted are checked below; all others already filter on threadId.
|
|
850
|
+
if (method !== 'turn/started' && method !== 'model/rerouted')
|
|
851
|
+
return;
|
|
852
|
+
if (params.threadId !== s.sessionId)
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
switch (method) {
|
|
856
|
+
case 'item/started':
|
|
857
|
+
handleItemStarted(params.item || {}, s, emit);
|
|
858
|
+
return;
|
|
859
|
+
case 'item/agentMessage/delta':
|
|
860
|
+
handleAgentMessageDelta(params, s, emit);
|
|
861
|
+
return;
|
|
862
|
+
case 'item/reasoning/textDelta':
|
|
863
|
+
case 'item/reasoning/summaryTextDelta':
|
|
864
|
+
s.thinking += params.delta || '';
|
|
865
|
+
emit();
|
|
866
|
+
return;
|
|
867
|
+
case 'item/completed':
|
|
868
|
+
handleItemCompleted(params.item || {}, s, emit);
|
|
869
|
+
return;
|
|
870
|
+
case 'rawResponseItem/completed':
|
|
871
|
+
handleRawResponseItemCompleted(params.item || {}, s, emit);
|
|
872
|
+
return;
|
|
873
|
+
case 'thread/tokenUsage/updated':
|
|
874
|
+
applyCodexTokenUsage(s, params.tokenUsage, opts.codexPrevCumulative);
|
|
875
|
+
emit();
|
|
876
|
+
return;
|
|
877
|
+
case 'turn/plan/updated':
|
|
878
|
+
handleTurnPlanUpdated(params, s, emit);
|
|
879
|
+
return;
|
|
880
|
+
case 'serverRequest/resolved': {
|
|
881
|
+
const requestId = String(params.requestId || '');
|
|
882
|
+
if (requestId)
|
|
883
|
+
pushRecentActivity(s.recentNarrative, 'Human input resolved');
|
|
884
|
+
emit();
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
case 'turn/completed': {
|
|
888
|
+
const turn = params.turn || {};
|
|
889
|
+
applyCodexTokenUsage(s, params.tokenUsage || turn.tokenUsage || turn.usage, opts.codexPrevCumulative);
|
|
890
|
+
s.turnStatus = turn.status ?? null;
|
|
891
|
+
if (turn.error)
|
|
892
|
+
s.turnError = turn.error.message || turn.error.code || JSON.stringify(turn.error);
|
|
893
|
+
s.turnId = turn.id ?? s.turnId;
|
|
894
|
+
clearTimeout(hardTimer);
|
|
895
|
+
settleTurnDone?.();
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
case 'turn/started':
|
|
899
|
+
s.turnId = params.turn?.id ?? null;
|
|
900
|
+
publishTurnControl?.();
|
|
901
|
+
return;
|
|
902
|
+
case 'model/rerouted':
|
|
903
|
+
s.model = params.model ?? s.model;
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
function handleItemStarted(item, s, emit) {
|
|
908
|
+
if (item.type === 'agentMessage' && item.id) {
|
|
909
|
+
const phase = item.phase || 'final_answer';
|
|
910
|
+
s.messagePhases.set(item.id, phase);
|
|
911
|
+
if (phase !== 'final_answer') {
|
|
912
|
+
s.commentaryByItem.set(item.id, item.text || '');
|
|
913
|
+
emit();
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
if (item.type === 'commandExecution' && item.id && item.command) {
|
|
917
|
+
const summary = summarizeCodexCommand(item.command);
|
|
918
|
+
pushRecentActivity(s.recentNarrative, summary);
|
|
919
|
+
s.activeCommands.set(item.id, summary);
|
|
920
|
+
emit();
|
|
921
|
+
}
|
|
922
|
+
if (item.id && isCodexToolCallItem(item)) {
|
|
923
|
+
const toolCall = summarizeCodexToolCall(item);
|
|
924
|
+
if (toolCall) {
|
|
925
|
+
s.activeToolCalls.set(item.id, toolCall);
|
|
926
|
+
emit();
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
// Codex's built-in `image_gen` tool surfaces as a distinct item type. Track
|
|
930
|
+
// the in-flight count so renderers can show "Generating image…" while the
|
|
931
|
+
// bytes are being written. Item id naming differs across Codex versions
|
|
932
|
+
// (`imageGenerationCall` / `image_generation_call`); accept either form.
|
|
933
|
+
if (item.id && (item.type === 'imageGenerationCall' || item.type === 'image_generation_call')) {
|
|
934
|
+
if (!s.pendingImageGen.has(item.id))
|
|
935
|
+
s.generatingImages++;
|
|
936
|
+
s.pendingImageGen.set(item.id, {
|
|
937
|
+
revisedPrompt: typeof item.revisedPrompt === 'string' ? item.revisedPrompt
|
|
938
|
+
: typeof item.revised_prompt === 'string' ? item.revised_prompt : undefined,
|
|
939
|
+
});
|
|
940
|
+
pushRecentActivity(s.recentNarrative, 'Generating image...');
|
|
941
|
+
// Some codex builds never fire a "completed" event for image_generation_call
|
|
942
|
+
// (rollout shows the item frozen at status="generating"). The PNG is on
|
|
943
|
+
// disk by the time item/started lands, so try an opportunistic emit here;
|
|
944
|
+
// tryEmit is a no-op when the file isn't ready yet — handleItemCompleted /
|
|
945
|
+
// rawResponseItem/completed / the turn-end drain will pick it up later.
|
|
946
|
+
tryEmitCodexImageBlock(s, item.id);
|
|
947
|
+
emit();
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
function handleAgentMessageDelta(params, s, emit) {
|
|
951
|
+
const delta = params.delta || '';
|
|
952
|
+
const phase = params.itemId ? (s.messagePhases.get(params.itemId) || 'final_answer') : 'final_answer';
|
|
953
|
+
if (phase === 'final_answer') {
|
|
954
|
+
s.text += delta;
|
|
955
|
+
if (params.itemId)
|
|
956
|
+
s.deltaSeenForItem.add(params.itemId);
|
|
957
|
+
}
|
|
958
|
+
else if (params.itemId) {
|
|
959
|
+
const prev = s.commentaryByItem.get(params.itemId) || '';
|
|
960
|
+
s.commentaryByItem.set(params.itemId, prev + delta);
|
|
961
|
+
}
|
|
962
|
+
emit();
|
|
963
|
+
}
|
|
964
|
+
function handleItemCompleted(item, s, emit) {
|
|
965
|
+
if (item.type === 'agentMessage' && item.id) {
|
|
966
|
+
handleCompletedAgentMessage(item, s, emit);
|
|
967
|
+
}
|
|
968
|
+
if (item.type === 'reasoning') {
|
|
969
|
+
const parts = [...(item.summary || []), ...(item.content || [])];
|
|
970
|
+
const text = parts.join('\n').trim();
|
|
971
|
+
if (text) {
|
|
972
|
+
s.thinkParts.push(text);
|
|
973
|
+
emit();
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
if (item.type === 'commandExecution' && item.id) {
|
|
977
|
+
handleCompletedCommand(item, s, emit);
|
|
978
|
+
}
|
|
979
|
+
if (item.id && isCodexToolCallItem(item)) {
|
|
980
|
+
handleCompletedToolCall(item, s, emit);
|
|
981
|
+
}
|
|
982
|
+
if (item.type === 'fileChange') {
|
|
983
|
+
pushRecentActivity(s.recentNarrative, summarizeCodexFileChange(item));
|
|
984
|
+
emit();
|
|
985
|
+
}
|
|
986
|
+
if (item.id && (item.type === 'imageGenerationCall' || item.type === 'image_generation_call')) {
|
|
987
|
+
const revised = typeof item.revised_prompt === 'string' ? item.revised_prompt
|
|
988
|
+
: typeof item.revisedPrompt === 'string' ? item.revisedPrompt : undefined;
|
|
989
|
+
if (tryEmitCodexImageBlock(s, item.id, revised))
|
|
990
|
+
emit();
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
function handleRawResponseItemCompleted(item, s, emit) {
|
|
994
|
+
if (item?.type === 'reasoning') {
|
|
995
|
+
const summary = Array.isArray(item.summary)
|
|
996
|
+
? item.summary
|
|
997
|
+
.map((entry) => (typeof entry === 'string' ? entry : entry?.text || ''))
|
|
998
|
+
.filter(Boolean)
|
|
999
|
+
.join('\n')
|
|
1000
|
+
.trim()
|
|
1001
|
+
: '';
|
|
1002
|
+
if (summary) {
|
|
1003
|
+
s.thinkParts.push(summary);
|
|
1004
|
+
emit();
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
// image_generation_call: Codex's built-in image_gen has just finished writing
|
|
1009
|
+
// the file at $CODEX_HOME/generated_images/<sessionId>/<id>.png. Read it into
|
|
1010
|
+
// an image MessageBlock so the bot's final-reply path can dispatch it to IM
|
|
1011
|
+
// channels and the dashboard renders it inline.
|
|
1012
|
+
if (item?.type === 'image_generation_call' || item?.type === 'imageGenerationCall') {
|
|
1013
|
+
const callId = typeof item.id === 'string' ? item.id
|
|
1014
|
+
: typeof item.call_id === 'string' ? item.call_id : '';
|
|
1015
|
+
if (callId) {
|
|
1016
|
+
// Merge revised_prompt from this event with anything we stashed earlier —
|
|
1017
|
+
// different Codex builds attach it on different events. Idempotent helper
|
|
1018
|
+
// handles the dedupe against item/started + handleItemCompleted paths.
|
|
1019
|
+
const revisedPrompt = typeof item.revised_prompt === 'string' ? item.revised_prompt
|
|
1020
|
+
: typeof item.revisedPrompt === 'string' ? item.revisedPrompt
|
|
1021
|
+
: undefined;
|
|
1022
|
+
tryEmitCodexImageBlock(s, callId, revisedPrompt);
|
|
1023
|
+
emit();
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
const summary = summarizeCodexRawResponseItem(item);
|
|
1028
|
+
if (!summary)
|
|
1029
|
+
return;
|
|
1030
|
+
pushRecentActivity(s.recentNarrative, summary);
|
|
1031
|
+
emit();
|
|
1032
|
+
}
|
|
1033
|
+
function handleCompletedAgentMessage(item, s, emit) {
|
|
1034
|
+
const phase = item.phase || s.messagePhases.get(item.id) || 'final_answer';
|
|
1035
|
+
if (phase === 'final_answer') {
|
|
1036
|
+
const text = item.text?.trim();
|
|
1037
|
+
if (text) {
|
|
1038
|
+
s.msgs.push(text);
|
|
1039
|
+
// When Codex emits the final-answer body without intervening deltas
|
|
1040
|
+
// (short replies, certain provider configs), `s.text` is empty and the
|
|
1041
|
+
// preview would stay blank until doCodexStream's turn-end backfill.
|
|
1042
|
+
// Append the completed body now so the live stream catches up. The
|
|
1043
|
+
// delta-seen set tells us whether we'd be duplicating content already
|
|
1044
|
+
// accumulated via item/agentMessage/delta.
|
|
1045
|
+
const alreadyStreamed = item.id && s.deltaSeenForItem.has(item.id);
|
|
1046
|
+
if (!alreadyStreamed) {
|
|
1047
|
+
s.text = s.text.trim() ? `${s.text.trim()}\n\n${text}` : text;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
emit();
|
|
1051
|
+
}
|
|
1052
|
+
else {
|
|
1053
|
+
const commentary = item.text?.trim() || s.commentaryByItem.get(item.id)?.trim() || '';
|
|
1054
|
+
if (commentary) {
|
|
1055
|
+
s.commentaryParts.push(commentary);
|
|
1056
|
+
pushRecentActivity(s.recentNarrative, commentary);
|
|
1057
|
+
}
|
|
1058
|
+
s.commentaryByItem.delete(item.id);
|
|
1059
|
+
emit();
|
|
1060
|
+
}
|
|
1061
|
+
if (item.id)
|
|
1062
|
+
s.deltaSeenForItem.delete(item.id);
|
|
1063
|
+
s.messagePhases.delete(item.id);
|
|
1064
|
+
}
|
|
1065
|
+
function handleCompletedCommand(item, s, emit) {
|
|
1066
|
+
const cmd = item.command || s.activeCommands.get(item.id) || '';
|
|
1067
|
+
s.activeCommands.delete(item.id);
|
|
1068
|
+
if (cmd) {
|
|
1069
|
+
const exitCode = typeof item.exitCode === 'number' ? item.exitCode : null;
|
|
1070
|
+
if (exitCode != null && exitCode !== 0)
|
|
1071
|
+
pushRecentActivity(s.recentFailures, `Command failed (${exitCode}): ${cmd}`, 4);
|
|
1072
|
+
else
|
|
1073
|
+
s.completedCommands++;
|
|
1074
|
+
}
|
|
1075
|
+
emit();
|
|
1076
|
+
}
|
|
1077
|
+
function handleCompletedToolCall(item, s, emit) {
|
|
1078
|
+
const toolCall = s.activeToolCalls.get(item.id) || summarizeCodexToolCall(item);
|
|
1079
|
+
s.activeToolCalls.delete(item.id);
|
|
1080
|
+
if (toolCall) {
|
|
1081
|
+
if (isCodexToolCallFailure(item))
|
|
1082
|
+
pushRecentActivity(s.recentFailures, `${toolCall.summary} failed`, 4);
|
|
1083
|
+
else if (toolCall.kind !== 'apply_patch')
|
|
1084
|
+
pushRecentActivity(s.recentNarrative, `${toolCall.summary} done`);
|
|
1085
|
+
}
|
|
1086
|
+
emit();
|
|
1087
|
+
}
|
|
1088
|
+
function handleTurnPlanUpdated(params, s, emit) {
|
|
1089
|
+
const rawPlan = Array.isArray(params.plan) ? params.plan : [];
|
|
1090
|
+
s.plan = {
|
|
1091
|
+
explanation: typeof params.explanation === 'string' ? params.explanation : null,
|
|
1092
|
+
steps: rawPlan
|
|
1093
|
+
.map((entry) => ({
|
|
1094
|
+
step: typeof entry?.step === 'string' ? entry.step : '',
|
|
1095
|
+
status: entry?.status === 'completed' || entry?.status === 'pending' || entry?.status === 'inProgress' ? entry.status : 'pending',
|
|
1096
|
+
}))
|
|
1097
|
+
.filter((entry) => entry.step.trim()),
|
|
1098
|
+
};
|
|
1099
|
+
emit();
|
|
1100
|
+
}
|
|
1101
|
+
// ---------------------------------------------------------------------------
|
|
1102
|
+
// Stream request handler (extracted from doCodexStream)
|
|
1103
|
+
// ---------------------------------------------------------------------------
|
|
1104
|
+
async function handleCodexRequest(method, params, requestId, s, opts, emit) {
|
|
1105
|
+
const interaction = toAgentInteraction(method, params, requestId);
|
|
1106
|
+
if (!interaction)
|
|
1107
|
+
return defaultCodexServerRequestResponse(method);
|
|
1108
|
+
pushRecentActivity(s.recentNarrative, interaction.kind === 'user-input' ? 'Waiting for user input' : 'Waiting for approval');
|
|
1109
|
+
emit();
|
|
1110
|
+
try {
|
|
1111
|
+
if (opts.onInteraction) {
|
|
1112
|
+
const response = await opts.onInteraction(interaction);
|
|
1113
|
+
return response ?? defaultAgentInteractionResponse(interaction);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
catch (error) {
|
|
1117
|
+
pushRecentActivity(s.recentFailures, `Human input failed: ${shortValue(error?.message || error, 120)}`, 4);
|
|
1118
|
+
emit();
|
|
1119
|
+
}
|
|
1120
|
+
return defaultAgentInteractionResponse(interaction);
|
|
1121
|
+
}
|
|
1122
|
+
// ---------------------------------------------------------------------------
|
|
1123
|
+
// Stream via app-server
|
|
1124
|
+
// ---------------------------------------------------------------------------
|
|
1125
|
+
export async function doCodexStream(opts) {
|
|
1126
|
+
const start = Date.now();
|
|
1127
|
+
const srv = new CodexAppServer();
|
|
1128
|
+
let timedOut = false;
|
|
1129
|
+
let interrupted = false;
|
|
1130
|
+
let unsubscribeNotifications = () => { };
|
|
1131
|
+
let unsubscribeRequests = () => { };
|
|
1132
|
+
let settleTurnDone = null;
|
|
1133
|
+
let emitPreview = () => { };
|
|
1134
|
+
let publishedTurnControl = false;
|
|
1135
|
+
try {
|
|
1136
|
+
const config = [];
|
|
1137
|
+
if (opts.codexExtraArgs?.length) {
|
|
1138
|
+
for (let i = 0; i < opts.codexExtraArgs.length; i++) {
|
|
1139
|
+
if (opts.codexExtraArgs[i] === '-c' && opts.codexExtraArgs[i + 1])
|
|
1140
|
+
config.push(opts.codexExtraArgs[++i]);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
// Enable codex's native `/goal` feature so `thread/goal/*` RPCs work and
|
|
1144
|
+
// the model gets the native `create_goal` / `update_goal` / `get_goal`
|
|
1145
|
+
// tools + continuation engine. User-provided -c overrides win.
|
|
1146
|
+
if (!config.some(entry => /^features\.goals\s*=/.test(entry))) {
|
|
1147
|
+
config.push('features.goals=true');
|
|
1148
|
+
}
|
|
1149
|
+
if (!(await srv.ensureRunning(config, opts.extraEnv))) {
|
|
1150
|
+
return codexErrorResult('Failed to start codex app-server.', start, opts.sessionId, opts.model, opts.thinkingEffort);
|
|
1151
|
+
}
|
|
1152
|
+
const s = createCodexStreamState(opts);
|
|
1153
|
+
const publishTurnControl = () => {
|
|
1154
|
+
if (publishedTurnControl || !opts.onCodexTurnReady || !s.sessionId || !s.turnId)
|
|
1155
|
+
return;
|
|
1156
|
+
publishedTurnControl = true;
|
|
1157
|
+
try {
|
|
1158
|
+
const control = {
|
|
1159
|
+
threadId: s.sessionId,
|
|
1160
|
+
turnId: s.turnId,
|
|
1161
|
+
steer: async (prompt, attachments = []) => {
|
|
1162
|
+
if (!s.sessionId || !s.turnId)
|
|
1163
|
+
return false;
|
|
1164
|
+
const expectedTurnId = s.turnId;
|
|
1165
|
+
const clippedPrompt = prompt.slice(0, 200);
|
|
1166
|
+
agentLog(`[codex-rpc] turn/steer turn=${expectedTurnId} prompt="${clippedPrompt}${prompt.length > 200 ? '…' : ''}"`);
|
|
1167
|
+
const steerResp = await srv.call('turn/steer', {
|
|
1168
|
+
threadId: s.sessionId,
|
|
1169
|
+
expectedTurnId,
|
|
1170
|
+
input: buildCodexTurnInput(prompt, attachments),
|
|
1171
|
+
}, 30_000);
|
|
1172
|
+
if (steerResp.error) {
|
|
1173
|
+
const errMsg = steerResp.error.message || 'turn/steer failed';
|
|
1174
|
+
agentWarn(`[codex-rpc] turn/steer error: ${errMsg}`);
|
|
1175
|
+
pushRecentActivity(s.recentFailures, `Steer failed: ${shortValue(errMsg, 120)}`, 4);
|
|
1176
|
+
emitPreview();
|
|
1177
|
+
return false;
|
|
1178
|
+
}
|
|
1179
|
+
s.turnId = steerResp.result?.turnId ?? s.turnId;
|
|
1180
|
+
pushRecentActivity(s.recentNarrative, 'Applied steer input');
|
|
1181
|
+
emitPreview();
|
|
1182
|
+
return true;
|
|
1183
|
+
},
|
|
1184
|
+
};
|
|
1185
|
+
opts.onSteerReady?.(control.steer);
|
|
1186
|
+
opts.onCodexTurnReady?.(control);
|
|
1187
|
+
}
|
|
1188
|
+
catch (error) {
|
|
1189
|
+
agentWarn(`[codex-rpc] onCodexTurnReady error: ${error?.message || error}`);
|
|
1190
|
+
}
|
|
1191
|
+
};
|
|
1192
|
+
// thread/start or thread/resume
|
|
1193
|
+
let threadResp;
|
|
1194
|
+
const threadParams = {
|
|
1195
|
+
cwd: opts.workdir,
|
|
1196
|
+
model: opts.codexModel || null,
|
|
1197
|
+
approvalPolicy: opts.codexFullAccess ? 'never' : undefined,
|
|
1198
|
+
sandbox: opts.codexFullAccess ? 'danger-full-access' : undefined,
|
|
1199
|
+
developerInstructions: opts.codexDeveloperInstructions || undefined,
|
|
1200
|
+
};
|
|
1201
|
+
if (opts.sessionId) {
|
|
1202
|
+
agentLog(`[codex-rpc] thread/resume id=${opts.sessionId}`);
|
|
1203
|
+
threadResp = await srv.call('thread/resume', { threadId: opts.sessionId, ...threadParams }, 60_000);
|
|
1204
|
+
}
|
|
1205
|
+
else {
|
|
1206
|
+
agentLog(`[codex-rpc] thread/start cwd=${opts.workdir} model=${opts.codexModel || '(default)'}`);
|
|
1207
|
+
threadResp = await srv.call('thread/start', threadParams, 60_000);
|
|
1208
|
+
}
|
|
1209
|
+
if (threadResp.error) {
|
|
1210
|
+
const errMsg = threadResp.error.message || 'thread/start failed';
|
|
1211
|
+
agentWarn(`[codex-rpc] thread error: ${errMsg}`);
|
|
1212
|
+
return codexErrorResult(errMsg, start, opts.sessionId, opts.model, opts.thinkingEffort);
|
|
1213
|
+
}
|
|
1214
|
+
const threadResult = threadResp.result;
|
|
1215
|
+
s.sessionId = threadResult.thread?.id ?? s.sessionId;
|
|
1216
|
+
s.model = threadResult.model ?? s.model;
|
|
1217
|
+
if (s.sessionId) {
|
|
1218
|
+
try {
|
|
1219
|
+
opts.onSessionId?.(s.sessionId);
|
|
1220
|
+
}
|
|
1221
|
+
catch (error) {
|
|
1222
|
+
agentWarn(`[codex-rpc] onSessionId error: ${error?.message || error}`);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
agentLog(`[codex-rpc] thread ready: id=${s.sessionId} model=${s.model}`);
|
|
1226
|
+
// turn/start
|
|
1227
|
+
const input = buildCodexTurnInput(opts.prompt, opts.attachments || []);
|
|
1228
|
+
const deadline = start + opts.timeout * 1000;
|
|
1229
|
+
const turnDone = new Promise((resolve) => {
|
|
1230
|
+
let settled = false;
|
|
1231
|
+
settleTurnDone = () => {
|
|
1232
|
+
if (settled)
|
|
1233
|
+
return;
|
|
1234
|
+
settled = true;
|
|
1235
|
+
settleTurnDone = null;
|
|
1236
|
+
resolve();
|
|
1237
|
+
};
|
|
1238
|
+
const hardTimer = setTimeout(() => {
|
|
1239
|
+
timedOut = true;
|
|
1240
|
+
agentWarn('[codex-rpc] timeout: interrupting turn');
|
|
1241
|
+
if (s.turnId && s.sessionId)
|
|
1242
|
+
srv.call('turn/interrupt', { threadId: s.sessionId, turnId: s.turnId }).catch(() => { });
|
|
1243
|
+
settleTurnDone?.();
|
|
1244
|
+
}, opts.timeout * 1000 + CODEX_STREAM_HARD_KILL_GRACE_MS);
|
|
1245
|
+
const emit = () => {
|
|
1246
|
+
s.activity = buildCodexActivityPreview(s);
|
|
1247
|
+
const previewText = buildCodexPreviewText(s);
|
|
1248
|
+
const previewActivity = buildCodexActivityPreview(s, { includeCommentary: false });
|
|
1249
|
+
opts.onText(previewText, s.thinking, previewActivity, buildStreamPreviewMeta(s), s.plan);
|
|
1250
|
+
};
|
|
1251
|
+
emitPreview = emit;
|
|
1252
|
+
unsubscribeNotifications = srv.onNotification((method, params) => {
|
|
1253
|
+
handleCodexNotification(method, params, s, opts, deadline, emit, hardTimer, settleTurnDone, publishTurnControl);
|
|
1254
|
+
});
|
|
1255
|
+
unsubscribeRequests = srv.onRequest((method, params, requestId) => {
|
|
1256
|
+
return handleCodexRequest(method, params, requestId, s, opts, emit);
|
|
1257
|
+
});
|
|
1258
|
+
});
|
|
1259
|
+
const abortStream = () => {
|
|
1260
|
+
if (interrupted)
|
|
1261
|
+
return;
|
|
1262
|
+
interrupted = true;
|
|
1263
|
+
s.turnStatus = s.turnStatus || 'interrupted';
|
|
1264
|
+
s.turnError = s.turnError || 'Interrupted by user.';
|
|
1265
|
+
agentWarn(`[codex-rpc] abort requested thread=${s.sessionId || '?'} turn=${s.turnId || '?'}`);
|
|
1266
|
+
if (s.turnId && s.sessionId) {
|
|
1267
|
+
// Send turn/interrupt and wait for Codex to acknowledge before settling.
|
|
1268
|
+
// Don't kill the process here — let the finally block handle it after
|
|
1269
|
+
// Codex has had time to persist the interrupted session state.
|
|
1270
|
+
srv.call('turn/interrupt', { threadId: s.sessionId, turnId: s.turnId }, 5_000)
|
|
1271
|
+
.finally(() => settleTurnDone?.());
|
|
1272
|
+
}
|
|
1273
|
+
else {
|
|
1274
|
+
srv.kill();
|
|
1275
|
+
settleTurnDone?.();
|
|
1276
|
+
}
|
|
1277
|
+
};
|
|
1278
|
+
if (opts.abortSignal?.aborted)
|
|
1279
|
+
abortStream();
|
|
1280
|
+
opts.abortSignal?.addEventListener('abort', abortStream, { once: true });
|
|
1281
|
+
// Log equivalent CLI command for reproducibility
|
|
1282
|
+
const cliParts = ['codex'];
|
|
1283
|
+
if (opts.codexModel)
|
|
1284
|
+
cliParts.push('--model', opts.codexModel);
|
|
1285
|
+
if (opts.codexFullAccess)
|
|
1286
|
+
cliParts.push('--full-access');
|
|
1287
|
+
const effort = mapEffort(opts.thinkingEffort);
|
|
1288
|
+
if (effort)
|
|
1289
|
+
cliParts.push('--effort', effort);
|
|
1290
|
+
if (opts.sessionId)
|
|
1291
|
+
cliParts.push('--resume', opts.sessionId);
|
|
1292
|
+
if (opts.codexExtraArgs?.length)
|
|
1293
|
+
cliParts.push(...opts.codexExtraArgs);
|
|
1294
|
+
cliParts.push('-p', `"${opts.prompt.slice(0, 300)}${opts.prompt.length > 300 ? '…' : ''}"`);
|
|
1295
|
+
agentLog(`[codex-rpc] full command: cd ${Q(opts.workdir)} && ${cliParts.join(' ')}`);
|
|
1296
|
+
agentLog(`[codex-rpc] turn/start prompt="${opts.prompt.slice(0, 300)}${opts.prompt.length > 300 ? '…' : ''}" effort=${effort}`);
|
|
1297
|
+
const turnResp = await srv.call('turn/start', {
|
|
1298
|
+
threadId: s.sessionId, input,
|
|
1299
|
+
model: opts.codexModel || undefined,
|
|
1300
|
+
effort: mapEffort(opts.thinkingEffort),
|
|
1301
|
+
}, 60_000);
|
|
1302
|
+
if (turnResp.error) {
|
|
1303
|
+
opts.abortSignal?.removeEventListener('abort', abortStream);
|
|
1304
|
+
unsubscribeNotifications();
|
|
1305
|
+
unsubscribeRequests();
|
|
1306
|
+
const errMsg = turnResp.error.message || 'turn/start failed';
|
|
1307
|
+
agentWarn(`[codex-rpc] turn/start error: ${errMsg}`);
|
|
1308
|
+
return codexErrorResult(errMsg, start, s.sessionId, s.model, s.thinkingEffort);
|
|
1309
|
+
}
|
|
1310
|
+
s.turnId = turnResp.result?.turn?.id ?? null;
|
|
1311
|
+
publishTurnControl();
|
|
1312
|
+
await turnDone;
|
|
1313
|
+
opts.abortSignal?.removeEventListener('abort', abortStream);
|
|
1314
|
+
unsubscribeNotifications();
|
|
1315
|
+
unsubscribeRequests();
|
|
1316
|
+
if (!s.text.trim() && s.msgs.length)
|
|
1317
|
+
s.text = s.msgs.join('\n\n');
|
|
1318
|
+
if (!s.thinking.trim() && s.thinkParts.length)
|
|
1319
|
+
s.thinking = s.thinkParts.join('\n\n');
|
|
1320
|
+
// Drain any image_gen calls that started but never received a completion
|
|
1321
|
+
// event. We've observed runs where the response_item stays at
|
|
1322
|
+
// status="generating" and no `rawResponseItem/completed` fires — the PNG
|
|
1323
|
+
// is on disk, we just never got told to emit it. Try once at turn end;
|
|
1324
|
+
// tryEmit is a no-op for already-emitted entries.
|
|
1325
|
+
for (const callId of [...s.pendingImageGen.keys()]) {
|
|
1326
|
+
tryEmitCodexImageBlock(s, callId);
|
|
1327
|
+
}
|
|
1328
|
+
const ok = s.turnStatus === 'completed' && !timedOut && !interrupted;
|
|
1329
|
+
const error = s.turnError
|
|
1330
|
+
|| (interrupted ? 'Interrupted by user.' : null)
|
|
1331
|
+
|| (timedOut ? `Timed out after ${opts.timeout}s waiting for turn completion.` : null)
|
|
1332
|
+
|| (!ok ? `Turn ${s.turnStatus || 'unknown'}.` : null);
|
|
1333
|
+
const stopReason = timedOut ? 'timeout' : ((interrupted || s.turnStatus === 'interrupted') ? 'interrupted' : null);
|
|
1334
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
1335
|
+
agentLog(`[codex-rpc] result: ok=${ok} elapsed=${elapsed}s text=${s.text.length}chars session=${s.sessionId} status=${s.turnStatus}`);
|
|
1336
|
+
return {
|
|
1337
|
+
ok, sessionId: s.sessionId,
|
|
1338
|
+
workspacePath: null, model: s.model, thinkingEffort: s.thinkingEffort,
|
|
1339
|
+
message: s.text.trim() || error || '(no textual response)',
|
|
1340
|
+
thinking: s.thinking.trim() || null,
|
|
1341
|
+
plan: s.plan?.steps?.length ? s.plan : null,
|
|
1342
|
+
elapsedS: (Date.now() - start) / 1000,
|
|
1343
|
+
inputTokens: s.inputTokens, outputTokens: s.outputTokens,
|
|
1344
|
+
cachedInputTokens: s.cachedInputTokens, cacheCreationInputTokens: s.cacheCreationInputTokens,
|
|
1345
|
+
contextWindow: s.contextWindow, ...computeContext(s),
|
|
1346
|
+
codexCumulative: s.codexCumulative, error, stopReason, incomplete: !ok,
|
|
1347
|
+
activity: s.activity.trim() || null,
|
|
1348
|
+
assistantBlocks: s.imageBlocks.length ? [...s.imageBlocks] : undefined,
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
finally {
|
|
1352
|
+
unsubscribeNotifications();
|
|
1353
|
+
unsubscribeRequests();
|
|
1354
|
+
srv.kill();
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
// ---------------------------------------------------------------------------
|
|
1358
|
+
// Sessions
|
|
1359
|
+
// ---------------------------------------------------------------------------
|
|
1360
|
+
/** Load title index from ~/.codex/session_index.jsonl (deduped, last entry wins). */
|
|
1361
|
+
function loadCodexSessionIndex() {
|
|
1362
|
+
const home = getHome();
|
|
1363
|
+
if (!home)
|
|
1364
|
+
return new Map();
|
|
1365
|
+
const indexPath = path.join(home, '.codex', 'session_index.jsonl');
|
|
1366
|
+
if (!fs.existsSync(indexPath))
|
|
1367
|
+
return new Map();
|
|
1368
|
+
const map = new Map();
|
|
1369
|
+
try {
|
|
1370
|
+
const data = fs.readFileSync(indexPath, 'utf8');
|
|
1371
|
+
for (const line of data.split('\n')) {
|
|
1372
|
+
if (!line.trim())
|
|
1373
|
+
continue;
|
|
1374
|
+
try {
|
|
1375
|
+
const entry = JSON.parse(line);
|
|
1376
|
+
if (entry.id)
|
|
1377
|
+
map.set(entry.id, { threadName: entry.thread_name || '', updatedAt: entry.updated_at || '' });
|
|
1378
|
+
}
|
|
1379
|
+
catch { /* skip */ }
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
catch { /* skip */ }
|
|
1383
|
+
return map;
|
|
1384
|
+
}
|
|
1385
|
+
/** Scan ~/.codex/sessions/ rollout files to find sessions matching the given workdir. */
|
|
1386
|
+
function extractCodexTailQA(filePath) {
|
|
1387
|
+
const lines = readTailLines(filePath, 128 * 1024);
|
|
1388
|
+
let lastQuestion = null;
|
|
1389
|
+
let lastAnswer = null;
|
|
1390
|
+
let lastMessageText = null;
|
|
1391
|
+
for (const raw of lines) {
|
|
1392
|
+
if (!raw || raw[0] !== '{' || !raw.includes('"event_msg"'))
|
|
1393
|
+
continue;
|
|
1394
|
+
try {
|
|
1395
|
+
const ev = JSON.parse(raw);
|
|
1396
|
+
if (ev?.type !== 'event_msg' || !ev.payload || typeof ev.payload !== 'object')
|
|
1397
|
+
continue;
|
|
1398
|
+
if (ev.payload.type === 'user_message' && typeof ev.payload.message === 'string') {
|
|
1399
|
+
const text = sanitizeSessionUserPreviewText(ev.payload.message);
|
|
1400
|
+
if (text) {
|
|
1401
|
+
lastQuestion = shortValue(text, 500);
|
|
1402
|
+
lastMessageText = shortValue(text, 500);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
else if (ev.payload.type === 'agent_message' && typeof ev.payload.message === 'string') {
|
|
1406
|
+
const text = ev.payload.message.trim();
|
|
1407
|
+
if (text) {
|
|
1408
|
+
lastAnswer = shortValue(text, 500);
|
|
1409
|
+
lastMessageText = shortValue(text, 500);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
catch { /* skip */ }
|
|
1414
|
+
}
|
|
1415
|
+
return { lastQuestion, lastAnswer, lastMessageText };
|
|
1416
|
+
}
|
|
1417
|
+
function readCodexSessionHead(filePath) {
|
|
1418
|
+
try {
|
|
1419
|
+
const fd = fs.openSync(filePath, 'r');
|
|
1420
|
+
const buf = Buffer.alloc(8 * 1024);
|
|
1421
|
+
const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
1422
|
+
fs.closeSync(fd);
|
|
1423
|
+
const head = buf.toString('utf8', 0, bytesRead);
|
|
1424
|
+
if (!head.includes('"session_meta"'))
|
|
1425
|
+
return null;
|
|
1426
|
+
const idMatch = head.match(/"id"\s*:\s*"([^"]+)"/);
|
|
1427
|
+
const cwdMatch = head.match(/"cwd"\s*:\s*"([^"]+)"/);
|
|
1428
|
+
const tsMatch = head.match(/"timestamp"\s*:\s*"([^"]+)"/);
|
|
1429
|
+
if (!idMatch || !cwdMatch)
|
|
1430
|
+
return null;
|
|
1431
|
+
return {
|
|
1432
|
+
sessionId: idMatch[1],
|
|
1433
|
+
cwd: cwdMatch[1],
|
|
1434
|
+
timestamp: tsMatch?.[1] || null,
|
|
1435
|
+
isSubagent: /"source"\s*:\s*\{\s*"subagent"\s*:/.test(head) || /"thread_spawn"\s*:/.test(head),
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
catch {
|
|
1439
|
+
return null;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
// Per-file cache of the head meta + tail Q&A. getNativeCodexSessions walks the
|
|
1443
|
+
// whole y/m/d rollout tree and reads each file's 8KB head (to filter by cwd) on
|
|
1444
|
+
// every list request AND per workspace×agent in the overview fan-out. Keyed by
|
|
1445
|
+
// (mtime,size): unchanged rollouts — including other workspaces' files passed
|
|
1446
|
+
// while filtering — are never re-read. `running` depends on Date.now() so it is
|
|
1447
|
+
// recomputed per call, not cached.
|
|
1448
|
+
const nativeCodexContentCache = new Map();
|
|
1449
|
+
function getNativeCodexSessions(workdir, limit) {
|
|
1450
|
+
const home = getHome();
|
|
1451
|
+
if (!home)
|
|
1452
|
+
return [];
|
|
1453
|
+
const sessionsDir = path.join(home, '.codex', 'sessions');
|
|
1454
|
+
if (!fs.existsSync(sessionsDir))
|
|
1455
|
+
return [];
|
|
1456
|
+
const resolvedWorkdir = path.resolve(workdir);
|
|
1457
|
+
const titleIndex = loadCodexSessionIndex();
|
|
1458
|
+
// Collect rollout files across the year/month/day tree, newest-first, then read
|
|
1459
|
+
// bodies only as far as needed: `limit` applies to a recency-sorted merge
|
|
1460
|
+
// downstream, so older rollouts can't surface in a top-`limit` view.
|
|
1461
|
+
const files = [];
|
|
1462
|
+
const walkDir = (dir) => {
|
|
1463
|
+
let entries;
|
|
1464
|
+
try {
|
|
1465
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1466
|
+
}
|
|
1467
|
+
catch {
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
for (const entry of entries) {
|
|
1471
|
+
const fullPath = path.join(dir, entry.name);
|
|
1472
|
+
if (entry.isDirectory()) {
|
|
1473
|
+
walkDir(fullPath);
|
|
1474
|
+
continue;
|
|
1475
|
+
}
|
|
1476
|
+
if (!entry.name.startsWith('rollout-') || !entry.name.endsWith('.jsonl'))
|
|
1477
|
+
continue;
|
|
1478
|
+
try {
|
|
1479
|
+
files.push({ filePath: fullPath, stat: fs.statSync(fullPath) });
|
|
1480
|
+
}
|
|
1481
|
+
catch { /* skip */ }
|
|
1482
|
+
}
|
|
1483
|
+
};
|
|
1484
|
+
walkDir(sessionsDir);
|
|
1485
|
+
files.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
|
|
1486
|
+
const sessions = [];
|
|
1487
|
+
const seenIds = new Set();
|
|
1488
|
+
for (const { filePath, stat } of files) {
|
|
1489
|
+
let cached = nativeCodexContentCache.get(filePath);
|
|
1490
|
+
if (!cached || cached.mtimeMs !== stat.mtimeMs || cached.size !== stat.size) {
|
|
1491
|
+
// First line can be very large (base_instructions), so read a head chunk
|
|
1492
|
+
// and extract session_meta via regex instead of a full JSON parse.
|
|
1493
|
+
const meta = readCodexSessionHead(filePath);
|
|
1494
|
+
const matches = !!meta && !meta.isSubagent && path.resolve(meta.cwd) === resolvedWorkdir;
|
|
1495
|
+
cached = { mtimeMs: stat.mtimeMs, size: stat.size, meta, tailQA: matches ? extractCodexTailQA(filePath) : null };
|
|
1496
|
+
nativeCodexContentCache.set(filePath, cached);
|
|
1497
|
+
}
|
|
1498
|
+
const meta = cached.meta;
|
|
1499
|
+
if (!meta || meta.isSubagent || path.resolve(meta.cwd) !== resolvedWorkdir)
|
|
1500
|
+
continue;
|
|
1501
|
+
if (seenIds.has(meta.sessionId))
|
|
1502
|
+
continue;
|
|
1503
|
+
seenIds.add(meta.sessionId);
|
|
1504
|
+
const idx = titleIndex.get(meta.sessionId);
|
|
1505
|
+
const updatedAt = idx?.updatedAt || stat.mtime.toISOString();
|
|
1506
|
+
const running = Date.now() - Date.parse(updatedAt) < SESSION_RUNNING_THRESHOLD_MS;
|
|
1507
|
+
sessions.push({
|
|
1508
|
+
sessionId: meta.sessionId,
|
|
1509
|
+
agent: 'codex',
|
|
1510
|
+
workdir: meta.cwd,
|
|
1511
|
+
workspacePath: null,
|
|
1512
|
+
model: null,
|
|
1513
|
+
createdAt: meta.timestamp || stat.birthtime.toISOString(),
|
|
1514
|
+
title: idx?.threadName || null,
|
|
1515
|
+
running,
|
|
1516
|
+
runState: running ? 'running' : 'completed',
|
|
1517
|
+
runDetail: null,
|
|
1518
|
+
runUpdatedAt: updatedAt,
|
|
1519
|
+
classification: null,
|
|
1520
|
+
userStatus: null,
|
|
1521
|
+
userNote: null,
|
|
1522
|
+
lastQuestion: cached.tailQA?.lastQuestion ?? null,
|
|
1523
|
+
lastAnswer: cached.tailQA?.lastAnswer ?? null,
|
|
1524
|
+
lastMessageText: cached.tailQA?.lastMessageText ?? null,
|
|
1525
|
+
migratedFrom: null,
|
|
1526
|
+
migratedTo: null,
|
|
1527
|
+
linkedSessions: [],
|
|
1528
|
+
numTurns: null,
|
|
1529
|
+
});
|
|
1530
|
+
if (typeof limit === 'number' && sessions.length >= limit)
|
|
1531
|
+
break;
|
|
1532
|
+
}
|
|
1533
|
+
return sessions;
|
|
1534
|
+
}
|
|
1535
|
+
function readCodexSessionMeta(filePath) {
|
|
1536
|
+
const meta = readCodexSessionHead(filePath);
|
|
1537
|
+
if (!meta)
|
|
1538
|
+
return null;
|
|
1539
|
+
return { sessionId: meta.sessionId, cwd: meta.cwd };
|
|
1540
|
+
}
|
|
1541
|
+
function findCodexRolloutPath(sessionId, workdir) {
|
|
1542
|
+
const home = getHome();
|
|
1543
|
+
if (!home)
|
|
1544
|
+
return null;
|
|
1545
|
+
const sessionsRoot = path.join(home, '.codex', 'sessions');
|
|
1546
|
+
if (!fs.existsSync(sessionsRoot))
|
|
1547
|
+
return null;
|
|
1548
|
+
const resolvedWorkdir = path.resolve(workdir);
|
|
1549
|
+
const walkDir = (dir) => {
|
|
1550
|
+
let entries;
|
|
1551
|
+
try {
|
|
1552
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1553
|
+
}
|
|
1554
|
+
catch {
|
|
1555
|
+
return null;
|
|
1556
|
+
}
|
|
1557
|
+
for (const entry of entries) {
|
|
1558
|
+
const fullPath = path.join(dir, entry.name);
|
|
1559
|
+
if (entry.isDirectory()) {
|
|
1560
|
+
const found = walkDir(fullPath);
|
|
1561
|
+
if (found)
|
|
1562
|
+
return found;
|
|
1563
|
+
continue;
|
|
1564
|
+
}
|
|
1565
|
+
if (!entry.name.startsWith('rollout-') || !entry.name.endsWith('.jsonl'))
|
|
1566
|
+
continue;
|
|
1567
|
+
const meta = readCodexSessionMeta(fullPath);
|
|
1568
|
+
if (!meta)
|
|
1569
|
+
continue;
|
|
1570
|
+
if (meta.sessionId === sessionId && path.resolve(meta.cwd) === resolvedWorkdir)
|
|
1571
|
+
return fullPath;
|
|
1572
|
+
}
|
|
1573
|
+
return null;
|
|
1574
|
+
};
|
|
1575
|
+
return walkDir(sessionsRoot);
|
|
1576
|
+
}
|
|
1577
|
+
function getCodexSessionTailFromRollout(opts) {
|
|
1578
|
+
const limit = opts.limit ?? 4;
|
|
1579
|
+
const rolloutPath = findCodexRolloutPath(opts.sessionId, opts.workdir);
|
|
1580
|
+
if (!rolloutPath)
|
|
1581
|
+
return { ok: false, messages: [], error: 'Session history file not found' };
|
|
1582
|
+
try {
|
|
1583
|
+
const lines = readTailLines(rolloutPath, 512 * 1024);
|
|
1584
|
+
const allMsgs = [];
|
|
1585
|
+
for (const raw of lines) {
|
|
1586
|
+
if (!raw || raw[0] !== '{' || !raw.includes('"event_msg"'))
|
|
1587
|
+
continue;
|
|
1588
|
+
let ev;
|
|
1589
|
+
try {
|
|
1590
|
+
ev = JSON.parse(raw);
|
|
1591
|
+
}
|
|
1592
|
+
catch {
|
|
1593
|
+
continue;
|
|
1594
|
+
}
|
|
1595
|
+
if (ev?.type !== 'event_msg' || !ev.payload || typeof ev.payload !== 'object')
|
|
1596
|
+
continue;
|
|
1597
|
+
if (ev.payload.type === 'user_message' && typeof ev.payload.message === 'string') {
|
|
1598
|
+
const text = stripInjectedPrompts(ev.payload.message).trim();
|
|
1599
|
+
if (text)
|
|
1600
|
+
allMsgs.push({ role: 'user', text });
|
|
1601
|
+
}
|
|
1602
|
+
else if (ev.payload.type === 'agent_message' && typeof ev.payload.message === 'string') {
|
|
1603
|
+
const text = ev.payload.message.trim();
|
|
1604
|
+
if (text)
|
|
1605
|
+
allMsgs.push({ role: 'assistant', text });
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
return { ok: true, messages: allMsgs.slice(-limit), error: null };
|
|
1609
|
+
}
|
|
1610
|
+
catch (error) {
|
|
1611
|
+
return { ok: false, messages: [], error: error?.message || 'Failed to read session history' };
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
function getCodexSessions(workdir, limit) {
|
|
1615
|
+
const resolvedWorkdir = path.resolve(workdir);
|
|
1616
|
+
// Merge pikiloop-tracked sessions with native Codex sessions
|
|
1617
|
+
const pikiloopSessions = listPikiloopSessions(resolvedWorkdir, 'codex').map(record => ({
|
|
1618
|
+
sessionId: record.sessionId,
|
|
1619
|
+
agent: 'codex',
|
|
1620
|
+
workdir: record.workdir,
|
|
1621
|
+
workspacePath: record.workspacePath,
|
|
1622
|
+
threadId: record.threadId,
|
|
1623
|
+
model: record.model,
|
|
1624
|
+
createdAt: record.createdAt,
|
|
1625
|
+
title: record.title,
|
|
1626
|
+
running: record.runState === 'running',
|
|
1627
|
+
runState: record.runState,
|
|
1628
|
+
runDetail: record.runDetail,
|
|
1629
|
+
runUpdatedAt: record.runUpdatedAt,
|
|
1630
|
+
runPid: record.runPid,
|
|
1631
|
+
classification: record.classification,
|
|
1632
|
+
userStatus: record.userStatus,
|
|
1633
|
+
userNote: record.userNote,
|
|
1634
|
+
lastQuestion: record.lastQuestion,
|
|
1635
|
+
lastAnswer: record.lastAnswer,
|
|
1636
|
+
lastMessageText: record.lastMessageText,
|
|
1637
|
+
migratedFrom: record.migratedFrom,
|
|
1638
|
+
migratedTo: record.migratedTo,
|
|
1639
|
+
linkedSessions: record.linkedSessions,
|
|
1640
|
+
numTurns: record.numTurns ?? null,
|
|
1641
|
+
}));
|
|
1642
|
+
const nativeSessions = getNativeCodexSessions(resolvedWorkdir, limit);
|
|
1643
|
+
const merged = mergeManagedAndNativeSessions(pikiloopSessions, nativeSessions);
|
|
1644
|
+
const sessions = typeof limit === 'number' ? merged.slice(0, limit) : merged;
|
|
1645
|
+
const sessionsDir = path.join(getHome(), '.codex', 'sessions');
|
|
1646
|
+
agentLog(`[sessions:codex] workdir=${resolvedWorkdir} sessionsDir=${sessionsDir} sessionsDirExists=${fs.existsSync(sessionsDir)} ` +
|
|
1647
|
+
`pikiloop=${pikiloopSessions.length} native=${nativeSessions.length} merged=${sessions.length}`);
|
|
1648
|
+
return { ok: true, sessions, error: null };
|
|
1649
|
+
}
|
|
1650
|
+
async function getCodexSessionTail(opts) {
|
|
1651
|
+
const limit = opts.limit ?? 4;
|
|
1652
|
+
const srv = getSharedServer();
|
|
1653
|
+
if (!(await srv.ensureRunning()))
|
|
1654
|
+
return getCodexSessionTailFromRollout(opts);
|
|
1655
|
+
const resp = await srv.call('thread/read', { threadId: opts.sessionId, includeTurns: true });
|
|
1656
|
+
if (resp.error) {
|
|
1657
|
+
const fallback = getCodexSessionTailFromRollout(opts);
|
|
1658
|
+
return fallback.ok ? fallback : { ok: false, messages: [], error: resp.error.message || fallback.error || 'thread/read failed' };
|
|
1659
|
+
}
|
|
1660
|
+
const thread = resp.result?.thread;
|
|
1661
|
+
if (!thread) {
|
|
1662
|
+
const fallback = getCodexSessionTailFromRollout(opts);
|
|
1663
|
+
return fallback.ok ? fallback : { ok: false, messages: [], error: 'No thread data returned' };
|
|
1664
|
+
}
|
|
1665
|
+
const allMsgs = [];
|
|
1666
|
+
for (const turn of (thread.turns ?? [])) {
|
|
1667
|
+
for (const item of (turn.items ?? [])) {
|
|
1668
|
+
if (item.type === 'userMessage') {
|
|
1669
|
+
const parts = [];
|
|
1670
|
+
for (const c of (item.content ?? [])) {
|
|
1671
|
+
if (c.type === 'text' && c.text)
|
|
1672
|
+
parts.push(c.text);
|
|
1673
|
+
}
|
|
1674
|
+
if (parts.length)
|
|
1675
|
+
allMsgs.push({ role: 'user', text: stripInjectedPrompts(parts.join('\n')) });
|
|
1676
|
+
}
|
|
1677
|
+
else if (item.type === 'agentMessage') {
|
|
1678
|
+
if (item.text)
|
|
1679
|
+
allMsgs.push({ role: 'assistant', text: item.text });
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
const messages = allMsgs.slice(-limit);
|
|
1684
|
+
if (messages.length > 0)
|
|
1685
|
+
return { ok: true, messages, error: null };
|
|
1686
|
+
return getCodexSessionTailFromRollout(opts);
|
|
1687
|
+
}
|
|
1688
|
+
// ---------------------------------------------------------------------------
|
|
1689
|
+
// Session messages (full content)
|
|
1690
|
+
// ---------------------------------------------------------------------------
|
|
1691
|
+
async function getCodexSessionMessages(opts) {
|
|
1692
|
+
if (opts.rich) {
|
|
1693
|
+
const rolloutResult = getCodexSessionMessagesFromRollout(opts);
|
|
1694
|
+
if (rolloutResult.ok)
|
|
1695
|
+
return rolloutResult;
|
|
1696
|
+
}
|
|
1697
|
+
// Try RPC first
|
|
1698
|
+
const srv = getSharedServer();
|
|
1699
|
+
if (await srv.ensureRunning()) {
|
|
1700
|
+
try {
|
|
1701
|
+
const resp = await srv.call('thread/read', { threadId: opts.sessionId, includeTurns: true });
|
|
1702
|
+
if (!resp.error && resp.result?.thread) {
|
|
1703
|
+
const thread = resp.result.thread;
|
|
1704
|
+
const allMsgs = [];
|
|
1705
|
+
const richMsgs = [];
|
|
1706
|
+
for (const turn of (thread.turns ?? [])) {
|
|
1707
|
+
for (const item of (turn.items ?? [])) {
|
|
1708
|
+
if (item.type === 'userMessage') {
|
|
1709
|
+
const parts = [];
|
|
1710
|
+
const blocks = [];
|
|
1711
|
+
for (const c of (item.content ?? [])) {
|
|
1712
|
+
if (c.type === 'text' && c.text)
|
|
1713
|
+
parts.push(c.text);
|
|
1714
|
+
else if (c.type === 'localImage' && c.path) {
|
|
1715
|
+
// Read the image file if it still exists
|
|
1716
|
+
try {
|
|
1717
|
+
if (fs.existsSync(c.path) && fs.statSync(c.path).size <= 4 * 1024 * 1024) {
|
|
1718
|
+
const ext = path.extname(c.path).toLowerCase();
|
|
1719
|
+
const data = fs.readFileSync(c.path).toString('base64');
|
|
1720
|
+
blocks.push({ type: 'image', content: `data:${mimeForExt(ext)};base64,${data}` });
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
catch { /* skip unreadable images */ }
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
if (parts.length || blocks.length) {
|
|
1727
|
+
const text = stripInjectedPrompts(parts.join('\n'));
|
|
1728
|
+
if (text)
|
|
1729
|
+
blocks.unshift({ type: 'text', content: text });
|
|
1730
|
+
allMsgs.push({ role: 'user', text });
|
|
1731
|
+
richMsgs.push({ role: 'user', text, blocks });
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
else if (item.type === 'agentMessage') {
|
|
1735
|
+
if (item.text) {
|
|
1736
|
+
allMsgs.push({ role: 'assistant', text: item.text });
|
|
1737
|
+
richMsgs.push({
|
|
1738
|
+
role: 'assistant',
|
|
1739
|
+
text: item.text,
|
|
1740
|
+
blocks: [{
|
|
1741
|
+
type: 'text',
|
|
1742
|
+
content: item.text,
|
|
1743
|
+
phase: item.phase === 'commentary' ? 'commentary' : 'final_answer',
|
|
1744
|
+
}],
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
if (allMsgs.length > 0) {
|
|
1751
|
+
return applyTurnWindow(allMsgs, opts, richMsgs);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
catch { /* fall through to rollout */ }
|
|
1756
|
+
}
|
|
1757
|
+
// Fallback: read full rollout file
|
|
1758
|
+
return getCodexSessionMessagesFromRollout(opts);
|
|
1759
|
+
}
|
|
1760
|
+
function getCodexSessionMessagesFromRollout(opts) {
|
|
1761
|
+
const rolloutPath = findCodexRolloutPath(opts.sessionId, opts.workdir);
|
|
1762
|
+
if (!rolloutPath)
|
|
1763
|
+
return { ok: false, messages: [], totalTurns: 0, error: 'Session history file not found' };
|
|
1764
|
+
try {
|
|
1765
|
+
const content = fs.readFileSync(rolloutPath, 'utf-8');
|
|
1766
|
+
const lines = content.split('\n');
|
|
1767
|
+
const allMsgs = [];
|
|
1768
|
+
const richMsgs = [];
|
|
1769
|
+
const fallbackMsgs = [];
|
|
1770
|
+
let pendingAssistant = null;
|
|
1771
|
+
let sawAssistantResponseItems = false;
|
|
1772
|
+
const ensureAssistant = () => {
|
|
1773
|
+
if (!pendingAssistant)
|
|
1774
|
+
pendingAssistant = { blocks: [], toolNamesByCallId: new Map() };
|
|
1775
|
+
return pendingAssistant;
|
|
1776
|
+
};
|
|
1777
|
+
const flushAssistant = () => {
|
|
1778
|
+
if (!pendingAssistant)
|
|
1779
|
+
return;
|
|
1780
|
+
const blocks = pendingAssistant.blocks.filter(block => block.type === 'plan'
|
|
1781
|
+
|| block.type === 'image'
|
|
1782
|
+
|| block.type === 'tool_use'
|
|
1783
|
+
|| block.type === 'tool_result'
|
|
1784
|
+
|| !!block.content.trim());
|
|
1785
|
+
pendingAssistant = null;
|
|
1786
|
+
if (!blocks.length)
|
|
1787
|
+
return;
|
|
1788
|
+
const text = buildCodexAssistantText(blocks);
|
|
1789
|
+
allMsgs.push({ role: 'assistant', text });
|
|
1790
|
+
richMsgs.push({ role: 'assistant', text, blocks });
|
|
1791
|
+
};
|
|
1792
|
+
for (const raw of lines) {
|
|
1793
|
+
if (!raw || raw[0] !== '{')
|
|
1794
|
+
continue;
|
|
1795
|
+
let ev;
|
|
1796
|
+
try {
|
|
1797
|
+
ev = JSON.parse(raw);
|
|
1798
|
+
}
|
|
1799
|
+
catch {
|
|
1800
|
+
continue;
|
|
1801
|
+
}
|
|
1802
|
+
if (!ev?.payload || typeof ev.payload !== 'object')
|
|
1803
|
+
continue;
|
|
1804
|
+
if (ev.type === 'event_msg') {
|
|
1805
|
+
if (ev.payload.type === 'user_message' && typeof ev.payload.message === 'string') {
|
|
1806
|
+
flushAssistant();
|
|
1807
|
+
const text = stripInjectedPrompts(ev.payload.message).trim();
|
|
1808
|
+
if (!text)
|
|
1809
|
+
continue;
|
|
1810
|
+
const userMessage = { role: 'user', text };
|
|
1811
|
+
fallbackMsgs.push(userMessage);
|
|
1812
|
+
allMsgs.push(userMessage);
|
|
1813
|
+
richMsgs.push({ role: 'user', text, blocks: [{ type: 'text', content: text }] });
|
|
1814
|
+
}
|
|
1815
|
+
else if (ev.payload.type === 'agent_message' && typeof ev.payload.message === 'string') {
|
|
1816
|
+
const text = ev.payload.message.trim();
|
|
1817
|
+
if (text)
|
|
1818
|
+
fallbackMsgs.push({ role: 'assistant', text });
|
|
1819
|
+
}
|
|
1820
|
+
continue;
|
|
1821
|
+
}
|
|
1822
|
+
if (ev.type !== 'response_item')
|
|
1823
|
+
continue;
|
|
1824
|
+
const payload = ev.payload;
|
|
1825
|
+
if (payload.type === 'message') {
|
|
1826
|
+
if (payload.role !== 'assistant')
|
|
1827
|
+
continue;
|
|
1828
|
+
const text = extractCodexMessageText(payload.content);
|
|
1829
|
+
if (!text)
|
|
1830
|
+
continue;
|
|
1831
|
+
ensureAssistant().blocks.push({
|
|
1832
|
+
type: 'text',
|
|
1833
|
+
content: text,
|
|
1834
|
+
phase: payload.phase === 'commentary' ? 'commentary' : 'final_answer',
|
|
1835
|
+
});
|
|
1836
|
+
sawAssistantResponseItems = true;
|
|
1837
|
+
continue;
|
|
1838
|
+
}
|
|
1839
|
+
if (payload.type === 'reasoning') {
|
|
1840
|
+
const text = extractCodexReasoningText(payload);
|
|
1841
|
+
if (!text)
|
|
1842
|
+
continue;
|
|
1843
|
+
ensureAssistant().blocks.push({ type: 'thinking', content: text });
|
|
1844
|
+
sawAssistantResponseItems = true;
|
|
1845
|
+
continue;
|
|
1846
|
+
}
|
|
1847
|
+
if (payload.type === 'function_call') {
|
|
1848
|
+
const name = typeof payload.name === 'string' ? payload.name.trim() : '';
|
|
1849
|
+
if (!name)
|
|
1850
|
+
continue;
|
|
1851
|
+
const assistant = ensureAssistant();
|
|
1852
|
+
const callId = typeof payload.call_id === 'string' ? payload.call_id : '';
|
|
1853
|
+
if (callId)
|
|
1854
|
+
assistant.toolNamesByCallId.set(callId, name);
|
|
1855
|
+
if (name === 'update_plan') {
|
|
1856
|
+
const plan = normalizeStreamPreviewPlan(parseCodexArguments(payload.arguments));
|
|
1857
|
+
if (plan) {
|
|
1858
|
+
assistant.blocks.push({
|
|
1859
|
+
type: 'plan',
|
|
1860
|
+
content: formatCodexPlanSummary(plan),
|
|
1861
|
+
plan,
|
|
1862
|
+
});
|
|
1863
|
+
sawAssistantResponseItems = true;
|
|
1864
|
+
}
|
|
1865
|
+
continue;
|
|
1866
|
+
}
|
|
1867
|
+
assistant.blocks.push({
|
|
1868
|
+
type: 'tool_use',
|
|
1869
|
+
content: formatCodexArguments(payload.arguments),
|
|
1870
|
+
toolName: name,
|
|
1871
|
+
toolId: callId || undefined,
|
|
1872
|
+
});
|
|
1873
|
+
sawAssistantResponseItems = true;
|
|
1874
|
+
continue;
|
|
1875
|
+
}
|
|
1876
|
+
if (payload.type === 'function_call_output') {
|
|
1877
|
+
const assistant = ensureAssistant();
|
|
1878
|
+
const callId = typeof payload.call_id === 'string' ? payload.call_id : '';
|
|
1879
|
+
const toolName = assistant.toolNamesByCallId.get(callId) || '';
|
|
1880
|
+
const output = formatCodexArguments(payload.output);
|
|
1881
|
+
if (toolName === 'update_plan' && output === 'Plan updated')
|
|
1882
|
+
continue;
|
|
1883
|
+
assistant.blocks.push({
|
|
1884
|
+
type: 'tool_result',
|
|
1885
|
+
content: output,
|
|
1886
|
+
toolName: toolName || undefined,
|
|
1887
|
+
toolId: callId || undefined,
|
|
1888
|
+
});
|
|
1889
|
+
sawAssistantResponseItems = true;
|
|
1890
|
+
continue;
|
|
1891
|
+
}
|
|
1892
|
+
// image_generation_call: Codex's built-in `image_gen` tool — surface the
|
|
1893
|
+
// file on disk as an image block so historical sessions render images
|
|
1894
|
+
// (not just text). Path: $CODEX_HOME/generated_images/<sessionId>/<id>.png
|
|
1895
|
+
if (payload.type === 'image_generation_call' || payload.type === 'imageGenerationCall') {
|
|
1896
|
+
const block = buildCodexImageBlock(opts.sessionId, payload);
|
|
1897
|
+
if (block) {
|
|
1898
|
+
ensureAssistant().blocks.push(block);
|
|
1899
|
+
sawAssistantResponseItems = true;
|
|
1900
|
+
}
|
|
1901
|
+
continue;
|
|
1902
|
+
}
|
|
1903
|
+
const fallbackSummary = summarizeCodexRawResponseItem(payload);
|
|
1904
|
+
if (fallbackSummary) {
|
|
1905
|
+
ensureAssistant().blocks.push({
|
|
1906
|
+
type: 'tool_use',
|
|
1907
|
+
content: formatCodexArguments(payload),
|
|
1908
|
+
toolName: fallbackSummary,
|
|
1909
|
+
});
|
|
1910
|
+
sawAssistantResponseItems = true;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
flushAssistant();
|
|
1914
|
+
if (!sawAssistantResponseItems && fallbackMsgs.some(message => message.role === 'assistant')) {
|
|
1915
|
+
return applyTurnWindow(fallbackMsgs, opts);
|
|
1916
|
+
}
|
|
1917
|
+
const richWithOverlay = overlayCodexManagedPreview(opts.workdir, opts.sessionId, richMsgs);
|
|
1918
|
+
const plainWithOverlay = richWithOverlay.map(message => ({ role: message.role, text: message.text }));
|
|
1919
|
+
return applyTurnWindow(plainWithOverlay, opts, opts.rich ? richWithOverlay : undefined);
|
|
1920
|
+
}
|
|
1921
|
+
catch (e) {
|
|
1922
|
+
return { ok: false, messages: [], totalTurns: 0, error: e?.message || 'Failed to read session history' };
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
// ---------------------------------------------------------------------------
|
|
1926
|
+
// Models (with TTL cache + stale fallback)
|
|
1927
|
+
// ---------------------------------------------------------------------------
|
|
1928
|
+
const MODEL_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
1929
|
+
let modelCache = null;
|
|
1930
|
+
function pushModel(models, seen, id, alias) {
|
|
1931
|
+
const cleanId = id.trim();
|
|
1932
|
+
if (!cleanId || seen.has(cleanId))
|
|
1933
|
+
return;
|
|
1934
|
+
seen.add(cleanId);
|
|
1935
|
+
models.push({ id: cleanId, alias: alias?.trim() || null });
|
|
1936
|
+
}
|
|
1937
|
+
/** Merge currentModel into a cached result so the selected model always appears first. */
|
|
1938
|
+
function withCurrentModel(cached, currentModel) {
|
|
1939
|
+
if (!currentModel?.trim())
|
|
1940
|
+
return cached;
|
|
1941
|
+
const cm = currentModel.trim();
|
|
1942
|
+
if (cached.models.some(m => m.id === cm))
|
|
1943
|
+
return cached;
|
|
1944
|
+
return { ...cached, models: [{ id: cm, alias: null }, ...cached.models] };
|
|
1945
|
+
}
|
|
1946
|
+
async function discoverCodexModels(opts) {
|
|
1947
|
+
// Return cached result if still fresh
|
|
1948
|
+
if (modelCache && Date.now() - modelCache.fetchedAt < MODEL_CACHE_TTL_MS) {
|
|
1949
|
+
return withCurrentModel(modelCache.result, opts.currentModel);
|
|
1950
|
+
}
|
|
1951
|
+
// Try fetching fresh
|
|
1952
|
+
const srv = getSharedServer();
|
|
1953
|
+
if (!(await srv.ensureRunning())) {
|
|
1954
|
+
if (modelCache)
|
|
1955
|
+
return withCurrentModel(modelCache.result, opts.currentModel);
|
|
1956
|
+
return { agent: 'codex', models: [], sources: [], note: 'Failed to start codex app-server.' };
|
|
1957
|
+
}
|
|
1958
|
+
const resp = await srv.call('model/list', { includeHidden: false });
|
|
1959
|
+
if (resp.error) {
|
|
1960
|
+
if (modelCache)
|
|
1961
|
+
return withCurrentModel(modelCache.result, opts.currentModel);
|
|
1962
|
+
return { agent: 'codex', models: [], sources: [], note: resp.error.message || 'model/list failed' };
|
|
1963
|
+
}
|
|
1964
|
+
const data = resp.result?.data ?? [];
|
|
1965
|
+
const models = [];
|
|
1966
|
+
const seen = new Set();
|
|
1967
|
+
if (opts.currentModel?.trim())
|
|
1968
|
+
pushModel(models, seen, opts.currentModel.trim(), null);
|
|
1969
|
+
for (const entry of data) {
|
|
1970
|
+
const id = entry.model || entry.id;
|
|
1971
|
+
if (!id || seen.has(id))
|
|
1972
|
+
continue;
|
|
1973
|
+
pushModel(models, seen, id, entry.displayName && entry.displayName !== id ? entry.displayName : null);
|
|
1974
|
+
}
|
|
1975
|
+
const result = { agent: 'codex', models, sources: ['app-server model/list'], note: null };
|
|
1976
|
+
modelCache = { result, fetchedAt: Date.now() };
|
|
1977
|
+
return result;
|
|
1978
|
+
}
|
|
1979
|
+
// ---------------------------------------------------------------------------
|
|
1980
|
+
// Usage
|
|
1981
|
+
// ---------------------------------------------------------------------------
|
|
1982
|
+
function getCodexStateDbPath(home) {
|
|
1983
|
+
const root = path.join(home, '.codex');
|
|
1984
|
+
if (!fs.existsSync(root))
|
|
1985
|
+
return null;
|
|
1986
|
+
try {
|
|
1987
|
+
const files = fs.readdirSync(root)
|
|
1988
|
+
.filter(name => /^state.*\.sqlite$/i.test(name))
|
|
1989
|
+
.map(name => ({ name, full: path.join(root, name), mtime: fs.statSync(path.join(root, name)).mtimeMs }))
|
|
1990
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
1991
|
+
return files[0]?.full || null;
|
|
1992
|
+
}
|
|
1993
|
+
catch {
|
|
1994
|
+
return null;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
function codexUsageFromRateLimits(rateLimits, capturedAt, source) {
|
|
1998
|
+
if (!rateLimits || typeof rateLimits !== 'object')
|
|
1999
|
+
return null;
|
|
2000
|
+
const windows = [
|
|
2001
|
+
usageWindowFromRateLimit('Primary', rateLimits.primary),
|
|
2002
|
+
usageWindowFromRateLimit('Secondary', rateLimits.secondary),
|
|
2003
|
+
].filter((v) => !!v);
|
|
2004
|
+
if (!windows.length)
|
|
2005
|
+
return null;
|
|
2006
|
+
let status = null;
|
|
2007
|
+
if (rateLimits.limit_reached === true)
|
|
2008
|
+
status = 'limit_reached';
|
|
2009
|
+
else if (rateLimits.allowed === true)
|
|
2010
|
+
status = 'allowed';
|
|
2011
|
+
return { ok: true, agent: 'codex', source, capturedAt, status, windows, error: null };
|
|
2012
|
+
}
|
|
2013
|
+
function getCodexUsageFromStateDb(home) {
|
|
2014
|
+
const dbPath = getCodexStateDbPath(home);
|
|
2015
|
+
if (!dbPath)
|
|
2016
|
+
return null;
|
|
2017
|
+
try {
|
|
2018
|
+
const query = "SELECT ts || '|' || message FROM logs WHERE message LIKE '%codex.rate_limits%' ORDER BY ts DESC LIMIT 1;";
|
|
2019
|
+
// stdio: 'pipe' keeps sqlite3 stderr ("no such table", "unable to open") out
|
|
2020
|
+
// of pikiloop's own stderr — this probe is best-effort and the catch below
|
|
2021
|
+
// already swallows failures.
|
|
2022
|
+
const out = execSync(`sqlite3 -noheader ${Q(dbPath)} ${Q(query)}`, { encoding: 'utf-8', timeout: 3000, stdio: ['ignore', 'pipe', 'pipe'] }).trim();
|
|
2023
|
+
if (!out)
|
|
2024
|
+
return null;
|
|
2025
|
+
const sep = out.indexOf('|');
|
|
2026
|
+
const rawTs = sep >= 0 ? out.slice(0, sep) : '';
|
|
2027
|
+
const rawMessage = sep >= 0 ? out.slice(sep + 1) : out;
|
|
2028
|
+
const payload = parseJsonTail(rawMessage);
|
|
2029
|
+
const capturedAt = toIsoFromEpochSeconds(rawTs);
|
|
2030
|
+
return codexUsageFromRateLimits(payload?.rate_limits, capturedAt, 'state-db');
|
|
2031
|
+
}
|
|
2032
|
+
catch {
|
|
2033
|
+
return null;
|
|
2034
|
+
}
|
|
2035
|
+
}
|
|
2036
|
+
function getCodexUsageFromSessions(home) {
|
|
2037
|
+
const sessionsRoot = path.join(home, '.codex', 'sessions');
|
|
2038
|
+
if (!fs.existsSync(sessionsRoot))
|
|
2039
|
+
return null;
|
|
2040
|
+
const all = [];
|
|
2041
|
+
try {
|
|
2042
|
+
for (const year of fs.readdirSync(sessionsRoot)) {
|
|
2043
|
+
const yp = path.join(sessionsRoot, year);
|
|
2044
|
+
if (!fs.statSync(yp).isDirectory())
|
|
2045
|
+
continue;
|
|
2046
|
+
for (const month of fs.readdirSync(yp)) {
|
|
2047
|
+
const mp = path.join(yp, month);
|
|
2048
|
+
if (!fs.statSync(mp).isDirectory())
|
|
2049
|
+
continue;
|
|
2050
|
+
for (const day of fs.readdirSync(mp)) {
|
|
2051
|
+
const dp = path.join(mp, day);
|
|
2052
|
+
if (!fs.statSync(dp).isDirectory())
|
|
2053
|
+
continue;
|
|
2054
|
+
for (const f of fs.readdirSync(dp)) {
|
|
2055
|
+
if (!f.endsWith('.jsonl'))
|
|
2056
|
+
continue;
|
|
2057
|
+
all.push({ path: path.join(dp, f), mtime: fs.statSync(path.join(dp, f)).mtimeMs });
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
catch {
|
|
2064
|
+
return null;
|
|
2065
|
+
}
|
|
2066
|
+
all.sort((a, b) => b.mtime - a.mtime);
|
|
2067
|
+
for (const entry of all.slice(0, 30)) {
|
|
2068
|
+
try {
|
|
2069
|
+
const lines = fs.readFileSync(entry.path, 'utf-8').trim().split('\n');
|
|
2070
|
+
for (let i = lines.length - 1; i >= 0 && i >= lines.length - 200; i--) {
|
|
2071
|
+
const raw = lines[i];
|
|
2072
|
+
if (!raw || raw[0] !== '{' || !raw.includes('rate_limits'))
|
|
2073
|
+
continue;
|
|
2074
|
+
let ev;
|
|
2075
|
+
try {
|
|
2076
|
+
ev = JSON.parse(raw);
|
|
2077
|
+
}
|
|
2078
|
+
catch {
|
|
2079
|
+
continue;
|
|
2080
|
+
}
|
|
2081
|
+
const result = codexUsageFromRateLimits(ev?.payload?.rate_limits, typeof ev?.timestamp === 'string' ? ev.timestamp : null, 'session-history');
|
|
2082
|
+
if (result)
|
|
2083
|
+
return result;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
catch { }
|
|
2087
|
+
}
|
|
2088
|
+
return null;
|
|
2089
|
+
}
|
|
2090
|
+
function parseRateLimitWindow(label, rl) {
|
|
2091
|
+
if (!rl || typeof rl !== 'object')
|
|
2092
|
+
return null;
|
|
2093
|
+
const usedPercent = roundPercent(rl.usedPercent);
|
|
2094
|
+
return {
|
|
2095
|
+
label: labelFromWindowMinutes(rl.windowDurationMins, label),
|
|
2096
|
+
usedPercent,
|
|
2097
|
+
remainingPercent: usedPercent == null ? null : Math.max(0, Math.round((100 - usedPercent) * 10) / 10),
|
|
2098
|
+
resetAt: toIsoFromEpochSeconds(rl.resetsAt),
|
|
2099
|
+
resetAfterSeconds: rl.resetsAt ? Math.max(0, Math.round(rl.resetsAt - Date.now() / 1000)) : null,
|
|
2100
|
+
status: null,
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
export async function getCodexUsageLive() {
|
|
2104
|
+
const home = getHome();
|
|
2105
|
+
const srv = getSharedServer();
|
|
2106
|
+
if (!(await srv.ensureRunning())) {
|
|
2107
|
+
return getCodexUsageFromStateDb(home) || emptyUsage('codex', 'Failed to start codex app-server.');
|
|
2108
|
+
}
|
|
2109
|
+
const resp = await srv.call('account/rateLimits/read');
|
|
2110
|
+
if (resp.error)
|
|
2111
|
+
return getCodexUsageFromStateDb(home) || emptyUsage('codex', resp.error.message || 'account/rateLimits/read failed');
|
|
2112
|
+
const rl = resp.result?.rateLimits;
|
|
2113
|
+
if (!rl)
|
|
2114
|
+
return getCodexUsageFromStateDb(home) || emptyUsage('codex', 'No rate limits in response.');
|
|
2115
|
+
const capturedAt = new Date().toISOString();
|
|
2116
|
+
const windows = [];
|
|
2117
|
+
const w1 = parseRateLimitWindow('Primary', rl.primary);
|
|
2118
|
+
if (w1)
|
|
2119
|
+
windows.push(w1);
|
|
2120
|
+
const w2 = parseRateLimitWindow('Secondary', rl.secondary);
|
|
2121
|
+
if (w2)
|
|
2122
|
+
windows.push(w2);
|
|
2123
|
+
return {
|
|
2124
|
+
ok: windows.length > 0, agent: 'codex', source: 'app-server-live', capturedAt, status: null,
|
|
2125
|
+
windows, error: windows.length > 0 ? null : 'No rate limit windows.',
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
// ---------------------------------------------------------------------------
|
|
2129
|
+
// Driver
|
|
2130
|
+
// ---------------------------------------------------------------------------
|
|
2131
|
+
class CodexDriver {
|
|
2132
|
+
id = 'codex';
|
|
2133
|
+
cmd = 'codex';
|
|
2134
|
+
thinkLabel = 'Reasoning';
|
|
2135
|
+
acceptedProviderKinds = ['openai', 'openai-compatible'];
|
|
2136
|
+
async doStream(opts) { return doCodexStream(opts); }
|
|
2137
|
+
async getSessions(workdir, limit) {
|
|
2138
|
+
return getCodexSessions(workdir, limit);
|
|
2139
|
+
}
|
|
2140
|
+
async getSessionTail(opts) {
|
|
2141
|
+
return getCodexSessionTail(opts);
|
|
2142
|
+
}
|
|
2143
|
+
async getSessionMessages(opts) {
|
|
2144
|
+
return getCodexSessionMessages(opts);
|
|
2145
|
+
}
|
|
2146
|
+
async listModels(opts) { return discoverCodexModels(opts); }
|
|
2147
|
+
getUsage(opts) {
|
|
2148
|
+
const home = getHome();
|
|
2149
|
+
if (!home)
|
|
2150
|
+
return emptyUsage('codex', 'HOME is not set.');
|
|
2151
|
+
return getCodexUsageFromStateDb(home)
|
|
2152
|
+
|| getCodexUsageFromSessions(home)
|
|
2153
|
+
|| emptyUsage('codex', 'No recent Codex usage data found.');
|
|
2154
|
+
}
|
|
2155
|
+
async getUsageLive(opts) { return getCodexUsageLive(); }
|
|
2156
|
+
async deleteNativeSession(workdir, sessionId) {
|
|
2157
|
+
return deleteNativeCodexSession(workdir, sessionId);
|
|
2158
|
+
}
|
|
2159
|
+
shutdown() { shutdownCodexServer(); }
|
|
2160
|
+
}
|
|
2161
|
+
/**
|
|
2162
|
+
* Locate and remove the codex rollout file backing a session. Codex stores
|
|
2163
|
+
* sessions under `~/.codex/sessions/<year>/<month>/<day>/rollout-<...>.jsonl`,
|
|
2164
|
+
* keyed by `meta.sessionId` inside the file rather than the filename — so we
|
|
2165
|
+
* walk the tree and match on the parsed head metadata, scoped to `workdir`.
|
|
2166
|
+
*/
|
|
2167
|
+
async function deleteNativeCodexSession(workdir, sessionId) {
|
|
2168
|
+
const home = getHome();
|
|
2169
|
+
if (!home || !sessionId)
|
|
2170
|
+
return [];
|
|
2171
|
+
const sessionsDir = path.join(home, '.codex', 'sessions');
|
|
2172
|
+
if (!fs.existsSync(sessionsDir))
|
|
2173
|
+
return [];
|
|
2174
|
+
const resolvedWorkdir = path.resolve(workdir);
|
|
2175
|
+
const removed = [];
|
|
2176
|
+
const walk = (dir) => {
|
|
2177
|
+
let entries;
|
|
2178
|
+
try {
|
|
2179
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
2180
|
+
}
|
|
2181
|
+
catch {
|
|
2182
|
+
return false;
|
|
2183
|
+
}
|
|
2184
|
+
for (const entry of entries) {
|
|
2185
|
+
const full = path.join(dir, entry.name);
|
|
2186
|
+
if (entry.isDirectory()) {
|
|
2187
|
+
if (walk(full))
|
|
2188
|
+
return true;
|
|
2189
|
+
continue;
|
|
2190
|
+
}
|
|
2191
|
+
if (!entry.name.startsWith('rollout-') || !entry.name.endsWith('.jsonl'))
|
|
2192
|
+
continue;
|
|
2193
|
+
try {
|
|
2194
|
+
const meta = readCodexSessionHead(full);
|
|
2195
|
+
if (!meta || meta.sessionId !== sessionId)
|
|
2196
|
+
continue;
|
|
2197
|
+
if (path.resolve(meta.cwd) !== resolvedWorkdir)
|
|
2198
|
+
continue;
|
|
2199
|
+
fs.rmSync(full, { force: true });
|
|
2200
|
+
removed.push(full);
|
|
2201
|
+
return true;
|
|
2202
|
+
}
|
|
2203
|
+
catch { /* skip */ }
|
|
2204
|
+
}
|
|
2205
|
+
return false;
|
|
2206
|
+
};
|
|
2207
|
+
walk(sessionsDir);
|
|
2208
|
+
return removed;
|
|
2209
|
+
}
|
|
2210
|
+
registerDriver(new CodexDriver());
|