@unlinearity/cli-wechat-bridge 1.0.0
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/LICENSE.txt +21 -0
- package/README.md +531 -0
- package/bin/_run-entry.mjs +41 -0
- package/bin/wechat-bridge-claude.mjs +5 -0
- package/bin/wechat-bridge-codex.mjs +5 -0
- package/bin/wechat-bridge-opencode.mjs +5 -0
- package/bin/wechat-bridge-shell.mjs +5 -0
- package/bin/wechat-bridge.mjs +5 -0
- package/bin/wechat-check-update.mjs +5 -0
- package/bin/wechat-claude-start.mjs +5 -0
- package/bin/wechat-claude.mjs +5 -0
- package/bin/wechat-codex-start.mjs +5 -0
- package/bin/wechat-codex.mjs +5 -0
- package/bin/wechat-opencode-start.mjs +5 -0
- package/bin/wechat-opencode.mjs +5 -0
- package/package.json +86 -0
- package/src/bridge/bridge-adapters.claude.ts +1047 -0
- package/src/bridge/bridge-adapters.codex.ts +2733 -0
- package/src/bridge/bridge-adapters.core.ts +877 -0
- package/src/bridge/bridge-adapters.opencode.ts +2390 -0
- package/src/bridge/bridge-adapters.shared.ts +1647 -0
- package/src/bridge/bridge-adapters.shell.ts +456 -0
- package/src/bridge/bridge-adapters.ts +30 -0
- package/src/bridge/bridge-controller.ts +58 -0
- package/src/bridge/bridge-final-reply.ts +56 -0
- package/src/bridge/bridge-process-reaper.ts +417 -0
- package/src/bridge/bridge-state.ts +520 -0
- package/src/bridge/bridge-types.ts +218 -0
- package/src/bridge/bridge-utils.ts +1524 -0
- package/src/bridge/claude-hook.ts +92 -0
- package/src/bridge/claude-hooks.ts +376 -0
- package/src/bridge/wechat-bridge.ts +1305 -0
- package/src/commands/check-update.ts +46 -0
- package/src/companion/codex-panel-link.ts +151 -0
- package/src/companion/codex-panel.ts +225 -0
- package/src/companion/codex-remote-client.ts +178 -0
- package/src/companion/local-companion-link.ts +338 -0
- package/src/companion/local-companion-start.ts +520 -0
- package/src/companion/local-companion.ts +532 -0
- package/src/media/media-types.ts +11 -0
- package/src/runtime/create-runtime-host.ts +16 -0
- package/src/runtime/legacy-adapter-runtime.ts +55 -0
- package/src/runtime/runtime-types.ts +51 -0
- package/src/utils/version-checker.ts +197 -0
- package/src/wechat/channel-config.ts +141 -0
- package/src/wechat/setup.ts +232 -0
- package/src/wechat/standalone-bot.ts +483 -0
- package/src/wechat/wechat-channel.ts +608 -0
- package/src/wechat/wechat-transport.ts +1290 -0
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import { spawn as spawnPty } from "node-pty";
|
|
3
|
+
import type { IPty } from "node-pty";
|
|
4
|
+
import {
|
|
5
|
+
attachLocalCompanionMessageListener,
|
|
6
|
+
buildLocalCompanionToken,
|
|
7
|
+
clearLocalCompanionEndpoint,
|
|
8
|
+
clearLocalCompanionOccupancy,
|
|
9
|
+
sendLocalCompanionMessage,
|
|
10
|
+
updateLocalCompanionOccupancy,
|
|
11
|
+
writeLocalCompanionEndpoint,
|
|
12
|
+
type LocalCompanionCloseReason,
|
|
13
|
+
type LocalCompanionCommand,
|
|
14
|
+
type LocalCompanionEndpoint,
|
|
15
|
+
type LocalCompanionMessage,
|
|
16
|
+
} from "../companion/local-companion-link.ts";
|
|
17
|
+
import type {
|
|
18
|
+
ApprovalRequest,
|
|
19
|
+
BridgeAdapter,
|
|
20
|
+
BridgeAdapterKind,
|
|
21
|
+
BridgeAdapterState,
|
|
22
|
+
BridgeEvent,
|
|
23
|
+
BridgeLifecycleMode,
|
|
24
|
+
BridgeResumeSessionCandidate,
|
|
25
|
+
} from "./bridge-types.ts";
|
|
26
|
+
import {
|
|
27
|
+
detectCliApproval,
|
|
28
|
+
normalizeOutput,
|
|
29
|
+
nowIso,
|
|
30
|
+
truncatePreview,
|
|
31
|
+
} from "./bridge-utils.ts";
|
|
32
|
+
import * as shared from "./bridge-adapters.shared.ts";
|
|
33
|
+
|
|
34
|
+
type AdapterOptions = shared.AdapterOptions;
|
|
35
|
+
type EventSink = shared.EventSink;
|
|
36
|
+
type SpawnTarget = shared.SpawnTarget;
|
|
37
|
+
|
|
38
|
+
const {
|
|
39
|
+
CODEX_APP_SERVER_HOST,
|
|
40
|
+
INTERRUPT_SETTLE_DELAY_MS,
|
|
41
|
+
buildCliEnvironment,
|
|
42
|
+
buildPtySpawnOptions,
|
|
43
|
+
getLocalCompanionCommandName,
|
|
44
|
+
getSharedSessionIdFromAdapterState,
|
|
45
|
+
LOCAL_COMPANION_RECONNECT_GRACE_MS,
|
|
46
|
+
resolveSpawnTarget,
|
|
47
|
+
} = shared;
|
|
48
|
+
|
|
49
|
+
export class LocalCompanionProxyAdapter implements BridgeAdapter {
|
|
50
|
+
private readonly options: AdapterOptions;
|
|
51
|
+
private readonly state: BridgeAdapterState;
|
|
52
|
+
private eventSink: EventSink = () => undefined;
|
|
53
|
+
private server: net.Server | null = null;
|
|
54
|
+
private socket: net.Socket | null = null;
|
|
55
|
+
private detachMessageListener: (() => void) | null = null;
|
|
56
|
+
private requestCounter = 0;
|
|
57
|
+
private endpoint: LocalCompanionEndpoint | null = null;
|
|
58
|
+
private readonly pendingRequests = new Map<
|
|
59
|
+
string,
|
|
60
|
+
{
|
|
61
|
+
resolve: (value: unknown) => void;
|
|
62
|
+
reject: (reason?: unknown) => void;
|
|
63
|
+
}
|
|
64
|
+
>();
|
|
65
|
+
private shuttingDown = false;
|
|
66
|
+
private expectedCloseReason: LocalCompanionCloseReason | null = null;
|
|
67
|
+
private reconnectShutdownTimer: ReturnType<typeof setTimeout> | null = null;
|
|
68
|
+
|
|
69
|
+
constructor(options: AdapterOptions) {
|
|
70
|
+
this.options = options;
|
|
71
|
+
this.state = {
|
|
72
|
+
kind: options.kind,
|
|
73
|
+
status: "stopped",
|
|
74
|
+
cwd: options.cwd,
|
|
75
|
+
command: options.command,
|
|
76
|
+
profile: options.profile,
|
|
77
|
+
sharedSessionId: options.initialSharedSessionId ?? options.initialSharedThreadId,
|
|
78
|
+
sharedThreadId:
|
|
79
|
+
options.kind === "codex" || options.kind === "opencode"
|
|
80
|
+
? options.initialSharedSessionId ?? options.initialSharedThreadId
|
|
81
|
+
: undefined,
|
|
82
|
+
activeRuntimeSessionId:
|
|
83
|
+
options.kind === "claude" || options.kind === "opencode"
|
|
84
|
+
? options.initialSharedSessionId ?? options.initialSharedThreadId
|
|
85
|
+
: undefined,
|
|
86
|
+
resumeConversationId:
|
|
87
|
+
options.kind === "claude" ? options.initialResumeConversationId : undefined,
|
|
88
|
+
transcriptPath: options.kind === "claude" ? options.initialTranscriptPath : undefined,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
setEventSink(sink: EventSink): void {
|
|
93
|
+
this.eventSink = sink;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async start(): Promise<void> {
|
|
97
|
+
if (this.server) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.shuttingDown = false;
|
|
102
|
+
this.expectedCloseReason = null;
|
|
103
|
+
this.clearReconnectShutdownTimer();
|
|
104
|
+
this.setStatus(
|
|
105
|
+
"starting",
|
|
106
|
+
`Waiting for manual ${this.options.kind} companion connection. Run "${getLocalCompanionCommandName(this.options.kind)}" in a second terminal for this directory.`,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
await new Promise<void>((resolve, reject) => {
|
|
110
|
+
const server = net.createServer((socket) => {
|
|
111
|
+
this.handlePanelSocket(socket);
|
|
112
|
+
});
|
|
113
|
+
this.server = server;
|
|
114
|
+
server.on("error", (error) => {
|
|
115
|
+
reject(error);
|
|
116
|
+
});
|
|
117
|
+
server.listen(0, CODEX_APP_SERVER_HOST, () => {
|
|
118
|
+
const address = server.address();
|
|
119
|
+
if (!address || typeof address === "string") {
|
|
120
|
+
reject(new Error(`Failed to allocate a local ${this.options.kind} companion port.`));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.endpoint = {
|
|
125
|
+
instanceId: `${process.pid}-${Date.now().toString(36)}`,
|
|
126
|
+
kind: this.options.kind,
|
|
127
|
+
port: address.port,
|
|
128
|
+
token: buildLocalCompanionToken(),
|
|
129
|
+
cwd: this.options.cwd,
|
|
130
|
+
command: this.options.command,
|
|
131
|
+
profile: this.options.profile,
|
|
132
|
+
sharedSessionId: getSharedSessionIdFromAdapterState(this.state),
|
|
133
|
+
resumeConversationId: this.state.resumeConversationId,
|
|
134
|
+
transcriptPath: this.state.transcriptPath,
|
|
135
|
+
startedAt: nowIso(),
|
|
136
|
+
};
|
|
137
|
+
writeLocalCompanionEndpoint(this.endpoint);
|
|
138
|
+
resolve();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async sendInput(text: string): Promise<void> {
|
|
144
|
+
await this.sendRequest({
|
|
145
|
+
command: "send_input",
|
|
146
|
+
text,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async listResumeSessions(limit = 10): Promise<BridgeResumeSessionCandidate[]> {
|
|
151
|
+
const result = await this.sendRequest({
|
|
152
|
+
command: "list_resume_sessions",
|
|
153
|
+
limit,
|
|
154
|
+
});
|
|
155
|
+
return Array.isArray(result) ? (result as BridgeResumeSessionCandidate[]) : [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async resumeSession(sessionId: string): Promise<void> {
|
|
159
|
+
await this.sendRequest({
|
|
160
|
+
command: "resume_session",
|
|
161
|
+
sessionId,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async interrupt(): Promise<boolean> {
|
|
166
|
+
const result = await this.sendRequest({
|
|
167
|
+
command: "interrupt",
|
|
168
|
+
});
|
|
169
|
+
return Boolean(result);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async reset(): Promise<void> {
|
|
173
|
+
await this.sendRequest({
|
|
174
|
+
command: "reset",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async resolveApproval(action: "confirm" | "deny"): Promise<boolean> {
|
|
179
|
+
const result = await this.sendRequest({
|
|
180
|
+
command: "resolve_approval",
|
|
181
|
+
action,
|
|
182
|
+
});
|
|
183
|
+
return Boolean(result);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async submitUserInput(_answers: Record<string, string[]>): Promise<boolean> {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async dispose(): Promise<void> {
|
|
191
|
+
this.shuttingDown = true;
|
|
192
|
+
this.expectedCloseReason = null;
|
|
193
|
+
this.clearReconnectShutdownTimer();
|
|
194
|
+
this.rejectPendingRequests(`${this.options.kind} companion proxy is shutting down.`);
|
|
195
|
+
clearLocalCompanionEndpoint(this.options.cwd, this.endpoint?.instanceId);
|
|
196
|
+
|
|
197
|
+
if (this.socket) {
|
|
198
|
+
try {
|
|
199
|
+
sendLocalCompanionMessage(this.socket, {
|
|
200
|
+
type: "request",
|
|
201
|
+
id: `${++this.requestCounter}`,
|
|
202
|
+
payload: { command: "dispose" },
|
|
203
|
+
});
|
|
204
|
+
} catch {
|
|
205
|
+
// Best effort.
|
|
206
|
+
}
|
|
207
|
+
this.detachPanelSocket();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!this.server) {
|
|
211
|
+
this.state.status = "stopped";
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const server = this.server;
|
|
216
|
+
this.server = null;
|
|
217
|
+
await new Promise<void>((resolve) => {
|
|
218
|
+
server.close(() => resolve());
|
|
219
|
+
});
|
|
220
|
+
this.state.status = "stopped";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
getState(): BridgeAdapterState {
|
|
224
|
+
return JSON.parse(JSON.stringify(this.state)) as BridgeAdapterState;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private handlePanelSocket(socket: net.Socket): void {
|
|
228
|
+
if (!this.endpoint) {
|
|
229
|
+
socket.destroy();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (this.socket) {
|
|
234
|
+
socket.end();
|
|
235
|
+
socket.destroy();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let authenticated = false;
|
|
240
|
+
socket.setNoDelay(true);
|
|
241
|
+
const detachListener = attachLocalCompanionMessageListener(socket, (message) => {
|
|
242
|
+
if (!authenticated) {
|
|
243
|
+
if (
|
|
244
|
+
message.type !== "hello" ||
|
|
245
|
+
message.token !== this.endpoint?.token
|
|
246
|
+
) {
|
|
247
|
+
socket.destroy();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
authenticated = true;
|
|
252
|
+
this.clearReconnectShutdownTimer();
|
|
253
|
+
this.expectedCloseReason = null;
|
|
254
|
+
this.socket = socket;
|
|
255
|
+
this.detachMessageListener = detachListener;
|
|
256
|
+
updateLocalCompanionOccupancy(
|
|
257
|
+
this.options.cwd,
|
|
258
|
+
{
|
|
259
|
+
companionPid: message.companionPid,
|
|
260
|
+
companionConnectedAt: nowIso(),
|
|
261
|
+
},
|
|
262
|
+
this.endpoint?.instanceId,
|
|
263
|
+
);
|
|
264
|
+
sendLocalCompanionMessage(socket, { type: "hello_ack" });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
this.handlePanelMessage(message);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
socket.once("close", () => {
|
|
272
|
+
if (this.socket === socket) {
|
|
273
|
+
const expectedCloseReason = this.expectedCloseReason;
|
|
274
|
+
this.expectedCloseReason = null;
|
|
275
|
+
clearLocalCompanionOccupancy(this.options.cwd, this.endpoint?.instanceId);
|
|
276
|
+
this.detachPanelSocket();
|
|
277
|
+
if (!this.shuttingDown) {
|
|
278
|
+
this.handleCompanionDisconnect(expectedCloseReason);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
socket.once("error", () => {
|
|
283
|
+
socket.destroy();
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private handlePanelMessage(message: LocalCompanionMessage): void {
|
|
288
|
+
switch (message.type) {
|
|
289
|
+
case "closing":
|
|
290
|
+
this.expectedCloseReason = message.reason;
|
|
291
|
+
return;
|
|
292
|
+
case "event":
|
|
293
|
+
this.eventSink(message.event);
|
|
294
|
+
return;
|
|
295
|
+
case "state":
|
|
296
|
+
if (this.endpoint) {
|
|
297
|
+
Object.assign(
|
|
298
|
+
this.endpoint,
|
|
299
|
+
buildCompanionHealthPatch(message.state, nowIso()),
|
|
300
|
+
);
|
|
301
|
+
const nextSessionId = getSharedSessionIdFromAdapterState(message.state);
|
|
302
|
+
if (
|
|
303
|
+
this.endpoint.sharedSessionId !== nextSessionId ||
|
|
304
|
+
this.endpoint.resumeConversationId !== message.state.resumeConversationId ||
|
|
305
|
+
this.endpoint.transcriptPath !== message.state.transcriptPath
|
|
306
|
+
) {
|
|
307
|
+
this.endpoint.sharedSessionId = nextSessionId;
|
|
308
|
+
this.endpoint.sharedThreadId =
|
|
309
|
+
this.options.kind === "codex" || this.options.kind === "opencode" ? nextSessionId : undefined;
|
|
310
|
+
this.endpoint.resumeConversationId = message.state.resumeConversationId;
|
|
311
|
+
this.endpoint.transcriptPath = message.state.transcriptPath;
|
|
312
|
+
}
|
|
313
|
+
writeLocalCompanionEndpoint(this.endpoint);
|
|
314
|
+
}
|
|
315
|
+
this.state.pid = undefined;
|
|
316
|
+
this.state.startedAt = undefined;
|
|
317
|
+
this.state.lastInputAt = undefined;
|
|
318
|
+
this.state.lastOutputAt = undefined;
|
|
319
|
+
this.state.pendingApproval = null;
|
|
320
|
+
this.state.sharedSessionId = undefined;
|
|
321
|
+
this.state.sharedThreadId = undefined;
|
|
322
|
+
this.state.activeRuntimeSessionId = undefined;
|
|
323
|
+
this.state.resumeConversationId = undefined;
|
|
324
|
+
this.state.transcriptPath = undefined;
|
|
325
|
+
this.state.lastSessionSwitchAt = undefined;
|
|
326
|
+
this.state.lastSessionSwitchSource = undefined;
|
|
327
|
+
this.state.lastSessionSwitchReason = undefined;
|
|
328
|
+
this.state.lastThreadSwitchAt = undefined;
|
|
329
|
+
this.state.lastThreadSwitchSource = undefined;
|
|
330
|
+
this.state.lastThreadSwitchReason = undefined;
|
|
331
|
+
this.state.activeTurnId = undefined;
|
|
332
|
+
this.state.activeTurnOrigin = undefined;
|
|
333
|
+
this.state.pendingApprovalOrigin = undefined;
|
|
334
|
+
Object.assign(this.state, message.state);
|
|
335
|
+
this.eventSink({
|
|
336
|
+
type: "status",
|
|
337
|
+
status: this.state.status,
|
|
338
|
+
timestamp: nowIso(),
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
case "response": {
|
|
342
|
+
const pending = this.pendingRequests.get(message.id);
|
|
343
|
+
if (!pending) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
this.pendingRequests.delete(message.id);
|
|
347
|
+
if (!message.ok) {
|
|
348
|
+
pending.reject(
|
|
349
|
+
new Error(message.error ?? `Unknown ${this.options.kind} companion error.`),
|
|
350
|
+
);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
pending.resolve(message.result);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private detachPanelSocket(): void {
|
|
360
|
+
this.detachMessageListener?.();
|
|
361
|
+
this.detachMessageListener = null;
|
|
362
|
+
if (this.socket) {
|
|
363
|
+
this.socket.removeAllListeners();
|
|
364
|
+
this.socket.destroy();
|
|
365
|
+
this.socket = null;
|
|
366
|
+
}
|
|
367
|
+
this.state.pid = undefined;
|
|
368
|
+
this.state.startedAt = undefined;
|
|
369
|
+
this.state.lastInputAt = undefined;
|
|
370
|
+
this.state.lastOutputAt = undefined;
|
|
371
|
+
this.state.pendingApproval = null;
|
|
372
|
+
this.state.pendingApprovalOrigin = undefined;
|
|
373
|
+
this.state.activeTurnId = undefined;
|
|
374
|
+
this.state.activeTurnOrigin = undefined;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private handleCompanionDisconnect(
|
|
378
|
+
expectedCloseReason: LocalCompanionCloseReason | null,
|
|
379
|
+
): void {
|
|
380
|
+
const disposition = getCompanionDisconnectDisposition({
|
|
381
|
+
kind: this.options.kind,
|
|
382
|
+
lifecycle: this.options.lifecycle,
|
|
383
|
+
expectedClose: isExpectedLocalCompanionClose(expectedCloseReason),
|
|
384
|
+
reconnectGraceMs: LOCAL_COMPANION_RECONNECT_GRACE_MS,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
if (disposition.action === "shutdown") {
|
|
388
|
+
this.clearReconnectShutdownTimer();
|
|
389
|
+
this.setStatus("stopped", disposition.message);
|
|
390
|
+
this.eventSink({
|
|
391
|
+
type: "shutdown_requested",
|
|
392
|
+
reason: disposition.shutdownReason,
|
|
393
|
+
message: disposition.message,
|
|
394
|
+
timestamp: nowIso(),
|
|
395
|
+
});
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (disposition.action === "wait_for_reconnect") {
|
|
400
|
+
this.setStatus("starting", disposition.message);
|
|
401
|
+
this.armReconnectShutdownTimer();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
this.clearReconnectShutdownTimer();
|
|
406
|
+
this.setStatus("starting", disposition.message);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private armReconnectShutdownTimer(): void {
|
|
410
|
+
this.clearReconnectShutdownTimer();
|
|
411
|
+
this.reconnectShutdownTimer = setTimeout(() => {
|
|
412
|
+
this.reconnectShutdownTimer = null;
|
|
413
|
+
if (this.shuttingDown || this.socket) {
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const message = buildCompanionReconnectTimeoutMessage({
|
|
418
|
+
kind: this.options.kind,
|
|
419
|
+
reconnectGraceMs: LOCAL_COMPANION_RECONNECT_GRACE_MS,
|
|
420
|
+
});
|
|
421
|
+
this.setStatus("stopped", message);
|
|
422
|
+
this.eventSink({
|
|
423
|
+
type: "shutdown_requested",
|
|
424
|
+
reason: "companion_reconnect_timeout",
|
|
425
|
+
message,
|
|
426
|
+
timestamp: nowIso(),
|
|
427
|
+
});
|
|
428
|
+
}, LOCAL_COMPANION_RECONNECT_GRACE_MS);
|
|
429
|
+
this.reconnectShutdownTimer.unref?.();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
private clearReconnectShutdownTimer(): void {
|
|
433
|
+
if (!this.reconnectShutdownTimer) {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
clearTimeout(this.reconnectShutdownTimer);
|
|
438
|
+
this.reconnectShutdownTimer = null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private setStatus(status: BridgeAdapterState["status"], message?: string): void {
|
|
442
|
+
this.state.status = status;
|
|
443
|
+
this.eventSink({
|
|
444
|
+
type: "status",
|
|
445
|
+
status,
|
|
446
|
+
message,
|
|
447
|
+
timestamp: nowIso(),
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private rejectPendingRequests(message: string): void {
|
|
452
|
+
for (const pending of this.pendingRequests.values()) {
|
|
453
|
+
pending.reject(new Error(message));
|
|
454
|
+
}
|
|
455
|
+
this.pendingRequests.clear();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private async sendRequest(payload: LocalCompanionCommand): Promise<unknown> {
|
|
459
|
+
const socket = this.socket;
|
|
460
|
+
if (!socket) {
|
|
461
|
+
throw new Error(
|
|
462
|
+
`${this.options.kind} companion is not connected. Run "${getLocalCompanionCommandName(this.options.kind)}" in a second terminal for this directory.`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
if (!this.state.pid && payload.command !== "dispose") {
|
|
466
|
+
throw new Error(`${this.options.kind} companion is connected but not ready yet. Wait for it to finish starting.`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const id = `${++this.requestCounter}`;
|
|
470
|
+
const response = new Promise<unknown>((resolve, reject) => {
|
|
471
|
+
this.pendingRequests.set(id, { resolve, reject });
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
sendLocalCompanionMessage(socket, {
|
|
475
|
+
type: "request",
|
|
476
|
+
id,
|
|
477
|
+
payload,
|
|
478
|
+
});
|
|
479
|
+
return await response;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export function shouldStopBridgeAfterCompanionDisconnect(
|
|
484
|
+
lifecycle: BridgeLifecycleMode | undefined,
|
|
485
|
+
): boolean {
|
|
486
|
+
return lifecycle === "companion_bound";
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function buildCompanionHealthPatch(
|
|
490
|
+
state: BridgeAdapterState,
|
|
491
|
+
timestamp: string,
|
|
492
|
+
): {
|
|
493
|
+
companionStatus: BridgeAdapterState["status"];
|
|
494
|
+
companionLastStateAt: string;
|
|
495
|
+
companionWorkerPid?: number;
|
|
496
|
+
} {
|
|
497
|
+
return {
|
|
498
|
+
companionStatus: state.status,
|
|
499
|
+
companionLastStateAt: timestamp,
|
|
500
|
+
companionWorkerPid: state.pid,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function isExpectedLocalCompanionClose(
|
|
505
|
+
reason: LocalCompanionCloseReason | null | undefined,
|
|
506
|
+
): boolean {
|
|
507
|
+
return typeof reason === "string" && reason.length > 0;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export type CompanionDisconnectDisposition =
|
|
511
|
+
| {
|
|
512
|
+
action: "shutdown";
|
|
513
|
+
message: string;
|
|
514
|
+
shutdownReason: "companion_closed";
|
|
515
|
+
}
|
|
516
|
+
| {
|
|
517
|
+
action: "wait_for_reconnect";
|
|
518
|
+
message: string;
|
|
519
|
+
}
|
|
520
|
+
| {
|
|
521
|
+
action: "await_manual_reconnect";
|
|
522
|
+
message: string;
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
export function buildCompanionReconnectTimeoutMessage(params: {
|
|
526
|
+
kind: BridgeAdapterKind;
|
|
527
|
+
reconnectGraceMs: number;
|
|
528
|
+
}): string {
|
|
529
|
+
return `${params.kind} companion did not reconnect within ${Math.ceil(params.reconnectGraceMs / 1000)}s. Stopping transient bridge bound to ${getLocalCompanionCommandName(params.kind)}.`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export function getCompanionDisconnectDisposition(params: {
|
|
533
|
+
kind: BridgeAdapterKind;
|
|
534
|
+
lifecycle: BridgeLifecycleMode | undefined;
|
|
535
|
+
expectedClose: boolean;
|
|
536
|
+
reconnectGraceMs: number;
|
|
537
|
+
}): CompanionDisconnectDisposition {
|
|
538
|
+
const commandName = getLocalCompanionCommandName(params.kind);
|
|
539
|
+
|
|
540
|
+
if (shouldStopBridgeAfterCompanionDisconnect(params.lifecycle)) {
|
|
541
|
+
if (params.expectedClose) {
|
|
542
|
+
return {
|
|
543
|
+
action: "shutdown",
|
|
544
|
+
shutdownReason: "companion_closed",
|
|
545
|
+
message: `${params.kind} companion closed. Stopping transient bridge bound to ${commandName}.`,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return {
|
|
550
|
+
action: "wait_for_reconnect",
|
|
551
|
+
message: `${params.kind} companion disconnected unexpectedly. Waiting up to ${Math.ceil(params.reconnectGraceMs / 1000)}s for ${commandName} to reconnect before stopping this transient bridge.`,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (params.expectedClose) {
|
|
556
|
+
return {
|
|
557
|
+
action: "await_manual_reconnect",
|
|
558
|
+
message: `${params.kind} companion closed. Run "${commandName}" again in a second terminal for this directory.`,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
action: "await_manual_reconnect",
|
|
564
|
+
message: `${params.kind} companion disconnected unexpectedly. Run "${commandName}" again in a second terminal for this directory to reconnect.`,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export abstract class AbstractPtyAdapter implements BridgeAdapter {
|
|
569
|
+
protected readonly options: AdapterOptions;
|
|
570
|
+
protected pty: IPty | null = null;
|
|
571
|
+
protected eventSink: EventSink = () => undefined;
|
|
572
|
+
protected completionTimer: ReturnType<typeof setTimeout> | null = null;
|
|
573
|
+
protected state: BridgeAdapterState;
|
|
574
|
+
protected hasAcceptedInput = false;
|
|
575
|
+
protected shuttingDown = false;
|
|
576
|
+
protected currentPreview = "(idle)";
|
|
577
|
+
protected pendingApproval: ApprovalRequest | null = null;
|
|
578
|
+
|
|
579
|
+
constructor(options: AdapterOptions) {
|
|
580
|
+
this.options = options;
|
|
581
|
+
this.state = {
|
|
582
|
+
kind: options.kind,
|
|
583
|
+
status: "stopped",
|
|
584
|
+
cwd: options.cwd,
|
|
585
|
+
command: options.command,
|
|
586
|
+
profile: options.profile,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
setEventSink(sink: EventSink): void {
|
|
591
|
+
this.eventSink = sink;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async start(): Promise<void> {
|
|
595
|
+
if (this.pty) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
this.setStatus("starting", `Starting ${this.options.kind} adapter...`);
|
|
600
|
+
|
|
601
|
+
let spawnTarget: SpawnTarget | null = null;
|
|
602
|
+
try {
|
|
603
|
+
spawnTarget = resolveSpawnTarget(this.options.command, this.options.kind);
|
|
604
|
+
const env = this.buildEnv();
|
|
605
|
+
const ptyProcess = spawnPty(
|
|
606
|
+
spawnTarget.file,
|
|
607
|
+
[...spawnTarget.args, ...this.buildSpawnArgs()],
|
|
608
|
+
buildPtySpawnOptions({
|
|
609
|
+
cwd: this.options.cwd,
|
|
610
|
+
env,
|
|
611
|
+
}),
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
this.pty = ptyProcess;
|
|
615
|
+
this.shuttingDown = false;
|
|
616
|
+
this.hasAcceptedInput = false;
|
|
617
|
+
this.state.pid = ptyProcess.pid;
|
|
618
|
+
this.state.startedAt = nowIso();
|
|
619
|
+
this.state.status = "idle";
|
|
620
|
+
this.state.pendingApproval = null;
|
|
621
|
+
|
|
622
|
+
ptyProcess.onData((data) => this.handleData(data));
|
|
623
|
+
ptyProcess.onExit(({ exitCode }) => this.handleExit(exitCode));
|
|
624
|
+
|
|
625
|
+
this.afterStart();
|
|
626
|
+
this.setStatus("idle", `${this.options.kind} adapter is ready.`);
|
|
627
|
+
} catch (err) {
|
|
628
|
+
this.state.status = "error";
|
|
629
|
+
this.emit({
|
|
630
|
+
type: "fatal_error",
|
|
631
|
+
message: `Failed to start ${this.options.kind}${spawnTarget ? ` (${spawnTarget.file})` : ""}: ${String(err)}`,
|
|
632
|
+
timestamp: nowIso(),
|
|
633
|
+
});
|
|
634
|
+
throw err;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async sendInput(text: string): Promise<void> {
|
|
639
|
+
if (!this.pty) {
|
|
640
|
+
throw new Error(`${this.options.kind} adapter is not running.`);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
this.hasAcceptedInput = true;
|
|
644
|
+
this.currentPreview = truncatePreview(text);
|
|
645
|
+
this.state.lastInputAt = nowIso();
|
|
646
|
+
this.pendingApproval = null;
|
|
647
|
+
this.state.pendingApproval = null;
|
|
648
|
+
this.writeToPty(this.prepareInput(text));
|
|
649
|
+
this.setStatus("busy");
|
|
650
|
+
this.scheduleTaskComplete(this.defaultCompletionDelayMs());
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async listResumeSessions(_limit = 10): Promise<BridgeResumeSessionCandidate[]> {
|
|
654
|
+
throw new Error("/resume is only supported for the codex adapter.");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async resumeSession(_sessionId: string): Promise<void> {
|
|
658
|
+
throw new Error("/resume is only supported for the codex adapter.");
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async interrupt(): Promise<boolean> {
|
|
662
|
+
if (!this.pty) {
|
|
663
|
+
return false;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
this.writeToPty("\u0003");
|
|
667
|
+
this.scheduleTaskComplete(INTERRUPT_SETTLE_DELAY_MS);
|
|
668
|
+
this.emit({
|
|
669
|
+
type: "status",
|
|
670
|
+
status: this.state.status,
|
|
671
|
+
message: "Interrupt signal sent to the worker.",
|
|
672
|
+
timestamp: nowIso(),
|
|
673
|
+
});
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async reset(): Promise<void> {
|
|
678
|
+
await this.dispose();
|
|
679
|
+
await this.start();
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async resolveApproval(action: "confirm" | "deny"): Promise<boolean> {
|
|
683
|
+
if (!this.pendingApproval) {
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const handled = await this.applyApproval(action, this.pendingApproval);
|
|
688
|
+
if (!handled) {
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
this.pendingApproval = null;
|
|
693
|
+
this.state.pendingApproval = null;
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async submitUserInput(_answers: Record<string, string[]>): Promise<boolean> {
|
|
698
|
+
return false;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async dispose(): Promise<void> {
|
|
702
|
+
this.clearCompletionTimer();
|
|
703
|
+
this.pendingApproval = null;
|
|
704
|
+
this.state.pendingApproval = null;
|
|
705
|
+
|
|
706
|
+
if (!this.pty) {
|
|
707
|
+
this.state.status = "stopped";
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
this.shuttingDown = true;
|
|
712
|
+
try {
|
|
713
|
+
this.pty.kill();
|
|
714
|
+
} catch {
|
|
715
|
+
// Best effort shutdown.
|
|
716
|
+
}
|
|
717
|
+
this.pty = null;
|
|
718
|
+
this.state.status = "stopped";
|
|
719
|
+
this.state.pid = undefined;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
getState(): BridgeAdapterState {
|
|
723
|
+
return JSON.parse(JSON.stringify(this.state)) as BridgeAdapterState;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
protected abstract buildSpawnArgs(): string[];
|
|
727
|
+
|
|
728
|
+
protected afterStart(): void {
|
|
729
|
+
// Optional hook.
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
protected prepareInput(text: string): string {
|
|
733
|
+
return `${text.replace(/\r?\n/g, "\r")}\r`;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
protected defaultCompletionDelayMs(): number {
|
|
737
|
+
return 5_000;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
protected async applyApproval(
|
|
741
|
+
action: "confirm" | "deny",
|
|
742
|
+
pendingApproval: ApprovalRequest,
|
|
743
|
+
): Promise<boolean> {
|
|
744
|
+
if (!this.pty) {
|
|
745
|
+
return false;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const input =
|
|
749
|
+
action === "confirm"
|
|
750
|
+
? pendingApproval.confirmInput ?? "y\r"
|
|
751
|
+
: pendingApproval.denyInput ?? "n\r";
|
|
752
|
+
this.setStatus("busy");
|
|
753
|
+
this.writeToPty(input);
|
|
754
|
+
this.scheduleTaskComplete(this.defaultCompletionDelayMs());
|
|
755
|
+
return true;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
protected buildEnv(): Record<string, string> {
|
|
759
|
+
return buildCliEnvironment(this.options.kind);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
protected emit(event: BridgeEvent): void {
|
|
763
|
+
this.eventSink(event);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
protected setStatus(
|
|
767
|
+
status: BridgeAdapterState["status"],
|
|
768
|
+
message?: string,
|
|
769
|
+
): void {
|
|
770
|
+
this.state.status = status;
|
|
771
|
+
this.emit({
|
|
772
|
+
type: "status",
|
|
773
|
+
status,
|
|
774
|
+
message,
|
|
775
|
+
timestamp: nowIso(),
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
protected scheduleTaskComplete(delayMs: number): void {
|
|
780
|
+
if (!this.hasAcceptedInput || this.state.status !== "busy") {
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
this.clearCompletionTimer();
|
|
785
|
+
this.completionTimer = setTimeout(() => {
|
|
786
|
+
this.completionTimer = null;
|
|
787
|
+
if (this.state.status !== "busy") {
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
this.setStatus("idle");
|
|
791
|
+
this.emit({
|
|
792
|
+
type: "task_complete",
|
|
793
|
+
summary: this.currentPreview,
|
|
794
|
+
timestamp: nowIso(),
|
|
795
|
+
});
|
|
796
|
+
}, delayMs);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
protected clearCompletionTimer(): void {
|
|
800
|
+
if (!this.completionTimer) {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
clearTimeout(this.completionTimer);
|
|
804
|
+
this.completionTimer = null;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
protected writeToPty(data: string): void {
|
|
808
|
+
this.pty?.write(data);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
protected handleData(rawText: string): void {
|
|
812
|
+
const text = normalizeOutput(rawText);
|
|
813
|
+
if (!text) {
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
this.state.lastOutputAt = nowIso();
|
|
818
|
+
if (!this.hasAcceptedInput) {
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (!this.pendingApproval) {
|
|
823
|
+
const approval = detectCliApproval(text);
|
|
824
|
+
if (approval) {
|
|
825
|
+
this.pendingApproval = approval;
|
|
826
|
+
this.state.pendingApproval = approval;
|
|
827
|
+
this.setStatus("awaiting_approval", "CLI approval is required.");
|
|
828
|
+
this.emit({
|
|
829
|
+
type: "approval_required",
|
|
830
|
+
request: approval,
|
|
831
|
+
timestamp: nowIso(),
|
|
832
|
+
});
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
this.emit({
|
|
838
|
+
type: "stdout",
|
|
839
|
+
text,
|
|
840
|
+
timestamp: nowIso(),
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
if (this.state.status === "busy") {
|
|
844
|
+
this.scheduleTaskComplete(this.defaultCompletionDelayMs());
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
protected handleExit(exitCode: number | undefined): void {
|
|
849
|
+
this.clearCompletionTimer();
|
|
850
|
+
const expectedShutdown = this.shuttingDown;
|
|
851
|
+
this.shuttingDown = false;
|
|
852
|
+
this.pty = null;
|
|
853
|
+
this.state.status = "stopped";
|
|
854
|
+
this.state.pid = undefined;
|
|
855
|
+
this.pendingApproval = null;
|
|
856
|
+
this.state.pendingApproval = null;
|
|
857
|
+
|
|
858
|
+
if (expectedShutdown) {
|
|
859
|
+
this.emit({
|
|
860
|
+
type: "status",
|
|
861
|
+
status: "stopped",
|
|
862
|
+
message: `${this.options.kind} worker stopped.`,
|
|
863
|
+
timestamp: nowIso(),
|
|
864
|
+
});
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const exitLabel =
|
|
869
|
+
typeof exitCode === "number" ? `code ${exitCode}` : "an unknown code";
|
|
870
|
+
this.emit({
|
|
871
|
+
type: "fatal_error",
|
|
872
|
+
message: `${this.options.kind} worker exited unexpectedly with ${exitLabel}.`,
|
|
873
|
+
timestamp: nowIso(),
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|