@byokit/herdr 0.5.0 → 0.7.1

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,35 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.7.1 (2026-10-07)
6
+
7
+ - Dependency update: pins @byokit/relay 0.5.3.
8
+ - Dependency update: pins @byokit/link 0.7.1.
9
+
10
+ - `move` and `moveToAccount` no longer type env prep into the new pane: the split placement env is verified silently through `pane.get` (set vars exact, unset vars absent, fail closed), so no `unset` lines, echo probes or account folder values stay in the scrollback after a move.
11
+ - The replacement agent keeps the conversation's name: it starts unique while the source is still live, then takes the source's session name through `agent.rename` once the source pane closes (best-effort; a refused rename never fails the move). New panes no longer show `move-<hex>` names.
12
+ - The quickstart now says what to read next (the kit spec's decisions and work packages, then the two ways in
13
+ and agent readiness). Docs only; no typed surface changed.
14
+ - The `relay` option takes anything with `RelayClient`'s `notify`, so a host can hand the kit a relay it opens after `Host.open` (the client needs the open host). A `RelayClient` still fits; behaviour is unchanged.
15
+ - Unreleased source dependency metadata: pin @byokit/seal 0.3.0 and @byokit/relay 0.5.2; this package's existing version is not republished. Previously published consumer metadata remains unchanged.
16
+ - `startAgent` with a record `env` that sets `HOME` now also places the pane's XDG config, data, state and cache directories under that home (unless set), so a sign-in inside the pane stays in the task-owned home instead of the server's.
17
+
18
+ ## 0.7.0 (2026-10-02)
19
+
20
+ - 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.
21
+
22
+ 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.
23
+ 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.
24
+
25
+ ## 0.6.0 (2026-10-02)
26
+
27
+ - 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.
28
+ - 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.
29
+
30
+ 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.
31
+
32
+ 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).
33
+
5
34
  ## 0.5.0 (2026-10-01)
6
35
 
7
36
  - 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
