pi-note 0.29.1 → 0.31.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 CHANGED
@@ -63,14 +63,25 @@ No Kitty action handler is needed for this extension.
63
63
 
64
64
  Completion keeps the conversation and editor. With an empty editor, **Ctrl+D**
65
65
  (or your configured exit key) offers Cancel, Exit without completing, or Complete
66
- task and exit. Completion requires idle Pi and a current displayed revision;
67
- failures leave Pi open. `/quit` exits without completing.
68
-
69
- Reload/resume restores the active
70
- branch's selection; `/new` retains it. Unrelated fresh launches start unselected.
66
+ task and exit. **`/new`** offers Cancel, Start new session without completing, or
67
+ Complete task and start new session. With no selected task, `/new` proceeds normally.
68
+ Completion requires idle Pi and a current displayed revision; failures keep the
69
+ current session open. `/quit` exits without completing.
70
+
71
+ Reload/resume restores the active branch's selection. Starting a new session
72
+ without completing retains the selection; completing starts it unselected.
73
+ Unrelated fresh launches start unselected.
71
74
  Changing endpoints starts a separate selection namespace. Unsaved Pi sessions
72
75
  cannot guarantee selection persistence.
73
76
 
77
+ The GTK app's **Start in Kitty…** action (note 0.13.0+) chooses a working directory
78
+ and opens a named tab with this task selected, without submitting a prompt. Client
79
+ and service both need pi-note 0.31.0+. The one-shot `PINOTE_LAUNCH_TASK` environment
80
+ handoff carries only the task ID and displayed revision, never task text or tokens.
81
+ Only a fresh session can consume it. The service must share the GUI's database;
82
+ missing/stale revisions fail closed. Resume, reload and `/new` never replay the
83
+ handoff or replace another selection. Pi keeps its normal endpoint/token settings.
84
+
74
85
  ### Agent tools and context
75
86
 
76
87
  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,7 +227,9 @@ 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
234
  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?' });
229
235
  if (matches.length > 200)
