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.
- package/README.md +57 -41
- package/bin/wechat-bridge-pi.mjs +5 -0
- package/bin/wechat-pi-start.mjs +5 -0
- package/bin/wechat-pi.mjs +5 -0
- package/dist/bridge/bridge-adapter-common.js +2 -0
- package/dist/bridge/bridge-adapters.codex.js +58 -2
- package/dist/bridge/bridge-adapters.core.js +36 -2
- package/dist/bridge/bridge-adapters.js +5 -0
- package/dist/bridge/bridge-adapters.opencode.js +261 -32
- package/dist/bridge/bridge-adapters.pi.js +697 -0
- package/dist/bridge/bridge-adapters.shared.js +10 -3
- package/dist/bridge/bridge-state.js +1 -0
- package/dist/bridge/bridge-utils.js +67 -23
- package/dist/bridge/wechat-bridge.js +8 -7
- package/dist/companion/local-companion-link.js +6 -1
- package/dist/companion/local-companion-start.js +24 -8
- package/dist/companion/local-companion.js +3 -3
- package/dist/companion/pi-tui-bridge-extension.js +256 -0
- package/dist/daemon/wechat-daemon.js +116 -23
- package/dist/i18n/messages-en.js +2 -2
- package/dist/i18n/messages-zh.js +2 -2
- package/dist/utils/doctor.js +5 -1
- package/dist/wechat/setup.js +2 -0
- package/package.json +120 -102
|
@@ -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("--
|
|
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" ||
|
|
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"
|
|
@@ -802,15 +802,25 @@ export function formatMirroredUserInputMessage(adapter, text) {
|
|
|
802
802
|
? "Local Claude input"
|
|
803
803
|
: adapter === "opencode"
|
|
804
804
|
? "Local OpenCode input"
|
|
805
|
-
:
|
|
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"
|
|
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"
|
|
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
|
|
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
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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(
|
|
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(
|
|
1225
|
-
|
|
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(
|
|
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 "
|
|
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 `
|
|
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 `
|
|
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" ||
|
|
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 === "
|
|
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" ||
|
|
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
|
-
"
|
|
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
|
|
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" ||
|
|
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:
|
|
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
|
+
}
|