@brutalsystems/muster 0.11.0 → 1.0.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.
package/dist/run.js CHANGED
@@ -1,4 +1,4 @@
1
- import { stat, readFile, realpath, writeFile, mkdir, rm, } from "node:fs/promises";
1
+ import { stat, readFile, writeFile, mkdir, rm, } from "node:fs/promises";
2
2
  import { join, resolve } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { fileURLToPath } from "node:url";
@@ -10,10 +10,13 @@ import { LOCAL } from "./requester.js";
10
10
  import { assertEnforcementMeetsMinimum, assertLevelWithinCeiling, assertMayAct, resolveIdentityForRequest, resolveRequesterPolicy, RequesterPolicyRefusal, } from "./requester-policy.js";
11
11
  import { IdentityError, readIdentity, } from "./identity-store.js";
12
12
  import { resolveCredential } from "./identity-token.js";
13
- import { copyIdentity, identityEnv, setCodexWorkspaceTrust, setWorkspaceTrust, } from "./identity-copy.js";
13
+ import { copyIdentity, identityEnv } from "./identity-copy.js";
14
14
  import { Registry } from "./registry.js";
15
15
  import { LaunchLog } from "./log.js";
16
16
  import { hosts, selectHost } from "./hosts/index.js";
17
+ import { AGENTS } from "./agents.js";
18
+ import { assertOptionsSupported, screenWorkspace } from "./options.js";
19
+ import { workspaceTrustKey } from "./identity/workspace-key.js";
17
20
  import { processRef, isSame, stopTree, delay, descendants, ownedGroup, } from "./identity/processes.js";
18
21
  import { resolveCodex } from "./identity/codex.js";
19
22
  import { claudeConfigDir, claudeRegistryDiagnostic, claudeWorkspaceTrusted, resolveClaude, } from "./identity/claude.js";
@@ -27,7 +30,7 @@ import { CodexRpc } from "./codex-rpc.js";
27
30
  import { allocateLoopbackPort, OpenCodeHttp } from "./opencode-http.js";
28
31
  import { assertOpenCodeVersion, openCodeMcpNames, openCodeSessionLaunch, openCodeTaskLaunch, } from "./opencode-policy.js";
29
32
  import { assignNames, resolvePeer, runtimeName, suffixOf, } from "./naming.js";
30
- import { selectMcp, prepareMcp, mcpEnvironment } from "./mcp.js";
33
+ import { selectMcp, prepareMcp, mcpEnvironment, } from "./mcp.js";
31
34
  import { pollInterval } from "./reap.js";
32
35
  import { selectPlugins } from "./plugins.js";
