baychat 0.33.3 → 0.34.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.
@@ -9,10 +9,13 @@ exports.launchBayEntry = launchBayEntry;
9
9
  const bays_1 = require("../../bays");
10
10
  const detect_1 = require("../../channel/detect");
11
11
  const policy_1 = require("../../launch/policy");
12
+ const acp_launch_1 = require("./acp-launch");
12
13
  const argv_1 = require("./argv");
13
14
  const approval_settings_1 = require("./approval-settings");
14
15
  const capabilities_1 = require("./capabilities");
16
+ const codex_launch_1 = require("./codex-launch");
15
17
  const dev_channel_screen_1 = require("./dev-channel-screen");
18
+ const stop_registered_1 = require("./stop-registered");
16
19
  const multiplexer_1 = require("./multiplexer");
17
20
  const started_1 = require("./started");
18
21
  const refused = (reason) => ({ status: "REFUSED", reason });
@@ -35,38 +38,101 @@ async function handleLaunch(launch, deps, io) {
35
38
  return refused("NAME_INVALID");
36
39
  if (launch.kind === "STOP")
37
40
  return stopSession(launch, deps);
41
+ if ((0, acp_launch_1.isAcpLaunchRuntime)(launch.runtime))
42
+ return startAcp(launch, deps);
38
43
  const started = startSession(launch, deps);
39
44
  if ("status" in started)
40
45
  return started;
41
- // Monitor sessions have no channel flag, so there is no warning to wait for.
42
- if (launch.listen === "monitor")
46
+ // Monitor sessions have no channel flag, so there is no warning to wait for; neither has Codex.
47
+ if (launch.listen === "monitor" || launch.runtime !== "claude")
43
48
  return { status: "STARTED" };
44
49
  return watchForDevChannelWarning(launch, started.entry, deps, io);
45
50
  }
