@pellux/goodvibes-agent 1.5.8 → 1.5.9

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/src/main.ts CHANGED
@@ -53,6 +53,7 @@ import { FOCUS_ENABLE, FOCUS_DISABLE, installFocusModeExitGuard, markFocusModeEn
53
53
  import { prepareShellCliRuntime } from './cli/entrypoint.ts';
54
54
  import { applyInitialTuiCliState, formatFatalStartupErrorForLog, formatFatalStartupErrorForUser, getInteractiveTerminalLaunchError } from './cli/tui-startup.ts';
55
55
  import { wireSpokenTurnRuntime } from './audio/spoken-turn-wiring.ts';
56
+ import { createUnhandledRejectionHandler } from './runtime/unhandled-rejection-guard.ts';
56
57
  import { attachSpokenTurnModelRouting, createSpokenTurnInputOptions } from './audio/spoken-turn-model-routing.ts';
57
58
  import { allowTerminalWrite, installTuiTerminalOutputGuard } from './runtime/terminal-output-guard.ts';
58
59
  import { buildCommandArgsHint } from './input/command-args-hint.ts';
@@ -221,43 +222,34 @@ async function main() {
221
222
 
222
223
  const unsubs: Array<() => void> = [];
223
224
  let recoveryInterval: ReturnType<typeof setInterval> | null = null;
224
- let stopSpokenOutputForExit: (() => void) | null = null;
225
+ let stopSpokenOutputForExit: (() => Promise<void>) | null = null;
225
226
  let recoveryPending = false;
226
227
 
227
228
  const sigintHandler = (): void => input.feed('\x03');
228
- let _unhandledRejectionCount = 0;
229
- let _unhandledRejectionWindowStart = Date.now();
230
- const unhandledRejectionHandler = (reason: unknown): void => {
231
- const now = Date.now();
232
- if (now - _unhandledRejectionWindowStart > 10000) {
233
- _unhandledRejectionCount = 0;
234
- _unhandledRejectionWindowStart = now;
235
- }
236
- _unhandledRejectionCount++;
237
- const msg = reason instanceof Error ? reason.message : String(reason);
238
- if (_unhandledRejectionCount > 3) {
239
- logger.error('CRITICAL: cascading unhandled rejections — consider restarting', {
240
- count: _unhandledRejectionCount,
241
- windowMs: now - _unhandledRejectionWindowStart,
242
- error: String(reason),
243
- });
244
- systemMessageRouter.high(
245
- `[Critical] Multiple errors detected (${_unhandledRejectionCount} in 10s). If the issue persists, please restart. Latest: ${msg}`
246
- );
247
- } else {
248
- systemMessageRouter.high(`[Error] ${msg}`);
249
- logger.error('unhandledRejection', { error: String(reason) });
250
- }
251
- render();
252
- };
229
+ const unhandledRejectionHandler = createUnhandledRejectionHandler({
230
+ notifyHigh: (message) => systemMessageRouter.high(message),
231
+ render: () => render(),
232
+ });
253
233
  const resizeHandler = (): void => {
254
234
  input.setContentWidth(getPromptContentWidth());
255
235
  compositor.resetDiff();
256
236
  render();
257
237
  };
258
238
 
239
+ let exiting = false;
259
240
  const exitApp = (): void => {
260
- stopSpokenOutputForExit?.();
241
+ // Reentrancy guard: a second /exit or keypress during the bounded
242
+ // spoken-audio drain below must not re-run teardown.
243
+ if (exiting) return;
244
+ exiting = true;
245
+ // Exit lets the spoken audio the user is already hearing finish inside a
246
+ // short bounded window (capped inside stopForExit) instead of killing the
247
+ // player mid-drain; queued-but-unplayed speech is dropped. Deliberate
248
+ // interrupts (Ctrl+C, /tts stop) still cut instantly via spokenTurns.stop().
249
+ let spokenOutputDrain: Promise<void> = Promise.resolve();
250
+ try {
251
+ spokenOutputDrain = Promise.resolve(stopSpokenOutputForExit?.()).then(() => undefined);
252
+ } catch { /* non-fatal to exit */ }
261
253
  unsubs.forEach(fn => fn());
262
254
  // Persist last-seen before shutdown so the next launch can compute the digest.
263
255
  autonomy.stop();
@@ -275,7 +267,9 @@ async function main() {
275
267
  allowTerminalWrite(() => stdout.write(PASTE_DISABLE + KEYBOARD_EXT_DISABLE + MOUSE_DISABLE + FOCUS_DISABLE + CURSOR_SHOW + exitScreen));
276
268
  terminalOutputGuard.dispose();
277
269
  stdin.setRawMode(false);
278
- process.exit(0);
270
+ // The terminal is already restored above; only the process exit waits for
271
+ // the (internally capped, ~2s max) audio drain.
272
+ void spokenOutputDrain.catch(() => undefined).then(() => process.exit(0));
279
273
  };
280
274
 
281
275
  commandContext.exit = exitApp;
@@ -286,7 +280,8 @@ async function main() {
286
280
  events: uiServices.events,
287
281
  notify: (message) => { systemMessageRouter.high(message); render(); },
288
282
  });
289
- stopSpokenOutputForExit = () => spokenTurns.stop();
283
+ // Exit-path stop: bounded drain of the audio already playing (see stopForExit).
284
+ stopSpokenOutputForExit = () => spokenTurns.stopForExit();
290
285
  unsubs.push(...spokenTurns.unsubs);
291
286
  unsubs.push(attachSpokenTurnModelRouting({
292
287
  orchestrator,
@@ -0,0 +1,41 @@
1
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
2
+
3
+ /**
4
+ * Builds the process-level unhandledRejection handler for the shell.
5
+ *
6
+ * Extracted from main() so the entrypoint stays under the architecture
7
+ * line-count cap. Behavior is unchanged: isolated rejections surface as a
8
+ * single "[Error]" line, while more than three within a rolling 10s window
9
+ * escalate to a "[Critical]" restart suggestion so a cascading failure is
10
+ * named instead of scrolling by as noise.
11
+ */
12
+ export function createUnhandledRejectionHandler(deps: {
13
+ readonly notifyHigh: (message: string) => void;
14
+ readonly render: () => void;
15
+ }): (reason: unknown) => void {
16
+ let rejectionCount = 0;
17
+ let windowStart = Date.now();
18
+ return (reason: unknown): void => {
19
+ const now = Date.now();
20
+ if (now - windowStart > 10000) {
21
+ rejectionCount = 0;
22
+ windowStart = now;
23
+ }
24
+ rejectionCount++;
25
+ const msg = reason instanceof Error ? reason.message : String(reason);
26
+ if (rejectionCount > 3) {
27
+ logger.error('CRITICAL: cascading unhandled rejections — consider restarting', {
28
+ count: rejectionCount,
29
+ windowMs: now - windowStart,
30
+ error: String(reason),
31
+ });
32
+ deps.notifyHigh(
33
+ `[Critical] Multiple errors detected (${rejectionCount} in 10s). If the issue persists, please restart. Latest: ${msg}`
34
+ );
35
+ } else {
36
+ deps.notifyHigh(`[Error] ${msg}`);
37
+ logger.error('unhandledRejection', { error: String(reason) });
38
+ }
39
+ deps.render();
40
+ };
41
+ }
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 = '1.5.8';
9
+ let _version = '1.5.9';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8')) as {
12
12
  readonly version?: unknown;