@unlinearity/cli-wechat-bridge 1.1.5 → 1.2.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 +661 -661
- package/README.md +434 -427
- package/bin/_run-entry.mjs +47 -9
- package/bin/wechat-check-update.mjs +6 -1
- package/bin/wechat-claude.mjs +6 -1
- package/bin/wechat-codex.mjs +6 -1
- package/bin/wechat-daemon.mjs +6 -1
- package/bin/wechat-opencode.mjs +6 -1
- package/bin/wechat-pi.mjs +6 -1
- package/bin/wechat-setup.mjs +6 -1
- package/bin/wecom-check-update.mjs +10 -0
- package/bin/wecom-claude.mjs +10 -0
- package/bin/wecom-codex.mjs +10 -0
- package/bin/wecom-daemon.mjs +10 -0
- package/bin/wecom-opencode.mjs +10 -0
- package/bin/wecom-pi.mjs +10 -0
- package/bin/wecom-setup.mjs +10 -0
- package/dist/bridge/adapter-control.js +58 -0
- package/dist/bridge/bridge-adapters.claude.js +163 -5
- package/dist/bridge/bridge-adapters.codex.js +331 -29
- package/dist/bridge/bridge-adapters.core.js +27 -2
- package/dist/bridge/bridge-adapters.opencode.js +156 -1
- package/dist/bridge/bridge-adapters.pi.js +46 -1
- package/dist/bridge/bridge-adapters.shared.js +8 -1
- package/dist/bridge/bridge-controller.js +6 -0
- package/dist/bridge/bridge-final-reply.js +8 -65
- package/dist/bridge/bridge-process-reaper.js +41 -2
- package/dist/bridge/bridge-state.js +17 -4
- package/dist/bridge/bridge-utils.js +10 -127
- package/dist/bridge/claude-hook.js +6 -0
- package/dist/bridge/claude-native-controls.js +133 -0
- package/dist/bridge/native-terminal-control.js +259 -0
- package/dist/bridge/wechat-bridge.js +520 -274
- package/dist/bridge/wechat-forwarding.js +8 -58
- package/dist/bridge/wechat-outbound-queue.js +8 -0
- package/dist/channels/wechat/bridge-final-reply.js +56 -0
- package/dist/channels/wechat/channel-message.js +24 -0
- package/dist/channels/wechat/index.js +11 -0
- package/dist/channels/wechat/wechat-channel-port.js +67 -0
- package/dist/channels/wechat/wechat-driver.js +191 -0
- package/dist/channels/wechat/wechat-forwarding.js +97 -0
- package/dist/channels/wechat/wechat-outbound-queue.js +118 -0
- package/dist/channels/wecom/bridge-final-reply.js +32 -0
- package/dist/channels/wecom/setup-cli.js +12 -0
- package/dist/channels/wecom/setup.js +182 -0
- package/dist/channels/wecom/wecom-channel-port.js +45 -0
- package/dist/channels/wecom/wecom-config.js +72 -0
- package/dist/channels/wecom/wecom-driver.js +106 -0
- package/dist/channels/wecom/wecom-media.js +147 -0
- package/dist/channels/wecom/wecom-message.js +149 -0
- package/dist/channels/wecom/wecom-transport.js +314 -0
- package/dist/commands/check-update.js +6 -0
- package/dist/companion/codex-remote-client.js +30 -3
- package/dist/companion/codex-visible-client-link.js +107 -0
- package/dist/companion/local-companion-link.js +6 -0
- package/dist/companion/local-companion-start.js +62 -11
- package/dist/companion/local-companion.js +25 -1
- package/dist/companion/opencode-tui-bridge-plugin.js +6 -0
- package/dist/companion/pi-tui-bridge-extension.js +62 -3
- package/dist/core/bridge-control.js +42 -0
- package/dist/core/bridge-defer.js +25 -0
- package/dist/core/bridge-event-forwarder.js +50 -0
- package/dist/core/bridge-message-router.js +32 -0
- package/dist/core/channel-driver.js +1 -0
- package/dist/core/channel-types.js +3 -0
- package/dist/core/conversation-routing.js +58 -0
- package/dist/core/direct-run.js +23 -0
- package/dist/core/inbound-message-claims.js +67 -0
- package/dist/core/outbound-target.js +32 -0
- package/dist/core/text-utils.js +69 -0
- package/dist/core/turn-coordinator.js +85 -0
- package/dist/daemon/daemon-link.js +9 -0
- package/dist/daemon/emoji-bindings.js +6 -0
- package/dist/daemon/wechat-daemon.js +827 -209
- package/dist/i18n/index.js +6 -0
- package/dist/i18n/messages-en.js +11 -3
- package/dist/i18n/messages-zh.js +11 -3
- package/dist/runtime/create-runtime-host.js +6 -0
- package/dist/runtime/legacy-adapter-runtime.js +15 -0
- package/dist/runtime/runtime-types.js +3 -0
- package/dist/utils/atomic-file.js +6 -0
- package/dist/utils/doctor.js +36 -10
- package/dist/utils/version-checker.js +17 -11
- package/dist/wechat/channel-config.js +6 -0
- package/dist/wechat/setup.js +119 -13
- package/dist/wechat/wechat-channel.js +6 -0
- package/dist/wechat/wechat-transport.js +169 -58
- package/package.json +14 -4
- package/scripts/ensure-node-pty-permissions.mjs +73 -73
|
@@ -1,17 +1,39 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
|
|
3
|
+
// Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
|
|
4
|
+
// This file is part of CLI-WeChat-Bridge. Modifications and derivative works
|
|
5
|
+
// must be released under AGPL-3.0-or-later with full source code; see
|
|
6
|
+
// LICENSE.txt. Network services built on it must offer source to users.
|
|
1
7
|
import fs from "node:fs";
|
|
2
8
|
import path from "node:path";
|
|
3
9
|
import crypto from "node:crypto";
|
|
4
|
-
import { spawn as spawnChild } from "node:child_process";
|
|
5
|
-
import { detectCliApproval,
|
|
10
|
+
import { spawn as spawnChild, spawnSync } from "node:child_process";
|
|
11
|
+
import { detectCliApproval, } from "./bridge-utils.js";
|
|
12
|
+
import { normalizeOutput, nowIso, summarizeOutput, truncatePreview, } from "../core/text-utils.js";
|
|
6
13
|
import { AbstractPtyAdapter } from "./bridge-adapters.core.js";
|
|
7
14
|
import { killProcessTreeSync } from "./bridge-process-reaper.js";
|
|
8
15
|
import * as shared from "./bridge-adapters.shared.js";
|
|
9
|
-
import { requestCodexVisibleThreadSwitch } from "../companion/codex-visible-client-link.js";
|
|
16
|
+
import { requestCodexVisibleClientShutdown, requestCodexVisibleThreadSwitch, } from "../companion/codex-visible-client-link.js";
|
|
17
|
+
import { readLocalCompanionEndpoint } from "../companion/local-companion-link.js";
|
|
10
18
|
import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
|
|
11
19
|
import { CODEX_REMOTE_AUTH_TOKEN_ENV, LOCAL_CLIENT_PROTOCOL_VERSION, } from "../runtime/runtime-types.js";
|
|
20
|
+
export function parseCodexCliVersion(output) {
|
|
21
|
+
const match = output.match(/\b(\d+\.\d+\.\d+)\b/);
|
|
22
|
+
return match?.[1] ?? null;
|
|
23
|
+
}
|
|
24
|
+
export function isCodexVersionInCompatibilityRange(version) {
|
|
25
|
+
const parts = version.split(".").map((part) => Number(part));
|
|
26
|
+
if (parts.length !== 3 || parts.some((part) => !Number.isInteger(part))) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const major = parts[0] ?? -1;
|
|
30
|
+
const minor = parts[1] ?? -1;
|
|
31
|
+
return major === 0 && minor >= 149 && minor <= 155;
|
|
32
|
+
}
|
|
12
33
|
const { CODEX_APP_SERVER_HOST, CODEX_APP_SERVER_READY_TIMEOUT_MS, CODEX_FINAL_REPLY_SETTLE_DELAY_MS, CODEX_RECENT_SESSION_KEY_LIMIT, CODEX_RPC_CONNECT_RETRY_MS, CODEX_RPC_RECONNECT_TIMEOUT_MS, CODEX_SESSION_FALLBACK_SCAN_INTERVAL_MS, CODEX_SESSION_LOCAL_MIRROR_FALLBACK_WINDOW_MS, CODEX_SESSION_POLL_INTERVAL_MS, CODEX_STARTUP_WARMUP_MS, CODEX_THREAD_SIGNAL_TTL_MS, INTERRUPT_SETTLE_DELAY_MS, appendBoundedLog, buildCodexApprovalRequest, buildCodexCliArgs, buildCodexDynamicToolCallFailureResponse, buildCodexMcpServerElicitationDeclineResponse, buildCodexPermissionsRequestApprovalResponse, buildCodexUserInputRequest, coerceWebSocketMessageData, delay, describeUnknownError, extractCodexFinalTextFromItem, extractCodexThreadFollowIdFromStatusChanged, extractCodexThreadStartedThreadId, extractCodexUserMessageText, findCodexSessionFile, findRecentCodexSessionFileForCwd, getCodexRpcRequestId, getCodexApprovalAutoResponse, getCodexWechatOutboundAttachmentDenyMessage, getNotificationThreadId, getNotificationTurnId, isRecord, isRecentIsoTimestamp, listCodexResumeSessions, normalizeComparablePath, normalizeCodexRpcError, reserveLocalPort, resolveSpawnTarget, shouldAutoCompleteCodexWechatTurnAfterFinalReply, shouldIgnoreCodexSessionReplayEntry, shouldRecoverCodexStaleBusyState, waitForTcpPort, } = shared;
|
|
13
34
|
const CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS = 150;
|
|
14
35
|
const CODEX_NON_BLOCKING_USER_INPUT_TIMEOUT_MS = 120_000;
|
|
36
|
+
const CODEX_RESUME_REPLAY_SETTLE_MS = 5_000;
|
|
15
37
|
function getCodexThreadStatusType(value) {
|
|
16
38
|
if (typeof value === "string") {
|
|
17
39
|
return value;
|
|
@@ -63,14 +85,20 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
63
85
|
appServerAuthToken = null;
|
|
64
86
|
appServerAuthTokenFilePath = null;
|
|
65
87
|
rpcSocket = null;
|
|
88
|
+
lastRpcCloseDetail = "";
|
|
66
89
|
rpcShuttingDown = false;
|
|
67
90
|
rpcReconnectPromise = null;
|
|
68
91
|
cleanPanelExitInProgress = false;
|
|
69
92
|
rpcRequestCounter = 0;
|
|
70
93
|
pendingRpcRequests = new Map();
|
|
71
94
|
subscribedThreadIds = new Set();
|
|
95
|
+
pendingThreadSubscriptions = new Map();
|
|
72
96
|
sharedThreadId = null;
|
|
97
|
+
previousCollaborationMode = null;
|
|
73
98
|
announcedThreadId = null;
|
|
99
|
+
localThreadFollowBlockedUntilMs = 0;
|
|
100
|
+
pendingThreadStatusChecks = new Map();
|
|
101
|
+
threadStatusCheckEpoch = 0;
|
|
74
102
|
pendingThreadAnnouncement = null;
|
|
75
103
|
activeTurn = null;
|
|
76
104
|
bridgeOwnedTurnIds = new Set();
|
|
@@ -80,6 +108,8 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
80
108
|
interruptPendingTurnStart = false;
|
|
81
109
|
pendingThreadFollowId = null;
|
|
82
110
|
pendingVisibleResume = null;
|
|
111
|
+
bridgeResumeReplayThreadId = null;
|
|
112
|
+
bridgeResumeReplayUntilMs = 0;
|
|
83
113
|
pendingApprovalRequests = [];
|
|
84
114
|
pendingUserInputRequest = null;
|
|
85
115
|
pendingUserInputTimer = null;
|
|
@@ -108,6 +138,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
108
138
|
finalReplyCompletionTimer = null;
|
|
109
139
|
finalReplyCompletionTurnId = null;
|
|
110
140
|
resumeThreadId;
|
|
141
|
+
codexCliVersion = null;
|
|
111
142
|
localClientInstanceId = `${process.pid}-${Date.now().toString(36)}`;
|
|
112
143
|
constructor(options) {
|
|
113
144
|
super(options);
|
|
@@ -126,6 +157,13 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
126
157
|
if (this.isHeadlessRuntimeMode()) {
|
|
127
158
|
this.setStatus("starting", `Starting ${this.options.kind} runtime host...`);
|
|
128
159
|
}
|
|
160
|
+
this.codexCliVersion = this.detectCodexCliVersion();
|
|
161
|
+
const compatibility = this.codexCliVersion
|
|
162
|
+
? isCodexVersionInCompatibilityRange(this.codexCliVersion)
|
|
163
|
+
? `compatible=${this.codexCliVersion}`
|
|
164
|
+
: `outside-supported-range=${this.codexCliVersion}`
|
|
165
|
+
: "unknown-version";
|
|
166
|
+
this.setStatus("starting", `Codex protocol compatibility: ${compatibility}.`);
|
|
129
167
|
await this.startAppServer();
|
|
130
168
|
await this.connectRpcClient();
|
|
131
169
|
await this.restoreInitialSharedThreadIfNeeded();
|
|
@@ -153,6 +191,13 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
153
191
|
throw err;
|
|
154
192
|
}
|
|
155
193
|
}
|
|
194
|
+
async prepareVisibleClientSession() {
|
|
195
|
+
if (this.isNativePanelMode() || this.sharedThreadId) {
|
|
196
|
+
return Boolean(this.sharedThreadId);
|
|
197
|
+
}
|
|
198
|
+
const threadId = await this.ensureThreadStarted();
|
|
199
|
+
return Boolean(threadId);
|
|
200
|
+
}
|
|
156
201
|
buildSpawnArgs() {
|
|
157
202
|
if (!this.appServerPort) {
|
|
158
203
|
throw new Error("Codex app-server is not ready.");
|
|
@@ -219,6 +264,74 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
219
264
|
}
|
|
220
265
|
await this.resumeVisibleSharedThread(threadId);
|
|
221
266
|
}
|
|
267
|
+
async listModels() {
|
|
268
|
+
if (!this.usesRpcTurnTransport() || !this.sharedThreadId) {
|
|
269
|
+
throw new Error("Codex model selection requires an active app-server thread.");
|
|
270
|
+
}
|
|
271
|
+
const response = await this.sendRpcRequest("model/list", { includeHidden: false });
|
|
272
|
+
const data = isRecord(response) && Array.isArray(response.data) ? response.data : [];
|
|
273
|
+
const current = await this.readCodexThreadSettings();
|
|
274
|
+
const models = data.flatMap((item) => {
|
|
275
|
+
if (!isRecord(item))
|
|
276
|
+
return [];
|
|
277
|
+
const id = typeof item.id === "string" ? item.id : typeof item.model === "string" ? item.model : "";
|
|
278
|
+
if (!id)
|
|
279
|
+
return [];
|
|
280
|
+
const displayName = typeof item.displayName === "string" ? item.displayName : id;
|
|
281
|
+
const efforts = Array.isArray(item.supportedReasoningEfforts)
|
|
282
|
+
? item.supportedReasoningEfforts.filter((value) => typeof value === "string")
|
|
283
|
+
: undefined;
|
|
284
|
+
return [{ id, displayName, isCurrent: id === current.model, supportedReasoningEfforts: efforts }];
|
|
285
|
+
});
|
|
286
|
+
return models;
|
|
287
|
+
}
|
|
288
|
+
async selectModel(modelId) {
|
|
289
|
+
const models = await this.listModels();
|
|
290
|
+
const selected = models.find((model) => model.id === modelId);
|
|
291
|
+
if (!selected)
|
|
292
|
+
throw new Error(`Codex model ${modelId} is not available.`);
|
|
293
|
+
await this.sendRpcRequest("thread/settings/update", { threadId: this.sharedThreadId, model: selected.id });
|
|
294
|
+
return selected;
|
|
295
|
+
}
|
|
296
|
+
async setPlanMode(enabled) {
|
|
297
|
+
if (!this.usesRpcTurnTransport() || !this.sharedThreadId) {
|
|
298
|
+
throw new Error("Codex plan mode requires an active app-server thread.");
|
|
299
|
+
}
|
|
300
|
+
const settings = await this.readCodexThreadSettings();
|
|
301
|
+
if (enabled) {
|
|
302
|
+
const response = await this.sendRpcRequest("collaborationMode/list", {});
|
|
303
|
+
const modes = isRecord(response) && Array.isArray(response.data) ? response.data : [];
|
|
304
|
+
const plan = modes.find((item) => isRecord(item) && (item.mode === "plan" || item.name?.toString().toLowerCase() === "plan"));
|
|
305
|
+
if (!isRecord(plan))
|
|
306
|
+
throw new Error("Codex plan mode is not available in this app-server.");
|
|
307
|
+
this.previousCollaborationMode = isRecord(settings.collaborationMode) ? settings.collaborationMode : null;
|
|
308
|
+
const mode = typeof plan.mode === "string" ? plan.mode : "plan";
|
|
309
|
+
const model = typeof plan.model === "string" ? plan.model : settings.model;
|
|
310
|
+
const effort = plan.reasoning_effort ?? plan.reasoningEffort ?? null;
|
|
311
|
+
await this.sendRpcRequest("thread/settings/update", {
|
|
312
|
+
threadId: this.sharedThreadId,
|
|
313
|
+
collaborationMode: { mode, settings: { model, reasoning_effort: effort, developer_instructions: null } },
|
|
314
|
+
});
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
const previous = this.previousCollaborationMode ?? {
|
|
318
|
+
mode: "default",
|
|
319
|
+
settings: { model: settings.model, reasoning_effort: settings.effort ?? null, developer_instructions: null },
|
|
320
|
+
};
|
|
321
|
+
await this.sendRpcRequest("thread/settings/update", {
|
|
322
|
+
threadId: this.sharedThreadId,
|
|
323
|
+
collaborationMode: previous,
|
|
324
|
+
});
|
|
325
|
+
this.previousCollaborationMode = null;
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
async readCodexThreadSettings() {
|
|
329
|
+
const response = await this.sendRpcRequest("thread/read", { threadId: this.sharedThreadId, includeTurns: false });
|
|
330
|
+
const thread = isRecord(response) && isRecord(response.thread) ? response.thread : null;
|
|
331
|
+
if (!thread || typeof thread.model !== "string")
|
|
332
|
+
throw new Error("Codex did not return the current thread model.");
|
|
333
|
+
return { model: thread.model, effort: thread.reasoningEffort, collaborationMode: thread.collaborationMode };
|
|
334
|
+
}
|
|
222
335
|
async interrupt() {
|
|
223
336
|
if (this.usesRpcTurnTransport()) {
|
|
224
337
|
return await this.interruptPanelTurn();
|
|
@@ -286,6 +399,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
286
399
|
return true;
|
|
287
400
|
}
|
|
288
401
|
async dispose() {
|
|
402
|
+
await this.shutdownVisibleClient();
|
|
289
403
|
this.resetTurnTracking({ preserveThread: false });
|
|
290
404
|
if (this.isEmbeddedCliMode()) {
|
|
291
405
|
this.detachLocalInputForwarding();
|
|
@@ -317,6 +431,25 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
317
431
|
}
|
|
318
432
|
await this.stopAppServer();
|
|
319
433
|
}
|
|
434
|
+
async shutdownVisibleClient() {
|
|
435
|
+
const endpoint = readLocalCompanionEndpoint(this.options.cwd, { adapter: "codex" });
|
|
436
|
+
if (!endpoint?.companionPid ||
|
|
437
|
+
endpoint.instanceId !== this.localClientInstanceId ||
|
|
438
|
+
!endpoint.codexControlPort ||
|
|
439
|
+
!endpoint.codexControlToken) {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
await requestCodexVisibleClientShutdown({
|
|
444
|
+
cwd: this.options.cwd,
|
|
445
|
+
instanceId: endpoint.instanceId,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
// The visible client may already be exiting. Its endpoint is cleared
|
|
450
|
+
// by the supervisor when the child process finishes.
|
|
451
|
+
}
|
|
452
|
+
}
|
|
320
453
|
getLocalClientEndpoint() {
|
|
321
454
|
if (!this.isHeadlessRuntimeMode() || !this.appServerPort || !this.appServerAuthToken) {
|
|
322
455
|
return null;
|
|
@@ -406,6 +539,24 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
406
539
|
}
|
|
407
540
|
return this.isNativePanelMode() ? Boolean(this.nativeProcess) : Boolean(this.pty);
|
|
408
541
|
}
|
|
542
|
+
detectCodexCliVersion() {
|
|
543
|
+
try {
|
|
544
|
+
const target = resolveSpawnTarget(this.options.command, "codex", {
|
|
545
|
+
forwardArgs: ["--version"],
|
|
546
|
+
});
|
|
547
|
+
const result = spawnSync(target.file, target.args, {
|
|
548
|
+
cwd: this.options.cwd,
|
|
549
|
+
env: this.buildEnv(),
|
|
550
|
+
encoding: "utf8",
|
|
551
|
+
timeout: 5_000,
|
|
552
|
+
windowsHide: true,
|
|
553
|
+
});
|
|
554
|
+
return parseCodexCliVersion(`${result.stdout ?? ""}\n${result.stderr ?? ""}`);
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
409
560
|
shouldPollSessionLog() {
|
|
410
561
|
return (this.isCodexClientRunning() ||
|
|
411
562
|
this.pendingTurnStart ||
|
|
@@ -1145,8 +1296,8 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1145
1296
|
socket.addEventListener("message", (event) => {
|
|
1146
1297
|
this.handleRpcMessageData(event.data);
|
|
1147
1298
|
});
|
|
1148
|
-
socket.addEventListener("close", () => {
|
|
1149
|
-
this.handleRpcSocketClosed();
|
|
1299
|
+
socket.addEventListener("close", (event) => {
|
|
1300
|
+
this.handleRpcSocketClosed(event.code, event.reason);
|
|
1150
1301
|
});
|
|
1151
1302
|
}
|
|
1152
1303
|
async disconnectRpcClient() {
|
|
@@ -1184,13 +1335,14 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1184
1335
|
});
|
|
1185
1336
|
this.rpcShuttingDown = false;
|
|
1186
1337
|
}
|
|
1187
|
-
handleRpcSocketClosed() {
|
|
1338
|
+
handleRpcSocketClosed(code, reason) {
|
|
1188
1339
|
const expectedShutdown = shouldSuppressCodexTransportFatalError({
|
|
1189
1340
|
transportShuttingDown: this.rpcShuttingDown,
|
|
1190
1341
|
shuttingDown: this.shuttingDown,
|
|
1191
1342
|
cleanPanelExitInProgress: this.cleanPanelExitInProgress,
|
|
1192
1343
|
});
|
|
1193
1344
|
this.rpcSocket = null;
|
|
1345
|
+
this.lastRpcCloseDetail = `close_code=${code ?? "unknown"}${reason ? ` reason=${truncatePreview(reason, 240)}` : ""}`;
|
|
1194
1346
|
this.subscribedThreadIds.clear();
|
|
1195
1347
|
this.rejectPendingRpcRequests("Codex websocket connection closed.");
|
|
1196
1348
|
this.rpcShuttingDown = false;
|
|
@@ -1222,7 +1374,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1222
1374
|
const details = this.describeAppServerLog();
|
|
1223
1375
|
this.emit({
|
|
1224
1376
|
type: "fatal_error",
|
|
1225
|
-
message: `codex app-server websocket closed unexpectedly.${details}`,
|
|
1377
|
+
message: `codex app-server websocket closed unexpectedly (${this.lastRpcCloseDetail || "no close details"}).${details}`,
|
|
1226
1378
|
timestamp: nowIso(),
|
|
1227
1379
|
});
|
|
1228
1380
|
this.terminateCodexClient();
|
|
@@ -1260,7 +1412,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1260
1412
|
}
|
|
1261
1413
|
this.emit({
|
|
1262
1414
|
type: "fatal_error",
|
|
1263
|
-
message: `codex app-server websocket closed unexpectedly and could not reconnect: ${lastError}.${details}`,
|
|
1415
|
+
message: `codex app-server websocket closed unexpectedly and could not reconnect (${this.lastRpcCloseDetail || "no close details"}): ${lastError}.${details}`,
|
|
1264
1416
|
timestamp: nowIso(),
|
|
1265
1417
|
});
|
|
1266
1418
|
this.terminateCodexClient();
|
|
@@ -1335,13 +1487,18 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1335
1487
|
if (this.subscribedThreadIds.has(threadId)) {
|
|
1336
1488
|
return true;
|
|
1337
1489
|
}
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
return
|
|
1341
|
-
}
|
|
1342
|
-
catch {
|
|
1343
|
-
return false;
|
|
1490
|
+
const pending = this.pendingThreadSubscriptions.get(threadId);
|
|
1491
|
+
if (pending) {
|
|
1492
|
+
return await pending;
|
|
1344
1493
|
}
|
|
1494
|
+
const attempt = this.ensureSharedThreadSubscribed(threadId)
|
|
1495
|
+
.then(() => true)
|
|
1496
|
+
.catch(() => false)
|
|
1497
|
+
.finally(() => {
|
|
1498
|
+
this.pendingThreadSubscriptions.delete(threadId);
|
|
1499
|
+
});
|
|
1500
|
+
this.pendingThreadSubscriptions.set(threadId, attempt);
|
|
1501
|
+
return await attempt;
|
|
1345
1502
|
}
|
|
1346
1503
|
async ensureSharedThreadSubscribed(threadId) {
|
|
1347
1504
|
if (this.subscribedThreadIds.has(threadId)) {
|
|
@@ -1359,11 +1516,12 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1359
1516
|
if (!resumedThreadId) {
|
|
1360
1517
|
throw new Error("Codex did not return a thread id while subscribing the bridge client.");
|
|
1361
1518
|
}
|
|
1519
|
+
if (resumedThreadId !== threadId) {
|
|
1520
|
+
throw new Error(`Codex opened ${resumedThreadId}, expected ${threadId}.`);
|
|
1521
|
+
}
|
|
1522
|
+
this.validateCodexThreadWorkspaceIfPresent(response, threadId);
|
|
1362
1523
|
this.rememberBridgeOwnedThreadSignal(resumedThreadId);
|
|
1363
1524
|
this.subscribedThreadIds.add(resumedThreadId);
|
|
1364
|
-
if (resumedThreadId !== this.sharedThreadId) {
|
|
1365
|
-
this.updateSharedThread(resumedThreadId);
|
|
1366
|
-
}
|
|
1367
1525
|
}
|
|
1368
1526
|
async listAppServerResumeSessions(limit) {
|
|
1369
1527
|
const boundedLimit = Math.max(1, Math.min(100, limit));
|
|
@@ -1407,6 +1565,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1407
1565
|
if (typeof thread.parentThreadId === "string") {
|
|
1408
1566
|
throw new Error(`Codex thread ${targetThreadId} is a subagent thread and cannot be resumed.`);
|
|
1409
1567
|
}
|
|
1568
|
+
if (thread.canAcceptDirectInput === false) {
|
|
1569
|
+
throw new Error(`Codex thread ${targetThreadId} is owned by a parent agent and cannot accept direct input.`);
|
|
1570
|
+
}
|
|
1410
1571
|
if (thread.ephemeral === true) {
|
|
1411
1572
|
throw new Error(`Codex thread ${targetThreadId} is ephemeral and cannot be resumed.`);
|
|
1412
1573
|
}
|
|
@@ -1415,6 +1576,28 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1415
1576
|
}
|
|
1416
1577
|
return thread;
|
|
1417
1578
|
}
|
|
1579
|
+
validateCodexThreadWorkspaceIfPresent(response, targetThreadId) {
|
|
1580
|
+
if (!isRecord(response) || !isRecord(response.thread)) {
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
const thread = response.thread;
|
|
1584
|
+
if (typeof thread.id === "string" && thread.id !== targetThreadId) {
|
|
1585
|
+
throw new Error(`Codex opened ${thread.id}, expected ${targetThreadId}.`);
|
|
1586
|
+
}
|
|
1587
|
+
if (typeof thread.cwd === "string" &&
|
|
1588
|
+
normalizeComparablePath(thread.cwd) !== normalizeComparablePath(this.options.cwd)) {
|
|
1589
|
+
throw new Error(`Codex thread ${targetThreadId} does not belong to ${this.options.cwd}.`);
|
|
1590
|
+
}
|
|
1591
|
+
if (typeof thread.parentThreadId === "string") {
|
|
1592
|
+
throw new Error(`Codex thread ${targetThreadId} is a subagent thread.`);
|
|
1593
|
+
}
|
|
1594
|
+
if (thread.canAcceptDirectInput === false) {
|
|
1595
|
+
throw new Error(`Codex thread ${targetThreadId} is owned by a parent agent.`);
|
|
1596
|
+
}
|
|
1597
|
+
if (thread.ephemeral === true) {
|
|
1598
|
+
throw new Error(`Codex thread ${targetThreadId} is ephemeral.`);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1418
1601
|
async resumeVisibleSharedThread(threadId) {
|
|
1419
1602
|
const targetThreadId = threadId.trim();
|
|
1420
1603
|
if (!targetThreadId) {
|
|
@@ -1439,17 +1622,19 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1439
1622
|
if (this.state.status !== "idle") {
|
|
1440
1623
|
throw new Error(`Codex cannot switch threads while its status is ${this.state.status}.`);
|
|
1441
1624
|
}
|
|
1442
|
-
const readResponse = await this.sendRpcRequest("thread/read", {
|
|
1443
|
-
threadId: targetThreadId,
|
|
1444
|
-
includeTurns: false,
|
|
1445
|
-
});
|
|
1446
|
-
this.validateCodexResumeThread(readResponse, targetThreadId);
|
|
1447
1625
|
const previousThreadId = this.sharedThreadId;
|
|
1448
1626
|
let targetSubscribed = false;
|
|
1449
1627
|
this.pendingVisibleResume = { targetThreadId };
|
|
1450
|
-
this.setStatus("starting", `
|
|
1628
|
+
this.setStatus("starting", `Codex resume validating thread ${targetThreadId.slice(0, 12)}...`);
|
|
1451
1629
|
try {
|
|
1630
|
+
const readResponse = await this.sendRpcRequest("thread/read", {
|
|
1631
|
+
threadId: targetThreadId,
|
|
1632
|
+
includeTurns: false,
|
|
1633
|
+
});
|
|
1634
|
+
this.validateCodexResumeThread(readResponse, targetThreadId);
|
|
1635
|
+
this.setStatus("starting", `Codex resume validated thread ${targetThreadId.slice(0, 12)}.`);
|
|
1452
1636
|
this.rememberBridgeOwnedThreadSignal(targetThreadId);
|
|
1637
|
+
this.setStatus("starting", `Codex resume requesting thread ${targetThreadId.slice(0, 12)}.`);
|
|
1453
1638
|
const resumeResponse = await this.sendRpcRequest("thread/resume", {
|
|
1454
1639
|
threadId: targetThreadId,
|
|
1455
1640
|
approvalPolicy: "on-request",
|
|
@@ -1460,6 +1645,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1460
1645
|
targetSubscribed = true;
|
|
1461
1646
|
this.validateCodexResumeThread(resumeResponse, targetThreadId);
|
|
1462
1647
|
this.subscribedThreadIds.add(targetThreadId);
|
|
1648
|
+
this.setStatus("starting", `Codex resume switching visible thread ${targetThreadId.slice(0, 12)}.`);
|
|
1463
1649
|
const visibleThreadId = await this.switchVisibleCodexThread(targetThreadId);
|
|
1464
1650
|
if (visibleThreadId !== targetThreadId) {
|
|
1465
1651
|
throw new Error(`The visible Codex client opened ${visibleThreadId}, expected ${targetThreadId}.`);
|
|
@@ -1474,6 +1660,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1474
1660
|
reason: "wechat_resume",
|
|
1475
1661
|
notify: true,
|
|
1476
1662
|
});
|
|
1663
|
+
this.bridgeResumeReplayThreadId = targetThreadId;
|
|
1664
|
+
this.bridgeResumeReplayUntilMs = Date.now() + CODEX_RESUME_REPLAY_SETTLE_MS;
|
|
1665
|
+
this.setStatus("starting", `Codex resume committed thread ${targetThreadId.slice(0, 12)}.`);
|
|
1477
1666
|
this.setStatus("idle");
|
|
1478
1667
|
if (previousThreadId && previousThreadId !== targetThreadId) {
|
|
1479
1668
|
await this.unsubscribeCodexThreadBestEffort(previousThreadId);
|
|
@@ -1483,7 +1672,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1483
1672
|
if (targetSubscribed && targetThreadId !== previousThreadId) {
|
|
1484
1673
|
await this.unsubscribeCodexThreadBestEffort(targetThreadId);
|
|
1485
1674
|
}
|
|
1486
|
-
this.setStatus("idle");
|
|
1675
|
+
this.setStatus("idle", `Codex resume failed for ${targetThreadId.slice(0, 12)}: ${describeUnknownError(error)}`);
|
|
1487
1676
|
throw error;
|
|
1488
1677
|
}
|
|
1489
1678
|
finally {
|
|
@@ -1533,6 +1722,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1533
1722
|
if (!resumedThreadId) {
|
|
1534
1723
|
throw new Error("Codex did not return a thread id while resuming the saved thread.");
|
|
1535
1724
|
}
|
|
1725
|
+
this.validateCodexThreadWorkspaceIfPresent(response, resumedThreadId);
|
|
1536
1726
|
this.rememberBridgeOwnedThreadSignal(resumedThreadId);
|
|
1537
1727
|
this.subscribedThreadIds.add(resumedThreadId);
|
|
1538
1728
|
this.sessionFilePath = null;
|
|
@@ -1680,6 +1870,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1680
1870
|
this.completedTurnOrder = [];
|
|
1681
1871
|
}
|
|
1682
1872
|
this.pendingInjectedInputs = [];
|
|
1873
|
+
this.threadStatusCheckEpoch += 1;
|
|
1874
|
+
this.pendingThreadStatusChecks.clear();
|
|
1875
|
+
this.clearBridgeResumeReplay();
|
|
1683
1876
|
this.recentBridgeThreadSignalAtById.clear();
|
|
1684
1877
|
this.sessionFinalText = null;
|
|
1685
1878
|
this.nextSessionFallbackScanAtMs = 0;
|
|
@@ -1746,6 +1939,10 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1746
1939
|
});
|
|
1747
1940
|
}
|
|
1748
1941
|
}
|
|
1942
|
+
blockLateLocalThreadFollow() {
|
|
1943
|
+
this.localThreadFollowBlockedUntilMs =
|
|
1944
|
+
Date.now() + CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS;
|
|
1945
|
+
}
|
|
1749
1946
|
clearPendingThreadAnnouncement() {
|
|
1750
1947
|
if (!this.pendingThreadAnnouncement) {
|
|
1751
1948
|
return;
|
|
@@ -1804,16 +2001,29 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1804
2001
|
pending.timer.unref?.();
|
|
1805
2002
|
}
|
|
1806
2003
|
trackLocalSharedThread(threadId, options) {
|
|
2004
|
+
const weakSignal = options.signal === "status_changed" ||
|
|
2005
|
+
options.signal === "thread_started" ||
|
|
2006
|
+
options.signal === "session_fallback";
|
|
2007
|
+
if (weakSignal && Date.now() < this.localThreadFollowBlockedUntilMs) {
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
if (options.signal === "user_message" || options.signal === "turn_started") {
|
|
2011
|
+
this.localThreadFollowBlockedUntilMs = 0;
|
|
2012
|
+
}
|
|
1807
2013
|
if (this.pendingVisibleResume) {
|
|
1808
2014
|
return;
|
|
1809
2015
|
}
|
|
1810
2016
|
this.interruptWechatTurnForLocalThreadSwitch(threadId);
|
|
1811
2017
|
if (!this.isNativePanelMode()) {
|
|
2018
|
+
const threadChanged = this.sharedThreadId !== threadId;
|
|
1812
2019
|
this.updateSharedThread(threadId, {
|
|
1813
2020
|
source: "local",
|
|
1814
2021
|
reason: options.reason,
|
|
1815
2022
|
notify: true,
|
|
1816
2023
|
});
|
|
2024
|
+
if (threadChanged) {
|
|
2025
|
+
void this.tryEnsureSharedThreadSubscribed(threadId);
|
|
2026
|
+
}
|
|
1817
2027
|
return;
|
|
1818
2028
|
}
|
|
1819
2029
|
this.updateSharedThread(threadId, {
|
|
@@ -1897,6 +2107,20 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1897
2107
|
}
|
|
1898
2108
|
return true;
|
|
1899
2109
|
}
|
|
2110
|
+
isBridgeResumeReplay(threadId) {
|
|
2111
|
+
if (this.bridgeResumeReplayThreadId !== threadId) {
|
|
2112
|
+
return false;
|
|
2113
|
+
}
|
|
2114
|
+
if (Date.now() >= this.bridgeResumeReplayUntilMs) {
|
|
2115
|
+
this.clearBridgeResumeReplay();
|
|
2116
|
+
return false;
|
|
2117
|
+
}
|
|
2118
|
+
return true;
|
|
2119
|
+
}
|
|
2120
|
+
clearBridgeResumeReplay() {
|
|
2121
|
+
this.bridgeResumeReplayThreadId = null;
|
|
2122
|
+
this.bridgeResumeReplayUntilMs = 0;
|
|
2123
|
+
}
|
|
1900
2124
|
clearPendingApprovalState() {
|
|
1901
2125
|
this.pendingApprovalRequests = [];
|
|
1902
2126
|
this.pendingApproval = null;
|
|
@@ -2393,6 +2617,68 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2393
2617
|
if (!threadId) {
|
|
2394
2618
|
return;
|
|
2395
2619
|
}
|
|
2620
|
+
if (this.pendingVisibleResume) {
|
|
2621
|
+
return;
|
|
2622
|
+
}
|
|
2623
|
+
if (this.isBridgeResumeReplay(threadId)) {
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
const notificationCwd = typeof params.cwd === "string"
|
|
2627
|
+
? params.cwd
|
|
2628
|
+
: isRecord(params.thread) && typeof params.thread.cwd === "string"
|
|
2629
|
+
? params.thread.cwd
|
|
2630
|
+
: null;
|
|
2631
|
+
if (notificationCwd &&
|
|
2632
|
+
normalizeComparablePath(notificationCwd) !== normalizeComparablePath(this.options.cwd)) {
|
|
2633
|
+
return;
|
|
2634
|
+
}
|
|
2635
|
+
if (!this.isTrustedThreadStatus(threadId, Boolean(notificationCwd))) {
|
|
2636
|
+
this.queueThreadStatusCwdCheck(threadId);
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
this.applyThreadStatusChanged(threadId);
|
|
2640
|
+
}
|
|
2641
|
+
isTrustedThreadStatus(threadId, hasCwd) {
|
|
2642
|
+
if (hasCwd || this.isNativePanelMode()) {
|
|
2643
|
+
return true;
|
|
2644
|
+
}
|
|
2645
|
+
return (this.subscribedThreadIds.has(threadId) ||
|
|
2646
|
+
this.activeTurn?.threadId === threadId ||
|
|
2647
|
+
this.pendingTurnThreadId === threadId ||
|
|
2648
|
+
this.pendingVisibleResume?.targetThreadId === threadId);
|
|
2649
|
+
}
|
|
2650
|
+
queueThreadStatusCwdCheck(threadId) {
|
|
2651
|
+
if (this.pendingThreadStatusChecks.has(threadId)) {
|
|
2652
|
+
return;
|
|
2653
|
+
}
|
|
2654
|
+
const epoch = this.threadStatusCheckEpoch;
|
|
2655
|
+
const check = this.sendRpcRequest("thread/read", {
|
|
2656
|
+
threadId,
|
|
2657
|
+
includeTurns: false,
|
|
2658
|
+
})
|
|
2659
|
+
.then((response) => {
|
|
2660
|
+
if (epoch !== this.threadStatusCheckEpoch) {
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
if (!isRecord(response) || !isRecord(response.thread)) {
|
|
2664
|
+
return;
|
|
2665
|
+
}
|
|
2666
|
+
const thread = response.thread;
|
|
2667
|
+
if (thread.id === threadId &&
|
|
2668
|
+
typeof thread.cwd === "string" &&
|
|
2669
|
+
normalizeComparablePath(thread.cwd) === normalizeComparablePath(this.options.cwd) &&
|
|
2670
|
+
typeof thread.parentThreadId !== "string" &&
|
|
2671
|
+
thread.ephemeral !== true) {
|
|
2672
|
+
this.applyThreadStatusChanged(threadId);
|
|
2673
|
+
}
|
|
2674
|
+
})
|
|
2675
|
+
.catch(() => undefined)
|
|
2676
|
+
.finally(() => {
|
|
2677
|
+
this.pendingThreadStatusChecks.delete(threadId);
|
|
2678
|
+
});
|
|
2679
|
+
this.pendingThreadStatusChecks.set(threadId, check);
|
|
2680
|
+
}
|
|
2681
|
+
applyThreadStatusChanged(threadId) {
|
|
2396
2682
|
if (!this.activeTurn ||
|
|
2397
2683
|
this.activeTurn.threadId === threadId ||
|
|
2398
2684
|
this.activeTurn.origin === "wechat") {
|
|
@@ -2410,12 +2696,18 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2410
2696
|
if (!threadId) {
|
|
2411
2697
|
return;
|
|
2412
2698
|
}
|
|
2699
|
+
if (this.isBridgeResumeReplay(threadId)) {
|
|
2700
|
+
return;
|
|
2701
|
+
}
|
|
2413
2702
|
if (this.isRecentlyBridgeOwnedThread(threadId)) {
|
|
2414
2703
|
return;
|
|
2415
2704
|
}
|
|
2416
2705
|
const thread = isRecord(params.thread) ? params.thread : null;
|
|
2417
|
-
if (thread
|
|
2418
|
-
if (
|
|
2706
|
+
if (thread) {
|
|
2707
|
+
if ((typeof thread.cwd === "string" &&
|
|
2708
|
+
normalizeComparablePath(thread.cwd) !== normalizeComparablePath(this.options.cwd)) ||
|
|
2709
|
+
typeof thread.parentThreadId === "string" ||
|
|
2710
|
+
thread.ephemeral === true) {
|
|
2419
2711
|
return;
|
|
2420
2712
|
}
|
|
2421
2713
|
}
|
|
@@ -2435,6 +2727,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2435
2727
|
if (this.activeTurn?.turnId === trackedTurn.turnId) {
|
|
2436
2728
|
return;
|
|
2437
2729
|
}
|
|
2730
|
+
if (trackedTurn.origin === "local") {
|
|
2731
|
+
this.clearBridgeResumeReplay();
|
|
2732
|
+
}
|
|
2438
2733
|
if (trackedTurn.origin === "local" &&
|
|
2439
2734
|
trackedTurn.threadId !== this.sharedThreadId) {
|
|
2440
2735
|
if (!this.activeTurn ||
|
|
@@ -2513,6 +2808,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2513
2808
|
if (this.activeTurn?.turnId === trackedTurn.turnId) {
|
|
2514
2809
|
this.setActiveTurn(null);
|
|
2515
2810
|
}
|
|
2811
|
+
if (completedTrackedTurn.origin === "wechat") {
|
|
2812
|
+
this.blockLateLocalThreadFollow();
|
|
2813
|
+
}
|
|
2516
2814
|
this.cleanupTurnArtifacts(trackedTurn.turnId);
|
|
2517
2815
|
if (this.state.status !== "stopped" &&
|
|
2518
2816
|
(!this.activeTurn || this.activeTurn.turnId === trackedTurn.turnId)) {
|
|
@@ -2652,6 +2950,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2652
2950
|
if (!activeTurn || !finalText) {
|
|
2653
2951
|
return;
|
|
2654
2952
|
}
|
|
2953
|
+
if (activeTurn.origin === "wechat") {
|
|
2954
|
+
this.blockLateLocalThreadFollow();
|
|
2955
|
+
}
|
|
2655
2956
|
this.clearPendingApprovalState();
|
|
2656
2957
|
this.clearPendingUserInputState();
|
|
2657
2958
|
this.setActiveTurn(null);
|
|
@@ -2712,11 +3013,12 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
2712
3013
|
this.appServerAuthToken = null;
|
|
2713
3014
|
}
|
|
2714
3015
|
describeAppServerLog() {
|
|
2715
|
-
const
|
|
2716
|
-
if (!
|
|
3016
|
+
const normalized = normalizeOutput(this.appServerLog).trim();
|
|
3017
|
+
if (!normalized) {
|
|
2717
3018
|
return "";
|
|
2718
3019
|
}
|
|
2719
|
-
|
|
3020
|
+
const summary = summarizeOutput(normalized, 500);
|
|
3021
|
+
return ` Recent app-server log: ${summary}`;
|
|
2720
3022
|
}
|
|
2721
3023
|
terminateCodexClient() {
|
|
2722
3024
|
this.shuttingDown = true;
|