@bridge_gpt/mcp-server 0.2.34 → 0.2.37

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 (109) hide show
  1. package/README.md +490 -366
  2. package/build/agent-capabilities/probe-context.js +8 -1
  3. package/build/agent-capabilities/probes.js +7 -1
  4. package/build/agents.generated.js +1 -1
  5. package/build/base-url.js +79 -0
  6. package/build/bridge-api-urls.js +9 -0
  7. package/build/chain-orchestrator.js +93 -15
  8. package/build/claude-review-workflow.js +264 -0
  9. package/build/claude-user-config-doctor.js +317 -0
  10. package/build/cli-release.js +53 -0
  11. package/build/commands.generated.js +6 -5
  12. package/build/conductor/bridge-api-client.js +393 -4
  13. package/build/conductor/deny-enforcement-preflight.js +1 -0
  14. package/build/conductor/done-gate.js +44 -5
  15. package/build/conductor/epic-reconcile.js +6 -0
  16. package/build/conductor/install-doctor.js +462 -0
  17. package/build/conductor-bin.js +3 -3
  18. package/build/conductor-bundle-artifacts.js +37 -15
  19. package/build/credential-store.js +205 -4
  20. package/build/direct-ticket-tools.js +70 -0
  21. package/build/doctor.js +473 -81
  22. package/build/executor/cli.js +83 -6
  23. package/build/executor/credentials.js +39 -11
  24. package/build/executor/deps.js +32 -1
  25. package/build/executor/env.js +71 -28
  26. package/build/executor/heartbeat.js +138 -17
  27. package/build/executor/http-client.js +49 -8
  28. package/build/executor/index.js +4 -0
  29. package/build/executor/job-errors.js +4 -0
  30. package/build/executor/job-runner.js +530 -20
  31. package/build/executor/observation.js +130 -0
  32. package/build/executor/permissions.js +114 -8
  33. package/build/executor/preflight.js +127 -8
  34. package/build/executor/prompt-spec.js +51 -0
  35. package/build/executor/runner.js +23 -2
  36. package/build/executor/service-unit.js +876 -0
  37. package/build/executor/test-clock.js +75 -3
  38. package/build/executor/types.js +4 -18
  39. package/build/executor/worker-command.js +73 -12
  40. package/build/executor/worker-config-isolation.js +287 -0
  41. package/build/executor/worker-finalization.js +68 -14
  42. package/build/executor/worktree.js +46 -4
  43. package/build/index.js +1039 -237
  44. package/build/init.js +503 -111
  45. package/build/install-bridge-conductor.js +491 -0
  46. package/build/install-bridge.js +1188 -247
  47. package/build/install-reexec.js +233 -0
  48. package/build/launcher-config-inspection.js +351 -0
  49. package/build/mcp-host-config.js +11 -1
  50. package/build/mcp-install-state.js +32 -0
  51. package/build/mcp-invoke.js +49 -6
  52. package/build/mcp-provisioning.js +52 -13
  53. package/build/mcp-registration-doctor.js +14 -5
  54. package/build/notifications.js +553 -0
  55. package/build/pipeline-orchestrator.js +146 -4
  56. package/build/pipeline-utils.js +3 -0
  57. package/build/pipelines.generated.js +32 -13
  58. package/build/plan-execution-ledger.js +550 -0
  59. package/build/plan-phase-routing.js +272 -0
  60. package/build/plane/alembic-head.js +110 -0
  61. package/build/plane/build-freshness.js +167 -0
  62. package/build/plane/cli.js +480 -0
  63. package/build/plane/defaults.js +266 -0
  64. package/build/plane/manifest.js +377 -0
  65. package/build/plane/member-logs.js +147 -0
  66. package/build/plane/member-roster.js +147 -0
  67. package/build/plane/preflight.js +289 -0
  68. package/build/plane/shutdown.js +195 -0
  69. package/build/plane/status.js +125 -0
  70. package/build/plane/supervisor.js +569 -0
  71. package/build/plane/test-fakes.js +156 -0
  72. package/build/plane/types.js +75 -0
  73. package/build/readme.generated.js +1 -1
  74. package/build/run-unit-tests-launcher.js +259 -0
  75. package/build/setup-epic.js +772 -28
  76. package/build/sfcc/log-gate.js +38 -11
  77. package/build/sfcc/log-query.js +55 -15
  78. package/build/sfcc/ocapi-shape.js +51 -14
  79. package/build/sfcc/output.js +41 -11
  80. package/build/sfcc/permissions.js +24 -2
  81. package/build/sfcc/read-projection.js +181 -0
  82. package/build/sfcc/read-result.js +158 -0
  83. package/build/sfcc/reads-custom-object-def.js +29 -18
  84. package/build/sfcc/reads-site-preference.js +75 -29
  85. package/build/sfcc/reads-system-object.js +40 -34
  86. package/build/sfcc/sfcc-result.js +106 -0
  87. package/build/sfcc/tool-wrapper.js +56 -13
  88. package/build/sfcc/write-grants.js +45 -22
  89. package/build/sfcc/write-guard.js +21 -13
  90. package/build/sfcc/write-result.js +61 -14
  91. package/build/sfcc/write-tool-common.js +126 -32
  92. package/build/sfcc/writes-system-object.js +11 -50
  93. package/build/start-tickets-prereqs.js +129 -0
  94. package/build/start-tickets.js +17 -13
  95. package/build/ticket-backend-metadata.js +59 -0
  96. package/build/ticket-key-utils.js +92 -0
  97. package/build/tool-error-envelope.js +71 -0
  98. package/build/tool-surface-gating.js +72 -0
  99. package/build/update-status.js +102 -0
  100. package/build/upgrade-advice.js +47 -0
  101. package/build/upgrade-cli.js +412 -110
  102. package/build/version.generated.js +1 -1
  103. package/build/worktree-core.js +73 -0
  104. package/docs/CONDUCTOR.md +132 -6
  105. package/docs/install/mcp-tool-integrations.md +29 -21
  106. package/package.json +9 -6
  107. package/pipelines/implement-ticket.json +19 -4
  108. package/build/conductor/supervisor-judgment-python.js +0 -141
  109. package/build/conductor/supervisor-judgment.js +0 -215
