@timurproko/a1 0.2.7-dev.1276 → 0.2.7-dev.1286

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.
Files changed (184) hide show
  1. package/README.md +5 -1
  2. package/dist/app/session-shell/session-shell-root.d.ts +4 -68
  3. package/dist/app/session-shell/session-shell.js +5 -11
  4. package/dist/app/session-shell/shell-host.js +2 -2
  5. package/dist/app/session-shell/shell-options.d.ts +65 -0
  6. package/dist/app/session-shell/shell-options.js +1 -0
  7. package/dist/app/session-shell/workflow-router.d.ts +3 -1
  8. package/dist/app/session-shell/workflow-router.js +12 -0
  9. package/dist/cli/dispatch.js +27 -1
  10. package/dist/cli/packages.js +18 -2
  11. package/dist/cli/session-context.js +6 -2
  12. package/dist/composition/owned-ui.js +20 -8
  13. package/dist/composition/session-naming.js +1 -1
  14. package/dist/composition/session-runtime-registry.d.ts +2 -0
  15. package/dist/composition/session-runtime-registry.js +49 -9
  16. package/dist/contracts/agent-engine/package-ports.d.ts +10 -14
  17. package/dist/contracts/agent-engine/package-ports.js +5 -9
  18. package/dist/contracts/owned-ui/session-workflows.d.ts +6 -0
  19. package/dist/contracts/presentation/index.d.ts +1 -0
  20. package/dist/contracts/presentation/progress-palette.generated.d.ts +1 -1
  21. package/dist/contracts/presentation/progress-palette.generated.js +1 -1
  22. package/dist/contracts/presentation/progress-spinner.d.ts +7 -0
  23. package/dist/contracts/presentation/progress-spinner.js +1 -0
  24. package/dist/features/launch/session-context.d.ts +8 -0
  25. package/dist/features/launch/session-context.js +19 -10
  26. package/dist/features/owned-ui/reference-screen-app.js +9 -5
  27. package/dist/features/owned-ui/session-fork-prompt.d.ts +2 -1
  28. package/dist/features/owned-ui/session-fork-prompt.js +11 -5
  29. package/dist/features/owned-ui/settings-app.d.ts +1 -1
  30. package/dist/features/owned-ui/settings-app.js +14 -7
  31. package/dist/features/owned-ui/settings-spinner-preview.d.ts +9 -0
  32. package/dist/features/owned-ui/settings-spinner-preview.js +29 -0
  33. package/dist/features/session-naming/context.d.ts +17 -0
  34. package/dist/features/session-naming/context.js +39 -0
  35. package/dist/features/session-naming/controller.d.ts +1 -5
  36. package/dist/features/session-naming/controller.js +7 -4
  37. package/dist/features/session-naming/generate.d.ts +2 -1
  38. package/dist/features/session-naming/generate.js +14 -10
  39. package/dist/features/session-naming/index.d.ts +1 -1
  40. package/dist/foundation/lifecycle/index.d.ts +3 -1
  41. package/dist/foundation/lifecycle/index.js +3 -1
  42. package/dist/foundation/lifecycle/session-claim-lock.d.ts +2 -2
  43. package/dist/foundation/lifecycle/session-claim-lock.js +36 -10
  44. package/dist/foundation/lifecycle/session-context-retry.d.ts +2 -0
  45. package/dist/foundation/lifecycle/session-context-retry.js +17 -0
  46. package/dist/foundation/lifecycle/session-repository-context.d.ts +7 -1
  47. package/dist/foundation/lifecycle/session-repository-context.js +105 -39
  48. package/dist/foundation/lifecycle/session-worktree-entry.d.ts +3 -0
  49. package/dist/foundation/lifecycle/session-worktree-entry.js +87 -0
  50. package/dist/foundation/release/automatic-update.js +2 -2
  51. package/dist/foundation/release/bootstrap.js +19 -3
  52. package/dist/foundation/release/index.d.ts +1 -1
  53. package/dist/foundation/release/index.js +1 -1
  54. package/dist/foundation/release/release-gc.js +41 -5
  55. package/dist/foundation/release/release-state.d.ts +48 -4
  56. package/dist/foundation/release/release-state.js +168 -31
  57. package/dist/foundation/release/release-use.d.ts +10 -0
  58. package/dist/foundation/release/release-use.js +86 -0
  59. package/dist/foundation/release/update-activation.d.ts +3 -1
  60. package/dist/foundation/release/update-activation.js +23 -6
  61. package/dist/foundation/release/update-recovery.js +2 -2
  62. package/dist/foundation/release/update.d.ts +3 -1
  63. package/dist/foundation/release/update.js +61 -22
  64. package/dist/foundation/release/warmup.js +8 -9
  65. package/dist/foundation/session-writer-lock/index.d.ts +2 -2
  66. package/dist/foundation/session-writer-lock/index.js +82 -12
  67. package/dist/foundation/startup/startup-descriptor.js +2 -2
  68. package/dist/foundation/terminal-cleanup/fatal-diagnostic.d.ts +2 -0
  69. package/dist/foundation/terminal-cleanup/fatal-diagnostic.js +59 -0
  70. package/dist/foundation/terminal-cleanup/fatal-exit.js +1 -29
  71. package/dist/integrations/pi/components/resources/builtin-themes.d.ts +2 -2
  72. package/dist/integrations/pi/components/resources/builtin-themes.js +2 -2
  73. package/dist/integrations/pi/components/share-operation-dialog.d.ts +2 -1
  74. package/dist/integrations/pi/components/share-operation-dialog.js +13 -5
  75. package/dist/integrations/pi/components/shell-footer-status.d.ts +2 -1
  76. package/dist/integrations/pi/components/shell-footer-status.js +15 -8
  77. package/dist/integrations/pi/components/shell-session-info-reference.js +11 -8
  78. package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.d.ts +1 -1
  79. package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.js +1 -1
  80. package/dist/integrations/pi/components/upstream/assets/earendil-image.json +2 -2
  81. package/dist/integrations/pi/components/upstream/components/auth-url.d.ts +1 -1
  82. package/dist/integrations/pi/components/upstream/components/auth-url.js +1 -1
  83. package/dist/integrations/pi/components/upstream/components/countdown-timer.d.ts +1 -1
  84. package/dist/integrations/pi/components/upstream/components/earendil-announcement.js +1 -1
  85. package/dist/integrations/pi/components/upstream/components/extension-editor.d.ts +1 -1
  86. package/dist/integrations/pi/components/upstream/components/extension-editor.js +1 -1
  87. package/dist/integrations/pi/components/upstream/components/extension-input.js +1 -1
  88. package/dist/integrations/pi/components/upstream/components/extension-selector.js +1 -1
  89. package/dist/integrations/pi/components/upstream/components/login-dialog.js +1 -1
  90. package/dist/integrations/pi/components/upstream/components/mermaid.js +1 -1
  91. package/dist/integrations/pi/components/upstream/components/oauth-selector.d.ts +1 -1
  92. package/dist/integrations/pi/components/upstream/components/oauth-selector.js +1 -1
  93. package/dist/integrations/pi/components/upstream/components/owned-editor.d.ts +1 -1
  94. package/dist/integrations/pi/components/upstream/components/owned-editor.js +1 -1
  95. package/dist/integrations/pi/components/upstream/components/pi-logo.js +1 -1
  96. package/dist/integrations/pi/components/upstream/components/scoped-models-selector.d.ts +1 -1
  97. package/dist/integrations/pi/components/upstream/components/session-footer.js +14 -8
  98. package/dist/integrations/pi/components/upstream/components/session-selector-search.js +1 -1
  99. package/dist/integrations/pi/components/upstream/components/session-selector.js +1 -1
  100. package/dist/integrations/pi/components/upstream/components/skill-invocation-message.d.ts +1 -1
  101. package/dist/integrations/pi/components/upstream/components/skill-invocation-message.js +1 -1
  102. package/dist/integrations/pi/components/upstream/components/status-indicator.d.ts +1 -1
  103. package/dist/integrations/pi/components/upstream/components/status-indicator.js +1 -1
  104. package/dist/integrations/pi/components/upstream/components/themed-text.d.ts +1 -1
  105. package/dist/integrations/pi/components/upstream/components/themed-text.js +1 -1
  106. package/dist/integrations/pi/components/upstream/components/thinking-selector.d.ts +1 -1
  107. package/dist/integrations/pi/components/upstream/components/thinking-selector.js +1 -1
  108. package/dist/integrations/pi/components/upstream/components/tool-execution.js +1 -1
  109. package/dist/integrations/pi/components/upstream/components/tree-selector.d.ts +1 -1
  110. package/dist/integrations/pi/components/upstream/components/tree-selector.js +1 -1
  111. package/dist/integrations/pi/components/upstream/components/trust-selector.js +1 -1
  112. package/dist/integrations/pi/components/upstream/external-editor.js +1 -1
  113. package/dist/integrations/pi/components/upstream/history/editor-core.d.ts +1 -1
  114. package/dist/integrations/pi/components/upstream/history/editor-core.js +1 -1
  115. package/dist/integrations/pi/components/upstream/history/kill-ring.d.ts +1 -1
  116. package/dist/integrations/pi/components/upstream/history/kill-ring.js +1 -1
  117. package/dist/integrations/pi/components/upstream/history/printable-key.js +1 -1
  118. package/dist/integrations/pi/components/upstream/history/text-helpers.js +1 -1
  119. package/dist/integrations/pi/components/upstream/history/undo-stack.d.ts +1 -1
  120. package/dist/integrations/pi/components/upstream/history/undo-stack.js +1 -1
  121. package/dist/integrations/pi/components/upstream/history/word-navigation.js +1 -1
  122. package/dist/integrations/pi/components/upstream/model-search.d.ts +1 -1
  123. package/dist/integrations/pi/components/upstream/program-status-reporter.d.ts +1 -1
  124. package/dist/integrations/pi/components/upstream/theme/theme.js +1 -1
  125. package/dist/integrations/pi/engine/adapter.d.ts +3 -1
  126. package/dist/integrations/pi/engine/adapter.js +24 -6
  127. package/dist/integrations/pi/engine/changelog.js +5 -2
  128. package/dist/integrations/pi/engine/delivery-retention.d.ts +7 -0
  129. package/dist/integrations/pi/engine/delivery-retention.js +66 -0
  130. package/dist/integrations/pi/engine/event-delivery.d.ts +17 -1
  131. package/dist/integrations/pi/engine/event-delivery.js +25 -3
  132. package/dist/integrations/pi/engine/extension-ui-binding.d.ts +2 -1
  133. package/dist/integrations/pi/engine/extension-ui-binding.js +45 -0
  134. package/dist/integrations/pi/engine/host-ports.d.ts +2 -0
  135. package/dist/integrations/pi/engine/host.d.ts +3 -0
  136. package/dist/integrations/pi/engine/host.js +2 -0
  137. package/dist/integrations/pi/engine/package-integration.js +19 -4
  138. package/dist/integrations/pi/engine/pending-delivery.d.ts +4 -1
  139. package/dist/integrations/pi/engine/pending-delivery.js +51 -18
  140. package/dist/integrations/pi/engine/resources/changelog.json +4 -4
  141. package/dist/integrations/pi/engine/runtime-integration.d.ts +8 -0
  142. package/dist/integrations/pi/engine/runtime-integration.js +58 -9
  143. package/dist/integrations/pi/engine/session-cwd-recovery.d.ts +11 -0
  144. package/dist/integrations/pi/engine/session-cwd-recovery.js +18 -0
  145. package/dist/integrations/pi/engine/session-runtime.d.ts +3 -0
  146. package/dist/integrations/pi/engine/session-runtime.js +2 -0
  147. package/dist/integrations/pi/engine/session-selection.d.ts +3 -2
  148. package/dist/integrations/pi/engine/session-selection.js +25 -5
  149. package/dist/integrations/pi/engine/transcript-image-assets.d.ts +3 -0
  150. package/dist/integrations/pi/engine/transcript-image-assets.js +65 -14
  151. package/dist/integrations/pi/engine/view-state.d.ts +14 -0
  152. package/dist/integrations/pi/engine/view-state.js +17 -0
  153. package/dist/integrations/pi/engine/workflow-runner.d.ts +2 -0
  154. package/dist/integrations/pi/engine/workflow-runner.js +47 -26
  155. package/dist/integrations/pi/engine/worktree-tools.d.ts +4 -0
  156. package/dist/integrations/pi/engine/worktree-tools.js +26 -0
  157. package/dist/integrations/pi/session-presenters/components.d.ts +2 -1
  158. package/dist/integrations/pi/session-presenters/components.js +5 -3
  159. package/dist/integrations/pi/session-presenters/presenters.d.ts +3 -1
  160. package/dist/integrations/pi/session-presenters/presenters.js +1 -1
  161. package/dist/integrations/pi/startup-public.d.ts +1 -1
  162. package/dist/integrations/pi/startup-public.js +16 -20
  163. package/dist/integrations/pi/startup-public.manifest.json +29 -29
  164. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.d.ts +1 -1
  165. package/dist/integrations/pi/tui-runtime/damage-aware-terminal.js +1 -1
  166. package/dist/product-identity.json +1 -1
  167. package/dist/runtime-payload-inventory.json +5027 -5027
  168. package/dist/ui/components/index.d.ts +1 -1
  169. package/dist/ui/components/index.js +1 -1
  170. package/dist/ui/components/progress-status.d.ts +7 -0
  171. package/dist/ui/components/progress-status.js +21 -0
  172. package/dist/ui/components/value-menu.d.ts +1 -1
  173. package/dist/ui/components/value-menu.js +4 -2
  174. package/dist/ui/settings/declarations.d.ts +16 -4
  175. package/dist/ui/settings/declarations.js +13 -4
  176. package/docs/architecture/internal-naming.md +1 -1
  177. package/docs/architecture/project-structure.md +7 -11
  178. package/docs/features/session-exports.md +1 -1
  179. package/docs/features/session-naming.md +8 -6
  180. package/docs/features/worktrees.md +38 -0
  181. package/docs/local-worktree-cleanup.md +27 -4
  182. package/docs/openspec-archive-automation.md +30 -4
  183. package/docs/worktree-link-recovery.md +2 -2
  184. package/package.json +5 -5
