@ccpocket/bridge 0.2.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,17 +3,29 @@ import { randomUUID } from "node:crypto";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { rm, writeFile } from "node:fs/promises";
6
- import { Codex } from "@openai/codex-sdk";
6
+ import { spawn } from "node:child_process";
7
7
  export class CodexProcess extends EventEmitter {
8
- codex;
9
- thread = null;
8
+ child = null;
10
9
  _status = "starting";
11
10
  _threadId = null;
12
11
  stopped = false;
13
12
  startModel;
14
- // User input channel
15
13
  inputResolve = null;
16
- pendingAbort = null;
14
+ pendingTurnId = null;
15
+ pendingTurnCompletion = null;
16
+ pendingApprovals = new Map();
17
+ pendingUserInputs = new Map();
18
+ lastTokenUsage = null;
19
+ rpcSeq = 1;
20
+ pendingRpc = new Map();
21
+ stdoutBuffer = "";
22
+ // Collaboration mode & plan completion state
23
+ _approvalPolicy = "never";
24
+ _collaborationMode = "default";
25
+ lastPlanItemText = null;
26
+ pendingPlanCompletion = null;
27
+ /** Queued plan execution text when inputResolve wasn't ready at approval time. */
28
+ _pendingPlanInput = null;
17
29
  get status() {
18
30
  return this._status;
19
31
  }
@@ -24,67 +36,125 @@ export class CodexProcess extends EventEmitter {
24
36
  return this._threadId;
25
37
  }
26
38
  get isRunning() {
27
- return this.thread !== null;
39
+ return this.child !== null;
28
40
  }
29
- constructor() {
30
- super();
31
- this.codex = new Codex();
41
+ get approvalPolicy() {
42
+ return this._approvalPolicy;
43
+ }
44
+ /**
45
+ * Update approval policy at runtime.
46
+ * Takes effect on the next `turn/start` RPC call.
47
+ */
48
+ setApprovalPolicy(policy) {
49
+ this._approvalPolicy = policy;
50
+ console.log(`[codex-process] Approval policy changed to: ${policy}`);
51
+ }
52
+ /**
53
+ * Set collaboration mode ("plan" or "default").
54
+ * Takes effect on the next `turn/start` RPC call.
55
+ */
56
+ setCollaborationMode(mode) {
57
+ this._collaborationMode = mode;
58
+ console.log(`[codex-process] Collaboration mode changed to: ${mode}`);
59
+ }
60
+ get collaborationMode() {
61
+ return this._collaborationMode;
62
+ }
63
+ /**
64
+ * Rename a thread via the app-server RPC.
65
+ * Sends thread/name/set which persists to ~/.codex/session_index.jsonl.
66
+ */
67
+ async renameThread(name) {
68
+ if (!this._threadId) {
69
+ throw new Error("No thread ID available for rename");
70
+ }
71
+ await this.request("thread/name/set", {
72
+ threadId: this._threadId,
73
+ name,
74
+ });
32
75
  }
33
76
  start(projectPath, options) {
34
- if (this.thread) {
77
+ if (this.child) {
35
78
  this.stop();
36
79
  }
37
80
  this.stopped = false;
38
81
  this._threadId = null;
39
- const threadOpts = {
40
- workingDirectory: projectPath,
41
- approvalPolicy: options?.approvalPolicy ?? "never",
42
- sandboxMode: options?.sandboxMode ?? "workspace-write",
43
- networkAccessEnabled: options?.networkAccessEnabled ?? true,
44
- skipGitRepoCheck: true,
45
- ...(options?.model ? { model: options.model } : {}),
46
- ...(options?.modelReasoningEffort ? { modelReasoningEffort: options.modelReasoningEffort } : {}),
47
- ...(options?.webSearchMode ? { webSearchMode: options.webSearchMode } : {}),
48
- };
49
- console.log(`[codex-process] Starting (cwd: ${projectPath}, sandbox: ${threadOpts.sandboxMode}, approval: ${threadOpts.approvalPolicy}, model: ${threadOpts.model ?? "default"}, reasoning: ${threadOpts.modelReasoningEffort ?? "default"}, network: ${threadOpts.networkAccessEnabled}, webSearch: ${threadOpts.webSearchMode ?? "default"})`);
50
- this.thread = options?.threadId
51
- ? this.codex.resumeThread(options.threadId, threadOpts)
52
- : this.codex.startThread(threadOpts);
53
- this.setStatus("idle");
82
+ this.pendingTurnId = null;
83
+ this.pendingTurnCompletion = null;
84
+ this.pendingApprovals.clear();
85
+ this.pendingUserInputs.clear();
86
+ this.lastTokenUsage = null;
54
87
  this.startModel = options?.model;
55
- // Start input loop
56
- this.runInputLoop().catch((err) => {
57
- if (!this.stopped) {
58
- console.error("[codex-process] Input loop error:", err);
59
- this.emitMessage({
60
- type: "error",
61
- message: `Codex error: ${err instanceof Error ? err.message : String(err)}`,
62
- });
88
+ this._approvalPolicy = options?.approvalPolicy ?? "never";
89
+ this._collaborationMode = options?.collaborationMode ?? "default";
90
+ this.lastPlanItemText = null;
91
+ this.pendingPlanCompletion = null;
92
+ this._pendingPlanInput = null;
93
+ console.log(`[codex-process] Starting app-server (cwd: ${projectPath}, sandbox: ${options?.sandboxMode ?? "workspace-write"}, approval: ${options?.approvalPolicy ?? "never"}, model: ${options?.model ?? "default"}, collaboration: ${this._collaborationMode})`);
94
+ const child = spawn("codex", ["app-server", "--listen", "stdio://"], {
95
+ cwd: projectPath,
96
+ stdio: "pipe",
97
+ env: process.env,
98
+ });
99
+ this.child = child;
100
+ child.stdout.setEncoding("utf8");
101
+ child.stdout.on("data", (chunk) => {
102
+ this.handleStdoutChunk(chunk);
103
+ });
104
+ child.stderr.setEncoding("utf8");
105
+ child.stderr.on("data", (chunk) => {
106
+ const line = chunk.trim();
107
+ if (line) {
108
+ console.log(`[codex-process] stderr: ${line}`);
63
109
  }
110
+ });
111
+ child.on("error", (err) => {
112
+ if (this.stopped)
113
+ return;
114
+ console.error("[codex-process] app-server process error:", err);
115
+ this.emitMessage({ type: "error", message: `Failed to start codex app-server: ${err.message}` });
64
116
  this.setStatus("idle");
65
117
  this.emit("exit", 1);
66
118
  });
119
+ child.on("exit", (code) => {
120
+ const exitCode = code ?? 0;
121
+ this.child = null;
122
+ this.rejectAllPending(new Error("codex app-server exited"));
123
+ if (!this.stopped && exitCode !== 0) {
124
+ this.emitMessage({ type: "error", message: `codex app-server exited with code ${exitCode}` });
125
+ }
126
+ this.setStatus("idle");
127
+ this.emit("exit", code);
128
+ });
129
+ void this.bootstrap(projectPath, options);
67
130
  }
68
131
  stop() {
69
132
  this.stopped = true;
70
- if (this.pendingAbort) {
71
- this.pendingAbort.abort();
72
- this.pendingAbort = null;
73
- }
74
- // Unblock pending input wait
75
133
  if (this.inputResolve) {
76
134
  this.inputResolve({ text: "" });
77
135
  this.inputResolve = null;
78
136
  }
79
- this.thread = null;
137
+ this.pendingApprovals.clear();
138
+ this.pendingUserInputs.clear();
139
+ this.rejectAllPending(new Error("stopped"));
140
+ if (this.child) {
141
+ this.child.kill("SIGTERM");
142
+ this.child = null;
143
+ }
80
144
  this.setStatus("idle");
81
145
  console.log("[codex-process] Stopped");
82
146
  }
83
147
  interrupt() {
84
- if (this.pendingAbort) {
85
- console.log("[codex-process] Interrupting current turn");
86
- this.pendingAbort.abort();
87
- }
148
+ if (!this._threadId || !this.pendingTurnId)
149
+ return;
150
+ void this.request("turn/interrupt", {
151
+ threadId: this._threadId,
152
+ turnId: this.pendingTurnId,
153
+ }).catch((err) => {
154
+ if (!this.stopped) {
155
+ console.warn(`[codex-process] turn/interrupt failed: ${err instanceof Error ? err.message : String(err)}`);
156
+ }
157
+ });
88
158
  }
89
159
  sendInput(text) {
90
160
  if (!this.inputResolve) {
@@ -102,201 +172,788 @@ export class CodexProcess extends EventEmitter {
102
172
  }
103
173
  const resolve = this.inputResolve;
104
174
  this.inputResolve = null;
105
- resolve({
106
- text,
107
- images,
175
+ resolve({ text, images });
176
+ }
177
+ approve(toolUseId, _updatedInput) {
178
+ // Check if this is a plan completion approval
179
+ if (this.pendingPlanCompletion && toolUseId === this.pendingPlanCompletion.toolUseId) {
180
+ this.handlePlanApproved(_updatedInput);
181
+ return;
182
+ }
183
+ const pending = this.resolvePendingApproval(toolUseId);
184
+ if (!pending) {
185
+ console.log("[codex-process] approve() called but no pending permission requests");
186
+ return;
187
+ }
188
+ this.pendingApprovals.delete(pending.toolUseId);
189
+ this.respondToServerRequest(pending.requestId, {
190
+ decision: "accept",
108
191
  });
192
+ this.emitToolResult(pending.toolUseId, "Approved");
193
+ if (this.pendingApprovals.size === 0) {
194
+ this.setStatus("running");
195
+ }
109
196
  }
110
- // ---- Private ----
111
- async runInputLoop() {
197
+ approveAlways(toolUseId) {
198
+ const pending = this.resolvePendingApproval(toolUseId);
199
+ if (!pending) {
200
+ console.log("[codex-process] approveAlways() called but no pending permission requests");
201
+ return;
202
+ }
203
+ this.pendingApprovals.delete(pending.toolUseId);
204
+ this.respondToServerRequest(pending.requestId, {
205
+ decision: "accept",
206
+ acceptSettings: {
207
+ forSession: true,
208
+ },
209
+ });
210
+ this.emitToolResult(pending.toolUseId, "Approved (always)");
211
+ if (this.pendingApprovals.size === 0) {
212
+ this.setStatus("running");
213
+ }
214
+ }
215
+ reject(toolUseId, _message) {
216
+ // Check if this is a plan completion rejection
217
+ if (this.pendingPlanCompletion && toolUseId === this.pendingPlanCompletion.toolUseId) {
218
+ this.handlePlanRejected(_message);
219
+ return;
220
+ }
221
+ const pending = this.resolvePendingApproval(toolUseId);
222
+ if (!pending) {
223
+ console.log("[codex-process] reject() called but no pending permission requests");
224
+ return;
225
+ }
226
+ this.pendingApprovals.delete(pending.toolUseId);
227
+ this.respondToServerRequest(pending.requestId, {
228
+ decision: "decline",
229
+ });
230
+ this.emitToolResult(pending.toolUseId, "Rejected");
231
+ if (this.pendingApprovals.size === 0) {
232
+ this.setStatus("running");
233
+ }
234
+ }
235
+ answer(toolUseId, result) {
236
+ const pending = this.resolvePendingUserInput(toolUseId);
237
+ if (!pending) {
238
+ console.log("[codex-process] answer() called but no pending AskUserQuestion");
239
+ return;
240
+ }
241
+ this.pendingUserInputs.delete(pending.toolUseId);
242
+ this.respondToServerRequest(pending.requestId, {
243
+ answers: buildUserInputAnswers(pending.questions, result),
244
+ });
245
+ this.emitToolResult(pending.toolUseId, "Answered");
246
+ if (this.pendingApprovals.size === 0 && this.pendingUserInputs.size === 0) {
247
+ this.setStatus("running");
248
+ }
249
+ }
250
+ getPendingPermission(toolUseId) {
251
+ // Check plan completion first
252
+ if (this.pendingPlanCompletion) {
253
+ if (!toolUseId || toolUseId === this.pendingPlanCompletion.toolUseId) {
254
+ return {
255
+ toolUseId: this.pendingPlanCompletion.toolUseId,
256
+ toolName: "ExitPlanMode",
257
+ input: { plan: this.pendingPlanCompletion.planText },
258
+ };
259
+ }
260
+ }
261
+ const pending = this.resolvePendingApproval(toolUseId);
262
+ if (pending) {
263
+ return {
264
+ toolUseId: pending.toolUseId,
265
+ toolName: pending.toolName,
266
+ input: { ...pending.input },
267
+ };
268
+ }
269
+ const pendingAsk = this.resolvePendingUserInput(toolUseId);
270
+ if (!pendingAsk)
271
+ return undefined;
272
+ return {
273
+ toolUseId: pendingAsk.toolUseId,
274
+ toolName: "AskUserQuestion",
275
+ input: { ...pendingAsk.input },
276
+ };
277
+ }
278
+ /** Emit a synthetic tool_result so history replay can match it to a permission_request. */
279
+ emitToolResult(toolUseId, content) {
280
+ this.emitMessage({
281
+ type: "tool_result",
282
+ toolUseId,
283
+ content,
284
+ });
285
+ }
286
+ resolvePendingApproval(toolUseId) {
287
+ if (toolUseId)
288
+ return this.pendingApprovals.get(toolUseId);
289
+ const first = this.pendingApprovals.values().next();
290
+ return first.done ? undefined : first.value;
291
+ }
292
+ resolvePendingUserInput(toolUseId) {
293
+ if (toolUseId)
294
+ return this.pendingUserInputs.get(toolUseId);
295
+ const first = this.pendingUserInputs.values().next();
296
+ return first.done ? undefined : first.value;
297
+ }
298
+ // ---------------------------------------------------------------------------
299
+ // Plan completion handlers (native collaboration_mode)
300
+ // ---------------------------------------------------------------------------
301
+ /**
302
+ * Plan approved → switch to Default mode and auto-start execution.
303
+ */
304
+ handlePlanApproved(updatedInput) {
305
+ const planText = updatedInput?.plan ?? this.pendingPlanCompletion?.planText ?? "";
306
+ const resolvedToolUseId = this.pendingPlanCompletion?.toolUseId;
307
+ this.pendingPlanCompletion = null;
308
+ this._collaborationMode = "default";
309
+ console.log("[codex-process] Plan approved, switching to Default mode");
310
+ // Emit synthetic tool_result so history replay knows this approval is resolved
311
+ if (resolvedToolUseId) {
312
+ this.emitToolResult(resolvedToolUseId, "Plan approved");
313
+ }
314
+ // Resolve inputResolve to start the next turn (Default mode) automatically
315
+ if (this.inputResolve) {
316
+ const resolve = this.inputResolve;
317
+ this.inputResolve = null;
318
+ resolve({ text: `Execute the following plan:\n\n${planText}` });
319
+ }
320
+ else {
321
+ // inputResolve may not be ready yet if approval comes before the next
322
+ // input loop iteration. Queue the text so sendInput() can pick it up.
323
+ console.warn("[codex-process] Plan approved but inputResolve not ready, queuing as pending input");
324
+ this._pendingPlanInput = `Execute the following plan:\n\n${planText}`;
325
+ }
326
+ }
327
+ /**
328
+ * Plan rejected → stay in Plan mode and re-plan with feedback.
329
+ */
330
+ handlePlanRejected(feedback) {
331
+ const resolvedToolUseId = this.pendingPlanCompletion?.toolUseId;
332
+ this.pendingPlanCompletion = null;
333
+ console.log("[codex-process] Plan rejected, continuing in Plan mode");
334
+ // Stay in Plan mode
335
+ // Emit synthetic tool_result so history replay knows this approval is resolved
336
+ if (resolvedToolUseId) {
337
+ this.emitToolResult(resolvedToolUseId, "Plan rejected");
338
+ }
339
+ if (feedback) {
340
+ if (this.inputResolve) {
341
+ const resolve = this.inputResolve;
342
+ this.inputResolve = null;
343
+ resolve({ text: feedback });
344
+ }
345
+ else {
346
+ console.warn("[codex-process] Plan rejected but inputResolve not ready, queuing feedback");
347
+ this._pendingPlanInput = feedback;
348
+ }
349
+ }
350
+ else {
351
+ this.setStatus("idle");
352
+ }
353
+ }
354
+ async bootstrap(projectPath, options) {
355
+ try {
356
+ await this.request("initialize", {
357
+ clientInfo: {
358
+ name: "ccpocket_bridge",
359
+ version: "1.0.0",
360
+ title: "ccpocket bridge",
361
+ },
362
+ capabilities: {
363
+ experimentalApi: true,
364
+ },
365
+ });
366
+ this.notify("initialized", {});
367
+ const threadParams = {
368
+ cwd: projectPath,
369
+ approvalPolicy: normalizeApprovalPolicy(options?.approvalPolicy ?? "never"),
370
+ sandbox: normalizeSandboxMode(options?.sandboxMode ?? "workspace-write"),
371
+ };
372
+ if (options?.model)
373
+ threadParams.model = options.model;
374
+ if (options?.modelReasoningEffort) {
375
+ threadParams.effort = normalizeReasoningEffort(options.modelReasoningEffort);
376
+ }
377
+ if (options?.networkAccessEnabled !== undefined) {
378
+ threadParams.sandboxPolicy = {
379
+ type: normalizeSandboxMode(options?.sandboxMode ?? "workspace-write"),
380
+ networkAccess: options.networkAccessEnabled,
381
+ };
382
+ }
383
+ if (options?.webSearchMode) {
384
+ threadParams.webSearchMode = options.webSearchMode;
385
+ }
386
+ const method = options?.threadId ? "thread/resume" : "thread/start";
387
+ if (options?.threadId) {
388
+ threadParams.threadId = options.threadId;
389
+ }
390
+ const response = await this.request(method, threadParams);
391
+ const thread = response.thread;
392
+ const threadId = typeof thread?.id === "string"
393
+ ? thread.id
394
+ : options?.threadId;
395
+ if (!threadId) {
396
+ throw new Error(`${method} returned no thread id`);
397
+ }
398
+ // Capture the resolved model name from thread response
399
+ if (typeof thread?.model === "string" && thread.model) {
400
+ this.startModel = thread.model;
401
+ }
402
+ this._threadId = threadId;
403
+ this.emitMessage({
404
+ type: "system",
405
+ subtype: "init",
406
+ sessionId: threadId,
407
+ model: this.startModel ?? "codex",
408
+ });
409
+ this.setStatus("idle");
410
+ // Fetch skills in background (non-blocking)
411
+ void this.fetchSkills(projectPath);
412
+ await this.runInputLoop(options);
413
+ }
414
+ catch (err) {
415
+ if (!this.stopped) {
416
+ const message = err instanceof Error ? err.message : String(err);
417
+ console.error("[codex-process] bootstrap error:", err);
418
+ this.emitMessage({ type: "error", message: `Codex error: ${message}` });
419
+ this.emitMessage({ type: "result", subtype: "error", error: message, sessionId: this._threadId ?? undefined });
420
+ }
421
+ this.setStatus("idle");
422
+ this.emit("exit", 1);
423
+ }
424
+ }
425
+ /**
426
+ * Fetch skills from Codex app-server via `skills/list` RPC and emit them
427
+ * as a `supported_commands` system message so the Flutter client can display
428
+ * skill entries alongside built-in slash commands.
429
+ */
430
+ async fetchSkills(projectPath) {
431
+ const TIMEOUT_MS = 10_000;
432
+ try {
433
+ const result = await Promise.race([
434
+ this.request("skills/list", { cwds: [projectPath] }),
435
+ new Promise((resolve) => setTimeout(() => resolve(null), TIMEOUT_MS)),
436
+ ]);
437
+ if (this.stopped || !result?.data)
438
+ return;
439
+ const skills = [];
440
+ const slashCommands = [];
441
+ for (const entry of result.data) {
442
+ for (const skill of entry.skills) {
443
+ if (skill.enabled) {
444
+ skills.push(skill.name);
445
+ slashCommands.push(skill.name);
446
+ }
447
+ }
448
+ }
449
+ if (slashCommands.length > 0) {
450
+ console.log(`[codex-process] skills/list returned ${slashCommands.length} skills`);
451
+ this.emitMessage({
452
+ type: "system",
453
+ subtype: "supported_commands",
454
+ slashCommands,
455
+ skills,
456
+ });
457
+ }
458
+ }
459
+ catch (err) {
460
+ console.log(`[codex-process] skills/list failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
461
+ }
462
+ }
463
+ async runInputLoop(options) {
112
464
  while (!this.stopped) {
113
- // Wait for user input
114
465
  const pendingInput = await new Promise((resolve) => {
115
466
  this.inputResolve = resolve;
467
+ // If plan approval arrived before inputResolve was ready, drain it now.
468
+ if (this._pendingPlanInput) {
469
+ const text = this._pendingPlanInput;
470
+ this._pendingPlanInput = null;
471
+ this.inputResolve = null;
472
+ resolve({ text });
473
+ }
116
474
  });
117
- if (this.stopped || !pendingInput.text || !this.thread)
475
+ if (this.stopped || !pendingInput.text)
118
476
  break;
119
- const { input, tempPaths } = await this.toSdkInput(pendingInput);
477
+ if (!this._threadId) {
478
+ this.emitMessage({ type: "error", message: "Codex thread is not initialized" });
479
+ continue;
480
+ }
481
+ const { input, tempPaths } = await this.toRpcInput(pendingInput);
120
482
  if (!input) {
121
483
  continue;
122
484
  }
123
- // Execute turn
124
485
  this.setStatus("running");
125
- const controller = new AbortController();
126
- this.pendingAbort = controller;
127
- try {
128
- const streamed = await this.thread.runStreamed(input, {
129
- signal: controller.signal,
130
- });
131
- for await (const event of streamed.events) {
132
- if (this.stopped)
133
- break;
134
- this.processEvent(event);
486
+ this.lastTokenUsage = null;
487
+ const completion = await new Promise((resolve, reject) => {
488
+ this.pendingTurnCompletion = { resolve, reject };
489
+ const params = {
490
+ threadId: this._threadId,
491
+ input,
492
+ approvalPolicy: normalizeApprovalPolicy(this._approvalPolicy),
493
+ };
494
+ if (options?.model)
495
+ params.model = options.model;
496
+ if (options?.modelReasoningEffort) {
497
+ params.effort = normalizeReasoningEffort(options.modelReasoningEffort);
135
498
  }
136
- }
137
- catch (err) {
138
- if (!this.stopped) {
139
- const msg = err instanceof Error ? err.message : String(err);
140
- // Don't emit error for abort (user-initiated interrupt)
141
- if (!controller.signal.aborted) {
142
- this.emitMessage({ type: "error", message: msg });
499
+ // Always send collaborationMode so the server switches modes correctly.
500
+ // Omitting it causes the server to persist the previous turn's mode.
501
+ const modeSettings = {
502
+ model: options?.model || this.startModel || "gpt-5.3-codex",
503
+ };
504
+ if (this._collaborationMode === "plan") {
505
+ modeSettings.reasoning_effort = "medium";
506
+ }
507
+ params.collaborationMode = {
508
+ mode: this._collaborationMode,
509
+ settings: modeSettings,
510
+ };
511
+ console.log(`[codex-process] turn/start: approval=${params.approvalPolicy}, collaboration=${this._collaborationMode}`);
512
+ void this.request("turn/start", params)
513
+ .then((result) => {
514
+ const turn = result.turn;
515
+ if (typeof turn?.id === "string") {
516
+ this.pendingTurnId = turn.id;
143
517
  }
518
+ })
519
+ .catch((err) => {
520
+ this.pendingTurnCompletion = null;
521
+ reject(err instanceof Error ? err : new Error(String(err)));
522
+ });
523
+ }).catch((err) => {
524
+ if (!this.stopped) {
525
+ const message = err instanceof Error ? err.message : String(err);
526
+ this.emitMessage({ type: "error", message });
144
527
  this.emitMessage({
145
528
  type: "result",
146
- subtype: controller.signal.aborted ? "interrupted" : "error",
147
- error: controller.signal.aborted ? undefined : msg,
529
+ subtype: "error",
530
+ error: message,
148
531
  sessionId: this._threadId ?? undefined,
149
532
  });
533
+ this.setStatus("idle");
150
534
  }
535
+ });
536
+ await Promise.all(tempPaths.map((path) => rm(path, { force: true }).catch(() => { })));
537
+ void completion;
538
+ }
539
+ }
540
+ handleStdoutChunk(chunk) {
541
+ this.stdoutBuffer += chunk;
542
+ while (true) {
543
+ const newlineIndex = this.stdoutBuffer.indexOf("\n");
544
+ if (newlineIndex < 0)
545
+ break;
546
+ const line = this.stdoutBuffer.slice(0, newlineIndex).trim();
547
+ this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
548
+ if (!line)
549
+ continue;
550
+ try {
551
+ const envelope = JSON.parse(line);
552
+ this.handleRpcEnvelope(envelope);
151
553
  }
152
- finally {
153
- for (const path of tempPaths) {
154
- await rm(path, { force: true }).catch(() => { });
155
- }
156
- this.pendingAbort = null;
554
+ catch (err) {
555
+ console.warn(`[codex-process] failed to parse app-server JSON line: ${line.slice(0, 200)}`);
157
556
  if (!this.stopped) {
158
- this.setStatus("idle");
557
+ this.emitMessage({
558
+ type: "error",
559
+ message: `Failed to parse codex app-server output: ${err instanceof Error ? err.message : String(err)}`,
560
+ });
159
561
  }
160
562
  }
161
563
  }
162
564
  }
163
- processEvent(event) {
164
- switch (event.type) {
165
- case "thread.started":
166
- this._threadId = event.thread_id;
167
- console.log(`[codex-process] Thread started: ${event.thread_id}`);
565
+ handleRpcEnvelope(envelope) {
566
+ if (envelope.id != null && envelope.method && envelope.result === undefined && envelope.error === undefined) {
567
+ this.handleServerRequest(envelope.id, envelope.method, envelope.params ?? {});
568
+ return;
569
+ }
570
+ if (envelope.id != null && (envelope.result !== undefined || envelope.error)) {
571
+ this.handleRpcResponse(envelope);
572
+ return;
573
+ }
574
+ if (envelope.method) {
575
+ this.handleNotification(envelope.method, envelope.params ?? {});
576
+ }
577
+ }
578
+ handleRpcResponse(envelope) {
579
+ if (typeof envelope.id !== "number") {
580
+ return;
581
+ }
582
+ const pending = this.pendingRpc.get(envelope.id);
583
+ if (!pending)
584
+ return;
585
+ this.pendingRpc.delete(envelope.id);
586
+ if ("error" in envelope && envelope.error) {
587
+ const message = envelope.error.message ?? `RPC error ${envelope.error.code ?? ""}`;
588
+ pending.reject(new Error(message));
589
+ return;
590
+ }
591
+ pending.resolve(envelope.result);
592
+ }
593
+ handleServerRequest(id, method, params) {
594
+ switch (method) {
595
+ case "item/commandExecution/requestApproval": {
596
+ const toolUseId = this.extractToolUseId(params, id);
597
+ const input = {
598
+ ...(typeof params.command === "string" ? { command: params.command } : {}),
599
+ ...(typeof params.cwd === "string" ? { cwd: params.cwd } : {}),
600
+ ...(params.commandActions ? { commandActions: params.commandActions } : {}),
601
+ ...(params.networkApprovalContext ? { networkApprovalContext: params.networkApprovalContext } : {}),
602
+ ...(typeof params.reason === "string" ? { reason: params.reason } : {}),
603
+ };
604
+ this.pendingApprovals.set(toolUseId, {
605
+ requestId: id,
606
+ toolUseId,
607
+ toolName: "Bash",
608
+ input,
609
+ });
168
610
  this.emitMessage({
169
- type: "system",
170
- subtype: "init",
171
- sessionId: event.thread_id,
172
- model: this.startModel ?? "codex",
611
+ type: "permission_request",
612
+ toolUseId,
613
+ toolName: "Bash",
614
+ input,
173
615
  });
616
+ this.setStatus("waiting_approval");
174
617
  break;
175
- case "turn.started":
618
+ }
619
+ case "item/fileChange/requestApproval": {
620
+ const toolUseId = this.extractToolUseId(params, id);
621
+ const input = {
622
+ ...(Array.isArray(params.changes) ? { changes: params.changes } : {}),
623
+ ...(typeof params.reason === "string" ? { reason: params.reason } : {}),
624
+ };
625
+ this.pendingApprovals.set(toolUseId, {
626
+ requestId: id,
627
+ toolUseId,
628
+ toolName: "FileChange",
629
+ input,
630
+ });
631
+ this.emitMessage({
632
+ type: "permission_request",
633
+ toolUseId,
634
+ toolName: "FileChange",
635
+ input,
636
+ });
637
+ this.setStatus("waiting_approval");
638
+ break;
639
+ }
640
+ case "item/tool/requestUserInput": {
641
+ const toolUseId = this.extractToolUseId(params, id);
642
+ const questions = normalizeUserInputQuestions(params.questions);
643
+ const input = {
644
+ questions: questions.map((q) => ({
645
+ id: q.id,
646
+ question: q.question,
647
+ header: q.header,
648
+ options: q.options,
649
+ multiSelect: false,
650
+ isOther: q.isOther,
651
+ isSecret: q.isSecret,
652
+ })),
653
+ };
654
+ this.pendingUserInputs.set(toolUseId, {
655
+ requestId: id,
656
+ toolUseId,
657
+ questions: questions.map((q) => ({
658
+ id: q.id,
659
+ question: q.question,
660
+ })),
661
+ input,
662
+ });
663
+ this.emitMessage({
664
+ type: "permission_request",
665
+ toolUseId,
666
+ toolName: "AskUserQuestion",
667
+ input,
668
+ });
669
+ this.setStatus("waiting_approval");
670
+ break;
671
+ }
672
+ default:
673
+ this.respondToServerRequest(id, {});
674
+ break;
675
+ }
676
+ }
677
+ handleNotification(method, params) {
678
+ switch (method) {
679
+ case "thread/started": {
680
+ const thread = params.thread;
681
+ if (typeof thread?.id === "string") {
682
+ this._threadId = thread.id;
683
+ }
684
+ break;
685
+ }
686
+ case "turn/started": {
687
+ const turn = params.turn;
688
+ if (typeof turn?.id === "string") {
689
+ this.pendingTurnId = turn.id;
690
+ }
176
691
  this.setStatus("running");
177
692
  break;
178
- case "item.started":
179
- this.processItemStarted(event.item);
693
+ }
694
+ case "turn/completed": {
695
+ this.handleTurnCompleted(params.turn);
180
696
  break;
181
- case "item.completed":
182
- this.processItemCompleted(event.item);
697
+ }
698
+ case "thread/name/updated": {
699
+ // Name change notification — handled by session manager
183
700
  break;
184
- case "item.updated":
185
- // Not fired in current SDK version, but handle for future compatibility
186
- this.processItemCompleted(event.item);
701
+ }
702
+ case "thread/tokenUsage/updated": {
703
+ const usage = params.usage;
704
+ if (usage) {
705
+ this.lastTokenUsage = {
706
+ input: numberOrUndefined(usage.inputTokens ?? usage.input_tokens),
707
+ cachedInput: numberOrUndefined(usage.cachedInputTokens ?? usage.cached_input_tokens),
708
+ output: numberOrUndefined(usage.outputTokens ?? usage.output_tokens),
709
+ };
710
+ }
187
711
  break;
188
- case "turn.completed":
189
- this.emitMessage({
190
- type: "result",
191
- subtype: "success",
192
- sessionId: this._threadId ?? undefined,
193
- inputTokens: event.usage.input_tokens,
194
- cachedInputTokens: event.usage.cached_input_tokens,
195
- outputTokens: event.usage.output_tokens,
196
- });
712
+ }
713
+ case "item/started": {
714
+ this.processItemStarted(params.item);
715
+ break;
716
+ }
717
+ case "item/completed": {
718
+ this.processItemCompleted(params.item);
719
+ break;
720
+ }
721
+ case "item/agentMessage/delta": {
722
+ const delta = typeof params.delta === "string"
723
+ ? params.delta
724
+ : typeof params.textDelta === "string"
725
+ ? params.textDelta
726
+ : "";
727
+ if (delta) {
728
+ this.emitMessage({ type: "stream_delta", text: delta });
729
+ }
197
730
  break;
198
- case "turn.failed":
731
+ }
732
+ case "item/reasoning/summaryTextDelta":
733
+ case "item/reasoning/textDelta": {
734
+ const delta = typeof params.delta === "string"
735
+ ? params.delta
736
+ : typeof params.textDelta === "string"
737
+ ? params.textDelta
738
+ : "";
739
+ if (delta) {
740
+ this.emitMessage({ type: "thinking_delta", text: delta });
741
+ }
742
+ break;
743
+ }
744
+ case "item/plan/delta": {
745
+ const delta = typeof params.delta === "string" ? params.delta : "";
746
+ if (delta) {
747
+ this.emitMessage({ type: "thinking_delta", text: delta });
748
+ }
749
+ break;
750
+ }
751
+ case "turn/plan/updated": {
752
+ // Default mode's update_plan tool output — always show as informational text
753
+ const text = formatPlanUpdateText(params);
754
+ if (!text)
755
+ break;
199
756
  this.emitMessage({
200
- type: "result",
201
- subtype: "error",
202
- error: event.error.message,
203
- sessionId: this._threadId ?? undefined,
757
+ type: "assistant",
758
+ message: {
759
+ id: randomUUID(),
760
+ role: "assistant",
761
+ content: [{ type: "text", text }],
762
+ model: "codex",
763
+ },
204
764
  });
205
765
  break;
206
- case "error":
207
- this.emitMessage({ type: "error", message: event.message });
766
+ }
767
+ default:
208
768
  break;
209
769
  }
210
770
  }
771
+ handleTurnCompleted(turn) {
772
+ const status = String(turn?.status ?? "completed");
773
+ const usage = this.lastTokenUsage;
774
+ this.lastTokenUsage = null;
775
+ if (status === "failed") {
776
+ const errorObj = turn?.error;
777
+ const message = typeof errorObj?.message === "string"
778
+ ? errorObj.message
779
+ : "Turn failed";
780
+ this.emitMessage({
781
+ type: "result",
782
+ subtype: "error",
783
+ error: message,
784
+ sessionId: this._threadId ?? undefined,
785
+ });
786
+ }
787
+ else if (status === "interrupted") {
788
+ this.emitMessage({
789
+ type: "result",
790
+ subtype: "interrupted",
791
+ sessionId: this._threadId ?? undefined,
792
+ });
793
+ }
794
+ else {
795
+ this.emitMessage({
796
+ type: "result",
797
+ subtype: "success",
798
+ sessionId: this._threadId ?? undefined,
799
+ ...(usage?.input != null ? { inputTokens: usage.input } : {}),
800
+ ...(usage?.cachedInput != null ? { cachedInputTokens: usage.cachedInput } : {}),
801
+ ...(usage?.output != null ? { outputTokens: usage.output } : {}),
802
+ });
803
+ }
804
+ this.pendingTurnId = null;
805
+ // Plan mode: emit synthetic plan approval and wait for user decision
806
+ if (this._collaborationMode === "plan" && this.lastPlanItemText) {
807
+ const toolUseId = `plan_${randomUUID()}`;
808
+ this.pendingPlanCompletion = {
809
+ toolUseId,
810
+ planText: this.lastPlanItemText,
811
+ };
812
+ this.lastPlanItemText = null;
813
+ this.emitMessage({
814
+ type: "permission_request",
815
+ toolUseId,
816
+ toolName: "ExitPlanMode",
817
+ input: { plan: this.pendingPlanCompletion.planText },
818
+ });
819
+ this.setStatus("waiting_approval");
820
+ // Do NOT set idle — waiting for plan approval
821
+ }
822
+ else {
823
+ this.lastPlanItemText = null;
824
+ if (this.pendingApprovals.size === 0 && this.pendingUserInputs.size === 0) {
825
+ this.setStatus("idle");
826
+ }
827
+ }
828
+ if (this.pendingTurnCompletion) {
829
+ this.pendingTurnCompletion.resolve();
830
+ this.pendingTurnCompletion = null;
831
+ }
832
+ }
211
833
  processItemStarted(item) {
212
- switch (item.type) {
213
- case "command_execution":
214
- // Emit tool_use for the command (shown before result)
834
+ if (!item || typeof item !== "object")
835
+ return;
836
+ const itemId = typeof item.id === "string" ? item.id : randomUUID();
837
+ const itemType = normalizeItemType(item.type);
838
+ switch (itemType) {
839
+ case "commandexecution": {
840
+ const commandText = typeof item.command === "string"
841
+ ? item.command
842
+ : Array.isArray(item.command)
843
+ ? item.command.map((part) => String(part)).join(" ")
844
+ : "";
215
845
  this.emitMessage({
216
846
  type: "assistant",
217
847
  message: {
218
- id: item.id,
848
+ id: itemId,
219
849
  role: "assistant",
220
850
  content: [
221
851
  {
222
852
  type: "tool_use",
223
- id: item.id,
853
+ id: itemId,
224
854
  name: "Bash",
225
- input: { command: item.command },
855
+ input: { command: commandText },
226
856
  },
227
857
  ],
228
858
  model: "codex",
229
859
  },
230
860
  });
231
861
  break;
232
- // Other item types: nothing to show on start
862
+ }
863
+ case "filechange": {
864
+ this.emitMessage({
865
+ type: "assistant",
866
+ message: {
867
+ id: itemId,
868
+ role: "assistant",
869
+ content: [
870
+ {
871
+ type: "tool_use",
872
+ id: itemId,
873
+ name: "FileChange",
874
+ input: {
875
+ changes: Array.isArray(item.changes) ? item.changes : [],
876
+ },
877
+ },
878
+ ],
879
+ model: "codex",
880
+ },
881
+ });
882
+ break;
883
+ }
884
+ default:
885
+ break;
233
886
  }
234
887
  }
235
888
  processItemCompleted(item) {
236
- switch (item.type) {
237
- case "agent_message":
889
+ if (!item || typeof item !== "object")
890
+ return;
891
+ const itemId = typeof item.id === "string" ? item.id : randomUUID();
892
+ const itemType = normalizeItemType(item.type);
893
+ switch (itemType) {
894
+ case "agentmessage": {
895
+ const text = extractAgentText(item);
896
+ if (!text)
897
+ return;
238
898
  this.emitMessage({
239
899
  type: "assistant",
240
900
  message: {
241
- id: item.id,
901
+ id: itemId,
242
902
  role: "assistant",
243
- content: [{ type: "text", text: item.text }],
903
+ content: [{ type: "text", text }],
244
904
  model: "codex",
245
905
  },
246
906
  });
247
907
  break;
248
- case "reasoning":
249
- this.emitMessage({ type: "thinking_delta", text: item.text });
908
+ }
909
+ case "reasoning": {
910
+ const text = extractReasoningText(item);
911
+ if (text) {
912
+ this.emitMessage({ type: "thinking_delta", text });
913
+ }
250
914
  break;
251
- case "command_execution":
915
+ }
916
+ case "commandexecution": {
917
+ const output = typeof item.aggregatedOutput === "string"
918
+ ? item.aggregatedOutput
919
+ : typeof item.output === "string"
920
+ ? item.output
921
+ : "";
922
+ const exitCode = numberOrUndefined(item.exitCode ?? item.exit_code);
252
923
  this.emitMessage({
253
924
  type: "tool_result",
254
- toolUseId: item.id,
255
- content: item.aggregated_output || `exit code: ${item.exit_code}`,
925
+ toolUseId: itemId,
926
+ content: output || `exit code: ${exitCode ?? "unknown"}`,
256
927
  toolName: "Bash",
257
928
  });
258
929
  break;
259
- case "file_change":
260
- // Emit tool_use first (for display)
261
- this.emitMessage({
262
- type: "assistant",
263
- message: {
264
- id: item.id,
265
- role: "assistant",
266
- content: [
267
- {
268
- type: "tool_use",
269
- id: item.id,
270
- name: "FileChange",
271
- input: { changes: item.changes },
272
- },
273
- ],
274
- model: "codex",
275
- },
276
- });
930
+ }
931
+ case "filechange": {
932
+ const content = formatFileChangesWithDiff(item.changes);
277
933
  this.emitMessage({
278
934
  type: "tool_result",
279
- toolUseId: item.id,
280
- content: item.changes
281
- .map((c) => `${c.kind}: ${c.path}`)
282
- .join("\n"),
935
+ toolUseId: itemId,
936
+ content,
283
937
  toolName: "FileChange",
284
938
  });
285
939
  break;
286
- case "mcp_tool_call": {
287
- const toolName = `mcp:${item.server}/${item.tool}`;
288
- // Emit tool_use first
940
+ }
941
+ case "mcptoolcall": {
942
+ const server = typeof item.server === "string" ? item.server : "mcp";
943
+ const tool = typeof item.tool === "string" ? item.tool : "unknown";
944
+ const toolName = `mcp:${server}/${tool}`;
945
+ const result = item.result ?? item.error ?? "MCP call completed";
289
946
  this.emitMessage({
290
947
  type: "assistant",
291
948
  message: {
292
- id: item.id,
949
+ id: itemId,
293
950
  role: "assistant",
294
951
  content: [
295
952
  {
296
953
  type: "tool_use",
297
- id: item.id,
954
+ id: itemId,
298
955
  name: toolName,
299
- input: item.arguments,
956
+ input: item.arguments ?? {},
300
957
  },
301
958
  ],
302
959
  model: "codex",
@@ -304,26 +961,25 @@ export class CodexProcess extends EventEmitter {
304
961
  });
305
962
  this.emitMessage({
306
963
  type: "tool_result",
307
- toolUseId: item.id,
308
- content: item.result
309
- ? JSON.stringify(item.result)
310
- : item.error?.message ?? "MCP call completed",
964
+ toolUseId: itemId,
965
+ content: typeof result === "string" ? result : JSON.stringify(result),
311
966
  toolName,
312
967
  });
313
968
  break;
314
969
  }
315
- case "web_search":
970
+ case "websearch": {
971
+ const query = typeof item.query === "string" ? item.query : "";
316
972
  this.emitMessage({
317
973
  type: "assistant",
318
974
  message: {
319
- id: item.id,
975
+ id: itemId,
320
976
  role: "assistant",
321
977
  content: [
322
978
  {
323
979
  type: "tool_use",
324
- id: item.id,
980
+ id: itemId,
325
981
  name: "WebSearch",
326
- input: { query: item.query },
982
+ input: { query },
327
983
  },
328
984
  ],
329
985
  model: "codex",
@@ -331,53 +987,33 @@ export class CodexProcess extends EventEmitter {
331
987
  });
332
988
  this.emitMessage({
333
989
  type: "tool_result",
334
- toolUseId: item.id,
335
- content: `Web search: ${item.query}`,
990
+ toolUseId: itemId,
991
+ content: query ? `Web search: ${query}` : "Web search completed",
336
992
  toolName: "WebSearch",
337
993
  });
338
994
  break;
339
- case "todo_list":
340
- this.emitMessage({
341
- type: "assistant",
342
- message: {
343
- id: item.id,
344
- role: "assistant",
345
- content: [
346
- {
347
- type: "text",
348
- text: item.items
349
- .map((t) => `${t.completed ? "\u2705" : "\u2B1C"} ${t.text}`)
350
- .join("\n"),
351
- },
352
- ],
353
- model: "codex",
354
- },
355
- });
995
+ }
996
+ case "plan": {
997
+ // Plan item completed — save text for plan approval emission in handleTurnCompleted()
998
+ const planText = typeof item.text === "string" ? item.text : "";
999
+ this.lastPlanItemText = planText;
356
1000
  break;
357
- case "error":
358
- this.emitMessage({ type: "error", message: item.message });
1001
+ }
1002
+ case "error": {
1003
+ const message = typeof item.message === "string" ? item.message : "Codex item error";
1004
+ this.emitMessage({ type: "error", message });
1005
+ break;
1006
+ }
1007
+ default:
359
1008
  break;
360
1009
  }
361
1010
  }
362
- setStatus(status) {
363
- if (this._status !== status) {
364
- this._status = status;
365
- this.emit("status", status);
366
- this.emitMessage({ type: "status", status });
367
- }
368
- }
369
- emitMessage(msg) {
370
- this.emit("message", msg);
371
- }
372
- async toSdkInput(pendingInput) {
1011
+ async toRpcInput(pendingInput) {
1012
+ const input = [{ type: "text", text: pendingInput.text }];
1013
+ const tempPaths = [];
373
1014
  if (!pendingInput.images || pendingInput.images.length === 0) {
374
- return { input: pendingInput.text, tempPaths: [] };
1015
+ return { input, tempPaths };
375
1016
  }
376
- const inputParts = [];
377
- const tempPaths = [];
378
- // Add text first
379
- inputParts.push({ type: "text", text: pendingInput.text });
380
- // Add each image
381
1017
  for (const image of pendingInput.images) {
382
1018
  const ext = extensionFromMime(image.mimeType);
383
1019
  if (!ext) {
@@ -400,14 +1036,311 @@ export class CodexProcess extends EventEmitter {
400
1036
  }
401
1037
  const tempPath = join(tmpdir(), `ccpocket-codex-image-${randomUUID()}.${ext}`);
402
1038
  await writeFile(tempPath, buffer);
403
- inputParts.push({ type: "local_image", path: tempPath });
404
1039
  tempPaths.push(tempPath);
1040
+ input.push({ type: "localImage", path: tempPath });
1041
+ }
1042
+ return { input, tempPaths };
1043
+ }
1044
+ request(method, params) {
1045
+ const id = this.rpcSeq++;
1046
+ const envelope = { id, method, params };
1047
+ return new Promise((resolve, reject) => {
1048
+ this.pendingRpc.set(id, { resolve, reject, method });
1049
+ try {
1050
+ this.writeEnvelope(envelope);
1051
+ }
1052
+ catch (err) {
1053
+ this.pendingRpc.delete(id);
1054
+ reject(err instanceof Error ? err : new Error(String(err)));
1055
+ }
1056
+ });
1057
+ }
1058
+ notify(method, params) {
1059
+ this.writeEnvelope({ method, params });
1060
+ }
1061
+ respondToServerRequest(id, result) {
1062
+ try {
1063
+ this.writeEnvelope({ id, result });
1064
+ }
1065
+ catch (err) {
1066
+ if (!this.stopped) {
1067
+ console.warn(`[codex-process] failed to respond to server request: ${err instanceof Error ? err.message : String(err)}`);
1068
+ }
1069
+ }
1070
+ }
1071
+ writeEnvelope(envelope) {
1072
+ if (!this.child || this.child.killed) {
1073
+ throw new Error("codex app-server is not running");
1074
+ }
1075
+ const line = `${JSON.stringify(envelope)}\n`;
1076
+ this.child.stdin.write(line);
1077
+ }
1078
+ rejectAllPending(error) {
1079
+ for (const pending of this.pendingRpc.values()) {
1080
+ pending.reject(error);
1081
+ }
1082
+ this.pendingRpc.clear();
1083
+ if (this.pendingTurnCompletion) {
1084
+ this.pendingTurnCompletion.reject(error);
1085
+ this.pendingTurnCompletion = null;
1086
+ }
1087
+ }
1088
+ setStatus(status) {
1089
+ if (this._status !== status) {
1090
+ this._status = status;
1091
+ this.emit("status", status);
1092
+ this.emitMessage({ type: "status", status });
1093
+ }
1094
+ }
1095
+ emitMessage(msg) {
1096
+ this.emit("message", msg);
1097
+ }
1098
+ extractToolUseId(params, requestId) {
1099
+ if (typeof params.approvalId === "string")
1100
+ return params.approvalId;
1101
+ if (typeof params.itemId === "string")
1102
+ return params.itemId;
1103
+ if (typeof requestId === "string")
1104
+ return requestId;
1105
+ return `approval-${requestId}`;
1106
+ }
1107
+ }
1108
+ function normalizeApprovalPolicy(value) {
1109
+ switch (value) {
1110
+ case "on-request":
1111
+ return "on-request";
1112
+ case "on-failure":
1113
+ return "on-failure";
1114
+ case "untrusted":
1115
+ return "untrusted";
1116
+ case "never":
1117
+ default:
1118
+ return "never";
1119
+ }
1120
+ }
1121
+ function normalizeSandboxMode(value) {
1122
+ switch (value) {
1123
+ case "read-only":
1124
+ return "read-only";
1125
+ case "danger-full-access":
1126
+ return "danger-full-access";
1127
+ case "workspace-write":
1128
+ default:
1129
+ return "workspace-write";
1130
+ }
1131
+ }
1132
+ function normalizeReasoningEffort(value) {
1133
+ switch (value) {
1134
+ case "xhigh":
1135
+ return "high";
1136
+ default:
1137
+ return value;
1138
+ }
1139
+ }
1140
+ function normalizeItemType(raw) {
1141
+ if (typeof raw !== "string")
1142
+ return "";
1143
+ return raw.replace(/[_\s-]/g, "").toLowerCase();
1144
+ }
1145
+ function numberOrUndefined(value) {
1146
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1147
+ }
1148
+ function summarizeFileChanges(changes) {
1149
+ if (!Array.isArray(changes) || changes.length === 0) {
1150
+ return "No file changes";
1151
+ }
1152
+ return changes
1153
+ .map((entry) => {
1154
+ if (!entry || typeof entry !== "object")
1155
+ return "changed";
1156
+ const record = entry;
1157
+ const kind = typeof record.kind === "string" ? record.kind : "changed";
1158
+ const path = typeof record.path === "string" ? record.path : "(unknown)";
1159
+ return `${kind}: ${path}`;
1160
+ })
1161
+ .join("\n");
1162
+ }
1163
+ /**
1164
+ * Format file changes including unified diff content for display in chat.
1165
+ * Falls back to `kind: path` summary when no diff is available.
1166
+ */
1167
+ function formatFileChangesWithDiff(changes) {
1168
+ if (!Array.isArray(changes) || changes.length === 0) {
1169
+ return "No file changes";
1170
+ }
1171
+ return changes
1172
+ .map((entry) => {
1173
+ if (!entry || typeof entry !== "object")
1174
+ return "changed";
1175
+ const record = entry;
1176
+ const kind = typeof record.kind === "string" ? record.kind : "changed";
1177
+ const path = typeof record.path === "string" ? record.path : "(unknown)";
1178
+ const diff = typeof record.diff === "string" ? record.diff.trim() : "";
1179
+ if (diff) {
1180
+ // If diff already has unified headers, use as-is; otherwise add them
1181
+ if (diff.startsWith("---") || diff.startsWith("@@")) {
1182
+ return `--- a/${path}\n+++ b/${path}\n${diff}`;
1183
+ }
1184
+ return diff;
405
1185
  }
406
- if (tempPaths.length === 0) {
407
- // All images failed, send text only
408
- return { input: pendingInput.text, tempPaths: [] };
1186
+ return `${kind}: ${path}`;
1187
+ })
1188
+ .join("\n\n");
1189
+ }
1190
+ function extractAgentText(item) {
1191
+ if (typeof item.text === "string")
1192
+ return item.text;
1193
+ const parts = item.content;
1194
+ if (Array.isArray(parts)) {
1195
+ const text = parts
1196
+ .filter((part) => part && typeof part === "object")
1197
+ .map((part) => {
1198
+ const record = part;
1199
+ if (record.type === "text" && typeof record.text === "string") {
1200
+ return record.text;
1201
+ }
1202
+ return "";
1203
+ })
1204
+ .filter((part) => part.length > 0)
1205
+ .join("\n");
1206
+ if (text)
1207
+ return text;
1208
+ }
1209
+ return "";
1210
+ }
1211
+ function extractReasoningText(item) {
1212
+ if (typeof item.text === "string")
1213
+ return item.text;
1214
+ const summary = item.summary;
1215
+ if (Array.isArray(summary)) {
1216
+ const text = summary
1217
+ .map((entry) => {
1218
+ if (!entry || typeof entry !== "object")
1219
+ return "";
1220
+ const record = entry;
1221
+ return typeof record.text === "string" ? record.text : "";
1222
+ })
1223
+ .filter((part) => part.length > 0)
1224
+ .join("\n");
1225
+ if (text)
1226
+ return text;
1227
+ }
1228
+ return "";
1229
+ }
1230
+ function normalizeUserInputQuestions(raw) {
1231
+ if (!Array.isArray(raw))
1232
+ return [];
1233
+ return raw
1234
+ .filter((entry) => !!entry && typeof entry === "object")
1235
+ .map((entry, index) => {
1236
+ const id = typeof entry.id === "string" ? entry.id : `question_${index + 1}`;
1237
+ const question = typeof entry.question === "string" ? entry.question : "";
1238
+ const header = typeof entry.header === "string" ? entry.header : `Question ${index + 1}`;
1239
+ const optionsRaw = Array.isArray(entry.options) ? entry.options : [];
1240
+ const options = optionsRaw
1241
+ .filter((option) => !!option && typeof option === "object")
1242
+ .map((option) => ({
1243
+ label: typeof option.label === "string" ? option.label : "",
1244
+ description: typeof option.description === "string" ? option.description : "",
1245
+ }))
1246
+ .filter((option) => option.label.length > 0);
1247
+ return {
1248
+ id,
1249
+ question,
1250
+ header,
1251
+ options,
1252
+ isOther: Boolean(entry.isOther),
1253
+ isSecret: Boolean(entry.isSecret),
1254
+ };
1255
+ })
1256
+ .filter((question) => question.question.length > 0);
1257
+ }
1258
+ function buildUserInputAnswers(questions, rawResult) {
1259
+ const parsed = parseResultObject(rawResult);
1260
+ const answerMap = {};
1261
+ for (const question of questions) {
1262
+ const candidate = parsed.byId[question.id] ?? parsed.byQuestion[question.question];
1263
+ const answers = normalizeAnswerValues(candidate);
1264
+ if (answers.length > 0) {
1265
+ answerMap[question.id] = { answers };
1266
+ }
1267
+ }
1268
+ if (Object.keys(answerMap).length === 0 && questions.length > 0) {
1269
+ answerMap[questions[0].id] = { answers: normalizeAnswerValues(rawResult) };
1270
+ }
1271
+ return answerMap;
1272
+ }
1273
+ function parseResultObject(rawResult) {
1274
+ try {
1275
+ const parsed = JSON.parse(rawResult);
1276
+ const byId = {};
1277
+ const byQuestion = {};
1278
+ if (parsed && typeof parsed === "object") {
1279
+ const answers = parsed.answers;
1280
+ if (answers && typeof answers === "object" && !Array.isArray(answers)) {
1281
+ for (const [key, value] of Object.entries(answers)) {
1282
+ byId[key] = value;
1283
+ byQuestion[key] = value;
1284
+ }
1285
+ }
409
1286
  }
410
- return { input: inputParts, tempPaths };
1287
+ return { byId, byQuestion };
1288
+ }
1289
+ catch {
1290
+ return { byId: {}, byQuestion: {} };
1291
+ }
1292
+ }
1293
+ function normalizeAnswerValues(value) {
1294
+ if (typeof value === "string") {
1295
+ return value
1296
+ .split(",")
1297
+ .map((part) => part.trim())
1298
+ .filter((part) => part.length > 0);
1299
+ }
1300
+ if (Array.isArray(value)) {
1301
+ return value
1302
+ .map((entry) => String(entry).trim())
1303
+ .filter((entry) => entry.length > 0);
1304
+ }
1305
+ if (value && typeof value === "object") {
1306
+ const record = value;
1307
+ if (Array.isArray(record.answers)) {
1308
+ return record.answers
1309
+ .map((entry) => String(entry).trim())
1310
+ .filter((entry) => entry.length > 0);
1311
+ }
1312
+ }
1313
+ if (value == null)
1314
+ return [];
1315
+ const normalized = String(value).trim();
1316
+ return normalized ? [normalized] : [];
1317
+ }
1318
+ function formatPlanUpdateText(params) {
1319
+ const stepsRaw = params.plan;
1320
+ if (!Array.isArray(stepsRaw) || stepsRaw.length === 0)
1321
+ return "";
1322
+ const explanation = typeof params.explanation === "string" ? params.explanation.trim() : "";
1323
+ const lines = stepsRaw
1324
+ .filter((entry) => !!entry && typeof entry === "object")
1325
+ .map((entry, index) => {
1326
+ const step = typeof entry.step === "string" ? entry.step : `Step ${index + 1}`;
1327
+ const status = normalizePlanStatus(entry.status);
1328
+ return `${index + 1}. [${status}] ${step}`;
1329
+ });
1330
+ if (lines.length === 0)
1331
+ return "";
1332
+ const header = explanation ? `Plan update: ${explanation}` : "Plan update:";
1333
+ return `${header}\n${lines.join("\n")}`;
1334
+ }
1335
+ function normalizePlanStatus(raw) {
1336
+ switch (raw) {
1337
+ case "inProgress":
1338
+ return "in progress";
1339
+ case "completed":
1340
+ return "completed";
1341
+ case "pending":
1342
+ default:
1343
+ return "pending";
411
1344
  }
412
1345
  }
413
1346
  function extensionFromMime(mimeType) {