@oddessentials/agent-guild 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -78,6 +78,21 @@ card names the command that reattaches it from a terminal. Stopping or
78
78
  restarting the manager detaches them the same way, and the cards come back,
79
79
  closed and ready to reattach, when it starts again.
80
80
 
81
+ Open **Terminal multiplexers** on the Shell card to install, update or
82
+ uninstall tmux and herdr. Operations run in a terminal so you can read their
83
+ output and answer package-manager prompts. tmux uses Homebrew on macOS and
84
+ Homebrew or apt, dnf, or pacman on Linux; a distribution must offer 3.2 or
85
+ later. Update distro packages with their package manager. Native Windows
86
+ offers herdr instead of tmux.
87
+
88
+ Herdr updates leave its existing servers and panes running, including
89
+ Homebrew updates. New starts and reattachments wait until the operation
90
+ finishes. Uninstall requires herdr's servers to be stopped and their status
91
+ to be verifiable. tmux asks before changing a copy that attached or detached
92
+ cards depend on. Settings and session data are kept. If a multiplexer is
93
+ missing when the manager starts, its saved cards wait for it and return
94
+ when it is detected again, provided their sessions still exist.
95
+
81
96
  <img src="docs/images/terminal.webp" alt="An open Claude Code session with its helper agents shown in the header">
82
97
 
83
98
  **Agents and models at work.** Helper agents that a tool starts appear on its
@@ -232,6 +247,8 @@ or prompts:
232
247
  | OpenRouter's public model list | Benchmarks | Every 6 hours |
233
248
  | Public news feeds, Hacker News, arXiv and GitHub | The newsfeed | Every 30 minutes while the page is open |
234
249
  | GitHub's releases API | What's new | Hourly |
250
+ | herdr.dev and formulae.brew.sh | Multiplexer version checks | At most hourly, or on Refresh or a requested operation; disabled by `AGENT_GUILD_NO_UPDATE_CHECK=1` |
251
+ | herdr.dev, GitHub release downloads, and the selected package manager's repositories | Multiplexer installation and updates | When you request the operation |
235
252
  | GitHub (sign-in, API, avatars and SSH) | Signing in to GitHub, listing your repositories, adding your SSH key and cloning | When you use **Clone from GitHub…** |
236
253
 
237
254
  ## Other front ends
@@ -138,6 +138,10 @@
138
138
  "vendor": "Local",
139
139
  "tool": "Shell",
140
140
  "command": "@shell",
141
+ "multiplexers": [
142
+ { "id": "tmux", "docs": "https://github.com/tmux/tmux/wiki/Installing", "channels": { "brew": { "names": ["tmux"] } } },
143
+ { "id": "herdr", "docs": "https://herdr.dev/docs/install/", "channels": { "brew": { "names": ["herdr"] } } }
144
+ ],
141
145
  "args": [],
142
146
  "color": "#8B5CF6",
143
147
  "monogram": ">",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -7,6 +7,7 @@ export const CHANNEL_LABELS = {
7
7
  native: 'native',
8
8
  brew: 'Homebrew',
9
9
  winget: 'WinGet',
10
+ system: 'system package',
10
11
  legacy: 'legacy install',
11
12
  unknown: 'unknown install',
12
13
  };
