@byokit/herdr 0.2.0 → 0.3.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,28 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.3.0 (2026-10-01)
6
+
7
+ - Dependency update: pins @byokit/reach 0.5.0.
8
+ - Dependency update: pins @byokit/relay 0.5.0.
9
+ - Dependency update: pins @byokit/link 0.7.0.
10
+
11
+ - `PromptReceipt.agentSession` optionally identifies the conversation from the prompt response without an extra read.
12
+
13
+ - FIX: Refuse non-empty `StartAgent.env` on existing panes instead of silently using the old subscription.
14
+ - Move a conversation to another subscription account with `moveToAccount`: verify the target shell folder,
15
+ resume and wait before closing the original pane, and roll back the new pane on failure.
16
+ - Open each CLI’s own sign-in in an account-specific tab with `openSignInTab` and the host’s explicit env.
17
+
18
+ - `HerdrKit.startAgent` lifecycle events: `installing` (with the progress words) before a start
19
+ that needs an install, `ready` with the fresh ref, and `launchFailed` with a typed reason
20
+ (`placement-failed` | `pane-busy` | `install-failed` | `start-rejected`) plus plain words —
21
+ subscribable per call (`StartAgent.onEvent`) or app-wide (`kit.onStartAgent`), no polling.
22
+ The shape and rejection are unchanged. Install detection (`agentInstallState`, also on
23
+ `agentStatus` as `installState`) tells a real runnable binary apart from an auto-install
24
+ launcher such as a mise shim: shimmed or absent reads `installs-on-first-start`, sharing the
25
+ one probe path with `installedAgentKinds` (`resolveAgentBinary`, `isAutoInstallShim`).
26
+
5
27
  ## 0.2.0 (2026-09-30)
6
28
 
7
29
  - `HerdrKit.agentStatus(kinds)` (B5): per-kind onboarding readiness — installed plus CLI
package/README.md CHANGED
@@ -149,8 +149,8 @@ persistent grant store, and the phone page above.
149
149
 
150
150
  | Export | What it does |
151
151
  |---|---|
152
- | `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`, `agentStatus`, `terminal`, `onEvent`, `statusWatchReady`, and the pass-throughs `call`, `subscribe` and `cli` |
153
- | `agentStatus`, `agentProbePath`, `extraPathDirs`, `runStatusCommand` (`@byokit/herdr`) | Onboarding readiness: per-kind install + CLI sign-in without a Herdr connection (see below) |
152
+ | `HerdrKit` (`@byokit/herdr`) | The host-side kit, in `adopt` or `own` mode: `start`/`stop`, `state`, `snapshot`/`onChange`, `startAgent`, `openSignInTab`, `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` |
153
+ | `agentStatus`, `agentInstallState`, `agentProbePath`, `extraPathDirs`, `runStatusCommand`, `isAutoInstallShim`, `resolveAgentBinary` (`@byokit/herdr`) | Onboarding readiness: per-kind install + CLI sign-in without a Herdr connection (see below) |
154
154
  | `HERDR_VERSION`, `HERDR_PROTOCOL` | The pinned Herdr release (`0.9.1`) and the protocol the kit speaks (`22`) |
155
155
  | `words`, `agentWords`, `stateWords`, `WORDS` | Plain sentences for agent statuses and kit states |
156
156
  | `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 |
@@ -160,9 +160,42 @@ persistent grant store, and the phone page above.
160
160
  | `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 |
161
161
  | `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 |
162
162
 
163
+ `PromptReceipt` contains `paneId`, `terminalId`, `revision`, and `status`. Its optional
164
+ `agentSession` (`{ source, agent, kind, value }`) identifies the conversation that received
165
+ this prompt, taken directly from the `agent.prompt` response without another read.
166
+ `kind` distinguishes a session id from a session path; `value` is that id or path.
167
+ Herdr exposes no separate generation counter. When Herdr omits the session (or returns
168
+ null), `agentSession` is absent; callers can fall back to their existing checks.
169
+
163
170
  Types (`HerdrKitOptions`, `HerdrState`, `StartAgent`, `PromptReceipt`, `BlockedAgent`, `HerdrSnapshot`,
164
171
  `HerdrMethod`/`HerdrParams`/`HerdrResult`, `HerdrEventName`/`HerdrEventOf`, ...) come from the main entry.
165
172
 
173
+ ## Account-specific sign-ins and moves
174
+
175
+ The host supplies account folders and shares conversation history as needed. The kit never reads or copies
176
+ credentials. To connect an account, open its CLI in a new tab and let the person complete the CLI's own login:
177
+
178
+ ```ts
179
+ import type { HerdrKit } from '@byokit/herdr';
180
+
181
+ declare const kit: HerdrKit; // the host's started kit
182
+ const signIn = await kit.openSignInTab({
183
+ workspaceId: 'w1', kind: 'codex', cwd: '/home/me/project', env: { CODEX_HOME: '/app/accounts/work' },
184
+ });
185
+ // Attach terminal(signIn.paneId, …) for the CLI's own sign-in screen.
186
+ const moved = await kit.moveToAccount({ paneId: 'w1:p2' }, {
187
+ provider: 'codex', folder: '/app/accounts/work',
188
+ });
189
+ if (moved.ok) console.log(moved.session); // new pane to follow
190
+ ```
191
+
192
+ A move accepts Claude/Pi for Claude folders and Codex/Pi for Codex folders. It verifies the new shell's effective
193
+ account folder, resumes the conversation there, waits for a ready session, then closes the old pane. A failed
194
+ start preserves the original; a failed source close rolls back the new pane. Failures return a plain `message`
195
+ and a `live` pane id for recovery, including when cleanup fails. Wait until a conversation is idle or done before
196
+ moving it. `StartAgent.env` applies to newly created placements; existing shells cannot receive a new env.
197
+ Tokens stay on the device and are never logged. Each provider's own terms apply to how you use your plan.
198
+
166
199
  ## Two ways in
167
200
 
168
201
  - `adopt` talks to the socket path the app passes: the person's own Herdr, nothing spawned, nothing written.
@@ -179,9 +212,12 @@ the extra install dirs a service PATH omits (`extraPathDirs`: `~/.local/bin`, th
179
212
  `~/.npm-global/bin`, Homebrew and the system dirs — the set muxr probed before this kit did).
180
213
  Sign-in comes only from each CLI's own documented non-secret status command, with a 10 s timeout;
181
214
  a kind whose CLI has none, or whose command gives no answer, reads `unknown`. Credential files
182
- are never opened, read or statted — only the signed-in boolean is kept. Herdr auto-installs Pi
183
- via mise on first start, so `pi` always reads `installed: false` with `installs on first start`
184
- rather than a missing-install error.
215
+ are never opened, read or statted — only the signed-in boolean is kept. Install detection tells a
216
+ real runnable binary apart from an auto-install launcher: a mise-style shim on PATH, or nothing
217
+ on PATH at all, reads `installState: 'installs-on-first-start'` with `installed: false` — Herdr
218
+ fetches the agent on first start — instead of a missing-install error. That covers `pi`, which
219
+ Herdr installs via mise: a missing or shimmed `pi` reads `installed: false` with `installs on
220
+ first start`, while a real `pi` binary reads `installed: true`.
185
221
 
186
222
  ```ts
