cli-wechat-bridge 1.0.6 → 1.0.9

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,6 @@
1
1
  import fs from "node:fs";
2
2
  import net from "node:net";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { buildLocalCompanionToken } from "../companion/local-companion-link.js";
5
6
  import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
@@ -14,6 +15,69 @@ const CLAUDE_COMPACT_DEDUP_MS = 2_000;
14
15
  const CLAUDE_BRACKETED_PASTE_START = "\u001b[200~";
15
16
  const CLAUDE_BRACKETED_PASTE_END = "\u001b[201~";
16
17
  const CLAUDE_REMOTE_ENTER_DELAY_MS = 40;
18
+ const CLAUDE_STARTUP_OUTPUT_BUFFER_LIMIT = 4_000;
19
+ function isClaudeWorkspaceTrustPrompt(text) {
20
+ const compact = text.replace(/\s+/g, " ").trim();
21
+ return (/Accessing workspace:/i.test(compact) &&
22
+ /Quick safety check:/i.test(compact) &&
23
+ /project you created or one you trust/i.test(compact) &&
24
+ /I trust this folder/i.test(compact));
25
+ }
26
+ function isRecord(value) {
27
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
28
+ }
29
+ export function normalizeClaudeProjectConfigKey(cwd) {
30
+ return path.resolve(cwd).replace(/\\/g, "/");
31
+ }
32
+ export function ensureClaudeWorkspaceTrustAccepted(cwd, homeDir = process.env.USERPROFILE || process.env.HOME || os.homedir()) {
33
+ const configPath = path.join(homeDir, ".claude.json");
34
+ let config = {};
35
+ try {
36
+ if (fs.existsSync(configPath)) {
37
+ const raw = fs.readFileSync(configPath, "utf8");
38
+ config = raw.trim() ? JSON.parse(raw) : {};
39
+ }
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ if (!isRecord(config)) {
45
+ return false;
46
+ }
47
+ const projectKey = normalizeClaudeProjectConfigKey(cwd);
48
+ const projects = isRecord(config.projects) ? config.projects : {};
49
+ const currentProject = isRecord(projects[projectKey])
50
+ ? projects[projectKey]
51
+ : {};
52
+ if (currentProject.hasTrustDialogAccepted === true) {
53
+ return false;
54
+ }
55
+ const nextConfig = {
56
+ ...config,
57
+ projects: {
58
+ ...projects,
59
+ [projectKey]: {
60
+ ...currentProject,
61
+ hasTrustDialogAccepted: true,
62
+ },
63
+ },
64
+ };
65
+ const tempPath = `${configPath}.tmp-${process.pid}-${Date.now()}`;
66
+ try {
67
+ fs.writeFileSync(tempPath, `${JSON.stringify(nextConfig, null, 2)}\n`, "utf8");
68
+ fs.renameSync(tempPath, configPath);
69
+ return true;
70
+ }
71
+ catch {
72
+ try {
73
+ fs.rmSync(tempPath, { force: true });
74
+ }
75
+ catch {
76
+ // Best effort cleanup.
77
+ }
78
+ return false;
79
+ }
80
+ }
17
81
  export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
18
82
  hookServer = null;
19
83
  hookPort = null;
@@ -32,11 +96,20 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
32
96
  workingNoticeSent = false;
33
97
  workingNoticeDelayMs = CLAUDE_WECHAT_WORKING_NOTICE_DELAY_MS;
34
98
  lastCompactCompletionAtMs = 0;
99
+ startupOutputBuffer = "";
100
+ hasAutoConfirmedWorkspaceTrustPrompt = false;
35
101
  constructor(options) {
36
102
  super(options);
37
- this.runtimeSessionId = options.initialSharedSessionId ?? options.initialSharedThreadId ?? null;
38
- this.resumeConversationId = options.initialResumeConversationId ?? null;
39
- this.transcriptPath = options.initialTranscriptPath ?? null;
103
+ const shouldRestoreInitialSession = options.sessionStartMode !== "new";
104
+ this.runtimeSessionId = shouldRestoreInitialSession
105
+ ? options.initialSharedSessionId ?? options.initialSharedThreadId ?? null
106
+ : null;
107
+ this.resumeConversationId = shouldRestoreInitialSession
108
+ ? options.initialResumeConversationId ?? null
109
+ : null;
110
+ this.transcriptPath = shouldRestoreInitialSession
111
+ ? options.initialTranscriptPath ?? null
112
+ : null;
40
113
  if (this.runtimeSessionId) {
41
114
  this.state.sharedSessionId = this.runtimeSessionId;
42
115
  this.state.activeRuntimeSessionId = this.runtimeSessionId;
@@ -52,6 +125,9 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
52
125
  if (this.pty) {
53
126
  return;
54
127
  }
128
+ this.startupOutputBuffer = "";
129
+ this.hasAutoConfirmedWorkspaceTrustPrompt = false;
130
+ ensureClaudeWorkspaceTrustAccepted(this.options.cwd);
55
131
  // Validate transcript file exists before launching Claude CLI.
56
132
  // After a compact, the old transcript is deleted and the persisted
57
133
  // resumeConversationId becomes invalid, causing --resume to crash.
@@ -124,6 +200,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
124
200
  this.pendingCliApprovalHints = null;
125
201
  this.flushPendingClaudeHookApprovals();
126
202
  this.writeToPty("\u0003");
203
+ this.scheduleTaskComplete(shared.INTERRUPT_SETTLE_DELAY_MS);
127
204
  return true;
128
205
  }
129
206
  async reset() {
@@ -209,6 +286,9 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
209
286
  return;
210
287
  }
211
288
  this.state.lastOutputAt = nowIso();
289
+ if (this.maybeAutoConfirmWorkspaceTrustPrompt(text)) {
290
+ return;
291
+ }
212
292
  if (this.shouldTreatClaudeOutputAsCompactCompletion(text)) {
213
293
  this.completeClaudeCompact();
214
294
  return;
@@ -241,6 +321,18 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
241
321
  return;
242
322
  }
243
323
  }
324
+ maybeAutoConfirmWorkspaceTrustPrompt(text) {
325
+ if (this.hasAcceptedInput || this.hasAutoConfirmedWorkspaceTrustPrompt) {
326
+ return false;
327
+ }
328
+ this.startupOutputBuffer = `${this.startupOutputBuffer}${text}`.slice(-CLAUDE_STARTUP_OUTPUT_BUFFER_LIMIT);
329
+ if (!isClaudeWorkspaceTrustPrompt(this.startupOutputBuffer)) {
330
+ return false;
331
+ }
332
+ this.hasAutoConfirmedWorkspaceTrustPrompt = true;
333
+ this.writeToPty("\r");
334
+ return true;
335
+ }
244
336
  handleExit(exitCode) {
245
337
  this.detachLocalTerminal();
246
338
  this.clearWechatWorkingNotice(true);
@@ -475,6 +567,9 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
475
567
  }
476
568
  return `${CLAUDE_BRACKETED_PASTE_START}${normalizedText}${CLAUDE_BRACKETED_PASTE_END}`;
477
569
  }