@@ -136,10 +136,12 @@ export function createUpdateLifecycleCoordinator(environment = process.env, file
136
136
  // its own to know. A tree that serves the contract activates itself with its own code;
137
137
  // a tree from before the contract is activated here, with the layout it had then.
138
138
  const contracts = await readActivationContracts(packageRoot, fileSystem.readFile);
139
- if (contracts.includes(UPDATE_ACTIVATION_CONTRACT))
139
+ if (contracts.includes(UPDATE_ACTIVATION_CONTRACT)) {
140
140
  await delegateActivation(request, callbacks);
141
- else
142
- await activateInstalledRelease(request, callbacks);
141
+ return "delegated";
142
+ }
143
+ await activateInstalledRelease(request, callbacks);
144
+ return "in-process";
143
145
  },
144
146
  };
145
147
  }
@@ -164,7 +166,7 @@ export function renderUpdateProgressBar(percent, accent = SHARED_PROGRESS_ACCENT
164
166
  // percentage retain the update meter's neutral treatment.
165
167
  const gray = "\u001b[38;2;128;128;128m";
166
168
  const track = "\u001b[38;2;102;102;102m";
167
- return `${accent}${"━".repeat(filled)}${track}${"─".repeat(PROGRESS_BAR_WIDTH - filled)}${gray} ${bounded}%\u001b[39m`;
169
+ return `${accent}${"━".repeat(filled)}${track}${"━".repeat(PROGRESS_BAR_WIDTH - filled)}${gray} ${bounded}%\u001b[39m`;
168
170
  }
