@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 CHANGED
@@ -60,6 +60,11 @@ and come back to the same screens. Drag a session card by its grip, with a
60
60
  mouse or a finger, to put the cards in the order you want; this browser
61
61
  remembers it, and new sessions join at the end.
62
62
 
63
+ On a touchscreen, **Copy…** opens a still copy of the terminal text. Touch and
64
+ hold to select, then use **Copy selection** or the browser's Copy menu. **Done**
65
+ returns to the live terminal; the session keeps running while you copy. The
66
+ view includes retained scrollback, or the current screen of a full-screen tool.
67
+
63
68
  **Pick your shell.** The Shell card offers the shells installed on your
64
69
  computer and remembers your choice in this browser. On Windows it defaults
65
70
  to PowerShell 7 when installed, then Windows PowerShell; Git Bash and Command
@@ -78,6 +83,21 @@ card names the command that reattaches it from a terminal. Stopping or
78
83
  restarting the manager detaches them the same way, and the cards come back,
79
84
  closed and ready to reattach, when it starts again.
80
85
 
86
+ Open **Terminal multiplexers** on the Shell card to install, update or
87
+ uninstall tmux and herdr. Operations run in a terminal so you can read their
88
+ output and answer package-manager prompts. tmux uses Homebrew on macOS and
89
+ Homebrew or apt, dnf, or pacman on Linux; a distribution must offer 3.2 or
90
+ later. Update distro packages with their package manager. Native Windows
91
+ offers herdr instead of tmux.
92
+
93
+ Herdr updates leave its existing servers and panes running, including
94
+ Homebrew updates. New starts and reattachments wait until the operation
95
+ finishes. Uninstall requires herdr's servers to be stopped and their status
96
+ to be verifiable. tmux asks before changing a copy that attached or detached
97
+ cards depend on. Settings and session data are kept. If a multiplexer is
98
+ missing when the manager starts, its saved cards wait for it and return
99
+ when it is detected again, provided their sessions still exist.
100
+
81
101
  <img src="docs/images/terminal.webp" alt="An open Claude Code session with its helper agents shown in the header">
82
102
 
83
103
  **Agents and models at work.** Helper agents that a tool starts appear on its
@@ -232,6 +252,8 @@ or prompts:
232
252
  | OpenRouter's public model list | Benchmarks | Every 6 hours |
233
253
  | Public news feeds, Hacker News, arXiv and GitHub | The newsfeed | Every 30 minutes while the page is open |
234
254
  | GitHub's releases API | What's new | Hourly |
255
+ | 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` |
256
+ | herdr.dev, GitHub release downloads, and the selected package manager's repositories | Multiplexer installation and updates | When you request the operation |
235
257
  | 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
258
 
237
259
  ## 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.1",
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
  }