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/README.md +30 -14
- package/dist-mcp/field-watchers.js +57 -0
- package/dist-mcp/footer-config.js +6 -1
- package/dist-mcp/mcp/core.js +48 -10
- package/dist-mcp/mcp/field-events.js +137 -70
- package/dist-mcp/mcp/http.js +3 -4
- package/field-watchers.ts +49 -0
- package/footer-config.ts +6 -1
- package/mcp/FIELD_EVENTS.md +46 -23
- package/mcp/README.md +8 -6
- package/mcp/core.ts +43 -11
- package/mcp/field-events.ts +113 -69
- package/mcp/http.ts +3 -4
- package/package.json +3 -1
- package/remote/menu.ts +21 -10
- package/remote/navigation.ts +22 -17
- package/remote/protocol.ts +1 -1
- package/remote/session.ts +10 -3
- package/remote/ui.ts +8 -4
- package/remote.ts +20 -5
- /package/{examples/field-events.mjs → watchers/github-pr.mjs} +0 -0
|
@@ -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
|
-
|
|
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,
|
|
81
|
+
constructor(db, watchers, noteCommand, changed, log) {
|
|
85
82
|
this.db = db;
|
|
86
|
-
this.
|
|
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
|
|
92
|
-
CREATE TABLE IF NOT EXISTS
|
|
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
|
|
95
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
152
|
-
this.db.prepare('DELETE FROM
|
|
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
|
-
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
this.
|
|
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
|
-
|
|
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;
|
|
269
|
+
return;
|
|
224
270
|
}
|
|
225
|
-
const jobs = this.db.prepare('SELECT job FROM
|
|
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 (!
|
|
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(
|
|
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
|
-
},
|
|
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 (!
|
|
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 (!
|
|
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
|
|
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
|
|
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
|
|
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() {
|
package/dist-mcp/mcp/http.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
})
|
|
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,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/mcp/FIELD_EVENTS.md
CHANGED
|
@@ -1,26 +1,40 @@
|
|
|
1
|
-
# Field
|
|
1
|
+
# Field watchers
|
|
2
2
|
|
|
3
|
-
pi-note **0.
|
|
4
|
-
changes
|
|
5
|
-
watcher is installed. The local-only extension and stdio service do not run
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
"
|
|
20
|
+
"fieldWatchers": [
|
|
21
|
+
{ "path": "/absolute/path/watcher.mjs", "enabled": true }
|
|
22
|
+
]
|
|
15
23
|
}
|
|
16
24
|
```
|
|
17
25
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 [`
|
|
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
|
|
33
|
-
|
|
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
|
|
118
|
-
IDs per branch).
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|