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 +10 -2
- package/config-path.ts +16 -0
- package/dist-mcp/config-path.js +19 -0
- package/dist-mcp/footer-config.js +167 -0
- package/dist-mcp/icons/add.txt +1 -0
- package/dist-mcp/icons/check.txt +1 -0
- package/dist-mcp/icons/cross.txt +1 -0
- package/dist-mcp/icons/note.txt +1 -0
- package/dist-mcp/mcp/server.js +290 -0
- package/footer-config.ts +4 -3
- package/index.ts +23 -13
- package/mcp/README.md +95 -0
- package/mcp/server.ts +231 -0
- package/package.json +12 -2
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
|
|
91
|
+
reads never lock out the agent's task updates. Settings also works during a running turn.
|
|
88
92
|
|
|
89
93
|

|
|
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
|
|
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 "
|
|
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 ||
|
|
567
|
-
ctx.ui.notify("Wait until
|
|
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
|
-
|
|
573
|
+
menuPending = operation;
|
|
572
574
|
const generation = epoch;
|
|
573
575
|
const branch = branchEpoch;
|
|
574
576
|
const currentSession = () => alive && generation === epoch;
|
|
575
|
-
|
|
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 (
|
|
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 (
|
|
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.
|
|
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
|
}
|