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