pi-note 0.18.0 → 0.19.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+**.
@@ -84,7 +88,7 @@ The agent can still read the task separately with `pinote_get_current`.
84
88
  Click previews work while the agent is running, provided no other Pinote operation
85
89
  is open. Each click saves a fresh snapshot in the transcript, above any currently
86
90
  streaming response. Later output scrolls it upward; it does not expire. Preview
87
- reads never lock out the agent's task updates. Settings still requires Pi to be idle.
91
+ reads never lock out the agent's task updates. Settings also works during a running turn.
88
92
 
89
93
  ![Preview above a running response](../docs/images/pi-note-active-preview.png)
90
94
 
@@ -358,7 +362,11 @@ Pinote no longer polls GitHub, runs `gh`, watches completed tasks, or prompts on
358
362
  PR merges. No GitHub authentication is required. `PINOTE_PR_POLL_SECONDS` is no
359
363
  longer used. Other separately installed Pi PR/Git extensions are unaffected.
360
364
 
361
- Tools work without a TUI, but `/pi-note` needs an idle TUI.
365
+ Tools work without a TUI. `/pi-note` works in a TUI while the agent is running:
366
+ you can select/switch tasks, use Continue, preview tasks, respond to suggestions,
367
+ and edit settings without interrupting the turn or submitting input. Completion
368
+ and CLI installation/upgrades still require an idle session. Another pending
369
+ Pinote operation blocks the menu. Open menus do not block agent handoff updates.
362
370
  This package does not synchronize databases or paths between machines. Note text
363
371
  and fields loaded into Pi are sent to the configured model when used as context;
