@byokit/herdr 0.7.2 → 0.8.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,45 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.0 (2026-10-12)
6
+
7
+ - Dependency update: pins @byokit/discover 0.8.0.
8
+ - Dependency update: pins @byokit/relay 0.6.1.
9
+ - Dependency update: pins @byokit/pair 0.10.0.
10
+
11
+ - FIX: A Node script (or any non-shell interpreter) whose path contains `mise` is now a real installed binary, so `agentStatus` runs its sign-in check instead of skipping it as an auto-install shim; only a shell launcher that mentions mise still reads `installs-on-first-start`.
12
+ - `startAgent` accepts a workspace label as `place.workspace`: the agent lands in the one workspace with that label (new tab, `created: false`) or in a workspace created with that label (`created: true`). Two workspaces with the label refuse with `workspace-ambiguous` and create nothing, and a failed start rolls back only what the kit made (the fresh pane, or the fresh workspace), never a reused workspace. `AgentRef` carries the new `created` flag.
13
+ - New control link ops `hd.rename { pane | tab | workspace, label }`, `hd.focus { pane | tab | workspace }` and
14
+ `hd.layout { root, focus?, tabId?, tabLabel?, workspaceId? }`, with device `rename()`, `focus()` and
15
+ `layout()`. Each copies `hd.close`'s scope rule: the one pane, tab (through its owning workspace) or
16
+ workspace it names must be in `scopeOf(grant)`, or the device hears the `link.notAllowed` sentence and no
17
+ Herdr call is made; a `view` grant is refused. They map only to the typed `pane.rename`/`tab.rename`/
18
+ `workspace.rename`, `pane.focus`/`tab.focus`/`workspace.focus` and `layout.apply` calls, never through
19
+ `hd.call`. The fake Herdr now answers `layout.apply` and emits `layout.updated`.
20
+ - `sealNotice`/`openNotice` (and the portable `./device` re-export) now delegate to `@byokit/seal`'s generic
21
+ `sealNotice`/`openNotice` instead of sealing and opening the box here: the device seed becomes the box secret
22
+ through `boxKeyPairFromSeed`, and the opened value is still shape-checked as a `BlockedAgent`. The wire format is
23
+ unchanged, so a notice sealed by an earlier kit still opens, and `seal.openNotice` opens a herdr notice. The
24
+ hand-rolled base64url helpers stay only for the box-key registration text (`boxPublicKeyB64`, link.ts's
25
+ `decodeB64Url`), because seal exports no base64url.
26
+ - FIX: A refused widening close now tells the app which scope it would widen to: `closePane`/`closeTab`/`closeWorkspace` still throw `pane-close-would-widen`/`tab-close-would-widen`/`workspace-close-would-widen`, but each also carries the wider target as `{ scope: 'tab' | 'workspace', id, label }` (a pane's tab, a tab's workspace, a worktree group named by its root), so the UI can offer the bigger close instead. `hd.close` over a link maps all three codes to a `PublicLinkError` with the `close.wouldWiden` words, so a device hears plain words instead of a bare failure.
27
+ - FIX: `moveToAccount` refuses signed-out managed targets before waiting or creating a pane, preserving the source; generic `move` remains caller-gated and unknown readiness still proceeds.
28
+ - FIX: Muse readiness routes its mise check through the shared shell-launcher predicate, so a non-shell binary whose interpreter path contains `mise` reads installed instead of launcher-only; genuine mise shell shims still read launcher-only.
29
+ - FIX: `runTurn` accepts an agent that is already `done` when the turn starts, treating it like `idle` at turn start; previously the call was refused with `turn-not-idle`/`turn-busy`.
30
+ - FIX: `runTurn` accepts `files: false` to skip the file scan entirely (`changedFiles` is then always empty), so a turn rooted in a folder with more than 10000 entries no longer fails with `turn: file limit`.
31
+
32
+ ## 0.7.3 (2026-10-11)
33
+
34
+ - Dependency update: pins @byokit/discover 0.7.0.
35
+
36
+ - `@byokit/herdr` exports `isPromptable(agent)`, the single readiness gate `prompt` already used, and adds
37
+ `kit.waitPromptable(target, { timeoutMs })`: it waits until a just-started agent can take a prompt instead of the
38
+ host polling `prompt` and catching `agent-not-ready`. It resolves once the kit's snapshot is promptable, rejects
39
+ `agent-not-ready` at the deadline, and rejects `pane-unavailable` when the pane closes mid-wait.
40
+ - The README's rename note and examples now point at `@byokit/pair` (the phone hand-off host) and the
41
+ example screenshots were re-captured from today's herdr-kit phone page. Docs only; no typed surface changed.
42
+ - Dependency update: depend on `@byokit/pair` 0.9.0 instead of its deprecated `@byokit/link` shim.
43
+
5
44
  ## 0.7.2 (2026-10-09)
6
45
 
7
46
  - Dependency update: pins @byokit/relay 0.6.0.
package/README.md CHANGED
@@ -8,13 +8,16 @@
8
8
 
9
9
  <p align="center"><strong>Drive the Herdr already on this computer from an app, or from a phone.</strong><br/>
10
10
  Workspaces, tabs, panes, coding agents and blocked-approval answers, from a Node app or handed to a phone over
