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 +16 -5
- package/dist-mcp/mcp/core.js +10 -3
- package/mcp/core.ts +10 -3
- package/package.json +1 -1
- package/remote/exit.ts +33 -20
- package/remote.ts +28 -3
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.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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`,
|
package/dist-mcp/mcp/core.js
CHANGED
|
@@ -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
|
|
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) =>
|
|
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
|
|
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) =>
|
|
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
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
30
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
57
|
-
|
|
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
|
|
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',
|
|
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
|
|
46
|
-
|
|
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
|
|
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));
|