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.
@@ -23,12 +23,37 @@ export function parseCodexVisibleSwitchRequest(value) {
23
23
  threadId: value.threadId,
24
24
  };
25
25
  }
26
+ export function parseCodexVisibleControlRequest(value) {
27
+ const switchRequest = parseCodexVisibleSwitchRequest(value);
28
+ if (switchRequest) {
29
+ return switchRequest;
30
+ }
31
+ if (!isRecord(value)) {
32
+ return null;
33
+ }
34
+ if (value.type !== "shutdown" ||
35
+ typeof value.id !== "string" ||
36
+ typeof value.token !== "string") {
37
+ return null;
38
+ }
39
+ return {
40
+ type: "shutdown",
41
+ id: value.id,
42
+ token: value.token,
43
+ };
44
+ }
26
45
  export function sendCodexVisibleSwitchResponse(socket, response) {
27
46
  if (socket.destroyed || socket.writableEnded) {
28
47
  return;
29
48
  }
30
49
  socket.end(`${JSON.stringify(response)}\n`);
31
50
  }
51
+ export function sendCodexVisibleShutdownResponse(socket, response) {
52
+ if (socket.destroyed || socket.writableEnded) {
53
+ return;
54
+ }
55
+ socket.end(`${JSON.stringify(response)}\n`);
56
+ }
32
57
  function resolveCodexControlEndpoint(cwd, instanceId) {
33
58
  const endpoint = readLocalCompanionEndpoint(cwd, { adapter: "codex" });
34
59
  if (!endpoint || endpoint.kind !== "codex" || endpoint.instanceId !== instanceId) {
@@ -120,3 +145,79 @@ export async function requestCodexVisibleThreadSwitch(params) {
120
145
  });
121
146
  });
122
147
  }
