@danypops/papyrus 0.11.3 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/extension/src/active-task-continuation.ts +6 -0
- package/extension/src/domain-tools.ts +108 -52
- package/extension/src/index.ts +90 -37
- package/extension/src/notes.ts +14 -1
- package/extension/src/task-focus-events.ts +57 -0
- package/extension/src/tasks.ts +51 -15
- package/extension/src/tool-rendering/artifact-card.ts +117 -0
- package/extension/src/tool-rendering/artifact-list.ts +179 -0
- package/extension/src/tool-rendering/index.ts +107 -0
- package/extension/src/tool-rendering/render-model.ts +406 -0
- package/package.json +4 -2
- package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
- package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
- package/src/adapters/sqlite-artifact-store.ts +20 -11
- package/src/adapters/sqlite-discourse-store.ts +325 -0
- package/src/adapters/sqlite-graph-projection-store.ts +41 -0
- package/src/adapters/sqlite-task-focus-store.ts +34 -15
- package/src/authority-registry.ts +115 -0
- package/src/cli.ts +904 -124
- package/src/constants.ts +38 -5
- package/src/conversation-journal-service.ts +87 -0
- package/src/db.ts +336 -8
- package/src/domain/artifact-event.ts +99 -0
- package/src/domain/conversation-journal.ts +168 -0
- package/src/domain/discourse-store.ts +142 -0
- package/src/domain/graph-projection.ts +74 -0
- package/src/domain/task-event.ts +4 -0
- package/src/domain-services.ts +133 -38
- package/src/graph-projection-service.ts +103 -0
- package/src/id-migration.ts +200 -0
- package/src/module-registry.ts +53 -0
- package/src/modules/docs.ts +77 -0
- package/src/modules/graph-projection.ts +82 -0
- package/src/modules/notes.ts +76 -0
- package/src/modules/rules.ts +81 -0
- package/src/modules/skills.ts +113 -0
- package/src/modules/tasks.ts +164 -0
- package/src/ops.ts +142 -15
- package/src/ports/artifact-scope-store.ts +20 -0
- package/src/ports/artifact-store.ts +10 -5
- package/src/ports/conversation-journal-store.ts +17 -0
- package/src/ports/graph-projection-store.ts +15 -0
- package/src/ports/task-focus-store.ts +62 -20
- package/src/service.ts +218 -223
- package/src/task-service.ts +70 -38
package/extension/src/index.ts
CHANGED
|
@@ -26,9 +26,19 @@ import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanI
|
|
|
26
26
|
import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
|
|
27
27
|
import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
|
|
28
28
|
import { buildContextInjection } from "./context-injection-telemetry.ts";
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
import { emitTaskFocusEvent, setTaskFocusEventBus } from "./task-focus-events.ts";
|
|
30
|
+
import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
|
|
31
|
+
import {
|
|
32
|
+
createArtifactDetails,
|
|
33
|
+
createArtifactListDetails,
|
|
34
|
+
createGraphDetails,
|
|
35
|
+
createModelContent,
|
|
36
|
+
createPreviewDetails,
|
|
37
|
+
} from "./tool-rendering/render-model.ts";
|
|
38
|
+
|
|
39
|
+
function text(value: string, details: unknown = {}) {
|
|
40
|
+
const modelContent = createModelContent(value);
|
|
41
|
+
return { content: [{ type: "text" as const, text: modelContent.text }], details };
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
// ---------------------------------------------------------------------------
|
|
@@ -58,6 +68,7 @@ class TaskOverlay {
|
|
|
58
68
|
private tui: any | undefined;
|
|
59
69
|
private snapshot: TaskGraph = { nodes: [], rootIds: [] };
|
|
60
70
|
private projectRoot: string | undefined;
|
|
71
|
+
private sessionId: string | undefined;
|
|
61
72
|
|
|
62
73
|
setUI(ctx: ExtensionUIContext): void {
|
|
63
74
|
if (ctx !== this.uiCtx) {
|
|
@@ -68,11 +79,14 @@ class TaskOverlay {
|
|
|
68
79
|
}
|
|
69
80
|
|
|
70
81
|
setProjectRoot(projectRoot: string): void { this.projectRoot = projectRoot; }
|
|
82
|
+
// Scopes the widget's "active" glyph to this Pi session's own Focus, so a second
|
|
83
|
+
// concurrent agent's focused task never shows as active in this session's widget.
|
|
84
|
+
setSessionId(sessionId: string): void { this.sessionId = sessionId; }
|
|
71
85
|
|
|
72
86
|
async refresh(): Promise<void> {
|
|
73
87
|
if (!this.projectRoot) return;
|
|
74
88
|
try {
|
|
75
|
-
this.snapshot = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 500, project_root: this.projectRoot });
|
|
89
|
+
this.snapshot = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", { limit: 500, project_root: this.projectRoot, session_id: this.sessionId });
|
|
76
90
|
} catch {
|
|
77
91
|
this.snapshot = { nodes: [], rootIds: [] };
|
|
78
92
|
}
|
|
@@ -124,6 +138,7 @@ class TaskOverlay {
|
|
|
124
138
|
this.tui = undefined;
|
|
125
139
|
this.uiCtx = undefined;
|
|
126
140
|
this.projectRoot = undefined;
|
|
141
|
+
this.sessionId = undefined;
|
|
127
142
|
}
|
|
128
143
|
}
|
|
129
144
|
|
|
@@ -132,6 +147,7 @@ class TaskOverlay {
|
|
|
132
147
|
// ---------------------------------------------------------------------------
|
|
133
148
|
|
|
134
149
|
export default async function (pi: ExtensionAPI) {
|
|
150
|
+
setTaskFocusEventBus(pi);
|
|
135
151
|
registerDomainTools(pi);
|
|
136
152
|
let contextInjectionSequence = 0;
|
|
137
153
|
const contextInjectionProducerId = randomUUID();
|
|
@@ -144,7 +160,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
144
160
|
const driveActiveTasks = async (ctx: ExtensionContext): Promise<void> => {
|
|
145
161
|
if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
|
|
146
162
|
try {
|
|
147
|
-
const
|
|
163
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
164
|
+
const active = await callService<Record<string, unknown>, ActiveTaskMarker | null>("tasks.active", { project_root: ctx.cwd, session_id: sessionId });
|
|
148
165
|
const decision = taskContinuation.evaluate(active, {
|
|
149
166
|
idle: ctx.isIdle(),
|
|
150
167
|
pendingMessages: ctx.hasPendingMessages(),
|
|
@@ -156,11 +173,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
156
173
|
display: false,
|
|
157
174
|
}, { triggerTurn: true, deliverAs: "nextTurn" });
|
|
158
175
|
} else if (decision.action === "pause") {
|
|
159
|
-
await callService("tasks.pause", {
|
|
176
|
+
const paused = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>("tasks.pause", {
|
|
160
177
|
actor: "system",
|
|
161
178
|
source: "task-continuation",
|
|
162
179
|
reason: automaticPauseReason(decision.reason),
|
|
180
|
+
session_id: sessionId,
|
|
163
181
|
});
|
|
182
|
+
emitTaskFocusEvent({ taskId: paused.artifact.id, sessionId, status: "paused" });
|
|
164
183
|
if (ctx.hasUI) ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Human input resumes it automatically.`, "warning");
|
|
165
184
|
}
|
|
166
185
|
} catch {
|
|
@@ -192,15 +211,17 @@ export default async function (pi: ExtensionAPI) {
|
|
|
192
211
|
template_id: Type.Optional(Type.String({ description: "skill/artifact-template id whose defaults and requirements apply" })),
|
|
193
212
|
project_root: Type.Optional(Type.String({ description: "required for Tasks; defaults to Pi cwd" })),
|
|
194
213
|
}),
|
|
214
|
+
renderCall(args, theme) { return renderPapyrusToolCall("Create artifact", args, theme); },
|
|
215
|
+
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
195
216
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
196
217
|
try {
|
|
197
218
|
const a = await callService<Record<string, unknown>, Artifact>("artifact.create", {
|
|
198
219
|
...params,
|
|
199
220
|
...(params.kind === "task" ? { project_root: params.project_root ?? ctx.cwd } : {}),
|
|
200
221
|
});
|
|
201
|
-
return text(`Created ${a.id} [${a.kind}|${a.status}] ${a.title}`,
|
|
222
|
+
return text(`Created ${a.id} [${a.kind}|${a.status}] ${a.title}`, createArtifactDetails("artifact.create", a));
|
|
202
223
|
} catch (e) {
|
|
203
|
-
|
|
224
|
+
throw new Error(`papyrus_create failed: ${e instanceof Error ? e.message : e}`);
|
|
204
225
|
}
|
|
205
226
|
},
|
|
206
227
|
});
|
|
@@ -215,14 +236,16 @@ export default async function (pi: ExtensionAPI) {
|
|
|
215
236
|
text: Type.Optional(Type.String({ description: "substring across title and body" })),
|
|
216
237
|
limit: Type.Optional(Type.Number()),
|
|
217
238
|
}),
|
|
239
|
+
renderCall(args, theme) { return renderPapyrusToolCall("Query artifacts", args, theme); },
|
|
240
|
+
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
218
241
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
219
242
|
try {
|
|
220
243
|
const rows = await callService<Record<string, unknown>, Artifact[]>("artifact.query", { ...params, limit: params.limit ?? 50 });
|
|
221
|
-
if (rows.length === 0) return text("No artifacts found.");
|
|
222
|
-
const lines = rows.map((
|
|
223
|
-
return text(`${rows.length} artifact(s):\n\n${lines.join("\n")}`,
|
|
244
|
+
if (rows.length === 0) return text("No artifacts found.", createArtifactListDetails("artifact.query", rows));
|
|
245
|
+
const lines = rows.map((row, index) => `${index + 1}. ${row.id} [${row.kind}|${row.status}] ${row.title}`);
|
|
246
|
+
return text(`${rows.length} artifact(s):\n\n${lines.join("\n")}`, createArtifactListDetails("artifact.query", rows));
|
|
224
247
|
} catch (e) {
|
|
225
|
-
|
|
248
|
+
throw new Error(`papyrus_query failed: ${e instanceof Error ? e.message : e}`);
|
|
226
249
|
}
|
|
227
250
|
},
|
|
228
251
|
});
|
|
@@ -231,11 +254,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
231
254
|
name: "papyrus_graph",
|
|
232
255
|
label: "Papyrus Graph",
|
|
233
256
|
description:
|
|
234
|
-
"Link artifacts with typed edges (any kind → any kind), view subgraph, or
|
|
257
|
+
"Link artifacts with typed edges (any kind → any kind), view subgraph, update status, or read the mutation event log. " +
|
|
235
258
|
"RELATIONS: references, implements, follows, depends_on, documents, blocks, supersedes, relates_to, gates, triggers, contains, part_of. " +
|
|
236
|
-
"ACTIONS: link (from+relation+to),
|
|
259
|
+
"ACTIONS: link (from+relation+to), unlink (from+relation+to — idempotent, no error if already absent; for Task depends_on/contains prefer the tasks tool's undepend/uncontain), " +
|
|
260
|
+
"tree (id → bounded BFS subgraph), status (id+status → lifecycle), " +
|
|
261
|
+
"history (who did what, when — requires id, actor, or session_id).",
|
|
237
262
|
parameters: Type.Object({
|
|
238
|
-
action: Type.String({ description: "link | tree | status" }),
|
|
263
|
+
action: Type.String({ description: "link | unlink | tree | status | history" }),
|
|
239
264
|
from: Type.Optional(Type.String()),
|
|
240
265
|
relation: Type.Optional(Type.String()),
|
|
241
266
|
to: Type.Optional(Type.String()),
|
|
@@ -243,37 +268,57 @@ export default async function (pi: ExtensionAPI) {
|
|
|
243
268
|
status: Type.Optional(Type.String()),
|
|
244
269
|
depth: Type.Optional(Type.Number({ description: "tree traversal depth; bounded by a hard ceiling" })),
|
|
245
270
|
max_nodes: Type.Optional(Type.Number({ description: "tree node cap; bounded by a hard ceiling" })),
|
|
271
|
+
actor: Type.Optional(Type.String({ description: "history: filter by actor" })),
|
|
272
|
+
session_id: Type.Optional(Type.String({ description: "history: filter by session" })),
|
|
273
|
+
since: Type.Optional(Type.String({ description: "history: RFC3339 lower bound" })),
|
|
274
|
+
limit: Type.Optional(Type.Number({ description: "history: bounded page size" })),
|
|
246
275
|
}),
|
|
276
|
+
renderCall(args, theme) { return renderPapyrusToolCall("Artifact graph", args, theme); },
|
|
277
|
+
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
247
278
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
248
279
|
try {
|
|
249
280
|
if (params.action === "link") {
|
|
250
281
|
await callService("graph.link", { from: params.from!, relation: params.relation!, to: params.to! });
|
|
251
|
-
|
|
282
|
+
const output = `Linked ${params.from} --${params.relation}--> ${params.to}`;
|
|
283
|
+
return text(output, createPreviewDetails("graph.link", "Artifact relationship", output));
|
|
284
|
+
}
|
|
285
|
+
if (params.action === "unlink") {
|
|
286
|
+
const result = await callService<Record<string, unknown>, { removed: boolean }>("graph.unlink", { from: params.from!, relation: params.relation!, to: params.to! });
|
|
287
|
+
const output = result.removed ? `Unlinked ${params.from} --${params.relation}--> ${params.to}` : `No such relationship: ${params.from} --${params.relation}--> ${params.to}`;
|
|
288
|
+
return text(output, createPreviewDetails("graph.unlink", "Artifact relationship", output));
|
|
252
289
|
}
|
|
253
290
|
if (params.action === "tree") {
|
|
254
291
|
const root = params.id ?? params.from;
|
|
255
|
-
if (!root)
|
|
292
|
+
if (!root) throw new Error("missing id for tree");
|
|
256
293
|
const a = await callService<Record<string, unknown>, Artifact | null>("graph.tree", {
|
|
257
294
|
id: root,
|
|
258
295
|
depth: params.depth,
|
|
259
296
|
max_nodes: params.max_nodes,
|
|
260
297
|
});
|
|
261
|
-
if (!a)
|
|
262
|
-
const edges =
|
|
263
|
-
if (edges.length === 0) return text(`${a.title} — no edges
|
|
298
|
+
if (!a) throw new Error(`artifact ${root} not found`);
|
|
299
|
+
const edges = a.edges ?? [];
|
|
300
|
+
if (edges.length === 0) return text(`${a.title} — no edges`, createGraphDetails("graph.tree", [a], []));
|
|
264
301
|
return text(
|
|
265
|
-
`Subgraph from ${a.title} (${edges.length} edges):\n\n${edges.map((
|
|
266
|
-
|
|
302
|
+
`Subgraph from ${a.title} (${edges.length} edges):\n\n${edges.map((edge: any) => ` ${edge.from} --${edge.relation}--> ${edge.to}`).join("\n")}`,
|
|
303
|
+
createGraphDetails("graph.tree", [a], edges),
|
|
267
304
|
);
|
|
268
305
|
}
|
|
269
306
|
if (params.action === "status") {
|
|
270
307
|
const a = await callService<Record<string, unknown>, Artifact | null>("graph.status", { id: params.id!, status: params.status! });
|
|
271
|
-
if (!a)
|
|
272
|
-
return text(`Updated ${a.id} → [${a.status}]`,
|
|
308
|
+
if (!a) throw new Error(`artifact ${params.id} not found`);
|
|
309
|
+
return text(`Updated ${a.id} → [${a.status}]`, createArtifactDetails("graph.status", a));
|
|
310
|
+
}
|
|
311
|
+
if (params.action === "history") {
|
|
312
|
+
const page = await callService<Record<string, unknown>, { events: Array<Record<string, unknown>> }>("graph.history", {
|
|
313
|
+
id: params.id, actor: params.actor, session_id: params.session_id, since: params.since, limit: params.limit,
|
|
314
|
+
});
|
|
315
|
+
if (page.events.length === 0) return text("No recorded events.", createPreviewDetails("graph.history", "Mutation event log", "No recorded events."));
|
|
316
|
+
const output = page.events.map((event) => `${event["occurredAt"]} ${event["artifactId"]} ${event["type"]} · ${event["actor"]}/${event["source"]}`).join("\n");
|
|
317
|
+
return text(output, createPreviewDetails("graph.history", "Mutation event log", output));
|
|
273
318
|
}
|
|
274
|
-
|
|
319
|
+
throw new Error(`unknown action: ${params.action}; use link, tree, status, or history`);
|
|
275
320
|
} catch (e) {
|
|
276
|
-
|
|
321
|
+
throw new Error(`papyrus_graph failed: ${e instanceof Error ? e.message : e}`);
|
|
277
322
|
}
|
|
278
323
|
},
|
|
279
324
|
});
|
|
@@ -288,6 +333,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
288
333
|
depth: Type.Optional(Type.Number({ description: "edge traversal depth" })),
|
|
289
334
|
max_nodes: Type.Optional(Type.Number({ description: "maximum traversed nodes" })),
|
|
290
335
|
}),
|
|
336
|
+
renderCall(args, theme) { return renderPapyrusToolCall("Show artifact", args, theme); },
|
|
337
|
+
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
291
338
|
async execute(_id, params, _signal, _onUpdate, _ctx) {
|
|
292
339
|
try {
|
|
293
340
|
const a = await callService<Record<string, unknown>, Artifact | null>("artifact.show", {
|
|
@@ -296,21 +343,21 @@ export default async function (pi: ExtensionAPI) {
|
|
|
296
343
|
depth: params.depth,
|
|
297
344
|
max_nodes: params.max_nodes,
|
|
298
345
|
});
|
|
299
|
-
if (!a)
|
|
346
|
+
if (!a) throw new Error(`artifact ${params.id} not found`);
|
|
300
347
|
let out = `${a.id} [${a.kind}|${a.status}]\n${a.title}\n\n${a.body}`;
|
|
301
348
|
if (Object.keys(a.extra).length > 0) {
|
|
302
349
|
out += `\n\nMetadata:\n${formatMetadata(a.extra).map((line) => ` ${line}`).join("\n")}`;
|
|
303
350
|
}
|
|
304
|
-
if (
|
|
305
|
-
out += `\n\nEdges:\n${
|
|
351
|
+
if (a.edges?.length) {
|
|
352
|
+
out += `\n\nEdges:\n${a.edges.map((edge) => ` ${edge.from} --${edge.relation}--> ${edge.to}`).join("\n")}`;
|
|
306
353
|
}
|
|
307
354
|
if (params.run_gates) {
|
|
308
355
|
const results = await callService<Record<string, unknown>, GateResult[]>("gates.run", { id: params.id });
|
|
309
|
-
out += `\n\nGates:\n${results.map((
|
|
356
|
+
out += `\n\nGates:\n${results.map((gate) => ` ${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n")}`;
|
|
310
357
|
}
|
|
311
|
-
return text(out,
|
|
358
|
+
return text(out, createArtifactDetails("artifact.show", a));
|
|
312
359
|
} catch (e) {
|
|
313
|
-
|
|
360
|
+
throw new Error(`papyrus_show failed: ${e instanceof Error ? e.message : e}`);
|
|
314
361
|
}
|
|
315
362
|
},
|
|
316
363
|
});
|
|
@@ -331,6 +378,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
331
378
|
description: "Browse and manage Papyrus tasks (interactive)",
|
|
332
379
|
handler: async (_args, ctx) => {
|
|
333
380
|
overlay?.setProjectRoot(ctx.cwd);
|
|
381
|
+
overlay?.setSessionId(ctx.sessionManager.getSessionId());
|
|
334
382
|
await tasksModule.showTasks(ctx);
|
|
335
383
|
await overlay?.refresh();
|
|
336
384
|
},
|
|
@@ -363,9 +411,11 @@ export default async function (pi: ExtensionAPI) {
|
|
|
363
411
|
overlay ??= new TaskOverlay();
|
|
364
412
|
overlay.setUI(ctx.ui);
|
|
365
413
|
overlay.setProjectRoot(ctx.cwd);
|
|
414
|
+
overlay.setSessionId(ctx.sessionManager.getSessionId());
|
|
366
415
|
await overlay.refresh();
|
|
367
416
|
});
|
|
368
417
|
|
|
418
|
+
pi.on("session_before_compact", () => { taskContinuation.onCompaction(); });
|
|
369
419
|
pi.on("session_compact", async () => { await overlay?.refresh(); });
|
|
370
420
|
pi.on("session_tree", async () => { await overlay?.refresh(); });
|
|
371
421
|
pi.on("session_shutdown", async () => { overlay?.dispose(); overlay = undefined; });
|
|
@@ -381,13 +431,15 @@ export default async function (pi: ExtensionAPI) {
|
|
|
381
431
|
// agent_settled is intentionally later than agent_end: Pi guarantees that
|
|
382
432
|
// retry, compaction retry, and queued follow-up processing have finished.
|
|
383
433
|
|
|
384
|
-
pi.on("input", async (event) => {
|
|
434
|
+
pi.on("input", async (event, ctx) => {
|
|
385
435
|
if (event.source === "extension") return;
|
|
386
436
|
taskContinuation.onHumanInput();
|
|
387
437
|
try {
|
|
388
|
-
const
|
|
438
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
439
|
+
const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string; pauseReason?: string } | null>("tasks.focused", { session_id: sessionId });
|
|
389
440
|
if (focus && shouldResumeFocusOnHumanInput(focus.status, focus.pauseReason)) {
|
|
390
|
-
await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation" });
|
|
441
|
+
await callService("tasks.unpause", { actor: "system", source: "task-continuation", reason: "human input resumed automatic task continuation", session_id: sessionId });
|
|
442
|
+
emitTaskFocusEvent({ taskId: focus.artifact.id, sessionId, status: "unpaused" });
|
|
391
443
|
}
|
|
392
444
|
} catch {
|
|
393
445
|
// The daemon may be unavailable during startup, reload, or shutdown.
|
|
@@ -402,9 +454,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
402
454
|
|
|
403
455
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
404
456
|
try {
|
|
457
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
405
458
|
const [rules, summary] = await Promise.all([
|
|
406
|
-
callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd }),
|
|
407
|
-
callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd }),
|
|
459
|
+
callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd, session_id: sessionId }),
|
|
460
|
+
callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd, session_id: sessionId }),
|
|
408
461
|
]);
|
|
409
462
|
const injection = buildContextInjection({
|
|
410
463
|
basePrompt: event.systemPrompt ?? "",
|
package/extension/src/notes.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { NOTE_LIST_MAX_LIMIT } from "../../src/constants.ts";
|
|
2
3
|
import { NOTE_DISPOSITIONS } from "../../src/note-service.ts";
|
|
3
4
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
4
5
|
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
@@ -17,6 +18,18 @@ export function noteCaptureInput(request: string, projectRoot: string): Record<s
|
|
|
17
18
|
return { body, project_root: projectRoot, actor: "human", source: "note-command" };
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The generic artifact browser (extension/src/artifact-browser.ts) requests a fixed 500-row
|
|
23
|
+
* page by default, but notes.list enforces its own tighter NOTE_LIST_MAX_LIMIT (200) — an
|
|
24
|
+
* unqualified /notes call exceeded that bound and the browser surfaced the daemon's rejection
|
|
25
|
+
* as an opaque extension error instead of ever rendering. Passing an explicit limit here that
|
|
26
|
+
* respects the Notes-specific bound is the fix; the generic browser's default stays as-is
|
|
27
|
+
* since no other kind's list operation has a bound below 500.
|
|
28
|
+
*/
|
|
29
|
+
export function noteListInput(projectRoot: string): Record<string, unknown> {
|
|
30
|
+
return { project_root: projectRoot, limit: NOTE_LIST_MAX_LIMIT };
|
|
31
|
+
}
|
|
32
|
+
|
|
20
33
|
export async function captureNote(request: string, ctx: ExtensionCommandContext): Promise<Artifact | null> {
|
|
21
34
|
const input = noteCaptureInput(request, ctx.cwd);
|
|
22
35
|
if (!input) {
|
|
@@ -38,7 +51,7 @@ export async function showNotes(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
38
51
|
kind: "note",
|
|
39
52
|
title: "Notes inbox",
|
|
40
53
|
listOperation: "notes.list",
|
|
41
|
-
listInput:
|
|
54
|
+
listInput: noteListInput(ctx.cwd),
|
|
42
55
|
statusOrder: ["draft", "active", "archived"],
|
|
43
56
|
glyphs: NOTE_GLYPHS,
|
|
44
57
|
rowMeta: noteRowMeta,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { PAPYRUS_TASK_FOCUS_CHANNEL, PAPYRUS_TASK_FOCUS_SCHEMA } from "../../src/constants.ts";
|
|
3
|
+
|
|
4
|
+
export type TaskFocusStatus = "focused" | "paused" | "unpaused" | "cleared";
|
|
5
|
+
|
|
6
|
+
export interface TaskFocusEvent {
|
|
7
|
+
schema: typeof PAPYRUS_TASK_FOCUS_SCHEMA;
|
|
8
|
+
taskId: string | null;
|
|
9
|
+
sessionId?: string;
|
|
10
|
+
status: TaskFocusStatus;
|
|
11
|
+
observedAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface TaskFocusEventInput {
|
|
15
|
+
taskId: string | null;
|
|
16
|
+
sessionId?: string;
|
|
17
|
+
status: TaskFocusStatus;
|
|
18
|
+
observedAt?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pure event builder, mirroring buildContextInjection's shape: no task title, body, or any other
|
|
23
|
+
* artifact content -- only the id, session, lifecycle status, and timestamp, which are already
|
|
24
|
+
* public metadata a caller with the id could look up directly. This is the payload emitted on
|
|
25
|
+
* papyrus.task-focus.v1, the analogue of papyrus.context-injection.v1, so extensions such as a
|
|
26
|
+
* token-cost router can correlate their own telemetry with the currently focused task without
|
|
27
|
+
* Papyrus depending on them.
|
|
28
|
+
*/
|
|
29
|
+
export function buildTaskFocusEvent(input: TaskFocusEventInput): TaskFocusEvent {
|
|
30
|
+
if (input.status !== "cleared" && input.taskId === null) throw new Error(`task-focus event of status "${input.status}" requires a taskId`);
|
|
31
|
+
return {
|
|
32
|
+
schema: PAPYRUS_TASK_FOCUS_SCHEMA,
|
|
33
|
+
taskId: input.taskId,
|
|
34
|
+
status: input.status,
|
|
35
|
+
observedAt: input.observedAt ?? Date.now(),
|
|
36
|
+
...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type EventBusHost = Pick<ExtensionAPI, "events">;
|
|
41
|
+
|
|
42
|
+
let bus: EventBusHost | undefined;
|
|
43
|
+
|
|
44
|
+
/** Call once from the extension entry point so call sites that only receive `ctx` (not `pi`) can still emit. */
|
|
45
|
+
export function setTaskFocusEventBus(host: EventBusHost): void {
|
|
46
|
+
bus = host;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resetTaskFocusEventBusForTests(): void {
|
|
50
|
+
bus = undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Best-effort broadcast: never throws, since a missing bus (e.g. an uninitialized test harness) must not break the focus operation it accompanies. */
|
|
54
|
+
export function emitTaskFocusEvent(input: TaskFocusEventInput): void {
|
|
55
|
+
if (!bus) return;
|
|
56
|
+
bus.events.emit(PAPYRUS_TASK_FOCUS_CHANNEL, buildTaskFocusEvent(input));
|
|
57
|
+
}
|
package/extension/src/tasks.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
|
7
7
|
import { DynamicBorder, rawKeyHint } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { Container, Input, Spacer, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
9
9
|
import { callService } from "./service-client.ts";
|
|
10
|
+
import { emitTaskFocusEvent } from "./task-focus-events.ts";
|
|
10
11
|
import { showTaskDetails } from "./task-detail-view.ts";
|
|
11
12
|
import { showTaskGraph } from "./task-graph.ts";
|
|
12
13
|
|
|
@@ -56,10 +57,11 @@ export function buildTaskHierarchy(graph: TaskGraph): TaskHierarchyRow[] {
|
|
|
56
57
|
return result;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
async function loadTaskGraph(projectRoot: string, scope?: "project" | "graph" | "all", rootTaskId?: string): Promise<TaskGraph> {
|
|
60
|
+
async function loadTaskGraph(projectRoot: string, sessionId: string, scope?: "project" | "graph" | "all", rootTaskId?: string): Promise<TaskGraph> {
|
|
60
61
|
return callService<Record<string, unknown>, TaskGraph>("tasks.graph", {
|
|
61
62
|
limit: 200,
|
|
62
63
|
project_root: projectRoot,
|
|
64
|
+
session_id: sessionId,
|
|
63
65
|
...(scope ? { scope } : {}),
|
|
64
66
|
...(rootTaskId ? { root_task_id: rootTaskId } : {}),
|
|
65
67
|
});
|
|
@@ -70,14 +72,17 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
70
72
|
ctx.ui.notify("/tasks requires interactive mode", "warning");
|
|
71
73
|
return;
|
|
72
74
|
}
|
|
73
|
-
|
|
75
|
+
// Scopes this panel's "active"/Focus reads and writes to this Pi session, so a second
|
|
76
|
+
// concurrent agent working the same project never appears as (or is overridden by) this one.
|
|
77
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
78
|
+
let graph = await loadTaskGraph(ctx.cwd, sessionId);
|
|
74
79
|
if (graph.nodes.length === 0) {
|
|
75
80
|
const create = await ctx.ui.select("No tasks yet", ["Create a task", "Cancel"]);
|
|
76
81
|
if (create === "Create a task") {
|
|
77
82
|
const title = await ctx.ui.input("Task title:", "");
|
|
78
83
|
if (title) {
|
|
79
|
-
await callService("tasks.create", { title, project_root: ctx.cwd, actor: "user", source: "tasks-tui" });
|
|
80
|
-
graph = await loadTaskGraph(ctx.cwd);
|
|
84
|
+
await callService("tasks.create", { title, project_root: ctx.cwd, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
85
|
+
graph = await loadTaskGraph(ctx.cwd, sessionId);
|
|
81
86
|
}
|
|
82
87
|
}
|
|
83
88
|
if (graph.nodes.length === 0) return;
|
|
@@ -86,14 +91,14 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
86
91
|
for (;;) {
|
|
87
92
|
const action = await renderPanel(ctx, graph);
|
|
88
93
|
if (!action) return;
|
|
89
|
-
if (action.type === "refresh") { graph = await loadTaskGraph(ctx.cwd); continue; }
|
|
94
|
+
if (action.type === "refresh") { graph = await loadTaskGraph(ctx.cwd, sessionId); continue; }
|
|
90
95
|
if (action.type === "scope") {
|
|
91
96
|
const choice = await ctx.ui.select("Task scope", ["Current project", "Focused graph", "All projects"]);
|
|
92
97
|
if (!choice) continue;
|
|
93
98
|
const scope: "project" | "graph" | "all" = choice === "Current project" ? "project" : choice === "All projects" ? "all" : "graph";
|
|
94
99
|
let rootTaskId: string | undefined;
|
|
95
100
|
if (scope === "graph") {
|
|
96
|
-
const projectGraph = await loadTaskGraph(ctx.cwd, "project");
|
|
101
|
+
const projectGraph = await loadTaskGraph(ctx.cwd, sessionId, "project");
|
|
97
102
|
const roots = projectGraph.rootIds.map((id) => projectGraph.nodes.find((node) => node.task.id === id)?.task).filter((task): task is Artifact => task !== undefined);
|
|
98
103
|
const selected = await ctx.ui.select("Focused root or epic", roots.map((task) => `${task.title} · ${task.id}`));
|
|
99
104
|
if (!selected) continue;
|
|
@@ -101,25 +106,49 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
101
106
|
if (!rootTaskId) continue;
|
|
102
107
|
}
|
|
103
108
|
await callService("tasks.set_scope", { project_root: ctx.cwd, scope, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) });
|
|
104
|
-
graph = await loadTaskGraph(ctx.cwd);
|
|
109
|
+
graph = await loadTaskGraph(ctx.cwd, sessionId);
|
|
105
110
|
continue;
|
|
106
111
|
}
|
|
107
112
|
if (action.type === "graph") { await showTaskGraph(ctx, graph); continue; }
|
|
108
113
|
if (action.type !== "action" || !action.row) continue;
|
|
109
114
|
|
|
110
|
-
const
|
|
111
|
-
const
|
|
115
|
+
const node = graph.nodes.find((entry) => entry.task.id === action.row!.id);
|
|
116
|
+
const active = node?.active === true;
|
|
117
|
+
const focusStatus = node?.focusStatus;
|
|
112
118
|
const choices = [
|
|
113
119
|
"Show details",
|
|
114
120
|
"Edit task",
|
|
115
121
|
...(!active && action.row.status !== "done" && action.row.status !== "canceled" ? ["Make active"] : []),
|
|
116
122
|
...(active ? [focusStatus === "paused" ? "Resume focus" : "Pause focus", "Clear focus"] : []),
|
|
117
123
|
...(action.row.status === "review" ? ["Run gates"] : []),
|
|
124
|
+
...((node?.dependencyIds.length ?? 0) > 0 ? ["Remove dependency"] : []),
|
|
125
|
+
...((node?.parentIds.length ?? 0) > 0 ? ["Remove from parent"] : []),
|
|
118
126
|
...(STATUS_ACTIONS[action.row.status] ?? []),
|
|
119
127
|
];
|
|
120
128
|
const choice = await ctx.ui.select(action.row.title, choices);
|
|
121
129
|
if (!choice) continue;
|
|
122
130
|
|
|
131
|
+
if (choice === "Remove dependency" || choice === "Remove from parent") {
|
|
132
|
+
const relatedIds = choice === "Remove dependency" ? node!.dependencyIds : node!.parentIds;
|
|
133
|
+
const relatedTitles = relatedIds.map((relatedId) => `${graph.nodes.find((entry) => entry.task.id === relatedId)?.task.title ?? relatedId} · ${relatedId}`);
|
|
134
|
+
const selected = await ctx.ui.select(choice === "Remove dependency" ? "Remove which dependency?" : "Remove from which parent?", relatedTitles);
|
|
135
|
+
if (!selected) continue;
|
|
136
|
+
const relatedId = relatedIds[relatedTitles.indexOf(selected)]!;
|
|
137
|
+
try {
|
|
138
|
+
if (choice === "Remove dependency") {
|
|
139
|
+
await callService("tasks.undepend", { id: action.row.id, dependency_id: relatedId, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
140
|
+
ctx.ui.notify(`Removed dependency on ${relatedId}`, "info");
|
|
141
|
+
} else {
|
|
142
|
+
await callService("tasks.uncontain", { parent_id: relatedId, child_id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
143
|
+
ctx.ui.notify(`Removed from parent ${relatedId}`, "info");
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
ctx.ui.notify(`Relationship removal failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
147
|
+
}
|
|
148
|
+
graph = await loadTaskGraph(ctx.cwd, sessionId);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
123
152
|
if (choice === "Show details") {
|
|
124
153
|
const art = await callService<Record<string, unknown>, Artifact | null>("tasks.show", { id: action.row.id });
|
|
125
154
|
if (!art) { ctx.ui.notify("Not found", "error"); continue; }
|
|
@@ -146,15 +175,22 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
146
175
|
}
|
|
147
176
|
} else if (choice === "Make active") {
|
|
148
177
|
try {
|
|
149
|
-
await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id, actor: "user", source: "tasks-tui" });
|
|
178
|
+
const focused = await callService<Record<string, unknown>, Artifact>("tasks.focus", { id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
179
|
+
emitTaskFocusEvent({ taskId: focused.id, sessionId, status: "focused" });
|
|
150
180
|
ctx.ui.notify(`Active: ${action.row.title}`, "info");
|
|
151
181
|
} catch (error) {
|
|
152
182
|
ctx.ui.notify(`Focus failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
153
183
|
}
|
|
154
184
|
} else if (choice === "Pause focus" || choice === "Resume focus" || choice === "Clear focus") {
|
|
155
185
|
try {
|
|
156
|
-
|
|
157
|
-
|
|
186
|
+
if (choice === "Clear focus") {
|
|
187
|
+
await callService("tasks.clear_focus", { actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
188
|
+
emitTaskFocusEvent({ taskId: null, sessionId, status: "cleared" });
|
|
189
|
+
} else {
|
|
190
|
+
const operation = choice === "Pause focus" ? "tasks.pause" : "tasks.unpause";
|
|
191
|
+
const result = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, { actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
192
|
+
emitTaskFocusEvent({ taskId: result.artifact.id, sessionId, status: choice === "Pause focus" ? "paused" : "unpaused" });
|
|
193
|
+
}
|
|
158
194
|
ctx.ui.notify(choice === "Clear focus" ? "Task focus cleared" : choice, "info");
|
|
159
195
|
} catch (error) {
|
|
160
196
|
ctx.ui.notify(`Focus action failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
@@ -180,7 +216,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
180
216
|
? "tasks.cancel"
|
|
181
217
|
: "tasks.complete";
|
|
182
218
|
if (operation === "tasks.complete") {
|
|
183
|
-
const result = await callService<Record<string, unknown>, TaskCompletion>(operation, { id: action.row.id, actor: "user", source: "tasks-tui" });
|
|
219
|
+
const result = await callService<Record<string, unknown>, TaskCompletion>(operation, { id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
184
220
|
action.row.status = result.artifact.status;
|
|
185
221
|
const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target}`).join("\n");
|
|
186
222
|
const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}`).join("\n");
|
|
@@ -195,7 +231,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
195
231
|
result.completed ? "info" : "warning",
|
|
196
232
|
);
|
|
197
233
|
} else {
|
|
198
|
-
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: action.row.id, actor: "user", source: "tasks-tui" });
|
|
234
|
+
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
199
235
|
action.row.status = updated.status;
|
|
200
236
|
ctx.ui.notify(`${updated.id} → [${updated.status}]`, "info");
|
|
201
237
|
}
|
|
@@ -203,7 +239,7 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
203
239
|
ctx.ui.notify(`Task action failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
204
240
|
}
|
|
205
241
|
}
|
|
206
|
-
graph = await loadTaskGraph(ctx.cwd);
|
|
242
|
+
graph = await loadTaskGraph(ctx.cwd, sessionId);
|
|
207
243
|
}
|
|
208
244
|
}
|
|
209
245
|
|