@osolmaz/pi-workflows 0.15.2 → 0.15.3

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.
@@ -14,6 +14,7 @@ import { createRunId, WorkflowRunStore } from "../workflows/store.js";
14
14
  import type { AgentStepContract, HumanDecisionResponse } from "../workflows/types.js";
15
15
  import { parseControllerArgs, type ParsedControllerArgs } from "./controller-command.js";
16
16
  import { SessionDeliveryCoordinator, type ClaimedSessionDelivery } from "./session-delivery.js";
17
+ import { SessionWorkflowView } from "./session-view.js";
17
18
  import {
18
19
  recoverAssistantStep,
19
20
  registerWorkflowAgentStepMessageRenderer,
@@ -133,6 +134,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
133
134
  let presentationTail = Promise.resolve();
134
135
  let toolTail = Promise.resolve();
135
136
  const sessionDelivery = new SessionDeliveryCoordinator();
137
+ const sessionView = new SessionWorkflowView();
136
138
 
137
139
  const presentInOrder = async (ctx: ExtensionContext): Promise<void> => {
138
140
  const prior = presentationTail;
@@ -148,6 +150,7 @@ export default function piWorkflows(pi: ExtensionAPI): void {
148
150
  () => claimPendingTurnDelivery(pi, client, ctx),
149
151
  ]);
150
152
  } finally {
153
+ sessionView.refresh(ctx);
151
154
  release?.();
152
155
  }
153
156
  };
@@ -268,6 +271,16 @@ export default function piWorkflows(pi: ExtensionAPI): void {
268
271
  },
269
272
  });
270
273
 
