@byokit/herdr 0.7.0 → 0.7.2

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,30 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.7.2 (2026-10-09)
6
+
7
+ - Dependency update: pins @byokit/relay 0.6.0.
8
+ - Dependency update: pins @byokit/link 0.8.0.
9
+ - FIX: (from @byokit/pair 0.9.0) a phone killed while the person at the computer decides now resumes against the pinned computer, for compact and version 1 QRs alike. `pairWithOffer` takes `onPending(grant)`, which hands over the pending grant to keep before the computer can approve this device, and `pendingGrant` accepts a compact QR given the authenticated `host` key, and `parseOffer` reads a compact QR for inspection (expiry, addresses, name, role) without one. Before, `pendingGrant` refused compact QRs, and even a version 1 pending grant never came online after a real kill. A socket that drops once the computer has taken the code now ends pairing with `unreachable` instead of trying the next address, where the spent code or ticket could only be refused as `wrong-code` or `expired`. A socket that closes while `onPending` is still saving ends pairing at once with that close; a version 1 `onPending` that never settles times out. The compact QR is unchanged: 109 characters for one relay address and the name Umer.
10
+ - SECURITY: (from @byokit/pair 0.9.0) pinning and approval semantics. A compact QR's pending grant pins the key the code handshake authenticated, kept after Noise message 2 and before the device's identity goes out in message 3; if saving it fails, pairing stops before the computer learns the device. A device using `onPending` says so inside the handshake, and only then does the computer keep a yes given after its socket dropped, granting only the device key that handshake authenticated; any other device that leaves still gets no grant. A computer with another key at the same address is still refused, and a version 1 `pendingGrant` refuses a `host` that differs from its QR.
11
+ - FIX: (from @byokit/pair 0.9.0) the pairing picture's words match what the host now prints: scan the code, or type the shown code where the phone page asks for it, with no raw address to type.
12
+ - FIX: (from @byokit/pair 0.9.0) the browser pairing test closes only the Chromium processes it spawned — over that browser's own private CDP pipe — instead of signalling a whole process group, so a killed or timed-out test can no longer take out or leave behind the wrong process tree. `BYOKIT_CHROME` is the single documented key that selects the test browser.
13
+
14
+ - The herdr-kit example's agent rows lead with the workspace folder and name the agent second (`shop · Pi`), question cards name folder and agent without repeating the computer, and state badges read without the sentence's full stop (`Ready for you`).
15
+
16
+ ## 0.7.1 (2026-10-07)
17
+
18
+ - Dependency update: pins @byokit/relay 0.5.3.
19
+ - Dependency update: pins @byokit/link 0.7.1.
20
+
21
+ - `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.
22
+ - 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.
23
+ - The quickstart now says what to read next (the kit spec's decisions and work packages, then the two ways in
24
+ and agent readiness). Docs only; no typed surface changed.
25
+ - 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.
26
+ - 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.
27
+ - `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.
28
+
5
29
  ## 0.7.0 (2026-10-02)
6
30
 
