@byokit/herdr 0.4.0 → 0.5.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,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.5.0 (2026-10-01)
6
+
7
+ - SECURITY: StartAgent and openSignInTab accept a clean launch environment with explicit unsets, verified before launch in idle shells on new or existing panes; credential values stay out of terminal commands, argv and errors, preventing inherited API key (billed per use) variables from overriding subscription sign-in.
8
+
9
+ ## 0.4.1 (2026-10-01)
10
+
11
+ - Dependency update: pins @byokit/relay 0.5.1.
12
+
13
+ - FIX: Preserve the ready replacement after a lost source-close acknowledgment unless fresh reads verify the original conversation survived; report only a verified recovery conversation, or omit `live` when verification is unavailable.
14
+
5
15
  ## 0.4.0 (2026-10-01)
6
16
 
7
17
  - Dependency update: pins @byokit/reach 0.6.0.
@@ -9,6 +19,9 @@
9
19
  - Add `move` with caller resume args, credential shedding, staging/replacement hooks and `MoveResult.paneId`.
10
20
  - Add per-call `cli` env overlays for managed folder hook installation.
11
21
  - Report surviving replacement panes when environment verification rollback fails; refuse folder moves for agents that ignore the folder variables.
22
+ - Track a subscription agent's turn with `runTurn` and `onTurnEnd`: acknowledged Herdr status
23
+ events, optional JSON validated by the app's schema validator, and changed files from bounded
24
+ before/after snapshots of the app-owned working directory. No additional API billing path.
12
25
 
13
26
  ## 0.3.0 (2026-10-01)
14
27
 
package/README.md CHANGED
@@ -192,8 +192,12 @@ if (moved.ok) console.log(moved.session); // new pane to follow
192
192
 
193
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
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
195
+ start preserves the original. A failed source close rolls back the new pane only after fresh reads verify
196
+ both conversations' identities. A lost close acknowledgment can mean the original already closed: the kit
197
+ preserves the replacement when the source is absent, replaced or unreachable and returns `close_failed`.
198
+ After uncertain source close or cleanup, `live` names a freshly verified surviving conversation; it is omitted
199
+ when neither conversation can be verified. Check the remaining panes before retrying. Independent changes
200
+ can still happen after verification; the server has no conditional close transaction. Failures return a plain `message`. Wait until a conversation is idle or done before
197
201
  moving it. `StartAgent.env` applies to newly created placements; existing shells cannot receive a new env.
198
202
  Tokens stay on the device and are never logged. Each provider's own terms apply to how you use your plan.
199
203
 
