@unlinearity/cli-wechat-bridge 1.1.3 → 1.1.5

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 (51) hide show
  1. package/README.md +110 -140
  2. package/bin/wechat-claude.mjs +1 -1
  3. package/bin/wechat-codex.mjs +1 -1
  4. package/bin/wechat-opencode.mjs +1 -1
  5. package/bin/wechat-pi.mjs +1 -1
  6. package/dist/bridge/bridge-adapters.claude.js +448 -11
  7. package/dist/bridge/bridge-adapters.codex.js +262 -11
  8. package/dist/bridge/bridge-adapters.core.js +76 -4
  9. package/dist/bridge/bridge-adapters.js +0 -3
  10. package/dist/bridge/bridge-adapters.opencode.js +497 -49
  11. package/dist/bridge/bridge-adapters.pi.js +44 -8
  12. package/dist/bridge/bridge-adapters.shared.js +13 -100
  13. package/dist/bridge/bridge-controller.js +3 -0
  14. package/dist/bridge/bridge-final-reply.js +17 -2
  15. package/dist/bridge/bridge-process-reaper.js +14 -4
  16. package/dist/bridge/bridge-session-resume.js +151 -0
  17. package/dist/bridge/bridge-state.js +41 -6
  18. package/dist/bridge/bridge-utils.js +63 -229
  19. package/dist/bridge/claude-hooks.js +1 -0
  20. package/dist/bridge/wechat-bridge.js +93 -140
  21. package/dist/bridge/wechat-forwarding.js +58 -0
  22. package/dist/companion/codex-remote-client.js +309 -36
  23. package/dist/companion/codex-visible-client-link.js +122 -0
  24. package/dist/companion/local-companion-link.js +33 -2
  25. package/dist/companion/local-companion-start.js +44 -13
  26. package/dist/companion/local-companion.js +6 -3
  27. package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
  28. package/dist/companion/pi-tui-bridge-extension.js +42 -3
  29. package/dist/daemon/daemon-link.js +25 -3
  30. package/dist/daemon/wechat-daemon.js +168 -56
  31. package/dist/i18n/index.js +0 -3
  32. package/dist/i18n/messages-en.js +2 -3
  33. package/dist/i18n/messages-zh.js +2 -3
  34. package/dist/utils/atomic-file.js +15 -0
  35. package/dist/utils/doctor.js +23 -11
  36. package/dist/wechat/channel-config.js +0 -2
  37. package/dist/wechat/setup.js +5 -11
  38. package/dist/wechat/wechat-transport.js +14 -8
  39. package/package.json +5 -12
  40. package/scripts/ensure-node-pty-permissions.mjs +73 -0
  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-pi.mjs +0 -5
  45. package/bin/wechat-bridge-shell.mjs +0 -5
  46. package/bin/wechat-bridge.mjs +0 -5
  47. package/bin/wechat-claude-start.mjs +0 -5
  48. package/bin/wechat-codex-start.mjs +0 -5
  49. package/bin/wechat-opencode-start.mjs +0 -5
  50. package/bin/wechat-pi-start.mjs +0 -5
  51. package/dist/bridge/bridge-adapters.shell.js +0 -363
@@ -6,11 +6,53 @@ import { detectCliApproval, normalizeOutput, nowIso, truncatePreview, } from "./
6
6
  import { AbstractPtyAdapter } from "./bridge-adapters.core.js";
7
7
  import { killProcessTreeSync } from "./bridge-process-reaper.js";
8
8
  import * as shared from "./bridge-adapters.shared.js";
9
+ import { requestCodexVisibleThreadSwitch } from "../companion/codex-visible-client-link.js";
9
10
  import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
10
11
  import { CODEX_REMOTE_AUTH_TOKEN_ENV, LOCAL_CLIENT_PROTOCOL_VERSION, } from "../runtime/runtime-types.js";
