@danypops/pi-papyrus 0.41.0 → 0.42.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 CHANGED
@@ -10,9 +10,9 @@ The `papyrus_*` tools are the low-level graph-store API:
10
10
  - **`papyrus_graph`** — link artifacts, perform bounded traversal, or read the mutation event log
11
11
  - **`papyrus_show`** — read nested metadata and bounded edges, optionally running gates
12
12
 
13
- Agent-facing domain tools own lifecycle invariants and sit above this store API. `tasks` and `discuss` are still single tools with an `action` parameter; `notes`, `docs`, `rules`, `skills`, and `playbooks` are projected from Papyrus's own Vehicle as one real tool per operation (`notes_capture`, `rules_create`, `skills_run`, `playbooks_invoke`, and so on) -- no `action` dispatch, each with its own schema:
13
+ Agent-facing domain tools own lifecycle invariants and sit above this store API. `discuss` is still a single tool with an `action` parameter -- `live:true` needs an interactive UI round-trip a stateless Vehicle operation can't express. `notes`, `docs`, `rules`, `skills`, `playbooks`, and `tasks` are projected from Papyrus's own Vehicle as one real tool per operation (`notes_capture`, `rules_create`, `skills_run`, `playbooks_invoke`, `tasks_complete`, and so on) -- no `action` dispatch, each with its own schema:
14
14
 
15
- - **`tasks`**create/update/list/show/plan, manage the singleton active focus, replace evidence-bearing checklists, hierarchy/dependencies, lifecycle transitions, non-blocking gates, and review completion that focuses one deterministic ready successor without claiming effort
15
+ - **tasks** (`tasks_create`, `tasks_update`, `tasks_list`, `tasks_show`, `tasks_plan`, `tasks_graph`, `tasks_focus`, `tasks_pause`, `tasks_unpause`, `tasks_clear_focus`, `tasks_start`, `tasks_submit`, `tasks_complete`, `tasks_reject`, `tasks_retry`, `tasks_cancel`, `tasks_cancel_subtree`, `tasks_run_gates`, `tasks_set_checklist`, `tasks_set_gates`, `tasks_depend`, `tasks_undepend`, `tasks_contain`, `tasks_uncontain`, `tasks_claim`, `tasks_heartbeat_lease`, `tasks_release_lease`, `tasks_lease`, `tasks_context`, `tasks_event_feed`, `tasks_scope`, `tasks_set_scope`, `tasks_assign_project`, `tasks_active`, `tasks_focused`, `tasks_history`) manages the singleton active focus, evidence-bearing checklists, hierarchy/dependencies, lifecycle transitions, non-blocking gates, and review completion that focuses one deterministic ready successor without claiming effort. `project_root` is required wherever a plain `tasks` call would otherwise need one (list/graph/plan/active/focused/scope/context/create) -- there is no ambient Pi cwd server-side. `tasks_focus`/`tasks_pause`/`tasks_unpause`/`tasks_clear_focus` still authorize their write via this session's own cached secret, and still broadcast `papyrus.task-focus.v1` on Pi's own event bus for a sibling extension to observe, exactly as before
16
16
  - **notes** (`notes_capture`, `notes_list`, `notes_show`, `notes_consume`, `notes_promote`, `notes_archive`) — capture/list/show deferred human intent, mark it consumed, promote it to an existing Task/Doc/Rule/Skill, or archive it with an explicit disposition
17
17
  - **docs** (`docs_create`, `docs_list`, `docs_show`, `docs_activate`, `docs_archive`, `docs_reopen`, `docs_link`, `docs_assign_project`, `docs_update`) — activate/archive/reopen and document-safe graph links; Note mutations remain behind the Notes facade
18
18
  - **rules** (`rules_create`, `rules_list`, `rules_show`, `rules_preview`, `rules_enable`, `rules_disable`, `rules_gate`, `rules_assign_project`, `rules_update`) — enable/disable and attach governance gates to tasks
@@ -1,31 +1,18 @@
1
1
  import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
  import {
4
- PROOF_TYPES,
5
4
  readDiscussionExtra,
6
5
  type Artifact,
7
6
  type DiscussionAndRounds,
8
7
  type DiscussionRound,
9
- type GateResult,
10
8
  type OperationName,
11
- type TaskCompletion,
12
- type TaskExecutionPlan,
13
- type TaskGraph,
14
- type TaskHistoryPage,
15
- type TaskLease,
16
- type TaskViewSelection,
17
9
  } from "@danypops/papyrus";
