pi-note 0.36.0 → 0.39.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.
- package/README.md +53 -15
- package/dist-mcp/mcp/core.js +85 -36
- package/dist-mcp/mcp/field-events.js +295 -0
- package/dist-mcp/mcp/http.js +20 -1
- package/examples/field-events.mjs +86 -0
- package/mcp/FIELD_EVENTS.md +151 -0
- package/mcp/README.md +7 -0
- package/mcp/core.ts +80 -38
- package/mcp/field-events.ts +238 -0
- package/mcp/http.ts +14 -1
- package/package.json +2 -1
- package/remote/dialogs.ts +150 -0
- package/remote/footer.ts +2 -1
- package/remote/menu.ts +107 -57
- package/remote/navigation.ts +90 -0
- package/remote/protocol.ts +2 -1
- package/remote/session.ts +14 -4
- package/remote/ui.ts +167 -126
- package/remote.ts +6 -1
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { spawn, execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { mkdirSync, lstatSync, openSync, closeSync } from 'node:fs';
|
|
5
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { readConfigDocument } from "../footer-config.js";
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
const integer = z.number().int().nonnegative().safe();
|
|
11
|
+
const field = z.string().min(1).max(128);
|
|
12
|
+
const value = z.string().max(8192).nullable();
|
|
13
|
+
const Event = z.object({ id: integer, taskId: integer.positive(), field,
|
|
14
|
+
event: z.enum(['added', 'changed', 'removed']), value, previousValue: value }).strict();
|
|
15
|
+
const Batch = z.object({ cursor: integer, events: z.array(Event).max(3200) }).strict();
|
|
16
|
+
export const FieldResult = z.object({
|
|
17
|
+
state: z.string().min(1).max(64).optional(), summary: z.string().max(1000).optional(),
|
|
18
|
+
display: z.object({ strikethrough: z.boolean().optional(), color: z.enum(['accent', 'muted', 'text', 'warning', 'error', 'success']).optional() }).strict().optional(),
|
|
19
|
+
notification: z.object({ key: z.string().min(1).max(256), message: z.string().min(1).max(1000) }).strict().optional(),
|
|
20
|
+
recheckAfterSeconds: z.number().int().min(10).max(86400).nullable().optional(),
|
|
21
|
+
}).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
|
+
/** One bounded invocation, no shell. Kill the process group on cancellation/overflow. */
|
|
32
|
+
export function runFieldHandler(handler, input, signal, timeoutMs = 30_000) {
|
|
33
|
+
signal.throwIfAborted();
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const child = spawn(handler, [], { cwd: dirname(handler), stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32' });
|
|
36
|
+
let output = '', bytes = 0, failed = false;
|
|
37
|
+
const kill = () => {
|
|
38
|
+
failed = true;
|
|
39
|
+
try {
|
|
40
|
+
if (process.platform !== 'win32' && child.pid)
|
|
41
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
42
|
+
else
|
|
43
|
+
child.kill('SIGKILL');
|
|
44
|
+
}
|
|
45
|
+
catch { /* Already exited. */ }
|
|
46
|
+
};
|
|
47
|
+
const timer = setTimeout(kill, timeoutMs);
|
|
48
|
+
signal.addEventListener('abort', kill, { once: true });
|
|
49
|
+
const cleanup = () => { clearTimeout(timer); signal.removeEventListener('abort', kill); };
|
|
50
|
+
child.stdout.setEncoding('utf8');
|
|
51
|
+
child.stdout.on('data', (chunk) => { bytes += Buffer.byteLength(chunk); if (bytes > 64 * 1024)
|
|
52
|
+
kill();
|
|
53
|
+
else
|
|
54
|
+
output += chunk; });
|
|
55
|
+
child.stderr.on('data', (chunk) => { bytes += chunk.length; if (bytes > 64 * 1024)
|
|
56
|
+
kill(); });
|
|
57
|
+
child.stdin.on('error', kill);
|
|
58
|
+
child.on('error', () => { cleanup(); reject(new Error('Field handler failed')); });
|
|
59
|
+
child.on('close', (code) => {
|
|
60
|
+
cleanup();
|
|
61
|
+
if (failed || code !== 0 || signal.aborted)
|
|
62
|
+
return reject(new Error('Field handler failed'));
|
|
63
|
+
try {
|
|
64
|
+
resolve(FieldResult.parse(JSON.parse(output)));
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
reject(new Error('Invalid field handler result'));
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
child.stdin.end(JSON.stringify(input));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** Service-owned durable scheduler. A SQLite lease fences competing HTTP processes. */
|
|
74
|
+
export class FieldAutomation {
|
|
75
|
+
db;
|
|
76
|
+
owner = randomUUID();
|
|
77
|
+
lifetime = new AbortController();
|
|
78
|
+
timer;
|
|
79
|
+
work;
|
|
80
|
+
handler;
|
|
81
|
+
noteCommand;
|
|
82
|
+
changed;
|
|
83
|
+
log;
|
|
84
|
+
constructor(db, handler, noteCommand, changed, log) {
|
|
85
|
+
this.db = db;
|
|
86
|
+
this.handler = handler;
|
|
87
|
+
this.noteCommand = noteCommand;
|
|
88
|
+
this.changed = changed;
|
|
89
|
+
this.log = log;
|
|
90
|
+
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));
|
|
93
|
+
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);
|
|
96
|
+
}
|
|
97
|
+
static async open(options) {
|
|
98
|
+
const dataHome = process.env.XDG_DATA_HOME;
|
|
99
|
+
const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
|
|
100
|
+
const namespace = createHash('sha256').update(database).digest('hex').slice(0, 16);
|
|
101
|
+
const path = options.path ?? join(process.env.XDG_STATE_HOME || join(homedir(), '.local/state'), 'pinote/field-events', namespace, 'state.db');
|
|
102
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
103
|
+
const directory = lstatSync(dirname(path));
|
|
104
|
+
if (!directory.isDirectory() || directory.uid !== process.getuid?.() || (directory.mode & 0o077))
|
|
105
|
+
throw new Error('Unsafe field automation directory');
|
|
106
|
+
try {
|
|
107
|
+
closeSync(openSync(path, 'wx', 0o600));
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (error.code !== 'EEXIST')
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
const stat = lstatSync(path);
|
|
114
|
+
if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o077))
|
|
115
|
+
throw new Error('Unsafe field automation database');
|
|
116
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
117
|
+
const db = new DatabaseSync(path);
|
|
118
|
+
try {
|
|
119
|
+
return new FieldAutomation(db, options.handler, options.noteCommand ?? process.env.PINOTE_NOTE_COMMAND ?? 'note', options.changed ?? (() => { }), options.log ?? (() => { }));
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
db.close();
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async note(args) {
|
|
127
|
+
const { stdout } = await exec(this.noteCommand, ['--no-notify', ...args], { signal: this.lifetime.signal, timeout: 10_000, maxBuffer: 8 * 1024 * 1024 });
|
|
128
|
+
return JSON.parse(stdout);
|
|
129
|
+
}
|
|
130
|
+
leased() {
|
|
131
|
+
return Boolean(this.db.prepare('SELECT 1 FROM scheduler WHERE id=1 AND owner=? AND lease_until>?').get(this.owner, Date.now()));
|
|
132
|
+
}
|
|
133
|
+
transaction(work) {
|
|
134
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
135
|
+
try {
|
|
136
|
+
if (!this.leased())
|
|
137
|
+
throw new Error('Field scheduler lease expired');
|
|
138
|
+
work();
|
|
139
|
+
this.db.exec('COMMIT');
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
this.db.exec('ROLLBACK');
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
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));
|
|
149
|
+
}
|
|
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);
|
|
153
|
+
}
|
|
154
|
+
/** Explicit selection/write owns alerts; background reads can only claim unowned tasks. */
|
|
155
|
+
claim(taskId, contextId, replace = false) {
|
|
156
|
+
this.db.prepare(replace
|
|
157
|
+
? 'INSERT INTO owners(task_id,context_id) VALUES (?,?) ON CONFLICT(task_id) DO UPDATE SET context_id=excluded.context_id'
|
|
158
|
+
: 'INSERT OR IGNORE INTO owners(task_id,context_id) VALUES (?,?)').run(taskId, contextId);
|
|
159
|
+
}
|
|
160
|
+
statuses(task) {
|
|
161
|
+
return this.db.prepare('SELECT job FROM jobs WHERE task_id=?').all(task.id).flatMap(({ job: raw }) => {
|
|
162
|
+
const job = JSON.parse(raw);
|
|
163
|
+
if (job.event.value === null || job.event.value !== task.agent_notes[job.event.field] || !job.result)
|
|
164
|
+
return [];
|
|
165
|
+
return [{ field: job.event.field, value: job.event.value, result: job.result, checkedAt: job.checkedAt, stale: job.stale ?? false }];
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
notices(task, contextId) {
|
|
169
|
+
this.claim(task.id, contextId);
|
|
170
|
+
const owner = this.db.prepare('SELECT context_id FROM owners WHERE task_id=?').get(task.id);
|
|
171
|
+
if (owner?.context_id !== contextId)
|
|
172
|
+
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 }));
|
|
176
|
+
}
|
|
177
|
+
start() {
|
|
178
|
+
const schedule = () => {
|
|
179
|
+
if (this.lifetime.signal.aborted)
|
|
180
|
+
return;
|
|
181
|
+
let delay = 1000;
|
|
182
|
+
this.work = this.tick().catch(() => { delay = 30_000; this.log(); }).finally(() => {
|
|
183
|
+
this.work = undefined;
|
|
184
|
+
if (!this.lifetime.signal.aborted) {
|
|
185
|
+
this.timer = setTimeout(schedule, delay);
|
|
186
|
+
this.timer.unref();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
schedule();
|
|
191
|
+
}
|
|
192
|
+
async tick() {
|
|
193
|
+
this.lifetime.signal.throwIfAborted();
|
|
194
|
+
const acquired = this.db.prepare('UPDATE scheduler SET owner=?, lease_until=? WHERE id=1 AND (lease_until<=? OR owner=?)')
|
|
195
|
+
.run(this.owner, Date.now() + 90_000, Date.now(), this.owner).changes;
|
|
196
|
+
if (!acquired)
|
|
197
|
+
return;
|
|
198
|
+
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();
|
|
207
|
+
}
|
|
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
|
+
});
|
|
222
|
+
this.changed();
|
|
223
|
+
return; // Drain durable history before executing; coalesce superseded values.
|
|
224
|
+
}
|
|
225
|
+
const jobs = this.db.prepare('SELECT job FROM jobs').all().map(({ job }) => JSON.parse(job));
|
|
226
|
+
const job = jobs.filter((j) => j.due !== null && j.due <= Date.now()).sort((a, b) => a.due - b.due)[0];
|
|
227
|
+
if (!job)
|
|
228
|
+
return;
|
|
229
|
+
const event = job.event;
|
|
230
|
+
const task = await this.note(['agent', 'get', String(event.taskId)]);
|
|
231
|
+
if (!this.leased())
|
|
232
|
+
return;
|
|
233
|
+
const currentValue = task && ['active', 'in_progress'].includes(task.state) ? task.agent_notes[event.field] ?? null : null;
|
|
234
|
+
if (event.value !== currentValue) {
|
|
235
|
+
this.transaction(() => this.clear(event.taskId, event.field));
|
|
236
|
+
this.changed();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
let result, retryAfterSeconds = 0;
|
|
240
|
+
try {
|
|
241
|
+
result = await runFieldHandler(this.handler, {
|
|
242
|
+
version: 1, eventId: `${event.id}:${event.field}`, taskId: event.taskId, field: event.field,
|
|
243
|
+
event: job.checkedAt ? 'check' : event.event, value: event.value, previousValue: event.previousValue,
|
|
244
|
+
...(job.result ? { previousResult: job.result } : {}),
|
|
245
|
+
}, this.lifetime.signal);
|
|
246
|
+
if (result.state === 'error') {
|
|
247
|
+
retryAfterSeconds = result.recheckAfterSeconds ?? 0;
|
|
248
|
+
throw new Error('Handler reported unavailable data');
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
if (!this.leased() || this.lifetime.signal.aborted)
|
|
253
|
+
return;
|
|
254
|
+
job.attempts++;
|
|
255
|
+
job.stale = true;
|
|
256
|
+
job.result ??= { state: 'error', summary: 'Field handler unavailable', display: { color: 'muted' } };
|
|
257
|
+
job.due = Date.now() + Math.max(retryAfterSeconds, Math.min(3600, 30 * 2 ** Math.min(job.attempts - 1, 7))) * 1000;
|
|
258
|
+
this.transaction(() => this.save(job));
|
|
259
|
+
this.changed();
|
|
260
|
+
this.log();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const latest = await this.note(['agent', 'get', String(event.taskId)]);
|
|
264
|
+
if (!this.leased())
|
|
265
|
+
return;
|
|
266
|
+
if (latest?.updated_at !== task?.updated_at)
|
|
267
|
+
return; // Includes remove/re-add (ABA); next tick drains new events.
|
|
268
|
+
this.transaction(() => {
|
|
269
|
+
if (event.value === null)
|
|
270
|
+
this.clear(event.taskId, event.field);
|
|
271
|
+
else {
|
|
272
|
+
const notification = result.notification;
|
|
273
|
+
if (!notification)
|
|
274
|
+
this.db.prepare('DELETE FROM notices WHERE task_id=? AND field=?').run(event.taskId, event.field);
|
|
275
|
+
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);
|
|
278
|
+
}
|
|
279
|
+
this.save({ event, result, due: result.recheckAfterSeconds ? Date.now() + result.recheckAfterSeconds * 1000 : null,
|
|
280
|
+
attempts: 0, checkedAt: new Date().toISOString(), stale: false });
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
this.changed();
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
this.db.prepare('UPDATE scheduler SET lease_until=0 WHERE id=1 AND owner=?').run(this.owner);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
async close() {
|
|
290
|
+
this.lifetime.abort();
|
|
291
|
+
clearTimeout(this.timer);
|
|
292
|
+
await this.work;
|
|
293
|
+
this.db.close();
|
|
294
|
+
}
|
|
295
|
+
}
|
package/dist-mcp/mcp/http.js
CHANGED
|
@@ -9,6 +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
13
|
function diagnostic(path, event) {
|
|
13
14
|
const line = `${new Date().toISOString()} Pinote HTTP ${event}\n`;
|
|
14
15
|
process.stdout.write(line);
|
|
@@ -48,6 +49,13 @@ export async function startPinoteHttpServer(options = {}) {
|
|
|
48
49
|
if (configuredHosts?.some((value) => !/^(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9.-]+)(?::[0-9]+)?$/.test(value)))
|
|
49
50
|
throw new Error('Invalid allowed HTTP authority');
|
|
50
51
|
let allowedHosts = new Set(configuredHosts);
|
|
52
|
+
const fieldListeners = new Set();
|
|
53
|
+
const handler = configuredFieldHandler();
|
|
54
|
+
const fieldAutomation = handler ? await FieldAutomation.open({ handler,
|
|
55
|
+
changed: () => { for (const listener of fieldListeners)
|
|
56
|
+
listener(); },
|
|
57
|
+
log: () => log('field automation check failed; will retry'),
|
|
58
|
+
}) : undefined;
|
|
51
59
|
const reject = (res, status) => { res.writeHead(status, { 'Content-Type': 'text/plain' }); res.end(`${status}\n`); };
|
|
52
60
|
const single = (req, name) => {
|
|
53
61
|
const values = req.rawHeaders.filter((_, i) => i % 2 === 0 && req.rawHeaders[i].toLowerCase() === name);
|
|
@@ -132,6 +140,8 @@ export async function startPinoteHttpServer(options = {}) {
|
|
|
132
140
|
});
|
|
133
141
|
const entry = { transport, server: undefined, busy: 0, streams: 0, touched: Date.now() };
|
|
134
142
|
entry.server = createPinoteServer({
|
|
143
|
+
fieldAutomation,
|
|
144
|
+
onFieldChange: (listener) => { fieldListeners.add(listener); return () => { fieldListeners.delete(listener); }; },
|
|
135
145
|
onError: () => log('protocol or notification failed'),
|
|
136
146
|
onBusy: (delta) => { entry.busy += delta; entry.touched = Date.now(); },
|
|
137
147
|
onClose: () => { all.delete(entry); if (transport.sessionId)
|
|
@@ -165,7 +175,15 @@ export async function startPinoteHttpServer(options = {}) {
|
|
|
165
175
|
});
|
|
166
176
|
listener.requestTimeout = 30_000;
|
|
167
177
|
listener.headersTimeout = 10_000;
|
|
168
|
-
|
|
178
|
+
try {
|
|
179
|
+
await new Promise((ok, fail) => { listener.once('error', fail); listener.listen(port, host, () => { listener.off('error', fail); ok(); }); });
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
await fieldAutomation?.close();
|
|
183
|
+
await events.close();
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
fieldAutomation?.start();
|
|
169
187
|
const address = listener.address();
|
|
170
188
|
if (!address || typeof address === 'string')
|
|
171
189
|
throw new Error('Missing HTTP address');
|
|
@@ -193,6 +211,7 @@ export async function startPinoteHttpServer(options = {}) {
|
|
|
193
211
|
const force = setTimeout(() => listener.closeAllConnections(), 3000);
|
|
194
212
|
try {
|
|
195
213
|
await events.close();
|
|
214
|
+
await fieldAutomation?.close();
|
|
196
215
|
await Promise.allSettled([...all].map((session) => session.server.close()));
|
|
197
216
|
await closed;
|
|
198
217
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Configure this executable as a field-event handler. Requires authenticated `gh`.
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
|
|
8
|
+
const run = promisify(execFile);
|
|
9
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
10
|
+
const errorResult = () => ({ state: 'error', summary: 'Unable to check PR status', recheckAfterSeconds: 120 });
|
|
11
|
+
|
|
12
|
+
function validateResponse(pr) {
|
|
13
|
+
if (!object(pr) || !['OPEN', 'CLOSED', 'MERGED'].includes(pr.state)
|
|
14
|
+
|| !['MERGEABLE', 'CONFLICTING', 'UNKNOWN'].includes(pr.mergeable)
|
|
15
|
+
|| typeof pr.headRefOid !== 'string' || !/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/i.test(pr.headRefOid)
|
|
16
|
+
|| !Array.isArray(pr.statusCheckRollup)) throw new Error('Invalid response');
|
|
17
|
+
return pr.statusCheckRollup.map((check) => {
|
|
18
|
+
if (!object(check)) throw new Error('Invalid check');
|
|
19
|
+
if (check.__typename === 'CheckRun') {
|
|
20
|
+
if (typeof check.name !== 'string' || !check.name
|
|
21
|
+
|| !['COMPLETED', 'IN_PROGRESS', 'QUEUED', 'REQUESTED', 'WAITING', 'PENDING'].includes(check.status)
|
|
22
|
+
|| !(check.conclusion === null || ['', 'SUCCESS', 'NEUTRAL', 'SKIPPED', 'FAILURE', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STALE', 'STARTUP_FAILURE'].includes(check.conclusion))
|
|
23
|
+
|| (check.status === 'COMPLETED' && !check.conclusion)
|
|
24
|
+
|| (check.status !== 'COMPLETED' && check.conclusion)) throw new Error('Invalid check run');
|
|
25
|
+
const failed = check.status === 'COMPLETED' && !['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(check.conclusion);
|
|
26
|
+
return { failed, pending: check.status !== 'COMPLETED', fingerprint: ['CheckRun', check.name, check.status, check.conclusion] };
|
|
27
|
+
}
|
|
28
|
+
if (check.__typename === 'StatusContext') {
|
|
29
|
+
if (typeof check.context !== 'string' || !check.context
|
|
30
|
+
|| !['EXPECTED', 'ERROR', 'FAILURE', 'PENDING', 'SUCCESS'].includes(check.state)) throw new Error('Invalid status context');
|
|
31
|
+
return { failed: ['ERROR', 'FAILURE'].includes(check.state), pending: ['EXPECTED', 'PENDING'].includes(check.state), fingerprint: ['StatusContext', check.context, check.state] };
|
|
32
|
+
}
|
|
33
|
+
throw new Error('Unknown check type');
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function handleFieldEvent(event) {
|
|
38
|
+
if (!object(event) || event.version !== 1 || !['added', 'changed', 'removed', 'check'].includes(event.event)
|
|
39
|
+
|| !Number.isSafeInteger(event.taskId) || typeof event.field !== 'string'
|
|
40
|
+
|| !(event.value === null || typeof event.value === 'string')
|
|
41
|
+
|| !(event.previousValue === null || typeof event.previousValue === 'string')
|
|
42
|
+
|| typeof event.eventId !== 'string'
|
|
43
|
+
|| (event.previousResult !== undefined && !object(event.previousResult))) throw new Error('Invalid event');
|
|
44
|
+
if (event.field !== 'PR' || event.event === 'removed') return {};
|
|
45
|
+
const value = event.value?.trim() ?? '';
|
|
46
|
+
const url = /^\[[^\]\r\n]*\]\(([^\s()]+)\)$/.exec(value)?.[1] ?? value;
|
|
47
|
+
const match = /^https:\/\/github\.com\/([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\/([A-Za-z0-9_.-]+)\/pull\/([1-9][0-9]*)$/.exec(url);
|
|
48
|
+
if (!match || ['.', '..'].includes(match[2])) return errorResult();
|
|
49
|
+
const [, owner, repo, number] = match;
|
|
50
|
+
const { stdout } = await run('gh', ['pr', 'view', number, '--repo', `${owner}/${repo}`, '--json', 'state,mergeable,statusCheckRollup,headRefOid'], { timeout: 20_000, maxBuffer: 1024 * 1024, encoding: 'utf8' });
|
|
51
|
+
const pr = JSON.parse(stdout);
|
|
52
|
+
const checks = validateResponse(pr);
|
|
53
|
+
const summary = `PR #${number}`;
|
|
54
|
+
if (pr.state === 'MERGED') return { state: 'merged', summary: `${summary} merged`, display: { strikethrough: true, color: 'muted' } };
|
|
55
|
+
if (pr.state === 'CLOSED') return { state: 'closed', summary: `${summary} closed`, display: { strikethrough: false, color: 'muted' } };
|
|
56
|
+
const conflict = pr.mergeable === 'CONFLICTING';
|
|
57
|
+
const failed = checks.filter((check) => check.failed);
|
|
58
|
+
if (conflict || failed.length) {
|
|
59
|
+
const state = conflict ? 'conflict' : 'failed';
|
|
60
|
+
const fingerprint = createHash('sha256').update(JSON.stringify([
|
|
61
|
+
pr.headRefOid.toLowerCase(), state,
|
|
62
|
+
failed.map((check) => JSON.stringify(check.fingerprint)).sort(),
|
|
63
|
+
])).digest('hex');
|
|
64
|
+
return { state, summary: `${summary} ${conflict ? 'has merge conflicts' : 'checks failed'}`,
|
|
65
|
+
display: { strikethrough: false, color: 'warning' },
|
|
66
|
+
notification: { key: `pr:${owner}/${repo}:${number}:${fingerprint}`, message: conflict ? 'PR has merge conflicts' : 'PR checks failed' },
|
|
67
|
+
recheckAfterSeconds: 60 };
|
|
68
|
+
}
|
|
69
|
+
const pending = pr.mergeable === 'UNKNOWN' || checks.length === 0 || checks.some((check) => check.pending);
|
|
70
|
+
return { state: pending ? 'pending' : 'passing', summary: `${summary} ${pending ? 'checks pending' : 'checks passing'}`,
|
|
71
|
+
display: { strikethrough: false, color: pending ? 'muted' : 'success' }, recheckAfterSeconds: 60 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
75
|
+
try {
|
|
76
|
+
let input = '';
|
|
77
|
+
for await (const chunk of process.stdin) {
|
|
78
|
+
input += chunk;
|
|
79
|
+
if (Buffer.byteLength(input) > 1024 * 1024) throw new Error('Input too large');
|
|
80
|
+
}
|
|
81
|
+
process.stdout.write(`${JSON.stringify(await handleFieldEvent(JSON.parse(input)))}\n`);
|
|
82
|
+
} catch {
|
|
83
|
+
process.stderr.write('PR field handler failed\n');
|
|
84
|
+
process.stdout.write(`${JSON.stringify(errorResult())}\n`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Field-event handler
|
|
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.
|
|
6
|
+
|
|
7
|
+
## Enable
|
|
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:
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
{
|
|
14
|
+
"fieldHandler": "/absolute/path/field-events.mjs"
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
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.
|
|
22
|
+
|
|
23
|
+
The bundled [`examples/field-events.mjs`](../examples/field-events.mjs) handles `PR`
|
|
24
|
+
and ignores other fields. Copy it to a user-owned location before customization,
|
|
25
|
+
make it executable (`chmod u+x`), and ensure authenticated `gh` and Node are on the
|
|
26
|
+
**service's** PATH. It accepts a GitHub PR URL or Markdown link, checks once, and
|
|
27
|
+
requests another check in 60 seconds. It warns on failing checks/conflicts and
|
|
28
|
+
strikes through merged PRs while keeping their links. Closed/merged PRs stop
|
|
29
|
+
polling; merging never completes the Pinote task. Passing means reported checks
|
|
30
|
+
passed, not that every review/branch-protection requirement is satisfied.
|
|
31
|
+
|
|
32
|
+
To support another field, add another branch to this same file. No registry or
|
|
33
|
+
plugin package is needed.
|
|
34
|
+
|
|
35
|
+
## Script contract
|
|
36
|
+
|
|
37
|
+
Pinote executes the file directly, **without a shell**, from its containing
|
|
38
|
+
directory. It sends one JSON object on stdin, closes stdin, and expects one JSON
|
|
39
|
+
object on stdout. Diagnostics belong on stderr. All values and external responses
|
|
40
|
+
are untrusted data; never interpolate them into shell commands.
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"version": 1,
|
|
45
|
+
"eventId": "123:PR",
|
|
46
|
+
"event": "added",
|
|
47
|
+
"taskId": 402,
|
|
48
|
+
"field": "PR",
|
|
49
|
+
"value": "https://github.com/owner/repo/pull/123",
|
|
50
|
+
"previousValue": null
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- `added`, `changed`, `removed`: effective field-value changes from committed task
|
|
55
|
+
history, including CLI/GUI/other-client writes. Completing, removing or scheduling
|
|
56
|
+
a task removes its effective fields; restoring/reactivating adds them again.
|
|
57
|
+
Editing a global footer definition does **not** emit a task-field event.
|
|
58
|
+
- `check`: a requested scheduled check. `eventId` and `previousValue` still identify
|
|
59
|
+
the originating change; `previousResult` contains the last stored result, if any.
|
|
60
|
+
- A removed field has `value: null`. Its handler result cannot style, notify or
|
|
61
|
+
reschedule it. Removed/replaced inputs invalidate old results and alerts.
|
|
62
|
+
|
|
63
|
+
Return `{}` for an ignored field, or:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"state": "failed",
|
|
68
|
+
"summary": "PR #123 checks failed",
|
|
69
|
+
"display": { "color": "warning", "strikethrough": false },
|
|
70
|
+
"notification": { "key": "head-sha:failed-check-fingerprint", "message": "PR checks failed" },
|
|
71
|
+
"recheckAfterSeconds": 60
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
All result properties are optional. Unknown properties are rejected.
|
|
76
|
+
|
|
77
|
+
| Property | Meaning / limit |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `state` | Status name, 1–64 characters; `error` is reserved for unavailable/failed checks |
|
|
80
|
+
| `summary` | Observation for agent context, at most 1,000 characters |
|
|
81
|
+
| `display` | `strikethrough` boolean; `color`: `accent`, `muted`, `text`, `warning`, `error`, `success` |
|
|
82
|
+
| `notification` | Stable `key` (1–256 characters) and `message` (1–1,000); changes create a fresh alert |
|
|
83
|
+
| `recheckAfterSeconds` | Integer 10–86,400; omitted/null stops successful checks |
|
|
84
|
+
|
|
85
|
+
Results replace previous results, not the original field value. Omitting a
|
|
86
|
+
notification clears the current alert. Identical notification key/message pairs
|
|
87
|
+
are deduplicated; a resolved condition followed by another failure alerts again.
|
|
88
|
+
|
|
89
|
+
## Delivery and reliability
|
|
90
|
+
|
|
91
|
+
- The service stores its history cursor, scheduled checks, results and alert owner
|
|
92
|
+
under `$XDG_STATE_HOME/pinote/field-events/<database-path-hash>/state.db` (default
|
|
93
|
+
state home: `~/.local/state`). Keep this private and local, like `notes.db`.
|
|
94
|
+
It uses Node's built-in SQLite API; Node 22 may print its experimental warning.
|
|
95
|
+
- Monitoring continues without an open Pi session while the HTTP service runs.
|
|
96
|
+
Deadlines survive restarts. A lease prevents overlapping normal execution by
|
|
97
|
+
competing service processes; run one service/configuration per notes database.
|
|
98
|
+
- Initial activation reconstructs fields from durable history. Intake scans at
|
|
99
|
+
most 25 history rows per tick and coalesces superseded pending changes before
|
|
100
|
+
execution. This is a **current-field automation API**, not a lossless audit hook:
|
|
101
|
+
intermediate values may never reach the script. Old migrated history without
|
|
102
|
+
field snapshots cannot be reconstructed.
|
|
103
|
+
- One handler runs at a time. Each invocation has a 30-second deadline and a
|
|
104
|
+
combined 64 KiB stdout/stderr limit. On Unix, cancellation kills its process
|
|
105
|
+
group. Scripts must not detach child processes. Invalid JSON/schema, nonzero
|
|
106
|
+
exit, timeout or `state: "error"` preserve the last good result as stale and
|
|
107
|
+
retry with exponential backoff (30 seconds to one hour, or a longer requested
|
|
108
|
+
retry). First-check failure appears as unavailable. Stale results don't alert.
|
|
109
|
+
- Results are discarded if the task changed during execution, including
|
|
110
|
+
remove/re-add of the same value. Durable intake then reconciles new input.
|
|
111
|
+
Handler invocations are **at least once** after crashes: use `eventId` and
|
|
112
|
+
idempotent logic for external side effects; exactly-once execution is not promised.
|
|
113
|
+
- Alerts belong to the conversation that last explicitly selected or updated the
|
|
114
|
+
task. For an unowned task, the first selected-task view claims alerts. Other
|
|
115
|
+
sessions still see shared field status. Explicitly selecting the task reclaims
|
|
116
|
+
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.**
|
|
123
|
+
- Only the latest current alert per field is retained, not an offline alert log.
|
|
124
|
+
If a failure resolves while Pi is closed, it is not delivered later as a stale
|
|
125
|
+
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.
|
|
128
|
+
Restoring an older notes backup can invalidate the cursor: stop the service and
|
|
129
|
+
deliberately back up/reset its corresponding automation state before restarting.
|
|
130
|
+
|
|
131
|
+
Handlers run with the service user's permissions and inherited environment,
|
|
132
|
+
including available credentials. They are trusted code, **not sandboxed**. The
|
|
133
|
+
remote UI and agent tools cannot configure executable paths. Service logs report
|
|
134
|
+
failures generically; script output and stderr are not copied into logs. Run the
|
|
135
|
+
script manually with synthetic input when debugging; don't put secrets in results,
|
|
136
|
+
which may be displayed and sent to the configured model.
|
|
137
|
+
|
|
138
|
+
## Read field history locally
|
|
139
|
+
|
|
140
|
+
`note field-events [--after CURSOR] [--limit N]` is read-only. Without `--after`, it
|
|
141
|
+
returns the current tail and an empty array. An explicit cursor (including `0`)
|
|
142
|
+
scans up to N durable rows, advancing even across unrelated events. Limits are
|
|
143
|
+
1–1,000 rows, default 256; several field changes can share one event-row ID:
|
|
144
|
+
|
|
145
|
+
```json
|
|
146
|
+
{"cursor":123,"events":[{"id":123,"taskId":402,"field":"PR","event":"added","value":"https://github.com/owner/repo/pull/123","previousValue":null}]}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Persist the **returned cursor**, not just the last emitted field ID. Cursors must
|
|
150
|
+
be nonnegative safe integers and cannot be ahead of current history. This command
|
|
151
|
+
does not execute handlers or change the existing completion-only `/events` stream.
|
package/mcp/README.md
CHANGED
|
@@ -141,6 +141,13 @@ 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.
|
|
150
|
+
|
|
144
151
|
## Task-completion events
|
|
145
152
|
|
|
146
153
|
`GET /events` on the same listener requires the same bearer token, Host allowlist
|