@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 +17 -0
- package/config/providers.default.json +4 -0
- package/package.json +1 -1
- package/src/manager/install-channels.mjs +1 -0
- package/src/manager/multiplexer-paths.mjs +81 -0
- package/src/manager/multiplexers.mjs +364 -0
- package/src/manager/providers.mjs +31 -4
- package/src/manager/server.mjs +7 -0
- package/src/manager/session-manager.mjs +207 -16
- package/src/manager/uninstall.mjs +29 -2
- package/src/manager/versions.mjs +12 -0
- package/web/app.js +131 -7
- package/web/index.html +5 -0
- package/web/styles.css +10 -0
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.
|
|
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": {
|
|
@@ -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 (
|
|
343
|
+
if (discovered === null || discovered === undefined) return false;
|
|
328
344
|
const key = pathKey(this.env, this.platform);
|
|
329
|
-
const
|
|
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
|
-
|
|
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,
|
package/src/manager/server.mjs
CHANGED
|
@@ -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: {
|
|
192
|
+
this._track(session, { spawnSpec, mux, shell, card: {
|
|
193
|
+
id, reportToken, provider: provider.id, account: account.id, shell: shell.id,
|
|
194
|
+
muxName, name: session.name, cwd: session.cwd, createdAt: session.createdAt,
|
|
195
|
+
// Herdr's request arguments belong to its client and must survive rebinding.
|
|
196
|
+
// Tmux's request arguments belong to the already-running inner session.
|
|
197
|
+
...(shell.id === 'herdr' ? { args: [...args] } : {}),
|
|
198
|
+
} });
|
|
181
199
|
this._watchHerdr(session);
|
|
182
200
|
return session;
|
|
183
201
|
}
|
|
@@ -197,12 +215,17 @@ export class SessionManager extends EventEmitter {
|
|
|
197
215
|
|
|
198
216
|
/** Keep a tmux or herdr card for Reattach, here and, through the store, across restarts. */
|
|
199
217
|
_track(session, { spawnSpec, mux, shell, card }) {
|
|
200
|
-
this.multiplexers.
|
|
218
|
+
const installationKey = this.registry.multiplexers?.identityFor(this.registry.get(card.provider), shell.id, shell.path);
|
|
219
|
+
const shellPath = shell.path;
|
|
220
|
+
// Keep old stores readable. Newly known installations carry a stable key,
|
|
221
|
+
// since an update can change a Cellar or Windows release realpath.
|
|
222
|
+
card.shellPath = shellPath;
|
|
223
|
+
if (installationKey) card.installationKey = installationKey;
|
|
224
|
+
const tracked = { spawnSpec, alive: mux.alive, shellPath, checking: false, herdr: shell.id === 'herdr' ? { path: shell.path, env: mux.clientEnv } : null, card };
|
|
225
|
+
this.multiplexers.set(session.id, tracked);
|
|
226
|
+
this.pendingCards.delete(session.id);
|
|
201
227
|
this._saveCards();
|
|
202
|
-
session.on('exit',
|
|
203
|
-
session.multiplexer.reattachable = await mux.alive();
|
|
204
|
-
session._changed();
|
|
205
|
-
});
|
|
228
|
+
session.on('exit', () => this._checkMultiplexer(session, tracked));
|
|
206
229
|
session.on('changed', () => {
|
|
207
230
|
if (card.name === session.name) return;
|
|
208
231
|
card.name = session.name;
|
|
@@ -214,7 +237,7 @@ export class SessionManager extends EventEmitter {
|
|
|
214
237
|
// A stopping manager leaves the file as it is, for the next one to bring the cards back.
|
|
215
238
|
if (!this.store || this.restoring || this.closing) return;
|
|
216
239
|
try {
|
|
217
|
-
this.store.save([...this.multiplexers.values()].map(({ card }) => card));
|
|
240
|
+
this.store.save([...this.multiplexers.values()].map(({ card }) => card).concat([...this.pendingCards.values()]));
|
|
218
241
|
} catch (err) {
|
|
219
242
|
console.warn(`[sessions] could not save the tmux and herdr cards: ${err.message}`);
|
|
220
243
|
}
|
|
@@ -229,7 +252,8 @@ export class SessionManager extends EventEmitter {
|
|
|
229
252
|
this.restoring = true;
|
|
230
253
|
try {
|
|
231
254
|
// All at once, so a multiplexer slow to answer holds up the start once, not once a card.
|
|
232
|
-
await Promise.all((this.store?.load() ?? []).map((card) => this._restoreCard(card).catch((err) => {
|
|
255
|
+
await Promise.all((this.store?.load() ?? []).map((card) => this._withMultiplexerStart(card?.provider, card?.shell, () => this._restoreCard(card)).catch((err) => {
|
|
256
|
+
if (['too_many_sessions', 'install_in_progress'].includes(err.code)) this.pendingCards.set(card.id, card);
|
|
233
257
|
console.warn(`[sessions] did not bring back the card ${card?.name ?? card?.id}: ${err.message}`);
|
|
234
258
|
})));
|
|
235
259
|
} finally {
|
|
@@ -243,12 +267,25 @@ export class SessionManager extends EventEmitter {
|
|
|
243
267
|
if (typeof card.reportToken !== 'string' || !/^[a-f0-9]{32}$/.test(card.reportToken)) return;
|
|
244
268
|
if (typeof card.muxName !== 'string' || !/^guild-[0-9a-f]{6}$/.test(card.muxName)) return;
|
|
245
269
|
const provider = this.registry.get(String(card.provider));
|
|
246
|
-
|
|
247
|
-
if (!
|
|
270
|
+
if (!provider || !['tmux', 'herdr'].includes(card.shell)) return;
|
|
271
|
+
if (card.shell === 'herdr' && card.args !== undefined && (!Array.isArray(card.args) || card.args.some((arg) => typeof arg !== 'string'))) return;
|
|
272
|
+
const shells = this.registry.shellsFor(provider)?.shells || [];
|
|
273
|
+
const shell = shells.find((s) => s.id === card.shell && s.multiplexer);
|
|
274
|
+
if (!shell) {
|
|
275
|
+
this.pendingCards.set(card.id, card);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (this.multiplexerOperations.has(card.shell)) {
|
|
279
|
+
this.pendingCards.set(card.id, card);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
248
282
|
const account = this.registry.account(provider, card.account);
|
|
249
283
|
const mux = this._multiplexer({ provider, account, shell, id: card.id, reportToken: card.reportToken, muxName: card.muxName });
|
|
250
|
-
if (!(await mux.alive()) || this.closing)
|
|
251
|
-
|
|
284
|
+
if (!(await mux.alive()) || this.closing) {
|
|
285
|
+
if (!this.closing) this.pendingCards.delete(card.id);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const spawnSpec = this.registry.spawnSpec(provider, shell.id === 'herdr' ? card.args ?? [] : [], null, [], mux.named);
|
|
252
289
|
const session = this._spawn({
|
|
253
290
|
provider, spawnSpec: null, cwd: typeof card.cwd === 'string' ? card.cwd : os.homedir(), name: card.name, account,
|
|
254
291
|
id: card.id, reportToken: card.reportToken, dropEnv: mux.dropEnv, extraEnv: shell.env,
|
|
@@ -260,6 +297,43 @@ export class SessionManager extends EventEmitter {
|
|
|
260
297
|
|
|
261
298
|
/** Attach a stopped tmux or herdr session's card to its multiplexer session again, keeping its id and report token. */
|
|
262
299
|
async reattach(id) {
|
|
300
|
+
const tracked = this.multiplexers.get(id);
|
|
301
|
+
if (!tracked) return this._reattach(id);
|
|
302
|
+
return this._withMultiplexerStart(tracked.card.provider, tracked.card.shell, () => this._reattach(id));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
_rebindMultiplexer(mux) {
|
|
306
|
+
const provider = this.registry.get(mux.card.provider);
|
|
307
|
+
const shell = provider && this.registry.shellsFor(provider)?.shells.find((s) => s.id === mux.card.shell && s.multiplexer);
|
|
308
|
+
if (!shell) return false;
|
|
309
|
+
const fresh = this._multiplexer({ provider, account: this.registry.account(provider, mux.card.account), shell, id: mux.card.id, reportToken: mux.card.reportToken, muxName: mux.card.muxName });
|
|
310
|
+
mux.spawnSpec = this.registry.spawnSpec(provider, shell.id === 'herdr' ? mux.card.args ?? [] : [], null, [], fresh.named);
|
|
311
|
+
mux.alive = fresh.alive;
|
|
312
|
+
mux.shellPath = shell.path;
|
|
313
|
+
mux.card.shellPath = shell.path;
|
|
314
|
+
const key = this.registry.multiplexers?.identityFor(provider, shell.id, shell.path);
|
|
315
|
+
if (key) mux.card.installationKey = key;
|
|
316
|
+
else delete mux.card.installationKey;
|
|
317
|
+
if (mux.herdr) mux.herdr = { path: shell.path, env: fresh.clientEnv };
|
|
318
|
+
this._saveCards();
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async _checkMultiplexer(session, tracked) {
|
|
323
|
+
const sequence = tracked.checkSequence = (tracked.checkSequence || 0) + 1;
|
|
324
|
+
tracked.checking = true;
|
|
325
|
+
let alive = false;
|
|
326
|
+
try {
|
|
327
|
+
this._rebindMultiplexer(tracked);
|
|
328
|
+
alive = await tracked.alive();
|
|
329
|
+
} catch {}
|
|
330
|
+
if (tracked.checkSequence !== sequence) return;
|
|
331
|
+
session.multiplexer.reattachable = alive;
|
|
332
|
+
tracked.checking = false;
|
|
333
|
+
session._changed();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async _reattach(id) {
|
|
263
337
|
const session = this.get(id);
|
|
264
338
|
const mux = this.multiplexers.get(id);
|
|
265
339
|
if (!mux) throw httpError(400, `${session.name} is not in tmux or herdr`, 'not_reattachable');
|
|
@@ -267,6 +341,9 @@ export class SessionManager extends EventEmitter {
|
|
|
267
341
|
if (session.status === 'running') throw httpError(409, `${session.name} is still attached`, 'session_running');
|
|
268
342
|
// As the page offers it: only once the manager has found, after the client closed or at a restart, that the multiplexer still has the session.
|
|
269
343
|
if (!session.multiplexer.reattachable) throw httpError(409, `${session.multiplexer.label} no longer has the session ${session.name} ran in`, 'multiplexer_session_gone');
|
|
344
|
+
// Refresh closures as well as the spawn spec: the previous executable
|
|
345
|
+
// may have disappeared in a native or Homebrew update.
|
|
346
|
+
if (!this._rebindMultiplexer(mux)) throw httpError(409, 'Install the multiplexer and refresh this page before reattaching.', 'shell_unavailable');
|
|
270
347
|
const alive = await mux.alive();
|
|
271
348
|
if (this.sessions.get(id) !== session || session.status === 'running') throw httpError(409, `${session.name} changed meanwhile`, 'session_running');
|
|
272
349
|
if (!alive) {
|
|
@@ -368,10 +445,123 @@ export class SessionManager extends EventEmitter {
|
|
|
368
445
|
|
|
369
446
|
installsRunningFor(providerId) {
|
|
370
447
|
let n = 0;
|
|
371
|
-
for (const s of [...this.sessions.values(), ...this.exiting]) if (s.status === 'running' && s.task === 'install' && s.provider.id === providerId) n++;
|
|
448
|
+
for (const s of [...this.sessions.values(), ...this.exiting]) if (s.status === 'running' && s.task === 'install' && !s.multiplexerInstall && s.provider.id === providerId) n++;
|
|
372
449
|
return n;
|
|
373
450
|
}
|
|
374
451
|
|
|
452
|
+
async _withMultiplexerStart(providerId, id, start) {
|
|
453
|
+
// Multiple @shell providers can share the same executable.
|
|
454
|
+
const key = id;
|
|
455
|
+
if (this.multiplexerOperations.has(key)) throw httpError(409, `${id} is being installed, updated or removed; try again when it finishes.`, 'install_in_progress');
|
|
456
|
+
this.multiplexerStarts.set(key, (this.multiplexerStarts.get(key) || 0) + 1);
|
|
457
|
+
try { return await start(); }
|
|
458
|
+
finally {
|
|
459
|
+
const count = this.multiplexerStarts.get(key) - 1;
|
|
460
|
+
if (count) this.multiplexerStarts.set(key, count);
|
|
461
|
+
else this.multiplexerStarts.delete(key);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
dependentsOf(providerId, id, copy = null) {
|
|
466
|
+
const platform = this.registry.platform;
|
|
467
|
+
const same = (a, b) => pathIdentity(a, platform) === pathIdentity(b, platform);
|
|
468
|
+
let running = 0;
|
|
469
|
+
for (const [sessionId, tracked] of this.multiplexers) {
|
|
470
|
+
if (tracked.card.shell !== id) continue;
|
|
471
|
+
if (copy && tracked.card.installationKey && tracked.card.installationKey !== copy.key) continue;
|
|
472
|
+
if (copy && !tracked.card.installationKey && tracked.shellPath
|
|
473
|
+
&& !same(tracked.shellPath, copy.resolvedPath)
|
|
474
|
+
&& !same(this.registry.multiplexers.fsx.realpath(tracked.shellPath), copy.realPath)) continue;
|
|
475
|
+
const session = this.sessions.get(sessionId);
|
|
476
|
+
if (session?.status === 'running' || session?.multiplexer?.reattachable || tracked.checking) running++;
|
|
477
|
+
}
|
|
478
|
+
const pending = [...this.pendingCards.values()].filter((c) => c.provider === providerId && c.shell === id).length;
|
|
479
|
+
return { running, pending };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async manageMultiplexer(providerId, id, kind, { path: copyPath = null, force = false } = {}) {
|
|
483
|
+
const provider = this.registry.get(providerId);
|
|
484
|
+
if (!provider) throw httpError(404, 'Unknown provider', 'unknown_provider');
|
|
485
|
+
this.registry.multiplexers.get(provider, id);
|
|
486
|
+
if (!['install', 'update', 'uninstall'].includes(kind)) throw httpError(400, 'Unknown operation', 'bad_request');
|
|
487
|
+
this._assertCanSpawn();
|
|
488
|
+
const key = id;
|
|
489
|
+
if (this.multiplexerOperations.has(key) || this.multiplexerStarts.has(key)) throw httpError(409, `${id} has another operation in progress. Try again shortly.`, 'install_in_progress');
|
|
490
|
+
this.multiplexerOperations.add(key);
|
|
491
|
+
this.registry.emit('updated');
|
|
492
|
+
let started = false;
|
|
493
|
+
try {
|
|
494
|
+
const prepared = await this.registry.multiplexers.prepare(provider, id, kind, copyPath);
|
|
495
|
+
if (id === 'herdr' && kind === 'uninstall') await this.registry.multiplexers.assertHerdrStopped(provider, prepared.copy, prepared.extraEnv);
|
|
496
|
+
// Herdr supports updates with servers and panes running, including brew.
|
|
497
|
+
// Uninstall's server check is never bypassed by force.
|
|
498
|
+
if (id === 'tmux') {
|
|
499
|
+
const { running, pending } = this.dependentsOf(providerId, id, prepared.copy);
|
|
500
|
+
if (running && !force) throw Object.assign(httpError(409, `${running} tmux card(s) depend on this installation. Changing it may prevent reattachment.`, 'multiplexer_in_use'), { running, pending });
|
|
501
|
+
}
|
|
502
|
+
this._assertCanSpawn();
|
|
503
|
+
const session = this._spawn({
|
|
504
|
+
provider, spawnSpec: prepared.spec, extraEnv: prepared.extraEnv, cwd: os.homedir(),
|
|
505
|
+
name: `${{ install: 'Install', update: 'Update', uninstall: 'Uninstall' }[kind]} ${id}`,
|
|
506
|
+
task: 'install', multiplexerInstall: { id, kind },
|
|
507
|
+
});
|
|
508
|
+
started = true;
|
|
509
|
+
// Keep the lock through refresh, and even if the installer card is removed.
|
|
510
|
+
session.exited.then(async () => {
|
|
511
|
+
try {
|
|
512
|
+
await this.registry.multiplexers.finish(provider, id, kind, prepared.copy, session.exitCode);
|
|
513
|
+
if (kind === 'update') {
|
|
514
|
+
// A detach during replacement may have probed a vanished release.
|
|
515
|
+
// Recheck closed cards once discovery has the new executable.
|
|
516
|
+
await Promise.all([...this.multiplexers].map(([cardId, tracked]) => {
|
|
517
|
+
const card = this.sessions.get(cardId);
|
|
518
|
+
return tracked.card.shell === id && card?.status === 'exited'
|
|
519
|
+
? this._checkMultiplexer(card, tracked) : null;
|
|
520
|
+
}));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
catch (err) { console.warn('[multiplexers] could not refresh:', err.message); }
|
|
524
|
+
finally {
|
|
525
|
+
this.multiplexerOperations.delete(key);
|
|
526
|
+
this.registry.emit('updated');
|
|
527
|
+
await this._restorePending();
|
|
528
|
+
}
|
|
529
|
+
}).catch((err) => console.warn('[multiplexers]', err.message));
|
|
530
|
+
return session;
|
|
531
|
+
} finally {
|
|
532
|
+
if (!started) {
|
|
533
|
+
this.multiplexerOperations.delete(key);
|
|
534
|
+
this.registry.emit('updated');
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
_restorePending() {
|
|
540
|
+
if (this.pendingRestore) {
|
|
541
|
+
this.pendingRestoreAgain = true;
|
|
542
|
+
return this.pendingRestore;
|
|
543
|
+
}
|
|
544
|
+
if (this.restoring || this.closing || !this.pendingCards.size) return Promise.resolve();
|
|
545
|
+
this.pendingRestore = (async () => {
|
|
546
|
+
const before = this.pendingCards.size;
|
|
547
|
+
do {
|
|
548
|
+
this.pendingRestoreAgain = false;
|
|
549
|
+
for (const card of [...this.pendingCards.values()]) {
|
|
550
|
+
if (this.closing) break;
|
|
551
|
+
if (this.multiplexerOperations.has(card.shell)) continue;
|
|
552
|
+
try { await this._withMultiplexerStart(card.provider, card.shell, () => this._restoreCard(card)); }
|
|
553
|
+
catch (err) {
|
|
554
|
+
// A capacity limit is temporary; never discard a recoverable card.
|
|
555
|
+
if (!['too_many_sessions', 'install_in_progress'].includes(err.code)) this.pendingCards.delete(card.id);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
} while (this.pendingRestoreAgain && !this.closing);
|
|
559
|
+
this._saveCards();
|
|
560
|
+
if (before !== this.pendingCards.size) this.registry.emit?.('updated');
|
|
561
|
+
})().finally(() => { this.pendingRestore = null; });
|
|
562
|
+
return this.pendingRestore;
|
|
563
|
+
}
|
|
564
|
+
|
|
375
565
|
/**
|
|
376
566
|
* Upgrade the manager itself: a visible session running npm. Sessions
|
|
377
567
|
* keep running; the new version is used once the manager is restarted.
|
|
@@ -470,7 +660,7 @@ export class SessionManager extends EventEmitter {
|
|
|
470
660
|
_spawn({
|
|
471
661
|
provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, resume = null, task = null, installKind = null, installPath = null, account = null,
|
|
472
662
|
extraEnv = null, dropEnv = null, clone = null, reporting = null, multiplexer = null,
|
|
473
|
-
id = newId(), reportToken = crypto.randomBytes(16).toString('hex'), createdAt,
|
|
663
|
+
id = newId(), reportToken = crypto.randomBytes(16).toString('hex'), createdAt, multiplexerInstall = null,
|
|
474
664
|
}) {
|
|
475
665
|
this._assertCanSpawn();
|
|
476
666
|
const env = this._sessionEnv({ id, reportToken, provider, account, extraEnv, dropEnv });
|
|
@@ -504,7 +694,8 @@ export class SessionManager extends EventEmitter {
|
|
|
504
694
|
if (this.sessions.has(id)) this.emit('event', { type: 'session.updated', session: session.toJSON() });
|
|
505
695
|
});
|
|
506
696
|
session.on('warning', (msg) => console.warn(`[session ${id}] ${msg}`));
|
|
507
|
-
|
|
697
|
+
session.multiplexerInstall = multiplexerInstall;
|
|
698
|
+
if (task === 'install' && !multiplexerInstall) {
|
|
508
699
|
session.on('exit', () => {
|
|
509
700
|
this.registry.finishInstall(provider.id, { exitCode: session.exitCode, kind: installKind, path: installPath }).catch(() => {});
|
|
510
701
|
});
|
|
@@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { buildSpawnSpec } from './command-resolver.mjs';
|
|
6
6
|
import { formatCommand } from './install-channels.mjs';
|
|
7
|
+
import { windowsPathCleanupScript } from './multiplexer-paths.mjs';
|
|
7
8
|
|
|
8
9
|
export const RUNNER = fileURLToPath(import.meta.url);
|
|
9
10
|
|
|
@@ -72,9 +73,24 @@ function removeFile(file) {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
export function runPlan(
|
|
75
|
-
{ run = null, remove = [], links = [], launcher = null },
|
|
76
|
-
{ env = process.env, platform = process.platform, log = console.log, rm = removeFile } = {},
|
|
76
|
+
{ run = null, remove = [], links = [], launcher = null, pathEntries = null, strict = false },
|
|
77
|
+
{ env = process.env, platform = process.platform, log = console.log, rm = removeFile, cleanPath = cleanWindowsPath } = {},
|
|
77
78
|
) {
|
|
79
|
+
if (strict) {
|
|
80
|
+
// An ancestor junction/symlink must not redirect deletion outside the
|
|
81
|
+
// server's allowlisted locations. The final launcher alias is handled below.
|
|
82
|
+
for (const file of [...remove, ...links]) {
|
|
83
|
+
if (!path.isAbsolute(file) || firstLink(path.dirname(file))) {
|
|
84
|
+
log(`Refused to remove ${file}: its parent is a link or the path is not absolute.`);
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
const target = linkTarget(file);
|
|
88
|
+
if ((target && !within(target, remove, platform)) || (links.includes(file) && exists(file) && !target)) {
|
|
89
|
+
log(`Refused to remove ${file}: it is not an owned installation link.`);
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
78
94
|
if (run) {
|
|
79
95
|
log(`> ${formatCommand(run.file, run.args)}`);
|
|
80
96
|
const spec = buildSpawnSpec(run.file, run.args, env, platform);
|
|
@@ -133,6 +149,10 @@ export function runPlan(
|
|
|
133
149
|
if (exists(file)) del(file);
|
|
134
150
|
log(`Removed ${file}`);
|
|
135
151
|
}
|
|
152
|
+
if (pathEntries && platform === 'win32') {
|
|
153
|
+
cleanPath(pathEntries, env);
|
|
154
|
+
log('Removed the installation’s entries from the user PATH.');
|
|
155
|
+
}
|
|
136
156
|
} catch (err) {
|
|
137
157
|
const code = err.code === 'ENOTDIR' && err.syscall === 'scandir' ? 'EPERM' : err.code;
|
|
138
158
|
const reason = { EBUSY: 'it is in use', EPERM: 'it is in use or protected', EACCES: 'permission was denied' }[code] || err.message;
|
|
@@ -142,6 +162,13 @@ export function runPlan(
|
|
|
142
162
|
return 0;
|
|
143
163
|
}
|
|
144
164
|
|
|
165
|
+
function cleanWindowsPath(rules, env) {
|
|
166
|
+
const powershell = path.win32.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
|
|
167
|
+
const encoded = Buffer.from(windowsPathCleanupScript(rules), 'utf16le').toString('base64');
|
|
168
|
+
const result = spawnSync(powershell, ['-NoLogo', '-NoProfile', '-EncodedCommand', encoded], { env, encoding: 'utf8', windowsHide: true, timeout: 10000 });
|
|
169
|
+
if (result.error || result.status !== 0) throw new Error(result.error?.message || result.stderr || 'Could not update user PATH. Retry Uninstall.');
|
|
170
|
+
}
|
|
171
|
+
|
|
145
172
|
if (process.argv[1] && path.resolve(process.argv[1]) === RUNNER) {
|
|
146
173
|
const plan = JSON.parse(Buffer.from(process.argv[2] || '', 'base64url').toString('utf8') || '{}');
|
|
147
174
|
process.exitCode = runPlan(plan);
|
package/src/manager/versions.mjs
CHANGED
|
@@ -10,6 +10,18 @@ export function parseVersion(text) {
|
|
|
10
10
|
return match ? match[1] : null;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** tmux uses two numbers and a patch letter; distributions add an epoch/revision. */
|
|
14
|
+
export function parseTmuxVersion(text) {
|
|
15
|
+
return String(text || '').trim().match(/^(?:tmux\s+)?(?:\d+:)?(\d+\.\d+(?:_?[a-z])?)(?=$|[-+~\s])/i)?.[1]?.replace('_', '') ?? null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function compareTmuxVersions(a, b) {
|
|
19
|
+
const parts = (v) => /^(\d+)\.(\d+)([a-z]?)$/i.exec(parseTmuxVersion(v) || '');
|
|
20
|
+
const aa = parts(a), bb = parts(b);
|
|
21
|
+
if (!aa || !bb) return null;
|
|
22
|
+
return Number(aa[1]) - Number(bb[1]) || Number(aa[2]) - Number(bb[2]) || aa[3].localeCompare(bb[3]);
|
|
23
|
+
}
|
|
24
|
+
|
|
13
25
|
/** Semantic-version order: negative when a < b, positive when a > b. */
|
|
14
26
|
export function compareVersions(a, b) {
|
|
15
27
|
const [aMain, aPre] = String(a).split('-');
|
package/web/app.js
CHANGED
|
@@ -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
|
-
|
|
826
|
-
box.
|
|
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
|
-
|
|
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%; }
|