@byokit/herdr 0.7.3 → 0.8.1

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,38 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.1 (2026-10-12)
6
+
7
+ - Dependency update: pins @byokit/discover 0.8.1.
8
+ - FIX: (from @byokit/discover 0.8.1) `browse()` and `scan()` accept resolved services whose `fullName` carries the service type without a trailing dot (`host._type._tcp`, as Android's react-native-zeroconf delivers) or with a trailing `.local` domain suffix, case-insensitive, instead of only the `host.local._type._tcp.` shape. Android phones now discover computers over mDNS instead of always scanning empty.
9
+
10
+ ## 0.8.0 (2026-10-12)
11
+
12
+ - Dependency update: pins @byokit/discover 0.8.0.
13
+ - Dependency update: pins @byokit/relay 0.6.1.
14
+ - Dependency update: pins @byokit/pair 0.10.0.
15
+
16
+ - FIX: A Node script (or any non-shell interpreter) whose path contains `mise` is now a real installed binary, so `agentStatus` runs its sign-in check instead of skipping it as an auto-install shim; only a shell launcher that mentions mise still reads `installs-on-first-start`.
17
+ - `startAgent` accepts a workspace label as `place.workspace`: the agent lands in the one workspace with that label (new tab, `created: false`) or in a workspace created with that label (`created: true`). Two workspaces with the label refuse with `workspace-ambiguous` and create nothing, and a failed start rolls back only what the kit made (the fresh pane, or the fresh workspace), never a reused workspace. `AgentRef` carries the new `created` flag.
18
+ - New control link ops `hd.rename { pane | tab | workspace, label }`, `hd.focus { pane | tab | workspace }` and
19
+ `hd.layout { root, focus?, tabId?, tabLabel?, workspaceId? }`, with device `rename()`, `focus()` and
20
+ `layout()`. Each copies `hd.close`'s scope rule: the one pane, tab (through its owning workspace) or
21
+ workspace it names must be in `scopeOf(grant)`, or the device hears the `link.notAllowed` sentence and no
22
+ Herdr call is made; a `view` grant is refused. They map only to the typed `pane.rename`/`tab.rename`/
23
+ `workspace.rename`, `pane.focus`/`tab.focus`/`workspace.focus` and `layout.apply` calls, never through
24
+ `hd.call`. The fake Herdr now answers `layout.apply` and emits `layout.updated`.
25
+ - `sealNotice`/`openNotice` (and the portable `./device` re-export) now delegate to `@byokit/seal`'s generic
26
+ `sealNotice`/`openNotice` instead of sealing and opening the box here: the device seed becomes the box secret
27
+ through `boxKeyPairFromSeed`, and the opened value is still shape-checked as a `BlockedAgent`. The wire format is
28
+ unchanged, so a notice sealed by an earlier kit still opens, and `seal.openNotice` opens a herdr notice. The
29
+ hand-rolled base64url helpers stay only for the box-key registration text (`boxPublicKeyB64`, link.ts's
30
+ `decodeB64Url`), because seal exports no base64url.
31
+ - FIX: A refused widening close now tells the app which scope it would widen to: `closePane`/`closeTab`/`closeWorkspace` still throw `pane-close-would-widen`/`tab-close-would-widen`/`workspace-close-would-widen`, but each also carries the wider target as `{ scope: 'tab' | 'workspace', id, label }` (a pane's tab, a tab's workspace, a worktree group named by its root), so the UI can offer the bigger close instead. `hd.close` over a link maps all three codes to a `PublicLinkError` with the `close.wouldWiden` words, so a device hears plain words instead of a bare failure.
32
+ - FIX: `moveToAccount` refuses signed-out managed targets before waiting or creating a pane, preserving the source; generic `move` remains caller-gated and unknown readiness still proceeds.
33
+ - FIX: Muse readiness routes its mise check through the shared shell-launcher predicate, so a non-shell binary whose interpreter path contains `mise` reads installed instead of launcher-only; genuine mise shell shims still read launcher-only.
34
+ - FIX: `runTurn` accepts an agent that is already `done` when the turn starts, treating it like `idle` at turn start; previously the call was refused with `turn-not-idle`/`turn-busy`.
35
+ - FIX: `runTurn` accepts `files: false` to skip the file scan entirely (`changedFiles` is then always empty), so a turn rooted in a folder with more than 10000 entries no longer fails with `turn: file limit`.
36
+
5
37
  ## 0.7.3 (2026-10-11)
6
38
 
7
39
  - Dependency update: pins @byokit/discover 0.7.0.
package/README.md CHANGED
@@ -205,6 +205,7 @@ if (moved.ok) console.log(moved.session); // new pane to follow
205
205
 
206
206
  A move accepts mapped managed kinds, including Claude (`CLAUDE_CONFIG_DIR`), Codex (`CODEX_HOME`) and Pi
207
207
  (`PI_CODING_AGENT_DIR`, `--session <id|absolute path>`). Hosts own cross-account history sharing.
208
+ `moveToAccount` checks the target managed folder once with `agentStatus`, after immediate refusals and before waiting or splitting. A signed-out target returns `signed_out` and “Sign in to {name} first.” (`name` defaults to “this account”), with `live` naming the unchanged source and no new pane. Unknown readiness proceeds (including Pi); generic `move` has no check, so Accounts hosts call `cliAccounts.launch` and pass its `set`/`unset` only on success. The check is not a lock: a later sign-out still starts only in the selected folder, with existing `start_failed` rollback and no default-login retry.
208
209
  It verifies the new shell's effective
209
210
  account folder, resumes the conversation there, waits for a ready session, then closes the old pane. A failed
210
211
  start preserves the original. A failed source close rolls back the new pane only after fresh reads verify
@@ -286,12 +287,17 @@ the extra install dirs a service PATH omits (`extraPathDirs`: `~/.local/bin`, th
286
287
  Sign-in comes only from each CLI's own documented non-secret status command, with a 10 s timeout;
287
288
  a kind whose CLI has none, or whose command gives no answer, reads `unknown`. Credential files
288
289
  are never opened, read or statted — only the signed-in boolean is kept. Install detection tells a
289
- real runnable binary apart from an auto-install launcher: a mise-style shim on PATH, or nothing
290
- on PATH at all, retains the legacy `installState: 'installs-on-first-start'` with `installed: false`.
290
+ real runnable binary apart from an auto-install launcher: a shell script (a `sh`, `bash`, `zsh`,
291
+ `dash`, `ksh` or `ash` shebang) whose text mentions mise, or nothing on PATH at all, retains the
292
+ legacy `installState: 'installs-on-first-start'` with `installed: false`. A binary whose interpreter
293
+ merely lives under mise (for example a Node script) reads `installed`.
291
294
  **Muse is different:** no executable, an auto-install shim, or an official launcher without its selected native release,
292
295
  reads `installState: 'missing'`. Stock Herdr does not install an absent Muse command. That covers `pi`, which
293
296
  Herdr installs via mise: a missing or shimmed `pi` reads `installed: false` with `installs on
294
- first start`, while a real `pi` binary reads `installed: true`.
297
+ first start`, while a real `pi` binary reads `installed: true`. The `museReadiness()` probe's
298
+ `launcher-only` state is a separate check, not this shell-shebang rule: it flags any `#!` file whose
299
+ head contains `mise`, so a Node script under mise reads `launcher-only` there but `installed` on the
300
+ agent path.
295
301
 
296
302
  ```ts
297
303
  import { HerdrKit } from '@byokit/herdr';
@@ -456,6 +462,7 @@ targets, do not traverse symlinks, always exclude `.git`, and ignore special fil
456
462
  and restored during the turn do not appear. Use `files.exclude` to prune generated or sensitive
457
463
  folders. Each scan defaults to 10000 entries and 128 MiB of regular-file content; set
458
464
  `files.maxFiles`/`maxBytes` to change these limits. An incomplete scan rejects the call.
465
+ Pass `files: false` to skip the scan entirely (`changedFiles` is then always empty).
459
466
 
460
467
  Reserve the pane and folder exclusively while the turn runs. Overlapping calls in one kit are
461
468
  refused, but changes by other apps or processes cannot be attributed to this agent. The supplied
@@ -1,8 +1,9 @@
1
1
  import type { Call } from './agents.ts';
2
- import type { AgentRef, Move, MoveResult, MoveToAccount, MoveToAccountResult, OpenSignInTab, StartAgent } from './types.ts';
2
+ import type { AgentReadiness, AgentStatusOptions, 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
+ agentStatus(kinds: readonly string[], o?: AgentStatusOptions): Promise<AgentReadiness[]>;
6
7
  }): {
7
8
  openSignInTab: (o: OpenSignInTab) => Promise<AgentRef>;
8
9
  move: (o: Move) => Promise<MoveResult>;
package/dist/accounts.js CHANGED
@@ -78,7 +78,7 @@ export function createAccountPanes(ctx) {
78
78
  }
79
79
  }
80
80
  async function perform(target, o, request) {
81
- const failed = (code, live = target.paneId) => ({ ok: false, code, message: words(`move.${code}`), ...(live === null ? {} : { live }) });
81
+ const failed = (code, live = target.paneId) => ({ ok: false, code, message: words(`move.${code}`, { name: o.name ?? 'this account' }), ...(live === null ? {} : { live }) });
82
82
  if (moving.has(target.paneId))
83
83
  return failed('busy');
84
84
  moving.add(target.paneId);
@@ -114,34 +114,6 @@ export function createAccountPanes(ctx) {
114
114
  if (!Number.isFinite(handoff.waitMs) || handoff.waitMs <= 0 || handoff.waitMs > 300_000
115
115
  || !Number.isSafeInteger(agent.state_change_seq) || agent.state_change_seq < 0)
116
116
  return failed('unsupported');
117
- if (agent.agent_status === 'working') {
118
- try {
119
- await call('agent.wait', { target: target.paneId, until: ['idle', 'done', 'blocked'],
120
- timeout_ms: handoff.waitMs }, handoff.waitMs + 5000);
121
- }
122
- catch {
123
- return failed('busy');
124
- }
125
- let current;
126
- try {
127
- current = (await call('agent.get', { target: target.paneId }))?.agent;
128
- }
129
- catch {
130
- return failed('changed', null);
131
- }
132
- if (!current || current.launch_pending === true || current.agent !== agent.agent
133
- || current.terminal_id !== agent.terminal_id || current.agent_session?.agent !== session.agent
134
- || current.agent_session?.kind !== session.kind || current.agent_session?.value !== session.value)
135
- return failed('changed', null);
136
- if (current.agent_status === 'blocked')
137
- return failed('blocked');
138
- if (!['idle', 'done'].includes(current.agent_status))
139
- return failed('busy');
140
- if (!Number.isSafeInteger(current.state_change_seq) || current.state_change_seq < 0)
141
- return failed('unsupported');
142
- agent = current;
143
- session = current.agent_session;
144
- }
145
117
  }
146
118
  const kind = agent.agent;
147
119
  const metadata = accountKind(o.provider);
@@ -161,6 +133,39 @@ export function createAccountPanes(ctx) {
161
133
  if (!Number.isFinite(timeout) || timeout <= 0)
162
134
  return failed('start_failed');
163
135
  const variable = metadata.folderVar;
136
+ if (request === undefined) {
137
+ const [status] = await ctx.agentStatus([kind], { folders: { [kind]: o.folder }, env: o.env });
138
+ if (status?.signedIn === 'no')
139
+ return failed('signed_out');
140
+ }
141
+ if (handoff?.busy === 'wait' && agent.agent_status === 'working') {
142
+ try {
143
+ await call('agent.wait', { target: target.paneId, until: ['idle', 'done', 'blocked'],
144
+ timeout_ms: handoff.waitMs }, handoff.waitMs + 5000);
145
+ }
146
+ catch {
147
+ return failed('busy');
148
+ }
149
+ let current;
150
+ try {
151
+ current = (await call('agent.get', { target: target.paneId }))?.agent;
152
+ }
153
+ catch {
154
+ return failed('changed', null);
155
+ }
156
+ if (!current || current.launch_pending === true || current.agent !== agent.agent
157
+ || current.terminal_id !== agent.terminal_id || current.agent_session?.agent !== session.agent
158
+ || current.agent_session?.kind !== session.kind || current.agent_session?.value !== session.value)
159
+ return failed('changed', null);
160
+ if (current.agent_status === 'blocked')
161
+ return failed('blocked');
162
+ if (!['idle', 'done'].includes(current.agent_status))
163
+ return failed('busy');
164
+ if (!Number.isSafeInteger(current.state_change_seq) || current.state_change_seq < 0)
165
+ return failed('unsupported');
166
+ agent = current;
167
+ session = current.agent_session;
168
+ }
164
169
  let paneId;
165
170
  let replacement;
166
171
  try {
package/dist/agents.js CHANGED
@@ -9,7 +9,7 @@ import { delimiter, isAbsolute, join } from 'node:path';
9
9
  import { words } from "./words.js";
10
10
  import { accountKind } from "./kinds.js";
11
11
  import { prepareLaunchEnv } from "./launch-env.js";
12
- import { museNative, MUSE_INSTALL_URL } from "./muse.js";
12
+ import { museNative, MUSE_INSTALL_URL, isMiseLauncher } from "./muse.js";
13
13
  const PROMPTABLE = ['idle', 'working', 'blocked', 'done'];
14
14
  const DEFAULT_TIMEOUT_MS = 60_000;
15
15
  const WAIT_PROMPTABLE_POLL_MS = 50;
@@ -62,7 +62,9 @@ export function createAgents(ctx) {
62
62
  };
63
63
  // Every placement takes its pane id from the server's answer, never a prediction (6.4).
64
64
  // Placements that make a pane (every path but `pane`) roll it back with `pane.close` when the
65
- // start fails, so a failed start leaves no extra pane behind.
65
+ // start fails, so a failed start leaves no extra pane behind. A label placement that created
66
+ // its workspace rolls the workspace back with `workspace.close` instead, so no empty workspace
67
+ // survives either; a reused workspace is never closed.
66
68
  async function startAgent(o) {
67
69
  const emit = (e) => {
68
70
  try {
@@ -130,7 +132,24 @@ export function createAgents(ctx) {
130
132
  XDG_STATE_HOME: join(home, '.local', 'state'), XDG_CACHE_HOME: join(home, '.cache'), ...o.env
131
133
  } };
132
134
  let paneId;
133
- let created = false;
135
+ let createdPane = false; // a pane this start made: roll it back on failure
136
+ let createdWorkspace; // a workspace this start made: roll it back instead
137
+ let workspaceCreated = false; // reported as `created`: a workspace was made
138
+ async function rollback() {
139
+ if (createdWorkspace !== undefined) {
140
+ try {
141
+ await call('workspace.close', { workspace_id: createdWorkspace });
142
+ return;
143
+ }
144
+ catch { /* fall through to the pane when the workspace close misses */ }
145
+ }
146
+ if (createdPane && paneId !== undefined) {
147
+ try {
148
+ await call('pane.close', { pane_id: paneId });
149
+ }
150
+ catch { /* rollback is best-effort */ }
151
+ }
152
+ }
134
153
  if (o.worktree !== undefined) {
135
154
  const made = await call('worktree.create', { cwd: o.cwd, focus: false, ...env,
136
155
  ...(o.worktree.branch === undefined ? {} : { branch: o.worktree.branch }),
@@ -143,23 +162,50 @@ export function createAgents(ctx) {
143
162
  paneId = rootPaneOf(made) ?? rootPaneOf(await call('tab.create', {
144
163
  workspace_id: made?.workspace?.workspace_id, cwd: checkout, focus: false, ...env,
145
164
  }));
146
- created = true;
165
+ createdPane = true;
166
+ workspaceCreated = true;
167
+ }
168
+ else if ('workspace' in o.place && o.place.workspace !== 'new') {
169
+ // A workspace label (6.4): reuse the one workspace with this label, or create it.
170
+ // The lookup is a fresh `workspace.list`, never the kit's tree, so a workspace made
171
+ // moments ago is found. Two matches refuse with `workspace-ambiguous` and pick nothing.
172
+ const label = o.place.workspace;
173
+ const listed = await call('workspace.list', {});
174
+ const workspaces = Array.isArray(listed?.workspaces) ? listed.workspaces : [];
175
+ const matches = workspaces.filter((w) => isObj(w) && w.label === label);
176
+ if (matches.length > 1)
177
+ throw fail('workspace-ambiguous', `More than one workspace is named "${label}". Rename one, then try again.`);
178
+ if (matches.length === 1) {
179
+ paneId = rootPaneOf(await call('tab.create', { workspace_id: matches[0].workspace_id, cwd: o.cwd,
180
+ ...(o.place.label === undefined ? {} : { label: o.place.label }), focus: false, ...env }));
181
+ createdPane = true;
182
+ }
183
+ else {
184
+ const made = await call('workspace.create', { cwd: o.cwd, label, focus: false, ...env });
185
+ paneId = rootPaneOf(made);
186
+ createdPane = true;
187
+ workspaceCreated = true;
188
+ if (isObj(made?.workspace) && typeof made.workspace.workspace_id === 'string') {
189
+ createdWorkspace = made.workspace.workspace_id;
190
+ }
191
+ }
147
192
  }
148
193
  else if ('workspace' in o.place) {
149
194
  paneId = rootPaneOf(await call('workspace.create', { cwd: o.cwd,
150
195
  ...(o.place.label === undefined ? {} : { label: o.place.label }), focus: false, ...env }));
151
- created = true;
196
+ createdPane = true;
197
+ workspaceCreated = true;
152
198
  }
153
199
  else if ('tab' in o.place) {
154
200
  paneId = rootPaneOf(await call('tab.create', { workspace_id: o.place.workspaceId, cwd: o.cwd,
155
201
  ...(o.place.label === undefined ? {} : { label: o.place.label }), focus: false, ...env }));
156
- created = true;
202
+ createdPane = true;
157
203
  }
158
204
  else if ('split' in o.place) {
159
205
  const made = await call('pane.split', { target_pane_id: o.place.split,
160
206
  direction: o.place.direction, focus: false, ...env });
161
207
  paneId = typeof made?.pane?.pane_id === 'string' ? made.pane.pane_id : undefined;
162
- created = true;
208
+ createdPane = true;
163
209
  }
164
210
  else {
165
211
  paneId = o.place.pane;
@@ -171,12 +217,7 @@ export function createAgents(ctx) {
171
217
  await prepareLaunchEnv(call, paneId, launch, timeout);
172
218
  }
173
219
  catch (error) {
174
- if (created) {
175
- try {
176
- await call('pane.close', { pane_id: paneId });
177
- }
178
- catch { /* best effort */ }
179
- }
220
+ await rollback();
180
221
  throw error;
181
222
  }
182
223
  }
@@ -188,19 +229,14 @@ export function createAgents(ctx) {
188
229
  for (;;) {
189
230
  try {
190
231
  await call('agent.start', params, timeout + 5000);
191
- return { paneId, name };
232
+ return { paneId, name, created: workspaceCreated };
192
233
  }
193
234
  catch (error) {
194
235
  if (START_RETRYABLE.has(codeOf(error) ?? '') && Date.now() < deadline) {
195
236
  await sleep(START_RETRY_DELAY_MS);
196
237
  continue;
197
238
  }
198
- if (created) {
199
- try {
200
- await call('pane.close', { pane_id: paneId });
201
- }
202
- catch { /* rollback is best-effort */ }
203
- }
239
+ await rollback();
204
240
  if (launch)
205
241
  throw fail('start_failed', words('agent.notReady'));
206
242
  throw error;
@@ -385,9 +421,8 @@ function readHead(file) {
385
421
  catch { /* already gone */ }
386
422
  }
387
423
  }
388
- // A mise-style auto-install launcher reads as a shell script that execs through mise — the
389
- // found "binary" runs, but the first run installs the agent instead. Real binaries (ELF,
390
- // Mach-O) never match: the shebang gate keeps a stray "mise" substring ("promise", …) out.
424
+ // A mise-style auto-install launcher is a shell script that goes through mise — the found
425
+ // "binary" runs, but the first run installs the agent instead (see isMiseLauncher in muse.ts).
391
426
  export function isAutoInstallShim(file, readFile = readHead) {
392
427
  let head;
393
428
  try {
@@ -396,7 +431,7 @@ export function isAutoInstallShim(file, readFile = readHead) {
396
431
  catch {
397
432
  return false;
398
433
  }
399
- return head !== undefined && head.startsWith('#!') && head.includes('mise');
434
+ return head !== undefined && isMiseLauncher(head);
400
435
  }
401
436
  // Muse has no absent-CLI auto-installer in stock Herdr. Its official launcher needs the
402
437
  // selected native release too. Other kinds retain their legacy shim classification.
@@ -414,7 +449,7 @@ export function agentInstallState(kind, o) {
414
449
  return { kind, state: 'missing', path: found.path };
415
450
  }
416
451
  const head = (o?.readFile ?? readHead)(found.path);
417
- if (head?.startsWith('#!') && (head.includes('mise') || (head.includes('MUSE_CHANNEL') && !museNative(found.path)))) {
452
+ if (head !== undefined && (isMiseLauncher(head) || (head.startsWith('#!') && head.includes('MUSE_CHANNEL') && !museNative(found.path)))) {
418
453
  return { kind, state: 'missing', path: found.path };
419
454
  }
420
455
  return { kind, state: 'installed', path: found.path };
package/dist/close.js CHANGED
@@ -4,7 +4,7 @@ const codeOf = (error) => {
4
4
  return code;
5
5
  return /herdr: ([a-z0-9_]+):/i.exec(error instanceof Error ? error.message : String(error))?.[1];
6
6
  };
7
- const fail = (code, message) => Object.assign(new Error(message), { code });
7
+ const fail = (code, message, wider) => Object.assign(new Error(message), { code }, wider ?? {});
8
8
  const unavailable = (kind) => fail(`${kind}-unavailable`, `That ${kind} is no longer available.`);
9
9
  const count = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
10
10
  export async function closePane(call, paneId) {
@@ -21,17 +21,20 @@ export async function closePane(call, paneId) {
21
21
  if (typeof tabId !== 'string')
22
22
  throw unavailable('pane');
23
23
  let paneCount = 0;
24
+ let tabLabel;
24
25
  try {
25
26
  const made = (await call('tab.get', { tab_id: tabId }));
26
27
  paneCount = count(made?.tab?.pane_count);
28
+ tabLabel = typeof made?.tab?.label === 'string' ? made.tab.label : undefined;
27
29
  }
28
30
  catch (error) {
29
31
  if (codeOf(error) === 'tab_not_found')
30
32
  throw unavailable('pane');
31
33
  throw error;
32
34
  }
33
- if (paneCount <= 1)
34
- throw fail('pane-close-would-widen', 'Closing this pane would close its tab; close the tab instead.');
35
+ if (paneCount <= 1) {
36
+ throw fail('pane-close-would-widen', 'Closing this pane would close its tab; close the tab instead.', { scope: 'tab', id: tabId, label: tabLabel ?? '' });
37
+ }
35
38
  await call('pane.close', { pane_id: paneId });
36
39
  }
37
40
  export async function closeTab(call, tabId) {
@@ -48,24 +51,29 @@ export async function closeTab(call, tabId) {
48
51
  if (typeof workspaceId !== 'string')
49
52
  throw unavailable('tab');
50
53
  let tabCount = 0;
54
+ let workspaceLabel;
51
55
  try {
52
56
  const made = (await call('workspace.get', { workspace_id: workspaceId }));
53
57
  tabCount = count(made?.workspace?.tab_count);
58
+ workspaceLabel = typeof made?.workspace?.label === 'string' ? made.workspace.label : undefined;
54
59
  }
55
60
  catch (error) {
56
61
  if (codeOf(error) === 'workspace_not_found')
57
62
  throw unavailable('tab');
58
63
  throw error;
59
64
  }
60
- if (tabCount <= 1)
61
- throw fail('tab-close-would-widen', 'Closing this tab would close its workspace; close the workspace instead.');
65
+ if (tabCount <= 1) {
66
+ throw fail('tab-close-would-widen', 'Closing this tab would close its workspace; close the workspace instead.', { scope: 'workspace', id: workspaceId, label: workspaceLabel ?? '' });
67
+ }
62
68
  await call('tab.close', { tab_id: tabId });
63
69
  }
64
70
  export async function closeWorkspace(call, workspaceId) {
65
71
  let worktree;
72
+ let workspaceLabel;
66
73
  try {
67
74
  const made = (await call('workspace.get', { workspace_id: workspaceId }));
68
75
  worktree = made?.workspace?.worktree;
76
+ workspaceLabel = typeof made?.workspace?.label === 'string' ? made.workspace.label : undefined;
69
77
  }
70
78
  catch (error) {
71
79
  if (codeOf(error) === 'workspace_not_found')
@@ -78,7 +86,8 @@ export async function closeWorkspace(call, workspaceId) {
78
86
  const group = (Array.isArray(made?.workspaces) ? made.workspaces : [])
79
87
  .filter((w) => w?.worktree?.repo_key === worktree.repo_key).length;
80
88
  if (group >= 2) {
81
- throw fail('workspace-close-would-widen', 'Closing this workspace would close its worktree group; close the linked checkouts instead.');
89
+ // The group is anchored at this root, so the wider target is named by that root workspace.
90
+ throw fail('workspace-close-would-widen', 'Closing this workspace would close its worktree group; close the linked checkouts instead.', { scope: 'workspace', id: workspaceId, label: workspaceLabel ?? '' });
82
91
  }
83
92
  }
84
93
  await call('workspace.close', { workspace_id: workspaceId });
package/dist/constants.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // The pinned Herdr release (docs/runtime-kits.md D5) and the protocol this kit speaks.
2
2
  export const HERDR_VERSION = '0.9.1';
3
3
  // From the v0.9.1 schema snapshot (schema/herdr-api-0.9.1.json, docs/runtime-kits.md 6.7);
4
- // regenerated by scripts/gen-types.ts and pinned by test/generated.test.ts.
4
+ // regenerated by scripts/gen-types.ts.
5
5
  export const HERDR_PROTOCOL = 22;
6
6
  // Effective accepted server protocols for K11: each undeclared bound means exactly the pin.
7
7
  export function protocolBounds(range) {
package/dist/device.d.ts CHANGED
@@ -53,6 +53,24 @@ export declare function herdrDevice(link: DeviceLink): {
53
53
  tab?: string;
54
54
  workspace?: string;
55
55
  }): Promise<void>;
56
+ rename(o: {
57
+ pane?: string;
58
+ tab?: string;
59
+ workspace?: string;
60
+ label: string;
61
+ }): Promise<void>;
62
+ focus(o: {
63
+ pane?: string;
64
+ tab?: string;
65
+ workspace?: string;
66
+ }): Promise<void>;
67
+ layout(o: {
68
+ root: HerdrParams<'layout.apply'>['root'];
69
+ focus?: boolean;
70
+ tabId?: string;
71
+ tabLabel?: string;
72
+ workspaceId?: string;
73
+ }): Promise<HerdrResult<'layout.apply'>>;
56
74
  events(): AsyncIterable<HerdrLinkEvent>;
57
75
  subscribe<E extends HerdrEventName>(subs: HerdrSubscription<E>[], on: (e: HerdrEventOf<E>) => void, onError?: (message: string) => void): () => void;
58
76
  registerNotices(seed: Uint8Array): Promise<void>;
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/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,
package/dist/link.js CHANGED
@@ -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`);
@@ -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');
package/dist/turns.js CHANGED
@@ -73,9 +73,11 @@ export function createTurns(kit) {
73
73
  if (!isAbsolute(o.cwd))
74
74
  throw fail('turn-cwd');
75
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);
76
+ // Fail invalid configuration before delivering a prompt (no scan means no file limits to check).
77
+ if (o.files !== false) {
78
+ positiveLimit(o.files?.maxFiles, 10000);
79
+ positiveLimit(o.files?.maxBytes, 128 * 1024 * 1024);
80
+ }
79
81
  if (o.result)
80
82
  positiveLimit(o.result.maxBytes, 1024 * 1024);
81
83
  const schema = o.result ? JSON.stringify(o.result.schema) : undefined;
@@ -125,7 +127,7 @@ export function createTurns(kit) {
125
127
  const endSignal = new Promise((resolve) => { resolveEnd = resolve; });
126
128
  const watch = kit.subscribe([{ type: 'pane.agent_status_changed', pane_id: target.paneId }], (e) => {
127
129
  if (!armed) {
128
- if (e.agent_status !== 'idle')
130
+ if (e.agent_status !== 'idle' && e.agent_status !== 'done')
129
131
  abort('turn-busy');
130
132
  return;
131
133
  }
@@ -153,7 +155,7 @@ export function createTurns(kit) {
153
155
  throw fail('turn-watch-lost');
154
156
  const beforeAgent = await gate(kit.call('agent.get', { target: target.paneId }));
155
157
  const agent = beforeAgent.agent;
156
- if (agent.agent_status !== 'idle' || agent.launch_pending === true)
158
+ if ((agent.agent_status !== 'idle' && agent.agent_status !== 'done') || agent.launch_pending === true)
157
159
  throw fail('turn-not-idle');
158
160
  if (target.name && agent.name !== target.name)
159
161
  throw fail('turn-unavailable');
package/dist/types.d.ts CHANGED
@@ -98,6 +98,7 @@ export type AgentStartEvent = {
98
98
  export type AgentRef = {
99
99
  paneId: string;
100
100
  name?: string;
101
+ created?: boolean;
101
102
  };
102
103
  export type StartAgent = {
103
104
  kind: string;
@@ -106,6 +107,9 @@ export type StartAgent = {
106
107
  place: {
107
108
  workspace: 'new';
108
109
  label?: string;
110
+ } | {
111
+ workspace: string;
112
+ label?: string;
109
113
  } | {
110
114
  tab: 'new';
111
115
  workspaceId: string;
@@ -155,6 +159,7 @@ export type MoveToAccount = {
155
159
  provider: string;
156
160
  folder: string;
157
161
  env?: Record<string, string>;
162
+ name?: string;
158
163
  direction?: 'right' | 'down';
159
164
  timeoutMs?: number;
160
165
  whenBusy?: BusyHandoff;
@@ -164,7 +169,7 @@ export type MoveToAccountResult = {
164
169
  session: string;
165
170
  } | {
166
171
  ok: false;
167
- code: 'too_early' | 'busy' | 'unsupported' | 'env_mismatch' | 'close_failed' | 'start_failed' | 'blocked' | 'changed' | 'interrupt_unsupported';
172
+ code: 'too_early' | 'busy' | 'unsupported' | 'env_mismatch' | 'close_failed' | 'start_failed' | 'blocked' | 'changed' | 'interrupt_unsupported' | 'signed_out';
168
173
  message: string;
169
174
  live?: string;
170
175
  };
@@ -209,7 +214,8 @@ export type AgentTurnOptions<T = unknown> = {
209
214
  cwd: string;
210
215
  timeoutMs?: number;
211
216
  signal?: AbortSignal;
212
- files?: AgentTurnFiles;
217
+ /** Pass false to skip the file scan entirely (changedFiles is then always empty). */
218
+ files?: AgentTurnFiles | false;
213
219
  result?: AgentTurnResultPolicy<T>;
214
220
  onEnd?: (end: AgentTurnEnd<T>) => void;
215
221
  };
package/dist/words.json CHANGED
@@ -20,6 +20,7 @@
20
20
  "approval.stale": "That question already changed. Look again before answering.",
21
21
  "close.wouldWiden": "Closing this would close more than you picked. Close the bigger one instead.",
22
22
  "link.notAllowed": "This device can't do that. Ask the person at the computer.",
23
+ "move.signed_out": "Sign in to {name} first.",
23
24
  "move.too_early": "This conversation has not started yet. Try again in a moment.",
24
25
  "move.busy": "Wait for this conversation to finish before moving it.",
25
26
  "move.unsupported": "This conversation cannot move between these accounts.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byokit/herdr",
3
- "version": "0.7.3",
3
+ "version": "0.8.1",
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/pair.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -52,9 +52,9 @@
52
52
  "prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
53
53
  },
54
54
  "dependencies": {
55
- "@byokit/pair": "0.9.0",
56
- "@byokit/relay": "0.6.0",
57
- "@byokit/discover": "0.7.0",
55
+ "@byokit/pair": "0.10.0",
56
+ "@byokit/relay": "0.6.1",
57
+ "@byokit/discover": "0.8.1",
58
58
  "@byokit/seal": "0.3.0",
59
59
  "ws": "8.22.0"
60
60
  },