@byokit/herdr 0.2.0 → 0.4.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,37 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.0 (2026-10-01)
6
+
7
+ - Dependency update: pins @byokit/reach 0.6.0.
8
+
9
+ - Add `move` with caller resume args, credential shedding, staging/replacement hooks and `MoveResult.paneId`.
10
+ - Add per-call `cli` env overlays for managed folder hook installation.
11
+ - Report surviving replacement panes when environment verification rollback fails; refuse folder moves for agents that ignore the folder variables.
12
+
13
+ ## 0.3.0 (2026-10-01)
14
+
15
+ - Dependency update: pins @byokit/reach 0.5.0.
16
+ - Dependency update: pins @byokit/relay 0.5.0.
17
+ - Dependency update: pins @byokit/link 0.7.0.
18
+
19
+ - `PromptReceipt.agentSession` optionally identifies the conversation from the prompt response without an extra read.
20
+
21
+ - FIX: Refuse non-empty `StartAgent.env` on existing panes instead of silently using the old subscription.
22
+ - Move a conversation to another subscription account with `moveToAccount`: verify the target shell folder,
23
+ resume and wait before closing the original pane, and roll back the new pane on failure.
24
+ - Open each CLI’s own sign-in in an account-specific tab with `openSignInTab` and the host’s explicit env.
25
+
26
+ - `HerdrKit.startAgent` lifecycle events: `installing` (with the progress words) before a start
27
+ that needs an install, `ready` with the fresh ref, and `launchFailed` with a typed reason
28
+ (`placement-failed` | `pane-busy` | `install-failed` | `start-rejected`) plus plain words —
29
+ subscribable per call (`StartAgent.onEvent`) or app-wide (`kit.onStartAgent`), no polling.
30
+ The shape and rejection are unchanged. Install detection (`agentInstallState`, also on
31
+ `agentStatus` as `installState`) tells a real runnable binary apart from an auto-install
32
+ launcher such as a mise shim: shimmed or absent reads `installs-on-first-start`, sharing the
33
+ one probe path with `installedAgentKinds` (`resolveAgentBinary`, `isAutoInstallShim`).
34
+
35
+
5
36
  ## 0.2.0 (2026-09-30)
6
37
 
