@pellux/goodvibes-tui 1.18.0 → 1.19.1

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.
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import type { CommandRegistry } from '../command-registry.ts';
4
4
  import { requirePanelManager, requireShellPaths } from './runtime-services.ts';
5
+ import { createVoiceProvisionGateway, renderVoiceProvision, runVoiceSetupWithProgress, VOICE_SETUP_ANNOUNCEMENT } from '../../core/voice-provision-gateway.ts';
5
6
 
6
7
  interface VoiceBundle {
7
8
  readonly version: 1;
@@ -222,11 +223,31 @@ export function registerExperienceRuntimeCommands(registry: CommandRegistry): vo
222
223
 
223
224
  registry.register({
224
225
  name: 'voice',
225
- description: 'Review or toggle always-speak mode (same switch as /tts on|off) and package portable voice metadata',
226
- usage: '[review|enable|disable|bundle export <path>|bundle inspect <path>]',
227
- handler(args, ctx) {
226
+ description: 'Review or toggle always-speak mode, provision the managed local voice runtime (setup/status), and package portable voice metadata',
227
+ usage: '[review|status|setup|enable|disable|bundle export <path>|bundle inspect <path>]',
228
+ async handler(args, ctx) {
228
229
  const shellPaths = requireShellPaths(ctx);
229
230
  const sub = (args[0] ?? 'review').toLowerCase();
231
+ if (sub === 'status' || sub === 'setup') {
232
+ // The one-act managed install returns a final receipt (no per-phase
233
+ // streaming over the verb), so the announcement block prints first and
234
+ // the receipt/failure block follows once the (possibly long,
235
+ // multi-hundred-MB) install resolves.
236
+ const resolution = createVoiceProvisionGateway({
237
+ configManager: ctx.platform.configManager,
238
+ // Lazy: the daemon-disabled / no-base-URL refusals resolve before shell paths.
239
+ homeDirectory: () => requireShellPaths(ctx).homeDirectory,
240
+ });
241
+ if (sub === 'setup') {
242
+ // Print the up-front announcement, then drive the install with live
243
+ // per-component progress polled from voice.local.status.
244
+ if (resolution.available) ctx.print(VOICE_SETUP_ANNOUNCEMENT);
245
+ await runVoiceSetupWithProgress(resolution, { print: (block) => ctx.print(block) });
246
+ return;
247
+ }
248
+ ctx.print(await renderVoiceProvision(sub, resolution));
249
+ return;
250
+ }
230
251
  if (sub === 'review') {
231
252
  const enabled = Boolean(ctx.platform.configManager.get('ui.voiceEnabled') ?? false);
232
253
  ctx.print([
@@ -273,7 +294,7 @@ export function registerExperienceRuntimeCommands(registry: CommandRegistry): vo
273
294
  return;
274
295
  }
275
296
  }
276
- ctx.print('Usage: /voice [review|enable|disable|bundle export <path>|bundle inspect <path>]');
297
+ ctx.print('Usage: /voice [review|status|setup|enable|disable|bundle export <path>|bundle inspect <path>]');
277
298
  },
278
299
  });
279
300
  }
@@ -21,6 +21,8 @@ import {
21
21
  } from './runtime-services.ts';
22
22
  import { getOperatorRpc, describeOperatorRpcError } from './operator-rpc.ts';
23
23
  import { formatQuotaSnapshotLine, renderMetricMap, telemetryScopeRefusalLine } from './health-metrics-format.ts';
24
+ import { createMemoryDiagnosticsGateway, renderMemoryDiagnostics } from '../../core/memory-diagnostics-gateway.ts';
25
+ import { requireShellPaths } from './runtime-services.ts';
24
26
 
25
27
  function renderSandboxHealthSummary(configManager: ConfigManager): string[] {
26
28
  const backend = String(configManager.get('sandbox.vmBackend') ?? 'local');
@@ -42,7 +44,7 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
42
44
  name: 'health',
43
45
  aliases: ['doctor'],
44
46
  description: 'Health workspace for startup posture, service readiness, sandbox posture, and provider health',
45
- usage: '[report|review|open|setup|services|sandbox|provider|accounts|auth|settings|intelligence|remote|mcp|metrics|continuity|worktrees|maintenance|term|repair [domain]] — bare and report stay a cross-domain transcript report (see also /health provider for the providers modal)',
47
+ usage: '[report|review|open|setup|services|sandbox|provider|accounts|auth|settings|intelligence|remote|mcp|memory|metrics|continuity|worktrees|maintenance|term|repair [domain]] — bare and report stay a cross-domain transcript report (see also /health provider for the providers modal)',
46
48
  async handler(args, ctx) {
47
49
  const sub = (args[0] ?? 'review').toLowerCase();
48
50
  const readModels = requireReadModels(ctx);
@@ -52,6 +54,18 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
52
54
  return;
53
55
  }
54
56
 
57
+ if (sub === 'memory') {
58
+ // The MemoryGovernor snapshot (ops.memory.get): tier, budget vs RSS,
59
+ // per-cache footprints, paused jobs, tripwire — read-only, honest 501
60
+ // when a daemon predates the governor verb.
61
+ const resolution = createMemoryDiagnosticsGateway({
62
+ configManager: ctx.platform.configManager,
63
+ homeDirectory: () => requireShellPaths(ctx).homeDirectory,
64
+ });
65
+ ctx.print(await renderMemoryDiagnostics(resolution));
66
+ return;
67
+ }
68
+
55
69
  if (sub === 'services') {
56
70
  const registry = requireServiceRegistry(ctx);
57
71
  const all = registry.getAll();
@@ -2,6 +2,7 @@ import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
2
2
  import type { CommandContext } from './command-registry.ts';
3
3
  import type { SelectionItem } from './selection-modal.ts';
4
4
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
5
+ import { localVoiceSetupOffer } from './voice-local-settings.ts';
5
6
 
6
7
  function getStreamingTtsProviders(ctx: CommandContext): Array<{ id: string; label: string; capabilities: readonly string[] }> {
7
8
  const registry = ctx.platform.voiceProviderRegistry;
@@ -28,14 +29,32 @@ export function openTtsProviderPicker(ctx: CommandContext): boolean {
28
29
  }
29
30
 
30
31
  const current = String(ctx.platform.configManager.get('tts.provider') ?? '').trim();
31
- const items: SelectionItem[] = providers.map((provider) => ({
32
- id: provider.id,
33
- label: provider.label,
34
- detail: provider.id === current ? `${provider.id} (current)` : provider.id,
35
- category: 'streaming TTS providers',
36
- primaryAction: 'select',
37
- actions: '[Enter] set provider',
38
- }));
32
+ const items: SelectionItem[] = providers.map((provider) => {
33
+ // The managed local engine renders beside ElevenLabs; when it is not yet
34
+ // provisioned, its detail line offers the size-labeled one-act install so
35
+ // the operator sees the download cost before committing to /voice setup.
36
+ if (provider.id === 'local') {
37
+ const offer = localVoiceSetupOffer((key) => ctx.platform.configManager.get(key as Parameters<typeof ctx.platform.configManager.get>[0]));
38
+ if (!offer.provisioned) {
39
+ return {
40
+ id: provider.id,
41
+ label: provider.label,
42
+ detail: provider.id === current ? `${offer.detail} (current)` : offer.detail,
43
+ category: 'streaming TTS providers',
44
+ primaryAction: 'select',
45
+ actions: offer.actions,
46
+ };
47
+ }
48
+ }
49
+ return {
50
+ id: provider.id,
51
+ label: provider.label,
52
+ detail: provider.id === current ? `${provider.id} (current)` : provider.id,
53
+ category: 'streaming TTS providers',
54
+ primaryAction: 'select',
55
+ actions: '[Enter] set provider',
56
+ };
57
+ });
39
58
 
40
59
  ctx.openSelection('Choose TTS Provider', items, { preSelectId: current, allowSearch: true }, (result) => {
41
60
  if (!result) return;
@@ -0,0 +1,52 @@
1
+ // ---------------------------------------------------------------------------
2
+ // voice-local-settings.ts — the managed local-voice setup affordance the voice
3
+ // settings surfaces render beside ElevenLabs.
4
+ //
5
+ // The local engine registers in the voice provider registry regardless of
6
+ // whether it is provisioned yet (its status is honestly "unconfigured", never
7
+ // an error). When it is NOT provisioned, the settings/provider-picker surface
8
+ // offers the one-act install with its download size declared up front — the
9
+ // size is computed synchronously from the pinned piper manifest
10
+ // (piperProvisionBytes) so no daemon round-trip is needed just to label the
11
+ // offer. Platforms with no pinned managed build say so honestly.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ import { currentVoicePlatform, piperProvisionBytes } from '@pellux/goodvibes-sdk/platform/voice';
15
+ import { formatVoiceBytes } from '../core/voice-provision-status.ts';
16
+
17
+ export interface LocalVoiceSetupOffer {
18
+ /** A pinned managed piper build exists for the current platform. */
19
+ readonly supported: boolean;
20
+ /** The local TTS engine + model config keys are set (managed install applied or user-set). */
21
+ readonly provisioned: boolean;
22
+ /** Human size label for the one-act install ("~85 MB" or an honest unavailable note). */
23
+ readonly sizeLabel: string;
24
+ /** The detail line the provider picker renders for the local engine. */
25
+ readonly detail: string;
26
+ /** The picker action hint (advertises /voice setup only where it can actually provision). */
27
+ readonly actions: string;
28
+ }
29
+
30
+ /**
31
+ * Compute the managed local-voice setup offer from config, with the download
32
+ * size declared up front. `provisioned` reflects whether the local TTS engine
33
+ * binary + model keys are set; an unprovisioned-but-supported platform offers
34
+ * the size-labeled /voice setup one-act.
35
+ */
36
+ export function localVoiceSetupOffer(configGet: (key: string) => unknown): LocalVoiceSetupOffer {
37
+ const platform = currentVoicePlatform();
38
+ const bytes = platform ? piperProvisionBytes(platform) : null;
39
+ const supported = bytes !== null && bytes !== undefined;
40
+ const ttsBinary = String(configGet('voice.local.ttsBinary') ?? '').trim();
41
+ const ttsModel = String(configGet('voice.local.ttsModelPath') ?? '').trim();
42
+ const provisioned = ttsBinary.length > 0 && ttsModel.length > 0;
43
+ const sizeLabel = supported ? `~${formatVoiceBytes(bytes)}` : 'unavailable on this platform';
44
+
45
+ let detail: string;
46
+ if (!supported) detail = 'local voice: no managed build for this platform (set voice.local.* by hand to use it)';
47
+ else if (provisioned) detail = 'local voice: configured (managed engine set)';
48
+ else detail = `local voice: not set up — run /voice setup (${sizeLabel})`;
49
+
50
+ const actions = supported && !provisioned ? '[Enter] select · /voice setup to provision' : '[Enter] set provider';
51
+ return { supported, provisioned, sizeLabel, detail, actions };
52
+ }
@@ -64,6 +64,15 @@ export interface CodeIndexServicesDeps {
64
64
  readonly workingDirectory: string;
65
65
  readonly configManager: Pick<ConfigManager, 'get'>;
66
66
  readonly memoryEmbeddingRegistry: MemoryEmbeddingProviderRegistry;
67
+ /**
68
+ * Memory-governor backpressure (optional): when the governor pauses the
69
+ * 'code-index-reindex' job, the tool-site reindex scheduler stops scheduling
70
+ * new work; the critical tier refuses new reindex work outright via
71
+ * admitExpensiveWork. Absent (most test fixtures) → never paused, always
72
+ * admitted, exactly as before.
73
+ */
74
+ readonly isReindexPaused?: (() => boolean) | undefined;
75
+ readonly admitExpensiveWork?: ((label: string) => { allowed: boolean; reason?: string | undefined }) | undefined;
67
76
  }
68
77
 
69
78
  /**
@@ -128,7 +137,12 @@ export function createCodeIndexServices(deps: CodeIndexServicesDeps): CodeIndexS
128
137
  const codeIndexReindexScheduler = new CodeIndexReindexScheduler({
129
138
  target: codeIndexStore,
130
139
  workingDirectory: deps.workingDirectory,
131
- isEnabled: () => isCodeInjectionSettingEnabled(deps.configManager),
140
+ // Honor governor backpressure: a paused code-index reindex job stops
141
+ // scheduling new work until the governor resumes it, and the critical
142
+ // memory tier refuses new reindex work outright (mirrors the SDK).
143
+ isEnabled: () => isCodeInjectionSettingEnabled(deps.configManager)
144
+ && !(deps.isReindexPaused?.() ?? false)
145
+ && (deps.admitExpensiveWork?.('code-index reindex').allowed ?? true),
132
146
  });
133
147
  return { codeIndexStore, codeIndexReindexScheduler };
134
148
  }
@@ -0,0 +1,94 @@
1
+ // ---------------------------------------------------------------------------
2
+ // knowledge-services.ts — the knowledge/wiki + home-graph stack (TUI wiring)
3
+ //
4
+ // Constructs the three KnowledgeStores (regular wiki, agent, home-graph), their
5
+ // semantic services, the ingest services, the home-graph service, and the
6
+ // project-planning + work-plan stores. Extracted into its own module rather
7
+ // than built inline in services.ts, which sits at the architecture check's
8
+ // 800-line cap (scripts/check-architecture.ts) — new/large construction blocks
9
+ // get their own module and a single wiring call there (mirrors
10
+ // createWorkstreamServices / createDurabilityServices / createCodeIndexServices).
11
+ //
12
+ // The semantic services + ingest services receive the memory-governor
13
+ // backpressure seams (isBackgroundPaused for the knowledge-self-improvement job
14
+ // + admitExpensiveWork for the critical-tier refusal), mirroring the SDK's own
15
+ // createRuntimeServices. The web-knowledge gap repairer is wired by the caller
16
+ // AFTER webSearchService exists (it is constructed later in services.ts), using
17
+ // the semantic + ingest handles this returns.
18
+ // ---------------------------------------------------------------------------
19
+
20
+ import {
21
+ HomeGraphService,
22
+ GOODVIBES_AGENT_KNOWLEDGE_DB_FILE,
23
+ HOME_GRAPH_KNOWLEDGE_EXTENSION,
24
+ KnowledgeService,
25
+ KnowledgeSemanticService,
26
+ KnowledgeStore,
27
+ ProjectPlanningService,
28
+ createProviderBackedKnowledgeSemanticLlm,
29
+ projectPlanningProjectIdFromPath,
30
+ } from '@pellux/goodvibes-sdk/platform/knowledge';
31
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
32
+ import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
33
+ import type { ArtifactStore } from '@pellux/goodvibes-sdk/platform/artifacts';
34
+ import type { MemoryRegistry } from '@pellux/goodvibes-sdk/platform/state';
35
+ import type { RuntimeEventBus } from '@/runtime/index.ts';
36
+ import { WorkPlanStore } from '../work-plans/work-plan-store.ts';
37
+
38
+ const REGULAR_KNOWLEDGE_DB_FILE = 'knowledge-wiki.sqlite';
39
+ const HOME_GRAPH_KNOWLEDGE_DB_FILE = 'knowledge-home-graph.sqlite';
40
+
41
+ export interface KnowledgeServicesDeps {
42
+ readonly configManager: ConfigManager;
43
+ readonly providerRegistry: ProviderRegistry;
44
+ readonly artifactStore: ArtifactStore;
45
+ readonly memoryRegistry: MemoryRegistry;
46
+ readonly runtimeBus: RuntimeEventBus;
47
+ readonly workingDirectory: string;
48
+ readonly homeDirectory: string;
49
+ /** True while the 'knowledge-self-improvement' job is governor-paused. */
50
+ readonly isBackgroundPaused: () => boolean;
51
+ /** Critical-tier admission gate for expensive knowledge work. */
52
+ readonly admitExpensiveWork: (label: string) => { allowed: boolean; reason?: string | undefined };
53
+ }
54
+
55
+ export interface KnowledgeServices {
56
+ readonly knowledgeStore: KnowledgeStore;
57
+ readonly agentKnowledgeStore: KnowledgeStore;
58
+ readonly homeGraphKnowledgeStore: KnowledgeStore;
59
+ readonly knowledgeSemanticService: KnowledgeSemanticService;
60
+ readonly homeGraphSemanticService: KnowledgeSemanticService;
61
+ readonly agentKnowledgeSemanticService: KnowledgeSemanticService;
62
+ readonly knowledgeService: KnowledgeService;
63
+ readonly agentKnowledgeService: KnowledgeService;
64
+ readonly homeGraphService: HomeGraphService;
65
+ readonly projectPlanningService: ProjectPlanningService;
66
+ readonly projectPlanningProjectId: string;
67
+ readonly workPlanStore: WorkPlanStore;
68
+ }
69
+
70
+ /** Construct the knowledge/wiki + home-graph stack with governor backpressure wired in. */
71
+ export function createKnowledgeServices(deps: KnowledgeServicesDeps): KnowledgeServices {
72
+ const { configManager, providerRegistry, artifactStore, memoryRegistry, runtimeBus, isBackgroundPaused, admitExpensiveWork } = deps;
73
+ const knowledgeStore = new KnowledgeStore({ configManager, dbFileName: REGULAR_KNOWLEDGE_DB_FILE });
74
+ const agentKnowledgeStore = new KnowledgeStore({ configManager, dbFileName: GOODVIBES_AGENT_KNOWLEDGE_DB_FILE });
75
+ const homeGraphKnowledgeStore = new KnowledgeStore({ configManager, dbFileName: HOME_GRAPH_KNOWLEDGE_DB_FILE });
76
+ const knowledgeSemanticLlm = createProviderBackedKnowledgeSemanticLlm(providerRegistry, { timeoutMs: 20_000, maxConcurrent: 1 });
77
+ const knowledgeSemanticService = new KnowledgeSemanticService(knowledgeStore, { llm: knowledgeSemanticLlm, maxLlmSourcesPerReindex: 3, isBackgroundPaused, admitExpensiveWork });
78
+ const homeGraphSemanticService = new KnowledgeSemanticService(homeGraphKnowledgeStore, { llm: knowledgeSemanticLlm, maxLlmSourcesPerReindex: 3, objectProfiles: HOME_GRAPH_KNOWLEDGE_EXTENSION.objectProfiles, isBackgroundPaused, admitExpensiveWork });
79
+ const agentKnowledgeSemanticService = new KnowledgeSemanticService(agentKnowledgeStore, { llm: knowledgeSemanticLlm, maxLlmSourcesPerReindex: 3, isBackgroundPaused, admitExpensiveWork });
80
+ const knowledgeService = new KnowledgeService(knowledgeStore, artifactStore, undefined, { memoryRegistry, runtimeBus, semanticService: knowledgeSemanticService, admitExpensiveWork });
81
+ knowledgeService.attachRuntimeBus(runtimeBus);
82
+ const agentKnowledgeService = new KnowledgeService(agentKnowledgeStore, artifactStore, undefined, { memoryRegistry, runtimeBus, semanticService: agentKnowledgeSemanticService, admitExpensiveWork });
83
+ agentKnowledgeService.attachRuntimeBus(runtimeBus);
84
+ const homeGraphService = new HomeGraphService(homeGraphKnowledgeStore, artifactStore, { semanticService: homeGraphSemanticService, admitExpensiveWork });
85
+ const projectPlanningProjectId = projectPlanningProjectIdFromPath(deps.workingDirectory);
86
+ const projectPlanningService = new ProjectPlanningService(knowledgeStore, { defaultProjectId: projectPlanningProjectId });
87
+ const workPlanStore = new WorkPlanStore({ homeDirectory: deps.homeDirectory, projectId: projectPlanningProjectId, projectRoot: deps.workingDirectory });
88
+ return {
89
+ knowledgeStore, agentKnowledgeStore, homeGraphKnowledgeStore,
90
+ knowledgeSemanticService, homeGraphSemanticService, agentKnowledgeSemanticService,
91
+ knowledgeService, agentKnowledgeService, homeGraphService,
92
+ projectPlanningService, projectPlanningProjectId, workPlanStore,
93
+ };
94
+ }
@@ -41,7 +41,7 @@
41
41
  * exercised deterministically via fakes and never touches a real running
42
42
  * service in tests.
43
43
  */
44
- import { existsSync, rmSync } from 'node:fs';
44
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
45
45
  import { spawnSync } from 'node:child_process';
46
46
  import net from 'node:net';
47
47
  import { basename, dirname, join } from 'node:path';
@@ -118,12 +118,22 @@ export function buildManagedDaemonServiceManager(params: BuildManagedDaemonServi
118
118
  homeDir: params.homeDir,
119
119
  surfaceRoot: 'tui',
120
120
  });
121
+ // The unit's ExecStart deliberately carries NO endpoint flags
122
+ // (--hostname/--port): the daemon resolves controlPlane.hostMode/host/port
123
+ // from the user's settings at boot, so a later config change (or a host
124
+ // already configured for hostMode=network / a non-default port) keeps its
125
+ // endpoint without a unit rewrite. Baking endpoint values here is what
126
+ // silently re-pinned custom-configured hosts back to the values current at
127
+ // install time. scripts/install.sh writes the same shape — the two paths
128
+ // must produce the identical running daemon (see the installer parity test).
129
+ // `params.host`/`params.port` remain inputs because the migration engine
130
+ // still needs them for its read-only port-liveness probe.
121
131
  const definition: ManagedServiceDefinition = {
122
132
  name: MANAGED_SERVICE_NAME,
123
133
  description: MANAGED_SERVICE_DESCRIPTION,
124
134
  workingDirectory,
125
135
  command: params.binaryPath,
126
- args: ['--daemon-home', params.homeDir, '--hostname', params.host, '--port', String(params.port)],
136
+ args: ['--daemon-home', params.homeDir],
127
137
  env: {},
128
138
  restartOnFailure: true,
129
139
  };
@@ -217,8 +227,7 @@ export function detectLegacyUnit(input: DetectLegacyUnitInput): LegacyUnitInfo {
217
227
  const path = legacyUnitPath(input.homeDir);
218
228
  const fileExists = input.legacyUnitFileExists ?? existsSync;
219
229
  if (!fileExists(path)) return { present: false, active: false, path };
220
- const run: ManagedServiceActionRunner = input.actionRunner
221
- ?? ((command, args) => spawnSync(command, args, { stdio: 'pipe', encoding: 'utf-8' }) as ManagedServiceActionResult);
230
+ const run: ManagedServiceActionRunner = input.actionRunner ?? defaultActionRunner(SYSTEMCTL_TIMEOUT_MS);
222
231
  const result = run('systemctl', ['--user', 'is-active', `${LEGACY_SERVICE_UNIT_NAME}.service`]);
223
232
  const state = (result.stdout ?? '').trim();
224
233
  const active = (result.status ?? 1) === 0 && state === 'active';
@@ -270,6 +279,30 @@ export function legacyUnitNote(legacy: LegacyUnitInfo, trackedServiceName: strin
270
279
  );
271
280
  }
272
281
 
282
+ /**
283
+ * Hard ceiling on every systemctl invocation made through a DEFAULT action
284
+ * runner in this module. The reconcile below runs on the daemon's own startup
285
+ * path, and `spawnSync` without a timeout blocks the single JS event loop for
286
+ * as long as the child runs — a wedged user D-Bus (a real incident class on
287
+ * this host) would freeze an already-listening daemon indefinitely. A timed-out
288
+ * call reports `status: null`, which every status check in this module treats
289
+ * as failure, so a wedge degrades to an honest refusal instead of a hang.
290
+ */
291
+ export const SYSTEMCTL_TIMEOUT_MS = 5_000;
292
+
293
+ export function defaultActionRunner(timeoutMs: number): ManagedServiceActionRunner {
294
+ return (command, args) =>
295
+ spawnSync(command, args, { stdio: 'pipe', encoding: 'utf-8', timeout: timeoutMs }) as ManagedServiceActionResult;
296
+ }
297
+
298
+ /** Parse a `systemctl show -p MainPID --value` reply: a positive integer pid, or undefined when absent/unparseable/0. */
299
+ export function parseMainPid(result: { status?: number | null; stdout?: string | null | undefined }): number | undefined {
300
+ if ((result.status ?? 1) !== 0) return undefined;
301
+ const parsed = Number.parseInt((result.stdout ?? '').trim(), 10);
302
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
303
+ }
304
+
305
+
273
306
  /**
274
307
  * Read-only, best-effort TCP connect probe used ONLY by the legacy-absent
275
308
  * branch to tell "nothing is listening on this port" apart from "an
@@ -490,8 +523,7 @@ export async function runLegacyDaemonMigration(
490
523
  }
491
524
 
492
525
  // New unit verified healthy — now, and only now, retire the legacy unit.
493
- const run: ManagedServiceActionRunner = params.actionRunner
494
- ?? ((command, args) => spawnSync(command, args, { stdio: 'pipe', encoding: 'utf-8' }) as ManagedServiceActionResult);
526
+ const run: ManagedServiceActionRunner = params.actionRunner ?? defaultActionRunner(SYSTEMCTL_TIMEOUT_MS);
495
527
  const stopResult = run('systemctl', ['--user', 'stop', `${LEGACY_SERVICE_UNIT_NAME}.service`]);
496
528
  const disableResult = run('systemctl', ['--user', 'disable', `${LEGACY_SERVICE_UNIT_NAME}.service`]);
497
529
  const removeFile = params.legacyUnitFileRemove ?? ((path: string) => rmSync(path, { force: true }));