@webority/ensemble 0.5.4 → 0.5.7

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