@oddessentials/agent-guild 0.20.0 → 0.22.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.
@@ -8,6 +8,9 @@ import path from 'node:path';
8
8
  import crypto from 'node:crypto';
9
9
  import { Session, newId, clampDimension, cleanName } from './session.mjs';
10
10
  import { prependPath } from './report-shims.mjs';
11
+ import { buildSpawnSpec, runSpec } from './command-resolver.mjs';
12
+ import { tmuxNewSession } from './shells.mjs';
13
+ import { HerdrAgents, herdrAgentReports, herdrSocket } from './herdr.mjs';
11
14
  import { CHANNEL_LABELS } from './install-channels.mjs';
12
15
  import { SELF_PROVIDER } from './self-update.mjs';
13
16
  import { GITHUB_PROVIDER, dropsFromCloneEnv, parseRepo } from './github.mjs';
@@ -21,6 +24,10 @@ function httpError(status, message, code) {
21
24
  return Object.assign(new Error(message), { status, code });
22
25
  }
23
26
 
27
+ function isFolder(dir) {
28
+ try { return fs.statSync(dir).isDirectory(); } catch { return false; }
29
+ }
30
+
24
31
  /**
25
32
  * Layer environment objects. On Windows, variable names are
26
33
  * case-insensitive, so a later "PATH" must replace an inherited "Path"
@@ -54,7 +61,7 @@ export class SessionManager extends EventEmitter {
54
61
  * @param {import('./self-update.mjs').SelfUpdate|null} [opts.selfUpdate] the manager's own upgrade
55
62
  * @param {import('./github.mjs').GitHub|null} [opts.github]
56
63
  */
