@pellux/goodvibes-tui 1.7.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +10 -9
  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/config/credential-availability.ts +122 -0
  12. package/src/config/index.ts +15 -0
  13. package/src/core/composer-state.ts +11 -3
  14. package/src/core/conversation-line-cache.ts +27 -3
  15. package/src/core/conversation-rendering.ts +71 -14
  16. package/src/core/stream-event-wiring.ts +20 -1
  17. package/src/core/system-message-noise.ts +87 -0
  18. package/src/core/system-message-router.ts +68 -1
  19. package/src/core/turn-cancellation.ts +7 -2
  20. package/src/core/turn-event-wiring.ts +10 -2
  21. package/src/daemon/cli.ts +29 -2
  22. package/src/daemon/handlers/register.ts +8 -1
  23. package/src/daemon/service-commands.ts +329 -0
  24. package/src/input/autocomplete.ts +27 -1
  25. package/src/input/command-registry.ts +46 -4
  26. package/src/input/commands/codebase-runtime.ts +46 -6
  27. package/src/input/commands/config.ts +43 -3
  28. package/src/input/commands/health-runtime.ts +9 -1
  29. package/src/input/commands/memory.ts +68 -35
  30. package/src/input/commands/operator-panel-runtime.ts +31 -7
  31. package/src/input/commands/planning-runtime.ts +95 -6
  32. package/src/input/commands/qrcode-runtime.ts +25 -5
  33. package/src/input/commands/remote-runtime-setup.ts +5 -3
  34. package/src/input/commands/session-content.ts +20 -9
  35. package/src/input/commands/settings-sync-runtime.ts +15 -3
  36. package/src/input/commands/shell-core.ts +10 -1
  37. package/src/input/commands/workstream-runtime.ts +168 -18
  38. package/src/input/config-modal-types.ts +15 -1
  39. package/src/input/config-modal.ts +227 -12
  40. package/src/input/feed-context-factory.ts +3 -0
  41. package/src/input/handler-command-route.ts +10 -3
  42. package/src/input/handler-content-actions.ts +17 -2
  43. package/src/input/handler-feed-routes.ts +43 -121
  44. package/src/input/handler-feed.ts +32 -4
  45. package/src/input/handler-modal-routes.ts +78 -13
  46. package/src/input/handler-modal-stack.ts +11 -2
  47. package/src/input/handler-onboarding-daemon-adopt.ts +149 -0
  48. package/src/input/handler-onboarding.ts +71 -59
  49. package/src/input/handler-picker-routes.ts +15 -8
  50. package/src/input/handler-shortcuts.ts +59 -9
  51. package/src/input/handler.ts +6 -1
  52. package/src/input/keybindings.ts +6 -5
  53. package/src/input/model-picker.ts +19 -2
  54. package/src/input/onboarding/onboarding-runtime-status.ts +10 -1
  55. package/src/input/onboarding/onboarding-wizard-apply.ts +8 -1
  56. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -1
  57. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +136 -0
  58. package/src/input/onboarding/onboarding-wizard-steps.ts +9 -6
  59. package/src/input/onboarding/onboarding-wizard-types.ts +3 -1
  60. package/src/input/panel-mouse-geometry.ts +97 -0
  61. package/src/input/panel-paste-flood-guard.ts +86 -0
  62. package/src/input/selection-modal.ts +11 -0
  63. package/src/input/session-picker-modal.ts +44 -4
  64. package/src/input/settings-modal-data.ts +51 -2
  65. package/src/input/settings-modal-types.ts +4 -2
  66. package/src/main.ts +28 -27
  67. package/src/panels/builtin/shared.ts +9 -3
  68. package/src/panels/fleet-deep-link.ts +31 -0
  69. package/src/panels/fleet-panel-format.ts +62 -0
  70. package/src/panels/fleet-panel-worktree-detail.ts +48 -0
  71. package/src/panels/fleet-panel.ts +89 -131
  72. package/src/panels/fleet-read-model.ts +43 -0
  73. package/src/panels/fleet-steer.ts +35 -2
  74. package/src/panels/fleet-stop.ts +29 -1
  75. package/src/panels/modals/keybindings-modal.ts +16 -1
  76. package/src/panels/modals/modal-theme.ts +35 -29
  77. package/src/panels/modals/pairing-modal.ts +25 -6
  78. package/src/panels/modals/planning-modal.ts +43 -21
  79. package/src/panels/modals/work-plan-modal.ts +28 -0
  80. package/src/panels/panel-manager.ts +15 -4
  81. package/src/panels/polish-core.ts +38 -25
  82. package/src/panels/project-planning-answer-actions.ts +8 -13
  83. package/src/panels/types.ts +24 -0
  84. package/src/permissions/prompt.ts +160 -13
  85. package/src/renderer/autocomplete-overlay.ts +28 -2
  86. package/src/renderer/compositor.ts +2 -3
  87. package/src/renderer/config-modal.ts +19 -5
  88. package/src/renderer/footer-tips.ts +5 -1
  89. package/src/renderer/fullscreen-primitives.ts +32 -22
  90. package/src/renderer/git-status.ts +3 -1
  91. package/src/renderer/layout.ts +0 -4
  92. package/src/renderer/markdown.ts +7 -3
  93. package/src/renderer/modal-factory.ts +25 -20
  94. package/src/renderer/model-workspace.ts +18 -3
  95. package/src/renderer/overlay-box.ts +21 -17
  96. package/src/renderer/process-indicator.ts +14 -3
  97. package/src/renderer/selection-modal-overlay.ts +6 -1
  98. package/src/renderer/session-picker-modal.ts +196 -3
  99. package/src/renderer/settings-modal-helpers.ts +2 -0
  100. package/src/renderer/settings-modal.ts +7 -0
  101. package/src/renderer/shell-surface.ts +8 -1
  102. package/src/renderer/status-glyphs.ts +14 -15
  103. package/src/renderer/system-message.ts +15 -3
  104. package/src/renderer/terminal-bg-probe.ts +339 -0
  105. package/src/renderer/terminal-escapes.ts +20 -0
  106. package/src/renderer/theme-mode-config.ts +67 -0
  107. package/src/renderer/theme.ts +91 -1
  108. package/src/renderer/thinking.ts +11 -3
  109. package/src/renderer/tool-call.ts +15 -9
  110. package/src/renderer/tool-result-summary.ts +148 -0
  111. package/src/renderer/turn-injection.ts +22 -3
  112. package/src/renderer/ui-factory.ts +154 -85
  113. package/src/renderer/ui-primitives.ts +30 -129
  114. package/src/runtime/bootstrap-command-context.ts +6 -0
  115. package/src/runtime/bootstrap-command-parts.ts +8 -4
  116. package/src/runtime/bootstrap-core.ts +46 -24
  117. package/src/runtime/bootstrap-hook-bridge.ts +7 -0
  118. package/src/runtime/bootstrap-shell.ts +19 -1
  119. package/src/runtime/bootstrap.ts +118 -5
  120. package/src/runtime/code-index-services.ts +25 -2
  121. package/src/runtime/legacy-daemon-migration.ts +516 -0
  122. package/src/runtime/memory-fold.ts +55 -0
  123. package/src/runtime/onboarding/derivation.ts +7 -2
  124. package/src/runtime/onboarding/snapshot.ts +27 -1
  125. package/src/runtime/onboarding/types.ts +31 -1
  126. package/src/runtime/operator-token-cleanup.ts +82 -1
  127. package/src/runtime/orchestrator-core-services.ts +10 -0
  128. package/src/runtime/resume-notice.ts +209 -0
  129. package/src/runtime/services.ts +12 -8
  130. package/src/runtime/session-inbound-inputs.ts +252 -0
  131. package/src/runtime/session-spine-transport.ts +64 -0
  132. package/src/runtime/terminal-output-guard.ts +15 -8
  133. package/src/runtime/ui-services.ts +19 -3
  134. package/src/runtime/workstream-services.ts +160 -28
  135. package/src/runtime/wrfc-persistence.ts +124 -17
  136. package/src/shell/blocking-input.ts +46 -3
  137. package/src/shell/recovery-input-helpers.ts +170 -1
  138. package/src/shell/ui-openers.ts +42 -9
  139. package/src/utils/terminal-width.ts +52 -0
  140. package/src/version.ts +1 -1
