pi-note 0.30.0 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,9 +50,10 @@ state routing, tools and UI.
50
50
 
51
51
  Open **`/pinote`** (`/pi-note` is an alias). Select or create a task, preview it,
52
52
  clear the selection, complete it, respond to suggestions, or edit shared settings.
53
- Selection/creation and settings changes require confirmation. Nothing submits a
54
- model prompt automatically; inserting Continue's draft requires confirmation and
55
- preserves an existing draft.
53
+ Selecting an existing task starts it immediately and returns focus to the prompt,
54
+ with no extra confirmation. Creation and settings changes still require confirmation.
55
+ Nothing submits a model prompt automatically; inserting Continue's draft requires
56
+ confirmation and preserves an existing draft.
56
57
 
57
58
  Menus use Pi's native components and configured selection keys. **Escape** goes
58
59
  back; **Ctrl+R** refreshes. Settings with fewer than five choices cycle inline.
@@ -74,6 +75,14 @@ Unrelated fresh launches start unselected.
74
75
  Changing endpoints starts a separate selection namespace. Unsaved Pi sessions
75
76
  cannot guarantee selection persistence.
76
77
 
78
+ The GTK app's **Start in Kitty…** action (note 0.13.0+) chooses a working directory
79
+ and opens a named tab with this task selected, without submitting a prompt. Client
80
+ and service both need pi-note 0.31.0+. The one-shot `PINOTE_LAUNCH_TASK` environment
81
+ handoff carries only the task ID and displayed revision, never task text or tokens.
82
+ Only a fresh session can consume it. The service must share the GUI's database;
83
+ missing/stale revisions fail closed. Resume, reload and `/new` never replay the
84
+ handoff or replace another selection. Pi keeps its normal endpoint/token settings.
85
+
77
86
  ### Agent tools and context
78
87
 
79
88
  The extension registers `pinote_search`, `pinote_get_task`, `pinote_get_current`,
@@ -29,6 +29,7 @@ const ProposalInput = z.union([NewProposal, ExistingProposal]);
29
29
  const Proposal = z.union([NewProposal.extend({ id: z.string().uuid() }), ExistingProposal.extend({ id: z.string().uuid() })]);
30
30
  const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), state: z.object({
31
31
  proposal: Proposal.nullable().optional(),
32
+ launch: z.object({ task_id: TaskId, updated_at: z.string().min(1).max(128) }).strict().optional(),
32
33
  selectedId: z.number().int().positive().safe().nullable().optional(), screen: z.enum(['tasks', 'settings', 'field']).optional(),
33
34
  field: z.string().refine((value) => [...value].length <= 64, 'Field name exceeds 64 code points').optional(), search: z.string().max(256).optional(),
34
35
  }).default({}) });
@@ -128,7 +129,10 @@ async function selection(state, signal) {
128
129
  return { task, proposal, proposedTask };
129
130
  }
130
131
  async function snapshot(state, signal) {
131
- const tasks = JSON.parse(await note(['list', '--json'], signal));
132
+ const launchTask = state.launch ? JSON.parse(await note(['agent', 'get', String(state.launch.task_id)], signal)) : null;
133
+ const tasks = state.launch
134
+ ? launchTask && ['active', 'in_progress'].includes(launchTask.state) ? [launchTask] : []
135
+ : JSON.parse(await note(['list', '--json'], signal));
132
136
  const { task, proposal, proposedTask } = await selection(state, signal);
133
137
  const config = settings();
134
138
  const suggestion = proposal && ('task_id' in proposal ? proposedTask : proposal);
@@ -223,9 +227,11 @@ function view(data, scoped = false, secondary = false, features = {}) {
223
227
  }
224
228
  controls.push({ id: 'add', label: 'New task', icon: icons.add, kind: 'text', multiline: true, value: '', confirm: 'Create and select this task?' });
225
229
  controls.push({ id: 'search', label: 'Filter tasks', kind: 'text', value: state.search ?? '' });
226
- const matches = tasks.filter((t) => `${t.text} ${t.tag ?? ''}`.toLowerCase().includes((state.search ?? '').toLowerCase()));
230
+ const matches = tasks.filter((t) => state.launch
231
+ ? t.id === state.launch.task_id && t.updated_at === state.launch.updated_at
232
+ : `${t.text} ${t.tag ?? ''}`.toLowerCase().includes((state.search ?? '').toLowerCase()));
227
233
  for (const candidate of matches.slice(0, 200))
228
- 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?' });
234
+ action(`select:${candidate.id}`, `${candidate.tag ? `[${candidate.tag}] ` : ''}${candidate.text.split('\n')[0]}`, { description: `#${candidate.id} · ${candidate.state} · Select and start this task` });
229
235
  if (matches.length > 200)
230
236
  body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
231
237
  }
