@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.
- package/README.md +22 -0
- package/config/providers.default.json +4 -0
- package/package.json +1 -1
- package/src/manager/install-channels.mjs +1 -0
- package/src/manager/multiplexer-paths.mjs +81 -0
- package/src/manager/multiplexers.mjs +364 -0
- package/src/manager/providers.mjs +31 -4
- package/src/manager/server.mjs +7 -0
- package/src/manager/session-manager.mjs +207 -16
- package/src/manager/uninstall.mjs +29 -2
- package/src/manager/versions.mjs +12 -0
- package/web/app.js +144 -7
- package/web/index.html +21 -0
- package/web/styles.css +31 -0
- package/web/terminal-copy.js +128 -0
|
@@ -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: {
|
|
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.
|
|
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',
|
|
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
|
-
|
|
247
|
-
if (!
|
|
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)
|
|
251
|
-
|
|
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
|
-
|
|
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);
|
package/src/manager/versions.mjs
CHANGED
|
@@ -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
|
-
|
|
826
|
-
box.
|
|
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
|
-
|
|
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();
|