@byokit/herdr 0.7.2 → 0.8.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/dist/device.js CHANGED
@@ -219,6 +219,13 @@ export function herdrDevice(link) {
219
219
  close: async (o) => {
220
220
  await link.request('hd.close', o);
221
221
  },
222
+ rename: async (o) => {
223
+ await link.request('hd.rename', o);
224
+ },
225
+ focus: async (o) => {
226
+ await link.request('hd.focus', o);
227
+ },
228
+ layout: (o) => link.request('hd.layout', o),
222
229
  events: () => streamFrames(link, 'hd.events', {}),
223
230
  subscribe: (subs, on, onError) => subscription(link.stream('hd.subscribe', { subs }), on, onError),
224
231
  registerNotices: async (seed) => {
package/dist/index.d.ts CHANGED
@@ -4,5 +4,5 @@ export { HERDR_VERSION, HERDR_PROTOCOL } from './constants.ts';
4
4
  export { accountKinds, accountKind, type AccountKind } from './kinds.ts';
5
5
  export { HerdrKit } from './kit.ts';
6
6
  export { agentWords, stateWords, words, WORDS, type WordKey } from './words.ts';
7
- export { agentProbePath, extraPathDirs, runStatusCommand, agentInstallState, isAutoInstallShim, resolveAgentBinary, classifyStartFailure } from './agents.ts';
7
+ export { agentProbePath, extraPathDirs, runStatusCommand, agentInstallState, isAutoInstallShim, resolveAgentBinary, classifyStartFailure, isPromptable } from './agents.ts';
8
8
  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, BusyHandoff, Move, MoveToAccount, MoveToAccountResult, MoveResult, OpenSignInTab, PromptReceipt, StartAgent, TerminalSession, AgentTurnEnd, AgentTurnOptions, AgentTurnFiles, AgentTurnResult, AgentTurnResultPolicy, } from './types.ts';
package/dist/index.js CHANGED
@@ -5,4 +5,4 @@ export { HERDR_VERSION, HERDR_PROTOCOL } from "./constants.js";
5
5
  export { accountKinds, accountKind } from "./kinds.js";
6
6
  export { HerdrKit } from "./kit.js";
7
7
  export { agentWords, stateWords, words, WORDS } from "./words.js";
8
- export { agentProbePath, extraPathDirs, runStatusCommand, agentInstallState, isAutoInstallShim, resolveAgentBinary, classifyStartFailure } from "./agents.js";
8
+ export { agentProbePath, extraPathDirs, runStatusCommand, agentInstallState, isAutoInstallShim, resolveAgentBinary, classifyStartFailure, isPromptable } from "./agents.js";
package/dist/kit.d.ts CHANGED
@@ -81,6 +81,9 @@ export declare class HerdrKit {
81
81
  timeoutMs: number;
82
82
  };
83
83
  }): Promise<PromptReceipt>;
84
+ waitPromptable(target: AgentRef, o: {
85
+ timeoutMs: number;
86
+ }): Promise<void>;
84
87
  runTurn<T = unknown>(target: AgentRef, o: AgentTurnOptions<T>): Promise<AgentTurnEnd<T>>;
85
88
  onTurnEnd(fn: (end: AgentTurnEnd) => void): () => void;
86
89
  sendKeys(target: AgentRef, keys: string[]): Promise<void>;
