@pellux/goodvibes-tui 0.20.3 → 0.22.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 (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +23 -2
  3. package/docs/foundation-artifacts/operator-contract.json +78 -1
  4. package/package.json +4 -2
  5. package/src/audio/spoken-turn-controller.ts +31 -1
  6. package/src/audio/spoken-turn-wiring.ts +26 -4
  7. package/src/cli/bundle-command.ts +1 -1
  8. package/src/cli/completions/generate.ts +658 -0
  9. package/src/cli/config-overrides.ts +68 -0
  10. package/src/cli/entrypoint.ts +6 -0
  11. package/src/cli/help.ts +4 -2
  12. package/src/cli/management-commands.ts +1 -1
  13. package/src/cli/management.ts +1 -8
  14. package/src/cli/parser.ts +31 -18
  15. package/src/cli/service-command.ts +1 -1
  16. package/src/cli/surface-command.ts +1 -1
  17. package/src/cli/tui-startup.ts +72 -10
  18. package/src/cli/types.ts +14 -3
  19. package/src/cli-flags.ts +1 -0
  20. package/src/config/atomic-write.ts +70 -0
  21. package/src/config/goodvibes-home-audit.ts +2 -0
  22. package/src/config/read-versioned.ts +115 -0
  23. package/src/core/context-auto-compact.ts +77 -0
  24. package/src/core/conversation-rendering.ts +49 -15
  25. package/src/core/conversation.ts +101 -16
  26. package/src/core/format-user-error.ts +192 -0
  27. package/src/core/stream-event-wiring.ts +144 -0
  28. package/src/core/stream-stall-watchdog.ts +103 -0
  29. package/src/core/system-message-router.ts +5 -1
  30. package/src/core/turn-event-wiring.ts +124 -0
  31. package/src/daemon/cli.ts +5 -0
  32. package/src/export/cost-utils.ts +71 -0
  33. package/src/export/gist-uploader.ts +136 -0
  34. package/src/input/command-registry.ts +32 -1
  35. package/src/input/commands/control-room-runtime.ts +10 -10
  36. package/src/input/commands/experience-runtime.ts +5 -4
  37. package/src/input/commands/knowledge.ts +1 -1
  38. package/src/input/commands/local-auth-runtime.ts +27 -5
  39. package/src/input/commands/local-setup.ts +4 -6
  40. package/src/input/commands/memory-product-runtime.ts +8 -6
  41. package/src/input/commands/operator-panel-runtime.ts +1 -1
  42. package/src/input/commands/operator-runtime.ts +3 -10
  43. package/src/input/commands/{integration-runtime.ts → plugin-runtime.ts} +1 -1
  44. package/src/input/commands/provider.ts +57 -3
  45. package/src/input/commands/recall-review.ts +26 -2
  46. package/src/input/commands/services-runtime.ts +2 -2
  47. package/src/input/commands/session-workflow.ts +8 -16
  48. package/src/input/commands/session.ts +70 -20
  49. package/src/input/commands/share-runtime.ts +99 -12
  50. package/src/input/commands/tts-runtime.ts +30 -4
  51. package/src/input/commands.ts +2 -4
  52. package/src/input/delete-key-policy.ts +46 -0
  53. package/src/input/feed-context-factory.ts +2 -0
  54. package/src/input/handler-feed.ts +3 -0
  55. package/src/input/handler-interactions.ts +2 -15
  56. package/src/input/handler-modal-routes.ts +128 -12
  57. package/src/input/handler-modal-token-routes.ts +22 -5
  58. package/src/input/handler-onboarding-cloudflare.ts +1 -1
  59. package/src/input/handler-onboarding.ts +73 -69
  60. package/src/input/handler-types.ts +163 -0
  61. package/src/input/handler.ts +6 -2
  62. package/src/input/input-history.ts +76 -6
  63. package/src/input/model-picker-filter.ts +265 -0
  64. package/src/input/model-picker-items.ts +208 -0
  65. package/src/input/model-picker.ts +92 -325
  66. package/src/input/onboarding/handler-onboarding-routes.ts +7 -2
  67. package/src/input/onboarding/onboarding-verification-helpers.ts +76 -0
  68. package/src/input/onboarding/onboarding-wizard-apply.ts +14 -4
  69. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +16 -2
  70. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +8 -8
  71. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +1 -1
  72. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +2 -29
  73. package/src/input/onboarding/onboarding-wizard-rules.ts +28 -28
  74. package/src/input/onboarding/onboarding-wizard-state.ts +20 -20
  75. package/src/input/onboarding/onboarding-wizard-steps.ts +24 -25
  76. package/src/input/onboarding/onboarding-wizard-types.ts +145 -3
  77. package/src/input/onboarding/onboarding-wizard-validation.ts +77 -0
  78. package/src/input/onboarding/onboarding-wizard.ts +3 -3
  79. package/src/input/settings-modal-behavior.ts +5 -0
  80. package/src/input/settings-modal-data.ts +378 -0
  81. package/src/input/settings-modal-mutations.ts +157 -0
  82. package/src/input/settings-modal-reset.ts +154 -0
  83. package/src/input/settings-modal.ts +236 -232
  84. package/src/main.ts +93 -85
  85. package/src/panels/agent-inspector-panel.ts +120 -18
  86. package/src/panels/agent-inspector-shared.ts +29 -0
  87. package/src/panels/builtin/agent.ts +4 -1
  88. package/src/panels/builtin/development.ts +5 -1
  89. package/src/panels/builtin/knowledge.ts +14 -13
  90. package/src/panels/builtin/operations.ts +22 -1
  91. package/src/panels/builtin/shared.ts +7 -0
  92. package/src/panels/cockpit-panel.ts +123 -3
  93. package/src/panels/cockpit-read-model.ts +232 -0
  94. package/src/panels/confirm-state.ts +27 -12
  95. package/src/panels/cost-tracker-panel.ts +23 -67
  96. package/src/panels/eval-panel.ts +10 -9
  97. package/src/panels/index.ts +1 -1
  98. package/src/panels/knowledge-graph-panel.ts +84 -0
  99. package/src/panels/local-auth-panel.ts +124 -4
  100. package/src/panels/memory-panel.ts +370 -40
  101. package/src/panels/project-planning-panel.ts +42 -4
  102. package/src/panels/search-focus.ts +11 -5
  103. package/src/panels/session-maintenance.ts +66 -15
  104. package/src/panels/subscription-panel.ts +33 -25
  105. package/src/panels/types.ts +28 -1
  106. package/src/panels/wrfc-panel.ts +224 -41
  107. package/src/renderer/agent-detail-modal.ts +118 -13
  108. package/src/renderer/code-block.ts +10 -2
  109. package/src/renderer/compositor.ts +18 -4
  110. package/src/renderer/context-inspector.ts +1 -5
  111. package/src/renderer/context-status-hint.ts +54 -0
  112. package/src/renderer/diff.ts +94 -21
  113. package/src/renderer/markdown.ts +29 -13
  114. package/src/renderer/settings-modal-helpers.ts +1 -1
  115. package/src/renderer/settings-modal.ts +90 -10
  116. package/src/renderer/shell-surface.ts +10 -0
  117. package/src/renderer/syntax-highlighter.ts +10 -3
  118. package/src/renderer/term-caps.ts +318 -0
  119. package/src/renderer/theme.ts +158 -0
  120. package/src/renderer/tool-call.ts +12 -2
  121. package/src/renderer/ui-factory.ts +50 -6
  122. package/src/runtime/bootstrap-command-context.ts +1 -0
  123. package/src/runtime/bootstrap-command-parts.ts +18 -0
  124. package/src/runtime/bootstrap-core.ts +145 -13
  125. package/src/runtime/bootstrap-shell.ts +11 -0
  126. package/src/runtime/bootstrap.ts +9 -0
  127. package/src/runtime/onboarding/apply.ts +4 -6
  128. package/src/runtime/onboarding/index.ts +1 -0
  129. package/src/runtime/onboarding/markers.ts +42 -49
  130. package/src/runtime/onboarding/progress.ts +148 -0
  131. package/src/runtime/onboarding/state.ts +133 -55
  132. package/src/runtime/onboarding/types.ts +20 -0
  133. package/src/runtime/services.ts +27 -1
  134. package/src/runtime/wrfc-persistence.ts +237 -0
  135. package/src/shell/blocking-input.ts +20 -5
  136. package/src/tools/wrfc-agent-guard.ts +64 -3
  137. package/src/utils/format-elapsed.ts +30 -0
  138. package/src/utils/terminal-width.ts +45 -0
  139. package/src/version.ts +1 -1
  140. package/src/work-plans/work-plan-store.ts +4 -6
  141. package/src/panels/knowledge-panel.ts +0 -345
  142. package/src/planning/project-planning-coordinator.ts +0 -543
@@ -1,3 +1,4 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
1
2
  import type { ConfigKey, ConfigManager, ConfigSetting, GoodVibesConfig, PersistedFlagState } from '../config/index.ts';
2
3
  import { CONFIG_SCHEMA, ConfigError } from '../config/index.ts';
3
4
  import type { GoodVibesCliCommand, GoodVibesCliFlags } from './types.ts';
@@ -62,6 +63,73 @@ function setNestedConfigValue(config: GoodVibesConfig, key: ConfigKey, value: un
62
63
  (cursor as Record<string, unknown>)[parts[parts.length - 1]!] = value;
63
64
  }
64
65
 
66
+ /**
67
+ * Typed accessor for the SDK's private file paths on ConfigManager.
68
+ *
69
+ * SDK coupling: configPath and projectConfigPath are declared `private readonly`
70
+ * in the SDK's ConfigManager (see manager.d.ts — configPath, projectConfigPath). No public accessors exist
71
+ * as of the SDK version pinned in this project. This cast is the narrowest
72
+ * possible workaround. SDK public-accessor request: see HANDOFF-FROM-TUI-SESSION-20260611.md,
73
+ * Item 6 area. If the SDK ever exposes getConfigPath()/getProjectConfigPath(), replace
74
+ * this cast with those calls and remove this comment.
75
+ *
76
+ * Fail-open: if the cast produces undefined (SDK internal rename), the paths are
77
+ * treated as absent and the default is applied safely.
78
+ */
79
+ function getPersistedPaths(configManager: ConfigManager): { configPath: string | undefined; projectConfigPath: string | undefined } {
80
+ const manager = configManager as unknown as { configPath?: string; projectConfigPath?: string };
81
+ return {
82
+ configPath: typeof manager.configPath === 'string' ? manager.configPath : undefined,
83
+ projectConfigPath: typeof manager.projectConfigPath === 'string' ? manager.projectConfigPath : undefined,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Returns true if the given dot-path key is explicitly present anywhere
89
+ * in the provided raw JSON object.
90
+ */
91
+ function isKeyPresentInRaw(raw: Record<string, unknown>, key: ConfigKey): boolean {
92
+ const parts = key.split('.');
93
+ let cursor: unknown = raw;
94
+ for (const part of parts) {
95
+ if (cursor == null || typeof cursor !== 'object' || !(part in (cursor as object))) {
96
+ return false;
97
+ }
98
+ cursor = (cursor as Record<string, unknown>)[part];
99
+ }
100
+ return true;
101
+ }
102
+
103
+ /**
104
+ * Apply a TUI default to a config key, but ONLY if the user has not explicitly
105
+ * set the key in EITHER their global OR project persisted settings files.
106
+ * Reads the raw settings JSON from disk (bypasses the in-memory merged config)
107
+ * so a user's explicit `false` in either file is never silently overridden at startup.
108
+ *
109
+ * Each settings file is read independently: a parse failure on one file
110
+ * contributes nothing but does NOT prevent the other file from being checked.
111
+ * The default is applied only when the key is absent from every readable file
112
+ * (e.g. new install, both files missing, or both unparseable).
113
+ */
114
+ export function applyRuntimeConfigDefault(configManager: ConfigManager, key: ConfigKey, defaultValue: unknown): void {
115
+ const { configPath, projectConfigPath } = getPersistedPaths(configManager);
116
+ // Check each settings file independently. A read/parse failure on one path
117
+ // contributes nothing but must not prevent the other path from being checked.
118
+ for (const filePath of [configPath, projectConfigPath]) {
119
+ if (typeof filePath !== 'string' || !existsSync(filePath)) continue;
120
+ try {
121
+ const raw = JSON.parse(readFileSync(filePath, 'utf-8')) as Record<string, unknown>;
122
+ if (isKeyPresentInRaw(raw, key)) {
123
+ // Key is explicitly set in this persisted config — respect the user's value.
124
+ return;
125
+ }
126
+ } catch {
127
+ // Unreadable or malformed JSON — this file contributes nothing; continue to next.
128
+ }
129
+ }
130
+ applyRuntimeConfigValue(configManager, key, defaultValue);
131
+ }
132
+
65
133
  export function applyRuntimeConfigValue(configManager: ConfigManager, key: ConfigKey, value: unknown): void {
66
134
  const setting = CONFIG_SCHEMA_BY_KEY.get(key);
67
135
  if (!setting) {
@@ -61,6 +61,12 @@ export async function prepareShellCliRuntime(
61
61
  process.exit(2);
62
62
  }
63
63
 
64
+ if (cli.warnings.length > 0) {
65
+ for (const warning of cli.warnings) {
66
+ console.warn(`[goodvibes] warning: ${warning}`);
67
+ }
68
+ }
69
+
64
70
  if (cli.flags.help || cli.command === 'help') {
65
71
  const helpTopic = cli.command === 'help'
66
72
  ? cli.commandArgs[0]
package/src/cli/help.ts CHANGED
@@ -71,12 +71,14 @@ export function renderGoodVibesHelp(binary = 'goodvibes'): string {
71
71
  ' --json Alias for --output-format json',
72
72
  ' --no-alt-screen Keep output in normal terminal scrollback',
73
73
  ' --port <port> Port for server/web commands',
74
- ' --hostname <host> Hostname for server/web commands',
74
+ ' --hostname <host> Hostname for server/web commands (--host is an alias)',
75
75
  ' --open Open browser when supported',
76
76
  ' -r, --resume [id|latest] Resume saved session when supported',
77
77
  ' -s, --session <id> Use a specific session when supported',
78
78
  ' --continue Continue the latest session when supported',
79
- ' --fork Fork session when supported',
79
+ ' --fork [id] Fork session (current or specific id) when supported',
80
+ ' -y, --yes Auto-confirm prompts (non-interactive)',
81
+ ' --non-interactive Disable all interactive prompts (implies --yes)',
80
82
  ' -h, --help Print help',
81
83
  ' -v, --version Print version',
82
84
  '',
@@ -9,7 +9,7 @@ import { getOrCreateCompanionToken, buildCompanionConnectionInfo, encodeConnecti
9
9
  import { generateQrMatrix, renderQrToString } from '@pellux/goodvibes-sdk/platform/pairing';
10
10
  import { resolveRuntimeEndpointBinding } from './endpoints.ts';
11
11
  import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
12
- import type { CliCommandRuntime } from './management.ts';
12
+ import type { CliCommandRuntime } from './types.ts';
13
13
  import { extractAuthorizationCode, formatJsonOrText, hasCommandFlag, openBrowser, probeTcp, readAuthPaths, runNonInteractiveAgent, urlHostForBindHost, withRuntimeServices, yesNo } from './management.ts';
14
14
 
15
15
  export async function renderSubscriptions(runtime: CliCommandRuntime): Promise<string> {
@@ -21,7 +21,7 @@ import { beginOpenAICodexLogin, exchangeOpenAICodexCode } from '@pellux/goodvibe
21
21
  import { inspectProviderAuth } from '@/runtime/index.ts';
22
22
  import { getOrCreateCompanionToken, buildCompanionConnectionInfo, encodeConnectionPayload, formatConnectionBlock } from '@pellux/goodvibes-sdk/platform/pairing';
23
23
  import { generateQrMatrix, renderQrToString } from '@pellux/goodvibes-sdk/platform/pairing';
24
- import type { GoodVibesCliParseResult } from './types.ts';
24
+ import type { CliCommandRuntime, GoodVibesCliParseResult } from './types.ts';
25
25
  import { formatProviderAuthRoute, summarizeProviderAuthRoutes } from './provider-auth-routes.ts';
26
26
  import { classifyProviderSetup } from './provider-classification.ts';
27
27
  import { resolveRuntimeEndpointBinding } from './endpoints.ts';
@@ -32,13 +32,6 @@ import { handleBundleCommand } from './bundle-command.ts';
32
32
  import { buildListenerTestResult, formatListenerTestResult, handleSurfacesCommand } from './surface-command.ts';
33
33
  import { buildControlPlaneStatusResult, formatControlPlaneStatus, handleSecrets, handleSessions, handleTasks, renderPairing, renderRemote, renderSubscriptions, renderWeb } from './management-commands.ts';
34
34
 
35
- export interface CliCommandRuntime {
36
- readonly cli: GoodVibesCliParseResult;
37
- readonly configManager: ConfigManager;
38
- readonly workingDirectory: string;
39
- readonly homeDirectory: string;
40
- }
41
-
42
35
  interface CliCommandResult {
43
36
  readonly handled: boolean;
44
37
  readonly exitCode: number;
package/src/cli/parser.ts CHANGED
@@ -76,9 +76,9 @@ function createDefaultFlags(): GoodVibesCliFlags {
76
76
  continueLast: false,
77
77
  resume: undefined,
78
78
  session: undefined,
79
- fork: false,
80
- rawOutput: false,
81
- acceptRawOutputRisk: false,
79
+ fork: undefined,
80
+ yes: false,
81
+ nonInteractive: false,
82
82
  };
83
83
  }
84
84
 
@@ -132,9 +132,9 @@ function parsePort(value: string | undefined, optionName: string, errors: string
132
132
  return port;
133
133
  }
134
134
 
135
- function normalizeOutputFormat(value: string | undefined, errors: string[]): GoodVibesCliOutputFormat {
135
+ function normalizeOutputFormat(value: string | undefined, flagName: string, errors: string[]): GoodVibesCliOutputFormat {
136
136
  if (value === 'text' || value === 'json' || value === 'stream-json') return value;
137
- errors.push('--output-format must be one of: text, json, stream-json.');
137
+ errors.push(`${flagName} must be one of: text, json, stream-json.`);
138
138
  return 'text';
139
139
  }
140
140
 
@@ -174,6 +174,7 @@ export function parseGoodVibesCli(
174
174
  const commandArgs: string[] = [];
175
175
  const positionals: string[] = [];
176
176
  const errors: string[] = [];
177
+ const warnings: string[] = [];
177
178
  let sawCommand = false;
178
179
  let passthrough = false;
179
180
 
@@ -238,15 +239,17 @@ export function parseGoodVibesCli(
238
239
  continue;
239
240
  }
240
241
  if (name === '--fork') {
241
- flags = withFlag(flags, 'fork', true);
242
+ const consumed = getOptionalValue(argv, index, inlineValue);
243
+ index = consumed.nextIndex;
244
+ flags = withFlag(flags, 'fork', consumed.value ?? true);
242
245
  continue;
243
246
  }
244
- if (name === '--raw-output') {
245
- flags = withFlag(flags, 'rawOutput', true);
247
+ if (name === '--yes' || name === '-y') {
248
+ flags = withFlag(flags, 'yes', true);
246
249
  continue;
247
250
  }
248
- if (name === '--accept-raw-output-risk') {
249
- flags = withFlag(flags, 'acceptRawOutputRisk', true);
251
+ if (name === '--non-interactive') {
252
+ flags = withFlag(withFlag(flags, 'nonInteractive', true), 'yes', true);
250
253
  continue;
251
254
  }
252
255
 
@@ -289,16 +292,13 @@ export function parseGoodVibesCli(
289
292
  if (name === '--output-format' || name === '--output' || name === '-o') {
290
293
  const consumed = getValue(argv, index, inlineValue, name, errors);
291
294
  index = consumed.nextIndex;
292
- flags = withFlag(flags, 'outputFormat', normalizeOutputFormat(consumed.value, errors));
293
- continue;
294
- }
295
- if (name === '--config') {
296
- const consumed = getValue(argv, index, inlineValue, name, errors);
297
- index = consumed.nextIndex;
298
- if (consumed.value !== undefined) flags = appendFlagArray(flags, 'configOverrides', consumed.value);
295
+ flags = withFlag(flags, 'outputFormat', normalizeOutputFormat(consumed.value, name, errors));
296
+ if (name === '--output-format') {
297
+ warnings.push('--output-format is deprecated; use --output (or -o) instead.');
298
+ }
299
299
  continue;
300
300
  }
301
- if (name === '-c') {
301
+ if (name === '--config' || name === '-c') {
302
302
  const consumed = getValue(argv, index, inlineValue, name, errors);
303
303
  index = consumed.nextIndex;
304
304
  if (consumed.value !== undefined) flags = appendFlagArray(flags, 'configOverrides', consumed.value);
@@ -357,6 +357,18 @@ export function parseGoodVibesCli(
357
357
  errors.push(`Unknown command: ${rawCommand}`);
358
358
  }
359
359
 
360
+ // Session lifecycle conflict detection — only one of --continue / --resume / --fork may be used.
361
+ const sessionLifecycleFlags = [
362
+ flags.continueLast ? '--continue' : undefined,
363
+ flags.resume !== undefined ? '--resume' : undefined,
364
+ flags.fork !== undefined ? '--fork' : undefined,
365
+ ].filter((f): f is string => f !== undefined);
366
+ if (sessionLifecycleFlags.length > 1) {
367
+ errors.push(
368
+ `Conflicting session lifecycle flags: ${sessionLifecycleFlags.join(' and ')}. Use only one of --continue, --resume, or --fork.`,
369
+ );
370
+ }
371
+
360
372
  return {
361
373
  binary,
362
374
  command,
@@ -365,5 +377,6 @@ export function parseGoodVibesCli(
365
377
  positionals,
366
378
  flags,
367
379
  errors,
380
+ warnings,
368
381
  };
369
382
  }
@@ -1,5 +1,5 @@
1
1
  import { mkdirSync } from 'node:fs';
2
- import type { CliCommandRuntime } from './management.ts';
2
+ import type { CliCommandRuntime } from './types.ts';
3
3
  import { buildCliServicePosture, createPlatformServiceManager, formatCliServicePosture, getServiceStateRoot } from './service-posture.ts';
4
4
  import type { CliCommandOutput } from './types.ts';
5
5
 
@@ -8,7 +8,7 @@ import {
8
8
  import { enableFeatureFlags, getMissingSurfaceFeatureFlags, getServerSurfaceFeatureFlags } from '../runtime/surface-feature-flags.ts';
9
9
  import { resolveRuntimeEndpointBinding } from './endpoints.ts';
10
10
  import { classifyBindPosture, isNetworkFacing } from './network-posture.ts';
11
- import type { CliCommandRuntime } from './management.ts';
11
+ import type { CliCommandRuntime } from './types.ts';
12
12
  import {
13
13
  applyTargetEndpointFlagsOrDefault,
14
14
  enableEndpointLanDefault,
@@ -1,32 +1,94 @@
1
1
  import type { CommandContext, CommandRegistry } from '../input/command-registry.ts';
2
2
  import type { InputHandler } from '../input/handler.ts';
3
- import { readOnboardingCheckMarker } from '../runtime/onboarding/index.ts';
3
+ import { hasResumableWizardProgress, readOnboardingCheckMarker, readWizardProgress } from '../runtime/onboarding/index.ts';
4
+ import { readLastSessionPointer } from '@/runtime/index.ts';
4
5
  import type { GoodVibesCliParseResult } from './types.ts';
5
6
 
7
+ export type TuiStartupShellPaths = Parameters<typeof readOnboardingCheckMarker>[0] & {
8
+ readonly workingDirectory: string;
9
+ readonly homeDirectory: string;
10
+ };
11
+
6
12
  export function applyInitialTuiCliState(options: {
7
13
  readonly cli: GoodVibesCliParseResult;
8
14
  readonly input: InputHandler;
9
15
  readonly commandRegistry: CommandRegistry;
10
16
  readonly commandContext: CommandContext;
11
- readonly shellPaths: Parameters<typeof readOnboardingCheckMarker>[0];
17
+ readonly shellPaths: TuiStartupShellPaths;
12
18
  readonly render: () => void;
13
- }): void {
19
+ }): Promise<void> | undefined {
14
20
  const { cli, input, commandRegistry, commandContext, shellPaths, render } = options;
15
21
  const globalOnboardingMarker = readOnboardingCheckMarker(shellPaths, 'user');
22
+
23
+ // Seeded prompt is always applied synchronously, regardless of session branch.
24
+ const seededPrompt = cli.flags.prompt ?? (cli.rawCommand === undefined && cli.positionals.length > 0 ? cli.positionals.join(' ') : undefined);
25
+ if (seededPrompt) {
26
+ input.prompt = seededPrompt;
27
+ input.cursorPos = seededPrompt.length;
28
+ }
29
+
16
30
  if (cli.command === 'onboarding') {
17
31
  input.openOnboardingWizard({ mode: 'edit', reset: true });
18
32
  } else if (cli.command === 'sessions' && cli.commandArgs[0] === 'resume') {
19
33
  const target = cli.commandArgs.slice(1).join(' ').trim();
20
34
  if (target) {
21
- void commandRegistry.execute('session', ['resume', target], commandContext).then(() => render());
35
+ return commandRegistry.execute('session', ['resume', target], commandContext).then(() => render());
36
+ }
37
+ } else if (cli.flags.continueLast) {
38
+ // --continue: resume the last session tracked by the pointer file
39
+ const lastId = readLastSessionPointer({
40
+ workingDirectory: shellPaths.workingDirectory,
41
+ homeDirectory: shellPaths.homeDirectory,
42
+ surfaceRoot: 'tui',
43
+ });
44
+ if (lastId) {
45
+ return commandRegistry.execute('session', ['resume', lastId], commandContext).then(() => render());
46
+ }
47
+ } else if (cli.flags.resume !== undefined) {
48
+ // --resume [id]: explicit id dispatches directly; bare form (sentinel 'latest') resolves via pointer
49
+ if (cli.flags.resume !== 'latest') {
50
+ return commandRegistry.execute('session', ['resume', cli.flags.resume], commandContext).then(() => render());
51
+ } else {
52
+ const lastId = readLastSessionPointer({
53
+ workingDirectory: shellPaths.workingDirectory,
54
+ homeDirectory: shellPaths.homeDirectory,
55
+ surfaceRoot: 'tui',
56
+ });
57
+ if (lastId) {
58
+ return commandRegistry.execute('session', ['resume', lastId], commandContext).then(() => render());
59
+ }
60
+ }
61
+ } else if (cli.flags.fork !== undefined) {
62
+ // --fork [id]: fork specific session (true = bare fork-current; string = explicit id to resume then fork)
63
+ if (cli.flags.fork === true) {
64
+ // Bare --fork: fork the current session without a prior resume
65
+ return commandRegistry.execute('session', ['fork'], commandContext).then(() => render());
66
+ } else {
67
+ // Explicit id: resume the named session first, then fork
68
+ return commandRegistry.execute('session', ['resume', cli.flags.fork], commandContext)
69
+ .then(() => commandRegistry.execute('session', ['fork'], commandContext))
70
+ .then(() => render());
22
71
  }
23
72
  } else if (!globalOnboardingMarker.exists) {
24
73
  input.openOnboardingWizard({ mode: 'new', reset: true });
25
- }
26
-
27
- const seededPrompt = cli.flags.prompt ?? (cli.rawCommand === undefined && cli.positionals.length > 0 ? cli.positionals.join(' ') : undefined);
28
- if (seededPrompt) {
29
- input.prompt = seededPrompt;
30
- input.cursorPos = seededPrompt.length;
74
+ } else {
75
+ // User has completed onboarding before but left a wizard session in progress.
76
+ // Reopen the wizard at the last saved step so they can continue or dismiss.
77
+ const progressState = readWizardProgress(shellPaths);
78
+ if (hasResumableWizardProgress(shellPaths, { state: progressState })) {
79
+ const { payload } = progressState;
80
+ if (payload !== null) {
81
+ input.openOnboardingWizard({
82
+ mode: payload.mode,
83
+ reset: true,
84
+ preload: (wizard) => {
85
+ wizard.setStep(payload.stepIndex);
86
+ for (const [fieldId, value] of payload.toggleState) wizard.toggleState.set(fieldId, value);
87
+ for (const [fieldId, value] of payload.radioState) wizard.radioState.set(fieldId, value);
88
+ for (const [fieldId, value] of payload.textState) wizard.textState.set(fieldId, value);
89
+ },
90
+ });
91
+ }
92
+ }
31
93
  }
32
94
  }
package/src/cli/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { ConfigManager } from '../config/index.ts';
2
+
1
3
  export type GoodVibesCliCommand =
2
4
  | 'tui'
3
5
  | 'run'
@@ -53,9 +55,9 @@ export interface GoodVibesCliFlags {
53
55
  readonly continueLast: boolean;
54
56
  readonly resume: string | undefined;
55
57
  readonly session: string | undefined;
56
- readonly fork: boolean;
57
- readonly rawOutput: boolean;
58
- readonly acceptRawOutputRisk: boolean;
58
+ readonly fork: string | true | undefined;
59
+ readonly yes: boolean;
60
+ readonly nonInteractive: boolean;
59
61
  }
60
62
 
61
63
  export interface GoodVibesCliParseResult {
@@ -66,4 +68,13 @@ export interface GoodVibesCliParseResult {
66
68
  readonly positionals: readonly string[];
67
69
  readonly flags: GoodVibesCliFlags;
68
70
  readonly errors: readonly string[];
71
+ /** Deprecation and soft-warning messages (non-fatal). Callers should surface these to users. */
72
+ readonly warnings: readonly string[];
73
+ }
74
+
75
+ export interface CliCommandRuntime {
76
+ readonly cli: GoodVibesCliParseResult;
77
+ readonly configManager: ConfigManager;
78
+ readonly workingDirectory: string;
79
+ readonly homeDirectory: string;
69
80
  }
package/src/cli-flags.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Compatibility wrapper for older imports. New CLI code lives in src/cli.
2
2
  export type { GoodVibesCliFlags as CliFlags } from './cli/types.ts';
3
3
  export {
4
+ applyRuntimeConfigDefault,
4
5
  applyRuntimeConfigOverrides,
5
6
  applyRuntimeConfigValue,
6
7
  applyRuntimeCommandEndpointFlagOverrides,
@@ -0,0 +1,70 @@
1
+ import { closeSync, fsyncSync, mkdirSync, openSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { dirname } from 'node:path';
4
+
5
+ export interface AtomicWriteOptions {
6
+ /**
7
+ * File permission mode for the written file. Defaults to 0o600 (owner read/write only).
8
+ */
9
+ readonly mode?: number;
10
+ /**
11
+ * When true, creates parent directories if they do not exist. Defaults to false.
12
+ */
13
+ readonly mkdirp?: boolean;
14
+ }
15
+
16
+ /**
17
+ * Writes `data` to `path` atomically: writes to a sibling temp file, fsyncs
18
+ * it, then renames it into place. On POSIX, rename(2) is atomic so readers
19
+ * always see either the old file or the new file — never a partial write.
20
+ *
21
+ * If the write or fsync fails, the temp file is cleaned up before rethrowing.
22
+ *
23
+ * @param path Destination file path.
24
+ * @param data UTF-8 string content to write.
25
+ * @param opts Optional mode and mkdirp flags.
26
+ */
27
+ export function atomicWriteFileSync(
28
+ path: string,
29
+ data: string,
30
+ opts: AtomicWriteOptions = {},
31
+ ): void {
32
+ const mode = opts.mode ?? 0o600;
33
+
34
+ if (opts.mkdirp) {
35
+ mkdirSync(dirname(path), { recursive: true });
36
+ }
37
+
38
+ const tmp = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
39
+
40
+ try {
41
+ writeFileSync(tmp, data, { mode });
42
+
43
+ // fsync the temp file to flush OS write buffers before rename.
44
+ const fd = openSync(tmp, 'r+');
45
+ try {
46
+ fsyncSync(fd);
47
+ } finally {
48
+ closeSync(fd);
49
+ }
50
+
51
+ renameSync(tmp, path);
52
+
53
+ // fsync the parent directory to ensure the renamed directory entry is
54
+ // flushed. Without this, a hard crash immediately after rename could lose
55
+ // the entry on some filesystems (ext3/ext4 with data=ordered, etc.).
56
+ const dirFd = openSync(dirname(path), 'r');
57
+ try {
58
+ fsyncSync(dirFd);
59
+ } finally {
60
+ closeSync(dirFd);
61
+ }
62
+ } catch (err) {
63
+ try {
64
+ unlinkSync(tmp);
65
+ } catch {
66
+ // Best-effort cleanup — original error takes priority.
67
+ }
68
+ throw err;
69
+ }
70
+ }
@@ -117,6 +117,8 @@ const KNOWN_DYNAMIC_KEYS = [
117
117
  /^featureFlags(?:\.|$)/,
118
118
  /^notifications\.webhookUrls$/,
119
119
  /^wrfc\.gates$/,
120
+ // TUI-bridged setting awaiting SDK schema registration (handoff Item 5b)
121
+ /^tts\.speed$/,
120
122
  ];
121
123
 
122
124
  export const GOODVIBES_ALLOWED_WRITE_ROOTS = ['tui/', 'daemon/'] as const;
@@ -0,0 +1,115 @@
1
+ import { existsSync, readFileSync, renameSync } from 'node:fs';
2
+
3
+ /**
4
+ * A migration function that transforms data from version N to N+1.
5
+ * Receives the raw parsed object and must return the upgraded object.
6
+ */
7
+ export type VersionMigration = (data: Record<string, unknown>) => Record<string, unknown>;
8
+
9
+ export interface ReadVersionedOptions {
10
+ /**
11
+ * The version number this reader expects. When the file version equals
12
+ * `currentVersion`, no migrations are run. When it is lower, migrations
13
+ * are applied stepwise. When it is higher or unrecognised, `onUnknown`
14
+ * behaviour fires.
15
+ */
16
+ readonly currentVersion: number;
17
+
18
+ /**
19
+ * Optional stepwise migrations indexed by the FROM version.
20
+ * `migrations[1]` upgrades version-1 data to version-2 data.
21
+ * Applied in ascending order until `currentVersion` is reached.
22
+ */
23
+ readonly migrations?: Readonly<Record<number, VersionMigration>>;
24
+
25
+ /**
26
+ * What to do when the file version is unrecognised (higher than
27
+ * `currentVersion` or missing/non-numeric).
28
+ *
29
+ * `'quarantine'` — rename the file to `<path>.unrecognized` and return null.
30
+ */
31
+ readonly onUnknown: 'quarantine';
32
+ }
33
+
34
+ /**
35
+ * Migration-aware, quarantine-on-failure versioned file reader.
36
+ *
37
+ * Parse flow:
38
+ * 1. If the file does not exist → return null.
39
+ * 2. If JSON is corrupt → quarantine to `<path>.unrecognized`, return null.
40
+ * 3. If the version field is missing or higher than currentVersion →
41
+ * quarantine, return null.
42
+ * 4. If the version is lower than currentVersion → apply stepwise migrations.
43
+ * If no migration exists for a version gap, or a migration throws,
44
+ * quarantine and return null.
45
+ * 5. Return the (possibly migrated) object. Callers are responsible for
46
+ * narrowing the returned value — this helper handles versioning and
47
+ * corruption only, not schema validation.
48
+ */
49
+ export function readVersioned<T extends { version: number }>(
50
+ path: string,
51
+ options: ReadVersionedOptions,
52
+ ): T | null {
53
+ if (!existsSync(path)) return null;
54
+
55
+ let raw: unknown;
56
+ try {
57
+ raw = JSON.parse(readFileSync(path, 'utf-8'));
58
+ } catch {
59
+ quarantine(path);
60
+ return null;
61
+ }
62
+
63
+ if (!isPlainObject(raw)) {
64
+ quarantine(path);
65
+ return null;
66
+ }
67
+
68
+ const fileVersion = raw['version'];
69
+ if (typeof fileVersion !== 'number' || !Number.isFinite(fileVersion)) {
70
+ quarantine(path);
71
+ return null;
72
+ }
73
+
74
+ if (fileVersion > options.currentVersion) {
75
+ // Produced by a newer process — quarantine rather than corrupt.
76
+ quarantine(path);
77
+ return null;
78
+ }
79
+
80
+ let data: Record<string, unknown> = raw;
81
+
82
+ if (fileVersion < options.currentVersion) {
83
+ const migrations = options.migrations ?? {};
84
+ for (let v = fileVersion; v < options.currentVersion; v++) {
85
+ const migrate = migrations[v];
86
+ if (!migrate) {
87
+ // No migration path for this version gap — quarantine.
88
+ quarantine(path);
89
+ return null;
90
+ }
91
+ try {
92
+ data = migrate(data);
93
+ } catch {
94
+ quarantine(path);
95
+ return null;
96
+ }
97
+ }
98
+ }
99
+
100
+ return data as T;
101
+ }
102
+
103
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
104
+
105
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
106
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
107
+ }
108
+
109
+ function quarantine(path: string): void {
110
+ try {
111
+ renameSync(path, `${path}.unrecognized`);
112
+ } catch {
113
+ // Best-effort — if rename fails (e.g. race), proceed silently.
114
+ }
115
+ }