pi-note 0.20.1 → 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.
@@ -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.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]) < 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,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 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 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
- if (before.revision !== request.params.revision)
240
- throw new Error('View changed; refresh before acting');
261
+ const scoped = scopedActions(request.params._meta);
241
262
  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');
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
- if (!before.tasks.some((t) => t.id === id))
275
- throw new Error('Task no longer selectable');
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,7 +351,7 @@ 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
  }));
@@ -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.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
@@ -106,9 +106,21 @@ to reject historical proposals. Do not delete them while old sessions may resume
106
106
  They are replay protection, not a second selection database or an authorization
107
107
  boundary against local processes.
108
108
 
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.
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.
112
124
  Trusted services/agents have ordinary OS permissions; UI-only routing is not a
113
125
  secrecy or authorization boundary.
114
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.20.1' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
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]) < 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,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 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 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
- if (before.revision !== request.params.revision) throw new Error('View changed; refresh before acting');
216
+ const scoped = scopedActions(request.params._meta);
199
217
  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');
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
- if (!before.tasks.some((t) => t.id === id)) throw new Error('Task no longer selectable');
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,7 +272,7 @@ 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
  }));
@@ -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());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.20.1",
3
+ "version": "0.21.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",