pi-note 0.14.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -43
- package/footer-status.ts +40 -0
- package/index.ts +100 -56
- package/package.json +2 -2
- package/task-suggestion.ts +5 -5
- package/pr-watch.ts +0 -236
package/README.md
CHANGED
|
@@ -68,7 +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 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.
|
|
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. Icons are bold; their apparent weight depends on the terminal font, and emoji may look unchanged.
|
|
72
72
|
|
|
73
73
|
### Display-only preview
|
|
74
74
|
|
|
@@ -185,25 +185,25 @@ asking in chat. The pin footer shows a pending suggestion, distinct from a selec
|
|
|
185
185
|
|
|
186
186
|
```text
|
|
187
187
|
📌 ✕ + · [tag] Task title...
|
|
188
|
-
📌 ✓
|
|
188
|
+
📌 ✓ · [tag] Task title...
|
|
189
189
|
```
|
|
190
190
|
|
|
191
191
|

|
|
192
192
|

|
|
193
193
|
|
|
194
|
-
- Muted **✕** dismisses without creating a note; further offers are suppressed until a new session
|
|
194
|
+
- Muted **✕** dismisses without creating a note; further offers are suppressed until a new session.
|
|
195
195
|
- Accent **+** creates, starts, and selects the suggested task.
|
|
196
|
-
- Green **✓** completes the selected task and
|
|
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
197
|
|
|
198
|
-
Done occupies the former ✕ position, not the + position, so clicking Add twice cannot accidentally complete the new task. The former + cell
|
|
198
|
+
Done occupies the former ✕ position, not the + position, so clicking Add twice cannot accidentally complete the new task. The selected row removes the former + cell's padding. The selected task's title is a separate preview link.
|
|
199
199
|
|
|
200
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
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
|
|
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
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.
|
|
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
205
|
|
|
206
|
-
An existing suggestion is retained rather than replaced.
|
|
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
|
|
207
207
|
tag name when it fits.
|
|
208
208
|
Task text starts with a short, action-oriented title (aim for at most 60 characters).
|
|
209
209
|
Put context, URLs, commands, and acceptance criteria after a blank line; the title
|
|
@@ -217,7 +217,7 @@ list, append its label to `Bar`, one label per line. Do not list `PR` in `Bar`.
|
|
|
217
217
|
Agents use `pinote_update_current`; there is no separate `add_to_bottom_bar` tool.
|
|
218
218
|
This is guidance, not truncation or a storage limit.
|
|
219
219
|
|
|
220
|
-
Values are Markdown strings; no fields are required. `PR`
|
|
220
|
+
Values are Markdown strings; no fields are required. `PR` can display a clickable link without GitHub polling.
|
|
221
221
|
`Bar` chooses extra footer fields unless configuration overrides it:
|
|
222
222
|
|
|
223
223
|
```markdown
|
|
@@ -314,40 +314,18 @@ Pi's final ellipsis is plain text; click the remaining task text to preview it.
|
|
|
314
314
|
This extension does not replace other footers. Other fields, such as `Jira` and
|
|
315
315
|
`CWD` in the example, stay off the footer unless selected.
|
|
316
316
|
|
|
317
|
-
### PR
|
|
318
|
-
|
|
319
|
-
Set `PR` with `pinote_update_current` to one `https://github.com/owner/repo/pull/123`
|
|
320
|
-
or Markdown link. Legacy footer settings show
|
|
321
|
-
field settings can change or hide
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
No polling runs in print/RPC mode or after Pi exits.
|
|
330
|
-
|
|
331
|
-
On merge, Pi waits until idle and asks **Mark this task completed?** In Kitty,
|
|
332
|
-
indeterminate progress animates the tab while the prompt awaits input (with Kitty's
|
|
333
|
-
default progress-aware tab title or a working/ready renderer). Progress clears on
|
|
334
|
-
response, cancellation, or shutdown; other terminals and redirected output are untouched.
|
|
335
|
-
Confirmation uses the same guarded CLI Done operation as `/pi-note`; declining leaves
|
|
336
|
-
the task unchanged. If it is already done, Pi says **PR #123 was merged and the task is
|
|
337
|
-
already completed**, without completing it again. Completion clears the selection
|
|
338
|
-
and hides the footer link, even with polling disabled. The background watch remains
|
|
339
|
-
until another task is selected, the link is removed, or Pi exits. Removed/scheduled tasks are no longer watched.
|
|
340
|
-
|
|
341
|
-
The watched task ID/link and merge acknowledgements are saved in the Pi session,
|
|
342
|
-
so `/reload` and session resume retain completed-task watches without repeating
|
|
343
|
-
acknowledged prompts. A new session can notify again for a
|
|
344
|
-
selected task. Task or PR changes during confirmation cannot complete a different
|
|
345
|
-
task. No completion-hook system is added; this uses Pinote's existing Done flow.
|
|
346
|
-
A separate branch-based PR-status extension may show a duplicate link; disable it
|
|
347
|
-
if you only want task-linked PRs.
|
|
348
|
-
|
|
349
|
-
Task data stays local except for GitHub status requests. Tools work without a TUI,
|
|
350
|
-
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.
|
|
351
329
|
This package does not synchronize databases or paths between machines. Note text
|
|
352
330
|
and fields loaded into Pi are sent to the configured model when used as context;
|
|
353
331
|
avoid secrets. Agent commands do not send desktop notifications.
|
package/footer-status.ts
ADDED
|
@@ -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/index.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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 {
|
|
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";
|
|
@@ -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
|
|
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 (
|
|
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;
|
|
@@ -270,38 +295,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
270
295
|
selectedId === previewId ? preview(ctx, signal) : Promise.resolve(false), current ? {
|
|
271
296
|
revision: current.updated_at,
|
|
272
297
|
invoke: () => alive && generation === epoch && previewBranch === branchEpoch && selectedId === previewId
|
|
273
|
-
?
|
|
298
|
+
? dispatchCompletion(ctx, current, `${runtimeToken}:${generation}:${previewBranch}:${previewId}`) : Promise.resolve(false),
|
|
274
299
|
} : undefined);
|
|
275
300
|
ctx.ui.setStatus("pinote", current ? renderSelectedTask(current, footerConfig.titleWidth, ctx.ui.theme, {
|
|
276
301
|
preview: previewBridge?.url(), done: previewBridge?.doneUrl(),
|
|
277
302
|
}) : suggestionStatus(ctx));
|
|
278
|
-
|
|
303
|
+
updateTaskFooter(ctx, current, footerConfig);
|
|
279
304
|
}
|
|
280
305
|
} catch {
|
|
281
306
|
if (alive && generation === epoch && serial === refreshSerial) {
|
|
282
307
|
previewBridge?.invalidate();
|
|
283
|
-
|
|
308
|
+
clearTaskFooter(ctx);
|
|
309
|
+
ctx.ui.setStatus("pinote", suggestionStatus(ctx) ?? `${ctx.ui.theme.bold(noteIcon)} Pinote unavailable · ${cliState === "upgrade" ? "/pi-note-upgrade" : cliState === "setup" ? "/pi-note-setup" : "/pi-note"}`);
|
|
284
310
|
}
|
|
285
311
|
}
|
|
286
312
|
};
|
|
287
|
-
const watcher = createPRWatcher(pi, {
|
|
288
|
-
selected,
|
|
289
|
-
footerConfig: () => footerConfig,
|
|
290
|
-
get: async (id) => requiredTask(await run(["agent", "get", String(id)]), id),
|
|
291
|
-
done: async (current, canAct) => {
|
|
292
|
-
const result = requiredTask(await run([
|
|
293
|
-
"agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
|
|
294
|
-
], undefined, canAct), current.id);
|
|
295
|
-
if (result.state !== "done") throw new Error(compatible);
|
|
296
|
-
},
|
|
297
|
-
claim: () => {
|
|
298
|
-
if (pending) return;
|
|
299
|
-
const operation = Symbol();
|
|
300
|
-
pending = operation;
|
|
301
|
-
return () => { if (pending === operation) pending = undefined; };
|
|
302
|
-
},
|
|
303
|
-
refresh,
|
|
304
|
-
});
|
|
305
313
|
const preview = async (ctx: ExtensionContext, requestSignal?: AbortSignal): Promise<boolean> => {
|
|
306
314
|
if (!ctx.hasUI || ctx.mode !== "tui" || !ctx.isIdle() || pending || !validId(selectedId)) return false;
|
|
307
315
|
const generation = epoch;
|
|
@@ -330,44 +338,75 @@ export default function (pi: ExtensionAPI) {
|
|
|
330
338
|
if (pending === operation) pending = undefined;
|
|
331
339
|
}
|
|
332
340
|
};
|
|
333
|
-
const
|
|
334
|
-
if (!
|
|
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
|
+
}
|
|
335
368
|
const generation = epoch;
|
|
336
369
|
const branch = branchEpoch;
|
|
337
370
|
const id = selectedId;
|
|
338
371
|
const operation = Symbol();
|
|
339
372
|
pending = operation;
|
|
373
|
+
request?.started();
|
|
340
374
|
refreshSerial++;
|
|
341
375
|
const canAct = () => alive && generation === epoch && branch === branchEpoch && selectedId === id && ctx.isIdle();
|
|
342
376
|
let wrote = false;
|
|
343
377
|
try {
|
|
344
|
-
const current = displayed ?? await currentTask(undefined, canAct);
|
|
345
|
-
if (!current || current.id !== id || !canAct()) return
|
|
378
|
+
const current = request?.task ?? displayed ?? await currentTask(undefined, canAct);
|
|
379
|
+
if (!current || current.id !== id || !canAct()) return;
|
|
346
380
|
const completed = requiredTask(await run([
|
|
347
381
|
"agent", "done", String(id), "--expected-updated-at", current.updated_at,
|
|
348
382
|
], undefined, canAct), id);
|
|
349
383
|
wrote = true;
|
|
350
384
|
if (completed.state !== "done") throw new Error(compatible);
|
|
351
385
|
if (!canAct()) throw new Error("The session or selection changed. Completion may have committed; read the task before retrying.");
|
|
386
|
+
request?.finish(true);
|
|
352
387
|
remember(null, branch);
|
|
353
|
-
ctx
|
|
354
|
-
|
|
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");
|
|
355
398
|
} catch (error) {
|
|
356
399
|
if (alive && generation === epoch) ctx.ui.notify(
|
|
357
400
|
`Pinote completion: ${clean(String(error))}${wrote ? " Check the task before retrying." : ""}`, "error");
|
|
358
|
-
return false;
|
|
359
401
|
} finally {
|
|
402
|
+
request?.finish(false);
|
|
360
403
|
if (pending === operation) pending = undefined;
|
|
361
404
|
if (alive && generation === epoch) await refresh(ctx);
|
|
362
405
|
}
|
|
363
406
|
};
|
|
364
407
|
pi.registerCommand("pi-note-done", {
|
|
365
|
-
description: "Complete the selected task
|
|
366
|
-
handler:
|
|
367
|
-
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
368
|
-
if (args.trim()) { ctx.ui.notify("Usage: /pi-note-done", "warning"); return; }
|
|
369
|
-
if (!await completeTask(ctx)) ctx.ui.notify("Select a task and wait until Pi is idle and Pinote is available before completing it.", "warning");
|
|
370
|
-
},
|
|
408
|
+
description: "Complete the selected task, start a clean session, and reload Pi",
|
|
409
|
+
handler: (args, ctx) => completeSelected(args, ctx),
|
|
371
410
|
});
|
|
372
411
|
pi.registerCommand("pi-note-preview", {
|
|
373
412
|
description: "Display the selected task locally without sending it to the model",
|
|
@@ -379,10 +418,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
379
418
|
});
|
|
380
419
|
pi.on("session_start", async (_event, ctx) => {
|
|
381
420
|
setupAbort?.abort();
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
clearSuggestion(activeContext ?? ctx);
|
|
385
|
-
offerDeclined = false;
|
|
421
|
+
cancelCompletionDispatch();
|
|
422
|
+
clearTaskFooter(activeContext ?? ctx);
|
|
423
|
+
clearSuggestion(activeContext ?? ctx, false);
|
|
386
424
|
alive = false;
|
|
387
425
|
epoch++;
|
|
388
426
|
branchEpoch++;
|
|
@@ -394,6 +432,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
394
432
|
alive = true;
|
|
395
433
|
activeContext = ctx;
|
|
396
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
|
+
}
|
|
397
442
|
footerConfig = loadFooterConfig((message) => {
|
|
398
443
|
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
399
444
|
});
|
|
@@ -418,13 +463,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
418
463
|
if (!setupAbort) pending = undefined;
|
|
419
464
|
await checkCLI(ctx, true);
|
|
420
465
|
if (!alive || generation !== epoch) return;
|
|
421
|
-
if (
|
|
466
|
+
if (suggestion) previewBridge?.setSuggestion((choice) => respondToSuggestion(choice, ctx));
|
|
422
467
|
await refresh(ctx);
|
|
423
468
|
});
|
|
424
469
|
pi.on("session_tree", async (_event, ctx) => {
|
|
425
470
|
if (!alive) return;
|
|
426
471
|
branchEpoch++;
|
|
427
|
-
|
|
472
|
+
cancelCompletionDispatch();
|
|
473
|
+
clearSuggestion(ctx, false);
|
|
474
|
+
// Never resurrect an already consumed proposal by navigating behind its tombstone.
|
|
475
|
+
saveSuggestion();
|
|
428
476
|
previewBridge?.invalidate();
|
|
429
477
|
const generation = branchEpoch;
|
|
430
478
|
const id = readSelection(ctx);
|
|
@@ -437,14 +485,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
437
485
|
pi.on("agent_end", async (_event, ctx) => { await refresh(ctx); });
|
|
438
486
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
439
487
|
setupAbort?.abort();
|
|
440
|
-
|
|
488
|
+
cancelCompletionDispatch();
|
|
489
|
+
clearSuggestion(ctx, false);
|
|
441
490
|
alive = false;
|
|
442
491
|
activeContext = undefined;
|
|
443
492
|
selectedId = null;
|
|
444
493
|
epoch++;
|
|
445
494
|
branchEpoch++;
|
|
446
495
|
refreshSerial++;
|
|
447
|
-
|
|
496
|
+
clearTaskFooter(ctx);
|
|
448
497
|
if (ctx.hasUI && ctx.mode === "tui") ctx.ui.setStatus("pinote", undefined);
|
|
449
498
|
const bridge = previewBridge;
|
|
450
499
|
previewBridge = undefined;
|
|
@@ -468,7 +517,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
468
517
|
const generation = epoch;
|
|
469
518
|
const controller = new AbortController();
|
|
470
519
|
setupAbort = controller;
|
|
471
|
-
|
|
520
|
+
clearTaskFooter(ctx);
|
|
472
521
|
const currentSession = () => alive && generation === epoch;
|
|
473
522
|
try {
|
|
474
523
|
await setupCLI(pi, ctx, name === "pi-note-upgrade",
|
|
@@ -483,7 +532,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
483
532
|
const generation = epoch;
|
|
484
533
|
await checkCLI(latestContext, !currentSession());
|
|
485
534
|
if (!alive || generation !== epoch || setupAbort) return;
|
|
486
|
-
watcher.start(latestContext);
|
|
487
535
|
await refresh(latestContext);
|
|
488
536
|
}
|
|
489
537
|
}
|
|
@@ -547,13 +595,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
547
595
|
if (!action || !canAct()) return;
|
|
548
596
|
if (action === "Settings") { settings = true; continue; }
|
|
549
597
|
if (action === "Done" && current) {
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
"agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
|
|
553
|
-
], undefined, canAct), current.id);
|
|
554
|
-
if (completed.state !== "done") throw new Error(compatible);
|
|
555
|
-
if (currentSession() && branch === branchEpoch) remember(null, branch);
|
|
556
|
-
if (currentSession()) ctx.ui.notify(`Pinote #${current.id} completed.`, "info");
|
|
598
|
+
if (pending === operation) pending = undefined;
|
|
599
|
+
await completeSelected("", ctx, current);
|
|
557
600
|
return;
|
|
558
601
|
}
|
|
559
602
|
let id: number;
|
|
@@ -601,7 +644,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
601
644
|
name: "pinote_update_current",
|
|
602
645
|
label: "Pinote update current",
|
|
603
646
|
promptGuidelines: [handoffGuidance],
|
|
604
|
-
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
|
|
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.",
|
|
605
648
|
parameters: Type.Object({
|
|
606
649
|
expected_updated_at: Type.String({ minLength: 1 }),
|
|
607
650
|
set: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
@@ -727,6 +770,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
727
770
|
if (offerDeclined) return result("dismissed; continue without a note and do not offer again");
|
|
728
771
|
if (suggestion) return result("pending; existing suggestion retained, continue work without asking again");
|
|
729
772
|
suggestion = { text: params.text, ...(params.tag === undefined ? {} : { tag: params.tag.trim() }) };
|
|
773
|
+
saveSuggestion();
|
|
730
774
|
previewBridge?.setSuggestion((choice) => respondToSuggestion(choice, ctx));
|
|
731
775
|
ctx.ui.setStatus("pinote", suggestionStatus(ctx));
|
|
732
776
|
return result("pending; user can click ✓ to add and select or ✕ to dismiss, continue work without asking again");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-note",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.1",
|
|
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
|
-
"
|
|
30
|
+
"footer-status.ts",
|
|
31
31
|
"footer-links.ts",
|
|
32
32
|
"footer-config.ts",
|
|
33
33
|
"footer-settings.ts",
|
package/task-suggestion.ts
CHANGED
|
@@ -11,11 +11,11 @@ const add = readFileSync(new URL("./icons/add.txt", import.meta.url), "utf8").tr
|
|
|
11
11
|
const gap = "\u00a0";
|
|
12
12
|
const link = (label: string, url?: string) => url ? `\x1b]8;;${url}\x07${label}\x1b]8;;\x07` : label;
|
|
13
13
|
type Task = { text: string; tag?: string | null };
|
|
14
|
-
type Colors = Pick<Theme, "fg">;
|
|
14
|
+
type Colors = Pick<Theme, "fg" | "bold">;
|
|
15
15
|
|
|
16
16
|
function renderTask(task: Task, width: number, theme: Colors, prefix: string, previewUrl?: string): string {
|
|
17
17
|
// All controls or none: never let a narrow budget reveal only one pending choice.
|
|
18
|
-
if (visibleWidth(prefix) > width) return truncateToWidth(pin, width, "");
|
|
18
|
+
if (visibleWidth(prefix) > width) return truncateToWidth(theme.bold(pin), width, "");
|
|
19
19
|
const remaining = width - visibleWidth(prefix) - 3; // separator: " · "
|
|
20
20
|
if (remaining < 4) return prefix.trimEnd();
|
|
21
21
|
const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/gu, " ").replace(/\s+/gu, " ").trim();
|
|
@@ -30,12 +30,12 @@ function renderTask(task: Task, width: number, theme: Colors, prefix: string, pr
|
|
|
30
30
|
|
|
31
31
|
export function renderSuggestion(task: Task, width: number, theme: Colors, urls: { yes?: string; no?: string }): string {
|
|
32
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))}`);
|
|
33
|
+
`${theme.bold(pin)} ${theme.fg("muted", link(theme.bold(cross), urls.no))}${gap.repeat(2)}${theme.fg("accent", link(theme.bold(add), urls.yes))}`);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export function renderSelectedTask(task: Task, width: number, theme: Colors, urls: { preview?: string; done?: string }): string {
|
|
37
|
-
//
|
|
37
|
+
// Done stays in the former '✕' cell, never the Add cell.
|
|
38
38
|
return renderTask(task, width, theme,
|
|
39
|
-
`${pin} ${theme.fg("success", link(check, urls.done))}
|
|
39
|
+
`${theme.bold(pin)} ${theme.fg("success", link(theme.bold(check), urls.done))}`,
|
|
40
40
|
urls.preview);
|
|
41
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
|
-
}
|