@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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.7.0 (2026-10-02)
6
+
7
+ - Includes the Muse installation, managed-kind discovery and isolated readiness FIX/SECURITY changes recorded in the unpublished 0.6.0 preparation below; none add native login qualification.
8
+
9
+ FIX: Re-read source quiescence, conversation, terminal and published sequence immediately before closing on every account move; roll back replacements on change and verify surviving conversations after uncertain cleanup. Published Herdr has no atomic conditional close.
10
+ FIX: Add explicit confirmed, bounded `whenBusy: { busy: 'wait', confirmed, waitMs }` account handoff: move when this step finishes, never interrupt. Preserve default busy refusal, refuse missing sequence/stale identity, stop on blocked approvals, and return typed `interrupt_unsupported` without keys or lifecycle calls. Native working-step qualification remains separate from offline protocol fixtures.
11
+
12
+ ## 0.6.0 (2026-10-02)
13
+
14
+ - SECURITY: Explicit Muse installation uses private staged HOME/XDG/temp paths, a clean environment, bounded HTTPS downloads and owned-process cancellation; it never imports personal credentials, edits shell profiles, or starts automatic login or updates.
15
+ - FIX: Missing Muse on the effective private launch PATH now reports not installed and refuses before pane placement or start instead of showing a fake installation and waiting for the startup timeout. The explicit official installer returns verified local release paths for launch; account/catalog readiness remains unknown.
16
+
17
+ FIX: Discover every pinned agent kind and use its actual account-folder and resume support for sign-in tabs and managed moves. Kinds without a folder override remain one sign-in per computer user, tab only. New move support is fixture-tested, not native-qualified.
18
+
19
+ SECURITY: Readiness no longer probes default CLI logins. Pass `agentStatus(kinds, { folders: { [kind]: appManagedFolder }, env? })` to select an app-owned account; absent or invalid folders return unknown without running a CLI. Status commands receive a clean env with HOME/XDG/platform directories confined to that folder. Direct `runStatusCommand` calls without an explicit HOME return no answer. Injected status runners now receive the resolved executable path and explicit env (not an inherited default login).
20
+
5
21
  ## 0.5.0 (2026-10-01)
6
22
 
7
23
  - SECURITY: StartAgent and openSignInTab accept a clean launch environment with explicit unsets, verified before launch in idle shells on new or existing panes; credential values stay out of terminal commands, argv and errors, preventing inherited API key (billed per use) variables from overriding subscription sign-in.