18
10
  import { askQuestion } from "./discuss-ask-view.ts";
19
- import { emitTaskFocusEvent } from "./task-focus-events.ts";
20
- import { sessionSecretField } from "./session-identity.ts";
21
11
  import { callService } from "./service-client.ts";
22
12
  import { renderPapyrusToolCall, renderPapyrusToolResult } from "./tool-rendering/index.ts";
23
13
  import {
24
14
  createArtifactDetails,
25
15
  createArtifactListDetails,
26
- createGateRunDetails,
27
- createGraphDetails,
28
- createInvocationDetails,
29
16
  createModelContent,
30
17
  createPreviewDetails,
31
18
  } from "./tool-rendering/render-model.ts";
@@ -112,15 +99,6 @@ export function artifactLines(artifacts: Artifact[]): string[] {
112
99
  return artifacts.map((artifact) => (titleCounts.get(artifact.title)! > 1 ? `${artifactLine(artifact)} (${artifact.id})` : artifactLine(artifact)));
113
100
  }
114
101
 
115
- /** Resolves internal ids for model text; ids resurface only when equal titles need disambiguation. */
116
- async function artifactLabelsById(ids: readonly string[]): Promise<Map<string, string>> {
117
- const uniqueIds = [...new Set(ids)];
118
- const artifacts = (await Promise.all(uniqueIds.map((id) => callService<Record<string, unknown>, Artifact | null>("artifact.show", { id })))).filter((artifact): artifact is Artifact => artifact !== null);
119
- const titleCounts = new Map<string, number>();
120
- for (const artifact of artifacts) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
121
- return new Map(artifacts.map((artifact) => [artifact.id, titleCounts.get(artifact.title)! > 1 ? `${artifact.title} (${artifact.id})` : artifact.title]));
122
- }
123
-
124
102
  /**
125
103
  * Exact, case-insensitive, trimmed title match against an already-fetched candidate set. Throws
126
104
  * a clear "not found" or "ambiguous -- use id" error rather than guessing at a fuzzy match -- id
@@ -256,271 +234,7 @@ async function handleArtifactRemoveRestore(action: unknown, params: Record<strin
256
234
  return null;
257
235
  }
258
236
 
259
- const proofReferenceSchema = Type.Object({
260
- type: Type.Union(PROOF_TYPES.map((type) => Type.Literal(type))),
261
- target: Type.String(),
262
- expect: Type.Optional(Type.String()),
263
- });
264
-
265
- const checklistCriterionSchema = Type.Object({
266
- proof: Type.Array(proofReferenceSchema, { minItems: 1 }),
267
- });
268
-
269
- export function registerTasksTool(pi: ExtensionAPI): void {
270
- pi.registerTool({
271
- name: "tasks",
272
- label: "Tasks",
273
- description: "Task domain tool. ACTIONS: create, update, list, show, history, context, scope, set_scope, assign_project, graph, plan, active, focused, focus, pause, unpause, clear_focus, start, submit, complete, reject, retry, cancel, cancel_subtree, run_gates, set_checklist, set_gates, depend, undepend, contain, uncontain, remove, remove_subtree, restore, claim, heartbeat_lease, release_lease, lease, event_feed. Lifecycle: todo → in-progress → review → done; review failure → rejected → retry → in-progress; canceled is terminal. Focus and lease are independent of lifecycle and of each other -- multiple sessions can focus the same task while only one holds its lease (claim throws if a different owner already holds one; release/heartbeat need the exact token claim returned; owner defaults to this session's id). context returns the full plan (the system prompt itself only carries a one-line pointer) -- call it explicitly after a compaction or before reconciling. complete runs gates + checklist-proof review, then focuses one ready successor. cancel_subtree cancels a task and its whole containment subtree in one call, skipping tasks already done/canceled. remove/restore use a time-gated trash (refuses the live Focus); remove_subtree trashes a whole `contains` subtree in one call; undepend/uncontain are idempotent no-ops when the edge is already absent. update recovers an accidentally-terminal task via status=todo + reason, without rewriting real history; update never touches gates (title/body/labels/status only) -- use set_gates to replace a task's gate commands after creation. Prefer `name` (exact title) over `id` -- id is a backend detail, resolved automatically, needed only to disambiguate a shared title; `parent_name`/`child_name`/`root_task_name` are the same pattern for their `_id` counterparts. For a prerequisite, use `dependency_name` (singular, resolved to `dependency_id`) with the `depend`/`undepend` actions; `depends_on_names` (plural array, resolved to `depends_on`) is only for `create`'s initial dependency set -- passing the wrong one of the two to `depend` leaves `dependency_id` unset and fails with a `dependency_id is required` error. A name resolved outside this call's own project scope (e.g. depending on a task in a different project) is retried once against every project before failing, and the response notes when that happened.",
274
- parameters: Type.Object({
275
- action: Type.String(),
276
- id: Type.Optional(Type.String()),
277
- name: Type.Optional(Type.String()),
278
- title: Type.Optional(Type.String()),
279
- body: Type.Optional(Type.String()),
280
- status: Type.Optional(Type.String()),
281
- text: Type.Optional(Type.String()),
282
- limit: Type.Optional(Type.Number()),
283
- cursor: Type.Optional(Type.Number()),
284
- direction: Type.Optional(Type.Union([Type.Literal("asc"), Type.Literal("desc")])),
285
- reason: Type.Optional(Type.String()),
286
- session_id: Type.Optional(Type.String()),
287
- labels: Type.Optional(Type.Array(Type.String())),
288
- extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
289
- gates: Type.Optional(Type.Array(Type.Record(Type.String(), Type.Unknown()))),
290
- checklist: Type.Optional(Type.Record(Type.String(), checklistCriterionSchema)),
291
- template_id: Type.Optional(Type.String()),
292
- parent_id: Type.Optional(Type.String()),
293
- parent_name: Type.Optional(Type.String()),
294
- child_id: Type.Optional(Type.String()),
295
- child_name: Type.Optional(Type.String()),
296
- dependency_id: Type.Optional(Type.String()),
297
- dependency_name: Type.Optional(Type.String()),
298
- depends_on: Type.Optional(Type.Array(Type.String())),
299
- depends_on_names: Type.Optional(Type.Array(Type.String())),
300
- project_root: Type.Optional(Type.String()),
301
- scope: Type.Optional(Type.Union([Type.Literal("project"), Type.Literal("graph"), Type.Literal("all")])),
302
- root_task_id: Type.Optional(Type.String()),
303
- root_task_name: Type.Optional(Type.String()),
304
- owner: Type.Optional(Type.String()),
305
- token: Type.Optional(Type.String()),
306
- ttl_ms: Type.Optional(Type.Number()),
307
- note: Type.Optional(Type.String()),
308
- event_types: Type.Optional(Type.Array(Type.String())),
309
- }),
310
- renderCall(args, theme) { return renderPapyrusToolCall("Tasks", args, theme); },
311
- renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
312
- async execute(_id, rawParams, _signal, _onUpdate, ctx) {
313
- try {
314
- const params: Record<string, unknown> = { ...rawParams };
315
- const action = params.action;
316
- // Defaults to this Pi session's own id so Focus reads/writes are isolated per agent
317
- // without depending on the model to know or supply its own session identity.
318
- // session_secret is looked up by the resolved session_id itself (not blindly the
319
- // current session's), so a model that explicitly overrides session_id to a DIFFERENT
320
- // session never gets this session's secret smuggled in on its behalf -- the cache only
321
- // ever holds this extension's own registered session anyway (see session-identity.ts).
322
- const resolvedSessionId = params.session_id ?? ctx.sessionManager.getSessionId();
323
- const baseRequest = { project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool", session_id: resolvedSessionId, ...sessionSecretField(resolvedSessionId as string) };
324
- // Collects a note whenever a name field below only resolved by widening past this call's
325
- // own project scope (see resolveArtifactIdByName) -- surfaced at the end of this action's
326
- // own response text rather than resolved silently, since a cross-project depend/contain
327
- // is exactly the case a shared per-call scope can't otherwise express.
328
- const notes: string[] = [];
329
- // Resolve the graph root first: every other name lookup must use the caller's final
330
- // project/scope/root selection, otherwise `scope: all|graph` silently collapses back
331
- // to the current project and forces callers to reach for an id.
332
- await resolveNameFields(params, [
333
- { nameKey: "root_task_name", idKey: "root_task_id", listOperation: "tasks.list", baseRequest: { ...baseRequest, scope: "project" } },
334
- ], notes);
335
- const resolutionRequest = {
336
- ...baseRequest,
337
- ...(params.scope === undefined ? {} : { scope: params.scope }),
338
- ...(params.root_task_id === undefined ? {} : { root_task_id: params.root_task_id }),
339
- };
340
- // The daemon remains keyed by stable ids; the agent facade resolves names against the
341
- // exact requested view before dispatching those internal ids.
342
- await resolveNameFields(params, [
343
- { nameKey: "name", idKey: "id", listOperation: "tasks.list", baseRequest: resolutionRequest },
344
- { nameKey: "dependency_name", idKey: "dependency_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
345
- { nameKey: "parent_name", idKey: "parent_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
346
- { nameKey: "child_name", idKey: "child_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
347
- ], notes);
348
- await resolveNameArrayField(params, "depends_on_names", "depends_on", "tasks.list", resolutionRequest, notes);
349
- const request = { ...params, ...baseRequest };
350
- const result = await (async (): Promise<ReturnType<typeof text>> => {
351
- if (action === "create") {
352
- const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
353
- return text(`Created task ${artifactLine(artifact)}`, createArtifactDetails("tasks.create", artifact));
354
- }
355
- if (action === "list") {
356
- const rows = await callService<Record<string, unknown>, Artifact[]>("tasks.list", request);
357
- return text(rows.length ? artifactLines(rows).join("\n") : "No tasks found.", createArtifactListDetails("tasks.list", rows));
358
- }
359
- if (action === "show") {
360
- const artifact = await callService<Record<string, unknown>, Artifact>("tasks.show", params);
361
- return text(`${artifactLine(artifact)}\n\n${artifact.body}`, createArtifactDetails("tasks.show", artifact));
362
- }
363
- if (action === "history") {
364
- const page = await callService<Record<string, unknown>, TaskHistoryPage>("tasks.history", request);
365
- const lines = page.events.map((event) => `${event.occurredAt} ${event.type} ${event.fromStatus ?? "∅"} → ${event.toStatus ?? "∅"} · ${event.actor}/${event.source}${event.reason ? ` · ${event.reason}` : ""}`);
366
- const output = lines.join("\n") || "No recorded history for this task.";
367
- return text(output, createPreviewDetails("tasks.history", "Task history", output));
368
- }
369
- if (action === "scope") {
370
- const selection = await callService<Record<string, unknown>, TaskViewSelection>("tasks.scope", request);
371
- return text(`Task scope: ${selection.label}`, createPreviewDetails("tasks.scope", "Task scope", selection.label));
372
- }
373
- if (action === "active") {
374
- const artifact = await callService<Record<string, unknown>, Artifact | null>("tasks.active", request);
375
- return artifact
376
- ? text(`Active: ${artifactLine(artifact)}`, createArtifactDetails("tasks.active", artifact))
377
- : text("No active task.", createPreviewDetails("tasks.active", "Active task", "No active task."));
378
- }
379
- if (action === "focused") {
380
- const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string } | null>("tasks.focused", request);
381
- return focus
382
- ? text(`Focused (${focus.status}): ${artifactLine(focus.artifact)}`, createArtifactDetails("tasks.focused", focus.artifact))
383
- : text("No focused task.", createPreviewDetails("tasks.focused", "Focused task", "No focused task."));
384
- }
385
- if (action === "pause" || action === "unpause") {
386
- const operation = action === "pause" ? "tasks.pause" : "tasks.unpause";
387
- const focus = await callService<Record<string, unknown>, { artifact: Artifact; status: string }>(operation, request);
388
- emitTaskFocusEvent({ taskId: focus.artifact.id, sessionId: request.session_id as string, status: action === "pause" ? "paused" : "unpaused" });
389
- return text(`Focused (${focus.status}): ${artifactLine(focus.artifact)}`, createArtifactDetails(operation, focus.artifact));
390
- }
391
- if (action === "clear_focus") {
392
- const result = await callService<Record<string, unknown>, { cleared: boolean }>("tasks.clear_focus", request);
393
- if (result.cleared) emitTaskFocusEvent({ taskId: null, sessionId: request.session_id as string, status: "cleared" });
394
- const output = result.cleared ? "Task focus cleared." : "No focused task.";
395
- return text(output, createPreviewDetails("tasks.clear_focus", "Task focus", output));
396
- }
397
- if (action === "graph") {
398
- const graph = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", request);
399
- const dependencies = graph.nodes.reduce((count, node) => count + node.dependencyIds.length, 0);
400
- const containment = graph.nodes.reduce((count, node) => count + node.childIds.length, 0);
401
- const edges = graph.nodes.flatMap((node) => [
402
- ...node.dependencyIds.map((dependencyId) => ({ from: node.task.id, relation: "depends_on", to: dependencyId })),
403
- ...node.childIds.map((childId) => ({ from: node.task.id, relation: "contains", to: childId })),
404
- ]);
405
- return text(
406
- `Task graph: ${graph.nodes.length} nodes, ${graph.rootIds.length} roots, ${dependencies} dependencies, ${containment} containment edges.`,
407
- createGraphDetails("tasks.graph", graph.nodes.map((node) => node.task), edges),
408
- );
409
- }
410
- if (action === "plan") {
411
- const plan = await callService<Record<string, unknown>, TaskExecutionPlan>("tasks.plan", request);
412
- const byId = new Map(plan.nodes.map((node) => [node.id, node]));
413
- const titleCounts = new Map<string, number>();
414
- for (const node of plan.nodes) titleCounts.set(node.title, (titleCounts.get(node.title) ?? 0) + 1);
415
- const nodeLabel = (id: string): string => {
416
- const node = byId.get(id);
417
- if (!node) return "unknown task";
418
- return (titleCounts.get(node.title) ?? 0) > 1 ? `${node.title} (${node.id})` : node.title;
419
- };
420
- const lines = plan.layers.flatMap((layer, index) => [
421
- `Layer ${index + 1}`,
422
- ...layer.map((id) => {
423
- const node = byId.get(id);
424
- return ` [${node?.state ?? "unknown"}] ${nodeLabel(id)}`;
425
- }),
426
- ]);
427
- if (plan.cycleIds.length > 0) lines.push(`Invalid cycle: ${plan.cycleIds.map(nodeLabel).join(", ")}`);
428
- const output = lines.join("\n") || "No tasks in execution plan.";
429
- return text(output, createPreviewDetails("tasks.plan", "Task execution plan", output));
430
- }
431
- if (action === "context") {
432
- const summary = await callService<Record<string, unknown>, string | null>("tasks.context", { ...request, verbosity: "full" });
433
- const output = summary ?? "No open tasks.";
434
- return text(output, createPreviewDetails("tasks.context", "Task reconciliation context", output));
435
- }
436
- if (action === "set_checklist") {
437
- const artifact = await callService<Record<string, unknown>, Artifact>("tasks.set_checklist", params);
438
- return text(`Updated checklist: ${artifactLine(artifact)}`, createArtifactDetails("tasks.set_checklist", artifact));
439
- }
440
- if (action === "set_gates") {
441
- const artifact = await callService<Record<string, unknown>, Artifact>("tasks.set_gates", params);
442
- return text(`Updated gates: ${artifactLine(artifact)}`, createArtifactDetails("tasks.set_gates", artifact));
443
- }
444
- if (action === "complete") {
445
- const result = await callService<Record<string, unknown>, TaskCompletion>("tasks.complete", request);
446
- const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n");
447
- const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}${item.reason ? ` — ${item.reason}` : ""}`).join("\n");
448
- const focused = result.focused ? `\nActive: ${artifactLine(result.focused)}` : "";
449
- const blockedLines = artifactLines(result.blocked.map((entry) => entry.artifact));
450
- const dependencyLabels = await artifactLabelsById(result.blocked.flatMap((entry) => entry.dependencyIds));
451
- const blocked = result.blocked.length > 0
452
- ? `\nBlocked: ${result.blocked.map((entry, index) => `${blockedLines[index]} waits for ${entry.dependencyIds.map((id) => dependencyLabels.get(id) ?? "unknown task").join(", ")}`).join("; ")}`
453
- : "";
454
- const output = `${result.completed ? "Completed" : "Rejected"}: ${artifactLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`;
455
- return text(output, createPreviewDetails("tasks.complete", "Task completion", output));
456
- }
457
- if (action === "run_gates") {
458
- const [gates, task] = await Promise.all([
459
- callService<Record<string, unknown>, GateResult[]>("tasks.run_gates", request),
460
- callService<Record<string, unknown>, Artifact>("tasks.show", { id: params.id }),
461
- ]);
462
- return text(
463
- gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target} — ${gate.output}`).join("\n") || "No gates configured.",
464
- createGateRunDetails("tasks.run_gates", (params.id as string | undefined) ?? "", task.title, gates.map((gate) => ({
465
- passed: gate.passed, type: gate.gate.type, target: gate.gate.target, output: gate.output,
466
- }))),
467
- );
468
- }
469
- if (action === "event_feed") {
470
- const page = await callService<Record<string, unknown>, { events: Array<{ id: number; occurredAt: string; taskId: string; type: string }>; nextCursor?: number }>("tasks.event_feed", { cursor: params.cursor, limit: params.limit, event_types: params.event_types });
471
- const output = page.events.length === 0 ? "No events." : page.events.map((event) => `${event.id} ${event.occurredAt} ${event.taskId} ${event.type}`).join("\n");
472
- return text(page.nextCursor !== undefined ? `${output}\n\n(more available -- resume with cursor: ${page.nextCursor})` : output, createPreviewDetails("tasks.event_feed", "Task event feed", output));
473
- }
474
- if (action === "claim" || action === "heartbeat_lease" || action === "release_lease" || action === "lease") {
475
- const leaseRequest = { ...request, owner: (params.owner as string | undefined) ?? resolvedSessionId };
476
- if (action === "release_lease") {
477
- const released = await callService<Record<string, unknown>, { released: boolean }>("tasks.release_lease", leaseRequest);
478
- const output = released.released ? "Lease released." : "No live lease to release.";
479
- return text(output, createPreviewDetails("tasks.release_lease", "Task lease", output));
480
- }
481
- const operation = action === "claim" ? "tasks.claim" : action === "heartbeat_lease" ? "tasks.heartbeat_lease" : "tasks.lease";
482
- const lease = await callService<Record<string, unknown>, TaskLease | null>(operation, leaseRequest);
483
- const output = lease ? `Leased by "${lease.owner}" until ${lease.leaseExpiresAt} (token ${lease.token}).` : "No live lease.";
484
- return text(output, createPreviewDetails(operation, "Task lease", output));
485
- }
486
- if (action === "cancel_subtree") {
487
- const outcome = await callService<Record<string, unknown>, { canceled: string[]; skipped: string[] }>("tasks.cancel_subtree", request);
488
- const output = `Canceled ${outcome.canceled.length} task(s)${outcome.skipped.length > 0 ? `, skipped ${outcome.skipped.length} already-terminal` : ""}.`;
489
- return text(output, createPreviewDetails("tasks.cancel_subtree", "Cancel task subtree", JSON.stringify(outcome, null, 2)));
490
- }
491
- const trashResult = await handleArtifactRemoveRestore(action, params);
492
- if (trashResult) return trashResult;
493
- const operations = {
494
- focus: "tasks.focus",
495
- start: "tasks.start",
496
- submit: "tasks.submit",
497
- reject: "tasks.reject",
498
- retry: "tasks.retry",
499
- cancel: "tasks.cancel",
500
- update: "tasks.update",
501
- set_scope: "tasks.set_scope",
502
- assign_project: "tasks.assign_project",
503
- depend: "tasks.depend",
504
- undepend: "tasks.undepend",
505
- contain: "tasks.contain",
506
- uncontain: "tasks.uncontain",
507
- } as const;
508
- const operation = operations[action as keyof typeof operations];
509
- if (!operation) throw new Error(`unknown tasks action: ${action}`);
510
- const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
511
- if (operation === "tasks.focus") emitTaskFocusEvent({ taskId: artifact.id, sessionId: request.session_id as string, status: "focused" });
512
- return text(artifactLine(artifact), createArtifactDetails(operation, artifact));
513
- })();
514
- if (notes.length > 0 && result.content[0]?.type === "text") result.content[0].text += `\n\n${notes.join("\n")}`;
515
- return result;
516
- } catch (error) {
517
- throw new Error(`tasks failed: ${error instanceof Error ? error.message : error}`);
518
- }
519
- },
520
- });
521
- }
522
-
523
- // notes.*, rules.*, docs.*, skills.*, playbooks.*, and the shared artifact.* are
237
+ // notes.*, rules.*, docs.*, skills.*, playbooks.*, tasks.*, and the shared artifact.* are
524
238
  // registered as Vehicles (see ../vehicle-notes-client.ts and @danypops/papyrus's
525
239
  // src/vehicle/papyrus-vehicle.ts), not pi.registerTool()s in this file.
526
240
 
@@ -627,11 +341,11 @@ export function registerDiscussTool(pi: ExtensionAPI): void {
627
341
  }
628
342
 
629
343
  /** Thin orchestrator: each domain's tool is independently navigable/testable via its own registerXTool function. */