7
31
  - 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.
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 |
@@ -220,6 +224,9 @@ and are re-read before staging. Timeout returns `busy`, approval returns `blocke
220
224
  and missing sequence or invalid bounds returns `unsupported`. The person may stop the step themselves in the pane.
221
225
  `busy: 'interrupt'` (confirmed session/terminal/seq) returns `interrupt_unsupported`, never keys or lifecycle calls.
222
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.
223
230
  Tokens stay on the device and are never logged. Each provider's own terms apply to how you use your plan.
224
231
 
225
232
  For managed-folder stores that supply resume arguments and credential shedding, use `move`:
package/dist/accounts.js CHANGED
@@ -17,52 +17,46 @@ export function createAccountPanes(ctx) {
17
17
  return false;
18
18
  }
19
19
  }
20
- // A shell rc can override placement env. Verify what the shell actually resolves, rather
21
- // than pane.get's launch env. The variable name is fixed by the provider, never caller text.
22
- async function checkEnv(paneId, variable, folder, timeout, absent = false) {
23
- const marker = `BYOKIT_ACCOUNT_${randomBytes(8).toString('hex')}`;
24
- const deadline = Date.now() + timeout;
25
- const expansion = absent ? '${' + variable + '+x}' : '$' + variable;
26
- try {
27
- await call('pane.send_text', { pane_id: paneId, text: `echo ${marker}="${expansion}"\n` }, timeout);
28
- for (;;) {
29
- const result = await call('pane.read', { pane_id: paneId, source: 'recent_unwrapped', lines: 40,
30
- format: 'text', strip_ansi: true }, Math.max(1, deadline - Date.now()));
31
- const readings = String(result?.read?.text ?? '').split('\n').map((line) => line.trim())
32
- .filter((line) => line.startsWith(`${marker}=`)).map((line) => line.slice(marker.length + 1));
33
- if (readings.length > 0)
34
- return readings.includes(folder);
35
- if (Date.now() >= deadline)
36
- return false;
37
- await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
38
- }
39
- }
40
- catch {
41
- return false;
42
- }
43
- }
44
- // agent.start has no env/unset/command field in the pinned protocol. Clear credentials in
45
- // the shell that launches it, then verify absence (never echo credential values).
46
- 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) {
47
29
  const entries = Object.entries(set);
48
30
  if ([...entries.map(([key]) => key), ...unset].some((key) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
49
31
  || entries.some(([, value]) => typeof value !== 'string' || /[\r\n\0]/.test(value))
50
32
  || unset.some((key) => key in set))
51
33
  return false;
52
34
  try {
53
- if (unset.length > 0)
54
- await call('pane.send_text', { pane_id: paneId, text: `unset ${unset.join(' ')}\n` }, timeout);
55
- 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))
56
38
  return false;
57
- for (const key of unset)
58
- if (!await checkEnv(paneId, key, '', timeout, true))
59
- return false;
60
- return true;
39
+ if (unset.some((key) => Object.hasOwn(env, key)))
40
+ return false;
41
+ return entries.every(([key, value]) => env[key] === value);
61
42
  }
62
43
  catch {
63
44
  return false;
64
45
  }
65
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
+ }
66
60
  async function openSignInTab(o) {
67
61
  const { workspaceId, label, folder, ...start } = o;
68
62
  try {
@@ -188,16 +182,17 @@ export function createAccountPanes(ctx) {
188
182
  catch {
189
183
  return rollback('start_failed');
190
184
  }
191
- if (request !== undefined && !await prepareEnv(paneId, request.set, request.unset ?? [], variable, timeout)) {
192
- return rollback('env_mismatch');
185
+ if (request !== undefined) {
186
+ if (!await verifyPaneEnv(paneId, request.set, request.unset ?? [], timeout))
187
+ return rollback('env_mismatch');
193
188
  }
194
- if (request === undefined && !await checkEnv(paneId, variable, o.folder, timeout)) {
189
+ else if (!await verifyPaneEnv(paneId, { ...o.env, [variable]: o.folder }, [], timeout)) {
195
190
  return rollback('env_mismatch');
196
191
  }
192
+ const names = moveNames(agent?.name, kind);
197
193
  try {
198
- // Unique names avoid colliding with the still-live source agent.
199
194
  await ctx.startAgent({ kind, cwd: agent.foreground_cwd ?? agent.cwd ?? '.',
200
- name: `move-${randomBytes(8).toString('hex')}`, place: { pane: paneId }, args, timeoutMs: timeout });
195
+ name: names.start, place: { pane: paneId }, args, timeoutMs: timeout });
201
196
  await call('agent.wait', { target: paneId, until: ['idle', 'done'], timeout_ms: timeout }, timeout + 5000);
202
197
  const deadline = Date.now() + timeout;
203
198
  for (;;) {
@@ -267,6 +262,15 @@ export function createAccountPanes(ctx) {
267
262
  return failed('close_failed', live);
268
263
  }
269
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
+ }
270
274
  try {
271
275
  request?.onReplaced?.(paneId);
272
276
  }
package/dist/agents.js CHANGED
@@ -116,7 +116,12 @@ export function createAgents(ctx) {
116
116
  throw fail('env_mismatch', 'This pane needs to be opened again to use that sign-in.');
117
117
  }
118
118
  const timeout = o.timeoutMs ?? DEFAULT_TIMEOUT_MS;
119
- const env = o.env === undefined || launch ? {} : { env: o.env };
119
+ // A pane HOME alone keeps the server's XDG dirs, so a sign-in would land outside it: confine them too.
120
+ const home = launch ? undefined : o.env?.HOME;
121
+ const env = o.env === undefined || launch ? {} : { env: home === undefined ? o.env : {
122
+ XDG_CONFIG_HOME: join(home, '.config'), XDG_DATA_HOME: join(home, '.local', 'share'),
123
+ XDG_STATE_HOME: join(home, '.local', 'state'), XDG_CACHE_HOME: join(home, '.cache'), ...o.env
124
+ } };
120
125
  let paneId;
121
126
  let created = false;
122
127
  if (o.worktree !== undefined) {
package/dist/link.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type IncomingMessage, type ServerResponse } from 'node:http';
2
2
  import { type Grant, type Host, type HostOptions } from '@byokit/link';
3
- import { type ServeIngress, type Via } from '@byokit/reach';
3
+ import { type ServeIngress, type Via } from '@byokit/discover';
4
4
  import type { RelayClient } from '@byokit/relay';
5
5
  import type { HerdrKit } from './kit.ts';
6
6
  export type HerdrScope = {
@@ -9,7 +9,7 @@ export type HerdrScope = {
9
9
  export declare function herdrLink(kit: HerdrKit, o: {
10
10
  scopeOf: (grant: Grant) => HerdrScope;
11
11
  passThrough?: (method: string, grant: Grant) => boolean;
12
- relay?: RelayClient;
12
+ relay?: Pick<RelayClient, 'notify'>;
13
13
  }): Pick<HostOptions, 'handle' | 'stream' | 'allow'>;
14
14
  export declare function serve(o: {
15
15
  host: Host;
package/dist/link.js CHANGED
@@ -7,7 +7,7 @@
7
7
  import { createServer } from 'node:http';
8
8
  import { WebSocketServer } from 'ws';
9
9
  import { PublicLinkError, } from '@byokit/link';
10
- import { reach } from '@byokit/reach';
10
+ import { reach } from '@byokit/discover';
11
11
  import { stateWords, words } from "./words.js";
12
12
  import { decodeB64Url, sealNotice } from "./notices.js";
13
13
  // Ops a view-role grant may open; every other op needs the control role (7.1, *view* marks).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/herdr",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Drive the Herdr on this computer — workspaces, tabs, panes, agents and blocked-approval answers — from an app, or hand it to a phone over @byokit/link.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -52,11 +52,11 @@
52
52
  "prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
53
53
  },
54
54
  "dependencies": {
55
- "@byokit/link": "0.7.0",
56
- "@byokit/relay": "0.5.1",
57
- "@byokit/reach": "0.6.0",
58
- "@byokit/seal": "0.2.0",
59
- "ws": "8.21.3"
55
+ "@byokit/link": "0.8.0",
56
+ "@byokit/relay": "0.6.0",
57
+ "@byokit/discover": "0.6.0",
58
+ "@byokit/seal": "0.3.0",
59
+ "ws": "8.22.0"
60
60
  },
61
61
  "publishConfig": {
62
62
  "access": "public"