pi-note 0.39.4 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp/core.ts CHANGED
@@ -14,6 +14,7 @@ import { stripVTControlCharacters } from 'node:util';
14
14
  import { defaultAgentGuidance, defaultFooterField, defaultHandoffPrompt, defaultNewSessionPrompt, effectiveFooterFields, parseAgentGuidance, parseFooterConfig, parseHandoffPrompt, parseNewSessionPrompt, readConfigDocument, readFooterDocument, saveFooterConfig } from '../footer-config.ts';
15
15
  import { proposalTitleOptions, proposalDescriptionOptions, proposalText } from '../proposal.ts';
16
16
  import type { FieldAutomation, FieldResult } from './field-events.ts';
17
+ import { discoverFieldWatchers, parseFieldWatchers, validateWatcherExecutable, type FieldWatcherConfig } from '../field-watchers.ts';
17
18
  import { taskTitle } from '../task-title.ts';
18
19
 
19
20
  const URI = 'pinote://ui';
@@ -35,7 +36,7 @@ const ProposalInput = z.union([
35
36
  ExistingProposal,
36
37
  ]);
37
38
  const Proposal = z.union([NewProposal.extend({ id: z.string().uuid() }), ExistingProposal.extend({ id: z.string().uuid() })]);
38
- const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), state: z.object({
39
+ const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), fieldReceipts: z.array(z.string().uuid()).max(4096).optional(), state: z.object({
39
40
  proposal: Proposal.nullable().optional(),
40
41
  launch: z.object({ task_id: TaskId, updated_at: z.string().min(1).max(128) }).strict().optional(),
41
42
  selectedId: z.number().int().positive().safe().nullable().optional(), screen: z.enum(['tasks', 'settings', 'field']).optional(),
@@ -104,7 +105,7 @@ function settings() {
104
105
  if (!['always', 'github-remote', 'never'].includes(policy)) throw new Error('Invalid taskOfferPolicy in pi-note.json');
105
106
  const guidanceOverridden = Object.hasOwn(root, 'agentGuidance');
106
107
  const guidance = guidanceOverridden ? parseAgentGuidance(root.agentGuidance) : defaultAgentGuidance;
107
- return { ...document, handoffPrompt, newSessionPrompt, policy, guidance, guidanceOverridden };
108
+ return { ...document, handoffPrompt, newSessionPrompt, policy, guidance, guidanceOverridden, watchers: discoverFieldWatchers(root) };
108
109
  }
109
110
  function agentGuidance(): string {
110
111
  const { value: root } = readConfigDocument();
@@ -117,7 +118,7 @@ async function selection(state: State, signal: AbortSignal) {
117
118
  if (proposal && 'task_id' in proposal && (!proposedTask || !['active', 'in_progress'].includes(proposedTask.state))) proposal = null;
118
119
  return { task, proposal, proposedTask };
119
120
  }
120
- async function snapshot(state: State, signal: AbortSignal, automation?: FieldAutomation, contextId?: string) {
121
+ async function snapshot(state: State, signal: AbortSignal, automation?: FieldAutomation, contextId?: string, receipts: readonly string[] = []) {
121
122
  const launchTask: Task | null = state.launch ? JSON.parse(await note(['agent', 'get', String(state.launch.task_id)], signal)) : null;
122
123
  const tasks: Task[] = state.launch
123
124
  ? launchTask && ['active', 'in_progress'].includes(launchTask.state) ? [launchTask] : []
@@ -129,7 +130,7 @@ async function snapshot(state: State, signal: AbortSignal, automation?: FieldAut
129
130
  if (state.screen === 'field' && !fields(config.config, task?.agent_notes).some((f) => f.name === state.field)) state.screen = 'settings';
130
131
  state = Context.parse({ conversationId: 'canonical', state }).state;
131
132
  const fieldStatuses = task ? automation?.statuses(task) ?? [] : [];
132
- const fieldNotifications = task && contextId ? automation?.notices(task, contextId) ?? [] : [];
133
+ const fieldNotifications = task && contextId ? automation?.notices(task, contextId, receipts) ?? [] : [];
133
134
  const revision = createHash('sha256').update(JSON.stringify({ state, tasks, task, proposedTask, raw: config.raw, fieldStatuses, fieldNotifications })).digest('hex');
134
135
  return { state, tasks, task, proposedTask, suggestion, config, revision, fieldStatuses, fieldNotifications };
135
136
  }
@@ -159,6 +160,7 @@ function footerFeatures(meta: Record<string, unknown> | undefined) {
159
160
  footerNativeActions: z.object({ footerNativeActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
160
161
  footerStyles: z.object({ footerStyles: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
161
162
  fieldEvents: z.object({ fieldEvents: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
163
+ fieldWatchers: z.object({ fieldWatchers: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
162
164
  footerDoubleActions: z.object({ footerDoubleActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
163
165
  };
164
166
  }
@@ -170,7 +172,7 @@ function fullPreview(task: { text: string; agent_notes?: Record<string, string>
170
172
  const document = task.text + notes;
171
173
  return document.length <= 20_000 ? document : 'Full preview exceeds the 20000-character display limit. Nothing has been truncated here: read the complete task and handoff notes through the Pinote CLI or service (get_task).';
172
174
  }
173
- function view(data: Snapshot, scoped = false, secondary = false, features: { footerNativeActions?: boolean; footerStyles?: boolean; footerDoubleActions?: boolean; fieldEvents?: boolean } = {}) {
175
+ function view(data: Snapshot, scoped = false, secondary = false, features: { footerNativeActions?: boolean; footerStyles?: boolean; footerDoubleActions?: boolean; fieldEvents?: boolean; fieldWatchers?: boolean } = {}) {
174
176
  const { tasks, task, suggestion, config, revision } = data;
175
177
  const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
176
178
  const controls: Control[] = [];
@@ -202,6 +204,16 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
202
204
  for (const [key, label, description] of [['titleWidth', 'Task title width', '3–1000 terminal cells.'], ['fieldWidth', 'Default field width', '3–1000 terminal cells; inherited by fields without a width override.'], ['maxFields', 'Maximum visible fields', '0–64 fields; 0 hides all task fields.']] as const) controls.push({ id: key, label, description, kind: 'number', value: config.config[key], confirm: 'Save this global footer setting?' });
203
205
  controls.push({ id: 'add-field', label: 'Add field', icon: icons.add, kind: 'text', value: '', description: 'Unique nonblank field name, at most 64 characters; Bar is reserved. Changes display definitions, not task values.', confirm: 'Add this global footer field?' });
204
206
  fields(config.config, task?.agent_notes).forEach((field) => action(`field:${field.name}`, field.name, { description: `${field.label || '(no label)'} · ${field.format}` }));
207
+ controls.push({ id: 'add-watcher', label: 'Add watcher file', icon: icons.add, kind: 'text', value: '',
208
+ description: 'Absolute executable path on the service machine. Added disabled; enable separately after reviewing trusted code.',
209
+ confirm: 'Add this trusted watcher file to shared fieldWatchers (disabled)?' });
210
+ for (const watcher of config.watchers) {
211
+ controls.push({ id: `watcher:${watcher.id}`, label: watcher.name, kind: 'toggle', value: watcher.enabled,
212
+ description: `${watcher.bundled ? 'Bundled' : 'Custom'} · ${watcher.path}`,
213
+ confirm: `Change this shared watcher?\n${watcher.path}\nEnabling executes trusted code with the service user’s permissions and credentials. Its alerts automatically trigger the owning Pi worker to investigate and fix the current task. Disabling stops checks and hides its results.` });
214
+ if (!watcher.bundled) action(`remove-watcher:${watcher.id}`, `Remove ${watcher.name}`, { icon: icons.cross,
215
+ description: watcher.path, confirm: `Remove this watcher from shared settings? The file and task values are retained.\n${watcher.path}` });
216
+ }
205
217
  } else if (state.screen === 'field') {
206
218
  const field = fields(config.config, task?.agent_notes).find((f) => f.name === state.field);
207
219
  if (!field) throw new Error('Field no longer exists; open Settings again');
@@ -222,6 +234,8 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
222
234
  const matches = tasks.filter((t) => state.launch
223
235
  ? t.id === state.launch.task_id && t.updated_at === state.launch.updated_at
224
236
  : terms.every((term) => `${t.text} ${t.tag ?? ''} #${t.id}`.toLowerCase().includes(term)));
237
+ // IDs are assigned by SQLite AUTOINCREMENT at creation and retained on edits/restores.
238
+ matches.sort((a, b) => b.id - a.id);
225
239
  for (const candidate of matches.slice(0, 200)) action(`select:${candidate.id}`, taskTitle(candidate.text), { description: `#${candidate.id} · ${candidate.tag || 'No tag'} · ${humanStatus(candidate.state)}${candidate.id === task?.id ? ' · CURRENT' : ''} · Select and start this task for this conversation` });
226
240
  if (matches.length > 200) body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
227
241
  }
@@ -274,7 +288,7 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
274
288
  return false;
275
289
  });
276
290
  if (scoped) for (const control of editable) control.revision = actionRevision(data, control);
277
- const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer, ...(features.fieldEvents ? { fieldNotifications: data.fieldNotifications } : {}) };
291
+ const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer, ...(features.fieldEvents ? { fieldNotifications: data.fieldNotifications.map((notice) => ({ ...notice, ...(features.fieldWatchers ? { triggerWorker: true } : {}) })) } : {}) };
278
292
  // JSON escaping and expanded footer formats can exceed the host budget even
279
293
  // with bounded row counts. Preserve navigation/filter controls ahead of rows.
280
294
  let shortened = false;
@@ -337,14 +351,14 @@ server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(a
337
351
  }
338
352
  if (request.params.uri !== URI) throw new Error('Unknown resource');
339
353
  const caller = context(request.params._meta);
340
- const data = await snapshot(caller.state, extra.signal, options.fieldAutomation, caller.contextId ?? caller.conversationId);
354
+ const data = await snapshot(caller.state, extra.signal, options.fieldAutomation, caller.contextId ?? caller.conversationId, caller.fieldReceipts);
341
355
  return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta), footerFeatures(request.params._meta))) }] };
342
356
  }));
343
357
  server.setRequestHandler(Action, (request, extra) => serial(async () => {
344
358
  const caller = context(request.params._meta);
345
359
  const state = caller.state;
346
360
  const owner = caller.contextId ?? caller.conversationId;
347
- const before = await snapshot(state, extra.signal, options.fieldAutomation, owner);
361
+ const before = await snapshot(state, extra.signal, options.fieldAutomation, owner, caller.fieldReceipts);
348
362
  const scoped = scopedActions(request.params._meta);
349
363
  const { action, value } = request.params;
350
364
  const control = view(before, scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta)).controls.find((item) => item.id === action);
@@ -389,7 +403,25 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
389
403
  const cfg = structuredClone(before.config.config);
390
404
  let handoff = before.config.handoffPrompt, newSession = before.config.newSessionPrompt, policy = before.config.policy;
391
405
  let guidance: string | null | undefined;
392
- if (action === 'agentGuidance') guidance = parseAgentGuidance(value);
406
+ let watchers: FieldWatcherConfig[] | undefined;
407
+ if (action === 'add-watcher' || action.startsWith('watcher:') || action.startsWith('remove-watcher:')) {
408
+ watchers = before.config.watchers.map(({ path, enabled }) => ({ path, enabled }));
409
+ if (action === 'add-watcher') {
410
+ const entry = parseFieldWatchers({ fieldWatchers: [{ path: z.string().parse(value), enabled: false }] })[0];
411
+ validateWatcherExecutable(entry.path);
412
+ watchers.push(entry);
413
+ } else {
414
+ const watcher = before.config.watchers.find((item) => item.id === action.split(':')[1]);
415
+ if (!watcher) staleAction();
416
+ if (action.startsWith('remove-watcher:')) watchers = watchers.filter((item) => item.path !== watcher.path);
417
+ else {
418
+ const enabled = z.boolean().parse(value);
419
+ if (enabled) validateWatcherExecutable(watcher.path);
420
+ watchers.find((item) => item.path === watcher.path)!.enabled = enabled;
421
+ }
422
+ }
423
+ watchers = parseFieldWatchers({ fieldWatchers: watchers });
424
+ } else if (action === 'agentGuidance') guidance = parseAgentGuidance(value);
393
425
  else if (action === 'reset-agent-guidance') guidance = null;
394
426
  else if (action === 'handoffPrompt') handoff = z.string().parse(value);
395
427
  else if (action === 'newSessionPrompt') newSession = z.string().parse(value);
@@ -408,10 +440,10 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
408
440
  }
409
441
  }
410
442
  extra.signal.throwIfAborted();
411
- saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy, guidance);
443
+ saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy, guidance, watchers);
412
444
  }
413
445
  if (next.selectedId && (action.startsWith('select:') || action === 'proposal:accept' || action === 'add')) options.fieldAutomation?.claim(next.selectedId, owner, true);
414
- const result = { view: view(await snapshot(next, extra.signal, options.fieldAutomation, owner), scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
446
+ const result = { view: view(await snapshot(next, extra.signal, options.fieldAutomation, owner, caller.fieldReceipts), scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
415
447
  await notifyChanged();
416
448
  return result;
417
449
  }));
@@ -7,6 +7,7 @@ import { homedir } from 'node:os';
7
7
  import type { DatabaseSync } from 'node:sqlite';
8
8
  import { z } from 'zod';
9
9
  import { readConfigDocument } from '../footer-config.ts';
10
+ import { discoverFieldWatchers, type FieldWatcher } from '../field-watchers.ts';
10
11
 
11
12
  const exec = promisify(execFile);
12
13
  const integer = z.number().int().nonnegative().safe();
@@ -23,20 +24,12 @@ export const FieldResult = z.object({
23
24
  }).strict();
24
25
  export type FieldResult = z.infer<typeof FieldResult>;
25
26
  type FieldEvent = z.infer<typeof Event>;
26
- type Job = { event: FieldEvent; result?: FieldResult; due: number | null; attempts: number; checkedAt?: string; stale?: boolean };
27
- export type FieldStatus = { field: string; value: string; result: FieldResult; checkedAt?: string; stale: boolean };
27
+ type Job = { watcherId: string; event: FieldEvent; result?: FieldResult; due: number | null; attempts: number; checkedAt?: string; stale?: boolean };
28
+ export type FieldStatus = { watcherId: string; field: string; value: string; result: FieldResult; checkedAt?: string; stale: boolean };
28
29
  export type FieldNotice = { id: string; taskId: number; field: string; message: string };
29
30
  type Task = { id: number; state: string; updated_at: string; agent_notes: Record<string, string> };
30
31
  type Row = { job: string };
31
32
 
32
- export function configuredFieldHandler(): string | undefined {
33
- const { value: config } = readConfigDocument();
34
- const handler = config.fieldHandler;
35
- if (handler === undefined || handler === null) return undefined;
36
- if (typeof handler !== 'string' || !isAbsolute(handler) || /[\u0000-\u001f\u007f]/u.test(handler)) throw new Error('fieldHandler must be an absolute executable file path');
37
- return handler;
38
- }
39
-
40
33
  /** One bounded invocation, no shell. Kill the process group on cancellation/overflow. */
41
34
  export function runFieldHandler(handler: string, input: unknown, signal: AbortSignal, timeoutMs = 30_000): Promise<FieldResult> {
42
35
  signal.throwIfAborted();
@@ -71,20 +64,26 @@ export class FieldAutomation {
71
64
  private lifetime = new AbortController();
72
65
  private timer?: ReturnType<typeof setTimeout>;
73
66
  private work?: Promise<void>;
74
- private handler: string;
67
+ private watchers: () => FieldWatcher[];
68
+ private enabled: FieldWatcher[] = [];
69
+ private signature = '';
70
+ private generation = 0;
71
+ private invocation?: AbortController;
72
+ private ticking = false;
75
73
  private noteCommand: string;
76
74
  private changed: () => void;
77
75
  private log: () => void;
78
- private constructor(db: DatabaseSync, handler: string, noteCommand: string, changed: () => void, log: () => void) {
79
- this.db = db; this.handler = handler; this.noteCommand = noteCommand; this.changed = changed; this.log = log;
76
+ private constructor(db: DatabaseSync, watchers: () => FieldWatcher[], noteCommand: string, changed: () => void, log: () => void) {
77
+ this.db = db; this.watchers = watchers; this.noteCommand = noteCommand; this.changed = changed; this.log = log;
80
78
  db.exec(`PRAGMA busy_timeout=5000;
81
- CREATE TABLE IF NOT EXISTS scheduler (id INTEGER PRIMARY KEY CHECK(id=1), cursor INTEGER NOT NULL DEFAULT 0, handler TEXT NOT NULL, owner TEXT, lease_until INTEGER NOT NULL DEFAULT 0);
82
- CREATE TABLE IF NOT EXISTS jobs (task_id INTEGER NOT NULL, field TEXT NOT NULL, job TEXT NOT NULL, PRIMARY KEY(task_id, field));
79
+ CREATE TABLE IF NOT EXISTS fw_scheduler (id INTEGER PRIMARY KEY CHECK(id=1), owner TEXT, lease_until INTEGER NOT NULL DEFAULT 0);
80
+ CREATE TABLE IF NOT EXISTS fw_watchers (id TEXT PRIMARY KEY, path TEXT NOT NULL, cursor INTEGER NOT NULL DEFAULT 0);
81
+ CREATE TABLE IF NOT EXISTS fw_jobs (watcher_id TEXT NOT NULL, task_id INTEGER NOT NULL, field TEXT NOT NULL, job TEXT NOT NULL, PRIMARY KEY(watcher_id,task_id,field));
83
82
  CREATE TABLE IF NOT EXISTS owners (task_id INTEGER PRIMARY KEY, context_id TEXT NOT NULL);
84
- CREATE TABLE IF NOT EXISTS notices (task_id INTEGER NOT NULL, field TEXT NOT NULL, id TEXT NOT NULL, message TEXT NOT NULL, PRIMARY KEY(task_id, field));`);
85
- db.prepare('INSERT OR IGNORE INTO scheduler(id, handler) VALUES (1, ?)').run(handler);
83
+ CREATE TABLE IF NOT EXISTS fw_notices (watcher_id TEXT NOT NULL, task_id INTEGER NOT NULL, field TEXT NOT NULL, id TEXT NOT NULL, message TEXT NOT NULL, PRIMARY KEY(watcher_id,task_id,field));
84
+ INSERT OR IGNORE INTO fw_scheduler(id) VALUES (1);`);
86
85
  }
87
- static async open(options: { handler: string; path?: string; noteCommand?: string; changed?: () => void; log?: () => void }): Promise<FieldAutomation> {
86
+ static async open(options: { watchers?: () => FieldWatcher[]; path?: string; noteCommand?: string; changed?: () => void; log?: () => void } = {}): Promise<FieldAutomation> {
88
87
  const dataHome = process.env.XDG_DATA_HOME;
89
88
  const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
90
89
  const namespace = createHash('sha256').update(database).digest('hex').slice(0, 16);
@@ -97,7 +96,7 @@ export class FieldAutomation {
97
96
  if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o077)) throw new Error('Unsafe field automation database');
98
97
  const { DatabaseSync } = await import('node:sqlite');
99
98
  const db = new DatabaseSync(path);
100
- try { return new FieldAutomation(db, options.handler, options.noteCommand ?? process.env.PINOTE_NOTE_COMMAND ?? 'note', options.changed ?? (() => {}), options.log ?? (() => {})); }
99
+ try { return new FieldAutomation(db, options.watchers ?? (() => discoverFieldWatchers(readConfigDocument().value)), options.noteCommand ?? process.env.PINOTE_NOTE_COMMAND ?? 'note', options.changed ?? (() => {}), options.log ?? (() => {})); }
101
100
  catch (error) { db.close(); throw error; }
102
101
  }
103
102
  private async note(args: string[]): Promise<unknown> {
@@ -105,7 +104,7 @@ export class FieldAutomation {
105
104
  return JSON.parse(stdout);
106
105
  }
107
106
  private leased(): boolean {
108
- return Boolean(this.db.prepare('SELECT 1 FROM scheduler WHERE id=1 AND owner=? AND lease_until>?').get(this.owner, Date.now()));
107
+ return Boolean(this.db.prepare('SELECT 1 FROM fw_scheduler WHERE id=1 AND owner=? AND lease_until>?').get(this.owner, Date.now()));
109
108
  }
110
109
  private transaction(work: () => void): void {
111
110
  this.db.exec('BEGIN IMMEDIATE');
@@ -114,13 +113,33 @@ export class FieldAutomation {
114
113
  work(); this.db.exec('COMMIT');
115
114
  } catch (error) { this.db.exec('ROLLBACK'); throw error; }
116
115
  }
116
+ private reconcile(): void {
117
+ const enabled = this.watchers().filter((watcher) => watcher.enabled);
118
+ if (enabled.length > 32 || new Set(enabled.map((w) => w.id)).size !== enabled.length) throw new Error('Invalid enabled watchers');
119
+ const signature = JSON.stringify(enabled.map((w) => [w.id, w.path]));
120
+ if (signature === this.signature) return;
121
+ this.generation++; this.invocation?.abort();
122
+ this.db.exec('BEGIN IMMEDIATE');
123
+ try {
124
+ for (const row of this.db.prepare('SELECT id,path FROM fw_watchers').all()) {
125
+ if (!enabled.some((w) => w.id === row.id && w.path === row.path)) {
126
+ this.db.prepare('DELETE FROM fw_jobs WHERE watcher_id=?').run(row.id);
127
+ this.db.prepare('DELETE FROM fw_notices WHERE watcher_id=?').run(row.id);
128
+ this.db.prepare('DELETE FROM fw_watchers WHERE id=?').run(row.id);
129
+ }
130
+ }
131
+ for (const watcher of enabled) this.db.prepare('INSERT OR IGNORE INTO fw_watchers(id,path) VALUES (?,?)').run(watcher.id, watcher.path);
132
+ this.db.exec('COMMIT');
133
+ } catch (error) { this.db.exec('ROLLBACK'); throw error; }
134
+ this.enabled = enabled; this.signature = signature; this.changed();
135
+ }
117
136
  private save(job: Job): void {
118
- this.db.prepare('INSERT INTO jobs(task_id,field,job) VALUES (?,?,?) ON CONFLICT(task_id,field) DO UPDATE SET job=excluded.job')
119
- .run(job.event.taskId, job.event.field, JSON.stringify(job));
137
+ this.db.prepare('INSERT INTO fw_jobs(watcher_id,task_id,field,job) VALUES (?,?,?,?) ON CONFLICT(watcher_id,task_id,field) DO UPDATE SET job=excluded.job')
138
+ .run(job.watcherId, job.event.taskId, job.event.field, JSON.stringify(job));
120
139
  }
121
- private clear(taskId: number, name: string): void {
122
- this.db.prepare('DELETE FROM jobs WHERE task_id=? AND field=?').run(taskId, name);
123
- this.db.prepare('DELETE FROM notices WHERE task_id=? AND field=?').run(taskId, name);
140
+ private clear(watcherId: string, taskId: number, name: string): void {
141
+ this.db.prepare('DELETE FROM fw_jobs WHERE watcher_id=? AND task_id=? AND field=?').run(watcherId, taskId, name);
142
+ this.db.prepare('DELETE FROM fw_notices WHERE watcher_id=? AND task_id=? AND field=?').run(watcherId, taskId, name);
124
143
  }
125
144
  /** Explicit selection/write owns alerts; background reads can only claim unowned tasks. */
126
145
  claim(taskId: number, contextId: string, replace = false): void {
@@ -129,19 +148,21 @@ export class FieldAutomation {
129
148
  : 'INSERT OR IGNORE INTO owners(task_id,context_id) VALUES (?,?)').run(taskId, contextId);
130
149
  }
131
150
  statuses(task: Task): FieldStatus[] {
132
- return (this.db.prepare('SELECT job FROM jobs WHERE task_id=?').all(task.id) as Row[]).flatMap(({ job: raw }) => {
151
+ this.reconcile();
152
+ return this.enabled.flatMap((watcher) => (this.db.prepare('SELECT job FROM fw_jobs WHERE watcher_id=? AND task_id=?').all(watcher.id, task.id) as Row[]).flatMap(({ job: raw }) => {
133
153
  const job = JSON.parse(raw) as Job;
134
- if (job.event.value === null || job.event.value !== task.agent_notes[job.event.field] || !job.result) return [];
135
- return [{ field: job.event.field, value: job.event.value, result: job.result, checkedAt: job.checkedAt, stale: job.stale ?? false }];
136
- });
154
+ if (job.event.value === null || job.event.value !== task.agent_notes[job.event.field] || !job.result || Object.keys(job.result).length === 0) return [];
155
+ return [{ watcherId: watcher.id, field: job.event.field, value: job.event.value, result: job.result, checkedAt: job.checkedAt, stale: job.stale ?? false }];
156
+ }));
137
157
  }
138
- notices(task: Task, contextId: string): FieldNotice[] {
158
+ notices(task: Task, contextId: string, receipts: readonly string[] = []): FieldNotice[] {
139
159
  this.claim(task.id, contextId);
140
160
  const owner = this.db.prepare('SELECT context_id FROM owners WHERE task_id=?').get(task.id);
141
161
  if (owner?.context_id !== contextId) return [];
142
- const fields = new Set(this.statuses(task).filter((s) => !s.stale && s.result.notification).map((s) => s.field));
143
- return (this.db.prepare('SELECT id, field, message FROM notices WHERE task_id=?').all(task.id) as { id: string; field: string; message: string }[])
144
- .filter((n) => fields.has(n.field)).map((n) => ({ ...n, taskId: task.id }));
162
+ const received = new Set(receipts);
163
+ const fields = new Set(this.statuses(task).filter((s) => !s.stale && s.result.notification).map((s) => `${s.watcherId}:${s.field}`));
164
+ return (this.db.prepare('SELECT watcher_id, id, field, message FROM fw_notices WHERE task_id=?').all(task.id) as { watcher_id: string; id: string; field: string; message: string }[])
165
+ .filter((n) => !received.has(n.id) && fields.has(`${n.watcher_id}:${n.field}`)).slice(0, 64).map(({ id, field, message }) => ({ id, field, message, taskId: task.id }));
145
166
  }
146
167
  start(): void {
147
168
  const schedule = () => {
@@ -154,80 +175,103 @@ export class FieldAutomation {
154
175
  };
155
176
  schedule();
156
177
  }
157
- async tick(): Promise<void> {
178
+ tick(): Promise<void> {
179
+ if (this.ticking) return this.work ?? Promise.resolve();
180
+ this.ticking = true;
181
+ const work = this.performTick().finally(() => { this.ticking = false; if (this.work === work) this.work = undefined; });
182
+ this.work = work;
183
+ return work;
184
+ }
185
+ private async performTick(): Promise<void> {
158
186
  this.lifetime.signal.throwIfAborted();
159
- const acquired = this.db.prepare('UPDATE scheduler SET owner=?, lease_until=? WHERE id=1 AND (lease_until<=? OR owner=?)')
187
+ const acquired = this.db.prepare('UPDATE fw_scheduler SET owner=?, lease_until=? WHERE id=1 AND (lease_until<=? OR owner=?)')
160
188
  .run(this.owner, Date.now() + 90_000, Date.now(), this.owner).changes;
161
189
  if (!acquired) return;
162
190
  try {
163
- let state = this.db.prepare('SELECT cursor, handler FROM scheduler WHERE id=1').get() as { cursor: number; handler: string };
164
- if (state.handler !== this.handler) {
165
- this.transaction(() => {
166
- this.db.exec('DELETE FROM jobs; DELETE FROM notices;');
167
- this.db.prepare('UPDATE scheduler SET cursor=0, handler=? WHERE id=1').run(this.handler);
168
- });
169
- state = { cursor: 0, handler: this.handler }; this.changed();
191
+ this.reconcile();
192
+ const generation = this.generation;
193
+ const valid = () => {
194
+ this.reconcile();
195
+ if (!this.leased() || generation !== this.generation || this.lifetime.signal.aborted) return false;
196
+ // Many watchers can make intake longer than one lease; each bounded operation renews it.
197
+ this.db.prepare('UPDATE fw_scheduler SET lease_until=? WHERE id=1 AND owner=?').run(Date.now() + 90_000, this.owner);
198
+ return true;
170
199
  }
171
- const batch = Batch.parse(await this.note(['field-events', '--after', String(state.cursor), '--limit', '25']));
172
- if (!this.leased()) return;
173
- if (batch.cursor < state.cursor || batch.events.some((e) => e.id <= state.cursor || e.id > batch.cursor)) throw new Error('Invalid field event cursor');
174
- if (batch.cursor !== state.cursor) {
175
- this.transaction(() => {
176
- for (const event of batch.events) {
177
- // New input invalidates both old display and pending alerts immediately.
178
- this.clear(event.taskId, event.field);
179
- this.save({ event, due: Date.now(), attempts: 0 });
180
- }
181
- this.db.prepare('UPDATE scheduler SET cursor=? WHERE id=1').run(batch.cursor);
182
- });
183
- this.changed();
184
- return; // Drain durable history before executing; coalesce superseded values.
200
+ let drained = false;
201
+ for (const watcher of this.enabled) {
202
+ const state = this.db.prepare('SELECT cursor FROM fw_watchers WHERE id=?').get(watcher.id) as { cursor: number };
203
+ const batch = Batch.parse(await this.note(['field-events', '--after', String(state.cursor), '--limit', '25']));
204
+ if (!valid()) return;
205
+ if (batch.cursor < state.cursor || batch.events.some((e) => e.id <= state.cursor || e.id > batch.cursor)) throw new Error('Invalid field event cursor');
206
+ if (batch.cursor !== state.cursor) {
207
+ this.transaction(() => {
208
+ for (const event of batch.events) {
209
+ this.clear(watcher.id, event.taskId, event.field);
210
+ this.save({ watcherId: watcher.id, event, due: Date.now(), attempts: 0 });
211
+ }
212
+ this.db.prepare('UPDATE fw_watchers SET cursor=? WHERE id=?').run(batch.cursor, watcher.id);
213
+ });
214
+ drained = true;
215
+ }
185
216
  }
186
- const jobs = (this.db.prepare('SELECT job FROM jobs').all() as Row[]).map(({ job }) => JSON.parse(job) as Job);
217
+ if (drained) { this.changed(); return; }
218
+ const jobs = (this.db.prepare('SELECT job FROM fw_jobs').all() as Row[]).map(({ job }) => JSON.parse(job) as Job);
187
219
  const job = jobs.filter((j) => j.due !== null && j.due <= Date.now()).sort((a, b) => a.due! - b.due!)[0];
188
220
  if (!job) return;
189
221
  const event = job.event;
222
+ const watcher = this.enabled.find((w) => w.id === job.watcherId);
223
+ if (!watcher) return;
190
224
  const task = await this.note(['agent', 'get', String(event.taskId)]) as Task;
191
- if (!this.leased()) return;
225
+ if (!valid()) return;
192
226
  const currentValue = task && ['active', 'in_progress'].includes(task.state) ? task.agent_notes[event.field] ?? null : null;
193
- if (event.value !== currentValue) { this.transaction(() => this.clear(event.taskId, event.field)); this.changed(); return; }
227
+ if (event.value !== currentValue) { this.transaction(() => this.clear(job.watcherId, event.taskId, event.field)); this.changed(); return; }
194
228
  let result: FieldResult, retryAfterSeconds = 0;
229
+ const invocation = new AbortController();
230
+ this.invocation = invocation;
231
+ const abort = () => invocation.abort();
232
+ this.lifetime.signal.addEventListener('abort', abort, { once: true });
233
+ const poll = setInterval(() => { try { this.reconcile(); } catch { invocation.abort(); } }, 100);
195
234
  try {
196
- result = await runFieldHandler(this.handler, {
235
+ result = await runFieldHandler(watcher.path, {
197
236
  version: 1, eventId: `${event.id}:${event.field}`, taskId: event.taskId, field: event.field,
198
237
  event: job.checkedAt ? 'check' : event.event, value: event.value, previousValue: event.previousValue,
199
238
  ...(job.result ? { previousResult: job.result } : {}),
200
- }, this.lifetime.signal);
239
+ }, invocation.signal);
201
240
  if (result.state === 'error') {
202
241
  retryAfterSeconds = result.recheckAfterSeconds ?? 0;
203
242
  throw new Error('Handler reported unavailable data');
204
243
  }
205
244
  } catch {
206
- if (!this.leased() || this.lifetime.signal.aborted) return;
245
+ if (!valid() || invocation.signal.aborted) return;
207
246
  job.attempts++; job.stale = true;
208
247
  job.result ??= { state: 'error', summary: 'Field handler unavailable', display: { color: 'muted' } };
209
248
  job.due = Date.now() + Math.max(retryAfterSeconds, Math.min(3600, 30 * 2 ** Math.min(job.attempts - 1, 7))) * 1000;
210
249
  this.transaction(() => this.save(job)); this.changed(); this.log(); return;
211
250
  }
251
+ finally {
252
+ clearInterval(poll); this.lifetime.signal.removeEventListener('abort', abort);
253
+ if (this.invocation === invocation) this.invocation = undefined;
254
+ }
255
+ if (!valid() || invocation.signal.aborted) return;
212
256
  const latest = await this.note(['agent', 'get', String(event.taskId)]) as Task;
213
- if (!this.leased()) return;
257
+ if (!valid()) return;
214
258
  if (latest?.updated_at !== task?.updated_at) return; // Includes remove/re-add (ABA); next tick drains new events.
215
259
  this.transaction(() => {
216
- if (event.value === null) this.clear(event.taskId, event.field);
260
+ if (event.value === null) this.clear(job.watcherId, event.taskId, event.field);
217
261
  else {
218
262
  const notification = result.notification;
219
- if (!notification) this.db.prepare('DELETE FROM notices WHERE task_id=? AND field=?').run(event.taskId, event.field);
263
+ if (!notification) this.db.prepare('DELETE FROM fw_notices WHERE watcher_id=? AND task_id=? AND field=?').run(job.watcherId, event.taskId, event.field);
220
264
  else if (JSON.stringify(notification) !== JSON.stringify(job.result?.notification)) {
221
- this.db.prepare('INSERT INTO notices(task_id,field,id,message) VALUES (?,?,?,?) ON CONFLICT(task_id,field) DO UPDATE SET id=excluded.id,message=excluded.message')
222
- .run(event.taskId, event.field, randomUUID(), notification.message);
265
+ this.db.prepare('INSERT INTO fw_notices(watcher_id,task_id,field,id,message) VALUES (?,?,?,?,?) ON CONFLICT(watcher_id,task_id,field) DO UPDATE SET id=excluded.id,message=excluded.message')
266
+ .run(job.watcherId, event.taskId, event.field, randomUUID(), notification.message);
223
267
  }
224
- this.save({ event, result, due: result.recheckAfterSeconds ? Date.now() + result.recheckAfterSeconds * 1000 : null,
268
+ this.save({ watcherId: job.watcherId, event, result, due: result.recheckAfterSeconds ? Date.now() + result.recheckAfterSeconds * 1000 : null,
225
269
  attempts: 0, checkedAt: new Date().toISOString(), stale: false });
226
270
  }
227
271
  });
228
272
  this.changed();
229
273
  } finally {
230
- this.db.prepare('UPDATE scheduler SET lease_until=0 WHERE id=1 AND owner=?').run(this.owner);
274
+ this.db.prepare('UPDATE fw_scheduler SET lease_until=0 WHERE id=1 AND owner=?').run(this.owner);
231
275
  }
232
276
  }
233
277
  async close(): Promise<void> {
package/mcp/http.ts CHANGED
@@ -9,7 +9,7 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
9
9
  import { createPinoteServer } from './core.ts';
10
10
  import { completionEvents } from './events.ts';
11
11
  import { pinoteToken } from '../token.ts';
12
- import { configuredFieldHandler, FieldAutomation } from './field-events.ts';
12
+ import { FieldAutomation } from './field-events.ts';
13
13
 
14
14
  export interface PinoteHttpOptions {
15
15
  token?: string;
@@ -61,11 +61,10 @@ export async function startPinoteHttpServer(options: PinoteHttpOptions = {}) {
61
61
  if (configuredHosts?.some((value) => !/^(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9.-]+)(?::[0-9]+)?$/.test(value))) throw new Error('Invalid allowed HTTP authority');
62
62
  let allowedHosts = new Set(configuredHosts);
63
63
  const fieldListeners = new Set<() => void>();
64
- const handler = configuredFieldHandler();
65
- const fieldAutomation = handler ? await FieldAutomation.open({ handler,
64
+ const fieldAutomation = await FieldAutomation.open({
66
65
  changed: () => { for (const listener of fieldListeners) listener(); },
67
66
  log: () => log('field automation check failed; will retry'),
68
- }) : undefined;
67
+ });
69
68
  const reject = (res: ServerResponse, status: number) => { res.writeHead(status, { 'Content-Type': 'text/plain' }); res.end(`${status}\n`); };
70
69
  const single = (req: IncomingMessage, name: string) => {
71
70
  const values = req.rawHeaders.filter((_, i) => i % 2 === 0 && req.rawHeaders[i].toLowerCase() === name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.39.4",
3
+ "version": "0.40.1",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,6 +32,8 @@
32
32
  "LOCAL.md",
33
33
  "deploy",
34
34
  "examples",
35
+ "watchers",
36
+ "field-watchers.ts",
35
37
  "footer-status.ts",
36
38
  "footer-links.ts",
37
39
  "footer-config.ts",
package/remote/menu.ts CHANGED
@@ -2,11 +2,12 @@ import { DynamicBorder, getSelectListTheme, keyText, type ExtensionContext } fro
2
2
  import { Input, Key, matchesKey, SelectList, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Container, type TUI } from '@earendil-works/pi-tui';
3
3
  import { clean, editorText, type NativeView } from './protocol.ts';
4
4
 
5
- export interface MenuState { search: string; selected?: string; input?: Input }
5
+ export interface MenuState { search: string; selected?: string; input?: Input; pendingSearch?: Promise<void> }
6
6
  export interface MenuChoice { id: string }
7
7
  export interface MenuOptions {
8
8
  search?: 'none' | 'local' | 'tasks';
9
9
  searchLabel?: string;
10
+ tab?: 'settings' | 'tasks';
10
11
  onSearch?: (query: string) => Promise<void>;
11
12
  notice?: string;
12
13
  }
@@ -55,21 +56,28 @@ export function remoteMenu(ctx: ExtensionContext, view: NativeView, state: MenuS
55
56
  const fresh = () => !tasks || state.search === String(view.state.search ?? '');
56
57
  const schedule = () => {
57
58
  clearTimeout(timer);
58
- if (fresh() || !options.onSearch || searching || disposed) return;
59
+ if (fresh() || !options.onSearch || state.pendingSearch || disposed) return;
59
60
  timer = setTimeout(() => {
60
61
  if (disposed || signal.aborted) return;
61
62
  searching = true;
62
- void options.onSearch!(state.search).catch(() => { error = 'Search unavailable · Ctrl+R refresh'; }).finally(() => {
63
+ const query = state.search;
64
+ const pending = options.onSearch!(query).catch(() => { error = 'Search unavailable · Ctrl+R refresh'; });
65
+ state.pendingSearch = pending;
66
+ void pending.then(() => {
67
+ if (state.pendingSearch === pending) state.pendingSearch = undefined;
63
68
  searching = false;
64
- if (!disposed) tui.requestRender();
69
+ if (!disposed) { if (!error && state.search !== query) schedule(); tui.requestRender(); }
65
70
  });
66
71
  }, 250);
67
72
  };
73
+ // A notification can replace this component before its search action settles.
74
+ // Share the in-flight read so replacements never queue stale duplicate searches.
75
+ if (state.pendingSearch) void state.pendingSearch.then(() => { if (!disposed) { schedule(); tui.requestRender(); } });
68
76
  schedule();
69
77
  const activate = (preview = false) => {
70
78
  if (signal.aborted || !usable || !layout || !fresh() || searching || geometry !== `${tui.terminal.columns}:${tui.terminal.rows}:${inlineHeight(tui, tui.terminal.columns, component)}`) return;
71
79
  const item = list?.getSelectedItem();
72
- if (item) done({ id: preview && tasks ? `__preview:${item.value.slice('select:'.length)}` : item.value });
80
+ if (item && (!preview || item.value.startsWith('select:'))) done({ id: preview ? `__preview:${item.value.slice('select:'.length)}` : item.value });
73
81
  };
74
82
  const component: Component & { dispose(): void; focused: boolean; capturesViewportInput: boolean } = {
75
83
  capturesViewportInput: true,
@@ -82,7 +90,7 @@ export function remoteMenu(ctx: ExtensionContext, view: NativeView, state: MenuS
82
90
  if (!usable) { layout = ''; return [truncateToWidth('Enlarge terminal · Esc back', width)]; }
83
91
  const compact = height < 18;
84
92
  const border = new DynamicBorder((s) => theme.fg('border', s)).render(width);
85
- const hints = wrapTextWithAnsi(`${keyText('tui.select.up')}/${keyText('tui.select.down')} move · ${keyText('tui.select.confirm')} ${tasks ? 'select · F1 preview' : 'open'} · ${keyText('tui.select.cancel')} back · Ctrl+R refresh`, width);
93
+ const hints = wrapTextWithAnsi(`${keyText('tui.select.up')}/${keyText('tui.select.down')} move · ${keyText('tui.select.confirm')} ${tasks ? 'open · F1 preview task' : 'open'}${options.tab ? ` · ${keyText('tui.input.tab')} ${options.tab}` : ''} · ${keyText('tui.select.cancel')} back · Ctrl+R refresh`, width);
86
94
  const summary = (view.body ?? '').split('\n').flatMap((s) => wrapTextWithAnsi(clean(s), width - 2));
87
95
  const summaryLimit = compact ? 1 : 3;
88
96
  const header = [...border, theme.fg('accent', heading(view.title, width)),
@@ -98,12 +106,14 @@ export function remoteMenu(ctx: ExtensionContext, view: NativeView, state: MenuS
98
106
  if (layout !== signature) {
99
107
  layout = signature;
100
108
  list = new SelectList(filtered, capacity, {
101
- ...getSelectListTheme(), noMatch: () => theme.fg('muted', tasks ? state.search ? ' No tasks match this search.' : ' No active tasks. Esc back to create one.' : ' No matching items.'),
109
+ ...getSelectListTheme(), noMatch: () => theme.fg('muted', tasks ? state.search ? ' No tasks match this search.' : ' No active tasks.' : ' No matching items.'),
102
110
  }, { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: Math.min(40, Math.floor(width * 0.6)) });
103
- list.setSelectedIndex(Math.max(0, filtered.findIndex((item) => item.value === state.selected)));
111
+ const selected = filtered.some((item) => item.value === state.selected) ? state.selected
112
+ : tasks && state.search.trim() ? filtered.find((item) => item.value.startsWith('select:'))?.value : undefined;
113
+ list.setSelectedIndex(Math.max(0, filtered.findIndex((item) => item.value === selected)));
104
114
  list.onSelectionChange = (item) => { state.selected = item.value; };
105
115
  list.onSelect = () => activate(); list.onCancel = cancel;
106
- if (list.getSelectedItem()) state.selected = list.getSelectedItem()!.value;
116
+ if (fresh() && list.getSelectedItem()) state.selected = list.getSelectedItem()!.value;
107
117
  }
108
118
  const selectedIndex = filtered.findIndex((item) => item.value === list?.getSelectedItem()?.value);
109
119
  const selected = filtered[selectedIndex];
@@ -122,7 +132,8 @@ export function remoteMenu(ctx: ExtensionContext, view: NativeView, state: MenuS
122
132
  handleInput(data) {
123
133
  if (keys.matches(data, 'tui.select.cancel') || matchesKey(data, Key.ctrl('c'))) cancel();
124
134
  else if (usable && geometry === `${tui.terminal.columns}:${tui.terminal.rows}:${inlineHeight(tui, tui.terminal.columns, component)}`) {
125
- if (matchesKey(data, Key.ctrl('r'))) done({ id: '__refresh' });
135
+ if (options.tab && keys.matches(data, 'tui.input.tab')) done({ id: `__${options.tab}` });
136
+ else if (matchesKey(data, Key.ctrl('r'))) done({ id: '__refresh' });
126
137
  else if (tasks && matchesKey(data, Key.f1)) activate(true);
127
138
  else if (keys.matches(data, 'tui.select.confirm')) activate();
128
139
  else if (keys.matches(data, 'tui.select.pageUp') || keys.matches(data, 'tui.select.pageDown')) {