pi-note 0.13.2 → 0.15.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
@@ -68,9 +68,7 @@ task, so existing values can be configured without creating another task.
68
68
  Type in the task picker to filter by text, ID, tag, or state (all words must match).
69
69
  Use ↑/↓ and Enter to select, or Esc to cancel. Rows show **●** for in progress or
70
70
  **○** for active, followed by the tag (`[Untagged]` when absent).
71
- The normal Pi status area shows `📌 [tag] Task title` without replacing other footers.
72
- The footer omits the task ID and state and shows only the first line, truncated to the configured width (default 60 terminal columns including the pin and tag). Overflow ends with `...`.
73
- The pin glyph is bundled in `icons/note.txt`; it uses terminal fonts, not a Nerd Font or icon theme.
71
+ The normal Pi status area shows selected-task controls and title without replacing other footers. The footer omits the task ID and state and shows only the first line, bounded by `footer.titleWidth` (default 60 terminal columns including the pin, controls, tag and title). Overflow ends with `...`. The title remains clickable for preview; task controls are separate. At very small widths controls are hidden (pin only). The pin and controls are bundled in `icons/note.txt`, `icons/check.txt`, `icons/cross.txt`, and `icons/add.txt`; they use terminal fonts, not a Nerd Font or icon theme.
74
72
 
75
73
  ### Display-only preview
76
74
 
@@ -186,32 +184,26 @@ When allowed by `taskOfferPolicy`, the agent uses `pinote_propose` instead of
186
184
  asking in chat. The pin footer shows a pending suggestion, distinct from a selected task:
187
185
 
188
186
  ```text
189
- 📌 Suggested: ✓ Add ✕ Dismiss · [tag] Task title...
187
+ 📌 ✕ + · [tag] Task title...
188
+ 📌 ✓ · [tag] Task title...
190
189
  ```
191
190
 
