cli-wechat-bridge 1.1.2 → 1.1.4
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/README.md +129 -150
- package/bin/_run-entry.mjs +12 -0
- package/bin/wechat-claude-start.mjs +7 -2
- package/bin/wechat-claude.mjs +1 -1
- package/bin/wechat-codex-start.mjs +7 -2
- package/bin/wechat-codex.mjs +1 -1
- package/bin/wechat-opencode-start.mjs +7 -2
- package/bin/wechat-opencode.mjs +1 -1
- package/bin/wechat-pi-start.mjs +10 -0
- package/bin/wechat-pi.mjs +5 -0
- package/dist/bridge/bridge-adapter-common.js +2 -0
- package/dist/bridge/bridge-adapters.claude.js +21 -1
- package/dist/bridge/bridge-adapters.codex.js +85 -6
- package/dist/bridge/bridge-adapters.core.js +112 -6
- package/dist/bridge/bridge-adapters.js +5 -3
- package/dist/bridge/bridge-adapters.opencode.js +390 -52
- package/dist/bridge/bridge-adapters.pi.js +707 -0
- package/dist/bridge/bridge-adapters.shared.js +22 -102
- package/dist/bridge/bridge-final-reply.js +17 -2
- package/dist/bridge/bridge-process-reaper.js +14 -4
- package/dist/bridge/bridge-state.js +42 -6
- package/dist/bridge/bridge-utils.js +94 -237
- package/dist/bridge/wechat-bridge.js +77 -131
- package/dist/bridge/wechat-forwarding.js +58 -0
- package/dist/companion/codex-remote-client.js +20 -6
- package/dist/companion/local-companion-link.js +13 -3
- package/dist/companion/local-companion-start.js +64 -16
- package/dist/companion/local-companion.js +8 -5
- package/dist/companion/pi-tui-bridge-extension.js +256 -0
- package/dist/daemon/daemon-link.js +25 -3
- package/dist/daemon/wechat-daemon.js +264 -77
- package/dist/i18n/index.js +0 -3
- package/dist/i18n/messages-en.js +4 -5
- package/dist/i18n/messages-zh.js +4 -5
- package/dist/utils/atomic-file.js +15 -0
- package/dist/utils/doctor.js +27 -11
- package/dist/wechat/channel-config.js +0 -2
- package/dist/wechat/setup.js +5 -9
- package/dist/wechat/wechat-transport.js +14 -8
- package/package.json +113 -102
- package/bin/wechat-bridge-claude.mjs +0 -5
- package/bin/wechat-bridge-codex.mjs +0 -5
- package/bin/wechat-bridge-opencode.mjs +0 -5
- package/bin/wechat-bridge-shell.mjs +0 -5
- package/bin/wechat-bridge.mjs +0 -5
- package/dist/bridge/bridge-adapters.shell.js +0 -363
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
import { spawn as spawnChildProcess } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { OPENCODE_SERVER_HOST, OPENCODE_SERVER_READY_TIMEOUT_MS, OPENCODE_SSE_RECONNECT_DELAY_MS, OPENCODE_SESSION_IDLE_SETTLE_MS, OPENCODE_WECHAT_WORKING_NOTICE_DELAY_MS, OPENCODE_HTTP_READY_TIMEOUT_MS, OPENCODE_HTTP_READY_PROBE_TIMEOUT_MS, OPENCODE_HTTP_READY_PROBE_INTERVAL_MS, buildCliEnvironment, isRecord, describeUnknownError, resolveSpawnTarget, reserveLocalPort, waitForTcpPort, delay, } from "./bridge-adapters.shared.js";
|
|
3
4
|
import { killProcessTreeSync } from "./bridge-process-reaper.js";
|
|
4
5
|
import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, buildOneTimeCode, isWechatOutboundAttachmentMutationTool, isWechatOutboundAttachmentWriteCommand, normalizeOutput, nowIso, truncatePreview, OutputBatcher, } from "./bridge-utils.js";
|
|
5
6
|
const OPENCODE_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.WECHAT_OPENCODE_DEBUG ?? "");
|
|
6
7
|
const OPENCODE_DUPLICATE_EVENT_TTL_MS = 150;
|
|
7
8
|
const OPENCODE_WECHAT_MIRROR_SUPPRESSION_TTL_MS = 30_000;
|
|
8
9
|
const OPENCODE_RECENT_LOCAL_PROMPT_TTL_MS = 10_000;
|
|
10
|
+
// How long a selectSession echo suppression stays valid before it is treated
|
|
11
|
+
// as lost and cleared.
|
|
12
|
+
const TUI_SESSION_SELECT_SUPPRESSION_TTL_MS = 5_000;
|
|
9
13
|
const OPENCODE_LOCAL_SESSION_CREATE_FOLLOW_TTL_MS = 5_000;
|
|
10
14
|
const OPENCODE_TUI_SELECT_RETRY_DELAYS_MS = [250, 750, 1_500];
|
|
15
|
+
const OPENCODE_MINIMUM_SUPPORTED_VERSION = "1.18.0";
|
|
16
|
+
const OPENCODE_MAXIMUM_SUPPORTED_MAJOR = 2;
|
|
11
17
|
/* ------------------------------------------------------------------ */
|
|
12
18
|
/* Adapter */
|
|
13
19
|
/* ------------------------------------------------------------------ */
|
|
@@ -18,6 +24,11 @@ export class OpenCodeServerAdapter {
|
|
|
18
24
|
serverProcess = null;
|
|
19
25
|
nativeProcess = null;
|
|
20
26
|
serverPort = 0;
|
|
27
|
+
// Random per-instance password handed to `opencode serve` via
|
|
28
|
+
// OPENCODE_SERVER_PASSWORD. Without it the server accepts unauthenticated
|
|
29
|
+
// connections from any local process; the attach client inherits the env
|
|
30
|
+
// automatically, and the SDK/health calls send the matching Basic header.
|
|
31
|
+
serverPassword = null;
|
|
21
32
|
client = null;
|
|
22
33
|
sseAbortController = null;
|
|
23
34
|
sseLoopPromise = null;
|
|
@@ -49,10 +60,11 @@ export class OpenCodeServerAdapter {
|
|
|
49
60
|
pendingWechatPromptMirrorSuppressions = [];
|
|
50
61
|
recentWechatPromptMirrorSuppressions = [];
|
|
51
62
|
recentSdkEventObservations = new Map();
|
|
52
|
-
|
|
63
|
+
suppressedTuiSessionSelect = null;
|
|
53
64
|
lastMirroredLocalPrompt = null;
|
|
54
65
|
pendingLocalSessionCreateFollowUntilMs = 0;
|
|
55
66
|
pendingPermission = null;
|
|
67
|
+
pendingQuestion = null;
|
|
56
68
|
constructor(options) {
|
|
57
69
|
this.options = options;
|
|
58
70
|
this.state = {
|
|
@@ -117,6 +129,9 @@ export class OpenCodeServerAdapter {
|
|
|
117
129
|
if (this.pendingPermission) {
|
|
118
130
|
throw new Error("An OpenCode approval request is pending. Reply with /confirm or /deny.");
|
|
119
131
|
}
|
|
132
|
+
if (this.pendingQuestion) {
|
|
133
|
+
throw new Error("OpenCode is waiting for user input. Reply with /answer or use /stop.");
|
|
134
|
+
}
|
|
120
135
|
const normalized = normalizeOutput(text).trim();
|
|
121
136
|
if (!normalized) {
|
|
122
137
|
return;
|
|
@@ -153,12 +168,50 @@ export class OpenCodeServerAdapter {
|
|
|
153
168
|
}
|
|
154
169
|
}
|
|
155
170
|
async listResumeSessions(limit = 10) {
|
|
156
|
-
|
|
157
|
-
|
|
171
|
+
if (!this.client) {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
let sessions;
|
|
175
|
+
try {
|
|
176
|
+
sessions = this.unwrapOrThrow(await this.client.session.list()) ?? [];
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
return sessions
|
|
182
|
+
.filter((session) => this.isCurrentDirectorySession(session))
|
|
183
|
+
.slice(0, limit)
|
|
184
|
+
.map((session) => {
|
|
185
|
+
const rawTime = typeof session.time?.updated === "number"
|
|
186
|
+
? session.time.updated
|
|
187
|
+
: typeof session.time?.created === "number"
|
|
188
|
+
? session.time.created
|
|
189
|
+
: 0;
|
|
190
|
+
const updatedMs = rawTime > 0 && rawTime < 1_000_000_000_000 ? rawTime * 1_000 : rawTime;
|
|
191
|
+
return {
|
|
192
|
+
sessionId: session.id,
|
|
193
|
+
title: session.title?.trim() || "untitled",
|
|
194
|
+
lastUpdatedAt: new Date(updatedMs > 0 ? updatedMs : Date.now()).toISOString(),
|
|
195
|
+
source: "opencode",
|
|
196
|
+
};
|
|
197
|
+
});
|
|
158
198
|
}
|
|
159
199
|
async resumeSession(sessionId) {
|
|
160
|
-
|
|
161
|
-
|
|
200
|
+
if (!this.client) {
|
|
201
|
+
throw new Error("OpenCode is not running yet.");
|
|
202
|
+
}
|
|
203
|
+
const session = await this.getSessionForCurrentDirectory(sessionId, []);
|
|
204
|
+
if (!session) {
|
|
205
|
+
throw new Error(`No OpenCode session ${sessionId} was found for ${this.options.cwd}.`);
|
|
206
|
+
}
|
|
207
|
+
this.switchSharedSession(session, {
|
|
208
|
+
source: "wechat",
|
|
209
|
+
reason: "wechat_resume",
|
|
210
|
+
notify: true,
|
|
211
|
+
clearTrackedTurn: true,
|
|
212
|
+
syncVisible: true,
|
|
213
|
+
forceVisibleSync: true,
|
|
214
|
+
});
|
|
162
215
|
}
|
|
163
216
|
async createSession() {
|
|
164
217
|
if (!this.client) {
|
|
@@ -170,6 +223,9 @@ export class OpenCodeServerAdapter {
|
|
|
170
223
|
if (this.pendingPermission) {
|
|
171
224
|
throw new Error("An OpenCode approval request is pending. Reply with /confirm or /deny.");
|
|
172
225
|
}
|
|
226
|
+
if (this.pendingQuestion) {
|
|
227
|
+
throw new Error("OpenCode is waiting for user input. Reply with /answer or use /stop.");
|
|
228
|
+
}
|
|
173
229
|
this.outputBatcher.clear();
|
|
174
230
|
this.clearStreamedPartState();
|
|
175
231
|
const session = this.unwrapOrThrow(await this.client.session.create({
|
|
@@ -190,10 +246,16 @@ export class OpenCodeServerAdapter {
|
|
|
190
246
|
if (!this.client || !this.activeSessionId) {
|
|
191
247
|
return false;
|
|
192
248
|
}
|
|
193
|
-
if (this.state.status !== "busy" &&
|
|
249
|
+
if (this.state.status !== "busy" &&
|
|
250
|
+
this.state.status !== "awaiting_approval" &&
|
|
251
|
+
this.state.status !== "awaiting_input") {
|
|
194
252
|
return false;
|
|
195
253
|
}
|
|
196
254
|
this.clearWechatWorkingNotice(true);
|
|
255
|
+
if (this.pendingQuestion) {
|
|
256
|
+
await this.rejectPendingQuestion(this.pendingQuestion);
|
|
257
|
+
this.clearPendingQuestionState();
|
|
258
|
+
}
|
|
197
259
|
try {
|
|
198
260
|
await this.client.session.abort({
|
|
199
261
|
sessionID: this.activeSessionId,
|
|
@@ -214,6 +276,7 @@ export class OpenCodeServerAdapter {
|
|
|
214
276
|
this.clearObservedMessageTracking();
|
|
215
277
|
this.recentSdkEventObservations.clear();
|
|
216
278
|
this.clearPendingPermissionState();
|
|
279
|
+
this.clearPendingQuestionState();
|
|
217
280
|
this.activeSessionId = null;
|
|
218
281
|
this.state.sharedSessionId = undefined;
|
|
219
282
|
this.state.sharedThreadId = undefined;
|
|
@@ -232,15 +295,14 @@ export class OpenCodeServerAdapter {
|
|
|
232
295
|
if (!this.pendingPermission || !this.client) {
|
|
233
296
|
return false;
|
|
234
297
|
}
|
|
235
|
-
const {
|
|
298
|
+
const { permissionId } = this.pendingPermission;
|
|
236
299
|
const response = action === "confirm" ? "once" : "reject";
|
|
237
300
|
try {
|
|
238
|
-
const result = await this.client.permission.
|
|
239
|
-
|
|
240
|
-
permissionID: permissionId,
|
|
301
|
+
const result = await this.client.permission.reply({
|
|
302
|
+
requestID: permissionId,
|
|
241
303
|
directory: this.options.cwd,
|
|
242
304
|
workspace: this.activeWorkspaceId ?? undefined,
|
|
243
|
-
response,
|
|
305
|
+
reply: response,
|
|
244
306
|
});
|
|
245
307
|
if (result.error !== undefined) {
|
|
246
308
|
throw new Error(`SDK error: ${describeUnknownError(result.error)}`);
|
|
@@ -263,8 +325,34 @@ export class OpenCodeServerAdapter {
|
|
|
263
325
|
const ok = await this.resolveApproval(action);
|
|
264
326
|
return ok ? 1 : 0;
|
|
265
327
|
}
|
|
266
|
-
async submitUserInput(
|
|
267
|
-
|
|
328
|
+
async submitUserInput(answers) {
|
|
329
|
+
if (!this.pendingQuestion || !this.client) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
const pending = this.pendingQuestion;
|
|
333
|
+
const orderedAnswers = pending.request.questions.map((question) => answers[question.id] ?? []);
|
|
334
|
+
try {
|
|
335
|
+
const result = await this.client.question.reply({
|
|
336
|
+
requestID: pending.requestId,
|
|
337
|
+
directory: this.options.cwd,
|
|
338
|
+
workspace: this.activeWorkspaceId ?? undefined,
|
|
339
|
+
answers: orderedAnswers,
|
|
340
|
+
});
|
|
341
|
+
if (result.error !== undefined) {
|
|
342
|
+
throw new Error(`SDK error: ${describeUnknownError(result.error)}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
this.emit({
|
|
347
|
+
type: "stderr",
|
|
348
|
+
text: `Failed to submit OpenCode user input: ${describeUnknownError(err)}`,
|
|
349
|
+
timestamp: nowIso(),
|
|
350
|
+
});
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
this.clearPendingQuestionState();
|
|
354
|
+
this.setStatus("busy", "OpenCode user input submitted.");
|
|
355
|
+
return true;
|
|
268
356
|
}
|
|
269
357
|
async dispose() {
|
|
270
358
|
this.shuttingDown = true;
|
|
@@ -277,6 +365,7 @@ export class OpenCodeServerAdapter {
|
|
|
277
365
|
this.outputBatcher.clear();
|
|
278
366
|
this.clearStreamedPartState();
|
|
279
367
|
this.clearPendingPermissionState();
|
|
368
|
+
this.clearPendingQuestionState();
|
|
280
369
|
// Stop SSE listener
|
|
281
370
|
if (this.sseAbortController) {
|
|
282
371
|
this.sseAbortController.abort();
|
|
@@ -318,7 +407,7 @@ export class OpenCodeServerAdapter {
|
|
|
318
407
|
this.client = null;
|
|
319
408
|
this.activeSessionId = null;
|
|
320
409
|
this.activeWorkspaceId = null;
|
|
321
|
-
this.
|
|
410
|
+
this.suppressedTuiSessionSelect = null;
|
|
322
411
|
this.state.status = "stopped";
|
|
323
412
|
this.state.pid = undefined;
|
|
324
413
|
this.state.startedAt = undefined;
|
|
@@ -326,6 +415,8 @@ export class OpenCodeServerAdapter {
|
|
|
326
415
|
/* ---- Server management ---- */
|
|
327
416
|
async startServerProcess() {
|
|
328
417
|
const env = buildCliEnvironment(this.options.kind);
|
|
418
|
+
this.serverPassword = randomBytes(24).toString("hex");
|
|
419
|
+
env.OPENCODE_SERVER_PASSWORD = this.serverPassword;
|
|
329
420
|
const serverArgs = [
|
|
330
421
|
"serve",
|
|
331
422
|
"--port",
|
|
@@ -435,6 +526,9 @@ export class OpenCodeServerAdapter {
|
|
|
435
526
|
}
|
|
436
527
|
buildNativeClientEnv() {
|
|
437
528
|
const env = buildCliEnvironment(this.options.kind);
|
|
529
|
+
if (this.serverPassword) {
|
|
530
|
+
env.OPENCODE_SERVER_PASSWORD = this.serverPassword;
|
|
531
|
+
}
|
|
438
532
|
if (this.activeSessionId) {
|
|
439
533
|
env.OPENCODE_ROUTE = JSON.stringify({
|
|
440
534
|
type: "session",
|
|
@@ -443,6 +537,13 @@ export class OpenCodeServerAdapter {
|
|
|
443
537
|
}
|
|
444
538
|
return env;
|
|
445
539
|
}
|
|
540
|
+
buildServerAuthHeaders() {
|
|
541
|
+
if (!this.serverPassword) {
|
|
542
|
+
return {};
|
|
543
|
+
}
|
|
544
|
+
const credentials = Buffer.from(`opencode:${this.serverPassword}`, "utf8").toString("base64");
|
|
545
|
+
return { Authorization: `Basic ${credentials}` };
|
|
546
|
+
}
|
|
446
547
|
async createSdkClient() {
|
|
447
548
|
try {
|
|
448
549
|
const { createOpencodeClient } = await import("@opencode-ai/sdk/v2");
|
|
@@ -450,6 +551,7 @@ export class OpenCodeServerAdapter {
|
|
|
450
551
|
baseUrl: `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`,
|
|
451
552
|
directory: this.options.cwd,
|
|
452
553
|
experimental_workspaceID: this.activeWorkspaceId ?? undefined,
|
|
554
|
+
headers: this.buildServerAuthHeaders(),
|
|
453
555
|
});
|
|
454
556
|
}
|
|
455
557
|
catch (err) {
|
|
@@ -458,10 +560,57 @@ export class OpenCodeServerAdapter {
|
|
|
458
560
|
}
|
|
459
561
|
async checkHealth() {
|
|
460
562
|
const baseUrl = `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`;
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
563
|
+
// `opencode serve` binds its TCP port before the HTTP layer is ready,
|
|
564
|
+
// so a fetch fired the instant `waitForTcpPort` returns can be accepted
|
|
565
|
+
// into the listen backlog but never answered, hanging until undici's
|
|
566
|
+
// ~300s `headersTimeout` and surfacing as `fetch failed` after ~5min.
|
|
567
|
+
// Retry with a short per-attempt timeout so the startup race
|
|
568
|
+
// self-heals once the server finishes booting.
|
|
569
|
+
const deadline = Date.now() + OPENCODE_HTTP_READY_TIMEOUT_MS;
|
|
570
|
+
let lastError;
|
|
571
|
+
while (Date.now() < deadline) {
|
|
572
|
+
try {
|
|
573
|
+
const response = await fetch(`${baseUrl}/global/health`, {
|
|
574
|
+
headers: this.buildServerAuthHeaders(),
|
|
575
|
+
signal: AbortSignal.timeout(OPENCODE_HTTP_READY_PROBE_TIMEOUT_MS),
|
|
576
|
+
});
|
|
577
|
+
if (response.ok) {
|
|
578
|
+
const health = await response.json();
|
|
579
|
+
if (!isRecord(health) || health.healthy !== true || typeof health.version !== "string") {
|
|
580
|
+
lastError = new Error("OpenCode health check returned an invalid response.");
|
|
581
|
+
}
|
|
582
|
+
else if (!this.isSupportedOpenCodeVersion(health.version)) {
|
|
583
|
+
throw new Error(`Unsupported OpenCode version ${health.version}. Install OpenCode >=${OPENCODE_MINIMUM_SUPPORTED_VERSION} <${OPENCODE_MAXIMUM_SUPPORTED_MAJOR}.0.0.`);
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
else {
|
|
590
|
+
lastError = new Error(`OpenCode health check failed (HTTP ${response.status}).`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch (err) {
|
|
594
|
+
if (err instanceof Error &&
|
|
595
|
+
err.message.startsWith("Unsupported OpenCode version ")) {
|
|
596
|
+
throw err;
|
|
597
|
+
}
|
|
598
|
+
lastError = err;
|
|
599
|
+
}
|
|
600
|
+
await delay(OPENCODE_HTTP_READY_PROBE_INTERVAL_MS);
|
|
601
|
+
}
|
|
602
|
+
throw lastError ?? new Error("OpenCode health check timed out.");
|
|
603
|
+
}
|
|
604
|
+
isSupportedOpenCodeVersion(version) {
|
|
605
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim());
|
|
606
|
+
if (!match) {
|
|
607
|
+
return false;
|
|
464
608
|
}
|
|
609
|
+
const major = Number(match[1]);
|
|
610
|
+
const minor = Number(match[2]);
|
|
611
|
+
return (major >= 1 &&
|
|
612
|
+
major < OPENCODE_MAXIMUM_SUPPORTED_MAJOR &&
|
|
613
|
+
(major > 1 || minor >= 18));
|
|
465
614
|
}
|
|
466
615
|
async initializeSessions() {
|
|
467
616
|
if (!this.client) {
|
|
@@ -568,11 +717,11 @@ export class OpenCodeServerAdapter {
|
|
|
568
717
|
return;
|
|
569
718
|
}
|
|
570
719
|
this.sseAbortController = new AbortController();
|
|
571
|
-
|
|
720
|
+
const sseLoops = [
|
|
572
721
|
this.runSseLoop("event"),
|
|
573
722
|
this.runSseLoop("global-event"),
|
|
574
|
-
|
|
575
|
-
|
|
723
|
+
];
|
|
724
|
+
this.sseLoopPromise = Promise.all(sseLoops).then(() => undefined);
|
|
576
725
|
}
|
|
577
726
|
async runSseLoop(streamName) {
|
|
578
727
|
while (!this.shuttingDown) {
|
|
@@ -607,9 +756,6 @@ export class OpenCodeServerAdapter {
|
|
|
607
756
|
}
|
|
608
757
|
subscribeToSseStream(streamName) {
|
|
609
758
|
const signal = this.sseAbortController?.signal;
|
|
610
|
-
if (streamName === "global-sync") {
|
|
611
|
-
return this.client.global.syncEvent.subscribe({ signal });
|
|
612
|
-
}
|
|
613
759
|
if (streamName === "global-event") {
|
|
614
760
|
return this.client.global.event({ signal });
|
|
615
761
|
}
|
|
@@ -623,11 +769,20 @@ export class OpenCodeServerAdapter {
|
|
|
623
769
|
return null;
|
|
624
770
|
}
|
|
625
771
|
if (typeof rawEvent.type === "string") {
|
|
772
|
+
if (rawEvent.type === "sync") {
|
|
773
|
+
return this.normalizeGlobalSyncPayload(rawEvent, rawEvent);
|
|
774
|
+
}
|
|
626
775
|
return rawEvent;
|
|
627
776
|
}
|
|
628
777
|
const payload = rawEvent.payload;
|
|
629
778
|
if (isRecord(payload) && typeof payload.type === "string") {
|
|
779
|
+
if (payload.type === "sync") {
|
|
780
|
+
return this.normalizeGlobalSyncPayload(payload, rawEvent);
|
|
781
|
+
}
|
|
630
782
|
const normalized = { ...payload };
|
|
783
|
+
if (typeof payload.id === "string") {
|
|
784
|
+
normalized.id = payload.id;
|
|
785
|
+
}
|
|
631
786
|
if (typeof rawEvent.directory === "string") {
|
|
632
787
|
normalized.directory = rawEvent.directory;
|
|
633
788
|
}
|
|
@@ -635,6 +790,17 @@ export class OpenCodeServerAdapter {
|
|
|
635
790
|
}
|
|
636
791
|
return null;
|
|
637
792
|
}
|
|
793
|
+
normalizeGlobalSyncPayload(payload, envelope) {
|
|
794
|
+
const syncEvent = isRecord(payload.syncEvent) ? payload.syncEvent : undefined;
|
|
795
|
+
if (!syncEvent || typeof syncEvent.type !== "string") {
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
const normalized = { ...syncEvent };
|
|
799
|
+
if (typeof envelope.directory === "string") {
|
|
800
|
+
normalized.directory = envelope.directory;
|
|
801
|
+
}
|
|
802
|
+
return normalized;
|
|
803
|
+
}
|
|
638
804
|
shouldHandleSseEvent(event, streamName) {
|
|
639
805
|
if (streamName === "event") {
|
|
640
806
|
return true;
|
|
@@ -647,7 +813,10 @@ export class OpenCodeServerAdapter {
|
|
|
647
813
|
type !== "command.executed" &&
|
|
648
814
|
type !== "session.created" &&
|
|
649
815
|
type !== "session.updated" &&
|
|
650
|
-
type !== "session.deleted"
|
|
816
|
+
type !== "session.deleted" &&
|
|
817
|
+
type !== "question.asked" &&
|
|
818
|
+
type !== "question.replied" &&
|
|
819
|
+
type !== "question.rejected") {
|
|
651
820
|
return false;
|
|
652
821
|
}
|
|
653
822
|
if (this.isImplicitLocalCompanionUiEvent(type)) {
|
|
@@ -656,13 +825,6 @@ export class OpenCodeServerAdapter {
|
|
|
656
825
|
return (this.matchesCurrentDirectoryEvent(event) ||
|
|
657
826
|
this.shouldAcceptUnscopedLocalSessionCreatedEvent(event));
|
|
658
827
|
}
|
|
659
|
-
if (type === "session.created" ||
|
|
660
|
-
type === "session.updated" ||
|
|
661
|
-
type === "session.deleted") {
|
|
662
|
-
return (this.matchesCurrentDirectoryEvent(event) ||
|
|
663
|
-
(type === "session.created" &&
|
|
664
|
-
this.shouldAcceptUnscopedLocalSessionCreatedEvent(event)));
|
|
665
|
-
}
|
|
666
828
|
return false;
|
|
667
829
|
}
|
|
668
830
|
isImplicitLocalCompanionUiEvent(type) {
|
|
@@ -696,11 +858,21 @@ export class OpenCodeServerAdapter {
|
|
|
696
858
|
this.handleSessionError(isRecord(payload) ? payload : undefined);
|
|
697
859
|
return;
|
|
698
860
|
}
|
|
699
|
-
|
|
861
|
+
// Note: only permission.asked exists in the OpenCode wire protocol;
|
|
862
|
+
// permission.updated was never emitted by the server.
|
|
700
863
|
case "permission.asked": {
|
|
701
864
|
this.handlePermissionRequest(payload);
|
|
702
865
|
return;
|
|
703
866
|
}
|
|
867
|
+
case "question.asked": {
|
|
868
|
+
this.handleQuestionAsked(payload);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
case "question.replied":
|
|
872
|
+
case "question.rejected": {
|
|
873
|
+
this.handleQuestionSettled(payload);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
704
876
|
case "session.created": {
|
|
705
877
|
this.handleSessionCreated(payload);
|
|
706
878
|
return;
|
|
@@ -762,18 +934,23 @@ export class OpenCodeServerAdapter {
|
|
|
762
934
|
if (!this.syncTrackedSessionFromEvent(sessionId, { allowLocalTurnFollow: false })) {
|
|
763
935
|
return;
|
|
764
936
|
}
|
|
765
|
-
if (this.state.status !== "busy" &&
|
|
937
|
+
if (this.state.status !== "busy" &&
|
|
938
|
+
this.state.status !== "awaiting_approval" &&
|
|
939
|
+
this.state.status !== "awaiting_input") {
|
|
766
940
|
return;
|
|
767
941
|
}
|
|
768
942
|
// Wait a short settle time before emitting task_complete,
|
|
769
943
|
// in case more events follow the idle signal.
|
|
770
944
|
setTimeout(() => {
|
|
771
|
-
if (this.state.status !== "busy" &&
|
|
945
|
+
if (this.state.status !== "busy" &&
|
|
946
|
+
this.state.status !== "awaiting_approval" &&
|
|
947
|
+
this.state.status !== "awaiting_input") {
|
|
772
948
|
return;
|
|
773
949
|
}
|
|
774
950
|
this.clearWechatWorkingNotice(true);
|
|
775
951
|
this.pendingLocalPrompt = "";
|
|
776
952
|
this.clearPendingPermissionState();
|
|
953
|
+
this.clearPendingQuestionState();
|
|
777
954
|
this.state.activeTurnOrigin = undefined;
|
|
778
955
|
this.hasAcceptedInput = false;
|
|
779
956
|
const completedPreview = this.currentPreview;
|
|
@@ -820,7 +997,10 @@ export class OpenCodeServerAdapter {
|
|
|
820
997
|
if (sessionId && !this.syncTrackedSessionFromEvent(sessionId)) {
|
|
821
998
|
return;
|
|
822
999
|
}
|
|
823
|
-
// properties: { sessionID
|
|
1000
|
+
// properties: { sessionID, status: { type: "busy" | "idle" | "retry" | ... } }
|
|
1001
|
+
// "retry" means the model call is being retried (attempt, message): the
|
|
1002
|
+
// session is still working, so it must keep the busy state instead of
|
|
1003
|
+
// letting WeChat messages race a retried turn.
|
|
824
1004
|
const status = properties.status;
|
|
825
1005
|
if (!isRecord(status)) {
|
|
826
1006
|
return;
|
|
@@ -829,7 +1009,7 @@ export class OpenCodeServerAdapter {
|
|
|
829
1009
|
if (!statusType) {
|
|
830
1010
|
return;
|
|
831
1011
|
}
|
|
832
|
-
if (statusType === "busy" || statusType === "running") {
|
|
1012
|
+
if (statusType === "busy" || statusType === "running" || statusType === "retry") {
|
|
833
1013
|
if (this.state.status === "idle") {
|
|
834
1014
|
this.outputBatcher.clear();
|
|
835
1015
|
this.clearStreamedPartState();
|
|
@@ -874,6 +1054,114 @@ export class OpenCodeServerAdapter {
|
|
|
874
1054
|
this.state.pendingApproval = null;
|
|
875
1055
|
this.state.pendingApprovalOrigin = undefined;
|
|
876
1056
|
}
|
|
1057
|
+
handleQuestionAsked(properties) {
|
|
1058
|
+
if (!isRecord(properties) || !this.client) {
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const sessionId = this.extractSessionId(properties);
|
|
1062
|
+
if (!sessionId || !this.syncTrackedSessionFromEvent(sessionId)) {
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
const requestId = typeof properties.id === "string" ? properties.id : null;
|
|
1066
|
+
if (!requestId || !Array.isArray(properties.questions)) {
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
const questions = properties.questions.flatMap((candidate, index) => {
|
|
1070
|
+
if (!isRecord(candidate)) {
|
|
1071
|
+
return [];
|
|
1072
|
+
}
|
|
1073
|
+
const header = typeof candidate.header === "string" ? candidate.header.trim() : "";
|
|
1074
|
+
const question = typeof candidate.question === "string" ? candidate.question.trim() : "";
|
|
1075
|
+
if (!header || !question) {
|
|
1076
|
+
return [];
|
|
1077
|
+
}
|
|
1078
|
+
const options = Array.isArray(candidate.options)
|
|
1079
|
+
? candidate.options.flatMap((option) => {
|
|
1080
|
+
if (!isRecord(option) || typeof option.label !== "string") {
|
|
1081
|
+
return [];
|
|
1082
|
+
}
|
|
1083
|
+
return [{
|
|
1084
|
+
label: option.label,
|
|
1085
|
+
description: typeof option.description === "string" ? option.description : "",
|
|
1086
|
+
}];
|
|
1087
|
+
})
|
|
1088
|
+
: [];
|
|
1089
|
+
return [{
|
|
1090
|
+
id: `question_${index + 1}`,
|
|
1091
|
+
header,
|
|
1092
|
+
question,
|
|
1093
|
+
isOther: candidate.custom === true,
|
|
1094
|
+
isSecret: false,
|
|
1095
|
+
multiple: candidate.multiple === true,
|
|
1096
|
+
customAnswerMode: "value",
|
|
1097
|
+
options,
|
|
1098
|
+
}];
|
|
1099
|
+
});
|
|
1100
|
+
if (questions.length !== properties.questions.length || questions.length === 0) {
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
const request = {
|
|
1104
|
+
summary: `OpenCode requested answers to ${questions.length} question${questions.length === 1 ? "" : "s"}.`,
|
|
1105
|
+
questions,
|
|
1106
|
+
};
|
|
1107
|
+
this.pendingQuestion = {
|
|
1108
|
+
sessionId,
|
|
1109
|
+
requestId,
|
|
1110
|
+
request,
|
|
1111
|
+
};
|
|
1112
|
+
this.state.pendingUserInput = request;
|
|
1113
|
+
this.state.pendingUserInputOrigin = this.state.activeTurnOrigin;
|
|
1114
|
+
this.clearWechatWorkingNotice();
|
|
1115
|
+
this.setStatus("awaiting_input", "OpenCode is waiting for user input.");
|
|
1116
|
+
this.emit({
|
|
1117
|
+
type: "user_input_required",
|
|
1118
|
+
request,
|
|
1119
|
+
timestamp: nowIso(),
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
handleQuestionSettled(properties) {
|
|
1123
|
+
if (!isRecord(properties) || !this.pendingQuestion) {
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
const sessionId = this.extractSessionId(properties);
|
|
1127
|
+
const requestId = typeof properties.requestID === "string" ? properties.requestID : null;
|
|
1128
|
+
if (sessionId !== this.pendingQuestion.sessionId ||
|
|
1129
|
+
requestId !== this.pendingQuestion.requestId) {
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
this.clearPendingQuestionState();
|
|
1133
|
+
if (this.state.status === "awaiting_input") {
|
|
1134
|
+
this.setStatus("busy", "OpenCode user input resolved.");
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
clearPendingQuestionState() {
|
|
1138
|
+
this.pendingQuestion = null;
|
|
1139
|
+
this.state.pendingUserInput = null;
|
|
1140
|
+
this.state.pendingUserInputOrigin = undefined;
|
|
1141
|
+
}
|
|
1142
|
+
async rejectPendingQuestion(pending) {
|
|
1143
|
+
const client = this.client;
|
|
1144
|
+
if (!client) {
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
try {
|
|
1148
|
+
const result = await client.question.reject({
|
|
1149
|
+
requestID: pending.requestId,
|
|
1150
|
+
directory: this.options.cwd,
|
|
1151
|
+
workspace: this.activeWorkspaceId ?? undefined,
|
|
1152
|
+
});
|
|
1153
|
+
if (result.error !== undefined) {
|
|
1154
|
+
throw new Error(`SDK error: ${describeUnknownError(result.error)}`);
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
catch (err) {
|
|
1158
|
+
this.emit({
|
|
1159
|
+
type: "stderr",
|
|
1160
|
+
text: `Failed to reject OpenCode user input: ${describeUnknownError(err)}`,
|
|
1161
|
+
timestamp: nowIso(),
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
877
1165
|
getWechatOutboundAttachmentPermissionDenyMessage(properties, pendingPermission) {
|
|
878
1166
|
const metadata = isRecord(properties.metadata) ? properties.metadata : {};
|
|
879
1167
|
const command = this.extractPermissionCommand(properties, metadata);
|
|
@@ -905,12 +1193,11 @@ export class OpenCodeServerAdapter {
|
|
|
905
1193
|
}
|
|
906
1194
|
void (async () => {
|
|
907
1195
|
try {
|
|
908
|
-
const result = await client.permission.
|
|
909
|
-
|
|
910
|
-
permissionID: pendingPermission.permissionId,
|
|
1196
|
+
const result = await client.permission.reply({
|
|
1197
|
+
requestID: pendingPermission.permissionId,
|
|
911
1198
|
directory: this.options.cwd,
|
|
912
1199
|
workspace: this.activeWorkspaceId ?? undefined,
|
|
913
|
-
|
|
1200
|
+
reply: "reject",
|
|
914
1201
|
});
|
|
915
1202
|
if (result.error !== undefined) {
|
|
916
1203
|
throw new Error(`SDK error: ${describeUnknownError(result.error)}`);
|
|
@@ -1378,8 +1665,11 @@ export class OpenCodeServerAdapter {
|
|
|
1378
1665
|
if (!sessionId) {
|
|
1379
1666
|
return;
|
|
1380
1667
|
}
|
|
1381
|
-
if (
|
|
1382
|
-
this.
|
|
1668
|
+
if (this.suppressedTuiSessionSelect &&
|
|
1669
|
+
sessionId === this.suppressedTuiSessionSelect.sessionId) {
|
|
1670
|
+
// Echo of our own selectSession — swallow it once. The TTL guard in
|
|
1671
|
+
// isSuppressedTuiSessionSelectExpired covers the lost-echo case.
|
|
1672
|
+
this.suppressedTuiSessionSelect = null;
|
|
1383
1673
|
return;
|
|
1384
1674
|
}
|
|
1385
1675
|
this.pendingLocalPrompt = "";
|
|
@@ -1661,8 +1951,10 @@ export class OpenCodeServerAdapter {
|
|
|
1661
1951
|
hasTrackedTurnState() {
|
|
1662
1952
|
return (this.state.status === "busy" ||
|
|
1663
1953
|
this.state.status === "awaiting_approval" ||
|
|
1954
|
+
this.state.status === "awaiting_input" ||
|
|
1664
1955
|
this.hasAcceptedInput ||
|
|
1665
1956
|
this.pendingPermission !== null ||
|
|
1957
|
+
this.pendingQuestion !== null ||
|
|
1666
1958
|
this.state.activeTurnOrigin !== undefined ||
|
|
1667
1959
|
this.currentPreview !== "(idle)");
|
|
1668
1960
|
}
|
|
@@ -1671,6 +1963,7 @@ export class OpenCodeServerAdapter {
|
|
|
1671
1963
|
this.pendingLocalPrompt = "";
|
|
1672
1964
|
this.localPromptNoticeSent = false;
|
|
1673
1965
|
this.clearPendingPermissionState();
|
|
1966
|
+
this.clearPendingQuestionState();
|
|
1674
1967
|
this.state.activeTurnOrigin = undefined;
|
|
1675
1968
|
this.hasAcceptedInput = false;
|
|
1676
1969
|
this.currentPreview = "(idle)";
|
|
@@ -1922,13 +2215,27 @@ export class OpenCodeServerAdapter {
|
|
|
1922
2215
|
workspaceID: this.activeWorkspaceId ?? undefined,
|
|
1923
2216
|
}, { force: options.force, retry: options.retry });
|
|
1924
2217
|
}
|
|
2218
|
+
isTuiSessionSelectSuppressed(sessionId) {
|
|
2219
|
+
const suppression = this.suppressedTuiSessionSelect;
|
|
2220
|
+
if (!suppression || suppression.sessionId !== sessionId) {
|
|
2221
|
+
return false;
|
|
2222
|
+
}
|
|
2223
|
+
// Bound the suppression window: if the echo never arrives (TUI not ready,
|
|
2224
|
+
// dropped event), a stale flag must not swallow a later genuine local
|
|
2225
|
+
// switch to the same session.
|
|
2226
|
+
if (Date.now() - suppression.setAtMs > TUI_SESSION_SELECT_SUPPRESSION_TTL_MS) {
|
|
2227
|
+
this.suppressedTuiSessionSelect = null;
|
|
2228
|
+
return false;
|
|
2229
|
+
}
|
|
2230
|
+
return true;
|
|
2231
|
+
}
|
|
1925
2232
|
async syncVisibleSessionSelection(session, options = {}) {
|
|
1926
2233
|
if (!this.client?.tui ||
|
|
1927
2234
|
this.options.renderMode !== "companion" ||
|
|
1928
|
-
(!options.force && session.id
|
|
2235
|
+
(!options.force && this.isTuiSessionSelectSuppressed(session.id))) {
|
|
1929
2236
|
return;
|
|
1930
2237
|
}
|
|
1931
|
-
this.
|
|
2238
|
+
this.suppressedTuiSessionSelect = { sessionId: session.id, setAtMs: Date.now() };
|
|
1932
2239
|
this.logDebug(`[opencode-adapter:tui] selectSession session=${session.id} workspace=${session.workspaceID ?? this.activeWorkspaceId ?? "(none)"}`);
|
|
1933
2240
|
try {
|
|
1934
2241
|
await this.sendVisibleSessionSelection(session);
|
|
@@ -1937,8 +2244,8 @@ export class OpenCodeServerAdapter {
|
|
|
1937
2244
|
}
|
|
1938
2245
|
}
|
|
1939
2246
|
catch (err) {
|
|
1940
|
-
if (this.
|
|
1941
|
-
this.
|
|
2247
|
+
if (this.suppressedTuiSessionSelect?.sessionId === session.id) {
|
|
2248
|
+
this.suppressedTuiSessionSelect = null;
|
|
1942
2249
|
}
|
|
1943
2250
|
this.logDebug(`[opencode-adapter:tui] selectSession failed for ${session.id}: ${describeUnknownError(err)}`);
|
|
1944
2251
|
}
|
|
@@ -2046,8 +2353,10 @@ export class OpenCodeServerAdapter {
|
|
|
2046
2353
|
}
|
|
2047
2354
|
return true;
|
|
2048
2355
|
}
|
|
2356
|
+
// Fold both path separators to "/" so directory comparisons behave
|
|
2357
|
+
// identically on Windows (backslash-native) and POSIX hosts.
|
|
2049
2358
|
normalizeDirectory(directory) {
|
|
2050
|
-
return directory.replace(/[\\/]+/g, "
|
|
2359
|
+
return directory.replace(/[\\/]+/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
2051
2360
|
}
|
|
2052
2361
|
recordSessionSwitch(sessionId, source, reason, notify = false) {
|
|
2053
2362
|
const timestamp = nowIso();
|
|
@@ -2105,13 +2414,20 @@ export class OpenCodeServerAdapter {
|
|
|
2105
2414
|
return "";
|
|
2106
2415
|
}
|
|
2107
2416
|
const previousText = this.emittedTextByPartId.get(partId) ?? "";
|
|
2108
|
-
if (nextChunk === previousText
|
|
2417
|
+
if (nextChunk === previousText) {
|
|
2418
|
+
// Full replay of everything emitted for this part (an SSE replay after
|
|
2419
|
+
// a reconnect): nothing new.
|
|
2109
2420
|
return "";
|
|
2110
2421
|
}
|
|
2111
2422
|
if (previousText && nextChunk.startsWith(previousText)) {
|
|
2423
|
+
// The "delta" actually carries the accumulated part text: keep the tail.
|
|
2112
2424
|
this.emittedTextByPartId.set(partId, nextChunk);
|
|
2113
2425
|
return nextChunk.slice(previousText.length);
|
|
2114
2426
|
}
|
|
2427
|
+
// Plain incremental delta: append. Duplicated fragments that legitimately
|
|
2428
|
+
// occur in model output (repeated words, table separators, markdown
|
|
2429
|
+
// rules) must still be forwarded, so there is deliberately no
|
|
2430
|
+
// suffix-based dedup here — it silently truncated real content.
|
|
2115
2431
|
this.emittedTextByPartId.set(partId, `${previousText}${nextChunk}`);
|
|
2116
2432
|
return nextChunk;
|
|
2117
2433
|
}
|
|
@@ -2199,18 +2515,31 @@ export class OpenCodeServerAdapter {
|
|
|
2199
2515
|
if (!key) {
|
|
2200
2516
|
return false;
|
|
2201
2517
|
}
|
|
2518
|
+
// Frame-id keys identify one exact server event, so any repeat — even on
|
|
2519
|
+
// the same stream (SSE replay) — is a duplicate; the wide TTL absorbs
|
|
2520
|
+
// reconnect replays. Synthetic keys still rely on the narrow cross-stream
|
|
2521
|
+
// window only, because they cannot distinguish a replay from a legitimate
|
|
2522
|
+
// repeated payload.
|
|
2523
|
+
const idKeyTtlMs = 30_000;
|
|
2524
|
+
const isIdKey = key.startsWith("evt:");
|
|
2525
|
+
const ttlMs = isIdKey ? idKeyTtlMs : OPENCODE_DUPLICATE_EVENT_TTL_MS;
|
|
2202
2526
|
const now = Date.now();
|
|
2203
|
-
const cutoff = now - OPENCODE_DUPLICATE_EVENT_TTL_MS;
|
|
2204
2527
|
for (const [candidateKey, observed] of this.recentSdkEventObservations.entries()) {
|
|
2205
|
-
if (observed.observedAtMs <
|
|
2528
|
+
if (observed.observedAtMs < now - idKeyTtlMs) {
|
|
2206
2529
|
this.recentSdkEventObservations.delete(candidateKey);
|
|
2207
2530
|
}
|
|
2208
2531
|
}
|
|
2209
2532
|
const previous = this.recentSdkEventObservations.get(key);
|
|
2210
2533
|
this.recentSdkEventObservations.set(key, { streamName, observedAtMs: now });
|
|
2211
|
-
return Boolean(previous &&
|
|
2534
|
+
return Boolean(previous &&
|
|
2535
|
+
previous.observedAtMs >= now - ttlMs &&
|
|
2536
|
+
(isIdKey || previous.streamName !== streamName));
|
|
2212
2537
|
}
|
|
2213
2538
|
getDuplicateSdkEventKey(event) {
|
|
2539
|
+
// Exact frame ids (when the stream provides them) dedupe precisely.
|
|
2540
|
+
if (typeof event.id === "string" && event.id) {
|
|
2541
|
+
return `evt:${event.id}`;
|
|
2542
|
+
}
|
|
2214
2543
|
const type = this.normalizeEventType(event.type);
|
|
2215
2544
|
const payload = this.extractEventPayload(event);
|
|
2216
2545
|
if (!isRecord(payload)) {
|
|
@@ -2241,6 +2570,15 @@ export class OpenCodeServerAdapter {
|
|
|
2241
2570
|
const sessionId = this.extractSessionId(payload);
|
|
2242
2571
|
return sessionId ? `${type}:${sessionId}` : null;
|
|
2243
2572
|
}
|
|
2573
|
+
case "question.asked": {
|
|
2574
|
+
const requestId = typeof payload.id === "string" ? payload.id : undefined;
|
|
2575
|
+
return requestId ? `${type}:${requestId}` : null;
|
|
2576
|
+
}
|
|
2577
|
+
case "question.replied":
|
|
2578
|
+
case "question.rejected": {
|
|
2579
|
+
const requestId = typeof payload.requestID === "string" ? payload.requestID : undefined;
|
|
2580
|
+
return requestId ? `${type}:${requestId}` : null;
|
|
2581
|
+
}
|
|
2244
2582
|
default:
|
|
2245
2583
|
return null;
|
|
2246
2584
|
}
|