@pellux/goodvibes-tui 0.26.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.
- package/CHANGELOG.md +17 -0
- package/README.md +12 -7
- package/docs/foundation-artifacts/operator-contract.json +4 -1
- package/package.json +2 -2
- package/src/cli/bundle-command.ts +2 -1
- package/src/cli/service-posture.ts +2 -1
- package/src/core/conversation-rendering.ts +2 -2
- package/src/core/conversation.ts +49 -2
- package/src/core/session-recovery.ts +24 -18
- package/src/core/system-message-router.ts +42 -26
- package/src/core/turn-event-wiring.ts +12 -16
- package/src/daemon/handlers/calendar/caldav-client.ts +2 -1
- package/src/daemon/handlers/inbox/index.ts +2 -1
- package/src/daemon/handlers/inbox/poller.ts +2 -1
- package/src/daemon/handlers/inbox/providers/discord.ts +2 -1
- package/src/daemon/handlers/inbox/providers/email.ts +2 -1
- package/src/daemon/handlers/inbox/providers/route-util.ts +2 -1
- package/src/daemon/handlers/inbox/providers/slack.ts +2 -1
- package/src/daemon/handlers/triage/integration.ts +2 -1
- package/src/daemon/handlers/triage/pipeline.ts +2 -1
- package/src/daemon/handlers/triage/tagger/discord.ts +2 -1
- package/src/daemon/handlers/triage/tagger/imap.ts +4 -3
- package/src/export/gist-uploader.ts +3 -1
- package/src/input/command-registry.ts +1 -0
- package/src/input/commands/cloudflare-runtime.ts +2 -1
- package/src/input/commands/guidance-runtime.ts +2 -4
- package/src/input/commands/health-runtime.ts +13 -7
- package/src/input/commands/knowledge.ts +2 -1
- package/src/input/commands/runtime-services.ts +40 -10
- package/src/input/handler-command-route.ts +1 -1
- package/src/input/handler-interactions.ts +2 -1
- package/src/input/handler-modal-routes.ts +2 -1
- package/src/input/handler-onboarding-cloudflare.ts +2 -1
- package/src/input/handler-onboarding.ts +8 -8
- package/src/input/handler-ui-state.ts +1 -1
- package/src/input/tts-settings-actions.ts +2 -1
- package/src/main.ts +52 -59
- package/src/panels/agent-inspector-panel.ts +72 -2
- package/src/panels/agent-logs-panel.ts +127 -19
- package/src/panels/base-panel.ts +2 -1
- package/src/panels/builtin/agent.ts +3 -1
- package/src/panels/builtin/development.ts +1 -0
- package/src/panels/builtin/session.ts +1 -1
- package/src/panels/context-visualizer-panel.ts +24 -14
- package/src/panels/cost-tracker-panel.ts +7 -13
- package/src/panels/debug-panel.ts +11 -22
- package/src/panels/docs-panel.ts +14 -24
- package/src/panels/file-explorer-panel.ts +2 -1
- package/src/panels/file-preview-panel.ts +2 -1
- package/src/panels/git-panel.ts +10 -17
- package/src/panels/marketplace-panel.ts +2 -1
- package/src/panels/memory-panel.ts +2 -1
- package/src/panels/project-planning-panel.ts +5 -4
- package/src/panels/provider-health-panel.ts +56 -67
- package/src/panels/schedule-panel.ts +4 -7
- package/src/panels/session-browser-panel.ts +13 -21
- package/src/panels/skills-panel.ts +2 -1
- package/src/panels/tasks-panel.ts +2 -7
- package/src/panels/thinking-panel.ts +6 -11
- package/src/panels/token-budget-panel.ts +31 -15
- package/src/panels/tool-inspector-panel.ts +10 -18
- package/src/panels/work-plan-panel.ts +2 -1
- package/src/panels/wrfc-panel.ts +37 -35
- package/src/renderer/agent-detail-modal.ts +2 -2
- package/src/renderer/modal-utils.ts +0 -10
- package/src/renderer/process-modal.ts +2 -2
- package/src/runtime/bootstrap-command-context.ts +3 -0
- package/src/runtime/bootstrap-command-parts.ts +3 -1
- package/src/runtime/bootstrap-core.ts +31 -31
- package/src/runtime/bootstrap-shell.ts +1 -0
- package/src/runtime/onboarding/apply.ts +4 -3
- package/src/runtime/onboarding/snapshot.ts +4 -3
- package/src/runtime/process-lifecycle.ts +195 -0
- package/src/runtime/services.ts +4 -1
- package/src/runtime/wrfc-persistence.ts +20 -5
- package/src/shell/ui-openers.ts +3 -2
- package/src/verification/live-verifier.ts +4 -3
- package/src/version.ts +1 -1
- package/src/core/context-auto-compact.ts +0 -110
package/src/panels/wrfc-panel.ts
CHANGED
|
@@ -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
|
-
/**
|
|
35
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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.
|
|
157
|
+
return { tag: '[UNV]', fg: C.dim, dim: true };
|
|
158
158
|
}
|
|
159
159
|
if (finding.satisfied) {
|
|
160
|
-
return { tag: '[SAT]', fg: C.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
501
|
+
badgeFg = C.dim;
|
|
502
502
|
} else if (satisfied === total) {
|
|
503
|
-
badgeFg = C.
|
|
503
|
+
badgeFg = C.good;
|
|
504
504
|
} else if (findings.some(f => !f.satisfied)) {
|
|
505
|
-
badgeFg = C.
|
|
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.
|
|
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.
|
|
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 =
|
|
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 {
|
|
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 : ${
|
|
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 {
|
|
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 =
|
|
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
|
|
403
|
-
//
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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}: ${
|
|
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:
|
|
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
|
+
}
|
package/src/runtime/services.ts
CHANGED
|
@@ -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
|
-
|
|
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',
|