@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,2390 @@
|
|
|
1
|
+
import { spawn as spawnChildProcess, type ChildProcess } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type AdapterOptions,
|
|
5
|
+
type EventSink,
|
|
6
|
+
OPENCODE_SERVER_HOST,
|
|
7
|
+
OPENCODE_SERVER_READY_TIMEOUT_MS,
|
|
8
|
+
OPENCODE_SSE_RECONNECT_DELAY_MS,
|
|
9
|
+
OPENCODE_SESSION_IDLE_SETTLE_MS,
|
|
10
|
+
OPENCODE_WECHAT_WORKING_NOTICE_DELAY_MS,
|
|
11
|
+
buildCliEnvironment,
|
|
12
|
+
isRecord,
|
|
13
|
+
describeUnknownError,
|
|
14
|
+
resolveSpawnTarget,
|
|
15
|
+
reserveLocalPort,
|
|
16
|
+
waitForTcpPort,
|
|
17
|
+
delay,
|
|
18
|
+
} from "./bridge-adapters.shared.ts";
|
|
19
|
+
import type {
|
|
20
|
+
ApprovalRequest,
|
|
21
|
+
BridgeAdapter,
|
|
22
|
+
BridgeAdapterState,
|
|
23
|
+
BridgeSessionSwitchReason,
|
|
24
|
+
BridgeSessionSwitchSource,
|
|
25
|
+
BridgeResumeSessionCandidate,
|
|
26
|
+
BridgeTurnOrigin,
|
|
27
|
+
BridgeEvent,
|
|
28
|
+
PendingApproval,
|
|
29
|
+
} from "./bridge-types.ts";
|
|
30
|
+
import { killProcessTreeSync } from "./bridge-process-reaper.ts";
|
|
31
|
+
import {
|
|
32
|
+
buildOneTimeCode,
|
|
33
|
+
normalizeOutput,
|
|
34
|
+
nowIso,
|
|
35
|
+
truncatePreview,
|
|
36
|
+
OutputBatcher,
|
|
37
|
+
} from "./bridge-utils.ts";
|
|
38
|
+
|
|
39
|
+
/* ------------------------------------------------------------------ */
|
|
40
|
+
/* Types for @opencode-ai/sdk (loose to avoid hard import-time deps) */
|
|
41
|
+
/* ------------------------------------------------------------------ */
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The real @opencode-ai/sdk OpencodeClient uses hey-api generated methods
|
|
45
|
+
* that return { data, error, request, response }. We define a minimal
|
|
46
|
+
* interface so the adapter can call methods without importing the SDK at
|
|
47
|
+
* compile-time (the SDK is loaded dynamically via createSdkClient).
|
|
48
|
+
*/
|
|
49
|
+
type SdkResult<T> =
|
|
50
|
+
| { data: T; error: undefined; request: unknown; response: unknown }
|
|
51
|
+
| { data: undefined; error: unknown; request: unknown; response: unknown };
|
|
52
|
+
|
|
53
|
+
type SdkSession = {
|
|
54
|
+
id: string;
|
|
55
|
+
projectID: string;
|
|
56
|
+
workspaceID?: string;
|
|
57
|
+
directory: string;
|
|
58
|
+
parentID?: string;
|
|
59
|
+
title: string;
|
|
60
|
+
version: string;
|
|
61
|
+
time: { created: number; updated: number; compacting?: number };
|
|
62
|
+
share?: { url: string };
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type SdkSessionStatus =
|
|
66
|
+
| { type: "idle" }
|
|
67
|
+
| { type: "busy" }
|
|
68
|
+
| { type: "retry"; attempt: number; message: string; next: number };
|
|
69
|
+
|
|
70
|
+
type SdkPermission = {
|
|
71
|
+
id: string;
|
|
72
|
+
type: string;
|
|
73
|
+
pattern?: string | Array<string>;
|
|
74
|
+
sessionID: string;
|
|
75
|
+
messageID: string;
|
|
76
|
+
callID?: string;
|
|
77
|
+
title: string;
|
|
78
|
+
metadata: Record<string, unknown>;
|
|
79
|
+
time: { created: number };
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
type SdkPart = {
|
|
83
|
+
id: string;
|
|
84
|
+
sessionID: string;
|
|
85
|
+
messageID: string;
|
|
86
|
+
type: string;
|
|
87
|
+
text?: string;
|
|
88
|
+
} & Record<string, unknown>;
|
|
89
|
+
|
|
90
|
+
type OpenCodeSdkClient = {
|
|
91
|
+
session: {
|
|
92
|
+
list(parameters?: Record<string, unknown>): Promise<SdkResult<SdkSession[]>>;
|
|
93
|
+
create(parameters?: Record<string, unknown>): Promise<SdkResult<SdkSession>>;
|
|
94
|
+
get(parameters: {
|
|
95
|
+
sessionID: string;
|
|
96
|
+
directory?: string;
|
|
97
|
+
workspace?: string;
|
|
98
|
+
}): Promise<SdkResult<SdkSession>>;
|
|
99
|
+
abort(parameters: {
|
|
100
|
+
sessionID: string;
|
|
101
|
+
directory?: string;
|
|
102
|
+
workspace?: string;
|
|
103
|
+
}): Promise<SdkResult<unknown>>;
|
|
104
|
+
promptAsync(parameters: {
|
|
105
|
+
sessionID: string;
|
|
106
|
+
directory?: string;
|
|
107
|
+
workspace?: string;
|
|
108
|
+
parts: Array<{ type: string; text: string }>;
|
|
109
|
+
}): Promise<SdkResult<void>>;
|
|
110
|
+
};
|
|
111
|
+
permission: {
|
|
112
|
+
respond(parameters: {
|
|
113
|
+
sessionID: string;
|
|
114
|
+
permissionID: string;
|
|
115
|
+
directory?: string;
|
|
116
|
+
workspace?: string;
|
|
117
|
+
response: string;
|
|
118
|
+
}): Promise<SdkResult<boolean>>;
|
|
119
|
+
};
|
|
120
|
+
tui: {
|
|
121
|
+
selectSession(parameters?: {
|
|
122
|
+
directory?: string;
|
|
123
|
+
workspace?: string;
|
|
124
|
+
sessionID?: string;
|
|
125
|
+
}): Promise<SdkResult<boolean>>;
|
|
126
|
+
};
|
|
127
|
+
event: {
|
|
128
|
+
subscribe(
|
|
129
|
+
parameters?: Record<string, unknown>,
|
|
130
|
+
options?: Record<string, unknown>,
|
|
131
|
+
): Promise<{
|
|
132
|
+
stream: AsyncIterable<unknown>;
|
|
133
|
+
}>;
|
|
134
|
+
};
|
|
135
|
+
global: {
|
|
136
|
+
event(options?: Record<string, unknown>): Promise<{
|
|
137
|
+
stream: AsyncIterable<unknown>;
|
|
138
|
+
}>;
|
|
139
|
+
syncEvent: {
|
|
140
|
+
subscribe(options?: Record<string, unknown>): Promise<{
|
|
141
|
+
stream: AsyncIterable<unknown>;
|
|
142
|
+
}>;
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
type SdkEvent = {
|
|
148
|
+
type: string;
|
|
149
|
+
properties?: unknown;
|
|
150
|
+
data?: unknown;
|
|
151
|
+
payload?: unknown;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
type SdkEventStreamName = "event" | "global-event" | "global-sync";
|
|
155
|
+
|
|
156
|
+
type SdkEventSubscription = {
|
|
157
|
+
stream: AsyncIterable<unknown>;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
type NormalizedSdkEvent = {
|
|
161
|
+
type: string;
|
|
162
|
+
properties?: unknown;
|
|
163
|
+
data?: unknown;
|
|
164
|
+
directory?: string;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
type OpenCodePendingPermission = {
|
|
168
|
+
sessionId: string;
|
|
169
|
+
permissionId: string;
|
|
170
|
+
code: string;
|
|
171
|
+
createdAt: string;
|
|
172
|
+
request: ApprovalRequest;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
type ObservedOpenCodeMessage = {
|
|
176
|
+
sessionId: string;
|
|
177
|
+
role?: "user" | "assistant";
|
|
178
|
+
text: string;
|
|
179
|
+
emitted: boolean;
|
|
180
|
+
updatedAtMs: number;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
type PendingWechatPromptMirrorSuppression = {
|
|
184
|
+
sessionId: string;
|
|
185
|
+
text: string;
|
|
186
|
+
createdAtMs: number;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const OPENCODE_DEBUG_ENABLED = /^(1|true|yes|on)$/i.test(
|
|
190
|
+
process.env.WECHAT_OPENCODE_DEBUG ?? "",
|
|
191
|
+
);
|
|
192
|
+
const OPENCODE_DUPLICATE_EVENT_TTL_MS = 150;
|
|
193
|
+
const OPENCODE_WECHAT_MIRROR_SUPPRESSION_TTL_MS = 30_000;
|
|
194
|
+
const OPENCODE_RECENT_LOCAL_PROMPT_TTL_MS = 10_000;
|
|
195
|
+
|
|
196
|
+
/* ------------------------------------------------------------------ */
|
|
197
|
+
/* Adapter */
|
|
198
|
+
/* ------------------------------------------------------------------ */
|
|
199
|
+
|
|
200
|
+
export class OpenCodeServerAdapter implements BridgeAdapter {
|
|
201
|
+
private readonly options: AdapterOptions;
|
|
202
|
+
private readonly state: BridgeAdapterState;
|
|
203
|
+
private eventSink: EventSink = () => undefined;
|
|
204
|
+
|
|
205
|
+
private serverProcess: ChildProcess | null = null;
|
|
206
|
+
private nativeProcess: ChildProcess | null = null;
|
|
207
|
+
private serverPort = 0;
|
|
208
|
+
private client: OpenCodeSdkClient | null = null;
|
|
209
|
+
private sseAbortController: AbortController | null = null;
|
|
210
|
+
private sseLoopPromise: Promise<void> | null = null;
|
|
211
|
+
private activeSessionId: string | null = null;
|
|
212
|
+
private activeWorkspaceId: string | null = null;
|
|
213
|
+
private outputBatcher: OutputBatcher;
|
|
214
|
+
private shuttingDown = false;
|
|
215
|
+
private hasAcceptedInput = false;
|
|
216
|
+
private currentPreview = "(idle)";
|
|
217
|
+
private workingNoticeDelayMs: number;
|
|
218
|
+
private workingNoticeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
219
|
+
private workingNoticeSent = false;
|
|
220
|
+
private lastBusyAtMs = 0;
|
|
221
|
+
private pendingLocalPrompt = "";
|
|
222
|
+
private localPromptNoticeSent = false;
|
|
223
|
+
private readonly loggedUnknownEventTypes = new Set<string>();
|
|
224
|
+
private readonly emittedTextByPartId = new Map<string, string>();
|
|
225
|
+
private readonly observedOpenCodeMessages = new Map<string, ObservedOpenCodeMessage>();
|
|
226
|
+
private readonly observedUserTextByPartId = new Map<string, string>();
|
|
227
|
+
private readonly observedUserMessagePartIds = new Map<string, Set<string>>();
|
|
228
|
+
private readonly pendingWechatPromptMirrorSuppressions: PendingWechatPromptMirrorSuppression[] = [];
|
|
229
|
+
private readonly recentSdkEventObservations = new Map<
|
|
230
|
+
string,
|
|
231
|
+
{ streamName: SdkEventStreamName; observedAtMs: number }
|
|
232
|
+
>();
|
|
233
|
+
private suppressedTuiSessionSelectId: string | null = null;
|
|
234
|
+
private lastMirroredLocalPrompt: { text: string; createdAtMs: number } | null = null;
|
|
235
|
+
|
|
236
|
+
private pendingPermission: OpenCodePendingPermission | null = null;
|
|
237
|
+
|
|
238
|
+
constructor(options: AdapterOptions) {
|
|
239
|
+
this.options = options;
|
|
240
|
+
this.state = {
|
|
241
|
+
kind: options.kind,
|
|
242
|
+
status: "stopped",
|
|
243
|
+
cwd: options.cwd,
|
|
244
|
+
command: options.command,
|
|
245
|
+
profile: options.profile,
|
|
246
|
+
};
|
|
247
|
+
this.outputBatcher = new OutputBatcher((text) =>
|
|
248
|
+
this.flushOutputBatch(text),
|
|
249
|
+
);
|
|
250
|
+
this.workingNoticeDelayMs = OPENCODE_WECHAT_WORKING_NOTICE_DELAY_MS;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* ---- BridgeAdapter interface ---- */
|
|
254
|
+
|
|
255
|
+
setEventSink(sink: EventSink): void {
|
|
256
|
+
this.eventSink = sink;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
getState(): BridgeAdapterState {
|
|
260
|
+
return JSON.parse(JSON.stringify(this.state)) as BridgeAdapterState;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async start(): Promise<void> {
|
|
264
|
+
if (this.serverProcess || this.nativeProcess) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
this.shuttingDown = false;
|
|
269
|
+
this.setStatus("starting", "Starting OpenCode companion...");
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
this.serverPort = await reserveLocalPort();
|
|
273
|
+
await this.startServerProcess();
|
|
274
|
+
|
|
275
|
+
await waitForTcpPort(
|
|
276
|
+
OPENCODE_SERVER_HOST,
|
|
277
|
+
this.serverPort,
|
|
278
|
+
OPENCODE_SERVER_READY_TIMEOUT_MS,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
await this.createSdkClient();
|
|
282
|
+
await this.checkHealth();
|
|
283
|
+
await this.initializeSessions();
|
|
284
|
+
this.startSseListener();
|
|
285
|
+
if (this.options.renderMode === "companion") {
|
|
286
|
+
await this.startNativeClient();
|
|
287
|
+
await this.syncVisibleSessionToShared({ force: true });
|
|
288
|
+
} else {
|
|
289
|
+
this.state.pid = this.serverProcess?.pid;
|
|
290
|
+
this.state.startedAt = nowIso();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
this.setStatus("idle", "OpenCode adapter is ready.");
|
|
294
|
+
} catch (err) {
|
|
295
|
+
this.state.status = "error";
|
|
296
|
+
this.emit({
|
|
297
|
+
type: "fatal_error",
|
|
298
|
+
message: `Failed to start OpenCode: ${describeUnknownError(err)}`,
|
|
299
|
+
timestamp: nowIso(),
|
|
300
|
+
});
|
|
301
|
+
await this.dispose();
|
|
302
|
+
throw err;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async sendInput(text: string): Promise<void> {
|
|
307
|
+
if (!this.client) {
|
|
308
|
+
throw new Error("OpenCode adapter is not running.");
|
|
309
|
+
}
|
|
310
|
+
if (this.state.status === "busy") {
|
|
311
|
+
throw new Error("OpenCode is still working. Wait for the current reply or use /stop.");
|
|
312
|
+
}
|
|
313
|
+
if (this.pendingPermission) {
|
|
314
|
+
throw new Error("An OpenCode approval request is pending. Reply with /confirm <code> or /deny.");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const normalized = normalizeOutput(text).trim();
|
|
318
|
+
if (!normalized) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
this.outputBatcher.clear();
|
|
323
|
+
this.clearStreamedPartState();
|
|
324
|
+
|
|
325
|
+
const session = await this.ensureSession();
|
|
326
|
+
this.switchSharedSession(session, {
|
|
327
|
+
source: "wechat",
|
|
328
|
+
reason: "wechat_resume",
|
|
329
|
+
syncVisible: true,
|
|
330
|
+
forceVisibleSync: true,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
const result = await this.client.session.promptAsync({
|
|
335
|
+
sessionID: session.id,
|
|
336
|
+
directory: this.options.cwd,
|
|
337
|
+
workspace: session.workspaceID ?? this.activeWorkspaceId ?? undefined,
|
|
338
|
+
parts: [{ type: "text", text: normalized }],
|
|
339
|
+
});
|
|
340
|
+
if (result.error !== undefined) {
|
|
341
|
+
throw new Error(`SDK error: ${describeUnknownError(result.error)}`);
|
|
342
|
+
}
|
|
343
|
+
} catch (err) {
|
|
344
|
+
throw new Error(`Failed to send prompt: ${describeUnknownError(err)}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
this.recordPendingWechatPromptMirrorSuppression(session.id, normalized);
|
|
348
|
+
this.beginTrackedTurn(normalized, "wechat");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async listResumeSessions(limit = 10): Promise<BridgeResumeSessionCandidate[]> {
|
|
352
|
+
void limit;
|
|
353
|
+
throw new Error(
|
|
354
|
+
'WeChat /resume is disabled in opencode mode. Use /session directly inside "wechat-opencode"; WeChat will follow the active local session.',
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async resumeSession(sessionId: string): Promise<void> {
|
|
359
|
+
void sessionId;
|
|
360
|
+
throw new Error(
|
|
361
|
+
'WeChat /resume is disabled in opencode mode. Use /session directly inside "wechat-opencode"; WeChat will follow the active local session.',
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async interrupt(): Promise<boolean> {
|
|
366
|
+
if (!this.client || !this.activeSessionId) {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
if (this.state.status !== "busy" && this.state.status !== "awaiting_approval") {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
this.clearWechatWorkingNotice(true);
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
await this.client.session.abort({
|
|
377
|
+
sessionID: this.activeSessionId,
|
|
378
|
+
directory: this.options.cwd,
|
|
379
|
+
workspace: this.activeWorkspaceId ?? undefined,
|
|
380
|
+
});
|
|
381
|
+
} catch {
|
|
382
|
+
// Best effort abort.
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async reset(): Promise<void> {
|
|
389
|
+
this.clearWechatWorkingNotice(true);
|
|
390
|
+
this.pendingLocalPrompt = "";
|
|
391
|
+
this.localPromptNoticeSent = false;
|
|
392
|
+
this.clearObservedMessageTracking();
|
|
393
|
+
this.recentSdkEventObservations.clear();
|
|
394
|
+
this.clearPendingPermissionState();
|
|
395
|
+
this.activeSessionId = null;
|
|
396
|
+
this.state.sharedSessionId = undefined;
|
|
397
|
+
this.state.sharedThreadId = undefined;
|
|
398
|
+
this.state.activeRuntimeSessionId = undefined;
|
|
399
|
+
this.state.lastSessionSwitchAt = undefined;
|
|
400
|
+
this.state.lastSessionSwitchSource = undefined;
|
|
401
|
+
this.state.lastSessionSwitchReason = undefined;
|
|
402
|
+
this.hasAcceptedInput = false;
|
|
403
|
+
this.currentPreview = "(idle)";
|
|
404
|
+
this.outputBatcher.clear();
|
|
405
|
+
this.clearStreamedPartState();
|
|
406
|
+
await this.dispose();
|
|
407
|
+
await this.start();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async resolveApproval(action: "confirm" | "deny"): Promise<boolean> {
|
|
411
|
+
if (!this.pendingPermission || !this.client) {
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const { sessionId, permissionId } = this.pendingPermission;
|
|
416
|
+
const response = action === "confirm" ? "once" : "reject";
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
const result = await this.client.permission.respond({
|
|
420
|
+
sessionID: sessionId,
|
|
421
|
+
permissionID: permissionId,
|
|
422
|
+
directory: this.options.cwd,
|
|
423
|
+
workspace: this.activeWorkspaceId ?? undefined,
|
|
424
|
+
response,
|
|
425
|
+
});
|
|
426
|
+
if (result.error !== undefined) {
|
|
427
|
+
throw new Error(`SDK error: ${describeUnknownError(result.error)}`);
|
|
428
|
+
}
|
|
429
|
+
} catch (err) {
|
|
430
|
+
this.emit({
|
|
431
|
+
type: "stderr",
|
|
432
|
+
text: `Failed to resolve permission: ${describeUnknownError(err)}`,
|
|
433
|
+
timestamp: nowIso(),
|
|
434
|
+
});
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
this.clearWechatWorkingNotice();
|
|
439
|
+
this.clearPendingPermissionState();
|
|
440
|
+
this.setStatus("busy");
|
|
441
|
+
return true;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async submitUserInput(_answers: Record<string, string[]>): Promise<boolean> {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async dispose(): Promise<void> {
|
|
449
|
+
this.shuttingDown = true;
|
|
450
|
+
this.clearWechatWorkingNotice(true);
|
|
451
|
+
this.pendingLocalPrompt = "";
|
|
452
|
+
this.localPromptNoticeSent = false;
|
|
453
|
+
this.clearObservedMessageTracking();
|
|
454
|
+
this.recentSdkEventObservations.clear();
|
|
455
|
+
this.outputBatcher.clear();
|
|
456
|
+
this.clearStreamedPartState();
|
|
457
|
+
|
|
458
|
+
this.clearPendingPermissionState();
|
|
459
|
+
|
|
460
|
+
// Stop SSE listener
|
|
461
|
+
if (this.sseAbortController) {
|
|
462
|
+
this.sseAbortController.abort();
|
|
463
|
+
this.sseAbortController = null;
|
|
464
|
+
}
|
|
465
|
+
if (this.sseLoopPromise) {
|
|
466
|
+
try {
|
|
467
|
+
await Promise.race([this.sseLoopPromise, delay(3_000)]);
|
|
468
|
+
} catch {
|
|
469
|
+
// Ignore SSE loop errors during shutdown.
|
|
470
|
+
}
|
|
471
|
+
this.sseLoopPromise = null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (this.nativeProcess) {
|
|
475
|
+
const proc = this.nativeProcess;
|
|
476
|
+
this.nativeProcess = null;
|
|
477
|
+
try {
|
|
478
|
+
killProcessTreeSync(proc.pid!);
|
|
479
|
+
} catch {
|
|
480
|
+
// Best effort.
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (this.serverProcess) {
|
|
485
|
+
const proc = this.serverProcess;
|
|
486
|
+
this.serverProcess = null;
|
|
487
|
+
try {
|
|
488
|
+
killProcessTreeSync(proc.pid!);
|
|
489
|
+
} catch {
|
|
490
|
+
// Best effort.
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
this.client = null;
|
|
495
|
+
this.activeSessionId = null;
|
|
496
|
+
this.activeWorkspaceId = null;
|
|
497
|
+
this.suppressedTuiSessionSelectId = null;
|
|
498
|
+
this.state.status = "stopped";
|
|
499
|
+
this.state.pid = undefined;
|
|
500
|
+
this.state.startedAt = undefined;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/* ---- Server management ---- */
|
|
504
|
+
|
|
505
|
+
private async startServerProcess(): Promise<void> {
|
|
506
|
+
const env = buildCliEnvironment(this.options.kind);
|
|
507
|
+
const serverArgs = [
|
|
508
|
+
"serve",
|
|
509
|
+
"--port",
|
|
510
|
+
String(this.serverPort),
|
|
511
|
+
"--hostname",
|
|
512
|
+
OPENCODE_SERVER_HOST,
|
|
513
|
+
];
|
|
514
|
+
|
|
515
|
+
const target = resolveSpawnTarget(this.options.command, this.options.kind, { env });
|
|
516
|
+
this.serverProcess = spawnChildProcess(target.file, [...target.args, ...serverArgs], {
|
|
517
|
+
cwd: this.options.cwd,
|
|
518
|
+
env,
|
|
519
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
520
|
+
windowsHide: true,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const server = this.serverProcess;
|
|
524
|
+
|
|
525
|
+
server.stdout?.on("data", (chunk: Buffer) => {
|
|
526
|
+
const text = chunk.toString("utf8").trim();
|
|
527
|
+
if (text) {
|
|
528
|
+
this.logDebug(`[opencode-serve:out] ${text}`);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
server.stderr?.on("data", (chunk: Buffer) => {
|
|
533
|
+
const text = chunk.toString("utf8").trim();
|
|
534
|
+
if (text) {
|
|
535
|
+
this.logDebug(`[opencode-serve:err] ${text}`);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
server.once("exit", (code) => {
|
|
540
|
+
if (this.shuttingDown) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
this.emit({
|
|
544
|
+
type: "fatal_error",
|
|
545
|
+
message: `OpenCode server exited unexpectedly (code ${code ?? "unknown"}).`,
|
|
546
|
+
timestamp: nowIso(),
|
|
547
|
+
});
|
|
548
|
+
this.setStatus("stopped");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
server.once("error", (err) => {
|
|
552
|
+
if (this.shuttingDown) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
this.emit({
|
|
556
|
+
type: "fatal_error",
|
|
557
|
+
message: `OpenCode server error: ${err.message}`,
|
|
558
|
+
timestamp: nowIso(),
|
|
559
|
+
});
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private async startNativeClient(): Promise<void> {
|
|
564
|
+
if (this.nativeProcess) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const env = buildCliEnvironment(this.options.kind);
|
|
569
|
+
const attachArgs = await this.buildNativeAttachArgs();
|
|
570
|
+
const target = resolveSpawnTarget(this.options.command, this.options.kind, { env });
|
|
571
|
+
const startedAt = nowIso();
|
|
572
|
+
const child = spawnChildProcess(target.file, [...target.args, ...attachArgs], {
|
|
573
|
+
cwd: this.options.cwd,
|
|
574
|
+
env,
|
|
575
|
+
stdio: "inherit",
|
|
576
|
+
windowsHide: false,
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
this.nativeProcess = child;
|
|
580
|
+
this.state.pid = child.pid ?? process.pid;
|
|
581
|
+
this.state.startedAt = startedAt;
|
|
582
|
+
|
|
583
|
+
child.once("error", (err) => {
|
|
584
|
+
if (this.shuttingDown) {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
this.emit({
|
|
588
|
+
type: "fatal_error",
|
|
589
|
+
message: `Failed to start OpenCode companion: ${describeUnknownError(err)}`,
|
|
590
|
+
timestamp: nowIso(),
|
|
591
|
+
});
|
|
592
|
+
this.setStatus("error", "OpenCode companion failed to start.");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
child.once("exit", (code, signal) => {
|
|
596
|
+
if (this.nativeProcess === child) {
|
|
597
|
+
this.nativeProcess = null;
|
|
598
|
+
}
|
|
599
|
+
if (this.shuttingDown) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
this.state.pid = undefined;
|
|
604
|
+
this.setStatus("stopped", "OpenCode companion exited.");
|
|
605
|
+
const detail = signal ? `signal ${signal}` : `code ${code ?? "unknown"}`;
|
|
606
|
+
this.emit({
|
|
607
|
+
type: "shutdown_requested",
|
|
608
|
+
reason: "companion_disconnected",
|
|
609
|
+
message: `OpenCode companion exited (${detail}).`,
|
|
610
|
+
exitCode: typeof code === "number" ? code : 0,
|
|
611
|
+
timestamp: nowIso(),
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private async buildNativeAttachArgs(): Promise<string[]> {
|
|
617
|
+
const args = ["attach", this.getServerUrl()];
|
|
618
|
+
const sessionId = this.activeSessionId;
|
|
619
|
+
if (sessionId && (await this.hasSession(sessionId))) {
|
|
620
|
+
args.push("--session", sessionId);
|
|
621
|
+
}
|
|
622
|
+
args.push(...(this.options.extraCliArgs ?? []));
|
|
623
|
+
return args;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private async createSdkClient(): Promise<void> {
|
|
627
|
+
try {
|
|
628
|
+
const { createOpencodeClient } = await import("@opencode-ai/sdk/v2");
|
|
629
|
+
this.client = createOpencodeClient({
|
|
630
|
+
baseUrl: `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`,
|
|
631
|
+
directory: this.options.cwd,
|
|
632
|
+
experimental_workspaceID: this.activeWorkspaceId ?? undefined,
|
|
633
|
+
}) as unknown as OpenCodeSdkClient;
|
|
634
|
+
} catch (err) {
|
|
635
|
+
throw new Error(
|
|
636
|
+
`Failed to load @opencode-ai/sdk. Make sure it is installed: ${describeUnknownError(err)}`,
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
private async checkHealth(): Promise<void> {
|
|
642
|
+
const baseUrl = `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`;
|
|
643
|
+
const response = await fetch(`${baseUrl}/session/status`);
|
|
644
|
+
if (!response.ok) {
|
|
645
|
+
throw new Error(`OpenCode health check failed (HTTP ${response.status}).`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
private async initializeSessions(): Promise<void> {
|
|
650
|
+
if (!this.client) {
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
let listedSessions: SdkSession[] = [];
|
|
655
|
+
|
|
656
|
+
try {
|
|
657
|
+
const result = await this.client.session.list();
|
|
658
|
+
if (result.data && result.data.length > 0) {
|
|
659
|
+
listedSessions = result.data.filter((session) => this.isCurrentDirectorySession(session));
|
|
660
|
+
const latest = listedSessions[0];
|
|
661
|
+
if (latest) {
|
|
662
|
+
this.switchSharedSession(latest, {
|
|
663
|
+
source: "restore",
|
|
664
|
+
reason: "startup_restore",
|
|
665
|
+
syncVisible: false,
|
|
666
|
+
});
|
|
667
|
+
this.state.lastSessionSwitchAt = undefined;
|
|
668
|
+
this.state.lastSessionSwitchSource = undefined;
|
|
669
|
+
this.state.lastSessionSwitchReason = undefined;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
} catch {
|
|
673
|
+
// Session listing is optional at startup.
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (this.options.initialSharedSessionId) {
|
|
677
|
+
const restoredSessionId = this.options.initialSharedSessionId;
|
|
678
|
+
const restoredSession = await this.getSessionForCurrentDirectory(restoredSessionId, listedSessions);
|
|
679
|
+
if (!restoredSession) {
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
this.switchSharedSession(restoredSession, {
|
|
684
|
+
source: "restore",
|
|
685
|
+
reason: "startup_restore",
|
|
686
|
+
syncVisible: false,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private async hasSession(
|
|
692
|
+
sessionId: string,
|
|
693
|
+
listedSessions: SdkSession[] = [],
|
|
694
|
+
): Promise<boolean> {
|
|
695
|
+
return Boolean(await this.getSessionForCurrentDirectory(sessionId, listedSessions));
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
private async getSessionForCurrentDirectory(
|
|
699
|
+
sessionId: string,
|
|
700
|
+
listedSessions: SdkSession[] = [],
|
|
701
|
+
): Promise<SdkSession | null> {
|
|
702
|
+
if (!this.client) {
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const listedSession = listedSessions.find((session) => session.id === sessionId);
|
|
707
|
+
if (listedSession && this.isCurrentDirectorySession(listedSession)) {
|
|
708
|
+
return listedSession;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const queryVariants = this.activeWorkspaceId
|
|
712
|
+
? [
|
|
713
|
+
{
|
|
714
|
+
sessionID: sessionId,
|
|
715
|
+
directory: this.options.cwd,
|
|
716
|
+
workspace: this.activeWorkspaceId,
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
sessionID: sessionId,
|
|
720
|
+
directory: this.options.cwd,
|
|
721
|
+
},
|
|
722
|
+
]
|
|
723
|
+
: [
|
|
724
|
+
{
|
|
725
|
+
sessionID: sessionId,
|
|
726
|
+
directory: this.options.cwd,
|
|
727
|
+
},
|
|
728
|
+
];
|
|
729
|
+
|
|
730
|
+
for (const query of queryVariants) {
|
|
731
|
+
try {
|
|
732
|
+
const result = await this.client.session.get(query);
|
|
733
|
+
if (result.error !== undefined || !result.data) {
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
if (!this.isCurrentDirectorySession(result.data)) {
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
return result.data;
|
|
740
|
+
} catch {
|
|
741
|
+
// Try the next query variant.
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/* ---- SSE event handling ---- */
|
|
749
|
+
|
|
750
|
+
private startSseListener(): void {
|
|
751
|
+
if (!this.client || this.sseLoopPromise) {
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
this.sseAbortController = new AbortController();
|
|
756
|
+
this.sseLoopPromise = Promise.all([
|
|
757
|
+
this.runSseLoop("event"),
|
|
758
|
+
this.runSseLoop("global-event"),
|
|
759
|
+
this.runSseLoop("global-sync"),
|
|
760
|
+
]).then(() => undefined);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
private async runSseLoop(streamName: SdkEventStreamName): Promise<void> {
|
|
764
|
+
while (!this.shuttingDown) {
|
|
765
|
+
try {
|
|
766
|
+
const subscription = await this.subscribeToSseStream(streamName);
|
|
767
|
+
const stream = subscription.stream;
|
|
768
|
+
|
|
769
|
+
for await (const rawEvent of stream) {
|
|
770
|
+
if (this.shuttingDown) {
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
const event = this.normalizeSdkEvent(rawEvent);
|
|
774
|
+
if (!event || !this.shouldHandleSseEvent(event, streamName)) {
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (this.shouldSkipDuplicateSdkEvent(event, streamName)) {
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
this.handleSseEvent(event);
|
|
781
|
+
}
|
|
782
|
+
} catch (err) {
|
|
783
|
+
if (this.shuttingDown) {
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
this.logDebug(
|
|
787
|
+
`[opencode-adapter:${streamName}] Stream error: ${describeUnknownError(err)}`,
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (this.shuttingDown) {
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
await delay(OPENCODE_SSE_RECONNECT_DELAY_MS);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
private subscribeToSseStream(streamName: SdkEventStreamName): Promise<SdkEventSubscription> {
|
|
800
|
+
const signal = this.sseAbortController?.signal;
|
|
801
|
+
if (streamName === "global-sync") {
|
|
802
|
+
return this.client!.global.syncEvent.subscribe({ signal });
|
|
803
|
+
}
|
|
804
|
+
if (streamName === "global-event") {
|
|
805
|
+
return this.client!.global.event({ signal });
|
|
806
|
+
}
|
|
807
|
+
return this.client!.event.subscribe(
|
|
808
|
+
{
|
|
809
|
+
directory: this.options.cwd,
|
|
810
|
+
workspace: this.activeWorkspaceId ?? undefined,
|
|
811
|
+
},
|
|
812
|
+
{ signal },
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
private normalizeSdkEvent(rawEvent: unknown): NormalizedSdkEvent | null {
|
|
817
|
+
if (!isRecord(rawEvent)) {
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (typeof rawEvent.type === "string") {
|
|
822
|
+
return rawEvent as NormalizedSdkEvent;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const payload = rawEvent.payload;
|
|
826
|
+
if (isRecord(payload) && typeof payload.type === "string") {
|
|
827
|
+
const normalized = { ...payload } as NormalizedSdkEvent;
|
|
828
|
+
if (typeof rawEvent.directory === "string") {
|
|
829
|
+
normalized.directory = rawEvent.directory;
|
|
830
|
+
}
|
|
831
|
+
return normalized;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
private shouldHandleSseEvent(
|
|
838
|
+
event: NormalizedSdkEvent,
|
|
839
|
+
streamName: SdkEventStreamName,
|
|
840
|
+
): boolean {
|
|
841
|
+
if (streamName === "event") {
|
|
842
|
+
return true;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const type = this.normalizeEventType(event.type);
|
|
846
|
+
if (streamName === "global-event") {
|
|
847
|
+
if (
|
|
848
|
+
type !== "tui.prompt.append" &&
|
|
849
|
+
type !== "tui.command.execute" &&
|
|
850
|
+
type !== "tui.session.select" &&
|
|
851
|
+
type !== "command.executed" &&
|
|
852
|
+
type !== "session.created" &&
|
|
853
|
+
type !== "session.updated" &&
|
|
854
|
+
type !== "session.deleted"
|
|
855
|
+
) {
|
|
856
|
+
return false;
|
|
857
|
+
}
|
|
858
|
+
if (this.isImplicitLocalCompanionUiEvent(type)) {
|
|
859
|
+
return true;
|
|
860
|
+
}
|
|
861
|
+
return this.matchesCurrentDirectoryEvent(event);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (
|
|
865
|
+
type === "session.created" ||
|
|
866
|
+
type === "session.updated" ||
|
|
867
|
+
type === "session.deleted"
|
|
868
|
+
) {
|
|
869
|
+
return this.matchesCurrentDirectoryEvent(event);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
return false;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
private isImplicitLocalCompanionUiEvent(type: string): boolean {
|
|
876
|
+
if (this.options.renderMode !== "companion") {
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return (
|
|
881
|
+
type === "tui.prompt.append" ||
|
|
882
|
+
type === "tui.command.execute" ||
|
|
883
|
+
type === "tui.session.select"
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
private handleSseEvent(event: SdkEvent): void {
|
|
888
|
+
const type = this.normalizeEventType(event.type);
|
|
889
|
+
const payload = this.extractEventPayload(event);
|
|
890
|
+
|
|
891
|
+
if (type === "message.updated") {
|
|
892
|
+
this.handleMessageUpdated(payload);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
switch (type) {
|
|
897
|
+
case "server.connected":
|
|
898
|
+
case "server.heartbeat":
|
|
899
|
+
return;
|
|
900
|
+
|
|
901
|
+
case "session.idle": {
|
|
902
|
+
this.handleSessionIdle(isRecord(payload) ? payload : undefined);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
case "session.status": {
|
|
907
|
+
this.handleSessionStatus(isRecord(payload) ? payload : undefined);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
case "session.error": {
|
|
912
|
+
this.handleSessionError(isRecord(payload) ? payload : undefined);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
case "permission.updated":
|
|
917
|
+
case "permission.asked": {
|
|
918
|
+
this.handlePermissionRequest(payload);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
case "session.created": {
|
|
923
|
+
this.handleSessionCreated(payload);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
case "session.updated": {
|
|
928
|
+
this.handleSessionUpdated(payload);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
case "message.updated": {
|
|
933
|
+
// Full message update — not used for incremental text extraction.
|
|
934
|
+
// Text output comes from message.part.updated events.
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
case "message.part.updated": {
|
|
939
|
+
this.handleMessagePartUpdated(payload);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
case "message.part.delta": {
|
|
944
|
+
this.handleMessagePartDelta(payload);
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
case "message.part.removed": {
|
|
949
|
+
this.handleMessagePartRemoved(payload);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
case "tui.prompt.append": {
|
|
954
|
+
this.handleTuiPromptAppend(payload);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
case "tui.command.execute": {
|
|
959
|
+
this.handleTuiCommandExecute(payload);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
case "tui.session.select": {
|
|
964
|
+
this.handleTuiSessionSelect(payload);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
case "command.executed": {
|
|
969
|
+
this.handleCommandExecuted(payload);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
case "session.diff":
|
|
974
|
+
case "session.diff.delta":
|
|
975
|
+
case "session.deleted":
|
|
976
|
+
case "message.removed":
|
|
977
|
+
case "permission.replied":
|
|
978
|
+
case "tui.toast.show":
|
|
979
|
+
return;
|
|
980
|
+
|
|
981
|
+
default:
|
|
982
|
+
this.logUnknownEvent(type);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
private handleSessionIdle(properties: Record<string, unknown> | undefined): void {
|
|
988
|
+
if (!isRecord(properties)) {
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const sessionId = this.extractSessionId(properties) ?? this.activeSessionId;
|
|
993
|
+
if (!this.syncTrackedSessionFromEvent(sessionId, { allowLocalTurnFollow: false })) {
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
if (this.state.status !== "busy" && this.state.status !== "awaiting_approval") {
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Wait a short settle time before emitting task_complete,
|
|
1002
|
+
// in case more events follow the idle signal.
|
|
1003
|
+
setTimeout(() => {
|
|
1004
|
+
if (this.state.status !== "busy" && this.state.status !== "awaiting_approval") {
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
this.clearWechatWorkingNotice(true);
|
|
1009
|
+
this.pendingLocalPrompt = "";
|
|
1010
|
+
this.clearPendingPermissionState();
|
|
1011
|
+
this.state.activeTurnOrigin = undefined;
|
|
1012
|
+
this.hasAcceptedInput = false;
|
|
1013
|
+
const completedPreview = this.currentPreview;
|
|
1014
|
+
|
|
1015
|
+
void this.outputBatcher.flushNow()
|
|
1016
|
+
.catch(() => undefined)
|
|
1017
|
+
.then(() => {
|
|
1018
|
+
const summary = this.outputBatcher.getRecentSummary(500);
|
|
1019
|
+
this.setStatus("idle");
|
|
1020
|
+
if (summary && summary !== "(no output)") {
|
|
1021
|
+
this.emit({
|
|
1022
|
+
type: "final_reply",
|
|
1023
|
+
text: summary,
|
|
1024
|
+
timestamp: nowIso(),
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
this.emit({
|
|
1029
|
+
type: "task_complete",
|
|
1030
|
+
summary: completedPreview,
|
|
1031
|
+
timestamp: nowIso(),
|
|
1032
|
+
});
|
|
1033
|
+
this.currentPreview = "(idle)";
|
|
1034
|
+
this.outputBatcher.clear();
|
|
1035
|
+
this.clearStreamedPartState();
|
|
1036
|
+
});
|
|
1037
|
+
}, OPENCODE_SESSION_IDLE_SETTLE_MS).unref?.();
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
private handleSessionStatus(properties: Record<string, unknown> | undefined): void {
|
|
1041
|
+
if (!isRecord(properties)) {
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const sessionId = this.extractSessionId(properties);
|
|
1046
|
+
if (sessionId && !this.syncTrackedSessionFromEvent(sessionId)) {
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// properties: { sessionID: string, status: { type: "busy" | "idle" | ... } }
|
|
1051
|
+
const status = properties.status;
|
|
1052
|
+
if (!isRecord(status)) {
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const statusType = typeof status.type === "string" ? status.type : undefined;
|
|
1057
|
+
if (!statusType) {
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (statusType === "busy" || statusType === "running") {
|
|
1062
|
+
if (this.state.status === "idle") {
|
|
1063
|
+
this.outputBatcher.clear();
|
|
1064
|
+
this.clearStreamedPartState();
|
|
1065
|
+
this.lastBusyAtMs = Date.now();
|
|
1066
|
+
this.setStatus(
|
|
1067
|
+
"busy",
|
|
1068
|
+
this.state.activeTurnOrigin === "local"
|
|
1069
|
+
? "OpenCode is busy with a local terminal turn."
|
|
1070
|
+
: undefined,
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
private handlePermissionRequest(properties: unknown): void {
|
|
1077
|
+
if (!isRecord(properties) || !this.client) {
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const sessionId = this.extractSessionId(properties);
|
|
1082
|
+
if (sessionId && !this.syncTrackedSessionFromEvent(sessionId)) {
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const pendingPermission = this.buildPendingPermission(properties);
|
|
1087
|
+
if (!pendingPermission) {
|
|
1088
|
+
return;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
this.clearWechatWorkingNotice();
|
|
1092
|
+
const approval = this.toPendingApproval(pendingPermission);
|
|
1093
|
+
this.pendingPermission = pendingPermission;
|
|
1094
|
+
this.state.pendingApproval = approval;
|
|
1095
|
+
this.state.pendingApprovalOrigin = this.state.activeTurnOrigin;
|
|
1096
|
+
this.setStatus("awaiting_approval", "OpenCode approval is required.");
|
|
1097
|
+
this.emit({
|
|
1098
|
+
type: "approval_required",
|
|
1099
|
+
request: approval,
|
|
1100
|
+
timestamp: nowIso(),
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
private clearPendingPermissionState(): void {
|
|
1105
|
+
this.pendingPermission = null;
|
|
1106
|
+
this.state.pendingApproval = null;
|
|
1107
|
+
this.state.pendingApprovalOrigin = undefined;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
private toPendingApproval(pendingPermission: OpenCodePendingPermission): PendingApproval {
|
|
1111
|
+
return {
|
|
1112
|
+
...pendingPermission.request,
|
|
1113
|
+
code: pendingPermission.code,
|
|
1114
|
+
createdAt: pendingPermission.createdAt,
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
private buildPendingPermission(
|
|
1119
|
+
properties: Record<string, unknown>,
|
|
1120
|
+
): OpenCodePendingPermission | null {
|
|
1121
|
+
const sessionId =
|
|
1122
|
+
typeof properties.sessionID === "string"
|
|
1123
|
+
? properties.sessionID
|
|
1124
|
+
: this.activeSessionId;
|
|
1125
|
+
const permissionId =
|
|
1126
|
+
typeof properties.id === "string"
|
|
1127
|
+
? properties.id
|
|
1128
|
+
: undefined;
|
|
1129
|
+
|
|
1130
|
+
if (!sessionId || !permissionId) {
|
|
1131
|
+
return null;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const toolName =
|
|
1135
|
+
typeof properties.type === "string"
|
|
1136
|
+
? properties.type
|
|
1137
|
+
: typeof properties.permission === "string"
|
|
1138
|
+
? properties.permission
|
|
1139
|
+
: undefined;
|
|
1140
|
+
const title =
|
|
1141
|
+
typeof properties.title === "string"
|
|
1142
|
+
? properties.title
|
|
1143
|
+
: typeof properties.permission === "string"
|
|
1144
|
+
? `Permission request: ${properties.permission}`
|
|
1145
|
+
: undefined;
|
|
1146
|
+
const metadata = isRecord(properties.metadata) ? properties.metadata : {};
|
|
1147
|
+
const command =
|
|
1148
|
+
typeof metadata.command === "string"
|
|
1149
|
+
? metadata.command
|
|
1150
|
+
: typeof metadata.detail === "string"
|
|
1151
|
+
? metadata.detail
|
|
1152
|
+
: Array.isArray(properties.patterns)
|
|
1153
|
+
? properties.patterns.filter((value): value is string => typeof value === "string").join(", ")
|
|
1154
|
+
: undefined;
|
|
1155
|
+
|
|
1156
|
+
return {
|
|
1157
|
+
sessionId,
|
|
1158
|
+
permissionId,
|
|
1159
|
+
code: buildOneTimeCode(),
|
|
1160
|
+
createdAt: nowIso(),
|
|
1161
|
+
request: {
|
|
1162
|
+
source: "cli",
|
|
1163
|
+
summary: title ?? `OpenCode needs approval${toolName ? ` for tool: ${toolName}` : ""}.`,
|
|
1164
|
+
commandPreview: truncatePreview(command ?? title ?? "Permission request", 180),
|
|
1165
|
+
toolName,
|
|
1166
|
+
detailPreview: typeof metadata.detail === "string" ? metadata.detail : undefined,
|
|
1167
|
+
detailLabel: typeof metadata.label === "string" ? metadata.label : undefined,
|
|
1168
|
+
confirmInput: undefined,
|
|
1169
|
+
denyInput: undefined,
|
|
1170
|
+
},
|
|
1171
|
+
};
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
private handleSessionCreated(properties: unknown): void {
|
|
1175
|
+
if (!isRecord(properties)) {
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const session = this.extractSessionReference(properties);
|
|
1180
|
+
if (!this.syncTrackedSessionFromEvent(session)) {
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
private handleSessionUpdated(properties: unknown): void {
|
|
1186
|
+
if (!isRecord(properties)) {
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const session = this.extractSessionReference(properties);
|
|
1191
|
+
this.syncTrackedSessionFromEvent(session, { allowLocalTurnFollow: false });
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
private handleMessageUpdated(properties: unknown): void {
|
|
1195
|
+
if (!isRecord(properties)) {
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const sessionId = this.extractSessionId(properties);
|
|
1200
|
+
if (sessionId && !this.syncTrackedSessionFromEvent(sessionId, { allowLocalTurnFollow: false })) {
|
|
1201
|
+
return;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
const info = isRecord(properties.info) ? properties.info : undefined;
|
|
1205
|
+
const messageId = typeof info?.id === "string" ? info.id : undefined;
|
|
1206
|
+
const role = info?.role === "user" || info?.role === "assistant" ? info.role : undefined;
|
|
1207
|
+
if (!messageId) {
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
const observed = this.getOrCreateObservedOpenCodeMessage(messageId, sessionId);
|
|
1212
|
+
observed.updatedAtMs = Date.now();
|
|
1213
|
+
observed.sessionId = sessionId ?? observed.sessionId;
|
|
1214
|
+
observed.role = role;
|
|
1215
|
+
|
|
1216
|
+
if (role === "assistant") {
|
|
1217
|
+
this.cleanupObservedOpenCodeMessage(messageId);
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
this.tryEmitObservedLocalUserMessage(messageId);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
private handleMessagePartUpdated(properties: unknown): void {
|
|
1225
|
+
if (!isRecord(properties)) {
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
const part = isRecord(properties.part) ? properties.part : undefined;
|
|
1230
|
+
if (this.isVisibleTextPart(part)) {
|
|
1231
|
+
this.trackObservedOpenCodeMessagePart({
|
|
1232
|
+
messageId: part.messageID,
|
|
1233
|
+
sessionId: part.sessionID,
|
|
1234
|
+
partId: part.id,
|
|
1235
|
+
snapshotText: typeof part.text === "string" ? part.text : undefined,
|
|
1236
|
+
deltaText: typeof properties.delta === "string" ? properties.delta : undefined,
|
|
1237
|
+
});
|
|
1238
|
+
if (this.observedOpenCodeMessages.get(part.messageID)?.role === "user") {
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
if (this.state.status !== "busy") {
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
if (!this.isVisibleTextPart(part)) {
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
if (!this.syncTrackedSessionFromEvent(part.sessionID)) {
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const partId = this.extractPartId(properties, part);
|
|
1256
|
+
if (!partId) {
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const partText =
|
|
1261
|
+
typeof part.text === "string"
|
|
1262
|
+
? part.text
|
|
1263
|
+
: undefined;
|
|
1264
|
+
const delta =
|
|
1265
|
+
typeof properties.delta === "string"
|
|
1266
|
+
? properties.delta
|
|
1267
|
+
: undefined;
|
|
1268
|
+
const text = partText
|
|
1269
|
+
? this.consumeVisiblePartSnapshot(partId, partText)
|
|
1270
|
+
: delta
|
|
1271
|
+
? this.consumeVisiblePartDelta(partId, delta)
|
|
1272
|
+
: "";
|
|
1273
|
+
this.pushVisibleOutput(text);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
private handleMessagePartDelta(properties: unknown): void {
|
|
1277
|
+
if (!isRecord(properties)) {
|
|
1278
|
+
return;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (this.state.status !== "busy") {
|
|
1282
|
+
const sessionIdForTrackedMessage =
|
|
1283
|
+
typeof properties.sessionID === "string" ? properties.sessionID : undefined;
|
|
1284
|
+
const messageIdForTrackedMessage =
|
|
1285
|
+
typeof properties.messageID === "string" ? properties.messageID : undefined;
|
|
1286
|
+
const partIdForTrackedMessage =
|
|
1287
|
+
typeof properties.partID === "string" ? properties.partID : undefined;
|
|
1288
|
+
const deltaForTrackedMessage =
|
|
1289
|
+
typeof properties.delta === "string" ? properties.delta : undefined;
|
|
1290
|
+
if (messageIdForTrackedMessage && partIdForTrackedMessage && deltaForTrackedMessage) {
|
|
1291
|
+
this.trackObservedOpenCodeMessagePart({
|
|
1292
|
+
messageId: messageIdForTrackedMessage,
|
|
1293
|
+
sessionId: sessionIdForTrackedMessage,
|
|
1294
|
+
partId: partIdForTrackedMessage,
|
|
1295
|
+
deltaText: deltaForTrackedMessage,
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
if (properties.field !== "text") {
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
const delta =
|
|
1306
|
+
typeof properties.delta === "string"
|
|
1307
|
+
? properties.delta
|
|
1308
|
+
: undefined;
|
|
1309
|
+
const sessionId =
|
|
1310
|
+
typeof properties.sessionID === "string"
|
|
1311
|
+
? properties.sessionID
|
|
1312
|
+
: undefined;
|
|
1313
|
+
const partId = this.extractPartId(properties);
|
|
1314
|
+
|
|
1315
|
+
if (typeof properties.messageID === "string" && partId && delta) {
|
|
1316
|
+
this.trackObservedOpenCodeMessagePart({
|
|
1317
|
+
messageId: properties.messageID,
|
|
1318
|
+
sessionId,
|
|
1319
|
+
partId,
|
|
1320
|
+
deltaText: delta,
|
|
1321
|
+
});
|
|
1322
|
+
if (this.observedOpenCodeMessages.get(properties.messageID)?.role === "user") {
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
if (!delta || !partId || !this.syncTrackedSessionFromEvent(sessionId)) {
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const text = this.consumeVisiblePartDelta(partId, delta);
|
|
1332
|
+
this.pushVisibleOutput(text);
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
private handleMessagePartRemoved(properties: unknown): void {
|
|
1336
|
+
if (!isRecord(properties)) {
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
const partId = this.extractPartId(properties);
|
|
1341
|
+
if (!partId) {
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
this.emittedTextByPartId.delete(partId);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
private handleTuiPromptAppend(properties: unknown): void {
|
|
1349
|
+
if (!isRecord(properties)) {
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const text = typeof properties.text === "string" ? properties.text : undefined;
|
|
1354
|
+
if (!text) {
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
this.pendingLocalPrompt += text;
|
|
1359
|
+
this.maybeNotifyLocalPromptDraftStarted();
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
private handleTuiCommandExecute(properties: unknown): void {
|
|
1363
|
+
if (!isRecord(properties)) {
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
const command = typeof properties.command === "string" ? properties.command : undefined;
|
|
1368
|
+
if (!command) {
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
switch (command) {
|
|
1373
|
+
case "prompt.clear":
|
|
1374
|
+
this.pendingLocalPrompt = "";
|
|
1375
|
+
this.localPromptNoticeSent = false;
|
|
1376
|
+
return;
|
|
1377
|
+
case "prompt.submit":
|
|
1378
|
+
this.handleLocalPromptSubmit();
|
|
1379
|
+
return;
|
|
1380
|
+
default:
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
private handleTuiSessionSelect(properties: unknown): void {
|
|
1386
|
+
if (!isRecord(properties)) {
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
const sessionId = typeof properties.sessionID === "string" ? properties.sessionID : undefined;
|
|
1391
|
+
if (!sessionId) {
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
if (sessionId === this.suppressedTuiSessionSelectId) {
|
|
1396
|
+
this.suppressedTuiSessionSelectId = null;
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
this.pendingLocalPrompt = "";
|
|
1401
|
+
this.localPromptNoticeSent = false;
|
|
1402
|
+
this.logDebug(`[opencode-adapter:local-follow] tui.session.select ${sessionId}`);
|
|
1403
|
+
this.switchSharedSession(
|
|
1404
|
+
{
|
|
1405
|
+
id: sessionId,
|
|
1406
|
+
workspaceID: this.extractWorkspaceId(properties) ?? undefined,
|
|
1407
|
+
},
|
|
1408
|
+
{
|
|
1409
|
+
source: "local",
|
|
1410
|
+
reason: "local_follow",
|
|
1411
|
+
notify: true,
|
|
1412
|
+
clearTrackedTurn: true,
|
|
1413
|
+
syncVisible: true,
|
|
1414
|
+
},
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
private handleCommandExecuted(properties: unknown): void {
|
|
1419
|
+
if (!isRecord(properties)) {
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const name = typeof properties.name === "string" ? properties.name : undefined;
|
|
1424
|
+
if (!this.isLocalSessionNavigationCommand(name)) {
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const sessionId = this.extractSessionId(properties);
|
|
1429
|
+
if (sessionId) {
|
|
1430
|
+
this.logDebug(`[opencode-adapter:local-follow] command.executed ${name} -> ${sessionId}`);
|
|
1431
|
+
this.switchSharedSession(
|
|
1432
|
+
{
|
|
1433
|
+
id: sessionId,
|
|
1434
|
+
workspaceID: this.extractWorkspaceId(properties) ?? undefined,
|
|
1435
|
+
},
|
|
1436
|
+
{
|
|
1437
|
+
source: "local",
|
|
1438
|
+
reason: "local_follow",
|
|
1439
|
+
notify: true,
|
|
1440
|
+
clearTrackedTurn: true,
|
|
1441
|
+
syncVisible: true,
|
|
1442
|
+
},
|
|
1443
|
+
);
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
private handleSessionError(properties: Record<string, unknown> | undefined): void {
|
|
1448
|
+
if (!isRecord(properties)) {
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
const error = isRecord(properties.error) ? properties.error : undefined;
|
|
1453
|
+
const errorName = typeof error?.name === "string" ? error.name : undefined;
|
|
1454
|
+
const message = this.describeSessionError(error);
|
|
1455
|
+
if (!message) {
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const sessionId = this.extractSessionId(properties);
|
|
1460
|
+
if (sessionId && !this.syncTrackedSessionFromEvent(sessionId, { allowLocalTurnFollow: false })) {
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
if (!this.hasTrackedTurnState()) {
|
|
1465
|
+
this.emit({
|
|
1466
|
+
type: "stderr",
|
|
1467
|
+
text: `OpenCode session error: ${message}`,
|
|
1468
|
+
timestamp: nowIso(),
|
|
1469
|
+
});
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
if (errorName === "MessageAbortedError") {
|
|
1474
|
+
this.settleTurnState();
|
|
1475
|
+
this.setStatus("idle");
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
this.failTrackedTurn(message);
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
private handleLocalPromptSubmit(): void {
|
|
1483
|
+
const prompt = normalizeOutput(this.pendingLocalPrompt).trim();
|
|
1484
|
+
this.pendingLocalPrompt = "";
|
|
1485
|
+
this.localPromptNoticeSent = false;
|
|
1486
|
+
if (!prompt) {
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
this.outputBatcher.clear();
|
|
1491
|
+
this.clearStreamedPartState();
|
|
1492
|
+
this.beginTrackedTurn(prompt, "local", {
|
|
1493
|
+
busyMessage: "OpenCode is busy with a local terminal turn.",
|
|
1494
|
+
emitMirroredUserInput: true,
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
private trackObservedOpenCodeMessagePart(params: {
|
|
1499
|
+
messageId: string;
|
|
1500
|
+
sessionId?: string;
|
|
1501
|
+
partId: string;
|
|
1502
|
+
snapshotText?: string;
|
|
1503
|
+
deltaText?: string;
|
|
1504
|
+
}): void {
|
|
1505
|
+
const observed = this.getOrCreateObservedOpenCodeMessage(params.messageId, params.sessionId);
|
|
1506
|
+
observed.updatedAtMs = Date.now();
|
|
1507
|
+
observed.sessionId = params.sessionId ?? observed.sessionId;
|
|
1508
|
+
|
|
1509
|
+
let chunk = "";
|
|
1510
|
+
if (typeof params.snapshotText === "string") {
|
|
1511
|
+
chunk = this.consumeObservedUserPartSnapshot(params.partId, params.snapshotText);
|
|
1512
|
+
} else if (typeof params.deltaText === "string") {
|
|
1513
|
+
chunk = this.consumeObservedUserPartDelta(params.partId, params.deltaText);
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
if (!chunk) {
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
let partIds = this.observedUserMessagePartIds.get(params.messageId);
|
|
1521
|
+
if (!partIds) {
|
|
1522
|
+
partIds = new Set<string>();
|
|
1523
|
+
this.observedUserMessagePartIds.set(params.messageId, partIds);
|
|
1524
|
+
}
|
|
1525
|
+
partIds.add(params.partId);
|
|
1526
|
+
observed.text = normalizeOutput(`${observed.text}${chunk}`);
|
|
1527
|
+
this.tryEmitObservedLocalUserMessage(params.messageId);
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
private getOrCreateObservedOpenCodeMessage(
|
|
1531
|
+
messageId: string,
|
|
1532
|
+
sessionId?: string,
|
|
1533
|
+
): ObservedOpenCodeMessage {
|
|
1534
|
+
const existing = this.observedOpenCodeMessages.get(messageId);
|
|
1535
|
+
if (existing) {
|
|
1536
|
+
if (sessionId) {
|
|
1537
|
+
existing.sessionId = sessionId;
|
|
1538
|
+
}
|
|
1539
|
+
return existing;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
const created: ObservedOpenCodeMessage = {
|
|
1543
|
+
sessionId: sessionId ?? this.activeSessionId ?? "",
|
|
1544
|
+
text: "",
|
|
1545
|
+
emitted: false,
|
|
1546
|
+
updatedAtMs: Date.now(),
|
|
1547
|
+
};
|
|
1548
|
+
this.observedOpenCodeMessages.set(messageId, created);
|
|
1549
|
+
return created;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
private tryEmitObservedLocalUserMessage(messageId: string): void {
|
|
1553
|
+
const observed = this.observedOpenCodeMessages.get(messageId);
|
|
1554
|
+
if (!observed || observed.role !== "user" || observed.emitted) {
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
const text = normalizeOutput(observed.text).trim();
|
|
1559
|
+
if (!text) {
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
observed.emitted = true;
|
|
1564
|
+
if (this.shouldSuppressWechatMirroredPrompt(observed.sessionId, text)) {
|
|
1565
|
+
this.cleanupObservedOpenCodeMessage(messageId);
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
if (this.wasRecentlyMirroredLocalPrompt(text)) {
|
|
1569
|
+
this.cleanupObservedOpenCodeMessage(messageId);
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
this.outputBatcher.clear();
|
|
1574
|
+
this.clearStreamedPartState();
|
|
1575
|
+
this.beginTrackedTurn(text, "local", {
|
|
1576
|
+
busyMessage: "OpenCode is busy with a local terminal turn.",
|
|
1577
|
+
emitMirroredUserInput: true,
|
|
1578
|
+
});
|
|
1579
|
+
this.cleanupObservedOpenCodeMessage(messageId);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
private recordPendingWechatPromptMirrorSuppression(sessionId: string, text: string): void {
|
|
1583
|
+
const normalizedText = normalizeOutput(text).trim();
|
|
1584
|
+
if (!sessionId || !normalizedText) {
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
const cutoff = Date.now() - OPENCODE_WECHAT_MIRROR_SUPPRESSION_TTL_MS;
|
|
1589
|
+
for (let index = this.pendingWechatPromptMirrorSuppressions.length - 1; index >= 0; index -= 1) {
|
|
1590
|
+
if (this.pendingWechatPromptMirrorSuppressions[index]!.createdAtMs < cutoff) {
|
|
1591
|
+
this.pendingWechatPromptMirrorSuppressions.splice(index, 1);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
this.pendingWechatPromptMirrorSuppressions.push({
|
|
1595
|
+
sessionId,
|
|
1596
|
+
text: normalizedText,
|
|
1597
|
+
createdAtMs: Date.now(),
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
private shouldSuppressWechatMirroredPrompt(sessionId: string, text: string): boolean {
|
|
1602
|
+
const normalizedText = normalizeOutput(text).trim();
|
|
1603
|
+
if (!sessionId || !normalizedText) {
|
|
1604
|
+
return false;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
const cutoff = Date.now() - OPENCODE_WECHAT_MIRROR_SUPPRESSION_TTL_MS;
|
|
1608
|
+
for (let index = this.pendingWechatPromptMirrorSuppressions.length - 1; index >= 0; index -= 1) {
|
|
1609
|
+
const pending = this.pendingWechatPromptMirrorSuppressions[index]!;
|
|
1610
|
+
if (pending.createdAtMs < cutoff) {
|
|
1611
|
+
this.pendingWechatPromptMirrorSuppressions.splice(index, 1);
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
if (pending.sessionId === sessionId && pending.text === normalizedText) {
|
|
1615
|
+
this.pendingWechatPromptMirrorSuppressions.splice(index, 1);
|
|
1616
|
+
return true;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
return false;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
private wasRecentlyMirroredLocalPrompt(text: string): boolean {
|
|
1624
|
+
const normalizedText = normalizeOutput(text).trim();
|
|
1625
|
+
if (!normalizedText) {
|
|
1626
|
+
return false;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
if (
|
|
1630
|
+
this.state.activeTurnOrigin === "local" &&
|
|
1631
|
+
this.hasAcceptedInput &&
|
|
1632
|
+
this.lastMirroredLocalPrompt &&
|
|
1633
|
+
this.lastMirroredLocalPrompt.createdAtMs >= Date.now() - OPENCODE_RECENT_LOCAL_PROMPT_TTL_MS &&
|
|
1634
|
+
this.lastMirroredLocalPrompt.text === normalizedText
|
|
1635
|
+
) {
|
|
1636
|
+
return true;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
return false;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/* ---- Session helpers ---- */
|
|
1643
|
+
|
|
1644
|
+
private unwrapOrThrow<T>(result: SdkResult<T>): T {
|
|
1645
|
+
if (result.error !== undefined) {
|
|
1646
|
+
throw new Error(`SDK error: ${describeUnknownError(result.error)}`);
|
|
1647
|
+
}
|
|
1648
|
+
return result.data as T;
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
private async ensureSession(): Promise<SdkSession> {
|
|
1652
|
+
if (this.activeSessionId && this.client) {
|
|
1653
|
+
const session = await this.getSessionForCurrentDirectory(this.activeSessionId);
|
|
1654
|
+
if (session) {
|
|
1655
|
+
return session;
|
|
1656
|
+
}
|
|
1657
|
+
this.activeSessionId = null;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
if (!this.client) {
|
|
1661
|
+
throw new Error("OpenCode SDK client is not initialized.");
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
const session = this.unwrapOrThrow(
|
|
1665
|
+
await this.client.session.create({
|
|
1666
|
+
directory: this.options.cwd,
|
|
1667
|
+
workspace: this.activeWorkspaceId ?? undefined,
|
|
1668
|
+
}),
|
|
1669
|
+
);
|
|
1670
|
+
return session;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
private beginTrackedTurn(
|
|
1674
|
+
text: string,
|
|
1675
|
+
origin: BridgeTurnOrigin,
|
|
1676
|
+
options: {
|
|
1677
|
+
busyMessage?: string;
|
|
1678
|
+
emitMirroredUserInput?: boolean;
|
|
1679
|
+
} = {},
|
|
1680
|
+
): void {
|
|
1681
|
+
this.hasAcceptedInput = true;
|
|
1682
|
+
this.currentPreview = truncatePreview(text);
|
|
1683
|
+
this.state.lastInputAt = nowIso();
|
|
1684
|
+
this.state.activeTurnOrigin = origin;
|
|
1685
|
+
this.lastBusyAtMs = Date.now();
|
|
1686
|
+
this.clearWechatWorkingNotice(true);
|
|
1687
|
+
this.setStatus("busy", options.busyMessage);
|
|
1688
|
+
|
|
1689
|
+
if (options.emitMirroredUserInput && origin === "local") {
|
|
1690
|
+
this.lastMirroredLocalPrompt = {
|
|
1691
|
+
text: normalizeOutput(text).trim(),
|
|
1692
|
+
createdAtMs: Date.now(),
|
|
1693
|
+
};
|
|
1694
|
+
this.emit({
|
|
1695
|
+
type: "mirrored_user_input",
|
|
1696
|
+
text,
|
|
1697
|
+
origin: "local",
|
|
1698
|
+
timestamp: nowIso(),
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
if (origin === "wechat") {
|
|
1703
|
+
this.armWechatWorkingNotice();
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
private hasTrackedTurnState(): boolean {
|
|
1708
|
+
return (
|
|
1709
|
+
this.state.status === "busy" ||
|
|
1710
|
+
this.state.status === "awaiting_approval" ||
|
|
1711
|
+
this.hasAcceptedInput ||
|
|
1712
|
+
this.pendingPermission !== null ||
|
|
1713
|
+
this.state.activeTurnOrigin !== undefined ||
|
|
1714
|
+
this.currentPreview !== "(idle)"
|
|
1715
|
+
);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
private settleTurnState(): void {
|
|
1719
|
+
this.clearWechatWorkingNotice(true);
|
|
1720
|
+
this.pendingLocalPrompt = "";
|
|
1721
|
+
this.localPromptNoticeSent = false;
|
|
1722
|
+
this.clearPendingPermissionState();
|
|
1723
|
+
this.state.activeTurnOrigin = undefined;
|
|
1724
|
+
this.hasAcceptedInput = false;
|
|
1725
|
+
this.currentPreview = "(idle)";
|
|
1726
|
+
this.outputBatcher.clear();
|
|
1727
|
+
this.clearStreamedPartState();
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
private clearObservedMessageTracking(): void {
|
|
1731
|
+
this.observedOpenCodeMessages.clear();
|
|
1732
|
+
this.observedUserTextByPartId.clear();
|
|
1733
|
+
this.observedUserMessagePartIds.clear();
|
|
1734
|
+
this.pendingWechatPromptMirrorSuppressions.length = 0;
|
|
1735
|
+
this.lastMirroredLocalPrompt = null;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
private clearTrackedTurnForLocalSessionSwitch(): void {
|
|
1739
|
+
if (!this.hasTrackedTurnState()) {
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
this.settleTurnState();
|
|
1744
|
+
if (this.state.status !== "idle") {
|
|
1745
|
+
this.setStatus("idle");
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
private failTrackedTurn(message: string): void {
|
|
1750
|
+
if (!this.hasTrackedTurnState()) {
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
this.settleTurnState();
|
|
1755
|
+
this.setStatus("idle");
|
|
1756
|
+
this.emit({
|
|
1757
|
+
type: "task_failed",
|
|
1758
|
+
message,
|
|
1759
|
+
timestamp: nowIso(),
|
|
1760
|
+
});
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
private describeSessionError(error: Record<string, unknown> | undefined): string | null {
|
|
1764
|
+
if (!error) {
|
|
1765
|
+
return "OpenCode reported an unknown session error.";
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
const name = typeof error.name === "string" ? error.name : "UnknownError";
|
|
1769
|
+
const data = isRecord(error.data) ? error.data : undefined;
|
|
1770
|
+
const message = typeof data?.message === "string" ? data.message.trim() : "";
|
|
1771
|
+
const providerId = typeof data?.providerID === "string" ? data.providerID : undefined;
|
|
1772
|
+
|
|
1773
|
+
if (name === "ProviderAuthError") {
|
|
1774
|
+
return providerId
|
|
1775
|
+
? `Authentication is required for provider "${providerId}".${message ? ` ${message}` : ""}`.trim()
|
|
1776
|
+
: message || "Authentication is required for the configured provider.";
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
return message || name;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/* ---- Working notice ---- */
|
|
1783
|
+
|
|
1784
|
+
private armWechatWorkingNotice(): void {
|
|
1785
|
+
this.clearWechatWorkingNotice();
|
|
1786
|
+
if (
|
|
1787
|
+
this.workingNoticeSent ||
|
|
1788
|
+
!this.hasAcceptedInput ||
|
|
1789
|
+
this.state.status !== "busy" ||
|
|
1790
|
+
this.pendingPermission ||
|
|
1791
|
+
this.state.activeTurnOrigin !== "wechat"
|
|
1792
|
+
) {
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
this.workingNoticeTimer = setTimeout(() => {
|
|
1797
|
+
this.workingNoticeTimer = null;
|
|
1798
|
+
if (
|
|
1799
|
+
this.workingNoticeSent ||
|
|
1800
|
+
!this.hasAcceptedInput ||
|
|
1801
|
+
this.state.status !== "busy" ||
|
|
1802
|
+
this.pendingPermission ||
|
|
1803
|
+
this.state.activeTurnOrigin !== "wechat"
|
|
1804
|
+
) {
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
this.workingNoticeSent = true;
|
|
1809
|
+
this.emit({
|
|
1810
|
+
type: "notice",
|
|
1811
|
+
text: `OpenCode is still working on:\n${this.currentPreview}`,
|
|
1812
|
+
level: "info",
|
|
1813
|
+
timestamp: nowIso(),
|
|
1814
|
+
});
|
|
1815
|
+
}, this.workingNoticeDelayMs);
|
|
1816
|
+
this.workingNoticeTimer.unref?.();
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
private clearWechatWorkingNotice(resetSent = false): void {
|
|
1820
|
+
if (this.workingNoticeTimer) {
|
|
1821
|
+
clearTimeout(this.workingNoticeTimer);
|
|
1822
|
+
this.workingNoticeTimer = null;
|
|
1823
|
+
}
|
|
1824
|
+
if (resetSent) {
|
|
1825
|
+
this.workingNoticeSent = false;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/* ---- Output batching ---- */
|
|
1830
|
+
|
|
1831
|
+
private flushOutputBatch(text: string): void {
|
|
1832
|
+
this.emit({
|
|
1833
|
+
type: "stdout",
|
|
1834
|
+
text,
|
|
1835
|
+
timestamp: nowIso(),
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
/* ---- Core helpers ---- */
|
|
1840
|
+
|
|
1841
|
+
private emit(event: BridgeEvent): void {
|
|
1842
|
+
this.eventSink(event);
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
private assignActiveSession(
|
|
1846
|
+
session: { id: string; workspaceID?: string } | string | null | undefined,
|
|
1847
|
+
): boolean {
|
|
1848
|
+
const sessionId = typeof session === "string" ? session : session?.id;
|
|
1849
|
+
if (!sessionId) {
|
|
1850
|
+
return false;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
const changed = sessionId !== this.activeSessionId;
|
|
1854
|
+
this.activeSessionId = sessionId;
|
|
1855
|
+
if (typeof session !== "string" && session?.workspaceID) {
|
|
1856
|
+
this.activeWorkspaceId = session.workspaceID;
|
|
1857
|
+
}
|
|
1858
|
+
this.state.sharedSessionId = sessionId;
|
|
1859
|
+
this.state.sharedThreadId = sessionId;
|
|
1860
|
+
this.state.activeRuntimeSessionId = sessionId;
|
|
1861
|
+
return changed;
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
private syncTrackedSessionFromEvent(
|
|
1865
|
+
session: { id: string; workspaceID?: string } | string | null | undefined,
|
|
1866
|
+
options: {
|
|
1867
|
+
allowLocalTurnFollow?: boolean;
|
|
1868
|
+
} = {},
|
|
1869
|
+
): boolean {
|
|
1870
|
+
const sessionId = typeof session === "string" ? session : session?.id;
|
|
1871
|
+
if (!sessionId) {
|
|
1872
|
+
return false;
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
if (sessionId === this.activeSessionId) {
|
|
1876
|
+
this.assignActiveSession(session);
|
|
1877
|
+
return true;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
if (options.allowLocalTurnFollow !== false && this.shouldFollowLocalTurnSession(sessionId)) {
|
|
1881
|
+
this.switchSharedSession(session, {
|
|
1882
|
+
source: "local",
|
|
1883
|
+
reason: "local_turn",
|
|
1884
|
+
notify: true,
|
|
1885
|
+
syncVisible: true,
|
|
1886
|
+
});
|
|
1887
|
+
return true;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
return false;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
private extractSessionReference(
|
|
1894
|
+
properties: Record<string, unknown>,
|
|
1895
|
+
): { id: string; workspaceID?: string } | null {
|
|
1896
|
+
if (typeof properties.sessionID === "string") {
|
|
1897
|
+
return {
|
|
1898
|
+
id: properties.sessionID,
|
|
1899
|
+
workspaceID: this.extractWorkspaceId(properties) ?? undefined,
|
|
1900
|
+
};
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
const info = isRecord(properties.info) ? properties.info : undefined;
|
|
1904
|
+
if (typeof info?.id === "string") {
|
|
1905
|
+
return {
|
|
1906
|
+
id: info.id,
|
|
1907
|
+
workspaceID: this.extractWorkspaceId(info) ?? this.extractWorkspaceId(properties) ?? undefined,
|
|
1908
|
+
};
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
return null;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
private extractSessionId(properties: Record<string, unknown>): string | null {
|
|
1915
|
+
return this.extractSessionReference(properties)?.id ?? null;
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
private extractWorkspaceId(properties: Record<string, unknown>): string | null {
|
|
1919
|
+
if (typeof properties.workspaceID === "string") {
|
|
1920
|
+
return properties.workspaceID;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
const info = isRecord(properties.info) ? properties.info : undefined;
|
|
1924
|
+
if (typeof info?.workspaceID === "string") {
|
|
1925
|
+
return info.workspaceID;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
return null;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
private shouldFollowLocalTurnSession(sessionId: string): boolean {
|
|
1932
|
+
return (
|
|
1933
|
+
sessionId !== this.activeSessionId &&
|
|
1934
|
+
this.state.activeTurnOrigin === "local" &&
|
|
1935
|
+
this.hasTrackedTurnState()
|
|
1936
|
+
);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
private switchSharedSession(
|
|
1940
|
+
session: { id: string; workspaceID?: string } | string,
|
|
1941
|
+
options: {
|
|
1942
|
+
source: BridgeSessionSwitchSource;
|
|
1943
|
+
reason: BridgeSessionSwitchReason;
|
|
1944
|
+
notify?: boolean;
|
|
1945
|
+
clearTrackedTurn?: boolean;
|
|
1946
|
+
syncVisible?: boolean;
|
|
1947
|
+
forceVisibleSync?: boolean;
|
|
1948
|
+
},
|
|
1949
|
+
): boolean {
|
|
1950
|
+
const sessionId = typeof session === "string" ? session : session.id;
|
|
1951
|
+
if (!sessionId) {
|
|
1952
|
+
return false;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
const changed = this.assignActiveSession(session);
|
|
1956
|
+
if (changed && options.clearTrackedTurn) {
|
|
1957
|
+
this.clearTrackedTurnForLocalSessionSwitch();
|
|
1958
|
+
}
|
|
1959
|
+
if (changed) {
|
|
1960
|
+
this.recordSessionSwitch(sessionId, options.source, options.reason, options.notify);
|
|
1961
|
+
}
|
|
1962
|
+
if (options.syncVisible !== false && (changed || options.forceVisibleSync)) {
|
|
1963
|
+
void this.syncVisibleSessionSelection(typeof session === "string" ? { id: session } : session);
|
|
1964
|
+
}
|
|
1965
|
+
return changed;
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
private async syncVisibleSessionToShared(options: { force?: boolean } = {}): Promise<void> {
|
|
1969
|
+
if (!this.activeSessionId) {
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
await this.syncVisibleSessionSelection(
|
|
1974
|
+
{
|
|
1975
|
+
id: this.activeSessionId,
|
|
1976
|
+
workspaceID: this.activeWorkspaceId ?? undefined,
|
|
1977
|
+
},
|
|
1978
|
+
{ force: options.force },
|
|
1979
|
+
);
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
private async syncVisibleSessionSelection(
|
|
1983
|
+
session: { id: string; workspaceID?: string },
|
|
1984
|
+
options: {
|
|
1985
|
+
force?: boolean;
|
|
1986
|
+
} = {},
|
|
1987
|
+
): Promise<void> {
|
|
1988
|
+
if (
|
|
1989
|
+
!this.client?.tui ||
|
|
1990
|
+
this.options.renderMode !== "companion" ||
|
|
1991
|
+
(!options.force && session.id === this.suppressedTuiSessionSelectId)
|
|
1992
|
+
) {
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
this.suppressedTuiSessionSelectId = session.id;
|
|
1997
|
+
this.logDebug(
|
|
1998
|
+
`[opencode-adapter:tui] selectSession session=${session.id} workspace=${session.workspaceID ?? this.activeWorkspaceId ?? "(none)"}`,
|
|
1999
|
+
);
|
|
2000
|
+
|
|
2001
|
+
try {
|
|
2002
|
+
const result = await this.client.tui.selectSession({
|
|
2003
|
+
directory: this.options.cwd,
|
|
2004
|
+
workspace: session.workspaceID ?? this.activeWorkspaceId ?? undefined,
|
|
2005
|
+
sessionID: session.id,
|
|
2006
|
+
});
|
|
2007
|
+
if (result.error !== undefined) {
|
|
2008
|
+
throw result.error;
|
|
2009
|
+
}
|
|
2010
|
+
} catch (err) {
|
|
2011
|
+
if (this.suppressedTuiSessionSelectId === session.id) {
|
|
2012
|
+
this.suppressedTuiSessionSelectId = null;
|
|
2013
|
+
}
|
|
2014
|
+
this.logDebug(
|
|
2015
|
+
`[opencode-adapter:tui] selectSession failed for ${session.id}: ${describeUnknownError(err)}`,
|
|
2016
|
+
);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
private isLocalSessionNavigationCommand(command: string | undefined): boolean {
|
|
2021
|
+
if (!command) {
|
|
2022
|
+
return false;
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
const normalized = command.trim().toLowerCase();
|
|
2026
|
+
return (
|
|
2027
|
+
normalized === "session" ||
|
|
2028
|
+
normalized === "/session" ||
|
|
2029
|
+
normalized.startsWith("session.") ||
|
|
2030
|
+
normalized.startsWith("/session ")
|
|
2031
|
+
);
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
private normalizeEventType(type: string): string {
|
|
2035
|
+
return type.endsWith(".1") ? type.slice(0, -2) : type;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
private extractEventPayload(event: SdkEvent): unknown {
|
|
2039
|
+
const syncEvent = event as SdkEvent & { data?: unknown };
|
|
2040
|
+
return syncEvent.properties ?? syncEvent.data;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
private matchesCurrentDirectoryEvent(event: SdkEvent): boolean {
|
|
2044
|
+
const payload = this.extractEventPayload(event);
|
|
2045
|
+
const wrappedDirectory =
|
|
2046
|
+
typeof (event as { directory?: unknown }).directory === "string"
|
|
2047
|
+
? (event as { directory: string }).directory
|
|
2048
|
+
: undefined;
|
|
2049
|
+
if (wrappedDirectory) {
|
|
2050
|
+
const matchesWrappedDirectory =
|
|
2051
|
+
this.normalizeDirectory(wrappedDirectory) === this.normalizeDirectory(this.options.cwd);
|
|
2052
|
+
if (!matchesWrappedDirectory) {
|
|
2053
|
+
return false;
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
if (!isRecord(payload)) {
|
|
2058
|
+
return Boolean(wrappedDirectory);
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
const directory = this.extractSessionDirectory(payload);
|
|
2062
|
+
if (directory) {
|
|
2063
|
+
const matchesDirectory =
|
|
2064
|
+
this.normalizeDirectory(directory) === this.normalizeDirectory(this.options.cwd);
|
|
2065
|
+
if (!matchesDirectory) {
|
|
2066
|
+
return false;
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
const workspaceId = this.extractWorkspaceId(payload);
|
|
2071
|
+
if (workspaceId && this.activeWorkspaceId && workspaceId !== this.activeWorkspaceId) {
|
|
2072
|
+
this.logDebug(
|
|
2073
|
+
`[opencode-adapter:sse] Ignored workspace mismatch session=${this.extractSessionId(payload) ?? "(unknown)"} eventWorkspace=${workspaceId} activeWorkspace=${this.activeWorkspaceId}`,
|
|
2074
|
+
);
|
|
2075
|
+
return false;
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
if (wrappedDirectory || directory || workspaceId) {
|
|
2079
|
+
return true;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
const sessionId = this.extractSessionId(payload);
|
|
2083
|
+
return Boolean(
|
|
2084
|
+
sessionId &&
|
|
2085
|
+
(sessionId === this.activeSessionId || sessionId === this.state.sharedSessionId),
|
|
2086
|
+
);
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
private extractSessionDirectory(properties: Record<string, unknown>): string | null {
|
|
2090
|
+
if (typeof properties.directory === "string") {
|
|
2091
|
+
return properties.directory;
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
const info = isRecord(properties.info) ? properties.info : undefined;
|
|
2095
|
+
if (typeof info?.directory === "string") {
|
|
2096
|
+
return info.directory;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
return null;
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
private isCurrentDirectorySession(session: SdkSession): boolean {
|
|
2103
|
+
if (this.normalizeDirectory(session.directory) !== this.normalizeDirectory(this.options.cwd)) {
|
|
2104
|
+
return false;
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
if (session.workspaceID && this.activeWorkspaceId && session.workspaceID !== this.activeWorkspaceId) {
|
|
2108
|
+
return false;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
return true;
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
private normalizeDirectory(directory: string): string {
|
|
2115
|
+
return directory.replace(/[\\/]+/g, "\\").replace(/\\$/, "").toLowerCase();
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
private recordSessionSwitch(
|
|
2119
|
+
sessionId: string,
|
|
2120
|
+
source: BridgeSessionSwitchSource,
|
|
2121
|
+
reason: BridgeSessionSwitchReason,
|
|
2122
|
+
notify = false,
|
|
2123
|
+
): void {
|
|
2124
|
+
const timestamp = nowIso();
|
|
2125
|
+
this.state.lastSessionSwitchAt = timestamp;
|
|
2126
|
+
this.state.lastSessionSwitchSource = source;
|
|
2127
|
+
this.state.lastSessionSwitchReason = reason;
|
|
2128
|
+
if (!notify) {
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
this.emit({
|
|
2133
|
+
type: "session_switched",
|
|
2134
|
+
sessionId,
|
|
2135
|
+
source,
|
|
2136
|
+
reason,
|
|
2137
|
+
timestamp,
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
private getServerUrl(): string {
|
|
2142
|
+
return `http://${OPENCODE_SERVER_HOST}:${this.serverPort}`;
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
private isVisibleTextPart(part: Record<string, unknown> | undefined): part is SdkPart {
|
|
2146
|
+
return !!part && part.type === "text" && part.ignored !== true;
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
private extractPartId(
|
|
2150
|
+
properties: Record<string, unknown>,
|
|
2151
|
+
part?: Record<string, unknown> | undefined,
|
|
2152
|
+
): string | null {
|
|
2153
|
+
if (typeof properties.partID === "string") {
|
|
2154
|
+
return properties.partID;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
if (typeof part?.id === "string") {
|
|
2158
|
+
return part.id;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
return null;
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
private consumeVisiblePartSnapshot(partId: string, text: string): string {
|
|
2165
|
+
const nextText = normalizeOutput(text);
|
|
2166
|
+
if (!nextText) {
|
|
2167
|
+
return "";
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
const previousText = this.emittedTextByPartId.get(partId) ?? "";
|
|
2171
|
+
if (nextText === previousText) {
|
|
2172
|
+
return "";
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
this.emittedTextByPartId.set(partId, nextText);
|
|
2176
|
+
if (!previousText) {
|
|
2177
|
+
return nextText;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
if (nextText.startsWith(previousText)) {
|
|
2181
|
+
return nextText.slice(previousText.length);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
const sharedPrefixLength = this.getSharedPrefixLength(previousText, nextText);
|
|
2185
|
+
return nextText.slice(sharedPrefixLength);
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
private consumeVisiblePartDelta(partId: string, delta: string): string {
|
|
2189
|
+
const nextChunk = normalizeOutput(delta);
|
|
2190
|
+
if (!nextChunk) {
|
|
2191
|
+
return "";
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
const previousText = this.emittedTextByPartId.get(partId) ?? "";
|
|
2195
|
+
if (nextChunk === previousText || previousText.endsWith(nextChunk)) {
|
|
2196
|
+
return "";
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
if (previousText && nextChunk.startsWith(previousText)) {
|
|
2200
|
+
this.emittedTextByPartId.set(partId, nextChunk);
|
|
2201
|
+
return nextChunk.slice(previousText.length);
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
this.emittedTextByPartId.set(partId, `${previousText}${nextChunk}`);
|
|
2205
|
+
return nextChunk;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
private consumeObservedUserPartSnapshot(partId: string, text: string): string {
|
|
2209
|
+
const nextText = normalizeOutput(text);
|
|
2210
|
+
if (!nextText) {
|
|
2211
|
+
return "";
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
const previousText = this.observedUserTextByPartId.get(partId) ?? "";
|
|
2215
|
+
if (nextText === previousText) {
|
|
2216
|
+
return "";
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
this.observedUserTextByPartId.set(partId, nextText);
|
|
2220
|
+
if (!previousText) {
|
|
2221
|
+
return nextText;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
if (nextText.startsWith(previousText)) {
|
|
2225
|
+
return nextText.slice(previousText.length);
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
const sharedPrefixLength = this.getSharedPrefixLength(previousText, nextText);
|
|
2229
|
+
return nextText.slice(sharedPrefixLength);
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
private consumeObservedUserPartDelta(partId: string, delta: string): string {
|
|
2233
|
+
const nextChunk = normalizeOutput(delta);
|
|
2234
|
+
if (!nextChunk) {
|
|
2235
|
+
return "";
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
const previousText = this.observedUserTextByPartId.get(partId) ?? "";
|
|
2239
|
+
if (nextChunk === previousText || previousText.endsWith(nextChunk)) {
|
|
2240
|
+
return "";
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
if (previousText && nextChunk.startsWith(previousText)) {
|
|
2244
|
+
this.observedUserTextByPartId.set(partId, nextChunk);
|
|
2245
|
+
return nextChunk.slice(previousText.length);
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
this.observedUserTextByPartId.set(partId, `${previousText}${nextChunk}`);
|
|
2249
|
+
return nextChunk;
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
private pushVisibleOutput(text: string): void {
|
|
2253
|
+
if (!text) {
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
this.state.lastOutputAt = nowIso();
|
|
2258
|
+
this.outputBatcher.push(text);
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
private clearStreamedPartState(): void {
|
|
2262
|
+
this.emittedTextByPartId.clear();
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
private cleanupObservedOpenCodeMessage(messageId: string): void {
|
|
2266
|
+
this.observedOpenCodeMessages.delete(messageId);
|
|
2267
|
+
const partIds = this.observedUserMessagePartIds.get(messageId);
|
|
2268
|
+
if (partIds) {
|
|
2269
|
+
for (const partId of partIds) {
|
|
2270
|
+
this.observedUserTextByPartId.delete(partId);
|
|
2271
|
+
}
|
|
2272
|
+
this.observedUserMessagePartIds.delete(messageId);
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
private getSharedPrefixLength(left: string, right: string): number {
|
|
2277
|
+
const limit = Math.min(left.length, right.length);
|
|
2278
|
+
let index = 0;
|
|
2279
|
+
while (index < limit && left[index] === right[index]) {
|
|
2280
|
+
index += 1;
|
|
2281
|
+
}
|
|
2282
|
+
return index;
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
private logDebug(message: string): void {
|
|
2286
|
+
if (!OPENCODE_DEBUG_ENABLED) {
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
process.stderr.write(`${message}\n`);
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
private logUnknownEvent(type: string): void {
|
|
2293
|
+
if (!OPENCODE_DEBUG_ENABLED || this.loggedUnknownEventTypes.has(type)) {
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
this.loggedUnknownEventTypes.add(type);
|
|
2297
|
+
this.logDebug(`[opencode-adapter:sse] Unknown event: ${type}`);
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
private shouldSkipDuplicateSdkEvent(
|
|
2301
|
+
event: NormalizedSdkEvent,
|
|
2302
|
+
streamName: SdkEventStreamName,
|
|
2303
|
+
): boolean {
|
|
2304
|
+
const key = this.getDuplicateSdkEventKey(event);
|
|
2305
|
+
if (!key) {
|
|
2306
|
+
return false;
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
const now = Date.now();
|
|
2310
|
+
const cutoff = now - OPENCODE_DUPLICATE_EVENT_TTL_MS;
|
|
2311
|
+
for (const [candidateKey, observed] of this.recentSdkEventObservations.entries()) {
|
|
2312
|
+
if (observed.observedAtMs < cutoff) {
|
|
2313
|
+
this.recentSdkEventObservations.delete(candidateKey);
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
const previous = this.recentSdkEventObservations.get(key);
|
|
2318
|
+
this.recentSdkEventObservations.set(key, { streamName, observedAtMs: now });
|
|
2319
|
+
return Boolean(previous && previous.streamName !== streamName && previous.observedAtMs >= cutoff);
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
private getDuplicateSdkEventKey(event: NormalizedSdkEvent): string | null {
|
|
2323
|
+
const type = this.normalizeEventType(event.type);
|
|
2324
|
+
const payload = this.extractEventPayload(event);
|
|
2325
|
+
if (!isRecord(payload)) {
|
|
2326
|
+
return null;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
switch (type) {
|
|
2330
|
+
case "tui.prompt.append": {
|
|
2331
|
+
const text = typeof payload.text === "string" ? payload.text : undefined;
|
|
2332
|
+
return text ? `${type}:${text}` : null;
|
|
2333
|
+
}
|
|
2334
|
+
case "tui.command.execute": {
|
|
2335
|
+
const command = typeof payload.command === "string" ? payload.command : undefined;
|
|
2336
|
+
return command ? `${type}:${command}` : null;
|
|
2337
|
+
}
|
|
2338
|
+
case "tui.session.select": {
|
|
2339
|
+
const sessionId = typeof payload.sessionID === "string" ? payload.sessionID : undefined;
|
|
2340
|
+
return sessionId ? `${type}:${sessionId}` : null;
|
|
2341
|
+
}
|
|
2342
|
+
case "command.executed": {
|
|
2343
|
+
const name = typeof payload.name === "string" ? payload.name : undefined;
|
|
2344
|
+
const sessionId = this.extractSessionId(payload) ?? "";
|
|
2345
|
+
const args = typeof payload.arguments === "string" ? payload.arguments : "";
|
|
2346
|
+
return name ? `${type}:${name}:${sessionId}:${args}` : null;
|
|
2347
|
+
}
|
|
2348
|
+
case "session.created":
|
|
2349
|
+
case "session.updated":
|
|
2350
|
+
case "session.deleted": {
|
|
2351
|
+
const sessionId = this.extractSessionId(payload);
|
|
2352
|
+
return sessionId ? `${type}:${sessionId}` : null;
|
|
2353
|
+
}
|
|
2354
|
+
default:
|
|
2355
|
+
return null;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
private maybeNotifyLocalPromptDraftStarted(): void {
|
|
2360
|
+
if (this.localPromptNoticeSent) {
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
const preview = truncatePreview(normalizeOutput(this.pendingLocalPrompt).trim(), 180);
|
|
2365
|
+
if (!preview) {
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
this.localPromptNoticeSent = true;
|
|
2370
|
+
this.emit({
|
|
2371
|
+
type: "notice",
|
|
2372
|
+
text: `OpenCode local draft:\n${preview}`,
|
|
2373
|
+
level: "info",
|
|
2374
|
+
timestamp: nowIso(),
|
|
2375
|
+
});
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
private setStatus(
|
|
2379
|
+
status: BridgeAdapterState["status"],
|
|
2380
|
+
message?: string,
|
|
2381
|
+
): void {
|
|
2382
|
+
this.state.status = status;
|
|
2383
|
+
this.emit({
|
|
2384
|
+
type: "status",
|
|
2385
|
+
status,
|
|
2386
|
+
message,
|
|
2387
|
+
timestamp: nowIso(),
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
2390
|
+
}
|