@pellux/goodvibes-agent 1.8.1 → 1.9.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 (89) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +1 -1
  3. package/dist/package/{ast-grep-napi.linux-x64-gnu-mkk8xwww.node → ast-grep-napi.linux-x64-gnu-44ar68xs.node} +0 -0
  4. package/dist/package/{ast-grep-napi.linux-x64-musl-ryqtgdv6.node → ast-grep-napi.linux-x64-musl-asvc2tv1.node} +0 -0
  5. package/dist/package/main.js +135337 -118733
  6. package/dist/package/{web-tree-sitter-vmqc5d26.wasm → web-tree-sitter-jbz042ba.wasm} +0 -0
  7. package/docs/README.md +1 -1
  8. package/docs/tools-and-commands.md +9 -0
  9. package/package.json +5 -3
  10. package/src/agent/capability-registry.ts +300 -0
  11. package/src/agent/email/style-reply-lane.ts +13 -4
  12. package/src/agent/memory-consolidation-receipts.ts +79 -0
  13. package/src/agent/memory-prompt.ts +3 -1
  14. package/src/agent/operator-gateway-call.ts +134 -0
  15. package/src/agent/principal-attribution.ts +97 -0
  16. package/src/agent/prompt-context-receipts.ts +5 -1
  17. package/src/agent/unified-inbox.ts +17 -1
  18. package/src/cli/bundle-command.ts +2 -1
  19. package/src/cli/channel-profiles-command.ts +168 -0
  20. package/src/cli/ci-command.ts +214 -0
  21. package/src/cli/completion.ts +5 -0
  22. package/src/cli/connected-host-metrics.ts +165 -0
  23. package/src/cli/entrypoint.ts +25 -1
  24. package/src/cli/fleet-command.ts +178 -0
  25. package/src/cli/help.ts +115 -0
  26. package/src/cli/import-command.ts +208 -0
  27. package/src/cli/index.ts +1 -0
  28. package/src/cli/management-commands.ts +9 -8
  29. package/src/cli/management.ts +42 -0
  30. package/src/cli/memory-command.ts +4 -3
  31. package/src/cli/openclaw-import.ts +382 -0
  32. package/src/cli/operator-command-args.ts +88 -0
  33. package/src/cli/parser.ts +12 -0
  34. package/src/cli/personas-command.ts +3 -2
  35. package/src/cli/principals-command.ts +230 -0
  36. package/src/cli/profiles-command.ts +2 -1
  37. package/src/cli/relay-command.ts +100 -0
  38. package/src/cli/routines-command.ts +4 -3
  39. package/src/cli/skill-bundle-command.ts +3 -2
  40. package/src/cli/skills-command.ts +3 -2
  41. package/src/cli/status.ts +114 -2
  42. package/src/cli/temporal-label.ts +89 -0
  43. package/src/cli/types.ts +7 -0
  44. package/src/cli/workspaces-command.ts +135 -0
  45. package/src/config/agent-settings-policy.ts +8 -0
  46. package/src/config/checkpoint-settings.ts +113 -0
  47. package/src/config/workspace-registration.ts +299 -0
  48. package/src/core/rewind-turn-anchors.ts +82 -0
  49. package/src/input/agent-workspace-categories.ts +1 -0
  50. package/src/input/agent-workspace-channel-triage.ts +27 -8
  51. package/src/input/agent-workspace-local-library-snapshot.ts +5 -1
  52. package/src/input/agent-workspace-onboarding-categories.ts +17 -1
  53. package/src/input/agent-workspace-snapshot.ts +25 -3
  54. package/src/input/agent-workspace-types.ts +5 -5
  55. package/src/input/commands/connected-host-admin-runtime.ts +75 -0
  56. package/src/input/commands.ts +2 -0
  57. package/src/input/settings-modal-types.ts +16 -5
  58. package/src/main.ts +17 -12
  59. package/src/permissions/approval-posture.ts +40 -5
  60. package/src/permissions/prompt.ts +35 -1
  61. package/src/renderer/settings-modal-helpers.ts +11 -0
  62. package/src/renderer/settings-modal.ts +21 -0
  63. package/src/renderer/terminal-escapes.ts +65 -12
  64. package/src/runtime/agent-runtime-events.ts +102 -3
  65. package/src/runtime/bootstrap-core.ts +111 -10
  66. package/src/runtime/bootstrap-external-services.ts +156 -0
  67. package/src/runtime/bootstrap.ts +73 -69
  68. package/src/runtime/context-accounting-source.ts +114 -0
  69. package/src/runtime/conversation-rewind-port.ts +133 -0
  70. package/src/runtime/index.ts +8 -28
  71. package/src/runtime/memory-consolidation-scheduler.ts +67 -0
  72. package/src/runtime/memory-consolidation-wiring.ts +56 -0
  73. package/src/runtime/memory-usage-wiring.ts +75 -0
  74. package/src/runtime/services.ts +250 -14
  75. package/src/runtime/tool-permission-safety.ts +18 -1
  76. package/src/shell/blocking-input.ts +38 -8
  77. package/src/shell/startup-wiring.ts +26 -1
  78. package/src/tools/agent-context-policy.ts +1 -1
  79. package/src/tools/agent-harness-cli-command-policy.ts +29 -3
  80. package/src/tools/agent-harness-learning-auto-promote.ts +7 -3
  81. package/src/tools/agent-harness-memory-posture.ts +57 -2
  82. package/src/tools/agent-harness-metadata.ts +12 -0
  83. package/src/tools/agent-harness-mode-catalog.ts +1 -1
  84. package/src/tools/agent-harness-personal-ops-lanes.ts +8 -6
  85. package/src/tools/agent-harness-prompt-context.ts +3 -1
  86. package/src/tools/agent-harness-tool.ts +3 -2
  87. package/src/tools/agent-policy-explanation.ts +40 -0
  88. package/src/tools/tool-definition-compaction.ts +2 -0
  89. package/src/version.ts +1 -1
