pi-note 0.40.1 → 0.41.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 CHANGED
@@ -107,6 +107,15 @@ Unrelated fresh launches start unselected.
107
107
  Changing endpoints starts a separate selection namespace. Unsaved Pi sessions
108
108
  cannot guarantee selection persistence.
109
109
 
110
+ With pi-note 0.41.0+ on both client and service, and note 0.25.0+, selecting a task
111
+ (including accepting a suggestion, creating one, or **Start in Kitty…**) appends
112
+ the actual Pi session ID to its persistent `pi-sessions` array, without duplicates.
113
+ Task reads and injected agent context include the array so later agents can locate
114
+ previous sessions on the machine that saved them. Reads, previews, reload/resume,
115
+ and carried selections do not append IDs; select the task in a new session to record it.
116
+ IDs are metadata only: transcripts are not uploaded, and unsaved/deleted sessions
117
+ may no longer be available. The array is separate from editable handoff fields.
118
+
110
119
  The GTK app's **Start in Kitty…** action (note 0.13.0+) chooses a working directory
111
120
  and opens a named tab with this task selected, without submitting a prompt.
112
121
  In note 0.16.0+, it uses the tag's default folder when available, otherwise keeps
@@ -232,6 +241,12 @@ Since pi-note 0.40.1, `/pinote` opens tasks directly and **Tab** opens settings.
232
241
  Update both client and service for newest-created-first search results, then
233
242
  `/reload` Pi.
234
243
 
244
+ With pi-note 0.41.1+ on client and service, watcher execution failures appear as
245
+ one terminal-only notice listing failing watchers and affected fields, with the
246
+ service log path for safe diagnostics. Repeated retries stay quiet; a changed
247
+ failure set or recovery updates the notice. These diagnostics never trigger a
248
+ model turn or enter model context; current actionable PR alerts remain separate.
249
+
235
250
  Since pi-note 0.40.0, `fieldWatchers` replaces `fieldHandler`. Existing handlers
236
251
  appear disabled and need explicit re-enabling: enabling now authorizes automatic
237
252
  worker turns, not just notifications. Update/restart the service and `/reload` Pi.
@@ -6,6 +6,8 @@ After=network.target
6
6
  [Service]
7
7
  Type=simple
8
8
  WorkingDirectory=/absolute/path/to/pinote
9
+ # Include the directory containing the ExecStart Node binary, plus watcher tools.
10
+ Environment="PATH=/absolute/path/to/node-bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
9
11
  EnvironmentFile=%h/.config/pinote/service.env
10
12
  ExecStart=/absolute/path/to/node /absolute/path/to/pinote/pi/dist-mcp/mcp/http.js
11
13
  Restart=on-failure
@@ -34,7 +34,7 @@ const ProposalInput = z.union([
34
34
  ExistingProposal,
35
35
  ]);
36
36
  const Proposal = z.union([NewProposal.extend({ id: z.string().uuid() }), ExistingProposal.extend({ id: z.string().uuid() })]);