7
38
  - `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`, `move`, `moveToAccount`, `onStartAgent`, `prompt`, `sendKeys`, `wait`, `read`, `blocked`/`onBlocked`/`answer`, `closePane`/`closeTab`/`closeWorkspace`, `agentKinds`, `installedAgentKinds`, `agentStatus`, `agentInstallState`, `terminal`, `onEvent`, `statusWatchReady`, and the pass-throughs `call`, `subscribe` and `cli` |
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,72 @@ 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 { HerdrKit } from '@byokit/herdr';
180
+ const kit = new HerdrKit({ mode: 'adopt', bin: '/app/bin/herdr', socketPath: '/app/herdr.sock' });
181
+ await kit.start();
182
+
183
+ const signIn = await kit.openSignInTab({
184
+ workspaceId: 'w1', kind: 'codex', cwd: '/home/me/project', env: { CODEX_HOME: '/app/accounts/work' },
185
+ });
186
+ // Attach terminal(signIn.paneId, …) for the CLI's own sign-in screen.
187
+ const moved = await kit.moveToAccount({ paneId: 'w1:p2' }, {
188
+ provider: 'codex', folder: '/app/accounts/work',
189
+ });
190
+ if (moved.ok) console.log(moved.session); // new pane to follow
191
+ ```
192
+
193
+ A move accepts Claude for Claude folders and Codex for Codex folders. Pi ignores these folder variables and is refused. It verifies the new shell's effective
194
+ account folder, resumes the conversation there, waits for a ready session, then closes the old pane. A failed
195
+ start preserves the original; a failed source close rolls back the new pane. Failures return a plain `message`
196
+ and a `live` pane id for recovery, including when cleanup fails. Wait until a conversation is idle or done before
197
+ moving it. `StartAgent.env` applies to newly created placements; existing shells cannot receive a new env.
198
+ Tokens stay on the device and are never logged. Each provider's own terms apply to how you use your plan.
199
+
200
+ For managed-folder stores that supply resume arguments and credential shedding, use `move`:
201
+
202
+ ```ts
203
+ import { HerdrKit } from '@byokit/herdr';
204
+ const kit = new HerdrKit({ mode: 'adopt', bin: '/app/bin/herdr', socketPath: '/app/herdr.sock' });
205
+ await kit.start();
206
+
207
+ const conversationId = 'published-conversation-id';
208
+ const staged = new Set<string>();
209
+ let activePane = 'w1:p2';
210
+ const moved = await kit.move({
211
+ paneId: 'w1:p2', kind: 'codex', args: ['resume', conversationId],
212
+ set: { CODEX_HOME: '/app/accounts/work' }, unset: ['OPENAI_API_KEY', 'CODEX_API_KEY'],
213
+ onStaged(paneId) { staged.add(paneId); },
214
+ onReplaced(paneId) { staged.delete(paneId); activePane = paneId; },
215
+ });
216
+ if (moved.ok) console.log(moved.paneId);
217
+ await kit.cli(['integration', 'install', 'codex'], { env: { CODEX_HOME: '/app/accounts/work' } });
218
+ ```
219
+
220
+ `MoveResult` names the new pane as `paneId`; `moveToAccount` retains its `session` result as
221
+ `MoveToAccountResult`. Both share the same per-source lock. Staging runs before verification/start; a staging
222
+ exception rolls back. Replacement notification runs after closing the source and cannot undo the move.
223
+ `move` clears `unset` in the new shell, verifies the account folder and unset names' absence, and requires an interactive
224
+ agent publishing a conversation before closing the source. The pinned start API has no command-prefix/env
225
+ field, so this uses shell `unset` rather than `env -u`; shells that cannot clear a variable fail closed.
226
+ Only folder paths belong in `set`, never tokens. `cli` env overlays the kit's explicit env for one call;
227
+ it inherits no process variables. The default move step timeout is 60 seconds.
228
+
166
229
  ## Two ways in
167
230
 
168
231
  - `adopt` talks to the socket path the app passes: the person's own Herdr, nothing spawned, nothing written.
@@ -179,9 +242,12 @@ the extra install dirs a service PATH omits (`extraPathDirs`: `~/.local/bin`, th
179
242
  `~/.npm-global/bin`, Homebrew and the system dirs — the set muxr probed before this kit did).
180
243
  Sign-in comes only from each CLI's own documented non-secret status command, with a 10 s timeout;
181
244
  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.
245
+ are never opened, read or statted — only the signed-in boolean is kept. Install detection tells a
246
+ real runnable binary apart from an auto-install launcher: a mise-style shim on PATH, or nothing
247
+ on PATH at all, reads `installState: 'installs-on-first-start'` with `installed: false` — Herdr
248
+ fetches the agent on first start — instead of a missing-install error. That covers `pi`, which
249
+ Herdr installs via mise: a missing or shimmed `pi` reads `installed: false` with `installs on
250
+ first start`, while a real `pi` binary reads `installed: true`.
185
251
 
186
252
  ```ts
187
253
  import { HerdrKit } from '@byokit/herdr';
@@ -193,10 +259,11 @@ console.log(await kit.agentStatus(['pi', 'claude', 'codex']));
193
259
 
