codekin 0.6.4 → 0.7.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 (86) hide show
  1. package/README.md +8 -5
  2. package/bin/codekin.mjs +69 -6
  3. package/dist/assets/index-CLuQVRRb.css +1 -0
  4. package/dist/assets/index-JVnFWiSw.js +185 -0
  5. package/dist/index.html +2 -2
  6. package/package.json +6 -4
  7. package/server/dist/anthropic-models.d.ts +40 -0
  8. package/server/dist/anthropic-models.js +212 -0
  9. package/server/dist/anthropic-models.js.map +1 -0
  10. package/server/dist/claude-process.d.ts +32 -3
  11. package/server/dist/claude-process.js +129 -9
  12. package/server/dist/claude-process.js.map +1 -1
  13. package/server/dist/codex-process.d.ts +147 -0
  14. package/server/dist/codex-process.js +741 -0
  15. package/server/dist/codex-process.js.map +1 -0
  16. package/server/dist/coding-process.d.ts +16 -4
  17. package/server/dist/coding-process.js +10 -0
  18. package/server/dist/coding-process.js.map +1 -1
  19. package/server/dist/commit-event-handler.d.ts +14 -1
  20. package/server/dist/commit-event-handler.js +40 -8
  21. package/server/dist/commit-event-handler.js.map +1 -1
  22. package/server/dist/config.d.ts +25 -0
  23. package/server/dist/config.js +42 -0
  24. package/server/dist/config.js.map +1 -1
  25. package/server/dist/opencode-process.d.ts +142 -5
  26. package/server/dist/opencode-process.js +664 -84
  27. package/server/dist/opencode-process.js.map +1 -1
  28. package/server/dist/orchestrator-children.d.ts +94 -7
  29. package/server/dist/orchestrator-children.js +375 -65
  30. package/server/dist/orchestrator-children.js.map +1 -1
  31. package/server/dist/orchestrator-manager.d.ts +10 -0
  32. package/server/dist/orchestrator-manager.js +70 -18
  33. package/server/dist/orchestrator-manager.js.map +1 -1
  34. package/server/dist/orchestrator-monitor.d.ts +7 -1
  35. package/server/dist/orchestrator-monitor.js +62 -19
  36. package/server/dist/orchestrator-monitor.js.map +1 -1
  37. package/server/dist/orchestrator-notify.d.ts +42 -0
  38. package/server/dist/orchestrator-notify.js +42 -0
  39. package/server/dist/orchestrator-notify.js.map +1 -0
  40. package/server/dist/orchestrator-outbox.d.ts +48 -0
  41. package/server/dist/orchestrator-outbox.js +154 -0
  42. package/server/dist/orchestrator-outbox.js.map +1 -0
  43. package/server/dist/orchestrator-session-router.js +43 -1
  44. package/server/dist/orchestrator-session-router.js.map +1 -1
  45. package/server/dist/prompt-router.d.ts +22 -1
  46. package/server/dist/prompt-router.js +94 -11
  47. package/server/dist/prompt-router.js.map +1 -1
  48. package/server/dist/session-archive.js +11 -1
  49. package/server/dist/session-archive.js.map +1 -1
  50. package/server/dist/session-lifecycle.d.ts +1 -0
  51. package/server/dist/session-lifecycle.js +37 -0
  52. package/server/dist/session-lifecycle.js.map +1 -1
  53. package/server/dist/session-manager.d.ts +49 -2
  54. package/server/dist/session-manager.js +221 -33
  55. package/server/dist/session-manager.js.map +1 -1
  56. package/server/dist/session-naming.d.ts +4 -0
  57. package/server/dist/session-naming.js +26 -5
  58. package/server/dist/session-naming.js.map +1 -1
  59. package/server/dist/session-routes.js +42 -2
  60. package/server/dist/session-routes.js.map +1 -1
  61. package/server/dist/stepflow-handler.js +2 -2
  62. package/server/dist/stepflow-handler.js.map +1 -1
  63. package/server/dist/tsconfig.tsbuildinfo +1 -1
  64. package/server/dist/types.d.ts +24 -3
  65. package/server/dist/types.js +1 -9
  66. package/server/dist/types.js.map +1 -1
  67. package/server/dist/upload-routes.d.ts +7 -0
  68. package/server/dist/upload-routes.js +85 -28
  69. package/server/dist/upload-routes.js.map +1 -1
  70. package/server/dist/webhook-handler.js +3 -3
  71. package/server/dist/webhook-handler.js.map +1 -1
  72. package/server/dist/workflow-config.d.ts +2 -2
  73. package/server/dist/workflow-engine.d.ts +20 -0
  74. package/server/dist/workflow-engine.js +52 -15
  75. package/server/dist/workflow-engine.js.map +1 -1
  76. package/server/dist/workflow-loader.d.ts +5 -5
  77. package/server/dist/workflow-loader.js +169 -54
  78. package/server/dist/workflow-loader.js.map +1 -1
  79. package/server/dist/workflow-routes.js +36 -2
  80. package/server/dist/workflow-routes.js.map +1 -1
  81. package/server/dist/ws-message-handler.js +24 -9
  82. package/server/dist/ws-message-handler.js.map +1 -1
  83. package/server/dist/ws-server.js +53 -11
  84. package/server/dist/ws-server.js.map +1 -1
  85. package/dist/assets/index-BRB_Ksyk.js +0 -182
  86. package/dist/assets/index-Q2WSVlHo.css +0 -1