148
+ export async function requestCodexVisibleClientShutdown(params) {
149
+ const endpoint = resolveCodexControlEndpoint(params.cwd, params.instanceId);
150
+ const requestId = crypto.randomUUID();
151
+ const timeoutMs = params.timeoutMs ?? 5_000;
152
+ await new Promise((resolve, reject) => {
153
+ const socket = net.connect({
154
+ host: CODEX_VISIBLE_CONTROL_HOST,
155
+ port: endpoint.codexControlPort,
156
+ });
157
+ let buffer = "";
158
+ let settled = false;
159
+ const finish = (error) => {
160
+ if (settled) {
161
+ return;
162
+ }
163
+ settled = true;
164
+ socket.destroy();
165
+ if (error) {
166
+ reject(error);
167
+ }
168
+ else {
169
+ resolve();
170
+ }
171
+ };
172
+ socket.setEncoding("utf8");
173
+ socket.setTimeout(timeoutMs);
174
+ socket.once("connect", () => {
175
+ const request = {
176
+ type: "shutdown",
177
+ id: requestId,
178
+ token: endpoint.codexControlToken,
179
+ };
180
+ socket.write(`${JSON.stringify(request)}\n`);
181
+ });
182
+ socket.on("data", (chunk) => {
183
+ buffer += chunk;
184
+ if (buffer.length > 65_536) {
185
+ finish(new Error("The visible Codex client returned an oversized shutdown response."));
186
+ return;
187
+ }
188
+ const newlineIndex = buffer.indexOf("\n");
189
+ if (newlineIndex < 0) {
190
+ return;
191
+ }
192
+ const line = buffer.slice(0, newlineIndex).trim();
193
+ let parsed;
194
+ try {
195
+ parsed = JSON.parse(line);
196
+ }
197
+ catch {
198
+ finish(new Error("The visible Codex client returned an invalid shutdown response."));
199
+ return;
200
+ }
201
+ if (!isRecord(parsed) || parsed.type !== "shutdown_result" || parsed.id !== requestId) {
202
+ finish(new Error("The visible Codex client returned an unexpected shutdown response."));
203
+ return;
204
+ }
205
+ if (parsed.ok !== true) {
206
+ finish(new Error(typeof parsed.error === "string"
207
+ ? parsed.error
208
+ : "The visible Codex client could not shut down."));
209
+ return;
210
+ }
211
+ finish();
212
+ });
213
+ socket.once("timeout", () => {
214
+ finish(new Error("Timed out waiting for the visible Codex client to shut down."));
215
+ });
216
+ socket.once("error", (error) => finish(error));
217
+ socket.once("close", () => {
218
+ if (!settled) {
219
+ finish(new Error("The visible Codex client closed the shutdown connection."));
220
+ }
221
+ });
222
+ });
223
+ }
@@ -39,8 +39,13 @@ export function formatSwitchFailureMessage(cwd) {
39
39
  export function formatRestartUnhealthyMessage(cwd) {
40
40
  return `Detected unhealthy companion state for ${cwd}. Restarting bridge...`;
41
41
  }
42
- export function defaultSessionStartMode(adapter) {
43
- return adapter === "codex" ? "restore" : "new";
42
+ export function defaultSessionStartMode(_adapter) {
43
+ // Codex 0.155+ persists app-server threads lazily, so launching the visible
44
+ // client with a freshly prepared (not yet persisted) thread id crashes its
45
+ // bootstrap with "no rollout found". Start every adapter fresh by default;
46
+ // explicit --session-start-mode restore remains available for deliberate
47
+ // restores of already persisted threads.
48
+ return "new";
44
49
  }
45
50
  export function decideLaunchAction(input) {
46
51
  if (input.lockShouldAutoReclaim) {
@@ -231,6 +231,23 @@ export async function runLocalCompanion(options) {
231
231
  publishState();
232
232
  sendResponse(socket, message.id, true);
233
233
  break;
234
+ case "list_models":
235
+ if (!adapter.listModels)
236
+ throw new Error("Model listing is not supported by this companion.");
237
+ sendResponse(socket, message.id, true, await adapter.listModels());
238
+ break;
239
+ case "select_model":
240
+ if (!adapter.selectModel)
241
+ throw new Error("Model selection is not supported by this companion.");
242
+ sendResponse(socket, message.id, true, await adapter.selectModel(message.payload.modelId));
243
+ publishState();
244
+ break;
245
+ case "set_plan_mode":
246
+ if (!adapter.setPlanMode)
247
+ throw new Error("Plan mode is not supported by this companion.");
248
+ sendResponse(socket, message.id, true, await adapter.setPlanMode(message.payload.enabled));
249
+ publishState();
250
+ break;
234
251
  case "interrupt":
235
252
  sendResponse(socket, message.id, true, await adapter.interrupt());
236
253
  break;
@@ -167,7 +167,7 @@ export default function piTuiBridgeExtension(pi) {
167
167
  }
168
168
  },
169
169
  });
170
- const executeCommand = (command) => {
170
+ const executeCommand = async (command) => {
171
171
  try {
172
172
  if (command.type === "prompt" && typeof command.text === "string") {
173
173
  if (!latestContext?.isIdle()) {
@@ -180,6 +180,59 @@ export default function piTuiBridgeExtension(pi) {
180
180
  latestContext?.abort();
181
181
  writeFrame({ type: "response", id: command.id, success: true });
182
182
  }
183
+ else if (command.type === "list_models") {
184
+ if (!latestContext) {
185
+ throw new Error("Pi session context is not ready.");
186
+ }
187
+ const models = latestContext.scopedModels.length > 0
188
+ ? latestContext.scopedModels.map((entry) => entry.model)
189
+ : latestContext.modelRegistry.getAvailable();
190
+ writeFrame({
191
+ type: "response",
192
+ id: command.id,
193
+ success: true,
194
+ data: {
195
+ models: models.map((model) => ({
196
+ id: `${model.provider}/${model.id}`,
197
+ displayName: model.name
198
+ ? `${model.name} (${model.provider})`
199
+ : `${model.id} (${model.provider})`,
200
+ isCurrent: latestContext?.model?.provider === model.provider &&
201
+ latestContext.model.id === model.id,
202
+ })),
203
+ },
204
+ });
205
+ }
206
+ else if (command.type === "select_model" && typeof command.modelId === "string") {
207
+ if (!latestContext?.isIdle()) {
208
+ throw new Error("Pi TUI is already processing a turn.");
209
+ }
210
+ const models = latestContext.scopedModels.length > 0
211
+ ? latestContext.scopedModels.map((entry) => entry.model)
212
+ : latestContext.modelRegistry.getAvailable();
213
+ const model = models.find((candidate) => `${candidate.provider}/${candidate.id}` === command.modelId);
214
+ if (!model) {
215
+ throw new Error(`Pi model is no longer available: ${command.modelId}`);
216
+ }
217
+ const selected = await pi.setModel(model);
218
+ if (!selected) {
219
+ throw new Error(`Pi could not switch to model: ${command.modelId}`);
220
+ }
221
+ writeFrame({
222
+ type: "response",
223
+ id: command.id,
224
+ success: true,
225
+ data: {
226
+ model: {
227
+ id: `${model.provider}/${model.id}`,
228
+ displayName: model.name
229
+ ? `${model.name} (${model.provider})`
230
+ : `${model.id} (${model.provider})`,
231
+ isCurrent: true,
232
+ },
233
+ },
234
+ });
235
+ }
183
236
  else if (command.type === "new_session" && typeof command.id === "string") {
184
237
  pi.sendUserMessage(`/${INTERNAL_NEW_COMMAND} ${command.id}`, {
185
238
  expandPromptTemplates: true,
@@ -245,7 +298,7 @@ export default function piTuiBridgeExtension(pi) {
245
298
  try {
246
299
  const parsed = JSON.parse(line);
247
300
  if (isRecord(parsed)) {
248
- dispatchCommand(parsed);
301
+ void dispatchCommand(parsed);
249
302
  }
250
303
  }
251
304
  catch {
@@ -260,7 +313,7 @@ export default function piTuiBridgeExtension(pi) {
260
313
  latestContext = context;
261
314
  writeSessionState(context, typeof event.reason === "string" ? event.reason : "startup");
262
315
  for (const command of queuedCommands.splice(0)) {
263
- executeCommand(command);
316
+ void executeCommand(command);
264
317
  }
265
318
  });
266
319
  pi.on("input", (event, context) => {
@@ -26,6 +26,14 @@ export function parseBridgeControlCommand(text) {
26
26
  return { type: "deny" };
27
27
  case "/answer":
28
28
  return argument ? { type: "answer", raw: argument } : null;
29
+ case "/model":
30
+ return { type: "model", target: argument || undefined };
31
+ case "/plan":
32
+ if (!argument || argument.toLowerCase() === "on")
33
+ return { type: "plan", enabled: true };
34
+ if (argument.toLowerCase() === "off")
35
+ return { type: "plan", enabled: false };
36
+ return null;
29
37
  default:
30
38
  return null;
31
39
  }
@@ -0,0 +1,48 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ /**
3
+ * Keeps the reply target scoped to one inbound async call chain. Different
4
+ * conversations may run concurrently without overwriting each other's target.
5
+ */
6
+ export class InboundConversationContext {
7
+ storage = new AsyncLocalStorage();
8
+ run(conversation, callback) {
9
+ return this.storage.run(conversation, callback);
10
+ }
11
+ get() {
12
+ return this.storage.getStore();
13
+ }
14
+ }
15
+ export function tryBeginTurn(state, task, conversation) {
16
+ if (state.activeTask) {
17
+ return null;
18
+ }
19
+ const lease = {
20
+ task,
21
+ previousConversation: state.activeConversation,
22
+ };
23
+ state.activeTask = task;
24
+ if (conversation) {
25
+ state.activeConversation = conversation;
26
+ state.lastConversation = conversation;
27
+ }
28
+ return lease;
29
+ }
30
+ export function rollbackTurn(state, lease) {
31
+ if (state.activeTask !== lease.task) {
32
+ return false;
33
+ }
34
+ state.activeTask = null;
35
+ state.activeConversation = lease.previousConversation;
36
+ return true;
37
+ }
38
+ export function clearTurn(state, expectedTask) {
39
+ if (expectedTask && state.activeTask !== expectedTask) {
40
+ return false;
41
+ }
42
+ state.activeTask = null;
43
+ state.activeConversation = undefined;
44
+ return true;
45
+ }
46
+ export function resolveConversationTarget(params) {
47
+ return params.active ?? params.last ?? params.fallback;
48
+ }