@fnava621/pilens 0.1.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 +32 -0
- package/index.ts +1481 -0
- package/package.json +35 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1481 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
4
|
+
import { mkdir, open, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
7
|
+
import type { AddressInfo } from "node:net";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import {
|
|
11
|
+
Box,
|
|
12
|
+
Image,
|
|
13
|
+
Key,
|
|
14
|
+
Spacer,
|
|
15
|
+
Text,
|
|
16
|
+
deleteKittyImage,
|
|
17
|
+
getCapabilities,
|
|
18
|
+
getImageDimensions,
|
|
19
|
+
matchesKey,
|
|
20
|
+
truncateToWidth,
|
|
21
|
+
visibleWidth,
|
|
22
|
+
} from "@earendil-works/pi-tui";
|
|
23
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
24
|
+
|
|
25
|
+
interface BridgeState {
|
|
26
|
+
available: boolean;
|
|
27
|
+
busy: boolean;
|
|
28
|
+
protocolVersion: number;
|
|
29
|
+
cwd: string;
|
|
30
|
+
sessionFile?: string;
|
|
31
|
+
sessionId?: string;
|
|
32
|
+
sessionName?: string;
|
|
33
|
+
lastUserMessage?: string;
|
|
34
|
+
lastUserMessageAt?: string;
|
|
35
|
+
hasUI?: boolean;
|
|
36
|
+
stdoutIsTTY?: boolean;
|
|
37
|
+
tty?: string;
|
|
38
|
+
heartbeatAt: string;
|
|
39
|
+
updatedAt: string;
|
|
40
|
+
port?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface DeliverPayload {
|
|
44
|
+
text?: string;
|
|
45
|
+
annotatedImageBase64: string;
|
|
46
|
+
annotatedMediaType?: string;
|
|
47
|
+
metadata?: unknown;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface DeliverResult {
|
|
51
|
+
ok: boolean;
|
|
52
|
+
deliveryMode: "immediate" | "steer";
|
|
53
|
+
confirmed: boolean;
|
|
54
|
+
confirmation: "accepted" | "queued" | "sessionUpdated";
|
|
55
|
+
message: string;
|
|
56
|
+
busy: boolean;
|
|
57
|
+
sessionFile?: string;
|
|
58
|
+
sessionId?: string;
|
|
59
|
+
sessionName?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface CaptureMetadata {
|
|
63
|
+
version?: number;
|
|
64
|
+
createdAt?: string;
|
|
65
|
+
sourceImagePath?: string;
|
|
66
|
+
annotatedImagePath?: string;
|
|
67
|
+
annotations?: unknown[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface CapturePreviewDetails {
|
|
71
|
+
targetSession: string;
|
|
72
|
+
deliveryMode: "immediate" | "steer";
|
|
73
|
+
sentAt: string;
|
|
74
|
+
protocolVersion: number;
|
|
75
|
+
annotatedMediaType: string;
|
|
76
|
+
annotatedBytes: number;
|
|
77
|
+
annotationCount?: number;
|
|
78
|
+
metadataVersion?: number;
|
|
79
|
+
metadataCreatedAt?: string;
|
|
80
|
+
annotatedImagePath?: string;
|
|
81
|
+
sessionFile?: string;
|
|
82
|
+
sessionId?: string;
|
|
83
|
+
sessionName?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type CapturePreviewContentBlock =
|
|
87
|
+
| { type: "text"; text: string }
|
|
88
|
+
| { type: "image"; data: string; mimeType: string };
|
|
89
|
+
|
|
90
|
+
interface CaptureOption {
|
|
91
|
+
name: string;
|
|
92
|
+
directory: string;
|
|
93
|
+
imagePath: string;
|
|
94
|
+
mediaType: string;
|
|
95
|
+
sortTimeMs: number;
|
|
96
|
+
createdAt?: string;
|
|
97
|
+
annotationCount?: number;
|
|
98
|
+
metadata: Record<string, unknown>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface LastUserMessagePreview {
|
|
102
|
+
text: string;
|
|
103
|
+
sentAt?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const REGISTRY_PATH = join(homedir(), ".pi", "agent", "pilens-bridge.json");
|
|
107
|
+
const REGISTRIES_DIRECTORY = join(homedir(), ".pi", "agent", "pilens-bridges");
|
|
108
|
+
const NATIVE_REGISTRY_PATH = join(homedir(), ".pi", "agent", "pilens-native.json");
|
|
109
|
+
const TRIGGER_PATH = join(homedir(), ".pi", "agent", "pilens-trigger.json");
|
|
110
|
+
const CAPTURES_DIRECTORY = join(homedir(), "Library", "Application Support", "PiLens", "Captures");
|
|
111
|
+
const INSTALLED_NATIVE_APP_PATH = "/Applications/PiLens.app";
|
|
112
|
+
const INSTALLED_NATIVE_APP_BINARY = join(INSTALLED_NATIVE_APP_PATH, "Contents", "MacOS", "PiLens");
|
|
113
|
+
const PACKAGE_DIRECTORY = dirname(fileURLToPath(import.meta.url));
|
|
114
|
+
const NATIVE_APP_DIRECTORY = process.env.PILENS_NATIVE_APP_DIRECTORY ?? join(PACKAGE_DIRECTORY, "..", "..", "native");
|
|
115
|
+
const NATIVE_DEBUG_BINARY = join(NATIVE_APP_DIRECTORY, ".build", "debug", "PiLens");
|
|
116
|
+
const NATIVE_RELEASE_BINARY = join(NATIVE_APP_DIRECTORY, ".build", "release", "PiLens");
|
|
117
|
+
const HOST = "127.0.0.1";
|
|
118
|
+
const BRIDGE_PROTOCOL_VERSION = 2;
|
|
119
|
+
const NATIVE_LAUNCH_POLL_MS = 500;
|
|
120
|
+
const NATIVE_LAUNCH_GRACE_MS = 60000;
|
|
121
|
+
const BRIDGE_HEARTBEAT_MS = 15000;
|
|
122
|
+
const SESSION_PREVIEW_TAIL_BYTES = 256 * 1024;
|
|
123
|
+
const SESSION_PREVIEW_MAX_LENGTH = 180;
|
|
124
|
+
|
|
125
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
126
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseCaptureMetadata(value: unknown): CaptureMetadata | undefined {
|
|
130
|
+
if (!isRecord(value)) return undefined;
|
|
131
|
+
return {
|
|
132
|
+
version: typeof value.version === "number" ? value.version : undefined,
|
|
133
|
+
createdAt: typeof value.createdAt === "string" ? value.createdAt : undefined,
|
|
134
|
+
sourceImagePath: typeof value.sourceImagePath === "string" ? value.sourceImagePath : undefined,
|
|
135
|
+
annotatedImagePath: typeof value.annotatedImagePath === "string" ? value.annotatedImagePath : undefined,
|
|
136
|
+
annotations: Array.isArray(value.annotations) ? value.annotations : undefined,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function formatBytes(bytes: number): string {
|
|
141
|
+
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
|
142
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
143
|
+
let value = bytes;
|
|
144
|
+
let unitIndex = 0;
|
|
145
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
146
|
+
value /= 1024;
|
|
147
|
+
unitIndex += 1;
|
|
148
|
+
}
|
|
149
|
+
const digits = value >= 100 || unitIndex === 0 ? 0 : value >= 10 ? 1 : 2;
|
|
150
|
+
return `${value.toFixed(digits)} ${units[unitIndex]}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function formatPathLine(label: string, path?: string): string {
|
|
154
|
+
return path ? `${label}: ${path}` : `${label}: embedded image payload`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isSupportedImageName(name: string): boolean {
|
|
158
|
+
return /\.(png|jpe?g|gif|webp)$/i.test(name);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function inferMediaType(imagePath: string): string {
|
|
162
|
+
switch (extname(imagePath).toLowerCase()) {
|
|
163
|
+
case ".jpg":
|
|
164
|
+
case ".jpeg":
|
|
165
|
+
return "image/jpeg";
|
|
166
|
+
case ".gif":
|
|
167
|
+
return "image/gif";
|
|
168
|
+
case ".webp":
|
|
169
|
+
return "image/webp";
|
|
170
|
+
case ".png":
|
|
171
|
+
default:
|
|
172
|
+
return "image/png";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function formatCaptureTimestamp(capture: CaptureOption): string {
|
|
177
|
+
const date = new Date(capture.createdAt ?? capture.sortTimeMs);
|
|
178
|
+
if (Number.isNaN(date.getTime())) return capture.name;
|
|
179
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
180
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
181
|
+
const hour = String(date.getHours()).padStart(2, "0");
|
|
182
|
+
const minute = String(date.getMinutes()).padStart(2, "0");
|
|
183
|
+
return `${month}-${day} ${hour}:${minute}`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function fitCell(text: string, width: number): string {
|
|
187
|
+
const targetWidth = Math.max(0, width);
|
|
188
|
+
const truncated = truncateToWidth(text, targetWidth, "");
|
|
189
|
+
return `${truncated}${" ".repeat(Math.max(0, targetWidth - visibleWidth(truncated)))}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function fileExists(path: string | undefined): Promise<boolean> {
|
|
193
|
+
if (!path) return false;
|
|
194
|
+
try {
|
|
195
|
+
return (await stat(path)).isFile();
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function readCaptureMetadataFile(directory: string): Promise<{
|
|
202
|
+
raw?: Record<string, unknown>;
|
|
203
|
+
parsed?: CaptureMetadata;
|
|
204
|
+
}> {
|
|
205
|
+
try {
|
|
206
|
+
const raw = JSON.parse(await readFile(join(directory, "annotations.json"), "utf8")) as unknown;
|
|
207
|
+
return {
|
|
208
|
+
raw: isRecord(raw) ? raw : undefined,
|
|
209
|
+
parsed: parseCaptureMetadata(raw),
|
|
210
|
+
};
|
|
211
|
+
} catch {
|
|
212
|
+
return {};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function extractTextContent(content: unknown): string | undefined {
|
|
217
|
+
if (typeof content === "string") {
|
|
218
|
+
return content;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!Array.isArray(content)) {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const text = content
|
|
226
|
+
.map((block) => (isRecord(block) && block.type === "text" && typeof block.text === "string" ? block.text : undefined))
|
|
227
|
+
.filter((part): part is string => Boolean(part?.trim()))
|
|
228
|
+
.join("\n");
|
|
229
|
+
return text.trim() ? text : undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function formatSessionPreviewText(text: string): string {
|
|
233
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
234
|
+
if (normalized.length <= SESSION_PREVIEW_MAX_LENGTH) {
|
|
235
|
+
return normalized;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return `${normalized.slice(0, SESSION_PREVIEW_MAX_LENGTH - 1).trimEnd()}…`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function timestampToISOString(value: unknown): string | undefined {
|
|
242
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
243
|
+
const milliseconds = value < 1_000_000_000_000 ? value * 1000 : value;
|
|
244
|
+
const date = new Date(milliseconds);
|
|
245
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (typeof value !== "string") {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const trimmed = value.trim();
|
|
253
|
+
if (!trimmed) {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const numericValue = Number(trimmed);
|
|
258
|
+
if (Number.isFinite(numericValue)) {
|
|
259
|
+
return timestampToISOString(numericValue);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const date = new Date(trimmed);
|
|
263
|
+
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function extractMessageTimestamp(record: Record<string, unknown>): string | undefined {
|
|
267
|
+
const message = isRecord(record.message) ? record.message : undefined;
|
|
268
|
+
return (
|
|
269
|
+
timestampToISOString(record.timestamp) ??
|
|
270
|
+
timestampToISOString(record.createdAt) ??
|
|
271
|
+
timestampToISOString(record.time) ??
|
|
272
|
+
timestampToISOString(message?.timestamp) ??
|
|
273
|
+
timestampToISOString(message?.createdAt) ??
|
|
274
|
+
timestampToISOString(message?.time)
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function readLastUserMessage(sessionFile?: string): Promise<LastUserMessagePreview | undefined> {
|
|
279
|
+
if (!sessionFile) return undefined;
|
|
280
|
+
|
|
281
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
|
282
|
+
try {
|
|
283
|
+
handle = await open(sessionFile, "r");
|
|
284
|
+
const fileStat = await handle.stat();
|
|
285
|
+
const start = Math.max(0, fileStat.size - SESSION_PREVIEW_TAIL_BYTES);
|
|
286
|
+
const byteLength = fileStat.size - start;
|
|
287
|
+
if (byteLength <= 0) return undefined;
|
|
288
|
+
|
|
289
|
+
const buffer = Buffer.alloc(byteLength);
|
|
290
|
+
await handle.read(buffer, 0, byteLength, start);
|
|
291
|
+
let lines = buffer.toString("utf8").split(/\r?\n/);
|
|
292
|
+
if (start > 0) {
|
|
293
|
+
lines = lines.slice(1);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
297
|
+
const line = lines[index]?.trim();
|
|
298
|
+
if (!line) continue;
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const record = JSON.parse(line) as unknown;
|
|
302
|
+
if (!isRecord(record) || !isRecord(record.message)) continue;
|
|
303
|
+
if (record.message.role !== "user") continue;
|
|
304
|
+
|
|
305
|
+
const text = extractTextContent(record.message.content);
|
|
306
|
+
if (text?.trim()) {
|
|
307
|
+
return {
|
|
308
|
+
text: formatSessionPreviewText(text),
|
|
309
|
+
sentAt: extractMessageTimestamp(record),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
} catch {
|
|
313
|
+
// Ignore partial or non-JSON lines in the session log tail.
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
return undefined;
|
|
318
|
+
} finally {
|
|
319
|
+
await handle?.close().catch(() => undefined);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function findAnnotatedImagePath(directory: string, name: string, metadata?: CaptureMetadata): Promise<string | undefined> {
|
|
326
|
+
for (const candidate of [metadata?.annotatedImagePath, join(directory, `${name}.png`)]) {
|
|
327
|
+
if (await fileExists(candidate)) return candidate;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const files = (await readdir(directory, { withFileTypes: true })) as Array<{ name: string; isFile(): boolean }>;
|
|
332
|
+
const annotated = files.find(
|
|
333
|
+
(file) => file.isFile() && file.name !== "original.png" && isSupportedImageName(file.name),
|
|
334
|
+
);
|
|
335
|
+
if (annotated) return join(directory, annotated.name);
|
|
336
|
+
|
|
337
|
+
const original = files.find((file) => file.isFile() && file.name === "original.png");
|
|
338
|
+
if (original) return join(directory, original.name);
|
|
339
|
+
} catch {
|
|
340
|
+
// Ignore unreadable capture directories.
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function readImageBase64(path: string): string {
|
|
347
|
+
return readFileSync(path).toString("base64");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function expandHomePath(path: string): string {
|
|
351
|
+
if (path === "~") return homedir();
|
|
352
|
+
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
|
353
|
+
return path;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function isNativeMacOSScreenshotName(name: string): boolean {
|
|
357
|
+
return /^(Screenshot|Screen Shot).+\.(png|jpe?g|gif|webp)$/i.test(name);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function commandOutput(command: string, args: string[], timeoutMs = 1000): Promise<string | undefined> {
|
|
361
|
+
return new Promise((resolve) => {
|
|
362
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
363
|
+
const chunks: Buffer[] = [];
|
|
364
|
+
let settled = false;
|
|
365
|
+
const finish = (value: string | undefined) => {
|
|
366
|
+
if (settled) return;
|
|
367
|
+
settled = true;
|
|
368
|
+
clearTimeout(timer);
|
|
369
|
+
resolve(value);
|
|
370
|
+
};
|
|
371
|
+
const timer = setTimeout(() => {
|
|
372
|
+
child.kill();
|
|
373
|
+
finish(undefined);
|
|
374
|
+
}, timeoutMs);
|
|
375
|
+
|
|
376
|
+
child.stdout?.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
377
|
+
child.once("error", () => finish(undefined));
|
|
378
|
+
child.once("close", (code) => {
|
|
379
|
+
if (code !== 0) {
|
|
380
|
+
finish(undefined);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const output = Buffer.concat(chunks).toString("utf8").trim();
|
|
384
|
+
finish(output || undefined);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function nativeMacOSScreenshotDirectories(): Promise<string[]> {
|
|
390
|
+
const directories = new Set<string>([join(homedir(), "Desktop")]);
|
|
391
|
+
const configuredLocation = await commandOutput("/usr/bin/defaults", ["read", "com.apple.screencapture", "location"]);
|
|
392
|
+
if (configuredLocation) {
|
|
393
|
+
directories.add(expandHomePath(configuredLocation));
|
|
394
|
+
}
|
|
395
|
+
return [...directories];
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function loadPiLensCaptures(limit?: number): Promise<CaptureOption[]> {
|
|
399
|
+
let entries: Array<{ name: string; isDirectory(): boolean }>;
|
|
400
|
+
try {
|
|
401
|
+
entries = (await readdir(CAPTURES_DIRECTORY, { withFileTypes: true })) as Array<{ name: string; isDirectory(): boolean }>;
|
|
402
|
+
} catch {
|
|
403
|
+
return [];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const directories = (
|
|
407
|
+
await Promise.all(
|
|
408
|
+
entries
|
|
409
|
+
.filter((entry) => entry.isDirectory())
|
|
410
|
+
.map(async (entry) => {
|
|
411
|
+
const directory = join(CAPTURES_DIRECTORY, entry.name);
|
|
412
|
+
try {
|
|
413
|
+
return {
|
|
414
|
+
name: entry.name,
|
|
415
|
+
directory,
|
|
416
|
+
mtimeMs: (await stat(directory)).mtimeMs,
|
|
417
|
+
};
|
|
418
|
+
} catch {
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
}),
|
|
422
|
+
)
|
|
423
|
+
).filter((entry): entry is { name: string; directory: string; mtimeMs: number } => Boolean(entry));
|
|
424
|
+
|
|
425
|
+
const captures: CaptureOption[] = [];
|
|
426
|
+
const maxCandidates = limit === undefined ? directories.length : Math.max(limit * 4, limit);
|
|
427
|
+
const candidates = directories
|
|
428
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
429
|
+
.slice(0, maxCandidates);
|
|
430
|
+
|
|
431
|
+
for (const candidate of candidates) {
|
|
432
|
+
const metadataResult = await readCaptureMetadataFile(candidate.directory);
|
|
433
|
+
const imagePath = await findAnnotatedImagePath(candidate.directory, candidate.name, metadataResult.parsed);
|
|
434
|
+
if (!imagePath) continue;
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
const imageDetails = await stat(imagePath);
|
|
438
|
+
const createdAt = metadataResult.parsed?.createdAt;
|
|
439
|
+
const createdAtMs = createdAt ? Date.parse(createdAt) : NaN;
|
|
440
|
+
const sortTimeMs = Number.isFinite(createdAtMs) ? createdAtMs : imageDetails.mtimeMs;
|
|
441
|
+
const metadata: Record<string, unknown> = {
|
|
442
|
+
...(metadataResult.raw ?? {}),
|
|
443
|
+
source: metadataResult.raw?.source ?? "pilens",
|
|
444
|
+
annotatedImagePath: metadataResult.raw?.annotatedImagePath ?? imagePath,
|
|
445
|
+
createdAt: metadataResult.raw?.createdAt ?? createdAt ?? new Date(imageDetails.mtimeMs).toISOString(),
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
captures.push({
|
|
449
|
+
name: candidate.name,
|
|
450
|
+
directory: candidate.directory,
|
|
451
|
+
imagePath,
|
|
452
|
+
mediaType: inferMediaType(imagePath),
|
|
453
|
+
sortTimeMs,
|
|
454
|
+
createdAt,
|
|
455
|
+
annotationCount: metadataResult.parsed?.annotations?.length,
|
|
456
|
+
metadata,
|
|
457
|
+
});
|
|
458
|
+
} catch {
|
|
459
|
+
// Ignore captures whose image disappeared while listing.
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return captures.sort((a, b) => b.sortTimeMs - a.sortTimeMs);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async function loadNativeMacOSScreenshots(limit?: number): Promise<CaptureOption[]> {
|
|
467
|
+
const captures: CaptureOption[] = [];
|
|
468
|
+
const directories = await nativeMacOSScreenshotDirectories();
|
|
469
|
+
const maxPerDirectory = limit === undefined ? undefined : Math.max(limit * 4, limit);
|
|
470
|
+
|
|
471
|
+
for (const directory of directories) {
|
|
472
|
+
let entries: Array<{ name: string; isFile(): boolean }>;
|
|
473
|
+
try {
|
|
474
|
+
entries = (await readdir(directory, { withFileTypes: true })) as Array<{ name: string; isFile(): boolean }>;
|
|
475
|
+
} catch {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const candidates = (
|
|
480
|
+
await Promise.all(
|
|
481
|
+
entries
|
|
482
|
+
.filter((entry) => entry.isFile() && isNativeMacOSScreenshotName(entry.name) && isSupportedImageName(entry.name))
|
|
483
|
+
.map(async (entry) => {
|
|
484
|
+
const imagePath = join(directory, entry.name);
|
|
485
|
+
try {
|
|
486
|
+
return { name: entry.name, imagePath, details: await stat(imagePath) };
|
|
487
|
+
} catch {
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
}),
|
|
491
|
+
)
|
|
492
|
+
)
|
|
493
|
+
.filter((candidate): candidate is { name: string; imagePath: string; details: { mtimeMs: number } } => Boolean(candidate))
|
|
494
|
+
.sort((a, b) => b.details.mtimeMs - a.details.mtimeMs)
|
|
495
|
+
.slice(0, maxPerDirectory);
|
|
496
|
+
|
|
497
|
+
for (const candidate of candidates) {
|
|
498
|
+
const createdAt = new Date(candidate.details.mtimeMs).toISOString();
|
|
499
|
+
captures.push({
|
|
500
|
+
name: candidate.name,
|
|
501
|
+
directory,
|
|
502
|
+
imagePath: candidate.imagePath,
|
|
503
|
+
mediaType: inferMediaType(candidate.imagePath),
|
|
504
|
+
sortTimeMs: candidate.details.mtimeMs,
|
|
505
|
+
createdAt,
|
|
506
|
+
metadata: {
|
|
507
|
+
source: "macos-screenshot",
|
|
508
|
+
createdAt,
|
|
509
|
+
annotatedImagePath: candidate.imagePath,
|
|
510
|
+
originalImagePath: candidate.imagePath,
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return captures.sort((a, b) => b.sortTimeMs - a.sortTimeMs);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function captureKey(capture: CaptureOption): string {
|
|
520
|
+
return capture.imagePath;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function blendCaptureSources(piLensCaptures: CaptureOption[], nativeScreenshots: CaptureOption[], limit?: number): CaptureOption[] {
|
|
524
|
+
if (piLensCaptures.length === 0 || nativeScreenshots.length === 0) {
|
|
525
|
+
const sorted = [...piLensCaptures, ...nativeScreenshots].sort((a, b) => b.sortTimeMs - a.sortTimeMs);
|
|
526
|
+
return limit === undefined ? sorted : sorted.slice(0, limit);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const visibleSourceMix = [...piLensCaptures.slice(0, 3), ...nativeScreenshots.slice(0, 2)].sort(
|
|
530
|
+
(a, b) => b.sortTimeMs - a.sortTimeMs,
|
|
531
|
+
);
|
|
532
|
+
const pinned = new Set(visibleSourceMix.map(captureKey));
|
|
533
|
+
const rest = [...piLensCaptures, ...nativeScreenshots]
|
|
534
|
+
.filter((capture) => !pinned.has(captureKey(capture)))
|
|
535
|
+
.sort((a, b) => b.sortTimeMs - a.sortTimeMs);
|
|
536
|
+
const blended = [...visibleSourceMix, ...rest];
|
|
537
|
+
return limit === undefined ? blended : blended.slice(0, limit);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
async function loadRecentCaptures(limit?: number): Promise<CaptureOption[]> {
|
|
541
|
+
const sourceLimit = limit === undefined ? undefined : Math.max(limit * 4, limit);
|
|
542
|
+
const [piLensCaptures, nativeScreenshots] = await Promise.all([
|
|
543
|
+
loadPiLensCaptures(sourceLimit),
|
|
544
|
+
loadNativeMacOSScreenshots(sourceLimit),
|
|
545
|
+
]);
|
|
546
|
+
return blendCaptureSources(piLensCaptures, nativeScreenshots, limit);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
class CapturePickerComponent implements Component {
|
|
550
|
+
private selectedIndex = 0;
|
|
551
|
+
private scrollOffset = 0;
|
|
552
|
+
private cachedImageKey?: string;
|
|
553
|
+
private cachedImageLines?: string[];
|
|
554
|
+
private readonly base64Cache = new Map<string, string>();
|
|
555
|
+
private readonly listVisibleItems = 5;
|
|
556
|
+
private readonly previewImageId = 9100;
|
|
557
|
+
private lastRenderedFrameKey = "";
|
|
558
|
+
|
|
559
|
+
constructor(
|
|
560
|
+
private readonly captures: CaptureOption[],
|
|
561
|
+
private readonly theme: any,
|
|
562
|
+
private readonly done: (capture: CaptureOption | null) => void,
|
|
563
|
+
) {}
|
|
564
|
+
|
|
565
|
+
handleInput(data: string): void {
|
|
566
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.left) || data === "k" || data === "h") {
|
|
567
|
+
this.moveSelection(-1);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (matchesKey(data, Key.down) || matchesKey(data, Key.right) || data === "j" || data === "l") {
|
|
572
|
+
this.moveSelection(1);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (matchesKey(data, Key.enter)) {
|
|
577
|
+
this.finish(this.captures[this.selectedIndex] ?? null);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
582
|
+
this.finish(null);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (/^[1-5]$/.test(data)) {
|
|
587
|
+
const index = this.scrollOffset + Number.parseInt(data, 10) - 1;
|
|
588
|
+
const capture = this.captures[index];
|
|
589
|
+
if (capture) {
|
|
590
|
+
this.selectedIndex = index;
|
|
591
|
+
this.finish(capture);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
render(width: number): string[] {
|
|
597
|
+
const safeWidth = Math.max(1, width);
|
|
598
|
+
const listWidth = Math.min(44, Math.max(26, Math.floor(safeWidth * 0.38)));
|
|
599
|
+
const previewWidth = Math.max(1, safeWidth - listWidth - 3);
|
|
600
|
+
const previewLines = 14;
|
|
601
|
+
const selected = this.captures[this.selectedIndex];
|
|
602
|
+
const imageLines = selected ? this.renderSelectedImageLines(selected, previewWidth, previewLines) : [];
|
|
603
|
+
const contentRows = previewLines;
|
|
604
|
+
const countInfo = this.captures.length > 0 ? ` (${this.selectedIndex + 1}/${this.captures.length})` : "";
|
|
605
|
+
const lines = [
|
|
606
|
+
truncateToWidth(this.theme.fg("accent", this.theme.bold(`Recent captures${countInfo}`)), safeWidth),
|
|
607
|
+
truncateToWidth(
|
|
608
|
+
this.theme.fg("dim", "↑/↓ move • 1-5 send visible row • enter send selected • esc cancel"),
|
|
609
|
+
safeWidth,
|
|
610
|
+
),
|
|
611
|
+
];
|
|
612
|
+
|
|
613
|
+
if (!getCapabilities().images) {
|
|
614
|
+
lines.push(
|
|
615
|
+
truncateToWidth(
|
|
616
|
+
this.theme.fg("warning", "Inline image preview is unavailable in this terminal; showing the selected capture path."),
|
|
617
|
+
safeWidth,
|
|
618
|
+
),
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
lines.push("");
|
|
623
|
+
for (let row = 0; row < contentRows; row++) {
|
|
624
|
+
const visibleIndex = this.scrollOffset + row;
|
|
625
|
+
const capture = row < this.listVisibleItems ? this.captures[visibleIndex] : undefined;
|
|
626
|
+
const listLine = capture ? this.renderListCell(capture, visibleIndex, listWidth) : fitCell("", listWidth);
|
|
627
|
+
const rawImageLine = imageLines[row] ?? "";
|
|
628
|
+
const imageLine = this.truncateNonImageLine(rawImageLine, previewWidth);
|
|
629
|
+
lines.push(`${listLine}${this.theme.fg("muted", "│")} ${imageLine}`);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
lines.push("");
|
|
633
|
+
lines.push(
|
|
634
|
+
truncateToWidth(
|
|
635
|
+
this.theme.fg("success", "Enter sends the selected capture to this pi session now."),
|
|
636
|
+
safeWidth,
|
|
637
|
+
),
|
|
638
|
+
);
|
|
639
|
+
lines.push(
|
|
640
|
+
truncateToWidth(
|
|
641
|
+
this.theme.fg("dim", `selected: ${selected?.imagePath ?? "none"}`),
|
|
642
|
+
safeWidth,
|
|
643
|
+
),
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
return lines;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
invalidate(): void {
|
|
650
|
+
this.cachedImageKey = undefined;
|
|
651
|
+
this.cachedImageLines = undefined;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private moveSelection(delta: number): void {
|
|
655
|
+
if (this.captures.length === 0) return;
|
|
656
|
+
this.selectedIndex = Math.max(0, Math.min(this.captures.length - 1, this.selectedIndex + delta));
|
|
657
|
+
if (this.selectedIndex < this.scrollOffset) {
|
|
658
|
+
this.scrollOffset = this.selectedIndex;
|
|
659
|
+
}
|
|
660
|
+
if (this.selectedIndex >= this.scrollOffset + this.listVisibleItems) {
|
|
661
|
+
this.scrollOffset = this.selectedIndex - this.listVisibleItems + 1;
|
|
662
|
+
}
|
|
663
|
+
this.invalidate();
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
private renderListCell(capture: CaptureOption, index: number, width: number): string {
|
|
667
|
+
const cursor = index === this.selectedIndex ? "▸" : " ";
|
|
668
|
+
const source = capture.metadata.source === "macos-screenshot" ? "macOS" : "PiLens";
|
|
669
|
+
const summary = `${cursor} ${index - this.scrollOffset + 1} ${source} ${formatCaptureTimestamp(capture)}`;
|
|
670
|
+
const padded = fitCell(summary, width);
|
|
671
|
+
if (index === this.selectedIndex) {
|
|
672
|
+
return this.theme.bg("selectedBg", this.theme.fg("accent", padded));
|
|
673
|
+
}
|
|
674
|
+
return this.theme.fg("muted", padded);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private renderSelectedImageLines(capture: CaptureOption, width: number, previewLines: number): string[] {
|
|
678
|
+
const key = `${capture.imagePath}:${width}:${previewLines}:${getCapabilities().images ?? "none"}`;
|
|
679
|
+
if (this.cachedImageKey === key && this.cachedImageLines) return this.cachedImageLines;
|
|
680
|
+
|
|
681
|
+
const deletePrevious =
|
|
682
|
+
this.lastRenderedFrameKey && this.lastRenderedFrameKey !== key && getCapabilities().images === "kitty"
|
|
683
|
+
? deleteKittyImage(this.previewImageId)
|
|
684
|
+
: "";
|
|
685
|
+
this.lastRenderedFrameKey = key;
|
|
686
|
+
|
|
687
|
+
const base64 = this.getCaptureBase64(capture);
|
|
688
|
+
if (!base64) {
|
|
689
|
+
const lines = [deletePrevious + this.theme.fg("error", `Unable to read ${capture.imagePath}`)];
|
|
690
|
+
while (lines.length < previewLines) lines.push("");
|
|
691
|
+
this.cachedImageLines = lines;
|
|
692
|
+
this.cachedImageKey = key;
|
|
693
|
+
return lines;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const maxWidthCells = Math.max(1, Math.min(80, width));
|
|
697
|
+
const dimensions = getImageDimensions(base64, capture.mediaType);
|
|
698
|
+
const constrainedWidth = dimensions
|
|
699
|
+
? this.calculateConstrainedImageWidth(dimensions.widthPx, dimensions.heightPx, previewLines, maxWidthCells)
|
|
700
|
+
: maxWidthCells;
|
|
701
|
+
const image = new Image(
|
|
702
|
+
base64,
|
|
703
|
+
capture.mediaType,
|
|
704
|
+
{ fallbackColor: (str: string) => this.theme.fg("muted", str) },
|
|
705
|
+
{ maxWidthCells: constrainedWidth, filename: capture.imagePath, imageId: this.previewImageId },
|
|
706
|
+
);
|
|
707
|
+
const rendered = image.render(constrainedWidth + 2);
|
|
708
|
+
const lines = Array.from({ length: previewLines }, (_, index) => rendered[index] ?? "");
|
|
709
|
+
if (lines.length > 0) lines[0] = deletePrevious + lines[0];
|
|
710
|
+
this.cachedImageLines = lines;
|
|
711
|
+
this.cachedImageKey = key;
|
|
712
|
+
return lines;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private getCaptureBase64(capture: CaptureOption): string | undefined {
|
|
716
|
+
const cached = this.base64Cache.get(capture.imagePath);
|
|
717
|
+
if (cached) return cached;
|
|
718
|
+
|
|
719
|
+
try {
|
|
720
|
+
const base64 = readImageBase64(capture.imagePath);
|
|
721
|
+
this.base64Cache.set(capture.imagePath, base64);
|
|
722
|
+
return base64;
|
|
723
|
+
} catch {
|
|
724
|
+
return undefined;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
private calculateConstrainedImageWidth(widthPx: number, heightPx: number, maxRows: number, maxWidthCells: number): number {
|
|
729
|
+
const cellWidthPx = 9;
|
|
730
|
+
const cellHeightPx = 18;
|
|
731
|
+
const scaledWidthPx = maxWidthCells * cellWidthPx;
|
|
732
|
+
const scale = scaledWidthPx / widthPx;
|
|
733
|
+
const rows = Math.ceil((heightPx * scale) / cellHeightPx);
|
|
734
|
+
if (rows <= maxRows) return maxWidthCells;
|
|
735
|
+
|
|
736
|
+
const targetHeightPx = maxRows * cellHeightPx;
|
|
737
|
+
const targetScale = targetHeightPx / heightPx;
|
|
738
|
+
return Math.max(1, Math.min(maxWidthCells, Math.floor((widthPx * targetScale) / cellWidthPx)));
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
private truncateNonImageLine(line: string, width: number): string {
|
|
742
|
+
if (line.includes("\x1b_G") || line.includes("\x1b]1337;File=")) return line;
|
|
743
|
+
return truncateToWidth(line, width, "");
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private finish(capture: CaptureOption | null): void {
|
|
747
|
+
this.cleanupImages();
|
|
748
|
+
this.done(capture);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
private cleanupImages(): void {
|
|
752
|
+
if (getCapabilities().images !== "kitty") return;
|
|
753
|
+
try {
|
|
754
|
+
process.stdout.write(deleteKittyImage(this.previewImageId));
|
|
755
|
+
} catch {
|
|
756
|
+
// Best-effort terminal image cleanup.
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function isPidAlive(pid: number): boolean {
|
|
762
|
+
try {
|
|
763
|
+
process.kill(pid, 0);
|
|
764
|
+
return true;
|
|
765
|
+
} catch {
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async function readNativeRegistry(): Promise<{ pid?: number; triggerPath?: string } | undefined> {
|
|
771
|
+
try {
|
|
772
|
+
return JSON.parse(await readFile(NATIVE_REGISTRY_PATH, "utf8")) as { pid?: number; triggerPath?: string };
|
|
773
|
+
} catch {
|
|
774
|
+
return undefined;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function pathMtimeMs(path: string): Promise<number> {
|
|
779
|
+
try {
|
|
780
|
+
return (await stat(path)).mtimeMs;
|
|
781
|
+
} catch {
|
|
782
|
+
return 0;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async function treeMtimeMs(path: string): Promise<number> {
|
|
787
|
+
try {
|
|
788
|
+
const details = await stat(path);
|
|
789
|
+
if (!details.isDirectory()) {
|
|
790
|
+
return details.mtimeMs;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
let latest = details.mtimeMs;
|
|
794
|
+
for (const entry of await readdir(path, { withFileTypes: true })) {
|
|
795
|
+
if (entry.name === ".build") continue;
|
|
796
|
+
latest = Math.max(latest, await treeMtimeMs(join(path, entry.name)));
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return latest;
|
|
800
|
+
} catch {
|
|
801
|
+
return 0;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async function hasNativeSourceDirectory(): Promise<boolean> {
|
|
806
|
+
return Boolean(await pathMtimeMs(join(NATIVE_APP_DIRECTORY, "Package.swift")));
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function latestNativeSourceMtimeMs(): Promise<number> {
|
|
810
|
+
if (!(await hasNativeSourceDirectory())) return 0;
|
|
811
|
+
return Math.max(
|
|
812
|
+
await pathMtimeMs(join(NATIVE_APP_DIRECTORY, "Package.swift")),
|
|
813
|
+
await pathMtimeMs(join(NATIVE_APP_DIRECTORY, "Package.resolved")),
|
|
814
|
+
await treeMtimeMs(join(NATIVE_APP_DIRECTORY, "Sources")),
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
async function spawnDetached(command: string, args: string[], cwd: string): Promise<void> {
|
|
819
|
+
await new Promise<void>((resolve, reject) => {
|
|
820
|
+
const child = spawn(command, args, {
|
|
821
|
+
cwd,
|
|
822
|
+
detached: true,
|
|
823
|
+
stdio: "ignore",
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
child.once("error", reject);
|
|
827
|
+
child.once("spawn", () => {
|
|
828
|
+
child.unref();
|
|
829
|
+
resolve();
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function json(response: ServerResponse, statusCode: number, payload: unknown): void {
|
|
835
|
+
response.writeHead(statusCode, {
|
|
836
|
+
"content-type": "application/json; charset=utf-8",
|
|
837
|
+
"cache-control": "no-store",
|
|
838
|
+
});
|
|
839
|
+
response.end(`${JSON.stringify(payload)}\n`);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
async function readJsonBody<T>(request: IncomingMessage): Promise<T> {
|
|
843
|
+
const chunks: Buffer[] = [];
|
|
844
|
+
for await (const chunk of request) {
|
|
845
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
846
|
+
}
|
|
847
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as T;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function sleep(ms: number): Promise<void> {
|
|
851
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
export default function piCaptureBridge(pi: ExtensionAPI) {
|
|
855
|
+
let server: Server | undefined;
|
|
856
|
+
let ownerToken: string | undefined;
|
|
857
|
+
let registryFilePath: string | undefined;
|
|
858
|
+
let nativeLaunchInProgress = false;
|
|
859
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
|
|
860
|
+
const initialHeartbeatAt = new Date().toISOString();
|
|
861
|
+
let state: BridgeState = {
|
|
862
|
+
available: false,
|
|
863
|
+
busy: false,
|
|
864
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
865
|
+
cwd: process.cwd(),
|
|
866
|
+
heartbeatAt: initialHeartbeatAt,
|
|
867
|
+
updatedAt: initialHeartbeatAt,
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
const isNativeAppRunning = async (): Promise<boolean> => {
|
|
871
|
+
const nativeRegistry = await readNativeRegistry();
|
|
872
|
+
return Boolean(nativeRegistry?.pid && isPidAlive(nativeRegistry.pid));
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
const watchNativeLaunch = () => {
|
|
876
|
+
const deadline = Date.now() + NATIVE_LAUNCH_GRACE_MS;
|
|
877
|
+
|
|
878
|
+
void (async () => {
|
|
879
|
+
while (Date.now() < deadline) {
|
|
880
|
+
if (await isNativeAppRunning()) {
|
|
881
|
+
nativeLaunchInProgress = false;
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
await sleep(NATIVE_LAUNCH_POLL_MS);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
nativeLaunchInProgress = false;
|
|
889
|
+
})();
|
|
890
|
+
};
|
|
891
|
+
|
|
892
|
+
const launchNativeAppIfNeeded = async (): Promise<{
|
|
893
|
+
launched: boolean;
|
|
894
|
+
launchMethod?: string;
|
|
895
|
+
running: boolean;
|
|
896
|
+
launching: boolean;
|
|
897
|
+
}> => {
|
|
898
|
+
if (await isNativeAppRunning()) {
|
|
899
|
+
nativeLaunchInProgress = false;
|
|
900
|
+
return { launched: false, running: true, launching: false };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (nativeLaunchInProgress) {
|
|
904
|
+
return { launched: false, running: false, launching: true };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const nativeSourceAvailable = await hasNativeSourceDirectory();
|
|
908
|
+
const latestSourceMtime = nativeSourceAvailable ? await latestNativeSourceMtimeMs() : 0;
|
|
909
|
+
const installedBinaryMtime = await pathMtimeMs(INSTALLED_NATIVE_APP_BINARY);
|
|
910
|
+
const debugBinaryMtime = nativeSourceAvailable ? await pathMtimeMs(NATIVE_DEBUG_BINARY) : 0;
|
|
911
|
+
const releaseBinaryMtime = nativeSourceAvailable ? await pathMtimeMs(NATIVE_RELEASE_BINARY) : 0;
|
|
912
|
+
const freshBinary =
|
|
913
|
+
debugBinaryMtime && debugBinaryMtime >= latestSourceMtime
|
|
914
|
+
? NATIVE_DEBUG_BINARY
|
|
915
|
+
: releaseBinaryMtime && releaseBinaryMtime >= latestSourceMtime
|
|
916
|
+
? NATIVE_RELEASE_BINARY
|
|
917
|
+
: undefined;
|
|
918
|
+
const fallbackBinary = debugBinaryMtime ? NATIVE_DEBUG_BINARY : releaseBinaryMtime ? NATIVE_RELEASE_BINARY : undefined;
|
|
919
|
+
const launchCandidates: Array<{ launchMethod: string; start: () => Promise<void> }> = [];
|
|
920
|
+
const launchCwd = nativeSourceAvailable ? NATIVE_APP_DIRECTORY : homedir();
|
|
921
|
+
|
|
922
|
+
if (installedBinaryMtime && installedBinaryMtime >= latestSourceMtime) {
|
|
923
|
+
launchCandidates.push({
|
|
924
|
+
launchMethod: "installed app (/Applications/PiLens.app)",
|
|
925
|
+
start: () => spawnDetached("open", [INSTALLED_NATIVE_APP_PATH], launchCwd),
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (nativeSourceAvailable) {
|
|
930
|
+
if (freshBinary) {
|
|
931
|
+
launchCandidates.push({
|
|
932
|
+
launchMethod: `native binary (${basename(freshBinary)})`,
|
|
933
|
+
start: () => spawnDetached(freshBinary, [], NATIVE_APP_DIRECTORY),
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
launchCandidates.push({
|
|
938
|
+
launchMethod: "swift run PiLens",
|
|
939
|
+
start: () => spawnDetached("swift", ["run", "PiLens"], NATIVE_APP_DIRECTORY),
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
if (fallbackBinary && fallbackBinary !== freshBinary) {
|
|
943
|
+
launchCandidates.push({
|
|
944
|
+
launchMethod: `existing binary (${basename(fallbackBinary)})`,
|
|
945
|
+
start: () => spawnDetached(fallbackBinary, [], NATIVE_APP_DIRECTORY),
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (launchCandidates.length === 0) {
|
|
951
|
+
throw new Error("PiLens Mac app is not installed. Download it from https://pilens.dev/download and open it once, then run /pilens again.");
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
nativeLaunchInProgress = true;
|
|
955
|
+
let lastError: unknown;
|
|
956
|
+
|
|
957
|
+
for (const candidate of launchCandidates) {
|
|
958
|
+
try {
|
|
959
|
+
await candidate.start();
|
|
960
|
+
watchNativeLaunch();
|
|
961
|
+
return {
|
|
962
|
+
launched: true,
|
|
963
|
+
launchMethod: candidate.launchMethod,
|
|
964
|
+
running: false,
|
|
965
|
+
launching: false,
|
|
966
|
+
};
|
|
967
|
+
} catch (error) {
|
|
968
|
+
lastError = error;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
nativeLaunchInProgress = false;
|
|
973
|
+
throw lastError instanceof Error ? lastError : new Error("Failed to launch the native PiLens app.");
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
const writeRegistry = async () => {
|
|
977
|
+
if (!state.port || !ownerToken) return;
|
|
978
|
+
const payload = `${JSON.stringify({ ownerToken, host: HOST, pid: process.pid, ...state }, null, "\t")}\n`;
|
|
979
|
+
await mkdir(join(homedir(), ".pi", "agent"), { recursive: true });
|
|
980
|
+
await mkdir(REGISTRIES_DIRECTORY, { recursive: true });
|
|
981
|
+
registryFilePath = registryFilePath ?? join(REGISTRIES_DIRECTORY, `${ownerToken}.json`);
|
|
982
|
+
await writeFile(registryFilePath, payload, "utf8");
|
|
983
|
+
await writeFile(REGISTRY_PATH, payload, "utf8");
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
const removeRegistryIfOwned = async () => {
|
|
987
|
+
if (registryFilePath) {
|
|
988
|
+
await rm(registryFilePath, { force: true });
|
|
989
|
+
registryFilePath = undefined;
|
|
990
|
+
}
|
|
991
|
+
if (!ownerToken) return;
|
|
992
|
+
try {
|
|
993
|
+
const existing = JSON.parse(await readFile(REGISTRY_PATH, "utf8")) as { ownerToken?: string };
|
|
994
|
+
if (existing.ownerToken === ownerToken) {
|
|
995
|
+
await rm(REGISTRY_PATH, { force: true });
|
|
996
|
+
}
|
|
997
|
+
} catch {
|
|
998
|
+
// Ignore missing or invalid registry files.
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
const refreshState = async (ctx?: ExtensionContext) => {
|
|
1003
|
+
const now = new Date().toISOString();
|
|
1004
|
+
const sessionFile = ctx?.sessionManager.getSessionFile() ?? state.sessionFile;
|
|
1005
|
+
const lastUserMessage = await readLastUserMessage(sessionFile);
|
|
1006
|
+
state = {
|
|
1007
|
+
...state,
|
|
1008
|
+
available: true,
|
|
1009
|
+
busy: ctx ? !ctx.isIdle() : state.busy,
|
|
1010
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
1011
|
+
cwd: ctx?.cwd ?? state.cwd,
|
|
1012
|
+
sessionFile,
|
|
1013
|
+
sessionId: (ctx?.sessionManager as any)?.getSessionId?.() ?? state.sessionId,
|
|
1014
|
+
sessionName: (ctx?.sessionManager as any)?.getSessionName?.() ?? state.sessionName,
|
|
1015
|
+
lastUserMessage: lastUserMessage?.text,
|
|
1016
|
+
lastUserMessageAt: lastUserMessage?.sentAt,
|
|
1017
|
+
hasUI: ctx?.hasUI ?? state.hasUI,
|
|
1018
|
+
stdoutIsTTY: Boolean(process.stdout.isTTY),
|
|
1019
|
+
tty: process.env.TTY,
|
|
1020
|
+
heartbeatAt: now,
|
|
1021
|
+
updatedAt: now,
|
|
1022
|
+
};
|
|
1023
|
+
await writeRegistry();
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
const startHeartbeat = () => {
|
|
1027
|
+
if (heartbeatTimer) return;
|
|
1028
|
+
heartbeatTimer = setInterval(() => {
|
|
1029
|
+
void refreshState();
|
|
1030
|
+
}, BRIDGE_HEARTBEAT_MS);
|
|
1031
|
+
heartbeatTimer.unref?.();
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
const stopHeartbeat = () => {
|
|
1035
|
+
if (!heartbeatTimer) return;
|
|
1036
|
+
clearInterval(heartbeatTimer);
|
|
1037
|
+
heartbeatTimer = undefined;
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
const describeTargetSession = () => {
|
|
1041
|
+
if (state.sessionName?.trim()) {
|
|
1042
|
+
return state.sessionName.trim();
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const projectName = basename(state.cwd) || state.cwd;
|
|
1046
|
+
if (state.sessionId) {
|
|
1047
|
+
return `${projectName} • ${state.sessionId.slice(0, 8)}`;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
if (state.sessionFile) {
|
|
1051
|
+
return `${projectName} • ${basename(state.sessionFile)}`;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
return projectName;
|
|
1055
|
+
};
|
|
1056
|
+
|
|
1057
|
+
const buildResponseBase = () =>
|
|
1058
|
+
({
|
|
1059
|
+
ok: true,
|
|
1060
|
+
busy: state.busy,
|
|
1061
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
1062
|
+
sessionFile: state.sessionFile,
|
|
1063
|
+
sessionId: state.sessionId,
|
|
1064
|
+
sessionName: state.sessionName,
|
|
1065
|
+
}) as const;
|
|
1066
|
+
|
|
1067
|
+
const buildCapturePreviewContent = (
|
|
1068
|
+
payload: DeliverPayload,
|
|
1069
|
+
promptText: string | undefined,
|
|
1070
|
+
annotatedMimeType: string,
|
|
1071
|
+
): CapturePreviewContentBlock[] => {
|
|
1072
|
+
const blocks: CapturePreviewContentBlock[] = [];
|
|
1073
|
+
const trimmedPrompt = promptText?.trim();
|
|
1074
|
+
if (trimmedPrompt) {
|
|
1075
|
+
blocks.push({ type: "text", text: trimmedPrompt });
|
|
1076
|
+
}
|
|
1077
|
+
blocks.push({ type: "image", data: payload.annotatedImageBase64, mimeType: annotatedMimeType });
|
|
1078
|
+
return blocks;
|
|
1079
|
+
};
|
|
1080
|
+
|
|
1081
|
+
pi.registerMessageRenderer<CapturePreviewDetails>("pilens-preview", (message, { expanded }, theme) => {
|
|
1082
|
+
const details = message.details as CapturePreviewDetails | undefined;
|
|
1083
|
+
const contentBlocks: CapturePreviewContentBlock[] =
|
|
1084
|
+
typeof message.content === "string"
|
|
1085
|
+
? [{ type: "text", text: message.content }]
|
|
1086
|
+
: (message.content as CapturePreviewContentBlock[]);
|
|
1087
|
+
const contentImageBlocks = contentBlocks.filter((block): block is Extract<CapturePreviewContentBlock, { type: "image" }> => block.type === "image");
|
|
1088
|
+
let filePreviewImageBlock: Extract<CapturePreviewContentBlock, { type: "image" }> | undefined;
|
|
1089
|
+
if (contentImageBlocks.length === 0 && details?.annotatedImagePath) {
|
|
1090
|
+
try {
|
|
1091
|
+
filePreviewImageBlock = {
|
|
1092
|
+
type: "image",
|
|
1093
|
+
data: readFileSync(details.annotatedImagePath).toString("base64"),
|
|
1094
|
+
mimeType: details.annotatedMediaType,
|
|
1095
|
+
};
|
|
1096
|
+
} catch {
|
|
1097
|
+
filePreviewImageBlock = undefined;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
const imageBlocks = filePreviewImageBlock ? [...contentImageBlocks, filePreviewImageBlock] : contentImageBlocks;
|
|
1101
|
+
const textBlocks = contentBlocks.filter((block): block is Extract<CapturePreviewContentBlock, { type: "text" }> => block.type === "text");
|
|
1102
|
+
const deliveryMode = details?.deliveryMode ?? "immediate";
|
|
1103
|
+
const color = deliveryMode === "steer" ? "warning" : "success";
|
|
1104
|
+
const heading = deliveryMode === "steer" ? "QUEUED" : "SENT";
|
|
1105
|
+
const lines = [
|
|
1106
|
+
`${theme.fg(color, theme.bold(`[pilens ${heading}]`))} ${details?.targetSession ?? "Pi session"}`,
|
|
1107
|
+
theme.fg(
|
|
1108
|
+
"muted",
|
|
1109
|
+
deliveryMode === "steer"
|
|
1110
|
+
? "Screenshot queued as a user message."
|
|
1111
|
+
: imageBlocks.length > 0
|
|
1112
|
+
? "Screenshot delivered as the final annotated image."
|
|
1113
|
+
: "Screenshot delivered as a custom message.",
|
|
1114
|
+
),
|
|
1115
|
+
];
|
|
1116
|
+
|
|
1117
|
+
if (details) {
|
|
1118
|
+
lines.push(
|
|
1119
|
+
`${theme.fg("accent", "annotated")} • ${formatBytes(details.annotatedBytes)} • ${details.annotatedMediaType}`,
|
|
1120
|
+
formatPathLine(" path", details.annotatedImagePath),
|
|
1121
|
+
);
|
|
1122
|
+
|
|
1123
|
+
const metadataBits = [
|
|
1124
|
+
details.annotationCount !== undefined ? `${details.annotationCount} annotation${details.annotationCount === 1 ? "" : "s"}` : undefined,
|
|
1125
|
+
details.metadataVersion !== undefined ? `metadata v${details.metadataVersion}` : undefined,
|
|
1126
|
+
`bridge v${details.protocolVersion}`,
|
|
1127
|
+
]
|
|
1128
|
+
.filter(Boolean)
|
|
1129
|
+
.join(" • ");
|
|
1130
|
+
|
|
1131
|
+
lines.push(theme.fg("dim", metadataBits));
|
|
1132
|
+
|
|
1133
|
+
if (expanded) {
|
|
1134
|
+
lines.push("", theme.fg("dim", JSON.stringify(details, null, 2)));
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
|
|
1139
|
+
box.addChild(new Text(lines.join("\n"), 0, 0));
|
|
1140
|
+
|
|
1141
|
+
if (textBlocks.length > 0 && imageBlocks.length === 0) {
|
|
1142
|
+
box.addChild(new Spacer(1));
|
|
1143
|
+
box.addChild(new Text(textBlocks.map((block) => block.text).join("\n"), 0, 0));
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (imageBlocks.length > 0) {
|
|
1147
|
+
box.addChild(new Spacer(1));
|
|
1148
|
+
imageBlocks.forEach((block, index) => {
|
|
1149
|
+
const imagePath = index === 0 ? details?.annotatedImagePath : undefined;
|
|
1150
|
+
box.addChild(
|
|
1151
|
+
new Text(
|
|
1152
|
+
theme.fg("accent", imagePath ? `annotated • ${imagePath}` : "annotated"),
|
|
1153
|
+
0,
|
|
1154
|
+
0,
|
|
1155
|
+
),
|
|
1156
|
+
);
|
|
1157
|
+
box.addChild(
|
|
1158
|
+
new Image(
|
|
1159
|
+
block.data,
|
|
1160
|
+
block.mimeType,
|
|
1161
|
+
{ fallbackColor: (str) => theme.fg("muted", str) },
|
|
1162
|
+
{ filename: imagePath },
|
|
1163
|
+
),
|
|
1164
|
+
);
|
|
1165
|
+
if (index < imageBlocks.length - 1) {
|
|
1166
|
+
box.addChild(new Spacer(1));
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
return box;
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
const emitCapturePreview = (payload: DeliverPayload, details: CapturePreviewDetails) => {
|
|
1175
|
+
pi.sendMessage<CapturePreviewDetails>(
|
|
1176
|
+
{
|
|
1177
|
+
customType: "pilens-preview",
|
|
1178
|
+
content: "PiLens screenshot preview — image also sent as a user message.",
|
|
1179
|
+
display: true,
|
|
1180
|
+
details,
|
|
1181
|
+
},
|
|
1182
|
+
details.deliveryMode === "steer" ? { deliverAs: "steer" } : undefined,
|
|
1183
|
+
);
|
|
1184
|
+
};
|
|
1185
|
+
|
|
1186
|
+
const sendCaptureUserMessage = (payload: DeliverPayload, details: CapturePreviewDetails) => {
|
|
1187
|
+
const content = buildCapturePreviewContent(
|
|
1188
|
+
payload,
|
|
1189
|
+
payload.text,
|
|
1190
|
+
details.annotatedMediaType,
|
|
1191
|
+
);
|
|
1192
|
+
|
|
1193
|
+
emitCapturePreview(payload, details);
|
|
1194
|
+
|
|
1195
|
+
if (details.deliveryMode === "steer") {
|
|
1196
|
+
pi.sendUserMessage(content, { deliverAs: "steer" });
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
pi.sendUserMessage(content);
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
const buildCapturePreviewDetails = (
|
|
1204
|
+
payload: DeliverPayload,
|
|
1205
|
+
targetSession: string,
|
|
1206
|
+
deliveryMode: "immediate" | "steer",
|
|
1207
|
+
): CapturePreviewDetails => {
|
|
1208
|
+
const metadata = parseCaptureMetadata(payload.metadata);
|
|
1209
|
+
return {
|
|
1210
|
+
targetSession,
|
|
1211
|
+
deliveryMode,
|
|
1212
|
+
sentAt: new Date().toISOString(),
|
|
1213
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
1214
|
+
annotatedMediaType: payload.annotatedMediaType ?? "image/png",
|
|
1215
|
+
annotatedBytes: Buffer.byteLength(payload.annotatedImageBase64, "base64"),
|
|
1216
|
+
annotationCount: metadata?.annotations?.length,
|
|
1217
|
+
metadataVersion: metadata?.version,
|
|
1218
|
+
metadataCreatedAt: metadata?.createdAt,
|
|
1219
|
+
annotatedImagePath: metadata?.annotatedImagePath,
|
|
1220
|
+
sessionFile: state.sessionFile,
|
|
1221
|
+
sessionId: state.sessionId,
|
|
1222
|
+
sessionName: state.sessionName,
|
|
1223
|
+
};
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
const deliverToSession = async (payload: DeliverPayload): Promise<DeliverResult> => {
|
|
1227
|
+
const targetSession = describeTargetSession();
|
|
1228
|
+
const responseBase = buildResponseBase();
|
|
1229
|
+
|
|
1230
|
+
if (state.busy) {
|
|
1231
|
+
const details = buildCapturePreviewDetails(payload, targetSession, "steer");
|
|
1232
|
+
sendCaptureUserMessage(payload, details);
|
|
1233
|
+
return {
|
|
1234
|
+
...responseBase,
|
|
1235
|
+
deliveryMode: "steer",
|
|
1236
|
+
confirmed: true,
|
|
1237
|
+
confirmation: "queued",
|
|
1238
|
+
message: `Queued for ${targetSession}. Pi is busy right now, so the screenshot will be delivered as a user message at the next interruption point.`,
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const details = buildCapturePreviewDetails(payload, targetSession, "immediate");
|
|
1243
|
+
sendCaptureUserMessage(payload, details);
|
|
1244
|
+
|
|
1245
|
+
return {
|
|
1246
|
+
...responseBase,
|
|
1247
|
+
deliveryMode: "immediate",
|
|
1248
|
+
confirmed: true,
|
|
1249
|
+
confirmation: "accepted",
|
|
1250
|
+
message: state.sessionFile
|
|
1251
|
+
? `Sent to pi for ${targetSession} as a user message. The screenshot should appear in this session shortly.`
|
|
1252
|
+
: `Sent to pi for ${targetSession} as a user message. This session is not persisted, so file confirmation is unavailable.`,
|
|
1253
|
+
};
|
|
1254
|
+
};
|
|
1255
|
+
|
|
1256
|
+
const ensureServer = async (ctx: ExtensionContext) => {
|
|
1257
|
+
if (server) {
|
|
1258
|
+
await refreshState(ctx);
|
|
1259
|
+
startHeartbeat();
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
ownerToken = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
|
1264
|
+
server = createServer(async (request, response) => {
|
|
1265
|
+
try {
|
|
1266
|
+
if (!request.url) {
|
|
1267
|
+
json(response, 404, { error: "Missing URL." });
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
if (request.method === "GET" && request.url === "/status") {
|
|
1272
|
+
await refreshState(ctx);
|
|
1273
|
+
json(response, 200, state);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
if (request.method === "POST" && request.url === "/deliver") {
|
|
1278
|
+
await refreshState(ctx);
|
|
1279
|
+
if (!state.available) {
|
|
1280
|
+
json(response, 503, { ok: false, error: "No active pi bridge is available. Open pi and /reload the pilens extension first." });
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const payload = await readJsonBody<DeliverPayload>(request);
|
|
1285
|
+
if (!payload.annotatedImageBase64) {
|
|
1286
|
+
json(response, 400, { ok: false, error: "The annotated image payload is required." });
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const delivery = await deliverToSession(payload);
|
|
1291
|
+
json(response, 200, delivery);
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
json(response, 404, { error: "Not found." });
|
|
1296
|
+
} catch (error) {
|
|
1297
|
+
json(response, 500, {
|
|
1298
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
await new Promise<void>((resolve, reject) => {
|
|
1304
|
+
server!.once("error", reject);
|
|
1305
|
+
server!.listen(0, HOST, () => {
|
|
1306
|
+
server!.off("error", reject);
|
|
1307
|
+
resolve();
|
|
1308
|
+
});
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
const address = server.address() as AddressInfo | null;
|
|
1312
|
+
if (!address?.port) {
|
|
1313
|
+
throw new Error("Failed to determine pilens bridge port.");
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
state.port = address.port;
|
|
1317
|
+
await refreshState(ctx);
|
|
1318
|
+
startHeartbeat();
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
const triggerNativeCapture = async () => {
|
|
1322
|
+
await mkdir(join(homedir(), ".pi", "agent"), { recursive: true });
|
|
1323
|
+
const requestId = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
|
1324
|
+
await writeFile(
|
|
1325
|
+
TRIGGER_PATH,
|
|
1326
|
+
`${JSON.stringify({ requestId, action: "capture", createdAt: new Date().toISOString() }, null, "\t")}\n`,
|
|
1327
|
+
"utf8",
|
|
1328
|
+
);
|
|
1329
|
+
return requestId;
|
|
1330
|
+
};
|
|
1331
|
+
|
|
1332
|
+
const showStatus = async (ctx: ExtensionContext) => {
|
|
1333
|
+
await refreshState(ctx);
|
|
1334
|
+
const nativeRunning = await isNativeAppRunning();
|
|
1335
|
+
|
|
1336
|
+
ctx.ui.notify(
|
|
1337
|
+
state.port
|
|
1338
|
+
? `Pi bridge ready on ${HOST}:${state.port}${nativeRunning ? " • native app detected" : nativeLaunchInProgress ? " • starting native app" : " • native app not detected"} • target ${describeTargetSession()}${state.busy ? " • busy (captures queue)" : " • idle"}`
|
|
1339
|
+
: "PiLens bridge is not running.",
|
|
1340
|
+
state.port ? "success" : "warning",
|
|
1341
|
+
);
|
|
1342
|
+
};
|
|
1343
|
+
|
|
1344
|
+
const triggerLens = async (ctx: ExtensionContext) => {
|
|
1345
|
+
await ensureServer(ctx);
|
|
1346
|
+
|
|
1347
|
+
let launchError: unknown;
|
|
1348
|
+
|
|
1349
|
+
try {
|
|
1350
|
+
await launchNativeAppIfNeeded();
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
launchError = error;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
await triggerNativeCapture();
|
|
1356
|
+
|
|
1357
|
+
// Avoid emitting a success/warning toast here: the native app may not start the
|
|
1358
|
+
// capture overlay until the next trigger-file poll, and the toast can leak into
|
|
1359
|
+
// the screenshot the user is trying to take.
|
|
1360
|
+
if (launchError) {
|
|
1361
|
+
ctx.ui.notify(
|
|
1362
|
+
`Queued a capture request at ${TRIGGER_PATH}, but PiLens could not auto-start: ${launchError instanceof Error ? launchError.message : String(launchError)}`,
|
|
1363
|
+
"warning",
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
};
|
|
1367
|
+
|
|
1368
|
+
const showCapturePicker = async (ctx: ExtensionContext) => {
|
|
1369
|
+
if (!ctx.hasUI) {
|
|
1370
|
+
ctx.ui.notify("/pilens requires the interactive TUI to show recent captures.", "warning");
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
await ensureServer(ctx);
|
|
1375
|
+
const captures = await loadRecentCaptures();
|
|
1376
|
+
if (captures.length === 0) {
|
|
1377
|
+
ctx.ui.notify(`No PiLens captures or native macOS screenshots found.`, "warning");
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
const selected = await ctx.ui.custom<CaptureOption | null>((tui, theme, _keybindings, done) => {
|
|
1382
|
+
const picker = new CapturePickerComponent(captures, theme, done);
|
|
1383
|
+
return {
|
|
1384
|
+
render: (width: number) => picker.render(width),
|
|
1385
|
+
invalidate: () => picker.invalidate(),
|
|
1386
|
+
handleInput: (data: string) => {
|
|
1387
|
+
picker.handleInput(data);
|
|
1388
|
+
tui.requestRender();
|
|
1389
|
+
},
|
|
1390
|
+
};
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
if (!selected) {
|
|
1394
|
+
ctx.ui.notify("PiLens capture selection cancelled.", "info");
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
await refreshState(ctx);
|
|
1399
|
+
let selectedBase64: string;
|
|
1400
|
+
try {
|
|
1401
|
+
selectedBase64 = readImageBase64(selected.imagePath);
|
|
1402
|
+
} catch (error) {
|
|
1403
|
+
ctx.ui.notify(`Unable to read selected PiLens capture: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
const delivery = await deliverToSession({
|
|
1408
|
+
annotatedImageBase64: selectedBase64,
|
|
1409
|
+
annotatedMediaType: selected.mediaType,
|
|
1410
|
+
metadata: selected.metadata,
|
|
1411
|
+
});
|
|
1412
|
+
ctx.ui.notify(delivery.message, delivery.deliveryMode === "steer" ? "warning" : "success");
|
|
1413
|
+
};
|
|
1414
|
+
|
|
1415
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1416
|
+
if (!ctx.hasUI) return;
|
|
1417
|
+
await ensureServer(ctx);
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
1421
|
+
state.busy = true;
|
|
1422
|
+
await refreshState(ctx);
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
1426
|
+
state.busy = false;
|
|
1427
|
+
await refreshState(ctx);
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
pi.on("session_switch", async (_event, ctx) => {
|
|
1431
|
+
await refreshState(ctx);
|
|
1432
|
+
});
|
|
1433
|
+
|
|
1434
|
+
pi.on("model_select", async (_event, ctx) => {
|
|
1435
|
+
await refreshState(ctx);
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
pi.on("session_shutdown", async () => {
|
|
1439
|
+
stopHeartbeat();
|
|
1440
|
+
state.available = false;
|
|
1441
|
+
state.updatedAt = new Date().toISOString();
|
|
1442
|
+
state.heartbeatAt = state.updatedAt;
|
|
1443
|
+
await removeRegistryIfOwned();
|
|
1444
|
+
if (server) {
|
|
1445
|
+
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
|
1446
|
+
server = undefined;
|
|
1447
|
+
}
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
pi.registerCommand("pilens", {
|
|
1451
|
+
description: "Show recent PiLens captures and send the selected capture",
|
|
1452
|
+
handler: async (args, ctx) => {
|
|
1453
|
+
const trimmed = args.trim();
|
|
1454
|
+
if (!trimmed) {
|
|
1455
|
+
await showCapturePicker(ctx);
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const [subcommand] = trimmed.split(/\s+/);
|
|
1460
|
+
|
|
1461
|
+
switch (subcommand) {
|
|
1462
|
+
case "capture":
|
|
1463
|
+
await triggerLens(ctx);
|
|
1464
|
+
return;
|
|
1465
|
+
case "captures":
|
|
1466
|
+
case "view":
|
|
1467
|
+
case "recent":
|
|
1468
|
+
case "list":
|
|
1469
|
+
await showCapturePicker(ctx);
|
|
1470
|
+
return;
|
|
1471
|
+
case "status":
|
|
1472
|
+
await showStatus(ctx);
|
|
1473
|
+
return;
|
|
1474
|
+
default:
|
|
1475
|
+
ctx.ui.notify("Usage: /pilens [capture|captures|status]", "warning");
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
},
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
}
|