@byokit/herdr 0.1.1 → 0.1.6

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,38 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.6 (2026-09-30)
6
+
7
+ - Depends on @byokit/relay 0.3.0.
8
+
9
+ ## 0.1.5 (2026-09-30)
10
+
11
+ - Depends on @byokit/relay 0.2.2.
12
+ - Depends on @byokit/link 0.4.0.
13
+
14
+ - The test fake's `fake.stream` ends with one `fake.stream.done` line on the same ordered byte stream, so the
15
+ paused-terminal backpressure test asserts delivery causally instead of on a wall-clock wait.
16
+
17
+ ## 0.1.4 (2026-09-30)
18
+
19
+ - Preserve seed-based notice opening with @byokit/seal 0.2.0.
20
+
21
+ ## 0.1.3
22
+
23
+ - Depends on @byokit/reach 0.3.0.
24
+
25
+ ## 0.1.2 (2026-09-29)
26
+
27
+ - FIX: `prompt` now reaches an agent that was already running when the app connected. Herdr marks only
28
+ the agents its own `agent.start` launched as interactive-ready, so an agent started by hand or by
29
+ another app was refused forever; and a snapshot read while an agent was still launching kept it
30
+ marked as launching, because Herdr never pushes the end of a launch. The kit no longer gates on
31
+ interactive-ready, and before refusing a prompt it re-reads the agent once from Herdr.
32
+ - FIX: when Herdr itself refuses a prompt as not ready, `prompt` now fails with `agent-not-ready`
33
+ like the kit's own check, not with Herdr's raw `agent_not_ready` error.
34
+ - The test fake leaves `launch_pending` and `interactive_ready` out when they are false, as Herdr v0.9.1 does.
35
+ - Depends on @byokit/relay 0.2.1.
36
+
5
37
  ## 0.1.1 (2026-09-29)
6
38
 
7
39
  - SECURITY: (from @byokit/relay 0.2.0) Removing a phone now reliably stops its push notifications, even if the relay was down or restarting when you removed it. Before, `RelayClient.revoke` dropped the device's grant and sent one unsubscribe that was lost if the offline queue overflowed or the host stopped or restarted before the relay was back, so the relay kept the phone's push address. Now the unsubscribe is saved first and resent on every connection until the relay confirms it; `revoke()` resolves then. Pass a durable `store` to `RelayClient` so a pending unsubscribe survives a restart (the default is memory).
package/dist/agents.d.ts CHANGED
@@ -4,4 +4,5 @@ export type Call = (method: string, params: Record<string, unknown>, timeoutMs?:
4
4
  export declare function createAgents(ctx: {
5
5
  call: Call;
6
6
  snapshot(): HerdrSnapshot;
7
+ reread(paneId: string): Promise<void>;
7
8
  }): Pick<HerdrKit, 'startAgent' | 'prompt' | 'sendKeys' | 'wait' | 'read' | 'agentKinds' | 'installedAgentKinds'>;
package/dist/agents.js CHANGED
@@ -109,11 +109,18 @@ export function createAgents(ctx) {
109
109
  }
110
110
  }
111
111
  async function prompt(target, text, o) {
112
- // The readiness gate reads the kit's own tree: a not-promptable agent is refused with no socket call.
113
- const agent = agentOf(ctx.snapshot(), target.paneId);
114
- if (agent === undefined || agent.launchPending === true || agent.interactiveReady === false
115
- || !PROMPTABLE.includes(agent.status)) {
116
- throw fail('agent-not-ready', 'That agent is not ready for a prompt yet.');
112
+ // The readiness gate reads the kit's own tree, re-read once before a refusal: `launch_pending`
113
+ // rides reads only, so a snapshot taken mid-launch goes stale. `interactive_ready` is not gated:
114
+ // Herdr sets it only for agents its own `agent.start` settled, and its `agent.prompt` accepts the
115
+ // rest. A refusal never sends `agent.prompt`.
116
+ const ready = () => {
117
+ const agent = agentOf(ctx.snapshot(), target.paneId);
118
+ return agent !== undefined && agent.launchPending !== true && PROMPTABLE.includes(agent.status);
119
+ };
120
+ if (!ready()) {
121
+ await ctx.reread(target.paneId);
122
+ if (!ready())
123
+ throw fail('agent-not-ready', 'That agent is not ready for a prompt yet.');
117
124
  }
118
125
  const wait = o?.wait === undefined ? undefined
119
126
  : { timeout_ms: o.wait.timeoutMs, ...(o.wait.until === undefined ? {} : { until: o.wait.until }) };
@@ -123,9 +130,14 @@ export function createAgents(ctx) {
123
130
  ...(wait === undefined ? {} : { wait }) });
124
131
  }
