@pellux/goodvibes-tui 0.20.3 → 0.21.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 (118) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +23 -2
  3. package/docs/foundation-artifacts/operator-contract.json +78 -1
  4. package/package.json +3 -2
  5. package/src/audio/spoken-turn-controller.ts +31 -1
  6. package/src/audio/spoken-turn-wiring.ts +26 -4
  7. package/src/cli/bundle-command.ts +1 -1
  8. package/src/cli/completions/generate.ts +662 -0
  9. package/src/cli/config-overrides.ts +68 -0
  10. package/src/cli/help.ts +4 -2
  11. package/src/cli/management-commands.ts +1 -1
  12. package/src/cli/management.ts +1 -8
  13. package/src/cli/parser.ts +14 -18
  14. package/src/cli/service-command.ts +1 -1
  15. package/src/cli/surface-command.ts +1 -1
  16. package/src/cli/tui-startup.ts +72 -10
  17. package/src/cli/types.ts +12 -3
  18. package/src/cli-flags.ts +1 -0
  19. package/src/config/atomic-write.ts +70 -0
  20. package/src/config/read-versioned.ts +115 -0
  21. package/src/core/conversation-rendering.ts +49 -15
  22. package/src/core/conversation.ts +101 -16
  23. package/src/core/format-user-error.ts +192 -0
  24. package/src/core/stream-event-wiring.ts +144 -0
  25. package/src/core/stream-stall-watchdog.ts +103 -0
  26. package/src/core/system-message-router.ts +5 -1
  27. package/src/export/cost-utils.ts +71 -0
  28. package/src/export/gist-uploader.ts +136 -0
  29. package/src/input/command-registry.ts +31 -1
  30. package/src/input/commands/control-room-runtime.ts +5 -5
  31. package/src/input/commands/experience-runtime.ts +5 -4
  32. package/src/input/commands/knowledge.ts +1 -1
  33. package/src/input/commands/local-auth-runtime.ts +27 -5
  34. package/src/input/commands/local-setup.ts +4 -6
  35. package/src/input/commands/memory-product-runtime.ts +8 -6
  36. package/src/input/commands/operator-panel-runtime.ts +1 -1
  37. package/src/input/commands/operator-runtime.ts +3 -10
  38. package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
  39. package/src/input/commands/recall-review.ts +26 -2
  40. package/src/input/commands/services-runtime.ts +2 -2
  41. package/src/input/commands/session-workflow.ts +3 -3
  42. package/src/input/commands/share-runtime.ts +99 -12
  43. package/src/input/commands/tts-runtime.ts +30 -4
  44. package/src/input/commands.ts +2 -2
  45. package/src/input/delete-key-policy.ts +46 -0
  46. package/src/input/feed-context-factory.ts +2 -0
  47. package/src/input/handler-feed.ts +3 -0
  48. package/src/input/handler-interactions.ts +2 -15
  49. package/src/input/handler-modal-routes.ts +91 -12
  50. package/src/input/handler-modal-token-routes.ts +3 -0
  51. package/src/input/handler-onboarding-cloudflare.ts +1 -1
  52. package/src/input/handler-onboarding.ts +55 -69
  53. package/src/input/handler-types.ts +163 -0
  54. package/src/input/handler.ts +5 -2
  55. package/src/input/input-history.ts +76 -6
  56. package/src/input/model-picker-filter.ts +265 -0
  57. package/src/input/model-picker-items.ts +208 -0
  58. package/src/input/model-picker.ts +92 -325
  59. package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
  60. package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
  61. package/src/input/onboarding/onboarding-wizard-apply.ts +4 -4
  62. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +2 -2
  63. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
  64. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
  65. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
  66. package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
  67. package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
  68. package/src/input/onboarding/onboarding-wizard-steps.ts +18 -25
  69. package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
  70. package/src/input/onboarding/onboarding-wizard.ts +3 -3
  71. package/src/input/settings-modal-data.ts +304 -0
  72. package/src/input/settings-modal-mutations.ts +154 -0
  73. package/src/input/settings-modal.ts +182 -220
  74. package/src/main.ts +57 -57
  75. package/src/panels/builtin/agent.ts +4 -1
  76. package/src/panels/builtin/development.ts +4 -1
  77. package/src/panels/confirm-state.ts +27 -12
  78. package/src/panels/cost-tracker-panel.ts +23 -67
  79. package/src/panels/eval-panel.ts +10 -9
  80. package/src/panels/knowledge-panel.ts +3 -5
  81. package/src/panels/local-auth-panel.ts +124 -4
  82. package/src/panels/project-planning-panel.ts +42 -4
  83. package/src/panels/search-focus.ts +11 -5
  84. package/src/panels/subscription-panel.ts +33 -25
  85. package/src/panels/types.ts +28 -1
  86. package/src/panels/wrfc-panel.ts +224 -41
  87. package/src/renderer/agent-detail-modal.ts +11 -10
  88. package/src/renderer/code-block.ts +10 -2
  89. package/src/renderer/compositor.ts +18 -4
  90. package/src/renderer/context-inspector.ts +1 -5
  91. package/src/renderer/diff.ts +94 -21
  92. package/src/renderer/markdown.ts +29 -13
  93. package/src/renderer/settings-modal-helpers.ts +1 -1
  94. package/src/renderer/settings-modal.ts +77 -8
  95. package/src/renderer/syntax-highlighter.ts +10 -3
  96. package/src/renderer/term-caps.ts +318 -0
  97. package/src/renderer/theme.ts +158 -0
  98. package/src/renderer/tool-call.ts +12 -2
  99. package/src/renderer/ui-factory.ts +50 -6
  100. package/src/runtime/bootstrap-command-context.ts +1 -0
  101. package/src/runtime/bootstrap-command-parts.ts +14 -0
  102. package/src/runtime/bootstrap-core.ts +121 -13
  103. package/src/runtime/bootstrap.ts +2 -0
  104. package/src/runtime/onboarding/apply.ts +4 -6
  105. package/src/runtime/onboarding/index.ts +1 -0
  106. package/src/runtime/onboarding/markers.ts +42 -49
  107. package/src/runtime/onboarding/progress.ts +148 -0
  108. package/src/runtime/onboarding/state.ts +133 -55
  109. package/src/runtime/onboarding/types.ts +20 -0
  110. package/src/runtime/services.ts +21 -0
  111. package/src/runtime/wrfc-persistence.ts +237 -0
  112. package/src/shell/blocking-input.ts +20 -5
  113. package/src/tools/wrfc-agent-guard.ts +64 -3
  114. package/src/utils/format-elapsed.ts +30 -0
  115. package/src/utils/terminal-width.ts +45 -0
  116. package/src/version.ts +1 -1
  117. package/src/work-plans/work-plan-store.ts +4 -6
  118. package/src/planning/project-planning-coordinator.ts +0 -543
