@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 +30 -9
- package/bin/ensemble.js +102 -15
- package/lib/core.js +574 -57
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# @webority/ensemble
|
|
2
2
|
|
|
3
|
-
Connect a machine to your [Ensemble](https://ensemble.host)
|
|
4
|
-
runner** (runs your AI coding sessions, appears in your fleet) and wires **ensemble
|
|
5
|
-
coding CLIs
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
portal), downloads + configures + auto-starts the runner, and for **each detected coding agent**:
|
|
30
|
+
|
|
31
|
+
| Agent | Hooks | MCP |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| Claude | `~/.claude/settings.json` | `~/.claude.json` → `ensemble` |
|
|
34
|
+
| Codex | `~/.codex/hooks.json` | `~/.codex/config.toml` |
|
|
35
|
+
| Grok | `~/.grok/hooks/agent-bus.json` | `~/.grok/config.toml` |
|
|
36
|
+
| OpenCode | (MCP primary) | `~/.config/opencode/opencode.json` → `mcp.ensemble` |
|
|
37
|
+
| OMP (oh-my-pi) | `~/.omp/agent/ensemble-hooks.json` | `~/.omp/agent/mcp.json` |
|
|
38
|
+
| Gemini | best-effort | `~/.gemini/settings.json` |
|
|
39
|
+
| Antigravity | `~/.antigravity/hooks.json` | `~/.antigravity/mcp.json` |
|
|
40
|
+
|
|
41
|
+
1. **Hooks** — mail at turn boundaries
|
|
42
|
+
2. **MCP** — `ensemble mcp --engine <e>` as server name **`ensemble`**
|
|
43
|
+
|
|
44
|
+
Fleet playbook: `docs/FLEET.md`.
|
|
45
|
+
**Your subscription logins never leave your machine.** Restart open agent sessions after enroll/hooks.
|
|
31
46
|
|
|
32
47
|
## Commands
|
|
33
|
-
- `ensemble enroll --token <t
|
|
34
|
-
- `ensemble
|
|
35
|
-
- `ensemble
|
|
48
|
+
- `ensemble login` / `ensemble enroll --token <t>` — auth + install runtime + wire hooks/MCP
|
|
49
|
+
- `ensemble install` — force-refresh runner/runtime into `~/.ensemble`; re-wires hooks/MCP if already enrolled
|
|
50
|
+
- `ensemble status` — connection, engines detected, Grok MCP path check
|
|
51
|
+
- `ensemble hooks` — re-wire **hooks + MCP** into every detected coding CLI (safe on existing configs)
|
|
52
|
+
|
|
53
|
+
### Grok / Codex MCP (existing config)
|
|
54
|
+
When `~/.grok/config.toml` (or Codex) already exists, install **upserts** only
|
|
55
|
+
`[mcp_servers.ensemble]` — other servers (e.g. weborityos) and UI/cli sections are kept.
|
|
56
|
+
Command points at `~/.ensemble/bin/ensemble-runtime` (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
|
|
49
|
+
core.log('• wired hooks+MCP: ' + (wired.join(', ') || '(no supported coding CLI found — run `ensemble hooks` after installing agents)'));
|
|
23
50
|
core.log('• installed auto-start + launched the runner');
|
|
24
|
-
core.log('\n✓ Done. This machine runs your Ensemble runner
|
|
25
|
-
core.log('
|
|
51
|
+
core.log('\n✓ Done. This machine runs your Ensemble runner; sessions share the bus (hooks + MCP).');
|
|
52
|
+
core.log(' Restart open coding agents so they load the ensemble MCP server.');
|
|
53
|
+
core.log(' Supported: claude, codex, grok, opencode, omp (oh-my-pi), gemini, antigravity.');
|
|
26
54
|
}
|
|
27
55
|
|
|
28
56
|
// Browser sign-in (device flow) — no token to copy. The recommended path.
|
|
@@ -56,15 +84,26 @@ async function waitForApproval(api, grant) {
|
|
|
56
84
|
core.die('timed out waiting for approval — nothing was installed. Run `ensemble login` again.');
|
|
57
85
|
}
|
|
58
86
|
|
|
59
|
-
// Explicit (re)install of the runner +
|
|
87
|
+
// Explicit (re)install of the runner + runtime into ~/.ensemble. Always refreshes binaries so
|
|
88
|
+
// upgrades replace stubs / old builds. If already enrolled, re-wires hooks+MCP (Grok/Codex/… with
|
|
89
|
+
// existing config.toml get [mcp_servers.ensemble] upserted without wiping other servers).
|
|
90
|
+
// `npm install` does NOT populate ~/.ensemble — there is no postinstall.
|
|
60
91
|
async function install() {
|
|
61
92
|
core.assertSupported();
|
|
62
|
-
await core.ensureBinaries();
|
|
63
|
-
|
|
93
|
+
await core.ensureBinaries({ force: true });
|
|
94
|
+
const tokenFile = path.join(core.ENSEMBLE_DIR, 'machine-token');
|
|
95
|
+
if (fs.existsSync(tokenFile)) {
|
|
96
|
+
const wired = core.wireHooks();
|
|
97
|
+
core.log('✓ Ensemble runtime reinstalled.');
|
|
98
|
+
core.log('• re-wired hooks+MCP: ' + (wired.join(', ') || '(no coding CLI detected)'));
|
|
99
|
+
core.log(' Restart open coding agents so they load the ensemble MCP server.');
|
|
100
|
+
} else {
|
|
101
|
+
core.log('✓ Ensemble runtime installed. Next: ensemble login');
|
|
102
|
+
}
|
|
64
103
|
}
|
|
65
104
|
|
|
66
|
-
// Headless / CI path: enrol with a copy-pasted enrollment token.
|
|
67
|
-
//
|
|
105
|
+
// Headless / CI path: enrol with a copy-pasted enrollment token. The runtime is populated into
|
|
106
|
+
// ~/.ensemble here (ensureRuntime) if it isn't already — same as `ensemble install`/`login`.
|
|
68
107
|
async function enroll(args) {
|
|
69
108
|
const token = args.token || process.env.ENSEMBLE_ENROLL_TOKEN;
|
|
70
109
|
if (!token) core.die('missing --token <enrollment-token> — get it from your Ensemble portal → Connect Runner, or use `ensemble login` for browser sign-in');
|
|
@@ -80,13 +119,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
|
|
89
|
-
mbx.slice(0,
|
|
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
|
|
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 +
|
|
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
|
|
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') {
|
|
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
|
-
|
|
85
|
+
runtimePath: path.join(BIN_DIR, 'ensemble-runtime' + pf.exe),
|
|
86
86
|
runnerPath: path.join(RUNNER_DIR, 'Ensemble.Runner' + pf.exe),
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
// The
|
|
90
|
+
// The ensemble-runtime binary path (the internal .NET CLI the Node wrapper forwards bus subcommands to).
|
|
91
|
+
function runtimeBin() {
|
|
92
|
+
return runtimePaths().runtimePath;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Real runtime/runner single-file binaries are tens of MB. 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
|
-
|
|
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`
|
|
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 +
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
//
|
|
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.
|
|
136
|
-
//
|
|
137
|
-
|
|
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
|
|
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-
|
|
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 (
|
|
150
|
-
|
|
151
|
-
|
|
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,
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = `"${
|
|
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) =>
|
|
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
|
-
"description": "Connect this machine to
|
|
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.
|
|
13
|
-
"@webority/ensemble-darwin-x64": "0.5.
|
|
14
|
-
"@webority/ensemble-linux-arm64": "0.5.
|
|
15
|
-
"@webority/ensemble-linux-x64": "0.5.
|
|
16
|
-
"@webority/ensemble-win-x64": "0.5.
|
|
12
|
+
"@webority/ensemble-darwin-arm64": "0.5.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",
|