11
12
  const { CODEX_APP_SERVER_HOST, CODEX_APP_SERVER_READY_TIMEOUT_MS, CODEX_FINAL_REPLY_SETTLE_DELAY_MS, CODEX_RECENT_SESSION_KEY_LIMIT, CODEX_RPC_CONNECT_RETRY_MS, CODEX_RPC_RECONNECT_TIMEOUT_MS, CODEX_SESSION_FALLBACK_SCAN_INTERVAL_MS, CODEX_SESSION_LOCAL_MIRROR_FALLBACK_WINDOW_MS, CODEX_SESSION_POLL_INTERVAL_MS, CODEX_STARTUP_WARMUP_MS, CODEX_THREAD_SIGNAL_TTL_MS, INTERRUPT_SETTLE_DELAY_MS, appendBoundedLog, buildCodexApprovalRequest, buildCodexCliArgs, buildCodexDynamicToolCallFailureResponse, buildCodexMcpServerElicitationDeclineResponse, buildCodexPermissionsRequestApprovalResponse, buildCodexUserInputRequest, coerceWebSocketMessageData, delay, describeUnknownError, extractCodexFinalTextFromItem, extractCodexThreadFollowIdFromStatusChanged, extractCodexThreadStartedThreadId, extractCodexUserMessageText, findCodexSessionFile, findRecentCodexSessionFileForCwd, getCodexRpcRequestId, getCodexApprovalAutoResponse, getCodexWechatOutboundAttachmentDenyMessage, getNotificationThreadId, getNotificationTurnId, isRecord, isRecentIsoTimestamp, listCodexResumeSessions, normalizeComparablePath, normalizeCodexRpcError, reserveLocalPort, resolveSpawnTarget, shouldAutoCompleteCodexWechatTurnAfterFinalReply, shouldIgnoreCodexSessionReplayEntry, shouldRecoverCodexStaleBusyState, waitForTcpPort, } = shared;
12
13
  const CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS = 150;
13
14
  const CODEX_NON_BLOCKING_USER_INPUT_TIMEOUT_MS = 120_000;
15
+ function getCodexThreadStatusType(value) {
16
+ if (typeof value === "string") {
17
+ return value;
18
+ }
19
+ return isRecord(value) && typeof value.type === "string" ? value.type : null;
20
+ }
21
+ function getCodexThreadTitle(thread) {
22
+ const name = typeof thread.name === "string" ? normalizeOutput(thread.name).trim() : "";
23
+ const preview = typeof thread.preview === "string" ? normalizeOutput(thread.preview).trim() : "";
24
+ const threadId = typeof thread.id === "string" ? thread.id : "";
25
+ return truncatePreview(name || preview || `Codex thread ${threadId.slice(0, 8)}`, 120);
26
+ }
27
+ export function buildCodexResumeCandidatesFromThreadList(response, cwd, limit) {
28
+ if (!isRecord(response) || !Array.isArray(response.data)) {
29
+ throw new Error("Codex returned an invalid thread/list response.");
30
+ }
31
+ const currentCwd = normalizeComparablePath(cwd);
32
+ return response.data
33
+ .filter((thread) => isRecord(thread))
34
+ .filter((thread) => typeof thread.id === "string")
35
+ .filter((thread) => {
36
+ const threadCwd = typeof thread.cwd === "string" ? thread.cwd : "";
37
+ return threadCwd && normalizeComparablePath(threadCwd) === currentCwd;
38
+ })
39
+ .filter((thread) => typeof thread.parentThreadId !== "string")
40
+ .map((thread) => {
41
+ const updatedAt = typeof thread.updatedAt === "number" && Number.isFinite(thread.updatedAt)
42
+ ? thread.updatedAt * 1_000
43
+ : 0;
44
+ const threadId = thread.id;
45
+ return {
46
+ sessionId: threadId,
47
+ threadId,
48
+ title: getCodexThreadTitle(thread),
49
+ lastUpdatedAt: new Date(updatedAt).toISOString(),
50
+ source: "codex",
51
+ };
52
+ })
53
+ .sort((left, right) => Date.parse(right.lastUpdatedAt) - Date.parse(left.lastUpdatedAt))
54
+ .slice(0, Math.max(1, limit));
55
+ }
14
56
  export class CodexPtyAdapter extends AbstractPtyAdapter {
15
57
  runtimeKind = "codex_runtime_host";
16
58
  appServer = null;
@@ -37,6 +79,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
37
79
  pendingTurnThreadId = null;
38
80
  interruptPendingTurnStart = false;
39
81
  pendingThreadFollowId = null;
82
+ pendingVisibleResume = null;
40
83
  pendingApprovalRequests = [];
41
84
  pendingUserInputRequest = null;
42
85
  pendingUserInputTimer = null;
@@ -165,13 +208,16 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
165
208
  this.writeToPty("\r");
166
209
  }