194
260
  ```text
195
261
  [
196
- { kind: 'pi', installed: false, signedIn: 'unknown', installHint: 'installs on first start' },
197
- { kind: 'claude', installed: true, signedIn: 'yes',
262
+ { kind: 'pi', installed: false, installState: 'installs-on-first-start', signedIn: 'unknown',
263
+ installHint: 'installs on first start' },
264
+ { kind: 'claude', installed: true, installState: 'installed', signedIn: 'yes',
198
265
  installHint: 'Install the claude command, then check again.' },
199
- { kind: 'codex', installed: true, signedIn: 'no',
266
+ { kind: 'codex', installed: true, installState: 'installed', signedIn: 'no',
200
267
  installHint: 'Install the codex command, then check again.',
201
268
  signInHint: 'On this computer run `codex`, sign in, then come back.' },
202
269
  ]
@@ -206,13 +273,35 @@ console.log(await kit.agentStatus(['pi', 'claude', 'codex']));
206
273
  |---|---|---|
207
274
  | `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
275
  | `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`. |
276
+ | `pi` | none (Herdr installs it via mise) | Missing or shimmed: `installed: false`, `installs on first start`. A real binary reads `installed: true`. |
210
277
  | any other kind | none | No documented non-secret status command, so `unknown` until the kit covers it. |
211
278
 
212
- Options: `{ path, aliases }` as in `installedAgentKinds`; `{ run }` injects the command runner
279
+ Options: `{ path, aliases }` as in `installedAgentKinds`; `{ readFile }` overrides the shim
280
+ sniff's file-head reader (tests use fakes); `{ run }` injects the command runner
213
281
  (`(command, args, { stdin, timeoutMs }) => Promise<{ stdout } | undefined>`, so tests use fakes);
214
282
  `{ timeoutMs }` bounds each probe. `signInHint` rides kinds the kit knows how to check whenever they
215
283
  are not signed in; `installHint` always rides along.
284
+
285
+ ## Agent start lifecycle
286
+
287
+ `startAgent` keeps its typed shape and rejection, and additionally reports the launch as events
288
+ so an app shows `Installing…` instead of a blank start: `installing` (with the progress words)
289
+ before a start that needs an install, `ready` with the fresh ref, and `launchFailed` with a typed
290
+ `reason` (`placement-failed` | `pane-busy` | `install-failed` | `start-rejected`) plus plain words.
291
+ Subscribe per call with `onEvent`, or app-wide with `kit.onStartAgent` — no polling either way.
292
+
293
+ ```ts
294
+ import { HerdrKit } from '@byokit/herdr';
295
+
296
+ const kit = new HerdrKit({ mode: 'adopt', bin: 'herdr', socketPath: '/tmp/herdr.sock' });
297
+ const off = kit.onStartAgent((e) => {
298
+ if (e.phase === 'installing') console.log(e.message); // Installing pi…
299
+ if (e.phase === 'launchFailed') console.log(e.reason, e.message);
300
+ });
301
+ await kit.startAgent({ kind: 'pi', cwd: '/home/me/project', place: { workspace: 'new' },
302
+ onEvent: (e) => { if (e.phase === 'ready') console.log(e.ref.paneId); } });
303
+ off();
304
+ ```
216
305
  `call` and `subscribe` are typed pass-throughs to the complete socket API, and `cli()` reaches the full CLI.
217
306
 
218
307
  `start()` is non-fatal: if Herdr is down it rejects (state `failed`, e.g. `failed/socket`) and may be called again
