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.
- package/README.md +17 -7
- package/dist-mcp/field-watchers.js +57 -0
- package/dist-mcp/footer-config.js +6 -1
- package/dist-mcp/mcp/core.js +46 -10
- package/dist-mcp/mcp/field-events.js +137 -70
- package/dist-mcp/mcp/http.js +3 -4
- package/field-watchers.ts +49 -0
- package/footer-config.ts +6 -1
- package/mcp/FIELD_EVENTS.md +46 -23
- package/mcp/README.md +8 -6
- package/mcp/core.ts +41 -11
- package/mcp/field-events.ts +113 -69
- package/mcp/http.ts +3 -4
- package/package.json +3 -1
- package/remote/navigation.ts +12 -3
- package/remote/protocol.ts +1 -1
- package/remote/session.ts +10 -3
- package/remote/ui.ts +2 -2
- package/remote.ts +20 -5
- /package/{examples/field-events.mjs → watchers/github-pr.mjs} +0 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { accessSync, constants, lstatSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, normalize } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
export type FieldWatcherConfig = { path: string; enabled: boolean };
|
|
7
|
+
export type FieldWatcher = FieldWatcherConfig & { id: string; name: string; bundled: boolean };
|
|
8
|
+
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
// tsc emits this module one level below the package root; discovery stays in
|
|
10
|
+
// the same shipped package directory in source and compiled entry points.
|
|
11
|
+
const bundledDirectory = join(basename(moduleDirectory) === 'dist-mcp' ? dirname(moduleDirectory) : moduleDirectory, 'watchers');
|
|
12
|
+
function watcherPath(path: unknown): string {
|
|
13
|
+
if (typeof path !== 'string' || path.length > 4096 || !isAbsolute(path) || /[\u0000-\u001f\u007f-\u009f]/u.test(path)) {
|
|
14
|
+
throw new Error('Watcher path must be an absolute executable file path (max 4096 characters)');
|
|
15
|
+
}
|
|
16
|
+
return normalize(path);
|
|
17
|
+
}
|
|
18
|
+
export function parseFieldWatchers(root: Record<string, unknown>): FieldWatcherConfig[] {
|
|
19
|
+
let entries = root.fieldWatchers;
|
|
20
|
+
if (entries === undefined) entries = root.fieldHandler == null ? [] : [{ path: root.fieldHandler, enabled: false }];
|
|
21
|
+
if (!Array.isArray(entries) || entries.length > 32) throw new Error('fieldWatchers must be an array of at most 32 watchers');
|
|
22
|
+
const seen = new Set<string>();
|
|
23
|
+
return entries.map((entry: unknown) => {
|
|
24
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry) || Object.keys(entry).some((key) => key !== 'path' && key !== 'enabled')) throw new Error('Invalid field watcher');
|
|
25
|
+
const config = entry as Record<string, unknown>;
|
|
26
|
+
const path = watcherPath(config.path);
|
|
27
|
+
if (typeof config.enabled !== 'boolean' || seen.has(path)) throw new Error('Invalid or duplicate field watcher');
|
|
28
|
+
seen.add(path);
|
|
29
|
+
return { path, enabled: config.enabled };
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
/** Validation only: never starts the executable or invokes a shell. */
|
|
33
|
+
export function validateWatcherExecutable(path: string): void {
|
|
34
|
+
const canonical = watcherPath(path);
|
|
35
|
+
if (!lstatSync(canonical).isFile()) throw new Error('Watcher must be a regular executable file');
|
|
36
|
+
accessSync(canonical, constants.X_OK);
|
|
37
|
+
}
|
|
38
|
+
export function discoverFieldWatchers(root: Record<string, unknown>): FieldWatcher[] {
|
|
39
|
+
const stored = parseFieldWatchers(root);
|
|
40
|
+
const bundled = new Set<string>();
|
|
41
|
+
for (const name of readdirSync(bundledDirectory).sort()) {
|
|
42
|
+
if (!name.endsWith('.mjs')) continue;
|
|
43
|
+
const path = join(bundledDirectory, name);
|
|
44
|
+
try { validateWatcherExecutable(path); bundled.add(path); } catch { /* Not a regular executable. */ }
|
|
45
|
+
}
|
|
46
|
+
const merged = [...stored, ...[...bundled].filter((path) => !stored.some((entry) => entry.path === path)).map((path) => ({ path, enabled: false }))];
|
|
47
|
+
if (merged.length > 32) throw new Error('At most 32 discovered field watchers are supported');
|
|
48
|
+
return merged.map((config) => ({ ...config, id: createHash('sha256').update(config.path).digest('hex'), name: basename(config.path) === 'github-pr.mjs' && bundled.has(config.path) ? 'GitHub PR' : basename(config.path, '.mjs'), bundled: bundled.has(config.path) }));
|
|
49
|
+
}
|
package/footer-config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { getAgentDir } from "./config-path.ts";
|
|
|
2
2
|
import { closeSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { parseFieldWatchers, type FieldWatcherConfig } from "./field-watchers.ts";
|
|
5
6
|
|
|
6
7
|
export const defaultHandoffPrompt = "Read the current Pinote task. Summarize your understanding, but don’t start work yet.";
|
|
7
8
|
export const defaultNewSessionPrompt = defaultHandoffPrompt;
|
|
@@ -117,7 +118,7 @@ export function loadFooterConfig(warn: (message: string) => void): FooterConfig
|
|
|
117
118
|
|
|
118
119
|
// Synchronous compare-and-replace: reject edits made while the settings dialog was open,
|
|
119
120
|
// preserve unrelated keys, and never leave partial JSON behind.
|
|
120
|
-
export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string, newSessionPrompt?: string, taskOfferPolicy?: string, agentGuidance?: string | null) {
|
|
121
|
+
export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string, newSessionPrompt?: string, taskOfferPolicy?: string, agentGuidance?: string | null, fieldWatchers?: FieldWatcherConfig[]) {
|
|
121
122
|
if (taskOfferPolicy !== undefined && !["always", "github-remote", "never"].includes(taskOfferPolicy)) throw new Error("Invalid taskOfferPolicy");
|
|
122
123
|
const checked = parseFooterConfig({ footer: config });
|
|
123
124
|
const prompts = {
|
|
@@ -140,6 +141,10 @@ export function saveFooterConfig(config: FooterConfig, expectedRaw: string | nul
|
|
|
140
141
|
const root = expectedRaw === null ? {} : JSON.parse(expectedRaw);
|
|
141
142
|
if (!object(root)) throw new Error("Invalid pi-note configuration; expected an object.");
|
|
142
143
|
if (agentGuidance === null) delete root.agentGuidance;
|
|
144
|
+
if (fieldWatchers !== undefined) {
|
|
145
|
+
root.fieldWatchers = parseFieldWatchers({ fieldWatchers });
|
|
146
|
+
delete root.fieldHandler;
|
|
147
|
+
}
|
|
143
148
|
raw = `${JSON.stringify({ ...root, ...prompts, ...(taskOfferPolicy === undefined ? {} : { taskOfferPolicy }), footer: checked }, null, 2)}\n`;
|
|
144
149
|
writeFileSync(temp, raw, { flag: "wx", mode: 0o600 });
|
|
145
150
|
renameSync(temp, path);
|
package/mcp/FIELD_EVENTS.md
CHANGED
|
@@ -1,26 +1,40 @@
|
|
|
1
|
-
# Field
|
|
1
|
+
# Field watchers
|
|
2
2
|
|
|
3
|
-
pi-note **0.
|
|
4
|
-
changes
|
|
5
|
-
watcher is installed. The local-only extension and stdio service do not run
|
|
3
|
+
pi-note **0.40.0+** with **note 0.24.0+** runs independently enabled executables for
|
|
4
|
+
task-field changes on the **HTTP service machine**. No extra daemon or Pi-local
|
|
5
|
+
watcher is installed. The local-only extension and stdio service do not run watchers.
|
|
6
6
|
|
|
7
7
|
## Enable
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
Open **Settings → Footer fields → Watchers**. The fixed package `watchers/` folder
|
|
10
|
+
is scanned for executable `.mjs` files; discovery never executes or enables them.
|
|
11
|
+
Enable **GitHub PR**, or add a custom absolute executable path on the service
|
|
12
|
+
machine and enable it separately. Every change requires review and confirmation.
|
|
13
|
+
Watchers are trusted code, not sandboxed; enabling authorizes their alerts to
|
|
14
|
+
trigger the owning worker to investigate and fix the task within existing permissions.
|
|
15
|
+
|
|
16
|
+
The corresponding service `pi-note.json` setting is:
|
|
11
17
|
|
|
12
18
|
```json
|
|
13
19
|
{
|
|
14
|
-
"
|
|
20
|
+
"fieldWatchers": [
|
|
21
|
+
{ "path": "/absolute/path/watcher.mjs", "enabled": true }
|
|
22
|
+
]
|
|
15
23
|
}
|
|
16
24
|
```
|
|
17
25
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
Update/restart the existing HTTP service and `/reload` Pi once after upgrading.
|
|
27
|
+
Subsequent watcher changes apply live. Disable a watcher to stop checks and hide
|
|
28
|
+
its results without changing task fields. Custom entries can also be removed;
|
|
29
|
+
the file is retained. At most 32 watchers may be configured. For overlapping
|
|
30
|
+
results, the first nonempty watcher result in configuration order supplies the
|
|
31
|
+
footer style; alerts remain independent.
|
|
32
|
+
|
|
33
|
+
`fieldHandler` is replaced, not run alongside the list. An old handler appears
|
|
34
|
+
disabled until explicitly enabled because the previous setting authorized only
|
|
35
|
+
notifications, not automatic worker actions. Saving watchers removes the old key.
|
|
22
36
|
|
|
23
|
-
The bundled [`
|
|
37
|
+
The bundled [`watchers/github-pr.mjs`](../watchers/github-pr.mjs) handles `PR`
|
|
24
38
|
and ignores other fields. Copy it to a user-owned location before customization,
|
|
25
39
|
make it executable (`chmod u+x`), and ensure authenticated `gh` and Node are on the
|
|
26
40
|
**service's** PATH. It accepts a GitHub PR URL or Markdown link, checks once, and
|
|
@@ -29,8 +43,8 @@ strikes through merged PRs while keeping their links. Closed/merged PRs stop
|
|
|
29
43
|
polling; merging never completes the Pinote task. Passing means reported checks
|
|
30
44
|
passed, not that every review/branch-protection requirement is satisfied.
|
|
31
45
|
|
|
32
|
-
To support another field, add another
|
|
33
|
-
|
|
46
|
+
To support another field, add another executable using the contract below. Each
|
|
47
|
+
watcher receives all field events and returns `{}` for fields it does not handle.
|
|
34
48
|
|
|
35
49
|
## Script contract
|
|
36
50
|
|
|
@@ -91,6 +105,7 @@ are deduplicated; a resolved condition followed by another failure alerts again.
|
|
|
91
105
|
- The service stores its history cursor, scheduled checks, results and alert owner
|
|
92
106
|
under `$XDG_STATE_HOME/pinote/field-events/<database-path-hash>/state.db` (default
|
|
93
107
|
state home: `~/.local/state`). Keep this private and local, like `notes.db`.
|
|
108
|
+
Watcher cursors, jobs and notices are independent; alert ownership is shared.
|
|
94
109
|
It uses Node's built-in SQLite API; Node 22 may print its experimental warning.
|
|
95
110
|
- Monitoring continues without an open Pi session while the HTTP service runs.
|
|
96
111
|
Deadlines survive restarts. A lease prevents overlapping normal execution by
|
|
@@ -114,23 +129,31 @@ are deduplicated; a resolved condition followed by another failure alerts again.
|
|
|
114
129
|
task. For an unowned task, the first selected-task view claims alerts. Other
|
|
115
130
|
sessions still see shared field status. Explicitly selecting the task reclaims
|
|
116
131
|
ownership. No alert is delivered while that conversation has another task selected.
|
|
117
|
-
- Pi
|
|
118
|
-
IDs per branch).
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
132
|
+
- Pi triggers a worker turn once per current alert and persists receipts across
|
|
133
|
+
reload/resume (latest 4,096 IDs per branch). Receipts accompany view reads so
|
|
134
|
+
bounded 64-alert pages advance past delivered failures even if they persist.
|
|
135
|
+
Busy workers defer delivery until
|
|
136
|
+
idle, then refresh to verify ownership, selection and current status. An aborted
|
|
137
|
+
run does not immediately drain deferred alerts. There is no launch of a closed
|
|
138
|
+
Pi process; current alerts are delivered when its owning conversation reconnects.
|
|
139
|
+
Older clients/services retain notification-only behavior until both are updated.
|
|
140
|
+
- Worker messages use fixed instructions plus explicitly untrusted observations;
|
|
141
|
+
the worker must verify fresh task context and obey existing scope and approvals.
|
|
142
|
+
Enabling never authorizes task selection/completion, PR merging or bypassing
|
|
143
|
+
approvals. Current results reach each agent run through fresh task-scoped
|
|
144
|
+
context; oversized results are omitted with a count, preserving the task first.
|
|
123
145
|
- Only the latest current alert per field is retained, not an offline alert log.
|
|
124
146
|
If a failure resolves while Pi is closed, it is not delivered later as a stale
|
|
125
147
|
failure. Network recovery remains explicit through `/pinote` or a tool call.
|
|
126
|
-
-
|
|
127
|
-
|
|
148
|
+
- Newly enabled watchers reconstruct current fields from history independently.
|
|
149
|
+
Disabling/removing clears their automation state; enabling again checks afresh.
|
|
150
|
+
Editing the same file affects its next invocation, not stopped checks.
|
|
128
151
|
Restoring an older notes backup can invalidate the cursor: stop the service and
|
|
129
152
|
deliberately back up/reset its corresponding automation state before restarting.
|
|
130
153
|
|
|
131
154
|
Handlers run with the service user's permissions and inherited environment,
|
|
132
155
|
including available credentials. They are trusted code, **not sandboxed**. The
|
|
133
|
-
|
|
156
|
+
human-confirmed Watchers UI can configure executable paths; agent tools cannot. Service logs report
|
|
134
157
|
failures generically; script output and stderr are not copied into logs. Run the
|
|
135
158
|
script manually with synthetic input when debugging; don't put secrets in results,
|
|
136
159
|
which may be displayed and sent to the configured model.
|
package/mcp/README.md
CHANGED
|
@@ -141,12 +141,14 @@ Tests use temporary data and real HTTP/stdio connections, including authenticati
|
|
|
141
141
|
shared notes, independent selections, conflicts and reconnects. Visual checks use
|
|
142
142
|
Xvfb and private D-Bus; never point them at personal data.
|
|
143
143
|
|
|
144
|
-
## Field
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
144
|
+
## Field watchers
|
|
145
|
+
|
|
146
|
+
Manage `fieldWatchers` in **Settings → Footer fields → Watchers**. Bundled files
|
|
147
|
+
are discovered disabled; trusted custom executables can be added and enabled.
|
|
148
|
+
Independent checks survive restarts and settings apply live, without another daemon.
|
|
149
|
+
The GitHub PR watcher triggers the owning worker on failed CI/conflicts and strikes
|
|
150
|
+
through merged PRs. Requires note 0.24.0+. Review the
|
|
151
|
+
[watcher contract and safeguards](FIELD_EVENTS.md) before enabling trusted code.
|
|
150
152
|
|
|
151
153
|
## Task-completion events
|
|
152
154
|
|
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
|
-
|
|
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
|
}));
|