pi-note 0.5.2 → 0.7.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
@@ -1,12 +1,13 @@
1
1
  # pi-note
2
2
 
3
3
  Persistent [pinote](https://github.com/kvidzibo/pinote) tasks and Markdown handoffs
4
- in Pi: selected-task footer status, `/pi-note`, and agent read/update/add/tag tools.
4
+ in Pi: selected-task footer status, `/pi-note`, and agent current-task read/update,
5
+ add, and tag-listing tools.
5
6
  The extension and Python app share a repository but install separately.
6
7
 
7
8
  ## Install
8
9
 
9
- Requires Node.js 22.19+ and **pinote 0.3.0+** (`note` on PATH). Add and tag need **pinote 0.4.0+**.
10
+ Requires Node.js 22.19+ and **pinote 0.3.0+** (`note` on PATH). Add and tag listing need **pinote 0.4.0+**.
10
11
  From this checkout, install the extension with `pi install ./pi`, then run
11
12
  `/reload` in interactive Pi. Use `/pi-note-setup` if the CLI is missing, or the
12
13
  suggested `/pi-note-upgrade` if it is older than the bundled CLI. The planned npm package name is
@@ -56,8 +57,8 @@ standalone `settings/pi/extensions/pinote.ts` entry if installed; load only one
56
57
  ## Use
57
58
 
58
59
  `/pi-note` picks a task, starts it, and adds a short task-ID prompt to the editor:
59
- “Read task #121 with pinote_get and work on it. Ask only if blocked. Save progress
60
- with pinote_update.” The agent fetches current task text and saved handoff fields
60
+ “Read the current task (#121) with pinote_get_current and work on it. Ask only if
61
+ blocked. Save progress with pinote_update_current.” The agent fetches current task text and saved handoff fields
61
62
  when you submit; they are not copied into the draft. Existing drafts are preserved;
62
63
  nothing is submitted automatically.
63
64
  With a selected task it offers **Continue**, **Done**, and **Switch task**.
@@ -82,16 +83,20 @@ command is not this session memory.
82
83
 
83
84
  Agent tools:
84
85
 
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.
87
- - `pinote_update`: supply `id`, `expected_updated_at` from that read, and arbitrary
88
- `set` label/value pairs or `remove` labels. Updates merge fields, never replace
89
- the task text or tag. If another process changed the task, read again before retrying.
86
+ - `pinote_get_current`: read this session's selected task, including its revision.
87
+ Takes no arguments; returns null when this session has no selected task.
88
+ - `pinote_update_current`: supply `expected_updated_at` from that read and arbitrary
89
+ `set` label/value pairs or `remove` labels. Updates the selected task only, with no
90
+ ID argument. Updates merge fields, never replace the task text or tag. Fails if
91
+ no task is selected or the selection changes during the operation. If another
92
+ process changed the task, read again before retrying.
90
93
  - `pinote_tags`: list saved tag names.
91
94
  - `pinote_add`: create an active task. Optional `tag`. `select: true` starts and
92
95
  remembers it for this session only; use that only after the user agrees.
93
- - `pinote_tag`: set or clear a tag on an active or in-progress task. Read first and
94
- pass `expected_updated_at`.
96
+
97
+ Version 0.7.0 replaces `pinote_get`/`pinote_update` with these current-task tools
98
+ and removes `pinote_tag`, without aliases. Agents cannot read/update tasks by ID
99
+ or retag existing tasks; use `/pi-note` to select an existing task.
95
100
 
96
101
  When the user gives work and no task is selected, the agent proposes one note as
97
102
  `[tag] text` and asks before creating it. No continues without a note. Yes calls
@@ -100,17 +105,23 @@ user asks to switch. Reuse a saved tag name when it fits.
100
105
 
101
106
  Agents are guided to keep notes to three short bullets total: relevant outcome,
102
107
  blocker, and next action. Replace stale notes; omit narration, repeated task text,
103
- and routine test logs. Keep PR links in `PR`. This is guidance, not truncation or
104
- a storage limit.
108
+ and routine test logs. Keep a GitHub pull request in `PR`. To show another footer
109
+ field, set its Markdown value and append its label to `Bar`, one label per line.
110
+ Do not list `PR` in `Bar`. Remove the field and its `Bar` line to drop it. This is
111
+ guidance, not truncation or a storage limit.
105
112
 
106
- Values are Markdown strings; no fields are required. `PR` enables the watcher below:
113
+ Values are Markdown strings; no fields are required. `PR` enables the watcher below.
114
+ `Bar` chooses extra footer fields:
107
115
 
108
116
  ```markdown
109
117
  # Agent
110
118
  PR: [Task selection #42](https://github.com/org/repo/pull/42)
119
+ Dashboard: [Metrics](http://127.0.0.1:3000/d/app)
120
+ Next: Address review comments
121
+ Bar: Dashboard
122
+ Next
111
123
  Jira: [PROJ-123](https://example.atlassian.net/browse/PROJ-123)
112
124
  CWD: `/home/me/project`
113
- Next: Address review comments
114
125
  ```
115
126
 
116
127
  The GTK preview renders this section using its existing safe Markdown renderer.
@@ -119,12 +130,26 @@ sessions. Labels are case-sensitive (trimmed, Unicode-normalized), at most 64
119
130
  characters; values are at most 4096 characters. Maximum 64 fields / 32 KiB JSON
120
131
  per task. Use removal rather than empty values.
121
132
 
133
+ ### Footer links
134
+
135
+ The selected active or in-progress task can show fields after its title.
136
+ `PR` is always shown when it matches the watcher format below, as **PR #123**.
137
+ `Bar` is a newline-separated list of other field labels. Each listed value is
138
+ Markdown: the footer shows its text, and links in it are clickable for any scheme
139
+ except `javascript:`, `data:`, and `vbscript:`. Credentials, control characters,
140
+ and targets over 2048 characters after serialization are shown as text but not
141
+ linked. Missing labels and the `PR` and `Bar` labels themselves are skipped.
142
+ At most four extra fields are shown, each truncated to 60 columns. Removing a listed
143
+ field hides it even if `Bar` still names it. Terminal OSC 8 support is required
144
+ for clicking links. Pi joins footer statuses on one line, so a narrow terminal can
145
+ ellipsize later fields. Other fields, such as `Jira` and `CWD` in the example, stay
146
+ off the footer unless named in `Bar`.
147
+
122
148
  ### PR merge watcher
123
149
 
124
- Set `PR` with `pinote_update` to one `https://github.com/owner/repo/pull/123` URL
150
+ Set `PR` with `pinote_update_current` to one `https://github.com/owner/repo/pull/123` URL
125
151
  or Markdown link. The footer adds a clickable, link-coloured **PR #123**, without
126
- status text. Other hosts, multiple links, and prose are not watched. Terminal
127
- OSC 8 support is required for clicking links.
152
+ status text. Other hosts, multiple links, and prose are not watched.
128
153
 
129
154
  Interactive Pi checks only the selected task's PR using authenticated `gh`
130
155
  (`gh auth login`). Polling defaults to 60 seconds; launch Pi with
@@ -0,0 +1,222 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { stripVTControlCharacters } from "node:util";
3
+
4
+ export const footerStatusKey = "pinote-links";
5
+ export const legacyFooterStatusKey = "pinote-pr";
6
+ const maxCustomChips = 4;
7
+ const maxLabelWidth = 24;
8
+ const maxChipWidth = 60;
9
+ const maxUrlLength = 2048;
10
+ const blockedProtocols = new Set(["javascript:", "data:", "vbscript:"]);
11
+ const control = /[\u0000-\u001f\u007f-\u009f]/u;
12
+
13
+ export type FooterSegment = { text: string; url?: string };
14
+ export type FooterChip = { segments: FooterSegment[] };
15
+
16
+ function display(value: string): string {
17
+ // Keep whitespace so newlines stay word boundaries. Drop other C0/C1 controls;
18
+ // U+009D survives stripVTControlCharacters and can start OSC.
19
+ return stripVTControlCharacters(value)
20
+ .replace(new RegExp(control, "gu"), (char) => /\s/u.test(char) ? " " : "")
21
+ .replace(/\s+/gu, " ");
22
+ }
23
+
24
+ function plain(value: string): string {
25
+ return display(value).trim();
26
+ }
27
+
28
+ function clip(value: string, width: number): string {
29
+ return stripVTControlCharacters(truncateToWidth(plain(value), width));
30
+ }
31
+
32
+ function ownText(notes: Record<string, string>, name: string): string | undefined {
33
+ if (!Object.hasOwn(notes, name)) return;
34
+ const value = notes[name];
35
+ return typeof value === "string" ? value : undefined;
36
+ }
37
+
38
+ // Any scheme except scriptable or credentialed targets. Reject terminal escapes and oversized serialization.
39
+ function linkTarget(value: string): string | undefined {
40
+ // Do not trim or strip first: that can turn a dirty destination into a different URL.
41
+ if (!value || value.length > maxUrlLength || /[\s\\<>"`]/u.test(value) || control.test(value)) return;
42
+ const candidate = value;
43
+ let parsed: URL;
44
+ try {
45
+ parsed = new URL(candidate);
46
+ } catch {
47
+ return;
48
+ }
49
+ if (blockedProtocols.has(parsed.protocol) || parsed.username || parsed.password || !parsed.protocol) return;
50
+ if (parsed.href.length > maxUrlLength || /[\\<>"`]/u.test(parsed.href) || control.test(parsed.href)) return;
51
+ return parsed.href;
52
+ }
53
+
54
+ function pushText(segments: FooterSegment[], text: string) {
55
+ const cleaned = display(text).replace(/\*\*|__/gu, "");
56
+ if (!cleaned) return;
57
+ const last = segments.at(-1);
58
+ if (last && !last.url) last.text += cleaned;
59
+ else segments.push({ text: cleaned });
60
+ }
61
+
62
+ function readCode(source: string, index: number): { text: string; end: number } | undefined {
63
+ if (source[index] !== "`") return;
64
+ const end = source.indexOf("`", index + 1);
65
+ if (end < 0) return;
66
+ return { text: source.slice(index + 1, end), end: end + 1 };
67
+ }
68
+
69
+ function readDestination(source: string, start: number): { dest: string; end: number } | undefined {
70
+ if (source[start] === "<") {
71
+ const close = source.indexOf(">", start + 1);
72
+ if (close < 0) return;
73
+ return { dest: source.slice(start + 1, close), end: close + 1 };
74
+ }
75
+ let depth = 0;
76
+ let end = start;
77
+ for (; end < source.length; end++) {
78
+ const char = source[end];
79
+ if (char === "(") depth++;
80
+ else if (char === ")") {
81
+ if (depth === 0) break;
82
+ depth--;
83
+ }
84
+ }
85
+ if (end === start || depth !== 0) return;
86
+ return { dest: source.slice(start, end), end };
87
+ }
88
+
89
+ function readLink(source: string, index: number): { text: string; dest: string; end: number } | undefined {
90
+ let start = index;
91
+ if (source[start] === "!") start++;
92
+ if (source[start] !== "[") return;
93
+ const labelEnd = source.indexOf("]", start + 1);
94
+ if (labelEnd < 0 || source[labelEnd + 1] !== "(") return;
95
+ const dest = readDestination(source, labelEnd + 2);
96
+ if (!dest || source[dest.end] !== ")") return;
97
+ return { text: source.slice(start + 1, labelEnd), dest: dest.dest, end: dest.end + 1 };
98
+ }
99
+
100
+ function readAuto(source: string, index: number): { text: string; dest: string; end: number } | undefined {
101
+ if (source[index] !== "<") return;
102
+ const close = source.indexOf(">", index + 1);
103
+ if (close < 0) return;
104
+ const dest = source.slice(index + 1, close);
105
+ if (!/^[a-z][a-z0-9+.-]*:/iu.test(dest)) return;
106
+ return { text: dest, dest, end: close + 1 };
107
+ }
108
+
109
+ function readBare(source: string, index: number): { text: string; dest: string; end: number } | undefined {
110
+ if (index > 0 && /[A-Za-z0-9]/u.test(source[index - 1])) return;
111
+ const scheme = /^[a-z][a-z0-9+.-]*:\/\//iu.exec(source.slice(index));
112
+ if (!scheme) return;
113
+ let end = index + scheme[0].length;
114
+ let depth = 0;
115
+ for (; end < source.length; end++) {
116
+ const char = source[end];
117
+ if (/[\s<>\]*]/u.test(char) || control.test(char)) break;
118
+ if (char === "(") depth++;
119
+ else if (char === ")") {
120
+ if (depth === 0) break;
121
+ depth--;
122
+ }
123
+ }
124
+ const raw = source.slice(index, end).replace(/[.,;:!?]+$/u, "").replace(/__$/u, "");
125
+ if (!raw || depth !== 0) return;
126
+ return { text: raw, dest: raw, end: index + raw.length };
127
+ }
128
+
129
+ function emitLink(segments: FooterSegment[], label: string, dest: string) {
130
+ const text = plain(label).replace(/\*\*|__/gu, "") || plain(dest);
131
+ if (!text) return;
132
+ const url = linkTarget(dest);
133
+ segments.push(url ? { text, url } : { text });
134
+ }
135
+
136
+ export function renderMarkdown(value: string | undefined): FooterSegment[] {
137
+ const source = value ?? "";
138
+ if (!plain(source)) return [];
139
+ const segments: FooterSegment[] = [];
140
+ let text = "";
141
+ for (let index = 0; index < source.length;) {
142
+ const code = readCode(source, index);
143
+ const link = code ? undefined : readLink(source, index);
144
+ const auto = code || link ? undefined : readAuto(source, index);
145
+ const bare = code || link || auto ? undefined : readBare(source, index);
146
+ const token = link ?? auto ?? bare;
147
+ if (code) {
148
+ pushText(segments, text);
149
+ text = "";
150
+ pushText(segments, code.text);
151
+ index = code.end;
152
+ } else if (token) {
153
+ pushText(segments, text);
154
+ text = "";
155
+ emitLink(segments, token.text, token.dest);
156
+ index = token.end;
157
+ } else {
158
+ text += source[index];
159
+ index++;
160
+ }
161
+ }
162
+ pushText(segments, text);
163
+ if (segments[0] && !segments[0].url) segments[0].text = segments[0].text.trimStart();
164
+ const last = segments.at(-1);
165
+ if (last && !last.url) last.text = last.text.trimEnd();
166
+ return segments.filter((segment) => segment.text);
167
+ }
168
+
169
+ function truncateSegments(segments: readonly FooterSegment[], width: number): FooterSegment[] {
170
+ const kept: FooterSegment[] = [];
171
+ let used = 0;
172
+ for (const segment of segments) {
173
+ if (used >= width) break;
174
+ const room = width - used;
175
+ const text = visibleWidth(segment.text) <= room ? segment.text : clip(segment.text, room);
176
+ if (!text) break;
177
+ kept.push(segment.url ? { text, url: segment.url } : { text });
178
+ used += visibleWidth(text);
179
+ }
180
+ return kept;
181
+ }
182
+
183
+ export function customFooterChips(notes: Record<string, string> | undefined): FooterChip[] {
184
+ if (!notes) return [];
185
+ const chips: FooterChip[] = [];
186
+ const seen = new Set(["PR", "Bar"]);
187
+ for (const raw of (notes.Bar ?? "").split("\n")) {
188
+ if (chips.length >= maxCustomChips) break;
189
+ const name = raw.normalize("NFC").trim();
190
+ if (!name || seen.has(name)) continue;
191
+ seen.add(name);
192
+ const label = clip(name, maxLabelWidth);
193
+ const body = renderMarkdown(ownText(notes, name));
194
+ if (!label || !body.length) continue;
195
+ chips.push({ segments: truncateSegments([{ text: `${label}: ` }, ...body], maxChipWidth) });
196
+ }
197
+ return chips;
198
+ }
199
+
200
+ export function footerChips(
201
+ task: { state: string; agent_notes: Record<string, string> } | null,
202
+ pr: { url: string; number: string } | undefined,
203
+ ): FooterChip[] {
204
+ if (!task || !["active", "in_progress"].includes(task.state)) return [];
205
+ return [
206
+ ...(pr ? [{ segments: [{ text: `PR #${pr.number}`, url: pr.url }] }] : []),
207
+ ...customFooterChips(task.agent_notes),
208
+ ];
209
+ }
210
+
211
+ export function renderFooterLinks(
212
+ chips: readonly FooterChip[],
213
+ colorLink: (text: string) => string,
214
+ ): string | undefined {
215
+ const rendered = chips.flatMap((chip) => {
216
+ const text = chip.segments.map((segment) => segment.url
217
+ ? colorLink(`\x1b]8;;${segment.url}\x1b\\${segment.text}\x1b]8;;\x1b\\`)
218
+ : segment.text).join("");
219
+ return text.trim() ? [text] : [];
220
+ });
221
+ return rendered.length ? rendered.join(" · ") : undefined;
222
+ }
package/index.ts CHANGED
@@ -7,10 +7,10 @@ import { createPRWatcher } from "./pr-watch.ts";
7
7
  import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
8
8
  import { bundledCLIVersion, cliMenu, detectCLI, setupCLI, setupHint, versionAtLeast, type CLIAction } from "./setup.ts";
9
9
 
10
- const handoffGuidance = "Keep agent notes to at most three short bullets total: outcome, blocker, next action, only when relevant. Replace stale notes; omit narration, repeated task text, and routine test logs. Keep PR links in the separate PR field.";
10
+ const handoffGuidance = "Keep agent notes to at most three short bullets total: outcome, blocker, next action, only when relevant. Replace stale notes; omit narration, repeated task text, and routine test logs. Keep a GitHub pull request in the PR field. To show another footer field, set its Markdown value and append its label to Bar, one label per line. Links in that text are clickable. Do not list PR in Bar. Remove the field and its Bar line to drop it. Notes stay off the footer unless named in Bar.";
11
11
  const createGuidance = [
12
12
  "When the user gives work and no pinote task is selected, propose one note as `[tag] text` and ask before creating it. On no, continue without a note. On yes, call pinote_add with select true so it becomes this session's active task. Never add or select without a yes. If a task is already selected, do not replace it unless the user asks to switch.",
13
- "Reuse a pinote_tags name when it fits. One tag; case-sensitive, trimmed, at most 64 characters. pinote_tag changes an existing active or in-progress task after pinote_get; pass updated_at. Do not use pinote_update for tags or task text.",
13
+ "Reuse a pinote_tags name when it fits. One tag; case-sensitive, trimmed, at most 64 characters. Tags are set when creating tasks with pinote_add; agent tools cannot retag existing tasks. Do not use pinote_update_current for tags or task text.",
14
14
  ];
15
15
  type Task = {
16
16
  id: number;
@@ -24,7 +24,6 @@ type Task = {
24
24
  type Summary = Pick<Task, "id" | "text" | "state" | "tag">;
25
25
  // The standard Pi footer accepts text, so ship a portable terminal glyph, not a theme icon.
26
26
  const noteIcon = readFileSync(new URL("./icons/note.txt", import.meta.url), "utf8").trim();
27
- const idSchema = Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
28
27
  const compatible = "Incompatible note CLI response. Install pinote 0.3.0+ and check note on PATH.";
29
28
  const validId = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0;
30
29
  const record = (value: unknown): value is Record<string, unknown> =>
@@ -118,6 +117,7 @@ export default function (pi: ExtensionAPI) {
118
117
  };
119
118
  const writeTask = async (
120
119
  ctx: ExtensionContext, signal: AbortSignal | undefined, argv: string[], id?: number, minimum = "0.3.0",
120
+ canRun = () => true,
121
121
  ) => {
122
122
  if (pending) throw new Error("A pinote operation is already open. Retry after it finishes.");
123
123
  const operation = Symbol();
@@ -125,7 +125,7 @@ export default function (pi: ExtensionAPI) {
125
125
  const generation = epoch;
126
126
  refreshSerial++;
127
127
  try {
128
- return requiredTask(await run(argv, signal, () => alive && generation === epoch, minimum), id);
128
+ return requiredTask(await run(argv, signal, () => alive && generation === epoch && canRun(), minimum), id);
129
129
  } finally {
130
130
  if (pending === operation) pending = undefined;
131
131
  if (alive && generation === epoch) await refresh(ctx);
@@ -162,7 +162,8 @@ export default function (pi: ExtensionAPI) {
162
162
  };
163
163
  const selected = async (_ctx: ExtensionContext, signal?: AbortSignal) => {
164
164
  const generation = epoch;
165
- const canUse = () => alive && generation === epoch;
165
+ const branch = branchEpoch;
166
+ const canUse = () => alive && generation === epoch && branch === branchEpoch;
166
167
  await ensureCLI(signal, canUse);
167
168
  return currentTask(signal, canUse);
168
169
  };
@@ -348,7 +349,7 @@ export default function (pi: ExtensionAPI) {
348
349
  const chosen = await startTask(id, canAct);
349
350
  if (!chosen || !canAct()) return;
350
351
  const draft = ctx.ui.getEditorText();
351
- const handoff = `Read task #${chosen.id} with pinote_get and work on it. Ask only if blocked. Save progress with pinote_update.`;
352
+ const handoff = `Read the current task (#${chosen.id}) with pinote_get_current and work on it. Ask only if blocked. Save progress with pinote_update_current.`;
352
353
  ctx.ui.setEditorText(draft ? `${draft}\n\n${handoff}` : handoff);
353
354
  ctx.ui.notify(`Pinote #${chosen.id} is in progress. Task added to input.`, "info");
354
355
  } catch (error) {
@@ -361,38 +362,44 @@ export default function (pi: ExtensionAPI) {
361
362
  });
362
363
 
363
364
  pi.registerTool({
364
- name: "pinote_get",
365
- label: "Pinote get",
365
+ name: "pinote_get_current",
366
+ label: "Pinote get current",
366
367
  promptGuidelines: createGuidance,
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.",
368
- parameters: Type.Object({ id: Type.Optional(idSchema) }),
369
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
370
- if (params.id !== undefined && !validId(params.id)) throw new Error("id must be a positive safe integer.");
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);
368
+ description: "Read this session's current pinote task and its Markdown agent fields/revision. Returns null when none is selected. No ID argument. Use the returned updated_at for pinote_update_current.",
369
+ parameters: Type.Object({}, { additionalProperties: false }),
370
+ async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
371
+ return toolResult(await selected(ctx, signal));
375
372
  },
376
373
  });
377
374
  pi.registerTool({
378
- name: "pinote_update",
379
- label: "Pinote update",
375
+ name: "pinote_update_current",
376
+ label: "Pinote update current",
380
377
  promptGuidelines: [handoffGuidance],
381
- description: "Patch arbitrary agent handoff fields on an explicit pinote task. Read first with pinote_get; pass its updated_at as expected_updated_at. set merges label/value pairs without replacing other fields or task text; values are Markdown, e.g. PR: [Fix #42](https://github.com/org/repo/pull/42). Set PR to one GitHub pull-request URL or Markdown link to show it in the footer and watch for merge confirmation in interactive Pi. remove deletes named fields. A stale revision fails; read again before retrying. Does not complete the task or change its tag.",
378
+ description: "Patch arbitrary agent handoff fields on this session's current pinote task. No ID argument. Read first with pinote_get_current; pass its updated_at as expected_updated_at. Fails when no task is selected or the selection changes during the operation. set merges label/value pairs without replacing other fields or task text; values are Markdown, e.g. PR: [Fix #42](https://github.com/org/repo/pull/42). Set PR to one GitHub pull-request URL or Markdown link to show PR #N in the footer and watch for merge confirmation in interactive Pi. Set Bar to newline-separated field labels to show those Markdown fields in the footer; links in the text are clickable. remove deletes named fields. A stale revision fails; read again before retrying. Does not complete the task or change its tag.",
382
379
  parameters: Type.Object({
383
- id: idSchema,
384
380
  expected_updated_at: Type.String({ minLength: 1 }),
385
381
  set: Type.Optional(Type.Record(Type.String(), Type.String())),
386
382
  remove: Type.Optional(Type.Array(Type.String())),
387
- }),
383
+ }, { additionalProperties: false }),
388
384
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
389
- if (!validId(params.id) || !text(params.expected_updated_at) || !params.expected_updated_at.trim()) {
390
- throw new Error("id and expected_updated_at are required; read the task first.");
385
+ if (!text(params.expected_updated_at) || !params.expected_updated_at.trim()) {
386
+ throw new Error("expected_updated_at is required; read the current task first.");
391
387
  }
392
- const argv = ["agent", "update", String(params.id), "--expected-updated-at", params.expected_updated_at,
388
+ const generation = epoch;
389
+ const branch = branchEpoch;
390
+ const id = selectedId;
391
+ const canRun = () => alive && generation === epoch && branch === branchEpoch && selectedId === id;
392
+ const current = await selected(ctx, signal);
393
+ if (!canRun()) throw new Error("Pinote operation cancelled: the session or selected task changed.");
394
+ if (!current) throw new Error("No selected pinote task. Select one with /pi-note first.");
395
+ const argv = ["agent", "update", String(current.id), "--expected-updated-at", params.expected_updated_at,
393
396
  "--set-json", JSON.stringify(params.set ?? {})];
394
397
  for (const label of params.remove ?? []) argv.push(`--remove=${label}`);
395
- return toolResult(await writeTask(ctx, signal, argv, params.id));
398
+ const updated = await writeTask(ctx, signal, argv, current.id, "0.3.0", canRun);
399
+ if (!canRun()) {
400
+ throw new Error("Pinote operation cancelled: the session or selected task changed. The write may have committed; read the current task before retrying.");
401
+ }
402
+ return toolResult(updated);
396
403
  },
397
404
  });
398
405
  pi.registerTool({
@@ -444,25 +451,4 @@ export default function (pi: ExtensionAPI) {
444
451
  }
445
452
  },
446
453
  });
447
- pi.registerTool({
448
- name: "pinote_tag",
449
- label: "Pinote tag",
450
- description: "Set or clear the tag on an active or in-progress pinote task. Read first with pinote_get and pass updated_at as expected_updated_at. Provide tag or clear true, not both. A stale revision fails without writing. Does not change task text.",
451
- parameters: Type.Object({
452
- id: idSchema,
453
- expected_updated_at: Type.String({ minLength: 1 }),
454
- tag: Type.Optional(Type.String({ minLength: 1 })),
455
- clear: Type.Optional(Type.Boolean()),
456
- }),
457
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
458
- if (!validId(params.id) || !text(params.expected_updated_at) || !params.expected_updated_at.trim()) {
459
- throw new Error("id and expected_updated_at are required; read the task first.");
460
- }
461
- if ((params.tag !== undefined) === (params.clear === true)) throw new Error("Provide tag or clear true, not both.");
462
- if (params.tag !== undefined && (!text(params.tag) || !params.tag.trim())) throw new Error("tag must be a non-empty name.");
463
- const argv = ["agent", "tag", String(params.id), "--expected-updated-at", params.expected_updated_at];
464
- argv.push(params.clear === true ? "--clear" : `--tag=${params.tag}`);
465
- return toolResult(await writeTask(ctx, signal, argv, params.id, "0.4.0"));
466
- },
467
- });
468
454
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,7 +14,7 @@
14
14
  "homepage": "https://github.com/kvidzibo/pinote/tree/main/pi#readme",
15
15
  "bugs": { "url": "https://github.com/kvidzibo/pinote/issues" },
16
16
  "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
17
- "files": ["index.ts", "pr-watch.ts", "task-picker.ts", "setup.ts", "icons", "README.md", "LICENSE"],
17
+ "files": ["index.ts", "pr-watch.ts", "footer-links.ts", "task-picker.ts", "setup.ts", "icons", "README.md", "LICENSE"],
18
18
  "pi": { "extensions": ["./index.ts"] },
19
19
  "scripts": {
20
20
  "check": "tsc --noEmit",
package/pr-watch.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { footerChips, footerStatusKey, legacyFooterStatusKey, renderFooterLinks, type FooterChip } from "./footer-links.ts";
2
3
 
3
4
  export type WatchedTask = {
4
5
  id: number;
@@ -60,6 +61,7 @@ export function createPRWatcher(pi: ExtensionAPI, deps: Dependencies) {
60
61
  let ctx: ExtensionContext | undefined;
61
62
  let watched: WatchedTask | null = null;
62
63
  let visiblePR: PR | undefined;
64
+ let visibleLinks: FooterChip[] = [];
63
65
  let timer: ReturnType<typeof setTimeout> | undefined;
64
66
  let controller = new AbortController();
65
67
  let generation = 0;
@@ -78,11 +80,13 @@ export function createPRWatcher(pi: ExtensionAPI, deps: Dependencies) {
78
80
  return changed;
79
81
  };
80
82
  const paint = () => {
81
- if (!ctx) return;
82
- const pr = visiblePR;
83
- ctx.ui.setStatus("pinote-pr", pr
84
- ? ctx.ui.theme.fg("mdLink", `\x1b]8;;${pr.url}\x1b\\PR #${pr.number}\x1b]8;;\x1b\\`)
85
- : undefined);
83
+ const current = ctx;
84
+ if (!current) return;
85
+ // Clear the old key first so a reload cannot show PR twice.
86
+ current.ui.setStatus(legacyFooterStatusKey, undefined);
87
+ current.ui.setStatus(footerStatusKey, renderFooterLinks(
88
+ visibleLinks, (value) => current.ui.theme.fg("mdLink", value),
89
+ ));
86
90
  };
87
91
  const schedule = (delay = interval) => {
88
92
  if (!ctx || !interval || running) return;
@@ -95,6 +99,7 @@ export function createPRWatcher(pi: ExtensionAPI, deps: Dependencies) {
95
99
  revision++;
96
100
  // Display only the active selection, independently of the retained merge watch.
97
101
  visiblePR = current && ["active", "in_progress"].includes(current.state) ? taskPR(current) : undefined;
102
+ visibleLinks = footerChips(current, visiblePR);
98
103
  // Completion clears selection. Keep the last task until poll verifies its state.
99
104
  const changed = current ? remember(taskPR(current) ? current : null) : false;
100
105
  paint();
@@ -216,10 +221,12 @@ export function createPRWatcher(pi: ExtensionAPI, deps: Dependencies) {
216
221
  controller.abort();
217
222
  controller = new AbortController();
218
223
  clearTimeout(timer);
219
- ctx?.ui.setStatus("pinote-pr", undefined);
224
+ ctx?.ui.setStatus(legacyFooterStatusKey, undefined);
225
+ ctx?.ui.setStatus(footerStatusKey, undefined);
220
226
  ctx = undefined;
221
227
  watched = null;
222
228
  visiblePR = undefined;
229
+ visibleLinks = [];
223
230
  running = false;
224
231
  warned = false;
225
232
  },