@byokit/herdr 0.5.0 → 0.7.0

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/dist/muse.js ADDED
@@ -0,0 +1,257 @@
1
+ // Explicit, opt-in official Muse installation. No auth, model calls or inherited environment.
2
+ import { spawn } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { accessSync, closeSync, constants, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { delimiter, dirname, isAbsolute, join, resolve, sep } from 'node:path';
7
+ export const MUSE_INSTALL_URL = 'https://dev.meta.ai/install.sh';
8
+ const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+-R[0-9]+(?:\.[0-9]+)?$/;
9
+ const hash = (file) => {
10
+ const fd = openSync(file, 'r');
11
+ const digest = createHash('sha256');
12
+ const buf = Buffer.alloc(64 * 1024);
13
+ try {
14
+ for (;;) {
15
+ const n = readSync(fd, buf);
16
+ if (!n)
17
+ return digest.digest('hex');
18
+ digest.update(buf.subarray(0, n));
19
+ }
20
+ }
21
+ finally {
22
+ closeSync(fd);
23
+ }
24
+ };
25
+ function launcherHead(file) {
26
+ const fd = openSync(file, 'r');
27
+ const buf = Buffer.alloc(2048);
28
+ try {
29
+ return buf.subarray(0, readSync(fd, buf)).toString('utf8');
30
+ }
31
+ finally {
32
+ closeSync(fd);
33
+ }
34
+ }
35
+ const quote = (s) => `'${s.replaceAll("'", "'\\''")}'`;
36
+ /** A launcher alone cannot run without its selected native release. Never executes Muse. */
37
+ export function museNative(launcher) {
38
+ try {
39
+ const state = join(dirname(launcher), '.muse-version');
40
+ if (!lstatSync(state).isFile() || lstatSync(state).size > 128)
41
+ return;
42
+ const version = readFileSync(state, 'utf8').trim();
43
+ if (!VERSION.test(version))
44
+ return;
45
+ const native = join(dirname(launcher), `muse-bin-${version}`);
46
+ const launcherStat = lstatSync(launcher);
47
+ const nativeStat = lstatSync(native);
48
+ if (!launcherStat.isFile() || launcherStat.size > 1024 * 1024
49
+ || !nativeStat.isFile() || nativeStat.size > 512 * 1024 * 1024)
50
+ return;
51
+ accessSync(launcher, constants.X_OK);
52
+ accessSync(native, constants.X_OK);
53
+ return { native, version };
54
+ }
55
+ catch {
56
+ return;
57
+ }
58
+ }
59
+ /** Only the supplied effective pane PATH is probed, never the controller's personal directories. */
60
+ export function museReadiness(env) {
61
+ if (env?.PATH === undefined)
62
+ return { state: 'unknown', signedIn: 'unknown' };
63
+ for (const dir of env.PATH.split(delimiter)) {
64
+ if (!isAbsolute(dir))
65
+ continue;
66
+ const bin = join(dir, 'muse');
67
+ try {
68
+ if (!lstatSync(bin).isFile())
69
+ continue;
70
+ accessSync(bin, constants.X_OK);
71
+ const selected = museNative(bin);
72
+ const head = launcherHead(bin);
73
+ const launcherOnly = head.startsWith('#!') && (head.includes('mise') || (!selected && head.includes('MUSE_CHANNEL')));
74
+ return { state: launcherOnly ? 'launcher-only' : 'installed', signedIn: 'unknown', bin,
75
+ ...(selected ? { version: selected.version } : {}) };
76
+ }
77
+ catch { /* next PATH entry */ }
78
+ }
79
+ return { state: 'missing', signedIn: 'unknown' };
80
+ }
81
+ // Walk existing ancestors before making anything: no symlink directory can redirect writes.
82
+ function safePath(path) {
83
+ if (!isAbsolute(path) || resolve(path) !== path || /[\0\r\n:]/.test(path))
84
+ throw new Error('unsafe_path');
85
+ for (let p = path;; p = dirname(p)) {
86
+ try {
87
+ if (lstatSync(p).isSymbolicLink())
88
+ throw new Error('unsafe_path');
89
+ }
90
+ catch (e) {
91
+ if (e.code !== 'ENOENT')
92
+ throw e;
93
+ }
94
+ if (dirname(p) === p)
95
+ break;
96
+ }
97
+ }
98
+ function tool(name, path) {
99
+ for (const dir of path) {
100
+ const file = join(dir, name);
101
+ try {
102
+ accessSync(file, constants.X_OK);
103
+ return file;
104
+ }
105
+ catch { /* next */ }
106
+ }
107
+ throw new Error('tools_missing');
108
+ }
109
+ // Detached group cancellation kills the owned installer and every curl/bash child, not shared tools.
110
+ async function run(bin, args, env, cwd, signal) {
111
+ signal.throwIfAborted();
112
+ return new Promise((done, reject) => {
113
+ const child = spawn(bin, args, { env, cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
114
+ const kill = () => { if (child.pid)
115
+ try {
116
+ process.kill(-child.pid, 'SIGKILL');
117
+ }
118
+ catch { /* exited */ } };
119
+ const abort = () => kill();
120
+ signal.addEventListener('abort', abort, { once: true });
121
+ let output = '';
122
+ const collect = (chunk) => {
123
+ output += chunk.toString();
124
+ if (output.length > 64 * 1024) {
125
+ output = output.slice(0, 64 * 1024);
126
+ kill();
127
+ }
128
+ };
129
+ child.stdout.on('data', collect);
130
+ child.stderr.on('data', collect);
131
+ child.once('error', (e) => { signal.removeEventListener('abort', abort); reject(e); });
132
+ child.once('close', (code) => {
133
+ signal.removeEventListener('abort', abort);
134
+ // Kill any surviving descendants before removing their private staging files.
135
+ kill();
136
+ if (signal.aborted)
137
+ reject(signal.reason);
138
+ else
139
+ done({ code, output });
140
+ });
141
+ if (signal.aborted)
142
+ kill();
143
+ });
144
+ }
145
+ /** Calling this authorizes the official download/install only. A protected artifact needs separate qualification. */
146
+ export async function installMuse(o) {
147
+ const bad = (code, message) => ({ ok: false, code, message, ...(installerSha256 ? { installerSha256 } : {}) });
148
+ let installerSha256;
149
+ if (!['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64'].includes(`${process.platform}-${process.arch}`)) {
150
+ return bad('unsupported_platform', 'The official private installer is supported here only on Linux and macOS x64/arm64.');
151
+ }
152
+ const dir = o.installDir ?? join(o.home, '.local/bin');
153
+ let stage;
154
+ const signal = AbortSignal.any([...(o.signal ? [o.signal] : []), AbortSignal.timeout(Math.min(300_000, Math.max(1, o.timeoutMs ?? 180_000)))]);
155
+ try {
156
+ safePath(o.home);
157
+ safePath(dir);
158
+ if (o.home === '/' || o.home === '/tmp' || o.home === homedir() || homedir().startsWith(o.home + sep)
159
+ || !dir.startsWith(o.home + sep) || /^\/(?:usr|bin|sbin|etc|opt|var|root|dev|proc|sys|run|lib|lib64|System|Library|Applications)(?:\/|$)/.test(o.home)
160
+ || !o.path.length || o.path.some((p) => !isAbsolute(p) || /[\0\r\n:]/.test(p)))
161
+ throw new Error('unsafe_path');
162
+ try {
163
+ const stat = lstatSync(o.home);
164
+ if (!stat.isDirectory() || (stat.mode & 0o022) !== 0 || (process.getuid && stat.uid !== process.getuid()))
165
+ throw new Error('unsafe_path');
166
+ }
167
+ catch (e) {
168
+ if (e.code !== 'ENOENT')
169
+ throw e;
170
+ }
171
+ const bash = tool('bash', o.path);
172
+ const curl = tool('curl', o.path);
173
+ const path = [dir, ...o.path];
174
+ const env = { HOME: o.home, XDG_CONFIG_HOME: join(o.home, '.config'), XDG_CACHE_HOME: join(o.home, '.cache'),
175
+ XDG_STATE_HOME: join(o.home, '.local/state'), TMPDIR: join(o.home, '.tmp'), PATH: path.join(delimiter),
176
+ LANG: 'C.UTF-8', MUSE_LOGIN: '0', MUSE_NO_AUTO_UPDATE: '1', MUSE_AUTH_PATH: join(o.home, '.config/muse/auth.json') };
177
+ for (const p of [env.XDG_CONFIG_HOME, env.XDG_CACHE_HOME, env.XDG_STATE_HOME, env.TMPDIR, dirname(env.MUSE_AUTH_PATH)])
178
+ safePath(p);
179
+ const receipt = (selected) => ({ ok: true, receipt: {
180
+ bin: join(dir, 'muse'), ...selected, launcherSha256: hash(join(dir, 'muse')), nativeSha256: hash(selected.native),
181
+ ...(installerSha256 ? { installerSha256 } : {}), source: MUSE_INSTALL_URL, path, env,
182
+ } });
183
+ signal.throwIfAborted();
184
+ const existing = museNative(join(dir, 'muse'));
185
+ if (existing) {
186
+ for (const p of [env.XDG_CONFIG_HOME, env.XDG_CACHE_HOME, env.XDG_STATE_HOME, env.TMPDIR])
187
+ mkdirSync(p, { recursive: true, mode: 0o700 });
188
+ return receipt(existing); // No updater and no risk to a working installation.
189
+ }
190
+ // Never replace an existing incomplete directory either: caller may inspect/recover it separately.
191
+ try {
192
+ lstatSync(dir);
193
+ return bad('incomplete_install', 'The target exists without a complete Muse installation; choose a fresh private install directory.');
194
+ }
195
+ catch (e) {
196
+ if (e.code !== 'ENOENT')
197
+ throw e;
198
+ }
199
+ mkdirSync(o.home, { recursive: true, mode: 0o700 });
200
+ stage = mkdtempSync(join(o.home, '.muse-install-'));
201
+ const privateHome = join(stage, 'home');
202
+ const temp = join(stage, 'tmp');
203
+ const tools = join(stage, 'tools');
204
+ for (const p of [privateHome, temp, tools])
205
+ mkdirSync(p, { mode: 0o700 });
206
+ // Official scripts remain byte-for-byte unchanged. This curl policy disables curlrc and bounds
207
+ // their downloads as well as the initial installer (HTTPS, redirects, bytes, connect/total time).
208
+ writeFileSync(join(tools, 'curl'), `#!/bin/sh\nexec ${quote(curl)} -q "$@" --proto '=https' --proto-redir '=https' --max-redirs 3 --connect-timeout 10 --max-time 60 --max-filesize 536870912\n`, { mode: 0o700 });
209
+ const stagedDir = join(stage, 'bin');
210
+ const installEnv = { HOME: privateHome, XDG_CONFIG_HOME: join(privateHome, '.config'), XDG_CACHE_HOME: join(privateHome, '.cache'),
211
+ XDG_STATE_HOME: join(privateHome, '.local/state'), TMPDIR: temp, PATH: [tools, stagedDir, ...o.path].join(delimiter),
212
+ LANG: 'C.UTF-8', MUSE_INSTALL_DIR: stagedDir, MUSE_NO_MODIFY_PATH: '1', MUSE_LOGIN: '0', MUSE_NO_AUTO_UPDATE: '1',
213
+ MUSE_AUTH_PATH: join(privateHome, '.config/muse/auth.json') };
214
+ const installer = join(stage, 'install.sh');
215
+ const fetched = await run(join(tools, 'curl'), ['--fail', '--silent', '--show-error', '--location', '--output', installer, MUSE_INSTALL_URL], installEnv, stage, signal);
216
+ if (fetched.code !== 0)
217
+ return bad('download_failed', 'The official installer download failed.');
218
+ if (!lstatSync(installer).isFile() || lstatSync(installer).size > 1024 * 1024)
219
+ return bad('download_failed', 'The official installer exceeds the allowed script size.');
220
+ installerSha256 = hash(installer);
221
+ const installed = await run(bash, ['--noprofile', '--norc', installer], installEnv, stage, signal);
222
+ if (installed.code !== 0) {
223
+ const denial = /HTTP (401|403|404)/.exec(installed.output);
224
+ return denial ? bad('protected_download', `The official Muse download is protected or unavailable (HTTP ${denial[1]}); no login was attempted.`)
225
+ : bad('install_failed', 'The official Muse installer failed; the previous installation was not changed.');
226
+ }
227
+ const selected = museNative(join(stagedDir, 'muse'));
228
+ if (!selected)
229
+ return bad('incomplete_install', 'The official installer did not produce an executable selected native release.');
230
+ signal.throwIfAborted();
231
+ safePath(o.home);
232
+ safePath(dir);
233
+ mkdirSync(dirname(dir), { recursive: true, mode: 0o700 });
234
+ for (const p of [env.XDG_CONFIG_HOME, env.XDG_CACHE_HOME, env.XDG_STATE_HOME, env.TMPDIR]) {
235
+ safePath(p);
236
+ mkdirSync(p, { recursive: true, mode: 0o700 });
237
+ }
238
+ signal.throwIfAborted();
239
+ renameSync(stagedDir, dir);
240
+ return receipt({ version: selected.version, native: join(dir, `muse-bin-${selected.version}`) });
241
+ }
242
+ catch (e) {
243
+ if (o.signal?.aborted)
244
+ return bad('cancelled', 'Muse installation was cancelled; the previous installation was not changed.');
245
+ if (signal.aborted)
246
+ return bad('timeout', 'Muse installation exceeded its deadline; the previous installation was not changed.');
247
+ if (e.message === 'unsafe_path')
248
+ return bad('unsafe_path', 'Use absolute private app-managed paths without symlinks, inside the selected home.');
249
+ if (e.message === 'tools_missing')
250
+ return bad('tools_missing', 'Bash and curl are required on the explicit tool PATH.');
251
+ return bad('install_failed', 'Muse installation failed; the previous installation was not changed.');
252
+ }
253
+ finally {
254
+ if (stage)
255
+ rmSync(stage, { recursive: true, force: true });
256
+ }
257
+ }
package/dist/types.d.ts CHANGED
@@ -65,6 +65,7 @@ export type AgentReadiness = {
65
65
  export type AgentStatusRunner = (command: string, args: string[], o?: {
66
66
  stdin?: string;
67
67
  timeoutMs?: number;
68
+ env?: Record<string, string>;
68
69
  }) => Promise<{
69
70
  stdout: string;
70
71
  } | undefined>;
@@ -74,8 +75,12 @@ export type AgentStatusOptions = {
74
75
  readFile?: (file: string) => string | undefined;
75
76
  run?: AgentStatusRunner;
76
77
  timeoutMs?: number;
78
+ /** Explicit app-managed account folders. Absent selection never probes a default login. */
79
+ folders?: Record<string, string>;
80
+ /** Clean, host-passed env only; HOME and XDG directories are confined to the selected folder. */
81
+ env?: Record<string, string>;
77
82
  };
78
- export type AgentLaunchFailureReason = 'placement-failed' | 'pane-busy' | 'install-failed' | 'start-rejected';
83
+ export type AgentLaunchFailureReason = 'placement-failed' | 'pane-busy' | 'install-failed' | 'start-rejected' | 'not-installed';
79
84
  export type AgentStartEvent = {
80
85
  phase: 'installing';
81
86
  kind: string;
@@ -127,20 +132,39 @@ export type StartAgent = {
127
132
  export type OpenSignInTab = Omit<StartAgent, 'place' | 'worktree'> & {
128
133
  workspaceId: string;
129
134
  label?: string;
135
+ folder?: string;
136
+ };
137
+ export type BusyHandoff = {
138
+ busy?: 'refuse';
139
+ } | {
140
+ busy: 'wait';
141
+ confirmed: {
142
+ session: string;
143
+ terminalId: string;
144
+ };
145
+ waitMs: number;
146
+ } | {
147
+ busy: 'interrupt';
148
+ confirmed: {
149
+ session: string;
150
+ terminalId: string;
151
+ seq: number;
152
+ };
130
153
  };
131
154
  export type MoveToAccount = {
132
- provider: 'claude' | 'codex';
155
+ provider: string;
133
156
  folder: string;
134
157
  env?: Record<string, string>;
135
158
  direction?: 'right' | 'down';
136
159
  timeoutMs?: number;
160
+ whenBusy?: BusyHandoff;
137
161
  };
138
162
  export type MoveToAccountResult = {
139
163
  ok: true;
140
164
  session: string;
141
165
  } | {
142
166
  ok: false;
143
- code: 'too_early' | 'busy' | 'unsupported' | 'env_mismatch' | 'close_failed' | 'start_failed';
167
+ code: 'too_early' | 'busy' | 'unsupported' | 'env_mismatch' | 'close_failed' | 'start_failed' | 'blocked' | 'changed' | 'interrupt_unsupported';
144
168
  message: string;
145
169
  live?: string;
146
170
  };
@@ -153,6 +177,7 @@ export type Move = {
153
177
  onStaged?(newPaneId: string): void;
154
178
  onReplaced?(newPaneId: string): void;
155
179
  timeoutMs?: number;
180
+ whenBusy?: BusyHandoff;
156
181
  };
157
182
  export type MoveResult = {
158
183
  ok: true;
package/dist/words.json CHANGED
@@ -15,6 +15,7 @@
15
15
  "agent.notReady": "This helper isn't ready yet. Try again in a moment.",
16
16
  "agent.installing": "Installing {agent}…",
17
17
  "agent.installFailed": "Installing {agent} didn't finish. Try again in a moment.",
18
+ "agent.notInstalled": "Install this helper in the app first, then try again.",
18
19
  "agent.launchFailed": "That helper couldn't start. Try again in a moment.",
19
20
  "approval.stale": "That question already changed. Look again before answering.",
20
21
  "close.wouldWiden": "Closing this would close more than you picked. Close the bigger one instead.",
@@ -25,5 +26,8 @@
25
26
  "move.env_mismatch": "The new pane did not receive that sign-in. Try again.",
26
27
  "move.close_failed": "The move could not be confirmed. Check the remaining panes before trying again.",
27
28
  "move.start_failed": "The new account could not take over. Try again.",
29
+ "move.blocked": "This conversation is waiting for your answer. Answer it, then move it.",
30
+ "move.changed": "This conversation changed while moving, so it was not moved. Check your panes, then try again.",
31
+ "move.interrupt_unsupported": "Stopping a step is not available for this agent. Move it when the step finishes.",
28
32
  "turn.failed": "This turn could not be confirmed. Check the helper before trying again."
29
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/herdr",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Drive the Herdr on this computer — workspaces, tabs, panes, agents and blocked-approval answers — from an app, or hand it to a phone over @byokit/link.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",