@@ -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,7 +197,9 @@ 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()));
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()));
197
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}`, confirm: 'Select and start this task for this conversation?' });
198
204
  if (matches.length > 200) body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
199
205
  }
@@ -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.29.1",
3
+ "version": "0.31.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/remote/exit.ts CHANGED
@@ -3,13 +3,11 @@ import { truncateToWidth } from '@earendil-works/pi-tui';
3
3
  import { clean } from './protocol.ts';
4
4
  import type { RemoteSession } from './session.ts';
5
5
 
6
- const leave = 'Exit without completing';
7
- const complete = 'Complete task and exit';
8
-
9
- /** Default remote companion. /quit and nonempty-editor input remain Pi-owned. */
6
+ /** Default remote companion. /quit and nonempty-editor exit input remain Pi-owned. */
10
7
  export function registerRemoteExit(pi: ExtensionAPI, getSession: () => RemoteSession | undefined): void {
11
8
  let invalidate: (() => void) | undefined;
12
9
  let stop: (() => void) | undefined;
10
+ let confirm: ((reason: 'exit' | 'new') => Promise<boolean>) | undefined;
13
11
  pi.on('session_start', (_event, ctx) => {
14
12
  stop?.();
15
13
  if (ctx.mode !== 'tui' || !ctx.hasUI) return;
@@ -17,33 +15,36 @@ export function registerRemoteExit(pi: ExtensionAPI, getSession: () => RemoteSes
17
15
  let alive = true, pending = false, epoch = 0;
18
16
  let controller: AbortController | undefined;
19
17
  invalidate = () => { epoch++; controller?.abort(); };
20
- const exit = async () => {
21
- if (!alive || pending) return;
18
+ confirm = async (reason) => {
19
+ if (!alive || pending) return false;
20
+ const leave = reason === 'new' ? 'Start new session without completing' : 'Exit without completing';
21
+ const complete = reason === 'new' ? 'Complete task and start new session' : 'Complete task and exit';
22
22
  pending = true;
23
23
  const generation = epoch, session = getSession();
24
24
  const operation = controller = new AbortController();
25
25
  const sessionId = ctx.sessionManager.getSessionId();
26
+ const draft = ctx.ui.getEditorText();
26
27
  const validSession = session?.current();
27
28
  const current = () => alive && generation === epoch && !operation.signal.aborted &&
28
29
  session === getSession() && !!validSession?.() &&
29
- ctx.sessionManager.getSessionId() === sessionId && ctx.ui.getEditorText().length === 0;
30
- const shutdown = () => { if (!ctx.isIdle()) ctx.abort(); ctx.shutdown(); };
30
+ ctx.sessionManager.getSessionId() === sessionId &&
31
+ (reason === 'exit' ? ctx.ui.getEditorText().length === 0 : ctx.ui.getEditorText() === draft);
31
32
  try {
32
33
  if (!session) throw new Error('unavailable');
33
34
  await session.refresh(true);
34
- if (!current()) return;
35
+ if (!current()) return false;
35
36
  const view = session.view;
36
37
  if (!view) throw new Error('unavailable');
37
- if (view.state.selectedId == null) { shutdown(); return; }
38
+ if (view.state.selectedId == null) return true;
38
39
  const branch = ctx.sessionManager.getLeafId();
39
40
  const done = view.controls.find((c) => c.id === 'done' && c.kind === 'action');
40
41
  const title = truncateToWidth(clean((view.body ?? view.title).split('\n', 1)[0]), 70);
41
42
  const choice = await ctx.ui.select(`Pinote · ${title}`, ['Cancel', leave, complete], {
42
43
  signal: AbortSignal.any([operation.signal, session.signal]),
43
44
  });
44
- if (!current()) return;
45
- if (choice === leave) { shutdown(); return; }
46
- if (choice !== complete) return;
45
+ if (!current()) return false;
46
+ if (choice === leave) return true;
47
+ if (choice !== complete) return false;
47
48
  const latest = session.view;
48
49
  if (!ctx.isIdle() || ctx.sessionManager.getLeafId() !== branch || !done ||
49
50
  !latest || latest.state.selectedId !== view.state.selectedId || latest.revision !== view.revision ||
@@ -51,13 +52,20 @@ export function registerRemoteExit(pi: ExtensionAPI, getSession: () => RemoteSes
51
52
  throw new Error('changed');
52
53
  }
53
54
  // Approval belongs to this exact displayed view, never to a refreshed revision.
54
- await session.action(view, done, undefined, () => ctx.isIdle());
55
- if (!current()) return;
56
- if (session.view && session.view.state.selectedId == null) ctx.shutdown();
57
- else ctx.ui.notify('Pinote: Pi stayed open because the selection was not cleared. Refresh /pinote before retrying.', 'warning');
55
+ await session.action(view, done, undefined, () => current() && ctx.isIdle() && ctx.sessionManager.getLeafId() === branch);
56
+ if (!current()) return false;
57
+ if (session.view && session.view.state.selectedId == null) return true;
58
+ ctx.ui.notify('Pinote: Pi stayed in this session because the selection was not cleared. Refresh /pinote before retrying.', 'warning');
58
59
  } catch {
59
- if (alive && generation === epoch) ctx.ui.notify('Pinote: Pi stayed open. Completion was not confirmed and may have committed; refresh /pinote before retrying. /quit exits without completing.', 'warning');
60
+ if (alive && generation === epoch) ctx.ui.notify('Pinote: Pi stayed in this session. Completion was not confirmed and may have committed; refresh /pinote before retrying. /quit exits without completing.', 'warning');
60
61
  } finally { pending = false; }
62
+ return false;
63
+ };
64
+ const exit = async () => {
65
+ if (await confirm?.('exit')) {
66
+ if (!ctx.isIdle()) ctx.abort();
67
+ ctx.shutdown();
68
+ }
61
69
  };
62
70
  const factory: NonNullable<ReturnType<typeof ctx.ui.getEditorComponent>> = (tui, theme, keybindings) => {
63
71
  const editor = previous ? previous(tui, theme, keybindings) : new CustomEditor(tui, theme, keybindings);
@@ -75,10 +83,15 @@ export function registerRemoteExit(pi: ExtensionAPI, getSession: () => RemoteSes
75
83
  if (ctx.ui.getEditorComponent() === factory) ctx.ui.setEditorComponent(previous);
76
84
  };
77
85
  });
78
- pi.on('session_shutdown', () => { stop?.(); stop = invalidate = undefined; });
86
+ pi.on('session_shutdown', () => { stop?.(); stop = invalidate = confirm = undefined; });
79
87
  const interrupted = () => { invalidate?.(); };
80
88
  pi.on('agent_start', interrupted);
81
- pi.on('session_before_switch', interrupted);
89
+ pi.on('session_before_switch', async (event, ctx) => {
90
+ interrupted();
91
+ if (event.reason === 'new' && ctx.mode === 'tui' && ctx.hasUI && !await confirm?.('new')) {
92
+ return { cancel: true };
93
+ }
94
+ });
82
95
  pi.on('session_before_fork', interrupted);
83
96
  pi.on('session_before_tree', interrupted);
84
97
  }
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));