pi-note 0.11.0 → 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 +49 -13
- package/footer-config.ts +5 -3
- package/footer-settings.ts +37 -24
- package/icons/eye.txt +1 -0
- package/index.ts +98 -13
- 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
|
|
@@ -63,14 +63,46 @@ they are not copied into the draft. The default prompt asks for a summary, not i
|
|
|
63
63
|
Existing drafts are preserved; nothing is submitted automatically.
|
|
64
64
|
With a selected task it offers **Continue**, **Done**, **Switch task**, and **Settings**.
|
|
65
65
|
Press **Tab** from the task menu or picker to open global settings; Tab
|
|
66
|
-
returns to tasks
|
|
66
|
+
returns to tasks, keeping saved changes. Settings also lists fields found on the current
|
|
67
67
|
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
|
|
@@ -110,10 +142,11 @@ values or unreadable/malformed configuration suppress offers; interactive startu
|
|
|
110
142
|
warns until you fix the file and reload. No project-local configuration is read.
|
|
111
143
|
|
|
112
144
|
In `/pi-note → Settings`, select **Task prompt** to edit the text inserted after
|
|
113
|
-
selection or **Continue**. **Shift+Enter** or **Ctrl+J** adds a newline; **Enter**
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
145
|
+
selection or **Continue**. **Shift+Enter** or **Ctrl+J** adds a newline; **Enter** saves it
|
|
146
|
+
globally, **Ctrl+C** clears the edit, and **Esc** cancels that unfinished edit.
|
|
147
|
+
Settings and fields save automatically after each confirmed change; no separate
|
|
148
|
+
Save action is needed. Leaving Settings with **Esc** or **Tab** keeps saved changes
|
|
149
|
+
and discards only unfinished input. Saving never changes existing editor input or submits anything.
|
|
117
150
|
|
|
118
151
|
Task selection and **Continue** reread `handoffPrompt` each time; changing that
|
|
119
152
|
field needs no `/reload`. Its value is literal text, not a prompt template; use
|
|
@@ -197,7 +230,7 @@ Add `footer` to `~/.pi/agent/pi-note.json` (or `pi-note.json` under
|
|
|
197
230
|
}
|
|
198
231
|
```
|
|
199
232
|
|
|
200
|
-
Widths are terminal columns, including
|
|
233
|
+
Widths are terminal columns, including the eye, pin, tags or field labels, and must be
|
|
201
234
|
integers from 3 to 1000. Defaults: `titleWidth: 60`, `fieldWidth: 60`,
|
|
202
235
|
`maxFields: 4` (allowed 0–64). Each field can override `fieldWidth` with `width`.
|
|
203
236
|
`fields` selects task field names in order, regardless of a task's `Bar`.
|
|
@@ -209,9 +242,12 @@ accepted. Missing/blank values do not display and do not consume the field limit
|
|
|
209
242
|
In `/pi-note → Settings`, **Add field** adds a global display definition, not a
|
|
210
243
|
task value. Select any field to edit **Link**, **Label**, **Format**, and **Width**.
|
|
211
244
|
Label starts as the field name; clear it for no prefix. Link off displays plain
|
|
212
|
-
text without a clickable link.
|
|
213
|
-
|
|
214
|
-
|
|
245
|
+
text without a clickable link. Toggles, field additions/removals, and edits confirmed
|
|
246
|
+
with **Enter** save immediately. **Esc** returns from a field to settings without
|
|
247
|
+
undoing saved changes. Invalid edits and failed saves stay open with an error;
|
|
248
|
+
failed saves leave saved values unchanged and can be retried. Saves include the task
|
|
249
|
+
prompt, preserve unrelated settings (such as `taskOfferPolicy`), and reject external
|
|
250
|
+
configuration changes made since opening settings or the last successful save.
|
|
215
251
|
Concurrent saves use `pi-note.json.lock`; remove stale locks only when no save is
|
|
216
252
|
running. Symlinked configuration must be edited manually; saves never replace the link.
|
|
217
253
|
|
package/footer-config.ts
CHANGED
|
@@ -90,7 +90,7 @@ export function loadFooterConfig(warn: (message: string) => void): FooterConfig
|
|
|
90
90
|
|
|
91
91
|
// Synchronous compare-and-replace: reject edits made while the settings dialog was open,
|
|
92
92
|
// preserve unrelated keys, and never leave partial JSON behind.
|
|
93
|
-
export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string)
|
|
93
|
+
export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string) {
|
|
94
94
|
const checked = parseFooterConfig({ footer: config });
|
|
95
95
|
const prompt = handoffPrompt === undefined ? {} : { handoffPrompt: parseHandoffPrompt(handoffPrompt) };
|
|
96
96
|
const directory = getAgentDir();
|
|
@@ -101,15 +101,17 @@ export function saveFooterConfig(config: FooterConfig, expectedRaw: string | nul
|
|
|
101
101
|
try { descriptor = openSync(lock, "wx", 0o600); }
|
|
102
102
|
catch { throw new Error("Pinote settings are locked. Retry after another save finishes; remove a stale pi-note.json.lock only when no save is running."); }
|
|
103
103
|
const temp = join(directory, `.pi-note-${randomUUID()}.tmp`);
|
|
104
|
+
let raw: string;
|
|
104
105
|
try {
|
|
105
106
|
if (readRaw() !== expectedRaw) throw new Error("Pi-note configuration changed while settings were open. Reopen settings before saving.");
|
|
106
107
|
if (lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error("pi-note.json is a symlink. Edit its target manually rather than replacing the link.");
|
|
107
108
|
const root = expectedRaw === null ? {} : JSON.parse(expectedRaw);
|
|
108
109
|
if (!object(root)) throw new Error("Invalid pi-note configuration; expected an object.");
|
|
109
|
-
|
|
110
|
+
raw = `${JSON.stringify({ ...root, ...prompt, footer: checked }, null, 2)}\n`;
|
|
111
|
+
writeFileSync(temp, raw, { flag: "wx", mode: 0o600 });
|
|
110
112
|
renameSync(temp, path);
|
|
111
113
|
} finally { rmSync(temp, { force: true }); closeSync(descriptor); rmSync(lock, { force: true }); }
|
|
112
|
-
return checked;
|
|
114
|
+
return { config: checked, raw };
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
export function effectiveFooterFields(config: FooterConfig, notes: Record<string, string> = {}): FooterField[] {
|
package/footer-settings.ts
CHANGED
|
@@ -44,7 +44,8 @@ export class FooterSettings {
|
|
|
44
44
|
private knownFields: string[];
|
|
45
45
|
private theme: Theme;
|
|
46
46
|
private matches: Matches;
|
|
47
|
-
private done: (result:
|
|
47
|
+
private done: (result: "tasks" | undefined) => void;
|
|
48
|
+
private save: (settings: PinoteSettings) => void;
|
|
48
49
|
private requestRender: () => void;
|
|
49
50
|
private index = 0;
|
|
50
51
|
private field?: FooterField;
|
|
@@ -53,10 +54,12 @@ export class FooterSettings {
|
|
|
53
54
|
private hasFocus = false;
|
|
54
55
|
|
|
55
56
|
constructor(config: PinoteSettings, knownFields: string[], theme: Theme, matches: Matches,
|
|
56
|
-
done: (result:
|
|
57
|
+
done: (result: "tasks" | undefined) => void, requestRender: () => void, createEditor: () => Editor,
|
|
58
|
+
save: (settings: PinoteSettings) => void) {
|
|
57
59
|
this.draft = structuredClone(config.footer);
|
|
58
60
|
this.prompt = config.handoffPrompt;
|
|
59
61
|
this.createEditor = createEditor;
|
|
62
|
+
this.save = save;
|
|
60
63
|
this.draft.fields ??= [];
|
|
61
64
|
this.knownFields = [...new Set(knownFields)].filter((name) => name !== "Bar");
|
|
62
65
|
this.theme = theme; this.matches = matches; this.done = done; this.requestRender = requestRender;
|
|
@@ -76,6 +79,20 @@ export class FooterSettings {
|
|
|
76
79
|
private validate(field: FooterField): FooterField {
|
|
77
80
|
return parseFooterConfig({ footer: { fields: [field] } }).fields![0];
|
|
78
81
|
}
|
|
82
|
+
private persist(footer = this.draft, handoffPrompt = this.prompt) {
|
|
83
|
+
const settings = { footer: parseFooterConfig({ footer }), handoffPrompt: parseHandoffPrompt(handoffPrompt) };
|
|
84
|
+
this.save(structuredClone(settings));
|
|
85
|
+
this.draft = settings.footer;
|
|
86
|
+
this.prompt = settings.handoffPrompt;
|
|
87
|
+
}
|
|
88
|
+
private persistField(field: FooterField) {
|
|
89
|
+
const candidate = this.validate(field);
|
|
90
|
+
const fields = this.draft.fields!.filter((entry) => entry.name !== candidate.name);
|
|
91
|
+
const index = this.draft.fields!.findIndex((entry) => entry.name === candidate.name);
|
|
92
|
+
fields.splice(index < 0 ? fields.length : index, 0, candidate);
|
|
93
|
+
this.persist({ ...this.draft, fields });
|
|
94
|
+
this.field = candidate;
|
|
95
|
+
}
|
|
79
96
|
private edit(name: string) {
|
|
80
97
|
this.field = structuredClone(this.draft.fields!.find((field) => field.name === name) ?? defaultFooterField(name));
|
|
81
98
|
this.index = 0; this.error = "";
|
|
@@ -86,7 +103,7 @@ export class FooterSettings {
|
|
|
86
103
|
action: () => this.ask(key, String(this.draft[key]), (value) => {
|
|
87
104
|
const candidate = { ...this.draft, [key]: Number(value) };
|
|
88
105
|
if (!value.trim()) throw new Error("Enter a number.");
|
|
89
|
-
this.
|
|
106
|
+
this.persist(candidate);
|
|
90
107
|
}),
|
|
91
108
|
}));
|
|
92
109
|
rows.push({ label: `Task prompt: ${display(this.prompt)}`, action: () => {
|
|
@@ -101,40 +118,36 @@ export class FooterSettings {
|
|
|
101
118
|
}
|
|
102
119
|
rows.push({ label: "+ Add field", action: () => this.ask("Field name", "", (value) => {
|
|
103
120
|
const field = this.validate(defaultFooterField(value));
|
|
104
|
-
this.edit(field.name);
|
|
105
|
-
|
|
121
|
+
if (this.draft.fields!.some((entry) => entry.name === field.name)) this.edit(field.name);
|
|
122
|
+
else {
|
|
123
|
+
this.persistField(field);
|
|
124
|
+
this.index = 0;
|
|
125
|
+
}
|
|
126
|
+
}) },
|
|
106
127
|
{ label: "Return to tasks", action: () => this.done("tasks") });
|
|
107
128
|
return rows;
|
|
108
129
|
}
|
|
109
130
|
private fieldRows(): Row[] {
|
|
110
131
|
const field = this.field!;
|
|
111
132
|
const rows: Row[] = [
|
|
112
|
-
{ label: `Link: ${field.link ? "on" : "off (plain text)"}`, action: () => { field
|
|
133
|
+
{ label: `Link: ${field.link ? "on" : "off (plain text)"}`, action: () => this.persistField({ ...field, link: !field.link }) },
|
|
113
134
|
{ label: `Label: ${display(field.label) || "(none)"}`, action: () => this.ask("Label (empty = no prefix)", field.label, (value) => {
|
|
114
|
-
this.
|
|
135
|
+
this.persistField({ ...field, label: value });
|
|
115
136
|
}) },
|
|
116
137
|
{ label: `Format: ${display(field.format) || "(empty)"}`, action: () => this.ask("Format: <value>, <url>, <number>", field.format, (value) => {
|
|
117
|
-
this.
|
|
138
|
+
this.persistField({ ...field, format: value });
|
|
118
139
|
}) },
|
|
119
140
|
{ label: `Width: ${field.width ?? `default (${this.draft.fieldWidth})`}`, action: () => this.ask("Width (empty = default)", field.width === undefined ? "" : String(field.width), (value) => {
|
|
120
141
|
const candidate = { ...field };
|
|
121
142
|
if (value.trim()) candidate.width = Number(value); else delete candidate.width;
|
|
122
|
-
this.
|
|
143
|
+
this.persistField(candidate);
|
|
123
144
|
}) },
|
|
124
|
-
{ label: "Save field", action: () => {
|
|
125
|
-
const candidate = this.validate(field);
|
|
126
|
-
const fields = this.draft.fields!.filter((entry) => entry.name !== candidate.name);
|
|
127
|
-
const index = this.draft.fields!.findIndex((entry) => entry.name === candidate.name);
|
|
128
|
-
fields.splice(index < 0 ? fields.length : index, 0, candidate);
|
|
129
|
-
this.draft = parseFooterConfig({ footer: { ...this.draft, fields } });
|
|
130
|
-
this.field = undefined; this.index = 0;
|
|
131
|
-
} },
|
|
132
145
|
];
|
|
133
146
|
if (this.draft.fields!.some((entry) => entry.name === field.name)) rows.push({ label: "Remove field", action: () => {
|
|
134
|
-
this.draft
|
|
147
|
+
this.persist({ ...this.draft, fields: this.draft.fields!.filter((entry) => entry.name !== field.name) });
|
|
135
148
|
this.field = undefined; this.index = 0;
|
|
136
149
|
} });
|
|
137
|
-
rows.push({ label: "
|
|
150
|
+
rows.push({ label: "Back to settings", action: () => { this.field = undefined; this.index = 0; } });
|
|
138
151
|
return rows;
|
|
139
152
|
}
|
|
140
153
|
handleInput(data: string) {
|
|
@@ -146,7 +159,7 @@ export class FooterSettings {
|
|
|
146
159
|
else if (this.matches(data, "tui.select.cancel")) this.editor = undefined;
|
|
147
160
|
else if (this.matches(data, "tui.input.newLine")) this.editor.handleInput(data);
|
|
148
161
|
else if (this.matches(data, "tui.select.confirm")) {
|
|
149
|
-
this.
|
|
162
|
+
this.persist(this.draft, this.editor.getExpandedText());
|
|
150
163
|
this.editor = undefined;
|
|
151
164
|
} else this.editor.handleInput(data);
|
|
152
165
|
} else if (this.input) {
|
|
@@ -172,10 +185,10 @@ export class FooterSettings {
|
|
|
172
185
|
const lines = [this.theme.fg("accent", "Pinote — Global Settings"), this.theme.fg("dim", "Tasks (Tab) · Settings · Fields")];
|
|
173
186
|
if (this.editor) {
|
|
174
187
|
lines.push(this.theme.fg("accent", "Task prompt"), ...this.editor.render(width),
|
|
175
|
-
this.theme.fg("dim", "Enter
|
|
188
|
+
this.theme.fg("dim", "Enter save · Shift+Enter/Ctrl+J newline · Ctrl+C clear · Esc cancel edit"));
|
|
176
189
|
} else if (this.input) {
|
|
177
190
|
lines.push(this.theme.fg("accent", this.input.heading), ...this.input.widget.render(width),
|
|
178
|
-
this.theme.fg("dim", "Enter
|
|
191
|
+
this.theme.fg("dim", "Enter save · Esc cancel edit"));
|
|
179
192
|
} else {
|
|
180
193
|
if (this.field) lines.push(this.theme.fg("accent", `Field: ${display(this.field.name)}`));
|
|
181
194
|
const rows = this.field ? this.fieldRows() : this.rootRows();
|
|
@@ -184,10 +197,10 @@ export class FooterSettings {
|
|
|
184
197
|
lines.push(...rows.slice(start, start + 8).map((row, offset) => offset + start === this.index
|
|
185
198
|
? this.theme.fg("accent", `> ${row.label}`) : ` ${row.label}`));
|
|
186
199
|
if (rows.length > 8) lines.push(this.theme.fg("dim", `${this.index + 1}/${rows.length}`));
|
|
187
|
-
lines.push(this.theme.fg("dim", "↑↓ navigate · Enter edit · Esc
|
|
200
|
+
lines.push(this.theme.fg("dim", "↑↓ navigate · Enter edit · Esc back"));
|
|
188
201
|
}
|
|
189
202
|
if (this.error) lines.push(this.theme.fg("error", this.error));
|
|
190
|
-
lines.push(this.theme.fg("dim", "
|
|
203
|
+
lines.push(this.theme.fg("dim", "Changes save automatically · Tab: tasks"));
|
|
191
204
|
return lines.map((line) => truncateToWidth(line, width));
|
|
192
205
|
}
|
|
193
206
|
invalidate() { this.input?.widget.invalidate(); this.editor?.invalidate(); }
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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, {
|
|
@@ -380,7 +460,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
380
460
|
footer: { ...document.config, fields: effectiveFooterFields(document.config, current?.agent_notes) },
|
|
381
461
|
handoffPrompt: "handoffPrompt" in root ? parseHandoffPrompt(root.handoffPrompt) : defaultHandoffPrompt,
|
|
382
462
|
};
|
|
383
|
-
|
|
463
|
+
let expectedRaw = document.raw;
|
|
464
|
+
const edited = await ctx.ui.custom<"tasks" | undefined>((tui, theme, kb, done) =>
|
|
384
465
|
new FooterSettings(config, Object.keys(current?.agent_notes ?? {}).filter((name) => name !== "Bar"),
|
|
385
466
|
theme, (data, action) => kb.matches(data, action as Keybinding), done, () => tui.requestRender(),
|
|
386
467
|
() => new Editor(tui, {
|
|
@@ -388,12 +469,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
388
469
|
selectList: { selectedPrefix: (line) => theme.fg("accent", line), selectedText: (line) => theme.fg("accent", line),
|
|
389
470
|
description: (line) => theme.fg("muted", line), scrollInfo: (line) => theme.fg("dim", line),
|
|
390
471
|
noMatch: (line) => theme.fg("warning", line) },
|
|
391
|
-
}))
|
|
472
|
+
}), (updated) => {
|
|
473
|
+
if (!canAct()) throw new Error("This session changed. Reopen settings before editing.");
|
|
474
|
+
const saved = saveFooterConfig(updated.footer, expectedRaw, updated.handoffPrompt);
|
|
475
|
+
expectedRaw = saved.raw;
|
|
476
|
+
footerConfig = saved.config;
|
|
477
|
+
void refresh(ctx);
|
|
478
|
+
}));
|
|
392
479
|
if (!canAct() || edited === undefined) return;
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
ctx.ui.notify("Global Pinote settings saved.", "info");
|
|
396
|
-
return;
|
|
480
|
+
settings = false;
|
|
481
|
+
continue;
|
|
397
482
|
}
|
|
398
483
|
const action = current
|
|
399
484
|
? await ctx.ui.custom<string | undefined>((tui, theme, kb, done) =>
|
|
@@ -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.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 };
|