46
- /** Check, spawn and record a START; the report when it ends here, else the recorded session. */
47
- function startSession(launch, deps) {
51
+ /** The checks every START makes before anything runs: the spot, the runtime, the room, the folder. */
52
+ function spotFor(launch, deps) {
48
53
  const spot = deps.loadPolicy()?.spots.find((s) => s.id === launch.spotId);
49
54
  if (!spot)
50
55
  return refused("SPOT_UNKNOWN");
51
56
  if (!(0, policy_1.isLaunchRuntime)(launch.runtime) || !spot.runtimes.includes(launch.runtime))
52
57
  return refused("RUNTIME_NOT_OFFERED");
53
- if (launch.runtime !== "claude")
54
- return refused("RUNTIME_NOT_YET");
55
58
  if (launch.join && "group" in launch.join && (0, argv_1.roomTitleProblem)(launch.join.group))
56
59
  return refused("ROOM_INVALID");
60
+ const folder = deps.resolveFolder(spot);
61
+ if (!folder.ok) {
62
+ deps.log(`launch ${launch.id}: refused, ${folder.reason}`);
63
+ return refused("FOLDER_CHANGED");
64
+ }
65
+ return { spot, folder: folder.path };
66
+ }
67
+ /** A relay-run agent (dsh, Cursor): no terminal — the daemon registers it and runs it per turn. */
68
+ async function startAcp(launch, deps) {
69
+ const place = spotFor(launch, deps);
70
+ if ("status" in place)
71
+ return place;
72
+ if (!deps.acp || !(0, acp_launch_1.isAcpLaunchRuntime)(launch.runtime))
73
+ return refused("RUNTIME_NOT_YET");
74
+ const bays = deps.loadBays();
75
+ if (!launchBayEntry(launch.bay, bays))
76
+ return refused("BAY_NOT_CONNECTED");
77
+ // The relay runs, keeps alive and answers for its agents with its DEFAULT Bay's login, so an
78
+ // agent started into another Bay would live in the wrong one after its first join.
79
+ const defaultBay = bays.find((b) => b.isDefault);
80
+ if (launch.bay && defaultBay && launch.bay.id !== defaultBay.bayId) {
81
+ deps.log(`launch ${launch.id}: refused, a relay-run agent starts only in this computer's default Bay for now`);
82
+ return refused("OTHER_BAY_NOT_YET");
83
+ }
84
+ // The same caps as a terminal session: per computer, and per folder.
85
+ const multiplexer = deps.detectMultiplexer();
86
+ const live = multiplexer ? (0, multiplexer_1.liveLaunchSessions)(multiplexer, deps.run) : [];
87
+ const agents = deps.acp.running();
88
+ // Ended terminal sessions are cleared first, as a terminal START does, so they never count.
89
+ const started = multiplexer ? pruneEnded((0, started_1.loadStarted)((0, started_1.startedRecordPath)(deps.launchDir)), live, deps) : [];
90
+ const inFolder = started.filter((s) => s.spotId === place.spot.id).length + agents.filter((a) => a.cwd === place.folder).length;
91
+ if (live.length + agents.length >= policy_1.MAX_SESSIONS_PER_COMPUTER || inFolder >= place.spot.maxSessions) {
92
+ deps.log(`launch ${launch.id}: refused, too many sessions running (${live.length + agents.length} here, the folder "${place.spot.label}" allows ${place.spot.maxSessions})`);
93
+ return refused("CAP_REACHED");
94
+ }
95
+ return deps.acp.start({ launchId: launch.id, session: launch.sessionName, runtime: launch.runtime, folder: place.folder, join: launch.join });
96
+ }
97
+ /** Check, spawn and record a START; the report when it ends here, else the recorded session. */
98
+ function startSession(launch, deps) {
99
+ const place = spotFor(launch, deps);
100
+ if ("status" in place)
101
+ return place;
102
+ const { spot } = place;
103
+ const folder = { path: place.folder };
104
+ const codex = launch.runtime === "codex";
105
+ if (!codex && launch.runtime !== "claude")
106
+ return refused("RUNTIME_NOT_YET");
57
107
  const bay = launchBayEntry(launch.bay, deps.loadBays());
58
108
  if (!bay) {
59
109
  deps.log(`launch ${launch.id}: refused, this computer is not logged in to the Bay "${launch.bay?.name ?? ""}". Run \`baychat login\` here.`);
60
110
  return refused("BAY_NOT_CONNECTED");
61
111
  }
62
- const folder = deps.resolveFolder(spot);
63
- if (!folder.ok) {
64
- deps.log(`launch ${launch.id}: refused, ${folder.reason}`);
65
- return refused("FOLDER_CHANGED");
112
+ if (codex) {
113
+ // Codex reads only its own config.toml: a relay on a setup of its own has no connection to
114
+ // give it there (a token in its argv would show in `ps`), so that case is not offered yet.
115
+ if (deps.ownServer) {
116
+ deps.log(`launch ${launch.id}: refused, Codex cannot use this relay's own connection yet`);
117
+ return refused("RUNTIME_NOT_YET");
118
+ }
119
+ if (!spot.unattendedCodex) {
120
+ deps.log(`launch ${launch.id}: refused, Codex may not start unattended in "${spot.label}". Run \`baychat launch add ${folder.path} --runtime ${spot.runtimes.join(",")} --unattended\` here to allow it.`);
121
+ return refused("UNATTENDED_NOT_ALLOWED");
122
+ }
123
+ if (launch.join && "group" in launch.join && !codex_launch_1.CODEX_ROOM_ID_RE.test(launch.join.group)) {
124
+ deps.log(`launch ${launch.id}: refused, Codex is started into an existing group only (by id)`);
125
+ return refused("ROOM_EXISTING_ONLY");
126
+ }
127
+ const problem = (0, codex_launch_1.codexLaunchProblem)(folder.path, () => deps.readCodexConfig?.() ?? null, bay.entry ?? bays_1.DEFAULT_MCP_ENTRY);
128
+ if (problem) {
129
+ deps.log(`launch ${launch.id}: refused, ${problem === "RUNTIME_NOT_CONNECTED" ? "Codex has no BayChat connection here. Run `baychat connect codex`." : `Codex has not trusted ${folder.path}. Open Codex there once and trust it.`}`);
130
+ return refused(problem);
131
+ }
66
132
  }
67
- // An untrusted folder would leave claude waiting at "Is this a project you trust?" forever,
68
- // in a detached pane nobody sees. Refuse instead, so the phone can say what to do.
69
- if (!deps.isFolderTrusted(folder.path)) {
133
+ else if (!deps.isFolderTrusted(folder.path)) {
134
+ // An untrusted folder would leave claude waiting at "Is this a project you trust?" forever,
135
+ // in a detached pane nobody sees. Refuse instead, so the phone can say what to do.
70
136
  deps.log(`launch ${launch.id}: refused, Claude Code has not trusted ${folder.path}. Run \`baychat launch add\` for it at this computer.`);
71
137
  return refused("FOLDER_NOT_TRUSTED");
72
138
  }
@@ -87,13 +153,28 @@ function startSession(launch, deps) {
87
153
  // code of its own and the app offers no Replace it (Mac QA 2026-10-16: a loop otherwise).
88
154
  return refused(own ? "NAME_TAKEN" : "NAME_TAKEN_FOREIGN");
89
155
  }
90
- if (live.length >= policy_1.MAX_SESSIONS_PER_COMPUTER || started.filter((s) => s.spotId === spot.id).length >= spot.maxSessions) {
156
+ // Relay-run agents (dsh, Cursor) count too: the caps are per computer and per folder, whatever runs.
157
+ const agents = deps.acp?.running() ?? [];
158
+ if (live.length + agents.length >= policy_1.MAX_SESSIONS_PER_COMPUTER ||
159
+ started.filter((s) => s.spotId === spot.id).length + agents.filter((a) => a.cwd === folder.path).length >= spot.maxSessions) {
91
160
  deps.log(`launch ${launch.id}: refused, too many sessions running (${live.length} here, the folder "${spot.label}" allows ${spot.maxSessions})`);
92
161
  return refused("CAP_REACHED");
93
162
  }
94
- const binary = deps.resolveBinary(capabilities_1.RUNTIME_BINARY.claude);
163
+ const binary = deps.resolveBinary(capabilities_1.RUNTIME_BINARY[codex ? "codex" : "claude"]);
95
164
  if (!binary.ok)
96
165
  return refused("RUNTIME_MISSING");
166
+ if (codex) {
167
+ const command = (0, argv_1.buildCodexLaunchCommand)({
168
+ multiplexer,
169
+ sessionName: launch.sessionName,
170
+ folder: folder.path,
171
+ codexBin: binary.path,
172
+ env: { ...(deps.relayEnv ?? {}), PATH: deps.pathEnv },
173
+ join: launch.join,
174
+ bayEntry: bay.entry,
175
+ });
176
+ return spawnRecorded(launch, deps, command, spot, multiplexer, started, recordFile, "Codex runs with --ask-for-approval never in its workspace sandbox");
177
+ }
97
178
  // Monitor: no channel flag and no channel marker — the session listens with listen_messages + Monitor.
98
179
  const channel = launch.listen === "monitor" ? { args: [], env: {} } : deps.channelLaunch(binary.path);
99
180
  // A relay on a setup of its own gives the session its own connection to the relay's server,
@@ -120,6 +201,11 @@ function startSession(launch, deps) {
120
201
  join: launch.join,
121
202
  bayEntry,
122
203
  });
204
+ return spawnRecorded(launch, deps, command, spot, multiplexer, started, recordFile, "Approvals for this session come to BayChat");
205
+ }
206
+ /** Run the multiplexer command and record the session BEFORE any watching, so a STOP (the
207
+ * owner's Deny, or Stop) can always find it. */
208
+ function spawnRecorded(launch, deps, command, spot, multiplexer, started, recordFile, note) {
123
209
  const res = deps.run(command.file, command.args, { cwd: command.cwd, env: command.env });
124
210
  if (res.error || res.status !== 0) {
125
211
  (0, approval_settings_1.removeLaunchFiles)(launch.sessionName, deps.launchDir);
@@ -127,9 +213,8 @@ function startSession(launch, deps) {
127
213
  return { status: "FAILED", reason: "SPAWN_FAILED" };
128
214
  }
129
215
  const entry = { name: launch.sessionName, spotId: spot.id, runtime: launch.runtime, multiplexer, launchId: launch.id, startedAt: deps.now().toISOString() };
130
- // Recorded BEFORE any watching, so a STOP (the owner's Deny, or Stop) can always find it.
131
216
  (0, started_1.saveStarted)([...started, entry], recordFile);
132
- deps.log(`launch ${launch.id}: started "${launch.sessionName}" in "${spot.label}" (${launch.listen}) for ${launch.requestedBy.name}. Approvals for this session come to BayChat.`);
217
+ deps.log(`launch ${launch.id}: started "${launch.sessionName}" (${launch.runtime}) in "${spot.label}" (${launch.listen}) for ${launch.requestedBy.name}. ${note}.`);
133
218
  return { entry };
134
219
  }
135
220
  /**
@@ -245,12 +330,18 @@ function stopOwn(launch, entry, deps, report, why) {
245
330
  * STOP kills only what the launcher itself started: the session must be in started.json AND
246
331
  * alive. A hand-made `bc-x` tmux session, or one listed but already gone, is never touched.
247
332
  */
248
- function stopSession(launch, deps) {
333
+ async function stopSession(launch, deps) {
249
334
  const recordFile = (0, started_1.startedRecordPath)(deps.launchDir);
250
335
  const started = (0, started_1.loadStarted)(recordFile);
251
336
  const entry = started.find((s) => s.name === launch.sessionName);
252
- if (!entry)
253
- return refused("NOT_LAUNCHED");
337
+ if (!entry) {
338
+ // A relay-run agent (dsh, Cursor) has no terminal: End is the relay no longer running it.
339
+ if (deps.acp?.stop(launch.sessionName)) {
340
+ deps.forgetSession?.(launch.sessionName);
341
+ return { status: "STARTED" };
342
+ }
343
+ return launch.scope === "any" ? await stopRegisteredSession(launch, deps) : refused("NOT_LAUNCHED");
344
+ }
254
345
  const outcome = stopRecorded(entry, deps);
255
346
  if (outcome === "not-running") {
256
347
  deps.log(`launch ${launch.id}: "${entry.name}" was not running any more; its records are cleared`);
@@ -263,6 +354,28 @@ function stopSession(launch, deps) {
263
354
  deps.log(`launch ${launch.id}: stopped "${entry.name}" for ${launch.requestedBy.name} — its ${entry.multiplexer} session is gone (checked)`);
264
355
  return { status: "STARTED" };
265
356
  }
357
+ /** A scope "any" STOP for a session the launcher did not start: its registered pane, checked. */
358
+ async function stopRegisteredSession(launch, deps) {
359
+ if (!deps.registeredSession || !deps.stopRegisteredDeps)
360
+ return refused("NOT_LAUNCHED");
361
+ const name = launch.sessionName;
362
+ const outcome = await (0, stop_registered_1.stopRegistered)(name, deps.registeredSession(name), deps.stopRegisteredDeps);
363
+ switch (outcome) {
364
+ case "stopped":
365
+ deps.forgetSession?.(name);
366
+ deps.log(`launch ${launch.id}: closed "${name}" for ${launch.requestedBy.name} (End in BayChat)`);
367
+ return { status: "STARTED" };
368
+ case "not-running":
369
+ deps.forgetSession?.(name);
370
+ return refused("NOT_RUNNING");
371
+ case "not-ours":
372
+ return refused("NOT_OURS");
373
+ case "not-registered":
374
+ return refused("NOT_REGISTERED");
375
+ default:
376
+ return { status: "FAILED", reason: "STOP_FAILED" };
377
+ }
378
+ }
266
379
  /**
267
380
  * The one stop routine (STOP, and a launch abandoned while watching its pane): kill the recorded
268
381
  * session if it is alive, then forget it and delete its settings file. A kill that fails leaves
@@ -359,6 +472,8 @@ function parseLaunch(raw) {
359
472
  ...parseBay(l.bay),
360
473
  // Only an explicit `true` authorises; a missing or odd value is a refusal.
361
474
  commandAuthorized: l.commandAuthorized === true,
475
+ // Only an explicit "any" on a STOP widens it; anything else is the launcher's own sessions.
476
+ ...(l.kind === "STOP" && l.scope === "any" ? { scope: "any" } : {}),
362
477
  };
363
478
  }
364
479
  /**
@@ -45,7 +45,10 @@ const runtime_binary_1 = require("../../runtime-binary");
45
45
  const spawn_env_1 = require("../spawn-env");
46
46
  const approval_settings_1 = require("./approval-settings");
47
47
  const capabilities_1 = require("./capabilities");
48
+ const codex_launch_1 = require("./codex-launch");
48
49
  const relay_identity_1 = require("./relay-identity");
50
+ const session_spots_1 = require("./session-spots");
51
+ const stop_registered_1 = require("./stop-registered");
49
52
  const handle_1 = require("./handle");
50
53
  const multiplexer_1 = require("./multiplexer");
51
54
  /**
@@ -67,9 +70,13 @@ const BACKOFF_MAX_MS = 60_000;
67
70
  const CAPABILITIES_REFRESH_MS = 10 * 60_000;
68
71
  /** A long-poll holds for WAIT_SECONDS; give the request this much beyond it before calling it dead. */
69
72
  const REQUEST_GRACE_MS = 15_000;
70
- async function startLauncher({ auth, log, signal, onLaunch, forgetSession, deps: overrides }) {
71
- const deps = { ...realLoopDeps(auth, log, signal, forgetSession), ...overrides };
73
+ async function startLauncher(opts) {
74
+ const { auth, log, signal, onLaunch, deps: overrides } = opts;
75
+ const deps = { ...realLoopDeps(auth, log, signal, opts), ...overrides };
72
76
  let advertised;
77
+ // What was last reported to PUT /launch-sessions; `false` once the server said it has no such
78
+ // route (an older server), so it is not asked again until the relay restarts.
79
+ let reportedSpots;
73
80
  let unreported;
74
81
  let backoff = BACKOFF_MIN_MS;
75
82
  while (!signal.aborted) {
@@ -84,6 +91,7 @@ async function startLauncher({ auth, log, signal, onLaunch, forgetSession, deps:
84
91
  await deps.request(auth, "PUT", "/api/device-api/launch-capabilities", deps.capabilities(), signal);
85
92
  advertised = { stamp, at: deps.now() };
86
93
  }
94
+ reportedSpots = await reportSessionSpots(deps, auth, signal, reportedSpots, log);
87
95
  const answer = (await deps.request(auth, "GET", `/api/device-api/launches/next?wait=${WAIT_SECONDS}`, undefined, signal));
88
96
  const raw = answer?.launch;
89
97
  if (raw) {
@@ -113,6 +121,32 @@ async function startLauncher({ auth, log, signal, onLaunch, forgetSession, deps:
113
121
  }
114
122
  }
115
123
  }
124
+ /**
125
+ * Send the sessions' launch spots when they changed since the last report. Never fails the loop:
126
+ * "Start again" is a convenience, and launching must go on without it.
127
+ */
128
+ async function reportSessionSpots(deps, auth, signal, last, log) {
129
+ if (last === false || !deps.sessionSpots)
130
+ return last;
131
+ const sessions = deps.sessionSpots();
132
+ const key = JSON.stringify(sessions);
133
+ if (key === last)
134
+ return last;
135
+ try {
136
+ await deps.request(auth, "PUT", "/api/device-api/launch-sessions", { sessions }, signal);
137
+ return key;
138
+ }
139
+ catch (err) {
140
+ if (err instanceof api_1.ApiError && err.status === 404)
141
+ return false;
142
+ log(`launcher: could not report the sessions' folders (${describe(err)})`);
143
+ // Refused as it is (a 4xx: say, a runtime this server does not know yet): sending the same
144
+ // report again every poll would only be refused again. It goes again once it changes.
145
+ if (err instanceof api_1.ApiError && err.status >= 400 && err.status < 500)
146
+ return key;
147
+ return last;
148
+ }
149
+ }
116
150
  /** A bug in handling one launch must report FAILED, not spin the loop on the same launch. */
117
151
  async function safeHandle(deps, launch, io, log) {
118
152
  try {
@@ -147,7 +181,7 @@ function idOf(raw) {
147
181
  function describe(err) {
148
182
  return err instanceof Error ? err.message : String(err);
149
183
  }
150
- function realLoopDeps(auth, log, signal, forgetSession) {
184
+ function realLoopDeps(auth, log, signal, { forgetSession, registeredSession, registeredSessions, acp }) {
151
185
  const binaryEnv = () => (0, runtime_binary_1.currentBinaryEnv)();
152
186
  const handleDeps = () => ({
153
187
  loadPolicy: () => (0, policy_1.loadLaunchPolicy)(),
@@ -173,6 +207,10 @@ function realLoopDeps(auth, log, signal, forgetSession) {
173
207
  now: () => new Date(),
174
208
  log,
175
209
  forgetSession,
210
+ registeredSession,
211
+ stopRegisteredDeps: (0, stop_registered_1.realStopRegisteredDeps)(multiplexer_1.realRun, log),
212
+ readCodexConfig: () => (0, codex_launch_1.readCodexConfig)(),
213
+ acp,
176
214
  });
177
215
  return {
178
216
  request: exports.deviceRequest,
@@ -192,6 +230,11 @@ function realLoopDeps(auth, log, signal, forgetSession) {
192
230
  handle: (launch, io) => (0, handle_1.handleLaunch)(launch, handleDeps(), io),
193
231
  sleep: sleepUnlessAborted,
194
232
  now: () => Date.now(),
233
+ ...(registeredSessions
234
+ ? {
235
+ sessionSpots: () => (0, session_spots_1.sessionSpots)(registeredSessions(), (0, policy_1.loadLaunchPolicy)(), { resolveFolder: policy_1.resolveSpotFolder, realpath: session_spots_1.realRealpath }),
236
+ }
237
+ : {}),
195
238
  };
196
239
  }
197
240
  /**
@@ -39,6 +39,8 @@ exports.detectMultiplexer = detectMultiplexer;
39
39
  exports.liveLaunchSessions = liveLaunchSessions;
40
40
  exports.launchSessionExists = launchSessionExists;
41
41
  exports.launchSessionState = launchSessionState;
42
+ exports.defaultTmuxSocket = defaultTmuxSocket;
43
+ exports.healTmuxSocket = healTmuxSocket;
42
44
  exports.killLaunchSession = killLaunchSession;
43
45
  exports.tmuxPaneTarget = tmuxPaneTarget;
44
46
  exports.capturePane = capturePane;
@@ -120,11 +122,21 @@ function launchSessionExists(multiplexer, sessionName, run) {
120
122
  * forgets a live session because tmux was briefly unreachable. tmux answers non-zero both for
121
123
  * "no such session" and "no server running" — either way there is no `bc-<name>`.
122
124
  */
123
- function launchSessionState(multiplexer, sessionName, run) {
125
+ function launchSessionState(multiplexer, sessionName, run, heal = healTmuxSocket) {
124
126
  if (multiplexer === "tmux") {
125
- const res = run("tmux", ["has-session", "-t", `=${(0, argv_1.multiplexerSessionName)(sessionName)}`]);
127
+ const ask = () => run("tmux", ["has-session", "-t", `=${(0, argv_1.multiplexerSessionName)(sessionName)}`]);
128
+ let res = ask();
126
129
  if (res.error)
127
130
  return "unknown";
131
+ // The socket gone under a live server reads exactly like "no session" — and on 2026-10-22 it
132
+ // made the relay forget every launch (started.json emptied), so End could close none of them.
133
+ if (res.status !== 0 && SOCKET_UNREACHABLE.test(res.stderr)) {
134
+ const healed = heal(run);
135
+ if (healed === "failed" || healed === "signalled")
136
+ return "unknown";
137
+ if (healed === "healed")
138
+ res = ask();
139
+ }
128
140
  return res.status === 0 ? "running" : "ended";
129
141
  }
130
142
  // `screen -ls` exits 1 even when listing, so only a failure to run it is "unknown".
@@ -132,6 +144,69 @@ function launchSessionState(multiplexer, sessionName, run) {
132
144
  return "unknown";
133
145
  return liveLaunchSessions(multiplexer, run).includes(sessionName) ? "running" : "ended";
134
146
  }
147
+ /** tmux's answer when it cannot reach a server through the socket file (as opposed to "can't find
148
+ * session" from a server that answered). "no server running" is the same thing when the socket's
149
+ * directory survived but the file did not. */
150
+ const SOCKET_UNREACHABLE = /error connecting to|no server running/i;
151
+ const realHealOps = {
152
+ exists: (file) => fs.existsSync(file),
153
+ mkdir: (dir) => fs.mkdirSync(dir, { recursive: true, mode: 0o700 }),
154
+ signal: (pid) => process.kill(pid, "SIGUSR1"),
155
+ };
156
+ /** The default tmux server's socket for this user: `$TMUX_TMPDIR` (else /tmp) + `tmux-<uid>/default`. */
157
+ function defaultTmuxSocket(env = process.env, uid = process.getuid?.() ?? 0) {
158
+ return path.join(env.TMUX_TMPDIR || "/tmp", `tmux-${uid}`, "default");
159
+ }
160
+ /**
161
+ * Make a tmux server's socket again when it was deleted under the running server. macOS's daily
162
+ * cleanup removes what under /private/tmp has not been touched for days (the Mac mini,
163
+ * 2026-10-22: `tmux-502/` gone, the server alive with every session, and every tmux command
164
+ * "error connecting"). tmux's documented cure is SIGUSR1 to the SERVER, which recreates its socket.
165
+ *
166
+ * Only a server is signalled, never a client (SIGUSR1 would end a client, detaching someone's
167
+ * terminal): the pid the session recorded from `$TMUX` when given, else this user's `tmux`
168
+ * processes whose parent is pid 1 — a tmux server daemonizes, a client keeps its shell as parent.
169
+ * Never waits (the relay is one thread): a socket not back yet is `signalled`, and the caller looks
170
+ * again later — a sweep reads "unknown" meanwhile, a STOP waits for it asynchronously.
171
+ */
172
+ function healTmuxSocket(run, opts = {}, ops = realHealOps) {
173
+ const socket = opts.socket ?? defaultTmuxSocket(process.env, opts.uid);
174
+ if (ops.exists(socket))
175
+ return "present";
176
+ const servers = opts.serverPid ? [opts.serverPid].filter((pid) => isTmuxProcess(pid, run)) : tmuxServers(run, opts.uid);
177
+ if (servers.length === 0)
178
+ return "no-server";
179
+ try {
180
+ ops.mkdir(path.dirname(socket));
181
+ for (const pid of servers)
182
+ ops.signal(pid);
183
+ }
184
+ catch {
185
+ return "failed";
186
+ }
187
+ return ops.exists(socket) ? "healed" : "signalled";
188
+ }
189
+ /** tmux's own process name: `tmux` (macOS `ps` may print its full path), or on Linux the server's
190
+ * `tmux: server` / a client's `tmux: client`. Never a lookalike such as `tmuxinator`. */
191
+ function isTmuxName(comm) {
192
+ return /^tmux(:|$)/.test(path.basename(comm.trim()));
193
+ }
194
+ function isTmuxProcess(pid, run) {
195
+ const res = run("ps", ["-p", String(pid), "-o", "comm="]);
196
+ return !res.error && res.status === 0 && isTmuxName(res.stdout);
197
+ }
198
+ /** This user's tmux SERVERS: `tmux` processes whose parent is pid 1. */
199
+ function tmuxServers(run, uid = process.getuid?.() ?? 0) {
200
+ const res = run("ps", ["-U", String(uid), "-o", "pid=,ppid=,comm="]);
201
+ if (res.error || res.status !== 0)
202
+ return [];
203
+ return res.stdout
204
+ .split("\n")
205
+ .map((line) => line.trim().split(/\s+/))
206
+ .filter(([, ppid, comm]) => ppid === "1" && !!comm && isTmuxName(comm))
207
+ .map(([pid]) => Number(pid))
208
+ .filter((pid) => Number.isInteger(pid) && pid > 0);
209
+ }
135
210
  /**
136
211
  * Kill the launcher session for `sessionName` — the WHOLE `bc-<name>` session, every window and
137
212
  * pane in it, never just the pane or the claude process. True when the multiplexer said it did;
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.MAX_SESSION_SPOTS = void 0;
37
+ exports.sessionSpots = sessionSpots;
38
+ exports.realRealpath = realRealpath;
39
+ const fs = __importStar(require("fs"));
40
+ const policy_1 = require("../../launch/policy");
41
+ const argv_1 = require("./argv");
42
+ /** The server takes at most this many per report. */
43
+ exports.MAX_SESSION_SPOTS = 50;
44
+ function sessionSpots(sessions, policy, deps) {
45
+ const folders = (policy?.spots ?? []).flatMap((spot) => {
46
+ const folder = deps.resolveFolder(spot);
47
+ return folder.ok ? [{ spot, path: folder.path }] : [];
48
+ });
49
+ const out = [];
50
+ for (const session of sessions) {
51
+ if ((0, argv_1.launchNameProblem)(session.name) || !(0, policy_1.isLaunchRuntime)(session.runtime))
52
+ continue;
53
+ // Where it runs is unknown (a registration older than this): say nothing about it.
54
+ const dir = session.resumeCwd ?? session.cwd;
55
+ if (!dir)
56
+ continue;
57
+ const real = deps.realpath(dir);
58
+ const runtime = session.runtime;
59
+ const match = real ? folders.find((f) => f.path === real && f.spot.runtimes.includes(runtime)) : undefined;
60
+ out.push({ name: session.name, spotId: match?.spot.id ?? null, runtime, listen: session.delivery === "channel" ? "channel" : "monitor" });
61
+ if (out.length === exports.MAX_SESSION_SPOTS)
62
+ break;
63
+ }
64
+ return out.sort((a, b) => a.name.localeCompare(b.name));
65
+ }
66
+ function realRealpath(dir) {
67
+ try {
68
+ return fs.realpathSync(dir);
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }