@pellux/goodvibes-tui 1.22.0 → 1.24.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 (36) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +7 -7
  5. package/src/cli/bundle-command.ts +4 -0
  6. package/src/cli/help.ts +5 -0
  7. package/src/cli/management-utils.ts +4 -0
  8. package/src/cluster/commands.ts +382 -0
  9. package/src/cluster/remote-daemon-target.ts +225 -0
  10. package/src/cluster/render.ts +268 -0
  11. package/src/core/conversation-line-cache.ts +11 -6
  12. package/src/core/conversation-render-context.ts +122 -0
  13. package/src/core/conversation-rendering.ts +86 -89
  14. package/src/daemon/cli.ts +71 -2
  15. package/src/daemon/handlers/inbox/index.ts +63 -3
  16. package/src/daemon/handlers/inbox/poller.ts +54 -8
  17. package/src/input/command-args-hint.ts +2 -0
  18. package/src/input/commands/cluster-runtime.ts +53 -0
  19. package/src/input/commands.ts +2 -0
  20. package/src/input/settings-modal-data.ts +6 -0
  21. package/src/main.ts +3 -0
  22. package/src/renderer/conversation-surface.ts +23 -6
  23. package/src/renderer/conversation-tree.ts +142 -93
  24. package/src/renderer/tool-call.ts +13 -12
  25. package/src/runtime/bootstrap.ts +6 -1
  26. package/src/runtime/cluster-composition.ts +124 -0
  27. package/src/runtime/cluster-group-composition.ts +284 -0
  28. package/src/runtime/daemon-handler-composition.ts +80 -0
  29. package/src/runtime/disposal-wiring.ts +66 -0
  30. package/src/runtime/durability-services.ts +7 -3
  31. package/src/runtime/embedded-service-factories.ts +19 -1
  32. package/src/runtime/process-lifecycle.ts +9 -1
  33. package/src/runtime/remote-execution-composition.ts +62 -0
  34. package/src/runtime/services.ts +50 -50
  35. package/src/verification/verification-ledger.ts +38 -0
  36. package/src/version.ts +1 -1
