pi-note 0.17.1 → 0.18.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
@@ -84,7 +84,7 @@ The agent can still read the task separately with `pinote_get_current`.
84
84
  Click previews work while the agent is running, provided no other Pinote operation
85
85
  is open. Each click saves a fresh snapshot in the transcript, above any currently
86
86
  streaming response. Later output scrolls it upward; it does not expire. Preview
87
- reads never lock out the agent's task updates. Settings still requires Pi to be idle.
87
+ reads never lock out the agent's task updates. Settings also works during a running turn.
88
88
 
89
89
  ![Preview above a running response](../docs/images/pi-note-active-preview.png)
90
90
 
@@ -112,8 +112,11 @@ are rejected. The **Preview task** Settings action is also available when the
112
112
  socket cannot start; the task remains visible without a link.
113
113
 
114
114
  Each Pi session remembers its own task, including several sessions in one folder.
115
- Resume restores that session's task; a new session starts unselected and does not import
116
- an older per-folder selection. Pi saves the session after the first submitted message;
115
+ Resume restores that session's task. `/new` keeps the current task selected and
116
+ prepopulates the editor with `newSessionPrompt`; nothing is submitted automatically.
117
+ A fresh Pi launch starts unselected and does not import an older per-folder selection.
118
+ Completion still starts a clean, unselected session. Retention requires normal
119
+ file-backed Pi sessions; `--no-session` does not retain the selection across `/new`. Pi saves the session after the first submitted message;
117
120
  quitting before that leaves the next launch unselected, while the task stays in progress.
118
121
  Use **Continue**
119
122
  to insert the same configured prompt. Completing, removing, or scheduling a task clears it
