@pellux/goodvibes-agent 1.9.1 → 1.10.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 (65) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +12 -1
  3. package/dist/package/main.js +36712 -28803
  4. package/dist/package/{web-tree-sitter-jbz042ba.wasm → web-tree-sitter-e011xaqr.wasm} +0 -0
  5. package/docs/README.md +1 -1
  6. package/docs/connected-host.md +1 -1
  7. package/docs/getting-started.md +1 -1
  8. package/docs/tools-and-commands.md +1 -0
  9. package/package.json +3 -3
  10. package/release/live-verification/live-verification.json +11 -11
  11. package/release/live-verification/live-verification.md +12 -12
  12. package/src/agent/email/email-service.ts +1 -1
  13. package/src/agent/email/imap-client.ts +4 -4
  14. package/src/agent/email/smtp-client.ts +5 -5
  15. package/src/cli/config-overrides.ts +29 -14
  16. package/src/cli/entrypoint.ts +12 -4
  17. package/src/cli/launch-auto-update.ts +218 -0
  18. package/src/cli/relay-command.ts +4 -4
  19. package/src/cli/service-posture.ts +2 -2
  20. package/src/cli/tui-startup.ts +31 -0
  21. package/src/cli/workspaces-command.ts +5 -1
  22. package/src/cli-flags.ts +1 -1
  23. package/src/config/index.ts +1 -1
  24. package/src/config/update-settings.ts +45 -0
  25. package/src/config/workspace-registration.ts +214 -15
  26. package/src/input/commands/runtime-services.ts +0 -5
  27. package/src/input/commands/update-runtime.ts +313 -0
  28. package/src/input/commands.ts +2 -0
  29. package/src/input/feed-context-factory.ts +2 -2
  30. package/src/input/handler-feed.ts +8 -8
  31. package/src/input/handler.ts +1 -1
  32. package/src/input/mcp-workspace.ts +5 -1
  33. package/src/input/panel-paste-flood-guard.ts +1 -1
  34. package/src/input/settings-modal-types.ts +11 -5
  35. package/src/input/settings-modal.ts +56 -61
  36. package/src/main.ts +19 -20
  37. package/src/renderer/activity-sidebar.ts +53 -4
  38. package/src/renderer/settings-modal-helpers.ts +2 -0
  39. package/src/renderer/settings-modal.ts +37 -25
  40. package/src/runtime/bootstrap-core.ts +16 -5
  41. package/src/runtime/bootstrap-external-services.ts +107 -11
  42. package/src/runtime/bootstrap-hook-bridge.ts +2 -2
  43. package/src/runtime/bootstrap-shell.ts +4 -4
  44. package/src/runtime/bootstrap.ts +34 -12
  45. package/src/runtime/connected-host-autostart.ts +269 -0
  46. package/src/runtime/daemon-receipts.ts +66 -0
  47. package/src/runtime/diagnostics/panels/index.ts +0 -2
  48. package/src/runtime/feature-enablement.ts +176 -0
  49. package/src/runtime/index.ts +12 -29
  50. package/src/runtime/memory-spine-adoption.ts +18 -0
  51. package/src/runtime/onboarding/apply.ts +50 -37
  52. package/src/runtime/onboarding/types.ts +2 -2
  53. package/src/runtime/onboarding/verify.ts +22 -4
  54. package/src/runtime/release-artifacts.ts +113 -0
  55. package/src/runtime/services.ts +228 -19
  56. package/src/runtime/session-spine-rest-transport.ts +84 -4
  57. package/src/runtime/ui-services.ts +1 -1
  58. package/src/runtime/update-check.ts +64 -0
  59. package/src/shell/ui-openers.ts +8 -0
  60. package/src/tools/agent-harness-metadata.ts +8 -0
  61. package/src/tools/agent-harness-setup-connected-host.ts +1 -1
  62. package/src/tools/agent-harness-setup-posture.ts +1 -1
  63. package/src/version.ts +1 -1
  64. package/src/runtime/diagnostics/panels/ops.ts +0 -156
  65. package/src/runtime/surface-feature-flags.ts +0 -100
@@ -2,18 +2,28 @@
2
2
  * Connected-host (daemon + HTTP listener) discovery for GoodVibes Agent.
3
3
  *
4
4
  * Split out of bootstrap.ts (which is near the 800-line architecture cap) to
5
- * keep this concern — and its "GoodVibes Agent never owns the daemon
6
- * lifecycle" framing — in one small, readable place.
5
+ * keep this concern — and the Agent's daemon-lifecycle boundary in one
6
+ * small, readable place.
7
7
  *
8
- * GoodVibes Agent never starts or restarts the connected daemon: every call
9
- * routes through the SDK-shared adopt-or-spawn policy
8
+ * Discovery routes through the SDK-shared adopt-or-spawn policy
10
9
  * (decideDaemonAdoption/classifyDaemonProbe) with `adoptOnly: true`, so a