@@ -0,0 +1,10 @@
1
+ import type { Call } from './agents.ts';
2
+ import type { AgentRef, Move, MoveResult, MoveToAccount, MoveToAccountResult, 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
+ move: (o: Move) => Promise<MoveResult>;
9
+ moveToAccount: (target: AgentRef, o: MoveToAccount) => Promise<MoveToAccountResult>;
10
+ };
@@ -0,0 +1,182 @@
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, absent = false) {
21
+ const marker = `BYOKIT_ACCOUNT_${randomBytes(8).toString('hex')}`;
22
+ const deadline = Date.now() + timeout;
23
+ const expansion = absent ? '${' + variable + '+x}' : '$' + variable;
24
+ try {
25
+ await call('pane.send_text', { pane_id: paneId, text: `echo ${marker}="${expansion}"\n` }, timeout);
26
+ for (;;) {
27
+ const result = await call('pane.read', { pane_id: paneId, source: 'recent_unwrapped', lines: 40,
28
+ format: 'text', strip_ansi: true }, Math.max(1, deadline - Date.now()));
29
+ const readings = String(result?.read?.text ?? '').split('\n').map((line) => line.trim())
30
+ .filter((line) => line.startsWith(`${marker}=`)).map((line) => line.slice(marker.length + 1));
31
+ if (readings.length > 0)
32
+ return readings.includes(folder);
33
+ if (Date.now() >= deadline)
34
+ return false;
35
+ await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
36
+ }
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ // agent.start has no env/unset/command field in the pinned protocol. Clear credentials in
43
+ // the shell that launches it, then verify absence (never echo credential values).
44
+ async function prepareEnv(paneId, set, unset, variable, timeout) {
45
+ const entries = Object.entries(set);
46
+ if ([...entries.map(([key]) => key), ...unset].some((key) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
47
+ || entries.some(([, value]) => typeof value !== 'string' || /[\r\n\0]/.test(value))
48
+ || unset.some((key) => key in set))
49
+ return false;
50
+ try {
51
+ if (unset.length > 0)
52
+ await call('pane.send_text', { pane_id: paneId, text: `unset ${unset.join(' ')}\n` }, timeout);
53
+ if (!await checkEnv(paneId, variable, set[variable], timeout))
54
+ return false;
55
+ for (const key of unset)
56
+ if (!await checkEnv(paneId, key, '', timeout, true))
57
+ return false;
58
+ return true;
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ async function openSignInTab(o) {
65
+ const { workspaceId, label, ...start } = o;
66
+ try {
67
+ return await ctx.startAgent({ ...start, place: { tab: 'new', workspaceId,
68
+ ...(label === undefined ? {} : { label }) } });
69
+ }
70
+ catch {
71
+ // A runtime error can contain account env. Never relay it to the sign-in screen.
72
+ throw Object.assign(new Error(words('agent.notReady')), { code: 'sign_in_failed' });
73
+ }
74
+ }
75
+ async function perform(target, o, request) {
76
+ const failed = (code, live = target.paneId) => ({ ok: false, code, message: words(`move.${code}`), live });
77
+ if (moving.has(target.paneId))
78
+ return failed('busy');
79
+ moving.add(target.paneId);
80
+ try {
81
+ let agent;
82
+ try {
83
+ agent = (await call('agent.get', { target: target.paneId }))?.agent;
84
+ }
85
+ catch {
86
+ return failed('too_early');
87
+ }
88
+ const session = agent?.agent_session;
89
+ if (!agent || agent.launch_pending === true || !session || typeof session.value !== 'string'
90
+ || session.value.length === 0)
91
+ return failed('too_early');
92
+ if (!['idle', 'done'].includes(agent.agent_status))
93
+ return failed('busy');
94
+ const kind = agent.agent;
95
+ if (!['claude', 'codex'].includes(o.provider) || kind !== o.provider
96
+ || (request !== undefined && request.kind !== kind)
97
+ || session.agent !== kind || session.kind !== 'id')
98
+ return failed('unsupported');
99
+ const args = request?.args ?? (kind === 'claude' && session.kind === 'id' ? ['--resume', session.value]
100
+ : kind === 'codex' && session.kind === 'id' ? ['resume', session.value] : undefined);
101
+ if (args === undefined || !Array.isArray(args) || args.length === 0 || args.some((a) => typeof a !== 'string' || /[\r\n\0]/.test(a)))
102
+ return failed('unsupported');
103
+ if (!o.folder || /[\r\n\0]/.test(o.folder))
104
+ return failed('env_mismatch');
105
+ const timeout = o.timeoutMs ?? 60_000;
106
+ if (!Number.isFinite(timeout) || timeout <= 0)
107
+ return failed('start_failed');
108
+ const variable = o.provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME';
109
+ let paneId;
110
+ try {
111
+ const split = await call('pane.split', { target_pane_id: target.paneId,
112
+ direction: o.direction ?? 'right', focus: false,
113
+ ...(typeof (agent.foreground_cwd ?? agent.cwd) === 'string' ? { cwd: agent.foreground_cwd ?? agent.cwd } : {}),
114
+ env: { ...o.env, [variable]: o.folder } }, timeout);
115
+ if (typeof split?.pane?.pane_id !== 'string')
116
+ return failed('start_failed');
117
+ paneId = split.pane.pane_id;
118
+ }
119
+ catch {
120
+ return failed('start_failed');
121
+ }
122
+ const rollback = async (code) => failed(code, await close(paneId) ? target.paneId : paneId);
123
+ try {
124
+ request?.onStaged?.(paneId);
125
+ }
126
+ catch {
127
+ return rollback('start_failed');
128
+ }
129
+ if (request !== undefined && !await prepareEnv(paneId, request.set, request.unset ?? [], variable, timeout)) {
130
+ return rollback('env_mismatch');
131
+ }
132
+ if (request === undefined && !await checkEnv(paneId, variable, o.folder, timeout)) {
133
+ return rollback('env_mismatch');
134
+ }
135
+ try {
136
+ // Unique names avoid colliding with the still-live source agent.
137
+ await ctx.startAgent({ kind, cwd: agent.foreground_cwd ?? agent.cwd ?? '.',
138
+ name: `move-${randomBytes(8).toString('hex')}`, place: { pane: paneId }, args, timeoutMs: timeout });
139
+ await call('agent.wait', { target: paneId, until: ['idle', 'done'], timeout_ms: timeout }, timeout + 5000);
140
+ const deadline = Date.now() + timeout;
141
+ for (;;) {
142
+ const ready = (await call('agent.get', { target: paneId }, Math.max(1, deadline - Date.now())))?.agent;
143
+ if (ready && ready.launch_pending !== true && ['idle', 'done'].includes(ready.agent_status)
144
+ && (request === undefined || ready.interactive_ready === true)
145
+ && ready.agent === kind && ready.agent_session?.agent === kind
146
+ && ready.agent_session?.kind === session.kind && typeof ready.agent_session?.value === 'string'
147
+ && ready.agent_session.value.length > 0)
148
+ break;
149
+ if (Date.now() >= deadline)
150
+ throw new Error('not ready');
151
+ await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
152
+ }
153
+ }
154
+ catch {
155
+ const cleaned = await close(paneId);
156
+ return failed('start_failed', cleaned ? target.paneId : paneId);
157
+ }
158
+ if (!await close(target.paneId)) {
159
+ const cleaned = await close(paneId);
160
+ return failed('close_failed', cleaned ? target.paneId : paneId);
161
+ }
162
+ // The source is closed: a notification failure cannot undo a successful move.
163
+ try {
164
+ request?.onReplaced?.(paneId);
165
+ }
166
+ catch { /* notification only */ }
167
+ return { ok: true, session: paneId };
168
+ }
169
+ finally {
170
+ moving.delete(target.paneId);
171
+ }
172
+ }
173
+ async function move(o) {
174
+ const variable = o.kind === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME';
175
+ const result = await perform({ paneId: o.paneId }, {
176
+ provider: o.kind === 'claude' ? 'claude' : 'codex', folder: o.set[variable] ?? '',
177
+ env: o.set, timeoutMs: o.timeoutMs,
178
+ }, o);
179
+ return result.ok ? { ok: true, paneId: result.session } : result;
180
+ }
181
+ return { openSignInTab, move, moveToAccount: (target, o) => perform(target, o) };
182
+ }
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, Move, MoveToAccount, MoveToAccountResult, 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, Move, MoveToAccount, MoveToAccountResult, 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;
@@ -37,6 +39,7 @@ export declare class HerdrKit {
37
39
  subscribe<E extends HerdrEventName>(subs: HerdrSubscription<E>[], on: (e: HerdrEventOf<E>) => void, onError?: (code: string, message: string) => void): HerdrSubscribeStop;
38
40
  cli(args: string[], o?: {
39
41
  timeoutMs?: number;
42
+ env?: Record<string, string>;
40
43
  }): Promise<{
41
44
  stdout: string;
42
45
  stderr: string;
@@ -65,7 +68,11 @@ export declare class HerdrKit {
65
68
  * status events instead of opening a second watch.
66
69
  */
67
70
  statusWatchReady(): Promise<void>;
71
+ openSignInTab(o: OpenSignInTab): Promise<AgentRef>;
72
+ move(o: Move): Promise<MoveResult>;
73
+ moveToAccount(target: AgentRef, o: MoveToAccount): Promise<MoveToAccountResult>;
68
74
  startAgent(o: StartAgent): Promise<AgentRef>;
75
+ onStartAgent(fn: (e: AgentStartEvent) => void): () => void;
69
76
  prompt(target: AgentRef, text: string, o?: {
70
77
  wait?: {
71
78
  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; }
@@ -418,7 +427,7 @@ export class HerdrKit {
418
427
  throw new Error('herdr: not connected');
419
428
  return this.transport.subscribe(subs, on, onError ?? (() => { }));
420
429
  }
421
- cli(args, o) { return runCli(this.o.bin, this.supervisor.env(), args, o?.timeoutMs); }
430
+ cli(args, o) { return runCli(this.o.bin, { ...this.supervisor.env(), ...o?.env }, args, o?.timeoutMs); }
422
431
  terminal(paneId, o) { return openTerminal(this.o.bin, this.supervisor.env(), paneId, o); }
423
432
  snapshot() { return structuredClone(this.tree); }
424
433
  onChange(fn) { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }
@@ -437,7 +446,16 @@ 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
+ move(o) { return this.accountPanes.move(o); }
451
+ moveToAccount(target, o) { return this.accountPanes.moveToAccount(target, o); }
440
452
  startAgent(o) { return this.agents.startAgent(o); }
453
+ // App-wide start lifecycle without polling: every startAgent's installing/ready/launchFailed
454
+ // lands here as well as on that call's own `onEvent`. Returns the unsubscribe function.
455
+ onStartAgent(fn) {
456
+ this.startListeners.add(fn);
457
+ return () => { this.startListeners.delete(fn); };
458
+ }
441
459
  prompt(target, text, o) { return this.agents.prompt(target, text, o); }
442
460
  sendKeys(target, keys) { return this.agents.sendKeys(target, keys); }
443
461
  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,51 @@ 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;
98
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 MoveToAccountResult = {
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;
143
+ };
144
+ export type Move = {
145
+ paneId: string;
146
+ kind: string;
147
+ args: string[];
148
+ set: Record<string, string>;
149
+ unset?: string[];
150
+ onStaged?(newPaneId: string): void;
151
+ onReplaced?(newPaneId: string): void;
152
+ timeoutMs?: number;
153
+ };
154
+ export type MoveResult = {
155
+ ok: true;
156
+ paneId: string;
157
+ } | Extract<MoveToAccountResult, {
158
+ ok: false;
159
+ }>;
99
160
  export type PromptReceipt = {
100
161
  paneId: string;
101
162
  terminalId: string;
102
163
  revision: number;
103
164
  status: AgentStatus;
165
+ agentSession?: AgentSessionRef;
104
166
  };
105
167
  export type BlockedAgent = {
106
168
  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.4.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.6.0",
58
58
  "@byokit/seal": "0.2.0",
59
59
  "ws": "8.21.3"
60
60
  },