187
223
  import { HerdrKit } from '@byokit/herdr';
@@ -193,10 +229,11 @@ console.log(await kit.agentStatus(['pi', 'claude', 'codex']));
193
229
 
194
230
  ```text
195
231
  [
196
- { kind: 'pi', installed: false, signedIn: 'unknown', installHint: 'installs on first start' },
197
- { kind: 'claude', installed: true, signedIn: 'yes',
232
+ { kind: 'pi', installed: false, installState: 'installs-on-first-start', signedIn: 'unknown',
233
+ installHint: 'installs on first start' },
234
+ { kind: 'claude', installed: true, installState: 'installed', signedIn: 'yes',
198
235
  installHint: 'Install the claude command, then check again.' },
199
- { kind: 'codex', installed: true, signedIn: 'no',
236
+ { kind: 'codex', installed: true, installState: 'installed', signedIn: 'no',
200
237
  installHint: 'Install the codex command, then check again.',
201
238
  signInHint: 'On this computer run `codex`, sign in, then come back.' },
202
239
  ]
@@ -206,13 +243,35 @@ console.log(await kit.agentStatus(['pi', 'claude', 'codex']));
206
243
  |---|---|---|
207
244
  | `claude` | `claude auth status` (JSON `loggedIn`) | The CLI's own documented status; a signed-out CLI still prints its JSON, so only an empty answer reads `unknown`. |
208
245
  | `codex` | `codex app-server` with a pipelined `initialize` + `account/read` round | Codex exposes sign-in only over its app-server protocol; a record `account` in the second answer means signed in. |
209
- | `pi` | none (Herdr installs it via mise) | No probe: always `installed: false`, `installs on first start`. |
246
+ | `pi` | none (Herdr installs it via mise) | Missing or shimmed: `installed: false`, `installs on first start`. A real binary reads `installed: true`. |
210
247
  | any other kind | none | No documented non-secret status command, so `unknown` until the kit covers it. |
211
248
 
212
- Options: `{ path, aliases }` as in `installedAgentKinds`; `{ run }` injects the command runner
249
+ Options: `{ path, aliases }` as in `installedAgentKinds`; `{ readFile }` overrides the shim
250
+ sniff's file-head reader (tests use fakes); `{ run }` injects the command runner
213
251
  (`(command, args, { stdin, timeoutMs }) => Promise<{ stdout } | undefined>`, so tests use fakes);
214
252
  `{ timeoutMs }` bounds each probe. `signInHint` rides kinds the kit knows how to check whenever they
215
253
  are not signed in; `installHint` always rides along.
254
+
255
+ ## Agent start lifecycle
256
+
257
+ `startAgent` keeps its typed shape and rejection, and additionally reports the launch as events
258
+ so an app shows `Installing…` instead of a blank start: `installing` (with the progress words)
259
+ before a start that needs an install, `ready` with the fresh ref, and `launchFailed` with a typed
260
+ `reason` (`placement-failed` | `pane-busy` | `install-failed` | `start-rejected`) plus plain words.
261
+ Subscribe per call with `onEvent`, or app-wide with `kit.onStartAgent` — no polling either way.
262
+
263
+ ```ts
264
+ import { HerdrKit } from '@byokit/herdr';
265
+
266
+ const kit = new HerdrKit({ mode: 'adopt', bin: 'herdr', socketPath: '/tmp/herdr.sock' });
267
+ const off = kit.onStartAgent((e) => {
268
+ if (e.phase === 'installing') console.log(e.message); // Installing pi…
269
+ if (e.phase === 'launchFailed') console.log(e.reason, e.message);
270
+ });
271
+ await kit.startAgent({ kind: 'pi', cwd: '/home/me/project', place: { workspace: 'new' },
272
+ onEvent: (e) => { if (e.phase === 'ready') console.log(e.ref.paneId); } });
273
+ off();
274
+ ```
216
275
  `call` and `subscribe` are typed pass-throughs to the complete socket API, and `cli()` reaches the full CLI.
217
276
 
218
277
  `start()` is non-fatal: if Herdr is down it rejects (state `failed`, e.g. `failed/socket`) and may be called again
@@ -0,0 +1,9 @@
1
+ import type { Call } from './agents.ts';
2
+ import type { AgentRef, MoveResult, MoveToAccount, OpenSignInTab, StartAgent } from './types.ts';
3
+ export declare function createAccountPanes(ctx: {
4
+ call: Call;
5
+ startAgent(o: StartAgent): Promise<AgentRef>;
6
+ }): {
7
+ openSignInTab: (o: OpenSignInTab) => Promise<AgentRef>;
8
+ moveToAccount: (target: AgentRef, o: MoveToAccount) => Promise<MoveResult>;
9
+ };
@@ -0,0 +1,130 @@
1
+ // Account-specific CLI panes (D11/6.6). No credentials are read or copied. Moves follow
2
+ // the decided start-then-close order; only fake transports exercise this in tests.
3
+ import { randomBytes } from 'node:crypto';
4
+ import { words } from "./words.js";
5
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
6
+ export function createAccountPanes(ctx) {
7
+ const moving = new Set();
8
+ const call = ctx.call;
9
+ async function close(paneId) {
10
+ try {
11
+ await call('pane.close', { pane_id: paneId });
12
+ return true;
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ }
18
+ // A shell rc can override placement env. Verify what the shell actually resolves, rather
19
+ // than pane.get's launch env. The variable name is fixed by the provider, never caller text.
20
+ async function checkEnv(paneId, variable, folder, timeout) {
21
+ const marker = `BYOKIT_ACCOUNT_${randomBytes(8).toString('hex')}`;
22
+ const deadline = Date.now() + timeout;
23
+ try {
24
+ await call('pane.send_text', { pane_id: paneId, text: `echo ${marker}="$${variable}"\n` }, timeout);
25
+ for (;;) {
26
+ const result = await call('pane.read', { pane_id: paneId, source: 'recent_unwrapped', lines: 40,
27
+ format: 'text', strip_ansi: true }, Math.max(1, deadline - Date.now()));
28
+ const readings = String(result?.read?.text ?? '').split('\n').map((line) => line.trim())
29
+ .filter((line) => line.startsWith(`${marker}=`)).map((line) => line.slice(marker.length + 1));
30
+ if (readings.length > 0)
31
+ return readings.includes(folder);
32
+ if (Date.now() >= deadline)
33
+ return false;
34
+ await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
35
+ }
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ }
41
+ async function openSignInTab(o) {
42
+ const { workspaceId, label, ...start } = o;
43
+ try {
44
+ return await ctx.startAgent({ ...start, place: { tab: 'new', workspaceId,
45
+ ...(label === undefined ? {} : { label }) } });
46
+ }
47
+ catch {
48
+ // A runtime error can contain account env. Never relay it to the sign-in screen.
49
+ throw Object.assign(new Error(words('agent.notReady')), { code: 'sign_in_failed' });
50
+ }
51
+ }
52
+ async function moveToAccount(target, o) {
53
+ const failed = (code, live = target.paneId) => ({ ok: false, code, message: words(`move.${code}`), live });
54
+ if (moving.has(target.paneId))
55
+ return failed('busy');
56
+ moving.add(target.paneId);
57
+ try {
58
+ let agent;
59
+ try {
60
+ agent = (await call('agent.get', { target: target.paneId }))?.agent;
61
+ }
62
+ catch {
63
+ return failed('too_early');
64
+ }
65
+ const session = agent?.agent_session;
66
+ if (!agent || agent.launch_pending === true || !session || typeof session.value !== 'string'
67
+ || session.value.length === 0)
68
+ return failed('too_early');
69
+ if (!['idle', 'done'].includes(agent.agent_status))
70
+ return failed('busy');
71
+ const kind = agent.agent;
72
+ if (!['claude', 'codex'].includes(o.provider) || ![o.provider, 'pi'].includes(kind)
73
+ || session.agent !== kind)
74
+ return failed('unsupported');
75
+ const args = kind === 'pi' && session.kind === 'path' ? ['--session', session.value]
76
+ : kind === 'claude' && session.kind === 'id' ? ['--resume', session.value]
77
+ : kind === 'codex' && session.kind === 'id' ? ['resume', session.value] : undefined;
78
+ if (args === undefined)
79
+ return failed('unsupported');
80
+ if (!o.folder || /[\r\n\0]/.test(o.folder))
81
+ return failed('env_mismatch');
82
+ const timeout = o.timeoutMs ?? 15_000;
83
+ if (!Number.isFinite(timeout) || timeout <= 0)
84
+ return failed('start_failed');
85
+ const variable = o.provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME';
86
+ let paneId;
87
+ try {
88
+ const split = await call('pane.split', { target_pane_id: target.paneId,
89
+ direction: o.direction ?? 'right', focus: false,
90
+ ...(typeof (agent.foreground_cwd ?? agent.cwd) === 'string' ? { cwd: agent.foreground_cwd ?? agent.cwd } : {}),
91
+ env: { ...o.env, [variable]: o.folder } }, timeout);
92
+ if (typeof split?.pane?.pane_id !== 'string')
93
+ return failed('start_failed');
94
+ paneId = split.pane.pane_id;
95
+ }
96
+ catch {
97
+ return failed('start_failed');
98
+ }
99
+ if (!await checkEnv(paneId, variable, o.folder, timeout)) {
100
+ await close(paneId);
101
+ return failed('env_mismatch');
102
+ }
103
+ try {
104
+ // Unique names avoid colliding with the still-live source agent.
105
+ await ctx.startAgent({ kind, cwd: agent.foreground_cwd ?? agent.cwd ?? '.',
106
+ name: `move-${randomBytes(8).toString('hex')}`, place: { pane: paneId }, args, timeoutMs: timeout });
107
+ await call('agent.wait', { target: paneId, until: ['idle', 'done'], timeout_ms: timeout }, timeout + 5000);
108
+ const ready = (await call('agent.get', { target: paneId }))?.agent;
109
+ if (!ready || ready.launch_pending === true || !['idle', 'done'].includes(ready.agent_status)
110
+ || ready.agent !== kind || ready.agent_session?.agent !== kind
111
+ || ready.agent_session?.kind !== session.kind || typeof ready.agent_session?.value !== 'string'
112
+ || ready.agent_session.value.length === 0)
113
+ throw new Error('not ready');
114
+ }
115
+ catch {
116
+ const cleaned = await close(paneId);
117
+ return failed('start_failed', cleaned ? target.paneId : paneId);
118
+ }
119
+ if (!await close(target.paneId)) {
120
+ const cleaned = await close(paneId);
121
+ return failed('close_failed', cleaned ? target.paneId : paneId);
122
+ }
123
+ return { ok: true, session: paneId };
124
+ }
125
+ finally {
126
+ moving.delete(target.paneId);
127
+ }
128
+ }
129
+ return { openSignInTab, moveToAccount };
130
+ }
package/dist/agents.d.ts CHANGED
@@ -1,11 +1,29 @@
1
1
  import type { HerdrKit } from './kit.ts';
2
- import type { HerdrSnapshot } from './types.ts';
2
+ import type { AgentInstallProbe, AgentInstallState, AgentLaunchFailureReason, AgentStartEvent, HerdrSnapshot } from './types.ts';
3
3
  export type Call = (method: string, params: Record<string, unknown>, timeoutMs?: number) => Promise<unknown>;
4
4
  export declare function createAgents(ctx: {
5
5
  call: Call;
6
6
  snapshot(): HerdrSnapshot;
7
7
  reread(paneId: string): Promise<void>;
8
+ emitStart?(e: AgentStartEvent): void;
8
9
  }): Pick<HerdrKit, 'startAgent' | 'prompt' | 'sendKeys' | 'wait' | 'read' | 'agentKinds' | 'installedAgentKinds' | 'agentStatus'>;
10
+ export declare function resolveAgentBinary(kind: string, o: {
11
+ path: string[];
12
+ aliases?: Record<string, string[]>;
13
+ }): {
14
+ binary: string;
15
+ path?: string;
16
+ };
17
+ export declare function isAutoInstallShim(file: string, readFile?: (file: string) => string | undefined): boolean;
18
+ export declare function agentInstallState(kind: string, o?: AgentInstallProbe): {
19
+ kind: string;
20
+ state: AgentInstallState;
21
+ path?: string;
22
+ };
23
+ export declare function classifyStartFailure(error: unknown, o?: {
24
+ installExpected?: boolean;
25
+ stage?: 'placement' | 'start';
26
+ }): AgentLaunchFailureReason;
9
27
  export declare function extraPathDirs(home?: string): string[];
10
28
  export declare function agentProbePath(path?: string[], home?: string): string[];
11
29
  export declare function runStatusCommand(command: string, args: string[], o?: {
package/dist/agents.js CHANGED
@@ -3,9 +3,10 @@
3
3
  // disagree the schema wins. `pane.split` takes `target_pane_id` (muxr agrees), and `agent.start`
4
4
  // has no `env` param — a start's env belongs to the placement create/split call (src/generated/methods.ts).
5
5
  import { spawn } from 'node:child_process';
6
- import { accessSync, constants as fsConstants } from 'node:fs';
6
+ import { accessSync, constants as fsConstants, openSync, readSync, closeSync } from 'node:fs';
7
7
  import { homedir } from 'node:os';
8
8
  import { delimiter, join } from 'node:path';
9
+ import { words } from "./words.js";
9
10
  const PROMPTABLE = ['idle', 'working', 'blocked', 'done'];
10
11
  const DEFAULT_TIMEOUT_MS = 60_000;
11
12
  // `agent.start` lands while the fresh pane is still at its shell prompt: the server answers
@@ -43,10 +44,50 @@ function rootPaneOf(result) {
43
44
  }
44
45
  export function createAgents(ctx) {
45
46
  const call = ctx.call;
47
+ const emitStart = (e) => {
48
+ try {
49
+ ctx.emitStart?.(e);
50
+ }
51
+ catch { /* a listener never breaks a start */ }
52
+ };
46
53
  // Every placement takes its pane id from the server's answer, never a prediction (6.4).
47
54
  // Placements that make a pane (every path but `pane`) roll it back with `pane.close` when the
48
55
  // start fails, so a failed start leaves no extra pane behind.
49
56
  async function startAgent(o) {
57
+ const emit = (e) => {
58
+ try {
59
+ o.onEvent?.(e);
60
+ }
61
+ catch { /* a listener never breaks a start */ }
62
+ emitStart(e);
63
+ };
64
+ // Install pre-check: a shim or nothing on PATH means Herdr installs on first start, so the
65
+ // app hears `installing` before the long start instead of watching a blank pane.
66
+ let installExpected = false;
67
+ try {
68
+ installExpected = agentInstallState(o.kind, o.installProbe).state === 'installs-on-first-start';
69
+ }
70
+ catch {
71
+ installExpected = false;
72
+ }
73
+ if (installExpected)
74
+ emit({ phase: 'installing', kind: o.kind, message: words('agent.installing', { agent: o.kind }) });
75
+ let stage = 'placement';
76
+ try {
77
+ const ref = await startAgentInner(o, () => { stage = 'start'; });
78
+ emit({ phase: 'ready', kind: o.kind, ref });
79
+ return ref;
80
+ }
81
+ catch (error) {
82
+ const reason = classifyStartFailure(error, { installExpected, stage });
83
+ emit({ phase: 'launchFailed', kind: o.kind, reason, message: launchFailureWords(reason, o.kind) });
84
+ throw error;
85
+ }
86
+ }
87
+ async function startAgentInner(o, onStartPhase) {
88
+ if ('pane' in o.place && Object.keys(o.env ?? {}).length > 0) {
89
+ throw fail('env_mismatch', 'This pane needs to be opened again to use that sign-in.');
90
+ }
50
91
  const timeout = o.timeoutMs ?? DEFAULT_TIMEOUT_MS;
51
92
  const env = o.env === undefined ? {} : { env: o.env };
52
93
  let paneId;
@@ -86,6 +127,7 @@ export function createAgents(ctx) {
86
127
  }
87
128
  if (paneId === undefined)
88
129
  throw new Error('herdr: the placement answered no pane id');
130
+ onStartPhase?.();
89
131
  const name = agentName(o);
90
132
  const params = { pane_id: paneId, kind: o.kind, name,
91
133
  ...(o.args === undefined ? {} : { args: o.args }), timeout_ms: timeout };
@@ -152,7 +194,12 @@ export function createAgents(ctx) {
152
194
  || a.pane_id !== target.paneId) {
153
195
  throw new Error('Herdr did not queue the prompt.');
154
196
  }
155
- return { paneId: a.pane_id, terminalId: a.terminal_id, revision: a.revision, status: a.agent_status };
197
+ const session = a.agent_session;
198
+ const agentSession = isObj(session) && typeof session.source === 'string'
199
+ && typeof session.agent === 'string' && typeof session.kind === 'string' && typeof session.value === 'string'
200
+ ? { source: session.source, agent: session.agent, kind: session.kind, value: session.value } : undefined;
201
+ return { paneId: a.pane_id, terminalId: a.terminal_id, revision: a.revision, status: a.agent_status,
202
+ ...(agentSession === undefined ? {} : { agentSession }) };
156
203
  }
157
204
  async function wait(target, o) {
158
205
  const made = await call('agent.wait', { target: target.paneId,
@@ -176,58 +223,123 @@ export function createAgents(ctx) {
176
223
  return manifests.map((m) => (typeof m?.agent === 'string' ? m.agent : '')).filter((kind) => kind !== '');
177
224
  }
178
225
  function installedAgentKinds(kinds, o) {
179
- const installed = [];
180
- for (const kind of kinds) {
181
- const names = [kind, ...(o.aliases?.[kind] ?? [])];
182
- if (names.some((name) => o.path.some((dir) => {
183
- try {
184
- accessSync(join(dir, name), fsConstants.X_OK);
185
- return true;
186
- }
187
- catch {
188
- return false;
189
- }
190
- })))
191
- installed.push(kind);
192
- }
193
- return installed;
194
- }
195
- // B5: the extra install dirs muxr's `executableOnPath` probes past PATH (a GUI daemon's
196
- // inherited PATH omits them), moved into the kit so the host probe is trusted.
197
- function installedSet(kinds, o) {
198
- return new Set(installedAgentKinds(kinds.filter((k) => k !== 'pi'), o));
226
+ return kinds.filter((kind) => resolveAgentBinary(kind, o).path !== undefined);
199
227
  }
200
228
  async function agentStatus(kinds, o) {
201
229
  const path = o?.path ?? agentProbePath();
202
230
  const run = o?.run ?? runStatusCommand;
203
231
  const timeoutMs = o?.timeoutMs ?? STATUS_TIMEOUT_MS;
204
- const installed = installedSet(kinds, { path, ...(o?.aliases === undefined ? {} : { aliases: o.aliases }) });
232
+ const probe = { path, ...(o?.aliases === undefined ? {} : { aliases: o.aliases }),
233
+ ...(o?.readFile === undefined ? {} : { readFile: o.readFile }) };
205
234
  return Promise.all(kinds.map(async (kind) => {
206
- // Herdr auto-installs Pi via mise on first start, so PATH presence is not the signal:
207
- // Pi always reads not-installed with the readiness words instead of a missing-install error.
208
- if (kind === 'pi')
209
- return { kind, installed: false, signedIn: 'unknown', installHint: PI_INSTALL_HINT };
235
+ const install = agentInstallState(kind, probe);
210
236
  const binary = o?.aliases?.[kind]?.[0] ?? kind;
211
- const installHint = `Install the ${binary} command, then check again.`;
212
- if (!installed.has(kind))
213
- return { kind, installed: false, signedIn: 'unknown', installHint };
214
- const probe = STATUS_PROBES[kind];
215
- if (probe === undefined)
216
- return { kind, installed: true, signedIn: 'unknown', installHint };
237
+ const installHint = kind === 'pi' ? PI_INSTALL_HINT : `Install the ${binary} command, then check again.`;
238
+ // A shim or nothing on PATH means Herdr installs on first start: not installed, with the
239
+ // readiness words instead of a missing-install error (pi included — a real pi binary is
240
+ // the only case that reads installed).
241
+ if (install.state !== 'installed')
242
+ return { kind, installed: false, installState: install.state, signedIn: 'unknown', installHint };
243
+ const statusProbe = STATUS_PROBES[kind];
244
+ if (statusProbe === undefined)
245
+ return { kind, installed: true, installState: 'installed', signedIn: 'unknown', installHint };
217
246
  let answer;
218
247
  try {
219
- answer = await run(probe.command, probe.args, { ...(probe.stdin === undefined ? {} : { stdin: probe.stdin }), timeoutMs });
248
+ answer = await run(statusProbe.command, statusProbe.args, { ...(statusProbe.stdin === undefined ? {} : { stdin: statusProbe.stdin }), timeoutMs });
220
249
  }
221
250
  catch {
222
251
  answer = undefined;
223
252
  }
224
- const signedIn = answer === undefined ? 'unknown' : probe.parse(answer.stdout);
225
- return { kind, installed: true, signedIn, installHint,
253
+ const signedIn = answer === undefined ? 'unknown' : statusProbe.parse(answer.stdout);
254
+ return { kind, installed: true, installState: 'installed', signedIn, installHint,
226
255
  ...(signedIn === 'yes' ? {} : { signInHint: SIGNIN_HINTS[kind] ?? `Sign in to ${kind} on this computer, then check again.` }) };
227
256
  }));
228
257
  }
229
258
  return { startAgent, prompt, sendKeys, wait, read, agentKinds, installedAgentKinds, agentStatus };
230
259
  }
260
+ // One source of truth for install detection (B5's probe plus the shim layer below): the first
261
+ // executable file named for the kind across the probe path, with its absolute path.
262
+ export function resolveAgentBinary(kind, o) {
263
+ const names = [kind, ...(o.aliases?.[kind] ?? [])];
264
+ for (const name of names) {
265
+ for (const dir of o.path) {
266
+ if (dir === '')
267
+ continue;
268
+ const file = join(dir, name);
269
+ try {
270
+ accessSync(file, fsConstants.X_OK);
271
+ return { binary: name, path: file };
272
+ }
273
+ catch { /* keep looking */ }
274
+ }
275
+ }
276
+ return { binary: o.aliases?.[kind]?.[0] ?? kind };
277
+ }
278
+ // Bounded head read for the shim sniff: the first 2 KiB as text, undefined when unreadable.
279
+ function readHead(file) {
280
+ let fd;
281
+ try {
282
+ fd = openSync(file, 'r');
283
+ const buf = Buffer.alloc(2048);
284
+ const n = readSync(fd, buf, 0, buf.length, 0);
285
+ return buf.subarray(0, n).toString('utf8');
286
+ }
287
+ catch {
288
+ return undefined;
289
+ }
290
+ finally {
291
+ if (fd !== undefined)
292
+ try {
293
+ closeSync(fd);
294
+ }
295
+ catch { /* already gone */ }
296
+ }
297
+ }
298
+ // A mise-style auto-install launcher reads as a shell script that execs through mise — the
299
+ // found "binary" runs, but the first run installs the agent instead. Real binaries (ELF,
300
+ // Mach-O) never match: the shebang gate keeps a stray "mise" substring ("promise", …) out.
301
+ export function isAutoInstallShim(file, readFile = readHead) {
302
+ let head;
303
+ try {
304
+ head = readFile(file);
305
+ }
306
+ catch {
307
+ return false;
308
+ }
309
+ return head !== undefined && head.startsWith('#!') && head.includes('mise');
310
+ }
311
+ // Per-kind install readiness sharing the B5 probe path: `installed` for a real runnable binary,
312
+ // `installs-on-first-start` for an auto-install launcher/shim or nothing on PATH (Herdr fetches
313
+ // the agent on first start). `missing` stays for callers that probe kinds Herdr never installs.
314
+ export function agentInstallState(kind, o) {
315
+ const path = o?.path ?? agentProbePath();
316
+ const found = resolveAgentBinary(kind, { path, ...(o?.aliases === undefined ? {} : { aliases: o.aliases }) });
317
+ if (found.path === undefined)
318
+ return { kind, state: 'installs-on-first-start' };
319
+ const shim = isAutoInstallShim(found.path, o?.readFile);
320
+ return shim ? { kind, state: 'installs-on-first-start', path: found.path }
321
+ : { kind, state: 'installed', path: found.path };
322
+ }
323
+ // Typed launch failure for the `launchFailed` event. The rejection itself is unchanged — this
324
+ // only classifies it: a busy pane that outlasted the retry budget, an install that never
325
+ // finished, a placement that made no pane, or any other rejected start.
326
+ export function classifyStartFailure(error, o) {
327
+ const code = codeOf(error);
328
+ if (code !== undefined && START_RETRYABLE.has(code))
329
+ return 'pane-busy';
330
+ if (o?.stage === 'placement')
331
+ return 'placement-failed';
332
+ if (o?.installExpected === true)
333
+ return 'install-failed';
334
+ return 'start-rejected';
335
+ }
336
+ function launchFailureWords(reason, kind) {
337
+ if (reason === 'pane-busy')
338
+ return words('agent.notReady');
339
+ if (reason === 'install-failed')
340
+ return words('agent.installFailed', { agent: kind });
341
+ return words('agent.launchFailed');
342
+ }
231
343
  // Extra install dirs muxr's `executableOnPath`
232
344
  // (`apps/host/src/agent/infrastructure/herdrSessionSource.ts`) probes past PATH: user-install
233
345
  // dirs a service or GUI daemon's inherited PATH omits — mise shims (Herdr installs Pi through
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { HERDR_VERSION, HERDR_PROTOCOL } from './constants.ts';
2
2
  export { HerdrKit } from './kit.ts';
3
3
  export { agentWords, stateWords, words, WORDS, type WordKey } from './words.ts';
4
- export { agentProbePath, extraPathDirs, runStatusCommand } from './agents.ts';
5
- export type { AgentCliSignIn, AgentReadiness, AgentStatusOptions, AgentStatusRunner, AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrMethods, HerdrParams, HerdrProtocolRange, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, HerdrSubscribeStop, HerdrTransport, PromptReceipt, StartAgent, TerminalSession, } from './types.ts';
4
+ export { agentProbePath, extraPathDirs, runStatusCommand, agentInstallState, isAutoInstallShim, resolveAgentBinary, classifyStartFailure } from './agents.ts';
5
+ export type { AgentCliSignIn, AgentInstallProbe, AgentInstallState, AgentLaunchFailureReason, AgentReadiness, AgentStartEvent, AgentStatusOptions, AgentStatusRunner, AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrMethods, HerdrParams, HerdrProtocolRange, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, HerdrSubscribeStop, HerdrTransport, MoveToAccount, MoveResult, OpenSignInTab, PromptReceipt, StartAgent, TerminalSession, } from './types.ts';
package/dist/index.js CHANGED
@@ -3,4 +3,4 @@
3
3
  export { HERDR_VERSION, HERDR_PROTOCOL } from "./constants.js";
4
4
  export { HerdrKit } from "./kit.js";
5
5
  export { agentWords, stateWords, words, WORDS } from "./words.js";
6
- export { agentProbePath, extraPathDirs, runStatusCommand } from "./agents.js";
6
+ export { agentProbePath, extraPathDirs, runStatusCommand, agentInstallState, isAutoInstallShim, resolveAgentBinary, classifyStartFailure } from "./agents.js";
package/dist/kit.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AgentReadiness, AgentStatusOptions, AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrParams, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, HerdrSubscribeStop, PromptReceipt, StartAgent, TerminalSession } from './types.ts';
1
+ import type { AgentReadiness, AgentStartEvent, AgentStatusOptions, AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrParams, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, HerdrSubscribeStop, PromptReceipt, StartAgent, TerminalSession, MoveToAccount, MoveResult, OpenSignInTab } from './types.ts';
2
2
  export declare class HerdrKit {
3
3
  private readonly supervisor;
4
4
  private transport?;
@@ -14,7 +14,9 @@ export declare class HerdrKit {
14
14
  private statusEpoch;
15
15
  private readonly o;
16
16
  private readonly callAny;
17
+ private readonly accountPanes;
17
18
  private readonly agents;
19
+ private readonly startListeners;
18
20
  private readonly blockedList;
19
21
  constructor(o: HerdrKitOptions);
20
22
  get state(): HerdrState;
@@ -65,7 +67,10 @@ export declare class HerdrKit {
65
67
  * status events instead of opening a second watch.
66
68
  */
67
69
  statusWatchReady(): Promise<void>;
70
+ openSignInTab(o: OpenSignInTab): Promise<AgentRef>;
71
+ moveToAccount(target: AgentRef, o: MoveToAccount): Promise<MoveResult>;
68
72
  startAgent(o: StartAgent): Promise<AgentRef>;
73
+ onStartAgent(fn: (e: AgentStartEvent) => void): () => void;
69
74
  prompt(target: AgentRef, text: string, o?: {
70
75
  wait?: {
71
76
  until?: AgentStatus[];
package/dist/kit.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createAccountPanes } from "./accounts.js";
1
2
  import { createAgents } from "./agents.js";
2
3
  import { Blocked } from "./approvals.js";
3
4
  import { runCli } from "./cli.js";
@@ -126,13 +127,21 @@ export class HerdrKit {
126
127
  // a snapshot read wins over the read (see bootstrap)
127
128
  o;
128
129
  callAny = (method, params, timeoutMs) => this.call(method, params, timeoutMs === undefined ? undefined : { timeoutMs });
130
+ accountPanes;
129
131
  agents;
132
+ startListeners = new Set();
130
133
  blockedList;
131
134
  constructor(o) {
132
135
  this.o = o;
133
136
  this.supervisor = new Supervisor(o, (s) => { this.current = s; o.onState?.(s); });
137
+ this.accountPanes = createAccountPanes({ call: this.callAny, startAgent: (o) => this.startAgent(o) });
134
138
  this.agents = createAgents({ call: this.callAny, snapshot: () => this.snapshot(),
135
- reread: (paneId) => this.reread(paneId) });
139
+ reread: (paneId) => this.reread(paneId),
140
+ emitStart: (e) => { for (const fn of [...this.startListeners])
141
+ try {
142
+ fn(e);
143
+ }
144
+ catch { /* a listener never breaks a start */ } } });
136
145
  this.blockedList = new Blocked({ call: this.callAny });
137
146
  }
138
147
  get state() { return this.current; }
@@ -437,7 +446,15 @@ export class HerdrKit {
437
446
  * status events instead of opening a second watch.
438
447
  */
439
448
  statusWatchReady() { return this.statusReadyPromise; }
449
+ openSignInTab(o) { return this.accountPanes.openSignInTab(o); }
450
+ moveToAccount(target, o) { return this.accountPanes.moveToAccount(target, o); }
440
451
  startAgent(o) { return this.agents.startAgent(o); }
452
+ // App-wide start lifecycle without polling: every startAgent's installing/ready/launchFailed
453
+ // lands here as well as on that call's own `onEvent`. Returns the unsubscribe function.
454
+ onStartAgent(fn) {
455
+ this.startListeners.add(fn);
456
+ return () => { this.startListeners.delete(fn); };
457
+ }
441
458
  prompt(target, text, o) { return this.agents.prompt(target, text, o); }
442
459
  sendKeys(target, keys) { return this.agents.sendKeys(target, keys); }
443
460
  wait(target, o) { return this.agents.wait(target, o); }
package/dist/types.d.ts CHANGED
@@ -48,9 +48,16 @@ export type { HerdrMethods, HerdrMethod, HerdrParams, HerdrResult } from './gene
48
48
  export type { HerdrEvents, HerdrEventName, HerdrEventOf, HerdrSubscription } from './generated/events.ts';
49
49
  export type AgentStatus = 'idle' | 'working' | 'blocked' | 'done' | 'unknown';
50
50
  export type AgentCliSignIn = 'yes' | 'no' | 'unknown';
51
+ export type AgentInstallState = 'installed' | 'installs-on-first-start' | 'missing';
52
+ export type AgentInstallProbe = {
53
+ path?: string[];
54
+ aliases?: Record<string, string[]>;
55
+ readFile?: (file: string) => string | undefined;
56
+ };
51
57
  export type AgentReadiness = {
52
58
  kind: string;
53
59
  installed: boolean;
60
+ installState: AgentInstallState;
54
61
  signedIn: AgentCliSignIn;
55
62
  installHint: string;
56
63
  signInHint?: string;
@@ -64,9 +71,25 @@ export type AgentStatusRunner = (command: string, args: string[], o?: {
64
71
  export type AgentStatusOptions = {
65
72
  path?: string[];
66
73
  aliases?: Record<string, string[]>;
74
+ readFile?: (file: string) => string | undefined;
67
75
  run?: AgentStatusRunner;
68
76
  timeoutMs?: number;
69
77
  };
78
+ export type AgentLaunchFailureReason = 'placement-failed' | 'pane-busy' | 'install-failed' | 'start-rejected';
79
+ export type AgentStartEvent = {
80
+ phase: 'installing';
81
+ kind: string;
82
+ message: string;
83
+ } | {
84
+ phase: 'ready';
85
+ kind: string;
86
+ ref: AgentRef;
87
+ } | {
88
+ phase: 'launchFailed';
89
+ kind: string;
90
+ reason: AgentLaunchFailureReason;
91
+ message: string;
92
+ };
70
93
  export type AgentRef = {
71
94
  paneId: string;
72
95
  name?: string;
@@ -95,12 +118,35 @@ export type StartAgent = {
95
118
  args?: string[];
96
119
  env?: Record<string, string>;
97
120
  timeoutMs?: number;
121
+ onEvent?: (e: AgentStartEvent) => void;
122
+ installProbe?: AgentInstallProbe;
123
+ };
124
+ export type OpenSignInTab = Omit<StartAgent, 'place' | 'worktree'> & {
125
+ workspaceId: string;
126
+ label?: string;
127
+ };
128
+ export type MoveToAccount = {
129
+ provider: 'claude' | 'codex';
130
+ folder: string;
131
+ env?: Record<string, string>;
132
+ direction?: 'right' | 'down';
133
+ timeoutMs?: number;
134
+ };
135
+ export type MoveResult = {
136
+ ok: true;
137
+ session: string;
138
+ } | {
139
+ ok: false;
140
+ code: 'too_early' | 'busy' | 'unsupported' | 'env_mismatch' | 'close_failed' | 'start_failed';
141
+ message: string;
142
+ live?: string;
98
143
  };
99
144
  export type PromptReceipt = {
100
145
  paneId: string;
101
146
  terminalId: string;
102
147
  revision: number;
103
148
  status: AgentStatus;
149
+ agentSession?: AgentSessionRef;
104
150
  };
105
151
  export type BlockedAgent = {
106
152
  paneId: string;
package/dist/words.json CHANGED
@@ -13,7 +13,16 @@
13
13
  "agent.unknown": "Running.",
14
14
  "agent.signIn": "Sign in inside {agent}: follow its own steps on the screen.",
15
15
  "agent.notReady": "This helper isn't ready yet. Try again in a moment.",
16
+ "agent.installing": "Installing {agent}…",
17
+ "agent.installFailed": "Installing {agent} didn't finish. Try again in a moment.",
18
+ "agent.launchFailed": "That helper couldn't start. Try again in a moment.",
16
19
  "approval.stale": "That question already changed. Look again before answering.",
17
20
  "close.wouldWiden": "Closing this would close more than you picked. Close the bigger one instead.",
18
- "link.notAllowed": "This device can't do that. Ask the person at the computer."
21
+ "link.notAllowed": "This device can't do that. Ask the person at the computer.",
22
+ "move.too_early": "This conversation has not started yet. Try again in a moment.",
23
+ "move.busy": "Wait for this conversation to finish before moving it.",
24
+ "move.unsupported": "This conversation cannot move between these accounts.",
25
+ "move.env_mismatch": "The new pane did not receive that sign-in. Try again.",
26
+ "move.close_failed": "The old pane could not close. The move was undone where possible.",
27
+ "move.start_failed": "The new account could not take over. Try again."
19
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/herdr",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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,9 +52,9 @@
52
52
  "prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
53
53
  },
54
54
  "dependencies": {
55
- "@byokit/link": "0.6.0",
56
- "@byokit/relay": "0.4.2",
57
- "@byokit/reach": "0.4.0",
55
+ "@byokit/link": "0.7.0",
56
+ "@byokit/relay": "0.5.0",
57
+ "@byokit/reach": "0.5.0",
58
58
  "@byokit/seal": "0.2.0",
59
59
  "ws": "8.21.3"
60
60
  },