pi-note 0.19.0 → 0.20.1
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/dist-mcp/mcp/server.js +103 -16
- package/mcp/README.md +42 -13
- package/mcp/server.ts +75 -17
- package/package.json +1 -1
package/dist-mcp/mcp/server.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
2
5
|
import { execFile } from 'node:child_process';
|
|
3
6
|
import { promisify } from 'node:util';
|
|
4
7
|
import { readFileSync } from 'node:fs';
|
|
@@ -12,8 +15,10 @@ const MIME = 'application/vnd.mcp-native-ui+json';
|
|
|
12
15
|
const CONTEXT = 'native-ui/context';
|
|
13
16
|
const exec = promisify(execFile);
|
|
14
17
|
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
|
|
18
|
+
const icons = Object.fromEntries(['add', 'check', 'cross', 'note'].map((name) => [name, readFileSync(new URL(`../icons/${name}.txt`, import.meta.url), 'utf8').trim()]));
|
|
19
|
+
const Proposal = z.object({ id: z.string().uuid(), text: z.string().min(1).max(4000), tag: z.string().trim().min(1).max(64).optional() }).strict();
|
|
20
|
+
const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), state: z.object({
|
|
21
|
+
proposal: Proposal.nullable().optional(),
|
|
17
22
|
selectedId: z.number().int().positive().safe().nullable().optional(), screen: z.enum(['tasks', 'settings', 'field']).optional(),
|
|
18
23
|
field: z.string().refine((value) => [...value].length <= 64, 'Field name exceeds 64 code points').optional(), search: z.string().max(256).optional(),
|
|
19
24
|
}).default({}) });
|
|
@@ -21,7 +26,7 @@ const Action = z.object({ method: z.literal('native-ui/action'), params: z.objec
|
|
|
21
26
|
uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
|
|
22
27
|
value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
|
|
23
28
|
}) });
|
|
24
|
-
const server = new Server({ name: 'pinote', version: '0.
|
|
29
|
+
const server = new Server({ name: 'pinote', version: '0.20.1' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
|
|
25
30
|
// Serialize complete read/check/write cycles even when a host sends parallel calls.
|
|
26
31
|
let queue = Promise.resolve();
|
|
27
32
|
function serial(run) {
|
|
@@ -30,6 +35,39 @@ function serial(run) {
|
|
|
30
35
|
return result;
|
|
31
36
|
}
|
|
32
37
|
function context(meta) { return Context.parse(meta?.[CONTEXT]); }
|
|
38
|
+
// Consent receipts are not selection storage. Tombstones prevent old branch state
|
|
39
|
+
// or a reconnect from creating the same proposed task twice, even after a crash.
|
|
40
|
+
const receiptDir = join(process.env.XDG_STATE_HOME || join(homedir(), '.local/state'), 'pinote/mcp-proposals');
|
|
41
|
+
async function consumed(id) {
|
|
42
|
+
try {
|
|
43
|
+
await fs.lstat(join(receiptDir, id));
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
if (error.code === 'ENOENT')
|
|
48
|
+
return false;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function consume(id) {
|
|
53
|
+
await fs.mkdir(receiptDir, { recursive: true, mode: 0o700 });
|
|
54
|
+
const stat = await fs.lstat(receiptDir);
|
|
55
|
+
if (!stat.isDirectory() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o700)
|
|
56
|
+
throw new Error('Unsafe proposal receipt directory');
|
|
57
|
+
const file = await fs.open(join(receiptDir, id), 'wx', 0o600);
|
|
58
|
+
try {
|
|
59
|
+
await file.writeFile('consumed\n');
|
|
60
|
+
await file.sync();
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await file.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function boundedState(state) {
|
|
67
|
+
if (JSON.stringify(state).length > 8192)
|
|
68
|
+
throw new Error('Proposal exceeds the host state budget; shorten it');
|
|
69
|
+
return state;
|
|
70
|
+
}
|
|
33
71
|
async function note(args, signal) {
|
|
34
72
|
signal.throwIfAborted();
|
|
35
73
|
const options = { signal, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 };
|
|
@@ -70,9 +108,10 @@ async function snapshot(state, signal) {
|
|
|
70
108
|
const tasks = JSON.parse(await note(['list', '--json'], signal));
|
|
71
109
|
const task = await selected(state, signal);
|
|
72
110
|
const config = settings();
|
|
73
|
-
state = { ...state, selectedId: task?.id ?? null };
|
|
111
|
+
state = { ...state, selectedId: task?.id ?? null, proposal: task || state.proposal && await consumed(state.proposal.id) ? null : state.proposal ?? null };
|
|
74
112
|
if (state.screen === 'field' && !fields(config.config, task?.agent_notes).some((f) => f.name === state.field))
|
|
75
113
|
state.screen = 'settings';
|
|
114
|
+
state = Context.parse({ conversationId: 'canonical', state }).state;
|
|
76
115
|
const revision = createHash('sha256').update(JSON.stringify({ state, tasks, task, raw: config.raw })).digest('hex');
|
|
77
116
|
return { state, tasks, task, config, revision };
|
|
78
117
|
}
|
|
@@ -83,6 +122,15 @@ function view(data) {
|
|
|
83
122
|
const action = (id, label, extra = {}) => controls.push({ id, label: label.slice(0, 2000), kind: 'action', ...extra });
|
|
84
123
|
let title = 'Pinote · Tasks';
|
|
85
124
|
let body = task ? task.text : 'Select a task or create one. No task is selected for this conversation.';
|
|
125
|
+
// Footer actions remain valid on every screen, including Settings.
|
|
126
|
+
if (task) {
|
|
127
|
+
action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
|
|
128
|
+
action('done', 'Complete task', { icon: icons.check });
|
|
129
|
+
}
|
|
130
|
+
else if (state.proposal) {
|
|
131
|
+
action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: state.proposal.text });
|
|
132
|
+
action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
|
|
133
|
+
}
|
|
86
134
|
action('screen:tasks', 'Tasks', { icon: icons.note });
|
|
87
135
|
action('screen:settings', 'Settings', { description: 'Global prompts, task offers, and footer fields' });
|
|
88
136
|
if (state.screen === 'settings') {
|
|
@@ -113,8 +161,6 @@ function view(data) {
|
|
|
113
161
|
if (task) {
|
|
114
162
|
if (config.handoffPrompt.length <= 20_000)
|
|
115
163
|
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
164
|
action('clear', 'Clear selection', { description: 'Keep the task and its progress unchanged' });
|
|
119
165
|
}
|
|
120
166
|
controls.push({ id: 'add', label: 'New task', icon: icons.add, kind: 'text', multiline: true, value: '', confirm: 'Create and select this task?' });
|
|
@@ -125,10 +171,21 @@ function view(data) {
|
|
|
125
171
|
if (matches.length > 200)
|
|
126
172
|
body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
|
|
127
173
|
}
|
|
128
|
-
const footer = [{ text: `${icons.note}
|
|
174
|
+
const footer = [{ text: `${icons.note} `, width: 3 }];
|
|
175
|
+
if (task) {
|
|
176
|
+
footer.push({ text: `${icons.check} `, action: 'done', width: 3 });
|
|
177
|
+
footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', width: config.config.titleWidth });
|
|
178
|
+
}
|
|
179
|
+
else if (state.proposal) {
|
|
180
|
+
footer.push({ text: `${icons.cross} `, action: 'proposal:dismiss', width: 3 }, { text: `${icons.add} `, action: 'proposal:accept', width: 3 });
|
|
181
|
+
footer.push({ text: `${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, width: config.config.titleWidth });
|
|
182
|
+
}
|
|
183
|
+
else
|
|
184
|
+
footer.push({ text: 'Pinote · no task', width: config.config.titleWidth });
|
|
185
|
+
const titleParts = footer.length;
|
|
129
186
|
if (task) {
|
|
130
187
|
for (const field of fields(config.config, task.agent_notes)) {
|
|
131
|
-
if (footer.length -
|
|
188
|
+
if (footer.length - titleParts >= config.config.maxFields)
|
|
132
189
|
break;
|
|
133
190
|
const raw = task.agent_notes[field.name];
|
|
134
191
|
if (!raw)
|
|
@@ -150,7 +207,7 @@ function view(data) {
|
|
|
150
207
|
body = `${control.label} exceeds the editor limit; edit it outside this UI.\n${body}`;
|
|
151
208
|
return false;
|
|
152
209
|
});
|
|
153
|
-
const result = { version: 1, title, body: body.slice(0, 20_000), revision, state, controls: editable, footer };
|
|
210
|
+
const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer };
|
|
154
211
|
// JSON escaping and expanded footer formats can exceed the host budget even
|
|
155
212
|
// with bounded row counts. Preserve navigation/filter controls ahead of rows.
|
|
156
213
|
let shortened = false;
|
|
@@ -184,9 +241,21 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
184
241
|
const { action, value } = request.params;
|
|
185
242
|
if (!view(before).controls.some((c) => c.id === action))
|
|
186
243
|
throw new Error('Action is not available in this view');
|
|
187
|
-
const next = { ...state };
|
|
244
|
+
const next = { ...before.state };
|
|
188
245
|
let draft, preview;
|
|
189
|
-
if (action
|
|
246
|
+
if (action === 'proposal:accept' || action === 'proposal:dismiss') {
|
|
247
|
+
const proposal = before.state.proposal;
|
|
248
|
+
if (!proposal || before.task)
|
|
249
|
+
throw new Error('Suggestion no longer available');
|
|
250
|
+
await consume(proposal.id); // Fail closed before any task mutation; never replay uncertain creation.
|
|
251
|
+
next.proposal = null;
|
|
252
|
+
if (action === 'proposal:accept') {
|
|
253
|
+
const task = JSON.parse(await note(['agent', 'add', `--text=${proposal.text}`, ...(proposal.tag ? [`--tag=${proposal.tag}`] : [])], extra.signal));
|
|
254
|
+
await note(['start', String(task.id)], extra.signal);
|
|
255
|
+
next.selectedId = task.id;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
else if (action.startsWith('screen:'))
|
|
190
259
|
next.screen = action.slice(7);
|
|
191
260
|
else if (action.startsWith('field:')) {
|
|
192
261
|
next.screen = 'field';
|
|
@@ -204,10 +273,16 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
204
273
|
const id = Number(action.slice(7));
|
|
205
274
|
if (!before.tasks.some((t) => t.id === id))
|
|
206
275
|
throw new Error('Task no longer selectable');
|
|
276
|
+
if (next.proposal)
|
|
277
|
+
await consume(next.proposal.id);
|
|
207
278
|
await note(['start', String(id)], extra.signal);
|
|
208
279
|
next.selectedId = id;
|
|
280
|
+
next.proposal = null;
|
|
209
281
|
}
|
|
210
282
|
else if (action === 'add') {
|
|
283
|
+
if (next.proposal)
|
|
284
|
+
await consume(next.proposal.id);
|
|
285
|
+
next.proposal = null;
|
|
211
286
|
const task = JSON.parse(await note(['agent', 'add', `--text=${z.string().min(1).parse(value)}`], extra.signal));
|
|
212
287
|
await note(['start', String(task.id)], extra.signal);
|
|
213
288
|
next.selectedId = task.id;
|
|
@@ -256,7 +331,8 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
256
331
|
}));
|
|
257
332
|
const empty = { type: 'object', properties: {}, additionalProperties: false };
|
|
258
333
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
|
|
259
|
-
{ name: '
|
|
334
|
+
{ name: 'propose', description: 'Before multi-step implementation or configuration migrations, suggest a task if get_current returns null; skip informational questions and trivial edits. Show a short action-oriented title, optional details after a blank line, and reuse a saved tag from tags. The user clicks + to create/start/select or dismiss. Replaces an unaccepted suggestion; continue working without waiting or asking again. Never creates a task before a user action.', inputSchema: { type: 'object', properties: { text: { type: 'string', minLength: 1, maxLength: 4000 }, tag: { type: 'string', minLength: 1, maxLength: 64 } }, required: ['text'], additionalProperties: false } },
|
|
335
|
+
{ name: 'get_current', description: 'Read the task selected through the native Pinote UI for this conversation before multi-step implementation or configuration migrations. Returns null if unselected; then use tags and propose to suggest a task without creating one. Skip informational questions and trivial edits; never replace an existing selection.', inputSchema: empty, annotations: { readOnlyHint: true } },
|
|
260
336
|
{ name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
|
|
261
337
|
{ name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
|
|
262
338
|
{ name: 'update_current', description: 'Merge Markdown handoff fields on the selected task. Read get_current first. Keep handoff notes concise; PR is one GitHub PR URL.', inputSchema: { type: 'object', properties: { expected_updated_at: { type: 'string' }, set: { type: 'object', additionalProperties: { type: 'string' } }, remove: { type: 'array', items: { type: 'string' } } }, required: ['expected_updated_at'], additionalProperties: false } },
|
|
@@ -264,8 +340,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
|
|
|
264
340
|
server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async () => {
|
|
265
341
|
try {
|
|
266
342
|
const state = context(request.params._meta).state;
|
|
267
|
-
let result;
|
|
268
|
-
if (request.params.name === '
|
|
343
|
+
let result, outgoing;
|
|
344
|
+
if (request.params.name === 'propose') {
|
|
345
|
+
if (await selected(state, extra.signal))
|
|
346
|
+
throw new Error('A task is already selected');
|
|
347
|
+
const args = Proposal.omit({ id: true }).parse(request.params.arguments);
|
|
348
|
+
if (!args.text.trim())
|
|
349
|
+
throw new Error('Task text must not be blank');
|
|
350
|
+
outgoing = boundedState({ ...state, selectedId: null, proposal: { ...args, id: randomUUID() } });
|
|
351
|
+
if (state.proposal && !await consumed(state.proposal.id))
|
|
352
|
+
await consume(state.proposal.id);
|
|
353
|
+
result = { status: 'Suggested; user can accept (+) or dismiss. Continue working without asking again.' };
|
|
354
|
+
}
|
|
355
|
+
else if (request.params.name === 'get_current')
|
|
269
356
|
result = await selected(state, extra.signal);
|
|
270
357
|
else if (request.params.name === 'fields')
|
|
271
358
|
result = settings().config;
|
|
@@ -281,7 +368,7 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
|
|
|
281
368
|
}
|
|
282
369
|
else
|
|
283
370
|
throw new Error('Unknown tool');
|
|
284
|
-
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
371
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(outgoing ? { _meta: { 'native-ui/state': outgoing } } : {}) };
|
|
285
372
|
}
|
|
286
373
|
catch (error) {
|
|
287
374
|
return { isError: true, content: [{ type: 'text', text: String(error) }] };
|
package/mcp/README.md
CHANGED
|
@@ -6,7 +6,7 @@ the generic [gateway UI host](https://github.com/kvidzibo/mcp-session-gateway)
|
|
|
6
6
|
only renders and routes them. No model turn is needed for UI actions.
|
|
7
7
|
|
|
8
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.
|
|
9
|
+
the built-in Pi MCP resource renderer. Use gateway 0.12.0+ with its Pi adapter.
|
|
10
10
|
|
|
11
11
|
## Setup
|
|
12
12
|
|
|
@@ -32,7 +32,8 @@ Add a service to the gateway config (use absolute paths):
|
|
|
32
32
|
}
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Then launch the gateway's generic Pi adapter and
|
|
35
|
+
Then launch the gateway's generic Pi adapter and open
|
|
36
|
+
`/gateway` → Services → pinote → Open app.
|
|
36
37
|
Do not also load `pi/index.ts` for this workflow. Nothing is automatically
|
|
37
38
|
installed, activated, or migrated; keep the existing integration until you approve
|
|
38
39
|
the switch. To test `/reload`, reload only a disposable session/profile first.
|
|
@@ -53,30 +54,58 @@ normal personal data. No desktop notifications are sent.
|
|
|
53
54
|
edits are rejected and unrelated settings are preserved. Invalid configuration
|
|
54
55
|
fails closed; repair it manually rather than replacing it with defaults.
|
|
55
56
|
- **Footer:** selected task plus configured fields; safe HTTP(S) links are clickable.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
The title previews the task; **✔** completes it and clears the selection without
|
|
58
|
+
resetting the conversation or editor. Suggestions show **✖ / ✚** to dismiss or
|
|
59
|
+
create/start/select. These use the gateway's generic footer actions, not a Pinote
|
|
60
|
+
extension or private handler. Install the gateway's Kitty protocol handler as
|
|
61
|
+
described in its native UI documentation; app menus remain the keyboard fallback.
|
|
62
|
+
- **Agent:** `propose`, `get_current`, `update_current`, `fields`, and `tags` are
|
|
63
|
+
available through gateway tool discovery. `propose` returns opaque UI state;
|
|
64
|
+
it does not create a task. It replaces an unaccepted suggestion only when no task
|
|
65
|
+
is selected. Text is limited to 4,000 characters and the host's 8 KiB state budget.
|
|
66
|
+
Acceptance/dismissal consumes a durable receipt before mutation, so old links,
|
|
67
|
+
branch restores and reconnects cannot replay consent. An interrupted acceptance
|
|
68
|
+
may have created a task: inspect the task list, never blindly propose it again. They use the same host-supplied selection as the UI.
|
|
59
69
|
UI actions and settings writes are not advertised as model tools.
|
|
60
70
|
|
|
61
71
|
The host owns durable conversation/branch state; the service does not store a
|
|
62
72
|
second selection database. Reload/reconnect restores saved state, separate sessions
|
|
63
|
-
in one folder stay independent, and
|
|
73
|
+
in one folder stay independent, and `/new` retains the current selection or pending
|
|
74
|
+
suggestion. An unrelated fresh launch starts unselected. The stable service
|
|
75
|
+
context is separate from both the Pi conversation ID and gateway process UUID. Pi must
|
|
64
76
|
have saved the session for resume persistence. Existing extension selections are
|
|
65
77
|
not imported. Changing the gateway config path or service name starts a separate
|
|
66
78
|
selection namespace.
|
|
67
79
|
|
|
80
|
+
## Task-offer guidance
|
|
81
|
+
|
|
82
|
+
Before multi-step implementation or configuration migrations, agents should call
|
|
83
|
+
`get_current`. If it returns null, use `tags` and `propose` to suggest a short task
|
|
84
|
+
with a saved tag, then continue without waiting. Skip informational questions and
|
|
85
|
+
trivial edits. Never replace an existing selection or create/select without consent.
|
|
86
|
+
|
|
87
|
+
The tool descriptions reinforce this workflow after discovery. For proactive
|
|
88
|
+
discovery, include the same guidance in the client's always-loaded agent rules;
|
|
89
|
+
tool descriptions alone cannot make an agent discover the service.
|
|
90
|
+
|
|
68
91
|
## Initial-version limits
|
|
69
92
|
|
|
70
93
|
This is a working alternative, **not full parity with the old extension**. It does
|
|
71
|
-
not
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
it never overwrites existing editor text or submits a prompt. Previews are plain
|
|
77
|
-
text/Markdown source
|
|
94
|
+
not retag/edit existing tasks or install/upgrade the CLI. Completion intentionally
|
|
95
|
+
does not reset conversations. The legacy `taskOfferPolicy` and new-session prompt
|
|
96
|
+
remain editable for native-extension compatibility; the MCP host does not apply
|
|
97
|
+
them automatically. Agent-side task offers follow the client rules and tool
|
|
98
|
+
descriptions above. Continue requests a separately confirmed draft;
|
|
99
|
+
it never overwrites existing editor text or submits a prompt. Previews are display-only transcript entries (never model context), showing plain
|
|
100
|
+
text/Markdown source limited to 20,000 characters; the task list shows at most 200
|
|
78
101
|
filtered matches (fewer if the serialized display budget is reached). Use the CLI for full text and unsupported task operations.
|
|
79
102
|
|
|
103
|
+
Consent receipts live under `$XDG_STATE_HOME/pinote/mcp-proposals` (default
|
|
104
|
+
`~/.local/state/pinote/mcp-proposals`). They contain no task content and are retained
|
|
105
|
+
to reject historical proposals. Do not delete them while old sessions may resume.
|
|
106
|
+
They are replay protection, not a second selection database or an authorization
|
|
107
|
+
boundary against local processes.
|
|
108
|
+
|
|
80
109
|
Changes through this service notify the host. External CLI/GTK/config changes need
|
|
81
110
|
Ctrl+R or reopening the app. Failed actions are never retried automatically: a
|
|
82
111
|
write might have committed before an interruption. Read fresh state before retrying.
|
package/mcp/server.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
2
5
|
import { execFile } from 'node:child_process';
|
|
3
6
|
import { promisify } from 'node:util';
|
|
4
7
|
import { readFileSync } from 'node:fs';
|
|
@@ -13,8 +16,10 @@ const MIME = 'application/vnd.mcp-native-ui+json';
|
|
|
13
16
|
const CONTEXT = 'native-ui/context';
|
|
14
17
|
const exec = promisify(execFile);
|
|
15
18
|
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
|
|
19
|
+
const icons = Object.fromEntries(['add', 'check', 'cross', 'note'].map((name) => [name, readFileSync(new URL(`../icons/${name}.txt`, import.meta.url), 'utf8').trim()]));
|
|
20
|
+
const Proposal = z.object({ id: z.string().uuid(), text: z.string().min(1).max(4000), tag: z.string().trim().min(1).max(64).optional() }).strict();
|
|
21
|
+
const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), state: z.object({
|
|
22
|
+
proposal: Proposal.nullable().optional(),
|
|
18
23
|
selectedId: z.number().int().positive().safe().nullable().optional(), screen: z.enum(['tasks', 'settings', 'field']).optional(),
|
|
19
24
|
field: z.string().refine((value) => [...value].length <= 64, 'Field name exceeds 64 code points').optional(), search: z.string().max(256).optional(),
|
|
20
25
|
}).default({}) });
|
|
@@ -26,7 +31,7 @@ const Action = z.object({ method: z.literal('native-ui/action'), params: z.objec
|
|
|
26
31
|
uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
|
|
27
32
|
value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
|
|
28
33
|
}) });
|
|
29
|
-
const server = new Server({ name: 'pinote', version: '0.
|
|
34
|
+
const server = new Server({ name: 'pinote', version: '0.20.1' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
|
|
30
35
|
// Serialize complete read/check/write cycles even when a host sends parallel calls.
|
|
31
36
|
let queue = Promise.resolve();
|
|
32
37
|
function serial<T>(run: () => Promise<T>): Promise<T> {
|
|
@@ -35,6 +40,24 @@ function serial<T>(run: () => Promise<T>): Promise<T> {
|
|
|
35
40
|
return result;
|
|
36
41
|
}
|
|
37
42
|
function context(meta: Record<string, unknown> | undefined) { return Context.parse(meta?.[CONTEXT]); }
|
|
43
|
+
// Consent receipts are not selection storage. Tombstones prevent old branch state
|
|
44
|
+
// or a reconnect from creating the same proposed task twice, even after a crash.
|
|
45
|
+
const receiptDir = join(process.env.XDG_STATE_HOME || join(homedir(), '.local/state'), 'pinote/mcp-proposals');
|
|
46
|
+
async function consumed(id: string): Promise<boolean> {
|
|
47
|
+
try { await fs.lstat(join(receiptDir, id)); return true; }
|
|
48
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; throw error; }
|
|
49
|
+
}
|
|
50
|
+
async function consume(id: string): Promise<void> {
|
|
51
|
+
await fs.mkdir(receiptDir, { recursive: true, mode: 0o700 });
|
|
52
|
+
const stat = await fs.lstat(receiptDir);
|
|
53
|
+
if (!stat.isDirectory() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o700) throw new Error('Unsafe proposal receipt directory');
|
|
54
|
+
const file = await fs.open(join(receiptDir, id), 'wx', 0o600);
|
|
55
|
+
try { await file.writeFile('consumed\n'); await file.sync(); } finally { await file.close(); }
|
|
56
|
+
}
|
|
57
|
+
function boundedState(state: State): State {
|
|
58
|
+
if (JSON.stringify(state).length > 8192) throw new Error('Proposal exceeds the host state budget; shorten it');
|
|
59
|
+
return state;
|
|
60
|
+
}
|
|
38
61
|
async function note(args: string[], signal: AbortSignal): Promise<string> {
|
|
39
62
|
signal.throwIfAborted();
|
|
40
63
|
const options = { signal, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 };
|
|
@@ -68,8 +91,9 @@ async function snapshot(state: State, signal: AbortSignal) {
|
|
|
68
91
|
const tasks: Task[] = JSON.parse(await note(['list', '--json'], signal));
|
|
69
92
|
const task = await selected(state, signal);
|
|
70
93
|
const config = settings();
|
|
71
|
-
state = { ...state, selectedId: task?.id ?? null };
|
|
94
|
+
state = { ...state, selectedId: task?.id ?? null, proposal: task || state.proposal && await consumed(state.proposal.id) ? null : state.proposal ?? null };
|
|
72
95
|
if (state.screen === 'field' && !fields(config.config, task?.agent_notes).some((f) => f.name === state.field)) state.screen = 'settings';
|
|
96
|
+
state = Context.parse({ conversationId: 'canonical', state }).state;
|
|
73
97
|
const revision = createHash('sha256').update(JSON.stringify({ state, tasks, task, raw: config.raw })).digest('hex');
|
|
74
98
|
return { state, tasks, task, config, revision };
|
|
75
99
|
}
|
|
@@ -81,6 +105,14 @@ function view(data: Snapshot) {
|
|
|
81
105
|
const action = (id: string, label: string, extra: Partial<Control> = {}) => controls.push({ id, label: label.slice(0, 2000), kind: 'action', ...extra });
|
|
82
106
|
let title = 'Pinote · Tasks';
|
|
83
107
|
let body = task ? task.text : 'Select a task or create one. No task is selected for this conversation.';
|
|
108
|
+
// Footer actions remain valid on every screen, including Settings.
|
|
109
|
+
if (task) {
|
|
110
|
+
action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
|
|
111
|
+
action('done', 'Complete task', { icon: icons.check });
|
|
112
|
+
} else if (state.proposal) {
|
|
113
|
+
action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: state.proposal.text });
|
|
114
|
+
action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
|
|
115
|
+
}
|
|
84
116
|
action('screen:tasks', 'Tasks', { icon: icons.note });
|
|
85
117
|
action('screen:settings', 'Settings', { description: 'Global prompts, task offers, and footer fields' });
|
|
86
118
|
if (state.screen === 'settings') {
|
|
@@ -104,8 +136,6 @@ function view(data: Snapshot) {
|
|
|
104
136
|
} else {
|
|
105
137
|
if (task) {
|
|
106
138
|
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
139
|
action('clear', 'Clear selection', { description: 'Keep the task and its progress unchanged' });
|
|
110
140
|
}
|
|
111
141
|
controls.push({ id: 'add', label: 'New task', icon: icons.add, kind: 'text', multiline: true, value: '', confirm: 'Create and select this task?' });
|
|
@@ -114,10 +144,18 @@ function view(data: Snapshot) {
|
|
|
114
144
|
for (const candidate of matches.slice(0, 200)) action(`select:${candidate.id}`, `${candidate.tag ? `[${candidate.tag}] ` : ''}${candidate.text.split('\n')[0]}`, { description: `#${candidate.id} · ${candidate.state}`, confirm: 'Select and start this task for this conversation?' });
|
|
115
145
|
if (matches.length > 200) body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
|
|
116
146
|
}
|
|
117
|
-
const footer: { text: string; url?: string; width?: number }[] = [{ text: `${icons.note}
|
|
147
|
+
const footer: { text: string; url?: string; action?: string; width?: number }[] = [{ text: `${icons.note} `, width: 3 }];
|
|
148
|
+
if (task) {
|
|
149
|
+
footer.push({ text: `${icons.check} `, action: 'done', width: 3 });
|
|
150
|
+
footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', width: config.config.titleWidth });
|
|
151
|
+
} else if (state.proposal) {
|
|
152
|
+
footer.push({ text: `${icons.cross} `, action: 'proposal:dismiss', width: 3 }, { text: `${icons.add} `, action: 'proposal:accept', width: 3 });
|
|
153
|
+
footer.push({ text: `${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, width: config.config.titleWidth });
|
|
154
|
+
} else footer.push({ text: 'Pinote · no task', width: config.config.titleWidth });
|
|
155
|
+
const titleParts = footer.length;
|
|
118
156
|
if (task) {
|
|
119
157
|
for (const field of fields(config.config, task.agent_notes)) {
|
|
120
|
-
if (footer.length -
|
|
158
|
+
if (footer.length - titleParts >= config.config.maxFields) break;
|
|
121
159
|
const raw = task.agent_notes[field.name];
|
|
122
160
|
if (!raw) continue;
|
|
123
161
|
const link = /^\[([^\n]*)\]\((https?:\/\/[^\s]+)\)$/.exec(raw);
|
|
@@ -135,7 +173,7 @@ function view(data: Snapshot) {
|
|
|
135
173
|
body = `${control.label} exceeds the editor limit; edit it outside this UI.\n${body}`;
|
|
136
174
|
return false;
|
|
137
175
|
});
|
|
138
|
-
const result = { version: 1, title, body: body.slice(0, 20_000), revision, state, controls: editable, footer };
|
|
176
|
+
const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer };
|
|
139
177
|
// JSON escaping and expanded footer formats can exceed the host budget even
|
|
140
178
|
// with bounded row counts. Preserve navigation/filter controls ahead of rows.
|
|
141
179
|
let shortened = false;
|
|
@@ -160,9 +198,18 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
160
198
|
if (before.revision !== request.params.revision) throw new Error('View changed; refresh before acting');
|
|
161
199
|
const { action, value } = request.params;
|
|
162
200
|
if (!view(before).controls.some((c) => c.id === action)) throw new Error('Action is not available in this view');
|
|
163
|
-
const next: State = { ...state };
|
|
201
|
+
const next: State = { ...before.state };
|
|
164
202
|
let draft: string | undefined, preview: string | undefined;
|
|
165
|
-
if (action
|
|
203
|
+
if (action === 'proposal:accept' || action === 'proposal:dismiss') {
|
|
204
|
+
const proposal = before.state.proposal;
|
|
205
|
+
if (!proposal || before.task) throw new Error('Suggestion no longer available');
|
|
206
|
+
await consume(proposal.id); // Fail closed before any task mutation; never replay uncertain creation.
|
|
207
|
+
next.proposal = null;
|
|
208
|
+
if (action === 'proposal:accept') {
|
|
209
|
+
const task: Task = JSON.parse(await note(['agent', 'add', `--text=${proposal.text}`, ...(proposal.tag ? [`--tag=${proposal.tag}`] : [])], extra.signal));
|
|
210
|
+
await note(['start', String(task.id)], extra.signal); next.selectedId = task.id;
|
|
211
|
+
}
|
|
212
|
+
} else if (action.startsWith('screen:')) next.screen = action.slice(7) as State['screen'];
|
|
166
213
|
else if (action.startsWith('field:')) { next.screen = 'field'; next.field = action.slice(6); }
|
|
167
214
|
else if (action === 'search') next.search = z.string().max(256).parse(value);
|
|
168
215
|
else if (action === 'clear') next.selectedId = null;
|
|
@@ -171,8 +218,11 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
171
218
|
else if (action.startsWith('select:')) {
|
|
172
219
|
const id = Number(action.slice(7));
|
|
173
220
|
if (!before.tasks.some((t) => t.id === id)) throw new Error('Task no longer selectable');
|
|
174
|
-
|
|
221
|
+
if (next.proposal) await consume(next.proposal.id);
|
|
222
|
+
await note(['start', String(id)], extra.signal); next.selectedId = id; next.proposal = null;
|
|
175
223
|
} else if (action === 'add') {
|
|
224
|
+
if (next.proposal) await consume(next.proposal.id);
|
|
225
|
+
next.proposal = null;
|
|
176
226
|
const task: Task = JSON.parse(await note(['agent', 'add', `--text=${z.string().min(1).parse(value)}`], extra.signal));
|
|
177
227
|
await note(['start', String(task.id)], extra.signal); next.selectedId = task.id;
|
|
178
228
|
} else if (action === 'done') {
|
|
@@ -206,7 +256,8 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
206
256
|
}));
|
|
207
257
|
const empty = { type: 'object' as const, properties: {}, additionalProperties: false };
|
|
208
258
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
|
|
209
|
-
{ name: '
|
|
259
|
+
{ name: 'propose', description: 'Before multi-step implementation or configuration migrations, suggest a task if get_current returns null; skip informational questions and trivial edits. Show a short action-oriented title, optional details after a blank line, and reuse a saved tag from tags. The user clicks + to create/start/select or dismiss. Replaces an unaccepted suggestion; continue working without waiting or asking again. Never creates a task before a user action.', inputSchema: { type: 'object' as const, properties: { text: { type: 'string', minLength: 1, maxLength: 4000 }, tag: { type: 'string', minLength: 1, maxLength: 64 } }, required: ['text'], additionalProperties: false } },
|
|
260
|
+
{ name: 'get_current', description: 'Read the task selected through the native Pinote UI for this conversation before multi-step implementation or configuration migrations. Returns null if unselected; then use tags and propose to suggest a task without creating one. Skip informational questions and trivial edits; never replace an existing selection.', inputSchema: empty, annotations: { readOnlyHint: true } },
|
|
210
261
|
{ name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
|
|
211
262
|
{ name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
|
|
212
263
|
{ name: 'update_current', description: 'Merge Markdown handoff fields on the selected task. Read get_current first. Keep handoff notes concise; PR is one GitHub PR URL.', inputSchema: { type: 'object' as const, properties: { expected_updated_at: { type: 'string' }, set: { type: 'object', additionalProperties: { type: 'string' } }, remove: { type: 'array', items: { type: 'string' } } }, required: ['expected_updated_at'], additionalProperties: false } },
|
|
@@ -214,8 +265,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
|
|
|
214
265
|
server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async () => {
|
|
215
266
|
try {
|
|
216
267
|
const state = context(request.params._meta).state;
|
|
217
|
-
let result: unknown;
|
|
218
|
-
if (request.params.name === '
|
|
268
|
+
let result: unknown, outgoing: State | undefined;
|
|
269
|
+
if (request.params.name === 'propose') {
|
|
270
|
+
if (await selected(state, extra.signal)) throw new Error('A task is already selected');
|
|
271
|
+
const args = Proposal.omit({ id: true }).parse(request.params.arguments);
|
|
272
|
+
if (!args.text.trim()) throw new Error('Task text must not be blank');
|
|
273
|
+
outgoing = boundedState({ ...state, selectedId: null, proposal: { ...args, id: randomUUID() } });
|
|
274
|
+
if (state.proposal && !await consumed(state.proposal.id)) await consume(state.proposal.id);
|
|
275
|
+
result = { status: 'Suggested; user can accept (+) or dismiss. Continue working without asking again.' };
|
|
276
|
+
} else if (request.params.name === 'get_current') result = await selected(state, extra.signal);
|
|
219
277
|
else if (request.params.name === 'fields') result = settings().config;
|
|
220
278
|
else if (request.params.name === 'tags') result = JSON.parse(await note(['agent', 'tags'], extra.signal));
|
|
221
279
|
else if (request.params.name === 'update_current') {
|
|
@@ -225,7 +283,7 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
|
|
|
225
283
|
result = JSON.parse(await note(['agent', 'update', String(task.id), '--expected-updated-at', args.expected_updated_at, '--set-json', JSON.stringify(args.set), ...args.remove.map((key) => `--remove=${key}`)], extra.signal));
|
|
226
284
|
await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
|
|
227
285
|
} else throw new Error('Unknown tool');
|
|
228
|
-
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
|
286
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(outgoing ? { _meta: { 'native-ui/state': outgoing } } : {}) };
|
|
229
287
|
} catch (error) { return { isError: true, content: [{ type: 'text', text: String(error) }] }; }
|
|
230
288
|
}));
|
|
231
289
|
await server.connect(new StdioServerTransport());
|