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 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
 
@@ -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.24.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
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;
@@ -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.24.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
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.24.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",