@@ -0,0 +1,741 @@
1
+ /**
2
+ * Manages an OpenAI Codex CLI session via `codex app-server` (JSON-RPC 2.0
3
+ * over stdio, newline-delimited JSON).
4
+ *
5
+ * Codex's app-server is OpenAI's documented integration surface for building
6
+ * UIs on top of Codex (it powers their own IDE extensions). The lifecycle is:
7
+ * initialize → initialized → thread/start (or thread/resume) → turn/start
8
+ * with streamed item/* notifications per turn and server-initiated JSON-RPC
9
+ * requests for command/file-change approvals.
10
+ *
11
+ * This class wraps that protocol behind the same CodingProcess interface
12
+ * implemented by ClaudeProcess and OpenCodeProcess, so SessionManager works
13
+ * identically for all three providers.
14
+ *
15
+ * Wire schema verified against codex-cli 0.139.0 (`codex app-server generate-ts`):
16
+ * - approvalPolicy: 'untrusted' | 'on-failure' | 'on-request' | 'never'
17
+ * - sandbox: 'read-only' | 'workspace-write' | 'danger-full-access'
18
+ * - approval decisions: 'accept' | 'acceptForSession' | 'decline' | 'cancel'
19
+ * - text input parts require snake_case `text_elements`
20
+ *
21
+ * Auth: the host must be pre-authenticated via `codex login` (writes
22
+ * ~/.codex/auth.json, reused by app-server with automatic token refresh).
23
+ */
24
+ import { spawn } from 'child_process';
25
+ import { createInterface } from 'readline';
26
+ import { existsSync } from 'fs';
27
+ import { extname } from 'path';
28
+ import { EventEmitter } from 'events';
29
+ import { randomUUID } from 'crypto';
30
+ import { CODEX_CAPABILITIES } from './coding-process.js';
31
+ import { summarizeToolInput } from './tool-labels.js';
32
+ /** Default timeout for client→server JSON-RPC requests. */
33
+ const RPC_TIMEOUT_MS = 30_000;
34
+ /** Prefix for synthesized approval request IDs surfaced via control_request. */
35
+ const APPROVAL_ID_PREFIX = 'codex-approval-';
36
+ const CODEX_BINARY = process.env.CODEX_BINARY || 'codex';
37
+ /** Env vars stripped from the child process env (same filtering as opencode-process). */
38
+ const API_KEY_VARS = new Set(['ANTHROPIC_API_KEY', 'CLAUDE_CODE_API_KEY', 'AUTH_TOKEN', 'AUTH_TOKEN_FILE']);
39
+ function buildEnv(extraEnv) {
40
+ return {
41
+ ...Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] != null &&
42
+ !API_KEY_VARS.has(entry[0]) &&
43
+ (!entry[0].startsWith('GIT_') || entry[0] === 'GIT_EDITOR'))),
44
+ ...extraEnv,
45
+ };
46
+ }
47
+ let modelCache = null;
48
+ const MODEL_CACHE_TTL_MS = 10 * 60 * 1000;
49
+ /**
50
+ * Fetch the available Codex models by spawning a short-lived app-server and
51
+ * calling model/list. Results reflect what the host's auth allows. Returns an
52
+ * empty array when the binary is missing or not authenticated. Cached for
53
+ * 10 minutes (the list only changes on CLI upgrade or auth change).
54
+ */
55
+ export async function fetchCodexModels() {
56
+ if (modelCache && Date.now() - modelCache.fetchedAt < MODEL_CACHE_TTL_MS) {
57
+ return { models: modelCache.models };
58
+ }
59
+ return new Promise((resolve) => {
60
+ let settled = false;
61
+ const done = (models) => {
62
+ if (settled)
63
+ return;
64
+ settled = true;
65
+ clearTimeout(timer);
66
+ try {
67
+ proc.kill('SIGTERM');
68
+ }
69
+ catch { /* already dead */ }
70
+ if (models.length > 0)
71
+ modelCache = { models, fetchedAt: Date.now() };
72
+ resolve({ models });
73
+ };
74
+ const timer = setTimeout(() => { done([]); }, 15_000);
75
+ let proc;
76
+ try {
77
+ proc = spawn(CODEX_BINARY, ['app-server'], { env: buildEnv({}), stdio: ['pipe', 'pipe', 'ignore'] });
78
+ }
79
+ catch {
80
+ clearTimeout(timer);
81
+ resolve({ models: [] });
82
+ return;
83
+ }
84
+ proc.on('error', () => { done([]); });
85
+ proc.on('close', () => { done([]); });
86
+ const rl = createInterface({ input: proc.stdout });
87
+ const write = (msg) => {
88
+ try {
89
+ proc.stdin.write(JSON.stringify(msg) + '\n');
90
+ }
91
+ catch {
92
+ done([]);
93
+ }
94
+ };
95
+ rl.on('line', (line) => {
96
+ let msg;
97
+ try {
98
+ msg = JSON.parse(line);
99
+ }
100
+ catch {
101
+ return;
102
+ }
103
+ if (msg.id === 1 && msg.result !== undefined) {
104
+ write({ method: 'initialized' });
105
+ write({ id: 2, method: 'model/list', params: { includeHidden: false } });
106
+ }
107
+ else if (msg.id === 2) {
108
+ const data = msg.result?.data ?? [];
109
+ done(data.map(m => ({ id: m.id, name: m.displayName, description: m.description, isDefault: m.isDefault })));
110
+ }
111
+ });
112
+ write({
113
+ id: 1,
114
+ method: 'initialize',
115
+ params: { clientInfo: { name: 'codekin', title: 'Codekin', version: '1.0.0' }, capabilities: null },
116
+ });
117
+ });
118
+ }
119
+ /** Reset the model cache (test helper). */
120
+ export function clearCodexModelCache() {
121
+ modelCache = null;
122
+ }
123
+ /** Map Codekin's permission mode to Codex approvalPolicy + sandbox values. */
124
+ function policyForMode(mode) {
125
+ switch (mode) {
126
+ case 'bypassPermissions':
127
+ case 'dangerouslySkipPermissions':
128
+ return { approvalPolicy: 'never', sandbox: 'danger-full-access' };
129
+ case 'plan':
130
+ return { approvalPolicy: 'on-request', sandbox: 'read-only' };
131
+ default:
132
+ return { approvalPolicy: 'on-request', sandbox: 'workspace-write' };
133
+ }
134
+ }
135
+ // ---------------------------------------------------------------------------
136
+ // CodexProcess
137
+ // ---------------------------------------------------------------------------
138
+ export class CodexProcess extends EventEmitter {
139
+ provider = 'codex';
140
+ capabilities = CODEX_CAPABILITIES;
141
+ proc = null;
142
+ rl = null;
143
+ alive = false;
144
+ ready = false;
145
+ sessionId;
146
+ threadId = null;
147
+ workingDir;
148
+ model;
149
+ extraEnv;
150
+ permissionMode;
151
+ startupTimer = null;
152
+ killTimer = null;
153
+ stderrTail = '';
154
+ /** Set when spawn() itself fails (ENOENT etc.) — restart should preserve thread id. */
155
+ _spawnFailed = false;
156
+ /** Set when Codex reports an auth failure — restarts will not help until `codex login`. */
157
+ _authFailed = false;
158
+ /** Set once the process emits at least one valid JSON message on stdout. */
159
+ _receivedOutput = false;
160
+ // JSON-RPC state
161
+ nextRpcId = 1;
162
+ pending = new Map();
163
+ /** Maps synthesized approval requestIds → the originating JSON-RPC request id + method. */
164
+ serverApprovals = new Map();
165
+ // Per-turn streaming state
166
+ turnActive = false;
167
+ currentTurnId = null;
168
+ receivedDeltas = false;
169
+ reasoningBuffer = '';
170
+ emittedReasoningSummary = false;
171
+ lastReasoningItemId = null;
172
+ queuedMessages = [];
173
+ constructor(workingDir, opts) {
174
+ super();
175
+ this.workingDir = workingDir;
176
+ this.sessionId = opts?.sessionId || randomUUID();
177
+ this.threadId = opts?.codexThreadId || null;
178
+ this.model = opts?.model;
179
+ this.extraEnv = opts?.extraEnv || {};
180
+ this.permissionMode = opts?.permissionMode;
181
+ }
182
+ /** Spawn `codex app-server` and run the initialize → thread/start handshake. */
183
+ start() {
184
+ if (this.proc)
185
+ return;
186
+ if (!existsSync(this.workingDir)) {
187
+ this.emit('error', `Working directory does not exist: ${this.workingDir}`);
188
+ this.emit('exit', 1, null);
189
+ return;
190
+ }
191
+ this.alive = true;
192
+ this.proc = spawn(CODEX_BINARY, ['app-server'], {
193
+ cwd: this.workingDir,
194
+ env: buildEnv(this.extraEnv),
195
+ stdio: ['pipe', 'pipe', 'pipe'],
196
+ });
197
+ this.proc.on('error', (err) => {
198
+ this._spawnFailed = true;
199
+ this.alive = false;
200
+ const hint = err.code === 'ENOENT'
201
+ ? 'Codex CLI not found — install it with: npm install -g @openai/codex'
202
+ : `Failed to start Codex CLI: ${err.message}`;
203
+ this.emit('error', hint);
204
+ this.cleanupTimers();
205
+ this.emit('exit', 1, null);
206
+ });
207
+ this.proc.stderr?.on('data', (chunk) => {
208
+ this.stderrTail = (this.stderrTail + chunk.toString()).slice(-2000);
209
+ });
210
+ this.rl = createInterface({ input: this.proc.stdout });
211
+ this.rl.on('line', (line) => { this.handleLine(line); });
212
+ this.proc.on('close', (code, signal) => {
213
+ const wasAlive = this.alive;
214
+ this.alive = false;
215
+ this.ready = false;
216
+ this.cleanupTimers();
217
+ // Settle outstanding RPC promises so initialize()/sendMessage() don't hang
218
+ for (const [, req] of this.pending) {
219
+ if (req.timer)
220
+ clearTimeout(req.timer);
221
+ req.reject(new Error(`Codex app-server exited before responding to ${req.method}`));
222
+ }
223
+ this.pending.clear();
224
+ if (wasAlive && this.turnActive) {
225
+ this.turnActive = false;
226
+ this.emit('result', 'Codex exited unexpectedly mid-turn', true);
227
+ }
228
+ this.emit('exit', code, signal);
229
+ });
230
+ // Startup timeout — if the handshake never completes, surface an error.
231
+ this.startupTimer = setTimeout(() => {
232
+ this.startupTimer = null;
233
+ if (this.alive && !this.ready) {
234
+ this.emit('error', `Codex process failed to initialize within 60 seconds${this.stderrTail ? ` — stderr: ${this.stderrTail.slice(-300)}` : ''}`);
235
+ this.stop();
236
+ }
237
+ }, 60_000);
238
+ void this.initialize().catch((err) => {
239
+ if (!this.alive)
240
+ return;
241
+ const msg = err.message || String(err);
242
+ if (/unauthorized|not.*logged.*in|auth/i.test(msg)) {
243
+ this._authFailed = true;
244
+ this.emit('error', 'Codex is not authenticated. Run `codex login` (or `codex login --device-auth`) on the host.');
245
+ }
246
+ else {
247
+ this.emit('error', `Codex initialization failed: ${msg}`);
248
+ }
249
+ this.stop();
250
+ });
251
+ }
252
+ async initialize() {
253
+ await this.request('initialize', {
254
+ clientInfo: { name: 'codekin', title: 'Codekin', version: '1.0.0' },
255
+ capabilities: null,
256
+ });
257
+ this.notify('initialized');
258
+ const { approvalPolicy, sandbox } = policyForMode(this.permissionMode);
259
+ const params = {
260
+ cwd: this.workingDir,
261
+ approvalPolicy,
262
+ sandbox,
263
+ ...(this.model ? { model: this.model } : {}),
264
+ };
265
+ const res = this.threadId
266
+ ? await this.request('thread/resume', { threadId: this.threadId, ...params })
267
+ : await this.request('thread/start', params);
268
+ const data = res;
269
+ if (data.thread?.id)
270
+ this.threadId = data.thread.id;
271
+ if (this.startupTimer) {
272
+ clearTimeout(this.startupTimer);
273
+ this.startupTimer = null;
274
+ }
275
+ this.ready = true;
276
+ this.emit('system_init', this.model || data.model || 'codex');
277
+ // Flush any messages queued while the handshake was in flight
278
+ const queued = this.queuedMessages;
279
+ this.queuedMessages = [];
280
+ for (const content of queued)
281
+ this.dispatchTurn(content);
282
+ }
283
+ // -------------------------------------------------------------------------
284
+ // JSON-RPC plumbing
285
+ // -------------------------------------------------------------------------
286
+ write(msg) {
287
+ if (!this.proc?.stdin?.writable)
288
+ return;
289
+ try {
290
+ this.proc.stdin.write(JSON.stringify(msg) + '\n');
291
+ }
292
+ catch (err) {
293
+ this.emit('error', `Failed to write to Codex: ${err instanceof Error ? err.message : String(err)}`);
294
+ }
295
+ }
296
+ /** Send a client→server request. timeoutMs=0 disables the timeout (long-running turns). */
297
+ request(method, params, timeoutMs = RPC_TIMEOUT_MS) {
298
+ const id = this.nextRpcId++;
299
+ return new Promise((resolve, reject) => {
300
+ const timer = timeoutMs > 0
301
+ ? setTimeout(() => {
302
+ this.pending.delete(id);
303
+ reject(new Error(`Codex request ${method} timed out after ${timeoutMs / 1000}s`));
304
+ }, timeoutMs)
305
+ : null;
306
+ this.pending.set(id, { resolve, reject, method, timer });
307
+ this.write({ id, method, ...(params !== undefined ? { params } : {}) });
308
+ });
309
+ }
310
+ notify(method, params) {
311
+ this.write({ method, ...(params !== undefined ? { params } : {}) });
312
+ }
313
+ handleLine(line) {
314
+ const trimmed = line.trim();
315
+ if (!trimmed)
316
+ return;
317
+ let msg;
318
+ try {
319
+ msg = JSON.parse(trimmed);
320
+ }
321
+ catch {
322
+ // Codex may print non-JSON banners on stdout — skip them.
323
+ return;
324
+ }
325
+ this._receivedOutput = true;
326
+ if (msg.id !== undefined && msg.method) {
327
+ // Server-initiated request (approval ask)
328
+ this.handleServerRequest(msg.id, msg.method, msg.params ?? {});
329
+ return;
330
+ }
331
+ if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
332
+ const req = this.pending.get(msg.id);
333
+ if (!req)
334
+ return;
335
+ this.pending.delete(msg.id);
336
+ if (req.timer)
337
+ clearTimeout(req.timer);
338
+ if (msg.error)
339
+ req.reject(new Error(msg.error.message || `Codex ${req.method} failed`));
340
+ else
341
+ req.resolve(msg.result);
342
+ return;
343
+ }
344
+ if (msg.method) {
345
+ this.handleNotification(msg.method, msg.params ?? {});
346
+ }
347
+ }
348
+ // -------------------------------------------------------------------------
349
+ // Server-initiated approval requests
350
+ // -------------------------------------------------------------------------
351
+ handleServerRequest(rpcId, method, params) {
352
+ const autoApprove = this.permissionMode === 'bypassPermissions' || this.permissionMode === 'dangerouslySkipPermissions';
353
+ switch (method) {
354
+ case 'item/commandExecution/requestApproval': {
355
+ if (autoApprove) {
356
+ this.write({ id: rpcId, result: { decision: 'accept' } });
357
+ return;
358
+ }
359
+ const requestId = `${APPROVAL_ID_PREFIX}${rpcId}`;
360
+ this.serverApprovals.set(requestId, { rpcId, method });
361
+ const input = {
362
+ command: params.command,
363
+ ...(params.cwd ? { cwd: params.cwd } : {}),
364
+ ...(params.reason ? { reason: params.reason } : {}),
365
+ };
366
+ this.emit('control_request', requestId, 'Bash', input);
367
+ return;
368
+ }
369
+ case 'item/fileChange/requestApproval': {
370
+ if (autoApprove || this.permissionMode === 'acceptEdits') {
371
+ this.write({ id: rpcId, result: { decision: 'accept' } });
372
+ return;
373
+ }
374
+ const requestId = `${APPROVAL_ID_PREFIX}${rpcId}`;
375
+ this.serverApprovals.set(requestId, { rpcId, method });
376
+ const input = {
377
+ ...(params.reason ? { reason: params.reason } : {}),
378
+ ...(params.grantRoot ? { grantRoot: params.grantRoot } : {}),
379
+ };
380
+ this.emit('control_request', requestId, 'Edit', input);
381
+ return;
382
+ }
383
+ default:
384
+ // Unsupported server request (permissions profiles, tool user input,
385
+ // MCP elicitation, …). Respond with a JSON-RPC error so the turn does
386
+ // not hang waiting for an answer we cannot provide.
387
+ console.warn(`[codex] Unsupported server request: ${method} — declining`);
388
+ this.write({ id: rpcId, error: { code: -32601, message: `Codekin does not support ${method}` } });
389
+ }
390
+ }
391
+ // -------------------------------------------------------------------------
392
+ // Notification → ClaudeProcessEvents mapping
393
+ // -------------------------------------------------------------------------
394
+ handleNotification(method, params) {
395
+ switch (method) {
396
+ case 'thread/started': {
397
+ const thread = params.thread;
398
+ if (thread?.id)
399
+ this.threadId = thread.id;
400
+ break;
401
+ }
402
+ case 'turn/started': {
403
+ const turn = params.turn;
404
+ this.currentTurnId = turn?.id ?? null;
405
+ this.turnActive = true;
406
+ break;
407
+ }
408
+ case 'item/agentMessage/delta': {
409
+ const delta = params.delta;
410
+ if (delta) {
411
+ this.receivedDeltas = true;
412
+ this.emit('text', delta);
413
+ }
414
+ break;
415
+ }
416
+ case 'item/reasoning/summaryTextDelta': {
417
+ const delta = params.delta;
418
+ const itemId = params.itemId;
419
+ if (!delta)
420
+ break;
421
+ // New reasoning item → allow a fresh thinking summary
422
+ if (itemId && itemId !== this.lastReasoningItemId) {
423
+ this.lastReasoningItemId = itemId;
424
+ this.reasoningBuffer = '';
425
+ this.emittedReasoningSummary = false;
426
+ }
427
+ this.reasoningBuffer += delta;
428
+ if (this.reasoningBuffer.length > 20 && !this.emittedReasoningSummary) {
429
+ this.emittedReasoningSummary = true;
430
+ this.emit('thinking', this.summarizeReasoning(this.reasoningBuffer));
431
+ }
432
+ break;
433
+ }
434
+ case 'item/started': {
435
+ const item = params.item;
436
+ if (item)
437
+ this.handleItemStarted(item);
438
+ break;
439
+ }
440
+ case 'item/completed': {
441
+ const item = params.item;
442
+ if (item)
443
+ this.handleItemCompleted(item);
444
+ break;
445
+ }
446
+ case 'turn/plan/updated': {
447
+ const plan = params.plan;
448
+ if (!Array.isArray(plan))
449
+ break;
450
+ const tasks = plan.map((p, i) => ({
451
+ id: String(i + 1),
452
+ subject: p.step,
453
+ status: p.status === 'inProgress' ? 'in_progress' : p.status === 'completed' ? 'completed' : 'pending',
454
+ }));
455
+ this.emit('todo_update', tasks);
456
+ break;
457
+ }
458
+ case 'turn/completed': {
459
+ const turn = params.turn;
460
+ this.turnActive = false;
461
+ this.currentTurnId = null;
462
+ if (turn?.status === 'failed') {
463
+ this.emit('result', turn.error?.message || 'Codex turn failed', true);
464
+ }
465
+ else {
466
+ this.emit('result', '', false);
467
+ }
468
+ // Start the next queued turn, if any
469
+ const next = this.queuedMessages.shift();
470
+ if (next !== undefined)
471
+ this.dispatchTurn(next);
472
+ break;
473
+ }
474
+ case 'error': {
475
+ const error = params.error;
476
+ const willRetry = params.willRetry === true;
477
+ const message = error?.message || 'Unknown Codex error';
478
+ if (error?.codexErrorInfo === 'usageLimitExceeded') {
479
+ this.emit('rate_limit', { ...params });
480
+ this.emit('error', message);
481
+ }
482
+ else if (error?.codexErrorInfo === 'unauthorized') {
483
+ this._authFailed = true;
484
+ this.emit('error', 'Codex is not authenticated. Run `codex login` on the host.');
485
+ }
486
+ else if (!willRetry) {
487
+ this.emit('error', message);
488
+ }
489
+ else {
490
+ console.warn(`[codex] Transient error (will retry): ${message}`);
491
+ }
492
+ break;
493
+ }
494
+ case 'account/rateLimits/updated': {
495
+ this.emit('rate_limit', { ...params });
496
+ break;
497
+ }
498
+ default:
499
+ // Many notifications (token usage, diffs, raw items, …) need no mapping.
500
+ break;
501
+ }
502
+ }
503
+ handleItemStarted(item) {
504
+ switch (item.type) {
505
+ case 'commandExecution':
506
+ this.emit('tool_active', 'Bash', item.command ? summarizeToolInput('Bash', { command: item.command }) : undefined);
507
+ break;
508
+ case 'fileChange': {
509
+ const paths = (item.changes ?? []).map(c => c.path).join(', ');
510
+ this.emit('tool_active', 'Edit', paths || undefined);
511
+ break;
512
+ }
513
+ case 'mcpToolCall':
514
+ this.emit('tool_active', item.tool || 'MCP', item.server);
515
+ break;
516
+ case 'webSearch':
517
+ this.emit('tool_active', 'WebSearch', item.query);
518
+ break;
519
+ }
520
+ }
521
+ handleItemCompleted(item) {
522
+ switch (item.type) {
523
+ case 'agentMessage':
524
+ // Text normally arrives via deltas; emit the full text only when no
525
+ // deltas were received this turn (e.g. non-streaming model/config).
526
+ if (item.text && !this.receivedDeltas) {
527
+ this.emit('text', item.text);
528
+ }
529
+ break;
530
+ case 'reasoning': {
531
+ // Fallback: emit a thinking summary when no summary deltas streamed.
532
+ const summary = (item.summary ?? []).join(' ').trim();
533
+ if (summary && !this.emittedReasoningSummary) {
534
+ this.emittedReasoningSummary = true;
535
+ this.emit('thinking', this.summarizeReasoning(summary));
536
+ }
537
+ break;
538
+ }
539
+ case 'commandExecution': {
540
+ const failed = item.status === 'failed' || (item.exitCode != null && item.exitCode !== 0);
541
+ const declined = item.status === 'declined';
542
+ const output = item.aggregatedOutput || '';
543
+ const summary = declined ? 'Declined' : output ? output.slice(0, 200) : undefined;
544
+ this.emit('tool_done', 'Bash', summary);
545
+ if (output) {
546
+ const truncated = output.length > 2000
547
+ ? output.slice(0, 2000) + `\n… (truncated, ${output.length} chars total)`
548
+ : output;
549
+ this.emit('tool_output', truncated, failed);
550
+ }
551
+ break;
552
+ }
553
+ case 'fileChange': {
554
+ const declined = item.status === 'declined';
555
+ const failed = item.status === 'failed';
556
+ const paths = (item.changes ?? []).map(c => c.path).join(', ');
557
+ const summary = declined ? 'Declined' : failed ? `Failed: ${paths}` : paths || undefined;
558
+ this.emit('tool_done', 'Edit', summary);
559
+ break;
560
+ }
561
+ case 'mcpToolCall':
562
+ this.emit('tool_done', item.tool || 'MCP', item.status === 'failed' ? 'Error' : undefined);
563
+ break;
564
+ case 'webSearch':
565
+ this.emit('tool_done', 'WebSearch', item.query);
566
+ break;
567
+ }
568
+ }
569
+ /** Extract a short first-sentence summary from reasoning text (mirrors OpenCodeProcess). */
570
+ summarizeReasoning(text) {
571
+ const match = text.match(/^(.+?[.!?\n])/);
572
+ return match && match[1].length <= 120
573
+ ? match[1].replace(/\n/g, ' ').trim()
574
+ : text.slice(0, 80).trim();
575
+ }
576
+ // -------------------------------------------------------------------------
577
+ // CodingProcess interface
578
+ // -------------------------------------------------------------------------
579
+ /** Send a user message — starts a new turn (or queues it if one is active). */
580
+ sendMessage(content) {
581
+ if (!this.alive) {
582
+ this.emit('error', 'Codex process is not running');
583
+ return;
584
+ }
585
+ if (!this.ready || this.turnActive) {
586
+ this.queuedMessages.push(content);
587
+ return;
588
+ }
589
+ this.dispatchTurn(content);
590
+ }
591
+ dispatchTurn(content) {
592
+ if (!this.threadId) {
593
+ this.queuedMessages.unshift(content);
594
+ return;
595
+ }
596
+ // Reset per-turn streaming state
597
+ this.turnActive = true;
598
+ this.receivedDeltas = false;
599
+ this.reasoningBuffer = '';
600
+ this.emittedReasoningSummary = false;
601
+ this.lastReasoningItemId = null;
602
+ const input = this.buildInput(content);
603
+ // No timeout: turn/start's response arrives when the turn is created, but
604
+ // be safe against servers that respond late in long turns.
605
+ this.request('turn/start', { threadId: this.threadId, input }, 0).catch((err) => {
606
+ if (!this.alive)
607
+ return;
608
+ this.turnActive = false;
609
+ this.emit('error', `Failed to start Codex turn: ${err.message}`);
610
+ this.emit('result', err.message, true);
611
+ });
612
+ }
613
+ /**
614
+ * Build the turn input parts. Parses the frontend's
615
+ * `[Attached files: …]` prefix and converts images to localImage parts
616
+ * (Codex reads them from disk — no base64 upload needed).
617
+ */
618
+ buildInput(content) {
619
+ const parts = [];
620
+ let textContent = content;
621
+ const attachMatch = content.match(/^\[Attached files: ([^\]]+)\]\n?/);
622
+ if (attachMatch) {
623
+ textContent = content.slice(attachMatch[0].length);
624
+ const imageExts = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']);
625
+ for (const filePath of attachMatch[1].split(',').map(p => p.trim())) {
626
+ if (!existsSync(filePath)) {
627
+ console.warn(`[codex] Attached file not found: ${filePath}`);
628
+ continue;
629
+ }
630
+ if (imageExts.has(extname(filePath).toLowerCase())) {
631
+ parts.push({ type: 'localImage', path: filePath });
632
+ }
633
+ else {
634
+ // Codex has no generic file part — reference the path in text so the
635
+ // agent can read it with its own tools.
636
+ textContent = `[Attached file: ${filePath}]\n${textContent}`;
637
+ }
638
+ }
639
+ }
640
+ if (textContent.trim()) {
641
+ parts.push({ type: 'text', text: textContent, text_elements: [] });
642
+ }
643
+ return parts;
644
+ }
645
+ /** No-op for Codex — raw protocol data is Claude-specific. */
646
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
647
+ sendRaw(_) {
648
+ // Codex uses JSON-RPC requests, not raw stdin passthrough
649
+ }
650
+ /**
651
+ * Respond to an approval request. Maps Codekin's allow/deny to Codex's
652
+ * accept/decline decision on the original server-initiated JSON-RPC request.
653
+ */
654
+ sendControlResponse(requestId, behavior) {
655
+ const approval = this.serverApprovals.get(requestId);
656
+ if (!approval) {
657
+ console.warn(`[codex] No pending approval for request ${requestId}`);
658
+ return;
659
+ }
660
+ this.serverApprovals.delete(requestId);
661
+ this.write({ id: approval.rpcId, result: { decision: behavior === 'allow' ? 'accept' : 'decline' } });
662
+ }
663
+ /** Interrupt any active turn, then terminate the app-server child. */
664
+ stop() {
665
+ if (!this.alive && !this.proc)
666
+ return;
667
+ this.alive = false;
668
+ this.ready = false;
669
+ this.cleanupTimers();
670
+ if (this.turnActive && this.threadId && this.currentTurnId && this.proc?.stdin?.writable) {
671
+ // Best-effort interrupt — don't wait for the response.
672
+ this.write({ id: this.nextRpcId++, method: 'turn/interrupt', params: { threadId: this.threadId, turnId: this.currentTurnId } });
673
+ }
674
+ if (this.proc && this.proc.exitCode === null && !this.proc.killed) {
675
+ this.proc.kill('SIGTERM');
676
+ this.killTimer = setTimeout(() => {
677
+ this.killTimer = null;
678
+ // killed=true only means SIGTERM was sent — check exitCode to see if
679
+ // the process actually terminated before escalating to SIGKILL.
680
+ if (this.proc && this.proc.exitCode === null)
681
+ this.proc.kill('SIGKILL');
682
+ }, 5_000);
683
+ }
684
+ }
685
+ cleanupTimers() {
686
+ if (this.startupTimer) {
687
+ clearTimeout(this.startupTimer);
688
+ this.startupTimer = null;
689
+ }
690
+ if (this.killTimer) {
691
+ clearTimeout(this.killTimer);
692
+ this.killTimer = null;
693
+ }
694
+ }
695
+ /**
696
+ * No-op: Codex emits the full plan on every turn/plan/updated notification,
697
+ * so there is no incremental task state to seed after a restart.
698
+ */
699
+ seedTasks() { }
700
+ isAlive() {
701
+ return this.alive;
702
+ }
703
+ isReady() {
704
+ return this.alive && this.ready && this.threadId !== null;
705
+ }
706
+ getSessionId() {
707
+ return this.threadId ?? this.sessionId;
708
+ }
709
+ waitForExit(timeoutMs = 10000) {
710
+ if (!this.alive && !this.proc)
711
+ return Promise.resolve();
712
+ return new Promise((resolve) => {
713
+ const timer = setTimeout(resolve, timeoutMs);
714
+ this.once('exit', () => {
715
+ clearTimeout(timer);
716
+ resolve();
717
+ });
718
+ });
719
+ }
720
+ // -------------------------------------------------------------------------
721
+ // Restart-scheduler diagnostics (duck-typed by session-lifecycle.ts)
722
+ // -------------------------------------------------------------------------
723
+ /**
724
+ * True when the exit is non-retryable without operator action. For Codex
725
+ * that means an auth failure — restarting cannot help until `codex login`
726
+ * is run on the host. (Named for parity with ClaudeProcess's duck-typed
727
+ * diagnostic; the restart scheduler treats it as "do not auto-restart".)
728
+ */
729
+ hasSessionConflict() {
730
+ return this._authFailed;
731
+ }
732
+ /** True if the process produced at least one valid JSON message before exiting. */
733
+ hadOutput() {
734
+ return this._receivedOutput;
735
+ }
736
+ /** True if spawn() itself failed (ENOENT, EACCES) — process never started. */
737
+ hasSpawnFailed() {
738
+ return this._spawnFailed;
739
+ }
740
+ }
741
+ //# sourceMappingURL=codex-process.js.map