@@ -0,0 +1,876 @@
1
+ /**
2
+ * Executor persistent-service packaging (BAPI-688).
3
+ *
4
+ * Renders long-running *user-domain* executor services — a macOS launchd
5
+ * LaunchAgent plist or a Linux systemd `--user` unit — for ONE stable executor
6
+ * id, and hosts the `executor install-service` CLI that previews or writes
7
+ * exactly one of them.
8
+ *
9
+ * Three invariants shape this module:
10
+ *
11
+ * 1. **Never embeds credentials.** A generated unit carries only the validated
12
+ * base URL, the generating shell's `PATH`, the repository list, the executor
13
+ * id, and the packaged invocation. `BAPI_API_KEY` (and every
14
+ * credential-store value) resolves at *launch* time through the executor's
15
+ * existing credential seam, so an artifact left on disk is not a secret.
16
+ * `PATH` is baked deliberately (the scheduler backends set the precedent —
17
+ * see `BAKED_ENV_VAR_NAMES` in `../scheduler-backends/escaping.js`,
18
+ * BAPI-327): under launchd / `systemd --user` the environment PATH is the
19
+ * minimal system default, and the executor spawns its worker as the bare
20
+ * command `claude`, which on a typical workstation lives in `~/.local/bin`,
21
+ * `~/.claude/local`, or `/opt/homebrew/bin` — without the generating
22
+ * shell's PATH a service-launched executor crash-loops at worker preflight.
23
+ * 2. **Filesystem effects only through injected dependencies.** Every `stat` /
24
+ * `mkdir` / `writeFile` / prompt goes through {@link ExecutorServiceInstallDeps},
25
+ * so the whole CLI is unit-testable without touching a real home directory.
26
+ * 3. **Never manages service lifecycle.** The installer writes a file. It does
27
+ * not run `launchctl`, `systemctl`, `loginctl`, or `schtasks` — starting,
28
+ * stopping, and enabling the unit stay operator-owned.
29
+ *
30
+ * The artifact type is deliberately executor-local rather than the scheduler's
31
+ * {@link ../scheduler-backends/types.js#GeneratedArtifact}: that shape carries a
32
+ * loose `kind: string`, whereas the executor wants a *discriminating* literal
33
+ * kind so a plist can never be handed to the systemd inspector. The two remain
34
+ * structurally compatible (`path` / `content` / `kind`).
35
+ */
36
+ import { mkdir as fsMkdir, stat as fsStat, writeFile as fsWriteFile } from "node:fs/promises";
37
+ import os from "node:os";
38
+ import { existsSync } from "node:fs";
39
+ import readline from "node:readline";
40
+ import { xmlEscape, systemdQuote, posixShellQuote, windowsCmdQuote, } from "../scheduler-backends/escaping.js";
41
+ import { pathApiForPlatform } from "../scheduler-backends/types.js";
42
+ import { resolveMcpShimInvocationForRuntime, MCP_SERVER_PACKAGE_NAME, } from "../mcp-server-invocation.js";
43
+ import { isTransientNpxEntryPath } from "../schedule-run.js";
44
+ import { parseExecutorArgs } from "./cli.js";
45
+ import { resolveBaseUrl, EXECUTOR_BASE_URL_REQUIRED_MESSAGE } from "./credentials.js";
46
+ // ---------------------------------------------------------------------------
47
+ // Fixed naming conventions (one service unit == one executor id)
48
+ // ---------------------------------------------------------------------------
49
+ /** launchd label prefix. The full label is `<prefix><executor-id>`. */
50
+ export const EXECUTOR_LAUNCHD_LABEL_PREFIX = "com.bridge-gpt.executor.";
51
+ /** launchd plist filename suffix. */
52
+ export const EXECUTOR_LAUNCHD_FILENAME_SUFFIX = ".plist";
53
+ /** systemd user-unit filename prefix. */
54
+ export const EXECUTOR_SYSTEMD_UNIT_PREFIX = "bridge-gpt-executor-";
55
+ /** systemd user-unit filename suffix. */
56
+ export const EXECUTOR_SYSTEMD_UNIT_SUFFIX = ".service";
57
+ /** The ONLY Bridge environment value baked into a generated unit. */
58
+ export const EXECUTOR_SERVICE_BASE_URL_ENV = "BAPI_BASE_URL";
59
+ /**
60
+ * The one NON-Bridge environment value baked into a generated unit: the
61
+ * generating shell's `PATH`, captured at render time so a service-launched
62
+ * executor can resolve the bare `claude` worker command (module docstring,
63
+ * invariant 1). Baked only when the captured value is non-empty — baking an
64
+ * empty PATH would be strictly worse than the service manager's default.
65
+ */
66
+ export const EXECUTOR_SERVICE_PATH_ENV = "PATH";
67
+ /** Home-relative directory segments holding Darwin LaunchAgents. */
68
+ const LAUNCHD_DIR_SEGMENTS = ["Library", "LaunchAgents"];
69
+ /** Home-relative directory segments holding Linux systemd user units. */
70
+ const SYSTEMD_DIR_SEGMENTS = [".config", "systemd", "user"];
71
+ // ---------------------------------------------------------------------------
72
+ // Path + label helpers (pure)
73
+ // ---------------------------------------------------------------------------
74
+ /** The launchd label for an executor id: `com.bridge-gpt.executor.<id>`. */
75
+ export function executorLaunchdLabelForId(executorId) {
76
+ return `${EXECUTOR_LAUNCHD_LABEL_PREFIX}${executorId}`;
77
+ }
78
+ /** Absolute LaunchAgents directory for a home dir (Darwin path semantics). */
79
+ export function executorLaunchdDirForHome(homeDir) {
80
+ return pathApiForPlatform("darwin").join(homeDir, ...LAUNCHD_DIR_SEGMENTS);
81
+ }
82
+ /** `~/Library/LaunchAgents/com.bridge-gpt.executor.<id>.plist`. */
83
+ export function executorLaunchdPlistPathForId(executorId, homeDir) {
84
+ const filename = `${executorLaunchdLabelForId(executorId)}${EXECUTOR_LAUNCHD_FILENAME_SUFFIX}`;
85
+ return pathApiForPlatform("darwin").join(executorLaunchdDirForHome(homeDir), filename);
86
+ }
87
+ /** The systemd unit name: `bridge-gpt-executor-<id>.service`. */
88
+ export function executorSystemdUnitNameForId(executorId) {
89
+ return `${EXECUTOR_SYSTEMD_UNIT_PREFIX}${executorId}${EXECUTOR_SYSTEMD_UNIT_SUFFIX}`;
90
+ }
91
+ /** Absolute systemd user-unit directory for a home dir (POSIX semantics). */
92
+ export function executorSystemdDirForHome(homeDir) {
93
+ return pathApiForPlatform("linux").join(homeDir, ...SYSTEMD_DIR_SEGMENTS);
94
+ }
95
+ /** `~/.config/systemd/user/bridge-gpt-executor-<id>.service`. */
96
+ export function executorSystemdUnitPathForId(executorId, homeDir) {
97
+ return pathApiForPlatform("linux").join(executorSystemdDirForHome(homeDir), executorSystemdUnitNameForId(executorId));
98
+ }
99
+ /**
100
+ * Recover the executor id from a LaunchAgents filename, or `null` when the name
101
+ * does not follow the fixed convention. Doctor uses this so enumeration and
102
+ * generation share one naming contract.
103
+ */
104
+ export function executorIdFromLaunchdFilename(filename) {
105
+ if (!filename.startsWith(EXECUTOR_LAUNCHD_LABEL_PREFIX) ||
106
+ !filename.endsWith(EXECUTOR_LAUNCHD_FILENAME_SUFFIX)) {
107
+ return null;
108
+ }
109
+ const id = filename.slice(EXECUTOR_LAUNCHD_LABEL_PREFIX.length, filename.length - EXECUTOR_LAUNCHD_FILENAME_SUFFIX.length);
110
+ return id.length > 0 ? id : null;
111
+ }
112
+ /** Recover the executor id from a systemd unit filename, or `null`. */
113
+ export function executorIdFromSystemdFilename(filename) {
114
+ if (!filename.startsWith(EXECUTOR_SYSTEMD_UNIT_PREFIX) ||
115
+ !filename.endsWith(EXECUTOR_SYSTEMD_UNIT_SUFFIX)) {
116
+ return null;
117
+ }
118
+ const id = filename.slice(EXECUTOR_SYSTEMD_UNIT_PREFIX.length, filename.length - EXECUTOR_SYSTEMD_UNIT_SUFFIX.length);
119
+ return id.length > 0 ? id : null;
120
+ }
121
+ // ---------------------------------------------------------------------------
122
+ // Validation (pure) — everything that becomes a filename or a unit directive
123
+ // ---------------------------------------------------------------------------
124
+ /** Thrown by the pure renderers/builder when an input cannot be safely rendered. */
125
+ export class ExecutorServiceValidationError extends Error {
126
+ constructor(message) {
127
+ super(message);
128
+ this.name = "ExecutorServiceValidationError";
129
+ }
130
+ }
131
+ /** Control characters (including NUL/CR/LF) are never legal in a rendered value. */
132
+ // eslint-disable-next-line no-control-regex
133
+ const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f]/;
134
+ /**
135
+ * systemd unit-name boundary. systemd accepts `[A-Za-z0-9:_.-]` (plus escape
136
+ * sequences) in a unit name; we additionally require an alphanumeric first
137
+ * character so a unit can never be mistaken for a flag or a dotfile. Invalid
138
+ * ids are REJECTED, never silently rewritten — rewriting would break the fixed
139
+ * one-unit-per-executor-id naming contract the operator and doctor both rely on.
140
+ */
141
+ const SYSTEMD_UNIT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.-]*$/;
142
+ /**
143
+ * Reject a value that cannot be interpolated into a unit directive at all.
144
+ *
145
+ * Every failure surfaces as an {@link ExecutorServiceValidationError} so the CLI
146
+ * has one error type to catch and sanitize. `CONTROL_CHAR_RE` covers NUL (which
147
+ * can truncate a written unit mid-file) alongside CR/LF (which could open a new
148
+ * directive) and the rest of the C0 range. The escaping helpers below re-check
149
+ * NUL independently, so this is the first of two barriers, not the only one.
150
+ */
151
+ function assertRenderableValue(value, label) {
152
+ if (typeof value !== "string" || value.length === 0) {
153
+ throw new ExecutorServiceValidationError(`${label} is required`);
154
+ }
155
+ if (CONTROL_CHAR_RE.test(value)) {
156
+ throw new ExecutorServiceValidationError(`${label} contains a control character (NUL, CR, LF, or similar), which could truncate the ` +
157
+ "unit or inject a directive");
158
+ }
159
+ }
160
+ /**
161
+ * Validate an executor id for the target platform. Darwin keeps XML
162
+ * metacharacters (`& < > " '` are legal filename characters there and are XML
163
+ * escaped at render time); Linux is held to the literal systemd unit-name
164
+ * boundary. Both reject path separators, control characters, and relative-path
165
+ * names so the id can never redirect the write.
166
+ */
167
+ export function validateExecutorId(executorId, platform) {
168
+ assertRenderableValue(executorId, "executor id");
169
+ if (executorId !== executorId.trim()) {
170
+ throw new ExecutorServiceValidationError("executor id must not have leading or trailing whitespace");
171
+ }
172
+ if (executorId.includes("/") || executorId.includes("\\")) {
173
+ throw new ExecutorServiceValidationError("executor id must not contain a path separator ('/' or '\\')");
174
+ }
175
+ if (executorId === "." || executorId === "..") {
176
+ throw new ExecutorServiceValidationError("executor id must not be a relative path segment");
177
+ }
178
+ if (executorId.length > 128) {
179
+ throw new ExecutorServiceValidationError("executor id must be 128 characters or fewer");
180
+ }
181
+ if (platform === "linux" && !SYSTEMD_UNIT_ID_RE.test(executorId)) {
182
+ throw new ExecutorServiceValidationError(`executor id '${executorId}' is not a valid systemd unit-name fragment: ` +
183
+ "use only letters, digits, ':', '_', '.', or '-', starting with a letter or digit " +
184
+ "(the id is never rewritten, because one executor id maps to exactly one unit name)");
185
+ }
186
+ }
187
+ /** Validate the full render input without producing an artifact. */
188
+ export function validateExecutorServiceRenderInput(input, platform) {
189
+ try {
190
+ validateExecutorId(input.executorId, platform);
191
+ assertRenderableValue(input.homeDir, "home directory");
192
+ assertRenderableValue(input.baseUrl, "base URL");
193
+ // Whitespace is never legitimate inside a URL, and a base URL is the one
194
+ // rendered value that has a strict grammar. Rejecting it here means callers
195
+ // other than the CLI (which additionally does a full `new URL(...)` check)
196
+ // still cannot bake a malformed endpoint into a durable unit.
197
+ if (/\s/.test(input.baseUrl)) {
198
+ throw new ExecutorServiceValidationError("base URL must not contain whitespace");
199
+ }
200
+ assertRenderableValue(input.invocation.executable, "service executable");
201
+ // PATH is optional (empty means "omit the directive"), but a present value
202
+ // is held to the same control-character barrier as every rendered value: a
203
+ // CR/LF or NUL in PATH could open a new directive or truncate the unit.
204
+ if (input.envPath !== undefined && input.envPath.length > 0) {
205
+ assertRenderableValue(input.envPath, "PATH value");
206
+ }
207
+ if (input.repos.length === 0) {
208
+ throw new ExecutorServiceValidationError("at least one repository is required");
209
+ }
210
+ for (const repo of input.repos)
211
+ assertRenderableValue(repo, "repository name");
212
+ for (const arg of input.invocation.prefixArgs)
213
+ assertRenderableValue(arg, "service argument");
214
+ return { ok: true };
215
+ }
216
+ catch (error) {
217
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
218
+ }
219
+ }
220
+ function assertValidRenderInput(input, platform) {
221
+ const result = validateExecutorServiceRenderInput(input, platform);
222
+ if (!result.ok)
223
+ throw new ExecutorServiceValidationError(result.error);
224
+ }
225
+ /**
226
+ * Build the executor daemon argument vector: the packaged prefix, then one
227
+ * `--repo <name>` pair per repository, then `--executor-id <id>`. An argument
228
+ * ARRAY, never a concatenated shell command — and repository *names*, never
229
+ * repository paths, because the executor's identity is the id, not a checkout.
230
+ */
231
+ export function buildExecutorServiceArguments(input) {
232
+ const args = [...input.invocation.prefixArgs];
233
+ for (const repo of input.repos)
234
+ args.push("--repo", repo);
235
+ args.push("--executor-id", input.executorId);
236
+ return args;
237
+ }
238
+ // ---------------------------------------------------------------------------
239
+ // Renderers (pure)
240
+ // ---------------------------------------------------------------------------
241
+ /**
242
+ * Render the launchd LaunchAgent plist for one executor id. `RunAtLoad` and
243
+ * `KeepAlive` make it a long-running daemon that comes back after a crash or a
244
+ * login. Every interpolated value is XML escaped. When the render input carries
245
+ * a non-empty `envPath`, the generating shell's `PATH` is baked alongside the
246
+ * base URL (module docstring, invariant 1) so the executor's worker spawn can
247
+ * resolve `claude` under launchd's minimal default environment.
248
+ */
249
+ export function renderExecutorLaunchdPlist(input) {
250
+ assertValidRenderInput(input, "darwin");
251
+ const label = executorLaunchdLabelForId(input.executorId);
252
+ const programArgs = [input.invocation.executable, ...buildExecutorServiceArguments(input)]
253
+ .map((arg) => ` <string>${xmlEscape(arg)}</string>`)
254
+ .join("\n");
255
+ const envEntries = [
256
+ ` <key>${EXECUTOR_SERVICE_BASE_URL_ENV}</key>`,
257
+ ` <string>${xmlEscape(input.baseUrl)}</string>`,
258
+ ];
259
+ if (input.envPath !== undefined && input.envPath.length > 0) {
260
+ envEntries.push(` <key>${EXECUTOR_SERVICE_PATH_ENV}</key>`, ` <string>${xmlEscape(input.envPath)}</string>`);
261
+ }
262
+ return [
263
+ '<?xml version="1.0" encoding="UTF-8"?>',
264
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
265
+ '<plist version="1.0">',
266
+ "<dict>",
267
+ " <key>Label</key>",
268
+ ` <string>${xmlEscape(label)}</string>`,
269
+ " <key>EnvironmentVariables</key>",
270
+ " <dict>",
271
+ ...envEntries,
272
+ " </dict>",
273
+ " <key>ProgramArguments</key>",
274
+ " <array>",
275
+ programArgs,
276
+ " </array>",
277
+ " <key>RunAtLoad</key>",
278
+ " <true/>",
279
+ " <key>KeepAlive</key>",
280
+ " <true/>",
281
+ "</dict>",
282
+ "</plist>",
283
+ "",
284
+ ].join("\n");
285
+ }
286
+ /**
287
+ * Escape a value for a systemd directive. {@link systemdQuote} handles
288
+ * backslashes and double quotes; `%` is additionally doubled because systemd
289
+ * expands `%`-specifiers (`%h`, `%i`, …) inside `ExecStart` and `Environment`.
290
+ */
291
+ export function systemdDirectiveValue(value) {
292
+ return systemdQuote(value.replace(/%/g, "%%"));
293
+ }
294
+ /**
295
+ * Render the systemd `--user` unit for one executor id. `Type=simple` +
296
+ * `Restart=always` make it a supervised long-running service; `[Install]` /
297
+ * `WantedBy=default.target` lets the operator `systemctl --user enable` it — the
298
+ * installer never runs that command itself. When the render input carries a
299
+ * non-empty `envPath`, the generating shell's `PATH` is baked alongside the base
300
+ * URL (module docstring, invariant 1) so the executor's worker spawn can resolve
301
+ * `claude` under the `--user` manager's minimal default environment. Like every
302
+ * directive value it goes through {@link systemdDirectiveValue}, so `%` is
303
+ * doubled against specifier expansion.
304
+ */
305
+ export function renderExecutorSystemdService(input) {
306
+ assertValidRenderInput(input, "linux");
307
+ const execStart = [input.invocation.executable, ...buildExecutorServiceArguments(input)]
308
+ .map(systemdDirectiveValue)
309
+ .join(" ");
310
+ const environmentLines = [
311
+ `Environment=${EXECUTOR_SERVICE_BASE_URL_ENV}=${systemdDirectiveValue(input.baseUrl)}`,
312
+ ];
313
+ if (input.envPath !== undefined && input.envPath.length > 0) {
314
+ environmentLines.push(`Environment=${EXECUTOR_SERVICE_PATH_ENV}=${systemdDirectiveValue(input.envPath)}`);
315
+ }
316
+ return [
317
+ "[Unit]",
318
+ `Description=${systemdDirectiveValue(`Bridge GPT epic-conductor executor (${input.executorId})`)}`,
319
+ "After=network-online.target",
320
+ "",
321
+ "[Service]",
322
+ "Type=simple",
323
+ ...environmentLines,
324
+ `ExecStart=${execStart}`,
325
+ "Restart=always",
326
+ "RestartSec=10",
327
+ "",
328
+ "[Install]",
329
+ "WantedBy=default.target",
330
+ "",
331
+ ].join("\n");
332
+ }
333
+ /**
334
+ * Build the single artifact for a supported persistent-service platform. Pure:
335
+ * it derives the deterministic path and renders the content, and rejects any
336
+ * platform that has no generated-unit format rather than guessing one.
337
+ */
338
+ export function buildExecutorServiceArtifact(input, platform) {
339
+ if (platform === "darwin") {
340
+ return {
341
+ path: executorLaunchdPlistPathForId(input.executorId, input.homeDir),
342
+ content: renderExecutorLaunchdPlist(input),
343
+ kind: "launchd-plist",
344
+ };
345
+ }
346
+ if (platform === "linux") {
347
+ return {
348
+ path: executorSystemdUnitPathForId(input.executorId, input.homeDir),
349
+ content: renderExecutorSystemdService(input),
350
+ kind: "systemd-service",
351
+ };
352
+ }
353
+ throw new ExecutorServiceValidationError(`platform '${platform}' has no generated executor service format ` +
354
+ "(persistent units are generated for darwin and linux only)");
355
+ }
356
+ /** Reverse {@link xmlEscape} for the five entities it emits. */
357
+ function xmlUnescape(value) {
358
+ return value
359
+ .replace(/&lt;/g, "<")
360
+ .replace(/&gt;/g, ">")
361
+ .replace(/&quot;/g, '"')
362
+ .replace(/&apos;/g, "'")
363
+ .replace(/&amp;/g, "&");
364
+ }
365
+ /** Pull `--repo`/`--executor-id` values out of a recovered argument vector. */
366
+ function readArgumentContract(args) {
367
+ const repos = [];
368
+ let executorId = null;
369
+ for (let i = 0; i < args.length; i++) {
370
+ if (args[i] === "--repo" && typeof args[i + 1] === "string") {
371
+ repos.push(args[++i]);
372
+ }
373
+ else if (args[i] === "--executor-id" && typeof args[i + 1] === "string") {
374
+ executorId = args[++i];
375
+ }
376
+ }
377
+ return { repos, executorId };
378
+ }
379
+ /** Inspect a plist produced by {@link renderExecutorLaunchdPlist}. */
380
+ function inspectLaunchdPlist(content) {
381
+ const problems = [];
382
+ const envMatch = content.match(new RegExp(`<key>${EXECUTOR_SERVICE_BASE_URL_ENV}</key>\\s*<string>([^<]*)</string>`));
383
+ const baseUrl = envMatch ? xmlUnescape(envMatch[1]) : null;
384
+ if (baseUrl === null) {
385
+ problems.push(`no ${EXECUTOR_SERVICE_BASE_URL_ENV} entry found in the plist`);
386
+ }
387
+ const arrayMatch = content.match(/<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/);
388
+ let args = [];
389
+ if (arrayMatch) {
390
+ args = Array.from(arrayMatch[1].matchAll(/<string>([^<]*)<\/string>/g)).map((m) => xmlUnescape(m[1]));
391
+ }
392
+ else {
393
+ problems.push("no ProgramArguments array found in the plist");
394
+ }
395
+ const { repos, executorId } = readArgumentContract(args);
396
+ return { ok: problems.length === 0, baseUrl, repos, executorId, problems };
397
+ }
398
+ /**
399
+ * Split a systemd directive value into tokens, honouring the double-quoted form
400
+ * {@link systemdDirectiveValue} emits (backslash escapes inside quotes, `%%`
401
+ * collapsing to a literal `%`).
402
+ */
403
+ function splitSystemdTokens(value) {
404
+ const tokens = [];
405
+ let current = "";
406
+ let inQuotes = false;
407
+ let started = false;
408
+ for (let i = 0; i < value.length; i++) {
409
+ const ch = value[i];
410
+ if (ch === "\\" && i + 1 < value.length) {
411
+ current += value[++i];
412
+ started = true;
413
+ }
414
+ else if (ch === "%" && value[i + 1] === "%") {
415
+ current += "%";
416
+ i++;
417
+ started = true;
418
+ }
419
+ else if (ch === '"') {
420
+ inQuotes = !inQuotes;
421
+ started = true;
422
+ }
423
+ else if (!inQuotes && /\s/.test(ch)) {
424
+ if (started)
425
+ tokens.push(current);
426
+ current = "";
427
+ started = false;
428
+ }
429
+ else {
430
+ current += ch;
431
+ started = true;
432
+ }
433
+ }
434
+ if (started)
435
+ tokens.push(current);
436
+ return tokens;
437
+ }
438
+ /** Inspect a unit produced by {@link renderExecutorSystemdService}. */
439
+ function inspectSystemdService(content) {
440
+ const problems = [];
441
+ const lines = content.split(/\r?\n/);
442
+ const envLine = lines.find((l) => l.startsWith(`Environment=${EXECUTOR_SERVICE_BASE_URL_ENV}=`));
443
+ let baseUrl = null;
444
+ if (envLine) {
445
+ const raw = envLine.slice(`Environment=${EXECUTOR_SERVICE_BASE_URL_ENV}=`.length);
446
+ baseUrl = splitSystemdTokens(raw)[0] ?? null;
447
+ }
448
+ if (baseUrl === null || baseUrl.length === 0) {
449
+ baseUrl = null;
450
+ problems.push(`no ${EXECUTOR_SERVICE_BASE_URL_ENV} directive found in the unit`);
451
+ }
452
+ const execLine = lines.find((l) => l.startsWith("ExecStart="));
453
+ let args = [];
454
+ if (execLine) {
455
+ args = splitSystemdTokens(execLine.slice("ExecStart=".length));
456
+ }
457
+ else {
458
+ problems.push("no ExecStart directive found in the unit");
459
+ }
460
+ const { repos, executorId } = readArgumentContract(args);
461
+ return { ok: problems.length === 0, baseUrl, repos, executorId, problems };
462
+ }
463
+ /**
464
+ * Recover the persisted contract (base URL, repositories, executor id) from an
465
+ * artifact this module generated. Doctor calls this instead of maintaining a
466
+ * second parser, so a renderer change can never silently desync the diagnostic.
467
+ * Malformed content yields a sanitized `problems` list — it never throws and
468
+ * never invents values.
469
+ */
470
+ export function inspectExecutorServiceArtifact(kind, content) {
471
+ try {
472
+ return kind === "launchd-plist" ? inspectLaunchdPlist(content) : inspectSystemdService(content);
473
+ }
474
+ catch {
475
+ return {
476
+ ok: false,
477
+ baseUrl: null,
478
+ repos: [],
479
+ executorId: null,
480
+ problems: ["unit content could not be parsed"],
481
+ };
482
+ }
483
+ }
484
+ /**
485
+ * Resolve the packaged launcher a persistent unit should bake in: this process's
486
+ * own absolute Node executable plus the absolute compiled `index.js`, followed by
487
+ * the `executor` subcommand token.
488
+ *
489
+ * A persistent unit must survive a reboot with no login shell, so the two unsafe
490
+ * forms are refused rather than written: the npm-channel (`npx`) fallback, which
491
+ * is a PATH-relative command that also re-resolves the package on every launch,
492
+ * and an entry path inside a prunable `_npx` cache directory.
493
+ */
494
+ export function resolvePackagedExecutorInvocation(deps) {
495
+ const resolved = resolveMcpShimInvocationForRuntime({
496
+ moduleUrl: deps?.moduleUrl ?? import.meta.url,
497
+ nodeExecutable: deps?.execPath ?? process.execPath,
498
+ argv1: deps?.argv1 ?? process.argv[1],
499
+ fileExists: deps?.fileExists ?? ((p) => existsSync(p)),
500
+ });
501
+ if (resolved.form !== "absolute-build-path") {
502
+ return {
503
+ ok: false,
504
+ error: "could not resolve an absolute packaged CLI path for a persistent service. " +
505
+ `A service unit must not launch through 'npx ${MCP_SERVER_PACKAGE_NAME}', which depends on ` +
506
+ "PATH and a prunable cache. Install the CLI persistently " +
507
+ `(e.g. 'npm i -g ${MCP_SERVER_PACKAGE_NAME}') and re-run install-service from that binary.`,
508
+ };
509
+ }
510
+ if (isTransientNpxEntryPath(resolved.serverEntryPath)) {
511
+ return {
512
+ ok: false,
513
+ error: `the running CLI lives in an npx cache directory ('${resolved.serverEntryPath}'), which can be ` +
514
+ "pruned at any time — a service unit baked with that path would fail with ENOENT after a reboot. " +
515
+ `Install the CLI persistently (e.g. 'npm i -g ${MCP_SERVER_PACKAGE_NAME}') and re-run install-service.`,
516
+ };
517
+ }
518
+ return {
519
+ ok: true,
520
+ invocation: {
521
+ executable: resolved.nodeExecutable,
522
+ prefixArgs: [resolved.serverEntryPath, "executor"],
523
+ },
524
+ };
525
+ }
526
+ // ---------------------------------------------------------------------------
527
+ // `executor install-service` CLI
528
+ // ---------------------------------------------------------------------------
529
+ /** User-facing usage for `executor install-service`. */
530
+ export function getExecutorInstallServiceUsage() {
531
+ return [
532
+ "Usage: mcp-server executor install-service --executor-id <id> --repo <name> [--repo <name> ...] [options]",
533
+ "",
534
+ "Generates ONE long-running user-domain service unit for ONE stable executor id:",
535
+ " macOS ~/Library/LaunchAgents/com.bridge-gpt.executor.<id>.plist",
536
+ " Linux ~/.config/systemd/user/bridge-gpt-executor-<id>.service",
537
+ " Windows manual Task Scheduler guidance is printed; nothing is written.",
538
+ "",
539
+ "Required:",
540
+ " --executor-id <id> Stable, explicit executor id. A persistent unit",
541
+ " needs a durable identity, so the foreground",
542
+ " <hostname>-<pid> default is never used here.",
543
+ " --repo <name> Repo to serve (repeatable; --repos=a,b also works).",
544
+ " --base-url <url> Bridge API endpoint, or set BAPI_BASE_URL.",
545
+ " There is NO production default.",
546
+ "",
547
+ "Options:",
548
+ " --dry-run Print the target path and the complete unit body.",
549
+ " Performs no consent prompt and no filesystem write.",
550
+ " --yes Explicit consent to create the unit's parent",
551
+ " directory and write the unit. Required when stdin",
552
+ " is not a TTY.",
553
+ " -h, --help Show this help.",
554
+ "",
555
+ "The generated unit contains only the base URL, the generating shell's PATH, the",
556
+ "repositories, the executor id, and the packaged invocation. PATH is baked because",
557
+ "the service manager's minimal default cannot resolve the `claude` worker command.",
558
+ "BAPI_API_KEY and every credential-store value are deliberately omitted —",
559
+ "credentials resolve at launch time from the environment or the user-scoped",
560
+ "bapi:<repo> credential store.",
561
+ "",
562
+ "This command writes a file and nothing else. Starting, stopping, enabling, and",
563
+ "removing the service stay operator-managed via launchctl / systemctl --user /",
564
+ "Task Scheduler.",
565
+ ].join("\n");
566
+ }
567
+ /** Default, production dependency set for the install-service CLI. */
568
+ export function createDefaultExecutorServiceInstallDeps() {
569
+ return {
570
+ platform: process.platform,
571
+ env: process.env,
572
+ homeDir: os.homedir(),
573
+ parseContext: {
574
+ hostname: os.hostname(),
575
+ pid: process.pid,
576
+ platform: process.platform,
577
+ env: process.env,
578
+ },
579
+ isTTY: Boolean(process.stdin.isTTY),
580
+ resolveInvocation: () => resolvePackagedExecutorInvocation(),
581
+ stat: (filePath) => fsStat(filePath),
582
+ mkdir: (dirPath, options) => fsMkdir(dirPath, options),
583
+ writeFile: (filePath, content) => fsWriteFile(filePath, content, "utf-8"),
584
+ prompt: defaultConsentPrompt,
585
+ log: (message) => console.log(message),
586
+ errorLog: (message) => console.error(message),
587
+ };
588
+ }
589
+ /** Default-No stdin consent prompt (only reached on a TTY). */
590
+ async function defaultConsentPrompt(question) {
591
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
592
+ try {
593
+ const answer = await new Promise((resolve) => rl.question(`${question} [y/N] `, resolve));
594
+ return /^y(es)?$/i.test(answer.trim());
595
+ }
596
+ finally {
597
+ rl.close();
598
+ }
599
+ }
600
+ /**
601
+ * Recognize only `--yes`, `--dry-run`, and help; forward everything else.
602
+ *
603
+ * Stripping is what makes `--base-url --yes` fail instead of persisting the
604
+ * literal string `--yes` as a URL: with `--yes` removed, `--base-url` has no
605
+ * value left and the executor parser rejects it.
606
+ */
607
+ export function preprocessInstallServiceFlags(argv) {
608
+ let yes = false;
609
+ let dryRun = false;
610
+ let help = false;
611
+ const forwardArgs = [];
612
+ for (const arg of argv) {
613
+ if (arg === "--yes")
614
+ yes = true;
615
+ else if (arg === "--dry-run")
616
+ dryRun = true;
617
+ else if (arg === "--help" || arg === "-h")
618
+ help = true;
619
+ else
620
+ forwardArgs.push(arg);
621
+ }
622
+ return { yes, dryRun, help, forwardArgs };
623
+ }
624
+ /** Runtime modes that are incoherent for a supervised long-running service. */
625
+ const SERVICE_INCOMPATIBLE_FLAGS = ["--once"];
626
+ /**
627
+ * Write exactly one rendered artifact: stat the target for create-vs-replace
628
+ * messaging, create ONLY the unit's parent directory, and write ONLY the unit.
629
+ * Runs no `launchctl` / `systemctl` / `loginctl` / `schtasks` — lifecycle
630
+ * management is deliberately not this command's job.
631
+ */
632
+ export async function writeExecutorServiceArtifact(artifact, deps, knownExists) {
633
+ const replaced = knownExists ?? (await targetExists(deps.stat, artifact.path));
634
+ const pathApi = pathApiForPlatform(deps.platform);
635
+ await deps.mkdir(pathApi.dirname(artifact.path), { recursive: true });
636
+ await deps.writeFile(artifact.path, artifact.content);
637
+ return { replaced };
638
+ }
639
+ /** ENOENT means absent; any other rejection is a real failure and propagates. */
640
+ async function targetExists(stat, filePath) {
641
+ try {
642
+ await stat(filePath);
643
+ return true;
644
+ }
645
+ catch (error) {
646
+ const code = error?.code;
647
+ if (code === "ENOENT" || code === "ENOTDIR")
648
+ return false;
649
+ throw error;
650
+ }
651
+ }
652
+ /** Sanitize a thrown value into a short, secret-free CLI message. */
653
+ function sanitizeError(error) {
654
+ const message = error instanceof Error ? error.message : String(error);
655
+ return message.slice(0, 300);
656
+ }
657
+ /**
658
+ * CLI entry for `executor install-service`. Returns a numeric exit code and
659
+ * contains every top-level failure rather than throwing through the process
660
+ * boundary. Ordering matters: EVERY user-supplied value is validated before the
661
+ * first `stat` / `mkdir` / `writeFile`, and `--dry-run` returns before both
662
+ * consent and filesystem access.
663
+ */
664
+ export async function runExecutorInstallServiceCli(argv, overrides) {
665
+ const deps = {
666
+ ...createDefaultExecutorServiceInstallDeps(),
667
+ ...overrides,
668
+ };
669
+ const flags = preprocessInstallServiceFlags(argv);
670
+ if (flags.help) {
671
+ deps.log(getExecutorInstallServiceUsage());
672
+ return 0;
673
+ }
674
+ // --- Validation phase: nothing below touches the filesystem. ---
675
+ if (!flags.forwardArgs.includes("--executor-id")) {
676
+ deps.errorLog("Error: --executor-id is required by install-service. A persistent unit needs a stable, " +
677
+ "explicit executor id (one unit maps to exactly one id); the foreground executor's " +
678
+ "<hostname>-<pid> default is never baked into durable state.");
679
+ deps.errorLog("");
680
+ deps.errorLog(getExecutorInstallServiceUsage());
681
+ return 1;
682
+ }
683
+ for (const flag of SERVICE_INCOMPATIBLE_FLAGS) {
684
+ if (flags.forwardArgs.includes(flag)) {
685
+ deps.errorLog(`Error: ${flag} is incompatible with install-service — a supervised service runs ` +
686
+ "continuously, so a single-cycle mode would be silently dropped from the generated unit.");
687
+ return 1;
688
+ }
689
+ }
690
+ const parsed = parseExecutorArgs(flags.forwardArgs, deps.parseContext);
691
+ if (parsed.kind === "help") {
692
+ deps.log(getExecutorInstallServiceUsage());
693
+ return 0;
694
+ }
695
+ if (parsed.kind === "error") {
696
+ deps.errorLog(`Error: ${parsed.message}`);
697
+ deps.errorLog("");
698
+ deps.errorLog(getExecutorInstallServiceUsage());
699
+ return 1;
700
+ }
701
+ const options = parsed.options;
702
+ const baseUrlResult = resolveBaseUrl(deps.env, options.baseUrl);
703
+ if (!baseUrlResult.ok) {
704
+ deps.errorLog(`Error: ${EXECUTOR_BASE_URL_REQUIRED_MESSAGE}`);
705
+ return 1;
706
+ }
707
+ const baseUrl = baseUrlResult.baseUrl;
708
+ let parsedUrl;
709
+ try {
710
+ parsedUrl = new URL(baseUrl);
711
+ }
712
+ catch {
713
+ deps.errorLog(`Error: --base-url / ${EXECUTOR_SERVICE_BASE_URL_ENV} must be an absolute http(s) URL; ` +
714
+ "the supplied value is not a valid URL.");
715
+ return 1;
716
+ }
717
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
718
+ deps.errorLog(`Error: --base-url / ${EXECUTOR_SERVICE_BASE_URL_ENV} must use http: or https: ` +
719
+ `(got '${parsedUrl.protocol}').`);
720
+ return 1;
721
+ }
722
+ if (deps.platform !== "darwin" && deps.platform !== "linux" && deps.platform !== "win32") {
723
+ deps.errorLog(`Error: install-service supports darwin, linux, and win32 (manual guidance); ` +
724
+ `'${deps.platform}' has no supported persistent-service format.`);
725
+ return 1;
726
+ }
727
+ try {
728
+ validateExecutorId(options.executorId, deps.platform);
729
+ }
730
+ catch (error) {
731
+ deps.errorLog(`Error: ${sanitizeError(error)}`);
732
+ return 1;
733
+ }
734
+ const invocationResult = deps.resolveInvocation();
735
+ if (!invocationResult.ok) {
736
+ deps.errorLog(`Error: ${invocationResult.error}`);
737
+ return 1;
738
+ }
739
+ // Capture the generating shell's PATH once, at render time (the
740
+ // scheduler-backends precedent, BAPI-327): the service manager's default PATH
741
+ // cannot resolve the bare `claude` worker command. `Path` is the Windows
742
+ // spelling; a missing/empty value simply omits the directive.
743
+ const envPath = deps.env.PATH ?? deps.env.Path ?? "";
744
+ const renderInput = {
745
+ invocation: invocationResult.invocation,
746
+ executorId: options.executorId,
747
+ repos: options.repos,
748
+ baseUrl,
749
+ homeDir: deps.homeDir,
750
+ envPath,
751
+ };
752
+ // Windows has no generated user-service format here: print the fully resolved,
753
+ // secret-free command line plus manual Task Scheduler guidance and stop. No
754
+ // consent is requested because nothing is written.
755
+ if (deps.platform === "win32") {
756
+ const args = buildExecutorServiceArguments(renderInput);
757
+ deps.log(formatWindowsGuidance(renderInput, args));
758
+ return 0;
759
+ }
760
+ let artifact;
761
+ try {
762
+ artifact = buildExecutorServiceArtifact(renderInput, deps.platform);
763
+ }
764
+ catch (error) {
765
+ deps.errorLog(`Error: ${sanitizeError(error)}`);
766
+ return 1;
767
+ }
768
+ // --- Dry run: a complete preview, before consent and before any fs call. ---
769
+ if (flags.dryRun) {
770
+ deps.log(`Target: ${artifact.path}`);
771
+ deps.log("");
772
+ deps.log(artifact.content);
773
+ deps.log("(dry run — nothing was written)");
774
+ return 0;
775
+ }
776
+ // --- Consent phase. ---
777
+ if (!flags.yes && !deps.isTTY) {
778
+ deps.errorLog(`Error: writing ${artifact.path} needs explicit consent, but stdin is not a TTY. ` +
779
+ "Re-run with --yes to authorize the write, or with --dry-run to preview the unit.");
780
+ return 1;
781
+ }
782
+ let exists;
783
+ try {
784
+ exists = await targetExists(deps.stat, artifact.path);
785
+ }
786
+ catch (error) {
787
+ deps.errorLog(`Error: could not inspect ${artifact.path}: ${sanitizeError(error)}`);
788
+ return 1;
789
+ }
790
+ if (!flags.yes) {
791
+ const action = exists ? "Replace" : "Create";
792
+ let approved = false;
793
+ try {
794
+ approved = await deps.prompt(`${action} executor service unit ${artifact.path}?`);
795
+ }
796
+ catch (error) {
797
+ deps.errorLog(`Error: could not read consent: ${sanitizeError(error)}`);
798
+ return 1;
799
+ }
800
+ if (!approved) {
801
+ deps.errorLog("Service installation was not confirmed; nothing was written.");
802
+ return 1;
803
+ }
804
+ }
805
+ // --- Write phase: parent directory + the single unit, nothing else. ---
806
+ let replaced = false;
807
+ try {
808
+ ({ replaced } = await writeExecutorServiceArtifact(artifact, deps, exists));
809
+ }
810
+ catch (error) {
811
+ deps.errorLog(`Error: could not write ${artifact.path}: ${sanitizeError(error)}`);
812
+ return 1;
813
+ }
814
+ deps.log(`${replaced ? "Replaced" : "Created"} ${artifact.path}`);
815
+ deps.log("");
816
+ deps.log(formatPostWriteGuidance(artifact, options.executorId));
817
+ return 0;
818
+ }
819
+ // ---------------------------------------------------------------------------
820
+ // Operator guidance formatters.
821
+ //
822
+ // These print command lines a human copy-pastes into a real shell, so they are
823
+ // the ONE surface in this module where a value becomes shell syntax rather than
824
+ // an argument-array element or an escaped unit directive. Executor ids stay
825
+ // deliberately permissive on Darwin (XML metacharacters are valid filename
826
+ // characters there — see `validateExecutorId`), and repository names are only
827
+ // checked for control characters, so every externally-supplied value MUST be
828
+ // shell-quoted here rather than assumed safe.
829
+ //
830
+ // Quoting reuses the escaping helpers the scheduler backends already ship:
831
+ // `posixShellQuote` (single-quote form, `'\''` for embedded quotes) and
832
+ // `windowsCmdQuote` (double-quote form, `""` for embedded quotes). The literal
833
+ // `$(id -u)` in the launchctl templates is intentionally left UNQUOTED — it is
834
+ // command substitution the operator wants evaluated, not interpolated data.
835
+ // ---------------------------------------------------------------------------
836
+ /** Manual Task Scheduler guidance plus the resolved, secret-free command line. */
837
+ function formatWindowsGuidance(input, args) {
838
+ // Quote EVERY token, not just the ones containing whitespace: the previous
839
+ // whitespace-only rule left embedded double quotes unescaped, so a value could
840
+ // close its own quoted token and append a command when pasted.
841
+ const commandLine = [input.invocation.executable, ...args].map(windowsCmdQuote).join(" ");
842
+ return [
843
+ "Windows: install-service does not create a service unit automatically.",
844
+ "Register the executor as a Task Scheduler task manually, using this resolved command line:",
845
+ "",
846
+ ` ${commandLine}`,
847
+ "",
848
+ `Set the task's environment variable ${EXECUTOR_SERVICE_BASE_URL_ENV}=${windowsCmdQuote(input.baseUrl)}`,
849
+ "and configure the task to run at logon and restart on failure.",
850
+ "",
851
+ "The command line above carries no credential. Provide the Bridge API key at run time via",
852
+ "the BAPI_API_KEY environment variable or the user-scoped bapi:<repo> credential store.",
853
+ ].join("\n");
854
+ }
855
+ /** Post-write operator guidance — naming the manual lifecycle step, not running it. */
856
+ function formatPostWriteGuidance(artifact, executorId) {
857
+ if (artifact.kind === "launchd-plist") {
858
+ const label = posixShellQuote(executorLaunchdLabelForId(executorId));
859
+ return [
860
+ "Next step (manual — install-service never manages lifecycle):",
861
+ ` launchctl bootstrap gui/$(id -u) ${posixShellQuote(artifact.path)}`,
862
+ ` launchctl print gui/$(id -u)/${label} # status`,
863
+ ` launchctl bootout gui/$(id -u)/${label} # stop + unload`,
864
+ ].join("\n");
865
+ }
866
+ const unit = posixShellQuote(executorSystemdUnitNameForId(executorId));
867
+ return [
868
+ "Next step (manual — install-service never manages lifecycle):",
869
+ " systemctl --user daemon-reload",
870
+ ` systemctl --user enable --now ${unit}`,
871
+ ` systemctl --user status ${unit}`,
872
+ "",
873
+ "On a headless host, also run `loginctl enable-linger $USER` so the user service",
874
+ "keeps running without an active login session.",
875
+ ].join("\n");
876
+ }