pi-note 0.33.0 → 0.33.2
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 +14 -1
- package/dist-mcp/mcp/core.js +1 -1
- package/mcp/core.ts +1 -1
- package/package.json +1 -1
- package/remote/exit.ts +30 -3
- package/remote/ui.ts +14 -3
- package/remote.ts +11 -4
package/README.md
CHANGED
|
@@ -62,7 +62,8 @@ title previews it and double-clicking completes it. A suggested title requires a
|
|
|
62
62
|
double-click to accept. Regular mode shows status; `/pinote` provides all actions.
|
|
63
63
|
No Kitty action handler is needed for this extension.
|
|
64
64
|
|
|
65
|
-
Completion keeps the conversation and editor
|
|
65
|
+
Completion keeps the conversation and editor, except for dedicated **Start in Kitty…**
|
|
66
|
+
sessions (see below). With an empty editor, **Ctrl+D**
|
|
66
67
|
(or your configured exit key) offers Cancel, Exit without completing, or Complete
|
|
67
68
|
task and exit. **`/new`** offers Cancel, Start new session without completing, or
|
|
68
69
|
Complete task and start new session. With no selected task, `/new` proceeds normally.
|
|
@@ -89,6 +90,18 @@ client-local and is never sent to the service or persisted as launch state.
|
|
|
89
90
|
Only a fresh session can consume it. The service must share the GUI's database;
|
|
90
91
|
missing/stale revisions fail closed. Resume, reload and `/new` never replay the
|
|
91
92
|
handoff or replace another selection. Pi keeps its normal endpoint/token settings.
|
|
93
|
+
With pi-note 0.33.1+, completing the launched task from the menu, footer or
|
|
94
|
+
**Complete task and exit** runs `git pull --ff-only --no-rebase --no-autostash` in
|
|
95
|
+
Pi's working directory before exiting, so Kitty closes its dedicated tab (or the
|
|
96
|
+
standalone window's last tab). It never merges, rebases or stashes local changes.
|
|
97
|
+
If Git fails, times out (30 seconds), or the session/draft changes, Pi stays open;
|
|
98
|
+
the task remains completed and is not retried. Check Git manually, then `/quit`.
|
|
99
|
+
With pi-note 0.33.2+, directories outside a Git repository skip the pull and exit
|
|
100
|
+
normally. Branches without an upstream still stay open after a failed pull.
|
|
101
|
+
`/quit` and Exit without completing do not pull.
|
|
102
|
+
This ownership survives `/reload`, but not `/new`, resume, fork, tree navigation
|
|
103
|
+
or process restart. Completing a different task or an externally completed task
|
|
104
|
+
does not pull or close Pi; failed/uncertain completion stays open.
|
|
92
105
|
|
|
93
106
|
### Agent tools and context
|
|
94
107
|
|
package/dist-mcp/mcp/core.js
CHANGED
|
@@ -183,7 +183,7 @@ function view(data, scoped = false, secondary = false, features = {}) {
|
|
|
183
183
|
// Footer actions remain valid on every screen, including Settings.
|
|
184
184
|
if (task) {
|
|
185
185
|
action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
|
|
186
|
-
action('done', 'Complete task', { icon: icons.check, ...(features.footerNativeActions ? { description: 'Complete
|
|
186
|
+
action('done', 'Complete task', { icon: icons.check, ...(features.footerNativeActions ? { description: 'Complete the selected task' } : {}) });
|
|
187
187
|
}
|
|
188
188
|
else if (state.proposal) {
|
|
189
189
|
const acceptance = 'task_id' in state.proposal ? `Start and select existing task #${state.proposal.task_id}; no new task is created.` : 'Create, start, and select this suggestion.';
|
package/mcp/core.ts
CHANGED
|
@@ -162,7 +162,7 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
|
|
|
162
162
|
// Footer actions remain valid on every screen, including Settings.
|
|
163
163
|
if (task) {
|
|
164
164
|
action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
|
|
165
|
-
action('done', 'Complete task', { icon: icons.check, ...(features.footerNativeActions ? { description: 'Complete
|
|
165
|
+
action('done', 'Complete task', { icon: icons.check, ...(features.footerNativeActions ? { description: 'Complete the selected task' } : {}) });
|
|
166
166
|
} else if (state.proposal) {
|
|
167
167
|
const acceptance = 'task_id' in state.proposal ? `Start and select existing task #${state.proposal.task_id}; no new task is created.` : 'Create, start, and select this suggestion.';
|
|
168
168
|
action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: `${acceptance} ${features.footerNativeActions ? 'Double-click the suggested footer title to accept; use Dismiss suggestion to dismiss.' : 'Left-click to accept; right-click to dismiss.'}\n${suggestion!.text.slice(0, 4000)}` });
|
package/package.json
CHANGED
package/remote/exit.ts
CHANGED
|
@@ -1,10 +1,32 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { CustomEditor, type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
3
|
import { truncateToWidth } from '@earendil-works/pi-tui';
|
|
3
4
|
import { clean } from './protocol.ts';
|
|
4
5
|
import type { RemoteSession } from './session.ts';
|
|
5
6
|
|
|
7
|
+
/** Completion is already committed: pull failures must never retry the task action. */
|
|
8
|
+
export async function pullBeforeExit(pi: ExtensionAPI, ctx: ExtensionContext, signal: AbortSignal, current: () => boolean): Promise<boolean> {
|
|
9
|
+
const sessionId = ctx.sessionManager.getSessionId(), branch = ctx.sessionManager.getLeafId();
|
|
10
|
+
const draft = ctx.ui.getEditorText();
|
|
11
|
+
const unchanged = () => current() && !signal.aborted && ctx.isIdle() &&
|
|
12
|
+
ctx.sessionManager.getSessionId() === sessionId && ctx.sessionManager.getLeafId() === branch && ctx.ui.getEditorText() === draft;
|
|
13
|
+
if (!unchanged()) return false;
|
|
14
|
+
try {
|
|
15
|
+
const repository = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
16
|
+
cwd: ctx.cwd, encoding: 'utf8', timeout: 3000, env: { ...process.env, LC_ALL: 'C' },
|
|
17
|
+
});
|
|
18
|
+
if (!repository.error && repository.status === 128 && repository.stderr.startsWith('fatal: not a git repository (or any ')) return unchanged();
|
|
19
|
+
if (repository.error || repository.status !== 0 || !unchanged()) throw new Error('Git repository check failed');
|
|
20
|
+
ctx.ui.notify('Task completed. Running git pull --ff-only before closing Pi…', 'info');
|
|
21
|
+
const result = await pi.exec('git', ['pull', '--ff-only', '--no-rebase', '--no-autostash'], { cwd: ctx.cwd, signal, timeout: 30_000 });
|
|
22
|
+
if (result.code === 0 && !result.killed && unchanged()) return true;
|
|
23
|
+
} catch { /* Completion succeeded; only the local pull failed. */ }
|
|
24
|
+
if (current()) ctx.ui.notify('Task completed, but Pi stayed open: git pull failed or the session changed. Check Git manually, then /quit. Completion will not be retried.', 'warning');
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
6
28
|
/** Default remote companion. /quit and nonempty-editor exit input remain Pi-owned. */
|
|
7
|
-
export function registerRemoteExit(pi: ExtensionAPI, getSession: () => RemoteSession | undefined): void {
|
|
29
|
+
export function registerRemoteExit(pi: ExtensionAPI, getSession: () => RemoteSession | undefined, ownsLaunch = (_taskId: number) => false): void {
|
|
8
30
|
let invalidate: (() => void) | undefined;
|
|
9
31
|
let stop: (() => void) | undefined;
|
|
10
32
|
let confirm: ((reason: 'exit' | 'new') => Promise<boolean>) | undefined;
|
|
@@ -54,7 +76,12 @@ export function registerRemoteExit(pi: ExtensionAPI, getSession: () => RemoteSes
|
|
|
54
76
|
// Approval belongs to this exact displayed view, never to a refreshed revision.
|
|
55
77
|
await session.action(view, done, undefined, () => current() && ctx.isIdle() && ctx.sessionManager.getLeafId() === branch);
|
|
56
78
|
if (!current()) return false;
|
|
57
|
-
if (session.view && session.view.state.selectedId == null)
|
|
79
|
+
if (session.view && session.view.state.selectedId == null) {
|
|
80
|
+
if (reason === 'exit' && typeof view.state.selectedId === 'number' && ownsLaunch(view.state.selectedId)) {
|
|
81
|
+
return await pullBeforeExit(pi, ctx, operation.signal, current);
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
58
85
|
ctx.ui.notify('Pinote: Pi stayed in this session because the selection was not cleared. Refresh /pinote before retrying.', 'warning');
|
|
59
86
|
} catch {
|
|
60
87
|
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');
|
package/remote/ui.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { NativeFooter } from './footer.ts';
|
|
|
4
4
|
import { clean, cleanUnbounded, editorText, footerText, literal, type NativeControl, type NativeView } from './protocol.ts';
|
|
5
5
|
import { RemoteFailure } from './client.ts';
|
|
6
6
|
import { RemoteSession } from './session.ts';
|
|
7
|
+
import { pullBeforeExit } from './exit.ts';
|
|
7
8
|
import { inlineHeight, remoteMenu, type MenuState } from './menu.ts';
|
|
8
9
|
|
|
9
10
|
const line = (text: string) => clean(text).replace(/\n/g, ' ');
|
|
@@ -63,10 +64,12 @@ export class RemoteUI {
|
|
|
63
64
|
private repaint = () => {};
|
|
64
65
|
private menuChanged?: () => void;
|
|
65
66
|
private opening = false;
|
|
67
|
+
private closing = false;
|
|
66
68
|
private pi: ExtensionAPI;
|
|
67
69
|
private session: RemoteSession;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
private exitOnComplete: (taskId: number) => boolean;
|
|
71
|
+
constructor(pi: ExtensionAPI, session: RemoteSession, exitOnComplete = (_taskId: number) => false) {
|
|
72
|
+
this.pi = pi; this.session = session; this.exitOnComplete = exitOnComplete;
|
|
70
73
|
session.onChange = () => { this.render(); this.menuChanged?.(); };
|
|
71
74
|
}
|
|
72
75
|
attach(ctx: ExtensionContext): void {
|
|
@@ -109,6 +112,14 @@ export class RemoteUI {
|
|
|
109
112
|
if (!current()) return false;
|
|
110
113
|
const result = await this.session.action(view, control, value, () => control.id !== 'done' || ctx.isIdle());
|
|
111
114
|
if (!current()) return false;
|
|
115
|
+
if (control.id === 'done' && ctx.isIdle() && typeof view.state.selectedId === 'number' &&
|
|
116
|
+
this.session.view && this.session.view.state.selectedId == null && this.exitOnComplete(view.state.selectedId)) {
|
|
117
|
+
if (await pullBeforeExit(this.pi, ctx, signal, current)) {
|
|
118
|
+
this.closing = true;
|
|
119
|
+
ctx.shutdown();
|
|
120
|
+
}
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
112
123
|
if (result.preview) this.pi.appendEntry('pinote-remote-preview', { title: line(view.title), text: clean(result.preview) });
|
|
113
124
|
if (result.draft && await review(ctx, 'Insert server draft?', result.draft, signal) && current()) {
|
|
114
125
|
if (ctx.ui.getEditorText().length) ctx.ui.notify('Draft not inserted: clear the editor first.', 'warning');
|
|
@@ -131,7 +142,7 @@ export class RemoteUI {
|
|
|
131
142
|
const menus = new Map<string, MenuState>();
|
|
132
143
|
try {
|
|
133
144
|
await this.session.refresh(true);
|
|
134
|
-
while (current() && !signal.aborted) {
|
|
145
|
+
while (current() && !signal.aborted && !this.closing) {
|
|
135
146
|
const view = this.session.view;
|
|
136
147
|
if (!view) return;
|
|
137
148
|
const screen = String(view.state.screen ?? 'tasks');
|
package/remote.ts
CHANGED
|
@@ -11,10 +11,13 @@ import { editorText } from './remote/protocol.ts';
|
|
|
11
11
|
// Reload/resume/fork restore branch entries, never this map or a folder-global store.
|
|
12
12
|
const key = Symbol.for('pinote.remote.new-transfer.v1');
|
|
13
13
|
const transfers: Map<string, SavedState> = ((globalThis as any)[key] ??= new Map());
|
|
14
|
+
// Process-local launch ownership survives /reload, never resume in another process.
|
|
15
|
+
const launches: Map<string, number> = ((globalThis as any)[Symbol.for('pinote.remote.kitty-launch.v1')] ??= new Map());
|
|
14
16
|
|
|
15
17
|
export default function pinoteRemote(pi: ExtensionAPI): void {
|
|
16
18
|
let session: RemoteSession | undefined, ui: RemoteUI | undefined;
|
|
17
|
-
let file: string | undefined;
|
|
19
|
+
let file: string | undefined, launchKey = '';
|
|
20
|
+
const ownsLaunch = (taskId: number) => launches.get(launchKey) === taskId;
|
|
18
21
|
let launchRequest = process.env.PINOTE_LAUNCH_TASK;
|
|
19
22
|
delete process.env.PINOTE_LAUNCH_TASK; // Never inherit consent into another Pi process or /reload.
|
|
20
23
|
const getSession = () => { if (!session) throw new RemoteFailure(); return session; };
|
|
@@ -37,12 +40,14 @@ export default function pinoteRemote(pi: ExtensionAPI): void {
|
|
|
37
40
|
pi.registerCommand('pinote', { description: 'Open remote Pinote tasks and settings', handler: open });
|
|
38
41
|
const start = async (ctx: ExtensionContext, reason?: string, previous?: string) => {
|
|
39
42
|
const request = launchRequest; launchRequest = undefined; // Never replay an uncertain start.
|
|
43
|
+
if (reason !== 'reload') launches.clear();
|
|
40
44
|
ui?.detach(); await session?.stop();
|
|
41
45
|
ui = undefined; session = undefined;
|
|
42
46
|
try {
|
|
43
47
|
const config = remoteConfig();
|
|
44
48
|
session = new RemoteSession(config, (saved) => pi.appendEntry(ENTRY_TYPE, saved));
|
|
45
|
-
|
|
49
|
+
launchKey = `${config.scope}:${ctx.sessionManager.getSessionId()}`;
|
|
50
|
+
ui = new RemoteUI(pi, session, ownsLaunch);
|
|
46
51
|
ui.attach(ctx);
|
|
47
52
|
file = ctx.sessionManager.getSessionFile();
|
|
48
53
|
const branch = ctx.sessionManager.getBranch();
|
|
@@ -73,6 +78,7 @@ export default function pinoteRemote(pi: ExtensionAPI): void {
|
|
|
73
78
|
const control = view.controls.find((item) => item.id === `select:${launch.task_id}`);
|
|
74
79
|
if (!control) throw new Error('Launch task changed or belongs to another service');
|
|
75
80
|
await session.action(view, control); // The directory chooser's Start click granted consent.
|
|
81
|
+
if (session.view?.state.selectedId === launch.task_id) launches.set(launchKey, launch.task_id);
|
|
76
82
|
if (prompt?.trim() && ctx.mode === 'tui' && ctx.isIdle()) {
|
|
77
83
|
if (ctx.ui.getEditorText().length) ctx.ui.notify('Launch draft not inserted: the editor already contains a draft.', 'warning');
|
|
78
84
|
else ctx.ui.setEditorText(prompt);
|
|
@@ -86,7 +92,8 @@ export default function pinoteRemote(pi: ExtensionAPI): void {
|
|
|
86
92
|
};
|
|
87
93
|
pi.on('session_start', (event, ctx) => start(ctx, event.reason, event.previousSessionFile));
|
|
88
94
|
pi.on('session_tree', (_event, ctx) => start(ctx));
|
|
89
|
-
pi.on('session_shutdown', async () => {
|
|
95
|
+
pi.on('session_shutdown', async (event) => {
|
|
96
|
+
if (event.reason !== 'reload') launches.clear();
|
|
90
97
|
const saved = session?.snapshot();
|
|
91
98
|
if (file && saved?.retainOnNew) {
|
|
92
99
|
if (transfers.size >= 32) transfers.delete(transfers.keys().next().value!);
|
|
@@ -105,5 +112,5 @@ export default function pinoteRemote(pi: ExtensionAPI): void {
|
|
|
105
112
|
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`),
|
|
106
113
|
].join('\n\n');
|
|
107
114
|
});
|
|
108
|
-
registerRemoteExit(pi, () => session);
|
|
115
|
+
registerRemoteExit(pi, () => session, ownsLaunch);
|
|
109
116
|
}
|