169
171
  function updateProgressAccent(environment) {
170
172
  return earlyAccentPalette(resolveEarlyAccentColor(environment), earlyTerminalAppearance(environment), earlyTerminalColorMode(environment)).accent;
@@ -416,13 +418,22 @@ async function withUpdateExecutionLease(input, options, output, installer, opera
416
418
  // back to the release that ran before it, so this request proceeds as an ordinary update of its
417
419
  // own target instead of reporting a dead attempt as in progress.
418
420
  const interrupted = lease.owner.targetVersion;
419
- const rollback = await installer.rollback(lease.transaction.priorActiveReleaseId)
420
- .catch(error => `rollback failed: ${errorMessage(error)}`);
421
421
  const store = new UpdateTransactionStore(input.dataDir);
422
- await store.finish("rolled-back", `interrupted ${interrupted} update settled by ${input.targetVersion}; ${rollback}`);
423
- await store.clearCompleted().catch(() => undefined);
424
- await reclaimDeadLease(input.dataDir, lease.owner);
425
- output.stdout(`${PRODUCT_TEXT.commandName} rolled back the interrupted ${interrupted} update: ${rollback}\n`);
422
+ if (lease.transaction.phase === "active-reference-committed") {
423
+ // Invariant: the interrupted update already committed its target; agents may run it, so it stays.
424
+ await store.finish("completed", `interrupted ${interrupted} update was already active; settled by ${input.targetVersion}`);
425
+ await store.clearCompleted().catch(() => undefined);
426
+ await reclaimDeadLease(input.dataDir, lease.owner);
427
+ output.stdout(`${PRODUCT_TEXT.commandName} settled the interrupted ${interrupted} update, which was already active\n`);
428
+ }
429
+ else {
430
+ const rollback = await installer.rollback(lease.transaction.priorActiveReleaseId)
431
+ .catch(error => `rollback failed: ${errorMessage(error)}`);
432
+ await store.finish("rolled-back", `interrupted ${interrupted} update settled by ${input.targetVersion}; ${rollback}`);
433
+ await store.clearCompleted().catch(() => undefined);
434
+ await reclaimDeadLease(input.dataDir, lease.owner);
435
+ output.stdout(`${PRODUCT_TEXT.commandName} rolled back the interrupted ${interrupted} update: ${rollback}\n`);
436
+ }
426
437
  lease = await acquireUpdateExecutionLease(input);
427
438
  }
428
439
  if (lease.status !== "acquired") {
@@ -533,7 +544,9 @@ async function runUpdateTransaction(plan) {
533
544
  const { packageRoot, npmCliRoot } = plan.installation;
534
545
  const run = { transaction: await transactionStore.read() };
535
546
  try {
536
- if ((!run.transaction || run.transaction.status === "completed") && await lifecycle.targetIsActive(targetVersion)) {
547
+ // Rationale: a settled transaction, even one reported as failed after its commit, leaves nothing
548
+ // to install when its target is already active; reinstalling the same release would only churn.
549
+ if ((!run.transaction || run.transaction.status !== "active") && await lifecycle.targetIsActive(targetVersion)) {
537
550
  await installer.maintenance();
538
551
  await transactionStore.clearCompleted();
539
552
  output.stdout(`${PRODUCT_TEXT.commandName} is up to date — no update needed.\n`);
@@ -610,7 +623,7 @@ async function replaceUpdatePackage(plan, run, protection, npmCli) {
610
623
  }
611
624
  /** Activate the installed package, commit maintenance, and only then report success. */
612
625
  async function activateUpdate(plan, run) {
613
- const { output, progress, transactionStore, now, onPhaseTiming, paths } = plan;
626
+ const { progress, transactionStore, now, onPhaseTiming } = plan;
614
627
  progress.set(70, MATERIALIZE_PROGRESS.from);
615
628
  let activationPhaseStartedAt = now();
616
629
  const recordActivationPhase = async (phase) => {
@@ -619,7 +632,7 @@ async function activateUpdate(plan, run) {
619
632
  progress.set(ACTIVATION_PROGRESS[phase].at, ACTIVATION_PROGRESS[phase].creepTo);
620
633
  activationPhaseStartedAt = now();
621
634
  };
622
- await plan.lifecycle.activateInstalled(plan.installation.packageRoot, plan.targetVersion, recordActivationPhase, ({ completed, total }) => progress.set(MATERIALIZE_PROGRESS.from
635
+ const activation = await plan.lifecycle.activateInstalled(plan.installation.packageRoot, plan.targetVersion, recordActivationPhase, ({ completed, total }) => progress.set(MATERIALIZE_PROGRESS.from
623
636
  + (MATERIALIZE_PROGRESS.to - MATERIALIZE_PROGRESS.from) * (total > 0 ? Math.min(1, completed / total) : 0)), state => {
624
637
  if (state === "started") {
625
638
  activationPhaseStartedAt = now();
@@ -632,19 +645,39 @@ async function activateUpdate(plan, run) {
632
645
  }
633
646
  });
634
647
  const transactionStartedAt = now();
635
- const transaction = run.transaction;
636
- if (transaction?.recovery?.capsulePath)
637
- await removeUpdateRecoveryCapsule(paths.dataDir, transaction.transactionId);
638
- // Invariant: successful output follows the durable cleanup disposition. Slow recursive
639
- // removal belongs to the detached worker started by this maintenance coordinator.
640
- await plan.installer.maintenance();
648
+ const warnings = [];
649
+ // Invariant: a delegated tree owns the control state it just wrote and has already scheduled its
650
+ // own maintenance; this updater may be older and must not interpret that state. An in-process
651
+ // activation ran this updater's own code, so the updater remains the owner of its maintenance.
652
+ if (activation !== "delegated") {
653
+ // Rationale: the activation is committed, so cleanup trouble is retried later, never a failure.
654
+ await plan.installer.maintenance().catch(error => { warnings.push(`release cleanup will retry: ${errorMessage(error)}`); });
655
+ }
656
+ await completeCommittedUpdate(plan, run.transaction, warnings);
657
+ onPhaseTiming?.({ phase: "transaction-complete", durationMs: Math.max(0, now() - transactionStartedAt) });
658
+ return 0;
659
+ }
660
+ /**
661
+ * The active reference names the target, so the update has happened. Everything here is
662
+ * best-effort bookkeeping: it may warn, but it never fails the update and never rolls back.
663
+ */
664
+ async function completeCommittedUpdate(plan, transaction, warnings) {
665
+ const { output, progress, transactionStore, paths } = plan;
666
+ if (transaction?.recovery?.capsulePath) {
667
+ await removeUpdateRecoveryCapsule(paths.dataDir, transaction.transactionId)
668
+ .catch(error => { warnings.push(`recovery evidence will be removed later: ${errorMessage(error)}`); });
669
+ }
641
670
  // Concurrency: this is the launch cutover. No failure-prone update work follows success.
642
- await transactionStore.finish("completed");
671
+ await transactionStore.finish("completed", warnings.length === 0 ? null : warnings.join("; ").slice(0, 2_000))
672
+ .catch(error => { warnings.push(`update journal could not be settled: ${errorMessage(error)}`); });
643
673
  await Promise.all([transactionStore.clearCompleted(), invalidateReleaseCheckCache(paths.configDir)]).catch(() => undefined);
644
- onPhaseTiming?.({ phase: "transaction-complete", durationMs: Math.max(0, now() - transactionStartedAt) });
645
674
  progress.finish();
646
675
  output.stdout(`${PRODUCT_TEXT.commandName} updated successfully to ${plan.targetVersion}\n`);
647
- return 0;
676
+ for (const warning of warnings)
677
+ output.stderr(`${PRODUCT_TEXT.diagnostic(`warning: ${singleLine(warning)}`)}\n`);
678
+ }
679
+ function singleLine(text) {
680
+ return text.replace(/[\r\n]+/gu, " ").slice(0, 400);
648
681
  }
649
682
  async function settleUpdateFailure(plan, transaction, error) {
650
683
  const { output, transactionStore } = plan;
@@ -654,6 +687,12 @@ async function settleUpdateFailure(plan, transaction, error) {
654
687
  output.stderr(`${PRODUCT_TEXT.diagnostic(`update could not start safely: ${message}. Nothing was changed. Retry with npm x -y -- ${PRODUCT_PACKAGE}-install`)}\n`);
655
688
  return 1;
656
689
  }
690
+ if (transaction?.status === "active" && transaction.phase === "active-reference-committed") {
691
+ // Invariant: once the active reference names the target, running agents may already use it.
692
+ // Rolling back would move the reference under them, so a later failure only warns.
693
+ await completeCommittedUpdate(plan, transaction, [`post-activation step failed: ${message}`]);
694
+ return 0;
695
+ }
657
696
  const rollback = await plan.installer.rollback(transaction?.priorActiveReleaseId ?? null)
658
697
  .catch(rollbackError => `rollback failed: ${errorMessage(rollbackError)}`);
659
698
  if (transaction)
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawnReleaseEntry } from "./release-use.js";
2
2
  import { resolveReleaseEntryPoint } from "./release-store.js";
3
3
  import { releaseEnvironment } from "./bootstrap.js";
4
4
  import { launchContractTarget, launchEnvironmentKeys } from "../launch-context/index.js";
@@ -11,15 +11,13 @@ function warmupDiagnostics(text) {
11
11
  /** Import the exact immutable startup graph in a terminal-free bounded child process. */
12
12
  export async function warmMaterializedRelease(release, environment, timeoutMs = 30_000) {
13
13
  const entry = await resolveReleaseEntryPoint(release, "bin/warmup.js");
14
+ const child = await spawnReleaseEntry(entry, [], {
15
+ env: { ...releaseEnvironment(environment, release), [launchEnvironmentKeys(launchContractTarget(release)).immutableWarmup]: "1" },
16
+ stdio: ["ignore", "ignore", "pipe"],
17
+ windowsHide: true,
18
+ });
14
19
  await new Promise((resolvePromise, rejectPromise) => {
15
- // Rationale: the child still gets no terminal, but a discarded failure reason leaves an
16
- // update reporting only an exit status. Its own diagnostics are read and bounded so the
17
- // transaction journal records why the graph could not be imported.
18
- const child = spawn(process.execPath, [entry], {
19
- env: { ...releaseEnvironment(environment, release), [launchEnvironmentKeys(launchContractTarget(release)).immutableWarmup]: "1" },
20
- stdio: ["ignore", "ignore", "pipe"],
21
- windowsHide: true,
22
- });
20
+ // Rationale: bounded diagnostics explain graph-import failures without attaching a terminal.
23
21
  let diagnostics = "";
24
22
  child.stderr?.setEncoding("utf8");
25
23
  child.stderr?.on("data", chunk => { diagnostics = `${diagnostics}${chunk}`.slice(-WARMUP_DIAGNOSTIC_LIMIT); });
@@ -44,4 +42,5 @@ export async function warmMaterializedRelease(release, environment, timeoutMs =
44
42
  }, timeoutMs);
45
43
  timer.unref?.();
46
44
  });
45
+ await child.releaseSettled;
47
46
  }
@@ -20,8 +20,8 @@ export interface SessionWriterHandle {
20
20
  release(): void;
21
21
  }
22
22
  /**
23
- * A crashed writer is not released by the kernel; its lock becomes stale once its holder stops
24
- * refreshing it. The bound keeps resume after a crash a matter of seconds.
23
+ * Heartbeats diagnose ownership loss, but never authorize takeover of a live writer.
24
+ * Dead owners are recovered by process absence; legacy or uncertain owners stay protected.
25
25
  */
26
26
  export declare const SESSION_WRITER_STALE_MS = 10000;
27
27
  /**
@@ -1,7 +1,7 @@
1
- import { existsSync, mkdirSync, realpathSync, statSync } from "node:fs";
1
+ import { existsSync, mkdirSync, realpathSync, statSync, readFileSync, writeFileSync, unlinkSync, rmdirSync, utimesSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { basename, dirname, join, resolve } from "node:path";
4
- import { createHash } from "node:crypto";
4
+ import { createHash, randomUUID } from "node:crypto";
5
5
  import lockfile from "proper-lockfile";
6
6
  /** Reports that a canonical session identity already has a live writer. */
7
7
  export class SessionWriterConflict extends Error {
@@ -15,8 +15,8 @@ export class SessionWriterConflict extends Error {
15
15
  }
16
16
  }
17
17
  /**
18
- * A crashed writer is not released by the kernel; its lock becomes stale once its holder stops
19
- * refreshing it. The bound keeps resume after a crash a matter of seconds.
18
+ * Heartbeats diagnose ownership loss, but never authorize takeover of a live writer.
19
+ * Dead owners are recovered by process absence; legacy or uncertain owners stay protected.
20
20
  */
21
21
  export const SESSION_WRITER_STALE_MS = 10_000;
22
22
  let backendOverride;
@@ -35,15 +35,37 @@ export function lockfileBackend(root = join(tmpdir(), "a1-session-writer-locks")
35
35
  };
36
36
  const acquireAuthority = (path) => {
37
37
  mkdirSync(root, { recursive: true, mode: 0o700 });
38
- // Rationale: the lock directory is the authority; no target file exists or is needed.
39
- return lockfile.lockSync(path, {
40
- realpath: false,
41
- lockfilePath: path,
42
- stale: staleMs,
43
- // Rationale: a missed refresh means this process stalled; the next writer takes over, and the
44
- // stalled one must not crash for it.
45
- onCompromised: () => { },
38
+ // Compatibility: the nonempty owner record also prevents legacy heartbeat-only clients from reclaiming
39
+ // a stalled live writer: proper-lockfile removes directories non-recursively.
40
+ reclaimDeadWriter(path);
41
+ const release = lockfile.lockSync(path, {
42
+ realpath: false, lockfilePath: path, stale: staleMs,
43
+ onCompromised: () => { throw new Error("Session writer ownership was lost; execution has been stopped."); },
46
44
  });
45
+ const owner = { pid: process.pid, token: randomUUID() };
46
+ const stamp = statSync(path).mtime;
47
+ try {
48
+ writeFileSync(join(path, "owner.json"), JSON.stringify(owner), { flag: "wx", mode: 0o600 });
49
+ // Concurrency: creating the owner record must not masquerade as a foreign heartbeat.
50
+ utimesSync(path, stamp, stamp);
51
+ }
52
+ catch (error) {
53
+ try {
54
+ unlinkSync(join(path, "owner.json"));
55
+ release();
56
+ }
57
+ catch { /* Security: retain uncertain ownership. */ }
58
+ throw error;
59
+ }
60
+ return () => {
61
+ if (readWriterOwner(path)?.token !== owner.token)
62
+ throw new Error("Session writer ownership was lost");
63
+ // Concurrency: a legacy contender must not reclaim the short empty-directory release interval.
64
+ const now = new Date();
65
+ utimesSync(path, now, now);
66
+ unlinkSync(join(path, "owner.json"));
67
+ release();
68
+ };
47
69
  };
48
70
  return {
49
71
  acquire(requested) {
@@ -92,6 +114,54 @@ export function lockfileBackend(root = join(tmpdir(), "a1-session-writer-locks")
92
114
  },
93
115
  };
94
116
  }
117
+ function readWriterOwner(path) {
118
+ try {
119
+ const source = readFileSync(join(path, "owner.json"), "utf8");
120
+ if (source.length > 1024)
121
+ return null;
122
+ const value = JSON.parse(source);
123
+ return Number.isSafeInteger(value.pid) && value.pid > 0 && typeof value.token === "string"
124
+ && /^[a-f0-9-]{36}$/.test(value.token) ? value : null;
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ function writerAbsent(pid) {
131
+ try {
132
+ process.kill(pid, 0);
133
+ return false;
134
+ }
135
+ catch (error) {
136
+ return error instanceof Error && "code" in error && error.code === "ESRCH";
137
+ }
138
+ }
139
+ function reclaimDeadWriter(path) {
140
+ if (!existsSync(path))
141
+ return;
142
+ const owner = readWriterOwner(path);
143
+ // Security: age and malformed/legacy records cannot prove that a writer is absent.
144
+ if (owner === null || !writerAbsent(owner.pid))
145
+ throw new Error("Session writer is live or unverifiable");
146
+ const gate = join(path, ".reclaim");
147
+ mkdirSync(gate);
148
+ try {
149
+ if (readWriterOwner(path)?.token !== owner.token || !writerAbsent(owner.pid))
150
+ throw new Error("Session writer changed during recovery");
151
+ const now = new Date();
152
+ utimesSync(path, now, now);
153
+ unlinkSync(join(path, "owner.json"));
154
+ rmdirSync(gate);
155
+ rmdirSync(path);
156
+ }
157
+ catch (error) {
158
+ try {
159
+ rmdirSync(gate);
160
+ }
161
+ catch { /* Security: a partial recovery remains conservatively blocked. */ }
162
+ throw error;
163
+ }
164
+ }
95
165
  function backend() {
96
166
  return backendOverride ?? (cachedBackend ??= lockfileBackend());
97
167
  }
@@ -10,7 +10,7 @@ export const STARTUP_DESCRIPTOR = Object.freeze({
10
10
  ],
11
11
  "generatedArtifact": {
12
12
  "path": "dist/integrations/pi/startup-public.js",
13
- "sha256": "e6a12f0ec3c5f2ada12bb29f8a02584f0724b37d107c29608b6629a4196cca13"
13
+ "sha256": "c1fca0ab0fe9d078a80faac1dad7d9f719cfc553645e38c2244a9c9ad381c205"
14
14
  },
15
15
  "dependencyLayerIdentity": {
16
16
  "source": "A1_RELEASE_LAYERS",
@@ -19,7 +19,7 @@ export const STARTUP_DESCRIPTOR = Object.freeze({
19
19
  "compileCacheNamespace": {
20
20
  "strategy": "node-abi-version-and-ordered-layer-sha256-v1"
21
21
  },
22
- "identity": "1823698969cb8a551539b6351a7ba7b0756121369e6f9bf898347080d7892ece"
22
+ "identity": "3a54e70a22a20fa71dc2916cff394d4b06a3359677b3423002ff5b2dfbdccd70"
23
23
  });
24
24
 
25
25
  export function loadDeclaredStartupGraph() {
@@ -0,0 +1,2 @@
1
+ /** Loaded only on a failure; persistence must never impede terminal restoration. */
2
+ export declare function writeFatalDiagnostic(directory: string, origin: string, error: unknown, releaseId?: string): Promise<string | null>;
@@ -0,0 +1,59 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ /** Loaded only on a failure; persistence must never impede terminal restoration. */
5
+ export async function writeFatalDiagnostic(directory, origin, error, releaseId) {
6
+ try {
7
+ // Security: only known classifications and code locations are retained, never private messages or objects.
8
+ const cleanupFailed = error instanceof AggregateError && error.cause instanceof Error;
9
+ const primary = cleanupFailed ? error.cause : error;
10
+ const classification = primary instanceof TypeError ? "TypeError" : primary instanceof RangeError ? "RangeError" : "Error";
11
+ const stack = primary instanceof Error && typeof primary.stack === "string" ? primary.stack.slice(0, 32768) : "";
12
+ const locations = stack.split("\n").slice(1).flatMap(line => {
13
+ const match = /\/(?:dist|src)\/([a-zA-Z0-9_./-]+\.[cm]?[jt]s):(\d+):(\d+)\)?$/.exec(line.replaceAll("\\", "/"));
14
+ return match ? [{ file: match[1].slice(0, 160), line: Number(match[2]), column: Number(match[3]) }] : [];
15
+ }).slice(0, 16);
16
+ const delivery = deliveryFailure(primary);
17
+ const record = {
18
+ timestamp: new Date().toISOString(),
19
+ origin: ["uncaughtException", "unhandledRejection", "entry", "delivery"].includes(origin) ? origin : "entry",
20
+ ...(delivery === null ? {} : { deliveryFailure: delivery }),
21
+ classification, cleanupFailed, locations, node: process.version, platform: process.platform,
22
+ releaseId: releaseId && /^[a-zA-Z0-9._-]{1,128}$/.test(releaseId) ? releaseId : "unknown",
23
+ };
24
+ await mkdir(directory, { recursive: true, mode: 0o700 });
25
+ const name = `${origin === "delivery" ? "delivery" : "fatal"}-${Date.now()}-${randomUUID()}.json`;
26
+ const path = join(directory, name);
27
+ const text = JSON.stringify(record, null, 2);
28
+ if (Buffer.byteLength(text) > 16 * 1024)
29
+ return null;
30
+ await writeFile(path, text, { flag: "wx", mode: 0o600 });
31
+ const names = (await readdir(directory)).filter(file => /^(?:fatal|delivery)-\d+-[a-f0-9-]+\.json$/.test(file))
32
+ .sort((a, b) => Number(b.split("-")[1]) - Number(a.split("-")[1]));
33
+ await Promise.all(names.slice(10).map(file => unlink(join(directory, file)).catch(() => undefined)));
34
+ return path;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ function deliveryFailure(error) {
41
+ if (!(error instanceof Error) || !("deliveryFailure" in error))
42
+ return null;
43
+ const value = error.deliveryFailure;
44
+ if (!value || typeof value !== "object")
45
+ return null;
46
+ const record = value;
47
+ if (!["event-count", "retained-bytes", "listener"].includes(String(record.reason))
48
+ || !["snapshot", "session-view", "transcript-block", "session-lifecycle", "status", "editor-state", "terminal-surface",
49
+ "agent-run-started", "agent-run-settled", "assistant-message-completed", "command-outcome", "diagnostic"].includes(String(record.event)))
50
+ return null;
51
+ const result = { reason: String(record.reason), event: String(record.event) };
52
+ for (const key of ["generation", "pending", "bytes"]) {
53
+ const number = record[key];
54
+ if (typeof number !== "number" || !Number.isSafeInteger(number) || number < 0)
55
+ return null;
56
+ result[key] = number;
57
+ }
58
+ return result;
59
+ }
@@ -1,36 +1,8 @@
1
- import { randomUUID } from "node:crypto";
2
- import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
3
1
  import { writeSync } from "node:fs";
4
- import { join } from "node:path";
5
2
  import { boundedCleanup, restoreProcessTerminal } from "./terminal-reset.js";
6
3
  export async function writeFatalDiagnostic(directory, origin, error, releaseId) {
7
4
  try {
8
- // Security: only known classifications and code locations are retained. Error messages,
9
- // function names, arbitrary paths, and request objects are never persisted.
10
- const cleanupFailed = error instanceof AggregateError && error.cause instanceof Error;
11
- const primary = cleanupFailed ? error.cause : error;
12
- const classification = primary instanceof TypeError ? "TypeError" : primary instanceof RangeError ? "RangeError" : "Error";
13
- const stack = primary instanceof Error && typeof primary.stack === "string" ? primary.stack.slice(0, 32768) : "";
14
- const locations = stack.split("\n").slice(1).flatMap(line => {
15
- const match = /\/(?:dist|src)\/([a-zA-Z0-9_./-]+\.[cm]?[jt]s):(\d+):(\d+)\)?$/.exec(line.replaceAll("\\", "/"));
16
- return match ? [{ file: match[1].slice(0, 160), line: Number(match[2]), column: Number(match[3]) }] : [];
17
- }).slice(0, 16);
18
- const record = {
19
- timestamp: new Date().toISOString(),
20
- origin: ["uncaughtException", "unhandledRejection", "entry"].includes(origin) ? origin : "entry",
21
- classification, cleanupFailed, locations, node: process.version, platform: process.platform,
22
- releaseId: releaseId && /^[a-zA-Z0-9._-]{1,128}$/.test(releaseId) ? releaseId : "unknown",
23
- };
24
- await mkdir(directory, { recursive: true, mode: 0o700 });
25
- const name = `fatal-${Date.now()}-${randomUUID()}.json`;
26
- const path = join(directory, name);
27
- const text = JSON.stringify(record, null, 2);
28
- if (Buffer.byteLength(text) > 16 * 1024)
29
- return null;
30
- await writeFile(path, text, { flag: "wx", mode: 0o600 });
31
- const names = (await readdir(directory)).filter(file => /^fatal-\d+-[a-f0-9-]+\.json$/.test(file)).sort().reverse();
32
- await Promise.all(names.slice(10).map(file => unlink(join(directory, file)).catch(() => undefined)));
33
- return path;
5
+ return await (await import("./fatal-diagnostic.js")).writeFatalDiagnostic(directory, origin, error, releaseId);
34
6
  }
35
7
  catch {
36
8
  return null;
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Attributed owned theme resources adapted from @earendil-works/pi-coding-agent 1.5.1 (MIT),
3
- * commit dd30101cfc5b0609411dd80b6f93adbc14db14cf.
2
+ * Attributed owned theme resources adapted from @earendil-works/pi-coding-agent 1.5.2 (MIT),
3
+ * commit b4afdddf44e059c67ccf5097e5317d913521b470.
4
4
  * Upstream dark.json SHA-256: e1f96982f8cb7d2a48eca7db03c9cb282cb1e14a243680cd855638ecd6a251b6
5
5
  * Upstream light.json SHA-256: 2dee6a4be7f765e7fd7728166b7ebf8c29e4ce313f8ec6b38ae50699e3abc2f3
6
6
  * Modifications: removed upstream schema/export metadata; runtime color data is unchanged.
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Attributed owned theme resources adapted from @earendil-works/pi-coding-agent 1.5.1 (MIT),
3
- * commit dd30101cfc5b0609411dd80b6f93adbc14db14cf.
2
+ * Attributed owned theme resources adapted from @earendil-works/pi-coding-agent 1.5.2 (MIT),
3
+ * commit b4afdddf44e059c67ccf5097e5317d913521b470.
4
4
  * Upstream dark.json SHA-256: e1f96982f8cb7d2a48eca7db03c9cb282cb1e14a243680cd855638ecd6a251b6
5
5
  * Upstream light.json SHA-256: 2dee6a4be7f765e7fd7728166b7ebf8c29e4ce313f8ec6b38ae50699e3abc2f3
6
6
  * Modifications: removed upstream schema/export metadata; runtime color data is unchanged.
@@ -1,8 +1,9 @@
1
+ import type { ProgressSpinnerSource } from "../../../contracts/presentation/index.js";
1
2
  import { type PiShellComponentPort, type PiShellEditorOptions } from "./shell-shared-facade.js";
2
3
  export interface PiShellOperationLoaderPort extends PiShellComponentPort {
3
4
  readonly signal: AbortSignal;
4
5
  /** Relabels the running operation; the pinned comparison loader keeps its first label. */
5
6
  readonly setMessage?: (message: string) => void;
6
7
  }
7
- export declare function createPiShellShareOperationDialog(runtime: Pick<PiShellEditorOptions, "getColumns" | "getRows" | "requestRender">, message: string): PiShellOperationLoaderPort;
8
+ export declare function createPiShellShareOperationDialog(runtime: Pick<PiShellEditorOptions, "getColumns" | "getRows" | "requestRender">, message: string, spinner?: ProgressSpinnerSource): PiShellOperationLoaderPort;
8
9
  export declare function createPiShellOperationLoader(runtime: Pick<PiShellEditorOptions, "getColumns" | "getRows" | "requestRender">, message: string): PiShellOperationLoaderPort;
@@ -1,5 +1,6 @@
1
1
  import { CancellableLoader, Spacer, Text, } from "@earendil-works/pi-tui";
2
2
  import { BorderedLoader, DynamicBorder } from "../startup-public.js";
3
+ import { progressSpinnerFrame } from "../../../ui/components/index.js";
3
4
  import { adoptPiModalFrame, PiModalFrame } from "./modal-frame.js";
4
5
  import { DIALOG_CLOSE_SHORTCUT_HINT, paintPiBorder, piTheme, renderPiModalShortcutHints } from "./theme.js";
5
6
  import { componentPort, createTuiFacade, ensureTheme, } from "./shell-shared-facade.js";
@@ -7,26 +8,33 @@ import { componentPort, createTuiFacade, ensureTheme, } from "./shell-shared-fac
7
8
  class ShareOperationDialog {
8
9
  #loader;
9
10
  #frame;
10
- constructor(runtime, message) {
11
+ #releaseSpinner;
12
+ constructor(runtime, message, spinner) {
11
13
  const theme = piTheme();
12
- this.#loader = new CancellableLoader(createTuiFacade(runtime), text => theme.fg("accent", text), text => theme.fg("muted", text), message);
14
+ let phase = -1;
15
+ this.#loader = new CancellableLoader(createTuiFacade(runtime), currentFrame => {
16
+ phase += 1;
17
+ const glyph = progressSpinnerFrame(spinner?.snapshot() ?? "current", phase, currentFrame);
18
+ return glyph === "" ? "" : piTheme().fg("accent", glyph);
19
+ }, text => piTheme().fg("muted", text), message);
13
20
  this.#frame = new PiModalFrame(new DynamicBorder(paintPiBorder), [
14
21
  new Text(theme.fg("accent", theme.bold("Share")), 0, 0),
15
22
  this.#loader,
16
23
  new Spacer(1),
17
24
  new Text(renderPiModalShortcutHints([DIALOG_CLOSE_SHORTCUT_HINT]), 0, 0),
18
25
  ], new DynamicBorder(paintPiBorder));
26
+ this.#releaseSpinner = spinner?.onChange(() => this.#loader.invalidate());
19
27
  }
20
28
  get signal() { return this.#loader.signal; }
21
29
  setMessage(message) { this.#loader.setMessage(message); }
22
30
  invalidate() { this.#frame.invalidate(); }
23
31
  render(width) { return this.#frame.render(width); }
24
32
  handleInput(data) { this.#loader.handleInput(data); }
25
- dispose() { this.#loader.dispose(); }
33
+ dispose() { this.#releaseSpinner?.(); this.#loader.dispose(); }
26
34
  }
27
- export function createPiShellShareOperationDialog(runtime, message) {
35
+ export function createPiShellShareOperationDialog(runtime, message, spinner) {
28
36
  ensureTheme();
29
- const dialog = new ShareOperationDialog(runtime, message);
37
+ const dialog = new ShareOperationDialog(runtime, message, spinner);
30
38
  return { ...componentPort(dialog), signal: dialog.signal, setMessage: message => dialog.setMessage(message) };
31
39
  }
32
40
  export function createPiShellOperationLoader(runtime, message) {
@@ -1,8 +1,9 @@
1
1
  import type { OwnedUiShellKeybindings } from "../../../contracts/owned-ui/index.js";
2
+ import type { ProgressSpinnerSource } from "../../../contracts/presentation/index.js";
2
3
  import type { OwnedUiSessionViewModel } from "../../../contracts/owned-ui/index.js";
3
4
  import { type PiShellViewComponentPort, type PiShellStatusPort, type PiShellProgressStatusPresentation, type PiShellQueuedInputPort, type PiShellHeaderPort, type PiShellResourceEntry, type PiShellLoadedResourcesPort, type PiShellHeaderOptions, type PiShellEditorOptions } from "./shell-shared-facade.js";
4
5
  export declare function createPiShellHeader(options?: PiShellHeaderOptions): PiShellHeaderPort;
5
6
  export declare function createPiShellLoadedResources(resources: readonly PiShellResourceEntry[], initialExpanded?: boolean): PiShellLoadedResourcesPort;
6
- export declare function createPiShellStatus(view: OwnedUiSessionViewModel, progressStatus: PiShellProgressStatusPresentation, runtime?: Pick<PiShellEditorOptions, "getColumns" | "getRows" | "requestRender">): PiShellStatusPort;
7
+ export declare function createPiShellStatus(view: OwnedUiSessionViewModel, progressStatus: PiShellProgressStatusPresentation, runtime?: Pick<PiShellEditorOptions, "getColumns" | "getRows" | "requestRender">, spinner?: ProgressSpinnerSource): PiShellStatusPort;
7
8
  export declare function createPiShellFooter(view: OwnedUiSessionViewModel, cwd: string, profile?: "pi" | "a1", showLevel?: () => boolean): PiShellViewComponentPort;
8
9
  export declare function createPiQueuedInputStatus(submissions: readonly string[], presentation?: "pinned" | "custom-viewport", getKeybindings?: () => OwnedUiShellKeybindings): PiShellQueuedInputPort;
@@ -1,3 +1,4 @@
1
+ import { progressSpinnerFrame } from "../../../ui/components/progress-status.js";
1
2
  import { piKeybindings } from "./shell-shared-facade.js";
2
3
  import { rawKeyHint, VERSION } from "../startup-public.js";
3
4
  import { Spacer, Text, } from "@earendil-works/pi-tui";
@@ -74,7 +75,7 @@ export function createPiShellLoadedResources(resources, initialExpanded = false)
74
75
  invalidate() { },
75
76
  };
76
77
  }
77
- export function createPiShellStatus(view, progressStatus, runtime) {
78
+ export function createPiShellStatus(view, progressStatus, runtime, spinner) {
78
79
  ensureTheme();
79
80
  const statusUi = createTuiFacade(runtime ?? { getColumns: () => 80, getRows: () => 24, requestRender() { } });
80
81
  let workingOverride;
@@ -83,7 +84,7 @@ export function createPiShellStatus(view, progressStatus, runtime) {
83
84
  let progressPresentation = "pinned";
84
85
  let placement = statusPlacement(view, workingOverride, workingOverrideActive);
85
86
  const liveStatusText = () => progressStatus.text(liveWorkingText(view, workingOverride, progressPresentation), progressPresentation);
86
- let component = statusComponent(view, statusUi, outputPad, liveStatusText, placement, progressPresentation, progressStatus);
87
+ let component = statusComponent(view, statusUi, outputPad, liveStatusText, placement, progressPresentation, progressStatus, spinner);
87
88
  let signature = statusSignature(view, workingOverride, outputPad, placement, progressPresentation);
88
89
  const rebuild = () => {
89
90
  const nextPlacement = statusPlacement(view, workingOverride, workingOverrideActive);
@@ -105,8 +106,12 @@ export function createPiShellStatus(view, progressStatus, runtime) {
105
106
  component.dispose();
106
107
  placement = nextPlacement;
107
108
  signature = nextSignature;
108
- component = statusComponent(view, statusUi, outputPad, liveStatusText, placement, progressPresentation, progressStatus);
109
+ component = statusComponent(view, statusUi, outputPad, liveStatusText, placement, progressPresentation, progressStatus, spinner);
109
110
  };
111
+ const releaseSpinner = spinner?.onChange(() => {
112
+ if (progressPresentation === "custom-viewport")
113
+ component?.invalidate();
114
+ });
110
115
  return {
111
116
  render: width => appendInstalledUpdateStatus(component?.render(width) ?? [], width, view, progressPresentation),
112
117
  renderDock: width => placement === "dock" ? component?.render(width) ?? [] : [],
@@ -131,6 +136,7 @@ export function createPiShellStatus(view, progressStatus, runtime) {
131
136
  rebuild();
132
137
  },
133
138
  dispose() {
139
+ releaseSpinner?.();
134
140
  if (component !== undefined && "dispose" in component && typeof component.dispose === "function")
135
141
  component.dispose();
136
142
  component = undefined;
@@ -201,21 +207,22 @@ function liveWorkingText(view, workingOverride, progressPresentation) {
201
207
  : message;
202
208
  }
203
209
  class OwnedWorkingStatusIndicator extends StatusIndicator {
204
- constructor(ui, message, frame) {
210
+ constructor(ui, message, frame, spinner) {
205
211
  let phase = -1;
206
- super("working", ui, spinner => {
212
+ super("working", ui, defaultFrame => {
207
213
  phase += 1;
208
- return piTheme().fg("accent", spinner);
214
+ const glyph = progressSpinnerFrame(spinner?.snapshot() ?? "current", phase, defaultFrame);
215
+ return glyph === "" ? "" : piTheme().fg("accent", glyph);
209
216
  }, text => frame(text, phase, {
210
217
  muted: value => piTheme().fg("muted", value),
211
218
  highlight: value => piTheme().fg("text", value),
212
219
  }), message);
213
220
  }
214
221
  }
215
- function statusComponent(view, ui, outputPad, liveStatusText, placement, progressPresentation, progressStatus) {
222
+ function statusComponent(view, ui, outputPad, liveStatusText, placement, progressPresentation, progressStatus, spinner) {
216
223
  if (placement === "live") {
217
224
  return progressPresentation === "custom-viewport"
218
- ? new OwnedWorkingStatusIndicator(ui, liveStatusText(), progressStatus.frame)
225
+ ? new OwnedWorkingStatusIndicator(ui, liveStatusText(), progressStatus.frame, spinner)
219
226
  : new WorkingStatusIndicator(ui, liveStatusText());
220
227
  }
221
228
  if (placement === "dock") {