@danypops/papyrus 0.33.3 → 0.33.5
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/extension/src/bounded-poll.ts +20 -0
- package/extension/src/index.ts +124 -13
- package/extension/src/note-widget.ts +8 -0
- package/package.json +1 -1
- package/src/constants.ts +2 -0
- package/src/domain-services.ts +7 -4
- package/src/service.ts +1 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared idempotent start/stop wrapper over setInterval, extracted once TaskOverlay and
|
|
3
|
+
* NoteOverlay both needed the identical "fallback refresh for a mutation no event announces"
|
|
4
|
+
* behavior -- a second start() is a no-op rather than a competing timer, and stop() is safe
|
|
5
|
+
* to call even if never started.
|
|
6
|
+
*/
|
|
7
|
+
export class BoundedPoll {
|
|
8
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
9
|
+
|
|
10
|
+
start(intervalMs: number, tick: () => void): void {
|
|
11
|
+
if (this.timer) return;
|
|
12
|
+
this.timer = setInterval(tick, intervalMs);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
stop(): void {
|
|
16
|
+
if (!this.timer) return;
|
|
17
|
+
clearInterval(this.timer);
|
|
18
|
+
this.timer = undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
package/extension/src/index.ts
CHANGED
|
@@ -17,12 +17,16 @@ import {
|
|
|
17
17
|
PAPYRUS_CONTEXT_INJECTION_CHANNEL,
|
|
18
18
|
CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN,
|
|
19
19
|
TASK_WIDGET_POLL_INTERVAL_MS,
|
|
20
|
+
NOTE_WIDGET_POLL_INTERVAL_MS,
|
|
21
|
+
NOTE_LIST_MAX_LIMIT,
|
|
20
22
|
} from "../../src/constants.ts";
|
|
21
23
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
22
24
|
import type { GateResult } from "../../src/domain/gate.ts";
|
|
23
25
|
import { formatMetadata } from "./artifact-format.ts";
|
|
24
26
|
import { callService } from "./service-client.ts";
|
|
25
27
|
import { registerDomainTools, resolveNameFields } from "./domain-tools.ts";
|
|
28
|
+
import { BoundedPoll } from "./bounded-poll.ts";
|
|
29
|
+
import { renderNoteWidgetLines } from "./note-widget.ts";
|
|
26
30
|
import { ensureTypingCourtesyTracking, isLiveAskPending } from "./discuss-ask-view.ts";
|
|
27
31
|
import { PLAYBOOK_BRIDGE_MAX_PLAYBOOKS, registerPlaybookBridge } from "./playbook-bridge.ts";
|
|
28
32
|
import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
|
|
@@ -103,7 +107,7 @@ export class TaskOverlay {
|
|
|
103
107
|
private snapshot: TaskGraph = { nodes: [], rootIds: [] };
|
|
104
108
|
private projectRoot: string | undefined;
|
|
105
109
|
private sessionId: string | undefined;
|
|
106
|
-
private
|
|
110
|
+
private readonly poll = new BoundedPoll();
|
|
107
111
|
|
|
108
112
|
setUI(ctx: ExtensionUIContext): void {
|
|
109
113
|
if (ctx !== this.uiCtx) {
|
|
@@ -179,18 +183,14 @@ export class TaskOverlay {
|
|
|
179
183
|
|
|
180
184
|
/**
|
|
181
185
|
* Fallback for a Task mutation no event announces -- the CLI run directly from a shell, or
|
|
182
|
-
* a second concurrent Pi session against the same daemon.
|
|
183
|
-
* no-op rather than starting a competing timer.
|
|
186
|
+
* a second concurrent Pi session against the same daemon.
|
|
184
187
|
*/
|
|
185
188
|
startPolling(intervalMs: number = TASK_WIDGET_POLL_INTERVAL_MS): void {
|
|
186
|
-
|
|
187
|
-
this.pollTimer = setInterval(() => { void this.refresh(); }, intervalMs);
|
|
189
|
+
this.poll.start(intervalMs, () => { void this.refresh(); });
|
|
188
190
|
}
|
|
189
191
|
|
|
190
192
|
stopPolling(): void {
|
|
191
|
-
|
|
192
|
-
clearInterval(this.pollTimer);
|
|
193
|
-
this.pollTimer = undefined;
|
|
193
|
+
this.poll.stop();
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
dispose(): void {
|
|
@@ -204,6 +204,98 @@ export class TaskOverlay {
|
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
const NOTE_WIDGET_KEY = "pi-papyrus-notes";
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Deliberately simple, unlike TaskOverlay's tree: just an open-note count for this session's own
|
|
211
|
+
* CWD -- notes.list already scopes to project_root exactly (a note's projectRoot is fixed at
|
|
212
|
+
* capture time), so passing this overlay's projectRoot is what makes the count CWD-aware by
|
|
213
|
+
* default.
|
|
214
|
+
*/
|
|
215
|
+
export class NoteOverlay {
|
|
216
|
+
private uiCtx: ExtensionUIContext | undefined;
|
|
217
|
+
private registered = false;
|
|
218
|
+
private tui: any | undefined;
|
|
219
|
+
private openCount = 0;
|
|
220
|
+
private projectRoot: string | undefined;
|
|
221
|
+
private readonly poll = new BoundedPoll();
|
|
222
|
+
|
|
223
|
+
setUI(ctx: ExtensionUIContext): void {
|
|
224
|
+
if (ctx !== this.uiCtx) {
|
|
225
|
+
this.uiCtx = ctx;
|
|
226
|
+
this.registered = false;
|
|
227
|
+
this.tui = undefined;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
setProjectRoot(projectRoot: string): void { this.projectRoot = projectRoot; }
|
|
232
|
+
|
|
233
|
+
async refresh(): Promise<void> {
|
|
234
|
+
if (!this.projectRoot) return;
|
|
235
|
+
try {
|
|
236
|
+
const rows = await callService<Record<string, unknown>, Artifact[]>("notes.list", { project_root: this.projectRoot, limit: NOTE_LIST_MAX_LIMIT });
|
|
237
|
+
this.openCount = rows.length;
|
|
238
|
+
} catch {
|
|
239
|
+
this.openCount = 0;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
this.render();
|
|
243
|
+
} catch {
|
|
244
|
+
// A rendering bug must not crash the extension host over a best-effort status widget.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private render(): void {
|
|
249
|
+
if (!this.uiCtx) return;
|
|
250
|
+
|
|
251
|
+
if (this.openCount === 0) {
|
|
252
|
+
if (this.registered) {
|
|
253
|
+
this.uiCtx.setWidget(NOTE_WIDGET_KEY, undefined);
|
|
254
|
+
this.registered = false;
|
|
255
|
+
this.tui = undefined;
|
|
256
|
+
}
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!this.registered) {
|
|
261
|
+
this.uiCtx.setWidget(
|
|
262
|
+
NOTE_WIDGET_KEY,
|
|
263
|
+
(tui: any, theme: Theme) => {
|
|
264
|
+
this.tui = tui;
|
|
265
|
+
return {
|
|
266
|
+
render: (width: number) => renderNoteWidgetLines(theme, this.openCount, width),
|
|
267
|
+
invalidate: () => {
|
|
268
|
+
this.registered = false;
|
|
269
|
+
this.tui = undefined;
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
{ placement: "aboveEditor" },
|
|
274
|
+
);
|
|
275
|
+
this.registered = true;
|
|
276
|
+
} else {
|
|
277
|
+
this.tui?.requestRender?.();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
startPolling(intervalMs: number = NOTE_WIDGET_POLL_INTERVAL_MS): void {
|
|
282
|
+
this.poll.start(intervalMs, () => { void this.refresh(); });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
stopPolling(): void {
|
|
286
|
+
this.poll.stop();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
dispose(): void {
|
|
290
|
+
this.stopPolling();
|
|
291
|
+
this.uiCtx?.setWidget(NOTE_WIDGET_KEY, undefined);
|
|
292
|
+
this.registered = false;
|
|
293
|
+
this.tui = undefined;
|
|
294
|
+
this.uiCtx = undefined;
|
|
295
|
+
this.projectRoot = undefined;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
207
299
|
// ---------------------------------------------------------------------------
|
|
208
300
|
// Entry point
|
|
209
301
|
// ---------------------------------------------------------------------------
|
|
@@ -466,6 +558,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
466
558
|
import("./discuss.ts"),
|
|
467
559
|
]);
|
|
468
560
|
let overlay: TaskOverlay | undefined;
|
|
561
|
+
let noteOverlay: NoteOverlay | undefined;
|
|
469
562
|
|
|
470
563
|
pi.registerCommand("tasks", {
|
|
471
564
|
description: "Browse and manage Papyrus tasks (interactive)",
|
|
@@ -482,11 +575,18 @@ export default async function (pi: ExtensionAPI) {
|
|
|
482
575
|
});
|
|
483
576
|
pi.registerCommand("note", {
|
|
484
577
|
description: "Capture a deferred request directly in Papyrus",
|
|
485
|
-
handler: async (args, ctx) => {
|
|
578
|
+
handler: async (args, ctx) => {
|
|
579
|
+
await notesModule.captureNote(args, ctx);
|
|
580
|
+
await noteOverlay?.refresh();
|
|
581
|
+
},
|
|
486
582
|
});
|
|
487
583
|
pi.registerCommand("notes", {
|
|
488
584
|
description: "Browse and triage the project Notes inbox",
|
|
489
|
-
handler: async (_args, ctx) => {
|
|
585
|
+
handler: async (_args, ctx) => {
|
|
586
|
+
noteOverlay?.setProjectRoot(ctx.cwd);
|
|
587
|
+
await notesModule.showNotes(ctx);
|
|
588
|
+
await noteOverlay?.refresh();
|
|
589
|
+
},
|
|
490
590
|
});
|
|
491
591
|
pi.registerCommand("rules", {
|
|
492
592
|
description: "Browse, preview, and toggle Papyrus rules (interactive)",
|
|
@@ -580,14 +680,22 @@ export default async function (pi: ExtensionAPI) {
|
|
|
580
680
|
overlay.setSessionId(ctx.sessionManager.getSessionId());
|
|
581
681
|
await overlay.refresh();
|
|
582
682
|
overlay.startPolling(TASK_WIDGET_POLL_INTERVAL_MS);
|
|
683
|
+
|
|
684
|
+
noteOverlay ??= new NoteOverlay();
|
|
685
|
+
noteOverlay.setUI(ctx.ui);
|
|
686
|
+
noteOverlay.setProjectRoot(ctx.cwd);
|
|
687
|
+
await noteOverlay.refresh();
|
|
688
|
+
noteOverlay.startPolling(NOTE_WIDGET_POLL_INTERVAL_MS);
|
|
583
689
|
});
|
|
584
690
|
|
|
585
691
|
pi.on("session_before_compact", () => { taskContinuation.onCompaction(); });
|
|
586
|
-
pi.on("session_compact", async () => { await overlay?.refresh(); });
|
|
587
|
-
pi.on("session_tree", async () => { await overlay?.refresh(); });
|
|
692
|
+
pi.on("session_compact", async () => { await Promise.all([overlay?.refresh(), noteOverlay?.refresh()]); });
|
|
693
|
+
pi.on("session_tree", async () => { await Promise.all([overlay?.refresh(), noteOverlay?.refresh()]); });
|
|
588
694
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
589
695
|
overlay?.dispose();
|
|
590
696
|
overlay = undefined;
|
|
697
|
+
noteOverlay?.dispose();
|
|
698
|
+
noteOverlay = undefined;
|
|
591
699
|
try {
|
|
592
700
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
593
701
|
await callService("session.release", { session_id: sessionId, ...sessionSecretField(sessionId) });
|
|
@@ -597,11 +705,14 @@ export default async function (pi: ExtensionAPI) {
|
|
|
597
705
|
}
|
|
598
706
|
});
|
|
599
707
|
|
|
600
|
-
// Update
|
|
708
|
+
// Update widgets after any papyrus tool call
|
|
601
709
|
pi.on("tool_execution_end", async (event) => {
|
|
602
710
|
if (event.toolName.startsWith("papyrus_") || event.toolName === "tasks") {
|
|
603
711
|
await overlay?.refresh();
|
|
604
712
|
}
|
|
713
|
+
if (event.toolName === "notes") {
|
|
714
|
+
await noteOverlay?.refresh();
|
|
715
|
+
}
|
|
605
716
|
});
|
|
606
717
|
|
|
607
718
|
// ── Keep driving active work after Pi has exhausted built-in continuations ──
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
/** Hidden at 0, matching TaskOverlay's own "nothing open" hiding rule. */
|
|
5
|
+
export function renderNoteWidgetLines(theme: Theme, openCount: number, width: number): string[] {
|
|
6
|
+
if (openCount === 0) return [];
|
|
7
|
+
return [truncateToWidth(`${theme.fg("muted", "Notes")} ${theme.fg("accent", String(openCount))}`, width, "…")];
|
|
8
|
+
}
|
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -71,6 +71,8 @@ export const TASK_DETAIL_MIN_VISIBLE_LINES = 8;
|
|
|
71
71
|
* directly from a shell. This bounded poll is the fallback for exactly that gap.
|
|
72
72
|
*/
|
|
73
73
|
export const TASK_WIDGET_POLL_INTERVAL_MS = 20_000;
|
|
74
|
+
/** Same fallback purpose as TASK_WIDGET_POLL_INTERVAL_MS, for the Notes widget's own count. */
|
|
75
|
+
export const NOTE_WIDGET_POLL_INTERVAL_MS = 20_000;
|
|
74
76
|
export const TASK_DETAIL_MAX_VISIBLE_LINES = 24;
|
|
75
77
|
export const TASK_DETAIL_RESERVED_ROWS = 8;
|
|
76
78
|
export const TASK_DETAIL_HORIZONTAL_PAN_COLUMNS = 4;
|
package/src/domain-services.ts
CHANGED
|
@@ -121,7 +121,10 @@ function templateSubtype(artifacts: ArtifactStore, templateId: string | undefine
|
|
|
121
121
|
return typeof subtype === "string" ? subtype : undefined;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
// No default action: linkDocument's own bug (both target and source checks silently defaulting to
|
|
125
|
+
// "status" here) was exactly what let a plain reference edge to a Task trip the tasks.* lifecycle
|
|
126
|
+
// guard, which is scoped to actual status changes only. Every call site now names its real action.
|
|
127
|
+
function requireMutableDocument(document: Artifact, authority: AuthorityRegistry, action: ArtifactAction): Artifact {
|
|
125
128
|
authority.requireArtifactAllowed(document.kind, document.subtype, action, "docs");
|
|
126
129
|
return document;
|
|
127
130
|
}
|
|
@@ -191,7 +194,7 @@ export function showDocument(artifacts: ArtifactStore, id: string): Artifact {
|
|
|
191
194
|
}
|
|
192
195
|
|
|
193
196
|
export function transitionDocument(artifacts: ArtifactStore, id: string, action: DocumentTransition, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
|
|
194
|
-
const document = requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority));
|
|
197
|
+
const document = requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority, "status"));
|
|
195
198
|
const transition = DOCUMENT_TRANSITIONS[action];
|
|
196
199
|
if (!transition.from.includes(document.status)) throw new Error(`cannot ${action} document from ${document.status}`);
|
|
197
200
|
return artifacts.setStatus(id, transition.to, context)!;
|
|
@@ -215,10 +218,10 @@ export function updateDocument(artifacts: ArtifactStore, id: string, input: Upda
|
|
|
215
218
|
}
|
|
216
219
|
|
|
217
220
|
export function linkDocument(artifacts: ArtifactStore, id: string, relation: DocumentRelation, targetId: string, authority: AuthorityRegistry, context?: ArtifactEventContext): Artifact {
|
|
218
|
-
requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority));
|
|
221
|
+
requireLocallyOwnedContent(requireMutableDocument(requireDocument(artifacts, id), authority, "link"));
|
|
219
222
|
const target = artifacts.get(targetId);
|
|
220
223
|
if (!target) throw new Error(`target artifact "${targetId}" not found`);
|
|
221
|
-
requireLocallyOwnedContent(requireMutableDocument(target, authority));
|
|
224
|
+
requireLocallyOwnedContent(requireMutableDocument(target, authority, "link"));
|
|
222
225
|
artifacts.link({ from: id, relation, to: targetId }, context);
|
|
223
226
|
return showDocument(artifacts, id);
|
|
224
227
|
}
|
package/src/service.ts
CHANGED
|
@@ -179,7 +179,7 @@ function lifecycleAuthorityClaim(owner: "docs" | "rules" | "skills" | "playbooks
|
|
|
179
179
|
};
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
function createAuthorityRegistry(): AuthorityRegistry {
|
|
182
|
+
export function createAuthorityRegistry(): AuthorityRegistry {
|
|
183
183
|
const authority = new AuthorityRegistry();
|
|
184
184
|
authority.claimAll([
|
|
185
185
|
notesAuthorityClaim,
|