cli-wechat-bridge 1.1.2 → 1.1.3

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.
@@ -40,13 +40,19 @@ export const OPENCODE_SERVER_READY_TIMEOUT_MS = 10_000;
40
40
  export const OPENCODE_SSE_RECONNECT_DELAY_MS = 2_000;
41
41
  export const OPENCODE_SESSION_IDLE_SETTLE_MS = 1_500;
42
42
  export const OPENCODE_WECHAT_WORKING_NOTICE_DELAY_MS = 12_000;
43
+ // `opencode serve` binds its TCP port before the HTTP layer is ready. The
44
+ // health probe therefore retries within this total budget so the startup
45
+ // race self-heals instead of hanging until undici's ~300s headersTimeout.
46
+ export const OPENCODE_HTTP_READY_TIMEOUT_MS = 15_000;
47
+ export const OPENCODE_HTTP_READY_PROBE_TIMEOUT_MS = 3_000;
48
+ export const OPENCODE_HTTP_READY_PROBE_INTERVAL_MS = 500;
43
49
  export function buildCodexCliArgs(remoteUrl, options = {}) {
44
50
  assertNoReservedExtraCliArgs(options.extraCliArgs ?? [], ["--remote", "--remote-auth-token-env"], "Codex remote connection");
45
51
  const args = [];
46
52
  if (options.resumeThreadId) {
47
53
  args.push("resume", options.resumeThreadId);
48
54
  }
49
- args.push("--enable", "tui_app_server", "--remote", remoteUrl);
55
+ args.push("--remote", remoteUrl);
50
56
  if (options.inlineMode) {
51
57
  args.push("--no-alt-screen");
52
58
  }
@@ -691,7 +697,7 @@ export function resolveDefaultAdapterCommand(kind, options = {}) {
691
697
  export function buildCliEnvironment(kind, options = {}) {
692
698
  const sourceEnv = options.env ?? process.env;
693
699
  const platform = options.platform ?? process.platform;
694
- if (kind === "codex" || kind === "claude" || kind === "opencode") {
700
+ if (kind === "codex" || kind === "claude" || kind === "opencode" || kind === "pi") {
695
701
  // Pass the full environment through on every platform. A previous
696
702
  // Windows-only allowlist silently dropped user-configured variables such
697
703
  // as ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN / OPENAI_API_KEY, which
@@ -1170,7 +1176,8 @@ export function resolveSpawnTarget(command, kind, options = {}) {
1170
1176
  return { file: trimmed, args: [...forwardArgs] };
1171
1177
  }
1172
1178
  const resolved = resolveCommandPath(trimmed, platform, env) ?? trimmed;
1173
- if (platform !== "win32" || (kind !== "codex" && kind !== "claude" && kind !== "opencode")) {
1179
+ if (platform !== "win32" ||
1180
+ (kind !== "codex" && kind !== "claude" && kind !== "opencode" && kind !== "pi")) {
1174
1181
  return { file: resolved, args: [...forwardArgs] };
1175
1182
  }
1176
1183
  const bundledExe = kind === "codex" || kind === "claude"
@@ -59,6 +59,7 @@ export function normalizeBridgeLockPayload(value) {
59
59
  const adapter = record.adapter === "codex" ||
60
60
  record.adapter === "claude" ||
61
61
  record.adapter === "opencode" ||
62
+ record.adapter === "pi" ||
62
63
  record.adapter === "shell"
63
64
  ? record.adapter
64
65
  : null;
@@ -802,15 +802,25 @@ export function formatMirroredUserInputMessage(adapter, text) {
802
802
  ? "Local Claude input"
803
803
  : adapter === "opencode"
804
804
  ? "Local OpenCode input"
805
- : "Local input";
805
+ : adapter === "pi"
806
+ ? "Local Pi input"
807
+ : "Local input";
806
808
  return `${label}:\n${truncatePreview(text, 500)}`;
807
809
  }
808
810
  export function formatFinalReplyMessage(adapter, text) {
809
- if (adapter === "claude" || adapter === "codex" || adapter === "opencode") {
811
+ if (adapter === "claude" || adapter === "codex" || adapter === "opencode" || adapter === "pi") {
810
812
  return text;
811
813
  }
812
814
  // After the early return above, only "shell" remains.
813
- const label = adapter === "codex" ? "Codex" : adapter === "claude" ? "Claude" : adapter === "opencode" ? "OpenCode" : adapter;
815
+ const label = adapter === "codex"
816
+ ? "Codex"
817
+ : adapter === "claude"
818
+ ? "Claude"
819
+ : adapter === "opencode"
820
+ ? "OpenCode"
821
+ : adapter === "pi"
822
+ ? "Pi"
823
+ : adapter;
814
824
  return `${label} final reply:\n${text}`;
815
825
  }
816
826
  const OPENCODE_WORKING_NOTICE_RE = /^OpenCode is still working on:\s*$/i;
@@ -1016,7 +1026,15 @@ function cleanupVisibleWechatReplyText(text) {
1016
1026
  .trim();
1017
1027
  }
1018
1028
  export function formatTaskFailedMessage(adapter, text) {
1019
- const label = adapter === "codex" ? "Codex" : adapter === "claude" ? "Claude" : adapter === "opencode" ? "OpenCode" : adapter;
1029
+ const label = adapter === "codex"
1030
+ ? "Codex"
1031
+ : adapter === "claude"
1032
+ ? "Claude"
1033
+ : adapter === "opencode"
1034
+ ? "OpenCode"
1035
+ : adapter === "pi"
1036
+ ? "Pi"
1037
+ : adapter;
1020
1038
  return `${label} task failed:\n${text}`;
1021
1039
  }
1022
1040
  export function formatApprovalMessage(pending, adapterState) {
@@ -1090,22 +1108,35 @@ function parseSingleUserInputAnswer(question, rawValue) {
1090
1108
  answers: [trimmed],
1091
1109
  };
1092
1110
  }
1093
- const separatorIndex = trimmed.indexOf("|");
1111
+ const customAnswerMode = question.customAnswerMode ?? "note";
1112
+ const separatorIndex = customAnswerMode === "note" ? trimmed.indexOf("|") : -1;
1094
1113
  const selection = separatorIndex >= 0 ? trimmed.slice(0, separatorIndex).trim() : trimmed;
1095
1114
  let note = separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1).trim() : "";
1096
1115
  const answers = [];
1097
1116
  if (selection) {
1098
- const selectedLabel = resolveUserInputOptionLabel(question, selection);
1099
- if (selectedLabel) {
1100
- answers.push(selectedLabel);
1101
- }
1102
- else if (question.isOther) {
1103
- note = note ? `${selection}; ${note}` : selection;
1104
- }
1105
- else {
1106
- return {
1107
- error: `Question "${question.header}" expects an option number or label.`,
1108
- };
1117
+ const selections = question.multiple
1118
+ ? selection.split(",").map((value) => value.trim()).filter(Boolean)
1119
+ : [selection];
1120
+ for (const value of selections) {
1121
+ const selectedLabel = resolveUserInputOptionLabel(question, value);
1122
+ if (selectedLabel) {
1123
+ if (!answers.includes(selectedLabel)) {
1124
+ answers.push(selectedLabel);
1125
+ }
1126
+ }
1127
+ else if (question.isOther && customAnswerMode === "value") {
1128
+ if (!answers.includes(value)) {
1129
+ answers.push(value);
1130
+ }
1131
+ }
1132
+ else if (question.isOther) {
1133
+ note = note ? `${value}; ${note}` : value;
1134
+ }
1135
+ else {
1136
+ return {
1137
+ error: `Question "${question.header}" expects an option number or label.`,
1138
+ };
1139
+ }
1109
1140
  }
1110
1141
  }
1111
1142
  if (note) {
@@ -1210,8 +1241,13 @@ export function formatUserInputRequestMessage(pending, adapterState) {
1210
1241
  lines.push(` ${optionIndex + 1}. ${option.label} - ${truncatePreview(option.description, 160)}`);
1211
1242
  });
1212
1243
  }
1244
+ if (question.multiple) {
1245
+ lines.push("multiple selections: allowed (separate values with commas)");
1246
+ }
1213
1247
  if (question.isOther) {
1214
- lines.push("custom note: allowed");
1248
+ lines.push(question.customAnswerMode === "value"
1249
+ ? "custom answer: allowed"
1250
+ : "custom note: allowed");
1215
1251
  }
1216
1252
  });
1217
1253
  lines.push("");
@@ -1221,8 +1257,13 @@ export function formatUserInputRequestMessage(pending, adapterState) {
1221
1257
  return lines.join("\n");
1222
1258
  }
1223
1259
  if (question.options?.length) {
1224
- lines.push("Reply with /answer <option number or exact label>.");
1225
- if (question.isOther) {
1260
+ lines.push(question.multiple
1261
+ ? "Reply with /answer <option numbers or exact labels separated by commas>."
1262
+ : "Reply with /answer <option number or exact label>.");
1263
+ if (question.isOther && question.customAnswerMode === "value") {
1264
+ lines.push("You may enter a custom answer instead of an option.");
1265
+ }
1266
+ else if (question.isOther) {
1226
1267
  lines.push('Add "| your note" to include extra context.');
1227
1268
  }
1228
1269
  }
@@ -1234,7 +1275,10 @@ export function formatUserInputRequestMessage(pending, adapterState) {
1234
1275
  lines.push("Reply with /answer questionId=value; questionId2=value.");
1235
1276
  lines.push("You can use question numbers instead of ids.");
1236
1277
  lines.push("For option questions, value can be the option number or exact label.");
1237
- lines.push('Add "| your note" after an option answer to include extra context.');
1278
+ lines.push("For multi-select questions, separate option values with commas.");
1279
+ if (pending.questions.some((question) => question.customAnswerMode !== "value")) {
1280
+ lines.push('Add "| your note" after an option answer to include extra context.');
1281
+ }
1238
1282
  }
1239
1283
  lines.push("Use /stop to interrupt the active turn instead.");
1240
1284
  return lines.join("\n");
@@ -1243,11 +1287,11 @@ export function formatPendingUserInputReminder(pending) {
1243
1287
  if (pending.questions.length === 1) {
1244
1288
  const question = pending.questions[0];
1245
1289
  if (!question) {
1246
- return "Codex is waiting for user input. Reply with /answer and your response, or use /stop to interrupt.";
1290
+ return "The active CLI is waiting for user input. Reply with /answer and your response, or use /stop to interrupt.";
1247
1291
  }
1248
- return `Codex is waiting for user input for ${question.header}. Reply with /answer and your response, or use /stop to interrupt.`;
1292
+ return `The active CLI is waiting for user input for ${question.header}. Reply with /answer and your response, or use /stop to interrupt.`;
1249
1293
  }
1250
- return `Codex is waiting for answers to ${pending.questions.length} questions. Reply with /answer questionId=value; questionId2=value, or use /stop to interrupt.`;
1294
+ return `The active CLI is waiting for answers to ${pending.questions.length} questions. Reply with /answer questionId=value; questionId2=value, or use /stop to interrupt.`;
1251
1295
  }
1252
1296
  export class OutputBatcher {
1253
1297
  onFlush;
@@ -46,7 +46,7 @@ export function formatUserFacingBridgeFatalError(message) {
46
46
  return `Bridge error: ${message.replace(/\s+Recent app-server log:.*$/s, "").trim()}`;
47
47
  }
48
48
  export function shouldForwardBridgeEventToWechat(adapter, eventType, options = {}) {
49
- if (adapter !== "opencode") {
49
+ if (adapter !== "opencode" && adapter !== "pi") {
50
50
  return true;
51
51
  }
52
52
  switch (eventType) {
@@ -55,7 +55,7 @@ export function shouldForwardBridgeEventToWechat(adapter, eventType, options = {
55
55
  case "thread_switched":
56
56
  return false;
57
57
  case "notice":
58
- return /^OpenCode local draft:\s*/i.test(options.text ?? "");
58
+ return adapter === "pi" || /^OpenCode local draft:\s*/i.test(options.text ?? "");
59
59
  case "mirrored_user_input":
60
60
  return true;
61
61
  default:
@@ -159,7 +159,7 @@ export function parseCliArgs(argv) {
159
159
  const next = argv[i + 1];
160
160
  switch (arg) {
161
161
  case "--adapter":
162
- if (!next || !["codex", "claude", "opencode", "shell"].includes(next)) {
162
+ if (!next || !["codex", "claude", "opencode", "pi", "shell"].includes(next)) {
163
163
  throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
164
164
  }
165
165
  adapter = next;
@@ -212,7 +212,7 @@ export function parseCliArgs(argv) {
212
212
  }
213
213
  }
214
214
  if (!adapter) {
215
- throw new Error("Missing required --adapter <codex|claude|opencode|shell>");
215
+ throw new Error("Missing required --adapter <codex|claude|opencode|pi|shell>");
216
216
  }
217
217
  const defaultCommand = resolveDefaultAdapterCommand(adapter);
218
218
  return {
@@ -226,12 +226,13 @@ export function parseCliArgs(argv) {
226
226
  }
227
227
  function printUsageAndExit() {
228
228
  process.stdout.write([
229
- "Usage: wechat-bridge --adapter <codex|claude|opencode|shell> [--cmd <executable>] [--cwd <path>] [--profile <name-or-path>] [--lifecycle <persistent|companion_bound>] [--session-start-mode <restore|new>]",
229
+ "Usage: wechat-bridge --adapter <codex|claude|opencode|pi|shell> [--cmd <executable>] [--cwd <path>] [--profile <name-or-path>] [--lifecycle <persistent|companion_bound>] [--session-start-mode <restore|new>]",
230
230
  "",
231
231
  "Examples:",
232
232
  " wechat-bridge-codex",
233
233
  " wechat-bridge-claude --cwd ~/work/my-project",
234
234
  " wechat-bridge-opencode --cwd ~/work/my-project",
235
+ " wechat-bridge-pi --cwd ~/work/my-project",
235
236
  " wechat-bridge-shell --cmd pwsh # headless shell executor for non-interactive commands/scripts",
236
237
  " wechat-bridge-shell --cmd bash # headless shell executor for non-interactive commands/scripts",
237
238
  " wechat-bridge-codex --lifecycle companion_bound",
@@ -1122,9 +1123,9 @@ async function handleInboundMessage(params) {
1122
1123
  return null;
1123
1124
  }
1124
1125
  if (adapterState.status === "busy") {
1125
- if ((options.adapter === "codex" || options.adapter === "opencode") &&
1126
+ if ((options.adapter === "codex" || options.adapter === "opencode" || options.adapter === "pi") &&
1126
1127
  adapterState.activeTurnOrigin === "local") {
1127
- await queueWechatMessage(message.senderId, `${options.adapter === "opencode" ? "OpenCode" : "codex"} is currently busy with a local terminal turn. Wait for it to finish or use /stop.`);
1128
+ await queueWechatMessage(message.senderId, `${options.adapter === "opencode" ? "OpenCode" : options.adapter === "pi" ? "Pi" : "codex"} is currently busy with a local terminal turn. Wait for it to finish or use /stop.`);
1128
1129
  return null;
1129
1130
  }
1130
1131
  await queueWechatMessage(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
@@ -7,7 +7,11 @@ function normalizeEndpoint(value) {
7
7
  return null;
8
8
  }
9
9
  const record = value;
10
- const kind = record.kind === "codex" || record.kind === "claude" || record.kind === "opencode" || record.kind === "shell"
10
+ const kind = record.kind === "codex" ||
11
+ record.kind === "claude" ||
12
+ record.kind === "opencode" ||
13
+ record.kind === "pi" ||
14
+ record.kind === "shell"
11
15
  ? record.kind
12
16
  : "codex";
13
17
  const sharedSessionId = typeof record.sharedSessionId === "string"
@@ -118,6 +122,7 @@ export function clearLocalCompanionEndpoint(cwd, instanceId, options = {}) {
118
122
  getWorkspaceAdapterEndpointFile(cwd, "codex"),
119
123
  getWorkspaceAdapterEndpointFile(cwd, "claude"),
120
124
  getWorkspaceAdapterEndpointFile(cwd, "opencode"),
125
+ getWorkspaceAdapterEndpointFile(cwd, "pi"),
121
126
  getWorkspaceAdapterEndpointFile(cwd, "shell"),
122
127
  ];
123
128
  for (const filePath of files) {
@@ -38,7 +38,7 @@ export function formatRestartUnhealthyMessage(cwd) {
38
38
  return `Detected unhealthy companion state for ${cwd}. Restarting bridge...`;
39
39
  }
40
40
  export function defaultSessionStartMode(adapter) {
41
- return adapter === "claude" || adapter === "opencode" ? "new" : "restore";
41
+ return adapter === "codex" ? "restore" : "new";
42
42
  }
43
43
  export function decideLaunchAction(input) {
44
44
  if (input.lockShouldAutoReclaim) {
@@ -59,7 +59,9 @@ export function decideLaunchAction(input) {
59
59
  };
60
60
  }
61
61
  if (input.sessionStartMode === "new" &&
62
- (input.requestedAdapter === "claude" || input.requestedAdapter === "opencode")) {
62
+ (input.requestedAdapter === "claude" ||
63
+ input.requestedAdapter === "opencode" ||
64
+ input.requestedAdapter === "pi")) {
63
65
  return {
64
66
  kind: "start_bridge",
65
67
  message: `Starting a fresh ${input.requestedAdapter} session for ${input.requestedCwd}...`,
@@ -95,6 +97,7 @@ export function parseCliArgs(argv) {
95
97
  let cwd = process.cwd();
96
98
  let profile;
97
99
  let timeoutMs = DEFAULT_WAIT_TIMEOUT_MS;
100
+ let sessionStartMode;
98
101
  const cliArgs = [];
99
102
  for (let i = 0; i < argv.length; i += 1) {
100
103
  const arg = argv[i];
@@ -107,10 +110,12 @@ export function parseCliArgs(argv) {
107
110
  "Usage: wechat-codex-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...codex args]",
108
111
  " wechat-claude-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...claude args]",
109
112
  " wechat-opencode-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...opencode args]",
110
- " local-companion-start [--adapter <codex|claude|opencode>] [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...cli args]",
113
+ " wechat-pi-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...pi args]",
114
+ " local-companion-start [--adapter <codex|claude|opencode|pi>] [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...cli args]",
111
115
  "",
112
116
  "Starts a bridge for the current directory, waits for the local endpoint, then opens the visible companion or panel.",
113
- "Claude and OpenCode launchers start a fresh CLI session by default.",
117
+ "Claude, OpenCode, and Pi launchers start a fresh CLI session by default.",
118
+ "Use --session-start-mode restore to explicitly restore the previous session.",
114
119
  "All adapters are companion-bound: closing the companion/panel also stops the bridge.",
115
120
  "Unknown arguments are forwarded to the visible CLI client.",
116
121
  "",
@@ -118,7 +123,7 @@ export function parseCliArgs(argv) {
118
123
  process.exit(0);
119
124
  }
120
125
  if (arg === "--adapter") {
121
- if (!next || !["codex", "claude", "opencode"].includes(next)) {
126
+ if (!next || !["codex", "claude", "opencode", "pi"].includes(next)) {
122
127
  throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
123
128
  }
124
129
  adapter = next;
@@ -153,6 +158,14 @@ export function parseCliArgs(argv) {
153
158
  i += 1;
154
159
  continue;
155
160
  }
161
+ if (arg === "--session-start-mode") {
162
+ if (!next || !["restore", "new"].includes(next)) {
163
+ throw new Error(`Invalid session start mode: ${next ?? "(missing)"}`);
164
+ }
165
+ sessionStartMode = next;
166
+ i += 1;
167
+ continue;
168
+ }
156
169
  cliArgs.push(arg);
157
170
  }
158
171
  return {
@@ -160,7 +173,7 @@ export function parseCliArgs(argv) {
160
173
  cwd,
161
174
  profile,
162
175
  timeoutMs,
163
- sessionStartMode: defaultSessionStartMode(adapter),
176
+ sessionStartMode: sessionStartMode ?? defaultSessionStartMode(adapter),
164
177
  cliArgs,
165
178
  };
166
179
  }
@@ -203,7 +216,10 @@ async function stopExistingBridge(lock, requestedAdapter) {
203
216
  if (!(await waitForProcessExit(pid, 10_000))) {
204
217
  throw new Error(`Timed out waiting for existing bridge pid=${pid} to exit.`);
205
218
  }
206
- if (lock.adapter === "codex" || lock.adapter === "claude" || lock.adapter === "opencode") {
219
+ if (lock.adapter === "codex" ||
220
+ lock.adapter === "claude" ||
221
+ lock.adapter === "opencode" ||
222
+ lock.adapter === "pi") {
207
223
  clearLocalCompanionEndpoint(cwd, undefined, { adapter: lock.adapter });
208
224
  }
209
225
  else {
@@ -383,7 +399,7 @@ export async function tryDelegateToDaemon(options, deps = {}) {
383
399
  cliArgs: options.cliArgs,
384
400
  openVisible: true,
385
401
  sessionStartMode: options.sessionStartMode,
386
- reuseExistingVisible: true,
402
+ reuseExistingVisible: options.adapter !== "pi" || options.sessionStartMode !== "new",
387
403
  });
388
404
  if (!response.ok) {
389
405
  throw new Error(response.error);
@@ -23,7 +23,7 @@ function parseCliArgs(argv) {
23
23
  const next = argv[i + 1];
24
24
  if (arg === "--help" || arg === "-h") {
25
25
  process.stdout.write([
26
- "Usage: local-companion --adapter <codex|claude|opencode> [--cwd <path>] [...cli args]",
26
+ "Usage: local-companion --adapter <codex|claude|opencode|pi> [--cwd <path>] [...cli args]",
27
27
  "",
28
28
  'Starts the visible local companion and connects it to the matching running bridge for the current directory.',
29
29
  "Unknown arguments are forwarded to the visible CLI client.",
@@ -32,7 +32,7 @@ function parseCliArgs(argv) {
32
32
  process.exit(0);
33
33
  }
34
34
  if (arg === "--adapter") {
35
- if (!next || !["codex", "claude", "opencode"].includes(next)) {
35
+ if (!next || !["codex", "claude", "opencode", "pi"].includes(next)) {
36
36
  throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
37
37
  }
38
38
  adapter = next;
@@ -58,7 +58,7 @@ function parseCliArgs(argv) {
58
58
  cliArgs.push(arg);
59
59
  }
60
60
  if (!adapter) {
61
- throw new Error("Missing required --adapter <codex|claude|opencode>");
61
+ throw new Error("Missing required --adapter <codex|claude|opencode|pi>");
62
62
  }
63
63
  return { adapter, cwd, sessionStartMode, cliArgs };
64
64
  }
@@ -0,0 +1,256 @@
1
+ import net from "node:net";
2
+ const BRIDGE_HOST = "127.0.0.1";
3
+ const INTERNAL_NEW_COMMAND = "__cli_bridge_new";
4
+ const INTERNAL_SWITCH_COMMAND = "__cli_bridge_switch";
5
+ const MAX_BUFFER_SIZE = 8 * 1024 * 1024;
6
+ function isRecord(value) {
7
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
8
+ }
9
+ function extractAssistantText(message) {
10
+ if (!isRecord(message) || message.role !== "assistant") {
11
+ return "";
12
+ }
13
+ if (typeof message.content === "string") {
14
+ return message.content.trim();
15
+ }
16
+ if (!Array.isArray(message.content)) {
17
+ return "";
18
+ }
19
+ return message.content
20
+ .filter((block) => isRecord(block))
21
+ .filter((block) => block.type === "text" && typeof block.text === "string")
22
+ .map((block) => block.text)
23
+ .join("\n")
24
+ .trim();
25
+ }
26
+ function parseControlArgs(args) {
27
+ const trimmed = args.trim();
28
+ if (!trimmed) {
29
+ return null;
30
+ }
31
+ const separatorIndex = trimmed.indexOf(" ");
32
+ if (separatorIndex < 0) {
33
+ return { id: trimmed };
34
+ }
35
+ return {
36
+ id: trimmed.slice(0, separatorIndex),
37
+ value: trimmed.slice(separatorIndex + 1),
38
+ };
39
+ }
40
+ export default function piTuiBridgeExtension(pi) {
41
+ const bridgePort = Number(process.env.CLI_BRIDGE_PI_TUI_PORT ?? "");
42
+ const bridgeToken = process.env.CLI_BRIDGE_PI_TUI_TOKEN ?? "";
43
+ if (!Number.isInteger(bridgePort) || bridgePort <= 0 || !bridgeToken) {
44
+ return;
45
+ }
46
+ const socket = net.connect({ host: BRIDGE_HOST, port: bridgePort });
47
+ let buffer = "";
48
+ let connected = false;
49
+ const queuedFrames = [];
50
+ const queuedCommands = [];
51
+ let latestContext = null;
52
+ const writeFrame = (frame) => {
53
+ if (socket.destroyed) {
54
+ return;
55
+ }
56
+ if (!connected) {
57
+ queuedFrames.push(frame);
58
+ return;
59
+ }
60
+ socket.write(`${JSON.stringify(frame)}\n`);
61
+ };
62
+ const writeSessionState = (context, reason) => {
63
+ writeFrame({
64
+ type: "session_state",
65
+ sessionId: context.sessionManager.getSessionId(),
66
+ sessionFile: context.sessionManager.getSessionFile(),
67
+ reason,
68
+ });
69
+ };
70
+ pi.registerCommand(INTERNAL_NEW_COMMAND, {
71
+ description: "Internal CLI WeChat Bridge session command",
72
+ handler: async (args, context) => {
73
+ const parsed = parseControlArgs(args);
74
+ if (!parsed) {
75
+ return;
76
+ }
77
+ try {
78
+ let sessionState = {};
79
+ const result = await context.newSession({
80
+ withSession: async (nextContext) => {
81
+ writeSessionState(nextContext, "new");
82
+ sessionState = {
83
+ sessionId: nextContext.sessionManager.getSessionId(),
84
+ sessionFile: nextContext.sessionManager.getSessionFile(),
85
+ };
86
+ },
87
+ });
88
+ writeFrame({
89
+ type: "response",
90
+ id: parsed.id,
91
+ success: true,
92
+ data: { ...result, ...sessionState },
93
+ });
94
+ }
95
+ catch (error) {
96
+ writeFrame({
97
+ type: "response",
98
+ id: parsed.id,
99
+ success: false,
100
+ error: error instanceof Error ? error.message : String(error),
101
+ });
102
+ }
103
+ },
104
+ });
105
+ pi.registerCommand(INTERNAL_SWITCH_COMMAND, {
106
+ description: "Internal CLI WeChat Bridge session command",
107
+ handler: async (args, context) => {
108
+ const parsed = parseControlArgs(args);
109
+ if (!parsed?.value) {
110
+ return;
111
+ }
112
+ try {
113
+ let sessionState = {};
114
+ const result = await context.switchSession(parsed.value, {
115
+ withSession: async (nextContext) => {
116
+ writeSessionState(nextContext, "resume");
117
+ sessionState = {
118
+ sessionId: nextContext.sessionManager.getSessionId(),
119
+ sessionFile: nextContext.sessionManager.getSessionFile(),
120
+ };
121
+ },
122
+ });
123
+ writeFrame({
124
+ type: "response",
125
+ id: parsed.id,
126
+ success: true,
127
+ data: { ...result, ...sessionState },
128
+ });
129
+ }
130
+ catch (error) {
131
+ writeFrame({
132
+ type: "response",
133
+ id: parsed.id,
134
+ success: false,
135
+ error: error instanceof Error ? error.message : String(error),
136
+ });
137
+ }
138
+ },
139
+ });
140
+ const executeCommand = (command) => {
141
+ try {
142
+ if (command.type === "prompt" && typeof command.text === "string") {
143
+ if (!latestContext?.isIdle()) {
144
+ throw new Error("Pi TUI is already processing a turn.");
145
+ }
146
+ pi.sendUserMessage(command.text);
147
+ writeFrame({ type: "response", id: command.id, success: true });
148
+ }
149
+ else if (command.type === "abort") {
150
+ latestContext?.abort();
151
+ writeFrame({ type: "response", id: command.id, success: true });
152
+ }
153
+ else if (command.type === "new_session" && typeof command.id === "string") {
154
+ pi.sendUserMessage(`/${INTERNAL_NEW_COMMAND} ${command.id}`, {
155
+ expandPromptTemplates: true,
156
+ });
157
+ }
158
+ else if (command.type === "switch_session" &&
159
+ typeof command.id === "string" &&
160
+ typeof command.sessionPath === "string") {
161
+ pi.sendUserMessage(`/${INTERNAL_SWITCH_COMMAND} ${command.id} ${command.sessionPath}`, { expandPromptTemplates: true });
162
+ }
163
+ }
164
+ catch (error) {
165
+ writeFrame({
166
+ type: "response",
167
+ id: command.id,
168
+ success: false,
169
+ error: error instanceof Error ? error.message : String(error),
170
+ });
171
+ }
172
+ };
173
+ const dispatchCommand = (command) => {
174
+ if (!latestContext) {
175
+ queuedCommands.push(command);
176
+ return;
177
+ }
178
+ executeCommand(command);
179
+ };
180
+ socket.setEncoding("utf8");
181
+ socket.on("connect", () => {
182
+ socket.setNoDelay(true);
183
+ socket.write(`${JSON.stringify({ type: "hello", token: bridgeToken })}\n`);
184
+ connected = true;
185
+ for (const frame of queuedFrames.splice(0)) {
186
+ writeFrame(frame);
187
+ }
188
+ });
189
+ socket.on("data", (chunk) => {
190
+ buffer += chunk;
191
+ if (buffer.length > MAX_BUFFER_SIZE) {
192
+ buffer = "";
193
+ socket.destroy();
194
+ return;
195
+ }
196
+ while (true) {
197
+ const newlineIndex = buffer.indexOf("\n");
198
+ if (newlineIndex < 0) {
199
+ return;
200
+ }
201
+ const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
202
+ buffer = buffer.slice(newlineIndex + 1);
203
+ if (!line.trim()) {
204
+ continue;
205
+ }
206
+ try {
207
+ const parsed = JSON.parse(line);
208
+ if (isRecord(parsed)) {
209
+ dispatchCommand(parsed);
210
+ }
211
+ }
212
+ catch {
213
+ // Ignore malformed bridge commands and keep the native TUI alive.
214
+ }
215
+ }
216
+ });
217
+ socket.on("error", () => {
218
+ // The visible TUI remains usable if the bridge side disappears.
219
+ });
220
+ pi.on("session_start", (event, context) => {
221
+ latestContext = context;
222
+ writeSessionState(context, typeof event.reason === "string" ? event.reason : "startup");
223
+ for (const command of queuedCommands.splice(0)) {
224
+ executeCommand(command);
225
+ }
226
+ });
227
+ pi.on("input", (event, context) => {
228
+ latestContext = context;
229
+ if (event.source === "interactive" && typeof event.text === "string") {
230
+ writeFrame({ type: "local_input", text: event.text });
231
+ }
232
+ });
233
+ pi.on("message_end", (event, context) => {
234
+ latestContext = context;
235
+ const text = extractAssistantText(event.message);
236
+ if (!text) {
237
+ return;
238
+ }
239
+ const message = isRecord(event.message) ? event.message : {};
240
+ writeFrame({
241
+ type: "assistant_message",
242
+ text,
243
+ stopReason: message.stopReason,
244
+ errorMessage: message.errorMessage,
245
+ });
246
+ });
247
+ pi.on("agent_start", (_event, context) => {
248
+ latestContext = context;
249
+ writeFrame({ type: "agent_start" });
250
+ });
251
+ pi.on("agent_settled", (_event, context) => {
252
+ latestContext = context;
253
+ writeSessionState(context, "settled");
254
+ writeFrame({ type: "agent_settled" });
255
+ });
256
+ }