cli-wechat-bridge 1.1.2 → 1.1.3

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.
@@ -1,5 +1,5 @@
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 { 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
3
  import { killProcessTreeSync } from "./bridge-process-reaper.js";
4
4
  import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, buildOneTimeCode, isWechatOutboundAttachmentMutationTool, isWechatOutboundAttachmentWriteCommand, normalizeOutput, nowIso, truncatePreview, OutputBatcher, } from "./bridge-utils.js";
5
5
  const OPENCODE_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.WECHAT_OPENCODE_DEBUG ?? "");
@@ -8,6 +8,8 @@ const OPENCODE_WECHAT_MIRROR_SUPPRESSION_TTL_MS = 30_000;
8
8
  const OPENCODE_RECENT_LOCAL_PROMPT_TTL_MS = 10_000;
9
9
  const OPENCODE_LOCAL_SESSION_CREATE_FOLLOW_TTL_MS = 5_000;
10
10
  const OPENCODE_TUI_SELECT_RETRY_DELAYS_MS = [250, 750, 1_500];
11
+ const OPENCODE_MINIMUM_SUPPORTED_VERSION = "1.18.0";
12
+ const OPENCODE_MAXIMUM_SUPPORTED_MAJOR = 2;
11
13
  /* ------------------------------------------------------------------ */
12
14
  /* Adapter */
13
15
  /* ------------------------------------------------------------------ */
