@oddessentials/agent-guild 0.23.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,7 @@ import { HerdrAgents, herdrAgentReports, herdrSocket } from './herdr.mjs';
14
14
  import { CHANNEL_LABELS } from './install-channels.mjs';
15
15
  import { SELF_PROVIDER } from './self-update.mjs';
16
16
  import { GITHUB_PROVIDER, dropsFromCloneEnv, parseRepo } from './github.mjs';
17
+ import { pathIdentity } from './multiplexer-paths.mjs';
17
18
 
18
19
  export const MAX_SESSIONS = 32;
19
20
 
@@ -84,6 +85,17 @@ export class SessionManager extends EventEmitter {
84
85
  /** Keeps the tmux and herdr cards across restarts: { load(), save(cards) }, or null. */
85
86
  this.store = store;
86
87
  this.restoring = false;
88
+ this.pendingCards = new Map();
89
+ this.multiplexerOperations = new Set();
90
+ this.multiplexerStarts = new Map();
91
+ this.pendingRestore = null;
92
+ if (this.registry) this.registry.multiplexerState = (provider, id) => ({
93
+ busy: this.multiplexerOperations.has(id),
94
+ pendingCards: [...this.pendingCards.values()].filter((c) => c.provider === provider && c.shell === id).length,
95
+ });
96
+ this.registry?.on?.('updated', () => {
97
+ this._restorePending().catch((err) => console.warn('[sessions] pending cards:', err.message));
98
+ });
87
99
  }
88
100
 
89
101
  list() {
@@ -129,7 +141,7 @@ export class SessionManager extends EventEmitter {
129
141
  || (provider.accounts.length > 1 ? `${provider.tool} · ${signIn.label}` : null)
130
142
  || (runShell && this.registry.shellsFor(provider).shells.length > 1 ? `${provider.tool} · ${runShell.label}` : null);
131
143
  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 || []);
144
+ if (runShell?.multiplexer) return this._withMultiplexerStart(provider.id, runShell.id, () => this._startMultiplexer(options, runShell, args || []));
133
145
  const spawnSpec = this.registry.spawnSpec(provider, args || [], resumeId, hooks.args, runShell);
134
146
  const session = this._spawn({ ...options, spawnSpec });
135
147
  const model = modelFromArgs([...provider.args, ...(args || [])]);
@@ -177,7 +189,13 @@ export class SessionManager extends EventEmitter {
177
189
  if (shell.id === 'tmux') mux.run(['kill-session', '-t', `=${muxName}`]).catch(() => {});
178
190
  throw err;
179
191
  }
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 } });
192
+ this._track(session, { spawnSpec, mux, shell, card: {
193
+ id, reportToken, provider: provider.id, account: account.id, shell: shell.id,
194
+ muxName, name: session.name, cwd: session.cwd, createdAt: session.createdAt,
195
+ // Herdr's request arguments belong to its client and must survive rebinding.
196
+ // Tmux's request arguments belong to the already-running inner session.
197
+ ...(shell.id === 'herdr' ? { args: [...args] } : {}),
198
+ } });
181
199
  this._watchHerdr(session);
182
200
  return session;
183
201
  }
@@ -197,12 +215,17 @@ export class SessionManager extends EventEmitter {
197
215
 
198
216
  /** Keep a tmux or herdr card for Reattach, here and, through the store, across restarts. */
199
217
  _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 });
218
+ const installationKey = this.registry.multiplexers?.identityFor(this.registry.get(card.provider), shell.id, shell.path);
219
+ const shellPath = shell.path;
220
+ // Keep old stores readable. Newly known installations carry a stable key,
221
+ // since an update can change a Cellar or Windows release realpath.
222
+ card.shellPath = shellPath;
223
+ if (installationKey) card.installationKey = installationKey;
224
+ const tracked = { spawnSpec, alive: mux.alive, shellPath, checking: false, herdr: shell.id === 'herdr' ? { path: shell.path, env: mux.clientEnv } : null, card };
225
+ this.multiplexers.set(session.id, tracked);
226
+ this.pendingCards.delete(session.id);
201
227
  this._saveCards();
