@mono-agent/agent-app 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/README.md +24 -10
  2. package/dist/adapter-send-tools-main.d.ts +3 -0
  3. package/dist/adapter-send-tools-main.d.ts.map +1 -0
  4. package/dist/adapter-send-tools-main.js +20 -0
  5. package/dist/adapter-send-tools-main.js.map +1 -0
  6. package/dist/adapter-send-tools.d.ts +125 -0
  7. package/dist/adapter-send-tools.d.ts.map +1 -0
  8. package/dist/adapter-send-tools.js +629 -0
  9. package/dist/adapter-send-tools.js.map +1 -0
  10. package/dist/app-config.d.ts +86 -26
  11. package/dist/app-config.d.ts.map +1 -1
  12. package/dist/app-config.js +284 -90
  13. package/dist/app-config.js.map +1 -1
  14. package/dist/app.d.ts +51 -20
  15. package/dist/app.d.ts.map +1 -1
  16. package/dist/app.js +889 -119
  17. package/dist/app.js.map +1 -1
  18. package/dist/artifact-retention.d.ts +32 -0
  19. package/dist/artifact-retention.d.ts.map +1 -0
  20. package/dist/artifact-retention.js +111 -0
  21. package/dist/artifact-retention.js.map +1 -0
  22. package/dist/audit-runs.d.ts +12 -0
  23. package/dist/audit-runs.d.ts.map +1 -0
  24. package/dist/audit-runs.js +68 -0
  25. package/dist/audit-runs.js.map +1 -0
  26. package/dist/backfill.d.ts +75 -0
  27. package/dist/backfill.d.ts.map +1 -0
  28. package/dist/backfill.js +350 -0
  29. package/dist/backfill.js.map +1 -0
  30. package/dist/background.d.ts +8 -5
  31. package/dist/background.d.ts.map +1 -1
  32. package/dist/background.js +279 -53
  33. package/dist/background.js.map +1 -1
  34. package/dist/broadcast-recorder.d.ts +27 -0
  35. package/dist/broadcast-recorder.d.ts.map +1 -0
  36. package/dist/broadcast-recorder.js +82 -0
  37. package/dist/broadcast-recorder.js.map +1 -0
  38. package/dist/channel-config-view.d.ts +28 -0
  39. package/dist/channel-config-view.d.ts.map +1 -0
  40. package/dist/channel-config-view.js +79 -0
  41. package/dist/channel-config-view.js.map +1 -0
  42. package/dist/channel-gate.d.ts +22 -0
  43. package/dist/channel-gate.d.ts.map +1 -0
  44. package/dist/channel-gate.js +35 -0
  45. package/dist/channel-gate.js.map +1 -0
  46. package/dist/channel-plugins.d.ts +39 -0
  47. package/dist/channel-plugins.d.ts.map +1 -0
  48. package/dist/channel-plugins.js +346 -0
  49. package/dist/channel-plugins.js.map +1 -0
  50. package/dist/channels.d.ts +62 -66
  51. package/dist/channels.d.ts.map +1 -1
  52. package/dist/channels.js +850 -120
  53. package/dist/channels.js.map +1 -1
  54. package/dist/cli.d.ts +103 -4
  55. package/dist/cli.d.ts.map +1 -1
  56. package/dist/cli.js +984 -112
  57. package/dist/cli.js.map +1 -1
  58. package/dist/config-reference.d.ts +24 -0
  59. package/dist/config-reference.d.ts.map +1 -0
  60. package/dist/config-reference.js +580 -0
  61. package/dist/config-reference.js.map +1 -0
  62. package/dist/configured-agent.d.ts +104 -0
  63. package/dist/configured-agent.d.ts.map +1 -0
  64. package/dist/configured-agent.js +578 -0
  65. package/dist/configured-agent.js.map +1 -0
  66. package/dist/consumer-contract.d.ts +36 -0
  67. package/dist/consumer-contract.d.ts.map +1 -0
  68. package/dist/consumer-contract.js +290 -0
  69. package/dist/consumer-contract.js.map +1 -0
  70. package/dist/doctor.d.ts +17 -0
  71. package/dist/doctor.d.ts.map +1 -1
  72. package/dist/doctor.js +392 -95
  73. package/dist/doctor.js.map +1 -1
  74. package/dist/index.d.ts +14 -6
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +7 -3
  77. package/dist/index.js.map +1 -1
  78. package/dist/init.d.ts +23 -7
  79. package/dist/init.d.ts.map +1 -1
  80. package/dist/init.js +91 -26
  81. package/dist/init.js.map +1 -1
  82. package/dist/install-skill.d.ts +1 -1
  83. package/dist/install-skill.js +2 -2
  84. package/dist/install-skill.js.map +1 -1
  85. package/dist/interaction-bridge.d.ts +72 -0
  86. package/dist/interaction-bridge.d.ts.map +1 -0
  87. package/dist/interaction-bridge.js +354 -0
  88. package/dist/interaction-bridge.js.map +1 -0
  89. package/dist/launchd.d.ts +0 -2
  90. package/dist/launchd.d.ts.map +1 -1
  91. package/dist/launchd.js +0 -2
  92. package/dist/launchd.js.map +1 -1
  93. package/dist/memory-command.d.ts +10 -0
  94. package/dist/memory-command.d.ts.map +1 -0
  95. package/dist/memory-command.js +649 -0
  96. package/dist/memory-command.js.map +1 -0
  97. package/dist/memory-recall-main.d.ts +3 -0
  98. package/dist/memory-recall-main.d.ts.map +1 -0
  99. package/dist/memory-recall-main.js +30 -0
  100. package/dist/memory-recall-main.js.map +1 -0
  101. package/dist/memory-recall.d.ts +147 -0
  102. package/dist/memory-recall.d.ts.map +1 -0
  103. package/dist/memory-recall.js +345 -0
  104. package/dist/memory-recall.js.map +1 -0
  105. package/dist/memory-rituals.d.ts +8 -11
  106. package/dist/memory-rituals.d.ts.map +1 -1
  107. package/dist/memory-rituals.js +26 -25
  108. package/dist/memory-rituals.js.map +1 -1
  109. package/dist/metrics.d.ts +13 -0
  110. package/dist/metrics.d.ts.map +1 -0
  111. package/dist/metrics.js +105 -0
  112. package/dist/metrics.js.map +1 -0
  113. package/dist/notify-destinations.d.ts +23 -0
  114. package/dist/notify-destinations.d.ts.map +1 -0
  115. package/dist/notify-destinations.js +79 -0
  116. package/dist/notify-destinations.js.map +1 -0
  117. package/dist/posted-message-index.d.ts +61 -0
  118. package/dist/posted-message-index.d.ts.map +1 -0
  119. package/dist/posted-message-index.js +178 -0
  120. package/dist/posted-message-index.js.map +1 -0
  121. package/dist/proactive-notify.d.ts +32 -0
  122. package/dist/proactive-notify.d.ts.map +1 -0
  123. package/dist/proactive-notify.js +62 -0
  124. package/dist/proactive-notify.js.map +1 -0
  125. package/dist/recipes/base.d.ts +15 -0
  126. package/dist/recipes/base.d.ts.map +1 -0
  127. package/dist/recipes/base.js +51 -0
  128. package/dist/recipes/base.js.map +1 -0
  129. package/dist/recipes/catalog.d.ts +4 -0
  130. package/dist/recipes/catalog.d.ts.map +1 -0
  131. package/dist/recipes/catalog.js +509 -0
  132. package/dist/recipes/catalog.js.map +1 -0
  133. package/dist/recipes/index.d.ts +11 -0
  134. package/dist/recipes/index.d.ts.map +1 -0
  135. package/dist/recipes/index.js +14 -0
  136. package/dist/recipes/index.js.map +1 -0
  137. package/dist/recipes/types.d.ts +70 -0
  138. package/dist/recipes/types.d.ts.map +1 -0
  139. package/dist/recipes/types.js +15 -0
  140. package/dist/recipes/types.js.map +1 -0
  141. package/dist/request-model-override.d.ts +67 -0
  142. package/dist/request-model-override.d.ts.map +1 -0
  143. package/dist/request-model-override.js +96 -0
  144. package/dist/request-model-override.js.map +1 -0
  145. package/dist/runs-health.d.ts +22 -0
  146. package/dist/runs-health.d.ts.map +1 -0
  147. package/dist/runs-health.js +144 -0
  148. package/dist/runs-health.js.map +1 -0
  149. package/dist/seen-conversations.d.ts +22 -0
  150. package/dist/seen-conversations.d.ts.map +1 -0
  151. package/dist/seen-conversations.js +120 -0
  152. package/dist/seen-conversations.js.map +1 -0
  153. package/dist/sessions.d.ts +21 -0
  154. package/dist/sessions.d.ts.map +1 -0
  155. package/dist/sessions.js +45 -0
  156. package/dist/sessions.js.map +1 -0
  157. package/dist/setup.d.ts +29 -0
  158. package/dist/setup.d.ts.map +1 -0
  159. package/dist/setup.js +97 -0
  160. package/dist/setup.js.map +1 -0
  161. package/dist/trigger-overrides.d.ts +17 -0
  162. package/dist/trigger-overrides.d.ts.map +1 -0
  163. package/dist/trigger-overrides.js +30 -0
  164. package/dist/trigger-overrides.js.map +1 -0
  165. package/dist/tui-command.d.ts +56 -0
  166. package/dist/tui-command.d.ts.map +1 -0
  167. package/dist/tui-command.js +160 -0
  168. package/dist/tui-command.js.map +1 -0
  169. package/dist/ui.d.ts +54 -0
  170. package/dist/ui.d.ts.map +1 -0
  171. package/dist/ui.js +157 -0
  172. package/dist/ui.js.map +1 -0
  173. package/dist/web-command.d.ts +39 -0
  174. package/dist/web-command.d.ts.map +1 -0
  175. package/dist/web-command.js +151 -0
  176. package/dist/web-command.js.map +1 -0
  177. package/package.json +22 -17
  178. package/schema/mono-agent.config.schema.json +1686 -0
  179. package/skills/mono-agent-composer/SKILL.md +38 -12
  180. package/skills/mono-agent-composer/agents/openai.yaml +4 -0
  181. package/skills/mono-agent-composer/references/config-blueprint.md +84 -31
  182. package/skills/mono-agent-composer/references/discovery-questions.md +49 -36
  183. package/skills/mono-agent-composer/references/feature-coverage.md +26 -19
  184. package/skills/mono-agent-composer/references/package-map.md +20 -22
  185. package/skills/mono-agent-composer/references/playbooks.md +209 -0
  186. package/skills/mono-agent-composer/references/validation.md +14 -5
