pi-note 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
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> =>
@@ -420,6 +427,11 @@ export default function (pi: ExtensionAPI) {
420
427
  alive = true;
421
428
  activeContext = ctx;
422
429
  selectedId = readSelection(ctx);
430
+ const sessionFile = ctx.sessionManager.getSessionFile?.();
431
+ const continuation = sessionFile ? continuations.get(sessionFile) : undefined;
432
+ if (sessionFile) continuations.delete(sessionFile);
433
+ const carrying = _event.reason === "new" && validId(continuation);
434
+ if (carrying) remember(continuation);
423
435
  restoreSuggestion(ctx);
424
436
  if (_event.reason === "new" || _event.reason === "fork") {
425
437
  // A fork may include an ancestor proposal whose consent was consumed elsewhere.
@@ -451,6 +463,22 @@ export default function (pi: ExtensionAPI) {
451
463
  if (!alive || generation !== epoch) return;
452
464
  if (suggestion) previewBridge?.setSuggestion((choice) => respondToSuggestion(choice, ctx));
453
465
  await refresh(ctx);
466
+ if (carrying && alive && generation === epoch) {
467
+ try {
468
+ const branch = branchEpoch;
469
+ const current = await selected(ctx);
470
+ if (!current || !alive || generation !== epoch || branch !== branchEpoch || selectedId !== current.id ||
471
+ !ctx.hasUI || ctx.mode !== "tui") return;
472
+ const config = readConfig();
473
+ const prompt = "newSessionPrompt" in config ? parseNewSessionPrompt(config.newSessionPrompt) : defaultNewSessionPrompt;
474
+ if (prompt.trim()) {
475
+ const draft = ctx.ui.getEditorText();
476
+ ctx.ui.setEditorText(draft ? `${draft}\n\n${prompt}` : prompt);
477
+ }
478
+ } catch (error) {
479
+ if (alive && generation === epoch && ctx.hasUI) ctx.ui.notify(`Pinote new session: ${clean(String(error))}`, "warning");
480
+ }
481
+ }
454
482
  });
455
483
  pi.on("session_tree", async (_event, ctx) => {
456
484
  if (!alive) return;
@@ -470,6 +498,12 @@ export default function (pi: ExtensionAPI) {
470
498
  pi.on("before_agent_start", async (_event, ctx) => { await refresh(ctx); });
471
499
  pi.on("agent_end", async (_event, ctx) => { await refresh(ctx); });
472
500
  pi.on("session_shutdown", async (_event, ctx) => {
501
+ // Shutdown means replacement was accepted; cancelled /new never leaves a transfer.
502
+ if (_event.reason === "new" && _event.targetSessionFile && validId(selectedId)) {
503
+ // Bound orphaned transfers if creating the replacement runtime fails.
504
+ if (continuations.size >= 32) continuations.delete(continuations.keys().next().value!);
505
+ continuations.set(_event.targetSessionFile, selectedId);
506
+ }
473
507
  setupAbort?.abort();
474
508
  cancelCompletionDispatch();
475
509
  clearSuggestion(ctx, false);
@@ -551,7 +585,7 @@ export default function (pi: ExtensionAPI) {
551
585
  return;
552
586
  }
553
587
  if (settings) {
554
- let config: PinoteSettings = { footer: { ...defaultFooterConfig, fields: [] }, handoffPrompt: defaultHandoffPrompt };
588
+ let config: PinoteSettings = { footer: { ...defaultFooterConfig, fields: [] }, handoffPrompt: defaultHandoffPrompt, newSessionPrompt: defaultNewSessionPrompt };
555
589
  let expectedRaw: string | null = null;
556
590
  let configurationError: string | undefined;
557
591
  try {
@@ -560,6 +594,7 @@ export default function (pi: ExtensionAPI) {
560
594
  config = {
561
595
  footer: { ...document.config, fields: effectiveFooterFields(document.config, current?.agent_notes) },
562
596
  handoffPrompt: "handoffPrompt" in root ? parseHandoffPrompt(root.handoffPrompt) : defaultHandoffPrompt,
597
+ newSessionPrompt: "newSessionPrompt" in root ? parseNewSessionPrompt(root.newSessionPrompt) : defaultNewSessionPrompt,
563
598
  };
564
599
  expectedRaw = document.raw;
565
600
  } catch (error) {
@@ -590,7 +625,7 @@ export default function (pi: ExtensionAPI) {
590
625
  }), (updated) => {
591
626
  if (!canAct()) throw new Error("This session changed. Reopen settings before editing.");
592
627
  if (configurationError) throw new Error(configurationError);
593
- const saved = saveFooterConfig(updated.footer, expectedRaw, updated.handoffPrompt);
628
+ const saved = saveFooterConfig(updated.footer, expectedRaw, updated.handoffPrompt, updated.newSessionPrompt);
594
629
  expectedRaw = saved.raw;
595
630
  footerConfig = saved.config;
596
631
  void refresh(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",