@@ -0,0 +1,284 @@
1
+ /**
2
+ * cluster-group-composition.ts — this machine's membership of a LAN group.
3
+ *
4
+ * Leader election answers "which of us reads the inbox". This answers the
5
+ * question underneath it: "which machines are US". Without a group, any daemon
6
+ * that happened to be on the same network — a neighbour's, a colleague's, a
7
+ * container someone left running — would join the same coordination and one of
8
+ * you would silently stop receiving messages.
9
+ *
10
+ * The group layer owns the socket and the election rides on it: every
11
+ * coordination datagram is wrapped in the group envelope and signed with the
12
+ * current group key, so a datagram from outside the group never reaches the
13
+ * election at all. That is why `createClusterComposition` is handed a transport
14
+ * from here instead of opening one of its own.
15
+ *
16
+ * Key material lives ONLY in the encrypted secrets store. Never in config,
17
+ * never in a log line, never in `/status`.
18
+ */
19
+ import {
20
+ ClusterGroupRuntime,
21
+ createClusterGroupVerbs,
22
+ createSystemClusterClock,
23
+ readClusterSettings,
24
+ rejoinGroup,
25
+ resolveClusterGroupSettings,
26
+ resolveNodeIdentity,
27
+ stillOnRoster,
28
+ UdpClusterTransport,
29
+ type ClusterGroupVerbSurface,
30
+ type ClusterSurfaceHolding,
31
+ type GroupOperationsContext,
32
+ } from '@pellux/goodvibes-sdk/platform/cluster';
33
+ import type { ConfigManager, SecretsManager } from '@pellux/goodvibes-sdk/platform/config';
34
+ import type { ClusterTransport } from '@pellux/goodvibes-sdk/platform/cluster';
35
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
36
+ import type { ShellPathService } from '@/runtime/index.ts';
37
+ import { createClusterComposition } from './cluster-composition.ts';
38
+ import type { ClusterCoordinator } from '@pellux/goodvibes-sdk/platform/cluster';
39
+ import { GOODVIBES_TUI_SURFACE_ROOT } from '../config/surface.ts';
40
+ import { VERSION } from '../version.ts';
41
+
42
+ export interface ClusterGroupComposition {
43
+ readonly runtime: ClusterGroupRuntime;
44
+ readonly verbs: ClusterGroupVerbSurface;
45
+ /** The transport the leader election must use, so its traffic is group-signed. */
46
+ readonly electionTransport: ClusterTransport;
47
+ /** Start the group layer. Safe to call more than once. */
48
+ start(): Promise<void>;
49
+ stop(): Promise<void>;
50
+ }
51
+
52
+ /**
53
+ * A label for THIS machine in the member list.
54
+ *
55
+ * Deliberately NOT the hostname. The roster replicates to every member and the
56
+ * group name reaches the network in a beacon, so defaulting to a hostname would
57
+ * quietly publish "mikes-laptop" to anything listening. A short slice of the
58
+ * node id is meaningless and safe, and `cluster nodes` shows it plainly so an
59
+ * operator who wants a real name can see there is one to set.
60
+ */
61
+ function defaultNodeDisplayName(nodeId: string): string {
62
+ return `machine ${nodeId.slice(0, 8)}`;
63
+ }
64
+
65
+ /**
66
+ * Build the group layer.
67
+ *
68
+ * Constructing it is inert: no socket is opened and no key material is read
69
+ * until `start()`, so composing a runtime in a test never touches the network
70
+ * or the secrets store.
71
+ */
72
+ export function createClusterGroupComposition(options: {
73
+ readonly configManager: ConfigManager;
74
+ readonly shellPaths: ShellPathService;
75
+ readonly secretsManager: SecretsManager;
76
+ /**
77
+ * The leader election's own answer to "am I the master".
78
+ *
79
+ * Config replication needs exactly one machine issuing revisions, and it must
80
+ * be the SAME machine leadership already picked — two notions of master in
81
+ * one process would disagree the moment one of them changed.
82
+ *
83
+ * Late-bound because the coordinator is built from this composition's
84
+ * transport: it does not exist yet when this function runs.
85
+ */
86
+ readonly isMaster?: (() => boolean) | undefined;
87
+ /**
88
+ * Which surfaces this machine currently holds, and why.
89
+ *
90
+ * The per-surface election establishes this; the group layer only reports it,
91
+ * in `cluster status` and in /status. Late-bound for the same reason
92
+ * `isMaster` is. Absent means the group layer reports that the information is
93
+ * unavailable rather than reporting an empty list as though this machine held
94
+ * nothing — a distinction an operator diagnosing a silent inbox depends on.
95
+ */
96
+ readonly surfaceHoldings?: (() => readonly ClusterSurfaceHolding[]) | undefined;
97
+ }): ClusterGroupComposition {
98
+ const settings = readClusterSettings(options.configManager);
99
+ const groupSettings = resolveClusterGroupSettings(
100
+ (options.configManager as { getCategory?: (name: string) => unknown }).getCategory?.('cluster'),
101
+ );
102
+ // Surface-scoped, alongside the election's node identity and this surface's
103
+ // other durable state.
104
+ const stateDirectory = options.shellPaths.resolveProjectPath(GOODVIBES_TUI_SURFACE_ROOT, 'cluster');
105
+ const nodeId = resolveNodeIdentity({ stateDirectory, logger }).nodeId;
106
+
107
+ const runtime = new ClusterGroupRuntime({
108
+ settings: groupSettings,
109
+ transport: new UdpClusterTransport({
110
+ port: settings.port,
111
+ multicastGroup: settings.multicastGroup,
112
+ peers: settings.peers,
113
+ logger,
114
+ }),
115
+ // Narrower than the SecretsManager itself: the group layer gets get/set/
116
+ // delete on one key and nothing else.
117
+ secrets: {
118
+ get: (key) => options.secretsManager.get(key),
119
+ set: (key, value) => options.secretsManager.set(key, value),
120
+ delete: (key) => options.secretsManager.delete(key),
121
+ },
122
+ stateDirectory,
123
+ nodeId,
124
+ nodeDisplayName: defaultNodeDisplayName(nodeId),
125
+ version: VERSION,
126
+ clock: createSystemClusterClock(),
127
+ logger,
128
+ ...(options.isMaster ? { isMaster: options.isMaster } : {}),
129
+ ...(options.surfaceHoldings ? { surfaceHoldings: options.surfaceHoldings } : {}),
130
+ // Only daemon-owned, group-scoped keys ever reach this; the SDK's
131
+ // replication policy decides which, and refuses anything machine-specific.
132
+ config: {
133
+ get: (path) => (options.configManager as unknown as {
134
+ get(key: string): unknown;
135
+ }).get(path),
136
+ set: (path, value) => (options.configManager as unknown as {
137
+ set(key: string, value: unknown): void;
138
+ }).set(path, value),
139
+ },
140
+ });
141
+
142
+ const context: GroupOperationsContext = {
143
+ runtime,
144
+ secrets: {
145
+ get: (key) => options.secretsManager.get(key),
146
+ set: (key, value) => options.secretsManager.set(key, value),
147
+ delete: (key) => options.secretsManager.delete(key),
148
+ },
149
+ settings: groupSettings,
150
+ nodeId,
151
+ nodeDisplayName: defaultNodeDisplayName(nodeId),
152
+ version: VERSION,
153
+ now: () => Date.now(),
154
+ };
155
+
156
+ let started = false;
157
+ return {
158
+ runtime,
159
+ verbs: createClusterGroupVerbs(context),
160
+ electionTransport: runtime.electionTransport(),
161
+ start: async () => {
162
+ if (started) return;
163
+ started = true;
164
+ await runtime.start();
165
+ await announceReturn(runtime, context, nodeId);
166
+ },
167
+ stop: async () => {
168
+ started = false;
169
+ await runtime.stop();
170
+ },
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Build both halves of the LAN cluster, wired to each other.
176
+ *
177
+ * They are mutually dependent and the dependency runs both ways, which is why
178
+ * this exists rather than two calls at the composition root: the election
179
+ * coordinates over the GROUP's transport, and the group's config replication
180
+ * needs the ELECTION's answer to "am I the master". The master signal is read
181
+ * through a closure because the coordinator does not exist yet when the group
182
+ * layer is constructed.
183
+ *
184
+ * Constructing either is inert — no socket, no key material read — until
185
+ * `startCluster` runs.
186
+ */
187
+ export function createClusterServices(options: {
188
+ readonly configManager: ConfigManager;
189
+ readonly shellPaths: ShellPathService;
190
+ readonly secretsManager: SecretsManager;
191
+ }): { readonly clusterGroup: ClusterGroupComposition; readonly clusterCoordinator: ClusterCoordinator } {
192
+ let coordinator: ClusterCoordinator | null = null;
193
+ const clusterGroup = createClusterGroupComposition({
194
+ ...options,
195
+ isMaster: () => coordinator?.isMaster ?? false,
196
+ // The elections actually running are the only honest source for this, so
197
+ // the group layer reads them rather than keeping a second tally that could
198
+ // disagree. Before the coordinator exists there is nothing to report, and
199
+ // an empty list is the truthful answer at that point: no election has been
200
+ // held, so this machine holds nothing.
201
+ surfaceHoldings: () => coordinator?.surfaceHoldings() ?? [],
202
+ });
203
+ coordinator = createClusterComposition({
204
+ configManager: options.configManager,
205
+ shellPaths: options.shellPaths,
206
+ transport: clusterGroup.electionTransport,
207
+ });
208
+ return { clusterGroup, clusterCoordinator: coordinator };
209
+ }
210
+
211
+ /**
212
+ * Start the LAN cluster, in the order it has to be started in.
213
+ *
214
+ * The group layer FIRST: it owns the socket the leader election coordinates
215
+ * over, and starting the election against a transport whose group is not yet
216
+ * loaded would sign its first datagrams with nothing. Both calls are
217
+ * idempotent, so a composition root that reaches this twice is fine.
218
+ */
219
+ export async function startClusterServices(services: {
220
+ readonly clusterGroup: Pick<ClusterGroupComposition, 'start'>;
221
+ readonly clusterCoordinator: { start(): Promise<void> };
222
+ }): Promise<void> {
223
+ await services.clusterGroup.start();
224
+ await services.clusterCoordinator.start();
225
+ }
226
+
227
+ /**
228
+ * Ask the group to take this machine back, on start.
229
+ *
230
+ * This is the zero-touch return. A machine that has been switched off for
231
+ * months — through many group-key rotations and possibly a join-key change —
232
+ * comes up holding stale keys, says "it is still me" with a key that never
233
+ * rotates, and is re-keyed to the current generation by whichever member
234
+ * answers. The operator does nothing.
235
+ *
236
+ * It runs only when this machine still believes it is on the roster. A machine
237
+ * that was REMOVED asks for nothing: it would be refused, and asking anyway
238
+ * would put a pointless refusal in everyone's logs every time it started.
239
+ */
240
+ async function announceReturn(
241
+ runtime: ClusterGroupRuntime,
242
+ context: GroupOperationsContext,
243
+ nodeId: string,
244
+ ): Promise<void> {
245
+ if (runtime.membership !== 'member') return;
246
+ if (!stillOnRoster(runtime.groupState, nodeId)) return;
247
+ const result = await rejoinGroup(context);
248
+ if (result.ok) {
249
+ logger.info('cluster: rejoined the group and took the current group key', {
250
+ group: result.data.groupName,
251
+ members: result.data.memberCount,
252
+ });
253
+ return;
254
+ }
255
+ if (result.terminal) {
256
+ // A member refused this machine and proved it was a member when it did.
257
+ // Retrying will never succeed, so this is not a quiet debug line: without
258
+ // it the operator sees a healthy daemon that is simply never given any
259
+ // work, with nothing anywhere saying why. Stated at ERROR with the command
260
+ // that fixes it, the same standard a surface this node cannot serve is
261
+ // held to.
262
+ logger.error('cluster: this machine is no longer in its group and will not be given work', {
263
+ reason: result.error,
264
+ action: result.fix,
265
+ });
266
+ return;
267
+ }
268
+ if (result.failure === 'unverifiable-replies') {
269
+ // Something answered and none of it could be authenticated. This machine
270
+ // cannot tell a removal it slept through from a stranger making noise, so
271
+ // it says exactly that and does not assert either. WARN rather than ERROR
272
+ // for the same reason: anything on the network can produce this, and a
273
+ // machine that shouted "you were removed" on cue would be worse than one
274
+ // that stayed quiet.
275
+ logger.warn('cluster: replies to this machine\'s return could not be authenticated', {
276
+ reason: result.error,
277
+ action: result.fix,
278
+ });
279
+ return;
280
+ }
281
+ // Not an error. A machine that starts first, or is alone on the network, has
282
+ // nobody to answer it and carries on with the keys it already holds.
283
+ logger.debug('cluster: no other machine answered the return announcement', { reason: result.error });
284
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * daemon-handler-composition.ts — the daemon's HOST-side handler surfaces.
3
+ *
4
+ * Attaches this repository's handlers to the SDK-auto-registered builtin
5
+ * gateway descriptors (channels.* / email.* / calendar.*) via
6
+ * catalog.register(descriptor, handler, { replace: true }) — the SDK owns
7
+ * every id, descriptor and schema; only the behaviour is ours. The remote
8
+ * surface reuses the SAME DistributedRuntimeManager the SDK facade injects.
9
+ *
10
+ * Split out of services.ts for the 800-line file cap, the same reason
11
+ * channel-composition.ts exists.
12
+ *
13
+ * The one behavioural decision that lives here: the inbox poller is handed to
14
+ * the cluster coordinator instead of being started eagerly. It is this
15
+ * product's own inbound consumer — the SDK facade does not know it exists —
16
+ * so if it is not gated here it is not gated anywhere, and two goodvibes nodes
17
+ * on one network each read the shared inbox and answer the same message twice.
18
+ */
19
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
20
+ import type { ClusterCoordinator } from '@pellux/goodvibes-sdk/platform/cluster';
21
+ import type { GatewayMethodCatalog } from '@pellux/goodvibes-sdk/platform/control-plane';
22
+ import type { SecretsManager } from '@pellux/goodvibes-sdk/platform/config';
23
+ import { registerDaemonHandlers, type DaemonHandlerSurfaces } from '../daemon/handlers/index.ts';
24
+ import type { HandlerContext, HandlerLogger } from '../daemon/handlers/context.ts';
25
+ import { createDaemonCredentialStore } from '../daemon/handlers/credentials.ts';
26
+ import { registerRouting } from '../daemon/handlers/routing/index.ts';
27
+ import { registerInboxMethods } from '../daemon/handlers/inbox/index.ts';
28
+ import { registerTriagedInbox } from '../daemon/handlers/triage/index.ts';
29
+ import { registerDraftMethods } from '../daemon/handlers/drafts/index.ts';
30
+ import { registerCalendar } from '../daemon/handlers/calendar/index.ts';
31
+ import { registerEmailMethods } from '../daemon/handlers/email/index.ts';
32
+ import { registerRemoteSurface } from '../daemon/handlers/remote/index.ts';
33
+ import { inboxPollerGate } from './cluster-composition.ts';
34
+
35
+ export interface DaemonHandlerCompositionOptions {
36
+ readonly gatewayMethods: GatewayMethodCatalog;
37
+ readonly secretsManager: SecretsManager;
38
+ readonly configManager: ConfigManager;
39
+ readonly workingDirectory: string;
40
+ readonly homeDirectory: string;
41
+ readonly distributedRuntime: NonNullable<Parameters<typeof registerRemoteSurface>[1]>['manager'];
42
+ /**
43
+ * Decides whether THIS node polls the shared inbox. Always supplied by the
44
+ * composition root; the poller is never started outside it.
45
+ */
46
+ readonly clusterCoordinator: ClusterCoordinator;
47
+ }
48
+
49
+ export function createDaemonHandlerComposition(
50
+ options: DaemonHandlerCompositionOptions,
51
+ ): DaemonHandlerSurfaces {
52
+ const handlerLogger: HandlerLogger = {
53
+ info: (message, meta) => console.info(message, meta ?? ''),
54
+ warn: (message, meta) => console.warn(message, meta ?? ''),
55
+ error: (message, meta) => console.error(message, meta ?? ''),
56
+ };
57
+ const handlerContext: HandlerContext = {
58
+ catalog: options.gatewayMethods,
59
+ credentials: createDaemonCredentialStore(options.secretsManager),
60
+ configManager: options.configManager,
61
+ workingDirectory: options.workingDirectory,
62
+ homeDirectory: options.homeDirectory,
63
+ logger: handlerLogger,
64
+ };
65
+ return registerDaemonHandlers(handlerContext, {
66
+ registerRouting,
67
+ registerInbox: (ctx, routing) =>
68
+ registerTriagedInbox(ctx, (inboxCtx) => registerInboxMethods(inboxCtx, routing, {
69
+ // Hands polling to leadership. The `channels.inbox.list` read stays
70
+ // available on every node — a standby still SERVES the persisted feed,
71
+ // it just does not FETCH into it.
72
+ gatePolling: (providerId, control) =>
73
+ options.clusterCoordinator.register(inboxPollerGate(providerId, control)),
74
+ })).unregister,
75
+ registerDrafts: (ctx) => registerDraftMethods(ctx),
76
+ registerCalendar,
77
+ registerEmail: (ctx) => registerEmailMethods(ctx),
78
+ registerRemote: (ctx) => registerRemoteSurface(ctx, { manager: options.distributedRuntime }),
79
+ });
80
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * disposal-wiring.ts
3
+ *
4
+ * Teardown for every poller this surface's runtime graph starts.
5
+ *
6
+ * The mechanics — the ordered, best-effort, idempotent scope and the
7
+ * all-required owner list — live in the SDK (`platform/runtime/disposal`), so
8
+ * this surface and the SDK's own composition root cannot drift into two
9
+ * different ideas of what "stop the graph" means. What lives here is only the
10
+ * mapping from this fork's assembled graph onto that list.
11
+ *
12
+ * It is a separate module because `runtime/services.ts` sits against the repo's
13
+ * 800-line source cap (check-architecture.ts) with no room for the wiring.
14
+ *
15
+ * Ownership note, and the reason any of this exists: this surface builds the
16
+ * graph and hands the SAME object to `DaemonServer`. The SDK facade disposes
17
+ * only a graph it constructed itself, so it deliberately leaves this one alone
18
+ * — nothing upstream will ever stop these pollers. The shutdown paths in
19
+ * daemon/cli.ts, main.ts's teardown registry and the one-shot CLI commands are
20
+ * the only things that can.
21
+ *
22
+ * The owner type below is declared structurally rather than imported from
23
+ * services.ts: that module imports this one, and a type-only edge is still a
24
+ * cycle to the architecture check.
25
+ */
26
+
27
+ import {
28
+ createDisposalScope,
29
+ registerRuntimePollers,
30
+ type DisposalRegistry,
31
+ type RuntimePollerOwners,
32
+ } from '@pellux/goodvibes-sdk/platform/runtime/disposal';
33
+
34
+ /** Re-exported so the composition root reaches the whole seam through one import. */
35
+ export { createDisposalScope };
36
+
37
+ /** The assembled graph, narrowed to the poller owners it exposes as fields. */
38
+ export interface SurfaceRuntimePollerOwners extends Omit<RuntimePollerOwners, 'stopConfigWatch'> {
39
+ /** Fork-only: the repeating crash-residue sweep (durability-services.ts). */
40
+ readonly stopDurabilityHousekeeping: () => void;
41
+ }
42
+
43
+ /**
44
+ * The poller owners this fork holds that are NOT reachable from the assembled
45
+ * graph — handles the factory keeps as locals.
46
+ */
47
+ export interface RuntimeDisposalExtras {
48
+ /** Handle returned by `ConfigManager.watchConfigFiles()` (durability-services.ts). */
49
+ readonly stopConfigWatch: () => void;
50
+ }
51
+
52
+ /**
53
+ * Register the stop call for every poller the graph started.
54
+ *
55
+ * `services` is the fully-assembled graph, which already exposes each poller
56
+ * owner as a field — so a poller whose owner reaches the public surface is
57
+ * wired by name rather than by threading another local out of the factory.
58
+ */
59
+ export function registerSurfaceRuntimePollers(
60
+ registry: DisposalRegistry,
61
+ services: SurfaceRuntimePollerOwners,
62
+ extras: RuntimeDisposalExtras,
63
+ ): void {
64
+ registerRuntimePollers(registry, { ...services, stopConfigWatch: extras.stopConfigWatch });
65
+ registry.add('durability housekeeping', services.stopDurabilityHousekeeping);
66
+ }
@@ -93,6 +93,8 @@ export interface DurabilityServices {
93
93
  * the work rather than to release the loop.
94
94
  */
95
95
  readonly stopDurabilityHousekeeping: () => void;
96
+ /** Stops the live config-file watch this factory started. */
97
+ readonly stopConfigWatch: () => void;
96
98
  }
97
99
 
98
100
  export function createDurabilityServices(input: DurabilityServicesInput): DurabilityServices {
@@ -141,11 +143,13 @@ export function createDurabilityServices(input: DurabilityServicesInput): Durabi
141
143
  extraQuarantineDirs: [configManager.getControlPlaneConfigDir()],
142
144
  });
143
145
  // External config edits apply LIVE through the same subscribe() pipeline an
144
- // in-process set() uses; the underlying watchers are unref'd.
145
- configManager.watchConfigFiles();
146
+ // in-process set() uses; the underlying watchers are unref'd. The returned
147
+ // handle is the only way to stop them — dropping it left a 250ms poll running
148
+ // for the life of the process after the graph that started it was gone.
149
+ const stopConfigWatch = configManager.watchConfigFiles();
146
150
 
147
151
  const userPermissionRuleStore = new UserPermissionRuleStore(join(configManager.getControlPlaneConfigDir(), 'permission-rules.json'));
148
152
  void userPermissionRuleStore.init().catch((error) => logger.warn('user permission rule store init failed; asks will prompt', { error: summarizeError(error) }));
149
153
 
150
- return { storeSnapshotScheduler, appendOnlyRetentionScheduler, userPermissionRuleStore, stopDurabilityHousekeeping };
154
+ return { storeSnapshotScheduler, appendOnlyRetentionScheduler, userPermissionRuleStore, stopDurabilityHousekeeping, stopConfigWatch };
151
155
  }
@@ -11,12 +11,23 @@
11
11
  * at all. See daemon/lifecycle.ts.
12
12
  */
13
13
  import { DaemonServer, HttpListener } from '@pellux/goodvibes-sdk/platform/daemon';
14
+ import type { ClusterCoordinator, ClusterGroupVerbSurface } from '@pellux/goodvibes-sdk/platform/cluster';
14
15
  import { createSafeHostServeFactory } from '../daemon/safe-serve.ts';
15
16
  import type { startExternalServices } from '@/runtime/index.ts';
16
17
 
17
18
  export type ExternalServiceFactories = NonNullable<Parameters<typeof startExternalServices>[4]>;
18
19
 
19
- export function createEmbeddedServiceFactories(sharedDaemonToken: string): ExternalServiceFactories {
20
+ /**
21
+ * `clusterCoordinator` is threaded through explicitly rather than read off the
22
+ * runtimeServices the SDK hands the factory: that object is typed by the SDK,
23
+ * which has no field for a coordinator this product composed. Passing it here
24
+ * keeps the ONE-coordinator-per-process rule enforceable by the type checker.
25
+ */
26
+ export function createEmbeddedServiceFactories(
27
+ sharedDaemonToken: string,
28
+ clusterCoordinator: ClusterCoordinator,
29
+ clusterGroupVerbs: ClusterGroupVerbSurface,
30
+ ): ExternalServiceFactories {
20
31
  return {
21
32
  sharedDaemonToken,
22
33
  createDaemonServer: (bus, userAuth, runtimeServices) => {
@@ -27,6 +38,13 @@ export function createEmbeddedServiceFactories(sharedDaemonToken: string): Exter
27
38
  userAuth,
28
39
  runtimeServices,
29
40
  serveFactory: createSafeHostServeFactory('Embedded daemon'),
41
+ // The SAME coordinator the inbox poller registered with. Letting the
42
+ // facade compose its own would put two election nodes in one process,
43
+ // and whichever lost would silence consumers the other owns.
44
+ clusterCoordinator,
45
+ // The `cluster` verbs (/api/cluster/*), so the CLI and the TUI command
46
+ // reach one implementation whether the daemon is embedded or standalone.
47
+ clusterGroupVerbs,
30
48
  });
31
49
  return daemonServer;
32
50
  },
@@ -19,7 +19,7 @@
19
19
  * `unsubs` registry is shared by reference and drained on exit.
20
20
  */
21
21
  import { existsSync } from 'node:fs';
22
- import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
22
+ import { flushActivityLogSync, logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
23
23
  import { createTerminalLifecycle, TERMINAL_ESCAPES } from '@pellux/goodvibes-terminal-shell';
24
24
  import { formatUserFacingErrorLine } from '../core/format-user-error.ts';
25
25
  import { allowTerminalWrite } from './terminal-output-guard.ts';
@@ -201,11 +201,17 @@ export function installProcessLifecycle(deps: ProcessLifecycleDeps): ProcessLife
201
201
  const uncaughtExceptionHandler = (err: Error): void => {
202
202
  restoreTerminal();
203
203
  logger.error('uncaughtException — terminal restored, exiting', { error: summarizeError(err) });
204
+ // The line naming the crash is the only record of it. process.exit does not
205
+ // drain the activity log's buffer, so it goes to disk here rather than
206
+ // relying on the exit hook that also runs — the crash log is worth stating
207
+ // the intent for at the site that produces it.
208
+ flushActivityLogSync();
204
209
  process.exit(1);
205
210
  };
206
211
  const terminationSignalHandler = (signal: NodeJS.Signals): void => {
207
212
  restoreTerminal();
208
213
  logger.error(`Received ${signal} — terminal restored, exiting`, {});
214
+ flushActivityLogSync();
209
215
  process.exit(signal === 'SIGHUP' ? 129 : 143);
210
216
  };
211
217
  const exitListener = (): void => { restoreTerminal(); };
@@ -285,6 +291,8 @@ export function installProcessLifecycle(deps: ProcessLifecycleDeps): ProcessLife
285
291
  // stop claiming the session is live regardless of whether the durable
286
292
  // save itself completed in time.
287
293
  removeLivenessMarker(ctx.services.surface, ctx.runtime.sessionId);
294
+ // An orderly exit's own record lands before the process stops being one.
295
+ flushActivityLogSync();
288
296
  process.exit(0);
289
297
  };
290
298
 
@@ -0,0 +1,62 @@
1
+ /**
2
+ * remote-execution-composition.ts — running work somewhere other than here.
3
+ *
4
+ * Four collaborators that only make sense together, so they are built together
5
+ * rather than as four unrelated lines in the middle of the runtime composition:
6
+ *
7
+ * - RemoteRunnerRegistry knows which remote runners exist and how to reach
8
+ * them; RemoteSupervisor drives them and is meaningless without it.
9
+ *
10
+ * - SandboxSessionRegistry owns the sandboxes a tool call can be confined to,
11
+ * and McpRegistry is the thing that confines them — an MCP server started
12
+ * without the sandbox registry runs its tools directly on the host.
13
+ *
14
+ * The pairing in each case is the point: handing the supervisor a registry it
15
+ * did not build, or the MCP registry a sandbox runtime it does not share with
16
+ * the session registry, produces a runtime that looks wired and quietly is not.
17
+ */
18
+ import { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
19
+ import {
20
+ RemoteRunnerRegistry,
21
+ RemoteSupervisor,
22
+ SandboxSessionRegistry,
23
+ type RuntimeEventBus,
24
+ } from '@/runtime/index.ts';
25
+ import type { AgentManager } from '@pellux/goodvibes-sdk/platform/tools';
26
+ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
27
+ import type { HookDispatcher } from '@pellux/goodvibes-sdk/platform/hooks';
28
+
29
+ export interface RemoteExecutionServices {
30
+ readonly remoteRunnerRegistry: RemoteRunnerRegistry;
31
+ readonly remoteSupervisor: RemoteSupervisor;
32
+ readonly sandboxSessionRegistry: SandboxSessionRegistry;
33
+ readonly mcpRegistry: McpRegistry;
34
+ }
35
+
36
+ /**
37
+ * Build the remote-execution and sandboxing seam.
38
+ *
39
+ * Constructing these opens no connection and starts no server: a runner is
40
+ * reached when something asks for it, and an MCP server starts when a session
41
+ * requests it. Composing a runtime in a test therefore executes nothing.
42
+ */
43
+ export function createRemoteExecutionServices(options: {
44
+ readonly agentManager: AgentManager;
45
+ readonly workingDirectory: string;
46
+ readonly hookDispatcher: HookDispatcher;
47
+ readonly configManager: ConfigManager;
48
+ readonly runtimeBus: RuntimeEventBus;
49
+ }): RemoteExecutionServices {
50
+ const remoteRunnerRegistry = new RemoteRunnerRegistry(options.agentManager);
51
+ const remoteSupervisor = new RemoteSupervisor(remoteRunnerRegistry);
52
+ const sandboxSessionRegistry = new SandboxSessionRegistry(options.workingDirectory);
53
+ const mcpRegistry = new McpRegistry({
54
+ hookDispatcher: options.hookDispatcher,
55
+ sandboxSessions: sandboxSessionRegistry,
56
+ });
57
+ mcpRegistry.setRuntimeBus(options.runtimeBus);
58
+ // The SAME sandbox registry the session registry above owns, so a tool call
59
+ // confined by one is confined by the other.
60
+ mcpRegistry.setSandboxRuntime(options.configManager, sandboxSessionRegistry);
61
+ return { remoteRunnerRegistry, remoteSupervisor, sandboxSessionRegistry, mcpRegistry };
62
+ }