@brutalsystems/muster 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/run.js CHANGED
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { spawn } from "node:child_process";
6
6
  import { loadConfig } from "./config.js";
7
- import { runSchema, agentBaseEnv, assertAllowedWorkspace, assertLevelExclusive, resolveLifecycle, resolveModel, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, runtimeArgs, } from "./guard.js";
7
+ import { runSchema, agentBaseEnv, assertAllowedWorkspace, assertLevelExclusive, resolveLifecycle, resolveModel, launchArgs, launchEnv, paramsFingerprint, resolvePermissions, resolveTerminal, runtimeArgs, } from "./guard.js";
8
8
  import { enforcementGrade } from "./enforcement.js";
9
9
  import { LOCAL } from "./requester.js";
10
10
  import { assertEnforcementMeetsMinimum, assertLevelWithinCeiling, assertMayAct, resolveIdentityForRequest, resolveRequesterPolicy, RequesterPolicyRefusal, } from "./requester-policy.js";
@@ -27,7 +27,7 @@ import { CodexRpc } from "./codex-rpc.js";
27
27
  import { allocateLoopbackPort, OpenCodeHttp } from "./opencode-http.js";
28
28
  import { assertOpenCodeVersion, openCodeMcpNames, openCodeSessionLaunch, openCodeTaskLaunch, } from "./opencode-policy.js";
29
29
  import { assignNames, resolvePeer, runtimeName, suffixOf, } from "./naming.js";
30
- import { selectMcp, prepareMcp, mcpEnvironment } from "./mcp.js";
30
+ import { selectMcp, prepareMcp, mcpEnvironment, } from "./mcp.js";
31
31
  import { pollInterval } from "./reap.js";
32
32
  import { selectPlugins } from "./plugins.js";
