@webority/ensemble 0.5.10 → 0.5.12
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/lib/core.js +102 -18
- package/package.json +9 -6
package/lib/core.js
CHANGED
|
@@ -14,8 +14,9 @@ const DEFAULT_API = process.env.ENSEMBLE_API || 'https://api.ensemble.host';
|
|
|
14
14
|
// Public host for the prebuilt binaries (runner + bus). Branded CDN in front of the blob
|
|
15
15
|
// (Cloudflare Worker → ensembledl blob; the raw blob URL still works as a fallback). Overridable.
|
|
16
16
|
const DL_BASE = process.env.ENSEMBLE_DL_BASE || 'https://storage.ensemble.host/cli';
|
|
17
|
-
// RIDs we
|
|
18
|
-
//
|
|
17
|
+
// RIDs we publish a prebuilt runner + runtime for (npm platform packages + the DL_BASE fallback).
|
|
18
|
+
// Keep in sync with the build pipeline (publish-cli.yml). win-arm64 is not built yet — ARM Windows
|
|
19
|
+
// runs the win-x64 build under emulation, since platform() maps every win32 host to win-x64.
|
|
19
20
|
const SUPPORTED_PLATFORMS = ['win-x64', 'osx-arm64', 'osx-x64', 'linux-x64', 'linux-arm64'];
|
|
20
21
|
|
|
21
22
|
function log(msg) { process.stdout.write(msg + '\n'); }
|
|
@@ -171,8 +172,13 @@ function restartRunner(pf, runnerPath) {
|
|
|
171
172
|
if (pf.os === 'windows') {
|
|
172
173
|
spawnSync('taskkill', ['/IM', 'Ensemble.Runner.exe', '/F'], { stdio: 'ignore' });
|
|
173
174
|
startRunner(runnerPath);
|
|
175
|
+
} else if (pf.os === 'mac') {
|
|
176
|
+
installAutostart(runnerPath); // launchctl unload+load kills + respawns onto the new binary
|
|
174
177
|
} else {
|
|
175
|
-
|
|
178
|
+
// systemd: `enable --now` is a no-op on an already-running unit, so the old process keeps its
|
|
179
|
+
// old inode after the binary is swapped — force an explicit restart onto the new build.
|
|
180
|
+
installAutostart(runnerPath);
|
|
181
|
+
spawnSync('systemctl', ['--user', 'restart', 'ensemble-runner.service'], { stdio: 'ignore' });
|
|
176
182
|
}
|
|
177
183
|
} catch (e) { /* best-effort — the new binary is on disk and runs on next launch */ }
|
|
178
184
|
}
|
|
@@ -317,9 +323,24 @@ async function ensureBinaries(opts) {
|
|
|
317
323
|
if (pf.os !== 'windows') {
|
|
318
324
|
fs.chmodSync(t.dest, 0o755);
|
|
319
325
|
// macOS requires at least an ad-hoc signature to run an unsigned binary.
|
|
320
|
-
if (pf.os === 'mac')
|
|
326
|
+
if (pf.os === 'mac') {
|
|
327
|
+
const sign = spawnSync('codesign', ['-s', '-', '-f', t.dest], { stdio: 'ignore' });
|
|
328
|
+
if (sign.status !== 0) {
|
|
329
|
+
warn('codesign failed for ' + path.basename(t.dest) + ' — macOS may refuse to run it (' +
|
|
330
|
+
(sign.error ? sign.error.message : 'exit ' + sign.status) + '). Install Xcode Command Line Tools.');
|
|
331
|
+
}
|
|
332
|
+
}
|
|
321
333
|
}
|
|
322
334
|
}
|
|
335
|
+
// A ≥1MB size check (isUsableBinary) does NOT prove the runtime matches this CPU — a wrong-arch copy
|
|
336
|
+
// passes it and then every hook silently dies with "bad CPU type". Prove it actually executes before
|
|
337
|
+
// we trust it, so a bad download / wrong platform package / stale CDN blob fails the install loudly.
|
|
338
|
+
const smoke = spawnSync(runtimePath, ['--help'], { stdio: 'ignore', timeout: 5000 });
|
|
339
|
+
if (smoke.error || smoke.status == null) {
|
|
340
|
+
throw new Error('the Ensemble runtime at ' + runtimePath + ' does not run on this machine (' + pf.key +
|
|
341
|
+
') — likely a wrong-arch or corrupt binary' + (smoke.error ? ': ' + smoke.error.message : '') +
|
|
342
|
+
'. Delete ~/.ensemble/bin and re-run `ensemble install`.');
|
|
343
|
+
}
|
|
323
344
|
removeLegacyBusArtifacts();
|
|
324
345
|
writeInstalledVersion();
|
|
325
346
|
// A replaced runner binary only takes effect once the daemon restarts onto it.
|
|
@@ -346,9 +367,16 @@ function writeRunnerConfig(api, machineToken) {
|
|
|
346
367
|
Logging: { LogLevel: { Default: 'Information', 'Microsoft.Hosting.Lifetime': 'Information' } },
|
|
347
368
|
Ensemble: { HubUrl: hubUrl, MachineToken: machineToken },
|
|
348
369
|
};
|
|
349
|
-
|
|
370
|
+
// appsettings.json embeds the plaintext machine token (cfg.Ensemble.MachineToken) — write it 0600,
|
|
371
|
+
// same as the dotfile below; a default-umask write left it world-readable on shared machines.
|
|
372
|
+
const appsettingsPath = path.join(RUNNER_DIR, 'appsettings.json');
|
|
373
|
+
fs.writeFileSync(appsettingsPath, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
350
374
|
// ensemble-runtime reads ENSEMBLE_MACHINE_TOKEN from env — persist it to the shell rc + a dotfile.
|
|
351
|
-
|
|
375
|
+
const tokenPath = path.join(ENSEMBLE_DIR, 'machine-token');
|
|
376
|
+
fs.writeFileSync(tokenPath, machineToken, { mode: 0o600 });
|
|
377
|
+
// `mode` only takes effect when the file is newly created — re-tighten on every enroll so a file an
|
|
378
|
+
// older build left at 0644 (or a manual edit widened) can't keep leaking the token.
|
|
379
|
+
try { fs.chmodSync(appsettingsPath, 0o600); fs.chmodSync(tokenPath, 0o600); } catch (e) { /* best-effort (Windows) */ }
|
|
352
380
|
const pf = platform();
|
|
353
381
|
if (pf.os === 'windows') {
|
|
354
382
|
spawnSync('setx', ['ENSEMBLE_MACHINE_TOKEN', machineToken], { stdio: 'ignore' });
|
|
@@ -356,7 +384,7 @@ function writeRunnerConfig(api, machineToken) {
|
|
|
356
384
|
const rc = path.join(HOME, pf.os === 'mac' ? '.zshrc' : '.bashrc');
|
|
357
385
|
try {
|
|
358
386
|
let cur = fs.existsSync(rc) ? fs.readFileSync(rc, 'utf8') : '';
|
|
359
|
-
cur = cur.replace(
|
|
387
|
+
cur = cur.replace(/(^|\n)export ENSEMBLE_MACHINE_TOKEN=.*\n?/g, '\n');
|
|
360
388
|
if (!cur.endsWith('\n')) cur += '\n';
|
|
361
389
|
cur += `export ENSEMBLE_MACHINE_TOKEN="${machineToken}"\n`;
|
|
362
390
|
if (!/\.ensemble\/bin/.test(cur)) cur += `export PATH="$HOME/.ensemble/bin:$PATH"\n`;
|
|
@@ -364,6 +392,36 @@ function writeRunnerConfig(api, machineToken) {
|
|
|
364
392
|
} catch (e) { /* best-effort */ }
|
|
365
393
|
}
|
|
366
394
|
|
|
395
|
+
function ensureWindowsRuntimePath() {
|
|
396
|
+
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === 'path') || 'Path';
|
|
397
|
+
const currentProcessPath = process.env[pathKey] || '';
|
|
398
|
+
const normalize = (value) => value.replace(/[\\/]+$/, '').toLowerCase();
|
|
399
|
+
if (!currentProcessPath.split(';').some((entry) => normalize(entry) === normalize(BIN_DIR))) {
|
|
400
|
+
process.env[pathKey] = [currentProcessPath, BIN_DIR].filter(Boolean).join(';');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const script =
|
|
404
|
+
"$ErrorActionPreference = 'Stop'; " +
|
|
405
|
+
'$target = $env:ENSEMBLE_BIN_TO_ADD; ' +
|
|
406
|
+
"$current = [Environment]::GetEnvironmentVariable('Path', 'User'); " +
|
|
407
|
+
"$entries = @($current -split ';' | Where-Object { $_ }); " +
|
|
408
|
+
"if (-not ($entries | Where-Object { [string]::Equals($_.TrimEnd('\\'), $target.TrimEnd('\\'), " +
|
|
409
|
+
"[StringComparison]::OrdinalIgnoreCase) })) { " +
|
|
410
|
+
"[Environment]::SetEnvironmentVariable('Path', (($entries + $target) -join ';'), 'User') }";
|
|
411
|
+
const result = spawnSync(
|
|
412
|
+
'powershell.exe',
|
|
413
|
+
['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script],
|
|
414
|
+
{
|
|
415
|
+
encoding: 'utf8',
|
|
416
|
+
env: { ...process.env, ENSEMBLE_BIN_TO_ADD: BIN_DIR },
|
|
417
|
+
},
|
|
418
|
+
);
|
|
419
|
+
if (result.status !== 0) {
|
|
420
|
+
throw new Error('could not add ' + BIN_DIR + ' to the Windows user PATH: ' +
|
|
421
|
+
((result.stderr || result.error || 'unknown error').toString().trim()));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
367
425
|
// --- wire ensemble-runtime into each installed harness (hooks + MCP) ---
|
|
368
426
|
// Fleet engines: Claude, Codex, Grok, OpenCode, OMP (oh-my-pi), Gemini, Antigravity (when present).
|
|
369
427
|
// Hooks = mail at turn boundaries. MCP = who/send/read/reply tools every turn.
|
|
@@ -399,7 +457,11 @@ function wireHooks() {
|
|
|
399
457
|
if (mergeClaudeHooks(path.join(HOME, '.claude', 'settings.json'), runtimeInvoke)) report.push('claude:hooks');
|
|
400
458
|
}
|
|
401
459
|
if (enginePresent('codex')) {
|
|
402
|
-
|
|
460
|
+
const windowsRuntime = pf.os === 'windows' ? path.basename(runtimeExe) : null;
|
|
461
|
+
if (windowsRuntime) ensureWindowsRuntimePath();
|
|
462
|
+
if (writeEngineHooks(path.join(HOME, '.codex', 'hooks.json'), runtimeInvoke, 'codex', windowsRuntime)) {
|
|
463
|
+
report.push('codex:hooks');
|
|
464
|
+
}
|
|
403
465
|
}
|
|
404
466
|
if (enginePresent('grok')) {
|
|
405
467
|
if (writeEngineHooks(path.join(HOME, '.grok', 'hooks', 'agent-bus.json'), runtimeInvoke, 'grok')) report.push('grok:hooks');
|
|
@@ -704,8 +766,9 @@ function isEnsembleHook(h) {
|
|
|
704
766
|
// only MCP worked). MERGE-preserving: keep other events + the user's own groups on our events, dedup
|
|
705
767
|
// our entry so re-runs don't stack, and migrate away the legacy lowercase keys. [BUG-06673-hooks]
|
|
706
768
|
// Returns true when written, false when skipped (malformed JSON left untouched).
|
|
707
|
-
function writeEngineHooks(file, runtimeInvoke, engine) {
|
|
708
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
769
|
+
function writeEngineHooks(file, runtimeInvoke, engine, windowsRuntime) {
|
|
770
|
+
try { fs.mkdirSync(path.dirname(file), { recursive: true }); }
|
|
771
|
+
catch (e) { warn('could not create ' + engine + ' hooks dir for ' + file + ': ' + (e.message || e)); return false; }
|
|
709
772
|
let existing = {};
|
|
710
773
|
if (fs.existsSync(file)) {
|
|
711
774
|
try { existing = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
@@ -729,7 +792,8 @@ function writeEngineHooks(file, runtimeInvoke, engine) {
|
|
|
729
792
|
// command directly and the hook handler is already silent-safe on failure.
|
|
730
793
|
const events = { SessionStart: 'session-start', UserPromptSubmit: 'user-prompt', Stop: 'stop', SessionEnd: 'session-end' };
|
|
731
794
|
for (const [evt, ev] of Object.entries(events)) {
|
|
732
|
-
const
|
|
795
|
+
const runtimeCommand = windowsRuntime || `"${runtimeInvoke}"`;
|
|
796
|
+
const command = `${runtimeCommand} hook ${ev} --engine ${engine}`;
|
|
733
797
|
const groups = Array.isArray(hooks[evt]) ? hooks[evt] : (hooks[evt] == null ? [] : [hooks[evt]]);
|
|
734
798
|
const kept = groups
|
|
735
799
|
.filter((g) => g && typeof g === 'object' && !Array.isArray(g))
|
|
@@ -739,13 +803,19 @@ function writeEngineHooks(file, runtimeInvoke, engine) {
|
|
|
739
803
|
hooks[evt] = kept;
|
|
740
804
|
}
|
|
741
805
|
existing.hooks = hooks;
|
|
742
|
-
|
|
743
|
-
|
|
806
|
+
try {
|
|
807
|
+
fs.writeFileSync(file, JSON.stringify(existing, null, 2) + '\n');
|
|
808
|
+
return true;
|
|
809
|
+
} catch (e) {
|
|
810
|
+
warn('could not write ' + engine + ' hooks config ' + file + ': ' + (e.message || e));
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
744
813
|
}
|
|
745
814
|
|
|
746
815
|
// Returns true when the settings were written, false when skipped (malformed JSON left untouched).
|
|
747
816
|
function mergeClaudeHooks(file, runtimeInvoke) {
|
|
748
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
817
|
+
try { fs.mkdirSync(path.dirname(file), { recursive: true }); }
|
|
818
|
+
catch (e) { warn('could not create Claude settings dir for ' + file + ': ' + (e.message || e)); return false; }
|
|
749
819
|
let s = {};
|
|
750
820
|
if (fs.existsSync(file)) {
|
|
751
821
|
try { s = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
@@ -765,8 +835,13 @@ function mergeClaudeHooks(file, runtimeInvoke) {
|
|
|
765
835
|
kept.push({ hooks: [{ type: 'command', command }] });
|
|
766
836
|
s.hooks[evt] = kept;
|
|
767
837
|
}
|
|
768
|
-
|
|
769
|
-
|
|
838
|
+
try {
|
|
839
|
+
fs.writeFileSync(file, JSON.stringify(s, null, 2) + '\n');
|
|
840
|
+
return true;
|
|
841
|
+
} catch (e) {
|
|
842
|
+
warn('could not write Claude settings ' + file + ': ' + (e.message || e));
|
|
843
|
+
return false;
|
|
844
|
+
}
|
|
770
845
|
}
|
|
771
846
|
|
|
772
847
|
// --- per-user auto-start of the runner (per OS), plus start it now ---
|
|
@@ -795,7 +870,11 @@ function installAutostart(runnerPath) {
|
|
|
795
870
|
<key>StandardErrorPath</key><string>${path.join(RUNNER_DIR, 'runner.log')}</string>
|
|
796
871
|
</dict></plist>`);
|
|
797
872
|
spawnSync('launchctl', ['unload', plist], { stdio: 'ignore' });
|
|
798
|
-
spawnSync('launchctl', ['load', plist], { stdio: 'ignore' });
|
|
873
|
+
const load = spawnSync('launchctl', ['load', plist], { stdio: 'ignore' });
|
|
874
|
+
if (load.error || load.status !== 0) {
|
|
875
|
+
warn('could not load the launchd runner agent (' +
|
|
876
|
+
(load.error ? load.error.message : 'exit ' + load.status) + ') — autostart may not be active.');
|
|
877
|
+
}
|
|
799
878
|
} else {
|
|
800
879
|
const unitDir = path.join(HOME, '.config', 'systemd', 'user');
|
|
801
880
|
fs.mkdirSync(unitDir, { recursive: true });
|
|
@@ -811,7 +890,12 @@ RestartSec=5
|
|
|
811
890
|
[Install]
|
|
812
891
|
WantedBy=default.target`);
|
|
813
892
|
spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
|
|
814
|
-
spawnSync('systemctl', ['--user', 'enable', '--now', 'ensemble-runner.service'], { stdio: 'ignore' });
|
|
893
|
+
const enable = spawnSync('systemctl', ['--user', 'enable', '--now', 'ensemble-runner.service'], { stdio: 'ignore' });
|
|
894
|
+
if (enable.error || enable.status !== 0) {
|
|
895
|
+
warn('could not enable the systemd --user runner service (' +
|
|
896
|
+
(enable.error ? enable.error.message : 'exit ' + enable.status) +
|
|
897
|
+
') — no systemd --user session? Autostart may not be active.');
|
|
898
|
+
}
|
|
815
899
|
spawnSync('loginctl', ['enable-linger', os.userInfo().username], { stdio: 'ignore' });
|
|
816
900
|
}
|
|
817
901
|
}
|
package/package.json
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webority/ensemble",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.12",
|
|
4
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
|
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "node --test"
|
|
10
|
+
},
|
|
8
11
|
"engines": {
|
|
9
12
|
"node": ">=18"
|
|
10
13
|
},
|
|
11
14
|
"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.
|
|
15
|
+
"@webority/ensemble-darwin-arm64": "0.5.12",
|
|
16
|
+
"@webority/ensemble-darwin-x64": "0.5.12",
|
|
17
|
+
"@webority/ensemble-linux-arm64": "0.5.12",
|
|
18
|
+
"@webority/ensemble-linux-x64": "0.5.12",
|
|
19
|
+
"@webority/ensemble-win-x64": "0.5.12"
|
|
17
20
|
},
|
|
18
21
|
"files": [
|
|
19
22
|
"bin",
|