@mono-agent/agent-app 0.6.2 → 0.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 (313) hide show
  1. package/README.md +259 -19
  2. package/dist/account-home.d.ts +6 -0
  3. package/dist/account-home.d.ts.map +1 -0
  4. package/dist/account-home.js +9 -0
  5. package/dist/account-home.js.map +1 -0
  6. package/dist/adapter-send-proxy.d.ts +18 -0
  7. package/dist/adapter-send-proxy.d.ts.map +1 -0
  8. package/dist/adapter-send-proxy.js +229 -0
  9. package/dist/adapter-send-proxy.js.map +1 -0
  10. package/dist/adapter-send-tools-main.js +62 -4
  11. package/dist/adapter-send-tools-main.js.map +1 -1
  12. package/dist/adapter-send-tools.d.ts +33 -6
  13. package/dist/adapter-send-tools.d.ts.map +1 -1
  14. package/dist/adapter-send-tools.js +485 -87
  15. package/dist/adapter-send-tools.js.map +1 -1
  16. package/dist/app-config.d.ts +3 -1
  17. package/dist/app-config.d.ts.map +1 -1
  18. package/dist/app-config.js +16 -5
  19. package/dist/app-config.js.map +1 -1
  20. package/dist/app.d.ts +27 -0
  21. package/dist/app.d.ts.map +1 -1
  22. package/dist/app.js +761 -147
  23. package/dist/app.js.map +1 -1
  24. package/dist/background-environment.d.ts +16 -0
  25. package/dist/background-environment.d.ts.map +1 -0
  26. package/dist/background-environment.js +68 -0
  27. package/dist/background-environment.js.map +1 -0
  28. package/dist/background-runtime.d.ts +62 -0
  29. package/dist/background-runtime.d.ts.map +1 -0
  30. package/dist/background-runtime.js +1115 -0
  31. package/dist/background-runtime.js.map +1 -0
  32. package/dist/background-snapshot-key.d.ts +13 -0
  33. package/dist/background-snapshot-key.d.ts.map +1 -0
  34. package/dist/background-snapshot-key.js +194 -0
  35. package/dist/background-snapshot-key.js.map +1 -0
  36. package/dist/background-snapshot.d.ts +96 -0
  37. package/dist/background-snapshot.d.ts.map +1 -0
  38. package/dist/background-snapshot.js +450 -0
  39. package/dist/background-snapshot.js.map +1 -0
  40. package/dist/background-worker-lease.d.ts +39 -0
  41. package/dist/background-worker-lease.d.ts.map +1 -0
  42. package/dist/background-worker-lease.js +356 -0
  43. package/dist/background-worker-lease.js.map +1 -0
  44. package/dist/background.d.ts +74 -2
  45. package/dist/background.d.ts.map +1 -1
  46. package/dist/background.js +815 -73
  47. package/dist/background.js.map +1 -1
  48. package/dist/broadcast-recorder.d.ts.map +1 -1
  49. package/dist/broadcast-recorder.js +24 -2
  50. package/dist/broadcast-recorder.js.map +1 -1
  51. package/dist/channel-plugins.d.ts +2 -0
  52. package/dist/channel-plugins.d.ts.map +1 -1
  53. package/dist/channel-plugins.js +8 -0
  54. package/dist/channel-plugins.js.map +1 -1
  55. package/dist/channels.d.ts +10 -0
  56. package/dist/channels.d.ts.map +1 -1
  57. package/dist/channels.js +87 -9
  58. package/dist/channels.js.map +1 -1
  59. package/dist/cli.d.ts +86 -4
  60. package/dist/cli.d.ts.map +1 -1
  61. package/dist/cli.js +2421 -143
  62. package/dist/cli.js.map +1 -1
  63. package/dist/config-reference.d.ts.map +1 -1
  64. package/dist/config-reference.js +448 -6
  65. package/dist/config-reference.js.map +1 -1
  66. package/dist/configuration-proposal-main.d.ts +3 -0
  67. package/dist/configuration-proposal-main.d.ts.map +1 -0
  68. package/dist/configuration-proposal-main.js +13 -0
  69. package/dist/configuration-proposal-main.js.map +1 -0
  70. package/dist/configuration-proposal-tool.d.ts +33 -0
  71. package/dist/configuration-proposal-tool.d.ts.map +1 -0
  72. package/dist/configuration-proposal-tool.js +188 -0
  73. package/dist/configuration-proposal-tool.js.map +1 -0
  74. package/dist/configured-agent.d.ts +16 -0
  75. package/dist/configured-agent.d.ts.map +1 -1
  76. package/dist/configured-agent.js +165 -18
  77. package/dist/configured-agent.js.map +1 -1
  78. package/dist/consumer-contract.d.ts +3 -0
  79. package/dist/consumer-contract.d.ts.map +1 -1
  80. package/dist/consumer-contract.js +44 -1
  81. package/dist/consumer-contract.js.map +1 -1
  82. package/dist/continuation-command.d.ts +15 -0
  83. package/dist/continuation-command.d.ts.map +1 -0
  84. package/dist/continuation-command.js +170 -0
  85. package/dist/continuation-command.js.map +1 -0
  86. package/dist/continuation-config.d.ts +25 -0
  87. package/dist/continuation-config.d.ts.map +1 -0
  88. package/dist/continuation-config.js +236 -0
  89. package/dist/continuation-config.js.map +1 -0
  90. package/dist/continuation-service.d.ts +99 -0
  91. package/dist/continuation-service.d.ts.map +1 -0
  92. package/dist/continuation-service.js +1313 -0
  93. package/dist/continuation-service.js.map +1 -0
  94. package/dist/continuation-store.d.ts +104 -0
  95. package/dist/continuation-store.d.ts.map +1 -0
  96. package/dist/continuation-store.js +688 -0
  97. package/dist/continuation-store.js.map +1 -0
  98. package/dist/continuations.d.ts +167 -0
  99. package/dist/continuations.d.ts.map +1 -0
  100. package/dist/continuations.js +71 -0
  101. package/dist/continuations.js.map +1 -0
  102. package/dist/doctor.d.ts +29 -5
  103. package/dist/doctor.d.ts.map +1 -1
  104. package/dist/doctor.js +1611 -116
  105. package/dist/doctor.js.map +1 -1
  106. package/dist/first-run-managed-memory.d.ts +39 -0
  107. package/dist/first-run-managed-memory.d.ts.map +1 -0
  108. package/dist/first-run-managed-memory.js +584 -0
  109. package/dist/first-run-managed-memory.js.map +1 -0
  110. package/dist/first-run-readiness.d.ts +111 -0
  111. package/dist/first-run-readiness.d.ts.map +1 -0
  112. package/dist/first-run-readiness.js +728 -0
  113. package/dist/first-run-readiness.js.map +1 -0
  114. package/dist/index.d.ts +17 -3
  115. package/dist/index.d.ts.map +1 -1
  116. package/dist/index.js +8 -1
  117. package/dist/index.js.map +1 -1
  118. package/dist/init.d.ts +95 -3
  119. package/dist/init.d.ts.map +1 -1
  120. package/dist/init.js +1218 -24
  121. package/dist/init.js.map +1 -1
  122. package/dist/interaction-bridge.d.ts +25 -1
  123. package/dist/interaction-bridge.d.ts.map +1 -1
  124. package/dist/interaction-bridge.js +281 -19
  125. package/dist/interaction-bridge.js.map +1 -1
  126. package/dist/launchd.d.ts +16 -6
  127. package/dist/launchd.d.ts.map +1 -1
  128. package/dist/launchd.js +30 -15
  129. package/dist/launchd.js.map +1 -1
  130. package/dist/local-configuration.d.ts +99 -0
  131. package/dist/local-configuration.d.ts.map +1 -0
  132. package/dist/local-configuration.js +1299 -0
  133. package/dist/local-configuration.js.map +1 -0
  134. package/dist/managed-runtime-packages.d.ts +9 -0
  135. package/dist/managed-runtime-packages.d.ts.map +1 -0
  136. package/dist/managed-runtime-packages.js +56 -0
  137. package/dist/managed-runtime-packages.js.map +1 -0
  138. package/dist/memory-command.d.ts +4 -0
  139. package/dist/memory-command.d.ts.map +1 -1
  140. package/dist/memory-command.js +751 -27
  141. package/dist/memory-command.js.map +1 -1
  142. package/dist/memory-embedding-service.d.ts +33 -0
  143. package/dist/memory-embedding-service.d.ts.map +1 -0
  144. package/dist/memory-embedding-service.js +207 -0
  145. package/dist/memory-embedding-service.js.map +1 -0
  146. package/dist/memory-recall.d.ts +43 -14
  147. package/dist/memory-recall.d.ts.map +1 -1
  148. package/dist/memory-recall.js +103 -33
  149. package/dist/memory-recall.js.map +1 -1
  150. package/dist/memory-retrieval.d.ts +66 -0
  151. package/dist/memory-retrieval.d.ts.map +1 -0
  152. package/dist/memory-retrieval.js +332 -0
  153. package/dist/memory-retrieval.js.map +1 -0
  154. package/dist/modules/base.d.ts +6 -5
  155. package/dist/modules/base.d.ts.map +1 -1
  156. package/dist/modules/base.js +11 -7
  157. package/dist/modules/base.js.map +1 -1
  158. package/dist/modules/catalog.d.ts.map +1 -1
  159. package/dist/modules/catalog.js +141 -25
  160. package/dist/modules/catalog.js.map +1 -1
  161. package/dist/modules/index.d.ts +2 -2
  162. package/dist/modules/index.d.ts.map +1 -1
  163. package/dist/modules/index.js +1 -1
  164. package/dist/modules/index.js.map +1 -1
  165. package/dist/modules/known-tools.d.ts +3 -0
  166. package/dist/modules/known-tools.d.ts.map +1 -1
  167. package/dist/modules/known-tools.js +11 -1
  168. package/dist/modules/known-tools.js.map +1 -1
  169. package/dist/modules/types.d.ts +20 -6
  170. package/dist/modules/types.d.ts.map +1 -1
  171. package/dist/modules/types.js.map +1 -1
  172. package/dist/notify-runtime.d.ts +26 -0
  173. package/dist/notify-runtime.d.ts.map +1 -0
  174. package/dist/notify-runtime.js +27 -0
  175. package/dist/notify-runtime.js.map +1 -0
  176. package/dist/notify-tool.d.ts +51 -0
  177. package/dist/notify-tool.d.ts.map +1 -0
  178. package/dist/notify-tool.js +182 -0
  179. package/dist/notify-tool.js.map +1 -0
  180. package/dist/package-version.d.ts +3 -0
  181. package/dist/package-version.d.ts.map +1 -0
  182. package/dist/package-version.js +14 -0
  183. package/dist/package-version.js.map +1 -0
  184. package/dist/pi-auth-store-inspection.d.ts +21 -0
  185. package/dist/pi-auth-store-inspection.d.ts.map +1 -0
  186. package/dist/pi-auth-store-inspection.js +120 -0
  187. package/dist/pi-auth-store-inspection.js.map +1 -0
  188. package/dist/pi-oauth-login-main.d.ts +3 -0
  189. package/dist/pi-oauth-login-main.d.ts.map +1 -0
  190. package/dist/pi-oauth-login-main.js +14 -0
  191. package/dist/pi-oauth-login-main.js.map +1 -0
  192. package/dist/pi-oauth-login.d.ts +22 -0
  193. package/dist/pi-oauth-login.d.ts.map +1 -0
  194. package/dist/pi-oauth-login.js +79 -0
  195. package/dist/pi-oauth-login.js.map +1 -0
  196. package/dist/proactive-notify.d.ts +2 -0
  197. package/dist/proactive-notify.d.ts.map +1 -1
  198. package/dist/proactive-notify.js +1 -0
  199. package/dist/proactive-notify.js.map +1 -1
  200. package/dist/process-incarnation.d.ts +23 -0
  201. package/dist/process-incarnation.d.ts.map +1 -0
  202. package/dist/process-incarnation.js +208 -0
  203. package/dist/process-incarnation.js.map +1 -0
  204. package/dist/project-skills.d.ts +40 -0
  205. package/dist/project-skills.d.ts.map +1 -0
  206. package/dist/project-skills.js +573 -0
  207. package/dist/project-skills.js.map +1 -0
  208. package/dist/provider-setup.d.ts +117 -2
  209. package/dist/provider-setup.d.ts.map +1 -1
  210. package/dist/provider-setup.js +1505 -49
  211. package/dist/provider-setup.js.map +1 -1
  212. package/dist/readiness-probe-worker.d.ts +12 -0
  213. package/dist/readiness-probe-worker.d.ts.map +1 -0
  214. package/dist/readiness-probe-worker.js +276 -0
  215. package/dist/readiness-probe-worker.js.map +1 -0
  216. package/dist/readiness-probe.d.ts +100 -0
  217. package/dist/readiness-probe.d.ts.map +1 -0
  218. package/dist/readiness-probe.js +780 -0
  219. package/dist/readiness-probe.js.map +1 -0
  220. package/dist/recipes/base.d.ts +15 -0
  221. package/dist/recipes/base.d.ts.map +1 -0
  222. package/dist/recipes/base.js +51 -0
  223. package/dist/recipes/base.js.map +1 -0
  224. package/dist/recipes/catalog.d.ts +4 -0
  225. package/dist/recipes/catalog.d.ts.map +1 -0
  226. package/dist/recipes/catalog.js +525 -0
  227. package/dist/recipes/catalog.js.map +1 -0
  228. package/dist/recipes/index.d.ts +11 -0
  229. package/dist/recipes/index.d.ts.map +1 -0
  230. package/dist/recipes/index.js +14 -0
  231. package/dist/recipes/index.js.map +1 -0
  232. package/dist/recipes/types.d.ts +70 -0
  233. package/dist/recipes/types.d.ts.map +1 -0
  234. package/dist/recipes/types.js +15 -0
  235. package/dist/recipes/types.js.map +1 -0
  236. package/dist/request-model-override.d.ts +43 -1
  237. package/dist/request-model-override.d.ts.map +1 -1
  238. package/dist/request-model-override.js +178 -19
  239. package/dist/request-model-override.js.map +1 -1
  240. package/dist/run-history.d.ts +32 -0
  241. package/dist/run-history.d.ts.map +1 -0
  242. package/dist/run-history.js +859 -0
  243. package/dist/run-history.js.map +1 -0
  244. package/dist/runs-health.d.ts.map +1 -1
  245. package/dist/runs-health.js +12 -4
  246. package/dist/runs-health.js.map +1 -1
  247. package/dist/runtime-option-extensions.d.ts +14 -0
  248. package/dist/runtime-option-extensions.d.ts.map +1 -0
  249. package/dist/runtime-option-extensions.js +98 -0
  250. package/dist/runtime-option-extensions.js.map +1 -0
  251. package/dist/runtime-routes.d.ts +9 -0
  252. package/dist/runtime-routes.d.ts.map +1 -0
  253. package/dist/runtime-routes.js +13 -0
  254. package/dist/runtime-routes.js.map +1 -0
  255. package/dist/sandbox-manager.d.ts +86 -0
  256. package/dist/sandbox-manager.d.ts.map +1 -0
  257. package/dist/sandbox-manager.js +1402 -0
  258. package/dist/sandbox-manager.js.map +1 -0
  259. package/dist/setup.d.ts +29 -0
  260. package/dist/setup.d.ts.map +1 -0
  261. package/dist/setup.js +97 -0
  262. package/dist/setup.js.map +1 -0
  263. package/dist/supermemory-plugin.d.ts +56 -0
  264. package/dist/supermemory-plugin.d.ts.map +1 -0
  265. package/dist/supermemory-plugin.js +142 -0
  266. package/dist/supermemory-plugin.js.map +1 -0
  267. package/dist/tui-command.d.ts +56 -0
  268. package/dist/tui-command.d.ts.map +1 -1
  269. package/dist/tui-command.js +305 -31
  270. package/dist/tui-command.js.map +1 -1
  271. package/dist/web-command.d.ts +8 -0
  272. package/dist/web-command.d.ts.map +1 -1
  273. package/dist/web-command.js +174 -17
  274. package/dist/web-command.js.map +1 -1
  275. package/dist/wizard/answers.d.ts +31 -4
  276. package/dist/wizard/answers.d.ts.map +1 -1
  277. package/dist/wizard/answers.js +141 -29
  278. package/dist/wizard/answers.js.map +1 -1
  279. package/dist/wizard/from-flags.d.ts +7 -0
  280. package/dist/wizard/from-flags.d.ts.map +1 -1
  281. package/dist/wizard/from-flags.js +36 -2
  282. package/dist/wizard/from-flags.js.map +1 -1
  283. package/dist/wizard/index.d.ts +4 -4
  284. package/dist/wizard/index.d.ts.map +1 -1
  285. package/dist/wizard/index.js +2 -2
  286. package/dist/wizard/index.js.map +1 -1
  287. package/dist/wizard/model-discovery.d.ts +33 -2
  288. package/dist/wizard/model-discovery.d.ts.map +1 -1
  289. package/dist/wizard/model-discovery.js +826 -108
  290. package/dist/wizard/model-discovery.js.map +1 -1
  291. package/dist/wizard/presets.d.ts +1 -1
  292. package/dist/wizard/presets.d.ts.map +1 -1
  293. package/dist/wizard/presets.js +7 -12
  294. package/dist/wizard/presets.js.map +1 -1
  295. package/dist/wizard/prompts.d.ts +56 -9
  296. package/dist/wizard/prompts.d.ts.map +1 -1
  297. package/dist/wizard/prompts.js +172 -20
  298. package/dist/wizard/prompts.js.map +1 -1
  299. package/dist/wizard/run.d.ts +59 -3
  300. package/dist/wizard/run.d.ts.map +1 -1
  301. package/dist/wizard/run.js +1290 -191
  302. package/dist/wizard/run.js.map +1 -1
  303. package/package.json +21 -16
  304. package/resources/srt/package-lock.json +66 -0
  305. package/resources/srt/package.json +9 -0
  306. package/schema/mono-agent.config.schema.json +587 -20
  307. package/skills/mono-agent-composer/SKILL.md +6 -6
  308. package/skills/mono-agent-composer/references/config-blueprint.md +11 -10
  309. package/skills/mono-agent-composer/references/discovery-questions.md +33 -18
  310. package/skills/mono-agent-composer/references/feature-coverage.md +14 -12
  311. package/skills/mono-agent-composer/references/package-map.md +2 -2
  312. package/skills/mono-agent-composer/references/playbooks.md +8 -7
  313. package/skills/mono-agent-composer/references/validation.md +3 -3
