@byokit/herdr 0.4.1 → 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,26 @@
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
+
21
+ ## 0.5.0 (2026-10-01)
22
+
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.
24
+
5
25
  ## 0.4.1 (2026-10-01)
6
26
 
7
27
  - Dependency update: pins @byokit/relay 0.5.1.
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
@@ -391,3 +473,10 @@ package, never touches a person's other AI tools. Its tests run against a fake H
391
473
  ## License
392
474
 
393
475
  Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](../../NOTICE).
476
+
477
+ `startAgent` and `openSignInTab` accept the accounts `launchEnv()` result as `env: launch`.
478
+ The host and Herdr must share a filesystem. New panes and existing idle POSIX shells apply
479
+ the settings and unsets through a private temporary file before launch; only the file path
480
+ is sent to the terminal. Busy panes and unsupported shells fail closed. Legacy record env
481
+ still travels on new-pane placement, and remains refused on existing panes. Credential
482
+ values must never be supplied in agent args.
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,10 +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";
11
+ import { prepareLaunchEnv } from "./launch-env.js";
12
+ import { museNative, MUSE_INSTALL_URL } from "./muse.js";
10
13
  const PROMPTABLE = ['idle', 'working', 'blocked', 'done'];
11
14
  const DEFAULT_TIMEOUT_MS = 60_000;
12
15
  // `agent.start` lands while the fresh pane is still at its shell prompt: the server answers
@@ -61,19 +64,37 @@ export function createAgents(ctx) {
61
64
  catch { /* a listener never breaks a start */ }
62
65
  emitStart(e);
63
66
  };
64
- // Install pre-check: a shim or nothing on PATH means Herdr installs on first start, so the
65
- // app hears `installing` before the long start instead of watching a blank pane.
66
67
  let installExpected = false;
67
- try {
68
- installExpected = agentInstallState(o.kind, o.installProbe).state === 'installs-on-first-start';
69
- }
70
- catch {
71
- installExpected = false;
72
- }
73
- if (installExpected)
74
- emit({ phase: 'installing', kind: o.kind, message: words('agent.installing', { agent: o.kind }) });
75
68
  let stage = 'placement';
76
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 }) });
77
98
  const ref = await startAgentInner(o, () => { stage = 'start'; });
78
99
  emit({ phase: 'ready', kind: o.kind, ref });
79
100
  return ref;
@@ -81,15 +102,21 @@ export function createAgents(ctx) {
81
102
  catch (error) {
82
103
  const reason = classifyStartFailure(error, { installExpected, stage });
83
104
  emit({ phase: 'launchFailed', kind: o.kind, reason, message: launchFailureWords(reason, o.kind) });
105
+ if (codeOf(error) === 'agent_not_installed')
106
+ throw error;
107
+ if (o.env && typeof o.env.env === 'object')
108
+ throw fail(codeOf(error) === 'env_mismatch' ? 'env_mismatch' : 'start_failed', words('agent.notReady'));
84
109
  throw error;
85
110
  }
86
111
  }
