@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/CHANGELOG.md +39 -0
- package/README.md +29 -13
- package/dist/accounts.d.ts +2 -1
- package/dist/accounts.js +34 -29
- package/dist/agents.d.ts +3 -2
- package/dist/agents.js +98 -30
- package/dist/close.js +15 -6
- package/dist/constants.js +1 -1
- package/dist/device.d.ts +19 -1
- package/dist/device.js +7 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/kit.d.ts +3 -0
- package/dist/kit.js +3 -1
- package/dist/link.d.ts +1 -1
- package/dist/link.js +139 -5
- package/dist/muse.d.ts +2 -0
- package/dist/muse.js +7 -1
- package/dist/notices.js +10 -16
- package/dist/testing/contract.js +138 -4
- package/dist/testing/fake-herdr/server.js +61 -1
- package/dist/turn-files.d.ts +1 -1
- package/dist/turn-files.js +6 -1
- package/dist/turns.js +7 -5
- package/dist/types.d.ts +8 -2
- package/dist/words.json +1 -0
- package/package.json +5 -5
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/
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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).
|
|
3
|
-
|
|
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
|
|
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
|
|
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 =
|
|
70
|
+
const parsed = sealOpenNotice(data, secretKey);
|
|
71
|
+
return isBlockedAgent(parsed) ? parsed : null;
|
|
77
72
|
}
|
|
78
|
-
|
|
79
|
-
|
|
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) {
|
package/dist/testing/contract.js
CHANGED
|
@@ -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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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)
|
package/dist/turn-files.d.ts
CHANGED
|
@@ -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'];
|
package/dist/turn-files.js
CHANGED
|
@@ -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 ||
|
|
35
|
+
if (name === '.git' || rel === resultName || exclude?.(rel))
|
|
31
36
|
continue;
|
|
32
37
|
if (++entries > maxFiles)
|
|
33
38
|
throw new Error('turn: file limit');
|