274
+ pi.registerShortcut("shift+up", {
275
+ description: "Scroll the workflow widget up",
276
+ handler: (ctx) => sessionView.scrollUp(ctx),
277
+ });
278
+
279
+ pi.registerShortcut("shift+down", {
280
+ description: "Scroll the workflow widget down",
281
+ handler: (ctx) => sessionView.scrollDown(ctx),
282
+ });
283
+
271
284
  pi.on("session_start", async (_event, ctx) => {
272
285
  sessionContext = ctx;
273
286
  try {
@@ -282,6 +295,44 @@ export default function piWorkflows(pi: ExtensionAPI): void {
282
295
  pollTimer.unref?.();
283
296
  });
284
297
 
298
+ pi.on("agent_end", async (event, ctx) => {
299
+ const interrupted =
300
+ ctx.signal?.aborted === true ||
301
+ event.messages.some(
302
+ (message) =>
303
+ isRecord(message) && "stopReason" in message && message.stopReason === "aborted",
304
+ );
305
+ if (!interrupted) return;
306
+ const interaction = pendingInteractionForSession(ctx.sessionManager.getSessionId());
307
+ if (
308
+ interaction === undefined ||
309
+ interaction.kind === "decision" ||
310
+ workflowRunPaused(interaction.runId)
311
+ ) {
312
+ return;
313
+ }
314
+ const turnHasPrompt = event.messages.some(
315
+ (message) => interactionRequestId(message) === interaction.requestId,
316
+ );
317
+ if (!turnHasPrompt) return;
318
+ try {
319
+ const pauseId = `escape-pause-${interaction.runId}-${randomUUID()}`;
320
+ await requestAccepted(client, {
321
+ operation: "run.pause",
322
+ requestId: pauseId,
323
+ idempotencyKey: pauseId,
324
+ runId: interaction.runId,
325
+ });
326
+ sessionView.refresh(ctx);
327
+ ctx.ui.notify(
328
+ `Workflow ${interaction.runId} paused because its model turn was interrupted. Use /workflow resume to continue.`,
329
+ "info",
330
+ );
331
+ } catch (error) {
332
+ ctx.ui.notify(`Could not pause interrupted workflow: ${errorMessage(error)}`, "warning");
333
+ }
334
+ });
335
+
285
336
  pi.on("agent_settled", async (_event, ctx) => {
286
337
  try {
287
338
  await submitVisibleAssistantResponse(client, ctx);
@@ -291,9 +342,10 @@ export default function piWorkflows(pi: ExtensionAPI): void {
291
342
  await presentInOrder(ctx).catch(() => undefined);
292
343
  });
293
344
 
294
- pi.on("session_shutdown", async () => {
345
+ pi.on("session_shutdown", async (_event, ctx) => {
295
346
  sessionContext = null;
296
347
  sessionDelivery.clear();
348
+ sessionView.clear(ctx);
297
349
  if (pollTimer !== null) clearInterval(pollTimer);
298
350
  pollTimer = null;
299
351
  });
@@ -530,8 +582,17 @@ async function claimPendingInteractionDelivery(
530
582
  entryIdentifier(entries.find((entry) => interactionRequestId(entry) === interaction.requestId));
531
583
  const existingEntryId = findSessionEntryId(ctx.sessionManager.getBranch());
532
584
  let presentationRevision = interaction.revision;
585
+ let claimExpiresAt = Number.POSITIVE_INFINITY;
533
586
  if (existingEntryId === undefined) {
534
- const claim = await requestAccepted(client, {
587
+ if (workflowRunPaused(interaction.runId)) return undefined;
588
+ if (
589
+ interaction.presentationClaimExpiresAt !== null &&
590
+ Date.parse(interaction.presentationClaimExpiresAt) > Date.now()
591
+ ) {
592
+ return undefined;
593
+ }
594
+ await client.ensureRunning();
595
+ const claim = await client.request({
535
596
  operation: "interaction.update",
536
597
  requestId: `claim-presentation-${interaction.requestId}-${interaction.revision}-${client.clientId}`,
537
598
  idempotencyKey: `claim-presentation-${interaction.requestId}-${interaction.revision}-${client.clientId}`,
@@ -539,13 +600,29 @@ async function claimPendingInteractionDelivery(
539
600
  expectedRevision: interaction.revision,
540
601
  payload: { requestId: interaction.requestId, claimPresentation: true },
541
602
  });
603
+ if (claim.outcome === "conflict") return undefined;
604
+ if (claim.outcome !== "accepted" && claim.outcome !== "adopted") {
605
+ throw new Error(claim.error ?? "Workflow host rejected interaction.update");
606
+ }
542
607
  if (claim.revision === undefined) throw new Error("Presentation claim has no revision");
608
+ const receipt = isRecord(claim.receipt) ? claim.receipt : undefined;
543
609
  presentationRevision = claim.revision;
610
+ claimExpiresAt = claimExpiry(receipt?.presentationClaimExpiresAt, "presentation claim");
544
611
  }
545
612
 
546
613
  const contract = interactionContract(interaction);
547
614
  return {
548
615
  deliveryId: `interaction:${interaction.requestId}`,
616
+ claimExpiresAt,
617
+ isStillDeliverable: () =>
618
+ existingEntryId === undefined &&
619
+ interactionPresentationClaimIsLive({
620
+ requestId: interaction.requestId,
621
+ runId: interaction.runId,
622
+ presenterId: client.clientId,
623
+ revision: presentationRevision,
624
+ claimExpiresAt,
625
+ }),
549
626
  findSessionEntryId,
550
627
  send: () => {
551
628
  if (interaction.kind === "decision") {
@@ -617,9 +694,18 @@ async function claimPendingNotificationDelivery(
617
694
  const notification = isRecord(receipt?.notification) ? receipt.notification : undefined;
618
695
  if (notification === undefined) return undefined;
619
696
  const claimId = requireText(receipt?.claimId, "notification claimId");
697
+ const claimExpiresAt = claimExpiry(receipt?.claimExpiresAt, "notification claim");
620
698
  const notificationId = requireText(notification.notificationId, "notificationId");
621
699
  return {
622
700
  deliveryId: `notification:${notificationId}`,
701
+ claimExpiresAt,
702
+ isStillDeliverable: () =>
703
+ hostDeliveryClaimIsLive(client, {
704
+ kind: "notification",
705
+ resourceId: notificationId,
706
+ targetSessionId: sessionId,
707
+ claimId,
708
+ }),
623
709
  findSessionEntryId: (entries) =>
624
710
  entryIdentifier(
625
711
  entries.find(
@@ -678,12 +764,21 @@ async function claimPendingTurnDelivery(
678
764
  const turn = isRecord(receipt?.turn) ? receipt.turn : undefined;
679
765
  if (turn === undefined) return undefined;
680
766
  const claimId = requireText(receipt?.claimId, "turn claimId");
767
+ const claimExpiresAt = claimExpiry(receipt?.claimExpiresAt, "turn claim");
681
768
  const intentId = requireText(turn.intentId, "turn intentId");
682
769
  const runId = requireText(turn.runId, "turn runId");
683
770
  const state = terminalRunState(runId);
684
771
  if (state === undefined) return undefined;
685
772
  return {
686
773
  deliveryId: `turn:${intentId}`,
774
+ claimExpiresAt,
775
+ isStillDeliverable: () =>
776
+ hostDeliveryClaimIsLive(client, {
777
+ kind: "turn",
778
+ resourceId: intentId,
779
+ targetSessionId: sessionId,
780
+ claimId,
781
+ }),
687
782
  findSessionEntryId: (entries) =>
688
783
  entryIdentifier(
689
784
  entries.find(
@@ -723,7 +818,13 @@ async function submitVisibleAssistantResponse(
723
818
  ctx: ExtensionContext,
724
819
  ): Promise<void> {
725
820
  const interaction = pendingInteractionForSession(ctx.sessionManager.getSessionId());
726
- if (interaction === undefined || interaction.kind !== "assistant") return;
821
+ if (
822
+ interaction === undefined ||
823
+ interaction.kind !== "assistant" ||
824
+ workflowRunPaused(interaction.runId)
825
+ ) {
826
+ return;
827
+ }
727
828
  const contract = agentContract(interaction);
728
829
  if (contract === undefined) return;
729
830
  const submission = recoverAssistantStep(ctx.sessionManager.getBranch(), contract);
@@ -874,6 +975,65 @@ function terminalRunState(runId: string): Record<string, unknown> | undefined {
874
975
  }
875
976
  }
876
977
 
978
+ function workflowRunPaused(runId: string): boolean {
979
+ return terminalRunState(runId)?.paused === true;
980
+ }
981
+
982
+ function interactionPresentationClaimIsLive(options: {
983
+ requestId: string;
984
+ runId: string;
985
+ presenterId: string;
986
+ revision: number;
987
+ claimExpiresAt: number;
988
+ }): boolean {
989
+ try {
990
+ const store = new HostStateStore(workflowStatePath(), { readOnly: true });
991
+ try {
992
+ const interaction = store.getInteraction(options.requestId);
993
+ return (
994
+ interaction?.runId === options.runId &&
995
+ interaction.status === "presenting" &&
996
+ interaction.presenterId === options.presenterId &&
997
+ interaction.revision === options.revision &&
998
+ interaction.presentationSessionEntryId === null &&
999
+ interaction.presentationClaimExpiresAt !== null &&
1000
+ Date.parse(interaction.presentationClaimExpiresAt) === options.claimExpiresAt &&
1001
+ !workflowRunPaused(options.runId)
1002
+ );
1003
+ } finally {
1004
+ store.close();
1005
+ }
1006
+ } catch {
1007
+ return false;
1008
+ }
1009
+ }
1010
+
1011
+ async function hostDeliveryClaimIsLive(
1012
+ client: WorkflowHostClient,
1013
+ options: {
1014
+ kind: "notification" | "turn";
1015
+ resourceId: string;
1016
+ targetSessionId: string;
1017
+ claimId: string;
1018
+ },
1019
+ ): Promise<boolean> {
1020
+ try {
1021
+ const validationId = randomUUID();
1022
+ const response = await client.request({
1023
+ operation: options.kind === "notification" ? "notification.claim" : "turn.claim",
1024
+ requestId: `delivery-validate-${options.claimId}-${validationId}`,
1025
+ idempotencyKey: validationId,
1026
+ payload: { ...options, validateClaim: true },
1027
+ });
1028
+ const receipt = isRecord(response.receipt) ? response.receipt : undefined;
1029
+ return (
1030
+ (response.outcome === "accepted" || response.outcome === "adopted") && receipt?.live === true
1031
+ );
1032
+ } catch {
1033
+ return false;
1034
+ }
1035
+ }
1036
+
877
1037
  function presentationMessage(
878
1038
  turn: Record<string, unknown>,
879
1039
  state: Record<string, unknown>,
@@ -929,6 +1089,12 @@ function requireText(value: unknown, name: string): string {
929
1089
  return value;
930
1090
  }
931
1091
 
1092
+ function claimExpiry(value: unknown, name: string): number {
1093
+ const expiry = Date.parse(requireText(value, `${name} expiry`));
1094
+ if (!Number.isFinite(expiry)) throw new Error(`${name} expiry must be a timestamp`);
1095
+ return expiry;
1096
+ }
1097
+
932
1098
  function activeSessionRun(ctx: ExtensionContext): WorkflowRunQueueRecord | undefined {
933
1099
  return sessionRun(ctx);
934
1100
  }
@@ -1023,7 +1189,11 @@ function agentContract(interaction: InteractiveRequestRecord): AgentStepContract
1023
1189
  }
1024
1190
 
1025
1191
  function interactionRequestId(value: unknown): string | undefined {
1026
- if (!isRecord(value) || value.type !== "custom_message" || !isRecord(value.details)) {
1192
+ if (
1193
+ !isRecord(value) ||
1194
+ (value.type !== "custom_message" && value.role !== "custom") ||
1195
+ !isRecord(value.details)
1196
+ ) {
1027
1197
  return undefined;
1028
1198
  }
1029
1199
  return typeof value.details.requestId === "string" ? value.details.requestId : undefined;
@@ -2,6 +2,8 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  export type ClaimedSessionDelivery = {
4
4
  deliveryId: string;
5
+ claimExpiresAt: number;
6
+ isStillDeliverable: () => boolean | Promise<boolean>;
5
7
  findSessionEntryId: (entries: readonly unknown[]) => string | undefined;
6
8
  send: () => void;
7
9
  settle: (sessionEntryId: string) => Promise<void>;
@@ -26,6 +28,7 @@ const SESSION_ENTRY_CONFIRMATION_MS = 10_000;
26
28
  */
27
29
  export class SessionDeliveryCoordinator {
28
30
  private readonly queued = new Map<string, QueuedSessionDelivery>();
31
+ private claimed: ClaimedSessionDelivery | undefined;
29
32
  private synchronizing = false;
30
33
 
31
34
  async synchronize(
@@ -36,6 +39,7 @@ export class SessionDeliveryCoordinator {
36
39
  this.synchronizing = true;
37
40
  try {
38
41
  if (await this.settleQueued(ctx)) return;
42
+ if (await this.sendClaimed(ctx)) return;
39
43
  if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
40
44
 
41
45
  for (const claim of claimers) {
@@ -44,32 +48,17 @@ export class SessionDeliveryCoordinator {
44
48
 
45
49
  const existingEntryId = delivery.findSessionEntryId(ctx.sessionManager.getBranch());
46
50
  if (existingEntryId !== undefined) {
47
- await delivery.settle(existingEntryId);
51
+ if (delivery.claimExpiresAt <= Date.now()) return;
52
+ this.rememberQueued(delivery);
53
+ await this.settleDelivery(ctx, delivery.deliveryId, existingEntryId);
48
54
  return;
49
55
  }
50
56
 
51
- // The host claim is asynchronous. Pi can start another turn while that
52
- // request is in flight, so check again immediately before the
53
- // synchronous send. An unused claim expires and is never sent later.
54
- if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
55
-
56
- this.queued.set(delivery.deliveryId, {
57
- delivery,
58
- queuedAt: Date.now(),
59
- ambiguityReported: false,
60
- });
61
- try {
62
- delivery.send();
63
- } catch (error) {
64
- this.queued.delete(delivery.deliveryId);
65
- throw error;
66
- }
67
-
68
- const insertedEntryId = delivery.findSessionEntryId(ctx.sessionManager.getBranch());
69
- if (insertedEntryId !== undefined) {
70
- this.queued.delete(delivery.deliveryId);
71
- await delivery.settle(insertedEntryId);
72
- }
57
+ // Remember the host claim before the final idle check. If Pi starts a
58
+ // turn while the claim request is in flight, a later poll can use this
59
+ // exact still-live claim instead of making a conflicting second claim.
60
+ this.claimed = delivery;
61
+ await this.sendClaimed(ctx);
73
62
  return;
74
63
  }
75
64
  } finally {
@@ -78,9 +67,63 @@ export class SessionDeliveryCoordinator {
78
67
  }
79
68
 
80
69
  clear(): void {
70
+ this.claimed = undefined;
81
71
  this.queued.clear();
82
72
  }
83
73
 
74
+ private async sendClaimed(
75
+ ctx: Pick<ExtensionContext, "hasPendingMessages" | "isIdle" | "sessionManager" | "ui">,
76
+ ): Promise<boolean> {
77
+ const delivery = this.claimed;
78
+ if (delivery === undefined) return false;
79
+ if (delivery.claimExpiresAt <= Date.now()) {
80
+ this.claimed = undefined;
81
+ return false;
82
+ }
83
+
84
+ const existingEntryId = delivery.findSessionEntryId(ctx.sessionManager.getBranch());
85
+ if (existingEntryId !== undefined) {
86
+ this.rememberQueued(delivery);
87
+ this.claimed = undefined;
88
+ await this.settleDelivery(ctx, delivery.deliveryId, existingEntryId);
89
+ return true;
90
+ }
91
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return true;
92
+ if (!(await delivery.isStillDeliverable())) {
93
+ this.claimed = undefined;
94
+ return false;
95
+ }
96
+ if (delivery.claimExpiresAt <= Date.now()) {
97
+ this.claimed = undefined;
98
+ return false;
99
+ }
100
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return true;
101
+
102
+ this.rememberQueued(delivery);
103
+ this.claimed = undefined;
104
+ try {
105
+ delivery.send();
106
+ } catch (error) {
107
+ this.queued.delete(delivery.deliveryId);
108
+ throw error;
109
+ }
110
+
111
+ const insertedEntryId = delivery.findSessionEntryId(ctx.sessionManager.getBranch());
112
+ if (insertedEntryId !== undefined) {
113
+ await this.settleDelivery(ctx, delivery.deliveryId, insertedEntryId);
114
+ }
115
+ return true;
116
+ }
117
+
118
+ private rememberQueued(delivery: ClaimedSessionDelivery): void {
119
+ if (this.queued.has(delivery.deliveryId)) return;
120
+ this.queued.set(delivery.deliveryId, {
121
+ delivery,
122
+ queuedAt: Date.now(),
123
+ ambiguityReported: false,
124
+ });
125
+ }
126
+
84
127
  private async settleQueued(
85
128
  ctx: Pick<ExtensionContext, "hasPendingMessages" | "isIdle" | "sessionManager" | "ui">,
86
129
  ): Promise<boolean> {
@@ -103,9 +146,29 @@ export class SessionDeliveryCoordinator {
103
146
  }
104
147
  return true;
105
148
  }
106
- this.queued.delete(deliveryId);
107
- await queued.delivery.settle(sessionEntryId);
149
+ await this.settleDelivery(ctx, deliveryId, sessionEntryId);
108
150
  }
109
151
  return hadQueuedDelivery;
110
152
  }
153
+
154
+ private async settleDelivery(
155
+ ctx: Pick<ExtensionContext, "ui">,
156
+ deliveryId: string,
157
+ sessionEntryId: string,
158
+ ): Promise<void> {
159
+ const queued = this.queued.get(deliveryId);
160
+ if (queued === undefined) return;
161
+ try {
162
+ await queued.delivery.settle(sessionEntryId);
163
+ this.queued.delete(deliveryId);
164
+ } catch (error) {
165
+ if (!queued.ambiguityReported) {
166
+ queued.ambiguityReported = true;
167
+ ctx.ui.notify(
168
+ `Workflow session delivery ${deliveryId} is visible but its durable receipt is ambiguous: ${String(error)}. Do not retry it until recovery checks the session history.`,
169
+ "warning",
170
+ );
171
+ }
172
+ }
173
+ }
111
174
  }
@@ -0,0 +1,131 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { SqliteControllerStore } from "../controllers/sqlite.js";
3
+ import { workflowStatePath } from "../state/database.js";
4
+ import { WorkflowRunStore, type LoadedWorkflowRun } from "../workflows/store.js";
5
+ import { buildWidgetView } from "./widget.js";
6
+
7
+ const WIDGET_KEY = "pi-workflows";
8
+ const WIDGET_SCROLL_STEP = 3;
9
+
10
+ /** Read-only projection of the host-owned run into the origin Pi session. */
11
+ export class SessionWorkflowView {
12
+ private loaded: LoadedWorkflowRun | undefined;
13
+ private scroll: number | null = null;
14
+ private shownScroll = 0;
15
+ private maxScroll = 0;
16
+ private stepCount = 0;
17
+ private visible = false;
18
+
19
+ refresh(ctx: ExtensionContext): void {
20
+ const loaded = loadSessionRun(ctx.sessionManager.getSessionId());
21
+ if (loaded === undefined) {
22
+ this.clear(ctx);
23
+ return;
24
+ }
25
+ if (this.loaded?.runId !== loaded.runId || this.stepCount !== loaded.state.steps.length) {
26
+ this.scroll = null;
27
+ this.stepCount = loaded.state.steps.length;
28
+ }
29
+ this.loaded = loaded;
30
+ this.render(ctx);
31
+ }
32
+
33
+ scrollUp(ctx: ExtensionContext): void {
34
+ this.scrollBy(ctx, -WIDGET_SCROLL_STEP);
35
+ }
36
+
37
+ scrollDown(ctx: ExtensionContext): void {
38
+ this.scrollBy(ctx, WIDGET_SCROLL_STEP);
39
+ }
40
+
41
+ clear(ctx: ExtensionContext): void {
42
+ this.loaded = undefined;
43
+ this.scroll = null;
44
+ this.shownScroll = 0;
45
+ this.maxScroll = 0;
46
+ this.stepCount = 0;
47
+ if (!this.visible) return;
48
+ this.visible = false;
49
+ safelyUpdateUi(ctx, () => {
50
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
51
+ ctx.ui.setStatus(WIDGET_KEY, undefined);
52
+ });
53
+ }
54
+
55
+ private scrollBy(ctx: ExtensionContext, delta: number): void {
56
+ if (this.loaded === undefined) return;
57
+ const current = this.scroll ?? this.shownScroll;
58
+ this.scroll = Math.max(0, Math.min(this.maxScroll, current + delta));
59
+ this.render(ctx);
60
+ }
61
+
62
+ private render(ctx: ExtensionContext): void {
63
+ const loaded = this.loaded;
64
+ if (loaded === undefined) return;
65
+ const render = (
66
+ width = Number.POSITIVE_INFINITY,
67
+ theme?: Parameters<typeof buildWidgetView>[6],
68
+ ) => {
69
+ const view = buildWidgetView(
70
+ loaded.state,
71
+ loaded.snapshot,
72
+ new Date(),
73
+ this.scroll,
74
+ loaded.state.paused === true,
75
+ width,
76
+ theme,
77
+ undefined,
78
+ );
79
+ this.shownScroll = view.scroll;
80
+ this.maxScroll = view.maxScroll;
81
+ if (this.scroll !== null) this.scroll = view.scroll;
82
+ return view.lines;
83
+ };
84
+ safelyUpdateUi(ctx, () => {
85
+ if (ctx.mode === "tui") {
86
+ ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) => ({
87
+ render: (width) => render(width, theme),
88
+ invalidate() {},
89
+ }));
90
+ } else {
91
+ ctx.ui.setWidget(WIDGET_KEY, render());
92
+ }
93
+ const label = loaded.state.paused === true ? "paused" : loaded.state.status;
94
+ ctx.ui.setStatus(WIDGET_KEY, `${loaded.state.workflowName} [${label}]`);
95
+ this.visible = true;
96
+ });
97
+ }
98
+ }
99
+
100
+ function loadSessionRun(sessionId: string): LoadedWorkflowRun | undefined {
101
+ try {
102
+ const queue = new SqliteControllerStore(workflowStatePath(), {
103
+ readOnly: true,
104
+ global: true,
105
+ });
106
+ let runId: string | undefined;
107
+ try {
108
+ runId = queue.findSessionReservation(sessionId)?.runId;
109
+ } finally {
110
+ queue.close();
111
+ }
112
+ if (runId === undefined) return undefined;
113
+
114
+ const runs = new WorkflowRunStore(workflowStatePath(), { readOnly: true });
115
+ try {
116
+ return runs.readRun(runId) ?? undefined;
117
+ } finally {
118
+ runs.close();
119
+ }
120
+ } catch {
121
+ return undefined;
122
+ }
123
+ }
124
+
125
+ function safelyUpdateUi(ctx: ExtensionContext, update: () => void): void {
126
+ try {
127
+ if (ctx.hasUI) update();
128
+ } catch {
129
+ // A session replacement can make a captured context stale between polls.
130
+ }
131
+ }
@@ -86,7 +86,7 @@ export function buildWidgetView(
86
86
  // `held` covers pauses the state cannot see yet: an escape-interrupted
87
87
  // step or a pause requested while the current node is still finishing.
88
88
  const paused = held || state.paused === true;
89
- const glyph = paused ? "⏸" : STATUS_GLYPHS[state.status];
89
+ const glyph = paused ? "⏸" : (STATUS_GLYPHS[state.status] ?? "·");
90
90
  const statusText = paused ? "paused" : state.status;
91
91
  // Titles, status details, and errors can carry model- or shell-controlled
92
92
  // text; never let escape sequences or newlines reach the terminal.
@@ -398,7 +398,7 @@ function elapsedSince(startedAt: string | undefined, now: Date): string | null {
398
398
  return formatDuration(Math.max(0, now.getTime() - started));
399
399
  }
400
400
 
401
- function statusTone(status: WorkflowRunStatus): ThemeColor {
401
+ function statusTone(status: string): ThemeColor {
402
402
  switch (status) {
403
403
  case "completed":
404
404
  return "success";
@@ -409,6 +409,7 @@ function statusTone(status: WorkflowRunStatus): ThemeColor {
409
409
  case "waiting":
410
410
  return "warning";
411
411
  case "running":
412
+ default:
412
413
  return "accent";
413
414
  }
414
415
  }