@pellux/goodvibes-tui 0.26.0 → 0.28.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 (148) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +12 -7
  3. package/docs/foundation-artifacts/operator-contract.json +4 -1
  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/context-usage.ts +53 -0
  8. package/src/core/conversation-rendering.ts +2 -2
  9. package/src/core/conversation.ts +53 -8
  10. package/src/core/session-recovery.ts +26 -18
  11. package/src/core/system-message-router.ts +42 -26
  12. package/src/core/turn-event-wiring.ts +13 -16
  13. package/src/daemon/handlers/calendar/caldav-client.ts +2 -1
  14. package/src/daemon/handlers/inbox/index.ts +2 -1
  15. package/src/daemon/handlers/inbox/poller.ts +2 -1
  16. package/src/daemon/handlers/inbox/providers/discord.ts +2 -1
  17. package/src/daemon/handlers/inbox/providers/email.ts +2 -1
  18. package/src/daemon/handlers/inbox/providers/route-util.ts +2 -1
  19. package/src/daemon/handlers/inbox/providers/slack.ts +2 -1
  20. package/src/daemon/handlers/triage/integration.ts +2 -1
  21. package/src/daemon/handlers/triage/pipeline.ts +2 -1
  22. package/src/daemon/handlers/triage/tagger/discord.ts +2 -1
  23. package/src/daemon/handlers/triage/tagger/imap.ts +4 -3
  24. package/src/export/gist-uploader.ts +3 -1
  25. package/src/input/command-registry.ts +1 -0
  26. package/src/input/commands/cloudflare-runtime.ts +2 -1
  27. package/src/input/commands/eval.ts +4 -3
  28. package/src/input/commands/guidance-runtime.ts +2 -4
  29. package/src/input/commands/health-runtime.ts +13 -7
  30. package/src/input/commands/knowledge.ts +2 -1
  31. package/src/input/commands/runtime-services.ts +40 -10
  32. package/src/input/handler-command-route.ts +1 -1
  33. package/src/input/handler-feed-routes.ts +61 -4
  34. package/src/input/handler-feed.ts +13 -0
  35. package/src/input/handler-interactions.ts +2 -1
  36. package/src/input/handler-modal-routes.ts +2 -1
  37. package/src/input/handler-onboarding-cloudflare.ts +2 -1
  38. package/src/input/handler-onboarding.ts +8 -8
  39. package/src/input/handler-picker-routes.ts +18 -13
  40. package/src/input/handler-ui-state.ts +5 -1
  41. package/src/input/keybindings.ts +5 -0
  42. package/src/input/model-picker.ts +5 -0
  43. package/src/input/panel-integration-actions.ts +10 -0
  44. package/src/input/tts-settings-actions.ts +2 -1
  45. package/src/main.ts +52 -60
  46. package/src/panels/agent-inspector-panel.ts +90 -15
  47. package/src/panels/agent-inspector-shared.ts +3 -3
  48. package/src/panels/agent-logs-panel.ts +149 -72
  49. package/src/panels/approval-panel.ts +146 -95
  50. package/src/panels/automation-control-panel.ts +24 -1
  51. package/src/panels/base-panel.ts +28 -3
  52. package/src/panels/builtin/agent.ts +3 -1
  53. package/src/panels/builtin/development.ts +2 -1
  54. package/src/panels/builtin/session.ts +1 -1
  55. package/src/panels/cockpit-panel.ts +24 -5
  56. package/src/panels/cockpit-read-model.ts +2 -1
  57. package/src/panels/communication-panel.ts +108 -43
  58. package/src/panels/context-visualizer-panel.ts +49 -15
  59. package/src/panels/control-plane-panel.ts +27 -1
  60. package/src/panels/cost-tracker-panel.ts +87 -65
  61. package/src/panels/debug-panel.ts +61 -37
  62. package/src/panels/diff-panel.ts +59 -30
  63. package/src/panels/docs-panel.ts +30 -24
  64. package/src/panels/eval-panel.ts +130 -81
  65. package/src/panels/expandable-list-panel.ts +189 -0
  66. package/src/panels/file-explorer-panel.ts +42 -42
  67. package/src/panels/file-preview-panel.ts +11 -3
  68. package/src/panels/forensics-panel.ts +27 -13
  69. package/src/panels/git-panel.ts +65 -84
  70. package/src/panels/hooks-panel.ts +36 -2
  71. package/src/panels/incident-review-panel.ts +31 -10
  72. package/src/panels/intelligence-panel.ts +152 -71
  73. package/src/panels/knowledge-graph-panel.ts +89 -27
  74. package/src/panels/local-auth-panel.ts +17 -11
  75. package/src/panels/marketplace-panel.ts +33 -13
  76. package/src/panels/memory-panel.ts +7 -5
  77. package/src/panels/ops-control-panel.ts +69 -7
  78. package/src/panels/ops-strategy-panel.ts +61 -43
  79. package/src/panels/orchestration-panel.ts +34 -4
  80. package/src/panels/panel-list-panel.ts +33 -8
  81. package/src/panels/panel-manager.ts +23 -4
  82. package/src/panels/plan-dashboard-panel.ts +183 -66
  83. package/src/panels/plugins-panel.ts +48 -10
  84. package/src/panels/policy-panel.ts +60 -23
  85. package/src/panels/polish-core.ts +157 -0
  86. package/src/panels/polish-tables.ts +258 -0
  87. package/src/panels/polish.ts +44 -145
  88. package/src/panels/project-planning-panel.ts +27 -4
  89. package/src/panels/provider-accounts-panel.ts +55 -18
  90. package/src/panels/provider-health-panel.ts +118 -87
  91. package/src/panels/provider-stats-panel.ts +47 -22
  92. package/src/panels/qr-panel.ts +23 -11
  93. package/src/panels/remote-panel.ts +12 -5
  94. package/src/panels/routes-panel.ts +27 -5
  95. package/src/panels/sandbox-panel.ts +62 -27
  96. package/src/panels/schedule-panel.ts +44 -24
  97. package/src/panels/scrollable-list-panel.ts +124 -10
  98. package/src/panels/security-panel.ts +72 -20
  99. package/src/panels/services-panel.ts +68 -22
  100. package/src/panels/session-browser-panel.ts +42 -26
  101. package/src/panels/session-maintenance.ts +2 -2
  102. package/src/panels/settings-sync-panel.ts +30 -15
  103. package/src/panels/skills-panel.ts +2 -1
  104. package/src/panels/subscription-panel.ts +21 -3
  105. package/src/panels/symbol-outline-panel.ts +40 -47
  106. package/src/panels/system-messages-panel.ts +60 -27
  107. package/src/panels/tasks-panel.ts +36 -14
  108. package/src/panels/thinking-panel.ts +32 -36
  109. package/src/panels/token-budget-panel.ts +80 -53
  110. package/src/panels/tool-inspector-panel.ts +37 -39
  111. package/src/panels/types.ts +25 -0
  112. package/src/panels/watchers-panel.ts +25 -5
  113. package/src/panels/work-plan-panel.ts +127 -35
  114. package/src/panels/worktree-panel.ts +65 -20
  115. package/src/panels/wrfc-panel-format.ts +133 -0
  116. package/src/panels/wrfc-panel.ts +53 -147
  117. package/src/renderer/agent-detail-modal.ts +2 -2
  118. package/src/renderer/compaction-preview.ts +2 -1
  119. package/src/renderer/compositor.ts +46 -43
  120. package/src/renderer/modal-utils.ts +0 -10
  121. package/src/renderer/model-picker-overlay.ts +9 -4
  122. package/src/renderer/model-workspace.ts +2 -3
  123. package/src/renderer/panel-composite.ts +7 -20
  124. package/src/renderer/panel-workspace-bar.ts +14 -8
  125. package/src/renderer/process-modal.ts +2 -2
  126. package/src/renderer/progress.ts +3 -2
  127. package/src/renderer/tab-strip.ts +148 -34
  128. package/src/renderer/ui-factory.ts +4 -6
  129. package/src/runtime/bootstrap-command-context.ts +3 -0
  130. package/src/runtime/bootstrap-command-parts.ts +3 -1
  131. package/src/runtime/bootstrap-core.ts +31 -31
  132. package/src/runtime/bootstrap-shell.ts +1 -0
  133. package/src/runtime/onboarding/apply.ts +4 -3
  134. package/src/runtime/onboarding/snapshot.ts +4 -3
  135. package/src/runtime/process-lifecycle.ts +195 -0
  136. package/src/runtime/services.ts +4 -1
  137. package/src/runtime/ui/model-picker/health-enrichment.ts +3 -0
  138. package/src/runtime/ui/model-picker/types.ts +4 -0
  139. package/src/runtime/wrfc-persistence.ts +20 -5
  140. package/src/shell/ui-openers.ts +3 -2
  141. package/src/utils/format-duration.ts +55 -0
  142. package/src/utils/format-number.ts +71 -0
  143. package/src/verification/live-verifier.ts +4 -3
  144. package/src/version.ts +1 -1
  145. package/src/core/context-auto-compact.ts +0 -110
  146. package/src/panels/panel-picker.ts +0 -106
  147. package/src/renderer/panel-picker-overlay.ts +0 -202
  148. package/src/renderer/panel-tab-bar.ts +0 -69
