pi-note 0.11.1 → 0.12.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 CHANGED
@@ -7,7 +7,7 @@ The extension and Python app share a repository but install separately.
7
7
 
8
8
  ## Install
9
9
 
10
- Requires Node.js 22.19+ and **pinote 0.3.0+** (`note` on PATH). Add and tag listing need **pinote 0.4.0+**.
10
+ Requires Pi 0.99.1+, Node.js 22.19+ and **pinote 0.3.0+** (`note` on PATH). Add and tag listing need **pinote 0.4.0+**.
11
11
  From this checkout, install the extension with `pi install ./pi`, then run
12
12
  `/reload` in interactive Pi. Use `/pi-note-setup` if the CLI is missing, or the
13
13
  suggested `/pi-note-upgrade` if it is older than the bundled CLI. The planned npm package name is
@@ -68,9 +68,41 @@ task, so existing values can be configured without creating another task.
68
68
  Type in the task picker to filter by text, ID, tag, or state (all words must match).
69
69
  Use ↑/↓ and Enter to select, or Esc to cancel. Rows show **●** for in progress or
70
70
  **○** for active, followed by the tag (`[Untagged]` when absent).
71
- The normal Pi status area shows `📌 [tag] Task title` without replacing other footers.
72
- The footer omits the task ID and state and shows only the first line, truncated to the configured width (default 60 terminal columns including the pin and tag). Overflow ends with `...`.
73
- The pin glyph is bundled in `icons/note.txt`; it uses the terminal's emoji font, not a Nerd Font or icon theme.
71
+ The normal Pi status area shows `👁 📌 [tag] Task title` without replacing other footers.
72
+ The footer omits the task ID and state and shows only the first line, truncated to the configured width (default 60 terminal columns including the eye, pin and tag). Overflow ends with `...`.
73
+ The glyphs are bundled in `icons/eye.txt` and `icons/note.txt`; they use terminal fonts, not a Nerd Font or icon theme.
74
+
75
+ ### Display-only preview
76
+
77
+ Run `/pi-note-preview` to print the selected task's full Markdown text and saved
78
+ agent fields in the transcript. The preview is labeled **display only · not sent
79
+ to model**: it is a local custom session entry, excluded from subsequent model
80
+ requests, compaction and branch summaries. It survives session resume but does not
81
+ modify the note, editor draft or task selection, and never starts a model call.
82
+ The agent can still read the task separately with `pinote_get_current`.
83
+ Previewing requires Pi to be idle with no other Pinote operation open.
84
+
85
+ On Linux, the eye is a clickable OSC 8 link to a private per-session Unix socket.
86
+ The package ships [a Kitty configuration example](kitty/open-actions.conf).
87
+ Append its block to `~/.config/kitty/open-actions.conf`, preserving existing
88
+ actions. Replace `/absolute/path/to/pi-note` with the installed package directory
89
+ containing `preview-click.cjs`; for this checkout, that directory is `pi/`.
90
+ Keep `${URL}` literal: Kitty substitutes the clicked eye's link.
91
+
92
+ ```conf
93
+ protocol pi-note-preview
94
+ action launch --type=background node /absolute/path/to/pi-note/preview-click.cjs ${URL}
95
+ ```
96
+
97
+ Do not replace or symlink your entire Kitty configuration to the example: it
98
+ contains only Pinote's handler. Package installation does not edit Kitty files.
99
+
100
+ Reload Kitty with **Ctrl+Shift+F5**. In Pi's regular mode, click the eye; in
101
+ fullscreen mode, use **Ctrl+Shift+click** so Kitty handles the link instead of
102
+ Pi's system URL opener. The helper sends only an authenticated selection
103
+ identifier, never note content or a model prompt. Stale links after task switching, tree navigation, reload or session exit
104
+ are rejected. Other terminals can use `/pi-note-preview`; if the socket cannot
105
+ start, the eye is omitted and that command remains available.
74
106
 
75
107
  Each Pi session remembers its own task, including several sessions in one folder.
76
108
  Resume restores that session's task; a new session starts unselected and does not import
@@ -198,7 +230,7 @@ Add `footer` to `~/.pi/agent/pi-note.json` (or `pi-note.json` under
198
230
  }