11
- <a href="../link"><code>@byokit/link</code></a>. Each agent inside Herdr keeps its own subscription login; the kit
11
+ <a href="../pair"><code>@byokit/pair</code></a>. Each agent inside Herdr keeps its own subscription login; the kit
12
12
  never sees a credential.</p>
13
13
 
14
+ <p align="center"><sub>This kit depends on <a href="../pair"><code>@byokit/pair</code></a>, and its examples
15
+ import from there.</sub></p>
16
+
14
17
  <p align="center">
15
- <img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/examples/herdr-kit/docs/3-agent.png" width="240" alt="Phone page titled Agents, Connected to Kitchen computer and Connected to Herdr: two pi agents marked Ready for you, and under Talk to it the agent's screen reading '> Add a --json flag to export', 'Read src/cli/export.ts', 'Edited src/cli/export.ts +18 -3' and 'export --json now prints JSON.' above a Message box, a Send button and Sent." />
16
- <img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/examples/herdr-kit/docs/4-question.png" width="240" alt="Phone page with Questions for you at the top: pi, Waiting for your answer., the question 'Allow this? (y/n)' and buttons Enter, y, n and Esc; below, the second pi agent marked Waiting for your answer." />
17
- <img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/examples/herdr-kit/docs/5-answered.png" width="240" alt="Phone page after the answer: no Questions for you section, both pi agents marked Ready for you, and the agent's screen ending in 'Allow this? (y/n)', 'y' and 'npm test: 42 passing'" />
18
+ <img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/examples/herdr-kit/docs/3-agent.png" width="240" alt="Phone page titled Agents, Connected to Kitchen computer - same Wi-Fi.: two Pi agents marked Ready for you, and under Talk to it the agent's screen reading '> Add a --json flag to export', 'Read src/cli/export.ts', 'Edited src/cli/export.ts +18 -3' and 'export --json now prints JSON.' above a Message box, a Send button and Waiting for the reply below." />
19
+ <img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/examples/herdr-kit/docs/4-question.png" width="240" alt="Phone page with Questions for you at the top: Pi, Waiting for your answer., the question 'Allow this? (y/n)' and buttons Allow, Deny and Skip; below, the second Pi agent marked Waiting for your answer." />
20
+ <img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/examples/herdr-kit/docs/5-answered.png" width="240" alt="Phone page after the answer: no Questions for you section, both Pi agents marked Ready for you, and the agent's screen ending in 'Allow this? (y/n)', 'y' and 'npm test: 42 passing'" />
18
21
  </p>
19
22
  <p align="center"><sub>The <a href="../../examples/herdr-kit">herdr-kit example</a>'s phone page: an agent at work, its
20
23
  question, answered. Captured by the example's e2e in a phone-sized headless Chromium, against the kit's stand-in
@@ -90,7 +93,7 @@ Waiting for your answer. | Allow this? (y/n)
90
93
  idle
91
94
  ```
92
95
 
93
- To hand Herdr to a phone, spread `herdrLink` into a `@byokit/link` host and serve it; the phone calls the same
96
+ To hand Herdr to a phone, spread `herdrLink` into a `@byokit/pair` host and serve it; the phone calls the same
94
97
  things through `herdrDevice`:
95
98
 
96
99
  ```ts
@@ -106,8 +109,8 @@ const own = new HerdrKit({ mode: 'own', bin, stateDir: './.state' });
106
109
  ```ts
107
110
  import { HerdrKit } from '@byokit/herdr';
108
111
  import { herdrLink, serve } from '@byokit/herdr/link';
109
- import { Host, type Grant } from '@byokit/link';
110
- import { hostKeyFile } from '@byokit/link/node';
112
+ import { Host, type Grant } from '@byokit/pair';
113
+ import { hostKeyFile } from '@byokit/pair/node';
111
114
 
112
115
  const kit = new HerdrKit({ mode: 'own', bin: '/usr/local/bin/herdr', stateDir: './.state' });
113
116
  const grants: Grant[] = [];
@@ -128,7 +131,7 @@ kit.start().catch(() => {}); // non-fatal: retry later while Herdr is dow
128
131
  ```
129
132
 
