@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.
Files changed (46) hide show
  1. package/README.md +16 -2
  2. package/extension/src/active-task-continuation.ts +6 -0
  3. package/extension/src/domain-tools.ts +108 -52
  4. package/extension/src/index.ts +90 -37
  5. package/extension/src/notes.ts +14 -1
  6. package/extension/src/task-focus-events.ts +57 -0
  7. package/extension/src/tasks.ts +51 -15
  8. package/extension/src/tool-rendering/artifact-card.ts +117 -0
  9. package/extension/src/tool-rendering/artifact-list.ts +179 -0
  10. package/extension/src/tool-rendering/index.ts +107 -0
  11. package/extension/src/tool-rendering/render-model.ts +406 -0
  12. package/package.json +4 -2
  13. package/src/adapters/in-memory-conversation-journal-store.ts +48 -0
  14. package/src/adapters/sqlite-artifact-scope-store.ts +36 -0
  15. package/src/adapters/sqlite-artifact-store.ts +20 -11
  16. package/src/adapters/sqlite-discourse-store.ts +325 -0
  17. package/src/adapters/sqlite-graph-projection-store.ts +41 -0
  18. package/src/adapters/sqlite-task-focus-store.ts +34 -15
  19. package/src/authority-registry.ts +115 -0
  20. package/src/cli.ts +904 -124
  21. package/src/constants.ts +38 -5
  22. package/src/conversation-journal-service.ts +87 -0
  23. package/src/db.ts +336 -8
  24. package/src/domain/artifact-event.ts +99 -0
  25. package/src/domain/conversation-journal.ts +168 -0
  26. package/src/domain/discourse-store.ts +142 -0
  27. package/src/domain/graph-projection.ts +74 -0
  28. package/src/domain/task-event.ts +4 -0
  29. package/src/domain-services.ts +133 -38
  30. package/src/graph-projection-service.ts +103 -0
  31. package/src/id-migration.ts +200 -0
  32. package/src/module-registry.ts +53 -0
  33. package/src/modules/docs.ts +77 -0
  34. package/src/modules/graph-projection.ts +82 -0
  35. package/src/modules/notes.ts +76 -0
  36. package/src/modules/rules.ts +81 -0
  37. package/src/modules/skills.ts +113 -0
  38. package/src/modules/tasks.ts +164 -0
  39. package/src/ops.ts +142 -15
  40. package/src/ports/artifact-scope-store.ts +20 -0
  41. package/src/ports/artifact-store.ts +10 -5
  42. package/src/ports/conversation-journal-store.ts +17 -0
  43. package/src/ports/graph-projection-store.ts +15 -0
  44. package/src/ports/task-focus-store.ts +62 -20
  45. package/src/service.ts +218 -223
  46. package/src/task-service.ts +70 -38
@@ -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
- function text(t: string, details: Record<string, unknown> = {}) {
31
- return { content: [{ type: "text" as const, text: t }], details };
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 active = await callService<Record<string, unknown>, ActiveTaskMarker | null>("tasks.active", { project_root: ctx.cwd });
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}`, { id: a.id });
222
+ return text(`Created ${a.id} [${a.kind}|${a.status}] ${a.title}`, createArtifactDetails("artifact.create", a));
202
223
  } catch (e) {
203
- return text(`papyrus_create failed: ${e instanceof Error ? e.message : e}`);
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((r: any, i: number) => `${i + 1}. ${r.id} [${r.kind}|${r.status}] ${r.title}`);
223
- return text(`${rows.length} artifact(s):\n\n${lines.join("\n")}`, { rows });
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
- return text(`papyrus_query failed: ${e instanceof Error ? e.message : e}`);
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 update status. " +
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), tree (id bounded BFS subgraph), status (id+status lifecycle).",
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
- return text(`Linked ${params.from} --${params.relation}--> ${params.to}`);
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) return text("Missing id for tree");
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) return text(`Artifact ${root} not found`);
262
- const edges = (a as any).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((e: any) => ` ${e.from} --${e.relation}--> ${e.to}`).join("\n")}`,
266
- { edges },
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) return text(`Artifact ${params.id} not found`);
272
- return text(`Updated ${a.id} → [${a.status}]`, { artifact: a });
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
- return text(`Unknown action: ${params.action}. Use 'link', 'tree', or 'status'.`);
319
+ throw new Error(`unknown action: ${params.action}; use link, tree, status, or history`);
275
320
  } catch (e) {
276
- return text(`papyrus_graph failed: ${e instanceof Error ? e.message : e}`);
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) return text(`Artifact ${params.id} not found`);
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 ((a as any).edges?.length) {
305
- out += `\n\nEdges:\n${(a as any).edges.map((e: any) => ` ${e.from} --${e.relation}--> ${e.to}`).join("\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((g: any) => ` ${g.passed ? "✓" : "✗"} ${g.gate.type}: ${g.gate.target} — ${g.output}`).join("\n")}`;
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, { artifact: a });
358
+ return text(out, createArtifactDetails("artifact.show", a));
312
359
  } catch (e) {
313
- return text(`papyrus_show failed: ${e instanceof Error ? e.message : e}`);
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 focus = await callService<Record<string, never>, { status: string; pauseReason?: string } | null>("tasks.focused", {});
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 ?? "",
@@ -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: { project_root: ctx.cwd },
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
+ }
@@ -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
- let graph = await loadTaskGraph(ctx.cwd);
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 active = graph.nodes.find((node) => node.task.id === action.row!.id)?.active === true;
111
- const focusStatus = graph.nodes.find((node) => node.task.id === action.row!.id)?.focusStatus;
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
- const operation = choice === "Pause focus" ? "tasks.pause" : choice === "Resume focus" ? "tasks.unpause" : "tasks.clear_focus";
157
- await callService(operation, { actor: "user", source: "tasks-tui" });
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