@pellux/goodvibes-tui 1.1.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (267) hide show
  1. package/CHANGELOG.md +85 -21
  2. package/README.md +28 -18
  3. package/docs/foundation-artifacts/operator-contract.json +4045 -1763
  4. package/package.json +2 -2
  5. package/src/audio/spoken-turn-controller.ts +12 -2
  6. package/src/audio/spoken-turn-wiring.ts +2 -1
  7. package/src/cli/help.ts +8 -1
  8. package/src/cli/service-posture.ts +2 -1
  9. package/src/cli/status.ts +2 -1
  10. package/src/cli/surface-command.ts +2 -2
  11. package/src/core/alert-gating.ts +67 -0
  12. package/src/core/approval-alert.ts +72 -0
  13. package/src/core/budget-breach-notifier.ts +106 -0
  14. package/src/core/composer-state.ts +11 -3
  15. package/src/core/conversation-line-cache.ts +27 -3
  16. package/src/core/conversation-rendering.ts +90 -14
  17. package/src/core/conversation.ts +18 -0
  18. package/src/core/focus-tracker.ts +41 -0
  19. package/src/core/long-task-notifier.ts +33 -6
  20. package/src/core/stream-event-wiring.ts +20 -1
  21. package/src/core/system-message-noise.ts +87 -0
  22. package/src/core/system-message-router.ts +104 -51
  23. package/src/core/turn-cancellation.ts +25 -0
  24. package/src/core/turn-event-wiring.ts +72 -3
  25. package/src/daemon/cli.ts +29 -2
  26. package/src/daemon/handlers/register.ts +8 -1
  27. package/src/daemon/service-commands.ts +329 -0
  28. package/src/export/cost-utils.ts +36 -0
  29. package/src/input/autocomplete.ts +27 -1
  30. package/src/input/command-registry.ts +84 -5
  31. package/src/input/commands/checkpoint-runtime.ts +280 -0
  32. package/src/input/commands/codebase-runtime.ts +232 -0
  33. package/src/input/commands/config.ts +43 -3
  34. package/src/input/commands/eval.ts +1 -1
  35. package/src/input/commands/health-runtime.ts +10 -2
  36. package/src/input/commands/image-runtime.ts +112 -0
  37. package/src/input/commands/intelligence-runtime.ts +11 -1
  38. package/src/input/commands/local-auth-runtime.ts +4 -1
  39. package/src/input/commands/local-runtime.ts +9 -3
  40. package/src/input/commands/marketplace-runtime.ts +2 -2
  41. package/src/input/commands/memory.ts +73 -35
  42. package/src/input/commands/operator-panel-runtime.ts +71 -31
  43. package/src/input/commands/planning-runtime.ts +116 -4
  44. package/src/input/commands/platform-sandbox-runtime.ts +1 -6
  45. package/src/input/commands/plugin-runtime.ts +2 -2
  46. package/src/input/commands/policy-dispatch.ts +21 -0
  47. package/src/input/commands/provider-accounts-runtime.ts +1 -1
  48. package/src/input/commands/qrcode-runtime.ts +26 -6
  49. package/src/input/commands/recall-review.ts +35 -0
  50. package/src/input/commands/remote-runtime-setup.ts +5 -3
  51. package/src/input/commands/remote-runtime.ts +3 -3
  52. package/src/input/commands/runtime-services.ts +54 -13
  53. package/src/input/commands/services-runtime.ts +1 -1
  54. package/src/input/commands/session-content.ts +20 -9
  55. package/src/input/commands/session-workflow.ts +16 -1
  56. package/src/input/commands/settings-sync-runtime.ts +16 -4
  57. package/src/input/commands/shell-core.ts +25 -8
  58. package/src/input/commands/skills-runtime.ts +2 -8
  59. package/src/input/commands/subscription-runtime.ts +2 -2
  60. package/src/input/commands/test-runtime.ts +277 -0
  61. package/src/input/commands/websearch-runtime.ts +101 -0
  62. package/src/input/commands/work-plan-runtime.ts +36 -10
  63. package/src/input/commands/workstream-runtime.ts +488 -0
  64. package/src/input/commands.ts +12 -0
  65. package/src/input/config-modal-types.ts +175 -0
  66. package/src/input/config-modal.ts +592 -0
  67. package/src/input/feed-context-factory.ts +10 -8
  68. package/src/input/handler-command-route.ts +37 -20
  69. package/src/input/handler-content-actions.ts +17 -2
  70. package/src/input/handler-feed-routes.ts +74 -114
  71. package/src/input/handler-feed.ts +79 -27
  72. package/src/input/handler-interactions.ts +1 -3
  73. package/src/input/handler-modal-routes.ts +136 -6
  74. package/src/input/handler-modal-stack.ts +14 -7
  75. package/src/input/handler-modal-token-routes.ts +16 -49
  76. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  77. package/src/input/handler-onboarding.ts +71 -59
  78. package/src/input/handler-picker-routes.ts +26 -102
  79. package/src/input/handler-shortcuts.ts +74 -14
  80. package/src/input/handler-types.ts +2 -6
  81. package/src/input/handler-ui-state.ts +10 -22
  82. package/src/input/handler.ts +12 -29
  83. package/src/input/keybindings.ts +23 -8
  84. package/src/input/model-picker-types.ts +24 -6
  85. package/src/input/model-picker.ts +77 -2
  86. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  87. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  88. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  89. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  90. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  91. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  92. package/src/input/panel-integration-actions.ts +9 -170
  93. package/src/input/panel-mouse-geometry.ts +97 -0
  94. package/src/input/panel-paste-flood-guard.ts +86 -0
  95. package/src/input/selection-modal.ts +11 -0
  96. package/src/input/session-picker-modal.ts +44 -4
  97. package/src/input/settings-modal-data.ts +146 -2
  98. package/src/input/settings-modal-mutations.ts +6 -4
  99. package/src/input/settings-modal-types.ts +4 -2
  100. package/src/main.ts +49 -51
  101. package/src/panels/agent-inspector-shared.ts +2 -1
  102. package/src/panels/base-panel.ts +22 -1
  103. package/src/panels/builtin/agent.ts +21 -107
  104. package/src/panels/builtin/development.ts +15 -61
  105. package/src/panels/builtin/knowledge.ts +8 -32
  106. package/src/panels/builtin/operations.ts +84 -428
  107. package/src/panels/builtin/session.ts +21 -112
  108. package/src/panels/builtin/shared.ts +18 -46
  109. package/src/panels/builtin-modals.ts +218 -0
  110. package/src/panels/builtin-panels.ts +5 -0
  111. package/src/panels/cost-tracker-panel.ts +36 -10
  112. package/src/panels/diff-panel.ts +12 -43
  113. package/src/panels/eval-registry.ts +60 -0
  114. package/src/panels/fleet-deep-link.ts +31 -0
  115. package/src/panels/fleet-panel-format.ts +62 -0
  116. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  117. package/src/panels/fleet-panel.ts +758 -0
  118. package/src/panels/fleet-read-model.ts +520 -0
  119. package/src/panels/fleet-steer.ts +125 -0
  120. package/src/panels/fleet-stop.ts +153 -0
  121. package/src/panels/fleet-tabs.ts +230 -0
  122. package/src/panels/fleet-transcript.ts +356 -0
  123. package/src/panels/index.ts +7 -31
  124. package/src/panels/modals/hooks-modal.ts +187 -0
  125. package/src/panels/modals/keybindings-modal.ts +166 -0
  126. package/src/panels/modals/knowledge-modal.ts +158 -0
  127. package/src/panels/modals/local-auth-modal.ts +132 -0
  128. package/src/panels/modals/marketplace-modal.ts +218 -0
  129. package/src/panels/modals/memory-modal.ts +180 -0
  130. package/src/panels/modals/modal-surface-helpers.ts +54 -0
  131. package/src/panels/modals/modal-theme.ts +42 -0
  132. package/src/panels/modals/pairing-modal.ts +163 -0
  133. package/src/panels/modals/planning-modal.ts +304 -0
  134. package/src/panels/modals/plugins-modal.ts +174 -0
  135. package/src/panels/modals/policy-modal.ts +256 -0
  136. package/src/panels/modals/provider-health-modal.ts +136 -0
  137. package/src/panels/modals/remote-modal.ts +115 -0
  138. package/src/panels/modals/sandbox-modal.ts +150 -0
  139. package/src/panels/modals/security-modal.ts +217 -0
  140. package/src/panels/modals/services-modal.ts +179 -0
  141. package/src/panels/modals/settings-sync-modal.ts +142 -0
  142. package/src/panels/modals/skills-modal.ts +189 -0
  143. package/src/panels/modals/subscription-modal.ts +172 -0
  144. package/src/panels/modals/work-plan-modal.ts +177 -0
  145. package/src/panels/panel-confirm-overlay.ts +72 -0
  146. package/src/panels/panel-manager.ts +123 -9
  147. package/src/panels/polish-core.ts +38 -25
  148. package/src/panels/project-planning-answer-actions.ts +8 -11
  149. package/src/panels/skills-panel.ts +7 -51
  150. package/src/panels/types.ts +37 -0
  151. package/src/permissions/hunk-selection.ts +179 -0
  152. package/src/permissions/prompt.ts +216 -13
  153. package/src/renderer/autocomplete-overlay.ts +28 -2
  154. package/src/renderer/compaction-history-modal.ts +19 -3
  155. package/src/renderer/compaction-preview.ts +13 -2
  156. package/src/renderer/compaction-quality.ts +100 -0
  157. package/src/renderer/compositor.ts +2 -3
  158. package/src/renderer/config-modal.ts +95 -0
  159. package/src/renderer/conversation-overlays.ts +10 -17
  160. package/src/renderer/fleet-tab-strip.ts +56 -0
  161. package/src/renderer/footer-tips.ts +15 -3
  162. package/src/renderer/fullscreen-primitives.ts +32 -22
  163. package/src/renderer/git-status.ts +43 -1
  164. package/src/renderer/help-overlay.ts +2 -2
  165. package/src/renderer/layout.ts +0 -4
  166. package/src/renderer/markdown.ts +7 -3
  167. package/src/renderer/modal-factory.ts +25 -20
  168. package/src/renderer/model-workspace.ts +62 -4
  169. package/src/renderer/overlay-box.ts +21 -17
  170. package/src/renderer/panel-workspace-bar.ts +8 -2
  171. package/src/renderer/process-indicator.ts +14 -3
  172. package/src/renderer/selection-modal-overlay.ts +6 -1
  173. package/src/renderer/session-picker-modal.ts +196 -3
  174. package/src/renderer/settings-modal-helpers.ts +2 -0
  175. package/src/renderer/settings-modal.ts +7 -0
  176. package/src/renderer/shell-surface.ts +8 -1
  177. package/src/renderer/status-glyphs.ts +14 -15
  178. package/src/renderer/system-message.ts +15 -3
  179. package/src/renderer/terminal-bg-probe.ts +339 -0
  180. package/src/renderer/terminal-escapes.ts +20 -0
  181. package/src/renderer/theme-mode-config.ts +67 -0
  182. package/src/renderer/theme.ts +91 -1
  183. package/src/renderer/thinking.ts +11 -3
  184. package/src/renderer/tool-call.ts +15 -9
  185. package/src/renderer/tool-result-summary.ts +148 -0
  186. package/src/renderer/turn-injection.ts +133 -0
  187. package/src/renderer/ui-factory.ts +154 -85
  188. package/src/renderer/ui-primitives.ts +30 -129
  189. package/src/runtime/bootstrap-command-context.ts +27 -1
  190. package/src/runtime/bootstrap-command-parts.ts +41 -10
  191. package/src/runtime/bootstrap-core.ts +47 -15
  192. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  193. package/src/runtime/bootstrap-shell.ts +48 -17
  194. package/src/runtime/bootstrap.ts +129 -22
  195. package/src/runtime/code-index-services.ts +135 -0
  196. package/src/runtime/legacy-daemon-migration.ts +516 -0
  197. package/src/runtime/memory-fold.ts +26 -0
  198. package/src/runtime/onboarding/derivation.ts +7 -2
  199. package/src/runtime/onboarding/snapshot.ts +27 -1
  200. package/src/runtime/onboarding/types.ts +31 -1
  201. package/src/runtime/operator-token-cleanup.ts +82 -1
  202. package/src/runtime/orchestrator-core-services.ts +59 -0
  203. package/src/runtime/process-lifecycle.ts +3 -1
  204. package/src/runtime/resume-notice.ts +209 -0
  205. package/src/runtime/services.ts +98 -46
  206. package/src/runtime/session-inbound-inputs.ts +252 -0
  207. package/src/runtime/session-spine-transport.ts +64 -0
  208. package/src/runtime/terminal-output-guard.ts +15 -8
  209. package/src/runtime/ui-services.ts +27 -3
  210. package/src/runtime/workstream-services.ts +327 -0
  211. package/src/runtime/wrfc-persistence.ts +124 -17
  212. package/src/shell/blocking-input.ts +68 -4
  213. package/src/shell/recovery-input-helpers.ts +170 -1
  214. package/src/shell/ui-openers.ts +171 -26
  215. package/src/utils/format-duration.ts +8 -18
  216. package/src/utils/splash-lines.ts +1 -1
  217. package/src/utils/terminal-width.ts +52 -0
  218. package/src/version.ts +1 -1
  219. package/src/panels/agent-inspector-panel.ts +0 -786
  220. package/src/panels/approval-panel.ts +0 -252
  221. package/src/panels/automation-control-panel.ts +0 -479
  222. package/src/panels/cockpit-panel.ts +0 -481
  223. package/src/panels/cockpit-read-model.ts +0 -238
  224. package/src/panels/communication-panel.ts +0 -256
  225. package/src/panels/control-plane-panel.ts +0 -470
  226. package/src/panels/debug-panel.ts +0 -615
  227. package/src/panels/docs-panel.ts +0 -384
  228. package/src/panels/eval-panel.ts +0 -627
  229. package/src/panels/file-explorer-panel.ts +0 -673
  230. package/src/panels/file-preview-panel.ts +0 -517
  231. package/src/panels/hooks-panel.ts +0 -401
  232. package/src/panels/incident-review-panel.ts +0 -406
  233. package/src/panels/intelligence-panel.ts +0 -383
  234. package/src/panels/knowledge-graph-panel.ts +0 -515
  235. package/src/panels/marketplace-panel.ts +0 -399
  236. package/src/panels/memory-panel.ts +0 -558
  237. package/src/panels/ops-control-panel.ts +0 -329
  238. package/src/panels/ops-strategy-panel.ts +0 -321
  239. package/src/panels/orchestration-panel.ts +0 -465
  240. package/src/panels/panel-list-panel.ts +0 -566
  241. package/src/panels/plan-dashboard-panel.ts +0 -707
  242. package/src/panels/policy-panel.ts +0 -517
  243. package/src/panels/project-planning-panel.ts +0 -731
  244. package/src/panels/provider-health-panel.ts +0 -678
  245. package/src/panels/provider-health-tracker.ts +0 -310
  246. package/src/panels/provider-health-views.ts +0 -567
  247. package/src/panels/qr-panel.ts +0 -280
  248. package/src/panels/remote-panel.ts +0 -534
  249. package/src/panels/routes-panel.ts +0 -241
  250. package/src/panels/sandbox-panel.ts +0 -456
  251. package/src/panels/security-panel.ts +0 -447
  252. package/src/panels/services-panel.ts +0 -329
  253. package/src/panels/session-browser-panel.ts +0 -496
  254. package/src/panels/settings-sync-panel.ts +0 -398
  255. package/src/panels/subscription-panel.ts +0 -342
  256. package/src/panels/symbol-outline-panel.ts +0 -619
  257. package/src/panels/system-messages-panel.ts +0 -364
  258. package/src/panels/tasks-panel.ts +0 -608
  259. package/src/panels/thinking-panel.ts +0 -333
  260. package/src/panels/tool-inspector-panel.ts +0 -537
  261. package/src/panels/work-plan-panel.ts +0 -540
  262. package/src/panels/worktree-panel.ts +0 -360
  263. package/src/panels/wrfc-panel.ts +0 -790
  264. package/src/renderer/agent-detail-modal.ts +0 -466
  265. package/src/renderer/live-tail-modal.ts +0 -156
  266. package/src/renderer/process-modal.ts +0 -671
  267. package/src/renderer/qr-renderer.ts +0 -120
