pi-note 0.5.0 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +14 -8
  2. package/index.ts +98 -18
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -68,22 +68,28 @@ The normal Pi status area shows `📌 [tag] Task title` without replacing other
68
68
  The footer omits the task ID and state and shows only the first line, truncated to 60 terminal columns including the pin and tag.
69
69
  The pin glyph is bundled in `icons/note.txt`; it uses the terminal's emoji font, not a Nerd Font or icon theme.
70
70
 
71
- Selection is stored per canonical working directory in pinote's database. A new
72
- Pi session shows that selection; use **Continue** to insert its task-ID prompt.
73
- Completing, removing, or scheduling a task clears its selections. Switching does
74
- not complete or reset the previous task. Task status refreshes at session start,
75
- before/after agent activity, and after commands/tools.
71
+ Each Pi session remembers its own task, including several sessions in one folder.
72
+ Resume restores that session's task; a new session starts unselected and does not import
73
+ an older per-folder selection. Pi saves the session after the first submitted message;
74
+ quitting before that leaves the next launch unselected, while the task stays in progress.
75
+ Use **Continue**
76
+ to insert its task-ID prompt. Completing, removing, or scheduling a task clears it
77
+ from sessions that refresh it. Switching does not complete or reset the previous task.
78
+ Task fields such as `Worktree` and `PR` stay on the task, so sessions can use separate
79
+ worktrees and pull requests. Task status refreshes at session start, before/after
80
+ agent activity, and after commands/tools. The CLI's per-directory `agent selected`
81
+ command is not this session memory.
76
82
 
77
83
  Agent tools:
78
84
 
79
- - `pinote_get`: read the selected task, or an explicit `id`, including its revision.
80
- Omitting `id` returns null when this project has no selected task.
85
+ - `pinote_get`: read this session's selected task, or an explicit `id`, including its revision.
86
+ Omitting `id` returns null when this session has no selected task.
81
87
  - `pinote_update`: supply `id`, `expected_updated_at` from that read, and arbitrary
82
88
  `set` label/value pairs or `remove` labels. Updates merge fields, never replace
83
89
  the task text or tag. If another process changed the task, read again before retrying.
84
90
  - `pinote_tags`: list saved tag names.
85
91
  - `pinote_add`: create an active task. Optional `tag`. `select: true` starts and
86
- selects it for this project; use that only after the user agrees.
92
+ remembers it for this session only; use that only after the user agrees.
87
93
  - `pinote_tag`: set or clear a tag on an active or in-progress task. Read first and
88
94
  pass `expected_updated_at`.
89
95
 
package/index.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth } from "@earendil-works/pi-tui";
3
3
  import { readFileSync } from "node:fs";
4
- import { isAbsolute } from "node:path";
5
4
  import { stripVTControlCharacters } from "node:util";
6
5
  import { Type } from "typebox";
7
6
  import { createPRWatcher } from "./pr-watch.ts";
@@ -103,7 +102,7 @@ export default function (pi: ExtensionAPI) {
103
102
  }
104
103
  return result.stdout;
105
104
  };
106
- const run = async (argv: string[], signal?: AbortSignal, canRun = () => true, minimum = "0.3.0") => {
105
+ const ensureCLI = async (signal?: AbortSignal, canRun = () => true, minimum = "0.3.0") => {
107
106
  if (setupAbort) throw new Error("Pinote CLI setup is still running. Retry after it finishes.");
108
107
  // Old pinote treats unknown commands as note text. Probe safely before every call,
109
108
  // including tools in headless sessions, and do not cache across CLI upgrades/downgrades.
@@ -112,6 +111,9 @@ export default function (pi: ExtensionAPI) {
112
111
  throw new Error(`pi-note requires pinote ${minimum}+; found ${clean(version) || "an unknown CLI"}. ${setupHint}`);
113
112
  }
114
113
  if (!canRun()) throw new Error("Pinote operation cancelled: the session changed.");
114
+ };
115
+ const run = async (argv: string[], signal?: AbortSignal, canRun = () => true, minimum = "0.3.0") => {
116
+ await ensureCLI(signal, canRun, minimum);
115
117
  return invoke(argv, signal);
116
118
  };
117
119
  const writeTask = async (
@@ -129,8 +131,57 @@ export default function (pi: ExtensionAPI) {
129
131
  if (alive && generation === epoch) await refresh(ctx);
130
132
  }
131
133
  };
132
- const selected = async (ctx: ExtensionContext, signal?: AbortSignal) =>
133
- task(await run(["agent", "selected", "--cwd", ctx.cwd], signal));
134
+ // One folder can host several sessions. Remember the task in the Pi session, not by cwd.
135
+ const selectionType = "pinote-selection";
136
+ let selectedId: number | null = null;
137
+ let branchEpoch = 0;
138
+ const remember = (id: number | null, branch = branchEpoch) => {
139
+ if (!alive || branch !== branchEpoch || selectedId === id) return;
140
+ selectedId = id;
141
+ pi.appendEntry(selectionType, { id });
142
+ };
143
+ const readSelection = (ctx: ExtensionContext) => {
144
+ let id: number | null = null;
145
+ for (const entry of ctx.sessionManager.getBranch()) {
146
+ if (entry.type !== "custom" || entry.customType !== selectionType) continue;
147
+ const data = entry.data as { id?: unknown } | null;
148
+ id = data && validId(data.id) ? data.id : null;
149
+ }
150
+ return id;
151
+ };
152
+ const currentTask = async (signal?: AbortSignal, canUse = () => alive) => {
153
+ const id = selectedId;
154
+ if (!validId(id) || !canUse()) return null;
155
+ const current = task(await run(["agent", "get", String(id)], signal, canUse));
156
+ if (!canUse() || selectedId !== id) return null;
157
+ if (!current || current.id !== id || !["active", "in_progress"].includes(current.state)) {
158
+ if (canUse() && selectedId === id) remember(null);
159
+ return null;
160
+ }
161
+ return current;
162
+ };
163
+ const selected = async (_ctx: ExtensionContext, signal?: AbortSignal) => {
164
+ const generation = epoch;
165
+ const canUse = () => alive && generation === epoch;
166
+ await ensureCLI(signal, canUse);
167
+ return currentTask(signal, canUse);
168
+ };
169
+ const startTask = async (id: number, canAct: () => boolean, signal?: AbortSignal) => {
170
+ const branch = branchEpoch;
171
+ const still = () => canAct() && branch === branchEpoch;
172
+ const before = requiredTask(await run(["agent", "get", String(id)], signal, still), id);
173
+ if (!still()) return;
174
+ if (!["active", "in_progress"].includes(before.state)) {
175
+ throw new Error(`Note ${id} is ${before.state}; restore it first.`);
176
+ }
177
+ // Start without the shared cwd selection so another session can keep a different task.
178
+ if (before.state === "active") await run(["--no-notify", "start", String(id)], signal, still);
179
+ const chosen = requiredTask(await run(["agent", "get", String(id)], signal, still), id);
180
+ if (chosen.state !== "in_progress") throw new Error(compatible);
181
+ if (!still()) return;
182
+ remember(chosen.id, branch);
183
+ return chosen;
184
+ };
134
185
 
135
186
  const refresh = async (ctx: ExtensionContext) => {
136
187
  if (!alive || setupAbort || !ctx.hasUI || ctx.mode !== "tui") return;
@@ -170,6 +221,8 @@ export default function (pi: ExtensionAPI) {
170
221
  alive = true;
171
222
  activeContext = ctx;
172
223
  epoch++;
224
+ branchEpoch++;
225
+ selectedId = readSelection(ctx);
173
226
  const generation = epoch;
174
227
  cliState = undefined;
175
228
  if (ctx.hasUI && ctx.mode === "tui") {
@@ -182,13 +235,25 @@ export default function (pi: ExtensionAPI) {
182
235
  if (!setupAbort) watcher.start(ctx);
183
236
  await refresh(ctx);
184
237
  });
238
+ pi.on("session_tree", async (_event, ctx) => {
239
+ if (!alive) return;
240
+ branchEpoch++;
241
+ const generation = branchEpoch;
242
+ const id = readSelection(ctx);
243
+ if (!alive || generation !== branchEpoch) return;
244
+ selectedId = id;
245
+ if (setupAbort) return;
246
+ await refresh(ctx);
247
+ });
185
248
  pi.on("before_agent_start", async (_event, ctx) => { await refresh(ctx); });
186
249
  pi.on("agent_end", async (_event, ctx) => { await refresh(ctx); });
187
250
  pi.on("session_shutdown", (_event, ctx) => {
188
251
  setupAbort?.abort();
189
252
  alive = false;
190
253
  activeContext = undefined;
254
+ selectedId = null;
191
255
  epoch++;
256
+ branchEpoch++;
192
257
  refreshSerial++;
193
258
  watcher.stop();
194
259
  if (ctx.hasUI && ctx.mode === "tui") ctx.ui.setStatus("pinote", undefined);
@@ -247,8 +312,9 @@ export default function (pi: ExtensionAPI) {
247
312
  const operation = Symbol();
248
313
  pending = operation;
249
314
  const generation = epoch;
315
+ const branch = branchEpoch;
250
316
  const currentSession = () => alive && generation === epoch;
251
- const canAct = () => currentSession() && ctx.isIdle();
317
+ const canAct = () => currentSession() && ctx.isIdle() && branch === branchEpoch;
252
318
  try {
253
319
  const current = await selected(ctx);
254
320
  if (!canAct()) return;
@@ -262,6 +328,7 @@ export default function (pi: ExtensionAPI) {
262
328
  "agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
263
329
  ], undefined, canAct), current.id);
264
330
  if (completed.state !== "done") throw new Error(compatible);
331
+ if (currentSession() && branch === branchEpoch) remember(null, branch);
265
332
  if (currentSession()) ctx.ui.notify(`Pinote #${current.id} completed.`, "info");
266
333
  return;
267
334
  }
@@ -277,10 +344,9 @@ export default function (pi: ExtensionAPI) {
277
344
  if (choice === undefined || !canAct()) return;
278
345
  id = choice;
279
346
  } else return;
280
- // Validate/start the explicit task and return its latest handoff in one transaction.
281
347
  refreshSerial++;
282
- const chosen = requiredTask(await run(["agent", "select", String(id), "--cwd", ctx.cwd], undefined, canAct), id);
283
- if (!canAct()) return;
348
+ const chosen = await startTask(id, canAct);
349
+ if (!chosen || !canAct()) return;
284
350
  const draft = ctx.ui.getEditorText();
285
351
  const handoff = `Read task #${chosen.id} with pinote_get and work on it. Ask only if blocked. Save progress with pinote_update.`;
286
352
  ctx.ui.setEditorText(draft ? `${draft}\n\n${handoff}` : handoff);
@@ -298,15 +364,14 @@ export default function (pi: ExtensionAPI) {
298
364
  name: "pinote_get",
299
365
  label: "Pinote get",
300
366
  promptGuidelines: createGuidance,
301
- description: "Read a pinote task and its Markdown agent fields/revision. Omit id to read this project's selected task; returns null when none is selected. Supply id to read another task. Use the returned updated_at for pinote_update and pinote_tag.",
367
+ description: "Read a pinote task and its Markdown agent fields/revision. Omit id to read this session's selected task; returns null when none is selected. Supply id to read another task. Use the returned updated_at for pinote_update and pinote_tag.",
302
368
  parameters: Type.Object({ id: Type.Optional(idSchema) }),
303
369
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
304
370
  if (params.id !== undefined && !validId(params.id)) throw new Error("id must be a positive safe integer.");
305
- const argv = params.id === undefined
306
- ? ["agent", "selected", "--cwd", ctx.cwd]
307
- : ["agent", "get", String(params.id)];
308
- const raw = await run(argv, signal);
309
- return toolResult(params.id === undefined ? task(raw) : requiredTask(raw, params.id));
371
+ const result = params.id === undefined
372
+ ? await selected(ctx, signal)
373
+ : requiredTask(await run(["agent", "get", String(params.id)], signal), params.id);
374
+ return toolResult(result);
310
375
  },
311
376
  });
312
377
  pi.registerTool({
@@ -345,7 +410,7 @@ export default function (pi: ExtensionAPI) {
345
410
  name: "pinote_add",
346
411
  label: "Pinote add",
347
412
  promptGuidelines: createGuidance,
348
- description: "Create an active pinote task. Optional tag is registered if new. Set select true only after the user agrees to make it this session's active task; that starts it and selects it for this project. Omitting select leaves the current selection unchanged. Does not refresh the desktop notification.",
413
+ description: "Create an active pinote task. Optional tag is registered if new. Set select true only after the user agrees to make it this session's active task; that starts it and remembers it for this session only. Omitting select leaves the current selection unchanged. Does not refresh the desktop notification or bind the project directory.",
349
414
  parameters: Type.Object({
350
415
  text: Type.String({ minLength: 1 }),
351
416
  tag: Type.Optional(Type.String({ minLength: 1 })),
@@ -357,11 +422,26 @@ export default function (pi: ExtensionAPI) {
357
422
  throw new Error("tag must be a non-empty name, or omit it.");
358
423
  }
359
424
  if (params.select !== undefined && typeof params.select !== "boolean") throw new Error("select must be a boolean.");
360
- if (params.select === true && !isAbsolute(ctx.cwd)) throw new Error("Project directory must be absolute before selecting a task.");
361
425
  const argv = ["agent", "add", `--text=${params.text}`];
362
426
  if (params.tag !== undefined) argv.push(`--tag=${params.tag}`);
363
- if (params.select === true) argv.push("--cwd", ctx.cwd);
364
- return toolResult(await writeTask(ctx, signal, argv, undefined, "0.4.0"));
427
+ const generation = epoch;
428
+ const branch = branchEpoch;
429
+ const created = await writeTask(ctx, signal, argv, undefined, "0.4.0");
430
+ if (params.select !== true) return toolResult(created);
431
+ if (!alive || generation !== epoch || branch !== branchEpoch) throw new Error("Pinote operation cancelled: the session changed.");
432
+ if (pending) throw new Error("A pinote operation is already open. Retry after it finishes.");
433
+ const operation = Symbol();
434
+ pending = operation;
435
+ try {
436
+ const chosen = await startTask(
437
+ created.id, () => alive && generation === epoch && branch === branchEpoch && pending === operation, signal,
438
+ );
439
+ if (!chosen) throw new Error("Pinote operation cancelled: the session changed.");
440
+ return toolResult(chosen);
441
+ } finally {
442
+ if (pending === operation) pending = undefined;
443
+ if (alive && generation === epoch) await refresh(ctx);
444
+ }
365
445
  },
366
446
  });
367
447
  pi.registerTool({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",