630
- // notes, rules, docs, skills, and playbooks are no longer registered here -- all migrated onto
631
- // Vehicle (registerNotesVehicle in vehicle-notes-client.ts, wired at session_start in index.ts),
632
- // replacing their own pi.registerTool() mega-tools. See @danypops/papyrus's
633
- // src/vehicle/papyrus-vehicle.ts for the server side.
344
+ // notes, rules, docs, skills, playbooks, and tasks are no longer registered here -- all migrated
345
+ // onto Vehicle (registerNotesVehicle in vehicle-notes-client.ts, wired at session_start in
346
+ // index.ts), replacing their own pi.registerTool() mega-tools. See @danypops/papyrus's
347
+ // src/vehicle/papyrus-vehicle.ts for the server side. discuss remains here -- live:true needs an
348
+ // interactive UI round-trip a stateless Vehicle operation can't express.
634
349
  export function registerDomainTools(pi: ExtensionAPI): void {
635
- registerTasksTool(pi);
636
350
  registerDiscussTool(pi);
637
351
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Registers every Vehicle-projected domain (notes.*, rules.*, docs.*, skills.*,
3
- * playbooks.*, artifact.*) as real Pi tools -- see @danypops/papyrus's
3
+ * playbooks.*, tasks.*, artifact.*) as real Pi tools -- see @danypops/papyrus's
4
4
  * src/vehicle/papyrus-vehicle.ts.