package/dist/kit.js CHANGED
@@ -136,7 +136,8 @@ export class HerdrKit {
136
136
  constructor(o) {
137
137
  this.o = o;
138
138
  this.supervisor = new Supervisor(o, (s) => { this.current = s; o.onState?.(s); });
139
- this.accountPanes = createAccountPanes({ call: this.callAny, startAgent: (o) => this.startAgent(o) });
139
+ this.accountPanes = createAccountPanes({ call: this.callAny, startAgent: (o) => this.startAgent(o),
140
+ agentStatus: (kinds, options) => this.agentStatus(kinds, options) });
140
141
  this.agents = createAgents({ call: this.callAny, snapshot: () => this.snapshot(),
141
142
  reread: (paneId) => this.reread(paneId),
142
143
  launchEnv: () => o.mode === 'own' ? this.supervisor.env() : undefined,
@@ -462,6 +463,7 @@ export class HerdrKit {
462
463
  return () => { this.startListeners.delete(fn); };
463
464
  }
464
465
  prompt(target, text, o) { return this.agents.prompt(target, text, o); }
466
+ waitPromptable(target, o) { return this.agents.waitPromptable(target, o); }
465
467
  runTurn(target, o) { return this.turns.runTurn(target, o); }
466
468
  onTurnEnd(fn) { return this.turns.onTurnEnd(fn); }
467
469
  sendKeys(target, keys) { return this.agents.sendKeys(target, keys); }
package/dist/link.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type IncomingMessage, type ServerResponse } from 'node:http';
2
- import { type Grant, type Host, type HostOptions } from '@byokit/link';
2
+ import { type Grant, type Host, type HostOptions } from '@byokit/pair';
3
3
  import { type ServeIngress, type Via } from '@byokit/discover';
4
4
  import type { RelayClient } from '@byokit/relay';
5
5
  import type { HerdrKit } from './kit.ts';
package/dist/link.js CHANGED
@@ -1,4 +1,4 @@
1
- // Host-side link adapter: the hd.* ops over @byokit/link, and the serve() wiring (duplicated per kit by design,
1
+ // Host-side link adapter: the hd.* ops over @byokit/pair, and the serve() wiring (duplicated per kit by design,
2
2
  // D3) — built in H7 (docs/runtime-kits.md 7.1-7.3).
3
3
  //
4
4
  // Permission shape: `allow` lets every request through and `handle`/`stream` refuse with the `link.notAllowed`
@@ -6,7 +6,7 @@
6
6
  // generic `view-only`/`not-allowed` words instead).
7
7
  import { createServer } from 'node:http';
8
8
  import { WebSocketServer } from 'ws';
9
- import { PublicLinkError, } from '@byokit/link';
9
+ import { PublicLinkError, } from '@byokit/pair';
10
10
  import { reach } from '@byokit/discover';
11
11
  import { stateWords, words } from "./words.js";
12
12
  import { decodeB64Url, sealNotice } from "./notices.js";
@@ -15,6 +15,9 @@ const VIEW_OPS = new Set(['hd.state', 'hd.tree', 'hd.kinds', 'hd.read', 'hd.bloc
15
15
  const deny = () => {
16
16
  throw new PublicLinkError(words('link.notAllowed'));
17
17
  };
18
+ // A refused widening close is the kit's plain sentence for the device, not a bare failure (6.4, 6.9).
19
+ const WIDEN_CODES = new Set(['pane-close-would-widen', 'tab-close-would-widen', 'workspace-close-would-widen']);
20
+ const widenError = (e) => WIDEN_CODES.has(e?.code ?? '') ? new PublicLinkError(words('close.wouldWiden')) : undefined;
18
21
  const findPane = (snap, paneId) => {
19
22
  for (const w of snap.workspaces) {
20
23
  for (const t of w.tabs) {
@@ -132,6 +135,14 @@ export function herdrLink(kit, o) {
132
135
  if (!wid || !kit.snapshot().workspaces.some((w) => w.id === wid && inScope(scope, w.id)))
133
136
  deny();
134
137
  }
138
+ if (typeof place.workspace === 'string' && place.workspace !== 'new') {
139
+ // A label placement lands its tab in the matching workspace: every match must already
140
+ // be in scope, or the device would reach a workspace its grant never named. No match
141
+ // creates, which stays creatable like a 'new' workspace.
142
+ const label = place.workspace;
143
+ if (kit.snapshot().workspaces.some((w) => w.label === label && !inScope(scope, w.id)))
144
+ deny();
145
+ }
135
146
  return kit.startAgent(args);
136
147
  }
137
148
  case 'hd.prompt': {
@@ -237,21 +248,144 @@ export function herdrLink(kit, o) {
237
248
  const where = findPane(snap, paneId);
238
249
  if (!where || !inScope(scope, where.workspaceId))
239
250
  deny();
240
- await kit.closePane(paneId);
251
+ try {
252
+ await kit.closePane(paneId);
253
+ }
254
+ catch (e) {
255
+ throw widenError(e) ?? e;
256
+ }
241
257
  return null;
242
258
  }
243
259
  if (tabId !== undefined) {
244
260
  const owner = snap.workspaces.find((w) => w.tabs.some((t) => t.id === tabId));
245
261
  if (!owner || !inScope(scope, owner.id))
246
262
  deny();
247
- await kit.closeTab(tabId);
263
+ try {
264
+ await kit.closeTab(tabId);
265
+ }
266
+ catch (e) {
267
+ throw widenError(e) ?? e;
268
+ }
248
269
  return null;
249
270
  }
250
271
  if (!snap.workspaces.some((w) => w.id === workspaceId && inScope(scope, w.id)))
251
272
  deny();
252
- await kit.closeWorkspace(workspaceId);
273
+ try {
274
+ await kit.closeWorkspace(workspaceId);
275
+ }
276
+ catch (e) {
277
+ throw widenError(e) ?? e;
278
+ }
253
279
  return null;
254
280
  }
281
+ // The typed edit ops copy hd.close's shape (7.1): exactly one target, control only, and the
282
+ // target's owning workspace checked against scope before any Herdr call is made (a tab through
283
+ // its workspace, a workspace through itself). Out of scope — or unknown — is link.notAllowed.
284
+ case 'hd.rename': {
285
+ if (grant.role !== 'control')
286
+ deny();
287
+ const snap = kit.snapshot();
288
+ const paneId = asString(args.pane);
289
+ const tabId = asString(args.tab);
290
+ const workspaceId = asString(args.workspace);
291
+ const label = asString(args.label);
292
+ const picked = [paneId, tabId, workspaceId].filter((v) => v !== undefined);
293
+ if (picked.length !== 1)
294
+ throw new Error('herdr: hd.rename picks exactly one of pane, tab or workspace');
295
+ if (label === undefined || label.length === 0)
296
+ throw new Error('herdr: hd.rename needs a label');
297
+ if (paneId !== undefined) {
298
+ const where = findPane(snap, paneId);
299
+ if (!where || !inScope(scope, where.workspaceId))
300
+ deny();
301
+ await kit.call('pane.rename', { pane_id: paneId, label });
302
+ return null;
303
+ }
304
+ if (tabId !== undefined) {
305
+ const owner = snap.workspaces.find((w) => w.tabs.some((t) => t.id === tabId));
306
+ if (!owner || !inScope(scope, owner.id))
307
+ deny();
308
+ await kit.call('tab.rename', { tab_id: tabId, label });
309
+ return null;
310
+ }
311
+ if (!snap.workspaces.some((w) => w.id === workspaceId && inScope(scope, w.id)))
312
+ deny();
313
+ await kit.call('workspace.rename', { workspace_id: workspaceId, label });
314
+ return null;
315
+ }
316
+ case 'hd.focus': {
317
+ if (grant.role !== 'control')
318
+ deny();
319
+ const snap = kit.snapshot();
320
+ const paneId = asString(args.pane);
321
+ const tabId = asString(args.tab);
322
+ const workspaceId = asString(args.workspace);
323
+ const picked = [paneId, tabId, workspaceId].filter((v) => v !== undefined);
324
+ if (picked.length !== 1)
325
+ throw new Error('herdr: hd.focus picks exactly one of pane, tab or workspace');
326
+ if (paneId !== undefined) {
327
+ const where = findPane(snap, paneId);
328
+ if (!where || !inScope(scope, where.workspaceId))
329
+ deny();
330
+ await kit.call('pane.focus', { pane_id: paneId });
331
+ return null;
332
+ }
333
+ if (tabId !== undefined) {
334
+ const owner = snap.workspaces.find((w) => w.tabs.some((t) => t.id === tabId));
335
+ if (!owner || !inScope(scope, owner.id))
336
+ deny();
337
+ await kit.call('tab.focus', { tab_id: tabId });
338
+ return null;
339
+ }
340
+ if (!snap.workspaces.some((w) => w.id === workspaceId && inScope(scope, w.id)))
341
+ deny();
342
+ await kit.call('workspace.focus', { workspace_id: workspaceId });
343
+ return null;
344
+ }
345
+ case 'hd.layout': {
346
+ if (grant.role !== 'control')
347
+ deny();
348
+ const root = args.root;
349
+ if (typeof root !== 'object' || root === null || Array.isArray(root)) {
350
+ throw new Error('herdr: hd.layout needs a root');
351
+ }
352
+ const snap = kit.snapshot();
353
+ const tabId = asString(args.tabId);
354
+ const workspaceId = asString(args.workspaceId);
355
+ // The layout picks exactly one tab or workspace, in scope, before any call.
356
+ if ([tabId, workspaceId].filter((v) => v !== undefined).length !== 1) {
357
+ throw new Error('herdr: hd.layout picks exactly one of tabId or workspaceId');
358
+ }
359
+ if (tabId !== undefined) {
360
+ const owner = snap.workspaces.find((w) => w.tabs.some((t) => t.id === tabId));
361
+ if (!owner || !inScope(scope, owner.id))
362
+ deny();
363
+ }
364
+ else if (!snap.workspaces.some((w) => w.id === workspaceId && inScope(scope, w.id)))
365
+ deny();
366
+ // Every named pane leaf must be known and in scope before any call; an unnamed leaf is a new pane.
367
+ const pending = [root];
368
+ while (pending.length > 0) {
369
+ const node = asRecord(pending.pop());
370
+ if (typeof node.pane_id === 'string') {
371
+ const where = findPane(snap, node.pane_id);
372
+ if (!where || !inScope(scope, where.workspaceId))
373
+ deny();
374
+ }
375
+ if (node.first !== undefined)
376
+ pending.push(node.first);
377
+ if (node.second !== undefined)
378
+ pending.push(node.second);
379
+ }
380
+ const tabLabel = asString(args.tabLabel);
381
+ return kit.call('layout.apply', {
382
+ root: root,
383
+ ...(args.focus === true ? { focus: true } : {}),
384
+ ...(tabId !== undefined ? { tab_id: tabId } : {}),
385
+ ...(workspaceId !== undefined ? { workspace_id: workspaceId } : {}),
386
+ ...(tabLabel !== undefined ? { tab_label: tabLabel } : {}),
387
+ });
388
+ }
255
389
  case 'hd.notices.register': {
256
390
  if (grant.role !== 'control')
257
391
  deny();
package/dist/muse.d.ts CHANGED
@@ -4,6 +4,8 @@ export declare function museNative(launcher: string): {
4
4
  native: string;
5
5
  version: string;
6
6
  } | undefined;
7
+ /** A mise-style auto-install launcher is a shell script that goes through mise. Shared with agents.ts. */
8
+ export declare function isMiseLauncher(head: string): boolean;
7
9
  export type MuseReadiness = {
8
10
  state: 'unknown' | 'missing' | 'launcher-only' | 'installed';
9
11
  signedIn: 'unknown';
package/dist/muse.js CHANGED
@@ -56,6 +56,12 @@ export function museNative(launcher) {
56
56
  return;
57
57
  }
58
58
  }
59
+ /** A mise-style auto-install launcher is a shell script that goes through mise. Shared with agents.ts. */
60
+ export function isMiseLauncher(head) {
61
+ const shebang = head.split('\n', 1)[0] ?? '';
62
+ return /^#!\s*(?:\S*\/)?(?:env\s+)?(?:sh|bash|zsh|dash|ksh|ash)(?:\s|$)/.test(shebang)
63
+ && head.includes('mise');
64
+ }
59
65
  /** Only the supplied effective pane PATH is probed, never the controller's personal directories. */
60
66
  export function museReadiness(env) {
61
67
  if (env?.PATH === undefined)
@@ -70,7 +76,7 @@ export function museReadiness(env) {
70
76
  accessSync(bin, constants.X_OK);
71
77
  const selected = museNative(bin);
72
78
  const head = launcherHead(bin);
73
- const launcherOnly = head.startsWith('#!') && (head.includes('mise') || (!selected && head.includes('MUSE_CHANNEL')));
79
+ const launcherOnly = isMiseLauncher(head) || (!selected && head.includes('MUSE_CHANNEL'));
74
80
  return { state: launcherOnly ? 'launcher-only' : 'installed', signedIn: 'unknown', bin,
75
81
  ...(selected ? { version: selected.version } : {}) };
76
82
  }
package/dist/notices.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // Sealed blocked-approval notices: the relay reads only a generic title, the phone opens the sealed body with its
2
- // own key (docs/runtime-kits.md 7.3). Portable: no Node import anywhere in it — `./device` re-exports `openNotice`.
3
- import { boxKeyPairFromSeed, openBoxFromSeed, sealBox } from '@byokit/seal';
2
+ // own key (docs/runtime-kits.md 7.3). The envelope comes from `@byokit/seal`'s generic notice API, so one opener
3
+ // serves every kit; this file keeps the blocked-agent shape check and the box-key text. Portable: no Node import
4
+ // anywhere in it — `./device` re-exports `openNotice`.
5
+ import { boxKeyPairFromSeed, openNotice as sealOpenNotice, sealNotice as sealSealNotice } from '@byokit/seal';
4
6
  const ABC = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
5
7
  /** Base64url without padding, hand-rolled so this file stays portable (no Buffer, no atob). */
6
8
  export function encodeB64Url(bytes) {
@@ -57,28 +59,20 @@ export function sealNotice(b, boxPublicKey) {
57
59
  }
58
60
  if (!isBlockedAgent(b))
59
61
  throw new Error('herdr: cannot seal that notice');
60
- return { v: 1, sealed: encodeB64Url(sealBox(new TextEncoder().encode(JSON.stringify(b)), boxPublicKey)) };
62
+ return sealSealNotice(b, boxPublicKey);
61
63
  }
62
64
  /** Open a notice envelope with the device's 32-byte seed; null when it is not ours or not a notice. */
63
65
  export function openNotice(data, seed) {
64
- if (typeof data !== 'object' || data === null || data.v !== 1 || typeof data.sealed !== 'string')
65
- return null;
66
66
  if (!(seed instanceof Uint8Array) || seed.length !== 32)
67
67
  return null;
68
- const bundle = decodeB64Url(data.sealed);
69
- if (bundle === null)
70
- return null;
71
- const bytes = openBoxFromSeed(bundle, seed);
72
- if (bytes === null)
73
- return null;
74
- let parsed;
68
+ const { secretKey } = boxKeyPairFromSeed(seed);
75
69
  try {
76
- parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
70
+ const parsed = sealOpenNotice(data, secretKey);
71
+ return isBlockedAgent(parsed) ? parsed : null;
77
72
  }
78
- catch {
79
- return null;
73
+ finally {
74
+ secretKey.fill(0);
80
75
  }
81
- return isBlockedAgent(parsed) ? parsed : null;
82
76
  }
83
77
  /** The box public key a device registers for a seed (the host seals to this). */
84
78
  export function boxPublicKeyB64(seed) {
@@ -53,6 +53,15 @@ export function herdrContract(make, options) {
53
53
  }
54
54
  return { status, panes };
55
55
  }
56
+ function ownerOf(snapshot, paneId) {
57
+ for (const workspace of snapshot.workspaces) {
58
+ for (const tab of workspace.tabs) {
59
+ if (tab.panes.some((pane) => pane.id === paneId))
60
+ return { workspace, tab };
61
+ }
62
+ }
63
+ return undefined;
64
+ }
56
65
  test('contract: start reaches ready and ping speaks the pinned protocol', async () => {
57
66
  await bench(async ({ kit }) => {
58
67
  await kit.start();
@@ -279,10 +288,93 @@ export function herdrContract(make, options) {
279
288
  }
280
289
  assert.equal(pane.paneId, 'w1:p1', 'the pane placement uses the pane as given');
281
290
  assert.equal(typeof ws.name, 'string');
291
+ // `created` tells a workspace-making placement from one landing in an existing workspace.
292
+ assert.equal(ws.created, true, 'a new workspace reports created');
293
+ assert.equal(tree.created, true, 'a worktree checkout reports created');
294
+ assert.equal(tab.created, false, 'a new tab reports reuse');
295
+ assert.equal(split.created, false, 'a split reports reuse');
296
+ assert.equal(pane.created, false, 'a caller-owned pane reports reuse');
282
297
  const workspaces = (await kit.call('workspace.list', {}));
283
298
  assert.ok(workspaces.workspaces?.some((workspace) => workspace.worktree?.is_linked_worktree === true), 'the worktree placement registers a linked checkout');
284
299
  });
285
300
  });
301
+ test('contract: startAgent with a workspace label reuses the matching workspace', async () => {
302
+ await bench(async ({ kit }) => {
303
+ await kit.start();
304
+ const before = kit.snapshot().workspaces.length;
305
+ const made = (await kit.call('workspace.create', { cwd: '/tmp/h6-label', label: 'byokit-reuse' }));
306
+ const wid = made.workspace?.workspace_id;
307
+ assert.equal(typeof wid, 'string');
308
+ await until(() => kit.snapshot().workspaces.find((w) => w.id === wid), (w) => w !== undefined);
309
+ const first = await kit.startAgent({ kind: 'pi', cwd: '/tmp/h6', place: { workspace: 'byokit-reuse', label: 'agent-tab' } });
310
+ assert.equal(first.created, false, 'landing in an existing workspace reports reuse');
311
+ assert.match(first.paneId, /^w\d+:p\d+$/);
312
+ const tree = await until(() => {
313
+ const snap = kit.snapshot();
314
+ const owner = ownerOf(snap, first.paneId);
315
+ return owner !== undefined && snap.workspaces.length === before + 1 ? { snap, owner } : undefined;
316
+ }, () => true);
317
+ assert.equal(tree.owner.workspace.id, wid, 'the agent lands in the workspace with that label');
318
+ assert.equal(tree.owner.tab.label, 'agent-tab', 'the tab label rides the reuse');
319
+ const second = await kit.startAgent({ kind: 'pi', cwd: '/tmp/h6', place: { workspace: 'byokit-reuse' } });
320
+ assert.equal(second.created, false);
321
+ assert.notEqual(second.paneId, first.paneId, 'the second start gets its own tab pane');
322
+ const again = await until(() => {
323
+ const snap = kit.snapshot();
324
+ return ownerOf(snap, second.paneId) !== undefined && snap.workspaces.length === before + 1 ? snap : undefined;
325
+ }, () => true);
326
+ assert.equal(again.workspaces.length, before + 1, 'reuse adds a tab, never a workspace');
327
+ });
328
+ });
329
+ test('contract: startAgent with an unknown workspace label creates it', async () => {
330
+ await bench(async ({ kit }) => {
331
+ await kit.start();
332
+ const before = kit.snapshot().workspaces.length;
333
+ const ref = await kit.startAgent({ kind: 'pi', cwd: '/tmp/h6', place: { workspace: 'byokit-fresh' } });
334
+ assert.equal(ref.created, true, 'no match creates the workspace');
335
+ assert.match(ref.paneId, /^w\d+:p\d+$/);
336
+ const snap = await until(() => {
337
+ const s = kit.snapshot();
338
+ const owner = ownerOf(s, ref.paneId);
339
+ return owner !== undefined && owner.workspace.label === 'byokit-fresh' && s.workspaces.length === before + 1
340
+ ? s : undefined;
341
+ }, () => true);
342
+ assert.equal(snap.workspaces.length, before + 1);
343
+ });
344
+ });
345
+ test('contract: a failed start into a reused workspace keeps the workspace', async () => {
346
+ await bench(async ({ kit, fake }) => {
347
+ if (!fake)
348
+ return;
349
+ await kit.start();
350
+ const made = (await kit.call('workspace.create', { cwd: '/tmp/h6-label', label: 'byokit-keep' }));
351
+ const wid = made.workspace?.workspace_id;
352
+ assert.equal(typeof wid, 'string');
353
+ await until(() => kit.snapshot().workspaces.find((w) => w.id === wid), (w) => w !== undefined);
354
+ const panesBefore = agentStatusOf(kit.snapshot(), 'none').panes;
355
+ fake.agentStartFaults.push({ code: 'agent_failed', message: 'boom' });
356
+ await assert.rejects(kit.startAgent({ kind: 'pi', cwd: '/tmp/h6', place: { workspace: 'byokit-keep' } }), /boom/);
357
+ await until(() => agentStatusOf(kit.snapshot(), 'none').panes, (n) => n === panesBefore);
358
+ assert.ok(kit.snapshot().workspaces.some((w) => w.id === wid && w.label === 'byokit-keep'), 'the reused workspace is never closed by a failed start');
359
+ });
360
+ });
361
+ test('contract: two workspaces with one label refuse as ambiguous and pick nothing', async () => {
362
+ await bench(async ({ kit }) => {
363
+ await kit.start();
364
+ await kit.call('workspace.create', { cwd: '/tmp/h6-label', label: 'byokit-dup' });
365
+ await kit.call('workspace.create', { cwd: '/tmp/h6-label', label: 'byokit-dup' });
366
+ const settled = await until(() => {
367
+ const snap = kit.snapshot();
368
+ return snap.workspaces.filter((w) => w.label === 'byokit-dup').length === 2 ? snap : undefined;
369
+ }, () => true);
370
+ const workspacesBefore = settled.workspaces.length;
371
+ const panesBefore = agentStatusOf(settled, 'none').panes;
372
+ await assert.rejects(kit.startAgent({ kind: 'pi', cwd: '/tmp/h6', place: { workspace: 'byokit-dup' } }), (e) => e.code === 'workspace-ambiguous', 'two matching labels refuse with the named ambiguous error');
373
+ assert.equal(kit.snapshot().workspaces.filter((w) => w.label === 'byokit-dup').length, 2);
374
+ assert.equal(kit.snapshot().workspaces.length, workspacesBefore, 'the refusal creates no workspace');
375
+ assert.equal(agentStatusOf(kit.snapshot(), 'none').panes, panesBefore, 'the refusal creates no pane');
376
+ });
377
+ });
286
378
  test('contract: a started agent joins snapshot() and its status stays current there', async () => {
287
379
  await bench(async ({ kit }) => {
288
380
  await kit.start();
@@ -374,14 +466,26 @@ export function herdrContract(make, options) {
374
466
  await bench(async ({ kit }) => {
375
467
  await kit.start();
376
468
  // A fresh workspace holds exactly one tab with exactly one pane: the widening cases.
377
- const made = (await kit.call('workspace.create', { cwd: '/tmp/h6-other' }));
469
+ const made = (await kit.call('workspace.create', { cwd: '/tmp/h6-other', label: 'other' }));
378
470
  const w2 = made.workspace?.workspace_id;
379
471
  assert.equal(typeof w2, 'string');
380
- await assert.rejects(kit.closePane(`${w2}:p1`), (e) => e.code === 'pane-close-would-widen', 'closing the last pane would close the tab');
381
- await assert.rejects(kit.closeTab(`${w2}:t1`), (e) => e.code === 'tab-close-would-widen', 'closing the only tab would close the workspace');
472
+ // A refusal carries the wider target (scope, id, label) the app would close instead (6.4).
473
+ const widen = async (work) => {
474
+ try {
475
+ await work;
476
+ }
477
+ catch (e) {
478
+ const { scope, id, label, code } = e;
479
+ return { scope, id, label, code };
480
+ }
481
+ assert.fail('expected a would-widen refusal');
482
+ };
483
+ assert.deepEqual(await widen(kit.closePane(`${w2}:p1`)), { scope: 'tab', id: `${w2}:t1`, label: 'main', code: 'pane-close-would-widen' }, 'closing the last pane widens to its tab');
484
+ assert.deepEqual(await widen(kit.closeTab(`${w2}:t1`)), { scope: 'workspace', id: w2, label: 'other', code: 'tab-close-would-widen' }, 'closing the only tab widens to its workspace');
382
485
  // worktree.create links a checkout into w1's group: the root now widens on close.
486
+ const w1Label = kit.snapshot().workspaces.find((w) => w.id === 'w1')?.label;
383
487
  await kit.startAgent({ kind: 'pi', cwd: '/tmp/h6', place: { workspace: 'new' }, worktree: { branch: 'h6' } });
384
- await assert.rejects(kit.closeWorkspace('w1'), (e) => e.code === 'workspace-close-would-widen', 'closing a worktree group parent would close the group');
488
+ assert.deepEqual(await widen(kit.closeWorkspace('w1')), { scope: 'workspace', id: 'w1', label: w1Label, code: 'workspace-close-would-widen' }, 'closing a worktree group parent widens to the group, named by its root');
385
489
  // The exact closes still go through.
386
490
  await kit.call('pane.split', { target_pane_id: `${w2}:p1`, direction: 'right' });
387
491
  await kit.closePane(`${w2}:p1`);
@@ -390,6 +494,36 @@ export function herdrContract(make, options) {
390
494
  await kit.closeWorkspace(w2);
391
495
  });
392
496
  });
497
+ test('contract: waitPromptable waits through a launch-pending agent and resolves', async () => {
498
+ await bench(async ({ kit, fake }) => {
499
+ if (!fake)
500
+ return;
501
+ const paneId = 'w1:p2';
502
+ const agent = fake.world.agents.find((row) => row.pane_id === paneId);
503
+ assert.ok(agent, 'the fake seeds w1:p2');
504
+ agent.launch_pending = true;
505
+ await kit.start();
506
+ // A still-pending launch is not promptable: the deadline rejects agent-not-ready.
507
+ await assert.rejects(kit.waitPromptable({ paneId }, { timeoutMs: 120 }), (e) => e.code === 'agent-not-ready', 'a launch that stays pending rejects with agent-not-ready');
508
+ // Flip the fake ready: the next read clears launch_pending and the wait resolves.
509
+ const waiting = kit.waitPromptable({ paneId }, { timeoutMs: 2000 });
510
+ agent.launch_pending = false;
511
+ await waiting;
512
+ assert.equal(agentStatusOf(kit.snapshot(), paneId).status, 'idle');
513
+ });
514
+ });
515
+ test('contract: waitPromptable rejects pane-unavailable when the pane closes mid-wait', async () => {
516
+ await bench(async ({ kit, fake }) => {
517
+ if (!fake)
518
+ return;
519
+ const paneId = 'w1:p2';
520
+ fake.world.agents.find((row) => row.pane_id === paneId).launch_pending = true;
521
+ await kit.start();
522
+ const waiting = kit.waitPromptable({ paneId }, { timeoutMs: 2000 });
523
+ await kit.closePane(paneId);
524
+ await assert.rejects(waiting, (e) => e.code === 'pane-unavailable', 'a pane closed while waiting rejects pane-unavailable instead of hanging');
525
+ });
526
+ });
393
527
  test('contract: the cli answers --version and the terminal echoes', async () => {
394
528
  await bench(async ({ kit }) => {
395
529
  await kit.start();
@@ -8,7 +8,7 @@ import { createServer } from 'node:net';
8
8
  import { join } from 'node:path';
9
9
  import { HERDR_PROTOCOL, HERDR_VERSION } from "../../constants.js";
10
10
  import { writeBinShim } from "./bin.js";
11
- import { agentRecord, createWorld, paneScroll, relayoutTab } from "./world.js";
11
+ import { COLS, ROWS, agentRecord, createWorld, paneScroll, relayoutTab } from "./world.js";
12
12
  const PROMPT_SETTLE_MS = 50; // 6.8: the agent settles 50 ms after a prompt
13
13
  const WAIT_POLL_MS = 5;
14
14
  // The subscription kinds that need a `pane_id` filter and ride their own socket each
@@ -18,6 +18,27 @@ const WAIT_POLL_MS = 5;
18
18
  // batch with another kind.
19
19
  const FILTERED = new Set(['pane_agent_status_changed', 'pane_output_matched', 'pane_scroll_changed']);
20
20
  const AGENT_NAME = /^[a-z][a-z0-9_-]{0,31}$/;
21
+ // Position every pane leaf of a layout tree inside the tab's area: a split divides its rect at its
22
+ // ratio, a pane leaf lands where the recursion left it. Leaves in tree order, so `focus` can pick
23
+ // the first one.
24
+ function layoutLeaves(node, rect, out) {
25
+ if (node.type === 'split' && node.first !== undefined && node.second !== undefined) {
26
+ const ratio = typeof node.ratio === 'number' ? Math.min(1, Math.max(0, node.ratio)) : 0.5;
27
+ if (node.direction === 'down') {
28
+ const top = Math.max(1, Math.round(rect.height * ratio));
29
+ layoutLeaves(node.first, { x: rect.x, y: rect.y, width: rect.width, height: top }, out);
30
+ layoutLeaves(node.second, { x: rect.x, y: rect.y + top, width: rect.width, height: Math.max(1, rect.height - top) }, out);
31
+ }
32
+ else {
33
+ const left = Math.max(1, Math.round(rect.width * ratio));
34
+ layoutLeaves(node.first, { x: rect.x, y: rect.y, width: left, height: rect.height }, out);
35
+ layoutLeaves(node.second, { x: rect.x + left, y: rect.y, width: Math.max(1, rect.width - left), height: rect.height }, out);
36
+ }
37
+ return;
38
+ }
39
+ if (typeof node.pane_id === 'string')
40
+ out.push({ paneId: node.pane_id, rect });
41
+ }
21
42
  export async function startFakeHerdr(options) {
22
43
  const dir = options.dir;
23
44
  if (typeof dir !== 'string' || dir.length === 0)
@@ -335,6 +356,45 @@ export async function startFakeHerdr(options) {
335
356
  return { zoom: { changed, zoomed: live.zoomed } };
336
357
  },
337
358
  'pane.layout': (p) => ({ layout: tabLayout(paneOf(p.pane_id).tab_id) }),
359
+ 'layout.apply': (p) => {
360
+ const root = p.root;
361
+ if (typeof root !== 'object' || root === null || Array.isArray(root)) {
362
+ throw fail('invalid_layout', 'a root layout is required');
363
+ }
364
+ // The target tab: an explicit tab_id, else the workspace's first tab (the pinned server needs one).
365
+ let tab;
366
+ if (typeof p.tab_id === 'string') {
367
+ tab = live.tabs.find((row) => row.tab_id === p.tab_id);
368
+ if (tab === undefined)
369
+ throw fail('tab_not_found', 'tab not found');
370
+ }
371
+ else if (typeof p.workspace_id === 'string') {
372
+ if (!live.workspaces.some((row) => row.workspace_id === p.workspace_id)) {
373
+ throw fail('workspace_not_found', 'workspace not found');
374
+ }
375
+ tab = live.tabs.find((row) => row.workspace_id === p.workspace_id);
376
+ if (tab === undefined)
377
+ throw fail('tab_not_found', 'tab not found');
378
+ }
379
+ else {
380
+ throw fail('invalid_target', 'layout.apply needs a tab_id or workspace_id');
381
+ }
382
+ // Every leaf must be a pane of the target tab; the layout repositions exactly those panes.
383
+ const leaves = [];
384
+ layoutLeaves(root, { x: 0, y: 0, width: COLS, height: ROWS }, leaves);
385
+ for (const leaf of leaves) {
386
+ const pane = live.panes.find((row) => row.pane_id === leaf.paneId);
387
+ if (pane === undefined || pane.tab_id !== tab.tab_id) {
388
+ throw fail('pane_not_found', `pane not found in tab: ${leaf.paneId}`);
389
+ }
390
+ pane.rect = leaf.rect;
391
+ }
392
+ if (p.focus === true && leaves[0] !== undefined)
393
+ focusPane(leaves[0].paneId);
394
+ const layout = tabLayout(tab.tab_id);
395
+ emitEvent('layout.updated', { layout });
396
+ return { type: 'layout_apply', layout: { ...layout, root } };
397
+ },
338
398
  'agent.start': (p) => {
339
399
  const fault = agentStartFaults.shift();
340
400
  if (fault !== undefined)
@@ -1,4 +1,4 @@
1
1
  import type { AgentTurnEnd, AgentTurnFiles } from './types.ts';
2
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>>;
3
+ export declare function scanFiles(root: string, policy: AgentTurnFiles | false | undefined, resultName: string, active: () => void): Promise<Map<string, string>>;
4
4
  export declare function changedFiles(before: Map<string, string>, after: Map<string, string>): AgentTurnEnd['changedFiles'];
@@ -11,8 +11,13 @@ export function positiveLimit(value, fallback) {
11
11
  // A baseline of the actual tree, independent of Git status and mtime granularity. Never traverse
12
12
  // symlinks or read special files; a changing/unreadable tree fails rather than claiming a full diff.
13
13
  export async function scanFiles(root, policy, resultName, active) {
14
+ // files:false skips the scan entirely: no walk, so a large tree (e.g. node_modules) cannot fail the turn.
15
+ if (policy === false)
16
+ return new Map();
14
17
  const maxFiles = positiveLimit(policy?.maxFiles, 10000);
15
18
  const maxBytes = positiveLimit(policy?.maxBytes, 128 * 1024 * 1024);
19
+ // Read once: narrowing on the parameter does not persist into the walk closure below.
20
+ const exclude = policy?.exclude;
16
21
  let entries = 0;
17
22
  let bytes = 0;
18
23
  const found = new Map();
@@ -27,7 +32,7 @@ export async function scanFiles(root, policy, resultName, active) {
27
32
  active();
28
33
  const path = join(dir, name);
29
34
  const rel = relative(root, path).split(sep).join('/');
30
- if (name === '.git' || rel === resultName || policy?.exclude?.(rel))
35
+ if (name === '.git' || rel === resultName || exclude?.(rel))
31
36
  continue;
32
37
  if (++entries > maxFiles)
33
38
  throw new Error('turn: file limit');