@@ -1,21 +1,42 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
- import { resolve } from "node:path";
2
+ import { randomUUID } from "node:crypto";
3
+ import { constants as fsConstants } from "node:fs";
4
+ import { lstat, mkdir, open, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
5
+ import { basename, dirname, join, resolve } from "node:path";
4
6
  import process from "node:process";
5
7
  import { listRecordedRuns, listTraceSources } from "@mono-agent/observability";
6
8
  import { describeSensitiveDataExportWarning, phoenixAppBaseUrl, resolveAppTraceRegistryDir, resolveAppTraceStaleAfterMs, } from "./app-config.js";
7
9
  import { formatChannelFactValue } from "./channel-fact-format.js";
8
- import { bootout, bootstrap, buildPlistXml, defaultPathEnv, deriveLaunchdLabel, isLoaded, kickstartRestart, launchdPathsFor, makeLaunchctlRunner, } from "./launchd.js";
10
+ import { bootout, bootstrap, buildPlistXml, defaultPathEnv, deriveLaunchdLabel, isLoaded, launchdServiceInfo, launchdPathsFor, makeLaunchctlRunner, } from "./launchd.js";
11
+ import { selectBackgroundOperationalEnvironment } from "./background-environment.js";
12
+ import { ensureManagedBackgroundRuntime, MANAGED_BACKGROUND_WORKER_ENV, } from "./background-runtime.js";
13
+ import { backgroundSnapshotFromMetadata, captureBackgroundSnapshot, encodeBackgroundSnapshot, sameBackgroundSnapshot, } from "./background-snapshot.js";
14
+ import { resolveConfiguredManagedRuntimePackages } from "./managed-runtime-packages.js";
15
+ import { currentProcessIncarnation, isSameProcessIncarnation as matchesProcessIncarnation, processIncarnationFromJson, } from "./process-incarnation.js";
9
16
  import { buildRunsHealthDisplay, RUNS_HEALTH_MAX_RUNS } from "./runs-health.js";
10
17
  import * as ui from "./ui.js";
18
+ export { acquireBackgroundWorkerLease, backgroundWorkerLeasePath, } from "./background-worker-lease.js";
19
+ /** Exact non-secret environment materialised into a managed LaunchAgent. */
20
+ export function managedBackgroundEnvironment(env) {
21
+ return {
22
+ ...selectBackgroundOperationalEnvironment(env),
23
+ PATH: defaultPathEnv({ ...env }),
24
+ // This is a lifecycle marker, not a config override. It tells cli.ts to
25
+ // discard launchd's ambient environment before loading the chosen dotenv.
26
+ [MANAGED_BACKGROUND_WORKER_ENV]: "1",
27
+ };
28
+ }
11
29
  /**
12
30
  * Resolve everything the control commands need from CLI args. The registry dir
13
31
  * and config path are resolved exactly as the worker will resolve them, so the
14
32
  * detached launcher can find the worker's manifest without any IPC.
15
33
  */
16
34
  export async function resolveInstanceTarget(input) {
17
- const cwd = resolve(input.cwd);
18
- const configPath = resolve(cwd, input.args.configPath ?? "mono-agent.config.json");
35
+ const lexicalCwd = resolve(input.cwd);
36
+ const [cwd, configPath] = await Promise.all([
37
+ realpath(lexicalCwd),
38
+ canonicalBackgroundConfigPath(lexicalCwd, input.args.configPath),
39
+ ]);
19
40
  const configInput = { env: input.env, cwd, configPath };
20
41
  const [registryDir, staleAfterMs] = await Promise.all([
21
42
  resolveAppTraceRegistryDir(configInput),
@@ -34,9 +55,41 @@ export async function resolveInstanceTarget(input) {
34
55
  // Bake an explicit --env-file (resolved absolute) into the plist so the
35
56
  // launchd worker loads the same env file the launcher did.
36
57
  ...(input.args.envFile === undefined ? {} : { envFile: resolve(cwd, input.args.envFile) }),
37
- pathEnv: defaultPathEnv(input.env),
58
+ configurationEnvironment: { ...input.env },
59
+ environment: managedBackgroundEnvironment(input.env),
60
+ ...(input.requireTui === true ? { requireTui: true } : {}),
38
61
  };
39
62
  }
63
+ /**
64
+ * Collapse symlinked parent aliases without following the config's final path
65
+ * component. The final component is separately required to be a regular,
66
+ * non-symlink file before start; keeping it unresolved preserves that check.
67
+ * Missing parents remain addressable so stop/status/logs can still operate on
68
+ * a previously installed label after an agent folder is damaged or removed.
69
+ */
70
+ export async function canonicalBackgroundConfigPath(cwd, configuredPath) {
71
+ const lexical = resolve(cwd, configuredPath ?? "mono-agent.config.json");
72
+ try {
73
+ const candidate = join(await realpath(dirname(lexical)), basename(lexical));
74
+ try {
75
+ const details = await lstat(candidate);
76
+ // Preserve the final-component symlink so the start-time regular-file
77
+ // check can reject it. For a real file, realpath also canonicalises the
78
+ // stored filename casing on the default case-insensitive macOS volume.
79
+ return details.isSymbolicLink() ? candidate : await realpath(candidate);
80
+ }
81
+ catch (error) {
82
+ if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR"))
83
+ return candidate;
84
+ throw error;
85
+ }
86
+ }
87
+ catch (error) {
88
+ if (isErrno(error, "ENOENT") || isErrno(error, "ENOTDIR"))
89
+ return lexical;
90
+ throw error;
91
+ }
92
+ }
40
93
  export function defaultBackgroundDeps() {
41
94
  return {
42
95
  runner: makeLaunchctlRunner(),
@@ -45,10 +98,10 @@ export function defaultBackgroundDeps() {
45
98
  sleep: (ms) => new Promise((resolvePromise) => setTimeout(resolvePromise, ms)),
46
99
  listRecordedRuns,
47
100
  listTraceSources,
48
- writeFile: (path, data) => writeFile(path, data, "utf8"),
49
- mkdir: (path) => mkdir(path, { recursive: true }).then(() => undefined),
101
+ writeFile: writeOwnerPrivateLaunchdFile,
102
+ mkdir: ensureOwnerPrivateLaunchdDirectory,
50
103
  rm: (path) => rm(path, { force: true }),
51
- stat,
104
+ stat: inspectOwnerPrivateLaunchdLog,
52
105
  rename,
53
106
  isAlive: (pid) => {
54
107
  try {
@@ -60,6 +113,14 @@ export function defaultBackgroundDeps() {
60
113
  return isErrno(error, "EPERM");
61
114
  }
62
115
  },
116
+ ensureManagedRuntime: (input) => ensureManagedBackgroundRuntime(input),
117
+ resolveManagedRuntimePackages: (target) => resolveConfiguredManagedRuntimePackages({
118
+ cwd: target.cwd,
119
+ configPath: target.configPath,
120
+ env: target.configurationEnvironment,
121
+ }),
122
+ acquireLifecycleLock: acquireFilesystemLifecycleLock,
123
+ probeTui: probeTuiEndpoint,
63
124
  stdout: (text) => void process.stdout.write(text),
64
125
  stderr: (text) => void process.stderr.write(text),
65
126
  spawnTail: (args) => new Promise((resolvePromise) => {
@@ -70,38 +131,176 @@ export function defaultBackgroundDeps() {
70
131
  };
71
132
  }
72
133
  const DEFAULT_POLL = { timeoutMs: 18_000, intervalMs: 400 };
73
- const SINCE_SKEW_MS = 2_000;
74
134
  export const LAUNCHD_LOG_MAX_BYTES = 5 * 1024 * 1024;
75
135
  export const LAUNCHD_LOG_ROTATION_COUNT = 3;
76
136
  export async function startBackground(target, deps, poll = DEFAULT_POLL) {
77
- return await launchBackground(target, deps, poll);
137
+ return (await ensureBackgroundReady(target, deps, poll)).ok ? 0 : 1;
78
138
  }
79
139
  /** Restart is behaviourally identical: ensure a single fresh running instance. */
80
140
  export async function restartBackground(target, deps, poll = DEFAULT_POLL) {
81
- return await launchBackground(target, deps, poll);
141
+ return (await ensureBackgroundReady(target, deps, poll)).ok ? 0 : 1;
142
+ }
143
+ /**
144
+ * Stop, perform the caller's stopped-worker mutation, and start again while
145
+ * retaining one lifecycle lock. This closes the force-restart gap in which a
146
+ * concurrent start could previously enter while the session store was purged.
147
+ */
148
+ export async function forceRestartBackground(target, deps, whileStopped, poll = DEFAULT_POLL) {
149
+ const release = await deps.acquireLifecycleLock(target);
150
+ if (release === undefined) {
151
+ deps.stderr(ui.errorLine(`Another lifecycle command is already active for ${target.label}.`));
152
+ deps.stderr(ui.hint("No LaunchAgent or session changes were made. Wait for that command to finish, then retry."));
153
+ return 1;
154
+ }
155
+ try {
156
+ const stopCode = await stopBackgroundUnlocked(target, deps, poll);
157
+ if (stopCode !== 0)
158
+ return stopCode;
159
+ await whileStopped();
160
+ return (await ensureBackgroundReadyUnlocked(target, deps, poll)).ok ? 0 : 1;
161
+ }
162
+ finally {
163
+ await release().catch((error) => {
164
+ deps.stderr(ui.errorLine(`Could not release lifecycle lock for ${target.label}: ${error instanceof Error ? error.message : String(error)}`));
165
+ });
166
+ }
167
+ }
168
+ /**
169
+ * Ensure the canonical per-config LaunchAgent is running and return the fresh
170
+ * trace source that proved it reached `startup-complete`. This is the shared
171
+ * lifecycle boundary for CLI start/restart and remote configuration handoffs;
172
+ * callers must not open a console when the result is not ok.
173
+ */
174
+ export async function ensureBackgroundReady(target, deps, poll = DEFAULT_POLL) {
175
+ const release = await deps.acquireLifecycleLock(target);
176
+ if (release === undefined) {
177
+ deps.stderr(ui.errorLine(`Another lifecycle command is already active for ${target.label}.`));
178
+ deps.stderr(ui.hint("No LaunchAgent changes were made. Wait for that command to finish, then retry."));
179
+ return { ok: false, action: "start", reason: "ownership" };
180
+ }
181
+ try {
182
+ return await ensureBackgroundReadyUnlocked(target, deps, poll);
183
+ }
184
+ finally {
185
+ await release().catch((error) => {
186
+ deps.stderr(ui.errorLine(`Could not release lifecycle lock for ${target.label}: ${error instanceof Error ? error.message : String(error)}`));
187
+ });
188
+ }
82
189
  }
83
- async function launchBackground(target, deps, poll) {
190
+ async function ensureBackgroundReadyUnlocked(target, deps, poll) {
191
+ if (target.expectedSnapshot === undefined) {
192
+ deps.stderr(ui.errorLine("Refusing to launch a managed worker without an approved background snapshot."));
193
+ deps.stderr(ui.hint("No LaunchAgent changes were made. Retry from the wizard or `mono-agent start`."));
194
+ return { ok: false, action: "start", reason: "snapshot" };
195
+ }
84
196
  const uid = deps.getuid();
85
- await writePlist(target, deps);
86
- await rotateLaunchdLogs(target.paths, deps);
87
- const sinceMs = deps.now();
88
- const outcome = await bootstrapOrRestart(target, deps, uid);
197
+ if (!(await snapshotStillMatches(target, deps))) {
198
+ reportSnapshotDrift(target, deps);
199
+ return { ok: false, action: "start", reason: "snapshot" };
200
+ }
201
+ let launchTarget;
202
+ try {
203
+ const additionalPackages = await deps.resolveManagedRuntimePackages?.(target) ?? [];
204
+ const runtime = await deps.ensureManagedRuntime({
205
+ currentCliPath: target.cliPath,
206
+ nodePath: target.nodePath,
207
+ additionalPackages,
208
+ });
209
+ launchTarget = {
210
+ ...target,
211
+ cliPath: runtime.cliPath,
212
+ nodePath: runtime.nodePath,
213
+ };
214
+ }
215
+ catch (error) {
216
+ reportLifecycleException(target, deps, "install and verify the durable managed runtime", error);
217
+ return { ok: false, action: "start", reason: "runtime" };
218
+ }
219
+ // Runtime materialisation can involve npm/native installation. Recheck the
220
+ // approved files after that unbounded external work and before writing any
221
+ // LaunchAgent state.
222
+ if (!(await snapshotStillMatches(launchTarget, deps))) {
223
+ reportSnapshotDrift(launchTarget, deps);
224
+ return { ok: false, action: "start", reason: "snapshot" };
225
+ }
226
+ try {
227
+ const conflict = await findOwnershipConflict(launchTarget, deps, uid);
228
+ if (conflict !== undefined) {
229
+ reportOwnershipConflict(launchTarget, deps, conflict);
230
+ return { ok: false, action: "start", reason: "ownership" };
231
+ }
232
+ }
233
+ catch (error) {
234
+ reportLifecycleException(launchTarget, deps, "verify the existing background worker ownership", error);
235
+ return { ok: false, action: "start", reason: "ownership" };
236
+ }
237
+ let sinceMs;
238
+ try {
239
+ await prepareLaunchdDirectories(launchTarget, deps);
240
+ await rotateLaunchdLogs(launchTarget.paths, deps);
241
+ await writePlist(launchTarget, deps);
242
+ sinceMs = deps.now();
243
+ }
244
+ catch (error) {
245
+ reportLifecycleException(launchTarget, deps, "prepare the LaunchAgent", error);
246
+ return { ok: false, action: "start", reason: "preparation" };
247
+ }
248
+ let outcome;
249
+ try {
250
+ outcome = await bootstrapOrRestart(launchTarget, deps, uid, poll);
251
+ }
252
+ catch (error) {
253
+ reportLifecycleException(launchTarget, deps, "start the LaunchAgent", error);
254
+ return { ok: false, action: "start", reason: "launchctl" };
255
+ }
256
+ const action = outcome.restarted ? "restart" : "start";
89
257
  if (!outcome.ok) {
90
- return failLaunch(target, deps, outcome.restarted ? "restart" : "start", outcome.failure);
258
+ reportLaunchFailure(launchTarget, deps, action, outcome.failure);
259
+ return { ok: false, action, reason: "launchctl" };
91
260
  }
92
261
  deps.stdout(ui.hint("Waiting for the worker to report ready…"));
93
- const ready = await pollInstanceReady(target, deps, { ...poll, sinceMs });
262
+ let ready;
263
+ try {
264
+ ready = await pollInstanceReady(launchTarget, deps, {
265
+ ...poll,
266
+ sinceMs,
267
+ ...(launchTarget.requireTui === true ? { requireTui: true } : {}),
268
+ });
269
+ }
270
+ catch (error) {
271
+ reportLifecycleException(launchTarget, deps, "read the worker readiness trace", error);
272
+ return { ok: false, action, reason: "readiness" };
273
+ }
94
274
  if (ready === undefined) {
95
- return reportTimeout(target, deps);
275
+ if (!(await snapshotStillMatches(launchTarget, deps))) {
276
+ const stopped = await stopBackgroundUnlocked(launchTarget, deps, poll);
277
+ reportSnapshotDrift(launchTarget, deps);
278
+ if (stopped === 0) {
279
+ deps.stderr(ui.style.dim("The drifted LaunchAgent was stopped before returning control.") + "\n");
280
+ }
281
+ else {
282
+ deps.stderr(ui.style.yellow("The drifted LaunchAgent could not be proven stopped; follow the recovery commands above.") + "\n");
283
+ }
284
+ return { ok: false, action, reason: "snapshot" };
285
+ }
286
+ reportTimeout(launchTarget, deps);
287
+ return { ok: false, action, reason: "timeout" };
96
288
  }
97
- printInstanceInfo(ready, target, deps, outcome.restarted ? "restarted" : "started");
98
- return 0;
289
+ const completedAction = outcome.restarted ? "restarted" : "started";
290
+ printInstanceInfo(ready, launchTarget, deps, completedAction);
291
+ return { ok: true, action: completedAction, source: ready };
99
292
  }
100
- async function writePlist(target, deps) {
293
+ async function prepareLaunchdDirectories(target, deps) {
101
294
  // launchd will not create the log file's parent directory, so make both dirs
102
295
  // before writing the plist that references them.
296
+ await deps.mkdir(dirname(target.paths.logDir));
103
297
  await deps.mkdir(target.paths.logDir);
104
298
  await deps.mkdir(target.paths.launchAgentsDir);
299
+ }
300
+ async function writePlist(target, deps) {
301
+ if (target.expectedSnapshot === undefined) {
302
+ throw new Error("A managed LaunchAgent requires an approved background snapshot.");
303
+ }
105
304
  const xml = buildPlistXml({
106
305
  label: target.label,
107
306
  nodePath: target.nodePath,
@@ -109,9 +308,10 @@ async function writePlist(target, deps) {
109
308
  configPath: target.configPath,
110
309
  cwd: target.cwd,
111
310
  ...(target.envFile === undefined ? {} : { envFile: target.envFile }),
311
+ expectedBackgroundSnapshot: encodeBackgroundSnapshot(target.expectedSnapshot),
112
312
  stdoutPath: target.paths.stdoutPath,
113
313
  stderrPath: target.paths.stderrPath,
114
- pathEnv: target.pathEnv,
314
+ environment: target.environment,
115
315
  });
116
316
  await deps.writeFile(target.paths.plistPath, xml);
117
317
  }
@@ -126,8 +326,10 @@ async function rotateLaunchdLog(path, deps) {
126
326
  try {
127
327
  size = (await deps.stat(path)).size;
128
328
  }
129
- catch {
130
- return;
329
+ catch (error) {
330
+ if (isErrno(error, "ENOENT"))
331
+ return;
332
+ throw error;
131
333
  }
132
334
  if (size <= LAUNCHD_LOG_MAX_BYTES) {
133
335
  return;
@@ -144,52 +346,270 @@ async function rotateLaunchdLog(path, deps) {
144
346
  }
145
347
  }
146
348
  }
349
+ async function ensureOwnerPrivateLaunchdDirectory(path) {
350
+ const parent = dirname(path);
351
+ const parentDetails = await lstat(parent);
352
+ assertOwnerDirectory(parentDetails, parent, "LaunchAgent parent");
353
+ try {
354
+ await mkdir(path, { mode: 0o700 });
355
+ }
356
+ catch (error) {
357
+ if (!isErrno(error, "EEXIST"))
358
+ throw error;
359
+ }
360
+ const parentAfter = await lstat(parent);
361
+ assertOwnerDirectory(parentAfter, parent, "LaunchAgent parent");
362
+ if (!sameFilesystemIdentity(parentDetails, parentAfter)) {
363
+ throw new Error(`LaunchAgent parent ${parent} changed while ${path} was created.`);
364
+ }
365
+ const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
366
+ try {
367
+ const before = await handle.stat();
368
+ assertOwnerDirectory(before, path, "LaunchAgent directory");
369
+ await handle.chmod(0o700);
370
+ const secured = await handle.stat();
371
+ if (!sameFilesystemIdentity(before, secured)) {
372
+ throw new Error(`LaunchAgent directory ${path} changed while it was secured.`);
373
+ }
374
+ assertOwnerDirectory(secured, path, "LaunchAgent directory");
375
+ if ((secured.mode & 0o077) !== 0) {
376
+ throw new Error(`LaunchAgent directory ${path} must be owner-only.`);
377
+ }
378
+ const current = await lstat(path);
379
+ if (!sameFilesystemIdentity(secured, current)) {
380
+ throw new Error(`LaunchAgent directory ${path} changed while it was secured.`);
381
+ }
382
+ }
383
+ finally {
384
+ await handle.close();
385
+ }
386
+ }
387
+ async function writeOwnerPrivateLaunchdFile(path, data) {
388
+ let existing;
389
+ try {
390
+ existing = await lstat(path);
391
+ assertOwnerRegularFile(existing, path, "LaunchAgent plist");
392
+ }
393
+ catch (error) {
394
+ if (!isErrno(error, "ENOENT"))
395
+ throw error;
396
+ }
397
+ const temporaryPath = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
398
+ let handle = await open(temporaryPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, 0o600);
399
+ try {
400
+ await handle.writeFile(data, "utf8");
401
+ await handle.chmod(0o600);
402
+ await handle.sync();
403
+ await handle.close();
404
+ handle = undefined;
405
+ const temporary = await lstat(temporaryPath);
406
+ assertOwnerRegularFile(temporary, temporaryPath, "temporary LaunchAgent plist");
407
+ if ((temporary.mode & 0o777) !== 0o600) {
408
+ throw new Error(`Temporary LaunchAgent plist ${temporaryPath} must be owner-readable and owner-writable only.`);
409
+ }
410
+ let current;
411
+ try {
412
+ current = await lstat(path);
413
+ }
414
+ catch (error) {
415
+ if (!isErrno(error, "ENOENT"))
416
+ throw error;
417
+ }
418
+ if ((existing === undefined) !== (current === undefined)
419
+ || (existing !== undefined && current !== undefined && !sameFilesystemIdentity(existing, current))) {
420
+ throw new Error(`LaunchAgent plist ${path} changed before the new definition was committed.`);
421
+ }
422
+ await rename(temporaryPath, path);
423
+ const committed = await lstat(path);
424
+ assertOwnerRegularFile(committed, path, "LaunchAgent plist");
425
+ if (!sameFilesystemIdentity(temporary, committed)) {
426
+ throw new Error(`LaunchAgent plist ${path} changed while the new definition was committed.`);
427
+ }
428
+ }
429
+ finally {
430
+ await handle?.close().catch(() => undefined);
431
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
432
+ }
433
+ }
434
+ async function inspectOwnerPrivateLaunchdLog(path) {
435
+ const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
436
+ try {
437
+ const before = await handle.stat();
438
+ assertOwnerRegularFile(before, path, "LaunchAgent log");
439
+ await handle.chmod(0o600);
440
+ const secured = await handle.stat();
441
+ if (!sameFilesystemIdentity(before, secured)) {
442
+ throw new Error(`LaunchAgent log ${path} changed while it was secured.`);
443
+ }
444
+ assertOwnerRegularFile(secured, path, "LaunchAgent log");
445
+ const current = await lstat(path);
446
+ if (!sameFilesystemIdentity(secured, current)) {
447
+ throw new Error(`LaunchAgent log ${path} changed while it was secured.`);
448
+ }
449
+ return secured;
450
+ }
451
+ finally {
452
+ await handle.close();
453
+ }
454
+ }
455
+ function assertOwnerDirectory(details, path, description) {
456
+ if (!details.isDirectory() || details.isSymbolicLink()) {
457
+ throw new Error(`${description} ${path} must be a real directory.`);
458
+ }
459
+ assertCurrentUserOwns(details, path, description);
460
+ }
461
+ function assertOwnerRegularFile(details, path, description) {
462
+ if (!details.isFile() || details.isSymbolicLink()) {
463
+ throw new Error(`${description} ${path} must be a regular non-symbolic-link file.`);
464
+ }
465
+ if (details.nlink !== 1) {
466
+ throw new Error(`${description} ${path} must have exactly one filesystem link.`);
467
+ }
468
+ assertCurrentUserOwns(details, path, description);
469
+ }
470
+ function assertCurrentUserOwns(details, path, description) {
471
+ if (typeof process.getuid === "function" && details.uid !== process.getuid()) {
472
+ throw new Error(`${description} ${path} is not owned by the current user.`);
473
+ }
474
+ }
475
+ function sameFilesystemIdentity(left, right) {
476
+ return left.dev === right.dev && left.ino === right.ino;
477
+ }
147
478
  /**
148
- * Idempotent: bootstrap when not loaded, otherwise `kickstart -k` to restart.
149
- * `bootstrap` reporting "already bootstrapped" (or any non-zero) is tolerated as
150
- * long as a follow-up `print` confirms the service is loaded.
479
+ * Idempotent: bootstrap when not loaded. A loaded job is always fully removed
480
+ * before bootstrap so launchd cannot retain stale ProgramArguments or env from
481
+ * the previous plist.
151
482
  */
152
- async function bootstrapOrRestart(target, deps, uid) {
153
- if (!(await isLoaded(deps.runner, target.label, uid))) {
483
+ async function bootstrapOrRestart(target, deps, uid, poll) {
484
+ const service = await launchdServiceInfo(deps.runner, target.label, uid);
485
+ if (!service.loaded) {
154
486
  const booted = await bootstrap(deps.runner, target.paths.plistPath, uid);
155
487
  if (booted.code === 0 || (await isLoaded(deps.runner, target.label, uid))) {
156
488
  return { ok: true, restarted: false };
157
489
  }
158
490
  return { ok: false, restarted: false, failure: booted };
159
491
  }
160
- const restart = await kickstartRestart(deps.runner, target.label, uid);
161
- if (restart.code === 0) {
162
- return { ok: true, restarted: true };
492
+ const oldSources = await findInstances(target, deps);
493
+ const oldPids = uniquePids([
494
+ service.pid,
495
+ // A cleanly stopped manifest can outlive its process long enough for the OS
496
+ // to reuse that pid for unrelated work. It is historical evidence, not an
497
+ // ownership claim, so never wait for its recycled pid during restart.
498
+ ...oldSources
499
+ .filter((source) => source.health !== "stopped")
500
+ .map((source) => source.pid),
501
+ ]);
502
+ const removed = await bootout(deps.runner, target.label, uid);
503
+ const unloaded = await pollUntil(deps, poll, async () => !(await launchdServiceInfo(deps.runner, target.label, uid)).loaded);
504
+ if (!unloaded) {
505
+ return {
506
+ ok: false,
507
+ restarted: true,
508
+ failure: lifecycleFailure(removed, "launchd still reports the previous service as loaded after bootout"),
509
+ };
510
+ }
511
+ const stopped = await pollUntil(deps, poll, async () => oldPids.every((pid) => !deps.isAlive(pid)));
512
+ if (!stopped) {
513
+ return {
514
+ ok: false,
515
+ restarted: true,
516
+ failure: lifecycleFailure(removed, `previous worker pid(s) ${oldPids.join(", ")} remained alive after bootout`),
517
+ };
163
518
  }
164
- // Fallback: a full unload + reload cycle.
165
- await bootout(deps.runner, target.label, uid);
166
519
  const booted = await bootstrap(deps.runner, target.paths.plistPath, uid);
167
520
  if (booted.code === 0 || (await isLoaded(deps.runner, target.label, uid))) {
168
521
  return { ok: true, restarted: true };
169
522
  }
170
523
  return { ok: false, restarted: true, failure: booted };
171
524
  }
172
- export async function stopBackground(target, deps) {
525
+ async function findOwnershipConflict(target, deps, uid) {
526
+ const [service, sources] = await Promise.all([
527
+ launchdServiceInfo(deps.runner, target.label, uid),
528
+ findInstances(target, deps),
529
+ ]);
530
+ const live = sources.filter((source) => source.health !== "stopped" && source.pid !== undefined && deps.isAlive(source.pid));
531
+ if (live.length === 0)
532
+ return undefined;
533
+ if (!service.loaded || service.pid === undefined || !deps.isAlive(service.pid)) {
534
+ return `live matching trace pid(s) ${live.map((source) => source.pid).join(", ")} are not owned by a live ${target.label} launchd job`;
535
+ }
536
+ const foreign = live.filter((source) => source.pid !== service.pid);
537
+ if (foreign.length > 0) {
538
+ return `live matching trace pid(s) ${foreign.map((source) => source.pid).join(", ")} differ from launchd pid ${service.pid}`;
539
+ }
540
+ return undefined;
541
+ }
542
+ async function pollUntil(deps, options, condition) {
543
+ const deadline = deps.now() + options.timeoutMs;
544
+ for (;;) {
545
+ if (await condition())
546
+ return true;
547
+ if (deps.now() >= deadline)
548
+ return false;
549
+ await deps.sleep(options.intervalMs);
550
+ }
551
+ }
552
+ function lifecycleFailure(result, detail) {
553
+ return {
554
+ code: result.code === 0 ? 1 : result.code,
555
+ stdout: result.stdout,
556
+ stderr: [result.stderr.trim(), detail].filter((value) => value.length > 0).join("\n"),
557
+ };
558
+ }
559
+ function uniquePids(values) {
560
+ return [...new Set(values.filter((value) => value !== undefined && value > 0))];
561
+ }
562
+ export async function stopBackground(target, deps, poll = DEFAULT_POLL) {
563
+ const release = await deps.acquireLifecycleLock(target);
564
+ if (release === undefined) {
565
+ deps.stderr(ui.errorLine(`Another lifecycle command is already active for ${target.label}.`));
566
+ deps.stderr(ui.hint("The LaunchAgent plist was preserved. Wait for that command to finish, then retry."));
567
+ return 1;
568
+ }
569
+ try {
570
+ return await stopBackgroundUnlocked(target, deps, poll);
571
+ }
572
+ finally {
573
+ await release().catch((error) => {
574
+ deps.stderr(ui.errorLine(`Could not release lifecycle lock for ${target.label}: ${error instanceof Error ? error.message : String(error)}`));
575
+ });
576
+ }
577
+ }
578
+ async function stopBackgroundUnlocked(target, deps, poll) {
173
579
  const uid = deps.getuid();
174
- const existing = await findInstance(target, deps);
175
- const wasLoaded = await isLoaded(deps.runner, target.label, uid);
580
+ const existing = await findInstances(target, deps);
581
+ const service = await launchdServiceInfo(deps.runner, target.label, uid);
582
+ const liveTracePids = uniquePids(existing
583
+ .filter((source) => source.health !== "stopped" && source.pid !== undefined && deps.isAlive(source.pid))
584
+ .map((source) => source.pid));
585
+ if (liveTracePids.some((pid) => pid !== service.pid)) {
586
+ deps.stderr(ui.errorLine(`Refusing to stop ${target.label}: matching live trace pid(s) ${liveTracePids.join(", ")} are not owned by that launchd job.`));
587
+ deps.stderr(ui.hint("The LaunchAgent plist was preserved. Stop the unmanaged process explicitly, then retry."));
588
+ return 1;
589
+ }
176
590
  const result = await bootout(deps.runner, target.label, uid);
177
- // Removing the plist stops it from relaunching at the next login.
178
- await deps.rm(target.paths.plistPath);
179
- await maybeUnlinkDeadManifest(target, deps, existing);
180
- // `bootout` also exits non-zero when the service was never loaded, which is
181
- // fine. A non-zero exit is only a real failure if the service is still loaded
182
- // afterwards — otherwise we'd silently swallow genuine stop failures.
183
- if (result.code !== 0 && (await isLoaded(deps.runner, target.label, uid))) {
184
- deps.stderr(ui.errorLine(`Failed to stop ${target.label}: launchctl bootout exited ${result.code}.`));
591
+ const unloaded = await pollUntil(deps, poll, async () => !(await launchdServiceInfo(deps.runner, target.label, uid)).loaded);
592
+ const ownedPids = uniquePids([service.pid, ...liveTracePids]);
593
+ const stopped = await pollUntil(deps, poll, async () => ownedPids.every((pid) => !deps.isAlive(pid)));
594
+ if (!unloaded || !stopped) {
595
+ deps.stderr(ui.errorLine(`Failed to prove ${target.label} stopped${result.code === 0 ? "" : ` (launchctl bootout exited ${result.code})`}.`));
185
596
  const detail = (result.stderr || result.stdout).trim();
186
597
  if (detail.length > 0) {
187
598
  deps.stderr(ui.style.dim(detail) + "\n");
188
599
  }
189
- deps.stderr(ui.hint("The plist was removed, but the service is still running."));
600
+ if (!unloaded)
601
+ deps.stderr(ui.style.dim("launchd still reports the service as loaded.\n"));
602
+ if (!stopped)
603
+ deps.stderr(ui.style.dim(`Worker pid(s) ${ownedPids.join(", ")} are still alive.\n`));
604
+ deps.stderr(ui.hint("The LaunchAgent plist was preserved so the service remains recoverable. Inspect status/logs and retry."));
190
605
  return 1;
191
606
  }
192
- deps.stdout(wasLoaded
607
+ // Remove the plist only after both launchd ownership and process death are
608
+ // proven. This prevents a failed stop from destroying its recovery handle.
609
+ await deps.rm(target.paths.plistPath);
610
+ for (const source of existing)
611
+ await maybeUnlinkDeadManifest(target, deps, source);
612
+ deps.stdout(service.loaded
193
613
  ? `${ui.badge("ok")}${ui.style.bold(`Stopped ${target.label}`)} and removed its LaunchAgent.\n`
194
614
  : `${ui.style.dim(`${target.label} was not running; removed its LaunchAgent if present.`)}\n`);
195
615
  deps.stdout(ui.keyValue([["config", target.configPath]]));
@@ -197,10 +617,14 @@ export async function stopBackground(target, deps) {
197
617
  }
198
618
  export async function statusBackground(target, deps) {
199
619
  const result = await deps.listTraceSources({ registryDir: target.registryDir, staleAfterMs: target.staleAfterMs });
200
- const current = result.sources.find((source) => matchesConfig(source, target.configPath));
620
+ const classified = await Promise.all(result.sources.map(async (source) => ({
621
+ source,
622
+ matches: await matchesConfig(source, target.configPath),
623
+ })));
624
+ const current = classified.find((entry) => entry.matches)?.source;
201
625
  if (current === undefined) {
202
626
  deps.stdout(ui.style.dim(`No running mono-agent instance for ${target.configPath}.`) + "\n");
203
- deps.stdout(ui.hint(`Start it with: mono-agent start${configFlag(target)}`));
627
+ deps.stdout(ui.hint(`Start it with: mono-agent start${commandFlags(target)}`));
204
628
  }
205
629
  else {
206
630
  writeInstanceDetail(current, target, deps);
@@ -208,7 +632,9 @@ export async function statusBackground(target, deps) {
208
632
  }
209
633
  // Only surface other instances that are live or crashed — cleanly stopped
210
634
  // manifests linger in the registry and would just be noise.
211
- const others = result.sources.filter((source) => !matchesConfig(source, target.configPath) && source.health !== "stopped");
635
+ const others = classified
636
+ .filter((entry) => !entry.matches && entry.source.health !== "stopped")
637
+ .map((entry) => entry.source);
212
638
  if (others.length > 0) {
213
639
  deps.stdout("\n" + ui.rule("Other mono-agent instances"));
214
640
  for (const source of others) {
@@ -229,11 +655,19 @@ export async function tailLogs(target, deps, options) {
229
655
  }
230
656
  export async function pollInstanceReady(target, deps, options) {
231
657
  const deadline = deps.now() + options.timeoutMs;
232
- const sinceFloor = options.sinceMs - SINCE_SKEW_MS;
233
658
  for (;;) {
234
- const match = await findInstance(target, deps);
235
- if (match !== undefined && isReady(match) && startedAtMs(match) >= sinceFloor) {
236
- return match;
659
+ const service = await launchdServiceInfo(deps.runner, target.label, deps.getuid());
660
+ if (service.loaded && service.pid !== undefined && deps.isAlive(service.pid)) {
661
+ const matches = await findInstances(target, deps);
662
+ const match = matches.find((source) => source.pid === service.pid);
663
+ if (match !== undefined
664
+ && isReady(match, options.requireTui === true)
665
+ && startedAtMs(match) >= options.sinceMs
666
+ && snapshotMetadataMatches(match, target.expectedSnapshot)
667
+ && await snapshotStillMatches(target, deps)
668
+ && (options.requireTui !== true || await deps.probeTui(match))) {
669
+ return match;
670
+ }
237
671
  }
238
672
  if (deps.now() >= deadline) {
239
673
  return undefined;
@@ -241,15 +675,52 @@ export async function pollInstanceReady(target, deps, options) {
241
675
  await deps.sleep(options.intervalMs);
242
676
  }
243
677
  }
678
+ async function snapshotStillMatches(target, deps) {
679
+ if (target.expectedSnapshot === undefined)
680
+ return true;
681
+ const capture = deps.captureSnapshot ?? captureTargetSnapshot;
682
+ try {
683
+ return sameBackgroundSnapshot(await capture(target), target.expectedSnapshot);
684
+ }
685
+ catch {
686
+ return false;
687
+ }
688
+ }
689
+ async function captureTargetSnapshot(target) {
690
+ return await captureBackgroundSnapshot({
691
+ cwd: target.cwd,
692
+ configPath: target.configPath,
693
+ ...(target.envFile === undefined ? {} : { envFile: target.envFile }),
694
+ env: target.configurationEnvironment,
695
+ });
696
+ }
697
+ function snapshotMetadataMatches(source, expected) {
698
+ if (expected === undefined)
699
+ return true;
700
+ const actual = backgroundSnapshotFromMetadata(source.metadata);
701
+ return actual !== undefined && sameBackgroundSnapshot(actual, expected);
702
+ }
703
+ function reportSnapshotDrift(target, deps) {
704
+ deps.stderr(ui.errorLine("The committed config, dotenv, Identity, Soul, MCP config, or durable environment changed before background readiness."));
705
+ deps.stderr(ui.style.dim("No readiness claim was made for a different snapshot.") + "\n");
706
+ reportLaunchRecovery(target, deps);
707
+ }
244
708
  export function printInstanceInfo(source, target, deps, verb) {
245
- const flag = configFlag(target);
709
+ const flag = commandFlags(target);
246
710
  deps.stdout(`${ui.badge("ok")}${ui.style.bold(`mono-agent ${verb} in the background.`)}\n\n`);
247
711
  writeInstanceDetail(source, target, deps);
248
712
  deps.stdout("\n" + ui.hint(`Stop with: mono-agent stop${flag} · Logs: mono-agent logs${flag} --follow`));
249
713
  }
250
- async function findInstance(target, deps) {
714
+ async function findInstances(target, deps) {
251
715
  const result = await deps.listTraceSources({ registryDir: target.registryDir, staleAfterMs: target.staleAfterMs });
252
- return result.sources.find((source) => matchesConfig(source, target.configPath));
716
+ const matches = await Promise.all(result.sources.map(async (source) => ({
717
+ source,
718
+ matches: await matchesConfig(source, target.configPath),
719
+ })));
720
+ return matches.filter((entry) => entry.matches).map((entry) => entry.source);
721
+ }
722
+ async function findInstance(target, deps) {
723
+ return (await findInstances(target, deps))[0];
253
724
  }
254
725
  async function maybeUnlinkDeadManifest(target, deps, existing) {
255
726
  // Only clean up a manifest whose process is already gone; a worker that is
@@ -259,25 +730,38 @@ async function maybeUnlinkDeadManifest(target, deps, existing) {
259
730
  }
260
731
  await deps.rm(resolve(target.registryDir, `${existing.sourceId}.json`));
261
732
  }
262
- function failLaunch(target, deps, verb, result) {
733
+ function reportLaunchFailure(target, deps, verb, result) {
263
734
  deps.stderr(ui.errorLine(`Failed to ${verb} ${target.label} via launchctl${result === undefined ? "" : ` (exit ${result.code})`}.`));
264
735
  const detail = (result?.stderr || result?.stdout || "").trim();
265
736
  if (detail.length > 0) {
266
737
  deps.stderr(ui.style.dim(detail) + "\n");
267
738
  }
268
- deps.stderr(ui.hint(`Logs: ${target.paths.stderrPath}`));
269
- return 1;
739
+ reportLaunchRecovery(target, deps);
740
+ }
741
+ function reportOwnershipConflict(target, deps, detail) {
742
+ deps.stderr(ui.errorLine(`Refusing to launch a second worker for ${target.configPath}.`));
743
+ deps.stderr(ui.style.dim(`${detail}.\n`));
744
+ deps.stderr(ui.hint("No LaunchAgent changes were made. Stop the unmanaged process or reconcile launchd ownership, then retry."));
745
+ reportLaunchRecovery(target, deps);
746
+ }
747
+ function reportLifecycleException(target, deps, operation, error) {
748
+ deps.stderr(ui.errorLine(`Failed to ${operation}: ${error instanceof Error ? error.message : String(error)}`));
749
+ deps.stderr(ui.style.dim("The committed agent files were preserved.") + "\n");
750
+ reportLaunchRecovery(target, deps);
270
751
  }
271
752
  function reportTimeout(target, deps) {
272
- const flag = configFlag(target);
273
753
  deps.stderr(ui.errorLine("mono-agent did not report ready within the timeout."));
274
- deps.stderr(ui.style.dim("It may still be starting, or it may have failed. Inspect:") + "\n");
754
+ deps.stderr(ui.style.dim("The committed agent files were preserved. It may still be starting, or it may have failed.") + "\n");
755
+ reportLaunchRecovery(target, deps);
756
+ }
757
+ function reportLaunchRecovery(target, deps) {
758
+ const flags = commandFlags(target);
759
+ deps.stderr(ui.style.dim("Retry or inspect with:") + "\n");
275
760
  deps.stderr(` ${ui.style.gray("logs: ")} ${target.paths.stderrPath}\n` +
276
761
  ` ${target.paths.stdoutPath}\n` +
277
- ` ${ui.style.gray("follow:")} mono-agent logs${flag} --follow\n` +
278
- ` ${ui.style.gray("status:")} mono-agent status${flag}\n` +
279
- ` ${ui.style.gray("stop: ")} mono-agent stop${flag}\n`);
280
- return 1;
762
+ ` ${ui.style.gray("retry: ")} mono-agent start${flags}\n` +
763
+ ` ${ui.style.gray("status:")} mono-agent status${flags}\n` +
764
+ ` ${ui.style.gray("follow:")} mono-agent logs${flags} --follow\n`);
281
765
  }
282
766
  function writeInstanceDetail(source, target, deps) {
283
767
  deps.stdout(ui.rule("instance"));
@@ -360,11 +844,32 @@ function formatOtherInstance(source) {
360
844
  const config = source.configPath ?? "(unknown config)";
361
845
  return ` ${ui.healthBadge(source.health)}${source.health.padEnd(8)} pid ${pid.padEnd(7)} ${source.sourceId} ${config}`;
362
846
  }
363
- function matchesConfig(source, configPath) {
364
- return source.configPath !== undefined && resolve(source.configPath) === configPath;
847
+ async function matchesConfig(source, configPath) {
848
+ if (source.configPath === undefined)
849
+ return false;
850
+ return await canonicalBackgroundConfigPath(process.cwd(), source.configPath) === configPath;
851
+ }
852
+ function isReady(source, requireTui) {
853
+ if (source.health !== "running" || metadataReason(source) !== "startup-complete")
854
+ return false;
855
+ if (source.memoryHealth?.status === "unhealthy")
856
+ return false;
857
+ const channels = channelRecords(source);
858
+ if (channels.some((channel) => channel.kind === "failed"))
859
+ return false;
860
+ if (!requireTui)
861
+ return true;
862
+ if (channels.some((channel) => typeof channel.kind === "string"
863
+ && ["failed", "degraded", "waiting_for_config"].includes(channel.kind)))
864
+ return false;
865
+ return tuiEndpoint(source) !== undefined;
365
866
  }
366
- function isReady(source) {
367
- return source.health === "running" && metadataReason(source) === "startup-complete";
867
+ function channelRecords(source) {
868
+ const channels = source.metadata?.channels;
869
+ if (channels === null || typeof channels !== "object")
870
+ return [];
871
+ return Object.values(channels)
872
+ .filter((value) => value !== null && typeof value === "object");
368
873
  }
369
874
  function metadataReason(source) {
370
875
  const reason = source.metadata?.reason;
@@ -519,7 +1024,244 @@ function stringFieldAsList(record, key) {
519
1024
  /** ` --config <path>` when a non-default config is in play, else empty. */
520
1025
  function configFlag(target) {
521
1026
  const defaultPath = resolve(target.cwd, "mono-agent.config.json");
522
- return target.configPath === defaultPath ? "" : ` --config ${target.configPath}`;
1027
+ return target.configPath === defaultPath ? "" : ` --config ${shellCommandArgument(target.configPath)}`;
1028
+ }
1029
+ function commandFlags(target) {
1030
+ return `${configFlag(target)}${target.envFile === undefined ? "" : ` --env-file ${shellCommandArgument(target.envFile)}`}`;
1031
+ }
1032
+ function shellCommandArgument(value) {
1033
+ return /^[a-zA-Z0-9_./:@%+=,-]+$/u.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
1034
+ }
1035
+ export async function acquireFilesystemLifecycleLock(target, options = {}) {
1036
+ const pid = options.pid ?? process.pid;
1037
+ const now = options.now ?? (() => Date.now());
1038
+ const isProcessAlive = options.isProcessAlive ?? processIsAlive;
1039
+ const incarnation = options.processIncarnation ?? await currentProcessIncarnation();
1040
+ const isSameProcess = options.isSameProcessIncarnation ?? matchesProcessIncarnation;
1041
+ const ownerlessGraceMs = options.ownerlessGraceMs ?? 5 * 60_000;
1042
+ const randomToken = options.randomToken ?? randomUUID;
1043
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
1044
+ throw new Error(`Lifecycle lock pid must be a positive safe integer; received ${String(pid)}.`);
1045
+ }
1046
+ if (!Number.isFinite(ownerlessGraceMs) || ownerlessGraceMs < 0) {
1047
+ throw new Error("Lifecycle lock ownerless grace must be a non-negative finite duration.");
1048
+ }
1049
+ const managedRoot = dirname(target.paths.logDir);
1050
+ const locksDir = resolve(managedRoot, "locks");
1051
+ for (const path of [managedRoot, locksDir]) {
1052
+ await ensureOwnerPrivateLaunchdDirectory(path);
1053
+ }
1054
+ const lockDir = resolve(locksDir, `${target.label}.lock`);
1055
+ const ownerPath = resolve(lockDir, "owner.json");
1056
+ const token = `${pid}-${now()}-${randomToken()}`;
1057
+ for (let attempt = 0; attempt < 4; attempt += 1) {
1058
+ let createdIdentity;
1059
+ try {
1060
+ await mkdir(lockDir, { mode: 0o700 });
1061
+ const created = await assertLifecycleLockDirectory(lockDir);
1062
+ createdIdentity = lifecycleLockIdentity(created);
1063
+ await writeFile(ownerPath, `${JSON.stringify({
1064
+ pid,
1065
+ token,
1066
+ createdAt: new Date(now()).toISOString(),
1067
+ incarnation,
1068
+ })}\n`, {
1069
+ encoding: "utf8",
1070
+ flag: "wx",
1071
+ mode: 0o600,
1072
+ });
1073
+ if (!(await sameLifecycleLockDirectory(lockDir, createdIdentity))) {
1074
+ throw new Error(`Lifecycle lock ${lockDir} changed while its owner record was written.`);
1075
+ }
1076
+ const ownedIdentity = createdIdentity;
1077
+ return async () => {
1078
+ try {
1079
+ if (!(await sameLifecycleLockDirectory(lockDir, ownedIdentity))) {
1080
+ throw new Error(`Lifecycle lock ${lockDir} is no longer owned by this command.`);
1081
+ }
1082
+ const current = JSON.parse(await readFile(ownerPath, "utf8"));
1083
+ if (current.token !== token) {
1084
+ throw new Error(`Lifecycle lock ${lockDir} is no longer owned by this command.`);
1085
+ }
1086
+ const releasedPath = resolve(locksDir, `${target.label}.released-${now()}-${pid}-${randomToken()}`);
1087
+ if (!(await sameLifecycleLockDirectory(lockDir, ownedIdentity))) {
1088
+ throw new Error(`Lifecycle lock ${lockDir} changed during release.`);
1089
+ }
1090
+ await rename(lockDir, releasedPath);
1091
+ const moved = await lstat(releasedPath);
1092
+ if (!sameLifecycleLockIdentity(moved, ownedIdentity)) {
1093
+ await rename(releasedPath, lockDir).catch(() => undefined);
1094
+ throw new Error(`Lifecycle lock ${lockDir} changed during release.`);
1095
+ }
1096
+ await rm(releasedPath, { recursive: true, force: true });
1097
+ }
1098
+ catch (error) {
1099
+ if (!isErrno(error, "ENOENT"))
1100
+ throw error;
1101
+ }
1102
+ };
1103
+ }
1104
+ catch (error) {
1105
+ if (createdIdentity !== undefined) {
1106
+ await removeSameLifecycleLockDirectory(lockDir, createdIdentity).catch(() => undefined);
1107
+ }
1108
+ if (!isErrno(error, "EEXIST"))
1109
+ throw error;
1110
+ }
1111
+ let observed;
1112
+ try {
1113
+ observed = await assertLifecycleLockDirectory(lockDir);
1114
+ }
1115
+ catch (error) {
1116
+ if (isErrno(error, "ENOENT"))
1117
+ continue;
1118
+ return undefined;
1119
+ }
1120
+ const observedIdentity = lifecycleLockIdentity(observed);
1121
+ const owner = await readLifecycleLockOwner(ownerPath);
1122
+ if (!(await sameLifecycleLockDirectory(lockDir, observedIdentity)))
1123
+ continue;
1124
+ if (owner !== undefined) {
1125
+ let sameOwner = true;
1126
+ try {
1127
+ sameOwner = owner.incarnation === undefined
1128
+ ? isProcessAlive(owner.pid)
1129
+ : await isSameProcess(owner.pid, owner.incarnation);
1130
+ }
1131
+ catch {
1132
+ // A failed identity probe cannot authorize stealing another command's lock.
1133
+ sameOwner = true;
1134
+ }
1135
+ if (sameOwner)
1136
+ return undefined;
1137
+ }
1138
+ if (owner === undefined) {
1139
+ const lockAgeMs = now() - observed.mtimeMs;
1140
+ // Another process may be between its atomic mkdir and owner.json write.
1141
+ // Never steal that fresh ownerless lock; only recover abandoned debris.
1142
+ if (lockAgeMs < ownerlessGraceMs)
1143
+ return undefined;
1144
+ }
1145
+ if (!(await sameLifecycleLockDirectory(lockDir, observedIdentity)))
1146
+ continue;
1147
+ await options.beforeStaleLockRename?.();
1148
+ const stalePath = resolve(locksDir, `${target.label}.stale-${now()}-${pid}-${randomToken()}`);
1149
+ try {
1150
+ await rename(lockDir, stalePath);
1151
+ }
1152
+ catch (error) {
1153
+ if (isErrno(error, "ENOENT") || isErrno(error, "EEXIST"))
1154
+ continue;
1155
+ return undefined;
1156
+ }
1157
+ const moved = await lstat(stalePath);
1158
+ if (!sameLifecycleLockIdentity(moved, observedIdentity)) {
1159
+ // A contender replaced the path after our final identity check. Restore
1160
+ // the directory we moved and fail closed; never delete its live lock.
1161
+ await rename(stalePath, lockDir).catch(() => undefined);
1162
+ return undefined;
1163
+ }
1164
+ await rm(stalePath, { recursive: true, force: true });
1165
+ }
1166
+ return undefined;
1167
+ }
1168
+ async function assertLifecycleLockDirectory(path) {
1169
+ const details = await lstat(path);
1170
+ if (!details.isDirectory() || details.isSymbolicLink()) {
1171
+ throw new Error(`Lifecycle lock ${path} must be a real directory.`);
1172
+ }
1173
+ if (typeof process.getuid === "function" && details.uid !== process.getuid()) {
1174
+ throw new Error(`Lifecycle lock ${path} is not owned by the current user.`);
1175
+ }
1176
+ return details;
1177
+ }
1178
+ function lifecycleLockIdentity(details) {
1179
+ return { dev: details.dev, ino: details.ino };
1180
+ }
1181
+ function sameLifecycleLockIdentity(details, identity) {
1182
+ return details.dev === identity.dev && details.ino === identity.ino;
1183
+ }
1184
+ async function sameLifecycleLockDirectory(path, identity) {
1185
+ try {
1186
+ const details = await assertLifecycleLockDirectory(path);
1187
+ return sameLifecycleLockIdentity(details, identity);
1188
+ }
1189
+ catch {
1190
+ return false;
1191
+ }
1192
+ }
1193
+ async function removeSameLifecycleLockDirectory(path, identity) {
1194
+ if (await sameLifecycleLockDirectory(path, identity)) {
1195
+ await rm(path, { recursive: true, force: true });
1196
+ }
1197
+ }
1198
+ async function readLifecycleLockOwner(path) {
1199
+ try {
1200
+ const parsed = JSON.parse(await readFile(path, "utf8"));
1201
+ const incarnation = processIncarnationFromJson(parsed.incarnation);
1202
+ return typeof parsed.pid === "number" && Number.isSafeInteger(parsed.pid) && parsed.pid > 0
1203
+ ? {
1204
+ pid: parsed.pid,
1205
+ ...(incarnation === undefined ? {} : { incarnation }),
1206
+ }
1207
+ : undefined;
1208
+ }
1209
+ catch {
1210
+ return undefined;
1211
+ }
1212
+ }
1213
+ function processIsAlive(pid) {
1214
+ try {
1215
+ process.kill(pid, 0);
1216
+ return true;
1217
+ }
1218
+ catch (error) {
1219
+ return isErrno(error, "EPERM");
1220
+ }
1221
+ }
1222
+ async function probeTuiEndpoint(source) {
1223
+ const baseUrl = tuiEndpoint(source);
1224
+ if (baseUrl === undefined)
1225
+ return false;
1226
+ let url;
1227
+ try {
1228
+ url = new URL(`${baseUrl.replace(/\/+$/u, "")}/v1/info`);
1229
+ }
1230
+ catch {
1231
+ return false;
1232
+ }
1233
+ if (url.protocol !== "http:" || !isLoopbackHostname(url.hostname))
1234
+ return false;
1235
+ try {
1236
+ const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });
1237
+ // Authenticated adapters return 401/403 without the secret. That still
1238
+ // proves the advertised loopback listener is reachable; an open endpoint
1239
+ // additionally proves it belongs to the expected worker pid.
1240
+ if (response.status === 401 || response.status === 403)
1241
+ return true;
1242
+ if (!response.ok)
1243
+ return false;
1244
+ const body = await response.json();
1245
+ return typeof source.pid === "number" && body.pid === source.pid;
1246
+ }
1247
+ catch {
1248
+ return false;
1249
+ }
1250
+ }
1251
+ function isLoopbackHostname(hostname) {
1252
+ return hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost";
1253
+ }
1254
+ function tuiEndpoint(source) {
1255
+ const channels = source.metadata?.channels;
1256
+ if (channels === null || typeof channels !== "object")
1257
+ return undefined;
1258
+ const tui = channels.tui;
1259
+ if (tui === null || typeof tui !== "object")
1260
+ return undefined;
1261
+ const record = tui;
1262
+ return record.kind === "running" && typeof record.baseUrl === "string" && record.baseUrl.trim().length > 0
1263
+ ? record.baseUrl
1264
+ : undefined;
523
1265
  }
524
1266
  function formatChannels(source) {
525
1267
  const channels = source.metadata?.channels;