@byokit/herdr 0.3.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,14 @@
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
+
5
13
  ## 0.3.0 (2026-10-01)
6
14
 
7
15
  - Dependency update: pins @byokit/reach 0.5.0.
@@ -24,6 +32,7 @@
24
32
  launcher such as a mise shim: shimmed or absent reads `installs-on-first-start`, sharing the
25
33
  one probe path with `installedAgentKinds` (`resolveAgentBinary`, `isAutoInstallShim`).
26
34
 
35
+
27
36
  ## 0.2.0 (2026-09-30)
28
37
 
29
38
  - `HerdrKit.agentStatus(kinds)` (B5): per-kind onboarding readiness — installed plus CLI
package/README.md CHANGED
@@ -149,7 +149,7 @@ 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`, `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` |
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
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 |
@@ -176,9 +176,10 @@ The host supplies account folders and shares conversation history as needed. The
176
176
  credentials. To connect an account, open its CLI in a new tab and let the person complete the CLI's own login:
177
177
 
178
178
  ```ts
179
- import type { HerdrKit } from '@byokit/herdr';
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();
180
182
 
181
- declare const kit: HerdrKit; // the host's started kit
182
183
  const signIn = await kit.openSignInTab({
183
184
  workspaceId: 'w1', kind: 'codex', cwd: '/home/me/project', env: { CODEX_HOME: '/app/accounts/work' },
184
185
  });
