pi-note 0.20.0 → 0.21.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/dist-mcp/mcp/server.js +82 -20
- package/mcp/README.md +31 -7
- package/mcp/server.ts +71 -17
- package/package.json +1 -1
package/dist-mcp/mcp/server.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { isAbsolute, join } from 'node:path';
|
|
5
5
|
import { execFile } from 'node:child_process';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
|
-
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { readFileSync, watchFile, unwatchFile } from 'node:fs';
|
|
8
|
+
import { getAgentDir } from "../config-path.js";
|
|
8
9
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
9
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
10
|
-
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
11
12
|
import { z } from 'zod';
|
|
12
13
|
import { defaultFooterField, defaultHandoffPrompt, defaultNewSessionPrompt, effectiveFooterFields, parseFooterConfig, parseHandoffPrompt, parseNewSessionPrompt, readFooterDocument, saveFooterConfig } from "../footer-config.js";
|
|
13
14
|
const URI = 'pinote://ui';
|
|
@@ -26,7 +27,7 @@ const Action = z.object({ method: z.literal('native-ui/action'), params: z.objec
|
|
|
26
27
|
uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
|
|
27
28
|
value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
|
|
28
29
|
}) });
|
|
29
|
-
const server = new Server({ name: 'pinote', version: '0.
|
|
30
|
+
const server = new Server({ name: 'pinote', version: '0.21.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
|
|
30
31
|
// Serialize complete read/check/write cycles even when a host sends parallel calls.
|
|
31
32
|
let queue = Promise.resolve();
|
|
32
33
|
function serial(run) {
|
|
@@ -73,8 +74,8 @@ async function note(args, signal) {
|
|
|
73
74
|
const options = { signal, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 };
|
|
74
75
|
const version = (await exec(noteCommand, ['--version'], options)).stdout.trim();
|
|
75
76
|
const match = /^pinote (\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
76
|
-
if (!match || Number(match[1]) === 0 && Number(match[2]) <
|
|
77
|
-
throw new Error('Pinote MCP requires note 0.
|
|
77
|
+
if (!match || Number(match[1]) === 0 && Number(match[2]) < 12)
|
|
78
|
+
throw new Error('Pinote MCP requires note 0.12.0+');
|
|
78
79
|
return (await exec(noteCommand, ['--no-notify', ...args], options)).stdout;
|
|
79
80
|
}
|
|
80
81
|
async function selected(state, signal) {
|
|
@@ -115,7 +116,25 @@ async function snapshot(state, signal) {
|
|
|
115
116
|
const revision = createHash('sha256').update(JSON.stringify({ state, tasks, task, raw: config.raw })).digest('hex');
|
|
116
117
|
return { state, tasks, task, config, revision };
|
|
117
118
|
}
|
|
118
|
-
function
|
|
119
|
+
function scopedActions(meta) {
|
|
120
|
+
return z.object({ actionRevisions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success;
|
|
121
|
+
}
|
|
122
|
+
function actionRevision(data, control) {
|
|
123
|
+
const { id, revision: _revision, ...semantics } = control;
|
|
124
|
+
let dependency = null;
|
|
125
|
+
if (id === 'done')
|
|
126
|
+
dependency = data.task && { id: data.task.id, updated_at: data.task.updated_at };
|
|
127
|
+
else if (id.startsWith('select:'))
|
|
128
|
+
dependency = data.tasks.find((task) => task.id === Number(id.slice(7)));
|
|
129
|
+
else if (!['preview', 'continue', 'clear', 'search', 'add', 'proposal:accept', 'proposal:dismiss'].includes(id) && !id.startsWith('screen:') && !id.startsWith('field:')) {
|
|
130
|
+
dependency = { raw: data.config.raw, ...(['add-field', 'remove-field', 'label', 'format', 'width', 'link'].includes(id) ? { fields: fields(data.config.config, data.task?.agent_notes) } : {}) };
|
|
131
|
+
}
|
|
132
|
+
return createHash('sha256').update(JSON.stringify({ action: id, semantics, state: data.state, dependency })).digest('hex');
|
|
133
|
+
}
|
|
134
|
+
function staleAction() {
|
|
135
|
+
throw new McpError(-33003, 'View changed; refresh before acting', { 'native-ui/action': { status: 'not-applied', reason: 'stale-view' } });
|
|
136
|
+
}
|
|
137
|
+
function view(data, scoped = false) {
|
|
119
138
|
const { tasks, task, config, revision } = data;
|
|
120
139
|
const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
|
|
121
140
|
const controls = [];
|
|
@@ -207,6 +226,9 @@ function view(data) {
|
|
|
207
226
|
body = `${control.label} exceeds the editor limit; edit it outside this UI.\n${body}`;
|
|
208
227
|
return false;
|
|
209
228
|
});
|
|
229
|
+
if (scoped)
|
|
230
|
+
for (const control of editable)
|
|
231
|
+
control.revision = actionRevision(data, control);
|
|
210
232
|
const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer };
|
|
211
233
|
// JSON escaping and expanded footer formats can exceed the host budget even
|
|
212
234
|
// with bounded row counts. Preserve navigation/filter controls ahead of rows.
|
|
@@ -231,16 +253,16 @@ server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(a
|
|
|
231
253
|
if (request.params.uri !== URI)
|
|
232
254
|
throw new Error('Unknown resource');
|
|
233
255
|
const data = await snapshot(context(request.params._meta).state, extra.signal);
|
|
234
|
-
return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data)) }] };
|
|
256
|
+
return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta))) }] };
|
|
235
257
|
}));
|
|
236
258
|
server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
237
259
|
const state = context(request.params._meta).state;
|
|
238
260
|
const before = await snapshot(state, extra.signal);
|
|
239
|
-
|
|
240
|
-
throw new Error('View changed; refresh before acting');
|
|
261
|
+
const scoped = scopedActions(request.params._meta);
|
|
241
262
|
const { action, value } = request.params;
|
|
242
|
-
|
|
243
|
-
|
|
263
|
+
const control = view(before, scoped).controls.find((item) => item.id === action);
|
|
264
|
+
if (!control || (control.revision ?? before.revision) !== request.params.revision)
|
|
265
|
+
staleAction();
|
|
244
266
|
const next = { ...before.state };
|
|
245
267
|
let draft, preview;
|
|
246
268
|
if (action === 'proposal:accept' || action === 'proposal:dismiss') {
|
|
@@ -251,7 +273,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
251
273
|
next.proposal = null;
|
|
252
274
|
if (action === 'proposal:accept') {
|
|
253
275
|
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);
|
|
276
|
+
await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal);
|
|
255
277
|
next.selectedId = task.id;
|
|
256
278
|
}
|
|
257
279
|
}
|
|
@@ -271,11 +293,15 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
271
293
|
preview = (before.task.text + '\n\n' + JSON.stringify(before.task.agent_notes, null, 2)).slice(0, 20_000);
|
|
272
294
|
else if (action.startsWith('select:')) {
|
|
273
295
|
const id = Number(action.slice(7));
|
|
274
|
-
|
|
275
|
-
|
|
296
|
+
const candidate = before.tasks.find((task) => task.id === id);
|
|
297
|
+
if (!candidate)
|
|
298
|
+
staleAction();
|
|
299
|
+
const target = JSON.parse(await note(['agent', 'get', String(id)], extra.signal));
|
|
300
|
+
if (target.text !== candidate.text || target.state !== candidate.state || target.tag !== candidate.tag)
|
|
301
|
+
staleAction();
|
|
276
302
|
if (next.proposal)
|
|
277
303
|
await consume(next.proposal.id);
|
|
278
|
-
await note(['start', String(id)], extra.signal);
|
|
304
|
+
await note(['agent', 'start', String(id), '--expected-updated-at', target.updated_at], extra.signal);
|
|
279
305
|
next.selectedId = id;
|
|
280
306
|
next.proposal = null;
|
|
281
307
|
}
|
|
@@ -284,7 +310,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
284
310
|
await consume(next.proposal.id);
|
|
285
311
|
next.proposal = null;
|
|
286
312
|
const task = JSON.parse(await note(['agent', 'add', `--text=${z.string().min(1).parse(value)}`], extra.signal));
|
|
287
|
-
await note(['start', String(task.id)], extra.signal);
|
|
313
|
+
await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal);
|
|
288
314
|
next.selectedId = task.id;
|
|
289
315
|
}
|
|
290
316
|
else if (action === 'done') {
|
|
@@ -325,14 +351,14 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
325
351
|
extra.signal.throwIfAborted();
|
|
326
352
|
saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy);
|
|
327
353
|
}
|
|
328
|
-
const result = { view: view(await snapshot(next, extra.signal)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
|
|
354
|
+
const result = { view: view(await snapshot(next, extra.signal), scoped), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
|
|
329
355
|
await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
|
|
330
356
|
return result;
|
|
331
357
|
}));
|
|
332
358
|
const empty = { type: 'object', properties: {}, additionalProperties: false };
|
|
333
359
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
|
|
334
|
-
{ name: 'propose', description: '
|
|
335
|
-
{ name: 'get_current', description: 'Read the task selected through the native Pinote UI for this conversation. Returns null if unselected.', inputSchema: empty, annotations: { readOnlyHint: true } },
|
|
360
|
+
{ 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 } },
|
|
361
|
+
{ 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 } },
|
|
336
362
|
{ name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
|
|
337
363
|
{ name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
|
|
338
364
|
{ 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 } },
|
|
@@ -374,4 +400,40 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
|
|
|
374
400
|
return { isError: true, content: [{ type: 'text', text: String(error) }] };
|
|
375
401
|
}
|
|
376
402
|
}));
|
|
403
|
+
// Every MCP process observes shared storage, including writes by CLI/GTK/other
|
|
404
|
+
// sessions. Stat polling survives missing files and atomic config replacement.
|
|
405
|
+
server.oninitialized = () => {
|
|
406
|
+
server.oninitialized = undefined;
|
|
407
|
+
const dataHome = process.env.XDG_DATA_HOME;
|
|
408
|
+
const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
|
|
409
|
+
const paths = [database, `${database}-wal`, join(getAgentDir(), 'pi-note.json'), receiptDir];
|
|
410
|
+
let timer;
|
|
411
|
+
let closed = false;
|
|
412
|
+
const changed = () => {
|
|
413
|
+
if (closed || timer)
|
|
414
|
+
return;
|
|
415
|
+
timer = setTimeout(() => {
|
|
416
|
+
timer = undefined;
|
|
417
|
+
void server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } }).catch((error) => {
|
|
418
|
+
if (!closed)
|
|
419
|
+
console.error('Pinote UI update failed:', error);
|
|
420
|
+
});
|
|
421
|
+
}, 100);
|
|
422
|
+
timer.unref();
|
|
423
|
+
};
|
|
424
|
+
const listeners = paths.map((path) => {
|
|
425
|
+
const listener = (current, previous) => {
|
|
426
|
+
if (current.mtimeMs !== previous.mtimeMs || current.ctimeMs !== previous.ctimeMs || current.size !== previous.size || current.ino !== previous.ino || current.nlink !== previous.nlink)
|
|
427
|
+
changed();
|
|
428
|
+
};
|
|
429
|
+
watchFile(path, { persistent: false, interval: 1000 }, listener);
|
|
430
|
+
return listener;
|
|
431
|
+
});
|
|
432
|
+
server.onclose = () => {
|
|
433
|
+
closed = true;
|
|
434
|
+
if (timer)
|
|
435
|
+
clearTimeout(timer);
|
|
436
|
+
paths.forEach((path, index) => unwatchFile(path, listeners[index]));
|
|
437
|
+
};
|
|
438
|
+
};
|
|
377
439
|
await server.connect(new StdioServerTransport());
|
package/mcp/README.md
CHANGED
|
@@ -10,7 +10,7 @@ the built-in Pi MCP resource renderer. Use gateway 0.12.0+ with its Pi adapter.
|
|
|
10
10
|
|
|
11
11
|
## Setup
|
|
12
12
|
|
|
13
|
-
Requires Node.js 22.19+, Python 3.11+, and `note` 0.
|
|
13
|
+
Requires Node.js 22.19+, Python 3.11+, and `note` 0.12.0+. From the Pinote checkout:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
uv sync --locked
|
|
@@ -77,13 +77,25 @@ have saved the session for resume persistence. Existing extension selections are
|
|
|
77
77
|
not imported. Changing the gateway config path or service name starts a separate
|
|
78
78
|
selection namespace.
|
|
79
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
|
+
|
|
80
91
|
## Initial-version limits
|
|
81
92
|
|
|
82
93
|
This is a working alternative, **not full parity with the old extension**. It does
|
|
83
94
|
not retag/edit existing tasks or install/upgrade the CLI. Completion intentionally
|
|
84
|
-
does not reset conversations.
|
|
85
|
-
|
|
86
|
-
|
|
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;
|
|
87
99
|
it never overwrites existing editor text or submits a prompt. Previews are display-only transcript entries (never model context), showing plain
|
|
88
100
|
text/Markdown source limited to 20,000 characters; the task list shows at most 200
|
|
89
101
|
filtered matches (fewer if the serialized display budget is reached). Use the CLI for full text and unsupported task operations.
|
|
@@ -94,9 +106,21 @@ to reject historical proposals. Do not delete them while old sessions may resume
|
|
|
94
106
|
They are replay protection, not a second selection database or an authorization
|
|
95
107
|
boundary against local processes.
|
|
96
108
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
109
|
+
Each MCP process observes the shared database (including its WAL), settings, and
|
|
110
|
+
proposal receipts with coalesced, one-second stat checks. External CLI/GTK/other-session
|
|
111
|
+
changes refresh connected hosts; Ctrl+R or reopening the app remains a fallback.
|
|
112
|
+
Watchers stop on disconnect and never invoke the CLI or change task data.
|
|
113
|
+
|
|
114
|
+
Gateway 0.16.0+ negotiates per-control revisions: unrelated task-list changes no
|
|
115
|
+
longer invalidate preview/navigation, task creation, settings saves, or completion
|
|
116
|
+
of an unchanged selected task. Completion checks the displayed task revision;
|
|
117
|
+
selection starts its target with an atomic revision check. Settings writes check
|
|
118
|
+
the original file and resolved field definitions, including task-defined `Bar`
|
|
119
|
+
fields. Older hosts retain full-view revision checks without extra control fields.
|
|
120
|
+
|
|
121
|
+
Known stale-view rejections occur before writes and report **Action not applied**
|
|
122
|
+
on supporting hosts. Failed actions are never retried automatically: other errors
|
|
123
|
+
can follow a committed or partial write. Read fresh state before retrying.
|
|
100
124
|
Trusted services/agents have ordinary OS permissions; UI-only routing is not a
|
|
101
125
|
secrecy or authorization boundary.
|
|
102
126
|
|
package/mcp/server.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { promises as fs } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { isAbsolute, join } from 'node:path';
|
|
5
5
|
import { execFile } from 'node:child_process';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
|
-
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { readFileSync, watchFile, unwatchFile } from 'node:fs';
|
|
8
|
+
import { getAgentDir } from '../config-path.ts';
|
|
8
9
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
9
10
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
10
|
-
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
11
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
11
12
|
import { z } from 'zod';
|
|
12
13
|
import { defaultFooterField, defaultHandoffPrompt, defaultNewSessionPrompt, effectiveFooterFields, parseFooterConfig, parseHandoffPrompt, parseNewSessionPrompt, readFooterDocument, saveFooterConfig } from '../footer-config.ts';
|
|
13
14
|
|
|
@@ -26,12 +27,12 @@ const Context = z.object({ conversationId: z.string().min(1).max(256), contextId
|
|
|
26
27
|
type State = z.infer<typeof Context>['state'];
|
|
27
28
|
type Task = { id: number; text: string; state: string; tag: string | null; updated_at: string; agent_notes: Record<string, string> };
|
|
28
29
|
type Scalar = string | number | boolean | null;
|
|
29
|
-
type Control = { id: string; label: string; kind: 'action' | 'text' | 'number' | 'toggle' | 'select'; value?: Scalar; icon?: string; description?: string; multiline?: boolean; confirm?: string; options?: { label: string; value: Scalar }[] };
|
|
30
|
+
type Control = { id: string; label: string; revision?: string; kind: 'action' | 'text' | 'number' | 'toggle' | 'select'; value?: Scalar; icon?: string; description?: string; multiline?: boolean; confirm?: string; options?: { label: string; value: Scalar }[] };
|
|
30
31
|
const Action = z.object({ method: z.literal('native-ui/action'), params: z.object({
|
|
31
32
|
uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
|
|
32
33
|
value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
|
|
33
34
|
}) });
|
|
34
|
-
const server = new Server({ name: 'pinote', version: '0.
|
|
35
|
+
const server = new Server({ name: 'pinote', version: '0.21.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
|
|
35
36
|
// Serialize complete read/check/write cycles even when a host sends parallel calls.
|
|
36
37
|
let queue = Promise.resolve();
|
|
37
38
|
function serial<T>(run: () => Promise<T>): Promise<T> {
|
|
@@ -63,7 +64,7 @@ async function note(args: string[], signal: AbortSignal): Promise<string> {
|
|
|
63
64
|
const options = { signal, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 };
|
|
64
65
|
const version = (await exec(noteCommand, ['--version'], options)).stdout.trim();
|
|
65
66
|
const match = /^pinote (\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
66
|
-
if (!match || Number(match[1]) === 0 && Number(match[2]) <
|
|
67
|
+
if (!match || Number(match[1]) === 0 && Number(match[2]) < 12) throw new Error('Pinote MCP requires note 0.12.0+');
|
|
67
68
|
return (await exec(noteCommand, ['--no-notify', ...args], options)).stdout;
|
|
68
69
|
}
|
|
69
70
|
async function selected(state: State, signal: AbortSignal): Promise<Task | null> {
|
|
@@ -98,7 +99,23 @@ async function snapshot(state: State, signal: AbortSignal) {
|
|
|
98
99
|
return { state, tasks, task, config, revision };
|
|
99
100
|
}
|
|
100
101
|
type Snapshot = Awaited<ReturnType<typeof snapshot>>;
|
|
101
|
-
function
|
|
102
|
+
function scopedActions(meta: Record<string, unknown> | undefined): boolean {
|
|
103
|
+
return z.object({ actionRevisions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success;
|
|
104
|
+
}
|
|
105
|
+
function actionRevision(data: Snapshot, control: Control): string {
|
|
106
|
+
const { id, revision: _revision, ...semantics } = control;
|
|
107
|
+
let dependency: unknown = null;
|
|
108
|
+
if (id === 'done') dependency = data.task && { id: data.task.id, updated_at: data.task.updated_at };
|
|
109
|
+
else if (id.startsWith('select:')) dependency = data.tasks.find((task) => task.id === Number(id.slice(7)));
|
|
110
|
+
else if (!['preview', 'continue', 'clear', 'search', 'add', 'proposal:accept', 'proposal:dismiss'].includes(id) && !id.startsWith('screen:') && !id.startsWith('field:')) {
|
|
111
|
+
dependency = { raw: data.config.raw, ...(['add-field', 'remove-field', 'label', 'format', 'width', 'link'].includes(id) ? { fields: fields(data.config.config, data.task?.agent_notes) } : {}) };
|
|
112
|
+
}
|
|
113
|
+
return createHash('sha256').update(JSON.stringify({ action: id, semantics, state: data.state, dependency })).digest('hex');
|
|
114
|
+
}
|
|
115
|
+
function staleAction(): never {
|
|
116
|
+
throw new McpError(-33003, 'View changed; refresh before acting', { 'native-ui/action': { status: 'not-applied', reason: 'stale-view' } });
|
|
117
|
+
}
|
|
118
|
+
function view(data: Snapshot, scoped = false) {
|
|
102
119
|
const { tasks, task, config, revision } = data;
|
|
103
120
|
const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
|
|
104
121
|
const controls: Control[] = [];
|
|
@@ -173,6 +190,7 @@ function view(data: Snapshot) {
|
|
|
173
190
|
body = `${control.label} exceeds the editor limit; edit it outside this UI.\n${body}`;
|
|
174
191
|
return false;
|
|
175
192
|
});
|
|
193
|
+
if (scoped) for (const control of editable) control.revision = actionRevision(data, control);
|
|
176
194
|
const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer };
|
|
177
195
|
// JSON escaping and expanded footer formats can exceed the host budget even
|
|
178
196
|
// with bounded row counts. Preserve navigation/filter controls ahead of rows.
|
|
@@ -190,14 +208,15 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [
|
|
|
190
208
|
server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(async () => {
|
|
191
209
|
if (request.params.uri !== URI) throw new Error('Unknown resource');
|
|
192
210
|
const data = await snapshot(context(request.params._meta).state, extra.signal);
|
|
193
|
-
return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data)) }] };
|
|
211
|
+
return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta))) }] };
|
|
194
212
|
}));
|
|
195
213
|
server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
196
214
|
const state = context(request.params._meta).state;
|
|
197
215
|
const before = await snapshot(state, extra.signal);
|
|
198
|
-
|
|
216
|
+
const scoped = scopedActions(request.params._meta);
|
|
199
217
|
const { action, value } = request.params;
|
|
200
|
-
|
|
218
|
+
const control = view(before, scoped).controls.find((item) => item.id === action);
|
|
219
|
+
if (!control || (control.revision ?? before.revision) !== request.params.revision) staleAction();
|
|
201
220
|
const next: State = { ...before.state };
|
|
202
221
|
let draft: string | undefined, preview: string | undefined;
|
|
203
222
|
if (action === 'proposal:accept' || action === 'proposal:dismiss') {
|
|
@@ -207,7 +226,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
207
226
|
next.proposal = null;
|
|
208
227
|
if (action === 'proposal:accept') {
|
|
209
228
|
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;
|
|
229
|
+
await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal); next.selectedId = task.id;
|
|
211
230
|
}
|
|
212
231
|
} else if (action.startsWith('screen:')) next.screen = action.slice(7) as State['screen'];
|
|
213
232
|
else if (action.startsWith('field:')) { next.screen = 'field'; next.field = action.slice(6); }
|
|
@@ -217,14 +236,17 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
217
236
|
else if (action === 'preview') preview = (before.task!.text + '\n\n' + JSON.stringify(before.task!.agent_notes, null, 2)).slice(0, 20_000);
|
|
218
237
|
else if (action.startsWith('select:')) {
|
|
219
238
|
const id = Number(action.slice(7));
|
|
220
|
-
|
|
239
|
+
const candidate = before.tasks.find((task) => task.id === id);
|
|
240
|
+
if (!candidate) staleAction();
|
|
241
|
+
const target: Task = JSON.parse(await note(['agent', 'get', String(id)], extra.signal));
|
|
242
|
+
if (target.text !== candidate.text || target.state !== candidate.state || target.tag !== candidate.tag) staleAction();
|
|
221
243
|
if (next.proposal) await consume(next.proposal.id);
|
|
222
|
-
await note(['start', String(id)], extra.signal); next.selectedId = id; next.proposal = null;
|
|
244
|
+
await note(['agent', 'start', String(id), '--expected-updated-at', target.updated_at], extra.signal); next.selectedId = id; next.proposal = null;
|
|
223
245
|
} else if (action === 'add') {
|
|
224
246
|
if (next.proposal) await consume(next.proposal.id);
|
|
225
247
|
next.proposal = null;
|
|
226
248
|
const task: Task = JSON.parse(await note(['agent', 'add', `--text=${z.string().min(1).parse(value)}`], extra.signal));
|
|
227
|
-
await note(['start', String(task.id)], extra.signal); next.selectedId = task.id;
|
|
249
|
+
await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal); next.selectedId = task.id;
|
|
228
250
|
} else if (action === 'done') {
|
|
229
251
|
await note(['agent', 'done', String(before.task!.id), '--expected-updated-at', before.task!.updated_at], extra.signal);
|
|
230
252
|
next.selectedId = null;
|
|
@@ -250,14 +272,14 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
250
272
|
extra.signal.throwIfAborted();
|
|
251
273
|
saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy);
|
|
252
274
|
}
|
|
253
|
-
const result = { view: view(await snapshot(next, extra.signal)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
|
|
275
|
+
const result = { view: view(await snapshot(next, extra.signal), scoped), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
|
|
254
276
|
await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
|
|
255
277
|
return result;
|
|
256
278
|
}));
|
|
257
279
|
const empty = { type: 'object' as const, properties: {}, additionalProperties: false };
|
|
258
280
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
|
|
259
|
-
{ name: 'propose', description: '
|
|
260
|
-
{ name: 'get_current', description: 'Read the task selected through the native Pinote UI for this conversation. Returns null if unselected.', inputSchema: empty, annotations: { readOnlyHint: true } },
|
|
281
|
+
{ 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 } },
|
|
282
|
+
{ 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 } },
|
|
261
283
|
{ name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
|
|
262
284
|
{ name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
|
|
263
285
|
{ 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 } },
|
|
@@ -286,4 +308,36 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
|
|
|
286
308
|
return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(outgoing ? { _meta: { 'native-ui/state': outgoing } } : {}) };
|
|
287
309
|
} catch (error) { return { isError: true, content: [{ type: 'text', text: String(error) }] }; }
|
|
288
310
|
}));
|
|
311
|
+
// Every MCP process observes shared storage, including writes by CLI/GTK/other
|
|
312
|
+
// sessions. Stat polling survives missing files and atomic config replacement.
|
|
313
|
+
server.oninitialized = () => {
|
|
314
|
+
server.oninitialized = undefined;
|
|
315
|
+
const dataHome = process.env.XDG_DATA_HOME;
|
|
316
|
+
const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
|
|
317
|
+
const paths = [database, `${database}-wal`, join(getAgentDir(), 'pi-note.json'), receiptDir];
|
|
318
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
319
|
+
let closed = false;
|
|
320
|
+
const changed = () => {
|
|
321
|
+
if (closed || timer) return;
|
|
322
|
+
timer = setTimeout(() => {
|
|
323
|
+
timer = undefined;
|
|
324
|
+
void server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } }).catch((error) => {
|
|
325
|
+
if (!closed) console.error('Pinote UI update failed:', error);
|
|
326
|
+
});
|
|
327
|
+
}, 100);
|
|
328
|
+
timer.unref();
|
|
329
|
+
};
|
|
330
|
+
const listeners = paths.map((path) => {
|
|
331
|
+
const listener = (current: import('node:fs').Stats, previous: import('node:fs').Stats) => {
|
|
332
|
+
if (current.mtimeMs !== previous.mtimeMs || current.ctimeMs !== previous.ctimeMs || current.size !== previous.size || current.ino !== previous.ino || current.nlink !== previous.nlink) changed();
|
|
333
|
+
};
|
|
334
|
+
watchFile(path, { persistent: false, interval: 1000 }, listener);
|
|
335
|
+
return listener;
|
|
336
|
+
});
|
|
337
|
+
server.onclose = () => {
|
|
338
|
+
closed = true;
|
|
339
|
+
if (timer) clearTimeout(timer);
|
|
340
|
+
paths.forEach((path, index) => unwatchFile(path, listeners[index]));
|
|
341
|
+
};
|
|
342
|
+
};
|
|
289
343
|
await server.connect(new StdioServerTransport());
|