cli-wechat-bridge 1.1.1 → 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.
Files changed (35) hide show
  1. package/README.md +60 -43
  2. package/bin/_run-entry.mjs +24 -0
  3. package/bin/wechat-bridge-pi.mjs +5 -0
  4. package/bin/wechat-pi-start.mjs +5 -0
  5. package/bin/wechat-pi.mjs +5 -0
  6. package/dist/bridge/bridge-adapter-common.js +2 -0
  7. package/dist/bridge/bridge-adapters.claude.js +18 -0
  8. package/dist/bridge/bridge-adapters.codex.js +83 -2
  9. package/dist/bridge/bridge-adapters.core.js +36 -2
  10. package/dist/bridge/bridge-adapters.js +5 -0
  11. package/dist/bridge/bridge-adapters.opencode.js +278 -32
  12. package/dist/bridge/bridge-adapters.pi.js +697 -0
  13. package/dist/bridge/bridge-adapters.shared.js +44 -46
  14. package/dist/bridge/bridge-final-reply.js +8 -4
  15. package/dist/bridge/bridge-process-reaper.js +58 -3
  16. package/dist/bridge/bridge-state.js +15 -6
  17. package/dist/bridge/bridge-utils.js +129 -30
  18. package/dist/bridge/claude-hook.js +7 -0
  19. package/dist/bridge/claude-hooks.js +10 -2
  20. package/dist/bridge/wechat-bridge.js +20 -8
  21. package/dist/commands/check-update.js +15 -4
  22. package/dist/companion/local-companion-link.js +6 -1
  23. package/dist/companion/local-companion-start.js +29 -8
  24. package/dist/companion/local-companion.js +3 -3
  25. package/dist/companion/pi-tui-bridge-extension.js +256 -0
  26. package/dist/daemon/wechat-daemon.js +126 -25
  27. package/dist/i18n/messages-en.js +12 -2
  28. package/dist/i18n/messages-zh.js +12 -2
  29. package/dist/utils/doctor.js +85 -2
  30. package/dist/utils/version-checker.js +77 -44
  31. package/dist/wechat/channel-config.js +28 -0
  32. package/dist/wechat/setup.js +2 -0
  33. package/dist/wechat/wechat-transport.js +49 -13
  34. package/package.json +120 -100
  35. package/scripts/ensure-node-pty-permissions.mjs +73 -0
@@ -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",
@@ -266,7 +267,9 @@ async function main() {
266
267
  }
267
268
  const transport = new WeChatTransport({ log, logError });
268
269
  // 非阻塞地检查更新(不影响启动速度,也避免首次登录时打断二维码输出)
