pi-note 0.39.4 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { accessSync, constants, lstatSync, readdirSync } from 'node:fs';
3
+ import { basename, dirname, isAbsolute, join, normalize } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ export type FieldWatcherConfig = { path: string; enabled: boolean };
7
+ export type FieldWatcher = FieldWatcherConfig & { id: string; name: string; bundled: boolean };
8
+ const moduleDirectory = dirname(fileURLToPath(import.meta.url));
9
+ // tsc emits this module one level below the package root; discovery stays in
10
+ // the same shipped package directory in source and compiled entry points.
11
+ const bundledDirectory = join(basename(moduleDirectory) === 'dist-mcp' ? dirname(moduleDirectory) : moduleDirectory, 'watchers');
12
+ function watcherPath(path: unknown): string {
13
+ if (typeof path !== 'string' || path.length > 4096 || !isAbsolute(path) || /[\u0000-\u001f\u007f-\u009f]/u.test(path)) {
14
+ throw new Error('Watcher path must be an absolute executable file path (max 4096 characters)');
15
+ }
16
+ return normalize(path);
17
+ }
18
+ export function parseFieldWatchers(root: Record<string, unknown>): FieldWatcherConfig[] {
19
+ let entries = root.fieldWatchers;
20
+ if (entries === undefined) entries = root.fieldHandler == null ? [] : [{ path: root.fieldHandler, enabled: false }];
21
+ if (!Array.isArray(entries) || entries.length > 32) throw new Error('fieldWatchers must be an array of at most 32 watchers');
22
+ const seen = new Set<string>();
23
+ return entries.map((entry: unknown) => {
24
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry) || Object.keys(entry).some((key) => key !== 'path' && key !== 'enabled')) throw new Error('Invalid field watcher');
25
+ const config = entry as Record<string, unknown>;
26
+ const path = watcherPath(config.path);
27
+ if (typeof config.enabled !== 'boolean' || seen.has(path)) throw new Error('Invalid or duplicate field watcher');
28
+ seen.add(path);
29
+ return { path, enabled: config.enabled };
30
+ });
31
+ }
32
+ /** Validation only: never starts the executable or invokes a shell. */
33
+ export function validateWatcherExecutable(path: string): void {
34
+ const canonical = watcherPath(path);
35
+ if (!lstatSync(canonical).isFile()) throw new Error('Watcher must be a regular executable file');
36
+ accessSync(canonical, constants.X_OK);
37
+ }
38
+ export function discoverFieldWatchers(root: Record<string, unknown>): FieldWatcher[] {
39
+ const stored = parseFieldWatchers(root);
40
+ const bundled = new Set<string>();
41
+ for (const name of readdirSync(bundledDirectory).sort()) {
42
+ if (!name.endsWith('.mjs')) continue;
43
+ const path = join(bundledDirectory, name);
44
+ try { validateWatcherExecutable(path); bundled.add(path); } catch { /* Not a regular executable. */ }
45
+ }
46
+ const merged = [...stored, ...[...bundled].filter((path) => !stored.some((entry) => entry.path === path)).map((path) => ({ path, enabled: false }))];
47
+ if (merged.length > 32) throw new Error('At most 32 discovered field watchers are supported');
48
+ return merged.map((config) => ({ ...config, id: createHash('sha256').update(config.path).digest('hex'), name: basename(config.path) === 'github-pr.mjs' && bundled.has(config.path) ? 'GitHub PR' : basename(config.path, '.mjs'), bundled: bundled.has(config.path) }));
49
+ }
package/footer-config.ts CHANGED
@@ -2,6 +2,7 @@ import { getAgentDir } from "./config-path.ts";
2
2
  import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
+ import { parseFieldWatchers, type FieldWatcherConfig } from "./field-watchers.ts";
5
6
 
6
7
  export const defaultHandoffPrompt = "Read the current Pinote task. Summarize your understanding, but don’t start work yet.";
7
8
  export const defaultNewSessionPrompt = defaultHandoffPrompt;
@@ -117,7 +118,7 @@ export function loadFooterConfig(warn: (message: string) => void): FooterConfig
117
118
 
118
119
  // Synchronous compare-and-replace: reject edits made while the settings dialog was open,
119
120
  // preserve unrelated keys, and never leave partial JSON behind.
