@pellux/goodvibes-tui 0.20.3 → 0.22.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 (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +23 -2
  3. package/docs/foundation-artifacts/operator-contract.json +78 -1
  4. package/package.json +4 -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 +658 -0
  9. package/src/cli/config-overrides.ts +68 -0
  10. package/src/cli/entrypoint.ts +6 -0
  11. package/src/cli/help.ts +4 -2
  12. package/src/cli/management-commands.ts +1 -1
  13. package/src/cli/management.ts +1 -8
  14. package/src/cli/parser.ts +31 -18
  15. package/src/cli/service-command.ts +1 -1
  16. package/src/cli/surface-command.ts +1 -1
  17. package/src/cli/tui-startup.ts +72 -10
  18. package/src/cli/types.ts +14 -3
  19. package/src/cli-flags.ts +1 -0
  20. package/src/config/atomic-write.ts +70 -0
  21. package/src/config/goodvibes-home-audit.ts +2 -0
  22. package/src/config/read-versioned.ts +115 -0
  23. package/src/core/context-auto-compact.ts +77 -0
  24. package/src/core/conversation-rendering.ts +49 -15
  25. package/src/core/conversation.ts +101 -16
  26. package/src/core/format-user-error.ts +192 -0
  27. package/src/core/stream-event-wiring.ts +144 -0
  28. package/src/core/stream-stall-watchdog.ts +103 -0
  29. package/src/core/system-message-router.ts +5 -1
  30. package/src/core/turn-event-wiring.ts +124 -0
  31. package/src/daemon/cli.ts +5 -0
  32. package/src/export/cost-utils.ts +71 -0
  33. package/src/export/gist-uploader.ts +136 -0
  34. package/src/input/command-registry.ts +32 -1
  35. package/src/input/commands/control-room-runtime.ts +10 -10
  36. package/src/input/commands/experience-runtime.ts +5 -4
  37. package/src/input/commands/knowledge.ts +1 -1
  38. package/src/input/commands/local-auth-runtime.ts +27 -5
  39. package/src/input/commands/local-setup.ts +4 -6
  40. package/src/input/commands/memory-product-runtime.ts +8 -6
  41. package/src/input/commands/operator-panel-runtime.ts +1 -1
  42. package/src/input/commands/operator-runtime.ts +3 -10
  43. package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
  44. package/src/input/commands/provider.ts +57 -3
  45. package/src/input/commands/recall-review.ts +26 -2
  46. package/src/input/commands/services-runtime.ts +2 -2
  47. package/src/input/commands/session-workflow.ts +8 -16
  48. package/src/input/commands/session.ts +70 -20
  49. package/src/input/commands/share-runtime.ts +99 -12
  50. package/src/input/commands/tts-runtime.ts +30 -4
  51. package/src/input/commands.ts +2 -4
  52. package/src/input/delete-key-policy.ts +46 -0
  53. package/src/input/feed-context-factory.ts +2 -0
  54. package/src/input/handler-feed.ts +3 -0
  55. package/src/input/handler-interactions.ts +2 -15
  56. package/src/input/handler-modal-routes.ts +128 -12
  57. package/src/input/handler-modal-token-routes.ts +22 -5
  58. package/src/input/handler-onboarding-cloudflare.ts +1 -1
  59. package/src/input/handler-onboarding.ts +73 -69
  60. package/src/input/handler-types.ts +163 -0
  61. package/src/input/handler.ts +6 -2
  62. package/src/input/input-history.ts +76 -6
  63. package/src/input/model-picker-filter.ts +265 -0
  64. package/src/input/model-picker-items.ts +208 -0
  65. package/src/input/model-picker.ts +92 -325
  66. package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
  67. package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
  68. package/src/input/onboarding/onboarding-wizard-apply.ts +14 -4
  69. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +16 -2
  70. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
  71. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
  72. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
  73. package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
  74. package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
  75. package/src/input/onboarding/onboarding-wizard-steps.ts +24 -25
  76. package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
  77. package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
  78. package/src/input/onboarding/onboarding-wizard.ts +3 -3
  79. package/src/input/settings-modal-behavior.ts +5 -0
  80. package/src/input/settings-modal-data.ts +378 -0
  81. package/src/input/settings-modal-mutations.ts +157 -0
  82. package/src/input/settings-modal-reset.ts +154 -0
  83. package/src/input/settings-modal.ts +236 -232
  84. package/src/main.ts +93 -85
  85. package/src/panels/agent-inspector-panel.ts +120 -18
  86. package/src/panels/agent-inspector-shared.ts +29 -0
  87. package/src/panels/builtin/agent.ts +4 -1
  88. package/src/panels/builtin/development.ts +5 -1
  89. package/src/panels/builtin/knowledge.ts +14 -13
  90. package/src/panels/builtin/operations.ts +22 -1
  91. package/src/panels/builtin/shared.ts +7 -0
  92. package/src/panels/cockpit-panel.ts +123 -3
  93. package/src/panels/cockpit-read-model.ts +232 -0
  94. package/src/panels/confirm-state.ts +27 -12
  95. package/src/panels/cost-tracker-panel.ts +23 -67
  96. package/src/panels/eval-panel.ts +10 -9
  97. package/src/panels/index.ts +1 -1
  98. package/src/panels/knowledge-graph-panel.ts +84 -0
  99. package/src/panels/local-auth-panel.ts +124 -4
  100. package/src/panels/memory-panel.ts +370 -40
  101. package/src/panels/project-planning-panel.ts +42 -4
  102. package/src/panels/search-focus.ts +11 -5
  103. package/src/panels/session-maintenance.ts +66 -15
  104. package/src/panels/subscription-panel.ts +33 -25
  105. package/src/panels/types.ts +28 -1
  106. package/src/panels/wrfc-panel.ts +224 -41
  107. package/src/renderer/agent-detail-modal.ts +118 -13
  108. package/src/renderer/code-block.ts +10 -2
  109. package/src/renderer/compositor.ts +18 -4
  110. package/src/renderer/context-inspector.ts +1 -5
  111. package/src/renderer/context-status-hint.ts +54 -0
  112. package/src/renderer/diff.ts +94 -21
  113. package/src/renderer/markdown.ts +29 -13
  114. package/src/renderer/settings-modal-helpers.ts +1 -1
  115. package/src/renderer/settings-modal.ts +90 -10
  116. package/src/renderer/shell-surface.ts +10 -0
  117. package/src/renderer/syntax-highlighter.ts +10 -3
  118. package/src/renderer/term-caps.ts +318 -0
  119. package/src/renderer/theme.ts +158 -0
  120. package/src/renderer/tool-call.ts +12 -2
  121. package/src/renderer/ui-factory.ts +50 -6
  122. package/src/runtime/bootstrap-command-context.ts +1 -0
  123. package/src/runtime/bootstrap-command-parts.ts +18 -0
  124. package/src/runtime/bootstrap-core.ts +145 -13
  125. package/src/runtime/bootstrap-shell.ts +11 -0
  126. package/src/runtime/bootstrap.ts +9 -0
  127. package/src/runtime/onboarding/apply.ts +4 -6
  128. package/src/runtime/onboarding/index.ts +1 -0
  129. package/src/runtime/onboarding/markers.ts +42 -49
  130. package/src/runtime/onboarding/progress.ts +148 -0
  131. package/src/runtime/onboarding/state.ts +133 -55
  132. package/src/runtime/onboarding/types.ts +20 -0
  133. package/src/runtime/services.ts +27 -1
  134. package/src/runtime/wrfc-persistence.ts +237 -0
  135. package/src/shell/blocking-input.ts +20 -5
  136. package/src/tools/wrfc-agent-guard.ts +64 -3
  137. package/src/utils/format-elapsed.ts +30 -0
  138. package/src/utils/terminal-width.ts +45 -0
  139. package/src/version.ts +1 -1
  140. package/src/work-plans/work-plan-store.ts +4 -6
  141. package/src/panels/knowledge-panel.ts +0 -345
  142. package/src/planning/project-planning-coordinator.ts +0 -543
@@ -29,7 +29,59 @@ import { registerBootstrapHookBridge } from '@/runtime/index.ts';
29
29
  import { registerBootstrapRuntimeEvents } from '@/runtime/index.ts';
30
30
  import { createRuntimeServices, type RuntimeServices } from './services.ts';
31
31
  import { createUiRuntimeServices, type UiRuntimeServices } from './ui-services.ts';
32
+ import { join } from 'node:path';
32
33
  import { installWrfcAgentToolGuard } from '../tools/wrfc-agent-guard.ts';
34
+ import { createWrfcPersistence, type WrfcPersistence } from './wrfc-persistence.ts';
35
+ import type { SystemMessagePriority } from '../panels/system-messages-panel.ts';
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Pre-router buffer
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const PRE_ROUTER_BUFFER_MAX = 100;
42
+
43
+ type BufferedWrfcMessage = {
44
+ readonly message: string;
45
+ readonly priority: SystemMessagePriority;
46
+ };
47
+
48
+ /**
49
+ * Small bounded queue that accumulates WRFC system messages emitted before
50
+ * the SystemMessageRouter is attached. On attach the queue flushes in order.
51
+ * If the queue overflows (> PRE_ROUTER_BUFFER_MAX), the oldest entries are
52
+ * dropped and a summary message is prepended to the first flushed message.
53
+ */
54
+ export class WrfcPreRouterBuffer {
55
+ private readonly queue: BufferedWrfcMessage[] = [];
56
+ private overflowCount = 0;
57
+
58
+ push(message: string, priority: SystemMessagePriority): void {
59
+ if (this.queue.length >= PRE_ROUTER_BUFFER_MAX) {
60
+ this.queue.shift();
61
+ this.overflowCount++;
62
+ }
63
+ this.queue.push({ message, priority });
64
+ }
65
+
66
+ flush(router: import('../core/system-message-router.ts').SystemMessageRouter): void {
67
+ const dropped = this.overflowCount;
68
+ const pending = this.queue.splice(0);
69
+ this.overflowCount = 0;
70
+ if (dropped > 0) {
71
+ router.wrfc(
72
+ `[WRFC] Pre-router buffer overflowed: ${dropped} earliest message${dropped !== 1 ? 's' : ''} were dropped`,
73
+ 'low',
74
+ );
75
+ }
76
+ for (const item of pending) {
77
+ router.wrfc(item.message, item.priority);
78
+ }
79
+ }
80
+
81
+ get size(): number {
82
+ return this.queue.length;
83
+ }
84
+ }
33
85
 
34
86
  export interface BootstrapCoreState {
35
87
  readonly userSessionId: string;
@@ -62,6 +114,11 @@ export interface BootstrapCoreState {
62
114
  readonly requestRender: () => void;
63
115
  readonly setRenderRequest: (fn: () => void) => void;
64
116
  readonly runtimeSessionIdRef: { value: string };
117
+ /**
118
+ * WRFC chain persistence — call `rehydrate()` once after the SystemMessageRouter
119
+ * is wired so interrupted chains from a previous process are surfaced to the operator.
120
+ */
121
+ readonly wrfcPersistence: WrfcPersistence;
65
122
  }
66
123
 
67
124
  export type CompanionMessagePayload = Extract<SessionEvent, { type: 'COMPANION_MESSAGE_RECEIVED' }>;
@@ -222,9 +279,9 @@ export async function initializeBootstrapCore(
222
279
  overflowHandler: services.overflowHandler,
223
280
  changeTracker: services.sessionChangeTracker,
224
281
  });
225
- installWrfcAgentToolGuard(toolRegistry, {
226
- getLastUserMessage: () => conversation.getLastUserMessage(),
227
- });
282
+ // Note: installWrfcAgentToolGuard is called after routeOrBuffer is defined
283
+ // (further below) so the onTrace callback can route guard decisions through
284
+ // the pre-router buffer.
228
285
  services.agentOrchestrator.setDependencies({
229
286
  surfaceRoot: 'tui',
230
287
  fileCache,
@@ -305,7 +362,26 @@ export async function initializeBootstrapCore(
305
362
  void approvalBroker.start();
306
363
  void sharedSessionBroker.start();
307
364
  const runtimeSessionIdRef = { value: userSessionId };
308
- const systemMessageRouterRef: { value: SystemMessageRouter | null } = { value: null };
365
+ const wrfcBuffer = new WrfcPreRouterBuffer();
366
+ // Smart ref: setting .value auto-flushes the pre-router buffer so events
367
+ // buffered before the SystemMessageRouter attaches are not permanently lost.
368
+ const systemMessageRouterRef = ((): { value: SystemMessageRouter | null } => {
369
+ let _value: SystemMessageRouter | null = null;
370
+ const ref = {} as { value: SystemMessageRouter | null };
371
+ Object.defineProperty(ref, 'value', {
372
+ get(): SystemMessageRouter | null { return _value; },
373
+ set(router: SystemMessageRouter | null): void {
374
+ _value = router;
375
+ if (router && wrfcBuffer.size > 0) {
376
+ wrfcBuffer.flush(router);
377
+ requestRender();
378
+ }
379
+ },
380
+ enumerable: true,
381
+ configurable: true,
382
+ });
383
+ return ref;
384
+ })();
309
385
  const conversationFollowUpRef: { value: ((item: ConversationFollowUpItem) => void) | null } = { value: null };
310
386
  const { unsubs: runtimeUnsubs, agentStatusIntervalRef } = registerBootstrapRuntimeEvents({
311
387
  runtimeBus,
@@ -318,21 +394,69 @@ export async function initializeBootstrapCore(
318
394
  wrfcController: services.wrfcController,
319
395
  });
320
396
 
397
+ // ── WRFC chain persistence ──────────────────────────────────────────────────────────
398
+ const wrfcPersistence = createWrfcPersistence({
399
+ snapshotPath: join(workingDir, '.goodvibes', 'tui', 'wrfc-chains.json'),
400
+ getSystemMessageRouter: () => systemMessageRouterRef.value,
401
+ controller: services.wrfcController,
402
+ });
403
+ runtimeUnsubs.push(...wrfcPersistence.attach(runtimeBus));
404
+ // Flush any debounced snapshot on clean shutdown so final chain state is
405
+ // never silently dropped during a SIGINT/teardown (250ms debounce window).
406
+ bootstrapUnsubs.push(() => wrfcPersistence.flush());
407
+
321
408
  // ── TUI-specific WRFC constraint-propagation event subscriptions (SDK 0.23.0) ──
322
409
  // These supplement the SDK's registerBootstrapRuntimeEvents which handles the
323
410
  // core WORKFLOW_REVIEW_COMPLETED / WORKFLOW_CHAIN_CREATED messages.
324
411
  // The SDK does not surface constraint-specific system messages; the TUI layer
325
412
  // adds them here so operators can observe constraint enumeration and violations
326
413
  // in the SystemMessagesPanel and main conversation.
414
+ //
415
+ // Pre-router buffering: events that arrive before the SystemMessageRouter is
416
+ // attached are held in wrfcBuffer (bounded, 100 entries). When the router is
417
+ // set on systemMessageRouterRef, the smart setter flushes the buffer in order.
418
+ // If the buffer overflows, the oldest entries are dropped and a summary message
419
+ // is prepended to the first flushed batch.
420
+ const routeOrBuffer = (message: string, priority: SystemMessagePriority): void => {
421
+ const router = systemMessageRouterRef.value;
422
+ if (router) {
423
+ router.wrfc(message, priority);
424
+ } else {
425
+ wrfcBuffer.push(message, priority);
426
+ }
427
+ };
428
+
429
+ // Startup TLS banner — emitted via wrfcBuffer.push() because the
430
+ // SystemMessageRouter is not attached yet at this point in bootstrap. The
431
+ // smart-ref setter on systemMessageRouterRef auto-flushes the buffer when
432
+ // the router attaches, so the message will appear in the WRFC panel on startup.
433
+ {
434
+ const cpEnabled = Boolean(configManager.get('controlPlane.enabled'));
435
+ const cpHostMode = String(configManager.get('controlPlane.hostMode') ?? 'local');
436
+ const cpTlsMode = String(configManager.get('controlPlane.tls.mode') ?? 'off');
437
+ const hlEnabled = Boolean(configManager.get('danger.httpListener'));
438
+ const hlHostMode = String(configManager.get('httpListener.hostMode') ?? 'local');
439
+ const hlTlsMode = String(configManager.get('httpListener.tls.mode') ?? 'off');
440
+ const cpNetworkPlaintext = cpEnabled && cpHostMode !== 'local' && cpTlsMode === 'off';
441
+ const hlNetworkPlaintext = hlEnabled && hlHostMode !== 'local' && hlTlsMode === 'off';
442
+ if (cpNetworkPlaintext || hlNetworkPlaintext) {
443
+ const affected: string[] = [];
444
+ if (cpNetworkPlaintext) affected.push('control plane');
445
+ if (hlNetworkPlaintext) affected.push('HTTP listener');
446
+ wrfcBuffer.push(
447
+ `[SECURITY] TLS is off for the ${affected.join(' and ')} but it is network-reachable. All traffic (credentials, tokens, conversation content) travels in plaintext. Enable TLS (controlPlane.tls.mode / httpListener.tls.mode) or restrict to loopback before exposing to untrusted networks.`,
448
+ 'high',
449
+ );
450
+ }
451
+ }
452
+
327
453
  runtimeUnsubs.push(
328
454
  runtimeBus.on<Extract<import('@/runtime/index.ts').WorkflowEvent, { type: 'WORKFLOW_CONSTRAINTS_ENUMERATED' }>>(
329
455
  'WORKFLOW_CONSTRAINTS_ENUMERATED',
330
456
  ({ payload }) => {
331
- const router = systemMessageRouterRef.value;
332
- if (!router) return;
333
457
  const count = payload.constraints.length;
334
458
  if (count > 0) {
335
- router.wrfc(
459
+ routeOrBuffer(
336
460
  `[WRFC] Engineer enumerated ${count} constraint${count !== 1 ? 's' : ''} for chain ${payload.chainId.slice(0, 12)}`,
337
461
  'low',
338
462
  );
@@ -345,11 +469,9 @@ export async function initializeBootstrapCore(
345
469
  runtimeBus.on<Extract<import('@/runtime/index.ts').WorkflowEvent, { type: 'WORKFLOW_FIX_ATTEMPTED' }>>(
346
470
  'WORKFLOW_FIX_ATTEMPTED',
347
471
  ({ payload }) => {
348
- const router = systemMessageRouterRef.value;
349
- if (!router) return;
350
472
  const targetIds = payload.targetConstraintIds;
351
473
  if (targetIds && targetIds.length > 0) {
352
- router.wrfc(
474
+ routeOrBuffer(
353
475
  `[WRFC] Fix #${payload.attempt} targeting ${targetIds.length} constraint${targetIds.length !== 1 ? 's' : ''} on chain ${payload.chainId.slice(0, 12)}`,
354
476
  'low',
355
477
  );
@@ -362,11 +484,9 @@ export async function initializeBootstrapCore(
362
484
  runtimeBus.on<Extract<import('@/runtime/index.ts').WorkflowEvent, { type: 'WORKFLOW_REVIEW_COMPLETED' }>>(
363
485
  'WORKFLOW_REVIEW_COMPLETED',
364
486
  ({ payload }) => {
365
- const router = systemMessageRouterRef.value;
366
- if (!router) return;
367
487
  const unsatisfied = payload.unsatisfiedConstraintIds;
368
488
  if (!payload.passed && unsatisfied && unsatisfied.length > 0) {
369
- router.wrfc(
489
+ routeOrBuffer(
370
490
  `[WRFC] ✗ Chain ${payload.chainId.slice(0, 12)}: ${unsatisfied.length} constraint violation${unsatisfied.length !== 1 ? 's' : ''} forced failure`,
371
491
  'high',
372
492
  );
@@ -376,6 +496,17 @@ export async function initializeBootstrapCore(
376
496
  ),
377
497
  );
378
498
 
499
+ // Wire the WRFC agent-guard with the onTrace callback so routing decisions are
500
+ // observable via the same routeOrBuffer path as WORKFLOW_* events.
501
+ // Placed here (after routeOrBuffer is defined) so the closure is fully wired.
502
+ installWrfcAgentToolGuard(toolRegistry, {
503
+ getLastUserMessage: () => conversation.getLastUserMessage(),
504
+ onTrace: ({ kind, reason, task }) => {
505
+ const shortTask = task.length > 80 ? `${task.slice(0, 77)}...` : task;
506
+ routeOrBuffer(`[WRFC] Guard: ${reason} — task: "${shortTask}" (${kind})`, 'low');
507
+ },
508
+ });
509
+
379
510
  // Subscribe to companion main-chat messages received from the daemon's HTTP layer.
380
511
  // The daemon emits COMPANION_MESSAGE_RECEIVED on the runtime bus when a companion
381
512
  // POST /api/sessions/:id/messages with kind='message' arrives.
@@ -535,5 +666,6 @@ export async function initializeBootstrapCore(
535
666
  renderRequestRef.value = fn;
536
667
  },
537
668
  runtimeSessionIdRef,
669
+ wrfcPersistence,
538
670
  };
539
671
  }
@@ -40,6 +40,11 @@ export interface BootstrapShellState {
40
40
  readonly lastGitInfoRef: { value: GitHeaderInfo | undefined };
41
41
  readonly inputHistory: InputHistory;
42
42
  readonly systemMessageRouter: SystemMessageRouter;
43
+ /**
44
+ * Wire the agent detail modal opener after InputHandler is constructed.
45
+ * Call with `(id) => input.agentDetailModal.open(id)` from main.ts.
46
+ */
47
+ readonly setOpenAgentDetail: (fn: (agentId: string) => void) => void;
43
48
  }
44
49
 
45
50
  export interface BootstrapShellOptions {
@@ -103,6 +108,8 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
103
108
  providerRegistry: services.providerRegistry,
104
109
  });
105
110
 
111
+ const openAgentDetailRef: { fn: (agentId: string) => void } = { fn: (_agentId: string) => {} };
112
+
106
113
  let commandContextRef: CommandContext | null = null;
107
114
  registerBuiltinPanels(services.panelManager, {
108
115
  configManager,
@@ -143,6 +150,7 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
143
150
  hookActivityTracker: services.hookActivityTracker,
144
151
  hookWorkbench: services.hookWorkbench,
145
152
  mcpRegistry: services.mcpRegistry,
153
+ openAgentDetail: (agentId: string) => openAgentDetailRef.fn(agentId),
146
154
  daemonHomeDir: join(services.homeDirectory, '.goodvibes', 'daemon'),
147
155
  });
148
156
  services.panelManager.prewarmRegistered();
@@ -278,5 +286,8 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
278
286
  lastGitInfoRef,
279
287
  inputHistory,
280
288
  systemMessageRouter,
289
+ setOpenAgentDetail: (fn: (agentId: string) => void) => {
290
+ openAgentDetailRef.fn = fn;
291
+ },
281
292
  };
282
293
  }
@@ -118,6 +118,11 @@ export type BootstrapContext = RuntimeContext & {
118
118
  * stay out of the main conversation and go to the SystemMessagesPanel instead.
119
119
  */
120
120
  systemMessageRouter: SystemMessageRouter;
121
+ /**
122
+ * Wire the agent detail modal opener after InputHandler is constructed in main.ts.
123
+ * Call with `(id) => input.agentDetailModal.open(id)` once the InputHandler is ready.
124
+ */
125
+ setOpenAgentDetail: (fn: (agentId: string) => void) => void;
121
126
  };
122
127
 
123
128
  // ── Bootstrap function ────────────────────────────────────────────────────
@@ -180,6 +185,7 @@ export async function bootstrapRuntime(
180
185
  requestRender,
181
186
  setRenderRequest,
182
187
  runtimeSessionIdRef,
188
+ wrfcPersistence,
183
189
  } = await initializeBootstrapCore(stdout, options, (limit) => controlPlaneRecentEventsRef.value(limit));
184
190
  const providerRegistry = services.providerRegistry;
185
191
  const {
@@ -285,11 +291,13 @@ export async function bootstrapRuntime(
285
291
  });
286
292
  const systemMessageRouter = shell.systemMessageRouter;
287
293
  systemMessageRouterRef.value = systemMessageRouter;
294
+ wrfcPersistence.rehydrate();
288
295
  const commandRegistry = shell.commandRegistry;
289
296
  const commandContext = shell.commandContext;
290
297
  const gitStatusProvider = shell.gitStatusProvider;
291
298
  const inputHistory = shell.inputHistory;
292
299
  const lastGitInfoRef = shell.lastGitInfoRef;
300
+ const setOpenAgentDetail = shell.setOpenAgentDetail;
293
301
  const pluginCommandRegistry = {
294
302
  register(command: {
295
303
  readonly name: string;
@@ -620,6 +628,7 @@ export async function bootstrapRuntime(
620
628
  _getConfiguredProviderIds: () => services.providerRegistry.getConfiguredProviderIds(),
621
629
  commandRegistry,
622
630
  systemMessageRouter,
631
+ setOpenAgentDetail,
623
632
  shutdown: async (sessionData) => {
624
633
  // Clear bootstrap-owned subscriptions
625
634
  bootstrapUnsubs.forEach(fn => fn());
@@ -1,5 +1,5 @@
1
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
- import { dirname } from 'node:path';
1
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs';
2
+ import { atomicWriteFileSync } from '../../config/atomic-write.ts';
3
3
  import { isSecretRefInput } from '@pellux/goodvibes-sdk/platform/config';
4
4
  import { CONFIG_SCHEMA, DEFAULT_CONFIG } from '../../config/index.ts';
5
5
  import type { FeatureFlagConfigKey } from '../surface-feature-flags.ts';
@@ -34,8 +34,7 @@ function readJsonObject(path: string): Record<string, unknown> {
34
34
  }
35
35
 
36
36
  function writeJsonObject(path: string, payload: Record<string, unknown>): void {
37
- mkdirSync(dirname(path), { recursive: true });
38
- writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
37
+ atomicWriteFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { mkdirp: true });
39
38
  }
40
39
 
41
40
  function setNestedValue(root: Record<string, unknown>, key: string, value: unknown): Record<string, unknown> {
@@ -76,8 +75,7 @@ function restoreFile(path: string, previous: string | null, reload?: () => void)
76
75
  if (previous === null) {
77
76
  if (existsSync(path)) unlinkSync(path);
78
77
  } else {
79
- mkdirSync(dirname(path), { recursive: true });
80
- writeFileSync(path, previous, 'utf-8');
78
+ atomicWriteFileSync(path, previous, { mkdirp: true });
81
79
  }
82
80
  reload?.();
83
81
  }
@@ -5,3 +5,4 @@ export * from './apply.ts';
5
5
  export * from './verify.ts';
6
6
  export * from './markers.ts';
7
7
  export * from './state.ts';
8
+ export * from './progress.ts';
@@ -1,5 +1,7 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
- import { dirname } from 'node:path';
1
+ import { existsSync } from 'node:fs';
2
+ import { atomicWriteFileSync } from '@/config/atomic-write.ts';
3
+ import { readVersioned } from '@/config/read-versioned.ts';
4
+
3
5
  import type { ShellPathService } from '@/runtime/index.ts';
4
6
  import type {
5
7
  OnboardingCheckMarkerPayload,
@@ -25,50 +27,34 @@ function resolveMarkerPath(
25
27
  : shellPaths.resolveUserPath('tui', ONBOARDING_CHECK_MARKER_FILE);
26
28
  }
27
29
 
28
- function isObject(value: unknown): value is Record<string, unknown> {
29
- return typeof value === 'object' && value !== null;
30
- }
31
-
32
- function isOnboardingMode(value: unknown): value is OnboardingCheckMarkerPayload['mode'] {
33
- return value === 'new' || value === 'edit' || value === 'reopen';
34
- }
35
-
36
30
  function isCheckMarkerPayload(value: unknown): value is OnboardingCheckMarkerPayload {
37
- return isObject(value)
38
- && value.version === 1
39
- && typeof value.checkedAt === 'number'
40
- && Number.isFinite(value.checkedAt)
41
- && typeof value.updatedAt === 'number'
42
- && Number.isFinite(value.updatedAt)
43
- && typeof value.source === 'string'
44
- && (value.mode === undefined || isOnboardingMode(value.mode))
45
- && (value.workspaceRoot === undefined || typeof value.workspaceRoot === 'string');
31
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
32
+ const v = value as Record<string, unknown>;
33
+ return (
34
+ v['version'] === 1
35
+ && typeof v['checkedAt'] === 'number'
36
+ && Number.isFinite(v['checkedAt'] as number)
37
+ && typeof v['updatedAt'] === 'number'
38
+ && Number.isFinite(v['updatedAt'] as number)
39
+ && typeof v['source'] === 'string'
40
+ && (v['mode'] === undefined || v['mode'] === 'new' || v['mode'] === 'edit' || v['mode'] === 'reopen')
41
+ && (v['workspaceRoot'] === undefined || typeof v['workspaceRoot'] === 'string')
42
+ );
46
43
  }
47
44
 
48
45
  function buildMissingMarkerState(
49
46
  scope: OnboardingStateScope,
50
47
  path: string,
51
48
  ): OnboardingCheckMarkerState {
52
- return {
53
- scope,
54
- path,
55
- exists: false,
56
- payload: null,
57
- };
49
+ return { scope, path, exists: false, payload: null };
58
50
  }
59
51
 
60
- function buildParseErrorState(
52
+ function buildErrorMarkerState(
61
53
  scope: OnboardingStateScope,
62
54
  path: string,
63
55
  parseError: string,
64
56
  ): OnboardingCheckMarkerState {
65
- return {
66
- scope,
67
- path,
68
- exists: true,
69
- payload: null,
70
- parseError,
71
- };
57
+ return { scope, path, exists: true, payload: null, parseError };
72
58
  }
73
59
 
74
60
  function pickEffectiveMarker(
@@ -91,24 +77,32 @@ export function readOnboardingCheckMarker(
91
77
  scope: OnboardingStateScope = 'user',
92
78
  ): OnboardingCheckMarkerState {
93
79
  const path = resolveMarkerPath(shellPaths, scope);
94
- if (!existsSync(path)) return buildMissingMarkerState(scope, path);
95
-
96
- try {
97
- const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
98
- if (!isCheckMarkerPayload(parsed)) {
99
- return buildParseErrorState(scope, path, 'Invalid onboarding check marker payload.');
100
- }
101
80
 
102
- return {
81
+ const parsed = readVersioned<OnboardingCheckMarkerPayload & { version: number }>(
82
+ path,
83
+ { currentVersion: 1, onUnknown: 'quarantine' },
84
+ );
85
+
86
+ if (parsed === null) {
87
+ // readVersioned returns null for: missing, corrupt JSON, or unrecognised
88
+ // version (renamed to <path>.unrecognized).
89
+ const nowExists = existsSync(path);
90
+ const quarantined = existsSync(`${path}.unrecognized`);
91
+ if (!nowExists && !quarantined) return buildMissingMarkerState(scope, path);
92
+ return buildErrorMarkerState(
103
93
  scope,
104
94
  path,
105
- exists: true,
106
- payload: parsed,
107
- };
108
- } catch (error) {
109
- const parseError = error instanceof Error ? error.message : String(error);
110
- return buildParseErrorState(scope, path, parseError);
95
+ quarantined
96
+ ? 'Unrecognised or corrupt marker file; quarantined.'
97
+ : 'Invalid onboarding check marker payload.',
98
+ );
99
+ }
100
+
101
+ if (!isCheckMarkerPayload(parsed)) {
102
+ return buildErrorMarkerState(scope, path, 'Invalid onboarding check marker payload.');
111
103
  }
104
+
105
+ return { scope, path, exists: true, payload: parsed };
112
106
  }
113
107
 
114
108
  export function readOnboardingCheckMarkers(
@@ -140,8 +134,7 @@ export function writeOnboardingCheckMarker(
140
134
  ...(options.workspaceRoot ? { workspaceRoot: options.workspaceRoot } : {}),
141
135
  };
142
136
 
143
- mkdirSync(dirname(path), { recursive: true });
144
- writeFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
137
+ atomicWriteFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { mkdirp: true });
145
138
 
146
139
  return readOnboardingCheckMarker(shellPaths, scope);
147
140
  }
@@ -0,0 +1,148 @@
1
+ import { existsSync, unlinkSync } from 'node:fs';
2
+ import { atomicWriteFileSync } from '@/config/atomic-write.ts';
3
+ import { readVersioned } from '@/config/read-versioned.ts';
4
+
5
+ import type {
6
+ OnboardingMode,
7
+ OnboardingShellPaths,
8
+ WizardProgressPayload,
9
+ WizardProgressState,
10
+ } from './types.ts';
11
+
12
+ const WIZARD_PROGRESS_FILE = 'onboarding-progress.json';
13
+
14
+ function resolveProgressPath(shellPaths: OnboardingShellPaths): string {
15
+ return shellPaths.resolveUserPath('tui', WIZARD_PROGRESS_FILE);
16
+ }
17
+
18
+ function isWizardProgressPayload(value: unknown): value is WizardProgressPayload {
19
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
20
+ const v = value as Record<string, unknown>;
21
+ if (v['version'] !== 1) return false;
22
+ if (typeof v['savedAt'] !== 'number' || !Number.isFinite(v['savedAt'] as number)) return false;
23
+ const mode = v['mode'];
24
+ if (mode !== 'new' && mode !== 'edit' && mode !== 'reopen') return false;
25
+ if (typeof v['stepIndex'] !== 'number' || !Number.isFinite(v['stepIndex'] as number)) return false;
26
+ if (!Array.isArray(v['toggleState'])) return false;
27
+ if (!Array.isArray(v['radioState'])) return false;
28
+ if (!Array.isArray(v['textState'])) return false;
29
+ return true;
30
+ }
31
+
32
+ /**
33
+ * Path to the wizard progress file (user-scoped, in ~/.config/goodvibes/tui/).
34
+ */
35
+ export function getWizardProgressPath(shellPaths: OnboardingShellPaths): string {
36
+ return resolveProgressPath(shellPaths);
37
+ }
38
+
39
+ /**
40
+ * Read the persisted wizard progress, if any.
41
+ *
42
+ * Returns a WizardProgressState with exists=false when no progress file is
43
+ * present. Returns exists=true, payload=null with a parseError when the file
44
+ * is present but unreadable or schema-mismatched (the bad file is left in
45
+ * place so the caller can decide whether to delete it).
46
+ */
47
+ export function readWizardProgress(shellPaths: OnboardingShellPaths): WizardProgressState {
48
+ const path = resolveProgressPath(shellPaths);
49
+
50
+ const parsed = readVersioned<WizardProgressPayload & { version: number }>(
51
+ path,
52
+ { currentVersion: 1, onUnknown: 'quarantine' },
53
+ );
54
+
55
+ if (parsed === null) {
56
+ const nowExists = existsSync(path);
57
+ const quarantined = existsSync(`${path}.unrecognized`);
58
+ if (!nowExists && !quarantined) return { path, exists: false, payload: null };
59
+ return {
60
+ path,
61
+ exists: true,
62
+ payload: null,
63
+ parseError: quarantined
64
+ ? 'Unrecognised or corrupt wizard progress file; quarantined.'
65
+ : 'Invalid wizard progress payload.',
66
+ };
67
+ }
68
+
69
+ if (!isWizardProgressPayload(parsed)) {
70
+ return { path, exists: true, payload: null, parseError: 'Invalid wizard progress payload.' };
71
+ }
72
+
73
+ return { path, exists: true, payload: parsed };
74
+ }
75
+
76
+ export interface WriteWizardProgressOptions {
77
+ readonly mode: OnboardingMode;
78
+ readonly stepIndex: number;
79
+ readonly toggleState: ReadonlyArray<readonly [string, boolean]>;
80
+ readonly radioState: ReadonlyArray<readonly [string, string]>;
81
+ readonly textState: ReadonlyArray<readonly [string, string]>;
82
+ readonly clock?: () => number;
83
+ }
84
+
85
+ /**
86
+ * Atomically persist wizard progress to disk.
87
+ *
88
+ * Uses atomicWriteFileSync (write-to-tmp + rename) so a crash mid-write
89
+ * never leaves a torn file. The file is user-scoped so it survives across
90
+ * project switches and is shared with the resume-prompt check at startup.
91
+ *
92
+ * Masked (password) fields are deliberately excluded from the serialised
93
+ * textState by the caller — this function accepts whatever is passed in and
94
+ * does NOT filter. Callers must strip sensitive fields before calling.
95
+ */
96
+ export function writeWizardProgress(
97
+ shellPaths: OnboardingShellPaths,
98
+ options: WriteWizardProgressOptions,
99
+ ): void {
100
+ const path = resolveProgressPath(shellPaths);
101
+ const payload: WizardProgressPayload = {
102
+ version: 1,
103
+ savedAt: (options.clock ?? Date.now)(),
104
+ mode: options.mode,
105
+ stepIndex: options.stepIndex,
106
+ toggleState: options.toggleState,
107
+ radioState: options.radioState,
108
+ textState: options.textState,
109
+ };
110
+ atomicWriteFileSync(path, `${JSON.stringify(payload, null, 2)}\n`, { mkdirp: true });
111
+ }
112
+
113
+ /**
114
+ * Delete the wizard progress file (best-effort; ignores ENOENT).
115
+ *
116
+ * Called after a successful apply so the resume prompt is not shown on
117
+ * next startup.
118
+ */
119
+ export function deleteWizardProgress(shellPaths: OnboardingShellPaths): void {
120
+ const path = resolveProgressPath(shellPaths);
121
+ try { unlinkSync(path); } catch { /* best-effort: file may not exist */ }
122
+ }
123
+
124
+ /**
125
+ * Returns true when an in-progress wizard session was interrupted and is
126
+ * still recent enough to reopen on startup (the caller reopens the wizard
127
+ * at the saved step so the user can continue or dismiss it).
128
+ *
129
+ * A progress file is considered resumable when:
130
+ * - it exists and can be parsed (payload !== null)
131
+ * - it is less than PROGRESS_MAX_AGE_MS old (default: 7 days)
132
+ *
133
+ * Negative age (future-dated `savedAt`) is treated as non-resumable: it
134
+ * indicates clock skew or a tampered file and is safer to reject than to
135
+ * open a wizard with unknown-age state.
136
+ */
137
+ const PROGRESS_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
138
+
139
+ export function hasResumableWizardProgress(
140
+ shellPaths: OnboardingShellPaths,
141
+ options: { now?: number; state?: WizardProgressState } = {},
142
+ ): boolean {
143
+ const state = options.state ?? readWizardProgress(shellPaths);
144
+ if (!state.payload) return false;
145
+ const age = (options.now ?? Date.now()) - state.payload.savedAt;
146
+ // age < 0 means savedAt is in the future (clock skew / tampered file) — treat as non-resumable.
147
+ return age >= 0 && age < PROGRESS_MAX_AGE_MS;
148
+ }