199
231
  ```
200
232
 
201
- Widths are terminal columns, including icons/tags or field labels, and must be
233
+ Widths are terminal columns, including the eye, pin, tags or field labels, and must be
202
234
  integers from 3 to 1000. Defaults: `titleWidth: 60`, `fieldWidth: 60`,
203
235
  `maxFields: 4` (allowed 0–64). Each field can override `fieldWidth` with `width`.
204
236
  `fields` selects task field names in order, regardless of a task's `Bar`.
package/icons/eye.txt ADDED
@@ -0,0 +1 @@
1
+ 👁
package/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { Editor, truncateToWidth, type Keybinding } from "@earendil-works/pi-tui";
1
+ import { getAgentDir, getMarkdownTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Editor, Markdown, Text, truncateToWidth, visibleWidth, type Keybinding } from "@earendil-works/pi-tui";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { stripVTControlCharacters } from "node:util";
@@ -9,6 +9,7 @@ import { defaultFooterConfig, defaultHandoffPrompt, effectiveFooterFields, loadF
9
9
  import { FooterSettings, TaskMenu, withSettingsTab } from "./footer-settings.ts";
10
10
  import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
11
11
  import { bundledCLIVersion, cliMenu, detectCLI, setupCLI, setupHint, versionAtLeast, type CLIAction } from "./setup.ts";
12
+ import { createPreviewBridge } from "./preview-bridge.ts";
12
13
 
13
14
  const handoffGuidance = "Keep agent notes to at most three short bullets total: outcome, blocker, next action, only when relevant. Replace stale notes; omit narration, repeated task text, and routine test logs. Keep a GitHub pull request in the PR field. Read pinote_fields to learn the user's globally configured footer field names and presentation. Populate those fields with meaningful task values when relevant using pinote_update_current; fields without values stay hidden. Footer fields and widths are chosen by the user's pi-note.json config. Set configured fields with Markdown values; links are clickable. When footer.fields is unset, Bar selects labels, one per line. Do not list PR in Bar. Remove a field to hide it. Do not modify config without user approval.";
14
15
  const offerGuidance = {
@@ -30,6 +31,8 @@ type Task = {
30
31
  type Summary = Pick<Task, "id" | "text" | "state" | "tag">;
31
32
  // The standard Pi footer accepts text, so ship a portable terminal glyph, not a theme icon.
32
33
  const noteIcon = readFileSync(new URL("./icons/note.txt", import.meta.url), "utf8").trim();
34
+ const eyeIcon = readFileSync(new URL("./icons/eye.txt", import.meta.url), "utf8").trim();
35
+ const previewType = "pinote-preview";
33
36
  const compatible = "Incompatible note CLI response. Install pinote 0.3.0+ and check note on PATH.";
34
37
  const validId = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0;
35
38
  const record = (value: unknown): value is Record<string, unknown> =>
@@ -115,6 +118,14 @@ function toolResult(value: Task | null) {
115
118
 
116
119
  export default function (pi: ExtensionAPI) {
117
120
  const { guidelines: createGuidance, error: offerConfigError } = creationGuidance();
121
+ pi.registerEntryRenderer<{ id: number; markdown: string }>(previewType, (entry, _options, theme) => {
122
+ if (!record(entry.data) || !validId(entry.data.id) || typeof entry.data.markdown !== "string") return;
123
+ const view = new Container();
124
+ view.addChild(new Text(theme.fg("muted", `Pinote #${entry.data.id} · display only · not sent to model`), 0, 0));
125
+ view.addChild(new Markdown(clean(entry.data.markdown), 0, 0, getMarkdownTheme()));
126
+ return view;
127
+ });
128
+ let previewBridge: ReturnType<typeof createPreviewBridge> | undefined;
118
129
  let alive = true;
119
130
  let epoch = 0;
120
131
  let refreshSerial = 0;
