@pellux/goodvibes-tui 0.25.0 → 0.27.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 (146) hide show
  1. package/CHANGELOG.md +22 -3
  2. package/README.md +12 -7
  3. package/docs/foundation-artifacts/operator-contract.json +2422 -1040
  4. package/package.json +2 -2
  5. package/src/cli/bundle-command.ts +2 -1
  6. package/src/cli/service-posture.ts +2 -1
  7. package/src/core/conversation-rendering.ts +2 -2
  8. package/src/core/conversation.ts +49 -2
  9. package/src/core/session-recovery.ts +24 -18
  10. package/src/core/system-message-router.ts +42 -26
  11. package/src/core/turn-event-wiring.ts +12 -16
  12. package/src/daemon/{calendar → handlers/calendar}/caldav-client.ts +29 -24
  13. package/src/daemon/handlers/calendar/index.ts +316 -0
  14. package/src/daemon/handlers/context.ts +29 -0
  15. package/src/daemon/handlers/contracts.ts +77 -0
  16. package/src/daemon/{channels → handlers}/drafts/draft-store.ts +114 -50
  17. package/src/daemon/handlers/drafts/index.ts +17 -0
  18. package/src/daemon/handlers/drafts/register.ts +331 -0
  19. package/src/daemon/handlers/email/config.ts +164 -0
  20. package/src/daemon/handlers/email/index.ts +43 -0
  21. package/src/daemon/handlers/email/read-handlers.ts +80 -0
  22. package/src/daemon/handlers/email/runtime.ts +140 -0
  23. package/src/daemon/handlers/email/validation.ts +147 -0
  24. package/src/daemon/handlers/email/write-handlers.ts +133 -0
  25. package/src/daemon/handlers/errors.ts +18 -0
  26. package/src/daemon/{channels → handlers}/inbox/cursor-store.ts +58 -66
  27. package/src/daemon/handlers/inbox/index.ts +211 -0
  28. package/src/daemon/{channels → handlers}/inbox/mapping.ts +8 -6
  29. package/src/daemon/{channels → handlers}/inbox/poller.ts +6 -5
  30. package/src/daemon/{channels → handlers}/inbox/provider-adapter.ts +14 -10
  31. package/src/daemon/{channels → handlers}/inbox/providers/discord.ts +10 -11
  32. package/src/daemon/{channels → handlers}/inbox/providers/email.ts +2 -1
  33. package/src/daemon/{channels → handlers}/inbox/providers/imap-client.ts +1 -1
  34. package/src/daemon/{channels → handlers}/inbox/providers/route-util.ts +4 -3
  35. package/src/daemon/{channels → handlers}/inbox/providers/slack.ts +11 -12
  36. package/src/daemon/handlers/index.ts +107 -0
  37. package/src/daemon/handlers/register.ts +161 -0
  38. package/src/daemon/{remote → handlers/remote}/backends/cloud-terminal.ts +8 -3
  39. package/src/daemon/{remote → handlers/remote}/backends/docker.ts +2 -3
  40. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  41. package/src/daemon/{remote → handlers/remote}/backends/process-runner.ts +16 -40
  42. package/src/daemon/{remote → handlers/remote}/backends/ssh.ts +8 -3
  43. package/src/daemon/{remote → handlers/remote}/backends/types.ts +29 -3
  44. package/src/daemon/{remote → handlers/remote}/dispatcher.ts +27 -6
  45. package/src/daemon/handlers/remote/index.ts +119 -0
  46. package/src/daemon/{remote → handlers/remote}/peer-registry.ts +47 -11
  47. package/src/daemon/handlers/remote/service.ts +191 -0
  48. package/src/daemon/{channels → handlers}/routing/inbox-bridge.ts +33 -20
  49. package/src/daemon/handlers/routing/index.ts +261 -0
  50. package/src/daemon/{channels → handlers}/routing/route-store.ts +57 -16
  51. package/src/daemon/{channels → handlers}/routing/routing-resolver.ts +1 -1
  52. package/src/daemon/{operator → handlers}/sqlite-store.ts +5 -5
  53. package/src/daemon/handlers/triage/index.ts +57 -0
  54. package/src/daemon/handlers/triage/integration.ts +213 -0
  55. package/src/daemon/{triage → handlers/triage}/pipeline.ts +60 -71
  56. package/src/daemon/{triage → handlers/triage}/scorer.ts +21 -21
  57. package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
  58. package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
  59. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  60. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  61. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  62. package/src/daemon/handlers/triage/types.ts +50 -0
  63. package/src/export/gist-uploader.ts +3 -1
  64. package/src/input/command-registry.ts +1 -0
  65. package/src/input/commands/cloudflare-runtime.ts +2 -1
  66. package/src/input/commands/guidance-runtime.ts +2 -4
  67. package/src/input/commands/health-runtime.ts +13 -7
  68. package/src/input/commands/knowledge.ts +2 -1
  69. package/src/input/commands/runtime-services.ts +40 -10
  70. package/src/input/handler-command-route.ts +1 -1
  71. package/src/input/handler-interactions.ts +2 -1
  72. package/src/input/handler-modal-routes.ts +2 -1
  73. package/src/input/handler-onboarding-cloudflare.ts +2 -1
  74. package/src/input/handler-onboarding.ts +8 -8
  75. package/src/input/handler-ui-state.ts +1 -1
  76. package/src/input/tts-settings-actions.ts +2 -1
  77. package/src/main.ts +52 -59
  78. package/src/panels/agent-inspector-panel.ts +72 -2
  79. package/src/panels/agent-logs-panel.ts +127 -19
  80. package/src/panels/base-panel.ts +2 -1
  81. package/src/panels/builtin/agent.ts +3 -1
  82. package/src/panels/builtin/development.ts +1 -0
  83. package/src/panels/builtin/session.ts +1 -1
  84. package/src/panels/context-visualizer-panel.ts +24 -14
  85. package/src/panels/cost-tracker-panel.ts +7 -13
  86. package/src/panels/debug-panel.ts +11 -22
  87. package/src/panels/docs-panel.ts +14 -24
  88. package/src/panels/file-explorer-panel.ts +2 -1
  89. package/src/panels/file-preview-panel.ts +2 -1
  90. package/src/panels/git-panel.ts +10 -17
  91. package/src/panels/marketplace-panel.ts +2 -1
  92. package/src/panels/memory-panel.ts +2 -1
  93. package/src/panels/project-planning-panel.ts +5 -4
  94. package/src/panels/provider-health-panel.ts +56 -67
  95. package/src/panels/schedule-panel.ts +4 -7
  96. package/src/panels/session-browser-panel.ts +13 -21
  97. package/src/panels/skills-panel.ts +2 -1
  98. package/src/panels/tasks-panel.ts +2 -7
  99. package/src/panels/thinking-panel.ts +6 -11
  100. package/src/panels/token-budget-panel.ts +31 -15
  101. package/src/panels/tool-inspector-panel.ts +10 -18
  102. package/src/panels/work-plan-panel.ts +2 -1
  103. package/src/panels/wrfc-panel.ts +37 -35
  104. package/src/renderer/agent-detail-modal.ts +2 -2
  105. package/src/renderer/modal-utils.ts +0 -10
  106. package/src/renderer/process-modal.ts +2 -2
  107. package/src/runtime/bootstrap-command-context.ts +3 -0
  108. package/src/runtime/bootstrap-command-parts.ts +3 -1
  109. package/src/runtime/bootstrap-core.ts +31 -31
  110. package/src/runtime/bootstrap-shell.ts +1 -0
  111. package/src/runtime/onboarding/apply.ts +4 -3
  112. package/src/runtime/onboarding/snapshot.ts +4 -3
  113. package/src/runtime/process-lifecycle.ts +195 -0
  114. package/src/runtime/services.ts +52 -36
  115. package/src/runtime/wrfc-persistence.ts +20 -5
  116. package/src/shell/ui-openers.ts +3 -2
  117. package/src/verification/live-verifier.ts +4 -3
  118. package/src/version.ts +1 -1
  119. package/src/core/context-auto-compact.ts +0 -110
  120. package/src/daemon/calendar/index.ts +0 -52
  121. package/src/daemon/calendar/register.ts +0 -527
  122. package/src/daemon/channels/drafts/index.ts +0 -22
  123. package/src/daemon/channels/drafts/register.ts +0 -449
  124. package/src/daemon/channels/inbox/index.ts +0 -58
  125. package/src/daemon/channels/inbox/register.ts +0 -247
  126. package/src/daemon/channels/routing/index.ts +0 -39
  127. package/src/daemon/channels/routing/register.ts +0 -296
  128. package/src/daemon/email/index.ts +0 -68
  129. package/src/daemon/email/register.ts +0 -715
  130. package/src/daemon/operator/index.ts +0 -43
  131. package/src/daemon/operator/register-helper.ts +0 -150
  132. package/src/daemon/operator/surfaces.ts +0 -137
  133. package/src/daemon/operator/types.ts +0 -207
  134. package/src/daemon/remote/backends/index.ts +0 -34
  135. package/src/daemon/remote/index.ts +0 -74
  136. package/src/daemon/remote/register.ts +0 -411
  137. package/src/daemon/triage/index.ts +0 -59
  138. package/src/daemon/triage/integration.ts +0 -179
  139. package/src/daemon/triage/register.ts +0 -231
  140. package/src/daemon/triage/tagger.ts +0 -777
  141. /package/src/daemon/{calendar → handlers/calendar}/ics.ts +0 -0
  142. /package/src/daemon/{operator/credential-store.ts → handlers/credentials.ts} +0 -0
  143. /package/src/daemon/{email → handlers/email}/imap-connector.ts +0 -0
  144. /package/src/daemon/{email → handlers/email}/imap-parsing.ts +0 -0
  145. /package/src/daemon/{email → handlers/email}/smtp-connector.ts +0 -0
  146. /package/src/daemon/{remote → handlers/remote}/backends/local-process.ts +0 -0