192
- ![Suggested task beside the footer pin](../docs/images/pi-task-suggestion.png)
193
-
194
- - **✓ Add** creates the note, starts it, and selects it for this session.
195
- - **✕ Dismiss** hides the suggestion without creating a note; further offers are suppressed
196
- until a new session or reload.
197
-
198
- The configured `footer.titleWidth` bounds the whole suggestion (default 60 columns).
199
- Controls precede the variable text so long titles cannot push them off the right edge.
200
- The tag is omitted when space is tight; the first-line title truncates with `...`.
201
- Smaller budgets shorten controls to ✓ and ✕; tiny budgets show only the suggestion cue.
202
- Pi can still clip the combined status row on narrow terminals; use the commands below
203
- if the controls are not visible. Accepting replaces the suggestion with the selected task.
204
-
205
- Neither action submits input or starts an agent turn; editor drafts stay unchanged.
206
- The icons reuse the [Kitty preview handler](#display-only-preview), with no additional
207
- configuration. Fullscreen Kitty uses **Ctrl+Shift+click**. Keyboard alternatives are
208
- `/pi-note-yes` and `/pi-note-no`, including when clickable links are unavailable.
209
- An existing suggestion is retained rather than replaced. Selection, tree navigation,
210
- reload and shutdown invalidate its links; repeated acceptance cannot duplicate a note.
211
- A busy Pinote operation blocks acceptance; retry after it finishes. On an error, check
212
- `/pi-note` before retrying, because a write may already have committed.
213
- Noninteractive agents still ask in chat before using `pinote_add` with `select: true`.
214
- An existing selection is not replaced unless the user asks to switch. Reuse a saved
191
+ ![Suggested task controls](../docs/images/pi-task-suggestion.png)
192
+ ![Selected task with Done control](../docs/images/pi-task-selected.png)
193
+
194
+ - Muted **✕** dismisses without creating a note; further offers are suppressed until a new session.
195
+ - Accent **+** creates, starts, and selects the suggested task.
196
+ - Green **✓** completes the selected task, starts a clean unselected session, and reloads Pi without a completion prompt. Menu **Done** and `/pi-note-done` do the same.
197
+
198
+ Done occupies the former ✕ position, not the + position, so clicking Add twice cannot accidentally complete the new task. The former + cell stays blank. The selected task's title is a separate preview link.
199
+
200
+ The configured `footer.titleWidth` bounds the entire row (default 60 columns). Controls stay first; the tag is dropped before truncating the first-line title with `...`. Tiny widths hide controls (pin only). Pi can still clip the combined status row on narrow terminals.
201
+
202
+ Controls use the [Kitty preview handler](#display-only-preview), with no additional configuration. Fullscreen Kitty uses **Ctrl+Shift+click**. Keyboard alternatives are `/pi-note-no`, `/pi-note-yes`, and `/pi-note-done`. No action submits input or starts an agent turn. Add and Dismiss preserve editor drafts; Done clears the new session's editor, so save unfinished input before completing a task.
203
+
204
+ Completion requires an idle Pi session and no pending Pinote operation. It rejects stale selected-task links and externally changed revisions. Accepted mutations drain even if the helper disconnects. On an error, check `/pi-note` before retrying, because a write may already have committed. Failed or stale writes never restart Pi. If another extension cancels the new session, the task remains completed and Pi reports the cancellation. The reload runs through the new session's fresh context; retired contexts are not reused.
205
+
206
+ An existing suggestion is retained rather than replaced. Pending suggestions survive `/reload` and session resume, including their full text and tag; new links replace stale capabilities. Accepted or dismissed suggestions never reappear on reload. New sessions and forks start without a suggestion; selecting a task or navigating the tree clears it. Suggestion state is local, display-only session metadata, not model context. Pi saves the session after the first submitted message; quitting before that can still lose unsaved session state. Repeated acceptance cannot duplicate a note. A busy Pinote operation blocks acceptance. Noninteractive agents still ask in chat before using `pinote_add` with `select: true`. An existing selection is not replaced unless the user asks to switch. Reuse a saved
215
207
  tag name when it fits.
216
208
  Task text starts with a short, action-oriented title (aim for at most 60 characters).
217
209
  Put context, URLs, commands, and acceptance criteria after a blank line; the title
@@ -225,7 +217,7 @@ list, append its label to `Bar`, one label per line. Do not list `PR` in `Bar`.
225
217
  Agents use `pinote_update_current`; there is no separate `add_to_bottom_bar` tool.
226
218
  This is guidance, not truncation or a storage limit.
227
219
 
228
- Values are Markdown strings; no fields are required. `PR` enables the watcher below.
220
+ Values are Markdown strings; no fields are required. `PR` can display a clickable link without GitHub polling.
229
221
  `Bar` chooses extra footer fields unless configuration overrides it:
230
222
 
231
223
  ```markdown
@@ -322,40 +314,18 @@ Pi's final ellipsis is plain text; click the remaining task text to preview it.
322
314
  This extension does not replace other footers. Other fields, such as `Jira` and
323
315
  `CWD` in the example, stay off the footer unless selected.
324
316
 
325
- ### PR merge watcher
326
-
327
- Set `PR` with `pinote_update_current` to one `https://github.com/owner/repo/pull/123` URL
328
- or Markdown link. Legacy footer settings show a clickable **PR #123**; global
329
- field settings can change or hide that presentation without disabling the watcher.
330
- Other hosts, multiple links, and prose are not watched.
331
-
332
- Interactive Pi checks only the selected task's PR using authenticated `gh`
333
- (`gh auth login`). Polling defaults to 60 seconds; launch Pi with
334
- `PINOTE_PR_POLL_SECONDS=120 pi` to change it, or `0` to disable polling.
335
- Allowed intervals are 10–86400 seconds. Configured links remain visible when polling is disabled.
336
- Network/authentication failures warn once until recovery and retry next interval.
337
- No polling runs in print/RPC mode or after Pi exits.
338
-
339
- On merge, Pi waits until idle and asks **Mark this task completed?** In Kitty,
340
- indeterminate progress animates the tab while the prompt awaits input (with Kitty's
341
- default progress-aware tab title or a working/ready renderer). Progress clears on
342
- response, cancellation, or shutdown; other terminals and redirected output are untouched.
343
- Confirmation uses the same guarded CLI Done operation as `/pi-note`; declining leaves
344
- the task unchanged. If it is already done, Pi says **PR #123 was merged and the task is
345
- already completed**, without completing it again. Completion clears the selection
346
- and hides the footer link, even with polling disabled. The background watch remains
347
- until another task is selected, the link is removed, or Pi exits. Removed/scheduled tasks are no longer watched.
348
-
349
- The watched task ID/link and merge acknowledgements are saved in the Pi session,
350
- so `/reload` and session resume retain completed-task watches without repeating
351
- acknowledged prompts. A new session can notify again for a
352
- selected task. Task or PR changes during confirmation cannot complete a different
353
- task. No completion-hook system is added; this uses Pinote's existing Done flow.
354
- A separate branch-based PR-status extension may show a duplicate link; disable it
355
- if you only want task-linked PRs.
356
-
357
- Task data stays local except for GitHub status requests. Tools work without a TUI,
358
- but `/pi-note` needs an idle TUI.
317
+ ### PR links without polling
318
+
319
+ Set `PR` with `pinote_update_current` to one `https://github.com/owner/repo/pull/123`
320
+ URL or Markdown link. Legacy footer settings show clickable **PR #123**; global
321
+ field settings can change or hide it. PR and other field links are rendered locally
322
+ for the active selection and disappear when that selection is cleared.
323
+
324
+ Pinote no longer polls GitHub, runs `gh`, watches completed tasks, or prompts on
325
+ PR merges. No GitHub authentication is required. `PINOTE_PR_POLL_SECONDS` is no
326
+ longer used. Other separately installed Pi PR/Git extensions are unaffected.
327
+
328
+ Tools work without a TUI, but `/pi-note` needs an idle TUI.
359
329
  This package does not synchronize databases or paths between machines. Note text
360
330
  and fields loaded into Pi are sent to the configured model when used as context;
361
331
  avoid secrets. Agent commands do not send desktop notifications.
@@ -0,0 +1,40 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { footerChips, footerStatusKey, legacyFooterStatusKey, renderFooterLinks } from "./footer-links.ts";
3
+ import type { FooterConfig } from "./footer-config.ts";
4
+
5
+ export type FooterTask = {
6
+ id: number;
7
+ state: string;
8
+ updated_at: string;
9
+ agent_notes: Record<string, string>;
10
+ };
11
+ type PR = { url: string; number: string };
12
+
13
+ // Accept one bare URL or Markdown link, never arbitrary hosts or terminal escapes.
14
+ export function taskPR(task: FooterTask | null): PR | undefined {
15
+ const field = task?.agent_notes.PR?.trim();
16
+ if (!field) return;
17
+ const url = /^\[[^\r\n]*\]\(([^\s]+)\)$/u.exec(field)?.[1] ?? field;
18
+ const match = /^https:\/\/github\.com\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_.-]+)\/pull\/([1-9][0-9]*)\/?$/u.exec(url);
19
+ if (!match || [".", ".."].includes(match[2]) || !Number.isSafeInteger(Number(match[3]))) return;
20
+ return { url: `https://github.com/${match[1]}/${match[2]}/pull/${match[3]}`, number: match[3] };
21
+ }
22
+
23
+ export function clearTaskFooter(ctx: ExtensionContext): void {
24
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
25
+ ctx.ui.setStatus(legacyFooterStatusKey, undefined);
26
+ ctx.ui.setStatus(footerStatusKey, undefined);
27
+ }
28
+
29
+ export function updateTaskFooter(
30
+ ctx: ExtensionContext, current: FooterTask | null, config?: FooterConfig,
31
+ ): void {
32
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
33
+ ctx.ui.setStatus(legacyFooterStatusKey, undefined);
34
+ const active = current && ["active", "in_progress"].includes(current.state) ? current : null;
35
+ const pr = active ? taskPR(active) : undefined;
36
+ const value = renderFooterLinks(
37
+ footerChips(active, pr, config), (text) => ctx.ui.theme.fg("mdLink", text),
38
+ );
39
+ ctx.ui.setStatus(footerStatusKey, value);
40
+ }
package/icons/add.txt ADDED
@@ -0,0 +1 @@
1
+ +
package/index.ts CHANGED
@@ -1,16 +1,17 @@
1
- import { getAgentDir, getMarkdownTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { getAgentDir, getMarkdownTheme, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Container, Editor, Markdown, Text, truncateToWidth, type Keybinding } from "@earendil-works/pi-tui";
3
3
  import { readFileSync } from "node:fs";
4
+ import { randomBytes } from "node:crypto";
4
5
  import { join } from "node:path";
5
6
  import { stripVTControlCharacters } from "node:util";
6
7
  import { Type } from "typebox";
7
- import { createPRWatcher } from "./pr-watch.ts";
8
+ import { updateTaskFooter, clearTaskFooter } from "./footer-status.ts";
8
9
  import { defaultFooterConfig, defaultHandoffPrompt, effectiveFooterFields, loadFooterConfig, parseHandoffPrompt, readFooterDocument, saveFooterConfig, type FooterConfig, type PinoteSettings } from "./footer-config.ts";
9
10
  import { FooterSettings, TaskMenu, withSettingsTab } from "./footer-settings.ts";
10
11
  import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
11
12
  import { bundledCLIVersion, cliMenu, detectCLI, setupCLI, setupHint, versionAtLeast, type CLIAction } from "./setup.ts";
12
13
  import { createPreviewBridge } from "./preview-bridge.ts";
13
- import { renderSuggestion } from "./task-suggestion.ts";
14
+ import { renderSuggestion, renderSelectedTask } from "./task-suggestion.ts";
14
15
 
15
16
  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. Read pinote_fields to learn the user's globally configured footer field names and presentation. Populate those fields with meaningful task values when relevant using pinote_update_current; fields without values stay hidden. Footer fields and widths are chosen by the user's pi-note.json config. Set configured fields with Markdown values; links are clickable. When footer.fields is unset, Bar selects labels, one per line. Do not list PR in Bar. Remove a field to hide it. Do not modify config without user approval.";
16
17
  const offerGuidance = {
@@ -18,7 +19,7 @@ const offerGuidance = {
18
19
  "github-remote": "When the user gives work and no pinote task is selected, first verify with Git that the current repository has a remote whose URL host is github.com (HTTPS or SSH). Only then use pinote_propose to show one suggested `[tag] task` in the bottom bar instead of asking in chat. In noninteractive modes, ask before creating it. Local paths, other hosts, and GitHub-looking URL paths do not qualify. If no GitHub remote is verified, do not offer a task; explicit user requests to create one remain allowed.",
19
20
  never: "Do not offer to create a pinote task. Create one only when the user explicitly requests it.",
20
21
  };
21
- const consentGuidance = "The suggestion bar's check creates, starts and selects the note; its cross dismisses it. Continue the requested work while the suggestion is pending; do not ask again in chat or create a note yourself. On a chat no, continue without a note; on a chat yes, call pinote_add with select true. Never add or select without consent. If a task is already selected, do not replace it unless the user asks to switch.";
22
+ const consentGuidance = "The suggestion bar's plus creates, starts and selects the note; its cross dismisses it. Continue the requested work while the suggestion is pending; do not ask again in chat or create a note yourself. On a chat no, continue without a note; on a chat yes, call pinote_add with select true. Never add or select without consent. If a task is already selected, do not replace it unless the user asks to switch.";
22
23
  const titleGuidance = "Start task text with a short, action-oriented summary (aim for at most 60 characters). Put context, URLs, commands, and acceptance criteria after a blank line. Never put implementation details in the title.";
23
24
  const tagGuidance = "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.";
24
25
  type Task = {
@@ -126,13 +127,37 @@ export default function (pi: ExtensionAPI) {
126
127
  view.addChild(new Markdown(clean(entry.data.markdown), 0, 0, getMarkdownTheme()));
127
128
  return view;
128
129
  });
130
+ const runtimeToken = randomBytes(16).toString("hex");
131
+ let completionDispatch: {
132
+ token: string; selection: string; task: Task; started: () => void; finish: (accepted: boolean) => void;
133
+ } | undefined;
134
+ const cancelCompletionDispatch = () => completionDispatch?.finish(false);
129
135
  let previewBridge: ReturnType<typeof createPreviewBridge> | undefined;
136
+ const suggestionType = "pinote-suggestion";
130
137
  let suggestion: { text: string; tag?: string } | undefined;
131
138
  let offerDeclined = false;
132
- const clearSuggestion = (ctx: ExtensionContext) => {
139
+ const saveSuggestion = () => pi.appendEntry(suggestionType, { suggestion: suggestion ?? null, declined: offerDeclined });
140
+ const clearSuggestion = (ctx: ExtensionContext, persist = true) => {
141
+ const hadSuggestion = suggestion !== undefined;
133
142
  previewBridge?.clearSuggestion();
134
- if (suggestion && ctx.hasUI && ctx.mode === "tui") ctx.ui.setStatus("pinote", undefined);
143
+ if (hadSuggestion && ctx.hasUI && ctx.mode === "tui") ctx.ui.setStatus("pinote", undefined);
135
144
  suggestion = undefined;
145
+ if (persist && hadSuggestion) saveSuggestion();
146
+ };
147
+ const restoreSuggestion = (ctx: ExtensionContext) => {
148
+ suggestion = undefined;
149
+ offerDeclined = false;
150
+ for (const entry of ctx.sessionManager.getBranch()) {
151
+ if (entry.type !== "custom" || entry.customType !== suggestionType) continue;
152
+ const data = entry.data;
153
+ if (!record(data) || typeof data.declined !== "boolean") continue;
154
+ const value = data.suggestion;
155
+ suggestion = record(value) && text(value.text) && value.text.trim() &&
156
+ (value.tag === undefined || (text(value.tag) && value.tag.trim() && value.tag.length <= 64))
157
+ ? { text: value.text, ...(value.tag === undefined ? {} : { tag: value.tag as string }) } : undefined;
158
+ offerDeclined = data.declined;
159
+ }
160
+ if (selectedId !== null || offerDeclined) suggestion = undefined;
136
161
  };
137
162
  let alive = true;
138
163
  let epoch = 0;
@@ -228,7 +253,7 @@ export default function (pi: ExtensionAPI) {
228
253
  const current = task(await run(["agent", "get", String(id)], signal, canUse));
229
254
  if (!canUse() || selectedId !== id) return null;
230
255
  if (!current || current.id !== id || !["active", "in_progress"].includes(current.state)) {
231
- if (canUse() && selectedId === id) remember(null);
256
+ if (!pending && canUse() && selectedId === id) remember(null);
232
257
  return null;
233
258
  }
234
259
  return current;
@@ -267,38 +292,24 @@ export default function (pi: ExtensionAPI) {
267
292
  const previewId = current?.id ?? null;
268
293
  const previewBranch = branchEpoch;
269
294
  previewBridge?.setTask(previewId, (signal) => alive && generation === epoch && previewBranch === branchEpoch &&
270
- selectedId === previewId ? preview(ctx, signal) : Promise.resolve(false));
271
- const url = previewBridge?.url();
272
- // Truncate before linking so the task label, including configured overflow, is clickable.
273
- const label = current ? truncateToWidth(`${noteIcon} ${taskTag(current)} ${firstLine(current)}`, footerConfig.titleWidth, "...") : undefined;
274
- ctx.ui.setStatus("pinote", current ? (label && url ? `\x1b]8;;${url}\x07${label}\x1b]8;;\x07` : label) : suggestionStatus(ctx));
275
- watcher.update(ctx, current);
295
+ selectedId === previewId ? preview(ctx, signal) : Promise.resolve(false), current ? {
296
+ revision: current.updated_at,
297
+ invoke: () => alive && generation === epoch && previewBranch === branchEpoch && selectedId === previewId
298
+ ? dispatchCompletion(ctx, current, `${runtimeToken}:${generation}:${previewBranch}:${previewId}`) : Promise.resolve(false),
299
+ } : undefined);
300
+ ctx.ui.setStatus("pinote", current ? renderSelectedTask(current, footerConfig.titleWidth, ctx.ui.theme, {
301
+ preview: previewBridge?.url(), done: previewBridge?.doneUrl(),
302
+ }) : suggestionStatus(ctx));
303
+ updateTaskFooter(ctx, current, footerConfig);
276
304
  }
277
305
  } catch {
278
306
  if (alive && generation === epoch && serial === refreshSerial) {
279
307
  previewBridge?.invalidate();
308
+ clearTaskFooter(ctx);
280
309
  ctx.ui.setStatus("pinote", suggestionStatus(ctx) ?? `${noteIcon} Pinote unavailable · ${cliState === "upgrade" ? "/pi-note-upgrade" : cliState === "setup" ? "/pi-note-setup" : "/pi-note"}`);
281
310
  }
282
311
  }
283
312
  };
284
- const watcher = createPRWatcher(pi, {
285
- selected,
286
- footerConfig: () => footerConfig,
287
- get: async (id) => requiredTask(await run(["agent", "get", String(id)]), id),
288
- done: async (current, canAct) => {
289
- const result = requiredTask(await run([
290
- "agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
291
- ], undefined, canAct), current.id);
292
- if (result.state !== "done") throw new Error(compatible);
293
- },
294
- claim: () => {
295
- if (pending) return;
296
- const operation = Symbol();
297
- pending = operation;
298
- return () => { if (pending === operation) pending = undefined; };
299
- },
300
- refresh,
301
- });
302
313
  const preview = async (ctx: ExtensionContext, requestSignal?: AbortSignal): Promise<boolean> => {
303
314
  if (!ctx.hasUI || ctx.mode !== "tui" || !ctx.isIdle() || pending || !validId(selectedId)) return false;
304
315
  const generation = epoch;
@@ -327,6 +338,76 @@ export default function (pi: ExtensionAPI) {
327
338
  if (pending === operation) pending = undefined;
328
339
  }
329
340
  };
341
+ const dispatchCompletion = (ctx: ExtensionContext, displayed: Task, selection: string): Promise<boolean> => {
342
+ if (!ctx.isIdle() || pending || completionDispatch) return Promise.resolve(false);
343
+ const token = `${selection}:${randomBytes(8).toString("hex")}`;
344
+ return new Promise((resolve) => {
345
+ const finish = (accepted: boolean) => {
346
+ clearTimeout(timer);
347
+ if (completionDispatch?.token === token) completionDispatch = undefined;
348
+ resolve(accepted);
349
+ };
350
+ const timer = setTimeout(() => {
351
+ finish(false);
352
+ if (alive && activeContext === ctx) ctx.ui.notify("Pinote completion could not start. Use /pi-note-done.", "warning");
353
+ }, 4000);
354
+ completionDispatch = { token, selection, task: displayed, started: () => clearTimeout(timer), finish };
355
+ try { pi.sendUserMessage(`/pi-note-done ${token}`, { expandPromptTemplates: true }); }
356
+ catch { finish(false); }
357
+ });
358
+ };
359
+ const completeSelected = async (args: string, ctx: ExtensionCommandContext, displayed?: Task) => {
360
+ const token = args.trim();
361
+ const request = token ? completionDispatch : undefined;
362
+ 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)) {
364
+ request?.finish(false);
365
+ if (ctx.hasUI) ctx.ui.notify("Select a task and wait until Pi is idle and the Pinote operation has finished.", "warning");
366
+ return;
367
+ }
368
+ const generation = epoch;
369
+ const branch = branchEpoch;
370
+ const id = selectedId;
371
+ const operation = Symbol();
372
+ pending = operation;
373
+ request?.started();
374
+ refreshSerial++;
375
+ const canAct = () => alive && generation === epoch && branch === branchEpoch && selectedId === id && ctx.isIdle();
376
+ let wrote = false;
377
+ try {
378
+ const current = request?.task ?? displayed ?? await currentTask(undefined, canAct);
379
+ if (!current || current.id !== id || !canAct()) return;
380
+ const completed = requiredTask(await run([
381
+ "agent", "done", String(id), "--expected-updated-at", current.updated_at,
382
+ ], undefined, canAct), id);
383
+ wrote = true;
384
+ if (completed.state !== "done") throw new Error(compatible);
385
+ if (!canAct()) throw new Error("The session or selection changed. Completion may have committed; read the task before retrying.");
386
+ request?.finish(true);
387
+ remember(null, branch);
388
+ await refresh(ctx);
389
+ if (!alive || generation !== epoch || branch !== branchEpoch || selectedId !== null || !ctx.isIdle()) {
390
+ throw new Error("Task completed, but the session changed. Start a new session manually.");
391
+ }
392
+ // Only a fresh command context may clear the new editor and reload the runtime.
393
+ const result = await ctx.newSession({ withSession: async (fresh) => {
394
+ fresh.ui.setEditorText("");
395
+ await fresh.reload();
396
+ } });
397
+ if (result.cancelled) ctx.ui.notify("Task completed; new session was cancelled.", "warning");
398
+ } catch (error) {
399
+ if (alive && generation === epoch) ctx.ui.notify(
400
+ `Pinote completion: ${clean(String(error))}${wrote ? " Check the task before retrying." : ""}`, "error");
401
+ } finally {
402
+ request?.finish(false);
403
+ if (pending === operation) pending = undefined;
404
+ if (alive && generation === epoch) await refresh(ctx);
405
+ }
406
+ };
407
+ pi.registerCommand("pi-note-done", {
408
+ description: "Complete the selected task, start a clean session, and reload Pi",
409
+ handler: (args, ctx) => completeSelected(args, ctx),
410
+ });
330
411
  pi.registerCommand("pi-note-preview", {
331
412
  description: "Display the selected task locally without sending it to the model",
332
413
  handler: async (args, ctx) => {
@@ -337,10 +418,9 @@ export default function (pi: ExtensionAPI) {
337
418
  });
338
419
  pi.on("session_start", async (_event, ctx) => {
339
420
  setupAbort?.abort();
340
- // Retire old polling before bridge teardown/startup yields to its scheduled callbacks.
341
- watcher.stop();
342
- clearSuggestion(activeContext ?? ctx);
343
- offerDeclined = false;
421
+ cancelCompletionDispatch();
422
+ clearTaskFooter(activeContext ?? ctx);
423
+ clearSuggestion(activeContext ?? ctx, false);
344
424
  alive = false;
345
425
  epoch++;
346
426
  branchEpoch++;
@@ -352,6 +432,13 @@ export default function (pi: ExtensionAPI) {
352
432
  alive = true;
353
433
  activeContext = ctx;
354
434
  selectedId = readSelection(ctx);
435
+ restoreSuggestion(ctx);
436
+ if (_event.reason === "new" || _event.reason === "fork") {
437
+ // A fork may include an ancestor proposal whose consent was consumed elsewhere.
438
+ clearSuggestion(ctx, false);
439
+ offerDeclined = false;
440
+ if (ctx.sessionManager.getBranch().some((entry) => entry.type === "custom" && entry.customType === suggestionType)) saveSuggestion();
441
+ }
355
442
  footerConfig = loadFooterConfig((message) => {
356
443
  if (ctx.hasUI) ctx.ui.notify(message, "warning");
357
444
  });
@@ -376,13 +463,16 @@ export default function (pi: ExtensionAPI) {
376
463
  if (!setupAbort) pending = undefined;
377
464
  await checkCLI(ctx, true);
378
465
  if (!alive || generation !== epoch) return;
379
- if (!setupAbort) watcher.start(ctx);
466
+ if (suggestion) previewBridge?.setSuggestion((choice) => respondToSuggestion(choice, ctx));
380
467
  await refresh(ctx);
381
468
  });
382
469
  pi.on("session_tree", async (_event, ctx) => {
383
470
  if (!alive) return;
384
471
  branchEpoch++;
385
- clearSuggestion(ctx);
472
+ cancelCompletionDispatch();
473
+ clearSuggestion(ctx, false);
474
+ // Never resurrect an already consumed proposal by navigating behind its tombstone.
475
+ saveSuggestion();
386
476
  previewBridge?.invalidate();
387
477
  const generation = branchEpoch;
388
478
  const id = readSelection(ctx);
@@ -395,14 +485,15 @@ export default function (pi: ExtensionAPI) {
395
485
  pi.on("agent_end", async (_event, ctx) => { await refresh(ctx); });
396
486
  pi.on("session_shutdown", async (_event, ctx) => {
397
487
  setupAbort?.abort();
398
- clearSuggestion(ctx);
488
+ cancelCompletionDispatch();
489
+ clearSuggestion(ctx, false);
399
490
  alive = false;
400
491
  activeContext = undefined;
401
492
  selectedId = null;
402
493
  epoch++;
403
494
  branchEpoch++;
404
495
  refreshSerial++;
405
- watcher.stop();
496
+ clearTaskFooter(ctx);
406
497
  if (ctx.hasUI && ctx.mode === "tui") ctx.ui.setStatus("pinote", undefined);
407
498
  const bridge = previewBridge;
408
499
  previewBridge = undefined;
@@ -426,7 +517,7 @@ export default function (pi: ExtensionAPI) {
426
517
  const generation = epoch;
427
518
  const controller = new AbortController();
428
519
  setupAbort = controller;
429
- watcher.stop();
520
+ clearTaskFooter(ctx);
430
521
  const currentSession = () => alive && generation === epoch;
431
522
  try {
432
523
  await setupCLI(pi, ctx, name === "pi-note-upgrade",
@@ -441,7 +532,6 @@ export default function (pi: ExtensionAPI) {
441
532
  const generation = epoch;
442
533
  await checkCLI(latestContext, !currentSession());
443
534
  if (!alive || generation !== epoch || setupAbort) return;
444
- watcher.start(latestContext);
445
535
  await refresh(latestContext);
446
536
  }
447
537
  }
@@ -505,13 +595,8 @@ export default function (pi: ExtensionAPI) {
505
595
  if (!action || !canAct()) return;
506
596
  if (action === "Settings") { settings = true; continue; }
507
597
  if (action === "Done" && current) {
508
- refreshSerial++;
509
- const completed = requiredTask(await run([
510
- "agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
511
- ], undefined, canAct), current.id);
512
- if (completed.state !== "done") throw new Error(compatible);
513
- if (currentSession() && branch === branchEpoch) remember(null, branch);
514
- if (currentSession()) ctx.ui.notify(`Pinote #${current.id} completed.`, "info");
598
+ if (pending === operation) pending = undefined;
599
+ await completeSelected("", ctx, current);
515
600
  return;
516
601
  }
517
602
  let id: number;
@@ -559,7 +644,7 @@ export default function (pi: ExtensionAPI) {
559
644
  name: "pinote_update_current",
560
645
  label: "Pinote update current",
561
646
  promptGuidelines: [handoffGuidance],
562
- 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. Read pinote_fields for the user's global field names, labels, link switches, formats, and widths; set those field names when their values are relevant. Fields without values stay hidden. When footer.fields is unset, 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.",
647
+ 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. Pinote does not poll GitHub or prompt on PR merges. Read pinote_fields for the user's global field names, labels, link switches, formats, and widths; set those field names when their values are relevant. Fields without values stay hidden. When footer.fields is unset, 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.",
563
648
  parameters: Type.Object({
564
649
  expected_updated_at: Type.String({ minLength: 1 }),
565
650
  set: Type.Optional(Type.Record(Type.String(), Type.String())),
@@ -649,7 +734,7 @@ export default function (pi: ExtensionAPI) {
649
734
  }
650
735
  };
651
736
  pi.registerCommand("pi-note-yes", {
652
- description: "Create, start and select the suggested task (same as ✓)",
737
+ description: "Create, start and select the suggested task (same as +)",
653
738
  handler: async (args, ctx) => {
654
739
  if (!ctx.hasUI || ctx.mode !== "tui") return;
655
740
  if (args.trim() || !await respondToSuggestion("yes", ctx)) ctx.ui.notify("No available suggestion, or a Pinote operation is busy. Retry when it finishes.", "warning");
@@ -666,7 +751,7 @@ export default function (pi: ExtensionAPI) {
666
751
  name: "pinote_propose",
667
752
  label: "Pinote suggest task",
668
753
  promptGuidelines: createGuidance,
669
- description: "Show a suggested task beside the pin icon in the footer, without creating it or asking in chat. ✓ creates, starts and selects it; ✕ dismisses it. Continue the requested work while awaiting consent. Requires an interactive TUI with no selected task. An existing suggestion is retained; a dismissed offer is not repeated. In noninteractive modes ask in chat, then use pinote_add only after consent.",
754
+ description: "Show a suggested task beside the pin icon in the footer, without creating it or asking in chat. + creates, starts and selects it; ✕ dismisses it. Continue the requested work while awaiting consent. Requires an interactive TUI with no selected task. An existing suggestion is retained; a dismissed offer is not repeated. In noninteractive modes ask in chat, then use pinote_add only after consent.",
670
755
  parameters: Type.Object({
671
756
  text: Type.String({ minLength: 1, description: "Short action-oriented title, then optional details after a blank line." }),
672
757
  tag: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
@@ -685,6 +770,7 @@ export default function (pi: ExtensionAPI) {
685
770
  if (offerDeclined) return result("dismissed; continue without a note and do not offer again");
686
771
  if (suggestion) return result("pending; existing suggestion retained, continue work without asking again");
687
772
  suggestion = { text: params.text, ...(params.tag === undefined ? {} : { tag: params.tag.trim() }) };
773
+ saveSuggestion();
688
774
  previewBridge?.setSuggestion((choice) => respondToSuggestion(choice, ctx));
689
775
  ctx.ui.setStatus("pinote", suggestionStatus(ctx));
690
776
  return result("pending; user can click ✓ to add and select or ✕ to dismiss, continue work without asking again");
@@ -717,7 +803,7 @@ export default function (pi: ExtensionAPI) {
717
803
  pending = operation;
718
804
  refreshSerial++;
719
805
  const canAct = () => alive && generation === epoch && branch === branchEpoch && pending === operation && selectedId === previousSelection;
720
- // Chat consent consumes the same suggestion as clicking ✓, even if start fails.
806
+ // Chat consent consumes the same suggestion as clicking +, even if start fails.
721
807
  clearSuggestion(ctx);
722
808
  try {
723
809
  const created = requiredTask(await run(argv, signal, canAct, "0.4.0"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.13.2",
3
+ "version": "0.15.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "files": [
29
29
  "index.ts",
30
- "pr-watch.ts",
30
+ "footer-status.ts",
31
31
  "footer-links.ts",
32
32
  "footer-config.ts",
33
33
  "footer-settings.ts",
package/preview-bridge.ts CHANGED
@@ -16,10 +16,11 @@ export function createPreviewBridge() {
16
16
  let taskId: number | null = null;
17
17
  let callback: ((signal: AbortSignal) => Promise<boolean>) | undefined;
18
18
  let nonce: string | undefined;
19
+ let completion: { revision: string; nonce: string; invoke: () => Promise<boolean> } | undefined;
19
20
  let suggestionCallback: ((choice: "yes" | "no") => Promise<boolean>) | undefined;
20
21
  let suggestionNonce: string | undefined;
21
22
  const clients = new Set<Socket>();
22
- const invalidate = () => { taskId = null; callback = undefined; nonce = undefined; };
23
+ const invalidate = () => { taskId = null; callback = undefined; nonce = undefined; completion = undefined; };
23
24
  const invalidateSuggestion = () => { suggestionCallback = undefined; suggestionNonce = undefined; };
24
25
  const cleanup = async () => {
25
26
  ready = false;
@@ -66,6 +67,7 @@ export function createPreviewBridge() {
66
67
  dispatched = true;
67
68
  const input = data.toString("utf8");
68
69
  const previewMatch = /^preview ([0-9a-f]{32}) ([1-9][0-9]*) ([0-9a-f]{32})\n$/u.exec(input);
70
+ const completionMatch = /^done ([0-9a-f]{32}) ([1-9][0-9]*) ([0-9a-f]{32})\n$/u.exec(input);
69
71
  const suggestionMatch = /^(yes|no) ([0-9a-f]{32}) 1 ([0-9a-f]{32})\n$/u.exec(input);
70
72
  const id = previewMatch ? Number(previewMatch[2]) : NaN;
71
73
  if (previewMatch && previewMatch[0] === input && previewMatch[1] === capability && Number.isSafeInteger(id) &&
@@ -78,6 +80,18 @@ export function createPreviewBridge() {
78
80
  () => { if (!client.destroyed) client.end("rejected\n"); });
79
81
  return;
80
82
  }
83
+ if (completionMatch && completionMatch[0] === input && completionMatch[1] === capability &&
84
+ Number(completionMatch[2]) === taskId && completionMatch[3] === completion?.nonce && completion) {
85
+ client.setTimeout(65000);
86
+ const acceptedCompletion = completion;
87
+ const acceptedId = taskId;
88
+ // Accepted writes drain even if the helper disconnects.
89
+ Promise.resolve().then(() => !closed && taskId === acceptedId && completion === acceptedCompletion
90
+ ? acceptedCompletion.invoke() : false)
91
+ .then((accepted) => { if (!client.destroyed) client.end(accepted === true ? "accepted\n" : "rejected\n"); },
92
+ () => { if (!client.destroyed) client.end("rejected\n"); });
93
+ return;
94
+ }
81
95
  if (suggestionMatch && suggestionMatch[0] === input && suggestionMatch[2] === capability && suggestionMatch[3] === suggestionNonce && suggestionCallback) {
82
96
  client.setTimeout(65000);
83
97
  const invoke = suggestionCallback;
@@ -111,10 +125,13 @@ export function createPreviewBridge() {
111
125
  })();
112
126
  return starting;
113
127
  },
114
- setTask(id: number | null, preview: (signal: AbortSignal) => Promise<boolean>): void {
128
+ setTask(id: number | null, preview: (signal: AbortSignal) => Promise<boolean>, done?: { revision: string; invoke: () => Promise<boolean> }): void {
115
129
  if (closed) return;
116
130
  if (id !== null && (!Number.isSafeInteger(id) || id <= 0)) throw new Error("Invalid preview task");
117
131
  if (id !== taskId) nonce = id === null ? undefined : randomBytes(16).toString("hex");
132
+ if (id === null || !done) completion = undefined;
133
+ else completion = { revision: done.revision, invoke: done.invoke,
134
+ nonce: id === taskId && completion?.revision === done.revision ? completion.nonce : randomBytes(16).toString("hex") };
118
135
  taskId = id;
119
136
  callback = id === null ? undefined : preview;
120
137
  },
@@ -123,6 +140,10 @@ export function createPreviewBridge() {
123
140
  if (!ready || closed || !socketPath || taskId === null || !nonce) return;
124
141
  return `pi-note-preview://${basename(socketPath, ".sock")}/${capability}/${taskId}/${nonce}`;
125
142
  },
143
+ doneUrl(): string | undefined {
144
+ if (!ready || closed || !socketPath || taskId === null || !completion) return;
145
+ return `pi-note-preview://${basename(socketPath, ".sock")}/${capability}/${taskId}/${completion.nonce}/done`;
146
+ },
126
147
  setSuggestion(suggestion: (choice: "yes" | "no") => Promise<boolean>): void {
127
148
  if (closed) return;
128
149
  suggestionCallback = suggestion;
package/preview-click.cjs CHANGED
@@ -4,12 +4,12 @@ const net = require('node:net');
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
6
 
7
- const urlPattern = /^pi-note-preview:\/\/([1-9][0-9]*)-([0-9a-f]{8})\/([0-9a-f]{32})\/([1-9][0-9]*)\/([0-9a-f]{32})(?:\/(yes|no))?$/;
7
+ const urlPattern = /^pi-note-preview:\/\/([1-9][0-9]*)-([0-9a-f]{8})\/([0-9a-f]{32})\/([1-9][0-9]*)\/([0-9a-f]{32})(?:\/(yes|no|done))?$/;
8
8
 
9
9
  function parse(value) {
10
10
  if (typeof value !== 'string' || value.length > 180) throw new Error('Preview or suggestion unavailable');
11
11
  const m = urlPattern.exec(value);
12
- if (!m || m[0] !== value || (m[6] && m[4] !== '1')) throw new Error('Preview or suggestion unavailable');
12
+ if (!m || m[0] !== value || (m[6] && m[6] !== 'done' && m[4] !== '1')) throw new Error('Preview or suggestion unavailable');
13
13
  const pid = Number(m[1]), id = Number(m[4]);
14
14
  if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(id) || pid <= 0 || id <= 0) throw new Error('Preview or suggestion unavailable');
15
15
  return { pid, tag: m[2], capability: m[3], id, nonce: m[5], choice: m[6] };
@@ -6,27 +6,36 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
6
6
  const pin = readFileSync(new URL("./icons/note.txt", import.meta.url), "utf8").trim();
7
7
  const check = readFileSync(new URL("./icons/check.txt", import.meta.url), "utf8").trim();
8
8
  const cross = readFileSync(new URL("./icons/cross.txt", import.meta.url), "utf8").trim();
9
+ const add = readFileSync(new URL("./icons/add.txt", import.meta.url), "utf8").trim();
10
+ // Pi's default footer collapses ASCII spaces; NBSP preserves the control cells.
11
+ const gap = "\u00a0";
9
12
  const link = (label: string, url?: string) => url ? `\x1b]8;;${url}\x07${label}\x1b]8;;\x07` : label;
13
+ type Task = { text: string; tag?: string | null };
14
+ type Colors = Pick<Theme, "fg">;
10
15
 
11
- export function renderSuggestion(
12
- task: { text: string; tag?: string }, width: number, theme: Pick<Theme, "fg">,
13
- urls: { yes?: string; no?: string },
14
- ): string {
15
- const cue = `${pin} ${theme.fg("accent", "Suggested:")}`;
16
- const actions = (compact: boolean) => `${theme.fg("success", link(compact ? check : `${check} Add`, urls.yes))} ${theme.fg("muted", link(compact ? cross : `${cross} Dismiss`, urls.no))}`;
17
- let prefix = `${cue} ${actions(false)}`;
18
- if (visibleWidth(prefix) > width) prefix = `${cue} ${actions(true)}`;
19
- // Never show unexplained action icons when even the suggestion cue cannot fit.
20
- if (visibleWidth(prefix) > width) return truncateToWidth(cue, width, "");
16
+ function renderTask(task: Task, width: number, theme: Colors, prefix: string, previewUrl?: string): string {
17
+ // All controls or none: never let a narrow budget reveal only one pending choice.
18
+ if (visibleWidth(prefix) > width) return truncateToWidth(pin, width, "");
21
19
  const remaining = width - visibleWidth(prefix) - 3; // separator: " · "
22
- if (remaining < 4) return prefix;
20
+ if (remaining < 4) return prefix.trimEnd();
23
21
  const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/gu, " ").replace(/\s+/gu, " ").trim();
24
22
  const tag = `[${clean(task.tag ?? "Untagged")}]`;
25
23
  const title = clean(task.text.split("\n", 1)[0]);
26
- // Drop the tag before sacrificing the title to a long tag or a small budget.
27
24
  const tagWidth = visibleWidth(tag) + 1;
28
25
  const label = remaining >= tagWidth + Math.min(12, visibleWidth(title))
29
26
  ? `${theme.fg("muted", tag)} ${theme.fg("text", truncateToWidth(title, remaining - tagWidth, "..."))}`
30
27
  : theme.fg("text", truncateToWidth(title, remaining, "..."));
31
- return `${prefix}${theme.fg("muted", " · ")}${label}`;
28
+ return `${prefix}${theme.fg("muted", " · ")}${link(label, previewUrl)}`;
29
+ }
30
+
31
+ export function renderSuggestion(task: Task, width: number, theme: Colors, urls: { yes?: string; no?: string }): string {
32
+ return renderTask(task, width, theme,
33
+ `${pin} ${theme.fg("muted", link(cross, urls.no))}${gap.repeat(2)}${theme.fg("accent", link(add, urls.yes))}`);
34
+ }
35
+
36
+ export function renderSelectedTask(task: Task, width: number, theme: Colors, urls: { preview?: string; done?: string }): string {
37
+ // Reserve the former '+' cell: accepting twice at the same position cannot click Done.
38
+ return renderTask(task, width, theme,
39
+ `${pin} ${theme.fg("success", link(check, urls.done))}${gap.repeat(visibleWidth(cross) + 2 + visibleWidth(add) - visibleWidth(check))}`,
40
+ urls.preview);
32
41
  }
package/pr-watch.ts DELETED
@@ -1,236 +0,0 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { footerChips, footerStatusKey, legacyFooterStatusKey, renderFooterLinks, type FooterChip } from "./footer-links.ts";
3
- import type { FooterConfig } from "./footer-config.ts";
4
-
5
- export type WatchedTask = {
6
- id: number;
7
- state: string;
8
- updated_at: string;
9
- agent_notes: Record<string, string>;
10
- };
11
- type PR = { url: string; number: string };
12
- type Dependencies = {
13
- selected: (ctx: ExtensionContext) => Promise<WatchedTask | null>;
14
- get: (id: number) => Promise<WatchedTask>;
15
- done: (task: WatchedTask, canAct: () => boolean) => Promise<void>;
16
- claim: () => (() => void) | undefined;
17
- refresh: (ctx: ExtensionContext) => Promise<void>;
18
- footerConfig?: () => FooterConfig;
19
- };
20
- const entryType = "pinote-pr-acknowledged";
21
- const watchEntryType = "pinote-pr-watched";
22
-
23
- // Accept one bare URL or Markdown link, never arbitrary hosts or terminal escapes.
24
- export function taskPR(task: WatchedTask | null): PR | undefined {
25
- const field = task?.agent_notes.PR?.trim();
26
- if (!field) return;
27
- const url = /^\[[^\r\n]*\]\(([^\s]+)\)$/u.exec(field)?.[1] ?? field;
28
- const match = /^https:\/\/github\.com\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_.-]+)\/pull\/([1-9][0-9]*)\/?$/u.exec(url);
29
- if (!match || [".", ".."].includes(match[2]) || !Number.isSafeInteger(Number(match[3]))) return;
30
- return { url: `https://github.com/${match[1]}/${match[2]}/pull/${match[3]}`, number: match[3] };
31
- }
32
- const keyFor = (task: WatchedTask, pr: PR) => JSON.stringify([task.id, pr.url.toLowerCase()]);
33
- const watchKey = (task: WatchedTask | null) => {
34
- const pr = taskPR(task);
35
- return task && pr ? keyFor(task, pr) : undefined;
36
- };
37
-
38
- async function confirmCompletion(ctx: ExtensionContext, pr: PR, signal: AbortSignal) {
39
- let timer: ReturnType<typeof setInterval> | undefined;
40
- const report = () => process.stdout.write("\x1b]9;4;3\x1b\\");
41
- const stop = () => {
42
- if (timer === undefined) return;
43
- clearInterval(timer);
44
- timer = undefined;
45
- process.stdout.write("\x1b]9;4;0\x1b\\");
46
- };
47
- if (process.stdout.isTTY && process.env.KITTY_WINDOW_ID && !signal.aborted) {
48
- report();
49
- // Refresh Kitty's progress timeout and redraw the tab while input is pending.
50
- timer = setInterval(report, 125);
51
- timer.unref();
52
- signal.addEventListener("abort", stop, { once: true });
53
- }
54
- try {
55
- return await ctx.ui.confirm(`PR #${pr.number} was merged`, "Mark this task completed?", { signal });
56
- } finally {
57
- signal.removeEventListener("abort", stop);
58
- stop();
59
- }
60
- }
61
-
62
- export function createPRWatcher(pi: ExtensionAPI, deps: Dependencies) {
63
- let ctx: ExtensionContext | undefined;
64
- let watched: WatchedTask | null = null;
65
- let visiblePR: PR | undefined;
66
- let visibleLinks: FooterChip[] = [];
67
- let timer: ReturnType<typeof setTimeout> | undefined;
68
- let controller = new AbortController();
69
- let generation = 0;
70
- let revision = 0;
71
- let running = false;
72
- let interval = 60_000;
73
- let warned = false;
74
- let acknowledged = new Set<string>();
75
-
76
- const remember = (current: WatchedTask | null) => {
77
- const changed = watchKey(current) !== watchKey(watched);
78
- watched = current;
79
- if (changed && ctx) pi.appendEntry(watchEntryType, {
80
- cwd: ctx.cwd, task: current ? { id: current.id, url: taskPR(current)!.url } : null,
81
- });
82
- return changed;
83
- };
84
- const paint = () => {
85
- const current = ctx;
86
- if (!current) return;
87
- // Clear the old key first so a reload cannot show PR twice.
88
- current.ui.setStatus(legacyFooterStatusKey, undefined);
89
- current.ui.setStatus(footerStatusKey, renderFooterLinks(
90
- visibleLinks, (value) => current.ui.theme.fg("mdLink", value),
91
- ));
92
- };
93
- const schedule = (delay = interval) => {
94
- if (!ctx || !interval || running) return;
95
- clearTimeout(timer);
96
- timer = setTimeout(() => { void poll(); }, delay);
97
- timer.unref();
98
- };
99
- const update = (context: ExtensionContext, current: WatchedTask | null) => {
100
- if (!ctx || ctx.cwd !== context.cwd) return;
101
- revision++;
102
- // Display only the active selection, independently of the retained merge watch.
103
- visiblePR = current && ["active", "in_progress"].includes(current.state) ? taskPR(current) : undefined;
104
- visibleLinks = footerChips(current, visiblePR, deps.footerConfig?.());
105
- // Completion clears selection. Keep the last task until poll verifies its state.
106
- const changed = current ? remember(taskPR(current) ? current : null) : false;
107
- paint();
108
- if (changed) schedule(0);
109
- };
110
- const latest = async (context: ExtensionContext, previous: WatchedTask | null) => {
111
- const current = await deps.selected(context);
112
- if (current) return taskPR(current) ? current : null;
113
- if (!previous) return null;
114
- const old = await deps.get(previous.id);
115
- return old.state === "done" && taskPR(old) ? old : null;
116
- };
117
- async function poll() {
118
- if (!ctx || running) return;
119
- const context = ctx;
120
- const epoch = generation;
121
- const signal = controller.signal;
122
- const valid = () => ctx === context && generation === epoch && !signal.aborted;
123
- running = true;
124
- try {
125
- await deps.refresh(context);
126
- if (!valid()) return;
127
- const snapshot = revision;
128
- const candidate = await latest(context, watched);
129
- if (!valid() || revision !== snapshot) return;
130
- remember(candidate);
131
- paint();
132
- const pr = taskPR(candidate);
133
- if (!candidate || !pr) return;
134
- const key = keyFor(candidate, pr);
135
- const same = () => valid() && watched !== null && taskPR(watched)?.url === pr.url && watched.id === candidate.id;
136
- if (acknowledged.has(key)) return;
137
- const result = await pi.exec("gh", ["pr", "view", pr.url, "--json", "state,url"], {
138
- cwd: context.cwd, timeout: 10_000, signal,
139
- });
140
- if (!same()) return;
141
- if (result.code !== 0 || result.killed) throw new Error("GitHub CLI failed");
142
- const data = JSON.parse(result.stdout);
143
- // GitHub canonicalizes owner/repository casing in its response URL.
144
- if (typeof data.url !== "string" || data.url.toLowerCase() !== pr.url.toLowerCase() ||
145
- !["OPEN", "CLOSED", "MERGED"].includes(data.state)) throw new Error("Invalid PR response");
146
- warned = false;
147
- if (data.state !== "MERGED" || !context.isIdle()) return;
148
- const release = deps.claim();
149
- if (!release) return;
150
- try {
151
- const current = await latest(context, candidate);
152
- if (!same() || !current || current.id !== candidate.id || taskPR(current)?.url !== pr.url || !context.isIdle()) return;
153
- const acknowledge = () => {
154
- pi.appendEntry(entryType, { cwd: context.cwd, key });
155
- acknowledged.add(key);
156
- };
157
- const alreadyDone = () => {
158
- context.ui.notify(`PR #${pr.number} was merged and the task is already completed.`, "info");
159
- acknowledge();
160
- };
161
- if (current.state === "done") { alreadyDone(); return; }
162
- if (!["active", "in_progress"].includes(current.state)) return;
163
- const confirmed = await confirmCompletion(context, pr, signal);
164
- if (!same() || !context.isIdle()) return;
165
- // Re-read selection, link, and revision after the dialog; never complete a changed task.
166
- const fresh = await latest(context, current);
167
- if (!same() || !fresh || fresh.id !== current.id || taskPR(fresh)?.url !== pr.url) return;
168
- if (fresh.state === "done") { alreadyDone(); return; }
169
- if (fresh.updated_at !== current.updated_at) {
170
- context.ui.notify("Pinote task changed while confirming. The merge will be checked again.", "warning");
171
- return;
172
- }
173
- if (confirmed) {
174
- await deps.done(fresh, () => same() && context.isIdle());
175
- if (!valid()) return;
176
- context.ui.notify(`Pinote #${fresh.id} completed.`, "info");
177
- }
178
- acknowledge();
179
- } finally {
180
- release();
181
- if (valid()) await deps.refresh(context);
182
- }
183
- } catch {
184
- if (valid() && !warned) {
185
- warned = true;
186
- context.ui.notify("Pinote PR check failed. Check gh authentication and note availability; polling will retry.", "warning");
187
- }
188
- } finally {
189
- // A previous session's request must not affect a replacement watcher's timer.
190
- if (valid()) { running = false; schedule(); }
191
- }
192
- }
193
- return {
194
- update,
195
- start(context: ExtensionContext) {
196
- this.stop();
197
- if (!context.hasUI || context.mode !== "tui") return;
198
- const seconds = Number(process.env.PINOTE_PR_POLL_SECONDS ?? "60");
199
- interval = Number.isInteger(seconds) && (seconds === 0 || seconds >= 10 && seconds <= 86400)
200
- ? seconds * 1000 : 60_000;
201
- if (interval !== seconds * 1000) context.ui.notify("Invalid PINOTE_PR_POLL_SECONDS; using 60 seconds (allowed: 0 or 10–86400).", "warning");
202
- ctx = context;
203
- const branch = context.sessionManager.getBranch();
204
- // Persist only the identity; latest() must verify the real task state before polling.
205
- for (const entry of branch) {
206
- if (entry.type !== "custom" || entry.customType !== watchEntryType) continue;
207
- const data = entry.data as { cwd?: unknown; task?: { id?: unknown; url?: unknown } | null } | undefined;
208
- if (data?.cwd !== context.cwd) continue;
209
- if (data.task === null) { watched = null; continue; }
210
- if (!data.task || !Number.isSafeInteger(data.task.id) || Number(data.task.id) < 1 || typeof data.task.url !== "string") continue;
211
- const restored = { id: Number(data.task.id), state: "unknown", updated_at: "", agent_notes: { PR: data.task.url } };
212
- if (taskPR(restored)) watched = restored;
213
- }
214
- acknowledged = new Set(branch.flatMap((entry) => {
215
- if (entry.type !== "custom" || entry.customType !== entryType) return [];
216
- const data = entry.data as { cwd?: unknown; key?: unknown } | undefined;
217
- return data?.cwd === context.cwd && typeof data.key === "string" ? [data.key] : [];
218
- }));
219
- schedule(0);
220
- },
221
- stop() {
222
- generation++;
223
- controller.abort();
224
- controller = new AbortController();
225
- clearTimeout(timer);
226
- ctx?.ui.setStatus(legacyFooterStatusKey, undefined);
227
- ctx?.ui.setStatus(footerStatusKey, undefined);
228
- ctx = undefined;
229
- watched = null;
230
- visiblePR = undefined;
231
- visibleLinks = [];
232
- running = false;
233
- warned = false;
234
- },
235
- };
236
- }