package/README.md CHANGED
@@ -190,15 +190,36 @@ const moved = await kit.moveToAccount({ paneId: 'w1:p2' }, {
190
190
  if (moved.ok) console.log(moved.session); // new pane to follow
191
191
  ```
192
192
 
193
- A move accepts Claude for Claude folders and Codex for Codex folders. Pi ignores these folder variables and is refused. It verifies the new shell's effective
193
+ A move accepts mapped managed kinds, including Claude (`CLAUDE_CONFIG_DIR`), Codex (`CODEX_HOME`) and Pi
194
+ (`PI_CODING_AGENT_DIR`, `--session <id|absolute path>`). Hosts own cross-account history sharing.
195
+ It verifies the new shell's effective
194
196
  account folder, resumes the conversation there, waits for a ready session, then closes the old pane. A failed
195
197
  start preserves the original. A failed source close rolls back the new pane only after fresh reads verify
196
198
  both conversations' identities. A lost close acknowledgment can mean the original already closed: the kit
197
199
  preserves the replacement when the source is absent, replaced or unreachable and returns `close_failed`.
198
200
  After uncertain source close or cleanup, `live` names a freshly verified surviving conversation; it is omitted
199
201
  when neither conversation can be verified. Check the remaining panes before retrying. Independent changes
200
- can still happen after verification; the server has no conditional close transaction. Failures return a plain `message`. Wait until a conversation is idle or done before
201
- moving it. `StartAgent.env` applies to newly created placements; existing shells cannot receive a new env.
202
+ can still happen after verification; the server has no conditional close transaction. Immediately before close,
203
+ the source must still be idle/done with the same conversation, terminal and published `state_change_seq` as
204
+ before splitting. Otherwise the replacement is closed and `changed` returned; `live` is freshly verified.
205
+ This guard applies to idle moves too. It is a bounded observation, not atomic or a new security guarantee.
206
+ Failures return a plain `message`. By default, working or blocked conversations return `busy`.
207
+
208
+ Both move APIs optionally accept:
209
+ ```ts
210
+ import type { BusyHandoff } from '@byokit/herdr';
211
+ const whenBusy: BusyHandoff = {
212
+ busy: 'wait',
213
+ confirmed: { session: 'published-conversation-id', terminalId: 'published-terminal-id' },
214
+ waitMs: 300_000,
215
+ }; // Pass whenBusy to either move API after the person confirms these current values.
216
+ ```
217
+ Label this **Move when this step finishes**. Confirmation must match the current published session and terminal;
218
+ wait requires a published sequence and finite positive `waitMs` ≤ 300000. Working sources wait for idle/done/blocked
219
+ and are re-read before staging. Timeout returns `busy`, approval returns `blocked`, stale identity returns `changed`,
220
+ and missing sequence or invalid bounds returns `unsupported`. The person may stop the step themselves in the pane.
221
+ `busy: 'interrupt'` (confirmed session/terminal/seq) returns `interrupt_unsupported`, never keys or lifecycle calls.
222
+ Offline fixtures do not qualify real working-step handoff or native Pi moves. `StartAgent.env` applies to newly created placements; existing shells cannot receive a new env.
202
223
  Tokens stay on the device and are never logged. Each provider's own terms apply to how you use your plan.
203
224
 
204
225
  For managed-folder stores that supply resume arguments and credential shedding, use `move`:
@@ -242,15 +263,17 @@ Helpers cover the common paths (start an agent, deliver a prompt with a receipt,
242
263
 
243
264
  `kit.agentStatus(kinds)` reports, per kind, whether the agent's CLI is installed and whether
244
265
  it is signed in — the onboarding agents-and-pickers check. It needs no Herdr connection: it
245
- probes the local machine. Install detection reuses `installedAgentKinds` over the host PATH plus
266
+ probes the explicit path or, for own-mode kits, the managed launch PATH. Adopt-mode inventory
267
+ uses the local machine; it is not evidence of an existing pane's environment. That inventory uses the host PATH plus
246
268
  the extra install dirs a service PATH omits (`extraPathDirs`: `~/.local/bin`, the mise shims,
247
269
  `~/.npm-global/bin`, Homebrew and the system dirs — the set muxr probed before this kit did).
248
270
  Sign-in comes only from each CLI's own documented non-secret status command, with a 10 s timeout;
249
271
  a kind whose CLI has none, or whose command gives no answer, reads `unknown`. Credential files
250
272
  are never opened, read or statted — only the signed-in boolean is kept. Install detection tells a
251
273
  real runnable binary apart from an auto-install launcher: a mise-style shim on PATH, or nothing
252
- on PATH at all, reads `installState: 'installs-on-first-start'` with `installed: false` — Herdr
253
- fetches the agent on first start — instead of a missing-install error. That covers `pi`, which
274
+ on PATH at all, retains the legacy `installState: 'installs-on-first-start'` with `installed: false`.
275
+ **Muse is different:** no executable, an auto-install shim, or an official launcher without its selected native release,
276
+ reads `installState: 'missing'`. Stock Herdr does not install an absent Muse command. That covers `pi`, which
254
277
  Herdr installs via mise: a missing or shimmed `pi` reads `installed: false` with `installs on
255
278
  first start`, while a real `pi` binary reads `installed: true`.
256
279
 
@@ -287,12 +310,71 @@ sniff's file-head reader (tests use fakes); `{ run }` injects the command runner
287
310
  `{ timeoutMs }` bounds each probe. `signInHint` rides kinds the kit knows how to check whenever they
288
311
  are not signed in; `installHint` always rides along.
289
312
 
313
+ ## Explicit private Muse installation
314
+
315
+ `installMuse` is an additive Node helper. Calling it authorizes the official published installer,
316
+ not sign-in or a model request. Show the source (`MUSE_INSTALL_URL`), destination and affected
317
+ files before offering that action. No hidden download happens on import or `startAgent`.
318
+
319
+ ```ts
320
+ import { HerdrKit, installMuse, museReadiness } from '@byokit/herdr';
321
+
322
+ const installed = await installMuse({
323
+ home: '/app-owned/private/muse',
324
+ path: ['/usr/bin', '/bin'], // clean host-selected tools; no inherited environment
325
+ // installDir: '/app-owned/private/muse/tools', signal, timeoutMs: 180_000
326
+ });
327
+ if (installed.ok) {
328
+ const { env, version } = installed.receipt;
329
+ console.log(museReadiness(env), version); // installation only, signedIn stays unknown
330
+ const kit = new HerdrKit({ mode: 'adopt', bin: '/app-owned/tools/herdr',
331
+ socketPath: '/app-owned/herdr.sock' }); // the app's already running stock Herdr
332
+ await kit.start();
333
+ // Existing env-replacement/rollback guards still apply. Use this complete environment
334
+ // rather than shell rc files, including for an existing pane you explicitly prepare.
335
+ await kit.startAgent({ kind: 'muse', cwd: '/app-owned/project',
336
+ place: { tab: 'new', workspaceId: 'project' }, env: { env, unset: [] } });
337
+ } else {
338
+ console.log(installed.code, installed.message); // e.g. protected_download, cancelled
339
+ }
340
+ ```
341
+
342
+ Only absolute app-managed private homes/targets are allowed. The destination must be inside
343
+ that home; symlink/escape/default-home/system targets are refused. Bash/curl are required on
344
+ the explicit tool PATH. The unmodified official HTTPS installer runs in a fresh staging
345
+ HOME/XDG/temp, with `MUSE_INSTALL_DIR`, `MUSE_NO_MODIFY_PATH=1`, `MUSE_LOGIN=0`, no inherited
346
+ URL/auth/profile overrides and a bounded curl policy. No credentials are copied or read from
347
+ the caller's existing home. HTTPS-only redirects (at most three), per-download 60 s/512 MiB,
348
+ a total deadline (default 180 s, maximum 300 s) and owned-process-group cancellation are enforced.
349
+ No shell profile or persistent PATH is modified.
350
+
351
+ Success requires both the executable launcher and selected executable `muse-bin-<version>`;
352
+ the receipt records the native release version, SHA-256 hashes, actual paths and launch env.
353
+ A working installation is reused without updates. A failed/cancelled new install leaves no
354
+ partial target; an existing incomplete target is preserved/refused (choose a fresh directory).
355
+ The returned environment disables automatic updates and login. Hosts own these private paths
356
+ and must avoid concurrent writers; filesystem validation is not an OS sandbox for publisher code.
357
+ Linux/macOS x64/arm64 are supported; Windows returns `unsupported_platform`.
358
+
359
+ Known-missing Muse refuses with `agent_not_installed` / `launchFailed.reason: 'not-installed'`
360
+ **before placement/start**. Preflight uses the explicit per-call environment, otherwise the
361
+ kit-owned launch environment for a new pane. Controller-global inventory/probe overrides
362
+ never prove readiness on a known private launch PATH. An unobserved existing/adopted pane
363
+ environment remains unknown (`museReadiness()`), not installed. Exact typed RPC pass-through
364
+ is unchanged; direct stock `agent.start` has upstream behavior.
365
+
366
+ Installation adds no folder/resume/native-move capability: Muse remains tab-only metadata.
367
+ It does not prove sign-in, account catalog or model readiness. The official public model name
368
+ is `muse-spark-1.3-contributor`; the installed account's own catalog still requires separate
369
+ owner qualification. No provider/model fallback is performed. Previously inspected published
370
+ kit versions 0.3.0 and 0.5.0 lack this helper/fix; this source change is not a published-version claim.
371
+
290
372
  ## Agent start lifecycle
291
373
 
292
374
  `startAgent` keeps its typed shape and rejection, and additionally reports the launch as events
293
375
  so an app shows `Installing…` instead of a blank start: `installing` (with the progress words)
294
376
  before a start that needs an install, `ready` with the fresh ref, and `launchFailed` with a typed
295
- `reason` (`placement-failed` | `pane-busy` | `install-failed` | `start-rejected`) plus plain words.
377
+ `reason` (`placement-failed` | `pane-busy` | `install-failed` | `start-rejected` | `not-installed`) plus plain words.
296
378
  Subscribe per call with `onEvent`, or app-wide with `kit.onStartAgent` — no polling either way.
297
379
 
298
380
  ```ts
package/dist/accounts.js CHANGED
@@ -1,7 +1,9 @@
1
1
  // Account-specific CLI panes (D11/6.6). No credentials are read or copied. Moves follow
2
2
  // the decided start-then-close order; only fake transports exercise this in tests.
3
3
  import { randomBytes } from 'node:crypto';
4
+ import { isAbsolute } from 'node:path';
4
5
  import { words } from "./words.js";
6
+ import { accountKind } from "./kinds.js";
5
7
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
6
8
  export function createAccountPanes(ctx) {
7
9
  const moving = new Set();
@@ -62,8 +64,17 @@ export function createAccountPanes(ctx) {
62
64
  }
63
65
  }
64
66
  async function openSignInTab(o) {
65
- const { workspaceId, label, ...start } = o;
67
+ const { workspaceId, label, folder, ...start } = o;
66
68
  try {
69
+ if (folder !== undefined) {
70
+ const variable = accountKind(start.kind)?.folderVar;
71
+ if (!variable || !folder || /[\r\n\0]/.test(folder))
72
+ throw new Error('unsupported folder');
73
+ const launch = start.env;
74
+ start.env = launch && typeof launch.env === 'object' && Array.isArray(launch.unset)
75
+ ? { env: { ...launch.env, [variable]: folder }, unset: launch.unset }
76
+ : { ...launch, [variable]: folder };
77
+ }
67
78
  return await ctx.startAgent({ ...start, place: { tab: 'new', workspaceId,
68
79
  ...(label === undefined ? {} : { label }) } });
69
80
  }
@@ -85,19 +96,69 @@ export function createAccountPanes(ctx) {
85
96
  catch {
86
97
  return failed('too_early');
87
98
  }
88
- const session = agent?.agent_session;
99
+ const handoff = o.whenBusy;
100
+ const optedIn = handoff?.busy === 'wait' || handoff?.busy === 'interrupt';
101
+ let session = agent?.agent_session;
89
102
  if (!agent || agent.launch_pending === true || !session || typeof session.value !== 'string'
90
103
  || session.value.length === 0)
91
104
  return failed('too_early');
92
- if (!['idle', 'done'].includes(agent.agent_status))
105
+ if (agent.agent_status === 'blocked')
106
+ return failed(optedIn ? 'blocked' : 'busy');
107
+ if (!['idle', 'done'].includes(agent.agent_status) && !(optedIn && agent.agent_status === 'working'))
93
108
  return failed('busy');
109
+ if (optedIn) {
110
+ if (!handoff.confirmed || handoff.confirmed.session !== session.value
111
+ || handoff.confirmed.terminalId !== agent.terminal_id)
112
+ return failed('changed');
113
+ if (typeof agent.terminal_id !== 'string' || agent.terminal_id.length === 0)
114
+ return failed('unsupported');
115
+ if (handoff.busy === 'interrupt') {
116
+ if (handoff.confirmed.seq !== agent.state_change_seq)
117
+ return failed('changed');
118
+ return failed('interrupt_unsupported');
119
+ }
120
+ if (!Number.isFinite(handoff.waitMs) || handoff.waitMs <= 0 || handoff.waitMs > 300_000
121
+ || !Number.isSafeInteger(agent.state_change_seq) || agent.state_change_seq < 0)
122
+ return failed('unsupported');
123
+ if (agent.agent_status === 'working') {
124
+ try {
125
+ await call('agent.wait', { target: target.paneId, until: ['idle', 'done', 'blocked'],
126
+ timeout_ms: handoff.waitMs }, handoff.waitMs + 5000);
127
+ }
128
+ catch {
129
+ return failed('busy');
130
+ }
131
+ let current;
132
+ try {
133
+ current = (await call('agent.get', { target: target.paneId }))?.agent;
134
+ }
135
+ catch {
136
+ return failed('changed', null);
137
+ }
138
+ if (!current || current.launch_pending === true || current.agent !== agent.agent
139
+ || current.terminal_id !== agent.terminal_id || current.agent_session?.agent !== session.agent
140
+ || current.agent_session?.kind !== session.kind || current.agent_session?.value !== session.value)
141
+ return failed('changed', null);
142
+ if (current.agent_status === 'blocked')
143
+ return failed('blocked');
144
+ if (!['idle', 'done'].includes(current.agent_status))
145
+ return failed('busy');
146
+ if (!Number.isSafeInteger(current.state_change_seq) || current.state_change_seq < 0)
147
+ return failed('unsupported');
148
+ agent = current;
149
+ session = current.agent_session;
150
+ }
151
+ }
94
152
  const kind = agent.agent;
95
- if (!['claude', 'codex'].includes(o.provider) || kind !== o.provider
96
- || (request !== undefined && request.kind !== kind)
97
- || session.agent !== kind || session.kind !== 'id')
153
+ const metadata = accountKind(o.provider);
154
+ if (!metadata?.folderVar || !metadata.resume || kind !== metadata.kind
155
+ || (request !== undefined && accountKind(request.kind)?.kind !== kind)
156
+ || session.agent !== kind || !metadata.sessionKinds.includes(session.kind))
157
+ return failed('unsupported');
158
+ if (/[\x00-\x1f\x7f]/.test(session.value) || session.value.length > (session.kind === 'path' ? 4096 : 512)
159
+ || (session.kind === 'path' && !isAbsolute(session.value)))
98
160
  return failed('unsupported');
99
- const args = request?.args ?? (kind === 'claude' && session.kind === 'id' ? ['--resume', session.value]
100
- : kind === 'codex' && session.kind === 'id' ? ['resume', session.value] : undefined);
161
+ const args = request?.args ?? metadata.resumeArgs?.map((arg) => arg.replace('{session}', () => session.value));
101
162
  if (args === undefined || !Array.isArray(args) || args.length === 0 || args.some((a) => typeof a !== 'string' || /[\r\n\0]/.test(a)))
102
163
  return failed('unsupported');
103
164
  if (!o.folder || /[\r\n\0]/.test(o.folder))
@@ -105,7 +166,7 @@ export function createAccountPanes(ctx) {
105
166
  const timeout = o.timeoutMs ?? 60_000;
106
167
  if (!Number.isFinite(timeout) || timeout <= 0)
107
168
  return failed('start_failed');
108
- const variable = o.provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME';
169
+ const variable = metadata.folderVar;
109
170
  let paneId;
110
171
  let replacement;
111
172
  try {
@@ -158,25 +219,43 @@ export function createAccountPanes(ctx) {
158
219
  const cleaned = await close(paneId);
159
220
  return failed('start_failed', cleaned ? target.paneId : paneId);
160
221
  }
222
+ // Compare identity, not revision: status changes can legitimately bump revision.
223
+ const survives = async (id, expected) => {
224
+ try {
225
+ const current = (await call('agent.get', { target: id }, timeout))?.agent;
226
+ return !!current && current.agent === expected.agent && current.launch_pending !== true
227
+ && ['idle', 'working', 'blocked', 'done'].includes(current.agent_status)
228
+ && current.agent_session?.agent === expected.agent_session?.agent
229
+ && current.agent_session?.kind === expected.agent_session?.kind
230
+ && current.agent_session?.value === expected.agent_session?.value
231
+ && (expected.terminal_id === undefined || current.terminal_id === expected.terminal_id)
232
+ && (expected.name === undefined || current.name === expected.name);
233
+ }
234
+ catch {
235
+ return false;
236
+ } // unavailable is not proof of survival
237
+ };
238
+ let current;
239
+ try {
240
+ current = (await call('agent.get', { target: target.paneId }, timeout))?.agent;
241
+ }
242
+ catch {
243
+ current = {};
244
+ }
245
+ if (!current || current.launch_pending === true || !['idle', 'done'].includes(current.agent_status)
246
+ || current.agent_status !== agent.agent_status || current.agent !== agent.agent || current.terminal_id !== agent.terminal_id
247
+ || current.agent_session?.agent !== session.agent || current.agent_session?.kind !== session.kind
248
+ || current.agent_session?.value !== session.value
249
+ || (agent.state_change_seq !== undefined && current.state_change_seq !== agent.state_change_seq)) {
250
+ await close(paneId);
251
+ const live = await survives(paneId, replacement) ? paneId
252
+ : await survives(target.paneId, agent) ? target.paneId : null;
253
+ return failed('changed', live);
254
+ }
255
+ // Published Herdr has no conditional close: the final observation is not atomic.
161
256
  if (!await close(target.paneId)) {
162
257
  // A lost ACK can mean the close already applied. Never destroy the ready
163
258
  // replacement unless a fresh read proves the original conversation survived.
164
- // Compare identity, not revision: status changes can legitimately bump revision.
165
- const survives = async (id, expected) => {
166
- try {
167
- const current = (await call('agent.get', { target: id }, timeout))?.agent;
168
- return !!current && current.agent === expected.agent && current.launch_pending !== true
169
- && ['idle', 'working', 'blocked', 'done'].includes(current.agent_status)
170
- && current.agent_session?.agent === expected.agent_session?.agent
171
- && current.agent_session?.kind === expected.agent_session?.kind
172
- && current.agent_session?.value === expected.agent_session?.value
173
- && (expected.terminal_id === undefined || current.terminal_id === expected.terminal_id)
174
- && (expected.name === undefined || current.name === expected.name);
175
- }
176
- catch {
177
- return false;
178
- } // unavailable is not proof of survival
179
- };
180
259
  const replacementAlive = await survives(paneId, replacement);
181
260
  const sourceAlive = await survives(target.paneId, agent);
182
261
  if (sourceAlive && replacementAlive)
@@ -199,10 +278,10 @@ export function createAccountPanes(ctx) {
199
278
  }
200
279
  }
201
280
  async function move(o) {
202
- const variable = o.kind === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME';
281
+ const variable = accountKind(o.kind)?.folderVar;
203
282
  const result = await perform({ paneId: o.paneId }, {
204
- provider: o.kind === 'claude' ? 'claude' : 'codex', folder: o.set[variable] ?? '',
205
- env: o.set, timeoutMs: o.timeoutMs,
283
+ provider: o.kind, folder: variable === undefined ? '' : o.set[variable] ?? '',
284
+ env: o.set, timeoutMs: o.timeoutMs, whenBusy: o.whenBusy,
206
285
  }, o);
207
286
  return result.ok ? { ok: true, paneId: result.session } : result;
208
287
  }
package/dist/agents.d.ts CHANGED
@@ -6,6 +6,7 @@ export declare function createAgents(ctx: {
6
6
  snapshot(): HerdrSnapshot;
7
7
  reread(paneId: string): Promise<void>;
8
8
  emitStart?(e: AgentStartEvent): void;
9
+ launchEnv?(): Record<string, string> | undefined;
9
10
  }): Pick<HerdrKit, 'startAgent' | 'prompt' | 'sendKeys' | 'wait' | 'read' | 'agentKinds' | 'installedAgentKinds' | 'agentStatus'>;
10
11
  export declare function resolveAgentBinary(kind: string, o: {
11
12
  path: string[];
@@ -29,6 +30,7 @@ export declare function agentProbePath(path?: string[], home?: string): string[]
29
30
  export declare function runStatusCommand(command: string, args: string[], o?: {
30
31
  stdin?: string;
31
32
  timeoutMs?: number;
33
+ env?: Record<string, string>;
32
34
  }): Promise<{
33
35
  stdout: string;
34
36
  } | undefined>;
package/dist/agents.js CHANGED
@@ -3,11 +3,13 @@
3
3
  // disagree the schema wins. `pane.split` takes `target_pane_id` (muxr agrees), and `agent.start`
4
4
  // has no `env` param — a start's env belongs to the placement create/split call (src/generated/methods.ts).
5
5
  import { spawn } from 'node:child_process';
6
- import { accessSync, constants as fsConstants, openSync, readSync, closeSync } from 'node:fs';
6
+ import { accessSync, constants as fsConstants, openSync, readSync, closeSync, statSync } from 'node:fs';
7
7
  import { homedir } from 'node:os';
8
- import { delimiter, join } from 'node:path';
8
+ import { delimiter, isAbsolute, join } from 'node:path';
9
9
  import { words } from "./words.js";
10
+ import { accountKind } from "./kinds.js";
10
11
  import { prepareLaunchEnv } from "./launch-env.js";
12
+ import { museNative, MUSE_INSTALL_URL } from "./muse.js";
11
13
  const PROMPTABLE = ['idle', 'working', 'blocked', 'done'];
12
14
  const DEFAULT_TIMEOUT_MS = 60_000;
13
15
  // `agent.start` lands while the fresh pane is still at its shell prompt: the server answers
@@ -62,19 +64,37 @@ export function createAgents(ctx) {
62
64
  catch { /* a listener never breaks a start */ }
63
65
  emitStart(e);
64
66
  };
65
- // Install pre-check: a shim or nothing on PATH means Herdr installs on first start, so the
66
- // app hears `installing` before the long start instead of watching a blank pane.
67
67
  let installExpected = false;
68
- try {
69
- installExpected = agentInstallState(o.kind, o.installProbe).state === 'installs-on-first-start';
70
- }
71
- catch {
72
- installExpected = false;
73
- }
74
- if (installExpected)
75
- emit({ phase: 'installing', kind: o.kind, message: words('agent.installing', { agent: o.kind }) });
76
68
  let stage = 'placement';
77
69
  try {
70
+ if (o.kind === 'muse') {
71
+ const replacement = o.env && typeof o.env.env === 'object' && Array.isArray(o.env.unset)
72
+ ? o.env : undefined;
73
+ const existingPane = 'pane' in o.place && o.worktree === undefined;
74
+ // An ordinary env overlay cannot replace an existing shell's environment.
75
+ if (existingPane && !replacement && Object.keys(o.env ?? {}).length > 0) {
76
+ throw fail('env_mismatch', 'This pane needs to be opened again to use that sign-in.');
77
+ }
78
+ const effective = replacement?.env ?? (existingPane ? undefined
79
+ : { ...ctx.launchEnv?.(), ...o.env });
80
+ // No controller/global extras and no probe override when the effective PATH is known.
81
+ // Existing unobserved panes remain unknown; the exact RPC pass-through remains available.
82
+ const path = effective?.PATH?.split(delimiter).filter(isAbsolute);
83
+ const probe = path === undefined ? (existingPane ? undefined : o.installProbe) : { path };
84
+ if (probe && agentInstallState('muse', probe).state === 'missing') {
85
+ throw fail('agent_not_installed', words('agent.notInstalled'));
86
+ }
87
+ }
88
+ else {
89
+ try {
90
+ installExpected = agentInstallState(o.kind, o.installProbe).state === 'installs-on-first-start';
91
+ }
92
+ catch {
93
+ installExpected = false;
94
+ }
95
+ }
96
+ if (installExpected)
97
+ emit({ phase: 'installing', kind: o.kind, message: words('agent.installing', { agent: o.kind }) });
78
98
  const ref = await startAgentInner(o, () => { stage = 'start'; });
79
99
  emit({ phase: 'ready', kind: o.kind, ref });
80
100
  return ref;
@@ -82,6 +102,8 @@ export function createAgents(ctx) {
82
102
  catch (error) {
83
103
  const reason = classifyStartFailure(error, { installExpected, stage });
84
104
  emit({ phase: 'launchFailed', kind: o.kind, reason, message: launchFailureWords(reason, o.kind) });
105
+ if (codeOf(error) === 'agent_not_installed')
106
+ throw error;
85
107
  if (o.env && typeof o.env.env === 'object')
86
108
  throw fail(codeOf(error) === 'env_mismatch' ? 'env_mismatch' : 'start_failed', words('agent.notReady'));
87
109
  throw error;
@@ -247,7 +269,7 @@ export function createAgents(ctx) {
247
269
  return kinds.filter((kind) => resolveAgentBinary(kind, o).path !== undefined);
248
270
  }
249
271
  async function agentStatus(kinds, o) {
250
- const path = o?.path ?? agentProbePath();
272
+ const path = o?.path ?? ctx.launchEnv?.()?.PATH?.split(delimiter) ?? agentProbePath();
251
273
  const run = o?.run ?? runStatusCommand;
252
274
  const timeoutMs = o?.timeoutMs ?? STATUS_TIMEOUT_MS;
253
275
  const probe = { path, ...(o?.aliases === undefined ? {} : { aliases: o.aliases }),
@@ -255,18 +277,27 @@ export function createAgents(ctx) {
255
277
  return Promise.all(kinds.map(async (kind) => {
256
278
  const install = agentInstallState(kind, probe);
257
279
  const binary = o?.aliases?.[kind]?.[0] ?? kind;
258
- const installHint = kind === 'pi' ? PI_INSTALL_HINT : `Install the ${binary} command, then check again.`;
259
- // A shim or nothing on PATH means Herdr installs on first start: not installed, with the
260
- // readiness words instead of a missing-install error (pi included — a real pi binary is
261
- // the only case that reads installed).
280
+ const installHint = kind === 'muse' ? `Install Muse explicitly from ${MUSE_INSTALL_URL} into the private launch environment.`
281
+ : kind === 'pi' ? PI_INSTALL_HINT : `Install the ${binary} command, then check again.`;
282
+ // Muse missing/launcher-only stays missing; legacy kinds retain their shim readiness.
283
+ // Install state never proves account or catalog readiness.
262
284
  if (install.state !== 'installed')
263
285
  return { kind, installed: false, installState: install.state, signedIn: 'unknown', installHint };
264
286
  const statusProbe = STATUS_PROBES[kind];
265
- if (statusProbe === undefined)
287
+ const variable = accountKind(kind)?.folderVar;
288
+ const folder = o?.folders?.[kind];
289
+ if (statusProbe === undefined || variable === undefined || !folder || !isAbsolute(folder) || /[\r\n\0]/.test(folder))
266
290
  return { kind, installed: true, installState: 'installed', signedIn: 'unknown', installHint };
267
291
  let answer;
292
+ // A folder override alone is not isolation: the CLI can fall back to HOME,
293
+ // XDG or platform stores. Supply a clean env and confine every home to this account.
294
+ const env = { ...o?.env, PATH: path.join(delimiter), HOME: folder, USERPROFILE: folder,
295
+ XDG_CONFIG_HOME: join(folder, '.config'), XDG_STATE_HOME: join(folder, '.local', 'state'),
296
+ XDG_DATA_HOME: join(folder, '.local', 'share'), XDG_CACHE_HOME: join(folder, '.cache'),
297
+ APPDATA: join(folder, 'AppData', 'Roaming'), LOCALAPPDATA: join(folder, 'AppData', 'Local'),
298
+ [variable]: folder };
268
299
  try {
269
- answer = await run(statusProbe.command, statusProbe.args, { ...(statusProbe.stdin === undefined ? {} : { stdin: statusProbe.stdin }), timeoutMs });
300
+ answer = await run(install.path, statusProbe.args, { ...(statusProbe.stdin === undefined ? {} : { stdin: statusProbe.stdin }), timeoutMs, env });
270
301
  }
271
302
  catch {
272
303
  answer = undefined;
@@ -329,14 +360,27 @@ export function isAutoInstallShim(file, readFile = readHead) {
329
360
  }
330
361
  return head !== undefined && head.startsWith('#!') && head.includes('mise');
331
362
  }
332
- // Per-kind install readiness sharing the B5 probe path: `installed` for a real runnable binary,
333
- // `installs-on-first-start` for an auto-install launcher/shim or nothing on PATH (Herdr fetches
334
- // the agent on first start). `missing` stays for callers that probe kinds Herdr never installs.
363
+ // Muse has no absent-CLI auto-installer in stock Herdr. Its official launcher needs the
364
+ // selected native release too. Other kinds retain their legacy shim classification.
335
365
  export function agentInstallState(kind, o) {
336
366
  const path = o?.path ?? agentProbePath();
337
367
  const found = resolveAgentBinary(kind, { path, ...(o?.aliases === undefined ? {} : { aliases: o.aliases }) });
338
368
  if (found.path === undefined)
339
- return { kind, state: 'installs-on-first-start' };
369
+ return { kind, state: kind === 'muse' ? 'missing' : 'installs-on-first-start' };
370
+ if (kind === 'muse') {
371
+ try {
372
+ if (!statSync(found.path).isFile())
373
+ return { kind, state: 'missing', path: found.path };
374
+ }
375
+ catch {
376
+ return { kind, state: 'missing', path: found.path };
377
+ }
378
+ const head = (o?.readFile ?? readHead)(found.path);
379
+ if (head?.startsWith('#!') && (head.includes('mise') || (head.includes('MUSE_CHANNEL') && !museNative(found.path)))) {
380
+ return { kind, state: 'missing', path: found.path };
381
+ }
382
+ return { kind, state: 'installed', path: found.path };
383
+ }
340
384
  const shim = isAutoInstallShim(found.path, o?.readFile);
341
385
  return shim ? { kind, state: 'installs-on-first-start', path: found.path }
342
386
  : { kind, state: 'installed', path: found.path };
@@ -346,6 +390,8 @@ export function agentInstallState(kind, o) {
346
390
  // finished, a placement that made no pane, or any other rejected start.
347
391
  export function classifyStartFailure(error, o) {
348
392
  const code = codeOf(error);
393
+ if (code === 'agent_not_installed')
394
+ return 'not-installed';
349
395
  if (code !== undefined && START_RETRYABLE.has(code))
350
396
  return 'pane-busy';
351
397
  if (o?.stage === 'placement')
@@ -355,6 +401,8 @@ export function classifyStartFailure(error, o) {
355
401
  return 'start-rejected';
356
402
  }
357
403
  function launchFailureWords(reason, kind) {
404
+ if (reason === 'not-installed')
405
+ return words('agent.notInstalled');
358
406
  if (reason === 'pane-busy')
359
407
  return words('agent.notReady');
360
408
  if (reason === 'install-failed')
@@ -396,7 +444,7 @@ const STATUS_PROBES = {
396
444
  }
397
445
  if (!isRecord(status))
398
446
  return 'unknown';
399
- return status.loggedIn === true ? 'yes' : 'no';
447
+ return status.loggedIn === true ? 'yes' : status.loggedIn === false ? 'no' : 'unknown';
400
448
  } },
401
449
  // Codex exposes sign-in only over its app-server protocol, so the probe pipes a pipelined
402
450
  // `initialize` + `account/read` round (same method muxr's planIdentity uses) and reads the
@@ -413,7 +461,10 @@ const STATUS_PROBES = {
413
461
  continue;
414
462
  }
415
463
  if (isRecord(message) && message.id === 2) {
416
- return isRecord(message.result) && isRecord(message.result.account) ? 'yes' : 'no';
464
+ if (!isRecord(message.result))
465
+ return 'unknown';
466
+ return isRecord(message.result.account) ? 'yes'
467
+ : message.result.account === null || !Object.hasOwn(message.result, 'account') ? 'no' : 'unknown';
417
468
  }
418
469
  }
419
470
  return 'unknown';
@@ -422,8 +473,12 @@ const STATUS_PROBES = {
422
473
  // Default runner: one bounded spawn per probe, stdin piped when the protocol needs it (codex).
423
474
  // Failures (missing binary, timeout, non-empty stderr, empty stdout) read as no answer — never throw.
424
475
  export async function runStatusCommand(command, args, o) {
476
+ // Public direct calls without an explicit isolated HOME are not allowed to read a login.
477
+ if (!o?.env?.HOME || !isAbsolute(o.env.HOME) || /[\r\n\0]/.test(o.env.HOME))
478
+ return undefined;
425
479
  return new Promise((resolve) => {
426
480
  let done = false;
481
+ let timer;
427
482
  const finish = (value) => {
428
483
  if (done)
429
484
  return;
@@ -433,14 +488,14 @@ export async function runStatusCommand(command, args, o) {
433
488
  };
434
489
  let child;
435
490
  try {
436
- child = spawn(command, args, { stdio: ['pipe', 'pipe', 'ignore'], timeout: o?.timeoutMs ?? STATUS_TIMEOUT_MS });
491
+ child = spawn(command, args, { env: o.env, stdio: ['pipe', 'pipe', 'ignore'], timeout: o.timeoutMs ?? STATUS_TIMEOUT_MS });
437
492
  }
438
493
  catch {
439
494
  finish(undefined);
440
495
  return;
441
496
  }
442
497
  let out = '';
443
- const timer = setTimeout(() => { child.kill('SIGKILL'); finish(undefined); }, (o?.timeoutMs ?? STATUS_TIMEOUT_MS) + 500);
498
+ timer = setTimeout(() => { child.kill('SIGKILL'); finish(undefined); }, (o?.timeoutMs ?? STATUS_TIMEOUT_MS) + 500);
444
499
  child.on('error', () => finish(undefined));
445
500
  const stdout = child.stdout;
446
501
  const stdin = child.stdin;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
+ export { installMuse, museReadiness, MUSE_INSTALL_URL } from './muse.ts';
2
+ export type { InstallMuseOptions, MuseReadiness, MuseInstallReceipt, MuseInstallResult } from './muse.ts';
1
3
  export { HERDR_VERSION, HERDR_PROTOCOL } from './constants.ts';
4
+ export { accountKinds, accountKind, type AccountKind } from './kinds.ts';
2
5
  export { HerdrKit } from './kit.ts';
3
6
  export { agentWords, stateWords, words, WORDS, type WordKey } from './words.ts';
4
7
  export { agentProbePath, extraPathDirs, runStatusCommand, agentInstallState, isAutoInstallShim, resolveAgentBinary, classifyStartFailure } from './agents.ts';
5
- export type { AgentCliSignIn, AgentInstallProbe, AgentInstallState, AgentLaunchFailureReason, AgentReadiness, AgentStartEvent, AgentStatusOptions, AgentStatusRunner, AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrMethods, HerdrParams, HerdrProtocolRange, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, HerdrSubscribeStop, HerdrTransport, Move, MoveToAccount, MoveToAccountResult, MoveResult, OpenSignInTab, PromptReceipt, StartAgent, TerminalSession, AgentTurnEnd, AgentTurnOptions, AgentTurnFiles, AgentTurnResult, AgentTurnResultPolicy, } from './types.ts';
8
+ export type { AgentCliSignIn, AgentInstallProbe, AgentInstallState, AgentLaunchFailureReason, AgentReadiness, AgentStartEvent, AgentStatusOptions, AgentStatusRunner, AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrMethods, HerdrParams, HerdrProtocolRange, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, HerdrSubscribeStop, HerdrTransport, BusyHandoff, Move, MoveToAccount, MoveToAccountResult, MoveResult, OpenSignInTab, PromptReceipt, StartAgent, TerminalSession, AgentTurnEnd, AgentTurnOptions, AgentTurnFiles, AgentTurnResult, AgentTurnResultPolicy, } from './types.ts';
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // `@byokit/herdr` — the Herdr runtime kit (Node host side). The phone and browser side lives in `./device`, the
2
2
  // host-side link adapter in `./link`; the fake Herdr and contract suite arrive with H6 in `./testing`.
3
+ export { installMuse, museReadiness, MUSE_INSTALL_URL } from "./muse.js";
3
4
  export { HERDR_VERSION, HERDR_PROTOCOL } from "./constants.js";
5
+ export { accountKinds, accountKind } from "./kinds.js";
4
6
  export { HerdrKit } from "./kit.js";
5
7
  export { agentWords, stateWords, words, WORDS } from "./words.js";
6
8
  export { agentProbePath, extraPathDirs, runStatusCommand, agentInstallState, isAutoInstallShim, resolveAgentBinary, classifyStartFailure } from "./agents.js";
@@ -0,0 +1,20 @@
1
+ /** Pinned capability data, not login readiness or native move qualification. */
2
+ export type AccountKind = {
3
+ kind: string;
4
+ aliases: string[];
5
+ aliasPattern?: string;
6
+ folderVar?: string;
7
+ resume: boolean;
8
+ sessionKinds: ('id' | 'path')[];
9
+ resumeArgs?: string[];
10
+ loginLabel: string;
11
+ billing: 'unknown';
12
+ offer: 'explicit';
13
+ upstream: {
14
+ version: string;
15
+ revision: string;
16
+ source: string;
17
+ };
18
+ };
19
+ export declare function accountKinds(): AccountKind[];
20
+ export declare function accountKind(kind: string): AccountKind | undefined;
package/dist/kinds.js ADDED
@@ -0,0 +1,6 @@
1
+ import table from './kinds.json' with { type: 'json' };
2
+ export function accountKinds() { return structuredClone(table); }
3
+ export function accountKind(kind) {
4
+ return accountKinds().find((row) => row.kind === kind || row.aliases.includes(kind)
5
+ || (row.aliasPattern !== undefined && new RegExp(row.aliasPattern).test(kind)));
6
+ }