@unlinearity/cli-wechat-bridge 1.1.3 → 1.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -135
- package/bin/_run-entry.mjs +12 -0
- package/bin/wechat-claude-start.mjs +7 -2
- package/bin/wechat-claude.mjs +1 -1
- package/bin/wechat-codex-start.mjs +7 -2
- package/bin/wechat-codex.mjs +1 -1
- package/bin/wechat-opencode-start.mjs +7 -2
- package/bin/wechat-opencode.mjs +1 -1
- package/bin/wechat-pi-start.mjs +7 -2
- package/bin/wechat-pi.mjs +1 -1
- package/dist/bridge/bridge-adapters.claude.js +21 -1
- package/dist/bridge/bridge-adapters.codex.js +27 -4
- package/dist/bridge/bridge-adapters.core.js +76 -4
- package/dist/bridge/bridge-adapters.js +0 -3
- package/dist/bridge/bridge-adapters.opencode.js +130 -21
- package/dist/bridge/bridge-adapters.pi.js +12 -2
- package/dist/bridge/bridge-adapters.shared.js +13 -100
- package/dist/bridge/bridge-final-reply.js +17 -2
- package/dist/bridge/bridge-process-reaper.js +14 -4
- package/dist/bridge/bridge-state.js +41 -6
- package/dist/bridge/bridge-utils.js +37 -224
- package/dist/bridge/wechat-bridge.js +75 -130
- package/dist/bridge/wechat-forwarding.js +58 -0
- package/dist/companion/codex-remote-client.js +20 -6
- package/dist/companion/local-companion-link.js +7 -2
- package/dist/companion/local-companion-start.js +45 -13
- package/dist/companion/local-companion.js +6 -3
- package/dist/daemon/daemon-link.js +25 -3
- package/dist/daemon/wechat-daemon.js +148 -54
- package/dist/i18n/index.js +0 -3
- package/dist/i18n/messages-en.js +2 -3
- package/dist/i18n/messages-zh.js +2 -3
- package/dist/utils/atomic-file.js +15 -0
- package/dist/utils/doctor.js +23 -11
- package/dist/wechat/channel-config.js +0 -2
- package/dist/wechat/setup.js +5 -11
- package/dist/wechat/wechat-transport.js +14 -8
- package/package.json +5 -8
- package/scripts/ensure-node-pty-permissions.mjs +73 -0
- package/bin/wechat-bridge-claude.mjs +0 -5
- package/bin/wechat-bridge-codex.mjs +0 -5
- package/bin/wechat-bridge-opencode.mjs +0 -5
- package/bin/wechat-bridge-pi.mjs +0 -5
- package/bin/wechat-bridge-shell.mjs +0 -5
- package/bin/wechat-bridge.mjs +0 -5
- package/dist/bridge/bridge-adapters.shell.js +0 -363
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn as spawnChildProcess } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
2
3
|
import { OPENCODE_SERVER_HOST, OPENCODE_SERVER_READY_TIMEOUT_MS, OPENCODE_SSE_RECONNECT_DELAY_MS, OPENCODE_SESSION_IDLE_SETTLE_MS, OPENCODE_WECHAT_WORKING_NOTICE_DELAY_MS, OPENCODE_HTTP_READY_TIMEOUT_MS, OPENCODE_HTTP_READY_PROBE_TIMEOUT_MS, OPENCODE_HTTP_READY_PROBE_INTERVAL_MS, buildCliEnvironment, isRecord, describeUnknownError, resolveSpawnTarget, reserveLocalPort, waitForTcpPort, delay, } from "./bridge-adapters.shared.js";
|
|
3
4
|
import { killProcessTreeSync } from "./bridge-process-reaper.js";
|
|
4
5
|
import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, buildOneTimeCode, isWechatOutboundAttachmentMutationTool, isWechatOutboundAttachmentWriteCommand, normalizeOutput, nowIso, truncatePreview, OutputBatcher, } from "./bridge-utils.js";
|
|
@@ -6,6 +7,9 @@ const OPENCODE_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(process.env.WECHAT_OPEN
|
|
|
6
7
|
const OPENCODE_DUPLICATE_EVENT_TTL_MS = 150;
|
|
7
8
|
const OPENCODE_WECHAT_MIRROR_SUPPRESSION_TTL_MS = 30_000;
|
|
8
9
|
const OPENCODE_RECENT_LOCAL_PROMPT_TTL_MS = 10_000;
|
|
10
|
+
// How long a selectSession echo suppression stays valid before it is treated
|
|
11
|
+
// as lost and cleared.
|
|
12
|
+
const TUI_SESSION_SELECT_SUPPRESSION_TTL_MS = 5_000;
|
|
9
13
|
const OPENCODE_LOCAL_SESSION_CREATE_FOLLOW_TTL_MS = 5_000;
|
|
10
14
|
const OPENCODE_TUI_SELECT_RETRY_DELAYS_MS = [250, 750, 1_500];
|
|
11
15
|
const OPENCODE_MINIMUM_SUPPORTED_VERSION = "1.18.0";
|
|
@@ -20,6 +24,11 @@ export class OpenCodeServerAdapter {
|
|
|
20
24
|
serverProcess = null;
|
|
21
25
|
nativeProcess = null;
|
|
22
26
|
serverPort = 0;
|
|
27
|
+
// Random per-instance password handed to `opencode serve` via
|
|
28
|
+
// OPENCODE_SERVER_PASSWORD. Without it the server accepts unauthenticated
|
|
29
|
+
// connections from any local process; the attach client inherits the env
|
|
30
|
+
// automatically, and the SDK/health calls send the matching Basic header.
|
|
31
|
+
serverPassword = null;
|
|
23
32
|
client = null;
|
|
24
33
|
sseAbortController = null;
|
|
25
34
|
sseLoopPromise = null;
|
|
@@ -51,7 +60,7 @@ export class OpenCodeServerAdapter {
|
|
|
51
60
|
pendingWechatPromptMirrorSuppressions = [];
|
|
52
61
|
recentWechatPromptMirrorSuppressions = [];
|
|
53
62
|
recentSdkEventObservations = new Map();
|
|
54
|
-
|
|
63
|
+
suppressedTuiSessionSelect = null;
|
|
55
64
|
lastMirroredLocalPrompt = null;
|
|
56
65
|
pendingLocalSessionCreateFollowUntilMs = 0;
|
|
57
66
|
pendingPermission = null;
|
|
@@ -159,12 +168,50 @@ export class OpenCodeServerAdapter {
|
|
|
159
168
|
}
|
|
160
169
|
}
|
|
161
170
|
async listResumeSessions(limit = 10) {
|
|
162
|
-
|
|
163
|
-
|
|
171
|
+
if (!this.client) {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
let sessions;
|
|
175
|
+
try {
|
|
176
|
+
sessions = this.unwrapOrThrow(await this.client.session.list()) ?? [];
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
return sessions
|
|
182
|
+
.filter((session) => this.isCurrentDirectorySession(session))
|
|
183
|
+
.slice(0, limit)
|
|
184
|
+
.map((session) => {
|
|
185
|
+
const rawTime = typeof session.time?.updated === "number"
|
|
186
|
+
? session.time.updated
|
|
187
|
+
: typeof session.time?.created === "number"
|
|
188
|
+
? session.time.created
|
|
189
|
+
: 0;
|
|
190
|
+
const updatedMs = rawTime > 0 && rawTime < 1_000_000_000_000 ? rawTime * 1_000 : rawTime;
|
|
191
|
+
return {
|
|
192
|
+
sessionId: session.id,
|
|
193
|
+
title: session.title?.trim() || "untitled",
|
|
194
|
+
lastUpdatedAt: new Date(updatedMs > 0 ? updatedMs : Date.now()).toISOString(),
|
|
195
|
+
source: "opencode",
|
|
196
|
+
};
|
|
197
|
+
});
|
|
164
198
|
}
|
|
165
199
|
async resumeSession(sessionId) {
|
|
166
|
-
|
|
167
|
-
|
|
200
|
+
if (!this.client) {
|
|
201
|
+
throw new Error("OpenCode is not running yet.");
|
|
202
|
+
}
|
|
203
|
+
const session = await this.getSessionForCurrentDirectory(sessionId, []);
|
|
204
|
+
if (!session) {
|
|
205
|
+
throw new Error(`No OpenCode session ${sessionId} was found for ${this.options.cwd}.`);
|
|
206
|
+
}
|
|
207
|
+
this.switchSharedSession(session, {
|
|
208
|
+
source: "wechat",
|
|
209
|
+
reason: "wechat_resume",
|
|
210
|
+
notify: true,
|
|
211
|
+
clearTrackedTurn: true,
|
|
212
|
+
syncVisible: true,
|
|
213
|
+
forceVisibleSync: true,
|
|
214
|
+
});
|
|
168
215
|
}
|
|
169
216
|
async createSession() {
|
|
170
217
|
if (!this.client) {
|
|
@@ -360,7 +407,7 @@ export class OpenCodeServerAdapter {
|
|
|
360
407
|
this.client = null;
|
|
361
408
|
this.activeSessionId = null;
|
|
362
409
|
this.activeWorkspaceId = null;
|
|
363
|
-
this.
|
|
410
|
+
this.suppressedTuiSessionSelect = null;
|
|
364
411
|
this.state.status = "stopped";
|
|
365
412
|
this.state.pid = undefined;
|
|
366
413
|
this.state.startedAt = undefined;
|
|
@@ -368,6 +415,8 @@ export class OpenCodeServerAdapter {
|
|
|
368
415
|
/* ---- Server management ---- */
|
|
369
416
|
async startServerProcess() {
|
|
370
417
|
const env = buildCliEnvironment(this.options.kind);
|
|
418
|
+
this.serverPassword = randomBytes(24).toString("hex");
|
|
419
|
+
env.OPENCODE_SERVER_PASSWORD = this.serverPassword;
|
|
371
420
|
const serverArgs = [
|
|
372
421
|
"serve",
|
|
373
422
|
"--port",
|
|
@@ -477,6 +526,9 @@ export class OpenCodeServerAdapter {
|
|
|
477
526
|
}
|
|
478
527
|
buildNativeClientEnv() {
|
|
479
528
|
const env = buildCliEnvironment(this.options.kind);
|
|
529
|
+
if (this.serverPassword) {
|
|
530
|
+
env.OPENCODE_SERVER_PASSWORD = this.serverPassword;
|
|
531
|
+
}
|
|
480
532
|
if (this.activeSessionId) {
|
|
481
533
|
env.OPENCODE_ROUTE = JSON.stringify({
|
|
482
534
|
type: "session",
|
|
@@ -485,6 +537,13 @@ export class OpenCodeServerAdapter {
|
|
|
485
537
|
}
|
|
486
538
|
return env;
|
|
487
539
|
}
|
|
540
|
+
buildServerAuthHeaders() {
|
|
541
|
+
if (!this.serverPassword) {
|
|
542
|
+
return {};
|
|
543
|
+
}
|
|
544
|
+
const credentials = Buffer.from(`opencode:${this.serverPassword}`, "utf8").toString("base64");
|
|
545
|
+
return { Authorization: `Basic ${credentials}` };
|
|
546
|
+
}
|
|
488
547
|
async createSdkClient() {
|
|
489
548
|
try {
|
|
490
549
|
const { createOpencodeClient } = await import("@opencode-ai/sdk/v2");
|
|
@@ -492,6 +551,7 @@ export class OpenCodeServerAdapter {
|
|
|
492
551
|
baseUrl: `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`,
|
|
493
552
|
directory: this.options.cwd,
|
|
494
553
|
experimental_workspaceID: this.activeWorkspaceId ?? undefined,
|
|
554
|
+
headers: this.buildServerAuthHeaders(),
|
|
495
555
|
});
|
|
496
556
|
}
|
|
497
557
|
catch (err) {
|
|
@@ -511,6 +571,7 @@ export class OpenCodeServerAdapter {
|
|
|
511
571
|
while (Date.now() < deadline) {
|
|
512
572
|
try {
|
|
513
573
|
const response = await fetch(`${baseUrl}/global/health`, {
|
|
574
|
+
headers: this.buildServerAuthHeaders(),
|
|
514
575
|
signal: AbortSignal.timeout(OPENCODE_HTTP_READY_PROBE_TIMEOUT_MS),
|
|
515
576
|
});
|
|
516
577
|
if (response.ok) {
|
|
@@ -547,7 +608,9 @@ export class OpenCodeServerAdapter {
|
|
|
547
608
|
}
|
|
548
609
|
const major = Number(match[1]);
|
|
549
610
|
const minor = Number(match[2]);
|
|
550
|
-
return major
|
|
611
|
+
return (major >= 1 &&
|
|
612
|
+
major < OPENCODE_MAXIMUM_SUPPORTED_MAJOR &&
|
|
613
|
+
(major > 1 || minor >= 18));
|
|
551
614
|
}
|
|
552
615
|
async initializeSessions() {
|
|
553
616
|
if (!this.client) {
|
|
@@ -717,6 +780,9 @@ export class OpenCodeServerAdapter {
|
|
|
717
780
|
return this.normalizeGlobalSyncPayload(payload, rawEvent);
|
|
718
781
|
}
|
|
719
782
|
const normalized = { ...payload };
|
|
783
|
+
if (typeof payload.id === "string") {
|
|
784
|
+
normalized.id = payload.id;
|
|
785
|
+
}
|
|
720
786
|
if (typeof rawEvent.directory === "string") {
|
|
721
787
|
normalized.directory = rawEvent.directory;
|
|
722
788
|
}
|
|
@@ -792,7 +858,8 @@ export class OpenCodeServerAdapter {
|
|
|
792
858
|
this.handleSessionError(isRecord(payload) ? payload : undefined);
|
|
793
859
|
return;
|
|
794
860
|
}
|
|
795
|
-
|
|
861
|
+
// Note: only permission.asked exists in the OpenCode wire protocol;
|
|
862
|
+
// permission.updated was never emitted by the server.
|
|
796
863
|
case "permission.asked": {
|
|
797
864
|
this.handlePermissionRequest(payload);
|
|
798
865
|
return;
|
|
@@ -930,7 +997,10 @@ export class OpenCodeServerAdapter {
|
|
|
930
997
|
if (sessionId && !this.syncTrackedSessionFromEvent(sessionId)) {
|
|
931
998
|
return;
|
|
932
999
|
}
|
|
933
|
-
// properties: { sessionID
|
|
1000
|
+
// properties: { sessionID, status: { type: "busy" | "idle" | "retry" | ... } }
|
|
1001
|
+
// "retry" means the model call is being retried (attempt, message): the
|
|
1002
|
+
// session is still working, so it must keep the busy state instead of
|
|
1003
|
+
// letting WeChat messages race a retried turn.
|
|
934
1004
|
const status = properties.status;
|
|
935
1005
|
if (!isRecord(status)) {
|
|
936
1006
|
return;
|
|
@@ -939,7 +1009,7 @@ export class OpenCodeServerAdapter {
|
|
|
939
1009
|
if (!statusType) {
|
|
940
1010
|
return;
|
|
941
1011
|
}
|
|
942
|
-
if (statusType === "busy" || statusType === "running") {
|
|
1012
|
+
if (statusType === "busy" || statusType === "running" || statusType === "retry") {
|
|
943
1013
|
if (this.state.status === "idle") {
|
|
944
1014
|
this.outputBatcher.clear();
|
|
945
1015
|
this.clearStreamedPartState();
|
|
@@ -1595,8 +1665,11 @@ export class OpenCodeServerAdapter {
|
|
|
1595
1665
|
if (!sessionId) {
|
|
1596
1666
|
return;
|
|
1597
1667
|
}
|
|
1598
|
-
if (
|
|
1599
|
-
this.
|
|
1668
|
+
if (this.suppressedTuiSessionSelect &&
|
|
1669
|
+
sessionId === this.suppressedTuiSessionSelect.sessionId) {
|
|
1670
|
+
// Echo of our own selectSession — swallow it once. The TTL guard in
|
|
1671
|
+
// isSuppressedTuiSessionSelectExpired covers the lost-echo case.
|
|
1672
|
+
this.suppressedTuiSessionSelect = null;
|
|
1600
1673
|
return;
|
|
1601
1674
|
}
|
|
1602
1675
|
this.pendingLocalPrompt = "";
|
|
@@ -2142,13 +2215,27 @@ export class OpenCodeServerAdapter {
|
|
|
2142
2215
|
workspaceID: this.activeWorkspaceId ?? undefined,
|
|
2143
2216
|
}, { force: options.force, retry: options.retry });
|
|
2144
2217
|
}
|
|
2218
|
+
isTuiSessionSelectSuppressed(sessionId) {
|
|
2219
|
+
const suppression = this.suppressedTuiSessionSelect;
|
|
2220
|
+
if (!suppression || suppression.sessionId !== sessionId) {
|
|
2221
|
+
return false;
|
|
2222
|
+
}
|
|
2223
|
+
// Bound the suppression window: if the echo never arrives (TUI not ready,
|
|
2224
|
+
// dropped event), a stale flag must not swallow a later genuine local
|
|
2225
|
+
// switch to the same session.
|
|
2226
|
+
if (Date.now() - suppression.setAtMs > TUI_SESSION_SELECT_SUPPRESSION_TTL_MS) {
|
|
2227
|
+
this.suppressedTuiSessionSelect = null;
|
|
2228
|
+
return false;
|
|
2229
|
+
}
|
|
2230
|
+
return true;
|
|
2231
|
+
}
|
|
2145
2232
|
async syncVisibleSessionSelection(session, options = {}) {
|
|
2146
2233
|
if (!this.client?.tui ||
|
|
2147
2234
|
this.options.renderMode !== "companion" ||
|
|
2148
|
-
(!options.force && session.id
|
|
2235
|
+
(!options.force && this.isTuiSessionSelectSuppressed(session.id))) {
|
|
2149
2236
|
return;
|
|
2150
2237
|
}
|
|
2151
|
-
this.
|
|
2238
|
+
this.suppressedTuiSessionSelect = { sessionId: session.id, setAtMs: Date.now() };
|
|
2152
2239
|
this.logDebug(`[opencode-adapter:tui] selectSession session=${session.id} workspace=${session.workspaceID ?? this.activeWorkspaceId ?? "(none)"}`);
|
|
2153
2240
|
try {
|
|
2154
2241
|
await this.sendVisibleSessionSelection(session);
|
|
@@ -2157,8 +2244,8 @@ export class OpenCodeServerAdapter {
|
|
|
2157
2244
|
}
|
|
2158
2245
|
}
|
|
2159
2246
|
catch (err) {
|
|
2160
|
-
if (this.
|
|
2161
|
-
this.
|
|
2247
|
+
if (this.suppressedTuiSessionSelect?.sessionId === session.id) {
|
|
2248
|
+
this.suppressedTuiSessionSelect = null;
|
|
2162
2249
|
}
|
|
2163
2250
|
this.logDebug(`[opencode-adapter:tui] selectSession failed for ${session.id}: ${describeUnknownError(err)}`);
|
|
2164
2251
|
}
|
|
@@ -2266,8 +2353,10 @@ export class OpenCodeServerAdapter {
|
|
|
2266
2353
|
}
|
|
2267
2354
|
return true;
|
|
2268
2355
|
}
|
|
2356
|
+
// Fold both path separators to "/" so directory comparisons behave
|
|
2357
|
+
// identically on Windows (backslash-native) and POSIX hosts.
|
|
2269
2358
|
normalizeDirectory(directory) {
|
|
2270
|
-
return directory.replace(/[\\/]+/g, "
|
|
2359
|
+
return directory.replace(/[\\/]+/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
2271
2360
|
}
|
|
2272
2361
|
recordSessionSwitch(sessionId, source, reason, notify = false) {
|
|
2273
2362
|
const timestamp = nowIso();
|
|
@@ -2325,13 +2414,20 @@ export class OpenCodeServerAdapter {
|
|
|
2325
2414
|
return "";
|
|
2326
2415
|
}
|
|
2327
2416
|
const previousText = this.emittedTextByPartId.get(partId) ?? "";
|
|
2328
|
-
if (nextChunk === previousText
|
|
2417
|
+
if (nextChunk === previousText) {
|
|
2418
|
+
// Full replay of everything emitted for this part (an SSE replay after
|
|
2419
|
+
// a reconnect): nothing new.
|
|
2329
2420
|
return "";
|
|
2330
2421
|
}
|
|
2331
2422
|
if (previousText && nextChunk.startsWith(previousText)) {
|
|
2423
|
+
// The "delta" actually carries the accumulated part text: keep the tail.
|
|
2332
2424
|
this.emittedTextByPartId.set(partId, nextChunk);
|
|
2333
2425
|
return nextChunk.slice(previousText.length);
|
|
2334
2426
|
}
|
|
2427
|
+
// Plain incremental delta: append. Duplicated fragments that legitimately
|
|
2428
|
+
// occur in model output (repeated words, table separators, markdown
|
|
2429
|
+
// rules) must still be forwarded, so there is deliberately no
|
|
2430
|
+
// suffix-based dedup here — it silently truncated real content.
|
|
2335
2431
|
this.emittedTextByPartId.set(partId, `${previousText}${nextChunk}`);
|
|
2336
2432
|
return nextChunk;
|
|
2337
2433
|
}
|
|
@@ -2419,18 +2515,31 @@ export class OpenCodeServerAdapter {
|
|
|
2419
2515
|
if (!key) {
|
|
2420
2516
|
return false;
|
|
2421
2517
|
}
|
|
2518
|
+
// Frame-id keys identify one exact server event, so any repeat — even on
|
|
2519
|
+
// the same stream (SSE replay) — is a duplicate; the wide TTL absorbs
|
|
2520
|
+
// reconnect replays. Synthetic keys still rely on the narrow cross-stream
|
|
2521
|
+
// window only, because they cannot distinguish a replay from a legitimate
|
|
2522
|
+
// repeated payload.
|
|
2523
|
+
const idKeyTtlMs = 30_000;
|
|
2524
|
+
const isIdKey = key.startsWith("evt:");
|
|
2525
|
+
const ttlMs = isIdKey ? idKeyTtlMs : OPENCODE_DUPLICATE_EVENT_TTL_MS;
|
|
2422
2526
|
const now = Date.now();
|
|
2423
|
-
const cutoff = now - OPENCODE_DUPLICATE_EVENT_TTL_MS;
|
|
2424
2527
|
for (const [candidateKey, observed] of this.recentSdkEventObservations.entries()) {
|
|
2425
|
-
if (observed.observedAtMs <
|
|
2528
|
+
if (observed.observedAtMs < now - idKeyTtlMs) {
|
|
2426
2529
|
this.recentSdkEventObservations.delete(candidateKey);
|
|
2427
2530
|
}
|
|
2428
2531
|
}
|
|
2429
2532
|
const previous = this.recentSdkEventObservations.get(key);
|
|
2430
2533
|
this.recentSdkEventObservations.set(key, { streamName, observedAtMs: now });
|
|
2431
|
-
return Boolean(previous &&
|
|
2534
|
+
return Boolean(previous &&
|
|
2535
|
+
previous.observedAtMs >= now - ttlMs &&
|
|
2536
|
+
(isIdKey || previous.streamName !== streamName));
|
|
2432
2537
|
}
|
|
2433
2538
|
getDuplicateSdkEventKey(event) {
|
|
2539
|
+
// Exact frame ids (when the stream provides them) dedupe precisely.
|
|
2540
|
+
if (typeof event.id === "string" && event.id) {
|
|
2541
|
+
return `evt:${event.id}`;
|
|
2542
|
+
}
|
|
2434
2543
|
const type = this.normalizeEventType(event.type);
|
|
2435
2544
|
const payload = this.extractEventPayload(event);
|
|
2436
2545
|
if (!isRecord(payload)) {
|
|
@@ -636,6 +636,12 @@ export class PiTuiAdapter {
|
|
|
636
636
|
this.reconnectTimer = setTimeout(() => {
|
|
637
637
|
this.reconnectTimer = null;
|
|
638
638
|
if (!this.socket && !this.disposing) {
|
|
639
|
+
// Reset the turn state alongside the fatal error: leaving status at
|
|
640
|
+
// "busy" would keep rejecting every message with "still working"
|
|
641
|
+
// while /stop stays unavailable (no socket) and /reset is the only
|
|
642
|
+
// way out.
|
|
643
|
+
this.clearActiveTurn();
|
|
644
|
+
this.setStatus("idle", "The Pi TUI extension disconnected; the bridge is waiting for a reconnect.");
|
|
639
645
|
this.emit({
|
|
640
646
|
type: "fatal_error",
|
|
641
647
|
message: "The native Pi TUI bridge extension disconnected and did not reconnect.",
|
|
@@ -680,9 +686,13 @@ export class PiTuiAdapter {
|
|
|
680
686
|
this.extensionReady = false;
|
|
681
687
|
this.clearActiveTurn();
|
|
682
688
|
if (!this.disposing) {
|
|
689
|
+
// Report unexpected exits as fatal errors, matching the other adapters:
|
|
690
|
+
// a bare "stopped" status is only logged by the bridge, so WeChat users
|
|
691
|
+
// would get no signal that the TUI is gone until their next message
|
|
692
|
+
// fails with a cryptic not-connected error.
|
|
683
693
|
this.emit({
|
|
684
|
-
type: "
|
|
685
|
-
|
|
694
|
+
type: "fatal_error",
|
|
695
|
+
message: `Pi TUI exited unexpectedly (code=${code ?? "none"}, signal=${signal ?? "none"}). Restart the bridge or use /reset to recover.`,
|
|
686
696
|
timestamp: nowIso(),
|
|
687
697
|
});
|
|
688
698
|
}
|
|
@@ -32,8 +32,6 @@ export const LOCAL_COMPANION_RECONNECT_GRACE_MS = 15_000;
|
|
|
32
32
|
export const CLAUDE_HOOK_LISTEN_HOST = "127.0.0.1";
|
|
33
33
|
export const CLAUDE_HELP_PROBE_TIMEOUT_MS = 5_000;
|
|
34
34
|
export const CLAUDE_WECHAT_WORKING_NOTICE_DELAY_MS = 12_000;
|
|
35
|
-
export const DEFAULT_UNIX_SHELL_CANDIDATES = ["pwsh", "bash", "zsh", "sh"];
|
|
36
|
-
export const POSIX_SHELL_NAMES = new Set(["bash", "zsh", "sh", "dash", "ksh"]);
|
|
37
35
|
export const CLAUDE_FLAG_SUPPORT_CACHE = new Map();
|
|
38
36
|
export const OPENCODE_SERVER_HOST = "127.0.0.1";
|
|
39
37
|
export const OPENCODE_SERVER_READY_TIMEOUT_MS = 10_000;
|
|
@@ -678,44 +676,25 @@ function applyLoopbackNoProxy(env) {
|
|
|
678
676
|
env.no_proxy = mergeNoProxyValue(env.no_proxy);
|
|
679
677
|
return env;
|
|
680
678
|
}
|
|
681
|
-
export function resolveDefaultAdapterCommand(kind,
|
|
682
|
-
|
|
683
|
-
if (kind !== "shell") {
|
|
684
|
-
return kind;
|
|
685
|
-
}
|
|
686
|
-
if (platform === "win32") {
|
|
687
|
-
return "powershell.exe";
|
|
688
|
-
}
|
|
689
|
-
const env = options.env ?? process.env;
|
|
690
|
-
for (const candidate of DEFAULT_UNIX_SHELL_CANDIDATES) {
|
|
691
|
-
if (resolveCommandPath(candidate, platform, env)) {
|
|
692
|
-
return candidate;
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
throw new Error(`No default shell executable was found on ${platform}. Tried: ${DEFAULT_UNIX_SHELL_CANDIDATES.join(", ")}. Use --cmd <executable>.`);
|
|
679
|
+
export function resolveDefaultAdapterCommand(kind, _options = {}) {
|
|
680
|
+
return kind;
|
|
696
681
|
}
|
|
697
|
-
export function buildCliEnvironment(
|
|
682
|
+
export function buildCliEnvironment(_kind, options = {}) {
|
|
698
683
|
const sourceEnv = options.env ?? process.env;
|
|
699
684
|
const platform = options.platform ?? process.platform;
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
const env = {
|
|
707
|
-
...copyDefinedEnv(sourceEnv),
|
|
708
|
-
TERM: sourceEnv.TERM || "xterm-256color",
|
|
709
|
-
};
|
|
710
|
-
if (platform === "win32" && !env.HOME && env.USERPROFILE) {
|
|
711
|
-
env.HOME = env.USERPROFILE;
|
|
712
|
-
}
|
|
713
|
-
return applyLoopbackNoProxy(env);
|
|
714
|
-
}
|
|
715
|
-
return {
|
|
685
|
+
// Pass the full environment through on every platform. A previous
|
|
686
|
+
// Windows-only allowlist silently dropped user-configured variables such
|
|
687
|
+
// as ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / OPENAI_API_KEY, which
|
|
688
|
+
// broke CLIs that authenticate via environment variables even though the
|
|
689
|
+
// same CLI worked when launched manually from the user's shell.
|
|
690
|
+
const env = {
|
|
716
691
|
...copyDefinedEnv(sourceEnv),
|
|
717
692
|
TERM: sourceEnv.TERM || "xterm-256color",
|
|
718
693
|
};
|
|
694
|
+
if (platform === "win32" && !env.HOME && env.USERPROFILE) {
|
|
695
|
+
env.HOME = env.USERPROFILE;
|
|
696
|
+
}
|
|
697
|
+
return applyLoopbackNoProxy(env);
|
|
719
698
|
}
|
|
720
699
|
// ConPTY shipped in Windows 10 build 18309; on older builds node-pty must be
|
|
721
700
|
// left to auto-select its winpty fallback instead of being forced onto ConPTY.
|
|
@@ -738,72 +717,6 @@ export function buildPtySpawnOptions(params) {
|
|
|
738
717
|
}
|
|
739
718
|
return options;
|
|
740
719
|
}
|
|
741
|
-
export function normalizeShellCommandName(command) {
|
|
742
|
-
return path.parse(path.basename(command)).name.toLowerCase();
|
|
743
|
-
}
|
|
744
|
-
export function resolveShellRuntime(command, options = {}) {
|
|
745
|
-
const platform = options.platform ?? process.platform;
|
|
746
|
-
const name = normalizeShellCommandName(command);
|
|
747
|
-
if (name === "powershell" || name === "pwsh") {
|
|
748
|
-
return {
|
|
749
|
-
family: "powershell",
|
|
750
|
-
launchArgs: platform === "win32"
|
|
751
|
-
? ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-NoExit"]
|
|
752
|
-
: ["-NoLogo", "-NoProfile", "-NoExit"],
|
|
753
|
-
};
|
|
754
|
-
}
|
|
755
|
-
if (POSIX_SHELL_NAMES.has(name)) {
|
|
756
|
-
return {
|
|
757
|
-
family: "posix",
|
|
758
|
-
launchArgs: ["-i"],
|
|
759
|
-
};
|
|
760
|
-
}
|
|
761
|
-
throw new Error(`Unsupported shell executable for shell adapter: ${command}. Supported shells: powershell, pwsh, bash, zsh, sh, dash, ksh.`);
|
|
762
|
-
}
|
|
763
|
-
export function escapePowerShellString(text) {
|
|
764
|
-
return text.replace(/`/g, "``").replace(/"/g, '`"');
|
|
765
|
-
}
|
|
766
|
-
export function escapePosixShellString(text) {
|
|
767
|
-
return `'${text.replace(/'/g, `'"'"'`)}'`;
|
|
768
|
-
}
|
|
769
|
-
export function buildShellProfileCommand(profilePath, family) {
|
|
770
|
-
const resolved = path.resolve(profilePath);
|
|
771
|
-
if (family === "powershell") {
|
|
772
|
-
return `. "${escapePowerShellString(resolved)}"`;
|
|
773
|
-
}
|
|
774
|
-
return `. ${escapePosixShellString(resolved)}`;
|
|
775
|
-
}
|
|
776
|
-
export function buildShellInputPayload(text, family, completionMarker = "__WECHAT_BRIDGE_DONE__") {
|
|
777
|
-
if (family === "powershell") {
|
|
778
|
-
const encodedCommand = Buffer.from(text, "utf8").toString("base64");
|
|
779
|
-
const script = [
|
|
780
|
-
"$__wechatBridgePreviousErrorActionPreference = $ErrorActionPreference",
|
|
781
|
-
"$ErrorActionPreference = 'Continue'",
|
|
782
|
-
"$global:LASTEXITCODE = 0",
|
|
783
|
-
"try {",
|
|
784
|
-
` $decoded = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("${escapePowerShellString(encodedCommand)}"))`,
|
|
785
|
-
" $scriptBlock = [scriptblock]::Create($decoded)",
|
|
786
|
-
" & $scriptBlock",
|
|
787
|
-
"} catch {",
|
|
788
|
-
" Write-Error $_",
|
|
789
|
-
" $global:LASTEXITCODE = 1",
|
|
790
|
-
"} finally {",
|
|
791
|
-
" if (-not ($global:LASTEXITCODE -is [int])) { $global:LASTEXITCODE = 0 }",
|
|
792
|
-
` Write-Output "${escapePowerShellString(completionMarker)}:$global:LASTEXITCODE"`,
|
|
793
|
-
" $ErrorActionPreference = $__wechatBridgePreviousErrorActionPreference",
|
|
794
|
-
"}",
|
|
795
|
-
"",
|
|
796
|
-
];
|
|
797
|
-
return `${script.join("\r")}\r`;
|
|
798
|
-
}
|
|
799
|
-
const script = [
|
|
800
|
-
text,
|
|
801
|
-
"__wechat_bridge_status=$?",
|
|
802
|
-
`printf '%s:%s\\n' ${escapePosixShellString(completionMarker)} "$__wechat_bridge_status"`,
|
|
803
|
-
"",
|
|
804
|
-
];
|
|
805
|
-
return `${script.join("\r")}\r`;
|
|
806
|
-
}
|
|
807
720
|
export async function reserveLocalPort() {
|
|
808
721
|
return await new Promise((resolve, reject) => {
|
|
809
722
|
const server = net.createServer();
|
|
@@ -8,9 +8,24 @@ export async function forwardWechatFinalReply(params) {
|
|
|
8
8
|
if (visibleText) {
|
|
9
9
|
// Send long replies in bounded chunks: a single oversized sendmessage call
|
|
10
10
|
// can be rejected by the WeChat API, silently losing the whole reply.
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
const chunks = splitWechatTextIntoChunks(visibleText);
|
|
12
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
13
|
+
const sent = await sender.sendText(chunks[index]);
|
|
13
14
|
if (sent === false) {
|
|
15
|
+
// The send channel is failing (e.g. an expired context token).
|
|
16
|
+
// Report what was dropped instead of ending mid-reply silently;
|
|
17
|
+
// this notice itself is best effort and may also fail.
|
|
18
|
+
const remainingChunks = chunks.length - index - 1;
|
|
19
|
+
if (remainingChunks > 0 || parsed.attachments.length > 0) {
|
|
20
|
+
const parts = [];
|
|
21
|
+
if (remainingChunks > 0) {
|
|
22
|
+
parts.push(`${remainingChunks} reply chunk(s)`);
|
|
23
|
+
}
|
|
24
|
+
if (parsed.attachments.length > 0) {
|
|
25
|
+
parts.push(`${parsed.attachments.length} attachment(s)`);
|
|
26
|
+
}
|
|
27
|
+
await sender.sendText(`[bridge] Reply delivery was interrupted; ${parts.join(" and ")} could not be sent. Send a new message to retry.`);
|
|
28
|
+
}
|
|
14
29
|
return;
|
|
15
30
|
}
|
|
16
31
|
}
|
|
@@ -56,9 +56,14 @@ export function isWechatDaemonCommandLineForCwd(commandLine, cwd) {
|
|
|
56
56
|
* opencode serve --port 12345 --hostname 127.0.0.1
|
|
57
57
|
* opencode.exe serve --port 12345
|
|
58
58
|
* Also matches wrapper invocations (opencode.cmd / opencode.bat) on Windows.
|
|
59
|
+
*
|
|
60
|
+
* `serve` must be the first subcommand token after the executable so that
|
|
61
|
+
* unrelated command lines merely containing both words (e.g.
|
|
62
|
+
* `node opencode-lint/serve.js` or `mytool --theme opencode --mode serve`)
|
|
63
|
+
* are not reaped as orphans.
|
|
59
64
|
*/
|
|
60
65
|
export function isOpencodeServeCommandLine(commandLine) {
|
|
61
|
-
return /\bopencode(?:\.exe|\.cmd|\.bat)?\
|
|
66
|
+
return /\bopencode(?:\.exe|\.cmd|\.bat)?["']?\s+serve\b/i.test(commandLine);
|
|
62
67
|
}
|
|
63
68
|
/**
|
|
64
69
|
* Detects `opencode attach` command lines spawned by the local OpenCode companion.
|
|
@@ -67,7 +72,7 @@ export function isOpencodeServeCommandLine(commandLine) {
|
|
|
67
72
|
* opencode.exe attach http://127.0.0.1:12345 --session ses_123
|
|
68
73
|
*/
|
|
69
74
|
export function isOpencodeAttachCommandLine(commandLine) {
|
|
70
|
-
return /\bopencode(?:\.exe|\.cmd|\.bat)?\
|
|
75
|
+
return /\bopencode(?:\.exe|\.cmd|\.bat)?["']?\s+attach\b/i.test(commandLine);
|
|
71
76
|
}
|
|
72
77
|
function normalizeBridgeProcessRecord(value) {
|
|
73
78
|
if (!isRecord(value)) {
|
|
@@ -158,7 +163,8 @@ function listWindowsBridgeProcesses(currentPid = process.pid) {
|
|
|
158
163
|
"Bypass",
|
|
159
164
|
"-Command",
|
|
160
165
|
[
|
|
161
|
-
|
|
166
|
+
// Trailing semicolon required — see the note in listAllProcessesRaw.
|
|
167
|
+
"$ErrorActionPreference='Stop';",
|
|
162
168
|
"Get-CimInstance Win32_Process",
|
|
163
169
|
"| Where-Object { $_.CommandLine }",
|
|
164
170
|
"| Select-Object ProcessId,ParentProcessId,Name,CommandLine",
|
|
@@ -236,7 +242,11 @@ function listAllProcessesRaw(currentPid = process.pid) {
|
|
|
236
242
|
"Bypass",
|
|
237
243
|
"-Command",
|
|
238
244
|
[
|
|
239
|
-
|
|
245
|
+
// Statement separator required: without the trailing semicolon
|
|
246
|
+
// PowerShell parses the preference assignment and the first command
|
|
247
|
+
// as one statement and fails with a ParserError, silently
|
|
248
|
+
// returning an empty process list on every probe.
|
|
249
|
+
"$ErrorActionPreference='Stop';",
|
|
240
250
|
"Get-CimInstance Win32_Process",
|
|
241
251
|
"| Where-Object { $_.CommandLine }",
|
|
242
252
|
"| Select-Object ProcessId,ParentProcessId,Name,CommandLine",
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, appendBoundedLog, ensureWorkspaceChannelDir, ensureChannelDataDir, } from "../wechat/channel-config.js";
|
|
3
3
|
import { buildInstanceId } from "./bridge-utils.js";
|
|
4
|
+
import { writeJsonFileAtomic } from "../utils/atomic-file.js";
|
|
5
|
+
import { getProcessRecordByPid, isWechatBridgeCommandLine, } from "./bridge-process-reaper.js";
|
|
4
6
|
const ORPHAN_LOCK_RECLAIM_TIMEOUT_MS = 2_000;
|
|
5
7
|
const ORPHAN_LOCK_RECLAIM_POLL_MS = 100;
|
|
6
8
|
export function resolveRestorableSharedSessionId(persisted, options) {
|
|
@@ -137,7 +139,37 @@ export function evaluateBridgeRuntimeOwnership(params) {
|
|
|
137
139
|
function buildLockConflictError(lock) {
|
|
138
140
|
return new Error(`Another bridge instance is already running (pid=${lock.pid}, instanceId=${lock.instanceId}, adapter=${lock.adapter}, cwd=${lock.cwd}, startedAt=${lock.startedAt}, lifecycle=${lock.lifecycle}). Stop it before starting a new bridge.`);
|
|
139
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Check whether the process currently holding a stale lock is still a
|
|
144
|
+
* wechat-bridge process. OS pid reuse can hand the recorded pid to an
|
|
145
|
+
* unrelated process; terminating that pid would kill an innocent process.
|
|
146
|
+
*
|
|
147
|
+
* Returns "bridge" when the command line matches a bridge, "foreign" when the
|
|
148
|
+
* pid is alive but belongs to something else, and "unknown" when the command
|
|
149
|
+
* line could not be resolved (probe failure, restricted permissions).
|
|
150
|
+
*/
|
|
151
|
+
export function classifyLockHolderProcess(pid) {
|
|
152
|
+
if (!isPidAlive(pid)) {
|
|
153
|
+
return "unknown";
|
|
154
|
+
}
|
|
155
|
+
const record = getProcessRecordByPid(pid);
|
|
156
|
+
if (!record) {
|
|
157
|
+
return "unknown";
|
|
158
|
+
}
|
|
159
|
+
return isWechatBridgeCommandLine(record.commandLine) ? "bridge" : "foreign";
|
|
160
|
+
}
|
|
140
161
|
function tryTerminateOrphanedBridge(lock) {
|
|
162
|
+
const holderKind = classifyLockHolderProcess(lock.pid);
|
|
163
|
+
if (holderKind === "foreign") {
|
|
164
|
+
// The lock-holding bridge is gone and its pid was reused by an unrelated
|
|
165
|
+
// process. Reclaim the lock without killing that process.
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
if (holderKind === "unknown") {
|
|
169
|
+
// Could not confirm the pid still belongs to a bridge. Do not risk
|
|
170
|
+
// killing an innocent process; keep the conflict error instead.
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
141
173
|
try {
|
|
142
174
|
process.kill(lock.pid);
|
|
143
175
|
}
|
|
@@ -295,13 +327,9 @@ export class BridgeStateStore {
|
|
|
295
327
|
}
|
|
296
328
|
}
|
|
297
329
|
writeAtomicJsonFile(filePath, value) {
|
|
298
|
-
ensureChannelDataDir();
|
|
299
|
-
const data = JSON.stringify(value, null, 2);
|
|
300
330
|
// temp + rename so a crash mid-write cannot leave a truncated/half-written
|
|
301
331
|
// state or lock file that would break the next launch or look like a stale lock.
|
|
302
|
-
|
|
303
|
-
fs.writeFileSync(tempPath, data, "utf-8");
|
|
304
|
-
fs.renameSync(tempPath, filePath);
|
|
332
|
+
writeJsonFileAtomic(filePath, value);
|
|
305
333
|
}
|
|
306
334
|
save() {
|
|
307
335
|
this.writeAtomicJsonFile(this.stateFilePath, this.state);
|
|
@@ -311,7 +339,14 @@ export class BridgeStateStore {
|
|
|
311
339
|
if (existing &&
|
|
312
340
|
existing.pid !== process.pid &&
|
|
313
341
|
isPidAlive(existing.pid)) {
|
|
314
|
-
|
|
342
|
+
const holderKind = classifyLockHolderProcess(existing.pid);
|
|
343
|
+
if (holderKind === "foreign") {
|
|
344
|
+
// The recorded bridge exited and the OS reused its pid for an
|
|
345
|
+
// unrelated process. The lock is stale: reclaim it instead of
|
|
346
|
+
// dead-locking new bridges on a phantom holder.
|
|
347
|
+
this.appendLog(`stale_lock_pid_reuse: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);
|
|
348
|
+
}
|
|
349
|
+
else if (shouldAutoReclaimBridgeLock(existing)) {
|
|
315
350
|
this.appendLog(`lock_reclaim_attempt: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);
|
|
316
351
|
if (tryTerminateOrphanedBridge(existing)) {
|
|
317
352
|
this.appendLog(`lock_reclaimed: pid=${existing.pid} instanceId=${existing.instanceId} adapter=${existing.adapter} cwd=${existing.cwd}`);
|