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 +40 -5
- package/icons/eye.txt +1 -0
- package/index.ts +91 -10
- package/kitty/open-actions.conf +5 -0
- package/package.json +2 -2
- package/preview-bridge.ts +113 -0
- package/preview-click.cjs +65 -0
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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.
|
|
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 };
|