130
133
  ```ts
131
- import type { DeviceLink } from '@byokit/link';
134
+ import type { DeviceLink } from '@byokit/pair';
132
135
  import { agentWords, herdrDevice } from '@byokit/herdr/device';
133
136
 
134
137
  declare const link: DeviceLink; // from pairing (pairWithCode / pairWithOffer)
@@ -153,8 +156,8 @@ kit does not have yet, and [Two ways in](#two-ways-in) plus
153
156
 
154
157
  | Export | What it does |
155
158
  |---|---|
156
- | `HerdrKit` (`@byokit/herdr`) | The host-side kit, in `adopt` or `own` mode: `start`/`stop`, `state`, `snapshot`/`onChange`, `startAgent`, `openSignInTab`, `move`, `moveToAccount`, `onStartAgent`, `prompt`, `sendKeys`, `wait`, `read`, `blocked`/`onBlocked`/`answer`, `closePane`/`closeTab`/`closeWorkspace`, `agentKinds`, `installedAgentKinds`, `agentStatus`, `agentInstallState`, `terminal`, `onEvent`, `statusWatchReady`, and the pass-throughs `call`, `subscribe` and `cli` |
157
- | `agentStatus`, `agentInstallState`, `agentProbePath`, `extraPathDirs`, `runStatusCommand`, `isAutoInstallShim`, `resolveAgentBinary` (`@byokit/herdr`) | Onboarding readiness: per-kind install + CLI sign-in without a Herdr connection (see below) |
159
+ | `HerdrKit` (`@byokit/herdr`) | The host-side kit, in `adopt` or `own` mode: `start`/`stop`, `state`, `snapshot`/`onChange`, `startAgent`, `openSignInTab`, `move`, `moveToAccount`, `onStartAgent`, `prompt`, `waitPromptable`, `sendKeys`, `wait`, `read`, `blocked`/`onBlocked`/`answer`, `closePane`/`closeTab`/`closeWorkspace`, `agentKinds`, `installedAgentKinds`, `agentStatus`, `agentInstallState`, `terminal`, `onEvent`, `statusWatchReady`, and the pass-throughs `call`, `subscribe` and `cli` |
160
+ | `isPromptable`, `agentStatus`, `agentInstallState`, `agentProbePath`, `extraPathDirs`, `runStatusCommand`, `isAutoInstallShim`, `resolveAgentBinary` (`@byokit/herdr`) | `isPromptable(agent)` is the readiness rule `prompt` and `waitPromptable` share; the rest is onboarding readiness: per-kind install + CLI sign-in without a Herdr connection (see below) |
158
161
  | `HERDR_VERSION`, `HERDR_PROTOCOL` | The pinned Herdr release (`0.9.1`) and the protocol the kit speaks (`22`) |
159
162
  | `words`, `agentWords`, `stateWords`, `WORDS` | Plain sentences for agent statuses and kit states |
160
163
  | `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 |
@@ -174,6 +177,12 @@ null), `agentSession` is absent; callers can fall back to their existing checks.
174
177
  Types (`HerdrKitOptions`, `HerdrState`, `StartAgent`, `PromptReceipt`, `BlockedAgent`, `HerdrSnapshot`,
175
178
  `HerdrMethod`/`HerdrParams`/`HerdrResult`, `HerdrEventName`/`HerdrEventOf`, ...) come from the main entry.
176
179
 
180
+ A just-started agent is not promptable until its launch settles. Instead of polling `prompt` and catching
181
+ `agent-not-ready`, wait once: `await kit.waitPromptable(agent, { timeoutMs: 30_000 })` resolves the moment the
182
+ kit's snapshot is promptable — the same gate (`isPromptable`: not `launch_pending` and status idle/working/blocked/done)
183
+ `prompt` itself uses. At the deadline it rejects with code `agent-not-ready`; a pane that closes while waiting rejects
184
+ with code `pane-unavailable`, so the wait never hangs.
185
+
177
186
  ## Account-specific sign-ins and moves
178
187
 
179
188
  The host supplies account folders and shares conversation history as needed. The kit never reads or copies
@@ -196,6 +205,7 @@ if (moved.ok) console.log(moved.session); // new pane to follow
196
205
 
197
206
  A move accepts mapped managed kinds, including Claude (`CLAUDE_CONFIG_DIR`), Codex (`CODEX_HOME`) and Pi
198
207
  (`PI_CODING_AGENT_DIR`, `--session <id|absolute path>`). Hosts own cross-account history sharing.
208
+ `moveToAccount` checks the target managed folder once with `agentStatus`, after immediate refusals and before waiting or splitting. A signed-out target returns `signed_out` and “Sign in to {name} first.” (`name` defaults to “this account”), with `live` naming the unchanged source and no new pane. Unknown readiness proceeds (including Pi); generic `move` has no check, so Accounts hosts call `cliAccounts.launch` and pass its `set`/`unset` only on success. The check is not a lock: a later sign-out still starts only in the selected folder, with existing `start_failed` rollback and no default-login retry.
199
209
  It verifies the new shell's effective
200
210
  account folder, resumes the conversation there, waits for a ready session, then closes the old pane. A failed
201
211
  start preserves the original. A failed source close rolls back the new pane only after fresh reads verify
