pi-note 0.11.1 → 0.12.1

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
@@ -149,6 +181,9 @@ When allowed by `taskOfferPolicy`, the agent proposes one note as `[tag] text`
149
181
  and asks before creating it. No continues without a note. Yes calls
150
182
  `pinote_add` with `select: true`. An existing selection is not replaced unless the
151
183
  user asks to switch. Reuse a saved tag name when it fits.
184
+ Task text starts with a short, action-oriented title (aim for at most 60 characters).
185
+ Put context, URLs, commands, and acceptance criteria after a blank line; the title
186
+ should not contain implementation details. This is agent guidance, not a storage limit.
152
187
 
153
188
  Agents are guided to keep notes to three short bullets total: relevant outcome,
154
189
  blocker, and next action. Replace stale notes; omit narration, repeated task text,
@@ -198,7 +233,7 @@ Add `footer` to `~/.pi/agent/pi-note.json` (or `pi-note.json` under
198
233
  }
199
234
  ```
200
235
 
201
- Widths are terminal columns, including icons/tags or field labels, and must be
236
+ Widths are terminal columns, including the eye, pin, tags or field labels, and must be
202
237
  integers from 3 to 1000. Defaults: `titleWidth: 60`, `fieldWidth: 60`,
203
238
  `maxFields: 4` (allowed 0–64). Each field can override `fieldWidth` with `width`.
204
239
  `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 = {
@@ -17,6 +18,7 @@ const offerGuidance = {
17
18
  never: "Do not offer to create a pinote task. Create one only when the user explicitly requests it.",
18
19
  };
19
20
  const consentGuidance = "On no, continue without a note. On yes, call pinote_add with select true so it becomes this session's active task. Never add or select without a yes. If a task is already selected, do not replace it unless the user asks to switch.";
21
+ const titleGuidance = "Start task text with a short, action-oriented summary (aim for at most 60 characters). Put context, URLs, commands, and acceptance criteria after a blank line. Never put implementation details in the title.";
20
22
  const tagGuidance = "Reuse a pinote_tags name when it fits. One tag; case-sensitive, trimmed, at most 64 characters. Tags are set when creating tasks with pinote_add; agent tools cannot retag existing tasks. Do not use pinote_update_current for tags or task text.";
21
23
  type Task = {
22
24
  id: number;
@@ -30,6 +32,8 @@ type Task = {
30
32
  type Summary = Pick<Task, "id" | "text" | "state" | "tag">;
31
33
  // The standard Pi footer accepts text, so ship a portable terminal glyph, not a theme icon.
32
34
  const noteIcon = readFileSync(new URL("./icons/note.txt", import.meta.url), "utf8").trim();
35
+ const eyeIcon = readFileSync(new URL("./icons/eye.txt", import.meta.url), "utf8").trim();
36
+ const previewType = "pinote-preview";
33
37
  const compatible = "Incompatible note CLI response. Install pinote 0.3.0+ and check note on PATH.";
34
38
  const validId = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0;
35
39
  const record = (value: unknown): value is Record<string, unknown> =>
@@ -71,7 +75,7 @@ function creationGuidance(): { guidelines: string[]; error?: string } {
71
75
  policy = "never";
72
76
  error = cause instanceof Error ? cause.message : String(cause);
73
77
  }
74
- return { guidelines: [`${offerGuidance[policy]} ${consentGuidance}`, tagGuidance], error };
78
+ return { guidelines: [`${offerGuidance[policy]} ${consentGuidance}`, titleGuidance, tagGuidance], error };
75
79
  }
76
80
 
77
81
  function handoffPrompt(): string {
@@ -115,6 +119,14 @@ function toolResult(value: Task | null) {
115
119
 
116
120
  export default function (pi: ExtensionAPI) {
117
121
  const { guidelines: createGuidance, error: offerConfigError } = creationGuidance();
122
+ pi.registerEntryRenderer<{ id: number; markdown: string }>(previewType, (entry, _options, theme) => {
123
+ if (!record(entry.data) || !validId(entry.data.id) || typeof entry.data.markdown !== "string") return;
124
+ const view = new Container();
125
+ view.addChild(new Text(theme.fg("muted", `Pinote #${entry.data.id} · display only · not sent to model`), 0, 0));
126
+ view.addChild(new Markdown(clean(entry.data.markdown), 0, 0, getMarkdownTheme()));
127
+ return view;
128
+ });
129
+ let previewBridge: ReturnType<typeof createPreviewBridge> | undefined;
118
130
  let alive = true;
119
131
  let epoch = 0;
120
132
  let refreshSerial = 0;
@@ -184,6 +196,7 @@ export default function (pi: ExtensionAPI) {
184
196
  let branchEpoch = 0;
185
197
  const remember = (id: number | null, branch = branchEpoch) => {
186
198
  if (!alive || branch !== branchEpoch || selectedId === id) return;
199
+ previewBridge?.invalidate();
187
200
  selectedId = id;
188
201
  pi.appendEntry(selectionType, { id });
189
202
  };
@@ -238,11 +251,20 @@ export default function (pi: ExtensionAPI) {
238
251
  try {
239
252
  const current = await selected(ctx);
240
253
  if (alive && generation === epoch && serial === refreshSerial) {
241
- ctx.ui.setStatus("pinote", current ? truncateToWidth(`${noteIcon} ${taskTag(current)} ${firstLine(current)}`, footerConfig.titleWidth, "...") : undefined);
254
+ const previewId = current?.id ?? null;
255
+ const previewBranch = branchEpoch;
256
+ previewBridge?.setTask(previewId, (signal) => alive && generation === epoch && previewBranch === branchEpoch &&
257
+ selectedId === previewId ? preview(ctx, signal) : Promise.resolve(false));
258
+ const url = previewBridge?.url();
259
+ // Keep the eye outside title truncation, including the minimum title width.
260
+ const eye = url ? `\x1b]8;;${url}\x07${eyeIcon}\x1b]8;;\x07 ` : "";
261
+ const width = Math.max(0, footerConfig.titleWidth - (eye ? visibleWidth(eyeIcon) + 1 : 0));
262
+ ctx.ui.setStatus("pinote", current ? eye + truncateToWidth(`${noteIcon} ${taskTag(current)} ${firstLine(current)}`, width, "...") : undefined);
242
263
  watcher.update(ctx, current);
243
264
  }
244
265
  } catch {
245
266
  if (alive && generation === epoch && serial === refreshSerial) {
267
+ previewBridge?.invalidate();
246
268
  ctx.ui.setStatus("pinote", `${noteIcon} Pinote unavailable · ${cliState === "upgrade" ? "/pi-note-upgrade" : cliState === "setup" ? "/pi-note-setup" : "/pi-note"}`);
247
269
  }
248
270
  }
@@ -265,14 +287,57 @@ export default function (pi: ExtensionAPI) {
265
287
  },
266
288
  refresh,
267
289
  });
290
+ const preview = async (ctx: ExtensionContext, requestSignal?: AbortSignal): Promise<boolean> => {
291
+ if (!ctx.hasUI || ctx.mode !== "tui" || !ctx.isIdle() || pending || !validId(selectedId)) return false;
292
+ const generation = epoch;
293
+ const branch = branchEpoch;
294
+ const id = selectedId;
295
+ const operation = Symbol();
296
+ pending = operation;
297
+ // Complete before the bridge's 5s and helper's 6s deadlines; disconnection cancels reads too.
298
+ const controller = new AbortController();
299
+ const deadline = Date.now() + 4000;
300
+ const timer = setTimeout(() => controller.abort(), 4000);
301
+ const signal = requestSignal ? AbortSignal.any([requestSignal, controller.signal]) : controller.signal;
302
+ const canUse = () => alive && generation === epoch && branch === branchEpoch && selectedId === id && ctx.isIdle() &&
303
+ !signal.aborted && Date.now() < deadline;
304
+ try {
305
+ const current = await currentTask(signal, canUse);
306
+ if (!current || !canUse()) return false;
307
+ // Custom entries are rendered locally, never conversation messages or compaction input.
308
+ pi.appendEntry(previewType, { id: current.id, markdown: current.markdown });
309
+ return true;
310
+ } catch (error) {
311
+ if (canUse()) ctx.ui.notify(`Pinote preview: ${clean(String(error))}`, "error");
312
+ return false;
313
+ } finally {
314
+ clearTimeout(timer);
315
+ if (pending === operation) pending = undefined;
316
+ }
317
+ };
318
+ pi.registerCommand("pi-note-preview", {
319
+ description: "Display the selected task locally without sending it to the model",
320
+ handler: async (args, ctx) => {
321
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
322
+ if (args.trim()) { ctx.ui.notify("Usage: /pi-note-preview", "warning"); return; }
323
+ if (!await preview(ctx)) ctx.ui.notify("Select a task with /pi-note and wait until Pi is idle before previewing.", "warning");
324
+ },
325
+ });
268
326
  pi.on("session_start", async (_event, ctx) => {
269
327
  setupAbort?.abort();
270
- alive = true;
271
- activeContext = ctx;
328
+ // Retire old polling before bridge teardown/startup yields to its scheduled callbacks.
329
+ watcher.stop();
330
+ alive = false;
272
331
  epoch++;
273
332
  branchEpoch++;
274
- selectedId = readSelection(ctx);
275
333
  const generation = epoch;
334
+ const previousBridge = previewBridge;
335
+ previewBridge = undefined;
336
+ if (previousBridge) await previousBridge.stop();
337
+ if (generation !== epoch) return;
338
+ alive = true;
339
+ activeContext = ctx;
340
+ selectedId = readSelection(ctx);
276
341
  footerConfig = loadFooterConfig((message) => {
277
342
  if (ctx.hasUI) ctx.ui.notify(message, "warning");
278
343
  });
@@ -281,6 +346,18 @@ export default function (pi: ExtensionAPI) {
281
346
  if (offerConfigError) ctx.ui.notify(`Pinote task offers disabled: ${clean(offerConfigError)} Fix the configuration and /reload.`, "warning");
282
347
  ctx.ui.addAutocompleteProvider((current) => cliMenu(current, () => setupAbort ? undefined : cliState));
283
348
  }
349
+ if (ctx.hasUI && ctx.mode === "tui") {
350
+ const bridge = createPreviewBridge();
351
+ previewBridge = bridge;
352
+ try {
353
+ await bridge.start();
354
+ } catch {
355
+ await bridge.stop();
356
+ if (previewBridge === bridge) previewBridge = undefined;
357
+ ctx.ui.notify("Pinote eye link unavailable; use /pi-note-preview.", "warning");
358
+ }
359
+ }
360
+ if (!alive || generation !== epoch) return;
284
361
  // Keep the setup lock until its aborted subprocess has actually settled.
285
362
  if (!setupAbort) pending = undefined;
286
363
  await checkCLI(ctx, true);
@@ -291,6 +368,7 @@ export default function (pi: ExtensionAPI) {
291
368
  pi.on("session_tree", async (_event, ctx) => {
292
369
  if (!alive) return;
293
370
  branchEpoch++;
371
+ previewBridge?.invalidate();
294
372
  const generation = branchEpoch;
295
373
  const id = readSelection(ctx);
296
374
  if (!alive || generation !== branchEpoch) return;
@@ -300,7 +378,7 @@ export default function (pi: ExtensionAPI) {
300
378
  });
301
379
  pi.on("before_agent_start", async (_event, ctx) => { await refresh(ctx); });
302
380
  pi.on("agent_end", async (_event, ctx) => { await refresh(ctx); });
303
- pi.on("session_shutdown", (_event, ctx) => {
381
+ pi.on("session_shutdown", async (_event, ctx) => {
304
382
  setupAbort?.abort();
305
383
  alive = false;
306
384
  activeContext = undefined;
@@ -310,6 +388,9 @@ export default function (pi: ExtensionAPI) {
310
388
  refreshSerial++;
311
389
  watcher.stop();
312
390
  if (ctx.hasUI && ctx.mode === "tui") ctx.ui.setStatus("pinote", undefined);
391
+ const bridge = previewBridge;
392
+ previewBridge = undefined;
393
+ if (bridge) await bridge.stop();
313
394
  });
314
395
 
315
396
  const registerInstallCommand = (name: "pi-note-setup" | "pi-note-upgrade") => pi.registerCommand(name, {
@@ -516,9 +597,9 @@ export default function (pi: ExtensionAPI) {
516
597
  name: "pinote_add",
517
598
  label: "Pinote add",
518
599
  promptGuidelines: createGuidance,
519
- description: "Create an active pinote task. Optional tag is registered if new. Set select true only after the user agrees to make it this session's active task; that starts it and remembers it for this session only. Omitting select leaves the current selection unchanged. Does not refresh the desktop notification or bind the project directory.",
600
+ description: "Create an active pinote task. Start text with a short task title; put details after a blank line. Optional tag is registered if new. Set select true only after the user agrees to make it this session's active task; that starts it and remembers it for this session only. Omitting select leaves the current selection unchanged. Does not refresh the desktop notification or bind the project directory.",
520
601
  parameters: Type.Object({
521
- text: Type.String({ minLength: 1 }),
602
+ text: Type.String({ minLength: 1, description: "Short action-oriented title (aim for at most 60 characters), then optional blank line and details." }),
522
603
  tag: Type.Optional(Type.String({ minLength: 1 })),
523
604
  select: Type.Optional(Type.Boolean()),
524
605
  }),
@@ -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.1",
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 };