cli-wechat-bridge 1.1.6 → 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.
Files changed (52) hide show
  1. package/LICENSE.txt +661 -661
  2. package/README.md +454 -475
  3. package/bin/_run-entry.mjs +23 -5
  4. package/bin/wecom-check-update.mjs +5 -0
  5. package/bin/wecom-claude.mjs +5 -0
  6. package/bin/wecom-codex.mjs +5 -0
  7. package/bin/wecom-daemon.mjs +5 -0
  8. package/bin/wecom-opencode.mjs +5 -0
  9. package/bin/wecom-pi.mjs +5 -0
  10. package/bin/wecom-setup.mjs +5 -0
  11. package/dist/bridge/adapter-control.js +58 -0
  12. package/dist/bridge/bridge-adapters.claude.js +155 -4
  13. package/dist/bridge/bridge-adapters.codex.js +112 -7
  14. package/dist/bridge/bridge-adapters.core.js +19 -1
  15. package/dist/bridge/bridge-adapters.opencode.js +150 -1
  16. package/dist/bridge/bridge-adapters.pi.js +39 -0
  17. package/dist/bridge/bridge-process-reaper.js +35 -2
  18. package/dist/bridge/bridge-state.js +10 -3
  19. package/dist/bridge/claude-native-controls.js +133 -0
  20. package/dist/bridge/native-terminal-control.js +253 -0
  21. package/dist/bridge/wechat-bridge.js +389 -87
  22. package/dist/channels/wechat/wechat-outbound-queue.js +10 -3
  23. package/dist/channels/wecom/bridge-final-reply.js +26 -0
  24. package/dist/channels/wecom/setup-cli.js +6 -0
  25. package/dist/channels/wecom/setup.js +176 -0
  26. package/dist/channels/wecom/wecom-channel-port.js +45 -0
  27. package/dist/channels/wecom/wecom-config.js +66 -0
  28. package/dist/channels/wecom/wecom-media.js +141 -0
  29. package/dist/channels/wecom/wecom-message.js +149 -0
  30. package/dist/channels/wecom/wecom-transport.js +308 -0
  31. package/dist/companion/codex-remote-client.js +24 -3
  32. package/dist/companion/codex-visible-client-link.js +101 -0
  33. package/dist/companion/local-companion-start.js +56 -11
  34. package/dist/companion/local-companion.js +19 -1
  35. package/dist/companion/pi-tui-bridge-extension.js +56 -3
  36. package/dist/core/bridge-control.js +8 -0
  37. package/dist/core/channel-types.js +3 -1
  38. package/dist/core/conversation-routing.js +48 -0
  39. package/dist/core/direct-run.js +17 -0
  40. package/dist/core/inbound-message-claims.js +61 -0
  41. package/dist/daemon/daemon-link.js +3 -0
  42. package/dist/daemon/wechat-daemon.js +335 -60
  43. package/dist/i18n/messages-en.js +3 -1
  44. package/dist/i18n/messages-zh.js +3 -1
  45. package/dist/runtime/legacy-adapter-runtime.js +15 -0
  46. package/dist/runtime/runtime-types.js +3 -0
  47. package/dist/utils/doctor.js +30 -10
  48. package/dist/utils/version-checker.js +11 -11
  49. package/dist/wechat/setup.js +113 -13
  50. package/dist/wechat/wechat-transport.js +96 -58
  51. package/package.json +116 -104
  52. package/scripts/ensure-node-pty-permissions.mjs +73 -73
@@ -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 <= 151;
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 || sessionId !== this.activeSessionId) {
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();
@@ -356,6 +356,41 @@ export class PiTuiAdapter {
356
356
  this.pendingSessionSwitch = null;
357
357
  }
358
358
  }