@@ -189,13 +190,42 @@ const moved = await kit.moveToAccount({ paneId: 'w1:p2' }, {
189
190
  if (moved.ok) console.log(moved.session); // new pane to follow
190
191
  ```
191
192
 
192
- A move accepts Claude/Pi for Claude folders and Codex/Pi for Codex folders. It verifies the new shell's effective
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
193
194
  account folder, resumes the conversation there, waits for a ready session, then closes the old pane. A failed
194
195
  start preserves the original; a failed source close rolls back the new pane. Failures return a plain `message`
195
196
  and a `live` pane id for recovery, including when cleanup fails. Wait until a conversation is idle or done before
196
197
  moving it. `StartAgent.env` applies to newly created placements; existing shells cannot receive a new env.
197
198
  Tokens stay on the device and are never logged. Each provider's own terms apply to how you use your plan.
198
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
+
199
229
  ## Two ways in
200
230
 
201
231
  - `adopt` talks to the socket path the app passes: the person's own Herdr, nothing spawned, nothing written.
@@ -1,9 +1,10 @@
1
1
  import type { Call } from './agents.ts';
2
- import type { AgentRef, MoveResult, MoveToAccount, OpenSignInTab, StartAgent } from './types.ts';
2
+ import type { AgentRef, Move, MoveResult, MoveToAccount, MoveToAccountResult, OpenSignInTab, StartAgent } from './types.ts';
3
3
  export declare function createAccountPanes(ctx: {
4
4
  call: Call;
5
5
  startAgent(o: StartAgent): Promise<AgentRef>;
6
6
  }): {
7
7
  openSignInTab: (o: OpenSignInTab) => Promise<AgentRef>;
8
- moveToAccount: (target: AgentRef, o: MoveToAccount) => Promise<MoveResult>;
8
+ move: (o: Move) => Promise<MoveResult>;
9
+ moveToAccount: (target: AgentRef, o: MoveToAccount) => Promise<MoveToAccountResult>;
9
10
  };
package/dist/accounts.js CHANGED
@@ -17,11 +17,12 @@ export function createAccountPanes(ctx) {
17
17
  }
18
18
  // A shell rc can override placement env. Verify what the shell actually resolves, rather
19
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) {
20
+ async function checkEnv(paneId, variable, folder, timeout, absent = false) {
21
21
  const marker = `BYOKIT_ACCOUNT_${randomBytes(8).toString('hex')}`;
22
22
  const deadline = Date.now() + timeout;
23
+ const expansion = absent ? '${' + variable + '+x}' : '$' + variable;
23
24
  try {
24
- await call('pane.send_text', { pane_id: paneId, text: `echo ${marker}="$${variable}"\n` }, timeout);
25
+ await call('pane.send_text', { pane_id: paneId, text: `echo ${marker}="${expansion}"\n` }, timeout);
25
26
  for (;;) {
26
27
  const result = await call('pane.read', { pane_id: paneId, source: 'recent_unwrapped', lines: 40,
27
28
  format: 'text', strip_ansi: true }, Math.max(1, deadline - Date.now()));
@@ -38,6 +39,28 @@ export function createAccountPanes(ctx) {
38
39
  return false;
39
40
  }
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
+ }
41
64
  async function openSignInTab(o) {
42
65
  const { workspaceId, label, ...start } = o;
43
66
  try {
@@ -49,7 +72,7 @@ export function createAccountPanes(ctx) {
49
72
  throw Object.assign(new Error(words('agent.notReady')), { code: 'sign_in_failed' });
50
73
  }
51
74
  }
52
- async function moveToAccount(target, o) {
75
+ async function perform(target, o, request) {
53
76
  const failed = (code, live = target.paneId) => ({ ok: false, code, message: words(`move.${code}`), live });
54
77
  if (moving.has(target.paneId))
55
78
  return failed('busy');
@@ -69,17 +92,17 @@ export function createAccountPanes(ctx) {
69
92
  if (!['idle', 'done'].includes(agent.agent_status))
70
93
  return failed('busy');
71
94
  const kind = agent.agent;
72
- if (!['claude', 'codex'].includes(o.provider) || ![o.provider, 'pi'].includes(kind)
73
- || session.agent !== kind)
95
+ if (!['claude', 'codex'].includes(o.provider) || kind !== o.provider
96
+ || (request !== undefined && request.kind !== kind)
97
+ || session.agent !== kind || session.kind !== 'id')
74
98
  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)
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)))
79
102
  return failed('unsupported');
80
103
  if (!o.folder || /[\r\n\0]/.test(o.folder))
81
104
  return failed('env_mismatch');
82
- const timeout = o.timeoutMs ?? 15_000;
105
+ const timeout = o.timeoutMs ?? 60_000;
83
106
  if (!Number.isFinite(timeout) || timeout <= 0)
84
107
  return failed('start_failed');
85
108
  const variable = o.provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME';
@@ -96,21 +119,37 @@ export function createAccountPanes(ctx) {
96
119
  catch {
97
120
  return failed('start_failed');
98
121
  }
99
- if (!await checkEnv(paneId, variable, o.folder, timeout)) {
100
- await close(paneId);
101
- return failed('env_mismatch');
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');
102
134
  }
103
135
  try {
104
136
  // Unique names avoid colliding with the still-live source agent.
105
137
  await ctx.startAgent({ kind, cwd: agent.foreground_cwd ?? agent.cwd ?? '.',
106
138
  name: `move-${randomBytes(8).toString('hex')}`, place: { pane: paneId }, args, timeoutMs: timeout });
107
139
  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');
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
+ }
114
153
  }
115
154
  catch {
116
155
  const cleaned = await close(paneId);
@@ -120,11 +159,24 @@ export function createAccountPanes(ctx) {
120
159
  const cleaned = await close(paneId);
121
160
  return failed('close_failed', cleaned ? target.paneId : paneId);
122
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 */ }
123
167
  return { ok: true, session: paneId };
124
168
  }
125
169
  finally {
126
170
  moving.delete(target.paneId);
127
171
  }
128
172
  }
129
- return { openSignInTab, moveToAccount };
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) };
130
182
  }
package/dist/index.d.ts CHANGED
@@ -2,4 +2,4 @@ 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
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';
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/kit.d.ts CHANGED
@@ -1,4 +1,4 @@
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';
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?;
@@ -39,6 +39,7 @@ export declare class HerdrKit {
39
39
  subscribe<E extends HerdrEventName>(subs: HerdrSubscription<E>[], on: (e: HerdrEventOf<E>) => void, onError?: (code: string, message: string) => void): HerdrSubscribeStop;
40
40
  cli(args: string[], o?: {
41
41
  timeoutMs?: number;
42
+ env?: Record<string, string>;
42
43
  }): Promise<{
43
44
  stdout: string;
44
45
  stderr: string;
@@ -68,7 +69,8 @@ export declare class HerdrKit {
68
69
  */
69
70
  statusWatchReady(): Promise<void>;
70
71
  openSignInTab(o: OpenSignInTab): Promise<AgentRef>;
71
- moveToAccount(target: AgentRef, o: MoveToAccount): Promise<MoveResult>;
72
+ move(o: Move): Promise<MoveResult>;
73
+ moveToAccount(target: AgentRef, o: MoveToAccount): Promise<MoveToAccountResult>;
72
74
  startAgent(o: StartAgent): Promise<AgentRef>;
73
75
  onStartAgent(fn: (e: AgentStartEvent) => void): () => void;
74
76
  prompt(target: AgentRef, text: string, o?: {
package/dist/kit.js CHANGED
@@ -427,7 +427,7 @@ export class HerdrKit {
427
427
  throw new Error('herdr: not connected');
428
428
  return this.transport.subscribe(subs, on, onError ?? (() => { }));
429
429
  }
430
- 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); }
431
431
  terminal(paneId, o) { return openTerminal(this.o.bin, this.supervisor.env(), paneId, o); }
432
432
  snapshot() { return structuredClone(this.tree); }
433
433
  onChange(fn) { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }
@@ -447,6 +447,7 @@ export class HerdrKit {
447
447
  */
448
448
  statusWatchReady() { return this.statusReadyPromise; }
449
449
  openSignInTab(o) { return this.accountPanes.openSignInTab(o); }
450
+ move(o) { return this.accountPanes.move(o); }
450
451
  moveToAccount(target, o) { return this.accountPanes.moveToAccount(target, o); }
451
452
  startAgent(o) { return this.agents.startAgent(o); }
452
453
  // App-wide start lifecycle without polling: every startAgent's installing/ready/launchFailed
package/dist/types.d.ts CHANGED
@@ -132,7 +132,7 @@ export type MoveToAccount = {
132
132
  direction?: 'right' | 'down';
133
133
  timeoutMs?: number;
134
134
  };
135
- export type MoveResult = {
135
+ export type MoveToAccountResult = {
136
136
  ok: true;
137
137
  session: string;
138
138
  } | {
@@ -141,6 +141,22 @@ export type MoveResult = {
141
141
  message: string;
142
142
  live?: string;
143
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
+ }>;
144
160
  export type PromptReceipt = {
145
161
  paneId: string;
146
162
  terminalId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/herdr",
3
- "version": "0.3.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",
@@ -54,7 +54,7 @@
54
54
  "dependencies": {
55
55
  "@byokit/link": "0.7.0",
56
56
  "@byokit/relay": "0.5.0",
57
- "@byokit/reach": "0.5.0",
57
+ "@byokit/reach": "0.6.0",
58
58
  "@byokit/seal": "0.2.0",
59
59
  "ws": "8.21.3"
60
60
  },