167
210
  async listResumeSessions(limit = 10) {
211
+ if (this.usesRpcTurnTransport()) {
212
+ return await this.listAppServerResumeSessions(limit);
213
+ }
168
214
  return listCodexResumeSessions(this.options.cwd, limit);
169
215
  }
170
216
  async resumeSession(threadId) {
171
- if (this.isNativePanelMode()) {
172
- throw new Error('WeChat /resume is disabled in codex mode. Use /resume directly inside "wechat-codex"; WeChat will follow the active local thread.');
217
+ if (!this.isHeadlessRuntimeMode()) {
218
+ throw new Error('WeChat /resume requires the managed "wechat-codex" visible client.');
173
219
  }
174
- await this.resumeSharedThread(threadId);
220
+ await this.resumeVisibleSharedThread(threadId);
175
221
  }
176
222
  async interrupt() {
177
223
  if (this.usesRpcTurnTransport()) {
@@ -292,6 +338,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
292
338
  profile: this.options.profile,
293
339
  sharedSessionId: this.state.sharedSessionId,
294
340
  sharedThreadId: this.state.sharedThreadId,
341
+ codexVisibleThreadId: this.state.sharedThreadId,
295
342
  resumeConversationId: this.state.resumeConversationId,
296
343
  transcriptPath: this.state.transcriptPath,
297
344
  startedAt: this.state.startedAt ?? nowIso(),
@@ -610,7 +657,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
610
657
  if (this.sessionFilePath && candidate.modifiedAtMs <= currentSessionModifiedAtMs) {
611
658
  return;
612
659
  }
613
- if (!this.activeTurn || this.activeTurn.threadId === candidate.threadId) {
660
+ if (!this.activeTurn ||
661
+ this.activeTurn.threadId === candidate.threadId ||
662
+ this.activeTurn.origin === "wechat") {
614
663
  this.trackLocalSharedThread(candidate.threadId, {
615
664
  reason: "local_session_fallback",
616
665
  signal: "session_fallback",
@@ -1186,6 +1235,14 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1186
1235
  Date.now() < reconnectDeadline) {
1187
1236
  try {
1188
1237
  await this.connectRpcClient();
1238
+ // The new connection starts with an empty subscription set.
1239
+ // Resubscribe the shared thread so turn notifications and approval
1240
+ // requests keep flowing: without this, a mid-turn reconnect
1241
+ // silently stops delivery, and approvals answered over the new
1242
+ // connection (with the old request id) are ignored by the server.
1243
+ if (this.state.sharedThreadId) {
1244
+ await this.tryEnsureSharedThreadSubscribed(this.state.sharedThreadId);
1245
+ }
1189
1246
  return true;
1190
1247
  }
1191
1248
  catch (error) {
@@ -1308,6 +1365,147 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1308
1365
  this.updateSharedThread(resumedThreadId);
1309
1366
  }
1310
1367
  }
1368
+ async listAppServerResumeSessions(limit) {
1369
+ const boundedLimit = Math.max(1, Math.min(100, limit));
1370
+ const baseParams = {
1371
+ limit: boundedLimit,
1372
+ sortKey: "updated_at",
1373
+ sortDirection: "desc",
1374
+ sourceKinds: ["cli", "vscode"],
1375
+ archived: false,
1376
+ cwd: this.options.cwd,
1377
+ };
1378
+ try {
1379
+ const fastResponse = await this.sendRpcRequest("thread/list", {
1380
+ ...baseParams,
1381
+ useStateDbOnly: true,
1382
+ });
1383
+ const fastCandidates = buildCodexResumeCandidatesFromThreadList(fastResponse, this.options.cwd, boundedLimit);
1384
+ if (fastCandidates.length > 0) {
1385
+ return fastCandidates;
1386
+ }
1387
+ }
1388
+ catch {
1389
+ // Older supported app-server builds may not understand useStateDbOnly.
1390
+ // The scan-and-repair request below is also the authoritative fallback.
1391
+ }
1392
+ const response = await this.sendRpcRequest("thread/list", baseParams);
1393
+ return buildCodexResumeCandidatesFromThreadList(response, this.options.cwd, boundedLimit);
1394
+ }
1395
+ validateCodexResumeThread(response, targetThreadId) {
1396
+ if (!isRecord(response) || !isRecord(response.thread)) {
1397
+ throw new Error("Codex returned an invalid thread response.");
1398
+ }
1399
+ const thread = response.thread;
1400
+ if (thread.id !== targetThreadId) {
1401
+ throw new Error(`Codex opened ${String(thread.id)}, expected ${targetThreadId}.`);
1402
+ }
1403
+ if (typeof thread.cwd !== "string" ||
1404
+ normalizeComparablePath(thread.cwd) !== normalizeComparablePath(this.options.cwd)) {
1405
+ throw new Error(`Codex thread ${targetThreadId} does not belong to ${this.options.cwd}.`);
1406
+ }
1407
+ if (typeof thread.parentThreadId === "string") {
1408
+ throw new Error(`Codex thread ${targetThreadId} is a subagent thread and cannot be resumed.`);
1409
+ }
1410
+ if (thread.ephemeral === true) {
1411
+ throw new Error(`Codex thread ${targetThreadId} is ephemeral and cannot be resumed.`);
1412
+ }
1413
+ if (getCodexThreadStatusType(thread.status) === "active") {
1414
+ throw new Error(`Codex thread ${targetThreadId} is still active. Stop its running task before resuming it from WeChat.`);
1415
+ }
1416
+ return thread;
1417
+ }
1418
+ async resumeVisibleSharedThread(threadId) {
1419
+ const targetThreadId = threadId.trim();
1420
+ if (!targetThreadId) {
1421
+ throw new Error("A thread id is required to resume a Codex thread.");
1422
+ }
1423
+ if (this.pendingVisibleResume) {
1424
+ throw new Error("Codex is already switching visible threads.");
1425
+ }
1426
+ if (this.pendingApproval || this.pendingApprovalRequests.length > 0) {
1427
+ throw new Error("A Codex approval request is pending. Reply with /confirm or /deny.");
1428
+ }
1429
+ if (this.pendingUserInputRequest || this.state.pendingUserInput) {
1430
+ throw new Error("Codex is waiting for user input. Reply with /answer or use /stop.");
1431
+ }
1432
+ if (this.pendingTurnStart ||
1433
+ this.activeTurn ||
1434
+ this.state.status === "busy" ||
1435
+ this.state.status === "awaiting_approval" ||
1436
+ this.state.status === "awaiting_input") {
1437
+ throw new Error("codex is still working. Wait for the current reply or use /stop.");
1438
+ }
1439
+ if (this.state.status !== "idle") {
1440
+ throw new Error(`Codex cannot switch threads while its status is ${this.state.status}.`);
1441
+ }
1442
+ const readResponse = await this.sendRpcRequest("thread/read", {
1443
+ threadId: targetThreadId,
1444
+ includeTurns: false,
1445
+ });
1446
+ this.validateCodexResumeThread(readResponse, targetThreadId);
1447
+ const previousThreadId = this.sharedThreadId;
1448
+ let targetSubscribed = false;
1449
+ this.pendingVisibleResume = { targetThreadId };
1450
+ this.setStatus("starting", `Switching Codex to thread ${targetThreadId.slice(0, 12)}...`);
1451
+ try {
1452
+ this.rememberBridgeOwnedThreadSignal(targetThreadId);
1453
+ const resumeResponse = await this.sendRpcRequest("thread/resume", {
1454
+ threadId: targetThreadId,
1455
+ approvalPolicy: "on-request",
1456
+ approvalsReviewer: "user",
1457
+ sandbox: "workspace-write",
1458
+ excludeTurns: true,
1459
+ });
1460
+ targetSubscribed = true;
1461
+ this.validateCodexResumeThread(resumeResponse, targetThreadId);
1462
+ this.subscribedThreadIds.add(targetThreadId);
1463
+ const visibleThreadId = await this.switchVisibleCodexThread(targetThreadId);
1464
+ if (visibleThreadId !== targetThreadId) {
1465
+ throw new Error(`The visible Codex client opened ${visibleThreadId}, expected ${targetThreadId}.`);
1466
+ }
1467
+ this.sessionFilePath = null;
1468
+ this.sessionReadOffset = 0;
1469
+ this.sessionPartialLine = "";
1470
+ this.sessionFinalText = null;
1471
+ this.pendingThreadFollowId = null;
1472
+ this.updateSharedThread(targetThreadId, {
1473
+ source: "wechat",
1474
+ reason: "wechat_resume",
1475
+ notify: true,
1476
+ });
1477
+ this.setStatus("idle");
1478
+ if (previousThreadId && previousThreadId !== targetThreadId) {
1479
+ await this.unsubscribeCodexThreadBestEffort(previousThreadId);
1480
+ }
1481
+ }
1482
+ catch (error) {
1483
+ if (targetSubscribed && targetThreadId !== previousThreadId) {
1484
+ await this.unsubscribeCodexThreadBestEffort(targetThreadId);
1485
+ }
1486
+ this.setStatus("idle");
1487
+ throw error;
1488
+ }
1489
+ finally {
1490
+ this.pendingVisibleResume = null;
1491
+ }
1492
+ }
1493
+ async switchVisibleCodexThread(threadId) {
1494
+ return await requestCodexVisibleThreadSwitch({
1495
+ cwd: this.options.cwd,
1496
+ instanceId: this.localClientInstanceId,
1497
+ threadId,
1498
+ });
1499
+ }
1500
+ async unsubscribeCodexThreadBestEffort(threadId) {
1501
+ try {
1502
+ await this.sendRpcRequest("thread/unsubscribe", { threadId });
1503
+ this.subscribedThreadIds.delete(threadId);
1504
+ }
1505
+ catch {
1506
+ // A failed unsubscribe must not undo an otherwise successful visible switch.
1507
+ }
1508
+ }
1311
1509
  async resumeSharedThread(threadId, options = {}) {
1312
1510
  const trimmedThreadId = threadId.trim();
1313
1511
  if (!trimmedThreadId) {
@@ -1394,7 +1592,20 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1394
1592
  this.state.status !== "awaiting_input") {
1395
1593
  return;
1396
1594
  }
1397
- this.resetTurnTracking({ preserveThread: true });
1595
+ // Remember the interrupted turn as completed before resetting: the
1596
+ // server may still deliver a late turn/completed for it, and without
1597
+ // this entry it would be tracked as a brand-new local turn — emitting a
1598
+ // duplicate task_complete (or a ghost reply) for work we already
1599
+ // reported as interrupted.
1600
+ const interruptedTurnId = this.activeTurn?.turnId;
1601
+ this.resetTurnTracking({
1602
+ preserveThread: true,
1603
+ preserveCompletedTurns: true,
1604
+ });
1605
+ if (interruptedTurnId) {
1606
+ this.rememberCompletedTurn(interruptedTurnId);
1607
+ this.bridgeOwnedTurnIds.add(interruptedTurnId);
1608
+ }
1398
1609
  this.setStatus("idle", "Codex task interrupted.");
1399
1610
  this.emit({
1400
1611
  type: "task_complete",
@@ -1463,9 +1674,11 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1463
1674
  this.turnErrorById.clear();
1464
1675
  this.turnLastActivityAtMs.clear();
1465
1676
  this.mirroredUserInputTurnIds.clear();
1466
- this.bridgeOwnedTurnIds.clear();
1467
- this.completedTurnIds.clear();
1468
- this.completedTurnOrder = [];
1677
+ if (!options.preserveCompletedTurns) {
1678
+ this.bridgeOwnedTurnIds.clear();
1679
+ this.completedTurnIds.clear();
1680
+ this.completedTurnOrder = [];
1681
+ }
1469
1682
  this.pendingInjectedInputs = [];
1470
1683
  this.recentBridgeThreadSignalAtById.clear();
1471
1684
  this.sessionFinalText = null;
@@ -1591,6 +1804,10 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1591
1804
  pending.timer.unref?.();
1592
1805
  }
1593
1806
  trackLocalSharedThread(threadId, options) {
1807
+ if (this.pendingVisibleResume) {
1808
+ return;
1809
+ }
1810
+ this.interruptWechatTurnForLocalThreadSwitch(threadId);
1594
1811
  if (!this.isNativePanelMode()) {
1595
1812
  this.updateSharedThread(threadId, {
1596
1813
  source: "local",
@@ -1632,6 +1849,34 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1632
1849
  }
1633
1850
  this.schedulePendingThreadAnnouncement();
1634
1851
  }
1852
+ interruptWechatTurnForLocalThreadSwitch(nextThreadId) {
1853
+ const activeTurn = this.activeTurn;
1854
+ if (!activeTurn ||
1855
+ activeTurn.origin !== "wechat" ||
1856
+ activeTurn.threadId === nextThreadId) {
1857
+ return;
1858
+ }
1859
+ const interruptedTurnId = activeTurn.turnId;
1860
+ void this.sendRpcRequest("turn/interrupt", {
1861
+ threadId: activeTurn.threadId,
1862
+ turnId: interruptedTurnId,
1863
+ }).catch(() => undefined);
1864
+ this.clearInterruptTimer();
1865
+ this.clearFinalReplyCompletionTimerForTurn(interruptedTurnId);
1866
+ this.clearPendingApprovalState();
1867
+ this.clearPendingUserInputState();
1868
+ this.pendingThreadFollowId = null;
1869
+ this.cleanupTurnArtifacts(interruptedTurnId);
1870
+ this.rememberCompletedTurn(interruptedTurnId);
1871
+ this.bridgeOwnedTurnIds.add(interruptedTurnId);
1872
+ this.setActiveTurn(null);
1873
+ this.setStatus("idle");
1874
+ this.emit({
1875
+ type: "task_failed",
1876
+ message: "The active WeChat task was interrupted because the local Codex terminal switched threads.",
1877
+ timestamp: nowIso(),
1878
+ });
1879
+ }
1635
1880
  rememberBridgeOwnedThreadSignal(threadId) {
1636
1881
  const cutoff = Date.now() - CODEX_THREAD_SIGNAL_TTL_MS;
1637
1882
  for (const [candidateThreadId, recordedAtMs] of this.recentBridgeThreadSignalAtById.entries()) {
@@ -2148,7 +2393,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2148
2393
  if (!threadId) {
2149
2394
  return;
2150
2395
  }
2151
- if (!this.activeTurn || this.activeTurn.threadId === threadId) {
2396
+ if (!this.activeTurn ||
2397
+ this.activeTurn.threadId === threadId ||
2398
+ this.activeTurn.origin === "wechat") {
2152
2399
  this.trackLocalSharedThread(threadId, {
2153
2400
  reason: "local_follow",
2154
2401
  signal: "status_changed",
@@ -2172,7 +2419,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2172
2419
  return;
2173
2420
  }
2174
2421
  }
2175
- if (!this.activeTurn || this.activeTurn.threadId === threadId) {
2422
+ if (!this.activeTurn ||
2423
+ this.activeTurn.threadId === threadId ||
2424
+ this.activeTurn.origin === "wechat") {
2176
2425
  this.trackLocalSharedThread(threadId, {
2177
2426
  reason: "local_follow",
2178
2427
  signal: "thread_started",
@@ -2188,7 +2437,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2188
2437
  }
2189
2438
  if (trackedTurn.origin === "local" &&
2190
2439
  trackedTurn.threadId !== this.sharedThreadId) {
2191
- if (!this.activeTurn || this.activeTurn.threadId === trackedTurn.threadId) {
2440
+ if (!this.activeTurn ||
2441
+ this.activeTurn.threadId === trackedTurn.threadId ||
2442
+ this.activeTurn.origin === "wechat") {
2192
2443
  this.trackLocalSharedThread(trackedTurn.threadId, {
2193
2444
  reason: "local_turn",
2194
2445
  signal: "turn_started",
@@ -45,6 +45,8 @@ export class LocalCompanionProxyAdapter {
45
45
  eventSink = () => undefined;
46
46
  server = null;
47
47
  socket = null;
48
+ // Pending senders waiting for the companion's first state frame.
49
+ readyWaiters = new Set();
48
50
  detachMessageListener = null;
49
51
  requestCounter = 0;
50
52
  endpoint = null;
@@ -99,11 +101,22 @@ export class LocalCompanionProxyAdapter {
99
101
  });
100
102
  this.server = server;
101
103
  server.on("error", (error) => {
104
+ // Roll back the half-initialized server: start() early-returns while
105
+ // this.server is set, so a leftover handle would make every retry
106
+ // silently "succeed" without ever listening.
107
+ if (this.server === server) {
108
+ this.server = null;
109
+ }
110
+ server.close();
102
111
  reject(error);
103
112
  });
104
113
  server.listen(0, CODEX_APP_SERVER_HOST, () => {
105
114
  const address = server.address();
106
115
  if (!address || typeof address === "string") {
116
+ if (this.server === server) {
117
+ this.server = null;
118
+ }
119
+ server.close();
107
120
  reject(new Error(`Failed to allocate a local ${this.options.kind} companion port.`));
108
121
  return;
109
122
  }
@@ -174,8 +187,17 @@ export class LocalCompanionProxyAdapter {
174
187
  const ok = await this.resolveApproval(action);
175
188
  return ok ? 1 : 0;
176
189
  }
177
- async submitUserInput(_answers) {
178
- return false;
190
+ async submitUserInput(answers) {
191
+ try {
192
+ const result = await this.sendRequest({
193
+ command: "submit_user_input",
194
+ answers,
195
+ });
196
+ return result === true;
197
+ }
198
+ catch {
199
+ return false;
200
+ }
179
201
  }
180
202
  async dispose() {
181
203
  this.shuttingDown = true;
@@ -310,6 +332,9 @@ export class LocalCompanionProxyAdapter {
310
332
  this.state.activeTurnOrigin = undefined;
311
333
  this.state.pendingApprovalOrigin = undefined;
312
334
  Object.assign(this.state, message.state);
335
+ if (this.state.pid) {
336
+ this.settleReadyWaiters();
337
+ }
313
338
  this.eventSink({
314
339
  type: "status",
315
340
  status: this.state.status,
@@ -332,6 +357,10 @@ export class LocalCompanionProxyAdapter {
332
357
  }
333
358
  }
334
359
  detachPanelSocket() {
360
+ this.settleReadyWaiters(new Error(formatCompanionNotConnectedMessage({
361
+ kind: this.options.kind,
362
+ launchMode: this.options.companionLaunchMode,
363
+ })));
335
364
  this.rejectPendingRequests("Companion socket disconnected unexpectedly.");
336
365
  this.detachMessageListener?.();
337
366
  this.detachMessageListener = null;
@@ -420,8 +449,44 @@ export class LocalCompanionProxyAdapter {
420
449
  }
421
450
  this.pendingRequests.clear();
422
451
  }
452
+ /**
453
+ * Wait for the companion's first state frame (which reports the worker
454
+ * pid). The companion sends it only after its adapter finished starting —
455
+ * for opencode that spans spawning `opencode serve`, connecting, and the
456
+ * initial session scan — so a WeChat message arriving inside that window
457
+ * must wait instead of failing with "connected but not ready yet".
458
+ */
459
+ waitForCompanionReady() {
460
+ if (this.state.pid) {
461
+ return Promise.resolve();
462
+ }
463
+ return new Promise((resolve, reject) => {
464
+ const entry = {
465
+ timer: setTimeout(() => {
466
+ this.readyWaiters.delete(entry);
467
+ reject(new Error(`${this.options.kind} companion is connected but not ready yet. Wait for it to finish starting.`));
468
+ }, 30_000),
469
+ settle: (error) => {
470
+ clearTimeout(entry.timer);
471
+ this.readyWaiters.delete(entry);
472
+ if (error) {
473
+ reject(error);
474
+ }
475
+ else {
476
+ resolve();
477
+ }
478
+ },
479
+ };
480
+ this.readyWaiters.add(entry);
481
+ });
482
+ }
483
+ settleReadyWaiters(error) {
484
+ for (const entry of [...this.readyWaiters]) {
485
+ entry.settle(error);
486
+ }
487
+ }
423
488
  async sendRequest(payload) {
424
- const socket = this.socket;
489
+ let socket = this.socket;
425
490
  if (!socket) {
426
491
  throw new Error(formatCompanionNotConnectedMessage({
427
492
  kind: this.options.kind,
@@ -429,7 +494,14 @@ export class LocalCompanionProxyAdapter {
429
494
  }));
430
495
  }
431
496
  if (!this.state.pid && payload.command !== "dispose") {
432
- throw new Error(`${this.options.kind} companion is connected but not ready yet. Wait for it to finish starting.`);
497
+ await this.waitForCompanionReady();
498
+ socket = this.socket;
499
+ if (!socket) {
500
+ throw new Error(formatCompanionNotConnectedMessage({
501
+ kind: this.options.kind,
502
+ launchMode: this.options.companionLaunchMode,
503
+ }));
504
+ }
433
505
  }
434
506
  const id = `${++this.requestCounter}`;
435
507
  const response = new Promise((resolve, reject) => {
@@ -3,7 +3,6 @@ import { LocalCompanionProxyAdapter } from "./bridge-adapters.core.js";
3
3
  import { CodexPtyAdapter } from "./bridge-adapters.codex.js";
4
4
  import { OpenCodeServerAdapter } from "./bridge-adapters.opencode.js";
5
5
  import { PiTuiAdapter } from "./bridge-adapters.pi.js";
6
- import { ShellAdapter } from "./bridge-adapters.shell.js";
7
6
  export * from "./bridge-adapters.shared.js";
8
7
  export function createBridgeAdapter(options) {
9
8
  switch (options.kind) {
@@ -23,8 +22,6 @@ export function createBridgeAdapter(options) {
23
22
  return options.renderMode === "companion"
24
23
  ? new PiTuiAdapter(options)
25
24
  : new LocalCompanionProxyAdapter(options);
26
- case "shell":
27
- return new ShellAdapter(options);
28
25
  default:
29
26
  throw new Error(`Unsupported adapter: ${options.kind}`);
30
27
  }