@@ -131,6 +134,7 @@ Configure `~/.pi/agent/pi-note.json` (or the agent directory set by
131
134
  ```json
132
135
  {
133
136
  "handoffPrompt": "Read the current Pinote task. Summarize your understanding and proposed approach, but don’t start work yet.",
137
+ "newSessionPrompt": "Read the current Pinote task. Summarize your understanding and proposed approach, but don’t start work yet.",
134
138
  "taskOfferPolicy": "github-remote"
135
139
  }
136
140
  ```
@@ -158,6 +162,13 @@ warns until you fix the file and reload. No project-local configuration is read.
158
162
  In `/pi-note → Settings`, select **Task prompt** to edit the text inserted after
159
163
  selection or **Continue**. **Shift+Enter** or **Ctrl+J** adds a newline; **Enter** saves it
160
164
  globally, **Ctrl+C** clears the edit, and **Esc** cancels that unfinished edit.
165
+
166
+ **New session prompt** edits the separate draft inserted by `/new` when a task is
167
+ retained. Its default matches the built-in task prompt; an empty/whitespace-only
168
+ value disables insertion without clearing the task. `/new` rereads it each time,
169
+ so changes need no `/reload`. Invalid configuration warns and leaves the retained
170
+ task selected without inserting a prompt. Both prompts are literal text, not templates.
171
+
161
172
  Settings and fields save automatically after each confirmed change; no separate
162
173
  Save action is needed. Leaving Settings with **Esc** or **Tab** keeps saved changes
163
174
  and discards only unfinished input. Saving never changes existing editor input or submits anything.
@@ -309,7 +320,7 @@ String definitions and the older `{ "label": "Next", "width": 24 }` form still
309
320
  work. Absent/`null` fields retain the legacy automatic PR link plus `Bar` fields;
310
321
  the first Settings save turns this legacy list into explicit global definitions.
311
322
 
312
- Settings saves apply immediately in this Pi session. Run `/reload` after manual
323
+ Settings saves include both prompts and apply immediately in this Pi session. Run `/reload` after manual
313
324
  footer edits or in other running sessions; settings are also reread on session start.
314
325
  Invalid footer configuration warns and uses footer defaults; missing files silently
315
326
  use defaults. The task prompt is also read separately on selection/Continue as
@@ -347,7 +358,11 @@ Pinote no longer polls GitHub, runs `gh`, watches completed tasks, or prompts on
347
358
  PR merges. No GitHub authentication is required. `PINOTE_PR_POLL_SECONDS` is no
348
359
  longer used. Other separately installed Pi PR/Git extensions are unaffected.
349
360
 
350
- Tools work without a TUI, but `/pi-note` needs an idle TUI.
361
+ Tools work without a TUI. `/pi-note` works in a TUI while the agent is running:
362
+ you can select/switch tasks, use Continue, preview tasks, respond to suggestions,
363
+ and edit settings without interrupting the turn or submitting input. Completion
364
+ and CLI installation/upgrades still require an idle session. Another pending
365
+ Pinote operation blocks the menu. Open menus do not block agent handoff updates.
351
366
  This package does not synchronize databases or paths between machines. Note text
352
367
  and fields loaded into Pi are sent to the configured model when used as context;
353
368
  avoid secrets. Agent commands do not send desktop notifications.
package/footer-config.ts CHANGED
@@ -4,13 +4,20 @@ import { join } from "node:path";
4
4
  import { randomUUID } from "node:crypto";
5
5
 
6
6
  export const defaultHandoffPrompt = "Read the current Pinote task. Summarize your understanding, but don’t start work yet.";
7
+ export const defaultNewSessionPrompt = defaultHandoffPrompt;
8
+ export function parseNewSessionPrompt(value: unknown): string {
9
+ if (typeof value !== "string" || /[\x00-\x08\x0b-\x1f\x7f-\x9f]/u.test(value)) {
10
+ throw new Error("newSessionPrompt in pi-note.json must be a string without control characters (except tabs/newlines).");
11
+ }
12
+ return value;
13
+ }
7
14
  export function parseHandoffPrompt(value: unknown): string {
8
15
  if (typeof value !== "string" || !value.trim() || /[\x00-\x08\x0b-\x1f\x7f-\x9f]/u.test(value)) {
9
16
  throw new Error("handoffPrompt in pi-note.json must be a nonblank string without control characters (except tabs/newlines).");
10
17
  }
11
18
  return value;
12
19
  }
13
- export type PinoteSettings = { footer: FooterConfig; handoffPrompt: string };
20
+ export type PinoteSettings = { footer: FooterConfig; handoffPrompt: string; newSessionPrompt?: string };
14
21
 
15
22
  export type FooterField = { name: string; label: string; link: boolean; format: string; width?: number };
16
23
  export type FooterConfig = { titleWidth: number; fieldWidth: number; maxFields: number; fields: FooterField[] | null };
@@ -90,9 +97,12 @@ export function loadFooterConfig(warn: (message: string) => void): FooterConfig
90
97
 
91
98
  // Synchronous compare-and-replace: reject edits made while the settings dialog was open,
92
99
  // preserve unrelated keys, and never leave partial JSON behind.
93
- export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string) {
100
+ export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string, newSessionPrompt?: string) {
94
101
  const checked = parseFooterConfig({ footer: config });
95
- const prompt = handoffPrompt === undefined ? {} : { handoffPrompt: parseHandoffPrompt(handoffPrompt) };
102
+ const prompts = {
103
+ ...(handoffPrompt === undefined ? {} : { handoffPrompt: parseHandoffPrompt(handoffPrompt) }),
104
+ ...(newSessionPrompt === undefined ? {} : { newSessionPrompt: parseNewSessionPrompt(newSessionPrompt) }),
105
+ };
96
106
  const directory = getAgentDir();
97
107
  mkdirSync(directory, { recursive: true });
98
108
  const path = join(directory, "pi-note.json");
@@ -107,7 +117,7 @@ export function saveFooterConfig(config: FooterConfig, expectedRaw: string | nul
107
117
  if (lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error("pi-note.json is a symlink. Edit its target manually rather than replacing the link.");
108
118
  const root = expectedRaw === null ? {} : JSON.parse(expectedRaw);
109
119
  if (!object(root)) throw new Error("Invalid pi-note configuration; expected an object.");
110
- raw = `${JSON.stringify({ ...root, ...prompt, footer: checked }, null, 2)}\n`;
120
+ raw = `${JSON.stringify({ ...root, ...prompts, footer: checked }, null, 2)}\n`;
111
121
  writeFileSync(temp, raw, { flag: "wx", mode: 0o600 });
112
122
  renameSync(temp, path);
113
123
  } finally { rmSync(temp, { force: true }); closeSync(descriptor); rmSync(lock, { force: true }); }
@@ -1,6 +1,6 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Editor, Input, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
- import { defaultFooterField, parseFooterConfig, parseHandoffPrompt, type PinoteSettings, type FooterConfig, type FooterField } from "./footer-config.ts";
3
+ import { defaultFooterField, defaultNewSessionPrompt, parseFooterConfig, parseHandoffPrompt, parseNewSessionPrompt, type PinoteSettings, type FooterConfig, type FooterField } from "./footer-config.ts";
4
4
 
5
5
  type Matches = (data: string, action: string) => boolean;
6
6
  type Row = { label: string; action: () => void };
@@ -42,7 +42,9 @@ export class TaskMenu {
42
42
  export class FooterSettings {
43
43
  private draft: FooterConfig;
44
44
  private prompt: string;
45
+ private newSessionPrompt: string;
45
46
  private editor?: Editor;
47
+ private editorMode?: "handoff" | "newSession";
46
48
  private createEditor: () => Editor;
47
49
  private knownFields: string[];
48
50
  private theme: Theme;
@@ -65,6 +67,7 @@ export class FooterSettings {
65
67
  this.configurationError = configurationError;
66
68
  this.draft = structuredClone(config.footer);
67
69
  this.prompt = config.handoffPrompt;
70
+ this.newSessionPrompt = config.newSessionPrompt === undefined ? defaultNewSessionPrompt : parseNewSessionPrompt(config.newSessionPrompt);
68
71
  this.createEditor = createEditor;
69
72
  this.save = save;
70
73
  this.draft.fields ??= [];
@@ -86,11 +89,13 @@ export class FooterSettings {
86
89
  private validate(field: FooterField): FooterField {
87
90
  return parseFooterConfig({ footer: { fields: [field] } }).fields![0];
88
91
  }
89
- private persist(footer = this.draft, handoffPrompt = this.prompt) {
90
- const settings = { footer: parseFooterConfig({ footer }), handoffPrompt: parseHandoffPrompt(handoffPrompt) };
92
+ private persist(footer = this.draft, handoffPrompt = this.prompt, newSessionPrompt = this.newSessionPrompt) {
93
+ const settings = { footer: parseFooterConfig({ footer }), handoffPrompt: parseHandoffPrompt(handoffPrompt),
94
+ newSessionPrompt: parseNewSessionPrompt(newSessionPrompt) };
91
95
  this.save(structuredClone(settings));
92
96
  this.draft = settings.footer;
93
97
  this.prompt = settings.handoffPrompt;
98
+ this.newSessionPrompt = settings.newSessionPrompt;
94
99
  }
95
100
  private persistField(field: FooterField) {
96
101
  const candidate = this.validate(field);
@@ -119,11 +124,19 @@ export class FooterSettings {
119
124
  }),
120
125
  }));
121
126
  rows.push({ label: `Task prompt: ${display(this.prompt)}`, action: () => {
127
+ this.editorMode = "handoff";
122
128
  this.editor = this.createEditor();
123
129
  this.editor.disableSubmit = true;
124
130
  this.editor.setText(this.prompt);
125
131
  this.editor.focused = this.hasFocus;
126
132
  } });
133
+ rows.push({ label: `New session prompt: ${display(this.newSessionPrompt).trim() || "(disabled)"}`, action: () => {
134
+ this.editorMode = "newSession";
135
+ this.editor = this.createEditor();
136
+ this.editor.disableSubmit = true;
137
+ this.editor.setText(this.newSessionPrompt);
138
+ this.editor.focused = this.hasFocus;
139
+ } });
127
140
  const configured = this.draft.fields!.map((field) => field.name);
128
141
  for (const name of [...configured, ...this.knownFields.filter((name) => !configured.includes(name))]) {
129
142
  rows.push({ label: `Field: ${display(name)}${configured.includes(name) ? "" : " (not configured)"}`, action: () => this.edit(name) });
@@ -168,11 +181,13 @@ export class FooterSettings {
168
181
  if (isTab(data)) this.done("tasks");
169
182
  else if (this.editor) {
170
183
  if (matchesKey(data, "ctrl+c")) this.editor.setText("");
171
- else if (this.matches(data, "tui.select.cancel")) this.editor = undefined;
184
+ else if (this.matches(data, "tui.select.cancel")) { this.editor = undefined; this.editorMode = undefined; }
172
185
  else if (this.matches(data, "tui.input.newLine")) this.editor.handleInput(data);
173
186
  else if (this.matches(data, "tui.select.confirm")) {
174
- this.persist(this.draft, this.editor.getExpandedText());
187
+ if (this.editorMode === "newSession") this.persist(this.draft, this.prompt, this.editor.getExpandedText());
188
+ else this.persist(this.draft, this.editor.getExpandedText(), this.newSessionPrompt);
175
189
  this.editor = undefined;
190
+ this.editorMode = undefined;
176
191
  } else this.editor.handleInput(data);
177
192
  } else if (this.input) {
178
193
  if (this.matches(data, "tui.select.cancel")) this.input = undefined;
@@ -197,7 +212,7 @@ export class FooterSettings {
197
212
  const lines = [this.theme.fg("accent", "Pinote — Global Settings"), this.theme.fg("dim", "Tasks (Tab) · Settings · Fields")];
198
213
  if (this.configurationError) lines.push(this.theme.fg("warning", this.configurationError));
199
214
  if (this.editor) {
200
- lines.push(this.theme.fg("accent", "Task prompt"), ...this.editor.render(width),
215
+ lines.push(this.theme.fg("accent", this.editorMode === "newSession" ? "New session prompt" : "Task prompt"), ...this.editor.render(width),
201
216
  this.theme.fg("dim", "Enter save · Shift+Enter/Ctrl+J newline · Ctrl+C clear · Esc cancel edit"));
202
217
  } else if (this.input) {
203
218
  lines.push(this.theme.fg("accent", this.input.heading), ...this.input.widget.render(width),
package/index.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from "node:path";
6
6
  import { stripVTControlCharacters } from "node:util";
7
7
  import { Type } from "typebox";
8
8
  import { updateTaskFooter, clearTaskFooter } from "./footer-status.ts";
9
- import { defaultFooterConfig, defaultHandoffPrompt, effectiveFooterFields, loadFooterConfig, parseHandoffPrompt, readFooterDocument, saveFooterConfig, type FooterConfig, type PinoteSettings } from "./footer-config.ts";
9
+ import { defaultFooterConfig, defaultHandoffPrompt, defaultNewSessionPrompt, effectiveFooterFields, loadFooterConfig, parseHandoffPrompt, parseNewSessionPrompt, readFooterDocument, saveFooterConfig, type FooterConfig, type PinoteSettings } from "./footer-config.ts";
10
10
  import { FooterSettings, TaskMenu, withSettingsTab, type SettingsOption, type SettingsResult } from "./footer-settings.ts";
11
11
  import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
12
12
  import { bundledCLIVersion, detectCLI, setupCLI, setupHint, versionAtLeast, type CLIAction } from "./setup.ts";
@@ -36,6 +36,13 @@ type Summary = Pick<Task, "id" | "text" | "state" | "tag">;
36
36
  // The standard Pi footer accepts text, so ship a portable terminal glyph, not a theme icon.
37
37
  const noteIcon = readFileSync(new URL("./icons/note.txt", import.meta.url), "utf8").trim();
38
38
  const previewType = "pinote-preview";
39
+ // /new replaces the extension runtime, even before the outgoing session is saved.
40
+ // Transfer only the ID, keyed by the exact destination session file, within this process.
41
+ const continuationKey = Symbol.for("pi-note.new-session-selections");
42
+ const processState = globalThis as typeof globalThis & {
43
+ [continuationKey]?: Map<string, number>;
44
+ };
45
+ const continuations = processState[continuationKey] ??= new Map<string, number>();
39
46
  const compatible = "Incompatible note CLI response. Install pinote 0.3.0+ and check note on PATH.";
40
47
  const validId = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0;
41
48
  const record = (value: unknown): value is Record<string, unknown> =>
@@ -162,6 +169,7 @@ export default function (pi: ExtensionAPI) {
162
169
  let epoch = 0;
163
170
  let refreshSerial = 0;
164
171
  let pending: symbol | undefined;
172
+ let menuPending: symbol | undefined;
165
173
  let previewPending = false;
166
174
  let setupAbort: AbortController | undefined;
167
175
  let activeContext: ExtensionContext | undefined;
@@ -311,7 +319,7 @@ export default function (pi: ExtensionAPI) {
311
319
  }
312
320
  };
313
321
  const preview = async (ctx: ExtensionContext, requestSignal?: AbortSignal): Promise<boolean> => {
314
- if (!ctx.hasUI || ctx.mode !== "tui" || pending || previewPending || !validId(selectedId)) return false;
322
+ if (!ctx.hasUI || ctx.mode !== "tui" || pending || menuPending || previewPending || !validId(selectedId)) return false;
315
323
  const generation = epoch;
316
324
  const branch = branchEpoch;
317
325
  const id = selectedId;
@@ -339,7 +347,7 @@ export default function (pi: ExtensionAPI) {
339
347
  }
340
348
  };
341
349
  const dispatchCompletion = (ctx: ExtensionContext, displayed: Task, selection: string): Promise<boolean> => {
342
- if (!ctx.isIdle() || pending || completionDispatch) return Promise.resolve(false);
350
+ if (!ctx.isIdle() || pending || menuPending || completionDispatch) return Promise.resolve(false);
343
351
  const token = `${selection}:${randomBytes(8).toString("hex")}`;
344
352
  return new Promise((resolve) => {
345
353
  const finish = (accepted: boolean) => {
@@ -360,7 +368,7 @@ export default function (pi: ExtensionAPI) {
360
368
  const token = args.trim();
361
369
  const request = token ? completionDispatch : undefined;
362
370
  if (token && (request?.token !== token || request.selection !== `${runtimeToken}:${epoch}:${branchEpoch}:${selectedId}`)) return;
363
- if (!alive || !ctx.hasUI || ctx.mode !== "tui" || !ctx.isIdle() || pending || setupAbort || !validId(selectedId)) {
371
+ if (!alive || !ctx.hasUI || ctx.mode !== "tui" || !ctx.isIdle() || pending || menuPending || setupAbort || !validId(selectedId)) {
364
372
  request?.finish(false);
365
373
  if (ctx.hasUI) ctx.ui.notify("Select a task and wait until Pi is idle and the Pinote operation has finished.", "warning");
366
374
  return;
@@ -420,6 +428,11 @@ export default function (pi: ExtensionAPI) {
420
428
  alive = true;
421
429
  activeContext = ctx;
422
430
  selectedId = readSelection(ctx);
431
+ const sessionFile = ctx.sessionManager.getSessionFile?.();
432
+ const continuation = sessionFile ? continuations.get(sessionFile) : undefined;
433
+ if (sessionFile) continuations.delete(sessionFile);
434
+ const carrying = _event.reason === "new" && validId(continuation);
435
+ if (carrying) remember(continuation);
423
436
  restoreSuggestion(ctx);
424
437
  if (_event.reason === "new" || _event.reason === "fork") {
425
438
  // A fork may include an ancestor proposal whose consent was consumed elsewhere.
@@ -447,10 +460,27 @@ export default function (pi: ExtensionAPI) {
447
460
  if (!alive || generation !== epoch) return;
448
461
  // Keep the setup lock until its aborted subprocess has actually settled.
449
462
  if (!setupAbort) pending = undefined;
463
+ menuPending = undefined;
450
464
  await checkCLI(ctx, true);
451
465
  if (!alive || generation !== epoch) return;
452
466
  if (suggestion) previewBridge?.setSuggestion((choice) => respondToSuggestion(choice, ctx));
453
467
  await refresh(ctx);
468
+ if (carrying && alive && generation === epoch) {
469
+ try {
470
+ const branch = branchEpoch;
471
+ const current = await selected(ctx);
472
+ if (!current || !alive || generation !== epoch || branch !== branchEpoch || selectedId !== current.id ||
473
+ !ctx.hasUI || ctx.mode !== "tui") return;
474
+ const config = readConfig();
475
+ const prompt = "newSessionPrompt" in config ? parseNewSessionPrompt(config.newSessionPrompt) : defaultNewSessionPrompt;
476
+ if (prompt.trim()) {
477
+ const draft = ctx.ui.getEditorText();
478
+ ctx.ui.setEditorText(draft ? `${draft}\n\n${prompt}` : prompt);
479
+ }
480
+ } catch (error) {
481
+ if (alive && generation === epoch && ctx.hasUI) ctx.ui.notify(`Pinote new session: ${clean(String(error))}`, "warning");
482
+ }
483
+ }
454
484
  });
455
485
  pi.on("session_tree", async (_event, ctx) => {
456
486
  if (!alive) return;
@@ -470,6 +500,12 @@ export default function (pi: ExtensionAPI) {
470
500
  pi.on("before_agent_start", async (_event, ctx) => { await refresh(ctx); });
471
501
  pi.on("agent_end", async (_event, ctx) => { await refresh(ctx); });
472
502
  pi.on("session_shutdown", async (_event, ctx) => {
503
+ // Shutdown means replacement was accepted; cancelled /new never leaves a transfer.
504
+ if (_event.reason === "new" && _event.targetSessionFile && validId(selectedId)) {
505
+ // Bound orphaned transfers if creating the replacement runtime fails.
506
+ if (continuations.size >= 32) continuations.delete(continuations.keys().next().value!);
507
+ continuations.set(_event.targetSessionFile, selectedId);
508
+ }
473
509
  setupAbort?.abort();
474
510
  cancelCompletionDispatch();
475
511
  clearSuggestion(ctx, false);
@@ -488,7 +524,7 @@ export default function (pi: ExtensionAPI) {
488
524
 
489
525
  const installCLI = async (upgrade: boolean, ctx: ExtensionCommandContext) => {
490
526
  if (!ctx.hasUI || ctx.mode !== "tui") return;
491
- if (pending || !ctx.isIdle()) {
527
+ if (pending || menuPending || !ctx.isIdle()) {
492
528
  ctx.ui.notify("Wait until Pi is idle and the pinote operation has finished.", "warning");
493
529
  return;
494
530
  }
@@ -529,16 +565,18 @@ export default function (pi: ExtensionAPI) {
529
565
  return;
530
566
  }
531
567
  if (token) { ctx.ui.notify("Usage: /pi-note", "warning"); return; }
532
- if (pending || !ctx.isIdle()) {
533
- ctx.ui.notify("Wait until Pi is idle and the pinote operation has finished.", "warning");
568
+ if (pending || menuPending) {
569
+ ctx.ui.notify("Wait until the pinote operation has finished.", "warning");
534
570
  return;
535
571
  }
536
572
  const operation = Symbol();
537
- pending = operation;
573
+ menuPending = operation;
538
574
  const generation = epoch;
539
575
  const branch = branchEpoch;
540
576
  const currentSession = () => alive && generation === epoch;
541
- const canAct = () => currentSession() && ctx.isIdle() && branch === branchEpoch;
577
+ // Menus, task selection and preference edits do not interrupt the agent.
578
+ // Completion and CLI setup retain their own idle-only guards.
579
+ const canAct = () => currentSession() && branch === branchEpoch && menuPending === operation;
542
580
  try {
543
581
  await checkCLI(ctx);
544
582
  if (!canAct()) return;
@@ -551,7 +589,7 @@ export default function (pi: ExtensionAPI) {
551
589
  return;
552
590
  }
553
591
  if (settings) {
554
- let config: PinoteSettings = { footer: { ...defaultFooterConfig, fields: [] }, handoffPrompt: defaultHandoffPrompt };
592
+ let config: PinoteSettings = { footer: { ...defaultFooterConfig, fields: [] }, handoffPrompt: defaultHandoffPrompt, newSessionPrompt: defaultNewSessionPrompt };
555
593
  let expectedRaw: string | null = null;
556
594
  let configurationError: string | undefined;
557
595
  try {
@@ -560,6 +598,7 @@ export default function (pi: ExtensionAPI) {
560
598
  config = {
561
599
  footer: { ...document.config, fields: effectiveFooterFields(document.config, current?.agent_notes) },
562
600
  handoffPrompt: "handoffPrompt" in root ? parseHandoffPrompt(root.handoffPrompt) : defaultHandoffPrompt,
601
+ newSessionPrompt: "newSessionPrompt" in root ? parseNewSessionPrompt(root.newSessionPrompt) : defaultNewSessionPrompt,
563
602
  };
564
603
  expectedRaw = document.raw;
565
604
  } catch (error) {
@@ -590,7 +629,7 @@ export default function (pi: ExtensionAPI) {
590
629
  }), (updated) => {
591
630
  if (!canAct()) throw new Error("This session changed. Reopen settings before editing.");
592
631
  if (configurationError) throw new Error(configurationError);
593
- const saved = saveFooterConfig(updated.footer, expectedRaw, updated.handoffPrompt);
632
+ const saved = saveFooterConfig(updated.footer, expectedRaw, updated.handoffPrompt, updated.newSessionPrompt);
594
633
  expectedRaw = saved.raw;
595
634
  footerConfig = saved.config;
596
635
  void refresh(ctx);
@@ -598,7 +637,7 @@ export default function (pi: ExtensionAPI) {
598
637
  if (!canAct() || edited === undefined) return;
599
638
  if (edited === "tasks") { settings = false; continue; }
600
639
  if (!options.some((option) => option.result === edited)) return;
601
- if (pending === operation) pending = undefined;
640
+ if (menuPending === operation) menuPending = undefined;
602
641
  if (edited === "preview") {
603
642
  if (!await preview(ctx)) ctx.ui.notify("The task is no longer available for preview.", "warning");
604
643
  } else if (edited === "done") await completeSelected("", ctx, current ?? undefined);
@@ -614,7 +653,7 @@ export default function (pi: ExtensionAPI) {
614
653
  if (!action || !canAct()) return;
615
654
  if (action === "Settings") { settings = true; continue; }
616
655
  if (action === "Done" && current) {
617
- if (pending === operation) pending = undefined;
656
+ if (menuPending === operation) menuPending = undefined;
618
657
  await completeSelected("", ctx, current);
619
658
  return;
620
659
  }
@@ -632,6 +671,11 @@ export default function (pi: ExtensionAPI) {
632
671
  id = choice;
633
672
  } else return;
634
673
  const handoff = handoffPrompt();
674
+ if (pending) {
675
+ ctx.ui.notify("Wait until the pinote operation has finished.", "warning");
676
+ return;
677
+ }
678
+ pending = operation;
635
679
  refreshSerial++;
636
680
  const chosen = await startTask(id, canAct);
637
681
  if (!chosen || !canAct()) return;
@@ -644,6 +688,7 @@ export default function (pi: ExtensionAPI) {
644
688
  if (currentSession()) ctx.ui.notify(`Pinote: ${clean(error instanceof Error ? error.message : String(error))}`, "error");
645
689
  } finally {
646
690
  if (pending === operation) pending = undefined;
691
+ if (menuPending === operation) menuPending = undefined;
647
692
  if (currentSession()) await refresh(ctx);
648
693
  }
649
694
  },
@@ -720,7 +765,7 @@ export default function (pi: ExtensionAPI) {
720
765
  clearSuggestion(ctx);
721
766
  return true;
722
767
  }
723
- if (pending || setupAbort) return false;
768
+ if (pending || menuPending || setupAbort) return false;
724
769
  const generation = epoch;
725
770
  const branch = branchEpoch;
726
771
  const operation = Symbol();
@@ -769,7 +814,7 @@ export default function (pi: ExtensionAPI) {
769
814
  const current = await selected(ctx, signal);
770
815
  if (!alive || generation !== epoch || branch !== branchEpoch) throw new Error("Pinote operation cancelled: the session changed.");
771
816
  if (current || selectedId !== null) throw new Error("A task is already selected; keep it unless the user asks to switch.");
772
- if (pending || setupAbort) throw new Error("A pinote operation is already open. Retry after it finishes.");
817
+ if (pending || menuPending || setupAbort) throw new Error("A pinote operation is already open. Retry after it finishes.");
773
818
  const result = (status: string) => ({ content: [{ type: "text" as const, text: JSON.stringify({ status }) }], details: { status } });
774
819
  suggestion = { text: params.text, ...(params.tag === undefined ? {} : { tag: params.tag.trim() }) };
775
820
  saveSuggestion();
@@ -799,7 +844,7 @@ export default function (pi: ExtensionAPI) {
799
844
  const generation = epoch;
800
845
  const branch = branchEpoch;
801
846
  if (params.select !== true) return toolResult(await writeTask(ctx, signal, argv, undefined, "0.4.0"));
802
- if (pending) throw new Error("A pinote operation is already open. Retry after it finishes.");
847
+ if (pending || menuPending) throw new Error("A pinote operation is already open. Retry after it finishes.");
803
848
  const operation = Symbol();
804
849
  const previousSelection = selectedId;
805
850
  pending = operation;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.17.1",
3
+ "version": "0.18.1",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",