@@ -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
+ }
@@ -63,6 +63,7 @@ import { CacheHitTracker } from '@pellux/goodvibes-sdk/platform/providers';
63
63
  import { FavoritesStore } from '@pellux/goodvibes-sdk/platform/providers';
64
64
  import { BenchmarkStore } from '@pellux/goodvibes-sdk/platform/providers';
65
65
  import { ModelLimitsService } from '@pellux/goodvibes-sdk/platform/providers';
66
+ import { inferFallbackContextWindow } from '@pellux/goodvibes-sdk/platform/providers';
66
67
  import { KeybindingsManager } from '../input/keybindings.ts';
67
68
  import { SessionMemoryStore } from '@pellux/goodvibes-sdk/platform/core';
68
69
  import { SessionLineageTracker } from '@pellux/goodvibes-sdk/platform/core';
@@ -123,7 +124,9 @@ function buildFallbackModelDefinition(provider: string, modelId: string): ModelD
123
124
  reasoning: isReasoningProvider,
124
125
  multimodal: isReasoningProvider,
125
126
  },
126
- contextWindow: isReasoningProvider ? 128_000 : 32_000,
127
+ // Pre-catalog fallback uses the SDK's family-aware inference (SDK 0.35.0+),
128
+ // matching the post-catalog window so the meter/compaction denominator agrees.
129
+ contextWindow: inferFallbackContextWindow(provider, modelId),
127
130
  contextWindowProvenance: 'fallback',
