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