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.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +353 -0
  3. package/README.v2.md +287 -0
  4. package/README.zh-CN.md +352 -0
  5. package/dashboard/dist/assets/AgentTab-UZPIhlkr.js +1 -0
  6. package/dashboard/dist/assets/DirBrowser-Ckcmi-Pi.js +1 -0
  7. package/dashboard/dist/assets/ExtensionsTab-KZhEDrdu.js +1 -0
  8. package/dashboard/dist/assets/IMAccessTab-Bd_IY1GQ.js +1 -0
  9. package/dashboard/dist/assets/Modal-CTeL0y7P.js +1 -0
  10. package/dashboard/dist/assets/Modals-axftHasy.js +1 -0
  11. package/dashboard/dist/assets/Select-C8tOdPhe.js +1 -0
  12. package/dashboard/dist/assets/SessionPanel-C1geSRxw.js +1 -0
  13. package/dashboard/dist/assets/SystemTab-DBDkaPiO.js +1 -0
  14. package/dashboard/dist/assets/anthropic-BAdojD7P.ico +0 -0
  15. package/dashboard/dist/assets/codex-DYadqqp0.png +0 -0
  16. package/dashboard/dist/assets/deepseek-BeYNZEk0.ico +0 -0
  17. package/dashboard/dist/assets/doubao-DloFDuFR.png +0 -0
  18. package/dashboard/dist/assets/feishu-C4OMrjCW.ico +0 -0
  19. package/dashboard/dist/assets/gemini-BYkEpiWr.svg +1 -0
  20. package/dashboard/dist/assets/hermes-BAarh-tH.png +0 -0
  21. package/dashboard/dist/assets/index-CpM4CqZJ.js +23 -0
  22. package/dashboard/dist/assets/index-DXSohzrE.js +3 -0
  23. package/dashboard/dist/assets/index-reSbuley.css +1 -0
  24. package/dashboard/dist/assets/markdown-DxQYQFeH.js +29 -0
  25. package/dashboard/dist/assets/minimax-PuEGTfrF.ico +0 -0
  26. package/dashboard/dist/assets/mlx-DhWwjtMw.png +0 -0
  27. package/dashboard/dist/assets/ollama-Bt9O-2K_.png +0 -0
  28. package/dashboard/dist/assets/openrouter-CsJ_bD5Q.ico +0 -0
  29. package/dashboard/dist/assets/playwright-BldPFZgC.ico +0 -0
  30. package/dashboard/dist/assets/qwen-xykkX0_y.png +0 -0
  31. package/dashboard/dist/assets/react-vendor-C7Sl8SE7.js +9 -0
  32. package/dashboard/dist/assets/router-DHISdpPk.js +3 -0
  33. package/dashboard/dist/assets/shared-BIP_4k4I.js +1 -0
  34. package/dashboard/dist/favicon.svg +28 -0
  35. package/dashboard/dist/index.html +17 -0
  36. package/dist/agent/acp-client.js +261 -0
  37. package/dist/agent/auto-update.js +432 -0
  38. package/dist/agent/await-resume.js +50 -0
  39. package/dist/agent/cli/auth.js +325 -0
  40. package/dist/agent/cli/catalog.js +40 -0
  41. package/dist/agent/cli/detector.js +136 -0
  42. package/dist/agent/cli/index.js +7 -0
  43. package/dist/agent/cli/registry.js +33 -0
  44. package/dist/agent/driver.js +39 -0
  45. package/dist/agent/drivers/claude-tui.js +2297 -0
  46. package/dist/agent/drivers/claude.js +2689 -0
  47. package/dist/agent/drivers/codex.js +2210 -0
  48. package/dist/agent/drivers/gemini.js +1059 -0
  49. package/dist/agent/drivers/hermes.js +795 -0
  50. package/dist/agent/goal.js +274 -0
  51. package/dist/agent/handover.js +130 -0
  52. package/dist/agent/images.js +355 -0
  53. package/dist/agent/index.js +50 -0
  54. package/dist/agent/mcp/bridge.js +791 -0
  55. package/dist/agent/mcp/extensions.js +637 -0
  56. package/dist/agent/mcp/oauth.js +353 -0
  57. package/dist/agent/mcp/registry.js +119 -0
  58. package/dist/agent/mcp/session-server.js +229 -0
  59. package/dist/agent/mcp/tools/ask-user.js +113 -0
  60. package/dist/agent/mcp/tools/await-resume.js +77 -0
  61. package/dist/agent/mcp/tools/goal.js +144 -0
  62. package/dist/agent/mcp/tools/types.js +12 -0
  63. package/dist/agent/mcp/tools/workspace.js +212 -0
  64. package/dist/agent/npm.js +31 -0
  65. package/dist/agent/session.js +1206 -0
  66. package/dist/agent/skill-installer.js +160 -0
  67. package/dist/agent/skills.js +257 -0
  68. package/dist/agent/stream.js +743 -0
  69. package/dist/agent/types.js +13 -0
  70. package/dist/agent/utils.js +687 -0
  71. package/dist/bot/bot.js +2499 -0
  72. package/dist/bot/command-ui.js +633 -0
  73. package/dist/bot/commands.js +513 -0
  74. package/dist/bot/headless-bot.js +36 -0
  75. package/dist/bot/host.js +192 -0
  76. package/dist/bot/human-loop.js +168 -0
  77. package/dist/bot/menu.js +48 -0
  78. package/dist/bot/orchestration.js +79 -0
  79. package/dist/bot/render-shared.js +309 -0
  80. package/dist/bot/session-hub.js +361 -0
  81. package/dist/bot/session-status.js +55 -0
  82. package/dist/bot/streaming.js +309 -0
  83. package/dist/browser-profile.js +579 -0
  84. package/dist/browser-supervisor.js +249 -0
  85. package/dist/catalog/cli-tools.js +421 -0
  86. package/dist/catalog/index.js +21 -0
  87. package/dist/catalog/local-models.js +94 -0
  88. package/dist/catalog/mcp-servers.js +315 -0
  89. package/dist/catalog/skill-repos.js +173 -0
  90. package/dist/channels/base.js +55 -0
  91. package/dist/channels/dingtalk/bot.js +549 -0
  92. package/dist/channels/dingtalk/channel.js +268 -0
  93. package/dist/channels/discord/bot.js +552 -0
  94. package/dist/channels/discord/channel.js +245 -0
  95. package/dist/channels/feishu/bot.js +1275 -0
  96. package/dist/channels/feishu/channel.js +911 -0
  97. package/dist/channels/feishu/markdown.js +91 -0
  98. package/dist/channels/feishu/render.js +619 -0
  99. package/dist/channels/health.js +109 -0
  100. package/dist/channels/slack/bot.js +554 -0
  101. package/dist/channels/slack/channel.js +283 -0
  102. package/dist/channels/states.js +6 -0
  103. package/dist/channels/telegram/bot.js +1310 -0
  104. package/dist/channels/telegram/channel.js +820 -0
  105. package/dist/channels/telegram/directory.js +111 -0
  106. package/dist/channels/telegram/live-preview.js +220 -0
  107. package/dist/channels/telegram/render.js +384 -0
  108. package/dist/channels/wecom/bot.js +558 -0
  109. package/dist/channels/wecom/channel.js +479 -0
  110. package/dist/channels/weixin/api.js +520 -0
  111. package/dist/channels/weixin/bot.js +1000 -0
  112. package/dist/channels/weixin/channel.js +222 -0
  113. package/dist/cli/autostart.js +262 -0
  114. package/dist/cli/channel-supervisor.js +313 -0
  115. package/dist/cli/channels.js +54 -0
  116. package/dist/cli/main.js +726 -0
  117. package/dist/cli/onboarding.js +227 -0
  118. package/dist/cli/run.js +308 -0
  119. package/dist/cli/setup-wizard.js +235 -0
  120. package/dist/core/config/runtime-config.js +201 -0
  121. package/dist/core/config/user-config.js +510 -0
  122. package/dist/core/config/validation.js +521 -0
  123. package/dist/core/constants.js +400 -0
  124. package/dist/core/git.js +145 -0
  125. package/dist/core/legacy-compat.js +60 -0
  126. package/dist/core/logging.js +101 -0
  127. package/dist/core/platform.js +59 -0
  128. package/dist/core/process-control.js +315 -0
  129. package/dist/core/secrets/index.js +42 -0
  130. package/dist/core/secrets/inline-seal.js +60 -0
  131. package/dist/core/secrets/ref.js +33 -0
  132. package/dist/core/secrets/resolver.js +65 -0
  133. package/dist/core/secrets/store.js +63 -0
  134. package/dist/core/utils.js +233 -0
  135. package/dist/core/version.js +15 -0
  136. package/dist/dashboard/platform.js +219 -0
  137. package/dist/dashboard/routes/agents.js +450 -0
  138. package/dist/dashboard/routes/cli.js +174 -0
  139. package/dist/dashboard/routes/config.js +523 -0
  140. package/dist/dashboard/routes/extensions.js +745 -0
  141. package/dist/dashboard/routes/local-models.js +290 -0
  142. package/dist/dashboard/routes/models.js +324 -0
  143. package/dist/dashboard/routes/sessions.js +838 -0
  144. package/dist/dashboard/runtime.js +410 -0
  145. package/dist/dashboard/server.js +237 -0
  146. package/dist/dashboard/session-control.js +347 -0
  147. package/dist/model/catalog.js +104 -0
  148. package/dist/model/index.js +20 -0
  149. package/dist/model/injector.js +272 -0
  150. package/dist/model/provider-models.js +112 -0
  151. package/dist/model/store.js +212 -0
  152. package/dist/model/types.js +13 -0
  153. package/dist/model/validation.js +203 -0
  154. 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());