@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 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-bus** into your
5
- coding CLIs so your sessions message each other all **scoped to your organization**.
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` 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 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,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 to ' + api + ' your org has ' + mbx.length + ' mailbox(es):');
89
- mbx.slice(0, 25).forEach((m) => core.log(' ' + m.name + ' (' + m.engine + ')'));
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 + bus for this platform
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-bus into installed coding CLIs
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') { core.log('wired: ' + (core.wireHooks().join(', ') || 'none')); }
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
- 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. 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
- const ok = (f) => fs.existsSync(f) && fs.statSync(f).size > 0;
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` (or reinstall @webority/ensemble).');
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 + bus are present, downloading them if this is the first run. Called by
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
- if (runtimeInstalled()) {
108
- return runtimePaths();
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
- // Put the runner + bus in ~/.ensemble (the stable location the daemon, hooks, and autostart use).
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. So `npm i` delivers the binary and `login`
136
- // just copies + wires it.
137
- async function ensureBinaries() {
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 busPath = path.join(BIN_DIR, 'ensemble-bus' + pf.exe);
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-bus' + pf.exe), cdn: 'ensemble-bus-' + pf.key + pf.exe, dest: busPath },
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 (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
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, t.dest);
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
- return { busPath, runnerPath, pf };
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-bus reads ENSEMBLE_MACHINE_TOKEN from env — persist it to the shell rc + a dotfile.
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-bus into each installed harness's session hooks ---
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 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;
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
- function writeEngineHooks(file, busInvoke, engine) {
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
- 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));
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
- function mergeClaudeHooks(file, busInvoke) {
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
- try { if (fs.existsSync(file)) s = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { s = {}; }
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 = `"${busInvoke}" hook ${ev} --engine claude 2>/dev/null || true`;
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) => !/ensemble-bus/i.test(h.command || '')) }))
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.5",
4
- "description": "Connect this machine to Ensemble — runs the local agent runner and wires the ensemble-bus session bus so your coding sessions talk (per-org, isolated).",
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.5",
13
- "@webority/ensemble-darwin-x64": "0.5.5",
14
- "@webority/ensemble-linux-arm64": "0.5.5",
15
- "@webority/ensemble-linux-x64": "0.5.5",
16
- "@webority/ensemble-win-x64": "0.5.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",