package/dist/app.js CHANGED
@@ -1,104 +1,117 @@
1
1
  import { resolve } from "node:path";
2
- import { startOperatorConsole } from "@mono-agent/operator-console";
3
- import { createConfiguredAgentResponder, createConfiguredAgentRuntime, createConfiguredMemory, } from "@mono-agent/agent-host";
4
- import { registerTraceSource } from "@mono-agent/observability";
5
- import { isAppCoreConfigError, loadAppCoreConfig, MONO_AGENT_APP_FIELD_GROUPS, resolveAppArtifactDir, resolveAppConsoleSettings, resolveAppTraceHeartbeatMs, resolveAppTraceRegistryDir, resolveAppTraceSourceId, resolveAppTraceSourceLabel, resolveAppTraceStaleAfterMs, } from "./app-config.js";
6
- import { defaultChannelDrivers } from "./channels.js";
2
+ import { createLiveEventBus } from "@mono-agent/agent-contracts";
3
+ import { pruneTraceSources, reconcileStaleRunArtifacts, registerTraceSource } from "@mono-agent/observability";
4
+ import { modelReferenceKey } from "@mono-agent/runtime-adapter";
5
+ import { describeSandboxEffectiveState, resolveSandboxEffectiveState, sandboxEffectiveStateWarning, } from "@mono-agent/runtime-adapter";
6
+ import { isAppCoreConfigError, loadAppCoreConfig, resolveAppArtifactDir, resolveAppObservabilityExporters, resolveAppTraceGlobalDiscovery, resolveAppTraceHeartbeatMs, resolveAppTraceRegistryDir, resolveAppTraceSourceId, resolveAppTraceSourceLabel, resolveAppTraceStaleAfterMs, resolveGlobalTraceRegistryDir, resolveTraceTmpdirRoot, shouldMirrorTraceSourceGlobally, } from "./app-config.js";
7
+ import { createConfiguredAgentResponder, createConfiguredAgentRuntime, createConfiguredMemory, } from "./configured-agent.js";
8
+ import { resolveChannelDrivers } from "./channels.js";
9
+ import { routeProactiveNotification } from "./proactive-notify.js";
10
+ import { adapterSendToolNames, createAdapterSendToolsRuntimeExtension, isAdapterSendToolAllowed, resolveAdapterSendToolsSettings, } from "./adapter-send-tools.js";
11
+ import { loadInteractionSettings, startInteractionBridge } from "./interaction-bridge.js";
7
12
  import { startMemoryRituals } from "./memory-rituals.js";
13
+ import { startArtifactRetentionScheduler } from "./artifact-retention.js";
14
+ import { createMemoryRecallRuntimeExtension, resolveMemoryRecallSettings, } from "./memory-recall.js";
15
+ import { createRequestModelOverrideRuntimeExtension } from "./request-model-override.js";
16
+ import { resolveNotifyDestinations } from "./notify-destinations.js";
17
+ import { resolvePostedMessageIndexPath } from "./posted-message-index.js";
8
18
  /**
9
- * Starts a config-first mono-agent host in `cwd`: operator console first (so
10
- * an incomplete config can be fixed in the browser), then traceability, then
11
- * every configured channel in parallel. Channels with incomplete config report
12
- * `waiting_for_config` instead of blocking the rest.
19
+ * Starts a config-first mono-agent host in `cwd`: traceability first, then every
20
+ * configured channel in parallel. Channels with incomplete config report
21
+ * `waiting_for_config` instead of blocking the rest. The host runs headless;
22
+ * config changes take effect on the next restart.
13
23
  */
