@pellux/goodvibes-tui 2.0.17 → 2.0.19

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 CHANGED
@@ -4,6 +4,47 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [2.0.19] - 2026-08-22
8
+
9
+ ### Changes
10
+
11
+ - **This terminal adopts the running daemon again.** The adoption gate had
12
+ compared the daemon's 1.28.x artifact version against the sdk's 2.x
13
+ version and refused every adoption since sdk 2.0.0, silently running
14
+ local-only with no session spine. It now band-checks the platform build
15
+ the daemon reports.
16
+ - **A dead subscription login is stamped and shown as ended**, everywhere a
17
+ status renders; a near-expiry token refreshes silently before the send.
18
+ - **A machine with no microphone is reported as that**, once, in plain
19
+ words, with a gentle retry every minute — not a wake-detector crash loop
20
+ spamming the transcript and latching off. A busy device keeps prompt
21
+ retries and the crash latch.
22
+ - Pins move to sdk/terminal-shell/toolchain 2.0.22 and daemon 1.28.24;
23
+ reusable-workflow pins repoint to the 2.0.22 release commit.
24
+
25
+ ## [2.0.18] - 2026-08-21
26
+
27
+ ### Changes
28
+
29
+ - **Session start no longer crashes behind the scenes.** Every session
30
+ opened with "[Error] Provider error: Cannot access 'render' before
31
+ initialization" — a startup crash inside the TUI's own wiring, mislabeled
32
+ as a provider problem, that killed whatever bootstrap step was mid-flight.
33
+ Render requests that arrive before the renderer exists are now deferred
34
+ into the first paint instead of throwing.
35
+ - **A dead subscription login is named as such.** An OpenAI subscription
36
+ session ended server-side used to surface as "the provider rejected your
37
+ API key. Run /login to re-authenticate or check your API key", which reads
38
+ as "your subscription isn't there" to a logged-in subscriber. It now says
39
+ the subscription session has ended and to sign in again — and with
40
+ platform runtime 2.0.21 underneath, a token OpenAI rotated early is
41
+ refreshed and retried before anything is surfaced at all.
42
+ - **Escaped errors are captioned honestly.** An unhandled rejection with no
43
+ provider markers is reported as a GoodVibes bug; one carrying provider
44
+ markers (a 5xx outage classifies as neither auth nor network) stays
45
+ neutral instead of blaming either side.
46
+ - Platform runtime pins move to 2.0.21; the bundled daemon moves to 1.28.23.
47
+
7
48
  ## [2.0.17] - 2026-08-21
8
49
 
9
50
  ### Changes
@@ -3,7 +3,7 @@
3
3
  "product": {
4
4
  "id": "goodvibes",
5
5
  "surface": "operator",
6
- "version": "2.0.20"
6
+ "version": "2.0.22"
7
7
  },
8
8
  "auth": {
9
9
  "modes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "2.0.17",
3
+ "version": "2.0.19",
4
4
  "description": "Terminal-native GoodVibes client for coding, operations, knowledge and channel workflows; a pure client of the GoodVibes daemon.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -96,11 +96,11 @@
96
96
  "@anthropic-ai/bedrock-sdk": "^0.29.1",
97
97
  "@anthropic-ai/sdk": "^0.92.0",
98
98
  "@ast-grep/napi": "^0.42.0",
99
- "@pellux/goodvibes-sdk": "2.0.20",
100
- "@pellux/goodvibes-terminal-shell": "2.0.20",
99
+ "@pellux/goodvibes-sdk": "2.0.22",
100
+ "@pellux/goodvibes-terminal-shell": "2.0.22",
101
101
  "bash-language-server": "^5.6.0",
102
102
  "fuse.js": "^7.1.0",
103
- "@pellux/goodvibes-daemon": "1.28.22",
103
+ "@pellux/goodvibes-daemon": "1.28.24",
104
104
  "graphql": "^16.13.2",
105
105
  "jszip": "^3.10.1",
106
106
  "node-edge-tts": "^1.2.10",
@@ -121,7 +121,7 @@
121
121
  "zustand": "^5.0.12"
122
122
  },
123
123
  "devDependencies": {
124
- "@pellux/goodvibes-toolchain": "2.0.20",
124
+ "@pellux/goodvibes-toolchain": "2.0.22",
125
125
  "@types/bun": "^1.3.10",
126
126
  "typescript": "^5.9.3"
127
127
  },
