@pellux/goodvibes-tui 0.19.64 → 0.19.65

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
@@ -8,6 +8,26 @@ All notable changes to GoodVibes TUI.
8
8
 
9
9
  ---
10
10
 
11
+ ## [0.19.65] — 2026-05-05
12
+
13
+ ### Fixed
14
+ - Made `/config service` changes apply to the OS service layer instead of only writing `~/.goodvibes/tui/settings.json`.
15
+ - Enabling `service.autostart` now enables service mode if needed, writes the platform service definition, reloads systemd user units when applicable, and starts/enables the service.
16
+ - Disabling `service.autostart` or `service.enabled` now disables/removes the platform service definition instead of leaving stale OS autostart state behind.
17
+ - Service definition changes such as restart-on-failure, platform, service name, or log path now rewrite and restart the OS service when service mode and autostart are active.
18
+ - Added a visible `/config` status notice for service install/start/disable/update results after a setting change.
19
+
20
+ ### Verified
21
+ - `bun test src/test/shell/service-settings-sync.test.ts src/test/input/settings-modal-network.test.ts`
22
+ - `bun run test`
23
+ - `bunx tsc --noEmit`
24
+ - `bun run build:prod`
25
+ - `bun run smoke:tui`
26
+ - `bun run smoke:daemon`
27
+ - `git diff --check`
28
+
29
+ ---
30
+
11
31
  ## [0.19.64] — 2026-05-05
12
32
 
13
33
  ### Fixed
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-0.19.64-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-0.19.65-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
6
6
 
7
7
  A terminal-native AI coding, operations, automation, knowledge, and integration console with a typed runtime, omnichannel surfaces, structured memory/knowledge, and a raw ANSI renderer.
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.19.64",
3
+ "version": "0.19.65",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -42,6 +42,22 @@ import {
42
42
  type SubscriptionEntry,
43
43
  } from './settings-modal-types.ts';
44
44
 
