@danypops/pi-papyrus 0.46.1 → 0.46.4

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.
@@ -8,12 +8,15 @@ import {
8
8
  } from "@danypops/papyrus";
9
9
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
10
10
  import { matchesKey, sliceByColumn, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
11
- import { buildDetailLines, type DetailField, type DetailSection } from "malevich-tui-components";
11
+ import { buildDetailLines, type DetailField, type DetailSection, type TextMeasure } from "malevich-tui-components";
12
12
  import { BeautifulMermaidRenderer } from "../beautiful-mermaid-renderer.ts";
13
13
  import { type ActiveTheme, renderMarkdownBody } from "../markdown.ts";
14
14
  import { type ArtifactDetailContent, artifactDetailContent, artifactDetailsText } from "./artifact-detail-format.ts";
15
15
  import { buildArtifactRelationshipLines } from "./artifact-relationship-lines.ts";
16
16
 
17
+ /** Real ANSI-aware measure for buildDetailLines -- without it, wrapped themed text loses color on every line but the first/last. */
18
+ const measure: TextMeasure = { visibleWidth, truncateToWidth, wrapTextWithAnsi };
19
+
17
20
  interface ArtifactDetailLine {
18
21
  text: string;
19
22
  wide: boolean;
@@ -117,6 +120,7 @@ class ArtifactDetailViewport {
117
120
  body: (s) => theme.fg("text", s),
118
121
  line: (s) => theme.fg("dim", s),
119
122
  },
123
+ measure,
120
124
  }).map((text) => ({ text, wide: false }))
121
125
  : [];
122
126
  // buildDetailLines' fields/sections don't insert a leading blank before the
@@ -694,21 +694,17 @@ export default async function (pi: ExtensionAPI) {
694
694
  },
695
695
  });
696
696
 
697
+ // registerNotesVehicle defers the actual registerVehicleTools() call to session_start
698
+ // internally (via registerVehicleToolsWhenReady), since Pi's extension runtime only
699
+ // finishes initializing (pi.getAllTools()/etc. becoming callable) after every
700
+ // extension's top-level factory has resolved. Calling it here, at factory time, is
701
+ // therefore safe -- and deliberately not awaited, so a slow-starting daemon's bounded
702
+ // retries never block this factory or session_start itself.
703
+ void registerNotesVehicle(pi);
704
+
697
705
  // ── Task widget (TodoOverlay pattern: factory form, requestRender) ──
698
706
 