@@ -10,6 +10,7 @@ import {
10
10
  buildPanelWorkspace,
11
11
  resolveScrollablePanelSection,
12
12
  DEFAULT_PANEL_PALETTE,
13
+ extendPalette,
13
14
  type PanelWorkspaceSection,
14
15
  buildSelectablePanelLine,
15
16
  buildStyledPanelLine,
@@ -20,6 +21,7 @@ import {
20
21
  type ConfirmState,
21
22
  handleConfirmInput,
22
23
  } from './confirm-state.ts';
24
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
23
25
 
24
26
  // ---------------------------------------------------------------------------
25
27
  // Constants
@@ -31,14 +33,22 @@ const STALL_THRESHOLD_MS = 5 * 60 * 1000;
31
33
  /** Terminal states — chains in these states cannot be resumed or cancelled. */
32
34
  const TERMINAL_STATES: readonly WrfcState[] = ['passed', 'failed'];
33
35
 
34
- /** States from which resume is permitted (pending = chain is awaiting retry). */
35
- const RESUMABLE_STATES: readonly WrfcState[] = ['pending'];
36
+ /**
37
+ * States from which resume is permitted. These mirror the states
38
+ * WrfcController.resumeChain() actually recovers from: 'pending' (awaiting
39
+ * retry / start engineering), 'reviewing' and 'fixing' (re-spawn the
40
+ * interrupted child), and 'awaiting_gates' (re-run gates). resumeChain no-ops
41
+ * safely when a child is still running, so offering resume for these states is
42
+ * always safe — it is exactly the set an operator needs after a restart or a
43
+ * STALLED chain.
44
+ */
45
+ const RESUMABLE_STATES: readonly WrfcState[] = ['pending', 'reviewing', 'fixing', 'awaiting_gates'];
36
46
 
37
47
  // ---------------------------------------------------------------------------
38
48
  // Colour palette
39
49
  // ---------------------------------------------------------------------------
40
- const C = {
41
- // states
50
+ const C = extendPalette(DEFAULT_PANEL_PALETTE, {
51
+ // WRFC state-machine colours (domain status -- no shared equivalent)
42
52
  passed: '#22c55e', // green
43
53
  failed: '#ef4444', // red
44
54
  reviewing: '#eab308', // yellow
@@ -47,31 +57,19 @@ const C = {
47
57
  pending: '#6b7280', // grey
48
58
  gating: '#a78bfa', // violet
49
59
  committing: '#38bdf8', // sky
60
+ integrating:'#818cf8', // indigo
50
61
 
51
- // UI chrome
52
- header: '#94a3b8',
53
- headerBold: '#e2e8f0',
54
- dim: '#4b5563',
55
- label: '#64748b',
56
- value: '#cbd5e1',
57
- selected: '#1e40af', // selection bg
58
- selectedFg: '#f8fafc',
59
- border: '#334155',
60
- spark: '#38bdf8',
61
- sparkLow: '#ef4444',
62
- sparkHigh: '#22c55e',
62
+ // Issue-severity ramp (domain -- no shared equivalent)
63
63
  issueCrit: '#ef4444',
64
64
  issueMaj: '#f97316',
65
65
  issueMin: '#eab308',
66
66
  issueSug: '#6b7280',
67
- gatePass: '#22c55e',
68
- gateFail: '#ef4444',
69
- // constraint status
70
- constraintSat: '#22c55e', // green — satisfied
71
- constraintUnsat:'#ef4444', // red — unsatisfied
72
- constraintUnv: '#4b5563', // grey — unverified (no finding yet)
73
- stalled: '#f59e0b', // amber — stalled / no recent event
74
- } as const;
67
+
68
+ // Selection + divider chrome with no shared equivalent
69
+ selected: '#1e40af', // selection bg
70
+ selectedFg: '#f8fafc',
71
+ border: '#334155',
72
+ });
75
73
 
76
74
  // ---------------------------------------------------------------------------
77
75
  // Helpers
@@ -99,6 +97,7 @@ export function stateColor(state: WrfcState): string {
99
97
  case 'gating':
100
98
  case 'awaiting_gates': return C.gating;
101
99
  case 'committing': return C.committing;
100
+ case 'integrating': return C.integrating;
102
101
  default: return C.pending;
103
102
  }
104
103
  }
@@ -111,6 +110,7 @@ export function stateLabel(state: WrfcState): string {
111
110
  case 'gating': return 'GATE';
112
111
  case 'awaiting_gates': return 'WAIT';
113
112
  case 'committing': return 'COMMIT';
113
+ case 'integrating': return 'INTG';
114
114
  case 'passed': return 'PASS';
115
115
  case 'failed': return 'FAIL';
116
116
  default: return 'PEND';
@@ -154,10 +154,10 @@ export function constraintStatusMarker(
154
154
  ): { tag: string; fg: string; dim: boolean } {
155
155
  const finding = findings?.find(f => f.constraintId === constraint.id);
156
156
  if (!finding) {
157
- return { tag: '[UNV]', fg: C.constraintUnv, dim: true };
157
+ return { tag: '[UNV]', fg: C.dim, dim: true };
158
158
  }
159
159
  if (finding.satisfied) {
160
- return { tag: '[SAT]', fg: C.constraintSat, dim: false };
160
+ return { tag: '[SAT]', fg: C.good, dim: false };
161
161
  }
162
162
  // Unsatisfied — use severity to pick colour and tag text
163
163
  const sev = finding.severity ?? 'major';
@@ -334,7 +334,7 @@ export class WrfcPanel extends BasePanel {
334
334
  const satisfied = findings ? findings.filter(f => f.satisfied).length : 0;
335
335
  const satFg = !findings || findings.length === 0
336
336
  ? DEFAULT_PANEL_PALETTE.dim
337
- : satisfied === total ? C.constraintSat : C.constraintUnsat;
337
+ : satisfied === total ? C.good : C.bad;
338
338
  return [
339
339
  [' Constraints ', DEFAULT_PANEL_PALETTE.label],
340
340
  [`${satisfied} sat / ${total} total`, satFg],
@@ -450,7 +450,7 @@ export class WrfcPanel extends BasePanel {
450
450
  fg: string,
451
451
  stalled = false,
452
452
  ): Line[] {
453
- const stateCol = stalled ? C.stalled : stateColor(chain.state);
453
+ const stateCol = stalled ? C.warn : stateColor(chain.state);
454
454
  const stateTag = stalled
455
455
  ? ` STALLED`
456
456
  : ` ${stateLabel(chain.state).padEnd(6)}`;
@@ -483,7 +483,7 @@ export class WrfcPanel extends BasePanel {
483
483
  const remaining = width - usedWidth;
484
484
 
485
485
  const segments = [
486
- { text: prefix, fg: isSelected ? fg : C.header },
486
+ { text: prefix, fg: isSelected ? fg : C.label },
487
487
  { text: stateTag, fg: stateCol, bold: true },
488
488
  { text: ' ', fg: '' },
489
489
  { text: taskText, fg: isSelected ? fg : C.value },
@@ -498,11 +498,11 @@ export class WrfcPanel extends BasePanel {
498
498
  // Determine badge colour
499
499
  let badgeFg: string;
500
500
  if (!findings || findings.length === 0) {
501
- badgeFg = C.constraintUnv;
501
+ badgeFg = C.dim;
502
502
  } else if (satisfied === total) {
503
- badgeFg = C.constraintSat;
503
+ badgeFg = C.good;
504
504
  } else if (findings.some(f => !f.satisfied)) {
505
- badgeFg = C.constraintUnsat;
505
+ badgeFg = C.bad;
506
506
  } else {
507
507
  badgeFg = C.reviewing; // some unverified but none failed
508
508
  }
@@ -536,7 +536,7 @@ export class WrfcPanel extends BasePanel {
536
536
  if (chain.reviewScores.length > 0) {
537
537
  const spark = sparkline(chain.reviewScores);
538
538
  const latestScore = chain.reviewScores[chain.reviewScores.length - 1];
539
- const sparkColor = latestScore >= 8 ? C.sparkHigh : latestScore >= 5 ? C.spark : C.sparkLow;
539
+ const sparkColor = latestScore >= 8 ? C.good : latestScore >= 5 ? C.info : C.bad;
540
540
  lines.push(buildStyledPanelLine(width, [
541
541
  { text: `${indent}Scores `, fg: C.label },
542
542
  { text: spark, fg: sparkColor },
@@ -648,7 +648,7 @@ export class WrfcPanel extends BasePanel {
648
648
 
649
649
  private renderGateResult(gate: QualityGateResult, width: number, indent: string): Line {
650
650
  const icon = gate.passed ? '✓' : '✕';
651
- const iconFg = gate.passed ? C.gatePass : C.gateFail;
651
+ const iconFg = gate.passed ? C.good : C.bad;
652
652
  const dur = `${gate.durationMs}ms`;
653
653
  const nameMax = width - indent.length - 2 - dur.length - 4;
654
654
  const name = truncate(gate.gate, Math.max(8, nameMax));
@@ -740,7 +740,7 @@ export class WrfcPanel extends BasePanel {
740
740
  if (hadChains) {
741
741
  // Post-init error: the controller was working and now throws. Surface
742
742
  // a faint diagnostic rather than silently preserving stale state.
743
- const msg = err instanceof Error ? err.message : String(err);
743
+ const msg = summarizeError(err);
744
744
  this.controllerError = msg;
745
745
  console.debug('[WrfcPanel] controller.listChains() error post-init:', msg);
746
746
  }
@@ -773,6 +773,8 @@ export class WrfcPanel extends BasePanel {
773
773
  if (!chain) return;
774
774
  if (!RESUMABLE_STATES.includes(chain.state)) return;
775
775
  this.deps.controller.resumeChain(chain.id);
776
+ // Re-sync so the rows/footer reflect the state resumeChain transitioned to.
777
+ this.syncFromController();
776
778
  this.markDirty();
777
779
  }
778
780
 
@@ -4,7 +4,7 @@ import { ModalFactory } from './modal-factory.ts';
4
4
  import type { AgentManager } from '@pellux/goodvibes-sdk/platform/tools';
5
5
  import type { AgentMessageBus } from '@pellux/goodvibes-sdk/platform/agents';
6
6
  import type { WrfcController } from '@pellux/goodvibes-sdk/platform/agents';
7
- import { formatDuration } from './modal-utils.ts';
7
+ import { formatElapsed } from '../utils/format-elapsed.ts';
8
8
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
9
9
  import { getOverlaySurfaceMetrics, getStableOverlayContentRows } from './overlay-viewport.ts';
10
10
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
@@ -249,7 +249,7 @@ export function renderAgentDetailModal(
249
249
  sections.push({ type: 'text', content: `Model : ${modelStr}` });
250
250
  const isStalled = !MODAL_TERMINAL_STATUSES.has(rec.status) && (now - rec.startedAt) >= MODAL_STALL_THRESHOLD_MS;
251
251
  sections.push({ type: 'text', content: `Status : ${rec.status}${isStalled ? ' [STALLED — 5+ min no activity]' : ''}` });
252
- sections.push({ type: 'text', content: `Duration : ${formatDuration(elapsedMs)}` });
252
+ sections.push({ type: 'text', content: `Duration : ${formatElapsed(elapsedMs)}` });
253
253
  sections.push({ type: 'separator' });
254
254
 
255
255
  // Metrics
@@ -2,16 +2,6 @@
2
2
  * Shared rendering utilities for modal components.
3
3
  */
4
4
 
5
- /** Format elapsed milliseconds as a compact duration string. */
6
- export function formatDuration(ms: number): string {
7
- if (ms < 1000) return `${ms}ms`;
8
- const secs = Math.floor(ms / 1000);
9
- if (secs < 60) return `${secs}s`;
10
- const mins = Math.floor(secs / 60);
11
- const remSecs = secs % 60;
12
- return `${mins}m${remSecs}s`;
13
- }
14
-
15
5
  /**
16
6
  * Format a Unix millisecond timestamp as YYYY-MM-DD HH:MM.
17
7
  * Returns '(unknown)' for falsy timestamps.
@@ -1,6 +1,6 @@
1
1
  import { type Line } from '../types/grid.ts';
2
2
  import { ModalFactory } from './modal-factory.ts';
3
- import { formatDuration } from './modal-utils.ts';
3
+ import { formatElapsed } from '../utils/format-elapsed.ts';
4
4
  import type { ProcessManager } from '@pellux/goodvibes-sdk/platform/tools';
5
5
  import type { AgentManager, AgentRecord } from '@pellux/goodvibes-sdk/platform/tools';
6
6
  import type { WrfcController } from '@pellux/goodvibes-sdk/platform/agents';
@@ -623,7 +623,7 @@ export function renderProcessModal(modal: ProcessModal, width: number, viewportH
623
623
  cancelled: '–',
624
624
  }[e.status] ?? '•';
625
625
  const typeTag = e.type === 'agent' ? '[agent]' : '[exec]';
626
- const dur = formatDuration(e.elapsedMs);
626
+ const dur = formatElapsed(e.elapsedMs);
627
627
  const statusStr = e.streamSnippet ? `streaming ${dur}` : `${e.status} ${dur}`;
628
628
  const suffix = ` ${statusStr}`;
629
629
  const treePrefix = e.treePrefix ?? '';
@@ -112,6 +112,7 @@ export type CreateBootstrapCommandContextOptions = {
112
112
  activatePlan: (planId: string, task: string) => void;
113
113
  completeModelSelectionSideEffect?: () => void;
114
114
  sessionLineageTracker?: import('@pellux/goodvibes-sdk/platform/core').SessionLineageTracker;
115
+ wrfcController?: import('@pellux/goodvibes-sdk/platform/agents').WrfcController;
115
116
  componentHealthMonitor: import('@/runtime/index.ts').ComponentHealthMonitor;
116
117
  };
117
118
 
@@ -164,6 +165,7 @@ export function createBootstrapCommandContext(
164
165
  webhookNotifier,
165
166
  sessionMemoryStore,
166
167
  sessionLineageTracker,
168
+ wrfcController,
167
169
  changeTracker,
168
170
  planManager,
169
171
  adaptivePlanner,
@@ -219,6 +221,7 @@ export function createBootstrapCommandContext(
219
221
  sessionManager,
220
222
  sessionMemoryStore,
221
223
  sessionLineageTracker,
224
+ wrfcController,
222
225
  changeTracker,
223
226
  });
224
227
  const provider = createBootstrapCommandProviderSection({
@@ -107,6 +107,7 @@ export interface BootstrapCommandSectionOptions {
107
107
  readonly webhookNotifier?: import('@pellux/goodvibes-sdk/platform/integrations').WebhookNotifier;
108
108
  readonly sessionMemoryStore?: import('@pellux/goodvibes-sdk/platform/core').SessionMemoryStore;
109
109
  readonly sessionLineageTracker?: import('@pellux/goodvibes-sdk/platform/core').SessionLineageTracker;
110
+ readonly wrfcController?: import('@pellux/goodvibes-sdk/platform/agents').WrfcController;
110
111
  readonly changeTracker?: import('@pellux/goodvibes-sdk/platform/sessions').SessionChangeTracker;
111
112
  readonly agentManager?: ShellAgentManagerService;
112
113
  readonly modeManager?: ShellModeManagerService;
@@ -299,7 +300,7 @@ export function createBootstrapCommandActions(
299
300
  export function createBootstrapCommandSessionSection(
300
301
  options: Pick<
301
302
  BootstrapCommandSectionOptions,
302
- 'conversation' | 'runtime' | 'sessionManager' | 'sessionMemoryStore' | 'sessionLineageTracker' | 'changeTracker'
303
+ 'conversation' | 'runtime' | 'sessionManager' | 'sessionMemoryStore' | 'sessionLineageTracker' | 'wrfcController' | 'changeTracker'
303
304
  >,
304
305
  ): BootstrapCommandSessionSection {
305
306
  return {
@@ -308,6 +309,7 @@ export function createBootstrapCommandSessionSection(
308
309
  sessionManager: options.sessionManager,
309
310
  sessionMemoryStore: options.sessionMemoryStore,
310
311
  sessionLineageTracker: options.sessionLineageTracker,
312
+ wrfcController: options.wrfcController,
311
313
  changeTracker: options.changeTracker,
312
314
  };
313
315
  }
@@ -399,50 +399,50 @@ export async function initializeBootstrapCore(
399
399
  });
400
400
 
401
401
  const renderRequestRef = { value: (): void => {} };
402
- // R1: Coalescing render scheduler — collapses N same-microtask requestRender() calls into 1.
403
- // Also enforces a 16ms minimum interval to cap at ~60fps during streaming.
402
+ // R1: Coalescing render scheduler — collapses N requestRender() calls into 1
403
+ // and enforces a 16ms minimum interval to cap repaints at ~60fps.
404
+ //
405
+ // renderScheduled stays set for the ENTIRE window (until run() executes), so
406
+ // requestRender() calls that arrive on later event-loop ticks within the same
407
+ // 16ms window are coalesced into the one already-pending tail render instead
408
+ // of each queuing their own setTimeout. (The streaming hot path drives its own
409
+ // direct repaints and does not flow through this scheduler.)
404
410
  let renderScheduled = false;
405
411
  let lastRenderTime = 0;
406
412
  const RENDER_INTERVAL_MS = 16;
413
+ // run() performs the actual render. It clears renderScheduled FIRST — even if
414
+ // the render callback throws — otherwise a single render exception would wedge
415
+ // the entire TUI (no future requestRender() call would schedule anything). The
416
+ // renderer is expected to surface failures via its own error path; we log at
417
+ // error so the next requestRender() can still reschedule.
418
+ const run = (): void => {
419
+ renderScheduled = false;
420
+ lastRenderTime = Date.now();
421
+ try {
422
+ renderRequestRef.value();
423
+ } catch (err) {
424
+ logger.error('Render threw; next requestRender will reschedule', { error: String(err) });
425
+ }
426
+ };
407
427
  const requestRender = (): void => {
408
428
  if (renderScheduled) return;
409
429
  renderScheduled = true;
410
430
  setImmediate(() => {
411
- // Error Handling: the scheduler flag MUST be cleared even if the render
412
- // callback throws; otherwise a single render exception would wedge the
413
- // entire TUI (no future requestRender() call would schedule anything).
414
- renderScheduled = false;
415
- const now = Date.now();
416
- const elapsed = now - lastRenderTime;
417
- try {
418
- if (elapsed < RENDER_INTERVAL_MS) {
419
- // Too soon — debounce to the tail of the current 16ms window
420
- const delay = RENDER_INTERVAL_MS - elapsed;
421
- setTimeout(() => {
422
- try {
423
- lastRenderTime = Date.now();
424
- renderRequestRef.value();
425
- } catch (err) {
426
- // Throttled-render error: swallow but log at error so the next
427
- // requestRender() call can still schedule. The renderer itself
428
- // is expected to surface failures via its own error path.
429
- logger.error('Throttled render threw; next requestRender will reschedule', { error: String(err) });
430
- }
431
- }, delay);
432
- } else {
433
- lastRenderTime = now;
434
- renderRequestRef.value();
435
- }
436
- } catch (err) {
437
- logger.error('Immediate render threw; next requestRender will reschedule', { error: String(err) });
431
+ const elapsed = Date.now() - lastRenderTime;
432
+ if (elapsed < RENDER_INTERVAL_MS) {
433
+ // Too soon debounce to the tail of the current 16ms window. The flag
434
+ // stays set until run() fires, so window-local requests coalesce here.
435
+ setTimeout(run, RENDER_INTERVAL_MS - elapsed);
436
+ } else {
437
+ run();
438
438
  }
439
439
  });
440
440
  };
441
441
  const permissionPromptRef = {
442
442
  requestPermission: (async () => ({ approved: false, remember: false })) as PermissionRequestHandler,
443
443
  };
444
- void approvalBroker.start();
445
- void sharedSessionBroker.start();
444
+ approvalBroker.start().catch((err) => logger.warn('approval broker start failed at bootstrap', { err }));
445
+ sharedSessionBroker.start().catch((err) => logger.warn('shared session broker start failed at bootstrap', { err }));
446
446
  const runtimeSessionIdRef = { value: userSessionId };
447
447
  const wrfcBuffer = new WrfcPreRouterBuffer();
448
448
  // Smart ref: setting .value auto-flushes the pre-router buffer so events
@@ -240,6 +240,7 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
240
240
  webhookNotifier: services.webhookNotifier,
241
241
  sessionMemoryStore: services.sessionMemoryStore,
242
242
  sessionLineageTracker: services.sessionLineageTracker,
243
+ wrfcController: services.wrfcController,
243
244
  changeTracker: services.sessionChangeTracker,
244
245
  planManager: services.planManager,
245
246
  adaptivePlanner: services.adaptivePlanner,
@@ -17,6 +17,7 @@ import type {
17
17
  OnboardingApplyRequest,
18
18
  OnboardingApplyResult,
19
19
  } from './types.ts';
20
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
20
21
 
21
22
  function getNow(deps: Pick<OnboardingApplyDependencies, 'clock'>): number {
22
23
  return deps.clock?.() ?? Date.now();
@@ -116,7 +117,7 @@ async function runRollbacks(rollbacks: readonly RollbackAction[]): Promise<reado
116
117
  try {
117
118
  await rollback();
118
119
  } catch (error) {
119
- errors.push(error instanceof Error ? error.message : String(error));
120
+ errors.push(summarizeError(error));
120
121
  }
121
122
  }
122
123
  return errors;
@@ -523,7 +524,7 @@ function prevalidateApplyRequest(
523
524
  } catch (error) {
524
525
  errors.push({
525
526
  kind: operation.kind,
526
- message: error instanceof Error ? error.message : String(error),
527
+ message: summarizeError(error),
527
528
  });
528
529
  }
529
530
  }
@@ -595,7 +596,7 @@ export async function applyOnboardingRequest(
595
596
  errors.push({
596
597
  kind: operation.kind,
597
598
  message: [
598
- error instanceof Error ? error.message : String(error),
599
+ summarizeError(error),
599
600
  ...rollbackErrors.map((rollbackError) => `rollback: ${rollbackError}`),
600
601
  ].join('; '),
601
602
  });
@@ -14,6 +14,7 @@ import type {
14
14
  OnboardingSnapshotState,
15
15
  OnboardingSurfaceRecord,
16
16
  } from './types.ts';
17
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
17
18
 
18
19
  function sortUnique(values: readonly string[]): string[] {
19
20
  return [...new Set(values)].sort((left, right) => left.localeCompare(right));
@@ -100,7 +101,7 @@ async function buildServicesSnapshot(
100
101
  issues: [
101
102
  {
102
103
  area: 'services',
103
- message: error instanceof Error ? error.message : String(error),
104
+ message: summarizeError(error),
104
105
  },
105
106
  ],
106
107
  };
@@ -147,7 +148,7 @@ async function buildServicesSnapshot(
147
148
  } satisfies OnboardingServiceState,
148
149
  issue: {
149
150
  area: 'services',
150
- message: `${name}: ${error instanceof Error ? error.message : String(error)}`,
151
+ message: `${name}: ${summarizeError(error)}`,
151
152
  } satisfies OnboardingSnapshotCollectionIssue,
152
153
  };
153
154
  }
@@ -244,7 +245,7 @@ async function loadOptionalSnapshot<T>(
244
245
  value: fallback,
245
246
  issue: {
246
247
  area,
247
- message: error instanceof Error ? error.message : String(error),
248
+ message: summarizeError(error),
248
249
  },
249
250
  };
250
251
  }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Process-lifecycle wiring for the TUI shell.
3
+ *
4
+ * Extracted from main() to keep the entrypoint under the architecture line-count
5
+ * gate. `installProcessLifecycle` builds the terminal-restoring crash/termination
6
+ * handlers and the graceful `exitApp` teardown, then returns them so main() can
7
+ * register them on the exact same process/stdin/stdout listeners it used before.
8
+ *
9
+ * Behavior is identical to the previous inline version: the synchronous terminal
10
+ * restore runs BEFORE the (possibly slow) async shutdown, the shutdown races a 3s
11
+ * hard timeout, the recovery file is deleted only when the durable save completed,
12
+ * signal exit codes are preserved (SIGHUP -> 129, otherwise 143; uncaught -> 1;
13
+ * exitApp -> 0), and the `exiting`/`terminalRestored` reentrancy guards are kept.
14
+ *
15
+ * Some captured values are late-bound or mutable in main(): the InputHandler, the
16
+ * render closure, and the terminal output guard are constructed AFTER this factory
17
+ * runs (injected as thunks), while `recoveryInterval` and `stopSpokenOutputForExit`
18
+ * are assigned later and read at exit time (injected as getters/setter). The
19
+ * `unsubs` registry is shared by reference and drained on exit.
20
+ */
21
+ import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
22
+ import { formatUserFacingErrorLine } from '../core/format-user-error.ts';
23
+ import { allowTerminalWrite } from './terminal-output-guard.ts';
24
+ import { buildPersistedSessionContext, deleteRecoveryFile } from '@/runtime/index.ts';
25
+ import type { BootstrapContext } from './bootstrap.ts';
26
+ import type { InputHandler } from '../input/handler.ts';
27
+ import type { ConversationMessageSnapshot } from '../core/conversation.ts';
28
+
29
+ /** ANSI escape sequences used by the synchronous terminal restore. */
30
+ export interface ProcessLifecycleAnsi {
31
+ readonly CLEAR_SCREEN: string;
32
+ readonly ALT_SCREEN_EXIT: string;
33
+ readonly PASTE_DISABLE: string;
34
+ readonly KEYBOARD_EXT_DISABLE: string;
35
+ readonly MOUSE_DISABLE: string;
36
+ readonly CURSOR_SHOW: string;
37
+ }
38
+
39
+ export interface ProcessLifecycleDeps {
40
+ readonly stdin: NodeJS.ReadStream;
41
+ readonly stdout: NodeJS.WriteStream;
42
+ readonly ctx: BootstrapContext;
43
+ /** Mirrors cli.flags.noAltScreen: whether the alt screen is exited on restore. */
44
+ readonly noAltScreen: boolean;
45
+ readonly ansi: ProcessLifecycleAnsi;
46
+ /** Late-bound: the InputHandler is constructed after this factory runs. */
47
+ readonly getInput: () => InputHandler;
48
+ /** Late-bound: the render closure is defined after this factory runs. */
49
+ readonly render: () => void;
50
+ /** Late-bound: the terminal output guard is installed after this factory runs. */
51
+ readonly getTerminalOutputGuard: () => { readonly dispose: () => void };
52
+ readonly getPromptContentWidth: () => number;
53
+ readonly buildSessionContinuityHints: () => Parameters<typeof buildPersistedSessionContext>[2];
54
+ /** Teardown registry shared with main(); drained on exit. */
55
+ readonly unsubs: ReadonlyArray<() => void>;
56
+ readonly getRecoveryInterval: () => ReturnType<typeof setInterval> | null;
57
+ readonly setRecoveryInterval: (value: ReturnType<typeof setInterval> | null) => void;
58
+ readonly getStopSpokenOutputForExit: () => (() => void) | null;
59
+ }
60
+
61
+ export interface ProcessLifecycleHandlers {
62
+ readonly exitApp: () => Promise<void>;
63
+ readonly restoreTerminal: () => void;
64
+ readonly resizeHandler: () => void;
65
+ readonly sigintHandler: () => void;
66
+ readonly unhandledRejectionHandler: (reason: unknown) => void;
67
+ readonly uncaughtExceptionHandler: (err: Error) => void;
68
+ readonly terminationSignalHandler: (signal: NodeJS.Signals) => void;
69
+ readonly exitListener: () => void;
70
+ }
71
+
72
+ export function installProcessLifecycle(deps: ProcessLifecycleDeps): ProcessLifecycleHandlers {
73
+ const {
74
+ stdin,
75
+ stdout,
76
+ ctx,
77
+ noAltScreen,
78
+ ansi,
79
+ getInput,
80
+ render,
81
+ getTerminalOutputGuard,
82
+ getPromptContentWidth,
83
+ buildSessionContinuityHints,
84
+ unsubs,
85
+ getRecoveryInterval,
86
+ setRecoveryInterval,
87
+ getStopSpokenOutputForExit,
88
+ } = deps;
89
+
90
+ const sigintHandler = (): void => getInput().feed('\x03');
91
+ let _unhandledRejectionCount = 0;
92
+ let _unhandledRejectionWindowStart = Date.now();
93
+ const unhandledRejectionHandler = (reason: unknown): void => {
94
+ const now = Date.now();
95
+ if (now - _unhandledRejectionWindowStart > 10000) {
96
+ _unhandledRejectionCount = 0;
97
+ _unhandledRejectionWindowStart = now;
98
+ }
99
+ _unhandledRejectionCount++;
100
+ const msg = summarizeError(reason);
101
+ if (_unhandledRejectionCount > 3) {
102
+ logger.error('CRITICAL: cascading unhandled rejections — consider restarting', {
103
+ count: _unhandledRejectionCount,
104
+ windowMs: now - _unhandledRejectionWindowStart,
105
+ error: String(reason),
106
+ });
107
+ ctx.systemMessageRouter.high(
108
+ `[Critical] Multiple errors detected (${_unhandledRejectionCount} in 10s). If the issue persists, please restart. Latest: ${msg}`
109
+ );
110
+ } else {
111
+ const formatted = formatUserFacingErrorLine(reason);
112
+ ctx.systemMessageRouter.high(`[Error] ${formatted}`);
113
+ logger.error('unhandledRejection', { error: String(reason) });
114
+ }
115
+ render();
116
+ };
117
+ const resizeHandler = (): void => {
118
+ getInput().setContentWidth(getPromptContentWidth());
119
+ ctx.compositor.resetDiff();
120
+ render();
121
+ };
122
+
123
+ let terminalRestored = false;
124
+ // Idempotent, synchronous-only terminal restore. Safe to call from process.on('exit'),
125
+ // signal handlers, uncaughtException, and exitApp. Disposes the output guard FIRST so a
126
+ // crash stack reaches the real stderr instead of being suppressed by the guard.
127
+ const restoreTerminal = (): void => {
128
+ if (terminalRestored) return;
129
+ terminalRestored = true;
130
+ const exitScreen = noAltScreen ? ansi.CLEAR_SCREEN : ansi.CLEAR_SCREEN + ansi.ALT_SCREEN_EXIT;
131
+ allowTerminalWrite(() => stdout.write(ansi.PASTE_DISABLE + ansi.KEYBOARD_EXT_DISABLE + ansi.MOUSE_DISABLE + ansi.CURSOR_SHOW + exitScreen));
132
+ getTerminalOutputGuard().dispose();
133
+ try { stdin.setRawMode(false); } catch { /* stdin may not be a TTY */ }
134
+ };
135
+
136
+ const uncaughtExceptionHandler = (err: Error): void => {
137
+ restoreTerminal();
138
+ logger.error('uncaughtException — terminal restored, exiting', { error: summarizeError(err) });
139
+ process.exit(1);
140
+ };
141
+ const terminationSignalHandler = (signal: NodeJS.Signals): void => {
142
+ restoreTerminal();
143
+ logger.error(`Received ${signal} — terminal restored, exiting`, {});
144
+ process.exit(signal === 'SIGHUP' ? 129 : 143);
145
+ };
146
+ const exitListener = (): void => { restoreTerminal(); };
147
+
148
+ let exiting = false;
149
+ const exitApp = async (): Promise<void> => {
150
+ // Reentrancy guard: a second /exit or keypress during the awaited shutdown below
151
+ // must not re-run teardown or double-fire ctx.shutdown.
152
+ if (exiting) return;
153
+ exiting = true;
154
+ getStopSpokenOutputForExit()?.();
155
+ unsubs.forEach(fn => fn());
156
+ const interval = getRecoveryInterval();
157
+ if (interval !== null) { clearInterval(interval); setRecoveryInterval(null); }
158
+ stdin.removeAllListeners('data');
159
+ stdout.removeListener('resize', resizeHandler);
160
+ process.removeListener('SIGINT', sigintHandler);
161
+ process.removeListener('unhandledRejection', unhandledRejectionHandler);
162
+ // Restore the terminal synchronously BEFORE the (possibly slow) async shutdown so the
163
+ // user is not left staring at a frozen alt screen while we drain and persist.
164
+ restoreTerminal();
165
+ const snapshot = ctx.conversation.toJSON() as { messages: Array<ConversationMessageSnapshot>; timestamp?: number };
166
+ let shutdownOk = false;
167
+ try {
168
+ // Race the graceful shutdown against a hard timeout — externalServices.stop() can hang
169
+ // and we must still exit; deferredStartup.drain only budgets 100ms internally.
170
+ await Promise.race([
171
+ ctx.shutdown({ ...snapshot, ...buildPersistedSessionContext(snapshot.messages, ctx.conversation.getTitleSource(), buildSessionContinuityHints()) }).then(() => { shutdownOk = true; }),
172
+ new Promise<void>((resolve) => setTimeout(resolve, 3000)),
173
+ ]);
174
+ } catch (err) {
175
+ logger.debug('ctx.shutdown error during exitApp (non-fatal)', { error: summarizeError(err) });
176
+ }
177
+ // Only remove the recovery fallback once the durable shutdown save actually completed,
178
+ // so an interrupted or timed-out shutdown leaves the snapshot for the next launch.
179
+ if (shutdownOk) {
180
+ deleteRecoveryFile({ homeDirectory: ctx.services.homeDirectory });
181
+ }
182
+ process.exit(0);
183
+ };
184
+
185
+ return {
186
+ exitApp,
187
+ restoreTerminal,
188
+ resizeHandler,
189
+ sigintHandler,
190
+ unhandledRejectionHandler,
191
+ uncaughtExceptionHandler,
192
+ terminationSignalHandler,
193
+ exitListener,
194
+ };
195
+ }