pi-note 0.20.1 → 0.22.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.
@@ -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.20.1' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
30
+ const server = new Server({ name: 'pinote', version: '0.22.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]) < 4)
77
- throw new Error('Pinote MCP requires note 0.4.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,28 @@ 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 view(data) {
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 secondaryActions(meta) {
138
+ return z.object({ footerSecondaryActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success;
139
+ }
140
+ function view(data, scoped = false, secondary = false) {
119
141
  const { tasks, task, config, revision } = data;
120
142
  const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
121
143
  const controls = [];
@@ -128,7 +150,7 @@ function view(data) {
128
150
  action('done', 'Complete task', { icon: icons.check });
129
151
  }
130
152
  else if (state.proposal) {
131
- action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: state.proposal.text });
153
+ action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: `Left-click to create/start/select; right-click to dismiss.\n${state.proposal.text}` });
132
154
  action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
133
155
  }
134
156
  action('screen:tasks', 'Tasks', { icon: icons.note });
@@ -177,7 +199,7 @@ function view(data) {
177
199
  footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', width: config.config.titleWidth });
178
200
  }
179
201
  else if (state.proposal) {
180
- footer.push({ text: `${icons.cross} `, action: 'proposal:dismiss', width: 3 }, { text: `${icons.add} `, action: 'proposal:accept', width: 3 });
202
+ footer.push({ text: `${icons.add} `, action: 'proposal:accept', ...(secondary ? { secondaryAction: 'proposal:dismiss' } : {}), width: 3 });
181
203
  footer.push({ text: `${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, width: config.config.titleWidth });
182
204
  }
183
205
  else
@@ -207,6 +229,9 @@ function view(data) {
207
229
  body = `${control.label} exceeds the editor limit; edit it outside this UI.\n${body}`;
208
230
  return false;
209
231
  });
232
+ if (scoped)
233
+ for (const control of editable)
234
+ control.revision = actionRevision(data, control);
210
235
  const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer };
211
236
  // JSON escaping and expanded footer formats can exceed the host budget even
212
237
  // with bounded row counts. Preserve navigation/filter controls ahead of rows.
@@ -231,16 +256,16 @@ server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(a
231
256
  if (request.params.uri !== URI)
232
257
  throw new Error('Unknown resource');
233
258
  const data = await snapshot(context(request.params._meta).state, extra.signal);
234
- return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data)) }] };
259
+ return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta))) }] };
235
260
  }));
236
261
  server.setRequestHandler(Action, (request, extra) => serial(async () => {
237
262
  const state = context(request.params._meta).state;
238
263
  const before = await snapshot(state, extra.signal);
239
- if (before.revision !== request.params.revision)
240
- throw new Error('View changed; refresh before acting');
264
+ const scoped = scopedActions(request.params._meta);
241
265
  const { action, value } = request.params;
242
- if (!view(before).controls.some((c) => c.id === action))
243
- throw new Error('Action is not available in this view');
266
+ const control = view(before, scoped).controls.find((item) => item.id === action);
267
+ if (!control || (control.revision ?? before.revision) !== request.params.revision)
268
+ staleAction();
244
269
  const next = { ...before.state };
245
270
  let draft, preview;
246
271
  if (action === 'proposal:accept' || action === 'proposal:dismiss') {
@@ -251,7 +276,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
251
276
  next.proposal = null;
252
277
  if (action === 'proposal:accept') {
253
278
  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);
279
+ await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal);
255
280
  next.selectedId = task.id;
256
281
  }
257
282
  }
@@ -271,11 +296,15 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
271
296
  preview = (before.task.text + '\n\n' + JSON.stringify(before.task.agent_notes, null, 2)).slice(0, 20_000);
272
297
  else if (action.startsWith('select:')) {
273
298
  const id = Number(action.slice(7));
274
- if (!before.tasks.some((t) => t.id === id))
275
- throw new Error('Task no longer selectable');
299
+ const candidate = before.tasks.find((task) => task.id === id);
300
+ if (!candidate)
301
+ staleAction();
302
+ const target = JSON.parse(await note(['agent', 'get', String(id)], extra.signal));
303
+ if (target.text !== candidate.text || target.state !== candidate.state || target.tag !== candidate.tag)
304
+ staleAction();
276
305
  if (next.proposal)
277
306
  await consume(next.proposal.id);
278
- await note(['start', String(id)], extra.signal);
307
+ await note(['agent', 'start', String(id), '--expected-updated-at', target.updated_at], extra.signal);
279
308
  next.selectedId = id;
280
309
  next.proposal = null;
281
310
  }
@@ -284,7 +313,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
284
313
  await consume(next.proposal.id);
285
314
  next.proposal = null;
286
315
  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);
316
+ await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal);
288
317
  next.selectedId = task.id;
289
318
  }
290
319
  else if (action === 'done') {
@@ -325,13 +354,13 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
325
354
  extra.signal.throwIfAborted();
326
355
  saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy);
327
356
  }
328
- const result = { view: view(await snapshot(next, extra.signal)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
357
+ const result = { view: view(await snapshot(next, extra.signal), scoped, secondaryActions(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
329
358
  await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
330
359
  return result;
331
360
  }));
332
361
  const empty = { type: 'object', properties: {}, additionalProperties: false };
333
362
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
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 } },
363
+ { 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 left-clicks + to create/start/select or right-clicks it to dismiss (also available in the app menu). 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
364
  { 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
365
  { name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
337
366
  { name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
@@ -350,7 +379,7 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
350
379
  outgoing = boundedState({ ...state, selectedId: null, proposal: { ...args, id: randomUUID() } });
351
380
  if (state.proposal && !await consumed(state.proposal.id))
352
381
  await consume(state.proposal.id);
353
- result = { status: 'Suggested; user can accept (+) or dismiss. Continue working without asking again.' };
382
+ result = { status: 'Suggested; user can left-click + to accept or right-click + to dismiss (or use the app menu). Continue working without asking again.' };
354
383
  }
355
384
  else if (request.params.name === 'get_current')
356
385
  result = await selected(state, extra.signal);
@@ -374,4 +403,40 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
374
403
  return { isError: true, content: [{ type: 'text', text: String(error) }] };
375
404
  }
376
405
  }));
406
+ // Every MCP process observes shared storage, including writes by CLI/GTK/other
407
+ // sessions. Stat polling survives missing files and atomic config replacement.
408
+ server.oninitialized = () => {
409
+ server.oninitialized = undefined;
410
+ const dataHome = process.env.XDG_DATA_HOME;
411
+ const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
412
+ const paths = [database, `${database}-wal`, join(getAgentDir(), 'pi-note.json'), receiptDir];
413
+ let timer;
414
+ let closed = false;
415
+ const changed = () => {
416
+ if (closed || timer)
417
+ return;
418
+ timer = setTimeout(() => {
419
+ timer = undefined;
420
+ void server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } }).catch((error) => {
421
+ if (!closed)
422
+ console.error('Pinote UI update failed:', error);
423
+ });
424
+ }, 100);
425
+ timer.unref();
426
+ };
427
+ const listeners = paths.map((path) => {
428
+ const listener = (current, previous) => {
429
+ if (current.mtimeMs !== previous.mtimeMs || current.ctimeMs !== previous.ctimeMs || current.size !== previous.size || current.ino !== previous.ino || current.nlink !== previous.nlink)
430
+ changed();
431
+ };
432
+ watchFile(path, { persistent: false, interval: 1000 }, listener);
433
+ return listener;
434
+ });
435
+ server.onclose = () => {
436
+ closed = true;
437
+ if (timer)
438
+ clearTimeout(timer);
439
+ paths.forEach((path, index) => unwatchFile(path, listeners[index]));
440
+ };
441
+ };
377
442
  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.4.0+. From the Pinote checkout:
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
@@ -55,10 +55,14 @@ normal personal data. No desktop notifications are sent.
55
55
  fails closed; repair it manually rather than replacing it with defaults.
56
56
  - **Footer:** selected task plus configured fields; safe HTTP(S) links are clickable.
57
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
58
+ resetting the conversation or editor. Suggestions show only **✚**: left-click to
59
+ create/start/select, right-click to dismiss. Right-click requires gateway 0.22.0+
60
+ and its Kitty mouse mapping; **Dismiss suggestion** in the app menu remains the
61
+ keyboard/older-host fallback. These use the gateway's generic footer actions, not a Pinote
60
62
  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.
63
+ and right-click mouse mapping described in its native UI documentation. Existing
64
+ installations need to add the mapping and reload Kitty; package updates do not
65
+ edit terminal configuration. App menus remain the keyboard fallback.
62
66
  - **Agent:** `propose`, `get_current`, `update_current`, `fields`, and `tags` are
63
67
  available through gateway tool discovery. `propose` returns opaque UI state;
64
68
  it does not create a task. It replaces an unaccepted suggestion only when no task
@@ -106,9 +110,21 @@ to reject historical proposals. Do not delete them while old sessions may resume
106
110
  They are replay protection, not a second selection database or an authorization
107
111
  boundary against local processes.
108
112
 
109
- Changes through this service notify the host. External CLI/GTK/config changes need
110
- Ctrl+R or reopening the app. Failed actions are never retried automatically: a
111
- write might have committed before an interruption. Read fresh state before retrying.
113
+ Each MCP process observes the shared database (including its WAL), settings, and
114
+ proposal receipts with coalesced, one-second stat checks. External CLI/GTK/other-session
115
+ changes refresh connected hosts; Ctrl+R or reopening the app remains a fallback.
116
+ Watchers stop on disconnect and never invoke the CLI or change task data.
117
+
118
+ Gateway 0.16.0+ negotiates per-control revisions: unrelated task-list changes no
119
+ longer invalidate preview/navigation, task creation, settings saves, or completion
120
+ of an unchanged selected task. Completion checks the displayed task revision;
121
+ selection starts its target with an atomic revision check. Settings writes check
122
+ the original file and resolved field definitions, including task-defined `Bar`
123
+ fields. Older hosts retain full-view revision checks without extra control fields.
124
+
125
+ Known stale-view rejections occur before writes and report **Action not applied**
126
+ on supporting hosts. Failed actions are never retried automatically: other errors
127
+ can follow a committed or partial write. Read fresh state before retrying.
112
128
  Trusted services/agents have ordinary OS permissions; UI-only routing is not a
113
129
  secrecy or authorization boundary.
114
130
 
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.20.1' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
35
+ const server = new Server({ name: 'pinote', version: '0.22.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]) < 4) throw new Error('Pinote MCP requires note 0.4.0+');
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,26 @@ 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 view(data: Snapshot) {
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 secondaryActions(meta: Record<string, unknown> | undefined): boolean {
119
+ return z.object({ footerSecondaryActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success;
120
+ }
121
+ function view(data: Snapshot, scoped = false, secondary = false) {
102
122
  const { tasks, task, config, revision } = data;
103
123
  const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
104
124
  const controls: Control[] = [];
@@ -110,7 +130,7 @@ function view(data: Snapshot) {
110
130
  action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
111
131
  action('done', 'Complete task', { icon: icons.check });
112
132
  } else if (state.proposal) {
113
- action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: state.proposal.text });
133
+ action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: `Left-click to create/start/select; right-click to dismiss.\n${state.proposal.text}` });
114
134
  action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
115
135
  }
116
136
  action('screen:tasks', 'Tasks', { icon: icons.note });
@@ -144,12 +164,12 @@ function view(data: Snapshot) {
144
164
  for (const candidate of matches.slice(0, 200)) action(`select:${candidate.id}`, `${candidate.tag ? `[${candidate.tag}] ` : ''}${candidate.text.split('\n')[0]}`, { description: `#${candidate.id} · ${candidate.state}`, confirm: 'Select and start this task for this conversation?' });
145
165
  if (matches.length > 200) body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
146
166
  }
147
- const footer: { text: string; url?: string; action?: string; width?: number }[] = [{ text: `${icons.note} `, width: 3 }];
167
+ const footer: { text: string; url?: string; action?: string; secondaryAction?: string; width?: number }[] = [{ text: `${icons.note} `, width: 3 }];
148
168
  if (task) {
149
169
  footer.push({ text: `${icons.check} `, action: 'done', width: 3 });
150
170
  footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', width: config.config.titleWidth });
151
171
  } else if (state.proposal) {
152
- footer.push({ text: `${icons.cross} `, action: 'proposal:dismiss', width: 3 }, { text: `${icons.add} `, action: 'proposal:accept', width: 3 });
172
+ footer.push({ text: `${icons.add} `, action: 'proposal:accept', ...(secondary ? { secondaryAction: 'proposal:dismiss' } : {}), width: 3 });
153
173
  footer.push({ text: `${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, width: config.config.titleWidth });
154
174
  } else footer.push({ text: 'Pinote · no task', width: config.config.titleWidth });
155
175
  const titleParts = footer.length;
@@ -173,6 +193,7 @@ function view(data: Snapshot) {
173
193
  body = `${control.label} exceeds the editor limit; edit it outside this UI.\n${body}`;
174
194
  return false;
175
195
  });
196
+ if (scoped) for (const control of editable) control.revision = actionRevision(data, control);
176
197
  const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer };
177
198
  // JSON escaping and expanded footer formats can exceed the host budget even
178
199
  // with bounded row counts. Preserve navigation/filter controls ahead of rows.
@@ -190,14 +211,15 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [
190
211
  server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(async () => {
191
212
  if (request.params.uri !== URI) throw new Error('Unknown resource');
192
213
  const data = await snapshot(context(request.params._meta).state, extra.signal);
193
- return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data)) }] };
214
+ return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta))) }] };
194
215
  }));
195
216
  server.setRequestHandler(Action, (request, extra) => serial(async () => {
196
217
  const state = context(request.params._meta).state;
197
218
  const before = await snapshot(state, extra.signal);
198
- if (before.revision !== request.params.revision) throw new Error('View changed; refresh before acting');
219
+ const scoped = scopedActions(request.params._meta);
199
220
  const { action, value } = request.params;
200
- if (!view(before).controls.some((c) => c.id === action)) throw new Error('Action is not available in this view');
221
+ const control = view(before, scoped).controls.find((item) => item.id === action);
222
+ if (!control || (control.revision ?? before.revision) !== request.params.revision) staleAction();
201
223
  const next: State = { ...before.state };
202
224
  let draft: string | undefined, preview: string | undefined;
203
225
  if (action === 'proposal:accept' || action === 'proposal:dismiss') {
@@ -207,7 +229,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
207
229
  next.proposal = null;
208
230
  if (action === 'proposal:accept') {
209
231
  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;
232
+ await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal); next.selectedId = task.id;
211
233
  }
212
234
  } else if (action.startsWith('screen:')) next.screen = action.slice(7) as State['screen'];
213
235
  else if (action.startsWith('field:')) { next.screen = 'field'; next.field = action.slice(6); }
@@ -217,14 +239,17 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
217
239
  else if (action === 'preview') preview = (before.task!.text + '\n\n' + JSON.stringify(before.task!.agent_notes, null, 2)).slice(0, 20_000);
218
240
  else if (action.startsWith('select:')) {
219
241
  const id = Number(action.slice(7));
220
- if (!before.tasks.some((t) => t.id === id)) throw new Error('Task no longer selectable');
242
+ const candidate = before.tasks.find((task) => task.id === id);
243
+ if (!candidate) staleAction();
244
+ const target: Task = JSON.parse(await note(['agent', 'get', String(id)], extra.signal));
245
+ if (target.text !== candidate.text || target.state !== candidate.state || target.tag !== candidate.tag) staleAction();
221
246
  if (next.proposal) await consume(next.proposal.id);
222
- await note(['start', String(id)], extra.signal); next.selectedId = id; next.proposal = null;
247
+ await note(['agent', 'start', String(id), '--expected-updated-at', target.updated_at], extra.signal); next.selectedId = id; next.proposal = null;
223
248
  } else if (action === 'add') {
224
249
  if (next.proposal) await consume(next.proposal.id);
225
250
  next.proposal = null;
226
251
  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;
252
+ await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal); next.selectedId = task.id;
228
253
  } else if (action === 'done') {
229
254
  await note(['agent', 'done', String(before.task!.id), '--expected-updated-at', before.task!.updated_at], extra.signal);
230
255
  next.selectedId = null;
@@ -250,13 +275,13 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
250
275
  extra.signal.throwIfAborted();
251
276
  saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy);
252
277
  }
253
- const result = { view: view(await snapshot(next, extra.signal)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
278
+ const result = { view: view(await snapshot(next, extra.signal), scoped, secondaryActions(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
254
279
  await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
255
280
  return result;
256
281
  }));
257
282
  const empty = { type: 'object' as const, properties: {}, additionalProperties: false };
258
283
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
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 } },
284
+ { 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 left-clicks + to create/start/select or right-clicks it to dismiss (also available in the app menu). 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
285
  { 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
286
  { name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
262
287
  { name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
@@ -272,7 +297,7 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
272
297
  if (!args.text.trim()) throw new Error('Task text must not be blank');
273
298
  outgoing = boundedState({ ...state, selectedId: null, proposal: { ...args, id: randomUUID() } });
274
299
  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.' };
300
+ result = { status: 'Suggested; user can left-click + to accept or right-click + to dismiss (or use the app menu). Continue working without asking again.' };
276
301
  } else if (request.params.name === 'get_current') result = await selected(state, extra.signal);
277
302
  else if (request.params.name === 'fields') result = settings().config;
278
303
  else if (request.params.name === 'tags') result = JSON.parse(await note(['agent', 'tags'], extra.signal));
@@ -286,4 +311,36 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
286
311
  return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(outgoing ? { _meta: { 'native-ui/state': outgoing } } : {}) };
287
312
  } catch (error) { return { isError: true, content: [{ type: 'text', text: String(error) }] }; }
288
313
  }));
314
+ // Every MCP process observes shared storage, including writes by CLI/GTK/other
315
+ // sessions. Stat polling survives missing files and atomic config replacement.
316
+ server.oninitialized = () => {
317
+ server.oninitialized = undefined;
318
+ const dataHome = process.env.XDG_DATA_HOME;
319
+ const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
320
+ const paths = [database, `${database}-wal`, join(getAgentDir(), 'pi-note.json'), receiptDir];
321
+ let timer: ReturnType<typeof setTimeout> | undefined;
322
+ let closed = false;
323
+ const changed = () => {
324
+ if (closed || timer) return;
325
+ timer = setTimeout(() => {
326
+ timer = undefined;
327
+ void server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } }).catch((error) => {
328
+ if (!closed) console.error('Pinote UI update failed:', error);
329
+ });
330
+ }, 100);
331
+ timer.unref();
332
+ };
333
+ const listeners = paths.map((path) => {
334
+ const listener = (current: import('node:fs').Stats, previous: import('node:fs').Stats) => {
335
+ if (current.mtimeMs !== previous.mtimeMs || current.ctimeMs !== previous.ctimeMs || current.size !== previous.size || current.ino !== previous.ino || current.nlink !== previous.nlink) changed();
336
+ };
337
+ watchFile(path, { persistent: false, interval: 1000 }, listener);
338
+ return listener;
339
+ });
340
+ server.onclose = () => {
341
+ closed = true;
342
+ if (timer) clearTimeout(timer);
343
+ paths.forEach((path, index) => unwatchFile(path, listeners[index]));
344
+ };
345
+ };
289
346
  await server.connect(new StdioServerTransport());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.20.1",
3
+ "version": "0.22.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",