120
- export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string, newSessionPrompt?: string, taskOfferPolicy?: string, agentGuidance?: string | null) {
121
+ export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string, newSessionPrompt?: string, taskOfferPolicy?: string, agentGuidance?: string | null, fieldWatchers?: FieldWatcherConfig[]) {
121
122
  if (taskOfferPolicy !== undefined && !["always", "github-remote", "never"].includes(taskOfferPolicy)) throw new Error("Invalid taskOfferPolicy");
122
123
  const checked = parseFooterConfig({ footer: config });
123
124
  const prompts = {
@@ -140,6 +141,10 @@ export function saveFooterConfig(config: FooterConfig, expectedRaw: string | nul
140
141
  const root = expectedRaw === null ? {} : JSON.parse(expectedRaw);
141
142
  if (!object(root)) throw new Error("Invalid pi-note configuration; expected an object.");
142
143
  if (agentGuidance === null) delete root.agentGuidance;
144
+ if (fieldWatchers !== undefined) {
145
+ root.fieldWatchers = parseFieldWatchers({ fieldWatchers });
146
+ delete root.fieldHandler;
147
+ }
143
148
  raw = `${JSON.stringify({ ...root, ...prompts, ...(taskOfferPolicy === undefined ? {} : { taskOfferPolicy }), footer: checked }, null, 2)}\n`;
144
149
  writeFileSync(temp, raw, { flag: "wx", mode: 0o600 });
145
150
  renameSync(temp, path);
@@ -1,26 +1,40 @@
1
- # Field-event handler
1
+ # Field watchers
2
2
 
3
- pi-note **0.39.0+** with **note 0.24.0+** can run one executable for all task-field
4
- changes. This is opt-in, on the **HTTP service machine**. No extra daemon or Pi-local
5
- watcher is installed. The local-only extension and stdio service do not run handlers.
3
+ pi-note **0.40.0+** with **note 0.24.0+** runs independently enabled executables for
4
+ task-field changes on the **HTTP service machine**. No extra daemon or Pi-local
5
+ watcher is installed. The local-only extension and stdio service do not run watchers.
6
6
 
7
7
  ## Enable
8
8
 
9
- Create a trusted executable with a shebang, then add its absolute path to the
10
- service's `pi-note.json`, preserving existing settings:
9
+ Open **Settings → Footer fields → Watchers**. The fixed package `watchers/` folder
10
+ is scanned for executable `.mjs` files; discovery never executes or enables them.
11
+ Enable **GitHub PR**, or add a custom absolute executable path on the service
12
+ machine and enable it separately. Every change requires review and confirmation.
13
+ Watchers are trusted code, not sandboxed; enabling authorizes their alerts to
14
+ trigger the owning worker to investigate and fix the task within existing permissions.
15
+
16
+ The corresponding service `pi-note.json` setting is:
11
17
 
12
18
  ```json
13
19
  {
14
- "fieldHandler": "/absolute/path/field-events.mjs"
20
+ "fieldWatchers": [
21
+ { "path": "/absolute/path/watcher.mjs", "enabled": true }
22
+ ]
15
23
  }
16
24
  ```
17
25
 
18
- Restart the existing Pinote HTTP service; update clients and `/reload` Pi for alerts
19
- and footer styling. Removing the setting or setting it to `null`, then restarting,
20
- disables execution and hides automation results without modifying task fields.
21
- Scripts and settings are never installed or enabled automatically.
26
+ Update/restart the existing HTTP service and `/reload` Pi once after upgrading.
27
+ Subsequent watcher changes apply live. Disable a watcher to stop checks and hide
28
+ its results without changing task fields. Custom entries can also be removed;
29
+ the file is retained. At most 32 watchers may be configured. For overlapping
30
+ results, the first nonempty watcher result in configuration order supplies the
31
+ footer style; alerts remain independent.
32
+
33
+ `fieldHandler` is replaced, not run alongside the list. An old handler appears
34
+ disabled until explicitly enabled because the previous setting authorized only
35
+ notifications, not automatic worker actions. Saving watchers removes the old key.
22
36
 
23
- The bundled [`examples/field-events.mjs`](../examples/field-events.mjs) handles `PR`
37
+ The bundled [`watchers/github-pr.mjs`](../watchers/github-pr.mjs) handles `PR`
24
38
  and ignores other fields. Copy it to a user-owned location before customization,
25
39
  make it executable (`chmod u+x`), and ensure authenticated `gh` and Node are on the
26
40
  **service's** PATH. It accepts a GitHub PR URL or Markdown link, checks once, and
@@ -29,8 +43,8 @@ strikes through merged PRs while keeping their links. Closed/merged PRs stop
29
43
  polling; merging never completes the Pinote task. Passing means reported checks
30
44
  passed, not that every review/branch-protection requirement is satisfied.
31
45
 
32
- To support another field, add another branch to this same file. No registry or
33
- plugin package is needed.
46
+ To support another field, add another executable using the contract below. Each
47
+ watcher receives all field events and returns `{}` for fields it does not handle.
34
48
 
35
49
  ## Script contract
36
50
 
@@ -91,6 +105,7 @@ are deduplicated; a resolved condition followed by another failure alerts again.
91
105
  - The service stores its history cursor, scheduled checks, results and alert owner
92
106
  under `$XDG_STATE_HOME/pinote/field-events/<database-path-hash>/state.db` (default
93
107
  state home: `~/.local/state`). Keep this private and local, like `notes.db`.
108
+ Watcher cursors, jobs and notices are independent; alert ownership is shared.
94
109
  It uses Node's built-in SQLite API; Node 22 may print its experimental warning.
95
110
  - Monitoring continues without an open Pi session while the HTTP service runs.
96
111
  Deadlines survive restarts. A lease prevents overlapping normal execution by
@@ -114,23 +129,31 @@ are deduplicated; a resolved condition followed by another failure alerts again.
114
129
  task. For an unowned task, the first selected-task view claims alerts. Other
115
130
  sessions still see shared field status. Explicitly selecting the task reclaims
116
131
  ownership. No alert is delivered while that conversation has another task selected.
117
- - Pi shows an alert once and persists receipts across reload/resume (latest 4,096
118
- IDs per branch). Current results reach the next agent run through fresh,
119
- task-scoped context. Duplicate field values are not repeated; results exceeding
120
- the context budget are omitted with a count, preserving the complete task first.
121
- **No prompt submission, model wake-up, task selection or
122
- completion is authorized by a handler result.**
132
+ - Pi triggers a worker turn once per current alert and persists receipts across
133
+ reload/resume (latest 4,096 IDs per branch). Receipts accompany view reads so
134
+ bounded 64-alert pages advance past delivered failures even if they persist.
135
+ Busy workers defer delivery until
136
+ idle, then refresh to verify ownership, selection and current status. An aborted
137
+ run does not immediately drain deferred alerts. There is no launch of a closed
138
+ Pi process; current alerts are delivered when its owning conversation reconnects.
139
+ Older clients/services retain notification-only behavior until both are updated.
140
+ - Worker messages use fixed instructions plus explicitly untrusted observations;
141
+ the worker must verify fresh task context and obey existing scope and approvals.
142
+ Enabling never authorizes task selection/completion, PR merging or bypassing
143
+ approvals. Current results reach each agent run through fresh task-scoped
144
+ context; oversized results are omitted with a count, preserving the task first.
123
145
  - Only the latest current alert per field is retained, not an offline alert log.
124
146
  If a failure resolves while Pi is closed, it is not delivered later as a stale
125
147
  failure. Network recovery remains explicit through `/pinote` or a tool call.
126
- - Changing the configured handler path resets automation state and reconstructs
127
- history; editing the same file affects its next invocation, not stopped checks.
148
+ - Newly enabled watchers reconstruct current fields from history independently.
149
+ Disabling/removing clears their automation state; enabling again checks afresh.
150
+ Editing the same file affects its next invocation, not stopped checks.
128
151
  Restoring an older notes backup can invalidate the cursor: stop the service and
129
152
  deliberately back up/reset its corresponding automation state before restarting.
130
153
 
131
154
  Handlers run with the service user's permissions and inherited environment,
132
155
  including available credentials. They are trusted code, **not sandboxed**. The
133
- remote UI and agent tools cannot configure executable paths. Service logs report
156
+ human-confirmed Watchers UI can configure executable paths; agent tools cannot. Service logs report
134
157
  failures generically; script output and stderr are not copied into logs. Run the
135
158
  script manually with synthetic input when debugging; don't put secrets in results,
136
159
  which may be displayed and sent to the configured model.
package/mcp/README.md CHANGED
@@ -141,12 +141,14 @@ Tests use temporary data and real HTTP/stdio connections, including authenticati
141
141
  shared notes, independent selections, conflicts and reconnects. Visual checks use
142
142
  Xvfb and private D-Bus; never point them at personal data.
143
143
 
144
- ## Field automations
145
-
146
- A user-configured single executable can react to task-field changes and request
147
- scheduled checks inside this service. State survives restarts; no additional daemon
148
- is needed. Requires note 0.24.0+ and is disabled by default. See the
149
- [field-handler contract and PR example](FIELD_EVENTS.md) before enabling trusted code.
144
+ ## Field watchers
145
+
146
+ Manage `fieldWatchers` in **Settings → Footer fields → Watchers**. Bundled files
147
+ are discovered disabled; trusted custom executables can be added and enabled.
148
+ Independent checks survive restarts and settings apply live, without another daemon.
149
+ The GitHub PR watcher triggers the owning worker on failed CI/conflicts and strikes
150
+ through merged PRs. Requires note 0.24.0+. Review the
151
+ [watcher contract and safeguards](FIELD_EVENTS.md) before enabling trusted code.
150
152
 
151
153
  ## Task-completion events
152
154
 
package/mcp/core.ts CHANGED
@@ -14,6 +14,7 @@ import { stripVTControlCharacters } from 'node:util';
14
14
  import { defaultAgentGuidance, defaultFooterField, defaultHandoffPrompt, defaultNewSessionPrompt, effectiveFooterFields, parseAgentGuidance, parseFooterConfig, parseHandoffPrompt, parseNewSessionPrompt, readConfigDocument, readFooterDocument, saveFooterConfig } from '../footer-config.ts';
15
15
  import { proposalTitleOptions, proposalDescriptionOptions, proposalText } from '../proposal.ts';
16
16
  import type { FieldAutomation, FieldResult } from './field-events.ts';
17
+ import { discoverFieldWatchers, parseFieldWatchers, validateWatcherExecutable, type FieldWatcherConfig } from '../field-watchers.ts';
17
18
  import { taskTitle } from '../task-title.ts';
18
19
 
19
20
  const URI = 'pinote://ui';
@@ -35,7 +36,7 @@ const ProposalInput = z.union([
35
36
  ExistingProposal,
36
37
  ]);
37
38
  const Proposal = z.union([NewProposal.extend({ id: z.string().uuid() }), ExistingProposal.extend({ id: z.string().uuid() })]);
38
- const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), state: z.object({
39
+ const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), fieldReceipts: z.array(z.string().uuid()).max(4096).optional(), state: z.object({
39
40
  proposal: Proposal.nullable().optional(),
40
41
  launch: z.object({ task_id: TaskId, updated_at: z.string().min(1).max(128) }).strict().optional(),
41
42
  selectedId: z.number().int().positive().safe().nullable().optional(), screen: z.enum(['tasks', 'settings', 'field']).optional(),
@@ -104,7 +105,7 @@ function settings() {
104
105
  if (!['always', 'github-remote', 'never'].includes(policy)) throw new Error('Invalid taskOfferPolicy in pi-note.json');
105
106
  const guidanceOverridden = Object.hasOwn(root, 'agentGuidance');
106
107
  const guidance = guidanceOverridden ? parseAgentGuidance(root.agentGuidance) : defaultAgentGuidance;
107
- return { ...document, handoffPrompt, newSessionPrompt, policy, guidance, guidanceOverridden };
108
+ return { ...document, handoffPrompt, newSessionPrompt, policy, guidance, guidanceOverridden, watchers: discoverFieldWatchers(root) };
108
109
  }
109
110
  function agentGuidance(): string {
110
111
  const { value: root } = readConfigDocument();
@@ -117,7 +118,7 @@ async function selection(state: State, signal: AbortSignal) {
117
118
  if (proposal && 'task_id' in proposal && (!proposedTask || !['active', 'in_progress'].includes(proposedTask.state))) proposal = null;
118
119
  return { task, proposal, proposedTask };
119
120
  }
120
- async function snapshot(state: State, signal: AbortSignal, automation?: FieldAutomation, contextId?: string) {
121
+ async function snapshot(state: State, signal: AbortSignal, automation?: FieldAutomation, contextId?: string, receipts: readonly string[] = []) {
121
122
  const launchTask: Task | null = state.launch ? JSON.parse(await note(['agent', 'get', String(state.launch.task_id)], signal)) : null;
122
123
  const tasks: Task[] = state.launch
123
124
  ? launchTask && ['active', 'in_progress'].includes(launchTask.state) ? [launchTask] : []
@@ -129,7 +130,7 @@ async function snapshot(state: State, signal: AbortSignal, automation?: FieldAut
129
130
  if (state.screen === 'field' && !fields(config.config, task?.agent_notes).some((f) => f.name === state.field)) state.screen = 'settings';
130
131
  state = Context.parse({ conversationId: 'canonical', state }).state;
131
132
  const fieldStatuses = task ? automation?.statuses(task) ?? [] : [];
132
- const fieldNotifications = task && contextId ? automation?.notices(task, contextId) ?? [] : [];
133
+ const fieldNotifications = task && contextId ? automation?.notices(task, contextId, receipts) ?? [] : [];
133
134
  const revision = createHash('sha256').update(JSON.stringify({ state, tasks, task, proposedTask, raw: config.raw, fieldStatuses, fieldNotifications })).digest('hex');
134
135
  return { state, tasks, task, proposedTask, suggestion, config, revision, fieldStatuses, fieldNotifications };
135
136
  }
@@ -159,6 +160,7 @@ function footerFeatures(meta: Record<string, unknown> | undefined) {
159
160
  footerNativeActions: z.object({ footerNativeActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
160
161
  footerStyles: z.object({ footerStyles: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
161
162
  fieldEvents: z.object({ fieldEvents: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
163
+ fieldWatchers: z.object({ fieldWatchers: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
162
164
  footerDoubleActions: z.object({ footerDoubleActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
163
165
  };
164
166
  }
@@ -170,7 +172,7 @@ function fullPreview(task: { text: string; agent_notes?: Record<string, string>
170
172
  const document = task.text + notes;
171
173
  return document.length <= 20_000 ? document : 'Full preview exceeds the 20000-character display limit. Nothing has been truncated here: read the complete task and handoff notes through the Pinote CLI or service (get_task).';
172
174
  }
173
- function view(data: Snapshot, scoped = false, secondary = false, features: { footerNativeActions?: boolean; footerStyles?: boolean; footerDoubleActions?: boolean; fieldEvents?: boolean } = {}) {
175
+ function view(data: Snapshot, scoped = false, secondary = false, features: { footerNativeActions?: boolean; footerStyles?: boolean; footerDoubleActions?: boolean; fieldEvents?: boolean; fieldWatchers?: boolean } = {}) {
174
176
  const { tasks, task, suggestion, config, revision } = data;
175
177
  const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
176
178
  const controls: Control[] = [];
@@ -202,6 +204,16 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
202
204
  for (const [key, label, description] of [['titleWidth', 'Task title width', '3–1000 terminal cells.'], ['fieldWidth', 'Default field width', '3–1000 terminal cells; inherited by fields without a width override.'], ['maxFields', 'Maximum visible fields', '0–64 fields; 0 hides all task fields.']] as const) controls.push({ id: key, label, description, kind: 'number', value: config.config[key], confirm: 'Save this global footer setting?' });
203
205
  controls.push({ id: 'add-field', label: 'Add field', icon: icons.add, kind: 'text', value: '', description: 'Unique nonblank field name, at most 64 characters; Bar is reserved. Changes display definitions, not task values.', confirm: 'Add this global footer field?' });
204
206
  fields(config.config, task?.agent_notes).forEach((field) => action(`field:${field.name}`, field.name, { description: `${field.label || '(no label)'} · ${field.format}` }));
207
+ controls.push({ id: 'add-watcher', label: 'Add watcher file', icon: icons.add, kind: 'text', value: '',
208
+ description: 'Absolute executable path on the service machine. Added disabled; enable separately after reviewing trusted code.',
209
+ confirm: 'Add this trusted watcher file to shared fieldWatchers (disabled)?' });
210
+ for (const watcher of config.watchers) {
211
+ controls.push({ id: `watcher:${watcher.id}`, label: watcher.name, kind: 'toggle', value: watcher.enabled,
212
+ description: `${watcher.bundled ? 'Bundled' : 'Custom'} · ${watcher.path}`,
213
+ confirm: `Change this shared watcher?\n${watcher.path}\nEnabling executes trusted code with the service user’s permissions and credentials. Its alerts automatically trigger the owning Pi worker to investigate and fix the current task. Disabling stops checks and hides its results.` });
214
+ if (!watcher.bundled) action(`remove-watcher:${watcher.id}`, `Remove ${watcher.name}`, { icon: icons.cross,
215
+ description: watcher.path, confirm: `Remove this watcher from shared settings? The file and task values are retained.\n${watcher.path}` });
216
+ }
205
217
  } else if (state.screen === 'field') {
206
218
  const field = fields(config.config, task?.agent_notes).find((f) => f.name === state.field);
207
219
  if (!field) throw new Error('Field no longer exists; open Settings again');
@@ -274,7 +286,7 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
274
286
  return false;
275
287
  });
276
288
  if (scoped) for (const control of editable) control.revision = actionRevision(data, control);
277
- const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer, ...(features.fieldEvents ? { fieldNotifications: data.fieldNotifications } : {}) };
289
+ const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer, ...(features.fieldEvents ? { fieldNotifications: data.fieldNotifications.map((notice) => ({ ...notice, ...(features.fieldWatchers ? { triggerWorker: true } : {}) })) } : {}) };
278
290
  // JSON escaping and expanded footer formats can exceed the host budget even
279
291
  // with bounded row counts. Preserve navigation/filter controls ahead of rows.
280
292
  let shortened = false;
@@ -337,14 +349,14 @@ server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(a
337
349
  }
338
350
  if (request.params.uri !== URI) throw new Error('Unknown resource');
339
351
  const caller = context(request.params._meta);
340
- const data = await snapshot(caller.state, extra.signal, options.fieldAutomation, caller.contextId ?? caller.conversationId);
352
+ const data = await snapshot(caller.state, extra.signal, options.fieldAutomation, caller.contextId ?? caller.conversationId, caller.fieldReceipts);
341
353
  return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta), footerFeatures(request.params._meta))) }] };
342
354
  }));
343
355
  server.setRequestHandler(Action, (request, extra) => serial(async () => {
344
356
  const caller = context(request.params._meta);
345
357
  const state = caller.state;
346
358
  const owner = caller.contextId ?? caller.conversationId;
347
- const before = await snapshot(state, extra.signal, options.fieldAutomation, owner);
359
+ const before = await snapshot(state, extra.signal, options.fieldAutomation, owner, caller.fieldReceipts);
348
360
  const scoped = scopedActions(request.params._meta);
349
361
  const { action, value } = request.params;
350
362
  const control = view(before, scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta)).controls.find((item) => item.id === action);
@@ -389,7 +401,25 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
389
401
  const cfg = structuredClone(before.config.config);
390
402
  let handoff = before.config.handoffPrompt, newSession = before.config.newSessionPrompt, policy = before.config.policy;
391
403
  let guidance: string | null | undefined;
392
- if (action === 'agentGuidance') guidance = parseAgentGuidance(value);
404
+ let watchers: FieldWatcherConfig[] | undefined;
405
+ if (action === 'add-watcher' || action.startsWith('watcher:') || action.startsWith('remove-watcher:')) {
406
+ watchers = before.config.watchers.map(({ path, enabled }) => ({ path, enabled }));
407
+ if (action === 'add-watcher') {
408
+ const entry = parseFieldWatchers({ fieldWatchers: [{ path: z.string().parse(value), enabled: false }] })[0];
409
+ validateWatcherExecutable(entry.path);
410
+ watchers.push(entry);
411
+ } else {
412
+ const watcher = before.config.watchers.find((item) => item.id === action.split(':')[1]);
413
+ if (!watcher) staleAction();
414
+ if (action.startsWith('remove-watcher:')) watchers = watchers.filter((item) => item.path !== watcher.path);
415
+ else {
416
+ const enabled = z.boolean().parse(value);
417
+ if (enabled) validateWatcherExecutable(watcher.path);
418
+ watchers.find((item) => item.path === watcher.path)!.enabled = enabled;
419
+ }
420
+ }
421
+ watchers = parseFieldWatchers({ fieldWatchers: watchers });
422
+ } else if (action === 'agentGuidance') guidance = parseAgentGuidance(value);
393
423
  else if (action === 'reset-agent-guidance') guidance = null;
394
424
  else if (action === 'handoffPrompt') handoff = z.string().parse(value);
395
425
  else if (action === 'newSessionPrompt') newSession = z.string().parse(value);
@@ -408,10 +438,10 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
408
438
  }
409
439
  }
410
440
  extra.signal.throwIfAborted();
411
- saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy, guidance);
441
+ saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy, guidance, watchers);
412
442
  }
413
443
  if (next.selectedId && (action.startsWith('select:') || action === 'proposal:accept' || action === 'add')) options.fieldAutomation?.claim(next.selectedId, owner, true);
414
- const result = { view: view(await snapshot(next, extra.signal, options.fieldAutomation, owner), scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
444
+ const result = { view: view(await snapshot(next, extra.signal, options.fieldAutomation, owner, caller.fieldReceipts), scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
415
445
  await notifyChanged();
416
446
  return result;
417
447
  }));