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.
Files changed (46) hide show
  1. package/README.md +129 -150
  2. package/bin/_run-entry.mjs +12 -0
  3. package/bin/wechat-claude-start.mjs +7 -2
  4. package/bin/wechat-claude.mjs +1 -1
  5. package/bin/wechat-codex-start.mjs +7 -2
  6. package/bin/wechat-codex.mjs +1 -1
  7. package/bin/wechat-opencode-start.mjs +7 -2
  8. package/bin/wechat-opencode.mjs +1 -1
  9. package/bin/wechat-pi-start.mjs +10 -0
  10. package/bin/wechat-pi.mjs +5 -0
  11. package/dist/bridge/bridge-adapter-common.js +2 -0
  12. package/dist/bridge/bridge-adapters.claude.js +21 -1
  13. package/dist/bridge/bridge-adapters.codex.js +85 -6
  14. package/dist/bridge/bridge-adapters.core.js +112 -6
  15. package/dist/bridge/bridge-adapters.js +5 -3
  16. package/dist/bridge/bridge-adapters.opencode.js +390 -52
  17. package/dist/bridge/bridge-adapters.pi.js +707 -0
  18. package/dist/bridge/bridge-adapters.shared.js +22 -102
  19. package/dist/bridge/bridge-final-reply.js +17 -2
  20. package/dist/bridge/bridge-process-reaper.js +14 -4
  21. package/dist/bridge/bridge-state.js +42 -6
  22. package/dist/bridge/bridge-utils.js +94 -237
  23. package/dist/bridge/wechat-bridge.js +77 -131
  24. package/dist/bridge/wechat-forwarding.js +58 -0
  25. package/dist/companion/codex-remote-client.js +20 -6
  26. package/dist/companion/local-companion-link.js +13 -3
  27. package/dist/companion/local-companion-start.js +64 -16
  28. package/dist/companion/local-companion.js +8 -5
  29. package/dist/companion/pi-tui-bridge-extension.js +256 -0
  30. package/dist/daemon/daemon-link.js +25 -3
  31. package/dist/daemon/wechat-daemon.js +264 -77
  32. package/dist/i18n/index.js +0 -3
  33. package/dist/i18n/messages-en.js +4 -5
  34. package/dist/i18n/messages-zh.js +4 -5
  35. package/dist/utils/atomic-file.js +15 -0
  36. package/dist/utils/doctor.js +27 -11
  37. package/dist/wechat/channel-config.js +0 -2
  38. package/dist/wechat/setup.js +5 -9
  39. package/dist/wechat/wechat-transport.js +14 -8
  40. package/package.json +113 -102
  41. package/bin/wechat-bridge-claude.mjs +0 -5
  42. package/bin/wechat-bridge-codex.mjs +0 -5
  43. package/bin/wechat-bridge-opencode.mjs +0 -5
  44. package/bin/wechat-bridge-shell.mjs +0 -5
  45. package/bin/wechat-bridge.mjs +0 -5
  46. 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 { OPENCODE_SERVER_HOST, OPENCODE_SERVER_READY_TIMEOUT_MS, OPENCODE_SSE_RECONNECT_DELAY_MS, OPENCODE_SESSION_IDLE_SETTLE_MS, OPENCODE_WECHAT_WORKING_NOTICE_DELAY_MS, buildCliEnvironment, isRecord, describeUnknownError, resolveSpawnTarget, reserveLocalPort, waitForTcpPort, delay, } from "./bridge-adapters.shared.js";
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
- suppressedTuiSessionSelectId = null;
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
- void limit;
157
- throw new Error('WeChat /resume is disabled in opencode mode. Use /session directly inside "wechat-opencode"; WeChat will follow the active local session.');
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
- void sessionId;
161
- throw new Error('WeChat /resume is disabled in opencode mode. Use /session directly inside "wechat-opencode"; WeChat will follow the active local session.');
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" && this.state.status !== "awaiting_approval") {
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 { sessionId, permissionId } = this.pendingPermission;
298
+ const { permissionId } = this.pendingPermission;
236
299
  const response = action === "confirm" ? "once" : "reject";
237
300
  try {
238
- const result = await this.client.permission.respond({
239
- sessionID: sessionId,
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(_answers) {
267
- return false;
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.suppressedTuiSessionSelectId = null;
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
- const response = await fetch(`${baseUrl}/session/status`);
462
- if (!response.ok) {
463
- throw new Error(`OpenCode health check failed (HTTP ${response.status}).`);
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
- this.sseLoopPromise = Promise.all([
720
+ const sseLoops = [
572
721
  this.runSseLoop("event"),
573
722
  this.runSseLoop("global-event"),
574
- this.runSseLoop("global-sync"),
575
- ]).then(() => undefined);
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
- case "permission.updated":
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" && this.state.status !== "awaiting_approval") {
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" && this.state.status !== "awaiting_approval") {
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: string, status: { type: "busy" | "idle" | ... } }
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.respond({
909
- sessionID: pendingPermission.sessionId,
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
- response: "reject",
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 (sessionId === this.suppressedTuiSessionSelectId) {
1382
- this.suppressedTuiSessionSelectId = null;
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 === this.suppressedTuiSessionSelectId)) {
2235
+ (!options.force && this.isTuiSessionSelectSuppressed(session.id))) {
1929
2236
  return;
1930
2237
  }
1931
- this.suppressedTuiSessionSelectId = session.id;
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.suppressedTuiSessionSelectId === session.id) {
1941
- this.suppressedTuiSessionSelectId = null;
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, "\\").replace(/\\$/, "").toLowerCase();
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 || previousText.endsWith(nextChunk)) {
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 < cutoff) {
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 && previous.streamName !== streamName && previous.observedAtMs >= cutoff);
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
  }