@@ -31,11 +31,22 @@
31
31
  import {
32
32
  createOrchestrationEngine,
33
33
  fromChainSpec,
34
+ fromPlanProposal,
34
35
  type CreateWorkstreamInput,
35
36
  type OrchestrationEngine,
37
+ type WorkstreamIsolation,
36
38
  } from '@pellux/goodvibes-sdk/platform/orchestration';
37
39
  export type { OrchestrationEngine } from '@pellux/goodvibes-sdk/platform/orchestration';
38
- import { AdaptivePlanner, type DecompositionGate, type PlannerInputs } from '@pellux/goodvibes-sdk/platform/core';
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';
39
50
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
40
51
  import type { AgentManager } from '@pellux/goodvibes-sdk/platform/tools';
41
52
  import type { RuntimeEventBus } from '@/runtime/index.ts';
@@ -49,12 +60,31 @@ export interface WorkstreamServicesDeps {
49
60
  readonly projectRoot: string;
50
61
  }
51
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
+
52
78
  /** A not-yet-launched /workstream proposal. See this file's header doc for why it lives here rather than on the engine. */
53
79
  export interface WorkstreamDraft {
54
80
  readonly id: string;
55
81
  task: string;
56
82
  spec: CreateWorkstreamInput;
57
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;
58
88
  approved: boolean;
59
89
  readonly createdAt: number;
60
90
  }
