oppi-mirror 0.4.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 +21 -0
- package/README.md +88 -0
- package/extensions/oppi-mirror.ts +2826 -0
- package/package.json +43 -0
|
@@ -0,0 +1,2826 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AgentSessionEvent,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
type ExtensionUIDialogOptions,
|
|
6
|
+
type ExtensionUIContext,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { WebSocket, type RawData } from "ws";
|
|
9
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join, resolve } from "node:path";
|
|
11
|
+
import { hostname } from "node:os";
|
|
12
|
+
|
|
13
|
+
interface OppiMirrorSettings {
|
|
14
|
+
serverUrl?: string;
|
|
15
|
+
token?: string;
|
|
16
|
+
autoStart?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type MirrorLogLevel = "debug" | "info" | "warn" | "error";
|
|
20
|
+
|
|
21
|
+
export interface QueueImageContent {
|
|
22
|
+
data: string;
|
|
23
|
+
mimeType: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MessageQueueDraftItem {
|
|
27
|
+
id?: string;
|
|
28
|
+
message: string;
|
|
29
|
+
images?: QueueImageContent[];
|
|
30
|
+
createdAt?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface MessageQueueItem {
|
|
34
|
+
id: string;
|
|
35
|
+
message: string;
|
|
36
|
+
images?: QueueImageContent[];
|
|
37
|
+
createdAt: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MessageQueueState {
|
|
41
|
+
version: number;
|
|
42
|
+
steering: MessageQueueItem[];
|
|
43
|
+
followUp: MessageQueueItem[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface EditableAgentSession {
|
|
47
|
+
sessionId?: string;
|
|
48
|
+
sessionManager?: { getSessionId?: () => string };
|
|
49
|
+
_steeringMessages?: string[];
|
|
50
|
+
_followUpMessages?: string[];
|
|
51
|
+
_emitQueueUpdate?: () => void;
|
|
52
|
+
getSteeringMessages?: () => readonly string[];
|
|
53
|
+
getFollowUpMessages?: () => readonly string[];
|
|
54
|
+
agent?: {
|
|
55
|
+
clearAllQueues?: () => void;
|
|
56
|
+
clearSteeringQueue?: () => void;
|
|
57
|
+
clearFollowUpQueue?: () => void;
|
|
58
|
+
steer?: (message: unknown) => void;
|
|
59
|
+
followUp?: (message: unknown) => void;
|
|
60
|
+
};
|
|
61
|
+
reload?: () => Promise<void>;
|
|
62
|
+
abortCompaction?: () => void;
|
|
63
|
+
abortRetry?: () => void;
|
|
64
|
+
abortBash?: () => void;
|
|
65
|
+
setAutoCompactionEnabled?: (enabled: boolean) => void;
|
|
66
|
+
setAutoRetryEnabled?: (enabled: boolean) => void;
|
|
67
|
+
setSteeringMode?: (mode: "all" | "one-at-a-time") => void;
|
|
68
|
+
setFollowUpMode?: (mode: "all" | "one-at-a-time") => void;
|
|
69
|
+
getUserMessagesForForking?: () => Array<{ entryId: string; text: string }>;
|
|
70
|
+
navigateTree?: (
|
|
71
|
+
targetId: string,
|
|
72
|
+
options?: {
|
|
73
|
+
summarize?: boolean;
|
|
74
|
+
customInstructions?: string;
|
|
75
|
+
replaceInstructions?: boolean;
|
|
76
|
+
label?: string;
|
|
77
|
+
},
|
|
78
|
+
) => Promise<unknown>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface QueueUpdateEvent {
|
|
82
|
+
steering: readonly string[];
|
|
83
|
+
followUp: readonly string[];
|
|
84
|
+
session?: EditableAgentSession;
|
|
85
|
+
sessionId?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type QueueUpdateListener = (event: QueueUpdateEvent) => void;
|
|
89
|
+
type AgentSessionPrototype = {
|
|
90
|
+
bindExtensions?: (this: unknown, ...args: unknown[]) => unknown;
|
|
91
|
+
_emit?: (this: unknown, event: unknown, ...args: unknown[]) => unknown;
|
|
92
|
+
_emitQueueUpdate?: (this: unknown, ...args: unknown[]) => unknown;
|
|
93
|
+
};
|
|
94
|
+
type InternalAgentSessionEvent = Extract<
|
|
95
|
+
AgentSessionEvent,
|
|
96
|
+
| { type: "compaction_start" }
|
|
97
|
+
| { type: "compaction_end" }
|
|
98
|
+
| { type: "auto_retry_start" }
|
|
99
|
+
| { type: "auto_retry_end" }
|
|
100
|
+
>;
|
|
101
|
+
type InternalAgentSessionEventListener = (
|
|
102
|
+
event: InternalAgentSessionEvent,
|
|
103
|
+
) => void;
|
|
104
|
+
|
|
105
|
+
type SessionTreeFilterMode =
|
|
106
|
+
| "default"
|
|
107
|
+
| "no-tools"
|
|
108
|
+
| "user-only"
|
|
109
|
+
| "labeled-only"
|
|
110
|
+
| "all";
|
|
111
|
+
|
|
112
|
+
interface MirrorSessionTreeEntry {
|
|
113
|
+
id: string;
|
|
114
|
+
parentId?: string | null;
|
|
115
|
+
type: string;
|
|
116
|
+
timestamp?: string;
|
|
117
|
+
message?: unknown;
|
|
118
|
+
tokensBefore?: unknown;
|
|
119
|
+
summary?: unknown;
|
|
120
|
+
content?: unknown;
|
|
121
|
+
name?: unknown;
|
|
122
|
+
modelId?: unknown;
|
|
123
|
+
thinkingLevel?: unknown;
|
|
124
|
+
label?: unknown;
|
|
125
|
+
customType?: unknown;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface MirrorSessionTreeNode {
|
|
129
|
+
entry: MirrorSessionTreeEntry;
|
|
130
|
+
children: MirrorSessionTreeNode[];
|
|
131
|
+
label?: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface MirrorSessionTreeManager {
|
|
135
|
+
getTree: () => MirrorSessionTreeNode[];
|
|
136
|
+
getLeafId: () => string | null;
|
|
137
|
+
getEntry: (id: string) => MirrorSessionTreeEntry | undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface SessionTreeNodeSnapshot {
|
|
141
|
+
id: string;
|
|
142
|
+
parentId: string | null;
|
|
143
|
+
type: string;
|
|
144
|
+
timestamp: string;
|
|
145
|
+
depth: number;
|
|
146
|
+
isLeafPath: boolean;
|
|
147
|
+
defaultVisible: boolean;
|
|
148
|
+
matchesFilter: boolean;
|
|
149
|
+
role?: string;
|
|
150
|
+
textPreview?: string;
|
|
151
|
+
label?: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
interface QueueUpdateBridge {
|
|
155
|
+
listeners: Set<QueueUpdateListener>;
|
|
156
|
+
internalEventListeners: Set<InternalAgentSessionEventListener>;
|
|
157
|
+
sessions: Map<string, EditableAgentSession>;
|
|
158
|
+
installed: boolean;
|
|
159
|
+
internalEventInstalled?: boolean;
|
|
160
|
+
last?: QueueUpdateEvent;
|
|
161
|
+
lastSession?: EditableAgentSession;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const QUEUE_UPDATE_BRIDGE_KEY = "__oppiMirrorQueueUpdateBridge";
|
|
165
|
+
|
|
166
|
+
const EVENT_TYPES = [
|
|
167
|
+
"agent_start",
|
|
168
|
+
"agent_end",
|
|
169
|
+
"turn_start",
|
|
170
|
+
"turn_end",
|
|
171
|
+
"message_start",
|
|
172
|
+
"message_update",
|
|
173
|
+
"message_end",
|
|
174
|
+
"tool_execution_start",
|
|
175
|
+
"tool_execution_update",
|
|
176
|
+
"tool_execution_end",
|
|
177
|
+
] as const;
|
|
178
|
+
|
|
179
|
+
const INTERNAL_AGENT_SESSION_EVENT_TYPES = new Set<AgentSessionEvent["type"]>([
|
|
180
|
+
"compaction_start",
|
|
181
|
+
"compaction_end",
|
|
182
|
+
"auto_retry_start",
|
|
183
|
+
"auto_retry_end",
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
function settingsPath(): string {
|
|
187
|
+
return join(process.env.HOME || "", ".pi/agent/settings.json");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function oppiConfigPath(): string {
|
|
191
|
+
if (process.env.OPPI_CONFIG_PATH) return process.env.OPPI_CONFIG_PATH;
|
|
192
|
+
const dataDir =
|
|
193
|
+
process.env.OPPI_DATA_DIR || join(process.env.HOME || "", ".config/oppi");
|
|
194
|
+
return join(dataDir, "config.json");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function readJsonFile(path: string): Record<string, unknown> {
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
200
|
+
} catch {
|
|
201
|
+
return {};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function expandHomePath(path: string): string {
|
|
206
|
+
const home = process.env.HOME || "";
|
|
207
|
+
if (path === "~") return home;
|
|
208
|
+
if (path.startsWith("~/")) return join(home, path.slice(2));
|
|
209
|
+
return path;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function oppiDataDir(): string {
|
|
213
|
+
const configured = process.env.OPPI_DATA_DIR?.trim();
|
|
214
|
+
if (configured) return resolve(expandHomePath(configured));
|
|
215
|
+
|
|
216
|
+
const config = readJsonFile(oppiConfigPath()) as { dataDir?: unknown };
|
|
217
|
+
const dataDir =
|
|
218
|
+
typeof config.dataDir === "string" ? config.dataDir.trim() : "";
|
|
219
|
+
if (dataDir) return resolve(expandHomePath(dataDir));
|
|
220
|
+
|
|
221
|
+
return resolve(join(process.env.HOME || "", ".config/oppi"));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function mirrorLogPath(): string {
|
|
225
|
+
const configured = process.env.OPPI_MIRROR_LOG_PATH?.trim();
|
|
226
|
+
return resolve(
|
|
227
|
+
expandHomePath(configured || join(oppiDataDir(), "oppi-mirror.log")),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function redactLogText(text: string): string {
|
|
232
|
+
return text
|
|
233
|
+
.replace(/Bearer\s+[^\s"']+/gi, "Bearer [REDACTED]")
|
|
234
|
+
.replace(/\bsk_[A-Za-z0-9._-]+\b/g, "[REDACTED_TOKEN]");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function sanitizedLogValue(
|
|
238
|
+
value: unknown,
|
|
239
|
+
depth = 0,
|
|
240
|
+
seen: WeakSet<object> = new WeakSet<object>(),
|
|
241
|
+
): unknown {
|
|
242
|
+
if (value === null || value === undefined) return value;
|
|
243
|
+
if (typeof value === "string") return redactLogText(value).slice(0, 4_000);
|
|
244
|
+
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
245
|
+
if (typeof value === "bigint") return value.toString();
|
|
246
|
+
if (typeof value === "symbol" || typeof value === "function")
|
|
247
|
+
return String(value);
|
|
248
|
+
if (value instanceof Error) return errorLogValue(value, seen);
|
|
249
|
+
if (depth >= 4) return "[truncated]";
|
|
250
|
+
if (seen.has(value)) return "[circular]";
|
|
251
|
+
seen.add(value);
|
|
252
|
+
|
|
253
|
+
if (Array.isArray(value)) {
|
|
254
|
+
return value
|
|
255
|
+
.slice(0, 25)
|
|
256
|
+
.map((item) => sanitizedLogValue(item, depth + 1, seen));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const out: Record<string, unknown> = {};
|
|
260
|
+
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
261
|
+
const lower = key.toLowerCase();
|
|
262
|
+
if (
|
|
263
|
+
lower.includes("token") ||
|
|
264
|
+
lower.includes("authorization") ||
|
|
265
|
+
lower === "data" ||
|
|
266
|
+
lower === "images"
|
|
267
|
+
) {
|
|
268
|
+
out[key] = "[REDACTED]";
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
out[key] = sanitizedLogValue(item, depth + 1, seen);
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function errorLogValue(
|
|
277
|
+
error: Error,
|
|
278
|
+
seen: WeakSet<object>,
|
|
279
|
+
): Record<string, unknown> {
|
|
280
|
+
const record = error as Error & Record<string, unknown>;
|
|
281
|
+
return sanitizedLogValue(
|
|
282
|
+
{
|
|
283
|
+
name: error.name,
|
|
284
|
+
message: error.message,
|
|
285
|
+
stack: error.stack,
|
|
286
|
+
code: record.code,
|
|
287
|
+
errno: record.errno,
|
|
288
|
+
syscall: record.syscall,
|
|
289
|
+
address: record.address,
|
|
290
|
+
port: record.port,
|
|
291
|
+
},
|
|
292
|
+
0,
|
|
293
|
+
seen,
|
|
294
|
+
) as Record<string, unknown>;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function writeMirrorLog(
|
|
298
|
+
level: MirrorLogLevel,
|
|
299
|
+
event: string,
|
|
300
|
+
details: Record<string, unknown> = {},
|
|
301
|
+
) {
|
|
302
|
+
try {
|
|
303
|
+
const path = mirrorLogPath();
|
|
304
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
305
|
+
appendFileSync(
|
|
306
|
+
path,
|
|
307
|
+
`${JSON.stringify({
|
|
308
|
+
ts: new Date().toISOString(),
|
|
309
|
+
level,
|
|
310
|
+
event,
|
|
311
|
+
...(sanitizedLogValue(details) as Record<string, unknown>),
|
|
312
|
+
})}\n`,
|
|
313
|
+
{ encoding: "utf8", mode: 0o600 },
|
|
314
|
+
);
|
|
315
|
+
} catch {
|
|
316
|
+
// Logging must never leak into the TUI or affect the Pi session.
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function readSettingsFile(): Record<string, unknown> {
|
|
321
|
+
return readJsonFile(settingsPath());
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function localHostForConfig(host: unknown): string {
|
|
325
|
+
const value = typeof host === "string" ? host.trim() : "";
|
|
326
|
+
if (!value || value === "0.0.0.0" || value === "::" || value === "[::]") {
|
|
327
|
+
return "127.0.0.1";
|
|
328
|
+
}
|
|
329
|
+
return value;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function autoDiscoverOppiSettings(): Partial<OppiMirrorSettings> {
|
|
333
|
+
const config = readJsonFile(oppiConfigPath()) as {
|
|
334
|
+
host?: unknown;
|
|
335
|
+
port?: unknown;
|
|
336
|
+
tls?: { mode?: unknown };
|
|
337
|
+
token?: unknown;
|
|
338
|
+
};
|
|
339
|
+
const token = typeof config.token === "string" ? config.token.trim() : "";
|
|
340
|
+
const port =
|
|
341
|
+
typeof config.port === "number" || typeof config.port === "string"
|
|
342
|
+
? String(config.port)
|
|
343
|
+
: "";
|
|
344
|
+
if (!token || !port) return {};
|
|
345
|
+
|
|
346
|
+
const scheme = config.tls?.mode === "disabled" ? "http" : "https";
|
|
347
|
+
const host = localHostForConfig(config.host);
|
|
348
|
+
return { serverUrl: `${scheme}://${host}:${port}`, token };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function loadSettings(): OppiMirrorSettings {
|
|
352
|
+
const parsed = readSettingsFile() as { oppiMirror?: OppiMirrorSettings };
|
|
353
|
+
const fileSettings = parsed.oppiMirror ?? {};
|
|
354
|
+
const discovered = autoDiscoverOppiSettings();
|
|
355
|
+
const autoStartEnv = process.env.OPPI_MIRROR_AUTO_START?.toLowerCase();
|
|
356
|
+
const envAutoStart =
|
|
357
|
+
autoStartEnv === undefined
|
|
358
|
+
? undefined
|
|
359
|
+
: autoStartEnv === "1" ||
|
|
360
|
+
autoStartEnv === "true" ||
|
|
361
|
+
autoStartEnv === "yes";
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
serverUrl:
|
|
365
|
+
process.env.OPPI_MIRROR_URL ||
|
|
366
|
+
fileSettings.serverUrl ||
|
|
367
|
+
discovered.serverUrl,
|
|
368
|
+
token:
|
|
369
|
+
process.env.OPPI_MIRROR_TOKEN || fileSettings.token || discovered.token,
|
|
370
|
+
// Installing/enabling the extension is the opt-in. Mirror automatically unless explicitly disabled.
|
|
371
|
+
autoStart: envAutoStart ?? fileSettings.autoStart !== false,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function isLocalUrl(urlText: string): boolean {
|
|
376
|
+
try {
|
|
377
|
+
const host = new URL(urlText).hostname;
|
|
378
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
379
|
+
} catch {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function isInteractiveTerminalProcess(): boolean {
|
|
385
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function bridgeUrl(serverUrl: string): string {
|
|
389
|
+
const url = new URL(serverUrl);
|
|
390
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
391
|
+
url.pathname = "/mirror/v1/bridge";
|
|
392
|
+
url.search = "";
|
|
393
|
+
return url.toString();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function modelWire(ctx: ExtensionContext) {
|
|
397
|
+
const model = ctx.model;
|
|
398
|
+
if (!model) return null;
|
|
399
|
+
return { provider: model.provider, id: model.id };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function contextUsageWire(ctx: ExtensionContext) {
|
|
403
|
+
const usage = ctx.getContextUsage();
|
|
404
|
+
if (!usage) return null;
|
|
405
|
+
return {
|
|
406
|
+
tokens: usage.tokens,
|
|
407
|
+
contextWindow: usage.contextWindow,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function queueId(): string {
|
|
412
|
+
return `mirror_q_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function cloneQueueItem(item: MessageQueueItem): MessageQueueItem {
|
|
416
|
+
return {
|
|
417
|
+
id: item.id,
|
|
418
|
+
message: item.message,
|
|
419
|
+
...(item.images
|
|
420
|
+
? { images: item.images.map((image) => ({ ...image })) }
|
|
421
|
+
: {}),
|
|
422
|
+
createdAt: item.createdAt,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function cloneQueueState(queue: MessageQueueState): MessageQueueState {
|
|
427
|
+
return {
|
|
428
|
+
version: queue.version,
|
|
429
|
+
steering: queue.steering.map(cloneQueueItem),
|
|
430
|
+
followUp: queue.followUp.map(cloneQueueItem),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function draftToItem(item: MessageQueueDraftItem): MessageQueueItem {
|
|
435
|
+
return {
|
|
436
|
+
id: item.id?.trim() || queueId(),
|
|
437
|
+
message: item.message,
|
|
438
|
+
...(item.images
|
|
439
|
+
? { images: item.images.map((image) => ({ ...image })) }
|
|
440
|
+
: {}),
|
|
441
|
+
createdAt: item.createdAt ?? Date.now(),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function itemsFromTexts(
|
|
446
|
+
texts: readonly string[],
|
|
447
|
+
previous: MessageQueueItem[],
|
|
448
|
+
): MessageQueueItem[] {
|
|
449
|
+
const used = new Set<number>();
|
|
450
|
+
return texts.map((message) => {
|
|
451
|
+
const previousIndex = previous.findIndex(
|
|
452
|
+
(item, index) => !used.has(index) && item.message === message,
|
|
453
|
+
);
|
|
454
|
+
if (previousIndex !== -1) {
|
|
455
|
+
used.add(previousIndex);
|
|
456
|
+
return cloneQueueItem(previous[previousIndex]!);
|
|
457
|
+
}
|
|
458
|
+
return draftToItem({ message });
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function queueTextMatches(
|
|
463
|
+
queue: MessageQueueState,
|
|
464
|
+
steering: readonly string[],
|
|
465
|
+
followUp: readonly string[],
|
|
466
|
+
): boolean {
|
|
467
|
+
const messagesMatch = (items: MessageQueueItem[], texts: readonly string[]) =>
|
|
468
|
+
items.length === texts.length &&
|
|
469
|
+
items.every((item, index) => item.message === texts[index]);
|
|
470
|
+
return (
|
|
471
|
+
messagesMatch(queue.steering, steering) &&
|
|
472
|
+
messagesMatch(queue.followUp, followUp)
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
type RuntimeQueueSnapshot = {
|
|
477
|
+
steering: readonly string[];
|
|
478
|
+
followUp: readonly string[];
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
type ProjectionChange = {
|
|
482
|
+
changed: boolean;
|
|
483
|
+
queue: MessageQueueState;
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
type ProjectionStartedItem = {
|
|
487
|
+
kind: "steer" | "follow_up";
|
|
488
|
+
item: MessageQueueItem;
|
|
489
|
+
queueVersion: number;
|
|
490
|
+
queue: MessageQueueState;
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
export class MirrorQueueProjection {
|
|
494
|
+
private queue: MessageQueueState;
|
|
495
|
+
|
|
496
|
+
constructor(
|
|
497
|
+
initialQueue: MessageQueueState = {
|
|
498
|
+
version: 0,
|
|
499
|
+
steering: [],
|
|
500
|
+
followUp: [],
|
|
501
|
+
},
|
|
502
|
+
) {
|
|
503
|
+
this.queue = cloneQueueState(initialQueue);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
snapshot(): MessageQueueState {
|
|
507
|
+
return cloneQueueState(this.queue);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
pendingCount(): number {
|
|
511
|
+
return this.queue.steering.length + this.queue.followUp.length;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
reconcileRuntimeSnapshot(snapshot: RuntimeQueueSnapshot): ProjectionChange {
|
|
515
|
+
if (queueTextMatches(this.queue, snapshot.steering, snapshot.followUp)) {
|
|
516
|
+
return { changed: false, queue: this.snapshot() };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
this.queue = {
|
|
520
|
+
version: this.queue.version + 1,
|
|
521
|
+
steering: itemsFromTexts(snapshot.steering, this.queue.steering),
|
|
522
|
+
followUp: itemsFromTexts(snapshot.followUp, this.queue.followUp),
|
|
523
|
+
};
|
|
524
|
+
return { changed: true, queue: this.snapshot() };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
replace(nextQueue: MessageQueueState): ProjectionChange {
|
|
528
|
+
const changed = JSON.stringify(this.queue) !== JSON.stringify(nextQueue);
|
|
529
|
+
this.queue = cloneQueueState(nextQueue);
|
|
530
|
+
return { changed, queue: this.snapshot() };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
clear(): ProjectionChange {
|
|
534
|
+
if (this.queue.steering.length === 0 && this.queue.followUp.length === 0) {
|
|
535
|
+
return { changed: false, queue: this.snapshot() };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
this.queue = {
|
|
539
|
+
version: this.queue.version + 1,
|
|
540
|
+
steering: [],
|
|
541
|
+
followUp: [],
|
|
542
|
+
};
|
|
543
|
+
return { changed: true, queue: this.snapshot() };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
queueFromDrafts(
|
|
547
|
+
baseVersion: number,
|
|
548
|
+
steering: MessageQueueDraftItem[],
|
|
549
|
+
followUp: MessageQueueDraftItem[],
|
|
550
|
+
): MessageQueueState {
|
|
551
|
+
return {
|
|
552
|
+
version:
|
|
553
|
+
Math.max(
|
|
554
|
+
this.queue.version,
|
|
555
|
+
Number.isFinite(baseVersion) ? baseVersion : 0,
|
|
556
|
+
) + 1,
|
|
557
|
+
steering: steering.map(draftToItem),
|
|
558
|
+
followUp: followUp.map(draftToItem),
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
enqueueOptimistic(
|
|
563
|
+
kind: "steer" | "follow_up",
|
|
564
|
+
message: string,
|
|
565
|
+
images?: QueueImageContent[],
|
|
566
|
+
options: { previousMatchingCount?: number } = {},
|
|
567
|
+
): ProjectionChange {
|
|
568
|
+
const item: MessageQueueItem = {
|
|
569
|
+
id: queueId(),
|
|
570
|
+
message,
|
|
571
|
+
...(images?.length
|
|
572
|
+
? { images: images.map((image) => ({ ...image })) }
|
|
573
|
+
: {}),
|
|
574
|
+
createdAt: Date.now(),
|
|
575
|
+
};
|
|
576
|
+
const list = kind === "steer" ? this.queue.steering : this.queue.followUp;
|
|
577
|
+
const matchingItems = list.filter(
|
|
578
|
+
(candidate) => candidate.message === message,
|
|
579
|
+
);
|
|
580
|
+
const runtimeUpdateAlreadyAddedItem =
|
|
581
|
+
options.previousMatchingCount !== undefined &&
|
|
582
|
+
matchingItems.length > options.previousMatchingCount;
|
|
583
|
+
|
|
584
|
+
if (runtimeUpdateAlreadyAddedItem) {
|
|
585
|
+
const existing = matchingItems.at(-1);
|
|
586
|
+
if (existing && images?.length && !existing.images?.length) {
|
|
587
|
+
existing.images = images.map((image) => ({ ...image }));
|
|
588
|
+
this.queue.version += 1;
|
|
589
|
+
return { changed: true, queue: this.snapshot() };
|
|
590
|
+
}
|
|
591
|
+
return { changed: false, queue: this.snapshot() };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
list.push(item);
|
|
595
|
+
this.queue.version += 1;
|
|
596
|
+
return { changed: true, queue: this.snapshot() };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
markStarted(message: string | undefined): ProjectionStartedItem | null {
|
|
600
|
+
const normalized = message?.trim();
|
|
601
|
+
if (!normalized) return null;
|
|
602
|
+
|
|
603
|
+
const dequeue = (
|
|
604
|
+
kind: "steer" | "follow_up",
|
|
605
|
+
list: MessageQueueItem[],
|
|
606
|
+
): ProjectionStartedItem | null => {
|
|
607
|
+
const index = list.findIndex(
|
|
608
|
+
(item) => item.message.trim() === normalized,
|
|
609
|
+
);
|
|
610
|
+
if (index === -1) return null;
|
|
611
|
+
const [item] = list.splice(index, 1);
|
|
612
|
+
if (!item) return null;
|
|
613
|
+
this.queue.version += 1;
|
|
614
|
+
return {
|
|
615
|
+
kind,
|
|
616
|
+
item: cloneQueueItem(item),
|
|
617
|
+
queueVersion: this.queue.version,
|
|
618
|
+
queue: this.snapshot(),
|
|
619
|
+
};
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
return (
|
|
623
|
+
dequeue("steer", this.queue.steering) ??
|
|
624
|
+
dequeue("follow_up", this.queue.followUp)
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function getQueueUpdateBridge(): QueueUpdateBridge {
|
|
630
|
+
const globalRecord = globalThis as typeof globalThis & {
|
|
631
|
+
[QUEUE_UPDATE_BRIDGE_KEY]?: QueueUpdateBridge;
|
|
632
|
+
};
|
|
633
|
+
globalRecord[QUEUE_UPDATE_BRIDGE_KEY] ??= {
|
|
634
|
+
listeners: new Set<QueueUpdateListener>(),
|
|
635
|
+
internalEventListeners: new Set<InternalAgentSessionEventListener>(),
|
|
636
|
+
sessions: new Map<string, EditableAgentSession>(),
|
|
637
|
+
installed: false,
|
|
638
|
+
};
|
|
639
|
+
const bridge = globalRecord[QUEUE_UPDATE_BRIDGE_KEY];
|
|
640
|
+
bridge.internalEventListeners ??=
|
|
641
|
+
new Set<InternalAgentSessionEventListener>();
|
|
642
|
+
return bridge;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function sessionIdFromAgentSession(
|
|
646
|
+
session: EditableAgentSession,
|
|
647
|
+
): string | undefined {
|
|
648
|
+
return session.sessionId ?? session.sessionManager?.getSessionId?.();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function rememberAgentSession(
|
|
652
|
+
bridge: QueueUpdateBridge,
|
|
653
|
+
session: EditableAgentSession,
|
|
654
|
+
): string | undefined {
|
|
655
|
+
bridge.lastSession = session;
|
|
656
|
+
const sessionId = sessionIdFromAgentSession(session);
|
|
657
|
+
if (sessionId) bridge.sessions.set(sessionId, session);
|
|
658
|
+
return sessionId;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function isInternalAgentSessionEvent(
|
|
662
|
+
event: unknown,
|
|
663
|
+
): event is InternalAgentSessionEvent {
|
|
664
|
+
if (!event || typeof event !== "object") return false;
|
|
665
|
+
const type = (event as { type?: AgentSessionEvent["type"] }).type;
|
|
666
|
+
return type !== undefined && INTERNAL_AGENT_SESSION_EVENT_TYPES.has(type);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function userMessageFromQueueItem(item: MessageQueueItem) {
|
|
670
|
+
const text = item.message || "(empty queued message)";
|
|
671
|
+
return {
|
|
672
|
+
role: "user" as const,
|
|
673
|
+
content: [
|
|
674
|
+
{ type: "text" as const, text },
|
|
675
|
+
...(item.images ?? []).map((image) => ({
|
|
676
|
+
type: "image" as const,
|
|
677
|
+
data: image.data,
|
|
678
|
+
mimeType: image.mimeType,
|
|
679
|
+
})),
|
|
680
|
+
],
|
|
681
|
+
timestamp: item.createdAt || Date.now(),
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function clearAgentSessionQueue(session: EditableAgentSession) {
|
|
686
|
+
const agent = session.agent;
|
|
687
|
+
const canClear =
|
|
688
|
+
agent?.clearAllQueues ||
|
|
689
|
+
(agent?.clearSteeringQueue && agent.clearFollowUpQueue);
|
|
690
|
+
if (!agent || !canClear) {
|
|
691
|
+
throw new Error("Terminal Pi runtime queue is not editable");
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (agent.clearAllQueues) {
|
|
695
|
+
agent.clearAllQueues();
|
|
696
|
+
} else {
|
|
697
|
+
agent.clearSteeringQueue?.();
|
|
698
|
+
agent.clearFollowUpQueue?.();
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
session._steeringMessages = [];
|
|
702
|
+
session._followUpMessages = [];
|
|
703
|
+
session._emitQueueUpdate?.();
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function replaceAgentSessionQueue(
|
|
707
|
+
session: EditableAgentSession,
|
|
708
|
+
nextQueue: MessageQueueState,
|
|
709
|
+
) {
|
|
710
|
+
const agent = session.agent;
|
|
711
|
+
if (!agent?.steer || !agent.followUp) {
|
|
712
|
+
throw new Error("Terminal Pi runtime queue is not editable");
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
clearAgentSessionQueue(session);
|
|
716
|
+
|
|
717
|
+
session._steeringMessages = nextQueue.steering.map((item) => item.message);
|
|
718
|
+
session._followUpMessages = nextQueue.followUp.map((item) => item.message);
|
|
719
|
+
for (const item of nextQueue.steering) {
|
|
720
|
+
agent.steer(userMessageFromQueueItem(item));
|
|
721
|
+
}
|
|
722
|
+
for (const item of nextQueue.followUp) {
|
|
723
|
+
agent.followUp(userMessageFromQueueItem(item));
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
session._emitQueueUpdate?.();
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function installQueueUpdateBridge(prototype: AgentSessionPrototype) {
|
|
730
|
+
const bridge = getQueueUpdateBridge();
|
|
731
|
+
|
|
732
|
+
// Pi updates the TUI queue through AgentSession listeners, but does not yet
|
|
733
|
+
// expose queue_update as an extension event. Patch the internal emitter so
|
|
734
|
+
// terminal-origin follow-up/steering edits reach the Oppi mirror immediately.
|
|
735
|
+
|
|
736
|
+
if (!bridge.installed) {
|
|
737
|
+
const originalBindExtensions = prototype.bindExtensions;
|
|
738
|
+
if (typeof originalBindExtensions === "function") {
|
|
739
|
+
prototype.bindExtensions = function patchedBindExtensions(
|
|
740
|
+
this: unknown,
|
|
741
|
+
...args: unknown[]
|
|
742
|
+
) {
|
|
743
|
+
rememberAgentSession(bridge, this as EditableAgentSession);
|
|
744
|
+
return originalBindExtensions.apply(this, args);
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const original = prototype._emitQueueUpdate;
|
|
749
|
+
if (typeof original === "function") {
|
|
750
|
+
prototype._emitQueueUpdate = function patchedEmitQueueUpdate(
|
|
751
|
+
this: unknown,
|
|
752
|
+
...args: unknown[]
|
|
753
|
+
) {
|
|
754
|
+
const result = original.apply(this, args);
|
|
755
|
+
const record = this as EditableAgentSession & {
|
|
756
|
+
getSteeringMessages?: () => readonly string[];
|
|
757
|
+
getFollowUpMessages?: () => readonly string[];
|
|
758
|
+
};
|
|
759
|
+
const steering = Array.from(
|
|
760
|
+
record.getSteeringMessages?.() ?? record._steeringMessages ?? [],
|
|
761
|
+
);
|
|
762
|
+
const followUp = Array.from(
|
|
763
|
+
record.getFollowUpMessages?.() ?? record._followUpMessages ?? [],
|
|
764
|
+
);
|
|
765
|
+
const sessionId = rememberAgentSession(bridge, record);
|
|
766
|
+
const event = { steering, followUp, session: record, sessionId };
|
|
767
|
+
bridge.last = event;
|
|
768
|
+
for (const listener of bridge.listeners) {
|
|
769
|
+
try {
|
|
770
|
+
listener(event);
|
|
771
|
+
} catch (error) {
|
|
772
|
+
writeMirrorLog("warn", "queue_update_listener_failed", { error });
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
return result;
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
bridge.installed = true;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
if (!bridge.internalEventInstalled) {
|
|
782
|
+
const originalEmit = prototype._emit;
|
|
783
|
+
if (typeof originalEmit === "function") {
|
|
784
|
+
prototype._emit = function patchedEmit(
|
|
785
|
+
this: unknown,
|
|
786
|
+
event: unknown,
|
|
787
|
+
...args: unknown[]
|
|
788
|
+
) {
|
|
789
|
+
const result = originalEmit.apply(this, [event, ...args]);
|
|
790
|
+
if (isInternalAgentSessionEvent(event)) {
|
|
791
|
+
rememberAgentSession(bridge, this as EditableAgentSession);
|
|
792
|
+
for (const listener of bridge.internalEventListeners) {
|
|
793
|
+
try {
|
|
794
|
+
listener(event);
|
|
795
|
+
} catch (error) {
|
|
796
|
+
writeMirrorLog("warn", "internal_event_listener_failed", {
|
|
797
|
+
error,
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return result;
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
bridge.internalEventInstalled = true;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
return bridge;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function textFromUserMessage(message: unknown): string | undefined {
|
|
812
|
+
if (!message || typeof message !== "object") return undefined;
|
|
813
|
+
const content = (message as { content?: unknown }).content;
|
|
814
|
+
if (typeof content === "string") return content;
|
|
815
|
+
if (!Array.isArray(content)) return undefined;
|
|
816
|
+
return content
|
|
817
|
+
.map((block) => {
|
|
818
|
+
if (!block || typeof block !== "object") return "";
|
|
819
|
+
const item = block as { type?: unknown; text?: unknown };
|
|
820
|
+
return item.type === "text" && typeof item.text === "string"
|
|
821
|
+
? item.text
|
|
822
|
+
: "";
|
|
823
|
+
})
|
|
824
|
+
.join("")
|
|
825
|
+
.trim();
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const MAX_TREE_TEXT_PREVIEW_CHARS = 160;
|
|
829
|
+
const TREE_DEFAULT_HIDDEN_ENTRY_TYPES = new Set([
|
|
830
|
+
"label",
|
|
831
|
+
"custom",
|
|
832
|
+
"model_change",
|
|
833
|
+
"thinking_level_change",
|
|
834
|
+
"session_info",
|
|
835
|
+
]);
|
|
836
|
+
|
|
837
|
+
function toRecord(value: unknown): Record<string, unknown> {
|
|
838
|
+
return value && typeof value === "object"
|
|
839
|
+
? (value as Record<string, unknown>)
|
|
840
|
+
: {};
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function readSessionTreeFilterMode(value: unknown): SessionTreeFilterMode {
|
|
844
|
+
return value === "no-tools" ||
|
|
845
|
+
value === "user-only" ||
|
|
846
|
+
value === "labeled-only" ||
|
|
847
|
+
value === "all"
|
|
848
|
+
? value
|
|
849
|
+
: "default";
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function compareTreeNodesByTimestamp(
|
|
853
|
+
left: MirrorSessionTreeNode,
|
|
854
|
+
right: MirrorSessionTreeNode,
|
|
855
|
+
): number {
|
|
856
|
+
const leftTime = Date.parse(left.entry.timestamp ?? "");
|
|
857
|
+
const rightTime = Date.parse(right.entry.timestamp ?? "");
|
|
858
|
+
|
|
859
|
+
if (
|
|
860
|
+
!Number.isNaN(leftTime) &&
|
|
861
|
+
!Number.isNaN(rightTime) &&
|
|
862
|
+
leftTime !== rightTime
|
|
863
|
+
) {
|
|
864
|
+
return leftTime - rightTime;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const leftTimestamp = left.entry.timestamp ?? "";
|
|
868
|
+
const rightTimestamp = right.entry.timestamp ?? "";
|
|
869
|
+
if (leftTimestamp !== rightTimestamp) {
|
|
870
|
+
return leftTimestamp.localeCompare(rightTimestamp);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
return left.entry.id.localeCompare(right.entry.id);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function sortTreeNodes(
|
|
877
|
+
nodes: MirrorSessionTreeNode[],
|
|
878
|
+
leafPathIds: Set<string>,
|
|
879
|
+
): MirrorSessionTreeNode[] {
|
|
880
|
+
return [...nodes].sort((left, right) => {
|
|
881
|
+
const leftOnActivePath = leafPathIds.has(left.entry.id) ? 1 : 0;
|
|
882
|
+
const rightOnActivePath = leafPathIds.has(right.entry.id) ? 1 : 0;
|
|
883
|
+
|
|
884
|
+
if (leftOnActivePath !== rightOnActivePath) {
|
|
885
|
+
return rightOnActivePath - leftOnActivePath;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return compareTreeNodesByTimestamp(left, right);
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function collectLeafPathIds(
|
|
893
|
+
manager: MirrorSessionTreeManager,
|
|
894
|
+
leafId: string | null,
|
|
895
|
+
): Set<string> {
|
|
896
|
+
const pathIds = new Set<string>();
|
|
897
|
+
let currentId = leafId;
|
|
898
|
+
|
|
899
|
+
while (currentId) {
|
|
900
|
+
if (pathIds.has(currentId)) break;
|
|
901
|
+
pathIds.add(currentId);
|
|
902
|
+
const entry = manager.getEntry(currentId);
|
|
903
|
+
currentId = entry?.parentId ?? null;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
return pathIds;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function previewText(rawText: string): string | undefined {
|
|
910
|
+
const normalized = rawText.replace(/\s+/g, " ").trim();
|
|
911
|
+
if (normalized.length === 0) return undefined;
|
|
912
|
+
if (normalized.length <= MAX_TREE_TEXT_PREVIEW_CHARS) return normalized;
|
|
913
|
+
return `${normalized.slice(0, MAX_TREE_TEXT_PREVIEW_CHARS - 1)}…`;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function extractDisplayTextFromMessageContent(content: unknown): string {
|
|
917
|
+
if (typeof content === "string") return content;
|
|
918
|
+
if (!Array.isArray(content)) return "";
|
|
919
|
+
|
|
920
|
+
const parts: string[] = [];
|
|
921
|
+
for (const block of content) {
|
|
922
|
+
const record = toRecord(block);
|
|
923
|
+
if (
|
|
924
|
+
(record.type === "text" || record.type === "output_text") &&
|
|
925
|
+
typeof record.text === "string"
|
|
926
|
+
) {
|
|
927
|
+
parts.push(record.text);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return parts.join(" ");
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function hasDisplayTextContent(content: unknown): boolean {
|
|
934
|
+
return (
|
|
935
|
+
previewText(extractDisplayTextFromMessageContent(content)) !== undefined
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
function shortenTreePath(path: string): string {
|
|
940
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
941
|
+
return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function formatTreeToolCall(
|
|
945
|
+
name: string,
|
|
946
|
+
args: Record<string, unknown>,
|
|
947
|
+
): string {
|
|
948
|
+
switch (name) {
|
|
949
|
+
case "read": {
|
|
950
|
+
const path = shortenTreePath(String(args.path || ""));
|
|
951
|
+
const offset = args.offset;
|
|
952
|
+
const limit = args.limit;
|
|
953
|
+
let display = path;
|
|
954
|
+
if (offset !== undefined || limit !== undefined) {
|
|
955
|
+
const start = typeof offset === "number" ? offset : 1;
|
|
956
|
+
const limitNumber = typeof limit === "number" ? limit : undefined;
|
|
957
|
+
const end = limitNumber !== undefined ? start + limitNumber - 1 : "";
|
|
958
|
+
display += `:${start}${end ? `-${end}` : ""}`;
|
|
959
|
+
}
|
|
960
|
+
return `[read: ${display}]`;
|
|
961
|
+
}
|
|
962
|
+
case "write":
|
|
963
|
+
return `[write: ${shortenTreePath(String(args.path || ""))}]`;
|
|
964
|
+
case "edit":
|
|
965
|
+
return `[edit: ${shortenTreePath(String(args.path || ""))}]`;
|
|
966
|
+
case "bash": {
|
|
967
|
+
const rawCommand = String(args.command || "");
|
|
968
|
+
const command = rawCommand
|
|
969
|
+
.replace(/[\n\t]/g, " ")
|
|
970
|
+
.trim()
|
|
971
|
+
.slice(0, 50);
|
|
972
|
+
return `[bash: ${command}${rawCommand.length > 50 ? "..." : ""}]`;
|
|
973
|
+
}
|
|
974
|
+
case "grep":
|
|
975
|
+
return `[grep: /${String(args.pattern || "")}/ in ${shortenTreePath(String(args.path || "."))}]`;
|
|
976
|
+
case "find":
|
|
977
|
+
return `[find: ${String(args.pattern || "")} in ${shortenTreePath(String(args.path || "."))}]`;
|
|
978
|
+
case "ls":
|
|
979
|
+
return `[ls: ${shortenTreePath(String(args.path || "."))}]`;
|
|
980
|
+
default: {
|
|
981
|
+
const argsJson = JSON.stringify(args);
|
|
982
|
+
const preview = argsJson.slice(0, 40);
|
|
983
|
+
return `[${name}: ${preview}${argsJson.length > 40 ? "..." : ""}]`;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function collectTreeToolCalls(
|
|
989
|
+
tree: MirrorSessionTreeNode[],
|
|
990
|
+
): Map<string, { name: string; arguments: Record<string, unknown> }> {
|
|
991
|
+
const toolCalls = new Map<
|
|
992
|
+
string,
|
|
993
|
+
{ name: string; arguments: Record<string, unknown> }
|
|
994
|
+
>();
|
|
995
|
+
const stack = [...tree];
|
|
996
|
+
|
|
997
|
+
while (stack.length > 0) {
|
|
998
|
+
const current = stack.pop();
|
|
999
|
+
if (!current) continue;
|
|
1000
|
+
|
|
1001
|
+
if (current.entry.type === "message") {
|
|
1002
|
+
const message = toRecord(current.entry.message);
|
|
1003
|
+
if (message.role === "assistant" && Array.isArray(message.content)) {
|
|
1004
|
+
for (const block of message.content) {
|
|
1005
|
+
const record = toRecord(block);
|
|
1006
|
+
if (
|
|
1007
|
+
record.type === "toolCall" &&
|
|
1008
|
+
typeof record.id === "string" &&
|
|
1009
|
+
typeof record.name === "string"
|
|
1010
|
+
) {
|
|
1011
|
+
toolCalls.set(record.id, {
|
|
1012
|
+
name: record.name,
|
|
1013
|
+
arguments: toRecord(record.arguments),
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
for (const child of current.children) stack.push(child);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
return toolCalls;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function isTreeEntryEligibleForFilters(
|
|
1027
|
+
entry: MirrorSessionTreeEntry,
|
|
1028
|
+
leafId: string | null,
|
|
1029
|
+
): boolean {
|
|
1030
|
+
if (entry.type !== "message" || entry.id === leafId) return true;
|
|
1031
|
+
|
|
1032
|
+
const message = toRecord(entry.message);
|
|
1033
|
+
if (message.role !== "assistant") return true;
|
|
1034
|
+
|
|
1035
|
+
const hasText = hasDisplayTextContent(message.content);
|
|
1036
|
+
const stopReason =
|
|
1037
|
+
typeof message.stopReason === "string" ? message.stopReason : undefined;
|
|
1038
|
+
const isErrorOrAborted =
|
|
1039
|
+
stopReason !== undefined &&
|
|
1040
|
+
stopReason !== "stop" &&
|
|
1041
|
+
stopReason !== "toolUse";
|
|
1042
|
+
|
|
1043
|
+
return hasText || isErrorOrAborted;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function isTreeEntryVisibleByDefault(
|
|
1047
|
+
entry: MirrorSessionTreeEntry,
|
|
1048
|
+
leafId: string | null,
|
|
1049
|
+
): boolean {
|
|
1050
|
+
return (
|
|
1051
|
+
isTreeEntryEligibleForFilters(entry, leafId) &&
|
|
1052
|
+
!TREE_DEFAULT_HIDDEN_ENTRY_TYPES.has(entry.type)
|
|
1053
|
+
);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function matchesSessionTreeFilter(
|
|
1057
|
+
node: MirrorSessionTreeNode,
|
|
1058
|
+
filterMode: SessionTreeFilterMode,
|
|
1059
|
+
leafId: string | null,
|
|
1060
|
+
): boolean {
|
|
1061
|
+
if (!isTreeEntryEligibleForFilters(node.entry, leafId)) return false;
|
|
1062
|
+
|
|
1063
|
+
const entry = node.entry;
|
|
1064
|
+
const isSettingsEntry = TREE_DEFAULT_HIDDEN_ENTRY_TYPES.has(entry.type);
|
|
1065
|
+
|
|
1066
|
+
switch (filterMode) {
|
|
1067
|
+
case "user-only":
|
|
1068
|
+
return (
|
|
1069
|
+
entry.type === "message" && toRecord(entry.message).role === "user"
|
|
1070
|
+
);
|
|
1071
|
+
case "no-tools":
|
|
1072
|
+
return (
|
|
1073
|
+
!isSettingsEntry &&
|
|
1074
|
+
!(
|
|
1075
|
+
entry.type === "message" &&
|
|
1076
|
+
toRecord(entry.message).role === "toolResult"
|
|
1077
|
+
)
|
|
1078
|
+
);
|
|
1079
|
+
case "labeled-only":
|
|
1080
|
+
return node.label !== undefined;
|
|
1081
|
+
case "all":
|
|
1082
|
+
return true;
|
|
1083
|
+
case "default":
|
|
1084
|
+
default:
|
|
1085
|
+
return !isSettingsEntry;
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function extractTreeNodeSnapshot(
|
|
1090
|
+
entry: MirrorSessionTreeEntry,
|
|
1091
|
+
toolCalls: Map<string, { name: string; arguments: Record<string, unknown> }>,
|
|
1092
|
+
leafId: string | null,
|
|
1093
|
+
): { defaultVisible: boolean; role?: string; textPreview?: string } {
|
|
1094
|
+
const defaultVisible = isTreeEntryVisibleByDefault(entry, leafId);
|
|
1095
|
+
|
|
1096
|
+
switch (entry.type) {
|
|
1097
|
+
case "message": {
|
|
1098
|
+
const message = toRecord(entry.message);
|
|
1099
|
+
const role = typeof message.role === "string" ? message.role : undefined;
|
|
1100
|
+
let textPreview: string | undefined;
|
|
1101
|
+
|
|
1102
|
+
switch (role) {
|
|
1103
|
+
case "toolResult": {
|
|
1104
|
+
const toolCallId =
|
|
1105
|
+
typeof message.toolCallId === "string"
|
|
1106
|
+
? message.toolCallId
|
|
1107
|
+
: undefined;
|
|
1108
|
+
const toolCall = toolCallId ? toolCalls.get(toolCallId) : undefined;
|
|
1109
|
+
textPreview = toolCall
|
|
1110
|
+
? formatTreeToolCall(toolCall.name, toolCall.arguments)
|
|
1111
|
+
: typeof message.toolName === "string"
|
|
1112
|
+
? `[${message.toolName}]`
|
|
1113
|
+
: undefined;
|
|
1114
|
+
break;
|
|
1115
|
+
}
|
|
1116
|
+
case "bashExecution":
|
|
1117
|
+
textPreview = previewText(String(message.command || ""));
|
|
1118
|
+
break;
|
|
1119
|
+
default:
|
|
1120
|
+
textPreview = previewText(
|
|
1121
|
+
extractDisplayTextFromMessageContent(message.content),
|
|
1122
|
+
);
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
return {
|
|
1127
|
+
defaultVisible,
|
|
1128
|
+
...(role ? { role } : {}),
|
|
1129
|
+
...(textPreview ? { textPreview } : {}),
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
case "compaction":
|
|
1133
|
+
return {
|
|
1134
|
+
defaultVisible,
|
|
1135
|
+
textPreview:
|
|
1136
|
+
typeof entry.tokensBefore === "number"
|
|
1137
|
+
? `${Math.round(entry.tokensBefore / 1000)}k tokens`
|
|
1138
|
+
: undefined,
|
|
1139
|
+
};
|
|
1140
|
+
case "branch_summary":
|
|
1141
|
+
return {
|
|
1142
|
+
defaultVisible,
|
|
1143
|
+
...(previewText(String(entry.summary || ""))
|
|
1144
|
+
? { textPreview: previewText(String(entry.summary || "")) }
|
|
1145
|
+
: {}),
|
|
1146
|
+
};
|
|
1147
|
+
case "custom_message": {
|
|
1148
|
+
const rawContent =
|
|
1149
|
+
typeof entry.content === "string"
|
|
1150
|
+
? entry.content
|
|
1151
|
+
: extractDisplayTextFromMessageContent(entry.content);
|
|
1152
|
+
const textPreview = previewText(rawContent);
|
|
1153
|
+
return { defaultVisible, ...(textPreview ? { textPreview } : {}) };
|
|
1154
|
+
}
|
|
1155
|
+
case "session_info":
|
|
1156
|
+
return {
|
|
1157
|
+
defaultVisible,
|
|
1158
|
+
...(previewText(String(entry.name || ""))
|
|
1159
|
+
? { textPreview: previewText(String(entry.name || "")) }
|
|
1160
|
+
: {}),
|
|
1161
|
+
};
|
|
1162
|
+
case "model_change":
|
|
1163
|
+
return {
|
|
1164
|
+
defaultVisible,
|
|
1165
|
+
...(previewText(String(entry.modelId || ""))
|
|
1166
|
+
? { textPreview: previewText(String(entry.modelId || "")) }
|
|
1167
|
+
: {}),
|
|
1168
|
+
};
|
|
1169
|
+
case "thinking_level_change":
|
|
1170
|
+
return {
|
|
1171
|
+
defaultVisible,
|
|
1172
|
+
...(previewText(String(entry.thinkingLevel || ""))
|
|
1173
|
+
? { textPreview: previewText(String(entry.thinkingLevel || "")) }
|
|
1174
|
+
: {}),
|
|
1175
|
+
};
|
|
1176
|
+
case "label":
|
|
1177
|
+
return {
|
|
1178
|
+
defaultVisible,
|
|
1179
|
+
...(previewText(String(entry.label || ""))
|
|
1180
|
+
? { textPreview: previewText(String(entry.label || "")) }
|
|
1181
|
+
: {}),
|
|
1182
|
+
};
|
|
1183
|
+
case "custom":
|
|
1184
|
+
return {
|
|
1185
|
+
defaultVisible,
|
|
1186
|
+
...(previewText(String(entry.customType || ""))
|
|
1187
|
+
? { textPreview: previewText(String(entry.customType || "")) }
|
|
1188
|
+
: {}),
|
|
1189
|
+
};
|
|
1190
|
+
default:
|
|
1191
|
+
return { defaultVisible };
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
export function serializeSessionTree(
|
|
1196
|
+
manager: MirrorSessionTreeManager,
|
|
1197
|
+
filterMode: SessionTreeFilterMode,
|
|
1198
|
+
): { leafId: string | null; nodes: SessionTreeNodeSnapshot[] } {
|
|
1199
|
+
const tree = manager.getTree();
|
|
1200
|
+
const leafId = manager.getLeafId();
|
|
1201
|
+
const leafPathIds = collectLeafPathIds(manager, leafId);
|
|
1202
|
+
const toolCalls = collectTreeToolCalls(tree);
|
|
1203
|
+
const nodes: SessionTreeNodeSnapshot[] = [];
|
|
1204
|
+
const stack = sortTreeNodes(tree, leafPathIds)
|
|
1205
|
+
.reverse()
|
|
1206
|
+
.map((node) => ({ node, depth: 0 }));
|
|
1207
|
+
|
|
1208
|
+
while (stack.length > 0) {
|
|
1209
|
+
const current = stack.pop();
|
|
1210
|
+
if (!current) continue;
|
|
1211
|
+
|
|
1212
|
+
const extracted = extractTreeNodeSnapshot(
|
|
1213
|
+
current.node.entry,
|
|
1214
|
+
toolCalls,
|
|
1215
|
+
leafId,
|
|
1216
|
+
);
|
|
1217
|
+
nodes.push({
|
|
1218
|
+
id: current.node.entry.id,
|
|
1219
|
+
parentId: current.node.entry.parentId ?? null,
|
|
1220
|
+
type: current.node.entry.type,
|
|
1221
|
+
timestamp: current.node.entry.timestamp ?? "",
|
|
1222
|
+
depth: current.depth,
|
|
1223
|
+
isLeafPath: leafPathIds.has(current.node.entry.id),
|
|
1224
|
+
matchesFilter: matchesSessionTreeFilter(current.node, filterMode, leafId),
|
|
1225
|
+
...extracted,
|
|
1226
|
+
...(current.node.label ? { label: current.node.label } : {}),
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
const children = sortTreeNodes(current.node.children, leafPathIds);
|
|
1230
|
+
for (let i = children.length - 1; i >= 0; i -= 1) {
|
|
1231
|
+
const child = children[i];
|
|
1232
|
+
if (child) stack.push({ node: child, depth: current.depth + 1 });
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
return { leafId, nodes };
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function stateSnapshot(pi: ExtensionAPI, ctx: ExtensionContext) {
|
|
1240
|
+
return {
|
|
1241
|
+
cwd: ctx.cwd,
|
|
1242
|
+
sessionFile: ctx.sessionManager.getSessionFile(),
|
|
1243
|
+
piSessionId: ctx.sessionManager.getSessionId(),
|
|
1244
|
+
sessionName: ctx.sessionManager.getSessionName?.() ?? undefined,
|
|
1245
|
+
leafId: ctx.sessionManager.getLeafId(),
|
|
1246
|
+
model: modelWire(ctx),
|
|
1247
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
1248
|
+
isIdle: ctx.isIdle(),
|
|
1249
|
+
contextUsage: contextUsageWire(ctx),
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function commandError(message: string, id: string, error: unknown) {
|
|
1254
|
+
return {
|
|
1255
|
+
type: "command_result",
|
|
1256
|
+
id,
|
|
1257
|
+
success: false,
|
|
1258
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function commandLogDetails(
|
|
1263
|
+
command: Record<string, unknown>,
|
|
1264
|
+
): Record<string, unknown> {
|
|
1265
|
+
const images = Array.isArray(command.images) ? command.images : [];
|
|
1266
|
+
return {
|
|
1267
|
+
commandType: typeof command.type === "string" ? command.type : "unknown",
|
|
1268
|
+
requestId:
|
|
1269
|
+
typeof command.requestId === "string" ? command.requestId : undefined,
|
|
1270
|
+
clientTurnId:
|
|
1271
|
+
typeof command.clientTurnId === "string"
|
|
1272
|
+
? command.clientTurnId
|
|
1273
|
+
: undefined,
|
|
1274
|
+
messageChars:
|
|
1275
|
+
typeof command.message === "string" ? command.message.length : undefined,
|
|
1276
|
+
imageCount: images.length,
|
|
1277
|
+
streamingBehavior:
|
|
1278
|
+
typeof command.streamingBehavior === "string"
|
|
1279
|
+
? command.streamingBehavior
|
|
1280
|
+
: undefined,
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function imagesFromCommand(value: unknown): QueueImageContent[] {
|
|
1285
|
+
if (!Array.isArray(value)) return [];
|
|
1286
|
+
return value.flatMap((image) => {
|
|
1287
|
+
const item = image as { data?: unknown; mimeType?: unknown };
|
|
1288
|
+
return typeof item.data === "string"
|
|
1289
|
+
? [
|
|
1290
|
+
{
|
|
1291
|
+
data: item.data,
|
|
1292
|
+
mimeType:
|
|
1293
|
+
typeof item.mimeType === "string" ? item.mimeType : "image/png",
|
|
1294
|
+
},
|
|
1295
|
+
]
|
|
1296
|
+
: [];
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function contentForMessage(message: string, images: QueueImageContent[]) {
|
|
1301
|
+
if (!images.length) return message;
|
|
1302
|
+
return [
|
|
1303
|
+
{
|
|
1304
|
+
type: "text" as const,
|
|
1305
|
+
text: message || "(see attached image)",
|
|
1306
|
+
},
|
|
1307
|
+
...images.map((image) => ({
|
|
1308
|
+
type: "image" as const,
|
|
1309
|
+
data: image.data,
|
|
1310
|
+
mimeType: image.mimeType,
|
|
1311
|
+
})),
|
|
1312
|
+
];
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
type MirrorIndicatorMode =
|
|
1316
|
+
| "connecting"
|
|
1317
|
+
| "live"
|
|
1318
|
+
| "reconnecting"
|
|
1319
|
+
| "blocked"
|
|
1320
|
+
| "error";
|
|
1321
|
+
type MirrorIndicatorColor = "success" | "error" | "warning" | "muted";
|
|
1322
|
+
|
|
1323
|
+
type MirrorExtensionUIMethod = "select" | "confirm" | "input" | "editor";
|
|
1324
|
+
|
|
1325
|
+
interface MirrorExtensionUIResponse {
|
|
1326
|
+
type: "extension_ui_response";
|
|
1327
|
+
id: string;
|
|
1328
|
+
value?: string;
|
|
1329
|
+
confirmed?: boolean;
|
|
1330
|
+
cancelled?: boolean;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
interface PendingMirrorUIResponse<T> {
|
|
1334
|
+
resolve: (value: T) => void;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
const DEFAULT_RECONNECT_DELAY_MS = 2_000;
|
|
1338
|
+
const OPPI_RUNTIME_CONFLICT_NOTIFY_INTERVAL_MS = 60_000;
|
|
1339
|
+
|
|
1340
|
+
export default async function oppiPiMirror(pi: ExtensionAPI) {
|
|
1341
|
+
let latestCtx: ExtensionContext | null = null;
|
|
1342
|
+
let ws: WebSocket | null = null;
|
|
1343
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1344
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
1345
|
+
let indicatorMode: MirrorIndicatorMode | null = null;
|
|
1346
|
+
let indicatorLabel: string | undefined;
|
|
1347
|
+
let indicatorWidgetMounted = false;
|
|
1348
|
+
let requestIndicatorRender: (() => void) | null = null;
|
|
1349
|
+
let manualStop = false;
|
|
1350
|
+
const bridgeId = `pi-tui-${process.pid}`;
|
|
1351
|
+
let connectedSessionId: string | null = null;
|
|
1352
|
+
let connectedWorkspaceId: string | null = null;
|
|
1353
|
+
const queueProjection = new MirrorQueueProjection();
|
|
1354
|
+
const pendingUIResponses = new Map<
|
|
1355
|
+
string,
|
|
1356
|
+
PendingMirrorUIResponse<unknown>
|
|
1357
|
+
>();
|
|
1358
|
+
const proxiedUIContexts = new WeakSet<object>();
|
|
1359
|
+
let suppressUIForwarding = false;
|
|
1360
|
+
let runtimeActive = true;
|
|
1361
|
+
let connectionSerial = 0;
|
|
1362
|
+
let nextReconnectDelayMs = DEFAULT_RECONNECT_DELAY_MS;
|
|
1363
|
+
let lastOppiRuntimeConflictSessionId: string | null = null;
|
|
1364
|
+
let lastOppiRuntimeConflictNotifiedAt = 0;
|
|
1365
|
+
|
|
1366
|
+
let settings = loadSettings();
|
|
1367
|
+
|
|
1368
|
+
const queueUpdateBridge = await (async () => {
|
|
1369
|
+
try {
|
|
1370
|
+
const { AgentSession } = await import("@earendil-works/pi-coding-agent");
|
|
1371
|
+
return installQueueUpdateBridge(AgentSession.prototype as AgentSessionPrototype);
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
writeMirrorLog("error", "queue_update_bridge_install_failed", { error });
|
|
1374
|
+
return getQueueUpdateBridge();
|
|
1375
|
+
}
|
|
1376
|
+
})();
|
|
1377
|
+
const queueUpdateListener: QueueUpdateListener = ({ steering, followUp }) => {
|
|
1378
|
+
publishQueueIfChanged(steering, followUp);
|
|
1379
|
+
};
|
|
1380
|
+
const internalAgentEventListener: InternalAgentSessionEventListener = (
|
|
1381
|
+
event,
|
|
1382
|
+
) => {
|
|
1383
|
+
const includeState =
|
|
1384
|
+
event.type === "compaction_end" || event.type === "auto_retry_end";
|
|
1385
|
+
send({
|
|
1386
|
+
type: "event",
|
|
1387
|
+
event,
|
|
1388
|
+
...(includeState && latestCtx
|
|
1389
|
+
? { state: stateSnapshot(pi, latestCtx) }
|
|
1390
|
+
: {}),
|
|
1391
|
+
});
|
|
1392
|
+
};
|
|
1393
|
+
queueUpdateBridge.listeners.add(queueUpdateListener);
|
|
1394
|
+
queueUpdateBridge.internalEventListeners.add(internalAgentEventListener);
|
|
1395
|
+
if (queueUpdateBridge.last) {
|
|
1396
|
+
publishQueueIfChanged(
|
|
1397
|
+
queueUpdateBridge.last.steering,
|
|
1398
|
+
queueUpdateBridge.last.followUp,
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
function isStaleExtensionContextError(error: unknown): boolean {
|
|
1403
|
+
return (
|
|
1404
|
+
error instanceof Error && error.message.includes("extension ctx is stale")
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function logCallbackError(scope: string, error: unknown) {
|
|
1409
|
+
if (isStaleExtensionContextError(error)) return;
|
|
1410
|
+
writeMirrorLog("warn", "callback_error", { scope, error });
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function truncatePlain(text: string, width: number): string {
|
|
1414
|
+
if (width <= 0) return "";
|
|
1415
|
+
return text.length > width ? text.slice(0, width) : text;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
function indicatorColor(): MirrorIndicatorColor {
|
|
1419
|
+
if (indicatorMode === "live") return "success";
|
|
1420
|
+
if (indicatorMode === "error") return "error";
|
|
1421
|
+
if (indicatorMode === "reconnecting" || indicatorMode === "blocked") {
|
|
1422
|
+
return "warning";
|
|
1423
|
+
}
|
|
1424
|
+
return "muted";
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function mountIndicatorWidget(ctx: ExtensionContext) {
|
|
1428
|
+
ctx.ui.setWidget(
|
|
1429
|
+
"oppi-mirror",
|
|
1430
|
+
(tui, theme) => {
|
|
1431
|
+
requestIndicatorRender = () => tui.requestRender();
|
|
1432
|
+
return {
|
|
1433
|
+
render(width: number): string[] {
|
|
1434
|
+
if (!indicatorLabel) return [];
|
|
1435
|
+
const maxTextWidth = Math.max(0, width - 2);
|
|
1436
|
+
const text = truncatePlain(indicatorLabel, maxTextWidth);
|
|
1437
|
+
const visibleWidth = text.length === 0 ? 1 : text.length + 2;
|
|
1438
|
+
const padding = " ".repeat(Math.max(0, width - visibleWidth));
|
|
1439
|
+
const dot = theme.fg(indicatorColor(), "●");
|
|
1440
|
+
return [`${padding}${text.length === 0 ? dot : `${dot} ${text}`}`];
|
|
1441
|
+
},
|
|
1442
|
+
invalidate(): void {},
|
|
1443
|
+
};
|
|
1444
|
+
},
|
|
1445
|
+
{ placement: "belowEditor" },
|
|
1446
|
+
);
|
|
1447
|
+
indicatorWidgetMounted = true;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function safeSetIndicator(ctx: ExtensionContext, label: string | undefined) {
|
|
1451
|
+
indicatorLabel = label;
|
|
1452
|
+
try {
|
|
1453
|
+
withSuppressedUIForwarding(() => {
|
|
1454
|
+
if (!label) {
|
|
1455
|
+
ctx.ui.setWidget("oppi-mirror", undefined);
|
|
1456
|
+
indicatorWidgetMounted = false;
|
|
1457
|
+
requestIndicatorRender = null;
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
if (!indicatorWidgetMounted) {
|
|
1461
|
+
mountIndicatorWidget(ctx);
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
requestIndicatorRender?.();
|
|
1465
|
+
});
|
|
1466
|
+
} catch (error) {
|
|
1467
|
+
logCallbackError("failed to update status", error);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function notify(
|
|
1472
|
+
ctx: ExtensionContext | null,
|
|
1473
|
+
message: string,
|
|
1474
|
+
type: "info" | "warning" | "error" = "info",
|
|
1475
|
+
) {
|
|
1476
|
+
if (!ctx || !ctx.hasUI) {
|
|
1477
|
+
writeMirrorLog("info", "notification_skipped", { message, type });
|
|
1478
|
+
return;
|
|
1479
|
+
}
|
|
1480
|
+
try {
|
|
1481
|
+
withSuppressedUIForwarding(() => ctx.ui.notify(message, type));
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
logCallbackError("failed to notify", error);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function send(payload: unknown) {
|
|
1488
|
+
if (ws?.readyState !== WebSocket.OPEN) return;
|
|
1489
|
+
try {
|
|
1490
|
+
ws.send(JSON.stringify(payload));
|
|
1491
|
+
} catch (error) {
|
|
1492
|
+
writeMirrorLog("warn", "websocket_send_failed", { error });
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function withSuppressedUIForwarding<T>(run: () => T): T {
|
|
1497
|
+
const previous = suppressUIForwarding;
|
|
1498
|
+
suppressUIForwarding = true;
|
|
1499
|
+
try {
|
|
1500
|
+
return run();
|
|
1501
|
+
} finally {
|
|
1502
|
+
suppressUIForwarding = previous;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function nextUIRequestId(method: string): string {
|
|
1507
|
+
return `mirror_ui_${method}_${Date.now().toString(36)}_${Math.random()
|
|
1508
|
+
.toString(36)
|
|
1509
|
+
.slice(2, 10)}`;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
function composeAbortSignals(
|
|
1513
|
+
first: AbortSignal | undefined,
|
|
1514
|
+
second: AbortSignal,
|
|
1515
|
+
): AbortSignal {
|
|
1516
|
+
if (!first) return second;
|
|
1517
|
+
if (first.aborted) return first;
|
|
1518
|
+
|
|
1519
|
+
const controller = new AbortController();
|
|
1520
|
+
const abort = () => controller.abort();
|
|
1521
|
+
first.addEventListener("abort", abort, { once: true });
|
|
1522
|
+
second.addEventListener("abort", abort, { once: true });
|
|
1523
|
+
return controller.signal;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function sendUIRequest(payload: Record<string, unknown>): void {
|
|
1527
|
+
send({ type: "extension_ui_request", ...payload });
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function sendUISettled(id: string): void {
|
|
1531
|
+
send({ type: "extension_ui_request_settled", id });
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
async function raceMirrorDialog<T>(
|
|
1535
|
+
method: MirrorExtensionUIMethod,
|
|
1536
|
+
request: Record<string, unknown>,
|
|
1537
|
+
opts: ExtensionUIDialogOptions | undefined,
|
|
1538
|
+
defaultValue: T,
|
|
1539
|
+
terminalCall: (opts: ExtensionUIDialogOptions | undefined) => Promise<T>,
|
|
1540
|
+
parsePhoneResponse: (response: MirrorExtensionUIResponse) => T,
|
|
1541
|
+
): Promise<T> {
|
|
1542
|
+
if (suppressUIForwarding || ws?.readyState !== WebSocket.OPEN) {
|
|
1543
|
+
return terminalCall(opts);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
const id = nextUIRequestId(method);
|
|
1547
|
+
const localAbort = new AbortController();
|
|
1548
|
+
const terminalOpts = {
|
|
1549
|
+
...opts,
|
|
1550
|
+
signal: composeAbortSignals(opts?.signal, localAbort.signal),
|
|
1551
|
+
};
|
|
1552
|
+
|
|
1553
|
+
let settledByPhone = false;
|
|
1554
|
+
const phonePromise = new Promise<{ source: "phone"; value: T }>(
|
|
1555
|
+
(resolve) => {
|
|
1556
|
+
pendingUIResponses.set(id, {
|
|
1557
|
+
resolve: (response) => {
|
|
1558
|
+
settledByPhone = true;
|
|
1559
|
+
resolve({
|
|
1560
|
+
source: "phone",
|
|
1561
|
+
value: parsePhoneResponse(response as MirrorExtensionUIResponse),
|
|
1562
|
+
});
|
|
1563
|
+
},
|
|
1564
|
+
});
|
|
1565
|
+
},
|
|
1566
|
+
);
|
|
1567
|
+
|
|
1568
|
+
sendUIRequest({
|
|
1569
|
+
id,
|
|
1570
|
+
method,
|
|
1571
|
+
...request,
|
|
1572
|
+
timeout: opts?.timeout,
|
|
1573
|
+
timeoutAt: opts?.timeout ? Date.now() + opts.timeout : undefined,
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
const terminalPromise = Promise.resolve()
|
|
1577
|
+
.then(() => terminalCall(terminalOpts))
|
|
1578
|
+
.then((value) => ({ source: "terminal" as const, value }))
|
|
1579
|
+
.catch((error: unknown) => {
|
|
1580
|
+
if (settledByPhone)
|
|
1581
|
+
return { source: "phone" as const, value: defaultValue };
|
|
1582
|
+
throw error;
|
|
1583
|
+
});
|
|
1584
|
+
|
|
1585
|
+
let winner: { source: "phone" | "terminal"; value: T };
|
|
1586
|
+
try {
|
|
1587
|
+
winner = await Promise.race([phonePromise, terminalPromise]);
|
|
1588
|
+
} finally {
|
|
1589
|
+
pendingUIResponses.delete(id);
|
|
1590
|
+
sendUISettled(id);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
if (winner.source === "phone") {
|
|
1594
|
+
localAbort.abort();
|
|
1595
|
+
}
|
|
1596
|
+
return winner.value;
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function handleExtensionUIResponse(
|
|
1600
|
+
response: MirrorExtensionUIResponse,
|
|
1601
|
+
): void {
|
|
1602
|
+
const pending = pendingUIResponses.get(response.id);
|
|
1603
|
+
if (!pending) return;
|
|
1604
|
+
pending.resolve(response);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function installExtensionUIProxy(ctx: ExtensionContext): void {
|
|
1608
|
+
const ui = ctx.ui as ExtensionUIContext;
|
|
1609
|
+
if (proxiedUIContexts.has(ui as object)) return;
|
|
1610
|
+
proxiedUIContexts.add(ui as object);
|
|
1611
|
+
|
|
1612
|
+
const original = {
|
|
1613
|
+
select: ui.select.bind(ui),
|
|
1614
|
+
confirm: ui.confirm.bind(ui),
|
|
1615
|
+
input: ui.input.bind(ui),
|
|
1616
|
+
editor: ui.editor.bind(ui),
|
|
1617
|
+
notify: ui.notify.bind(ui),
|
|
1618
|
+
setStatus: ui.setStatus.bind(ui),
|
|
1619
|
+
setWidget: ui.setWidget.bind(ui) as ExtensionUIContext["setWidget"],
|
|
1620
|
+
setTitle: ui.setTitle.bind(ui),
|
|
1621
|
+
setEditorText: ui.setEditorText.bind(ui),
|
|
1622
|
+
pasteToEditor: ui.pasteToEditor.bind(ui),
|
|
1623
|
+
};
|
|
1624
|
+
|
|
1625
|
+
ui.select = (title, options, opts) =>
|
|
1626
|
+
raceMirrorDialog(
|
|
1627
|
+
"select",
|
|
1628
|
+
{ title, options },
|
|
1629
|
+
opts,
|
|
1630
|
+
undefined,
|
|
1631
|
+
(nextOpts) => original.select(title, options, nextOpts),
|
|
1632
|
+
(response) => (response.cancelled ? undefined : response.value),
|
|
1633
|
+
);
|
|
1634
|
+
|
|
1635
|
+
ui.confirm = (title, message, opts) =>
|
|
1636
|
+
raceMirrorDialog(
|
|
1637
|
+
"confirm",
|
|
1638
|
+
{ title, message },
|
|
1639
|
+
opts,
|
|
1640
|
+
false,
|
|
1641
|
+
(nextOpts) => original.confirm(title, message, nextOpts),
|
|
1642
|
+
(response) =>
|
|
1643
|
+
response.cancelled ? false : (response.confirmed ?? false),
|
|
1644
|
+
);
|
|
1645
|
+
|
|
1646
|
+
ui.input = (title, placeholder, opts) =>
|
|
1647
|
+
raceMirrorDialog(
|
|
1648
|
+
"input",
|
|
1649
|
+
{ title, placeholder },
|
|
1650
|
+
opts,
|
|
1651
|
+
undefined,
|
|
1652
|
+
(nextOpts) => original.input(title, placeholder, nextOpts),
|
|
1653
|
+
(response) => (response.cancelled ? undefined : response.value),
|
|
1654
|
+
);
|
|
1655
|
+
|
|
1656
|
+
ui.editor = (title, prefill) =>
|
|
1657
|
+
raceMirrorDialog(
|
|
1658
|
+
"editor",
|
|
1659
|
+
{ title, prefill },
|
|
1660
|
+
undefined,
|
|
1661
|
+
undefined,
|
|
1662
|
+
() => original.editor(title, prefill),
|
|
1663
|
+
(response) => (response.cancelled ? undefined : response.value),
|
|
1664
|
+
);
|
|
1665
|
+
|
|
1666
|
+
ui.notify = (message, type) => {
|
|
1667
|
+
original.notify(message, type);
|
|
1668
|
+
if (!suppressUIForwarding) {
|
|
1669
|
+
sendUIRequest({
|
|
1670
|
+
id: nextUIRequestId("notify"),
|
|
1671
|
+
method: "notify",
|
|
1672
|
+
message,
|
|
1673
|
+
notifyType: type,
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
};
|
|
1677
|
+
|
|
1678
|
+
ui.setStatus = (key, text) => {
|
|
1679
|
+
original.setStatus(key, text);
|
|
1680
|
+
if (!suppressUIForwarding) {
|
|
1681
|
+
sendUIRequest({
|
|
1682
|
+
id: nextUIRequestId("setStatus"),
|
|
1683
|
+
method: "setStatus",
|
|
1684
|
+
statusKey: key,
|
|
1685
|
+
statusText: text,
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
};
|
|
1689
|
+
|
|
1690
|
+
ui.setWidget = ((
|
|
1691
|
+
key: string,
|
|
1692
|
+
content: unknown,
|
|
1693
|
+
options?: { placement?: string },
|
|
1694
|
+
) => {
|
|
1695
|
+
original.setWidget(key, content as never, options as never);
|
|
1696
|
+
if (
|
|
1697
|
+
!suppressUIForwarding &&
|
|
1698
|
+
(content === undefined || Array.isArray(content))
|
|
1699
|
+
) {
|
|
1700
|
+
sendUIRequest({
|
|
1701
|
+
id: nextUIRequestId("setWidget"),
|
|
1702
|
+
method: "setWidget",
|
|
1703
|
+
widgetKey: key,
|
|
1704
|
+
widgetLines: content,
|
|
1705
|
+
widgetPlacement: options?.placement,
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
}) as ExtensionUIContext["setWidget"];
|
|
1709
|
+
|
|
1710
|
+
ui.setTitle = (title) => {
|
|
1711
|
+
original.setTitle(title);
|
|
1712
|
+
if (!suppressUIForwarding) {
|
|
1713
|
+
sendUIRequest({
|
|
1714
|
+
id: nextUIRequestId("setTitle"),
|
|
1715
|
+
method: "setTitle",
|
|
1716
|
+
title,
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
};
|
|
1720
|
+
|
|
1721
|
+
ui.setEditorText = (text) => {
|
|
1722
|
+
original.setEditorText(text);
|
|
1723
|
+
if (!suppressUIForwarding) {
|
|
1724
|
+
sendUIRequest({
|
|
1725
|
+
id: nextUIRequestId("set_editor_text"),
|
|
1726
|
+
method: "set_editor_text",
|
|
1727
|
+
text,
|
|
1728
|
+
});
|
|
1729
|
+
}
|
|
1730
|
+
};
|
|
1731
|
+
|
|
1732
|
+
ui.pasteToEditor = (text) => {
|
|
1733
|
+
original.pasteToEditor(text);
|
|
1734
|
+
if (!suppressUIForwarding) {
|
|
1735
|
+
sendUIRequest({
|
|
1736
|
+
id: nextUIRequestId("set_editor_text"),
|
|
1737
|
+
method: "set_editor_text",
|
|
1738
|
+
text,
|
|
1739
|
+
});
|
|
1740
|
+
}
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
function renderIndicator(ctx: ExtensionContext | null = latestCtx) {
|
|
1745
|
+
if (!runtimeActive || !ctx || !indicatorMode) return;
|
|
1746
|
+
const pendingCount = queueProjection.pendingCount();
|
|
1747
|
+
const queued = pendingCount > 0 ? ` q:${pendingCount}` : "";
|
|
1748
|
+
const label =
|
|
1749
|
+
indicatorMode === "live"
|
|
1750
|
+
? `Oppi mirroring live${queued}`
|
|
1751
|
+
: indicatorMode === "connecting"
|
|
1752
|
+
? "Oppi mirror connecting"
|
|
1753
|
+
: indicatorMode === "reconnecting"
|
|
1754
|
+
? "Oppi mirror reconnecting"
|
|
1755
|
+
: indicatorMode === "blocked"
|
|
1756
|
+
? "Oppi mirror waiting"
|
|
1757
|
+
: "Oppi mirror offline";
|
|
1758
|
+
safeSetIndicator(ctx, label);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
function startIndicator(ctx: ExtensionContext, mode: MirrorIndicatorMode) {
|
|
1762
|
+
if (!runtimeActive) return;
|
|
1763
|
+
latestCtx = ctx;
|
|
1764
|
+
indicatorMode = mode;
|
|
1765
|
+
renderIndicator(ctx);
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
function setIndicatorMode(mode: MirrorIndicatorMode) {
|
|
1769
|
+
if (!runtimeActive) return;
|
|
1770
|
+
indicatorMode = mode;
|
|
1771
|
+
renderIndicator();
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
function stopIndicator(ctx: ExtensionContext | null = latestCtx) {
|
|
1775
|
+
indicatorMode = null;
|
|
1776
|
+
if (ctx) safeSetIndicator(ctx, undefined);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
function sendQueueState() {
|
|
1780
|
+
send({ type: "queue_state", queue: queueProjection.snapshot() });
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
function syncQueueFromTexts(
|
|
1784
|
+
steering: readonly string[],
|
|
1785
|
+
followUp: readonly string[],
|
|
1786
|
+
source = "runtime_snapshot",
|
|
1787
|
+
): boolean {
|
|
1788
|
+
const previous = queueProjection.snapshot();
|
|
1789
|
+
const result = queueProjection.reconcileRuntimeSnapshot({
|
|
1790
|
+
steering,
|
|
1791
|
+
followUp,
|
|
1792
|
+
});
|
|
1793
|
+
if (result.changed) {
|
|
1794
|
+
writeMirrorLog("info", "queue_projection_reconciled", {
|
|
1795
|
+
runtime: "pi-tui",
|
|
1796
|
+
bridgeId,
|
|
1797
|
+
sessionId: connectedSessionId,
|
|
1798
|
+
workspaceId: connectedWorkspaceId,
|
|
1799
|
+
source,
|
|
1800
|
+
previousVersion: previous.version,
|
|
1801
|
+
version: result.queue.version,
|
|
1802
|
+
previousSteeringCount: previous.steering.length,
|
|
1803
|
+
steeringCount: result.queue.steering.length,
|
|
1804
|
+
previousFollowUpCount: previous.followUp.length,
|
|
1805
|
+
followUpCount: result.queue.followUp.length,
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
return result.changed;
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
function publishQueueIfChanged(
|
|
1812
|
+
steering: readonly string[],
|
|
1813
|
+
followUp: readonly string[],
|
|
1814
|
+
) {
|
|
1815
|
+
if (!runtimeActive) return;
|
|
1816
|
+
if (!syncQueueFromTexts(steering, followUp, "queue_update")) return;
|
|
1817
|
+
sendQueueState();
|
|
1818
|
+
renderIndicator();
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
function queueTextsFromAgentSession(
|
|
1822
|
+
session: EditableAgentSession | undefined,
|
|
1823
|
+
): { steering: readonly string[]; followUp: readonly string[] } | null {
|
|
1824
|
+
if (!session) return null;
|
|
1825
|
+
const hasSteeringQueue =
|
|
1826
|
+
typeof session.getSteeringMessages === "function" ||
|
|
1827
|
+
Array.isArray(session._steeringMessages);
|
|
1828
|
+
const hasFollowUpQueue =
|
|
1829
|
+
typeof session.getFollowUpMessages === "function" ||
|
|
1830
|
+
Array.isArray(session._followUpMessages);
|
|
1831
|
+
if (!hasSteeringQueue && !hasFollowUpQueue) return null;
|
|
1832
|
+
|
|
1833
|
+
return {
|
|
1834
|
+
steering: Array.from(
|
|
1835
|
+
session.getSteeringMessages?.() ?? session._steeringMessages ?? [],
|
|
1836
|
+
),
|
|
1837
|
+
followUp: Array.from(
|
|
1838
|
+
session.getFollowUpMessages?.() ?? session._followUpMessages ?? [],
|
|
1839
|
+
),
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
function syncQueueFromEditableSession(ctx: ExtensionContext): boolean {
|
|
1844
|
+
const texts = queueTextsFromAgentSession(findEditableAgentSession(ctx));
|
|
1845
|
+
if (!texts) return false;
|
|
1846
|
+
if (
|
|
1847
|
+
!syncQueueFromTexts(
|
|
1848
|
+
texts.steering,
|
|
1849
|
+
texts.followUp,
|
|
1850
|
+
"agent_session_snapshot",
|
|
1851
|
+
)
|
|
1852
|
+
)
|
|
1853
|
+
return false;
|
|
1854
|
+
sendQueueState();
|
|
1855
|
+
renderIndicator();
|
|
1856
|
+
return true;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function findEditableAgentSession(
|
|
1860
|
+
ctx: ExtensionContext,
|
|
1861
|
+
): EditableAgentSession | undefined {
|
|
1862
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
1863
|
+
return (
|
|
1864
|
+
queueUpdateBridge.sessions.get(sessionId) ?? queueUpdateBridge.lastSession
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function requireEditableAgentSession(
|
|
1869
|
+
ctx: ExtensionContext,
|
|
1870
|
+
): EditableAgentSession {
|
|
1871
|
+
const session = findEditableAgentSession(ctx);
|
|
1872
|
+
if (!session) {
|
|
1873
|
+
throw new Error(
|
|
1874
|
+
"Terminal Pi runtime session control is not attached yet",
|
|
1875
|
+
);
|
|
1876
|
+
}
|
|
1877
|
+
return session;
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
function replaceLocalQueue(
|
|
1881
|
+
ctx: ExtensionContext,
|
|
1882
|
+
nextQueue: MessageQueueState,
|
|
1883
|
+
) {
|
|
1884
|
+
const session = requireEditableAgentSession(ctx);
|
|
1885
|
+
const previous = queueProjection.snapshot();
|
|
1886
|
+
const result = queueProjection.replace(nextQueue);
|
|
1887
|
+
if (result.changed) {
|
|
1888
|
+
writeMirrorLog("info", "queue_projection_replaced", {
|
|
1889
|
+
runtime: "pi-tui",
|
|
1890
|
+
bridgeId,
|
|
1891
|
+
sessionId: connectedSessionId,
|
|
1892
|
+
workspaceId: connectedWorkspaceId,
|
|
1893
|
+
source: "set_queue",
|
|
1894
|
+
previousVersion: previous.version,
|
|
1895
|
+
version: result.queue.version,
|
|
1896
|
+
previousSteeringCount: previous.steering.length,
|
|
1897
|
+
steeringCount: result.queue.steering.length,
|
|
1898
|
+
previousFollowUpCount: previous.followUp.length,
|
|
1899
|
+
followUpCount: result.queue.followUp.length,
|
|
1900
|
+
});
|
|
1901
|
+
}
|
|
1902
|
+
replaceAgentSessionQueue(session, nextQueue);
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
function clearQueueForShutdown(ctx: ExtensionContext) {
|
|
1906
|
+
const previous = queueProjection.snapshot();
|
|
1907
|
+
const result = queueProjection.clear();
|
|
1908
|
+
const session = findEditableAgentSession(ctx);
|
|
1909
|
+
if (session) {
|
|
1910
|
+
try {
|
|
1911
|
+
clearAgentSessionQueue(session);
|
|
1912
|
+
} catch (error) {
|
|
1913
|
+
logCallbackError("failed to clear queue for shutdown", error);
|
|
1914
|
+
}
|
|
1915
|
+
}
|
|
1916
|
+
writeMirrorLog("info", "queue_projection_cleared_for_shutdown", {
|
|
1917
|
+
runtime: "pi-tui",
|
|
1918
|
+
bridgeId,
|
|
1919
|
+
sessionId: connectedSessionId,
|
|
1920
|
+
workspaceId: connectedWorkspaceId,
|
|
1921
|
+
previousVersion: previous.version,
|
|
1922
|
+
version: result.queue.version,
|
|
1923
|
+
previousSteeringCount: previous.steering.length,
|
|
1924
|
+
steeringCount: result.queue.steering.length,
|
|
1925
|
+
previousFollowUpCount: previous.followUp.length,
|
|
1926
|
+
followUpCount: result.queue.followUp.length,
|
|
1927
|
+
});
|
|
1928
|
+
sendQueueState();
|
|
1929
|
+
renderIndicator();
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
function scheduleRuntimeReload(ctx: ExtensionContext) {
|
|
1933
|
+
const session = findEditableAgentSession(ctx);
|
|
1934
|
+
if (!session?.reload) {
|
|
1935
|
+
throw new Error("Terminal Pi runtime reload is not attached yet");
|
|
1936
|
+
}
|
|
1937
|
+
setTimeout(() => {
|
|
1938
|
+
session.reload?.().catch((error) => {
|
|
1939
|
+
logCallbackError("remote reload failed", error);
|
|
1940
|
+
});
|
|
1941
|
+
}, 0);
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
async function refreshQueueFromRuntime(
|
|
1945
|
+
ctx: ExtensionContext,
|
|
1946
|
+
): Promise<MessageQueueState> {
|
|
1947
|
+
const api = ctx as unknown as {
|
|
1948
|
+
getMessageQueue?: () => Promise<MessageQueueState> | MessageQueueState;
|
|
1949
|
+
getSteeringMessages?: () => Promise<string[]> | string[];
|
|
1950
|
+
getFollowUpMessages?: () => Promise<string[]> | string[];
|
|
1951
|
+
};
|
|
1952
|
+
|
|
1953
|
+
if (api.getMessageQueue) {
|
|
1954
|
+
const latestQueue = await api.getMessageQueue();
|
|
1955
|
+
queueProjection.replace(latestQueue);
|
|
1956
|
+
return queueProjection.snapshot();
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
if (api.getSteeringMessages && api.getFollowUpMessages) {
|
|
1960
|
+
const [steering, followUp] = await Promise.all([
|
|
1961
|
+
api.getSteeringMessages(),
|
|
1962
|
+
api.getFollowUpMessages(),
|
|
1963
|
+
]);
|
|
1964
|
+
syncQueueFromTexts(steering, followUp, "extension_context_api");
|
|
1965
|
+
return queueProjection.snapshot();
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
return queueProjection.snapshot();
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
function enqueueShadow(
|
|
1972
|
+
kind: "steer" | "followUp",
|
|
1973
|
+
message: string,
|
|
1974
|
+
images?: QueueImageContent[],
|
|
1975
|
+
options: { previousMatchingCount?: number } = {},
|
|
1976
|
+
) {
|
|
1977
|
+
const result = queueProjection.enqueueOptimistic(
|
|
1978
|
+
kind === "steer" ? "steer" : "follow_up",
|
|
1979
|
+
message,
|
|
1980
|
+
images,
|
|
1981
|
+
options,
|
|
1982
|
+
);
|
|
1983
|
+
if (!result.changed) return;
|
|
1984
|
+
sendQueueState();
|
|
1985
|
+
renderIndicator();
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
function markQueueItemStarted(message: string | undefined) {
|
|
1989
|
+
const started = queueProjection.markStarted(message);
|
|
1990
|
+
if (!started) return;
|
|
1991
|
+
send({
|
|
1992
|
+
type: "queue_item_started",
|
|
1993
|
+
kind: started.kind,
|
|
1994
|
+
item: started.item,
|
|
1995
|
+
queueVersion: started.queueVersion,
|
|
1996
|
+
queue: started.queue,
|
|
1997
|
+
});
|
|
1998
|
+
sendQueueState();
|
|
1999
|
+
renderIndicator();
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
function clearTimers() {
|
|
2003
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
2004
|
+
reconnectTimer = null;
|
|
2005
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
2006
|
+
heartbeatTimer = null;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
function startHeartbeat(socket: WebSocket, serial: number) {
|
|
2010
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
2011
|
+
heartbeatTimer = setInterval(() => {
|
|
2012
|
+
if (!runtimeActive || connectionSerial !== serial || ws !== socket) {
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
if (!latestCtx) return;
|
|
2016
|
+
try {
|
|
2017
|
+
syncQueueFromEditableSession(latestCtx);
|
|
2018
|
+
send({
|
|
2019
|
+
type: "heartbeat",
|
|
2020
|
+
state: stateSnapshot(pi, latestCtx),
|
|
2021
|
+
queue: queueProjection.snapshot(),
|
|
2022
|
+
});
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
logCallbackError("heartbeat failed", error);
|
|
2025
|
+
}
|
|
2026
|
+
}, 10_000);
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
function configured(): { serverUrl: string; token: string } | null {
|
|
2030
|
+
if (!settings.serverUrl || !settings.token) return null;
|
|
2031
|
+
return { serverUrl: settings.serverUrl, token: settings.token };
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
function connect(ctx: ExtensionContext) {
|
|
2035
|
+
if (!runtimeActive) return;
|
|
2036
|
+
latestCtx = ctx;
|
|
2037
|
+
installExtensionUIProxy(ctx);
|
|
2038
|
+
if (!isInteractiveTerminalProcess()) {
|
|
2039
|
+
notify(
|
|
2040
|
+
ctx,
|
|
2041
|
+
"Oppi Mirror only starts from an interactive Pi TUI terminal",
|
|
2042
|
+
"warning",
|
|
2043
|
+
);
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
settings = loadSettings();
|
|
2047
|
+
const config = configured();
|
|
2048
|
+
if (!config) {
|
|
2049
|
+
notify(
|
|
2050
|
+
ctx,
|
|
2051
|
+
"Oppi Mirror could not auto-discover ~/.config/oppi/config.json. Start the Oppi server once, or set OPPI_MIRROR_URL/OPPI_MIRROR_TOKEN.",
|
|
2052
|
+
"warning",
|
|
2053
|
+
);
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
if (
|
|
2058
|
+
ws &&
|
|
2059
|
+
(ws.readyState === WebSocket.OPEN ||
|
|
2060
|
+
ws.readyState === WebSocket.CONNECTING)
|
|
2061
|
+
) {
|
|
2062
|
+
notify(ctx, "Oppi Mirror is already running", "info");
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
manualStop = false;
|
|
2067
|
+
startIndicator(ctx, "connecting");
|
|
2068
|
+
let url: string;
|
|
2069
|
+
let socket: WebSocket;
|
|
2070
|
+
try {
|
|
2071
|
+
url = bridgeUrl(config.serverUrl);
|
|
2072
|
+
socket = new WebSocket(url, {
|
|
2073
|
+
headers: { Authorization: `Bearer ${config.token}` },
|
|
2074
|
+
perMessageDeflate: false,
|
|
2075
|
+
// Auto-discovery reads the local Oppi config/token from the same user account.
|
|
2076
|
+
// Local self-signed HTTPS is expected; do not require manual cert pairing for this path.
|
|
2077
|
+
rejectUnauthorized: !isLocalUrl(config.serverUrl),
|
|
2078
|
+
});
|
|
2079
|
+
} catch (error) {
|
|
2080
|
+
logCallbackError("websocket setup failed", error);
|
|
2081
|
+
setIndicatorMode("error");
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
const serial = ++connectionSerial;
|
|
2085
|
+
ws = socket;
|
|
2086
|
+
|
|
2087
|
+
socket.on("open", () => {
|
|
2088
|
+
if (!runtimeActive || connectionSerial !== serial || ws !== socket) {
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
try {
|
|
2092
|
+
send({
|
|
2093
|
+
type: "hello",
|
|
2094
|
+
protocolVersion: 1,
|
|
2095
|
+
bridgeId,
|
|
2096
|
+
pid: process.pid,
|
|
2097
|
+
hostname: hostname(),
|
|
2098
|
+
cwd: ctx.cwd,
|
|
2099
|
+
capabilities: [
|
|
2100
|
+
"prompt",
|
|
2101
|
+
"steer",
|
|
2102
|
+
"follow_up",
|
|
2103
|
+
"abort",
|
|
2104
|
+
"model",
|
|
2105
|
+
"thinking",
|
|
2106
|
+
"session_name",
|
|
2107
|
+
"compact",
|
|
2108
|
+
"queue",
|
|
2109
|
+
"tree_navigation",
|
|
2110
|
+
"runtime_modes",
|
|
2111
|
+
"retry",
|
|
2112
|
+
"bash_abort",
|
|
2113
|
+
"state",
|
|
2114
|
+
"extension_ui_proxy",
|
|
2115
|
+
],
|
|
2116
|
+
state: stateSnapshot(pi, ctx),
|
|
2117
|
+
});
|
|
2118
|
+
sendQueueState();
|
|
2119
|
+
startHeartbeat(socket, serial);
|
|
2120
|
+
setIndicatorMode("connecting");
|
|
2121
|
+
} catch (error) {
|
|
2122
|
+
logCallbackError("websocket open handler failed", error);
|
|
2123
|
+
}
|
|
2124
|
+
});
|
|
2125
|
+
|
|
2126
|
+
socket.on("message", (raw: RawData) => {
|
|
2127
|
+
if (!runtimeActive || connectionSerial !== serial || ws !== socket) {
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
void handleServerMessage(raw.toString()).catch((error: unknown) => {
|
|
2131
|
+
logCallbackError("websocket message handler failed", error);
|
|
2132
|
+
});
|
|
2133
|
+
});
|
|
2134
|
+
|
|
2135
|
+
socket.on("close", (code: number, reason: Buffer) => {
|
|
2136
|
+
if (!runtimeActive || connectionSerial !== serial || ws !== socket) {
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
writeMirrorLog("info", "bridge_disconnected", {
|
|
2140
|
+
runtime: "pi-tui",
|
|
2141
|
+
bridgeId,
|
|
2142
|
+
sessionId: connectedSessionId,
|
|
2143
|
+
workspaceId: connectedWorkspaceId,
|
|
2144
|
+
code,
|
|
2145
|
+
reason: reason.toString("utf8"),
|
|
2146
|
+
manualStop,
|
|
2147
|
+
});
|
|
2148
|
+
clearTimers();
|
|
2149
|
+
pendingUIResponses.clear();
|
|
2150
|
+
ws = null;
|
|
2151
|
+
if (!manualStop) {
|
|
2152
|
+
const delayMs = Math.max(
|
|
2153
|
+
DEFAULT_RECONNECT_DELAY_MS,
|
|
2154
|
+
nextReconnectDelayMs,
|
|
2155
|
+
);
|
|
2156
|
+
setIndicatorMode(
|
|
2157
|
+
delayMs > DEFAULT_RECONNECT_DELAY_MS ? "blocked" : "reconnecting",
|
|
2158
|
+
);
|
|
2159
|
+
reconnectTimer = setTimeout(() => {
|
|
2160
|
+
if (!runtimeActive || connectionSerial !== serial || manualStop) {
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
connect(ctx);
|
|
2164
|
+
}, delayMs);
|
|
2165
|
+
} else {
|
|
2166
|
+
stopIndicator(ctx);
|
|
2167
|
+
}
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
socket.on("error", (error: Error) => {
|
|
2171
|
+
if (!runtimeActive || connectionSerial !== serial || ws !== socket) {
|
|
2172
|
+
return;
|
|
2173
|
+
}
|
|
2174
|
+
nextReconnectDelayMs = DEFAULT_RECONNECT_DELAY_MS;
|
|
2175
|
+
setIndicatorMode("reconnecting");
|
|
2176
|
+
writeMirrorLog("warn", "websocket_error", { url, error });
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
function stop(
|
|
2181
|
+
ctx: ExtensionContext | null,
|
|
2182
|
+
reason = "stopped",
|
|
2183
|
+
options: { notify?: boolean } = {},
|
|
2184
|
+
) {
|
|
2185
|
+
const shouldNotify = options.notify ?? true;
|
|
2186
|
+
manualStop = true;
|
|
2187
|
+
connectionSerial += 1;
|
|
2188
|
+
clearTimers();
|
|
2189
|
+
pendingUIResponses.clear();
|
|
2190
|
+
const socket = ws;
|
|
2191
|
+
const stateCtx = ctx ?? latestCtx;
|
|
2192
|
+
if (socket?.readyState === WebSocket.OPEN) {
|
|
2193
|
+
try {
|
|
2194
|
+
socket.send(
|
|
2195
|
+
JSON.stringify({
|
|
2196
|
+
type: "goodbye",
|
|
2197
|
+
reason,
|
|
2198
|
+
...(stateCtx ? { state: stateSnapshot(pi, stateCtx) } : {}),
|
|
2199
|
+
}),
|
|
2200
|
+
);
|
|
2201
|
+
} catch (error) {
|
|
2202
|
+
logCallbackError("failed to send goodbye", error);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
if (
|
|
2206
|
+
socket &&
|
|
2207
|
+
(socket.readyState === WebSocket.OPEN ||
|
|
2208
|
+
socket.readyState === WebSocket.CONNECTING)
|
|
2209
|
+
) {
|
|
2210
|
+
socket.close();
|
|
2211
|
+
}
|
|
2212
|
+
ws = null;
|
|
2213
|
+
connectedSessionId = null;
|
|
2214
|
+
connectedWorkspaceId = null;
|
|
2215
|
+
stopIndicator(stateCtx);
|
|
2216
|
+
latestCtx = null;
|
|
2217
|
+
if (shouldNotify) notify(ctx, "Oppi Mirror stopped");
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
async function handleServerMessage(raw: string) {
|
|
2221
|
+
if (!runtimeActive) return;
|
|
2222
|
+
const ctx = latestCtx;
|
|
2223
|
+
if (!ctx) return;
|
|
2224
|
+
|
|
2225
|
+
const message = JSON.parse(raw) as {
|
|
2226
|
+
type?: string;
|
|
2227
|
+
id?: string;
|
|
2228
|
+
command?: Record<string, unknown>;
|
|
2229
|
+
};
|
|
2230
|
+
switch (message.type) {
|
|
2231
|
+
case "hello_ack":
|
|
2232
|
+
connectedSessionId =
|
|
2233
|
+
(message as { sessionId?: string }).sessionId ?? null;
|
|
2234
|
+
connectedWorkspaceId =
|
|
2235
|
+
(message as { workspaceId?: string }).workspaceId ?? null;
|
|
2236
|
+
nextReconnectDelayMs = DEFAULT_RECONNECT_DELAY_MS;
|
|
2237
|
+
lastOppiRuntimeConflictSessionId = null;
|
|
2238
|
+
lastOppiRuntimeConflictNotifiedAt = 0;
|
|
2239
|
+
writeMirrorLog("info", "bridge_connected", {
|
|
2240
|
+
runtime: "pi-tui",
|
|
2241
|
+
bridgeId,
|
|
2242
|
+
sessionId: connectedSessionId,
|
|
2243
|
+
workspaceId: connectedWorkspaceId,
|
|
2244
|
+
});
|
|
2245
|
+
setIndicatorMode("live");
|
|
2246
|
+
return;
|
|
2247
|
+
|
|
2248
|
+
case "command":
|
|
2249
|
+
if (message.id && message.command) {
|
|
2250
|
+
await handleCommand(ctx, message.id, message.command);
|
|
2251
|
+
}
|
|
2252
|
+
return;
|
|
2253
|
+
|
|
2254
|
+
case "extension_ui_response":
|
|
2255
|
+
handleExtensionUIResponse(message as MirrorExtensionUIResponse);
|
|
2256
|
+
return;
|
|
2257
|
+
|
|
2258
|
+
case "error": {
|
|
2259
|
+
const err = message as {
|
|
2260
|
+
code?: string;
|
|
2261
|
+
error?: string;
|
|
2262
|
+
retryAfterMs?: number;
|
|
2263
|
+
sessionId?: string;
|
|
2264
|
+
};
|
|
2265
|
+
const errorText = err.error ?? "unknown";
|
|
2266
|
+
if (
|
|
2267
|
+
err.code === "oppi_runtime_active" ||
|
|
2268
|
+
errorText.includes("already owned by the oppi runtime")
|
|
2269
|
+
) {
|
|
2270
|
+
const sessionId = err.sessionId ?? "this session";
|
|
2271
|
+
nextReconnectDelayMs =
|
|
2272
|
+
typeof err.retryAfterMs === "number" &&
|
|
2273
|
+
Number.isFinite(err.retryAfterMs)
|
|
2274
|
+
? Math.max(DEFAULT_RECONNECT_DELAY_MS, err.retryAfterMs)
|
|
2275
|
+
: 10_000;
|
|
2276
|
+
setIndicatorMode("blocked");
|
|
2277
|
+
|
|
2278
|
+
const now = Date.now();
|
|
2279
|
+
const shouldNotify =
|
|
2280
|
+
lastOppiRuntimeConflictSessionId !== sessionId ||
|
|
2281
|
+
now - lastOppiRuntimeConflictNotifiedAt >
|
|
2282
|
+
OPPI_RUNTIME_CONFLICT_NOTIFY_INTERVAL_MS;
|
|
2283
|
+
if (shouldNotify) {
|
|
2284
|
+
lastOppiRuntimeConflictSessionId = sessionId;
|
|
2285
|
+
lastOppiRuntimeConflictNotifiedAt = now;
|
|
2286
|
+
notify(
|
|
2287
|
+
ctx,
|
|
2288
|
+
`Oppi Mirror is waiting for Oppi session ${sessionId} to stop before taking over this terminal session.`,
|
|
2289
|
+
"warning",
|
|
2290
|
+
);
|
|
2291
|
+
}
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
nextReconnectDelayMs = DEFAULT_RECONNECT_DELAY_MS;
|
|
2296
|
+
setIndicatorMode("error");
|
|
2297
|
+
writeMirrorLog("warn", "server_error_message", {
|
|
2298
|
+
code: err.code,
|
|
2299
|
+
error: errorText,
|
|
2300
|
+
retryAfterMs: err.retryAfterMs,
|
|
2301
|
+
sessionId: err.sessionId,
|
|
2302
|
+
});
|
|
2303
|
+
notify(
|
|
2304
|
+
ctx,
|
|
2305
|
+
"Oppi Mirror reported a bridge error; details were logged.",
|
|
2306
|
+
"warning",
|
|
2307
|
+
);
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
async function handleCommand(
|
|
2314
|
+
ctx: ExtensionContext,
|
|
2315
|
+
id: string,
|
|
2316
|
+
command: Record<string, unknown>,
|
|
2317
|
+
) {
|
|
2318
|
+
const startedAt = Date.now();
|
|
2319
|
+
const details = commandLogDetails(command);
|
|
2320
|
+
writeMirrorLog("info", "command_received", {
|
|
2321
|
+
runtime: "pi-tui",
|
|
2322
|
+
bridgeId,
|
|
2323
|
+
sessionId: connectedSessionId,
|
|
2324
|
+
workspaceId: connectedWorkspaceId,
|
|
2325
|
+
commandId: id,
|
|
2326
|
+
...details,
|
|
2327
|
+
});
|
|
2328
|
+
try {
|
|
2329
|
+
const data = await runCommand(ctx, command);
|
|
2330
|
+
writeMirrorLog("info", "command_completed", {
|
|
2331
|
+
runtime: "pi-tui",
|
|
2332
|
+
bridgeId,
|
|
2333
|
+
sessionId: connectedSessionId,
|
|
2334
|
+
workspaceId: connectedWorkspaceId,
|
|
2335
|
+
commandId: id,
|
|
2336
|
+
...details,
|
|
2337
|
+
outcome: "success",
|
|
2338
|
+
durationMs: Date.now() - startedAt,
|
|
2339
|
+
});
|
|
2340
|
+
send({
|
|
2341
|
+
type: "command_result",
|
|
2342
|
+
id,
|
|
2343
|
+
success: true,
|
|
2344
|
+
data,
|
|
2345
|
+
state: stateSnapshot(pi, ctx),
|
|
2346
|
+
});
|
|
2347
|
+
} catch (error) {
|
|
2348
|
+
writeMirrorLog("warn", "command_completed", {
|
|
2349
|
+
runtime: "pi-tui",
|
|
2350
|
+
bridgeId,
|
|
2351
|
+
sessionId: connectedSessionId,
|
|
2352
|
+
workspaceId: connectedWorkspaceId,
|
|
2353
|
+
commandId: id,
|
|
2354
|
+
...details,
|
|
2355
|
+
outcome: "error",
|
|
2356
|
+
durationMs: Date.now() - startedAt,
|
|
2357
|
+
error,
|
|
2358
|
+
});
|
|
2359
|
+
send(commandError("command_result", id, error));
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
async function runCommand(
|
|
2364
|
+
ctx: ExtensionContext,
|
|
2365
|
+
command: Record<string, unknown>,
|
|
2366
|
+
): Promise<unknown> {
|
|
2367
|
+
const type = command.type;
|
|
2368
|
+
switch (type) {
|
|
2369
|
+
case "prompt": {
|
|
2370
|
+
const message =
|
|
2371
|
+
typeof command.message === "string" ? command.message : "";
|
|
2372
|
+
if (message.trim() === "/reload") {
|
|
2373
|
+
scheduleRuntimeReload(ctx);
|
|
2374
|
+
return { reloading: true };
|
|
2375
|
+
}
|
|
2376
|
+
const images = imagesFromCommand(command.images);
|
|
2377
|
+
const content = contentForMessage(message, images);
|
|
2378
|
+
const streamingBehavior = command.streamingBehavior;
|
|
2379
|
+
if (streamingBehavior === "steer") {
|
|
2380
|
+
const previousMatchingCount = queueProjection
|
|
2381
|
+
.snapshot()
|
|
2382
|
+
.steering.filter((item) => item.message === message).length;
|
|
2383
|
+
pi.sendUserMessage(content, { deliverAs: "steer" });
|
|
2384
|
+
enqueueShadow("steer", message, images, { previousMatchingCount });
|
|
2385
|
+
} else if (streamingBehavior === "followUp") {
|
|
2386
|
+
const previousMatchingCount = queueProjection
|
|
2387
|
+
.snapshot()
|
|
2388
|
+
.followUp.filter((item) => item.message === message).length;
|
|
2389
|
+
pi.sendUserMessage(content, { deliverAs: "followUp" });
|
|
2390
|
+
enqueueShadow("followUp", message, images, { previousMatchingCount });
|
|
2391
|
+
} else {
|
|
2392
|
+
pi.sendUserMessage(content);
|
|
2393
|
+
}
|
|
2394
|
+
return { dispatched: true, queue: queueProjection.snapshot() };
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2397
|
+
case "steer": {
|
|
2398
|
+
const message = String(command.message ?? "");
|
|
2399
|
+
const images = imagesFromCommand(command.images);
|
|
2400
|
+
const previousMatchingCount = queueProjection
|
|
2401
|
+
.snapshot()
|
|
2402
|
+
.steering.filter((item) => item.message === message).length;
|
|
2403
|
+
pi.sendUserMessage(contentForMessage(message, images), {
|
|
2404
|
+
deliverAs: "steer",
|
|
2405
|
+
});
|
|
2406
|
+
enqueueShadow("steer", message, images, { previousMatchingCount });
|
|
2407
|
+
return { dispatched: true, queue: queueProjection.snapshot() };
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
case "follow_up": {
|
|
2411
|
+
const message = String(command.message ?? "");
|
|
2412
|
+
const images = imagesFromCommand(command.images);
|
|
2413
|
+
const previousMatchingCount = queueProjection
|
|
2414
|
+
.snapshot()
|
|
2415
|
+
.followUp.filter((item) => item.message === message).length;
|
|
2416
|
+
pi.sendUserMessage(contentForMessage(message, images), {
|
|
2417
|
+
deliverAs: "followUp",
|
|
2418
|
+
});
|
|
2419
|
+
enqueueShadow("followUp", message, images, { previousMatchingCount });
|
|
2420
|
+
return { dispatched: true, queue: queueProjection.snapshot() };
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
case "abort":
|
|
2424
|
+
findEditableAgentSession(ctx)?.abortCompaction?.();
|
|
2425
|
+
ctx.abort();
|
|
2426
|
+
// Remote abort has no terminal composer to restore queued text into.
|
|
2427
|
+
// Keep the queue intact so phone users do not lose queued steer/follow-up
|
|
2428
|
+
// messages. Terminal Escape still owns its native clear-and-restore path.
|
|
2429
|
+
sendQueueState();
|
|
2430
|
+
renderIndicator();
|
|
2431
|
+
return { aborted: true, queue: queueProjection.snapshot() };
|
|
2432
|
+
|
|
2433
|
+
case "stop": {
|
|
2434
|
+
const session = findEditableAgentSession(ctx);
|
|
2435
|
+
notify(
|
|
2436
|
+
ctx,
|
|
2437
|
+
"Oppi requested a pi-tui shutdown. Current work will be interrupted and this session will exit.",
|
|
2438
|
+
"warning",
|
|
2439
|
+
);
|
|
2440
|
+
session?.abortCompaction?.();
|
|
2441
|
+
session?.abortRetry?.();
|
|
2442
|
+
session?.abortBash?.();
|
|
2443
|
+
clearQueueForShutdown(ctx);
|
|
2444
|
+
ctx.abort();
|
|
2445
|
+
ctx.shutdown();
|
|
2446
|
+
return { stopping: true };
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
case "reload":
|
|
2450
|
+
scheduleRuntimeReload(ctx);
|
|
2451
|
+
return { reloading: true };
|
|
2452
|
+
|
|
2453
|
+
case "get_queue": {
|
|
2454
|
+
const latestQueue = await refreshQueueFromRuntime(ctx);
|
|
2455
|
+
sendQueueState();
|
|
2456
|
+
renderIndicator();
|
|
2457
|
+
return { queue: latestQueue };
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
case "set_queue": {
|
|
2461
|
+
const api = ctx as unknown as {
|
|
2462
|
+
setMessageQueue?: (payload: {
|
|
2463
|
+
baseVersion: number;
|
|
2464
|
+
steering: MessageQueueDraftItem[];
|
|
2465
|
+
followUp: MessageQueueDraftItem[];
|
|
2466
|
+
}) => Promise<MessageQueueState | void> | MessageQueueState | void;
|
|
2467
|
+
};
|
|
2468
|
+
const baseVersion = Number(command.baseVersion);
|
|
2469
|
+
const steering = Array.isArray(command.steering)
|
|
2470
|
+
? (command.steering as MessageQueueDraftItem[])
|
|
2471
|
+
: [];
|
|
2472
|
+
const followUp = Array.isArray(command.followUp)
|
|
2473
|
+
? (command.followUp as MessageQueueDraftItem[])
|
|
2474
|
+
: [];
|
|
2475
|
+
const requestedQueue = queueProjection.queueFromDrafts(
|
|
2476
|
+
baseVersion,
|
|
2477
|
+
steering,
|
|
2478
|
+
followUp,
|
|
2479
|
+
);
|
|
2480
|
+
|
|
2481
|
+
const nextQueue = api.setMessageQueue
|
|
2482
|
+
? await api.setMessageQueue({ baseVersion, steering, followUp })
|
|
2483
|
+
: undefined;
|
|
2484
|
+
if (nextQueue) {
|
|
2485
|
+
queueProjection.replace(nextQueue);
|
|
2486
|
+
} else if (api.setMessageQueue) {
|
|
2487
|
+
queueProjection.replace(requestedQueue);
|
|
2488
|
+
} else {
|
|
2489
|
+
replaceLocalQueue(ctx, requestedQueue);
|
|
2490
|
+
}
|
|
2491
|
+
sendQueueState();
|
|
2492
|
+
renderIndicator();
|
|
2493
|
+
return { queue: queueProjection.snapshot() };
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
case "get_state":
|
|
2497
|
+
return stateSnapshot(pi, ctx);
|
|
2498
|
+
|
|
2499
|
+
case "get_messages":
|
|
2500
|
+
return { entries: ctx.sessionManager.getEntries() };
|
|
2501
|
+
|
|
2502
|
+
case "get_fork_messages": {
|
|
2503
|
+
const messages =
|
|
2504
|
+
requireEditableAgentSession(ctx).getUserMessagesForForking?.();
|
|
2505
|
+
if (!messages)
|
|
2506
|
+
throw new Error(
|
|
2507
|
+
"Terminal Pi runtime fork messages are not attached yet",
|
|
2508
|
+
);
|
|
2509
|
+
return { messages };
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
case "get_session_tree":
|
|
2513
|
+
return serializeSessionTree(
|
|
2514
|
+
ctx.sessionManager as unknown as MirrorSessionTreeManager,
|
|
2515
|
+
readSessionTreeFilterMode(command.filterMode),
|
|
2516
|
+
);
|
|
2517
|
+
|
|
2518
|
+
case "navigate_tree": {
|
|
2519
|
+
const targetId = String(command.targetId ?? "").trim();
|
|
2520
|
+
if (!targetId) throw new Error("Invalid payload: expected targetId");
|
|
2521
|
+
const navigateTree = requireEditableAgentSession(ctx).navigateTree;
|
|
2522
|
+
if (!navigateTree)
|
|
2523
|
+
throw new Error(
|
|
2524
|
+
"Terminal Pi runtime tree navigation is not attached yet",
|
|
2525
|
+
);
|
|
2526
|
+
return await navigateTree(targetId, {
|
|
2527
|
+
summarize:
|
|
2528
|
+
typeof command.summarize === "boolean"
|
|
2529
|
+
? command.summarize
|
|
2530
|
+
: undefined,
|
|
2531
|
+
customInstructions:
|
|
2532
|
+
typeof command.customInstructions === "string"
|
|
2533
|
+
? command.customInstructions
|
|
2534
|
+
: undefined,
|
|
2535
|
+
replaceInstructions:
|
|
2536
|
+
typeof command.replaceInstructions === "boolean"
|
|
2537
|
+
? command.replaceInstructions
|
|
2538
|
+
: undefined,
|
|
2539
|
+
label: typeof command.label === "string" ? command.label : undefined,
|
|
2540
|
+
});
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
case "get_session_stats": {
|
|
2544
|
+
const entries = ctx.sessionManager.getEntries();
|
|
2545
|
+
return {
|
|
2546
|
+
sessionFile: ctx.sessionManager.getSessionFile(),
|
|
2547
|
+
piSessionId: ctx.sessionManager.getSessionId(),
|
|
2548
|
+
totalMessages: entries.length,
|
|
2549
|
+
contextUsage: contextUsageWire(ctx),
|
|
2550
|
+
};
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
case "get_commands":
|
|
2554
|
+
return { commands: pi.getCommands() };
|
|
2555
|
+
|
|
2556
|
+
case "get_available_models":
|
|
2557
|
+
return { models: await ctx.modelRegistry.getAvailable() };
|
|
2558
|
+
|
|
2559
|
+
case "set_model": {
|
|
2560
|
+
const provider = String(command.provider ?? "");
|
|
2561
|
+
const modelId = String(command.modelId ?? command.id ?? "");
|
|
2562
|
+
const models = await ctx.modelRegistry.getAvailable();
|
|
2563
|
+
const model = models.find(
|
|
2564
|
+
(candidate) =>
|
|
2565
|
+
candidate.provider === provider && candidate.id === modelId,
|
|
2566
|
+
);
|
|
2567
|
+
if (!model) throw new Error(`Model not found: ${provider}/${modelId}`);
|
|
2568
|
+
const ok = await pi.setModel(model);
|
|
2569
|
+
if (!ok) throw new Error("No API key for this model");
|
|
2570
|
+
return model;
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
case "cycle_model": {
|
|
2574
|
+
const models = await ctx.modelRegistry.getAvailable();
|
|
2575
|
+
if (!ctx.model || models.length === 0) return null;
|
|
2576
|
+
const index = models.findIndex(
|
|
2577
|
+
(candidate) =>
|
|
2578
|
+
candidate.provider === ctx.model?.provider &&
|
|
2579
|
+
candidate.id === ctx.model?.id,
|
|
2580
|
+
);
|
|
2581
|
+
const next = models[(index + 1 + models.length) % models.length];
|
|
2582
|
+
await pi.setModel(next);
|
|
2583
|
+
return { model: next };
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
case "set_thinking_level":
|
|
2587
|
+
pi.setThinkingLevel(
|
|
2588
|
+
String(command.level ?? "medium") as Parameters<
|
|
2589
|
+
typeof pi.setThinkingLevel
|
|
2590
|
+
>[0],
|
|
2591
|
+
);
|
|
2592
|
+
return { level: pi.getThinkingLevel() };
|
|
2593
|
+
|
|
2594
|
+
case "cycle_thinking_level": {
|
|
2595
|
+
const levels = [
|
|
2596
|
+
"off",
|
|
2597
|
+
"minimal",
|
|
2598
|
+
"low",
|
|
2599
|
+
"medium",
|
|
2600
|
+
"high",
|
|
2601
|
+
"xhigh",
|
|
2602
|
+
] as const;
|
|
2603
|
+
const current = pi.getThinkingLevel();
|
|
2604
|
+
const next =
|
|
2605
|
+
levels[
|
|
2606
|
+
(levels.indexOf(current as (typeof levels)[number]) + 1) %
|
|
2607
|
+
levels.length
|
|
2608
|
+
];
|
|
2609
|
+
pi.setThinkingLevel(next);
|
|
2610
|
+
return { level: pi.getThinkingLevel() };
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
case "set_session_name": {
|
|
2614
|
+
const name = String(command.name ?? "").trim();
|
|
2615
|
+
if (!name) throw new Error("Session name cannot be empty");
|
|
2616
|
+
pi.setSessionName(name);
|
|
2617
|
+
return { name };
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
case "compact":
|
|
2621
|
+
ctx.compact({
|
|
2622
|
+
customInstructions:
|
|
2623
|
+
typeof command.customInstructions === "string"
|
|
2624
|
+
? command.customInstructions
|
|
2625
|
+
: undefined,
|
|
2626
|
+
});
|
|
2627
|
+
return { compacting: true };
|
|
2628
|
+
|
|
2629
|
+
case "set_auto_compaction": {
|
|
2630
|
+
const enabled = !!command.enabled;
|
|
2631
|
+
const session = requireEditableAgentSession(ctx);
|
|
2632
|
+
if (!session.setAutoCompactionEnabled) {
|
|
2633
|
+
throw new Error(
|
|
2634
|
+
"Terminal Pi runtime auto-compaction control is not attached yet",
|
|
2635
|
+
);
|
|
2636
|
+
}
|
|
2637
|
+
session.setAutoCompactionEnabled(enabled);
|
|
2638
|
+
return { enabled };
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
case "set_steering_mode": {
|
|
2642
|
+
const mode = String(command.mode ?? "");
|
|
2643
|
+
if (mode !== "all" && mode !== "one-at-a-time") {
|
|
2644
|
+
throw new Error(
|
|
2645
|
+
"Invalid set_steering_mode payload: expected all or one-at-a-time",
|
|
2646
|
+
);
|
|
2647
|
+
}
|
|
2648
|
+
const session = requireEditableAgentSession(ctx);
|
|
2649
|
+
if (!session.setSteeringMode) {
|
|
2650
|
+
throw new Error(
|
|
2651
|
+
"Terminal Pi runtime steering mode control is not attached yet",
|
|
2652
|
+
);
|
|
2653
|
+
}
|
|
2654
|
+
session.setSteeringMode(mode);
|
|
2655
|
+
return { mode };
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
case "set_follow_up_mode": {
|
|
2659
|
+
const mode = String(command.mode ?? "");
|
|
2660
|
+
if (mode !== "all" && mode !== "one-at-a-time") {
|
|
2661
|
+
throw new Error(
|
|
2662
|
+
"Invalid set_follow_up_mode payload: expected all or one-at-a-time",
|
|
2663
|
+
);
|
|
2664
|
+
}
|
|
2665
|
+
const session = requireEditableAgentSession(ctx);
|
|
2666
|
+
if (!session.setFollowUpMode) {
|
|
2667
|
+
throw new Error(
|
|
2668
|
+
"Terminal Pi runtime follow-up mode control is not attached yet",
|
|
2669
|
+
);
|
|
2670
|
+
}
|
|
2671
|
+
session.setFollowUpMode(mode);
|
|
2672
|
+
return { mode };
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
case "set_auto_retry": {
|
|
2676
|
+
const enabled = !!command.enabled;
|
|
2677
|
+
const session = requireEditableAgentSession(ctx);
|
|
2678
|
+
if (!session.setAutoRetryEnabled) {
|
|
2679
|
+
throw new Error(
|
|
2680
|
+
"Terminal Pi runtime auto-retry control is not attached yet",
|
|
2681
|
+
);
|
|
2682
|
+
}
|
|
2683
|
+
session.setAutoRetryEnabled(enabled);
|
|
2684
|
+
return { enabled };
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
case "abort_retry": {
|
|
2688
|
+
const session = requireEditableAgentSession(ctx);
|
|
2689
|
+
if (!session.abortRetry) {
|
|
2690
|
+
throw new Error(
|
|
2691
|
+
"Terminal Pi runtime retry abort is not attached yet",
|
|
2692
|
+
);
|
|
2693
|
+
}
|
|
2694
|
+
session.abortRetry();
|
|
2695
|
+
return { success: true };
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
case "abort_bash": {
|
|
2699
|
+
const session = requireEditableAgentSession(ctx);
|
|
2700
|
+
if (!session.abortBash) {
|
|
2701
|
+
throw new Error("Terminal Pi runtime bash abort is not attached yet");
|
|
2702
|
+
}
|
|
2703
|
+
session.abortBash();
|
|
2704
|
+
return { success: true };
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
default:
|
|
2708
|
+
throw new Error(`Unsupported Oppi Mirror command: ${String(type)}`);
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
function guardExtensionCallback<T extends unknown[]>(
|
|
2713
|
+
scope: string,
|
|
2714
|
+
callback: (...args: T) => void | Promise<void>,
|
|
2715
|
+
) {
|
|
2716
|
+
return (...args: T) => {
|
|
2717
|
+
try {
|
|
2718
|
+
const result = callback(...args);
|
|
2719
|
+
if (result && typeof (result as Promise<void>).catch === "function") {
|
|
2720
|
+
void (result as Promise<void>).catch((error: unknown) => {
|
|
2721
|
+
logCallbackError(scope, error);
|
|
2722
|
+
});
|
|
2723
|
+
}
|
|
2724
|
+
} catch (error) {
|
|
2725
|
+
logCallbackError(scope, error);
|
|
2726
|
+
}
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
for (const eventType of EVENT_TYPES) {
|
|
2731
|
+
pi.on(
|
|
2732
|
+
eventType as never,
|
|
2733
|
+
guardExtensionCallback(
|
|
2734
|
+
`event:${eventType}`,
|
|
2735
|
+
(event: unknown, ctx: ExtensionContext) => {
|
|
2736
|
+
latestCtx = ctx;
|
|
2737
|
+
if (eventType === "message_start") {
|
|
2738
|
+
markQueueItemStarted(
|
|
2739
|
+
textFromUserMessage((event as { message?: unknown }).message),
|
|
2740
|
+
);
|
|
2741
|
+
syncQueueFromEditableSession(ctx);
|
|
2742
|
+
}
|
|
2743
|
+
const includeState =
|
|
2744
|
+
eventType === "agent_start" ||
|
|
2745
|
+
eventType === "agent_end" ||
|
|
2746
|
+
eventType === "turn_start" ||
|
|
2747
|
+
eventType === "turn_end" ||
|
|
2748
|
+
eventType === "message_end";
|
|
2749
|
+
send({
|
|
2750
|
+
type: "event",
|
|
2751
|
+
event: { ...(event as object), type: eventType },
|
|
2752
|
+
...(includeState ? { state: stateSnapshot(pi, ctx) } : {}),
|
|
2753
|
+
});
|
|
2754
|
+
},
|
|
2755
|
+
) as never,
|
|
2756
|
+
);
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
pi.on(
|
|
2760
|
+
"session_start",
|
|
2761
|
+
guardExtensionCallback(
|
|
2762
|
+
"session_start",
|
|
2763
|
+
(_event: unknown, ctx: ExtensionContext) => {
|
|
2764
|
+
latestCtx = ctx;
|
|
2765
|
+
if (settings.autoStart && isInteractiveTerminalProcess()) connect(ctx);
|
|
2766
|
+
},
|
|
2767
|
+
),
|
|
2768
|
+
);
|
|
2769
|
+
|
|
2770
|
+
pi.on(
|
|
2771
|
+
"session_shutdown",
|
|
2772
|
+
guardExtensionCallback(
|
|
2773
|
+
"session_shutdown",
|
|
2774
|
+
(event: unknown, ctx: ExtensionContext) => {
|
|
2775
|
+
runtimeActive = false;
|
|
2776
|
+
queueUpdateBridge.listeners.delete(queueUpdateListener);
|
|
2777
|
+
queueUpdateBridge.internalEventListeners.delete(
|
|
2778
|
+
internalAgentEventListener,
|
|
2779
|
+
);
|
|
2780
|
+
const shutdownReason = (event as { reason?: unknown }).reason;
|
|
2781
|
+
const reason =
|
|
2782
|
+
shutdownReason === "reload"
|
|
2783
|
+
? "reload"
|
|
2784
|
+
: shutdownReason === "quit"
|
|
2785
|
+
? "stopped"
|
|
2786
|
+
: "session_shutdown";
|
|
2787
|
+
stop(ctx, reason, { notify: false });
|
|
2788
|
+
},
|
|
2789
|
+
),
|
|
2790
|
+
);
|
|
2791
|
+
|
|
2792
|
+
pi.registerCommand("oppi-mirror", {
|
|
2793
|
+
description: "Mirror this live Pi TUI session into Oppi",
|
|
2794
|
+
handler: async (args, ctx) => {
|
|
2795
|
+
try {
|
|
2796
|
+
const [subcommand] = args.trim().split(/\s+/).filter(Boolean);
|
|
2797
|
+
switch (subcommand || "status") {
|
|
2798
|
+
case "start":
|
|
2799
|
+
connect(ctx);
|
|
2800
|
+
return;
|
|
2801
|
+
case "stop":
|
|
2802
|
+
stop(ctx);
|
|
2803
|
+
return;
|
|
2804
|
+
case "status":
|
|
2805
|
+
notify(
|
|
2806
|
+
ctx,
|
|
2807
|
+
ws?.readyState === WebSocket.OPEN
|
|
2808
|
+
? `Oppi mirroring live: workspace=${connectedWorkspaceId ?? "?"} session=${connectedSessionId ?? "?"}`
|
|
2809
|
+
: "Oppi mirror offline",
|
|
2810
|
+
ws?.readyState === WebSocket.OPEN ? "info" : "warning",
|
|
2811
|
+
);
|
|
2812
|
+
return;
|
|
2813
|
+
default:
|
|
2814
|
+
notify(ctx, "Usage: /oppi-mirror start|stop|status", "warning");
|
|
2815
|
+
}
|
|
2816
|
+
} catch (error) {
|
|
2817
|
+
logCallbackError("command:oppi-mirror", error);
|
|
2818
|
+
notify(
|
|
2819
|
+
ctx,
|
|
2820
|
+
"Oppi Mirror command failed; details were logged.",
|
|
2821
|
+
"warning",
|
|
2822
|
+
);
|
|
2823
|
+
}
|
|
2824
|
+
},
|
|
2825
|
+
});
|
|
2826
|
+
}
|