@@ -277,12 +287,17 @@ the extra install dirs a service PATH omits (`extraPathDirs`: `~/.local/bin`, th
277
287
  Sign-in comes only from each CLI's own documented non-secret status command, with a 10 s timeout;
278
288
  a kind whose CLI has none, or whose command gives no answer, reads `unknown`. Credential files
279
289
  are never opened, read or statted — only the signed-in boolean is kept. Install detection tells a
280
- real runnable binary apart from an auto-install launcher: a mise-style shim on PATH, or nothing
281
- on PATH at all, retains the legacy `installState: 'installs-on-first-start'` with `installed: false`.
290
+ real runnable binary apart from an auto-install launcher: a shell script (a `sh`, `bash`, `zsh`,
291
+ `dash`, `ksh` or `ash` shebang) whose text mentions mise, or nothing on PATH at all, retains the
292
+ legacy `installState: 'installs-on-first-start'` with `installed: false`. A binary whose interpreter
293
+ merely lives under mise (for example a Node script) reads `installed`.
282
294
  **Muse is different:** no executable, an auto-install shim, or an official launcher without its selected native release,
283
295
  reads `installState: 'missing'`. Stock Herdr does not install an absent Muse command. That covers `pi`, which
284
296
  Herdr installs via mise: a missing or shimmed `pi` reads `installed: false` with `installs on
285
- first start`, while a real `pi` binary reads `installed: true`.
297
+ first start`, while a real `pi` binary reads `installed: true`. The `museReadiness()` probe's
298
+ `launcher-only` state is a separate check, not this shell-shebang rule: it flags any `#!` file whose
299
+ head contains `mise`, so a Node script under mise reads `launcher-only` there but `installed` on the
300
+ agent path.
286
301
 
287
302
  ```ts
288
303
  import { HerdrKit } from '@byokit/herdr';
@@ -447,6 +462,7 @@ targets, do not traverse symlinks, always exclude `.git`, and ignore special fil
447
462
  and restored during the turn do not appear. Use `files.exclude` to prune generated or sensitive
448
463
  folders. Each scan defaults to 10000 entries and 128 MiB of regular-file content; set
449
464
  `files.maxFiles`/`maxBytes` to change these limits. An incomplete scan rejects the call.
465
+ Pass `files: false` to skip the scan entirely (`changedFiles` is then always empty).
450
466
 
451
467
  Reserve the pane and folder exclusively while the turn runs. Overlapping calls in one kit are
452
468
  refused, but changes by other apps or processes cannot be attributed to this agent. The supplied
@@ -1,8 +1,9 @@
1
1
  import type { Call } from './agents.ts';
2
- import type { AgentRef, Move, MoveResult, MoveToAccount, MoveToAccountResult, OpenSignInTab, StartAgent } from './types.ts';
2
+ import type { AgentReadiness, AgentStatusOptions, AgentRef, Move, MoveResult, MoveToAccount, MoveToAccountResult, OpenSignInTab, StartAgent } from './types.ts';
3
3
  export declare function createAccountPanes(ctx: {
4
4
  call: Call;
5
5
  startAgent(o: StartAgent): Promise<AgentRef>;
6
+ agentStatus(kinds: readonly string[], o?: AgentStatusOptions): Promise<AgentReadiness[]>;
6
7
  }): {
7
8
  openSignInTab: (o: OpenSignInTab) => Promise<AgentRef>;
8
9
  move: (o: Move) => Promise<MoveResult>;
package/dist/accounts.js CHANGED
@@ -78,7 +78,7 @@ export function createAccountPanes(ctx) {
78
78
  }
79
79
  }
80
80
  async function perform(target, o, request) {
81
- const failed = (code, live = target.paneId) => ({ ok: false, code, message: words(`move.${code}`), ...(live === null ? {} : { live }) });
81
+ const failed = (code, live = target.paneId) => ({ ok: false, code, message: words(`move.${code}`, { name: o.name ?? 'this account' }), ...(live === null ? {} : { live }) });
82
82
  if (moving.has(target.paneId))
83
83
  return failed('busy');
84
84
  moving.add(target.paneId);
@@ -114,34 +114,6 @@ export function createAccountPanes(ctx) {
114
114
  if (!Number.isFinite(handoff.waitMs) || handoff.waitMs <= 0 || handoff.waitMs > 300_000
115
115
  || !Number.isSafeInteger(agent.state_change_seq) || agent.state_change_seq < 0)
116
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
117
  }
146
118
  const kind = agent.agent;
147
119
  const metadata = accountKind(o.provider);
@@ -161,6 +133,39 @@ export function createAccountPanes(ctx) {
161
133
  if (!Number.isFinite(timeout) || timeout <= 0)
162
134
  return failed('start_failed');
163
135
  const variable = metadata.folderVar;
136
+ if (request === undefined) {
137
+ const [status] = await ctx.agentStatus([kind], { folders: { [kind]: o.folder }, env: o.env });
138
+ if (status?.signedIn === 'no')
139
+ return failed('signed_out');
140
+ }
141
+ if (handoff?.busy === 'wait' && agent.agent_status === 'working') {
142
+ try {
143
+ await call('agent.wait', { target: target.paneId, until: ['idle', 'done', 'blocked'],
144
+ timeout_ms: handoff.waitMs }, handoff.waitMs + 5000);
145
+ }
146
+ catch {
147
+ return failed('busy');
148
+ }
149
+ let current;
150
+ try {
151
+ current = (await call('agent.get', { target: target.paneId }))?.agent;
152
+ }
153
+ catch {
154
+ return failed('changed', null);
155
+ }
156
+ if (!current || current.launch_pending === true || current.agent !== agent.agent
157
+ || current.terminal_id !== agent.terminal_id || current.agent_session?.agent !== session.agent
158
+ || current.agent_session?.kind !== session.kind || current.agent_session?.value !== session.value)
159
+ return failed('changed', null);
160
+ if (current.agent_status === 'blocked')
161
+ return failed('blocked');
162
+ if (!['idle', 'done'].includes(current.agent_status))
163
+ return failed('busy');
164
+ if (!Number.isSafeInteger(current.state_change_seq) || current.state_change_seq < 0)
165
+ return failed('unsupported');
166
+ agent = current;
167
+ session = current.agent_session;
168
+ }
164
169
  let paneId;
165
170
  let replacement;
166
171
  try {
package/dist/agents.d.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import type { HerdrKit } from './kit.ts';
2
- import type { AgentInstallProbe, AgentInstallState, AgentLaunchFailureReason, AgentStartEvent, HerdrSnapshot } from './types.ts';
2
+ import type { AgentInstallProbe, AgentInstallState, AgentLaunchFailureReason, AgentStartEvent, HerdrSnapshot, HerdrSnapshotAgent } from './types.ts';
3
3
  export type Call = (method: string, params: Record<string, unknown>, timeoutMs?: number) => Promise<unknown>;
4
+ export declare function isPromptable(agent: HerdrSnapshotAgent | undefined): boolean;
4
5
  export declare function createAgents(ctx: {
5
6
  call: Call;
6
7
  snapshot(): HerdrSnapshot;
7
8
  reread(paneId: string): Promise<void>;
8
9
  emitStart?(e: AgentStartEvent): void;
9
10
  launchEnv?(): Record<string, string> | undefined;
10
- }): Pick<HerdrKit, 'startAgent' | 'prompt' | 'sendKeys' | 'wait' | 'read' | 'agentKinds' | 'installedAgentKinds' | 'agentStatus'>;
11
+ }): Pick<HerdrKit, 'startAgent' | 'prompt' | 'waitPromptable' | 'sendKeys' | 'wait' | 'read' | 'agentKinds' | 'installedAgentKinds' | 'agentStatus'>;
11
12
  export declare function resolveAgentBinary(kind: string, o: {
12
13
  path: string[];
13
14
  aliases?: Record<string, string[]>;
package/dist/agents.js CHANGED
@@ -9,9 +9,10 @@ import { delimiter, isAbsolute, join } from 'node:path';
9
9
  import { words } from "./words.js";
10
10
  import { accountKind } from "./kinds.js";
11
11
  import { prepareLaunchEnv } from "./launch-env.js";
12
- import { museNative, MUSE_INSTALL_URL } from "./muse.js";
12
+ import { museNative, MUSE_INSTALL_URL, isMiseLauncher } from "./muse.js";
13
13
  const PROMPTABLE = ['idle', 'working', 'blocked', 'done'];
14
14
  const DEFAULT_TIMEOUT_MS = 60_000;
15
+ const WAIT_PROMPTABLE_POLL_MS = 50;
15
16
  // `agent.start` lands while the fresh pane is still at its shell prompt: the server answers
16
17
  // `agent_pane_busy`/`agent_pane_unavailable` until the pane is ready. The kit retries those for a
17
18
  // bounded 5 s, then rolls the pane it created back with `pane.close` like any other start failure.
@@ -36,6 +37,12 @@ function agentOf(snapshot, paneId) {
36
37
  }
37
38
  return undefined;
38
39
  }
40
+ // The one readiness gate (6.4): an agent record is promptable when it exists, is not mid-launch and
41
+ // its status is one Herdr accepts a prompt in. `prompt` refuses with `agent-not-ready` and
42
+ // `waitPromptable` waits, both through this single rule.
43
+ export function isPromptable(agent) {
44
+ return agent !== undefined && agent.launchPending !== true && PROMPTABLE.includes(agent.status);
45
+ }
39
46
  // `agent.start` requires a name matching /^[a-z][a-z0-9_-]{0,31}$/; derive one from the kind when absent.
40
47
  function agentName(o) {
41
48
  const slug = (o.name ?? o.kind).toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[-_0-9]+/, '').slice(0, 32);
@@ -55,7 +62,9 @@ export function createAgents(ctx) {
55
62
  };
56
63
  // Every placement takes its pane id from the server's answer, never a prediction (6.4).
57
64
  // Placements that make a pane (every path but `pane`) roll it back with `pane.close` when the
58
- // start fails, so a failed start leaves no extra pane behind.
65
+ // start fails, so a failed start leaves no extra pane behind. A label placement that created
66
+ // its workspace rolls the workspace back with `workspace.close` instead, so no empty workspace
67
+ // survives either; a reused workspace is never closed.
59
68
  async function startAgent(o) {
60
69
  const emit = (e) => {
61
70
  try {
@@ -123,7 +132,24 @@ export function createAgents(ctx) {
123
132
  XDG_STATE_HOME: join(home, '.local', 'state'), XDG_CACHE_HOME: join(home, '.cache'), ...o.env
124
133
  } };
125
134
  let paneId;
126
- let created = false;
135
+ let createdPane = false; // a pane this start made: roll it back on failure
136
+ let createdWorkspace; // a workspace this start made: roll it back instead
137
+ let workspaceCreated = false; // reported as `created`: a workspace was made
138
+ async function rollback() {
139
+ if (createdWorkspace !== undefined) {
140
+ try {
141
+ await call('workspace.close', { workspace_id: createdWorkspace });
142
+ return;
143
+ }
144
+ catch { /* fall through to the pane when the workspace close misses */ }
145
+ }
146
+ if (createdPane && paneId !== undefined) {
147
+ try {
148
+ await call('pane.close', { pane_id: paneId });
149
+ }
150
+ catch { /* rollback is best-effort */ }
151
+ }
152
+ }
127
153
  if (o.worktree !== undefined) {
128
154
  const made = await call('worktree.create', { cwd: o.cwd, focus: false, ...env,
129
155
  ...(o.worktree.branch === undefined ? {} : { branch: o.worktree.branch }),
@@ -136,23 +162,50 @@ export function createAgents(ctx) {
136
162
  paneId = rootPaneOf(made) ?? rootPaneOf(await call('tab.create', {
137
163
  workspace_id: made?.workspace?.workspace_id, cwd: checkout, focus: false, ...env,
138
164
  }));
139
- created = true;
165
+ createdPane = true;
166
+ workspaceCreated = true;
167
+ }
168
+ else if ('workspace' in o.place && o.place.workspace !== 'new') {
169
+ // A workspace label (6.4): reuse the one workspace with this label, or create it.
170
+ // The lookup is a fresh `workspace.list`, never the kit's tree, so a workspace made
171
+ // moments ago is found. Two matches refuse with `workspace-ambiguous` and pick nothing.
172
+ const label = o.place.workspace;
173
+ const listed = await call('workspace.list', {});
174
+ const workspaces = Array.isArray(listed?.workspaces) ? listed.workspaces : [];
175
+ const matches = workspaces.filter((w) => isObj(w) && w.label === label);
176
+ if (matches.length > 1)
177
+ throw fail('workspace-ambiguous', `More than one workspace is named "${label}". Rename one, then try again.`);
178
+ if (matches.length === 1) {
179
+ paneId = rootPaneOf(await call('tab.create', { workspace_id: matches[0].workspace_id, cwd: o.cwd,
180
+ ...(o.place.label === undefined ? {} : { label: o.place.label }), focus: false, ...env }));
181
+ createdPane = true;
182
+ }
183
+ else {
184
+ const made = await call('workspace.create', { cwd: o.cwd, label, focus: false, ...env });
185
+ paneId = rootPaneOf(made);
186
+ createdPane = true;
187
+ workspaceCreated = true;
188
+ if (isObj(made?.workspace) && typeof made.workspace.workspace_id === 'string') {
189
+ createdWorkspace = made.workspace.workspace_id;
190
+ }
191
+ }
140
192
  }
141
193
  else if ('workspace' in o.place) {
142
194
  paneId = rootPaneOf(await call('workspace.create', { cwd: o.cwd,
143
195
  ...(o.place.label === undefined ? {} : { label: o.place.label }), focus: false, ...env }));
144
- created = true;
196
+ createdPane = true;
197
+ workspaceCreated = true;
145
198
  }
146
199
  else if ('tab' in o.place) {
147
200
  paneId = rootPaneOf(await call('tab.create', { workspace_id: o.place.workspaceId, cwd: o.cwd,
148
201
  ...(o.place.label === undefined ? {} : { label: o.place.label }), focus: false, ...env }));
149
- created = true;
202
+ createdPane = true;
150
203
  }
151
204
  else if ('split' in o.place) {
152
205
  const made = await call('pane.split', { target_pane_id: o.place.split,
153
206
  direction: o.place.direction, focus: false, ...env });
154
207
  paneId = typeof made?.pane?.pane_id === 'string' ? made.pane.pane_id : undefined;
155
- created = true;
208
+ createdPane = true;
156
209
  }
157
210
  else {
158
211
  paneId = o.place.pane;
@@ -164,12 +217,7 @@ export function createAgents(ctx) {
164
217
  await prepareLaunchEnv(call, paneId, launch, timeout);
165
218
  }
166
219
  catch (error) {
167
- if (created) {
168
- try {
169
- await call('pane.close', { pane_id: paneId });
170
- }
171
- catch { /* best effort */ }
172
- }
220
+ await rollback();
173
221
  throw error;
174
222
  }
175
223
  }
@@ -181,19 +229,14 @@ export function createAgents(ctx) {
181
229
  for (;;) {
182
230
  try {
183
231
  await call('agent.start', params, timeout + 5000);
184
- return { paneId, name };
232
+ return { paneId, name, created: workspaceCreated };
185
233
  }
186
234
  catch (error) {
187
235
  if (START_RETRYABLE.has(codeOf(error) ?? '') && Date.now() < deadline) {
188
236
  await sleep(START_RETRY_DELAY_MS);
189
237
  continue;
190
238
  }
191
- if (created) {
192
- try {
193
- await call('pane.close', { pane_id: paneId });
194
- }
195
- catch { /* rollback is best-effort */ }
196
- }
239
+ await rollback();
197
240
  if (launch)
198
241
  throw fail('start_failed', words('agent.notReady'));
199
242
  throw error;
@@ -205,10 +248,7 @@ export function createAgents(ctx) {
205
248
  // rides reads only, so a snapshot taken mid-launch goes stale. `interactive_ready` is not gated:
206
249
  // Herdr sets it only for agents its own `agent.start` settled, and its `agent.prompt` accepts the
207
250
  // rest. A refusal never sends `agent.prompt`.
208
- const ready = () => {
209
- const agent = agentOf(ctx.snapshot(), target.paneId);
210
- return agent !== undefined && agent.launchPending !== true && PROMPTABLE.includes(agent.status);
211
- };
251
+ const ready = () => isPromptable(agentOf(ctx.snapshot(), target.paneId));
212
252
  if (!ready()) {
213
253
  await ctx.reread(target.paneId);
214
254
  if (!ready())
@@ -249,6 +289,35 @@ export function createAgents(ctx) {
249
289
  return { paneId: a.pane_id, terminalId: a.terminal_id, revision: a.revision, status: a.agent_status,
250
290
  ...(agentSession === undefined ? {} : { agentSession }) };
251
291
  }
292
+ // Wait until a just-started agent can take a prompt, instead of the host polling `prompt` and
293
+ // catching `agent-not-ready`. Resolves on the kit's own snapshot once `isPromptable` holds; the
294
+ // snapshot is re-read each pass because `launch_pending` rides `agent.get` reads only. A pane that
295
+ // closed while waiting rejects `pane-unavailable` (confirmed by an `agent_not_found` read); the
296
+ // deadline rejects `agent-not-ready`. Either way the wait never hangs.
297
+ async function waitPromptable(target, o) {
298
+ const deadline = Date.now() + o.timeoutMs;
299
+ const agent = () => agentOf(ctx.snapshot(), target.paneId);
300
+ for (;;) {
301
+ if (isPromptable(agent()))
302
+ return;
303
+ if (agent() === undefined) {
304
+ try {
305
+ await call('agent.get', { target: target.paneId });
306
+ }
307
+ catch (error) {
308
+ if (codeOf(error) === 'agent_not_found')
309
+ throw fail('pane-unavailable', 'That pane is no longer available.');
310
+ throw error;
311
+ }
312
+ }
313
+ if (Date.now() >= deadline)
314
+ throw fail('agent-not-ready', 'That agent is not ready for a prompt yet.');
315
+ await ctx.reread(target.paneId);
316
+ if (isPromptable(agent()))
317
+ return;
318
+ await sleep(WAIT_PROMPTABLE_POLL_MS);
319
+ }
320
+ }
252
321
  async function wait(target, o) {
253
322
  const made = await call('agent.wait', { target: target.paneId,
254
323
  ...(o.until === undefined ? {} : { until: o.until }), timeout_ms: o.timeoutMs });
@@ -312,7 +381,7 @@ export function createAgents(ctx) {
312
381
  ...(signedIn === 'yes' ? {} : { signInHint: SIGNIN_HINTS[kind] ?? `Sign in to ${kind} on this computer, then check again.` }) };
313
382
  }));
314
383
  }
315
- return { startAgent, prompt, sendKeys, wait, read, agentKinds, installedAgentKinds, agentStatus };
384
+ return { startAgent, prompt, waitPromptable, sendKeys, wait, read, agentKinds, installedAgentKinds, agentStatus };
316
385
  }
317
386
  // One source of truth for install detection (B5's probe plus the shim layer below): the first
318
387
  // executable file named for the kind across the probe path, with its absolute path.
@@ -352,9 +421,8 @@ function readHead(file) {
352
421
  catch { /* already gone */ }
353
422
  }
354
423
  }
355
- // A mise-style auto-install launcher reads as a shell script that execs through mise — the
356
- // found "binary" runs, but the first run installs the agent instead. Real binaries (ELF,
357
- // Mach-O) never match: the shebang gate keeps a stray "mise" substring ("promise", …) out.
424
+ // A mise-style auto-install launcher is a shell script that goes through mise — the found
425
+ // "binary" runs, but the first run installs the agent instead (see isMiseLauncher in muse.ts).
358
426
  export function isAutoInstallShim(file, readFile = readHead) {
359
427
  let head;
360
428
  try {
@@ -363,7 +431,7 @@ export function isAutoInstallShim(file, readFile = readHead) {
363
431
  catch {
364
432
  return false;
365
433
  }
366
- return head !== undefined && head.startsWith('#!') && head.includes('mise');
434
+ return head !== undefined && isMiseLauncher(head);
367
435
  }
368
436
  // Muse has no absent-CLI auto-installer in stock Herdr. Its official launcher needs the
369
437
  // selected native release too. Other kinds retain their legacy shim classification.
@@ -381,7 +449,7 @@ export function agentInstallState(kind, o) {
381
449
  return { kind, state: 'missing', path: found.path };
382
450
  }
383
451
  const head = (o?.readFile ?? readHead)(found.path);
384
- if (head?.startsWith('#!') && (head.includes('mise') || (head.includes('MUSE_CHANNEL') && !museNative(found.path)))) {
452
+ if (head !== undefined && (isMiseLauncher(head) || (head.startsWith('#!') && head.includes('MUSE_CHANNEL') && !museNative(found.path)))) {
385
453
  return { kind, state: 'missing', path: found.path };
386
454
  }
387
455
  return { kind, state: 'installed', path: found.path };
package/dist/close.js CHANGED
@@ -4,7 +4,7 @@ const codeOf = (error) => {
4
4
  return code;
5
5
  return /herdr: ([a-z0-9_]+):/i.exec(error instanceof Error ? error.message : String(error))?.[1];
6
6
  };
7
- const fail = (code, message) => Object.assign(new Error(message), { code });
7
+ const fail = (code, message, wider) => Object.assign(new Error(message), { code }, wider ?? {});
8
8
  const unavailable = (kind) => fail(`${kind}-unavailable`, `That ${kind} is no longer available.`);
9
9
  const count = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
10
10
  export async function closePane(call, paneId) {
@@ -21,17 +21,20 @@ export async function closePane(call, paneId) {
21
21
  if (typeof tabId !== 'string')
22
22
  throw unavailable('pane');
23
23
  let paneCount = 0;
24
+ let tabLabel;
24
25
  try {
25
26
  const made = (await call('tab.get', { tab_id: tabId }));
26
27
  paneCount = count(made?.tab?.pane_count);
28
+ tabLabel = typeof made?.tab?.label === 'string' ? made.tab.label : undefined;
27
29
  }
28
30
  catch (error) {
29
31
  if (codeOf(error) === 'tab_not_found')
30
32
  throw unavailable('pane');
31
33
  throw error;
32
34
  }
33
- if (paneCount <= 1)
34
- throw fail('pane-close-would-widen', 'Closing this pane would close its tab; close the tab instead.');
35
+ if (paneCount <= 1) {
36
+ throw fail('pane-close-would-widen', 'Closing this pane would close its tab; close the tab instead.', { scope: 'tab', id: tabId, label: tabLabel ?? '' });
37
+ }
35
38
  await call('pane.close', { pane_id: paneId });
36
39
  }
37
40
  export async function closeTab(call, tabId) {
@@ -48,24 +51,29 @@ export async function closeTab(call, tabId) {
48
51
  if (typeof workspaceId !== 'string')
49
52
  throw unavailable('tab');
50
53
  let tabCount = 0;
54
+ let workspaceLabel;
51
55
  try {
52
56
  const made = (await call('workspace.get', { workspace_id: workspaceId }));
53
57
  tabCount = count(made?.workspace?.tab_count);
58
+ workspaceLabel = typeof made?.workspace?.label === 'string' ? made.workspace.label : undefined;
54
59
  }
55
60
  catch (error) {
56
61
  if (codeOf(error) === 'workspace_not_found')
57
62
  throw unavailable('tab');
58
63
  throw error;
59
64
  }
60
- if (tabCount <= 1)
61
- throw fail('tab-close-would-widen', 'Closing this tab would close its workspace; close the workspace instead.');
65
+ if (tabCount <= 1) {
66
+ throw fail('tab-close-would-widen', 'Closing this tab would close its workspace; close the workspace instead.', { scope: 'workspace', id: workspaceId, label: workspaceLabel ?? '' });
67
+ }
62
68
  await call('tab.close', { tab_id: tabId });
63
69
  }
64
70
  export async function closeWorkspace(call, workspaceId) {
65
71
  let worktree;
72
+ let workspaceLabel;
66
73
  try {
67
74
  const made = (await call('workspace.get', { workspace_id: workspaceId }));
68
75
  worktree = made?.workspace?.worktree;
76
+ workspaceLabel = typeof made?.workspace?.label === 'string' ? made.workspace.label : undefined;
69
77
  }
70
78
  catch (error) {
71
79
  if (codeOf(error) === 'workspace_not_found')
@@ -78,7 +86,8 @@ export async function closeWorkspace(call, workspaceId) {
78
86
  const group = (Array.isArray(made?.workspaces) ? made.workspaces : [])
79
87
  .filter((w) => w?.worktree?.repo_key === worktree.repo_key).length;
80
88
  if (group >= 2) {
81
- throw fail('workspace-close-would-widen', 'Closing this workspace would close its worktree group; close the linked checkouts instead.');
89
+ // The group is anchored at this root, so the wider target is named by that root workspace.
90
+ throw fail('workspace-close-would-widen', 'Closing this workspace would close its worktree group; close the linked checkouts instead.', { scope: 'workspace', id: workspaceId, label: workspaceLabel ?? '' });
82
91
  }
83
92
  }
84
93
  await call('workspace.close', { workspace_id: workspaceId });
package/dist/constants.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The pinned Herdr release (docs/runtime-kits.md D5) and the protocol this kit speaks.
2
2
  export const HERDR_VERSION = '0.9.1';
3
3
  // From the v0.9.1 schema snapshot (schema/herdr-api-0.9.1.json, docs/runtime-kits.md 6.7);
4
- // regenerated by scripts/gen-types.ts and pinned by test/generated.test.ts.
4
+ // regenerated by scripts/gen-types.ts.
5
5
  export const HERDR_PROTOCOL = 22;
6
6
  // Effective accepted server protocols for K11: each undeclared bound means exactly the pin.
7
7
  export function protocolBounds(range) {
package/dist/device.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { DeviceLink } from '@byokit/link';
1
+ import type { DeviceLink } from '@byokit/pair';
2
2
  import type { AgentRef, AgentStatus, BlockedAgent, HerdrEventName, HerdrEventOf, HerdrMethod, HerdrParams, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, PromptReceipt, StartAgent } from './types.ts';
3
3
  export type { HerdrEventName, HerdrEventOf, HerdrEvents, HerdrMethod, HerdrMethods, HerdrParams, HerdrResult, HerdrSubscription, } from './types.ts';
4
4
  /** One `hd.events` frame (7.2); `raw` carries a line that was not JSON. */
@@ -53,6 +53,24 @@ export declare function herdrDevice(link: DeviceLink): {
53
53
  tab?: string;
54
54
  workspace?: string;
55
55
  }): Promise<void>;
56
+ rename(o: {
57
+ pane?: string;
58
+ tab?: string;
59
+ workspace?: string;
60
+ label: string;
61
+ }): Promise<void>;
62
+ focus(o: {
63
+ pane?: string;
64
+ tab?: string;
65
+ workspace?: string;
66
+ }): Promise<void>;
67
+ layout(o: {
68
+ root: HerdrParams<'layout.apply'>['root'];
69
+ focus?: boolean;
70
+ tabId?: string;
71
+ tabLabel?: string;
72
+ workspaceId?: string;
73
+ }): Promise<HerdrResult<'layout.apply'>>;
56
74
  events(): AsyncIterable<HerdrLinkEvent>;
57
75
  subscribe<E extends HerdrEventName>(subs: HerdrSubscription<E>[], on: (e: HerdrEventOf<E>) => void, onError?: (message: string) => void): () => void;
58
76
  registerNotices(seed: Uint8Array): Promise<void>;