@danypops/pi-papyrus 0.49.1 → 0.50.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.
@@ -55,6 +55,23 @@ function simpleDetailsText(details: Exclude<PapyrusToolDetails, { kind: "artifac
55
55
  return `${details.title}\n${details.content}${details.completeness.truncated ? `\n[truncated ${details.completeness.omitted} characters]` : ""}`;
56
56
  case "error":
57
57
  return `${details.code}: ${details.message}`;
58
+ case "execution-plan":
59
+ return `${details.nodes.length} task(s) across ${details.layers.length} layer(s)${details.cycleIds.length ? `, ${details.cycleIds.length} in a cycle` : ""}`;
60
+ case "playbook-invocation":
61
+ return [
62
+ `✓ Run ${details.runId}`,
63
+ `${details.created.tasks.length} tasks · ${details.created.docs.length} docs · ${details.created.rules.length} rules`,
64
+ ].join("\n");
65
+ case "playbook-missing-arguments":
66
+ return `Missing required argument(s): ${details.missingArguments.join(", ")}`;
67
+ case "discussion":
68
+ return `${details.discussion ? `${details.discussion.title}\n` : ""}${details.rounds.length} round(s)`;
69
+ case "task-completion":
70
+ return `${details.completed ? "✓" : "✗"} ${details.artifact.title}`;
71
+ case "no-focus":
72
+ return "No focused task.";
73
+ case "lease":
74
+ return `${details.taskName} — ${details.taskTitle}\nowner ${details.owner}, expires ${details.leaseExpiresAt}`;
58
75
  }
59
76
  }
60
77
 
@@ -127,6 +127,105 @@ export interface ErrorToolDetails extends ToolDetailsBase {
127
127
  message: string;
128
128
  }
129
129
 
130
+ export interface ExecutionPlanNode {
131
+ id: string;
132
+ title: string;
133
+ status: string;
134
+ active: boolean;
135
+ state: string;
136
+ layer: number | null;
137
+ prerequisiteIds: string[];
138
+ successorIds: string[];
139
+ }
140
+
141
+ export interface ExecutionPlanToolDetails extends ToolDetailsBase {
142
+ kind: "execution-plan";
143
+ nodes: ExecutionPlanNode[];
144
+ layers: string[][];
145
+ cycleIds: string[];
146
+ completeness: ResultCompleteness;
147
+ }
148
+
149
+ export interface PlaybookInvocationCreated {
150
+ docs: string[];
151
+ rules: string[];
152
+ tasks: string[];
153
+ }
154
+
155
+ export interface PlaybookInvocationToolDetails extends ToolDetailsBase {
156
+ kind: "playbook-invocation";
157
+ playbookId: string;
158
+ runId: string;
159
+ created: PlaybookInvocationCreated;
160
+ rootTaskIds: string[];
161
+ entryTaskId: string;
162
+ execution: { nodes: ExecutionPlanNode[]; layers: string[][]; cycleIds: string[] };
163
+ completeness: ResultCompleteness;
164
+ }
165
+
166
+ export interface PlaybookMissingArgumentsToolDetails extends ToolDetailsBase {
167
+ kind: "playbook-missing-arguments";
168
+ playbookId: string;
169
+ missingArguments: string[];
170
+ }
171
+
172
+ export interface DiscussionRoundSummary {
173
+ roundNumber: number;
174
+ actor: string;
175
+ content: string;
176
+ }
177
+
178
+ export interface DiscussionToolDetails extends ToolDetailsBase {
179
+ kind: "discussion";
180
+ /** Absent for discuss.rounds, which returns rounds with no parent discussion in its own output. */
181
+ discussion?: ToolArtifactSummary;
182
+ rounds: DiscussionRoundSummary[];
183
+ completeness: ResultCompleteness;
184
+ }
185
+
186
+ export interface TaskGateResultSummary {
187
+ passed: boolean;
188
+ output: string;
189
+ }
190
+
191
+ export interface TaskChecklistReviewSummary {
192
+ item: string;
193
+ accepted: boolean;
194
+ reason?: string;
195
+ }
196
+
197
+ export interface TaskBlockageSummary {
198
+ artifact: ToolArtifactSummary;
199
+ dependencyIds: string[];
200
+ }
201
+
202
+ export interface TaskCompletionToolDetails extends ToolDetailsBase {
203
+ kind: "task-completion";
204
+ artifact: ToolArtifactSummary;
205
+ gates: TaskGateResultSummary[];
206
+ checklist: TaskChecklistReviewSummary[];
207
+ completed: boolean;
208
+ focused?: ToolArtifactSummary;
209
+ blocked: TaskBlockageSummary[];
210
+ completeness: ResultCompleteness;
211
+ }
212
+
213
+ export interface NoFocusToolDetails extends ToolDetailsBase {
214
+ kind: "no-focus";
215
+ }
216
+
217
+ /** tasks.claim/heartbeat_lease/release_lease/lease's own name-first view -- deliberately never carries the raw lease token; the model channel (which needs the real token for a later heartbeat/release call) is a separate, independent channel from this persisted, human-facing one. */
218
+ export interface LeaseToolDetails extends ToolDetailsBase {
219
+ kind: "lease";
220
+ taskName: string;
221
+ taskTitle: string;
222
+ owner: string;
223
+ claimedAt: string;
224
+ leaseExpiresAt: string;
225
+ heartbeatAt?: string;
226
+ note?: string;
227
+ }
228
+
130
229
  export type PapyrusToolDetails =