@@ -1,4 +1,5 @@
1
1
  import { ConversationManager } from '../core/conversation';
2
+ import { registerSessionConversation } from './conversation-rewind-port.ts';
2
3
  import { SelectionManager } from '../input/selection.ts';
3
4
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
4
5
  import { ConfigManager, getConfiguredSystemPrompt } from '../config/index.ts';
@@ -9,7 +10,7 @@ import { PermissionManager, createPermissionConfigReader } from '@pellux/goodvib
9
10
  import { Notifier, WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integrations';
10
11
 
11
12
  import { Compositor } from '../renderer/compositor.ts';
12
- import type { PermissionRequestHandler } from '@pellux/goodvibes-sdk/platform/permissions';
13
+ import type { PermissionRequestHandler, PermissionPromptRequest } from '@pellux/goodvibes-sdk/platform/permissions';
13
14
  import type { SystemMessageRouter } from '../core/system-message-router.ts';
14
15
  import type { ConversationFollowUpItem } from '@pellux/goodvibes-sdk/platform/core';
15
16
  import type { OrchestratorUserInputOptions } from '../core/orchestrator.ts';
@@ -19,6 +20,7 @@ import type { BootstrapOptions } from './context.ts';
19
20
  import { createFeatureFlagManager } from '@/runtime/index.ts';
20
21
  import { RuntimeEventBus } from '@/runtime/index.ts';
21
22
  import type { SessionEvent } from '@/runtime/index.ts';
23
+ import { emitPermissionModeChanged } from '@pellux/goodvibes-sdk/platform/runtime/emitters';
22
24
  import { createRuntimeStore, createDomainDispatch, type RuntimeStore } from './store/index.ts';
23
25
  import { ForensicsCollector, ForensicsRegistry } from '@/runtime/index.ts';
24
26
  import {
@@ -135,6 +137,38 @@ export function companionMessageToOrchestratorInputOptions(
135
137
  };
136
138
  }
137
139
 
140
+ /**
141
+ * Fleet-attention wiring: derive the shared-approval metadata for a
142
+ * permission ask. Background/subagent asks carry
143
+ * `request.attribution` (kind: 'background-agent', agentId, template — set by
144
+ * AgentOrchestrator's gateBackgroundToolCall once permissionManager is wired
145
+ * into it, see the `services.agentOrchestrator.setDependencies(...)` call
146
+ * below and orchestrator.test.ts's "background agent permission gating"
147
+ * suite). Forwarding `attribution.agentId` into the shared approval's
148
+ * `metadata.agentId` is what lets the SDK's fleet ProcessRegistry
149
+ * (runtime/fleet/registry.js, collectPendingApprovals) attribute the pending
150
+ * ask to that agent's ProcessNode — deriving
151
+ * `state: 'awaiting-approval'` / `needsAttention: { reason: 'approval' }` on
152
+ * the spawned agent itself, rather than leaving the ask an anonymous approval
153
+ * entry the fleet plane cannot attach to anything. Foreground asks (no
154
+ * attribution) return undefined, preserving today's un-attributed shape.
155
+ *
156
+ * PermissionAttribution is a discriminated union (SDK 1.6.1 added
157
+ * `mcp-server` and `sandbox-escalation` alongside the original
158
+ * `background-agent`); only `background-agent` carries an `agentId` the
159
+ * fleet ProcessRegistry can attach a pending approval to, so only that kind
160
+ * populates `metadata.agentId` here. The other two kinds are surfaced in the
161
+ * permission-prompt UI itself (see src/permissions/prompt.ts), not through
162
+ * fleet attribution metadata — an MCP server or a sandbox escalation is not
163
+ * a spawned agent's ProcessNode.
164
+ */
165
+ export function approvalMetadataForRequest(
166
+ request: Pick<PermissionPromptRequest, 'attribution'>,
167
+ ): Record<string, unknown> | undefined {
168
+ const attribution = request.attribution;
169
+ return attribution?.kind === 'background-agent' ? { agentId: attribution.agentId } : undefined;
170
+ }
171
+
138
172
  export async function initializeBootstrapCore(
139
173
  stdout: NodeJS.WriteStream,
140
174
  options: BootstrapOptions,
@@ -150,6 +184,15 @@ export async function initializeBootstrapCore(
150
184
  });
151
185
 
152
186
  const userSessionId = `user-${generateUserSessionId()}`;
187
+ // Declared here, before createRuntimeServices, so the checkpoint manager can be
188
+ // handed a live session-id resolver at construction time. Its value starts as
189
+ // the bootstrap user session id and is advanced to the real runtime session id
190
+ // below (see runtimeSessionIdRef.value = runtime.sessionId). Because the
191
+ // resolver is consulted at the moment each automatic snapshot fires — not at
192
+ // subscription time — reading .value through this closure keeps same-launch
193
+ // checkpoints stamped with whatever session id is current when the turn ends,
194
+ // which is exactly the id the restore/rewind lookup filters on.
195
+ const runtimeSessionIdRef = { value: userSessionId };
153
196
  const runtimeBus = new RuntimeEventBus();
154
197
  const store = createRuntimeStore();
155
198
  const domainDispatch = createDomainDispatch(store);
@@ -160,6 +203,7 @@ export async function initializeBootstrapCore(
160
203
  runtimeBus,
161
204
  runtimeStore: store,
162
205
  getConversationTitle: () => getConversationTitle(),
206
+ resolveSessionId: () => runtimeSessionIdRef.value || undefined,
163
207
  workingDir,
164
208
  homeDirectory,
165
209
  });