@@ -53,6 +55,7 @@ export class OpenCodeServerAdapter {
53
55
  lastMirroredLocalPrompt = null;
54
56
  pendingLocalSessionCreateFollowUntilMs = 0;
55
57
  pendingPermission = null;
58
+ pendingQuestion = null;
56
59
  constructor(options) {
57
60
  this.options = options;
58
61
  this.state = {
@@ -117,6 +120,9 @@ export class OpenCodeServerAdapter {
117
120
  if (this.pendingPermission) {
118
121
  throw new Error("An OpenCode approval request is pending. Reply with /confirm or /deny.");
119
122
  }
123
+ if (this.pendingQuestion) {
124
+ throw new Error("OpenCode is waiting for user input. Reply with /answer or use /stop.");
125
+ }
120
126
  const normalized = normalizeOutput(text).trim();
121
127
  if (!normalized) {
122
128
  return;
@@ -170,6 +176,9 @@ export class OpenCodeServerAdapter {
170
176
  if (this.pendingPermission) {
171
177
  throw new Error("An OpenCode approval request is pending. Reply with /confirm or /deny.");
172
178
  }
179
+ if (this.pendingQuestion) {
180
+ throw new Error("OpenCode is waiting for user input. Reply with /answer or use /stop.");
181
+ }
173
182
  this.outputBatcher.clear();
174
183
  this.clearStreamedPartState();
175
184
  const session = this.unwrapOrThrow(await this.client.session.create({
@@ -190,10 +199,16 @@ export class OpenCodeServerAdapter {
190
199
  if (!this.client || !this.activeSessionId) {
191
200
  return false;
192
201
  }
193
- if (this.state.status !== "busy" && this.state.status !== "awaiting_approval") {
202
+ if (this.state.status !== "busy" &&
203
+ this.state.status !== "awaiting_approval" &&
204
+ this.state.status !== "awaiting_input") {
194
205
  return false;
195
206
  }
196
207
  this.clearWechatWorkingNotice(true);
208
+ if (this.pendingQuestion) {
209
+ await this.rejectPendingQuestion(this.pendingQuestion);
210
+ this.clearPendingQuestionState();
211
+ }
197
212
  try {
198
213
  await this.client.session.abort({
199
214
  sessionID: this.activeSessionId,
@@ -214,6 +229,7 @@ export class OpenCodeServerAdapter {
214
229
  this.clearObservedMessageTracking();
215
230
  this.recentSdkEventObservations.clear();
216
231
  this.clearPendingPermissionState();
232
+ this.clearPendingQuestionState();
217
233
  this.activeSessionId = null;
218
234
  this.state.sharedSessionId = undefined;
219
235
  this.state.sharedThreadId = undefined;
@@ -232,15 +248,14 @@ export class OpenCodeServerAdapter {
232
248
  if (!this.pendingPermission || !this.client) {
233
249
  return false;
234
250
  }
235
- const { sessionId, permissionId } = this.pendingPermission;
251
+ const { permissionId } = this.pendingPermission;
236
252
  const response = action === "confirm" ? "once" : "reject";
237
253
  try {
238
- const result = await this.client.permission.respond({
239
- sessionID: sessionId,
240
- permissionID: permissionId,
254
+ const result = await this.client.permission.reply({
255
+ requestID: permissionId,
241
256
  directory: this.options.cwd,
242
257
  workspace: this.activeWorkspaceId ?? undefined,
243
- response,
258
+ reply: response,
244
259
  });
245
260
  if (result.error !== undefined) {
246
261
  throw new Error(`SDK error: ${describeUnknownError(result.error)}`);
@@ -263,8 +278,34 @@ export class OpenCodeServerAdapter {
263
278
  const ok = await this.resolveApproval(action);
264
279
  return ok ? 1 : 0;
265
280
  }
266
- async submitUserInput(_answers) {
267
- return false;
281
+ async submitUserInput(answers) {
282
+ if (!this.pendingQuestion || !this.client) {
283
+ return false;
284
+ }
285
+ const pending = this.pendingQuestion;
286
+ const orderedAnswers = pending.request.questions.map((question) => answers[question.id] ?? []);
287
+ try {
288
+ const result = await this.client.question.reply({
289
+ requestID: pending.requestId,
290
+ directory: this.options.cwd,
291
+ workspace: this.activeWorkspaceId ?? undefined,
292
+ answers: orderedAnswers,
293
+ });
294
+ if (result.error !== undefined) {
295
+ throw new Error(`SDK error: ${describeUnknownError(result.error)}`);
296
+ }
297
+ }
298
+ catch (err) {
299
+ this.emit({
300
+ type: "stderr",
301
+ text: `Failed to submit OpenCode user input: ${describeUnknownError(err)}`,
302
+ timestamp: nowIso(),
303
+ });
304
+ return false;
305
+ }
306
+ this.clearPendingQuestionState();
307
+ this.setStatus("busy", "OpenCode user input submitted.");
308
+ return true;
268
309
  }
269
310
  async dispose() {
270
311
  this.shuttingDown = true;
@@ -277,6 +318,7 @@ export class OpenCodeServerAdapter {
277
318
  this.outputBatcher.clear();
278
319
  this.clearStreamedPartState();
279
320
  this.clearPendingPermissionState();
321
+ this.clearPendingQuestionState();
280
322
  // Stop SSE listener
281
323
  if (this.sseAbortController) {
282
324
  this.sseAbortController.abort();
@@ -458,10 +500,54 @@ export class OpenCodeServerAdapter {
458
500
  }
459
501
  async checkHealth() {
460
502
  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}).`);
503
+ // `opencode serve` binds its TCP port before the HTTP layer is ready,
504
+ // so a fetch fired the instant `waitForTcpPort` returns can be accepted
505
+ // into the listen backlog but never answered, hanging until undici's
506
+ // ~300s `headersTimeout` and surfacing as `fetch failed` after ~5min.
507
+ // Retry with a short per-attempt timeout so the startup race
508
+ // self-heals once the server finishes booting.
509
+ const deadline = Date.now() + OPENCODE_HTTP_READY_TIMEOUT_MS;
510
+ let lastError;
511
+ while (Date.now() < deadline) {
512
+ try {
513
+ const response = await fetch(`${baseUrl}/global/health`, {
514
+ signal: AbortSignal.timeout(OPENCODE_HTTP_READY_PROBE_TIMEOUT_MS),
515
+ });
516
+ if (response.ok) {
517
+ const health = await response.json();
518
+ if (!isRecord(health) || health.healthy !== true || typeof health.version !== "string") {
519
+ lastError = new Error("OpenCode health check returned an invalid response.");
520
+ }
521
+ else if (!this.isSupportedOpenCodeVersion(health.version)) {
522
+ throw new Error(`Unsupported OpenCode version ${health.version}. Install OpenCode >=${OPENCODE_MINIMUM_SUPPORTED_VERSION} <${OPENCODE_MAXIMUM_SUPPORTED_MAJOR}.0.0.`);
523
+ }
524
+ else {
525
+ return;
526
+ }
527
+ }
528
+ else {
529
+ lastError = new Error(`OpenCode health check failed (HTTP ${response.status}).`);
530
+ }
531
+ }
532
+ catch (err) {
533
+ if (err instanceof Error &&
534
+ err.message.startsWith("Unsupported OpenCode version ")) {
535
+ throw err;
536
+ }
537
+ lastError = err;
538
+ }
539
+ await delay(OPENCODE_HTTP_READY_PROBE_INTERVAL_MS);
464
540
  }
541
+ throw lastError ?? new Error("OpenCode health check timed out.");
542
+ }
543
+ isSupportedOpenCodeVersion(version) {
544
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim());
545
+ if (!match) {
546
+ return false;
547
+ }
548
+ const major = Number(match[1]);
549
+ const minor = Number(match[2]);
550
+ return major === 1 && minor >= 18;
465
551
  }
466
552
  async initializeSessions() {
467
553
  if (!this.client) {
@@ -568,11 +654,11 @@ export class OpenCodeServerAdapter {
568
654
  return;
569
655
  }
570
656
  this.sseAbortController = new AbortController();
571
- this.sseLoopPromise = Promise.all([
657
+ const sseLoops = [
572
658
  this.runSseLoop("event"),
573
659
  this.runSseLoop("global-event"),
574
- this.runSseLoop("global-sync"),
575
- ]).then(() => undefined);
660
+ ];
661
+ this.sseLoopPromise = Promise.all(sseLoops).then(() => undefined);
576
662
  }
577
663
  async runSseLoop(streamName) {
578
664
  while (!this.shuttingDown) {
@@ -607,9 +693,6 @@ export class OpenCodeServerAdapter {
607
693
  }
608
694
  subscribeToSseStream(streamName) {
609
695
  const signal = this.sseAbortController?.signal;
610
- if (streamName === "global-sync") {
611
- return this.client.global.syncEvent.subscribe({ signal });
612
- }
613
696
  if (streamName === "global-event") {
614
697
  return this.client.global.event({ signal });
615
698
  }
@@ -623,10 +706,16 @@ export class OpenCodeServerAdapter {
623
706
  return null;
624
707
  }
625
708
  if (typeof rawEvent.type === "string") {
709
+ if (rawEvent.type === "sync") {
710
+ return this.normalizeGlobalSyncPayload(rawEvent, rawEvent);
711
+ }
626
712
  return rawEvent;
627
713
  }
628
714
  const payload = rawEvent.payload;
629
715
  if (isRecord(payload) && typeof payload.type === "string") {
716
+ if (payload.type === "sync") {
717
+ return this.normalizeGlobalSyncPayload(payload, rawEvent);
718
+ }
630
719
  const normalized = { ...payload };
631
720
  if (typeof rawEvent.directory === "string") {
632
721
  normalized.directory = rawEvent.directory;
@@ -635,6 +724,17 @@ export class OpenCodeServerAdapter {
635
724
  }
636
725
  return null;
637
726
  }
727
+ normalizeGlobalSyncPayload(payload, envelope) {
728
+ const syncEvent = isRecord(payload.syncEvent) ? payload.syncEvent : undefined;
729
+ if (!syncEvent || typeof syncEvent.type !== "string") {
730
+ return null;
731
+ }
732
+ const normalized = { ...syncEvent };
733
+ if (typeof envelope.directory === "string") {
734
+ normalized.directory = envelope.directory;
735
+ }
736
+ return normalized;
737
+ }
638
738
  shouldHandleSseEvent(event, streamName) {
639
739
  if (streamName === "event") {
640
740
  return true;
@@ -647,7 +747,10 @@ export class OpenCodeServerAdapter {
647
747
  type !== "command.executed" &&
648
748
  type !== "session.created" &&
649
749
  type !== "session.updated" &&
650
- type !== "session.deleted") {
750
+ type !== "session.deleted" &&
751
+ type !== "question.asked" &&
752
+ type !== "question.replied" &&
753
+ type !== "question.rejected") {
651
754
  return false;
652
755
  }
653
756
  if (this.isImplicitLocalCompanionUiEvent(type)) {
@@ -656,13 +759,6 @@ export class OpenCodeServerAdapter {
656
759
  return (this.matchesCurrentDirectoryEvent(event) ||
657
760
  this.shouldAcceptUnscopedLocalSessionCreatedEvent(event));
658
761
  }
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
762
  return false;
667
763
  }
668
764
  isImplicitLocalCompanionUiEvent(type) {
@@ -701,6 +797,15 @@ export class OpenCodeServerAdapter {
701
797
  this.handlePermissionRequest(payload);
702
798
  return;
703
799
  }
800
+ case "question.asked": {
801
+ this.handleQuestionAsked(payload);
802
+ return;
803
+ }
804
+ case "question.replied":
805
+ case "question.rejected": {
806
+ this.handleQuestionSettled(payload);
807
+ return;
808
+ }
704
809
  case "session.created": {
705
810
  this.handleSessionCreated(payload);
706
811
  return;
@@ -762,18 +867,23 @@ export class OpenCodeServerAdapter {
762
867
  if (!this.syncTrackedSessionFromEvent(sessionId, { allowLocalTurnFollow: false })) {
763
868
  return;
764
869
  }
765
- if (this.state.status !== "busy" && this.state.status !== "awaiting_approval") {
870
+ if (this.state.status !== "busy" &&
871
+ this.state.status !== "awaiting_approval" &&
872
+ this.state.status !== "awaiting_input") {
766
873
  return;
767
874
  }
768
875
  // Wait a short settle time before emitting task_complete,
769
876
  // in case more events follow the idle signal.
770
877
  setTimeout(() => {
771
- if (this.state.status !== "busy" && this.state.status !== "awaiting_approval") {
878
+ if (this.state.status !== "busy" &&
879
+ this.state.status !== "awaiting_approval" &&
880
+ this.state.status !== "awaiting_input") {
772
881
  return;
773
882
  }
774
883
  this.clearWechatWorkingNotice(true);
775
884
  this.pendingLocalPrompt = "";
776
885
  this.clearPendingPermissionState();
886
+ this.clearPendingQuestionState();
777
887
  this.state.activeTurnOrigin = undefined;
778
888
  this.hasAcceptedInput = false;
779
889
  const completedPreview = this.currentPreview;
@@ -874,6 +984,114 @@ export class OpenCodeServerAdapter {
874
984
  this.state.pendingApproval = null;
875
985
  this.state.pendingApprovalOrigin = undefined;
876
986
  }
987
+ handleQuestionAsked(properties) {
988
+ if (!isRecord(properties) || !this.client) {
989
+ return;
990
+ }
991
+ const sessionId = this.extractSessionId(properties);
992
+ if (!sessionId || !this.syncTrackedSessionFromEvent(sessionId)) {
993
+ return;
994
+ }
995
+ const requestId = typeof properties.id === "string" ? properties.id : null;
996
+ if (!requestId || !Array.isArray(properties.questions)) {
997
+ return;
998
+ }
999
+ const questions = properties.questions.flatMap((candidate, index) => {
1000
+ if (!isRecord(candidate)) {
1001
+ return [];
1002
+ }
1003
+ const header = typeof candidate.header === "string" ? candidate.header.trim() : "";
1004
+ const question = typeof candidate.question === "string" ? candidate.question.trim() : "";
1005
+ if (!header || !question) {
1006
+ return [];
1007
+ }
1008
+ const options = Array.isArray(candidate.options)
1009
+ ? candidate.options.flatMap((option) => {
1010
+ if (!isRecord(option) || typeof option.label !== "string") {
1011
+ return [];
1012
+ }
1013
+ return [{
1014
+ label: option.label,
1015
+ description: typeof option.description === "string" ? option.description : "",
1016
+ }];
1017
+ })
1018
+ : [];
1019
+ return [{
1020
+ id: `question_${index + 1}`,
1021
+ header,
1022
+ question,
1023
+ isOther: candidate.custom === true,
1024
+ isSecret: false,
1025
+ multiple: candidate.multiple === true,
1026
+ customAnswerMode: "value",
1027
+ options,
1028
+ }];
1029
+ });
1030
+ if (questions.length !== properties.questions.length || questions.length === 0) {
1031
+ return;
1032
+ }
1033
+ const request = {
1034
+ summary: `OpenCode requested answers to ${questions.length} question${questions.length === 1 ? "" : "s"}.`,
1035
+ questions,
1036
+ };
1037
+ this.pendingQuestion = {
1038
+ sessionId,
1039
+ requestId,
1040
+ request,
1041
+ };
1042
+ this.state.pendingUserInput = request;
1043
+ this.state.pendingUserInputOrigin = this.state.activeTurnOrigin;
1044
+ this.clearWechatWorkingNotice();
1045
+ this.setStatus("awaiting_input", "OpenCode is waiting for user input.");
1046
+ this.emit({
1047
+ type: "user_input_required",
1048
+ request,
1049
+ timestamp: nowIso(),
1050
+ });
1051
+ }
1052
+ handleQuestionSettled(properties) {
1053
+ if (!isRecord(properties) || !this.pendingQuestion) {
1054
+ return;
1055
+ }
1056
+ const sessionId = this.extractSessionId(properties);
1057
+ const requestId = typeof properties.requestID === "string" ? properties.requestID : null;
1058
+ if (sessionId !== this.pendingQuestion.sessionId ||
1059
+ requestId !== this.pendingQuestion.requestId) {
1060
+ return;
1061
+ }
1062
+ this.clearPendingQuestionState();
1063
+ if (this.state.status === "awaiting_input") {
1064
+ this.setStatus("busy", "OpenCode user input resolved.");
1065
+ }
1066
+ }
1067
+ clearPendingQuestionState() {
1068
+ this.pendingQuestion = null;
1069
+ this.state.pendingUserInput = null;
1070
+ this.state.pendingUserInputOrigin = undefined;
1071
+ }
1072
+ async rejectPendingQuestion(pending) {
1073
+ const client = this.client;
1074
+ if (!client) {
1075
+ return;
1076
+ }
1077
+ try {
1078
+ const result = await client.question.reject({
1079
+ requestID: pending.requestId,
1080
+ directory: this.options.cwd,
1081
+ workspace: this.activeWorkspaceId ?? undefined,
1082
+ });
1083
+ if (result.error !== undefined) {
1084
+ throw new Error(`SDK error: ${describeUnknownError(result.error)}`);
1085
+ }
1086
+ }
1087
+ catch (err) {
1088
+ this.emit({
1089
+ type: "stderr",
1090
+ text: `Failed to reject OpenCode user input: ${describeUnknownError(err)}`,
1091
+ timestamp: nowIso(),
1092
+ });
1093
+ }
1094
+ }
877
1095
  getWechatOutboundAttachmentPermissionDenyMessage(properties, pendingPermission) {
878
1096
  const metadata = isRecord(properties.metadata) ? properties.metadata : {};
879
1097
  const command = this.extractPermissionCommand(properties, metadata);
@@ -905,12 +1123,11 @@ export class OpenCodeServerAdapter {
905
1123
  }
906
1124
  void (async () => {
907
1125
  try {
908
- const result = await client.permission.respond({
909
- sessionID: pendingPermission.sessionId,
910
- permissionID: pendingPermission.permissionId,
1126
+ const result = await client.permission.reply({
1127
+ requestID: pendingPermission.permissionId,
911
1128
  directory: this.options.cwd,
912
1129
  workspace: this.activeWorkspaceId ?? undefined,
913
- response: "reject",
1130
+ reply: "reject",
914
1131
  });
915
1132
  if (result.error !== undefined) {
916
1133
  throw new Error(`SDK error: ${describeUnknownError(result.error)}`);
@@ -1661,8 +1878,10 @@ export class OpenCodeServerAdapter {
1661
1878
  hasTrackedTurnState() {
1662
1879
  return (this.state.status === "busy" ||
1663
1880
  this.state.status === "awaiting_approval" ||
1881
+ this.state.status === "awaiting_input" ||
1664
1882
  this.hasAcceptedInput ||
1665
1883
  this.pendingPermission !== null ||
1884
+ this.pendingQuestion !== null ||
1666
1885
  this.state.activeTurnOrigin !== undefined ||
1667
1886
  this.currentPreview !== "(idle)");
1668
1887
  }
@@ -1671,6 +1890,7 @@ export class OpenCodeServerAdapter {
1671
1890
  this.pendingLocalPrompt = "";
1672
1891
  this.localPromptNoticeSent = false;
1673
1892
  this.clearPendingPermissionState();
1893
+ this.clearPendingQuestionState();
1674
1894
  this.state.activeTurnOrigin = undefined;
1675
1895
  this.hasAcceptedInput = false;
1676
1896
  this.currentPreview = "(idle)";
@@ -2241,6 +2461,15 @@ export class OpenCodeServerAdapter {
2241
2461
  const sessionId = this.extractSessionId(payload);
2242
2462
  return sessionId ? `${type}:${sessionId}` : null;
2243
2463
  }
2464
+ case "question.asked": {
2465
+ const requestId = typeof payload.id === "string" ? payload.id : undefined;
2466
+ return requestId ? `${type}:${requestId}` : null;
2467
+ }
2468
+ case "question.replied":
2469
+ case "question.rejected": {
2470
+ const requestId = typeof payload.requestID === "string" ? payload.requestID : undefined;
2471
+ return requestId ? `${type}:${requestId}` : null;
2472
+ }
2244
2473
  default:
2245
2474
  return null;
2246
2475
  }