cli-wechat-bridge 1.0.5

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 (54) hide show
  1. package/LICENSE.txt +21 -0
  2. package/README.md +637 -0
  3. package/bin/_run-entry.mjs +35 -0
  4. package/bin/wechat-bridge-claude.mjs +5 -0
  5. package/bin/wechat-bridge-codex.mjs +5 -0
  6. package/bin/wechat-bridge-opencode.mjs +5 -0
  7. package/bin/wechat-bridge-shell.mjs +5 -0
  8. package/bin/wechat-bridge.mjs +5 -0
  9. package/bin/wechat-check-update.mjs +5 -0
  10. package/bin/wechat-claude-start.mjs +5 -0
  11. package/bin/wechat-claude.mjs +5 -0
  12. package/bin/wechat-codex-start.mjs +5 -0
  13. package/bin/wechat-codex.mjs +5 -0
  14. package/bin/wechat-daemon.mjs +5 -0
  15. package/bin/wechat-opencode-start.mjs +5 -0
  16. package/bin/wechat-opencode.mjs +5 -0
  17. package/bin/wechat-setup.mjs +5 -0
  18. package/dist/bridge/bridge-adapter-common.js +95 -0
  19. package/dist/bridge/bridge-adapters.claude.js +829 -0
  20. package/dist/bridge/bridge-adapters.codex.js +2228 -0
  21. package/dist/bridge/bridge-adapters.core.js +717 -0
  22. package/dist/bridge/bridge-adapters.js +26 -0
  23. package/dist/bridge/bridge-adapters.opencode.js +2129 -0
  24. package/dist/bridge/bridge-adapters.shared.js +1005 -0
  25. package/dist/bridge/bridge-adapters.shell.js +363 -0
  26. package/dist/bridge/bridge-controller.js +48 -0
  27. package/dist/bridge/bridge-final-reply.js +46 -0
  28. package/dist/bridge/bridge-process-reaper.js +348 -0
  29. package/dist/bridge/bridge-state.js +362 -0
  30. package/dist/bridge/bridge-types.js +1 -0
  31. package/dist/bridge/bridge-utils.js +1240 -0
  32. package/dist/bridge/claude-hook.js +82 -0
  33. package/dist/bridge/claude-hooks.js +267 -0
  34. package/dist/bridge/wechat-bridge.js +1026 -0
  35. package/dist/commands/check-update.js +30 -0
  36. package/dist/companion/codex-panel-link.js +72 -0
  37. package/dist/companion/codex-panel.js +179 -0
  38. package/dist/companion/codex-remote-client.js +124 -0
  39. package/dist/companion/local-companion-link.js +240 -0
  40. package/dist/companion/local-companion-start.js +420 -0
  41. package/dist/companion/local-companion.js +424 -0
  42. package/dist/daemon/daemon-link.js +175 -0
  43. package/dist/daemon/wechat-daemon.js +1202 -0
  44. package/dist/media/media-types.js +1 -0
  45. package/dist/runtime/create-runtime-host.js +12 -0
  46. package/dist/runtime/legacy-adapter-runtime.js +46 -0
  47. package/dist/runtime/runtime-types.js +5 -0
  48. package/dist/utils/version-checker.js +161 -0
  49. package/dist/wechat/channel-config.js +196 -0
  50. package/dist/wechat/setup.js +283 -0
  51. package/dist/wechat/standalone-bot.js +355 -0
  52. package/dist/wechat/wechat-channel.js +492 -0
  53. package/dist/wechat/wechat-transport.js +1213 -0
  54. package/package.json +101 -0