@@ -6,7 +6,7 @@ import { createUiReadModels, type UiReadModels, type UiReadModelOptions } from '
6
6
  import type { ForensicsRegistry } from '@/runtime/index.ts';
7
7
  import type { ControlPlaneRecentEvent } from '@pellux/goodvibes-sdk/platform/control-plane';
8
8
  import type { ApprovalBroker } from '@pellux/goodvibes-sdk/platform/control-plane';
9
- import type { SharedSessionBroker } from '@pellux/goodvibes-sdk/platform/control-plane';
9
+ import { SessionUnionCache, type SessionReadFacade } from '@pellux/goodvibes-sdk/platform/runtime/session-spine';
10
10
  import type { ShellPathService } from '@/runtime/index.ts';
11
11
  import type { HostServiceStatus } from '@/runtime/index.ts';
12
12
  import type { SecretsManager } from '../config/secrets.ts';
@@ -39,7 +39,13 @@ export interface UiProviderServices {
39
39
 
40
40
  export interface UiSessionServices {
41
41
  readonly sessionManager: RuntimeServices['sessionManager'];
42
- readonly sessionBroker: SharedSessionBroker;
42
+ /**
43
+ * The panel-facing session READ source is the cross-surface union
44
+ * facade, not the raw local broker — in adopted-daemon ('external') mode the
45
+ * local broker misses sessions hosted for other surfaces. Sync signature
46
+ * preserved; the facade's cache makes that honest (see session-union-cache.ts).
47
+ */
48
+ readonly sessionBroker: SessionReadFacade;
43
49
  readonly sessionOrchestration: RuntimeServices['sessionOrchestration'];
44
50
  readonly sessionMemoryStore: RuntimeServices['sessionMemoryStore'];
45
51
  }
@@ -55,6 +61,7 @@ export interface UiPlatformServices {
55
61
  readonly tokenAuditor: RuntimeServices['tokenAuditor'];
56
62
  readonly replayEngine: RuntimeServices['replayEngine'];
57
63
  readonly webhookNotifier: RuntimeServices['webhookNotifier'];
64
+ readonly focusTracker: RuntimeServices['focusTracker'];
58
65
  readonly policyRuntimeState: RuntimeServices['policyRuntimeState'];
59
66
  readonly externalServices?: {
60
67
  inspect(): {
@@ -64,6 +71,10 @@ export interface UiPlatformServices {
64
71
  readonly httpListenerPortInUse?: boolean;
65
72
  readonly daemonStatus?: HostServiceStatus;
66
73
  readonly httpListenerStatus?: HostServiceStatus;
74
+ // Honest session-spine posture for the footer segment, driven by
75
+ // the spine client's own live wire attempts (not the one-shot adopt probe).
76
+ readonly sessionSpineActive?: boolean;
77
+ readonly sessionSpineStatus?: 'unknown' | 'online' | 'offline';
67
78
  };
68
79
  restart(): Promise<{
69
80
  readonly daemonRunning: boolean;
@@ -101,6 +112,10 @@ export interface UiRuntimeSharedServices {
101
112
  readonly distributedRuntime: RuntimeServices['distributedRuntime'];
102
113
  readonly remoteRunnerRegistry: RuntimeServices['remoteRunnerRegistry'] & RemoteRunnerRegistry;
103
114
  readonly remoteSupervisor: RuntimeServices['remoteSupervisor'] & RemoteSupervisor;
115
+ /** W2.2: the shared live process registry backing the Fleet panel. */
116
+ readonly processRegistry: RuntimeServices['processRegistry'];
117
+ /** Wave-3 (W3.2): the shared runtime event bus — the Fleet panel subscribes to its 'communication' domain for the honest steer-consumed signal. */
118
+ readonly runtimeBus: RuntimeServices['runtimeBus'];
104
119
  };
105
120
  }
106
121
 
@@ -121,6 +136,12 @@ export interface UiRuntimeServices {
121
136
  export interface UiRuntimeServicesOptions extends UiReadModelOptions {
122
137
  readonly forensicsRegistry?: ForensicsRegistry;
123
138
  readonly getControlPlaneRecentEvents?: (limit: number) => readonly ControlPlaneRecentEvent[];
139
+ /**
140
+ * The shared session read facade constructed in bootstrap-core (so
141
+ * bootstrap.ts can drive its adopted-external/embedded/local mode). When omitted
142
+ * (test callers), a local-only passthrough facade over the broker is auto-created.
143
+ */
144
+ readonly sessionUnionCache?: SessionUnionCache;
124
145
  }
125
146
 
126
147
  export function createUiRuntimeServices(
@@ -152,7 +173,7 @@ export function createUiRuntimeServices(
152
173
  },
153
174
  sessions: {
154
175
  sessionManager: runtimeServices.sessionManager,
155
- sessionBroker: runtimeServices.sessionBroker,
176
+ sessionBroker: options.sessionUnionCache ?? new SessionUnionCache({ local: runtimeServices.sessionBroker }),
156
177
  sessionOrchestration: runtimeServices.sessionOrchestration,
157
178
  sessionMemoryStore: runtimeServices.sessionMemoryStore,
158
179
  },
@@ -167,6 +188,7 @@ export function createUiRuntimeServices(
167
188
  tokenAuditor: runtimeServices.tokenAuditor,
168
189
  replayEngine: runtimeServices.replayEngine,
169
190
  webhookNotifier: runtimeServices.webhookNotifier,
191
+ focusTracker: runtimeServices.focusTracker,
170
192
  policyRuntimeState: runtimeServices.policyRuntimeState,
171
193
  },
172
194
  planning: {
@@ -183,6 +205,8 @@ export function createUiRuntimeServices(
183
205
  distributedRuntime: runtimeServices.distributedRuntime,
184
206
  remoteRunnerRegistry: runtimeServices.remoteRunnerRegistry,
185
207
  remoteSupervisor: runtimeServices.remoteSupervisor,
208
+ processRegistry: runtimeServices.processRegistry,
209
+ runtimeBus: runtimeServices.runtimeBus,
186
210
  },
187
211
  events: createUiRuntimeEvents(runtimeServices.runtimeBus),
188
212
  readModels: createUiReadModels(runtimeServices, options),
@@ -0,0 +1,327 @@
1
+ // ---------------------------------------------------------------------------
2
+ // workstream-services.ts — Wave 4 (wo703)
3
+ //
4
+ // Constructs the TUI's OrchestrationEngine instance (@pellux/goodvibes-sdk/
5
+ // platform/orchestration, landed on SDK main as wo701/W4.1) and a thin
6
+ // command-facing facade around it. Extracted into its own module rather than
7
+ // built inline in services.ts: services.ts sits at the architecture check's
8
+ // 800-line cap (scripts/check-architecture.ts), so any new service gets its
9
+ // own construction module and a single wiring call there (mirrors
10
+ // createWorkflowServices's one-function-bundle shape).
11
+ //
12
+ // Why a facade, not the bare engine: createOrchestrationEngine() is a pure
13
+ // construction call with no auto-start and NO concept of a not-yet-launched
14
+ // "proposal" a human can review/edit before anything is spent — its only
15
+ // creation entry point, createWorkstream(), immediately materializes a real,
16
+ // ticking-eligible Workstream. The /workstream command module
17
+ // (input/commands/workstream-runtime.ts) needs a create -> propose -> approve
18
+ // -> launch flow (Pillar-3 doctrine: render the plan in the transcript before
19
+ // spending anything, mirroring /plan's approve step). REALITY-WINS DIVERGENCE
20
+ // from the wo703 design brief: the brief recommended an "engine-owned/
21
+ // journal-backed" draft so `/workstream status` could re-render a pending
22
+ // proposal after a restart; the real engine (verified against the linked SDK
23
+ // build) exposes no pre-creation draft concept at all, so WorkstreamDraft
24
+ // below is process-lifetime-only state, owned by this module's facade
25
+ // instance (constructed once, threaded onto CommandContext) — never a
26
+ // module-level ambient global. A draft that existed only in memory is lost on
27
+ // restart, same as an unsent chat draft; that is a real, stated limitation,
28
+ // not a bug.
29
+ // ---------------------------------------------------------------------------
30
+
31
+ import {
32
+ createOrchestrationEngine,
33
+ fromChainSpec,
34
+ fromPlanProposal,
35
+ type CreateWorkstreamInput,
36
+ type OrchestrationEngine,
37
+ type WorkstreamIsolation,
38
+ } from '@pellux/goodvibes-sdk/platform/orchestration';
39
+ export type { OrchestrationEngine } from '@pellux/goodvibes-sdk/platform/orchestration';
40
+ import {
41
+ AdaptivePlanner,
42
+ decomposeGoal,
43
+ type DecompositionGate,
44
+ type DecompositionServiceConfig,
45
+ type DecomposeGoalResult,
46
+ type PlannerInputs,
47
+ type PlanProposal,
48
+ } from '@pellux/goodvibes-sdk/platform/core';
49
+ import { createAgentManagerDecompositionRunner } from '@pellux/goodvibes-sdk/platform/agents';
50
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
51
+ import type { AgentManager } from '@pellux/goodvibes-sdk/platform/tools';
52
+ import type { RuntimeEventBus } from '@/runtime/index.ts';
53
+ import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
54
+
55
+ export interface WorkstreamServicesDeps {
56
+ readonly agentManager: Pick<AgentManager, 'spawn' | 'getStatus' | 'cancel' | 'registerCancellationSignal' | 'releaseCancellationSignal'>;
57
+ readonly configManager: Pick<ConfigManager, 'get' | 'getCategory'>;
58
+ readonly adaptivePlanner: AdaptivePlanner;
59
+ readonly runtimeBus: RuntimeEventBus;
60
+ readonly projectRoot: string;
61
+ }
62
+
63
+ /**
64
+ * Honest provenance for how a draft's decomposition was produced. Derived from
65
+ * the SDK decomposition service's outcome so the draft render can state plainly
66
+ * whether a planning agent decomposed the goal, or the heuristic path did (and
67
+ * if so, why).
68
+ */
69
+ export interface WorkstreamDraftProvenance {
70
+ readonly kind: 'agent' | 'heuristic-configured' | 'gate-declined' | 'fallback';
71
+ readonly itemCount: number;
72
+ readonly agentCostUsd?: number | undefined;
73
+ readonly agentTokens?: number | undefined;
74
+ readonly elapsedMs?: number | undefined;
75
+ readonly fallbackReason?: string | undefined;
76
+ }
77
+
78
+ /** A not-yet-launched /workstream proposal. See this file's header doc for why it lives here rather than on the engine. */
79
+ export interface WorkstreamDraft {
80
+ readonly id: string;
81
+ task: string;
82
+ spec: CreateWorkstreamInput;
83
+ readonly gate: DecompositionGate;
84
+ /** The engine-agnostic decomposition proposal (model- or heuristic-produced). */
85
+ proposal: PlanProposal;
86
+ /** How that proposal came to be, for honest rendering. */
87
+ provenance: WorkstreamDraftProvenance;
88
+ approved: boolean;
89
+ readonly createdAt: number;
90
+ }
91
+
92
+ /** `ctx.session.workstreamEngine`'s real shape: the live engine plus the draft-proposal bookkeeping the engine itself has no concept of. */
93
+ export interface WorkstreamCommandService {
94
+ readonly engine: OrchestrationEngine;
95
+ /** Spawn a bounded read-only planning agent to decompose the goal (with automatic heuristic fallback), then hold the draft. Async because the planning agent is real. `isolation` omitted ⇒ the engine's own default ('shared'); see CreateWorkstreamInput.isolation (SDK). */
96
+ proposeDraft(task: string, isolation?: WorkstreamIsolation): Promise<WorkstreamDraft>;
97
+ getDraft(id: string): WorkstreamDraft | undefined;
98
+ listDrafts(): WorkstreamDraft[];
99
+ /** Re-derive a held draft's spec + decomposition from a new task string. Clears any prior approval — an edit must be re-approved. `isolation` omitted ⇒ keeps the draft's current choice (an edit that only changes the task text must not silently reset isolation back to shared). */
100
+ editDraft(id: string, task: string, isolation?: WorkstreamIsolation): Promise<WorkstreamDraft | undefined>;
101
+ approveDraft(id: string): WorkstreamDraft | undefined;
102
+ removeDraft(id: string): boolean;
103
+ /** Materialize an approved draft into a real, running Workstream (engine.createWorkstream + start), then drop the draft. Null when the draft is missing or not approved. */
104
+ launchDraft(id: string): { workstreamId: string } | null;
105
+ }
106
+
107
+ export interface WorkstreamServices {
108
+ readonly orchestrationEngine: OrchestrationEngine;
109
+ readonly workstreamCommands: WorkstreamCommandService;
110
+ }
111
+
112
+ /**
113
+ * Placeholder planner inputs until a richer risk/latency signal is wired in.
114
+ * `/workstream create` is inherently a multi-step authoring surface, so
115
+ * isMultiStep is always true; the rest are neutral defaults that let
116
+ * AdaptivePlanner's real scoring run rather than short-circuiting it.
117
+ */
118
+ function buildPlannerInputs(task: string): PlannerInputs {
119
+ return {
120
+ riskScore: 0.3,
121
+ latencyBudgetMs: Number.POSITIVE_INFINITY,
122
+ isMultiStep: true,
123
+ remoteAvailable: false,
124
+ backgroundEligible: false,
125
+ taskDescription: task,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Read the planner decomposition config (mode + bounds) from the config
131
+ * manager, defensively defaulting anything missing or invalid. Real config
132
+ * always supplies the DEFAULT_CONFIG values; these fallbacks matter only for a
133
+ * partially-stubbed config manager, and guarantee finite positive bounds so a
134
+ * planning-agent poll can never loop forever on a NaN deadline.
135
+ */
136
+ function readDecompositionConfig(configManager: Pick<ConfigManager, 'get'>): DecompositionServiceConfig {
137
+ const num = (value: unknown, fallback: number): number =>
138
+ typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
139
+ return {
140
+ mode: configManager.get('planner.decomposition') === 'heuristic' ? 'heuristic' : 'agent',
141
+ bounds: {
142
+ maxTurns: num(configManager.get('planner.maxTurns'), 6),
143
+ tokenCeiling: num(configManager.get('planner.tokenCeiling'), 120_000),
144
+ wallTimeoutMs: num(configManager.get('planner.wallTimeoutMs'), 60_000),
145
+ },
146
+ };
147
+ }
148
+
149
+ /** Honest cost estimator: prices the planning agent's tokens only when the
150
+ * session's default model is one we actually have pricing for; otherwise the
151
+ * render falls back to a raw token count. Never throws. */
152
+ function makeCostEstimator(configManager: Pick<ConfigManager, 'get'>): (usage: { inputTokens: number; outputTokens: number; cacheReadTokens?: number | undefined; cacheWriteTokens?: number | undefined }) => number | undefined {
153
+ return (usage) => {
154
+ try {
155
+ const model = configManager.get('provider.model') as unknown as string | undefined;
156
+ if (!model || !isModelPriced(model)) return undefined;
157
+ return calcSessionCost(usage.inputTokens, usage.outputTokens, usage.cacheReadTokens ?? 0, usage.cacheWriteTokens ?? 0, model);
158
+ } catch {
159
+ return undefined;
160
+ }
161
+ };
162
+ }
163
+
164
+ function toProvenance(result: DecomposeGoalResult): WorkstreamDraftProvenance {
165
+ const p = result.proposal;
166
+ const kind: WorkstreamDraftProvenance['kind'] =
167
+ result.outcome.kind === 'agent' ? 'agent'
168
+ : result.outcome.kind === 'heuristic-configured' ? 'heuristic-configured'
169
+ : result.outcome.kind === 'gate-declined' ? 'gate-declined'
170
+ : 'fallback';
171
+ return {
172
+ kind,
173
+ itemCount: p.workItems.length,
174
+ ...(p.agentCostUsd !== undefined ? { agentCostUsd: p.agentCostUsd } : {}),
175
+ ...(p.agentUsage ? { agentTokens: p.agentUsage.totalTokens } : {}),
176
+ ...(p.elapsedMs !== undefined ? { elapsedMs: p.elapsedMs } : {}),
177
+ ...(p.fallbackReason ? { fallbackReason: p.fallbackReason } : {}),
178
+ };
179
+ }
180
+
181
+ function createWorkstreamCommandService(
182
+ engine: OrchestrationEngine,
183
+ adaptivePlanner: AdaptivePlanner,
184
+ configManager: Pick<ConfigManager, 'get' | 'getCategory'>,
185
+ agentManager: Pick<AgentManager, 'spawn' | 'getStatus' | 'cancel'>,
186
+ projectRoot: string,
187
+ ): WorkstreamCommandService {
188
+ const drafts = new Map<string, WorkstreamDraft>();
189
+
190
+ /**
191
+ * Run the SDK decomposition service: it spawns a bounded, read-only planning
192
+ * agent (which surfaces in the fleet like any agent — kill/steer reach it,
193
+ * and a kill lands as a 'cancelled' fallback) and validates its structured
194
+ * output, or falls back to the heuristic single-item path on any failure.
195
+ * The returned proposal is engine-agnostic; the launchable `spec` is still
196
+ * derived by `buildSpec` (see below).
197
+ */
198
+ async function decompose(task: string): Promise<DecomposeGoalResult> {
199
+ const runner = createAgentManagerDecompositionRunner({ agentManager });
200
+ return decomposeGoal(
201
+ { goal: task, workingDir: projectRoot, constraints: {} },
202
+ adaptivePlanner,
203
+ buildPlannerInputs(task),
204
+ readDecompositionConfig(configManager),
205
+ runner,
206
+ { estimateCostUsd: makeCostEstimator(configManager) },
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Derive the launchable CreateWorkstreamInput from the decomposition
212
+ * proposal (BIG-3 — the final stage of the WRFC→orchestration migration).
213
+ *
214
+ * THE BOUNDARY, stated honestly (and in the /workstream help + draft render):
215
+ * - A genuinely MULTI-ITEM proposal (the planning agent decomposed the goal
216
+ * into >1 work item) is assembled by the SDK's fromPlanProposal into the
217
+ * REAL multi-item workstream: one engineer→review-phased item per proposal
218
+ * item, inter-item dependencies preserved as scheduling constraints, and
219
+ * workstream-level provenance carried. This is the plan the engine runs —
220
+ * no flattening.
221
+ * - A SINGLE-ITEM proposal (the heuristic single-item path, a gate-decline,
222
+ * or an agent that honestly returned one item) keeps the fromChainSpec
223
+ * COMPAT path: byte-for-byte the same engineer→review chain
224
+ * WrfcController.createChain would start. A single item carries no
225
+ * dependencies and no multi-item structure, so the proposal mapping would
226
+ * add nothing — the compat path is the honest, unchanged choice.
227
+ *
228
+ * Earlier waves always took the compat path because no planning-agent-spawn
229
+ * pipeline existed to produce a real multi-item decomposition; BIG-2 landed
230
+ * that pipeline, so `result.proposal` can now be a real multi-item plan and
231
+ * this maps it faithfully. The rendered draft shows THIS spec, so the
232
+ * proposal preview and the launch are always the same plan.
233
+ */
234
+ function buildSpec(task: string, proposal: PlanProposal): CreateWorkstreamInput {
235
+ if (proposal.workItems.length > 1) {
236
+ return fromPlanProposal(proposal, configManager);
237
+ }
238
+ return fromChainSpec({ id: `item-${crypto.randomUUID().slice(0, 8)}`, task }, configManager);
239
+ }
240
+
241
+ return {
242
+ engine,
243
+ async proposeDraft(task: string, isolation?: WorkstreamIsolation): Promise<WorkstreamDraft> {
244
+ const result = await decompose(task);
245
+ const spec = buildSpec(task, result.proposal);
246
+ const draft: WorkstreamDraft = {
247
+ id: `wsd_${crypto.randomUUID().slice(0, 8)}`,
248
+ task,
249
+ spec: isolation ? { ...spec, isolation } : spec,
250
+ gate: result.gate,
251
+ proposal: result.proposal,
252
+ provenance: toProvenance(result),
253
+ approved: false,
254
+ createdAt: Date.now(),
255
+ };
256
+ drafts.set(draft.id, draft);
257
+ return draft;
258
+ },
259
+ getDraft: (id) => drafts.get(id),
260
+ listDrafts: () => Array.from(drafts.values()).sort((a, b) => a.createdAt - b.createdAt),
261
+ async editDraft(id, task, isolation) {
262
+ const draft = drafts.get(id);
263
+ if (!draft) return undefined;
264
+ const result = await decompose(task);
265
+ const nextIsolation = isolation ?? draft.spec.isolation;
266
+ draft.task = task;
267
+ draft.spec = { ...buildSpec(task, result.proposal), isolation: nextIsolation };
268
+ draft.proposal = result.proposal;
269
+ draft.provenance = toProvenance(result);
270
+ draft.approved = false;
271
+ return draft;
272
+ },
273
+ approveDraft(id) {
274
+ const draft = drafts.get(id);
275
+ if (!draft) return undefined;
276
+ draft.approved = true;
277
+ return draft;
278
+ },
279
+ removeDraft: (id) => drafts.delete(id),
280
+ launchDraft(id) {
281
+ const draft = drafts.get(id);
282
+ if (!draft || !draft.approved) return null;
283
+ const workstream = engine.createWorkstream(draft.spec);
284
+ engine.start(workstream.id);
285
+ drafts.delete(id);
286
+ return { workstreamId: workstream.id };
287
+ },
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Constructs the TUI's OrchestrationEngine instance and its command-facing
293
+ * facade. `persist` and `createWorktree` are left at the engine's own
294
+ * defaults: journal-backed snapshots under .goodvibes/orchestration/ (so
295
+ * resumeAllFromDisk below has something to resume) and a plain
296
+ * AgentWorktree(projectRoot) for the (default) `shared`-isolation path.
297
+ * A draft's `isolation: 'worktree'` (see /workstream create --isolation
298
+ * worktree, input/commands/workstream-runtime.ts) opts a single workstream
299
+ * into the SDK's per-item git-worktree isolation (WorktreeIsolationManager,
300
+ * engine-side) instead — the engine, not this facade, owns that lifecycle.
301
+ */
302
+ export function createWorkstreamServices(deps: WorkstreamServicesDeps): WorkstreamServices {
303
+ const orchestrationEngine = createOrchestrationEngine({
304
+ agentManager: deps.agentManager,
305
+ configManager: deps.configManager,
306
+ runtimeBus: deps.runtimeBus,
307
+ projectRoot: deps.projectRoot,
308
+ priceUsage: (model, usage) => {
309
+ const modelId = model ?? 'unknown';
310
+ if (!isModelPriced(modelId)) return null;
311
+ return calcSessionCost(usage.inputTokens, usage.outputTokens, usage.cacheReadTokens, usage.cacheWriteTokens, modelId);
312
+ },
313
+ });
314
+ // Honest resume: a prior process's still-in-flight workstreams pick back up
315
+ // (and reappear in the fleet tree) instead of silently vanishing on
316
+ // restart. Never throws — persistence.ts guards every read/parse and
317
+ // quarantines an unrecognized snapshot rather than propagating.
318
+ orchestrationEngine.resumeAllFromDisk();
319
+ const workstreamCommands = createWorkstreamCommandService(
320
+ orchestrationEngine,
321
+ deps.adaptivePlanner,
322
+ deps.configManager,
323
+ deps.agentManager,
324
+ deps.projectRoot,
325
+ );
326
+ return { orchestrationEngine, workstreamCommands };
327
+ }
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * WRFC chain persistence — snapshot active chains to disk on every lifecycle
3
- * event so that a crash/restart can surface interrupted chains to the operator.
3
+ * event so that a crash/restart can surface interrupted chains to the operator,
4
+ * and so that terminal chains (passed/failed/cancelled) remain visible as
5
+ * bounded history rather than vanishing the moment they finish.
4
6
  *
5
7
  * Architecture
6
8
  * ─────────────
@@ -16,11 +18,27 @@
16
18
  * row and can be resumed by the operator. The `interruptedChains` accessor
17
19
  * additionally exposes the recovered set for inspection.
18
20
  *
19
- * 3. Snapshot lifecycle:
20
- * - Terminal chains ('passed' | 'failed') are pruned from the snapshot
21
- * after rehydration surfaces them.
22
- * - A corrupt or version-mismatched snapshot is quarantined by renaming it
23
- * to `<path>.unrecognized` never a hard crash.
21
+ * A chain that looked interrupted (non-terminal) in the old snapshot can be
22
+ * reaped to terminal IN PLACE by `WrfcController.importChain` (Wave 6, wo-F:
23
+ * no member agent survived the restart — see wrfc-controller.ts). This
24
+ * module always re-checks `chain.state` AFTER the import call, never the
25
+ * pre-import classification, so a reaped chain is treated as history, not
26
+ * re-surfaced as interrupted and never re-imported again on a later restart.
27
+ *
28
+ * 3. Terminal-history retention (bounded, most-recently-completed first):
29
+ * Previously, every terminal chain was pruned from the snapshot the moment
30
+ * it was written or rehydrated — a killed/finished chain vanished from
31
+ * wrfc-chains.json entirely, including across a restart, so nothing could
32
+ * ever honestly report "last chain: <state>". Terminal chains are now
33
+ * RETAINED, capped at MAX_TERMINAL_HISTORY (20) most-recently-completed,
34
+ * both:
35
+ * - across a live session, via an internal history cache that survives
36
+ * even after WrfcController's own in-memory cleanup (60s after a
37
+ * chain terminates) drops the chain from listChains(); and
38
+ * - across a restart, via rehydrate() seeding that cache from whatever
39
+ * was already terminal in the prior snapshot instead of discarding it.
40
+ * A corrupt or version-mismatched snapshot is quarantined by renaming it
41
+ * to `<path>.unrecognized` — never a hard crash.
24
42
  *
25
43
  * Snapshot path: `.goodvibes/tui/wrfc-chains.json`
26
44
  * Snapshot schema: `{ version: 1, writtenAt: number, chains: WrfcChain[] }`
@@ -37,6 +55,9 @@ import type { SystemMessageRouter } from '../core/system-message-router.ts';
37
55
  const SNAPSHOT_VERSION = 1;
38
56
  const DEBOUNCE_MS = 250;
39
57
 
58
+ /** Bounded terminal-history retention: keep the most recent K chains, prune beyond. */
59
+ const MAX_TERMINAL_HISTORY = 20;
60
+
40
61
  /** Terminal states — chains in these states will not be surfaced as interrupted. */
41
62
  const TERMINAL_STATES = new Set<WrfcState>(['passed', 'failed']);
42
63
 
@@ -45,6 +66,13 @@ function isNonTerminal(state: WrfcState): boolean {
45
66
  return !TERMINAL_STATES.has(state);
46
67
  }
47
68
 
69
+ /** Sort by most-recently-completed first (falls back to createdAt for chains missing completedAt) and keep only the first `limit`. */
70
+ function takeMostRecentTerminal(chains: readonly WrfcChain[], limit: number): WrfcChain[] {
71
+ return [...chains]
72
+ .sort((a, b) => (b.completedAt ?? b.createdAt ?? 0) - (a.completedAt ?? a.createdAt ?? 0))
73
+ .slice(0, limit);
74
+ }
75
+
48
76
  // ─── Snapshot schema ─────────────────────────────────────────────────────────
49
77
 
50
78
  interface WrfcSnapshot {
@@ -90,9 +118,19 @@ export interface WrfcPersistence {
90
118
  */
91
119
  attach(runtimeBus: RuntimeEventBus): Array<() => void>;
92
120
 
121
+ /**
122
+ * All chains known after rehydrate(): the (re-checked, post-reap) interrupted
123
+ * set plus the bounded terminal-history cache seeded from the prior snapshot.
124
+ * Empty until rehydrate() is called. Consumers building a post-restart
125
+ * summary (e.g. the boot resume notice) read this instead of re-parsing the
126
+ * snapshot file themselves.
127
+ */
128
+ readonly knownChains: readonly WrfcChain[];
129
+
93
130
  /**
94
131
  * Read the snapshot from a previous process, surface any interrupted chains
95
- * as system messages, and prune terminal chains from the snapshot on disk.
132
+ * as system messages, and retain terminal chains (bounded, most-recently-
133
+ * completed first) as on-disk history instead of pruning them.
96
134
  *
97
135
  * Must be called after the SystemMessageRouter is available.
98
136
  */
@@ -111,6 +149,14 @@ class WrfcPersistenceImpl implements WrfcPersistence {
111
149
 
112
150
  private _interruptedChains: WrfcChain[] = [];
113
151
  private _debounceTimer: ReturnType<typeof setTimeout> | null = null;
152
+ /**
153
+ * Bounded terminal-history cache (most-recently-completed first once
154
+ * trimmed). Survives past WrfcController's own in-memory cleanup of
155
+ * terminated chains (60s after termination — see wrfc-controller.ts
156
+ * scheduleChainCleanup) so a chain that ages out of listChains() is not
157
+ * silently dropped from the persisted snapshot.
158
+ */
159
+ private _terminalHistory: WrfcChain[] = [];
114
160
 
115
161
  constructor(options: WrfcPersistenceOptions) {
116
162
  this.snapshotPath = options.snapshotPath;
@@ -122,6 +168,10 @@ class WrfcPersistenceImpl implements WrfcPersistence {
122
168
  return this._interruptedChains;
123
169
  }
124
170
 
171
+ get knownChains(): readonly WrfcChain[] {
172
+ return [...this._interruptedChains, ...this._terminalHistory];
173
+ }
174
+
125
175
  attach(runtimeBus: RuntimeEventBus): Array<() => void> {
126
176
  const schedule = (): void => this._scheduleSnapshot();
127
177
 
@@ -144,17 +194,40 @@ class WrfcPersistenceImpl implements WrfcPersistence {
144
194
  const snapshot = this._readSnapshot();
145
195
  if (!snapshot) return;
146
196
 
147
- const interrupted = snapshot.chains.filter((c) => isNonTerminal(c.state));
148
- this._interruptedChains = interrupted;
197
+ // Seed the bounded terminal-history cache from whatever was already
198
+ // terminal in the prior process's snapshot, so it survives this restart
199
+ // instead of being wiped (the old behaviour pruned every terminal chain
200
+ // unconditionally on every rehydrate).
201
+ const alreadyTerminal = snapshot.chains.filter((c) => !isNonTerminal(c.state));
202
+ this._terminalHistory = takeMostRecentTerminal(alreadyTerminal, MAX_TERMINAL_HISTORY);
149
203
 
150
- const router = this.getSystemMessageRouter();
151
- for (const chain of interrupted) {
204
+ const candidateInterrupted = snapshot.chains.filter((c) => isNonTerminal(c.state));
205
+ for (const chain of candidateInterrupted) {
152
206
  // Re-import so the chain reappears in the controller's in-memory map and
153
207
  // becomes selectable/resumable from the panel. On a fresh process start
154
208
  // the map is empty, so importChain (force=false) never clobbers a live
155
209
  // chain. The accessor is optional for read-only test doubles.
210
+ //
211
+ // NOTE: importChain may reap this chain to a terminal state IN PLACE
212
+ // (Wave 6, wo-F zombie-reap: no member agent survived the restart) —
213
+ // always read chain.state AFTER this call below, never the pre-import
214
+ // classification captured by candidateInterrupted.
156
215
  this.controller.importChain?.(chain);
216
+ }
217
+
218
+ // Re-partition after the reap check: a chain that looked interrupted
219
+ // before import may now be terminal. Only genuinely still-live chains are
220
+ // surfaced to the operator as interrupted; anything reaped just now joins
221
+ // history instead — it is done, not a resurrection candidate, and must
222
+ // never be handed to importChain again on a future restart (a chain only
223
+ // ever lands in candidateInterrupted while its persisted state is
224
+ // non-terminal; once it's history its state is terminal for good).
225
+ this._interruptedChains = candidateInterrupted.filter((c) => isNonTerminal(c.state));
226
+ const reapedJustNow = candidateInterrupted.filter((c) => !isNonTerminal(c.state));
227
+ for (const chain of reapedJustNow) this._upsertHistoryChain(chain);
157
228
 
229
+ const router = this.getSystemMessageRouter();
230
+ for (const chain of this._interruptedChains) {
158
231
  const msg =
159
232
  `[WRFC] Chain ${chain.id.slice(0, 12)} (${chain.task.slice(0, 60).trim()}) ` +
160
233
  `was interrupted by a restart — state was '${chain.state}' ` +
@@ -162,14 +235,19 @@ class WrfcPersistenceImpl implements WrfcPersistence {
162
235
  router?.wrfc(msg, 'high');
163
236
  }
164
237
 
165
- // Prune terminal chains from the on-disk snapshot after surfacing.
166
- if (snapshot.chains.length !== interrupted.length) {
167
- const pruned: WrfcSnapshot = {
238
+ // Rewrite the snapshot only when something actually changed: a chain was
239
+ // reaped just now, or the history cap pruned an entry that was on disk.
240
+ // Terminal chains are RETAINED (bounded to MAX_TERMINAL_HISTORY, most
241
+ // recently completed first) instead of erased — post-restart surfaces
242
+ // (fleet, the resume notice, /wrfc history) need this to honestly report
243
+ // e.g. "last chain: cancelled".
244
+ if (reapedJustNow.length > 0 || alreadyTerminal.length !== this._terminalHistory.length) {
245
+ const rebuilt: WrfcSnapshot = {
168
246
  version: SNAPSHOT_VERSION,
169
247
  writtenAt: Date.now(),
170
- chains: interrupted,
248
+ chains: [...this._interruptedChains, ...this._terminalHistory],
171
249
  };
172
- this._writeSnapshot(pruned);
250
+ this._writeSnapshot(rebuilt);
173
251
  }
174
252
  }
175
253
 
@@ -194,14 +272,43 @@ class WrfcPersistenceImpl implements WrfcPersistence {
194
272
  }
195
273
 
196
274
  private _writeCurrentSnapshot(): void {
275
+ const live = this.controller.listChains();
276
+ this._recordTerminalHistory(live);
197
277
  const snapshot: WrfcSnapshot = {
198
278
  version: SNAPSHOT_VERSION,
199
279
  writtenAt: Date.now(),
200
- chains: this.controller.listChains(),
280
+ chains: this._mergeWithHistory(live),
201
281
  };
202
282
  this._writeSnapshot(snapshot);
203
283
  }
204
284
 
285
+ /** Fold any terminal chains in a live listing into the bounded history cache, so they are not lost once WrfcController's own in-memory cleanup drops them from listChains(). */
286
+ private _recordTerminalHistory(chains: readonly WrfcChain[]): void {
287
+ for (const chain of chains) {
288
+ if (!isNonTerminal(chain.state)) this._upsertHistoryChain(chain);
289
+ }
290
+ }
291
+
292
+ /** Insert/replace `chain` in the terminal-history cache (by id), then trim to MAX_TERMINAL_HISTORY, most-recently-completed first. */
293
+ private _upsertHistoryChain(chain: WrfcChain): void {
294
+ const next = this._terminalHistory.filter((c) => c.id !== chain.id);
295
+ next.push(chain);
296
+ this._terminalHistory = takeMostRecentTerminal(next, MAX_TERMINAL_HISTORY);
297
+ }
298
+
299
+ /**
300
+ * Combine the live chains the controller is currently tracking with any
301
+ * historical terminal chains the live map has already forgotten (e.g. after
302
+ * WrfcController's own 60s in-memory cleanup) — so the persisted file never
303
+ * loses a terminal chain just because the live map stopped carrying it.
304
+ * Live entries win on id collision since they are always the freshest copy.
305
+ */
306
+ private _mergeWithHistory(live: readonly WrfcChain[]): WrfcChain[] {
307
+ const liveIds = new Set(live.map((c) => c.id));
308
+ const historyOnly = this._terminalHistory.filter((c) => !liveIds.has(c.id));
309
+ return [...live, ...historyOnly];
310
+ }
311
+
205
312
  private _writeSnapshot(snapshot: WrfcSnapshot): void {
206
313
  try {
207
314
  atomicWriteFileSync(this.snapshotPath, JSON.stringify(snapshot), { mkdirp: true });