@@ -219,7 +223,8 @@ await kit.cli(['integration', 'install', 'codex'], { env: { CODEX_HOME: '/app/ac
219
223
 
220
224
  `MoveResult` names the new pane as `paneId`; `moveToAccount` retains its `session` result as
221
225
  `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.
226
+ exception rolls back. Replacement notification runs once after an acknowledged source close and cannot undo the move.
227
+ An ambiguous close returns failure without a replacement notification, even if the source has disappeared.
223
228
  `move` clears `unset` in the new shell, verifies the account folder and unset names' absence, and requires an interactive
224
229
  agent publishing a conversation before closing the source. The pinned start API has no command-prefix/env
225
230
  field, so this uses shell `unset` rather than `env -u`; shells that cannot clear a variable fail closed.
@@ -307,6 +312,64 @@ off();
307
312
  `start()` is non-fatal: if Herdr is down it rejects (state `failed`, e.g. `failed/socket`) and may be called again
308
313
  afterwards, so the host comes up while Herdr is down by retrying `start()` in a backoff loop.
309
314
 
315
+ ## Turn results
316
+
317
+ `runTurn` prompts an existing subscription agent and resolves after Herdr reports that it worked
318
+ and returned to `idle` or `done`. Subscribe app-wide with `onTurnEnd`, or use the call's `onEnd`.
319
+ An initial idle or a blocked approval does not finish the turn; keep using `onBlocked`/`answer`.
320
+ No provider API or additional billing path is involved.
321
+
322
+ ```ts
323
+ import { HerdrKit } from '@byokit/herdr';
324
+
325
+ const kit = new HerdrKit({ mode: 'adopt', bin: 'herdr', socketPath: '/tmp/herdr.sock' });
326
+ await kit.start();
327
+ const target = await kit.startAgent({ kind: 'codex', cwd: '/app/worktree', place: { workspace: 'new' } });
328
+ const off = kit.onTurnEnd((end) => console.log(end.changedFiles));
329
+ const end = await kit.runTurn(target, {
330
+ prompt: 'Implement the requested change, then report whether the build passed.',
331
+ cwd: '/app/worktree',
332
+ timeoutMs: 10 * 60_000,
333
+ files: { exclude: (path) => path === 'node_modules' || path === 'dist' },
334
+ result: {
335
+ schema: { type: 'object', required: ['buildPassed'], additionalProperties: false,
336
+ properties: { buildPassed: { type: 'boolean' } } },
337
+ // For larger schemas, call the app's existing JSON Schema validator here.
338
+ validate: (value: unknown): value is { buildPassed: boolean } =>
339
+ typeof value === 'object' && value !== null && 'buildPassed' in value &&
340
+ typeof value.buildPassed === 'boolean' && Object.keys(value).length === 1,
341
+ },
342
+ });
343
+ if (end.result.state === 'valid') console.log(end.result.value.buildPassed);
344
+ off();
345
+ ```
346
+
347
+ The kit asks the agent to write `{ turnId, result }` to a fresh JSON file in that folder before
348
+ finishing. It checks the id, parses and validates the bounded file, and removes it. The result
349
+ is `not-requested` without a policy, `missing` if the agent did not write it, `invalid` with a
350
+ reason if it could not be accepted, or `valid` with a typed value. Unchecked payloads are never
351
+ returned or logged; the transient result file is excluded from `changedFiles`. Default result
352
+ limit: 1 MiB (`result.maxBytes`). The agent must follow these instructions; Herdr has no native
353
+ schema-enforced response API. Task success is a separate app decision.
354
+
355
+ `changedFiles` lists sorted relative paths with `added`, `modified` or `deleted`, including
356
+ untracked files and changes to already-dirty files. Snapshots compare content, modes and symlink
357
+ targets, do not traverse symlinks, always exclude `.git`, and ignore special files. Files changed
358
+ and restored during the turn do not appear. Use `files.exclude` to prune generated or sensitive
359
+ folders. Each scan defaults to 10000 entries and 128 MiB of regular-file content; set
360
+ `files.maxFiles`/`maxBytes` to change these limits. An incomplete scan rejects the call.
361
+
362
+ Reserve the pane and folder exclusively while the turn runs. Overlapping calls in one kit are
363
+ refused, but changes by other apps or processes cannot be attributed to this agent. The supplied
364
+ absolute `cwd` must match Herdr's reported working directory. Timeout, watch loss, pane closure or
365
+ replacement, `kit.stop()` and an aborted `signal` reject without a completion event. Monitoring
366
+ cancellation does not interrupt the agent; it may still finish or write its result file later.
367
+ Herdr's working-to-idle/done detection is the end signal, not proof of semantic success. The kit
368
+ does not guess completion from elapsed silence or a prompt receipt.
369
+
370
+ This helper runs on the host. Apps own branches, review/apply/rollback and authorized forwarding
371
+ of results to devices; it adds no link permission or device operation.
372
+
310
373
  ## Status
311
374
 
312
375
  Pinned to Herdr v0.9.1. The schema snapshot (`schema/herdr-api-0.9.1.json`, protocol 22) generates the typed surface
@@ -328,3 +391,10 @@ package, never touches a person's other AI tools. Its tests run against a fake H
328
391
  ## License
329
392
 
330
393
  Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](../../NOTICE).
394
+
395
+ `startAgent` and `openSignInTab` accept the accounts `launchEnv()` result as `env: launch`.
396
+ The host and Herdr must share a filesystem. New panes and existing idle POSIX shells apply
397
+ the settings and unsets through a private temporary file before launch; only the file path
398
+ is sent to the terminal. Busy panes and unsupported shells fail closed. Legacy record env
399
+ still travels on new-pane placement, and remains refused on existing panes. Credential
400
+ values must never be supplied in agent args.
package/dist/accounts.js CHANGED
@@ -73,7 +73,7 @@ export function createAccountPanes(ctx) {
73
73
  }
74
74
  }
75
75
  async function perform(target, o, request) {
76
- const failed = (code, live = target.paneId) => ({ ok: false, code, message: words(`move.${code}`), live });
76
+ const failed = (code, live = target.paneId) => ({ ok: false, code, message: words(`move.${code}`), ...(live === null ? {} : { live }) });
77
77
  if (moving.has(target.paneId))
78
78
  return failed('busy');
79
79
  moving.add(target.paneId);
@@ -107,6 +107,7 @@ export function createAccountPanes(ctx) {
107
107
  return failed('start_failed');
108
108
  const variable = o.provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME';
109
109
  let paneId;
110
+ let replacement;
110
111
  try {
111
112
  const split = await call('pane.split', { target_pane_id: target.paneId,
112
113
  direction: o.direction ?? 'right', focus: false,
@@ -144,8 +145,10 @@ export function createAccountPanes(ctx) {
144
145
  && (request === undefined || ready.interactive_ready === true)
145
146
  && ready.agent === kind && ready.agent_session?.agent === kind
146
147
  && ready.agent_session?.kind === session.kind && typeof ready.agent_session?.value === 'string'
147
- && ready.agent_session.value.length > 0)
148
+ && ready.agent_session.value.length > 0) {
149
+ replacement = ready;
148
150
  break;
151
+ }
149
152
  if (Date.now() >= deadline)
150
153
  throw new Error('not ready');
151
154
  await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
@@ -156,8 +159,33 @@ export function createAccountPanes(ctx) {
156
159
  return failed('start_failed', cleaned ? target.paneId : paneId);
157
160
  }
158
161
  if (!await close(target.paneId)) {
159
- const cleaned = await close(paneId);
160
- return failed('close_failed', cleaned ? target.paneId : paneId);
162
+ // A lost ACK can mean the close already applied. Never destroy the ready
163
+ // replacement unless a fresh read proves the original conversation survived.
164
+ // Compare identity, not revision: status changes can legitimately bump revision.
165
+ const survives = async (id, expected) => {
166
+ try {
167
+ const current = (await call('agent.get', { target: id }, timeout))?.agent;
168
+ return !!current && current.agent === expected.agent && current.launch_pending !== true
169
+ && ['idle', 'working', 'blocked', 'done'].includes(current.agent_status)
170
+ && current.agent_session?.agent === expected.agent_session?.agent
171
+ && current.agent_session?.kind === expected.agent_session?.kind
172
+ && current.agent_session?.value === expected.agent_session?.value
173
+ && (expected.terminal_id === undefined || current.terminal_id === expected.terminal_id)
174
+ && (expected.name === undefined || current.name === expected.name);
175
+ }
176
+ catch {
177
+ return false;
178
+ } // unavailable is not proof of survival
179
+ };
180
+ const replacementAlive = await survives(paneId, replacement);
181
+ const sourceAlive = await survives(target.paneId, agent);
182
+ if (sourceAlive && replacementAlive)
183
+ await close(paneId);
184
+ // Re-read after cleanup too: its ACK can also be lost, or another actor can
185
+ // replace a pane. `live` is optional precisely when neither can be verified.
186
+ const live = await survives(paneId, replacement) ? paneId
187
+ : await survives(target.paneId, agent) ? target.paneId : null;
188
+ return failed('close_failed', live);
161
189
  }
162
190
  // The source is closed: a notification failure cannot undo a successful move.
163
191
  try {
package/dist/agents.js CHANGED
@@ -7,6 +7,7 @@ import { accessSync, constants as fsConstants, openSync, readSync, closeSync } f
7
7
  import { homedir } from 'node:os';
8
8
  import { delimiter, join } from 'node:path';
9
9
  import { words } from "./words.js";
10
+ import { prepareLaunchEnv } from "./launch-env.js";
10
11
  const PROMPTABLE = ['idle', 'working', 'blocked', 'done'];
11
12
  const DEFAULT_TIMEOUT_MS = 60_000;
12
13
  // `agent.start` lands while the fresh pane is still at its shell prompt: the server answers
@@ -81,15 +82,19 @@ export function createAgents(ctx) {
81
82
  catch (error) {
82
83
  const reason = classifyStartFailure(error, { installExpected, stage });
83
84
  emit({ phase: 'launchFailed', kind: o.kind, reason, message: launchFailureWords(reason, o.kind) });
85
+ if (o.env && typeof o.env.env === 'object')
86
+ throw fail(codeOf(error) === 'env_mismatch' ? 'env_mismatch' : 'start_failed', words('agent.notReady'));
84
87
  throw error;
85
88
  }
86
89
  }
87
90
  async function startAgentInner(o, onStartPhase) {
88
- if ('pane' in o.place && Object.keys(o.env ?? {}).length > 0) {
91
+ const launch = o.env && typeof o.env.env === 'object' && Array.isArray(o.env.unset)
92
+ ? o.env : undefined;
93
+ if (!launch && 'pane' in o.place && Object.keys(o.env ?? {}).length > 0) {
89
94
  throw fail('env_mismatch', 'This pane needs to be opened again to use that sign-in.');
90
95
  }
91
96
  const timeout = o.timeoutMs ?? DEFAULT_TIMEOUT_MS;
92
- const env = o.env === undefined ? {} : { env: o.env };
97
+ const env = o.env === undefined || launch ? {} : { env: o.env };
93
98
  let paneId;
94
99
  let created = false;
95
100
  if (o.worktree !== undefined) {
@@ -127,6 +132,20 @@ export function createAgents(ctx) {
127
132
  }
128
133
  if (paneId === undefined)
129
134
  throw new Error('herdr: the placement answered no pane id');
135
+ if (launch) {
136
+ try {
137
+ await prepareLaunchEnv(call, paneId, launch, timeout);
138
+ }
139
+ catch (error) {
140
+ if (created) {
141
+ try {
142
+ await call('pane.close', { pane_id: paneId });
143
+ }
144
+ catch { /* best effort */ }
145
+ }
146
+ throw error;
147
+ }
148
+ }
130
149
  onStartPhase?.();
131
150
  const name = agentName(o);
132
151
  const params = { pane_id: paneId, kind: o.kind, name,
@@ -148,6 +167,8 @@ export function createAgents(ctx) {
148
167
  }
149
168
  catch { /* rollback is best-effort */ }
150
169
  }
170
+ if (launch)
171
+ throw fail('start_failed', words('agent.notReady'));
151
172
  throw error;
152
173
  }
153
174
  }
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, Move, MoveToAccount, MoveToAccountResult, 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, AgentTurnEnd, AgentTurnOptions, AgentTurnFiles, AgentTurnResult, AgentTurnResultPolicy, } from './types.ts';
package/dist/kit.d.ts CHANGED
@@ -1,4 +1,5 @@
1
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
+ import type { AgentTurnEnd, AgentTurnOptions } from './types.ts';
2
3
  export declare class HerdrKit {
3
4
  private readonly supervisor;
4
5
  private transport?;
@@ -18,6 +19,7 @@ export declare class HerdrKit {
18
19
  private readonly agents;
19
20
  private readonly startListeners;
20
21
  private readonly blockedList;
22
+ private readonly turns;
21
23
  constructor(o: HerdrKitOptions);
22
24
  get state(): HerdrState;
23
25
  private publish;
@@ -79,6 +81,8 @@ export declare class HerdrKit {
79
81
  timeoutMs: number;
80
82
  };
81
83
  }): Promise<PromptReceipt>;
84
+ runTurn<T = unknown>(target: AgentRef, o: AgentTurnOptions<T>): Promise<AgentTurnEnd<T>>;
85
+ onTurnEnd(fn: (end: AgentTurnEnd) => void): () => void;
82
86
  sendKeys(target: AgentRef, keys: string[]): Promise<void>;
83
87
  wait(target: AgentRef, o: {
84
88
  until?: AgentStatus[];
package/dist/kit.js CHANGED
@@ -6,6 +6,7 @@ import { openTerminal } from "./terminal.js";
6
6
  import { protocolBounds } from "./constants.js";
7
7
  import { closePane, closeTab, closeWorkspace } from "./close.js";
8
8
  import { Supervisor } from "./supervise.js";
9
+ import { createTurns } from "./turns.js";
9
10
  const kinds = ['pane.agent_detected', 'pane.created', 'pane.closed', 'pane.moved', 'pane.exited', 'pane.updated',
10
11
  'workspace.created', 'workspace.closed', 'workspace.renamed', 'workspace.updated', 'tab.created', 'tab.closed', 'tab.renamed'];
11
12
  const empty = () => ({ connected: false, workspaces: [] });
@@ -131,6 +132,7 @@ export class HerdrKit {
131
132
  agents;
132
133
  startListeners = new Set();
133
134
  blockedList;
135
+ turns;
134
136
  constructor(o) {
135
137
  this.o = o;
136
138
  this.supervisor = new Supervisor(o, (s) => { this.current = s; o.onState?.(s); });
@@ -143,6 +145,7 @@ export class HerdrKit {
143
145
  }
144
146
  catch { /* a listener never breaks a start */ } } });
145
147
  this.blockedList = new Blocked({ call: this.callAny });
148
+ this.turns = createTurns(this);
146
149
  }
147
150
  get state() { return this.current; }
148
151
  publish() { for (const fn of this.listeners)
@@ -396,6 +399,7 @@ export class HerdrKit {
396
399
  }
397
400
  }
398
401
  async stop() {
402
+ this.turns.stop();
399
403
  ++this.generation;
400
404
  this.statusReadyResolve?.();
401
405
  this.statusReadyPromise = Promise.resolve();
@@ -457,6 +461,8 @@ export class HerdrKit {
457
461
  return () => { this.startListeners.delete(fn); };
458
462
  }
459
463
  prompt(target, text, o) { return this.agents.prompt(target, text, o); }
464
+ runTurn(target, o) { return this.turns.runTurn(target, o); }
465
+ onTurnEnd(fn) { return this.turns.onTurnEnd(fn); }
460
466
  sendKeys(target, keys) { return this.agents.sendKeys(target, keys); }
461
467
  wait(target, o) { return this.agents.wait(target, o); }
462
468
  read(paneId, o) { return this.agents.read(paneId, o); }
@@ -0,0 +1,6 @@
1
+ import type { Call } from './agents.ts';
2
+ export type LaunchEnvironment = {
3
+ env: Record<string, string>;
4
+ unset: string[];
5
+ };
6
+ export declare function prepareLaunchEnv(call: Call, paneId: string, launch: LaunchEnvironment, timeout: number): Promise<void>;
@@ -0,0 +1,71 @@
1
+ // The pinned agent.start launches through the pane shell. Values stay in a private host file,
2
+ // never terminal text or argv. Only an idle POSIX shell may source it; preparation fails closed.
3
+ import { randomBytes } from 'node:crypto';
4
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ const quote = (v) => `'${v.replaceAll("'", "'\\''")}'`;
8
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
+ export async function prepareLaunchEnv(call, paneId, launch, timeout) {
10
+ let dir;
11
+ try {
12
+ if (!Number.isFinite(timeout) || timeout <= 0)
13
+ throw new Error();
14
+ const entries = Object.entries(launch.env);
15
+ const unset = [...launch.unset];
16
+ const names = [...entries.map(([key]) => key), ...unset];
17
+ if (names.some((key) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
18
+ || entries.some(([, value]) => typeof value !== 'string' || /[\r\n\0]/.test(value))
19
+ || unset.some((key) => Object.hasOwn(launch.env, key)))
20
+ throw new Error();
21
+ const deadline = Date.now() + timeout;
22
+ // Do not send shell input to an agent or another foreground program.
23
+ for (;;) {
24
+ const result = await call('pane.process_info', { pane_id: paneId }, Math.max(1, deadline - Date.now()));
25
+ const info = result?.process_info;
26
+ if (info?.shell_pid && info.foreground_processes?.length === 1
27
+ && info.foreground_processes[0]?.pid === info.shell_pid
28
+ && /^(?:-)?(?:ba|z|da|k)?sh$/.test(info.foreground_processes[0].name))
29
+ break;
30
+ if (Date.now() >= deadline)
31
+ throw new Error();
32
+ await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
33
+ }
34
+ const marker = `BYOKIT_ENV_${randomBytes(16).toString('hex')}`;
35
+ dir = mkdtempSync(join(tmpdir(), 'byokit-env-'));
36
+ const file = join(dir, 'prepare.sh');
37
+ const steps = [
38
+ 'set +x +v',
39
+ // Remove exports absent from the clean result too: a pane may inherit more than the host.
40
+ // The pipeline is private; neither values nor variable listings reach the terminal.
41
+ `for BYOKIT_ENV_KEY in $(/usr/bin/env | /usr/bin/cut -d= -f1); do
42
+ case "$BYOKIT_ENV_KEY" in ''|[!A-Za-z_]*|*[!A-Za-z0-9_]*) continue ;; esac
43
+ case "$BYOKIT_ENV_KEY" in ${entries.length ? entries.map(([key]) => key).join('|') : 'BYOKIT_ENV_KEY'}) ;; *) unset "$BYOKIT_ENV_KEY" 2>/dev/null || return 1 ;; esac
44
+ done`,
45
+ 'unset BYOKIT_ENV_KEY',
46
+ ...unset.map((key) => `unset ${key}`),
47
+ ...entries.map(([key, value]) => `export ${key}=${quote(value)}`),
48
+ ...unset.map((key) => `test "\${${key}+present}" = ''`),
49
+ ...entries.map(([key, value]) => `test "\${${key}+present}" = present && test "$${key}" = ${quote(value)}`),
50
+ `printf '%s\\n' ${quote(marker)}`,
51
+ ];
52
+ writeFileSync(file, steps.join(' &&\n') + '\n', { mode: 0o600, flag: 'wx' });
53
+ await call('pane.send_text', { pane_id: paneId, text: `set +x +v; . ${quote(file)}\n` }, Math.max(1, deadline - Date.now()));
54
+ for (;;) {
55
+ const result = await call('pane.read', { pane_id: paneId, source: 'recent_unwrapped', lines: 40,
56
+ format: 'text', strip_ansi: true }, Math.max(1, deadline - Date.now()));
57
+ if (result?.read?.text?.split(/\r?\n/).some((line) => line.trim() === marker))
58
+ return;
59
+ if (Date.now() >= deadline)
60
+ throw new Error();
61
+ await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
62
+ }
63
+ }
64
+ catch {
65
+ throw Object.assign(new Error('This pane could not use that sign-in.'), { code: 'env_mismatch' });
66
+ }
67
+ finally {
68
+ if (dir !== undefined)
69
+ rmSync(dir, { recursive: true, force: true });
70
+ }
71
+ }
@@ -2,6 +2,7 @@
2
2
  // request per connection — the server closes after answering; only `events.subscribe` is held open.
3
3
  // Ported from muxr `perf/fake-herdr/server.mjs` with the UI plugins, title churn, graphics and perf
4
4
  // byte rates removed.
5
+ import { spawnSync } from 'node:child_process';
5
6
  import { mkdirSync, unlinkSync } from 'node:fs';
6
7
  import { createServer } from 'node:net';
7
8
  import { join } from 'node:path';
@@ -235,6 +236,28 @@ export async function startFakeHerdr(options) {
235
236
  emitEvent('tab.renamed', { tab_id: tab.tab_id, workspace_id: tab.workspace_id, label: tab.label });
236
237
  return { type: 'tab_info', tab: tabView(tab) };
237
238
  },
239
+ 'pane.process_info': (p) => {
240
+ const pane = paneOf(p.pane_id);
241
+ const busy = live.agents.some((a) => a.pane_id === pane.pane_id);
242
+ return { process_info: { shell_pid: 1, foreground_processes: [{ pid: busy ? 2 : 1, name: busy ? 'agent' : 'sh' }] } };
243
+ },
244
+ 'pane.send_text': (p) => {
245
+ const pane = paneOf(p.pane_id);
246
+ const text = String(p.text ?? '');
247
+ // A real offline shell exercises the private launch file, never a real agent CLI.
248
+ if (!/^set \+x \+v; \. '/.test(text))
249
+ throw fail('unsupported', 'unsupported fake shell command');
250
+ const r = spawnSync('/bin/sh', ['-c', text + "printf '\\0'; /usr/bin/env -0"], {
251
+ env: pane.env ?? { PATH: '/usr/bin:/bin', HOME: dir }, encoding: 'utf8', timeout: 5000,
252
+ });
253
+ const [output, ...environment] = r.stdout.split('\0');
254
+ pane.text.push(output);
255
+ pane.env = Object.fromEntries(environment.filter(Boolean).map((entry) => {
256
+ const i = entry.indexOf('=');
257
+ return [entry.slice(0, i), entry.slice(i + 1)];
258
+ }));
259
+ return {};
260
+ },
238
261
  'pane.get': (p) => ({ pane: paneView(paneOf(p.pane_id)) }),
239
262
  'pane.close': (p) => {
240
263
  const pane = paneOf(p.pane_id);
@@ -0,0 +1,4 @@
1
+ import type { AgentTurnEnd, AgentTurnFiles } from './types.ts';
2
+ export declare function positiveLimit(value: number | undefined, fallback: number): number;
3
+ export declare function scanFiles(root: string, policy: AgentTurnFiles | undefined, resultName: string, active: () => void): Promise<Map<string, string>>;
4
+ export declare function changedFiles(before: Map<string, string>, after: Map<string, string>): AgentTurnEnd['changedFiles'];
@@ -0,0 +1,88 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, open, readdir, readlink, realpath } from 'node:fs/promises';
4
+ import { join, relative, sep } from 'node:path';
5
+ export function positiveLimit(value, fallback) {
6
+ const n = value ?? fallback;
7
+ if (!Number.isSafeInteger(n) || n <= 0)
8
+ throw new Error('turn: invalid limit');
9
+ return n;
10
+ }
11
+ // A baseline of the actual tree, independent of Git status and mtime granularity. Never traverse
12
+ // symlinks or read special files; a changing/unreadable tree fails rather than claiming a full diff.
13
+ export async function scanFiles(root, policy, resultName, active) {
14
+ const maxFiles = positiveLimit(policy?.maxFiles, 10000);
15
+ const maxBytes = positiveLimit(policy?.maxBytes, 128 * 1024 * 1024);
16
+ let entries = 0;
17
+ let bytes = 0;
18
+ const found = new Map();
19
+ async function walk(dir) {
20
+ active();
21
+ // Catch a directory being swapped for an out-of-tree symlink during a scan.
22
+ const canonical = await realpath(dir);
23
+ const within = relative(root, canonical);
24
+ if (canonical !== dir || within === '..' || within.startsWith(`..${sep}`))
25
+ throw new Error('turn: directory escaped');
26
+ for (const name of await readdir(dir)) {
27
+ active();
28
+ const path = join(dir, name);
29
+ const rel = relative(root, path).split(sep).join('/');
30
+ if (name === '.git' || rel === resultName || policy?.exclude?.(rel))
31
+ continue;
32
+ if (++entries > maxFiles)
33
+ throw new Error('turn: file limit');
34
+ const info = await lstat(path);
35
+ if (info.isSymbolicLink()) {
36
+ found.set(rel, `link:${await readlink(path)}`);
37
+ }
38
+ else if (info.isDirectory()) {
39
+ await walk(path);
40
+ }
41
+ else if (info.isFile()) {
42
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
43
+ try {
44
+ const before = await file.stat();
45
+ if (!before.isFile() || before.ino !== info.ino || before.dev !== info.dev)
46
+ throw new Error('turn: file changed');
47
+ const digest = createHash('sha256');
48
+ const buffer = Buffer.alloc(64 * 1024);
49
+ let total = 0;
50
+ for (;;) {
51
+ active();
52
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, null);
53
+ bytes += bytesRead;
54
+ total += bytesRead;
55
+ if (bytes > maxBytes)
56
+ throw new Error('turn: byte limit');
57
+ if (bytesRead === 0)
58
+ break;
59
+ digest.update(buffer.subarray(0, bytesRead));
60
+ }
61
+ const after = await file.stat();
62
+ if (before.size !== total || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) {
63
+ throw new Error('turn: file changed');
64
+ }
65
+ found.set(rel, `${before.mode}:${digest.digest('hex')}`);
66
+ }
67
+ finally {
68
+ await file.close();
69
+ }
70
+ }
71
+ }
72
+ }
73
+ await walk(root);
74
+ active();
75
+ return found;
76
+ }
77
+ export function changedFiles(before, after) {
78
+ const changes = [];
79
+ for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
80
+ if (!before.has(path))
81
+ changes.push({ path, change: 'added' });
82
+ else if (!after.has(path))
83
+ changes.push({ path, change: 'deleted' });
84
+ else if (before.get(path) !== after.get(path))
85
+ changes.push({ path, change: 'modified' });
86
+ }
87
+ return changes;
88
+ }
@@ -0,0 +1,7 @@
1
+ import type { HerdrKit } from './kit.ts';
2
+ import type { AgentRef, AgentTurnEnd, AgentTurnOptions } from './types.ts';
3
+ export declare function createTurns(kit: HerdrKit): {
4
+ runTurn: <T>(target: AgentRef, o: AgentTurnOptions<T>) => Promise<AgentTurnEnd<T>>;
5
+ onTurnEnd(fn: (end: AgentTurnEnd) => void): () => void;
6
+ stop(): void;
7
+ };
package/dist/turns.js ADDED
@@ -0,0 +1,225 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { open, realpath, unlink } from 'node:fs/promises';
4
+ import { isAbsolute, join, relative, sep } from 'node:path';
5
+ import { changedFiles, positiveLimit, scanFiles } from "./turn-files.js";
6
+ import { words } from "./words.js";
7
+ const fail = (code) => Object.assign(new Error(words('turn.failed')), { code });
8
+ const contains = (parent, child) => {
9
+ const rel = relative(parent, child);
10
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
11
+ };
12
+ async function readResult(path, id, policy) {
13
+ const maxBytes = positiveLimit(policy.maxBytes, 1024 * 1024);
14
+ let file;
15
+ try {
16
+ file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
17
+ }
18
+ catch (e) {
19
+ if (e.code === 'ENOENT')
20
+ return { state: 'missing' };
21
+ return { state: 'invalid', reason: 'unsafe-file' };
22
+ }
23
+ try {
24
+ const info = await file.stat();
25
+ if (!info.isFile())
26
+ return { state: 'invalid', reason: 'unsafe-file' };
27
+ if (info.size > maxBytes)
28
+ return { state: 'invalid', reason: 'too-large' };
29
+ const buffer = Buffer.alloc(Math.min(info.size + 1, maxBytes + 1));
30
+ let size = 0;
31
+ while (size < buffer.length) {
32
+ const { bytesRead } = await file.read(buffer, size, buffer.length - size, null);
33
+ if (!bytesRead)
34
+ break;
35
+ size += bytesRead;
36
+ }
37
+ if (size > maxBytes)
38
+ return { state: 'invalid', reason: 'too-large' };
39
+ const after = await file.stat();
40
+ if (size !== info.size || after.mtimeMs !== info.mtimeMs || after.ctimeMs !== info.ctimeMs) {
41
+ return { state: 'invalid', reason: 'format' };
42
+ }
43
+ let payload;
44
+ try {
45
+ payload = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, size)));
46
+ }
47
+ catch {
48
+ return { state: 'invalid', reason: 'format' };
49
+ }
50
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload) ||
51
+ !('turnId' in payload) || payload.turnId !== id || !('result' in payload)) {
52
+ return { state: 'invalid', reason: 'format' };
53
+ }
54
+ try {
55
+ if (policy.validate(payload.result))
56
+ return { state: 'valid', value: payload.result };
57
+ }
58
+ catch { /* Validator diagnostics may contain private data; never log them. */ }
59
+ return { state: 'invalid', reason: 'schema' };
60
+ }
61
+ finally {
62
+ await file.close();
63
+ }
64
+ }
65
+ export function createTurns(kit) {
66
+ const panes = new Set();
67
+ const roots = new Set();
68
+ const pending = new Set();
69
+ const listeners = new Set();
70
+ async function runTurn(target, o) {
71
+ if (panes.has(target.paneId))
72
+ throw fail('turn-busy');
73
+ if (!isAbsolute(o.cwd))
74
+ throw fail('turn-cwd');
75
+ const timeoutMs = positiveLimit(o.timeoutMs, 300000);
76
+ // Fail invalid configuration before delivering a prompt.
77
+ positiveLimit(o.files?.maxFiles, 10000);
78
+ positiveLimit(o.files?.maxBytes, 128 * 1024 * 1024);
79
+ if (o.result)
80
+ positiveLimit(o.result.maxBytes, 1024 * 1024);
81
+ const schema = o.result ? JSON.stringify(o.result.schema) : undefined;
82
+ const id = randomUUID();
83
+ const resultName = `.byokit-turn-${id}.json`;
84
+ let root;
85
+ let ownsRoot = false;
86
+ let failure;
87
+ let rejectFailure;
88
+ const failed = new Promise((_, reject) => { rejectFailure = reject; });
89
+ void failed.catch(() => { });
90
+ const abort = (code) => {
91
+ if (failure)
92
+ return;
93
+ failure = fail(code);
94
+ rejectFailure(failure);
95
+ };
96
+ const active = () => { if (failure)
97
+ throw failure; };
98
+ const gate = (promise) => Promise.race([promise, failed]);
99
+ const cancelled = () => abort('turn-cancelled');
100
+ const stopped = () => abort('turn-unavailable');
101
+ panes.add(target.paneId);
102
+ pending.add(stopped);
103
+ const timer = setTimeout(() => abort('turn-timeout'), timeoutMs);
104
+ o.signal?.addEventListener('abort', cancelled, { once: true });
105
+ if (o.signal?.aborted)
106
+ cancelled();
107
+ let stopWatch;
108
+ let offEvent;
109
+ let offChange;
110
+ try {
111
+ active();
112
+ root = await gate(realpath(o.cwd));
113
+ if ([...roots].some((held) => contains(held, root) || contains(root, held)))
114
+ throw fail('turn-busy');
115
+ roots.add(root);
116
+ ownsRoot = true;
117
+ if (!kit.snapshot().connected)
118
+ throw fail('turn-watch-lost');
119
+ offChange = kit.onChange((tree) => { if (!tree.connected)
120
+ abort('turn-watch-lost'); });
121
+ let armed = false;
122
+ let working = false;
123
+ let ended = false;
124
+ let resolveEnd;
125
+ const endSignal = new Promise((resolve) => { resolveEnd = resolve; });
126
+ const watch = kit.subscribe([{ type: 'pane.agent_status_changed', pane_id: target.paneId }], (e) => {
127
+ if (!armed) {
128
+ if (e.agent_status !== 'idle')
129
+ abort('turn-busy');
130
+ return;
131
+ }
132
+ if (ended) {
133
+ if (e.agent_status !== 'idle' && e.agent_status !== 'done')
134
+ abort('turn-busy');
135
+ return;
136
+ }
137
+ if (e.agent_status === 'working')
138
+ working = true;
139
+ else if (working && (e.agent_status === 'idle' || e.agent_status === 'done')) {
140
+ ended = true;
141
+ resolveEnd(e.agent_status);
142
+ }
143
+ }, () => abort('turn-watch-lost'));
144
+ stopWatch = watch;
145
+ watch.onDisconnect(() => abort('turn-watch-lost'));
146
+ offEvent = kit.onEvent((e) => {
147
+ const kind = e.type.replace(/_/g, '.');
148
+ if (e.pane_id === target.paneId && ['pane.closed', 'pane.exited', 'pane.moved', 'pane.agent.detected'].includes(kind)) {
149
+ abort('turn-unavailable');
150
+ }
151
+ });
152
+ if (!await gate(watch.ready))
153
+ throw fail('turn-watch-lost');
154
+ const beforeAgent = await gate(kit.call('agent.get', { target: target.paneId }));
155
+ const agent = beforeAgent.agent;
156
+ if (agent.agent_status !== 'idle' || agent.launch_pending === true)
157
+ throw fail('turn-not-idle');
158
+ if (target.name && agent.name !== target.name)
159
+ throw fail('turn-unavailable');
160
+ const cwd = agent.foreground_cwd ?? agent.cwd;
161
+ if (typeof cwd !== 'string' || await gate(realpath(cwd)) !== root)
162
+ throw fail('turn-cwd');
163
+ const before = await gate(scanFiles(root, o.files, resultName, active));
164
+ const prompt = o.result ? `${o.prompt}\n\nBefore finishing, write a UTF-8 JSON file at ${JSON.stringify(join(root, resultName))}. ` +
165
+ `The file must contain {"turnId":${JSON.stringify(id)},"result":<your result>}. ` +
166
+ `The result must satisfy this JSON Schema: ${schema}. Write the file completely before returning to idle.` : o.prompt;
167
+ active();
168
+ armed = true;
169
+ const receipt = await gate(kit.prompt(target, prompt));
170
+ const status = await gate(endSignal);
171
+ // Recheck the occupant/directory before reading an agent-created file.
172
+ const afterAgent = (await gate(kit.call('agent.get', { target: target.paneId }))).agent;
173
+ if (afterAgent.terminal_id !== receipt.terminalId || afterAgent.name !== agent.name ||
174
+ afterAgent.agent !== agent.agent || !['idle', 'done'].includes(afterAgent.agent_status) ||
175
+ await gate(realpath(afterAgent.foreground_cwd ?? afterAgent.cwd ?? '')) !== root)
176
+ throw fail('turn-unavailable');
177
+ const result = o.result
178
+ ? await gate(readResult(join(root, resultName), id, o.result)) : { state: 'not-requested' };
179
+ const after = await gate(scanFiles(root, o.files, resultName, active));
180
+ active();
181
+ const end = { id, target: { ...target }, receipt, status, changedFiles: changedFiles(before, after), result };
182
+ // App callbacks cannot turn a completed turn into a failed call, and never see unchecked bytes.
183
+ for (const fn of [...listeners])
184
+ try {
185
+ fn(end);
186
+ }
187
+ catch { /* host listener */ }
188
+ try {
189
+ o.onEnd?.(end);
190
+ }
191
+ catch { /* per-turn listener */ }
192
+ return end;
193
+ }
194
+ catch (error) {
195
+ if (failure)
196
+ throw failure;
197
+ const code = error?.code;
198
+ if (code?.startsWith('turn-'))
199
+ throw error;
200
+ throw fail('turn-failed');
201
+ }
202
+ finally {
203
+ clearTimeout(timer);
204
+ o.signal?.removeEventListener('abort', cancelled);
205
+ stopWatch?.();
206
+ offEvent?.();
207
+ offChange?.();
208
+ pending.delete(stopped);
209
+ panes.delete(target.paneId);
210
+ if (ownsRoot && root) {
211
+ // Unlink only the kit's unique output path; never recursively delete a directory or follow a link.
212
+ if (o.result && await realpath(root).catch(() => undefined) === root) {
213
+ await unlink(join(root, resultName)).catch(() => { });
214
+ }
215
+ roots.delete(root);
216
+ }
217
+ }
218
+ }
219
+ return {
220
+ runTurn,
221
+ onTurnEnd(fn) { listeners.add(fn); return () => { listeners.delete(fn); }; },
222
+ stop() { for (const cancel of [...pending])
223
+ cancel(); },
224
+ };
225
+ }
package/dist/types.d.ts CHANGED
@@ -116,7 +116,10 @@ export type StartAgent = {
116
116
  base?: string;
117
117
  };
118
118
  args?: string[];
119
- env?: Record<string, string>;
119
+ env?: Record<string, string> | {
120
+ env: Record<string, string>;
121
+ unset: string[];
122
+ };
120
123
  timeoutMs?: number;
121
124
  onEvent?: (e: AgentStartEvent) => void;
122
125
  installProbe?: AgentInstallProbe;
@@ -164,6 +167,47 @@ export type PromptReceipt = {
164
167
  status: AgentStatus;
165
168
  agentSession?: AgentSessionRef;
166
169
  };
170
+ export type AgentTurnFiles = {
171
+ /** Relative paths, including directories. Returning true prunes the entry. `.git` is always excluded. */
172
+ exclude?: (relativePath: string) => boolean;
173
+ maxFiles?: number;
174
+ maxBytes?: number;
175
+ };
176
+ export type AgentTurnResultPolicy<T> = {
177
+ schema: Record<string, unknown>;
178
+ /** Use the app's JSON Schema validator; false or a throw rejects the payload. */
179
+ validate: (value: unknown) => value is T;
180
+ maxBytes?: number;
181
+ };
182
+ export type AgentTurnOptions<T = unknown> = {
183
+ prompt: string;
184
+ cwd: string;
185
+ timeoutMs?: number;
186
+ signal?: AbortSignal;
187
+ files?: AgentTurnFiles;
188
+ result?: AgentTurnResultPolicy<T>;
189
+ onEnd?: (end: AgentTurnEnd<T>) => void;
190
+ };
191
+ export type AgentTurnResult<T = unknown> = {
192
+ state: 'not-requested' | 'missing';
193
+ } | {
194
+ state: 'invalid';
195
+ reason: 'format' | 'schema' | 'too-large' | 'unsafe-file';
196
+ } | {
197
+ state: 'valid';
198
+ value: T;
199
+ };
200
+ export type AgentTurnEnd<T = unknown> = {
201
+ id: string;
202
+ target: AgentRef;
203
+ receipt: PromptReceipt;
204
+ status: 'idle' | 'done';
205
+ changedFiles: {
206
+ path: string;
207
+ change: 'added' | 'modified' | 'deleted';
208
+ }[];
209
+ result: AgentTurnResult<T>;
210
+ };
167
211
  export type BlockedAgent = {
168
212
  paneId: string;
169
213
  workspaceId: string;
package/dist/words.json CHANGED
@@ -23,6 +23,7 @@
23
23
  "move.busy": "Wait for this conversation to finish before moving it.",
24
24
  "move.unsupported": "This conversation cannot move between these accounts.",
25
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."
26
+ "move.close_failed": "The move could not be confirmed. Check the remaining panes before trying again.",
27
+ "move.start_failed": "The new account could not take over. Try again.",
28
+ "turn.failed": "This turn could not be confirmed. Check the helper before trying again."
28
29
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/herdr",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -53,7 +53,7 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "@byokit/link": "0.7.0",
56
- "@byokit/relay": "0.5.0",
56
+ "@byokit/relay": "0.5.1",
57
57
  "@byokit/reach": "0.6.0",
58
58
  "@byokit/seal": "0.2.0",
59
59
  "ws": "8.21.3"