@@ -77,12 +77,15 @@ function probeString(err: unknown): string {
77
77
  export function classifyError(err: unknown): ErrorClass {
78
78
  const probe = probeString(err);
79
79
 
80
- // Auth: 401 status, key/token invalidity, Unauthorized
80
+ // Auth: 401 status, key/token invalidity, Unauthorized, or a subscription
81
+ // session the provider's backend has ended (matched by message too, because
82
+ // a wrapper that re-throws can drop the 401 status on the way up).
81
83
  if (
82
84
  /\b401\b/.test(probe) ||
83
85
  /invalid.{0,10}(api.?key|key|token)/i.test(probe) ||
84
86
  /unauthorized/i.test(probe) ||
85
87
  /authentication/i.test(probe) ||
88
+ /subscription session has ended/i.test(probe) ||
86
89
  /api.?key.{0,20}(missing|not.?set|required|invalid)/i.test(probe)
87
90
  ) {
88
91
  return 'auth';
@@ -143,12 +146,24 @@ export function formatUserFacingError(err: unknown): UserFacingError {
143
146
  const kind = classifyError(err);
144
147
 
145
148
  switch (kind) {
146
- case 'auth':
149
+ case 'auth': {
150
+ // A subscription-session death is not an API-key problem, and telling a
151
+ // subscriber to "check your API key" reads as "your subscription isn't
152
+ // there". The SDK's subscription providers phrase that failure
153
+ // distinctly; keep their meaning.
154
+ if (/subscription session has ended/i.test(probeString(err))) {
155
+ return {
156
+ kind,
157
+ message: 'Authentication failed: your provider subscription session has ended.',
158
+ action: 'Run /login to sign in to the provider again.',
159
+ };
160
+ }
147
161
  return {
148
162
  kind,
149
163
  message: 'Authentication failed: the provider rejected your API key.',
150
164
  action: 'Run /login to re-authenticate or check your API key.',
151
165
  };
166
+ }
152
167
 
153
168
  case 'rate-limit':
154
169
  return {
package/src/main.ts CHANGED
@@ -54,9 +54,7 @@ import { prepareShellCliRuntime } from './cli/entrypoint.ts';
54
54
  import { applyInitialTuiCliState, reportFatalStartupError } from './cli/tui-startup.ts';
55
55
  import { applyConfiguredHitlMode, applyRuntimeConfigValue, applyTerminalRuntimeConfigDefaults } from '@pellux/goodvibes-terminal-shell';
56
56
  import { renderToolCallBlock } from './renderer/tool-call.ts';
57
- import { wireSpokenTurnRuntime } from './audio/spoken-turn-wiring.ts';
58
57
  import { installVoiceCapture } from './shell/voice-capture-shell.ts';
59
- import { attachSpokenTurnModelRouting, createSpokenTurnInputOptions } from './audio/spoken-turn-model-routing.ts';
60
58
  import { allowTerminalWrite, installFullScreenTerminalOutputGuard } from '@pellux/goodvibes-terminal-shell/terminal-output-guard';
61
59
  import { installProcessLifecycle } from './runtime/process-lifecycle.ts';
62
60
  import { createRenderScheduler } from '@pellux/goodvibes-terminal-shell';
@@ -68,9 +66,7 @@ import { wireStreamEventMetrics, createStreamMetrics, type StreamMetrics, type W
68
66
  import { wireTurnEventHandlers } from './core/turn-event-wiring.ts';
69
67
  import { resolveContextStatusHint } from './renderer/context-status-hint.ts';
70
68
  import { isEffectiveDangerMode } from '@pellux/goodvibes-sdk/platform/config';
71
- import { createScriptableStatusline } from './core/scriptable-statusline.ts';
72
69
  import { applyComposerCapture, applyAtModelDirective } from './input/composer-capture.ts';
73
- import { createSessionAutoTitler } from './core/session-auto-titler.ts';
74
70
  import { makeComposerEditorOpener } from './input/composer-editor.ts';
75
71
  import { evaluateSessionMaintenance } from '@/runtime/index.ts';
76
72
  import { createCancelGeneration } from './core/turn-cancellation.ts';
@@ -80,6 +76,9 @@ import { fetchDaemonPowerState, installKeepAwakeRemoteForward } from './runtime/
80
76
  import { wrapRequestPermissionWithAlert } from './core/approval-alert.ts';
81
77
  import { createTerminalNotifier } from './core/terminal-notifier.ts';
82
78
  import { setPanelFrameRequester } from './panels/base-panel.ts';
79
+ import { createDeferredRender } from './renderer/deferred-render.ts';
80
+ import { wireSessionAmbience } from './runtime/session-ambience-wiring.ts';
81
+ import { createSpokenTurnInputOptions } from './audio/spoken-turn-model-routing.ts';
83
82
 
84
83
  import { ALT_SCREEN_ENTER, ALT_SCREEN_EXIT, MOUSE_ENABLE, MOUSE_DISABLE, CURSOR_HIDE, CURSOR_SHOW, CLEAR_SCREEN, KEYBOARD_EXT_ENABLE, KEYBOARD_EXT_DISABLE, PASTE_ENABLE, PASTE_DISABLE, FOCUS_ENABLE, FOCUS_DISABLE } from './renderer/terminal-escapes.ts';
85
84
  import { installBackgroundThemeProbe } from './renderer/terminal-bg-probe.ts';
@@ -162,6 +161,10 @@ async function main() {
162
161
  const panelManager = ctx.services.panelManager;
163
162
  const buildSessionContinuityHints = createSessionContinuityHintsBuilder({ readModels: uiServices.readModels, panelManager });
164
163
 
164
+ // Callable before the scheduler exists; see deferred-render.ts for why.
165
+ const deferredRender = createDeferredRender();
166
+ const render = deferredRender.render;
167
+
165
168
  let pendingPermission: PendingPermissionState | null = null;
166
169
  // One-key jump/attach to a spawned CI fix-session: the affordance ARMS the id; the next 'j' runs the resume so the user never retypes it.
167
170
  let fixSessionAttachArmed: string | null = null;
@@ -259,30 +262,15 @@ async function main() {
259
262
  configGet: (k: string) => configManager.get(k as Parameters<typeof configManager.get>[0]),
260
263
  });
261
264
 
262
- const spokenTurns = wireSpokenTurnRuntime({
263
- voiceService: ctx.services.voiceService,
264
- configManager,
265
- events: uiServices.events,
266
- notify: (message) => { systemMessageRouter.high(message); render(); },
267
- });
268
- // Exit-path stop: bounded drain of the audio already playing (see stopForExit).
269
- stopSpokenOutputForExit = () => spokenTurns.stopForExit();
270
- unsubs.push(...spokenTurns.unsubs);
271
- // Scriptable status line: runs the user's `statusline.command` at turn boundaries.
272
- const scriptableStatusline = createScriptableStatusline({ configManager, cwd: workingDir, turns: uiServices.events.turns, onChange: () => render() });
273
- unsubs.push(...scriptableStatusline.unsubs);
274
- // Auto-title an untitled session via the weak/fast tool model (session.autoTitle, off by default).
275
- const sessionAutoTitler = createSessionAutoTitler({
276
- conversation, model: ctx.services.toolLLM, configManager, turns: uiServices.events.turns,
277
- onTitled: (title) => { systemMessageRouter.high(`[Session] Auto-titled: "${title}"`); render(); },
265
+ // Spoken output, scriptable statusline, auto-titling, spoken-turn routing.
266
+ const ambience = wireSessionAmbience({
267
+ voiceService: ctx.services.voiceService, configManager, events: uiServices.events,
268
+ conversation, toolLLM: ctx.services.toolLLM, orchestrator, providerRegistry, workingDir,
269
+ notify: (message) => systemMessageRouter.high(message), render,
278
270
  });
279
- unsubs.push(...sessionAutoTitler.unsubs);
280
- unsubs.push(attachSpokenTurnModelRouting({
281
- orchestrator,
282
- providerRegistry,
283
- configManager,
284
- notify: (message) => { systemMessageRouter.high(message); render(); },
285
- }));
271
+ stopSpokenOutputForExit = ambience.stopSpokenOutputForExit;
272
+ const { spokenTurns, scriptableStatusline } = ambience;
273
+ unsubs.push(...ambience.unsubs);
286
274
  const submitInput = (text: string, content?: ContentPart[], options: { readonly spokenOutput?: boolean } = {}) => {
287
275
  input.clearModalStack();
288
276
  scrollLocked = true; // Re-lock on any user input
@@ -649,7 +637,7 @@ async function main() {
649
637
  });
650
638
  };
651
639
  const renderScheduler = createRenderScheduler(renderNow, undefined, () => lifecycle.isTerminalRestored()); // coalescer; no frames after terminal restore
652
- const render = (): void => renderScheduler.schedule(); // captured direct writes → activity log + quiet /debug counter, not repeated transcript lines (1a)
640
+ deferredRender.set(() => renderScheduler.schedule()); // from here on, render() actually schedules
653
641
  const terminalOutputGuard = installFullScreenTerminalOutputGuard({ stdout, stderr: process.stderr, onCapture: (total) => { commandContext.session.runtime.terminalWritesIntercepted = total; render(); } });
654
642
 
655
643
  setRenderRequest(() => renderScheduler.flushNow()); // bootstrap's 16ms coalescer composites via the (restore-gated) scheduler
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A render function usable before the renderer exists.
3
+ *
4
+ * main() captures `render` in dozens of closures declared long before the
5
+ * render scheduler is built (the scheduler needs the compositor and lifecycle
6
+ * constructed below them). A direct `const render` declared at the scheduler
7
+ * put every one of those closures in its temporal dead zone, and every
8
+ * session start crashed with "Cannot access 'render' before initialization"
9
+ * surfacing as an unhandled rejection that killed whatever bootstrap step was
10
+ * mid-flight. The firing path has to be a callback some installer in that
11
+ * window invokes synchronously with the rejection crossing a promise boundary
12
+ * on the way out — the window contains no await, so no timer or scan callback
13
+ * can interleave into it — and this indirection removes the entire class
14
+ * rather than the one caller: a closure that fires before {@link
15
+ * DeferredRender.set} runs gets a no-op, and main()'s unconditional first
16
+ * paint after wiring covers everything deferred.
17
+ */
18
+ export interface DeferredRender {
19
+ /** Safe to call at any time; a no-op until {@link set} provides the real renderer. */
20
+ readonly render: () => void;
21
+ /** Install the real renderer; every later render() call goes to it. */
22
+ readonly set: (impl: () => void) => void;
23
+ }
24
+
25
+ export function createDeferredRender(): DeferredRender {
26
+ let impl: () => void = () => {};
27
+ return {
28
+ render: () => impl(),
29
+ set: (next) => { impl = next; },
30
+ };
31
+ }
@@ -21,7 +21,7 @@
21
21
  import { existsSync } from 'node:fs';
22
22
  import { flushActivityLogSync, logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
23
23
  import { createTerminalLifecycle, TERMINAL_ESCAPES } from '@pellux/goodvibes-terminal-shell';
24
- import { formatUserFacingErrorLine } from '../core/format-user-error.ts';
24
+ import { formatUserFacingError } from '../core/format-user-error.ts';
25
25
  import { allowTerminalWrite } from '@pellux/goodvibes-terminal-shell/terminal-output-guard';
26
26
  import { buildPersistedSessionContext, deleteRecoveryFile } from '@/runtime/index.ts';
27
27
  import type { SessionSurface } from '@/runtime/index.ts';
@@ -154,9 +154,29 @@ export function installProcessLifecycle(deps: ProcessLifecycleDeps): ProcessLife
154
154
  `[Critical] Multiple errors detected (${_unhandledRejectionCount} in 10s). If the issue persists, please restart. Latest: ${msg}`
155
155
  );
156
156
  } else {
157
- const formatted = formatUserFacingErrorLine(reason);
158
- ctx.systemMessageRouter.high(`[Error] ${formatted}`);
159
- logger.error('unhandledRejection', { error: String(reason) });
157
+ // Recognized kinds (auth, rate-limit, network...) keep their specific
158
+ // line. A generic rejection used to be captioned "Provider error",
159
+ // which sent a startup UI crash out dressed as a model-backend
160
+ // problem — but the reverse lie matters too: the classifier has no
161
+ // 5xx rule, so a provider outage also classifies generic, and calling
162
+ // THAT a GoodVibes bug blames us for OpenAI's downtime. The
163
+ // discriminator is whether the reason carries provider markers
164
+ // (provider / statusCode, which every SDK AppError sets): with them,
165
+ // stay neutral; without them, it escaped from our own code and
166
+ // honesty says so.
167
+ const classified = formatUserFacingError(reason);
168
+ let line: string;
169
+ if (classified.kind !== 'generic') {
170
+ line = `[Error] ${classified.message} ${classified.action}`;
171
+ } else {
172
+ const record = typeof reason === 'object' && reason !== null ? reason as Record<string, unknown> : {};
173
+ const providerShaped = typeof record['provider'] === 'string' || typeof record['statusCode'] === 'number';
174
+ line = providerShaped
175
+ ? `[Error] Unexpected error: ${msg}. Retry your last message, or switch models with /model.`
176
+ : `[Error] Unexpected error: ${msg}. This is a GoodVibes bug, not a provider failure; if it repeats, restart the session.`;
177
+ }
178
+ ctx.systemMessageRouter.high(line);
179
+ logger.error('unhandledRejection', { error: String(reason), stack: reason instanceof Error ? reason.stack : undefined });
160
180
  }
161
181
  render();
162
182
  };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Turn-boundary ambience: spoken turn output, the user's scriptable status
3
+ * line, session auto-titling, and spoken-turn model routing. Extracted from
4
+ * main() as one seam because the four share the same inputs (turn events,
5
+ * config, a notify line, a repaint) and main() only needs their teardowns and
6
+ * the bounded exit-path audio stop.
7
+ */
8
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
9
+ import type { Orchestrator } from '@pellux/goodvibes-sdk/platform/core';
10
+ import { wireSpokenTurnRuntime } from '../audio/spoken-turn-wiring.ts';
11
+ import { attachSpokenTurnModelRouting } from '../audio/spoken-turn-model-routing.ts';
12
+ import { createScriptableStatusline } from '../core/scriptable-statusline.ts';
13
+ import { createSessionAutoTitler } from '../core/session-auto-titler.ts';
14
+
15
+ export interface SessionAmbienceDeps {
16
+ readonly voiceService: Parameters<typeof wireSpokenTurnRuntime>[0]['voiceService'];
17
+ readonly configManager: ConfigManager;
18
+ readonly events: Parameters<typeof wireSpokenTurnRuntime>[0]['events'];
19
+ readonly conversation: Parameters<typeof createSessionAutoTitler>[0]['conversation'];
20
+ readonly toolLLM: Parameters<typeof createSessionAutoTitler>[0]['model'];
21
+ readonly orchestrator: Orchestrator;
22
+ readonly providerRegistry: Parameters<typeof attachSpokenTurnModelRouting>[0]['providerRegistry'];
23
+ readonly workingDir: string;
24
+ readonly notify: (message: string) => void;
25
+ readonly render: () => void;
26
+ }
27
+
28
+ export interface SessionAmbience {
29
+ /** Bounded drain of audio already playing; the exit path calls this. */
30
+ readonly stopSpokenOutputForExit: () => Promise<void>;
31
+ /** Spoken-turn runtime handle: submit/stop, and the cancel path consumes it whole. */
32
+ readonly spokenTurns: ReturnType<typeof wireSpokenTurnRuntime>;
33
+ /** The footer reads the statusline's current text each frame. */
34
+ readonly scriptableStatusline: ReturnType<typeof createScriptableStatusline>;
35
+ readonly unsubs: ReadonlyArray<() => void>;
36
+ }
37
+
38
+ export function wireSessionAmbience(deps: SessionAmbienceDeps): SessionAmbience {
39
+ const { configManager, notify, render } = deps;
40
+ const notifyAndPaint = (message: string): void => { notify(message); render(); };
41
+ const spokenTurns = wireSpokenTurnRuntime({
42
+ voiceService: deps.voiceService,
43
+ configManager,
44
+ events: deps.events,
45
+ notify: notifyAndPaint,
46
+ });
47
+ const scriptableStatusline = createScriptableStatusline({
48
+ configManager, cwd: deps.workingDir, turns: deps.events.turns, onChange: () => render(),
49
+ });
50
+ const sessionAutoTitler = createSessionAutoTitler({
51
+ conversation: deps.conversation, model: deps.toolLLM, configManager, turns: deps.events.turns,
52
+ onTitled: (title) => notifyAndPaint(`[Session] Auto-titled: "${title}"`),
53
+ });
54
+ const routingUnsub = attachSpokenTurnModelRouting({
55
+ orchestrator: deps.orchestrator,
56
+ providerRegistry: deps.providerRegistry,
57
+ configManager,
58
+ notify: notifyAndPaint,
59
+ });
60
+ return {
61
+ stopSpokenOutputForExit: () => spokenTurns.stopForExit(),
62
+ spokenTurns,
63
+ scriptableStatusline,
64
+ unsubs: [...spokenTurns.unsubs, ...scriptableStatusline.unsubs, ...sessionAutoTitler.unsubs, routingUnsub],
65
+ };
66
+ }