699
707
  pi.on("session_start", async (_event, ctx) => {
700
- // registerVehicleTools() (which registerNotesVehicle wraps) needs
701
- // pi.getAllTools()/getActiveTools()/setActiveTools() -- Pi's extension
702
- // runtime only finishes initializing after every extension's top-level
703
- // factory (this one included) has resolved, so calling it directly from
704
- // there throws "Extension runtime not initialized" (previously silently
705
- // swallowed by registerNotesVehicle's own daemon-unreachable try/catch,
706
- // making every projected notes.* tool invisible to the model with zero
707
- // visible sign why -- confirmed live in the identical pi-tickets bug).
708
- // session_start fires only after that initialization completes, and Pi
709
- // awaits every session_start handler before the model's first turn, so
710
- // registering here is both safe and still visible on turn one.
711
- await registerNotesVehicle(pi);
712
708
  // Registers this session's identity with the daemon as early as possible -- before any
713
709
  // Focus-mutating call could plausibly happen -- shrinking (not eliminating; see
714
710
  // domain/session-identity.ts) the first-touch race window. Best-effort: the daemon may be
@@ -36,8 +36,12 @@ export async function showRules(ctx: ExtensionCommandContext): Promise<void> {
36
36
  const updated = await callService<Record<string, unknown>, Artifact>("rules.update", { id: rule.id, title, body });
37
37
  commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
38
38
  } else if (choice === "Preview injection") {
39
- const preview = await callService<Record<string, unknown>, string>("rules.preview", { id: rule.id });
40
- commandCtx.ui.notify(preview, "info");
39
+ const result = await callService<Record<string, unknown>, { preview: string; combinedLength: number; warning?: string }>(
40
+ "rules.preview",
41
+ { id: rule.id },
42
+ );
43
+ const text = result.warning === undefined ? result.preview : `${result.preview}\n\n⚠ ${result.warning}`;
44
+ commandCtx.ui.notify(text, result.warning === undefined ? "info" : "warning");
41
45
  } else if (choice === "Link gated task") {
42
46
  const taskId = await commandCtx.ui.input("Task artifact id:", "");
43
47
  if (taskId) await callService("rules.gate", { id: rule.id, task_id: taskId });
@@ -11,12 +11,15 @@ import {
11
11
  } from "@danypops/papyrus";
12
12
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
13
  import { matchesKey, sliceByColumn, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
14
- import { buildDetailLines, type DetailField, type DetailSection } from "malevich-tui-components";
14
+ import { buildDetailLines, type DetailField, type DetailSection, type TextMeasure } from "malevich-tui-components";
15
15
  import { BeautifulMermaidRenderer } from "../beautiful-mermaid-renderer.ts";
16
16
  import { type ActiveTheme, renderMarkdownBody } from "../markdown.ts";
17
17
  import { type TaskDetailContent, taskDetailContent, taskDetailsText } from "./task-detail-format.ts";
18
18
  import { TASK_STATUS_PRESENTATION } from "./task-presentation.ts";
19
19
 
20
+ /** Real ANSI-aware measure for buildDetailLines -- without it, wrapped themed text loses color on every line but the first/last. */
21
+ const measure: TextMeasure = { visibleWidth, truncateToWidth, wrapTextWithAnsi };
22
+
20
23
  interface DetailLine {
21
24
  text: string;
22
25
  graph: boolean;
@@ -122,12 +125,15 @@ class TaskDetailViewport {
122
125
  const fields: DetailField[] = this.content.labels.length > 0 ? [{ label: "Labels", value: this.content.labels.join(", ") }] : [];
123
126
  const labels =
124
127
  fields.length > 0
125
- ? [...buildDetailLines(width, { fields, theme: detailTheme }).map((text) => ({ text, graph: false })), { text: "", graph: false }]
128
+ ? [
129
+ ...buildDetailLines(width, { fields, theme: detailTheme, measure }).map((text) => ({ text, graph: false })),
130
+ { text: "", graph: false },
131
+ ]
126
132
  : [{ text: "", graph: false }];
127
133
  const body = renderMarkdownBody(this.content.body, width, this.activeTheme).map((text) => ({ text, graph: false }));
128
134
  const sections: DetailSection[] = this.content.sections.map((section) => ({ heading: section[0], lines: section.slice(1) }));
129
135
  const sectionLines =
130
- sections.length > 0 ? buildDetailLines(width, { sections, theme: detailTheme }).map((text) => ({ text, graph: false })) : [];
136
+ sections.length > 0 ? buildDetailLines(width, { sections, theme: detailTheme, measure }).map((text) => ({ text, graph: false })) : [];
131
137
  const relationshipHeader =
132
138
  this.graphLines.length > 0
133
139
  ? [{ text: "", graph: false }, ...wrap("Relationships:", "muted"), ...wrap(" Dependencies point prerequisite → dependent.", "dim")]
@@ -13,7 +13,7 @@ import type { ArtifactToolDetails } from "./render-model.ts";
13
13
  * terminal's default color. wrapTextWithAnsi re-injects the active codes on every wrapped
14
14
  * line instead.
15
15
  */
16
- const measure: TextMeasure = { visibleWidth, truncateToWidth, wrapTextWithAnsi };
16
+ export const measure: TextMeasure = { visibleWidth, truncateToWidth, wrapTextWithAnsi };
17
17
 
18
18
  /** Shared by every buildDetailLines caller in this extension (ArtifactCard, and
19
19
  * tools/vehicle-artifact-renderers.ts's discuss/tasks.complete renderers) -- one Theme -> DetailViewTheme
@@ -19,8 +19,16 @@ import { renderVehicleResult } from "@danypops/vehicle-client-pi/vehicle-render"
19
19
  import type { 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
- import { buildDetailLines, type DagEdge, type DagNode, DagView, type DetailField, type DetailSection } from "malevich-tui-components";
23
- import { ArtifactCard, detailViewTheme, expandHint, statusColor, statusGlyph } from "../tool-rendering/artifact-card.ts";
22
+ import {
23
+ buildDetailLines,
24
+ type DagEdge,
25
+ type DagNode,
26
+ DagView,
27
+ type DetailField,
28
+ type DetailSection,
29
+ statelessComponent,
30
+ } from "malevich-tui-components";
31
+ import { ArtifactCard, detailViewTheme, expandHint, measure, statusColor, statusGlyph } from "../tool-rendering/artifact-card.ts";
24
32
  import { ArtifactListCard } from "../tool-rendering/artifact-list.ts";
25
33
  import { type ArtifactFocusAnnotation, createArtifactDetails, createArtifactListDetails } from "../tool-rendering/render-model.ts";
26
34
 
@@ -268,12 +276,6 @@ function roundsSection(rounds: readonly DiscussionRoundOutput[]): DetailSection
268
276
  };
269
277
  }
270
278
 
271
- /** A one-shot render function with nothing to invalidate -- every buildDetailLines-based
272
- * renderer in this file (unlike ArtifactCard/DagView) has no cache to clear. */
273
- function statelessComponent(render: (width: number) => string[]): Component {
274
- return { render, invalidate: () => {} };
275
- }
276
-
277
279
  function renderDiscussionAndRounds(output: DiscussionAndRoundsOutput, theme: Theme, expanded: boolean): Component {
278
280
  const discussion = output.discussion;
279
281
  return statelessComponent((width) => {
@@ -283,7 +285,7 @@ function renderDiscussionAndRounds(output: DiscussionAndRoundsOutput, theme: The
283
285
  { label: "Status", value: theme.fg(statusColor(discussion.status), `${statusGlyph(discussion.status)} ${discussion.status}`) },
284
286
  ];
285
287
  const sections: DetailSection[] = expanded && output.rounds.length > 0 ? [roundsSection(output.rounds)] : [];
286
- const lines = buildDetailLines(safeWidth, { fields, sections, alignFields: true, theme: detailViewTheme(theme) });
288
+ const lines = buildDetailLines(safeWidth, { fields, sections, alignFields: true, theme: detailViewTheme(theme), measure });
287
289
  if (!expanded && output.rounds.length > 0) {
288
290
  const count = output.rounds.length;
289
291
  lines.push(truncateToWidth(theme.fg("dim", `${count} round${count === 1 ? "" : "s"} · ${expandHint()}`), safeWidth));
@@ -295,7 +297,7 @@ function renderDiscussionAndRounds(output: DiscussionAndRoundsOutput, theme: The
295
297
  function renderDiscussionRoundsOnly(output: DiscussionRoundsOnlyOutput, theme: Theme): Component {
296
298
  return statelessComponent((width) => {
297
299
  const sections: DetailSection[] = output.rounds.length > 0 ? [roundsSection(output.rounds)] : [{ lines: ["No rounds."] }];
298
- return buildDetailLines(Math.max(1, width), { sections, theme: detailViewTheme(theme) });
300
+ return buildDetailLines(Math.max(1, width), { sections, theme: detailViewTheme(theme), measure });
299
301
  });
300
302
  }
301
303
 
@@ -373,7 +375,7 @@ function renderTaskCompletion(result: TaskCompletionOutput, theme: Theme, expand
373
375
  lines: result.blocked.map((entry) => theme.fg("warning", `◼ ${entry.artifact.title}`)),
374
376
  });
375
377
  }
376
- const lines = buildDetailLines(safeWidth, { fields, sections, alignFields: true, theme: detailViewTheme(theme) });
378
+ const lines = buildDetailLines(safeWidth, { fields, sections, alignFields: true, theme: detailViewTheme(theme), measure });
377
379
  if (result.focused && expanded) {
378
380
  lines.push(truncateToWidth(theme.fg("accent", `▶ focus ${result.focused.title}`), safeWidth));
379
381
  }
@@ -4,25 +4,28 @@
4
4
  * src/handlers/registry.ts. discuss.* is the last of the six domains to
5
5
  * migrate off pi-papyrus's own retired hand-rolled pi.registerTool() mega-tool.
6
6
  *
7
- * Fails silently on a stale/unreachable daemon handle instead of aborting extension
8
- * setup: Papyrus's daemon doesn't auto-spawn, and a tool that failed to register
9
- * here has no later retry path.
7
+ * Deferred to registerVehicleToolsWhenReady's own internal session_start handler
8
+ * (bounded retry/backoff, matching pi-tickets' registerTicketsVehicle) rather than
9
+ * a single unretried attempt: a daemon that's merely slow to start, or transiently
10
+ * unreachable right when session_start fires (including on /reload, which re-runs
11
+ * this extension's factory and this call), no longer permanently drops every
12
+ * notes/rules/docs/playbooks/tasks/discuss/artifact tool for the rest of the
13
+ * session. Every outcome logs through ctx.ui.notify instead of vanishing.
10
14
  *
11
15
  * Uses service-client.ts's currentVehicleClientTarget() (test-injectable) rather
12
16
  * than resolveVehicleClientTarget() directly, so a test exercising the full
13
17
  * extension entrypoint doesn't resolve a real daemonStateDir().
14
18
  *
15
- * The client itself is wrapped in createReconnectingVehicleClient(), re-resolving
19
+ * The client itself is wrapped in createReconnectingVehicleClient() once, re-resolving
16
20
  * currentVehicleClientTarget() on every reconnect attempt rather than closing over
17
- * one target captured here at session_start -- confirmed live: the daemon rebinds
18
- * a new random port on every restart, and a bare RemoteVehicleClient built once had
19
- * no way to notice its baseUrl had died, breaking every Vehicle tool call for the
20
- * rest of the Pi session until a full extension reload.
21
+ * one target captured here -- the daemon rebinds a new random port on every restart,
22
+ * so a bare RemoteVehicleClient built once would have no way to notice its baseUrl
23
+ * had died.
21
24
  */
22
25
 
23
26
  import { createReconnectingVehicleClient } from "@danypops/vehicle-client/daemon-client";
24
27
  import { RemoteVehicleClient } from "@danypops/vehicle-client/http";
25
- import { registerVehicleTools } from "@danypops/vehicle-client-pi";
28
+ import { type RegisteredPiVehicle, registerVehicleToolsWhenReady, type VehicleReadyEvent } from "@danypops/vehicle-client-pi";
26
29
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
30
  import { discussLiveFollowUp } from "../discuss/discuss-live-follow-up.ts";
28
31
  import { currentVehicleClientTarget } from "../service-client.ts";
@@ -59,82 +62,116 @@ const FOCUS_MUTATION_OPERATIONS = new Set(["tasks.focus", "tasks.pause", "tasks.
59
62
  */
60
63
  const CORE_OPERATIONS = ["tasks.list", "tasks.create", "tasks.start", "tasks.submit", "tasks.complete", "tasks.context"];
61
64
 
62
- export async function registerNotesVehicle(pi: ExtensionAPI): Promise<void> {
63
- const target = currentVehicleClientTarget();
64
- if (!target) return;
65
- try {
66
- const client = createReconnectingVehicleClient(async () => {
67
- const resolved = currentVehicleClientTarget();
68
- if (!resolved) throw new Error("Papyrus daemon is not running");
69
- return new RemoteVehicleClient({ baseUrl: resolved.baseUrl, token: resolved.token });
70
- });
71
- await registerVehicleTools(pi, client, {
72
- permissions: REGISTERED_PERMISSIONS,
73
- principal: { id: "pi-papyrus" },
74
- renderers: papyrusVehicleRenderers,
75
- shell: { coreOperations: CORE_OPERATIONS },
76
- // playbooks.invoke's own module handler, and tasks.focus/pause/unpause/clear_focus's
77
- // own module handlers, authorize an internal Task Focus write via
78
- // sessionIdentity.assertAuthorized(session_id, session_secret) -- see
79
- // @danypops/papyrus's src/handlers/playbooks.ts and tasks.ts. That
80
- // secret must never be a model-visible input field (the model has no business
81
- // knowing or supplying it), so it travels here instead, in principal.claims, from
82
- // this extension's own already-cached secret (registered at session_start -- see
83
- // index.ts) -- the same value sessionSecretField() used to thread through as a raw
84
- // RPC input field before these operations moved onto Vehicle.
85
- resolveInvocation: ({ descriptor, input, context }) => {
86
- if (descriptor.name !== "playbooks.invoke" && !FOCUS_MUTATION_OPERATIONS.has(descriptor.name)) return {};
87
- // tasks.* defaults session_id to this Pi session's own id, same as the removed
88
- // hand-rolled tool -- but the secret cache is keyed by whichever session_id is
89
- // actually being authorized, not blindly this session's, so a model that
90
- // explicitly overrides session_id to a DIFFERENT session never gets this
91
- // session's secret smuggled in on its behalf.
92
- const requestedSessionId = (input as { session_id?: unknown } | undefined)?.session_id;
93
- const sessionId =
94
- typeof requestedSessionId === "string" && requestedSessionId.length > 0
95
- ? requestedSessionId
96
- : context.sessionManager.getSessionId();
97
- const { session_secret: sessionSecret } = sessionSecretField(sessionId);
98
- // Omit sessionSecret entirely when nothing is cached (unregistered session) --
99
- // {sessionSecret: null} would fail the module's own optionalString(input,
100
- // "session_secret") check (undefined-or-string, not null), a real regression from
101
- // sessionSecretField()'s own {} (key omitted) return for the same case.
102
- const claims: Record<string, string> = sessionSecret ? { sessionId, sessionSecret } : { sessionId };
103
- return { principal: { id: "pi-papyrus", claims } };
104
- },
105
- // papyrus.task-focus.v1 is a same-process Pi extension event bus broadcast (e.g. a
106
- // token-cost router correlating its own telemetry with the currently focused task)
107
- // -- has no Vehicle-transport equivalent, so it's emitted here, client-side, rather
108
- // than from the operation's own output.
109
- // discuss.open/discuss.reply's own live:true synchronous human round-trip --
110
- // see discuss/discuss-live-follow-up.ts. Every other operation's resolver call
111
- // returns undefined, meaning zero behavior change for the other 5 domains.
112
- interactiveFollowUps: (descriptor) =>
113
- descriptor.name === "discuss.open" || descriptor.name === "discuss.reply" ? discussLiveFollowUp : undefined,
114
- // The retired discuss tool declared executionMode: "sequential" so the model
115
- // couldn't batch a live ask alongside other tool calls in the same turn and
116
- // let those run before the human sees the prompt -- same reasoning here.
117
- executionMode: (descriptor) => (descriptor.name === "discuss.open" || descriptor.name === "discuss.reply" ? "sequential" : undefined),
118
- onInvoked: ({ descriptor }, output) => {
119
- if (descriptor.name === "tasks.focus") {
120
- const artifact = output as { id: string } | undefined;
121
- if (artifact?.id) emitTaskFocusEvent({ taskId: artifact.id, status: "focused" });
122
- return;
123
- }
124
- if (descriptor.name === "tasks.pause" || descriptor.name === "tasks.unpause") {
125
- const focus = output as { artifact: { id: string } } | undefined;
126
- if (focus?.artifact?.id)
127
- emitTaskFocusEvent({ taskId: focus.artifact.id, status: descriptor.name === "tasks.pause" ? "paused" : "unpaused" });
128
- return;
129
- }
130
- if (descriptor.name === "tasks.clear_focus") {
131
- const result = output as { cleared: boolean } | undefined;
132
- if (result?.cleared) emitTaskFocusEvent({ taskId: null, status: "cleared" });
133
- }
134
- },
135
- });
136
- } catch {
137
- // Daemon state is stale/unreachable -- degrade silently, matching
138
- // subscribeTaskPushChannel's own tolerance for the same condition.
65
+ function errorMessage(error: unknown): string {
66
+ return error instanceof Error ? error.message : String(error);
67
+ }
68
+
69
+ /**
70
+ * Surfaces a real resolution/registration error and the terminal exhausted state (the
71
+ * case that used to leave every notes/rules/docs/playbooks/tasks/discuss/artifact tool
72
+ * unregistered for the whole session with no visible sign why) -- a daemon merely still
73
+ * starting up (repeated client-unavailable before the last attempt) stays quiet, matching
74
+ * pi-tickets' own notifyReadyEvent.
75
+ */
76
+ function notifyReadyEvent(event: VehicleReadyEvent): void {
77
+ switch (event.kind) {
78
+ case "client-resolution-failed":
79
+ event.ctx.ui.notify(`papyrus daemon target resolution failed: ${errorMessage(event.error)}`, "warning");
80
+ return;
81
+ case "registration-failed":
82
+ event.ctx.ui.notify(`papyrus tool registration failed: ${errorMessage(event.error)}`, "warning");
83
+ return;
84
+ case "exhausted":
85
+ event.ctx.ui.notify(
86
+ `papyrus tools unavailable this session -- the daemon never became reachable after ${event.attempts} attempts`,
87
+ "warning",
88
+ );
89
+ return;
90
+ case "client-unavailable":
91
+ case "registered":
92
+ return;
139
93
  }
140
94
  }
95
+
96
+ /**
97
+ * Fire-and-forget from the extension's top-level factory: registerVehicleToolsWhenReady
98
+ * registers its own session_start handler internally and defers the actual
99
+ * pi.getAllTools()/getActiveTools()/setActiveTools() calls to it (Pi's extension runtime
100
+ * only finishes initializing after every extension's factory has resolved, so calling
101
+ * registerVehicleTools directly from here throws "Extension runtime not initialized").
102
+ * The returned promise settles once that sequence succeeds or exhausts its attempts --
103
+ * awaiting it is optional and mainly useful for tests.
104
+ */
105
+ export function registerNotesVehicle(pi: ExtensionAPI): Promise<RegisteredPiVehicle | undefined> {
106
+ const client = createReconnectingVehicleClient(async () => {
107
+ const resolved = currentVehicleClientTarget();
108
+ if (!resolved) throw new Error("Papyrus daemon is not running");
109
+ return new RemoteVehicleClient({ baseUrl: resolved.baseUrl, token: resolved.token });
110
+ });
111
+ return registerVehicleToolsWhenReady(pi, () => Promise.resolve(currentVehicleClientTarget() ? client : undefined), {
112
+ log: notifyReadyEvent,
113
+ permissions: REGISTERED_PERMISSIONS,
114
+ principal: { id: "pi-papyrus" },
115
+ renderers: papyrusVehicleRenderers,
116
+ shell: { coreOperations: CORE_OPERATIONS },
117
+ // playbooks.invoke's own module handler, and tasks.focus/pause/unpause/clear_focus's
118
+ // own module handlers, authorize an internal Task Focus write via
119
+ // sessionIdentity.assertAuthorized(session_id, session_secret) -- see
120
+ // @danypops/papyrus's src/handlers/playbooks.ts and tasks.ts. That
121
+ // secret must never be a model-visible input field (the model has no business
122
+ // knowing or supplying it), so it travels here instead, in principal.claims, from
123
+ // this extension's own already-cached secret (registered at session_start -- see
124
+ // index.ts) -- the same value sessionSecretField() used to thread through as a raw
125
+ // RPC input field before these operations moved onto Vehicle.
126
+ resolveInvocation: ({ descriptor, input, context }) => {
127
+ if (descriptor.name !== "playbooks.invoke" && !FOCUS_MUTATION_OPERATIONS.has(descriptor.name)) return {};
128
+ // tasks.* defaults session_id to this Pi session's own id, same as the removed
129
+ // hand-rolled tool -- but the secret cache is keyed by whichever session_id is
130
+ // actually being authorized, not blindly this session's, so a model that
131
+ // explicitly overrides session_id to a DIFFERENT session never gets this
132
+ // session's secret smuggled in on its behalf.
133
+ const requestedSessionId = (input as { session_id?: unknown } | undefined)?.session_id;
134
+ const sessionId =
135
+ typeof requestedSessionId === "string" && requestedSessionId.length > 0
136
+ ? requestedSessionId
137
+ : context.sessionManager.getSessionId();
138
+ const { session_secret: sessionSecret } = sessionSecretField(sessionId);
139
+ // Omit sessionSecret entirely when nothing is cached (unregistered session) --
140
+ // {sessionSecret: null} would fail the module's own optionalString(input,
141
+ // "session_secret") check (undefined-or-string, not null), a real regression from
142
+ // sessionSecretField()'s own {} (key omitted) return for the same case.
143
+ const claims: Record<string, string> = sessionSecret ? { sessionId, sessionSecret } : { sessionId };
144
+ return { principal: { id: "pi-papyrus", claims } };
145
+ },
146
+ // papyrus.task-focus.v1 is a same-process Pi extension event bus broadcast (e.g. a
147
+ // token-cost router correlating its own telemetry with the currently focused task)
148
+ // -- has no Vehicle-transport equivalent, so it's emitted here, client-side, rather
149
+ // than from the operation's own output.
150
+ // discuss.open/discuss.reply's own live:true synchronous human round-trip --
151
+ // see discuss/discuss-live-follow-up.ts. Every other operation's resolver call
152
+ // returns undefined, meaning zero behavior change for the other 5 domains.
153
+ interactiveFollowUps: (descriptor) =>
154
+ descriptor.name === "discuss.open" || descriptor.name === "discuss.reply" ? discussLiveFollowUp : undefined,
155
+ // The retired discuss tool declared executionMode: "sequential" so the model
156
+ // couldn't batch a live ask alongside other tool calls in the same turn and
157
+ // let those run before the human sees the prompt -- same reasoning here.
158
+ executionMode: (descriptor) => (descriptor.name === "discuss.open" || descriptor.name === "discuss.reply" ? "sequential" : undefined),
159
+ onInvoked: ({ descriptor }, output) => {
160
+ if (descriptor.name === "tasks.focus") {
161
+ const artifact = output as { id: string } | undefined;
162
+ if (artifact?.id) emitTaskFocusEvent({ taskId: artifact.id, status: "focused" });
163
+ return;
164
+ }
165
+ if (descriptor.name === "tasks.pause" || descriptor.name === "tasks.unpause") {
166
+ const focus = output as { artifact: { id: string } } | undefined;
167
+ if (focus?.artifact?.id)
168
+ emitTaskFocusEvent({ taskId: focus.artifact.id, status: descriptor.name === "tasks.pause" ? "paused" : "unpaused" });
169
+ return;
170
+ }
171
+ if (descriptor.name === "tasks.clear_focus") {
172
+ const result = output as { cleared: boolean } | undefined;
173
+ if (result?.cleared) emitTaskFocusEvent({ taskId: null, status: "cleared" });
174
+ }
175
+ },
176
+ });
177
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-papyrus",
3
- "version": "0.46.1",
3
+ "version": "0.46.4",
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,13 +18,13 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@danypops/jittor": "^0.14.0",
21
- "@danypops/papyrus": "^0.44.0",
21
+ "@danypops/papyrus": "^0.45.2",
22
22
  "@danypops/vehicle-client": "^0.5.0",
23
- "@danypops/vehicle-client-pi": "^0.18.3",
23
+ "@danypops/vehicle-client-pi": "^0.18.4",
24
24
  "@danypops/vehicle-core": "^0.12.3",
25
25
  "@danypops/vehicle-server": "^0.18.1",
26
26
  "beautiful-mermaid": "1.1.3",
27
- "malevich-tui-components": "^0.21.2"
27
+ "malevich-tui-components": "^0.24.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@danypops/pi-tui-harness": "^0.0.2",