@@ -62,11 +92,12 @@ export interface WorkstreamDraft {
62
92
  /** `ctx.session.workstreamEngine`'s real shape: the live engine plus the draft-proposal bookkeeping the engine itself has no concept of. */
63
93
  export interface WorkstreamCommandService {
64
94
  readonly engine: OrchestrationEngine;
65
- proposeDraft(task: string): WorkstreamDraft;
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>;
66
97
  getDraft(id: string): WorkstreamDraft | undefined;
67
98
  listDrafts(): WorkstreamDraft[];
68
- /** Re-derive a held draft's spec from a new task string. Clears any prior approval — an edit must be re-approved. */
69
- editDraft(id: string, task: string): WorkstreamDraft | undefined;
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>;
70
101
  approveDraft(id: string): WorkstreamDraft | undefined;
71
102
  removeDraft(id: string): boolean;
72
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. */
@@ -95,41 +126,130 @@ function buildPlannerInputs(task: string): PlannerInputs {
95
126
  };
96
127
  }
97
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
+
98
181
  function createWorkstreamCommandService(
99
182
  engine: OrchestrationEngine,
100
183
  adaptivePlanner: AdaptivePlanner,
101
184
  configManager: Pick<ConfigManager, 'get' | 'getCategory'>,
185
+ agentManager: Pick<AgentManager, 'spawn' | 'getStatus' | 'cancel'>,
186
+ projectRoot: string,
102
187
  ): WorkstreamCommandService {
103
188
  const drafts = new Map<string, WorkstreamDraft>();
104
189
 
105
190
  /**
106
- * fromChainSpec is the SDK's own bridge from "a task string" to a real,
107
- * launchable engineer -> review CreateWorkstreamInputthe same two-phase
108
- * shape WrfcController.createChain would otherwise start (see
109
- * controller-compat.ts). AdaptivePlanner.proposeWorkstream's own
110
- * PlanProposal is deliberately NOT used for the rendered shape: it always
111
- * degrades to a fictional single-phase fallback in this wave (nothing yet
112
- * supplies it a `raw` LLM decomposition — no planning-agent-spawn pipeline
113
- * exists in the shipped SDK surface this item compiles against), which
114
- * would silently disagree with what launchDraft actually creates. Showing
115
- * the real fromChainSpec shape keeps the proposal and the launch
116
- * byte-for-byte the same plan. proposeWorkstream is still called (below)
117
- * for its `gate` — the real strategy/reason-code rationale — which stays
118
- * meaningful even without a raw decomposition.
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.
119
233
  */
120
- function buildSpec(task: string): CreateWorkstreamInput {
234
+ function buildSpec(task: string, proposal: PlanProposal): CreateWorkstreamInput {
235
+ if (proposal.workItems.length > 1) {
236
+ return fromPlanProposal(proposal, configManager);
237
+ }
121
238
  return fromChainSpec({ id: `item-${crypto.randomUUID().slice(0, 8)}`, task }, configManager);
122
239
  }
123
240
 
124
241
  return {
125
242
  engine,
126
- proposeDraft(task: string): WorkstreamDraft {
127
- const { gate } = adaptivePlanner.proposeWorkstream(buildPlannerInputs(task));
243
+ async proposeDraft(task: string, isolation?: WorkstreamIsolation): Promise<WorkstreamDraft> {
244
+ const result = await decompose(task);
245
+ const spec = buildSpec(task, result.proposal);
128
246
  const draft: WorkstreamDraft = {
129
247
  id: `wsd_${crypto.randomUUID().slice(0, 8)}`,
130
248
  task,
131
- spec: buildSpec(task),
132
- gate,
249
+ spec: isolation ? { ...spec, isolation } : spec,
250
+ gate: result.gate,
251
+ proposal: result.proposal,
252
+ provenance: toProvenance(result),
133
253
  approved: false,
134
254
  createdAt: Date.now(),
135
255
  };
@@ -138,11 +258,15 @@ function createWorkstreamCommandService(
138
258
  },
139
259
  getDraft: (id) => drafts.get(id),
140
260
  listDrafts: () => Array.from(drafts.values()).sort((a, b) => a.createdAt - b.createdAt),
141
- editDraft(id, task) {
261
+ async editDraft(id, task, isolation) {
142
262
  const draft = drafts.get(id);
143
263
  if (!draft) return undefined;
264
+ const result = await decompose(task);
265
+ const nextIsolation = isolation ?? draft.spec.isolation;
144
266
  draft.task = task;
145
- draft.spec = buildSpec(task);
267
+ draft.spec = { ...buildSpec(task, result.proposal), isolation: nextIsolation };
268
+ draft.proposal = result.proposal;
269
+ draft.provenance = toProvenance(result);
146
270
  draft.approved = false;
147
271
  return draft;
148
272
  },
@@ -169,9 +293,11 @@ function createWorkstreamCommandService(
169
293
  * facade. `persist` and `createWorktree` are left at the engine's own
170
294
  * defaults: journal-backed snapshots under .goodvibes/orchestration/ (so
171
295
  * resumeAllFromDisk below has something to resume) and a plain
172
- * AgentWorktree(projectRoot) the same "shared working directory, no real
173
- * per-item isolation" behavior WrfcController itself uses today (see
174
- * phase-runner.ts's own REALITY-WINS divergence doc in the SDK).
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.
175
301
  */
176
302
  export function createWorkstreamServices(deps: WorkstreamServicesDeps): WorkstreamServices {
177
303
  const orchestrationEngine = createOrchestrationEngine({
@@ -190,6 +316,12 @@ export function createWorkstreamServices(deps: WorkstreamServicesDeps): Workstre
190
316
  // restart. Never throws — persistence.ts guards every read/parse and
191
317
  // quarantines an unrecognized snapshot rather than propagating.
192
318
  orchestrationEngine.resumeAllFromDisk();
193
- const workstreamCommands = createWorkstreamCommandService(orchestrationEngine, deps.adaptivePlanner, deps.configManager);
319
+ const workstreamCommands = createWorkstreamCommandService(
320
+ orchestrationEngine,
321
+ deps.adaptivePlanner,
322
+ deps.configManager,
323
+ deps.agentManager,
324
+ deps.projectRoot,
325
+ );
194
326
  return { orchestrationEngine, workstreamCommands };
195
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 });
@@ -10,6 +10,8 @@ export type PendingPermissionState = PermissionRequest & {
10
10
  resolve: (approved: boolean, remember?: boolean, modifiedArgs?: Record<string, unknown>) => void;
11
11
  /** Present only when isHunkSelectable(request) was true when the prompt was opened. */
12
12
  hunkState?: HunkSelectionState;
13
+ /** True once the user pressed `d` to expand a condensed low-risk card. (UX-B 2b.) */
14
+ detailsExpanded?: boolean;
13
15
  };
14
16
 
15
17
  export type BlockingInputHandlerOptions = {
@@ -22,6 +24,15 @@ export type BlockingInputHandlerOptions = {
22
24
  render: () => void;
23
25
  loadRecoveryConversation: () => SessionSnapshot | null;
24
26
  deleteRecoveryFile: () => void;
27
+ /**
28
+ * Copy the live recovery.jsonl aside to a preserved sibling so the dismiss
29
+ * message's promise ("still on disk") survives this session's later 60s
30
+ * autosaves, which would otherwise overwrite the shared recovery file with
31
+ * the CURRENT (post-dismiss) session's state (W3 Finding 3). Optional so
32
+ * every pre-existing test/caller that doesn't care about preservation is
33
+ * unaffected; when omitted, dismiss behaves exactly as before.
34
+ */
35
+ preserveRecoveryFile?: () => { readonly preserved: boolean; readonly replacedPrevious: boolean };
25
36
  /**
26
37
  * Absolute home directory used to locate the transcript journal for this
27
38
  * recovery session. Required for journal replay on Ctrl+R restore.
@@ -68,6 +79,7 @@ export function handleBlockingShellInput(
68
79
  render,
69
80
  loadRecoveryConversation,
70
81
  deleteRecoveryFile,
82
+ preserveRecoveryFile,
71
83
  homeDirectory,
72
84
  sessionId,
73
85
  persistSnapshot,
@@ -115,6 +127,16 @@ export function handleBlockingShellInput(
115
127
  return { handled: true, pendingPermission: null, recoveryPending };
116
128
  }
117
129
 
130
+ if (key === 'd') {
131
+ // Toggle the condensed↔full detail view without resolving the request.
132
+ render();
133
+ return {
134
+ handled: true,
135
+ pendingPermission: { ...req, detailsExpanded: !req.detailsExpanded },
136
+ recoveryPending,
137
+ };
138
+ }
139
+
118
140
  render();
119
141
  return { handled: true, pendingPermission, recoveryPending };
120
142
  }
@@ -156,10 +178,31 @@ export function handleBlockingShellInput(
156
178
  return { handled: true, pendingPermission: null, recoveryPending: false };
157
179
  }
158
180
 
159
- // Stray key: leave the recovery prompt active so the user can still Ctrl+R or Esc.
160
- systemMessageRouter.high('[Recovery] Ctrl+R to restore · Esc to discard');
181
+ // Any other key demonstrates the user's intent to ignore the banner and
182
+ // keep working — the prompt's own text invites exactly this ("start
183
+ // typing to ignore it"). Previously this branch re-posted the same
184
+ // '[Recovery] Ctrl+R to restore...' line on EVERY such key and never
185
+ // cleared recoveryPending, so a user who took that invitation got a
186
+ // fresh [Recovery] line injected into the transcript around every
187
+ // character they typed, forever. Dismiss ONCE instead: clear
188
+ // recoveryPending so it stops re-asserting, but do NOT delete the
189
+ // recovery file — dismiss is not discard, and the file remains
190
+ // restorable by the automatic recovery check on the next launch.
191
+ //
192
+ // W3 Finding 3: the promise below ("still on disk; you will be asked
193
+ // again") used to go false silently — main.ts's 60s autosave overwrites
194
+ // the single shared recovery.jsonl with the CURRENT session's state
195
+ // within a minute. preserveRecoveryFile (when wired) copies it aside
196
+ // NOW, while it still holds the dismissed session's data, so the
197
+ // promise stays true. If an earlier dismiss's preserved snapshot gets
198
+ // replaced by this one, say so honestly instead of silently discarding it.
199
+ const preserveResult = preserveRecoveryFile?.();
200
+ if (preserveResult?.replacedPrevious) {
201
+ systemMessageRouter.low('[Recovery] Replacing the previously preserved (unrestored) snapshot with this one.');
202
+ }
203
+ systemMessageRouter.high('[Recovery] Dismissed — the unsaved session is still on disk; you will be asked again next time GoodVibes starts here.');
161
204
  render();
162
- return { handled: false, pendingPermission, recoveryPending: true };
205
+ return { handled: false, pendingPermission, recoveryPending: false };
163
206
  }
164
207
 
165
208
  return { handled: false, pendingPermission, recoveryPending };