pi-note 0.33.0 → 0.33.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
@@ -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. With an empty editor, **Ctrl+D**
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,17 @@ 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
+ Non-Git directories or branches without an upstream also stay open after the
100
+ failed pull. `/quit` and Exit without completing do not pull.
101
+ This ownership survives `/reload`, but not `/new`, resume, fork, tree navigation
102
+ or process restart. Completing a different task or an externally completed task
103
+ does not pull or close Pi; failed/uncertain completion stays open.
92
104
 
93
105
  ### Agent tools and context
94
106
 
@@ -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 task; keep this conversation and draft' } : {}) });
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 task; keep this conversation and draft' } : {}) });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.33.0",
3
+ "version": "0.33.1",
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
@@ -1,10 +1,26 @@
1
- import { CustomEditor, type ExtensionAPI } from '@earendil-works/pi-coding-agent';
1
+ import { CustomEditor, type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
2
2
  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
+ /** Completion is already committed: pull failures must never retry the task action. */
7
+ export async function pullBeforeExit(pi: ExtensionAPI, ctx: ExtensionContext, signal: AbortSignal, current: () => boolean): Promise<boolean> {
8
+ const sessionId = ctx.sessionManager.getSessionId(), branch = ctx.sessionManager.getLeafId();
9
+ const draft = ctx.ui.getEditorText();
10
+ const unchanged = () => current() && !signal.aborted && ctx.isIdle() &&
11
+ ctx.sessionManager.getSessionId() === sessionId && ctx.sessionManager.getLeafId() === branch && ctx.ui.getEditorText() === draft;
12
+ if (!unchanged()) return false;
13
+ ctx.ui.notify('Task completed. Running git pull --ff-only before closing Pi…', 'info');
14
+ try {
15
+ const result = await pi.exec('git', ['pull', '--ff-only', '--no-rebase', '--no-autostash'], { cwd: ctx.cwd, signal, timeout: 30_000 });
16
+ if (result.code === 0 && !result.killed && unchanged()) return true;
17
+ } catch { /* Completion succeeded; only the local pull failed. */ }
18
+ 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');
19
+ return false;
20
+ }
21
+
6
22
  /** Default remote companion. /quit and nonempty-editor exit input remain Pi-owned. */
7
- export function registerRemoteExit(pi: ExtensionAPI, getSession: () => RemoteSession | undefined): void {
23
+ export function registerRemoteExit(pi: ExtensionAPI, getSession: () => RemoteSession | undefined, ownsLaunch = (_taskId: number) => false): void {
8
24
  let invalidate: (() => void) | undefined;
9
25
  let stop: (() => void) | undefined;
10
26
  let confirm: ((reason: 'exit' | 'new') => Promise<boolean>) | undefined;
@@ -54,7 +70,12 @@ export function registerRemoteExit(pi: ExtensionAPI, getSession: () => RemoteSes
54
70
  // Approval belongs to this exact displayed view, never to a refreshed revision.
55
71
  await session.action(view, done, undefined, () => current() && ctx.isIdle() && ctx.sessionManager.getLeafId() === branch);
56
72
  if (!current()) return false;
57
- if (session.view && session.view.state.selectedId == null) return true;
73
+ if (session.view && session.view.state.selectedId == null) {
74
+ if (reason === 'exit' && typeof view.state.selectedId === 'number' && ownsLaunch(view.state.selectedId)) {
75
+ return await pullBeforeExit(pi, ctx, operation.signal, current);
76
+ }
77
+ return true;
78
+ }
58
79
  ctx.ui.notify('Pinote: Pi stayed in this session because the selection was not cleared. Refresh /pinote before retrying.', 'warning');
59
80
  } catch {
60
81
  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
- constructor(pi: ExtensionAPI, session: RemoteSession) {
69
- this.pi = pi; this.session = session;
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
- ui = new RemoteUI(pi, session);
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
  }