pi-note 0.39.3 → 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 +21 -7
- package/dist-mcp/field-watchers.js +57 -0
- package/dist-mcp/footer-config.js +6 -1
- package/dist-mcp/mcp/core.js +54 -17
- package/dist-mcp/mcp/field-events.js +137 -70
- package/dist-mcp/mcp/http.js +3 -4
- package/dist-mcp/task-title.js +4 -0
- package/field-watchers.ts +49 -0
- package/footer-config.ts +6 -1
- package/index.ts +2 -1
- package/mcp/FIELD_EVENTS.md +46 -23
- package/mcp/README.md +8 -6
- package/mcp/core.ts +49 -18
- package/mcp/field-events.ts +113 -69
- package/mcp/http.ts +3 -4
- package/package.json +4 -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/task-suggestion.ts +2 -1
- package/task-title.ts +4 -0
- /package/{examples/field-events.mjs → watchers/github-pr.mjs} +0 -0
package/mcp/core.ts
CHANGED
|
@@ -14,6 +14,8 @@ 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';
|
|
18
|
+
import { taskTitle } from '../task-title.ts';
|
|
17
19
|
|
|
18
20
|
const URI = 'pinote://ui';
|
|
19
21
|
const MIME = 'application/vnd.mcp-native-ui+json';
|
|
@@ -34,7 +36,7 @@ const ProposalInput = z.union([
|
|
|
34
36
|
ExistingProposal,
|
|
35
37
|
]);
|
|
36
38
|
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(), 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({
|
|
38
40
|
proposal: Proposal.nullable().optional(),
|
|
39
41
|
launch: z.object({ task_id: TaskId, updated_at: z.string().min(1).max(128) }).strict().optional(),
|
|
40
42
|
selectedId: z.number().int().positive().safe().nullable().optional(), screen: z.enum(['tasks', 'settings', 'field']).optional(),
|
|
@@ -103,7 +105,7 @@ function settings() {
|
|
|
103
105
|
if (!['always', 'github-remote', 'never'].includes(policy)) throw new Error('Invalid taskOfferPolicy in pi-note.json');
|
|
104
106
|
const guidanceOverridden = Object.hasOwn(root, 'agentGuidance');
|
|
105
107
|
const guidance = guidanceOverridden ? parseAgentGuidance(root.agentGuidance) : defaultAgentGuidance;
|
|
106
|
-
return { ...document, handoffPrompt, newSessionPrompt, policy, guidance, guidanceOverridden };
|
|
108
|
+
return { ...document, handoffPrompt, newSessionPrompt, policy, guidance, guidanceOverridden, watchers: discoverFieldWatchers(root) };
|
|
107
109
|
}
|
|
108
110
|
function agentGuidance(): string {
|
|
109
111
|
const { value: root } = readConfigDocument();
|
|
@@ -116,7 +118,7 @@ async function selection(state: State, signal: AbortSignal) {
|
|
|
116
118
|
if (proposal && 'task_id' in proposal && (!proposedTask || !['active', 'in_progress'].includes(proposedTask.state))) proposal = null;
|
|
117
119
|
return { task, proposal, proposedTask };
|
|
118
120
|
}
|
|
119
|
-
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[] = []) {
|
|
120
122
|
const launchTask: Task | null = state.launch ? JSON.parse(await note(['agent', 'get', String(state.launch.task_id)], signal)) : null;
|
|
121
123
|
const tasks: Task[] = state.launch
|
|
122
124
|
? launchTask && ['active', 'in_progress'].includes(launchTask.state) ? [launchTask] : []
|
|
@@ -128,7 +130,7 @@ async function snapshot(state: State, signal: AbortSignal, automation?: FieldAut
|
|
|
128
130
|
if (state.screen === 'field' && !fields(config.config, task?.agent_notes).some((f) => f.name === state.field)) state.screen = 'settings';
|
|
129
131
|
state = Context.parse({ conversationId: 'canonical', state }).state;
|
|
130
132
|
const fieldStatuses = task ? automation?.statuses(task) ?? [] : [];
|
|
131
|
-
const fieldNotifications = task && contextId ? automation?.notices(task, contextId) ?? [] : [];
|
|
133
|
+
const fieldNotifications = task && contextId ? automation?.notices(task, contextId, receipts) ?? [] : [];
|
|
132
134
|
const revision = createHash('sha256').update(JSON.stringify({ state, tasks, task, proposedTask, raw: config.raw, fieldStatuses, fieldNotifications })).digest('hex');
|
|
133
135
|
return { state, tasks, task, proposedTask, suggestion, config, revision, fieldStatuses, fieldNotifications };
|
|
134
136
|
}
|
|
@@ -158,6 +160,7 @@ function footerFeatures(meta: Record<string, unknown> | undefined) {
|
|
|
158
160
|
footerNativeActions: z.object({ footerNativeActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
|
|
159
161
|
footerStyles: z.object({ footerStyles: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
|
|
160
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,
|
|
161
164
|
footerDoubleActions: z.object({ footerDoubleActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
|
|
162
165
|
};
|
|
163
166
|
}
|
|
@@ -169,13 +172,13 @@ function fullPreview(task: { text: string; agent_notes?: Record<string, string>
|
|
|
169
172
|
const document = task.text + notes;
|
|
170
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).';
|
|
171
174
|
}
|
|
172
|
-
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 } = {}) {
|
|
173
176
|
const { tasks, task, suggestion, config, revision } = data;
|
|
174
177
|
const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
|
|
175
178
|
const controls: Control[] = [];
|
|
176
179
|
const action = (id: string, label: string, extra: Partial<Control> = {}) => controls.push({ id, label: label.slice(0, 2000), kind: 'action', ...extra });
|
|
177
180
|
let title = 'Pinote · Tasks';
|
|
178
|
-
let body = task ? `#${task.id} · ${task.tag || 'No tag'} · ${humanStatus(task.state)} · CURRENT\n${task.text
|
|
181
|
+
let body = task ? `#${task.id} · ${task.tag || 'No tag'} · ${humanStatus(task.state)} · CURRENT\n${taskTitle(task.text)}` : state.proposal ? `Suggestion only; no task is selected for this conversation.\n${taskTitle(suggestion!.text)}` : 'Select a task or create one. No task is selected for this conversation.';
|
|
179
182
|
// Footer actions remain valid on every screen, including Settings.
|
|
180
183
|
if (task) {
|
|
181
184
|
action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
|
|
@@ -201,6 +204,16 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
|
|
|
201
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?' });
|
|
202
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?' });
|
|
203
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
|
+
}
|
|
204
217
|
} else if (state.screen === 'field') {
|
|
205
218
|
const field = fields(config.config, task?.agent_notes).find((f) => f.name === state.field);
|
|
206
219
|
if (!field) throw new Error('Field no longer exists; open Settings again');
|
|
@@ -221,22 +234,22 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
|
|
|
221
234
|
const matches = tasks.filter((t) => state.launch
|
|
222
235
|
? t.id === state.launch.task_id && t.updated_at === state.launch.updated_at
|
|
223
236
|
: terms.every((term) => `${t.text} ${t.tag ?? ''} #${t.id}`.toLowerCase().includes(term)));
|
|
224
|
-
for (const candidate of matches.slice(0, 200)) action(`select:${candidate.id}`, candidate.text
|
|
237
|
+
for (const candidate of matches.slice(0, 200)) action(`select:${candidate.id}`, taskTitle(candidate.text), { description: `#${candidate.id} · ${candidate.tag || 'No tag'} · ${humanStatus(candidate.state)}${candidate.id === task?.id ? ' · CURRENT' : ''} · Select and start this task for this conversation` });
|
|
225
238
|
if (matches.length > 200) body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
|
|
226
239
|
}
|
|
227
240
|
const footer: { text: string; openApp?: true; url?: string; action?: string; secondaryAction?: string; doubleAction?: string; style?: { italic?: boolean } & NonNullable<FieldResult['display']>; width?: number }[] = features.footerNativeActions
|
|
228
241
|
? [{ text: `${icons['open-app']} `, openApp: true, width: 3 }]
|
|
229
242
|
: [{ text: `${icons.note} `, width: 3 }];
|
|
230
243
|
if (features.footerNativeActions) {
|
|
231
|
-
if (task) footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text
|
|
232
|
-
else if (state.proposal) footer.push({ text: `? ${suggestion!.tag ? `[${suggestion!.tag}] ` : ''}${suggestion!.text
|
|
244
|
+
if (task) footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${taskTitle(task.text).slice(0, 2000)}`, action: 'preview', ...(features.footerDoubleActions ? { doubleAction: 'done' } : {}), width: config.config.titleWidth });
|
|
245
|
+
else if (state.proposal) footer.push({ text: `? ${suggestion!.tag ? `[${suggestion!.tag}] ` : ''}${taskTitle(suggestion!.text).slice(0, 2000)}`, action: 'proposal:preview', ...(features.footerDoubleActions ? { doubleAction: 'proposal:accept' } : {}), ...(features.footerStyles ? { style: { italic: true, color: 'accent' } as const } : {}), width: config.config.titleWidth });
|
|
233
246
|
else footer.push({ text: 'Pinote · No task', width: config.config.titleWidth });
|
|
234
247
|
} else if (task) {
|
|
235
248
|
footer.push({ text: `${icons.check} `, action: 'done', width: 3 });
|
|
236
|
-
footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text
|
|
249
|
+
footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${taskTitle(task.text).slice(0, 2000)}`, action: 'preview', width: config.config.titleWidth });
|
|
237
250
|
} else if (state.proposal) {
|
|
238
251
|
footer.push({ text: `${icons.add} `, action: 'proposal:accept', ...(secondary ? { secondaryAction: 'proposal:dismiss' } : {}), width: 3 });
|
|
239
|
-
footer.push({ text: `${suggestion!.tag ? `[${suggestion!.tag}] ` : ''}${suggestion!.text
|
|
252
|
+
footer.push({ text: `${suggestion!.tag ? `[${suggestion!.tag}] ` : ''}${taskTitle(suggestion!.text).slice(0, 2000)}`, action: 'proposal:preview', ...(features.footerStyles ? { style: { italic: true, color: 'accent' } as const } : {}), width: config.config.titleWidth });
|
|
240
253
|
} else footer.push({ text: 'Pinote · no task', width: config.config.titleWidth });
|
|
241
254
|
let fieldCount = 0;
|
|
242
255
|
if (task) {
|
|
@@ -273,7 +286,7 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
|
|
|
273
286
|
return false;
|
|
274
287
|
});
|
|
275
288
|
if (scoped) for (const control of editable) control.revision = actionRevision(data, control);
|
|
276
|
-
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 } : {}) })) } : {}) };
|
|
277
290
|
// JSON escaping and expanded footer formats can exceed the host budget even
|
|
278
291
|
// with bounded row counts. Preserve navigation/filter controls ahead of rows.
|
|
279
292
|
let shortened = false;
|
|
@@ -336,14 +349,14 @@ server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(a
|
|
|
336
349
|
}
|
|
337
350
|
if (request.params.uri !== URI) throw new Error('Unknown resource');
|
|
338
351
|
const caller = context(request.params._meta);
|
|
339
|
-
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);
|
|
340
353
|
return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta), footerFeatures(request.params._meta))) }] };
|
|
341
354
|
}));
|
|
342
355
|
server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
343
356
|
const caller = context(request.params._meta);
|
|
344
357
|
const state = caller.state;
|
|
345
358
|
const owner = caller.contextId ?? caller.conversationId;
|
|
346
|
-
const before = await snapshot(state, extra.signal, options.fieldAutomation, owner);
|
|
359
|
+
const before = await snapshot(state, extra.signal, options.fieldAutomation, owner, caller.fieldReceipts);
|
|
347
360
|
const scoped = scopedActions(request.params._meta);
|
|
348
361
|
const { action, value } = request.params;
|
|
349
362
|
const control = view(before, scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta)).controls.find((item) => item.id === action);
|
|
@@ -388,7 +401,25 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
388
401
|
const cfg = structuredClone(before.config.config);
|
|
389
402
|
let handoff = before.config.handoffPrompt, newSession = before.config.newSessionPrompt, policy = before.config.policy;
|
|
390
403
|
let guidance: string | null | undefined;
|
|
391
|
-
|
|
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);
|
|
392
423
|
else if (action === 'reset-agent-guidance') guidance = null;
|
|
393
424
|
else if (action === 'handoffPrompt') handoff = z.string().parse(value);
|
|
394
425
|
else if (action === 'newSessionPrompt') newSession = z.string().parse(value);
|
|
@@ -407,10 +438,10 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
407
438
|
}
|
|
408
439
|
}
|
|
409
440
|
extra.signal.throwIfAborted();
|
|
410
|
-
saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy, guidance);
|
|
441
|
+
saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy, guidance, watchers);
|
|
411
442
|
}
|
|
412
443
|
if (next.selectedId && (action.startsWith('select:') || action === 'proposal:accept' || action === 'add')) options.fieldAutomation?.claim(next.selectedId, owner, true);
|
|
413
|
-
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 } : {}) };
|
|
414
445
|
await notifyChanged();
|
|
415
446
|
return result;
|
|
416
447
|
}));
|
|
@@ -536,7 +567,7 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
|
|
|
536
567
|
const tasks: Task[] = JSON.parse(await note(['list', '--json'], extra.signal));
|
|
537
568
|
const matches = tasks.filter((task) => words.every((word) => `${task.text} ${task.tag ?? ''}`.toLowerCase().includes(word)));
|
|
538
569
|
const page = matches.slice(args.offset, args.offset + args.limit);
|
|
539
|
-
result = { tasks: page.map(({ id, text, state, tag }) => ({ id, title: text
|
|
570
|
+
result = { tasks: page.map(({ id, text, state, tag }) => ({ id, title: taskTitle(text).slice(0, 1000), state, tag })), total: matches.length, next_offset: args.offset + page.length < matches.length ? args.offset + page.length : null };
|
|
540
571
|
} else if (request.params.name === 'get_task') {
|
|
541
572
|
const args = ExistingProposal.parse(request.params.arguments);
|
|
542
573
|
result = JSON.parse(await note(['agent', 'get', String(args.task_id)], extra.signal));
|
package/mcp/field-events.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { homedir } from 'node:os';
|
|
|
7
7
|
import type { DatabaseSync } from 'node:sqlite';
|
|
8
8
|
import { z } from 'zod';
|
|
9
9
|
import { readConfigDocument } from '../footer-config.ts';
|
|
10
|
+
import { discoverFieldWatchers, type FieldWatcher } from '../field-watchers.ts';
|
|
10
11
|
|
|
11
12
|
const exec = promisify(execFile);
|
|
12
13
|
const integer = z.number().int().nonnegative().safe();
|
|
@@ -23,20 +24,12 @@ export const FieldResult = z.object({
|
|
|
23
24
|
}).strict();
|
|
24
25
|
export type FieldResult = z.infer<typeof FieldResult>;
|
|
25
26
|
type FieldEvent = z.infer<typeof Event>;
|
|
26
|
-
type Job = { event: FieldEvent; result?: FieldResult; due: number | null; attempts: number; checkedAt?: string; stale?: boolean };
|
|
27
|
-
export type FieldStatus = { field: string; value: string; result: FieldResult; checkedAt?: string; stale: boolean };
|
|
27
|
+
type Job = { watcherId: string; event: FieldEvent; result?: FieldResult; due: number | null; attempts: number; checkedAt?: string; stale?: boolean };
|
|
28
|
+
export type FieldStatus = { watcherId: string; field: string; value: string; result: FieldResult; checkedAt?: string; stale: boolean };
|
|
28
29
|
export type FieldNotice = { id: string; taskId: number; field: string; message: string };
|
|
29
30
|
type Task = { id: number; state: string; updated_at: string; agent_notes: Record<string, string> };
|
|
30
31
|
type Row = { job: string };
|
|
31
32
|
|
|
32
|
-
export function configuredFieldHandler(): string | undefined {
|
|
33
|
-
const { value: config } = readConfigDocument();
|
|
34
|
-
const handler = config.fieldHandler;
|
|
35
|
-
if (handler === undefined || handler === null) return undefined;
|
|
36
|
-
if (typeof handler !== 'string' || !isAbsolute(handler) || /[\u0000-\u001f\u007f]/u.test(handler)) throw new Error('fieldHandler must be an absolute executable file path');
|
|
37
|
-
return handler;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
33
|
/** One bounded invocation, no shell. Kill the process group on cancellation/overflow. */
|
|
41
34
|
export function runFieldHandler(handler: string, input: unknown, signal: AbortSignal, timeoutMs = 30_000): Promise<FieldResult> {
|
|
42
35
|
signal.throwIfAborted();
|
|
@@ -71,20 +64,26 @@ export class FieldAutomation {
|
|
|
71
64
|
private lifetime = new AbortController();
|
|
72
65
|
private timer?: ReturnType<typeof setTimeout>;
|
|
73
66
|
private work?: Promise<void>;
|
|
74
|
-
private
|
|
67
|
+
private watchers: () => FieldWatcher[];
|
|
68
|
+
private enabled: FieldWatcher[] = [];
|
|
69
|
+
private signature = '';
|
|
70
|
+
private generation = 0;
|
|
71
|
+
private invocation?: AbortController;
|
|
72
|
+
private ticking = false;
|
|
75
73
|
private noteCommand: string;
|
|
76
74
|
private changed: () => void;
|
|
77
75
|
private log: () => void;
|
|
78
|
-
private constructor(db: DatabaseSync,
|
|
79
|
-
this.db = db; this.
|
|
76
|
+
private constructor(db: DatabaseSync, watchers: () => FieldWatcher[], noteCommand: string, changed: () => void, log: () => void) {
|
|
77
|
+
this.db = db; this.watchers = watchers; this.noteCommand = noteCommand; this.changed = changed; this.log = log;
|
|
80
78
|
db.exec(`PRAGMA busy_timeout=5000;
|
|
81
|
-
CREATE TABLE IF NOT EXISTS
|
|
82
|
-
CREATE TABLE IF NOT EXISTS
|
|
79
|
+
CREATE TABLE IF NOT EXISTS fw_scheduler (id INTEGER PRIMARY KEY CHECK(id=1), owner TEXT, lease_until INTEGER NOT NULL DEFAULT 0);
|
|
80
|
+
CREATE TABLE IF NOT EXISTS fw_watchers (id TEXT PRIMARY KEY, path TEXT NOT NULL, cursor INTEGER NOT NULL DEFAULT 0);
|
|
81
|
+
CREATE TABLE IF NOT EXISTS fw_jobs (watcher_id TEXT NOT NULL, task_id INTEGER NOT NULL, field TEXT NOT NULL, job TEXT NOT NULL, PRIMARY KEY(watcher_id,task_id,field));
|
|
83
82
|
CREATE TABLE IF NOT EXISTS owners (task_id INTEGER PRIMARY KEY, context_id TEXT NOT NULL);
|
|
84
|
-
CREATE TABLE IF NOT EXISTS
|
|
85
|
-
|
|
83
|
+
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
|
+
INSERT OR IGNORE INTO fw_scheduler(id) VALUES (1);`);
|
|
86
85
|
}
|
|
87
|
-
static async open(options: {
|
|
86
|
+
static async open(options: { watchers?: () => FieldWatcher[]; path?: string; noteCommand?: string; changed?: () => void; log?: () => void } = {}): Promise<FieldAutomation> {
|
|
88
87
|
const dataHome = process.env.XDG_DATA_HOME;
|
|
89
88
|
const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
|
|
90
89
|
const namespace = createHash('sha256').update(database).digest('hex').slice(0, 16);
|
|
@@ -97,7 +96,7 @@ export class FieldAutomation {
|
|
|
97
96
|
if (!stat.isFile() || stat.uid !== process.getuid?.() || (stat.mode & 0o077)) throw new Error('Unsafe field automation database');
|
|
98
97
|
const { DatabaseSync } = await import('node:sqlite');
|
|
99
98
|
const db = new DatabaseSync(path);
|
|
100
|
-
try { return new FieldAutomation(db, options.
|
|
99
|
+
try { return new FieldAutomation(db, options.watchers ?? (() => discoverFieldWatchers(readConfigDocument().value)), options.noteCommand ?? process.env.PINOTE_NOTE_COMMAND ?? 'note', options.changed ?? (() => {}), options.log ?? (() => {})); }
|
|
101
100
|
catch (error) { db.close(); throw error; }
|
|
102
101
|
}
|
|
103
102
|
private async note(args: string[]): Promise<unknown> {
|
|
@@ -105,7 +104,7 @@ export class FieldAutomation {
|
|
|
105
104
|
return JSON.parse(stdout);
|
|
106
105
|
}
|
|
107
106
|
private leased(): boolean {
|
|
108
|
-
return Boolean(this.db.prepare('SELECT 1 FROM
|
|
107
|
+
return Boolean(this.db.prepare('SELECT 1 FROM fw_scheduler WHERE id=1 AND owner=? AND lease_until>?').get(this.owner, Date.now()));
|
|
109
108
|
}
|
|
110
109
|
private transaction(work: () => void): void {
|
|
111
110
|
this.db.exec('BEGIN IMMEDIATE');
|
|
@@ -114,13 +113,33 @@ export class FieldAutomation {
|
|
|
114
113
|
work(); this.db.exec('COMMIT');
|
|
115
114
|
} catch (error) { this.db.exec('ROLLBACK'); throw error; }
|
|
116
115
|
}
|
|
116
|
+
private reconcile(): void {
|
|
117
|
+
const enabled = this.watchers().filter((watcher) => watcher.enabled);
|
|
118
|
+
if (enabled.length > 32 || new Set(enabled.map((w) => w.id)).size !== enabled.length) throw new Error('Invalid enabled watchers');
|
|
119
|
+
const signature = JSON.stringify(enabled.map((w) => [w.id, w.path]));
|
|
120
|
+
if (signature === this.signature) return;
|
|
121
|
+
this.generation++; this.invocation?.abort();
|
|
122
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
123
|
+
try {
|
|
124
|
+
for (const row of this.db.prepare('SELECT id,path FROM fw_watchers').all()) {
|
|
125
|
+
if (!enabled.some((w) => w.id === row.id && w.path === row.path)) {
|
|
126
|
+
this.db.prepare('DELETE FROM fw_jobs WHERE watcher_id=?').run(row.id);
|
|
127
|
+
this.db.prepare('DELETE FROM fw_notices WHERE watcher_id=?').run(row.id);
|
|
128
|
+
this.db.prepare('DELETE FROM fw_watchers WHERE id=?').run(row.id);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const watcher of enabled) this.db.prepare('INSERT OR IGNORE INTO fw_watchers(id,path) VALUES (?,?)').run(watcher.id, watcher.path);
|
|
132
|
+
this.db.exec('COMMIT');
|
|
133
|
+
} catch (error) { this.db.exec('ROLLBACK'); throw error; }
|
|
134
|
+
this.enabled = enabled; this.signature = signature; this.changed();
|
|
135
|
+
}
|
|
117
136
|
private save(job: Job): void {
|
|
118
|
-
this.db.prepare('INSERT INTO
|
|
119
|
-
.run(job.event.taskId, job.event.field, JSON.stringify(job));
|
|
137
|
+
this.db.prepare('INSERT INTO fw_jobs(watcher_id,task_id,field,job) VALUES (?,?,?,?) ON CONFLICT(watcher_id,task_id,field) DO UPDATE SET job=excluded.job')
|
|
138
|
+
.run(job.watcherId, job.event.taskId, job.event.field, JSON.stringify(job));
|
|
120
139
|
}
|
|
121
|
-
private clear(taskId: number, name: string): void {
|
|
122
|
-
this.db.prepare('DELETE FROM
|
|
123
|
-
this.db.prepare('DELETE FROM
|
|
140
|
+
private clear(watcherId: string, taskId: number, name: string): void {
|
|
141
|
+
this.db.prepare('DELETE FROM fw_jobs WHERE watcher_id=? AND task_id=? AND field=?').run(watcherId, taskId, name);
|
|
142
|
+
this.db.prepare('DELETE FROM fw_notices WHERE watcher_id=? AND task_id=? AND field=?').run(watcherId, taskId, name);
|
|
124
143
|
}
|
|
125
144
|
/** Explicit selection/write owns alerts; background reads can only claim unowned tasks. */
|
|
126
145
|
claim(taskId: number, contextId: string, replace = false): void {
|
|
@@ -129,19 +148,21 @@ export class FieldAutomation {
|
|
|
129
148
|
: 'INSERT OR IGNORE INTO owners(task_id,context_id) VALUES (?,?)').run(taskId, contextId);
|
|
130
149
|
}
|
|
131
150
|
statuses(task: Task): FieldStatus[] {
|
|
132
|
-
|
|
151
|
+
this.reconcile();
|
|
152
|
+
return this.enabled.flatMap((watcher) => (this.db.prepare('SELECT job FROM fw_jobs WHERE watcher_id=? AND task_id=?').all(watcher.id, task.id) as Row[]).flatMap(({ job: raw }) => {
|
|
133
153
|
const job = JSON.parse(raw) as Job;
|
|
134
|
-
if (job.event.value === null || job.event.value !== task.agent_notes[job.event.field] || !job.result) return [];
|
|
135
|
-
return [{ field: job.event.field, value: job.event.value, result: job.result, checkedAt: job.checkedAt, stale: job.stale ?? false }];
|
|
136
|
-
});
|
|
154
|
+
if (job.event.value === null || job.event.value !== task.agent_notes[job.event.field] || !job.result || Object.keys(job.result).length === 0) return [];
|
|
155
|
+
return [{ watcherId: watcher.id, field: job.event.field, value: job.event.value, result: job.result, checkedAt: job.checkedAt, stale: job.stale ?? false }];
|
|
156
|
+
}));
|
|
137
157
|
}
|
|
138
|
-
notices(task: Task, contextId: string): FieldNotice[] {
|
|
158
|
+
notices(task: Task, contextId: string, receipts: readonly string[] = []): FieldNotice[] {
|
|
139
159
|
this.claim(task.id, contextId);
|
|
140
160
|
const owner = this.db.prepare('SELECT context_id FROM owners WHERE task_id=?').get(task.id);
|
|
141
161
|
if (owner?.context_id !== contextId) return [];
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
162
|
+
const received = new Set(receipts);
|
|
163
|
+
const fields = new Set(this.statuses(task).filter((s) => !s.stale && s.result.notification).map((s) => `${s.watcherId}:${s.field}`));
|
|
164
|
+
return (this.db.prepare('SELECT watcher_id, id, field, message FROM fw_notices WHERE task_id=?').all(task.id) as { watcher_id: string; id: string; field: string; message: string }[])
|
|
165
|
+
.filter((n) => !received.has(n.id) && fields.has(`${n.watcher_id}:${n.field}`)).slice(0, 64).map(({ id, field, message }) => ({ id, field, message, taskId: task.id }));
|
|
145
166
|
}
|
|
146
167
|
start(): void {
|
|
147
168
|
const schedule = () => {
|
|
@@ -154,80 +175,103 @@ export class FieldAutomation {
|
|
|
154
175
|
};
|
|
155
176
|
schedule();
|
|
156
177
|
}
|
|
157
|
-
|
|
178
|
+
tick(): Promise<void> {
|
|
179
|
+
if (this.ticking) return this.work ?? Promise.resolve();
|
|
180
|
+
this.ticking = true;
|
|
181
|
+
const work = this.performTick().finally(() => { this.ticking = false; if (this.work === work) this.work = undefined; });
|
|
182
|
+
this.work = work;
|
|
183
|
+
return work;
|
|
184
|
+
}
|
|
185
|
+
private async performTick(): Promise<void> {
|
|
158
186
|
this.lifetime.signal.throwIfAborted();
|
|
159
|
-
const acquired = this.db.prepare('UPDATE
|
|
187
|
+
const acquired = this.db.prepare('UPDATE fw_scheduler SET owner=?, lease_until=? WHERE id=1 AND (lease_until<=? OR owner=?)')
|
|
160
188
|
.run(this.owner, Date.now() + 90_000, Date.now(), this.owner).changes;
|
|
161
189
|
if (!acquired) return;
|
|
162
190
|
try {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
191
|
+
this.reconcile();
|
|
192
|
+
const generation = this.generation;
|
|
193
|
+
const valid = () => {
|
|
194
|
+
this.reconcile();
|
|
195
|
+
if (!this.leased() || generation !== this.generation || this.lifetime.signal.aborted) return false;
|
|
196
|
+
// Many watchers can make intake longer than one lease; each bounded operation renews it.
|
|
197
|
+
this.db.prepare('UPDATE fw_scheduler SET lease_until=? WHERE id=1 AND owner=?').run(Date.now() + 90_000, this.owner);
|
|
198
|
+
return true;
|
|
170
199
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
200
|
+
let drained = false;
|
|
201
|
+
for (const watcher of this.enabled) {
|
|
202
|
+
const state = this.db.prepare('SELECT cursor FROM fw_watchers WHERE id=?').get(watcher.id) as { cursor: number };
|
|
203
|
+
const batch = Batch.parse(await this.note(['field-events', '--after', String(state.cursor), '--limit', '25']));
|
|
204
|
+
if (!valid()) return;
|
|
205
|
+
if (batch.cursor < state.cursor || batch.events.some((e) => e.id <= state.cursor || e.id > batch.cursor)) throw new Error('Invalid field event cursor');
|
|
206
|
+
if (batch.cursor !== state.cursor) {
|
|
207
|
+
this.transaction(() => {
|
|
208
|
+
for (const event of batch.events) {
|
|
209
|
+
this.clear(watcher.id, event.taskId, event.field);
|
|
210
|
+
this.save({ watcherId: watcher.id, event, due: Date.now(), attempts: 0 });
|
|
211
|
+
}
|
|
212
|
+
this.db.prepare('UPDATE fw_watchers SET cursor=? WHERE id=?').run(batch.cursor, watcher.id);
|
|
213
|
+
});
|
|
214
|
+
drained = true;
|
|
215
|
+
}
|
|
185
216
|
}
|
|
186
|
-
|
|
217
|
+
if (drained) { this.changed(); return; }
|
|
218
|
+
const jobs = (this.db.prepare('SELECT job FROM fw_jobs').all() as Row[]).map(({ job }) => JSON.parse(job) as Job);
|
|
187
219
|
const job = jobs.filter((j) => j.due !== null && j.due <= Date.now()).sort((a, b) => a.due! - b.due!)[0];
|
|
188
220
|
if (!job) return;
|
|
189
221
|
const event = job.event;
|
|
222
|
+
const watcher = this.enabled.find((w) => w.id === job.watcherId);
|
|
223
|
+
if (!watcher) return;
|
|
190
224
|
const task = await this.note(['agent', 'get', String(event.taskId)]) as Task;
|
|
191
|
-
if (!
|
|
225
|
+
if (!valid()) return;
|
|
192
226
|
const currentValue = task && ['active', 'in_progress'].includes(task.state) ? task.agent_notes[event.field] ?? null : null;
|
|
193
|
-
if (event.value !== currentValue) { this.transaction(() => this.clear(event.taskId, event.field)); this.changed(); return; }
|
|
227
|
+
if (event.value !== currentValue) { this.transaction(() => this.clear(job.watcherId, event.taskId, event.field)); this.changed(); return; }
|
|
194
228
|
let result: FieldResult, retryAfterSeconds = 0;
|
|
229
|
+
const invocation = new AbortController();
|
|
230
|
+
this.invocation = invocation;
|
|
231
|
+
const abort = () => invocation.abort();
|
|
232
|
+
this.lifetime.signal.addEventListener('abort', abort, { once: true });
|
|
233
|
+
const poll = setInterval(() => { try { this.reconcile(); } catch { invocation.abort(); } }, 100);
|
|
195
234
|
try {
|
|
196
|
-
result = await runFieldHandler(
|
|
235
|
+
result = await runFieldHandler(watcher.path, {
|
|
197
236
|
version: 1, eventId: `${event.id}:${event.field}`, taskId: event.taskId, field: event.field,
|
|
198
237
|
event: job.checkedAt ? 'check' : event.event, value: event.value, previousValue: event.previousValue,
|
|
199
238
|
...(job.result ? { previousResult: job.result } : {}),
|
|
200
|
-
},
|
|
239
|
+
}, invocation.signal);
|
|
201
240
|
if (result.state === 'error') {
|
|
202
241
|
retryAfterSeconds = result.recheckAfterSeconds ?? 0;
|
|
203
242
|
throw new Error('Handler reported unavailable data');
|
|
204
243
|
}
|
|
205
244
|
} catch {
|
|
206
|
-
if (!
|
|
245
|
+
if (!valid() || invocation.signal.aborted) return;
|
|
207
246
|
job.attempts++; job.stale = true;
|
|
208
247
|
job.result ??= { state: 'error', summary: 'Field handler unavailable', display: { color: 'muted' } };
|
|
209
248
|
job.due = Date.now() + Math.max(retryAfterSeconds, Math.min(3600, 30 * 2 ** Math.min(job.attempts - 1, 7))) * 1000;
|
|
210
249
|
this.transaction(() => this.save(job)); this.changed(); this.log(); return;
|
|
211
250
|
}
|
|
251
|
+
finally {
|
|
252
|
+
clearInterval(poll); this.lifetime.signal.removeEventListener('abort', abort);
|
|
253
|
+
if (this.invocation === invocation) this.invocation = undefined;
|
|
254
|
+
}
|
|
255
|
+
if (!valid() || invocation.signal.aborted) return;
|
|
212
256
|
const latest = await this.note(['agent', 'get', String(event.taskId)]) as Task;
|
|
213
|
-
if (!
|
|
257
|
+
if (!valid()) return;
|
|
214
258
|
if (latest?.updated_at !== task?.updated_at) return; // Includes remove/re-add (ABA); next tick drains new events.
|
|
215
259
|
this.transaction(() => {
|
|
216
|
-
if (event.value === null) this.clear(event.taskId, event.field);
|
|
260
|
+
if (event.value === null) this.clear(job.watcherId, event.taskId, event.field);
|
|
217
261
|
else {
|
|
218
262
|
const notification = result.notification;
|
|
219
|
-
if (!notification) this.db.prepare('DELETE FROM
|
|
263
|
+
if (!notification) this.db.prepare('DELETE FROM fw_notices WHERE watcher_id=? AND task_id=? AND field=?').run(job.watcherId, event.taskId, event.field);
|
|
220
264
|
else if (JSON.stringify(notification) !== JSON.stringify(job.result?.notification)) {
|
|
221
|
-
this.db.prepare('INSERT INTO
|
|
222
|
-
.run(event.taskId, event.field, randomUUID(), notification.message);
|
|
265
|
+
this.db.prepare('INSERT INTO fw_notices(watcher_id,task_id,field,id,message) VALUES (?,?,?,?,?) ON CONFLICT(watcher_id,task_id,field) DO UPDATE SET id=excluded.id,message=excluded.message')
|
|
266
|
+
.run(job.watcherId, event.taskId, event.field, randomUUID(), notification.message);
|
|
223
267
|
}
|
|
224
|
-
this.save({ event, result, due: result.recheckAfterSeconds ? Date.now() + result.recheckAfterSeconds * 1000 : null,
|
|
268
|
+
this.save({ watcherId: job.watcherId, event, result, due: result.recheckAfterSeconds ? Date.now() + result.recheckAfterSeconds * 1000 : null,
|
|
225
269
|
attempts: 0, checkedAt: new Date().toISOString(), stale: false });
|
|
226
270
|
}
|
|
227
271
|
});
|
|
228
272
|
this.changed();
|
|
229
273
|
} finally {
|
|
230
|
-
this.db.prepare('UPDATE
|
|
274
|
+
this.db.prepare('UPDATE fw_scheduler SET lease_until=0 WHERE id=1 AND owner=?').run(this.owner);
|
|
231
275
|
}
|
|
232
276
|
}
|
|
233
277
|
async close(): Promise<void> {
|
package/mcp/http.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
|
9
9
|
import { createPinoteServer } from './core.ts';
|
|
10
10
|
import { completionEvents } from './events.ts';
|
|
11
11
|
import { pinoteToken } from '../token.ts';
|
|
12
|
-
import {
|
|
12
|
+
import { FieldAutomation } from './field-events.ts';
|
|
13
13
|
|
|
14
14
|
export interface PinoteHttpOptions {
|
|
15
15
|
token?: string;
|
|
@@ -61,11 +61,10 @@ export async function startPinoteHttpServer(options: PinoteHttpOptions = {}) {
|
|
|
61
61
|
if (configuredHosts?.some((value) => !/^(?:\[[0-9a-fA-F:]+\]|[a-zA-Z0-9.-]+)(?::[0-9]+)?$/.test(value))) throw new Error('Invalid allowed HTTP authority');
|
|
62
62
|
let allowedHosts = new Set(configuredHosts);
|
|
63
63
|
const fieldListeners = new Set<() => void>();
|
|
64
|
-
const
|
|
65
|
-
const fieldAutomation = handler ? await FieldAutomation.open({ handler,
|
|
64
|
+
const fieldAutomation = await FieldAutomation.open({
|
|
66
65
|
changed: () => { for (const listener of fieldListeners) listener(); },
|
|
67
66
|
log: () => log('field automation check failed; will retry'),
|
|
68
|
-
})
|
|
67
|
+
});
|
|
69
68
|
const reject = (res: ServerResponse, status: number) => { res.writeHead(status, { 'Content-Type': 'text/plain' }); res.end(`${status}\n`); };
|
|
70
69
|
const single = (req: IncomingMessage, name: string) => {
|
|
71
70
|
const values = req.rawHeaders.filter((_, i) => i % 2 === 0 && req.rawHeaders[i].toLowerCase() === name);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-note",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -32,6 +32,8 @@
|
|
|
32
32
|
"LOCAL.md",
|
|
33
33
|
"deploy",
|
|
34
34
|
"examples",
|
|
35
|
+
"watchers",
|
|
36
|
+
"field-watchers.ts",
|
|
35
37
|
"footer-status.ts",
|
|
36
38
|
"footer-links.ts",
|
|
37
39
|
"footer-config.ts",
|
|
@@ -42,6 +44,7 @@
|
|
|
42
44
|
"footer-settings.ts",
|
|
43
45
|
"task-picker.ts",
|
|
44
46
|
"task-suggestion.ts",
|
|
47
|
+
"task-title.ts",
|
|
45
48
|
"proposal.ts",
|
|
46
49
|
"setup.ts",
|
|
47
50
|
"preview-bridge.ts",
|
package/remote/navigation.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { defaultFooterField, parseAgentGuidance, parseFooterConfig, parseHandoffPrompt, parseNewSessionPrompt } from '../footer-config.ts';
|
|
2
|
+
import { parseFieldWatchers } from '../field-watchers.ts';
|
|
2
3
|
import { clean, editorText, type NativeControl, type NativeView } from './protocol.ts';
|
|
3
4
|
|
|
4
|
-
export type Screen = 'home' | 'tasks' | 'settings' | 'prompts' | 'footer' | 'fields' | 'field';
|
|
5
|
+
export type Screen = 'home' | 'tasks' | 'settings' | 'prompts' | 'footer' | 'fields' | 'field' | 'watchers';
|
|
5
6
|
const group = (id: Screen, label: string, description: string): NativeControl => ({ id: `__${id}`, label, description, kind: 'action' });
|
|
6
|
-
export const parentScreen = (screen: Screen): Screen | undefined => ({ tasks: 'home', settings: 'home', prompts: 'settings', footer: 'settings', fields: 'settings', field: 'fields' } as const)[screen as Exclude<Screen, 'home'>];
|
|
7
|
+
export const parentScreen = (screen: Screen): Screen | undefined => ({ tasks: 'home', settings: 'home', prompts: 'settings', footer: 'settings', fields: 'settings', field: 'fields', watchers: 'fields' } as const)[screen as Exclude<Screen, 'home'>];
|
|
7
8
|
export const serverScreen = (screen: Screen) => screen === 'home' || screen === 'tasks' ? 'tasks' : screen === 'field' ? 'field' : 'settings';
|
|
8
9
|
|
|
9
10
|
/** Local navigation only. Server controls/revisions remain the authority for every action. */
|
|
@@ -39,7 +40,13 @@ export function menuView(view: NativeView, screen: Screen): NativeView {
|
|
|
39
40
|
break;
|
|
40
41
|
case 'fields':
|
|
41
42
|
title = 'Pinote / Settings / Footer fields'; body = 'Display definitions only. Populated task values appear in the footer.';
|
|
42
|
-
controls = [
|
|
43
|
+
controls = [group('watchers', 'Watchers…', 'Bundled and custom field watchers; explicitly enable trusted code and automatic worker actions.'),
|
|
44
|
+
...view.controls.filter((c) => c.id.startsWith('field:')), ...take('add-field')];
|
|
45
|
+
break;
|
|
46
|
+
case 'watchers':
|
|
47
|
+
title = 'Pinote / Settings / Fields / Watchers';
|
|
48
|
+
body = 'Shared service watchers · enabling runs trusted code and triggers the owning worker on alerts. New files are disabled.';
|
|
49
|
+
controls = [...view.controls.filter((c) => c.id.startsWith('watcher:') || c.id.startsWith('remove-watcher:')), ...take('add-watcher')];
|
|
43
50
|
break;
|
|
44
51
|
case 'field':
|
|
45
52
|
title = `Pinote / Settings / Fields / ${clean(String(view.state.field ?? ''))}`;
|
|
@@ -68,6 +75,8 @@ export function inputValue(control: NativeControl, text: string, view: NativeVie
|
|
|
68
75
|
case 'handoffPrompt': parseHandoffPrompt(text); break;
|
|
69
76
|
case 'newSessionPrompt': parseNewSessionPrompt(text); break;
|
|
70
77
|
case 'agentGuidance': parseAgentGuidance(text); break;
|
|
78
|
+
case 'add-watcher':
|
|
79
|
+
return parseFieldWatchers({ fieldWatchers: [{ path: text, enabled: false }] })[0].path;
|
|
71
80
|
case 'add-field': {
|
|
72
81
|
const field = parseFooterConfig({ footer: { fields: [defaultFooterField(text)] } }).fields![0];
|
|
73
82
|
const fields = view.controls.filter((c) => c.id.startsWith('field:'));
|
package/remote/protocol.ts
CHANGED
|
@@ -22,7 +22,7 @@ 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
|
-
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) }).strict()).max(64).optional(),
|
|
25
|
+
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
26
|
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
27
|
}).strict().superRefine((view, ctx) => {
|
|
28
28
|
if (new Set(view.controls.map((c) => c.id)).size !== view.controls.length) ctx.addIssue({ code: 'custom', message: 'Duplicate control IDs' });
|