pi-note 0.32.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/LOCAL.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [npm](https://www.npmjs.com/package/pi-note) · [Pi package directory](https://pi.dev/packages/pi-note)
4
4
 
5
5
  Persistent [pinote](https://github.com/kvidzibo/pinote) tasks and Markdown handoffs
6
- in Pi: selected-task footer status, `/pi-note`, and agent current-task read/update,
6
+ in Pi: selected-task footer status, `/pinote`, and agent current-task read/update,
7
7
  add, and tag-listing tools.
8
8
  The extension and Python app share a repository but install separately.
9
9
 
@@ -30,8 +30,8 @@ Install [uv](https://docs.astral.sh/uv/getting-started/installation/) first and
30
30
  restart Pi with `uv` on PATH. At startup the extension checks `note --version`
31
31
  against its bundled CLI version (currently 0.4.0), without network requests:
32
32
 
33
- - Missing/unrecognized CLI: `/pi-note` opens Settings directly, where **Install CLI** is available.
34
- - Older CLI: `/pi-note` opens Settings directly, where **Upgrade CLI** is available.
33
+ - Missing/unrecognized CLI: `/pinote` opens Settings directly, where **Install CLI** is available.
34
+ - Older CLI: `/pinote` opens Settings directly, where **Upgrade CLI** is available.
35
35
  - Equal/newer CLI: neither installation action is shown; no upgrade notification.
36
36
 
37
37
  Install and upgrade recheck the CLI version and ask before installing into uv's
@@ -54,7 +54,7 @@ Check `note --version`, then restart Pi or `/reload`. Network/build failures and
54
54
  conflicting executables are reported without forcibly overwriting another
55
55
  installer's commands. Fix the reported cause and retry; installs time out after
56
56
  three minutes. Installation actions are unavailable while installation runs.
57
- Setup hints direct users to `/pi-note` → Settings.
57
+ Setup hints direct users to `/pinote` → Settings.
58
58
  Missing uv is reported with its installation link, not installed
59
59
  automatically. The [GTK desktop app](../README.md#optional-gtk-checklist) is optional
60
60
  and still requires separate system dependencies.
@@ -65,16 +65,16 @@ standalone `settings/pi/extensions/pinote.ts` entry if installed; load only one
65
65
 
66
66
  ## Use
67
67
 
68
- When the CLI is ready and no task is selected, `/pi-note` opens the task picker.
68
+ When the CLI is ready and no task is selected, `/pinote` opens the task picker.
69
69
  It marks the chosen task in progress and adds this default prompt to the editor:
70
70
  “Read the current Pinote task. Summarize your understanding, but don’t start work yet.”
71
- If the CLI is missing or older than the bundled version, `/pi-note` opens Settings
71
+ If the CLI is missing or older than the bundled version, `/pinote` opens Settings
72
72
  directly so the relevant CLI installation action remains accessible. **Tab** opens
73
73
  Settings from the picker or task view; **Tab** from Settings returns to tasks.
74
74
  The agent fetches current task text and saved handoff fields when you submit;
75
75
  they are not copied into the draft. The default prompt asks for a summary, not implementation.
76
76
  Existing drafts are preserved; nothing is submitted automatically.
77
- With a selected task, `/pi-note` offers **Continue**, **Done**, **Switch task**, and
77
+ With a selected task, `/pinote` offers **Continue**, **Done**, **Switch task**, and
78
78
  **Settings**. Settings provides **Preview task**, **Complete task (new session)**,
79
79
  **Accept suggestion**, and **Dismiss suggestion** when applicable, plus
80
80
  **Install CLI** or **Upgrade CLI** only when needed. Settings also lists fields found
@@ -170,7 +170,7 @@ and selected tasks. Run `/reload` after changing this setting. Invalid policy
170
170
  values or unreadable/malformed configuration suppress offers; interactive startup
171
171
  warns until you fix the file and reload. No project-local configuration is read.
172
172
 
173
- In `/pi-note → Settings`, select **Task prompt** to edit the text inserted after
173
+ In `/pinote → Settings`, select **Task prompt** to edit the text inserted after
174
174
  selection or **Continue**. **Shift+Enter** or **Ctrl+J** adds a newline; **Enter** saves it
175
175
  globally, **Ctrl+C** clears the edit, and **Esc** cancels that unfinished edit.
176
176
 
@@ -219,7 +219,7 @@ configuration only; project-local files are not read.
219
219
 
220
220
  Version 0.7.0 replaces `pinote_get`/`pinote_update` with these current-task tools
221
221
  and removes `pinote_tag`, without aliases. Agents cannot read/update tasks by ID
222
- or retag existing tasks; use `/pi-note` to select an existing task.
222
+ or retag existing tasks; use `/pinote` to select an existing task.
223
223
 
224
224
  When allowed by `taskOfferPolicy`, the agent uses `pinote_propose` instead of
225
225
  asking in chat. The pin footer shows a pending suggestion, distinct from a selected task:
@@ -242,7 +242,7 @@ The configured `footer.titleWidth` bounds the entire row (default 60 columns). C
242
242
 
243
243
  Controls use the [Kitty preview handler](#display-only-preview), with no additional configuration. Fullscreen Kitty uses **Ctrl+Shift+click**. The Settings actions **Accept suggestion** and **Dismiss suggestion** perform the corresponding footer actions. No action submits input or starts an agent turn. Add and Dismiss preserve editor drafts; **Complete task** starts a clean session and reloads Pi, discarding the editor draft, so save unfinished input before completing a task.
244
244
 
245
- Completion requires an idle Pi session and no pending Pinote operation. It rejects stale selected-task links and externally changed revisions. Accepted mutations drain even if the helper disconnects. On an error, check `/pi-note` before retrying, because a write may already have committed. Failed or stale writes never restart Pi. If another extension cancels the new session, the task remains completed and Pi reports the cancellation. The reload runs through the new session's fresh context; retired contexts are not reused.
245
+ Completion requires an idle Pi session and no pending Pinote operation. It rejects stale selected-task links and externally changed revisions. Accepted mutations drain even if the helper disconnects. On an error, check `/pinote` before retrying, because a write may already have committed. Failed or stale writes never restart Pi. If another extension cancels the new session, the task remains completed and Pi reports the cancellation. The reload runs through the new session's fresh context; retired contexts are not reused.
246
246
 
247
247
  If another task fits better, the agent can replace an unaccepted suggestion by calling `pinote_propose` again, without waiting for dismissal. Replacement invalidates the old ✚/✖ links; only the latest suggestion can be accepted or dismissed. Pending suggestions survive `/reload` and session resume, including their full text and tag; new links replace stale capabilities. Accepted or dismissed suggestions never reappear on reload, but dismissal does not block new proposals—even in sessions saved by older versions. New sessions and forks start without a suggestion; selecting a task or navigating the tree clears it. Suggestion state is local, display-only session metadata, not model context. Pi saves the session after the first submitted message; quitting before that can still lose unsaved session state. Repeated acceptance cannot duplicate a note. A busy Pinote operation blocks acceptance. Noninteractive agents still ask in chat before using `pinote_add` with `select: true`. An existing selection is not replaced unless the user asks to switch. Reuse a saved
248
248
  tag name when it fits.
@@ -310,7 +310,7 @@ Names are case-sensitive, trimmed and NFC-normalized, at most 64 characters;
310
310
  accepted. Missing/blank values do not display and do not consume the field limit.
311
311
  `[]` or `maxFields: 0` hides configured fields, including PR.
312
312
 
313
- In `/pi-note → Settings`, **Add field** adds a global display definition, not a
313
+ In `/pinote → Settings`, **Add field** adds a global display definition, not a
314
314
  task value. Select any field to edit **Link**, **Label**, **Format**, and **Width**.
315
315
  Label starts as the field name; clear it for no prefix. Link off displays plain
316
316
  text without a clickable link. Toggles, field additions/removals, and edits confirmed
@@ -369,7 +369,7 @@ Pinote no longer polls GitHub, runs `gh`, watches completed tasks, or prompts on
369
369
  PR merges. No GitHub authentication is required. `PINOTE_PR_POLL_SECONDS` is no
370
370
  longer used. Other separately installed Pi PR/Git extensions are unaffected.
371
371
 
372
- Tools work without a TUI. `/pi-note` works in a TUI while the agent is running:
372
+ Tools work without a TUI. `/pinote` works in a TUI while the agent is running:
373
373
  you can select/switch tasks, use Continue, preview tasks, respond to suggestions,
374
374
  and edit settings without interrupting the turn or submitting input. Completion
375
375
  and CLI installation/upgrades still require an idle session. Another pending
package/README.md CHANGED
@@ -48,7 +48,7 @@ state routing, tools and UI.
48
48
 
49
49
  ## Use
50
50
 
51
- Open **`/pinote`** (`/pi-note` is an alias). Select or create a task, preview it,
51
+ Open **`/pinote`**. Select or create a task, preview it,
52
52
  clear the selection, complete it, respond to suggestions, or edit shared settings.
53
53
  Selecting an existing task starts it immediately and returns focus to the prompt,
54
54
  with no extra confirmation. Creation and settings changes still require confirmation.
@@ -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.
@@ -77,6 +78,8 @@ cannot guarantee selection persistence.
77
78
 
78
79
  The GTK app's **Start in Kitty…** action (note 0.13.0+) chooses a working directory
79
80
  and opens a named tab with this task selected, without submitting a prompt.
81
+ In note 0.16.0+, it uses the tag's default folder when available, otherwise keeps
82
+ the directory chooser. Set or clear defaults in **Manage tags…**.
80
83
  With note 0.15.0+ and pi-note 0.32.0+, successful selection prefills an empty Pi editor
81
84
  with `is task clear?`. Configure `gui.kitty_start_prompt` in the
82
85
  [GUI config](../docs/gui.md#gui-configuration), or set it to `""` to disable; existing
@@ -87,6 +90,17 @@ client-local and is never sent to the service or persisted as launch state.
87
90
  Only a fresh session can consume it. The service must share the GUI's database;
88
91
  missing/stale revisions fail closed. Resume, reload and `/new` never replay the
89
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.
90
104
 
91
105
  ### Agent tools and context
92
106
 
@@ -121,6 +135,10 @@ separate from global display settings.
121
135
 
122
136
  ## Migration
123
137
 
138
+ Since pi-note 0.33.0, `/pinote` is the only slash command in both modes.
139
+ The `/pi-note` alias is removed; update saved shortcuts to `/pinote`.
140
+ The npm package name and `pi-note.json` configuration filename are unchanged.
141
+
124
142
  1. Back up the service machine's Pinote data; keep `notes.db` local to that machine.
125
143
  2. Start the standalone service against that same database and settings directory.
126
144
  3. Remove Pinote's service entry and its unused package entry from the gateway
@@ -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/index.ts CHANGED
@@ -107,7 +107,7 @@ function task(raw: string): Task | null {
107
107
  }
108
108
  function requiredTask(raw: string, id?: number): Task {
109
109
  const value = task(raw);
110
- if (!value) throw new Error("No selected pinote task. Select one with /pi-note first.");
110
+ if (!value) throw new Error("No selected pinote task. Select one with /pinote first.");
111
111
  if (id !== undefined && value.id !== id) throw new Error(compatible);
112
112
  return value;
113
113
  }
@@ -187,7 +187,7 @@ export default function (pi: ExtensionAPI) {
187
187
  if (!alive || generation !== epoch || setupAbort) return;
188
188
  cliState = action;
189
189
  if (suggest && action === "upgrade") {
190
- ctx.ui.notify(`Pinote CLI ${bundledCLIVersion} is bundled with this extension. Open /pi-note → Settings → Upgrade CLI to update your older CLI.`, "info");
190
+ ctx.ui.notify(`Pinote CLI ${bundledCLIVersion} is bundled with this extension. Open /pinote → Settings → Upgrade CLI to update your older CLI.`, "info");
191
191
  }
192
192
  };
193
193
 
@@ -314,7 +314,7 @@ export default function (pi: ExtensionAPI) {
314
314
  if (alive && generation === epoch && serial === refreshSerial) {
315
315
  previewBridge?.invalidate();
316
316
  clearTaskFooter(ctx);
317
- ctx.ui.setStatus("pinote", suggestionStatus(ctx) ?? `${ctx.ui.theme.bold(noteIcon)} Pinote unavailable · /pi-note → Settings`);
317
+ ctx.ui.setStatus("pinote", suggestionStatus(ctx) ?? `${ctx.ui.theme.bold(noteIcon)} Pinote unavailable · /pinote → Settings`);
318
318
  }
319
319
  }
320
320
  };
@@ -357,10 +357,10 @@ export default function (pi: ExtensionAPI) {
357
357
  };
358
358
  const timer = setTimeout(() => {
359
359
  finish(false);
360
- if (alive && activeContext === ctx) ctx.ui.notify("Pinote completion could not start. Open /pi-note → Settings → Complete task.", "warning");
360
+ if (alive && activeContext === ctx) ctx.ui.notify("Pinote completion could not start. Open /pinote → Settings → Complete task.", "warning");
361
361
  }, 4000);
362
362
  completionDispatch = { token, selection, task: displayed, started: () => clearTimeout(timer), finish };
363
- try { pi.sendUserMessage(`/pi-note ${token}`, { expandPromptTemplates: true }); }
363
+ try { pi.sendUserMessage(`/pinote ${token}`, { expandPromptTemplates: true }); }
364
364
  catch { finish(false); }
365
365
  });
366
366
  };
@@ -454,7 +454,7 @@ export default function (pi: ExtensionAPI) {
454
454
  } catch {
455
455
  await bridge.stop();
456
456
  if (previewBridge === bridge) previewBridge = undefined;
457
- ctx.ui.notify("Pinote task link unavailable; open /pi-note → Settings → Preview task.", "warning");
457
+ ctx.ui.notify("Pinote task link unavailable; open /pinote → Settings → Preview task.", "warning");
458
458
  }
459
459
  }
460
460
  if (!alive || generation !== epoch) return;
@@ -553,7 +553,7 @@ export default function (pi: ExtensionAPI) {
553
553
  }
554
554
  };