269
- setTimeout(async () => {
270
+ // unref:不能让这个延迟检查把 event loop 挂活(如 --doctor 或快速退出场景),
271
+ // 否则会与强制退出 teardown 竞态。
272
+ const updateCheckTimer = setTimeout(async () => {
270
273
  try {
271
274
  const versionInfo = await checkForUpdate();
272
275
  if (versionInfo?.hasUpdate) {
@@ -277,6 +280,7 @@ async function main() {
277
280
  // 静默失败,不影响正常使用
278
281
  }
279
282
  }, 3000); // 延迟3秒,确保不影响启动
283
+ updateCheckTimer.unref?.();
280
284
  const stateStore = new BridgeStateStore({
281
285
  ...options,
282
286
  authorizedUserId: credentials.userId,
@@ -338,6 +342,7 @@ async function main() {
338
342
  let lastOutputAt = 0;
339
343
  let lastHeartbeatAt = 0;
340
344
  let consecutivePollFailures = 0;
345
+ let backlogNoticeSent = false;
341
346
  const queueWechatTextAction = (action) => {
342
347
  const run = textSendChain.then(action);
343
348
  textSendChain = run.then(() => undefined, () => undefined);
@@ -661,6 +666,13 @@ async function main() {
661
666
  if (pollResult.ignoredBacklogCount > 0) {
662
667
  stateStore.incrementIgnoredBacklog(pollResult.ignoredBacklogCount);
663
668
  stateStore.appendLog(`ignored_startup_backlog: count=${pollResult.ignoredBacklogCount}`);
669
+ if (!backlogNoticeSent) {
670
+ backlogNoticeSent = true;
671
+ await queueWechatMessage(stateStore.getState().authorizedUserId, t("bridge.backlogIgnored", {
672
+ count: pollResult.ignoredBacklogCount,
673
+ graceSeconds: Math.round(MESSAGE_START_GRACE_MS / 1000),
674
+ }), "notice");
675
+ }
664
676
  }
665
677
  for (const message of pollResult.messages) {
666
678
  if (!ensureRuntimeOwnership()) {
@@ -1111,9 +1123,9 @@ async function handleInboundMessage(params) {
1111
1123
  return null;
1112
1124
  }
1113
1125
  if (adapterState.status === "busy") {
1114
- if ((options.adapter === "codex" || options.adapter === "opencode") &&
1126
+ if ((options.adapter === "codex" || options.adapter === "opencode" || options.adapter === "pi") &&
1115
1127
  adapterState.activeTurnOrigin === "local") {
1116
- 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.`);
1117
1129
  return null;
1118
1130
  }
1119
1131
  await queueWechatMessage(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
@@ -7,12 +7,23 @@ async function main() {
7
7
  console.log(`Checking for updates...`);
8
8
  const versionInfo = await checkForUpdate(true); // 强制检查
9
9
  if (!versionInfo) {
10
- console.log(`ERROR: Unable to check for updates, please check your network connection`);
11
- process.exit(1);
10
+ console.error(`ERROR: Unable to check for updates.`);
11
+ console.error(`Could not read the latest version from the npm registry or GitHub.`);
12
+ console.error(`Possible causes:`);
13
+ console.error(` - No network connection`);
14
+ console.error(` - A proxy is required but not configured (set HTTP_PROXY/HTTPS_PROXY and NODE_USE_ENV_PROXY=1)`);
15
+ console.error(` - The npm registry or GitHub API are temporarily unavailable`);
16
+ console.error(`\nCheck the latest release manually:`);
17
+ console.error(` https://github.com/UNLINEARITY/CLI-WeChat-Bridge/releases`);
18
+ // 不使用 process.exit():强制退出会打断 fetch 底层 handle 的关闭流程,
19
+ // 在 Windows + Node 24 上触发 libuv 的 UV_HANDLE_CLOSING 断言崩溃。
20
+ // 设置 exitCode 后让事件循环自然退出。
21
+ process.exitCode = 1;
22
+ return;
12
23
  }
13
24
  if (!versionInfo.hasUpdate) {
14
25
  console.log(`OK: Already up to date (v${versionInfo.latest})`);
15
- process.exit(0);
26
+ return;
16
27
  }
17
28
  console.log(`[New Version Available] v${versionInfo.latest}`);
18
29
  console.log(`Current version: v${versionInfo.current}\n`);
@@ -26,5 +37,5 @@ async function main() {
26
37
  }
27
38
  main().catch((error) => {
28
39
  console.error(`Error checking for updates: ${error.message}`);
29
- process.exit(1);
40
+ process.exitCode = 1;
30
41
  });
@@ -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 {
@@ -283,6 +299,11 @@ function startBridgeInBackground(options) {
283
299
  stdio: "ignore",
284
300
  windowsHide: true,
285
301
  });
302
+ // A detached background spawn must still handle its own 'error' event
303
+ // (e.g. ENOENT), otherwise it surfaces as an unhandled exception.
304
+ child.on("error", () => {
305
+ /* best effort: background bridge failed to detach */
306
+ });
286
307
  child.unref();
287
308
  }
288
309
  async function waitForEndpoint(cwd, adapter, timeoutMs) {
@@ -378,7 +399,7 @@ export async function tryDelegateToDaemon(options, deps = {}) {
378
399
  cliArgs: options.cliArgs,
379
400
  openVisible: true,
380
401
  sessionStartMode: options.sessionStartMode,
381
- reuseExistingVisible: true,
402
+ reuseExistingVisible: options.adapter !== "pi" || options.sessionStartMode !== "new",
382
403
  });
383
404
  if (!response.ok) {
384
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
+ }