@@ -0,0 +1,81 @@
1
+ import path from 'node:path';
2
+ import { homeDir } from './install-channels.mjs';
3
+ import { expandWindowsVars } from './shell-env.mjs';
4
+
5
+ export function envValue(env, name) {
6
+ return env[Object.keys(env).find((key) => key.toUpperCase() === name.toUpperCase())];
7
+ }
8
+
9
+ export function pathIdentity(file, platform) {
10
+ const p = platform === 'win32' ? path.win32 : path.posix;
11
+ const value = p.normalize(file.replace(/^\\\\\?\\/, '')).replace(/[\\/]+$/, '');
12
+ return platform === 'win32' ? value.toLowerCase() : value;
13
+ }
14
+
15
+ export function herdrLayout(env, platform) {
16
+ const p = platform === 'win32' ? path.win32 : path.posix;
17
+ const home = homeDir(env, platform);
18
+ if (platform !== 'win32') {
19
+ const bin = p.join(home, '.local/bin');
20
+ return { home, bin, launcher: p.join(bin, 'herdr'), remove: [p.join(bin, 'herdr')], links: [] };
21
+ }
22
+ const root = p.join(home, '.herdr');
23
+ const standalone = p.join(root, 'packages/standalone');
24
+ const bin = p.join(envValue(env, 'LOCALAPPDATA') || p.join(home, 'AppData/Local'), 'Programs/Herdr/bin');
25
+ const current = p.join(standalone, 'current');
26
+ const releases = p.join(standalone, 'releases');
27
+ return {
28
+ home: root, bin, current, releases, standalone, launcher: p.join(current, 'herdr.exe'),
29
+ remove: [standalone], links: [bin],
30
+ pathEntries: { exact: [bin, current], parent: releases },
31
+ };
32
+ }
33
+
34
+ export function ownsPathEntry(entry, rules, env = {}) {
35
+ const normalized = pathIdentity(expandWindowsVars(entry.trim().replace(/^"(.*)"$/, '$1'), env), 'win32');
36
+ return rules.exact.some((p) => normalized === pathIdentity(p, 'win32'))
37
+ || pathIdentity(path.win32.dirname(normalized), 'win32') === pathIdentity(rules.parent, 'win32');
38
+ }
39
+
40
+ /** Only herdr's versioned entries are replaced from the registry; other inherited PATH entries stay. */
41
+ export function reconcileHerdrPath(current, env) {
42
+ const rules = herdrLayout(env, 'win32').pathEntries;
43
+ return current.split(';').filter((entry) => !ownsPathEntry(entry, rules, env)).join(';');
44
+ }
45
+
46
+ // This same filter is tested against strings without changing the user's registry.
47
+ export const WINDOWS_PATH_FILTER = String.raw`
48
+ function Remove-OwnedPathEntries($PathValue, $Rules) {
49
+ function Normalize-Entry([string]$Value) {
50
+ $value = [Environment]::ExpandEnvironmentVariables($Value.Trim().Trim('"')).TrimEnd('\')
51
+ try { [System.IO.Path]::GetFullPath($value).TrimEnd('\') } catch { $value }
52
+ }
53
+ $owned = @($Rules.exact | ForEach-Object { Normalize-Entry $_ })
54
+ $ownedParent = Normalize-Entry $Rules.parent
55
+ $kept = @($PathValue.Split(';') | Where-Object {
56
+ $segment = Normalize-Entry $_
57
+ try { $parent = [System.IO.Path]::GetDirectoryName($segment) } catch { $parent = $null }
58
+ -not ($owned -icontains $segment) -and $parent -ine $ownedParent
59
+ })
60
+ return ($kept -join ';')
61
+ }
62
+ `;
63
+
64
+ export function windowsPathCleanupScript(rules) {
65
+ const encoded = Buffer.from(JSON.stringify(rules)).toString('base64');
66
+ return WINDOWS_PATH_FILTER + String.raw`
67
+ $ErrorActionPreference = 'Stop'
68
+ $rules = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('` + encoded + String.raw`')) | ConvertFrom-Json
69
+ $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
70
+ if ($null -ne $key) {
71
+ try {
72
+ $value = $key.GetValue('Path', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
73
+ if ($null -ne $value) {
74
+ $kind = $key.GetValueKind('Path')
75
+ $next = Remove-OwnedPathEntries $value $rules
76
+ if ($next -cne $value) { $key.SetValue('Path', $next, $kind) }
77
+ }
78
+ } finally { $key.Dispose() }
79
+ }
80
+ `;
81
+ }
@@ -0,0 +1,364 @@
1
+ // Inventory is separate from launchable shells: old tmux and off-PATH or
2
+ // incomplete herdr installations still need management controls.
3
+ import path from 'node:path';
4
+ import { buildSpawnSpec, resolveCommand, resolveAllCommands, runSpec, pathKey } from './command-resolver.mjs';
5
+ import { classifyInstall, defaultFsx, formatCommand, homeRelative, installationKey, uninstallPlan } from './install-channels.mjs';
6
+ import { parseVersion, parseTmuxVersion, compareVersions, compareTmuxVersions } from './versions.mjs';
7
+ import { tmuxSupported } from './shells.mjs';
8
+ import { herdrLayout, ownsPathEntry, pathIdentity } from './multiplexer-paths.mjs';
9
+ import { RUNNER, encodePlan } from './uninstall.mjs';
10
+
11
+ const TTL = 60_000;
12
+ const LATEST_TTL = 3_600_000;
13
+ const error = (code, message, status = 409) => Object.assign(new Error(message), { code, status });
14
+ const command = (recipe) => recipe ? formatCommand(recipe.file, recipe.args) : null;
15
+ const posixQuote = (s) => "'" + s.replaceAll("'", "'\\''") + "'";
16
+
17
+ export function distroCandidate(manager, output) {
18
+ const value = manager === 'apt'
19
+ ? output.match(/^\s*Candidate:\s*(\S+)/m)?.[1]
20
+ : manager === 'pacman' ? output.match(/^Version\s*:\s*(\S+)/m)?.[1] : output.trim().split(/\s+/)[0];
21
+ return parseTmuxVersion(value);
22
+ }
23
+
24
+ export function nativeHerdrEnv(env, platform) {
25
+ const layout = herdrLayout(env, platform);
26
+ return platform === 'win32'
27
+ ? { HERDR_HOME: layout.home, HERDR_INSTALL_DIR: layout.bin }
28
+ : { HERDR_INSTALL_DIR: layout.bin };
29
+ }
30
+
31
+ export class MultiplexerRegistry {
32
+ constructor(registry, { run = runSpec, resolve = resolveCommand, resolveAll = resolveAllCommands, fsx = defaultFsx, uid = () => process.getuid?.() } = {}) {
33
+ Object.assign(this, { registry, run, resolve, resolveAll, fsx, uid });
34
+ this.inventory = new Map();
35
+ this.latest = new Map();
36
+ }
37
+
38
+ key(provider, id) { return `${provider.id}:${id}`; }
39
+ definitions(provider) { return provider.command === '@shell' ? provider.multiplexers || [] : []; }
40
+ env(provider) { return { ...this.registry.env, ...provider.env }; }
41
+ get(provider, id) {
42
+ const found = this.definitions(provider).find((m) => m.id === id);
43
+ if (!found) throw error('unknown_multiplexer', `Unknown multiplexer "${id}"`, 404);
44
+ return found;
45
+ }
46
+
47
+ describe(provider) {
48
+ return this.definitions(provider).map((definition) => {
49
+ const entry = this.inventory.get(this.key(provider, definition.id));
50
+ return entry?.public || {
51
+ id: definition.id, tool: definition.tool, docs: definition.docs, checked: false,
52
+ available: false, installable: false, installs: [], guidance: 'Checking installation…',
53
+ };
54
+ });
55
+ }
56
+
57
+ async execute(file, args, env) {
58
+ return this.run(buildSpawnSpec(file, args, env, this.registry.platform), { env, timeoutMs: 10_000 });
59
+ }
60
+
61
+ async systemInstall(file, definition, env) {
62
+ if (definition.id !== 'tmux' || this.registry.platform !== 'linux') return null;
63
+ const candidates = [
64
+ ['apt', 'dpkg', ['-S', file], /^tmux(?::[a-z0-9_-]+)?:\s/m, 'apt-get'],
65
+ ['dnf', 'rpm', ['-qf', '--queryformat', '%{NAME}', file], /^tmux$/, 'dnf'],
66
+ ['pacman', 'pacman', ['-Qqo', file], /^tmux\s*$/, 'pacman'],
67
+ ];
68
+ for (const [manager, query, args, match, binary] of candidates) {
69
+ const probe = this.resolve(query, env, 'linux');
70
+ if (!probe) continue;
71
+ try {
72
+ if (!match.test((await this.execute(probe, args, { ...env, LC_ALL: 'C' })).stdout.trim())) continue;
73
+ const executable = this.resolve(binary, env, 'linux');
74
+ const removal = executable && this.elevated(executable, manager === 'pacman' ? ['-R', 'tmux'] : ['remove', 'tmux'], env);
75
+ return {
76
+ channel: 'system', resolvedPath: file, realPath: this.fsx.realpath(file), key: `system:${manager}:tmux`,
77
+ update: null, uninstall: removal ? { run: removal, remove: [], links: [] } : null,
78
+ guidance: `Managed by ${manager}. Update tmux with your distribution's package manager.`,
79
+ };
80
+ } catch { /* another manager, or no verifiable owner */ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ elevated(file, args, env) {
86
+ if (this.uid() === 0) return { file, args };
87
+ const sudo = this.resolve('sudo', env, this.registry.platform);
88
+ return sudo ? { file: sudo, args: [file, ...args] } : null;
89
+ }
90
+
91
+ nativeOwns(file, env) {
92
+ const platform = this.registry.platform;
93
+ const layout = herdrLayout(env, platform);
94
+ const real = pathIdentity(this.fsx.realpath(file), platform);
95
+ if (platform !== 'win32') return real === pathIdentity(layout.launcher, platform) && !this.fsx.isLink(file);
96
+ const p = path.win32;
97
+ return p.basename(real) === 'herdr.exe'
98
+ && pathIdentity(p.dirname(p.dirname(real)), platform) === pathIdentity(layout.releases, platform)
99
+ && pathIdentity(this.fsx.realpath(layout.standalone), platform) === pathIdentity(layout.standalone, platform);
100
+ }
101
+
102
+ nativeCopy(file, env, partial = false) {
103
+ const platform = this.registry.platform;
104
+ const layout = herdrLayout(env, platform);
105
+ return {
106
+ channel: 'native', resolvedPath: file, realPath: this.fsx.realpath(file), key: `native:${pathIdentity(layout.launcher, platform)}`,
107
+ partial, update: partial ? null : { file, args: ['update'] },
108
+ uninstall: { run: null, remove: layout.remove, links: layout.links, launcher: partial ? null : file, pathEntries: layout.pathEntries, strict: true },
109
+ guidance: partial ? 'An incomplete herdr installation remains. Install again to repair it before checking server status and removing it.' : null,
110
+ };
111
+ }
112
+
113
+ managedPathsSafe(env) {
114
+ const platform = this.registry.platform;
115
+ const p = platform === 'win32' ? path.win32 : path.posix;
116
+ const layout = herdrLayout(env, platform);
117
+ const roots = platform === 'win32' ? [layout.standalone, p.dirname(layout.bin)] : [layout.bin];
118
+ for (const root of roots) {
119
+ for (let parent = root; ; parent = p.dirname(parent)) {
120
+ if (this.fsx.isLink(parent) || pathIdentity(this.fsx.realpath(parent), platform) !== pathIdentity(parent, platform)) return false;
121
+ if (p.dirname(parent) === parent) break;
122
+ }
123
+ }
124
+ if (platform === 'win32' && this.fsx.isLink(layout.bin)) {
125
+ const target = this.fsx.realpath(layout.bin);
126
+ if (pathIdentity(p.dirname(target), platform) !== pathIdentity(layout.releases, platform)) return false;
127
+ }
128
+ return true;
129
+ }
130
+
131
+ async inspect(definition, env) {
132
+ const platform = this.registry.platform;
133
+ const onPath = this.resolveAll(definition.id, env, platform);
134
+ const files = [...onPath];
135
+ const layout = herdrLayout(env, platform);
136
+ if (definition.id === 'herdr') {
137
+ const known = [layout.launcher, ...(platform === 'win32' ? [path.win32.join(layout.bin, 'herdr.exe')] : [])];
138
+ for (const file of known) if (this.fsx.isFile(file) && !files.includes(file)) files.push(file);
139
+ }
140
+ const copies = new Map();
141
+ for (const file of files) {
142
+ // Classify Homebrew first, including symlinks in a user's bin directory.
143
+ let copy = classifyInstall({ resolvedPath: file, provider: { ...definition, channels: { brew: definition.channels.brew } }, env, platform, fsx: this.fsx });
144
+ if (copy.channel === 'unknown') {
145
+ if (definition.id === 'herdr' && this.nativeOwns(file, env)) copy = this.nativeCopy(file, env);
146
+ else copy = await this.systemInstall(file, definition, env) || copy;
147
+ }
148
+ copy.key ??= installationKey(copy, definition, platform, this.fsx);
149
+ copy.uninstall ??= uninstallPlan(copy, definition, env, platform, this.fsx);
150
+ copy.onPath = onPath.includes(file);
151
+ copy.active = file === onPath[0];
152
+ if (copies.has(copy.key)) continue;
153
+ try {
154
+ const { stdout } = await this.execute(file, definition.versionArgs, env);
155
+ copy.version = definition.id === 'tmux' ? parseTmuxVersion(stdout) : parseVersion(stdout);
156
+ copy.supported = definition.id !== 'tmux' || tmuxSupported(stdout.trim());
157
+ copy.versionStatus = copy.version ? 'ok' : 'unavailable';
158
+ copy.versionError = null;
159
+ } catch (err) {
160
+ Object.assign(copy, { version: null, supported: false, versionStatus: 'failed', versionError: String(err.message).slice(0, 200) });
161
+ }
162
+ if (copy.channel === 'native' && copy.update) {
163
+ try {
164
+ const out = await this.execute(file, ['update', '--help'], { ...env, ...nativeHerdrEnv(env, platform) });
165
+ if (!/Usage:\s*herdr update/i.test(out.stdout + out.stderr)) copy.update = null;
166
+ } catch { copy.update = null; }
167
+ }
168
+ copies.set(copy.key, copy);
169
+ }
170
+ if (definition.id === 'herdr' && ![...copies.values()].some((c) => c.channel === 'native')) {
171
+ const remnants = layout.remove.some((p) => this.fsx.exists(p))
172
+ || (platform === 'win32' && (env[pathKey(env, platform)] || '').split(';').some((p) => ownsPathEntry(p, layout.pathEntries, env)));
173
+ if (remnants && layout.remove.every((p) => pathIdentity(this.fsx.realpath(p), platform) === pathIdentity(p, platform))) {
174
+ const copy = this.nativeCopy(layout.launcher, env, true);
175
+ copies.set(copy.key, { ...copy, onPath: false, active: false, supported: false, version: null, versionStatus: 'failed' });
176
+ }
177
+ }
178
+ return [...copies.values()];
179
+ }
180
+
181
+ async installRecipe(definition, env) {
182
+ const platform = this.registry.platform;
183
+ const resolve = (name) => this.resolve(name, env, platform);
184
+ if (definition.id === 'tmux') {
185
+ if (platform === 'win32') return { guidance: 'Native tmux is not supported on Windows. Install herdr to use a multiplexer here.' };
186
+ let guidance = 'Install tmux 3.2 or later with Homebrew, or use herdr.';
187
+ if (platform === 'linux') {
188
+ for (const [manager, query, args, binary, installArgs] of [
189
+ ['apt', 'apt-cache', ['policy', 'tmux'], 'apt-get', ['install', 'tmux']],
190
+ ['dnf', 'dnf', ['repoquery', '--available', '--latest-limit=1', '--queryformat', '%{version}\\n', 'tmux'], 'dnf', ['install', 'tmux']],
191
+ ['pacman', 'pacman', ['-Si', 'tmux'], 'pacman', ['-S', 'tmux']],
192
+ ]) {
193
+ if (!resolve(query) || !resolve(binary)) continue;
194
+ let candidate = null;
195
+ try { candidate = distroCandidate(manager, (await this.execute(resolve(query), args, { ...env, LC_ALL: 'C' })).stdout); } catch {}
196
+ if (candidate && compareTmuxVersions(candidate, '3.2') >= 0) {
197
+ const recipe = this.elevated(resolve(binary), installArgs, env);
198
+ if (recipe) return { recipe };
199
+ }
200
+ guidance = candidate
201
+ ? `Your distribution offers tmux ${candidate}; Agent Guild needs 3.2 or later and sudo (unless root). Install with Homebrew, or use herdr.`
202
+ : 'Could not verify a usable tmux package. Check your package metadata, install tmux 3.2 or later with Homebrew, or use herdr.';
203
+ break;
204
+ }
205
+ }
206
+ return resolve('brew') ? { recipe: { file: resolve('brew'), args: ['install', 'tmux'] } } : { guidance };
207
+ }
208
+ const extraEnv = nativeHerdrEnv(env, platform);
209
+ if (!this.managedPathsSafe(env)) return { guidance: 'The default herdr installation directory is redirected by a link. Manage this installation with its original installer.' };
210
+ if (platform === 'win32') {
211
+ const powershell = resolve('powershell.exe');
212
+ if (!powershell || !resolve('curl.exe')) return { guidance: 'Install herdr with the Windows installer at https://herdr.dev/docs/install/; PowerShell and curl.exe are required.' };
213
+ return {
214
+ recipe: {
215
+ file: powershell, args: ['-NoLogo', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', "$ErrorActionPreference = 'Stop'; & ([scriptblock]::Create((Invoke-RestMethod 'https://herdr.dev/install.ps1'))) -Channel stable -ManifestUrl 'https://herdr.dev/latest.json' -ExpectedBuildId ''"],
216
+ },
217
+ extraEnv: { ...extraEnv, HERDR_CHANNEL: 'stable', HERDR_MANIFEST_URL: '', HERDR_EXPECTED_BUILD_ID: '' },
218
+ };
219
+ }
220
+ if (!['sh', 'curl', 'awk'].every(resolve)) return { guidance: 'Install herdr with https://herdr.dev/install.sh; sh, curl and awk are required.' };
221
+ // Download first: a failed curl in a pipe must not look like a successful install.
222
+ return {
223
+ recipe: { file: resolve('sh'), args: ['-c', `script=$(${posixQuote(resolve('curl'))} -fsSL https://herdr.dev/install.sh) || exit $?; ${posixQuote(resolve('sh'))} -c "$script"`] },
224
+ extraEnv,
225
+ };
226
+ }
227
+
228
+ async latestFor(definition, copy, env, force) {
229
+ if (!this.registry.checkUpdates) return null;
230
+ let url;
231
+ if (copy.channel === 'brew') url = `https://formulae.brew.sh/api/formula/${definition.id}.json`;
232
+ else if (copy.channel === 'native' && !copy.partial) {
233
+ // Preview identity is not ordinary semver. Do not compare it to stable.
234
+ try {
235
+ const { stdout } = await this.execute(copy.resolvedPath, ['channel', 'show'], { ...env, ...nativeHerdrEnv(env, this.registry.platform) });
236
+ if (stdout.trim() !== 'stable') return null;
237
+ } catch { return null; }
238
+ url = 'https://herdr.dev/latest.json';
239
+ }
240
+ if (!url || !this.registry.checkUpdates) return null;
241
+ const cached = this.latest.get(url);
242
+ if (!force && cached && Date.now() - cached.at < LATEST_TTL) return cached.version;
243
+ let version = null;
244
+ try {
245
+ const response = await (this.registry.fetchImpl || fetch)(url, { signal: AbortSignal.timeout(10_000) });
246
+ if (response.ok) {
247
+ const body = await response.json();
248
+ const raw = copy.channel === 'brew' ? body.versions?.stable : body.version;
249
+ version = definition.id === 'tmux' ? parseTmuxVersion(raw) : parseVersion(raw);
250
+ }
251
+ } catch {}
252
+ this.latest.set(url, { version, at: Date.now() });
253
+ return version;
254
+ }
255
+
256
+ async refresh(provider, { force = false } = {}) {
257
+ let changed = false;
258
+ await Promise.all(this.definitions(provider).map(async (definition) => {
259
+ const key = this.key(provider, definition.id);
260
+ const previous = this.inventory.get(key);
261
+ if (!force && previous && Date.now() - previous.at < TTL) return;
262
+ const env = this.env(provider);
263
+ const unsupported = definition.id === 'tmux' && this.registry.platform === 'win32';
264
+ const copies = unsupported ? [] : await this.inspect(definition, env);
265
+ const install = await this.installRecipe(definition, env);
266
+ const active = copies.find((c) => c.active);
267
+ const compare = definition.id === 'tmux' ? compareTmuxVersions : compareVersions;
268
+ const installs = await Promise.all(copies.map(async (copy) => {
269
+ const latestVersion = await this.latestFor(definition, copy, env, force);
270
+ const guidance = copy.guidance || (!copy.update ? 'Update this copy the way you installed it.'
271
+ : !latestVersion ? `Latest version is unknown. You can run ${command(copy.update)} to check for an update using this installation's channel.` : null);
272
+ return {
273
+ path: copy.resolvedPath, key: copy.key, displayPath: homeRelative(copy.resolvedPath, env, this.registry.platform),
274
+ channel: copy.channel, version: copy.version, versionStatus: copy.versionStatus,
275
+ active: copy.active, onPath: copy.onPath, partial: Boolean(copy.partial), supported: copy.supported,
276
+ latestVersion, updateAvailable: Boolean(copy.version && latestVersion && compare(latestVersion, copy.version) > 0),
277
+ updateCommand: command(copy.update), updateGuidance: guidance,
278
+ uninstall: copy.uninstall && { command: command(copy.uninstall.run), remove: [...copy.uninstall.remove, ...copy.uninstall.links].map((p) => homeRelative(p, env, this.registry.platform)), pathEntries: Boolean(copy.uninstall.pathEntries) },
279
+ uninstallGuidance: copy.uninstall ? null : 'Remove this copy the way you installed it.',
280
+ };
281
+ }));
282
+ const offPath = copies.some((c) => !c.onPath && !c.partial);
283
+ const old = active && definition.id === 'tmux' && !active.supported;
284
+ const publicInfo = {
285
+ id: definition.id, tool: definition.tool, docs: definition.docs, checked: true,
286
+ available: Boolean(active?.supported), installable: Boolean(install.recipe && !copies.some((c) => !c.partial)),
287
+ installCommand: command(install.recipe), installs,
288
+ guidance: unsupported ? install.guidance : old ? `tmux ${active.version || 'at this path'} cannot be started here; 3.2 or later is required. ${install.guidance || 'Remove the old copy before installing a supported one.'}`
289
+ : offPath ? `A copy exists off PATH. Add its directory to your shell's PATH, then Refresh to make it available.`
290
+ : copies.some((c) => c.partial) ? copies.find((c) => c.partial).guidance : copies.length ? null : install.guidance || null,
291
+ lastInstall: previous?.public.lastInstall ?? null,
292
+ };
293
+ changed ||= JSON.stringify(previous?.public) !== JSON.stringify(publicInfo);
294
+ this.inventory.set(key, { at: Date.now(), copies, install, public: publicInfo });
295
+ }));
296
+ return changed;
297
+ }
298
+
299
+ copyFor(provider, id, file) {
300
+ return this.inventory.get(this.key(provider, id))?.copies.find((c) => pathIdentity(c.resolvedPath, this.registry.platform) === pathIdentity(file, this.registry.platform));
301
+ }
302
+
303
+ identityFor(provider, id, file) {
304
+ const cached = this.copyFor(provider, id, file);
305
+ if (cached) return cached.key;
306
+ const definition = this.definitions(provider).find((m) => m.id === id);
307
+ if (!definition) return null;
308
+ const env = this.env(provider);
309
+ if (id === 'herdr' && this.nativeOwns(file, env)) return this.nativeCopy(file, env).key;
310
+ const copy = classifyInstall({ resolvedPath: file, provider: definition, env, platform: this.registry.platform, fsx: this.fsx });
311
+ return copy.channel === 'unknown' ? null : installationKey(copy, definition, this.registry.platform, this.fsx);
312
+ }
313
+
314
+ async prepare(provider, id, kind, copyPath) {
315
+ this.get(provider, id);
316
+ await this.registry.refreshVersions({ force: true, ids: [provider.id] });
317
+ const entry = this.inventory.get(this.key(provider, id));
318
+ const env = this.env(provider);
319
+ if (kind === 'install') {
320
+ if (!entry.public.installable) throw error('not_installable', entry.public.guidance || 'This tool is already installed or has no supported installer.', 400);
321
+ return { spec: buildSpawnSpec(entry.install.recipe.file, entry.install.recipe.args, env, this.registry.platform), extraEnv: entry.install.extraEnv, copy: null };
322
+ }
323
+ if (typeof copyPath !== 'string' || !copyPath) throw error('bad_request', 'path must name the installation to change', 400);
324
+ const copy = this.copyFor(provider, id, copyPath);
325
+ if (!copy) throw error('unknown_copy', 'The selected installation is no longer present. Refresh and try again.', 404);
326
+ const recipe = kind === 'update' ? copy.update : copy.uninstall;
327
+ if (!recipe) throw error(kind === 'update' ? 'not_updatable' : 'not_removable', copy.guidance || 'Manage this copy with its original installer.', 400);
328
+ const spec = kind === 'update'
329
+ ? buildSpawnSpec(recipe.file, recipe.args, env, this.registry.platform)
330
+ : buildSpawnSpec(process.execPath, [RUNNER, encodePlan(recipe)], env, this.registry.platform);
331
+ return { spec, copy, extraEnv: copy.channel === 'native' ? nativeHerdrEnv(env, this.registry.platform) : null };
332
+ }
333
+
334
+ async assertHerdrStopped(provider, copy, extraEnv) {
335
+ try {
336
+ if (copy.partial) throw new Error('herdr executable is missing');
337
+ const env = { ...this.env(provider), ...extraEnv };
338
+ const { stdout } = await this.execute(copy.resolvedPath, ['session', 'list', '--json'], env);
339
+ const body = JSON.parse(stdout);
340
+ if (!Array.isArray(body.sessions) || body.sessions.some((s) => typeof s.running !== 'boolean')) throw new Error('invalid session listing');
341
+ if (body.sessions.some((s) => s.running)) {
342
+ throw error('herdr_running', 'Stop herdr before uninstalling: herdr server stop (and herdr session stop <name> for other running sessions).');
343
+ }
344
+ } catch (err) {
345
+ if (err.code === 'herdr_running') throw err;
346
+ throw error('herdr_status_unknown', 'Cannot safely determine whether herdr is running. Repair the installation if necessary, then check herdr session list --json before retrying.');
347
+ }
348
+ }
349
+
350
+ async finish(provider, id, kind, copy, exitCode) {
351
+ const key = this.key(provider, id);
352
+ const before = copy?.version;
353
+ await this.registry.refreshVersions({ force: true, ids: [provider.id] });
354
+ const entry = this.inventory.get(key);
355
+ if (!entry) return;
356
+ const after = copy && entry.copies.find((c) => c.key === copy.key);
357
+ const outcome = exitCode !== 0 ? 'failed' : kind === 'uninstall' ? after ? 'remaining' : 'removed'
358
+ : kind === 'install' ? entry.public.available ? 'installed' : 'missing'
359
+ : !after || after.partial ? 'missing' : after.version && after.version === before ? 'unchanged' : 'updated';
360
+ const checked = kind === 'install' ? entry.copies.find((c) => c.active) : after;
361
+ entry.public.lastInstall = { kind, outcome, exitCode, ...(kind !== 'uninstall' && checked?.versionStatus === 'failed' ? { verification: 'failed' } : {}) };
362
+ this.registry.emit('updated');
363
+ }
364
+ }
@@ -16,6 +16,8 @@ import { weavePaths } from './shell-env.mjs';
16
16
  import { detectShells, fallbackShell } from './shells.mjs';
17
17
  import { RUNNER, encodePlan } from './uninstall.mjs';
18
18
  import { paths } from './config.mjs';
19
+ import { MultiplexerRegistry } from './multiplexers.mjs';
20
+ import { reconcileHerdrPath } from './multiplexer-paths.mjs';
19
21
 
20
22
  const here = path.dirname(fileURLToPath(import.meta.url));
21
23
  export const DEFAULTS_FILE = path.resolve(here, '../../config/providers.default.json');
@@ -177,6 +179,17 @@ function normalize(raw, platform, warnings) {
177
179
  install: String(merged.install || ''),
178
180
  npmNote: merged.npmNote ? String(merged.npmNote) : null,
179
181
  channels: normalizeChannels(merged.channels),
182
+ multiplexers: Array.isArray(merged.multiplexers) ? merged.multiplexers
183
+ .filter((m) => m && typeof m === 'object' && !Array.isArray(m))
184
+ .map((m) => ({ ...m, ...m[platform] }))
185
+ .filter((m) => ['tmux', 'herdr'].includes(m.id) && m.enabled !== false)
186
+ .map((entry) => {
187
+ return {
188
+ id: entry.id, tool: String(entry.tool || entry.id), docs: String(entry.docs || ''),
189
+ versionArgs: entry.id === 'tmux' ? ['-V'] : ['--version'],
190
+ channels: normalizeChannels(entry.channels),
191
+ };
192
+ }) : [],
180
193
  docs: String(merged.docs || ''),
181
194
  usageUrl: httpsUrl(merged.usageUrl, 'usageUrl', merged.id, warnings),
182
195
  billingUrl: httpsUrl(merged.billingUrl, 'billingUrl', merged.id, warnings),
@@ -241,7 +254,7 @@ export class ProviderRegistry extends EventEmitter {
241
254
  * @param {Function} [opts.fetchImpl]
242
255
  * @param {string} [opts.accountsDir] where accounts without a dir get their home folders
243
256
  */
244
- constructor({ userFile, env, platform = process.platform, iconDir, registryUrl = null, checkUpdates = true, fetchImpl, pathReader = null, accountsDir = paths.accounts } = {}) {
257
+ constructor({ userFile, env, platform = process.platform, iconDir, registryUrl = null, checkUpdates = true, fetchImpl, pathReader = null, accountsDir = paths.accounts, multiplexerOptions } = {}) {
245
258
  super();
246
259
  this.userFile = userFile;
247
260
  this.accountsDir = accountsDir;
@@ -260,6 +273,8 @@ export class ProviderRegistry extends EventEmitter {
260
273
  this._refreshing = null;
261
274
  this._npmRegistry = null;
262
275
  this.reportingEnabled = null;
276
+ this.multiplexers = new MultiplexerRegistry(this, multiplexerOptions);
277
+ this.multiplexerState = null;
263
278
  this.reload();
264
279
  }
265
280
 
@@ -270,6 +285,7 @@ export class ProviderRegistry extends EventEmitter {
270
285
  this._npmRegistry = null;
271
286
  this._installs.clear();
272
287
  this._shells.clear();
288
+ this.multiplexers.inventory.clear();
273
289
  for (const w of warnings) console.warn(`[providers] ${w}`);
274
290
  this.emit('updated');
275
291
  }
@@ -324,9 +340,10 @@ export class ProviderRegistry extends EventEmitter {
324
340
 
325
341
  async _readPath() {
326
342
  const discovered = await Promise.resolve().then(() => this.pathReader()).catch(() => null);
327
- if (!discovered) return false;
343
+ if (discovered === null || discovered === undefined) return false;
328
344
  const key = pathKey(this.env, this.platform);
329
- const next = weavePaths(this.env[key] || '', discovered, {
345
+ const current = this.platform === 'win32' ? reconcileHerdrPath(this.env[key] || '', this.env) : this.env[key] || '';
346
+ const next = weavePaths(current, discovered, {
330
347
  delimiter: this.platform === 'win32' ? ';' : ':',
331
348
  caseInsensitive: this.platform === 'win32',
332
349
  });
@@ -341,10 +358,18 @@ export class ProviderRegistry extends EventEmitter {
341
358
  const now = Date.now();
342
359
  let changed = await this.refreshPath({ force });
343
360
  const providers = this.providers.filter((p) => !ids || ids.includes(p.id));
344
- if (force) for (const provider of providers) this._installs.delete(provider.id);
361
+ const shellsBefore = JSON.stringify(providers.map((p) => this._shells.get(p.id)?.found ?? null));
362
+ // Installing into an existing PATH directory does not change PATH itself.
363
+ for (const provider of providers) {
364
+ if (force) this._installs.delete(provider.id);
365
+ if (force) this._shells.delete(provider.id);
366
+ }
345
367
  const lookups = this.checkUpdates && providers.some((p) => p.package);
346
368
  const registryUrl = lookups ? await this.npmRegistryUrl() : null;
347
369
  await Promise.all(providers.map(async (provider) => {
370
+ const muxChanged = await this.multiplexers.refresh(provider, { force });
371
+ if (muxChanged) this._shells.delete(provider.id);
372
+ changed ||= muxChanged;
348
373
  const entry = this.versions.get(provider.id) || {
349
374
  installed: null, versionStatus: null, versionError: null, installedPath: null, installedMtime: null, installedAt: 0,
350
375
  latest: null, latestAt: 0, probePath: null, probeMtime: null, probeAt: 0, probeOk: null, lastInstall: null, copies: {},
@@ -412,6 +437,7 @@ export class ProviderRegistry extends EventEmitter {
412
437
  }
413
438
  this.versions.set(provider.id, entry);
414
439
  }));
440
+ changed ||= shellsBefore !== JSON.stringify(providers.map((p) => this.shellsFor(p)));
415
441
  if (changed) this.emit('updated');
416
442
  }
417
443
 
@@ -671,6 +697,7 @@ export class ProviderRegistry extends EventEmitter {
671
697
  accounts: provider.accounts.map((account) => ({ id: account.id, label: account.label })),
672
698
  shells: shells?.shells.map((shell) => ({ id: shell.id, label: shell.label, path: shell.path, multiplexer: Boolean(shell.multiplexer) })) ?? null,
673
699
  defaultShell: shells?.defaultId ?? null,
700
+ multiplexers: this.multiplexers.describe(provider).map((m) => ({ ...m, ...this.multiplexerState?.(provider.id, m.id) })),
674
701
  modelPattern: provider.modelPattern,
675
702
  color: provider.color,
676
703
  monogram: provider.monogram,
@@ -285,6 +285,12 @@ export function createManagerServer({
285
285
  registry.emit('updated');
286
286
  return sendJson(res, 200, { provider: registry.describe(provider) });
287
287
  }
288
+ const multiplexerMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/multiplexers\/([a-z0-9_-]+)\/(install|update|uninstall)$/);
289
+ if (multiplexerMatch && method === 'POST') {
290
+ const body = await readJsonBody(req);
291
+ const session = await manager.manageMultiplexer(multiplexerMatch[1], multiplexerMatch[2], multiplexerMatch[3], { path: body.path, force: body.force === true });
292
+ return sendJson(res, 201, { session: session.toJSON() });
293
+ }
288
294
  const installMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/install$/);
289
295
  if (installMatch && method === 'POST') {
290
296
  const body = await readJsonBody(req);
@@ -408,6 +414,7 @@ export function createManagerServer({
408
414
  if (!res.headersSent) {
409
415
  const error = { code: err.code || 'error', message: err.message };
410
416
  if (err.running !== undefined) error.running = err.running;
417
+ if (err.pending !== undefined) error.pending = err.pending;
411
418
  if (err.target !== undefined) error.target = err.target;
412
419
  sendJson(res, status, { error });
413
420
  }
@@ -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
@@ -759,6 +759,8 @@ let dealt = false;
759
759
 
760
760
  function renderProviders() {
761
761
  const list = $('providers');
762
+ const focused = document.activeElement;
763
+ const focusKey = focused?.dataset?.muxFocus;
762
764
  const tpl = $('provider-template');
763
765
  list.replaceChildren(...state.providers.map((provider) => {
764
766
  const node = tpl.content.firstElementChild.cloneNode(true);
@@ -798,11 +800,20 @@ function renderProviders() {
798
800
  renderVendorLinks(node, provider);
799
801
  renderAccounts(node, provider);
800
802
  renderShells(node, provider);
803
+ renderMultiplexers(node, provider);
801
804
  renderUsage(node, provider);
802
805
  renderReportingSetup(node, provider);
803
806
  renderModelStats(node, provider);
804
807
  return node;
805
808
  }));
809
+ if (focusKey) {
810
+ const controls = [...list.querySelectorAll('[data-mux-focus]')];
811
+ const fallback = focusKey.split(':').slice(0, 2).join(':') + ':copies';
812
+ const replacement = controls.find((node) => node.dataset.muxFocus === focusKey && !node.disabled)
813
+ || controls.find((node) => node.dataset.muxFocus === fallback && !node.closest('[hidden]'))
814
+ || controls.find((node) => node.dataset.muxFocus === focusKey.split(':')[0] + ':multiplexers');
815
+ replacement?.focus({ preventScroll: true });
816
+ }
806
817
  if (!dealt && state.providers.length) {
807
818
  dealt = true;
808
819
  if (!reducedMotion.matches) list.classList.add('deal');
@@ -811,19 +822,21 @@ function renderProviders() {
811
822
 
812
823
  const openCopies = new Set();
813
824
 
814
- function renderCopies(box, provider, card) {
825
+ function renderCopies(box, provider, card, actions = null) {
815
826
  const installs = provider.installs || [];
816
827
  const warnings = provider.warnings || [];
817
- box.hidden = warnings.length === 0 && !installs.some((i) => i.uninstall);
828
+ box.hidden = actions ? installs.length === 0 : warnings.length === 0 && !installs.some((i) => i.uninstall);
818
829
  if (box.hidden) return;
819
830
  box.classList.toggle('warned', warnings.length > 0);
820
831
  const inUse = installs.some((i) => i.active);
821
832
  const older = installs.some((i) => i.newer);
822
- box.querySelector('summary').textContent = !inUse
833
+ box.querySelector('summary').textContent = installs.every((i) => i.partial) ? 'Incomplete installation' : !inUse
823
834
  ? 'A copy exists off PATH'
824
835
  : 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)));
836
+ const key = actions?.key || provider.id;
837
+ box.open = openCopies.has(key);
838
+ box.addEventListener('toggle', () => (box.open ? openCopies.add(key) : openCopies.delete(key)));
839
+ if (actions) box.querySelector('summary').dataset.muxFocus = key + ':copies';
827
840
  const line = (className, ...content) => {
828
841
  const span = document.createElement('span');
829
842
  span.className = className;
@@ -842,6 +855,17 @@ function renderCopies(box, provider, card) {
842
855
  const where = line('copy-path', install.displayPath);
843
856
  where.title = install.path;
844
857
  item.append(head, where);
858
+ if (actions && install.updateCommand && (install.updateAvailable || install.versionStatus === 'failed')) {
859
+ const update = document.createElement('button');
860
+ update.type = 'button';
861
+ update.className = 'btn copy-update';
862
+ update.textContent = 'Update';
863
+ update.title = `Run ${install.updateCommand}`;
864
+ update.disabled = actions.busy;
865
+ update.dataset.muxFocus = key + ':update:' + install.path;
866
+ update.addEventListener('click', () => actions.update(install));
867
+ head.append(update);
868
+ } else if (actions && install.updateGuidance) item.append(line('copy-remove', install.updateGuidance));
845
869
  if (install.uninstall) {
846
870
  const button = document.createElement('button');
847
871
  button.type = 'button';
@@ -851,7 +875,11 @@ function renderCopies(box, provider, card) {
851
875
  button.title = install.uninstall.command
852
876
  ? [`Runs ${install.uninstall.command}`, ...install.uninstall.remove.map((p) => `then deletes ${p}`)].join('\n')
853
877
  : ['Deletes', ...install.uninstall.remove].join('\n');
854
- button.addEventListener('click', () => uninstallCopy(provider, card, install));
878
+ if (actions) {
879
+ button.disabled = actions.busy;
880
+ button.dataset.muxFocus = key + ':uninstall:' + install.path;
881
+ }
882
+ button.addEventListener('click', () => actions ? actions.uninstall(install) : uninstallCopy(provider, card, install));
855
883
  head.append(button);
856
884
  } else {
857
885
  item.append(line('copy-remove', install.uninstallGuidance));
@@ -860,6 +888,102 @@ function renderCopies(box, provider, card) {
860
888
  }));
861
889
  }
862
890
 
891
+ const openMultiplexers = new Set();
892
+
893
+ function renderMultiplexers(card, provider) {
894
+ const host = card.querySelector('.multiplexers');
895
+ const tools = provider.multiplexers || [];
896
+ host.hidden = tools.length === 0;
897
+ if (host.hidden) return;
898
+ host.open = openMultiplexers.has(provider.id);
899
+ host.querySelector('summary').dataset.muxFocus = provider.id + ':multiplexers';
900
+ host.addEventListener('toggle', () => host.open ? openMultiplexers.add(provider.id) : openMultiplexers.delete(provider.id));
901
+ const refresh = host.querySelector('.multiplexer-refresh');
902
+ refresh.dataset.muxFocus = provider.id + ':refresh';
903
+ refresh.addEventListener('click', async () => {
904
+ refresh.disabled = true;
905
+ try {
906
+ const result = await api('POST', '/providers/reload', {});
907
+ state.providers = result.providers;
908
+ renderProviders();
909
+ } catch (err) {
910
+ if (err instanceof AuthError) showAuth(err.message);
911
+ else toast(err.message, 10000);
912
+ } finally { refresh.disabled = false; }
913
+ });
914
+ host.querySelector('.multiplexer-list').replaceChildren(...tools.map((tool) => {
915
+ const row = document.createElement('section');
916
+ row.className = 'multiplexer-row';
917
+ const title = document.createElement('strong');
918
+ title.textContent = tool.tool;
919
+ row.append(title);
920
+ const key = provider.id + ':' + tool.id;
921
+ if (tool.installable) {
922
+ const button = document.createElement('button');
923
+ button.type = 'button';
924
+ button.className = 'btn primary';
925
+ button.textContent = 'Install';
926
+ button.disabled = tool.busy;
927
+ button.title = tool.installCommand || '';
928
+ button.dataset.muxFocus = key + ':install';
929
+ button.addEventListener('click', () => manageMultiplexer(provider, tool, 'install'));
930
+ row.append(button);
931
+ }
932
+ const note = document.createElement('p');
933
+ note.className = 'multiplexer-note';
934
+ note.textContent = [
935
+ tool.busy ? 'Installation operation in progress…' : tool.guidance,
936
+ tool.pendingCards ? `${tool.pendingCards} detached ${tool.tool} card(s) are waiting to be restored. Check the installation or remove finished cards, then Refresh.` : '',
937
+ installNote(tool),
938
+ ].filter(Boolean).join(' ');
939
+ if (note.textContent) row.append(note);
940
+ const copies = document.createElement('details');
941
+ copies.className = 'copies';
942
+ copies.append(document.createElement('summary'), document.createElement('ul'));
943
+ row.append(copies);
944
+ renderCopies(copies, { ...tool, id: key, warnings: [] }, card, {
945
+ key, busy: tool.busy,
946
+ update: (copy) => manageMultiplexer(provider, tool, 'update', copy),
947
+ uninstall: (copy) => {
948
+ const plan = copy.uninstall;
949
+ const lines = [
950
+ `Uninstall ${tool.tool} from ${copy.displayPath}?`,
951
+ plan.command && `Runs: ${plan.command}`,
952
+ ...plan.remove.map((p) => `Removes: ${p}`),
953
+ plan.pathEntries && 'Removes only this installation’s entries from your user PATH.',
954
+ 'Your settings and session data are kept.',
955
+ ].filter(Boolean);
956
+ if (confirm(lines.join('\n'))) return manageMultiplexer(provider, tool, 'uninstall', copy);
957
+ },
958
+ });
959
+ return row;
960
+ }));
961
+ }
962
+
963
+ async function manageMultiplexer(provider, tool, kind, copy = null, force = false) {
964
+ // Busy state also comes from the manager, covering other tabs and refreshes.
965
+ tool.busy = true;
966
+ renderProviders();
967
+ try {
968
+ const { session } = await api('POST', `/providers/${provider.id}/multiplexers/${tool.id}/${kind}`, { path: copy?.path, force });
969
+ upsertSession(session);
970
+ openPanel(session.id);
971
+ } catch (err) {
972
+ if (err instanceof AuthError) return showAuth(err.message);
973
+ if (err.code === 'multiplexer_in_use' && !force) {
974
+ const waiting = err.pending ? ` ${err.pending} detached cards are also waiting for the tool.` : '';
975
+ if (confirm(`${err.message}${waiting} Continue anyway?`)) return manageMultiplexer(provider, tool, kind, copy, true);
976
+ } else toast(err.message, 10000);
977
+ } finally {
978
+ // A successful operation stays busy until the manager sends fresh state.
979
+ try {
980
+ const result = await api('GET', '/providers');
981
+ state.providers = result.providers;
982
+ } catch { tool.busy = false; }
983
+ renderProviders();
984
+ }
985
+ }
986
+
863
987
  function renderHint(hint, provider) {
864
988
  let text = '';
865
989
  if (!provider.available) text = provider.installable ? '' : provider.install || `${provider.command} was not found on PATH.`;
@@ -1887,7 +2011,7 @@ function renderChangelog() {
1887
2011
  }
1888
2012
 
1889
2013
  const CHANNEL_LABELS = {
1890
- npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', legacy: 'legacy install', unknown: 'unknown install',
2014
+ npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', system: 'system package', legacy: 'legacy install', unknown: 'unknown install',
1891
2015
  };
1892
2016
 
1893
2017
  function installNote(provider) {
package/web/index.html CHANGED
@@ -304,6 +304,11 @@
304
304
  <summary></summary>
305
305
  <ul></ul>
306
306
  </details>
307
+ <details class="multiplexers" hidden>
308
+ <summary>Terminal multiplexers</summary>
309
+ <button class="btn multiplexer-refresh" type="button">Refresh</button>
310
+ <div class="multiplexer-list"></div>
311
+ </details>
307
312
  <div class="provider-actions">
308
313
  <button class="btn primary new" type="button">New</button>
309
314
  <button class="btn existing" type="button">Existing…</button>
package/web/styles.css CHANGED
@@ -736,3 +736,13 @@ a.news-link:visited { color: var(--muted); }
736
736
  .history-row .session-id { grid-row: 2; justify-self: start; }
737
737
  .history-form { flex-wrap: wrap; }
738
738
  }
739
+ /* Installation controls share each skin's card colors and button styles. */
740
+ .multiplexers { margin: .6rem 0; }
741
+ .multiplexers > summary { cursor: pointer; font-size: .85rem; }
742
+ .multiplexer-refresh { margin: .5rem 0; }
743
+ .multiplexer-row { padding: .65rem 0; border-top: 1px solid var(--border); }
744
+ .multiplexer-row > strong { margin-right: .6rem; }
745
+ .multiplexer-note { font-size: .8rem; line-height: 1.5; overflow-wrap: anywhere; margin: .4rem 0; }
746
+ .multiplexer-row .copies { margin-top: .4rem; }
747
+ .multiplexer-row .copy-head { flex-wrap: wrap; }
748
+ .multiplexer-row .copy-name { flex: 1 0 100%; }