57
- constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null, github = null, sessionHooks = null }) {
64
+ constructor({ registry, baseEnv, getApiUrl, sessionDefaults = {}, shimDir = null, selfUpdate = null, github = null, sessionHooks = null, store = null }) {
58
65
  super();
59
66
  this.registry = registry;
60
67
  this.baseEnv = baseEnv;
@@ -70,6 +77,13 @@ export class SessionManager extends EventEmitter {
70
77
  /** True once shutdown has begun; no new session may start after that. */
71
78
  this.closing = false;
72
79
  this.installing = new Set();
80
+ /** Session id → how a tmux or herdr card attaches again, and whether its multiplexer session is still there. */
81
+ this.multiplexers = new Map();
82
+ /** Session id → the watcher that shows a running herdr card the agents herdr sees. */
83
+ this.watchers = new Map();
84
+ /** Keeps the tmux and herdr cards across restarts: { load(), save(cards) }, or null. */
85
+ this.store = store;
86
+ this.restoring = false;
73
87
  }
74
88
 
75
89
  list() {
@@ -110,19 +124,184 @@ export class SessionManager extends EventEmitter {
110
124
  if (this.installing.has(provider.id) || this.installsRunningFor(provider.id) > 0) {
111
125
  throw httpError(409, `${provider.tool} is being installed, updated or removed; start it once that finishes`, 'install_in_progress');
112
126
  }
113
- const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId, hooks.args, runShell);
114
127
  this.prepareAccount(provider, signIn, { hooksSupplied: hooks.args.length > 0 });
115
128
  const sessionName = cleanName(name)
116
129
  || (provider.accounts.length > 1 ? `${provider.tool} · ${signIn.label}` : null)
117
130
  || (runShell && this.registry.shellsFor(provider).shells.length > 1 ? `${provider.tool} · ${runShell.label}` : null);
118
- const session = this._spawn({
119
- provider, spawnSpec, cwd: workDir, cols, rows, name: sessionName, resume: resumeId, account: signIn, reporting: hooks.reporting, extraEnv: runShell?.env,
120
- });
131
+ const options = { provider, cwd: workDir, cols, rows, name: sessionName, resume: resumeId, account: signIn, reporting: hooks.reporting, extraEnv: runShell?.env };
132
+ if (runShell?.multiplexer) return this._startMultiplexer(options, runShell, args || []);
133
+ const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId, hooks.args, runShell);
134
+ const session = this._spawn({ ...options, spawnSpec });
121
135
  const model = modelFromArgs([...provider.args, ...(args || [])]);
122
136
  if (model) session.setModel({ name: model }, 'args');
123
137
  return session;
124
138
  }
125
139
 
140
+ /**
141
+ * A Shell session in tmux or herdr. Its client is the session's process;
142
+ * the multiplexer's server outlives it and gives its own environment to
143
+ * every session it starts later, outside Agent Guild too, so the client
144
+ * never carries this session's identity. A tmux card gets a tmux session
145
+ * of its own, made first with that identity, so tools in it report to the
146
+ * card as they would in a shell. A herdr card shows the agents herdr sees.
147
+ */
148
+ async _startMultiplexer(options, shell, args) {
149
+ const { provider, account } = options;
150
+ const id = newId();
151
+ const reportToken = crypto.randomBytes(16).toString('hex');
152
+ // A name of its own, so the card can say how to reattach it.
153
+ const muxName = `guild-${newId(3)}`;
154
+ const mux = this._multiplexer({ provider, account, shell, id, reportToken, muxName });
155
+ const spawnSpec = this.registry.spawnSpec(provider, shell.id === 'tmux' ? [] : args, options.resume, [], mux.named);
156
+ if (shell.id === 'tmux') {
157
+ this._assertCanSpawn();
158
+ const own = this._sessionEnv({ id, reportToken, provider, account, extraEnv: shell.env });
159
+ const env = Object.fromEntries(Object.entries(own).filter(([key]) => mux.dropEnv(key) || key.toUpperCase() === 'PATH'));
160
+ const cols = clampDimension(options.cols, 120, 2, 1000);
161
+ const rows = clampDimension(options.rows, 32, 1, 500);
162
+ // Outside the try: a folder or argument tmux cannot take is the request's fault, refused before tmux runs.
163
+ const input = tmuxNewSession({ name: muxName, cwd: options.cwd, cols, rows, env, args });
164
+ try {
165
+ await mux.run(['-u', 'start-server', ';', 'source-file', '-'], { cwd: options.cwd, input });
166
+ } catch (err) {
167
+ throw httpError(500, `tmux could not make a session: ${(err.stderr || err.message).trim()}`, 'spawn_failed');
168
+ }
169
+ }
170
+ let session;
171
+ try {
172
+ session = this._spawn({
173
+ ...options, id, reportToken, spawnSpec, dropEnv: mux.dropEnv,
174
+ multiplexer: { label: shell.label, attach: shell.multiplexer.attach.replaceAll('{name}', muxName), reattachable: false },
175
+ });
176
+ } catch (err) {
177
+ if (shell.id === 'tmux') mux.run(['kill-session', '-t', `=${muxName}`]).catch(() => {});
178
+ throw err;
179
+ }
180
+ this._track(session, { spawnSpec, mux, shell, card: { id, reportToken, provider: provider.id, account: account.id, shell: shell.id, muxName, name: session.name, cwd: session.cwd, createdAt: session.createdAt } });
181
+ this._watchHerdr(session);
182
+ return session;
183
+ }
184
+
185
+ /** How to run a card's tmux or herdr client, and tell whether the multiplexer still has the card's session. */
186
+ _multiplexer({ provider, account, shell, id, reportToken, muxName }) {
187
+ const named = { ...shell, args: shell.args.map((arg) => arg.replaceAll('{name}', muxName)) };
188
+ const dropEnv = (key) => key.startsWith('AGENT_GUILD_');
189
+ const clientEnv = this._sessionEnv({ id, reportToken, provider, account, extraEnv: shell.env, dropEnv });
190
+ const run = (args, extra = {}) => runSpec(buildSpawnSpec(shell.path, args, clientEnv, this.registry.platform), { env: clientEnv, ...extra });
191
+ // tmux keeps the card's own session by name; a herdr card's session is herdr's, there while its server runs.
192
+ const alive = shell.id === 'tmux'
193
+ ? () => run(['has-session', '-t', `=${muxName}`]).then(() => true, () => false)
194
+ : () => run(['session', 'list', '--json']).then(({ stdout }) => herdrSocket(JSON.parse(stdout), clientEnv, this.registry.platform) !== null, () => false);
195
+ return { named, dropEnv, clientEnv, run, alive };
196
+ }
197
+
198
+ /** Keep a tmux or herdr card for Reattach, here and, through the store, across restarts. */
199
+ _track(session, { spawnSpec, mux, shell, card }) {
200
+ this.multiplexers.set(session.id, { spawnSpec, alive: mux.alive, herdr: shell.id === 'herdr' ? { path: shell.path, env: mux.clientEnv } : null, card });
201
+ this._saveCards();
202
+ session.on('exit', async () => {
203
+ session.multiplexer.reattachable = await mux.alive();
204
+ session._changed();
205
+ });
206
+ session.on('changed', () => {
207
+ if (card.name === session.name) return;
208
+ card.name = session.name;
209
+ this._saveCards();
210
+ });
211
+ }
212
+
213
+ _saveCards() {
214
+ // A stopping manager leaves the file as it is, for the next one to bring the cards back.
215
+ if (!this.store || this.restoring || this.closing) return;
216
+ try {
217
+ this.store.save([...this.multiplexers.values()].map(({ card }) => card));
218
+ } catch (err) {
219
+ console.warn(`[sessions] could not save the tmux and herdr cards: ${err.message}`);
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Bring back the tmux and herdr cards the previous manager had, closed and
225
+ * ready to reattach, with their own ids and report tokens so whatever runs
226
+ * inside reports to them again. A card whose session is gone stays gone.
227
+ */
228
+ async restore() {
229
+ this.restoring = true;
230
+ try {
231
+ // All at once, so a multiplexer slow to answer holds up the start once, not once a card.
232
+ await Promise.all((this.store?.load() ?? []).map((card) => this._restoreCard(card).catch((err) => {
233
+ console.warn(`[sessions] did not bring back the card ${card?.name ?? card?.id}: ${err.message}`);
234
+ })));
235
+ } finally {
236
+ this.restoring = false;
237
+ }
238
+ this._saveCards();
239
+ }
240
+
241
+ async _restoreCard(card) {
242
+ if (typeof card?.id !== 'string' || !/^[a-f0-9]{1,32}$/.test(card.id) || this.sessions.has(card.id)) return;
243
+ if (typeof card.reportToken !== 'string' || !/^[a-f0-9]{32}$/.test(card.reportToken)) return;
244
+ if (typeof card.muxName !== 'string' || !/^guild-[0-9a-f]{6}$/.test(card.muxName)) return;
245
+ const provider = this.registry.get(String(card.provider));
246
+ const shell = provider && this.registry.shellsFor(provider)?.shells.find((s) => s.id === card.shell && s.multiplexer);
247
+ if (!shell) return;
248
+ const account = this.registry.account(provider, card.account);
249
+ const mux = this._multiplexer({ provider, account, shell, id: card.id, reportToken: card.reportToken, muxName: card.muxName });
250
+ if (!(await mux.alive()) || this.closing) return;
251
+ const spawnSpec = this.registry.spawnSpec(provider, [], null, [], mux.named);
252
+ const session = this._spawn({
253
+ provider, spawnSpec: null, cwd: typeof card.cwd === 'string' ? card.cwd : os.homedir(), name: card.name, account,
254
+ id: card.id, reportToken: card.reportToken, dropEnv: mux.dropEnv, extraEnv: shell.env,
255
+ createdAt: typeof card.createdAt === 'string' && !Number.isNaN(Date.parse(card.createdAt)) ? card.createdAt : undefined,
256
+ multiplexer: { label: shell.label, attach: shell.multiplexer.attach.replaceAll('{name}', card.muxName), reattachable: true },
257
+ });
258
+ this._track(session, { spawnSpec, mux, shell, card: { ...card, name: session.name, cwd: session.cwd, createdAt: session.createdAt } });
259
+ }
260
+
261
+ /** Attach a stopped tmux or herdr session's card to its multiplexer session again, keeping its id and report token. */
262
+ async reattach(id) {
263
+ const session = this.get(id);
264
+ const mux = this.multiplexers.get(id);
265
+ if (!mux) throw httpError(400, `${session.name} is not in tmux or herdr`, 'not_reattachable');
266
+ if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
267
+ if (session.status === 'running') throw httpError(409, `${session.name} is still attached`, 'session_running');
268
+ // As the page offers it: only once the manager has found, after the client closed or at a restart, that the multiplexer still has the session.
269
+ if (!session.multiplexer.reattachable) throw httpError(409, `${session.multiplexer.label} no longer has the session ${session.name} ran in`, 'multiplexer_session_gone');
270
+ const alive = await mux.alive();
271
+ if (this.sessions.get(id) !== session || session.status === 'running') throw httpError(409, `${session.name} changed meanwhile`, 'session_running');
272
+ if (!alive) {
273
+ session.multiplexer.reattachable = false;
274
+ session._changed();
275
+ throw httpError(409, `${session.multiplexer.label} no longer has the session ${session.name} ran in`, 'multiplexer_session_gone');
276
+ }
277
+ try {
278
+ // The client can run anywhere, and the card's folder may be gone by now.
279
+ session.reattach(mux.spawnSpec, { cwd: isFolder(session.cwd) ? session.cwd : os.homedir() });
280
+ } catch (err) {
281
+ throw httpError(500, `could not attach to ${session.multiplexer.label}: ${err.message}`, 'spawn_failed');
282
+ }
283
+ session.multiplexer.reattachable = false;
284
+ this._watchHerdr(session);
285
+ return session;
286
+ }
287
+
288
+ /** While a herdr card runs, its agents are the ones herdr reports. */
289
+ _watchHerdr(session) {
290
+ const herdr = this.multiplexers.get(session.id)?.herdr;
291
+ if (!herdr) return;
292
+ this.watchers.get(session.id)?.stop();
293
+ const watcher = new HerdrAgents({ herdr: herdr.path, env: herdr.env, platform: this.registry.platform, onAgents: (agents) => showHerdrAgents(session, agents) });
294
+ this.watchers.set(session.id, watcher);
295
+ // herdr's server may still be starting; each burst of output from the client tries again until connected.
296
+ const poke = () => { if (session.status === 'running') watcher.poke(); };
297
+ session.on('changed', poke);
298
+ session.once('exit', () => {
299
+ session.off('changed', poke);
300
+ watcher.stop();
301
+ if (this.watchers.get(session.id) === watcher) this.watchers.delete(session.id);
302
+ });
303
+ }
304
+
126
305
  prepareAccount(provider, account, { hooksSupplied = false } = {}) {
127
306
  if (!account.dir) return;
128
307
  try {
@@ -242,24 +421,26 @@ export class SessionManager extends EventEmitter {
242
421
  return n;
243
422
  }
244
423
 
245
- /** Sessions whose process is still running, install sessions included. */
424
+ /**
425
+ * Sessions whose process is still running, install sessions included,
426
+ * except tmux and herdr ones: stopping the manager only detaches those,
427
+ * and their cards come back.
428
+ */
246
429
  runningCount() {
247
430
  let n = 0;
248
- for (const s of this.sessions.values()) if (s.status === 'running') n++;
431
+ for (const s of this.sessions.values()) if (s.status === 'running' && !s.multiplexer) n++;
249
432
  return n;
250
433
  }
251
434
 
252
- _spawn({
253
- provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null, installPath = null, account = null,
254
- extraEnv = null, dropEnv = null, clone = null, reporting = null,
255
- }) {
435
+ _assertCanSpawn() {
256
436
  if (this.closing) throw httpError(503, 'the session manager is stopping', 'manager_stopping');
257
437
  if (this.sessions.size >= MAX_SESSIONS) {
258
438
  throw httpError(429, `session limit reached (${MAX_SESSIONS}); remove finished sessions first`, 'too_many_sessions');
259
439
  }
260
- const id = newId();
261
- const reportToken = crypto.randomBytes(16).toString('hex');
440
+ }
262
441
 
442
+ /** The environment of a session's process. */
443
+ _sessionEnv({ id, reportToken, provider, account = null, extraEnv = null, dropEnv = null }) {
263
444
  // The tool's hooks run `agent-guild-report` by name, so the launchers
264
445
  // go first on PATH, after any provider PATH override.
265
446
  let env = prependPath(mergeEnv([this.baseEnv, provider.env, account?.env, {
@@ -274,11 +455,25 @@ export class SessionManager extends EventEmitter {
274
455
  }]), this.shimDir);
275
456
  // The tool runs in its own terminal, not in the terminal or multiplexer
276
457
  // the manager was started from: Claude Code would otherwise open
277
- // agent-team panes in that tmux window, outside the page, and tools
278
- // would tune their output to a terminal program that is not there.
279
- for (const key of ['TMUX', 'TMUX_PANE', 'STY', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'ZELLIJ', 'ZELLIJ_SESSION_NAME', 'ZELLIJ_PANE_ID']) delete env[key];
458
+ // agent-team panes in that tmux window, outside the page, tools would
459
+ // tune their output to a terminal program that is not there, and herdr
460
+ // would refuse to start, taking itself to be nested in a herdr pane.
461
+ for (const key of [
462
+ 'TMUX', 'TMUX_PANE', 'STY', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'ZELLIJ', 'ZELLIJ_SESSION_NAME', 'ZELLIJ_PANE_ID',
463
+ 'HERDR_ENV', 'HERDR_PANE_ID', 'HERDR_TAB_ID', 'HERDR_WORKSPACE_ID', 'HERDR_SOCKET_PATH', 'HERDR_BIN_PATH',
464
+ ]) delete env[key];
280
465
  if (dropEnv) for (const key of Object.keys(env)) if (dropEnv(key)) delete env[key];
281
466
  if (extraEnv) env = mergeEnv([env, extraEnv]);
467
+ return env;
468
+ }
469
+
470
+ _spawn({
471
+ provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null, installPath = null, account = null,
472
+ extraEnv = null, dropEnv = null, clone = null, reporting = null, multiplexer = null,
473
+ id = newId(), reportToken = crypto.randomBytes(16).toString('hex'), createdAt,
474
+ }) {
475
+ this._assertCanSpawn();
476
+ const env = this._sessionEnv({ id, reportToken, provider, account, extraEnv, dropEnv });
282
477
 
283
478
  let session;
284
479
  try {
@@ -298,6 +493,8 @@ export class SessionManager extends EventEmitter {
298
493
  account: account ? { id: account.id, label: account.label } : null,
299
494
  clone,
300
495
  reporting,
496
+ multiplexer,
497
+ createdAt,
301
498
  });
302
499
  } catch (err) {
303
500
  throw httpError(500, `could not start ${provider.tool}: ${err.message}`, 'spawn_failed');
@@ -327,6 +524,9 @@ export class SessionManager extends EventEmitter {
327
524
  remove(id) {
328
525
  const session = this.get(id);
329
526
  this.sessions.delete(id);
527
+ if (this.multiplexers.delete(id)) this._saveCards();
528
+ this.watchers.get(id)?.stop();
529
+ this.watchers.delete(id);
330
530
  session._broadcast({ type: 'removed' });
331
531
  if (session.status === 'running') {
332
532
  this.exiting.add(session);
@@ -377,6 +577,8 @@ export class SessionManager extends EventEmitter {
377
577
  */
378
578
  async shutdown({ graceMs = 1500, timeoutMs = 5000 } = {}) {
379
579
  this.closing = true;
580
+ for (const watcher of this.watchers.values()) watcher.stop();
581
+ this.watchers.clear();
380
582
  const sessions = [...this.sessions.values()];
381
583
  this.sessions.clear();
382
584
  const pending = new Set([...sessions.filter((s) => s.status === 'running'), ...this.exiting]);
@@ -393,6 +595,17 @@ export class SessionManager extends EventEmitter {
393
595
  }
394
596
  }
395
597
 
598
+ /** Show a herdr card the agents herdr lists, and drop the ones it no longer lists. */
599
+ function showHerdrAgents(session, agents) {
600
+ if (session.status !== 'running') return;
601
+ const reports = herdrAgentReports(agents);
602
+ const listed = new Set(reports.map((report) => report.agentId));
603
+ for (const id of [...session.agents.keys()]) if (id.startsWith('herdr:') && !listed.has(id)) session.reportAgent({ agentId: id, remove: true }, 'herdr');
604
+ for (const report of reports) {
605
+ try { session.reportAgent(report, 'herdr'); } catch { /* more agents than a card holds */ }
606
+ }
607
+ }
608
+
396
609
  /** The model named by a --model, --model=, or -m argument, or null. */
397
610
  export function modelFromArgs(args) {
398
611
  for (let i = 0; i < args.length; i++) {
@@ -62,6 +62,8 @@ export class Session extends EventEmitter {
62
62
  * @param {string|null} [opts.task] "install" for a package install, "upgrade" for the manager's own, "clone" for a GitHub clone, else null
63
63
  * @param {{id: string, label: string}|null} [opts.account] the tool sign-in the session runs under
64
64
  * @param {{repo: string, path: string, accountId: number}|null} [opts.clone] what a clone session clones, and where
65
+ * @param {{label: string, attach: string, reattachable: boolean}|null} [opts.multiplexer] the multiplexer a Shell session runs in, and the command that reattaches it
66
+ * @param {string} [opts.createdAt] when the card was first made; a restored tmux or herdr card keeps its own. With no spawnSpec, the session is such a card, closed
65
67
  * @param {string} opts.reportToken
66
68
  * @param {number} [opts.scrollback]
67
69
  * @param {number} [opts.activityIdleMs]
@@ -77,6 +79,7 @@ export class Session extends EventEmitter {
77
79
  this.task = opts.task ?? null;
78
80
  this.account = opts.account ?? null;
79
81
  this.clone = opts.clone ?? null;
82
+ this.multiplexer = opts.multiplexer ?? null;
80
83
  this.cwd = opts.cwd;
81
84
  this.cols = opts.cols;
82
85
  this.rows = opts.rows;
@@ -92,7 +95,9 @@ export class Session extends EventEmitter {
92
95
  this.shells = new Map();
93
96
  this._shellSeq = 0;
94
97
  this._endedTasks = new Set();
95
- this.createdAt = new Date().toISOString();
98
+ this.createdAt = opts.createdAt ?? new Date().toISOString();
99
+ /** When the current process started; later than createdAt once a multiplexer session is reattached. */
100
+ this.startedAt = this.createdAt;
96
101
  this.exitedAt = null;
97
102
  this.status = 'running';
98
103
  this.exitCode = null;
@@ -144,21 +149,50 @@ export class Session extends EventEmitter {
144
149
  }
145
150
 
146
151
  this.disposed = false;
152
+ this.env = opts.env;
153
+ if (!opts.spawnSpec) {
154
+ // A tmux or herdr card brought back after the manager restarted: its
155
+ // multiplexer still runs the session, and the card is closed until reattached.
156
+ this.status = 'exited';
157
+ this.exitedAt = new Date().toISOString();
158
+ this.activity = 'quiet';
159
+ this._resolveExited();
160
+ const label = this.multiplexer?.label ?? 'The multiplexer';
161
+ this.term.write(`\x1b[2m[Agent Guild restarted. ${label} still runs this session; Reattach it from its card.]\x1b[0m\r\n`);
162
+ return;
163
+ }
147
164
  try {
148
- this.pty = pty.spawn(opts.spawnSpec.file, opts.spawnSpec.args, {
149
- name: 'xterm-256color',
150
- cols: this.cols,
151
- rows: this.rows,
152
- cwd: this.cwd,
153
- env: opts.env,
154
- useConpty: true,
155
- });
165
+ this._start(opts.spawnSpec);
156
166
  } catch (err) {
157
167
  this.term.dispose();
158
168
  throw err;
159
169
  }
160
- this.pty.onData((data) => this._onData(data));
161
- this.pty.onExit(({ exitCode, signal }) => this._onExit(exitCode, signal));
170
+ }
171
+
172
+ _start(spawnSpec, cwd = this.cwd) {
173
+ const proc = pty.spawn(spawnSpec.file, spawnSpec.args, {
174
+ name: 'xterm-256color',
175
+ cols: this.cols,
176
+ rows: this.rows,
177
+ cwd,
178
+ env: this.env,
179
+ useConpty: true,
180
+ });
181
+ this.pty = proc;
182
+ proc.onData((data) => { if (this.pty === proc) this._onData(data); });
183
+ proc.onExit(({ exitCode, signal }) => { if (this.pty === proc) this._onExit(exitCode, signal); });
184
+ }
185
+
186
+ /**
187
+ * Run a new process in an exited session, keeping its id, report token and
188
+ * screen: a multiplexer's client attaching to its session again, in `cwd`.
189
+ */
190
+ reattach(spawnSpec, { cwd = this.cwd } = {}) {
191
+ if (this.disposed || this.status !== 'exited') throw Object.assign(new Error('only an exited session can be reattached'), { status: 409 });
192
+ this._start(spawnSpec, cwd);
193
+ Object.assign(this, { status: 'running', exitCode: null, signal: null, exitedAt: null, activity: 'quiet', startedAt: new Date().toISOString() });
194
+ this.exited = new Promise((resolve) => { this._resolveExited = resolve; });
195
+ this._changed();
162
196
  }
163
197
 
164
198
  /**
@@ -307,9 +341,11 @@ export class Session extends EventEmitter {
307
341
 
308
342
  /**
309
343
  * End the process. On macOS and Linux: hang-up first, force after a grace
310
- * period. On Windows: end the whole process tree at once. node-pty's own
311
- * Windows kill first asks a helper process for the console's process list,
312
- * and when that helper fails it waits a fixed five seconds.
344
+ * period. On Windows: end the whole process tree at once, except for a
345
+ * multiplexer's client, whose server is its child there and must outlive
346
+ * it. node-pty's own Windows kill first asks a helper process for the
347
+ * console's process list, and when that helper fails it waits a fixed
348
+ * five seconds.
313
349
  */
314
350
  kill({ graceMs = this.killGraceMs } = {}) {
315
351
  if (this.status !== 'running') return;
@@ -330,7 +366,7 @@ export class Session extends EventEmitter {
330
366
  };
331
367
  const pid = this.pty.pid;
332
368
  if (!pid) return fallback();
333
- killWindowsTree(pid, (err) => { if (err) fallback(); });
369
+ killWindowsTree(pid, (err) => { if (err) fallback(); }, { tree: !this.multiplexer });
334
370
  }
335
371
 
336
372
  /**
@@ -682,6 +718,7 @@ export class Session extends EventEmitter {
682
718
  task: this.task,
683
719
  account: this.account,
684
720
  clone: this.clone,
721
+ multiplexer: this.multiplexer,
685
722
  pid: this.pid,
686
723
  status: this.status,
687
724
  exitCode: this.exitCode,
@@ -689,6 +726,7 @@ export class Session extends EventEmitter {
689
726
  activity: this.activity,
690
727
  lastOutputAt: this.lastOutputAt ? new Date(this.lastOutputAt).toISOString() : null,
691
728
  createdAt: this.createdAt,
729
+ startedAt: this.startedAt,
692
730
  exitedAt: this.exitedAt,
693
731
  cols: this.cols,
694
732
  rows: this.rows,
@@ -3,6 +3,7 @@
3
3
 
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ import { execFileSync } from 'node:child_process';
6
7
  import { resolveCommand } from './command-resolver.mjs';
7
8
 
8
9
  const UNIX_SHELLS = [
@@ -12,10 +13,34 @@ const UNIX_SHELLS = [
12
13
  { id: 'pwsh', label: 'PowerShell' },
13
14
  ];
14
15
 
16
+ // Terminal multiplexers, offered after the shells. A session only ever ends
17
+ // its client: the multiplexer's own session keeps running after the card
18
+ // stops, so the card can attach to it again. {name} is that session's name.
19
+ // A tmux card attaches to a tmux session made for it first (tmuxNewSession),
20
+ // so the card's identity reaches that session only; that needs tmux 3.2.
21
+ // tmux gets -u because the page's terminal is always UTF-8, whatever locale
22
+ // the manager inherited; without it tmux draws other characters as "_".
23
+ // A herdr card attaches to herdr's own persistent session; herdr runs on
24
+ // Windows too, tmux does not.
25
+ const TMUX = { id: 'tmux', label: 'tmux', args: ['-u', 'attach-session', '-t', '={name}'], multiplexer: { attach: 'tmux attach -t {name}' } };
26
+ const HERDR = { id: 'herdr', label: 'herdr', args: [], multiplexer: { attach: 'herdr' } };
27
+
15
28
  function isFile(file) {
16
29
  try { return fs.statSync(file).isFile(); } catch { return false; }
17
30
  }
18
31
 
32
+ /** The output of `<file> -V`, or '' when it cannot run. */
33
+ function readVersion(file) {
34
+ try { return execFileSync(file, ['-V'], { encoding: 'utf8', timeout: 5000, windowsHide: true }); } catch { return ''; }
35
+ }
36
+
37
+ /** Whether `tmux -V` names 3.2 or later. A build without a number (master, a BSD's own) counts as recent. */
38
+ export function tmuxSupported(versionText) {
39
+ if (!/^tmux /.test(versionText)) return false;
40
+ const [, major, minor] = versionText.match(/(\d+)\.(\d+)/) ?? [];
41
+ return major === undefined || Number(major) > 3 || (Number(major) === 3 && Number(minor) >= 2);
42
+ }
43
+
19
44
  function envValue(env, name) {
20
45
  const key = Object.keys(env).find((k) => k.toUpperCase() === name.toUpperCase());
21
46
  return key ? env[key] : undefined;
@@ -45,7 +70,16 @@ export function fallbackShell(env, platform) {
45
70
  return env.SHELL || (platform === 'darwin' ? '/bin/zsh' : '/bin/bash');
46
71
  }
47
72
 
48
- function windowsShells(env, resolve, exists) {
73
+ /** Add the installed multiplexers after the shells, unless a shell already has their id. */
74
+ function withMultiplexers(shells, candidates, resolve, version) {
75
+ for (const { id, label, args, multiplexer } of candidates) {
76
+ const found = !shells.some((shell) => shell.id === id) && resolve(id);
77
+ if (found && (id !== 'tmux' || tmuxSupported(version(found)))) shells.push({ id, label, path: found, args, env: {}, multiplexer });
78
+ }
79
+ return shells;
80
+ }
81
+
82
+ function windowsShells(env, resolve, exists, version) {
49
83
  const installed = ['ProgramFiles', 'ProgramW6432'].map((name) => envValue(env, name)).filter(Boolean).map((base) => path.win32.join(base, 'PowerShell', '7', 'pwsh.exe'));
50
84
  const pwsh = resolve('pwsh') || installed.find(exists) || null;
51
85
  const gitBash = findGitBash(env, resolve, exists);
@@ -56,10 +90,11 @@ function windowsShells(env, resolve, exists) {
56
90
  // CHERE_INVOKING keeps a login bash in the session's folder instead of moving to HOME.
57
91
  gitBash && { id: 'git-bash', label: 'Git Bash', path: gitBash, args: ['--login', '-i'], env: { CHERE_INVOKING: '1' } },
58
92
  ].filter((shell) => shell?.path);
59
- return { shells, defaultId: shells[0]?.id ?? null };
93
+ const defaultId = shells[0]?.id ?? null;
94
+ return { shells: withMultiplexers(shells, [HERDR], resolve, version), defaultId };
60
95
  }
61
96
 
62
- function unixShells(env, platform, resolve) {
97
+ function unixShells(env, platform, resolve, version) {
63
98
  const name = (file) => path.posix.basename(file);
64
99
  const own = resolve(env.SHELL || '') || resolve(fallbackShell({}, platform));
65
100
  const shells = [];
@@ -72,11 +107,32 @@ function unixShells(env, platform, resolve) {
72
107
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/.test(defaultId)) defaultId = 'login';
73
108
  shells.unshift({ id: defaultId, label: name(own), path: own, args: [], env: {} });
74
109
  }
75
- return { shells, defaultId: defaultId ?? shells[0]?.id ?? null };
110
+ defaultId ??= shells[0]?.id ?? null;
111
+ return { shells: withMultiplexers(shells, [TMUX, HERDR], resolve, version), defaultId };
76
112
  }
77
113
 
78
- /** The installed shells, as { id, label, path, args, env }, and the id of the default one. */
79
- export function detectShells(env = process.env, platform = process.platform, { exists = isFile, resolve: resolveWith = resolveCommand } = {}) {
114
+ /** The installed shells, as { id, label, path, args, env, multiplexer? }, and the id of the default one. */
115
+ export function detectShells(env = process.env, platform = process.platform, { exists = isFile, resolve: resolveWith = resolveCommand, version = readVersion } = {}) {
80
116
  const resolve = (command) => resolveWith(command, env, platform);
81
- return platform === 'win32' ? windowsShells(env, resolve, exists) : unixShells(env, platform, resolve);
117
+ return platform === 'win32' ? windowsShells(env, resolve, exists, version) : unixShells(env, platform, resolve, version);
118
+ }
119
+
120
+ /** Quote one word for tmux's command parser: nothing inside single quotes is expanded. `code` names the field a line break came from. */
121
+ function tmuxQuote(value, code = 'bad_request') {
122
+ const text = String(value);
123
+ if (/[\r\n]/.test(text)) throw Object.assign(new Error('tmux cannot take a value with a line break'), { status: 400, code });
124
+ return `'${text.replaceAll("'", "'\\''")}'`;
125
+ }
126
+
127
+ /**
128
+ * The tmux commands, read from stdin, that make a card's own tmux session:
129
+ * detached, `cols` by `rows`, in `cwd`, with `env` as that session's own
130
+ * environment and `args`, if any, as its command. Stdin keeps the card's
131
+ * report token off the command line, where other users could read it.
132
+ */
133
+ export function tmuxNewSession({ name, cwd, cols, rows, env, args = [] }) {
134
+ const words = ['new-session', '-d', '-s', tmuxQuote(name), '-x', String(cols), '-y', String(rows), '-c', tmuxQuote(cwd, 'bad_cwd')];
135
+ for (const [key, value] of Object.entries(env)) words.push('-e', tmuxQuote(`${key}=${value}`));
136
+ for (const arg of args) words.push(tmuxQuote(arg, 'bad_args'));
137
+ return `${words.join(' ')}\n`;
82
138
  }