@@ -362,6 +368,7 @@ export function createPinoteServer(options = {}) {
362
368
  if (!control || (control.revision ?? before.revision) !== request.params.revision)
363
369
  staleAction();
364
370
  const next = { ...before.state };
371
+ delete next.launch; // A launcher filter is one-shot; ordinary navigation restores the list.
365
372
  let draft, preview;
366
373
  if (action === 'proposal:accept' || action === 'proposal:dismiss') {
367
374
  const proposal = before.state.proposal;
@@ -395,7 +402,7 @@ export function createPinoteServer(options = {}) {
395
402
  if (!candidate)
396
403
  staleAction();
397
404
  const target = JSON.parse(await note(['agent', 'get', String(id)], extra.signal));
398
- if (target.text !== candidate.text || target.state !== candidate.state || target.tag !== candidate.tag)
405
+ if (target.text !== candidate.text || target.state !== candidate.state || target.tag !== candidate.tag || state.launch && target.updated_at !== state.launch.updated_at)
399
406
  staleAction();
400
407
  if (next.proposal)
401
408
  await consume(next.proposal.id);
package/mcp/core.ts CHANGED
@@ -30,6 +30,7 @@ const ProposalInput = z.union([NewProposal, ExistingProposal]);
30
30
  const Proposal = z.union([NewProposal.extend({ id: z.string().uuid() }), ExistingProposal.extend({ id: z.string().uuid() })]);
31
31
  const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), state: z.object({
32
32
  proposal: Proposal.nullable().optional(),
33
+ launch: z.object({ task_id: TaskId, updated_at: z.string().min(1).max(128) }).strict().optional(),
33
34
  selectedId: z.number().int().positive().safe().nullable().optional(), screen: z.enum(['tasks', 'settings', 'field']).optional(),
34
35
  field: z.string().refine((value) => [...value].length <= 64, 'Field name exceeds 64 code points').optional(), search: z.string().max(256).optional(),
35
36
  }).default({}) });
@@ -110,7 +111,10 @@ async function selection(state: State, signal: AbortSignal) {
110
111
  return { task, proposal, proposedTask };
111
112
  }
112
113
  async function snapshot(state: State, signal: AbortSignal) {
113
- const tasks: Task[] = JSON.parse(await note(['list', '--json'], signal));
114
+ const launchTask: Task | null = state.launch ? JSON.parse(await note(['agent', 'get', String(state.launch.task_id)], signal)) : null;
115
+ const tasks: Task[] = state.launch
116
+ ? launchTask && ['active', 'in_progress'].includes(launchTask.state) ? [launchTask] : []
117
+ : JSON.parse(await note(['list', '--json'], signal));
114
118
  const { task, proposal, proposedTask } = await selection(state, signal);
115
119
  const config = settings();
116
120
  const suggestion = proposal && ('task_id' in proposal ? proposedTask : proposal);
@@ -193,8 +197,10 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
193
197
  }
194
198
  controls.push({ id: 'add', label: 'New task', icon: icons.add, kind: 'text', multiline: true, value: '', confirm: 'Create and select this task?' });
195
199
  controls.push({ id: 'search', label: 'Filter tasks', kind: 'text', value: state.search ?? '' });
196
- const matches = tasks.filter((t) => `${t.text} ${t.tag ?? ''}`.toLowerCase().includes((state.search ?? '').toLowerCase()));
197
- 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?' });
200
+ const matches = tasks.filter((t) => state.launch
201
+ ? t.id === state.launch.task_id && t.updated_at === state.launch.updated_at
202
+ : `${t.text} ${t.tag ?? ''}`.toLowerCase().includes((state.search ?? '').toLowerCase()));
203
+ 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} · Select and start this task` });
198
204
  if (matches.length > 200) body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
199
205
  }
200
206
  const footer: { text: string; openApp?: true; url?: string; action?: string; secondaryAction?: string; doubleAction?: string; style?: { italic: boolean; color: 'accent' }; width?: number }[] = features.footerNativeActions
@@ -303,6 +309,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
303
309
  const control = view(before, scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta)).controls.find((item) => item.id === action);
304
310
  if (!control || (control.revision ?? before.revision) !== request.params.revision) staleAction();
305
311
  const next: State = { ...before.state };
312
+ delete next.launch; // A launcher filter is one-shot; ordinary navigation restores the list.
306
313
  let draft: string | undefined, preview: string | undefined;
307
314
  if (action === 'proposal:accept' || action === 'proposal:dismiss') {
308
315
  const proposal = before.state.proposal;
@@ -324,7 +331,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
324
331
  const candidate = before.tasks.find((task) => task.id === id);
325
332
  if (!candidate) staleAction();
326
333
  const target: Task = JSON.parse(await note(['agent', 'get', String(id)], extra.signal));
327
- if (target.text !== candidate.text || target.state !== candidate.state || target.tag !== candidate.tag) staleAction();
334
+ if (target.text !== candidate.text || target.state !== candidate.state || target.tag !== candidate.tag || state.launch && target.updated_at !== state.launch.updated_at) staleAction();
328
335
  if (next.proposal) await consume(next.proposal.id);
329
336
  await note(['agent', 'start', String(id), '--expected-updated-at', target.updated_at], extra.signal); next.selectedId = id; next.proposal = null;
330
337
  } else if (action === 'add') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/remote/ui.ts CHANGED
@@ -172,7 +172,7 @@ export class RemoteUI {
172
172
  ctx.ui.notify('Value does not match the field type.', 'warning'); continue;
173
173
  }
174
174
  }
175
- if (current()) await this.dispatch(ctx, view, control, value);
175
+ if (current() && await this.dispatch(ctx, view, control, value) && control.id.startsWith('select:')) return;
176
176
  }
177
177
  } catch (error) { if (current()) ctx.ui.notify(error instanceof RemoteFailure ? error.message : 'Pinote unavailable', 'error'); }
178
178
  finally { this.opening = false; }
package/remote.ts CHANGED
@@ -14,6 +14,8 @@ const transfers: Map<string, SavedState> = ((globalThis as any)[key] ??= new Map
14
14
  export default function pinoteRemote(pi: ExtensionAPI): void {
15
15
  let session: RemoteSession | undefined, ui: RemoteUI | undefined;
16
16
  let file: string | undefined;
17
+ let launchRequest = process.env.PINOTE_LAUNCH_TASK;
18
+ delete process.env.PINOTE_LAUNCH_TASK; // Never inherit consent into another Pi process or /reload.
17
19
  const getSession = () => { if (!session) throw new RemoteFailure(); return session; };
18
20
  registerTools(pi, getSession);
19
21
  registerPreview(pi);
@@ -34,6 +36,7 @@ export default function pinoteRemote(pi: ExtensionAPI): void {
34
36
  pi.registerCommand('pinote', { description: 'Open remote Pinote tasks and settings', handler: open });
35
37
  pi.registerCommand('pi-note', { description: 'Open remote Pinote (compatibility alias)', handler: open });
36
38
  const start = async (ctx: ExtensionContext, reason?: string, previous?: string) => {
39
+ const request = launchRequest; launchRequest = undefined; // Never replay an uncertain start.
37
40
  ui?.detach(); await session?.stop();
38
41
  ui = undefined; session = undefined;
39
42
  try {
@@ -42,12 +45,34 @@ export default function pinoteRemote(pi: ExtensionAPI): void {
42
45
  ui = new RemoteUI(pi, session);
43
46
  ui.attach(ctx);
44
47
  file = ctx.sessionManager.getSessionFile();
45
- const carried = reason === 'new' && previous ? transfers.get(previous) : undefined;
46
- await session.start(ctx.sessionManager.getBranch(), ctx.sessionManager.getSessionId() || randomUUID(), carried);
48
+ const branch = ctx.sessionManager.getBranch();
49
+ let carried = reason === 'new' && previous ? transfers.get(previous) : undefined;
50
+ let launch: { task_id: number; updated_at: string } | undefined;
51
+ if (request) {
52
+ try {
53
+ if (reason !== 'startup' || branch.some((entry) => entry.type === 'message' || entry.type === 'custom' && entry.customType === ENTRY_TYPE)) throw new Error('Launch requires a fresh Pi session');
54
+ const parsed = JSON.parse(request);
55
+ if (!parsed || !Number.isSafeInteger(parsed.task_id) || parsed.task_id < 1 ||
56
+ typeof parsed.updated_at !== 'string' || !parsed.updated_at.length || parsed.updated_at.length > 128) throw new Error('Invalid launch request');
57
+ launch = { task_id: parsed.task_id, updated_at: parsed.updated_at };
58
+ carried = { scope: config.scope, contextId: randomUUID(), state: { launch } };
59
+ } catch {
60
+ ctx.ui.notify('Pinote launch ignored: invalid request or nonempty/resumed session. Existing selection is unchanged.', 'warning');
61
+ }
62
+ }
63
+ await session.start(branch, ctx.sessionManager.getSessionId() || randomUUID(), carried);
64
+ if (launch) {
65
+ const view = session.view!;
66
+ // The service exposes this control only for the exact GUI-displayed revision.
67
+ if (JSON.stringify(view.state.launch) !== JSON.stringify(launch)) throw new Error('Service does not support launch handoffs');
68
+ const control = view.controls.find((item) => item.id === `select:${launch.task_id}`);
69
+ if (!control) throw new Error('Launch task changed or belongs to another service');
70
+ await session.action(view, control); // The directory chooser's Start click granted consent.
71
+ }
47
72
  if (reason === 'new' && previous) transfers.delete(previous);
48
73
  } catch (error) {
49
74
  // Configuration messages are fixed strings; transport response bodies are never shown.
50
- ctx.ui.notify(error instanceof RemoteFailure ? error.message : 'Pinote unavailable: check PINOTE_URL and private token configuration, then /reload.', 'warning');
75
+ ctx.ui.notify(error instanceof RemoteFailure ? error.message : 'Pinote unavailable or launch task changed: check the service, task revision and private token configuration. Use /pinote to select manually; launch requests are never retried.', 'warning');
51
76
  }
52
77
  };
53
78
  pi.on('session_start', (event, ctx) => start(ctx, event.reason, event.previousSessionFile));