@@ -184,6 +195,7 @@ export default function (pi: ExtensionAPI) {
184
195
  let branchEpoch = 0;
185
196
  const remember = (id: number | null, branch = branchEpoch) => {
186
197
  if (!alive || branch !== branchEpoch || selectedId === id) return;
198
+ previewBridge?.invalidate();
187
199
  selectedId = id;
188
200
  pi.appendEntry(selectionType, { id });
189
201
  };
@@ -238,11 +250,20 @@ export default function (pi: ExtensionAPI) {
238
250
  try {
239
251
  const current = await selected(ctx);
240
252
  if (alive && generation === epoch && serial === refreshSerial) {
241
- ctx.ui.setStatus("pinote", current ? truncateToWidth(`${noteIcon} ${taskTag(current)} ${firstLine(current)}`, footerConfig.titleWidth, "...") : undefined);
253
+ const previewId = current?.id ?? null;
254
+ const previewBranch = branchEpoch;
255
+ previewBridge?.setTask(previewId, (signal) => alive && generation === epoch && previewBranch === branchEpoch &&
256
+ selectedId === previewId ? preview(ctx, signal) : Promise.resolve(false));
257
+ const url = previewBridge?.url();
258
+ // Keep the eye outside title truncation, including the minimum title width.
259
+ const eye = url ? `\x1b]8;;${url}\x07${eyeIcon}\x1b]8;;\x07 ` : "";
260
+ const width = Math.max(0, footerConfig.titleWidth - (eye ? visibleWidth(eyeIcon) + 1 : 0));
261
+ ctx.ui.setStatus("pinote", current ? eye + truncateToWidth(`${noteIcon} ${taskTag(current)} ${firstLine(current)}`, width, "...") : undefined);
242
262
  watcher.update(ctx, current);
243
263
  }
244
264
  } catch {
245
265
  if (alive && generation === epoch && serial === refreshSerial) {
266
+ previewBridge?.invalidate();
246
267
  ctx.ui.setStatus("pinote", `${noteIcon} Pinote unavailable · ${cliState === "upgrade" ? "/pi-note-upgrade" : cliState === "setup" ? "/pi-note-setup" : "/pi-note"}`);
247
268
  }
248
269
  }
@@ -265,14 +286,57 @@ export default function (pi: ExtensionAPI) {
265
286
  },
266
287
  refresh,
267
288
  });
