pi-note 0.4.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/LICENSE +21 -0
- package/README.md +171 -0
- package/icons/note.txt +1 -0
- package/index.ts +324 -0
- package/package.json +37 -0
- package/pr-watch.ts +203 -0
- package/setup.ts +103 -0
- package/task-picker.ts +81 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kvidzibo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# pi-note
|
|
2
|
+
|
|
3
|
+
Persistent [pinote](https://github.com/kvidzibo/pinote) tasks and Markdown handoffs
|
|
4
|
+
in Pi: selected-task footer status, `/pi-note`, and agent read/update tools.
|
|
5
|
+
The extension and Python app share a repository but install separately.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Requires Node.js 22.19+ and **pinote 0.3.0+** (`note` on PATH).
|
|
10
|
+
From this checkout, install the extension with `pi install ./pi`, then run
|
|
11
|
+
`/reload` in interactive Pi. Use `/pi-note-setup` if the CLI is missing, or the
|
|
12
|
+
suggested `/pi-note-upgrade` if it is older than the bundled CLI. The planned npm package name is
|
|
13
|
+
`pi-note`; after its first release use `pi install npm:pi-note` instead.
|
|
14
|
+
It is not published yet. npm installation does not run Python installers.
|
|
15
|
+
|
|
16
|
+
### CLI setup and upgrades
|
|
17
|
+
|
|
18
|
+
Install [uv](https://docs.astral.sh/uv/getting-started/installation/) first and
|
|
19
|
+
restart Pi with `uv` on PATH. At startup the extension checks `note --version`
|
|
20
|
+
against its bundled CLI version (currently 0.3.0), without network requests:
|
|
21
|
+
|
|
22
|
+
- Missing/unrecognized CLI: show `/pi-note-setup` in the slash-command menu.
|
|
23
|
+
- Older CLI: show and suggest `/pi-note-upgrade`.
|
|
24
|
+
- Equal/newer CLI: hide both commands; no upgrade notification.
|
|
25
|
+
|
|
26
|
+
Both commands ask before installing into uv's isolated tool environment.
|
|
27
|
+
The menu refreshes after setup/upgrade; restart Pi or `/reload` after external
|
|
28
|
+
CLI changes. Manually typing either command rechecks the version and never
|
|
29
|
+
reinstalls an equal/newer CLI or intentionally downgrades it.
|
|
30
|
+
Python 3.11+ is required; uv can download a suitable interpreter when missing.
|
|
31
|
+
The command downloads a pinned GitHub source archive and build dependencies,
|
|
32
|
+
not a similarly named PyPI package. It does not install GTK dependencies, edit
|
|
33
|
+
shell configuration, or change tasks. This checks the Python CLI bundled with
|
|
34
|
+
the installed extension, not the latest npm/GitHub release. Update the extension
|
|
35
|
+
first to receive a newer bundled CLI. `/pi-note-setup --upgrade` is replaced by
|
|
36
|
+
`/pi-note-upgrade`.
|
|
37
|
+
|
|
38
|
+
Back up your database before upgrading and update an optional GUI separately.
|
|
39
|
+
For checkout development, use `uv tool install --reinstall .` at the repo root
|
|
40
|
+
instead, so local Python changes are installed.
|
|
41
|
+
|
|
42
|
+
If setup reports a PATH problem, run `uv tool dir --bin` in your terminal,
|
|
43
|
+
put that directory before older `note` executables on PATH, and restart Pi.
|
|
44
|
+
Check `note --version`, then restart Pi or `/reload`. Network/build failures and
|
|
45
|
+
conflicting executables are reported without forcibly overwriting another
|
|
46
|
+
installer's commands. Fix the reported cause and retry; installs time out after
|
|
47
|
+
three minutes. Setup/upgrade commands are hidden while installation runs.
|
|
48
|
+
Missing uv is reported with its installation link, not installed
|
|
49
|
+
automatically. The [GTK desktop app](../README.md#optional-gtk-checklist) is optional
|
|
50
|
+
and still requires separate system dependencies.
|
|
51
|
+
|
|
52
|
+
The extension probes `note --version` before task commands: older CLIs interpret
|
|
53
|
+
unknown commands as note text and must be rejected before use. Remove the old
|
|
54
|
+
standalone `settings/pi/extensions/pinote.ts` entry if installed; load only one copy.
|
|
55
|
+
|
|
56
|
+
## Use
|
|
57
|
+
|
|
58
|
+
`/pi-note` picks a task, starts it, and puts its full text and saved handoff fields
|
|
59
|
+
in the editor. Existing drafts are preserved; nothing is submitted automatically.
|
|
60
|
+
With a selected task it offers **Continue**, **Done**, and **Switch task**.
|
|
61
|
+
Type in the task picker to filter by text, ID, tag, or state (all words must match).
|
|
62
|
+
Use โ/โ and Enter to select, or Esc to cancel. Rows show **โ** for in progress or
|
|
63
|
+
**โ** for active, followed by the tag (`[Untagged]` when absent).
|
|
64
|
+
The normal Pi status area shows `๐ [tag] Task title` without replacing other footers.
|
|
65
|
+
The footer omits the task ID and state and shows only the first line, truncated to 60 terminal columns including the pin and tag.
|
|
66
|
+
The pin glyph is bundled in `icons/note.txt`; it uses the terminal's emoji font, not a Nerd Font or icon theme.
|
|
67
|
+
|
|
68
|
+
Selection is stored per canonical working directory in pinote's database. A new
|
|
69
|
+
Pi session shows that selection; use **Continue** to load its current contents.
|
|
70
|
+
Completing, removing, or scheduling a task clears its selections. Switching does
|
|
71
|
+
not complete or reset the previous task. Task status refreshes at session start,
|
|
72
|
+
before/after agent activity, and after commands/tools.
|
|
73
|
+
|
|
74
|
+
Agent tools:
|
|
75
|
+
|
|
76
|
+
- `pinote_get`: read the selected task, or an explicit `id`, including its revision.
|
|
77
|
+
- `pinote_update`: supply `id`, `expected_updated_at` from that read, and arbitrary
|
|
78
|
+
`set` label/value pairs or `remove` labels. Updates merge fields, never replace
|
|
79
|
+
the task text. If another process changed the task, read again before retrying.
|
|
80
|
+
|
|
81
|
+
Agents are guided to keep notes to three short bullets total: relevant outcome,
|
|
82
|
+
blocker, and next action. Replace stale notes; omit narration, repeated task text,
|
|
83
|
+
and routine test logs. Keep PR links in `PR`. This is guidance, not truncation or
|
|
84
|
+
a storage limit.
|
|
85
|
+
|
|
86
|
+
Values are Markdown strings; no fields are required. `PR` enables the watcher below:
|
|
87
|
+
|
|
88
|
+
```markdown
|
|
89
|
+
# Agent
|
|
90
|
+
PR: [Task selection #42](https://github.com/org/repo/pull/42)
|
|
91
|
+
Jira: [PROJ-123](https://example.atlassian.net/browse/PROJ-123)
|
|
92
|
+
CWD: `/home/me/project`
|
|
93
|
+
Next: Address review comments
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The GTK preview renders this section using its existing safe Markdown renderer.
|
|
97
|
+
Fields are stored separately from task text, retained in history, and survive
|
|
98
|
+
sessions. Labels are case-sensitive (trimmed, Unicode-normalized), at most 64
|
|
99
|
+
characters; values are at most 4096 characters. Maximum 64 fields / 32 KiB JSON
|
|
100
|
+
per task. Use removal rather than empty values.
|
|
101
|
+
|
|
102
|
+
### PR merge watcher
|
|
103
|
+
|
|
104
|
+
Set `PR` with `pinote_update` to one `https://github.com/owner/repo/pull/123` URL
|
|
105
|
+
or Markdown link. The footer adds a clickable, link-coloured **PR #123**, without
|
|
106
|
+
status text. Other hosts, multiple links, and prose are not watched. Terminal
|
|
107
|
+
OSC 8 support is required for clicking links.
|
|
108
|
+
|
|
109
|
+
Interactive Pi checks only the selected task's PR using authenticated `gh`
|
|
110
|
+
(`gh auth login`). Polling defaults to 60 seconds; launch Pi with
|
|
111
|
+
`PINOTE_PR_POLL_SECONDS=120 pi` to change it, or `0` to disable polling.
|
|
112
|
+
Allowed intervals are 10โ86400 seconds. The selected task's link remains visible when polling is disabled.
|
|
113
|
+
Network/authentication failures warn once until recovery and retry next interval.
|
|
114
|
+
No polling runs in print/RPC mode or after Pi exits.
|
|
115
|
+
|
|
116
|
+
On merge, Pi waits until idle and asks **Mark this task completed?** Confirmation
|
|
117
|
+
uses the same guarded CLI Done operation as `/pi-note`; declining leaves the task
|
|
118
|
+
unchanged. If it is already done, Pi says **PR #123 was merged and the task is
|
|
119
|
+
already completed**, without completing it again. Completion clears the selection
|
|
120
|
+
and hides the footer link, even with polling disabled. The background watch remains
|
|
121
|
+
until another task is selected, the link is removed, or Pi exits. Removed/scheduled tasks are no longer watched.
|
|
122
|
+
|
|
123
|
+
The watched task ID/link and merge acknowledgements are saved in the Pi session,
|
|
124
|
+
so `/reload` and session resume retain completed-task watches without repeating
|
|
125
|
+
acknowledged prompts. A new session can notify again for a
|
|
126
|
+
selected task. Task or PR changes during confirmation cannot complete a different
|
|
127
|
+
task. No completion-hook system is added; this uses Pinote's existing Done flow.
|
|
128
|
+
A separate branch-based PR-status extension may show a duplicate link; disable it
|
|
129
|
+
if you only want task-linked PRs.
|
|
130
|
+
|
|
131
|
+
Task data stays local except for GitHub status requests. Tools work without a TUI,
|
|
132
|
+
but `/pi-note` needs an idle TUI.
|
|
133
|
+
This package does not synchronize databases or paths between machines. Note text
|
|
134
|
+
and fields loaded into Pi are sent to the configured model when used as context;
|
|
135
|
+
avoid secrets. Agent commands do not send desktop notifications.
|
|
136
|
+
|
|
137
|
+
## Development and releases
|
|
138
|
+
|
|
139
|
+
From the repository root:
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
uv sync --locked
|
|
143
|
+
npm --prefix pi ci --ignore-scripts
|
|
144
|
+
npm --prefix pi test
|
|
145
|
+
(cd pi && npm pack --dry-run)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The load test uses the real Pi loader and checkout's `.venv/bin/note` with temporary
|
|
149
|
+
data. Run visual checks under Xvfb/private D-Bus; never use personal task data.
|
|
150
|
+
|
|
151
|
+
`.github/workflows/publish.yml` follows the other Pi packages: every push to
|
|
152
|
+
`main` runs the full Python/GTK/Pi tests, then publishes a new npm version using
|
|
153
|
+
OIDC trusted publishing and provenance. Existing versions are skipped; registry
|
|
154
|
+
failures fail the workflow. No npm token is stored in GitHub.
|
|
155
|
+
|
|
156
|
+
One-time maintainer setup (not performed by installing this package):
|
|
157
|
+
|
|
158
|
+
1. From `pi/`, run `npm login`, then `npm publish --ignore-scripts --access public`
|
|
159
|
+
to bootstrap `pi-note` after checking name availability.
|
|
160
|
+
2. In npm's package settings configure the GitHub trusted publisher: owner
|
|
161
|
+
`kvidzibo`, repository `pinote`, workflow `publish.yml`, no environment.
|
|
162
|
+
3. Add verified npm and Pi package-directory links here after publication.
|
|
163
|
+
|
|
164
|
+
The CLI source pin in `setup.ts` must point to a verified immutable commit with a
|
|
165
|
+
compatible Python package. Update it and the bundled-version setup text together
|
|
166
|
+
when releasing Python changes; verify installation in a temporary uv tool directory.
|
|
167
|
+
|
|
168
|
+
Subsequent releases: run `npm version patch|minor|major --no-git-tag-version`
|
|
169
|
+
inside `pi/`, commit both version files in a PR, and merge through the normal
|
|
170
|
+
review/check process. Python and npm versions are independent; update the stated
|
|
171
|
+
minimum CLI version whenever the integration contract changes.
|
package/icons/note.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
๐
|
package/index.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { stripVTControlCharacters } from "node:util";
|
|
5
|
+
import { Type } from "typebox";
|
|
6
|
+
import { createPRWatcher } from "./pr-watch.ts";
|
|
7
|
+
import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
|
|
8
|
+
import { bundledCLIVersion, cliMenu, detectCLI, compatibleVersion, setupCLI, setupHint, type CLIAction } from "./setup.ts";
|
|
9
|
+
|
|
10
|
+
const handoffGuidance = "Keep agent notes to at most three short bullets total: outcome, blocker, next action, only when relevant. Replace stale notes; omit narration, repeated task text, and routine test logs. Keep PR links in the separate PR field.";
|
|
11
|
+
type Task = {
|
|
12
|
+
id: number;
|
|
13
|
+
text: string;
|
|
14
|
+
state: "active" | "in_progress" | "done" | "removed" | "scheduled";
|
|
15
|
+
tag: string | null;
|
|
16
|
+
updated_at: string;
|
|
17
|
+
agent_notes: Record<string, string>;
|
|
18
|
+
markdown: string;
|
|
19
|
+
};
|
|
20
|
+
type Summary = Pick<Task, "id" | "text" | "state" | "tag">;
|
|
21
|
+
// The standard Pi footer accepts text, so ship a portable terminal glyph, not a theme icon.
|
|
22
|
+
const noteIcon = readFileSync(new URL("./icons/note.txt", import.meta.url), "utf8").trim();
|
|
23
|
+
const idSchema = Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER });
|
|
24
|
+
const compatible = "Incompatible note CLI response. Install pinote 0.3.0+ and check note on PATH.";
|
|
25
|
+
const validId = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0;
|
|
26
|
+
const record = (value: unknown): value is Record<string, unknown> =>
|
|
27
|
+
value !== null && typeof value === "object" && !Array.isArray(value);
|
|
28
|
+
const text = (value: unknown): value is string =>
|
|
29
|
+
typeof value === "string" && !/[\x00-\x08\x0b-\x1f\x7f-\x9f]/u.test(value);
|
|
30
|
+
const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/gu, "๏ฟฝ");
|
|
31
|
+
const firstLine = (task: Summary) => clean(task.text.split("\n", 1)[0]).replace(/\s+/gu, " ").trim();
|
|
32
|
+
const title = (task: Summary) => truncateToWidth(`#${task.id} ${firstLine(task)}`, 60);
|
|
33
|
+
|
|
34
|
+
function parse(raw: string): unknown {
|
|
35
|
+
try { return JSON.parse(raw); } catch { throw new Error(compatible); }
|
|
36
|
+
}
|
|
37
|
+
function task(raw: string): Task | null {
|
|
38
|
+
const value = parse(raw);
|
|
39
|
+
if (value === null) return null;
|
|
40
|
+
if (!record(value) || !validId(value.id) || !text(value.text) || !value.text.trim() ||
|
|
41
|
+
typeof value.state !== "string" || !["active", "in_progress", "done", "removed", "scheduled"].includes(value.state) ||
|
|
42
|
+
!(value.tag === null || text(value.tag)) || !text(value.updated_at) || !value.updated_at.trim() ||
|
|
43
|
+
!record(value.agent_notes) || !Object.entries(value.agent_notes).every(([key, v]) => text(key) && text(v)) ||
|
|
44
|
+
!text(value.markdown)) throw new Error(compatible);
|
|
45
|
+
return value as Task;
|
|
46
|
+
}
|
|
47
|
+
function requiredTask(raw: string, id?: number): Task {
|
|
48
|
+
const value = task(raw);
|
|
49
|
+
if (!value) throw new Error("No selected pinote task. Select one with /pi-note first.");
|
|
50
|
+
if (id !== undefined && value.id !== id) throw new Error(compatible);
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
function taskList(raw: string): Summary[] {
|
|
54
|
+
const value = parse(raw);
|
|
55
|
+
if (!Array.isArray(value) || !value.every((v) => record(v) && validId(v.id) && text(v.text) &&
|
|
56
|
+
v.text.trim() && (v.tag === null || text(v.tag)) &&
|
|
57
|
+
typeof v.state === "string" && ["active", "in_progress"].includes(v.state))) throw new Error(compatible);
|
|
58
|
+
return value.sort((a, b) => b.id - a.id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function toolResult(value: Task) {
|
|
62
|
+
// Keep the model payload under 50 KiB at the CLI's limits; don't send the same fields twice.
|
|
63
|
+
const { markdown: _markdown, ...data } = value;
|
|
64
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(data) }], details: value };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export default function (pi: ExtensionAPI) {
|
|
68
|
+
let alive = true;
|
|
69
|
+
let epoch = 0;
|
|
70
|
+
let refreshSerial = 0;
|
|
71
|
+
let pending: symbol | undefined;
|
|
72
|
+
let setupAbort: AbortController | undefined;
|
|
73
|
+
let activeContext: ExtensionContext | undefined;
|
|
74
|
+
let cliState: CLIAction | undefined;
|
|
75
|
+
const checkCLI = async (ctx: ExtensionContext, suggest = false) => {
|
|
76
|
+
if (!ctx.hasUI || ctx.mode !== "tui" || setupAbort) return;
|
|
77
|
+
const generation = epoch;
|
|
78
|
+
const action = await detectCLI(pi);
|
|
79
|
+
if (!alive || generation !== epoch || setupAbort) return;
|
|
80
|
+
cliState = action;
|
|
81
|
+
if (suggest && action === "upgrade") {
|
|
82
|
+
ctx.ui.notify(`Pinote CLI ${bundledCLIVersion} is bundled with this extension. Run /pi-note-upgrade to update your older CLI.`, "info");
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const invoke = async (argv: string[], signal?: AbortSignal) => {
|
|
87
|
+
signal?.throwIfAborted();
|
|
88
|
+
let result;
|
|
89
|
+
try {
|
|
90
|
+
result = await pi.exec("note", argv, { timeout: 5000, signal });
|
|
91
|
+
} catch (error) {
|
|
92
|
+
throw new Error(`Pinote CLI unavailable: ${clean(String(error))}. ${setupHint}`);
|
|
93
|
+
}
|
|
94
|
+
if (result.killed || result.code !== 0) {
|
|
95
|
+
const detail = clean((result.stderr || result.stdout || "note failed or timed out").trim());
|
|
96
|
+
throw new Error(`${detail}${argv[0] === "--version" ? ` ${setupHint}` : result.killed ? " Read the task before retrying; a write may have committed." : ""}`);
|
|
97
|
+
}
|
|
98
|
+
return result.stdout;
|
|
99
|
+
};
|
|
100
|
+
const run = async (argv: string[], signal?: AbortSignal, canRun = () => true) => {
|
|
101
|
+
if (setupAbort) throw new Error("Pinote CLI setup is still running. Retry after it finishes.");
|
|
102
|
+
// Old pinote treats unknown commands as note text. Probe safely before every call,
|
|
103
|
+
// including tools in headless sessions, and do not cache across CLI upgrades/downgrades.
|
|
104
|
+
const version = (await invoke(["--version"], signal)).trim();
|
|
105
|
+
if (!compatibleVersion(version)) {
|
|
106
|
+
throw new Error(`pi-note requires pinote 0.3.0+; found ${clean(version) || "an unknown CLI"}. ${setupHint}`);
|
|
107
|
+
}
|
|
108
|
+
if (!canRun()) throw new Error("Pinote operation cancelled: the session changed.");
|
|
109
|
+
return invoke(argv, signal);
|
|
110
|
+
};
|
|
111
|
+
const selected = async (ctx: ExtensionContext, signal?: AbortSignal) =>
|
|
112
|
+
task(await run(["agent", "selected", "--cwd", ctx.cwd], signal));
|
|
113
|
+
|
|
114
|
+
const refresh = async (ctx: ExtensionContext) => {
|
|
115
|
+
if (!alive || setupAbort || !ctx.hasUI || ctx.mode !== "tui") return;
|
|
116
|
+
const generation = epoch;
|
|
117
|
+
const serial = ++refreshSerial;
|
|
118
|
+
try {
|
|
119
|
+
const current = await selected(ctx);
|
|
120
|
+
if (alive && generation === epoch && serial === refreshSerial) {
|
|
121
|
+
ctx.ui.setStatus("pinote", current ? truncateToWidth(`${noteIcon} ${taskTag(current)} ${firstLine(current)}`, 60) : undefined);
|
|
122
|
+
watcher.update(ctx, current);
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
if (alive && generation === epoch && serial === refreshSerial) {
|
|
126
|
+
ctx.ui.setStatus("pinote", `${noteIcon} Pinote unavailable ยท ${cliState === "upgrade" ? "/pi-note-upgrade" : cliState === "setup" ? "/pi-note-setup" : "/pi-note"}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const watcher = createPRWatcher(pi, {
|
|
131
|
+
selected,
|
|
132
|
+
get: async (id) => requiredTask(await run(["agent", "get", String(id)]), id),
|
|
133
|
+
done: async (current, canAct) => {
|
|
134
|
+
const result = requiredTask(await run([
|
|
135
|
+
"agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
|
|
136
|
+
], undefined, canAct), current.id);
|
|
137
|
+
if (result.state !== "done") throw new Error(compatible);
|
|
138
|
+
},
|
|
139
|
+
claim: () => {
|
|
140
|
+
if (pending) return;
|
|
141
|
+
const operation = Symbol();
|
|
142
|
+
pending = operation;
|
|
143
|
+
return () => { if (pending === operation) pending = undefined; };
|
|
144
|
+
},
|
|
145
|
+
refresh,
|
|
146
|
+
});
|
|
147
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
148
|
+
setupAbort?.abort();
|
|
149
|
+
alive = true;
|
|
150
|
+
activeContext = ctx;
|
|
151
|
+
epoch++;
|
|
152
|
+
const generation = epoch;
|
|
153
|
+
cliState = undefined;
|
|
154
|
+
if (ctx.hasUI && ctx.mode === "tui") {
|
|
155
|
+
ctx.ui.addAutocompleteProvider((current) => cliMenu(current, () => setupAbort ? undefined : cliState));
|
|
156
|
+
}
|
|
157
|
+
// Keep the setup lock until its aborted subprocess has actually settled.
|
|
158
|
+
if (!setupAbort) pending = undefined;
|
|
159
|
+
await checkCLI(ctx, true);
|
|
160
|
+
if (!alive || generation !== epoch) return;
|
|
161
|
+
if (!setupAbort) watcher.start(ctx);
|
|
162
|
+
await refresh(ctx);
|
|
163
|
+
});
|
|
164
|
+
pi.on("before_agent_start", async (_event, ctx) => { await refresh(ctx); });
|
|
165
|
+
pi.on("agent_end", async (_event, ctx) => { await refresh(ctx); });
|
|
166
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
167
|
+
setupAbort?.abort();
|
|
168
|
+
alive = false;
|
|
169
|
+
activeContext = undefined;
|
|
170
|
+
epoch++;
|
|
171
|
+
refreshSerial++;
|
|
172
|
+
watcher.stop();
|
|
173
|
+
if (ctx.hasUI && ctx.mode === "tui") ctx.ui.setStatus("pinote", undefined);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const registerInstallCommand = (name: "pi-note-setup" | "pi-note-upgrade") => pi.registerCommand(name, {
|
|
177
|
+
description: name === "pi-note-setup" ? "Install the missing Python CLI with uv" : "Upgrade an older Python CLI to the bundled version",
|
|
178
|
+
handler: async (args, ctx) => {
|
|
179
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
180
|
+
if (args.trim()) {
|
|
181
|
+
ctx.ui.notify(`Usage: /${name}`, "warning");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (pending || !ctx.isIdle()) {
|
|
185
|
+
ctx.ui.notify("Wait until Pi is idle and the pinote operation has finished.", "warning");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const operation = Symbol();
|
|
189
|
+
pending = operation;
|
|
190
|
+
const generation = epoch;
|
|
191
|
+
const controller = new AbortController();
|
|
192
|
+
setupAbort = controller;
|
|
193
|
+
watcher.stop();
|
|
194
|
+
const currentSession = () => alive && generation === epoch;
|
|
195
|
+
try {
|
|
196
|
+
await setupCLI(pi, ctx, name === "pi-note-upgrade",
|
|
197
|
+
() => currentSession() && ctx.isIdle(), controller.signal);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (currentSession()) ctx.ui.notify(`Pinote setup: ${clean(String(error))}`, "error");
|
|
200
|
+
} finally {
|
|
201
|
+
if (pending === operation) pending = undefined;
|
|
202
|
+
if (setupAbort === controller) setupAbort = undefined;
|
|
203
|
+
const latestContext = activeContext ?? (currentSession() ? ctx : undefined);
|
|
204
|
+
if (alive && latestContext) {
|
|
205
|
+
const generation = epoch;
|
|
206
|
+
await checkCLI(latestContext, !currentSession());
|
|
207
|
+
if (!alive || generation !== epoch || setupAbort) return;
|
|
208
|
+
watcher.start(latestContext);
|
|
209
|
+
await refresh(latestContext);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
registerInstallCommand("pi-note-setup");
|
|
215
|
+
registerInstallCommand("pi-note-upgrade");
|
|
216
|
+
|
|
217
|
+
pi.registerCommand("pi-note", {
|
|
218
|
+
description: "Continue, complete, or switch the selected pinote task",
|
|
219
|
+
handler: async (args, ctx) => {
|
|
220
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
221
|
+
if (args.trim()) { ctx.ui.notify("Usage: /pi-note", "warning"); return; }
|
|
222
|
+
if (pending || !ctx.isIdle()) {
|
|
223
|
+
ctx.ui.notify("Wait until Pi is idle and the pinote operation has finished.", "warning");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const operation = Symbol();
|
|
227
|
+
pending = operation;
|
|
228
|
+
const generation = epoch;
|
|
229
|
+
const currentSession = () => alive && generation === epoch;
|
|
230
|
+
const canAct = () => currentSession() && ctx.isIdle();
|
|
231
|
+
try {
|
|
232
|
+
const current = await selected(ctx);
|
|
233
|
+
if (!canAct()) return;
|
|
234
|
+
const action = current
|
|
235
|
+
? await ctx.ui.select(`Pinote โ ${taskState(current)} ${taskTag(current)} ${title(current)}`, ["Continue", "Done", "Switch task"])
|
|
236
|
+
: "Switch task";
|
|
237
|
+
if (!action || !canAct()) return;
|
|
238
|
+
if (action === "Done" && current) {
|
|
239
|
+
refreshSerial++;
|
|
240
|
+
const completed = requiredTask(await run([
|
|
241
|
+
"agent", "done", String(current.id), "--expected-updated-at", current.updated_at,
|
|
242
|
+
], undefined, canAct), current.id);
|
|
243
|
+
if (completed.state !== "done") throw new Error(compatible);
|
|
244
|
+
if (currentSession()) ctx.ui.notify(`Pinote #${current.id} completed.`, "info");
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
let id: number;
|
|
248
|
+
if (action === "Continue" && current) {
|
|
249
|
+
id = current.id;
|
|
250
|
+
} else if (action === "Switch task") {
|
|
251
|
+
const tasks = taskList(await run(["list", "--json"]));
|
|
252
|
+
if (!canAct()) return;
|
|
253
|
+
if (!tasks.length) { ctx.ui.notify("No active pinote tasks.", "info"); return; }
|
|
254
|
+
const choice = await ctx.ui.custom<number | undefined>((tui, theme, kb, done) =>
|
|
255
|
+
new TaskPicker(tasks, theme, (data, action) => kb.matches(data, action), done, () => tui.requestRender()));
|
|
256
|
+
if (choice === undefined || !canAct()) return;
|
|
257
|
+
id = choice;
|
|
258
|
+
} else return;
|
|
259
|
+
// Validate/start the explicit task and return its latest handoff in one transaction.
|
|
260
|
+
refreshSerial++;
|
|
261
|
+
const chosen = requiredTask(await run(["agent", "select", String(id), "--cwd", ctx.cwd], undefined, canAct), id);
|
|
262
|
+
if (!canAct()) return;
|
|
263
|
+
const draft = ctx.ui.getEditorText();
|
|
264
|
+
const handoff = `Pinote task #${chosen.id} (revision ${chosen.updated_at}).\n` +
|
|
265
|
+
"Use pinote_get to read current task data and pinote_update to save agent handoff fields.\n" +
|
|
266
|
+
handoffGuidance + "\n\n" + chosen.markdown;
|
|
267
|
+
ctx.ui.setEditorText(draft ? `${draft}\n\n${handoff}` : handoff);
|
|
268
|
+
ctx.ui.notify(`Pinote #${chosen.id} is in progress. Task added to input.`, "info");
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if (currentSession()) ctx.ui.notify(`Pinote: ${clean(error instanceof Error ? error.message : String(error))}`, "error");
|
|
271
|
+
} finally {
|
|
272
|
+
if (pending === operation) pending = undefined;
|
|
273
|
+
if (currentSession()) await refresh(ctx);
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
pi.registerTool({
|
|
279
|
+
name: "pinote_get",
|
|
280
|
+
label: "Pinote get",
|
|
281
|
+
description: "Read a pinote task and its Markdown agent fields/revision. Omit id to read this project's selected task; supply id to read another task. Use the returned updated_at for pinote_update.",
|
|
282
|
+
parameters: Type.Object({ id: Type.Optional(idSchema) }),
|
|
283
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
284
|
+
if (params.id !== undefined && !validId(params.id)) throw new Error("id must be a positive safe integer.");
|
|
285
|
+
const argv = params.id === undefined
|
|
286
|
+
? ["agent", "selected", "--cwd", ctx.cwd]
|
|
287
|
+
: ["agent", "get", String(params.id)];
|
|
288
|
+
const result = requiredTask(await run(argv, signal), params.id);
|
|
289
|
+
return toolResult(result);
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
pi.registerTool({
|
|
293
|
+
name: "pinote_update",
|
|
294
|
+
label: "Pinote update",
|
|
295
|
+
promptGuidelines: [handoffGuidance],
|
|
296
|
+
description: "Patch arbitrary agent handoff fields on an explicit pinote task. Read first with pinote_get; pass its updated_at as expected_updated_at. set merges label/value pairs without replacing other fields or task text; values are Markdown, e.g. PR: [Fix #42](https://github.com/org/repo/pull/42). Set PR to one GitHub pull-request URL or Markdown link to show it in the footer and watch for merge confirmation in interactive Pi. remove deletes named fields. A stale revision fails; read again before retrying. Does not complete the task.",
|
|
297
|
+
parameters: Type.Object({
|
|
298
|
+
id: idSchema,
|
|
299
|
+
expected_updated_at: Type.String({ minLength: 1 }),
|
|
300
|
+
set: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
301
|
+
remove: Type.Optional(Type.Array(Type.String())),
|
|
302
|
+
}),
|
|
303
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
304
|
+
if (!validId(params.id) || !text(params.expected_updated_at) || !params.expected_updated_at.trim()) {
|
|
305
|
+
throw new Error("id and expected_updated_at are required; read the task first.");
|
|
306
|
+
}
|
|
307
|
+
if (pending) throw new Error("A pinote operation is already open. Retry after it finishes.");
|
|
308
|
+
const operation = Symbol();
|
|
309
|
+
pending = operation;
|
|
310
|
+
const generation = epoch;
|
|
311
|
+
refreshSerial++;
|
|
312
|
+
try {
|
|
313
|
+
const argv = ["agent", "update", String(params.id), "--expected-updated-at", params.expected_updated_at,
|
|
314
|
+
"--set-json", JSON.stringify(params.set ?? {})];
|
|
315
|
+
for (const label of params.remove ?? []) argv.push(`--remove=${label}`);
|
|
316
|
+
const result = requiredTask(await run(argv, signal, () => alive && generation === epoch), params.id);
|
|
317
|
+
return toolResult(result);
|
|
318
|
+
} finally {
|
|
319
|
+
if (pending === operation) pending = undefined;
|
|
320
|
+
if (alive && generation === epoch) await refresh(ctx);
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
});
|
|
324
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-note",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": ["pi-package", "tasks", "notes"],
|
|
8
|
+
"engines": { "node": ">=22.19.0" },
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/kvidzibo/pinote.git",
|
|
12
|
+
"directory": "pi"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/kvidzibo/pinote/tree/main/pi#readme",
|
|
15
|
+
"bugs": { "url": "https://github.com/kvidzibo/pinote/issues" },
|
|
16
|
+
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
|
|
17
|
+
"files": ["index.ts", "pr-watch.ts", "task-picker.ts", "setup.ts", "icons", "README.md", "LICENSE"],
|
|
18
|
+
"pi": { "extensions": ["./index.ts"] },
|
|
19
|
+
"scripts": {
|
|
20
|
+
"check": "tsc --noEmit",
|
|
21
|
+
"test:unit": "node --test --experimental-strip-types tests/*.test.ts",
|
|
22
|
+
"test:load": "node --test --experimental-strip-types test-load.ts",
|
|
23
|
+
"test": "npm run check && npm run test:unit && npm run test:load"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
27
|
+
"@earendil-works/pi-tui": "*",
|
|
28
|
+
"typebox": "*"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@earendil-works/pi-coding-agent": "0.99.1",
|
|
32
|
+
"@earendil-works/pi-tui": "0.99.1",
|
|
33
|
+
"@types/node": "22.20.3",
|
|
34
|
+
"typebox": "1.3.7",
|
|
35
|
+
"typescript": "5.9.3"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/pr-watch.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type WatchedTask = {
|
|
4
|
+
id: number;
|
|
5
|
+
state: string;
|
|
6
|
+
updated_at: string;
|
|
7
|
+
agent_notes: Record<string, string>;
|
|
8
|
+
};
|
|
9
|
+
type PR = { url: string; number: string };
|
|
10
|
+
type Dependencies = {
|
|
11
|
+
selected: (ctx: ExtensionContext) => Promise<WatchedTask | null>;
|
|
12
|
+
get: (id: number) => Promise<WatchedTask>;
|
|
13
|
+
done: (task: WatchedTask, canAct: () => boolean) => Promise<void>;
|
|
14
|
+
claim: () => (() => void) | undefined;
|
|
15
|
+
refresh: (ctx: ExtensionContext) => Promise<void>;
|
|
16
|
+
};
|
|
17
|
+
const entryType = "pinote-pr-acknowledged";
|
|
18
|
+
const watchEntryType = "pinote-pr-watched";
|
|
19
|
+
|
|
20
|
+
// Accept one bare URL or Markdown link, never arbitrary hosts or terminal escapes.
|
|
21
|
+
export function taskPR(task: WatchedTask | null): PR | undefined {
|
|
22
|
+
const field = task?.agent_notes.PR?.trim();
|
|
23
|
+
if (!field) return;
|
|
24
|
+
const url = /^\[[^\r\n]*\]\(([^\s]+)\)$/u.exec(field)?.[1] ?? field;
|
|
25
|
+
const match = /^https:\/\/github\.com\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_.-]+)\/pull\/([1-9][0-9]*)\/?$/u.exec(url);
|
|
26
|
+
if (!match || [".", ".."].includes(match[2]) || !Number.isSafeInteger(Number(match[3]))) return;
|
|
27
|
+
return { url: `https://github.com/${match[1]}/${match[2]}/pull/${match[3]}`, number: match[3] };
|
|
28
|
+
}
|
|
29
|
+
const keyFor = (task: WatchedTask, pr: PR) => JSON.stringify([task.id, pr.url.toLowerCase()]);
|
|
30
|
+
const watchKey = (task: WatchedTask | null) => {
|
|
31
|
+
const pr = taskPR(task);
|
|
32
|
+
return task && pr ? keyFor(task, pr) : undefined;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function createPRWatcher(pi: ExtensionAPI, deps: Dependencies) {
|
|
36
|
+
let ctx: ExtensionContext | undefined;
|
|
37
|
+
let watched: WatchedTask | null = null;
|
|
38
|
+
let visiblePR: PR | undefined;
|
|
39
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
40
|
+
let controller = new AbortController();
|
|
41
|
+
let generation = 0;
|
|
42
|
+
let revision = 0;
|
|
43
|
+
let running = false;
|
|
44
|
+
let interval = 60_000;
|
|
45
|
+
let warned = false;
|
|
46
|
+
let acknowledged = new Set<string>();
|
|
47
|
+
|
|
48
|
+
const remember = (current: WatchedTask | null) => {
|
|
49
|
+
const changed = watchKey(current) !== watchKey(watched);
|
|
50
|
+
watched = current;
|
|
51
|
+
if (changed && ctx) pi.appendEntry(watchEntryType, {
|
|
52
|
+
cwd: ctx.cwd, task: current ? { id: current.id, url: taskPR(current)!.url } : null,
|
|
53
|
+
});
|
|
54
|
+
return changed;
|
|
55
|
+
};
|
|
56
|
+
const paint = () => {
|
|
57
|
+
if (!ctx) return;
|
|
58
|
+
const pr = visiblePR;
|
|
59
|
+
ctx.ui.setStatus("pinote-pr", pr
|
|
60
|
+
? ctx.ui.theme.fg("mdLink", `\x1b]8;;${pr.url}\x1b\\PR #${pr.number}\x1b]8;;\x1b\\`)
|
|
61
|
+
: undefined);
|
|
62
|
+
};
|
|
63
|
+
const schedule = (delay = interval) => {
|
|
64
|
+
if (!ctx || !interval || running) return;
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
timer = setTimeout(() => { void poll(); }, delay);
|
|
67
|
+
timer.unref();
|
|
68
|
+
};
|
|
69
|
+
const update = (context: ExtensionContext, current: WatchedTask | null) => {
|
|
70
|
+
if (!ctx || ctx.cwd !== context.cwd) return;
|
|
71
|
+
revision++;
|
|
72
|
+
// Display only the active selection, independently of the retained merge watch.
|
|
73
|
+
visiblePR = current && ["active", "in_progress"].includes(current.state) ? taskPR(current) : undefined;
|
|
74
|
+
// Completion clears selection. Keep the last task until poll verifies its state.
|
|
75
|
+
const changed = current ? remember(taskPR(current) ? current : null) : false;
|
|
76
|
+
paint();
|
|
77
|
+
if (changed) schedule(0);
|
|
78
|
+
};
|
|
79
|
+
const latest = async (context: ExtensionContext, previous: WatchedTask | null) => {
|
|
80
|
+
const current = await deps.selected(context);
|
|
81
|
+
if (current) return taskPR(current) ? current : null;
|
|
82
|
+
if (!previous) return null;
|
|
83
|
+
const old = await deps.get(previous.id);
|
|
84
|
+
return old.state === "done" && taskPR(old) ? old : null;
|
|
85
|
+
};
|
|
86
|
+
async function poll() {
|
|
87
|
+
if (!ctx || running) return;
|
|
88
|
+
const context = ctx;
|
|
89
|
+
const epoch = generation;
|
|
90
|
+
const signal = controller.signal;
|
|
91
|
+
const valid = () => ctx === context && generation === epoch && !signal.aborted;
|
|
92
|
+
running = true;
|
|
93
|
+
try {
|
|
94
|
+
await deps.refresh(context);
|
|
95
|
+
if (!valid()) return;
|
|
96
|
+
const snapshot = revision;
|
|
97
|
+
const candidate = await latest(context, watched);
|
|
98
|
+
if (!valid() || revision !== snapshot) return;
|
|
99
|
+
remember(candidate);
|
|
100
|
+
paint();
|
|
101
|
+
const pr = taskPR(candidate);
|
|
102
|
+
if (!candidate || !pr) return;
|
|
103
|
+
const key = keyFor(candidate, pr);
|
|
104
|
+
const same = () => valid() && watched !== null && taskPR(watched)?.url === pr.url && watched.id === candidate.id;
|
|
105
|
+
if (acknowledged.has(key)) return;
|
|
106
|
+
const result = await pi.exec("gh", ["pr", "view", pr.url, "--json", "state,url"], {
|
|
107
|
+
cwd: context.cwd, timeout: 10_000, signal,
|
|
108
|
+
});
|
|
109
|
+
if (!same()) return;
|
|
110
|
+
if (result.code !== 0 || result.killed) throw new Error("GitHub CLI failed");
|
|
111
|
+
const data = JSON.parse(result.stdout);
|
|
112
|
+
// GitHub canonicalizes owner/repository casing in its response URL.
|
|
113
|
+
if (typeof data.url !== "string" || data.url.toLowerCase() !== pr.url.toLowerCase() ||
|
|
114
|
+
!["OPEN", "CLOSED", "MERGED"].includes(data.state)) throw new Error("Invalid PR response");
|
|
115
|
+
warned = false;
|
|
116
|
+
if (data.state !== "MERGED" || !context.isIdle()) return;
|
|
117
|
+
const release = deps.claim();
|
|
118
|
+
if (!release) return;
|
|
119
|
+
try {
|
|
120
|
+
const current = await latest(context, candidate);
|
|
121
|
+
if (!same() || !current || current.id !== candidate.id || taskPR(current)?.url !== pr.url || !context.isIdle()) return;
|
|
122
|
+
const acknowledge = () => {
|
|
123
|
+
pi.appendEntry(entryType, { cwd: context.cwd, key });
|
|
124
|
+
acknowledged.add(key);
|
|
125
|
+
};
|
|
126
|
+
const alreadyDone = () => {
|
|
127
|
+
context.ui.notify(`PR #${pr.number} was merged and the task is already completed.`, "info");
|
|
128
|
+
acknowledge();
|
|
129
|
+
};
|
|
130
|
+
if (current.state === "done") { alreadyDone(); return; }
|
|
131
|
+
if (!["active", "in_progress"].includes(current.state)) return;
|
|
132
|
+
const confirmed = await context.ui.confirm(`PR #${pr.number} was merged`, "Mark this task completed?", { signal });
|
|
133
|
+
if (!same() || !context.isIdle()) return;
|
|
134
|
+
// Re-read selection, link, and revision after the dialog; never complete a changed task.
|
|
135
|
+
const fresh = await latest(context, current);
|
|
136
|
+
if (!same() || !fresh || fresh.id !== current.id || taskPR(fresh)?.url !== pr.url) return;
|
|
137
|
+
if (fresh.state === "done") { alreadyDone(); return; }
|
|
138
|
+
if (fresh.updated_at !== current.updated_at) {
|
|
139
|
+
context.ui.notify("Pinote task changed while confirming. The merge will be checked again.", "warning");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (confirmed) {
|
|
143
|
+
await deps.done(fresh, () => same() && context.isIdle());
|
|
144
|
+
if (!valid()) return;
|
|
145
|
+
context.ui.notify(`Pinote #${fresh.id} completed.`, "info");
|
|
146
|
+
}
|
|
147
|
+
acknowledge();
|
|
148
|
+
} finally {
|
|
149
|
+
release();
|
|
150
|
+
if (valid()) await deps.refresh(context);
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
if (valid() && !warned) {
|
|
154
|
+
warned = true;
|
|
155
|
+
context.ui.notify("Pinote PR check failed. Check gh authentication and note availability; polling will retry.", "warning");
|
|
156
|
+
}
|
|
157
|
+
} finally {
|
|
158
|
+
// A previous session's request must not affect a replacement watcher's timer.
|
|
159
|
+
if (valid()) { running = false; schedule(); }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
update,
|
|
164
|
+
start(context: ExtensionContext) {
|
|
165
|
+
this.stop();
|
|
166
|
+
if (!context.hasUI || context.mode !== "tui") return;
|
|
167
|
+
const seconds = Number(process.env.PINOTE_PR_POLL_SECONDS ?? "60");
|
|
168
|
+
interval = Number.isInteger(seconds) && (seconds === 0 || seconds >= 10 && seconds <= 86400)
|
|
169
|
+
? seconds * 1000 : 60_000;
|
|
170
|
+
if (interval !== seconds * 1000) context.ui.notify("Invalid PINOTE_PR_POLL_SECONDS; using 60 seconds (allowed: 0 or 10โ86400).", "warning");
|
|
171
|
+
ctx = context;
|
|
172
|
+
const branch = context.sessionManager.getBranch();
|
|
173
|
+
// Persist only the identity; latest() must verify the real task state before polling.
|
|
174
|
+
for (const entry of branch) {
|
|
175
|
+
if (entry.type !== "custom" || entry.customType !== watchEntryType) continue;
|
|
176
|
+
const data = entry.data as { cwd?: unknown; task?: { id?: unknown; url?: unknown } | null } | undefined;
|
|
177
|
+
if (data?.cwd !== context.cwd) continue;
|
|
178
|
+
if (data.task === null) { watched = null; continue; }
|
|
179
|
+
if (!data.task || !Number.isSafeInteger(data.task.id) || Number(data.task.id) < 1 || typeof data.task.url !== "string") continue;
|
|
180
|
+
const restored = { id: Number(data.task.id), state: "unknown", updated_at: "", agent_notes: { PR: data.task.url } };
|
|
181
|
+
if (taskPR(restored)) watched = restored;
|
|
182
|
+
}
|
|
183
|
+
acknowledged = new Set(branch.flatMap((entry) => {
|
|
184
|
+
if (entry.type !== "custom" || entry.customType !== entryType) return [];
|
|
185
|
+
const data = entry.data as { cwd?: unknown; key?: unknown } | undefined;
|
|
186
|
+
return data?.cwd === context.cwd && typeof data.key === "string" ? [data.key] : [];
|
|
187
|
+
}));
|
|
188
|
+
schedule(0);
|
|
189
|
+
},
|
|
190
|
+
stop() {
|
|
191
|
+
generation++;
|
|
192
|
+
controller.abort();
|
|
193
|
+
controller = new AbortController();
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
ctx?.ui.setStatus("pinote-pr", undefined);
|
|
196
|
+
ctx = undefined;
|
|
197
|
+
watched = null;
|
|
198
|
+
visiblePR = undefined;
|
|
199
|
+
running = false;
|
|
200
|
+
warned = false;
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
package/setup.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { stripVTControlCharacters } from "node:util";
|
|
3
|
+
import type { AutocompleteProvider } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
// Immutable source: the Python app is not yet published to PyPI.
|
|
6
|
+
export const cliSource = "https://github.com/kvidzibo/pinote/archive/9c6795f655e6b9d2c51c735fc84b6485fb663bb1.tar.gz";
|
|
7
|
+
export const bundledCLIVersion = "0.3.0";
|
|
8
|
+
export const setupHint = "Use /pi-note-setup for a missing CLI or /pi-note-upgrade for an older CLI; note must be on PATH.";
|
|
9
|
+
export type CLIAction = "setup" | "upgrade" | "ready";
|
|
10
|
+
|
|
11
|
+
export function cliAction(version: string): CLIAction {
|
|
12
|
+
const match = /^pinote (\d+)\.(\d+)\.(\d+)$/u.exec(version.trim());
|
|
13
|
+
if (!match) return "setup";
|
|
14
|
+
const installed = match.slice(1).map(BigInt);
|
|
15
|
+
const bundled = bundledCLIVersion.split(".").map(BigInt);
|
|
16
|
+
for (let i = 0; i < 3; i++) {
|
|
17
|
+
if (installed[i] < bundled[i]) return "upgrade";
|
|
18
|
+
if (installed[i] > bundled[i]) return "ready";
|
|
19
|
+
}
|
|
20
|
+
return "ready";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function detectCLI(pi: ExtensionAPI, signal?: AbortSignal): Promise<CLIAction> {
|
|
24
|
+
try {
|
|
25
|
+
const result = await pi.exec("note", ["--version"], { timeout: 5000, signal });
|
|
26
|
+
return !result.killed && result.code === 0 ? cliAction(result.stdout) : "setup";
|
|
27
|
+
} catch { return "setup"; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function cliMenu(current: AutocompleteProvider, action: () => CLIAction | undefined): AutocompleteProvider {
|
|
31
|
+
return {
|
|
32
|
+
triggerCharacters: current.triggerCharacters,
|
|
33
|
+
async getSuggestions(...args) {
|
|
34
|
+
const suggestions = await current.getSuggestions(...args);
|
|
35
|
+
if (!suggestions || !/^\/\S*$/u.test(suggestions.prefix)) return suggestions;
|
|
36
|
+
const items = suggestions.items.filter(({ value }) => {
|
|
37
|
+
const name = value.replace(/^\//u, "");
|
|
38
|
+
return name === "pi-note-setup" ? action() === "setup"
|
|
39
|
+
: name === "pi-note-upgrade" ? action() === "upgrade" : true;
|
|
40
|
+
});
|
|
41
|
+
return items.length ? { ...suggestions, items } : null;
|
|
42
|
+
},
|
|
43
|
+
applyCompletion: (...args) => current.applyCompletion(...args),
|
|
44
|
+
shouldTriggerFileCompletion: (...args) => current.shouldTriggerFileCompletion?.(...args) ?? true,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function compatibleVersion(version: string): boolean {
|
|
48
|
+
const match = /^pinote (\d+)\.(\d+)\.(\d+)$/u.exec(version.trim());
|
|
49
|
+
return !!match && (Number(match[1]) > 0 || Number(match[2]) >= 3);
|
|
50
|
+
}
|
|
51
|
+
const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/gu, " ").slice(0, 1500);
|
|
52
|
+
|
|
53
|
+
export async function setupCLI(
|
|
54
|
+
pi: ExtensionAPI, ctx: ExtensionContext, upgrade: boolean,
|
|
55
|
+
canAct: () => boolean, signal: AbortSignal,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
const exec = (command: string, args: string[], timeout = 5000) =>
|
|
58
|
+
pi.exec(command, args, { timeout, signal });
|
|
59
|
+
const action = await detectCLI(pi, signal);
|
|
60
|
+
if (!canAct() || signal.aborted) return;
|
|
61
|
+
if (action === "ready") {
|
|
62
|
+
ctx.ui.notify(`Pinote CLI is ready (bundled version ${bundledCLIVersion} or newer). Use /pi-note.`, "info");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const command = action === "upgrade" ? "/pi-note-upgrade" : "/pi-note-setup";
|
|
66
|
+
if (upgrade !== (action === "upgrade")) {
|
|
67
|
+
ctx.ui.notify(`Use ${command} ${action === "upgrade" ? "to upgrade the older CLI" : "to install the missing CLI"}.`, "info");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const uv = await exec("uv", ["--version"]);
|
|
72
|
+
if (uv.killed || uv.code !== 0) throw new Error("uv unavailable");
|
|
73
|
+
} catch {
|
|
74
|
+
if (canAct()) ctx.ui.notify(`Install uv from https://docs.astral.sh/uv/getting-started/installation/ and restart Pi with uv on PATH, then run ${command}. Python 3.11+ is required; uv can download it.`, "error");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!canAct() || signal.aborted) return;
|
|
78
|
+
const approved = await ctx.ui.confirm("Install Pinote CLI?",
|
|
79
|
+
`Install bundled pinote ${bundledCLIVersion} in uv's isolated tool environment (may replace an existing uv installation). ` +
|
|
80
|
+
"Downloads source and build dependencies; uv may download Python 3.11+. No GTK, shell configuration, or task changes. " +
|
|
81
|
+
`Source: ${cliSource}`);
|
|
82
|
+
if (!approved || !canAct() || signal.aborted) return;
|
|
83
|
+
const confirmedAction = await detectCLI(pi, signal);
|
|
84
|
+
if (!canAct() || signal.aborted) return;
|
|
85
|
+
if (confirmedAction !== action) {
|
|
86
|
+
ctx.ui.notify("Pinote CLI changed while confirmation was open; installation skipped.", "info");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
ctx.ui.notify("Installing Pinote CLI with uvโฆ (up to 3 minutes)", "info");
|
|
90
|
+
const installed = await exec("uv", ["--no-config", "tool", "install", "--reinstall", "--python", ">=3.11", `pinote @ ${cliSource}`], 180000);
|
|
91
|
+
if (signal.aborted) return;
|
|
92
|
+
if (installed.killed || installed.code !== 0) {
|
|
93
|
+
throw new Error(`CLI installation failed or timed out: ${clean(installed.stderr || installed.stdout)}. Check uv/network access and retry ${command}. Conflicting executables are not overwritten; resolve them manually.`);
|
|
94
|
+
}
|
|
95
|
+
if (!canAct()) return;
|
|
96
|
+
try {
|
|
97
|
+
const version = await exec("note", ["--version"]);
|
|
98
|
+
if (version.killed || version.code !== 0 || cliAction(version.stdout) !== "ready") throw new Error("note unavailable or older than bundled CLI");
|
|
99
|
+
if (canAct()) ctx.ui.notify(`${clean(version.stdout.trim())} is ready. Use /pi-note. GTK is optional and installed separately.`, "info");
|
|
100
|
+
} catch {
|
|
101
|
+
if (canAct()) ctx.ui.notify("Pinote installed, but note on PATH is missing or older than the bundled CLI. Run uv tool dir --bin in your terminal, add that directory to PATH before older note executables, then restart Pi.", "warning");
|
|
102
|
+
}
|
|
103
|
+
}
|
package/task-picker.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Input, SelectList, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export type TaskSummary = {
|
|
5
|
+
id: number;
|
|
6
|
+
text: string;
|
|
7
|
+
state: string;
|
|
8
|
+
tag: string | null;
|
|
9
|
+
};
|
|
10
|
+
const singleLine = (value: string) => value.replace(/\s+/gu, " ").trim();
|
|
11
|
+
export const taskState = (task: TaskSummary) => task.state === "in_progress" ? "โ" : "โ";
|
|
12
|
+
export const taskTag = (task: TaskSummary) => task.tag ? `[${singleLine(task.tag)}]` : "[Untagged]";
|
|
13
|
+
|
|
14
|
+
const selectionActions = ["tui.select.up", "tui.select.down", "tui.select.confirm", "tui.select.cancel"] as const;
|
|
15
|
+
type Matches = (data: string, action: typeof selectionActions[number]) => boolean;
|
|
16
|
+
|
|
17
|
+
export class TaskPicker {
|
|
18
|
+
private input = new Input({ prompt: "Search: ", placeholder: "task text, #id, tag, or state" });
|
|
19
|
+
private list!: SelectList;
|
|
20
|
+
private count = 0;
|
|
21
|
+
|
|
22
|
+
private tasks: TaskSummary[];
|
|
23
|
+
private theme: Theme;
|
|
24
|
+
private matches: Matches;
|
|
25
|
+
private done: (id: number | undefined) => void;
|
|
26
|
+
private requestRender: () => void;
|
|
27
|
+
|
|
28
|
+
constructor(tasks: TaskSummary[], theme: Theme, matches: Matches,
|
|
29
|
+
done: (id: number | undefined) => void, requestRender: () => void) {
|
|
30
|
+
this.tasks = tasks;
|
|
31
|
+
this.theme = theme;
|
|
32
|
+
this.matches = matches;
|
|
33
|
+
this.done = done;
|
|
34
|
+
this.requestRender = requestRender;
|
|
35
|
+
this.filter();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get focused() { return this.input.focused; }
|
|
39
|
+
set focused(value: boolean) { this.input.focused = value; }
|
|
40
|
+
|
|
41
|
+
private filter() {
|
|
42
|
+
const terms = this.input.getValue().toLocaleLowerCase().trim().split(/\s+/u).filter(Boolean);
|
|
43
|
+
const tasks = this.tasks.filter((task) => {
|
|
44
|
+
const search = `${task.text} #${task.id} ${taskTag(task)} ${task.state.replaceAll("_", " ")}`.toLocaleLowerCase();
|
|
45
|
+
return terms.every((term) => search.includes(term));
|
|
46
|
+
});
|
|
47
|
+
this.count = tasks.length;
|
|
48
|
+
this.list = new SelectList(tasks.map((task) => ({
|
|
49
|
+
value: String(task.id),
|
|
50
|
+
label: `${taskState(task)} ${taskTag(task)} #${task.id} ${singleLine(task.text)}`,
|
|
51
|
+
})), 8, {
|
|
52
|
+
selectedPrefix: (s) => this.theme.fg("accent", s),
|
|
53
|
+
selectedText: (s) => this.theme.fg("accent", s),
|
|
54
|
+
description: (s) => this.theme.fg("muted", s),
|
|
55
|
+
scrollInfo: (s) => this.theme.fg("dim", s),
|
|
56
|
+
noMatch: (s) => this.theme.fg("warning", s),
|
|
57
|
+
});
|
|
58
|
+
this.list.onSelect = (item) => this.done(Number(item.value));
|
|
59
|
+
this.list.onCancel = () => this.done(undefined);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
handleInput(data: string) {
|
|
63
|
+
if (selectionActions.some((action) => this.matches(data, action))) this.list.handleInput(data);
|
|
64
|
+
else {
|
|
65
|
+
const before = this.input.getValue();
|
|
66
|
+
this.input.handleInput(data);
|
|
67
|
+
if (this.input.getValue() !== before) this.filter();
|
|
68
|
+
}
|
|
69
|
+
this.requestRender();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
render(width: number) {
|
|
73
|
+
return [
|
|
74
|
+
truncateToWidth(this.theme.fg("accent", `Pinote โ select a task (${this.count}/${this.tasks.length})`), width),
|
|
75
|
+
...this.input.render(width),
|
|
76
|
+
...this.list.render(width),
|
|
77
|
+
truncateToWidth(this.theme.fg("dim", "Type to filter ยท โโ navigate ยท Enter select ยท Esc cancel"), width),
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
invalidate() { this.input.invalidate(); this.list.invalidate(); }
|
|
81
|
+
}
|