@@ -0,0 +1,2228 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { spawn as spawnChild } from "node:child_process";
5
+ import { detectCliApproval, normalizeOutput, nowIso, truncatePreview, } from "./bridge-utils.js";
6
+ import { AbstractPtyAdapter } from "./bridge-adapters.core.js";
7
+ import * as shared from "./bridge-adapters.shared.js";
8
+ import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
9
+ import { CODEX_REMOTE_AUTH_TOKEN_ENV, LOCAL_CLIENT_PROTOCOL_VERSION, } from "../runtime/runtime-types.js";
10
+ const { CODEX_APP_SERVER_HOST, CODEX_APP_SERVER_LOG_LIMIT, CODEX_APP_SERVER_READY_TIMEOUT_MS, CODEX_FINAL_REPLY_SETTLE_DELAY_MS, CODEX_RECENT_SESSION_KEY_LIMIT, CODEX_RPC_CONNECT_RETRY_MS, CODEX_RPC_RECONNECT_TIMEOUT_MS, CODEX_SESSION_FALLBACK_SCAN_INTERVAL_MS, CODEX_SESSION_LOCAL_MIRROR_FALLBACK_WINDOW_MS, CODEX_SESSION_POLL_INTERVAL_MS, CODEX_STARTUP_WARMUP_MS, CODEX_THREAD_SIGNAL_TTL_MS, INTERRUPT_SETTLE_DELAY_MS, appendBoundedLog, buildCliEnvironment, buildCodexApprovalRequest, buildCodexCliArgs, buildCodexUserInputRequest, coerceWebSocketMessageData, delay, describeUnknownError, extractCodexFinalTextFromItem, extractCodexThreadFollowIdFromStatusChanged, extractCodexThreadStartedThreadId, extractCodexUserMessageText, findCodexSessionFile, findRecentCodexSessionFileForCwd, getCodexRpcRequestId, getNotificationThreadId, getNotificationTurnId, isRecord, isRecentIsoTimestamp, listCodexResumeSessions, normalizeComparablePath, normalizeCodexRpcError, reserveLocalPort, resolveSpawnTarget, shouldAutoCompleteCodexWechatTurnAfterFinalReply, shouldIgnoreCodexSessionReplayEntry, shouldRecoverCodexStaleBusyState, waitForTcpPort, } = shared;
11
+ const CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS = 150;
12
+ export class CodexPtyAdapter extends AbstractPtyAdapter {
13
+ runtimeKind = "codex_runtime_host";
14
+ appServer = null;
15
+ nativeProcess = null;
16
+ appServerPort = null;
17
+ appServerShuttingDown = false;
18
+ appServerLog = "";
19
+ appServerAuthToken = null;
20
+ appServerAuthTokenFilePath = null;
21
+ rpcSocket = null;
22
+ rpcShuttingDown = false;
23
+ rpcReconnectPromise = null;
24
+ cleanPanelExitInProgress = false;
25
+ rpcRequestCounter = 0;
26
+ pendingRpcRequests = new Map();
27
+ sharedThreadId = null;
28
+ announcedThreadId = null;
29
+ pendingThreadAnnouncement = null;
30
+ activeTurn = null;
31
+ bridgeOwnedTurnIds = new Set();
32
+ recentBridgeThreadSignalAtById = new Map();
33
+ pendingTurnStart = false;
34
+ pendingTurnThreadId = null;
35
+ interruptPendingTurnStart = false;
36
+ pendingThreadFollowId = null;
37
+ pendingApprovalRequest = null;
38
+ pendingUserInputRequest = null;
39
+ queuedTurnNotifications = [];
40
+ queuedTurnServerRequests = [];
41
+ mirroredUserInputTurnIds = new Set();
42
+ turnFinalMessages = new Map();
43
+ turnDeltaByItem = new Map();
44
+ turnErrorById = new Map();
45
+ turnLastActivityAtMs = new Map();
46
+ startupBlocker = null;
47
+ warmupUntilMs = 0;
48
+ sessionFilePath = null;
49
+ sessionPollTimer = null;
50
+ sessionReadOffset = 0;
51
+ sessionPartialLine = "";
52
+ sessionFinalText = null;
53
+ sessionIgnoreBeforeMs = null;
54
+ nextSessionFallbackScanAtMs = 0;
55
+ completedTurnIds = new Set();
56
+ completedTurnOrder = [];
57
+ pendingInjectedInputs = [];
58
+ localInputListener = null;
59
+ interruptTimer = null;
60
+ finalReplyCompletionTimer = null;
61
+ finalReplyCompletionTurnId = null;
62
+ resumeThreadId;
63
+ localClientInstanceId = `${process.pid}-${Date.now().toString(36)}`;
64
+ constructor(options) {
65
+ super(options);
66
+ this.resumeThreadId = options.initialSharedSessionId ?? options.initialSharedThreadId ?? null;
67
+ if (this.resumeThreadId && options.renderMode !== "panel") {
68
+ this.state.sharedSessionId = this.resumeThreadId;
69
+ this.state.sharedThreadId = this.resumeThreadId;
70
+ }
71
+ }
72
+ async start() {
73
+ if (this.isCodexClientRunning()) {
74
+ return;
75
+ }
76
+ if (this.isHeadlessRuntimeMode()) {
77
+ this.setStatus("starting", `Starting ${this.options.kind} runtime host...`);
78
+ }
79
+ await this.startAppServer();
80
+ await this.connectRpcClient();
81
+ await this.restoreInitialSharedThreadIfNeeded();
82
+ try {
83
+ if (this.isNativePanelMode()) {
84
+ await this.startNativeClient();
85
+ }
86
+ else if (this.isHeadlessRuntimeMode()) {
87
+ this.shuttingDown = false;
88
+ this.cleanPanelExitInProgress = false;
89
+ this.hasAcceptedInput = true;
90
+ this.state.pid = this.appServer?.pid ?? undefined;
91
+ this.state.startedAt = nowIso();
92
+ this.state.pendingApproval = null;
93
+ this.afterStart();
94
+ this.setStatus("idle", `${this.options.kind} adapter is ready.`);
95
+ }
96
+ else {
97
+ await super.start();
98
+ }
99
+ }
100
+ catch (err) {
101
+ await this.disconnectRpcClient();
102
+ await this.stopAppServer();
103
+ throw err;
104
+ }
105
+ }
106
+ buildSpawnArgs() {
107
+ if (!this.appServerPort) {
108
+ throw new Error("Codex app-server is not ready.");
109
+ }
110
+ return buildCodexCliArgs(`ws://${CODEX_APP_SERVER_HOST}:${this.appServerPort}`, {
111
+ inlineMode: this.options.renderMode !== "panel",
112
+ profile: this.options.profile,
113
+ extraCliArgs: this.options.extraCliArgs,
114
+ });
115
+ }
116
+ afterStart() {
117
+ this.warmupUntilMs = this.usesRpcTurnTransport()
118
+ ? 0
119
+ : Date.now() + CODEX_STARTUP_WARMUP_MS;
120
+ if (this.isEmbeddedCliMode()) {
121
+ this.attachLocalInputForwarding();
122
+ }
123
+ this.startSessionPolling();
124
+ }
125
+ async sendInput(text) {
126
+ if (this.usesRpcTurnTransport()) {
127
+ await this.sendPanelTurn(text);
128
+ return;
129
+ }
130
+ if (!this.pty) {
131
+ throw new Error("codex adapter is not running.");
132
+ }
133
+ if (this.state.status === "busy") {
134
+ throw new Error("codex is still working. Wait for the current reply or use /stop.");
135
+ }
136
+ if (this.pendingApproval) {
137
+ throw new Error("A Codex approval request is pending. Reply with /confirm <code> or /deny.");
138
+ }
139
+ if (this.startupBlocker) {
140
+ throw new Error("Codex is waiting for local terminal input before the session can continue.");
141
+ }
142
+ await delay(this.warmupUntilMs - Date.now());
143
+ if (!this.pty) {
144
+ throw new Error("codex adapter is not running.");
145
+ }
146
+ if (this.startupBlocker) {
147
+ throw new Error("Codex is waiting for local terminal input before the session can continue.");
148
+ }
149
+ this.clearInterruptTimer();
150
+ this.hasAcceptedInput = true;
151
+ this.currentPreview = truncatePreview(text);
152
+ this.state.lastInputAt = nowIso();
153
+ this.rememberInjectedInput(text);
154
+ this.setStatus("busy");
155
+ this.state.activeTurnOrigin = "wechat";
156
+ await this.typeIntoPty(text.replace(/\r?\n/g, "\r"));
157
+ await delay(40);
158
+ this.writeToPty("\r");
159
+ }
160
+ async listResumeSessions(limit = 10) {
161
+ return listCodexResumeSessions(this.options.cwd, limit);
162
+ }
163
+ async resumeSession(threadId) {
164
+ if (this.isNativePanelMode()) {
165
+ throw new Error('WeChat /resume is disabled in codex mode. Use /resume directly inside "wechat-codex"; WeChat will follow the active local thread.');
166
+ }
167
+ await this.resumeSharedThread(threadId);
168
+ }
169
+ async interrupt() {
170
+ if (this.usesRpcTurnTransport()) {
171
+ return await this.interruptPanelTurn();
172
+ }
173
+ if (!this.pty) {
174
+ return false;
175
+ }
176
+ if (this.state.status !== "busy" && this.state.status !== "awaiting_approval") {
177
+ return false;
178
+ }
179
+ this.clearPendingApprovalState();
180
+ this.writeToPty("\u0003");
181
+ this.armInterruptFallback();
182
+ return true;
183
+ }
184
+ async resolveApproval(action) {
185
+ if (!this.pendingApproval) {
186
+ return false;
187
+ }
188
+ if (this.pendingApprovalRequest && this.rpcSocket) {
189
+ const request = this.pendingApprovalRequest;
190
+ await this.respondToApprovalRequest(request, action);
191
+ this.clearPendingApprovalState();
192
+ this.setStatus("busy");
193
+ return true;
194
+ }
195
+ return await super.resolveApproval(action);
196
+ }
197
+ async dispose() {
198
+ this.resetTurnTracking({ preserveThread: false });
199
+ if (this.isEmbeddedCliMode()) {
200
+ this.detachLocalInputForwarding();
201
+ }
202
+ this.stopSessionPolling();
203
+ if (this.isNativePanelMode()) {
204
+ this.cleanPanelExitInProgress = true;
205
+ }
206
+ await this.disconnectRpcClient();
207
+ if (this.isNativePanelMode()) {
208
+ await this.stopNativeClient();
209
+ this.clearCompletionTimer();
210
+ this.pendingApproval = null;
211
+ this.state.pendingApproval = null;
212
+ this.state.status = "stopped";
213
+ this.state.pid = undefined;
214
+ this.state.startedAt = undefined;
215
+ }
216
+ else if (this.isHeadlessRuntimeMode()) {
217
+ this.clearCompletionTimer();
218
+ this.clearInterruptTimer();
219
+ this.clearPendingApprovalState();
220
+ this.state.status = "stopped";
221
+ this.state.pid = undefined;
222
+ this.state.startedAt = undefined;
223
+ }
224
+ else {
225
+ await super.dispose();
226
+ }
227
+ await this.stopAppServer();
228
+ }
229
+ getLocalClientEndpoint() {
230
+ if (!this.isHeadlessRuntimeMode() || !this.appServerPort || !this.appServerAuthToken) {
231
+ return null;
232
+ }
233
+ return {
234
+ protocolVersion: LOCAL_CLIENT_PROTOCOL_VERSION,
235
+ runtimeKind: this.runtimeKind,
236
+ instanceId: this.localClientInstanceId,
237
+ kind: this.options.kind,
238
+ port: this.appServerPort,
239
+ token: this.appServerAuthToken,
240
+ renderMode: "headless",
241
+ bridgeOwnerPid: process.pid,
242
+ serverPort: this.appServerPort,
243
+ serverUrl: `ws://${CODEX_APP_SERVER_HOST}:${this.appServerPort}`,
244
+ remoteAuthTokenEnv: CODEX_REMOTE_AUTH_TOKEN_ENV,
245
+ cwd: this.options.cwd,
246
+ command: this.options.command,
247
+ profile: this.options.profile,
248
+ sharedSessionId: this.state.sharedSessionId,
249
+ sharedThreadId: this.state.sharedThreadId,
250
+ resumeConversationId: this.state.resumeConversationId,
251
+ transcriptPath: this.state.transcriptPath,
252
+ startedAt: this.state.startedAt ?? nowIso(),
253
+ };
254
+ }
255
+ handleData(rawText) {
256
+ this.renderLocalOutput(rawText);
257
+ const text = normalizeOutput(rawText);
258
+ if (!text) {
259
+ return;
260
+ }
261
+ this.state.lastOutputAt = nowIso();
262
+ const approval = detectCliApproval(text);
263
+ if (this.hasAcceptedInput) {
264
+ if (approval && !this.pendingApproval) {
265
+ this.pendingApproval = approval;
266
+ this.state.pendingApproval = approval;
267
+ this.state.pendingApprovalOrigin = this.state.activeTurnOrigin;
268
+ this.setStatus("awaiting_approval", "Codex approval is required.");
269
+ this.emit({
270
+ type: "approval_required",
271
+ request: approval,
272
+ timestamp: nowIso(),
273
+ });
274
+ }
275
+ return;
276
+ }
277
+ if (approval) {
278
+ this.startupBlocker = approval.commandPreview;
279
+ if (this.state.status !== "awaiting_approval") {
280
+ this.setStatus("awaiting_approval", "Codex is waiting for local terminal input.");
281
+ }
282
+ return;
283
+ }
284
+ if (this.startupBlocker) {
285
+ this.startupBlocker = null;
286
+ if (this.state.status === "awaiting_approval") {
287
+ this.setStatus("idle", "codex adapter is ready.");
288
+ }
289
+ }
290
+ }
291
+ handleExit(exitCode) {
292
+ this.resetTurnTracking({ preserveThread: false });
293
+ this.detachLocalInputForwarding();
294
+ this.stopSessionPolling();
295
+ void this.disconnectRpcClient();
296
+ void this.stopAppServer();
297
+ super.handleExit(exitCode);
298
+ }
299
+ isNativePanelMode() {
300
+ return this.options.renderMode === "panel";
301
+ }
302
+ isHeadlessRuntimeMode() {
303
+ return this.options.renderMode === "headless";
304
+ }
305
+ isEmbeddedCliMode() {
306
+ return !this.isNativePanelMode() && !this.isHeadlessRuntimeMode();
307
+ }
308
+ usesRpcTurnTransport() {
309
+ return this.isNativePanelMode() || this.isHeadlessRuntimeMode();
310
+ }
311
+ isCodexClientRunning() {
312
+ if (this.isHeadlessRuntimeMode()) {
313
+ return Boolean(this.appServer);
314
+ }
315
+ return this.isNativePanelMode() ? Boolean(this.nativeProcess) : Boolean(this.pty);
316
+ }
317
+ shouldPollSessionLog() {
318
+ return (this.isCodexClientRunning() ||
319
+ this.pendingTurnStart ||
320
+ Boolean(this.activeTurn) ||
321
+ Boolean(this.state.activeTurnId) ||
322
+ Boolean(this.sessionFilePath));
323
+ }
324
+ async startNativeClient() {
325
+ this.setStatus("starting", `Starting ${this.options.kind} adapter...`);
326
+ let spawnTarget = null;
327
+ try {
328
+ spawnTarget = resolveSpawnTarget(this.options.command, this.options.kind);
329
+ const child = spawnChild(spawnTarget.file, [...spawnTarget.args, ...this.buildSpawnArgs()], {
330
+ cwd: this.options.cwd,
331
+ env: this.buildEnv(),
332
+ stdio: "inherit",
333
+ windowsHide: false,
334
+ });
335
+ this.nativeProcess = child;
336
+ this.shuttingDown = false;
337
+ this.cleanPanelExitInProgress = false;
338
+ this.hasAcceptedInput = false;
339
+ this.state.pid = child.pid ?? undefined;
340
+ this.state.startedAt = nowIso();
341
+ this.state.status = "idle";
342
+ this.state.pendingApproval = null;
343
+ child.once("error", (error) => {
344
+ if (this.nativeProcess === child) {
345
+ this.handleNativeExit(undefined, undefined, error);
346
+ }
347
+ });
348
+ child.once("exit", (exitCode, signal) => {
349
+ if (this.nativeProcess === child) {
350
+ this.handleNativeExit(exitCode ?? undefined, signal ?? undefined);
351
+ }
352
+ });
353
+ this.afterStart();
354
+ this.setStatus("idle", `${this.options.kind} adapter is ready.`);
355
+ }
356
+ catch (err) {
357
+ this.state.status = "error";
358
+ this.emit({
359
+ type: "fatal_error",
360
+ message: `Failed to start ${this.options.kind}${spawnTarget ? ` (${spawnTarget.file})` : ""}: ${String(err)}`,
361
+ timestamp: nowIso(),
362
+ });
363
+ throw err;
364
+ }
365
+ }
366
+ handleNativeExit(exitCode, signal, startupError) {
367
+ const expectedShutdown = shouldTreatCodexNativeExitAsExpected({
368
+ renderMode: this.options.renderMode,
369
+ shuttingDown: this.shuttingDown,
370
+ exitCode,
371
+ signal,
372
+ startupError,
373
+ });
374
+ if (expectedShutdown && this.isNativePanelMode()) {
375
+ this.cleanPanelExitInProgress = true;
376
+ }
377
+ this.clearCompletionTimer();
378
+ this.resetTurnTracking({ preserveThread: false });
379
+ this.stopSessionPolling();
380
+ void this.disconnectRpcClient();
381
+ void this.stopAppServer();
382
+ this.shuttingDown = false;
383
+ this.nativeProcess = null;
384
+ this.state.status = "stopped";
385
+ this.state.pid = undefined;
386
+ this.pendingApproval = null;
387
+ this.state.pendingApproval = null;
388
+ if (expectedShutdown) {
389
+ this.emit({
390
+ type: "status",
391
+ status: "stopped",
392
+ message: `${this.options.kind} worker stopped.`,
393
+ timestamp: nowIso(),
394
+ });
395
+ return;
396
+ }
397
+ const exitLabel = startupError
398
+ ? startupError.message
399
+ : signal
400
+ ? `signal ${signal}`
401
+ : typeof exitCode === "number"
402
+ ? `code ${exitCode}`
403
+ : "an unknown code";
404
+ this.emit({
405
+ type: "fatal_error",
406
+ message: `${this.options.kind} worker exited unexpectedly with ${exitLabel}.`,
407
+ timestamp: nowIso(),
408
+ });
409
+ }
410
+ async stopNativeClient() {
411
+ if (!this.nativeProcess) {
412
+ this.state.pid = undefined;
413
+ return;
414
+ }
415
+ const child = this.nativeProcess;
416
+ this.shuttingDown = true;
417
+ this.nativeProcess = null;
418
+ await new Promise((resolve) => {
419
+ let settled = false;
420
+ const finish = () => {
421
+ if (settled) {
422
+ return;
423
+ }
424
+ settled = true;
425
+ resolve();
426
+ };
427
+ child.once("exit", () => finish());
428
+ try {
429
+ child.kill();
430
+ }
431
+ catch {
432
+ finish();
433
+ }
434
+ const timer = setTimeout(() => finish(), 1_500);
435
+ timer.unref?.();
436
+ });
437
+ }
438
+ startSessionPolling() {
439
+ this.stopSessionPolling();
440
+ const poll = () => {
441
+ void this.pollSessionLog();
442
+ };
443
+ this.sessionPollTimer = setInterval(poll, CODEX_SESSION_POLL_INTERVAL_MS);
444
+ this.sessionPollTimer.unref?.();
445
+ poll();
446
+ }
447
+ stopSessionPolling() {
448
+ if (this.sessionPollTimer) {
449
+ clearInterval(this.sessionPollTimer);
450
+ this.sessionPollTimer = null;
451
+ }
452
+ this.sessionFilePath = null;
453
+ this.sessionReadOffset = 0;
454
+ this.sessionPartialLine = "";
455
+ this.sessionFinalText = null;
456
+ this.sessionIgnoreBeforeMs = null;
457
+ this.nextSessionFallbackScanAtMs = 0;
458
+ }
459
+ async pollSessionLog() {
460
+ if (!this.shouldPollSessionLog()) {
461
+ return;
462
+ }
463
+ this.maybeApplyRecentSessionFallback();
464
+ if (!this.sessionFilePath) {
465
+ const startedAtMs = this.state.startedAt ? Date.parse(this.state.startedAt) : Date.now();
466
+ this.sessionFilePath = findCodexSessionFile(this.options.cwd, startedAtMs, { threadId: this.sharedThreadId ?? undefined });
467
+ if (!this.sessionFilePath) {
468
+ return;
469
+ }
470
+ this.sessionReadOffset = 0;
471
+ this.sessionPartialLine = "";
472
+ this.seedSessionReplayCutoff(startedAtMs);
473
+ }
474
+ let content;
475
+ try {
476
+ content = fs.readFileSync(this.sessionFilePath, "utf8");
477
+ }
478
+ catch {
479
+ this.sessionFilePath = null;
480
+ this.sessionReadOffset = 0;
481
+ this.sessionPartialLine = "";
482
+ return;
483
+ }
484
+ if (content.length < this.sessionReadOffset) {
485
+ this.sessionReadOffset = 0;
486
+ this.sessionPartialLine = "";
487
+ }
488
+ if (content.length === this.sessionReadOffset) {
489
+ return;
490
+ }
491
+ const chunk = content.slice(this.sessionReadOffset);
492
+ this.sessionReadOffset = content.length;
493
+ const lines = `${this.sessionPartialLine}${chunk}`.split(/\r?\n/);
494
+ this.sessionPartialLine = lines.pop() ?? "";
495
+ for (const line of lines) {
496
+ this.handleSessionLogLine(line);
497
+ }
498
+ }
499
+ seedSessionReplayCutoff(startedAtMs) {
500
+ if (this.sessionIgnoreBeforeMs !== null ||
501
+ this.pendingTurnStart ||
502
+ this.activeTurn ||
503
+ this.state.activeTurnId) {
504
+ return;
505
+ }
506
+ if (Number.isFinite(startedAtMs)) {
507
+ this.sessionIgnoreBeforeMs = startedAtMs;
508
+ }
509
+ }
510
+ maybeApplyRecentSessionFallback() {
511
+ if (!this.isNativePanelMode()) {
512
+ return;
513
+ }
514
+ const now = Date.now();
515
+ if (now < this.nextSessionFallbackScanAtMs) {
516
+ return;
517
+ }
518
+ this.nextSessionFallbackScanAtMs = now + CODEX_SESSION_FALLBACK_SCAN_INTERVAL_MS;
519
+ const startedAtMs = this.state.startedAt ? Date.parse(this.state.startedAt) : now;
520
+ const candidate = findRecentCodexSessionFileForCwd(this.options.cwd, startedAtMs);
521
+ if (!candidate) {
522
+ return;
523
+ }
524
+ let currentSessionModifiedAtMs = Number.NEGATIVE_INFINITY;
525
+ if (this.sessionFilePath) {
526
+ try {
527
+ currentSessionModifiedAtMs = fs.statSync(this.sessionFilePath).mtimeMs;
528
+ }
529
+ catch {
530
+ currentSessionModifiedAtMs = Number.NEGATIVE_INFINITY;
531
+ }
532
+ }
533
+ if (candidate.threadId !== this.sharedThreadId) {
534
+ if (this.sessionFilePath && candidate.modifiedAtMs <= currentSessionModifiedAtMs) {
535
+ return;
536
+ }
537
+ if (!this.activeTurn || this.activeTurn.threadId === candidate.threadId) {
538
+ this.trackLocalSharedThread(candidate.threadId, {
539
+ reason: "local_session_fallback",
540
+ signal: "session_fallback",
541
+ });
542
+ this.pendingThreadFollowId = null;
543
+ }
544
+ else {
545
+ this.pendingThreadFollowId = candidate.threadId;
546
+ }
547
+ }
548
+ if (this.sessionFilePath !== candidate.filePath) {
549
+ this.sessionFilePath = candidate.filePath;
550
+ this.sessionReadOffset = 0;
551
+ this.sessionPartialLine = "";
552
+ this.sessionFinalText = null;
553
+ this.seedSessionReplayCutoff(startedAtMs);
554
+ }
555
+ }
556
+ handleSessionLogLine(line) {
557
+ const trimmed = line.trim();
558
+ if (!trimmed) {
559
+ return;
560
+ }
561
+ let parsed;
562
+ try {
563
+ parsed = JSON.parse(trimmed);
564
+ }
565
+ catch {
566
+ return;
567
+ }
568
+ if (!isRecord(parsed) || !isRecord(parsed.payload) || typeof parsed.payload.type !== "string") {
569
+ return;
570
+ }
571
+ if (shouldIgnoreCodexSessionReplayEntry(parsed.timestamp, this.sessionIgnoreBeforeMs)) {
572
+ return;
573
+ }
574
+ const payload = parsed.payload;
575
+ const timestamp = typeof parsed.timestamp === "string" ? parsed.timestamp : nowIso();
576
+ if (this.sessionIgnoreBeforeMs !== null) {
577
+ this.sessionIgnoreBeforeMs = null;
578
+ }
579
+ switch (payload.type) {
580
+ case "task_started": {
581
+ if (typeof payload.turn_id === "string") {
582
+ this.recordTurnActivity(payload.turn_id, timestamp);
583
+ this.hasAcceptedInput = true;
584
+ this.state.activeTurnId = payload.turn_id;
585
+ const hasTrackedTurnContext = this.pendingTurnStart ||
586
+ Boolean(this.activeTurn) ||
587
+ this.state.activeTurnOrigin === "local" ||
588
+ this.state.activeTurnOrigin === "wechat";
589
+ if (hasTrackedTurnContext &&
590
+ this.state.status !== "busy" &&
591
+ this.state.status !== "awaiting_approval") {
592
+ const message = this.state.activeTurnOrigin === "local"
593
+ ? "Codex is busy with a local terminal turn."
594
+ : undefined;
595
+ this.setStatus("busy", message);
596
+ }
597
+ }
598
+ return;
599
+ }
600
+ case "user_message": {
601
+ if (typeof payload.message !== "string") {
602
+ return;
603
+ }
604
+ const message = normalizeOutput(payload.message).trim();
605
+ if (!message) {
606
+ return;
607
+ }
608
+ this.hasAcceptedInput = true;
609
+ this.state.lastInputAt = timestamp;
610
+ const origin = this.consumeInjectedInput(message) ? "wechat" : "local";
611
+ this.state.activeTurnOrigin = origin;
612
+ if (origin === "local") {
613
+ const turnId = this.activeTurn?.turnId ?? this.state.activeTurnId ?? null;
614
+ if (turnId && !this.mirroredUserInputTurnIds.has(turnId)) {
615
+ this.mirroredUserInputTurnIds.add(turnId);
616
+ this.emit({
617
+ type: "mirrored_user_input",
618
+ text: message,
619
+ timestamp,
620
+ origin: "local",
621
+ });
622
+ }
623
+ if (this.state.status !== "busy" && this.state.status !== "awaiting_approval") {
624
+ this.setStatus("busy", "Codex is busy with a local terminal turn.");
625
+ }
626
+ if (!turnId &&
627
+ !this.isRpcSocketOpen() &&
628
+ isRecentIsoTimestamp(timestamp, CODEX_SESSION_LOCAL_MIRROR_FALLBACK_WINDOW_MS)) {
629
+ this.emit({
630
+ type: "mirrored_user_input",
631
+ text: message,
632
+ timestamp,
633
+ origin: "local",
634
+ });
635
+ }
636
+ }
637
+ return;
638
+ }
639
+ case "agent_message": {
640
+ if (payload.phase !== "final_answer" || typeof payload.message !== "string") {
641
+ return;
642
+ }
643
+ const message = normalizeOutput(payload.message).trim();
644
+ if (message) {
645
+ this.sessionFinalText = message;
646
+ this.state.lastOutputAt = timestamp;
647
+ const activeTurnId = this.activeTurn?.turnId ?? this.state.activeTurnId ?? null;
648
+ if (activeTurnId) {
649
+ this.recordTurnActivity(activeTurnId, timestamp);
650
+ this.scheduleFinalReplyCompletionIfEligible(activeTurnId);
651
+ }
652
+ }
653
+ return;
654
+ }
655
+ case "task_complete": {
656
+ if (typeof payload.turn_id !== "string") {
657
+ return;
658
+ }
659
+ this.clearFinalReplyCompletionTimerForTurn(payload.turn_id);
660
+ if (this.hasCompletedTurn(payload.turn_id)) {
661
+ this.sessionFinalText = null;
662
+ if (this.activeTurn?.turnId === payload.turn_id) {
663
+ this.setActiveTurn(null);
664
+ }
665
+ this.cleanupTurnArtifacts(payload.turn_id);
666
+ if (this.state.status !== "stopped") {
667
+ this.setStatus("idle");
668
+ }
669
+ return;
670
+ }
671
+ const finalText = this.sessionFinalText ||
672
+ (typeof payload.last_agent_message === "string"
673
+ ? normalizeOutput(payload.last_agent_message).trim()
674
+ : "");
675
+ const completionOrigin = this.activeTurn?.turnId === payload.turn_id
676
+ ? this.activeTurn.origin
677
+ : this.state.activeTurnOrigin;
678
+ this.sessionFinalText = null;
679
+ if (this.activeTurn?.turnId === payload.turn_id) {
680
+ this.setActiveTurn(null);
681
+ }
682
+ else if (this.state.activeTurnId === payload.turn_id) {
683
+ this.state.activeTurnId = undefined;
684
+ this.state.activeTurnOrigin = undefined;
685
+ }
686
+ this.clearPendingApprovalState();
687
+ this.cleanupTurnArtifacts(payload.turn_id);
688
+ if (this.state.status !== "stopped") {
689
+ this.setStatus("idle");
690
+ }
691
+ if (finalText) {
692
+ this.emit({
693
+ type: "final_reply",
694
+ text: finalText,
695
+ timestamp,
696
+ });
697
+ }
698
+ this.emit({
699
+ type: "task_complete",
700
+ summary: completionOrigin === "local"
701
+ ? "Local terminal turn completed."
702
+ : this.currentPreview,
703
+ timestamp,
704
+ });
705
+ this.rememberCompletedTurn(payload.turn_id);
706
+ return;
707
+ }
708
+ }
709
+ }
710
+ rememberInjectedInput(text) {
711
+ const normalizedText = normalizeOutput(text).trim();
712
+ if (!normalizedText) {
713
+ return;
714
+ }
715
+ const cutoff = Date.now() - 60_000;
716
+ this.pendingInjectedInputs = this.pendingInjectedInputs.filter((entry) => entry.createdAtMs >= cutoff);
717
+ this.pendingInjectedInputs.push({
718
+ text,
719
+ normalizedText,
720
+ createdAtMs: Date.now(),
721
+ });
722
+ if (this.pendingInjectedInputs.length > 8) {
723
+ this.pendingInjectedInputs.splice(0, this.pendingInjectedInputs.length - 8);
724
+ }
725
+ }
726
+ consumeInjectedInput(message) {
727
+ const normalizedMessage = normalizeOutput(message).trim();
728
+ if (!normalizedMessage) {
729
+ return false;
730
+ }
731
+ const cutoff = Date.now() - 60_000;
732
+ this.pendingInjectedInputs = this.pendingInjectedInputs.filter((entry) => entry.createdAtMs >= cutoff);
733
+ const index = this.pendingInjectedInputs.findIndex((entry) => entry.normalizedText === normalizedMessage);
734
+ if (index < 0) {
735
+ return false;
736
+ }
737
+ this.pendingInjectedInputs.splice(index, 1);
738
+ return true;
739
+ }
740
+ async typeIntoPty(text) {
741
+ for (const character of text) {
742
+ this.writeToPty(character);
743
+ await delay(4);
744
+ }
745
+ }
746
+ async sendPanelTurn(text) {
747
+ if (this.isNativePanelMode() && !this.nativeProcess) {
748
+ throw new Error("codex panel is not running.");
749
+ }
750
+ this.recoverStaleBusyStateIfNeeded();
751
+ this.recoverStaleActiveTurnStateIfNeeded();
752
+ if (this.pendingApproval) {
753
+ throw new Error("A Codex approval request is pending. Reply with /confirm <code> or /deny.");
754
+ }
755
+ if (this.pendingTurnStart || this.activeTurn || this.state.status === "busy") {
756
+ const origin = this.state.activeTurnOrigin;
757
+ if (origin === "local") {
758
+ throw new Error("The local Codex panel is still working. Wait for the current reply or use /stop.");
759
+ }
760
+ throw new Error("codex is still working. Wait for the current reply or use /stop.");
761
+ }
762
+ this.clearInterruptTimer();
763
+ this.hasAcceptedInput = true;
764
+ this.currentPreview = truncatePreview(text);
765
+ this.state.lastInputAt = nowIso();
766
+ this.rememberInjectedInput(text);
767
+ this.clearPendingApprovalState();
768
+ const threadId = await this.ensureThreadStarted();
769
+ this.pendingTurnStart = true;
770
+ this.pendingTurnThreadId = threadId;
771
+ this.interruptPendingTurnStart = false;
772
+ this.state.activeTurnOrigin = "wechat";
773
+ this.setStatus("busy");
774
+ try {
775
+ const response = await this.sendRpcRequest("turn/start", {
776
+ threadId,
777
+ cwd: this.options.cwd,
778
+ approvalPolicy: "on-request",
779
+ approvalsReviewer: "user",
780
+ input: [
781
+ {
782
+ type: "text",
783
+ text,
784
+ },
785
+ ],
786
+ });
787
+ const turnId = this.extractTurnIdFromResponse(response);
788
+ if (!turnId) {
789
+ throw new Error("Codex did not return a turn id for the requested turn.");
790
+ }
791
+ this.bindActiveTurn({
792
+ threadId,
793
+ turnId,
794
+ origin: "wechat",
795
+ });
796
+ if (this.interruptPendingTurnStart) {
797
+ await this.requestActiveTurnInterrupt();
798
+ this.armInterruptFallback();
799
+ }
800
+ }
801
+ catch (error) {
802
+ this.pendingTurnStart = false;
803
+ this.pendingTurnThreadId = null;
804
+ this.interruptPendingTurnStart = false;
805
+ this.state.activeTurnOrigin = undefined;
806
+ if (!this.activeTurn && this.getState().status === "busy") {
807
+ this.setStatus("idle");
808
+ }
809
+ throw error;
810
+ }
811
+ }
812
+ async interruptPanelTurn() {
813
+ if (this.isNativePanelMode() && !this.nativeProcess) {
814
+ return false;
815
+ }
816
+ const turnPending = this.pendingTurnStart || this.state.status === "busy" || this.state.status === "awaiting_approval";
817
+ if (!turnPending) {
818
+ return false;
819
+ }
820
+ this.clearPendingApprovalState();
821
+ if (this.pendingTurnStart && !this.activeTurn) {
822
+ this.interruptPendingTurnStart = true;
823
+ this.armInterruptFallback();
824
+ return true;
825
+ }
826
+ if (!this.activeTurn) {
827
+ return false;
828
+ }
829
+ await this.requestActiveTurnInterrupt();
830
+ this.armInterruptFallback();
831
+ return true;
832
+ }
833
+ async startAppServer() {
834
+ if (this.appServer) {
835
+ return;
836
+ }
837
+ const port = await reserveLocalPort();
838
+ const env = this.buildEnv();
839
+ const workspacePaths = ensureWorkspaceChannelDir(this.options.cwd);
840
+ const token = crypto.randomBytes(24).toString("hex");
841
+ const tokenFilePath = path.join(workspacePaths.workspaceDir, `codex-app-server-token-${this.localClientInstanceId}.txt`);
842
+ fs.writeFileSync(tokenFilePath, `${token}\n`, "utf8");
843
+ const spawnTarget = resolveSpawnTarget(this.options.command, "codex");
844
+ const child = spawnChild(spawnTarget.file, [
845
+ ...spawnTarget.args,
846
+ "app-server",
847
+ "--listen",
848
+ `ws://${CODEX_APP_SERVER_HOST}:${port}`,
849
+ "--ws-auth",
850
+ "capability-token",
851
+ "--ws-token-file",
852
+ tokenFilePath,
853
+ ], {
854
+ cwd: this.options.cwd,
855
+ env,
856
+ stdio: "pipe",
857
+ windowsHide: true,
858
+ });
859
+ this.appServer = child;
860
+ this.appServerPort = port;
861
+ this.appServerShuttingDown = false;
862
+ this.appServerLog = "";
863
+ this.appServerAuthToken = token;
864
+ this.appServerAuthTokenFilePath = tokenFilePath;
865
+ child.stdout.setEncoding("utf8");
866
+ child.stderr.setEncoding("utf8");
867
+ child.stdout.on("data", (chunk) => {
868
+ this.appServerLog = appendBoundedLog(this.appServerLog, chunk);
869
+ });
870
+ child.stderr.on("data", (chunk) => {
871
+ this.appServerLog = appendBoundedLog(this.appServerLog, chunk);
872
+ });
873
+ child.on("exit", (code, signal) => {
874
+ const expectedShutdown = shouldSuppressCodexTransportFatalError({
875
+ transportShuttingDown: this.appServerShuttingDown,
876
+ shuttingDown: this.shuttingDown,
877
+ cleanPanelExitInProgress: this.cleanPanelExitInProgress,
878
+ });
879
+ this.appServer = null;
880
+ this.appServerPort = null;
881
+ this.appServerShuttingDown = false;
882
+ this.deleteAppServerAuthTokenFile();
883
+ this.appServerAuthToken = null;
884
+ if (expectedShutdown) {
885
+ return;
886
+ }
887
+ const exitLabel = signal ? `signal ${signal}` : `code ${typeof code === "number" ? code : "unknown"}`;
888
+ const details = this.describeAppServerLog();
889
+ this.emit({
890
+ type: "fatal_error",
891
+ message: `codex app-server exited unexpectedly with ${exitLabel}.${details}`,
892
+ timestamp: nowIso(),
893
+ });
894
+ this.terminateCodexClient();
895
+ });
896
+ try {
897
+ await waitForTcpPort(CODEX_APP_SERVER_HOST, port, CODEX_APP_SERVER_READY_TIMEOUT_MS);
898
+ }
899
+ catch (err) {
900
+ await this.stopAppServer();
901
+ const details = this.describeAppServerLog();
902
+ throw new Error(`Failed to start Codex app-server: ${String(err)}${details}`, {
903
+ cause: err,
904
+ });
905
+ }
906
+ }
907
+ async connectRpcClient() {
908
+ if (this.rpcSocket) {
909
+ return;
910
+ }
911
+ if (!this.appServerPort) {
912
+ throw new Error("Codex app-server is not ready.");
913
+ }
914
+ if (typeof WebSocket !== "function") {
915
+ throw new Error("Global WebSocket is unavailable in this runtime.");
916
+ }
917
+ const url = `ws://${CODEX_APP_SERVER_HOST}:${this.appServerPort}`;
918
+ const deadline = Date.now() + CODEX_APP_SERVER_READY_TIMEOUT_MS;
919
+ let lastError = "Timed out before the websocket became ready.";
920
+ while (Date.now() < deadline) {
921
+ try {
922
+ const socket = await this.openRpcSocket(url, this.appServerAuthToken, deadline - Date.now());
923
+ this.attachRpcSocket(socket);
924
+ await this.initializeRpcClient();
925
+ return;
926
+ }
927
+ catch (err) {
928
+ lastError = describeUnknownError(err);
929
+ await this.disconnectRpcClient();
930
+ await delay(CODEX_RPC_CONNECT_RETRY_MS);
931
+ }
932
+ }
933
+ throw new Error(`Failed to connect to Codex app-server websocket: ${lastError}`);
934
+ }
935
+ async openRpcSocket(url, authToken, timeoutMs) {
936
+ if (!authToken) {
937
+ throw new Error("Codex app-server websocket auth token is unavailable.");
938
+ }
939
+ return await new Promise((resolve, reject) => {
940
+ const socket = new WebSocket(url, {
941
+ headers: {
942
+ Authorization: `Bearer ${authToken}`,
943
+ },
944
+ });
945
+ let settled = false;
946
+ const timer = setTimeout(() => {
947
+ if (settled) {
948
+ return;
949
+ }
950
+ settled = true;
951
+ try {
952
+ socket.close();
953
+ }
954
+ catch {
955
+ // Best effort cleanup after timeout.
956
+ }
957
+ reject(new Error(`Timed out opening Codex websocket ${url}.`));
958
+ }, Math.max(500, timeoutMs));
959
+ const cleanup = () => {
960
+ clearTimeout(timer);
961
+ };
962
+ socket.addEventListener("open", () => {
963
+ if (settled) {
964
+ return;
965
+ }
966
+ settled = true;
967
+ cleanup();
968
+ resolve(socket);
969
+ }, { once: true });
970
+ socket.addEventListener("error", () => {
971
+ if (settled) {
972
+ return;
973
+ }
974
+ settled = true;
975
+ cleanup();
976
+ reject(new Error(`Failed to open Codex websocket ${url}.`));
977
+ }, { once: true });
978
+ });
979
+ }
980
+ attachRpcSocket(socket) {
981
+ this.rpcSocket = socket;
982
+ this.rpcShuttingDown = false;
983
+ socket.addEventListener("message", (event) => {
984
+ this.handleRpcMessageData(event.data);
985
+ });
986
+ socket.addEventListener("close", () => {
987
+ this.handleRpcSocketClosed();
988
+ });
989
+ }
990
+ async disconnectRpcClient() {
991
+ const socket = this.rpcSocket;
992
+ this.rpcSocket = null;
993
+ this.rpcShuttingDown = true;
994
+ this.rejectPendingRpcRequests("Codex websocket connection closed.");
995
+ if (!socket) {
996
+ this.rpcShuttingDown = false;
997
+ return;
998
+ }
999
+ await new Promise((resolve) => {
1000
+ if (socket.readyState === WebSocket.CLOSED) {
1001
+ resolve();
1002
+ return;
1003
+ }
1004
+ let settled = false;
1005
+ const finish = () => {
1006
+ if (settled) {
1007
+ return;
1008
+ }
1009
+ settled = true;
1010
+ resolve();
1011
+ };
1012
+ socket.addEventListener("close", () => finish(), { once: true });
1013
+ const timer = setTimeout(() => finish(), 1_000);
1014
+ timer.unref?.();
1015
+ try {
1016
+ socket.close();
1017
+ }
1018
+ catch {
1019
+ finish();
1020
+ }
1021
+ });
1022
+ this.rpcShuttingDown = false;
1023
+ }
1024
+ handleRpcSocketClosed() {
1025
+ const expectedShutdown = shouldSuppressCodexTransportFatalError({
1026
+ transportShuttingDown: this.rpcShuttingDown,
1027
+ shuttingDown: this.shuttingDown,
1028
+ cleanPanelExitInProgress: this.cleanPanelExitInProgress,
1029
+ });
1030
+ this.rpcSocket = null;
1031
+ this.rejectPendingRpcRequests("Codex websocket connection closed.");
1032
+ this.rpcShuttingDown = false;
1033
+ if (expectedShutdown) {
1034
+ return;
1035
+ }
1036
+ void this.reconnectRpcClientAfterUnexpectedClose();
1037
+ }
1038
+ async reconnectRpcClientAfterUnexpectedClose() {
1039
+ if (this.rpcReconnectPromise) {
1040
+ return await this.rpcReconnectPromise;
1041
+ }
1042
+ this.rpcReconnectPromise = (async () => {
1043
+ if (shouldSuppressCodexTransportFatalError({
1044
+ transportShuttingDown: this.rpcShuttingDown,
1045
+ shuttingDown: this.shuttingDown,
1046
+ cleanPanelExitInProgress: this.cleanPanelExitInProgress,
1047
+ })) {
1048
+ return false;
1049
+ }
1050
+ if (!this.appServer || !this.appServerPort) {
1051
+ if (shouldSuppressCodexTransportFatalError({
1052
+ transportShuttingDown: this.appServerShuttingDown,
1053
+ shuttingDown: this.shuttingDown,
1054
+ cleanPanelExitInProgress: this.cleanPanelExitInProgress,
1055
+ })) {
1056
+ return false;
1057
+ }
1058
+ const details = this.describeAppServerLog();
1059
+ this.emit({
1060
+ type: "fatal_error",
1061
+ message: `codex app-server websocket closed unexpectedly.${details}`,
1062
+ timestamp: nowIso(),
1063
+ });
1064
+ this.terminateCodexClient();
1065
+ return false;
1066
+ }
1067
+ const reconnectDeadline = Date.now() + CODEX_RPC_RECONNECT_TIMEOUT_MS;
1068
+ let lastError = "Codex websocket connection closed.";
1069
+ while (!this.shuttingDown &&
1070
+ !this.cleanPanelExitInProgress &&
1071
+ Date.now() < reconnectDeadline) {
1072
+ try {
1073
+ await this.connectRpcClient();
1074
+ return true;
1075
+ }
1076
+ catch (error) {
1077
+ lastError = describeUnknownError(error);
1078
+ await delay(CODEX_RPC_CONNECT_RETRY_MS);
1079
+ }
1080
+ }
1081
+ const details = this.describeAppServerLog();
1082
+ if (shouldSuppressCodexTransportFatalError({
1083
+ transportShuttingDown: this.appServerShuttingDown,
1084
+ shuttingDown: this.shuttingDown,
1085
+ cleanPanelExitInProgress: this.cleanPanelExitInProgress,
1086
+ })) {
1087
+ return false;
1088
+ }
1089
+ this.emit({
1090
+ type: "fatal_error",
1091
+ message: `codex app-server websocket closed unexpectedly and could not reconnect: ${lastError}.${details}`,
1092
+ timestamp: nowIso(),
1093
+ });
1094
+ this.terminateCodexClient();
1095
+ return false;
1096
+ })();
1097
+ try {
1098
+ return await this.rpcReconnectPromise;
1099
+ }
1100
+ finally {
1101
+ this.rpcReconnectPromise = null;
1102
+ }
1103
+ }
1104
+ rejectPendingRpcRequests(message) {
1105
+ for (const pending of this.pendingRpcRequests.values()) {
1106
+ pending.reject(new Error(message));
1107
+ }
1108
+ this.pendingRpcRequests.clear();
1109
+ }
1110
+ async initializeRpcClient() {
1111
+ await this.sendRpcRequest("initialize", {
1112
+ clientInfo: {
1113
+ name: "wechat-bridge",
1114
+ title: "WeChat Bridge",
1115
+ version: "0.1.0",
1116
+ },
1117
+ capabilities: {
1118
+ experimentalApi: true,
1119
+ },
1120
+ });
1121
+ }
1122
+ async restoreInitialSharedThreadIfNeeded() {
1123
+ if (!this.resumeThreadId || this.isNativePanelMode()) {
1124
+ return;
1125
+ }
1126
+ const threadId = this.resumeThreadId;
1127
+ this.resumeThreadId = null;
1128
+ try {
1129
+ await this.resumeSharedThread(threadId, { startup: true });
1130
+ }
1131
+ catch (error) {
1132
+ this.updateSharedThread(null);
1133
+ this.emit({
1134
+ type: "status",
1135
+ status: "starting",
1136
+ message: `Failed to restore the previous Codex thread ${threadId.slice(0, 12)}. Starting without resume: ${describeUnknownError(error)}`,
1137
+ timestamp: nowIso(),
1138
+ });
1139
+ }
1140
+ }
1141
+ async ensureThreadStarted() {
1142
+ if (this.sharedThreadId) {
1143
+ return this.sharedThreadId;
1144
+ }
1145
+ const response = await this.sendRpcRequest("thread/start", {
1146
+ cwd: this.options.cwd,
1147
+ approvalPolicy: "on-request",
1148
+ approvalsReviewer: "user",
1149
+ sandbox: "workspace-write",
1150
+ serviceName: "wechat-bridge",
1151
+ experimentalRawEvents: false,
1152
+ persistExtendedHistory: true,
1153
+ });
1154
+ const threadId = this.extractThreadIdFromResponse(response);
1155
+ if (!threadId) {
1156
+ throw new Error("Codex did not return a thread id for the bridge session.");
1157
+ }
1158
+ this.rememberBridgeOwnedThreadSignal(threadId);
1159
+ this.updateSharedThread(threadId);
1160
+ return threadId;
1161
+ }
1162
+ async resumeSharedThread(threadId, options = {}) {
1163
+ const trimmedThreadId = threadId.trim();
1164
+ if (!trimmedThreadId) {
1165
+ throw new Error("A thread id is required to resume a Codex thread.");
1166
+ }
1167
+ if (this.pendingApproval) {
1168
+ throw new Error("A Codex approval request is pending. Reply with /confirm <code> or /deny.");
1169
+ }
1170
+ if (!options.startup &&
1171
+ (this.pendingTurnStart ||
1172
+ this.activeTurn ||
1173
+ this.state.status === "busy" ||
1174
+ this.state.status === "awaiting_approval")) {
1175
+ throw new Error("codex is still working. Wait for the current reply or use /stop.");
1176
+ }
1177
+ const response = await this.sendRpcRequest("thread/resume", {
1178
+ threadId: trimmedThreadId,
1179
+ cwd: this.options.cwd,
1180
+ approvalPolicy: "on-request",
1181
+ approvalsReviewer: "user",
1182
+ sandbox: "workspace-write",
1183
+ });
1184
+ const resumedThreadId = this.extractThreadIdFromResponse(response);
1185
+ if (!resumedThreadId) {
1186
+ throw new Error("Codex did not return a thread id while resuming the saved thread.");
1187
+ }
1188
+ this.rememberBridgeOwnedThreadSignal(resumedThreadId);
1189
+ this.sessionFilePath = null;
1190
+ this.sessionReadOffset = 0;
1191
+ this.sessionPartialLine = "";
1192
+ this.sessionFinalText = null;
1193
+ this.pendingThreadFollowId = null;
1194
+ this.updateSharedThread(resumedThreadId, {
1195
+ source: options.startup ? "restore" : "wechat",
1196
+ reason: options.startup ? "startup_restore" : "wechat_resume",
1197
+ notify: true,
1198
+ });
1199
+ }
1200
+ extractThreadIdFromResponse(response) {
1201
+ if (!isRecord(response) || !isRecord(response.thread)) {
1202
+ return null;
1203
+ }
1204
+ return typeof response.thread.id === "string" ? response.thread.id : null;
1205
+ }
1206
+ extractTurnIdFromResponse(response) {
1207
+ if (!isRecord(response) || !isRecord(response.turn)) {
1208
+ return null;
1209
+ }
1210
+ return typeof response.turn.id === "string" ? response.turn.id : null;
1211
+ }
1212
+ bindActiveTurn(activeTurn) {
1213
+ this.pendingTurnStart = false;
1214
+ this.pendingTurnThreadId = null;
1215
+ this.bridgeOwnedTurnIds.add(activeTurn.turnId);
1216
+ this.setActiveTurn(activeTurn);
1217
+ const queuedNotifications = this.queuedTurnNotifications;
1218
+ this.queuedTurnNotifications = [];
1219
+ for (const notification of queuedNotifications) {
1220
+ this.handleRpcNotification(notification.method, notification.params);
1221
+ }
1222
+ const queuedRequests = this.queuedTurnServerRequests;
1223
+ this.queuedTurnServerRequests = [];
1224
+ for (const request of queuedRequests) {
1225
+ this.handleRpcServerRequest(request.requestId, request.method, request.params);
1226
+ }
1227
+ }
1228
+ async requestActiveTurnInterrupt() {
1229
+ if (!this.activeTurn) {
1230
+ return;
1231
+ }
1232
+ await this.sendRpcRequest("turn/interrupt", {
1233
+ threadId: this.activeTurn.threadId,
1234
+ turnId: this.activeTurn.turnId,
1235
+ });
1236
+ }
1237
+ armInterruptFallback() {
1238
+ this.clearInterruptTimer();
1239
+ this.interruptTimer = setTimeout(() => {
1240
+ this.interruptTimer = null;
1241
+ if (this.state.status !== "busy" && this.state.status !== "awaiting_approval") {
1242
+ return;
1243
+ }
1244
+ this.resetTurnTracking({ preserveThread: true });
1245
+ this.setStatus("idle", "Codex task interrupted.");
1246
+ this.emit({
1247
+ type: "task_complete",
1248
+ summary: "Interrupted",
1249
+ timestamp: nowIso(),
1250
+ });
1251
+ }, INTERRUPT_SETTLE_DELAY_MS);
1252
+ }
1253
+ clearInterruptTimer() {
1254
+ if (!this.interruptTimer) {
1255
+ return;
1256
+ }
1257
+ clearTimeout(this.interruptTimer);
1258
+ this.interruptTimer = null;
1259
+ }
1260
+ recoverStaleBusyStateIfNeeded() {
1261
+ if (!shouldRecoverCodexStaleBusyState({
1262
+ status: this.state.status,
1263
+ pendingTurnStart: this.pendingTurnStart,
1264
+ hasActiveTurn: Boolean(this.activeTurn),
1265
+ hasPendingApproval: Boolean(this.pendingApproval || this.pendingApprovalRequest),
1266
+ activeTurnId: this.state.activeTurnId,
1267
+ })) {
1268
+ return;
1269
+ }
1270
+ this.pendingTurnStart = false;
1271
+ this.pendingTurnThreadId = null;
1272
+ this.interruptPendingTurnStart = false;
1273
+ this.state.activeTurnId = undefined;
1274
+ this.state.activeTurnOrigin = undefined;
1275
+ this.clearInterruptTimer();
1276
+ this.setStatus("idle", "Recovered stale busy state.");
1277
+ }
1278
+ recoverStaleActiveTurnStateIfNeeded() {
1279
+ if (!this.activeTurn ||
1280
+ this.pendingTurnStart ||
1281
+ this.pendingApproval ||
1282
+ this.pendingApprovalRequest ||
1283
+ this.state.status === "busy" ||
1284
+ this.state.status === "awaiting_approval" ||
1285
+ this.state.activeTurnId) {
1286
+ return;
1287
+ }
1288
+ this.cleanupTurnArtifacts(this.activeTurn.turnId);
1289
+ this.setActiveTurn(null);
1290
+ this.clearInterruptTimer();
1291
+ }
1292
+ resetTurnTracking(options) {
1293
+ this.clearInterruptTimer();
1294
+ this.clearFinalReplyCompletionTimer();
1295
+ if (this.activeTurn) {
1296
+ this.cleanupTurnArtifacts(this.activeTurn.turnId);
1297
+ }
1298
+ this.setActiveTurn(null);
1299
+ this.pendingTurnStart = false;
1300
+ this.pendingTurnThreadId = null;
1301
+ this.interruptPendingTurnStart = false;
1302
+ this.pendingThreadFollowId = null;
1303
+ this.clearPendingApprovalState();
1304
+ this.queuedTurnNotifications = [];
1305
+ this.queuedTurnServerRequests = [];
1306
+ this.turnFinalMessages.clear();
1307
+ this.turnDeltaByItem.clear();
1308
+ this.turnErrorById.clear();
1309
+ this.turnLastActivityAtMs.clear();
1310
+ this.mirroredUserInputTurnIds.clear();
1311
+ this.bridgeOwnedTurnIds.clear();
1312
+ this.completedTurnIds.clear();
1313
+ this.completedTurnOrder = [];
1314
+ this.pendingInjectedInputs = [];
1315
+ this.recentBridgeThreadSignalAtById.clear();
1316
+ this.sessionFinalText = null;
1317
+ this.nextSessionFallbackScanAtMs = 0;
1318
+ this.state.activeTurnId = undefined;
1319
+ this.state.activeTurnOrigin = undefined;
1320
+ if (!options.preserveThread) {
1321
+ this.clearPendingThreadAnnouncement();
1322
+ this.announcedThreadId = null;
1323
+ }
1324
+ if (!options.preserveThread) {
1325
+ this.updateSharedThread(null);
1326
+ }
1327
+ }
1328
+ updateSharedThread(threadId, options = {}) {
1329
+ const previousThreadId = this.sharedThreadId;
1330
+ this.sharedThreadId = threadId;
1331
+ this.state.sharedSessionId = threadId ?? undefined;
1332
+ this.state.sharedThreadId = threadId ?? undefined;
1333
+ if (!threadId) {
1334
+ this.clearPendingThreadAnnouncement();
1335
+ this.announcedThreadId = null;
1336
+ }
1337
+ else if (previousThreadId !== threadId &&
1338
+ this.pendingThreadAnnouncement &&
1339
+ this.pendingThreadAnnouncement.threadId !== threadId) {
1340
+ this.clearPendingThreadAnnouncement();
1341
+ }
1342
+ if (threadId && options.source && options.reason) {
1343
+ const switchedAt = nowIso();
1344
+ this.state.lastSessionSwitchAt = switchedAt;
1345
+ this.state.lastSessionSwitchSource = options.source;
1346
+ this.state.lastSessionSwitchReason = options.reason;
1347
+ this.state.lastThreadSwitchAt = switchedAt;
1348
+ this.state.lastThreadSwitchSource = options.source;
1349
+ this.state.lastThreadSwitchReason = options.reason;
1350
+ if (options.notify) {
1351
+ this.emitThreadSwitched(threadId, options.source, options.reason);
1352
+ }
1353
+ }
1354
+ if (previousThreadId !== threadId) {
1355
+ this.sessionFilePath = null;
1356
+ this.sessionReadOffset = 0;
1357
+ this.sessionPartialLine = "";
1358
+ this.sessionFinalText = null;
1359
+ this.sessionIgnoreBeforeMs = threadId ? Date.now() : null;
1360
+ this.nextSessionFallbackScanAtMs = 0;
1361
+ this.emit({
1362
+ type: "status",
1363
+ status: this.state.status,
1364
+ timestamp: nowIso(),
1365
+ });
1366
+ }
1367
+ }
1368
+ setActiveTurn(activeTurn) {
1369
+ this.activeTurn = activeTurn;
1370
+ this.state.activeTurnId = activeTurn?.turnId;
1371
+ this.state.activeTurnOrigin = activeTurn?.origin;
1372
+ if (!activeTurn && this.pendingThreadFollowId) {
1373
+ const pendingThreadId = this.pendingThreadFollowId;
1374
+ this.pendingThreadFollowId = null;
1375
+ this.trackLocalSharedThread(pendingThreadId, {
1376
+ reason: "local_follow",
1377
+ signal: "status_changed",
1378
+ });
1379
+ }
1380
+ }
1381
+ clearPendingThreadAnnouncement() {
1382
+ if (!this.pendingThreadAnnouncement) {
1383
+ return;
1384
+ }
1385
+ if (this.pendingThreadAnnouncement.timer) {
1386
+ clearTimeout(this.pendingThreadAnnouncement.timer);
1387
+ }
1388
+ this.pendingThreadAnnouncement = null;
1389
+ }
1390
+ emitThreadSwitched(threadId, source, reason) {
1391
+ if (this.announcedThreadId === threadId) {
1392
+ if (this.pendingThreadAnnouncement?.threadId === threadId) {
1393
+ this.clearPendingThreadAnnouncement();
1394
+ }
1395
+ return;
1396
+ }
1397
+ if (this.pendingThreadAnnouncement?.threadId === threadId) {
1398
+ this.clearPendingThreadAnnouncement();
1399
+ }
1400
+ const switchedAt = nowIso();
1401
+ this.announcedThreadId = threadId;
1402
+ this.state.lastSessionSwitchAt = switchedAt;
1403
+ this.state.lastSessionSwitchSource = source;
1404
+ this.state.lastSessionSwitchReason = reason;
1405
+ this.state.lastThreadSwitchAt = switchedAt;
1406
+ this.state.lastThreadSwitchSource = source;
1407
+ this.state.lastThreadSwitchReason = reason;
1408
+ this.emit({
1409
+ type: "thread_switched",
1410
+ threadId,
1411
+ source,
1412
+ reason,
1413
+ timestamp: switchedAt,
1414
+ });
1415
+ }
1416
+ isPendingThreadAnnouncementStable(pending) {
1417
+ return pending.signals.has("user_message") || pending.signals.size >= 2;
1418
+ }
1419
+ schedulePendingThreadAnnouncement() {
1420
+ const pending = this.pendingThreadAnnouncement;
1421
+ if (!pending || pending.timer || !this.isNativePanelMode()) {
1422
+ return;
1423
+ }
1424
+ pending.timer = setTimeout(() => {
1425
+ const current = this.pendingThreadAnnouncement;
1426
+ if (!current || current.threadId !== pending.threadId) {
1427
+ return;
1428
+ }
1429
+ current.timer = null;
1430
+ this.updateSharedThread(current.threadId, {
1431
+ source: current.source,
1432
+ reason: current.reason,
1433
+ notify: true,
1434
+ });
1435
+ }, CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS);
1436
+ pending.timer.unref?.();
1437
+ }
1438
+ trackLocalSharedThread(threadId, options) {
1439
+ if (!this.isNativePanelMode()) {
1440
+ this.updateSharedThread(threadId, {
1441
+ source: "local",
1442
+ reason: options.reason,
1443
+ notify: true,
1444
+ });
1445
+ return;
1446
+ }
1447
+ this.updateSharedThread(threadId, {
1448
+ source: "local",
1449
+ reason: options.reason,
1450
+ });
1451
+ if (this.announcedThreadId === threadId) {
1452
+ if (this.pendingThreadAnnouncement?.threadId === threadId) {
1453
+ this.clearPendingThreadAnnouncement();
1454
+ }
1455
+ return;
1456
+ }
1457
+ if (!this.pendingThreadAnnouncement || this.pendingThreadAnnouncement.threadId !== threadId) {
1458
+ this.clearPendingThreadAnnouncement();
1459
+ this.pendingThreadAnnouncement = {
1460
+ threadId,
1461
+ source: "local",
1462
+ reason: options.reason,
1463
+ signals: new Set(),
1464
+ timer: null,
1465
+ };
1466
+ }
1467
+ this.pendingThreadAnnouncement.source = "local";
1468
+ this.pendingThreadAnnouncement.reason = options.reason;
1469
+ this.pendingThreadAnnouncement.signals.add(options.signal);
1470
+ if (this.isPendingThreadAnnouncementStable(this.pendingThreadAnnouncement)) {
1471
+ this.updateSharedThread(threadId, {
1472
+ source: "local",
1473
+ reason: options.reason,
1474
+ notify: true,
1475
+ });
1476
+ return;
1477
+ }
1478
+ this.schedulePendingThreadAnnouncement();
1479
+ }
1480
+ rememberBridgeOwnedThreadSignal(threadId) {
1481
+ const cutoff = Date.now() - CODEX_THREAD_SIGNAL_TTL_MS;
1482
+ for (const [candidateThreadId, recordedAtMs] of this.recentBridgeThreadSignalAtById.entries()) {
1483
+ if (recordedAtMs < cutoff) {
1484
+ this.recentBridgeThreadSignalAtById.delete(candidateThreadId);
1485
+ }
1486
+ }
1487
+ this.recentBridgeThreadSignalAtById.set(threadId, Date.now());
1488
+ }
1489
+ isRecentlyBridgeOwnedThread(threadId) {
1490
+ const recordedAtMs = this.recentBridgeThreadSignalAtById.get(threadId);
1491
+ if (!recordedAtMs) {
1492
+ return false;
1493
+ }
1494
+ if (recordedAtMs < Date.now() - CODEX_THREAD_SIGNAL_TTL_MS) {
1495
+ this.recentBridgeThreadSignalAtById.delete(threadId);
1496
+ return false;
1497
+ }
1498
+ return true;
1499
+ }
1500
+ clearPendingApprovalState() {
1501
+ this.pendingApprovalRequest = null;
1502
+ this.pendingApproval = null;
1503
+ this.state.pendingApproval = null;
1504
+ this.state.pendingApprovalOrigin = undefined;
1505
+ }
1506
+ cleanupTurnArtifacts(turnId) {
1507
+ this.clearFinalReplyCompletionTimerForTurn(turnId);
1508
+ this.turnFinalMessages.delete(turnId);
1509
+ this.turnDeltaByItem.delete(turnId);
1510
+ this.turnErrorById.delete(turnId);
1511
+ this.turnLastActivityAtMs.delete(turnId);
1512
+ this.mirroredUserInputTurnIds.delete(turnId);
1513
+ this.bridgeOwnedTurnIds.delete(turnId);
1514
+ }
1515
+ rpcRequestKey(requestId) {
1516
+ return `${typeof requestId}:${String(requestId)}`;
1517
+ }
1518
+ isRpcSocketOpen() {
1519
+ return Boolean(this.rpcSocket && this.rpcSocket.readyState === WebSocket.OPEN);
1520
+ }
1521
+ async ensureRpcClientConnected() {
1522
+ if (this.isRpcSocketOpen()) {
1523
+ return;
1524
+ }
1525
+ if (this.rpcReconnectPromise) {
1526
+ const reconnected = await this.rpcReconnectPromise;
1527
+ if (!reconnected || !this.isRpcSocketOpen()) {
1528
+ throw new Error("Codex websocket is not connected.");
1529
+ }
1530
+ return;
1531
+ }
1532
+ await this.connectRpcClient();
1533
+ if (!this.isRpcSocketOpen()) {
1534
+ throw new Error("Codex websocket is not connected.");
1535
+ }
1536
+ }
1537
+ async sendRpcRequest(method, params) {
1538
+ await this.ensureRpcClientConnected();
1539
+ const socket = this.rpcSocket;
1540
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
1541
+ throw new Error("Codex websocket is not connected.");
1542
+ }
1543
+ const requestId = ++this.rpcRequestCounter;
1544
+ const requestKey = this.rpcRequestKey(requestId);
1545
+ const responsePromise = new Promise((resolve, reject) => {
1546
+ this.pendingRpcRequests.set(requestKey, {
1547
+ method,
1548
+ resolve,
1549
+ reject,
1550
+ });
1551
+ });
1552
+ try {
1553
+ this.sendRpcMessage({
1554
+ id: requestId,
1555
+ method,
1556
+ params,
1557
+ });
1558
+ }
1559
+ catch (err) {
1560
+ this.pendingRpcRequests.delete(requestKey);
1561
+ throw err;
1562
+ }
1563
+ return await responsePromise;
1564
+ }
1565
+ sendRpcMessage(payload) {
1566
+ const socket = this.rpcSocket;
1567
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
1568
+ throw new Error("Codex websocket is not connected.");
1569
+ }
1570
+ socket.send(JSON.stringify(payload));
1571
+ }
1572
+ async respondToApprovalRequest(request, action) {
1573
+ const decision = action === "confirm" ? "accept" : "decline";
1574
+ this.sendRpcMessage({
1575
+ id: request.requestId,
1576
+ result: { decision },
1577
+ });
1578
+ }
1579
+ handleRpcMessageData(data) {
1580
+ const text = coerceWebSocketMessageData(data);
1581
+ if (!text) {
1582
+ return;
1583
+ }
1584
+ let payload;
1585
+ try {
1586
+ payload = JSON.parse(text);
1587
+ }
1588
+ catch {
1589
+ return;
1590
+ }
1591
+ if (!isRecord(payload)) {
1592
+ return;
1593
+ }
1594
+ const requestId = getCodexRpcRequestId(payload.id);
1595
+ const method = typeof payload.method === "string" ? payload.method : null;
1596
+ if (requestId !== null && method) {
1597
+ this.handleRpcServerRequest(requestId, method, payload.params);
1598
+ return;
1599
+ }
1600
+ if (requestId !== null) {
1601
+ this.handleRpcResponse(requestId, payload);
1602
+ return;
1603
+ }
1604
+ if (method) {
1605
+ this.handleRpcNotification(method, payload.params);
1606
+ }
1607
+ }
1608
+ handleRpcResponse(requestId, payload) {
1609
+ const requestKey = this.rpcRequestKey(requestId);
1610
+ const pending = this.pendingRpcRequests.get(requestKey);
1611
+ if (!pending) {
1612
+ return;
1613
+ }
1614
+ this.pendingRpcRequests.delete(requestKey);
1615
+ if (payload.error !== undefined && payload.error !== null) {
1616
+ pending.reject(new Error(normalizeCodexRpcError(payload.error)));
1617
+ return;
1618
+ }
1619
+ pending.resolve(payload.result);
1620
+ }
1621
+ handleRpcNotification(method, params) {
1622
+ if (!isRecord(params)) {
1623
+ return;
1624
+ }
1625
+ if (method === "thread/started") {
1626
+ this.handleThreadStarted(params);
1627
+ return;
1628
+ }
1629
+ if (method === "thread/status/changed") {
1630
+ this.handleThreadStatusChanged(params);
1631
+ return;
1632
+ }
1633
+ if (method === "item/started" ||
1634
+ method === "item/agentMessage/delta" ||
1635
+ method === "item/completed" ||
1636
+ method === "turn/completed" ||
1637
+ method === "turn/started" ||
1638
+ method === "error" ||
1639
+ method === "serverRequest/resolved") {
1640
+ if (this.shouldQueuePendingTurnEvent(params)) {
1641
+ this.queuedTurnNotifications.push({ method, params });
1642
+ return;
1643
+ }
1644
+ const trackedTurn = this.identifyTrackedTurn(method, params);
1645
+ if (!trackedTurn) {
1646
+ return;
1647
+ }
1648
+ this.handleTrackedTurnNotification(method, params, trackedTurn);
1649
+ return;
1650
+ }
1651
+ if (this.activeTurn) {
1652
+ this.state.lastOutputAt = nowIso();
1653
+ }
1654
+ }
1655
+ shouldQueuePendingTurnEvent(params) {
1656
+ if (!this.pendingTurnStart || this.activeTurn || !this.pendingTurnThreadId) {
1657
+ return false;
1658
+ }
1659
+ return getNotificationThreadId(params) === this.pendingTurnThreadId;
1660
+ }
1661
+ identifyTrackedTurn(method, params) {
1662
+ const threadId = getNotificationThreadId(params);
1663
+ const turnId = getNotificationTurnId(params);
1664
+ if (!threadId || !turnId) {
1665
+ return null;
1666
+ }
1667
+ if (this.bridgeOwnedTurnIds.has(turnId)) {
1668
+ return {
1669
+ threadId,
1670
+ turnId,
1671
+ origin: "wechat",
1672
+ };
1673
+ }
1674
+ if (this.activeTurn?.turnId === turnId) {
1675
+ return {
1676
+ threadId,
1677
+ turnId,
1678
+ origin: this.activeTurn.origin,
1679
+ };
1680
+ }
1681
+ const localBootstrapUserMessage = this.isNativePanelMode() &&
1682
+ !this.activeTurn &&
1683
+ (method === "item/started" || method === "item/completed") &&
1684
+ extractCodexUserMessageText(params.item);
1685
+ if (localBootstrapUserMessage) {
1686
+ return {
1687
+ threadId,
1688
+ turnId,
1689
+ origin: "local",
1690
+ };
1691
+ }
1692
+ if (this.sharedThreadId && threadId === this.sharedThreadId) {
1693
+ return {
1694
+ threadId,
1695
+ turnId,
1696
+ origin: "local",
1697
+ };
1698
+ }
1699
+ if (method === "turn/started" && !this.activeTurn) {
1700
+ return {
1701
+ threadId,
1702
+ turnId,
1703
+ origin: "local",
1704
+ };
1705
+ }
1706
+ return null;
1707
+ }
1708
+ handleTrackedTurnNotification(method, params, trackedTurn) {
1709
+ this.state.lastOutputAt = nowIso();
1710
+ this.recordTurnActivity(trackedTurn.turnId);
1711
+ this.handleTrackedTurnStarted(trackedTurn);
1712
+ switch (method) {
1713
+ case "item/started": {
1714
+ this.maybeMirrorLocalUserInput(trackedTurn, params.item);
1715
+ return;
1716
+ }
1717
+ case "item/agentMessage/delta": {
1718
+ const itemId = typeof params.itemId === "string" ? params.itemId : null;
1719
+ const delta = typeof params.delta === "string" ? params.delta : "";
1720
+ if (!itemId || !delta) {
1721
+ return;
1722
+ }
1723
+ const deltaByItem = this.getTurnDeltaMap(trackedTurn.turnId);
1724
+ const previous = deltaByItem.get(itemId) ?? "";
1725
+ deltaByItem.set(itemId, `${previous}${delta}`);
1726
+ return;
1727
+ }
1728
+ case "item/completed": {
1729
+ this.maybeMirrorLocalUserInput(trackedTurn, params.item);
1730
+ const itemId = isRecord(params.item) && typeof params.item.id === "string"
1731
+ ? params.item.id
1732
+ : null;
1733
+ const finalText = extractCodexFinalTextFromItem(params.item);
1734
+ if (itemId && finalText) {
1735
+ this.getTurnFinalMessageMap(trackedTurn.turnId).set(itemId, finalText);
1736
+ this.scheduleFinalReplyCompletionIfEligible(trackedTurn.turnId);
1737
+ }
1738
+ return;
1739
+ }
1740
+ case "error": {
1741
+ if (isRecord(params.error) && typeof params.error.message === "string") {
1742
+ this.turnErrorById.set(trackedTurn.turnId, params.error.message);
1743
+ }
1744
+ return;
1745
+ }
1746
+ case "serverRequest/resolved": {
1747
+ const requestId = getCodexRpcRequestId(params.requestId);
1748
+ if (requestId !== null &&
1749
+ this.pendingApprovalRequest &&
1750
+ requestId === this.pendingApprovalRequest.requestId &&
1751
+ trackedTurn.turnId === this.pendingApprovalRequest.turnId) {
1752
+ this.clearPendingApprovalState();
1753
+ if (this.state.status === "awaiting_approval") {
1754
+ this.setStatus("busy", "Codex approval resolved.");
1755
+ }
1756
+ }
1757
+ return;
1758
+ }
1759
+ case "turn/completed": {
1760
+ this.clearFinalReplyCompletionTimerForTurn(trackedTurn.turnId);
1761
+ this.handleTurnCompleted(trackedTurn, params);
1762
+ return;
1763
+ }
1764
+ }
1765
+ }
1766
+ handleRpcServerRequest(requestId, method, params) {
1767
+ if (method !== "item/commandExecution/requestApproval" &&
1768
+ method !== "item/fileChange/requestApproval") {
1769
+ this.sendRpcMessage({
1770
+ id: requestId,
1771
+ error: {
1772
+ code: -32601,
1773
+ message: `Unsupported server request: ${method}`,
1774
+ },
1775
+ });
1776
+ return;
1777
+ }
1778
+ if (!isRecord(params)) {
1779
+ this.sendRpcMessage({
1780
+ id: requestId,
1781
+ error: {
1782
+ code: -32602,
1783
+ message: "Invalid Codex approval request payload.",
1784
+ },
1785
+ });
1786
+ return;
1787
+ }
1788
+ if (this.shouldQueuePendingTurnEvent(params)) {
1789
+ this.queuedTurnServerRequests.push({
1790
+ requestId,
1791
+ method,
1792
+ params,
1793
+ });
1794
+ return;
1795
+ }
1796
+ const trackedTurn = this.identifyTrackedTurn("server/request", params);
1797
+ if (!trackedTurn) {
1798
+ return;
1799
+ }
1800
+ this.handleTrackedTurnStarted(trackedTurn);
1801
+ this.handleTrackedTurnServerRequest(requestId, method, params, trackedTurn);
1802
+ }
1803
+ handleTrackedTurnServerRequest(requestId, method, params, trackedTurn) {
1804
+ const request = buildCodexApprovalRequest(method, params);
1805
+ if (!request) {
1806
+ return;
1807
+ }
1808
+ this.pendingApprovalRequest = {
1809
+ requestId,
1810
+ method,
1811
+ threadId: trackedTurn.threadId,
1812
+ turnId: trackedTurn.turnId,
1813
+ origin: trackedTurn.origin,
1814
+ };
1815
+ this.pendingApproval = request;
1816
+ this.state.pendingApproval = request;
1817
+ this.state.pendingApprovalOrigin = trackedTurn.origin;
1818
+ this.state.lastOutputAt = nowIso();
1819
+ this.setStatus("awaiting_approval", "Codex approval is required.");
1820
+ this.emit({
1821
+ type: "approval_required",
1822
+ request,
1823
+ timestamp: nowIso(),
1824
+ });
1825
+ }
1826
+ handleThreadStatusChanged(params) {
1827
+ const threadId = extractCodexThreadFollowIdFromStatusChanged(params);
1828
+ if (!threadId) {
1829
+ return;
1830
+ }
1831
+ if (!this.activeTurn || this.activeTurn.threadId === threadId) {
1832
+ this.trackLocalSharedThread(threadId, {
1833
+ reason: "local_follow",
1834
+ signal: "status_changed",
1835
+ });
1836
+ this.pendingThreadFollowId = null;
1837
+ return;
1838
+ }
1839
+ this.pendingThreadFollowId = threadId;
1840
+ }
1841
+ handleThreadStarted(params) {
1842
+ const threadId = extractCodexThreadStartedThreadId(params);
1843
+ if (!threadId) {
1844
+ return;
1845
+ }
1846
+ if (this.isRecentlyBridgeOwnedThread(threadId)) {
1847
+ return;
1848
+ }
1849
+ const thread = isRecord(params.thread) ? params.thread : null;
1850
+ if (thread && typeof thread.cwd === "string") {
1851
+ if (normalizeComparablePath(thread.cwd) !== normalizeComparablePath(this.options.cwd)) {
1852
+ return;
1853
+ }
1854
+ }
1855
+ if (!this.activeTurn || this.activeTurn.threadId === threadId) {
1856
+ this.trackLocalSharedThread(threadId, {
1857
+ reason: "local_follow",
1858
+ signal: "thread_started",
1859
+ });
1860
+ this.pendingThreadFollowId = null;
1861
+ return;
1862
+ }
1863
+ this.pendingThreadFollowId = threadId;
1864
+ }
1865
+ handleTrackedTurnStarted(trackedTurn) {
1866
+ if (this.activeTurn?.turnId === trackedTurn.turnId) {
1867
+ return;
1868
+ }
1869
+ if (trackedTurn.origin === "local" &&
1870
+ trackedTurn.threadId !== this.sharedThreadId) {
1871
+ if (!this.activeTurn || this.activeTurn.threadId === trackedTurn.threadId) {
1872
+ this.trackLocalSharedThread(trackedTurn.threadId, {
1873
+ reason: "local_turn",
1874
+ signal: "turn_started",
1875
+ });
1876
+ this.pendingThreadFollowId = null;
1877
+ }
1878
+ else {
1879
+ this.pendingThreadFollowId = trackedTurn.threadId;
1880
+ }
1881
+ }
1882
+ if (!this.activeTurn) {
1883
+ this.setActiveTurn(trackedTurn);
1884
+ if (trackedTurn.origin === "local" && this.state.status !== "awaiting_approval") {
1885
+ this.setStatus("busy", "Codex is busy with a local terminal turn.");
1886
+ }
1887
+ return;
1888
+ }
1889
+ if (this.activeTurn.threadId !== trackedTurn.threadId) {
1890
+ this.pendingThreadFollowId = trackedTurn.threadId;
1891
+ }
1892
+ }
1893
+ maybeMirrorLocalUserInput(trackedTurn, item) {
1894
+ if (trackedTurn.origin !== "local" || this.mirroredUserInputTurnIds.has(trackedTurn.turnId)) {
1895
+ return;
1896
+ }
1897
+ const text = extractCodexUserMessageText(item);
1898
+ if (!text) {
1899
+ return;
1900
+ }
1901
+ this.trackLocalSharedThread(trackedTurn.threadId, {
1902
+ reason: "local_turn",
1903
+ signal: "user_message",
1904
+ });
1905
+ this.mirroredUserInputTurnIds.add(trackedTurn.turnId);
1906
+ this.emit({
1907
+ type: "mirrored_user_input",
1908
+ text,
1909
+ timestamp: nowIso(),
1910
+ origin: "local",
1911
+ });
1912
+ }
1913
+ handleTurnCompleted(trackedTurn, params) {
1914
+ this.clearFinalReplyCompletionTimerForTurn(trackedTurn.turnId);
1915
+ if (this.hasCompletedTurn(trackedTurn.turnId)) {
1916
+ if (this.activeTurn?.turnId === trackedTurn.turnId) {
1917
+ this.setActiveTurn(null);
1918
+ }
1919
+ this.cleanupTurnArtifacts(trackedTurn.turnId);
1920
+ return;
1921
+ }
1922
+ const turn = isRecord(params.turn) ? params.turn : null;
1923
+ const status = turn && typeof turn.status === "string" ? turn.status : "completed";
1924
+ const completedError = turn && isRecord(turn.error) && typeof turn.error.message === "string"
1925
+ ? turn.error.message
1926
+ : this.turnErrorById.get(trackedTurn.turnId) ?? null;
1927
+ const finalText = this.collectTurnOutput(trackedTurn.turnId);
1928
+ const completedTrackedTurn = this.activeTurn?.turnId === trackedTurn.turnId ? this.activeTurn : trackedTurn;
1929
+ const summary = status === "interrupted"
1930
+ ? "Interrupted"
1931
+ : completedTrackedTurn.origin === "local"
1932
+ ? "Local terminal turn completed."
1933
+ : this.currentPreview;
1934
+ if (this.pendingApprovalRequest &&
1935
+ this.pendingApprovalRequest.turnId === trackedTurn.turnId) {
1936
+ this.clearPendingApprovalState();
1937
+ }
1938
+ if (this.activeTurn?.turnId === trackedTurn.turnId) {
1939
+ this.setActiveTurn(null);
1940
+ }
1941
+ this.cleanupTurnArtifacts(trackedTurn.turnId);
1942
+ if (this.state.status !== "stopped" &&
1943
+ (!this.activeTurn || this.activeTurn.turnId === trackedTurn.turnId)) {
1944
+ const statusMessage = status === "interrupted" ? "Codex task interrupted." : undefined;
1945
+ this.setStatus("idle", statusMessage);
1946
+ }
1947
+ if (finalText) {
1948
+ this.emit({
1949
+ type: "final_reply",
1950
+ text: finalText,
1951
+ timestamp: nowIso(),
1952
+ });
1953
+ }
1954
+ else if (status === "failed") {
1955
+ const failureText = completedError
1956
+ ? `Codex could not complete the request: ${completedError}`
1957
+ : "Codex could not complete the request.";
1958
+ this.emit({
1959
+ type: "stdout",
1960
+ text: failureText,
1961
+ timestamp: nowIso(),
1962
+ });
1963
+ }
1964
+ this.emit({
1965
+ type: "task_complete",
1966
+ summary,
1967
+ timestamp: nowIso(),
1968
+ });
1969
+ this.rememberCompletedTurn(trackedTurn.turnId);
1970
+ }
1971
+ getTurnFinalMessageMap(turnId) {
1972
+ let finalMessages = this.turnFinalMessages.get(turnId);
1973
+ if (!finalMessages) {
1974
+ finalMessages = new Map();
1975
+ this.turnFinalMessages.set(turnId, finalMessages);
1976
+ }
1977
+ return finalMessages;
1978
+ }
1979
+ getTurnDeltaMap(turnId) {
1980
+ let deltaByItem = this.turnDeltaByItem.get(turnId);
1981
+ if (!deltaByItem) {
1982
+ deltaByItem = new Map();
1983
+ this.turnDeltaByItem.set(turnId, deltaByItem);
1984
+ }
1985
+ return deltaByItem;
1986
+ }
1987
+ collectTurnOutput(turnId) {
1988
+ const finalMessages = Array.from(this.getTurnFinalMessageMap(turnId).values())
1989
+ .map((text) => normalizeOutput(text).trim())
1990
+ .filter(Boolean);
1991
+ if (finalMessages.length > 0) {
1992
+ return finalMessages.join("\n\n");
1993
+ }
1994
+ const deltaFallback = Array.from(this.getTurnDeltaMap(turnId).values())
1995
+ .map((text) => normalizeOutput(text).trim())
1996
+ .filter(Boolean);
1997
+ if (deltaFallback.length === 0) {
1998
+ return null;
1999
+ }
2000
+ return deltaFallback[deltaFallback.length - 1] ?? null;
2001
+ }
2002
+ recordTurnActivity(turnId, timestamp = Date.now()) {
2003
+ const timestampMs = typeof timestamp === "number" ? timestamp : Date.parse(timestamp);
2004
+ this.turnLastActivityAtMs.set(turnId, Number.isFinite(timestampMs) ? timestampMs : Date.now());
2005
+ }
2006
+ clearFinalReplyCompletionTimer() {
2007
+ if (this.finalReplyCompletionTimer) {
2008
+ clearTimeout(this.finalReplyCompletionTimer);
2009
+ this.finalReplyCompletionTimer = null;
2010
+ }
2011
+ this.finalReplyCompletionTurnId = null;
2012
+ }
2013
+ clearFinalReplyCompletionTimerForTurn(turnId) {
2014
+ if (this.finalReplyCompletionTurnId !== turnId) {
2015
+ return;
2016
+ }
2017
+ this.clearFinalReplyCompletionTimer();
2018
+ }
2019
+ scheduleFinalReplyCompletionIfEligible(turnId) {
2020
+ if (!this.activeTurn ||
2021
+ this.activeTurn.turnId !== turnId ||
2022
+ this.activeTurn.origin !== "wechat" ||
2023
+ this.pendingTurnStart ||
2024
+ this.pendingApproval ||
2025
+ this.pendingApprovalRequest ||
2026
+ !this.collectTurnOutput(turnId)) {
2027
+ return;
2028
+ }
2029
+ this.clearFinalReplyCompletionTimer();
2030
+ this.finalReplyCompletionTurnId = turnId;
2031
+ this.finalReplyCompletionTimer = setTimeout(() => {
2032
+ this.autoCompleteWechatTurnAfterFinalReply(turnId);
2033
+ }, CODEX_FINAL_REPLY_SETTLE_DELAY_MS);
2034
+ this.finalReplyCompletionTimer.unref?.();
2035
+ }
2036
+ autoCompleteWechatTurnAfterFinalReply(turnId) {
2037
+ this.clearFinalReplyCompletionTimerForTurn(turnId);
2038
+ const activeTurn = this.activeTurn;
2039
+ const finalText = this.collectTurnOutput(turnId);
2040
+ const lastActivityAtMs = this.turnLastActivityAtMs.get(turnId) ?? null;
2041
+ const pendingApproval = Boolean(this.pendingApproval || this.pendingApprovalRequest);
2042
+ const nowMs = Date.now();
2043
+ if (!shouldAutoCompleteCodexWechatTurnAfterFinalReply({
2044
+ candidateTurnId: turnId,
2045
+ activeTurnId: activeTurn?.turnId,
2046
+ activeTurnOrigin: activeTurn?.origin,
2047
+ pendingTurnStart: this.pendingTurnStart,
2048
+ hasPendingApproval: pendingApproval,
2049
+ hasFinalOutput: Boolean(finalText),
2050
+ hasCompletedTurn: this.hasCompletedTurn(turnId),
2051
+ lastActivityAtMs,
2052
+ nowMs,
2053
+ settleDelayMs: CODEX_FINAL_REPLY_SETTLE_DELAY_MS,
2054
+ })) {
2055
+ if (activeTurn?.turnId === turnId &&
2056
+ activeTurn.origin === "wechat" &&
2057
+ !this.pendingTurnStart &&
2058
+ !pendingApproval &&
2059
+ finalText &&
2060
+ typeof lastActivityAtMs === "number") {
2061
+ const remainingMs = CODEX_FINAL_REPLY_SETTLE_DELAY_MS - (nowMs - lastActivityAtMs);
2062
+ if (remainingMs > 0) {
2063
+ this.finalReplyCompletionTurnId = turnId;
2064
+ this.finalReplyCompletionTimer = setTimeout(() => {
2065
+ this.autoCompleteWechatTurnAfterFinalReply(turnId);
2066
+ }, remainingMs);
2067
+ this.finalReplyCompletionTimer.unref?.();
2068
+ }
2069
+ }
2070
+ return;
2071
+ }
2072
+ if (!activeTurn || !finalText) {
2073
+ return;
2074
+ }
2075
+ this.clearPendingApprovalState();
2076
+ this.setActiveTurn(null);
2077
+ this.cleanupTurnArtifacts(turnId);
2078
+ this.state.lastOutputAt = nowIso();
2079
+ if (this.state.status !== "stopped") {
2080
+ this.setStatus("idle", "Recovered delayed Codex completion after final reply.");
2081
+ }
2082
+ this.emit({
2083
+ type: "final_reply",
2084
+ text: finalText,
2085
+ timestamp: nowIso(),
2086
+ });
2087
+ this.emit({
2088
+ type: "task_complete",
2089
+ summary: this.currentPreview,
2090
+ timestamp: nowIso(),
2091
+ });
2092
+ this.rememberCompletedTurn(turnId);
2093
+ }
2094
+ async stopAppServer() {
2095
+ if (!this.appServer) {
2096
+ this.appServerPort = null;
2097
+ this.appServerShuttingDown = false;
2098
+ this.deleteAppServerAuthTokenFile();
2099
+ this.appServerAuthToken = null;
2100
+ return;
2101
+ }
2102
+ const child = this.appServer;
2103
+ this.appServerShuttingDown = true;
2104
+ this.appServer = null;
2105
+ this.appServerPort = null;
2106
+ await new Promise((resolve) => {
2107
+ let settled = false;
2108
+ const finish = () => {
2109
+ if (settled) {
2110
+ return;
2111
+ }
2112
+ settled = true;
2113
+ resolve();
2114
+ };
2115
+ child.once("exit", () => finish());
2116
+ try {
2117
+ child.kill();
2118
+ }
2119
+ catch {
2120
+ finish();
2121
+ }
2122
+ const timer = setTimeout(() => finish(), 1_000);
2123
+ timer.unref?.();
2124
+ });
2125
+ this.deleteAppServerAuthTokenFile();
2126
+ this.appServerAuthToken = null;
2127
+ }
2128
+ describeAppServerLog() {
2129
+ const summary = normalizeOutput(this.appServerLog).trim();
2130
+ if (!summary) {
2131
+ return "";
2132
+ }
2133
+ return ` Recent app-server log: ${truncatePreview(summary, 220)}`;
2134
+ }
2135
+ terminateCodexClient() {
2136
+ this.shuttingDown = true;
2137
+ if (this.pty) {
2138
+ try {
2139
+ this.pty.kill();
2140
+ }
2141
+ catch {
2142
+ // Best effort cleanup after embedded client failure.
2143
+ }
2144
+ return;
2145
+ }
2146
+ if (this.nativeProcess) {
2147
+ try {
2148
+ this.nativeProcess.kill();
2149
+ }
2150
+ catch {
2151
+ // Best effort cleanup after panel client failure.
2152
+ }
2153
+ }
2154
+ }
2155
+ deleteAppServerAuthTokenFile() {
2156
+ if (!this.appServerAuthTokenFilePath) {
2157
+ return;
2158
+ }
2159
+ try {
2160
+ fs.unlinkSync(this.appServerAuthTokenFilePath);
2161
+ }
2162
+ catch {
2163
+ // Best effort cleanup after app-server shutdown.
2164
+ }
2165
+ this.appServerAuthTokenFilePath = null;
2166
+ }
2167
+ attachLocalInputForwarding() {
2168
+ if (this.localInputListener || !process.stdin.readable) {
2169
+ return;
2170
+ }
2171
+ process.stdin.setEncoding("utf8");
2172
+ process.stdin.resume();
2173
+ this.localInputListener = (chunk) => {
2174
+ const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
2175
+ if (!text) {
2176
+ return;
2177
+ }
2178
+ this.writeToPty(text);
2179
+ };
2180
+ process.stdin.on("data", this.localInputListener);
2181
+ }
2182
+ detachLocalInputForwarding() {
2183
+ if (!this.localInputListener) {
2184
+ return;
2185
+ }
2186
+ process.stdin.off("data", this.localInputListener);
2187
+ this.localInputListener = null;
2188
+ if (process.stdin.isTTY) {
2189
+ process.stdin.pause();
2190
+ }
2191
+ }
2192
+ renderLocalOutput(rawText) {
2193
+ try {
2194
+ process.stdout.write(rawText);
2195
+ }
2196
+ catch {
2197
+ // Best effort local mirroring for the visible Codex panel.
2198
+ }
2199
+ }
2200
+ hasCompletedTurn(turnId) {
2201
+ return this.completedTurnIds.has(turnId);
2202
+ }
2203
+ rememberCompletedTurn(turnId) {
2204
+ if (this.completedTurnIds.has(turnId)) {
2205
+ return;
2206
+ }
2207
+ this.completedTurnIds.add(turnId);
2208
+ this.completedTurnOrder.push(turnId);
2209
+ while (this.completedTurnOrder.length > CODEX_RECENT_SESSION_KEY_LIMIT) {
2210
+ const staleTurnId = this.completedTurnOrder.shift();
2211
+ if (staleTurnId) {
2212
+ this.completedTurnIds.delete(staleTurnId);
2213
+ }
2214
+ }
2215
+ }
2216
+ }
2217
+ export function shouldTreatCodexNativeExitAsExpected(params) {
2218
+ return (params.shuttingDown ||
2219
+ (params.renderMode === "panel" &&
2220
+ !params.startupError &&
2221
+ !params.signal &&
2222
+ params.exitCode === 0));
2223
+ }
2224
+ export function shouldSuppressCodexTransportFatalError(params) {
2225
+ return (params.transportShuttingDown ||
2226
+ params.shuttingDown ||
2227
+ params.cleanPanelExitInProgress);
2228
+ }