364
372
  avoid secrets. Agent commands do not send desktop notifications.
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,290 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { readFileSync } from 'node:fs';
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
8
+ import { z } from 'zod';
9
+ import { defaultFooterField, defaultHandoffPrompt, defaultNewSessionPrompt, effectiveFooterFields, parseFooterConfig, parseHandoffPrompt, parseNewSessionPrompt, readFooterDocument, saveFooterConfig } from "../footer-config.js";
10
+ const URI = 'pinote://ui';
11
+ const MIME = 'application/vnd.mcp-native-ui+json';
12
+ const CONTEXT = 'native-ui/context';
13
+ const exec = promisify(execFile);
14
+ const noteCommand = process.env.PINOTE_NOTE_COMMAND || 'note';
15
+ const icons = Object.fromEntries(['add', 'check', 'note'].map((name) => [name, readFileSync(new URL(`../icons/${name}.txt`, import.meta.url), 'utf8').trim()]));
16
+ const Context = z.object({ conversationId: z.string().min(1).max(256), state: z.object({
17
+ selectedId: z.number().int().positive().safe().nullable().optional(), screen: z.enum(['tasks', 'settings', 'field']).optional(),
18
+ field: z.string().refine((value) => [...value].length <= 64, 'Field name exceeds 64 code points').optional(), search: z.string().max(256).optional(),
19
+ }).default({}) });
20
+ const Action = z.object({ method: z.literal('native-ui/action'), params: z.object({
21
+ uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
22
+ value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
23
+ }) });
24
+ const server = new Server({ name: 'pinote', version: '0.19.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
25
+ // Serialize complete read/check/write cycles even when a host sends parallel calls.
26
+ let queue = Promise.resolve();
27
+ function serial(run) {
28
+ const result = queue.then(run);
29
+ queue = result.then(() => { }, () => { });
30
+ return result;
31
+ }
32
+ function context(meta) { return Context.parse(meta?.[CONTEXT]); }
33
+ async function note(args, signal) {
34
+ signal.throwIfAborted();
35
+ const options = { signal, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 };
36
+ const version = (await exec(noteCommand, ['--version'], options)).stdout.trim();
37
+ const match = /^pinote (\d+)\.(\d+)\.(\d+)$/.exec(version);
38
+ if (!match || Number(match[1]) === 0 && Number(match[2]) < 4)
39
+ throw new Error('Pinote MCP requires note 0.4.0+');
40
+ return (await exec(noteCommand, ['--no-notify', ...args], options)).stdout;
41
+ }
42
+ async function selected(state, signal) {
43
+ if (!state.selectedId)
44
+ return null;
45
+ const task = JSON.parse(await note(['agent', 'get', String(state.selectedId)], signal));
46
+ return task && ['active', 'in_progress'].includes(task.state) ? task : null;
47
+ }
48
+ function fields(config, notes) {
49
+ // Legacy Bar values are arbitrary task text, not validated field definitions.
50
+ return effectiveFooterFields(config, notes).flatMap((field) => {
51
+ try {
52
+ return parseFooterConfig({ footer: { fields: [field] } }).fields;
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ }).slice(0, 64);
58
+ }
59
+ function settings() {
60
+ const document = readFooterDocument();
61
+ const root = document.raw === null ? {} : JSON.parse(document.raw);
62
+ const handoffPrompt = 'handoffPrompt' in root ? parseHandoffPrompt(root.handoffPrompt) : defaultHandoffPrompt;
63
+ const newSessionPrompt = 'newSessionPrompt' in root ? parseNewSessionPrompt(root.newSessionPrompt) : defaultNewSessionPrompt;
64
+ const policy = Object.hasOwn(root, 'taskOfferPolicy') ? root.taskOfferPolicy : 'always';
65
+ if (!['always', 'github-remote', 'never'].includes(policy))
66
+ throw new Error('Invalid taskOfferPolicy in pi-note.json');
67
+ return { ...document, handoffPrompt, newSessionPrompt, policy };
68
+ }
69
+ async function snapshot(state, signal) {
70
+ const tasks = JSON.parse(await note(['list', '--json'], signal));
71
+ const task = await selected(state, signal);
72
+ const config = settings();
73
+ state = { ...state, selectedId: task?.id ?? null };
74
+ if (state.screen === 'field' && !fields(config.config, task?.agent_notes).some((f) => f.name === state.field))
75
+ state.screen = 'settings';
76
+ const revision = createHash('sha256').update(JSON.stringify({ state, tasks, task, raw: config.raw })).digest('hex');
77
+ return { state, tasks, task, config, revision };
78
+ }
79
+ function view(data) {
80
+ const { tasks, task, config, revision } = data;
81
+ const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
82
+ const controls = [];
83
+ const action = (id, label, extra = {}) => controls.push({ id, label: label.slice(0, 2000), kind: 'action', ...extra });
84
+ let title = 'Pinote · Tasks';
85
+ let body = task ? task.text : 'Select a task or create one. No task is selected for this conversation.';
86
+ action('screen:tasks', 'Tasks', { icon: icons.note });
87
+ action('screen:settings', 'Settings', { description: 'Global prompts, task offers, and footer fields' });
88
+ if (state.screen === 'settings') {
89
+ title = 'Pinote · Settings';
90
+ body = 'Personal pi-note.json · changes save after confirmation';
91
+ for (const [id, label, value] of [['handoffPrompt', 'Task prompt', config.handoffPrompt], ['newSessionPrompt', 'New session prompt', config.newSessionPrompt]]) {
92
+ controls.push({ id, label, value, kind: 'text', multiline: true, confirm: 'Save this global prompt?' });
93
+ }
94
+ 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?' });
95
+ for (const key of ['titleWidth', 'fieldWidth', 'maxFields'])
96
+ controls.push({ id: key, label: key, kind: 'number', value: config.config[key], confirm: 'Save this global footer setting?' });
97
+ controls.push({ id: 'add-field', label: 'Add field', icon: icons.add, kind: 'text', value: '', confirm: 'Add this global footer field?' });
98
+ fields(config.config, task?.agent_notes).forEach((field) => action(`field:${field.name}`, field.name, { description: `${field.label || '(no label)'} · ${field.format}` }));
99
+ }
100
+ else if (state.screen === 'field') {
101
+ const field = fields(config.config, task?.agent_notes).find((f) => f.name === state.field);
102
+ if (!field)
103
+ throw new Error('Field no longer exists; open Settings again');
104
+ title = `Pinote · ${field.name}`;
105
+ body = 'Global footer display definition; task values are unchanged.';
106
+ controls.push({ id: 'label', label: 'Label', kind: 'text', value: field.label, confirm: 'Save this global field label?' });
107
+ controls.push({ id: 'format', label: 'Format', kind: 'text', value: field.format, description: '<value>, <url>, <number>', confirm: 'Save this global field format?' });
108
+ controls.push({ id: 'width', label: 'Width', kind: 'number', value: field.width ?? config.config.fieldWidth, confirm: 'Save this global field width?' });
109
+ controls.push({ id: 'link', label: 'Link', kind: 'toggle', value: field.link, confirm: 'Change this global link setting?' });
110
+ action('remove-field', 'Remove field', { confirm: 'Remove this global display definition? Task values are retained.' });
111
+ }
112
+ else {
113
+ if (task) {
114
+ if (config.handoffPrompt.length <= 20_000)
115
+ action('continue', 'Continue', { description: 'Request insertion of the task prompt; never submit it' });
116
+ action('preview', 'Preview full task', { description: 'Read-only local preview; never sent to the model' });
117
+ action('done', 'Complete task', { icon: icons.check, confirm: 'Complete this task? This does not start a new conversation.' });
118
+ action('clear', 'Clear selection', { description: 'Keep the task and its progress unchanged' });
119
+ }
120
+ controls.push({ id: 'add', label: 'New task', icon: icons.add, kind: 'text', multiline: true, value: '', confirm: 'Create and select this task?' });
121
+ controls.push({ id: 'search', label: 'Filter tasks', kind: 'text', value: state.search ?? '' });
122
+ const matches = tasks.filter((t) => `${t.text} ${t.tag ?? ''}`.toLowerCase().includes((state.search ?? '').toLowerCase()));
123
+ for (const candidate of matches.slice(0, 200))
124
+ 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?' });
125
+ if (matches.length > 200)
126
+ body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
127
+ }
128
+ const footer = [{ text: `${icons.note} ${task ? task.text.split('\n')[0].slice(0, 2000) : 'Pinote · no task'}`, width: config.config.titleWidth }];
129
+ if (task) {
130
+ for (const field of fields(config.config, task.agent_notes)) {
131
+ if (footer.length - 1 >= config.config.maxFields)
132
+ break;
133
+ const raw = task.agent_notes[field.name];
134
+ if (!raw)
135
+ continue;
136
+ const link = /^\[([^\n]*)\]\((https?:\/\/[^\s]+)\)$/.exec(raw);
137
+ const url = link?.[2] ?? (/^https?:\/\/[^\s]+$/.test(raw) ? raw : undefined);
138
+ const value = link?.[1] ?? raw;
139
+ const number = url?.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)\/?$/)?.[1] ?? (/^\d+$/.test(value) ? value : undefined);
140
+ if (field.format.includes('<url>') && !url || field.format.includes('<number>') && !number)
141
+ continue;
142
+ const substitutions = { value, url: url ?? '', number: number ?? '' };
143
+ const rendered = ` · ${field.label ? `${field.label}: ` : ''}${field.format.replace(/<(value|url|number)>/g, (_match, key) => substitutions[key])}`;
144
+ footer.push({ text: rendered.slice(0, 20_000), ...(field.link && url && url.length <= 2048 ? { url } : {}), width: field.width ?? config.config.fieldWidth });
145
+ }
146
+ }
147
+ const editable = controls.filter((control) => {
148
+ if (typeof control.value !== 'string' || control.value.length <= 20_000)
149
+ return true;
150
+ body = `${control.label} exceeds the editor limit; edit it outside this UI.\n${body}`;
151
+ return false;
152
+ });
153
+ const result = { version: 1, title, body: body.slice(0, 20_000), revision, state, controls: editable, footer };
154
+ // JSON escaping and expanded footer formats can exceed the host budget even
155
+ // with bounded row counts. Preserve navigation/filter controls ahead of rows.
156
+ let shortened = false;
157
+ while (JSON.stringify(result).length > 240_000) {
158
+ const row = result.controls.map((control) => control.id.startsWith('select:')).lastIndexOf(true);
159
+ if (row >= 0) {
160
+ result.controls.splice(row, 1);
161
+ shortened = true;
162
+ }
163
+ else if (result.footer.length > 1)
164
+ result.footer.pop();
165
+ else
166
+ throw new Error('UI exceeds its display budget; shorten configuration values');
167
+ }
168
+ if (shortened)
169
+ result.body = `More matches omitted to fit the display budget; narrow Filter tasks.\n${result.body}`.slice(0, 20_000);
170
+ return result;
171
+ }
172
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [{ uri: URI, name: 'Pinote', mimeType: MIME }] }));
173
+ server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(async () => {
174
+ if (request.params.uri !== URI)
175
+ throw new Error('Unknown resource');
176
+ const data = await snapshot(context(request.params._meta).state, extra.signal);
177
+ return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data)) }] };
178
+ }));
179
+ server.setRequestHandler(Action, (request, extra) => serial(async () => {
180
+ const state = context(request.params._meta).state;
181
+ const before = await snapshot(state, extra.signal);
182
+ if (before.revision !== request.params.revision)
183
+ throw new Error('View changed; refresh before acting');
184
+ const { action, value } = request.params;
185
+ if (!view(before).controls.some((c) => c.id === action))
186
+ throw new Error('Action is not available in this view');
187
+ const next = { ...state };
188
+ let draft, preview;
189
+ if (action.startsWith('screen:'))
190
+ next.screen = action.slice(7);
191
+ else if (action.startsWith('field:')) {
192
+ next.screen = 'field';
193
+ next.field = action.slice(6);
194
+ }
195
+ else if (action === 'search')
196
+ next.search = z.string().max(256).parse(value);
197
+ else if (action === 'clear')
198
+ next.selectedId = null;
199
+ else if (action === 'continue')
200
+ draft = before.config.handoffPrompt;
201
+ else if (action === 'preview')
202
+ preview = (before.task.text + '\n\n' + JSON.stringify(before.task.agent_notes, null, 2)).slice(0, 20_000);
203
+ else if (action.startsWith('select:')) {
204
+ const id = Number(action.slice(7));
205
+ if (!before.tasks.some((t) => t.id === id))
206
+ throw new Error('Task no longer selectable');
207
+ await note(['start', String(id)], extra.signal);
208
+ next.selectedId = id;
209
+ }
210
+ else if (action === 'add') {
211
+ const task = JSON.parse(await note(['agent', 'add', `--text=${z.string().min(1).parse(value)}`], extra.signal));
212
+ await note(['start', String(task.id)], extra.signal);
213
+ next.selectedId = task.id;
214
+ }
215
+ else if (action === 'done') {
216
+ await note(['agent', 'done', String(before.task.id), '--expected-updated-at', before.task.updated_at], extra.signal);
217
+ next.selectedId = null;
218
+ }
219
+ else {
220
+ const cfg = structuredClone(before.config.config);
221
+ let handoff = before.config.handoffPrompt, newSession = before.config.newSessionPrompt, policy = before.config.policy;
222
+ if (action === 'handoffPrompt')
223
+ handoff = z.string().parse(value);
224
+ else if (action === 'newSessionPrompt')
225
+ newSession = z.string().parse(value);
226
+ else if (action === 'taskOfferPolicy')
227
+ policy = z.enum(['always', 'github-remote', 'never']).parse(value);
228
+ else if (['titleWidth', 'fieldWidth', 'maxFields'].includes(action))
229
+ cfg[action] = z.number().int().parse(value);
230
+ else {
231
+ cfg.fields = fields(cfg, before.task?.agent_notes);
232
+ if (action === 'add-field')
233
+ cfg.fields.push(defaultFooterField(z.string().parse(value)));
234
+ else if (action === 'remove-field') {
235
+ cfg.fields = cfg.fields.filter((f) => f.name !== state.field);
236
+ next.screen = 'settings';
237
+ }
238
+ else {
239
+ const field = cfg.fields.find((f) => f.name === state.field);
240
+ if (action === 'link')
241
+ field.link = z.boolean().parse(value);
242
+ else if (action === 'width')
243
+ field.width = z.number().int().parse(value);
244
+ else if (action === 'label' || action === 'format')
245
+ field[action] = z.string().parse(value);
246
+ else
247
+ 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', properties: {}, additionalProperties: false };
258
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
259
+ { 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 } },
260
+ { name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
261
+ { name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
262
+ { 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 } },
263
+ ] }));
264
+ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async () => {
265
+ try {
266
+ const state = context(request.params._meta).state;
267
+ let result;
268
+ if (request.params.name === 'get_current')
269
+ result = await selected(state, extra.signal);
270
+ else if (request.params.name === 'fields')
271
+ result = settings().config;
272
+ else if (request.params.name === 'tags')
273
+ result = JSON.parse(await note(['agent', 'tags'], extra.signal));
274
+ else if (request.params.name === 'update_current') {
275
+ 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);
276
+ const task = await selected(state, extra.signal);
277
+ if (!task)
278
+ throw new Error('No task is selected');
279
+ 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));
280
+ await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
281
+ }
282
+ else
283
+ throw new Error('Unknown tool');
284
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
285
+ }
286
+ catch (error) {
287
+ return { isError: true, content: [{ type: 'text', text: String(error) }] };
288
+ }
289
+ }));
290
+ 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/index.ts CHANGED
@@ -169,6 +169,7 @@ export default function (pi: ExtensionAPI) {
169
169
  let epoch = 0;
170
170
  let refreshSerial = 0;
171
171
  let pending: symbol | undefined;
172
+ let menuPending: symbol | undefined;
172
173
  let previewPending = false;
173
174
  let setupAbort: AbortController | undefined;
174
175
  let activeContext: ExtensionContext | undefined;
@@ -318,7 +319,7 @@ export default function (pi: ExtensionAPI) {
318
319
  }
319
320
  };
