@byokit/herdr 0.1.0-next.dbe7142 → 0.1.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,53 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Opt-in pinned Herdr fetch (G11, `./binary`): `ensureHerdr({ dir, platform? })` (also spelled
6
+ `fetchHerdr`) downloads the pinned v0.9.1 asset for the current platform into an app-owned `dir`
7
+ only when called, verifies it against a committed per-platform sha256 table (linux x64/arm64,
8
+ darwin x64/arm64 — each hash recomputed over the official release asset), writes atomically, marks
9
+ executable, and returns the absolute `own`-mode `bin`. Never PATH, never `~/.local/bin`, nothing on
10
+ install or import; a hash mismatch (nothing left behind) or unsupported platform fails in plain words.
11
+
12
+ - K11 protocol range: `HerdrKitOptions` takes `protocolRange?: { min?: number; max?: number }`
13
+ (each bound defaults to the pinned `HERDR_PROTOCOL`). A server below the floor still fails
14
+ closed; a newer server is the steady state `needs-update`/`version` instead of a throw — the
15
+ snapshot installs and the kit stays usable — so a Herdr protocol bump does not take the host
16
+ down before the kit's pin moves.
17
+ - `herdrContract(make, { test })` accepts the runner's `test` (node:test's by default), so a vitest host suite can run the contract against its lab Herdr.
18
+
19
+ ## 0.1.0 (2026-09-29)
20
+ - Typed device pass-through (G4, docs/runtime-kits.md 7.1/7.2): `herdrDevice(link).call<M>(method, params)` returns
21
+ `HerdrResult<M>` from the generated table, `events()` yields `HerdrLinkEvent` frames, and `./device` re-exports the
22
+ table types. New link ops `hd.kinds` (*view*, `agentKinds()`), `hd.wait` (control, pane in scope) and `hd.subscribe`
23
+ (stream, *view*; filter panes must be in scope, a scoped grant gets only events whose every workspace is in scope)
24
+ with device `agentKinds()`, `wait()` and typed `subscribe(subs, on, onError?)`; a rejected batch or a kit
25
+ disconnect ends the stream (`kit.subscribe` forwards an optional `onError`). The device `terminal()` gains `ready`
26
+ (first frame) and `exited` (`{ reason }`). `hd.call` stays default-denied.
27
+ - K2 snapshot fields: `snapshot()` carries the agent (`agentSession`, `displayAgent`, `title`,
28
+ `foregroundCwd`), pane (`label`, `focused`, `terminalTitle` from `terminal_title_stripped`, `tokens`)
29
+ and workspace (`focused`, `number`, `tokens`, `worktree` in camelCase) fields; `pane.updated`
30
+ titles and labels merge in place with no re-bootstrap; a per-pane lifecycle epoch keeps a status
31
+ push that races a snapshot read (the live status and revision win over the read). The fake seeds
32
+ and serves every field (agent `title`, workspace `number`/`tokens`, `workspace.report_metadata`).
33
+ - `words`, `stateWords`, `agentWords` and `WORDS` are exported from `.` and `./device`, so a host or phone UI
34
+ shows the kit's own sentences for Herdr and agent states instead of writing its own.
35
+ - FIX `herdrDevice(link).events()` ends its link stream when the reader stops (`break`, or `return()` from a
36
+ view that stops watching), even while it waits for the next frame; before, the stream stayed open and the
37
+ `return()` never settled.
38
+ - FIX the fake Herdr (`./testing`) emits `workspace.created`, `tab.created`, `pane.created`,
39
+ `pane.agent_detected` and the matching `*.closed` events for its own layout changes, so `snapshot()` shows an
40
+ agent started in a new workspace; `herdrContract` holds it (a started agent joins `snapshot()` and its status
41
+ stays current there).
42
+ - FIX `hd.prompt` over a link answers a not-ready agent with the `agent.notReady` sentence (and a blocked one
43
+ with `agent.blocked`) instead of the link's generic failure.
44
+
45
+ - K3 raw event tap: `kit.onEvent(fn)` delivers every event from the kit's own batch and
46
+ per-pane status sockets with wire payloads intact (`pane.moved.previous_pane_id`,
47
+ `workspace.*`), firing on arrival before the snapshot update/refresh (buffered replays never
48
+ re-fire, `subscribe()` sockets are not tapped, the tap opens no socket); plus
49
+ `kit.statusWatchReady()` resolving when the status-watch set has acked. The fake exposes
50
+ `subscriptionCount()` (distinct held subscription sockets).
51
+
5
52
  - FIX docs drift (K10): README pins the v0.9.1 snapshot (protocol 22) instead of the H1