37
- const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), fieldReceipts: z.array(z.string().uuid()).max(4096).optional(), state: z.object({
37
+ const Context = z.object({ conversationId: z.string().min(1).max(256), piSessionId: z.string().max(256).regex(/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/).optional(), contextId: z.string().min(1).max(256).optional(), fieldReceipts: z.array(z.string().uuid()).max(4096).optional(), state: z.object({
38
38
  proposal: Proposal.nullable().optional(),
39
39
  launch: z.object({ task_id: TaskId, updated_at: z.string().min(1).max(128) }).strict().optional(),
40
40
  selectedId: z.number().int().positive().safe().nullable().optional(), screen: z.enum(['tasks', 'settings', 'field']).optional(),
@@ -90,8 +90,8 @@ async function note(args, signal) {
90
90
  const options = { signal, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 };
91
91
  const version = (await exec(noteCommand, ['--version'], options)).stdout.trim();
92
92
  const match = /^pinote (\d+)\.(\d+)\.(\d+)$/.exec(version);
93
- if (!match || Number(match[1]) === 0 && Number(match[2]) < 12)
94
- throw new Error('Pinote MCP requires note 0.12.0+');
93
+ if (!match || Number(match[1]) === 0 && Number(match[2]) < 25)
94
+ throw new Error('Pinote service requires note 0.25.0+');
95
95
  return (await exec(noteCommand, ['--no-notify', ...args], options)).stdout;
96
96
  }
97
97
  async function selected(state, signal) {
@@ -149,8 +149,9 @@ async function snapshot(state, signal, automation, contextId, receipts = []) {
149
149
  state = Context.parse({ conversationId: 'canonical', state }).state;
150
150
  const fieldStatuses = task ? automation?.statuses(task) ?? [] : [];
151
151
  const fieldNotifications = task && contextId ? automation?.notices(task, contextId, receipts) ?? [] : [];
152
- const revision = createHash('sha256').update(JSON.stringify({ state, tasks, task, proposedTask, raw: config.raw, fieldStatuses, fieldNotifications })).digest('hex');
153
- return { state, tasks, task, proposedTask, suggestion, config, revision, fieldStatuses, fieldNotifications };
152
+ const watcherFailures = automation?.failures() ?? [];
153
+ const revision = createHash('sha256').update(JSON.stringify({ state, tasks, task, proposedTask, raw: config.raw, fieldStatuses, fieldNotifications, watcherFailures })).digest('hex');
154
+ return { state, tasks, task, proposedTask, suggestion, config, revision, fieldStatuses, fieldNotifications, watcherFailures };
154
155
  }
155
156
  function scopedActions(meta) {
156
157
  return z.object({ actionRevisions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success;
@@ -181,6 +182,7 @@ function footerFeatures(meta) {
181
182
  footerStyles: z.object({ footerStyles: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
182
183
  fieldEvents: z.object({ fieldEvents: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
183
184
  fieldWatchers: z.object({ fieldWatchers: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
185
+ watcherDiagnostics: z.object({ watcherDiagnostics: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
184
186
  footerDoubleActions: z.object({ footerDoubleActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
185
187
  };
186
188
  }
@@ -192,7 +194,7 @@ function fullPreview(task) {
192
194
  const document = task.text + notes;
193
195
  return document.length <= 20_000 ? document : 'Full preview exceeds the 20000-character display limit. Nothing has been truncated here: read the complete task and handoff notes through the Pinote CLI or service (get_task).';
194
196
  }
195
- function view(data, scoped = false, secondary = false, features = {}) {
197
+ function view(data, scoped = false, secondary = false, features = {}, watcherLogPath) {
196
198
  const { tasks, task, suggestion, config, revision } = data;
197
199
  const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
198
200
  const controls = [];
@@ -339,7 +341,7 @@ function view(data, scoped = false, secondary = false, features = {}) {
339
341
  if (scoped)
340
342
  for (const control of editable)
341
343
  control.revision = actionRevision(data, control);
342
- const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer, ...(features.fieldEvents ? { fieldNotifications: data.fieldNotifications.map((notice) => ({ ...notice, ...(features.fieldWatchers ? { triggerWorker: true } : {}) })) } : {}) };
344
+ const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer, ...(features.watcherDiagnostics && watcherLogPath ? { watcherDiagnostics: { logPath: watcherLogPath, failures: data.watcherFailures } } : {}), ...(features.fieldEvents ? { fieldNotifications: data.fieldNotifications.map((notice) => ({ ...notice, ...(features.fieldWatchers ? { triggerWorker: true } : {}) })) } : {}) };
343
345
  // JSON escaping and expanded footer formats can exceed the host budget even
344
346
  // with bounded row counts. Preserve navigation/filter controls ahead of rows.
345
347
  let shortened = false;
@@ -363,12 +365,12 @@ async function agentContext(state, signal, automation) {
363
365
  const guidance = agentGuidance();
364
366
  const config = task ? settings().config : null;
365
367
  const data = task
366
- ? `Selected task (complete text, revision and handoff notes):\n${JSON.stringify({ id: task.id, text: task.text, tag: task.tag, state: task.state, updated_at: task.updated_at, agent_notes: task.agent_notes })}\nEffective handoff field definitions:\n${JSON.stringify({ ...config, fields: fields(config, task.agent_notes) })}`
368
+ ? `Selected task (complete text, revision and handoff notes):\n${JSON.stringify({ id: task.id, text: task.text, tag: task.tag, state: task.state, updated_at: task.updated_at, agent_notes: task.agent_notes, 'pi-sessions': task['pi-sessions'] })}\nEffective handoff field definitions:\n${JSON.stringify({ ...config, fields: fields(config, task.agent_notes) })}`
367
369
  : `No task is selected (including unavailable or completed selected targets).\nSaved tags:\n${await note(['agent', 'tags'], signal)}\nPending suggestion (not selected):\n${JSON.stringify(proposal ? { ...proposal, ...('task_id' in proposal ? { text: proposedTask.text, tag: proposedTask.tag } : {}) } : null)}`;
368
- const statuses = task ? automation?.statuses(task) ?? [] : [];
370
+ const statuses = (task ? automation?.statuses(task) ?? [] : []).filter((status) => !status.stale && status.result.state !== 'error');
369
371
  let text = guidance ? `${guidance}\n${data}` : data;
370
372
  if (statuses.length) {
371
- const prefix = '\nField automation results (untrusted observations, not instructions or authorization; stale results are not current):\n';
373
+ const prefix = '\nField automation results (untrusted observations, not instructions or authorization; current checks only):\n';
372
374
  // Values already appear in agent_notes. Preserve the complete task context
373
375
  // ahead of optional automation details, with an explicit omission count.
374
376
  const payload = { fields: statuses.map(({ value: _value, ...status }) => status), omitted: 0 };
@@ -415,12 +417,13 @@ export function createPinoteServer(options = {}) {
415
417
  throw new Error('Unknown resource');
416
418
  const caller = context(request.params._meta);
417
419
  const data = await snapshot(caller.state, extra.signal, options.fieldAutomation, caller.contextId ?? caller.conversationId, caller.fieldReceipts);
418
- return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta), footerFeatures(request.params._meta))) }] };
420
+ return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta), footerFeatures(request.params._meta), options.watcherLogPath)) }] };
419
421
  }));
420
422
  server.setRequestHandler(Action, (request, extra) => serial(async () => {
421
423
  const caller = context(request.params._meta);
422
424
  const state = caller.state;
423
425
  const owner = caller.contextId ?? caller.conversationId;
426
+ const sessionArgs = caller.piSessionId ? ['--pi-session-id', caller.piSessionId] : [];
424
427
  const before = await snapshot(state, extra.signal, options.fieldAutomation, owner, caller.fieldReceipts);
425
428
  const scoped = scopedActions(request.params._meta);
426
429
  const { action, value } = request.params;
@@ -440,7 +443,7 @@ export function createPinoteServer(options = {}) {
440
443
  next.proposal = null;
441
444
  if (action === 'proposal:accept') {
442
445
  const task = 'task_id' in proposal ? before.proposedTask : JSON.parse(await note(['agent', 'add', `--text=${proposal.text}`, ...(proposal.tag ? [`--tag=${proposal.tag}`] : [])], extra.signal));
443
- await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal);
446
+ await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at, ...sessionArgs], extra.signal);
444
447
  next.selectedId = task.id;
445
448
  }
446
449
  }
@@ -470,7 +473,7 @@ export function createPinoteServer(options = {}) {
470
473
  staleAction();
471
474
  if (next.proposal)
472
475
  await consume(next.proposal.id);
473
- await note(['agent', 'start', String(id), '--expected-updated-at', target.updated_at], extra.signal);
476
+ await note(['agent', 'start', String(id), '--expected-updated-at', target.updated_at, ...sessionArgs], extra.signal);
474
477
  next.selectedId = id;
475
478
  next.proposal = null;
476
479
  }
@@ -479,7 +482,7 @@ export function createPinoteServer(options = {}) {
479
482
  await consume(next.proposal.id);
480
483
  next.proposal = null;
481
484
  const task = JSON.parse(await note(['agent', 'add', `--text=${z.string().min(1).parse(value)}`], extra.signal));
482
- await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal);
485
+ await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at, ...sessionArgs], extra.signal);
483
486
  next.selectedId = task.id;
484
487
  }
485
488
  else if (action === 'done') {
@@ -550,7 +553,7 @@ export function createPinoteServer(options = {}) {
550
553
  }
551
554
  if (next.selectedId && (action.startsWith('select:') || action === 'proposal:accept' || action === 'add'))
552
555
  options.fieldAutomation?.claim(next.selectedId, owner, true);
553
- const result = { view: view(await snapshot(next, extra.signal, options.fieldAutomation, owner, caller.fieldReceipts), scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
556
+ const result = { view: view(await snapshot(next, extra.signal, options.fieldAutomation, owner, caller.fieldReceipts), scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta), options.watcherLogPath), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
554
557
  await notifyChanged();
555
558
  return result;
556
559
  }));
@@ -20,14 +20,16 @@ export const FieldResult = z.object({
20
20
  notification: z.object({ key: z.string().min(1).max(256), message: z.string().min(1).max(1000) }).strict().optional(),
21
21
  recheckAfterSeconds: z.number().int().min(10).max(86400).nullable().optional(),
22
22
  }).strict();
23
+ class FieldHandlerFailure extends Error {
24
+ }
23
25
  /** One bounded invocation, no shell. Kill the process group on cancellation/overflow. */
24
26
  export function runFieldHandler(handler, input, signal, timeoutMs = 30_000) {
25
27
  signal.throwIfAborted();
26
28
  return new Promise((resolve, reject) => {
27
29
  const child = spawn(handler, [], { cwd: dirname(handler), stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32' });
28
- let output = '', bytes = 0, failed = false;
29
- const kill = () => {
30
- failed = true;
30
+ let output = '', bytes = 0, failure;
31
+ const kill = (reason = 'Watcher cancelled') => {
32
+ failure ??= reason;
31
33
  try {
32
34
  if (process.platform !== 'win32' && child.pid)
33
35
  process.kill(-child.pid, 'SIGKILL');
@@ -36,27 +38,33 @@ export function runFieldHandler(handler, input, signal, timeoutMs = 30_000) {
36
38
  }
37
39
  catch { /* Already exited. */ }
38
40
  };
39
- const timer = setTimeout(kill, timeoutMs);
40
- signal.addEventListener('abort', kill, { once: true });
41
- const cleanup = () => { clearTimeout(timer); signal.removeEventListener('abort', kill); };
41
+ const timer = setTimeout(() => kill('Watcher timed out'), timeoutMs);
42
+ const abort = () => kill();
43
+ signal.addEventListener('abort', abort, { once: true });
44
+ const cleanup = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); };
42
45
  child.stdout.setEncoding('utf8');
43
46
  child.stdout.on('data', (chunk) => { bytes += Buffer.byteLength(chunk); if (bytes > 64 * 1024)
44
- kill();
47
+ kill('Watcher output exceeded 64 KiB');
45
48
  else
46
49
  output += chunk; });
47
50
  child.stderr.on('data', (chunk) => { bytes += chunk.length; if (bytes > 64 * 1024)
48
- kill(); });
49
- child.stdin.on('error', kill);
50
- child.on('error', () => { cleanup(); reject(new Error('Field handler failed')); });
51
+ kill('Watcher output exceeded 64 KiB'); });
52
+ child.stdin.on('error', () => kill('Watcher input could not be delivered'));
53
+ child.on('error', (error) => {
54
+ cleanup();
55
+ reject(new FieldHandlerFailure(error.code === 'ENOENT' ? 'Watcher executable or interpreter not found; check service PATH' :
56
+ error.code === 'EACCES' ? 'Watcher execution denied; check permissions' : 'Watcher could not be started'));
57
+ });
51
58
  child.on('close', (code) => {
52
59
  cleanup();
53
- if (failed || code !== 0 || signal.aborted)
54
- return reject(new Error('Field handler failed'));
60
+ if (failure || code !== 0 || signal.aborted)
61
+ return reject(new FieldHandlerFailure(failure ??
62
+ (code === 127 ? 'Watcher exited with code 127; check interpreter and service PATH' : 'Watcher exited unsuccessfully')));
55
63
  try {
56
64
  resolve(FieldResult.parse(JSON.parse(output)));
57
65
  }
58
66
  catch {
59
- reject(new Error('Invalid field handler result'));
67
+ reject(new FieldHandlerFailure('Invalid watcher JSON or result schema'));
60
68
  }
61
69
  });
62
70
  child.stdin.end(JSON.stringify(input));
@@ -194,6 +202,15 @@ export class FieldAutomation {
194
202
  return [{ watcherId: watcher.id, field: job.event.field, value: job.event.value, result: job.result, checkedAt: job.checkedAt, stale: job.stale ?? false }];
195
203
  }));
196
204
  }
205
+ /** Service-wide health, separate from task results and worker alerts. */
206
+ failures() {
207
+ this.reconcile();
208
+ return this.enabled.flatMap((watcher) => {
209
+ const fields = [...new Set(this.db.prepare('SELECT job FROM fw_jobs WHERE watcher_id=?').all(watcher.id)
210
+ .map(({ job }) => JSON.parse(job)).filter((job) => job.stale && job.event.value !== null).map((job) => job.event.field))].sort();
211
+ return fields.length ? [{ watcherId: watcher.id, name: watcher.name.slice(0, 256), fields: fields.slice(0, 8), moreFields: Math.max(0, fields.length - 8) }] : [];
212
+ });
213
+ }
197
214
  notices(task, contextId, receipts = []) {
198
215
  this.claim(task.id, contextId);
199
216
  const owner = this.db.prepare('SELECT context_id FROM owners WHERE task_id=?').get(task.id);
@@ -304,10 +321,10 @@ export class FieldAutomation {
304
321
  }, invocation.signal);
305
322
  if (result.state === 'error') {
306
323
  retryAfterSeconds = result.recheckAfterSeconds ?? 0;
307
- throw new Error('Handler reported unavailable data');
324
+ throw new FieldHandlerFailure('Watcher reported unavailable data; check dependencies, authentication and connectivity');
308
325
  }
309
326
  }
310
- catch {
327
+ catch (error) {
311
328
  if (!valid() || invocation.signal.aborted)
312
329
  return;
313
330
  job.attempts++;
@@ -316,7 +333,9 @@ export class FieldAutomation {
316
333
  job.due = Date.now() + Math.max(retryAfterSeconds, Math.min(3600, 30 * 2 ** Math.min(job.attempts - 1, 7))) * 1000;
317
334
  this.transaction(() => this.save(job));
318
335
  this.changed();
319
- this.log();
336
+ this.log({ watcher: watcher.name, watcherId: watcher.id, taskId: event.taskId, field: event.field,
337
+ reason: error instanceof FieldHandlerFailure ? error.message : 'Watcher check failed',
338
+ lastSuccess: job.checkedAt ?? null, retryAt: new Date(job.due).toISOString() });
320
339
  return;
321
340
  }
322
341
  finally {
@@ -53,7 +53,7 @@ export async function startPinoteHttpServer(options = {}) {
53
53
  const fieldAutomation = await FieldAutomation.open({
54
54
  changed: () => { for (const listener of fieldListeners)
55
55
  listener(); },
56
- log: () => log('field automation check failed; will retry'),
56
+ log: (failure) => log(failure ? `watcher check failed ${JSON.stringify(failure)}` : 'field automation check failed; will retry'),
57
57
  });
58
58
  const reject = (res, status) => { res.writeHead(status, { 'Content-Type': 'text/plain' }); res.end(`${status}\n`); };
59
59
  const single = (req, name) => {
@@ -140,6 +140,7 @@ export async function startPinoteHttpServer(options = {}) {
140
140
  const entry = { transport, server: undefined, busy: 0, streams: 0, touched: Date.now() };
141
141
  entry.server = createPinoteServer({
142
142
  fieldAutomation,
143
+ watcherLogPath: logPath,
143
144
  onFieldChange: (listener) => { fieldListeners.add(listener); return () => { fieldListeners.delete(listener); }; },
144
145
  onError: () => log('protocol or notification failed'),
145
146
  onBusy: (delta) => { entry.busy += delta; entry.touched = Date.now(); },
@@ -37,7 +37,9 @@ notifications, not automatic worker actions. Saving watchers removes the old key
37
37
  The bundled [`watchers/github-pr.mjs`](../watchers/github-pr.mjs) handles `PR`
38
38
  and ignores other fields. Copy it to a user-owned location before customization,
39
39
  make it executable (`chmod u+x`), and ensure authenticated `gh` and Node are on the
40
- **service's** PATH. It accepts a GitHub PR URL or Markdown link, checks once, and
40
+ **service's** PATH. For systemd, follow the [service PATH setup](README.md#run-continuously);
41
+ using an absolute Node path in `ExecStart` alone is insufficient for the watcher's
42
+ `#!/usr/bin/env node` interpreter. It accepts a GitHub PR URL or Markdown link, checks once, and
41
43
  requests another check in 60 seconds. It warns on failing checks/conflicts and
42
44
  strikes through merged PRs while keeping their links. Closed/merged PRs stop
43
45
  polling; merging never completes the Pinote task. Passing means reported checks
@@ -115,6 +117,15 @@ are deduplicated; a resolved condition followed by another failure alerts again.
115
117
  execution. This is a **current-field automation API**, not a lossless audit hook:
116
118
  intermediate values may never reach the script. Old migrated history without
117
119
  field snapshots cannot be reconstructed.
120
+ - In Pi's interactive terminal, execution failures produce one grouped notice:
121
+ `Pinote watchers failing: GitHub PR [PR], Deploy [Build, URL].`
122
+ It points to the actual service log on the **service machine**. Health is
123
+ service-wide, grouped by watcher and affected field across tasks; long field
124
+ lists show an additional count. Notices appear on connection and when the
125
+ failure set changes, not on every retry. A final notice clears the failure set
126
+ after recovery, disabling a watcher or removing its failing fields. Disconnects
127
+ and older services are not treated as recovery. These notices use native Pi
128
+ notifications only: no model turn, transcript entry, or model-context diagnostics.
118
129
  - One handler runs at a time. Each invocation has a 30-second deadline and a
119
130
  combined 64 KiB stdout/stderr limit. On Unix, cancellation kills its process
120
131
  group. Scripts must not detach child processes. Invalid JSON/schema, nonzero
@@ -141,7 +152,9 @@ are deduplicated; a resolved condition followed by another failure alerts again.
141
152
  the worker must verify fresh task context and obey existing scope and approvals.
142
153
  Enabling never authorizes task selection/completion, PR merging or bypassing
143
154
  approvals. Current results reach each agent run through fresh task-scoped
144
- context; oversized results are omitted with a count, preserving the task first.
155
+ context; stale/unavailable checks are excluded. Oversized current results are
156
+ omitted with a count, preserving the task first. Actionable results such as PR
157
+ CI failures still use the separately enabled worker-alert behavior above.
145
158
  - Only the latest current alert per field is retained, not an offline alert log.
146
159
  If a failure resolves while Pi is closed, it is not delivered later as a stale
147
160
  failure. Network recovery remains explicit through `/pinote` or a tool call.
@@ -153,8 +166,13 @@ are deduplicated; a resolved condition followed by another failure alerts again.
153
166
 
154
167
  Handlers run with the service user's permissions and inherited environment,
155
168
  including available credentials. They are trusted code, **not sandboxed**. The
156
- human-confirmed Watchers UI can configure executable paths; agent tools cannot. Service logs report
157
- failures generically; script output and stderr are not copied into logs. Run the
169
+ human-confirmed Watchers UI can configure executable paths; agent tools cannot.
170
+ The existing service log records each failed invocation with timestamp, watcher
171
+ name/ID, task ID, field name, a fixed safe reason, last successful check and next
172
+ retry. Reasons distinguish missing executables/interpreters, permissions, exit
173
+ failures (including interpreter/PATH exit 127), timeouts, output limits, invalid
174
+ results and reported unavailable data. No field values, raw exceptions, script
175
+ stdout/stderr or credentials are copied into diagnostics. Run the
158
176
  script manually with synthetic input when debugging; don't put secrets in results,
159
177
  which may be displayed and sent to the configured model.
160
178
 
package/mcp/README.md CHANGED
@@ -7,7 +7,8 @@ resources, notifications and its native terminal UI. No browser UI is served.
7
7
  ## Build and run
8
8
 
9
9
  On the machine holding your notes, requires **Node.js 22.19+**, **Python 3.11+**
10
- and the checkout's `note` CLI:
10
+ and the checkout's `note` CLI **0.25.0+** (schema 8). Upgrade all installations
11
+ sharing the database before starting the service:
11
12
 
12
13
  ```sh
13
14
  uv sync --locked
@@ -84,8 +85,12 @@ rotation requires restarting the service and updating client credentials.
84
85
  ## Run continuously
85
86
 
86
87
  A [systemd user-unit template](../deploy/pinote.service) is included. Replace its
87
- absolute checkout, Node and CLI paths and review it before installation. Store the
88
- environment file outside the repository with mode `0600`:
88
+ absolute checkout and Node paths. Set its `Environment=PATH=…` Node-bin directory
89
+ to the directory containing that Node executable; also include `gh`'s directory if
90
+ outside the listed system directories. An absolute Node path in `ExecStart` starts
91
+ the service but does not put Node on the PATH inherited by watcher scripts.
92
+ Review the unit before installation. Store the environment file outside the
93
+ repository with mode `0600`:
89
94
 
90
95
  ```ini
91
96
  PINOTE_TOKEN_FILE=/absolute/path/to/private/pinote-token
@@ -95,6 +100,11 @@ PINOTE_LOG_PATH=/absolute/path/to/private/state/pinote/app.log
95
100
  # PINOTE_ALLOWED_HOSTS=notes.example.net
96
101
  ```
97
102
 
103
+ Set `PINOTE_NOTE_COMMAND` to the absolute CLI path. If `service.env` also sets
104
+ `PATH`, it overrides the unit's value: provide the complete list of directories,
105
+ including Node and watcher tools. Systemd does not expand `$PATH` or `~` here and
106
+ does not load shell startup files. The service user must authenticate `gh`.
107
+
98
108
  Install the reviewed unit under your user systemd directory, then:
99
109
 
100
110
  ```sh
@@ -103,11 +113,17 @@ systemctl --user enable --now pinote.service
103
113
  journalctl --user -u pinote.service
104
114
  ```
105
115
 
116
+ For an existing installation, update the installed unit/environment file, run
117
+ `systemctl --user daemon-reload`, then `systemctl --user restart pinote.service`.
118
+ A restart briefly disconnects clients; it preserves notes and watcher state.
119
+ Pulling the checkout alone does not update an installed unit or running environment.
120
+
106
121
  This starts at login and survives closing Pi. Running before login requires
107
122
  administrator-approved user lingering. Your machine must remain awake and reachable.
108
123
  Installation never creates the unit, changes lingering, or edits personal config
109
- automatically. Logs go to stdout/journal and `app.log`; arguments, results, tokens
110
- and task content are not logged. Arrange external rotation if needed.
124
+ automatically. Logs go to stdout/journal and `app.log`. Watcher failures include
125
+ safe reason categories, watcher/field names, task IDs and retry times, never field
126
+ values, raw script output, arguments or credentials. Arrange external rotation if needed.
111
127
 
112
128
  ## Sessions and failures
113
129
 
@@ -147,7 +163,9 @@ Manage `fieldWatchers` in **Settings → Footer fields → Watchers**. Bundled f
147
163
  are discovered disabled; trusted custom executables can be added and enabled.
148
164
  Independent checks survive restarts and settings apply live, without another daemon.
149
165
  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
166
+ through merged PRs. With pi-note 0.41.1+ on both sides, failed watcher executions
167
+ produce grouped terminal-only notices pointing to the service log; retries do not
168
+ spam the terminal or trigger model turns. Requires note 0.24.0+. Review the
151
169
  [watcher contract and safeguards](FIELD_EVENTS.md) before enabling trusted code.
152
170
 
153
171
  ## Task-completion events
package/mcp/core.ts CHANGED
@@ -36,14 +36,14 @@ const ProposalInput = z.union([
36
36
  ExistingProposal,
37
37
  ]);
38
38
  const Proposal = z.union([NewProposal.extend({ id: z.string().uuid() }), ExistingProposal.extend({ id: z.string().uuid() })]);
39
- const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), fieldReceipts: z.array(z.string().uuid()).max(4096).optional(), state: z.object({
39
+ const Context = z.object({ conversationId: z.string().min(1).max(256), piSessionId: z.string().max(256).regex(/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/).optional(), contextId: z.string().min(1).max(256).optional(), fieldReceipts: z.array(z.string().uuid()).max(4096).optional(), state: z.object({
40
40
  proposal: Proposal.nullable().optional(),
41
41
  launch: z.object({ task_id: TaskId, updated_at: z.string().min(1).max(128) }).strict().optional(),
42
42
  selectedId: z.number().int().positive().safe().nullable().optional(), screen: z.enum(['tasks', 'settings', 'field']).optional(),
43
43
  field: z.string().refine((value) => [...value].length <= 64, 'Field name exceeds 64 code points').optional(), search: z.string().max(256).optional(),
44
44
  }).default({}) });
45
45
  type State = z.infer<typeof Context>['state'];
46
- type Task = { id: number; text: string; state: string; tag: string | null; updated_at: string; agent_notes: Record<string, string> };
46
+ type Task = { id: number; text: string; state: string; tag: string | null; updated_at: string; agent_notes: Record<string, string>; 'pi-sessions': string[] };
47
47
  type Scalar = string | number | boolean | null;
48
48
  type Control = { id: string; label: string; revision?: string; kind: 'action' | 'text' | 'number' | 'toggle' | 'select'; value?: Scalar; icon?: string; description?: string; multiline?: boolean; confirm?: string; options?: { label: string; value: Scalar }[] };
49
49
  const Action = z.object({ method: z.literal('native-ui/action'), params: z.object({
@@ -81,7 +81,7 @@ async function note(args: string[], signal: AbortSignal): Promise<string> {
81
81
  const options = { signal, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 };
82
82
  const version = (await exec(noteCommand, ['--version'], options)).stdout.trim();
83
83
  const match = /^pinote (\d+)\.(\d+)\.(\d+)$/.exec(version);
84
- if (!match || Number(match[1]) === 0 && Number(match[2]) < 12) throw new Error('Pinote MCP requires note 0.12.0+');
84
+ if (!match || Number(match[1]) === 0 && Number(match[2]) < 25) throw new Error('Pinote service requires note 0.25.0+');
85
85
  return (await exec(noteCommand, ['--no-notify', ...args], options)).stdout;
86
86
  }
87
87
  async function selected(state: State, signal: AbortSignal): Promise<Task | null> {
@@ -131,8 +131,9 @@ async function snapshot(state: State, signal: AbortSignal, automation?: FieldAut
131
131
  state = Context.parse({ conversationId: 'canonical', state }).state;
132
132
  const fieldStatuses = task ? automation?.statuses(task) ?? [] : [];
133
133
  const fieldNotifications = task && contextId ? automation?.notices(task, contextId, receipts) ?? [] : [];
134
- const revision = createHash('sha256').update(JSON.stringify({ state, tasks, task, proposedTask, raw: config.raw, fieldStatuses, fieldNotifications })).digest('hex');
135
- return { state, tasks, task, proposedTask, suggestion, config, revision, fieldStatuses, fieldNotifications };
134
+ const watcherFailures = automation?.failures() ?? [];
135
+ const revision = createHash('sha256').update(JSON.stringify({ state, tasks, task, proposedTask, raw: config.raw, fieldStatuses, fieldNotifications, watcherFailures })).digest('hex');
136
+ return { state, tasks, task, proposedTask, suggestion, config, revision, fieldStatuses, fieldNotifications, watcherFailures };
136
137
  }
137
138
  type Snapshot = Awaited<ReturnType<typeof snapshot>>;
138
139
  function scopedActions(meta: Record<string, unknown> | undefined): boolean {
@@ -161,6 +162,7 @@ function footerFeatures(meta: Record<string, unknown> | undefined) {
161
162
  footerStyles: z.object({ footerStyles: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
162
163
  fieldEvents: z.object({ fieldEvents: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
163
164
  fieldWatchers: z.object({ fieldWatchers: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
165
+ watcherDiagnostics: z.object({ watcherDiagnostics: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
164
166
  footerDoubleActions: z.object({ footerDoubleActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
165
167
  };
166
168
  }
@@ -172,7 +174,7 @@ function fullPreview(task: { text: string; agent_notes?: Record<string, string>
172
174
  const document = task.text + notes;
173
175
  return document.length <= 20_000 ? document : 'Full preview exceeds the 20000-character display limit. Nothing has been truncated here: read the complete task and handoff notes through the Pinote CLI or service (get_task).';
174
176
  }
175
- function view(data: Snapshot, scoped = false, secondary = false, features: { footerNativeActions?: boolean; footerStyles?: boolean; footerDoubleActions?: boolean; fieldEvents?: boolean; fieldWatchers?: boolean } = {}) {
177
+ function view(data: Snapshot, scoped = false, secondary = false, features: { footerNativeActions?: boolean; footerStyles?: boolean; footerDoubleActions?: boolean; fieldEvents?: boolean; fieldWatchers?: boolean; watcherDiagnostics?: boolean } = {}, watcherLogPath?: string) {
176
178
  const { tasks, task, suggestion, config, revision } = data;
177
179
  const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
178
180
  const controls: Control[] = [];
@@ -288,7 +290,7 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
288
290
  return false;
289
291
  });
290
292
  if (scoped) for (const control of editable) control.revision = actionRevision(data, control);
291
- const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer, ...(features.fieldEvents ? { fieldNotifications: data.fieldNotifications.map((notice) => ({ ...notice, ...(features.fieldWatchers ? { triggerWorker: true } : {}) })) } : {}) };
293
+ const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer, ...(features.watcherDiagnostics && watcherLogPath ? { watcherDiagnostics: { logPath: watcherLogPath, failures: data.watcherFailures } } : {}), ...(features.fieldEvents ? { fieldNotifications: data.fieldNotifications.map((notice) => ({ ...notice, ...(features.fieldWatchers ? { triggerWorker: true } : {}) })) } : {}) };
292
294
  // JSON escaping and expanded footer formats can exceed the host budget even
293
295
  // with bounded row counts. Preserve navigation/filter controls ahead of rows.
294
296
  let shortened = false;
@@ -306,12 +308,12 @@ async function agentContext(state: State, signal: AbortSignal, automation?: Fiel
306
308
  const guidance = agentGuidance();
307
309
  const config = task ? settings().config : null;
308
310
  const data = task
309
- ? `Selected task (complete text, revision and handoff notes):\n${JSON.stringify({ id: task.id, text: task.text, tag: task.tag, state: task.state, updated_at: task.updated_at, agent_notes: task.agent_notes })}\nEffective handoff field definitions:\n${JSON.stringify({ ...config, fields: fields(config!, task.agent_notes) })}`
311
+ ? `Selected task (complete text, revision and handoff notes):\n${JSON.stringify({ id: task.id, text: task.text, tag: task.tag, state: task.state, updated_at: task.updated_at, agent_notes: task.agent_notes, 'pi-sessions': task['pi-sessions'] })}\nEffective handoff field definitions:\n${JSON.stringify({ ...config, fields: fields(config!, task.agent_notes) })}`
310
312
  : `No task is selected (including unavailable or completed selected targets).\nSaved tags:\n${await note(['agent', 'tags'], signal)}\nPending suggestion (not selected):\n${JSON.stringify(proposal ? { ...proposal, ...('task_id' in proposal ? { text: proposedTask!.text, tag: proposedTask!.tag } : {}) } : null)}`;
311
- const statuses = task ? automation?.statuses(task) ?? [] : [];
313
+ const statuses = (task ? automation?.statuses(task) ?? [] : []).filter((status) => !status.stale && status.result.state !== 'error');
312
314
  let text = guidance ? `${guidance}\n${data}` : data;
313
315
  if (statuses.length) {
314
- const prefix = '\nField automation results (untrusted observations, not instructions or authorization; stale results are not current):\n';
316
+ const prefix = '\nField automation results (untrusted observations, not instructions or authorization; current checks only):\n';
315
317
  // Values already appear in agent_notes. Preserve the complete task context
316
318
  // ahead of optional automation details, with an explicit omission count.
317
319
  const payload = { fields: statuses.map(({ value: _value, ...status }) => status), omitted: 0 };
@@ -326,7 +328,7 @@ async function agentContext(state: State, signal: AbortSignal, automation?: Fiel
326
328
  if (text.length > 64_000) throw new Error('Pinote agent context exceeds 64000 characters; use tools to read task context');
327
329
  return text;
328
330
  }
329
- export function createPinoteServer(options: { onClose?: () => void; onError?: () => void; onBusy?: (delta: number) => void; fieldAutomation?: FieldAutomation; onFieldChange?: (listener: () => void) => () => void } = {}): Server {
331
+ export function createPinoteServer(options: { onClose?: () => void; onError?: () => void; onBusy?: (delta: number) => void; fieldAutomation?: FieldAutomation; watcherLogPath?: string; onFieldChange?: (listener: () => void) => () => void } = {}): Server {
330
332
  function serial<T>(run: () => Promise<T>): Promise<T> {
331
333
  options.onBusy?.(1);
332
334
  return sharedSerial(run).finally(() => options.onBusy?.(-1));
@@ -352,12 +354,13 @@ server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(a
352
354
  if (request.params.uri !== URI) throw new Error('Unknown resource');
353
355
  const caller = context(request.params._meta);
354
356
  const data = await snapshot(caller.state, extra.signal, options.fieldAutomation, caller.contextId ?? caller.conversationId, caller.fieldReceipts);
355
- return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta), footerFeatures(request.params._meta))) }] };
357
+ return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta), footerFeatures(request.params._meta), options.watcherLogPath)) }] };
356
358
  }));
357
359
  server.setRequestHandler(Action, (request, extra) => serial(async () => {
358
360
  const caller = context(request.params._meta);
359
361
  const state = caller.state;
360
362
  const owner = caller.contextId ?? caller.conversationId;
363
+ const sessionArgs = caller.piSessionId ? ['--pi-session-id', caller.piSessionId] : [];
361
364
  const before = await snapshot(state, extra.signal, options.fieldAutomation, owner, caller.fieldReceipts);
362
365
  const scoped = scopedActions(request.params._meta);
363
366
  const { action, value } = request.params;
@@ -374,7 +377,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
374
377
  next.proposal = null;
375
378
  if (action === 'proposal:accept') {
376
379
  const task: Task = 'task_id' in proposal ? before.proposedTask! : JSON.parse(await note(['agent', 'add', `--text=${proposal.text}`, ...(proposal.tag ? [`--tag=${proposal.tag}`] : [])], extra.signal));
377
- await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal); next.selectedId = task.id;
380
+ await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at, ...sessionArgs], extra.signal); next.selectedId = task.id;
378
381
  }
379
382
  } else if (action.startsWith('screen:')) next.screen = action.slice(7) as State['screen'];
380
383
  else if (action.startsWith('field:')) { next.screen = 'field'; next.field = action.slice(6); }
@@ -390,12 +393,12 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
390
393
  const target: Task = JSON.parse(await note(['agent', 'get', String(id)], extra.signal));
391
394
  if (target.text !== candidate.text || target.state !== candidate.state || target.tag !== candidate.tag || state.launch && target.updated_at !== state.launch.updated_at) staleAction();
392
395
  if (next.proposal) await consume(next.proposal.id);
393
- await note(['agent', 'start', String(id), '--expected-updated-at', target.updated_at], extra.signal); next.selectedId = id; next.proposal = null;
396
+ await note(['agent', 'start', String(id), '--expected-updated-at', target.updated_at, ...sessionArgs], extra.signal); next.selectedId = id; next.proposal = null;
394
397
  } else if (action === 'add') {
395
398
  if (next.proposal) await consume(next.proposal.id);
396
399
  next.proposal = null;
397
400
  const task: Task = JSON.parse(await note(['agent', 'add', `--text=${z.string().min(1).parse(value)}`], extra.signal));
398
- await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal); next.selectedId = task.id;
401
+ await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at, ...sessionArgs], extra.signal); next.selectedId = task.id;
399
402
  } else if (action === 'done') {
400
403
  await note(['agent', 'done', String(before.task!.id), '--expected-updated-at', before.task!.updated_at], extra.signal);
401
404
  next.selectedId = null;
@@ -443,7 +446,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
443
446
  saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy, guidance, watchers);
444
447
  }
445
448
  if (next.selectedId && (action.startsWith('select:') || action === 'proposal:accept' || action === 'add')) options.fieldAutomation?.claim(next.selectedId, owner, true);
446
- const result = { view: view(await snapshot(next, extra.signal, options.fieldAutomation, owner, caller.fieldReceipts), scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
449
+ const result = { view: view(await snapshot(next, extra.signal, options.fieldAutomation, owner, caller.fieldReceipts), scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta), options.watcherLogPath), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
447
450
  await notifyChanged();
448
451
  return result;
449
452
  }));
@@ -25,6 +25,8 @@ export const FieldResult = z.object({
25
25
  export type FieldResult = z.infer<typeof FieldResult>;
26
26
  type FieldEvent = z.infer<typeof Event>;
27
27
  type Job = { watcherId: string; event: FieldEvent; result?: FieldResult; due: number | null; attempts: number; checkedAt?: string; stale?: boolean };
28
+ export type WatcherFailureLog = { watcher: string; watcherId: string; taskId: number; field: string; reason: string; lastSuccess: string | null; retryAt: string };
29
+ class FieldHandlerFailure extends Error {}
28
30
  export type FieldStatus = { watcherId: string; field: string; value: string; result: FieldResult; checkedAt?: string; stale: boolean };
29
31
  export type FieldNotice = { id: string; taskId: number; field: string; message: string };
30
32
  type Task = { id: number; state: string; updated_at: string; agent_notes: Record<string, string> };
@@ -35,23 +37,29 @@ export function runFieldHandler(handler: string, input: unknown, signal: AbortSi
35
37
  signal.throwIfAborted();
36
38
  return new Promise((resolve, reject) => {
37
39
  const child = spawn(handler, [], { cwd: dirname(handler), stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32' });
38
- let output = '', bytes = 0, failed = false;
39
- const kill = () => {
40
- failed = true;
40
+ let output = '', bytes = 0, failure: string | undefined;
41
+ const kill = (reason = 'Watcher cancelled') => {
42
+ failure ??= reason;
41
43
  try { if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, 'SIGKILL'); else child.kill('SIGKILL'); } catch { /* Already exited. */ }
42
44
  };
43
- const timer = setTimeout(kill, timeoutMs);
44
- signal.addEventListener('abort', kill, { once: true });
45
- const cleanup = () => { clearTimeout(timer); signal.removeEventListener('abort', kill); };
45
+ const timer = setTimeout(() => kill('Watcher timed out'), timeoutMs);
46
+ const abort = () => kill();
47
+ signal.addEventListener('abort', abort, { once: true });
48
+ const cleanup = () => { clearTimeout(timer); signal.removeEventListener('abort', abort); };
46
49
  child.stdout.setEncoding('utf8');
47
- child.stdout.on('data', (chunk: string) => { bytes += Buffer.byteLength(chunk); if (bytes > 64 * 1024) kill(); else output += chunk; });
48
- child.stderr.on('data', (chunk: Buffer) => { bytes += chunk.length; if (bytes > 64 * 1024) kill(); });
49
- child.stdin.on('error', kill);
50
- child.on('error', () => { cleanup(); reject(new Error('Field handler failed')); });
50
+ child.stdout.on('data', (chunk: string) => { bytes += Buffer.byteLength(chunk); if (bytes > 64 * 1024) kill('Watcher output exceeded 64 KiB'); else output += chunk; });
51
+ child.stderr.on('data', (chunk: Buffer) => { bytes += chunk.length; if (bytes > 64 * 1024) kill('Watcher output exceeded 64 KiB'); });
52
+ child.stdin.on('error', () => kill('Watcher input could not be delivered'));
53
+ child.on('error', (error: NodeJS.ErrnoException) => {
54
+ cleanup();
55
+ reject(new FieldHandlerFailure(error.code === 'ENOENT' ? 'Watcher executable or interpreter not found; check service PATH' :
56
+ error.code === 'EACCES' ? 'Watcher execution denied; check permissions' : 'Watcher could not be started'));
57
+ });
51
58
  child.on('close', (code) => {
52
59
  cleanup();
53
- if (failed || code !== 0 || signal.aborted) return reject(new Error('Field handler failed'));
54
- try { resolve(FieldResult.parse(JSON.parse(output))); } catch { reject(new Error('Invalid field handler result')); }
60
+ if (failure || code !== 0 || signal.aborted) return reject(new FieldHandlerFailure(failure ??
61
+ (code === 127 ? 'Watcher exited with code 127; check interpreter and service PATH' : 'Watcher exited unsuccessfully')));
62
+ try { resolve(FieldResult.parse(JSON.parse(output))); } catch { reject(new FieldHandlerFailure('Invalid watcher JSON or result schema')); }
55
63
  });
56
64
  child.stdin.end(JSON.stringify(input));
57
65
  });
@@ -72,8 +80,8 @@ export class FieldAutomation {
72
80
  private ticking = false;
73
81
  private noteCommand: string;
74
82
  private changed: () => void;
75
- private log: () => void;
76
- private constructor(db: DatabaseSync, watchers: () => FieldWatcher[], noteCommand: string, changed: () => void, log: () => void) {
83
+ private log: (failure?: WatcherFailureLog) => void;
84
+ private constructor(db: DatabaseSync, watchers: () => FieldWatcher[], noteCommand: string, changed: () => void, log: (failure?: WatcherFailureLog) => void) {
77
85
  this.db = db; this.watchers = watchers; this.noteCommand = noteCommand; this.changed = changed; this.log = log;
78
86
  db.exec(`PRAGMA busy_timeout=5000;
79
87
  CREATE TABLE IF NOT EXISTS fw_scheduler (id INTEGER PRIMARY KEY CHECK(id=1), owner TEXT, lease_until INTEGER NOT NULL DEFAULT 0);
@@ -83,7 +91,7 @@ export class FieldAutomation {
83
91
  CREATE TABLE IF NOT EXISTS fw_notices (watcher_id TEXT NOT NULL, task_id INTEGER NOT NULL, field TEXT NOT NULL, id TEXT NOT NULL, message TEXT NOT NULL, PRIMARY KEY(watcher_id,task_id,field));
84
92
  INSERT OR IGNORE INTO fw_scheduler(id) VALUES (1);`);
85
93
  }
86
- static async open(options: { watchers?: () => FieldWatcher[]; path?: string; noteCommand?: string; changed?: () => void; log?: () => void } = {}): Promise<FieldAutomation> {
94
+ static async open(options: { watchers?: () => FieldWatcher[]; path?: string; noteCommand?: string; changed?: () => void; log?: (failure?: WatcherFailureLog) => void } = {}): Promise<FieldAutomation> {
87
95
  const dataHome = process.env.XDG_DATA_HOME;
88
96
  const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
89
97
  const namespace = createHash('sha256').update(database).digest('hex').slice(0, 16);
@@ -155,6 +163,15 @@ export class FieldAutomation {
155
163
  return [{ watcherId: watcher.id, field: job.event.field, value: job.event.value, result: job.result, checkedAt: job.checkedAt, stale: job.stale ?? false }];
156
164
  }));
157
165
  }
166
+ /** Service-wide health, separate from task results and worker alerts. */
167
+ failures() {
168
+ this.reconcile();
169
+ return this.enabled.flatMap((watcher) => {
170
+ const fields = [...new Set((this.db.prepare('SELECT job FROM fw_jobs WHERE watcher_id=?').all(watcher.id) as Row[])
171
+ .map(({ job }) => JSON.parse(job) as Job).filter((job) => job.stale && job.event.value !== null).map((job) => job.event.field))].sort();
172
+ return fields.length ? [{ watcherId: watcher.id, name: watcher.name.slice(0, 256), fields: fields.slice(0, 8), moreFields: Math.max(0, fields.length - 8) }] : [];
173
+ });
174
+ }
158
175
  notices(task: Task, contextId: string, receipts: readonly string[] = []): FieldNotice[] {
159
176
  this.claim(task.id, contextId);
160
177
  const owner = this.db.prepare('SELECT context_id FROM owners WHERE task_id=?').get(task.id);
@@ -239,14 +256,18 @@ export class FieldAutomation {
239
256
  }, invocation.signal);
240
257
  if (result.state === 'error') {
241
258
  retryAfterSeconds = result.recheckAfterSeconds ?? 0;
242
- throw new Error('Handler reported unavailable data');
259
+ throw new FieldHandlerFailure('Watcher reported unavailable data; check dependencies, authentication and connectivity');
243
260
  }
244
- } catch {
261
+ } catch (error) {
245
262
  if (!valid() || invocation.signal.aborted) return;
246
263
  job.attempts++; job.stale = true;
247
264
  job.result ??= { state: 'error', summary: 'Field handler unavailable', display: { color: 'muted' } };
248
265
  job.due = Date.now() + Math.max(retryAfterSeconds, Math.min(3600, 30 * 2 ** Math.min(job.attempts - 1, 7))) * 1000;
249
- this.transaction(() => this.save(job)); this.changed(); this.log(); return;
266
+ this.transaction(() => this.save(job)); this.changed();
267
+ this.log({ watcher: watcher.name, watcherId: watcher.id, taskId: event.taskId, field: event.field,
268
+ reason: error instanceof FieldHandlerFailure ? error.message : 'Watcher check failed',
269
+ lastSuccess: job.checkedAt ?? null, retryAt: new Date(job.due).toISOString() });
270
+ return;
250
271
  }
251
272
  finally {
252
273
  clearInterval(poll); this.lifetime.signal.removeEventListener('abort', abort);
package/mcp/http.ts CHANGED
@@ -63,7 +63,7 @@ export async function startPinoteHttpServer(options: PinoteHttpOptions = {}) {
63
63
  const fieldListeners = new Set<() => void>();
64
64
  const fieldAutomation = await FieldAutomation.open({
65
65
  changed: () => { for (const listener of fieldListeners) listener(); },
66
- log: () => log('field automation check failed; will retry'),
66
+ log: (failure) => log(failure ? `watcher check failed ${JSON.stringify(failure)}` : 'field automation check failed; will retry'),
67
67
  });
68
68
  const reject = (res: ServerResponse, status: number) => { res.writeHead(status, { 'Content-Type': 'text/plain' }); res.end(`${status}\n`); };
69
69
  const single = (req: IncomingMessage, name: string) => {
@@ -124,6 +124,7 @@ export async function startPinoteHttpServer(options: PinoteHttpOptions = {}) {
124
124
  const entry: Session = { transport, server: undefined!, busy: 0, streams: 0, touched: Date.now() };
125
125
  entry.server = createPinoteServer({
126
126
  fieldAutomation,
127
+ watcherLogPath: logPath,
127
128
  onFieldChange: (listener) => { fieldListeners.add(listener); return () => { fieldListeners.delete(listener); }; },
128
129
  onError: () => log('protocol or notification failed'),
129
130
  onBusy: (delta) => { entry.busy += delta; entry.touched = Date.now(); },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.40.1",
3
+ "version": "0.41.1",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,6 +22,9 @@ export const ViewSchema = z.object({
22
22
  version: z.literal(1), title: text, revision: z.string().min(1).max(256),
23
23
  state: StateSchema, body: text.optional(), retainOnNew: z.boolean().optional(),
24
24
  controls: z.array(control).max(256),
25
+ watcherDiagnostics: z.object({ logPath: z.string().min(1).max(4096), failures: z.array(z.object({
26
+ watcherId: z.string().min(1).max(256), name: z.string().max(256), fields: z.array(z.string().max(128)).max(8), moreFields: z.number().int().nonnegative(),
27
+ }).strict()).max(32) }).strict().optional(),
25
28
  fieldNotifications: z.array(z.object({ id: z.string().uuid(), taskId: z.number().int().positive().safe(), field: z.string().min(1).max(128), message: z.string().min(1).max(1000), triggerWorker: z.literal(true).optional() }).strict()).max(64).optional(),
26
29
  footer: z.array(z.object({ text, openApp: z.literal(true).optional(), url: z.string().max(2048).optional(), action: z.string().min(1).max(256).optional(), secondaryAction: z.string().min(1).max(256).optional(), doubleAction: z.string().min(1).max(256).optional(), style: z.object({ italic: z.boolean().optional(), strikethrough: z.boolean().optional(), color: z.enum(['accent', 'muted', 'text', 'warning', 'error', 'success']).optional() }).strict().optional(), width: z.number().int().min(3).max(1000).optional() }).strict()).max(128).optional(),
27
30
  }).strict().superRefine((view, ctx) => {
package/remote/session.ts CHANGED
@@ -31,6 +31,7 @@ export class RemoteSession {
31
31
  private refreshing = false;
32
32
  private active = false;
33
33
  private conversationId = '';
34
+ private piSessionId?: string;
34
35
  private saved?: SavedState;
35
36
  view?: NativeView;
36
37
  fullscreen = false;
@@ -46,10 +47,11 @@ export class RemoteSession {
46
47
  get signal(): AbortSignal { return this.lifetime.signal; }
47
48
  snapshot(): SavedState | undefined { return this.saved && structuredClone(this.saved); }
48
49
  current(): () => boolean { const epoch = this.epoch; return () => this.active && epoch === this.epoch; }
49
- async start(branch: readonly BranchEntry[], conversationId: string, carried?: SavedState): Promise<void> {
50
+ async start(branch: readonly BranchEntry[], conversationId: string, carried?: SavedState, piSessionId?: string): Promise<void> {
50
51
  await this.stop();
51
52
  this.lifetime = new AbortController(); this.active = true;
52
53
  this.conversationId = conversationId;
54
+ this.piSessionId = piSessionId;
53
55
  this.saved = carried?.scope === this.config.scope ? structuredClone(carried) : restoreState(branch, this.config.scope);
54
56
  this.persist(this.saved);
55
57
  await this.refresh(true);
@@ -64,8 +66,8 @@ export class RemoteSession {
64
66
  private meta(): Record<string, unknown> {
65
67
  if (!this.saved) throw new RemoteFailure();
66
68
  return {
67
- [UI_CONTEXT]: { conversationId: this.conversationId, contextId: this.saved.contextId, state: this.saved.state, fieldReceipts: this.saved.fieldReceipts ?? [] },
68
- [UI_FEATURES]: { actionRevisions: true, fieldEvents: true, fieldWatchers: true, footerStyles: true, footerDoubleActions: true, footerSecondaryActions: true, footerNativeActions: this.fullscreen },
69
+ [UI_CONTEXT]: { conversationId: this.conversationId, piSessionId: this.piSessionId, contextId: this.saved.contextId, state: this.saved.state, fieldReceipts: this.saved.fieldReceipts ?? [] },
70
+ [UI_FEATURES]: { actionRevisions: true, fieldEvents: true, fieldWatchers: true, watcherDiagnostics: true, footerStyles: true, footerDoubleActions: true, footerSecondaryActions: true, footerNativeActions: this.fullscreen },
69
71
  };
70
72
  }
71
73
  private serial<T>(run: () => Promise<T>, signal?: AbortSignal): Promise<T> {
package/remote/ui.ts CHANGED
@@ -23,6 +23,7 @@ export class RemoteUI {
23
23
  private opening = false;
24
24
  private acting = false;
25
25
  private closing = false;
26
+ private watcherFailureKey = '';
26
27
  private failure?: RemoteFailure;
27
28
  private pi: ExtensionAPI;
28
29
  private session: RemoteSession;
@@ -59,6 +60,17 @@ export class RemoteUI {
59
60
  }
60
61
  private render(): void {
61
62
  if (this.ctx?.mode !== 'tui') return;
63
+ const diagnostics = this.session.view?.watcherDiagnostics;
64
+ if (diagnostics) {
65
+ const key = diagnostics.failures.length ? JSON.stringify(diagnostics) : '';
66
+ if (key !== this.watcherFailureKey) {
67
+ const failures = diagnostics.failures.map((watcher) =>
68
+ `${line(watcher.name)} [${watcher.fields.map(line).join(', ')}${watcher.moreFields ? `, +${watcher.moreFields} fields` : ''}]`).join(', ').slice(0, 2000);
69
+ this.ctx.ui.notify(failures ? `Pinote watchers failing: ${failures}.\nDetails (service machine): ${line(diagnostics.logPath)}` :
70
+ 'Pinote watcher failures cleared.', failures ? 'warning' : 'info');
71
+ this.watcherFailureKey = key;
72
+ }
73
+ }
62
74
  const theme = this.ctx.ui.theme;
63
75
  this.ctx.ui.setStatus('pinote-remote', this.session.fullscreen ? undefined : this.session.view ? footerText(this.session.view, truncateToWidth, (text, style) => {
64
76
  let rendered = style.color ? theme.fg(style.color, text) : text;
package/remote.ts CHANGED
@@ -89,7 +89,8 @@ export default function pinoteRemote(pi: ExtensionAPI): void {
89
89
  ctx.ui.notify('Pinote launch ignored: invalid request or nonempty/resumed session. Existing selection is unchanged.', 'warning');
90
90
  }
91
91
  }
92
- await session.start(branch, ctx.sessionManager.getSessionId() || randomUUID(), carried);
92
+ const piSessionId = ctx.sessionManager.getSessionId();
93
+ await session.start(branch, piSessionId || randomUUID(), carried, piSessionId || undefined);
93
94
  if (launch) {
94
95
  const view = session.view!;
95
96
  // The service exposes this control only for the exact GUI-displayed revision.