pi-note 0.39.3 → 0.40.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.
@@ -6,6 +6,7 @@ import { dirname, isAbsolute, join } from 'node:path';
6
6
  import { homedir } from 'node:os';
7
7
  import { z } from 'zod';
8
8
  import { readConfigDocument } from "../footer-config.js";
9
+ import { discoverFieldWatchers } from "../field-watchers.js";
9
10
  const exec = promisify(execFile);
10
11
  const integer = z.number().int().nonnegative().safe();
11
12
  const field = z.string().min(1).max(128);
@@ -19,15 +20,6 @@ export const FieldResult = z.object({
19
20
  notification: z.object({ key: z.string().min(1).max(256), message: z.string().min(1).max(1000) }).strict().optional(),
20
21
  recheckAfterSeconds: z.number().int().min(10).max(86400).nullable().optional(),
21
22
  }).strict();
22
- export function configuredFieldHandler() {
23
- const { value: config } = readConfigDocument();
24
- const handler = config.fieldHandler;
25
- if (handler === undefined || handler === null)
26
- return undefined;
27
- if (typeof handler !== 'string' || !isAbsolute(handler) || /[\u0000-\u001f\u007f]/u.test(handler))
28
- throw new Error('fieldHandler must be an absolute executable file path');
29
- return handler;
30
- }
31
23
  /** One bounded invocation, no shell. Kill the process group on cancellation/overflow. */