87
112
  async function startAgentInner(o, onStartPhase) {
88
- if ('pane' in o.place && Object.keys(o.env ?? {}).length > 0) {
113
+ const launch = o.env && typeof o.env.env === 'object' && Array.isArray(o.env.unset)
114
+ ? o.env : undefined;
115
+ if (!launch && 'pane' in o.place && Object.keys(o.env ?? {}).length > 0) {
89
116
  throw fail('env_mismatch', 'This pane needs to be opened again to use that sign-in.');
90
117
  }
91
118
  const timeout = o.timeoutMs ?? DEFAULT_TIMEOUT_MS;
92
- const env = o.env === undefined ? {} : { env: o.env };
119
+ const env = o.env === undefined || launch ? {} : { env: o.env };
93
120
  let paneId;
94
121
  let created = false;
95
122
  if (o.worktree !== undefined) {
@@ -127,6 +154,20 @@ export function createAgents(ctx) {
127
154
  }
128
155
  if (paneId === undefined)
129
156
  throw new Error('herdr: the placement answered no pane id');
157
+ if (launch) {
158
+ try {
159
+ await prepareLaunchEnv(call, paneId, launch, timeout);
160
+ }
161
+ catch (error) {
162
+ if (created) {
163
+ try {
164
+ await call('pane.close', { pane_id: paneId });
165
+ }
166
+ catch { /* best effort */ }
167
+ }
168
+ throw error;
169
+ }
170
+ }
130
171
  onStartPhase?.();
131
172
  const name = agentName(o);
132
173
  const params = { pane_id: paneId, kind: o.kind, name,
@@ -148,6 +189,8 @@ export function createAgents(ctx) {
148
189
  }
149
190
  catch { /* rollback is best-effort */ }
150
191
  }
192
+ if (launch)
193
+ throw fail('start_failed', words('agent.notReady'));
151
194
  throw error;
152
195
  }
153
196
  }
@@ -226,7 +269,7 @@ export function createAgents(ctx) {
226
269
  return kinds.filter((kind) => resolveAgentBinary(kind, o).path !== undefined);
227
270
  }
228
271
  async function agentStatus(kinds, o) {
229
- const path = o?.path ?? agentProbePath();
272
+ const path = o?.path ?? ctx.launchEnv?.()?.PATH?.split(delimiter) ?? agentProbePath();
230
273
  const run = o?.run ?? runStatusCommand;
231
274
  const timeoutMs = o?.timeoutMs ?? STATUS_TIMEOUT_MS;
232
275
  const probe = { path, ...(o?.aliases === undefined ? {} : { aliases: o.aliases }),
@@ -234,18 +277,27 @@ export function createAgents(ctx) {
234
277
  return Promise.all(kinds.map(async (kind) => {
235
278
  const install = agentInstallState(kind, probe);
236
279
  const binary = o?.aliases?.[kind]?.[0] ?? kind;
237
- const installHint = kind === 'pi' ? PI_INSTALL_HINT : `Install the ${binary} command, then check again.`;
238
- // A shim or nothing on PATH means Herdr installs on first start: not installed, with the
239
- // readiness words instead of a missing-install error (pi included — a real pi binary is
240
- // 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.
241
284
  if (install.state !== 'installed')
242
285
  return { kind, installed: false, installState: install.state, signedIn: 'unknown', installHint };
243
286
  const statusProbe = STATUS_PROBES[kind];
244
- 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))
245
290
  return { kind, installed: true, installState: 'installed', signedIn: 'unknown', installHint };
246
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 };
247
299
  try {
248
- 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 });
249
301
  }
250
302
  catch {
251
303
  answer = undefined;
@@ -308,14 +360,27 @@ export function isAutoInstallShim(file, readFile = readHead) {
308
360
  }
309
361
  return head !== undefined && head.startsWith('#!') && head.includes('mise');
310
362
  }
311
- // Per-kind install readiness sharing the B5 probe path: `installed` for a real runnable binary,
312
- // `installs-on-first-start` for an auto-install launcher/shim or nothing on PATH (Herdr fetches
313
- // 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.
314
365
  export function agentInstallState(kind, o) {
315
366
  const path = o?.path ?? agentProbePath();
316
367
  const found = resolveAgentBinary(kind, { path, ...(o?.aliases === undefined ? {} : { aliases: o.aliases }) });
317
368
  if (found.path === undefined)
318
- 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
+ }
319
384
  const shim = isAutoInstallShim(found.path, o?.readFile);
320
385
  return shim ? { kind, state: 'installs-on-first-start', path: found.path }
321
386
  : { kind, state: 'installed', path: found.path };
@@ -325,6 +390,8 @@ export function agentInstallState(kind, o) {
325
390
  // finished, a placement that made no pane, or any other rejected start.
326
391
  export function classifyStartFailure(error, o) {
327
392
  const code = codeOf(error);
393
+ if (code === 'agent_not_installed')
394
+ return 'not-installed';
328
395
  if (code !== undefined && START_RETRYABLE.has(code))
329
396
  return 'pane-busy';
330
397
  if (o?.stage === 'placement')
@@ -334,6 +401,8 @@ export function classifyStartFailure(error, o) {
334
401
  return 'start-rejected';
335
402
  }
336
403
  function launchFailureWords(reason, kind) {
404
+ if (reason === 'not-installed')
405
+ return words('agent.notInstalled');
337
406
  if (reason === 'pane-busy')
338
407
  return words('agent.notReady');
339
408
  if (reason === 'install-failed')
@@ -375,7 +444,7 @@ const STATUS_PROBES = {
375
444
  }
376
445
  if (!isRecord(status))
377
446
  return 'unknown';
378
- return status.loggedIn === true ? 'yes' : 'no';
447
+ return status.loggedIn === true ? 'yes' : status.loggedIn === false ? 'no' : 'unknown';
379
448
  } },
380
449
  // Codex exposes sign-in only over its app-server protocol, so the probe pipes a pipelined
381
450
  // `initialize` + `account/read` round (same method muxr's planIdentity uses) and reads the
@@ -392,7 +461,10 @@ const STATUS_PROBES = {
392
461
  continue;
393
462
  }
394
463
  if (isRecord(message) && message.id === 2) {
395
- 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';
396
468
  }
397
469
  }
398
470
  return 'unknown';
@@ -401,8 +473,12 @@ const STATUS_PROBES = {
401
473
  // Default runner: one bounded spawn per probe, stdin piped when the protocol needs it (codex).
402
474
  // Failures (missing binary, timeout, non-empty stderr, empty stdout) read as no answer — never throw.
403
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;
404
479
  return new Promise((resolve) => {
405
480
  let done = false;
481
+ let timer;
406
482
  const finish = (value) => {
407
483
  if (done)
408
484
  return;
@@ -412,14 +488,14 @@ export async function runStatusCommand(command, args, o) {
412
488
  };
413
489
  let child;
414
490
  try {
415
- 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 });
416
492
  }
417
493
  catch {
418
494
  finish(undefined);
419
495
  return;
420
496
  }
421
497
  let out = '';
422
- 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);
423
499
  child.on('error', () => finish(undefined));
424
500
  const stdout = child.stdout;
425
501
  const stdin = child.stdin;