pi-note 0.18.1 → 0.20.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
@@ -5,6 +5,10 @@ in Pi: selected-task footer status, `/pi-note`, and agent current-task read/upda
5
5
  add, and tag-listing tools.
6
6
  The extension and Python app share a repository but install separately.
7
7
 
8
+ For server-provided UI instead of this Pinote-specific extension, see the opt-in
9
+ [native MCP UI alternative](mcp/README.md). It uses a generic gateway host and has
10
+ explicit initial-version limits; it is not activated by installing this extension.
11
+
8
12
  ## Install
9
13
 
10
14
  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+**.
package/config-path.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ // Same personal configuration location in Pi and the standalone MCP process.
6
+ export function getAgentDir(): string {
7
+ let path = process.env.PI_CODING_AGENT_DIR;
8
+ if (!path) return join(homedir(), '.pi', 'agent');
9
+ if (path === '~') return homedir();
10
+ if (process.platform === 'win32' && !path.includes('\\')) {
11
+ const match = /^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i.exec(path);
12
+ if (match) path = `${match[1].toUpperCase()}:\\${(match[2] ?? '').replaceAll('/', '\\')}`;
13
+ }
14
+ if (path.startsWith('~/') || process.platform === 'win32' && path.startsWith('~\\')) return join(homedir(), path.slice(2));
15
+ return path.startsWith('file://') ? fileURLToPath(path) : path;
16
+ }
@@ -0,0 +1,19 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ // Same personal configuration location in Pi and the standalone MCP process.
5
+ export function getAgentDir() {
6
+ let path = process.env.PI_CODING_AGENT_DIR;
7
+ if (!path)
8
+ return join(homedir(), '.pi', 'agent');
9
+ if (path === '~')
10
+ return homedir();
11
+ if (process.platform === 'win32' && !path.includes('\\')) {
12
+ const match = /^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i.exec(path);
13
+ if (match)
14
+ path = `${match[1].toUpperCase()}:\\${(match[2] ?? '').replaceAll('/', '\\')}`;
15
+ }
16
+ if (path.startsWith('~/') || process.platform === 'win32' && path.startsWith('~\\'))
17
+ return join(homedir(), path.slice(2));
18
+ return path.startsWith('file://') ? fileURLToPath(path) : path;
19
+ }
@@ -0,0 +1,167 @@
1
+ import { getAgentDir } from "./config-path.js";
2
+ import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ export const defaultHandoffPrompt = "Read the current Pinote task. Summarize your understanding, but don’t start work yet.";
6
+ export const defaultNewSessionPrompt = defaultHandoffPrompt;
7
+ export function parseNewSessionPrompt(value) {
8
+ if (typeof value !== "string" || /[\x00-\x08\x0b-\x1f\x7f-\x9f]/u.test(value)) {
9
+ throw new Error("newSessionPrompt in pi-note.json must be a string without control characters (except tabs/newlines).");
10
+ }
11
+ return value;
12
+ }
13
+ export function parseHandoffPrompt(value) {
14
+ if (typeof value !== "string" || !value.trim() || /[\x00-\x08\x0b-\x1f\x7f-\x9f]/u.test(value)) {
15
+ throw new Error("handoffPrompt in pi-note.json must be a nonblank string without control characters (except tabs/newlines).");
16
+ }
17
+ return value;
18
+ }
19
+ export const defaultFooterConfig = { titleWidth: 60, fieldWidth: 60, maxFields: 4, fields: null };
20
+ export const defaultFooterField = (name) => ({
21
+ name, label: name, link: true, format: name === "PR" ? "#<number>" : "<value>",
22
+ });
23
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
24
+ const width = (value) => Number.isInteger(value) && Number(value) >= 3 && Number(value) <= 1000;
25
+ const safeText = (value) => typeof value === "string" && !/[\x00-\x1f\x7f-\x9f]/u.test(value);
26
+ export function parseFooterConfig(value) {
27
+ if (!object(value))
28
+ throw new Error("expected an object");
29
+ if (value.footer === undefined)
30
+ return { ...defaultFooterConfig };
31
+ if (!object(value.footer))
32
+ throw new Error("footer must be an object");
33
+ const footer = value.footer;
34
+ for (const key of ["titleWidth", "fieldWidth"]) {
35
+ if (footer[key] !== undefined && !width(footer[key]))
36
+ throw new Error(`${key} must be an integer from 3 to 1000`);
37
+ }
38
+ if (footer.maxFields !== undefined &&
39
+ (!Number.isInteger(footer.maxFields) || Number(footer.maxFields) < 0 || Number(footer.maxFields) > 64)) {
40
+ throw new Error("maxFields must be an integer from 0 to 64");
41
+ }
42
+ let fields = null;
43
+ if (footer.fields !== undefined && footer.fields !== null) {
44
+ if (!Array.isArray(footer.fields) || footer.fields.length > 64)
45
+ throw new Error("fields must be null or an array of at most 64 fields");
46
+ const seen = new Set();
47
+ fields = footer.fields.map((entry) => {
48
+ const field = typeof entry === "string" ? { name: entry } : entry;
49
+ if (!object(field))
50
+ throw new Error("each field needs a name");
51
+ // Accept the original {label: name, width} form as well as explicit names.
52
+ const source = field.name === undefined ? field.label : field.name;
53
+ if (!safeText(source))
54
+ throw new Error("invalid field name");
55
+ const name = source.normalize("NFC").trim();
56
+ if (!name || [...name].length > 64 || name === "Bar")
57
+ throw new Error("invalid field name");
58
+ if (seen.has(name))
59
+ throw new Error("duplicate field name");
60
+ seen.add(name);
61
+ const label = field.name === undefined ? name : field.label === undefined ? name : field.label;
62
+ const format = field.format === undefined ? defaultFooterField(name).format : field.format;
63
+ if (!safeText(label) || [...label].length > 64)
64
+ throw new Error("invalid display label");
65
+ if (!safeText(format) || format.length > 4096 || /<(?!value>|number>|url>)[^>]*>/u.test(format)) {
66
+ throw new Error("format supports only <value>, <number>, and <url>");
67
+ }
68
+ if (field.link !== undefined && typeof field.link !== "boolean")
69
+ throw new Error("link must be a boolean");
70
+ if (field.width !== undefined && !width(field.width))
71
+ throw new Error("field width must be an integer from 3 to 1000");
72
+ return { name, label, link: field.link ?? true, format,
73
+ ...(field.width === undefined ? {} : { width: field.width }) };
74
+ });
75
+ }
76
+ return {
77
+ titleWidth: footer.titleWidth ?? defaultFooterConfig.titleWidth,
78
+ fieldWidth: footer.fieldWidth ?? defaultFooterConfig.fieldWidth,
79
+ maxFields: footer.maxFields ?? defaultFooterConfig.maxFields, fields,
80
+ };
81
+ }
82
+ function readRaw() {
83
+ try {
84
+ return readFileSync(join(getAgentDir(), "pi-note.json"), "utf8");
85
+ }
86
+ catch (error) {
87
+ if (error.code === "ENOENT")
88
+ return null;
89
+ throw error;
90
+ }
91
+ }
92
+ export function readFooterDocument() {
93
+ const raw = readRaw();
94
+ let value = {};
95
+ if (raw !== null) {
96
+ try {
97
+ value = JSON.parse(raw);
98
+ }
99
+ catch {
100
+ throw new Error("Invalid JSON in pi-note.json.");
101
+ }
102
+ }
103
+ return { raw, config: parseFooterConfig(value) };
104
+ }
105
+ export function loadFooterConfig(warn) {
106
+ try {
107
+ return readFooterDocument().config;
108
+ }
109
+ catch {
110
+ warn("Cannot load pi-note.json; using default Pinote footer settings. Check the footer configuration in pi-note's README.");
111
+ return { ...defaultFooterConfig };
112
+ }
113
+ }
114
+ // Synchronous compare-and-replace: reject edits made while the settings dialog was open,
115
+ // preserve unrelated keys, and never leave partial JSON behind.
116
+ export function saveFooterConfig(config, expectedRaw, handoffPrompt, newSessionPrompt, taskOfferPolicy) {
117
+ if (taskOfferPolicy !== undefined && !["always", "github-remote", "never"].includes(taskOfferPolicy))
118
+ throw new Error("Invalid taskOfferPolicy");
119
+ const checked = parseFooterConfig({ footer: config });
120
+ const prompts = {
121
+ ...(handoffPrompt === undefined ? {} : { handoffPrompt: parseHandoffPrompt(handoffPrompt) }),
122
+ ...(newSessionPrompt === undefined ? {} : { newSessionPrompt: parseNewSessionPrompt(newSessionPrompt) }),
123
+ };
124
+ const directory = getAgentDir();
125
+ mkdirSync(directory, { recursive: true });
126
+ const path = join(directory, "pi-note.json");
127
+ const lock = join(directory, "pi-note.json.lock");
128
+ let descriptor;
129
+ try {
130
+ descriptor = openSync(lock, "wx", 0o600);
131
+ }
132
+ catch {
133
+ 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.");
134
+ }
135
+ const temp = join(directory, `.pi-note-${randomUUID()}.tmp`);
136
+ let raw;
137
+ try {
138
+ if (readRaw() !== expectedRaw)
139
+ throw new Error("Pi-note configuration changed while settings were open. Reopen settings before saving.");
140
+ if (lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink())
141
+ throw new Error("pi-note.json is a symlink. Edit its target manually rather than replacing the link.");
142
+ const root = expectedRaw === null ? {} : JSON.parse(expectedRaw);
143
+ if (!object(root))
144
+ throw new Error("Invalid pi-note configuration; expected an object.");
145
+ raw = `${JSON.stringify({ ...root, ...prompts, ...(taskOfferPolicy === undefined ? {} : { taskOfferPolicy }), footer: checked }, null, 2)}\n`;
146
+ writeFileSync(temp, raw, { flag: "wx", mode: 0o600 });
147
+ renameSync(temp, path);
148
+ }
149
+ finally {
150
+ rmSync(temp, { force: true });
151
+ closeSync(descriptor);
152
+ rmSync(lock, { force: true });
153
+ }
154
+ return { config: checked, raw };
155
+ }
156
+ export function effectiveFooterFields(config, notes = {}) {
157
+ if (config.fields !== null)
158
+ return config.fields;
159
+ const seen = new Set(["Bar"]);
160
+ return ["PR", ...(notes.Bar ?? "").split("\n")].flatMap((raw) => {
161
+ const name = raw.normalize("NFC").trim();
162
+ if (!name || seen.has(name))
163
+ return [];
164
+ seen.add(name);
165
+ return [defaultFooterField(name)];
166
+ });
167
+ }
@@ -0,0 +1 @@
1
+ ✚
@@ -0,0 +1 @@
1
+ ✔
@@ -0,0 +1 @@
1
+ ✖
@@ -0,0 +1 @@
1
+ 📌
@@ -0,0 +1,377 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import { readFileSync } from 'node:fs';
8
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
9
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
11
+ import { z } from 'zod';
12
+ import { defaultFooterField, defaultHandoffPrompt, defaultNewSessionPrompt, effectiveFooterFields, parseFooterConfig, parseHandoffPrompt, parseNewSessionPrompt, readFooterDocument, saveFooterConfig } from "../footer-config.js";
13
+ const URI = 'pinote://ui';
14
+ const MIME = 'application/vnd.mcp-native-ui+json';
15
+ const CONTEXT = 'native-ui/context';
16
+ const exec = promisify(execFile);
17
+ const noteCommand = process.env.PINOTE_NOTE_COMMAND || 'note';
18
+ const icons = Object.fromEntries(['add', 'check', 'cross', 'note'].map((name) => [name, readFileSync(new URL(`../icons/${name}.txt`, import.meta.url), 'utf8').trim()]));
19
+ 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();
20
+ const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), state: z.object({
21
+ proposal: Proposal.nullable().optional(),
22
+ selectedId: z.number().int().positive().safe().nullable().optional(), screen: z.enum(['tasks', 'settings', 'field']).optional(),
23
+ field: z.string().refine((value) => [...value].length <= 64, 'Field name exceeds 64 code points').optional(), search: z.string().max(256).optional(),
24
+ }).default({}) });
25
+ const Action = z.object({ method: z.literal('native-ui/action'), params: z.object({
26
+ uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
27
+ value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
28
+ }) });
29
+ const server = new Server({ name: 'pinote', version: '0.20.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
30
+ // Serialize complete read/check/write cycles even when a host sends parallel calls.
31
+ let queue = Promise.resolve();
32
+ function serial(run) {
33
+ const result = queue.then(run);
34
+ queue = result.then(() => { }, () => { });
35
+ return result;
36
+ }
37
+ function context(meta) { return Context.parse(meta?.[CONTEXT]); }
38
+ // Consent receipts are not selection storage. Tombstones prevent old branch state
39
+ // or a reconnect from creating the same proposed task twice, even after a crash.
40
+ const receiptDir = join(process.env.XDG_STATE_HOME || join(homedir(), '.local/state'), 'pinote/mcp-proposals');
41
+ async function consumed(id) {
42
+ try {
43
+ await fs.lstat(join(receiptDir, id));
44
+ return true;
45
+ }
46
+ catch (error) {
47
+ if (error.code === 'ENOENT')
48
+ return false;
49
+ throw error;
50
+ }
51
+ }
52
+ async function consume(id) {
53
+ await fs.mkdir(receiptDir, { recursive: true, mode: 0o700 });
54
+ const stat = await fs.lstat(receiptDir);
55
+ if (!stat.isDirectory() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o700)
56
+ throw new Error('Unsafe proposal receipt directory');
57
+ const file = await fs.open(join(receiptDir, id), 'wx', 0o600);
58
+ try {
59
+ await file.writeFile('consumed\n');
60
+ await file.sync();
61
+ }
62
+ finally {
63
+ await file.close();
64
+ }
65
+ }
66
+ function boundedState(state) {
67
+ if (JSON.stringify(state).length > 8192)
68
+ throw new Error('Proposal exceeds the host state budget; shorten it');
69
+ return state;
70
+ }
71
+ async function note(args, signal) {
72
+ signal.throwIfAborted();
73
+ const options = { signal, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 };
74
+ const version = (await exec(noteCommand, ['--version'], options)).stdout.trim();
75
+ const match = /^pinote (\d+)\.(\d+)\.(\d+)$/.exec(version);
76
+ if (!match || Number(match[1]) === 0 && Number(match[2]) < 4)
77
+ throw new Error('Pinote MCP requires note 0.4.0+');
78
+ return (await exec(noteCommand, ['--no-notify', ...args], options)).stdout;
79
+ }
80
+ async function selected(state, signal) {
81
+ if (!state.selectedId)
82
+ return null;
83
+ const task = JSON.parse(await note(['agent', 'get', String(state.selectedId)], signal));
84
+ return task && ['active', 'in_progress'].includes(task.state) ? task : null;
85
+ }
86
+ function fields(config, notes) {
87
+ // Legacy Bar values are arbitrary task text, not validated field definitions.
88
+ return effectiveFooterFields(config, notes).flatMap((field) => {
89
+ try {
90
+ return parseFooterConfig({ footer: { fields: [field] } }).fields;
91
+ }
92
+ catch {
93
+ return [];
94
+ }
95
+ }).slice(0, 64);
96
+ }
97
+ function settings() {
98
+ const document = readFooterDocument();
99
+ const root = document.raw === null ? {} : JSON.parse(document.raw);
100
+ const handoffPrompt = 'handoffPrompt' in root ? parseHandoffPrompt(root.handoffPrompt) : defaultHandoffPrompt;
101
+ const newSessionPrompt = 'newSessionPrompt' in root ? parseNewSessionPrompt(root.newSessionPrompt) : defaultNewSessionPrompt;
102
+ const policy = Object.hasOwn(root, 'taskOfferPolicy') ? root.taskOfferPolicy : 'always';
103
+ if (!['always', 'github-remote', 'never'].includes(policy))
104
+ throw new Error('Invalid taskOfferPolicy in pi-note.json');
105
+ return { ...document, handoffPrompt, newSessionPrompt, policy };
106
+ }
107
+ async function snapshot(state, signal) {
108
+ const tasks = JSON.parse(await note(['list', '--json'], signal));
109
+ const task = await selected(state, signal);
110
+ const config = settings();
111
+ state = { ...state, selectedId: task?.id ?? null, proposal: task || state.proposal && await consumed(state.proposal.id) ? null : state.proposal ?? null };
112
+ if (state.screen === 'field' && !fields(config.config, task?.agent_notes).some((f) => f.name === state.field))
113
+ state.screen = 'settings';
114
+ state = Context.parse({ conversationId: 'canonical', state }).state;
115
+ const revision = createHash('sha256').update(JSON.stringify({ state, tasks, task, raw: config.raw })).digest('hex');
116
+ return { state, tasks, task, config, revision };
117
+ }
118
+ function view(data) {
119
+ const { tasks, task, config, revision } = data;
120
+ const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
121
+ const controls = [];
122
+ const action = (id, label, extra = {}) => controls.push({ id, label: label.slice(0, 2000), kind: 'action', ...extra });
123
+ let title = 'Pinote · Tasks';
124
+ let body = task ? task.text : 'Select a task or create one. No task is selected for this conversation.';
125
+ // Footer actions remain valid on every screen, including Settings.
126
+ if (task) {
127
+ action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
128
+ action('done', 'Complete task', { icon: icons.check });
129
+ }
130
+ else if (state.proposal) {
131
+ action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: state.proposal.text });
132
+ action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
133
+ }
134
+ action('screen:tasks', 'Tasks', { icon: icons.note });
135
+ action('screen:settings', 'Settings', { description: 'Global prompts, task offers, and footer fields' });
136
+ if (state.screen === 'settings') {
137
+ title = 'Pinote · Settings';
138
+ body = 'Personal pi-note.json · changes save after confirmation';
139
+ for (const [id, label, value] of [['handoffPrompt', 'Task prompt', config.handoffPrompt], ['newSessionPrompt', 'New session prompt', config.newSessionPrompt]]) {
140
+ controls.push({ id, label, value, kind: 'text', multiline: true, confirm: 'Save this global prompt?' });
141
+ }
142
+ controls.push({ id: 'taskOfferPolicy', label: 'Task offers', kind: 'select', value: config.policy, options: ['always', 'github-remote', 'never'].map((value) => ({ label: value, value })), confirm: 'Save this global task-offer policy?' });
143
+ for (const key of ['titleWidth', 'fieldWidth', 'maxFields'])
144
+ controls.push({ id: key, label: key, kind: 'number', value: config.config[key], confirm: 'Save this global footer setting?' });
145
+ controls.push({ id: 'add-field', label: 'Add field', icon: icons.add, kind: 'text', value: '', confirm: 'Add this global footer field?' });
146
+ fields(config.config, task?.agent_notes).forEach((field) => action(`field:${field.name}`, field.name, { description: `${field.label || '(no label)'} · ${field.format}` }));
147
+ }
148
+ else if (state.screen === 'field') {
149
+ const field = fields(config.config, task?.agent_notes).find((f) => f.name === state.field);
150
+ if (!field)
151
+ throw new Error('Field no longer exists; open Settings again');
152
+ title = `Pinote · ${field.name}`;
153
+ body = 'Global footer display definition; task values are unchanged.';
154
+ controls.push({ id: 'label', label: 'Label', kind: 'text', value: field.label, confirm: 'Save this global field label?' });
155
+ controls.push({ id: 'format', label: 'Format', kind: 'text', value: field.format, description: '<value>, <url>, <number>', confirm: 'Save this global field format?' });
156
+ controls.push({ id: 'width', label: 'Width', kind: 'number', value: field.width ?? config.config.fieldWidth, confirm: 'Save this global field width?' });
157
+ controls.push({ id: 'link', label: 'Link', kind: 'toggle', value: field.link, confirm: 'Change this global link setting?' });
158
+ action('remove-field', 'Remove field', { confirm: 'Remove this global display definition? Task values are retained.' });
159
+ }
160
+ else {
161
+ if (task) {
162
+ if (config.handoffPrompt.length <= 20_000)
163
+ action('continue', 'Continue', { description: 'Request insertion of the task prompt; never submit it' });
164
+ action('clear', 'Clear selection', { description: 'Keep the task and its progress unchanged' });
165
+ }
166
+ controls.push({ id: 'add', label: 'New task', icon: icons.add, kind: 'text', multiline: true, value: '', confirm: 'Create and select this task?' });
167
+ controls.push({ id: 'search', label: 'Filter tasks', kind: 'text', value: state.search ?? '' });
168
+ const matches = tasks.filter((t) => `${t.text} ${t.tag ?? ''}`.toLowerCase().includes((state.search ?? '').toLowerCase()));
169
+ for (const candidate of matches.slice(0, 200))
170
+ action(`select:${candidate.id}`, `${candidate.tag ? `[${candidate.tag}] ` : ''}${candidate.text.split('\n')[0]}`, { description: `#${candidate.id} · ${candidate.state}`, confirm: 'Select and start this task for this conversation?' });
171
+ if (matches.length > 200)
172
+ body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
173
+ }
174
+ const footer = [{ text: `${icons.note} `, width: 3 }];
175
+ if (task) {
176
+ footer.push({ text: `${icons.check} `, action: 'done', width: 3 });
177
+ footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', width: config.config.titleWidth });
178
+ }
179
+ else if (state.proposal) {
180
+ footer.push({ text: `${icons.cross} `, action: 'proposal:dismiss', width: 3 }, { text: `${icons.add} `, action: 'proposal:accept', width: 3 });
181
+ footer.push({ text: `${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, width: config.config.titleWidth });
182
+ }
183
+ else
184
+ footer.push({ text: 'Pinote · no task', width: config.config.titleWidth });
185
+ const titleParts = footer.length;
186
+ if (task) {
187
+ for (const field of fields(config.config, task.agent_notes)) {
188
+ if (footer.length - titleParts >= config.config.maxFields)
189
+ break;
190
+ const raw = task.agent_notes[field.name];
191
+ if (!raw)
192
+ continue;
193
+ const link = /^\[([^\n]*)\]\((https?:\/\/[^\s]+)\)$/.exec(raw);
194
+ const url = link?.[2] ?? (/^https?:\/\/[^\s]+$/.test(raw) ? raw : undefined);
195
+ const value = link?.[1] ?? raw;
196
+ const number = url?.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)\/?$/)?.[1] ?? (/^\d+$/.test(value) ? value : undefined);
197
+ if (field.format.includes('<url>') && !url || field.format.includes('<number>') && !number)
198
+ continue;
199
+ const substitutions = { value, url: url ?? '', number: number ?? '' };
200
+ const rendered = ` · ${field.label ? `${field.label}: ` : ''}${field.format.replace(/<(value|url|number)>/g, (_match, key) => substitutions[key])}`;
201
+ footer.push({ text: rendered.slice(0, 20_000), ...(field.link && url && url.length <= 2048 ? { url } : {}), width: field.width ?? config.config.fieldWidth });
202
+ }
203
+ }
204
+ const editable = controls.filter((control) => {
205
+ if (typeof control.value !== 'string' || control.value.length <= 20_000)
206
+ return true;
207
+ body = `${control.label} exceeds the editor limit; edit it outside this UI.\n${body}`;
208
+ return false;
209
+ });
210
+ const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer };
211
+ // JSON escaping and expanded footer formats can exceed the host budget even
212
+ // with bounded row counts. Preserve navigation/filter controls ahead of rows.
213
+ let shortened = false;
214
+ while (JSON.stringify(result).length > 240_000) {
215
+ const row = result.controls.map((control) => control.id.startsWith('select:')).lastIndexOf(true);
216
+ if (row >= 0) {
217
+ result.controls.splice(row, 1);
218
+ shortened = true;
219
+ }
220
+ else if (result.footer.length > 1)
221
+ result.footer.pop();
222
+ else
223
+ throw new Error('UI exceeds its display budget; shorten configuration values');
224
+ }
225
+ if (shortened)
226
+ result.body = `More matches omitted to fit the display budget; narrow Filter tasks.\n${result.body}`.slice(0, 20_000);
227
+ return result;
228
+ }
229
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [{ uri: URI, name: 'Pinote', mimeType: MIME }] }));
230
+ server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(async () => {
231
+ if (request.params.uri !== URI)
232
+ throw new Error('Unknown resource');
233
+ const data = await snapshot(context(request.params._meta).state, extra.signal);
234
+ return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data)) }] };
235
+ }));
236
+ server.setRequestHandler(Action, (request, extra) => serial(async () => {
237
+ const state = context(request.params._meta).state;
238
+ const before = await snapshot(state, extra.signal);
239
+ if (before.revision !== request.params.revision)
240
+ throw new Error('View changed; refresh before acting');
241
+ const { action, value } = request.params;
242
+ if (!view(before).controls.some((c) => c.id === action))
243
+ throw new Error('Action is not available in this view');
244
+ const next = { ...before.state };
245
+ let draft, preview;
246
+ if (action === 'proposal:accept' || action === 'proposal:dismiss') {
247
+ const proposal = before.state.proposal;
248
+ if (!proposal || before.task)
249
+ throw new Error('Suggestion no longer available');
250
+ await consume(proposal.id); // Fail closed before any task mutation; never replay uncertain creation.
251
+ next.proposal = null;
252
+ if (action === 'proposal:accept') {
253
+ const task = JSON.parse(await note(['agent', 'add', `--text=${proposal.text}`, ...(proposal.tag ? [`--tag=${proposal.tag}`] : [])], extra.signal));
254
+ await note(['start', String(task.id)], extra.signal);
255
+ next.selectedId = task.id;
256
+ }
257
+ }
258
+ else if (action.startsWith('screen:'))
259
+ next.screen = action.slice(7);
260
+ else if (action.startsWith('field:')) {
261
+ next.screen = 'field';
262
+ next.field = action.slice(6);
263
+ }
264
+ else if (action === 'search')
265
+ next.search = z.string().max(256).parse(value);
266
+ else if (action === 'clear')
267
+ next.selectedId = null;
268
+ else if (action === 'continue')
269
+ draft = before.config.handoffPrompt;
270
+ else if (action === 'preview')
271
+ preview = (before.task.text + '\n\n' + JSON.stringify(before.task.agent_notes, null, 2)).slice(0, 20_000);
272
+ else if (action.startsWith('select:')) {
273
+ const id = Number(action.slice(7));
274
+ if (!before.tasks.some((t) => t.id === id))
275
+ throw new Error('Task no longer selectable');
276
+ if (next.proposal)
277
+ await consume(next.proposal.id);
278
+ await note(['start', String(id)], extra.signal);
279
+ next.selectedId = id;
280
+ next.proposal = null;
281
+ }
282
+ else if (action === 'add') {
283
+ if (next.proposal)
284
+ await consume(next.proposal.id);
285
+ next.proposal = null;
286
+ const task = JSON.parse(await note(['agent', 'add', `--text=${z.string().min(1).parse(value)}`], extra.signal));
287
+ await note(['start', String(task.id)], extra.signal);
288
+ next.selectedId = task.id;
289
+ }
290
+ else if (action === 'done') {
291
+ await note(['agent', 'done', String(before.task.id), '--expected-updated-at', before.task.updated_at], extra.signal);
292
+ next.selectedId = null;
293
+ }
294
+ else {
295
+ const cfg = structuredClone(before.config.config);
296
+ let handoff = before.config.handoffPrompt, newSession = before.config.newSessionPrompt, policy = before.config.policy;
297
+ if (action === 'handoffPrompt')
298
+ handoff = z.string().parse(value);
299
+ else if (action === 'newSessionPrompt')
300
+ newSession = z.string().parse(value);
301
+ else if (action === 'taskOfferPolicy')
302
+ policy = z.enum(['always', 'github-remote', 'never']).parse(value);
303
+ else if (['titleWidth', 'fieldWidth', 'maxFields'].includes(action))
304
+ cfg[action] = z.number().int().parse(value);
305
+ else {
306
+ cfg.fields = fields(cfg, before.task?.agent_notes);
307
+ if (action === 'add-field')
308
+ cfg.fields.push(defaultFooterField(z.string().parse(value)));
309
+ else if (action === 'remove-field') {
310
+ cfg.fields = cfg.fields.filter((f) => f.name !== state.field);
311
+ next.screen = 'settings';
312
+ }
313
+ else {
314
+ const field = cfg.fields.find((f) => f.name === state.field);
315
+ if (action === 'link')
316
+ field.link = z.boolean().parse(value);
317
+ else if (action === 'width')
318
+ field.width = z.number().int().parse(value);
319
+ else if (action === 'label' || action === 'format')
320
+ field[action] = z.string().parse(value);
321
+ else
322
+ throw new Error('Unknown action');
323
+ }
324
+ }
325
+ extra.signal.throwIfAborted();
326
+ saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy);
327
+ }
328
+ const result = { view: view(await snapshot(next, extra.signal)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
329
+ await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
330
+ return result;
331
+ }));
332
+ const empty = { type: 'object', properties: {}, additionalProperties: false };
333
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
334
+ { name: 'propose', description: 'Suggest a task without creating it. Requires no selected task. Show a short action-oriented title, optional details after a blank line, and reuse a saved tag. The user clicks + to create/start/select or dismiss. Replaces an unaccepted suggestion; continue working without asking again. Never creates a task before a user action.', inputSchema: { type: 'object', properties: { text: { type: 'string', minLength: 1, maxLength: 4000 }, tag: { type: 'string', minLength: 1, maxLength: 64 } }, required: ['text'], additionalProperties: false } },
335
+ { name: 'get_current', description: 'Read the task selected through the native Pinote UI for this conversation. Returns null if unselected.', inputSchema: empty, annotations: { readOnlyHint: true } },
336
+ { name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
337
+ { name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
338
+ { name: 'update_current', description: 'Merge Markdown handoff fields on the selected task. Read get_current first. Keep handoff notes concise; PR is one GitHub PR URL.', inputSchema: { type: 'object', properties: { expected_updated_at: { type: 'string' }, set: { type: 'object', additionalProperties: { type: 'string' } }, remove: { type: 'array', items: { type: 'string' } } }, required: ['expected_updated_at'], additionalProperties: false } },
339
+ ] }));
340
+ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async () => {
341
+ try {
342
+ const state = context(request.params._meta).state;
343
+ let result, outgoing;
344
+ if (request.params.name === 'propose') {
345
+ if (await selected(state, extra.signal))
346
+ throw new Error('A task is already selected');
347
+ const args = Proposal.omit({ id: true }).parse(request.params.arguments);
348
+ if (!args.text.trim())
349
+ throw new Error('Task text must not be blank');
350
+ outgoing = boundedState({ ...state, selectedId: null, proposal: { ...args, id: randomUUID() } });
351
+ if (state.proposal && !await consumed(state.proposal.id))
352
+ await consume(state.proposal.id);
353
+ result = { status: 'Suggested; user can accept (+) or dismiss. Continue working without asking again.' };
354
+ }
355
+ else if (request.params.name === 'get_current')
356
+ result = await selected(state, extra.signal);
357
+ else if (request.params.name === 'fields')
358
+ result = settings().config;
359
+ else if (request.params.name === 'tags')
360
+ result = JSON.parse(await note(['agent', 'tags'], extra.signal));
361
+ else if (request.params.name === 'update_current') {
362
+ const args = z.object({ expected_updated_at: z.string(), set: z.record(z.string()).default({}), remove: z.array(z.string()).default([]) }).strict().parse(request.params.arguments);
363
+ const task = await selected(state, extra.signal);
364
+ if (!task)
365
+ throw new Error('No task is selected');
366
+ 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));
367
+ await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
368
+ }
369
+ else
370
+ throw new Error('Unknown tool');
371
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(outgoing ? { _meta: { 'native-ui/state': outgoing } } : {}) };
372
+ }
373
+ catch (error) {
374
+ return { isError: true, content: [{ type: 'text', text: String(error) }] };
375
+ }
376
+ }));
377
+ await server.connect(new StdioServerTransport());
package/footer-config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
1
+ import { getAgentDir } from "./config-path.ts";
2
2
  import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
@@ -97,7 +97,8 @@ export function loadFooterConfig(warn: (message: string) => void): FooterConfig
97
97
 
98
98
  // Synchronous compare-and-replace: reject edits made while the settings dialog was open,
99
99
  // preserve unrelated keys, and never leave partial JSON behind.
100
- export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string, newSessionPrompt?: string) {
100
+ export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string, newSessionPrompt?: string, taskOfferPolicy?: string) {
101
+ if (taskOfferPolicy !== undefined && !["always", "github-remote", "never"].includes(taskOfferPolicy)) throw new Error("Invalid taskOfferPolicy");
101
102
  const checked = parseFooterConfig({ footer: config });
102
103
  const prompts = {
103
104
  ...(handoffPrompt === undefined ? {} : { handoffPrompt: parseHandoffPrompt(handoffPrompt) }),
@@ -117,7 +118,7 @@ export function saveFooterConfig(config: FooterConfig, expectedRaw: string | nul
117
118
  if (lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error("pi-note.json is a symlink. Edit its target manually rather than replacing the link.");
118
119
  const root = expectedRaw === null ? {} : JSON.parse(expectedRaw);
119
120
  if (!object(root)) throw new Error("Invalid pi-note configuration; expected an object.");
120
- raw = `${JSON.stringify({ ...root, ...prompts, footer: checked }, null, 2)}\n`;
121
+ raw = `${JSON.stringify({ ...root, ...prompts, ...(taskOfferPolicy === undefined ? {} : { taskOfferPolicy }), footer: checked }, null, 2)}\n`;
121
122
  writeFileSync(temp, raw, { flag: "wx", mode: 0o600 });
122
123
  renameSync(temp, path);
123
124
  } finally { rmSync(temp, { force: true }); closeSync(descriptor); rmSync(lock, { force: true }); }
package/mcp/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # Pinote native UI over MCP
2
+
3
+ An opt-in alternative to the Pinote-specific Pi extension. Pinote serves the task
4
+ picker, settings forms, previews, footer data, and action validation through MCP;
5
+ the generic [gateway UI host](https://github.com/kvidzibo/mcp-session-gateway)
6
+ only renders and routes them. No model turn is needed for UI actions.
7
+
8
+ This uses the gateway's **private native UI v1 protocol**, not browser MCP Apps or
9
+ the built-in Pi MCP resource renderer. Use gateway 0.12.0+ with its Pi adapter.
10
+
11
+ ## Setup
12
+
13
+ Requires Node.js 22.19+, Python 3.11+, and `note` 0.4.0+. From the Pinote checkout:
14
+
15
+ ```bash
16
+ uv sync --locked
17
+ npm --prefix pi ci --ignore-scripts
18
+ npm --prefix pi run build:mcp
19
+ ```
20
+
21
+ Add a service to the gateway config (use absolute paths):
22
+
23
+ ```json
24
+ {
25
+ "servers": {
26
+ "pinote": {
27
+ "command": "node",
28
+ "args": ["/absolute/path/to/pinote/pi/dist-mcp/mcp/server.js"],
29
+ "env": { "PINOTE_NOTE_COMMAND": "/absolute/path/to/pinote/.venv/bin/note" }
30
+ }
31
+ }
32
+ }
33
+ ```
34
+
35
+ Then launch the gateway's generic Pi adapter and open
36
+ `/gateway` → Services → pinote → Open app.
37
+ Do not also load `pi/index.ts` for this workflow. Nothing is automatically
38
+ installed, activated, or migrated; keep the existing integration until you approve
39
+ the switch. To test `/reload`, reload only a disposable session/profile first.
40
+
41
+ The service shares the existing database and `pi-note.json`. `PINOTE_NOTE_COMMAND`
42
+ selects an executable, not a shell command. Without it, `note` must be on PATH.
43
+ Explicitly pass `PI_CODING_AGENT_DIR`, `XDG_DATA_HOME`, and `XDG_STATE_HOME` in service
44
+ `env` when using overrides or disposable data. Defaults otherwise refer to your
45
+ normal personal data. No desktop notifications are sent.
46
+
47
+ ## Use
48
+
49
+ - **Tasks:** filter, select/start, create/select, clear selection, complete, preview,
50
+ and request insertion of the task prompt. Selecting a task asks for confirmation.
51
+ - **Settings:** edit prompts, task-offer policy, footer limits, and field definitions
52
+ (label, link, format, width). Enter text as JSON strings (`\n` for line breaks)
53
+ so whitespace round-trips exactly. Saves are confirmed, validated, and atomic; concurrent
54
+ edits are rejected and unrelated settings are preserved. Invalid configuration
55
+ fails closed; repair it manually rather than replacing it with defaults.
56
+ - **Footer:** selected task plus configured fields; safe HTTP(S) links are clickable.
57
+ The title previews the task; **✔** completes it and clears the selection without
58
+ resetting the conversation or editor. Suggestions show **✖ / ✚** to dismiss or
59
+ create/start/select. These use the gateway's generic footer actions, not a Pinote
60
+ extension or private handler. Install the gateway's Kitty protocol handler as
61
+ described in its native UI documentation; app menus remain the keyboard fallback.
62
+ - **Agent:** `propose`, `get_current`, `update_current`, `fields`, and `tags` are
63
+ available through gateway tool discovery. `propose` returns opaque UI state;
64
+ it does not create a task. It replaces an unaccepted suggestion only when no task
65
+ is selected. Text is limited to 4,000 characters and the host's 8 KiB state budget.
66
+ Acceptance/dismissal consumes a durable receipt before mutation, so old links,
67
+ branch restores and reconnects cannot replay consent. An interrupted acceptance
68
+ may have created a task: inspect the task list, never blindly propose it again. They use the same host-supplied selection as the UI.
69
+ UI actions and settings writes are not advertised as model tools.
70
+
71
+ The host owns durable conversation/branch state; the service does not store a
72
+ second selection database. Reload/reconnect restores saved state, separate sessions
73
+ in one folder stay independent, and `/new` retains the current selection or pending
74
+ suggestion. An unrelated fresh launch starts unselected. The stable service
75
+ context is separate from both the Pi conversation ID and gateway process UUID. Pi must
76
+ have saved the session for resume persistence. Existing extension selections are
77
+ not imported. Changing the gateway config path or service name starts a separate
78
+ selection namespace.
79
+
80
+ ## Initial-version limits
81
+
82
+ This is a working alternative, **not full parity with the old extension**. It does
83
+ not retag/edit existing tasks or install/upgrade the CLI. Completion intentionally
84
+ does not reset conversations. Task-offer policy and new-session prompt can
85
+ be edited for compatibility with the existing extension, but this host does not
86
+ yet implement their automation. Continue requests a separately confirmed draft;
87
+ it never overwrites existing editor text or submits a prompt. Previews are display-only transcript entries (never model context), showing plain
88
+ text/Markdown source limited to 20,000 characters; the task list shows at most 200
89
+ filtered matches (fewer if the serialized display budget is reached). Use the CLI for full text and unsupported task operations.
90
+
91
+ Consent receipts live under `$XDG_STATE_HOME/pinote/mcp-proposals` (default
92
+ `~/.local/state/pinote/mcp-proposals`). They contain no task content and are retained
93
+ to reject historical proposals. Do not delete them while old sessions may resume.
94
+ They are replay protection, not a second selection database or an authorization
95
+ boundary against local processes.
96
+
97
+ Changes through this service notify the host. External CLI/GTK/config changes need
98
+ Ctrl+R or reopening the app. Failed actions are never retried automatically: a
99
+ write might have committed before an interruption. Read fresh state before retrying.
100
+ Trusted services/agents have ordinary OS permissions; UI-only routing is not a
101
+ secrecy or authorization boundary.
102
+
103
+ ## Validation
104
+
105
+ ```bash
106
+ npm --prefix pi test
107
+ ```
108
+
109
+ The MCP integration test runs the real stdio server and CLI against temporary
110
+ storage, including settings conflicts, host selection isolation, handoff updates,
111
+ completion, and reconnect. Visual validation must use Xvfb, private D-Bus, and
112
+ explicit temporary service data/settings paths.
package/mcp/server.ts ADDED
@@ -0,0 +1,289 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import { readFileSync } from 'node:fs';
8
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
9
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
10
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
11
+ import { z } from 'zod';
12
+ import { defaultFooterField, defaultHandoffPrompt, defaultNewSessionPrompt, effectiveFooterFields, parseFooterConfig, parseHandoffPrompt, parseNewSessionPrompt, readFooterDocument, saveFooterConfig } from '../footer-config.ts';
13
+
14
+ const URI = 'pinote://ui';
15
+ const MIME = 'application/vnd.mcp-native-ui+json';
16
+ const CONTEXT = 'native-ui/context';
17
+ const exec = promisify(execFile);
18
+ const noteCommand = process.env.PINOTE_NOTE_COMMAND || 'note';
19
+ const icons = Object.fromEntries(['add', 'check', 'cross', 'note'].map((name) => [name, readFileSync(new URL(`../icons/${name}.txt`, import.meta.url), 'utf8').trim()]));
20
+ 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();
21
+ const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), state: z.object({
22
+ proposal: Proposal.nullable().optional(),
23
+ selectedId: z.number().int().positive().safe().nullable().optional(), screen: z.enum(['tasks', 'settings', 'field']).optional(),
24
+ field: z.string().refine((value) => [...value].length <= 64, 'Field name exceeds 64 code points').optional(), search: z.string().max(256).optional(),
25
+ }).default({}) });
26
+ type State = z.infer<typeof Context>['state'];
27
+ type Task = { id: number; text: string; state: string; tag: string | null; updated_at: string; agent_notes: Record<string, string> };
28
+ type Scalar = string | number | boolean | null;
29
+ type Control = { id: string; label: string; kind: 'action' | 'text' | 'number' | 'toggle' | 'select'; value?: Scalar; icon?: string; description?: string; multiline?: boolean; confirm?: string; options?: { label: string; value: Scalar }[] };
30
+ const Action = z.object({ method: z.literal('native-ui/action'), params: z.object({
31
+ uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
32
+ value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
33
+ }) });
34
+ const server = new Server({ name: 'pinote', version: '0.20.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
35
+ // Serialize complete read/check/write cycles even when a host sends parallel calls.
36
+ let queue = Promise.resolve();
37
+ function serial<T>(run: () => Promise<T>): Promise<T> {
38
+ const result = queue.then(run);
39
+ queue = result.then(() => {}, () => {});
40
+ return result;
41
+ }
42
+ function context(meta: Record<string, unknown> | undefined) { return Context.parse(meta?.[CONTEXT]); }
43
+ // Consent receipts are not selection storage. Tombstones prevent old branch state
44
+ // or a reconnect from creating the same proposed task twice, even after a crash.
45
+ const receiptDir = join(process.env.XDG_STATE_HOME || join(homedir(), '.local/state'), 'pinote/mcp-proposals');
46
+ async function consumed(id: string): Promise<boolean> {
47
+ try { await fs.lstat(join(receiptDir, id)); return true; }
48
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; throw error; }
49
+ }
50
+ async function consume(id: string): Promise<void> {
51
+ await fs.mkdir(receiptDir, { recursive: true, mode: 0o700 });
52
+ const stat = await fs.lstat(receiptDir);
53
+ if (!stat.isDirectory() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o700) throw new Error('Unsafe proposal receipt directory');
54
+ const file = await fs.open(join(receiptDir, id), 'wx', 0o600);
55
+ try { await file.writeFile('consumed\n'); await file.sync(); } finally { await file.close(); }
56
+ }
57
+ function boundedState(state: State): State {
58
+ if (JSON.stringify(state).length > 8192) throw new Error('Proposal exceeds the host state budget; shorten it');
59
+ return state;
60
+ }
61
+ async function note(args: string[], signal: AbortSignal): Promise<string> {
62
+ signal.throwIfAborted();
63
+ const options = { signal, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 };
64
+ const version = (await exec(noteCommand, ['--version'], options)).stdout.trim();
65
+ const match = /^pinote (\d+)\.(\d+)\.(\d+)$/.exec(version);
66
+ if (!match || Number(match[1]) === 0 && Number(match[2]) < 4) throw new Error('Pinote MCP requires note 0.4.0+');
67
+ return (await exec(noteCommand, ['--no-notify', ...args], options)).stdout;
68
+ }
69
+ async function selected(state: State, signal: AbortSignal): Promise<Task | null> {
70
+ if (!state.selectedId) return null;
71
+ const task: Task | null = JSON.parse(await note(['agent', 'get', String(state.selectedId)], signal));
72
+ return task && ['active', 'in_progress'].includes(task.state) ? task : null;
73
+ }
74
+ function fields(config: ReturnType<typeof readFooterDocument>['config'], notes?: Record<string, string>) {
75
+ // Legacy Bar values are arbitrary task text, not validated field definitions.
76
+ return effectiveFooterFields(config, notes).flatMap((field) => {
77
+ try { return parseFooterConfig({ footer: { fields: [field] } }).fields!; }
78
+ catch { return []; }
79
+ }).slice(0, 64);
80
+ }
81
+ function settings() {
82
+ const document = readFooterDocument();
83
+ const root = document.raw === null ? {} : JSON.parse(document.raw);
84
+ const handoffPrompt = 'handoffPrompt' in root ? parseHandoffPrompt(root.handoffPrompt) : defaultHandoffPrompt;
85
+ const newSessionPrompt = 'newSessionPrompt' in root ? parseNewSessionPrompt(root.newSessionPrompt) : defaultNewSessionPrompt;
86
+ const policy = Object.hasOwn(root, 'taskOfferPolicy') ? root.taskOfferPolicy : 'always';
87
+ if (!['always', 'github-remote', 'never'].includes(policy)) throw new Error('Invalid taskOfferPolicy in pi-note.json');
88
+ return { ...document, handoffPrompt, newSessionPrompt, policy };
89
+ }
90
+ async function snapshot(state: State, signal: AbortSignal) {
91
+ const tasks: Task[] = JSON.parse(await note(['list', '--json'], signal));
92
+ const task = await selected(state, signal);
93
+ const config = settings();
94
+ state = { ...state, selectedId: task?.id ?? null, proposal: task || state.proposal && await consumed(state.proposal.id) ? null : state.proposal ?? null };
95
+ if (state.screen === 'field' && !fields(config.config, task?.agent_notes).some((f) => f.name === state.field)) state.screen = 'settings';
96
+ state = Context.parse({ conversationId: 'canonical', state }).state;
97
+ const revision = createHash('sha256').update(JSON.stringify({ state, tasks, task, raw: config.raw })).digest('hex');
98
+ return { state, tasks, task, config, revision };
99
+ }
100
+ type Snapshot = Awaited<ReturnType<typeof snapshot>>;
101
+ function view(data: Snapshot) {
102
+ const { tasks, task, config, revision } = data;
103
+ const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
104
+ const controls: Control[] = [];
105
+ const action = (id: string, label: string, extra: Partial<Control> = {}) => controls.push({ id, label: label.slice(0, 2000), kind: 'action', ...extra });
106
+ let title = 'Pinote · Tasks';
107
+ let body = task ? task.text : 'Select a task or create one. No task is selected for this conversation.';
108
+ // Footer actions remain valid on every screen, including Settings.
109
+ if (task) {
110
+ action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
111
+ action('done', 'Complete task', { icon: icons.check });
112
+ } else if (state.proposal) {
113
+ action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: state.proposal.text });
114
+ action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
115
+ }
116
+ action('screen:tasks', 'Tasks', { icon: icons.note });
117
+ action('screen:settings', 'Settings', { description: 'Global prompts, task offers, and footer fields' });
118
+ if (state.screen === 'settings') {
119
+ title = 'Pinote · Settings'; body = 'Personal pi-note.json · changes save after confirmation';
120
+ for (const [id, label, value] of [['handoffPrompt', 'Task prompt', config.handoffPrompt], ['newSessionPrompt', 'New session prompt', config.newSessionPrompt]] as const) {
121
+ controls.push({ id, label, value, kind: 'text', multiline: true, confirm: 'Save this global prompt?' });
122
+ }
123
+ controls.push({ id: 'taskOfferPolicy', label: 'Task offers', kind: 'select', value: config.policy, options: ['always', 'github-remote', 'never'].map((value) => ({ label: value, value })), confirm: 'Save this global task-offer policy?' });
124
+ for (const key of ['titleWidth', 'fieldWidth', 'maxFields'] as const) controls.push({ id: key, label: key, kind: 'number', value: config.config[key], confirm: 'Save this global footer setting?' });
125
+ controls.push({ id: 'add-field', label: 'Add field', icon: icons.add, kind: 'text', value: '', confirm: 'Add this global footer field?' });
126
+ fields(config.config, task?.agent_notes).forEach((field) => action(`field:${field.name}`, field.name, { description: `${field.label || '(no label)'} · ${field.format}` }));
127
+ } else if (state.screen === 'field') {
128
+ const field = fields(config.config, task?.agent_notes).find((f) => f.name === state.field);
129
+ if (!field) throw new Error('Field no longer exists; open Settings again');
130
+ title = `Pinote · ${field.name}`; body = 'Global footer display definition; task values are unchanged.';
131
+ controls.push({ id: 'label', label: 'Label', kind: 'text', value: field.label, confirm: 'Save this global field label?' });
132
+ controls.push({ id: 'format', label: 'Format', kind: 'text', value: field.format, description: '<value>, <url>, <number>', confirm: 'Save this global field format?' });
133
+ controls.push({ id: 'width', label: 'Width', kind: 'number', value: field.width ?? config.config.fieldWidth, confirm: 'Save this global field width?' });
134
+ controls.push({ id: 'link', label: 'Link', kind: 'toggle', value: field.link, confirm: 'Change this global link setting?' });
135
+ action('remove-field', 'Remove field', { confirm: 'Remove this global display definition? Task values are retained.' });
136
+ } else {
137
+ if (task) {
138
+ if (config.handoffPrompt.length <= 20_000) action('continue', 'Continue', { description: 'Request insertion of the task prompt; never submit it' });
139
+ action('clear', 'Clear selection', { description: 'Keep the task and its progress unchanged' });
140
+ }
141
+ controls.push({ id: 'add', label: 'New task', icon: icons.add, kind: 'text', multiline: true, value: '', confirm: 'Create and select this task?' });
142
+ controls.push({ id: 'search', label: 'Filter tasks', kind: 'text', value: state.search ?? '' });
143
+ const matches = tasks.filter((t) => `${t.text} ${t.tag ?? ''}`.toLowerCase().includes((state.search ?? '').toLowerCase()));
144
+ for (const candidate of matches.slice(0, 200)) action(`select:${candidate.id}`, `${candidate.tag ? `[${candidate.tag}] ` : ''}${candidate.text.split('\n')[0]}`, { description: `#${candidate.id} · ${candidate.state}`, confirm: 'Select and start this task for this conversation?' });
145
+ if (matches.length > 200) body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
146
+ }
147
+ const footer: { text: string; url?: string; action?: string; width?: number }[] = [{ text: `${icons.note} `, width: 3 }];
148
+ if (task) {
149
+ footer.push({ text: `${icons.check} `, action: 'done', width: 3 });
150
+ footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', width: config.config.titleWidth });
151
+ } else if (state.proposal) {
152
+ footer.push({ text: `${icons.cross} `, action: 'proposal:dismiss', width: 3 }, { text: `${icons.add} `, action: 'proposal:accept', width: 3 });
153
+ footer.push({ text: `${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, width: config.config.titleWidth });
154
+ } else footer.push({ text: 'Pinote · no task', width: config.config.titleWidth });
155
+ const titleParts = footer.length;
156
+ if (task) {
157
+ for (const field of fields(config.config, task.agent_notes)) {
158
+ if (footer.length - titleParts >= config.config.maxFields) break;
159
+ const raw = task.agent_notes[field.name];
160
+ if (!raw) continue;
161
+ const link = /^\[([^\n]*)\]\((https?:\/\/[^\s]+)\)$/.exec(raw);
162
+ const url = link?.[2] ?? (/^https?:\/\/[^\s]+$/.test(raw) ? raw : undefined);
163
+ const value = link?.[1] ?? raw;
164
+ const number = url?.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)\/?$/)?.[1] ?? (/^\d+$/.test(value) ? value : undefined);
165
+ if (field.format.includes('<url>') && !url || field.format.includes('<number>') && !number) continue;
166
+ const substitutions: Record<string, string> = { value, url: url ?? '', number: number ?? '' };
167
+ const rendered = ` · ${field.label ? `${field.label}: ` : ''}${field.format.replace(/<(value|url|number)>/g, (_match, key: string) => substitutions[key])}`;
168
+ footer.push({ text: rendered.slice(0, 20_000), ...(field.link && url && url.length <= 2048 ? { url } : {}), width: field.width ?? config.config.fieldWidth });
169
+ }
170
+ }
171
+ const editable = controls.filter((control) => {
172
+ if (typeof control.value !== 'string' || control.value.length <= 20_000) return true;
173
+ body = `${control.label} exceeds the editor limit; edit it outside this UI.\n${body}`;
174
+ return false;
175
+ });
176
+ const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer };
177
+ // JSON escaping and expanded footer formats can exceed the host budget even
178
+ // with bounded row counts. Preserve navigation/filter controls ahead of rows.
179
+ let shortened = false;
180
+ while (JSON.stringify(result).length > 240_000) {
181
+ const row = result.controls.map((control) => control.id.startsWith('select:')).lastIndexOf(true);
182
+ if (row >= 0) { result.controls.splice(row, 1); shortened = true; }
183
+ else if (result.footer.length > 1) result.footer.pop();
184
+ else throw new Error('UI exceeds its display budget; shorten configuration values');
185
+ }
186
+ if (shortened) result.body = `More matches omitted to fit the display budget; narrow Filter tasks.\n${result.body}`.slice(0, 20_000);
187
+ return result;
188
+ }
189
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [{ uri: URI, name: 'Pinote', mimeType: MIME }] }));
190
+ server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(async () => {
191
+ if (request.params.uri !== URI) throw new Error('Unknown resource');
192
+ const data = await snapshot(context(request.params._meta).state, extra.signal);
193
+ return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data)) }] };
194
+ }));
195
+ server.setRequestHandler(Action, (request, extra) => serial(async () => {
196
+ const state = context(request.params._meta).state;
197
+ const before = await snapshot(state, extra.signal);
198
+ if (before.revision !== request.params.revision) throw new Error('View changed; refresh before acting');
199
+ const { action, value } = request.params;
200
+ if (!view(before).controls.some((c) => c.id === action)) throw new Error('Action is not available in this view');
201
+ const next: State = { ...before.state };
202
+ let draft: string | undefined, preview: string | undefined;
203
+ if (action === 'proposal:accept' || action === 'proposal:dismiss') {
204
+ const proposal = before.state.proposal;
205
+ if (!proposal || before.task) throw new Error('Suggestion no longer available');
206
+ await consume(proposal.id); // Fail closed before any task mutation; never replay uncertain creation.
207
+ next.proposal = null;
208
+ if (action === 'proposal:accept') {
209
+ const task: Task = JSON.parse(await note(['agent', 'add', `--text=${proposal.text}`, ...(proposal.tag ? [`--tag=${proposal.tag}`] : [])], extra.signal));
210
+ await note(['start', String(task.id)], extra.signal); next.selectedId = task.id;
211
+ }
212
+ } else if (action.startsWith('screen:')) next.screen = action.slice(7) as State['screen'];
213
+ else if (action.startsWith('field:')) { next.screen = 'field'; next.field = action.slice(6); }
214
+ else if (action === 'search') next.search = z.string().max(256).parse(value);
215
+ else if (action === 'clear') next.selectedId = null;
216
+ else if (action === 'continue') draft = before.config.handoffPrompt;
217
+ else if (action === 'preview') preview = (before.task!.text + '\n\n' + JSON.stringify(before.task!.agent_notes, null, 2)).slice(0, 20_000);
218
+ else if (action.startsWith('select:')) {
219
+ const id = Number(action.slice(7));
220
+ if (!before.tasks.some((t) => t.id === id)) throw new Error('Task no longer selectable');
221
+ if (next.proposal) await consume(next.proposal.id);
222
+ await note(['start', String(id)], extra.signal); next.selectedId = id; next.proposal = null;
223
+ } else if (action === 'add') {
224
+ if (next.proposal) await consume(next.proposal.id);
225
+ next.proposal = null;
226
+ const task: Task = JSON.parse(await note(['agent', 'add', `--text=${z.string().min(1).parse(value)}`], extra.signal));
227
+ await note(['start', String(task.id)], extra.signal); next.selectedId = task.id;
228
+ } else if (action === 'done') {
229
+ await note(['agent', 'done', String(before.task!.id), '--expected-updated-at', before.task!.updated_at], extra.signal);
230
+ next.selectedId = null;
231
+ } else {
232
+ const cfg = structuredClone(before.config.config);
233
+ let handoff = before.config.handoffPrompt, newSession = before.config.newSessionPrompt, policy = before.config.policy;
234
+ if (action === 'handoffPrompt') handoff = z.string().parse(value);
235
+ else if (action === 'newSessionPrompt') newSession = z.string().parse(value);
236
+ else if (action === 'taskOfferPolicy') policy = z.enum(['always', 'github-remote', 'never']).parse(value);
237
+ else if (['titleWidth', 'fieldWidth', 'maxFields'].includes(action)) cfg[action as 'titleWidth'] = z.number().int().parse(value);
238
+ else {
239
+ cfg.fields = fields(cfg, before.task?.agent_notes);
240
+ if (action === 'add-field') cfg.fields.push(defaultFooterField(z.string().parse(value)));
241
+ else if (action === 'remove-field') { cfg.fields = cfg.fields.filter((f) => f.name !== state.field); next.screen = 'settings'; }
242
+ else {
243
+ const field = cfg.fields.find((f) => f.name === state.field)!;
244
+ if (action === 'link') field.link = z.boolean().parse(value);
245
+ else if (action === 'width') field.width = z.number().int().parse(value);
246
+ else if (action === 'label' || action === 'format') field[action] = z.string().parse(value);
247
+ else throw new Error('Unknown action');
248
+ }
249
+ }
250
+ extra.signal.throwIfAborted();
251
+ saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy);
252
+ }
253
+ const result = { view: view(await snapshot(next, extra.signal)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
254
+ await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
255
+ return result;
256
+ }));
257
+ const empty = { type: 'object' as const, properties: {}, additionalProperties: false };
258
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
259
+ { name: 'propose', description: 'Suggest a task without creating it. Requires no selected task. Show a short action-oriented title, optional details after a blank line, and reuse a saved tag. The user clicks + to create/start/select or dismiss. Replaces an unaccepted suggestion; continue working without asking again. Never creates a task before a user action.', inputSchema: { type: 'object' as const, properties: { text: { type: 'string', minLength: 1, maxLength: 4000 }, tag: { type: 'string', minLength: 1, maxLength: 64 } }, required: ['text'], additionalProperties: false } },
260
+ { name: 'get_current', description: 'Read the task selected through the native Pinote UI for this conversation. Returns null if unselected.', inputSchema: empty, annotations: { readOnlyHint: true } },
261
+ { name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
262
+ { name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
263
+ { name: 'update_current', description: 'Merge Markdown handoff fields on the selected task. Read get_current first. Keep handoff notes concise; PR is one GitHub PR URL.', inputSchema: { type: 'object' as const, properties: { expected_updated_at: { type: 'string' }, set: { type: 'object', additionalProperties: { type: 'string' } }, remove: { type: 'array', items: { type: 'string' } } }, required: ['expected_updated_at'], additionalProperties: false } },
264
+ ] }));
265
+ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async () => {
266
+ try {
267
+ const state = context(request.params._meta).state;
268
+ let result: unknown, outgoing: State | undefined;
269
+ if (request.params.name === 'propose') {
270
+ if (await selected(state, extra.signal)) throw new Error('A task is already selected');
271
+ const args = Proposal.omit({ id: true }).parse(request.params.arguments);
272
+ if (!args.text.trim()) throw new Error('Task text must not be blank');
273
+ outgoing = boundedState({ ...state, selectedId: null, proposal: { ...args, id: randomUUID() } });
274
+ if (state.proposal && !await consumed(state.proposal.id)) await consume(state.proposal.id);
275
+ result = { status: 'Suggested; user can accept (+) or dismiss. Continue working without asking again.' };
276
+ } else if (request.params.name === 'get_current') result = await selected(state, extra.signal);
277
+ else if (request.params.name === 'fields') result = settings().config;
278
+ else if (request.params.name === 'tags') result = JSON.parse(await note(['agent', 'tags'], extra.signal));
279
+ else if (request.params.name === 'update_current') {
280
+ const args = z.object({ expected_updated_at: z.string(), set: z.record(z.string()).default({}), remove: z.array(z.string()).default([]) }).strict().parse(request.params.arguments);
281
+ const task = await selected(state, extra.signal);
282
+ if (!task) throw new Error('No task is selected');
283
+ 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));
284
+ await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
285
+ } else throw new Error('Unknown tool');
286
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(outgoing ? { _meta: { 'native-ui/state': outgoing } } : {}) };
287
+ } catch (error) { return { isError: true, content: [{ type: 'text', text: String(error) }] }; }
288
+ }));
289
+ await server.connect(new StdioServerTransport());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.18.1",
3
+ "version": "0.20.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,6 +30,9 @@
30
30
  "footer-status.ts",
31
31
  "footer-links.ts",
32
32
  "footer-config.ts",
33
+ "config-path.ts",
34
+ "mcp",
35
+ "dist-mcp",
33
36
  "footer-settings.ts",
34
37
  "task-picker.ts",
35
38
  "task-suggestion.ts",
@@ -47,10 +50,13 @@
47
50
  ]
48
51
  },
49
52
  "scripts": {
53
+ "build:mcp": "tsc -p tsconfig.mcp.json && node -e \"require('node:fs').cpSync('icons', 'dist-mcp/icons', { recursive: true })\"",
54
+ "prepack": "npm run build:mcp",
55
+ "mcp": "node dist-mcp/mcp/server.js",
50
56
  "check": "tsc --noEmit",
51
57
  "test:unit": "node --test --experimental-strip-types tests/*.test.ts",
52
58
  "test:load": "node --test --experimental-strip-types test-load.ts",
53
- "test": "npm run check && npm run test:unit && npm run test:load"
59
+ "test": "npm run build:mcp && npm run check && npm run test:unit && npm run test:load"
54
60
  },
55
61
  "peerDependencies": {
56
62
  "@earendil-works/pi-coding-agent": "*",
@@ -63,5 +69,9 @@
63
69
  "@types/node": "22.20.3",
64
70
  "typebox": "1.3.7",
65
71
  "typescript": "5.9.3"
72
+ },
73
+ "dependencies": {
74
+ "@modelcontextprotocol/sdk": "^1.32.1",
75
+ "zod": "^3.25.76"
66
76
  }
67
77
  }