131
230
  | ArtifactToolDetails
132
231
  | ArtifactListToolDetails
@@ -135,7 +234,14 @@ export type PapyrusToolDetails =
135
234
  | GateRunToolDetails
136
235
  | InvocationToolDetails
137
236
  | PreviewToolDetails
138
- | ErrorToolDetails;
237
+ | ErrorToolDetails
238
+ | ExecutionPlanToolDetails
239
+ | PlaybookInvocationToolDetails
240
+ | PlaybookMissingArgumentsToolDetails
241
+ | DiscussionToolDetails
242
+ | TaskCompletionToolDetails
243
+ | NoFocusToolDetails
244
+ | LeaseToolDetails;
139
245
 
140
246
  export interface ModelContent {
141
247
  text: string;
@@ -153,7 +259,9 @@ function boundedText(value: string, maximum: number): { value: string; completen
153
259
  return { value: clipped, completeness: completeness(value.length, clipped.length) };
154
260
  }
155
261
 
156
- function artifactSummary(artifact: Artifact): ToolArtifactSummary {
262
+ type ArtifactSummarySource = Pick<Artifact, "id" | "kind" | "title" | "status" | "subtype" | "labels"> & { alias?: string };
263
+
264
+ function artifactSummary(artifact: ArtifactSummarySource): ToolArtifactSummary {
157
265
  return {
158
266
  id: artifact.id,
159
267
  alias: artifact.alias,
@@ -290,6 +398,171 @@ export function createErrorDetails(operation: string, code: string, message: str
290
398
  };
291
399
  }
292
400
 
401
+ function boundedStringArray(values: readonly string[]): string[] {
402
+ return values.slice(0, TOOL_DETAILS_MAX_ITEMS);
403
+ }
404
+
405
+ function boundedExecutionPlanNodes(nodes: readonly ExecutionPlanNode[]): ExecutionPlanNode[] {
406
+ return nodes.slice(0, TOOL_DETAILS_MAX_ITEMS).map((node) => ({
407
+ ...node,
408
+ prerequisiteIds: boundedStringArray(node.prerequisiteIds),
409
+ successorIds: boundedStringArray(node.successorIds),
410
+ }));
411
+ }
412
+
413
+ function boundedLayers(layers: readonly (readonly string[])[]): string[][] {
414
+ return layers.slice(0, TOOL_DETAILS_MAX_ITEMS).map((layer) => boundedStringArray(layer));
415
+ }
416
+
417
+ export function createExecutionPlanDetails(
418
+ operation: string,
419
+ nodes: readonly ExecutionPlanNode[],
420
+ layers: readonly (readonly string[])[],
421
+ cycleIds: readonly string[],
422
+ ): ExecutionPlanToolDetails {
423
+ const boundedNodes = boundedExecutionPlanNodes(nodes);
424
+ return {
425
+ schemaVersion: PAPYRUS_TOOL_DETAILS_SCHEMA,
426
+ kind: "execution-plan",
427
+ operation,
428
+ nodes: boundedNodes,
429
+ layers: boundedLayers(layers),
430
+ cycleIds: boundedStringArray(cycleIds),
431
+ completeness: completeness(nodes.length, boundedNodes.length),
432
+ };
433
+ }
434
+
435
+ export function createPlaybookInvocationDetails(
436
+ operation: string,
437
+ fields: {
438
+ playbookId: string;
439
+ runId: string;
440
+ created: PlaybookInvocationCreated;
441
+ rootTaskIds: readonly string[];
442
+ entryTaskId: string;
443
+ execution: { nodes: readonly ExecutionPlanNode[]; layers: readonly (readonly string[])[]; cycleIds: readonly string[] };
444
+ },
445
+ ): PlaybookInvocationToolDetails {
446
+ const boundedNodes = boundedExecutionPlanNodes(fields.execution.nodes);
447
+ return {
448
+ schemaVersion: PAPYRUS_TOOL_DETAILS_SCHEMA,
449
+ kind: "playbook-invocation",
450
+ operation,
451
+ playbookId: fields.playbookId,
452
+ runId: fields.runId,
453
+ created: {
454
+ docs: boundedStringArray(fields.created.docs),
455
+ rules: boundedStringArray(fields.created.rules),
456
+ tasks: boundedStringArray(fields.created.tasks),
457
+ },
458
+ rootTaskIds: boundedStringArray(fields.rootTaskIds),
459
+ entryTaskId: fields.entryTaskId,
460
+ execution: {
461
+ nodes: boundedNodes,
462
+ layers: boundedLayers(fields.execution.layers),
463
+ cycleIds: boundedStringArray(fields.execution.cycleIds),
464
+ },
465
+ completeness: completeness(fields.execution.nodes.length, boundedNodes.length),
466
+ };
467
+ }
468
+
469
+ export function createPlaybookMissingArgumentsDetails(
470
+ operation: string,
471
+ playbookId: string,
472
+ missingArguments: readonly string[],
473
+ ): PlaybookMissingArgumentsToolDetails {
474
+ return {
475
+ schemaVersion: PAPYRUS_TOOL_DETAILS_SCHEMA,
476
+ kind: "playbook-missing-arguments",
477
+ operation,
478
+ playbookId,
479
+ missingArguments: boundedStringArray(missingArguments),
480
+ };
481
+ }
482
+
483
+ export function createDiscussionDetails(
484
+ operation: string,
485
+ rounds: readonly DiscussionRoundSummary[],
486
+ discussion?: ArtifactSummarySource,
487
+ ): DiscussionToolDetails {
488
+ const boundedRounds = rounds.slice(0, TOOL_DETAILS_MAX_ITEMS).map((round) => ({
489
+ ...round,
490
+ content: round.content.slice(0, TOOL_DETAILS_ROW_OUTPUT_MAX_CHARACTERS),
491
+ }));
492
+ return {
493
+ schemaVersion: PAPYRUS_TOOL_DETAILS_SCHEMA,
494
+ kind: "discussion",
495
+ operation,
496
+ ...(discussion ? { discussion: artifactSummary(discussion) } : {}),
497
+ rounds: boundedRounds,
498
+ completeness: completeness(rounds.length, boundedRounds.length),
499
+ };
500
+ }
501
+
502
+ export function createTaskCompletionDetails(
503
+ operation: string,
504
+ fields: {
505
+ artifact: Artifact;
506
+ gates: readonly TaskGateResultSummary[];
507
+ checklist: readonly TaskChecklistReviewSummary[];
508
+ completed: boolean;
509
+ focused: Artifact | null;
510
+ blocked: readonly { artifact: Artifact; dependencyIds: readonly string[] }[];
511
+ },
512
+ ): TaskCompletionToolDetails {
513
+ const boundedGates = fields.gates.slice(0, TOOL_DETAILS_MAX_ITEMS).map((gate) => ({
514
+ ...gate,
515
+ output: gate.output.slice(0, TOOL_DETAILS_ROW_OUTPUT_MAX_CHARACTERS),
516
+ }));
517
+ const boundedChecklist = fields.checklist.slice(0, TOOL_DETAILS_MAX_ITEMS);
518
+ const boundedBlocked = fields.blocked.slice(0, TOOL_DETAILS_MAX_ITEMS).map((entry) => ({
519
+ artifact: artifactSummary(entry.artifact),
520
+ dependencyIds: boundedStringArray(entry.dependencyIds),
521
+ }));
522
+ return {
523
+ schemaVersion: PAPYRUS_TOOL_DETAILS_SCHEMA,
524
+ kind: "task-completion",
525
+ operation,
526
+ artifact: artifactSummary(fields.artifact),
527
+ gates: boundedGates,
528
+ checklist: boundedChecklist,
529
+ completed: fields.completed,
530
+ ...(fields.focused ? { focused: artifactSummary(fields.focused) } : {}),
531
+ blocked: boundedBlocked,
532
+ completeness: completeness(fields.blocked.length, boundedBlocked.length),
533
+ };
534
+ }
535
+
536
+ export function createNoFocusDetails(operation: string): NoFocusToolDetails {
537
+ return { schemaVersion: PAPYRUS_TOOL_DETAILS_SCHEMA, kind: "no-focus", operation };
538
+ }
539
+
540
+ export function createLeaseDetails(
541
+ operation: string,
542
+ lease: {
543
+ taskName: string;
544
+ taskTitle: string;
545
+ owner: string;
546
+ claimedAt: string;
547
+ leaseExpiresAt: string;
548
+ heartbeatAt?: string;
549
+ note?: string;
550
+ },
551
+ ): LeaseToolDetails {
552
+ return {
553
+ schemaVersion: PAPYRUS_TOOL_DETAILS_SCHEMA,
554
+ kind: "lease",
555
+ operation,
556
+ taskName: lease.taskName,
557
+ taskTitle: lease.taskTitle,
558
+ owner: lease.owner,
559
+ claimedAt: lease.claimedAt,
560
+ leaseExpiresAt: lease.leaseExpiresAt,
561
+ ...(lease.heartbeatAt ? { heartbeatAt: lease.heartbeatAt } : {}),
562
+ ...(lease.note ? { note: lease.note.slice(0, TOOL_DETAILS_FIELD_MAX_CHARACTERS) } : {}),
563
+ };
564
+ }
565
+
293
566
  export function createModelContent(value: string): ModelContent {
294
567
  if (value.length <= TOOL_MODEL_CONTENT_MAX_CHARACTERS) {
295
568
  return { text: value, truncated: false, omitted: 0 };
@@ -377,6 +650,54 @@ function isBoundedArray<T>(value: unknown, maximum: number, predicate: (entry: u
377
650
  return Array.isArray(value) && value.length <= maximum && value.every(predicate);
378
651
  }
379
652
 
653
+ function isExecutionPlanNode(value: unknown): value is ExecutionPlanNode {
654
+ return (
655
+ isRecord(value) &&
656
+ isBoundedString(value.id) &&
657
+ isBoundedString(value.title) &&
658
+ isBoundedString(value.status) &&
659
+ typeof value.active === "boolean" &&
660
+ isBoundedString(value.state) &&
661
+ (value.layer === null || Number.isSafeInteger(value.layer)) &&
662
+ isStringArray(value.prerequisiteIds) &&
663
+ isStringArray(value.successorIds)
664
+ );
665
+ }
666
+
667
+ function isLayers(value: unknown): value is string[][] {
668
+ return isBoundedArray(value, TOOL_DETAILS_MAX_ITEMS, (entry): entry is string[] => isStringArray(entry));
669
+ }
670
+
671
+ function isExecution(value: unknown): value is { nodes: ExecutionPlanNode[]; layers: string[][]; cycleIds: string[] } {
672
+ return (
673
+ isRecord(value) &&
674
+ isBoundedArray(value.nodes, TOOL_DETAILS_MAX_ITEMS, isExecutionPlanNode) &&
675
+ isLayers(value.layers) &&
676
+ isStringArray(value.cycleIds)
677
+ );
678
+ }
679
+
680
+ function isDiscussionRoundSummary(value: unknown): value is DiscussionRoundSummary {
681
+ return isRecord(value) && Number.isSafeInteger(value.roundNumber) && isBoundedString(value.actor) && isBoundedString(value.content);
682
+ }
683
+
684
+ function isTaskGateResultSummary(value: unknown): value is TaskGateResultSummary {
685
+ return isRecord(value) && typeof value.passed === "boolean" && isBoundedString(value.output, TOOL_DETAILS_ROW_OUTPUT_MAX_CHARACTERS);
686
+ }
687
+
688
+ function isTaskChecklistReviewSummary(value: unknown): value is TaskChecklistReviewSummary {
689
+ return (
690
+ isRecord(value) &&
691
+ isBoundedString(value.item) &&
692
+ typeof value.accepted === "boolean" &&
693
+ (value.reason === undefined || isBoundedString(value.reason))
694
+ );
695
+ }
696
+
697
+ function isTaskBlockageSummary(value: unknown): value is TaskBlockageSummary {
698
+ return isRecord(value) && isArtifactSummary(value.artifact) && isStringArray(value.dependencyIds);
699
+ }
700
+
380
701
  /** Validate renderer details restored from session history before using them as typed presentation state. */
381
702
  export function parsePapyrusToolDetails(value: unknown): PapyrusToolDetails | undefined {
382
703
  let serializedLength: number;
@@ -447,6 +768,59 @@ export function parsePapyrusToolDetails(value: unknown): PapyrusToolDetails | un
447
768
  return isBoundedString(value.code) && isBoundedString(value.message, TOOL_DETAILS_BODY_MAX_CHARACTERS)
448
769
  ? (value as unknown as ErrorToolDetails)
449
770
  : undefined;
771
+ case "execution-plan":
772
+ return isBoundedArray(value.nodes, TOOL_DETAILS_MAX_ITEMS, isExecutionPlanNode) &&
773
+ isLayers(value.layers) &&
774
+ isStringArray(value.cycleIds) &&
775
+ isCompleteness(value.completeness)
776
+ ? (value as unknown as ExecutionPlanToolDetails)
777
+ : undefined;
778
+ case "playbook-invocation": {
779
+ if (!isRecord(value.created)) return undefined;
780
+ return isBoundedString(value.playbookId) &&
781
+ isBoundedString(value.runId) &&
782
+ isStringArray(value.created.docs) &&
783
+ isStringArray(value.created.rules) &&
784
+ isStringArray(value.created.tasks) &&
785
+ isStringArray(value.rootTaskIds) &&
786
+ isBoundedString(value.entryTaskId) &&
787
+ isExecution(value.execution) &&
788
+ isCompleteness(value.completeness)
789
+ ? (value as unknown as PlaybookInvocationToolDetails)
790
+ : undefined;
791
+ }
792
+ case "playbook-missing-arguments":
793
+ return isBoundedString(value.playbookId) && isStringArray(value.missingArguments)
794
+ ? (value as unknown as PlaybookMissingArgumentsToolDetails)
795
+ : undefined;
796
+ case "discussion":
797
+ return (value.discussion === undefined || isArtifactSummary(value.discussion)) &&
798
+ isBoundedArray(value.rounds, TOOL_DETAILS_MAX_ITEMS, isDiscussionRoundSummary) &&
799
+ isCompleteness(value.completeness)
800
+ ? (value as unknown as DiscussionToolDetails)
801
+ : undefined;
802
+ case "task-completion":
803
+ return isArtifactSummary(value.artifact) &&
804
+ isBoundedArray(value.gates, TOOL_DETAILS_MAX_ITEMS, isTaskGateResultSummary) &&
805
+ isBoundedArray(value.checklist, TOOL_DETAILS_MAX_ITEMS, isTaskChecklistReviewSummary) &&
806
+ typeof value.completed === "boolean" &&
807
+ (value.focused === undefined || isArtifactSummary(value.focused)) &&
808
+ isBoundedArray(value.blocked, TOOL_DETAILS_MAX_ITEMS, isTaskBlockageSummary) &&
809
+ isCompleteness(value.completeness)
810
+ ? (value as unknown as TaskCompletionToolDetails)
811
+ : undefined;
812
+ case "no-focus":
813
+ return value as unknown as NoFocusToolDetails;
814
+ case "lease":
815
+ return isBoundedString(value.taskName) &&
816
+ isBoundedString(value.taskTitle) &&
817
+ isBoundedString(value.owner) &&
818
+ isBoundedString(value.claimedAt) &&
819
+ isBoundedString(value.leaseExpiresAt) &&
820
+ (value.heartbeatAt === undefined || isBoundedString(value.heartbeatAt)) &&
821
+ (value.note === undefined || isBoundedString(value.note))
822
+ ? (value as unknown as LeaseToolDetails)
823
+ : undefined;
450
824
  default:
451
825
  return undefined;
452
826
  }
@@ -13,10 +13,10 @@
13
13
  */
14
14
 
15
15
  import type { Artifact } from "@danypops/papyrus";
16
- import { TOOL_COLLAPSED_ROW_LIMIT } from "@danypops/papyrus";
17
- import type { VehicleToolRenderers } from "@danypops/vehicle-client-pi";
16
+ import { TOOL_COLLAPSED_ROW_LIMIT, TOOL_DETAILS_MAX_SERIALIZED_CHARACTERS } from "@danypops/papyrus";
17
+ import type { PiVehicleInvocationRequest, PiVehiclePresentationContract, VehicleToolRenderers } from "@danypops/vehicle-client-pi";
18
18
  import { renderVehicleCall, renderVehicleResult } from "@danypops/vehicle-client-pi/vehicle-render";
19
- import type { VehicleOperationDescriptor } from "@danypops/vehicle-core";
19
+ import type { JsonValue, VehicleOperationDescriptor } from "@danypops/vehicle-core";
20
20
  import type { Theme } from "@earendil-works/pi-coding-agent";
21
21
  import { type Component, Text, truncateToWidth } from "@earendil-works/pi-tui";
22
22
  import {
@@ -30,7 +30,20 @@ import {
30
30
  } from "malevich-tui-components";
31
31
  import { ArtifactCard, detailViewTheme, expandHint, measure, statusColor, statusGlyph } from "../tool-rendering/artifact-card.ts";
32
32
  import { ArtifactListCard } from "../tool-rendering/artifact-list.ts";
33
- import { type ArtifactFocusAnnotation, createArtifactDetails, createArtifactListDetails } from "../tool-rendering/render-model.ts";
33
+ import {
34
+ type ArtifactFocusAnnotation,
35
+ createArtifactDetails,
36
+ createArtifactListDetails,
37
+ createDiscussionDetails,
38
+ createExecutionPlanDetails,
39
+ createLeaseDetails,
40
+ createNoFocusDetails,
41
+ createPlaybookInvocationDetails,
42
+ createPlaybookMissingArgumentsDetails,
43
+ createPreviewDetails,
44
+ createTaskCompletionDetails,
45
+ parsePapyrusToolDetails,
46
+ } from "../tool-rendering/render-model.ts";
34
47
  import { recordRenderDiagnostic, shapeFingerprint } from "./render-diagnostics.ts";
35
48
 
36
49
  function isArtifact(value: unknown): value is Artifact {
@@ -244,6 +257,12 @@ interface DiscussionAndRoundsOutput {
244
257
  rounds: DiscussionRoundOutput[];
245
258
  }
246
259
 
260
+ /** What renderDiscussionAndRounds actually reads -- satisfied by both a raw Artifact (the live duck-typed output path) and the leaner projected ToolArtifactSummary (the typed-DTO path), with no cast needed at either call site. */
261
+ interface RenderableDiscussionParent {
262
+ title: string;
263
+ status: string;
264
+ }
265
+
247
266
  function isDiscussionAndRounds(value: unknown): value is DiscussionAndRoundsOutput {
248
267
  if (typeof value !== "object" || value === null) return false;
249
268
  const row = value as Record<string, unknown>;
@@ -277,7 +296,11 @@ function roundsSection(rounds: readonly DiscussionRoundOutput[]): DetailSection
277
296
  };
278
297
  }
279
298
 
280
- function renderDiscussionAndRounds(output: DiscussionAndRoundsOutput, theme: Theme, expanded: boolean): Component {
299
+ function renderDiscussionAndRounds(
300
+ output: { discussion: RenderableDiscussionParent; rounds: readonly DiscussionRoundOutput[] },
301
+ theme: Theme,
302
+ expanded: boolean,
303
+ ): Component {
281
304
  const discussion = output.discussion;
282
305
  return statelessComponent((width) => {
283
306
  const safeWidth = Math.max(1, width);
@@ -331,6 +354,16 @@ interface TaskCompletionOutput {
331
354
  blocked: TaskBlockageOutput[];
332
355
  }
333
356
 
357
+ /** What renderTaskCompletion actually reads -- satisfied by both the raw duck-typed TaskCompletionOutput and the leaner projected TaskCompletionToolDetails. */
358
+ interface RenderableTaskCompletion {
359
+ artifact: RenderableDiscussionParent;
360
+ gates: readonly { passed: boolean; output: string }[];
361
+ checklist: readonly TaskChecklistReviewOutput[];
362
+ completed: boolean;
363
+ focused?: RenderableDiscussionParent | null;
364
+ blocked: readonly { artifact: RenderableDiscussionParent; dependencyIds: readonly string[] }[];
365
+ }
366
+
334
367
  function isTaskCompletion(value: unknown): value is TaskCompletionOutput {
335
368
  if (typeof value !== "object" || value === null) return false;
336
369
  const row = value as Record<string, unknown>;
@@ -344,7 +377,7 @@ function isTaskCompletion(value: unknown): value is TaskCompletionOutput {
344
377
  );
345
378
  }
346
379
 
347
- function renderTaskCompletion(result: TaskCompletionOutput, theme: Theme, expanded: boolean): Component {
380
+ function renderTaskCompletion(result: RenderableTaskCompletion, theme: Theme, expanded: boolean): Component {
348
381
  const task = result.artifact;
349
382
  return statelessComponent((width) => {
350
383
  const safeWidth = Math.max(1, width);
@@ -384,6 +417,69 @@ function renderTaskCompletion(result: TaskCompletionOutput, theme: Theme, expand
384
417
  });
385
418
  }
386
419
 
420
+ /** tasks.claim/heartbeat_lease/release_lease/lease's own name-first view. Detected the same
421
+ * name-independent, shape-based way as the others in this file. */
422
+ interface TaskLeaseViewOutput {
423
+ taskName: string;
424
+ taskTitle: string;
425
+ owner: string;
426
+ token: string;
427
+ claimedAt: string;
428
+ leaseExpiresAt: string;
429
+ heartbeatAt?: string;
430
+ note?: string;
431
+ }
432
+
433
+ function isTaskLeaseView(value: unknown): value is TaskLeaseViewOutput {
434
+ if (typeof value !== "object" || value === null) return false;
435
+ const row = value as Record<string, unknown>;
436
+ return (
437
+ typeof row.taskName === "string" &&
438
+ typeof row.taskTitle === "string" &&
439
+ typeof row.owner === "string" &&
440
+ typeof row.token === "string" &&
441
+ typeof row.claimedAt === "string" &&
442
+ typeof row.leaseExpiresAt === "string" &&
443
+ (row.heartbeatAt === undefined || typeof row.heartbeatAt === "string") &&
444
+ (row.note === undefined || typeof row.note === "string")
445
+ );
446
+ }
447
+
448
+ /** Renders a lease's own safe fields -- never the raw token, which the model channel (not this persisted, human-facing one) carries for a later heartbeat/release call. */
449
+ function renderLease(
450
+ lease: {
451
+ taskName: string;
452
+ taskTitle: string;
453
+ owner: string;
454
+ claimedAt: string;
455
+ leaseExpiresAt: string;
456
+ heartbeatAt?: string;
457
+ note?: string;
458
+ },
459
+ theme: Theme,
460
+ ): Component {
461
+ return statelessComponent((width) => {
462
+ const fields: DetailField[] = [
463
+ { label: "Task", value: `${lease.taskName} \u2014 ${lease.taskTitle}` },
464
+ { label: "Owner", value: lease.owner },
465
+ { label: "Expires", value: lease.leaseExpiresAt },
466
+ ...(lease.heartbeatAt ? [{ label: "Last heartbeat", value: lease.heartbeatAt }] : []),
467
+ ...(lease.note ? [{ label: "Note", value: lease.note }] : []),
468
+ ];
469
+ return buildDetailLines(Math.max(1, width), { fields, alignFields: true, theme: detailViewTheme(theme), measure });
470
+ });
471
+ }
472
+
473
+ /** Deliberately does not catch: a value JSON.stringify can't serialize (e.g. a circular
474
+ * reference) has no safe textual fallback, so this propagates and the projector's own caller
475
+ * (invokeVehicleOperation) fails the whole call closed rather than persisting a placeholder --
476
+ * the same "never silently substitute raw/unsafe output" contract every other projection
477
+ * failure already carries. */
478
+ function boundedJsonPreview(value: unknown): string {
479
+ const text = JSON.stringify(value, null, 2);
480
+ return typeof text === "string" ? text : String(value);
481
+ }
482
+
387
483
  export function papyrusVehicleRenderers(descriptor: VehicleOperationDescriptor): VehicleToolRenderers {
388
484
  return {
389
485
  // Pure pass-through to the generic renderer -- the only reason this exists at all is
@@ -455,3 +551,105 @@ export function papyrusVehicleRenderers(descriptor: VehicleOperationDescriptor):
455
551
  },
456
552
  };
457
553
  }
554
+
555
+ /**
556
+ * Projects a raw Papyrus operation output into a bounded, versioned PapyrusToolDetails DTO
557
+ * before Pi ever persists it -- the seam papyrusVehicleRenderers' own renderResult never had
558
+ * (it only converts shape at render time, from whatever the legacy {vehicle, output} path
559
+ * already persisted verbatim, lease tokens and all). Every branch here mirrors the same
560
+ * shape-detection papyrusVehicleRenderers's own renderResult uses, so the two stay in lockstep;
561
+ * anything genuinely unmatched still becomes a real, bounded PreviewToolDetails rather than an
562
+ * unprojected raw passthrough -- the one requirement this whole seam exists to satisfy.
563
+ */
564
+ function projectPapyrusPresentation(descriptor: VehicleOperationDescriptor, output: unknown): JsonValue {
565
+ if (isArtifactArray(output)) return createArtifactListDetails(descriptor.name, output) as unknown as JsonValue;
566
+ if (isArtifact(output)) return createArtifactDetails(descriptor.name, output) as unknown as JsonValue;
567
+ if (isTaskFocus(output)) return createArtifactDetails(descriptor.name, output.artifact, focusAnnotation(output)) as unknown as JsonValue;
568
+ if (output === null && descriptor.name === "tasks.focused") return createNoFocusDetails(descriptor.name) as unknown as JsonValue;
569
+ if (isTaskExecutionPlan(output))
570
+ return createExecutionPlanDetails(descriptor.name, output.nodes, output.layers, output.cycleIds) as unknown as JsonValue;
571
+ if (isPlaybookInvocationResult(output)) {
572
+ return createPlaybookInvocationDetails(descriptor.name, {
573
+ playbookId: output.playbookId,
574
+ runId: output.runId,
575
+ created: output.created,
576
+ rootTaskIds: output.rootTaskIds,
577
+ entryTaskId: output.entryTaskId,
578
+ execution: output.execution,
579
+ }) as unknown as JsonValue;
580
+ }
581
+ if (isPlaybookMissingArguments(output)) {
582
+ return createPlaybookMissingArgumentsDetails(descriptor.name, output.playbookId, output.missingArguments) as unknown as JsonValue;
583
+ }
584
+ if (isDiscussionAndRounds(output))
585
+ return createDiscussionDetails(descriptor.name, output.rounds, output.discussion) as unknown as JsonValue;
586
+ if (isDiscussionRoundsOnly(output)) return createDiscussionDetails(descriptor.name, output.rounds) as unknown as JsonValue;
587
+ if (isDiscussionListOutput(output)) return createArtifactListDetails(descriptor.name, output.discussions) as unknown as JsonValue;
588
+ if (isTaskCompletion(output)) return createTaskCompletionDetails(descriptor.name, output) as unknown as JsonValue;
589
+ if (isTaskLeaseView(output)) return createLeaseDetails(descriptor.name, output) as unknown as JsonValue;
590
+ return createPreviewDetails(descriptor.name, descriptor.name, boundedJsonPreview(output)) as unknown as JsonValue;
591
+ }
592
+
593
+ function renderFromPapyrusPresentation(
594
+ presentation: NonNullable<ReturnType<typeof parsePapyrusToolDetails>>,
595
+ theme: Theme,
596
+ expanded: boolean,
597
+ ): Component {
598
+ switch (presentation.kind) {
599
+ case "artifact-list":
600
+ return new ArtifactListCard(presentation, theme, expanded);
601
+ case "artifact":
602
+ return new ArtifactCard(presentation, theme, expanded);
603
+ case "no-focus":
604
+ return renderNoFocusedTask(theme);
605
+ case "execution-plan":
606
+ return renderTaskExecutionPlan(presentation, theme, expanded);
607
+ case "playbook-invocation":
608
+ return renderPlaybookInvocationResult(presentation, theme, expanded);
609
+ case "playbook-missing-arguments":
610
+ return renderPlaybookMissingArguments(presentation, theme);
611
+ case "discussion":
612
+ return presentation.discussion
613
+ ? renderDiscussionAndRounds({ discussion: presentation.discussion, rounds: presentation.rounds }, theme, expanded)
614
+ : renderDiscussionRoundsOnly(presentation, theme);
615
+ case "task-completion":
616
+ return renderTaskCompletion(presentation, theme, expanded);
617
+ case "lease":
618
+ return renderLease(presentation, theme);
619
+ case "preview":
620
+ return new Text(theme.fg("toolOutput", presentation.content), 0, 0);
621
+ case "transition":
622
+ case "graph":
623
+ case "gate-run":
624
+ case "invocation":
625
+ case "error":
626
+ // Reachable only if a future caller starts producing these kinds through this seam
627
+ // (today's Papyrus Vehicle outputs never do) -- a bounded JSON preview is still a
628
+ // real, safe rendering rather than a crash.
629
+ return new Text(theme.fg("toolOutput", boundedJsonPreview(presentation)), 0, 0);
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Pairs the projector above with a renderResult that reads the already-projected,
635
+ * already-bounded `details.presentation` DTO instead of raw `details.output` -- the seam
636
+ * pi-papyrus task "project typed bounded render details before Vehicle persists" exists for.
637
+ * Falls back to papyrusVehicleRenderers' own renderResult (which still reads `details.output`)
638
+ * for a partial/progress update, an error result, or a historical session row persisted before
639
+ * this seam existed -- both keep working exactly as before, unchanged.
640
+ */
641
+ export function papyrusVehiclePresentations(descriptor: VehicleOperationDescriptor): PiVehiclePresentationContract {
642
+ return {
643
+ projector: {
644
+ maxBytes: TOOL_DETAILS_MAX_SERIALIZED_CHARACTERS,
645
+ project: (output: unknown, _request: PiVehicleInvocationRequest) => projectPapyrusPresentation(descriptor, output),
646
+ },
647
+ renderResult(result, options, theme, context) {
648
+ if (!options.isPartial && !context.isError) {
649
+ const presentation = parsePapyrusToolDetails((result.details as { presentation?: unknown } | undefined)?.presentation);
650
+ if (presentation) return renderFromPapyrusPresentation(presentation, theme, options.expanded);
651
+ }
652
+ return papyrusVehicleRenderers(descriptor).renderResult!(result, options, theme, context);
653
+ },
654
+ };
655
+ }
@@ -32,7 +32,7 @@ import { currentVehicleClientTarget } from "../service-client.ts";
32
32
  import { sessionSecretField } from "../session-identity.ts";
33
33
  import { emitTaskFocusEvent } from "../task/task-focus-events.ts";
34
34
  import { recordRenderDiagnostic, shapeFingerprint } from "./render-diagnostics.ts";
35
- import { papyrusVehicleRenderers } from "./vehicle-artifact-renderers.ts";
35
+ import { papyrusVehiclePresentations, papyrusVehicleRenderers } from "./vehicle-artifact-renderers.ts";
36
36
 
37
37
  const REGISTERED_PERMISSIONS = [
38
38
  "notes:read",
@@ -135,6 +135,12 @@ export function registerNotesVehicle(pi: ExtensionAPI): Promise<RegisteredPiVehi
135
135
  permissions: REGISTERED_PERMISSIONS,
136
136
  principal: { id: "pi-papyrus" },
137
137
  renderers: papyrusVehicleRenderers,
138
+ // papyrusVehiclePresentations projects a bounded, versioned PapyrusToolDetails DTO
139
+ // before Pi persists this call's details -- renderers above still supplies renderCall
140
+ // (createTool sources renderCall/renderResult independently; presentations' own
141
+ // renderResult takes priority over renderers' renderResult, and falls back to it for a
142
+ // partial/error result or a historical session row persisted before this seam existed).
143
+ presentations: papyrusVehiclePresentations,
138
144
  shell: { coreOperations: CORE_OPERATIONS },
139
145
  // playbooks.invoke's own module handler, and tasks.focus/pause/unpause/clear_focus's
140
146
  // own module handlers, authorize an internal Task Focus write via
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-papyrus",
3
- "version": "0.49.1",
3
+ "version": "0.50.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"],
@@ -20,7 +20,7 @@
20
20
  "@danypops/jittor": "^0.14.0",
21
21
  "@danypops/papyrus": "^0.47.0",
22
22
  "@danypops/vehicle-client": "^0.7.0",
23
- "@danypops/vehicle-client-pi": "^0.22.0",
23
+ "@danypops/vehicle-client-pi": "^0.22.1",
24
24
  "@danypops/vehicle-core": "^0.12.3",
25
25
  "@danypops/vehicle-server": "^0.18.1",
26
26
  "beautiful-mermaid": "1.1.3",
@@ -28,6 +28,7 @@
28
28
  },
29
29
  "devDependencies": {
30
30
  "@danypops/pi-tui-harness": "^0.0.2",
31
+ "@danypops/vehicle-conformance": "^0.3.0",
31
32
  "@earendil-works/pi-coding-agent": "^0.80.10",
32
33
  "bun-types": "latest",
33
34
  "typebox": "^1.3.6",