6
53
  protocol placeholder; `agents.ts` agrees muxr sends `target_pane_id` to `pane.split`; the
7
54
  fake bin's `api schema` reports the pinned identity with no placeholder text.
@@ -9,6 +56,31 @@
9
56
  - `adopt` mode takes `env` and `path` like `own`, merged into the `cli()`/`terminal()` env (so `HOME`,
10
57
  `HERDR_CLIENT_SOCKET_PATH`, `HERDR_SESSION` and a `#!/usr/bin/env node` bin work); `TerminalSession.pause()`/`resume()`
11
58
  apply backpressure to the Herdr child instead of buffering frames in the host.
59
+ - K5 non-fatal start: a rejected `start()` (e.g. `failed/socket` while Herdr is down) may be called again —
60
+ the host comes up while Herdr is down by retrying `start()` in a backoff loop. A failed attempt drops its
61
+ event subscription, and `stop()` drops the closed transport, so a retry or restart dials clean.
62
+ - `installedAgentKinds` takes `aliases?: Record<kind, string[]>`: a kind counts as installed when its own
63
+ name or any of its aliases names an executable on the given path (e.g. kind `cursor` with
64
+ `{ cursor: ['cursor-agent'] }` matches a `cursor-agent` binary).
65
+
66
+ - Lab contract run against real Herdr v0.9.1 (docs/runtime-kits.md §11.3 H9):
67
+ `test/lab/contract.lab.ts` (17 pass, 0 fail; agent cases skipped — the schema has no
68
+ `bash` custom kind — and the live blocked-agent push skipped for the same reason)
69
+ with the result log in `schema/LAB.md`. Covers the H7 link/device/notices/
70
+ terminal-over-link surface over a real Host + DeviceLink pair on loopback. The run
71
+ found and fixed one fake/real disagreement: live frames carry the underscore const
72
+ (`pane_created`) while subscription kinds use dots, so the kit now matches both
73
+ spellings at the event boundary and the fake emits real-style underscore wire frames.
74
+ - FIX: match both event spellings at the event boundary: live frames on real Herdr carry
75
+ the underscore const (`pane_created`) while subscription kinds use dots. 0.1.0-next
76
+ matched dots only, so no live `created`/`closed`/status event ever updated the tree.
77
+ - `herdrContract(make, { test })` accepts the runner's `test` (node:test's by default), so a vitest host suite can run the contract against its lab Herdr.
78
+ - K7 `startAgent` parity: `agent_pane_busy`/`agent_pane_unavailable` retry inside a bounded 5 s
79
+ budget, `pane.close` rollback of the created pane when `agent.start` fails (a caller-owned `pane`
80
+ placement is never closed), `focus: false` with the env on `worktree.create`, an optional
81
+ `worktree.branch`, and an `agentStartFaults` hook on the fake scripting `agent.start` failures; the
82
+ fake's `worktree.create` opens the linked checkout with its root tab and pane like the pinned server,
83
+ and every placement records its env on the fresh pane where `pane.get` surfaces it.
12
84
  - FIX: the packed `dist/words.d.ts` keeps `with { type: 'json' }` on its `./words.json` import, so a strict NodeNext consumer with `skipLibCheck: false` no longer fails with TS1543.
13
85
 
14
86
  - Scaffold (docs/runtime-kits.md §11.3 H1): frozen public types, kit facade, internal seams and words. Behavior
package/README.md CHANGED
@@ -1,18 +1,188 @@
1
- # @byokit/herdr
1
+ <h1 align="center">@byokit/herdr</h1>
2
2
 
3
- Drive the Herdr already on this computer — workspaces, tabs, panes, coding agents and blocked-approval answers —
4
- from an app, or hand it to a phone over [`@byokit/link`](../link). Each agent inside Herdr keeps its own
5
- subscription login; the kit never sees a credential.
3
+ <p align="center">
4
+ <img alt="status: in development" src="https://img.shields.io/badge/status-in%20development-lightgrey?style=flat" />
5
+ <a href="https://github.com/umeranjum17/byokit/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/umeranjum17/byokit/ci.yml?style=flat&branch=main" /></a>
6
+ <a href="LICENSE"><img alt="Apache 2.0" src="https://img.shields.io/badge/license-Apache--2.0-666?style=flat" /></a>
7
+ </p>
6
8
 
7
- Two ways in: `adopt` talks to the socket path the app passes (the person's own Herdr, nothing spawned, nothing
8
- written), `own` runs the Herdr binary the app names in an app-owned state directory. Helpers cover the common
9
- paths (start an agent, deliver a prompt with a receipt, watch blocked agents, exact closes); `call` and
10
- `subscribe` are typed pass-throughs to the complete socket API, and `cli()` reaches the full CLI.
9
+ <p align="center"><strong>Drive the Herdr already on this computer from an app, or from a phone.</strong><br/>
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
12
+ never sees a credential.</p>
11
13
 