11
10
  * reachable, version-compatible daemon is adopted and anything else (absent,
12
- * blocked, or on an incompatible wire version) is reported honestly. This
13
- * replaces a local stub that hard-declared every daemon 'external' without
14
- * probing or version-checking it.
11
+ * blocked, or on an incompatible wire version) is reported honestly. The
12
+ * Agent never spawns or embeds a daemon of its own and never restarts a
13
+ * running one. One bounded exception at boot: when discovery finds nothing
14
+ * on the configured port but the host's service entry IS installed on this
15
+ * machine, the Agent issues a single start through the platform service
16
+ * manager, waits a bounded time for the daemon to answer, re-probes, and
17
+ * reports what it did (see connected-host-autostart.ts). A machine with the
18
+ * host installed but stopped must not hand the user homework.
15
19
  */
16
20
  import { startExternalServices } from '@/runtime/index.ts';
21
+ import {
22
+ autostartInstalledConnectedHost,
23
+ createConnectedHostServiceControl,
24
+ type ConnectedHostServiceControl,
25
+ } from './connected-host-autostart.ts';
26
+ import type { SpineReachability } from '@pellux/goodvibes-sdk/platform/runtime/session-spine';
17
27
  import type {
18
28
  DeferredStartupCoordinator,
19
29
  ExternalServicesHandle,
@@ -65,9 +75,25 @@ const hostServiceIsBlocked = (status: HostServiceStatus): boolean => status.mode
65
75
  export interface AgentExternalServicesController {
66
76
  /** The most recently resolved (or pending) connected-host status. */
67
77
  getStatus(): ExternalServicesHandle;
78
+ /**
79
+ * Resolves once the deferred boot discovery (including any boot-time start
80
+ * of an installed-but-stopped host) has settled. Later probes that reuse
81
+ * the daemon's reachability (session/memory spine adoption) await this so
82
+ * they see the post-discovery daemon state instead of racing it.
83
+ */
84
+ whenDiscovered(): Promise<void>;
68
85
  stop(): Promise<void>;
69
86
  }
70
87
 
88
+ /** Test seams for the boot-time start of an installed-but-stopped host. */
89
+ export interface ConnectedHostAutostartSeams {
90
+ readonly control?: ConnectedHostServiceControl;
91
+ readonly probeReachability?: () => Promise<SpineReachability>;
92
+ readonly waitTimeoutMs?: number;
93
+ readonly pollIntervalMs?: number;
94
+ readonly sleep?: (ms: number) => Promise<void>;
95
+ }
96
+
71
97
  export function wireAgentExternalServices(options: {
72
98
  readonly configManager: HostServicesConfig;
73
99
  readonly runtimeBus: RuntimeEventBus;
@@ -77,8 +103,23 @@ export function wireAgentExternalServices(options: {
77
103
  readonly deferredStartup: DeferredStartupCoordinator;
78
104
  readonly systemMessageRouter: SystemMessageRouter;
79
105
  readonly requestRender: () => void;
106
+ /** Injectable discovery implementation (tests); defaults to the SDK-shared policy. */
107
+ readonly startServices?: typeof startExternalServices;
108
+ readonly connectedHostAutostart?: ConnectedHostAutostartSeams;
80
109
  }): AgentExternalServicesController {
81
110
  const { configManager, runtimeBus, hookDispatcher, services, uiServices, deferredStartup, systemMessageRouter, requestRender } = options;
111
+ const startServices = options.startServices ?? startExternalServices;
112
+
113
+ // Connected-host honesty receipts ("updated from X to Y", "restarted after
114
+ // a crash at HH:MM", settings migrations) captured off the once-per-attach
115
+ // ?receipts=consume /status read (bootstrap.ts's memory-spine onAttach):
116
+ // delivery at the daemon is destructive (served once, to the consuming
117
+ // reader), so every captured receipt renders here — buffered ones from before
118
+ // this sink attaches flush immediately.
119
+ services.daemonReceiptFeed.attach((receipt) => {
120
+ systemMessageRouter.high(`[Connected host] ${receipt.text}`);
121
+ requestRender();
122
+ });
82
123
 
83
124
  const inspectAgentDependencies = () => {
84
125
  const daemonStatus = externalServices.daemonStatus;
@@ -103,8 +144,60 @@ export function wireAgentExternalServices(options: {
103
144
  };
104
145
  let externalServicesPromise: Promise<ExternalServicesHandle> | null = null;
105
146
 
147
+ // Deliberately NOT passing the daemon facade's `updateArtifact` identity
148
+ // ({version, execPath}): with adoptOnly the agent never constructs or embeds
149
+ // a DaemonServer, so there is no daemon-side hourly update loop here to feed
150
+ // — absent means host-managed, which is exactly the agent's stance toward
151
+ // whichever host it adopts. The agent's OWN binary updates at launch through
152
+ // its launch auto-update path instead (src/cli/launch-auto-update.ts).
106
153
  const startAgentExternalServices = (): Promise<ExternalServicesHandle> =>
107
- startExternalServices(configManager, runtimeBus, hookDispatcher, services, { adoptOnly: true });
154
+ startServices(configManager, runtimeBus, hookDispatcher, services, { adoptOnly: true });
155
+
156
+ // Boot-time start of an installed-but-stopped host: when discovery found
157
+ // nothing on the configured port, check the platform service manager for an
158
+ // installed host entry, start it once, wait a bounded time, and re-probe.
159
+ // Every skip/failure path is honest; errors here never break discovery.
160
+ const maybeStartInstalledConnectedHost = async (): Promise<void> => {
161
+ const seams = options.connectedHostAutostart ?? {};
162
+ try {
163
+ const outcome = await autostartInstalledConnectedHost({
164
+ daemonStatus: externalServices.daemonStatus,
165
+ control: seams.control ?? createConnectedHostServiceControl({
166
+ configManager: services.configManager,
167
+ workingDirectory: services.workingDirectory,
168
+ homeDirectory: services.homeDirectory,
169
+ }),
170
+ probeReachability: seams.probeReachability ?? (() => services.sessionSpineClient.probeReachability()),
171
+ waitTimeoutMs: seams.waitTimeoutMs,
172
+ pollIntervalMs: seams.pollIntervalMs,
173
+ sleep: seams.sleep,
174
+ });
175
+ switch (outcome.action) {
176
+ case 'started':
177
+ case 'came-online': {
178
+ externalServicesPromise = startAgentExternalServices();
179
+ externalServices = await externalServicesPromise;
180
+ const adopted = externalServices.daemonStatus.mode === 'external';
181
+ const suffix = adopted
182
+ ? ''
183
+ : ` — but adopting it still failed: ${externalServices.daemonStatus.reason ?? externalServices.daemonStatus.mode}`;
184
+ systemMessageRouter.low(outcome.action === 'started'
185
+ ? `[Startup] Connected host was installed but stopped; started it (service "${outcome.serviceName}")${suffix}.`
186
+ : `[Startup] Connected host service "${outcome.serviceName}" was already starting; connected once it answered${suffix}.`);
187
+ break;
188
+ }
189
+ case 'start-failed': {
190
+ systemMessageRouter.high(`[Startup] Connected host is installed but not answering, and starting it did not succeed: ${outcome.reason}. Start it manually with: goodvibes service start`);
191
+ break;
192
+ }
193
+ case 'not-installed':
194
+ case 'none':
195
+ break;
196
+ }
197
+ } catch (error) {
198
+ logger.debug('Boot-time connected-host start check failed', { error: summarizeError(error) });
199
+ }
200
+ };
108
201
 
109
202
  const platformExternalServices = uiServices.platform as typeof uiServices.platform & {
110
203
  externalServices: NonNullable<typeof uiServices.platform.externalServices>;
@@ -122,7 +215,7 @@ export function wireAgentExternalServices(options: {
122
215
  await externalServices.stop();
123
216
  externalServicesPromise = startAgentExternalServices();
124
217
  externalServices = await externalServicesPromise;
125
- systemMessageRouter.high('[Startup] GoodVibes Agent does not start or restart the connected GoodVibes host it adopted the current status from a fresh probe.');
218
+ systemMessageRouter.high('[Startup] GoodVibes Agent re-probed the connected GoodVibes host and adopted its current status; this route never starts or restarts the host itself.');
126
219
  requestRender();
127
220
  return inspectAgentDependencies();
128
221
  },
@@ -131,12 +224,14 @@ export function wireAgentExternalServices(options: {
131
224
  // Connected-host discovery OFF the interactive path: probe the configured
132
225
  // host/port through the shared adopt-or-spawn policy (adoptOnly — Agent
133
226
  // never spawns or embeds) and replace the pending status with an honest
134
- // one (adopted, incompatible, blocked, or unavailable).
135
- deferredStartup.schedule({
227
+ // one (adopted, incompatible, blocked, or unavailable). An unavailable
228
+ // daemon then gets the one bounded installed-but-stopped start check.
229
+ const discoveryComplete = deferredStartup.schedule({
136
230
  label: 'external-services',
137
231
  run: async () => {
138
232
  externalServicesPromise = startAgentExternalServices();
139
233
  externalServices = await externalServicesPromise;
234
+ await maybeStartInstalledConnectedHost();
140
235
  requestRender();
141
236
  },
142
237
  onError: (error) => {
@@ -149,6 +244,7 @@ export function wireAgentExternalServices(options: {
149
244
 
150
245
  return {
151
246
  getStatus: () => externalServices,
247
+ whenDiscovered: () => discoveryComplete,
152
248
  stop: async () => {
153
249
  await externalServices.stop();
154
250
  },
@@ -28,7 +28,7 @@ export interface ResumeSessionOptions {
28
28
  readonly hookDispatcher: HookDispatcher;
29
29
  readonly sessionManager: SessionManager;
30
30
  readonly configManager: Pick<ConfigManager, 'get' | 'getCategory'>;
31
- readonly providerRegistry: Pick<ProviderRegistry, 'get' | 'getCurrentModel' | 'getForModel' | 'require'>;
31
+ readonly providerRegistry: Pick<ProviderRegistry, 'get' | 'getCurrentModel' | 'getForModel' | 'require' | 'resolveModelPricing'>;
32
32
  }
33
33
 
34
34
  export function createResumeSessionHandler(options: ResumeSessionOptions): (sessionId: string) => void {
@@ -54,7 +54,7 @@ export function createResumeSessionHandler(options: ResumeSessionOptions): (sess
54
54
  if (meta?.provider) options.runtime.provider = meta.provider;
55
55
  options.writeLastSessionPointer(sessionId);
56
56
  void options.sharedSessionBroker.reopenSession(sessionId).catch((err) => { logger.debug('session broker reopen session failed', { err }); });
57
- // W2A: mirror the reopen into the daemon spine — reopen:true is sent ONLY on
57
+ // Mirror the reopen into the daemon spine — reopen:true is sent ONLY on
58
58
  // this explicit user resume verb (fire-and-forget; never blocks the resume).
59
59
  options.sessionSpineClient.reopen({ sessionId, project: options.projectRoot });
60
60
  options.conversation.log(`Resumed session: ${sessionId}`, { fg: '135' });
@@ -8,7 +8,6 @@ import type { RuntimeStore } from './store/index.ts';
8
8
  import type { RuntimeServices } from './services.ts';
9
9
  import type { CommandContext } from '../input/command-registry.ts';
10
10
  import type { AgentPromptContextReceiptStore } from '../agent/prompt-context-receipts.ts';
11
- import type { OpsControlPlane } from '@/runtime/index.ts';
12
11
  import { CommandRegistry } from '../input/command-registry.ts';
13
12
  import { registerBuiltinCommands } from '../input/commands.ts';
14
13
  import { InputHistory } from '../input/input-history.ts';
@@ -58,7 +57,6 @@ export interface BootstrapShellOptions {
58
57
  readonly policyRuntimeState: PolicyRuntimeState;
59
58
  readonly uiServices: UiRuntimeServices;
60
59
  readonly taskManager: TaskManager;
61
- readonly opsControlPlane?: OpsControlPlane;
62
60
  readonly completeModelSelectionSideEffect?: () => void;
63
61
  }
64
62
 
@@ -119,7 +117,6 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
119
117
  policyRuntimeState,
120
118
  uiServices,
121
119
  taskManager,
122
- opsControlPlane,
123
120
  completeModelSelectionSideEffect,
124
121
  } = options;
125
122
 
@@ -156,11 +153,14 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
156
153
 
157
154
  const commandRegistry = new CommandRegistry();
158
155
  registerBuiltinCommands(commandRegistry);
156
+ // No opsControlPlane: the Agent's product boundary keeps connected-host
157
+ // tasks read-only (see /tasks: every mutation subcommand is blocked and
158
+ // routed to /workplan or /delegate), so the opsApi intervention verbs
159
+ // honestly report the control plane as unavailable.
159
160
  const foundationClients = createRuntimeFoundationClients({
160
161
  runtimeServices: services,
161
162
  tasksReadModel: uiServices.readModels.tasks,
162
163
  taskManager,
163
- opsControlPlane,
164
164
  });
165
165
  const {
166
166
  directTransport,
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { Orchestrator, type OrchestratorUserInputOptions } from '../core/orchestrator.ts';
13
13
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
14
+ import { collectStartupAnnouncements } from '@pellux/goodvibes-sdk/platform/runtime/feature-announcements';
14
15
  import type { PermissionRequestHandler } from '@pellux/goodvibes-sdk/platform/permissions';
15
16
  import type { CommandContext } from '../input/command-registry.ts';
16
17
  import type { InputHistory } from '../input/input-history.ts';
@@ -20,7 +21,6 @@ import type { Compositor } from '../renderer/compositor.ts';
20
21
  import type { RuntimeContext, BootstrapOptions } from './context.ts';
21
22
  import { shutdownRuntime, fireSessionStart, saveSession } from '@/runtime/index.ts';
22
23
  import { createTaskManager } from '@/runtime/index.ts';
23
- import { OpsControlPlane } from '@/runtime/index.ts';
24
24
  import type { SystemMessageRouter } from '../core/system-message-router.ts';
25
25
  import { emitSessionReady, emitSessionStarted } from '@/runtime/index.ts';
26
26
  import {
@@ -77,7 +77,7 @@ const GOODVIBES_AGENT_OPERATOR_POLICY = [
77
77
  '## GoodVibes Agent Operator Policy',
78
78
  '- Act as one user-facing autonomous assistant. Prefer the lowest-friction safe path that completes the user outcome; do not expose internal package or host ownership unless it is needed for diagnosis, setup, or safety.',
79
79
  '- Work serially in the main conversation by default for ordinary chat, research, planning, setup, local context, and short tool work. Use visible schedules, work plans, operator actions, or delegated/remote routes for durable or long-running autonomy.',
80
- '- Connected-host lifecycle is not ambient. If the host is unavailable, explain the shortest user action to make the assistant reachable; do not pretend a missing host route worked.',
80
+ '- Connected-host lifecycle is not ambient beyond one bounded boot behavior: at startup the runtime starts an installed-but-stopped host through the platform service manager and reports it. If the host is unavailable after that, explain the shortest user action to make the assistant reachable; do not pretend a missing host route worked.',
81
81
  '- Read tools: `route action:"plan|status"`, `schedule action:"list"`, `setup action:"status|item|repair|checkpoint"`, `settings action:"list|get"`, `vibe action:"status|show"`, `context action:"status|files|file|prompt|receipts|receipt"`, `memory action:"status|provider|curator|candidate|list|search|get"`, `channels action:"status|channel|setup|triage|deliveries"`, `models action:"status|route|local|providers|provider"`, `personal_ops action:"briefing|status|queue|intake|lane"`, `autonomy action:"intake|queue|item|status"`, `delegation action:"status|routes|route"`, `execution action:"status|route|history|record|processes|process_capabilities|process|recovery"`, `security action:"status|finding|explain"`, `support action:"status|bundle"`, `sessions action:"list|get"`, `audit action:"readiness|item|evidence|artifact"`, `computer action:"status|plan|control|browser|setup|mcp"`, `research action:"plan|search|runner|runs|run|sources|source|bundle|reports|report_artifact"`, `device action:"status|capability|browser|control|voice|provider"`, `workspace action:"status|actions|action|surfaces|surface|shortcuts|keybindings|keybinding|commands|command|cli_commands|cli_command"`, `host action:"status|capabilities|capability|services|service|methods|method"`, `import_goodvibes_settings action:"preview"`, and `agent_operator_briefing` for connected work/approvals/automation/schedules, `agent_knowledge` for isolated Agent Knowledge, and `agent_harness` for harness catalogs/status/capability discovery. Use `agent_artifacts` for saved artifact list/preview/export/package/archive; write modes are confirmation-gated.',
82
82
  '- Harness access: use `settings action:"set|reset|import"` and `workspace action:"run|run_command|open|run_keybinding|set_keybinding|reset_keybinding"` to use the same surfaces the user can use; lower-level `agent_harness` modes remain for detail inspection and compatibility.',
83
83
  '- State tools: `agent_work_plan` for visible local work items; `agent_local_registry` for Agent-local notes, memory, personas, skills, bundles, and routines; `agent_learning_consolidation` for confirmed duplicate cleanup phases; `agent_documents` for versioned Agent document drafts. Keep records non-secret, sourced, and reviewable.',
@@ -395,9 +395,6 @@ export async function bootstrapRuntime(
395
395
  }));
396
396
 
397
397
  const opsTaskManager = createTaskManager(store, runtimeBus, userSessionId);
398
- const opsControlPlane = services.featureFlags.isEnabled('operator-control-plane')
399
- ? new OpsControlPlane(opsTaskManager, runtimeBus, store, userSessionId)
400
- : undefined;
401
398
 
402
399
  const shell = createBootstrapShell({
403
400
  configManager,
@@ -420,13 +417,23 @@ export async function bootstrapRuntime(
420
417
  policyRuntimeState: services.policyRuntimeState,
421
418
  uiServices,
422
419
  taskManager: opsTaskManager,
423
- opsControlPlane,
424
420
  completeModelSelectionSideEffect: () => {
425
421
  compositor.resetDiff();
426
422
  },
427
423
  });
428
424
  const systemMessageRouter = shell.systemMessageRouter;
429
425
  systemMessageRouterRef.value = systemMessageRouter;
426
+ // Announce-once receipts due at boot (e.g. the web surface URL when
427
+ // web.enabled). The store is shared per install, so whichever process of
428
+ // this install boots first (daemon, TUI, or this Agent) announces; every
429
+ // other process stays silent. Mirrors the SDK daemon boot's collection.
430
+ for (const announcement of collectStartupAnnouncements({
431
+ configManager,
432
+ store: services.featureAnnouncementStore,
433
+ })) {
434
+ logger.info(announcement.text, { announcement: announcement.id });
435
+ systemMessageRouter.low(`[Startup] ${announcement.text}`);
436
+ }
430
437
  const commandRegistry = shell.commandRegistry;
431
438
  const commandContext = shell.commandContext;
432
439
  const inputHistory = shell.inputHistory;
@@ -478,9 +485,11 @@ export async function bootstrapRuntime(
478
485
 
479
486
  const deferredStartup = createDeferredStartupCoordinator();
480
487
 
481
- // GoodVibes Agent never owns the connected daemon's (or HTTP listener's)
488
+ // GoodVibes Agent does not own the connected daemon's (or HTTP listener's)
482
489
  // lifecycle — see bootstrap-external-services.ts for the adopt-only wiring
483
- // and the deferred discovery probe it schedules.
490
+ // and the deferred discovery probe it schedules. One bounded exception at
491
+ // boot: a host that is INSTALLED on this machine but stopped is started
492
+ // once through the platform service manager, with an honest receipt.
484
493
  const agentExternalServices = wireAgentExternalServices({
485
494
  configManager,
486
495
  runtimeBus,
@@ -519,13 +528,15 @@ export async function bootstrapRuntime(
519
528
  requestRender();
520
529
  },
521
530
  });
522
- // W2A: probe the daemon spine OFF the interactive path. On a reachable daemon,
531
+ // Probe the daemon spine OFF the interactive path. On a reachable daemon,
523
532
  // fold the agent's own per-cwd legacy session store into it (once; marked
524
- // migrated). Never starts the daemon; a down/absent daemon leaves the agent
525
- // local-only with an honest offline status.
533
+ // migrated). Waits for connected-host discovery (which may have just started
534
+ // an installed-but-stopped daemon) so this probe sees the settled state; a
535
+ // down/absent daemon leaves the agent local-only with an honest offline status.
526
536
  deferredStartup.schedule({
527
537
  label: 'session-spine',
528
538
  run: async () => {
539
+ await agentExternalServices.whenDiscovered();
529
540
  const reachability = await services.sessionSpineClient.probeReachability();
530
541
  if (reachability !== 'online') return;
531
542
  foldLegacySpineStore(services.sessionSpineClient, {
@@ -558,10 +569,21 @@ export async function bootstrapRuntime(
558
569
  memorySpineClient: services.memorySpineClient,
559
570
  transport: services.memorySpineTransport,
560
571
  probeReachability: () => services.sessionSpineClient.probeReachability(),
572
+ // One consuming /status read per attach: a current daemon delivers its
573
+ // one-shot honesty receipts ("restarted after a crash at HH:MM", "updated
574
+ // from X to Y") only to a ?receipts=consume read, and this adoption edge is
575
+ // exactly where the agent (re)attaches to a daemon. The plain liveness probe
576
+ // stays receipt-neutral.
577
+ onAttach: () => services.consumeDaemonReceipts(),
561
578
  });
562
579
  deferredStartup.schedule({
563
580
  label: 'memory-spine',
564
581
  run: async () => {
582
+ // Wait for connected-host discovery (which may have just started an
583
+ // installed-but-stopped daemon) so the first adoption decision sees the
584
+ // settled daemon state instead of racing it; the heartbeat below keeps
585
+ // reconciling for the rest of the process lifetime either way.
586
+ await agentExternalServices.whenDiscovered();
565
587
  await reconcileMemorySpine();
566
588
  // Prime the recall snapshot (SDK 1.2.0 sync-recall seam) so the FIRST
567
589
  // system prompt built after boot already has a real snapshot to read
@@ -721,7 +743,7 @@ export async function bootstrapRuntime(
721
743
  commandRegistry,
722
744
  systemMessageRouter,
723
745
  shutdown: async (sessionData) => {
724
- // W2A: best-effort spine close (short timeout, fire-and-forget) then stop
746
+ // Best-effort spine close (short timeout, fire-and-forget) then stop
725
747
  // the heartbeat timer. Tolerates a racing daemon stop; never blocks teardown.
726
748
  services.sessionSpineClient.close(runtime.sessionId);
727
749
  services.sessionSpineClient.dispose();
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Boot-time start of an installed-but-stopped connected GoodVibes host.
3
+ *
4
+ * When boot discovery finds nothing on the configured daemon host/port, a
5
+ * machine that actually HAS the host installed should not hand the user
6
+ * homework. This module checks whether the host's service entry is present
7
+ * for the platform service manager and, when it is installed but stopped,
8
+ * issues one start through that service manager, then waits a bounded time
9
+ * for the daemon to answer before the session proceeds.
10
+ *
11
+ * Boundaries that stay exactly as strict as before:
12
+ * - A reachable daemon (adopted or embedded) is never restarted.
13
+ * - A held port ('blocked' by an unverified process, or 'incompatible'
14
+ * wire version) is respected and left alone — the closest states the
15
+ * probe has to "another owner is mid-update or mid-restart".
16
+ * - A service unit that the service manager already reports active gets a
17
+ * bounded wait only, never a second start command underneath it.
18
+ * - The Agent still never spawns or embeds a daemon of its own, and a host
19
+ * that is genuinely not installed keeps the existing honest guidance.
20
+ *
21
+ * Detection reuses the platform's one existing installed-daemon detector,
22
+ * the SDK's PlatformServiceManager (`status().installed` = service entry
23
+ * present, `status().running` = live service-manager query). It is checked
24
+ * for the connected host's managed service name and for the older unit name
25
+ * earlier installs used; a `service.serviceName` config value overrides both,
26
+ * through the manager's own name resolution.
27
+ */
28
+ import { PlatformServiceManager, type ManagedServiceStatus } from '@pellux/goodvibes-sdk/platform/daemon';
29
+ import type { SpineReachability } from '@pellux/goodvibes-sdk/platform/runtime/session-spine';
30
+ import type { ConfigManager } from '../config/index.ts';
31
+
32
+ /** The connected host's managed service name (the goodvibes CLI's default). */
33
+ export const MANAGED_CONNECTED_HOST_SERVICE_NAME = 'goodvibes';
34
+ /** The unit name older connected-host installs registered. */
35
+ export const LEGACY_CONNECTED_HOST_SERVICE_NAME = 'goodvibes-daemon';
36
+
37
+ /** Structural mirror of the SDK service manager's action-runner result. */
38
+ export interface ConnectedHostServiceActionResult {
39
+ readonly status: number | null;
40
+ readonly stdout?: string | undefined;
41
+ readonly stderr?: string | undefined;
42
+ }
43
+
44
+ /** One candidate service entry, as the platform service manager sees it. */
45
+ export interface ConnectedHostServiceSnapshot {
46
+ readonly serviceName: string;
47
+ readonly platform: ManagedServiceStatus['platform'];
48
+ readonly unitPath: string;
49
+ readonly installed: boolean;
50
+ readonly running: boolean;
51
+ /**
52
+ * False on the 'manual' platform: there the SDK manager would spawn its own
53
+ * locally-resolved command, which the Agent cannot honestly resolve for a
54
+ * host it does not own — those stay on the guidance path.
55
+ */
56
+ readonly startSupported: boolean;
57
+ }
58
+
59
+ export interface ConnectedHostServiceStartResult {
60
+ readonly ok: boolean;
61
+ readonly error?: string | undefined;
62
+ }
63
+
64
+ /** Narrow detector/starter seam over the SDK's PlatformServiceManager. */
65
+ export interface ConnectedHostServiceControl {
66
+ snapshot(): readonly ConnectedHostServiceSnapshot[];
67
+ start(serviceName: string): ConnectedHostServiceStartResult;
68
+ }
69
+
70
+ export interface ConnectedHostServiceControlOptions {
71
+ readonly configManager: ConfigManager;
72
+ readonly workingDirectory: string;
73
+ readonly homeDirectory: string;
74
+ /** Injectable systemctl/launchctl/schtasks runner so tests never touch the host. */
75
+ readonly actionRunner?: ((command: string, args: readonly string[]) => ConnectedHostServiceActionResult) | undefined;
76
+ }
77
+
78
+ function summarizeThrown(error: unknown): string {
79
+ return error instanceof Error ? error.message : String(error);
80
+ }
81
+
82
+ /**
83
+ * Build the detector/starter over the SDK's PlatformServiceManager.
84
+ *
85
+ * When `service.serviceName` is configured, the manager's own resolution
86
+ * collapses every candidate to that one name, so a single manager is built;
87
+ * otherwise the managed and legacy default names are each checked.
88
+ */
89
+ export function createConnectedHostServiceControl(
90
+ options: ConnectedHostServiceControlOptions,
91
+ ): ConnectedHostServiceControl {
92
+ // The config schema ships `service.serviceName` with the managed default, so
93
+ // get() never returns undefined here. A value that IS the managed default
94
+ // (configured or defaulted — indistinguishable, and equivalent) also gets the
95
+ // older unit name checked; any other value is an explicit operator choice and
96
+ // is trusted exclusively.
97
+ const primaryName = String(options.configManager.get('service.serviceName') ?? '').trim()
98
+ || MANAGED_CONNECTED_HOST_SERVICE_NAME;
99
+ const candidateNames = primaryName === MANAGED_CONNECTED_HOST_SERVICE_NAME
100
+ ? [MANAGED_CONNECTED_HOST_SERVICE_NAME, LEGACY_CONNECTED_HOST_SERVICE_NAME]
101
+ : [primaryName];
102
+ // Pin each manager's view of `service.serviceName` to its candidate so the
103
+ // SDK's own name resolution yields exactly that unit (the schema default
104
+ // would otherwise shadow every `defaultServiceName`). Only `get` is consulted
105
+ // by the manager, so the narrow delegate below is honest.
106
+ const pinnedConfigView = (serviceName: string): ConfigManager => ({
107
+ get: (key: string) => key === 'service.serviceName' ? serviceName : options.configManager.get(key as Parameters<ConfigManager['get']>[0]),
108
+ }) as unknown as ConfigManager;
109
+ const managers = candidateNames.map((resolvedName) => ({
110
+ resolvedName,
111
+ manager: new PlatformServiceManager(pinnedConfigView(resolvedName), {
112
+ workingDirectory: options.workingDirectory,
113
+ homeDirectory: options.homeDirectory,
114
+ // Match the connected host's own service scope (log/pid file layout on the
115
+ // manual platform); systemd/launchd/windows entries are home-scoped anyway.
116
+ surfaceRoot: 'daemon',
117
+ defaultServiceName: resolvedName,
118
+ ...(options.actionRunner ? { actionRunner: options.actionRunner } : {}),
119
+ }),
120
+ }));
121
+
122
+ const toSnapshot = (status: ManagedServiceStatus): ConnectedHostServiceSnapshot => ({
123
+ serviceName: status.serviceName,
124
+ platform: status.platform,
125
+ unitPath: status.path,
126
+ installed: status.installed,
127
+ running: status.running,
128
+ startSupported: status.platform !== 'manual',
129
+ });
130
+
131
+ return {
132
+ snapshot(): readonly ConnectedHostServiceSnapshot[] {
133
+ const seen = new Set<string>();
134
+ const snapshots: ConnectedHostServiceSnapshot[] = [];
135
+ for (const { resolvedName, manager } of managers) {
136
+ if (seen.has(resolvedName)) continue;
137
+ seen.add(resolvedName);
138
+ snapshots.push(toSnapshot(manager.status()));
139
+ }
140
+ return snapshots;
141
+ },
142
+ start(serviceName: string): ConnectedHostServiceStartResult {
143
+ const candidate = managers.find((entry) => entry.resolvedName === serviceName);
144
+ if (!candidate) {
145
+ return { ok: false, error: `no service entry named "${serviceName}" is tracked on this host` };
146
+ }
147
+ try {
148
+ const result = candidate.manager.start();
149
+ return result.actionError === undefined
150
+ ? { ok: true }
151
+ : { ok: false, error: result.actionError };
152
+ } catch (error) {
153
+ return { ok: false, error: summarizeThrown(error) };
154
+ }
155
+ },
156
+ };
157
+ }
158
+
159
+ /** Subset of the discovery status the autostart decision needs. */
160
+ export interface ConnectedHostDaemonStatusView {
161
+ readonly mode: 'disabled' | 'embedded' | 'external' | 'blocked' | 'incompatible' | 'unavailable';
162
+ }
163
+
164
+ export type ConnectedHostAutostartOutcome =
165
+ /** Nothing to do: the daemon answers, the port is held, or the daemon is disabled by config. */
166
+ | { readonly action: 'none'; readonly reason: 'daemon-active' | 'port-held' | 'daemon-disabled' }
167
+ /** No service entry is installed on this host — the existing guidance path is unchanged. */
168
+ | { readonly action: 'not-installed' }
169
+ /** The Agent issued a start through the service manager and the daemon answered. */
170
+ | { readonly action: 'started'; readonly serviceName: string }
171
+ /** The service unit was already active (starting or restarting); the daemon answered within the bounded wait, with no start issued. */
172
+ | { readonly action: 'came-online'; readonly serviceName: string }
173
+ /** A start was attempted (or was impossible) and the daemon still does not answer; guidance applies, with this reason. */
174
+ | { readonly action: 'start-failed'; readonly serviceName: string; readonly reason: string };
175
+
176
+ export interface ConnectedHostAutostartOptions {
177
+ readonly daemonStatus: ConnectedHostDaemonStatusView;
178
+ readonly control: ConnectedHostServiceControl;
179
+ readonly probeReachability: () => Promise<SpineReachability>;
180
+ /** Total bounded wait for the daemon to answer after a start (default 8000ms). */
181
+ readonly waitTimeoutMs?: number | undefined;
182
+ /** Poll interval within the bounded wait (default 400ms). */
183
+ readonly pollIntervalMs?: number | undefined;
184
+ /** Injectable so tests drive the wait loop deterministically. */
185
+ readonly sleep?: ((ms: number) => Promise<void>) | undefined;
186
+ }
187
+
188
+ const DEFAULT_WAIT_TIMEOUT_MS = 8_000;
189
+ const DEFAULT_POLL_INTERVAL_MS = 400;
190
+
191
+ /**
192
+ * Decide and (when safe) perform the boot-time start of an installed-but-
193
+ * stopped connected host. Pure over its seams: every effect goes through
194
+ * `control` and `probeReachability`.
195
+ */
196
+ export async function autostartInstalledConnectedHost(
197
+ options: ConnectedHostAutostartOptions,
198
+ ): Promise<ConnectedHostAutostartOutcome> {
199
+ const waitTimeoutMs = options.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
200
+ const pollIntervalMs = Math.max(1, options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
201
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => {
202
+ const timer = setTimeout(resolve, ms);
203
+ timer.unref?.();
204
+ }));
205
+ // Attempt-counted (not wall-clock) so an injected no-op sleep still terminates.
206
+ const attempts = Math.max(1, Math.ceil(waitTimeoutMs / pollIntervalMs));
207
+
208
+ const waitForAnswer = async (): Promise<boolean> => {
209
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
210
+ if (attempt > 0) await sleep(pollIntervalMs);
211
+ if ((await options.probeReachability()) === 'online') return true;
212
+ }
213
+ return false;
214
+ };
215
+
216
+ switch (options.daemonStatus.mode) {
217
+ case 'embedded':
218
+ case 'external':
219
+ // A running daemon is never restarted.
220
+ return { action: 'none', reason: 'daemon-active' };
221
+ case 'blocked':
222
+ case 'incompatible':
223
+ // The port is held — by an unverified process or a daemon this build
224
+ // refuses to adopt. Either way another owner may be mid-update or
225
+ // mid-restart; never fight it.
226
+ return { action: 'none', reason: 'port-held' };
227
+ case 'disabled':
228
+ return { action: 'none', reason: 'daemon-disabled' };
229
+ case 'unavailable':
230
+ break;
231
+ }
232
+
233
+ const installed = options.control.snapshot().find((snapshot) => snapshot.installed);
234
+ if (!installed) return { action: 'not-installed' };
235
+
236
+ if (installed.running) {
237
+ // The service manager already reports the unit active — it may be mid-start
238
+ // or mid-restart. Wait for it, but never issue another start underneath it.
239
+ if (await waitForAnswer()) return { action: 'came-online', serviceName: installed.serviceName };
240
+ return {
241
+ action: 'start-failed',
242
+ serviceName: installed.serviceName,
243
+ reason: `service "${installed.serviceName}" is active per the service manager but the daemon did not answer within ${waitTimeoutMs}ms — check its logs`,
244
+ };
245
+ }
246
+
247
+ if (!installed.startSupported) {
248
+ return {
249
+ action: 'start-failed',
250
+ serviceName: installed.serviceName,
251
+ reason: `service "${installed.serviceName}" is installed without a service-manager entry this Agent can start`,
252
+ };
253
+ }
254
+
255
+ const startResult = options.control.start(installed.serviceName);
256
+ if (!startResult.ok) {
257
+ return {
258
+ action: 'start-failed',
259
+ serviceName: installed.serviceName,
260
+ reason: startResult.error ?? 'the service start command failed',
261
+ };
262
+ }
263
+ if (await waitForAnswer()) return { action: 'started', serviceName: installed.serviceName };
264
+ return {
265
+ action: 'start-failed',
266
+ serviceName: installed.serviceName,
267
+ reason: `the service start command was accepted but the daemon did not answer within ${waitTimeoutMs}ms`,
268
+ };
269
+ }