@@ -268,6 +312,13 @@ export async function initializeBootstrapCore(
268
312
  serviceRegistry: services.serviceRegistry,
269
313
  overflowHandler: services.overflowHandler,
270
314
  changeTracker: services.sessionChangeTracker,
315
+ // Same holder instance `services.contextAccountingHolder` exposes — the
316
+ // context_accounting tool registered here and the bind call bootstrap.ts
317
+ // makes after constructing the Orchestrator (see
318
+ // context-accounting-source.ts) must share ONE holder, otherwise the tool
319
+ // would read an unbound holder of its own while the real source sits on a
320
+ // different instance nothing reads.
321
+ contextAccountingHolder: services.contextAccountingHolder,
271
322
  });
272
323
  registerAgentArtifactsTool(toolRegistry, services.artifactStore, { projectRoot: services.shellPaths.workingDirectory });
273
324
  registerAgentDocumentsTool(toolRegistry, services.shellPaths, services.artifactStore);
@@ -309,7 +360,12 @@ export async function initializeBootstrapCore(
309
360
  });
310
361
  installToolExecutionSafetyGuard(toolRegistry);
311
362
  compactRegisteredToolDefinitions(toolRegistry);
312
- services.agentOrchestrator.setDependencies({
363
+ // Captured so the permissionManager-bearing follow-up call below (issued once
364
+ // permissionManager exists, further down this function) can replay every
365
+ // field — AgentOrchestrator.setDependencies() fully replaces its stored
366
+ // toolDeps rather than merging, so a partial second call would silently drop
367
+ // everything set here.
368
+ const agentOrchestratorToolDeps = {
313
369
  surfaceRoot: GOODVIBES_AGENT_SURFACE_ROOT,
314
370
  fileCache,
315
371
  projectIndex,
@@ -334,7 +390,8 @@ export async function initializeBootstrapCore(
334
390
  memoryRegistry: services.memoryRegistry,
335
391
  sandboxSessionRegistry: services.sandboxSessionRegistry,
336
392
  workflowServices: services.workflow,
337
- });
393
+ };
394
+ services.agentOrchestrator.setDependencies(agentOrchestratorToolDeps);
338
395
 
339
396
  const bootstrapUnsubs: Array<() => void> = [];
340
397
  // D5 fix: stop the heartbeat watcher on shutdown so the setInterval is cleared.
@@ -421,7 +478,6 @@ export async function initializeBootstrapCore(
421
478
  };
422
479
  void approvalBroker.start();
423
480
  void sharedSessionBroker.start();
424
- const runtimeSessionIdRef = { value: userSessionId };
425
481
  const systemMessageRouterRef: { value: SystemMessageRouter | null } = { value: null };
426
482
  const conversationFollowUpRef: { value: ((item: ConversationFollowUpItem) => void) | null } = { value: null };
427
483
  const { unsubs: runtimeUnsubs, agentStatusIntervalRef } = registerAgentRuntimeEvents({
@@ -432,6 +488,7 @@ export async function initializeBootstrapCore(
432
488
  requestRender,
433
489
  configManager,
434
490
  agentManager: services.agentManager,
491
+ toolRegistry,
435
492
  });
436
493
 
437
494
  // Subscribe to companion main-chat messages received from the connected host's HTTP layer.
@@ -515,17 +572,30 @@ export async function initializeBootstrapCore(
515
572
  await syncConfiguredServices(domainDispatch.syncIntegration, services.serviceRegistry);
516
573
 
517
574
  const permissionManager = new PermissionManager(
518
- (request) => approvalBroker.requestApproval({
519
- request,
520
- sessionId: runtimeSessionIdRef.value,
521
- localPrompt: permissionPromptRef.requestPermission,
522
- }),
575
+ (request) => {
576
+ const metadata = approvalMetadataForRequest(request);
577
+ return approvalBroker.requestApproval({
578
+ request,
579
+ sessionId: runtimeSessionIdRef.value,
580
+ localPrompt: permissionPromptRef.requestPermission,
581
+ ...(metadata ? { metadata } : {}),
582
+ });
583
+ },
523
584
  createPermissionConfigReader(configManager),
524
585
  policyRuntimeState,
525
586
  services.hookDispatcher,
526
587
  featureFlags,
527
588
  );
528
589
  installPermissionManagerSafetyGuard(permissionManager);
590
+ // Wire permissionManager into the SAME AgentOrchestrator instance that runs
591
+ // spawned/background agent tool calls (services.agentOrchestrator, set up
592
+ // above with agentOrchestratorToolDeps before permissionManager existed).
593
+ // Without this, the SDK's gateBackgroundToolCall() sees no permissionManager
594
+ // on context and leaves every background tool call ungated — spawned agents
595
+ // would run with no permission check at all, regardless of permissions.mode
596
+ // or permissions.backgroundAgents. Replays the full toolDeps object because
597
+ // setDependencies() replaces rather than merges.
598
+ services.agentOrchestrator.setDependencies({ ...agentOrchestratorToolDeps, permissionManager });
529
599
  await hookWorkbench.loadAndApplyManagedHooks();
530
600
 
531
601
  const runtime: MutableRuntimeState = {
@@ -537,10 +607,21 @@ export async function initializeBootstrapCore(
537
607
  sessionId: userSessionId,
538
608
  };
539
609
  runtimeSessionIdRef.value = runtime.sessionId;
610
+ // Register this process's live conversation so the composed daemon's
611
+ // rewind.plan/apply verbs can serve conversation scope for this session
612
+ // (see conversation-rewind-port.ts and services.ts's conversationRewindPort
613
+ // wiring). Ported from goodvibes-tui's identical bootstrap-time call.
614
+ registerSessionConversation(runtime.sessionId, conversation);
540
615
  void sharedSessionBroker.createSession({
541
616
  id: runtime.sessionId,
542
617
  title: 'GoodVibes Agent session',
543
- metadata: { source: 'goodvibes-agent' },
618
+ // Declares the session permission mode at creation time in the shared
619
+ // metadata bag (SDK 1.6.1 permissions.mode: prompt/allow-all/custom/
620
+ // plan/accept-edits). Read-only declaration — the permission layer
621
+ // itself always reads permissions.mode live off configManager, so this
622
+ // does not change enforcement; it lets cross-session tooling see which
623
+ // mode a session started under without re-deriving it.
624
+ metadata: { source: 'goodvibes-agent', permissionMode: configManager.get('permissions.mode') },
544
625
  participant: {
545
626
  surfaceKind: 'service',
546
627
  surfaceId: 'surface:goodvibes-agent',
@@ -564,6 +645,26 @@ export async function initializeBootstrapCore(
564
645
  uiServices.events.turns.on('TURN_COMPLETED', () => services.sessionSpineClient.heartbeat(runtimeSessionIdRef.value)),
565
646
  );
566
647
 
648
+ // Producer half of the live mode-metadata refresh. permissions.mode can change
649
+ // mid-session (the Permission mode setting cycles it via configManager), and
650
+ // the SDK models that as a PERMISSION_MODE_CHANGED wire event — but nothing
651
+ // emitted it, so the runtime store's permission domain (and any surface built
652
+ // off it) stayed frozen at the boot-time value until restart. Bridging the
653
+ // config-key subscription onto the runtime bus makes every mode mutation, from
654
+ // any path that goes through configManager.set, publish the event; the
655
+ // consumer half in agent-runtime-events.ts folds it into the store and shows a
656
+ // system message. Guarded on an actual change so a no-op re-set stays silent.
657
+ runtimeUnsubs.push(
658
+ configManager.subscribe('permissions.mode', (newValue, oldValue) => {
659
+ if (newValue === oldValue) return;
660
+ emitPermissionModeChanged(
661
+ runtimeBus,
662
+ { sessionId: runtimeSessionIdRef.value, source: 'goodvibes-agent', traceId: `perm-mode-${generateUserSessionId()}` },
663
+ { mode: String(newValue), previousMode: String(oldValue) },
664
+ );
665
+ }),
666
+ );
667
+
567
668
  domainDispatch.syncSessionState({
568
669
  id: userSessionId,
569
670
  projectRoot: workingDir,
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Connected-host (daemon + HTTP listener) discovery for GoodVibes Agent.
3
+ *
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.
7
+ *
8
+ * GoodVibes Agent never starts or restarts the connected daemon: every call
9
+ * routes through the SDK-shared adopt-or-spawn policy
10
+ * (decideDaemonAdoption/classifyDaemonProbe) with `adoptOnly: true`, so a
11
+ * 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.
15
+ */
16
+ import { startExternalServices } from '@/runtime/index.ts';
17
+ import type {
18
+ DeferredStartupCoordinator,
19
+ ExternalServicesHandle,
20
+ HostServiceStatus,
21
+ HostServicesConfig,
22
+ RuntimeEventBus,
23
+ } from '@/runtime/index.ts';
24
+ import type { HookDispatcher } from '@pellux/goodvibes-sdk/platform/hooks';
25
+ import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
26
+ import type { SystemMessageRouter } from '../core/system-message-router.ts';
27
+ import type { RuntimeServices } from './services.ts';
28
+ import type { UiRuntimeServices } from './ui-services.ts';
29
+
30
+ function formatHostServiceBaseUrl(host: string, port: number): string {
31
+ const normalized = host.trim().toLowerCase();
32
+ const probeHost = normalized === '0.0.0.0'
33
+ ? '127.0.0.1'
34
+ : normalized === '::' || normalized === '[::]'
35
+ ? '::1'
36
+ : host;
37
+ const urlHost = probeHost.includes(':') && !probeHost.startsWith('[') ? `[${probeHost}]` : probeHost;
38
+ return `http://${urlHost}:${port}`;
39
+ }
40
+
41
+ // Pending status shown before the deferred probe completes — honest
42
+ // 'unavailable', never a guessed 'external'.
43
+ function createPendingServiceStatus(
44
+ configManager: HostServicesConfig,
45
+ service: 'daemon' | 'httpListener',
46
+ ): HostServiceStatus {
47
+ const host = String(configManager.get(service === 'daemon' ? 'controlPlane.host' : 'httpListener.host') ?? '127.0.0.1');
48
+ const port = Number(configManager.get(service === 'daemon' ? 'controlPlane.port' : 'httpListener.port') ?? (service === 'daemon' ? 3421 : 3422));
49
+ return {
50
+ mode: 'unavailable',
51
+ host,
52
+ port,
53
+ baseUrl: formatHostServiceBaseUrl(host, port),
54
+ reason: 'Connected-host discovery has not completed yet',
55
+ };
56
+ }
57
+
58
+ const hostServiceIsActive = (status: HostServiceStatus): boolean => status.mode === 'embedded' || status.mode === 'external';
59
+
60
+ // 'blocked' (occupied by an unverified process) and 'incompatible' (a
61
+ // GoodVibes daemon on a wire-version band this Agent build refuses to adopt)
62
+ // both mean the configured port is held and unusable by this instance.
63
+ const hostServiceIsBlocked = (status: HostServiceStatus): boolean => status.mode === 'blocked' || status.mode === 'incompatible';
64
+
65
+ export interface AgentExternalServicesController {
66
+ /** The most recently resolved (or pending) connected-host status. */
67
+ getStatus(): ExternalServicesHandle;
68
+ stop(): Promise<void>;
69
+ }
70
+
71
+ export function wireAgentExternalServices(options: {
72
+ readonly configManager: HostServicesConfig;
73
+ readonly runtimeBus: RuntimeEventBus;
74
+ readonly hookDispatcher: HookDispatcher;
75
+ readonly services: RuntimeServices;
76
+ readonly uiServices: UiRuntimeServices;
77
+ readonly deferredStartup: DeferredStartupCoordinator;
78
+ readonly systemMessageRouter: SystemMessageRouter;
79
+ readonly requestRender: () => void;
80
+ }): AgentExternalServicesController {
81
+ const { configManager, runtimeBus, hookDispatcher, services, uiServices, deferredStartup, systemMessageRouter, requestRender } = options;
82
+
83
+ const inspectAgentDependencies = () => {
84
+ const daemonStatus = externalServices.daemonStatus;
85
+ const httpListenerStatus = externalServices.httpListenerStatus;
86
+ return {
87
+ connectedHostRunning: hostServiceIsActive(daemonStatus),
88
+ connectedHostPortInUse: hostServiceIsBlocked(daemonStatus),
89
+ httpListenerRunning: hostServiceIsActive(httpListenerStatus),
90
+ httpListenerPortInUse: hostServiceIsBlocked(httpListenerStatus),
91
+ connectedHostStatus: daemonStatus,
92
+ httpListenerStatus,
93
+ };
94
+ };
95
+
96
+ let externalServices: ExternalServicesHandle = {
97
+ daemonServer: null,
98
+ httpListener: null,
99
+ daemonStatus: createPendingServiceStatus(configManager, 'daemon'),
100
+ httpListenerStatus: createPendingServiceStatus(configManager, 'httpListener'),
101
+ listRecentControlPlaneEvents: () => [],
102
+ async stop(): Promise<void> {},
103
+ };
104
+ let externalServicesPromise: Promise<ExternalServicesHandle> | null = null;
105
+
106
+ const startAgentExternalServices = (): Promise<ExternalServicesHandle> =>
107
+ startExternalServices(configManager, runtimeBus, hookDispatcher, services, { adoptOnly: true });
108
+
109
+ const platformExternalServices = uiServices.platform as typeof uiServices.platform & {
110
+ externalServices: NonNullable<typeof uiServices.platform.externalServices>;
111
+ };
112
+ platformExternalServices.externalServices = {
113
+ inspect: inspectAgentDependencies,
114
+ restart: async () => {
115
+ if (externalServicesPromise) {
116
+ try {
117
+ externalServices = await externalServicesPromise;
118
+ } catch {
119
+ // A failed previous startup should not prevent a re-probe attempt.
120
+ }
121
+ }
122
+ await externalServices.stop();
123
+ externalServicesPromise = startAgentExternalServices();
124
+ 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.');
126
+ requestRender();
127
+ return inspectAgentDependencies();
128
+ },
129
+ };
130
+
131
+ // Connected-host discovery OFF the interactive path: probe the configured
132
+ // host/port through the shared adopt-or-spawn policy (adoptOnly — Agent
133
+ // never spawns or embeds) and replace the pending status with an honest
134
+ // one (adopted, incompatible, blocked, or unavailable).
135
+ deferredStartup.schedule({
136
+ label: 'external-services',
137
+ run: async () => {
138
+ externalServicesPromise = startAgentExternalServices();
139
+ externalServices = await externalServicesPromise;
140
+ requestRender();
141
+ },
142
+ onError: (error) => {
143
+ const message = summarizeError(error);
144
+ logger.error('Deferred connected-host discovery failed', { error: message });
145
+ systemMessageRouter.high(`[Startup] Connected-host discovery failed: ${message}`);
146
+ requestRender();
147
+ },
148
+ });
149
+
150
+ return {
151
+ getStatus: () => externalServices,
152
+ stop: async () => {
153
+ await externalServices.stop();
154
+ },
155
+ };
156
+ }
@@ -31,10 +31,12 @@ import { scheduleBackgroundMcpDiscovery } from '@/runtime/index.ts';
31
31
  import { restoreSavedModel } from '@/runtime/index.ts';
32
32
  import { runGatedLanScan } from './lan-scan-consent.ts';
33
33
  import { scheduleCalendarSubscriptionBootRefresh } from './calendar-boot-refresh.ts';
34
- import type { ExternalServicesHandle, HostServiceStatus, TurnEvent } from '@/runtime/index.ts';
34
+ import type { TurnEvent } from '@/runtime/index.ts';
35
35
  import type { UiRuntimeServices } from './ui-services.ts';
36
36
  import { createDeferredStartupCoordinator } from '@/runtime/index.ts';
37
+ import { wireAgentExternalServices } from './bootstrap-external-services.ts';
37
38
  import { initializeBootstrapCore } from './bootstrap-core.ts';
39
+ import { bindOrchestratorContextAccounting } from './context-accounting-source.ts';
38
40
  import { createBootstrapShell } from './bootstrap-shell.ts';
39
41
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
40
42
  import { startMcpConfigAutoReload } from '../mcp/runtime-reload.ts';
@@ -42,6 +44,9 @@ import { GOODVIBES_AGENT_SURFACE_ROOT } from '../config/surface.ts';
42
44
  import { foldLegacySpineStore } from '@pellux/goodvibes-sdk/platform/runtime/session-spine';
43
45
  import { reconcileMemorySpineAdoption } from './memory-spine-adoption.ts';
44
46
  import { AgentPromptContextReceiptStore, composeRuntimePromptWithReceipt } from '../agent/prompt-context-receipts.ts';
47
+ import { createMemoryConsolidationScheduler } from './memory-consolidation-wiring.ts';
48
+ import { recordTurnAnchor, summarizeTurnLabel } from '../core/rewind-turn-anchors.ts';
49
+ import { createMemoryUsageTracker } from './memory-usage-wiring.ts';
45
50
  import { registerAgentAuditTool } from '../tools/agent-audit-tool.ts';
46
51
  import { registerAgentAutonomyTool } from '../tools/agent-autonomy-tool.ts';
47
52
  import { registerAgentChannelsTool } from '../tools/agent-channels-tool.ts';
@@ -211,6 +216,20 @@ export async function bootstrapRuntime(
211
216
  // was just never captured. Mirrors activePromptTurnId's lifecycle exactly: set on
212
217
  // TURN_SUBMITTED, cleared on every terminal event for that turn.
213
218
  let activePromptTurnText: string | null = null;
219
+ // Idle-time memory consolidation: reviews stored memory when the agent is
220
+ // genuinely idle (no active turn) and no sooner than the configured interval,
221
+ // off by default. Every run leaves a receipt of what it merged/archived/proposed.
222
+ // Usage-outcome instrumentation: records which memories were injected and, at
223
+ // turn completion, whether the model output plausibly referenced them (honest
224
+ // heuristic overlap). Feeds consolidation's never-referenced-first decay.
225
+ const memoryUsageTracker = createMemoryUsageTracker(services.shellPaths, services.memoryRegistry);
226
+ const memoryConsolidationScheduler = createMemoryConsolidationScheduler({
227
+ configManager,
228
+ memoryRegistry: services.memoryRegistry,
229
+ shellPaths: services.shellPaths,
230
+ isIdle: () => activePromptTurnId === null,
231
+ usageLookup: (id) => memoryUsageTracker.lookup(id),
232
+ });
214
233
  runtimeUnsubs.push(
215
234
  runtimeBus.on<Extract<TurnEvent, { type: 'TURN_SUBMITTED' }>>('TURN_SUBMITTED', (event) => {
216
235
  activePromptTurnId = event.payload.turnId;
@@ -233,10 +252,30 @@ export async function bootstrapRuntime(
233
252
  terminalEvent: 'TURN_COMPLETED',
234
253
  stopReason: event.payload.stopReason,
235
254
  });
255
+ // Record this turn's rewind anchor: pair the turnId (shared with the
256
+ // workspace checkpoint the turn engine snapshots for this same turn)
257
+ // with the live conversation message count, so a later message-anchored
258
+ // rewind.plan/apply (scope 'conversation' or 'both') can truncate the
259
+ // conversation to exactly this boundary — the join key between
260
+ // conversation and files rewind (see core/rewind-turn-anchors.ts and
261
+ // services.ts's conversationRewindPort wiring).
262
+ try {
263
+ recordTurnAnchor(runtimeSessionIdRef.value, {
264
+ turnId: event.payload.turnId,
265
+ label: summarizeTurnLabel(conversation.getLastUserMessage()),
266
+ messageCount: conversation.getMessageCount(),
267
+ at: Date.now(),
268
+ });
269
+ } catch (error) {
270
+ // Best-effort; a rewind-anchor miss must never break the turn.
271
+ logger.debug('rewind turn-anchor recording failed', { error: summarizeError(error) });
272
+ }
236
273
  if (activePromptTurnId === event.payload.turnId) {
237
274
  activePromptTurnId = null;
238
275
  activePromptTurnText = null;
239
276
  }
277
+ memoryUsageTracker.onTurnCompleted(event.payload.turnId, event.payload.response);
278
+ memoryConsolidationScheduler.onTurnSettled();
240
279
  }),
241
280
  runtimeBus.on<Extract<TurnEvent, { type: 'TURN_ERROR' }>>('TURN_ERROR', (event) => {
242
281
  promptContextReceipts.recordTurnOutcome({
@@ -250,6 +289,8 @@ export async function bootstrapRuntime(
250
289
  activePromptTurnId = null;
251
290
  activePromptTurnText = null;
252
291
  }
292
+ memoryUsageTracker.onTurnAborted(event.payload.turnId);
293
+ memoryConsolidationScheduler.onTurnSettled();
253
294
  }),
254
295
  runtimeBus.on<Extract<TurnEvent, { type: 'TURN_CANCEL' }>>('TURN_CANCEL', (event) => {
255
296
  promptContextReceipts.recordTurnOutcome({
@@ -263,6 +304,8 @@ export async function bootstrapRuntime(
263
304
  activePromptTurnId = null;
264
305
  activePromptTurnText = null;
265
306
  }
307
+ memoryUsageTracker.onTurnAborted(event.payload.turnId);
308
+ memoryConsolidationScheduler.onTurnSettled();
266
309
  }),
267
310
  );
268
311
  const {
@@ -305,6 +348,7 @@ export async function bootstrapRuntime(
305
348
  memoryRecallSnapshot: services.memorySpineClient.recallSnapshot(),
306
349
  });
307
350
  promptContextReceipts.record(composed.receipt);
351
+ memoryUsageTracker.onComposed(activePromptTurnId, composed.receipt);
308
352
  return composed.prompt;
309
353
  },
310
354
  hookDispatcher,
@@ -336,6 +380,20 @@ export async function bootstrapRuntime(
336
380
  });
337
381
  conversation.setSessionLineageTracker(services.sessionLineageTracker);
338
382
 
383
+ // Bind the context_accounting tool (SDK 1.6.1) to THIS Orchestrator so it
384
+ // reports real session data instead of the unbound-holder honesty message.
385
+ // Must run after setCoreServices (nothing here depends on it, but this
386
+ // keeps every orchestrator.* wiring call grouped) and before the first
387
+ // turn — see context-accounting-source.ts for what each facet reads.
388
+ runtimeUnsubs.push(bindOrchestratorContextAccounting({
389
+ orchestrator,
390
+ holder: services.contextAccountingHolder,
391
+ runtimeBus,
392
+ runtimeStore: store,
393
+ sessionId: runtime.sessionId,
394
+ getContextWindow: () => providerRegistry.getContextWindowForModel(providerRegistry.getCurrentModel()),
395
+ }));
396
+
339
397
  const opsTaskManager = createTaskManager(store, runtimeBus, userSessionId);
340
398
  const opsControlPlane = services.featureFlags.isEnabled('operator-control-plane')
341
399
  ? new OpsControlPlane(opsTaskManager, runtimeBus, store, userSessionId)
@@ -420,74 +478,20 @@ export async function bootstrapRuntime(
420
478
 
421
479
  const deferredStartup = createDeferredStartupCoordinator();
422
480
 
423
- const formatHostServiceBaseUrl = (host: string, port: number): string => {
424
- const normalized = host.trim().toLowerCase();
425
- const probeHost = normalized === '0.0.0.0'
426
- ? '127.0.0.1'
427
- : normalized === '::' || normalized === '[::]'
428
- ? '::1'
429
- : host;
430
- const urlHost = probeHost.includes(':') && !probeHost.startsWith('[') ? `[${probeHost}]` : probeHost;
431
- return `http://${urlHost}:${port}`;
432
- };
433
-
434
- const createAgentDependencyStatus = (
435
- service: 'daemon' | 'httpListener',
436
- ): HostServiceStatus => {
437
- const host = String(configManager.get(service === 'daemon' ? 'controlPlane.host' : 'httpListener.host') ?? '127.0.0.1');
438
- const port = Number(configManager.get(service === 'daemon' ? 'controlPlane.port' : 'httpListener.port') ?? (service === 'daemon' ? 3421 : 3422));
439
- return {
440
- mode: service === 'daemon' ? 'external' : 'disabled',
441
- host,
442
- port,
443
- baseUrl: formatHostServiceBaseUrl(host, port),
444
- reason: service === 'daemon'
445
- ? 'GoodVibes Agent connects to a GoodVibes host owned outside this product and does not start or restart it.'
446
- : 'GoodVibes Agent does not own listener lifecycle.',
447
- };
448
- };
449
-
450
- const hostServiceIsActive = (status: HostServiceStatus): boolean => status.mode === 'embedded' || status.mode === 'external';
451
-
452
- const hostServiceIsBlocked = (status: HostServiceStatus): boolean => status.mode === 'blocked';
453
-
454
- const inspectAgentDependencies = () => {
455
- const daemonStatus = externalServices.daemonStatus;
456
- const httpListenerStatus = externalServices.httpListenerStatus;
457
- return {
458
- connectedHostRunning: hostServiceIsActive(daemonStatus),
459
- connectedHostPortInUse: hostServiceIsBlocked(daemonStatus),
460
- httpListenerRunning: hostServiceIsActive(httpListenerStatus),
461
- httpListenerPortInUse: hostServiceIsBlocked(httpListenerStatus),
462
- connectedHostStatus: daemonStatus,
463
- httpListenerStatus,
464
- };
465
- };
481
+ // GoodVibes Agent never owns the connected daemon's (or HTTP listener's)
482
+ // lifecycle see bootstrap-external-services.ts for the adopt-only wiring
483
+ // and the deferred discovery probe it schedules.
484
+ const agentExternalServices = wireAgentExternalServices({
485
+ configManager,
486
+ runtimeBus,
487
+ hookDispatcher,
488
+ services,
489
+ uiServices,
490
+ deferredStartup,
491
+ systemMessageRouter,
492
+ requestRender,
493
+ });
466
494
 
467
- let externalServices: ExternalServicesHandle = {
468
- daemonServer: null,
469
- httpListener: null,
470
- daemonStatus: createAgentDependencyStatus('daemon'),
471
- httpListenerStatus: createAgentDependencyStatus('httpListener'),
472
- listRecentControlPlaneEvents: () => [],
473
- async stop(): Promise<void> {},
474
- };
475
- const platformExternalServices = uiServices.platform as typeof uiServices.platform & {
476
- externalServices: NonNullable<typeof uiServices.platform.externalServices>;
477
- };
478
- platformExternalServices.externalServices = {
479
- inspect: inspectAgentDependencies,
480
- restart: async () => {
481
- externalServices = {
482
- ...externalServices,
483
- daemonStatus: createAgentDependencyStatus('daemon'),
484
- httpListenerStatus: createAgentDependencyStatus('httpListener'),
485
- };
486
- systemMessageRouter.high('[Startup] GoodVibes Agent does not start or restart the connected GoodVibes host. Start it from GoodVibes TUI or the owning host, then refresh status.');
487
- requestRender();
488
- return inspectAgentDependencies();
489
- },
490
- };
491
495
  deferredStartup.schedule({
492
496
  label: 'plugins',
493
497
  run: async () => {
@@ -736,7 +740,7 @@ export async function bootstrapRuntime(
736
740
  forensicsCollector.dispose();
737
741
  services.executionLedger.dispose();
738
742
  await deferredStartup.drain(100);
739
- await externalServices.stop();
743
+ await agentExternalServices.stop();
740
744
  // Clear agent status interval via ref (consistent with agentStatusIntervalRef usage)
741
745
  if (agentStatusIntervalRef.value !== null) {
742
746
  clearInterval(agentStatusIntervalRef.value);