33
33
  function taskHandle(entry, pid) {
@@ -98,7 +98,8 @@ export class Muster {
98
98
  }
99
99
  static async create(opts = {}) {
100
100
  const env = launchEnv(opts.env ?? process.env), home = opts.home ?? join(env.HOME ?? homedir(), ".muster");
101
- return new Muster(await loadConfig(home), home, env, opts.drivers ?? hosts(opts.env ?? process.env), { ...(opts.env ?? process.env) });
101
+ const config = await loadConfig(home);
102
+ return new Muster(config, home, env, opts.drivers ?? hosts(opts.env ?? process.env, config.tmux_status), { ...(opts.env ?? process.env) });
102
103
  }
103
104
  run(input, requester = LOCAL) {
104
105
  if (this.closing)
@@ -107,7 +108,14 @@ export class Muster {
107
108
  this.active.add(operation);
108
109
  return operation.finally(() => this.active.delete(operation));
109
110
  }
110
- async launch(input, requester) {
111
+ /**
112
+ * Phase 1 of a launch: decide whether this caller may ask for this at all,
113
+ * and settle what "this" is. Everything here refuses BEFORE anything is
114
+ * reserved, spawned or written, which is why it reads the raw input in places
115
+ * rather than the parsed request — once defaults have been applied, a value
116
+ * Muster chose is indistinguishable from one the caller sent.
117
+ */
118
+ async authorize(input, requester) {
111
119
  // Read before parsing, because cwd defaults during it and a default is then
112
120
  // indistinguishable from a directory the caller asked for. Naming both a
113
121
  // project and a directory is a contradiction worth reporting rather than
@@ -171,6 +179,15 @@ export class Muster {
171
179
  const level = req.level ?? policy.level;
172
180
  if (level)
173
181
  assertLevelWithinCeiling(level, policy);
182
+ return { req, policy, level };
183
+ }
184
+ /**
185
+ * Phase 2: the read-only half of identity resolution. Validated before
186
+ * anything is reserved or spawned, so a bad identity leaves no registry entry
187
+ * behind. The per-launch COPY cannot be made here — it is named after the
188
+ * launch id, which only `reserve` produces — so that half happens later.
189
+ */
190
+ async resolveIdentity(req, policy) {
174
191
  // Validated here, before anything is reserved or spawned: a bad identity
175
192
  // must leave no registry entry behind. The copy cannot be made yet — it is
176
193
  // named after the launch id, which only `reserve` produces — so this is the
@@ -200,6 +217,15 @@ export class Muster {
200
217
  : "has no credential — run `setup-identity --interactive` to log in and store a token");
201
218
  }
202
219
  }
220
+ return { identityName, identityMeta, credential };
221
+ }
222
+ /**
223
+ * Phase 3: everything the launch needs decided, and every refusal that can be
224
+ * made without side effects — host and terminal availability, the runtime's
225
+ * own preflight, the enforcement floor. Nothing here reserves, spawns or
226
+ * writes, so a launch refused at this point leaves no trace.
227
+ */
228
+ async plan(req, policy, requester, level) {
203
229
  /**
204
230
  * The one request every permission- and argv-deriving helper is given.
205
231
  *
@@ -217,7 +243,7 @@ export class Muster {
217
243
  throw new Error("host applies only to session launches");
218
244
  if (req.terminal && !req.open)
219
245
  throw new Error("--terminal requires --open");
220
- const terminal = !req.terminal || req.terminal === "auto" ? "terminal" : req.terminal;
246
+ const terminal = resolveTerminal(req, this.config);
221
247
  const requestedHost = req.host ?? this.config.host;
222
248
  if (req.open &&
223
249
  (req.kind !== "session" || !["auto", "tmux"].includes(requestedHost)))
@@ -247,7 +273,6 @@ export class Muster {
247
273
  if (req.open &&
248
274
  (!driver?.open || !(await driver.openAvailable?.(terminal))))
249
275
  throw new Error(`--open requires the selected terminal (${terminal}) installed on macOS and tmux`);
250
- let codexPolicy = [];
251
276
  if (req.runtime === "claude")
252
277
  await assertClaudePolicy(this.hostEnv);
253
278
  if (req.runtime === "opencode")
@@ -258,6 +283,28 @@ export class Muster {
258
283
  // Deciding from `req.host` accepts --idle-timeout under `host = "pty"` in
259
284
  // config and then never arms it.
260
285
  const lifecycle = resolveLifecycle(effective, this.config, req.kind === "session" && typeof driver?.schedule === "function");
286
+ return {
287
+ effective,
288
+ terminal,
289
+ permissions,
290
+ enforcement,
291
+ agentBase,
292
+ selectedMcp,
293
+ selectedPlugins,
294
+ model,
295
+ driver,
296
+ lifecycle,
297
+ argv,
298
+ };
299
+ }
300
+ /**
301
+ * Phase 4: claim the slot. The first step with a side effect, and the first
302
+ * that can answer "this launch already happened" — a repeated `--request-key`
303
+ * returns the original outcome rather than starting a second agent, so the
304
+ * caller cannot tell a retry from a duplicate.
305
+ */
306
+ async reserveLaunch(c) {
307
+ const { req, effective, policy, requester, permissions, enforcement, model, lifecycle, selectedMcp, selectedPlugins, identityName, } = c;
261
308
  const { entry: reservation, deduped } = await this.registry.reserve({
262
309
  runtime: req.runtime,
263
310
  kind: req.kind,
@@ -294,9 +341,409 @@ export class Muster {
294
341
  status: reservation.status,
295
342
  requester,
296
343
  }, policy.logMode);
297
- return this.recordedOutcome(reservation);
344
+ return { outcome: this.recordedOutcome(reservation) };
298
345
  }
299
346
  this.owned.add(reservation.launchId);
347
+ return { entry: reservation };
348
+ }
349
+ /**
350
+ * Phase 5: everything the agent's own environment needs before a process can
351
+ * be started — MCP servers probed, the per-launch identity copy made (the
352
+ * launch id exists now, and the copy is named after it), the configuration
353
+ * home the child will actually open recorded, and Claude's MCP bridge file
354
+ * written.
355
+ *
356
+ * Three of the sixteen per-agent capabilities surface here: workspace trust
357
+ * is written to a different file and key per agent and does not apply to
358
+ * OpenCode, the configuration-home variable differs, and the bridge file is
359
+ * Claude's alone. See AGENT_CAPABILITIES.md.
360
+ */
361
+ async prepareEnvironment(c) {
362
+ const { req, identityName, identityMeta, credential, selectedMcp, agentBase, deadline, } = c;
363
+ let entry = c.entry;
364
+ const prepared = await prepareMcp(selectedMcp, mcpEnvironment(selectedMcp, agentBase, this.sourceEnv), req.cwd, deadline);
365
+ let runtimeEnv = mcpEnvironment(prepared.servers, agentBase, this.sourceEnv);
366
+ entry = await this.registry.update(entry.launchId, prepared.summary);
367
+ if (identityName && identityMeta) {
368
+ // The launch id exists now, so the copy can be made and named after it.
369
+ const copy = await copyIdentity(this.home, identityName, identityMeta, entry.launchId);
370
+ // Written into the copy, never the live ~/.claude.json: a running
371
+ // session rewrites that file continuously and Muster cannot coordinate.
372
+ if (identityMeta.agent === "claude")
373
+ // RESOLVED, not as written: Claude keys projects[] by what its own
374
+ // getcwd(2) returns, which has every symlink resolved. On macOS a
375
+ // launch into /tmp/... is really /private/tmp/..., so the un-resolved
376
+ // key is one Claude never looks up — the trust dialog appears, the
377
+ // session never registers, and the launch burns its whole deadline.
378
+ // `assertAllowedWorkspace` already realpaths for containment.
379
+ await setWorkspaceTrust(copy, await realpath(req.cwd));
380
+ // Codex gates the same way behind a different file and a different key,
381
+ // and a fresh copy trusts nothing — so every Codex identity launch sat
382
+ // on "Do you trust the contents of this directory?" until the deadline.
383
+ // Resolved for the same reason: Codex keys `projects` by its own cwd.
384
+ else if (identityMeta.agent === "codex")
385
+ await setCodexWorkspaceTrust(copy, await realpath(req.cwd));
386
+ runtimeEnv = {
387
+ ...runtimeEnv,
388
+ ...identityEnv(identityMeta, copy, this.sourceEnv, credential?.token),
389
+ };
390
+ entry = await this.registry.update(entry.launchId, {
391
+ identityPath: copy,
392
+ // Recorded alongside the path, because the path outlives the directory
393
+ // it names and never named the account.
394
+ identity: identityName,
395
+ });
396
+ }
397
+ // Where the agent will record its session. Distinct from `hostEnv`, which
398
+ // keeps spawning Muster's own helpers — see `resolutionEnv`.
399
+ //
400
+ // Derived from the COMPOSED agent environment, not from `identityPath`
401
+ // alone: a requester profile's `env_defaults` delivers any key by design,
402
+ // so a profile setting CLAUDE_CONFIG_DIR or CODEX_HOME hands the agent one
403
+ // configuration home while resolution searched the host's — reserve, spawn,
404
+ // dead at the deadline. The identity copy still wins where there is one,
405
+ // because `identityEnv` is merged into `runtimeEnv` above.
406
+ const configVar = configHomeVar(req.runtime);
407
+ const configHome = configVar
408
+ ? (runtimeEnv[configVar] ?? entry.identityPath)
409
+ : undefined;
410
+ // Persisted only when it differs from the host's, so a launch with neither
411
+ // an identity nor an override writes nothing new and reads exactly as
412
+ // before. `refreshed()` has only the entry in scope and must agree.
413
+ if (configVar && configHome && configHome !== this.hostEnv[configVar])
414
+ entry = await this.registry.update(entry.launchId, { configHome });
415
+ const resolveEnv = this.resolutionEnv(req.runtime, configHome);
416
+ let bridgePath;
417
+ if (req.runtime === "claude" && prepared.servers.length) {
418
+ const dir = join(this.home, "mcp");
419
+ await mkdir(dir, { recursive: true, mode: 0o700 });
420
+ bridgePath = join(dir, entry.launchId + ".json");
421
+ entry = await this.registry.update(entry.launchId, {
422
+ mcpConfigPath: bridgePath,
423
+ });
424
+ await writeFile(bridgePath, JSON.stringify(Object.fromEntries(prepared.servers.map((s) => [s.name, s.config]))), { mode: 0o600 });
425
+ }
426
+ return { entry, prepared, runtimeEnv, configHome, resolveEnv, bridgePath };
427
+ }
428
+ /**
429
+ * Phase 6: turn the plan into a command line. This is the widest per-agent
430
+ * split in the launch — Claude and Codex are handed argv and contain
431
+ * themselves from there, while OpenCode is a server Muster starts on an
432
+ * allocated loopback port and then talks to over HTTP, so its "argv" comes
433
+ * back from a builder that also reports the port and an environment patch.
434
+ *
435
+ * Codex's policy overrides are spliced in AFTER launchArgs rather than passed
436
+ * to it, because they are computed from the composed runtime environment that
437
+ * only exists by this point.
438
+ */
439
+ async buildCommand(c) {
440
+ const { req, effective, prepared, bridgePath, selectedPlugins, deadline } = c;
441
+ let { argv, codexPolicy, runtimeEnv, entry } = c;
442
+ if (req.runtime !== "opencode")
443
+ // `runtimeEnv`, not the host's: an identity launch relocates
444
+ // CLAUDE_CONFIG_DIR to the per-launch copy, and the sandbox must grant
445
+ // the dir the child will actually open, not the one Muster runs under.
446
+ argv = launchArgs(effective, this.config, prepared.servers, bridgePath, runtimeEnv);
447
+ if (req.runtime === "codex") {
448
+ codexPolicy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers, req.kind === "task");
449
+ argv.splice(1, 0, ...codexPolicy);
450
+ }
451
+ else if (req.runtime === "opencode" && req.kind === "session") {
452
+ const port = await allocateLoopbackPort();
453
+ const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
454
+ const launch = await openCodeSessionLaunch(effective, this.config, prepared.servers, inheritedMcpNames, port, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
455
+ argv = launch.argv;
456
+ runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
457
+ entry = await this.registry.update(entry.launchId, {
458
+ server_url: launch.serverUrl,
459
+ opencode_port: launch.port,
460
+ });
461
+ }
462
+ else if (req.runtime === "opencode" && req.kind === "task") {
463
+ const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
464
+ const launch = await openCodeTaskLaunch(effective, this.config, prepared.servers, inheritedMcpNames, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
465
+ argv = launch.argv;
466
+ runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
467
+ }
468
+ return { argv, codexPolicy, runtimeEnv, entry };
469
+ }
470
+ /**
471
+ * Phase 7: start the process. The first point at which a process can exist
472
+ * without the registry knowing about it, which is why `spawning` is recorded
473
+ * before the host is asked to launch anything.
474
+ *
475
+ * A task ends here — `startTask` owns its whole lifetime — so this returns an
476
+ * outcome rather than falling through, and only a session carries on to
477
+ * readiness.
478
+ */
479
+ async startProcess(c) {
480
+ const { req, argv, runtimeEnv, policy, driver, lifecycle, deadline } = c;
481
+ let entry = c.entry;
482
+ if (this.closing)
483
+ throw new Error("Launch cancelled: Muster is closing");
484
+ // From here a process may exist without the registry knowing it. See the
485
+ // recovery branch in Registry.transaction.
486
+ entry = await this.registry.update(entry.launchId, { spawning: true });
487
+ if (req.kind === "task")
488
+ return {
489
+ outcome: await this.startTask(entry, argv, runtimeEnv, policy.logMode),
490
+ };
491
+ const launched = await driver.launch({
492
+ argv,
493
+ cwd: req.cwd,
494
+ env: runtimeEnv,
495
+ label: req.prompt.slice(0, 48),
496
+ deadline,
497
+ });
498
+ const root = await processRef(launched.pid);
499
+ if (!root) {
500
+ await driver.stop(launched.hostRef);
501
+ throw new Error("process exited during launch");
502
+ }
503
+ const group = await ownedGroup(launched.pid);
504
+ entry = {
505
+ ...entry,
506
+ root,
507
+ ...(group ? { group } : {}),
508
+ host: driver.id,
509
+ hostRef: launched.hostRef,
510
+ };
511
+ // Only the fields that changed, not the whole entry: `entry` still
512
+ // carries its recorded `requester`, and update() refuses a patch that
513
+ // mentions that field at all, changed or not.
514
+ await this.registry.update(entry.launchId, {
515
+ root,
516
+ ...(group ? { group } : {}),
517
+ host: driver.id,
518
+ hostRef: launched.hostRef,
519
+ });
520
+ // Armed only once hostRef exists: the job addresses the window, and a job
521
+ // armed before the window is recorded would poll a target the registry
522
+ // cannot name. `schedule` is optional on the host interface, so pty and
523
+ // macos-terminal opt out by not implementing it.
524
+ if (driver?.schedule && (lifecycle.idleTimeout ?? lifecycle.ttl)) {
525
+ const armed = await driver
526
+ .schedule(pollInterval(lifecycle), [
527
+ process.execPath,
528
+ // ../dist/muster.js, not ./muster.js: this resolves from src/run.ts
529
+ // under vitest as well as from dist/run.js in the package, landing
530
+ // on <root>/dist either way. Same idiom as the tmux bootstrap.
531
+ fileURLToPath(new URL("../dist/muster.js", import.meta.url)),
532
+ "reap-check",
533
+ "--home",
534
+ this.home,
535
+ "--socket",
536
+ driver.socketName?.() ?? "muster",
537
+ entry.launchId,
538
+ ])
539
+ .then(() => true, () => false);
540
+ // A lifecycle that could not be armed must not fail the launch — the
541
+ // session is up and usable, and the orphan risk is what we had before.
542
+ // But it must not be REPORTED either: a record promising an expiry
543
+ // nothing will honour is worse than one promising none, because there
544
+ // is no way to tell from the outside.
545
+ if (!armed) {
546
+ lifecycle.idleTimeout = null;
547
+ lifecycle.ttl = null;
548
+ entry = await this.registry.update(entry.launchId, {
549
+ idleTimeout: null,
550
+ ttl: null,
551
+ });
552
+ }
553
+ }
554
+ return { entry, root, launched };
555
+ }
556
+ /**
557
+ * Phase 8: wait until the agent has registered a session Muster can address,
558
+ * or until the deadline, whichever comes first. The subtlest phase, and the
559
+ * one where the runtimes differ most: a session record on disk for Claude, a
560
+ * JSON-RPC thread read for Codex, an HTTP endpoint plus a port-ownership
561
+ * check for OpenCode. They share a question, not a mechanism.
562
+ *
563
+ * `diagnostic` is the reason the launch would fail if the deadline arrived
564
+ * now. It is overwritten by anything the loop learns, so a timeout reports
565
+ * the last real explanation rather than "no identity found" — and
566
+ * `neverRegistered` keeps the original, because only that one can be about a
567
+ * consent screen nobody answered.
568
+ *
569
+ * The RPC connection is created by the caller rather than here: `launch`'s
570
+ * `finally` has to close it even when this throws.
571
+ */
572
+ async awaitReadiness(c) {
573
+ const { req, root, launched, driver, policy, requester, resolveEnv, rpc, enforcement, prepared, identityName, model, permissions, terminal, lifecycle, startedAt, deadline, } = c;
574
+ let entry = c.entry;
575
+ let diagnostic = req.runtime === "claude"
576
+ ? "no Claude session registry found (check terminal for workspace trust or login prompts)"
577
+ : req.runtime === "opencode"
578
+ ? "no OpenCode session matched the launch endpoint, cwd, and creation window"
579
+ : "no descendant runtime identity found";
580
+ // Kept so the throw below can tell "nothing ever registered" from a
581
+ // diagnosis the loop actually earned. Only the first case can be about a
582
+ // consent screen; anything later means a session existed.
583
+ const neverRegistered = diagnostic;
584
+ // `resolveEnv`, not `hostEnv`: this connection is how the thread
585
+ // `resolveCodex` found gets READ — both its `rejectionOf` screen, where a
586
+ // non-null answer is fatal, and `codexReachable` at readiness. An
587
+ // app-server reading the operator's CODEX_HOME has no rollout for a thread
588
+ // created under the copy, so it answers "no rollout found", every
589
+ // candidate is rejected, and the launch burns its deadline. Pointing
590
+ // discovery at the copy while its reads stay on the host is the same bug
591
+ // one call downstream.
592
+ while (Date.now() < deadline) {
593
+ if (deadline - Date.now() < 25)
594
+ break;
595
+ if (await isSame(root)) {
596
+ const tracked = (await Promise.all((await descendants(root.pid, Date.now() + 1000, entry.group)).map(processRef))).filter((p) => !!p);
597
+ const refs = [...(entry.descendants ?? [])];
598
+ for (const ref of tracked)
599
+ if (!refs.some((r) => r.pid === ref.pid && r.start === ref.start))
600
+ refs.push(ref);
601
+ if (refs.length !== (entry.descendants ?? []).length) {
602
+ entry = { ...entry, descendants: refs };
603
+ await this.registry.update(entry.launchId, { descendants: refs });
604
+ }
605
+ }
606
+ if (this.closing)
607
+ throw new Error("Launch cancelled: Muster is closing");
608
+ if (!(await isSame(root)))
609
+ throw new Error(`process exited at ${((Date.now() - startedAt) / 1000).toFixed(2)}s`);
610
+ let openingTerminal = false;
611
+ try {
612
+ const identity = req.runtime === "codex"
613
+ ? await resolveCodex(root.pid, resolveEnv, deadline, async (id) => {
614
+ try {
615
+ const { thread } = await rpc.call("thread/read", { threadId: id }, deadline);
616
+ return threadRejection(thread, id);
617
+ }
618
+ catch (e) {
619
+ return e.message;
620
+ }
621
+ })
622
+ : req.runtime === "opencode"
623
+ ? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline)
624
+ : await resolveClaude(root.pid, resolveEnv, deadline);
625
+ if (identity) {
626
+ diagnostic =
627
+ req.runtime === "codex"
628
+ ? "thread id reserved but no rollout"
629
+ : req.runtime === "opencode"
630
+ ? "OpenCode session exists but its endpoint status is not readable"
631
+ : "registry exists but inbox socket is not accepting connections";
632
+ let metadata;
633
+ if (req.runtime === "codex")
634
+ metadata = await codexReachable(rpc, identity.id, deadline);
635
+ else if (req.runtime === "opencode")
636
+ metadata = await openCodeReachable(new OpenCodeHttp(entry.server_url), identity, deadline);
637
+ else {
638
+ const claude = identity;
639
+ if (!(await claudeReachable(claude.socketPath, deadline)))
640
+ throw new Error(diagnostic);
641
+ metadata = claude;
642
+ }
643
+ if (Date.now() >= deadline)
644
+ throw new Error("readiness completed after launch deadline");
645
+ if (!(await isSame(root)) || !(await processRef(identity.pid)))
646
+ throw new Error("process exited before readiness completed");
647
+ if (req.runtime === "opencode" &&
648
+ !(await openCodeEndpointOwnedBy(root.pid, entry.server_url, deadline)))
649
+ throw new Error("OpenCode endpoint listener is not owned by the launched process tree");
650
+ const runtime = runtimeName(req.runtime);
651
+ const named = assignNames([
652
+ { runtime, uuid: identity.id, rawName: metadata.rawName },
653
+ ])[0];
654
+ const peer = {
655
+ kind: "session",
656
+ ...permissions,
657
+ enforcement,
658
+ ...prepared.summary,
659
+ name: named.display,
660
+ canonical_id: named.canonicalId,
661
+ runtime,
662
+ state: metadata.state,
663
+ cwd: req.cwd,
664
+ ...(req.runtime === "codex"
665
+ ? { thread_id: identity.id }
666
+ : req.runtime === "opencode"
667
+ ? {
668
+ session_id: identity.id,
669
+ server_url: entry.server_url,
670
+ }
671
+ : { session_id: identity.id }),
672
+ pid: identity.pid,
673
+ host: driver.id,
674
+ capabilities: driver.capabilities(),
675
+ // The RESOLVED name, matching what the entry records and what
676
+ // `list` projects: a launch result and a listing must not
677
+ // disagree about which account this ran as.
678
+ ...(identityName ? { identity: identityName } : {}),
679
+ // Same reason, for the same pair of records.
680
+ model: model?.model ?? null,
681
+ model_source: model?.source ?? null,
682
+ ...(lifecycle.idleTimeout
683
+ ? { idle_timeout: lifecycle.idleTimeout }
684
+ : {}),
685
+ ...(lifecycle.ttl ? { ttl: lifecycle.ttl } : {}),
686
+ attach_hint: driver.attachHint(launched.hostRef),
687
+ };
688
+ if (req.open) {
689
+ openingTerminal = true;
690
+ await driver.open(launched.hostRef, deadline, terminal);
691
+ peer.terminal_opened = true;
692
+ peer.terminal = terminal;
693
+ }
694
+ entry = await this.registry.update(entry.launchId, {
695
+ id: identity.id,
696
+ status: "running",
697
+ peer,
698
+ });
699
+ await this.log.write({ event: "ready", launch_id: entry.launchId, peer }, policy.logMode);
700
+ return peer;
701
+ }
702
+ }
703
+ catch (e) {
704
+ if (openingTerminal)
705
+ throw e;
706
+ // An error arriving after the deadline is dropped, because most of
707
+ // them are artifacts of the deadline itself — aborted round trips
708
+ // that would overwrite a real diagnosis with "timed out". The cost is
709
+ // that a genuine answer landing a moment late is lost too, so raise
710
+ // one as early as it is known rather than at readiness (#20).
711
+ if (Date.now() < deadline)
712
+ diagnostic = e.message;
713
+ }
714
+ await delay(Math.min(250, Math.max(0, deadline - Date.now())));
715
+ }
716
+ // Resolved here rather than before the launch: this reads the operator's
717
+ // live `.claude.json`, and a launch that is going to work should not pay
718
+ // for it. Failing closed is also wrong before a launch — `--kind task`
719
+ // runs headless and never sees the dialog, so refusing an untrusted
720
+ // directory up front would break task launches that work today.
721
+ if (req.runtime === "claude" && diagnostic === neverRegistered)
722
+ diagnostic = claudeRegistryDiagnostic(await claudeWorkspaceTrusted(resolveEnv, req.cwd), req.cwd, claudeConfigDir(resolveEnv));
723
+ throw new Error(`${diagnostic} after ${this.config.launch_timeout_sec}s`);
724
+ }
725
+ async launch(input, requester) {
726
+ const { req, policy, level } = await this.authorize(input, requester);
727
+ const { identityName, identityMeta, credential } = await this.resolveIdentity(req, policy);
728
+ const { effective, terminal, permissions, enforcement, agentBase, selectedMcp, selectedPlugins, model, driver, lifecycle, argv: plannedArgv, } = await this.plan(req, policy, requester, level);
729
+ let argv = plannedArgv;
730
+ let codexPolicy = [];
731
+ const reserved = await this.reserveLaunch({
732
+ req,
733
+ effective,
734
+ policy,
735
+ requester,
736
+ permissions,
737
+ enforcement,
738
+ model,
739
+ lifecycle,
740
+ selectedMcp,
741
+ selectedPlugins,
742
+ identityName,
743
+ });
744
+ if ("outcome" in reserved)
745
+ return reserved.outcome;
746
+ const reservation = reserved.entry;
300
747
  let entry = reservation;
301
748
  const startedAt = Date.now();
302
749
  const deadline = startedAt + this.config.launch_timeout_sec * 1000;
@@ -326,315 +773,70 @@ export class Muster {
326
773
  ...permissions,
327
774
  enforcement,
328
775
  }, policy.logMode);
329
- const prepared = await prepareMcp(selectedMcp, mcpEnvironment(selectedMcp, agentBase, this.sourceEnv), req.cwd, deadline);
330
- let runtimeEnv = mcpEnvironment(prepared.servers, agentBase, this.sourceEnv);
331
- entry = await this.registry.update(entry.launchId, prepared.summary);
332
- if (identityName && identityMeta) {
333
- // The launch id exists now, so the copy can be made and named after it.
334
- const copy = await copyIdentity(this.home, identityName, identityMeta, entry.launchId);
335
- // Written into the copy, never the live ~/.claude.json: a running
336
- // session rewrites that file continuously and Muster cannot coordinate.
337
- if (identityMeta.agent === "claude")
338
- // RESOLVED, not as written: Claude keys projects[] by what its own
339
- // getcwd(2) returns, which has every symlink resolved. On macOS a
340
- // launch into /tmp/... is really /private/tmp/..., so the un-resolved
341
- // key is one Claude never looks up — the trust dialog appears, the
342
- // session never registers, and the launch burns its whole deadline.
343
- // `assertAllowedWorkspace` already realpaths for containment.
344
- await setWorkspaceTrust(copy, await realpath(req.cwd));
345
- // Codex gates the same way behind a different file and a different key,
346
- // and a fresh copy trusts nothing — so every Codex identity launch sat
347
- // on "Do you trust the contents of this directory?" until the deadline.
348
- // Resolved for the same reason: Codex keys `projects` by its own cwd.
349
- else if (identityMeta.agent === "codex")
350
- await setCodexWorkspaceTrust(copy, await realpath(req.cwd));
351
- runtimeEnv = {
352
- ...runtimeEnv,
353
- ...identityEnv(identityMeta, copy, this.sourceEnv, credential?.token),
354
- };
355
- entry = await this.registry.update(entry.launchId, {
356
- identityPath: copy,
357
- // Recorded alongside the path, because the path outlives the directory
358
- // it names and never named the account.
359
- identity: identityName,
360
- });
361
- }
362
- // Where the agent will record its session. Distinct from `hostEnv`, which
363
- // keeps spawning Muster's own helpers — see `resolutionEnv`.
364
- //
365
- // Derived from the COMPOSED agent environment, not from `identityPath`
366
- // alone: a requester profile's `env_defaults` delivers any key by design,
367
- // so a profile setting CLAUDE_CONFIG_DIR or CODEX_HOME hands the agent one
368
- // configuration home while resolution searched the host's — reserve, spawn,
369
- // dead at the deadline. The identity copy still wins where there is one,
370
- // because `identityEnv` is merged into `runtimeEnv` above.
371
- const configVar = configHomeVar(req.runtime);
372
- const configHome = configVar
373
- ? (runtimeEnv[configVar] ?? entry.identityPath)
374
- : undefined;
375
- // Persisted only when it differs from the host's, so a launch with neither
376
- // an identity nor an override writes nothing new and reads exactly as
377
- // before. `refreshed()` has only the entry in scope and must agree.
378
- if (configVar && configHome && configHome !== this.hostEnv[configVar])
379
- entry = await this.registry.update(entry.launchId, { configHome });
380
- const resolveEnv = this.resolutionEnv(req.runtime, configHome);
381
- let bridgePath;
382
- if (req.runtime === "claude" && prepared.servers.length) {
383
- const dir = join(this.home, "mcp");
384
- await mkdir(dir, { recursive: true, mode: 0o700 });
385
- bridgePath = join(dir, entry.launchId + ".json");
386
- entry = await this.registry.update(entry.launchId, {
387
- mcpConfigPath: bridgePath,
388
- });
389
- await writeFile(bridgePath, JSON.stringify(Object.fromEntries(prepared.servers.map((s) => [s.name, s.config]))), { mode: 0o600 });
390
- }
391
- if (req.runtime !== "opencode")
392
- // `runtimeEnv`, not the host's: an identity launch relocates
393
- // CLAUDE_CONFIG_DIR to the per-launch copy, and the sandbox must grant
394
- // the dir the child will actually open, not the one Muster runs under.
395
- argv = launchArgs(effective, this.config, prepared.servers, bridgePath, runtimeEnv);
396
- if (req.runtime === "codex") {
397
- codexPolicy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers, req.kind === "task");
398
- argv.splice(1, 0, ...codexPolicy);
399
- }
400
- else if (req.runtime === "opencode" && req.kind === "session") {
401
- const port = await allocateLoopbackPort();
402
- const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
403
- const launch = await openCodeSessionLaunch(effective, this.config, prepared.servers, inheritedMcpNames, port, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
404
- argv = launch.argv;
405
- runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
406
- entry = await this.registry.update(entry.launchId, {
407
- server_url: launch.serverUrl,
408
- opencode_port: launch.port,
409
- });
410
- }
411
- else if (req.runtime === "opencode" && req.kind === "task") {
412
- const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
413
- const launch = await openCodeTaskLaunch(effective, this.config, prepared.servers, inheritedMcpNames, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
414
- argv = launch.argv;
415
- runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
416
- }
417
- if (this.closing)
418
- throw new Error("Launch cancelled: Muster is closing");
419
- // From here a process may exist without the registry knowing it. See the
420
- // recovery branch in Registry.transaction.
421
- entry = await this.registry.update(entry.launchId, { spawning: true });
422
- if (req.kind === "task")
423
- return await this.startTask(entry, argv, runtimeEnv, policy.logMode);
424
- const launched = await driver.launch({
425
- argv,
426
- cwd: req.cwd,
427
- env: runtimeEnv,
428
- label: req.prompt.slice(0, 48),
776
+ const environment = await this.prepareEnvironment({
777
+ req,
778
+ entry,
779
+ identityName,
780
+ identityMeta,
781
+ credential,
782
+ selectedMcp,
783
+ agentBase,
429
784
  deadline,
430
785
  });
431
- const root = await processRef(launched.pid);
432
- if (!root) {
433
- await driver.stop(launched.hostRef);
434
- throw new Error("process exited during launch");
435
- }
436
- const group = await ownedGroup(launched.pid);
437
- entry = {
438
- ...entry,
439
- root,
440
- ...(group ? { group } : {}),
441
- host: driver.id,
442
- hostRef: launched.hostRef,
443
- };
444
- // Only the fields that changed, not the whole entry: `entry` still
445
- // carries its recorded `requester`, and update() refuses a patch that
446
- // mentions that field at all, changed or not.
447
- await this.registry.update(entry.launchId, {
448
- root,
449
- ...(group ? { group } : {}),
450
- host: driver.id,
451
- hostRef: launched.hostRef,
786
+ entry = environment.entry;
787
+ // `runtimeEnv` is still reassigned below — an OpenCode launch patches it
788
+ // with the port its server ended up on — so it stays a `let` here rather
789
+ // than being destructured as a const.
790
+ let runtimeEnv = environment.runtimeEnv;
791
+ const { prepared, configHome, resolveEnv, bridgePath } = environment;
792
+ ({ argv, codexPolicy, runtimeEnv, entry } = await this.buildCommand({
793
+ req,
794
+ effective,
795
+ entry,
796
+ argv,
797
+ codexPolicy,
798
+ runtimeEnv,
799
+ prepared,
800
+ bridgePath,
801
+ selectedPlugins,
802
+ deadline,
803
+ }));
804
+ const started = await this.startProcess({
805
+ req,
806
+ entry,
807
+ argv,
808
+ runtimeEnv,
809
+ policy,
810
+ driver,
811
+ lifecycle,
812
+ deadline,
452
813
  });
453
- // Armed only once hostRef exists: the job addresses the window, and a job
454
- // armed before the window is recorded would poll a target the registry
455
- // cannot name. `schedule` is optional on the host interface, so pty and
456
- // macos-terminal opt out by not implementing it.
457
- if (driver?.schedule && (lifecycle.idleTimeout ?? lifecycle.ttl)) {
458
- const armed = await driver
459
- .schedule(pollInterval(lifecycle), [
460
- process.execPath,
461
- // ../dist/muster.js, not ./muster.js: this resolves from src/run.ts
462
- // under vitest as well as from dist/run.js in the package, landing
463
- // on <root>/dist either way. Same idiom as the tmux bootstrap.
464
- fileURLToPath(new URL("../dist/muster.js", import.meta.url)),
465
- "reap-check",
466
- "--home",
467
- this.home,
468
- "--socket",
469
- driver.socketName?.() ?? "muster",
470
- entry.launchId,
471
- ])
472
- .then(() => true, () => false);
473
- // A lifecycle that could not be armed must not fail the launch — the
474
- // session is up and usable, and the orphan risk is what we had before.
475
- // But it must not be REPORTED either: a record promising an expiry
476
- // nothing will honour is worse than one promising none, because there
477
- // is no way to tell from the outside.
478
- if (!armed) {
479
- lifecycle.idleTimeout = null;
480
- lifecycle.ttl = null;
481
- entry = await this.registry.update(entry.launchId, {
482
- idleTimeout: null,
483
- ttl: null,
484
- });
485
- }
486
- }
487
- let diagnostic = req.runtime === "claude"
488
- ? "no Claude session registry found (check terminal for workspace trust or login prompts)"
489
- : req.runtime === "opencode"
490
- ? "no OpenCode session matched the launch endpoint, cwd, and creation window"
491
- : "no descendant runtime identity found";
492
- // Kept so the throw below can tell "nothing ever registered" from a
493
- // diagnosis the loop actually earned. Only the first case can be about a
494
- // consent screen; anything later means a session existed.
495
- const neverRegistered = diagnostic;
496
- // `resolveEnv`, not `hostEnv`: this connection is how the thread
497
- // `resolveCodex` found gets READ — both its `rejectionOf` screen, where a
498
- // non-null answer is fatal, and `codexReachable` at readiness. An
499
- // app-server reading the operator's CODEX_HOME has no rollout for a thread
500
- // created under the copy, so it answers "no rollout found", every
501
- // candidate is rejected, and the launch burns its deadline. Pointing
502
- // discovery at the copy while its reads stay on the host is the same bug
503
- // one call downstream.
814
+ if ("outcome" in started)
815
+ return started.outcome;
816
+ entry = started.entry;
817
+ const { root, launched } = started;
504
818
  if (req.runtime === "codex")
505
819
  rpc = new CodexRpc(resolveEnv, req.cwd);
506
- while (Date.now() < deadline) {
507
- if (deadline - Date.now() < 25)
508
- break;
509
- if (await isSame(root)) {
510
- const tracked = (await Promise.all((await descendants(root.pid, Date.now() + 1000, entry.group)).map(processRef))).filter((p) => !!p);
511
- const refs = [...(entry.descendants ?? [])];
512
- for (const ref of tracked)
513
- if (!refs.some((r) => r.pid === ref.pid && r.start === ref.start))
514
- refs.push(ref);
515
- if (refs.length !== (entry.descendants ?? []).length) {
516
- entry = { ...entry, descendants: refs };
517
- await this.registry.update(entry.launchId, { descendants: refs });
518
- }
519
- }
520
- if (this.closing)
521
- throw new Error("Launch cancelled: Muster is closing");
522
- if (!(await isSame(root)))
523
- throw new Error(`process exited at ${((Date.now() - startedAt) / 1000).toFixed(2)}s`);
524
- let openingTerminal = false;
525
- try {
526
- const identity = req.runtime === "codex"
527
- ? await resolveCodex(root.pid, resolveEnv, deadline, async (id) => {
528
- try {
529
- const { thread } = await rpc.call("thread/read", { threadId: id }, deadline);
530
- return threadRejection(thread, id);
531
- }
532
- catch (e) {
533
- return e.message;
534
- }
535
- })
536
- : req.runtime === "opencode"
537
- ? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline)
538
- : await resolveClaude(root.pid, resolveEnv, deadline);
539
- if (identity) {
540
- diagnostic =
541
- req.runtime === "codex"
542
- ? "thread id reserved but no rollout"
543
- : req.runtime === "opencode"
544
- ? "OpenCode session exists but its endpoint status is not readable"
545
- : "registry exists but inbox socket is not accepting connections";
546
- let metadata;
547
- if (req.runtime === "codex")
548
- metadata = await codexReachable(rpc, identity.id, deadline);
549
- else if (req.runtime === "opencode")
550
- metadata = await openCodeReachable(new OpenCodeHttp(entry.server_url), identity, deadline);
551
- else {
552
- const claude = identity;
553
- if (!(await claudeReachable(claude.socketPath, deadline)))
554
- throw new Error(diagnostic);
555
- metadata = claude;
556
- }
557
- if (Date.now() >= deadline)
558
- throw new Error("readiness completed after launch deadline");
559
- if (!(await isSame(root)) || !(await processRef(identity.pid)))
560
- throw new Error("process exited before readiness completed");
561
- if (req.runtime === "opencode" &&
562
- !(await openCodeEndpointOwnedBy(root.pid, entry.server_url, deadline)))
563
- throw new Error("OpenCode endpoint listener is not owned by the launched process tree");
564
- const runtime = runtimeName(req.runtime);
565
- const named = assignNames([
566
- { runtime, uuid: identity.id, rawName: metadata.rawName },
567
- ])[0];
568
- const peer = {
569
- kind: "session",
570
- ...permissions,
571
- enforcement,
572
- ...prepared.summary,
573
- name: named.display,
574
- canonical_id: named.canonicalId,
575
- runtime,
576
- state: metadata.state,
577
- cwd: req.cwd,
578
- ...(req.runtime === "codex"
579
- ? { thread_id: identity.id }
580
- : req.runtime === "opencode"
581
- ? {
582
- session_id: identity.id,
583
- server_url: entry.server_url,
584
- }
585
- : { session_id: identity.id }),
586
- pid: identity.pid,
587
- host: driver.id,
588
- capabilities: driver.capabilities(),
589
- // The RESOLVED name, matching what the entry records and what
590
- // `list` projects: a launch result and a listing must not
591
- // disagree about which account this ran as.
592
- ...(identityName ? { identity: identityName } : {}),
593
- // Same reason, for the same pair of records.
594
- model: model?.model ?? null,
595
- model_source: model?.source ?? null,
596
- ...(lifecycle.idleTimeout
597
- ? { idle_timeout: lifecycle.idleTimeout }
598
- : {}),
599
- ...(lifecycle.ttl ? { ttl: lifecycle.ttl } : {}),
600
- attach_hint: driver.attachHint(launched.hostRef),
601
- };
602
- if (req.open) {
603
- openingTerminal = true;
604
- await driver.open(launched.hostRef, deadline, terminal);
605
- peer.terminal_opened = true;
606
- peer.terminal = terminal;
607
- }
608
- entry = await this.registry.update(entry.launchId, {
609
- id: identity.id,
610
- status: "running",
611
- peer,
612
- });
613
- await this.log.write({ event: "ready", launch_id: entry.launchId, peer }, policy.logMode);
614
- return peer;
615
- }
616
- }
617
- catch (e) {
618
- if (openingTerminal)
619
- throw e;
620
- // An error arriving after the deadline is dropped, because most of
621
- // them are artifacts of the deadline itself — aborted round trips
622
- // that would overwrite a real diagnosis with "timed out". The cost is
623
- // that a genuine answer landing a moment late is lost too, so raise
624
- // one as early as it is known rather than at readiness (#20).
625
- if (Date.now() < deadline)
626
- diagnostic = e.message;
627
- }
628
- await delay(Math.min(250, Math.max(0, deadline - Date.now())));
629
- }
630
- // Resolved here rather than before the launch: this reads the operator's
631
- // live `.claude.json`, and a launch that is going to work should not pay
632
- // for it. Failing closed is also wrong before a launch — `--kind task`
633
- // runs headless and never sees the dialog, so refusing an untrusted
634
- // directory up front would break task launches that work today.
635
- if (req.runtime === "claude" && diagnostic === neverRegistered)
636
- diagnostic = claudeRegistryDiagnostic(await claudeWorkspaceTrusted(resolveEnv, req.cwd), req.cwd, claudeConfigDir(resolveEnv));
637
- throw new Error(`${diagnostic} after ${this.config.launch_timeout_sec}s`);
820
+ return await this.awaitReadiness({
821
+ req,
822
+ entry,
823
+ root,
824
+ launched,
825
+ driver,
826
+ policy,
827
+ requester,
828
+ resolveEnv,
829
+ rpc,
830
+ enforcement,
831
+ prepared,
832
+ identityName,
833
+ model,
834
+ permissions,
835
+ terminal,
836
+ lifecycle,
837
+ startedAt,
838
+ deadline,
839
+ });
638
840
  }
639
841
  catch (e) {
640
842
  await this.stopEntry(entry).catch(() => { });