202
- session.on('exit', async () => {
203
- session.multiplexer.reattachable = await mux.alive();
204
- session._changed();
205
- });
228
+ session.on('exit', () => this._checkMultiplexer(session, tracked));
206
229
  session.on('changed', () => {
207
230
  if (card.name === session.name) return;
208
231
  card.name = session.name;
@@ -214,7 +237,7 @@ export class SessionManager extends EventEmitter {
214
237
  // A stopping manager leaves the file as it is, for the next one to bring the cards back.
215
238
  if (!this.store || this.restoring || this.closing) return;
216
239
  try {
217
- this.store.save([...this.multiplexers.values()].map(({ card }) => card));
240
+ this.store.save([...this.multiplexers.values()].map(({ card }) => card).concat([...this.pendingCards.values()]));
218
241
  } catch (err) {
219
242
  console.warn(`[sessions] could not save the tmux and herdr cards: ${err.message}`);
220
243
  }
@@ -229,7 +252,8 @@ export class SessionManager extends EventEmitter {
229
252
  this.restoring = true;
230
253
  try {
231
254
  // 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) => {
255
+ await Promise.all((this.store?.load() ?? []).map((card) => this._withMultiplexerStart(card?.provider, card?.shell, () => this._restoreCard(card)).catch((err) => {
256
+ if (['too_many_sessions', 'install_in_progress'].includes(err.code)) this.pendingCards.set(card.id, card);
233
257
  console.warn(`[sessions] did not bring back the card ${card?.name ?? card?.id}: ${err.message}`);
234
258
  })));
235
259
  } finally {
@@ -243,12 +267,25 @@ export class SessionManager extends EventEmitter {
243
267
  if (typeof card.reportToken !== 'string' || !/^[a-f0-9]{32}$/.test(card.reportToken)) return;
244
268
  if (typeof card.muxName !== 'string' || !/^guild-[0-9a-f]{6}$/.test(card.muxName)) return;
245
269
  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;
270
+ if (!provider || !['tmux', 'herdr'].includes(card.shell)) return;
271
+ if (card.shell === 'herdr' && card.args !== undefined && (!Array.isArray(card.args) || card.args.some((arg) => typeof arg !== 'string'))) return;
272
+ const shells = this.registry.shellsFor(provider)?.shells || [];
273
+ const shell = shells.find((s) => s.id === card.shell && s.multiplexer);
274
+ if (!shell) {
275
+ this.pendingCards.set(card.id, card);
276
+ return;
277
+ }
278
+ if (this.multiplexerOperations.has(card.shell)) {
279
+ this.pendingCards.set(card.id, card);
280
+ return;
281
+ }
248
282
  const account = this.registry.account(provider, card.account);
249
283
  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);
284
+ if (!(await mux.alive()) || this.closing) {
285
+ if (!this.closing) this.pendingCards.delete(card.id);
286
+ return;
287
+ }
288
+ const spawnSpec = this.registry.spawnSpec(provider, shell.id === 'herdr' ? card.args ?? [] : [], null, [], mux.named);
252
289
  const session = this._spawn({
253
290
  provider, spawnSpec: null, cwd: typeof card.cwd === 'string' ? card.cwd : os.homedir(), name: card.name, account,
254
291
  id: card.id, reportToken: card.reportToken, dropEnv: mux.dropEnv, extraEnv: shell.env,
@@ -260,6 +297,43 @@ export class SessionManager extends EventEmitter {
260
297
 
261
298
  /** Attach a stopped tmux or herdr session's card to its multiplexer session again, keeping its id and report token. */
262
299
  async reattach(id) {
300
+ const tracked = this.multiplexers.get(id);
301
+ if (!tracked) return this._reattach(id);
302
+ return this._withMultiplexerStart(tracked.card.provider, tracked.card.shell, () => this._reattach(id));
303
+ }
304
+
305
+ _rebindMultiplexer(mux) {
306
+ const provider = this.registry.get(mux.card.provider);
307
+ const shell = provider && this.registry.shellsFor(provider)?.shells.find((s) => s.id === mux.card.shell && s.multiplexer);
308
+ if (!shell) return false;
309
+ const fresh = this._multiplexer({ provider, account: this.registry.account(provider, mux.card.account), shell, id: mux.card.id, reportToken: mux.card.reportToken, muxName: mux.card.muxName });
310
+ mux.spawnSpec = this.registry.spawnSpec(provider, shell.id === 'herdr' ? mux.card.args ?? [] : [], null, [], fresh.named);
311
+ mux.alive = fresh.alive;
312
+ mux.shellPath = shell.path;
313
+ mux.card.shellPath = shell.path;
314
+ const key = this.registry.multiplexers?.identityFor(provider, shell.id, shell.path);
315
+ if (key) mux.card.installationKey = key;
316
+ else delete mux.card.installationKey;
317
+ if (mux.herdr) mux.herdr = { path: shell.path, env: fresh.clientEnv };
318
+ this._saveCards();
319
+ return true;
320
+ }
321
+
322
+ async _checkMultiplexer(session, tracked) {
323
+ const sequence = tracked.checkSequence = (tracked.checkSequence || 0) + 1;
324
+ tracked.checking = true;
325
+ let alive = false;
326
+ try {
327
+ this._rebindMultiplexer(tracked);
328
+ alive = await tracked.alive();
329
+ } catch {}
330
+ if (tracked.checkSequence !== sequence) return;
331
+ session.multiplexer.reattachable = alive;
332
+ tracked.checking = false;
333
+ session._changed();
334
+ }
335
+
336
+ async _reattach(id) {
263
337
  const session = this.get(id);
264
338
  const mux = this.multiplexers.get(id);
265
339
  if (!mux) throw httpError(400, `${session.name} is not in tmux or herdr`, 'not_reattachable');
@@ -267,6 +341,9 @@ export class SessionManager extends EventEmitter {
267
341
  if (session.status === 'running') throw httpError(409, `${session.name} is still attached`, 'session_running');
268
342
  // 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
343
  if (!session.multiplexer.reattachable) throw httpError(409, `${session.multiplexer.label} no longer has the session ${session.name} ran in`, 'multiplexer_session_gone');
344
+ // Refresh closures as well as the spawn spec: the previous executable
345
+ // may have disappeared in a native or Homebrew update.
346
+ if (!this._rebindMultiplexer(mux)) throw httpError(409, 'Install the multiplexer and refresh this page before reattaching.', 'shell_unavailable');
270
347
  const alive = await mux.alive();
271
348
  if (this.sessions.get(id) !== session || session.status === 'running') throw httpError(409, `${session.name} changed meanwhile`, 'session_running');
272
349
  if (!alive) {
@@ -368,10 +445,123 @@ export class SessionManager extends EventEmitter {
368
445
 
369
446
  installsRunningFor(providerId) {
370
447
  let n = 0;
371
- for (const s of [...this.sessions.values(), ...this.exiting]) if (s.status === 'running' && s.task === 'install' && s.provider.id === providerId) n++;
448
+ for (const s of [...this.sessions.values(), ...this.exiting]) if (s.status === 'running' && s.task === 'install' && !s.multiplexerInstall && s.provider.id === providerId) n++;
372
449
  return n;
373
450
  }
374
451
 
452
+ async _withMultiplexerStart(providerId, id, start) {
453
+ // Multiple @shell providers can share the same executable.
454
+ const key = id;
455
+ if (this.multiplexerOperations.has(key)) throw httpError(409, `${id} is being installed, updated or removed; try again when it finishes.`, 'install_in_progress');
456
+ this.multiplexerStarts.set(key, (this.multiplexerStarts.get(key) || 0) + 1);
457
+ try { return await start(); }
458
+ finally {
459
+ const count = this.multiplexerStarts.get(key) - 1;
460
+ if (count) this.multiplexerStarts.set(key, count);
461
+ else this.multiplexerStarts.delete(key);
462
+ }
463
+ }
464
+
465
+ dependentsOf(providerId, id, copy = null) {
466
+ const platform = this.registry.platform;
467
+ const same = (a, b) => pathIdentity(a, platform) === pathIdentity(b, platform);
468
+ let running = 0;
469
+ for (const [sessionId, tracked] of this.multiplexers) {
470
+ if (tracked.card.shell !== id) continue;
471
+ if (copy && tracked.card.installationKey && tracked.card.installationKey !== copy.key) continue;
472
+ if (copy && !tracked.card.installationKey && tracked.shellPath
473
+ && !same(tracked.shellPath, copy.resolvedPath)
474
+ && !same(this.registry.multiplexers.fsx.realpath(tracked.shellPath), copy.realPath)) continue;
475
+ const session = this.sessions.get(sessionId);
476
+ if (session?.status === 'running' || session?.multiplexer?.reattachable || tracked.checking) running++;
477
+ }
478
+ const pending = [...this.pendingCards.values()].filter((c) => c.provider === providerId && c.shell === id).length;
479
+ return { running, pending };
480
+ }
481
+
482
+ async manageMultiplexer(providerId, id, kind, { path: copyPath = null, force = false } = {}) {
483
+ const provider = this.registry.get(providerId);
484
+ if (!provider) throw httpError(404, 'Unknown provider', 'unknown_provider');
485
+ this.registry.multiplexers.get(provider, id);
486
+ if (!['install', 'update', 'uninstall'].includes(kind)) throw httpError(400, 'Unknown operation', 'bad_request');
487
+ this._assertCanSpawn();
488
+ const key = id;
489
+ if (this.multiplexerOperations.has(key) || this.multiplexerStarts.has(key)) throw httpError(409, `${id} has another operation in progress. Try again shortly.`, 'install_in_progress');
490
+ this.multiplexerOperations.add(key);
491
+ this.registry.emit('updated');
492
+ let started = false;
493
+ try {
494
+ const prepared = await this.registry.multiplexers.prepare(provider, id, kind, copyPath);
495
+ if (id === 'herdr' && kind === 'uninstall') await this.registry.multiplexers.assertHerdrStopped(provider, prepared.copy, prepared.extraEnv);
496
+ // Herdr supports updates with servers and panes running, including brew.
497
+ // Uninstall's server check is never bypassed by force.
498
+ if (id === 'tmux') {
499
+ const { running, pending } = this.dependentsOf(providerId, id, prepared.copy);
500
+ if (running && !force) throw Object.assign(httpError(409, `${running} tmux card(s) depend on this installation. Changing it may prevent reattachment.`, 'multiplexer_in_use'), { running, pending });
501
+ }
502
+ this._assertCanSpawn();
503
+ const session = this._spawn({
504
+ provider, spawnSpec: prepared.spec, extraEnv: prepared.extraEnv, cwd: os.homedir(),
505
+ name: `${{ install: 'Install', update: 'Update', uninstall: 'Uninstall' }[kind]} ${id}`,
506
+ task: 'install', multiplexerInstall: { id, kind },
507
+ });
508
+ started = true;
509
+ // Keep the lock through refresh, and even if the installer card is removed.
510
+ session.exited.then(async () => {
511
+ try {
512
+ await this.registry.multiplexers.finish(provider, id, kind, prepared.copy, session.exitCode);
513
+ if (kind === 'update') {
514
+ // A detach during replacement may have probed a vanished release.
515
+ // Recheck closed cards once discovery has the new executable.
516
+ await Promise.all([...this.multiplexers].map(([cardId, tracked]) => {
517
+ const card = this.sessions.get(cardId);
518
+ return tracked.card.shell === id && card?.status === 'exited'
519
+ ? this._checkMultiplexer(card, tracked) : null;
520
+ }));
521
+ }
522
+ }
523
+ catch (err) { console.warn('[multiplexers] could not refresh:', err.message); }
524
+ finally {
525
+ this.multiplexerOperations.delete(key);
526
+ this.registry.emit('updated');
527
+ await this._restorePending();
528
+ }
529
+ }).catch((err) => console.warn('[multiplexers]', err.message));
530
+ return session;
531
+ } finally {
532
+ if (!started) {
533
+ this.multiplexerOperations.delete(key);
534
+ this.registry.emit('updated');
535
+ }
536
+ }
537
+ }
538
+
539
+ _restorePending() {
540
+ if (this.pendingRestore) {
541
+ this.pendingRestoreAgain = true;
542
+ return this.pendingRestore;
543
+ }
544
+ if (this.restoring || this.closing || !this.pendingCards.size) return Promise.resolve();
545
+ this.pendingRestore = (async () => {
546
+ const before = this.pendingCards.size;
547
+ do {
548
+ this.pendingRestoreAgain = false;
549
+ for (const card of [...this.pendingCards.values()]) {
550
+ if (this.closing) break;
551
+ if (this.multiplexerOperations.has(card.shell)) continue;
552
+ try { await this._withMultiplexerStart(card.provider, card.shell, () => this._restoreCard(card)); }
553
+ catch (err) {
554
+ // A capacity limit is temporary; never discard a recoverable card.
555
+ if (!['too_many_sessions', 'install_in_progress'].includes(err.code)) this.pendingCards.delete(card.id);
556
+ }
557
+ }
558
+ } while (this.pendingRestoreAgain && !this.closing);
559
+ this._saveCards();
560
+ if (before !== this.pendingCards.size) this.registry.emit?.('updated');
561
+ })().finally(() => { this.pendingRestore = null; });
562
+ return this.pendingRestore;
563
+ }
564
+
375
565
  /**
376
566
  * Upgrade the manager itself: a visible session running npm. Sessions
377
567
  * keep running; the new version is used once the manager is restarted.
@@ -470,7 +660,7 @@ export class SessionManager extends EventEmitter {
470
660
  _spawn({
471
661
  provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null, installPath = null, account = null,
472
662
  extraEnv = null, dropEnv = null, clone = null, reporting = null, multiplexer = null,
473
- id = newId(), reportToken = crypto.randomBytes(16).toString('hex'), createdAt,
663
+ id = newId(), reportToken = crypto.randomBytes(16).toString('hex'), createdAt, multiplexerInstall = null,
474
664
  }) {
475
665
  this._assertCanSpawn();
476
666
  const env = this._sessionEnv({ id, reportToken, provider, account, extraEnv, dropEnv });
@@ -504,7 +694,8 @@ export class SessionManager extends EventEmitter {
504
694
  if (this.sessions.has(id)) this.emit('event', { type: 'session.updated', session: session.toJSON() });
505
695
  });
506
696
  session.on('warning', (msg) => console.warn(`[session ${id}] ${msg}`));
507
- if (task === 'install') {
697
+ session.multiplexerInstall = multiplexerInstall;
698
+ if (task === 'install' && !multiplexerInstall) {
508
699
  session.on('exit', () => {
509
700
  this.registry.finishInstall(provider.id, { exitCode: session.exitCode, kind: installKind, path: installPath }).catch(() => {});
510
701
  });
@@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { buildSpawnSpec } from './command-resolver.mjs';
6
6
  import { formatCommand } from './install-channels.mjs';
7
+ import { windowsPathCleanupScript } from './multiplexer-paths.mjs';
7
8
 
8
9
  export const RUNNER = fileURLToPath(import.meta.url);
9
10
 
@@ -72,9 +73,24 @@ function removeFile(file) {
72
73
  }
73
74
 
74
75
  export function runPlan(
75
- { run = null, remove = [], links = [], launcher = null },
76
- { env = process.env, platform = process.platform, log = console.log, rm = removeFile } = {},
76
+ { run = null, remove = [], links = [], launcher = null, pathEntries = null, strict = false },
77
+ { env = process.env, platform = process.platform, log = console.log, rm = removeFile, cleanPath = cleanWindowsPath } = {},
77
78
  ) {
79
+ if (strict) {
80
+ // An ancestor junction/symlink must not redirect deletion outside the
81
+ // server's allowlisted locations. The final launcher alias is handled below.
82
+ for (const file of [...remove, ...links]) {
83
+ if (!path.isAbsolute(file) || firstLink(path.dirname(file))) {
84
+ log(`Refused to remove ${file}: its parent is a link or the path is not absolute.`);
85
+ return 1;
86
+ }
87
+ const target = linkTarget(file);
88
+ if ((target && !within(target, remove, platform)) || (links.includes(file) && exists(file) && !target)) {
89
+ log(`Refused to remove ${file}: it is not an owned installation link.`);
90
+ return 1;
91
+ }
92
+ }
93
+ }
78
94
  if (run) {
79
95
  log(`> ${formatCommand(run.file, run.args)}`);
80
96
  const spec = buildSpawnSpec(run.file, run.args, env, platform);
@@ -133,6 +149,10 @@ export function runPlan(
133
149
  if (exists(file)) del(file);
134
150
  log(`Removed ${file}`);
135
151
  }
152
+ if (pathEntries && platform === 'win32') {
153
+ cleanPath(pathEntries, env);
154
+ log('Removed the installation’s entries from the user PATH.');
155
+ }
136
156
  } catch (err) {
137
157
  const code = err.code === 'ENOTDIR' && err.syscall === 'scandir' ? 'EPERM' : err.code;
138
158
  const reason = { EBUSY: 'it is in use', EPERM: 'it is in use or protected', EACCES: 'permission was denied' }[code] || err.message;
@@ -142,6 +162,13 @@ export function runPlan(
142
162
  return 0;
143
163
  }
144
164
 
165
+ function cleanWindowsPath(rules, env) {
166
+ const powershell = path.win32.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
167
+ const encoded = Buffer.from(windowsPathCleanupScript(rules), 'utf16le').toString('base64');
168
+ const result = spawnSync(powershell, ['-NoLogo', '-NoProfile', '-EncodedCommand', encoded], { env, encoding: 'utf8', windowsHide: true, timeout: 10000 });
169
+ if (result.error || result.status !== 0) throw new Error(result.error?.message || result.stderr || 'Could not update user PATH. Retry Uninstall.');
170
+ }
171
+
145
172
  if (process.argv[1] && path.resolve(process.argv[1]) === RUNNER) {
146
173
  const plan = JSON.parse(Buffer.from(process.argv[2] || '', 'base64url').toString('utf8') || '{}');
147
174
  process.exitCode = runPlan(plan);
@@ -10,6 +10,18 @@ export function parseVersion(text) {
10
10
  return match ? match[1] : null;
11
11
  }
12
12
 
13
+ /** tmux uses two numbers and a patch letter; distributions add an epoch/revision. */
14
+ export function parseTmuxVersion(text) {
15
+ return String(text || '').trim().match(/^(?:tmux\s+)?(?:\d+:)?(\d+\.\d+(?:_?[a-z])?)(?=$|[-+~\s])/i)?.[1]?.replace('_', '') ?? null;
16
+ }
17
+
18
+ export function compareTmuxVersions(a, b) {
19
+ const parts = (v) => /^(\d+)\.(\d+)([a-z]?)$/i.exec(parseTmuxVersion(v) || '');
20
+ const aa = parts(a), bb = parts(b);
21
+ if (!aa || !bb) return null;
22
+ return Number(aa[1]) - Number(bb[1]) || Number(aa[2]) - Number(bb[2]) || aa[3].localeCompare(bb[3]);
23
+ }
24
+
13
25
  /** Semantic-version order: negative when a < b, positive when a > b. */
14
26
  export function compareVersions(a, b) {
15
27
  const [aMain, aPre] = String(a).split('-');
package/web/app.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // it never owns sessions, so closing the page leaves them running.
3
3
 
4
4
  import { SOUNDS, MAX_ALERT_AGE_MS, playOnce, rearmSound, managerLossWatcher, stopWatcher, updateWatcher } from './alerts.js';
5
+ import { TerminalCopy } from './terminal-copy.js';
5
6
 
6
7
  const TOKEN_KEY = 'agentGuild.token';
7
8
  const CWD_KEY = 'agentGuild.cwd';
@@ -759,6 +760,8 @@ let dealt = false;
759
760
 
760
761
  function renderProviders() {
761
762
  const list = $('providers');
763
+ const focused = document.activeElement;
764
+ const focusKey = focused?.dataset?.muxFocus;
762
765
  const tpl = $('provider-template');
763
766
  list.replaceChildren(...state.providers.map((provider) => {
764
767
  const node = tpl.content.firstElementChild.cloneNode(true);
@@ -798,11 +801,20 @@ function renderProviders() {
798
801
  renderVendorLinks(node, provider);
799
802
  renderAccounts(node, provider);
800
803
  renderShells(node, provider);
804
+ renderMultiplexers(node, provider);
801
805
  renderUsage(node, provider);
802
806
  renderReportingSetup(node, provider);
803
807
  renderModelStats(node, provider);
804
808
  return node;
805
809
  }));
810
+ if (focusKey) {
811
+ const controls = [...list.querySelectorAll('[data-mux-focus]')];
812
+ const fallback = focusKey.split(':').slice(0, 2).join(':') + ':copies';
813
+ const replacement = controls.find((node) => node.dataset.muxFocus === focusKey && !node.disabled)
814
+ || controls.find((node) => node.dataset.muxFocus === fallback && !node.closest('[hidden]'))
815
+ || controls.find((node) => node.dataset.muxFocus === focusKey.split(':')[0] + ':multiplexers');
816
+ replacement?.focus({ preventScroll: true });
817
+ }
806
818
  if (!dealt && state.providers.length) {
807
819
  dealt = true;
808
820
  if (!reducedMotion.matches) list.classList.add('deal');
@@ -811,19 +823,21 @@ function renderProviders() {
811
823
 
812
824
  const openCopies = new Set();
813
825
 
814
- function renderCopies(box, provider, card) {
826
+ function renderCopies(box, provider, card, actions = null) {
815
827
  const installs = provider.installs || [];
816
828
  const warnings = provider.warnings || [];
817
- box.hidden = warnings.length === 0 && !installs.some((i) => i.uninstall);
829
+ box.hidden = actions ? installs.length === 0 : warnings.length === 0 && !installs.some((i) => i.uninstall);
818
830
  if (box.hidden) return;
819
831
  box.classList.toggle('warned', warnings.length > 0);
820
832
  const inUse = installs.some((i) => i.active);
821
833
  const older = installs.some((i) => i.newer);
822
- box.querySelector('summary').textContent = !inUse
834
+ box.querySelector('summary').textContent = installs.every((i) => i.partial) ? 'Incomplete installation' : !inUse
823
835
  ? 'A copy exists off PATH'
824
836
  : installs.length === 1 ? 'Installation' : `${installs.length} copies installed${older ? ' · older copy in use' : ''}`;
825
- box.open = openCopies.has(provider.id);
826
- box.addEventListener('toggle', () => (box.open ? openCopies.add(provider.id) : openCopies.delete(provider.id)));
837
+ const key = actions?.key || provider.id;
838
+ box.open = openCopies.has(key);
839
+ box.addEventListener('toggle', () => (box.open ? openCopies.add(key) : openCopies.delete(key)));
840
+ if (actions) box.querySelector('summary').dataset.muxFocus = key + ':copies';
827
841
  const line = (className, ...content) => {
828
842
  const span = document.createElement('span');
829
843
  span.className = className;
@@ -842,6 +856,17 @@ function renderCopies(box, provider, card) {
842
856
  const where = line('copy-path', install.displayPath);
843
857
  where.title = install.path;
844
858
  item.append(head, where);
859
+ if (actions && install.updateCommand && (install.updateAvailable || install.versionStatus === 'failed')) {
860
+ const update = document.createElement('button');
861
+ update.type = 'button';
862
+ update.className = 'btn copy-update';
863
+ update.textContent = 'Update';
864
+ update.title = `Run ${install.updateCommand}`;
865
+ update.disabled = actions.busy;
866
+ update.dataset.muxFocus = key + ':update:' + install.path;
867
+ update.addEventListener('click', () => actions.update(install));
868
+ head.append(update);
869
+ } else if (actions && install.updateGuidance) item.append(line('copy-remove', install.updateGuidance));
845
870
  if (install.uninstall) {
846
871
  const button = document.createElement('button');
847
872
  button.type = 'button';
@@ -851,7 +876,11 @@ function renderCopies(box, provider, card) {
851
876
  button.title = install.uninstall.command
852
877
  ? [`Runs ${install.uninstall.command}`, ...install.uninstall.remove.map((p) => `then deletes ${p}`)].join('\n')
853
878
  : ['Deletes', ...install.uninstall.remove].join('\n');
854
- button.addEventListener('click', () => uninstallCopy(provider, card, install));
879
+ if (actions) {
880
+ button.disabled = actions.busy;
881
+ button.dataset.muxFocus = key + ':uninstall:' + install.path;
882
+ }
883
+ button.addEventListener('click', () => actions ? actions.uninstall(install) : uninstallCopy(provider, card, install));
855
884
  head.append(button);
856
885
  } else {
857
886
  item.append(line('copy-remove', install.uninstallGuidance));
@@ -860,6 +889,102 @@ function renderCopies(box, provider, card) {
860
889
  }));
861
890
  }
862
891
 
892
+ const openMultiplexers = new Set();
893
+
894
+ function renderMultiplexers(card, provider) {
895
+ const host = card.querySelector('.multiplexers');
896
+ const tools = provider.multiplexers || [];
897
+ host.hidden = tools.length === 0;
898
+ if (host.hidden) return;
899
+ host.open = openMultiplexers.has(provider.id);
900
+ host.querySelector('summary').dataset.muxFocus = provider.id + ':multiplexers';
901
+ host.addEventListener('toggle', () => host.open ? openMultiplexers.add(provider.id) : openMultiplexers.delete(provider.id));
902
+ const refresh = host.querySelector('.multiplexer-refresh');
903
+ refresh.dataset.muxFocus = provider.id + ':refresh';
904
+ refresh.addEventListener('click', async () => {
905
+ refresh.disabled = true;
906
+ try {
907
+ const result = await api('POST', '/providers/reload', {});
908
+ state.providers = result.providers;
909
+ renderProviders();
910
+ } catch (err) {
911
+ if (err instanceof AuthError) showAuth(err.message);
912
+ else toast(err.message, 10000);
913
+ } finally { refresh.disabled = false; }
914
+ });
915
+ host.querySelector('.multiplexer-list').replaceChildren(...tools.map((tool) => {
916
+ const row = document.createElement('section');
917
+ row.className = 'multiplexer-row';
918
+ const title = document.createElement('strong');
919
+ title.textContent = tool.tool;
920
+ row.append(title);
921
+ const key = provider.id + ':' + tool.id;
922
+ if (tool.installable) {
923
+ const button = document.createElement('button');
924
+ button.type = 'button';
925
+ button.className = 'btn primary';
926
+ button.textContent = 'Install';
927
+ button.disabled = tool.busy;
928
+ button.title = tool.installCommand || '';
929
+ button.dataset.muxFocus = key + ':install';
930
+ button.addEventListener('click', () => manageMultiplexer(provider, tool, 'install'));
931
+ row.append(button);
932
+ }
933
+ const note = document.createElement('p');
934
+ note.className = 'multiplexer-note';
935
+ note.textContent = [
936
+ tool.busy ? 'Installation operation in progress…' : tool.guidance,
937
+ tool.pendingCards ? `${tool.pendingCards} detached ${tool.tool} card(s) are waiting to be restored. Check the installation or remove finished cards, then Refresh.` : '',
938
+ installNote(tool),
939
+ ].filter(Boolean).join(' ');
940
+ if (note.textContent) row.append(note);
941
+ const copies = document.createElement('details');
942
+ copies.className = 'copies';
943
+ copies.append(document.createElement('summary'), document.createElement('ul'));
944
+ row.append(copies);
945
+ renderCopies(copies, { ...tool, id: key, warnings: [] }, card, {
946
+ key, busy: tool.busy,
947
+ update: (copy) => manageMultiplexer(provider, tool, 'update', copy),
948
+ uninstall: (copy) => {
949
+ const plan = copy.uninstall;
950
+ const lines = [
951
+ `Uninstall ${tool.tool} from ${copy.displayPath}?`,
952
+ plan.command && `Runs: ${plan.command}`,
953
+ ...plan.remove.map((p) => `Removes: ${p}`),
954
+ plan.pathEntries && 'Removes only this installation’s entries from your user PATH.',
955
+ 'Your settings and session data are kept.',
956
+ ].filter(Boolean);
957
+ if (confirm(lines.join('\n'))) return manageMultiplexer(provider, tool, 'uninstall', copy);
958
+ },
959
+ });
960
+ return row;
961
+ }));
962
+ }
963
+
964
+ async function manageMultiplexer(provider, tool, kind, copy = null, force = false) {
965
+ // Busy state also comes from the manager, covering other tabs and refreshes.
966
+ tool.busy = true;
967
+ renderProviders();
968
+ try {
969
+ const { session } = await api('POST', `/providers/${provider.id}/multiplexers/${tool.id}/${kind}`, { path: copy?.path, force });
970
+ upsertSession(session);
971
+ openPanel(session.id);
972
+ } catch (err) {
973
+ if (err instanceof AuthError) return showAuth(err.message);
974
+ if (err.code === 'multiplexer_in_use' && !force) {
975
+ const waiting = err.pending ? ` ${err.pending} detached cards are also waiting for the tool.` : '';
976
+ if (confirm(`${err.message}${waiting} Continue anyway?`)) return manageMultiplexer(provider, tool, kind, copy, true);
977
+ } else toast(err.message, 10000);
978
+ } finally {
979
+ // A successful operation stays busy until the manager sends fresh state.
980
+ try {
981
+ const result = await api('GET', '/providers');
982
+ state.providers = result.providers;
983
+ } catch { tool.busy = false; }
984
+ renderProviders();
985
+ }
986
+ }
987
+
863
988
  function renderHint(hint, provider) {
864
989
  let text = '';
865
990
  if (!provider.available) text = provider.installable ? '' : provider.install || `${provider.command} was not found on PATH.`;
@@ -1887,7 +2012,7 @@ function renderChangelog() {
1887
2012
  }
1888
2013
 
1889
2014
  const CHANNEL_LABELS = {
1890
- npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', legacy: 'legacy install', unknown: 'unknown install',
2015
+ npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', system: 'system package', legacy: 'legacy install', unknown: 'unknown install',
1891
2016
  };
1892
2017
 
1893
2018
  function installNote(provider) {
@@ -3467,8 +3592,18 @@ class TerminalView {
3467
3592
 
3468
3593
  // ---- terminal panel -------------------------------------------------------
3469
3594
 
3595
+ const terminalCopy = new TerminalCopy({
3596
+ opener: $('panel-copy'), dialog: $('terminal-copy'),
3597
+ getCurrent: () => {
3598
+ const session = state.sessions.get(state.activeId);
3599
+ const view = state.views.get(state.activeId);
3600
+ return session && view ? { id: session.id, name: session.name, term: view.term } : null;
3601
+ },
3602
+ });
3603
+
3470
3604
  function openPanel(id) {
3471
3605
  if (!state.sessions.has(id)) return;
3606
+ if (state.activeId !== id) terminalCopy.close();
3472
3607
  if (dictation && dictation.id !== id) stopDictation();
3473
3608
  if (state.activeId && state.activeId !== id) state.views.get(state.activeId)?.unmount();
3474
3609
  state.activeId = id;
@@ -3480,6 +3615,7 @@ function openPanel(id) {
3480
3615
  }
3481
3616
 
3482
3617
  function closePanel() {
3618
+ terminalCopy.close();
3483
3619
  stopDictation();
3484
3620
  if (state.activeId) state.views.get(state.activeId)?.unmount();
3485
3621
  state.activeId = null;
@@ -4063,6 +4199,7 @@ document.addEventListener('visibilitychange', () => {
4063
4199
  });
4064
4200
  addEventListener('pagehide', () => {
4065
4201
  state.pageAway = true;
4202
+ terminalCopy.close();
4066
4203
  stopDictation();
4067
4204
  managerLoss.cancel();
4068
4205
  state.eventsSocket?.close();