12
- **Status:** pinned to Herdr v0.9.1 — the schema snapshot (`schema/herdr-api-0.9.1.json`,
13
- protocol 22) generates the typed surface (`src/generated/`, `HERDR_PROTOCOL` in `src/constants.ts`).
14
- See [docs/runtime-kits.md](../../docs/runtime-kits.md) §11.3 for the work packages.
14
+ <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
+ </p>
19
+ <p align="center"><sub>The <a href="../../examples/herdr-kit">herdr-kit example</a>'s phone page: an agent at work, its
20
+ question, answered. Captured by the example's e2e in a phone-sized headless Chromium, against the kit's stand-in
21
+ Herdr.</sub></p>
15
22
 
16
- Herdr itself is not an npm dependency; the app installs it (see herdr.dev) and passes `bin`. The kit reads no
17
- environment variables, spawns processes with an explicit env only, and — like every byokit package — never
18
- touches a person's other AI tools; its tests run against a fake Herdr.
23
+ <p align="center">
24
+ <img src="https://raw.githubusercontent.com/umeranjum17/byokit/main/docs/images/herdr-kit-host.png" width="420" alt="Terminal running npm start -- --herdr &quot;$(command -v herdr)&quot; --via lan --name 'Kitchen computer': a QR code, then 'On the phone, scan this, or open http://192.168.1.144:7310/ and type K3J8-CJZ7-SE4R', 'Codes last five minutes. Press Enter for new ones.', 'Connecting to Herdr…' and 'Connected to Herdr.'" />
25
+ </p>
26
+ <p align="center"><sub>The same example's host terminal (<code>host.ts</code>), started with <code>BYOKIT_EXAMPLE_FAKE=1</code> so it runs the kit's stand-in Herdr.</sub></p>
27
+
28
+ ## Quickstart
29
+
30
+ First release 0.1.0; use it from this repo, or from its packed packages as
31
+ [`examples/herdr-kit`](../../examples/herdr-kit) does. From npm once it is published:
32
+
33
+ ```sh
34
+ npm install @byokit/herdr
35
+ ```
36
+
37
+ Herdr itself is not an npm dependency: the app installs Herdr 0.9.1 (see herdr.dev) and passes its path as `bin`.
38
+ The opt-in helper `ensureHerdr` (`@byokit/herdr/binary`, also spelled `fetchHerdr`) fetches that for the app:
39
+ it downloads the pinned release asset for the current platform into an app-owned directory, verifies its sha256
40
+ against a committed per-platform table, marks it executable, and returns the absolute `bin` for `own` mode.
41
+ Nothing downloads on install or import — the network happens only when the helper is called.
42
+ This runs against the kit's stand-in Herdr from `@byokit/herdr/testing`, so it needs neither Herdr nor an agent:
43
+
44
+ ```ts
45
+ import { mkdtempSync } from 'node:fs';
46
+ import { tmpdir } from 'node:os';
47
+ import { join } from 'node:path';
48
+ import { HerdrKit, agentWords, type BlockedAgent } from '@byokit/herdr';
49
+ import { startFakeHerdr } from '@byokit/herdr/testing';
50
+
51
+ // The kit's stand-in Herdr. With a real one, pass its bin and socket path instead.
52
+ const fake = await startFakeHerdr({ dir: mkdtempSync(join(tmpdir(), 'herdr-')) });
53
+ const kit = new HerdrKit({ mode: 'adopt', bin: fake.bin, socketPath: fake.socketPath });
54
+ await kit.start();
55
+
56
+ const agent = { paneId: 'w1:p2' };
57
+ await kit.prompt(agent, 'hello'); // resolves with a receipt once delivered
58
+ console.log(await kit.wait(agent, { until: ['idle'], timeoutMs: 5000 }));
59
+ console.log((await kit.read(agent.paneId)).text);
60
+
61
+ // The question arrives once the kit has read it off the pane, so wait for it.
62
+ const asked = new Promise<BlockedAgent>((resolve) => {
63
+ const off = kit.onBlocked((b, change) => {
64
+ if (change === 'added' && b.paneId === agent.paneId) { off(); resolve(b); }
65
+ });
66
+ });
67
+ await kit.prompt(agent, 'ask permission');
68
+ const question = await asked;
69
+ console.log(agentWords('blocked'), '|', question.prompt);
70
+ await kit.answer(agent.paneId, ['y'], { revision: question.revision });
71
+ console.log(await kit.wait(agent, { until: ['idle'], timeoutMs: 5000 }));
72
+
73
+ await kit.stop();
74
+ await fake.stop();
75
+ ```
76
+
77
+ ```text
78
+ idle
79
+ ready.
80
+ fake pi: hello
81
+ Waiting for your answer. | Allow this? (y/n)
82
+ idle
83
+ ```
84
+
85
+ To hand Herdr to a phone, spread `herdrLink` into a `@byokit/link` host and serve it; the phone calls the same
86
+ things through `herdrDevice`:
87
+
88
+ ```ts
89
+ import { ensureHerdr } from '@byokit/herdr/binary';
90
+ import { HerdrKit } from '@byokit/herdr';
91
+
92
+ // One call, only when the app wants it: the pinned Herdr 0.9.1 binary lands verified in an
93
+ // app-owned directory (never PATH, never ~/.local/bin), ready for own mode.
94
+ const bin = await ensureHerdr({ dir: './.state/herdr-bin' });
95
+ const own = new HerdrKit({ mode: 'own', bin, stateDir: './.state' });
96
+ ```
97
+
98
+ ```ts
99
+ import { HerdrKit } from '@byokit/herdr';
100
+ import { herdrLink, serve } from '@byokit/herdr/link';
101
+ import { Host, type Grant } from '@byokit/link';
102
+ import { hostKeyFile } from '@byokit/link/node';
103
+
104
+ const kit = new HerdrKit({ mode: 'own', bin: '/usr/local/bin/herdr', stateDir: './.state' });
105
+ const grants: Grant[] = [];
106
+
107
+ const host = await Host.open({
108
+ keys: hostKeyFile('./.state/link-key.json'),
109
+ name: 'my-computer',
110
+ grants: { load: () => grants, save: (g) => { grants.splice(0, grants.length, ...g); } },
111
+ confirm: async () => true, // ask the person at the computer here
112
+ // Each paired device sees the workspaces its grant's meta names; none by default.
113
+ ...herdrLink(kit, {
114
+ scopeOf: (g) => (g.meta as { scope?: { workspaces: 'all' | string[] } } | undefined)?.scope ?? { workspaces: [] },
115
+ }),
116
+ });
117
+ const served = await serve({ host, port: 7310 });
118
+ console.log(served.urls);
119
+ kit.start().catch(() => {}); // non-fatal: retry later while Herdr is down
120
+ ```
121
+
122
+ ```ts
123
+ import type { DeviceLink } from '@byokit/link';
124
+ import { agentWords, herdrDevice } from '@byokit/herdr/device';
125
+
126
+ declare const link: DeviceLink; // from pairing (pairWithCode / pairWithOffer)
127
+
128
+ const hd = herdrDevice(link);
129
+ const agent = await hd.startAgent({ kind: 'pi', cwd: '/home/me/project', place: { workspace: 'new' } });
130
+ await hd.prompt(agent.paneId, 'Run the tests');
131
+ for (const q of await hd.blocked()) {
132
+ console.log(agentWords('blocked'), q.prompt);
133
+ await hd.answer(q.paneId, ['y'], q.revision);
134
+ }
135
+ ```
136
+
137
+ The [herdr-kit example](../../examples/herdr-kit) is the full version: pairing with a QR code and typed code, a
138
+ persistent grant store, and the phone page above.
139
+
140
+ ## API at a glance
141
+
142
+ | Export | What it does |
143
+ |---|---|
144
+ | `HerdrKit` (`@byokit/herdr`) | The host-side kit, in `adopt` or `own` mode: `start`/`stop`, `state`, `snapshot`/`onChange`, `startAgent`, `prompt`, `sendKeys`, `wait`, `read`, `blocked`/`onBlocked`/`answer`, `closePane`/`closeTab`/`closeWorkspace`, `agentKinds`, `installedAgentKinds`, `terminal`, `onEvent`, `statusWatchReady`, and the pass-throughs `call`, `subscribe` and `cli` |
145
+ | `HERDR_VERSION`, `HERDR_PROTOCOL` | The pinned Herdr release (`0.9.1`) and the protocol the kit speaks (`22`) |
146
+ | `words`, `agentWords`, `stateWords`, `WORDS` | Plain sentences for agent statuses and kit states |
147
+ | `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 |
148
+ | `serve` (`@byokit/herdr/link`) | Finds the address with `@byokit/reach` and serves the link (and an optional page) on one port |
149
+ | `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 |
150
+ | `openNotice` (`@byokit/herdr/device`) | Opens a sealed approval notice with the device's own seed |
151
+ | `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 |
152
+ | `ensureHerdr`, `fetchHerdr`, `HERDR_ASSETS` (`@byokit/herdr/binary`) | Opt-in pinned fetch: the v0.9.1 asset for the current platform into an app-owned dir, sha256-verified, executable, returned as the absolute `own`-mode `bin`; refused (nothing left) on a hash mismatch or unsupported platform |
153
+
154
+ Types (`HerdrKitOptions`, `HerdrState`, `StartAgent`, `PromptReceipt`, `BlockedAgent`, `HerdrSnapshot`,
155
+ `HerdrMethod`/`HerdrParams`/`HerdrResult`, `HerdrEventName`/`HerdrEventOf`, ...) come from the main entry.
156
+
157
+ ## Two ways in
158
+
159
+ - `adopt` talks to the socket path the app passes: the person's own Herdr, nothing spawned, nothing written.
160
+ - `own` runs the Herdr binary the app names, in an app-owned state directory.
161
+
162
+ Helpers cover the common paths (start an agent, deliver a prompt with a receipt, watch blocked agents, exact closes).
163
+ `call` and `subscribe` are typed pass-throughs to the complete socket API, and `cli()` reaches the full CLI.
164
+
165
+ `start()` is non-fatal: if Herdr is down it rejects (state `failed`, e.g. `failed/socket`) and may be called again
166
+ afterwards, so the host comes up while Herdr is down by retrying `start()` in a backoff loop.
167
+
168
+ ## Status
169
+
170
+ Pinned to Herdr v0.9.1. The schema snapshot (`schema/herdr-api-0.9.1.json`, protocol 22) generates the typed surface
171
+ (`src/generated/`, `HERDR_PROTOCOL` in `src/constants.ts`). See [docs/runtime-kits.md](../../docs/runtime-kits.md)
172
+ §11.3 for the work packages.
173
+
174
+ ## Isolation
175
+
176
+ The kit reads no environment variables, spawns processes with an explicit env only, and, like every byokit
177
+ package, never touches a person's other AI tools. Its tests run against a fake Herdr.
178
+
179
+ ## Links
180
+
181
+ - [byokit](../../README.md): the other packages and the repo's isolation rules
182
+ - [examples/herdr-kit](../../examples/herdr-kit): Herdr's agents from a phone browser
183
+ - [docs/runtime-kits.md](../../docs/runtime-kits.md): the runtime kits' decisions and work packages
184
+ - [CHANGELOG.md](CHANGELOG.md)
185
+
186
+ ## License
187
+
188
+ Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](../../NOTICE).
package/dist/agents.js CHANGED
@@ -6,8 +6,21 @@ import { accessSync, constants as fsConstants } from 'node:fs';
6
6
  import { join } from 'node:path';
7
7
  const PROMPTABLE = ['idle', 'working', 'blocked', 'done'];
8
8
  const DEFAULT_TIMEOUT_MS = 60_000;
9
+ // `agent.start` lands while the fresh pane is still at its shell prompt: the server answers
10
+ // `agent_pane_busy`/`agent_pane_unavailable` until the pane is ready. The kit retries those for a
11
+ // bounded 5 s, then rolls the pane it created back with `pane.close` like any other start failure.
12
+ const START_RETRYABLE = new Set(['agent_pane_busy', 'agent_pane_unavailable']);
13
+ const START_RETRY_BUDGET_MS = 5_000;
14
+ const START_RETRY_DELAY_MS = 100;
9
15
  const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
10
16
  const fail = (code, message) => Object.assign(new Error(message), { code });
17
+ const codeOf = (error) => {
18
+ const code = error?.code;
19
+ if (typeof code === 'string')
20
+ return code;
21
+ return /herdr: ([a-z0-9_]+):/i.exec(error instanceof Error ? error.message : String(error))?.[1];
22
+ };
23
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
11
24
  function agentOf(snapshot, paneId) {
12
25
  for (const w of snapshot.workspaces)
13
26
  for (const t of w.tabs)
@@ -29,12 +42,16 @@ function rootPaneOf(result) {
29
42
  export function createAgents(ctx) {
30
43
  const call = ctx.call;
31
44
  // Every placement takes its pane id from the server's answer, never a prediction (6.4).
45
+ // Placements that make a pane (every path but `pane`) roll it back with `pane.close` when the
46
+ // start fails, so a failed start leaves no extra pane behind.
32
47
  async function startAgent(o) {
33
48
  const timeout = o.timeoutMs ?? DEFAULT_TIMEOUT_MS;
34
49
  const env = o.env === undefined ? {} : { env: o.env };
35
50
  let paneId;
51
+ let created = false;
36
52
  if (o.worktree !== undefined) {
37
- const made = await call('worktree.create', { cwd: o.cwd, branch: o.worktree.branch,
53
+ const made = await call('worktree.create', { cwd: o.cwd, focus: false, ...env,
54
+ ...(o.worktree.branch === undefined ? {} : { branch: o.worktree.branch }),
38
55
  ...(o.worktree.base === undefined ? {} : { base: o.worktree.base }) });
39
56
  const worktree = isObj(made?.worktree) ? made.worktree : undefined;
40
57
  const checkout = typeof worktree?.checkout_path === 'string' ? worktree.checkout_path
@@ -44,19 +61,23 @@ export function createAgents(ctx) {
44
61
  paneId = rootPaneOf(made) ?? rootPaneOf(await call('tab.create', {
45
62
  workspace_id: made?.workspace?.workspace_id, cwd: checkout, focus: false, ...env,
46
63
  }));
64
+ created = true;
47
65
  }
48
66
  else if ('workspace' in o.place) {
49
67
  paneId = rootPaneOf(await call('workspace.create', { cwd: o.cwd,
50
68
  ...(o.place.label === undefined ? {} : { label: o.place.label }), focus: false, ...env }));
69
+ created = true;
51
70
  }
52
71
  else if ('tab' in o.place) {
53
72
  paneId = rootPaneOf(await call('tab.create', { workspace_id: o.place.workspaceId, cwd: o.cwd,
54
73
  ...(o.place.label === undefined ? {} : { label: o.place.label }), focus: false, ...env }));
74
+ created = true;
55
75
  }
56
76
  else if ('split' in o.place) {
57
77
  const made = await call('pane.split', { target_pane_id: o.place.split,
58
78
  direction: o.place.direction, focus: false, ...env });
59
79
  paneId = typeof made?.pane?.pane_id === 'string' ? made.pane.pane_id : undefined;
80
+ created = true;
60
81
  }
61
82
  else {
62
83
  paneId = o.place.pane;
@@ -64,9 +85,28 @@ export function createAgents(ctx) {
64
85
  if (paneId === undefined)
65
86
  throw new Error('herdr: the placement answered no pane id');
66
87
  const name = agentName(o);
67
- await call('agent.start', { pane_id: paneId, kind: o.kind, name,
68
- ...(o.args === undefined ? {} : { args: o.args }), timeout_ms: timeout }, timeout + 5000);
69
- return { paneId, name };
88
+ const params = { pane_id: paneId, kind: o.kind, name,
89
+ ...(o.args === undefined ? {} : { args: o.args }), timeout_ms: timeout };
90
+ const deadline = Date.now() + START_RETRY_BUDGET_MS;
91
+ for (;;) {
92
+ try {
93
+ await call('agent.start', params, timeout + 5000);
94
+ return { paneId, name };
95
+ }
96
+ catch (error) {
97
+ if (START_RETRYABLE.has(codeOf(error) ?? '') && Date.now() < deadline) {
98
+ await sleep(START_RETRY_DELAY_MS);
99
+ continue;
100
+ }
101
+ if (created) {
102
+ try {
103
+ await call('pane.close', { pane_id: paneId });
104
+ }
105
+ catch { /* rollback is best-effort */ }
106
+ }
107
+ throw error;
108
+ }
109
+ }
70
110
  }
71
111
  async function prompt(target, text, o) {
72
112
  // The readiness gate reads the kit's own tree: a not-promptable agent is refused with no socket call.
@@ -124,15 +164,16 @@ export function createAgents(ctx) {
124
164
  function installedAgentKinds(kinds, o) {
125
165
  const installed = [];
126
166
  for (const kind of kinds) {
127
- if (o.path.some((dir) => {
167
+ const names = [kind, ...(o.aliases?.[kind] ?? [])];
168
+ if (names.some((name) => o.path.some((dir) => {
128
169
  try {
129
- accessSync(join(dir, kind), fsConstants.X_OK);
170
+ accessSync(join(dir, name), fsConstants.X_OK);
130
171
  return true;
131
172
  }
132
173
  catch {
133
174
  return false;
134
175
  }
135
- }))
176
+ })))
136
177
  installed.push(kind);
137
178
  }
138
179
  return installed;
@@ -0,0 +1,29 @@
1
+ export type HerdrBinaryPlatform = 'linux-x64' | 'linux-arm64' | 'darwin-x64' | 'darwin-arm64';
2
+ type PinnedAsset = {
3
+ url: string;
4
+ sha256: string;
5
+ };
6
+ /** The pinned asset (URL and sha256) for each platform this helper covers. */
7
+ export declare const HERDR_ASSETS: Record<HerdrBinaryPlatform, PinnedAsset>;
8
+ export type EnsureHerdrOptions = {
9
+ /** App-owned directory the binary lives in; created when missing. Never a shared location. */
10
+ dir: string;
11
+ /** Defaults to this computer's platform. Only the four pinned platforms are supported. */
12
+ platform?: HerdrBinaryPlatform | string;
13
+ /** Defaults to the pinned release. Anything else is refused: only the pinned hashes are committed. */
14
+ version?: string;
15
+ /** Override the pinned asset URL (tests serve a loopback fixture; mirrors). The hash is still verified. */
16
+ url?: string;
17
+ /** Override the expected sha256 (tests use their fixture's hash). The download is still verified. */
18
+ sha256?: string;
19
+ };
20
+ /**
21
+ * Make sure the pinned Herdr binary is in the app-owned `dir` and return its absolute path for
22
+ * `HerdrKit` own mode. Downloads the pinned release asset only when the verified file is missing;
23
+ * an existing file whose sha256 matches is returned without touching the network. A bad download
24
+ * is refused and no file is left behind.
25
+ */
26
+ export declare function ensureHerdr(o: EnsureHerdrOptions): Promise<string>;
27
+ /** Alias of `ensureHerdr` for the `fetchHerdr({ version, dir, platform? })` spelling. */
28
+ export declare const fetchHerdr: typeof ensureHerdr;
29
+ export {};
package/dist/binary.js ADDED
@@ -0,0 +1,132 @@
1
+ // Opt-in pinned binary fetch (gap G11): `ensureHerdr({ dir })` downloads the pinned release asset for the
2
+ // current platform into an app-owned `dir`, verifies its sha256 against the committed table below, marks it
3
+ // executable, and returns the absolute `bin` for `HerdrKit` own mode. Nothing downloads on install or import —
4
+ // the network happens only when this is called. Never PATH, never `~/.local/bin`.
5
+ //
6
+ // The hashes are the v0.9.1 release manifest's (`https://herdr.dev/latest.json`) per-platform entries, each
7
+ // recomputed over the downloaded official asset before being committed (linux-x86_64 in schema/SOURCE.md via
8
+ // H2/H9; the other three against the same manifest on 2026-09-29).
9
+ import { createHash } from 'node:crypto';
10
+ import { once } from 'node:events';
11
+ import { chmodSync, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync } from 'node:fs';
12
+ import { isAbsolute, join, resolve } from 'node:path';
13
+ import { HERDR_VERSION } from "./constants.js";
14
+ const RELEASE = 'https://github.com/herdrdev/herdr/releases/download/v0.9.1';
15
+ const PINNED = {
16
+ 'linux-x64': {
17
+ url: `${RELEASE}/herdr-linux-x86_64`,
18
+ sha256: '2a02fed16beb651ef006e1d43f048f652ca4dc58ad053cd2d44450563d5c54b7',
19
+ },
20
+ 'linux-arm64': {
21
+ url: `${RELEASE}/herdr-linux-aarch64`,
22
+ sha256: 'f4ccf4de745f2cb9a39a983e9ba3703dad50ec2a58dea83026ceab721bbd8d9e',
23
+ },
24
+ 'darwin-x64': {
25
+ url: `${RELEASE}/herdr-macos-x86_64`,
26
+ sha256: '053be0639935fe54ab5efbdb46651054e4f6a753a5b43153c88bd6912bce1e94',
27
+ },
28
+ 'darwin-arm64': {
29
+ url: `${RELEASE}/herdr-macos-aarch64`,
30
+ sha256: '5fc7a7e7adfaca56fa80aa89dcb025693357268dab8285b9ce2d08a2313c89de',
31
+ },
32
+ };
33
+ /** The pinned asset (URL and sha256) for each platform this helper covers. */
34
+ export const HERDR_ASSETS = PINNED;
35
+ const PLATFORMS = Object.keys(PINNED);
36
+ function currentPlatform() {
37
+ const name = `${process.platform}-${process.arch}`;
38
+ return PLATFORMS.includes(name) ? name : undefined;
39
+ }
40
+ function sha256File(path) {
41
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
42
+ }
43
+ // The release assets are ~24 MB; a body far past that is never the pinned binary.
44
+ const MAX_BYTES = 128 * 1024 * 1024;
45
+ async function download(url, dest, expected) {
46
+ let response;
47
+ try {
48
+ response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
49
+ }
50
+ catch {
51
+ throw new Error('herdr: could not download the Herdr app (network failed); check the connection and try again');
52
+ }
53
+ if (!response.ok || !response.body) {
54
+ throw new Error(`herdr: could not download the Herdr app (server answered ${response.status}); try again later`);
55
+ }
56
+ const hash = createHash('sha256');
57
+ let bytes = 0;
58
+ const out = createWriteStream(dest, { mode: 0o600 });
59
+ try {
60
+ const reader = response.body.getReader();
61
+ try {
62
+ for (;;) {
63
+ const { done, value } = await reader.read();
64
+ if (done)
65
+ break;
66
+ bytes += value.byteLength;
67
+ if (bytes > MAX_BYTES)
68
+ throw new Error('herdr: the download is far bigger than the Herdr app; refusing it');
69
+ hash.update(value);
70
+ if (!out.write(value))
71
+ await once(out, 'drain');
72
+ }
73
+ }
74
+ finally {
75
+ reader.releaseLock();
76
+ }
77
+ out.end();
78
+ await once(out, 'close');
79
+ }
80
+ catch (error) {
81
+ try {
82
+ unlinkSync(dest);
83
+ }
84
+ catch { /* already gone */ }
85
+ throw error;
86
+ }
87
+ if (hash.digest('hex') !== expected) {
88
+ try {
89
+ unlinkSync(dest);
90
+ }
91
+ catch { /* already gone */ }
92
+ throw new Error('herdr: the downloaded Herdr app failed its safety check, so it was deleted; try again later');
93
+ }
94
+ }
95
+ /**
96
+ * Make sure the pinned Herdr binary is in the app-owned `dir` and return its absolute path for
97
+ * `HerdrKit` own mode. Downloads the pinned release asset only when the verified file is missing;
98
+ * an existing file whose sha256 matches is returned without touching the network. A bad download
99
+ * is refused and no file is left behind.
100
+ */
101
+ export async function ensureHerdr(o) {
102
+ if (o.version !== undefined && o.version !== HERDR_VERSION) {
103
+ throw new Error(`herdr: this helper only fetches the pinned Herdr ${HERDR_VERSION}; install another version by hand`);
104
+ }
105
+ const platform = o.platform ?? currentPlatform();
106
+ const asset = platform !== undefined ? PINNED[platform] : undefined;
107
+ if (platform === undefined || asset === undefined) {
108
+ throw new Error(`herdr: no pinned Herdr app for "${o.platform ?? `${process.platform}-${process.arch}`}" ` +
109
+ `(this helper covers ${PLATFORMS.join(', ')} at ${HERDR_VERSION}); install Herdr by hand`);
110
+ }
111
+ const url = o.url ?? asset.url;
112
+ if (!url.startsWith('https://') && !url.startsWith('http://')) {
113
+ throw new Error('herdr: the download address must be an http(s) address');
114
+ }
115
+ const expected = o.sha256 ?? asset.sha256;
116
+ mkdirSync(o.dir, { recursive: true });
117
+ const dir = isAbsolute(o.dir) ? o.dir : resolve(o.dir);
118
+ const bin = join(dir, 'herdr');
119
+ if (existsSync(bin) && statSync(bin).isFile() && sha256File(bin) === expected) {
120
+ chmodSync(bin, 0o755);
121
+ return bin;
122
+ }
123
+ // The download lands beside its target so the rename below is atomic; a failed or tampered
124
+ // download is deleted, never moved into place.
125
+ const temp = join(dir, `.herdr-download-${process.pid}`);
126
+ await download(url, temp, expected);
127
+ chmodSync(temp, 0o755);
128
+ renameSync(temp, bin);
129
+ return bin;
130
+ }
131
+ /** Alias of `ensureHerdr` for the `fetchHerdr({ version, dir, platform? })` spelling. */
132
+ export const fetchHerdr = ensureHerdr;
@@ -1,2 +1,9 @@
1
1
  export declare const HERDR_VERSION = "0.9.1";
2
2
  export declare const HERDR_PROTOCOL: number;
3
+ export declare function protocolBounds(range?: {
4
+ min?: number;
5
+ max?: number;
6
+ }): {
7
+ min: number;
8
+ max: number;
9
+ };
package/dist/constants.js CHANGED
@@ -3,3 +3,7 @@ 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
4
  // regenerated by scripts/gen-types.ts and pinned by test/generated.test.ts.
5
5
  export const HERDR_PROTOCOL = 22;
6
+ // Effective accepted server protocols for K11: each undeclared bound means exactly the pin.
7
+ export function protocolBounds(range) {
8
+ return { min: range?.min ?? HERDR_PROTOCOL, max: range?.max ?? HERDR_PROTOCOL };
9
+ }