@danypops/pi-papyrus 0.58.0 → 0.59.1

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.
@@ -42,7 +42,7 @@ import { ensureTypingCourtesyTracking, isLiveAskPending } from "./discuss/discus
42
42
  import { resolveNameFields } from "./domain-tools.ts";
43
43
  import { buildNoteWidgetSection, type NoteWidgetRow } from "./note/note-widget.ts";
44
44
  import { PLAYBOOK_BRIDGE_MAX_PLAYBOOKS, registerPlaybookBridge } from "./playbook/playbook-bridge.ts";
45
- import { callService, subscribeTaskPushChannel } from "./service-client.ts";
45
+ import { callService, callServicePassive, subscribeTaskPushChannel } from "./service-client.ts";
46
46
  import { cacheSessionSecret, forgetSessionSecret, sessionSecretField } from "./session-identity.ts";
47
47
  import {
48
48
  ActiveTaskContinuation,
@@ -154,6 +154,7 @@ export class TaskOverlay {
154
154
  private snapshot: TaskGraph = { nodes: [], rootIds: [] };
155
155
  private projectRoot: string | undefined;
156
156
  private sessionId: string | undefined;
157
+ private generation = 0;
157
158
  private readonly poll = new BoundedPoll();
158
159
  private pushChannel: PushChannelClient | undefined;
159
160
  private readonly rotation = new AutoRotatingWindow({
@@ -167,11 +168,13 @@ export class TaskOverlay {
167
168
  }
168
169
 
169
170
  setProjectRoot(projectRoot: string): void {
171
+ if (projectRoot !== this.projectRoot) this.generation++;
170
172
  this.projectRoot = projectRoot;
171
173
  }
172
174
  // Scopes the widget's "active" glyph to this Pi session's own Focus, so a second
173
175
  // concurrent agent's focused task never shows as active in this session's widget.
174
176
  setSessionId(sessionId: string): void {
177
+ if (sessionId !== this.sessionId) this.generation++;
175
178
  this.sessionId = sessionId;
176
179
  }
177
180
 
@@ -183,15 +186,20 @@ export class TaskOverlay {
183
186
  */
184
187
  async refresh(): Promise<void> {
185
188
  if (!this.projectRoot) return;
189
+ const generation = this.generation;
190
+ const sessionId = this.sessionId;
191
+ let snapshot: TaskGraph;
186
192
  try {
187
- this.snapshot = await callService<Record<string, unknown>, TaskGraph>("tasks.graph", {
193
+ snapshot = await callServicePassive<Record<string, unknown>, TaskGraph>("tasks.graph", {
188
194
  limit: 500,
189
195
  project_root: this.projectRoot,
190
- session_id: this.sessionId,
196
+ session_id: sessionId,
191
197
  });
192
198
  } catch {
193
- this.snapshot = { nodes: [], rootIds: [] };
199
+ snapshot = { nodes: [], rootIds: [] };
194
200
  }
201
+ if (generation !== this.generation) return;
202
+ this.snapshot = snapshot;
195
203
  try {
196
204
  this.widgetGroup?.requestUpdate();
197
205
  } catch {
@@ -240,6 +248,7 @@ export class TaskOverlay {
240
248
  }
241
249
 
242
250
  dispose(): void {
251
+ this.generation++;
243
252
  this.stopPolling();
244
253
  this.pushChannel?.close();
245
254
  this.pushChannel = undefined;
@@ -259,6 +268,7 @@ export class NoteOverlay {
259
268
  private notes: NoteWidgetRow[] = [];
260
269
  private totalOpenCount = 0;
261
270
  private projectRoot: string | undefined;
271
+ private generation = 0;
262
272
  private readonly poll = new BoundedPoll();
263
273
  private readonly rotation = new AutoRotatingWindow({
264
274
  totalRows: 0,
@@ -271,22 +281,29 @@ export class NoteOverlay {
271
281
  }
272
282
 
273
283
  setProjectRoot(projectRoot: string): void {
284
+ if (projectRoot !== this.projectRoot) this.generation++;
274
285
  this.projectRoot = projectRoot;
275
286
  }
276
287
 
277
288
  async refresh(): Promise<void> {
278
289
  if (!this.projectRoot) return;
290
+ const generation = this.generation;
291
+ const projectRoot = this.projectRoot;
292
+ let notes: NoteWidgetRow[] = [];
293
+ let totalOpenCount = 0;
279
294
  try {
280
- const rows = await callService<Record<string, unknown>, Artifact[]>("notes.list", {
281
- project_root: this.projectRoot,
295
+ const rows = await callServicePassive<Record<string, unknown>, Artifact[]>("notes.list", {
296
+ project_root: projectRoot,
282
297
  limit: NOTE_LIST_MAX_LIMIT,
283
298
  });
284
- this.totalOpenCount = rows.length;
285
- this.notes = rows.slice(0, NOTE_WIDGET_OPEN_LIMIT).map((row) => ({ id: row.id, title: row.title }));
299
+ totalOpenCount = rows.length;
300
+ notes = rows.slice(0, NOTE_WIDGET_OPEN_LIMIT).map((row) => ({ id: row.id, title: row.title }));
286
301
  } catch {
287
- this.totalOpenCount = 0;
288
- this.notes = [];
302
+ // A missing daemon is the normal passive-startup case.
289
303
  }
304
+ if (generation !== this.generation) return;
305
+ this.totalOpenCount = totalOpenCount;
306
+ this.notes = notes;
290
307
  try {
291
308
  this.widgetGroup?.requestUpdate();
292
309
  } catch {
@@ -315,6 +332,7 @@ export class NoteOverlay {
315
332
  }
316
333
 
317
334
  dispose(): void {
335
+ this.generation++;
318
336
  this.stopPolling();
319
337
  this.widgetGroup = undefined;
320
338
  this.projectRoot = undefined;
@@ -441,7 +459,7 @@ export class PapyrusWidgetGroup {
441
459
  // Entry point
442
460
  // ---------------------------------------------------------------------------
443
461
 
444
- export default async function (pi: ExtensionAPI) {
462
+ export default function (pi: ExtensionAPI) {
445
463
  setTaskFocusEventBus(pi);
446
464
  registerPlaybookBridge(pi);
447
465
  let contextInjectionSequence = 0;
@@ -492,7 +510,12 @@ export default async function (pi: ExtensionAPI) {
492
510
  session_id: sessionId,
493
511
  ...sessionSecretField(sessionId),
494
512
  });
495
- emitTaskFocusEvent({ taskId: paused.artifact.id, sessionId, status: "paused", effort: extractDeclaredEffort(paused.artifact.extra) });
513
+ emitTaskFocusEvent({
514
+ taskId: paused.artifact.id,
515
+ sessionId,
516
+ status: "paused",
517
+ effort: extractDeclaredEffort(paused.artifact.extra),
518
+ });
496
519
  if (ctx.hasUI) ctx.ui.notify(`Papyrus task driving paused: ${decision.reason}. Human input resumes it automatically.`, "warning");
497
520
  }
498
521
  } catch {
@@ -717,15 +740,25 @@ export default async function (pi: ExtensionAPI) {
717
740
 
718
741
  // ── Interactive artifact browsers ──────────────────────────────────
719
742
 
720
- // Lazy imports keep TUI components out of non-interactive startup paths.
721
- const [tasksModule, docsModule, notesModule, rulesModule, playbooksModule, discussModule] = await Promise.all([
722
- import("./task/tasks.ts"),
723
- import("./docs/docs.ts"),
724
- import("./note/notes.ts"),
725
- import("./rules/rules.ts"),
726
- import("./playbook/playbooks.ts"),
727
- import("./discuss/discuss.ts"),
728
- ]);
743
+ // Command modules are loaded on first use. Registration itself stays synchronous,
744
+ // so interactive-only TUI code cannot delay extension startup.
745
+ const loadTasks = () => import("./task/tasks.ts");
746
+ const loadDocs = () => import("./docs/docs.ts");
747
+ const loadNotes = () => import("./note/notes.ts");
748
+ const loadRules = () => import("./rules/rules.ts");
749
+ let loadedPlaybooks: Awaited<ReturnType<typeof importPlaybooks>> | undefined;
750
+ let playbooksPromise: ReturnType<typeof importPlaybooks> | undefined;
751
+ function importPlaybooks() {
752
+ return import("./playbook/playbooks.ts");
753
+ }
754
+ function loadPlaybooks() {
755
+ playbooksPromise ??= importPlaybooks().then((module) => {
756
+ loadedPlaybooks = module;
757
+ return module;
758
+ });
759
+ return playbooksPromise;
760
+ }
761
+ const loadDiscuss = () => import("./discuss/discuss.ts");
729
762
  let overlay: TaskOverlay | undefined;
730
763
  let noteOverlay: NoteOverlay | undefined;
731
764
  let widgetGroup: PapyrusWidgetGroup | undefined;
@@ -735,20 +768,20 @@ export default async function (pi: ExtensionAPI) {
735
768
  handler: async (_args, ctx) => {
736
769
  overlay?.setProjectRoot(ctx.cwd);
737
770
  overlay?.setSessionId(ctx.sessionManager.getSessionId());
738
- await tasksModule.showTasks(ctx);
771
+ await (await loadTasks()).showTasks(ctx);
739
772
  await overlay?.refresh();
740
773
  },
741
774
  });
742
775
  pi.registerCommand("docs", {
743
776
  description: "Browse and manage Papyrus documents (interactive)",
744
777
  handler: async (_args, ctx) => {
745
- await docsModule.showDocs(ctx);
778
+ await (await loadDocs()).showDocs(ctx);
746
779
  },
747
780
  });
748
781
  pi.registerCommand("note", {
749
782
  description: "Capture a deferred request directly in Papyrus",
750
783
  handler: async (args, ctx) => {
751
- await notesModule.captureNote(args, ctx);
784
+ await (await loadNotes()).captureNote(args, ctx);
752
785
  await noteOverlay?.refresh();
753
786
  },
754
787
  });
@@ -756,34 +789,38 @@ export default async function (pi: ExtensionAPI) {
756
789
  description: "Browse and triage the project Notes inbox",
757
790
  handler: async (_args, ctx) => {
758
791
  noteOverlay?.setProjectRoot(ctx.cwd);
759
- await notesModule.showNotes(ctx);
792
+ await (await loadNotes()).showNotes(ctx);
760
793
  await noteOverlay?.refresh();
761
794
  },
762
795
  });
763
796
  pi.registerCommand("rules", {
764
797
  description: "Browse, preview, and toggle Papyrus rules (interactive)",
765
798
  handler: async (_args, ctx) => {
766
- await rulesModule.showRules(ctx);
799
+ await (await loadRules()).showRules(ctx);
767
800
  },
768
801
  });
769
802
  pi.registerCommand("playbooks", {
770
803
  description: "Browse, edit, and invoke Papyrus playbooks -- trigger/steps guidance an agent reads and follows (interactive)",
771
804
  handler: async (_args, ctx) => {
772
- await playbooksModule.showPlaybooks(ctx);
805
+ await (await loadPlaybooks()).showPlaybooks(ctx);
773
806
  },
774
807
  });
775
808
  pi.registerCommand("playbook", {
776
809
  description:
777
810
  "Open one Papyrus playbook directly by name (tab-completes active playbook titles) and place its invocation in the editor; no argument opens the full /playbooks browser instead",
778
- getArgumentCompletions: (argumentPrefix) => playbooksModule.playbookArgumentCompletions(argumentPrefix),
811
+ getArgumentCompletions: (argumentPrefix) => {
812
+ if (loadedPlaybooks) return loadedPlaybooks.playbookArgumentCompletions(argumentPrefix);
813
+ void loadPlaybooks();
814
+ return [];
815
+ },
779
816
  handler: async (args, ctx) => {
780
- await playbooksModule.openPlaybookByName(args, ctx);
817
+ await (await loadPlaybooks()).openPlaybookByName(args, ctx);
781
818
  },
782
819
  });
783
820
  pi.registerCommand("discuss", {
784
821
  description: "Browse Papyrus Discussions and reply, defer, resume, settle, or block/unblock a task (interactive)",
785
822
  handler: async (_args, ctx) => {
786
- await discussModule.showDiscussions(ctx);
823
+ await (await loadDiscuss()).showDiscussions(ctx);
787
824
  },
788
825
  });
789
826
 
@@ -797,22 +834,19 @@ export default async function (pi: ExtensionAPI) {
797
834
 
798
835
  // ── Task widget (TodoOverlay pattern: factory form, requestRender) ──
799
836
 
800
- pi.on("session_start", async (_event, ctx) => {
801
- // Registers this session's identity with the daemon as early as possible -- before any
802
- // Focus-mutating call could plausibly happen -- shrinking (not eliminating; see
803
- // domain/session-identity.ts) the first-touch race window. Best-effort: the daemon may be
804
- // unavailable during startup, and every other Focus-mutating call already tolerates an
805
- // unregistered/never-armored session_id (opt-in armor), so a missed registration here is
806
- // not worth surfacing to the user.
807
- try {
808
- const sessionId = ctx.sessionManager.getSessionId();
809
- const { secret } = await callService<Record<string, unknown>, { sessionId: string; secret: string }>("session.register", {
810
- session_id: sessionId,
811
- });
812
- cacheSessionSecret(sessionId, secret);
813
- } catch {
814
- // intentionally silent -- see comment above
815
- }
837
+ let sessionGeneration = 0;
838
+ pi.on("session_start", (_event, ctx) => {
839
+ const generation = ++sessionGeneration;
840
+ const sessionId = ctx.sessionManager.getSessionId();
841
+ // Identity registration is best-effort lifecycle bookkeeping. It starts now but
842
+ // cannot hold Pi's first paint behind a daemon connection or retry budget.
843
+ void callServicePassive<Record<string, unknown>, { sessionId: string; secret: string }>("session.register", {
844
+ session_id: sessionId,
845
+ })
846
+ .then(({ secret }) => {
847
+ if (generation === sessionGeneration) cacheSessionSecret(sessionId, secret);
848
+ })
849
+ .catch(() => {});
816
850
  if (!ctx.hasUI) return;
817
851
  // Attached from session start, not lazily on first ask -- a per-ask listener would only see
818
852
  // keystrokes from the moment that tool call happens to begin, missing typing already in
@@ -824,7 +858,7 @@ export default async function (pi: ExtensionAPI) {
824
858
  overlay ??= new TaskOverlay();
825
859
  overlay.setWidgetGroup(widgetGroup);
826
860
  overlay.setProjectRoot(ctx.cwd);
827
- overlay.setSessionId(ctx.sessionManager.getSessionId());
861
+ overlay.setSessionId(sessionId);
828
862
 
829
863
  noteOverlay ??= new NoteOverlay();
830
864
  noteOverlay.setWidgetGroup(widgetGroup);
@@ -832,10 +866,11 @@ export default async function (pi: ExtensionAPI) {
832
866
 
833
867
  widgetGroup.setOverlays(overlay, noteOverlay);
834
868
 
835
- await overlay.refresh();
836
- overlay.startPolling(TASK_WIDGET_POLL_INTERVAL_MS);
837
- await noteOverlay.refresh();
838
- noteOverlay.startPolling(NOTE_WIDGET_POLL_INTERVAL_MS);
869
+ void Promise.all([overlay.refresh(), noteOverlay.refresh()]).then(() => {
870
+ if (generation !== sessionGeneration) return;
871
+ overlay?.startPolling(TASK_WIDGET_POLL_INTERVAL_MS);
872
+ noteOverlay?.startPolling(NOTE_WIDGET_POLL_INTERVAL_MS);
873
+ });
839
874
  });
840
875
 
841
876
  pi.on("session_before_compact", () => {
@@ -847,20 +882,18 @@ export default async function (pi: ExtensionAPI) {
847
882
  pi.on("session_tree", async () => {
848
883
  await Promise.all([overlay?.refresh(), noteOverlay?.refresh()]);
849
884
  });
850
- pi.on("session_shutdown", async (_event, ctx) => {
885
+ pi.on("session_shutdown", (_event, ctx) => {
886
+ sessionGeneration++;
851
887
  overlay?.dispose();
852
888
  overlay = undefined;
853
889
  noteOverlay?.dispose();
854
890
  noteOverlay = undefined;
855
891
  widgetGroup?.dispose();
856
892
  widgetGroup = undefined;
857
- try {
858
- const sessionId = ctx.sessionManager.getSessionId();
859
- await callService("session.release", { session_id: sessionId, ...sessionSecretField(sessionId) });
860
- forgetSessionSecret(sessionId);
861
- } catch {
862
- // intentionally silent -- see session_start's comment above
863
- }
893
+ const sessionId = ctx.sessionManager.getSessionId();
894
+ const secret = sessionSecretField(sessionId);
895
+ forgetSessionSecret(sessionId);
896
+ void callServicePassive("session.release", { session_id: sessionId, ...secret }).catch(() => {});
864
897
  });
865
898
 
866
899
  // Update widgets after any papyrus tool call
@@ -21,7 +21,7 @@
21
21
  import type { Artifact } from "@danypops/papyrus";
22
22
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
23
  import { buildActivationContext } from "../context/activation-context.ts";
24
- import { callService } from "../service-client.ts";
24
+ import { callService, callServicePassive } from "../service-client.ts";
25
25
 
26
26
  export const PLAYBOOK_BRIDGE_MAX_PLAYBOOKS = 100;
27
27
 
@@ -34,8 +34,14 @@ function slugify(title: string): string {
34
34
  return slug.length > 0 ? slug : "playbook";
35
35
  }
36
36
 
37
- async function activePlaybooks(projectRoot?: string, capabilities: readonly string[] = []): Promise<Artifact[]> {
38
- return callService<Record<string, unknown>, Artifact[]>("playbooks.list", {
37
+ type ServiceCall = <Input extends Record<string, unknown>, Output>(operation: "playbooks.list", input: Input) => Promise<Output>;
38
+
39
+ async function activePlaybooks(
40
+ projectRoot?: string,
41
+ capabilities: readonly string[] = [],
42
+ serviceCall: ServiceCall = callService,
43
+ ): Promise<Artifact[]> {
44
+ return serviceCall<Record<string, unknown>, Artifact[]>("playbooks.list", {
39
45
  status: "active",
40
46
  full: true,
41
47
  limit: PLAYBOOK_BRIDGE_MAX_PLAYBOOKS,
@@ -70,8 +76,9 @@ export function playbookInjectionPreview(playbook: Pick<Artifact, "title" | "ext
70
76
  export async function planPlaybookCommandRegistrations(
71
77
  projectRoot?: string,
72
78
  capabilities: readonly string[] = [],
79
+ serviceCall: ServiceCall = callService,
73
80
  ): Promise<Array<{ name: string; id: string; title: string; trigger: string }>> {
74
- const playbooks = await activePlaybooks(projectRoot, capabilities);
81
+ const playbooks = await activePlaybooks(projectRoot, capabilities, serviceCall);
75
82
  const usedNames = new Set<string>();
76
83
  return playbooks.map((playbook) => {
77
84
  let name = playbookCommandName(playbook.title);
@@ -82,10 +89,18 @@ export async function planPlaybookCommandRegistrations(
82
89
  });
83
90
  }
84
91
 
85
- export function registerPlaybookBridge(pi: ExtensionAPI): void {
86
- const refresh = async (projectRoot?: string) => {
92
+ export interface PlaybookBridgeHandle {
93
+ /** Explicit test/shutdown boundary for the most recently scheduled discovery refresh. */
94
+ waitForRefresh(): Promise<void>;
95
+ }
96
+
97
+ export function registerPlaybookBridge(pi: ExtensionAPI): PlaybookBridgeHandle {
98
+ let generation = 0;
99
+ let latestRefresh = Promise.resolve();
100
+ const refresh = async (projectRoot: string | undefined, scheduledGeneration: number) => {
87
101
  try {
88
- const registrations = await planPlaybookCommandRegistrations(projectRoot, pi.getActiveTools?.() ?? []);
102
+ const registrations = await planPlaybookCommandRegistrations(projectRoot, pi.getActiveTools?.() ?? [], callServicePassive);
103
+ if (scheduledGeneration !== generation) return;
89
104
  for (const { name, id, title, trigger } of registrations) {
90
105
  pi.registerCommand(name, {
91
106
  description: trigger,
@@ -126,8 +141,13 @@ export function registerPlaybookBridge(pi: ExtensionAPI): void {
126
141
  // "no new/updated playbook commands this cycle", not a broken session start.
127
142
  }
128
143
  };
129
- pi.on("resources_discover", async (event) => {
130
- await refresh(event?.cwd);
144
+ pi.on("resources_discover", (event) => {
145
+ const scheduledGeneration = ++generation;
146
+ latestRefresh = refresh(event?.cwd, scheduledGeneration);
131
147
  return {};
132
148
  });
149
+ pi.on("session_shutdown", () => {
150
+ generation++;
151
+ });
152
+ return { waitForRefresh: () => latestRefresh };
133
153
  }
@@ -25,6 +25,13 @@ const client: RetryingClient<PapyrusClient> = createRetryingClient<PapyrusClient
25
25
  connectRetry: true,
26
26
  });
27
27
 
28
+ // Passive lifecycle work must fail fast when the daemon is absent. The regular
29
+ // client keeps its restart-surviving retry budget for explicit user/tool calls.
30
+ const passiveClient: RetryingClient<PapyrusClient> = createRetryingClient<PapyrusClient>(() => connector(), {
31
+ label: "Papyrus passive",
32
+ connectRetry: false,
33
+ });
34
+
28
35
  export async function papyrusClient(): Promise<PapyrusClient> {
29
36
  return client.call(async (resolved) => resolved);
30
37
  }
@@ -33,14 +40,24 @@ export async function callService<Input extends Record<string, unknown>, Output>
33
40
  return client.call((resolved) => resolved.call<Input, Output>(operation, input));
34
41
  }
35
42
 
43
+ /** Fail-fast daemon call for widgets, discovery, and lifecycle bookkeeping. */
44
+ export async function callServicePassive<Input extends Record<string, unknown>, Output>(
45
+ operation: OperationName,
46
+ input: Input,
47
+ ): Promise<Output> {
48
+ return passiveClient.call((resolved) => resolved.call<Input, Output>(operation, input));
49
+ }
50
+
36
51
  export function setPapyrusClientConnectorForTests(value: ClientConnector): void {
37
52
  connector = value;
38
53
  client.reset();
54
+ passiveClient.reset();
39
55
  }
40
56
 
41
57
  export function resetPapyrusClientForTests(): void {
42
58
  connector = () => connectPapyrusClient();
43
59
  client.reset();
60
+ passiveClient.reset();
44
61
  }
45
62
 
46
63
  let pushChannelTargetResolver: typeof resolvePushChannelTarget = resolvePushChannelTarget;
@@ -23,6 +23,7 @@
23
23
  * had died.
24
24
  */
25
25
 
26
+ import { createAgentNotifier } from "@danypops/vehicle-client-pi/agent-poll-ticker";
26
27
  import { createReconnectingVehicleClient, daemonInstanceIdentity } from "@danypops/vehicle-client/daemon-client";
27
28
  import { RemoteVehicleClient } from "@danypops/vehicle-client/http";
28
29
  import {
@@ -31,6 +32,7 @@ import {
31
32
  registerVehicleToolsWhenReady,
32
33
  type VehicleReadyEvent,
33
34
  } from "@danypops/vehicle-client-pi";
35
+ import { VehicleApprovalOutcomePoll } from "@danypops/vehicle-client-pi/vehicle-approval-outcome-poll";
34
36
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
35
37
  import { discussLiveFollowUp } from "../discuss/discuss-live-follow-up.ts";
36
38
  import { currentVehicleClientTarget } from "../service-client.ts";
@@ -61,6 +63,13 @@ export const PAPYRUS_VEHICLE_PERMISSIONS = [
61
63
  /** 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. */
62
64
  const FOCUS_MUTATION_OPERATIONS = new Set(["tasks.focus", "tasks.pause", "tasks.unpause", "tasks.clear_focus"]);
63
65
 
66
+ /** Matches pi-tickets' own watch-events poll cadence -- no established Papyrus-specific reason
67
+ * to differ, and this Vehicle doesn't call configureApprovals() on anything today, so nothing yet
68
+ * actually exercises this path; it exists so a future gated operation (e.g. a genuinely
69
+ * destructive artifact mutation) gets outcome-visibility for free rather than needing this same
70
+ * wiring added later. */
71
+ const APPROVAL_OUTCOME_POLL_INTERVAL_MS = 30_000;
72
+
64
73
  /**
65
74
  * Vehicle Shell's core set (see @danypops/vehicle-client-pi's registerVehicleTools `shell`
66
75
  * option): the handful of operations used in nearly every session, active from turn one with no
@@ -134,6 +143,22 @@ export function registerNotesVehicle(pi: ExtensionAPI): Promise<RegisteredPiVehi
134
143
  connectRetry: true,
135
144
  },
136
145
  );
146
+ // Push half of the Approval Gate's outcome-visibility story -- see
147
+ // @danypops/vehicle-client-pi's own vehicle-approval-outcome-poll.ts doc comment (the Papyrus
148
+ // Discussion parallel this mirrors) and pi-tickets' identical wiring in vehicle-client.ts.
149
+ const approvalOutcomePoll = new VehicleApprovalOutcomePoll(client, createAgentNotifier(pi));
150
+ let approvalOutcomePollTimer: ReturnType<typeof setInterval> | undefined;
151
+ pi.on("session_start", (_event, ctx) => {
152
+ if (!ctx.hasUI) return;
153
+ if (approvalOutcomePollTimer) return;
154
+ approvalOutcomePollTimer = setInterval(() => void approvalOutcomePoll.poll(), APPROVAL_OUTCOME_POLL_INTERVAL_MS);
155
+ void approvalOutcomePoll.poll(); // an outcome may already be resolved before this session ever started polling
156
+ });
157
+ pi.on("session_shutdown", () => {
158
+ if (!approvalOutcomePollTimer) return;
159
+ clearInterval(approvalOutcomePollTimer);
160
+ approvalOutcomePollTimer = undefined;
161
+ });
137
162
  return registerVehicleToolsWhenReady(pi, () => Promise.resolve(currentVehicleClientTarget() ? client : undefined), {
138
163
  // registerVehicleMetricsOperations(..., "papyrus") in @danypops/papyrus's own daemon.ts uses
139
164
  // the default "metrics" prefix -- without this, its metrics.query/metrics.recordClientEvent
@@ -205,6 +230,7 @@ export function registerNotesVehicle(pi: ExtensionAPI): Promise<RegisteredPiVehi
205
230
  // couldn't batch a live ask alongside other tool calls in the same turn and
206
231
  // let those run before the human sees the prompt -- same reasoning here.
207
232
  executionMode: (descriptor) => (descriptor.name === "discuss.open" || descriptor.name === "discuss.reply" ? "sequential" : undefined),
233
+ onApprovalPending: (requestId, descriptor) => approvalOutcomePoll.record(requestId, descriptor.name),
208
234
  onInvoked: ({ descriptor }, output) => {
209
235
  // See vehicle-notes-client.ts's log wiring above -- correlates a real invocation's
210
236
  // timestamp against when registration actually completed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-papyrus",
3
- "version": "0.58.0",
3
+ "version": "0.59.1",
4
4
  "description": "Pi host extension for Papyrus: native tools, TUI panels, and context injection over the daemon-backed graph store",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,7 +13,7 @@
13
13
  "typecheck": "tsc --noEmit -p tsconfig.json"
14
14
  },
15
15
  "peerDependencies": {
16
- "@danypops/vehicle-client-pi": "^0.45.1",
16
+ "@danypops/vehicle-client-pi": "^0.46.0",
17
17
  "@earendil-works/pi-coding-agent": "*",
18
18
  "@earendil-works/pi-tui": "*",
19
19
  "typebox": "*"
@@ -28,8 +28,9 @@
28
28
  "malevich-tui-components": "^0.32.1"
29
29
  },
30
30
  "devDependencies": {
31
+ "@danypops/pi-extension-harness": "^0.8.3",
31
32
  "@danypops/pi-tui-harness": "^0.0.2",
32
- "@danypops/vehicle-client-pi": "^0.45.1",
33
+ "@danypops/vehicle-client-pi": "^0.46.0",
33
34
  "@danypops/vehicle-conformance": "^0.3.0",
34
35
  "@earendil-works/pi-coding-agent": "^0.80.10",
35
36
  "bun-types": "latest",