pi-note 0.19.0 → 0.20.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.
@@ -1,4 +1,7 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
2
5
  import { execFile } from 'node:child_process';
3
6
  import { promisify } from 'node:util';
4
7
  import { readFileSync } from 'node:fs';
@@ -12,8 +15,10 @@ const MIME = 'application/vnd.mcp-native-ui+json';
12
15
  const CONTEXT = 'native-ui/context';
13
16
  const exec = promisify(execFile);
14
17
  const noteCommand = process.env.PINOTE_NOTE_COMMAND || 'note';
15
- const icons = Object.fromEntries(['add', 'check', 'note'].map((name) => [name, readFileSync(new URL(`../icons/${name}.txt`, import.meta.url), 'utf8').trim()]));
16
- const Context = z.object({ conversationId: z.string().min(1).max(256), state: z.object({
18
+ const icons = Object.fromEntries(['add', 'check', 'cross', 'note'].map((name) => [name, readFileSync(new URL(`../icons/${name}.txt`, import.meta.url), 'utf8').trim()]));
19
+ const Proposal = z.object({ id: z.string().uuid(), text: z.string().min(1).max(4000), tag: z.string().trim().min(1).max(64).optional() }).strict();
20
+ const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), state: z.object({
21
+ proposal: Proposal.nullable().optional(),
17
22
  selectedId: z.number().int().positive().safe().nullable().optional(), screen: z.enum(['tasks', 'settings', 'field']).optional(),
18
23
  field: z.string().refine((value) => [...value].length <= 64, 'Field name exceeds 64 code points').optional(), search: z.string().max(256).optional(),
19
24
  }).default({}) });
@@ -21,7 +26,7 @@ const Action = z.object({ method: z.literal('native-ui/action'), params: z.objec
21
26
  uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
22
27
  value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
23
28
  }) });
