@unlinearity/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.
- package/LICENSE.txt +661 -21
- package/README.md +157 -353
- package/dist/bridge/bridge-adapters.claude.js +102 -3
- package/dist/bridge/bridge-adapters.codex.js +30 -15
- package/dist/bridge/bridge-adapters.core.js +32 -10
- package/dist/bridge/bridge-adapters.opencode.js +79 -22
- package/dist/bridge/bridge-adapters.shared.js +3 -6
- package/dist/bridge/bridge-process-reaper.js +47 -3
- package/dist/bridge/bridge-state.js +6 -3
- package/dist/bridge/bridge-utils.js +4 -2
- package/dist/bridge/claude-hooks.js +5 -0
- package/dist/bridge/wechat-bridge.js +11 -1
- package/dist/commands/check-update.js +1 -1
- package/dist/companion/local-companion-link.js +6 -0
- package/dist/companion/local-companion-start.js +27 -2
- package/dist/companion/local-companion.js +21 -4
- package/dist/daemon/daemon-link.js +6 -0
- package/dist/daemon/emoji-bindings.js +80 -0
- package/dist/daemon/wechat-daemon.js +440 -29
- package/dist/wechat/channel-config.js +1 -0
- package/package.json +2 -4
- package/dist/companion/codex-panel-link.js +0 -72
- package/dist/companion/codex-panel.js +0 -179
- package/dist/media/media-types.js +0 -1
- package/dist/wechat/standalone-bot.js +0 -355
|
@@ -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
|
-
|
|
38
|
-
this.
|
|
39
|
-
|
|
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,
|
|
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.
|
|
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
|
|
498
|
+
let chunk;
|
|
497
499
|
try {
|
|
498
|
-
|
|
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:
|
|
33
|
+
sharedSessionId: initialSharedSessionId,
|
|
30
34
|
sharedThreadId: options.kind === "codex" || options.kind === "opencode"
|
|
31
|
-
?
|
|
35
|
+
? initialSharedSessionId
|
|
32
36
|
: undefined,
|
|
33
37
|
activeRuntimeSessionId: options.kind === "claude" || options.kind === "opencode"
|
|
34
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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 =
|
|
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
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
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 {
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
6
|
import { spawn as spawnPty } from "node-pty";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
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 = []) {
|