128
131
  selectable: true,
129
132
  tier: 'standard',
@@ -154,6 +154,9 @@ export function enrichModelEntries(
154
154
  // Determine source: custom/local providers carry provenance on ModelDefinition;
155
155
  // for catalog models, if getContextWindowForModel returned more than the
156
156
  // static contextWindow it came from OpenRouter, else it's the registry value.
157
+ // NOTE: This is a best-effort heuristic. When the OpenRouter live value equals
158
+ // the static registry value the badge shows 'registry' even though OpenRouter
159
+ // was consulted. The contextWindow value itself is always accurate.
157
160
  let contextWindowSource: ModelPickerEntry['contextWindowSource'];
158
161
  if (model.contextWindowProvenance) {
159
162
  contextWindowSource = model.contextWindowProvenance;
@@ -97,6 +97,10 @@ export interface ModelPickerEntry {
97
97
  * - `fallback` — default constant (no config or API source)
98
98
  * - `openrouter` — sourced from OpenRouter model data (built-in catalog models)
99
99
  * - `registry` — static value in the built-in model registry
100
+ *
101
+ * For built-in catalog models this badge is best-effort: when the OpenRouter
102
+ * resolved value equals the static registry value the source shows 'registry'
103
+ * even if OpenRouter was consulted at runtime.
100
104
  */
101
105
  readonly contextWindowSource:
102
106
  | 'provider_api'
@@ -10,10 +10,11 @@
10
10
  *
11
11
  * 2. `WrfcPersistence.rehydrate(router)` is called once on boot (after the
12
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.
13
+ * chains that were in a non-terminal state at last write, emits a
14
+ * high-priority 'wrfc' system message per interrupted chain, and re-imports
15
+ * each interrupted chain into the WrfcController so it reappears as a panel
16
+ * row and can be resumed by the operator. The `interruptedChains` accessor
17
+ * additionally exposes the recovered set for inspection.
17
18
  *
18
19
  * 3. Snapshot lifecycle:
19
20
  * - Terminal chains ('passed' | 'failed') are pruned from the snapshot
@@ -57,6 +58,14 @@ interface WrfcSnapshot {
57
58
  /** Subset of WrfcController needed by this module. */
58
59
  export interface WrfcControllerReader {
59
60
  listChains(): WrfcChain[];
61
+ /**
62
+ * Re-import a chain recovered from a previous process so it reappears in the
63
+ * controller's in-memory map and becomes selectable/resumable from the panel.
64
+ * Optional so read-only test doubles can omit it; the real WrfcController
65
+ * provides it. `force` is left at its default — on a fresh start the map is
66
+ * empty so importing never clobbers a live chain.
67
+ */
68
+ importChain?(chain: WrfcChain, force?: boolean): boolean;
60
69
  }
61
70
 
62
71
  export interface WrfcPersistenceOptions {
@@ -64,7 +73,7 @@ export interface WrfcPersistenceOptions {
64
73
  readonly snapshotPath: string;
65
74
  /** Factory for the current SystemMessageRouter — may return null before it is wired. */
66
75
  readonly getSystemMessageRouter: () => SystemMessageRouter | null;
67
- /** WrfcController readeronly listChains() is needed. */
76
+ /** WrfcController access — listChains() (read) plus optional importChain() (re-import on rehydrate). */
68
77
  readonly controller: WrfcControllerReader;
69
78
  }
70
79
 
@@ -140,6 +149,12 @@ class WrfcPersistenceImpl implements WrfcPersistence {
140
149
 
141
150
  const router = this.getSystemMessageRouter();
142
151
  for (const chain of interrupted) {
152
+ // Re-import so the chain reappears in the controller's in-memory map and
153
+ // becomes selectable/resumable from the panel. On a fresh process start
154
+ // the map is empty, so importChain (force=false) never clobbers a live
155
+ // chain. The accessor is optional for read-only test doubles.
156
+ this.controller.importChain?.(chain);
157
+
143
158
  const msg =
144
159
  `[WRFC] Chain ${chain.id.slice(0, 12)} (${chain.task.slice(0, 60).trim()}) ` +
145
160
  `was interrupted by a restart — state was '${chain.state}' ` +
@@ -13,6 +13,7 @@ import type { SecretsManager } from '@pellux/goodvibes-sdk/platform/config';
13
13
  import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
14
14
  import type { ModelPickerTargetInfo } from '../input/model-picker.ts';
15
15
  import { syncServiceSettingToPlatform } from './service-settings-sync.ts';
16
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
16
17
 
17
18
  type WireShellUiOpenersOptions = {
18
19
  commandContext: CommandContext;
@@ -203,7 +204,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
203
204
  input.modelPicker.openAllModels(models, getCurrentModelForPickerTarget());
204
205
  render();
205
206
  })().catch((error: unknown) => {
206
- commandContext.print?.(`Model picker failed to open: ${error instanceof Error ? error.message : String(error)}`);
207
+ commandContext.print?.(`Model picker failed to open: ${summarizeError(error)}`);
207
208
  render();
208
209
  });
209
210
  };
@@ -223,7 +224,7 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
223
224
  input.modelPicker.openProviders(providers, getCurrentProviderForPickerTarget());
224
225
  render();
225
226
  })().catch((error: unknown) => {
226
- commandContext.print?.(`Provider picker failed to open: ${error instanceof Error ? error.message : String(error)}`);
227
+ commandContext.print?.(`Provider picker failed to open: ${summarizeError(error)}`);
227
228
  render();
228
229
  });
229
230
  };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Shared ms-duration formatters for the panel layer.
3
+ *
4
+ * Three families are exported, each matching a distinct behavior cluster.
5
+ * DO NOT replace these with formatElapsed from utils/format-elapsed.ts —
6
+ * formatElapsed floors to integer seconds and lacks the null/'?ms' guards
7
+ * that the latency panels require.
8
+ */
9
+
10
+ /**
11
+ * Format a latency value in milliseconds with sub-second precision.
12
+ *
13
+ * Used by: debug-panel, provider-health-panel, provider-stats-panel
14
+ *
15
+ * ms <= 0 → 'n/a'
16
+ * ms >= 10000 → '12.3s' (one decimal)
17
+ * ms >= 1000 → '1.23s' (two decimals)
18
+ * else → '500ms' (integer ms)
19
+ */
20
+ export function formatLatencyMs(ms: number): string {
21
+ if (ms <= 0) return 'n/a';
22
+ if (ms >= 10_000) return `${(ms / 1000).toFixed(1)}s`;
23
+ if (ms >= 1_000) return `${(ms / 1000).toFixed(2)}s`;
24
+ return `${Math.round(ms)}ms`;
25
+ }
26
+
27
+ /**
28
+ * Format a task/agent duration with minute-rolling notation.
29
+ *
30
+ * Used by: tool-inspector-panel, agent-inspector-shared
31
+ *
32
+ * ms < 1000 → '500ms'
33
+ * ms < 60000 → '3.5s'
34
+ * else → '1m30s'
35
+ */
36
+ export function formatDuration(ms: number): string {
37
+ if (ms < 1000) return `${ms}ms`;
38
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
39
+ return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
40
+ }
41
+
42
+ /**
43
+ * Format a short eval/forensics duration; treats undefined as '?ms'.
44
+ *
45
+ * Used by: forensics-panel, eval-panel
46
+ *
47
+ * undefined → '?ms'
48
+ * ms < 1000 → '500ms'
49
+ * else → '1.5s'
50
+ */
51
+ export function formatShortDuration(ms: number | undefined): string {
52
+ if (ms === undefined) return '?ms';
53
+ if (ms < 1000) return `${ms}ms`;
54
+ return `${(ms / 1000).toFixed(1)}s`;
55
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Parametrised number abbreviation helper.
3
+ *
4
+ * Each call site can reproduce its exact current output by passing the
5
+ * appropriate options rather than a single "agreed" format being imposed.
6
+ *
7
+ * Ranges (default opts: guard=1_000, decimals=1, rounding='fixed'):
8
+ * n < guard → String(n) e.g. 999 → '999'
9
+ * n < 1_000_000 → X.Xk e.g. 1500 → '1.5k'
10
+ * n < 1_000_000_000 → X.XM e.g. 1.5M → '1.5M'
11
+ * (with bSuffix) → X.XB / X.XT
12
+ * (with noM) → always k, never M
13
+ */
14
+
15
+ export interface AbbreviateCountOpts {
16
+ /**
17
+ * Values strictly below this are returned as-is via String(n).
18
+ * Default: 1_000
19
+ */
20
+ guard?: number;
21
+ /**
22
+ * Decimal places used for the k suffix (and for M when mDecimals is not set).
23
+ * Default: 1
24
+ */
25
+ decimals?: number;
26
+ /**
27
+ * Override decimal places for the M suffix only.
28
+ * Default: same as decimals.
29
+ */
30
+ mDecimals?: number;
31
+ /**
32
+ * 'fixed' → value.toFixed(decimals) (default)
33
+ * 'round' → String(Math.round(value)) — always integer, decimals ignored
34
+ */
35
+ rounding?: 'fixed' | 'round';
36
+ /**
37
+ * When true, extend formatting to B (billions) and T (trillions).
38
+ * Default: false
39
+ */
40
+ bSuffix?: boolean;
41
+ /**
42
+ * When true, never produce M/B/T — format as k even for millions.
43
+ * Default: false
44
+ */
45
+ noM?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Format a non-negative integer with k/M/B/T magnitude suffix.
50
+ *
51
+ * Preserves each call site's exact current format by accepting
52
+ * per-site option overrides (guard, decimals, rounding, etc.).
53
+ */
54
+ export function abbreviateCount(n: number, opts?: AbbreviateCountOpts): string {
55
+ const guard = opts?.guard ?? 1_000;
56
+ const decimals = opts?.decimals ?? 1;
57
+ const mDecimals = opts?.mDecimals ?? decimals;
58
+ const rounding = opts?.rounding ?? 'fixed';
59
+ const noM = opts?.noM ?? false;
60
+ const bSuffix = opts?.bSuffix ?? false;
61
+
62
+ /** Render a scaled value according to the configured rounding mode. */
63
+ const fmt = (val: number, d: number): string =>
64
+ rounding === 'round' ? String(Math.round(val)) : val.toFixed(d);
65
+
66
+ if (n < guard) return String(n);
67
+ if (noM || n < 1_000_000) return fmt(n / 1_000, decimals) + 'k';
68
+ if (!bSuffix || n < 1_000_000_000) return fmt(n / 1_000_000, mDecimals) + 'M';
69
+ if (n < 1_000_000_000_000) return fmt(n / 1_000_000_000, decimals) + 'B';
70
+ return fmt(n / 1_000_000_000_000, decimals) + 'T';
71
+ }
@@ -3,6 +3,7 @@ import { join, resolve } from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { auditGoodVibesHome } from '../config/goodvibes-home-audit.ts';
5
5
  import { buildVerificationLedger } from './verification-ledger.ts';
6
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
7
 
7
8
  export type LiveVerificationStatus = 'pass' | 'warn' | 'fail' | 'skip';
8
9
 
@@ -111,7 +112,7 @@ function runCommand(command: string, args: string[], cwd: string, timeoutMs = 15
111
112
  resolveCommand({
112
113
  exitCode: -1,
113
114
  stdout: '',
114
- stderr: error instanceof Error ? error.message : String(error),
115
+ stderr: summarizeError(error),
115
116
  timedOut,
116
117
  });
117
118
  });
@@ -161,7 +162,7 @@ function commandCheck(
161
162
  title,
162
163
  status: 'fail',
163
164
  summary: 'Command succeeded but did not return valid JSON.',
164
- detail: error instanceof Error ? error.message : String(error),
165
+ detail: summarizeError(error),
165
166
  };
166
167
  }
167
168
  }
@@ -208,7 +209,7 @@ async function fetchCheck(
208
209
  title,
209
210
  status: 'fail',
210
211
  summary: 'Request failed.',
211
- detail: error instanceof Error ? error.message : String(error),
212
+ detail: summarizeError(error),
212
213
  };
213
214
  }
214
215
  }
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '0.26.0';
9
+ let _version = '0.28.0';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;
@@ -1,110 +0,0 @@
1
- /**
2
- * Auto-compaction helper — TASK-058.
3
- *
4
- * Evaluates whether auto-compact should run after a turn completes and, if so,
5
- * triggers compaction and posts an honest transcript notice.
6
- *
7
- * behavior.autoCompactThreshold: SDK schema range [10, 100], default 80.
8
- * Auto-compact is active whenever the threshold is in its valid range (>0).
9
- * The display/suggestion path (hint + meter) is always active regardless.
10
- */
11
-
12
- import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
13
- import type { ConversationManager } from './conversation';
14
- import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
15
- import type { SystemMessageRouter } from './system-message-router';
16
- import { logger } from '@pellux/goodvibes-sdk/platform/utils';
17
- import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
18
- import { getLastCompactionEvent } from '@pellux/goodvibes-sdk/platform/core';
19
- import type { CompactionContext } from '@pellux/goodvibes-sdk/platform/core';
20
- import { buildCompactionPreview, buildCompactionAfterNotice } from '../renderer/compaction-preview.ts';
21
-
22
- export interface AutoCompactDeps {
23
- readonly configManager: Pick<ConfigManager, 'get'>;
24
- readonly conversation: ConversationManager;
25
- readonly providerRegistry: ProviderRegistry;
26
- readonly systemMessageRouter: SystemMessageRouter;
27
- readonly model: string;
28
- readonly provider: string;
29
- readonly lastInputTokens: number;
30
- readonly contextWindow: number;
31
- }
32
-
33
- /**
34
- * Run after each TURN_COMPLETED event.
35
- *
36
- * Reads behavior.autoCompactThreshold from config (SDK default: 80, range [10, 100]).
37
- * When usage is at or above the threshold, compacts the conversation and posts
38
- * an honest transcript notice so the user understands any summary discontinuity.
39
- *
40
- * This function is intentionally non-throwing; failures are logged and
41
- * surfaced via the system message router.
42
- */
43
- export async function maybeAutoCompact(deps: AutoCompactDeps): Promise<void> {
44
- // SDK schema default is 80; valid range is [10, 100]. The ?? 0 fallback is a
45
- // defensive guard for missing/null values only — not a normal operating state.
46
- const rawThreshold = Number(deps.configManager.get('behavior.autoCompactThreshold') ?? 0);
47
- const thresholdPct = Number.isFinite(rawThreshold) ? rawThreshold : 0;
48
-
49
- // Defensive guard: skip only when threshold is missing/non-positive (real config defaults to 80).
50
- if (thresholdPct <= 0 || deps.contextWindow <= 0) return;
51
-
52
- const usagePct = Math.min(100, Math.round((Math.max(0, deps.lastInputTokens) / deps.contextWindow) * 100));
53
- if (usagePct < thresholdPct) return;
54
-
55
- try {
56
- logger.debug('auto-compact triggered', { usagePct, thresholdPct });
57
- // Pre-compact preview — uses buildCompactionPreview for an honest estimate.
58
- const messages = deps.conversation.getMessagesForLLM();
59
- const sessionMemoryStore = deps.conversation.getSessionMemoryStore();
60
- const sessionMemories = sessionMemoryStore?.list() ?? [];
61
- const pinnedMemoryCount = sessionMemories.length;
62
- const preview = buildCompactionPreview({
63
- messages,
64
- contextWindow: deps.contextWindow,
65
- pinnedMemoryCount,
66
- trigger: 'auto',
67
- });
68
- deps.systemMessageRouter.routeSystemMessage(preview, 'high');
69
- const eventBefore = getLastCompactionEvent();
70
- const compactionCtx: CompactionContext = {
71
- messages,
72
- sessionMemories,
73
- agents: [],
74
- wrfcChains: [],
75
- activePlan: null,
76
- lineageEntries: [],
77
- compactionCount: 0,
78
- contextWindow: deps.contextWindow,
79
- trigger: 'auto',
80
- extractionModelId: deps.model,
81
- extractionProvider: deps.provider,
82
- };
83
- await deps.conversation.compact(
84
- deps.providerRegistry,
85
- deps.model,
86
- 'auto',
87
- deps.provider,
88
- compactionCtx,
89
- );
90
- // Post-compact notice using real CompactionEvent figures.
91
- const eventAfter = getLastCompactionEvent();
92
- if (eventAfter !== null && eventAfter !== eventBefore) {
93
- deps.systemMessageRouter.routeSystemMessage(
94
- buildCompactionAfterNotice({ event: eventAfter, pinnedMemoryCount }),
95
- 'low',
96
- );
97
- } else {
98
- deps.systemMessageRouter.routeSystemMessage(
99
- '[Context] Auto-compact complete — older turns summarised. Use /compact to compact again manually.',
100
- 'low',
101
- );
102
- }
103
- } catch (err) {
104
- logger.error('auto-compact failed', { error: summarizeError(err) });
105
- deps.systemMessageRouter.routeSystemMessage(
106
- `[Context] Auto-compact failed: ${summarizeError(err)}. Use /compact to try manually.`,
107
- 'high',
108
- );
109
- }
110
- }