pi-note 0.24.0 → 0.25.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 +3 -0
- package/dist-mcp/mcp/server.js +4 -4
- package/exit-confirm.ts +127 -0
- package/mcp/README.md +34 -0
- package/mcp/server.ts +4 -4
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -10,6 +10,9 @@ The extension and Python app share a repository but install separately.
|
|
|
10
10
|
For server-provided UI instead of this Pinote-specific extension, see the opt-in
|
|
11
11
|
[native MCP UI alternative](mcp/README.md). It uses a generic gateway host and has
|
|
12
12
|
explicit initial-version limits; it is not activated by installing this extension.
|
|
13
|
+
MCP users can separately enable the [optional Ctrl+D exit companion](mcp/README.md#optional-ctrld-exit-confirmation).
|
|
14
|
+
It ships as `exit-confirm.ts` but is not loaded by default; do not load `index.ts`
|
|
15
|
+
alongside MCP.
|
|
13
16
|
|
|
14
17
|
## Install
|
|
15
18
|
|
package/dist-mcp/mcp/server.js
CHANGED
|
@@ -18,6 +18,8 @@ const MIME = 'application/vnd.mcp-native-ui+json';
|
|
|
18
18
|
const CONTEXT = 'native-ui/context';
|
|
19
19
|
const exec = promisify(execFile);
|
|
20
20
|
const noteCommand = process.env.PINOTE_NOTE_COMMAND || 'note';
|
|
21
|
+
const dataHome = process.env.XDG_DATA_HOME;
|
|
22
|
+
const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
|
|
21
23
|
const icons = Object.fromEntries(['add', 'check', 'cross', 'note', 'open-app', 'external-link'].map((name) => [name, readFileSync(new URL(`../icons/${name}.txt`, import.meta.url), 'utf8').trim()]));
|
|
22
24
|
const Proposal = z.object({ id: z.string().uuid(), text: z.string().min(1).max(4000), tag: z.string().trim().min(1).max(64).optional() }).strict();
|
|
23
25
|
const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), state: z.object({
|
|
@@ -29,7 +31,7 @@ const Action = z.object({ method: z.literal('native-ui/action'), params: z.objec
|
|
|
29
31
|
uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
|
|
30
32
|
value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
|
|
31
33
|
}) });
|
|
32
|
-
const server = new Server({ name: 'pinote', version: '0.
|
|
34
|
+
const server = new Server({ name: 'pinote', version: '0.25.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
|
|
33
35
|
// Serialize complete read/check/write cycles even when a host sends parallel calls.
|
|
34
36
|
let queue = Promise.resolve();
|
|
35
37
|
function serial(run) {
|
|
@@ -431,7 +433,7 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
|
|
|
431
433
|
}
|
|
432
434
|
else
|
|
433
435
|
throw new Error('Unknown tool');
|
|
434
|
-
return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(outgoing ? { _meta: { 'native-ui/state': outgoing } } : {}) };
|
|
436
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(outgoing ? { _meta: { 'native-ui/state': outgoing } } : request.params.name === 'get_current' ? { _meta: { 'pinote/database': database } } : {}) };
|
|
435
437
|
}
|
|
436
438
|
catch (error) {
|
|
437
439
|
return { isError: true, content: [{ type: 'text', text: String(error) }] };
|
|
@@ -441,8 +443,6 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
|
|
|
441
443
|
// sessions. Stat polling survives missing files and atomic config replacement.
|
|
442
444
|
server.oninitialized = () => {
|
|
443
445
|
server.oninitialized = undefined;
|
|
444
|
-
const dataHome = process.env.XDG_DATA_HOME;
|
|
445
|
-
const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
|
|
446
446
|
const paths = [database, `${database}-wal`, join(getAgentDir(), 'pi-note.json'), receiptDir];
|
|
447
447
|
let timer;
|
|
448
448
|
let closed = false;
|
package/exit-confirm.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { CustomEditor, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { isAbsolute, join } from "node:path";
|
|
6
|
+
import { stripVTControlCharacters } from "node:util";
|
|
7
|
+
|
|
8
|
+
type Task = { id: number; text: string; updated_at: string; state: string };
|
|
9
|
+
type Current = { task: Task | null; database?: string };
|
|
10
|
+
const complete = "Complete task and exit";
|
|
11
|
+
const leave = "Exit without completing";
|
|
12
|
+
const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/gu, " ");
|
|
13
|
+
function task(value: unknown): Task | null {
|
|
14
|
+
if (value === null) return null;
|
|
15
|
+
const item = value as Task | undefined;
|
|
16
|
+
if (!item || !Number.isSafeInteger(item.id) || item.id < 1 || typeof item.text !== "string" ||
|
|
17
|
+
typeof item.updated_at !== "string" || !item.updated_at ||
|
|
18
|
+
!["active", "in_progress", "done"].includes(item.state)) throw new Error("Invalid Pinote task response");
|
|
19
|
+
return item;
|
|
20
|
+
}
|
|
21
|
+
function same(left: Task | null, right: Task): boolean {
|
|
22
|
+
return left?.id === right.id && left.updated_at === right.updated_at && left.text === right.text && left.state === right.state;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Optional MCP companion: deliberately absent from package.json's default extensions.
|
|
26
|
+
export default function (pi: ExtensionAPI) {
|
|
27
|
+
let invalidate: (() => void) | undefined;
|
|
28
|
+
let stop: (() => void) | undefined;
|
|
29
|
+
const readCurrent = async (): Promise<Current> => {
|
|
30
|
+
const replies: { handled?: boolean; error?: string; result?: Promise<unknown> }[] = [];
|
|
31
|
+
pi.events.emit("gateway:query-service-tool", {
|
|
32
|
+
service: "pinote", tool: "get_current",
|
|
33
|
+
respond: (reply: typeof replies[number]) => {
|
|
34
|
+
if (reply.handled) { replies.push(reply); void reply.result?.catch(() => {}); }
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
if (replies.length !== 1 || replies[0].error || !replies[0].result) {
|
|
38
|
+
throw new Error(replies[0]?.error || "Pinote gateway service unavailable; use /quit to exit without completing");
|
|
39
|
+
}
|
|
40
|
+
const result = await replies[0].result as {
|
|
41
|
+
isError?: boolean; content?: { type: string; text?: string }[]; _meta?: Record<string, unknown>;
|
|
42
|
+
};
|
|
43
|
+
if (result.isError) throw new Error(result.content?.find((item) => item.type === "text")?.text || "Pinote query failed");
|
|
44
|
+
const raw = result.content?.find((item) => item.type === "text")?.text;
|
|
45
|
+
if (raw === undefined) throw new Error("Missing Pinote task response");
|
|
46
|
+
const value = task(JSON.parse(raw));
|
|
47
|
+
if (value && !["active", "in_progress"].includes(value.state)) throw new Error("Pinote task is no longer active");
|
|
48
|
+
const database = result._meta?.["pinote/database"];
|
|
49
|
+
return { task: value, database: typeof database === "string" ? database : undefined };
|
|
50
|
+
};
|
|
51
|
+
pi.on("session_start", (_event, ctx) => {
|
|
52
|
+
if (ctx.mode !== "tui" || !ctx.hasUI) return;
|
|
53
|
+
const previous = ctx.ui.getEditorComponent();
|
|
54
|
+
let alive = true, pending = false, epoch = 0;
|
|
55
|
+
let controller: AbortController | undefined;
|
|
56
|
+
invalidate = () => { epoch++; controller?.abort(); };
|
|
57
|
+
const exit = async () => {
|
|
58
|
+
if (pending || !alive) return;
|
|
59
|
+
pending = true;
|
|
60
|
+
const generation = epoch;
|
|
61
|
+
const operation = controller = new AbortController();
|
|
62
|
+
const current = () => alive && generation === epoch && !operation.signal.aborted &&
|
|
63
|
+
ctx.ui.getEditorText().length === 0;
|
|
64
|
+
const shutdown = () => { if (!ctx.isIdle()) ctx.abort(); ctx.shutdown(); };
|
|
65
|
+
const run = async (args: string[]) => {
|
|
66
|
+
if (!current() || !ctx.isIdle()) throw new Error("Pinote exit cancelled: session changed or became busy");
|
|
67
|
+
const result = await pi.exec(process.env.PINOTE_NOTE_COMMAND || "note", args, { timeout: 10_000, signal: operation.signal });
|
|
68
|
+
if (result.killed || result.code !== 0) throw new Error(clean(result.stderr || result.stdout || "Pinote CLI failed or timed out"));
|
|
69
|
+
return result.stdout;
|
|
70
|
+
};
|
|
71
|
+
try {
|
|
72
|
+
const selected = await readCurrent();
|
|
73
|
+
if (!current()) return;
|
|
74
|
+
if (!selected.task) { shutdown(); return; }
|
|
75
|
+
const approved = selected.task;
|
|
76
|
+
const title = truncateToWidth(clean(approved.text.split("\n", 1)[0]), 70);
|
|
77
|
+
const choice = await ctx.ui.select(`Pinote · ${title}`, [complete, leave, "Cancel"], { signal: operation.signal });
|
|
78
|
+
if (!current()) return;
|
|
79
|
+
if (choice === leave) { shutdown(); return; }
|
|
80
|
+
if (choice !== complete) return;
|
|
81
|
+
if (!ctx.isIdle()) throw new Error("Wait for Pi to become idle before completing a task");
|
|
82
|
+
const dataHome = process.env.XDG_DATA_HOME;
|
|
83
|
+
const localDatabase = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), ".local/share"), "pinote/notes.db");
|
|
84
|
+
if (!selected.database || !isAbsolute(selected.database) ||
|
|
85
|
+
await realpath(selected.database) !== await realpath(localDatabase)) {
|
|
86
|
+
throw new Error("Pinote database mismatch: update the MCP server and use the same XDG_DATA_HOME in Pi and the service");
|
|
87
|
+
}
|
|
88
|
+
const version = (await run(["--version"])).trim();
|
|
89
|
+
const match = /^pinote (\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
90
|
+
if (!match || Number(match[1]) === 0 && Number(match[2]) < 12) throw new Error("Pinote exit confirmation requires note 0.12.0+");
|
|
91
|
+
const local = task(JSON.parse(await run(["--no-notify", "agent", "get", String(approved.id)])));
|
|
92
|
+
const fresh = await readCurrent();
|
|
93
|
+
if (!same(local, approved) || !same(fresh.task, approved) || fresh.database !== selected.database) {
|
|
94
|
+
throw new Error("Pinote task changed; inspect it before retrying");
|
|
95
|
+
}
|
|
96
|
+
const done = task(JSON.parse(await run(["--no-notify", "agent", "done", String(approved.id), "--expected-updated-at", approved.updated_at])));
|
|
97
|
+
if (done?.id !== approved.id || done.state !== "done") throw new Error("Unexpected completion response; inspect the task before retrying");
|
|
98
|
+
if (current() && !(await readCurrent()).task && current()) shutdown();
|
|
99
|
+
else if (current()) ctx.ui.notify("Task completed; Pi stayed open because the selection changed", "info");
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (alive && generation === epoch) ctx.ui.notify(`Pinote: ${clean(String(error))}. Pi stayed open; inspect the task before retrying. /quit exits without completing.`, "error");
|
|
102
|
+
} finally { pending = false; }
|
|
103
|
+
};
|
|
104
|
+
const factory: NonNullable<ReturnType<typeof ctx.ui.getEditorComponent>> = (tui, theme, keybindings) => {
|
|
105
|
+
const editor = previous ? previous(tui, theme, keybindings) : new CustomEditor(tui, theme, keybindings);
|
|
106
|
+
const handleInput = editor.handleInput.bind(editor);
|
|
107
|
+
editor.handleInput = (data) => {
|
|
108
|
+
if (keybindings.matches(data, "app.exit") && editor.getText().length === 0) { void exit(); return; }
|
|
109
|
+
handleInput(data);
|
|
110
|
+
};
|
|
111
|
+
return editor;
|
|
112
|
+
};
|
|
113
|
+
ctx.ui.setEditorComponent(factory);
|
|
114
|
+
stop = () => {
|
|
115
|
+
alive = false;
|
|
116
|
+
invalidate?.();
|
|
117
|
+
if (ctx.ui.getEditorComponent() === factory) ctx.ui.setEditorComponent(previous);
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
pi.on("session_shutdown", () => { stop?.(); stop = invalidate = undefined; });
|
|
121
|
+
// Retire in-flight dialogs/reads before navigation or a new agent run can act.
|
|
122
|
+
const interrupted = () => { invalidate?.(); };
|
|
123
|
+
pi.on("session_before_switch", interrupted);
|
|
124
|
+
pi.on("session_before_fork", interrupted);
|
|
125
|
+
pi.on("session_before_tree", interrupted);
|
|
126
|
+
pi.on("agent_start", interrupted);
|
|
127
|
+
}
|
package/mcp/README.md
CHANGED
|
@@ -103,6 +103,40 @@ have saved the session for resume persistence. Existing extension selections are
|
|
|
103
103
|
not imported. Changing the gateway config path or service name starts a separate
|
|
104
104
|
selection namespace.
|
|
105
105
|
|
|
106
|
+
## Optional Ctrl+D exit confirmation
|
|
107
|
+
|
|
108
|
+
`pi-note` 0.25.0 ships `pi/exit-confirm.ts`, a disabled-by-default **companion** to this
|
|
109
|
+
MCP setup, not the old `pi/index.ts` extension. It requires Pi 0.99.1+, a gateway
|
|
110
|
+
supporting `gateway:query-service-tool`, and this version of the Pinote MCP server
|
|
111
|
+
registered as `pinote`. Enable it for one launch:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
PINOTE_NOTE_COMMAND=/absolute/path/to/pinote/.venv/bin/note \
|
|
115
|
+
pi -e /absolute/path/to/pinote/pi/exit-confirm.ts
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
For persistent use, add that absolute extension path to `extensions` in Pi's
|
|
119
|
+
user `settings.json`, preserving other entries, then `/reload`. Remove the path
|
|
120
|
+
and `/reload` to disable it. Installing `pi-note` does not enable the companion.
|
|
121
|
+
Load it after other editor extensions so it can wrap their editor without replacing
|
|
122
|
+
its normal input behavior.
|
|
123
|
+
|
|
124
|
+
With an empty editor, Ctrl+D (or your configured `app.exit` key) offers **Complete
|
|
125
|
+
task and exit**, **Exit without completing**, or **Cancel**. Escape cancels. With
|
|
126
|
+
no selected task Pi exits immediately; nonempty text keeps normal editing behavior.
|
|
127
|
+
Completion requires idle Pi, rechecks the selection and displayed revision, and
|
|
128
|
+
uses the local CLI without notifications. Query/completion failures leave Pi open;
|
|
129
|
+
inspect the task before retrying because a failed write may have committed. `/quit`
|
|
130
|
+
and other exit paths bypass the companion and never complete a task.
|
|
131
|
+
|
|
132
|
+
Pi and the MCP service must use the **same local database**: match `XDG_DATA_HOME`
|
|
133
|
+
(and HOME when using defaults). Set `PINOTE_NOTE_COMMAND` in Pi's environment if
|
|
134
|
+
`note` is not on PATH; its executable must use that same database. The companion
|
|
135
|
+
compares the server's database path and task revision before writing; it does not
|
|
136
|
+
fall back to a folder selection or create another selection store. Remote Pinote
|
|
137
|
+
services are unsupported. RPC/print mode is unaffected; no Pi/gateway patch or
|
|
138
|
+
personal configuration change is made by installation.
|
|
139
|
+
|
|
106
140
|
## Task-offer guidance
|
|
107
141
|
|
|
108
142
|
Before multi-step implementation or configuration migrations, agents should call
|
package/mcp/server.ts
CHANGED
|
@@ -19,6 +19,8 @@ const MIME = 'application/vnd.mcp-native-ui+json';
|
|
|
19
19
|
const CONTEXT = 'native-ui/context';
|
|
20
20
|
const exec = promisify(execFile);
|
|
21
21
|
const noteCommand = process.env.PINOTE_NOTE_COMMAND || 'note';
|
|
22
|
+
const dataHome = process.env.XDG_DATA_HOME;
|
|
23
|
+
const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
|
|
22
24
|
const icons = Object.fromEntries(['add', 'check', 'cross', 'note', 'open-app', 'external-link'].map((name) => [name, readFileSync(new URL(`../icons/${name}.txt`, import.meta.url), 'utf8').trim()]));
|
|
23
25
|
const Proposal = z.object({ id: z.string().uuid(), text: z.string().min(1).max(4000), tag: z.string().trim().min(1).max(64).optional() }).strict();
|
|
24
26
|
const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), state: z.object({
|
|
@@ -34,7 +36,7 @@ const Action = z.object({ method: z.literal('native-ui/action'), params: z.objec
|
|
|
34
36
|
uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
|
|
35
37
|
value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
|
|
36
38
|
}) });
|
|
37
|
-
const server = new Server({ name: 'pinote', version: '0.
|
|
39
|
+
const server = new Server({ name: 'pinote', version: '0.25.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
|
|
38
40
|
// Serialize complete read/check/write cycles even when a host sends parallel calls.
|
|
39
41
|
let queue = Promise.resolve();
|
|
40
42
|
function serial<T>(run: () => Promise<T>): Promise<T> {
|
|
@@ -334,15 +336,13 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
|
|
|
334
336
|
result = JSON.parse(await note(['agent', 'update', String(task.id), '--expected-updated-at', args.expected_updated_at, '--set-json', JSON.stringify(args.set), ...args.remove.map((key) => `--remove=${key}`)], extra.signal));
|
|
335
337
|
await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
|
|
336
338
|
} else throw new Error('Unknown tool');
|
|
337
|
-
return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(outgoing ? { _meta: { 'native-ui/state': outgoing } } : {}) };
|
|
339
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(outgoing ? { _meta: { 'native-ui/state': outgoing } } : request.params.name === 'get_current' ? { _meta: { 'pinote/database': database } } : {}) };
|
|
338
340
|
} catch (error) { return { isError: true, content: [{ type: 'text', text: String(error) }] }; }
|
|
339
341
|
}));
|
|
340
342
|
// Every MCP process observes shared storage, including writes by CLI/GTK/other
|
|
341
343
|
// sessions. Stat polling survives missing files and atomic config replacement.
|
|
342
344
|
server.oninitialized = () => {
|
|
343
345
|
server.oninitialized = undefined;
|
|
344
|
-
const dataHome = process.env.XDG_DATA_HOME;
|
|
345
|
-
const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
|
|
346
346
|
const paths = [database, `${database}-wal`, join(getAgentDir(), 'pi-note.json'), receiptDir];
|
|
347
347
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
348
348
|
let closed = false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-note",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"index.ts",
|
|
30
|
+
"exit-confirm.ts",
|
|
30
31
|
"footer-status.ts",
|
|
31
32
|
"footer-links.ts",
|
|
32
33
|
"footer-config.ts",
|