@pellux/goodvibes-agent 1.9.0 → 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.
- package/CHANGELOG.md +18 -0
- package/README.md +28 -1
- package/dist/package/main.js +36631 -28780
- package/dist/package/{web-tree-sitter-jbz042ba.wasm → web-tree-sitter-e011xaqr.wasm} +0 -0
- package/docs/README.md +1 -1
- package/docs/connected-host.md +1 -1
- package/docs/getting-started.md +1 -1
- package/docs/release-and-publishing.md +5 -1
- package/docs/tools-and-commands.md +1 -0
- package/package.json +3 -3
- package/release/live-verification/live-verification.json +11 -11
- package/release/live-verification/live-verification.md +12 -12
- package/src/agent/email/email-service.ts +1 -1
- package/src/agent/email/imap-client.ts +4 -4
- package/src/agent/email/smtp-client.ts +5 -5
- package/src/cli/config-overrides.ts +29 -14
- package/src/cli/entrypoint.ts +12 -4
- package/src/cli/launch-auto-update.ts +218 -0
- package/src/cli/relay-command.ts +4 -4
- package/src/cli/service-posture.ts +2 -2
- package/src/cli/tui-startup.ts +31 -0
- package/src/cli/workspaces-command.ts +5 -1
- package/src/cli-flags.ts +1 -1
- package/src/config/index.ts +1 -1
- package/src/config/update-settings.ts +45 -0
- package/src/config/workspace-registration.ts +214 -15
- package/src/input/commands/runtime-services.ts +0 -5
- package/src/input/commands/update-runtime.ts +313 -0
- package/src/input/commands.ts +2 -0
- package/src/input/feed-context-factory.ts +2 -2
- package/src/input/handler-feed.ts +8 -8
- package/src/input/handler.ts +1 -1
- package/src/input/mcp-workspace.ts +5 -1
- package/src/input/panel-paste-flood-guard.ts +1 -1
- package/src/input/settings-modal-types.ts +11 -5
- package/src/input/settings-modal.ts +56 -61
- package/src/main.ts +19 -20
- package/src/renderer/activity-sidebar.ts +53 -4
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +37 -25
- package/src/runtime/bootstrap-core.ts +16 -5
- package/src/runtime/bootstrap-external-services.ts +107 -11
- package/src/runtime/bootstrap-hook-bridge.ts +2 -2
- package/src/runtime/bootstrap-shell.ts +4 -4
- package/src/runtime/bootstrap.ts +34 -12
- package/src/runtime/connected-host-autostart.ts +269 -0
- package/src/runtime/daemon-receipts.ts +66 -0
- package/src/runtime/diagnostics/panels/index.ts +0 -2
- package/src/runtime/feature-enablement.ts +176 -0
- package/src/runtime/index.ts +12 -29
- package/src/runtime/memory-spine-adoption.ts +18 -0
- package/src/runtime/onboarding/apply.ts +50 -37
- package/src/runtime/onboarding/types.ts +2 -2
- package/src/runtime/onboarding/verify.ts +22 -4
- package/src/runtime/release-artifacts.ts +113 -0
- package/src/runtime/services.ts +228 -19
- package/src/runtime/session-spine-rest-transport.ts +84 -4
- package/src/runtime/ui-services.ts +1 -1
- package/src/runtime/update-check.ts +64 -0
- package/src/shell/ui-openers.ts +8 -0
- package/src/tools/agent-harness-metadata.ts +8 -0
- package/src/tools/agent-harness-setup-connected-host.ts +1 -1
- package/src/tools/agent-harness-setup-posture.ts +1 -1
- package/src/version.ts +1 -1
- package/src/runtime/diagnostics/panels/ops.ts +0 -156
- 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
|
|
6
|
-
*
|
|
5
|
+
* keep this concern — and the Agent's daemon-lifecycle boundary — in one
|
|
6
|
+
* small, readable place.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
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.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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,
|
package/src/runtime/bootstrap.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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).
|
|
525
|
-
//
|
|
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
|
-
//
|
|
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
|
+
}
|