@oddessentials/agent-guild 0.23.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/config/providers.default.json +4 -0
- package/package.json +1 -1
- package/src/manager/install-channels.mjs +1 -0
- package/src/manager/multiplexer-paths.mjs +81 -0
- package/src/manager/multiplexers.mjs +364 -0
- package/src/manager/providers.mjs +31 -4
- package/src/manager/server.mjs +7 -0
- package/src/manager/session-manager.mjs +207 -16
- package/src/manager/uninstall.mjs +29 -2
- package/src/manager/versions.mjs +12 -0
- package/web/app.js +144 -7
- package/web/index.html +21 -0
- package/web/styles.css +31 -0
- package/web/terminal-copy.js +128 -0
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.
|
|
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": {
|
|
@@ -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
|
}
|