33
36
  function taskHandle(entry, pid) {
@@ -58,12 +61,13 @@ function taskHandle(entry, pid) {
58
61
  * rather than a configuration path, so relocating its configuration moves
59
62
  * nothing Muster reads.
60
63
  */
64
+ /**
65
+ * `undefined` rather than the table's `null`, because every caller treats this
66
+ * as "there is no such variable" via `?? entry.identityPath` and an absent
67
+ * value is what that idiom expects.
68
+ */
61
69
  function configHomeVar(runtime) {
62
- if (runtime === "codex")
63
- return "CODEX_HOME";
64
- if (runtime === "claude")
65
- return "CLAUDE_CONFIG_DIR";
66
- return undefined;
70
+ return AGENTS[runtime].configHomeVar ?? undefined;
67
71
  }
68
72
  export class Muster {
69
73
  config;
@@ -108,7 +112,14 @@ export class Muster {
108
112
  this.active.add(operation);
109
113
  return operation.finally(() => this.active.delete(operation));
110
114
  }
111
- async launch(input, requester) {
115
+ /**
116
+ * Phase 1 of a launch: decide whether this caller may ask for this at all,
117
+ * and settle what "this" is. Everything here refuses BEFORE anything is
118
+ * reserved, spawned or written, which is why it reads the raw input in places
119
+ * rather than the parsed request — once defaults have been applied, a value
120
+ * Muster chose is indistinguishable from one the caller sent.
121
+ */
122
+ async authorize(input, requester) {
112
123
  // Read before parsing, because cwd defaults during it and a default is then
113
124
  // indistinguishable from a directory the caller asked for. Naming both a
114
125
  // project and a directory is a contradiction worth reporting rather than
@@ -140,7 +151,11 @@ export class Muster {
140
151
  // itself is not expressible remotely, and a remote launch gets the
141
152
  // receiver's configured `default_mcp` / `default_plugins`. Configuration
142
153
  // is the grant.
143
- for (const key of ["mcp", "plugin"])
154
+ // `options` joins them for the same reason, and one of its own:
155
+ // auto-approve-path would have this machine trust a directory a remote
156
+ // caller named, and trust is what lets that directory configure the
157
+ // session. Configuration is the grant.
158
+ for (const key of ["mcp", "plugin", "options"])
144
159
  if (raw[key] !== undefined)
145
160
  throw new RequesterPolicyRefusal("not-expressible-remotely", `a remote request may not select ${key}; this receiver's configured defaults apply`);
146
161
  }
@@ -172,6 +187,15 @@ export class Muster {
172
187
  const level = req.level ?? policy.level;
173
188
  if (level)
174
189
  assertLevelWithinCeiling(level, policy);
190
+ return { req, policy, level };
191
+ }
192
+ /**
193
+ * Phase 2: the read-only half of identity resolution. Validated before
194
+ * anything is reserved or spawned, so a bad identity leaves no registry entry
195
+ * behind. The per-launch COPY cannot be made here — it is named after the
196
+ * launch id, which only `reserve` produces — so that half happens later.
197
+ */
198
+ async resolveIdentity(req, policy) {
175
199
  // Validated here, before anything is reserved or spawned: a bad identity
176
200
  // must leave no registry entry behind. The copy cannot be made yet — it is
177
201
  // named after the launch id, which only `reserve` produces — so this is the
@@ -201,6 +225,15 @@ export class Muster {
201
225
  : "has no credential — run `setup-identity --interactive` to log in and store a token");
202
226
  }
203
227
  }
228
+ return { identityName, identityMeta, credential };
229
+ }
230
+ /**
231
+ * Phase 3: everything the launch needs decided, and every refusal that can be
232
+ * made without side effects — host and terminal availability, the runtime's
233
+ * own preflight, the enforcement floor. Nothing here reserves, spawns or
234
+ * writes, so a launch refused at this point leaves no trace.
235
+ */
236
+ async plan(req, policy, requester, level) {
204
237
  /**
205
238
  * The one request every permission- and argv-deriving helper is given.
206
239
  *
@@ -223,6 +256,9 @@ export class Muster {
223
256
  if (req.open &&
224
257
  (req.kind !== "session" || !["auto", "tmux"].includes(requestedHost)))
225
258
  throw new Error("--open requires a tmux session on macOS");
259
+ // Refused here, with the other checks that cost nothing to fail: an option
260
+ // the runtime cannot honour must not leave a registry entry behind.
261
+ assertOptionsSupported(req.runtime, req.options);
226
262
  const permissions = resolvePermissions(effective, this.config);
227
263
  const enforcement = enforcementGrade(req.runtime, permissions);
228
264
  assertEnforcementMeetsMinimum(req.runtime, enforcement, policy);
@@ -248,7 +284,6 @@ export class Muster {
248
284
  if (req.open &&
249
285
  (!driver?.open || !(await driver.openAvailable?.(terminal))))
250
286
  throw new Error(`--open requires the selected terminal (${terminal}) installed on macOS and tmux`);
251
- let codexPolicy = [];
252
287
  if (req.runtime === "claude")
253
288
  await assertClaudePolicy(this.hostEnv);
254
289
  if (req.runtime === "opencode")
@@ -259,6 +294,28 @@ export class Muster {
259
294
  // Deciding from `req.host` accepts --idle-timeout under `host = "pty"` in
260
295
  // config and then never arms it.
261
296
  const lifecycle = resolveLifecycle(effective, this.config, req.kind === "session" && typeof driver?.schedule === "function");
297
+ return {
298
+ effective,
299
+ terminal,
300
+ permissions,
301
+ enforcement,
302
+ agentBase,
303
+ selectedMcp,
304
+ selectedPlugins,
305
+ model,
306
+ driver,
307
+ lifecycle,
308
+ argv,
309
+ };
310
+ }
311
+ /**
312
+ * Phase 4: claim the slot. The first step with a side effect, and the first
313
+ * that can answer "this launch already happened" — a repeated `--request-key`
314
+ * returns the original outcome rather than starting a second agent, so the
315
+ * caller cannot tell a retry from a duplicate.
316
+ */
317
+ async reserveLaunch(c) {
318
+ const { req, effective, policy, requester, permissions, enforcement, model, lifecycle, selectedMcp, selectedPlugins, identityName, } = c;
262
319
  const { entry: reservation, deduped } = await this.registry.reserve({
263
320
  runtime: req.runtime,
264
321
  kind: req.kind,
@@ -295,9 +352,415 @@ export class Muster {
295
352
  status: reservation.status,
296
353
  requester,
297
354
  }, policy.logMode);
298
- return this.recordedOutcome(reservation);
355
+ return { outcome: this.recordedOutcome(reservation) };
299
356
  }
300
357
  this.owned.add(reservation.launchId);
358
+ return { entry: reservation };
359
+ }
360
+ /**
361
+ * Phase 5: everything the agent's own environment needs before a process can
362
+ * be started — MCP servers probed, the per-launch identity copy made (the
363
+ * launch id exists now, and the copy is named after it), the configuration
364
+ * home the child will actually open recorded, and Claude's MCP bridge file
365
+ * written.
366
+ *
367
+ * Three of the sixteen per-agent capabilities surface here: workspace trust
368
+ * is written to a different file and key per agent and does not apply to
369
+ * OpenCode, the configuration-home variable differs, and the bridge file is
370
+ * Claude's alone. See AGENT_CAPABILITIES.md.
371
+ */
372
+ async prepareEnvironment(c) {
373
+ const { req, identityName, identityMeta, credential, selectedMcp, agentBase, deadline, } = c;
374
+ let entry = c.entry;
375
+ const prepared = await prepareMcp(selectedMcp, mcpEnvironment(selectedMcp, agentBase, this.sourceEnv), req.cwd, deadline);
376
+ let runtimeEnv = mcpEnvironment(prepared.servers, agentBase, this.sourceEnv);
377
+ entry = await this.registry.update(entry.launchId, prepared.summary);
378
+ if (identityName && identityMeta) {
379
+ // The launch id exists now, so the copy can be made and named after it.
380
+ const copy = await copyIdentity(this.home, identityName, identityMeta, entry.launchId);
381
+ // Keyed on the identity's agent, which `resolveIdentity` has already
382
+ // refused unless it equals req.runtime — so this is the same row either
383
+ // way, and reading it from the identity keeps the check and the use
384
+ // agreed. A fresh copy trusts nothing, so without this every identity
385
+ // launch sat on "Do you trust the contents of this directory?" until the
386
+ // deadline. `realpath` and the reason for it are documented on the slot.
387
+ await AGENTS[identityMeta.agent].trustWorkspace?.(copy, await workspaceTrustKey(req.cwd));
388
+ runtimeEnv = {
389
+ ...runtimeEnv,
390
+ ...identityEnv(identityMeta, copy, this.sourceEnv, credential?.token),
391
+ };
392
+ entry = await this.registry.update(entry.launchId, {
393
+ identityPath: copy,
394
+ // Recorded alongside the path, because the path outlives the directory
395
+ // it names and never named the account.
396
+ identity: identityName,
397
+ });
398
+ }
399
+ // Where the agent will record its session. Distinct from `hostEnv`, which
400
+ // keeps spawning Muster's own helpers — see `resolutionEnv`.
401
+ //
402
+ // Derived from the COMPOSED agent environment, not from `identityPath`
403
+ // alone: a requester profile's `env_defaults` delivers any key by design,
404
+ // so a profile setting CLAUDE_CONFIG_DIR or CODEX_HOME hands the agent one
405
+ // configuration home while resolution searched the host's — reserve, spawn,
406
+ // dead at the deadline. The identity copy still wins where there is one,
407
+ // because `identityEnv` is merged into `runtimeEnv` above.
408
+ // `--options auto-approve-path`: record the launch directory as trusted in
409
+ // the profile the agent will actually use, so the one-time dialog does not
410
+ // stop the launch. Screened first — a directory that ships hooks or MCP
411
+ // servers configures the session it is trusted by, and those run without
412
+ // asking, so it is named and refused rather than waved through.
413
+ if (req.options.includes("auto-approve-path")) {
414
+ const findings = await screenWorkspace(req.cwd);
415
+ if (findings.length)
416
+ throw new Error(`--options auto-approve-path refused: ${req.cwd} ships ${findings.join(" and ")}; trusting it would let that configure the session. Trust it yourself once, or launch without the option.`);
417
+ }
418
+ const configVar = configHomeVar(req.runtime);
419
+ const configHome = configVar
420
+ ? (runtimeEnv[configVar] ?? entry.identityPath)
421
+ : undefined;
422
+ // Persisted only when it differs from the host's, so a launch with neither
423
+ // an identity nor an override writes nothing new and reads exactly as
424
+ // before. `refreshed()` has only the entry in scope and must agree.
425
+ if (configVar && configHome && configHome !== this.hostEnv[configVar])
426
+ entry = await this.registry.update(entry.launchId, { configHome });
427
+ // Written before the agent starts and into the SAME dir the child will open.
428
+ // An identity launch has already had its copy trusted above, so this is a
429
+ // no-op there rather than a second write.
430
+ if (req.options.includes("auto-approve-path") && configHome)
431
+ await AGENTS[req.runtime].trustWorkspace?.(configHome, await workspaceTrustKey(req.cwd));
432
+ const resolveEnv = this.resolutionEnv(req.runtime, configHome);
433
+ let bridgePath;
434
+ if (req.runtime === "claude" && prepared.servers.length) {
435
+ const dir = join(this.home, "mcp");
436
+ await mkdir(dir, { recursive: true, mode: 0o700 });
437
+ bridgePath = join(dir, entry.launchId + ".json");
438
+ entry = await this.registry.update(entry.launchId, {
439
+ mcpConfigPath: bridgePath,
440
+ });
441
+ await writeFile(bridgePath, JSON.stringify(Object.fromEntries(prepared.servers.map((s) => [s.name, s.config]))), { mode: 0o600 });
442
+ }
443
+ return { entry, prepared, runtimeEnv, configHome, resolveEnv, bridgePath };
444
+ }
445
+ /**
446
+ * Phase 6: turn the plan into a command line. This is the widest per-agent
447
+ * split in the launch — Claude and Codex are handed argv and contain
448
+ * themselves from there, while OpenCode is a server Muster starts on an
449
+ * allocated loopback port and then talks to over HTTP, so its "argv" comes
450
+ * back from a builder that also reports the port and an environment patch.
451
+ *
452
+ * Codex's policy overrides are spliced in AFTER launchArgs rather than passed
453
+ * to it, because they are computed from the composed runtime environment that
454
+ * only exists by this point.
455
+ */
456
+ async buildCommand(c) {
457
+ const { req, effective, prepared, bridgePath, selectedPlugins, deadline } = c;
458
+ let { argv, codexPolicy, runtimeEnv, entry } = c;
459
+ if (req.runtime !== "opencode")
460
+ // `runtimeEnv`, not the host's: an identity launch relocates
461
+ // CLAUDE_CONFIG_DIR to the per-launch copy, and the sandbox must grant
462
+ // the dir the child will actually open, not the one Muster runs under.
463
+ argv = launchArgs(effective, this.config, prepared.servers, bridgePath, runtimeEnv);
464
+ if (req.runtime === "codex") {
465
+ codexPolicy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers, req.kind === "task");
466
+ argv.splice(1, 0, ...codexPolicy);
467
+ }
468
+ else if (req.runtime === "opencode" && req.kind === "session") {
469
+ const port = await allocateLoopbackPort();
470
+ const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
471
+ const launch = await openCodeSessionLaunch(effective, this.config, prepared.servers, inheritedMcpNames, port, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
472
+ argv = launch.argv;
473
+ runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
474
+ entry = await this.registry.update(entry.launchId, {
475
+ server_url: launch.serverUrl,
476
+ opencode_port: launch.port,
477
+ });
478
+ }
479
+ else if (req.runtime === "opencode" && req.kind === "task") {
480
+ const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
481
+ const launch = await openCodeTaskLaunch(effective, this.config, prepared.servers, inheritedMcpNames, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
482
+ argv = launch.argv;
483
+ runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
484
+ }
485
+ return { argv, codexPolicy, runtimeEnv, entry };
486
+ }
487
+ /**
488
+ * Phase 7: start the process. The first point at which a process can exist
489
+ * without the registry knowing about it, which is why `spawning` is recorded
490
+ * before the host is asked to launch anything.
491
+ *
492
+ * A task ends here — `startTask` owns its whole lifetime — so this returns an
493
+ * outcome rather than falling through, and only a session carries on to
494
+ * readiness.
495
+ */
496
+ async startProcess(c) {
497
+ const { req, argv, runtimeEnv, policy, driver, lifecycle, deadline } = c;
498
+ let entry = c.entry;
499
+ if (this.closing)
500
+ throw new Error("Launch cancelled: Muster is closing");
501
+ // From here a process may exist without the registry knowing it. See the
502
+ // recovery branch in Registry.transaction.
503
+ entry = await this.registry.update(entry.launchId, { spawning: true });
504
+ if (req.kind === "task")
505
+ return {
506
+ outcome: await this.startTask(entry, argv, runtimeEnv, policy.logMode),
507
+ };
508
+ const launched = await driver.launch({
509
+ argv,
510
+ cwd: req.cwd,
511
+ env: runtimeEnv,
512
+ label: req.prompt.slice(0, 48),
513
+ deadline,
514
+ });
515
+ const root = await processRef(launched.pid);
516
+ if (!root) {
517
+ await driver.stop(launched.hostRef);
518
+ throw new Error("process exited during launch");
519
+ }
520
+ const group = await ownedGroup(launched.pid);
521
+ entry = {
522
+ ...entry,
523
+ root,
524
+ ...(group ? { group } : {}),
525
+ host: driver.id,
526
+ hostRef: launched.hostRef,
527
+ };
528
+ // Only the fields that changed, not the whole entry: `entry` still
529
+ // carries its recorded `requester`, and update() refuses a patch that
530
+ // mentions that field at all, changed or not.
531
+ await this.registry.update(entry.launchId, {
532
+ root,
533
+ ...(group ? { group } : {}),
534
+ host: driver.id,
535
+ hostRef: launched.hostRef,
536
+ });
537
+ // Armed only once hostRef exists: the job addresses the window, and a job
538
+ // armed before the window is recorded would poll a target the registry
539
+ // cannot name. `schedule` is optional on the host interface, so pty and
540
+ // macos-terminal opt out by not implementing it.
541
+ if (driver?.schedule && (lifecycle.idleTimeout ?? lifecycle.ttl)) {
542
+ const armed = await driver
543
+ .schedule(pollInterval(lifecycle), [
544
+ process.execPath,
545
+ // ../dist/muster.js, not ./muster.js: this resolves from src/run.ts
546
+ // under vitest as well as from dist/run.js in the package, landing
547
+ // on <root>/dist either way. Same idiom as the tmux bootstrap.
548
+ fileURLToPath(new URL("../dist/muster.js", import.meta.url)),
549
+ "reap-check",
550
+ "--home",
551
+ this.home,
552
+ "--socket",
553
+ driver.socketName?.() ?? "muster",
554
+ entry.launchId,
555
+ ])
556
+ .then(() => true, () => false);
557
+ // A lifecycle that could not be armed must not fail the launch — the
558
+ // session is up and usable, and the orphan risk is what we had before.
559
+ // But it must not be REPORTED either: a record promising an expiry
560
+ // nothing will honour is worse than one promising none, because there
561
+ // is no way to tell from the outside.
562
+ if (!armed) {
563
+ lifecycle.idleTimeout = null;
564
+ lifecycle.ttl = null;
565
+ entry = await this.registry.update(entry.launchId, {
566
+ idleTimeout: null,
567
+ ttl: null,
568
+ });
569
+ }
570
+ }
571
+ return { entry, root, launched };
572
+ }
573
+ /**
574
+ * Phase 8: wait until the agent has registered a session Muster can address,
575
+ * or until the deadline, whichever comes first. The subtlest phase, and the
576
+ * one where the runtimes differ most: a session record on disk for Claude, a
577
+ * JSON-RPC thread read for Codex, an HTTP endpoint plus a port-ownership
578
+ * check for OpenCode. They share a question, not a mechanism.
579
+ *
580
+ * `diagnostic` is the reason the launch would fail if the deadline arrived
581
+ * now. It is overwritten by anything the loop learns, so a timeout reports
582
+ * the last real explanation rather than "no identity found" — and
583
+ * `neverRegistered` keeps the original, because only that one can be about a
584
+ * consent screen nobody answered.
585
+ *
586
+ * The RPC connection is created by the caller rather than here: `launch`'s
587
+ * `finally` has to close it even when this throws.
588
+ */
589
+ async awaitReadiness(c) {
590
+ const { req, root, launched, driver, policy, requester, resolveEnv, rpc, enforcement, prepared, identityName, model, permissions, terminal, lifecycle, startedAt, deadline, } = c;
591
+ let entry = c.entry;
592
+ let diagnostic = req.runtime === "claude"
593
+ ? "no Claude session registry found (check terminal for workspace trust or login prompts)"
594
+ : req.runtime === "opencode"
595
+ ? "no OpenCode session matched the launch endpoint, cwd, and creation window"
596
+ : "no descendant runtime identity found";
597
+ // Kept so the throw below can tell "nothing ever registered" from a
598
+ // diagnosis the loop actually earned. Only the first case can be about a
599
+ // consent screen; anything later means a session existed.
600
+ const neverRegistered = diagnostic;
601
+ // `resolveEnv`, not `hostEnv`: this connection is how the thread
602
+ // `resolveCodex` found gets READ — both its `rejectionOf` screen, where a
603
+ // non-null answer is fatal, and `codexReachable` at readiness. An
604
+ // app-server reading the operator's CODEX_HOME has no rollout for a thread
605
+ // created under the copy, so it answers "no rollout found", every
606
+ // candidate is rejected, and the launch burns its deadline. Pointing
607
+ // discovery at the copy while its reads stay on the host is the same bug
608
+ // one call downstream.
609
+ while (Date.now() < deadline) {
610
+ if (deadline - Date.now() < 25)
611
+ break;
612
+ if (await isSame(root)) {
613
+ const tracked = (await Promise.all((await descendants(root.pid, Date.now() + 1000, entry.group)).map(processRef))).filter((p) => !!p);
614
+ const refs = [...(entry.descendants ?? [])];
615
+ for (const ref of tracked)
616
+ if (!refs.some((r) => r.pid === ref.pid && r.start === ref.start))
617
+ refs.push(ref);
618
+ if (refs.length !== (entry.descendants ?? []).length) {
619
+ entry = { ...entry, descendants: refs };
620
+ await this.registry.update(entry.launchId, { descendants: refs });
621
+ }
622
+ }
623
+ if (this.closing)
624
+ throw new Error("Launch cancelled: Muster is closing");
625
+ if (!(await isSame(root)))
626
+ throw new Error(`process exited at ${((Date.now() - startedAt) / 1000).toFixed(2)}s`);
627
+ let openingTerminal = false;
628
+ try {
629
+ const identity = req.runtime === "codex"
630
+ ? await resolveCodex(root.pid, resolveEnv, deadline, async (id) => {
631
+ try {
632
+ const { thread } = await rpc.call("thread/read", { threadId: id }, deadline);
633
+ return threadRejection(thread, id);
634
+ }
635
+ catch (e) {
636
+ return e.message;
637
+ }
638
+ })
639
+ : req.runtime === "opencode"
640
+ ? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline)
641
+ : await resolveClaude(root.pid, resolveEnv, deadline);
642
+ if (identity) {
643
+ diagnostic =
644
+ req.runtime === "codex"
645
+ ? "thread id reserved but no rollout"
646
+ : req.runtime === "opencode"
647
+ ? "OpenCode session exists but its endpoint status is not readable"
648
+ : "registry exists but inbox socket is not accepting connections";
649
+ let metadata;
650
+ if (req.runtime === "codex")
651
+ metadata = await codexReachable(rpc, identity.id, deadline);
652
+ else if (req.runtime === "opencode")
653
+ metadata = await openCodeReachable(new OpenCodeHttp(entry.server_url), identity, deadline);
654
+ else {
655
+ const claude = identity;
656
+ if (!(await claudeReachable(claude.socketPath, deadline)))
657
+ throw new Error(diagnostic);
658
+ metadata = claude;
659
+ }
660
+ if (Date.now() >= deadline)
661
+ throw new Error("readiness completed after launch deadline");
662
+ if (!(await isSame(root)) || !(await processRef(identity.pid)))
663
+ throw new Error("process exited before readiness completed");
664
+ if (req.runtime === "opencode" &&
665
+ !(await openCodeEndpointOwnedBy(root.pid, entry.server_url, deadline)))
666
+ throw new Error("OpenCode endpoint listener is not owned by the launched process tree");
667
+ const runtime = runtimeName(req.runtime);
668
+ const named = assignNames([
669
+ { runtime, uuid: identity.id, rawName: metadata.rawName },
670
+ ])[0];
671
+ const peer = {
672
+ kind: "session",
673
+ ...permissions,
674
+ enforcement,
675
+ ...prepared.summary,
676
+ name: named.display,
677
+ canonical_id: named.canonicalId,
678
+ runtime,
679
+ state: metadata.state,
680
+ cwd: req.cwd,
681
+ ...(req.runtime === "codex"
682
+ ? { thread_id: identity.id }
683
+ : req.runtime === "opencode"
684
+ ? {
685
+ session_id: identity.id,
686
+ server_url: entry.server_url,
687
+ }
688
+ : { session_id: identity.id }),
689
+ pid: identity.pid,
690
+ host: driver.id,
691
+ capabilities: driver.capabilities(),
692
+ // The RESOLVED name, matching what the entry records and what
693
+ // `list` projects: a launch result and a listing must not
694
+ // disagree about which account this ran as.
695
+ ...(identityName ? { identity: identityName } : {}),
696
+ // Same reason, for the same pair of records.
697
+ model: model?.model ?? null,
698
+ model_source: model?.source ?? null,
699
+ ...(lifecycle.idleTimeout
700
+ ? { idle_timeout: lifecycle.idleTimeout }
701
+ : {}),
702
+ ...(lifecycle.ttl ? { ttl: lifecycle.ttl } : {}),
703
+ attach_hint: driver.attachHint(launched.hostRef),
704
+ };
705
+ if (req.open) {
706
+ openingTerminal = true;
707
+ await driver.open(launched.hostRef, deadline, terminal);
708
+ peer.terminal_opened = true;
709
+ peer.terminal = terminal;
710
+ }
711
+ entry = await this.registry.update(entry.launchId, {
712
+ id: identity.id,
713
+ status: "running",
714
+ peer,
715
+ });
716
+ await this.log.write({ event: "ready", launch_id: entry.launchId, peer }, policy.logMode);
717
+ return peer;
718
+ }
719
+ }
720
+ catch (e) {
721
+ if (openingTerminal)
722
+ throw e;
723
+ // An error arriving after the deadline is dropped, because most of
724
+ // them are artifacts of the deadline itself — aborted round trips
725
+ // that would overwrite a real diagnosis with "timed out". The cost is
726
+ // that a genuine answer landing a moment late is lost too, so raise
727
+ // one as early as it is known rather than at readiness (#20).
728
+ if (Date.now() < deadline)
729
+ diagnostic = e.message;
730
+ }
731
+ await delay(Math.min(250, Math.max(0, deadline - Date.now())));
732
+ }
733
+ // Resolved here rather than before the launch: this reads the operator's
734
+ // live `.claude.json`, and a launch that is going to work should not pay
735
+ // for it. Failing closed is also wrong before a launch — `--kind task`
736
+ // runs headless and never sees the dialog, so refusing an untrusted
737
+ // directory up front would break task launches that work today.
738
+ if (req.runtime === "claude" && diagnostic === neverRegistered)
739
+ diagnostic = claudeRegistryDiagnostic(await claudeWorkspaceTrusted(resolveEnv, req.cwd), req.cwd, claudeConfigDir(resolveEnv));
740
+ throw new Error(`${diagnostic} after ${this.config.launch_timeout_sec}s`);
741
+ }
742
+ async launch(input, requester) {
743
+ const { req, policy, level } = await this.authorize(input, requester);
744
+ const { identityName, identityMeta, credential } = await this.resolveIdentity(req, policy);
745
+ const { effective, terminal, permissions, enforcement, agentBase, selectedMcp, selectedPlugins, model, driver, lifecycle, argv: plannedArgv, } = await this.plan(req, policy, requester, level);
746
+ let argv = plannedArgv;
747
+ let codexPolicy = [];
748
+ const reserved = await this.reserveLaunch({
749
+ req,
750
+ effective,
751
+ policy,
752
+ requester,
753
+ permissions,
754
+ enforcement,
755
+ model,
756
+ lifecycle,
757
+ selectedMcp,
758
+ selectedPlugins,
759
+ identityName,
760
+ });
761
+ if ("outcome" in reserved)
762
+ return reserved.outcome;
763
+ const reservation = reserved.entry;
301
764
  let entry = reservation;
302
765
  const startedAt = Date.now();
303
766
  const deadline = startedAt + this.config.launch_timeout_sec * 1000;
@@ -327,315 +790,70 @@ export class Muster {
327
790
  ...permissions,
328
791
  enforcement,
329
792
  }, policy.logMode);
330
- const prepared = await prepareMcp(selectedMcp, mcpEnvironment(selectedMcp, agentBase, this.sourceEnv), req.cwd, deadline);
331
- let runtimeEnv = mcpEnvironment(prepared.servers, agentBase, this.sourceEnv);
332
- entry = await this.registry.update(entry.launchId, prepared.summary);
333
- if (identityName && identityMeta) {
334
- // The launch id exists now, so the copy can be made and named after it.
335
- const copy = await copyIdentity(this.home, identityName, identityMeta, entry.launchId);
336
- // Written into the copy, never the live ~/.claude.json: a running
337
- // session rewrites that file continuously and Muster cannot coordinate.
338
- if (identityMeta.agent === "claude")
339
- // RESOLVED, not as written: Claude keys projects[] by what its own
340
- // getcwd(2) returns, which has every symlink resolved. On macOS a
341
- // launch into /tmp/... is really /private/tmp/..., so the un-resolved
342
- // key is one Claude never looks up — the trust dialog appears, the
343
- // session never registers, and the launch burns its whole deadline.
344
- // `assertAllowedWorkspace` already realpaths for containment.
345
- await setWorkspaceTrust(copy, await realpath(req.cwd));
346
- // Codex gates the same way behind a different file and a different key,
347
- // and a fresh copy trusts nothing — so every Codex identity launch sat
348
- // on "Do you trust the contents of this directory?" until the deadline.
349
- // Resolved for the same reason: Codex keys `projects` by its own cwd.
350
- else if (identityMeta.agent === "codex")
351
- await setCodexWorkspaceTrust(copy, await realpath(req.cwd));
352
- runtimeEnv = {
353
- ...runtimeEnv,
354
- ...identityEnv(identityMeta, copy, this.sourceEnv, credential?.token),
355
- };
356
- entry = await this.registry.update(entry.launchId, {
357
- identityPath: copy,
358
- // Recorded alongside the path, because the path outlives the directory
359
- // it names and never named the account.
360
- identity: identityName,
361
- });
362
- }
363
- // Where the agent will record its session. Distinct from `hostEnv`, which
364
- // keeps spawning Muster's own helpers — see `resolutionEnv`.
365
- //
366
- // Derived from the COMPOSED agent environment, not from `identityPath`
367
- // alone: a requester profile's `env_defaults` delivers any key by design,
368
- // so a profile setting CLAUDE_CONFIG_DIR or CODEX_HOME hands the agent one
369
- // configuration home while resolution searched the host's — reserve, spawn,
370
- // dead at the deadline. The identity copy still wins where there is one,
371
- // because `identityEnv` is merged into `runtimeEnv` above.
372
- const configVar = configHomeVar(req.runtime);
373
- const configHome = configVar
374
- ? (runtimeEnv[configVar] ?? entry.identityPath)
375
- : undefined;
376
- // Persisted only when it differs from the host's, so a launch with neither
377
- // an identity nor an override writes nothing new and reads exactly as
378
- // before. `refreshed()` has only the entry in scope and must agree.
379
- if (configVar && configHome && configHome !== this.hostEnv[configVar])
380
- entry = await this.registry.update(entry.launchId, { configHome });
381
- const resolveEnv = this.resolutionEnv(req.runtime, configHome);
382
- let bridgePath;
383
- if (req.runtime === "claude" && prepared.servers.length) {
384
- const dir = join(this.home, "mcp");
385
- await mkdir(dir, { recursive: true, mode: 0o700 });
386
- bridgePath = join(dir, entry.launchId + ".json");
387
- entry = await this.registry.update(entry.launchId, {
388
- mcpConfigPath: bridgePath,
389
- });
390
- await writeFile(bridgePath, JSON.stringify(Object.fromEntries(prepared.servers.map((s) => [s.name, s.config]))), { mode: 0o600 });
391
- }
392
- if (req.runtime !== "opencode")
393
- // `runtimeEnv`, not the host's: an identity launch relocates
394
- // CLAUDE_CONFIG_DIR to the per-launch copy, and the sandbox must grant
395
- // the dir the child will actually open, not the one Muster runs under.
396
- argv = launchArgs(effective, this.config, prepared.servers, bridgePath, runtimeEnv);
397
- if (req.runtime === "codex") {
398
- codexPolicy = await codexPolicyArgs(runtimeEnv, req.cwd, deadline, prepared.servers, req.kind === "task");
399
- argv.splice(1, 0, ...codexPolicy);
400
- }
401
- else if (req.runtime === "opencode" && req.kind === "session") {
402
- const port = await allocateLoopbackPort();
403
- const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
404
- const launch = await openCodeSessionLaunch(effective, this.config, prepared.servers, inheritedMcpNames, port, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
405
- argv = launch.argv;
406
- runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
407
- entry = await this.registry.update(entry.launchId, {
408
- server_url: launch.serverUrl,
409
- opencode_port: launch.port,
410
- });
411
- }
412
- else if (req.runtime === "opencode" && req.kind === "task") {
413
- const inheritedMcpNames = await openCodeMcpNames(runtimeEnv, req.cwd, deadline);
414
- const launch = await openCodeTaskLaunch(effective, this.config, prepared.servers, inheritedMcpNames, runtimeEnv, req.cwd, deadline, selectedPlugins, this.sourceEnv);
415
- argv = launch.argv;
416
- runtimeEnv = { ...runtimeEnv, ...launch.envPatch };
417
- }
418
- if (this.closing)
419
- throw new Error("Launch cancelled: Muster is closing");
420
- // From here a process may exist without the registry knowing it. See the
421
- // recovery branch in Registry.transaction.
422
- entry = await this.registry.update(entry.launchId, { spawning: true });
423
- if (req.kind === "task")
424
- return await this.startTask(entry, argv, runtimeEnv, policy.logMode);
425
- const launched = await driver.launch({
426
- argv,
427
- cwd: req.cwd,
428
- env: runtimeEnv,
429
- label: req.prompt.slice(0, 48),
793
+ const environment = await this.prepareEnvironment({
794
+ req,
795
+ entry,
796
+ identityName,
797
+ identityMeta,
798
+ credential,
799
+ selectedMcp,
800
+ agentBase,
430
801
  deadline,
431
802
  });
432
- const root = await processRef(launched.pid);
433
- if (!root) {
434
- await driver.stop(launched.hostRef);
435
- throw new Error("process exited during launch");
436
- }
437
- const group = await ownedGroup(launched.pid);
438
- entry = {
439
- ...entry,
440
- root,
441
- ...(group ? { group } : {}),
442
- host: driver.id,
443
- hostRef: launched.hostRef,
444
- };
445
- // Only the fields that changed, not the whole entry: `entry` still
446
- // carries its recorded `requester`, and update() refuses a patch that
447
- // mentions that field at all, changed or not.
448
- await this.registry.update(entry.launchId, {
449
- root,
450
- ...(group ? { group } : {}),
451
- host: driver.id,
452
- hostRef: launched.hostRef,
803
+ entry = environment.entry;
804
+ // `runtimeEnv` is still reassigned below — an OpenCode launch patches it
805
+ // with the port its server ended up on — so it stays a `let` here rather
806
+ // than being destructured as a const.
807
+ let runtimeEnv = environment.runtimeEnv;
808
+ const { prepared, configHome, resolveEnv, bridgePath } = environment;
809
+ ({ argv, codexPolicy, runtimeEnv, entry } = await this.buildCommand({
810
+ req,
811
+ effective,
812
+ entry,
813
+ argv,
814
+ codexPolicy,
815
+ runtimeEnv,
816
+ prepared,
817
+ bridgePath,
818
+ selectedPlugins,
819
+ deadline,
820
+ }));
821
+ const started = await this.startProcess({
822
+ req,
823
+ entry,
824
+ argv,
825
+ runtimeEnv,
826
+ policy,
827
+ driver,
828
+ lifecycle,
829
+ deadline,
453
830
  });
454
- // Armed only once hostRef exists: the job addresses the window, and a job
455
- // armed before the window is recorded would poll a target the registry
456
- // cannot name. `schedule` is optional on the host interface, so pty and
457
- // macos-terminal opt out by not implementing it.
458
- if (driver?.schedule && (lifecycle.idleTimeout ?? lifecycle.ttl)) {
459
- const armed = await driver
460
- .schedule(pollInterval(lifecycle), [
461
- process.execPath,
462
- // ../dist/muster.js, not ./muster.js: this resolves from src/run.ts
463
- // under vitest as well as from dist/run.js in the package, landing
464
- // on <root>/dist either way. Same idiom as the tmux bootstrap.
465
- fileURLToPath(new URL("../dist/muster.js", import.meta.url)),
466
- "reap-check",
467
- "--home",
468
- this.home,
469
- "--socket",
470
- driver.socketName?.() ?? "muster",
471
- entry.launchId,
472
- ])
473
- .then(() => true, () => false);
474
- // A lifecycle that could not be armed must not fail the launch — the
475
- // session is up and usable, and the orphan risk is what we had before.
476
- // But it must not be REPORTED either: a record promising an expiry
477
- // nothing will honour is worse than one promising none, because there
478
- // is no way to tell from the outside.
479
- if (!armed) {
480
- lifecycle.idleTimeout = null;
481
- lifecycle.ttl = null;
482
- entry = await this.registry.update(entry.launchId, {
483
- idleTimeout: null,
484
- ttl: null,
485
- });
486
- }
487
- }
488
- let diagnostic = req.runtime === "claude"
489
- ? "no Claude session registry found (check terminal for workspace trust or login prompts)"
490
- : req.runtime === "opencode"
491
- ? "no OpenCode session matched the launch endpoint, cwd, and creation window"
492
- : "no descendant runtime identity found";
493
- // Kept so the throw below can tell "nothing ever registered" from a
494
- // diagnosis the loop actually earned. Only the first case can be about a
495
- // consent screen; anything later means a session existed.
496
- const neverRegistered = diagnostic;
497
- // `resolveEnv`, not `hostEnv`: this connection is how the thread
498
- // `resolveCodex` found gets READ — both its `rejectionOf` screen, where a
499
- // non-null answer is fatal, and `codexReachable` at readiness. An
500
- // app-server reading the operator's CODEX_HOME has no rollout for a thread
501
- // created under the copy, so it answers "no rollout found", every
502
- // candidate is rejected, and the launch burns its deadline. Pointing
503
- // discovery at the copy while its reads stay on the host is the same bug
504
- // one call downstream.
831
+ if ("outcome" in started)
832
+ return started.outcome;
833
+ entry = started.entry;
834
+ const { root, launched } = started;
505
835
  if (req.runtime === "codex")
506
836
  rpc = new CodexRpc(resolveEnv, req.cwd);
507
- while (Date.now() < deadline) {
508
- if (deadline - Date.now() < 25)
509
- break;
510
- if (await isSame(root)) {
511
- const tracked = (await Promise.all((await descendants(root.pid, Date.now() + 1000, entry.group)).map(processRef))).filter((p) => !!p);
512
- const refs = [...(entry.descendants ?? [])];
513
- for (const ref of tracked)
514
- if (!refs.some((r) => r.pid === ref.pid && r.start === ref.start))
515
- refs.push(ref);
516
- if (refs.length !== (entry.descendants ?? []).length) {
517
- entry = { ...entry, descendants: refs };
518
- await this.registry.update(entry.launchId, { descendants: refs });
519
- }
520
- }
521
- if (this.closing)
522
- throw new Error("Launch cancelled: Muster is closing");
523
- if (!(await isSame(root)))
524
- throw new Error(`process exited at ${((Date.now() - startedAt) / 1000).toFixed(2)}s`);
525
- let openingTerminal = false;
526
- try {
527
- const identity = req.runtime === "codex"
528
- ? await resolveCodex(root.pid, resolveEnv, deadline, async (id) => {
529
- try {
530
- const { thread } = await rpc.call("thread/read", { threadId: id }, deadline);
531
- return threadRejection(thread, id);
532
- }
533
- catch (e) {
534
- return e.message;
535
- }
536
- })
537
- : req.runtime === "opencode"
538
- ? await resolveOpenCode(root.pid, entry.server_url, req.cwd, startedAt, deadline)
539
- : await resolveClaude(root.pid, resolveEnv, deadline);
540
- if (identity) {
541
- diagnostic =
542
- req.runtime === "codex"
543
- ? "thread id reserved but no rollout"
544
- : req.runtime === "opencode"
545
- ? "OpenCode session exists but its endpoint status is not readable"
546
- : "registry exists but inbox socket is not accepting connections";
547
- let metadata;
548
- if (req.runtime === "codex")
549
- metadata = await codexReachable(rpc, identity.id, deadline);
550
- else if (req.runtime === "opencode")
551
- metadata = await openCodeReachable(new OpenCodeHttp(entry.server_url), identity, deadline);
552
- else {
553
- const claude = identity;
554
- if (!(await claudeReachable(claude.socketPath, deadline)))
555
- throw new Error(diagnostic);
556
- metadata = claude;
557
- }
558
- if (Date.now() >= deadline)
559
- throw new Error("readiness completed after launch deadline");
560
- if (!(await isSame(root)) || !(await processRef(identity.pid)))
561
- throw new Error("process exited before readiness completed");
562
- if (req.runtime === "opencode" &&
563
- !(await openCodeEndpointOwnedBy(root.pid, entry.server_url, deadline)))
564
- throw new Error("OpenCode endpoint listener is not owned by the launched process tree");
565
- const runtime = runtimeName(req.runtime);
566
- const named = assignNames([
567
- { runtime, uuid: identity.id, rawName: metadata.rawName },
568
- ])[0];
569
- const peer = {
570
- kind: "session",
571
- ...permissions,
572
- enforcement,
573
- ...prepared.summary,
574
- name: named.display,
575
- canonical_id: named.canonicalId,
576
- runtime,
577
- state: metadata.state,
578
- cwd: req.cwd,
579
- ...(req.runtime === "codex"
580
- ? { thread_id: identity.id }
581
- : req.runtime === "opencode"
582
- ? {
583
- session_id: identity.id,
584
- server_url: entry.server_url,
585
- }
586
- : { session_id: identity.id }),
587
- pid: identity.pid,
588
- host: driver.id,
589
- capabilities: driver.capabilities(),
590
- // The RESOLVED name, matching what the entry records and what
591
- // `list` projects: a launch result and a listing must not
592
- // disagree about which account this ran as.
593
- ...(identityName ? { identity: identityName } : {}),
594
- // Same reason, for the same pair of records.
595
- model: model?.model ?? null,
596
- model_source: model?.source ?? null,
597
- ...(lifecycle.idleTimeout
598
- ? { idle_timeout: lifecycle.idleTimeout }
599
- : {}),
600
- ...(lifecycle.ttl ? { ttl: lifecycle.ttl } : {}),
601
- attach_hint: driver.attachHint(launched.hostRef),
602
- };
603
- if (req.open) {
604
- openingTerminal = true;
605
- await driver.open(launched.hostRef, deadline, terminal);
606
- peer.terminal_opened = true;
607
- peer.terminal = terminal;
608
- }
609
- entry = await this.registry.update(entry.launchId, {
610
- id: identity.id,
611
- status: "running",
612
- peer,
613
- });
614
- await this.log.write({ event: "ready", launch_id: entry.launchId, peer }, policy.logMode);
615
- return peer;
616
- }
617
- }
618
- catch (e) {
619
- if (openingTerminal)
620
- throw e;
621
- // An error arriving after the deadline is dropped, because most of
622
- // them are artifacts of the deadline itself — aborted round trips
623
- // that would overwrite a real diagnosis with "timed out". The cost is
624
- // that a genuine answer landing a moment late is lost too, so raise
625
- // one as early as it is known rather than at readiness (#20).
626
- if (Date.now() < deadline)
627
- diagnostic = e.message;
628
- }
629
- await delay(Math.min(250, Math.max(0, deadline - Date.now())));
630
- }
631
- // Resolved here rather than before the launch: this reads the operator's
632
- // live `.claude.json`, and a launch that is going to work should not pay
633
- // for it. Failing closed is also wrong before a launch — `--kind task`
634
- // runs headless and never sees the dialog, so refusing an untrusted
635
- // directory up front would break task launches that work today.
636
- if (req.runtime === "claude" && diagnostic === neverRegistered)
637
- diagnostic = claudeRegistryDiagnostic(await claudeWorkspaceTrusted(resolveEnv, req.cwd), req.cwd, claudeConfigDir(resolveEnv));
638
- throw new Error(`${diagnostic} after ${this.config.launch_timeout_sec}s`);
837
+ return await this.awaitReadiness({
838
+ req,
839
+ entry,
840
+ root,
841
+ launched,
842
+ driver,
843
+ policy,
844
+ requester,
845
+ resolveEnv,
846
+ rpc,
847
+ enforcement,
848
+ prepared,
849
+ identityName,
850
+ model,
851
+ permissions,
852
+ terminal,
853
+ lifecycle,
854
+ startedAt,
855
+ deadline,
856
+ });
639
857
  }
640
858
  catch (e) {
641
859
  await this.stopEntry(entry).catch(() => { });
@@ -649,9 +867,6 @@ export class Muster {
649
867
  .catch(() => { });
650
868
  throw e;
651
869
  }
652
- finally {
653
- await rpc?.close();
654
- }
655
870
  }
656
871
  /**
657
872
  * The answer to a request we have already seen. Never a new launch: a repeat
@@ -733,17 +948,11 @@ export class Muster {
733
948
  }
734
949
  async stopEntry(entry) {
735
950
  try {
736
- const openCodeSessionId = entry.peer?.session_id;
737
- const abortDeadline = Date.now() + 1000;
738
- if (entry.runtime === "opencode" &&
739
- entry.root &&
740
- entry.server_url &&
741
- typeof openCodeSessionId === "string" &&
742
- (await isSame(entry.root)) &&
743
- (await openCodeEndpointOwnedBy(entry.root.pid, entry.server_url, abortDeadline)))
744
- await new OpenCodeHttp(entry.server_url)
745
- .abort(openCodeSessionId, abortDeadline)
746
- .catch(() => { });
951
+ // One second, and best effort: the kill below is what guarantees the
952
+ // stop, so an agent that will not answer must not delay it. Which agents
953
+ // have anything to say, and every precondition on saying it, lives on
954
+ // the row rather than here.
955
+ await AGENTS[entry.runtime].beforeStop?.(entry, Date.now() + 1000);
747
956
  await this.stopEntryProcesses(entry);
748
957
  }
749
958
  finally {
@@ -839,47 +1048,11 @@ export class Muster {
839
1048
  !entry.peer)
840
1049
  return entry;
841
1050
  const deadline = Date.now() + 1500;
842
- let rpc;
843
1051
  try {
844
- let metadata;
845
- if (entry.runtime === "codex") {
846
- // Derived from the entry for the same reason as the claude branch
847
- // below: this reads the session's own thread, and a session launched
848
- // under an identity recorded it inside the copy. On `hostEnv` a healthy
849
- // codex identity session reads as `unreachable` in every listing.
850
- rpc = new CodexRpc(this.resolutionEnv("codex", entry.configHome ?? entry.identityPath), entry.cwd);
851
- metadata = await codexReachable(rpc, entry.id, deadline);
852
- }
853
- else if (entry.runtime === "opencode") {
854
- if (!entry.server_url)
855
- throw new Error("session endpoint missing");
856
- if (!(await isSame(entry.root)) ||
857
- !(await openCodeEndpointOwnedBy(entry.root.pid, entry.server_url, deadline)))
858
- throw new Error("session endpoint is not owned by the launch");
859
- metadata = await openCodeReachable(new OpenCodeHttp(entry.server_url), {
860
- id: entry.id,
861
- pid: entry.root.pid,
862
- rawName: null,
863
- cwd: entry.cwd,
864
- serverUrl: entry.server_url,
865
- }, deadline);
866
- if (!(await isSame(entry.root)) ||
867
- !(await openCodeEndpointOwnedBy(entry.root.pid, entry.server_url, deadline)))
868
- throw new Error("session endpoint ownership changed during refresh");
869
- }
870
- else {
871
- // Derived from the entry, not from a launch-time value: this runs long
872
- // after the launch returned and has only the recorded entry in scope. A
873
- // running session launched under an identity recorded itself inside the
874
- // copy, so re-resolution must look there or `list` reports every such
875
- // session as unreachable.
876
- const identity = await resolveClaude(entry.root.pid, this.resolutionEnv(entry.runtime, entry.configHome ?? entry.identityPath), deadline);
877
- if (!identity ||
878
- identity.id !== entry.id ||
879
- !(await claudeReachable(identity.socketPath, deadline)))
880
- throw new Error("session unreachable");
881
- metadata = identity;
882
- }
1052
+ // The agent's own configuration home, which an identity launch relocates
1053
+ // to a per-launch copy — composed here because only Muster knows where
1054
+ // that copy went, and handed to the row that knows what to do with it.
1055
+ const metadata = await AGENTS[entry.runtime].refreshMetadata(entry, this.resolutionEnv(entry.runtime, entry.configHome ?? entry.identityPath), deadline);
883
1056
  const named = assignNames([
884
1057
  {
885
1058
  runtime: runtimeName(entry.runtime),
@@ -900,13 +1073,13 @@ export class Muster {
900
1073
  });
901
1074
  }
902
1075
  catch {
1076
+ // Any failure is "unreachable", including a connection this refresh never
1077
+ // managed to open. Each implementation owns whatever it opened, so there
1078
+ // is nothing left for this to close.
903
1079
  return await this.registry.update(entry.launchId, {
904
1080
  peer: { ...entry.peer, state: "unreachable" },
905
1081
  });
906
1082
  }
907
- finally {
908
- await rpc?.close();
909
- }
910
1083
  }
911
1084
  /**
912
1085
  * Unscoped by requester, unlike stop and output. Filtering the listing is a