@@ -145,6 +145,10 @@ for (const q of await hd.blocked()) {
145
145
  The [herdr-kit example](../../examples/herdr-kit) is the full version: pairing with a QR code and typed code, a
146
146
  persistent grant store, and the phone page above.
147
147
 
148
+ Read next: [docs/runtime-kits.md](../../docs/runtime-kits.md) §11 for the decisions and the work packages this
149
+ kit does not have yet, and [Two ways in](#two-ways-in) plus
150
+ [Agent readiness](#agent-readiness) for the parts a new app picks first.
151
+
148
152
  ## API at a glance
149
153
 
150
154
  | Export | What it does |
@@ -154,7 +158,7 @@ persistent grant store, and the phone page above.
154
158
  | `HERDR_VERSION`, `HERDR_PROTOCOL` | The pinned Herdr release (`0.9.1`) and the protocol the kit speaks (`22`) |
155
159
  | `words`, `agentWords`, `stateWords`, `WORDS` | Plain sentences for agent statuses and kit states |
156
160
  | `herdrLink` (`@byokit/herdr/link`) | The host side of the `hd.*` link ops, spread into `Host.open`; scopes each grant to its workspaces and can push sealed approval notices through a relay |
157
- | `serve` (`@byokit/herdr/link`) | Finds the address with `@byokit/reach` and serves the link (and an optional page) on one port |
161
+ | `serve` (`@byokit/herdr/link`) | Finds the address with `@byokit/discover` and serves the link (and an optional page) on one port |
158
162
  | `herdrDevice` (`@byokit/herdr/device`) | The phone and browser side: typed calls over a `DeviceLink` (`tree`, `startAgent`, `prompt`, `read`, `blocked`, `answer`, `events`, `terminal`, notices); no Node import |
159
163
  | `openNotice` (`@byokit/herdr/device`) | Opens a sealed approval notice with the device's own seed |
160
164
  | `startFakeHerdr`, `writeBinShim`, `herdrContract` (`@byokit/herdr/testing`) | The kit's stand-in Herdr, a bin shim that runs it, and the contract suite the kit passes |
@@ -190,15 +194,39 @@ const moved = await kit.moveToAccount({ paneId: 'w1:p2' }, {
190
194
  if (moved.ok) console.log(moved.session); // new pane to follow
191
195
  ```
192
196
 
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
197
+ A move accepts mapped managed kinds, including Claude (`CLAUDE_CONFIG_DIR`), Codex (`CODEX_HOME`) and Pi
198
+ (`PI_CODING_AGENT_DIR`, `--session <id|absolute path>`). Hosts own cross-account history sharing.
199
+ It verifies the new shell's effective
194
200
  account folder, resumes the conversation there, waits for a ready session, then closes the old pane. A failed
195
201
  start preserves the original. A failed source close rolls back the new pane only after fresh reads verify
196
202
  both conversations' identities. A lost close acknowledgment can mean the original already closed: the kit
197
203
  preserves the replacement when the source is absent, replaced or unreachable and returns `close_failed`.
198
204
  After uncertain source close or cleanup, `live` names a freshly verified surviving conversation; it is omitted
199
205
  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.
206
+ can still happen after verification; the server has no conditional close transaction. Immediately before close,
207
+ the source must still be idle/done with the same conversation, terminal and published `state_change_seq` as
208
+ before splitting. Otherwise the replacement is closed and `changed` returned; `live` is freshly verified.
209
+ This guard applies to idle moves too. It is a bounded observation, not atomic or a new security guarantee.
210
+ Failures return a plain `message`. By default, working or blocked conversations return `busy`.
211
+
212
+ Both move APIs optionally accept:
213
+ ```ts
214
+ import type { BusyHandoff } from '@byokit/herdr';
215
+ const whenBusy: BusyHandoff = {
216
+ busy: 'wait',
217
+ confirmed: { session: 'published-conversation-id', terminalId: 'published-terminal-id' },
218
+ waitMs: 300_000,
219
+ }; // Pass whenBusy to either move API after the person confirms these current values.
220
+ ```
221
+ Label this **Move when this step finishes**. Confirmation must match the current published session and terminal;
222
+ wait requires a published sequence and finite positive `waitMs` ≤ 300000. Working sources wait for idle/done/blocked
223
+ and are re-read before staging. Timeout returns `busy`, approval returns `blocked`, stale identity returns `changed`,
224
+ and missing sequence or invalid bounds returns `unsupported`. The person may stop the step themselves in the pane.
225
+ `busy: 'interrupt'` (confirmed session/terminal/seq) returns `interrupt_unsupported`, never keys or lifecycle calls.
226
+ 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.
227
+ A record env with `HOME` gives the pane a task-owned home: unless the env sets them, `XDG_CONFIG_HOME`,
228
+ `XDG_DATA_HOME`, `XDG_STATE_HOME` and `XDG_CACHE_HOME` move under it too, so a sign-in inside the pane stays
229
+ there. Other server variables still reach the pane; pass `{ env, unset }` for a fully replaced environment.
202
230
  Tokens stay on the device and are never logged. Each provider's own terms apply to how you use your plan.
203
231
 
204
232
  For managed-folder stores that supply resume arguments and credential shedding, use `move`:
@@ -242,15 +270,17 @@ Helpers cover the common paths (start an agent, deliver a prompt with a receipt,
242
270
 
243
271
  `kit.agentStatus(kinds)` reports, per kind, whether the agent's CLI is installed and whether
244
272
  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
273
+ probes the explicit path or, for own-mode kits, the managed launch PATH. Adopt-mode inventory
274
+ uses the local machine; it is not evidence of an existing pane's environment. That inventory uses the host PATH plus
246
275
  the extra install dirs a service PATH omits (`extraPathDirs`: `~/.local/bin`, the mise shims,
247
276
  `~/.npm-global/bin`, Homebrew and the system dirs — the set muxr probed before this kit did).
248
277
  Sign-in comes only from each CLI's own documented non-secret status command, with a 10 s timeout;
249
278
  a kind whose CLI has none, or whose command gives no answer, reads `unknown`. Credential files
250
279
  are never opened, read or statted — only the signed-in boolean is kept. Install detection tells a
251
280
  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
281
+ on PATH at all, retains the legacy `installState: 'installs-on-first-start'` with `installed: false`.
282
+ **Muse is different:** no executable, an auto-install shim, or an official launcher without its selected native release,
283
+ reads `installState: 'missing'`. Stock Herdr does not install an absent Muse command. That covers `pi`, which
254
284
  Herdr installs via mise: a missing or shimmed `pi` reads `installed: false` with `installs on
255
285
  first start`, while a real `pi` binary reads `installed: true`.
256
286
 
@@ -287,12 +317,71 @@ sniff's file-head reader (tests use fakes); `{ run }` injects the command runner
287
317
  `{ timeoutMs }` bounds each probe. `signInHint` rides kinds the kit knows how to check whenever they
288
318
  are not signed in; `installHint` always rides along.
289
319
 
320
+ ## Explicit private Muse installation
321
+
322
+ `installMuse` is an additive Node helper. Calling it authorizes the official published installer,
323
+ not sign-in or a model request. Show the source (`MUSE_INSTALL_URL`), destination and affected
324
+ files before offering that action. No hidden download happens on import or `startAgent`.
325
+
326
+ ```ts
327
+ import { HerdrKit, installMuse, museReadiness } from '@byokit/herdr';
328
+
329
+ const installed = await installMuse({
330
+ home: '/app-owned/private/muse',
331
+ path: ['/usr/bin', '/bin'], // clean host-selected tools; no inherited environment
332
+ // installDir: '/app-owned/private/muse/tools', signal, timeoutMs: 180_000
333
+ });
334
+ if (installed.ok) {
335
+ const { env, version } = installed.receipt;
336
+ console.log(museReadiness(env), version); // installation only, signedIn stays unknown
337
+ const kit = new HerdrKit({ mode: 'adopt', bin: '/app-owned/tools/herdr',
338
+ socketPath: '/app-owned/herdr.sock' }); // the app's already running stock Herdr
339
+ await kit.start();
340
+ // Existing env-replacement/rollback guards still apply. Use this complete environment
341
+ // rather than shell rc files, including for an existing pane you explicitly prepare.
342
+ await kit.startAgent({ kind: 'muse', cwd: '/app-owned/project',
343
+ place: { tab: 'new', workspaceId: 'project' }, env: { env, unset: [] } });
344
+ } else {
345
+ console.log(installed.code, installed.message); // e.g. protected_download, cancelled
346
+ }
347
+ ```
348
+
349
+ Only absolute app-managed private homes/targets are allowed. The destination must be inside
350
+ that home; symlink/escape/default-home/system targets are refused. Bash/curl are required on
351
+ the explicit tool PATH. The unmodified official HTTPS installer runs in a fresh staging
352
+ HOME/XDG/temp, with `MUSE_INSTALL_DIR`, `MUSE_NO_MODIFY_PATH=1`, `MUSE_LOGIN=0`, no inherited
353
+ URL/auth/profile overrides and a bounded curl policy. No credentials are copied or read from
354
+ the caller's existing home. HTTPS-only redirects (at most three), per-download 60 s/512 MiB,
355
+ a total deadline (default 180 s, maximum 300 s) and owned-process-group cancellation are enforced.
356
+ No shell profile or persistent PATH is modified.
357
+
358
+ Success requires both the executable launcher and selected executable `muse-bin-<version>`;
359
+ the receipt records the native release version, SHA-256 hashes, actual paths and launch env.
360
+ A working installation is reused without updates. A failed/cancelled new install leaves no
361
+ partial target; an existing incomplete target is preserved/refused (choose a fresh directory).
362
+ The returned environment disables automatic updates and login. Hosts own these private paths
363
+ and must avoid concurrent writers; filesystem validation is not an OS sandbox for publisher code.
364
+ Linux/macOS x64/arm64 are supported; Windows returns `unsupported_platform`.
365
+
366
+ Known-missing Muse refuses with `agent_not_installed` / `launchFailed.reason: 'not-installed'`
367
+ **before placement/start**. Preflight uses the explicit per-call environment, otherwise the
368
+ kit-owned launch environment for a new pane. Controller-global inventory/probe overrides
369
+ never prove readiness on a known private launch PATH. An unobserved existing/adopted pane
370
+ environment remains unknown (`museReadiness()`), not installed. Exact typed RPC pass-through
371
+ is unchanged; direct stock `agent.start` has upstream behavior.
372
+
373
+ Installation adds no folder/resume/native-move capability: Muse remains tab-only metadata.
374
+ It does not prove sign-in, account catalog or model readiness. The official public model name
375
+ is `muse-spark-1.3-contributor`; the installed account's own catalog still requires separate
376
+ owner qualification. No provider/model fallback is performed. Previously inspected published
377
+ kit versions 0.3.0 and 0.5.0 lack this helper/fix; this source change is not a published-version claim.
378
+
290
379
  ## Agent start lifecycle
291
380
 
292
381
  `startAgent` keeps its typed shape and rejection, and additionally reports the launch as events
293
382
  so an app shows `Installing…` instead of a blank start: `installing` (with the progress words)
294
383
  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.
384
+ `reason` (`placement-failed` | `pane-busy` | `install-failed` | `start-rejected` | `not-installed`) plus plain words.
296
385
  Subscribe per call with `onEvent`, or app-wide with `kit.onStartAgent` — no polling either way.
297
386
 
298
387
  ```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();
@@ -15,55 +17,58 @@ export function createAccountPanes(ctx) {
15
17
  return false;
16
18
  }
17
19
  }
18
- // A shell rc can override placement env. Verify what the shell actually resolves, rather
19
- // than pane.get's launch env. The variable name is fixed by the provider, never caller text.
20
- async function checkEnv(paneId, variable, folder, timeout, absent = false) {
21
- const marker = `BYOKIT_ACCOUNT_${randomBytes(8).toString('hex')}`;
22
- const deadline = Date.now() + timeout;
23
- const expansion = absent ? '${' + variable + '+x}' : '$' + variable;
24
- try {
25
- await call('pane.send_text', { pane_id: paneId, text: `echo ${marker}="${expansion}"\n` }, timeout);
26
- for (;;) {
27
- const result = await call('pane.read', { pane_id: paneId, source: 'recent_unwrapped', lines: 40,
28
- format: 'text', strip_ansi: true }, Math.max(1, deadline - Date.now()));
29
- const readings = String(result?.read?.text ?? '').split('\n').map((line) => line.trim())
30
- .filter((line) => line.startsWith(`${marker}=`)).map((line) => line.slice(marker.length + 1));
31
- if (readings.length > 0)
32
- return readings.includes(folder);
33
- if (Date.now() >= deadline)
34
- return false;
35
- await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
36
- }
37
- }
38
- catch {
39
- return false;
40
- }
41
- }
42
- // agent.start has no env/unset/command field in the pinned protocol. Clear credentials in
43
- // the shell that launches it, then verify absence (never echo credential values).
44
- async function prepareEnv(paneId, set, unset, variable, timeout) {
20
+ // Env verification types nothing into the pane: every prep keystroke (unset lines,
21
+ // echo probes) stays in the new pane's scrollback where users see it after a move. The
22
+ // placement env rides `pane.split`, applied by the server before the shell is shown,
23
+ // so `pane.get` proves the new shell's launch env: every set var exact, every unset
24
+ // var absent. Fail closed: the split call cannot express removals, so a leaked
25
+ // credential in the launch env refuses the move instead of running on it.
26
+ // Limitation: a shell rc overriding placement env after launch is not visible here;
27
+ // no pinned RPC reads shell-effective env without typing into the pane.
28
+ async function verifyPaneEnv(paneId, set, unset, timeout) {
45
29
  const entries = Object.entries(set);
46
30
  if ([...entries.map(([key]) => key), ...unset].some((key) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
47
31
  || entries.some(([, value]) => typeof value !== 'string' || /[\r\n\0]/.test(value))
48
32
  || unset.some((key) => key in set))
49
33
  return false;
50
34
  try {
51
- if (unset.length > 0)
52
- await call('pane.send_text', { pane_id: paneId, text: `unset ${unset.join(' ')}\n` }, timeout);
53
- if (!await checkEnv(paneId, variable, set[variable], timeout))
35
+ const pane = (await call('pane.get', { pane_id: paneId }, timeout))?.pane;
36
+ const env = pane?.env;
37
+ if (!pane || typeof env !== 'object' || env === null || Array.isArray(env))
54
38
  return false;
55
- for (const key of unset)
56
- if (!await checkEnv(paneId, key, '', timeout, true))
57
- return false;
58
- return true;
39
+ if (unset.some((key) => Object.hasOwn(env, key)))
40
+ return false;
41
+ return entries.every(([key, value]) => env[key] === value);
59
42
  }
60
43
  catch {
61
44
  return false;
62
45
  }
63
46
  }
47
+ // The replacement keeps the conversation's user-visible name. The server rejects
48
+ // duplicate agent names, so it starts unique while the source is still live, then
49
+ // takes the source's name once that pane is closed. A failed rename keeps the
50
+ // unique start name and never fails the move.
51
+ function moveNames(sourceName, kind) {
52
+ const clean = (value) => {
53
+ const slug = String(value ?? '').toLowerCase().replace(/[^a-z0-9_-]+/g, '-')
54
+ .replace(/^[-_0-9]+/, '').slice(0, 32);
55
+ return /^[a-z][a-z0-9_-]{0,31}$/.test(slug) ? slug : undefined;
56
+ };
57
+ const want = clean(sourceName) ?? clean(kind) ?? 'agent';
58
+ return { start: `${want.slice(0, 25)}-${randomBytes(3).toString('hex')}`, want };
59
+ }
64
60
  async function openSignInTab(o) {
65
- const { workspaceId, label, ...start } = o;
61
+ const { workspaceId, label, folder, ...start } = o;
66
62
  try {
63
+ if (folder !== undefined) {
64
+ const variable = accountKind(start.kind)?.folderVar;
65
+ if (!variable || !folder || /[\r\n\0]/.test(folder))
66
+ throw new Error('unsupported folder');
67
+ const launch = start.env;
68
+ start.env = launch && typeof launch.env === 'object' && Array.isArray(launch.unset)
69
+ ? { env: { ...launch.env, [variable]: folder }, unset: launch.unset }
70
+ : { ...launch, [variable]: folder };
71
+ }
67
72
  return await ctx.startAgent({ ...start, place: { tab: 'new', workspaceId,
68
73
  ...(label === undefined ? {} : { label }) } });
69
74
  }
@@ -85,19 +90,69 @@ export function createAccountPanes(ctx) {
85
90
  catch {
86
91
  return failed('too_early');
87
92
  }
88
- const session = agent?.agent_session;
93
+ const handoff = o.whenBusy;
94
+ const optedIn = handoff?.busy === 'wait' || handoff?.busy === 'interrupt';
95
+ let session = agent?.agent_session;
89
96
  if (!agent || agent.launch_pending === true || !session || typeof session.value !== 'string'
90
97
  || session.value.length === 0)
91
98
  return failed('too_early');
92
- if (!['idle', 'done'].includes(agent.agent_status))
99
+ if (agent.agent_status === 'blocked')
100
+ return failed(optedIn ? 'blocked' : 'busy');
101
+ if (!['idle', 'done'].includes(agent.agent_status) && !(optedIn && agent.agent_status === 'working'))
93
102
  return failed('busy');
103
+ if (optedIn) {
104
+ if (!handoff.confirmed || handoff.confirmed.session !== session.value
105
+ || handoff.confirmed.terminalId !== agent.terminal_id)
106
+ return failed('changed');
107
+ if (typeof agent.terminal_id !== 'string' || agent.terminal_id.length === 0)
108
+ return failed('unsupported');
109
+ if (handoff.busy === 'interrupt') {
110
+ if (handoff.confirmed.seq !== agent.state_change_seq)
111
+ return failed('changed');
112
+ return failed('interrupt_unsupported');
113
+ }
114
+ if (!Number.isFinite(handoff.waitMs) || handoff.waitMs <= 0 || handoff.waitMs > 300_000
115
+ || !Number.isSafeInteger(agent.state_change_seq) || agent.state_change_seq < 0)
116
+ return failed('unsupported');
117
+ if (agent.agent_status === 'working') {
118
+ try {
119
+ await call('agent.wait', { target: target.paneId, until: ['idle', 'done', 'blocked'],
120
+ timeout_ms: handoff.waitMs }, handoff.waitMs + 5000);
121
+ }
122
+ catch {
123
+ return failed('busy');
124
+ }
125
+ let current;
126
+ try {
127
+ current = (await call('agent.get', { target: target.paneId }))?.agent;
128
+ }
129
+ catch {
130
+ return failed('changed', null);
131
+ }
132
+ if (!current || current.launch_pending === true || current.agent !== agent.agent
133
+ || current.terminal_id !== agent.terminal_id || current.agent_session?.agent !== session.agent
134
+ || current.agent_session?.kind !== session.kind || current.agent_session?.value !== session.value)
135
+ return failed('changed', null);
136
+ if (current.agent_status === 'blocked')
137
+ return failed('blocked');
138
+ if (!['idle', 'done'].includes(current.agent_status))
139
+ return failed('busy');
140
+ if (!Number.isSafeInteger(current.state_change_seq) || current.state_change_seq < 0)
141
+ return failed('unsupported');
142
+ agent = current;
143
+ session = current.agent_session;
144
+ }
145
+ }
94
146
  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')
147
+ const metadata = accountKind(o.provider);
148
+ if (!metadata?.folderVar || !metadata.resume || kind !== metadata.kind
149
+ || (request !== undefined && accountKind(request.kind)?.kind !== kind)
150
+ || session.agent !== kind || !metadata.sessionKinds.includes(session.kind))
151
+ return failed('unsupported');
152
+ if (/[\x00-\x1f\x7f]/.test(session.value) || session.value.length > (session.kind === 'path' ? 4096 : 512)
153
+ || (session.kind === 'path' && !isAbsolute(session.value)))
98
154
  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);
155
+ const args = request?.args ?? metadata.resumeArgs?.map((arg) => arg.replace('{session}', () => session.value));
101
156
  if (args === undefined || !Array.isArray(args) || args.length === 0 || args.some((a) => typeof a !== 'string' || /[\r\n\0]/.test(a)))
102
157
  return failed('unsupported');
103
158
  if (!o.folder || /[\r\n\0]/.test(o.folder))
@@ -105,7 +160,7 @@ export function createAccountPanes(ctx) {
105
160
  const timeout = o.timeoutMs ?? 60_000;
106
161
  if (!Number.isFinite(timeout) || timeout <= 0)
107
162
  return failed('start_failed');
108
- const variable = o.provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME';
163
+ const variable = metadata.folderVar;
109
164
  let paneId;
110
165
  let replacement;
111
166
  try {
@@ -127,16 +182,17 @@ export function createAccountPanes(ctx) {
127
182
  catch {
128
183
  return rollback('start_failed');
129
184
  }
130
- if (request !== undefined && !await prepareEnv(paneId, request.set, request.unset ?? [], variable, timeout)) {
131
- return rollback('env_mismatch');
185
+ if (request !== undefined) {
186
+ if (!await verifyPaneEnv(paneId, request.set, request.unset ?? [], timeout))
187
+ return rollback('env_mismatch');
132
188
  }
133
- if (request === undefined && !await checkEnv(paneId, variable, o.folder, timeout)) {
189
+ else if (!await verifyPaneEnv(paneId, { ...o.env, [variable]: o.folder }, [], timeout)) {
134
190
  return rollback('env_mismatch');
135
191
  }
192
+ const names = moveNames(agent?.name, kind);
136
193
  try {
137
- // Unique names avoid colliding with the still-live source agent.
138
194
  await ctx.startAgent({ kind, cwd: agent.foreground_cwd ?? agent.cwd ?? '.',
139
- name: `move-${randomBytes(8).toString('hex')}`, place: { pane: paneId }, args, timeoutMs: timeout });
195
+ name: names.start, place: { pane: paneId }, args, timeoutMs: timeout });
140
196
  await call('agent.wait', { target: paneId, until: ['idle', 'done'], timeout_ms: timeout }, timeout + 5000);
141
197
  const deadline = Date.now() + timeout;
142
198
  for (;;) {
@@ -158,25 +214,43 @@ export function createAccountPanes(ctx) {
158
214
  const cleaned = await close(paneId);
159
215
  return failed('start_failed', cleaned ? target.paneId : paneId);
160
216
  }
217
+ // Compare identity, not revision: status changes can legitimately bump revision.
218
+ const survives = async (id, expected) => {
219
+ try {
220
+ const current = (await call('agent.get', { target: id }, timeout))?.agent;
221
+ return !!current && current.agent === expected.agent && current.launch_pending !== true
222
+ && ['idle', 'working', 'blocked', 'done'].includes(current.agent_status)
223
+ && current.agent_session?.agent === expected.agent_session?.agent
224
+ && current.agent_session?.kind === expected.agent_session?.kind
225
+ && current.agent_session?.value === expected.agent_session?.value
226
+ && (expected.terminal_id === undefined || current.terminal_id === expected.terminal_id)
227
+ && (expected.name === undefined || current.name === expected.name);
228
+ }
229
+ catch {
230
+ return false;
231
+ } // unavailable is not proof of survival
232
+ };
233
+ let current;
234
+ try {
235
+ current = (await call('agent.get', { target: target.paneId }, timeout))?.agent;
236
+ }
237
+ catch {
238
+ current = {};
239
+ }
240
+ if (!current || current.launch_pending === true || !['idle', 'done'].includes(current.agent_status)
241
+ || current.agent_status !== agent.agent_status || current.agent !== agent.agent || current.terminal_id !== agent.terminal_id
242
+ || current.agent_session?.agent !== session.agent || current.agent_session?.kind !== session.kind
243
+ || current.agent_session?.value !== session.value
244
+ || (agent.state_change_seq !== undefined && current.state_change_seq !== agent.state_change_seq)) {
245
+ await close(paneId);
246
+ const live = await survives(paneId, replacement) ? paneId
247
+ : await survives(target.paneId, agent) ? target.paneId : null;
248
+ return failed('changed', live);
249
+ }
250
+ // Published Herdr has no conditional close: the final observation is not atomic.
161
251
  if (!await close(target.paneId)) {
162
252
  // A lost ACK can mean the close already applied. Never destroy the ready
163
253
  // 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
254
  const replacementAlive = await survives(paneId, replacement);
181
255
  const sourceAlive = await survives(target.paneId, agent);
182
256
  if (sourceAlive && replacementAlive)
@@ -188,6 +262,15 @@ export function createAccountPanes(ctx) {
188
262
  return failed('close_failed', live);
189
263
  }
190
264
  // The source is closed: a notification failure cannot undo a successful move.
265
+ // Take the conversation's name now that it is free; the unique start name stays
266
+ // when the rename is refused, and a rename failure never undoes the move.
267
+ if (names.want !== names.start) {
268
+ try {
269
+ await call('agent.rename', { target: paneId, name: names.want }, timeout);
270
+ replacement.name = names.want;
271
+ }
272
+ catch { /* the unique start name stays; the move already succeeded */ }
273
+ }
191
274
  try {
192
275
  request?.onReplaced?.(paneId);
193
276
  }
@@ -199,10 +282,10 @@ export function createAccountPanes(ctx) {
199
282
  }
200
283
  }
201
284
  async function move(o) {
202
- const variable = o.kind === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME';
285
+ const variable = accountKind(o.kind)?.folderVar;
203
286
  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,
287
+ provider: o.kind, folder: variable === undefined ? '' : o.set[variable] ?? '',
288
+ env: o.set, timeoutMs: o.timeoutMs, whenBusy: o.whenBusy,
206
289
  }, o);
207
290
  return result.ok ? { ok: true, paneId: result.session } : result;
208
291
  }
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>;