289
+ const preview = async (ctx: ExtensionContext, requestSignal?: AbortSignal): Promise<boolean> => {
290
+ if (!ctx.hasUI || ctx.mode !== "tui" || !ctx.isIdle() || pending || !validId(selectedId)) return false;
291
+ const generation = epoch;
292
+ const branch = branchEpoch;
293
+ const id = selectedId;
294
+ const operation = Symbol();
295
+ pending = operation;
296
+ // Complete before the bridge's 5s and helper's 6s deadlines; disconnection cancels reads too.
297
+ const controller = new AbortController();
298
+ const deadline = Date.now() + 4000;
299
+ const timer = setTimeout(() => controller.abort(), 4000);
300
+ const signal = requestSignal ? AbortSignal.any([requestSignal, controller.signal]) : controller.signal;
301
+ const canUse = () => alive && generation === epoch && branch === branchEpoch && selectedId === id && ctx.isIdle() &&
302
+ !signal.aborted && Date.now() < deadline;
303
+ try {
304
+ const current = await currentTask(signal, canUse);
305
+ if (!current || !canUse()) return false;
306
+ // Custom entries are rendered locally, never conversation messages or compaction input.
307
+ pi.appendEntry(previewType, { id: current.id, markdown: current.markdown });
308
+ return true;
309
+ } catch (error) {
310
+ if (canUse()) ctx.ui.notify(`Pinote preview: ${clean(String(error))}`, "error");
311
+ return false;
312
+ } finally {
313
+ clearTimeout(timer);
314
+ if (pending === operation) pending = undefined;
315
+ }
316
+ };
317
+ pi.registerCommand("pi-note-preview", {
318
+ description: "Display the selected task locally without sending it to the model",
319
+ handler: async (args, ctx) => {
320
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
321
+ if (args.trim()) { ctx.ui.notify("Usage: /pi-note-preview", "warning"); return; }
322
+ if (!await preview(ctx)) ctx.ui.notify("Select a task with /pi-note and wait until Pi is idle before previewing.", "warning");
323
+ },
324
+ });
268
325
  pi.on("session_start", async (_event, ctx) => {
269
326
  setupAbort?.abort();
270
- alive = true;
271
- activeContext = ctx;
327
+ // Retire old polling before bridge teardown/startup yields to its scheduled callbacks.
328
+ watcher.stop();
329
+ alive = false;
272
330
  epoch++;
273
331
  branchEpoch++;
274
- selectedId = readSelection(ctx);
275
332
  const generation = epoch;
333
+ const previousBridge = previewBridge;
334
+ previewBridge = undefined;
335
+ if (previousBridge) await previousBridge.stop();
336
+ if (generation !== epoch) return;
337
+ alive = true;
338
+ activeContext = ctx;
339
+ selectedId = readSelection(ctx);
276
340
  footerConfig = loadFooterConfig((message) => {
277
341
  if (ctx.hasUI) ctx.ui.notify(message, "warning");
278
342
  });
@@ -281,6 +345,18 @@ export default function (pi: ExtensionAPI) {
281
345
  if (offerConfigError) ctx.ui.notify(`Pinote task offers disabled: ${clean(offerConfigError)} Fix the configuration and /reload.`, "warning");
282
346
  ctx.ui.addAutocompleteProvider((current) => cliMenu(current, () => setupAbort ? undefined : cliState));
283
347
  }
348
+ if (ctx.hasUI && ctx.mode === "tui") {
349
+ const bridge = createPreviewBridge();
350
+ previewBridge = bridge;
351
+ try {
352
+ await bridge.start();
353
+ } catch {
354
+ await bridge.stop();
355
+ if (previewBridge === bridge) previewBridge = undefined;
356
+ ctx.ui.notify("Pinote eye link unavailable; use /pi-note-preview.", "warning");
357
+ }
358
+ }
359
+ if (!alive || generation !== epoch) return;
284
360
  // Keep the setup lock until its aborted subprocess has actually settled.
285
361
  if (!setupAbort) pending = undefined;
286
362
  await checkCLI(ctx, true);
@@ -291,6 +367,7 @@ export default function (pi: ExtensionAPI) {
291
367
  pi.on("session_tree", async (_event, ctx) => {
292
368
  if (!alive) return;
293
369
  branchEpoch++;
370
+ previewBridge?.invalidate();
294
371
  const generation = branchEpoch;
295
372
  const id = readSelection(ctx);
296
373
  if (!alive || generation !== branchEpoch) return;
@@ -300,7 +377,7 @@ export default function (pi: ExtensionAPI) {
300
377
  });
301
378
  pi.on("before_agent_start", async (_event, ctx) => { await refresh(ctx); });
302
379
  pi.on("agent_end", async (_event, ctx) => { await refresh(ctx); });
303
- pi.on("session_shutdown", (_event, ctx) => {
380
+ pi.on("session_shutdown", async (_event, ctx) => {
304
381
  setupAbort?.abort();
305
382
  alive = false;
306
383
  activeContext = undefined;
@@ -310,6 +387,9 @@ export default function (pi: ExtensionAPI) {
310
387
  refreshSerial++;
311
388
  watcher.stop();
312
389
  if (ctx.hasUI && ctx.mode === "tui") ctx.ui.setStatus("pinote", undefined);
390
+ const bridge = previewBridge;
391
+ previewBridge = undefined;
392
+ if (bridge) await bridge.stop();
313
393
  });
314
394
 
315
395
  const registerInstallCommand = (name: "pi-note-setup" | "pi-note-upgrade") => pi.registerCommand(name, {
@@ -0,0 +1,5 @@
1
+ # Append this block to ~/.config/kitty/open-actions.conf; preserve existing actions.
2
+ # Replace /absolute/path/to/pi-note with the directory containing preview-click.cjs.
3
+ # Keep ${URL} literal: Kitty substitutes the clicked eye's link.
4
+ protocol pi-note-preview
5
+ action launch --type=background node /absolute/path/to/pi-note/preview-click.cjs ${URL}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,7 +14,7 @@
14
14
  "homepage": "https://github.com/kvidzibo/pinote/tree/main/pi#readme",
15
15
  "bugs": { "url": "https://github.com/kvidzibo/pinote/issues" },
16
16
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
17
- "files": ["index.ts", "pr-watch.ts", "footer-links.ts", "footer-config.ts", "footer-settings.ts", "task-picker.ts", "setup.ts", "icons", "README.md", "LICENSE"],
17
+ "files": ["index.ts", "pr-watch.ts", "footer-links.ts", "footer-config.ts", "footer-settings.ts", "task-picker.ts", "setup.ts", "preview-bridge.ts", "preview-click.cjs", "icons", "kitty", "README.md", "LICENSE"],
18
18
  "pi": { "extensions": ["./index.ts"] },
19
19
  "scripts": {
20
20
  "check": "tsc --noEmit",
@@ -0,0 +1,113 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import { createServer, type Server, type Socket } from "node:net";
4
+ import { basename, join } from "node:path";
5
+
6
+ // No note data travels through the socket or hyperlink; both identify a local selection.
7
+ export function createPreviewBridge() {
8
+ const uid = process.getuid?.();
9
+ const capability = randomBytes(16).toString("hex");
10
+ let server: Server | undefined;
11
+ let socketPath: string | undefined;
12
+ let starting: Promise<void> | undefined;
13
+ let stopping: Promise<void> | undefined;
14
+ let closed = false;
15
+ let ready = false;
16
+ let taskId: number | null = null;
17
+ let callback: ((signal: AbortSignal) => Promise<boolean>) | undefined;
18
+ let nonce: string | undefined;
19
+ const clients = new Set<Socket>();
20
+ const invalidate = () => { taskId = null; callback = undefined; nonce = undefined; };
21
+ const cleanup = async () => {
22
+ ready = false;
23
+ for (const client of clients) client.destroy();
24
+ const instance = server;
25
+ server = undefined;
26
+ if (instance) await new Promise<void>((resolve) => instance.close(() => resolve()));
27
+ const file = socketPath;
28
+ socketPath = undefined;
29
+ if (file) {
30
+ try {
31
+ const st = await fs.lstat(file);
32
+ if (st.isSocket() && st.uid === uid) await fs.unlink(file);
33
+ } catch (error) {
34
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
35
+ }
36
+ }
37
+ };
38
+ const api = {
39
+ async start(): Promise<void> {
40
+ if (closed || uid === undefined) throw new Error("Preview bridge unavailable");
41
+ if (starting) return starting;
42
+ starting = (async () => {
43
+ const dir = `/tmp/pi-note-preview-${uid}`;
44
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
45
+ const st = await fs.lstat(dir);
46
+ if (!st.isDirectory() || st.uid !== uid || (st.mode & 0o777) !== 0o700 || closed) {
47
+ throw new Error("Preview bridge unavailable");
48
+ }
49
+ socketPath = join(dir, `${process.pid}-${randomBytes(4).toString("hex")}.sock`);
50
+ const instance = createServer((client) => {
51
+ clients.add(client);
52
+ const request = new AbortController();
53
+ client.on("error", () => client.destroy());
54
+ client.on("close", () => { request.abort(); clients.delete(client); });
55
+ client.setTimeout(5000, () => { request.abort(); client.destroy(); });
56
+ let data = Buffer.alloc(0);
57
+ let dispatched = false;
58
+ client.on("data", (chunk) => {
59
+ if (dispatched || closed) return;
60
+ data = Buffer.concat([data, chunk]);
61
+ if (data.length > 128) { client.destroy(); return; }
62
+ if (!data.includes(10)) return;
63
+ dispatched = true;
64
+ const input = data.toString("utf8");
65
+ const match = /^preview ([0-9a-f]{32}) ([1-9][0-9]*) ([0-9a-f]{32})\n$/u.exec(input);
66
+ const id = match ? Number(match[2]) : NaN;
67
+ if (!match || match[0] !== input || match[1] !== capability || !Number.isSafeInteger(id) ||
68
+ id !== taskId || match[3] !== nonce || !callback) { client.end("rejected\n"); return; }
69
+ const invoke = callback;
70
+ Promise.resolve().then(() => !closed && !request.signal.aborted && taskId === id && nonce === match[3] ? invoke(request.signal) : false)
71
+ .then((accepted) => { if (!client.destroyed) client.end(accepted === true ? "accepted\n" : "rejected\n"); },
72
+ () => { if (!client.destroyed) client.end("rejected\n"); });
73
+ });
74
+ });
75
+ server = instance;
76
+ try {
77
+ await new Promise<void>((resolve, reject) => {
78
+ instance.once("error", reject);
79
+ instance.listen(socketPath!, resolve);
80
+ });
81
+ await fs.chmod(socketPath, 0o600);
82
+ if (closed) throw new Error("Preview bridge unavailable");
83
+ instance.on("error", () => { void api.stop().catch(() => {}); });
84
+ instance.unref();
85
+ ready = true;
86
+ } catch {
87
+ await cleanup();
88
+ throw new Error("Preview bridge unavailable");
89
+ }
90
+ })();
91
+ return starting;
92
+ },
93
+ setTask(id: number | null, preview: (signal: AbortSignal) => Promise<boolean>): void {
94
+ if (closed) return;
95
+ if (id !== null && (!Number.isSafeInteger(id) || id <= 0)) throw new Error("Invalid preview task");
96
+ if (id !== taskId) nonce = id === null ? undefined : randomBytes(16).toString("hex");
97
+ taskId = id;
98
+ callback = id === null ? undefined : preview;
99
+ },
100
+ invalidate,
101
+ url(): string | undefined {
102
+ if (!ready || closed || !socketPath || taskId === null || !nonce) return;
103
+ return `pi-note-preview://${basename(socketPath, ".sock")}/${capability}/${taskId}/${nonce}`;
104
+ },
105
+ async stop(): Promise<void> {
106
+ closed = true;
107
+ invalidate();
108
+ if (!stopping) stopping = (async () => { await starting?.catch(() => {}); await cleanup(); })();
109
+ return stopping;
110
+ },
111
+ };
112
+ return api;
113
+ }
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const net = require('node:net');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ const urlPattern = /^pi-note-preview:\/\/([1-9][0-9]*)-([0-9a-f]{8})\/([0-9a-f]{32})\/([1-9][0-9]*)\/([0-9a-f]{32})$/;
8
+
9
+ function parse(value) {
10
+ if (typeof value !== 'string' || value.length > 160) throw new Error('Preview unavailable');
11
+ const m = urlPattern.exec(value);
12
+ if (!m || m[0] !== value) throw new Error('Preview unavailable');
13
+ const pid = Number(m[1]), id = Number(m[4]);
14
+ if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(id) || pid <= 0 || id <= 0) throw new Error('Preview unavailable');
15
+ return { pid, tag: m[2], capability: m[3], id, nonce: m[5] };
16
+ }
17
+ function socketPathFor(value) {
18
+ const p = parse(value);
19
+ return path.join(`/tmp/pi-note-preview-${typeof process.getuid === 'function' ? process.getuid() : -1}`, `${p.pid}-${p.tag}.sock`);
20
+ }
21
+ function verify(file) {
22
+ const uid = typeof process.getuid === 'function' ? process.getuid() : -1;
23
+ if (uid < 0) throw new Error('Preview unavailable');
24
+ const dir = `/tmp/pi-note-preview-${uid}`;
25
+ const ds = fs.lstatSync(dir);
26
+ if (!ds.isDirectory() || ds.isSymbolicLink() || ds.uid !== uid || (ds.mode & 0o777) !== 0o700) throw new Error('Preview unavailable');
27
+ const ss = fs.lstatSync(file);
28
+ if (!ss.isSocket() || ss.isSymbolicLink() || ss.uid !== uid || (ss.mode & 0o777) !== 0o600) throw new Error('Preview unavailable');
29
+ }
30
+ function preview(value) {
31
+ let p, file;
32
+ try { p = parse(value); file = socketPathFor(value); verify(file); }
33
+ catch { return Promise.reject(new Error('Preview unavailable')); }
34
+ return new Promise((resolve, reject) => {
35
+ const client = net.createConnection(file);
36
+ let response = Buffer.alloc(0), done = false;
37
+ const finish = (err, result) => {
38
+ if (done) return;
39
+ done = true; clearTimeout(timer); client.destroy();
40
+ err ? reject(new Error('Preview unavailable')) : resolve(result);
41
+ };
42
+ const timer = setTimeout(() => finish(new Error()), 6000);
43
+ client.on('connect', () => client.write(`preview ${p.capability} ${p.id} ${p.nonce}\n`));
44
+ client.on('data', chunk => {
45
+ response = Buffer.concat([response, chunk]);
46
+ if (response.length > 64) return finish(new Error());
47
+ const text = response.toString('utf8');
48
+ if (text.includes('\n')) finish(text === 'accepted\n' ? null : new Error(), text === 'accepted\n');
49
+ });
50
+ client.on('error', () => finish(new Error()));
51
+ client.on('end', () => { if (!done) finish(new Error()); });
52
+ });
53
+ }
54
+ if (require.main === module) {
55
+ if (process.argv.length !== 3) {
56
+ console.error('Usage: preview-click.cjs <preview URL>');
57
+ process.exitCode = 2;
58
+ } else {
59
+ preview(process.argv[2]).catch(() => {
60
+ console.error('Pinote preview unavailable; select a task and wait until Pi is idle.');
61
+ process.exitCode = 1;
62
+ });
63
+ }
64
+ }
65
+ module.exports = { preview, socketPathFor };