@webority/ensemble 0.5.5 → 0.5.8
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 +29 -8
- package/bin/ensemble.js +103 -14
- package/lib/core.js +568 -57
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# @webority/ensemble
|
|
2
2
|
|
|
3
3
|
Connect a machine to your [Ensemble](https://ensemble.host) Console. Installs the local **agent
|
|
4
|
-
runner** (runs your AI coding sessions, appears in your fleet) and wires **ensemble
|
|
5
|
-
coding CLIs
|
|
4
|
+
runner** (runs your AI coding sessions, appears in your fleet) and wires the **ensemble** session bus into your
|
|
5
|
+
coding CLIs — **lifecycle hooks** (mail at turn boundaries) **and MCP** (first-class
|
|
6
|
+
who/send/read/reply tools) — so sessions message each other, scoped to your organization.
|
|
6
7
|
|
|
7
8
|
## Install & enrol
|
|
8
9
|
Get an **enrollment token** from the portal → *Connect Runner*, then:
|
|
@@ -25,14 +26,34 @@ irm https://ensemble.host/install.ps1 | iex # then: ensemble enroll --token <T
|
|
|
25
26
|
```
|
|
26
27
|
|
|
27
28
|
`enroll` exchanges your enrollment token for a **per-machine token** (revocable individually from the
|
|
28
|
-
portal), downloads + configures + auto-starts the runner, and
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
portal), downloads + configures + auto-starts the runner, and for **each detected coding agent**:
|
|
30
|
+
|
|
31
|
+
| Agent | Hooks | MCP |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| Claude | `~/.claude/settings.json` | `~/.claude.json` → `ensemble` |
|
|
34
|
+
| Codex | `~/.codex/hooks.json` | `~/.codex/config.toml` |
|
|
35
|
+
| Grok | `~/.grok/hooks/agent-bus.json` | `~/.grok/config.toml` |
|
|
36
|
+
| OpenCode | (MCP primary) | `~/.config/opencode/opencode.json` → `mcp.ensemble` |
|
|
37
|
+
| OMP (oh-my-pi) | `~/.omp/agent/ensemble-hooks.json` | `~/.omp/agent/mcp.json` |
|
|
38
|
+
| Gemini | best-effort | `~/.gemini/settings.json` |
|
|
39
|
+
| Antigravity | `~/.antigravity/hooks.json` | `~/.antigravity/mcp.json` |
|
|
40
|
+
|
|
41
|
+
1. **Hooks** — mail at turn boundaries
|
|
42
|
+
2. **MCP** — `ensemble mcp --engine <e>` as server name **`ensemble`**
|
|
43
|
+
|
|
44
|
+
Fleet playbook: `docs/FLEET.md`.
|
|
45
|
+
**Your subscription logins never leave your machine.** Restart open agent sessions after enroll/hooks.
|
|
31
46
|
|
|
32
47
|
## Commands
|
|
33
|
-
- `ensemble enroll --token <t
|
|
34
|
-
- `ensemble
|
|
35
|
-
- `ensemble
|
|
48
|
+
- `ensemble login` / `ensemble enroll --token <t>` — auth + install runtime + wire hooks/MCP
|
|
49
|
+
- `ensemble install` — force-refresh runner/runtime into `~/.ensemble`; re-wires hooks/MCP if already enrolled
|
|
50
|
+
- `ensemble status` — connection, engines detected, Grok MCP path check
|
|
51
|
+
- `ensemble hooks` — re-wire **hooks + MCP** into every detected coding CLI (safe on existing configs)
|
|
52
|
+
|
|
53
|
+
### Grok / Codex MCP (existing config)
|
|
54
|
+
When `~/.grok/config.toml` (or Codex) already exists, install **upserts** only
|
|
55
|
+
`[mcp_servers.ensemble]` — other servers (e.g. weborityos) and UI/cli sections are kept.
|
|
56
|
+
Command points at `~/.ensemble/bin/ensemble-runtime` only (no `ensemble-bus` binary or alias).
|
|
36
57
|
|
|
37
58
|
## Security model
|
|
38
59
|
Machine ⇄ server auth is a **per-machine token**, resolved server-side to your organization; every
|
package/bin/ensemble.js
CHANGED
|
@@ -3,8 +3,35 @@
|
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
6
7
|
const core = require('../lib/core');
|
|
7
8
|
|
|
9
|
+
// Bus subcommands are handled by the internal `ensemble-runtime` binary; the Node CLI forwards argv
|
|
10
|
+
// through to it (inherited stdio, same exit code) so users type only one command: `ensemble <sub>`.
|
|
11
|
+
const BUS_SUBCOMMANDS = new Set([
|
|
12
|
+
'send', 'who', 'read', 'reply', 'label', 'hook', 'mcp', 'register', 'detach', 'ask',
|
|
13
|
+
'rooms', 'room-create', 'room-send', 'room-messages', 'room-add', 'room-remove',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
function forwardToRuntime(argv) {
|
|
17
|
+
const runtimePath = core.runtimeBin();
|
|
18
|
+
if (!core.isUsableBinary(runtimePath)) {
|
|
19
|
+
core.die('the Ensemble runtime is not installed — run `ensemble install` to download it.');
|
|
20
|
+
}
|
|
21
|
+
// Prefer env; else load ~/.ensemble/machine-token so bus subcommands work in shells that never
|
|
22
|
+
// got setx/export (common after enroll from another terminal).
|
|
23
|
+
const env = { ...process.env };
|
|
24
|
+
if (!env.ENSEMBLE_MACHINE_TOKEN) {
|
|
25
|
+
const tokenFile = path.join(core.ENSEMBLE_DIR, 'machine-token');
|
|
26
|
+
if (fs.existsSync(tokenFile)) {
|
|
27
|
+
env.ENSEMBLE_MACHINE_TOKEN = fs.readFileSync(tokenFile, 'utf8').trim();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const r = spawnSync(runtimePath, argv, { stdio: 'inherit', env });
|
|
31
|
+
if (r.error) core.die(r.error.message);
|
|
32
|
+
process.exit(r.status == null ? 1 : r.status);
|
|
33
|
+
}
|
|
34
|
+
|
|
8
35
|
function parseArgs(argv) {
|
|
9
36
|
const args = { _: [] };
|
|
10
37
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -19,10 +46,11 @@ function parseArgs(argv) {
|
|
|
19
46
|
}
|
|
20
47
|
|
|
21
48
|
function reportSetup(wired) {
|
|
22
|
-
core.log('• wired
|
|
49
|
+
core.log('• wired hooks+MCP: ' + (wired.join(', ') || '(no supported coding CLI found — run `ensemble hooks` after installing agents)'));
|
|
23
50
|
core.log('• installed auto-start + launched the runner');
|
|
24
|
-
core.log('\n✓ Done. This machine runs your Ensemble runner
|
|
25
|
-
core.log('
|
|
51
|
+
core.log('\n✓ Done. This machine runs your Ensemble runner; sessions share the bus (hooks + MCP).');
|
|
52
|
+
core.log(' Restart open coding agents so they load the ensemble MCP server.');
|
|
53
|
+
core.log(' Supported: claude, codex, grok, opencode, omp (oh-my-pi), gemini, antigravity.');
|
|
26
54
|
}
|
|
27
55
|
|
|
28
56
|
// Browser sign-in (device flow) — no token to copy. The recommended path.
|
|
@@ -56,15 +84,26 @@ async function waitForApproval(api, grant) {
|
|
|
56
84
|
core.die('timed out waiting for approval — nothing was installed. Run `ensemble login` again.');
|
|
57
85
|
}
|
|
58
86
|
|
|
59
|
-
// Explicit (re)install of the runner +
|
|
87
|
+
// Explicit (re)install of the runner + runtime into ~/.ensemble. Always refreshes binaries so
|
|
88
|
+
// upgrades replace stubs / old builds. If already enrolled, re-wires hooks+MCP (Grok/Codex/… with
|
|
89
|
+
// existing config.toml get [mcp_servers.ensemble] upserted without wiping other servers).
|
|
90
|
+
// `npm install` does NOT populate ~/.ensemble — there is no postinstall.
|
|
60
91
|
async function install() {
|
|
61
92
|
core.assertSupported();
|
|
62
|
-
await core.ensureBinaries();
|
|
63
|
-
|
|
93
|
+
await core.ensureBinaries({ force: true });
|
|
94
|
+
const tokenFile = path.join(core.ENSEMBLE_DIR, 'machine-token');
|
|
95
|
+
if (fs.existsSync(tokenFile)) {
|
|
96
|
+
const wired = core.wireHooks();
|
|
97
|
+
core.log('✓ Ensemble runtime reinstalled.');
|
|
98
|
+
core.log('• re-wired hooks+MCP: ' + (wired.join(', ') || '(no coding CLI detected)'));
|
|
99
|
+
core.log(' Restart open coding agents so they load the ensemble MCP server.');
|
|
100
|
+
} else {
|
|
101
|
+
core.log('✓ Ensemble runtime installed. Next: ensemble login');
|
|
102
|
+
}
|
|
64
103
|
}
|
|
65
104
|
|
|
66
|
-
// Headless / CI path: enrol with a copy-pasted enrollment token.
|
|
67
|
-
//
|
|
105
|
+
// Headless / CI path: enrol with a copy-pasted enrollment token. The runtime is populated into
|
|
106
|
+
// ~/.ensemble here (ensureRuntime) if it isn't already — same as `ensemble install`/`login`.
|
|
68
107
|
async function enroll(args) {
|
|
69
108
|
const token = args.token || process.env.ENSEMBLE_ENROLL_TOKEN;
|
|
70
109
|
if (!token) core.die('missing --token <enrollment-token> — get it from your Ensemble portal → Connect Runner, or use `ensemble login` for browser sign-in');
|
|
@@ -80,13 +119,41 @@ async function enroll(args) {
|
|
|
80
119
|
async function status(args) {
|
|
81
120
|
const api = args.api || core.DEFAULT_API;
|
|
82
121
|
const tokenFile = path.join(core.ENSEMBLE_DIR, 'machine-token');
|
|
83
|
-
if (!fs.existsSync(tokenFile)) return core.log('not enrolled — run: ensemble enroll --token <token>');
|
|
122
|
+
if (!fs.existsSync(tokenFile)) return core.log('not enrolled — run: ensemble enroll --token <token> (or: ensemble login)');
|
|
84
123
|
const t = fs.readFileSync(tokenFile, 'utf8').trim();
|
|
124
|
+
core.log('Ensemble status @ ' + api);
|
|
125
|
+
const runtimeExe = core.runtimeBin();
|
|
126
|
+
core.log('• runtime binary: ' + (core.isUsableBinary(runtimeExe) ? 'ok (' + runtimeExe + ')' : 'MISSING/STUB — ensemble install'));
|
|
127
|
+
const engines = ['claude', 'codex', 'grok', 'opencode', 'omp', 'gemini', 'antigravity'];
|
|
128
|
+
const present = engines.filter((e) => core.enginePresent(e));
|
|
129
|
+
core.log('• engines detected: ' + (present.join(', ') || 'none'));
|
|
130
|
+
// Quick MCP path check for engines that use a known config file.
|
|
131
|
+
if (core.enginePresent('grok')) {
|
|
132
|
+
const grokToml = path.join(os.homedir(), '.grok', 'config.toml');
|
|
133
|
+
if (fs.existsSync(grokToml)) {
|
|
134
|
+
const txt = fs.readFileSync(grokToml, 'utf8');
|
|
135
|
+
const hasEnsemble = /\[mcp_servers\.ensemble\]/.test(txt);
|
|
136
|
+
const pointsRuntime = /ensemble-runtime/.test(txt);
|
|
137
|
+
const hasLegacyBus = /ensemble-bus/.test(txt);
|
|
138
|
+
core.log('• grok MCP: ' + (hasEnsemble && pointsRuntime && !hasLegacyBus
|
|
139
|
+
? 'configured (ensemble-runtime) in ~/.grok/config.toml'
|
|
140
|
+
: hasLegacyBus
|
|
141
|
+
? 'LEGACY ensemble-bus path — run: ensemble hooks'
|
|
142
|
+
: 'NOT wired — run: ensemble hooks'));
|
|
143
|
+
} else {
|
|
144
|
+
core.log('• grok MCP: no ~/.grok/config.toml — run: ensemble hooks');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
core.log('• run `ensemble hooks` to (re)wire hooks+MCP for all detected engines');
|
|
85
148
|
const res = await core.request('GET', api.replace(/\/$/, '') + '/api/bus/who', { 'X-Ensemble-Machine-Token': t });
|
|
86
149
|
if (res.status === 200) {
|
|
87
150
|
const mbx = JSON.parse(res.body);
|
|
88
|
-
core.log('✓ connected
|
|
89
|
-
mbx.slice(0,
|
|
151
|
+
core.log('✓ connected — ' + mbx.length + ' mailbox(es) (label / focus / engine):');
|
|
152
|
+
mbx.slice(0, 30).forEach((m) => {
|
|
153
|
+
const label = m.label ? m.label : '-';
|
|
154
|
+
const focus = m.focus ? m.focus : '-';
|
|
155
|
+
core.log(' ' + m.name + ' [' + (m.engine || '?') + '] ' + (m.status || '') + ' label=' + label + ' focus=' + focus);
|
|
156
|
+
});
|
|
90
157
|
} else {
|
|
91
158
|
core.log('✗ machine token not accepted (HTTP ' + res.status + ') — re-enroll with a fresh enrollment token.');
|
|
92
159
|
}
|
|
@@ -100,11 +167,21 @@ function usage() {
|
|
|
100
167
|
sign in via your browser + set up this machine (downloads the runtime on first run)
|
|
101
168
|
ensemble enroll --token <enrollment-token> [--api <url>] [--name <machine>]
|
|
102
169
|
headless / CI: enrol with an enrollment token from the portal
|
|
103
|
-
ensemble install (re)download the runner +
|
|
170
|
+
ensemble install (re)download the runner + runtime for this platform
|
|
104
171
|
ensemble status show connection + your org's live session mailboxes
|
|
105
|
-
ensemble hooks (re)wire ensemble
|
|
172
|
+
ensemble hooks (re)wire ensemble hooks + MCP into installed coding CLIs
|
|
106
173
|
ensemble version
|
|
107
174
|
|
|
175
|
+
Session bus — message other agent sessions (forwarded to the ensemble runtime):
|
|
176
|
+
ensemble who [--all] [--label <f>] list live session mailboxes
|
|
177
|
+
ensemble label "<purpose>" --engine <e> set this session's discovery label/focus
|
|
178
|
+
ensemble send <to> "<msg>" --from <me> [--intent <i>] [--expect none|ack|reply|done]
|
|
179
|
+
ensemble read --from <me> [--peek] read + mark-read this mailbox
|
|
180
|
+
ensemble reply "<msg>" --from <me> --thread <guid>
|
|
181
|
+
ensemble ask "<question>" --engine <e> ask the human (default: mobile)
|
|
182
|
+
ensemble rooms | room-create | room-add | room-remove | room-send | room-messages
|
|
183
|
+
ensemble register | detach | hook | mcp (used by lifecycle hooks + MCP wiring)
|
|
184
|
+
|
|
108
185
|
Run \`ensemble login\` and approve the machine in your browser.`);
|
|
109
186
|
}
|
|
110
187
|
|
|
@@ -117,8 +194,20 @@ Run \`ensemble login\` and approve the machine in your browser.`);
|
|
|
117
194
|
else if (cmd === 'enroll') await enroll(args);
|
|
118
195
|
else if (cmd === 'install') await install();
|
|
119
196
|
else if (cmd === 'status') await status(args);
|
|
120
|
-
else if (cmd === 'hooks') {
|
|
197
|
+
else if (cmd === 'hooks') {
|
|
198
|
+
// Ensure a usable (non-stub) runtime BEFORE wiring — otherwise every engine gets pointed at a
|
|
199
|
+
// dead path. Fails closed on unsupported platforms. No ensemble-bus compat alias.
|
|
200
|
+
await core.ensureRuntime();
|
|
201
|
+
core.removeLegacyBusArtifacts();
|
|
202
|
+
const wired = core.wireHooks();
|
|
203
|
+
core.log('wired hooks+MCP: ' + (wired.join(', ') || 'none'));
|
|
204
|
+
if (!wired.some((w) => w.endsWith(':mcp'))) {
|
|
205
|
+
core.log('(no MCP wired — install a coding CLI, then re-run ensemble hooks)');
|
|
206
|
+
}
|
|
207
|
+
core.log('(restart open coding-agent sessions so they pick up MCP server changes)');
|
|
208
|
+
}
|
|
121
209
|
else if (cmd === 'version' || cmd === '--version' || cmd === '-v') core.log(require('../package.json').version);
|
|
210
|
+
else if (BUS_SUBCOMMANDS.has(cmd)) forwardToRuntime(argv);
|
|
122
211
|
else usage();
|
|
123
212
|
} catch (e) {
|
|
124
213
|
core.die(e && e.message ? e.message : String(e));
|
package/lib/core.js
CHANGED
|
@@ -82,32 +82,129 @@ function runtimePaths() {
|
|
|
82
82
|
const pf = platform();
|
|
83
83
|
return {
|
|
84
84
|
pf,
|
|
85
|
-
|
|
85
|
+
runtimePath: path.join(BIN_DIR, 'ensemble-runtime' + pf.exe),
|
|
86
86
|
runnerPath: path.join(RUNNER_DIR, 'Ensemble.Runner' + pf.exe),
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
// The
|
|
90
|
+
// The ensemble-runtime binary path (the internal .NET CLI the Node wrapper forwards bus subcommands to).
|
|
91
|
+
function runtimeBin() {
|
|
92
|
+
return runtimePaths().runtimePath;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Real runtime/runner single-file binaries are tens of MB. Anything smaller is corrupt/stub.
|
|
96
|
+
const MIN_BINARY_BYTES = 1_000_000;
|
|
97
|
+
|
|
98
|
+
function isUsableBinary(filePath) {
|
|
99
|
+
try {
|
|
100
|
+
return fs.existsSync(filePath) && fs.statSync(filePath).size >= MIN_BINARY_BYTES;
|
|
101
|
+
} catch (e) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The npm package version is the release version-of-record (published packages carry the real value;
|
|
107
|
+
// a repo checkout is 0.0.0). The installed runtime records the version it was written for in
|
|
108
|
+
// ~/.ensemble/.runtime-version; a later login/enroll/hooks after an `npm upgrade` sees the mismatch
|
|
109
|
+
// and force-refreshes the binaries — this is what actually lands a new release across the fleet.
|
|
110
|
+
// Previously only an explicit `ensemble install` upgraded, so login/enroll/hooks kept the stale
|
|
111
|
+
// runner/runtime forever. [BUG-06673]
|
|
112
|
+
const PKG_VERSION = (() => {
|
|
113
|
+
try { return String(require('../package.json').version || ''); } catch (e) { return ''; }
|
|
114
|
+
})();
|
|
115
|
+
const VERSION_MARKER = path.join(ENSEMBLE_DIR, '.runtime-version');
|
|
116
|
+
|
|
117
|
+
function readInstalledVersion() {
|
|
118
|
+
try { return fs.readFileSync(VERSION_MARKER, 'utf8').trim(); } catch (e) { return ''; }
|
|
119
|
+
}
|
|
120
|
+
function writeInstalledVersion() {
|
|
121
|
+
try { fs.writeFileSync(VERSION_MARKER, PKG_VERSION + '\n'); } catch (e) { /* best-effort */ }
|
|
122
|
+
}
|
|
123
|
+
// Stale when the marker doesn't match the current package version. An unknown package version (dev
|
|
124
|
+
// checkout = 0.0.0/empty) never counts as stale, so a dev tree doesn't thrash on every command; an
|
|
125
|
+
// ABSENT marker (a pre-versioning 0.5.x install) counts as stale so the first run upgrades it.
|
|
126
|
+
function installedVersionCurrent() {
|
|
127
|
+
if (!PKG_VERSION || PKG_VERSION === '0.0.0') return true;
|
|
128
|
+
return readInstalledVersion() === PKG_VERSION;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Move a freshly-written temp binary onto dest, tolerating a Windows lock on the old binary (the
|
|
132
|
+
// running runner holds Ensemble.Runner.exe open). Windows permits renaming a running exe, so rename
|
|
133
|
+
// the locked old file aside, then move the new one into place; the old process keeps its aside until
|
|
134
|
+
// it restarts. Stale asides are swept best-effort.
|
|
135
|
+
function placeBinary(tmp, dest) {
|
|
136
|
+
let aside = null;
|
|
137
|
+
try {
|
|
138
|
+
if (fs.existsSync(dest)) {
|
|
139
|
+
// Always move the current binary aside FIRST (Windows permits renaming even a running exe), so
|
|
140
|
+
// dest is never left missing if the place step then fails. The old process keeps its aside.
|
|
141
|
+
aside = dest + '.old-' + Date.now();
|
|
142
|
+
fs.renameSync(dest, aside);
|
|
143
|
+
}
|
|
144
|
+
fs.renameSync(tmp, dest);
|
|
145
|
+
} catch (e) {
|
|
146
|
+
// Cross-device or rename edge — fall back to a copy.
|
|
147
|
+
try {
|
|
148
|
+
fs.copyFileSync(tmp, dest);
|
|
149
|
+
try { fs.unlinkSync(tmp); } catch (_) { /* ignore */ }
|
|
150
|
+
} catch (e2) {
|
|
151
|
+
// Both placements failed — restore the original so dest is never left missing.
|
|
152
|
+
if (aside && !fs.existsSync(dest)) { try { fs.renameSync(aside, dest); aside = null; } catch (_) { /* ignore */ } }
|
|
153
|
+
throw e2;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Sweep now-unlocked old asides from earlier upgrades.
|
|
157
|
+
try {
|
|
158
|
+
const dir = path.dirname(dest);
|
|
159
|
+
const prefix = path.basename(dest) + '.old-';
|
|
160
|
+
for (const f of fs.readdirSync(dir)) {
|
|
161
|
+
if (f.indexOf(prefix) === 0) { try { fs.unlinkSync(path.join(dir, f)); } catch (_) { /* still locked */ } }
|
|
162
|
+
}
|
|
163
|
+
} catch (_) { /* ignore */ }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// After the runner binary is replaced, the OLD runner process is still executing old code (it kept
|
|
167
|
+
// the renamed-aside file). Restart it so the new build runs. Best-effort: Windows kill+relaunch;
|
|
168
|
+
// mac/linux reload the launchd/systemd unit. Failure just defers the new runner to next launch.
|
|
169
|
+
function restartRunner(pf, runnerPath) {
|
|
170
|
+
try {
|
|
171
|
+
if (pf.os === 'windows') {
|
|
172
|
+
spawnSync('taskkill', ['/IM', 'Ensemble.Runner.exe', '/F'], { stdio: 'ignore' });
|
|
173
|
+
startRunner(runnerPath);
|
|
174
|
+
} else {
|
|
175
|
+
installAutostart(runnerPath); // unload+load (launchd) / enable --now (systemd) → picks up new binary
|
|
176
|
+
}
|
|
177
|
+
} catch (e) { /* best-effort — the new binary is on disk and runs on next launch */ }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The runner + runtime are copied into ~/.ensemble by `ensemble install` (or on first
|
|
181
|
+
// `ensemble login`/`enroll`). `npm install` alone does NOT populate ~/.ensemble — there is no
|
|
182
|
+
// postinstall; it only places the per-platform binary package under node_modules.
|
|
91
183
|
function runtimeInstalled() {
|
|
92
184
|
const p = runtimePaths();
|
|
93
|
-
|
|
94
|
-
return ok(p.busPath) && ok(p.runnerPath);
|
|
185
|
+
return isUsableBinary(p.runtimePath) && isUsableBinary(p.runnerPath);
|
|
95
186
|
}
|
|
96
187
|
|
|
97
188
|
function requireRuntime() {
|
|
98
189
|
if (!runtimeInstalled()) {
|
|
99
|
-
die('the Ensemble runtime is not installed — run `ensemble install`
|
|
190
|
+
die('the Ensemble runtime is not installed — run `ensemble install` to download it.');
|
|
100
191
|
}
|
|
101
192
|
return runtimePaths();
|
|
102
193
|
}
|
|
103
194
|
|
|
104
|
-
// Ensure the runner +
|
|
195
|
+
// Ensure the runner + runtime are present, downloading them if this is the first run. Called by
|
|
105
196
|
// `login`/`enroll` AFTER authentication, so the whole thing is: `npm i` then `ensemble login`.
|
|
197
|
+
// Also reinstalls when an existing binary is a tiny stub (broken prior install).
|
|
106
198
|
async function ensureRuntime() {
|
|
107
|
-
|
|
108
|
-
|
|
199
|
+
// Fleet auto-upgrade: refresh when a binary is missing/stub OR was installed for an OLDER package
|
|
200
|
+
// version (an `npm upgrade` bumped us). This makes login/enroll/hooks land a new release — not only
|
|
201
|
+
// an explicit `ensemble install`, which was the only upgrade path before and left the fleet stale. [BUG-06673]
|
|
202
|
+
if (runtimeInstalled() && installedVersionCurrent()) {
|
|
203
|
+
const p = runtimePaths();
|
|
204
|
+
removeLegacyBusArtifacts();
|
|
205
|
+
return p;
|
|
109
206
|
}
|
|
110
|
-
return ensureBinaries();
|
|
207
|
+
return ensureBinaries({ force: true });
|
|
111
208
|
}
|
|
112
209
|
|
|
113
210
|
// The per-platform npm optionalDependency (esbuild model): npm installs only the one matching this
|
|
@@ -130,36 +227,104 @@ function platformPackageDir() {
|
|
|
130
227
|
}
|
|
131
228
|
}
|
|
132
229
|
|
|
133
|
-
//
|
|
230
|
+
// No backward-compat binary. Delete leftover ensemble-bus* (and framework-dependent debris that
|
|
231
|
+
// sometimes landed next to a bad publish) so only ensemble-runtime remains the bus CLI.
|
|
232
|
+
function removeLegacyBusArtifacts() {
|
|
233
|
+
ensureDirs();
|
|
234
|
+
let names;
|
|
235
|
+
try {
|
|
236
|
+
names = fs.readdirSync(BIN_DIR);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
for (const name of names) {
|
|
241
|
+
const lower = name.toLowerCase();
|
|
242
|
+
const drop =
|
|
243
|
+
lower.startsWith('ensemble-bus')
|
|
244
|
+
|| lower.startsWith('microsoft.')
|
|
245
|
+
|| lower.startsWith('modelcontextprotocol')
|
|
246
|
+
|| lower === 'system.diagnostics.eventlog.dll'
|
|
247
|
+
|| lower === 'runtimes';
|
|
248
|
+
if (!drop) continue;
|
|
249
|
+
const full = path.join(BIN_DIR, name);
|
|
250
|
+
try {
|
|
251
|
+
if (fs.statSync(full).isDirectory()) {
|
|
252
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
253
|
+
} else {
|
|
254
|
+
fs.unlinkSync(full);
|
|
255
|
+
}
|
|
256
|
+
} catch (e) {
|
|
257
|
+
// Locked (running MCP still holds a handle): rename aside so nothing can launch it by name.
|
|
258
|
+
try {
|
|
259
|
+
const trash = full + '.trash-' + Date.now();
|
|
260
|
+
fs.renameSync(full, trash);
|
|
261
|
+
warn('moved locked legacy ' + name + ' aside → ' + path.basename(trash) +
|
|
262
|
+
' (delete after restarting agents)');
|
|
263
|
+
} catch (e2) {
|
|
264
|
+
warn('could not remove legacy ' + name + ' (locked) — restart agents and re-run `ensemble install`. (' +
|
|
265
|
+
(e2 && e2.message ? e2.message : e2) + ')');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function shouldRefreshBinary(dest, src, force) {
|
|
272
|
+
if (force) return true;
|
|
273
|
+
if (!isUsableBinary(dest)) return true; // missing or stub
|
|
274
|
+
// If the npm platform package has a different-sized binary, refresh (upgrade path).
|
|
275
|
+
if (src && isUsableBinary(src) && fs.statSync(src).size !== fs.statSync(dest).size) return true;
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Put the runner + runtime in ~/.ensemble (the stable location the daemon, hooks, and autostart use).
|
|
134
280
|
// Source them from the npm-installed platform package (instant copy, no download); only if that
|
|
135
|
-
// package isn't present do we download from the CDN.
|
|
136
|
-
//
|
|
137
|
-
|
|
281
|
+
// package isn't present do we download from the CDN. `npm i` only places the platform package under
|
|
282
|
+
// node_modules — `ensemble install` (or first `ensemble login`) is what copies them into ~/.ensemble.
|
|
283
|
+
//
|
|
284
|
+
// opts.force: always re-copy/download (used by `ensemble install` so upgrades land).
|
|
285
|
+
async function ensureBinaries(opts) {
|
|
286
|
+
const force = !!(opts && opts.force);
|
|
138
287
|
ensureDirs();
|
|
139
288
|
const pf = platform();
|
|
140
289
|
assertSupported();
|
|
141
|
-
const
|
|
290
|
+
const runtimePath = path.join(BIN_DIR, 'ensemble-runtime' + pf.exe);
|
|
142
291
|
const runnerPath = path.join(RUNNER_DIR, 'Ensemble.Runner' + pf.exe);
|
|
143
292
|
const pkgDir = platformPackageDir();
|
|
144
293
|
const targets = [
|
|
145
|
-
{ src: pkgDir && path.join(pkgDir, 'ensemble-
|
|
294
|
+
{ src: pkgDir && path.join(pkgDir, 'ensemble-runtime' + pf.exe), cdn: 'ensemble-runtime-' + pf.key + pf.exe, dest: runtimePath },
|
|
146
295
|
{ src: pkgDir && path.join(pkgDir, 'Ensemble.Runner' + pf.exe), cdn: 'ensemble-runner-' + pf.key + pf.exe, dest: runnerPath },
|
|
147
296
|
];
|
|
297
|
+
let runnerRefreshed = false;
|
|
148
298
|
for (const t of targets) {
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
299
|
+
if (!shouldRefreshBinary(t.dest, t.src, force)) continue;
|
|
300
|
+
// Write to a temp then atomically place onto dest — placeBinary tolerates a Windows lock on the
|
|
301
|
+
// running runner (rename-aside), so an upgrade never fails just because the daemon is up.
|
|
302
|
+
const tmp = t.dest + '.new';
|
|
303
|
+
if (t.src && isUsableBinary(t.src)) {
|
|
304
|
+
log(' installing ' + path.basename(t.dest) + ' from npm package …');
|
|
305
|
+
fs.copyFileSync(t.src, tmp);
|
|
152
306
|
} else {
|
|
153
307
|
log(' downloading ' + t.cdn + ' …');
|
|
154
|
-
await downloadTo(DL_BASE + '/' + t.cdn,
|
|
308
|
+
await downloadTo(DL_BASE + '/' + t.cdn, tmp);
|
|
309
|
+
}
|
|
310
|
+
if (!isUsableBinary(tmp)) {
|
|
311
|
+
try { fs.unlinkSync(tmp); } catch (_) { /* ignore */ }
|
|
312
|
+
throw new Error('installed binary looks corrupt or too small: ' + t.dest +
|
|
313
|
+
' (expected ≥ ' + MIN_BINARY_BYTES + ' bytes). Check CDN / platform package.');
|
|
155
314
|
}
|
|
315
|
+
placeBinary(tmp, t.dest);
|
|
316
|
+
if (t.dest === runnerPath) runnerRefreshed = true;
|
|
156
317
|
if (pf.os !== 'windows') {
|
|
157
318
|
fs.chmodSync(t.dest, 0o755);
|
|
158
319
|
// macOS requires at least an ad-hoc signature to run an unsigned binary.
|
|
159
320
|
if (pf.os === 'mac') spawnSync('codesign', ['-s', '-', '-f', t.dest], { stdio: 'ignore' });
|
|
160
321
|
}
|
|
161
322
|
}
|
|
162
|
-
|
|
323
|
+
removeLegacyBusArtifacts();
|
|
324
|
+
writeInstalledVersion();
|
|
325
|
+
// A replaced runner binary only takes effect once the daemon restarts onto it.
|
|
326
|
+
if (runnerRefreshed) restartRunner(pf, runnerPath);
|
|
327
|
+
return { runtimePath, runnerPath, pf };
|
|
163
328
|
}
|
|
164
329
|
|
|
165
330
|
// --- enrollment: exchange the enrollment token for a per-machine runner token ---
|
|
@@ -182,7 +347,7 @@ function writeRunnerConfig(api, machineToken) {
|
|
|
182
347
|
Ensemble: { HubUrl: hubUrl, MachineToken: machineToken },
|
|
183
348
|
};
|
|
184
349
|
fs.writeFileSync(path.join(RUNNER_DIR, 'appsettings.json'), JSON.stringify(cfg, null, 2));
|
|
185
|
-
// ensemble-
|
|
350
|
+
// ensemble-runtime reads ENSEMBLE_MACHINE_TOKEN from env — persist it to the shell rc + a dotfile.
|
|
186
351
|
fs.writeFileSync(path.join(ENSEMBLE_DIR, 'machine-token'), machineToken, { mode: 0o600 });
|
|
187
352
|
const pf = platform();
|
|
188
353
|
if (pf.os === 'windows') {
|
|
@@ -199,25 +364,322 @@ function writeRunnerConfig(api, machineToken) {
|
|
|
199
364
|
} catch (e) { /* best-effort */ }
|
|
200
365
|
}
|
|
201
366
|
|
|
202
|
-
// --- wire ensemble-
|
|
367
|
+
// --- wire ensemble-runtime into each installed harness (hooks + MCP) ---
|
|
368
|
+
// Fleet engines: Claude, Codex, Grok, OpenCode, OMP (oh-my-pi), Gemini, Antigravity (when present).
|
|
369
|
+
// Hooks = mail at turn boundaries. MCP = who/send/read/reply tools every turn.
|
|
370
|
+
|
|
371
|
+
const ENGINE_CMDS = {
|
|
372
|
+
claude: ['claude'],
|
|
373
|
+
codex: ['codex'],
|
|
374
|
+
grok: ['grok'],
|
|
375
|
+
opencode: ['opencode'],
|
|
376
|
+
omp: ['omp', 'oh-my-pi', 'pi'],
|
|
377
|
+
gemini: ['gemini'],
|
|
378
|
+
antigravity: ['antigravity'],
|
|
379
|
+
};
|
|
380
|
+
|
|
203
381
|
function wireHooks() {
|
|
204
382
|
const pf = platform();
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
383
|
+
const runtimeExe = path.join(BIN_DIR, 'ensemble-runtime' + pf.exe);
|
|
384
|
+
// Never wire hooks/MCP to a runtime binary that isn't present — that silently points every engine
|
|
385
|
+
// at a dead path (hook + MCP death). Callers (`login`/`enroll`) run ensureRuntime first, and the
|
|
386
|
+
// `hooks` command does too; this guard fails closed if the binary is somehow still missing.
|
|
387
|
+
if (!isUsableBinary(runtimeExe)) {
|
|
388
|
+
throw new Error('the Ensemble runtime binary is missing or a stub at ' + runtimeExe +
|
|
389
|
+
' — run `ensemble install` to download it before wiring hooks.');
|
|
390
|
+
}
|
|
391
|
+
const runtimeInvoke = pf.os === 'windows' ? runtimeExe.replace(/\\/g, '\\\\') : runtimeExe;
|
|
392
|
+
const runtimeCmd = runtimeExe.replace(/\\/g, '/');
|
|
393
|
+
const report = [];
|
|
394
|
+
|
|
395
|
+
// --- hooks ---
|
|
396
|
+
// Report a head only when the write actually succeeded; a skipped (malformed-JSON) write returns
|
|
397
|
+
// false and must NOT be reported as wired.
|
|
398
|
+
if (enginePresent('claude')) {
|
|
399
|
+
if (mergeClaudeHooks(path.join(HOME, '.claude', 'settings.json'), runtimeInvoke)) report.push('claude:hooks');
|
|
400
|
+
}
|
|
401
|
+
if (enginePresent('codex')) {
|
|
402
|
+
if (writeEngineHooks(path.join(HOME, '.codex', 'hooks.json'), runtimeInvoke, 'codex')) report.push('codex:hooks');
|
|
403
|
+
}
|
|
404
|
+
if (enginePresent('grok')) {
|
|
405
|
+
if (writeEngineHooks(path.join(HOME, '.grok', 'hooks', 'agent-bus.json'), runtimeInvoke, 'grok')) report.push('grok:hooks');
|
|
406
|
+
}
|
|
407
|
+
// OpenCode: no stable global lifecycle-hooks file yet — MCP is the primary path.
|
|
408
|
+
if (enginePresent('opencode')) report.push('opencode:hooks?');
|
|
409
|
+
// OMP (oh-my-pi): NO JSON lifecycle-hooks mechanism — hooks are a JS/TS extension API (pi.on(...)),
|
|
410
|
+
// so a hooks.json is inert (consumed by nothing). MCP (below) is omp's integration path.
|
|
411
|
+
if (enginePresent('omp')) report.push('omp:hooks?');
|
|
412
|
+
if (enginePresent('gemini')) {
|
|
413
|
+
// Gemini CLI hook surface varies; still register MCP below.
|
|
414
|
+
report.push('gemini:hooks?');
|
|
415
|
+
}
|
|
416
|
+
// Antigravity uses a DIFFERENT hooks schema (a named-hook wrapper at ~/.gemini/config/hooks.json,
|
|
417
|
+
// where only a Stop-family event maps) — the old ~/.antigravity/hooks.json we wrote is never read.
|
|
418
|
+
// Left to MCP (below) until a native Antigravity hook writer lands.
|
|
419
|
+
if (enginePresent('antigravity')) report.push('antigravity:hooks?');
|
|
420
|
+
|
|
421
|
+
// --- MCP ---
|
|
422
|
+
if (enginePresent('claude')) {
|
|
423
|
+
if (upsertClaudeMcp(runtimeCmd, 'claude')) report.push('claude:mcp');
|
|
424
|
+
}
|
|
425
|
+
if (enginePresent('codex')) {
|
|
426
|
+
if (upsertTomlMcp(path.join(HOME, '.codex', 'config.toml'), 'ensemble', runtimeCmd, ['mcp', '--engine', 'codex'])) {
|
|
427
|
+
report.push('codex:mcp');
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (enginePresent('grok')) {
|
|
431
|
+
if (upsertTomlMcp(path.join(HOME, '.grok', 'config.toml'), 'ensemble', runtimeCmd, ['mcp', '--engine', 'grok'])) {
|
|
432
|
+
report.push('grok:mcp');
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (enginePresent('opencode')) {
|
|
436
|
+
if (upsertOpenCodeMcp(runtimeCmd)) report.push('opencode:mcp');
|
|
437
|
+
}
|
|
438
|
+
if (enginePresent('omp')) {
|
|
439
|
+
if (upsertOmpMcp(runtimeCmd)) report.push('omp:mcp');
|
|
440
|
+
}
|
|
441
|
+
if (enginePresent('gemini')) {
|
|
442
|
+
if (upsertJsonMcp(path.join(HOME, '.gemini', 'settings.json'), 'ensemble', runtimeCmd, ['mcp', '--engine', 'gemini'])) {
|
|
443
|
+
report.push('gemini:mcp');
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (enginePresent('antigravity')) {
|
|
447
|
+
// Prefer Claude-shaped global json if Antigravity is a Claude-family fork; also write local mcp.json.
|
|
448
|
+
if (upsertJsonMcp(path.join(HOME, '.antigravity', 'mcp.json'), 'ensemble', runtimeCmd, ['mcp', '--engine', 'antigravity'])) {
|
|
449
|
+
report.push('antigravity:mcp');
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return report;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// A genuine-install marker per engine — deliberately NOT a bare engine dir (the wiring itself
|
|
457
|
+
// creates `~/.gemini`, `~/.codex`, etc., and an empty staging dir would then count), and
|
|
458
|
+
// deliberately NOT a file Ensemble authors (settings.json / hooks.json / config.toml / mcp.json),
|
|
459
|
+
// so a prior `ensemble hooks` run can't make an uninstalled engine look present. PATH detection
|
|
460
|
+
// stays the primary, strongest signal; these are the fallback for when the CLI isn't on PATH.
|
|
461
|
+
function engineMarkers(engine) {
|
|
462
|
+
switch (engine) {
|
|
463
|
+
case 'claude': return [path.join(HOME, '.claude.json'), path.join(HOME, '.claude', 'projects')];
|
|
464
|
+
case 'codex': return [path.join(HOME, '.codex', 'auth.json'), path.join(HOME, '.codex', 'config.toml')];
|
|
465
|
+
case 'grok': return [path.join(HOME, '.grok', 'auth.json'), path.join(HOME, '.grok', 'config.toml')];
|
|
466
|
+
case 'opencode': return [path.join(HOME, '.config', 'opencode', 'opencode.json'), path.join(HOME, '.local', 'share', 'opencode'), path.join(HOME, '.opencode')];
|
|
467
|
+
case 'omp': return [path.join(HOME, '.omp', 'auth.json'), path.join(HOME, '.omp', 'config.json')];
|
|
468
|
+
case 'gemini': return [path.join(HOME, '.gemini', 'oauth_creds.json'), path.join(HOME, '.gemini', 'google_accounts.json'), path.join(HOME, '.gemini', 'installation_id')];
|
|
469
|
+
case 'antigravity': return [path.join(process.env.LOCALAPPDATA || '', 'Antigravity'), path.join(process.env.LOCALAPPDATA || '', 'antigravity'), path.join(HOME, '.antigravity', 'config.json')];
|
|
470
|
+
default: return [];
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Detect an engine by a resolvable binary on PATH, else a genuine-install marker file. */
|
|
475
|
+
function enginePresent(engine) {
|
|
476
|
+
const cmds = ENGINE_CMDS[engine] || [engine];
|
|
477
|
+
for (const c of cmds) {
|
|
478
|
+
if (has(c)) return true;
|
|
479
|
+
}
|
|
480
|
+
for (const m of engineMarkers(engine)) {
|
|
481
|
+
if (m && fs.existsSync(m)) return true;
|
|
482
|
+
}
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function wireMcpServers(runtimeExe) {
|
|
487
|
+
// Back-compat alias — full wiring is wireHooks().
|
|
488
|
+
return wireHooks().filter((x) => x.endsWith(':mcp')).map((x) => x.replace(/:mcp$/, ''));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// A config file exists but is not valid JSON. Fail CLOSED: never overwrite it — resetting to {} and
|
|
492
|
+
// writing would destroy the user's real config — just warn and skip wiring this engine. Returns
|
|
493
|
+
// false so callers propagate "not wired".
|
|
494
|
+
function skipMalformedJson(file, label, err) {
|
|
495
|
+
warn('ensemble: ' + label + ' at ' + file + ' is not valid JSON (' +
|
|
496
|
+
(err && err.message ? err.message : String(err)) +
|
|
497
|
+
') — leaving it untouched and skipping. Fix or remove the file, then run `ensemble hooks`.');
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function upsertClaudeMcp(runtimeCmd, engine) {
|
|
502
|
+
// Claude Code global MCP registry lives in ~/.claude.json → mcpServers
|
|
503
|
+
const file = path.join(HOME, '.claude.json');
|
|
504
|
+
let j = {};
|
|
505
|
+
if (fs.existsSync(file)) {
|
|
506
|
+
try {
|
|
507
|
+
j = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
508
|
+
} catch (e) {
|
|
509
|
+
return skipMalformedJson(file, 'Claude MCP config (~/.claude.json)', e);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (!j || typeof j !== 'object') j = {};
|
|
513
|
+
j.mcpServers = j.mcpServers || {};
|
|
514
|
+
delete j.mcpServers['ensemble-bus'];
|
|
515
|
+
j.mcpServers['ensemble'] = {
|
|
516
|
+
type: 'stdio',
|
|
517
|
+
command: runtimeCmd,
|
|
518
|
+
args: ['mcp', '--engine', engine],
|
|
519
|
+
};
|
|
520
|
+
try {
|
|
521
|
+
fs.writeFileSync(file, JSON.stringify(j, null, 2) + '\n');
|
|
522
|
+
return true;
|
|
523
|
+
} catch (e) {
|
|
524
|
+
warn('could not write Claude MCP config: ' + (e.message || e));
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/// OpenCode: mcp.<name> = { type: "local", command: [...], enabled: true } in opencode.json
|
|
530
|
+
function upsertOpenCodeMcp(runtimeCmd) {
|
|
531
|
+
try {
|
|
532
|
+
const dir = path.join(HOME, '.config', 'opencode');
|
|
533
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
534
|
+
const file = path.join(dir, 'opencode.json');
|
|
535
|
+
let j = {};
|
|
536
|
+
if (fs.existsSync(file)) {
|
|
537
|
+
try { j = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
538
|
+
catch (e) { return skipMalformedJson(file, 'OpenCode config (opencode.json)', e); }
|
|
539
|
+
}
|
|
540
|
+
if (!j || typeof j !== 'object') j = {};
|
|
541
|
+
j.mcp = j.mcp || {};
|
|
542
|
+
j.mcp.ensemble = {
|
|
543
|
+
type: 'local',
|
|
544
|
+
command: [runtimeCmd, 'mcp', '--engine', 'opencode'],
|
|
545
|
+
enabled: true,
|
|
546
|
+
};
|
|
547
|
+
// OpenCode rejects a stray top-level `mcpServers` key ("Unrecognized key: mcpServers") so the
|
|
548
|
+
// whole config fails to load. Older ensemble wiring left one (usually empty {}). Migrate ALL
|
|
549
|
+
// supported transports/fields into the accepted `mcp` shape — local (command → command,
|
|
550
|
+
// env → environment) AND remote (url/type:remote → url, headers) — never silently drop a user's
|
|
551
|
+
// servers. OpenCode's `mcp` schema has no place for cwd/timeout (or an unrecognized transport):
|
|
552
|
+
// rather than dropping those fields, back the file up and fail with guidance so nothing is lost.
|
|
553
|
+
if (j.mcpServers && typeof j.mcpServers === 'object') {
|
|
554
|
+
const migrated = {};
|
|
555
|
+
const unconvertible = [];
|
|
556
|
+
for (const [name, s] of Object.entries(j.mcpServers)) {
|
|
557
|
+
if (name === 'ensemble' || j.mcp[name]) continue;
|
|
558
|
+
if (!s || typeof s !== 'object') { unconvertible.push(name); continue; }
|
|
559
|
+
// OpenCode's mcp entries can't represent cwd/timeout — refuse rather than drop them.
|
|
560
|
+
if (s.cwd != null || s.timeout != null) { unconvertible.push(name); continue; }
|
|
561
|
+
const isRemote = !!s.url && !s.command;
|
|
562
|
+
if (isRemote) {
|
|
563
|
+
const entry = { type: 'remote', url: s.url, enabled: s.enabled !== false };
|
|
564
|
+
if (s.headers && typeof s.headers === 'object') entry.headers = s.headers;
|
|
565
|
+
migrated[name] = entry;
|
|
566
|
+
} else if (s.command) {
|
|
567
|
+
const cmd = Array.isArray(s.command) ? s.command.slice() : [s.command, ...(s.args || [])];
|
|
568
|
+
const entry = { type: 'local', command: cmd, enabled: s.enabled !== false };
|
|
569
|
+
const env = s.environment || s.env;
|
|
570
|
+
if (env && typeof env === 'object') entry.environment = env;
|
|
571
|
+
migrated[name] = entry;
|
|
572
|
+
} else {
|
|
573
|
+
unconvertible.push(name); // neither command nor url — unknown transport
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (unconvertible.length) {
|
|
577
|
+
const bak = file + '.bak';
|
|
578
|
+
try { fs.copyFileSync(file, bak); } catch (e) { /* best-effort backup */ }
|
|
579
|
+
warn('ensemble: OpenCode config ' + file + ' has mcpServers entries that cannot be safely ' +
|
|
580
|
+
'migrated to the `mcp` shape (' + unconvertible.join(', ') + ') — they use cwd/timeout or ' +
|
|
581
|
+
'an unrecognized transport OpenCode\'s schema does not support. Backed up to ' + bak +
|
|
582
|
+
' and left the original untouched. Migrate those servers into `mcp` by hand, then run ' +
|
|
583
|
+
'`ensemble hooks`.');
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
Object.assign(j.mcp, migrated);
|
|
587
|
+
delete j.mcpServers;
|
|
588
|
+
}
|
|
589
|
+
fs.writeFileSync(file, JSON.stringify(j, null, 2) + '\n');
|
|
590
|
+
return true;
|
|
591
|
+
} catch (e) {
|
|
592
|
+
warn('could not write OpenCode MCP config: ' + (e.message || e));
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/// OMP (oh-my-pi): ~/.omp/agent/mcp.json → mcpServers
|
|
598
|
+
function upsertOmpMcp(runtimeCmd) {
|
|
599
|
+
const file = path.join(HOME, '.omp', 'agent', 'mcp.json');
|
|
600
|
+
return upsertJsonMcp(file, 'ensemble', runtimeCmd, ['mcp', '--engine', 'omp'], {
|
|
601
|
+
$schema: 'https://raw.githubusercontent.com/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json',
|
|
602
|
+
type: 'stdio',
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/// Upsert [mcp_servers.<name>] in a TOML config (Codex / Grok). No TOML library — line surgery only.
|
|
607
|
+
/// Idempotent against an already-populated config: other tables (e.g. weborityos, [ui], [cli]) stay.
|
|
608
|
+
function upsertTomlMcp(filePath, serverName, command, argsArr) {
|
|
609
|
+
try {
|
|
610
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
611
|
+
let text = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';
|
|
612
|
+
const header = '[mcp_servers.' + serverName + ']';
|
|
613
|
+
// Drop our server table AND any of its CHILD tables ([mcp_servers.ensemble.env],
|
|
614
|
+
// [mcp_servers.ensemble.tools.x], the ensemble-bus variants) so a re-run replaces our entry
|
|
615
|
+
// cleanly and never leaves an orphaned sub-table that redefines the parent later (invalid TOML).
|
|
616
|
+
const esc = serverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
617
|
+
const ownTable = (h) =>
|
|
618
|
+
new RegExp('^\\[mcp_servers\\.(' + esc + '|ensemble-bus)(\\.|\\])').test(h) ||
|
|
619
|
+
new RegExp('^\\[mcp_servers\\."(' + esc + '|ensemble-bus)"(\\.|\\])').test(h);
|
|
620
|
+
const lines = text.split(/\r?\n/);
|
|
621
|
+
const out = [];
|
|
622
|
+
let skipping = false;
|
|
623
|
+
for (const line of lines) {
|
|
624
|
+
const trimmed = line.trim();
|
|
625
|
+
if (/^\[mcp_servers\./.test(trimmed)) {
|
|
626
|
+
skipping = ownTable(trimmed); // our table or a child of it — drop; other servers stay
|
|
627
|
+
if (skipping) continue;
|
|
628
|
+
} else if (skipping && /^\[/.test(trimmed)) {
|
|
629
|
+
skipping = false; // a different table begins — keep this line
|
|
630
|
+
}
|
|
631
|
+
if (!skipping) out.push(line);
|
|
632
|
+
}
|
|
633
|
+
while (out.length && out[out.length - 1].trim() === '') out.pop();
|
|
634
|
+
const argsToml = argsArr.map((a) => '"' + String(a).replace(/\\/g, '/') + '"').join(', ');
|
|
635
|
+
const cmd = command.replace(/\\/g, '/');
|
|
636
|
+
out.push('');
|
|
637
|
+
out.push(header);
|
|
638
|
+
out.push('command = "' + cmd + '"');
|
|
639
|
+
out.push('args = [' + argsToml + ']');
|
|
640
|
+
out.push('enabled = true');
|
|
641
|
+
out.push('');
|
|
642
|
+
fs.writeFileSync(filePath, out.join('\n'));
|
|
643
|
+
// Verify the write stuck (catches partial writes / ACL issues).
|
|
644
|
+
const written = fs.readFileSync(filePath, 'utf8');
|
|
645
|
+
if (!written.includes(header) || !written.includes(cmd)) {
|
|
646
|
+
warn('TOML MCP write verification failed for ' + filePath);
|
|
647
|
+
return false;
|
|
648
|
+
}
|
|
649
|
+
return true;
|
|
650
|
+
} catch (e) {
|
|
651
|
+
warn('could not write TOML MCP config ' + filePath + ': ' + (e.message || e));
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/// Upsert mcpServers.<name> in a JSON settings file (Gemini / OMP / Antigravity).
|
|
657
|
+
function upsertJsonMcp(filePath, serverName, command, argsArr, extras) {
|
|
658
|
+
try {
|
|
659
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
660
|
+
let j = {};
|
|
661
|
+
if (fs.existsSync(filePath)) {
|
|
662
|
+
try { j = JSON.parse(fs.readFileSync(filePath, 'utf8')); }
|
|
663
|
+
catch (e) { return skipMalformedJson(filePath, 'MCP config', e); }
|
|
664
|
+
}
|
|
665
|
+
if (!j || typeof j !== 'object') j = {};
|
|
666
|
+
if (extras && extras.$schema && !j.$schema) j.$schema = extras.$schema;
|
|
667
|
+
// Support mcp.servers, mcpServers map
|
|
668
|
+
if (j.mcp && typeof j.mcp === 'object' && !Array.isArray(j.mcp) && j.mcp.servers) {
|
|
669
|
+
j.mcp.servers[serverName] = { command, args: argsArr, type: (extras && extras.type) || 'stdio' };
|
|
670
|
+
} else {
|
|
671
|
+
j.mcpServers = j.mcpServers || {};
|
|
672
|
+
delete j.mcpServers['ensemble-bus'];
|
|
673
|
+
const entry = { command, args: argsArr };
|
|
674
|
+
if (extras && extras.type) entry.type = extras.type;
|
|
675
|
+
j.mcpServers[serverName] = entry;
|
|
676
|
+
}
|
|
677
|
+
fs.writeFileSync(filePath, JSON.stringify(j, null, 2) + '\n');
|
|
678
|
+
return true;
|
|
679
|
+
} catch (e) {
|
|
680
|
+
warn('could not write JSON MCP config ' + filePath + ': ' + (e.message || e));
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
221
683
|
}
|
|
222
684
|
|
|
223
685
|
function has(cmd) {
|
|
@@ -226,38 +688,85 @@ function has(cmd) {
|
|
|
226
688
|
return which.status === 0;
|
|
227
689
|
}
|
|
228
690
|
|
|
229
|
-
|
|
691
|
+
// True if a hook handler entry (string command, or an object carrying a command field) is one
|
|
692
|
+
// Ensemble owns — used to dedup on re-run so our entry never stacks.
|
|
693
|
+
function isEnsembleHook(h) {
|
|
694
|
+
const cmd = typeof h === 'string' ? h : (h && (h.command || h.cmd || h.run)) || '';
|
|
695
|
+
// Match ensemble-runtime and any leftover ensemble-bus paths so re-wire strips outdated entries.
|
|
696
|
+
return /ensemble-(bus|runtime)/i.test(String(cmd));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Codex + Grok use the SAME hooks schema as Claude Code: PascalCase event names, each mapping to an
|
|
700
|
+
// array of matcher-groups { matcher?, hooks: [{ type: "command", command }] }. (Verified against
|
|
701
|
+
// Codex's config-advanced docs and Grok's bundled ~/.grok/docs hooks guide.) The old FLAT
|
|
702
|
+
// lowercase-scalar form we wrote ({ "session-start": "<cmd>" }) is silently ignored by both engines,
|
|
703
|
+
// so their SessionStart/Stop/SessionEnd hooks never fired (auto-register / idle-wake / detach dead;
|
|
704
|
+
// only MCP worked). MERGE-preserving: keep other events + the user's own groups on our events, dedup
|
|
705
|
+
// our entry so re-runs don't stack, and migrate away the legacy lowercase keys. [BUG-06673-hooks]
|
|
706
|
+
// Returns true when written, false when skipped (malformed JSON left untouched).
|
|
707
|
+
function writeEngineHooks(file, runtimeInvoke, engine) {
|
|
230
708
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
709
|
+
let existing = {};
|
|
710
|
+
if (fs.existsSync(file)) {
|
|
711
|
+
try { existing = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
712
|
+
catch (e) { return skipMalformedJson(file, engine + ' hooks config', e); }
|
|
713
|
+
}
|
|
714
|
+
if (!existing || typeof existing !== 'object') existing = {};
|
|
715
|
+
const hooks = (existing.hooks && typeof existing.hooks === 'object' && !Array.isArray(existing.hooks))
|
|
716
|
+
? existing.hooks : {};
|
|
717
|
+
// Remove the legacy flat lowercase keys we used to write (both engines ignore them). Preserve any
|
|
718
|
+
// non-Ensemble handler a user happened to place on a lowercase key; drop only ours.
|
|
719
|
+
for (const legacy of ['session-start', 'user-prompt', 'stop', 'session-end']) {
|
|
720
|
+
const v = hooks[legacy];
|
|
721
|
+
if (typeof v === 'string' && isEnsembleHook(v)) delete hooks[legacy];
|
|
722
|
+
else if (Array.isArray(v)) {
|
|
723
|
+
const rest = v.filter((h) => !isEnsembleHook(h));
|
|
724
|
+
if (rest.length === 0) delete hooks[legacy]; else hooks[legacy] = rest;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// JSON key = the PascalCase event the engine matches on; the command's `hook <ev>` arg stays
|
|
728
|
+
// lowercase — that's what our runtime's hook dispatcher parses. No bash suffix: Codex/Grok run the
|
|
729
|
+
// command directly and the hook handler is already silent-safe on failure.
|
|
730
|
+
const events = { SessionStart: 'session-start', UserPromptSubmit: 'user-prompt', Stop: 'stop', SessionEnd: 'session-end' };
|
|
731
|
+
for (const [evt, ev] of Object.entries(events)) {
|
|
732
|
+
const command = `"${runtimeInvoke}" hook ${ev} --engine ${engine}`;
|
|
733
|
+
const groups = Array.isArray(hooks[evt]) ? hooks[evt] : (hooks[evt] == null ? [] : [hooks[evt]]);
|
|
734
|
+
const kept = groups
|
|
735
|
+
.filter((g) => g && typeof g === 'object' && !Array.isArray(g))
|
|
736
|
+
.map((g) => ({ ...g, hooks: (Array.isArray(g.hooks) ? g.hooks : []).filter((h) => !isEnsembleHook(h)) }))
|
|
737
|
+
.filter((g) => (g.hooks || []).length > 0 || g.matcher);
|
|
738
|
+
kept.push({ hooks: [{ type: 'command', command }] });
|
|
739
|
+
hooks[evt] = kept;
|
|
740
|
+
}
|
|
741
|
+
existing.hooks = hooks;
|
|
742
|
+
fs.writeFileSync(file, JSON.stringify(existing, null, 2) + '\n');
|
|
743
|
+
return true;
|
|
241
744
|
}
|
|
242
745
|
|
|
243
|
-
|
|
746
|
+
// Returns true when the settings were written, false when skipped (malformed JSON left untouched).
|
|
747
|
+
function mergeClaudeHooks(file, runtimeInvoke) {
|
|
244
748
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
245
749
|
let s = {};
|
|
246
|
-
|
|
750
|
+
if (fs.existsSync(file)) {
|
|
751
|
+
try { s = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
752
|
+
catch (e) { return skipMalformedJson(file, 'Claude settings (~/.claude/settings.json)', e); }
|
|
753
|
+
}
|
|
754
|
+
if (!s || typeof s !== 'object') s = {};
|
|
247
755
|
s.hooks = s.hooks || {};
|
|
248
756
|
// APPEND our bus hook, never replace — preserve the user's other hooks (config sync, etc.).
|
|
249
|
-
// Re-enroll is idempotent: strip any prior ensemble-bus hook first, then add the current one.
|
|
757
|
+
// Re-enroll is idempotent: strip any prior ensemble-bus/ensemble-runtime hook first, then add the current one.
|
|
250
758
|
const events = { SessionStart: 'session-start', UserPromptSubmit: 'user-prompt', Stop: 'stop', SessionEnd: 'session-end' };
|
|
251
759
|
for (const [evt, ev] of Object.entries(events)) {
|
|
252
|
-
const command = `"${
|
|
760
|
+
const command = `"${runtimeInvoke}" hook ${ev} --engine claude 2>/dev/null || true`;
|
|
253
761
|
const groups = Array.isArray(s.hooks[evt]) ? s.hooks[evt] : [];
|
|
254
762
|
const kept = groups
|
|
255
|
-
.map((g) => ({ ...g, hooks: (g.hooks || []).filter((h) =>
|
|
763
|
+
.map((g) => ({ ...g, hooks: (g.hooks || []).filter((h) => !isEnsembleHook(h)) }))
|
|
256
764
|
.filter((g) => (g.hooks || []).length > 0);
|
|
257
765
|
kept.push({ hooks: [{ type: 'command', command }] });
|
|
258
766
|
s.hooks[evt] = kept;
|
|
259
767
|
}
|
|
260
|
-
fs.writeFileSync(file, JSON.stringify(s, null, 2));
|
|
768
|
+
fs.writeFileSync(file, JSON.stringify(s, null, 2) + '\n');
|
|
769
|
+
return true;
|
|
261
770
|
}
|
|
262
771
|
|
|
263
772
|
// --- per-user auto-start of the runner (per OS), plus start it now ---
|
|
@@ -351,7 +860,9 @@ function finishSetup(api, machineToken, runnerPath) {
|
|
|
351
860
|
|
|
352
861
|
module.exports = {
|
|
353
862
|
log, warn, die, platform, DEFAULT_API, ENSEMBLE_DIR, RUNNER_DIR, BIN_DIR,
|
|
354
|
-
ensureBinaries, enrollMachine, writeRunnerConfig, wireHooks, installAutostart, startRunner, request,
|
|
863
|
+
ensureBinaries, enrollMachine, writeRunnerConfig, wireHooks, wireMcpServers, enginePresent, installAutostart, startRunner, request,
|
|
355
864
|
sleep, openBrowser, startDevice, pollDevice, finishSetup, assertSupported,
|
|
356
|
-
runtimePaths, runtimeInstalled, requireRuntime, ensureRuntime,
|
|
865
|
+
runtimePaths, runtimeBin, runtimeInstalled, requireRuntime, ensureRuntime,
|
|
866
|
+
isUsableBinary, removeLegacyBusArtifacts, MIN_BINARY_BYTES,
|
|
867
|
+
writeEngineHooks, upsertTomlMcp, installedVersionCurrent, readInstalledVersion, PKG_VERSION,
|
|
357
868
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webority/ensemble",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "Connect this machine to Ensemble — runs the local agent runner and wires the ensemble
|
|
3
|
+
"version": "0.5.8",
|
|
4
|
+
"description": "Connect this machine to Ensemble — runs the local agent runner and wires the ensemble session bus so your coding sessions talk (per-org, isolated).",
|
|
5
5
|
"bin": {
|
|
6
6
|
"ensemble": "bin/ensemble.js"
|
|
7
7
|
},
|
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
"node": ">=18"
|
|
10
10
|
},
|
|
11
11
|
"optionalDependencies": {
|
|
12
|
-
"@webority/ensemble-darwin-arm64": "0.5.
|
|
13
|
-
"@webority/ensemble-darwin-x64": "0.5.
|
|
14
|
-
"@webority/ensemble-linux-arm64": "0.5.
|
|
15
|
-
"@webority/ensemble-linux-x64": "0.5.
|
|
16
|
-
"@webority/ensemble-win-x64": "0.5.
|
|
12
|
+
"@webority/ensemble-darwin-arm64": "0.5.8",
|
|
13
|
+
"@webority/ensemble-darwin-x64": "0.5.8",
|
|
14
|
+
"@webority/ensemble-linux-arm64": "0.5.8",
|
|
15
|
+
"@webority/ensemble-linux-x64": "0.5.8",
|
|
16
|
+
"@webority/ensemble-win-x64": "0.5.8"
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"bin",
|