32
24
  export function runFieldHandler(handler, input, signal, timeoutMs = 30_000) {
33
25
  signal.throwIfAborted();
@@ -77,24 +69,30 @@ export class FieldAutomation {
77
69
  lifetime = new AbortController();
78
70
  timer;
79
71
  work;
80
- handler;
72
+ watchers;
73
+ enabled = [];
74
+ signature = '';
75
+ generation = 0;
76
+ invocation;
77
+ ticking = false;
81
78
  noteCommand;
82
79
  changed;
83
80
  log;
84
- constructor(db, handler, noteCommand, changed, log) {
81
+ constructor(db, watchers, noteCommand, changed, log) {
85
82
  this.db = db;
86
- this.handler = handler;
83
+ this.watchers = watchers;
87
84
  this.noteCommand = noteCommand;
88
85
  this.changed = changed;
89
86
  this.log = log;
90
87
  db.exec(`PRAGMA busy_timeout=5000;
91
- 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);
92
- CREATE TABLE IF NOT EXISTS jobs (task_id INTEGER NOT NULL, field TEXT NOT NULL, job TEXT NOT NULL, PRIMARY KEY(task_id, field));
88
+ CREATE TABLE IF NOT EXISTS fw_scheduler (id INTEGER PRIMARY KEY CHECK(id=1), owner TEXT, lease_until INTEGER NOT NULL DEFAULT 0);
89
+ CREATE TABLE IF NOT EXISTS fw_watchers (id TEXT PRIMARY KEY, path TEXT NOT NULL, cursor INTEGER NOT NULL DEFAULT 0);
90
+ 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));
93
91
  CREATE TABLE IF NOT EXISTS owners (task_id INTEGER PRIMARY KEY, context_id TEXT NOT NULL);
94
- 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));`);
95
- db.prepare('INSERT OR IGNORE INTO scheduler(id, handler) VALUES (1, ?)').run(handler);
92
+ 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));
93
+ INSERT OR IGNORE INTO fw_scheduler(id) VALUES (1);`);
96
94
  }
97
- static async open(options) {
95
+ static async open(options = {}) {
98
96
  const dataHome = process.env.XDG_DATA_HOME;
99
97
  const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
100
98
  const namespace = createHash('sha256').update(database).digest('hex').slice(0, 16);
@@ -116,7 +114,7 @@ export class FieldAutomation {
116
114
  const { DatabaseSync } = await import('node:sqlite');
117
115
  const db = new DatabaseSync(path);
118
116
  try {
119
- return new FieldAutomation(db, options.handler, options.noteCommand ?? process.env.PINOTE_NOTE_COMMAND ?? 'note', options.changed ?? (() => { }), options.log ?? (() => { }));
117
+ return new FieldAutomation(db, options.watchers ?? (() => discoverFieldWatchers(readConfigDocument().value)), options.noteCommand ?? process.env.PINOTE_NOTE_COMMAND ?? 'note', options.changed ?? (() => { }), options.log ?? (() => { }));
120
118
  }
121
119
  catch (error) {
122
120
  db.close();
@@ -128,7 +126,7 @@ export class FieldAutomation {
128
126
  return JSON.parse(stdout);
129
127
  }
130
128
  leased() {
131
- return Boolean(this.db.prepare('SELECT 1 FROM scheduler WHERE id=1 AND owner=? AND lease_until>?').get(this.owner, Date.now()));
129
+ return Boolean(this.db.prepare('SELECT 1 FROM fw_scheduler WHERE id=1 AND owner=? AND lease_until>?').get(this.owner, Date.now()));
132
130
  }
133
131
  transaction(work) {
134
132
  this.db.exec('BEGIN IMMEDIATE');
@@ -143,13 +141,43 @@ export class FieldAutomation {
143
141
  throw error;
144
142
  }
145
143
  }
144
+ reconcile() {
145
+ const enabled = this.watchers().filter((watcher) => watcher.enabled);
146
+ if (enabled.length > 32 || new Set(enabled.map((w) => w.id)).size !== enabled.length)
147
+ throw new Error('Invalid enabled watchers');
148
+ const signature = JSON.stringify(enabled.map((w) => [w.id, w.path]));
149
+ if (signature === this.signature)
150
+ return;
151
+ this.generation++;
152
+ this.invocation?.abort();
153
+ this.db.exec('BEGIN IMMEDIATE');
154
+ try {
155
+ for (const row of this.db.prepare('SELECT id,path FROM fw_watchers').all()) {
156
+ if (!enabled.some((w) => w.id === row.id && w.path === row.path)) {
157
+ this.db.prepare('DELETE FROM fw_jobs WHERE watcher_id=?').run(row.id);
158
+ this.db.prepare('DELETE FROM fw_notices WHERE watcher_id=?').run(row.id);
159
+ this.db.prepare('DELETE FROM fw_watchers WHERE id=?').run(row.id);
160
+ }
161
+ }
162
+ for (const watcher of enabled)
163
+ this.db.prepare('INSERT OR IGNORE INTO fw_watchers(id,path) VALUES (?,?)').run(watcher.id, watcher.path);
164
+ this.db.exec('COMMIT');
165
+ }
166
+ catch (error) {
167
+ this.db.exec('ROLLBACK');
168
+ throw error;
169
+ }
170
+ this.enabled = enabled;
171
+ this.signature = signature;
172
+ this.changed();
173
+ }
146
174
  save(job) {
147
- this.db.prepare('INSERT INTO jobs(task_id,field,job) VALUES (?,?,?) ON CONFLICT(task_id,field) DO UPDATE SET job=excluded.job')
148
- .run(job.event.taskId, job.event.field, JSON.stringify(job));
175
+ 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')
176
+ .run(job.watcherId, job.event.taskId, job.event.field, JSON.stringify(job));
149
177
  }
150
- clear(taskId, name) {
151
- this.db.prepare('DELETE FROM jobs WHERE task_id=? AND field=?').run(taskId, name);
152
- this.db.prepare('DELETE FROM notices WHERE task_id=? AND field=?').run(taskId, name);
178
+ clear(watcherId, taskId, name) {
179
+ this.db.prepare('DELETE FROM fw_jobs WHERE watcher_id=? AND task_id=? AND field=?').run(watcherId, taskId, name);
180
+ this.db.prepare('DELETE FROM fw_notices WHERE watcher_id=? AND task_id=? AND field=?').run(watcherId, taskId, name);
153
181
  }
154
182
  /** Explicit selection/write owns alerts; background reads can only claim unowned tasks. */
155
183
  claim(taskId, contextId, replace = false) {
@@ -158,21 +186,23 @@ export class FieldAutomation {
158
186
  : 'INSERT OR IGNORE INTO owners(task_id,context_id) VALUES (?,?)').run(taskId, contextId);
159
187
  }
160
188
  statuses(task) {
161
- return this.db.prepare('SELECT job FROM jobs WHERE task_id=?').all(task.id).flatMap(({ job: raw }) => {
189
+ this.reconcile();
190
+ return this.enabled.flatMap((watcher) => this.db.prepare('SELECT job FROM fw_jobs WHERE watcher_id=? AND task_id=?').all(watcher.id, task.id).flatMap(({ job: raw }) => {
162
191
  const job = JSON.parse(raw);
163
- if (job.event.value === null || job.event.value !== task.agent_notes[job.event.field] || !job.result)
192
+ if (job.event.value === null || job.event.value !== task.agent_notes[job.event.field] || !job.result || Object.keys(job.result).length === 0)
164
193
  return [];
165
- return [{ field: job.event.field, value: job.event.value, result: job.result, checkedAt: job.checkedAt, stale: job.stale ?? false }];
166
- });
194
+ return [{ watcherId: watcher.id, field: job.event.field, value: job.event.value, result: job.result, checkedAt: job.checkedAt, stale: job.stale ?? false }];
195
+ }));
167
196
  }
168
- notices(task, contextId) {
197
+ notices(task, contextId, receipts = []) {
169
198
  this.claim(task.id, contextId);
170
199
  const owner = this.db.prepare('SELECT context_id FROM owners WHERE task_id=?').get(task.id);
171
200
  if (owner?.context_id !== contextId)
172
201
  return [];
173
- const fields = new Set(this.statuses(task).filter((s) => !s.stale && s.result.notification).map((s) => s.field));
174
- return this.db.prepare('SELECT id, field, message FROM notices WHERE task_id=?').all(task.id)
175
- .filter((n) => fields.has(n.field)).map((n) => ({ ...n, taskId: task.id }));
202
+ const received = new Set(receipts);
203
+ const fields = new Set(this.statuses(task).filter((s) => !s.stale && s.result.notification).map((s) => `${s.watcherId}:${s.field}`));
204
+ return this.db.prepare('SELECT watcher_id, id, field, message FROM fw_notices WHERE task_id=?').all(task.id)
205
+ .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 }));
176
206
  }
177
207
  start() {
178
208
  const schedule = () => {
@@ -189,67 +219,96 @@ export class FieldAutomation {
189
219
  };
190
220
  schedule();
191
221
  }
192
- async tick() {
222
+ tick() {
223
+ if (this.ticking)
224
+ return this.work ?? Promise.resolve();
225
+ this.ticking = true;
226
+ const work = this.performTick().finally(() => { this.ticking = false; if (this.work === work)
227
+ this.work = undefined; });
228
+ this.work = work;
229
+ return work;
230
+ }
231
+ async performTick() {
193
232
  this.lifetime.signal.throwIfAborted();
194
- const acquired = this.db.prepare('UPDATE scheduler SET owner=?, lease_until=? WHERE id=1 AND (lease_until<=? OR owner=?)')
233
+ const acquired = this.db.prepare('UPDATE fw_scheduler SET owner=?, lease_until=? WHERE id=1 AND (lease_until<=? OR owner=?)')
195
234
  .run(this.owner, Date.now() + 90_000, Date.now(), this.owner).changes;
196
235
  if (!acquired)
197
236
  return;
198
237
  try {
199
- let state = this.db.prepare('SELECT cursor, handler FROM scheduler WHERE id=1').get();
200
- if (state.handler !== this.handler) {
201
- this.transaction(() => {
202
- this.db.exec('DELETE FROM jobs; DELETE FROM notices;');
203
- this.db.prepare('UPDATE scheduler SET cursor=0, handler=? WHERE id=1').run(this.handler);
204
- });
205
- state = { cursor: 0, handler: this.handler };
206
- this.changed();
238
+ this.reconcile();
239
+ const generation = this.generation;
240
+ const valid = () => {
241
+ this.reconcile();
242
+ if (!this.leased() || generation !== this.generation || this.lifetime.signal.aborted)
243
+ return false;
244
+ // Many watchers can make intake longer than one lease; each bounded operation renews it.
245
+ this.db.prepare('UPDATE fw_scheduler SET lease_until=? WHERE id=1 AND owner=?').run(Date.now() + 90_000, this.owner);
246
+ return true;
247
+ };
248
+ let drained = false;
249
+ for (const watcher of this.enabled) {
250
+ const state = this.db.prepare('SELECT cursor FROM fw_watchers WHERE id=?').get(watcher.id);
251
+ const batch = Batch.parse(await this.note(['field-events', '--after', String(state.cursor), '--limit', '25']));
252
+ if (!valid())
253
+ return;
254
+ if (batch.cursor < state.cursor || batch.events.some((e) => e.id <= state.cursor || e.id > batch.cursor))
255
+ throw new Error('Invalid field event cursor');
256
+ if (batch.cursor !== state.cursor) {
257
+ this.transaction(() => {
258
+ for (const event of batch.events) {
259
+ this.clear(watcher.id, event.taskId, event.field);
260
+ this.save({ watcherId: watcher.id, event, due: Date.now(), attempts: 0 });
261
+ }
262
+ this.db.prepare('UPDATE fw_watchers SET cursor=? WHERE id=?').run(batch.cursor, watcher.id);
263
+ });
264
+ drained = true;
265
+ }
207
266
  }
208
- const batch = Batch.parse(await this.note(['field-events', '--after', String(state.cursor), '--limit', '25']));
209
- if (!this.leased())
210
- return;
211
- if (batch.cursor < state.cursor || batch.events.some((e) => e.id <= state.cursor || e.id > batch.cursor))
212
- throw new Error('Invalid field event cursor');
213
- if (batch.cursor !== state.cursor) {
214
- this.transaction(() => {
215
- for (const event of batch.events) {
216
- // New input invalidates both old display and pending alerts immediately.
217
- this.clear(event.taskId, event.field);
218
- this.save({ event, due: Date.now(), attempts: 0 });
219
- }
220
- this.db.prepare('UPDATE scheduler SET cursor=? WHERE id=1').run(batch.cursor);
221
- });
267
+ if (drained) {
222
268
  this.changed();
223
- return; // Drain durable history before executing; coalesce superseded values.
269
+ return;
224
270
  }
225
- const jobs = this.db.prepare('SELECT job FROM jobs').all().map(({ job }) => JSON.parse(job));
271
+ const jobs = this.db.prepare('SELECT job FROM fw_jobs').all().map(({ job }) => JSON.parse(job));
226
272
  const job = jobs.filter((j) => j.due !== null && j.due <= Date.now()).sort((a, b) => a.due - b.due)[0];
227
273
  if (!job)
228
274
  return;
229
275
  const event = job.event;
276
+ const watcher = this.enabled.find((w) => w.id === job.watcherId);
277
+ if (!watcher)
278
+ return;
230
279
  const task = await this.note(['agent', 'get', String(event.taskId)]);
231
- if (!this.leased())
280
+ if (!valid())
232
281
  return;
233
282
  const currentValue = task && ['active', 'in_progress'].includes(task.state) ? task.agent_notes[event.field] ?? null : null;
234
283
  if (event.value !== currentValue) {
235
- this.transaction(() => this.clear(event.taskId, event.field));
284
+ this.transaction(() => this.clear(job.watcherId, event.taskId, event.field));
236
285
  this.changed();
237
286
  return;
238
287
  }
239
288
  let result, retryAfterSeconds = 0;
289
+ const invocation = new AbortController();
290
+ this.invocation = invocation;
291
+ const abort = () => invocation.abort();
292
+ this.lifetime.signal.addEventListener('abort', abort, { once: true });
293
+ const poll = setInterval(() => { try {
294
+ this.reconcile();
295
+ }
296
+ catch {
297
+ invocation.abort();
298
+ } }, 100);
240
299
  try {
241
- result = await runFieldHandler(this.handler, {
300
+ result = await runFieldHandler(watcher.path, {
242
301
  version: 1, eventId: `${event.id}:${event.field}`, taskId: event.taskId, field: event.field,
243
302
  event: job.checkedAt ? 'check' : event.event, value: event.value, previousValue: event.previousValue,
244
303
  ...(job.result ? { previousResult: job.result } : {}),
245
- }, this.lifetime.signal);
304
+ }, invocation.signal);
246
305
  if (result.state === 'error') {
247
306
  retryAfterSeconds = result.recheckAfterSeconds ?? 0;
248
307
  throw new Error('Handler reported unavailable data');
249
308
  }
250
309
  }
251
310
  catch {
252
- if (!this.leased() || this.lifetime.signal.aborted)
311
+ if (!valid() || invocation.signal.aborted)
253
312
  return;
254
313
  job.attempts++;
255
314
  job.stale = true;
@@ -260,30 +319,38 @@ export class FieldAutomation {
260
319
  this.log();
261
320
  return;
262
321
  }
322
+ finally {
323
+ clearInterval(poll);
324
+ this.lifetime.signal.removeEventListener('abort', abort);
325
+ if (this.invocation === invocation)
326
+ this.invocation = undefined;
327
+ }
328
+ if (!valid() || invocation.signal.aborted)
329
+ return;
263
330
  const latest = await this.note(['agent', 'get', String(event.taskId)]);
264
- if (!this.leased())
331
+ if (!valid())
265
332
  return;
266
333
  if (latest?.updated_at !== task?.updated_at)
267
334
  return; // Includes remove/re-add (ABA); next tick drains new events.
268
335
  this.transaction(() => {
269
336
  if (event.value === null)
270
- this.clear(event.taskId, event.field);
337
+ this.clear(job.watcherId, event.taskId, event.field);
271
338
  else {
272
339
  const notification = result.notification;
273
340
  if (!notification)
274
- this.db.prepare('DELETE FROM notices WHERE task_id=? AND field=?').run(event.taskId, event.field);
341
+ this.db.prepare('DELETE FROM fw_notices WHERE watcher_id=? AND task_id=? AND field=?').run(job.watcherId, event.taskId, event.field);
275
342
  else if (JSON.stringify(notification) !== JSON.stringify(job.result?.notification)) {
276
- 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')
277
- .run(event.taskId, event.field, randomUUID(), notification.message);
343
+ 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')
344
+ .run(job.watcherId, event.taskId, event.field, randomUUID(), notification.message);
278
345
  }
279
- this.save({ event, result, due: result.recheckAfterSeconds ? Date.now() + result.recheckAfterSeconds * 1000 : null,
346
+ this.save({ watcherId: job.watcherId, event, result, due: result.recheckAfterSeconds ? Date.now() + result.recheckAfterSeconds * 1000 : null,
280
347
  attempts: 0, checkedAt: new Date().toISOString(), stale: false });
281
348
  }
282
349
  });
283
350
  this.changed();
284
351
  }
285
352
  finally {
286
- this.db.prepare('UPDATE scheduler SET lease_until=0 WHERE id=1 AND owner=?').run(this.owner);
353
+ this.db.prepare('UPDATE fw_scheduler SET lease_until=0 WHERE id=1 AND owner=?').run(this.owner);
287
354
  }
288
355
  }
289
356
  async close() {
@@ -9,7 +9,7 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
9
9
  import { createPinoteServer } from "./core.js";
10
10
  import { completionEvents } from "./events.js";
11
11
  import { pinoteToken } from "../token.js";
12
- import { configuredFieldHandler, FieldAutomation } from "./field-events.js";
12
+ import { FieldAutomation } from "./field-events.js";
13
13
  function diagnostic(path, event) {
14
14
  const line = `${new Date().toISOString()} Pinote HTTP ${event}\n`;
15
15
  process.stdout.write(line);
@@ -50,12 +50,11 @@ export async function startPinoteHttpServer(options = {}) {
50
50
  throw new Error('Invalid allowed HTTP authority');
51
51
  let allowedHosts = new Set(configuredHosts);
52
52
  const fieldListeners = new Set();
53
- const handler = configuredFieldHandler();
54
- const fieldAutomation = handler ? await FieldAutomation.open({ handler,
53
+ const fieldAutomation = await FieldAutomation.open({
55
54
  changed: () => { for (const listener of fieldListeners)
56
55
  listener(); },
57
56
  log: () => log('field automation check failed; will retry'),
58
- }) : undefined;
57
+ });
59
58
  const reject = (res, status) => { res.writeHead(status, { 'Content-Type': 'text/plain' }); res.end(`${status}\n`); };
60
59
  const single = (req, name) => {
61
60
  const values = req.rawHeaders.filter((_, i) => i % 2 === 0 && req.rawHeaders[i].toLowerCase() === name);
@@ -0,0 +1,4 @@
1
+ /** Display-only title: keep Markdown source intact for editing and previews. */
2
+ export function taskTitle(text) {
3
+ return text.split("\n", 1)[0].replace(/^ {0,3}#{1,6}[ \t]+/u, "");
4
+ }
@@ -0,0 +1,49 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { accessSync, constants, lstatSync, readdirSync } from 'node:fs';
3
+ import { basename, dirname, isAbsolute, join, normalize } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ export type FieldWatcherConfig = { path: string; enabled: boolean };
7
+ export type FieldWatcher = FieldWatcherConfig & { id: string; name: string; bundled: boolean };
8
+ const moduleDirectory = dirname(fileURLToPath(import.meta.url));
9
+ // tsc emits this module one level below the package root; discovery stays in
10
+ // the same shipped package directory in source and compiled entry points.
11
+ const bundledDirectory = join(basename(moduleDirectory) === 'dist-mcp' ? dirname(moduleDirectory) : moduleDirectory, 'watchers');
12
+ function watcherPath(path: unknown): string {
13
+ if (typeof path !== 'string' || path.length > 4096 || !isAbsolute(path) || /[\u0000-\u001f\u007f-\u009f]/u.test(path)) {
14
+ throw new Error('Watcher path must be an absolute executable file path (max 4096 characters)');
15
+ }
16
+ return normalize(path);
17
+ }
18
+ export function parseFieldWatchers(root: Record<string, unknown>): FieldWatcherConfig[] {
19
+ let entries = root.fieldWatchers;
20
+ if (entries === undefined) entries = root.fieldHandler == null ? [] : [{ path: root.fieldHandler, enabled: false }];
21
+ if (!Array.isArray(entries) || entries.length > 32) throw new Error('fieldWatchers must be an array of at most 32 watchers');
22
+ const seen = new Set<string>();
23
+ return entries.map((entry: unknown) => {
24
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry) || Object.keys(entry).some((key) => key !== 'path' && key !== 'enabled')) throw new Error('Invalid field watcher');
25
+ const config = entry as Record<string, unknown>;
26
+ const path = watcherPath(config.path);
27
+ if (typeof config.enabled !== 'boolean' || seen.has(path)) throw new Error('Invalid or duplicate field watcher');
28
+ seen.add(path);
29
+ return { path, enabled: config.enabled };
30
+ });
31
+ }
32
+ /** Validation only: never starts the executable or invokes a shell. */
33
+ export function validateWatcherExecutable(path: string): void {
34
+ const canonical = watcherPath(path);
35
+ if (!lstatSync(canonical).isFile()) throw new Error('Watcher must be a regular executable file');
36
+ accessSync(canonical, constants.X_OK);
37
+ }
38
+ export function discoverFieldWatchers(root: Record<string, unknown>): FieldWatcher[] {
39
+ const stored = parseFieldWatchers(root);
40
+ const bundled = new Set<string>();
41
+ for (const name of readdirSync(bundledDirectory).sort()) {
42
+ if (!name.endsWith('.mjs')) continue;
43
+ const path = join(bundledDirectory, name);
44
+ try { validateWatcherExecutable(path); bundled.add(path); } catch { /* Not a regular executable. */ }
45
+ }
46
+ const merged = [...stored, ...[...bundled].filter((path) => !stored.some((entry) => entry.path === path)).map((path) => ({ path, enabled: false }))];
47
+ if (merged.length > 32) throw new Error('At most 32 discovered field watchers are supported');
48
+ return merged.map((config) => ({ ...config, id: createHash('sha256').update(config.path).digest('hex'), name: basename(config.path) === 'github-pr.mjs' && bundled.has(config.path) ? 'GitHub PR' : basename(config.path, '.mjs'), bundled: bundled.has(config.path) }));
49
+ }
package/footer-config.ts CHANGED
@@ -2,6 +2,7 @@ import { getAgentDir } from "./config-path.ts";
2
2
  import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
+ import { parseFieldWatchers, type FieldWatcherConfig } from "./field-watchers.ts";
5
6
 
6
7
  export const defaultHandoffPrompt = "Read the current Pinote task. Summarize your understanding, but don’t start work yet.";
7
8
  export const defaultNewSessionPrompt = defaultHandoffPrompt;
@@ -117,7 +118,7 @@ export function loadFooterConfig(warn: (message: string) => void): FooterConfig
117
118
 
118
119
  // Synchronous compare-and-replace: reject edits made while the settings dialog was open,
119
120
  // preserve unrelated keys, and never leave partial JSON behind.
120
- export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string, newSessionPrompt?: string, taskOfferPolicy?: string, agentGuidance?: string | null) {
121
+ export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string, newSessionPrompt?: string, taskOfferPolicy?: string, agentGuidance?: string | null, fieldWatchers?: FieldWatcherConfig[]) {
121
122
  if (taskOfferPolicy !== undefined && !["always", "github-remote", "never"].includes(taskOfferPolicy)) throw new Error("Invalid taskOfferPolicy");
122
123
  const checked = parseFooterConfig({ footer: config });
123
124
  const prompts = {
@@ -140,6 +141,10 @@ export function saveFooterConfig(config: FooterConfig, expectedRaw: string | nul
140
141
  const root = expectedRaw === null ? {} : JSON.parse(expectedRaw);
141
142
  if (!object(root)) throw new Error("Invalid pi-note configuration; expected an object.");
142
143
  if (agentGuidance === null) delete root.agentGuidance;
144
+ if (fieldWatchers !== undefined) {
145
+ root.fieldWatchers = parseFieldWatchers({ fieldWatchers });
146
+ delete root.fieldHandler;
147
+ }
143
148
  raw = `${JSON.stringify({ ...root, ...prompts, ...(taskOfferPolicy === undefined ? {} : { taskOfferPolicy }), footer: checked }, null, 2)}\n`;
144
149
  writeFileSync(temp, raw, { flag: "wx", mode: 0o600 });
145
150
  renameSync(temp, path);
package/index.ts CHANGED
@@ -11,6 +11,7 @@ import { FooterSettings, TaskMenu, withSettingsTab, type SettingsOption, type Se
11
11
  import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
12
12
  import { bundledCLIVersion, detectCLI, setupCLI, setupHint, versionAtLeast, type CLIAction } from "./setup.ts";
13
13
  import { createPreviewBridge } from "./preview-bridge.ts";
14
+ import { taskTitle } from "./task-title.ts";
14
15
  import { renderSuggestion, renderSelectedTask } from "./task-suggestion.ts";
15
16
 
16
17
  import { proposalTitleOptions, proposalDescriptionOptions, proposalText } from "./proposal.ts";
@@ -52,7 +53,7 @@ const record = (value: unknown): value is Record<string, unknown> =>
52
53
  const text = (value: unknown): value is string =>
53
54
  typeof value === "string" && !/[\x00-\x08\x0b-\x1f\x7f-\x9f]/u.test(value);
54
55
  const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/gu, "�");
55
- const firstLine = (task: Summary) => clean(task.text.split("\n", 1)[0]).replace(/\s+/gu, " ").trim();
56
+ const firstLine = (task: Summary) => clean(taskTitle(task.text)).replace(/\s+/gu, " ").trim();
56
57
  const title = (task: Summary) => truncateToWidth(`#${task.id} ${firstLine(task)}`, 60);
57
58
 
58
59
  function readConfig(): Record<string, unknown> {
@@ -1,26 +1,40 @@
1
- # Field-event handler
1
+ # Field watchers
2
2
 
3
- pi-note **0.39.0+** with **note 0.24.0+** can run one executable for all task-field
4
- changes. This is opt-in, on the **HTTP service machine**. No extra daemon or Pi-local
5
- watcher is installed. The local-only extension and stdio service do not run handlers.
3
+ pi-note **0.40.0+** with **note 0.24.0+** runs independently enabled executables for
4
+ task-field changes on the **HTTP service machine**. No extra daemon or Pi-local
5
+ watcher is installed. The local-only extension and stdio service do not run watchers.
6
6
 
7
7
  ## Enable
8
8
 
9
- Create a trusted executable with a shebang, then add its absolute path to the
10
- service's `pi-note.json`, preserving existing settings:
9
+ Open **Settings → Footer fields → Watchers**. The fixed package `watchers/` folder
10
+ is scanned for executable `.mjs` files; discovery never executes or enables them.
11
+ Enable **GitHub PR**, or add a custom absolute executable path on the service
12
+ machine and enable it separately. Every change requires review and confirmation.
13
+ Watchers are trusted code, not sandboxed; enabling authorizes their alerts to
14
+ trigger the owning worker to investigate and fix the task within existing permissions.
15
+
16
+ The corresponding service `pi-note.json` setting is:
11
17
 
12
18
  ```json
13
19
  {
14
- "fieldHandler": "/absolute/path/field-events.mjs"
20
+ "fieldWatchers": [
21
+ { "path": "/absolute/path/watcher.mjs", "enabled": true }
22
+ ]
15
23
  }
16
24
  ```
17
25
 
18
- Restart the existing Pinote HTTP service; update clients and `/reload` Pi for alerts
19
- and footer styling. Removing the setting or setting it to `null`, then restarting,
20
- disables execution and hides automation results without modifying task fields.
21
- Scripts and settings are never installed or enabled automatically.
26
+ Update/restart the existing HTTP service and `/reload` Pi once after upgrading.
27
+ Subsequent watcher changes apply live. Disable a watcher to stop checks and hide
28
+ its results without changing task fields. Custom entries can also be removed;
29
+ the file is retained. At most 32 watchers may be configured. For overlapping
30
+ results, the first nonempty watcher result in configuration order supplies the
31
+ footer style; alerts remain independent.
32
+
33
+ `fieldHandler` is replaced, not run alongside the list. An old handler appears
34
+ disabled until explicitly enabled because the previous setting authorized only
35
+ notifications, not automatic worker actions. Saving watchers removes the old key.
22
36
 
23
- The bundled [`examples/field-events.mjs`](../examples/field-events.mjs) handles `PR`
37
+ The bundled [`watchers/github-pr.mjs`](../watchers/github-pr.mjs) handles `PR`
24
38
  and ignores other fields. Copy it to a user-owned location before customization,
25
39
  make it executable (`chmod u+x`), and ensure authenticated `gh` and Node are on the
26
40
  **service's** PATH. It accepts a GitHub PR URL or Markdown link, checks once, and
@@ -29,8 +43,8 @@ strikes through merged PRs while keeping their links. Closed/merged PRs stop
29
43
  polling; merging never completes the Pinote task. Passing means reported checks
30
44
  passed, not that every review/branch-protection requirement is satisfied.
31
45
 
32
- To support another field, add another branch to this same file. No registry or
33
- plugin package is needed.
46
+ To support another field, add another executable using the contract below. Each
47
+ watcher receives all field events and returns `{}` for fields it does not handle.
34
48
 
35
49
  ## Script contract
36
50
 
@@ -91,6 +105,7 @@ are deduplicated; a resolved condition followed by another failure alerts again.
91
105
  - The service stores its history cursor, scheduled checks, results and alert owner
92
106
  under `$XDG_STATE_HOME/pinote/field-events/<database-path-hash>/state.db` (default
93
107
  state home: `~/.local/state`). Keep this private and local, like `notes.db`.
108
+ Watcher cursors, jobs and notices are independent; alert ownership is shared.
94
109
  It uses Node's built-in SQLite API; Node 22 may print its experimental warning.
95
110
  - Monitoring continues without an open Pi session while the HTTP service runs.
96
111
  Deadlines survive restarts. A lease prevents overlapping normal execution by
@@ -114,23 +129,31 @@ are deduplicated; a resolved condition followed by another failure alerts again.
114
129
  task. For an unowned task, the first selected-task view claims alerts. Other
115
130
  sessions still see shared field status. Explicitly selecting the task reclaims
116
131
  ownership. No alert is delivered while that conversation has another task selected.
117
- - Pi shows an alert once and persists receipts across reload/resume (latest 4,096
118
- IDs per branch). Current results reach the next agent run through fresh,
119
- task-scoped context. Duplicate field values are not repeated; results exceeding
120
- the context budget are omitted with a count, preserving the complete task first.
121
- **No prompt submission, model wake-up, task selection or
122
- completion is authorized by a handler result.**
132
+ - Pi triggers a worker turn once per current alert and persists receipts across
133
+ reload/resume (latest 4,096 IDs per branch). Receipts accompany view reads so
134
+ bounded 64-alert pages advance past delivered failures even if they persist.
135
+ Busy workers defer delivery until
136
+ idle, then refresh to verify ownership, selection and current status. An aborted
137
+ run does not immediately drain deferred alerts. There is no launch of a closed
138
+ Pi process; current alerts are delivered when its owning conversation reconnects.
139
+ Older clients/services retain notification-only behavior until both are updated.
140
+ - Worker messages use fixed instructions plus explicitly untrusted observations;
141
+ the worker must verify fresh task context and obey existing scope and approvals.
142
+ Enabling never authorizes task selection/completion, PR merging or bypassing
143
+ approvals. Current results reach each agent run through fresh task-scoped
144
+ context; oversized results are omitted with a count, preserving the task first.
123
145
  - Only the latest current alert per field is retained, not an offline alert log.
124
146
  If a failure resolves while Pi is closed, it is not delivered later as a stale
125
147
  failure. Network recovery remains explicit through `/pinote` or a tool call.
126
- - Changing the configured handler path resets automation state and reconstructs
127
- history; editing the same file affects its next invocation, not stopped checks.
148
+ - Newly enabled watchers reconstruct current fields from history independently.
149
+ Disabling/removing clears their automation state; enabling again checks afresh.
150
+ Editing the same file affects its next invocation, not stopped checks.
128
151
  Restoring an older notes backup can invalidate the cursor: stop the service and
129
152
  deliberately back up/reset its corresponding automation state before restarting.
130
153
 
131
154
  Handlers run with the service user's permissions and inherited environment,
132
155
  including available credentials. They are trusted code, **not sandboxed**. The
133
- remote UI and agent tools cannot configure executable paths. Service logs report
156
+ human-confirmed Watchers UI can configure executable paths; agent tools cannot. Service logs report
134
157
  failures generically; script output and stderr are not copied into logs. Run the
135
158
  script manually with synthetic input when debugging; don't put secrets in results,
136
159
  which may be displayed and sent to the configured model.
package/mcp/README.md CHANGED
@@ -141,12 +141,14 @@ Tests use temporary data and real HTTP/stdio connections, including authenticati
141
141
  shared notes, independent selections, conflicts and reconnects. Visual checks use
142
142
  Xvfb and private D-Bus; never point them at personal data.
143
143
 
144
- ## Field automations
145
-
146
- A user-configured single executable can react to task-field changes and request
147
- scheduled checks inside this service. State survives restarts; no additional daemon
148
- is needed. Requires note 0.24.0+ and is disabled by default. See the
149
- [field-handler contract and PR example](FIELD_EVENTS.md) before enabling trusted code.
144
+ ## Field watchers
145
+
146
+ Manage `fieldWatchers` in **Settings → Footer fields → Watchers**. Bundled files
147
+ are discovered disabled; trusted custom executables can be added and enabled.
148
+ Independent checks survive restarts and settings apply live, without another daemon.
149
+ The GitHub PR watcher triggers the owning worker on failed CI/conflicts and strikes
150
+ through merged PRs. Requires note 0.24.0+. Review the
151
+ [watcher contract and safeguards](FIELD_EVENTS.md) before enabling trusted code.
150
152
 
151
153
  ## Task-completion events
152
154