45
+ export interface SettingsModalChange {
46
+ readonly key: ConfigKey;
47
+ readonly previousValue: unknown;
48
+ readonly value: unknown;
49
+ }
50
+
51
+ export interface SettingsModalChangeResult {
52
+ readonly message?: string;
53
+ }
54
+
55
+ export type SettingsModalChangeHandler = (change: SettingsModalChange) => SettingsModalChangeResult | void;
56
+
57
+ export interface SettingsModalOpenOptions {
58
+ readonly onSettingApplied?: SettingsModalChangeHandler;
59
+ }
60
+
45
61
  export {
46
62
  SETTINGS_CATEGORIES,
47
63
  type FlagEntry,
@@ -104,6 +120,7 @@ export class SettingsModal {
104
120
  * Cleared on next open() or close().
105
121
  */
106
122
  public lastSaveTriggeredRestart: 'control-plane' | 'http-listener' | 'web' | null = null;
123
+ public lastSettingEffectMessage: string | null = null;
107
124
 
108
125
  private configManager: ConfigManager | null = null;
109
126
  private secretsManager: SettingsSecretsManager | null = null;
@@ -111,6 +128,7 @@ export class SettingsModal {
111
128
  private mcpRegistry: McpRegistry | null = null;
112
129
  private subscriptionManager: SubscriptionManager | null = null;
113
130
  private serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'> | null = null;
131
+ private onSettingApplied: SettingsModalChangeHandler | null = null;
114
132
 
115
133
  /**
116
134
  * Open the modal, loading current config values from configManager.
@@ -125,6 +143,7 @@ export class SettingsModal {
125
143
  serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>,
126
144
  mcpRegistry?: McpRegistry,
127
145
  secretsManager?: SettingsSecretsManager,
146
+ options?: SettingsModalOpenOptions,
128
147
  ): void {
129
148
  this.configManager = configManager;
130
149
  this.secretsManager = secretsManager ?? null;
@@ -132,6 +151,7 @@ export class SettingsModal {
132
151
  this.subscriptionManager = subscriptionManager;
133
152
  this.serviceRegistry = serviceRegistry;
134
153
  this.mcpRegistry = mcpRegistry ?? null;
154
+ this.onSettingApplied = options?.onSettingApplied ?? null;
135
155
  this._loadGroups(configManager);
136
156
  this._loadFlagEntries();
137
157
  this._loadMcpEntries();
@@ -147,6 +167,7 @@ export class SettingsModal {
147
167
  this.mcpAllowAllConfirmationTarget = null;
148
168
  this.subscriptionLogoutConfirmationTarget = null;
149
169
  this.lastSaveTriggeredRestart = null;
170
+ this.lastSettingEffectMessage = null;
150
171
  this.active = true;
151
172
  }
152
173
 
@@ -160,8 +181,10 @@ export class SettingsModal {
160
181
  this.mcpAllowAllConfirmationTarget = null;
161
182
  this.subscriptionLogoutConfirmationTarget = null;
162
183
  this.lastSaveTriggeredRestart = null;
184
+ this.lastSettingEffectMessage = null;
163
185
  this.serviceRegistry = null;
164
186
  this.secretsManager = null;
187
+ this.onSettingApplied = null;
165
188
  this.focusPane = 'settings';
166
189
  }
167
190
 
@@ -715,6 +738,16 @@ export class SettingsModal {
715
738
  return items;
716
739
  }
717
740
 
741
+ private _refreshAllEntries(): void {
742
+ if (!this.configManager) return;
743
+ for (const entries of this.groups.values()) {
744
+ for (const entry of entries) {
745
+ entry.currentValue = this.configManager.get(entry.setting.key as ConfigKey);
746
+ entry.isDefault = entry.currentValue === entry.setting.default;
747
+ }
748
+ }
749
+ }
750
+
718
751
  private _setValue(key: ConfigKey, value: unknown): void {
719
752
  if (!this.configManager) return;
720
753
  // Diff previous value before writing — avoids false restart notices on no-op saves
@@ -744,8 +777,14 @@ export class SettingsModal {
744
777
  entry.isDefault = entry.currentValue === entry.setting.default;
745
778
  }
746
779
  }
780
+ if (previousValue !== value && this.onSettingApplied) {
781
+ const result = this.onSettingApplied({ key, previousValue, value });
782
+ this.lastSettingEffectMessage = result?.message ?? null;
783
+ this._refreshAllEntries();
784
+ }
747
785
  } catch (e) {
748
786
  logger.error('SettingsModal: failed to set config value', { key, error: summarizeError(e) });
787
+ this.lastSettingEffectMessage = `Save failed: ${summarizeError(e)}`;
749
788
  }
750
789
  }
751
790
 
package/src/main.ts CHANGED
@@ -678,6 +678,8 @@ async function main() {
678
678
  subscriptionManager,
679
679
  secretsManager,
680
680
  serviceRegistry: ctx.services.serviceRegistry,
681
+ workingDirectory: workingDir,
682
+ homeDirectory,
681
683
  getConfiguredProviderIds: ctx._getConfiguredProviderIds,
682
684
  getPinned: ctx._getPinned,
683
685
  render,
@@ -590,7 +590,11 @@ export function renderSettingsModal(
590
590
  const header = contentLine(safeWidth, PALETTE.footerBg);
591
591
  drawVertical(header, dividerX, PALETTE.footerBg);
592
592
  writeText(header, leftStart + 1, leftWidth - 2, 'Categories', { fg: PALETTE.subtitle, bold: true, bg: PALETTE.footerBg });
593
- const headerText = `${CATEGORY_LABELS[modal.currentCategory]} (${categoryItemCount(modal, modal.currentCategory)})${modal.lastSaveTriggeredRestart ? ` · Restarting ${modal.lastSaveTriggeredRestart}` : ''}`;
593
+ const notices = [
594
+ ...(modal.lastSaveTriggeredRestart ? [`Restarting ${modal.lastSaveTriggeredRestart}`] : []),
595
+ ...(modal.lastSettingEffectMessage ? [modal.lastSettingEffectMessage] : []),
596
+ ];
597
+ const headerText = `${CATEGORY_LABELS[modal.currentCategory]} (${categoryItemCount(modal, modal.currentCategory)})${notices.length > 0 ? ` · ${notices.join(' · ')}` : ''}`;
594
598
  writeText(header, centerStart + 1, centerWidth - 2, headerText, { fg: PALETTE.subtitle, bold: true, bg: PALETTE.footerBg });
595
599
  lines.push(header);
596
600
 
@@ -0,0 +1,273 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+ import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
4
+ import type { ManagedServiceStatus } from '@pellux/goodvibes-sdk/platform/daemon';
5
+ import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
+ import {
7
+ createPlatformServiceManager,
8
+ getServiceStateRoot,
9
+ type CliServiceRuntime,
10
+ } from '../cli/service-posture.ts';
11
+
12
+ type ManagedServiceAction = 'install' | 'uninstall' | 'start' | 'stop' | 'restart' | 'status';
13
+
14
+ export interface ServiceManagerLike {
15
+ status(): ManagedServiceStatus;
16
+ install(): ManagedServiceStatus;
17
+ uninstall(): ManagedServiceStatus;
18
+ start(): ManagedServiceStatus;
19
+ stop(): ManagedServiceStatus;
20
+ restart(): ManagedServiceStatus;
21
+ }
22
+
23
+ export interface CommandResult {
24
+ readonly status: number | null;
25
+ readonly stdout?: string;
26
+ readonly stderr?: string;
27
+ }
28
+
29
+ export interface ServiceSettingsSyncChange {
30
+ readonly key: ConfigKey;
31
+ readonly previousValue: unknown;
32
+ readonly value: unknown;
33
+ }
34
+
35
+ export interface ServiceSettingsSyncResult {
36
+ readonly handled: boolean;
37
+ readonly action?: ManagedServiceAction | 'install-start' | 'disable';
38
+ readonly status?: ManagedServiceStatus;
39
+ readonly message?: string;
40
+ readonly error?: string;
41
+ }
42
+
43
+ export interface ServiceSettingsSyncOptions {
44
+ readonly createManager?: (runtime: CliServiceRuntime) => ServiceManagerLike;
45
+ readonly runCommand?: (command: string, args: readonly string[]) => CommandResult;
46
+ readonly mkdir?: typeof mkdirSync;
47
+ }
48
+
49
+ const SERVICE_DEFINITION_KEYS = new Set<ConfigKey>([
50
+ 'service.restartOnFailure',
51
+ 'service.platform',
52
+ 'service.serviceName',
53
+ 'service.logPath',
54
+ ] as ConfigKey[]);
55
+
56
+ function runCommand(command: string, args: readonly string[], options: ServiceSettingsSyncOptions): CommandResult {
57
+ if (options.runCommand) return options.runCommand(command, args);
58
+ return spawnSync(command, [...args], { stdio: 'pipe', encoding: 'utf-8' });
59
+ }
60
+
61
+ function commandError(result: CommandResult): string | null {
62
+ if ((result.status ?? 1) === 0) return null;
63
+ return ((result.stderr ?? '') || (result.stdout ?? '') || `command exited with ${result.status}`).trim();
64
+ }
65
+
66
+ function serviceName(runtime: CliServiceRuntime, fallback = 'goodvibes'): string {
67
+ return String(runtime.configManager.get('service.serviceName') ?? fallback).trim() || fallback;
68
+ }
69
+
70
+ function runSystemd(runtime: CliServiceRuntime, args: readonly string[], options: ServiceSettingsSyncOptions): string | null {
71
+ const result = runCommand('systemctl', ['--user', ...args], options);
72
+ return commandError(result);
73
+ }
74
+
75
+ function reloadSystemdIfNeeded(
76
+ runtime: CliServiceRuntime,
77
+ status: ManagedServiceStatus,
78
+ options: ServiceSettingsSyncOptions,
79
+ ): string | null {
80
+ if (status.platform !== 'systemd') return null;
81
+ return runSystemd(runtime, ['daemon-reload'], options);
82
+ }
83
+
84
+ function disableSystemService(
85
+ runtime: CliServiceRuntime,
86
+ manager: ServiceManagerLike,
87
+ options: ServiceSettingsSyncOptions,
88
+ ): ServiceSettingsSyncResult {
89
+ const before = manager.status();
90
+ let disableError: string | null = null;
91
+ if (before.platform === 'systemd') {
92
+ disableError = before.installed || before.running
93
+ ? runSystemd(runtime, ['disable', '--now', `${serviceName(runtime)}.service`], options)
94
+ : null;
95
+ if (disableError) {
96
+ logger.warn('Settings service sync: systemd disable failed', { error: disableError });
97
+ }
98
+ } else if (before.running || before.installed) {
99
+ const stopped = manager.stop();
100
+ if (stopped.actionError) {
101
+ return {
102
+ handled: true,
103
+ action: 'stop',
104
+ status: stopped,
105
+ message: `Service disable failed: ${stopped.actionError}`,
106
+ error: stopped.actionError,
107
+ };
108
+ }
109
+ }
110
+
111
+ const uninstalled = manager.uninstall();
112
+ const reloadError = reloadSystemdIfNeeded(runtime, uninstalled, options);
113
+ const error = uninstalled.actionError ?? reloadError ?? disableError ?? undefined;
114
+ return {
115
+ handled: true,
116
+ action: 'disable',
117
+ status: uninstalled,
118
+ message: error ? `Service disable failed: ${error}` : 'OS service disabled',
119
+ ...(error ? { error } : {}),
120
+ };
121
+ }
122
+
123
+ function installAndStartSystemService(
124
+ runtime: CliServiceRuntime,
125
+ manager: ServiceManagerLike,
126
+ options: ServiceSettingsSyncOptions,
127
+ ): ServiceSettingsSyncResult {
128
+ (options.mkdir ?? mkdirSync)(getServiceStateRoot(runtime), { recursive: true });
129
+ const installed = manager.install();
130
+ if (installed.actionError) {
131
+ return {
132
+ handled: true,
133
+ action: 'install',
134
+ status: installed,
135
+ message: `Service install failed: ${installed.actionError}`,
136
+ error: installed.actionError,
137
+ };
138
+ }
139
+
140
+ const reloadError = reloadSystemdIfNeeded(runtime, installed, options);
141
+ if (reloadError) {
142
+ return {
143
+ handled: true,
144
+ action: 'install',
145
+ status: installed,
146
+ message: `Service install failed: ${reloadError}`,
147
+ error: reloadError,
148
+ };
149
+ }
150
+
151
+ const started = manager.start();
152
+ const error = started.actionError ?? undefined;
153
+ return {
154
+ handled: true,
155
+ action: 'install-start',
156
+ status: started,
157
+ message: error ? `Service start failed: ${error}` : 'OS service installed and started',
158
+ ...(error ? { error } : {}),
159
+ };
160
+ }
161
+
162
+ function refreshInstalledSystemService(
163
+ runtime: CliServiceRuntime,
164
+ manager: ServiceManagerLike,
165
+ options: ServiceSettingsSyncOptions,
166
+ ): ServiceSettingsSyncResult {
167
+ const before = manager.status();
168
+ if (!before.installed && runtime.configManager.get('service.autostart') !== true) {
169
+ return {
170
+ handled: true,
171
+ action: 'status',
172
+ status: before,
173
+ message: 'Service setting saved',
174
+ };
175
+ }
176
+
177
+ const installed = manager.install();
178
+ if (installed.actionError) {
179
+ return {
180
+ handled: true,
181
+ action: 'install',
182
+ status: installed,
183
+ message: `Service update failed: ${installed.actionError}`,
184
+ error: installed.actionError,
185
+ };
186
+ }
187
+
188
+ const reloadError = reloadSystemdIfNeeded(runtime, installed, options);
189
+ if (reloadError) {
190
+ return {
191
+ handled: true,
192
+ action: 'install',
193
+ status: installed,
194
+ message: `Service update failed: ${reloadError}`,
195
+ error: reloadError,
196
+ };
197
+ }
198
+
199
+ const next = before.running ? manager.restart() : manager.start();
200
+ const error = next.actionError ?? undefined;
201
+ return {
202
+ handled: true,
203
+ action: before.running ? 'restart' : 'start',
204
+ status: next,
205
+ message: error ? `Service update failed: ${error}` : 'OS service updated',
206
+ ...(error ? { error } : {}),
207
+ };
208
+ }
209
+
210
+ export function syncServiceSettingToPlatform(
211
+ runtime: CliServiceRuntime,
212
+ change: ServiceSettingsSyncChange,
213
+ options: ServiceSettingsSyncOptions = {},
214
+ ): ServiceSettingsSyncResult {
215
+ if (!String(change.key).startsWith('service.')) return { handled: false };
216
+ if (change.previousValue === change.value) return { handled: true, message: 'Service setting unchanged' };
217
+
218
+ const manager = options.createManager?.(runtime) ?? createPlatformServiceManager(runtime);
219
+
220
+ try {
221
+ if (change.key === 'service.autostart') {
222
+ if (change.value === true) {
223
+ if (runtime.configManager.get('service.enabled') !== true) {
224
+ runtime.configManager.setDynamic('service.enabled', true);
225
+ }
226
+ return installAndStartSystemService(runtime, manager, options);
227
+ }
228
+ return disableSystemService(runtime, manager, options);
229
+ }
230
+
231
+ if (change.key === 'service.enabled') {
232
+ if (change.value === false) {
233
+ if (runtime.configManager.get('service.autostart') === true) {
234
+ runtime.configManager.setDynamic('service.autostart', false);
235
+ }
236
+ return disableSystemService(runtime, manager, options);
237
+ }
238
+ if (runtime.configManager.get('service.autostart') === true) {
239
+ return installAndStartSystemService(runtime, manager, options);
240
+ }
241
+ return {
242
+ handled: true,
243
+ action: 'status',
244
+ status: manager.status(),
245
+ message: 'Service mode saved; enable autostart to install the OS service',
246
+ };
247
+ }
248
+
249
+ if (SERVICE_DEFINITION_KEYS.has(change.key)) {
250
+ if (runtime.configManager.get('service.enabled') === true && runtime.configManager.get('service.autostart') === true) {
251
+ return refreshInstalledSystemService(runtime, manager, options);
252
+ }
253
+ return {
254
+ handled: true,
255
+ action: 'status',
256
+ status: manager.status(),
257
+ message: 'Service setting saved; enable autostart to install the OS service',
258
+ };
259
+ }
260
+ } catch (error) {
261
+ const summarized = summarizeError(error);
262
+ logger.error('Settings service sync failed', { key: change.key, error: summarized });
263
+ return {
264
+ handled: true,
265
+ action: 'status',
266
+ status: manager.status(),
267
+ message: `Service sync failed: ${summarized}`,
268
+ error: summarized,
269
+ };
270
+ }
271
+
272
+ return { handled: false };
273
+ }
@@ -12,6 +12,7 @@ import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config'
12
12
  import type { SecretsManager } from '@pellux/goodvibes-sdk/platform/config';
13
13
  import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
14
14
  import type { ModelPickerTargetInfo } from '../input/model-picker.ts';
15
+ import { syncServiceSettingToPlatform } from './service-settings-sync.ts';
15
16
 
16
17
  type WireShellUiOpenersOptions = {
17
18
  commandContext: CommandContext;
@@ -26,6 +27,8 @@ type WireShellUiOpenersOptions = {
26
27
  subscriptionManager: SubscriptionManager;
27
28
  secretsManager?: Pick<SecretsManager, 'delete' | 'get' | 'set'>;
28
29
  serviceRegistry: Pick<ServiceInspectionQuery, 'getAll'>;
30
+ workingDirectory: string;
31
+ homeDirectory: string;
29
32
  getConfiguredProviderIds: () => string[];
30
33
  getPinned: () => Promise<string[]>;
31
34
  render: () => void;
@@ -89,6 +92,8 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
89
92
  subscriptionManager,
90
93
  secretsManager,
91
94
  serviceRegistry,
95
+ workingDirectory,
96
+ homeDirectory,
92
97
  getConfiguredProviderIds,
93
98
  getPinned,
94
99
  render,
@@ -264,7 +269,12 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
264
269
 
265
270
  commandContext.openSettingsModal = (target?: string) => {
266
271
  input.modalOpened('settings');
267
- input.settingsModal.open(configManager, featureFlags, subscriptionManager, serviceRegistry, mcpRegistry, secretsManager);
272
+ input.settingsModal.open(configManager, featureFlags, subscriptionManager, serviceRegistry, mcpRegistry, secretsManager, {
273
+ onSettingApplied: (change) => syncServiceSettingToPlatform(
274
+ { configManager, workingDirectory, homeDirectory },
275
+ change,
276
+ ),
277
+ });
268
278
  input.settingsModal.selectTarget(target);
269
279
  render();
270
280
  };
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '0.19.64';
9
+ let _version = '0.19.65';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;