24
- const server = new Server({ name: 'pinote', version: '0.19.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
29
+ const server = new Server({ name: 'pinote', version: '0.20.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
25
30
  // Serialize complete read/check/write cycles even when a host sends parallel calls.
26
31
  let queue = Promise.resolve();
27
32
  function serial(run) {
@@ -30,6 +35,39 @@ function serial(run) {
30
35
  return result;
31
36
  }
32
37
  function context(meta) { return Context.parse(meta?.[CONTEXT]); }
38
+ // Consent receipts are not selection storage. Tombstones prevent old branch state
39
+ // or a reconnect from creating the same proposed task twice, even after a crash.
40
+ const receiptDir = join(process.env.XDG_STATE_HOME || join(homedir(), '.local/state'), 'pinote/mcp-proposals');
41
+ async function consumed(id) {
42
+ try {
43
+ await fs.lstat(join(receiptDir, id));
44
+ return true;
45
+ }
46
+ catch (error) {
47
+ if (error.code === 'ENOENT')
48
+ return false;
49
+ throw error;
50
+ }
51
+ }
52
+ async function consume(id) {
53
+ await fs.mkdir(receiptDir, { recursive: true, mode: 0o700 });
54
+ const stat = await fs.lstat(receiptDir);
55
+ if (!stat.isDirectory() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o700)
56
+ throw new Error('Unsafe proposal receipt directory');
57
+ const file = await fs.open(join(receiptDir, id), 'wx', 0o600);
58
+ try {
59
+ await file.writeFile('consumed\n');
60
+ await file.sync();
61
+ }
62
+ finally {
63
+ await file.close();
64
+ }
65
+ }
66
+ function boundedState(state) {
67
+ if (JSON.stringify(state).length > 8192)
68
+ throw new Error('Proposal exceeds the host state budget; shorten it');
69
+ return state;
70
+ }
33
71
  async function note(args, signal) {
34
72
  signal.throwIfAborted();
35
73
  const options = { signal, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 };
@@ -70,9 +108,10 @@ async function snapshot(state, signal) {
70
108
  const tasks = JSON.parse(await note(['list', '--json'], signal));
71
109
  const task = await selected(state, signal);
72
110
  const config = settings();
73
- state = { ...state, selectedId: task?.id ?? null };
111
+ state = { ...state, selectedId: task?.id ?? null, proposal: task || state.proposal && await consumed(state.proposal.id) ? null : state.proposal ?? null };
74
112
  if (state.screen === 'field' && !fields(config.config, task?.agent_notes).some((f) => f.name === state.field))
75
113
  state.screen = 'settings';
114
+ state = Context.parse({ conversationId: 'canonical', state }).state;
76
115
  const revision = createHash('sha256').update(JSON.stringify({ state, tasks, task, raw: config.raw })).digest('hex');
77
116
  return { state, tasks, task, config, revision };
78
117
  }
@@ -83,6 +122,15 @@ function view(data) {
83
122
  const action = (id, label, extra = {}) => controls.push({ id, label: label.slice(0, 2000), kind: 'action', ...extra });
84
123
  let title = 'Pinote · Tasks';
85
124
  let body = task ? task.text : 'Select a task or create one. No task is selected for this conversation.';
125
+ // Footer actions remain valid on every screen, including Settings.
126
+ if (task) {
127
+ action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
128
+ action('done', 'Complete task', { icon: icons.check });
129
+ }
130
+ else if (state.proposal) {
131
+ action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: state.proposal.text });
132
+ action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
133
+ }
86
134
  action('screen:tasks', 'Tasks', { icon: icons.note });
87
135
  action('screen:settings', 'Settings', { description: 'Global prompts, task offers, and footer fields' });
88
136
  if (state.screen === 'settings') {
@@ -113,8 +161,6 @@ function view(data) {
113
161
  if (task) {
114
162
  if (config.handoffPrompt.length <= 20_000)
115
163
  action('continue', 'Continue', { description: 'Request insertion of the task prompt; never submit it' });
116
- action('preview', 'Preview full task', { description: 'Read-only local preview; never sent to the model' });
117
- action('done', 'Complete task', { icon: icons.check, confirm: 'Complete this task? This does not start a new conversation.' });
118
164
  action('clear', 'Clear selection', { description: 'Keep the task and its progress unchanged' });
119
165
  }
120
166
  controls.push({ id: 'add', label: 'New task', icon: icons.add, kind: 'text', multiline: true, value: '', confirm: 'Create and select this task?' });
@@ -125,10 +171,21 @@ function view(data) {
125
171
  if (matches.length > 200)
126
172
  body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
127
173
  }
128
- const footer = [{ text: `${icons.note} ${task ? task.text.split('\n')[0].slice(0, 2000) : 'Pinote · no task'}`, width: config.config.titleWidth }];
174
+ const footer = [{ text: `${icons.note} `, width: 3 }];
175
+ if (task) {
176
+ footer.push({ text: `${icons.check} `, action: 'done', width: 3 });
177
+ footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', width: config.config.titleWidth });
178
+ }
179
+ else if (state.proposal) {
180
+ footer.push({ text: `${icons.cross} `, action: 'proposal:dismiss', width: 3 }, { text: `${icons.add} `, action: 'proposal:accept', width: 3 });
181
+ footer.push({ text: `${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, width: config.config.titleWidth });
182
+ }
183
+ else
184
+ footer.push({ text: 'Pinote · no task', width: config.config.titleWidth });
185
+ const titleParts = footer.length;
129
186
  if (task) {
130
187
  for (const field of fields(config.config, task.agent_notes)) {
131
- if (footer.length - 1 >= config.config.maxFields)
188
+ if (footer.length - titleParts >= config.config.maxFields)
132
189
  break;
133
190
  const raw = task.agent_notes[field.name];
134
191
  if (!raw)
@@ -150,7 +207,7 @@ function view(data) {
150
207
  body = `${control.label} exceeds the editor limit; edit it outside this UI.\n${body}`;
151
208
  return false;
152
209
  });
153
- const result = { version: 1, title, body: body.slice(0, 20_000), revision, state, controls: editable, footer };
210
+ const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer };
154
211
  // JSON escaping and expanded footer formats can exceed the host budget even
155
212
  // with bounded row counts. Preserve navigation/filter controls ahead of rows.
156
213
  let shortened = false;
@@ -184,9 +241,21 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
184
241
  const { action, value } = request.params;
185
242
  if (!view(before).controls.some((c) => c.id === action))
186
243
  throw new Error('Action is not available in this view');
187
- const next = { ...state };
244
+ const next = { ...before.state };
188
245
  let draft, preview;
189
- if (action.startsWith('screen:'))
246
+ if (action === 'proposal:accept' || action === 'proposal:dismiss') {
247
+ const proposal = before.state.proposal;
248
+ if (!proposal || before.task)
249
+ throw new Error('Suggestion no longer available');
250
+ await consume(proposal.id); // Fail closed before any task mutation; never replay uncertain creation.
251
+ next.proposal = null;
252
+ if (action === 'proposal:accept') {
253
+ const task = JSON.parse(await note(['agent', 'add', `--text=${proposal.text}`, ...(proposal.tag ? [`--tag=${proposal.tag}`] : [])], extra.signal));
254
+ await note(['start', String(task.id)], extra.signal);
255
+ next.selectedId = task.id;
256
+ }
257
+ }
258
+ else if (action.startsWith('screen:'))
190
259
  next.screen = action.slice(7);
191
260
  else if (action.startsWith('field:')) {
192
261
  next.screen = 'field';
@@ -204,10 +273,16 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
204
273
  const id = Number(action.slice(7));
205
274
  if (!before.tasks.some((t) => t.id === id))
206
275
  throw new Error('Task no longer selectable');
276
+ if (next.proposal)
277
+ await consume(next.proposal.id);
207
278
  await note(['start', String(id)], extra.signal);
208
279
  next.selectedId = id;
280
+ next.proposal = null;
209
281
  }
210
282
  else if (action === 'add') {
283
+ if (next.proposal)
284
+ await consume(next.proposal.id);
285
+ next.proposal = null;
211
286
  const task = JSON.parse(await note(['agent', 'add', `--text=${z.string().min(1).parse(value)}`], extra.signal));
212
287
  await note(['start', String(task.id)], extra.signal);
213
288
  next.selectedId = task.id;
@@ -256,6 +331,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
256
331
  }));
257
332
  const empty = { type: 'object', properties: {}, additionalProperties: false };
258
333
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
334
+ { name: 'propose', description: 'Suggest a task without creating it. Requires no selected task. Show a short action-oriented title, optional details after a blank line, and reuse a saved tag. The user clicks + to create/start/select or dismiss. Replaces an unaccepted suggestion; continue working without asking again. Never creates a task before a user action.', inputSchema: { type: 'object', properties: { text: { type: 'string', minLength: 1, maxLength: 4000 }, tag: { type: 'string', minLength: 1, maxLength: 64 } }, required: ['text'], additionalProperties: false } },
259
335
  { name: 'get_current', description: 'Read the task selected through the native Pinote UI for this conversation. Returns null if unselected.', inputSchema: empty, annotations: { readOnlyHint: true } },
260
336
  { name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
261
337
  { name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
@@ -264,8 +340,19 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
264
340
  server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async () => {
265
341
  try {
266
342
  const state = context(request.params._meta).state;
267
- let result;
268
- if (request.params.name === 'get_current')
343
+ let result, outgoing;
344
+ if (request.params.name === 'propose') {
345
+ if (await selected(state, extra.signal))
346
+ throw new Error('A task is already selected');
347
+ const args = Proposal.omit({ id: true }).parse(request.params.arguments);
348
+ if (!args.text.trim())
349
+ throw new Error('Task text must not be blank');
350
+ outgoing = boundedState({ ...state, selectedId: null, proposal: { ...args, id: randomUUID() } });
351
+ if (state.proposal && !await consumed(state.proposal.id))
352
+ await consume(state.proposal.id);
353
+ result = { status: 'Suggested; user can accept (+) or dismiss. Continue working without asking again.' };
354
+ }
355
+ else if (request.params.name === 'get_current')
269
356
  result = await selected(state, extra.signal);
270
357
  else if (request.params.name === 'fields')
271
358
  result = settings().config;
@@ -281,7 +368,7 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
281
368
  }
282
369
  else
283
370
  throw new Error('Unknown tool');
284
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
371
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(outgoing ? { _meta: { 'native-ui/state': outgoing } } : {}) };
285
372
  }
286
373
  catch (error) {
287
374
  return { isError: true, content: [{ type: 'text', text: String(error) }] };
package/mcp/README.md CHANGED
@@ -6,7 +6,7 @@ the generic [gateway UI host](https://github.com/kvidzibo/mcp-session-gateway)
6
6
  only renders and routes them. No model turn is needed for UI actions.
7
7
 
8
8
  This uses the gateway's **private native UI v1 protocol**, not browser MCP Apps or
9
- the built-in Pi MCP resource renderer. Use gateway 0.7.0+ with its Pi adapter.
9
+ the built-in Pi MCP resource renderer. Use gateway 0.12.0+ with its Pi adapter.
10
10
 
11
11
  ## Setup
12
12
 
@@ -32,7 +32,8 @@ Add a service to the gateway config (use absolute paths):
32
32
  }
33
33
  ```
34
34
 
35
- Then launch the gateway's generic Pi adapter and run `/gateway-ui pinote`.
35
+ Then launch the gateway's generic Pi adapter and open
36
+ `/gateway` → Services → pinote → Open app.
36
37
  Do not also load `pi/index.ts` for this workflow. Nothing is automatically
37
38
  installed, activated, or migrated; keep the existing integration until you approve
38
39
  the switch. To test `/reload`, reload only a disposable session/profile first.
@@ -53,14 +54,25 @@ normal personal data. No desktop notifications are sent.
53
54
  edits are rejected and unrelated settings are preserved. Invalid configuration
54
55
  fails closed; repair it manually rather than replacing it with defaults.
55
56
  - **Footer:** selected task plus configured fields; safe HTTP(S) links are clickable.
56
- Menu actions are keyboard-accessible, not footer icon buttons.
57
- - **Agent:** `get_current`, `update_current`, `fields`, and `tags` are available through
58
- gateway tool discovery. They use the same host-supplied selection as the UI.
57
+ The title previews the task; **✔** completes it and clears the selection without
58
+ resetting the conversation or editor. Suggestions show **✖ / ✚** to dismiss or
59
+ create/start/select. These use the gateway's generic footer actions, not a Pinote
60
+ extension or private handler. Install the gateway's Kitty protocol handler as
61
+ described in its native UI documentation; app menus remain the keyboard fallback.
62
+ - **Agent:** `propose`, `get_current`, `update_current`, `fields`, and `tags` are
63
+ available through gateway tool discovery. `propose` returns opaque UI state;
64
+ it does not create a task. It replaces an unaccepted suggestion only when no task
65
+ is selected. Text is limited to 4,000 characters and the host's 8 KiB state budget.
66
+ Acceptance/dismissal consumes a durable receipt before mutation, so old links,
67
+ branch restores and reconnects cannot replay consent. An interrupted acceptance
68
+ may have created a task: inspect the task list, never blindly propose it again. They use the same host-supplied selection as the UI.
59
69
  UI actions and settings writes are not advertised as model tools.
60
70
 
61
71
  The host owns durable conversation/branch state; the service does not store a
62
72
  second selection database. Reload/reconnect restores saved state, separate sessions
63
- in one folder stay independent, and a new conversation starts unselected. Pi must
73
+ in one folder stay independent, and `/new` retains the current selection or pending
74
+ suggestion. An unrelated fresh launch starts unselected. The stable service
75
+ context is separate from both the Pi conversation ID and gateway process UUID. Pi must
64
76
  have saved the session for resume persistence. Existing extension selections are
65
77
  not imported. Changing the gateway config path or service name starts a separate
66
78
  selection namespace.
@@ -68,15 +80,20 @@ selection namespace.
68
80
  ## Initial-version limits
69
81
 
70
82
  This is a working alternative, **not full parity with the old extension**. It does
71
- not offer agent task suggestions, retag/edit existing tasks, install/upgrade the
72
- CLI, automatically retain selection on `/new`, reset conversations on completion,
73
- or provide clickable footer actions. Task-offer policy and new-session prompt can
83
+ not retag/edit existing tasks or install/upgrade the CLI. Completion intentionally
84
+ does not reset conversations. Task-offer policy and new-session prompt can
74
85
  be edited for compatibility with the existing extension, but this host does not
75
86
  yet implement their automation. Continue requests a separately confirmed draft;
76
- it never overwrites existing editor text or submits a prompt. Previews are plain
77
- text/Markdown source, limited to 20,000 characters; the task list shows at most 200
87
+ it never overwrites existing editor text or submits a prompt. Previews are display-only transcript entries (never model context), showing plain
88
+ text/Markdown source limited to 20,000 characters; the task list shows at most 200
78
89
  filtered matches (fewer if the serialized display budget is reached). Use the CLI for full text and unsupported task operations.
79
90
 
91
+ Consent receipts live under `$XDG_STATE_HOME/pinote/mcp-proposals` (default
92
+ `~/.local/state/pinote/mcp-proposals`). They contain no task content and are retained
93
+ to reject historical proposals. Do not delete them while old sessions may resume.
94
+ They are replay protection, not a second selection database or an authorization
95
+ boundary against local processes.
96
+
80
97
  Changes through this service notify the host. External CLI/GTK/config changes need
81
98
  Ctrl+R or reopening the app. Failed actions are never retried automatically: a
82
99
  write might have committed before an interruption. Read fresh state before retrying.
package/mcp/server.ts CHANGED
@@ -1,4 +1,7 @@
1
- import { createHash } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
2
5
  import { execFile } from 'node:child_process';
3
6
  import { promisify } from 'node:util';
4
7
  import { readFileSync } from 'node:fs';
@@ -13,8 +16,10 @@ const MIME = 'application/vnd.mcp-native-ui+json';
13
16
  const CONTEXT = 'native-ui/context';
14
17
  const exec = promisify(execFile);
15
18
  const noteCommand = process.env.PINOTE_NOTE_COMMAND || 'note';
16
- const icons = Object.fromEntries(['add', 'check', 'note'].map((name) => [name, readFileSync(new URL(`../icons/${name}.txt`, import.meta.url), 'utf8').trim()]));
17
- const Context = z.object({ conversationId: z.string().min(1).max(256), state: z.object({
19
+ const icons = Object.fromEntries(['add', 'check', 'cross', 'note'].map((name) => [name, readFileSync(new URL(`../icons/${name}.txt`, import.meta.url), 'utf8').trim()]));
20
+ const Proposal = z.object({ id: z.string().uuid(), text: z.string().min(1).max(4000), tag: z.string().trim().min(1).max(64).optional() }).strict();
21
+ const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), state: z.object({
22
+ proposal: Proposal.nullable().optional(),
18
23
  selectedId: z.number().int().positive().safe().nullable().optional(), screen: z.enum(['tasks', 'settings', 'field']).optional(),
19
24
  field: z.string().refine((value) => [...value].length <= 64, 'Field name exceeds 64 code points').optional(), search: z.string().max(256).optional(),
20
25
  }).default({}) });
@@ -26,7 +31,7 @@ const Action = z.object({ method: z.literal('native-ui/action'), params: z.objec
26
31
  uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
27
32
  value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
28
33
  }) });
29
- const server = new Server({ name: 'pinote', version: '0.19.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
34
+ const server = new Server({ name: 'pinote', version: '0.20.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
30
35
  // Serialize complete read/check/write cycles even when a host sends parallel calls.
31
36
  let queue = Promise.resolve();
32
37
  function serial<T>(run: () => Promise<T>): Promise<T> {
@@ -35,6 +40,24 @@ function serial<T>(run: () => Promise<T>): Promise<T> {
35
40
  return result;
36
41
  }
37
42
  function context(meta: Record<string, unknown> | undefined) { return Context.parse(meta?.[CONTEXT]); }
43
+ // Consent receipts are not selection storage. Tombstones prevent old branch state
44
+ // or a reconnect from creating the same proposed task twice, even after a crash.
45
+ const receiptDir = join(process.env.XDG_STATE_HOME || join(homedir(), '.local/state'), 'pinote/mcp-proposals');
46
+ async function consumed(id: string): Promise<boolean> {
47
+ try { await fs.lstat(join(receiptDir, id)); return true; }
48
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; throw error; }
49
+ }
50
+ async function consume(id: string): Promise<void> {
51
+ await fs.mkdir(receiptDir, { recursive: true, mode: 0o700 });
52
+ const stat = await fs.lstat(receiptDir);
53
+ if (!stat.isDirectory() || stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o700) throw new Error('Unsafe proposal receipt directory');
54
+ const file = await fs.open(join(receiptDir, id), 'wx', 0o600);
55
+ try { await file.writeFile('consumed\n'); await file.sync(); } finally { await file.close(); }
56
+ }
57
+ function boundedState(state: State): State {
58
+ if (JSON.stringify(state).length > 8192) throw new Error('Proposal exceeds the host state budget; shorten it');
59
+ return state;
60
+ }
38
61
  async function note(args: string[], signal: AbortSignal): Promise<string> {
39
62
  signal.throwIfAborted();
40
63
  const options = { signal, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 };
@@ -68,8 +91,9 @@ async function snapshot(state: State, signal: AbortSignal) {
68
91
  const tasks: Task[] = JSON.parse(await note(['list', '--json'], signal));
69
92
  const task = await selected(state, signal);
70
93
  const config = settings();
71
- state = { ...state, selectedId: task?.id ?? null };
94
+ state = { ...state, selectedId: task?.id ?? null, proposal: task || state.proposal && await consumed(state.proposal.id) ? null : state.proposal ?? null };
72
95
  if (state.screen === 'field' && !fields(config.config, task?.agent_notes).some((f) => f.name === state.field)) state.screen = 'settings';
96
+ state = Context.parse({ conversationId: 'canonical', state }).state;
73
97
  const revision = createHash('sha256').update(JSON.stringify({ state, tasks, task, raw: config.raw })).digest('hex');
74
98
  return { state, tasks, task, config, revision };
75
99
  }
@@ -81,6 +105,14 @@ function view(data: Snapshot) {
81
105
  const action = (id: string, label: string, extra: Partial<Control> = {}) => controls.push({ id, label: label.slice(0, 2000), kind: 'action', ...extra });
82
106
  let title = 'Pinote · Tasks';
83
107
  let body = task ? task.text : 'Select a task or create one. No task is selected for this conversation.';
108
+ // Footer actions remain valid on every screen, including Settings.
109
+ if (task) {
110
+ action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
111
+ action('done', 'Complete task', { icon: icons.check });
112
+ } else if (state.proposal) {
113
+ action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: state.proposal.text });
114
+ action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
115
+ }
84
116
  action('screen:tasks', 'Tasks', { icon: icons.note });
85
117
  action('screen:settings', 'Settings', { description: 'Global prompts, task offers, and footer fields' });
86
118
  if (state.screen === 'settings') {
@@ -104,8 +136,6 @@ function view(data: Snapshot) {
104
136
  } else {
105
137
  if (task) {
106
138
  if (config.handoffPrompt.length <= 20_000) action('continue', 'Continue', { description: 'Request insertion of the task prompt; never submit it' });
107
- action('preview', 'Preview full task', { description: 'Read-only local preview; never sent to the model' });
108
- action('done', 'Complete task', { icon: icons.check, confirm: 'Complete this task? This does not start a new conversation.' });
109
139
  action('clear', 'Clear selection', { description: 'Keep the task and its progress unchanged' });
110
140
  }
111
141
  controls.push({ id: 'add', label: 'New task', icon: icons.add, kind: 'text', multiline: true, value: '', confirm: 'Create and select this task?' });
@@ -114,10 +144,18 @@ function view(data: Snapshot) {
114
144
  for (const candidate of matches.slice(0, 200)) action(`select:${candidate.id}`, `${candidate.tag ? `[${candidate.tag}] ` : ''}${candidate.text.split('\n')[0]}`, { description: `#${candidate.id} · ${candidate.state}`, confirm: 'Select and start this task for this conversation?' });
115
145
  if (matches.length > 200) body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
116
146
  }
117
- const footer: { text: string; url?: string; width?: number }[] = [{ text: `${icons.note} ${task ? task.text.split('\n')[0].slice(0, 2000) : 'Pinote · no task'}`, width: config.config.titleWidth }];
147
+ const footer: { text: string; url?: string; action?: string; width?: number }[] = [{ text: `${icons.note} `, width: 3 }];
148
+ if (task) {
149
+ footer.push({ text: `${icons.check} `, action: 'done', width: 3 });
150
+ footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', width: config.config.titleWidth });
151
+ } else if (state.proposal) {
152
+ footer.push({ text: `${icons.cross} `, action: 'proposal:dismiss', width: 3 }, { text: `${icons.add} `, action: 'proposal:accept', width: 3 });
153
+ footer.push({ text: `${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, width: config.config.titleWidth });
154
+ } else footer.push({ text: 'Pinote · no task', width: config.config.titleWidth });
155
+ const titleParts = footer.length;
118
156
  if (task) {
119
157
  for (const field of fields(config.config, task.agent_notes)) {
120
- if (footer.length - 1 >= config.config.maxFields) break;
158
+ if (footer.length - titleParts >= config.config.maxFields) break;
121
159
  const raw = task.agent_notes[field.name];
122
160
  if (!raw) continue;
123
161
  const link = /^\[([^\n]*)\]\((https?:\/\/[^\s]+)\)$/.exec(raw);
@@ -135,7 +173,7 @@ function view(data: Snapshot) {
135
173
  body = `${control.label} exceeds the editor limit; edit it outside this UI.\n${body}`;
136
174
  return false;
137
175
  });
138
- const result = { version: 1, title, body: body.slice(0, 20_000), revision, state, controls: editable, footer };
176
+ const result = { version: 1, title, body: body.slice(0, 20_000), revision, state: boundedState(state), retainOnNew: true, controls: editable, footer };
139
177
  // JSON escaping and expanded footer formats can exceed the host budget even
140
178
  // with bounded row counts. Preserve navigation/filter controls ahead of rows.
141
179
  let shortened = false;
@@ -160,9 +198,18 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
160
198
  if (before.revision !== request.params.revision) throw new Error('View changed; refresh before acting');
161
199
  const { action, value } = request.params;
162
200
  if (!view(before).controls.some((c) => c.id === action)) throw new Error('Action is not available in this view');
163
- const next: State = { ...state };
201
+ const next: State = { ...before.state };
164
202
  let draft: string | undefined, preview: string | undefined;
165
- if (action.startsWith('screen:')) next.screen = action.slice(7) as State['screen'];
203
+ if (action === 'proposal:accept' || action === 'proposal:dismiss') {
204
+ const proposal = before.state.proposal;
205
+ if (!proposal || before.task) throw new Error('Suggestion no longer available');
206
+ await consume(proposal.id); // Fail closed before any task mutation; never replay uncertain creation.
207
+ next.proposal = null;
208
+ if (action === 'proposal:accept') {
209
+ const task: Task = JSON.parse(await note(['agent', 'add', `--text=${proposal.text}`, ...(proposal.tag ? [`--tag=${proposal.tag}`] : [])], extra.signal));
210
+ await note(['start', String(task.id)], extra.signal); next.selectedId = task.id;
211
+ }
212
+ } else if (action.startsWith('screen:')) next.screen = action.slice(7) as State['screen'];
166
213
  else if (action.startsWith('field:')) { next.screen = 'field'; next.field = action.slice(6); }
167
214
  else if (action === 'search') next.search = z.string().max(256).parse(value);
168
215
  else if (action === 'clear') next.selectedId = null;
@@ -171,8 +218,11 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
171
218
  else if (action.startsWith('select:')) {
172
219
  const id = Number(action.slice(7));
173
220
  if (!before.tasks.some((t) => t.id === id)) throw new Error('Task no longer selectable');
174
- await note(['start', String(id)], extra.signal); next.selectedId = id;
221
+ if (next.proposal) await consume(next.proposal.id);
222
+ await note(['start', String(id)], extra.signal); next.selectedId = id; next.proposal = null;
175
223
  } else if (action === 'add') {
224
+ if (next.proposal) await consume(next.proposal.id);
225
+ next.proposal = null;
176
226
  const task: Task = JSON.parse(await note(['agent', 'add', `--text=${z.string().min(1).parse(value)}`], extra.signal));
177
227
  await note(['start', String(task.id)], extra.signal); next.selectedId = task.id;
178
228
  } else if (action === 'done') {
@@ -206,6 +256,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
206
256
  }));
207
257
  const empty = { type: 'object' as const, properties: {}, additionalProperties: false };
208
258
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
259
+ { name: 'propose', description: 'Suggest a task without creating it. Requires no selected task. Show a short action-oriented title, optional details after a blank line, and reuse a saved tag. The user clicks + to create/start/select or dismiss. Replaces an unaccepted suggestion; continue working without asking again. Never creates a task before a user action.', inputSchema: { type: 'object' as const, properties: { text: { type: 'string', minLength: 1, maxLength: 4000 }, tag: { type: 'string', minLength: 1, maxLength: 64 } }, required: ['text'], additionalProperties: false } },
209
260
  { name: 'get_current', description: 'Read the task selected through the native Pinote UI for this conversation. Returns null if unselected.', inputSchema: empty, annotations: { readOnlyHint: true } },
210
261
  { name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
211
262
  { name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
@@ -214,8 +265,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
214
265
  server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async () => {
215
266
  try {
216
267
  const state = context(request.params._meta).state;
217
- let result: unknown;
218
- if (request.params.name === 'get_current') result = await selected(state, extra.signal);
268
+ let result: unknown, outgoing: State | undefined;
269
+ if (request.params.name === 'propose') {
270
+ if (await selected(state, extra.signal)) throw new Error('A task is already selected');
271
+ const args = Proposal.omit({ id: true }).parse(request.params.arguments);
272
+ if (!args.text.trim()) throw new Error('Task text must not be blank');
273
+ outgoing = boundedState({ ...state, selectedId: null, proposal: { ...args, id: randomUUID() } });
274
+ if (state.proposal && !await consumed(state.proposal.id)) await consume(state.proposal.id);
275
+ result = { status: 'Suggested; user can accept (+) or dismiss. Continue working without asking again.' };
276
+ } else if (request.params.name === 'get_current') result = await selected(state, extra.signal);
219
277
  else if (request.params.name === 'fields') result = settings().config;
220
278
  else if (request.params.name === 'tags') result = JSON.parse(await note(['agent', 'tags'], extra.signal));
221
279
  else if (request.params.name === 'update_current') {
@@ -225,7 +283,7 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
225
283
  result = JSON.parse(await note(['agent', 'update', String(task.id), '--expected-updated-at', args.expected_updated_at, '--set-json', JSON.stringify(args.set), ...args.remove.map((key) => `--remove=${key}`)], extra.signal));
226
284
  await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
227
285
  } else throw new Error('Unknown tool');
228
- return { content: [{ type: 'text', text: JSON.stringify(result) }] };
286
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(outgoing ? { _meta: { 'native-ui/state': outgoing } } : {}) };
229
287
  } catch (error) { return { isError: true, content: [{ type: 'text', text: String(error) }] }; }
230
288
  }));
231
289
  await server.connect(new StdioServerTransport());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",