@rmrdeveloper/sideroom-pi 8.12.0 → 8.13.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/README.md +16 -0
- package/assets/visual-context/browser/INSTALL.md +69 -0
- package/assets/visual-context/browser/background.js +578 -0
- package/assets/visual-context/browser/content.js +198 -0
- package/assets/visual-context/browser/icons/README.md +9 -0
- package/assets/visual-context/browser/icons/icon-128.png +0 -0
- package/assets/visual-context/browser/icons/icon-16.png +0 -0
- package/assets/visual-context/browser/icons/icon-32.png +0 -0
- package/assets/visual-context/browser/icons/icon-48.png +0 -0
- package/assets/visual-context/browser/manifest.json +20 -0
- package/assets/visual-context/browser/popup.css +19 -0
- package/assets/visual-context/browser/popup.html +27 -0
- package/assets/visual-context/browser/popup.js +141 -0
- package/extensions/visual-context/bridge.ts +240 -0
- package/extensions/visual-context/broker.ts +292 -0
- package/extensions/visual-context/envelope.ts +34 -0
- package/extensions/visual-context/index.ts +328 -0
- package/extensions/visual-context/literal.ts +34 -0
- package/extensions/visual-context/markup.ts +104 -0
- package/extensions/visual-context/match.ts +222 -0
- package/extensions/visual-context/model.ts +180 -0
- package/extensions/visual-context/pairing.ts +114 -0
- package/extensions/visual-context/parse-astro.ts +166 -0
- package/extensions/visual-context/parse-html.ts +72 -0
- package/extensions/visual-context/parse-jsx.ts +157 -0
- package/extensions/visual-context/parse-svelte.ts +128 -0
- package/extensions/visual-context/parse-vue.ts +133 -0
- package/extensions/visual-context/session.ts +192 -0
- package/extensions/visual-context/source.ts +197 -0
- package/extensions/visual-context/ui.ts +57 -0
- package/package.json +16 -7
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { Check } from 'typebox/value';
|
|
4
|
+
import { type WebSocket, WebSocketServer } from 'ws';
|
|
5
|
+
import { parseEnvelope, send } from './envelope.ts';
|
|
6
|
+
import {
|
|
7
|
+
BRIDGE_PATH,
|
|
8
|
+
MAX_MESSAGE_BYTES,
|
|
9
|
+
MESSAGE_TYPE,
|
|
10
|
+
PEER_ROLE,
|
|
11
|
+
PI_ORIGIN,
|
|
12
|
+
PROTOCOL_VERSION,
|
|
13
|
+
parseSelection,
|
|
14
|
+
type SessionDescriptor,
|
|
15
|
+
SessionDescriptorSchema,
|
|
16
|
+
} from './model.ts';
|
|
17
|
+
import type { PairingIdentity } from './pairing.ts';
|
|
18
|
+
|
|
19
|
+
const MAX_PEERS = 20;
|
|
20
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
21
|
+
const MIN_SELECTION_INTERVAL_MS = 300;
|
|
22
|
+
const EXTENSION_ORIGIN = /^chrome-extension:\/\/[a-p]{32}$/u;
|
|
23
|
+
interface Peer {
|
|
24
|
+
role?: string;
|
|
25
|
+
session?: SessionDescriptor;
|
|
26
|
+
lastSelectionAt: number;
|
|
27
|
+
}
|
|
28
|
+
interface Delivery {
|
|
29
|
+
browser: WebSocket;
|
|
30
|
+
pi: WebSocket;
|
|
31
|
+
id: string;
|
|
32
|
+
revision: string;
|
|
33
|
+
timer: NodeJS.Timeout;
|
|
34
|
+
}
|
|
35
|
+
export interface LocalBroker {
|
|
36
|
+
close(): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function startBroker({
|
|
40
|
+
identity,
|
|
41
|
+
onError,
|
|
42
|
+
}: {
|
|
43
|
+
readonly identity: PairingIdentity;
|
|
44
|
+
readonly onError: (error: Error) => void;
|
|
45
|
+
}): Promise<LocalBroker> {
|
|
46
|
+
const server = createServer((_request, response) => {
|
|
47
|
+
response.writeHead(404, { 'Cache-Control': 'no-store' });
|
|
48
|
+
response.end();
|
|
49
|
+
});
|
|
50
|
+
const sockets = new WebSocketServer({
|
|
51
|
+
noServer: true,
|
|
52
|
+
maxPayload: MAX_MESSAGE_BYTES,
|
|
53
|
+
});
|
|
54
|
+
const peers = new Map<WebSocket, Peer>();
|
|
55
|
+
const deliveries = new Map<string, Delivery>();
|
|
56
|
+
const sessions = () =>
|
|
57
|
+
[...peers.values()].flatMap((peer) => (peer.session ? [peer.session] : []));
|
|
58
|
+
const publish = () => {
|
|
59
|
+
for (const [socket, peer] of peers) {
|
|
60
|
+
if (peer.role === PEER_ROLE.browser) {
|
|
61
|
+
send(socket, { type: MESSAGE_TYPE.sessions, sessions: sessions() });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
server.on('upgrade', (request, socket, head) => {
|
|
66
|
+
const origin = request.headers.origin;
|
|
67
|
+
if (
|
|
68
|
+
request.url !== BRIDGE_PATH ||
|
|
69
|
+
typeof origin !== 'string' ||
|
|
70
|
+
(origin !== PI_ORIGIN && !EXTENSION_ORIGIN.test(origin)) ||
|
|
71
|
+
peers.size >= MAX_PEERS
|
|
72
|
+
) {
|
|
73
|
+
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
sockets.handleUpgrade(request, socket, head, (connection) =>
|
|
77
|
+
sockets.emit('connection', connection, origin),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
sockets.on('connection', (socket: WebSocket, origin: string) => {
|
|
81
|
+
const peer: Peer = { lastSelectionAt: 0 };
|
|
82
|
+
peers.set(socket, peer);
|
|
83
|
+
const authenticationTimeout = setTimeout(
|
|
84
|
+
() => socket.terminate(),
|
|
85
|
+
REQUEST_TIMEOUT_MS,
|
|
86
|
+
);
|
|
87
|
+
authenticationTimeout.unref();
|
|
88
|
+
socket.on('error', onError);
|
|
89
|
+
socket.on('close', () => {
|
|
90
|
+
clearTimeout(authenticationTimeout);
|
|
91
|
+
peers.delete(socket);
|
|
92
|
+
for (const [requestId, delivery] of deliveries) {
|
|
93
|
+
if (delivery.pi === socket || delivery.browser === socket) {
|
|
94
|
+
clearTimeout(delivery.timer);
|
|
95
|
+
deliveries.delete(requestId);
|
|
96
|
+
if (delivery.browser !== socket) {
|
|
97
|
+
send(delivery.browser, {
|
|
98
|
+
type: MESSAGE_TYPE.error,
|
|
99
|
+
id: delivery.id,
|
|
100
|
+
message:
|
|
101
|
+
'The destination session disconnected. Select the element again.',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
publish();
|
|
107
|
+
});
|
|
108
|
+
socket.on('message', (bytes, binary) => {
|
|
109
|
+
try {
|
|
110
|
+
const message = parseEnvelope(bytes.toString(), binary);
|
|
111
|
+
if (!peer.role) {
|
|
112
|
+
const expectedRole =
|
|
113
|
+
origin === PI_ORIGIN ? PEER_ROLE.pi : PEER_ROLE.browser;
|
|
114
|
+
if (
|
|
115
|
+
message.type !== MESSAGE_TYPE.authenticate ||
|
|
116
|
+
message.version !== PROTOCOL_VERSION ||
|
|
117
|
+
message.role !== expectedRole ||
|
|
118
|
+
!matchesSecret(message.token, identity.token)
|
|
119
|
+
) {
|
|
120
|
+
socket.close(1008, 'Invalid pairing');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
peer.role = expectedRole;
|
|
124
|
+
clearTimeout(authenticationTimeout);
|
|
125
|
+
send(socket, {
|
|
126
|
+
type: MESSAGE_TYPE.ready,
|
|
127
|
+
version: PROTOCOL_VERSION,
|
|
128
|
+
sessions: sessions(),
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (message.type === MESSAGE_TYPE.ping) {
|
|
133
|
+
send(socket, { type: MESSAGE_TYPE.pong });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (peer.role === PEER_ROLE.pi) {
|
|
137
|
+
if (
|
|
138
|
+
message.type === MESSAGE_TYPE.register &&
|
|
139
|
+
Check(SessionDescriptorSchema, message.session)
|
|
140
|
+
) {
|
|
141
|
+
const registeredSession = message.session;
|
|
142
|
+
if (peer.session && peer.session.id !== registeredSession.id) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
'A Pi connection cannot replace another instance.',
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (
|
|
148
|
+
[...peers.values()].some(
|
|
149
|
+
(other) =>
|
|
150
|
+
other !== peer && other.session?.id === registeredSession.id,
|
|
151
|
+
)
|
|
152
|
+
) {
|
|
153
|
+
throw new Error('The Pi instance is already registered.');
|
|
154
|
+
}
|
|
155
|
+
peer.session = registeredSession;
|
|
156
|
+
send(socket, {
|
|
157
|
+
type: MESSAGE_TYPE.register,
|
|
158
|
+
revision: peer.session.revision,
|
|
159
|
+
});
|
|
160
|
+
publish();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (
|
|
164
|
+
message.type === MESSAGE_TYPE.result &&
|
|
165
|
+
typeof message.requestId === 'string'
|
|
166
|
+
) {
|
|
167
|
+
const delivery = deliveries.get(message.requestId);
|
|
168
|
+
if (!delivery || delivery.pi !== socket) {
|
|
169
|
+
throw new Error('Unknown selection receipt.');
|
|
170
|
+
}
|
|
171
|
+
clearTimeout(delivery.timer);
|
|
172
|
+
deliveries.delete(message.requestId);
|
|
173
|
+
const stale = peer.session?.revision !== delivery.revision;
|
|
174
|
+
send(
|
|
175
|
+
delivery.browser,
|
|
176
|
+
typeof message.error === 'string' || stale
|
|
177
|
+
? {
|
|
178
|
+
type: MESSAGE_TYPE.error,
|
|
179
|
+
id: delivery.id,
|
|
180
|
+
message: stale
|
|
181
|
+
? 'The session changed. Select the element again.'
|
|
182
|
+
: String(message.error).slice(0, 500),
|
|
183
|
+
}
|
|
184
|
+
: {
|
|
185
|
+
type: MESSAGE_TYPE.received,
|
|
186
|
+
id: delivery.id,
|
|
187
|
+
candidates: message.candidates,
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
throw new Error('Invalid Pi message.');
|
|
193
|
+
}
|
|
194
|
+
if (
|
|
195
|
+
message.type !== MESSAGE_TYPE.selection ||
|
|
196
|
+
!Check(SessionDescriptorSchema, message.target)
|
|
197
|
+
) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
'Choose a destination session in the browser companion.',
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const target = message.target;
|
|
203
|
+
const destination = [...peers.entries()].find(
|
|
204
|
+
([, entry]) =>
|
|
205
|
+
entry.session?.id === target.id &&
|
|
206
|
+
entry.session.revision === target.revision,
|
|
207
|
+
);
|
|
208
|
+
if (!destination) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
'The destination session changed. Select the element again.',
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (
|
|
214
|
+
Date.now() - peer.lastSelectionAt < MIN_SELECTION_INTERVAL_MS ||
|
|
215
|
+
deliveries.size >= 128
|
|
216
|
+
) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
'Pi is still processing selections. Try again shortly.',
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const selection = parseSelection(message.selection);
|
|
222
|
+
peer.lastSelectionAt = Date.now();
|
|
223
|
+
const requestId = randomUUID();
|
|
224
|
+
const timer = setTimeout(() => {
|
|
225
|
+
deliveries.delete(requestId);
|
|
226
|
+
send(socket, {
|
|
227
|
+
type: MESSAGE_TYPE.error,
|
|
228
|
+
id: selection.id,
|
|
229
|
+
message:
|
|
230
|
+
'The destination session did not respond. Select the element again.',
|
|
231
|
+
});
|
|
232
|
+
}, REQUEST_TIMEOUT_MS);
|
|
233
|
+
timer.unref();
|
|
234
|
+
deliveries.set(requestId, {
|
|
235
|
+
browser: socket,
|
|
236
|
+
pi: destination[0],
|
|
237
|
+
id: selection.id,
|
|
238
|
+
revision: target.revision,
|
|
239
|
+
timer,
|
|
240
|
+
});
|
|
241
|
+
send(destination[0], {
|
|
242
|
+
type: MESSAGE_TYPE.deliver,
|
|
243
|
+
requestId,
|
|
244
|
+
revision: target.revision,
|
|
245
|
+
selection,
|
|
246
|
+
});
|
|
247
|
+
} catch (cause) {
|
|
248
|
+
const error =
|
|
249
|
+
cause instanceof Error
|
|
250
|
+
? cause
|
|
251
|
+
: new Error('Cannot route browser selection.', { cause });
|
|
252
|
+
send(socket, { type: MESSAGE_TYPE.error, message: error.message });
|
|
253
|
+
if (!peer.role) {
|
|
254
|
+
socket.close(1008, 'Invalid pairing');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
await new Promise<void>((resolve, reject) => {
|
|
260
|
+
server.once('error', reject);
|
|
261
|
+
server.listen(identity.port, '127.0.0.1', () => {
|
|
262
|
+
server.removeListener('error', reject);
|
|
263
|
+
resolve();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
server.on('error', onError);
|
|
267
|
+
let closing: Promise<void> | undefined;
|
|
268
|
+
return {
|
|
269
|
+
close() {
|
|
270
|
+
closing ??= new Promise<void>((resolve, reject) => {
|
|
271
|
+
for (const delivery of deliveries.values()) {
|
|
272
|
+
clearTimeout(delivery.timer);
|
|
273
|
+
}
|
|
274
|
+
deliveries.clear();
|
|
275
|
+
for (const socket of sockets.clients) {
|
|
276
|
+
socket.terminate();
|
|
277
|
+
}
|
|
278
|
+
sockets.close();
|
|
279
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
280
|
+
});
|
|
281
|
+
return closing;
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function matchesSecret(token: unknown, secret: string): boolean {
|
|
287
|
+
return (
|
|
288
|
+
typeof token === 'string' &&
|
|
289
|
+
/^[a-f0-9]{64}$/u.test(token) &&
|
|
290
|
+
timingSafeEqual(Buffer.from(token), Buffer.from(secret))
|
|
291
|
+
);
|
|
292
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { WebSocket } from 'ws';
|
|
2
|
+
|
|
3
|
+
const INVALID_MESSAGE = 'Invalid inspector message.';
|
|
4
|
+
|
|
5
|
+
export function send(
|
|
6
|
+
socket: WebSocket,
|
|
7
|
+
message: Readonly<Record<string, unknown>>,
|
|
8
|
+
): void {
|
|
9
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
10
|
+
socket.send(JSON.stringify(message));
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseEnvelope(
|
|
15
|
+
source: string,
|
|
16
|
+
binary = false,
|
|
17
|
+
): Record<string, unknown> {
|
|
18
|
+
let message: unknown;
|
|
19
|
+
try {
|
|
20
|
+
message = JSON.parse(source);
|
|
21
|
+
} catch (cause) {
|
|
22
|
+
throw new Error(INVALID_MESSAGE, { cause });
|
|
23
|
+
}
|
|
24
|
+
if (
|
|
25
|
+
binary ||
|
|
26
|
+
!message ||
|
|
27
|
+
typeof message !== 'object' ||
|
|
28
|
+
Array.isArray(message) ||
|
|
29
|
+
!('type' in message)
|
|
30
|
+
) {
|
|
31
|
+
throw new Error(INVALID_MESSAGE);
|
|
32
|
+
}
|
|
33
|
+
return message as Record<string, unknown>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
getAgentDir,
|
|
6
|
+
} from '@earendil-works/pi-coding-agent';
|
|
7
|
+
import { connectSession, type VisualBridge } from './bridge.ts';
|
|
8
|
+
import {
|
|
9
|
+
candidateLabel,
|
|
10
|
+
cleanLabel,
|
|
11
|
+
elementLabel,
|
|
12
|
+
type PendingSelection,
|
|
13
|
+
type SessionDescriptor,
|
|
14
|
+
VISUAL_SHORTCUT,
|
|
15
|
+
VISUAL_WIDGET,
|
|
16
|
+
} from './model.ts';
|
|
17
|
+
import { ensurePairing, pairingAddress, readPairing } from './pairing.ts';
|
|
18
|
+
import {
|
|
19
|
+
commitPending,
|
|
20
|
+
createVisualStore,
|
|
21
|
+
prepareContext,
|
|
22
|
+
receiveSelection,
|
|
23
|
+
restorePending,
|
|
24
|
+
} from './session.ts';
|
|
25
|
+
import { candidateIsCurrent } from './source.ts';
|
|
26
|
+
import { BROWSER_EXTENSION_DIRECTORY, refreshVisualWidget } from './ui.ts';
|
|
27
|
+
|
|
28
|
+
const CONTROL = {
|
|
29
|
+
pair: 'Pair browser',
|
|
30
|
+
stop: 'Disconnect browser',
|
|
31
|
+
clear: 'Remove all pending elements',
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
type VisualDialogOption =
|
|
35
|
+
| { readonly label: string; readonly action: 'pair' }
|
|
36
|
+
| { readonly label: string; readonly action: 'stop' }
|
|
37
|
+
| { readonly label: string; readonly action: 'clear' }
|
|
38
|
+
| {
|
|
39
|
+
readonly label: string;
|
|
40
|
+
readonly action: 'review';
|
|
41
|
+
readonly entry: PendingSelection;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function notifyRestoreFailure(ctx: ExtensionContext, cause: unknown): void {
|
|
45
|
+
ctx.ui.notify(`Cannot restore browser context: ${String(cause)}`, 'error');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default function registerVisualContext(
|
|
49
|
+
pi: ExtensionAPI,
|
|
50
|
+
agentDirectory = getAgentDir(),
|
|
51
|
+
): void {
|
|
52
|
+
const store = createVisualStore();
|
|
53
|
+
let bridge: VisualBridge | undefined;
|
|
54
|
+
let activeContext: ExtensionContext | undefined;
|
|
55
|
+
let opening = false;
|
|
56
|
+
let generation = 0;
|
|
57
|
+
let preparedContext: ReturnType<typeof prepareContext>;
|
|
58
|
+
const instanceId = randomUUID();
|
|
59
|
+
let destination: SessionDescriptor | undefined;
|
|
60
|
+
|
|
61
|
+
async function stopBridge(): Promise<void> {
|
|
62
|
+
generation += 1;
|
|
63
|
+
preparedContext = undefined;
|
|
64
|
+
const previous = bridge;
|
|
65
|
+
bridge = undefined;
|
|
66
|
+
await previous?.close();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const refresh = (ctx: ExtensionContext): void =>
|
|
70
|
+
refreshVisualWidget(store.pending, ctx);
|
|
71
|
+
const savePending = (
|
|
72
|
+
ctx: ExtensionContext,
|
|
73
|
+
pending: readonly PendingSelection[],
|
|
74
|
+
): void => {
|
|
75
|
+
commitPending({ store, pi, pending });
|
|
76
|
+
refresh(ctx);
|
|
77
|
+
};
|
|
78
|
+
// An async dialog may only act while it still belongs to the active session.
|
|
79
|
+
const dialogIsCurrent = (ctx: ExtensionContext, epoch: number): boolean =>
|
|
80
|
+
epoch === generation && activeContext === ctx;
|
|
81
|
+
async function activate(ctx: ExtensionContext): Promise<void> {
|
|
82
|
+
generation += 1;
|
|
83
|
+
preparedContext = undefined;
|
|
84
|
+
activeContext = ctx;
|
|
85
|
+
if (ctx.mode !== 'tui') {
|
|
86
|
+
await stopBridge();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
destination = {
|
|
90
|
+
id: instanceId,
|
|
91
|
+
revision: randomUUID(),
|
|
92
|
+
name: cleanLabel(
|
|
93
|
+
ctx.sessionManager.getSessionName() ??
|
|
94
|
+
ctx.sessionManager.getSessionId(),
|
|
95
|
+
160,
|
|
96
|
+
),
|
|
97
|
+
project: cleanLabel(ctx.cwd, 512),
|
|
98
|
+
environment:
|
|
99
|
+
process.platform === 'win32'
|
|
100
|
+
? 'Windows'
|
|
101
|
+
: process.env.WSL_DISTRO_NAME
|
|
102
|
+
? `WSL (${cleanLabel(process.env.WSL_DISTRO_NAME, 140)})`
|
|
103
|
+
: process.platform,
|
|
104
|
+
};
|
|
105
|
+
restorePending({ store, ctx });
|
|
106
|
+
refresh(ctx);
|
|
107
|
+
if (!ctx.isProjectTrusted()) {
|
|
108
|
+
await stopBridge();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
bridge?.update(destination);
|
|
112
|
+
if (!bridge) {
|
|
113
|
+
const identity = readPairing(agentDirectory);
|
|
114
|
+
if (identity) {
|
|
115
|
+
await openBridge(identity);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function openBridge(
|
|
120
|
+
identity: Awaited<ReturnType<typeof ensurePairing>>,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
if (!destination) {
|
|
123
|
+
throw new Error('The Pi conversation is not available.');
|
|
124
|
+
}
|
|
125
|
+
const captured = generation;
|
|
126
|
+
const created = await connectSession({
|
|
127
|
+
identity,
|
|
128
|
+
session: destination,
|
|
129
|
+
onSelection: (selection, revision) => {
|
|
130
|
+
const current = activeContext;
|
|
131
|
+
if (
|
|
132
|
+
!current?.isProjectTrusted() ||
|
|
133
|
+
destination?.revision !== revision
|
|
134
|
+
) {
|
|
135
|
+
throw new Error('The destination Pi session is no longer trusted.');
|
|
136
|
+
}
|
|
137
|
+
const receipt = receiveSelection({
|
|
138
|
+
store,
|
|
139
|
+
pi,
|
|
140
|
+
cwd: current.cwd,
|
|
141
|
+
selection,
|
|
142
|
+
});
|
|
143
|
+
refresh(current);
|
|
144
|
+
return receipt;
|
|
145
|
+
},
|
|
146
|
+
onError: (error) => activeContext?.ui.notify(error.message, 'error'),
|
|
147
|
+
});
|
|
148
|
+
const current = activeContext;
|
|
149
|
+
if (!current || !dialogIsCurrent(current, captured)) {
|
|
150
|
+
await created.close();
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
bridge = created;
|
|
154
|
+
}
|
|
155
|
+
const activateSafely = async (ctx: ExtensionContext): Promise<void> => {
|
|
156
|
+
try {
|
|
157
|
+
await activate(ctx);
|
|
158
|
+
} catch (cause) {
|
|
159
|
+
notifyRestoreFailure(ctx, cause);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
pi.on('session_start', (_event, ctx) => activateSafely(ctx));
|
|
163
|
+
pi.on('session_tree', (_event, ctx) => activateSafely(ctx));
|
|
164
|
+
pi.on('session_compact', (_event, ctx) => {
|
|
165
|
+
activeContext = ctx;
|
|
166
|
+
try {
|
|
167
|
+
restorePending({ store, ctx });
|
|
168
|
+
} catch (cause) {
|
|
169
|
+
notifyRestoreFailure(ctx, cause);
|
|
170
|
+
}
|
|
171
|
+
refresh(ctx);
|
|
172
|
+
});
|
|
173
|
+
pi.on('session_shutdown', async (_event, ctx) => {
|
|
174
|
+
activeContext = undefined;
|
|
175
|
+
await stopBridge();
|
|
176
|
+
ctx.ui.setWidget(VISUAL_WIDGET, undefined);
|
|
177
|
+
});
|
|
178
|
+
pi.on('input', (event, ctx) => {
|
|
179
|
+
if (
|
|
180
|
+
event.source !== 'interactive' ||
|
|
181
|
+
!ctx.isIdle() ||
|
|
182
|
+
event.streamingBehavior !== undefined ||
|
|
183
|
+
/^[!/]/u.test(event.text.trimStart())
|
|
184
|
+
) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const context = prepareContext({ store, cwd: ctx.cwd });
|
|
188
|
+
if (context === undefined) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
preparedContext = context;
|
|
192
|
+
return {
|
|
193
|
+
action: 'transform',
|
|
194
|
+
text: `${event.text}\n\n${context.text}`,
|
|
195
|
+
images: event.images,
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
// Input precedes model/auth validation in Pi. Keep the pending snapshot until
|
|
199
|
+
// the run actually starts so a failed submission cannot lose the attachment.
|
|
200
|
+
pi.on('before_agent_start', (event, ctx) => {
|
|
201
|
+
const attachment = preparedContext;
|
|
202
|
+
preparedContext = undefined;
|
|
203
|
+
if (!attachment || !event.prompt.includes(attachment.text)) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
savePending(
|
|
207
|
+
ctx,
|
|
208
|
+
store.pending.filter(
|
|
209
|
+
(entry) => !attachment.selectionIds.includes(entry.selection.id),
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
pi.registerShortcut(VISUAL_SHORTCUT, {
|
|
214
|
+
description: 'Pair the browser or review selected elements',
|
|
215
|
+
handler: async (ctx) => {
|
|
216
|
+
if (opening || ctx.mode !== 'tui') {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (!ctx.isProjectTrusted()) {
|
|
220
|
+
ctx.ui.notify(
|
|
221
|
+
'Trust this project in Pi before connecting the browser.',
|
|
222
|
+
'warning',
|
|
223
|
+
);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
opening = true;
|
|
227
|
+
try {
|
|
228
|
+
if (!destination || activeContext !== ctx) {
|
|
229
|
+
await activate(ctx);
|
|
230
|
+
}
|
|
231
|
+
const dialogGeneration = generation;
|
|
232
|
+
const stopOptions: readonly VisualDialogOption[] = bridge
|
|
233
|
+
? [{ label: CONTROL.stop, action: 'stop' }]
|
|
234
|
+
: [];
|
|
235
|
+
const options: readonly VisualDialogOption[] = [
|
|
236
|
+
{ label: CONTROL.pair, action: 'pair' },
|
|
237
|
+
...store.pending.map(
|
|
238
|
+
(entry): VisualDialogOption => ({
|
|
239
|
+
label: `Review ${elementLabel(entry.selection)} [${entry.selection.id}]`,
|
|
240
|
+
action: 'review',
|
|
241
|
+
entry,
|
|
242
|
+
}),
|
|
243
|
+
),
|
|
244
|
+
{ label: CONTROL.clear, action: 'clear' },
|
|
245
|
+
...stopOptions,
|
|
246
|
+
];
|
|
247
|
+
const choice = await ctx.ui.select(
|
|
248
|
+
'Browser context',
|
|
249
|
+
options.map((option) => option.label),
|
|
250
|
+
);
|
|
251
|
+
if (!dialogIsCurrent(ctx, dialogGeneration)) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const picked = options.find((option) => option.label === choice);
|
|
255
|
+
if (!picked) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (picked.action === 'pair') {
|
|
259
|
+
const identity = await ensurePairing(agentDirectory);
|
|
260
|
+
if (!bridge) {
|
|
261
|
+
await openBridge(identity);
|
|
262
|
+
}
|
|
263
|
+
if (!dialogIsCurrent(ctx, dialogGeneration)) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
ctx.ui.notify(
|
|
267
|
+
`Load the browser extension from ${BROWSER_EXTENSION_DIRECTORY}. Pair once with this address: ${pairingAddress(identity)}. The browser reconnects to future sessions automatically.`,
|
|
268
|
+
'info',
|
|
269
|
+
);
|
|
270
|
+
await ctx.ui.editor(
|
|
271
|
+
'Pairing address — copy into the Sideroom browser extension',
|
|
272
|
+
pairingAddress(identity),
|
|
273
|
+
);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (picked.action === 'stop') {
|
|
277
|
+
await stopBridge();
|
|
278
|
+
ctx.ui.notify(
|
|
279
|
+
'This session stopped receiving browser elements. Other Pi instances remain available. Pending elements are preserved.',
|
|
280
|
+
'info',
|
|
281
|
+
);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (picked.action === 'clear') {
|
|
285
|
+
savePending(ctx, []);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const entry = picked.entry;
|
|
289
|
+
const labels = entry.resolution.candidates.map(
|
|
290
|
+
(candidate) =>
|
|
291
|
+
`${candidateLabel(candidate)} · ${candidate.reasons.join(', ')}`,
|
|
292
|
+
);
|
|
293
|
+
const remove = 'Remove this element';
|
|
294
|
+
const selected = await ctx.ui.select(
|
|
295
|
+
'Choose a source candidate — origin is inferred',
|
|
296
|
+
[...labels, remove],
|
|
297
|
+
);
|
|
298
|
+
if (!dialogIsCurrent(ctx, dialogGeneration)) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (selected === undefined) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const candidate = entry.resolution.candidates[labels.indexOf(selected)];
|
|
305
|
+
if (candidate && !candidateIsCurrent(ctx.cwd, candidate)) {
|
|
306
|
+
ctx.ui.notify(
|
|
307
|
+
'The source changed. Select the element again in your browser.',
|
|
308
|
+
'warning',
|
|
309
|
+
);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const pending =
|
|
313
|
+
selected === remove
|
|
314
|
+
? store.pending.filter(
|
|
315
|
+
(item) => item.selection.id !== entry.selection.id,
|
|
316
|
+
)
|
|
317
|
+
: store.pending.map((item) =>
|
|
318
|
+
item.selection.id === entry.selection.id
|
|
319
|
+
? { ...item, chosen: candidate }
|
|
320
|
+
: item,
|
|
321
|
+
);
|
|
322
|
+
savePending(ctx, pending);
|
|
323
|
+
} finally {
|
|
324
|
+
opening = false;
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { parseExpression } from '@babel/parser';
|
|
2
|
+
|
|
3
|
+
const SYNTAX_ERROR_CODES = new Set([
|
|
4
|
+
'BABEL_PARSER_SYNTAX_ERROR',
|
|
5
|
+
'BABEL_PARSER_SOURCETYPE_MODULE_REQUIRED',
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
export function isBabelSyntaxError(cause: unknown): boolean {
|
|
9
|
+
return (
|
|
10
|
+
cause instanceof Error &&
|
|
11
|
+
'code' in cause &&
|
|
12
|
+
SYNTAX_ERROR_CODES.has(String(cause.code))
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Only syntax literals are read. No inspected source is imported or executed.
|
|
17
|
+
export function literalText(expression: string): string | undefined {
|
|
18
|
+
let node: ReturnType<typeof parseExpression>;
|
|
19
|
+
try {
|
|
20
|
+
node = parseExpression(expression, { plugins: ['typescript'] });
|
|
21
|
+
} catch (cause) {
|
|
22
|
+
if (isBabelSyntaxError(cause)) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
throw new Error('Cannot parse a source expression', { cause });
|
|
26
|
+
}
|
|
27
|
+
if (node.type === 'StringLiteral') {
|
|
28
|
+
return node.value;
|
|
29
|
+
}
|
|
30
|
+
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
|
|
31
|
+
return node.quasis[0]?.value.cooked ?? undefined;
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|