@@ -1,5 +1,8 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
1
+ import { closeSync, existsSync, mkdirSync, openSync, statSync, unlinkSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
+ import { atomicWriteFileSync } from '@/config/atomic-write.ts';
4
+ import { readVersioned } from '@/config/read-versioned.ts';
5
+
3
6
  import type {
4
7
  OnboardingAcknowledgementRuntimeState,
5
8
  OnboardingAcknowledgementTarget,
@@ -10,6 +13,24 @@ import type {
10
13
 
11
14
  const ONBOARDING_RUNTIME_STATE_FILE = 'onboarding-state.json';
12
15
 
16
+ /**
17
+ * Lockfile serialisation for writeOnboardingAcknowledgementState.
18
+ *
19
+ * Mechanism: O_EXCL advisory lockfile in the same directory as the state file.
20
+ * This is the simplest correct approach for two same-host processes (daemon
21
+ * + TUI) that both run this read-modify-write path:
22
+ *
23
+ * - Acquire: open(<statefile>.lock, O_CREAT|O_EXCL|O_WRONLY) — atomic on POSIX.
24
+ * - Stale detection: if the lockfile mtime is >= LOCK_STALE_MS old, force-remove.
25
+ * - Retry: up to LOCK_MAX_RETRIES rapid non-blocking attempts (no sleep — main-thread safe).
26
+ * - Release: unlink the lockfile (best-effort on failure).
27
+ *
28
+ * O_EXCL was chosen over flock(2) because it works on all POSIX targets
29
+ * without requiring an open fd on the guarded file, and is Bun-compatible.
30
+ */
31
+ const LOCK_MAX_RETRIES = 10;
32
+ const LOCK_STALE_MS = 5_000;
33
+
13
34
  export interface OnboardingRuntimeStateRecord {
14
35
  readonly scope: OnboardingStateScope;
15
36
  readonly path: string;
@@ -37,30 +58,67 @@ function resolveStatePath(
37
58
  : shellPaths.resolveUserPath('tui', ONBOARDING_RUNTIME_STATE_FILE);
38
59
  }
39
60
 
40
- function isObject(value: unknown): value is Record<string, unknown> {
41
- return typeof value === 'object' && value !== null;
61
+ function isAcknowledgementTarget(value: string): value is OnboardingAcknowledgementTarget {
62
+ return value === 'providers' || value === 'subscriptions' || value === 'auth';
42
63
  }
43
64
 
44
- function isOnboardingMode(value: unknown): value is OnboardingAcknowledgementRuntimeState['mode'] {
45
- return value === 'new' || value === 'edit' || value === 'reopen';
65
+ function isRuntimeStatePayload(value: unknown): value is OnboardingAcknowledgementRuntimeState {
66
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
67
+ const v = value as Record<string, unknown>;
68
+ if (v['version'] !== 1) return false;
69
+ if (typeof v['updatedAt'] !== 'number' || !Number.isFinite(v['updatedAt'] as number)) return false;
70
+ if (typeof v['source'] !== 'string') return false;
71
+ const mode = v['mode'];
72
+ if (mode !== undefined && mode !== 'new' && mode !== 'edit' && mode !== 'reopen') return false;
73
+ if (v['workspaceRoot'] !== undefined && typeof v['workspaceRoot'] !== 'string') return false;
74
+ if (typeof v['acknowledgements'] !== 'object' || v['acknowledgements'] === null) return false;
75
+
76
+ return Object.entries(v['acknowledgements'] as Record<string, unknown>).every(
77
+ ([key, entry]) => isAcknowledgementTarget(key) && typeof entry === 'boolean',
78
+ );
46
79
  }
47
80
 
48
- function isAcknowledgementTarget(value: string): value is OnboardingAcknowledgementTarget {
49
- return value === 'providers' || value === 'subscriptions' || value === 'auth';
81
+ // ─── Lock helpers ──────────────────────────────────────────────────────────────────
82
+
83
+ function stateLockPath(statePath: string): string {
84
+ return `${statePath}.lock`;
50
85
  }
51
86
 
52
- function isRuntimeStatePayload(value: unknown): value is OnboardingAcknowledgementRuntimeState {
53
- if (!isObject(value)) return false;
54
- if (value.version !== 1) return false;
55
- if (typeof value.updatedAt !== 'number' || !Number.isFinite(value.updatedAt)) return false;
56
- if (typeof value.source !== 'string') return false;
57
- if (value.mode !== undefined && !isOnboardingMode(value.mode)) return false;
58
- if (value.workspaceRoot !== undefined && typeof value.workspaceRoot !== 'string') return false;
59
- if (!isObject(value.acknowledgements)) return false;
60
-
61
- return Object.entries(value.acknowledgements).every(([key, entry]) => isAcknowledgementTarget(key) && typeof entry === 'boolean');
87
+ /**
88
+ * Attempt to acquire an O_EXCL advisory lock. Returns true if acquired.
89
+ * Stale locks (older than LOCK_STALE_MS) are forcibly removed before retry.
90
+ *
91
+ * Retries are non-blocking (no sleep between attempts) so this function is
92
+ * safe to call on the main thread. Each O_EXCL open is a single syscall;
93
+ * 10 rapid retries add negligible latency and are safe for a one-shot path.
94
+ */
95
+ function acquireLock(lp: string): boolean {
96
+ for (let attempt = 0; attempt < LOCK_MAX_RETRIES; attempt++) {
97
+ // Stale-lock takeover: if the lockfile is old enough, forcibly remove it.
98
+ try {
99
+ const st = statSync(lp);
100
+ if (Date.now() - st.mtimeMs >= LOCK_STALE_MS) {
101
+ try { unlinkSync(lp); } catch { /* another process may have beaten us */ }
102
+ }
103
+ } catch { /* lockfile does not exist — expected happy path */ }
104
+
105
+ try {
106
+ // 'wx' ≡ O_CREAT | O_EXCL | O_WRONLY — fails atomically if file exists.
107
+ const fd = openSync(lp, 'wx');
108
+ closeSync(fd);
109
+ return true;
110
+ } catch { /* file exists, held by another process */ }
111
+ }
112
+ return false;
113
+ }
114
+
115
+ /** Release the advisory lockfile (best-effort). */
116
+ function releaseLock(lp: string): void {
117
+ try { unlinkSync(lp); } catch { /* best-effort */ }
62
118
  }
63
119
 
120
+ // ─── Public API ────────────────────────────────────────────────────────────────────
121
+
64
122
  export function getOnboardingRuntimeStatePath(
65
123
  shellPaths: OnboardingShellPaths,
66
124
  scope: OnboardingStateScope = 'project',
@@ -73,42 +131,39 @@ export function readOnboardingRuntimeState(
73
131
  scope: OnboardingStateScope = 'project',
74
132
  ): OnboardingRuntimeStateRecord {
75
133
  const path = resolveStatePath(shellPaths, scope);
76
- if (!existsSync(path)) {
134
+
135
+ const parsed = readVersioned<OnboardingAcknowledgementRuntimeState & { version: number }>(
136
+ path,
137
+ { currentVersion: 1, onUnknown: 'quarantine' },
138
+ );
139
+
140
+ if (parsed === null) {
141
+ // readVersioned returns null for: missing file, corrupt JSON, or
142
+ // unrecognised version (in which case it renames to <path>.unrecognized).
143
+ const nowExists = existsSync(path);
144
+ const quarantined = existsSync(`${path}.unrecognized`);
77
145
  return {
78
146
  scope,
79
147
  path,
80
- exists: false,
148
+ exists: nowExists || quarantined,
81
149
  payload: null,
150
+ ...(quarantined
151
+ ? { parseError: 'Unrecognised or corrupt onboarding state file; quarantined.' }
152
+ : {}),
82
153
  };
83
154
  }
84
155
 
85
- try {
86
- const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
87
- if (!isRuntimeStatePayload(parsed)) {
88
- return {
89
- scope,
90
- path,
91
- exists: true,
92
- payload: null,
93
- parseError: 'Invalid onboarding runtime state payload.',
94
- };
95
- }
96
-
97
- return {
98
- scope,
99
- path,
100
- exists: true,
101
- payload: parsed,
102
- };
103
- } catch (error) {
156
+ if (!isRuntimeStatePayload(parsed)) {
104
157
  return {
105
158
  scope,
106
159
  path,
107
160
  exists: true,
108
161
  payload: null,
109
- parseError: error instanceof Error ? error.message : String(error),
162
+ parseError: 'Invalid onboarding runtime state payload.',
110
163
  };
111
164
  }
165
+
166
+ return { scope, path, exists: true, payload: parsed };
112
167
  }
113
168
 
114
169
  export function writeOnboardingAcknowledgementState(
@@ -117,24 +172,47 @@ export function writeOnboardingAcknowledgementState(
117
172
  ): OnboardingRuntimeStateRecord {
118
173
  const scope = options.scope ?? 'project';
119
174
  const path = resolveStatePath(shellPaths, scope);
120
- const existing = readOnboardingRuntimeState(shellPaths, scope);
121
- const updatedAt = options.updatedAt ?? Date.now();
122
- const payload: OnboardingAcknowledgementRuntimeState = {
123
- version: 1,
124
- updatedAt,
125
- source: options.source,
126
- ...(options.mode ? { mode: options.mode } : {}),
127
- ...(options.workspaceRoot ?? shellPaths.workingDirectory
128
- ? { workspaceRoot: options.workspaceRoot ?? shellPaths.workingDirectory }
129
- : {}),
130
- acknowledgements: {
131
- ...(existing.payload?.acknowledgements ?? {}),
132
- [options.target]: options.acknowledged,
133
- },
134
- };
175
+ const lp = stateLockPath(path);
135
176
 
177
+ // Ensure the parent directory exists before we try to create the lockfile.
136
178
  mkdirSync(dirname(path), { recursive: true });
137
- writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
179
+
180
+ const acquired = acquireLock(lp);
181
+ if (!acquired) {
182
+ // Lock exhaustion: another process has held the lock for all LOCK_MAX_RETRIES
183
+ // attempts. Proceeding without the lock — the atomic write (rename) prevents
184
+ // torn files, but under true concurrent contention a concurrent read-modify-write
185
+ // may result in a lost-update (last writer wins). Surfaced here so it is
186
+ // detectable in logs rather than silently discarded.
187
+ console.warn(
188
+ '[goodvibes] onboarding-state: lock exhausted, proceeding without lock.',
189
+ { path, target: options.target, source: options.source },
190
+ );
191
+ }
192
+
193
+ try {
194
+ // Re-read (inside the lock when acquired; best-effort when degraded) to get
195
+ // the freshest acknowledgements state, eliminating the read-modify-write
196
+ // race between daemon and TUI under normal conditions.
197
+ const existing = readOnboardingRuntimeState(shellPaths, scope);
198
+ const updatedAt = options.updatedAt ?? Date.now();
199
+ const ws = options.workspaceRoot ?? shellPaths.workingDirectory;
200
+ const payload: OnboardingAcknowledgementRuntimeState = {
201
+ version: 1,
202
+ updatedAt,
203
+ source: options.source,
204
+ ...(options.mode ? { mode: options.mode } : {}),
205
+ ...(ws ? { workspaceRoot: ws } : {}),
206
+ acknowledgements: {
207
+ ...(existing.payload?.acknowledgements ?? {}),
208
+ [options.target]: options.acknowledged,
209
+ },
210
+ };
211
+
212
+ atomicWriteFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { mkdirp: true });
213
+ } finally {
214
+ if (acquired) releaseLock(lp);
215
+ }
138
216
 
139
217
  return readOnboardingRuntimeState(shellPaths, scope);
140
218
  }
@@ -340,6 +340,26 @@ export interface OnboardingCheckMarkersState {
340
340
  readonly effective: OnboardingCheckMarkerState | null;
341
341
  }
342
342
 
343
+ export interface WizardProgressPayload {
344
+ readonly version: 1;
345
+ readonly savedAt: number;
346
+ readonly mode: OnboardingMode;
347
+ readonly stepIndex: number;
348
+ /** Serialised Map<fieldId, boolean> entries */
349
+ readonly toggleState: ReadonlyArray<readonly [string, boolean]>;
350
+ /** Serialised Map<fieldId, string> entries */
351
+ readonly radioState: ReadonlyArray<readonly [string, string]>;
352
+ /** Serialised Map<fieldId, string> entries */
353
+ readonly textState: ReadonlyArray<readonly [string, string]>;
354
+ }
355
+
356
+ export interface WizardProgressState {
357
+ readonly path: string;
358
+ readonly exists: boolean;
359
+ readonly payload: WizardProgressPayload | null;
360
+ readonly parseError?: string;
361
+ }
362
+
343
363
  export interface WriteOnboardingCheckMarkerOptions {
344
364
  readonly scope?: OnboardingStateScope;
345
365
  readonly checkedAt?: number;
@@ -11,6 +11,7 @@ import { WatcherRegistry } from '@pellux/goodvibes-sdk/platform/watchers';
11
11
  import { ArtifactStore } from '@pellux/goodvibes-sdk/platform/artifacts';
12
12
  import {
13
13
  HomeGraphService,
14
+ GOODVIBES_AGENT_KNOWLEDGE_DB_FILE,
14
15
  HOME_GRAPH_KNOWLEDGE_EXTENSION,
15
16
  KnowledgeService,
16
17
  KnowledgeSemanticService,
@@ -169,6 +170,7 @@ export interface RuntimeServices {
169
170
  readonly gatewayMethods: GatewayMethodCatalog;
170
171
  readonly artifactStore: ArtifactStore;
171
172
  readonly knowledgeService: KnowledgeService;
173
+ readonly agentKnowledgeService: KnowledgeService;
172
174
  readonly homeGraphService: HomeGraphService;
173
175
  readonly projectPlanningService: ProjectPlanningService;
174
176
  readonly projectPlanningProjectId: string;
@@ -419,6 +421,10 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
419
421
  configManager,
420
422
  dbFileName: REGULAR_KNOWLEDGE_DB_FILE,
421
423
  });
424
+ const agentKnowledgeStore = new KnowledgeStore({
425
+ configManager,
426
+ dbFileName: GOODVIBES_AGENT_KNOWLEDGE_DB_FILE,
427
+ });
422
428
  const homeGraphKnowledgeStore = new KnowledgeStore({
423
429
  configManager,
424
430
  dbFileName: HOME_GRAPH_KNOWLEDGE_DB_FILE,
@@ -436,12 +442,22 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
436
442
  maxLlmSourcesPerReindex: 3,
437
443
  objectProfiles: HOME_GRAPH_KNOWLEDGE_EXTENSION.objectProfiles,
438
444
  });
445
+ const agentKnowledgeSemanticService = new KnowledgeSemanticService(agentKnowledgeStore, {
446
+ llm: knowledgeSemanticLlm,
447
+ maxLlmSourcesPerReindex: 3,
448
+ });
439
449
  const knowledgeService = new KnowledgeService(knowledgeStore, artifactStore, undefined, {
440
450
  memoryRegistry,
441
451
  runtimeBus: options.runtimeBus,
442
452
  semanticService: knowledgeSemanticService,
443
453
  });
444
454
  knowledgeService.attachRuntimeBus(options.runtimeBus);
455
+ const agentKnowledgeService = new KnowledgeService(agentKnowledgeStore, artifactStore, undefined, {
456
+ memoryRegistry,
457
+ runtimeBus: options.runtimeBus,
458
+ semanticService: agentKnowledgeSemanticService,
459
+ });
460
+ agentKnowledgeService.attachRuntimeBus(options.runtimeBus);
445
461
  const homeGraphService = new HomeGraphService(homeGraphKnowledgeStore, artifactStore, {
446
462
  semanticService: homeGraphSemanticService,
447
463
  });
@@ -469,6 +485,10 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
469
485
  searchService: webSearchService,
470
486
  ingestService: knowledgeService,
471
487
  }));
488
+ agentKnowledgeSemanticService.setGapRepairer(createWebKnowledgeGapRepairer({
489
+ searchService: webSearchService,
490
+ ingestService: agentKnowledgeService,
491
+ }));
472
492
  homeGraphSemanticService.setGapRepairer(createWebKnowledgeGapRepairer({
473
493
  searchService: webSearchService,
474
494
  ingestService: homeGraphService,
@@ -600,6 +620,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
600
620
  gatewayMethods,
601
621
  artifactStore,
602
622
  knowledgeService,
623
+ agentKnowledgeService,
603
624
  homeGraphService,
604
625
  projectPlanningService,
605
626
  projectPlanningProjectId,
@@ -0,0 +1,237 @@
1
+ /**
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.
4
+ *
5
+ * Architecture
6
+ * ─────────────
7
+ * 1. `createWrfcPersistence` subscribes to all 7 WORKFLOW_* events on the
8
+ * runtimeBus. Each event schedules a trailing-debounced snapshot (250 ms)
9
+ * so event bursts from a single state transition don't thrash disk.
10
+ *
11
+ * 2. `WrfcPersistence.rehydrate(router)` is called once on boot (after the
12
+ * SystemMessageRouter is available). It reads the snapshot, identifies
13
+ * chains that were in a non-terminal state at last write, and emits a
14
+ * high-priority 'wrfc' system message per interrupted chain. The
15
+ * `interruptedChains` accessor makes the data available for panel reads
16
+ * without coupling this module to wrfc-panel.ts.
17
+ *
18
+ * 3. Snapshot lifecycle:
19
+ * - Terminal chains ('passed' | 'failed') are pruned from the snapshot
20
+ * after rehydration surfaces them.
21
+ * - A corrupt or version-mismatched snapshot is quarantined by renaming it
22
+ * to `<path>.unrecognized` — never a hard crash.
23
+ *
24
+ * Snapshot path: `.goodvibes/tui/wrfc-chains.json`
25
+ * Snapshot schema: `{ version: 1, writtenAt: number, chains: WrfcChain[] }`
26
+ */
27
+
28
+ import type { WrfcChain, WrfcState } from '@pellux/goodvibes-sdk/platform/agents';
29
+ import type { RuntimeEventBus, WorkflowEvent } from '@/runtime/index.ts';
30
+ import { atomicWriteFileSync } from '../config/atomic-write.ts';
31
+ import { readVersioned } from '../config/read-versioned.ts';
32
+ import type { SystemMessageRouter } from '../core/system-message-router.ts';
33
+
34
+ // ─── Constants ───────────────────────────────────────────────────────────────
35
+
36
+ const SNAPSHOT_VERSION = 1;
37
+ const DEBOUNCE_MS = 250;
38
+
39
+ /** Terminal states — chains in these states will not be surfaced as interrupted. */
40
+ const TERMINAL_STATES = new Set<WrfcState>(['passed', 'failed']);
41
+
42
+ /** Non-terminal (interruptible) states. */
43
+ function isNonTerminal(state: WrfcState): boolean {
44
+ return !TERMINAL_STATES.has(state);
45
+ }
46
+
47
+ // ─── Snapshot schema ─────────────────────────────────────────────────────────
48
+
49
+ interface WrfcSnapshot {
50
+ readonly version: number;
51
+ readonly writtenAt: number;
52
+ readonly chains: WrfcChain[];
53
+ }
54
+
55
+ // ─── Public API ──────────────────────────────────────────────────────────────
56
+
57
+ /** Subset of WrfcController needed by this module. */
58
+ export interface WrfcControllerReader {
59
+ listChains(): WrfcChain[];
60
+ }
61
+
62
+ export interface WrfcPersistenceOptions {
63
+ /** Absolute path to the snapshot file, e.g. `.goodvibes/tui/wrfc-chains.json`. */
64
+ readonly snapshotPath: string;
65
+ /** Factory for the current SystemMessageRouter — may return null before it is wired. */
66
+ readonly getSystemMessageRouter: () => SystemMessageRouter | null;
67
+ /** WrfcController reader — only listChains() is needed. */
68
+ readonly controller: WrfcControllerReader;
69
+ }
70
+
71
+ export interface WrfcPersistence {
72
+ /**
73
+ * Chains from the previous process that were in a non-terminal state.
74
+ * Populated only after `rehydrate()` is called.
75
+ */
76
+ readonly interruptedChains: readonly WrfcChain[];
77
+
78
+ /**
79
+ * Subscribe to runtimeBus workflow events and start persisting snapshots.
80
+ * Returns an array of unsubscribe functions to be added to `runtimeUnsubs`.
81
+ */
82
+ attach(runtimeBus: RuntimeEventBus): Array<() => void>;
83
+
84
+ /**
85
+ * Read the snapshot from a previous process, surface any interrupted chains
86
+ * as system messages, and prune terminal chains from the snapshot on disk.
87
+ *
88
+ * Must be called after the SystemMessageRouter is available.
89
+ */
90
+ rehydrate(): void;
91
+
92
+ /** Flush any pending debounced snapshot immediately (used in tests). */
93
+ flush(): void;
94
+ }
95
+
96
+ // ─── Implementation ──────────────────────────────────────────────────────────
97
+
98
+ class WrfcPersistenceImpl implements WrfcPersistence {
99
+ private readonly snapshotPath: string;
100
+ private readonly getSystemMessageRouter: () => SystemMessageRouter | null;
101
+ private readonly controller: WrfcControllerReader;
102
+
103
+ private _interruptedChains: WrfcChain[] = [];
104
+ private _debounceTimer: ReturnType<typeof setTimeout> | null = null;
105
+
106
+ constructor(options: WrfcPersistenceOptions) {
107
+ this.snapshotPath = options.snapshotPath;
108
+ this.getSystemMessageRouter = options.getSystemMessageRouter;
109
+ this.controller = options.controller;
110
+ }
111
+
112
+ get interruptedChains(): readonly WrfcChain[] {
113
+ return this._interruptedChains;
114
+ }
115
+
116
+ attach(runtimeBus: RuntimeEventBus): Array<() => void> {
117
+ const schedule = (): void => this._scheduleSnapshot();
118
+
119
+ const events: WorkflowEvent['type'][] = [
120
+ 'WORKFLOW_CHAIN_CREATED',
121
+ 'WORKFLOW_STATE_CHANGED',
122
+ 'WORKFLOW_REVIEW_COMPLETED',
123
+ 'WORKFLOW_FIX_ATTEMPTED',
124
+ 'WORKFLOW_GATE_RESULT',
125
+ 'WORKFLOW_CHAIN_PASSED',
126
+ 'WORKFLOW_CHAIN_FAILED',
127
+ ];
128
+
129
+ return events.map((eventType) =>
130
+ runtimeBus.on<Extract<WorkflowEvent, { type: typeof eventType }>>(eventType, schedule),
131
+ );
132
+ }
133
+
134
+ rehydrate(): void {
135
+ const snapshot = this._readSnapshot();
136
+ if (!snapshot) return;
137
+
138
+ const interrupted = snapshot.chains.filter((c) => isNonTerminal(c.state));
139
+ this._interruptedChains = interrupted;
140
+
141
+ const router = this.getSystemMessageRouter();
142
+ for (const chain of interrupted) {
143
+ const msg =
144
+ `[WRFC] Chain ${chain.id.slice(0, 12)} (${chain.task.slice(0, 60).trim()}) ` +
145
+ `was interrupted by a restart — state was '${chain.state}' ` +
146
+ `after ${chain.reviewCycles} review cycle${chain.reviewCycles !== 1 ? 's' : ''}`;
147
+ router?.wrfc(msg, 'high');
148
+ }
149
+
150
+ // Prune terminal chains from the on-disk snapshot after surfacing.
151
+ if (snapshot.chains.length !== interrupted.length) {
152
+ const pruned: WrfcSnapshot = {
153
+ version: SNAPSHOT_VERSION,
154
+ writtenAt: Date.now(),
155
+ chains: interrupted,
156
+ };
157
+ this._writeSnapshot(pruned);
158
+ }
159
+ }
160
+
161
+ flush(): void {
162
+ if (this._debounceTimer !== null) {
163
+ clearTimeout(this._debounceTimer);
164
+ this._debounceTimer = null;
165
+ }
166
+ this._writeCurrentSnapshot();
167
+ }
168
+
169
+ // ── Private ────────────────────────────────────────────────────────────────
170
+
171
+ private _scheduleSnapshot(): void {
172
+ if (this._debounceTimer !== null) {
173
+ clearTimeout(this._debounceTimer);
174
+ }
175
+ this._debounceTimer = setTimeout(() => {
176
+ this._debounceTimer = null;
177
+ this._writeCurrentSnapshot();
178
+ }, DEBOUNCE_MS);
179
+ }
180
+
181
+ private _writeCurrentSnapshot(): void {
182
+ const snapshot: WrfcSnapshot = {
183
+ version: SNAPSHOT_VERSION,
184
+ writtenAt: Date.now(),
185
+ chains: this.controller.listChains(),
186
+ };
187
+ this._writeSnapshot(snapshot);
188
+ }
189
+
190
+ private _writeSnapshot(snapshot: WrfcSnapshot): void {
191
+ try {
192
+ atomicWriteFileSync(this.snapshotPath, JSON.stringify(snapshot), { mkdirp: true });
193
+ } catch {
194
+ // Best-effort — never crash the TUI over a persistence failure.
195
+ }
196
+ }
197
+
198
+ private _readSnapshot(): WrfcSnapshot | null {
199
+ // readVersioned handles: missing file → null, corrupt JSON → quarantine to
200
+ // .unrecognized, future/unrecognised version → quarantine, stepwise migration.
201
+ const raw = readVersioned<WrfcSnapshot & { version: number }>(
202
+ this.snapshotPath,
203
+ {
204
+ currentVersion: SNAPSHOT_VERSION,
205
+ // v0 → v1: pass through the data as-is (safety net only).
206
+ // NOTE: readVersioned does NOT coerce missing/non-numeric version fields to 0.
207
+ // Files without a version field are quarantined immediately. This migration
208
+ // only fires for files that explicitly contain `version: 0`.
209
+ migrations: {
210
+ 0: (d) => ({ ...d, version: 1 }),
211
+ },
212
+ onUnknown: 'quarantine',
213
+ },
214
+ );
215
+ if (!raw) return null;
216
+
217
+ // Narrow the application-level fields that readVersioned does not validate.
218
+ if (typeof raw['writtenAt'] !== 'number' || !Array.isArray(raw['chains'])) {
219
+ return null;
220
+ }
221
+
222
+ return raw as WrfcSnapshot;
223
+ }
224
+ }
225
+
226
+ // ─── Factory ─────────────────────────────────────────────────────────────────
227
+
228
+ /**
229
+ * Create a WrfcPersistence instance.
230
+ *
231
+ * Call `persistence.attach(runtimeBus)` and push the returned unsubs into
232
+ * `runtimeUnsubs`. Call `persistence.rehydrate()` once the SystemMessageRouter
233
+ * is available.
234
+ */
235
+ export function createWrfcPersistence(options: WrfcPersistenceOptions): WrfcPersistence {
236
+ return new WrfcPersistenceImpl(options);
237
+ }
@@ -17,6 +17,15 @@ export type BlockingInputHandlerOptions = {
17
17
  render: () => void;
18
18
  loadRecoveryConversation: () => SessionSnapshot | null;
19
19
  deleteRecoveryFile: () => void;
20
+ /**
21
+ * Optional callback invoked after Ctrl+R restore to reopen panels captured in
22
+ * the recovery snapshot's returnContext. When provided (as wired in main.ts),
23
+ * the callback iterates snapshot.returnContext.openPanels and calls
24
+ * panelManager.open() for each entry, then panelManager.show() + render() to
25
+ * restore the panel posture from the recovered session. When omitted, panel
26
+ * posture is not restored.
27
+ */
28
+ reopenPanels?: (snapshot: SessionSnapshot) => void;
20
29
  };
21
30
 
22
31
  export type BlockingInputHandlerResult = {
@@ -38,6 +47,7 @@ export function handleBlockingShellInput(
38
47
  render,
39
48
  loadRecoveryConversation,
40
49
  deleteRecoveryFile,
50
+ reopenPanels,
41
51
  } = options;
42
52
 
43
53
  if (pendingPermission) {
@@ -71,12 +81,17 @@ export function handleBlockingShellInput(
71
81
  if (data === '\x12') {
72
82
  const recovery = loadRecoveryConversation();
73
83
  if (recovery) {
74
- conversation.fromJSON({ messages: recovery.messages as Parameters<typeof conversation.fromJSON>[0]['messages'] });
84
+ conversation.fromJSON({
85
+ messages: recovery.messages as Parameters<typeof conversation.fromJSON>[0]['messages'],
86
+ title: recovery.title,
87
+ titleSource: recovery.titleSource,
88
+ });
89
+ reopenPanels?.(recovery);
75
90
  systemMessageRouter.high('[Recovery] Session restored.');
91
+ deleteRecoveryFile();
76
92
  } else {
77
93
  systemMessageRouter.high('[Recovery] Failed to restore saved data.');
78
94
  }
79
- deleteRecoveryFile();
80
95
  render();
81
96
  return { handled: true, pendingPermission: null, recoveryPending: false };
82
97
  }
@@ -88,10 +103,10 @@ export function handleBlockingShellInput(
88
103
  return { handled: true, pendingPermission: null, recoveryPending: false };
89
104
  }
90
105
 
91
- systemMessageRouter.high('[Recovery] Ignored saved session; starting a new prompt.');
92
- deleteRecoveryFile();
106
+ // Stray key: leave the recovery prompt active so the user can still Ctrl+R or Esc.
107
+ systemMessageRouter.high('[Recovery] Ctrl+R to restore · Esc to discard');
93
108
  render();
94
- return { handled: false, pendingPermission: null, recoveryPending: false };
109
+ return { handled: false, pendingPermission, recoveryPending: true };
95
110
  }
96
111
 
97
112
  return { handled: false, pendingPermission, recoveryPending };