359
+ async listModels() {
360
+ if (this.state.status === "busy") {
361
+ throw new Error("Pi is still working. Wait for the current turn before listing models.");
362
+ }
363
+ const response = await this.sendCommand("list_models");
364
+ if (!isRecord(response.data) || !Array.isArray(response.data.models)) {
365
+ throw new Error("Pi TUI returned an invalid model list.");
366
+ }
367
+ return response.data.models
368
+ .filter((model) => isRecord(model))
369
+ .filter((model) => typeof model.id === "string" && typeof model.displayName === "string")
370
+ .map((model) => ({
371
+ id: model.id,
372
+ displayName: model.displayName,
373
+ isCurrent: model.isCurrent === true,
374
+ }));
375
+ }
376
+ async selectModel(modelId) {
377
+ if (this.state.status === "busy") {
378
+ throw new Error("Pi is still working. Wait for the current turn before switching models.");
379
+ }
380
+ const response = await this.sendCommand("select_model", { modelId });
381
+ if (!isRecord(response.data) || !isRecord(response.data.model)) {
382
+ throw new Error("Pi TUI returned an invalid selected model.");
383
+ }
384
+ const model = response.data.model;
385
+ if (typeof model.id !== "string" || typeof model.displayName !== "string") {
386
+ throw new Error("Pi TUI returned an invalid selected model.");
387
+ }
388
+ return {
389
+ id: model.id,
390
+ displayName: model.displayName,
391
+ isCurrent: true,
392
+ };
393
+ }
359
394
  async interrupt() {
360
395
  if (!this.socket || this.state.status !== "busy") {
361
396
  return false;
@@ -569,6 +604,10 @@ export class PiTuiAdapter {
569
604
  else if (this.currentAssistantText) {
570
605
  this.emit({ type: "final_reply", text: this.currentAssistantText, timestamp: nowIso() });
571
606
  }
607
+ // Mirror the other adapters: a settled turn always reports completion so
608
+ // bridge/daemon ownership state is released even when the turn produced
609
+ // no visible reply.
610
+ this.emit({ type: "task_complete", timestamp: nowIso() });
572
611
  this.state.lastOutputAt = nowIso();
573
612
  this.clearActiveTurn();
574
613
  this.setStatus("idle");
@@ -113,7 +113,7 @@ function normalizeBridgeProcessRecord(value) {
113
113
  }
114
114
  return record;
115
115
  }
116
- export function parseWindowsBridgeProcessProbeOutput(stdout, currentPid = process.pid) {
116
+ function parseWindowsProcessProbeRecords(stdout) {
117
117
  const trimmed = stdout.trim();
118
118
  if (!trimmed) {
119
119
  return [];
@@ -128,7 +128,13 @@ export function parseWindowsBridgeProcessProbeOutput(stdout, currentPid = proces
128
128
  const values = Array.isArray(parsed) ? parsed : [parsed];
129
129
  return values
130
130
  .map(normalizeBridgeProcessRecord)
131
- .filter((record) => Boolean(record))
131
+ .filter((record) => Boolean(record));
132
+ }
133
+ export function parseWindowsProcessRecordProbeOutput(stdout, currentPid = process.pid) {
134
+ return (parseWindowsProcessProbeRecords(stdout).find((record) => record.pid !== currentPid) ?? null);
135
+ }
136
+ export function parseWindowsBridgeProcessProbeOutput(stdout, currentPid = process.pid) {
137
+ return parseWindowsProcessProbeRecords(stdout)
132
138
  .filter((record) => record.pid !== currentPid && isWechatBridgeCommandLine(record.commandLine));
133
139
  }
134
140
  export function parsePosixBridgeProcessProbeOutput(stdout, currentPid = process.pid) {
@@ -195,10 +201,37 @@ export function listPeerBridgeProcesses(currentPid = process.pid) {
195
201
  ? listWindowsBridgeProcesses(currentPid)
196
202
  : listPosixBridgeProcesses(currentPid);
197
203
  }
204
+ function getWindowsProcessRecordByPid(pid, currentPid = process.pid) {
205
+ const probe = spawnSync("powershell.exe", [
206
+ "-NoLogo",
207
+ "-NoProfile",
208
+ "-NonInteractive",
209
+ "-ExecutionPolicy",
210
+ "Bypass",
211
+ "-Command",
212
+ [
213
+ "$ErrorActionPreference='Stop';",
214
+ `Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}'`,
215
+ "| Select-Object ProcessId,ParentProcessId,Name,CommandLine",
216
+ "| ConvertTo-Json -Compress",
217
+ ].join(" "),
218
+ ], {
219
+ encoding: "utf8",
220
+ windowsHide: true,
221
+ timeout: 8_000,
222
+ });
223
+ if (probe.status !== 0 || typeof probe.stdout !== "string") {
224
+ return null;
225
+ }
226
+ return parseWindowsProcessRecordProbeOutput(probe.stdout, currentPid);
227
+ }
198
228
  export function getProcessRecordByPid(pid, currentPid = process.pid) {
199
229
  if (!Number.isInteger(pid) || pid <= 0 || pid === currentPid) {
200
230
  return null;
201
231
  }
232
+ if (process.platform === "win32") {
233
+ return getWindowsProcessRecordByPid(pid, currentPid);
234
+ }
202
235
  return listAllProcessesRaw(currentPid).find((record) => record.pid === pid) ?? null;
203
236
  }
204
237
  export function listWechatDaemonProcesses(params = {}) {
@@ -79,6 +79,9 @@ export function normalizeBridgeLockPayload(value) {
79
79
  startedAt: record.startedAt,
80
80
  lifecycle: record.lifecycle === "companion_bound" ? "companion_bound" : "persistent",
81
81
  legacyLifecycleFallback: hasExplicitLifecycle ? undefined : true,
82
+ ...(record.channelId === "wechat" || record.channelId === "wecom"
83
+ ? { channelId: record.channelId }
84
+ : {}),
82
85
  };
83
86
  }
84
87
  export function readBridgeLockFile() {
@@ -148,11 +151,13 @@ function buildLockConflictError(lock) {
148
151
  * pid is alive but belongs to something else, and "unknown" when the command
149
152
  * line could not be resolved (probe failure, restricted permissions).
150
153
  */
151
- export function classifyLockHolderProcess(pid) {
152
- if (!isPidAlive(pid)) {
154
+ export function classifyLockHolderProcess(pid, dependencies = {}) {
155
+ const isProcessAlive = dependencies.isPidAlive ?? isPidAlive;
156
+ const getProcessRecord = dependencies.getProcessRecordByPid ?? getProcessRecordByPid;
157
+ if (!isProcessAlive(pid)) {
153
158
  return "unknown";
154
159
  }
155
- const record = getProcessRecordByPid(pid);
160
+ const record = getProcessRecord(pid);
156
161
  if (!record) {
157
162
  return "unknown";
158
163
  }
@@ -200,6 +205,7 @@ export class BridgeStateStore {
200
205
  cwd: options.cwd,
201
206
  startedAt: new Date(this.bridgeStartedAtMs).toISOString(),
202
207
  lifecycle: options.lifecycle,
208
+ channelId: options.channelId,
203
209
  };
204
210
  this.acquireLock();
205
211
  const shouldRestoreSessionState = options.sessionStartMode !== "new";
@@ -231,6 +237,7 @@ export class BridgeStateStore {
231
237
  resumeConversationId: persistedResumeConversationId,
232
238
  transcriptPath: persistedTranscriptPath,
233
239
  lastActivityAt: persisted?.lastActivityAt,
240
+ channelId: options.channelId,
234
241
  pendingConfirmation: null,
235
242
  pendingUserInput: null,
236
243
  };