555
555
 
556
- pi.registerCommand("pi-note", {
556
+ pi.registerCommand("pinote", {
557
557
  description: "Continue, complete, switch tasks, or edit global settings (Tab)",
558
558
  handler: async (args, ctx) => {
559
559
  if (!ctx.hasUI || ctx.mode !== "tui") return;
@@ -564,7 +564,7 @@ export default function (pi: ExtensionAPI) {
564
564
  await completeSelected(token, ctx);
565
565
  return;
566
566
  }
567
- if (token) { ctx.ui.notify("Usage: /pi-note", "warning"); return; }
567
+ if (token) { ctx.ui.notify("Usage: /pinote", "warning"); return; }
568
568
  if (pending || menuPending) {
569
569
  ctx.ui.notify("Wait until the pinote operation has finished.", "warning");
570
570
  return;
@@ -724,7 +724,7 @@ export default function (pi: ExtensionAPI) {
724
724
  const canRun = () => alive && generation === epoch && branch === branchEpoch && selectedId === id;
725
725
  const current = await selected(ctx, signal);
726
726
  if (!canRun()) throw new Error("Pinote operation cancelled: the session or selected task changed.");
727
- if (!current) throw new Error("No selected pinote task. Select one with /pi-note first.");
727
+ if (!current) throw new Error("No selected pinote task. Select one with /pinote first.");
728
728
  const argv = ["agent", "update", String(current.id), "--expected-updated-at", params.expected_updated_at,
729
729
  "--set-json", JSON.stringify(params.set ?? {})];
730
730
  for (const label of params.remove ?? []) argv.push(`--remove=${label}`);
@@ -789,7 +789,7 @@ export default function (pi: ExtensionAPI) {
789
789
  return true;
790
790
  } catch (error) {
791
791
  if (alive && generation === epoch) ctx.ui.notify(
792
- `Pinote suggestion: ${clean(String(error))}${created ? ` Note #${created.id} was created; use /pi-note to select it. Do not add it again.` : " A write may have committed; check /pi-note before retrying."}`, "error");
792
+ `Pinote suggestion: ${clean(String(error))}${created ? ` Note #${created.id} was created; use /pinote to select it. Do not add it again.` : " A write may have committed; check /pinote before retrying."}`, "error");
793
793
  return false;
794
794
  } finally {
795
795
  if (pending === operation) pending = undefined;
@@ -854,10 +854,10 @@ export default function (pi: ExtensionAPI) {
854
854
  clearSuggestion(ctx);
855
855
  try {
856
856
  const created = requiredTask(await run(argv, signal, canAct, "0.4.0"));
857
- if (!canAct()) throw new Error("Pinote operation cancelled: the session changed. The note was created; check /pi-note before retrying.");
857
+ if (!canAct()) throw new Error("Pinote operation cancelled: the session changed. The note was created; check /pinote before retrying.");
858
858
  const chosen = await startTask(created.id, canAct, signal);
859
859
  if (!chosen || !alive || generation !== epoch || branch !== branchEpoch || selectedId !== chosen.id) {
860
- throw new Error("Pinote operation cancelled: the session changed. The note may have started; check /pi-note before retrying.");
860
+ throw new Error("Pinote operation cancelled: the session changed. The note may have started; check /pinote before retrying.");
861
861
  }
862
862
  return toolResult(chosen);
863
863
  } finally {
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.32.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; };
@@ -35,15 +38,16 @@ export default function pinoteRemote(pi: ExtensionAPI): void {
35
38
  await ui.open(ctx);
36
39
  };
37
40
  pi.registerCommand('pinote', { description: 'Open remote Pinote tasks and settings', handler: open });
38
- pi.registerCommand('pi-note', { description: 'Open remote Pinote (compatibility alias)', handler: open });
39
41
  const start = async (ctx: ExtensionContext, reason?: string, previous?: string) => {
40
42
  const request = launchRequest; launchRequest = undefined; // Never replay an uncertain start.
43
+ if (reason !== 'reload') launches.clear();
41
44
  ui?.detach(); await session?.stop();
42
45
  ui = undefined; session = undefined;
43
46
  try {
44
47
  const config = remoteConfig();
45
48
  session = new RemoteSession(config, (saved) => pi.appendEntry(ENTRY_TYPE, saved));
46
- ui = new RemoteUI(pi, session);
49
+ launchKey = `${config.scope}:${ctx.sessionManager.getSessionId()}`;
50
+ ui = new RemoteUI(pi, session, ownsLaunch);
47
51
  ui.attach(ctx);
48
52
  file = ctx.sessionManager.getSessionFile();
49
53
  const branch = ctx.sessionManager.getBranch();
@@ -74,6 +78,7 @@ export default function pinoteRemote(pi: ExtensionAPI): void {
74
78
  const control = view.controls.find((item) => item.id === `select:${launch.task_id}`);
75
79
  if (!control) throw new Error('Launch task changed or belongs to another service');
76
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);
77
82
  if (prompt?.trim() && ctx.mode === 'tui' && ctx.isIdle()) {
78
83
  if (ctx.ui.getEditorText().length) ctx.ui.notify('Launch draft not inserted: the editor already contains a draft.', 'warning');
79
84
  else ctx.ui.setEditorText(prompt);
@@ -87,7 +92,8 @@ export default function pinoteRemote(pi: ExtensionAPI): void {
87
92
  };
88
93
  pi.on('session_start', (event, ctx) => start(ctx, event.reason, event.previousSessionFile));
89
94
  pi.on('session_tree', (_event, ctx) => start(ctx));
90
- pi.on('session_shutdown', async () => {
95
+ pi.on('session_shutdown', async (event) => {
96
+ if (event.reason !== 'reload') launches.clear();
91
97
  const saved = session?.snapshot();
92
98
  if (file && saved?.retainOnNew) {
93
99
  if (transfers.size >= 32) transfers.delete(transfers.keys().next().value!);
@@ -106,5 +112,5 @@ export default function pinoteRemote(pi: ExtensionAPI): void {
106
112
  (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`),
107
113
  ].join('\n\n');
108
114
  });
109
- registerRemoteExit(pi, () => session);
115
+ registerRemoteExit(pi, () => session, ownsLaunch);
110
116
  }
package/setup.ts CHANGED
@@ -4,7 +4,7 @@ import { stripVTControlCharacters } from "node:util";
4
4
  // Immutable source: the Python app is not yet published to PyPI.
5
5
  export const cliSource = "https://github.com/kvidzibo/pinote/archive/1597baf60bff8bc96893be40781f152a001623d1.tar.gz";
6
6
  export const bundledCLIVersion = "0.4.0";
7
- export const setupHint = "Open /pi-note → Settings to install or upgrade the CLI; note must be on PATH.";
7
+ export const setupHint = "Open /pinote → Settings to install or upgrade the CLI; note must be on PATH.";
8
8
  export type CLIAction = "setup" | "upgrade" | "ready";
9
9
 
10
10
  export function cliAction(version: string): CLIAction {
@@ -51,10 +51,10 @@ export async function setupCLI(
51
51
  const action = await detectCLI(pi, signal);
52
52
  if (!canAct() || signal.aborted) return;
53
53
  if (action === "ready") {
54
- ctx.ui.notify(`Pinote CLI is ready (bundled version ${bundledCLIVersion} or newer). Use /pi-note.`, "info");
54
+ ctx.ui.notify(`Pinote CLI is ready (bundled version ${bundledCLIVersion} or newer). Use /pinote.`, "info");
55
55
  return;
56
56
  }
57
- const command = `/pi-note → Settings → ${action === "upgrade" ? "Upgrade CLI" : "Install CLI"}`;
57
+ const command = `/pinote → Settings → ${action === "upgrade" ? "Upgrade CLI" : "Install CLI"}`;
58
58
  if (upgrade !== (action === "upgrade")) {
59
59
  ctx.ui.notify(`Use ${command} ${action === "upgrade" ? "to upgrade the older CLI" : "to install the missing CLI"}.`, "info");
60
60
  return;
@@ -88,7 +88,7 @@ export async function setupCLI(
88
88
  try {
89
89
  const version = await exec("note", ["--version"]);
90
90
  if (version.killed || version.code !== 0 || cliAction(version.stdout) !== "ready") throw new Error("note unavailable or older than bundled CLI");
91
- if (canAct()) ctx.ui.notify(`${clean(version.stdout.trim())} is ready. Use /pi-note. GTK is optional and installed separately.`, "info");
91
+ if (canAct()) ctx.ui.notify(`${clean(version.stdout.trim())} is ready. Use /pinote. GTK is optional and installed separately.`, "info");
92
92
  } catch {
93
93
  if (canAct()) ctx.ui.notify("Pinote installed, but note on PATH is missing or older than the bundled CLI. Run uv tool dir --bin in your terminal, add that directory to PATH before older note executables, then restart Pi.", "warning");
94
94
  }