570
+ // Fallback heuristic for older Claude Code versions that lack PostCompact hooks.
571
+ // The structured PostCompact hook event (handled in handleClaudeHookEnvelope) is
572
+ // the reliable signal; this regex match serves as a best-effort fallback.
478
573
  shouldTreatClaudeOutputAsCompactCompletion(text) {
479
574
  if (this.state.status !== "busy" &&
480
575
  this.state.status !== "awaiting_approval" &&
@@ -604,6 +699,10 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
604
699
  this.handleClaudeStopFailure(payload);
605
700
  this.respondToClaudeHook(params.socket, params.requestId);
606
701
  return;
702
+ case "PostCompact":
703
+ this.completeClaudeCompact();
704
+ this.respondToClaudeHook(params.socket, params.requestId);
705
+ return;
607
706
  default:
608
707
  this.respondToClaudeHook(params.socket, params.requestId);
609
708
  return;
@@ -7,7 +7,7 @@ import { AbstractPtyAdapter } from "./bridge-adapters.core.js";
7
7
  import * as shared from "./bridge-adapters.shared.js";
8
8
  import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
9
9
  import { CODEX_REMOTE_AUTH_TOKEN_ENV, LOCAL_CLIENT_PROTOCOL_VERSION, } from "../runtime/runtime-types.js";
10
- const { CODEX_APP_SERVER_HOST, CODEX_APP_SERVER_LOG_LIMIT, 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, buildCliEnvironment, 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;
10
+ 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;
11
11
  const CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS = 150;
12
12
  export class CodexPtyAdapter extends AbstractPtyAdapter {
13
13
  runtimeKind = "codex_runtime_host";
@@ -64,7 +64,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
64
64
  localClientInstanceId = `${process.pid}-${Date.now().toString(36)}`;
65
65
  constructor(options) {
66
66
  super(options);
67
- this.resumeThreadId = options.initialSharedSessionId ?? options.initialSharedThreadId ?? null;
67
+ this.resumeThreadId = options.sessionStartMode === "new"
68
+ ? null
69
+ : options.initialSharedSessionId ?? options.initialSharedThreadId ?? null;
68
70
  if (this.resumeThreadId && options.renderMode !== "panel") {
69
71
  this.state.sharedSessionId = this.resumeThreadId;
70
72
  this.state.sharedThreadId = this.resumeThreadId;
@@ -493,9 +495,27 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
493
495
  this.sessionPartialLine = "";
494
496
  this.seedSessionReplayCutoff(startedAtMs);
495
497
  }
496
- let content;
498
+ let chunk;
497
499
  try {
498
- content = fs.readFileSync(this.sessionFilePath, "utf8");
500
+ const stat = fs.statSync(this.sessionFilePath);
501
+ if (stat.size < this.sessionReadOffset) {
502
+ this.sessionReadOffset = 0;
503
+ this.sessionPartialLine = "";
504
+ }
505
+ if (stat.size === this.sessionReadOffset) {
506
+ return;
507
+ }
508
+ const fd = fs.openSync(this.sessionFilePath, "r");
509
+ try {
510
+ const bytesToRead = stat.size - this.sessionReadOffset;
511
+ const buf = Buffer.alloc(bytesToRead);
512
+ fs.readSync(fd, buf, 0, bytesToRead, this.sessionReadOffset);
513
+ chunk = buf.toString("utf8");
514
+ this.sessionReadOffset = stat.size;
515
+ }
516
+ finally {
517
+ fs.closeSync(fd);
518
+ }
499
519
  }
500
520
  catch {
501
521
  this.sessionFilePath = null;
@@ -503,15 +523,6 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
503
523
  this.sessionPartialLine = "";
504
524
  return;
505
525
  }
506
- if (content.length < this.sessionReadOffset) {
507
- this.sessionReadOffset = 0;
508
- this.sessionPartialLine = "";
509
- }
510
- if (content.length === this.sessionReadOffset) {
511
- return;
512
- }
513
- const chunk = content.slice(this.sessionReadOffset);
514
- this.sessionReadOffset = content.length;
515
526
  const lines = `${this.sessionPartialLine}${chunk}`.split(/\r?\n/);
516
527
  this.sessionPartialLine = lines.pop() ?? "";
517
528
  for (const line of lines) {
@@ -1625,10 +1636,14 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1625
1636
  const requestId = ++this.rpcRequestCounter;
1626
1637
  const requestKey = this.rpcRequestKey(requestId);
1627
1638
  const responsePromise = new Promise((resolve, reject) => {
1639
+ const timer = setTimeout(() => {
1640
+ this.pendingRpcRequests.delete(requestKey);
1641
+ reject(new Error(`Codex RPC request timed out after 30s (method: ${method})`));
1642
+ }, 30_000);
1628
1643
  this.pendingRpcRequests.set(requestKey, {
1629
1644
  method,
1630
- resolve,
1631
- reject,
1645
+ resolve: (value) => { clearTimeout(timer); resolve(value); },
1646
+ reject: (err) => { clearTimeout(timer); reject(err); },
1632
1647
  });
1633
1648
  });
1634
1649
  try {
@@ -20,21 +20,29 @@ export class LocalCompanionProxyAdapter {
20
20
  reconnectShutdownTimer = null;
21
21
  constructor(options) {
22
22
  this.options = options;
23
+ const shouldRestoreInitialSession = options.sessionStartMode !== "new";
24
+ const initialSharedSessionId = shouldRestoreInitialSession
25
+ ? options.initialSharedSessionId ?? options.initialSharedThreadId
26
+ : undefined;
23
27
  this.state = {
24
28
  kind: options.kind,
25
29
  status: "stopped",
26
30
  cwd: options.cwd,
27
31
  command: options.command,
28
32
  profile: options.profile,
29
- sharedSessionId: options.initialSharedSessionId ?? options.initialSharedThreadId,
33
+ sharedSessionId: initialSharedSessionId,
30
34
  sharedThreadId: options.kind === "codex" || options.kind === "opencode"
31
- ? options.initialSharedSessionId ?? options.initialSharedThreadId
35
+ ? initialSharedSessionId
32
36
  : undefined,
33
37
  activeRuntimeSessionId: options.kind === "claude" || options.kind === "opencode"
34
- ? options.initialSharedSessionId ?? options.initialSharedThreadId
38
+ ? initialSharedSessionId
39
+ : undefined,
40
+ resumeConversationId: shouldRestoreInitialSession && options.kind === "claude"
41
+ ? options.initialResumeConversationId
42
+ : undefined,
43
+ transcriptPath: shouldRestoreInitialSession && options.kind === "claude"
44
+ ? options.initialTranscriptPath
35
45
  : undefined,
36
- resumeConversationId: options.kind === "claude" ? options.initialResumeConversationId : undefined,
37
- transcriptPath: options.kind === "claude" ? options.initialTranscriptPath : undefined,
38
46
  };
39
47
  }
40
48
  setEventSink(sink) {
@@ -286,6 +294,7 @@ export class LocalCompanionProxyAdapter {
286
294
  }
287
295
  }
288
296
  detachPanelSocket() {
297
+ this.rejectPendingRequests("Companion socket disconnected unexpectedly.");
289
298
  this.detachMessageListener?.();
290
299
  this.detachMessageListener = null;
291
300
  if (this.socket) {
@@ -385,7 +394,14 @@ export class LocalCompanionProxyAdapter {
385
394
  }
386
395
  const id = `${++this.requestCounter}`;
387
396
  const response = new Promise((resolve, reject) => {
388
- this.pendingRequests.set(id, { resolve, reject });
397
+ const timer = setTimeout(() => {
398
+ this.pendingRequests.delete(id);
399
+ reject(new Error(`${this.options.kind} companion request timed out after 30s (command: ${payload.command})`));
400
+ }, 30_000);
401
+ this.pendingRequests.set(id, {
402
+ resolve: (value) => { clearTimeout(timer); resolve(value); },
403
+ reject: (err) => { clearTimeout(timer); reject(err); },
404
+ });
389
405
  });
390
406
  sendLocalCompanionMessage(socket, {
391
407
  type: "request",
@@ -580,15 +596,21 @@ export class AbstractPtyAdapter {
580
596
  return;
581
597
  }
582
598
  this.shuttingDown = true;
599
+ const pty = this.pty;
600
+ this.pty = null;
601
+ this.state.status = "stopped";
602
+ this.state.pid = undefined;
583
603
  try {
584
- this.pty.kill();
604
+ const exitPromise = new Promise((resolve) => {
605
+ const timeout = setTimeout(resolve, 3_000);
606
+ pty.onExit(() => { clearTimeout(timeout); resolve(); });
607
+ });
608
+ pty.kill();
609
+ await exitPromise;
585
610
  }
586
611
  catch {
587
612
  // Best effort shutdown.
588
613
  }
589
- this.pty = null;
590
- this.state.status = "stopped";
591
- this.state.pid = undefined;
592
614
  }
593
615
  getState() {
594
616
  return JSON.parse(JSON.stringify(this.state));
@@ -1,12 +1,13 @@
1
1
  import { spawn as spawnChildProcess } from "node:child_process";
2
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";
3
3
  import { killProcessTreeSync } from "./bridge-process-reaper.js";
4
- import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, containsWechatOutboundAttachmentPath, buildOneTimeCode, isWechatOutboundAttachmentMutationTool, isWechatOutboundAttachmentWriteCommand, normalizeOutput, nowIso, truncatePreview, OutputBatcher, } from "./bridge-utils.js";
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 ?? "");
6
6
  const OPENCODE_DUPLICATE_EVENT_TTL_MS = 150;
7
7
  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
+ const OPENCODE_TUI_SELECT_RETRY_DELAYS_MS = [250, 750, 1_500];
10
11
  /* ------------------------------------------------------------------ */
11
12
  /* Adapter */
12
13
  /* ------------------------------------------------------------------ */
@@ -82,7 +83,7 @@ export class OpenCodeServerAdapter {
82
83
  this.startSseListener();
83
84
  if (this.options.renderMode === "companion") {
84
85
  await this.startNativeClient();
85
- await this.syncVisibleSessionToShared({ force: true });
86
+ await this.syncVisibleSessionToShared({ force: true, retry: true });
86
87
  }
87
88
  else {
88
89
  this.state.pid = serverProcess.pid;
@@ -177,6 +178,7 @@ export class OpenCodeServerAdapter {
177
178
  clearTrackedTurn: true,
178
179
  syncVisible: true,
179
180
  forceVisibleSync: true,
181
+ retryVisibleSync: true,
180
182
  });
181
183
  }
182
184
  async interrupt() {
@@ -283,21 +285,25 @@ export class OpenCodeServerAdapter {
283
285
  if (this.nativeProcess) {
284
286
  const proc = this.nativeProcess;
285
287
  this.nativeProcess = null;
286
- try {
287
- killProcessTreeSync(proc.pid);
288
- }
289
- catch {
290
- // Best effort.
288
+ if (proc.pid != null) {
289
+ try {
290
+ killProcessTreeSync(proc.pid);
291
+ }
292
+ catch {
293
+ // Best effort.
294
+ }
291
295
  }
292
296
  }
293
297
  if (this.serverProcess) {
294
298
  const proc = this.serverProcess;
295
299
  this.serverProcess = null;
296
- try {
297
- killProcessTreeSync(proc.pid);
298
- }
299
- catch {
300
- // Best effort.
300
+ if (proc.pid != null) {
301
+ try {
302
+ killProcessTreeSync(proc.pid);
303
+ }
304
+ catch {
305
+ // Best effort.
306
+ }
301
307
  }
302
308
  }
303
309
  this.client = null;
@@ -365,7 +371,7 @@ export class OpenCodeServerAdapter {
365
371
  if (this.nativeProcess) {
366
372
  return;
367
373
  }
368
- const env = buildCliEnvironment(this.options.kind);
374
+ const env = this.buildNativeClientEnv();
369
375
  const attachArgs = await this.buildNativeAttachArgs();
370
376
  const target = resolveSpawnTarget(this.options.command, this.options.kind, { env });
371
377
  const startedAt = nowIso();
@@ -410,6 +416,7 @@ export class OpenCodeServerAdapter {
410
416
  }
411
417
  async buildNativeAttachArgs() {
412
418
  const args = ["attach", this.getServerUrl()];
419
+ args.push("--dir", this.options.cwd);
413
420
  const sessionId = this.activeSessionId;
414
421
  if (sessionId && (await this.hasSession(sessionId))) {
415
422
  args.push("--session", sessionId);
@@ -417,6 +424,16 @@ export class OpenCodeServerAdapter {
417
424
  args.push(...(this.options.extraCliArgs ?? []));
418
425
  return args;
419
426
  }
427
+ buildNativeClientEnv() {
428
+ const env = buildCliEnvironment(this.options.kind);
429
+ if (this.activeSessionId) {
430
+ env.OPENCODE_ROUTE = JSON.stringify({
431
+ type: "session",
432
+ sessionID: this.activeSessionId,
433
+ });
434
+ }
435
+ return env;
436
+ }
420
437
  async createSdkClient() {
421
438
  try {
422
439
  const { createOpencodeClient } = await import("@opencode-ai/sdk/v2");
@@ -441,6 +458,21 @@ export class OpenCodeServerAdapter {
441
458
  if (!this.client) {
442
459
  return;
443
460
  }
461
+ if (this.options.sessionStartMode === "new") {
462
+ const session = this.unwrapOrThrow(await this.client.session.create({
463
+ directory: this.options.cwd,
464
+ workspace: this.activeWorkspaceId ?? undefined,
465
+ }));
466
+ this.switchSharedSession(session, {
467
+ source: "local",
468
+ reason: "local_follow",
469
+ syncVisible: false,
470
+ });
471
+ this.state.lastSessionSwitchAt = undefined;
472
+ this.state.lastSessionSwitchSource = undefined;
473
+ this.state.lastSessionSwitchReason = undefined;
474
+ return;
475
+ }
444
476
  let listedSessions = [];
445
477
  try {
446
478
  const result = await this.client.session.list();
@@ -1854,7 +1886,9 @@ export class OpenCodeServerAdapter {
1854
1886
  this.recordSessionSwitch(sessionId, options.source, options.reason, options.notify);
1855
1887
  }
1856
1888
  if (options.syncVisible !== false && (changed || options.forceVisibleSync)) {
1857
- void this.syncVisibleSessionSelection(typeof session === "string" ? { id: session } : session);
1889
+ void this.syncVisibleSessionSelection(typeof session === "string" ? { id: session } : session, {
1890
+ retry: options.retryVisibleSync,
1891
+ });
1858
1892
  }
1859
1893
  return changed;
1860
1894
  }
@@ -1865,7 +1899,7 @@ export class OpenCodeServerAdapter {
1865
1899
  await this.syncVisibleSessionSelection({
1866
1900
  id: this.activeSessionId,
1867
1901
  workspaceID: this.activeWorkspaceId ?? undefined,
1868
- }, { force: options.force });
1902
+ }, { force: options.force, retry: options.retry });
1869
1903
  }
1870
1904
  async syncVisibleSessionSelection(session, options = {}) {
1871
1905
  if (!this.client?.tui ||
@@ -1876,13 +1910,9 @@ export class OpenCodeServerAdapter {
1876
1910
  this.suppressedTuiSessionSelectId = session.id;
1877
1911
  this.logDebug(`[opencode-adapter:tui] selectSession session=${session.id} workspace=${session.workspaceID ?? this.activeWorkspaceId ?? "(none)"}`);
1878
1912
  try {
1879
- const result = await this.client.tui.selectSession({
1880
- directory: this.options.cwd,
1881
- workspace: session.workspaceID ?? this.activeWorkspaceId ?? undefined,
1882
- sessionID: session.id,
1883
- });
1884
- if (result.error !== undefined) {
1885
- throw result.error;
1913
+ await this.sendVisibleSessionSelection(session);
1914
+ if (options.retry === true) {
1915
+ this.scheduleVisibleSessionSelectionRetries(session);
1886
1916
  }
1887
1917
  }
1888
1918
  catch (err) {
@@ -1892,6 +1922,33 @@ export class OpenCodeServerAdapter {
1892
1922
  this.logDebug(`[opencode-adapter:tui] selectSession failed for ${session.id}: ${describeUnknownError(err)}`);
1893
1923
  }
1894
1924
  }
1925
+ async sendVisibleSessionSelection(session) {
1926
+ if (!this.client?.tui) {
1927
+ return;
1928
+ }
1929
+ const result = await this.client.tui.selectSession({
1930
+ directory: this.options.cwd,
1931
+ workspace: session.workspaceID ?? this.activeWorkspaceId ?? undefined,
1932
+ sessionID: session.id,
1933
+ });
1934
+ if (result.error !== undefined) {
1935
+ throw result.error;
1936
+ }
1937
+ }
1938
+ scheduleVisibleSessionSelectionRetries(session) {
1939
+ for (const retryDelayMs of OPENCODE_TUI_SELECT_RETRY_DELAYS_MS) {
1940
+ setTimeout(() => {
1941
+ if (this.shuttingDown ||
1942
+ this.options.renderMode !== "companion" ||
1943
+ this.activeSessionId !== session.id) {
1944
+ return;
1945
+ }
1946
+ void this.sendVisibleSessionSelection(session).catch((err) => {
1947
+ this.logDebug(`[opencode-adapter:tui] selectSession retry failed for ${session.id}: ${describeUnknownError(err)}`);
1948
+ });
1949
+ }, retryDelayMs).unref?.();
1950
+ }
1951
+ }
1895
1952
  isLocalSessionNavigationCommand(command) {
1896
1953
  if (!command) {
1897
1954
  return false;
@@ -2,13 +2,10 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import net from "node:net";
4
4
  import { fileURLToPath } from "node:url";
5
- import { spawn as spawnChild, spawnSync } from "node:child_process";
5
+ import { spawnSync } from "node:child_process";
6
6
  import { spawn as spawnPty } from "node-pty";
7
- import { attachLocalCompanionMessageListener, buildLocalCompanionToken, clearLocalCompanionEndpoint, sendLocalCompanionMessage, writeLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
8
- import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
9
- import { buildClaudeFailureMessage, buildClaudeHookSettings, buildClaudePermissionDecisionHookOutput, buildClaudePermissionApprovalRequest, extractClaudeResumeConversationId, findInjectedClaudePromptIndex, normalizeClaudeAssistantMessage, parseClaudeHookPayload, } from "./claude-hooks.js";
10
- import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, containsWechatOutboundAttachmentPath, containsWechatOutboundAttachmentPathDeep, detectCliApproval, isWechatOutboundAttachmentWriteCommand, isHighRiskShellCommand, normalizeOutput, nowIso, truncatePreview, } from "./bridge-utils.js";
11
- import { coerceWebSocketMessageData, describeUnknownError, getCodexRpcRequestId, getLocalCompanionCommandName, getNotificationThreadId, getNotificationTurnId, getSharedSessionIdFromAdapterState, isRecentIsoTimestamp, isRecord, normalizeCodexRpcError, quotePosixCommandArg, quoteWindowsCommandArg, } from "./bridge-adapter-common.js";
7
+ import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, containsWechatOutboundAttachmentPath, containsWechatOutboundAttachmentPathDeep, isWechatOutboundAttachmentWriteCommand, isHighRiskShellCommand, normalizeOutput, truncatePreview, } from "./bridge-utils.js";
8
+ import { getNotificationThreadId, isRecord, } from "./bridge-adapter-common.js";
12
9
  export { coerceWebSocketMessageData, describeUnknownError, getCodexRpcRequestId, getLocalCompanionCommandName, getNotificationThreadId, getNotificationTurnId, getSharedSessionIdFromAdapterState, isRecentIsoTimestamp, isRecord, normalizeCodexRpcError, quotePosixCommandArg, quoteWindowsCommandArg, } from "./bridge-adapter-common.js";
13
10
  export const DEFAULT_COLS = 120;
14
11
  export const DEFAULT_ROWS = 30;
@@ -1,4 +1,5 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import path from "node:path";
2
3
  const PEER_BRIDGE_EXIT_TIMEOUT_MS = 4_000;
3
4
  const PEER_BRIDGE_EXIT_POLL_MS = 100;
4
5
  function isPidAlive(pid) {
@@ -24,7 +25,30 @@ function isRecord(value) {
24
25
  }
25
26
  export function isWechatBridgeCommandLine(commandLine) {
26
27
  return (/wechat-bridge\.(?:ts|js|mjs)/i.test(commandLine) &&
27
- /(?:^|\s)--adapter(?:\s|=)/i.test(commandLine));
28
+ /(?:^|\s|["'])--adapter(?:(?:["']?\s)|=)/i.test(commandLine));
29
+ }
30
+ export function isWechatDaemonCommandLine(commandLine) {
31
+ return /wechat-daemon\.(?:ts|js|mjs)/i.test(commandLine);
32
+ }
33
+ function normalizeComparablePath(filePath) {
34
+ const normalized = path.resolve(filePath);
35
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
36
+ }
37
+ function extractCommandLineOption(commandLine, optionName) {
38
+ const escaped = optionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
39
+ const pattern = new RegExp(`(?:^|\\s|["'])--${escaped}(?:(?:["']?\\s+)|=)(?:"([^"]+)"|'([^']+)'|(\\S+))`, "i");
40
+ const match = pattern.exec(commandLine);
41
+ return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
42
+ }
43
+ export function isWechatDaemonCommandLineForCwd(commandLine, cwd) {
44
+ if (!isWechatDaemonCommandLine(commandLine)) {
45
+ return false;
46
+ }
47
+ const commandCwd = extractCommandLineOption(commandLine, "cwd");
48
+ if (!commandCwd) {
49
+ return true;
50
+ }
51
+ return normalizeComparablePath(commandCwd) === normalizeComparablePath(cwd);
28
52
  }
29
53
  /**
30
54
  * Detects `opencode serve` command lines spawned by the bridge adapter.
@@ -165,6 +189,21 @@ export function listPeerBridgeProcesses(currentPid = process.pid) {
165
189
  ? listWindowsBridgeProcesses(currentPid)
166
190
  : listPosixBridgeProcesses(currentPid);
167
191
  }
192
+ export function getProcessRecordByPid(pid, currentPid = process.pid) {
193
+ if (!Number.isInteger(pid) || pid <= 0 || pid === currentPid) {
194
+ return null;
195
+ }
196
+ return listAllProcessesRaw(currentPid).find((record) => record.pid === pid) ?? null;
197
+ }
198
+ export function listWechatDaemonProcesses(params = {}) {
199
+ const currentPid = params.currentPid ?? process.pid;
200
+ const excludePids = new Set(params.excludePids ?? []);
201
+ return listAllProcessesRaw(currentPid)
202
+ .filter((record) => !excludePids.has(record.pid))
203
+ .filter((record) => params.cwd
204
+ ? isWechatDaemonCommandLineForCwd(record.commandLine, params.cwd)
205
+ : isWechatDaemonCommandLine(record.commandLine));
206
+ }
168
207
  /**
169
208
  * List all managed OpenCode child processes (`serve` and `attach`) whose parent
170
209
  * PID is no longer alive. These are orphaned processes left behind when a bridge
@@ -289,9 +328,14 @@ export function killProcessTreeSync(pid) {
289
328
  }
290
329
  else {
291
330
  try {
292
- process.kill(pid);
331
+ process.kill(-pid);
332
+ }
333
+ catch {
334
+ try {
335
+ process.kill(pid);
336
+ }
337
+ catch { /* best effort */ }
293
338
  }
294
- catch { /* best effort */ }
295
339
  }
296
340
  }
297
341
  export async function reapPeerBridgeProcesses(params = {}) {
@@ -169,14 +169,17 @@ export class BridgeStateStore {
169
169
  lifecycle: options.lifecycle,
170
170
  };
171
171
  this.acquireLock();
172
+ const shouldRestoreSessionState = options.sessionStartMode !== "new";
172
173
  const persisted = this.readStateFile();
173
- const persistedSharedSessionId = resolveRestorableSharedSessionId(persisted, options);
174
- const persistedResumeConversationId = options.adapter === "claude" &&
174
+ const persistedSharedSessionId = resolveRestorableSharedSessionId(shouldRestoreSessionState ? persisted : null, options);
175
+ const persistedResumeConversationId = shouldRestoreSessionState &&
176
+ options.adapter === "claude" &&
175
177
  persisted?.cwd === options.cwd &&
176
178
  typeof persisted.resumeConversationId === "string"
177
179
  ? persisted.resumeConversationId
178
180
  : undefined;
179
- const persistedTranscriptPath = options.adapter === "claude" &&
181
+ const persistedTranscriptPath = shouldRestoreSessionState &&
182
+ options.adapter === "claude" &&
180
183
  persisted?.cwd === options.cwd &&
181
184
  typeof persisted.transcriptPath === "string"
182
185
  ? persisted.transcriptPath
@@ -268,8 +268,8 @@ function formatPromptByteSize(bytes) {
268
268
  }
269
269
  function formatWechatInboundAttachmentPrompt(attachments) {
270
270
  const lines = [
271
- "[WeChat inbound attachments]",
272
- "The user sent attachment files through WeChat. They have been saved locally. Read the local paths directly if the user's request requires inspecting them.",
271
+ "[WeChat inbound attachments — ACTION REQUIRED]",
272
+ "The user sent the following files through WeChat. You MUST use the Read tool to read each file path below BEFORE responding. Do NOT skip reading these files — the user expects you to see their content (including images).",
273
273
  ];
274
274
  attachments.forEach((attachment, index) => {
275
275
  const sizeText = formatPromptByteSize(attachment.sizeBytes);
@@ -280,6 +280,8 @@ function formatWechatInboundAttachmentPrompt(attachments) {
280
280
  ].filter(Boolean);
281
281
  lines.push(`${index + 1}. ${metadata.join(" ")} path=${attachment.path}`);
282
282
  });
283
+ lines.push("");
284
+ lines.push("Remember: Read each path above with the Read tool NOW. Images are viewable via Read.");
283
285
  return lines.join("\n");
284
286
  }
285
287
  export function buildWechatInboundPrompt(text, attachments = []) {