320
321
  const preview = async (ctx: ExtensionContext, requestSignal?: AbortSignal): Promise<boolean> => {
321
- if (!ctx.hasUI || ctx.mode !== "tui" || pending || previewPending || !validId(selectedId)) return false;
322
+ if (!ctx.hasUI || ctx.mode !== "tui" || pending || menuPending || previewPending || !validId(selectedId)) return false;
322
323
  const generation = epoch;
323
324
  const branch = branchEpoch;
324
325
  const id = selectedId;
@@ -346,7 +347,7 @@ export default function (pi: ExtensionAPI) {
346
347
  }
347
348
  };
348
349
  const dispatchCompletion = (ctx: ExtensionContext, displayed: Task, selection: string): Promise<boolean> => {
349
- if (!ctx.isIdle() || pending || completionDispatch) return Promise.resolve(false);
350
+ if (!ctx.isIdle() || pending || menuPending || completionDispatch) return Promise.resolve(false);
350
351
  const token = `${selection}:${randomBytes(8).toString("hex")}`;
351
352
  return new Promise((resolve) => {
352
353
  const finish = (accepted: boolean) => {
@@ -367,7 +368,7 @@ export default function (pi: ExtensionAPI) {
367
368
  const token = args.trim();
368
369
  const request = token ? completionDispatch : undefined;
369
370
  if (token && (request?.token !== token || request.selection !== `${runtimeToken}:${epoch}:${branchEpoch}:${selectedId}`)) return;
370
- if (!alive || !ctx.hasUI || ctx.mode !== "tui" || !ctx.isIdle() || pending || setupAbort || !validId(selectedId)) {
371
+ if (!alive || !ctx.hasUI || ctx.mode !== "tui" || !ctx.isIdle() || pending || menuPending || setupAbort || !validId(selectedId)) {
371
372
  request?.finish(false);
372
373
  if (ctx.hasUI) ctx.ui.notify("Select a task and wait until Pi is idle and the Pinote operation has finished.", "warning");
373
374
  return;
@@ -459,6 +460,7 @@ export default function (pi: ExtensionAPI) {
459
460
  if (!alive || generation !== epoch) return;
460
461
  // Keep the setup lock until its aborted subprocess has actually settled.
461
462
  if (!setupAbort) pending = undefined;
463
+ menuPending = undefined;
462
464
  await checkCLI(ctx, true);
463
465
  if (!alive || generation !== epoch) return;
464
466
  if (suggestion) previewBridge?.setSuggestion((choice) => respondToSuggestion(choice, ctx));
@@ -522,7 +524,7 @@ export default function (pi: ExtensionAPI) {
522
524
 
523
525
  const installCLI = async (upgrade: boolean, ctx: ExtensionCommandContext) => {
524
526
  if (!ctx.hasUI || ctx.mode !== "tui") return;
525
- if (pending || !ctx.isIdle()) {
527
+ if (pending || menuPending || !ctx.isIdle()) {
526
528
  ctx.ui.notify("Wait until Pi is idle and the pinote operation has finished.", "warning");
527
529
  return;
528
530
  }
@@ -563,16 +565,18 @@ export default function (pi: ExtensionAPI) {
563
565
  return;
564
566
  }
565
567
  if (token) { ctx.ui.notify("Usage: /pi-note", "warning"); return; }
566
- if (pending || !ctx.isIdle()) {
567
- ctx.ui.notify("Wait until Pi is idle and the pinote operation has finished.", "warning");
568
+ if (pending || menuPending) {
569
+ ctx.ui.notify("Wait until the pinote operation has finished.", "warning");
568
570
  return;
569
571
  }
570
572
  const operation = Symbol();
571
- pending = operation;
573
+ menuPending = operation;
572
574
  const generation = epoch;
573
575
  const branch = branchEpoch;
574
576
  const currentSession = () => alive && generation === epoch;
575
- const canAct = () => currentSession() && ctx.isIdle() && branch === branchEpoch;
577
+ // Menus, task selection and preference edits do not interrupt the agent.
578
+ // Completion and CLI setup retain their own idle-only guards.
579
+ const canAct = () => currentSession() && branch === branchEpoch && menuPending === operation;
576
580
  try {
577
581
  await checkCLI(ctx);
578
582
  if (!canAct()) return;
@@ -633,7 +637,7 @@ export default function (pi: ExtensionAPI) {
633
637
  if (!canAct() || edited === undefined) return;
634
638
  if (edited === "tasks") { settings = false; continue; }
635
639
  if (!options.some((option) => option.result === edited)) return;
636
- if (pending === operation) pending = undefined;
640
+ if (menuPending === operation) menuPending = undefined;
637
641
  if (edited === "preview") {
638
642
  if (!await preview(ctx)) ctx.ui.notify("The task is no longer available for preview.", "warning");
639
643
  } else if (edited === "done") await completeSelected("", ctx, current ?? undefined);
@@ -649,7 +653,7 @@ export default function (pi: ExtensionAPI) {
649
653
  if (!action || !canAct()) return;
650
654
  if (action === "Settings") { settings = true; continue; }
651
655
  if (action === "Done" && current) {
652
- if (pending === operation) pending = undefined;
656
+ if (menuPending === operation) menuPending = undefined;
653
657
  await completeSelected("", ctx, current);
654
658
  return;
655
659
  }
@@ -667,6 +671,11 @@ export default function (pi: ExtensionAPI) {
667
671
  id = choice;
668
672
  } else return;
669
673
  const handoff = handoffPrompt();
674
+ if (pending) {
675
+ ctx.ui.notify("Wait until the pinote operation has finished.", "warning");
676
+ return;
677
+ }
678
+ pending = operation;
670
679
  refreshSerial++;
671
680
  const chosen = await startTask(id, canAct);
672
681
  if (!chosen || !canAct()) return;
@@ -679,6 +688,7 @@ export default function (pi: ExtensionAPI) {
679
688
  if (currentSession()) ctx.ui.notify(`Pinote: ${clean(error instanceof Error ? error.message : String(error))}`, "error");
680
689
  } finally {
681
690
  if (pending === operation) pending = undefined;
691
+ if (menuPending === operation) menuPending = undefined;
682
692
  if (currentSession()) await refresh(ctx);
683
693
  }
684
694
  },
@@ -755,7 +765,7 @@ export default function (pi: ExtensionAPI) {
755
765
  clearSuggestion(ctx);
756
766
  return true;
757
767
  }
758
- if (pending || setupAbort) return false;
768
+ if (pending || menuPending || setupAbort) return false;
759
769
  const generation = epoch;
760
770
  const branch = branchEpoch;
761
771
  const operation = Symbol();
@@ -804,7 +814,7 @@ export default function (pi: ExtensionAPI) {
804
814
  const current = await selected(ctx, signal);
805
815
  if (!alive || generation !== epoch || branch !== branchEpoch) throw new Error("Pinote operation cancelled: the session changed.");
806
816
  if (current || selectedId !== null) throw new Error("A task is already selected; keep it unless the user asks to switch.");
807
- if (pending || setupAbort) throw new Error("A pinote operation is already open. Retry after it finishes.");
817
+ if (pending || menuPending || setupAbort) throw new Error("A pinote operation is already open. Retry after it finishes.");
808
818
  const result = (status: string) => ({ content: [{ type: "text" as const, text: JSON.stringify({ status }) }], details: { status } });
809
819
  suggestion = { text: params.text, ...(params.tag === undefined ? {} : { tag: params.tag.trim() }) };
810
820
  saveSuggestion();
@@ -834,7 +844,7 @@ export default function (pi: ExtensionAPI) {
834
844
  const generation = epoch;
835
845
  const branch = branchEpoch;
836
846
  if (params.select !== true) return toolResult(await writeTask(ctx, signal, argv, undefined, "0.4.0"));
837
- if (pending) throw new Error("A pinote operation is already open. Retry after it finishes.");
847
+ if (pending || menuPending) throw new Error("A pinote operation is already open. Retry after it finishes.");
838
848
  const operation = Symbol();
839
849
  const previousSelection = selectedId;
840
850
  pending = operation;
package/mcp/README.md ADDED
@@ -0,0 +1,95 @@
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.7.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 run `/gateway-ui pinote`.
36
+ Do not also load `pi/index.ts` for this workflow. Nothing is automatically
37
+ installed, activated, or migrated; keep the existing integration until you approve
38
+ the switch. To test `/reload`, reload only a disposable session/profile first.
39
+
40
+ The service shares the existing database and `pi-note.json`. `PINOTE_NOTE_COMMAND`
41
+ selects an executable, not a shell command. Without it, `note` must be on PATH.
42
+ Explicitly pass `PI_CODING_AGENT_DIR`, `XDG_DATA_HOME`, and `XDG_STATE_HOME` in service
43
+ `env` when using overrides or disposable data. Defaults otherwise refer to your
44
+ normal personal data. No desktop notifications are sent.
45
+
46
+ ## Use
47
+
48
+ - **Tasks:** filter, select/start, create/select, clear selection, complete, preview,
49
+ and request insertion of the task prompt. Selecting a task asks for confirmation.
50
+ - **Settings:** edit prompts, task-offer policy, footer limits, and field definitions
51
+ (label, link, format, width). Enter text as JSON strings (`\n` for line breaks)
52
+ so whitespace round-trips exactly. Saves are confirmed, validated, and atomic; concurrent
53
+ edits are rejected and unrelated settings are preserved. Invalid configuration
54
+ fails closed; repair it manually rather than replacing it with defaults.
55
+ - **Footer:** selected task plus configured fields; safe HTTP(S) links are clickable.
56
+ Menu actions are keyboard-accessible, not footer icon buttons.
57
+ - **Agent:** `get_current`, `update_current`, `fields`, and `tags` are available through
58
+ gateway tool discovery. They use the same host-supplied selection as the UI.
59
+ UI actions and settings writes are not advertised as model tools.
60
+
61
+ The host owns durable conversation/branch state; the service does not store a
62
+ second selection database. Reload/reconnect restores saved state, separate sessions
63
+ in one folder stay independent, and a new conversation starts unselected. Pi must
64
+ have saved the session for resume persistence. Existing extension selections are
65
+ not imported. Changing the gateway config path or service name starts a separate
66
+ selection namespace.
67
+
68
+ ## Initial-version limits
69
+
70
+ This is a working alternative, **not full parity with the old extension**. It does
71
+ not offer agent task suggestions, retag/edit existing tasks, install/upgrade the
72
+ CLI, automatically retain selection on `/new`, reset conversations on completion,
73
+ or provide clickable footer actions. Task-offer policy and new-session prompt can
74
+ be edited for compatibility with the existing extension, but this host does not
75
+ yet implement their automation. Continue requests a separately confirmed draft;
76
+ it never overwrites existing editor text or submits a prompt. Previews are plain
77
+ text/Markdown source, limited to 20,000 characters; the task list shows at most 200
78
+ filtered matches (fewer if the serialized display budget is reached). Use the CLI for full text and unsupported task operations.
79
+
80
+ Changes through this service notify the host. External CLI/GTK/config changes need
81
+ Ctrl+R or reopening the app. Failed actions are never retried automatically: a
82
+ write might have committed before an interruption. Read fresh state before retrying.
83
+ Trusted services/agents have ordinary OS permissions; UI-only routing is not a
84
+ secrecy or authorization boundary.
85
+
86
+ ## Validation
87
+
88
+ ```bash
89
+ npm --prefix pi test
90
+ ```
91
+
92
+ The MCP integration test runs the real stdio server and CLI against temporary
93
+ storage, including settings conflicts, host selection isolation, handoff updates,
94
+ completion, and reconnect. Visual validation must use Xvfb, private D-Bus, and
95
+ explicit temporary service data/settings paths.
package/mcp/server.ts ADDED
@@ -0,0 +1,231 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { readFileSync } from 'node:fs';
5
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
8
+ import { z } from 'zod';
9
+ import { defaultFooterField, defaultHandoffPrompt, defaultNewSessionPrompt, effectiveFooterFields, parseFooterConfig, parseHandoffPrompt, parseNewSessionPrompt, readFooterDocument, saveFooterConfig } from '../footer-config.ts';
10
+
11
+ const URI = 'pinote://ui';
12
+ const MIME = 'application/vnd.mcp-native-ui+json';
13
+ const CONTEXT = 'native-ui/context';
14
+ const exec = promisify(execFile);
15
+ const noteCommand = process.env.PINOTE_NOTE_COMMAND || 'note';
16
+ const icons = Object.fromEntries(['add', 'check', 'note'].map((name) => [name, readFileSync(new URL(`../icons/${name}.txt`, import.meta.url), 'utf8').trim()]));
17
+ const Context = z.object({ conversationId: z.string().min(1).max(256), state: z.object({
18
+ selectedId: z.number().int().positive().safe().nullable().optional(), screen: z.enum(['tasks', 'settings', 'field']).optional(),
19
+ field: z.string().refine((value) => [...value].length <= 64, 'Field name exceeds 64 code points').optional(), search: z.string().max(256).optional(),
20
+ }).default({}) });
21
+ type State = z.infer<typeof Context>['state'];
22
+ type Task = { id: number; text: string; state: string; tag: string | null; updated_at: string; agent_notes: Record<string, string> };
23
+ type Scalar = string | number | boolean | null;
24
+ 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 }[] };
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.19.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<T>(run: () => Promise<T>): Promise<T> {
33
+ const result = queue.then(run);
34
+ queue = result.then(() => {}, () => {});
35
+ return result;
36
+ }
37
+ function context(meta: Record<string, unknown> | undefined) { return Context.parse(meta?.[CONTEXT]); }
38
+ async function note(args: string[], signal: AbortSignal): Promise<string> {
39
+ signal.throwIfAborted();
40
+ const options = { signal, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 };
41
+ const version = (await exec(noteCommand, ['--version'], options)).stdout.trim();
42
+ const match = /^pinote (\d+)\.(\d+)\.(\d+)$/.exec(version);
43
+ if (!match || Number(match[1]) === 0 && Number(match[2]) < 4) throw new Error('Pinote MCP requires note 0.4.0+');
44
+ return (await exec(noteCommand, ['--no-notify', ...args], options)).stdout;
45
+ }
46
+ async function selected(state: State, signal: AbortSignal): Promise<Task | null> {
47
+ if (!state.selectedId) return null;
48
+ const task: Task | null = JSON.parse(await note(['agent', 'get', String(state.selectedId)], signal));
49
+ return task && ['active', 'in_progress'].includes(task.state) ? task : null;
50
+ }
51
+ function fields(config: ReturnType<typeof readFooterDocument>['config'], notes?: Record<string, string>) {
52
+ // Legacy Bar values are arbitrary task text, not validated field definitions.
53
+ return effectiveFooterFields(config, notes).flatMap((field) => {
54
+ try { return parseFooterConfig({ footer: { fields: [field] } }).fields!; }
55
+ catch { return []; }
56
+ }).slice(0, 64);
57
+ }
58
+ function settings() {
59
+ const document = readFooterDocument();
60
+ const root = document.raw === null ? {} : JSON.parse(document.raw);
61
+ const handoffPrompt = 'handoffPrompt' in root ? parseHandoffPrompt(root.handoffPrompt) : defaultHandoffPrompt;
62
+ const newSessionPrompt = 'newSessionPrompt' in root ? parseNewSessionPrompt(root.newSessionPrompt) : defaultNewSessionPrompt;
63
+ const policy = Object.hasOwn(root, 'taskOfferPolicy') ? root.taskOfferPolicy : 'always';
64
+ if (!['always', 'github-remote', 'never'].includes(policy)) throw new Error('Invalid taskOfferPolicy in pi-note.json');
65
+ return { ...document, handoffPrompt, newSessionPrompt, policy };
66
+ }
67
+ async function snapshot(state: State, signal: AbortSignal) {
68
+ const tasks: Task[] = JSON.parse(await note(['list', '--json'], signal));
69
+ const task = await selected(state, signal);
70
+ const config = settings();
71
+ state = { ...state, selectedId: task?.id ?? null };
72
+ if (state.screen === 'field' && !fields(config.config, task?.agent_notes).some((f) => f.name === state.field)) state.screen = 'settings';
73
+ const revision = createHash('sha256').update(JSON.stringify({ state, tasks, task, raw: config.raw })).digest('hex');
74
+ return { state, tasks, task, config, revision };
75
+ }
76
+ type Snapshot = Awaited<ReturnType<typeof snapshot>>;
77
+ function view(data: Snapshot) {
78
+ const { tasks, task, config, revision } = data;
79
+ const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
80
+ const controls: Control[] = [];
81
+ const action = (id: string, label: string, extra: Partial<Control> = {}) => controls.push({ id, label: label.slice(0, 2000), kind: 'action', ...extra });
82
+ let title = 'Pinote · Tasks';
83
+ let body = task ? task.text : 'Select a task or create one. No task is selected for this conversation.';
84
+ action('screen:tasks', 'Tasks', { icon: icons.note });
85
+ action('screen:settings', 'Settings', { description: 'Global prompts, task offers, and footer fields' });
86
+ if (state.screen === 'settings') {
87
+ title = 'Pinote · Settings'; body = 'Personal pi-note.json · changes save after confirmation';
88
+ for (const [id, label, value] of [['handoffPrompt', 'Task prompt', config.handoffPrompt], ['newSessionPrompt', 'New session prompt', config.newSessionPrompt]] as const) {
89
+ controls.push({ id, label, value, kind: 'text', multiline: true, confirm: 'Save this global prompt?' });
90
+ }
91
+ 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?' });
92
+ 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?' });
93
+ controls.push({ id: 'add-field', label: 'Add field', icon: icons.add, kind: 'text', value: '', confirm: 'Add this global footer field?' });
94
+ fields(config.config, task?.agent_notes).forEach((field) => action(`field:${field.name}`, field.name, { description: `${field.label || '(no label)'} · ${field.format}` }));
95
+ } else if (state.screen === 'field') {
96
+ const field = fields(config.config, task?.agent_notes).find((f) => f.name === state.field);
97
+ if (!field) throw new Error('Field no longer exists; open Settings again');
98
+ title = `Pinote · ${field.name}`; body = 'Global footer display definition; task values are unchanged.';
99
+ controls.push({ id: 'label', label: 'Label', kind: 'text', value: field.label, confirm: 'Save this global field label?' });
100
+ controls.push({ id: 'format', label: 'Format', kind: 'text', value: field.format, description: '<value>, <url>, <number>', confirm: 'Save this global field format?' });
101
+ controls.push({ id: 'width', label: 'Width', kind: 'number', value: field.width ?? config.config.fieldWidth, confirm: 'Save this global field width?' });
102
+ controls.push({ id: 'link', label: 'Link', kind: 'toggle', value: field.link, confirm: 'Change this global link setting?' });
103
+ action('remove-field', 'Remove field', { confirm: 'Remove this global display definition? Task values are retained.' });
104
+ } else {
105
+ if (task) {
106
+ if (config.handoffPrompt.length <= 20_000) action('continue', 'Continue', { description: 'Request insertion of the task prompt; never submit it' });
107
+ action('preview', 'Preview full task', { description: 'Read-only local preview; never sent to the model' });
108
+ action('done', 'Complete task', { icon: icons.check, confirm: 'Complete this task? This does not start a new conversation.' });
109
+ action('clear', 'Clear selection', { description: 'Keep the task and its progress unchanged' });
110
+ }
111
+ controls.push({ id: 'add', label: 'New task', icon: icons.add, kind: 'text', multiline: true, value: '', confirm: 'Create and select this task?' });
112
+ controls.push({ id: 'search', label: 'Filter tasks', kind: 'text', value: state.search ?? '' });
113
+ const matches = tasks.filter((t) => `${t.text} ${t.tag ?? ''}`.toLowerCase().includes((state.search ?? '').toLowerCase()));
114
+ 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?' });
115
+ if (matches.length > 200) body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
116
+ }
117
+ const footer: { text: string; url?: string; width?: number }[] = [{ text: `${icons.note} ${task ? task.text.split('\n')[0].slice(0, 2000) : 'Pinote · no task'}`, width: config.config.titleWidth }];
118
+ if (task) {
119
+ for (const field of fields(config.config, task.agent_notes)) {
120
+ if (footer.length - 1 >= config.config.maxFields) break;
121
+ const raw = task.agent_notes[field.name];
122
+ if (!raw) continue;
123
+ const link = /^\[([^\n]*)\]\((https?:\/\/[^\s]+)\)$/.exec(raw);
124
+ const url = link?.[2] ?? (/^https?:\/\/[^\s]+$/.test(raw) ? raw : undefined);
125
+ const value = link?.[1] ?? raw;
126
+ const number = url?.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)\/?$/)?.[1] ?? (/^\d+$/.test(value) ? value : undefined);
127
+ if (field.format.includes('<url>') && !url || field.format.includes('<number>') && !number) continue;
128
+ const substitutions: Record<string, string> = { value, url: url ?? '', number: number ?? '' };
129
+ const rendered = ` · ${field.label ? `${field.label}: ` : ''}${field.format.replace(/<(value|url|number)>/g, (_match, key: string) => substitutions[key])}`;
130
+ footer.push({ text: rendered.slice(0, 20_000), ...(field.link && url && url.length <= 2048 ? { url } : {}), width: field.width ?? config.config.fieldWidth });
131
+ }
132
+ }
133
+ const editable = controls.filter((control) => {
134
+ if (typeof control.value !== 'string' || control.value.length <= 20_000) return true;
135
+ body = `${control.label} exceeds the editor limit; edit it outside this UI.\n${body}`;
136
+ return false;
137
+ });
138
+ const result = { version: 1, title, body: body.slice(0, 20_000), revision, state, controls: editable, footer };
139
+ // JSON escaping and expanded footer formats can exceed the host budget even
140
+ // with bounded row counts. Preserve navigation/filter controls ahead of rows.
141
+ let shortened = false;
142
+ while (JSON.stringify(result).length > 240_000) {
143
+ const row = result.controls.map((control) => control.id.startsWith('select:')).lastIndexOf(true);
144
+ if (row >= 0) { result.controls.splice(row, 1); shortened = true; }
145
+ else if (result.footer.length > 1) result.footer.pop();
146
+ else throw new Error('UI exceeds its display budget; shorten configuration values');
147
+ }
148
+ if (shortened) result.body = `More matches omitted to fit the display budget; narrow Filter tasks.\n${result.body}`.slice(0, 20_000);
149
+ return result;
150
+ }
151
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [{ uri: URI, name: 'Pinote', mimeType: MIME }] }));
152
+ server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(async () => {
153
+ if (request.params.uri !== URI) throw new Error('Unknown resource');
154
+ const data = await snapshot(context(request.params._meta).state, extra.signal);
155
+ return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data)) }] };
156
+ }));
157
+ server.setRequestHandler(Action, (request, extra) => serial(async () => {
158
+ const state = context(request.params._meta).state;
159
+ const before = await snapshot(state, extra.signal);
160
+ if (before.revision !== request.params.revision) throw new Error('View changed; refresh before acting');
161
+ const { action, value } = request.params;
162
+ if (!view(before).controls.some((c) => c.id === action)) throw new Error('Action is not available in this view');
163
+ const next: State = { ...state };
164
+ let draft: string | undefined, preview: string | undefined;
165
+ if (action.startsWith('screen:')) next.screen = action.slice(7) as State['screen'];
166
+ else if (action.startsWith('field:')) { next.screen = 'field'; next.field = action.slice(6); }
167
+ else if (action === 'search') next.search = z.string().max(256).parse(value);
168
+ else if (action === 'clear') next.selectedId = null;
169
+ else if (action === 'continue') draft = before.config.handoffPrompt;
170
+ else if (action === 'preview') preview = (before.task!.text + '\n\n' + JSON.stringify(before.task!.agent_notes, null, 2)).slice(0, 20_000);
171
+ else if (action.startsWith('select:')) {
172
+ const id = Number(action.slice(7));
173
+ if (!before.tasks.some((t) => t.id === id)) throw new Error('Task no longer selectable');
174
+ await note(['start', String(id)], extra.signal); next.selectedId = id;
175
+ } else if (action === 'add') {
176
+ const task: Task = JSON.parse(await note(['agent', 'add', `--text=${z.string().min(1).parse(value)}`], extra.signal));
177
+ await note(['start', String(task.id)], extra.signal); next.selectedId = task.id;
178
+ } else if (action === 'done') {
179
+ await note(['agent', 'done', String(before.task!.id), '--expected-updated-at', before.task!.updated_at], extra.signal);
180
+ next.selectedId = null;
181
+ } else {
182
+ const cfg = structuredClone(before.config.config);
183
+ let handoff = before.config.handoffPrompt, newSession = before.config.newSessionPrompt, policy = before.config.policy;
184
+ if (action === 'handoffPrompt') handoff = z.string().parse(value);
185
+ else if (action === 'newSessionPrompt') newSession = z.string().parse(value);
186
+ else if (action === 'taskOfferPolicy') policy = z.enum(['always', 'github-remote', 'never']).parse(value);
187
+ else if (['titleWidth', 'fieldWidth', 'maxFields'].includes(action)) cfg[action as 'titleWidth'] = z.number().int().parse(value);
188
+ else {
189
+ cfg.fields = fields(cfg, before.task?.agent_notes);
190
+ if (action === 'add-field') cfg.fields.push(defaultFooterField(z.string().parse(value)));
191
+ else if (action === 'remove-field') { cfg.fields = cfg.fields.filter((f) => f.name !== state.field); next.screen = 'settings'; }
192
+ else {
193
+ const field = cfg.fields.find((f) => f.name === state.field)!;
194
+ if (action === 'link') field.link = z.boolean().parse(value);
195
+ else if (action === 'width') field.width = z.number().int().parse(value);
196
+ else if (action === 'label' || action === 'format') field[action] = z.string().parse(value);
197
+ else throw new Error('Unknown action');
198
+ }
199
+ }
200
+ extra.signal.throwIfAborted();
201
+ saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy);
202
+ }
203
+ const result = { view: view(await snapshot(next, extra.signal)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
204
+ await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
205
+ return result;
206
+ }));
207
+ const empty = { type: 'object' as const, properties: {}, additionalProperties: false };
208
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
209
+ { 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 } },
210
+ { name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
211
+ { name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
212
+ { 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 } },
213
+ ] }));
214
+ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async () => {
215
+ try {
216
+ const state = context(request.params._meta).state;
217
+ let result: unknown;
218
+ if (request.params.name === 'get_current') result = await selected(state, extra.signal);
219
+ else if (request.params.name === 'fields') result = settings().config;
220
+ else if (request.params.name === 'tags') result = JSON.parse(await note(['agent', 'tags'], extra.signal));
221
+ else if (request.params.name === 'update_current') {
222
+ 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);
223
+ const task = await selected(state, extra.signal);
224
+ if (!task) throw new Error('No task is selected');
225
+ 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));
226
+ await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
227
+ } else throw new Error('Unknown tool');
228
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
229
+ } catch (error) { return { isError: true, content: [{ type: 'text', text: String(error) }] }; }
230
+ }));
231
+ await server.connect(new StdioServerTransport());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.18.0",
3
+ "version": "0.19.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
  }