@byokit/herdr 0.3.0 → 0.4.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 +18 -0
- package/README.md +99 -6
- package/dist/accounts.d.ts +3 -2
- package/dist/accounts.js +103 -23
- package/dist/index.d.ts +1 -1
- package/dist/kit.d.ts +8 -2
- package/dist/kit.js +8 -1
- package/dist/turn-files.d.ts +4 -0
- package/dist/turn-files.js +88 -0
- package/dist/turns.d.ts +7 -0
- package/dist/turns.js +225 -0
- package/dist/types.d.ts +58 -1
- package/dist/words.json +3 -2
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.4.1 (2026-10-01)
|
|
6
|
+
|
|
7
|
+
- Dependency update: pins @byokit/relay 0.5.1.
|
|
8
|
+
|
|
9
|
+
- FIX: Preserve the ready replacement after a lost source-close acknowledgment unless fresh reads verify the original conversation survived; report only a verified recovery conversation, or omit `live` when verification is unavailable.
|
|
10
|
+
|
|
11
|
+
## 0.4.0 (2026-10-01)
|
|
12
|
+
|
|
13
|
+
- Dependency update: pins @byokit/reach 0.6.0.
|
|
14
|
+
|
|
15
|
+
- Add `move` with caller resume args, credential shedding, staging/replacement hooks and `MoveResult.paneId`.
|
|
16
|
+
- Add per-call `cli` env overlays for managed folder hook installation.
|
|
17
|
+
- Report surviving replacement panes when environment verification rollback fails; refuse folder moves for agents that ignore the folder variables.
|
|
18
|
+
- Track a subscription agent's turn with `runTurn` and `onTurnEnd`: acknowledged Herdr status
|
|
19
|
+
events, optional JSON validated by the app's schema validator, and changed files from bounded
|
|
20
|
+
before/after snapshots of the app-owned working directory. No additional API billing path.
|
|
21
|
+
|
|
5
22
|
## 0.3.0 (2026-10-01)
|
|
6
23
|
|
|
7
24
|
- Dependency update: pins @byokit/reach 0.5.0.
|
|
@@ -24,6 +41,7 @@
|
|
|
24
41
|
launcher such as a mise shim: shimmed or absent reads `installs-on-first-start`, sharing the
|
|
25
42
|
one probe path with `installedAgentKinds` (`resolveAgentBinary`, `isAutoInstallShim`).
|
|
26
43
|
|
|
44
|
+
|
|
27
45
|
## 0.2.0 (2026-09-30)
|
|
28
46
|
|
|
29
47
|
- `HerdrKit.agentStatus(kinds)` (B5): per-kind onboarding readiness — installed plus CLI
|
package/README.md
CHANGED
|
@@ -149,7 +149,7 @@ persistent grant store, and the phone page above.
|
|
|
149
149
|
|
|
150
150
|
| Export | What it does |
|
|
151
151
|
|---|---|
|
|
152
|
-
| `HerdrKit` (`@byokit/herdr`) | The host-side kit, in `adopt` or `own` mode: `start`/`stop`, `state`, `snapshot`/`onChange`, `startAgent`, `openSignInTab`, `moveToAccount`, `onStartAgent`, `prompt`, `sendKeys`, `wait`, `read`, `blocked`/`onBlocked`/`answer`, `closePane`/`closeTab`/`closeWorkspace`, `agentKinds`, `installedAgentKinds`, `agentStatus`, `agentInstallState`, `terminal`, `onEvent`, `statusWatchReady`, and the pass-throughs `call`, `subscribe` and `cli` |
|
|
152
|
+
| `HerdrKit` (`@byokit/herdr`) | The host-side kit, in `adopt` or `own` mode: `start`/`stop`, `state`, `snapshot`/`onChange`, `startAgent`, `openSignInTab`, `move`, `moveToAccount`, `onStartAgent`, `prompt`, `sendKeys`, `wait`, `read`, `blocked`/`onBlocked`/`answer`, `closePane`/`closeTab`/`closeWorkspace`, `agentKinds`, `installedAgentKinds`, `agentStatus`, `agentInstallState`, `terminal`, `onEvent`, `statusWatchReady`, and the pass-throughs `call`, `subscribe` and `cli` |
|
|
153
153
|
| `agentStatus`, `agentInstallState`, `agentProbePath`, `extraPathDirs`, `runStatusCommand`, `isAutoInstallShim`, `resolveAgentBinary` (`@byokit/herdr`) | Onboarding readiness: per-kind install + CLI sign-in without a Herdr connection (see below) |
|
|
154
154
|
| `HERDR_VERSION`, `HERDR_PROTOCOL` | The pinned Herdr release (`0.9.1`) and the protocol the kit speaks (`22`) |
|
|
155
155
|
| `words`, `agentWords`, `stateWords`, `WORDS` | Plain sentences for agent statuses and kit states |
|
|
@@ -176,9 +176,10 @@ The host supplies account folders and shares conversation history as needed. The
|
|
|
176
176
|
credentials. To connect an account, open its CLI in a new tab and let the person complete the CLI's own login:
|
|
177
177
|
|
|
178
178
|
```ts
|
|
179
|
-
import
|
|
179
|
+
import { HerdrKit } from '@byokit/herdr';
|
|
180
|
+
const kit = new HerdrKit({ mode: 'adopt', bin: '/app/bin/herdr', socketPath: '/app/herdr.sock' });
|
|
181
|
+
await kit.start();
|
|
180
182
|
|
|
181
|
-
declare const kit: HerdrKit; // the host's started kit
|
|
182
183
|
const signIn = await kit.openSignInTab({
|
|
183
184
|
workspaceId: 'w1', kind: 'codex', cwd: '/home/me/project', env: { CODEX_HOME: '/app/accounts/work' },
|
|
184
185
|
});
|
|
@@ -189,13 +190,47 @@ const moved = await kit.moveToAccount({ paneId: 'w1:p2' }, {
|
|
|
189
190
|
if (moved.ok) console.log(moved.session); // new pane to follow
|
|
190
191
|
```
|
|
191
192
|
|
|
192
|
-
A move accepts Claude
|
|
193
|
+
A move accepts Claude for Claude folders and Codex for Codex folders. Pi ignores these folder variables and is refused. It verifies the new shell's effective
|
|
193
194
|
account folder, resumes the conversation there, waits for a ready session, then closes the old pane. A failed
|
|
194
|
-
start preserves the original
|
|
195
|
-
|
|
195
|
+
start preserves the original. A failed source close rolls back the new pane only after fresh reads verify
|
|
196
|
+
both conversations' identities. A lost close acknowledgment can mean the original already closed: the kit
|
|
197
|
+
preserves the replacement when the source is absent, replaced or unreachable and returns `close_failed`.
|
|
198
|
+
After uncertain source close or cleanup, `live` names a freshly verified surviving conversation; it is omitted
|
|
199
|
+
when neither conversation can be verified. Check the remaining panes before retrying. Independent changes
|
|
200
|
+
can still happen after verification; the server has no conditional close transaction. Failures return a plain `message`. Wait until a conversation is idle or done before
|
|
196
201
|
moving it. `StartAgent.env` applies to newly created placements; existing shells cannot receive a new env.
|
|
197
202
|
Tokens stay on the device and are never logged. Each provider's own terms apply to how you use your plan.
|
|
198
203
|
|
|
204
|
+
For managed-folder stores that supply resume arguments and credential shedding, use `move`:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
import { HerdrKit } from '@byokit/herdr';
|
|
208
|
+
const kit = new HerdrKit({ mode: 'adopt', bin: '/app/bin/herdr', socketPath: '/app/herdr.sock' });
|
|
209
|
+
await kit.start();
|
|
210
|
+
|
|
211
|
+
const conversationId = 'published-conversation-id';
|
|
212
|
+
const staged = new Set<string>();
|
|
213
|
+
let activePane = 'w1:p2';
|
|
214
|
+
const moved = await kit.move({
|
|
215
|
+
paneId: 'w1:p2', kind: 'codex', args: ['resume', conversationId],
|
|
216
|
+
set: { CODEX_HOME: '/app/accounts/work' }, unset: ['OPENAI_API_KEY', 'CODEX_API_KEY'],
|
|
217
|
+
onStaged(paneId) { staged.add(paneId); },
|
|
218
|
+
onReplaced(paneId) { staged.delete(paneId); activePane = paneId; },
|
|
219
|
+
});
|
|
220
|
+
if (moved.ok) console.log(moved.paneId);
|
|
221
|
+
await kit.cli(['integration', 'install', 'codex'], { env: { CODEX_HOME: '/app/accounts/work' } });
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`MoveResult` names the new pane as `paneId`; `moveToAccount` retains its `session` result as
|
|
225
|
+
`MoveToAccountResult`. Both share the same per-source lock. Staging runs before verification/start; a staging
|
|
226
|
+
exception rolls back. Replacement notification runs once after an acknowledged source close and cannot undo the move.
|
|
227
|
+
An ambiguous close returns failure without a replacement notification, even if the source has disappeared.
|
|
228
|
+
`move` clears `unset` in the new shell, verifies the account folder and unset names' absence, and requires an interactive
|
|
229
|
+
agent publishing a conversation before closing the source. The pinned start API has no command-prefix/env
|
|
230
|
+
field, so this uses shell `unset` rather than `env -u`; shells that cannot clear a variable fail closed.
|
|
231
|
+
Only folder paths belong in `set`, never tokens. `cli` env overlays the kit's explicit env for one call;
|
|
232
|
+
it inherits no process variables. The default move step timeout is 60 seconds.
|
|
233
|
+
|
|
199
234
|
## Two ways in
|
|
200
235
|
|
|
201
236
|
- `adopt` talks to the socket path the app passes: the person's own Herdr, nothing spawned, nothing written.
|
|
@@ -277,6 +312,64 @@ off();
|
|
|
277
312
|
`start()` is non-fatal: if Herdr is down it rejects (state `failed`, e.g. `failed/socket`) and may be called again
|
|
278
313
|
afterwards, so the host comes up while Herdr is down by retrying `start()` in a backoff loop.
|
|
279
314
|
|
|
315
|
+
## Turn results
|
|
316
|
+
|
|
317
|
+
`runTurn` prompts an existing subscription agent and resolves after Herdr reports that it worked
|
|
318
|
+
and returned to `idle` or `done`. Subscribe app-wide with `onTurnEnd`, or use the call's `onEnd`.
|
|
319
|
+
An initial idle or a blocked approval does not finish the turn; keep using `onBlocked`/`answer`.
|
|
320
|
+
No provider API or additional billing path is involved.
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
import { HerdrKit } from '@byokit/herdr';
|
|
324
|
+
|
|
325
|
+
const kit = new HerdrKit({ mode: 'adopt', bin: 'herdr', socketPath: '/tmp/herdr.sock' });
|
|
326
|
+
await kit.start();
|
|
327
|
+
const target = await kit.startAgent({ kind: 'codex', cwd: '/app/worktree', place: { workspace: 'new' } });
|
|
328
|
+
const off = kit.onTurnEnd((end) => console.log(end.changedFiles));
|
|
329
|
+
const end = await kit.runTurn(target, {
|
|
330
|
+
prompt: 'Implement the requested change, then report whether the build passed.',
|
|
331
|
+
cwd: '/app/worktree',
|
|
332
|
+
timeoutMs: 10 * 60_000,
|
|
333
|
+
files: { exclude: (path) => path === 'node_modules' || path === 'dist' },
|
|
334
|
+
result: {
|
|
335
|
+
schema: { type: 'object', required: ['buildPassed'], additionalProperties: false,
|
|
336
|
+
properties: { buildPassed: { type: 'boolean' } } },
|
|
337
|
+
// For larger schemas, call the app's existing JSON Schema validator here.
|
|
338
|
+
validate: (value: unknown): value is { buildPassed: boolean } =>
|
|
339
|
+
typeof value === 'object' && value !== null && 'buildPassed' in value &&
|
|
340
|
+
typeof value.buildPassed === 'boolean' && Object.keys(value).length === 1,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
if (end.result.state === 'valid') console.log(end.result.value.buildPassed);
|
|
344
|
+
off();
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
The kit asks the agent to write `{ turnId, result }` to a fresh JSON file in that folder before
|
|
348
|
+
finishing. It checks the id, parses and validates the bounded file, and removes it. The result
|
|
349
|
+
is `not-requested` without a policy, `missing` if the agent did not write it, `invalid` with a
|
|
350
|
+
reason if it could not be accepted, or `valid` with a typed value. Unchecked payloads are never
|
|
351
|
+
returned or logged; the transient result file is excluded from `changedFiles`. Default result
|
|
352
|
+
limit: 1 MiB (`result.maxBytes`). The agent must follow these instructions; Herdr has no native
|
|
353
|
+
schema-enforced response API. Task success is a separate app decision.
|
|
354
|
+
|
|
355
|
+
`changedFiles` lists sorted relative paths with `added`, `modified` or `deleted`, including
|
|
356
|
+
untracked files and changes to already-dirty files. Snapshots compare content, modes and symlink
|
|
357
|
+
targets, do not traverse symlinks, always exclude `.git`, and ignore special files. Files changed
|
|
358
|
+
and restored during the turn do not appear. Use `files.exclude` to prune generated or sensitive
|
|
359
|
+
folders. Each scan defaults to 10000 entries and 128 MiB of regular-file content; set
|
|
360
|
+
`files.maxFiles`/`maxBytes` to change these limits. An incomplete scan rejects the call.
|
|
361
|
+
|
|
362
|
+
Reserve the pane and folder exclusively while the turn runs. Overlapping calls in one kit are
|
|
363
|
+
refused, but changes by other apps or processes cannot be attributed to this agent. The supplied
|
|
364
|
+
absolute `cwd` must match Herdr's reported working directory. Timeout, watch loss, pane closure or
|
|
365
|
+
replacement, `kit.stop()` and an aborted `signal` reject without a completion event. Monitoring
|
|
366
|
+
cancellation does not interrupt the agent; it may still finish or write its result file later.
|
|
367
|
+
Herdr's working-to-idle/done detection is the end signal, not proof of semantic success. The kit
|
|
368
|
+
does not guess completion from elapsed silence or a prompt receipt.
|
|
369
|
+
|
|
370
|
+
This helper runs on the host. Apps own branches, review/apply/rollback and authorized forwarding
|
|
371
|
+
of results to devices; it adds no link permission or device operation.
|
|
372
|
+
|
|
280
373
|
## Status
|
|
281
374
|
|
|
282
375
|
Pinned to Herdr v0.9.1. The schema snapshot (`schema/herdr-api-0.9.1.json`, protocol 22) generates the typed surface
|
package/dist/accounts.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { Call } from './agents.ts';
|
|
2
|
-
import type { AgentRef, MoveResult, MoveToAccount, OpenSignInTab, StartAgent } from './types.ts';
|
|
2
|
+
import type { AgentRef, Move, MoveResult, MoveToAccount, MoveToAccountResult, OpenSignInTab, StartAgent } from './types.ts';
|
|
3
3
|
export declare function createAccountPanes(ctx: {
|
|
4
4
|
call: Call;
|
|
5
5
|
startAgent(o: StartAgent): Promise<AgentRef>;
|
|
6
6
|
}): {
|
|
7
7
|
openSignInTab: (o: OpenSignInTab) => Promise<AgentRef>;
|
|
8
|
-
|
|
8
|
+
move: (o: Move) => Promise<MoveResult>;
|
|
9
|
+
moveToAccount: (target: AgentRef, o: MoveToAccount) => Promise<MoveToAccountResult>;
|
|
9
10
|
};
|
package/dist/accounts.js
CHANGED
|
@@ -17,11 +17,12 @@ export function createAccountPanes(ctx) {
|
|
|
17
17
|
}
|
|
18
18
|
// A shell rc can override placement env. Verify what the shell actually resolves, rather
|
|
19
19
|
// than pane.get's launch env. The variable name is fixed by the provider, never caller text.
|
|
20
|
-
async function checkEnv(paneId, variable, folder, timeout) {
|
|
20
|
+
async function checkEnv(paneId, variable, folder, timeout, absent = false) {
|
|
21
21
|
const marker = `BYOKIT_ACCOUNT_${randomBytes(8).toString('hex')}`;
|
|
22
22
|
const deadline = Date.now() + timeout;
|
|
23
|
+
const expansion = absent ? '${' + variable + '+x}' : '$' + variable;
|
|
23
24
|
try {
|
|
24
|
-
await call('pane.send_text', { pane_id: paneId, text: `echo ${marker}="
|
|
25
|
+
await call('pane.send_text', { pane_id: paneId, text: `echo ${marker}="${expansion}"\n` }, timeout);
|
|
25
26
|
for (;;) {
|
|
26
27
|
const result = await call('pane.read', { pane_id: paneId, source: 'recent_unwrapped', lines: 40,
|
|
27
28
|
format: 'text', strip_ansi: true }, Math.max(1, deadline - Date.now()));
|
|
@@ -38,6 +39,28 @@ export function createAccountPanes(ctx) {
|
|
|
38
39
|
return false;
|
|
39
40
|
}
|
|
40
41
|
}
|
|
42
|
+
// agent.start has no env/unset/command field in the pinned protocol. Clear credentials in
|
|
43
|
+
// the shell that launches it, then verify absence (never echo credential values).
|
|
44
|
+
async function prepareEnv(paneId, set, unset, variable, timeout) {
|
|
45
|
+
const entries = Object.entries(set);
|
|
46
|
+
if ([...entries.map(([key]) => key), ...unset].some((key) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
47
|
+
|| entries.some(([, value]) => typeof value !== 'string' || /[\r\n\0]/.test(value))
|
|
48
|
+
|| unset.some((key) => key in set))
|
|
49
|
+
return false;
|
|
50
|
+
try {
|
|
51
|
+
if (unset.length > 0)
|
|
52
|
+
await call('pane.send_text', { pane_id: paneId, text: `unset ${unset.join(' ')}\n` }, timeout);
|
|
53
|
+
if (!await checkEnv(paneId, variable, set[variable], timeout))
|
|
54
|
+
return false;
|
|
55
|
+
for (const key of unset)
|
|
56
|
+
if (!await checkEnv(paneId, key, '', timeout, true))
|
|
57
|
+
return false;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
41
64
|
async function openSignInTab(o) {
|
|
42
65
|
const { workspaceId, label, ...start } = o;
|
|
43
66
|
try {
|
|
@@ -49,8 +72,8 @@ export function createAccountPanes(ctx) {
|
|
|
49
72
|
throw Object.assign(new Error(words('agent.notReady')), { code: 'sign_in_failed' });
|
|
50
73
|
}
|
|
51
74
|
}
|
|
52
|
-
async function
|
|
53
|
-
const failed = (code, live = target.paneId) => ({ ok: false, code, message: words(`move.${code}`), live });
|
|
75
|
+
async function perform(target, o, request) {
|
|
76
|
+
const failed = (code, live = target.paneId) => ({ ok: false, code, message: words(`move.${code}`), ...(live === null ? {} : { live }) });
|
|
54
77
|
if (moving.has(target.paneId))
|
|
55
78
|
return failed('busy');
|
|
56
79
|
moving.add(target.paneId);
|
|
@@ -69,21 +92,22 @@ export function createAccountPanes(ctx) {
|
|
|
69
92
|
if (!['idle', 'done'].includes(agent.agent_status))
|
|
70
93
|
return failed('busy');
|
|
71
94
|
const kind = agent.agent;
|
|
72
|
-
if (!['claude', 'codex'].includes(o.provider) ||
|
|
73
|
-
||
|
|
95
|
+
if (!['claude', 'codex'].includes(o.provider) || kind !== o.provider
|
|
96
|
+
|| (request !== undefined && request.kind !== kind)
|
|
97
|
+
|| session.agent !== kind || session.kind !== 'id')
|
|
74
98
|
return failed('unsupported');
|
|
75
|
-
const args = kind === '
|
|
76
|
-
: kind === '
|
|
77
|
-
|
|
78
|
-
if (args === undefined)
|
|
99
|
+
const args = request?.args ?? (kind === 'claude' && session.kind === 'id' ? ['--resume', session.value]
|
|
100
|
+
: kind === 'codex' && session.kind === 'id' ? ['resume', session.value] : undefined);
|
|
101
|
+
if (args === undefined || !Array.isArray(args) || args.length === 0 || args.some((a) => typeof a !== 'string' || /[\r\n\0]/.test(a)))
|
|
79
102
|
return failed('unsupported');
|
|
80
103
|
if (!o.folder || /[\r\n\0]/.test(o.folder))
|
|
81
104
|
return failed('env_mismatch');
|
|
82
|
-
const timeout = o.timeoutMs ??
|
|
105
|
+
const timeout = o.timeoutMs ?? 60_000;
|
|
83
106
|
if (!Number.isFinite(timeout) || timeout <= 0)
|
|
84
107
|
return failed('start_failed');
|
|
85
108
|
const variable = o.provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME';
|
|
86
109
|
let paneId;
|
|
110
|
+
let replacement;
|
|
87
111
|
try {
|
|
88
112
|
const split = await call('pane.split', { target_pane_id: target.paneId,
|
|
89
113
|
direction: o.direction ?? 'right', focus: false,
|
|
@@ -96,35 +120,91 @@ export function createAccountPanes(ctx) {
|
|
|
96
120
|
catch {
|
|
97
121
|
return failed('start_failed');
|
|
98
122
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
123
|
+
const rollback = async (code) => failed(code, await close(paneId) ? target.paneId : paneId);
|
|
124
|
+
try {
|
|
125
|
+
request?.onStaged?.(paneId);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return rollback('start_failed');
|
|
129
|
+
}
|
|
130
|
+
if (request !== undefined && !await prepareEnv(paneId, request.set, request.unset ?? [], variable, timeout)) {
|
|
131
|
+
return rollback('env_mismatch');
|
|
132
|
+
}
|
|
133
|
+
if (request === undefined && !await checkEnv(paneId, variable, o.folder, timeout)) {
|
|
134
|
+
return rollback('env_mismatch');
|
|
102
135
|
}
|
|
103
136
|
try {
|
|
104
137
|
// Unique names avoid colliding with the still-live source agent.
|
|
105
138
|
await ctx.startAgent({ kind, cwd: agent.foreground_cwd ?? agent.cwd ?? '.',
|
|
106
139
|
name: `move-${randomBytes(8).toString('hex')}`, place: { pane: paneId }, args, timeoutMs: timeout });
|
|
107
140
|
await call('agent.wait', { target: paneId, until: ['idle', 'done'], timeout_ms: timeout }, timeout + 5000);
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
141
|
+
const deadline = Date.now() + timeout;
|
|
142
|
+
for (;;) {
|
|
143
|
+
const ready = (await call('agent.get', { target: paneId }, Math.max(1, deadline - Date.now())))?.agent;
|
|
144
|
+
if (ready && ready.launch_pending !== true && ['idle', 'done'].includes(ready.agent_status)
|
|
145
|
+
&& (request === undefined || ready.interactive_ready === true)
|
|
146
|
+
&& ready.agent === kind && ready.agent_session?.agent === kind
|
|
147
|
+
&& ready.agent_session?.kind === session.kind && typeof ready.agent_session?.value === 'string'
|
|
148
|
+
&& ready.agent_session.value.length > 0) {
|
|
149
|
+
replacement = ready;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
if (Date.now() >= deadline)
|
|
153
|
+
throw new Error('not ready');
|
|
154
|
+
await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
|
|
155
|
+
}
|
|
114
156
|
}
|
|
115
157
|
catch {
|
|
116
158
|
const cleaned = await close(paneId);
|
|
117
159
|
return failed('start_failed', cleaned ? target.paneId : paneId);
|
|
118
160
|
}
|
|
119
161
|
if (!await close(target.paneId)) {
|
|
120
|
-
|
|
121
|
-
|
|
162
|
+
// A lost ACK can mean the close already applied. Never destroy the ready
|
|
163
|
+
// replacement unless a fresh read proves the original conversation survived.
|
|
164
|
+
// Compare identity, not revision: status changes can legitimately bump revision.
|
|
165
|
+
const survives = async (id, expected) => {
|
|
166
|
+
try {
|
|
167
|
+
const current = (await call('agent.get', { target: id }, timeout))?.agent;
|
|
168
|
+
return !!current && current.agent === expected.agent && current.launch_pending !== true
|
|
169
|
+
&& ['idle', 'working', 'blocked', 'done'].includes(current.agent_status)
|
|
170
|
+
&& current.agent_session?.agent === expected.agent_session?.agent
|
|
171
|
+
&& current.agent_session?.kind === expected.agent_session?.kind
|
|
172
|
+
&& current.agent_session?.value === expected.agent_session?.value
|
|
173
|
+
&& (expected.terminal_id === undefined || current.terminal_id === expected.terminal_id)
|
|
174
|
+
&& (expected.name === undefined || current.name === expected.name);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return false;
|
|
178
|
+
} // unavailable is not proof of survival
|
|
179
|
+
};
|
|
180
|
+
const replacementAlive = await survives(paneId, replacement);
|
|
181
|
+
const sourceAlive = await survives(target.paneId, agent);
|
|
182
|
+
if (sourceAlive && replacementAlive)
|
|
183
|
+
await close(paneId);
|
|
184
|
+
// Re-read after cleanup too: its ACK can also be lost, or another actor can
|
|
185
|
+
// replace a pane. `live` is optional precisely when neither can be verified.
|
|
186
|
+
const live = await survives(paneId, replacement) ? paneId
|
|
187
|
+
: await survives(target.paneId, agent) ? target.paneId : null;
|
|
188
|
+
return failed('close_failed', live);
|
|
189
|
+
}
|
|
190
|
+
// The source is closed: a notification failure cannot undo a successful move.
|
|
191
|
+
try {
|
|
192
|
+
request?.onReplaced?.(paneId);
|
|
122
193
|
}
|
|
194
|
+
catch { /* notification only */ }
|
|
123
195
|
return { ok: true, session: paneId };
|
|
124
196
|
}
|
|
125
197
|
finally {
|
|
126
198
|
moving.delete(target.paneId);
|
|
127
199
|
}
|
|
128
200
|
}
|
|
129
|
-
|
|
201
|
+
async function move(o) {
|
|
202
|
+
const variable = o.kind === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME';
|
|
203
|
+
const result = await perform({ paneId: o.paneId }, {
|
|
204
|
+
provider: o.kind === 'claude' ? 'claude' : 'codex', folder: o.set[variable] ?? '',
|
|
205
|
+
env: o.set, timeoutMs: o.timeoutMs,
|
|
206
|
+
}, o);
|
|
207
|
+
return result.ok ? { ok: true, paneId: result.session } : result;
|
|
208
|
+
}
|
|
209
|
+
return { openSignInTab, move, moveToAccount: (target, o) => perform(target, o) };
|
|
130
210
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -2,4 +2,4 @@ export { HERDR_VERSION, HERDR_PROTOCOL } from './constants.ts';
|
|
|
2
2
|
export { HerdrKit } from './kit.ts';
|
|
3
3
|
export { agentWords, stateWords, words, WORDS, type WordKey } from './words.ts';
|
|
4
4
|
export { agentProbePath, extraPathDirs, runStatusCommand, agentInstallState, isAutoInstallShim, resolveAgentBinary, classifyStartFailure } from './agents.ts';
|
|
5
|
-
export type { AgentCliSignIn, AgentInstallProbe, AgentInstallState, AgentLaunchFailureReason, AgentReadiness, AgentStartEvent, AgentStatusOptions, AgentStatusRunner, AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrMethods, HerdrParams, HerdrProtocolRange, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, HerdrSubscribeStop, HerdrTransport, MoveToAccount, MoveResult, OpenSignInTab, PromptReceipt, StartAgent, TerminalSession, } from './types.ts';
|
|
5
|
+
export type { AgentCliSignIn, AgentInstallProbe, AgentInstallState, AgentLaunchFailureReason, AgentReadiness, AgentStartEvent, AgentStatusOptions, AgentStatusRunner, AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrMethods, HerdrParams, HerdrProtocolRange, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, HerdrSubscribeStop, HerdrTransport, Move, MoveToAccount, MoveToAccountResult, MoveResult, OpenSignInTab, PromptReceipt, StartAgent, TerminalSession, AgentTurnEnd, AgentTurnOptions, AgentTurnFiles, AgentTurnResult, AgentTurnResultPolicy, } from './types.ts';
|
package/dist/kit.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { AgentReadiness, AgentStartEvent, AgentStatusOptions, AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrParams, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, HerdrSubscribeStop, PromptReceipt, StartAgent, TerminalSession, MoveToAccount, MoveResult, OpenSignInTab } from './types.ts';
|
|
1
|
+
import type { AgentReadiness, AgentStartEvent, AgentStatusOptions, AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrParams, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, HerdrSubscribeStop, PromptReceipt, StartAgent, TerminalSession, Move, MoveToAccount, MoveToAccountResult, MoveResult, OpenSignInTab } from './types.ts';
|
|
2
|
+
import type { AgentTurnEnd, AgentTurnOptions } from './types.ts';
|
|
2
3
|
export declare class HerdrKit {
|
|
3
4
|
private readonly supervisor;
|
|
4
5
|
private transport?;
|
|
@@ -18,6 +19,7 @@ export declare class HerdrKit {
|
|
|
18
19
|
private readonly agents;
|
|
19
20
|
private readonly startListeners;
|
|
20
21
|
private readonly blockedList;
|
|
22
|
+
private readonly turns;
|
|
21
23
|
constructor(o: HerdrKitOptions);
|
|
22
24
|
get state(): HerdrState;
|
|
23
25
|
private publish;
|
|
@@ -39,6 +41,7 @@ export declare class HerdrKit {
|
|
|
39
41
|
subscribe<E extends HerdrEventName>(subs: HerdrSubscription<E>[], on: (e: HerdrEventOf<E>) => void, onError?: (code: string, message: string) => void): HerdrSubscribeStop;
|
|
40
42
|
cli(args: string[], o?: {
|
|
41
43
|
timeoutMs?: number;
|
|
44
|
+
env?: Record<string, string>;
|
|
42
45
|
}): Promise<{
|
|
43
46
|
stdout: string;
|
|
44
47
|
stderr: string;
|
|
@@ -68,7 +71,8 @@ export declare class HerdrKit {
|
|
|
68
71
|
*/
|
|
69
72
|
statusWatchReady(): Promise<void>;
|
|
70
73
|
openSignInTab(o: OpenSignInTab): Promise<AgentRef>;
|
|
71
|
-
|
|
74
|
+
move(o: Move): Promise<MoveResult>;
|
|
75
|
+
moveToAccount(target: AgentRef, o: MoveToAccount): Promise<MoveToAccountResult>;
|
|
72
76
|
startAgent(o: StartAgent): Promise<AgentRef>;
|
|
73
77
|
onStartAgent(fn: (e: AgentStartEvent) => void): () => void;
|
|
74
78
|
prompt(target: AgentRef, text: string, o?: {
|
|
@@ -77,6 +81,8 @@ export declare class HerdrKit {
|
|
|
77
81
|
timeoutMs: number;
|
|
78
82
|
};
|
|
79
83
|
}): Promise<PromptReceipt>;
|
|
84
|
+
runTurn<T = unknown>(target: AgentRef, o: AgentTurnOptions<T>): Promise<AgentTurnEnd<T>>;
|
|
85
|
+
onTurnEnd(fn: (end: AgentTurnEnd) => void): () => void;
|
|
80
86
|
sendKeys(target: AgentRef, keys: string[]): Promise<void>;
|
|
81
87
|
wait(target: AgentRef, o: {
|
|
82
88
|
until?: AgentStatus[];
|
package/dist/kit.js
CHANGED
|
@@ -6,6 +6,7 @@ import { openTerminal } from "./terminal.js";
|
|
|
6
6
|
import { protocolBounds } from "./constants.js";
|
|
7
7
|
import { closePane, closeTab, closeWorkspace } from "./close.js";
|
|
8
8
|
import { Supervisor } from "./supervise.js";
|
|
9
|
+
import { createTurns } from "./turns.js";
|
|
9
10
|
const kinds = ['pane.agent_detected', 'pane.created', 'pane.closed', 'pane.moved', 'pane.exited', 'pane.updated',
|
|
10
11
|
'workspace.created', 'workspace.closed', 'workspace.renamed', 'workspace.updated', 'tab.created', 'tab.closed', 'tab.renamed'];
|
|
11
12
|
const empty = () => ({ connected: false, workspaces: [] });
|
|
@@ -131,6 +132,7 @@ export class HerdrKit {
|
|
|
131
132
|
agents;
|
|
132
133
|
startListeners = new Set();
|
|
133
134
|
blockedList;
|
|
135
|
+
turns;
|
|
134
136
|
constructor(o) {
|
|
135
137
|
this.o = o;
|
|
136
138
|
this.supervisor = new Supervisor(o, (s) => { this.current = s; o.onState?.(s); });
|
|
@@ -143,6 +145,7 @@ export class HerdrKit {
|
|
|
143
145
|
}
|
|
144
146
|
catch { /* a listener never breaks a start */ } } });
|
|
145
147
|
this.blockedList = new Blocked({ call: this.callAny });
|
|
148
|
+
this.turns = createTurns(this);
|
|
146
149
|
}
|
|
147
150
|
get state() { return this.current; }
|
|
148
151
|
publish() { for (const fn of this.listeners)
|
|
@@ -396,6 +399,7 @@ export class HerdrKit {
|
|
|
396
399
|
}
|
|
397
400
|
}
|
|
398
401
|
async stop() {
|
|
402
|
+
this.turns.stop();
|
|
399
403
|
++this.generation;
|
|
400
404
|
this.statusReadyResolve?.();
|
|
401
405
|
this.statusReadyPromise = Promise.resolve();
|
|
@@ -427,7 +431,7 @@ export class HerdrKit {
|
|
|
427
431
|
throw new Error('herdr: not connected');
|
|
428
432
|
return this.transport.subscribe(subs, on, onError ?? (() => { }));
|
|
429
433
|
}
|
|
430
|
-
cli(args, o) { return runCli(this.o.bin, this.supervisor.env(), args, o?.timeoutMs); }
|
|
434
|
+
cli(args, o) { return runCli(this.o.bin, { ...this.supervisor.env(), ...o?.env }, args, o?.timeoutMs); }
|
|
431
435
|
terminal(paneId, o) { return openTerminal(this.o.bin, this.supervisor.env(), paneId, o); }
|
|
432
436
|
snapshot() { return structuredClone(this.tree); }
|
|
433
437
|
onChange(fn) { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }
|
|
@@ -447,6 +451,7 @@ export class HerdrKit {
|
|
|
447
451
|
*/
|
|
448
452
|
statusWatchReady() { return this.statusReadyPromise; }
|
|
449
453
|
openSignInTab(o) { return this.accountPanes.openSignInTab(o); }
|
|
454
|
+
move(o) { return this.accountPanes.move(o); }
|
|
450
455
|
moveToAccount(target, o) { return this.accountPanes.moveToAccount(target, o); }
|
|
451
456
|
startAgent(o) { return this.agents.startAgent(o); }
|
|
452
457
|
// App-wide start lifecycle without polling: every startAgent's installing/ready/launchFailed
|
|
@@ -456,6 +461,8 @@ export class HerdrKit {
|
|
|
456
461
|
return () => { this.startListeners.delete(fn); };
|
|
457
462
|
}
|
|
458
463
|
prompt(target, text, o) { return this.agents.prompt(target, text, o); }
|
|
464
|
+
runTurn(target, o) { return this.turns.runTurn(target, o); }
|
|
465
|
+
onTurnEnd(fn) { return this.turns.onTurnEnd(fn); }
|
|
459
466
|
sendKeys(target, keys) { return this.agents.sendKeys(target, keys); }
|
|
460
467
|
wait(target, o) { return this.agents.wait(target, o); }
|
|
461
468
|
read(paneId, o) { return this.agents.read(paneId, o); }
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AgentTurnEnd, AgentTurnFiles } from './types.ts';
|
|
2
|
+
export declare function positiveLimit(value: number | undefined, fallback: number): number;
|
|
3
|
+
export declare function scanFiles(root: string, policy: AgentTurnFiles | undefined, resultName: string, active: () => void): Promise<Map<string, string>>;
|
|
4
|
+
export declare function changedFiles(before: Map<string, string>, after: Map<string, string>): AgentTurnEnd['changedFiles'];
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, open, readdir, readlink, realpath } from 'node:fs/promises';
|
|
4
|
+
import { join, relative, sep } from 'node:path';
|
|
5
|
+
export function positiveLimit(value, fallback) {
|
|
6
|
+
const n = value ?? fallback;
|
|
7
|
+
if (!Number.isSafeInteger(n) || n <= 0)
|
|
8
|
+
throw new Error('turn: invalid limit');
|
|
9
|
+
return n;
|
|
10
|
+
}
|
|
11
|
+
// A baseline of the actual tree, independent of Git status and mtime granularity. Never traverse
|
|
12
|
+
// symlinks or read special files; a changing/unreadable tree fails rather than claiming a full diff.
|
|
13
|
+
export async function scanFiles(root, policy, resultName, active) {
|
|
14
|
+
const maxFiles = positiveLimit(policy?.maxFiles, 10000);
|
|
15
|
+
const maxBytes = positiveLimit(policy?.maxBytes, 128 * 1024 * 1024);
|
|
16
|
+
let entries = 0;
|
|
17
|
+
let bytes = 0;
|
|
18
|
+
const found = new Map();
|
|
19
|
+
async function walk(dir) {
|
|
20
|
+
active();
|
|
21
|
+
// Catch a directory being swapped for an out-of-tree symlink during a scan.
|
|
22
|
+
const canonical = await realpath(dir);
|
|
23
|
+
const within = relative(root, canonical);
|
|
24
|
+
if (canonical !== dir || within === '..' || within.startsWith(`..${sep}`))
|
|
25
|
+
throw new Error('turn: directory escaped');
|
|
26
|
+
for (const name of await readdir(dir)) {
|
|
27
|
+
active();
|
|
28
|
+
const path = join(dir, name);
|
|
29
|
+
const rel = relative(root, path).split(sep).join('/');
|
|
30
|
+
if (name === '.git' || rel === resultName || policy?.exclude?.(rel))
|
|
31
|
+
continue;
|
|
32
|
+
if (++entries > maxFiles)
|
|
33
|
+
throw new Error('turn: file limit');
|
|
34
|
+
const info = await lstat(path);
|
|
35
|
+
if (info.isSymbolicLink()) {
|
|
36
|
+
found.set(rel, `link:${await readlink(path)}`);
|
|
37
|
+
}
|
|
38
|
+
else if (info.isDirectory()) {
|
|
39
|
+
await walk(path);
|
|
40
|
+
}
|
|
41
|
+
else if (info.isFile()) {
|
|
42
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
43
|
+
try {
|
|
44
|
+
const before = await file.stat();
|
|
45
|
+
if (!before.isFile() || before.ino !== info.ino || before.dev !== info.dev)
|
|
46
|
+
throw new Error('turn: file changed');
|
|
47
|
+
const digest = createHash('sha256');
|
|
48
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
49
|
+
let total = 0;
|
|
50
|
+
for (;;) {
|
|
51
|
+
active();
|
|
52
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, null);
|
|
53
|
+
bytes += bytesRead;
|
|
54
|
+
total += bytesRead;
|
|
55
|
+
if (bytes > maxBytes)
|
|
56
|
+
throw new Error('turn: byte limit');
|
|
57
|
+
if (bytesRead === 0)
|
|
58
|
+
break;
|
|
59
|
+
digest.update(buffer.subarray(0, bytesRead));
|
|
60
|
+
}
|
|
61
|
+
const after = await file.stat();
|
|
62
|
+
if (before.size !== total || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) {
|
|
63
|
+
throw new Error('turn: file changed');
|
|
64
|
+
}
|
|
65
|
+
found.set(rel, `${before.mode}:${digest.digest('hex')}`);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
await file.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
await walk(root);
|
|
74
|
+
active();
|
|
75
|
+
return found;
|
|
76
|
+
}
|
|
77
|
+
export function changedFiles(before, after) {
|
|
78
|
+
const changes = [];
|
|
79
|
+
for (const path of [...new Set([...before.keys(), ...after.keys()])].sort()) {
|
|
80
|
+
if (!before.has(path))
|
|
81
|
+
changes.push({ path, change: 'added' });
|
|
82
|
+
else if (!after.has(path))
|
|
83
|
+
changes.push({ path, change: 'deleted' });
|
|
84
|
+
else if (before.get(path) !== after.get(path))
|
|
85
|
+
changes.push({ path, change: 'modified' });
|
|
86
|
+
}
|
|
87
|
+
return changes;
|
|
88
|
+
}
|
package/dist/turns.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { HerdrKit } from './kit.ts';
|
|
2
|
+
import type { AgentRef, AgentTurnEnd, AgentTurnOptions } from './types.ts';
|
|
3
|
+
export declare function createTurns(kit: HerdrKit): {
|
|
4
|
+
runTurn: <T>(target: AgentRef, o: AgentTurnOptions<T>) => Promise<AgentTurnEnd<T>>;
|
|
5
|
+
onTurnEnd(fn: (end: AgentTurnEnd) => void): () => void;
|
|
6
|
+
stop(): void;
|
|
7
|
+
};
|
package/dist/turns.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { open, realpath, unlink } from 'node:fs/promises';
|
|
4
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
5
|
+
import { changedFiles, positiveLimit, scanFiles } from "./turn-files.js";
|
|
6
|
+
import { words } from "./words.js";
|
|
7
|
+
const fail = (code) => Object.assign(new Error(words('turn.failed')), { code });
|
|
8
|
+
const contains = (parent, child) => {
|
|
9
|
+
const rel = relative(parent, child);
|
|
10
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
11
|
+
};
|
|
12
|
+
async function readResult(path, id, policy) {
|
|
13
|
+
const maxBytes = positiveLimit(policy.maxBytes, 1024 * 1024);
|
|
14
|
+
let file;
|
|
15
|
+
try {
|
|
16
|
+
file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
if (e.code === 'ENOENT')
|
|
20
|
+
return { state: 'missing' };
|
|
21
|
+
return { state: 'invalid', reason: 'unsafe-file' };
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const info = await file.stat();
|
|
25
|
+
if (!info.isFile())
|
|
26
|
+
return { state: 'invalid', reason: 'unsafe-file' };
|
|
27
|
+
if (info.size > maxBytes)
|
|
28
|
+
return { state: 'invalid', reason: 'too-large' };
|
|
29
|
+
const buffer = Buffer.alloc(Math.min(info.size + 1, maxBytes + 1));
|
|
30
|
+
let size = 0;
|
|
31
|
+
while (size < buffer.length) {
|
|
32
|
+
const { bytesRead } = await file.read(buffer, size, buffer.length - size, null);
|
|
33
|
+
if (!bytesRead)
|
|
34
|
+
break;
|
|
35
|
+
size += bytesRead;
|
|
36
|
+
}
|
|
37
|
+
if (size > maxBytes)
|
|
38
|
+
return { state: 'invalid', reason: 'too-large' };
|
|
39
|
+
const after = await file.stat();
|
|
40
|
+
if (size !== info.size || after.mtimeMs !== info.mtimeMs || after.ctimeMs !== info.ctimeMs) {
|
|
41
|
+
return { state: 'invalid', reason: 'format' };
|
|
42
|
+
}
|
|
43
|
+
let payload;
|
|
44
|
+
try {
|
|
45
|
+
payload = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, size)));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { state: 'invalid', reason: 'format' };
|
|
49
|
+
}
|
|
50
|
+
if (typeof payload !== 'object' || payload === null || Array.isArray(payload) ||
|
|
51
|
+
!('turnId' in payload) || payload.turnId !== id || !('result' in payload)) {
|
|
52
|
+
return { state: 'invalid', reason: 'format' };
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
if (policy.validate(payload.result))
|
|
56
|
+
return { state: 'valid', value: payload.result };
|
|
57
|
+
}
|
|
58
|
+
catch { /* Validator diagnostics may contain private data; never log them. */ }
|
|
59
|
+
return { state: 'invalid', reason: 'schema' };
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await file.close();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function createTurns(kit) {
|
|
66
|
+
const panes = new Set();
|
|
67
|
+
const roots = new Set();
|
|
68
|
+
const pending = new Set();
|
|
69
|
+
const listeners = new Set();
|
|
70
|
+
async function runTurn(target, o) {
|
|
71
|
+
if (panes.has(target.paneId))
|
|
72
|
+
throw fail('turn-busy');
|
|
73
|
+
if (!isAbsolute(o.cwd))
|
|
74
|
+
throw fail('turn-cwd');
|
|
75
|
+
const timeoutMs = positiveLimit(o.timeoutMs, 300000);
|
|
76
|
+
// Fail invalid configuration before delivering a prompt.
|
|
77
|
+
positiveLimit(o.files?.maxFiles, 10000);
|
|
78
|
+
positiveLimit(o.files?.maxBytes, 128 * 1024 * 1024);
|
|
79
|
+
if (o.result)
|
|
80
|
+
positiveLimit(o.result.maxBytes, 1024 * 1024);
|
|
81
|
+
const schema = o.result ? JSON.stringify(o.result.schema) : undefined;
|
|
82
|
+
const id = randomUUID();
|
|
83
|
+
const resultName = `.byokit-turn-${id}.json`;
|
|
84
|
+
let root;
|
|
85
|
+
let ownsRoot = false;
|
|
86
|
+
let failure;
|
|
87
|
+
let rejectFailure;
|
|
88
|
+
const failed = new Promise((_, reject) => { rejectFailure = reject; });
|
|
89
|
+
void failed.catch(() => { });
|
|
90
|
+
const abort = (code) => {
|
|
91
|
+
if (failure)
|
|
92
|
+
return;
|
|
93
|
+
failure = fail(code);
|
|
94
|
+
rejectFailure(failure);
|
|
95
|
+
};
|
|
96
|
+
const active = () => { if (failure)
|
|
97
|
+
throw failure; };
|
|
98
|
+
const gate = (promise) => Promise.race([promise, failed]);
|
|
99
|
+
const cancelled = () => abort('turn-cancelled');
|
|
100
|
+
const stopped = () => abort('turn-unavailable');
|
|
101
|
+
panes.add(target.paneId);
|
|
102
|
+
pending.add(stopped);
|
|
103
|
+
const timer = setTimeout(() => abort('turn-timeout'), timeoutMs);
|
|
104
|
+
o.signal?.addEventListener('abort', cancelled, { once: true });
|
|
105
|
+
if (o.signal?.aborted)
|
|
106
|
+
cancelled();
|
|
107
|
+
let stopWatch;
|
|
108
|
+
let offEvent;
|
|
109
|
+
let offChange;
|
|
110
|
+
try {
|
|
111
|
+
active();
|
|
112
|
+
root = await gate(realpath(o.cwd));
|
|
113
|
+
if ([...roots].some((held) => contains(held, root) || contains(root, held)))
|
|
114
|
+
throw fail('turn-busy');
|
|
115
|
+
roots.add(root);
|
|
116
|
+
ownsRoot = true;
|
|
117
|
+
if (!kit.snapshot().connected)
|
|
118
|
+
throw fail('turn-watch-lost');
|
|
119
|
+
offChange = kit.onChange((tree) => { if (!tree.connected)
|
|
120
|
+
abort('turn-watch-lost'); });
|
|
121
|
+
let armed = false;
|
|
122
|
+
let working = false;
|
|
123
|
+
let ended = false;
|
|
124
|
+
let resolveEnd;
|
|
125
|
+
const endSignal = new Promise((resolve) => { resolveEnd = resolve; });
|
|
126
|
+
const watch = kit.subscribe([{ type: 'pane.agent_status_changed', pane_id: target.paneId }], (e) => {
|
|
127
|
+
if (!armed) {
|
|
128
|
+
if (e.agent_status !== 'idle')
|
|
129
|
+
abort('turn-busy');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (ended) {
|
|
133
|
+
if (e.agent_status !== 'idle' && e.agent_status !== 'done')
|
|
134
|
+
abort('turn-busy');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (e.agent_status === 'working')
|
|
138
|
+
working = true;
|
|
139
|
+
else if (working && (e.agent_status === 'idle' || e.agent_status === 'done')) {
|
|
140
|
+
ended = true;
|
|
141
|
+
resolveEnd(e.agent_status);
|
|
142
|
+
}
|
|
143
|
+
}, () => abort('turn-watch-lost'));
|
|
144
|
+
stopWatch = watch;
|
|
145
|
+
watch.onDisconnect(() => abort('turn-watch-lost'));
|
|
146
|
+
offEvent = kit.onEvent((e) => {
|
|
147
|
+
const kind = e.type.replace(/_/g, '.');
|
|
148
|
+
if (e.pane_id === target.paneId && ['pane.closed', 'pane.exited', 'pane.moved', 'pane.agent.detected'].includes(kind)) {
|
|
149
|
+
abort('turn-unavailable');
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
if (!await gate(watch.ready))
|
|
153
|
+
throw fail('turn-watch-lost');
|
|
154
|
+
const beforeAgent = await gate(kit.call('agent.get', { target: target.paneId }));
|
|
155
|
+
const agent = beforeAgent.agent;
|
|
156
|
+
if (agent.agent_status !== 'idle' || agent.launch_pending === true)
|
|
157
|
+
throw fail('turn-not-idle');
|
|
158
|
+
if (target.name && agent.name !== target.name)
|
|
159
|
+
throw fail('turn-unavailable');
|
|
160
|
+
const cwd = agent.foreground_cwd ?? agent.cwd;
|
|
161
|
+
if (typeof cwd !== 'string' || await gate(realpath(cwd)) !== root)
|
|
162
|
+
throw fail('turn-cwd');
|
|
163
|
+
const before = await gate(scanFiles(root, o.files, resultName, active));
|
|
164
|
+
const prompt = o.result ? `${o.prompt}\n\nBefore finishing, write a UTF-8 JSON file at ${JSON.stringify(join(root, resultName))}. ` +
|
|
165
|
+
`The file must contain {"turnId":${JSON.stringify(id)},"result":<your result>}. ` +
|
|
166
|
+
`The result must satisfy this JSON Schema: ${schema}. Write the file completely before returning to idle.` : o.prompt;
|
|
167
|
+
active();
|
|
168
|
+
armed = true;
|
|
169
|
+
const receipt = await gate(kit.prompt(target, prompt));
|
|
170
|
+
const status = await gate(endSignal);
|
|
171
|
+
// Recheck the occupant/directory before reading an agent-created file.
|
|
172
|
+
const afterAgent = (await gate(kit.call('agent.get', { target: target.paneId }))).agent;
|
|
173
|
+
if (afterAgent.terminal_id !== receipt.terminalId || afterAgent.name !== agent.name ||
|
|
174
|
+
afterAgent.agent !== agent.agent || !['idle', 'done'].includes(afterAgent.agent_status) ||
|
|
175
|
+
await gate(realpath(afterAgent.foreground_cwd ?? afterAgent.cwd ?? '')) !== root)
|
|
176
|
+
throw fail('turn-unavailable');
|
|
177
|
+
const result = o.result
|
|
178
|
+
? await gate(readResult(join(root, resultName), id, o.result)) : { state: 'not-requested' };
|
|
179
|
+
const after = await gate(scanFiles(root, o.files, resultName, active));
|
|
180
|
+
active();
|
|
181
|
+
const end = { id, target: { ...target }, receipt, status, changedFiles: changedFiles(before, after), result };
|
|
182
|
+
// App callbacks cannot turn a completed turn into a failed call, and never see unchecked bytes.
|
|
183
|
+
for (const fn of [...listeners])
|
|
184
|
+
try {
|
|
185
|
+
fn(end);
|
|
186
|
+
}
|
|
187
|
+
catch { /* host listener */ }
|
|
188
|
+
try {
|
|
189
|
+
o.onEnd?.(end);
|
|
190
|
+
}
|
|
191
|
+
catch { /* per-turn listener */ }
|
|
192
|
+
return end;
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (failure)
|
|
196
|
+
throw failure;
|
|
197
|
+
const code = error?.code;
|
|
198
|
+
if (code?.startsWith('turn-'))
|
|
199
|
+
throw error;
|
|
200
|
+
throw fail('turn-failed');
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
o.signal?.removeEventListener('abort', cancelled);
|
|
205
|
+
stopWatch?.();
|
|
206
|
+
offEvent?.();
|
|
207
|
+
offChange?.();
|
|
208
|
+
pending.delete(stopped);
|
|
209
|
+
panes.delete(target.paneId);
|
|
210
|
+
if (ownsRoot && root) {
|
|
211
|
+
// Unlink only the kit's unique output path; never recursively delete a directory or follow a link.
|
|
212
|
+
if (o.result && await realpath(root).catch(() => undefined) === root) {
|
|
213
|
+
await unlink(join(root, resultName)).catch(() => { });
|
|
214
|
+
}
|
|
215
|
+
roots.delete(root);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
runTurn,
|
|
221
|
+
onTurnEnd(fn) { listeners.add(fn); return () => { listeners.delete(fn); }; },
|
|
222
|
+
stop() { for (const cancel of [...pending])
|
|
223
|
+
cancel(); },
|
|
224
|
+
};
|
|
225
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -132,7 +132,7 @@ export type MoveToAccount = {
|
|
|
132
132
|
direction?: 'right' | 'down';
|
|
133
133
|
timeoutMs?: number;
|
|
134
134
|
};
|
|
135
|
-
export type
|
|
135
|
+
export type MoveToAccountResult = {
|
|
136
136
|
ok: true;
|
|
137
137
|
session: string;
|
|
138
138
|
} | {
|
|
@@ -141,6 +141,22 @@ export type MoveResult = {
|
|
|
141
141
|
message: string;
|
|
142
142
|
live?: string;
|
|
143
143
|
};
|
|
144
|
+
export type Move = {
|
|
145
|
+
paneId: string;
|
|
146
|
+
kind: string;
|
|
147
|
+
args: string[];
|
|
148
|
+
set: Record<string, string>;
|
|
149
|
+
unset?: string[];
|
|
150
|
+
onStaged?(newPaneId: string): void;
|
|
151
|
+
onReplaced?(newPaneId: string): void;
|
|
152
|
+
timeoutMs?: number;
|
|
153
|
+
};
|
|
154
|
+
export type MoveResult = {
|
|
155
|
+
ok: true;
|
|
156
|
+
paneId: string;
|
|
157
|
+
} | Extract<MoveToAccountResult, {
|
|
158
|
+
ok: false;
|
|
159
|
+
}>;
|
|
144
160
|
export type PromptReceipt = {
|
|
145
161
|
paneId: string;
|
|
146
162
|
terminalId: string;
|
|
@@ -148,6 +164,47 @@ export type PromptReceipt = {
|
|
|
148
164
|
status: AgentStatus;
|
|
149
165
|
agentSession?: AgentSessionRef;
|
|
150
166
|
};
|
|
167
|
+
export type AgentTurnFiles = {
|
|
168
|
+
/** Relative paths, including directories. Returning true prunes the entry. `.git` is always excluded. */
|
|
169
|
+
exclude?: (relativePath: string) => boolean;
|
|
170
|
+
maxFiles?: number;
|
|
171
|
+
maxBytes?: number;
|
|
172
|
+
};
|
|
173
|
+
export type AgentTurnResultPolicy<T> = {
|
|
174
|
+
schema: Record<string, unknown>;
|
|
175
|
+
/** Use the app's JSON Schema validator; false or a throw rejects the payload. */
|
|
176
|
+
validate: (value: unknown) => value is T;
|
|
177
|
+
maxBytes?: number;
|
|
178
|
+
};
|
|
179
|
+
export type AgentTurnOptions<T = unknown> = {
|
|
180
|
+
prompt: string;
|
|
181
|
+
cwd: string;
|
|
182
|
+
timeoutMs?: number;
|
|
183
|
+
signal?: AbortSignal;
|
|
184
|
+
files?: AgentTurnFiles;
|
|
185
|
+
result?: AgentTurnResultPolicy<T>;
|
|
186
|
+
onEnd?: (end: AgentTurnEnd<T>) => void;
|
|
187
|
+
};
|
|
188
|
+
export type AgentTurnResult<T = unknown> = {
|
|
189
|
+
state: 'not-requested' | 'missing';
|
|
190
|
+
} | {
|
|
191
|
+
state: 'invalid';
|
|
192
|
+
reason: 'format' | 'schema' | 'too-large' | 'unsafe-file';
|
|
193
|
+
} | {
|
|
194
|
+
state: 'valid';
|
|
195
|
+
value: T;
|
|
196
|
+
};
|
|
197
|
+
export type AgentTurnEnd<T = unknown> = {
|
|
198
|
+
id: string;
|
|
199
|
+
target: AgentRef;
|
|
200
|
+
receipt: PromptReceipt;
|
|
201
|
+
status: 'idle' | 'done';
|
|
202
|
+
changedFiles: {
|
|
203
|
+
path: string;
|
|
204
|
+
change: 'added' | 'modified' | 'deleted';
|
|
205
|
+
}[];
|
|
206
|
+
result: AgentTurnResult<T>;
|
|
207
|
+
};
|
|
151
208
|
export type BlockedAgent = {
|
|
152
209
|
paneId: string;
|
|
153
210
|
workspaceId: string;
|
package/dist/words.json
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"move.busy": "Wait for this conversation to finish before moving it.",
|
|
24
24
|
"move.unsupported": "This conversation cannot move between these accounts.",
|
|
25
25
|
"move.env_mismatch": "The new pane did not receive that sign-in. Try again.",
|
|
26
|
-
"move.close_failed": "The
|
|
27
|
-
"move.start_failed": "The new account could not take over. Try again."
|
|
26
|
+
"move.close_failed": "The move could not be confirmed. Check the remaining panes before trying again.",
|
|
27
|
+
"move.start_failed": "The new account could not take over. Try again.",
|
|
28
|
+
"turn.failed": "This turn could not be confirmed. Check the helper before trying again."
|
|
28
29
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@byokit/herdr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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/link.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@byokit/link": "0.7.0",
|
|
56
|
-
"@byokit/relay": "0.5.
|
|
57
|
-
"@byokit/reach": "0.
|
|
56
|
+
"@byokit/relay": "0.5.1",
|
|
57
|
+
"@byokit/reach": "0.6.0",
|
|
58
58
|
"@byokit/seal": "0.2.0",
|
|
59
59
|
"ws": "8.21.3"
|
|
60
60
|
},
|