14
24
  export async function startMonoAgentApp(options = {}) {
15
25
  const cwd = resolve(options.cwd ?? process.cwd());
16
26
  const configPath = resolve(cwd, options.configPath ?? "mono-agent.config.json");
17
27
  const env = options.env ?? process.env;
18
- const drivers = options.drivers ?? defaultChannelDrivers();
19
- const input = { env, cwd, configPath };
20
- let controller;
21
- let consoleServer;
22
- const consoleSettings = await resolveAppConsoleSettings(input);
23
- const consolePort = options.operatorConsolePort ?? consoleSettings.port;
24
- if (options.operatorConsole !== false && consoleSettings.enabled) {
25
- const consoleFactory = options.operatorConsoleFactory ?? startOperatorConsole;
26
- consoleServer = await consoleFactory({
27
- configPath,
28
- cwd,
29
- fieldGroups: options.fieldGroups ?? MONO_AGENT_APP_FIELD_GROUPS,
30
- observability: {
31
- artifactDir: () => resolveAppArtifactDir(input),
32
- maxRuns: 100,
33
- maxEventsPerRun: 750,
34
- },
35
- traceability: {
36
- registryDir: () => resolveAppTraceRegistryDir(input),
37
- staleAfterMs: () => resolveAppTraceStaleAfterMs(input),
38
- maxRuns: 100,
39
- maxEventsPerRun: 750,
40
- },
41
- applyConfigWrite: async () => {
42
- if (controller === undefined) {
43
- return {
44
- kind: "failed",
45
- message: "Mono agent app lifecycle is not ready to apply config changes.",
46
- transports: [],
47
- };
48
- }
49
- return await controller.applyConfigChange("operator-console-write");
50
- },
51
- ...(consolePort === undefined ? {} : { port: consolePort }),
52
- log: (event) => {
53
- logOperatorConsoleEvent(options.logger, event);
54
- },
55
- });
56
- }
57
- controller = new MonoAgentAppController({
58
- consoleServer,
28
+ const drivers = options.drivers ?? await resolveChannelDrivers({ env, cwd, configPath });
29
+ const controller = new MonoAgentAppController({
59
30
  cwd,
60
31
  configPath,
61
32
  env,
62
33
  drivers,
63
34
  ...(options.logger === undefined ? {} : { logger: options.logger }),
64
35
  ...(options.runtime === undefined ? {} : { runtime: options.runtime }),
36
+ ...(options.sandboxEngine === undefined ? {} : { sandboxEngine: options.sandboxEngine }),
65
37
  ...(options.traceDefaults === undefined ? {} : { traceDefaults: options.traceDefaults }),
66
38
  });
39
+ await controller.refreshSandboxStatus("startup");
67
40
  await controller.startTraceability("startup");
41
+ await controller.startExporters("startup");
68
42
  await Promise.all(drivers.map((driver) => controller?.startChannelIfConfigured(driver.id, "startup")));
69
43
  await controller.startMemoryRitualsIfConfigured("startup");
70
44
  await controller.refreshTraceSource("startup-complete");
71
45
  return controller;
72
46
  }
47
+ const DEFAULT_SANDBOX_STATUS = sandboxStatusFromState({
48
+ configured: false,
49
+ configuredMode: undefined,
50
+ effective: "off",
51
+ engine: undefined,
52
+ engineAvailable: undefined,
53
+ fallback: undefined,
54
+ fallbackActive: false,
55
+ unsafeAllowHostProcess: false,
56
+ });
73
57
  class MonoAgentAppController {
74
- operatorConsole;
75
58
  configPath;
76
- consoleServer;
77
59
  cwd;
78
60
  env;
79
61
  drivers;
80
62
  driversById;
81
63
  logger;
82
64
  runtime;
65
+ sandboxEngine;
83
66
  traceDefaults;
84
67
  activeRuntimes = [];
85
68
  statuses = new Map();
86
69
  running = new Map();
87
70
  startsInFlight = new Map();
71
+ /** Captured at construction (~process start): the cutoff for reclaiming orphaned "running" runs. */
72
+ processStartMs = Date.now();
73
+ staleRunsReconciled = false;
88
74
  traceabilityStatusValue = {
89
75
  kind: "disabled",
90
76
  reason: "Traceability has not started yet.",
91
77
  };
78
+ exporterStatusValue = {
79
+ kind: "disabled",
80
+ reason: "No observability exporter configured.",
81
+ };
82
+ sandboxStatusValue = DEFAULT_SANDBOX_STATUS;
83
+ selectedSkillsValue;
84
+ sessionMetadataValue;
85
+ /** The exporter the responder threads into agent-host (first configured exporter). */
86
+ resolvedExporter;
92
87
  traceSource;
88
+ traceRefreshTail = Promise.resolve();
89
+ // Best-effort mirror of `traceSource` into the machine-wide global registry,
90
+ // present only when `shouldMirrorTraceSourceGlobally` gates it on (see
91
+ // `startTraceability`). Kept in lockstep with `traceSource` on every
92
+ // refresh/stop so both manifests describe the same instance identically.
93
+ globalTraceSource;
94
+ artifactRetentionScheduler;
95
+ artifactRetentionGeneration = 0;
93
96
  memoryRituals;
94
97
  // One shared memory store across all channel responders + the ritual scheduler, so there is a single
95
98
  // memory.db handle (not one per channel plus one for rituals). Rebuilt on config reload, closed on stop.
96
99
  sharedMemory = undefined;
97
100
  sharedMemoryBuilt = false;
101
+ sharedMemoryBuild;
98
102
  configApplyTail = Promise.resolve();
99
103
  stopped = false;
104
+ // Interaction bridge (ask_user + tool progress): lazily started once, shared
105
+ // by every channel; the exported env keys are tracked for cleanup on stop.
106
+ interactionBridge;
107
+ interactionBridgeStart;
108
+ interactionBridgeEnvKeys = [];
109
+ // Shared in-process run-event bus: every run's recorder publishes to it (via the
110
+ // broadcast recorder threaded as `runEventSink`), and the `live` channel relays
111
+ // it over SSE. One instance for the app's lifetime — cheap, bounded ring buffer,
112
+ // and it must exist before any responder is built (like the interaction bridge).
113
+ liveEventBus = createLiveEventBus();
100
114
  constructor(input) {
101
- this.consoleServer = input.consoleServer;
102
115
  this.cwd = input.cwd;
103
116
  this.configPath = input.configPath;
104
117
  this.env = input.env;
@@ -106,6 +119,7 @@ class MonoAgentAppController {
106
119
  this.driversById = new Map(input.drivers.map((driver) => [driver.id, driver]));
107
120
  this.logger = input.logger;
108
121
  this.runtime = input.runtime;
122
+ this.sandboxEngine = input.sandboxEngine;
109
123
  this.traceDefaults = input.traceDefaults;
110
124
  for (const driver of input.drivers) {
111
125
  this.statuses.set(driver.id, {
@@ -113,18 +127,19 @@ class MonoAgentAppController {
113
127
  reason: `${driver.label} has not been configured yet.`,
114
128
  });
115
129
  }
116
- this.operatorConsole = input.consoleServer === undefined
117
- ? undefined
118
- : {
119
- url: input.consoleServer.url,
120
- appUrl: `${input.consoleServer.url}/?t=${input.consoleServer.token}`,
121
- token: input.consoleServer.token,
122
- configPath: input.configPath,
123
- };
124
130
  }
125
131
  get traceabilityStatus() {
126
132
  return this.traceabilityStatusValue;
127
133
  }
134
+ get exporterStatus() {
135
+ return this.exporterStatusValue;
136
+ }
137
+ get sandboxStatus() {
138
+ return this.sandboxStatusValue;
139
+ }
140
+ get selectedSkills() {
141
+ return this.selectedSkillsValue;
142
+ }
128
143
  channelStatus(id) {
129
144
  const status = this.statuses.get(id);
130
145
  if (status === undefined) {
@@ -148,9 +163,12 @@ class MonoAgentAppController {
148
163
  await this.stopChannel(driver.id, `${reason}:reload`);
149
164
  }
150
165
  this.stopMemoryRituals();
166
+ this.stopArtifactRetentionScheduler();
151
167
  await this.resetSharedMemory();
152
168
  await this.stopTraceSource(`${reason}:reload`);
169
+ await this.refreshSandboxStatus(reason);
153
170
  await this.startTraceability(reason);
171
+ await this.startExporters(reason);
154
172
  await Promise.all(this.drivers.map((driver) => this.startChannelIfConfigured(driver.id, reason)));
155
173
  await this.startMemoryRitualsIfConfigured(reason);
156
174
  await this.refreshTraceSource(`${reason}:complete`);
@@ -166,11 +184,6 @@ class MonoAgentAppController {
166
184
  return this.channelStatus(id);
167
185
  }
168
186
  if (this.running.has(id)) {
169
- if (reason === "operator-console-write") {
170
- this.logger?.info?.(`${driver.label} is already running; restart the app to apply later config changes.`, {
171
- status: "running",
172
- });
173
- }
174
187
  await this.refreshTraceSource(reason);
175
188
  return this.channelStatus(id);
176
189
  }
@@ -190,16 +203,20 @@ class MonoAgentAppController {
190
203
  if (this.stopped) {
191
204
  return this.traceabilityStatusValue;
192
205
  }
206
+ let artifactDirForRetention;
193
207
  try {
194
208
  const input = { env: this.env, cwd: this.cwd, configPath: this.configPath };
195
- const [registryDir, artifactDir, sourceId, label, heartbeatMs] = await Promise.all([
209
+ await this.refreshSelectedSkillsSnapshot(reason);
210
+ const [registryDir, artifactDir, sourceId, label, heartbeatMs, globalDiscovery] = await Promise.all([
196
211
  resolveAppTraceRegistryDir(input),
197
212
  resolveAppArtifactDir(input),
198
213
  resolveAppTraceSourceId(input, this.traceDefaults),
199
214
  resolveAppTraceSourceLabel(input, this.traceDefaults),
200
215
  resolveAppTraceHeartbeatMs(input),
216
+ resolveAppTraceGlobalDiscovery(input),
201
217
  ]);
202
- this.traceSource = await registerTraceSource({
218
+ artifactDirForRetention = artifactDir;
219
+ const registerOptions = {
203
220
  registryDir,
204
221
  sourceId,
205
222
  label,
@@ -209,30 +226,238 @@ class MonoAgentAppController {
209
226
  configPath: this.configPath,
210
227
  metadata: this.traceMetadata(reason),
211
228
  heartbeatMs,
212
- });
229
+ };
230
+ this.traceSource = await registerTraceSource(registerOptions);
213
231
  this.traceabilityStatusValue = { kind: "running", sourceId, registryDir, artifactDir };
214
232
  this.logger?.info?.("Traceability source registered.", { reason, sourceId, registryDir, artifactDir });
233
+ void pruneTraceSources({ registryDir });
234
+ // Best-effort global mirror: makes this agent discoverable by `mono-agent
235
+ // tui` run anywhere on the machine, even when its own registryDir is a
236
+ // config-local override (e.g. `mono-agent init`'s scaffold). A mirror
237
+ // failure must never affect the primary registration above.
238
+ const globalRegistryDir = resolveGlobalTraceRegistryDir(this.env);
239
+ const tmpdirRoot = resolveTraceTmpdirRoot(this.env);
240
+ if (shouldMirrorTraceSourceGlobally({ registryDir, globalRegistryDir, globalDiscovery, tmpdirRoot })) {
241
+ try {
242
+ this.globalTraceSource = await registerTraceSource({ ...registerOptions, registryDir: globalRegistryDir });
243
+ void pruneTraceSources({ registryDir: globalRegistryDir });
244
+ }
245
+ catch (error) {
246
+ this.globalTraceSource = undefined;
247
+ this.logger?.warn?.("Global trace-source mirror registration failed.", { reason: reasonOf(error) });
248
+ }
249
+ }
250
+ else {
251
+ this.globalTraceSource = undefined;
252
+ }
215
253
  }
216
254
  catch (error) {
217
255
  const failure = reasonOf(error);
218
256
  this.traceabilityStatusValue = { kind: "failed", reason: failure };
219
257
  this.logger?.error?.("Traceability source registration failed.", { reason: failure });
220
258
  }
259
+ finally {
260
+ if (artifactDirForRetention !== undefined) {
261
+ // Fire-and-forget: orphan reclamation and retention are best-effort cleanup
262
+ // and must not gate startup readiness. Keep them independent from trace
263
+ // source registration; a broken registry should not disable artifact GC.
264
+ this.restartArtifactRetentionScheduler(artifactDirForRetention, reason);
265
+ }
266
+ }
221
267
  return this.traceabilityStatusValue;
222
268
  }
223
- async refreshTraceSource(reason) {
224
- if (this.traceSource === undefined) {
269
+ async refreshSandboxStatus(reason) {
270
+ try {
271
+ const input = { env: this.env, cwd: this.cwd, configPath: this.configPath };
272
+ const coreConfig = await loadAppCoreConfig(input);
273
+ const state = await resolveSandboxEffectiveState({
274
+ ...(coreConfig.sandbox === undefined ? {} : { policy: coreConfig.sandbox }),
275
+ ...(this.sandboxEngine === undefined ? {} : { engine: this.sandboxEngine }),
276
+ });
277
+ const status = sandboxStatusFromState(state);
278
+ this.sandboxStatusValue = status;
279
+ if (status.warning !== undefined) {
280
+ this.logger?.warn?.(status.warning, { reason, detail: status.detail });
281
+ }
282
+ return status;
283
+ }
284
+ catch (error) {
285
+ const resolutionError = reasonOf(error);
286
+ const status = {
287
+ ...DEFAULT_SANDBOX_STATUS,
288
+ detail: `Sandbox status unavailable until agent config loads: ${resolutionError}`,
289
+ resolutionError,
290
+ };
291
+ this.sandboxStatusValue = status;
292
+ this.logger?.info?.("Sandbox status unavailable until agent config loads.", { reason, detail: resolutionError });
293
+ return status;
294
+ }
295
+ }
296
+ /**
297
+ * One-shot at startup: a process that crashed mid-run leaves its summary stuck at "running"
298
+ * forever (a ghost run in `status`/observability). Reclaim those — any "running" summary that
299
+ * began before THIS process started — by rewriting them to "interrupted". Gated so a config
300
+ * reload (which re-runs startTraceability) does not repeat the scan. Best-effort: never fatal.
301
+ */
302
+ async reconcileStaleRunsOnce(artifactDir) {
303
+ if (this.staleRunsReconciled) {
225
304
  return;
226
305
  }
306
+ this.staleRunsReconciled = true;
227
307
  try {
228
- await this.traceSource.update({
229
- transports: this.activeTransports(),
230
- metadata: this.traceMetadata(reason),
308
+ const { reconciled, warnings } = await reconcileStaleRunArtifacts(artifactDir, {
309
+ startedBeforeMs: this.processStartMs,
231
310
  });
311
+ for (const warning of warnings) {
312
+ this.logger?.warn?.(`Stale-run reconciliation: ${warning}`);
313
+ }
314
+ if (reconciled.length > 0) {
315
+ this.logger?.info?.('Reclaimed orphaned runs left as "running" by a prior process.', {
316
+ count: reconciled.length,
317
+ runIds: reconciled.slice(0, 20),
318
+ });
319
+ }
320
+ }
321
+ catch (error) {
322
+ this.logger?.warn?.("Stale-run reconciliation failed.", { reason: reasonOf(error) });
323
+ }
324
+ }
325
+ /**
326
+ * Resolve the configured observability exporter(s) and publish the export
327
+ * status. No reachability probe runs here — Phoenix may start after the agent,
328
+ * so an unreachable endpoint must not block startup (that probe runs in
329
+ * `validate`). A present-but-invalid exporter config surfaces as `failed`.
330
+ */
331
+ async startExporters(reason) {
332
+ if (this.stopped) {
333
+ return this.exporterStatusValue;
334
+ }
335
+ const input = { env: this.env, cwd: this.cwd, configPath: this.configPath };
336
+ let exporters;
337
+ try {
338
+ exporters = await resolveAppObservabilityExporters(input);
232
339
  }
233
340
  catch (error) {
234
- this.logger?.warn?.("Traceability source update failed.", { reason: reasonOf(error) });
341
+ this.resolvedExporter = undefined;
342
+ this.exporterStatusValue = { kind: "failed", reason: reasonOf(error) };
343
+ this.logger?.error?.("Observability exporter config is invalid.", { reason: reasonOf(error) });
344
+ return this.exporterStatusValue;
345
+ }
346
+ const exporter = exporters[0];
347
+ if (exporter === undefined) {
348
+ this.resolvedExporter = undefined;
349
+ this.exporterStatusValue = { kind: "disabled", reason: "No observability exporter configured." };
350
+ return this.exporterStatusValue;
351
+ }
352
+ this.resolvedExporter = exporter;
353
+ this.exporterStatusValue = {
354
+ kind: "configured",
355
+ endpoint: exporter.endpoint,
356
+ includeSensitiveData: exporter.includeSensitiveData ?? false,
357
+ };
358
+ this.logger?.info?.("Observability exporter configured.", {
359
+ reason,
360
+ endpoint: exporter.endpoint,
361
+ includeSensitiveData: exporter.includeSensitiveData ?? false,
362
+ });
363
+ return this.exporterStatusValue;
364
+ }
365
+ /** Record a best-effort export warning so `status` can surface it without failing the run. */
366
+ recordExporterWarning(warning) {
367
+ const current = this.exporterStatusValue;
368
+ if (current.kind !== "configured") {
369
+ return;
370
+ }
371
+ const message = `${warning.phase}: ${warning.message}`;
372
+ // The "fail" phase fires only when export fails on the run-failure path;
373
+ // surface it as lastError so operators can tell it apart from a transient
374
+ // best-effort warning. The run outcome is unchanged either way.
375
+ this.exporterStatusValue =
376
+ warning.phase === "fail" ? { ...current, lastError: message } : { ...current, lastWarning: message };
377
+ this.logger?.warn?.("Observability export warning.", { phase: warning.phase, message: warning.message });
378
+ // Persist to the trace-source manifest so the detached `mono-agent status`
379
+ // (which reads the manifest, not this live object) can surface it too.
380
+ void this.refreshTraceSource("exporter-warning").catch(() => undefined);
381
+ }
382
+ async refreshTraceSource(reason) {
383
+ const run = async () => {
384
+ if (this.traceSource === undefined) {
385
+ return;
386
+ }
387
+ const patch = { transports: this.activeTransports(), metadata: this.traceMetadata(reason) };
388
+ try {
389
+ await this.traceSource.update(patch);
390
+ }
391
+ catch (error) {
392
+ this.logger?.warn?.("Traceability source update failed.", { reason: reasonOf(error) });
393
+ }
394
+ if (this.globalTraceSource !== undefined) {
395
+ try {
396
+ await this.globalTraceSource.update(patch);
397
+ }
398
+ catch (error) {
399
+ this.logger?.warn?.("Global trace-source mirror update failed.", { reason: reasonOf(error) });
400
+ }
401
+ }
402
+ };
403
+ const next = this.traceRefreshTail.then(run, run);
404
+ this.traceRefreshTail = next.then(() => undefined, () => undefined);
405
+ await next;
406
+ }
407
+ /**
408
+ * Start the interaction bridge once, when the ask_user tool is allowed by the
409
+ * tool policy or the operator configured the `interaction` block. Exports the
410
+ * bridge env into the app env AND process env so settings resolution and
411
+ * spawned stdio tool children (which inherit process.env) can reach it.
412
+ */
413
+ ensureInteractionBridge(coreConfig) {
414
+ this.interactionBridgeStart ??= (async () => {
415
+ const askUserAllowed = isAdapterSendToolAllowed("ask_user", {
416
+ allowedTools: coreConfig.tools.allowedTools,
417
+ disallowedTools: coreConfig.tools.disallowedTools,
418
+ });
419
+ const settings = await loadInteractionSettings({ env: this.env, configPath: this.configPath });
420
+ if (!askUserAllowed && !settings.configured) {
421
+ return undefined;
422
+ }
423
+ try {
424
+ const bridge = await startInteractionBridge({
425
+ host: settings.host,
426
+ port: settings.port,
427
+ askTimeoutMs: settings.askTimeoutMs,
428
+ ...(this.logger === undefined ? {} : { logger: this.logger }),
429
+ });
430
+ const bridgeEnv = bridge.env();
431
+ this.interactionBridgeEnvKeys = Object.keys(bridgeEnv);
432
+ Object.assign(this.env, bridgeEnv);
433
+ if (this.env !== process.env) {
434
+ Object.assign(process.env, bridgeEnv);
435
+ }
436
+ this.interactionBridge = bridge;
437
+ this.logger?.info?.("Interaction bridge started.", { url: bridge.url });
438
+ return bridge;
439
+ }
440
+ catch (error) {
441
+ this.logger?.warn?.("Interaction bridge failed to start; ask_user and tool progress are unavailable.", {
442
+ reason: reasonOf(error),
443
+ });
444
+ return undefined;
445
+ }
446
+ })();
447
+ return this.interactionBridgeStart;
448
+ }
449
+ async stopInteractionBridge() {
450
+ const bridge = this.interactionBridge;
451
+ this.interactionBridge = undefined;
452
+ this.interactionBridgeStart = undefined;
453
+ for (const key of this.interactionBridgeEnvKeys) {
454
+ delete this.env[key];
455
+ if (this.env !== process.env) {
456
+ delete process.env[key];
457
+ }
235
458
  }
459
+ this.interactionBridgeEnvKeys = [];
460
+ await bridge?.stop().catch(() => undefined);
236
461
  }
237
462
  async stop() {
238
463
  if (this.stopped) {
@@ -242,10 +467,11 @@ class MonoAgentAppController {
242
467
  for (const driver of this.drivers) {
243
468
  await this.stopChannel(driver.id, "stop");
244
469
  }
470
+ await this.stopInteractionBridge();
245
471
  this.stopMemoryRituals();
472
+ this.stopArtifactRetentionScheduler();
246
473
  await this.resetSharedMemory();
247
474
  await this.stopTraceSource("stop");
248
- await this.consoleServer?.stop();
249
475
  for (const runtime of this.activeRuntimes.splice(0)) {
250
476
  await runtime.disposeAllSessions?.().catch(() => undefined);
251
477
  }
@@ -258,22 +484,30 @@ class MonoAgentAppController {
258
484
  try {
259
485
  const input = { env: this.env, cwd: this.cwd, configPath: this.configPath };
260
486
  coreConfig = await loadAppCoreConfig(input);
487
+ this.rememberSelectedSkills(coreConfig);
261
488
  }
262
489
  catch {
263
- // Config not ready yet — rituals will start on the next applyConfigChange.
490
+ // Config not ready yet — consolidation will start on the next applyConfigChange.
264
491
  return;
265
492
  }
266
493
  if (coreConfig.memory?.mode !== "bujo") {
267
494
  return;
268
495
  }
269
- const store = this.memoryStore(coreConfig);
270
- // Duck-type: only bujo-tier BujoMemoryStore has reflect/migrate.
496
+ const runtime = this.runtime ?? createConfiguredAgentRuntime({
497
+ config: coreConfig,
498
+ ...(this.sandboxEngine === undefined ? {} : { sandboxEngine: this.sandboxEngine }),
499
+ });
500
+ if (!this.activeRuntimes.includes(runtime)) {
501
+ this.activeRuntimes.push(runtime);
502
+ }
503
+ const store = await this.memoryStore(coreConfig);
504
+ // Duck-type: only bujo-tier BujoMemoryStore has consolidate().
271
505
  // Cast through unknown to bypass the MemoryStore contract's type mismatch.
272
506
  const storeAsAny = store;
273
507
  if (store === undefined ||
274
- typeof storeAsAny["reflect"] !== "function" ||
275
- typeof storeAsAny["migrate"] !== "function" ||
508
+ typeof storeAsAny["consolidate"] !== "function" ||
276
509
  typeof storeAsAny["tier"] !== "function") {
510
+ this.logger?.info?.("Memory consolidation scheduler skipped — store does not support consolidate().", { reason });
277
511
  return;
278
512
  }
279
513
  const bujoStore = store;
@@ -282,13 +516,12 @@ class MonoAgentAppController {
282
516
  // scheduler started in that case — log an accurate skip instead.
283
517
  const tier = bujoStore.tier();
284
518
  if (tier !== "bujo") {
285
- this.logger?.info?.("Memory ritual scheduler skipped — store tier is not bujo (reflect/migrate need a local chat LLM).", { reason, tier });
519
+ this.logger?.info?.("Memory consolidation scheduler skipped — configured bujo mode resolved to the journal tier because memory.llm is missing.", { reason, tier });
286
520
  return;
287
521
  }
288
522
  this.memoryRituals = startMemoryRituals({
289
523
  store: bujoStore,
290
- ...(coreConfig.memory.reflection !== undefined && { reflection: coreConfig.memory.reflection }),
291
- ...(coreConfig.memory.migration !== undefined && { migration: coreConfig.memory.migration }),
524
+ ...(coreConfig.memory.consolidation !== undefined && { consolidation: coreConfig.memory.consolidation }),
292
525
  ...(this.logger !== undefined && {
293
526
  logger: {
294
527
  info: (m) => this.logger?.info?.(m),
@@ -296,7 +529,7 @@ class MonoAgentAppController {
296
529
  },
297
530
  }),
298
531
  });
299
- this.logger?.info?.("Memory ritual scheduler started.", { reason, mode: "bujo" });
532
+ this.logger?.info?.("Memory consolidation scheduler started.", { reason, mode: "bujo" });
300
533
  }
301
534
  stopMemoryRituals() {
302
535
  const rituals = this.memoryRituals;
@@ -305,7 +538,94 @@ class MonoAgentAppController {
305
538
  }
306
539
  this.memoryRituals = undefined;
307
540
  rituals.stop();
308
- this.logger?.info?.("Memory ritual scheduler stopped.");
541
+ this.logger?.info?.("Memory consolidation scheduler stopped.");
542
+ }
543
+ restartArtifactRetentionScheduler(artifactDir, reason) {
544
+ this.stopArtifactRetentionScheduler();
545
+ const generation = ++this.artifactRetentionGeneration;
546
+ void (async () => {
547
+ let coreConfig;
548
+ try {
549
+ const input = { env: this.env, cwd: this.cwd, configPath: this.configPath };
550
+ coreConfig = await loadAppCoreConfig(input);
551
+ this.rememberSelectedSkills(coreConfig);
552
+ }
553
+ catch (error) {
554
+ this.logger?.warn?.("Artifact retention scheduler skipped until core config loads.", { reason: reasonOf(error) });
555
+ void this.reconcileStaleRunsOnce(artifactDir);
556
+ return;
557
+ }
558
+ if (this.stopped || generation !== this.artifactRetentionGeneration) {
559
+ return;
560
+ }
561
+ this.artifactRetentionScheduler = startArtifactRetentionScheduler({
562
+ artifactDir,
563
+ retention: coreConfig.artifacts.retention,
564
+ memoryRetention: coreConfig.artifacts.memoryRetention,
565
+ ...(this.logger === undefined ? {} : { logger: this.logger }),
566
+ beforeFirstRun: () => this.reconcileStaleRunsOnce(artifactDir),
567
+ });
568
+ this.logger?.info?.("Artifact retention scheduler started.", {
569
+ reason,
570
+ artifactDir,
571
+ agent: {
572
+ maxAgeDays: coreConfig.artifacts.retention.maxAgeDays,
573
+ maxCount: coreConfig.artifacts.retention.maxCount,
574
+ dryRun: coreConfig.artifacts.retention.dryRun,
575
+ },
576
+ memory: {
577
+ maxAgeDays: coreConfig.artifacts.memoryRetention.maxAgeDays,
578
+ maxCount: coreConfig.artifacts.memoryRetention.maxCount,
579
+ dryRun: coreConfig.artifacts.memoryRetention.dryRun,
580
+ },
581
+ });
582
+ })();
583
+ }
584
+ stopArtifactRetentionScheduler() {
585
+ this.artifactRetentionGeneration += 1;
586
+ const scheduler = this.artifactRetentionScheduler;
587
+ if (scheduler === undefined) {
588
+ return;
589
+ }
590
+ this.artifactRetentionScheduler = undefined;
591
+ scheduler.stop();
592
+ this.logger?.info?.("Artifact retention scheduler stopped.");
593
+ }
594
+ /**
595
+ * Deliver a native cron/webhook notification to whichever running channel owns
596
+ * the destination conversationId. With `verbatim`, the channel posts `text`
597
+ * unchanged (no model call) and records it to history; otherwise it runs `text`
598
+ * as a turn. Best-effort: an unavailable/unsupported destination is warned and
599
+ * skipped, never thrown back to the cron/webhook trigger.
600
+ */
601
+ async notifyDestination(conversationId, text, options) {
602
+ const result = await routeProactiveNotification({
603
+ conversationId,
604
+ text,
605
+ running: this.running,
606
+ ...(options?.verbatim === undefined ? {} : { verbatim: options.verbatim }),
607
+ ...(this.logger === undefined ? {} : { logger: this.logger }),
608
+ });
609
+ // Make the delivery outcome inspectable (the failure cases already warn inside
610
+ // the router / channel hooks; log the success path too so a notify is auditable).
611
+ if (result.delivered) {
612
+ this.logger?.info?.("Proactive notification delivered.", { conversationId });
613
+ }
614
+ return result;
615
+ }
616
+ /**
617
+ * Candidate destinations for native cron/webhook notification delivery, used to
618
+ * infer a target when a job/endpoint sets no explicit `notifyConversationId`.
619
+ */
620
+ async listNotifyDestinations() {
621
+ const input = { env: this.env, cwd: this.cwd, configPath: this.configPath };
622
+ const artifactDir = await resolveAppArtifactDir(input);
623
+ return await resolveNotifyDestinations({
624
+ input,
625
+ artifactDir,
626
+ isRunning: (id) => this.running.has(id),
627
+ ...(this.logger === undefined ? {} : { logger: this.logger }),
628
+ });
309
629
  }
310
630
  async startChannel(driver, reason) {
311
631
  const input = { env: this.env, cwd: this.cwd, configPath: this.configPath };
@@ -327,9 +647,16 @@ class MonoAgentAppController {
327
647
  if (waitingReason !== undefined) {
328
648
  return this.setStatus(driver.id, { kind: "waiting_for_config", reason: waitingReason });
329
649
  }
650
+ // Structural issues (e.g. an invalid per-trigger model override) fail
651
+ // `validate` but only WARN here: the run-time override path ignores the
652
+ // bad value and falls back, so starting is still the safe choice.
653
+ for (const issue of driver.configIssues?.(config) ?? []) {
654
+ this.logger?.warn?.("Channel config issue (run `mono-agent validate`).", { channel: driver.id, issue });
655
+ }
330
656
  let coreConfig;
331
657
  try {
332
658
  coreConfig = await loadAppCoreConfig(input);
659
+ this.rememberSelectedSkills(coreConfig);
333
660
  }
334
661
  catch (error) {
335
662
  if (isAppCoreConfigError(error)) {
@@ -338,21 +665,82 @@ class MonoAgentAppController {
338
665
  }
339
666
  throw error;
340
667
  }
668
+ // Resolve the posted-message index path once so the Slack driver can link
669
+ // posted messages to their producing conversation (in-thread reply continuity).
670
+ const postedMessageIndexPath = resolvePostedMessageIndexPath(await resolveAppArtifactDir(input));
671
+ // The bridge must exist BEFORE the responder is built (ask_user settings
672
+ // resolution reads the exported bridge env) and before driver.start (sink
673
+ // registration + pending-ask interception).
674
+ const interactionBridge = await this.ensureInteractionBridge(coreConfig);
341
675
  try {
342
- const responder = this.buildResponder(coreConfig);
676
+ const responder = await this.buildResponder(coreConfig);
677
+ // The AgentResponder contract has no dispose(), but the configured responder
678
+ // (createAgentResponder) exposes one that tears down the harness + live-session
679
+ // manager. Read it LAZILY (at teardown time) off the `responder` so both the
680
+ // normal stop/reload path AND the onFailure (transport-death) path retire the
681
+ // per-channel harness rather than orphan it, and any wrapper applied to the
682
+ // responder is honored. Duck-typed, consistent with the resetSharedMemory /
683
+ // bujo-store patterns elsewhere in this file.
684
+ const disposeResponder = () => responder.dispose?.();
685
+ const hasDispose = typeof responder.dispose === "function";
343
686
  const runningChannel = await driver.start({
344
687
  config,
345
688
  coreConfig,
346
689
  responder,
347
690
  cwd: this.cwd,
691
+ notifyDestination: (conversationId, text, options) => this.notifyDestination(conversationId, text, options),
692
+ listNotifyDestinations: () => this.listNotifyDestinations(),
693
+ postedMessageIndexPath,
694
+ ...(interactionBridge === undefined ? {} : { interaction: interactionBridge }),
695
+ liveEventBus: this.liveEventBus,
348
696
  ...(this.logger === undefined ? {} : { logger: this.logger }),
349
697
  onFailure: (failureReason) => {
350
698
  this.running.delete(driver.id);
351
699
  this.setStatus(driver.id, { kind: "failed", reason: failureReason });
352
700
  this.logger?.error?.(`${driver.label} channel stopped with an error.`, { reason: failureReason });
701
+ // The running-channel entry (which holds the stop/reload dispose handle)
702
+ // was just deleted, so dispose the responder here too — otherwise a
703
+ // transport death orphans the per-channel harness/live-session manager
704
+ // (the stop/reload path early-returns on the now-missing entry).
705
+ void disposeResponder()?.catch((error) => {
706
+ this.logger?.warn?.(`${driver.label} responder did not dispose cleanly after failure.`, {
707
+ reason: failureReason,
708
+ error: reasonOf(error),
709
+ });
710
+ });
711
+ },
712
+ // A self-recovering transport (e.g. telegram poll crash on a network blip).
713
+ // Unlike onFailure, this must NOT delete the running entry or dispose the
714
+ // responder: the channel owns its own restart, so the harness stays alive
715
+ // for the restarted transport to deliver into, and a later stop/reload still
716
+ // finds the entry and disposes it exactly once.
717
+ onDegraded: (reason) => {
718
+ if (this.statuses.get(driver.id)?.kind === "degraded") {
719
+ return;
720
+ }
721
+ this.setStatus(driver.id, { kind: "degraded", reason });
722
+ this.logger?.warn?.(`${driver.label} channel degraded; transport is recovering.`, { reason });
723
+ },
724
+ // The transport's self-recovery succeeded. Flip back to running, reusing the
725
+ // preserved entry's summary. Guard on the entry: a recovery that races a
726
+ // stop/reload must not resurrect a torn-down channel's status.
727
+ onRecovered: () => {
728
+ const entry = this.running.get(driver.id);
729
+ if (entry === undefined) {
730
+ return;
731
+ }
732
+ if (this.statuses.get(driver.id)?.kind !== "degraded") {
733
+ return;
734
+ }
735
+ this.setStatus(driver.id, { kind: "running", summary: entry.summary });
736
+ this.logger?.info?.(`${driver.label} channel recovered.`, {});
353
737
  },
354
738
  });
355
- this.running.set(driver.id, runningChannel);
739
+ this.running.set(driver.id, {
740
+ ...runningChannel,
741
+ stop: () => runningChannel.stop(),
742
+ ...(hasDispose ? { dispose: () => disposeResponder() ?? Promise.resolve() } : {}),
743
+ });
356
744
  const status = this.setStatus(driver.id, { kind: "running", summary: runningChannel.summary });
357
745
  this.logger?.info?.(`${driver.label} channel is running.`, { reason, ...runningChannel.summary });
358
746
  return status;
@@ -373,6 +761,12 @@ class MonoAgentAppController {
373
761
  await runningChannel.stop().catch((error) => {
374
762
  this.logger?.warn?.(`${driver.label} channel did not stop cleanly.`, { reason, error: reasonOf(error) });
375
763
  });
764
+ // Stop the transport first (so no new turns arrive), then dispose the responder
765
+ // so the harness/live-session manager and warm provider sessions are retired
766
+ // rather than lingering against stale config across a reload.
767
+ await runningChannel.dispose?.().catch((error) => {
768
+ this.logger?.warn?.(`${driver.label} responder did not dispose cleanly.`, { reason, error: reasonOf(error) });
769
+ });
376
770
  if (!this.stopped) {
377
771
  this.setStatus(id, {
378
772
  kind: "waiting_for_config",
@@ -380,35 +774,210 @@ class MonoAgentAppController {
380
774
  });
381
775
  }
382
776
  }
383
- buildResponder(coreConfig) {
777
+ async buildResponder(coreConfig) {
384
778
  const runtime = this.runtime ?? createConfiguredAgentRuntime(coreConfig);
385
779
  if (!this.activeRuntimes.includes(runtime)) {
386
780
  this.activeRuntimes.push(runtime);
387
781
  }
388
- const memory = this.memoryStore(coreConfig);
389
- return createConfiguredAgentResponder({
782
+ const memory = await this.memoryStore(coreConfig);
783
+ const memoryRecall = this.memoryRecallRuntimeOptions(coreConfig);
784
+ const supermemoryMcp = this.supermemoryMcpRuntimeOptions(coreConfig);
785
+ const adapterSendTools = await this.adapterSendToolsRuntimeOptions(coreConfig);
786
+ // Always active: a no-op for interactive turns (which carry no cron/webhook
787
+ // metadata), it applies the per-trigger model/effort override otherwise.
788
+ const requestModelOverride = this.requestModelOverrideRuntimeOptions(coreConfig);
789
+ const runtimeOptionsForRequest = composeRuntimeOptionExtensions([
790
+ memoryRecall,
791
+ supermemoryMcp,
792
+ adapterSendTools,
793
+ requestModelOverride,
794
+ ]);
795
+ // The override factory is only needed when fallbacks are configured: the
796
+ // fallback router freezes the model chain, so an override must run on a runtime
797
+ // whose chain has it as primary. With no fallbacks the shared (plain) runtime
798
+ // honors the per-run model directly, so building a separate runtime would be
799
+ // redundant. Omit the factory there and the harness uses the shared runtime.
800
+ const runtimeForModel = (coreConfig.runtime.fallbackModels?.length ?? 0) > 0
801
+ ? this.buildRuntimeForModel(coreConfig)
802
+ : undefined;
803
+ const observabilityContext = await this.observabilityContext();
804
+ const responder = await createConfiguredAgentResponder({
390
805
  config: coreConfig,
391
806
  runtime,
807
+ ...(runtimeForModel === undefined ? {} : { runtimeForModel }),
808
+ ...(this.sandboxEngine === undefined ? {} : { sandboxEngine: this.sandboxEngine }),
392
809
  ...(memory !== undefined && { memory }),
810
+ ...(runtimeOptionsForRequest === undefined ? {} : { runtimeOptionsForRequest }),
811
+ // Thread run-identifying context onto exported spans and surface per-run
812
+ // export warnings to `exporterStatus` (agent-host only builds the exporter
813
+ // when config.observability.exporters is non-empty).
814
+ observabilityContext,
815
+ exporterWarn: (warning) => this.recordExporterWarning(warning),
816
+ onSessionEvent: (event) => this.recordSessionEvent(event, coreConfig),
817
+ // Publish every run's start/event/finish to the shared bus so the `live`
818
+ // channel can relay it. Best-effort + additive (see broadcast recorder).
819
+ runEventSink: this.liveEventBus,
820
+ });
821
+ return responder;
822
+ }
823
+ /**
824
+ * Per-request extension that applies a cron/webhook/tui per-trigger model +
825
+ * effort override (validated, warn-and-ignore on bad input). Threads the
826
+ * configured local providers so a LOCAL-model override recomputes its endpoint
827
+ * block for the OVERRIDE model (see request-model-override doc). Composed
828
+ * alongside the memory/adapter extensions.
829
+ */
830
+ requestModelOverrideRuntimeOptions(coreConfig) {
831
+ const extension = createRequestModelOverrideRuntimeExtension({
832
+ ...(this.logger === undefined ? {} : { logger: this.logger }),
833
+ ...(coreConfig.providers?.local === undefined ? {} : { localProviders: coreConfig.providers.local }),
834
+ });
835
+ return async (input) => extension({ request: input.request });
836
+ }
837
+ /**
838
+ * Memoized factory for runtimes bound to a per-request override model. Reuses
839
+ * createConfiguredAgentRuntime so the override becomes the fallback-chain
840
+ * primary with the configured backups after it (override + keep fallbacks).
841
+ * Built runtimes register in `activeRuntimes`, which is disposed on `stop()`
842
+ * (config reload rebuilds the responder but does not drain prior runtimes —
843
+ * same lifetime as the base runtime built in `buildResponder`).
844
+ */
845
+ buildRuntimeForModel(coreConfig) {
846
+ const cache = new Map();
847
+ return (model, executionMode) => {
848
+ const key = `${modelReferenceKey(model)}|${executionMode ?? ""}`;
849
+ const cached = cache.get(key);
850
+ if (cached !== undefined) {
851
+ return cached;
852
+ }
853
+ const runtime = createConfiguredAgentRuntime({
854
+ config: coreConfig,
855
+ model,
856
+ ...(executionMode === undefined ? {} : { executionMode }),
857
+ ...(this.sandboxEngine === undefined ? {} : { sandboxEngine: this.sandboxEngine }),
858
+ });
859
+ cache.set(key, runtime);
860
+ this.activeRuntimes.push(runtime);
861
+ return runtime;
862
+ };
863
+ }
864
+ /**
865
+ * Run-identifying context threaded onto exported spans (Phoenix shows the same
866
+ * source/run identifiers as the local trace-source registry, so local artifact
867
+ * lookup stays possible). Resolved with the same source-id/label resolvers the
868
+ * trace source uses.
869
+ */
870
+ async observabilityContext() {
871
+ const input = { env: this.env, cwd: this.cwd, configPath: this.configPath };
872
+ const [sourceId, sourceLabel] = await Promise.all([
873
+ resolveAppTraceSourceId(input, this.traceDefaults),
874
+ resolveAppTraceSourceLabel(input, this.traceDefaults),
875
+ ]);
876
+ return { sourceId, sourceLabel, configPath: this.configPath };
877
+ }
878
+ memoryRecallRuntimeOptions(coreConfig) {
879
+ const settings = resolveMemoryRecallSettings(coreConfig);
880
+ if (settings === undefined) {
881
+ return undefined;
882
+ }
883
+ this.logger?.info?.("Read-only memory_recall tool enabled.", {
884
+ provider: "supermemory" in settings ? "supermemory" : settings.embeddings?.provider ?? "fts-only",
885
+ });
886
+ return createMemoryRecallRuntimeExtension(settings, this.cwd);
887
+ }
888
+ /**
889
+ * Optional CLOUD-ONLY escape hatch: when `memory.supermemory.exposeMcpServer` is on, ALSO inject
890
+ * Supermemory's hosted MCP server alongside the in-app `memory_recall` tool. The hosted MCP cannot
891
+ * point at a self-hosted instance, so self-hosters rely on the in-app recall tool; this just adds
892
+ * the cloud server's richer tools for cloud deployments. Requires an apiKey (skipped + warned if
893
+ * absent).
894
+ */
895
+ supermemoryMcpRuntimeOptions(coreConfig) {
896
+ const memory = coreConfig.memory;
897
+ if (memory?.backend !== "supermemory" || memory.supermemory?.exposeMcpServer !== true) {
898
+ return undefined;
899
+ }
900
+ const apiKey = memory.supermemory.apiKey;
901
+ if (apiKey === undefined) {
902
+ this.logger?.warn?.("memory.supermemory.exposeMcpServer is on but no apiKey is set; the hosted Supermemory MCP server (cloud-only) was not injected.");
903
+ return undefined;
904
+ }
905
+ this.logger?.info?.("Supermemory hosted MCP server injected (cloud-only).");
906
+ const entry = {
907
+ supermemory: {
908
+ type: "http",
909
+ url: "https://mcp.supermemory.ai/mcp",
910
+ headers: { Authorization: `Bearer ${apiKey}` },
911
+ },
912
+ };
913
+ return async () => ({ runtimeOptions: { mcpServers: entry }, cleanup: async () => { } });
914
+ }
915
+ async adapterSendToolsRuntimeOptions(coreConfig) {
916
+ const input = { env: this.env, cwd: this.cwd, configPath: this.configPath };
917
+ const settings = await resolveAdapterSendToolsSettings(input, {
918
+ allowedTools: coreConfig.tools.allowedTools,
919
+ disallowedTools: coreConfig.tools.disallowedTools,
920
+ logger: this.logger,
393
921
  });
922
+ if (settings === undefined) {
923
+ return undefined;
924
+ }
925
+ const toolNames = adapterSendToolNames(settings);
926
+ this.logger?.info?.("Adapter send tools enabled.", { tools: toolNames });
927
+ // Forward the posted-message index path so `slack_send_message` links each post
928
+ // back to the producing conversation (so a later in-thread reply resumes it).
929
+ const indexPath = resolvePostedMessageIndexPath(await resolveAppArtifactDir(input));
930
+ return createAdapterSendToolsRuntimeExtension(this.configPath, this.cwd, toolNames, indexPath);
394
931
  }
395
932
  /** Build the configured memory store once and share it across responders + the ritual scheduler. */
396
- memoryStore(coreConfig) {
397
- if (!this.sharedMemoryBuilt) {
933
+ async memoryStore(coreConfig) {
934
+ if (this.sharedMemoryBuilt) {
935
+ return this.sharedMemory;
936
+ }
937
+ if (this.sharedMemoryBuild !== undefined) {
938
+ return await this.sharedMemoryBuild;
939
+ }
940
+ this.sharedMemoryBuild = (async () => {
398
941
  const appLogger = this.logger;
399
942
  const logger = appLogger?.warn !== undefined
400
943
  ? { warn: (message) => { appLogger.warn?.(message); } }
401
944
  : undefined;
402
- this.sharedMemory = createConfiguredMemory(coreConfig, ...(logger !== undefined ? [{ logger }] : []));
945
+ // Thread the per-app observability context so the bujo memory LLM records
946
+ // capture and consolidation runs through the same JSONL + Phoenix pipeline
947
+ // as channel runs (gated by `memory.llm.trace`, default on). The context is
948
+ // per-app (not per-request), so caching it into the shared store is correct.
949
+ //
950
+ // The channel runtime is intentionally NOT passed: the memory LLM must run
951
+ // on `config.memory.llm.model`, but the channel runtime carries the channel
952
+ // fallback chain whose primary is `config.runtime.model` and the fallback
953
+ // router overrides each run's per-call model. createConfiguredMemory builds
954
+ // the memory LLM its own fallback-free runtime when no `memoryRuntime` is set.
955
+ const observabilityContext = await this.observabilityContext();
956
+ const observability = {
957
+ observabilityContext,
958
+ exporterWarn: (warning) => this.recordExporterWarning(warning),
959
+ runEventSink: this.liveEventBus,
960
+ };
961
+ this.sharedMemory = await createConfiguredMemory(coreConfig, {
962
+ ...(logger === undefined ? {} : { logger }),
963
+ observability,
964
+ });
403
965
  this.sharedMemoryBuilt = true;
966
+ return this.sharedMemory;
967
+ })();
968
+ try {
969
+ return await this.sharedMemoryBuild;
970
+ }
971
+ finally {
972
+ this.sharedMemoryBuild = undefined;
404
973
  }
405
- return this.sharedMemory;
406
974
  }
407
975
  /** Close + clear the shared memory store (on config reload or stop) so the next build is fresh. */
408
976
  async resetSharedMemory() {
409
977
  const mem = this.sharedMemory;
410
978
  this.sharedMemory = undefined;
411
979
  this.sharedMemoryBuilt = false;
980
+ this.sharedMemoryBuild = undefined;
412
981
  if (mem?.flush !== undefined) {
413
982
  await Promise.resolve(mem.flush()).catch(() => undefined);
414
983
  }
@@ -427,7 +996,8 @@ class MonoAgentAppController {
427
996
  }
428
997
  applyResult() {
429
998
  const transports = this.activeTransports();
430
- const statuses = [...this.statuses.values()];
999
+ const statusEntries = [...this.statuses.entries()];
1000
+ const statuses = statusEntries.map(([, status]) => status);
431
1001
  const failedChannel = statuses.find((status) => status.kind === "failed");
432
1002
  const failure = failedChannel?.kind === "failed"
433
1003
  ? failedChannel.reason
@@ -441,8 +1011,10 @@ class MonoAgentAppController {
441
1011
  transports,
442
1012
  };
443
1013
  }
444
- const hasRunningChannel = statuses.some((status) => status.kind === "running");
445
- if (!hasRunningChannel && statuses.some((status) => status.kind === "waiting_for_config")) {
1014
+ // A degraded channel is still serving (transport self-recovering, harness alive),
1015
+ // so it counts as running for the "is anything live?" check.
1016
+ const hasServingChannel = statusEntries.some(([id, status]) => id !== "live" && (status.kind === "running" || status.kind === "degraded"));
1017
+ if (!hasServingChannel && statuses.some((status) => status.kind === "waiting_for_config")) {
446
1018
  return {
447
1019
  kind: "waiting_for_config",
448
1020
  message: "Saved config, but no agent channel is running yet.",
@@ -457,32 +1029,77 @@ class MonoAgentAppController {
457
1029
  }
458
1030
  async stopTraceSource(reason) {
459
1031
  const traceSource = this.traceSource;
460
- if (traceSource === undefined) {
1032
+ const globalTraceSource = this.globalTraceSource;
1033
+ if (traceSource === undefined && globalTraceSource === undefined) {
461
1034
  return;
462
1035
  }
463
1036
  this.traceSource = undefined;
464
- await traceSource.stop({
465
- metadata: this.traceMetadata(reason),
466
- transports: this.activeTransports(),
467
- }).catch((error) => {
1037
+ this.globalTraceSource = undefined;
1038
+ const patch = { metadata: this.traceMetadata(reason), transports: this.activeTransports() };
1039
+ await traceSource?.stop(patch).catch((error) => {
468
1040
  this.logger?.warn?.("Traceability source stop update failed.", { reason: reasonOf(error) });
469
1041
  });
1042
+ await globalTraceSource?.stop(patch).catch((error) => {
1043
+ this.logger?.warn?.("Global trace-source mirror stop update failed.", { reason: reasonOf(error) });
1044
+ });
470
1045
  if (!this.stopped) {
471
1046
  this.traceabilityStatusValue = { kind: "disabled", reason: "Traceability source stopped while applying config." };
472
1047
  }
473
1048
  }
474
1049
  activeTransports() {
475
1050
  const transports = [];
476
- if (this.consoleServer !== undefined) {
477
- transports.push("operator-console");
478
- }
479
1051
  for (const driver of this.drivers) {
480
- if (this.statuses.get(driver.id)?.kind === "running") {
1052
+ const kind = this.statuses.get(driver.id)?.kind;
1053
+ // A degraded channel is still an active transport (self-recovering, serving).
1054
+ if (kind === "running" || kind === "degraded") {
481
1055
  transports.push(driver.id);
482
1056
  }
483
1057
  }
484
1058
  return transports;
485
1059
  }
1060
+ async refreshSelectedSkillsSnapshot(reason) {
1061
+ try {
1062
+ const input = { env: this.env, cwd: this.cwd, configPath: this.configPath };
1063
+ this.rememberSelectedSkills(await loadAppCoreConfig(input));
1064
+ }
1065
+ catch (error) {
1066
+ this.selectedSkillsValue = undefined;
1067
+ this.logger?.info?.("Selected skills unavailable until agent config loads.", { reason, detail: reasonOf(error) });
1068
+ }
1069
+ }
1070
+ rememberSelectedSkills(coreConfig) {
1071
+ this.selectedSkillsValue = [...coreConfig.context.selectedSkills];
1072
+ }
1073
+ recordSessionEvent(event, coreConfig) {
1074
+ const now = new Date();
1075
+ const nextRolloverAt = coreConfig.runtime.session.rollover === "daily"
1076
+ ? nextDailyRolloverAt(now, coreConfig.runtime.session.rolloverTimezone)
1077
+ : undefined;
1078
+ const snapshot = event.snapshot ?? [];
1079
+ const current = snapshot.find((entry) => entry.conversationId === event.conversationId);
1080
+ const providerSessionId = current?.providerSessionId ?? event.providerSessionId;
1081
+ this.sessionMetadataValue = {
1082
+ currentBucketId: event.conversationId,
1083
+ state: current === undefined ? "cold" : "warm",
1084
+ event: event.kind,
1085
+ updatedAt: now.toISOString(),
1086
+ ...(snapshot.length === 0 ? {} : { snapshot }),
1087
+ ...(providerSessionId === undefined ? {} : { providerSessionId }),
1088
+ ...(current?.createdAt === undefined ? {} : { createdAt: current.createdAt }),
1089
+ ...(current?.lastActivityAt === undefined ? {} : { lastActivityAt: current.lastActivityAt }),
1090
+ ...(current?.busy === undefined ? {} : { busy: current.busy }),
1091
+ ...(event.reason === undefined ? {} : { reason: event.reason }),
1092
+ ...(nextRolloverAt === undefined ? {} : { nextRolloverAt }),
1093
+ };
1094
+ if (event.kind === "evicted") {
1095
+ this.logger?.info?.("Provider session evicted.", {
1096
+ conversationId: event.conversationId,
1097
+ providerSessionId: event.providerSessionId,
1098
+ reason: event.reason,
1099
+ });
1100
+ }
1101
+ void this.refreshTraceSource(`session-${event.kind}`).catch(() => undefined);
1102
+ }
486
1103
  traceMetadata(reason) {
487
1104
  const channels = {};
488
1105
  for (const driver of this.drivers) {
@@ -496,29 +1113,182 @@ class MonoAgentAppController {
496
1113
  }
497
1114
  return {
498
1115
  reason,
499
- ...(this.operatorConsole === undefined
1116
+ ...(this.exporterStatusValue.kind === "configured"
1117
+ ? {
1118
+ observability: {
1119
+ // Persist only the endpoint + warning/error strings (never headers
1120
+ // or secrets) so the detached `status` reader can surface exporter
1121
+ // state. JSONL artifacts always remain local.
1122
+ endpoint: this.exporterStatusValue.endpoint,
1123
+ includeSensitiveData: this.exporterStatusValue.includeSensitiveData,
1124
+ jsonlArtifactsLocal: true,
1125
+ ...(this.exporterStatusValue.lastWarning === undefined
1126
+ ? {}
1127
+ : { lastWarning: this.exporterStatusValue.lastWarning }),
1128
+ ...(this.exporterStatusValue.lastError === undefined
1129
+ ? {}
1130
+ : { lastError: this.exporterStatusValue.lastError }),
1131
+ },
1132
+ }
1133
+ : {}),
1134
+ sandbox: {
1135
+ configured: this.sandboxStatusValue.configured,
1136
+ configuredMode: this.sandboxStatusValue.configuredMode,
1137
+ effective: this.sandboxStatusValue.effective,
1138
+ engine: this.sandboxStatusValue.engine,
1139
+ engineAvailable: this.sandboxStatusValue.engineAvailable,
1140
+ fallback: this.sandboxStatusValue.fallback,
1141
+ fallbackActive: this.sandboxStatusValue.fallbackActive,
1142
+ unsafeAllowHostProcess: this.sandboxStatusValue.unsafeAllowHostProcess,
1143
+ detail: this.sandboxStatusValue.detail,
1144
+ ...(this.sandboxStatusValue.warning === undefined ? {} : { warning: this.sandboxStatusValue.warning }),
1145
+ ...(this.sandboxStatusValue.resolutionError === undefined
1146
+ ? {}
1147
+ : { resolutionError: this.sandboxStatusValue.resolutionError }),
1148
+ },
1149
+ ...(this.selectedSkillsValue === undefined
500
1150
  ? {}
501
1151
  : {
502
- operatorConsole: {
503
- // Only the base loopback URL is persisted — never the per-boot
504
- // access token. A detached `status`/`start` reader surfaces this
505
- // URL and points at the logs for the tokenized link.
506
- url: this.operatorConsole.url,
507
- configPath: this.operatorConsole.configPath,
1152
+ context: {
1153
+ selectedSkills: [...this.selectedSkillsValue],
508
1154
  },
509
1155
  }),
1156
+ ...(this.sessionMetadataValue === undefined ? {} : { session: this.sessionMetadataValue }),
510
1157
  channels,
511
1158
  };
512
1159
  }
513
1160
  }
514
- function logOperatorConsoleEvent(logger, event) {
515
- if (event.kind === "validation_failed" || event.kind === "unauthorized") {
516
- logger?.warn?.("Operator Console event.", { event });
517
- return;
1161
+ function nextDailyRolloverAt(now, timezone) {
1162
+ if (timezone === undefined || timezone.trim().length === 0) {
1163
+ const next = new Date(now);
1164
+ next.setHours(24, 0, 0, 0);
1165
+ return next.toISOString();
518
1166
  }
519
- logger?.debug?.("Operator Console event.", { event });
1167
+ try {
1168
+ const parts = datePartsInTimeZone(now, timezone);
1169
+ return new Date(zonedDateTimeToUtcMs({
1170
+ year: parts.year,
1171
+ month: parts.month,
1172
+ day: parts.day + 1,
1173
+ hour: 0,
1174
+ minute: 0,
1175
+ second: 0,
1176
+ }, timezone)).toISOString();
1177
+ }
1178
+ catch {
1179
+ const next = new Date(now);
1180
+ next.setHours(24, 0, 0, 0);
1181
+ return next.toISOString();
1182
+ }
1183
+ }
1184
+ function zonedDateTimeToUtcMs(parts, timezone) {
1185
+ const targetAsUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
1186
+ let utcMs = targetAsUtc - timeZoneOffsetMs(new Date(targetAsUtc), timezone);
1187
+ utcMs = targetAsUtc - timeZoneOffsetMs(new Date(utcMs), timezone);
1188
+ return utcMs;
1189
+ }
1190
+ function timeZoneOffsetMs(date, timezone) {
1191
+ const parts = datePartsInTimeZone(date, timezone);
1192
+ const asUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
1193
+ return asUtc - date.getTime();
1194
+ }
1195
+ function datePartsInTimeZone(date, timezone) {
1196
+ const formatter = new Intl.DateTimeFormat("en-CA", {
1197
+ timeZone: timezone,
1198
+ year: "numeric",
1199
+ month: "2-digit",
1200
+ day: "2-digit",
1201
+ hour: "2-digit",
1202
+ minute: "2-digit",
1203
+ second: "2-digit",
1204
+ hourCycle: "h23",
1205
+ });
1206
+ const entries = new Map(formatter.formatToParts(date).map((part) => [part.type, part.value]));
1207
+ return {
1208
+ year: Number(entries.get("year")),
1209
+ month: Number(entries.get("month")),
1210
+ day: Number(entries.get("day")),
1211
+ hour: Number(entries.get("hour")),
1212
+ minute: Number(entries.get("minute")),
1213
+ second: Number(entries.get("second")),
1214
+ };
1215
+ }
1216
+ function sandboxStatusFromState(state) {
1217
+ const warning = sandboxEffectiveStateWarning(state);
1218
+ return {
1219
+ ...state,
1220
+ detail: describeSandboxEffectiveState(state),
1221
+ ...(warning === undefined ? {} : { warning }),
1222
+ };
520
1223
  }
521
1224
  function reasonOf(error) {
522
1225
  return error instanceof Error ? error.message : String(error);
523
1226
  }
1227
+ /**
1228
+ * Compose several per-request runtime-options extensions into one. Each extension is invoked per
1229
+ * request; mergeable runtime option maps/lists are unioned and their cleanups are chained. Returns
1230
+ * `undefined` when no extension is active, so the host omits `runtimeOptionsForRequest` entirely.
1231
+ */
1232
+ function composeRuntimeOptionExtensions(extensions) {
1233
+ const active = extensions.filter((extension) => extension !== undefined);
1234
+ if (active.length === 0) {
1235
+ return undefined;
1236
+ }
1237
+ if (active.length === 1) {
1238
+ return active[0];
1239
+ }
1240
+ return async (input) => {
1241
+ const results = await Promise.all(active.map((extension) => extension(input)));
1242
+ const runtimeOptions = {};
1243
+ for (const result of results) {
1244
+ mergeRuntimeOptions(runtimeOptions, result.runtimeOptions);
1245
+ }
1246
+ return {
1247
+ runtimeOptions,
1248
+ cleanup: async () => {
1249
+ // Chain every cleanup; run them all even if one rejects so no extension leaks resources.
1250
+ await Promise.all(results.map(async (result) => result.cleanup?.()));
1251
+ },
1252
+ };
1253
+ };
1254
+ }
1255
+ function mergeRuntimeOptions(target, next) {
1256
+ if (next === undefined) {
1257
+ return;
1258
+ }
1259
+ for (const [key, value] of Object.entries(next)) {
1260
+ if (value === undefined) {
1261
+ continue;
1262
+ }
1263
+ if (key === "allowedTools" || key === "disallowedTools") {
1264
+ target[key] = mergeStringLists(target[key], value);
1265
+ continue;
1266
+ }
1267
+ if (key === "mcpServers") {
1268
+ target[key] = {
1269
+ ...(isRecord(target[key]) ? target[key] : {}),
1270
+ ...(isRecord(value) ? value : {}),
1271
+ };
1272
+ continue;
1273
+ }
1274
+ target[key] = value;
1275
+ }
1276
+ }
1277
+ function mergeStringLists(current, next) {
1278
+ const out = [];
1279
+ for (const list of [current, next]) {
1280
+ if (!Array.isArray(list)) {
1281
+ continue;
1282
+ }
1283
+ for (const item of list) {
1284
+ if (typeof item === "string" && !out.includes(item)) {
1285
+ out.push(item);
1286
+ }
1287
+ }
1288
+ }
1289
+ return out;
1290
+ }
1291
+ function isRecord(value) {
1292
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1293
+ }
524
1294
  //# sourceMappingURL=app.js.map