cli-wechat-bridge 1.1.7 → 1.1.8
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 -661
- package/README.md +448 -434
- package/bin/_run-entry.mjs +23 -5
- package/dist/bridge/adapter-control.js +58 -0
- package/dist/bridge/bridge-adapters.claude.js +29 -1
- package/dist/bridge/bridge-adapters.codex.js +112 -7
- package/dist/bridge/bridge-adapters.core.js +19 -1
- package/dist/bridge/bridge-adapters.opencode.js +150 -1
- package/dist/bridge/bridge-adapters.pi.js +39 -0
- package/dist/bridge/claude-native-controls.js +133 -0
- package/dist/bridge/native-terminal-control.js +253 -0
- package/dist/bridge/wechat-bridge.js +182 -113
- package/dist/companion/codex-remote-client.js +22 -2
- package/dist/companion/codex-visible-client-link.js +101 -0
- package/dist/companion/local-companion-start.js +7 -2
- package/dist/companion/local-companion.js +17 -0
- package/dist/companion/pi-tui-bridge-extension.js +56 -3
- package/dist/core/bridge-control.js +8 -0
- package/dist/core/conversation-routing.js +48 -0
- package/dist/daemon/wechat-daemon.js +120 -74
- package/dist/i18n/messages-en.js +1 -1
- package/dist/i18n/messages-zh.js +1 -1
- package/dist/runtime/legacy-adapter-runtime.js +15 -0
- package/dist/runtime/runtime-types.js +3 -0
- package/dist/utils/version-checker.js +11 -11
- package/dist/wechat/wechat-transport.js +96 -58
- package/package.json +108 -106
- package/scripts/ensure-node-pty-permissions.mjs +73 -73
package/bin/_run-entry.mjs
CHANGED
|
@@ -8,6 +8,8 @@ const BIN_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
8
8
|
const PROJECT_DIR = path.resolve(BIN_DIR, "..");
|
|
9
9
|
export const MIN_NODE_VERSION = Object.freeze([22, 13, 0]);
|
|
10
10
|
export const MIN_NODE_VERSION_TEXT = MIN_NODE_VERSION.join(".");
|
|
11
|
+
export const MIN_PI_NODE_VERSION = Object.freeze([22, 19, 0]);
|
|
12
|
+
export const MIN_PI_NODE_VERSION_TEXT = MIN_PI_NODE_VERSION.join(".");
|
|
11
13
|
|
|
12
14
|
export function isSupportedNodeVersion(version) {
|
|
13
15
|
const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(version);
|
|
@@ -24,19 +26,21 @@ export function isSupportedNodeVersion(version) {
|
|
|
24
26
|
return true;
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
function ensureSupportedNodeVersion() {
|
|
29
|
+
function ensureSupportedNodeVersion(extraArgs = []) {
|
|
28
30
|
if (process.env.CLI_BRIDGE_SKIP_NODE_CHECK === "1") {
|
|
29
31
|
return;
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
const piRequested = extraArgs.some((arg, index) => arg === "pi" && extraArgs[index - 1] === "--adapter");
|
|
32
35
|
if (isSupportedNodeVersion(process.versions.node)) {
|
|
33
|
-
return;
|
|
36
|
+
if (!piRequested || compareNodeVersion(process.versions.node, MIN_PI_NODE_VERSION) >= 0) return;
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
const minimumText = piRequested ? MIN_PI_NODE_VERSION_TEXT : MIN_NODE_VERSION_TEXT;
|
|
36
40
|
process.stderr.write(
|
|
37
41
|
[
|
|
38
|
-
`[cli-wechat-bridge] Node.js >= ${
|
|
39
|
-
`[cli-wechat-bridge] 需要 Node.js >= ${
|
|
42
|
+
`[cli-wechat-bridge] Node.js >= ${minimumText} is required, but you are running ${process.version}.`,
|
|
43
|
+
`[cli-wechat-bridge] 需要 Node.js >= ${minimumText},当前版本为 ${process.version}。`,
|
|
40
44
|
"Install the latest LTS from https://nodejs.org/ (or via nvm), then retry.",
|
|
41
45
|
"Set CLI_BRIDGE_SKIP_NODE_CHECK=1 to bypass this check at your own risk.",
|
|
42
46
|
"",
|
|
@@ -45,8 +49,22 @@ function ensureSupportedNodeVersion() {
|
|
|
45
49
|
process.exit(1);
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
function compareNodeVersion(version, minimum) {
|
|
53
|
+
const match = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(version);
|
|
54
|
+
if (!match) return -1;
|
|
55
|
+
const current = [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)];
|
|
56
|
+
for (let index = 0; index < minimum.length; index += 1) {
|
|
57
|
+
if (current[index] !== minimum[index]) return current[index] > minimum[index] ? 1 : -1;
|
|
58
|
+
}
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isSupportedPiNodeVersion(version) {
|
|
63
|
+
return compareNodeVersion(version, MIN_PI_NODE_VERSION) >= 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
48
66
|
export function runJsEntry(relativeEntryPath, extraArgs = []) {
|
|
49
|
-
ensureSupportedNodeVersion();
|
|
67
|
+
ensureSupportedNodeVersion([...extraArgs, ...process.argv.slice(2)]);
|
|
50
68
|
const entryPath = path.join(PROJECT_DIR, relativeEntryPath);
|
|
51
69
|
const child = spawn(
|
|
52
70
|
process.execPath,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const snapshots = new WeakMap();
|
|
2
|
+
export const MODEL_SNAPSHOT_TTL_MS = 5 * 60_000;
|
|
3
|
+
export function invalidateModelSnapshot(adapter) {
|
|
4
|
+
snapshots.delete(adapter);
|
|
5
|
+
}
|
|
6
|
+
function snapshotOwner(adapter, senderId) {
|
|
7
|
+
const state = adapter.getState();
|
|
8
|
+
return JSON.stringify([
|
|
9
|
+
senderId, state.kind, state.cwd, state.pid, state.startedAt,
|
|
10
|
+
state.resumeConversationId ?? state.sharedSessionId ?? state.sharedThreadId,
|
|
11
|
+
state.lastSessionSwitchAt ?? state.lastThreadSwitchAt,
|
|
12
|
+
]);
|
|
13
|
+
}
|
|
14
|
+
export async function handleAdapterControl(adapter, senderId, command) {
|
|
15
|
+
const kind = adapter.getState().kind;
|
|
16
|
+
const name = kind === "claude" ? "Claude Code" : kind === "opencode" ? "OpenCode" : kind === "codex" ? "Codex" : "Pi";
|
|
17
|
+
try {
|
|
18
|
+
if (command.type === "plan") {
|
|
19
|
+
if (!adapter.setPlanMode)
|
|
20
|
+
throw new Error(`Plan mode is not available for ${name}.`);
|
|
21
|
+
const enabled = await adapter.setPlanMode(command.enabled);
|
|
22
|
+
return enabled
|
|
23
|
+
? `${name} plan mode enabled. Send /plan off to restore the previous mode.`
|
|
24
|
+
: `${name} plan mode disabled.`;
|
|
25
|
+
}
|
|
26
|
+
if (!adapter.listModels || !adapter.selectModel)
|
|
27
|
+
throw new Error(`Model selection is not available for ${name}.`);
|
|
28
|
+
const owner = snapshotOwner(adapter, senderId);
|
|
29
|
+
if (!command.target) {
|
|
30
|
+
snapshots.delete(adapter);
|
|
31
|
+
const models = await adapter.listModels();
|
|
32
|
+
if (snapshotOwner(adapter, senderId) !== owner)
|
|
33
|
+
throw new Error("The active session changed. Send /model again.");
|
|
34
|
+
if (!models.length)
|
|
35
|
+
return `${name} did not return any available models.`;
|
|
36
|
+
snapshots.set(adapter, { owner, createdAtMs: Date.now(), models });
|
|
37
|
+
return [
|
|
38
|
+
`Available ${name} models:`, "",
|
|
39
|
+
...models.map((model, index) => `${index + 1}. ${model.displayName}${model.isCurrent ? " (current)" : ""}`),
|
|
40
|
+
"", "Reply with /model <number> to switch. The list is valid for 5 minutes.",
|
|
41
|
+
].join("\n");
|
|
42
|
+
}
|
|
43
|
+
const snapshot = snapshots.get(adapter);
|
|
44
|
+
if (!snapshot || snapshot.owner !== owner || Date.now() - snapshot.createdAtMs > MODEL_SNAPSHOT_TTL_MS) {
|
|
45
|
+
throw new Error("The model list has expired or belongs to another session. Send /model again.");
|
|
46
|
+
}
|
|
47
|
+
const index = Number(command.target);
|
|
48
|
+
if (!/^\d+$/.test(command.target) || !Number.isSafeInteger(index) || index < 1 || index > snapshot.models.length) {
|
|
49
|
+
throw new Error("Model selection is outside the displayed range. Send /model again to refresh the list.");
|
|
50
|
+
}
|
|
51
|
+
const model = await adapter.selectModel(snapshot.models[index - 1].id);
|
|
52
|
+
snapshots.delete(adapter);
|
|
53
|
+
return `${name} model switched to ${model.displayName}.`;
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
return error instanceof Error ? error.message : String(error);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { NativeTerminalControl, readClaudePermissionMode } from "./native-terminal-control.js";
|
|
2
|
+
import { ClaudeNativeControls } from "./claude-native-controls.js";
|
|
1
3
|
import fs from "node:fs";
|
|
2
4
|
import net from "node:net";
|
|
3
5
|
import os from "node:os";
|
|
@@ -345,6 +347,20 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
|
|
|
345
347
|
remoteSubmitRetryTimer = null;
|
|
346
348
|
remoteSubmitRetryIndex = 0;
|
|
347
349
|
remoteSubmitText = null;
|
|
350
|
+
terminalControl = new NativeTerminalControl({
|
|
351
|
+
write: (text) => this.writeToPty(text),
|
|
352
|
+
identity: () => JSON.stringify([this.state.pid, this.runtimeSessionId, this.resumeConversationId]),
|
|
353
|
+
isPromptEmpty: () => readClaudePermissionMode(this.terminalControl.text) !== null,
|
|
354
|
+
assertReady: () => {
|
|
355
|
+
if (!this.pty || this.state.status !== "idle" || this.pendingApproval || this.pendingResume) {
|
|
356
|
+
throw new Error("Claude must be idle with no pending approval before using remote controls.");
|
|
357
|
+
}
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
nativeControls = new ClaudeNativeControls(this.terminalControl, () => this.resumeConversationId ?? this.runtimeSessionId ?? "");
|
|
361
|
+
async listModels() { return this.nativeControls.listModels(); }
|
|
362
|
+
async selectModel(modelId) { return this.nativeControls.selectModel(modelId); }
|
|
363
|
+
async setPlanMode(enabled) { return this.nativeControls.setPlanMode(enabled); }
|
|
348
364
|
constructor(options) {
|
|
349
365
|
super(options);
|
|
350
366
|
const shouldRestoreInitialSession = options.sessionStartMode !== "new";
|
|
@@ -408,6 +424,8 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
|
|
|
408
424
|
}
|
|
409
425
|
}
|
|
410
426
|
async sendInput(text) {
|
|
427
|
+
if (this.terminalControl.active)
|
|
428
|
+
throw new Error("A Claude control operation is in progress.");
|
|
411
429
|
if (!this.pty) {
|
|
412
430
|
throw new Error("claude adapter is not running.");
|
|
413
431
|
}
|
|
@@ -452,6 +470,8 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
|
|
|
452
470
|
return listClaudeResumeSessions(this.options.cwd, limit);
|
|
453
471
|
}
|
|
454
472
|
async resumeSession(sessionId) {
|
|
473
|
+
if (this.terminalControl.active)
|
|
474
|
+
throw new Error("A CLI control operation is in progress. Wait for it to finish before switching sessions.");
|
|
455
475
|
if (!this.pty) {
|
|
456
476
|
throw new Error("Claude is not running yet.");
|
|
457
477
|
}
|
|
@@ -548,6 +568,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
|
|
|
548
568
|
return findClaudeResumeSessionFile(this.options.cwd, sessionId);
|
|
549
569
|
}
|
|
550
570
|
async interrupt() {
|
|
571
|
+
this.terminalControl.cancel("The remote control operation was interrupted.");
|
|
551
572
|
if (!this.pty) {
|
|
552
573
|
return false;
|
|
553
574
|
}
|
|
@@ -578,6 +599,8 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
|
|
|
578
599
|
return true;
|
|
579
600
|
}
|
|
580
601
|
async reset() {
|
|
602
|
+
if (this.terminalControl.active)
|
|
603
|
+
throw new Error("A CLI control operation is in progress. Wait for it to finish before switching sessions.");
|
|
581
604
|
this.rejectPendingResume(new Error("Claude was reset during session switching."));
|
|
582
605
|
this.clearWechatWorkingNotice(true);
|
|
583
606
|
this.pendingCliApprovalHints = null;
|
|
@@ -647,6 +670,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
|
|
|
647
670
|
return ok ? 1 : 0;
|
|
648
671
|
}
|
|
649
672
|
async dispose() {
|
|
673
|
+
this.terminalControl.cancel("Claude is shutting down.");
|
|
650
674
|
this.rejectPendingResume(new Error("Claude is shutting down during session switching."));
|
|
651
675
|
this.detachLocalTerminal();
|
|
652
676
|
this.clearWechatWorkingNotice(true);
|
|
@@ -676,6 +700,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
|
|
|
676
700
|
this.startHookHealthCheck();
|
|
677
701
|
}
|
|
678
702
|
handleData(rawText) {
|
|
703
|
+
this.terminalControl.feed(rawText);
|
|
679
704
|
this.renderLocalOutput(rawText);
|
|
680
705
|
const text = normalizeOutput(rawText);
|
|
681
706
|
if (!text) {
|
|
@@ -747,6 +772,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
|
|
|
747
772
|
return true;
|
|
748
773
|
}
|
|
749
774
|
handleExit(exitCode) {
|
|
775
|
+
this.terminalControl.cancel("Claude exited during the control operation.");
|
|
750
776
|
this.detachLocalTerminal();
|
|
751
777
|
this.clearWechatWorkingNotice(true);
|
|
752
778
|
this.clearRemoteSubmitRetry();
|
|
@@ -903,6 +929,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
|
|
|
903
929
|
}
|
|
904
930
|
this.localTerminalInputListener = (chunk) => {
|
|
905
931
|
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
932
|
+
this.terminalControl.localInput(text);
|
|
906
933
|
this.writeToPty(text);
|
|
907
934
|
};
|
|
908
935
|
process.stdin.on("data", this.localTerminalInputListener);
|
|
@@ -931,10 +958,11 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
|
|
|
931
958
|
}
|
|
932
959
|
}
|
|
933
960
|
resizePtyToTerminal() {
|
|
934
|
-
if (!this.pty
|
|
961
|
+
if (!this.pty) {
|
|
935
962
|
return;
|
|
936
963
|
}
|
|
937
964
|
try {
|
|
965
|
+
this.terminalControl.resize(process.stdout.columns || DEFAULT_COLS, process.stdout.rows || DEFAULT_ROWS);
|
|
938
966
|
this.pty.resize?.(process.stdout.columns || DEFAULT_COLS, process.stdout.rows || DEFAULT_ROWS);
|
|
939
967
|
}
|
|
940
968
|
catch {
|
|
@@ -7,7 +7,8 @@ import { normalizeOutput, nowIso, summarizeOutput, truncatePreview, } from "../c
|
|
|
7
7
|
import { AbstractPtyAdapter } from "./bridge-adapters.core.js";
|
|
8
8
|
import { killProcessTreeSync } from "./bridge-process-reaper.js";
|
|
9
9
|
import * as shared from "./bridge-adapters.shared.js";
|
|
10
|
-
import { requestCodexVisibleThreadSwitch } from "../companion/codex-visible-client-link.js";
|
|
10
|
+
import { requestCodexVisibleClientShutdown, requestCodexVisibleThreadSwitch, } from "../companion/codex-visible-client-link.js";
|
|
11
|
+
import { readLocalCompanionEndpoint } from "../companion/local-companion-link.js";
|
|
11
12
|
import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
|
|
12
13
|
import { CODEX_REMOTE_AUTH_TOKEN_ENV, LOCAL_CLIENT_PROTOCOL_VERSION, } from "../runtime/runtime-types.js";
|
|
13
14
|
export function parseCodexCliVersion(output) {
|
|
@@ -21,7 +22,7 @@ export function isCodexVersionInCompatibilityRange(version) {
|
|
|
21
22
|
}
|
|
22
23
|
const major = parts[0] ?? -1;
|
|
23
24
|
const minor = parts[1] ?? -1;
|
|
24
|
-
return major === 0 && minor >= 149 && minor <=
|
|
25
|
+
return major === 0 && minor >= 149 && minor <= 155;
|
|
25
26
|
}
|
|
26
27
|
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;
|
|
27
28
|
const CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS = 150;
|
|
@@ -78,6 +79,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
78
79
|
appServerAuthToken = null;
|
|
79
80
|
appServerAuthTokenFilePath = null;
|
|
80
81
|
rpcSocket = null;
|
|
82
|
+
lastRpcCloseDetail = "";
|
|
81
83
|
rpcShuttingDown = false;
|
|
82
84
|
rpcReconnectPromise = null;
|
|
83
85
|
cleanPanelExitInProgress = false;
|
|
@@ -86,6 +88,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
86
88
|
subscribedThreadIds = new Set();
|
|
87
89
|
pendingThreadSubscriptions = new Map();
|
|
88
90
|
sharedThreadId = null;
|
|
91
|
+
previousCollaborationMode = null;
|
|
89
92
|
announcedThreadId = null;
|
|
90
93
|
localThreadFollowBlockedUntilMs = 0;
|
|
91
94
|
pendingThreadStatusChecks = new Map();
|
|
@@ -182,6 +185,13 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
182
185
|
throw err;
|
|
183
186
|
}
|
|
184
187
|
}
|
|
188
|
+
async prepareVisibleClientSession() {
|
|
189
|
+
if (this.isNativePanelMode() || this.sharedThreadId) {
|
|
190
|
+
return Boolean(this.sharedThreadId);
|
|
191
|
+
}
|
|
192
|
+
const threadId = await this.ensureThreadStarted();
|
|
193
|
+
return Boolean(threadId);
|
|
194
|
+
}
|
|
185
195
|
buildSpawnArgs() {
|
|
186
196
|
if (!this.appServerPort) {
|
|
187
197
|
throw new Error("Codex app-server is not ready.");
|
|
@@ -248,6 +258,74 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
248
258
|
}
|
|
249
259
|
await this.resumeVisibleSharedThread(threadId);
|
|
250
260
|
}
|
|
261
|
+
async listModels() {
|
|
262
|
+
if (!this.usesRpcTurnTransport() || !this.sharedThreadId) {
|
|
263
|
+
throw new Error("Codex model selection requires an active app-server thread.");
|
|
264
|
+
}
|
|
265
|
+
const response = await this.sendRpcRequest("model/list", { includeHidden: false });
|
|
266
|
+
const data = isRecord(response) && Array.isArray(response.data) ? response.data : [];
|
|
267
|
+
const current = await this.readCodexThreadSettings();
|
|
268
|
+
const models = data.flatMap((item) => {
|
|
269
|
+
if (!isRecord(item))
|
|
270
|
+
return [];
|
|
271
|
+
const id = typeof item.id === "string" ? item.id : typeof item.model === "string" ? item.model : "";
|
|
272
|
+
if (!id)
|
|
273
|
+
return [];
|
|
274
|
+
const displayName = typeof item.displayName === "string" ? item.displayName : id;
|
|
275
|
+
const efforts = Array.isArray(item.supportedReasoningEfforts)
|
|
276
|
+
? item.supportedReasoningEfforts.filter((value) => typeof value === "string")
|
|
277
|
+
: undefined;
|
|
278
|
+
return [{ id, displayName, isCurrent: id === current.model, supportedReasoningEfforts: efforts }];
|
|
279
|
+
});
|
|
280
|
+
return models;
|
|
281
|
+
}
|
|
282
|
+
async selectModel(modelId) {
|
|
283
|
+
const models = await this.listModels();
|
|
284
|
+
const selected = models.find((model) => model.id === modelId);
|
|
285
|
+
if (!selected)
|
|
286
|
+
throw new Error(`Codex model ${modelId} is not available.`);
|
|
287
|
+
await this.sendRpcRequest("thread/settings/update", { threadId: this.sharedThreadId, model: selected.id });
|
|
288
|
+
return selected;
|
|
289
|
+
}
|
|
290
|
+
async setPlanMode(enabled) {
|
|
291
|
+
if (!this.usesRpcTurnTransport() || !this.sharedThreadId) {
|
|
292
|
+
throw new Error("Codex plan mode requires an active app-server thread.");
|
|
293
|
+
}
|
|
294
|
+
const settings = await this.readCodexThreadSettings();
|
|
295
|
+
if (enabled) {
|
|
296
|
+
const response = await this.sendRpcRequest("collaborationMode/list", {});
|
|
297
|
+
const modes = isRecord(response) && Array.isArray(response.data) ? response.data : [];
|
|
298
|
+
const plan = modes.find((item) => isRecord(item) && (item.mode === "plan" || item.name?.toString().toLowerCase() === "plan"));
|
|
299
|
+
if (!isRecord(plan))
|
|
300
|
+
throw new Error("Codex plan mode is not available in this app-server.");
|
|
301
|
+
this.previousCollaborationMode = isRecord(settings.collaborationMode) ? settings.collaborationMode : null;
|
|
302
|
+
const mode = typeof plan.mode === "string" ? plan.mode : "plan";
|
|
303
|
+
const model = typeof plan.model === "string" ? plan.model : settings.model;
|
|
304
|
+
const effort = plan.reasoning_effort ?? plan.reasoningEffort ?? null;
|
|
305
|
+
await this.sendRpcRequest("thread/settings/update", {
|
|
306
|
+
threadId: this.sharedThreadId,
|
|
307
|
+
collaborationMode: { mode, settings: { model, reasoning_effort: effort, developer_instructions: null } },
|
|
308
|
+
});
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
const previous = this.previousCollaborationMode ?? {
|
|
312
|
+
mode: "default",
|
|
313
|
+
settings: { model: settings.model, reasoning_effort: settings.effort ?? null, developer_instructions: null },
|
|
314
|
+
};
|
|
315
|
+
await this.sendRpcRequest("thread/settings/update", {
|
|
316
|
+
threadId: this.sharedThreadId,
|
|
317
|
+
collaborationMode: previous,
|
|
318
|
+
});
|
|
319
|
+
this.previousCollaborationMode = null;
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
async readCodexThreadSettings() {
|
|
323
|
+
const response = await this.sendRpcRequest("thread/read", { threadId: this.sharedThreadId, includeTurns: false });
|
|
324
|
+
const thread = isRecord(response) && isRecord(response.thread) ? response.thread : null;
|
|
325
|
+
if (!thread || typeof thread.model !== "string")
|
|
326
|
+
throw new Error("Codex did not return the current thread model.");
|
|
327
|
+
return { model: thread.model, effort: thread.reasoningEffort, collaborationMode: thread.collaborationMode };
|
|
328
|
+
}
|
|
251
329
|
async interrupt() {
|
|
252
330
|
if (this.usesRpcTurnTransport()) {
|
|
253
331
|
return await this.interruptPanelTurn();
|
|
@@ -315,6 +393,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
315
393
|
return true;
|
|
316
394
|
}
|
|
317
395
|
async dispose() {
|
|
396
|
+
await this.shutdownVisibleClient();
|
|
318
397
|
this.resetTurnTracking({ preserveThread: false });
|
|
319
398
|
if (this.isEmbeddedCliMode()) {
|
|
320
399
|
this.detachLocalInputForwarding();
|
|
@@ -346,6 +425,25 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
346
425
|
}
|
|
347
426
|
await this.stopAppServer();
|
|
348
427
|
}
|
|
428
|
+
async shutdownVisibleClient() {
|
|
429
|
+
const endpoint = readLocalCompanionEndpoint(this.options.cwd, { adapter: "codex" });
|
|
430
|
+
if (!endpoint?.companionPid ||
|
|
431
|
+
endpoint.instanceId !== this.localClientInstanceId ||
|
|
432
|
+
!endpoint.codexControlPort ||
|
|
433
|
+
!endpoint.codexControlToken) {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
await requestCodexVisibleClientShutdown({
|
|
438
|
+
cwd: this.options.cwd,
|
|
439
|
+
instanceId: endpoint.instanceId,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
// The visible client may already be exiting. Its endpoint is cleared
|
|
444
|
+
// by the supervisor when the child process finishes.
|
|
445
|
+
}
|
|
446
|
+
}
|
|
349
447
|
getLocalClientEndpoint() {
|
|
350
448
|
if (!this.isHeadlessRuntimeMode() || !this.appServerPort || !this.appServerAuthToken) {
|
|
351
449
|
return null;
|
|
@@ -1192,8 +1290,8 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1192
1290
|
socket.addEventListener("message", (event) => {
|
|
1193
1291
|
this.handleRpcMessageData(event.data);
|
|
1194
1292
|
});
|
|
1195
|
-
socket.addEventListener("close", () => {
|
|
1196
|
-
this.handleRpcSocketClosed();
|
|
1293
|
+
socket.addEventListener("close", (event) => {
|
|
1294
|
+
this.handleRpcSocketClosed(event.code, event.reason);
|
|
1197
1295
|
});
|
|
1198
1296
|
}
|
|
1199
1297
|
async disconnectRpcClient() {
|
|
@@ -1231,13 +1329,14 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1231
1329
|
});
|
|
1232
1330
|
this.rpcShuttingDown = false;
|
|
1233
1331
|
}
|
|
1234
|
-
handleRpcSocketClosed() {
|
|
1332
|
+
handleRpcSocketClosed(code, reason) {
|
|
1235
1333
|
const expectedShutdown = shouldSuppressCodexTransportFatalError({
|
|
1236
1334
|
transportShuttingDown: this.rpcShuttingDown,
|
|
1237
1335
|
shuttingDown: this.shuttingDown,
|
|
1238
1336
|
cleanPanelExitInProgress: this.cleanPanelExitInProgress,
|
|
1239
1337
|
});
|
|
1240
1338
|
this.rpcSocket = null;
|
|
1339
|
+
this.lastRpcCloseDetail = `close_code=${code ?? "unknown"}${reason ? ` reason=${truncatePreview(reason, 240)}` : ""}`;
|
|
1241
1340
|
this.subscribedThreadIds.clear();
|
|
1242
1341
|
this.rejectPendingRpcRequests("Codex websocket connection closed.");
|
|
1243
1342
|
this.rpcShuttingDown = false;
|
|
@@ -1269,7 +1368,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1269
1368
|
const details = this.describeAppServerLog();
|
|
1270
1369
|
this.emit({
|
|
1271
1370
|
type: "fatal_error",
|
|
1272
|
-
message: `codex app-server websocket closed unexpectedly.${details}`,
|
|
1371
|
+
message: `codex app-server websocket closed unexpectedly (${this.lastRpcCloseDetail || "no close details"}).${details}`,
|
|
1273
1372
|
timestamp: nowIso(),
|
|
1274
1373
|
});
|
|
1275
1374
|
this.terminateCodexClient();
|
|
@@ -1307,7 +1406,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1307
1406
|
}
|
|
1308
1407
|
this.emit({
|
|
1309
1408
|
type: "fatal_error",
|
|
1310
|
-
message: `codex app-server websocket closed unexpectedly and could not reconnect: ${lastError}.${details}`,
|
|
1409
|
+
message: `codex app-server websocket closed unexpectedly and could not reconnect (${this.lastRpcCloseDetail || "no close details"}): ${lastError}.${details}`,
|
|
1311
1410
|
timestamp: nowIso(),
|
|
1312
1411
|
});
|
|
1313
1412
|
this.terminateCodexClient();
|
|
@@ -1460,6 +1559,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1460
1559
|
if (typeof thread.parentThreadId === "string") {
|
|
1461
1560
|
throw new Error(`Codex thread ${targetThreadId} is a subagent thread and cannot be resumed.`);
|
|
1462
1561
|
}
|
|
1562
|
+
if (thread.canAcceptDirectInput === false) {
|
|
1563
|
+
throw new Error(`Codex thread ${targetThreadId} is owned by a parent agent and cannot accept direct input.`);
|
|
1564
|
+
}
|
|
1463
1565
|
if (thread.ephemeral === true) {
|
|
1464
1566
|
throw new Error(`Codex thread ${targetThreadId} is ephemeral and cannot be resumed.`);
|
|
1465
1567
|
}
|
|
@@ -1483,6 +1585,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
|
|
|
1483
1585
|
if (typeof thread.parentThreadId === "string") {
|
|
1484
1586
|
throw new Error(`Codex thread ${targetThreadId} is a subagent thread.`);
|
|
1485
1587
|
}
|
|
1588
|
+
if (thread.canAcceptDirectInput === false) {
|
|
1589
|
+
throw new Error(`Codex thread ${targetThreadId} is owned by a parent agent.`);
|
|
1590
|
+
}
|
|
1486
1591
|
if (thread.ephemeral === true) {
|
|
1487
1592
|
throw new Error(`Codex thread ${targetThreadId} is ephemeral.`);
|
|
1488
1593
|
}
|
|
@@ -15,7 +15,7 @@ const { CODEX_APP_SERVER_HOST, INTERRUPT_SETTLE_DELAY_MS, buildCliEnvironment, b
|
|
|
15
15
|
* the script path contains spaces: node-pty quotes the already-quoted path,
|
|
16
16
|
* making cmd.exe treat the literal quotes as part of the filename.
|
|
17
17
|
*/
|
|
18
|
-
function unwrapCmdExeForPty(rawTarget) {
|
|
18
|
+
export function unwrapCmdExeForPty(rawTarget) {
|
|
19
19
|
if (process.platform !== "win32") {
|
|
20
20
|
return rawTarget;
|
|
21
21
|
}
|
|
@@ -166,6 +166,15 @@ export class LocalCompanionProxyAdapter {
|
|
|
166
166
|
command: "create_session",
|
|
167
167
|
});
|
|
168
168
|
}
|
|
169
|
+
async listModels() {
|
|
170
|
+
return await this.sendRequest({ command: "list_models" });
|
|
171
|
+
}
|
|
172
|
+
async selectModel(modelId) {
|
|
173
|
+
return await this.sendRequest({ command: "select_model", modelId });
|
|
174
|
+
}
|
|
175
|
+
async setPlanMode(enabled) {
|
|
176
|
+
return await this.sendRequest({ command: "set_plan_mode", enabled });
|
|
177
|
+
}
|
|
169
178
|
async interrupt() {
|
|
170
179
|
const result = await this.sendRequest({
|
|
171
180
|
command: "interrupt",
|
|
@@ -612,6 +621,15 @@ export class AbstractPtyAdapter {
|
|
|
612
621
|
setEventSink(sink) {
|
|
613
622
|
this.eventSink = sink;
|
|
614
623
|
}
|
|
624
|
+
async listModels() {
|
|
625
|
+
throw new Error(`/${this.options.kind} model selection is not available.`);
|
|
626
|
+
}
|
|
627
|
+
async selectModel(_modelId) {
|
|
628
|
+
throw new Error(`/${this.options.kind} model selection is not available.`);
|
|
629
|
+
}
|
|
630
|
+
async setPlanMode(_enabled) {
|
|
631
|
+
throw new Error(`/${this.options.kind} plan mode is not available.`);
|
|
632
|
+
}
|
|
615
633
|
async start() {
|
|
616
634
|
if (this.pty) {
|
|
617
635
|
return;
|
|
@@ -85,6 +85,139 @@ export class OpenCodeServerAdapter {
|
|
|
85
85
|
pendingLocalSessionCreateFollowUntilMs = 0;
|
|
86
86
|
pendingPermission = null;
|
|
87
87
|
pendingQuestion = null;
|
|
88
|
+
previousAgentBySession = new Map();
|
|
89
|
+
async listModels() {
|
|
90
|
+
this.assertRemoteControlReady();
|
|
91
|
+
const session = await this.ensureControlSession();
|
|
92
|
+
const models = await this.listControlModels();
|
|
93
|
+
return models.map((model) => ({
|
|
94
|
+
id: model.id,
|
|
95
|
+
displayName: model.displayName,
|
|
96
|
+
isCurrent: session.model?.providerID === model.providerID &&
|
|
97
|
+
session.model.id === model.modelID,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
async selectModel(modelId) {
|
|
101
|
+
this.assertRemoteControlReady();
|
|
102
|
+
const session = await this.ensureControlSession();
|
|
103
|
+
const models = await this.listControlModels();
|
|
104
|
+
const target = models.find((model) => model.id === modelId);
|
|
105
|
+
if (!target) {
|
|
106
|
+
throw new Error("This OpenCode model is no longer available. Send /model again.");
|
|
107
|
+
}
|
|
108
|
+
const currentVariant = session.model?.variant;
|
|
109
|
+
const variant = currentVariant && currentVariant !== "default" && target.variants.includes(currentVariant)
|
|
110
|
+
? currentVariant
|
|
111
|
+
: "default";
|
|
112
|
+
const result = await this.client.v2.session.switchModel({
|
|
113
|
+
sessionID: session.id,
|
|
114
|
+
model: {
|
|
115
|
+
providerID: target.providerID,
|
|
116
|
+
id: target.modelID,
|
|
117
|
+
variant,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
this.unwrapOrThrow(result);
|
|
121
|
+
const updated = await this.getSessionForCurrentDirectory(session.id);
|
|
122
|
+
if (!updated ||
|
|
123
|
+
updated.model?.providerID !== target.providerID ||
|
|
124
|
+
updated.model.id !== target.modelID ||
|
|
125
|
+
(updated.model.variant ?? "default") !== variant) {
|
|
126
|
+
throw new Error("OpenCode did not confirm the selected model.");
|
|
127
|
+
}
|
|
128
|
+
return { id: target.id, displayName: target.displayName, isCurrent: true };
|
|
129
|
+
}
|
|
130
|
+
async setPlanMode(enabled) {
|
|
131
|
+
this.assertRemoteControlReady();
|
|
132
|
+
const session = await this.ensureControlSession();
|
|
133
|
+
const agents = await this.listPrimaryAgents();
|
|
134
|
+
const planAgent = agents.find((agent) => agent.name === "plan");
|
|
135
|
+
if (!planAgent) {
|
|
136
|
+
throw new Error("OpenCode primary agent 'plan' is not available.");
|
|
137
|
+
}
|
|
138
|
+
const currentAgent = session.agent ?? agents[0]?.name;
|
|
139
|
+
if (!currentAgent) {
|
|
140
|
+
throw new Error("OpenCode did not return any primary agents.");
|
|
141
|
+
}
|
|
142
|
+
if (enabled && currentAgent === "plan") {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
if (!enabled && currentAgent !== "plan") {
|
|
146
|
+
this.previousAgentBySession.delete(session.id);
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
const previousAgent = this.previousAgentBySession.get(session.id);
|
|
150
|
+
const target = enabled
|
|
151
|
+
? "plan"
|
|
152
|
+
: agents.some((agent) => agent.name === previousAgent)
|
|
153
|
+
? previousAgent
|
|
154
|
+
: agents.find((agent) => agent.name === "build")?.name ??
|
|
155
|
+
agents.find((agent) => agent.name !== "plan")?.name;
|
|
156
|
+
if (!target) {
|
|
157
|
+
throw new Error("OpenCode did not return an agent that can replace plan mode.");
|
|
158
|
+
}
|
|
159
|
+
const result = await this.client.v2.session.switchAgent({
|
|
160
|
+
sessionID: session.id,
|
|
161
|
+
agent: target,
|
|
162
|
+
});
|
|
163
|
+
this.unwrapOrThrow(result);
|
|
164
|
+
const updated = await this.getSessionForCurrentDirectory(session.id);
|
|
165
|
+
if (!updated || updated.agent !== target) {
|
|
166
|
+
throw new Error("OpenCode did not confirm the selected agent.");
|
|
167
|
+
}
|
|
168
|
+
if (enabled) {
|
|
169
|
+
this.previousAgentBySession.set(session.id, currentAgent);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
this.previousAgentBySession.delete(session.id);
|
|
173
|
+
}
|
|
174
|
+
return enabled;
|
|
175
|
+
}
|
|
176
|
+
assertRemoteControlReady() {
|
|
177
|
+
if (!this.client) {
|
|
178
|
+
throw new Error("OpenCode is not running.");
|
|
179
|
+
}
|
|
180
|
+
if (this.state.status === "busy") {
|
|
181
|
+
throw new Error("OpenCode is still working. Wait for the current reply or use /stop.");
|
|
182
|
+
}
|
|
183
|
+
if (this.pendingPermission) {
|
|
184
|
+
throw new Error("An OpenCode approval request is pending. Reply with /confirm or /deny.");
|
|
185
|
+
}
|
|
186
|
+
if (this.pendingQuestion) {
|
|
187
|
+
throw new Error("OpenCode is waiting for user input. Reply with /answer or use /stop.");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async ensureControlSession() {
|
|
191
|
+
const session = await this.ensureSession();
|
|
192
|
+
if (session.id !== this.activeSessionId) {
|
|
193
|
+
this.switchSharedSession(session, {
|
|
194
|
+
source: "wechat",
|
|
195
|
+
reason: "wechat_resume",
|
|
196
|
+
syncVisible: true,
|
|
197
|
+
forceVisibleSync: true,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return session;
|
|
201
|
+
}
|
|
202
|
+
async listPrimaryAgents() {
|
|
203
|
+
if (!this.client)
|
|
204
|
+
throw new Error("OpenCode is not running.");
|
|
205
|
+
const agents = this.unwrapOrThrow(await this.client.app.agents({
|
|
206
|
+
directory: this.options.cwd,
|
|
207
|
+
workspace: this.activeWorkspaceId ?? undefined,
|
|
208
|
+
}, { signal: AbortSignal.timeout(10_000) }));
|
|
209
|
+
return agents.filter((agent) => agent.mode !== "subagent" && !agent.hidden);
|
|
210
|
+
}
|
|
211
|
+
async listControlModels() {
|
|
212
|
+
if (!this.client)
|
|
213
|
+
throw new Error("OpenCode is not running.");
|
|
214
|
+
const providers = this.unwrapOrThrow(await this.client.provider.list({ directory: this.options.cwd, workspace: this.activeWorkspaceId ?? undefined }, { signal: AbortSignal.timeout(10_000) }));
|
|
215
|
+
return providers.all.filter((provider) => providers.connected.includes(provider.id)).flatMap((provider) => Object.entries(provider.models).filter(([id, model]) => model.status !== "deprecated" && !(provider.id === "opencode" && id.includes("-nano"))).map(([id, model]) => ({
|
|
216
|
+
id: `${provider.id}/${id}`, displayName: `${model.name || id} (${provider.name})`,
|
|
217
|
+
providerID: provider.id, modelID: id,
|
|
218
|
+
variants: Object.keys(model.variants ?? {}),
|
|
219
|
+
})));
|
|
220
|
+
}
|
|
88
221
|
constructor(options) {
|
|
89
222
|
this.options = options;
|
|
90
223
|
this.state = {
|
|
@@ -170,10 +303,21 @@ export class OpenCodeServerAdapter {
|
|
|
170
303
|
this.recordPendingWechatPromptMirrorSuppression(session.id, normalized);
|
|
171
304
|
this.beginTrackedTurn(normalized, "wechat");
|
|
172
305
|
try {
|
|
306
|
+
const selection = await this.getSessionForCurrentDirectory(session.id) ?? session;
|
|
173
307
|
const result = await this.client.session.promptAsync({
|
|
174
308
|
sessionID: session.id,
|
|
175
309
|
directory: this.options.cwd,
|
|
176
310
|
workspace: session.workspaceID ?? this.activeWorkspaceId ?? undefined,
|
|
311
|
+
...(selection.agent ? { agent: selection.agent } : {}),
|
|
312
|
+
...(selection.model
|
|
313
|
+
? {
|
|
314
|
+
model: {
|
|
315
|
+
providerID: selection.model.providerID,
|
|
316
|
+
modelID: selection.model.id,
|
|
317
|
+
},
|
|
318
|
+
variant: selection.model.variant ?? "default",
|
|
319
|
+
}
|
|
320
|
+
: {}),
|
|
177
321
|
parts: [{ type: "text", text: normalized }],
|
|
178
322
|
});
|
|
179
323
|
if (result.error !== undefined) {
|
|
@@ -411,6 +555,7 @@ export class OpenCodeServerAdapter {
|
|
|
411
555
|
this.pendingLocalSessionCreateFollowUntilMs = 0;
|
|
412
556
|
this.clearObservedMessageTracking();
|
|
413
557
|
this.recentSdkEventObservations.clear();
|
|
558
|
+
this.previousAgentBySession.clear();
|
|
414
559
|
this.outputBatcher.clear();
|
|
415
560
|
this.clearStreamedPartState();
|
|
416
561
|
this.clearPendingPermissionState();
|
|
@@ -1535,7 +1680,11 @@ export class OpenCodeServerAdapter {
|
|
|
1535
1680
|
return;
|
|
1536
1681
|
}
|
|
1537
1682
|
const sessionId = this.extractSessionId(properties);
|
|
1538
|
-
if (!sessionId
|
|
1683
|
+
if (!sessionId) {
|
|
1684
|
+
return;
|
|
1685
|
+
}
|
|
1686
|
+
this.previousAgentBySession.delete(sessionId);
|
|
1687
|
+
if (sessionId !== this.activeSessionId) {
|
|
1539
1688
|
return;
|
|
1540
1689
|
}
|
|
1541
1690
|
this.clearTrackedTurnForLocalSessionSwitch();
|