125
132
  catch (error) {
126
- if (error?.code === 'agent_blocked') {
133
+ const code = error?.code;
134
+ if (code === 'agent_blocked') {
127
135
  throw fail('agent-blocked', 'That agent is blocked and needs an answer before a new prompt.');
128
136
  }
137
+ // Herdr's own gate (unknown agent kind, launch pending, not the pane's foreground process).
138
+ if (code === 'agent_not_ready') {
139
+ throw fail('agent-not-ready', 'That agent is not ready for a prompt yet.');
140
+ }
129
141
  throw error;
130
142
  }
131
143
  const body = isObj(receipt) ? receipt : undefined;
package/dist/kit.d.ts CHANGED
@@ -23,7 +23,7 @@ export declare class HerdrKit {
23
23
  private watchError;
24
24
  private update;
25
25
  private bootstrap;
26
- private catchUp;
26
+ private reread;
27
27
  /**
28
28
  * Connect and bootstrap. Non-fatal: a rejected `start()` (e.g. `failed/socket` while Herdr is
29
29
  * down) leaves the kit stopped, and `start()` may be called again afterwards — the host comes up
package/dist/kit.js CHANGED
@@ -131,7 +131,8 @@ export class HerdrKit {
131
131
  constructor(o) {
132
132
  this.o = o;
133
133
  this.supervisor = new Supervisor(o, (s) => { this.current = s; o.onState?.(s); });
134
- this.agents = createAgents({ call: this.callAny, snapshot: () => this.snapshot() });
134
+ this.agents = createAgents({ call: this.callAny, snapshot: () => this.snapshot(),
135
+ reread: (paneId) => this.reread(paneId) });
135
136
  this.blockedList = new Blocked({ call: this.callAny });
136
137
  }
137
138
  get state() { return this.current; }
@@ -292,16 +293,18 @@ export class HerdrKit {
292
293
  this.statusStops.add(stop);
293
294
  const ready = stop.ready;
294
295
  if (ready)
295
- statusAcks.push(ready.then((ok) => (ok === false ? undefined : this.catchUp(token, pane.id))));
296
+ statusAcks.push(ready.then((ok) => (ok === false ? undefined : this.reread(pane.id, token))));
296
297
  }
297
298
  void Promise.allSettled(statusAcks).then(() => { if (token === this.generation)
298
299
  resolveStatus(); });
299
300
  this.current = newer ? { phase: 'needs-update', why: 'version' } : { phase: 'ready' };
300
301
  this.o.onState?.(this.current);
301
302
  }
302
- // A status change between the snapshot read and a watch's ack reached no socket: once the watch
303
- // is live, re-read that agent. A push landing meanwhile is newer and wins (epoch guard).
304
- async catchUp(token, paneId) {
303
+ // Re-read one agent and land its status, revision and launch flags: after a watch acks (a status
304
+ // change before the ack reached no socket) and before `prompt` refuses (`launch_pending` rides
305
+ // reads only, never a push). A push landing meanwhile is newer and wins status and revision (epoch
306
+ // guard); the launch flags still land, since no push carries them.
307
+ async reread(paneId, token = this.generation) {
305
308
  const epoch = this.statusEpoch.get(paneId);
306
309
  let made;
307
310
  try {
@@ -311,12 +314,21 @@ export class HerdrKit {
311
314
  return;
312
315
  }
313
316
  const agent = made?.agent;
314
- if (token !== this.generation || this.statusEpoch.get(paneId) !== epoch ||
315
- typeof agent?.agent_status !== 'string')
317
+ if (token !== this.generation || typeof agent?.agent_status !== 'string')
316
318
  return;
317
- // Status and revision only: the read's other fields may be staler than a merged pane.updated.
319
+ // v0.9.1 leaves both flags out when false (`skip_serializing_if = is_false`): absent means false.
320
+ const flags = { launch_pending: agent.launch_pending === true, interactive_ready: agent.interactive_ready === true };
321
+ if (this.statusEpoch.get(paneId) !== epoch) {
322
+ const pane = findPane(this.tree, paneId);
323
+ if (pane?.agent !== undefined) {
324
+ pane.agent = mergeAgent(pane.agent, flags);
325
+ this.publish();
326
+ }
327
+ return;
328
+ }
329
+ // The read's other fields may be staler than a merged pane.updated.
318
330
  this.update({ type: 'pane_agent_status_changed', pane_id: paneId, agent_status: agent.agent_status,
319
- ...(typeof agent.revision === 'number' ? { revision: agent.revision } : {}) });
331
+ ...(typeof agent.revision === 'number' ? { revision: agent.revision } : {}), ...flags });
320
332
  }
321
333
  /**
322
334
  * Connect and bootstrap. Non-fatal: a rejected `start()` (e.g. `failed/socket` while Herdr is
package/dist/notices.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Sealed blocked-approval notices: the relay reads only a generic title, the phone opens the sealed body with its
2
2
  // own key (docs/runtime-kits.md 7.3). Portable: no Node import anywhere in it — `./device` re-exports `openNotice`.
3
- import { boxKeyPairFromSeed, openBox, sealBox } from '@byokit/seal';
3
+ import { boxKeyPairFromSeed, openBoxFromSeed, sealBox } from '@byokit/seal';
4
4
  const ABC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
5
5
  /** Base64url without padding, hand-rolled so this file stays portable (no Buffer, no atob). */
6
6
  export function encodeB64Url(bytes) {
@@ -68,7 +68,7 @@ export function openNotice(data, seed) {
68
68
  const bundle = decodeB64Url(data.sealed);
69
69
  if (bundle === null)
70
70
  return null;
71
- const bytes = openBox(bundle, seed);
71
+ const bytes = openBoxFromSeed(bundle, seed);
72
72
  if (bytes === null)
73
73
  return null;
74
74
  let parsed;
@@ -88,19 +88,24 @@ async function runServer() {
88
88
  // resizes the session and answers one empty frame stamped with the new size; `terminal.release`
89
89
  // exits; `{"type":"fake.stream","count":n,"size":b,"progress":file}` streams n frames with full
90
90
  // `<i>:` plus b bytes as fast as the pipe takes them, recording the frames-written count in
91
- // `progress` after each write so a test can see how far a paused reader let the stream run.
91
+ // `progress` after each write so a test can see how far a paused reader let the stream run, then
92
+ // one `{"type":"fake.stream.done","count":n}` line on the same ordered byte stream: when a test sees
93
+ // the marker, every stream frame is already past every frame handler, so the delivery verdict is
94
+ // causal instead of a wall-clock wait (a stall then means a genuinely wedged child, not a slow one).
92
95
  function frame(paneId, full, extra = {}) {
93
96
  return `${JSON.stringify({ type: 'terminal.frame', pane_id: paneId, data: full, full, bytes: Buffer.byteLength(full), ...extra })}\n`;
94
97
  }
95
98
  async function stream(paneId, o) {
99
+ const count = o.count ?? 0;
96
100
  const pad = 'x'.repeat(o.size ?? 1024);
97
- for (let i = 0; i < (o.count ?? 0); i += 1) {
101
+ for (let i = 0; i < count; i += 1) {
98
102
  const ok = process.stdout.write(frame(paneId, `${i}:${pad}`));
99
103
  if (o.progress)
100
104
  writeFileSync(o.progress, String(i + 1));
101
105
  if (!ok)
102
106
  await new Promise((resolve) => process.stdout.once('drain', resolve));
103
107
  }
108
+ process.stdout.write(`${JSON.stringify({ type: 'fake.stream.done', pane_id: paneId, count })}\n`);
104
109
  }
105
110
  function runTerminal(args) {
106
111
  // `herdr terminal session <control|observe> <pane> [--takeover] --cols <n> --rows <n>`.
@@ -423,12 +423,12 @@ export async function startFakeHerdr(options) {
423
423
  }
424
424
  return { target: p.target, keys };
425
425
  },
426
- 'agent.list': () => ({ agents: live.agents.map((agent) => ({ ...agent })) }),
426
+ 'agent.list': () => ({ agents: live.agents.map(agentWire) }),
427
427
  'agent.get': (p) => {
428
428
  const agent = live.agents.find((row) => row.pane_id === p.target);
429
429
  if (agent === undefined)
430
430
  throw fail('agent_not_found', 'agent not found');
431
- return { agent };
431
+ return { agent: agentWire(agent) };
432
432
  },
433
433
  'server.agent_manifests': () => ({ manifests: kinds.map((kind) => ({ agent: kind })) }),
434
434
  'plugin.list': (p) => {
@@ -607,10 +607,15 @@ export async function startFakeHerdr(options) {
607
607
  function agentOf(pane) {
608
608
  return live.agents.find((row) => row.pane_id === pane.pane_id);
609
609
  }
610
+ // v0.9.1 serializes `launch_pending`/`interactive_ready` only when true (`skip_serializing_if = is_false`).
611
+ function agentWire(agent) {
612
+ const { launch_pending, interactive_ready, ...rest } = agent;
613
+ return { ...rest, ...(launch_pending ? { launch_pending } : {}), ...(interactive_ready ? { interactive_ready } : {}) };
614
+ }
610
615
  function agentView(paneId) {
611
616
  const agent = live.agents.find((row) => row.pane_id === paneId);
612
617
  if (agent !== undefined)
613
- return { agent: { ...agent } };
618
+ return { agent: agentWire(agent) };
614
619
  const pane = live.panes.find((row) => row.pane_id === paneId);
615
620
  return { agent: { pane_id: paneId, agent_status: pane?.agent_status ?? 'unknown', interactive_ready: true } };
616
621
  }
@@ -658,7 +663,7 @@ export async function startFakeHerdr(options) {
658
663
  workspaces: live.workspaces.map(workspaceView),
659
664
  tabs: live.tabs.map((tab) => ({ ...tab })),
660
665
  panes: live.panes.map(paneView),
661
- agents: live.agents.map((agent) => ({ ...agent })),
666
+ agents: live.agents.map(agentWire),
662
667
  layouts: [],
663
668
  };
664
669
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/herdr",
3
- "version": "0.1.1",
3
+ "version": "0.1.6",
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,10 +52,10 @@
52
52
  "prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
53
53
  },
54
54
  "dependencies": {
55
- "@byokit/link": "0.3.2",
56
- "@byokit/relay": "0.2.0",
57
- "@byokit/reach": "0.2.0",
58
- "@byokit/seal": "0.1.0",
55
+ "@byokit/link": "0.4.0",
56
+ "@byokit/relay": "0.3.0",
57
+ "@byokit/reach": "0.3.0",
58
+ "@byokit/seal": "0.2.0",
59
59
  "ws": "8.21.3"
60
60
  },
61
61
  "publishConfig": {