5
5
  *
6
6
  * Fails silently on a stale/unreachable daemon handle instead of aborting extension
@@ -16,12 +16,17 @@ import { RemoteVehicleClient } from "@danypops/vehicle-client/http";
16
16
  import { registerVehicleTools } from "@danypops/vehicle-client-pi";
17
17
  import { currentVehicleClientTarget } from "./service-client.ts";
18
18
  import { sessionSecretField } from "./session-identity.ts";
19
+ import { emitTaskFocusEvent } from "./task-focus-events.ts";
19
20
 
20
21
  const REGISTERED_PERMISSIONS = [
21
22
  "notes:read", "notes:write", "rules:read", "rules:write", "docs:read", "docs:write",
22
- "skills:read", "skills:write", "playbooks:read", "playbooks:write", "artifact:read", "artifact:write",
23
+ "skills:read", "skills:write", "playbooks:read", "playbooks:write", "tasks:read", "tasks:write",
24
+ "artifact:read", "artifact:write",
23
25
  ];
24
26
 
27
+ /** Task Focus's own internal write needs a real, per-session secret -- see below. Every other tasks.* operation reads session_id purely for read-scoping and needs no secret. */
28
+ const FOCUS_MUTATION_OPERATIONS = new Set(["tasks.focus", "tasks.pause", "tasks.unpause", "tasks.clear_focus"]);
29
+
25
30
  export async function registerNotesVehicle(pi: ExtensionAPI): Promise<void> {
26
31
  const target = currentVehicleClientTarget();
27
32
  if (!target) return;
@@ -30,17 +35,24 @@ export async function registerNotesVehicle(pi: ExtensionAPI): Promise<void> {
30
35
  await registerVehicleTools(pi, client, {
31
36
  permissions: REGISTERED_PERMISSIONS,
32
37
  principal: { id: "pi-papyrus" },
33
- // playbooks.invoke's own module handler authorizes an internal Task Focus write via
38
+ // playbooks.invoke's own module handler, and tasks.focus/pause/unpause/clear_focus's
39
+ // own module handlers, authorize an internal Task Focus write via
34
40
  // sessionIdentity.assertAuthorized(session_id, session_secret) -- see
35
- // @danypops/papyrus's src/vehicle/playbooks-vehicle.ts. That secret must never be a
36
- // model-visible input field (the model has no business knowing or supplying it), so
37
- // it travels here instead, in principal.claims, from this extension's own already-
38
- // cached secret (registered at session_start -- see index.ts) -- the same value
39
- // sessionSecretField() used to thread through as a raw RPC input field before this
40
- // operation moved onto Vehicle.
41
- resolveInvocation: ({ descriptor, context }) => {
42
- if (descriptor.name !== "playbooks.invoke") return {};
43
- const sessionId = context.sessionManager.getSessionId();
41
+ // @danypops/papyrus's src/vehicle/playbooks-vehicle.ts and tasks-vehicle.ts. That
42
+ // secret must never be a model-visible input field (the model has no business
43
+ // knowing or supplying it), so it travels here instead, in principal.claims, from
44
+ // this extension's own already-cached secret (registered at session_start -- see
45
+ // index.ts) -- the same value sessionSecretField() used to thread through as a raw
46
+ // RPC input field before these operations moved onto Vehicle.
47
+ resolveInvocation: ({ descriptor, input, context }) => {
48
+ if (descriptor.name !== "playbooks.invoke" && !FOCUS_MUTATION_OPERATIONS.has(descriptor.name)) return {};
49
+ // tasks.* defaults session_id to this Pi session's own id, same as the removed
50
+ // hand-rolled tool -- but the secret cache is keyed by whichever session_id is
51
+ // actually being authorized, not blindly this session's, so a model that
52
+ // explicitly overrides session_id to a DIFFERENT session never gets this
53
+ // session's secret smuggled in on its behalf.
54
+ const requestedSessionId = (input as { session_id?: unknown } | undefined)?.session_id;
55
+ const sessionId = typeof requestedSessionId === "string" && requestedSessionId.length > 0 ? requestedSessionId : context.sessionManager.getSessionId();
44
56
  const { session_secret: sessionSecret } = sessionSecretField(sessionId);
45
57
  // Omit sessionSecret entirely when nothing is cached (unregistered session) --
46
58
  // {sessionSecret: null} would fail the module's own optionalString(input,
@@ -49,6 +61,26 @@ export async function registerNotesVehicle(pi: ExtensionAPI): Promise<void> {
49
61
  const claims: Record<string, string> = sessionSecret ? { sessionId, sessionSecret } : { sessionId };
50
62
  return { principal: { id: "pi-papyrus", claims } };
51
63
  },
64
+ // papyrus.task-focus.v1 is a same-process Pi extension event bus broadcast (e.g. a
65
+ // token-cost router correlating its own telemetry with the currently focused task)
66
+ // -- has no Vehicle-transport equivalent, so it's emitted here, client-side, rather
67
+ // than from the operation's own output.
68
+ onInvoked: ({ descriptor }, output) => {
69
+ if (descriptor.name === "tasks.focus") {
70
+ const artifact = output as { id: string } | undefined;
71
+ if (artifact?.id) emitTaskFocusEvent({ taskId: artifact.id, status: "focused" });
72
+ return;
73
+ }
74
+ if (descriptor.name === "tasks.pause" || descriptor.name === "tasks.unpause") {
75
+ const focus = output as { artifact: { id: string } } | undefined;
76
+ if (focus?.artifact?.id) emitTaskFocusEvent({ taskId: focus.artifact.id, status: descriptor.name === "tasks.pause" ? "paused" : "unpaused" });
77
+ return;
78
+ }
79
+ if (descriptor.name === "tasks.clear_focus") {
80
+ const result = output as { cleared: boolean } | undefined;
81
+ if (result?.cleared) emitTaskFocusEvent({ taskId: null, status: "cleared" });
82
+ }
83
+ },
52
84
  });
53
85
  } catch {
54
86
  // Daemon state is stale/unreachable -- degrade silently, matching
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-papyrus",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Pi host extension for Papyrus: native tools, TUI panels, and context injection over the daemon-backed graph store",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -18,11 +18,11 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@danypops/jittor": "^0.14.0",
21
- "@danypops/papyrus": "^0.40.0",
21
+ "@danypops/papyrus": "^0.41.0",
22
22
  "@danypops/vehicle-core": "^0.2.0",
23
23
  "@danypops/vehicle-server": "^0.1.1",
24
24
  "@danypops/vehicle-client": "^0.1.1",
25
- "@danypops/vehicle-client-pi": "^0.2.0",
25
+ "@danypops/vehicle-client-pi": "^0.3.0",
26
26
  "beautiful-mermaid": "1.1.3",
27
27
  "malevich-tui-components": "^0.5.0"
28
28
  },