@byokit/herdr 0.2.0 → 0.4.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 +31 -0
- package/README.md +99 -10
- package/dist/accounts.d.ts +10 -0
- package/dist/accounts.js +182 -0
- package/dist/agents.d.ts +19 -1
- package/dist/agents.js +148 -36
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/kit.d.ts +8 -1
- package/dist/kit.js +20 -2
- package/dist/types.d.ts +62 -0
- package/dist/words.json +10 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.4.0 (2026-10-01)
|
|
6
|
+
|
|
7
|
+
- Dependency update: pins @byokit/reach 0.6.0.
|
|
8
|
+
|
|
9
|
+
- Add `move` with caller resume args, credential shedding, staging/replacement hooks and `MoveResult.paneId`.
|
|
10
|
+
- Add per-call `cli` env overlays for managed folder hook installation.
|
|
11
|
+
- Report surviving replacement panes when environment verification rollback fails; refuse folder moves for agents that ignore the folder variables.
|
|
12
|
+
|
|
13
|
+
## 0.3.0 (2026-10-01)
|
|
14
|
+
|
|
15
|
+
- Dependency update: pins @byokit/reach 0.5.0.
|
|
16
|
+
- Dependency update: pins @byokit/relay 0.5.0.
|
|
17
|
+
- Dependency update: pins @byokit/link 0.7.0.
|
|
18
|
+
|
|
19
|
+
- `PromptReceipt.agentSession` optionally identifies the conversation from the prompt response without an extra read.
|
|
20
|
+
|
|
21
|
+
- FIX: Refuse non-empty `StartAgent.env` on existing panes instead of silently using the old subscription.
|
|
22
|
+
- Move a conversation to another subscription account with `moveToAccount`: verify the target shell folder,
|
|
23
|
+
resume and wait before closing the original pane, and roll back the new pane on failure.
|
|
24
|
+
- Open each CLI’s own sign-in in an account-specific tab with `openSignInTab` and the host’s explicit env.
|
|
25
|
+
|
|
26
|
+
- `HerdrKit.startAgent` lifecycle events: `installing` (with the progress words) before a start
|
|
27
|
+
that needs an install, `ready` with the fresh ref, and `launchFailed` with a typed reason
|
|
28
|
+
(`placement-failed` | `pane-busy` | `install-failed` | `start-rejected`) plus plain words —
|
|
29
|
+
subscribable per call (`StartAgent.onEvent`) or app-wide (`kit.onStartAgent`), no polling.
|
|
30
|
+
The shape and rejection are unchanged. Install detection (`agentInstallState`, also on
|
|
31
|
+
`agentStatus` as `installState`) tells a real runnable binary apart from an auto-install
|
|
32
|
+
launcher such as a mise shim: shimmed or absent reads `installs-on-first-start`, sharing the
|
|
33
|
+
one probe path with `installedAgentKinds` (`resolveAgentBinary`, `isAutoInstallShim`).
|
|
34
|
+
|
|
35
|
+
|
|
5
36
|
## 0.2.0 (2026-09-30)
|
|
6
37
|
|
|
7
38
|
- `HerdrKit.agentStatus(kinds)` (B5): per-kind onboarding readiness — installed plus CLI
|
package/README.md
CHANGED
|
@@ -149,8 +149,8 @@ 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`, `prompt`, `sendKeys`, `wait`, `read`, `blocked`/`onBlocked`/`answer`, `closePane`/`closeTab`/`closeWorkspace`, `agentKinds`, `installedAgentKinds`, `agentStatus`, `terminal`, `onEvent`, `statusWatchReady`, and the pass-throughs `call`, `subscribe` and `cli` |
|
|
153
|
-
| `agentStatus`, `agentProbePath`, `extraPathDirs`, `runStatusCommand` (`@byokit/herdr`) | Onboarding readiness: per-kind install + CLI sign-in without a Herdr connection (see below) |
|
|
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
|
+
| `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 |
|
|
156
156
|
| `herdrLink` (`@byokit/herdr/link`) | The host side of the `hd.*` link ops, spread into `Host.open`; scopes each grant to its workspaces and can push sealed approval notices through a relay |
|
|
@@ -160,9 +160,72 @@ persistent grant store, and the phone page above.
|
|
|
160
160
|
| `startFakeHerdr`, `writeBinShim`, `herdrContract` (`@byokit/herdr/testing`) | The kit's stand-in Herdr, a bin shim that runs it, and the contract suite the kit passes |
|
|
161
161
|
| `ensureHerdr`, `fetchHerdr`, `HERDR_ASSETS` (`@byokit/herdr/binary`) | Opt-in pinned fetch: the v0.9.1 asset for the current platform into an app-owned dir, sha256-verified, executable, returned as the absolute `own`-mode `bin`; refused (nothing left) on a hash mismatch or unsupported platform |
|
|
162
162
|
|
|
163
|
+
`PromptReceipt` contains `paneId`, `terminalId`, `revision`, and `status`. Its optional
|
|
164
|
+
`agentSession` (`{ source, agent, kind, value }`) identifies the conversation that received
|
|
165
|
+
this prompt, taken directly from the `agent.prompt` response without another read.
|
|
166
|
+
`kind` distinguishes a session id from a session path; `value` is that id or path.
|
|
167
|
+
Herdr exposes no separate generation counter. When Herdr omits the session (or returns
|
|
168
|
+
null), `agentSession` is absent; callers can fall back to their existing checks.
|
|
169
|
+
|
|
163
170
|
Types (`HerdrKitOptions`, `HerdrState`, `StartAgent`, `PromptReceipt`, `BlockedAgent`, `HerdrSnapshot`,
|
|
164
171
|
`HerdrMethod`/`HerdrParams`/`HerdrResult`, `HerdrEventName`/`HerdrEventOf`, ...) come from the main entry.
|
|
165
172
|
|
|
173
|
+
## Account-specific sign-ins and moves
|
|
174
|
+
|
|
175
|
+
The host supplies account folders and shares conversation history as needed. The kit never reads or copies
|
|
176
|
+
credentials. To connect an account, open its CLI in a new tab and let the person complete the CLI's own login:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
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();
|
|
182
|
+
|
|
183
|
+
const signIn = await kit.openSignInTab({
|
|
184
|
+
workspaceId: 'w1', kind: 'codex', cwd: '/home/me/project', env: { CODEX_HOME: '/app/accounts/work' },
|
|
185
|
+
});
|
|
186
|
+
// Attach terminal(signIn.paneId, …) for the CLI's own sign-in screen.
|
|
187
|
+
const moved = await kit.moveToAccount({ paneId: 'w1:p2' }, {
|
|
188
|
+
provider: 'codex', folder: '/app/accounts/work',
|
|
189
|
+
});
|
|
190
|
+
if (moved.ok) console.log(moved.session); // new pane to follow
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
A move accepts Claude for Claude folders and Codex for Codex folders. Pi ignores these folder variables and is refused. It verifies the new shell's effective
|
|
194
|
+
account folder, resumes the conversation there, waits for a ready session, then closes the old pane. A failed
|
|
195
|
+
start preserves the original; a failed source close rolls back the new pane. Failures return a plain `message`
|
|
196
|
+
and a `live` pane id for recovery, including when cleanup fails. Wait until a conversation is idle or done before
|
|
197
|
+
moving it. `StartAgent.env` applies to newly created placements; existing shells cannot receive a new env.
|
|
198
|
+
Tokens stay on the device and are never logged. Each provider's own terms apply to how you use your plan.
|
|
199
|
+
|
|
200
|
+
For managed-folder stores that supply resume arguments and credential shedding, use `move`:
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
import { HerdrKit } from '@byokit/herdr';
|
|
204
|
+
const kit = new HerdrKit({ mode: 'adopt', bin: '/app/bin/herdr', socketPath: '/app/herdr.sock' });
|
|
205
|
+
await kit.start();
|
|
206
|
+
|
|
207
|
+
const conversationId = 'published-conversation-id';
|
|
208
|
+
const staged = new Set<string>();
|
|
209
|
+
let activePane = 'w1:p2';
|
|
210
|
+
const moved = await kit.move({
|
|
211
|
+
paneId: 'w1:p2', kind: 'codex', args: ['resume', conversationId],
|
|
212
|
+
set: { CODEX_HOME: '/app/accounts/work' }, unset: ['OPENAI_API_KEY', 'CODEX_API_KEY'],
|
|
213
|
+
onStaged(paneId) { staged.add(paneId); },
|
|
214
|
+
onReplaced(paneId) { staged.delete(paneId); activePane = paneId; },
|
|
215
|
+
});
|
|
216
|
+
if (moved.ok) console.log(moved.paneId);
|
|
217
|
+
await kit.cli(['integration', 'install', 'codex'], { env: { CODEX_HOME: '/app/accounts/work' } });
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
`MoveResult` names the new pane as `paneId`; `moveToAccount` retains its `session` result as
|
|
221
|
+
`MoveToAccountResult`. Both share the same per-source lock. Staging runs before verification/start; a staging
|
|
222
|
+
exception rolls back. Replacement notification runs after closing the source and cannot undo the move.
|
|
223
|
+
`move` clears `unset` in the new shell, verifies the account folder and unset names' absence, and requires an interactive
|
|
224
|
+
agent publishing a conversation before closing the source. The pinned start API has no command-prefix/env
|
|
225
|
+
field, so this uses shell `unset` rather than `env -u`; shells that cannot clear a variable fail closed.
|
|
226
|
+
Only folder paths belong in `set`, never tokens. `cli` env overlays the kit's explicit env for one call;
|
|
227
|
+
it inherits no process variables. The default move step timeout is 60 seconds.
|
|
228
|
+
|
|
166
229
|
## Two ways in
|
|
167
230
|
|
|
168
231
|
- `adopt` talks to the socket path the app passes: the person's own Herdr, nothing spawned, nothing written.
|
|
@@ -179,9 +242,12 @@ the extra install dirs a service PATH omits (`extraPathDirs`: `~/.local/bin`, th
|
|
|
179
242
|
`~/.npm-global/bin`, Homebrew and the system dirs — the set muxr probed before this kit did).
|
|
180
243
|
Sign-in comes only from each CLI's own documented non-secret status command, with a 10 s timeout;
|
|
181
244
|
a kind whose CLI has none, or whose command gives no answer, reads `unknown`. Credential files
|
|
182
|
-
are never opened, read or statted — only the signed-in boolean is kept.
|
|
183
|
-
|
|
184
|
-
|
|
245
|
+
are never opened, read or statted — only the signed-in boolean is kept. Install detection tells a
|
|
246
|
+
real runnable binary apart from an auto-install launcher: a mise-style shim on PATH, or nothing
|
|
247
|
+
on PATH at all, reads `installState: 'installs-on-first-start'` with `installed: false` — Herdr
|
|
248
|
+
fetches the agent on first start — instead of a missing-install error. That covers `pi`, which
|
|
249
|
+
Herdr installs via mise: a missing or shimmed `pi` reads `installed: false` with `installs on
|
|
250
|
+
first start`, while a real `pi` binary reads `installed: true`.
|
|
185
251
|
|
|
186
252
|
```ts
|
|
187
253
|
import { HerdrKit } from '@byokit/herdr';
|
|
@@ -193,10 +259,11 @@ console.log(await kit.agentStatus(['pi', 'claude', 'codex']));
|
|
|
193
259
|
|
|
194
260
|
```text
|
|
195
261
|
[
|
|
196
|
-
{ kind: 'pi', installed: false,
|
|
197
|
-
|
|
262
|
+
{ kind: 'pi', installed: false, installState: 'installs-on-first-start', signedIn: 'unknown',
|
|
263
|
+
installHint: 'installs on first start' },
|
|
264
|
+
{ kind: 'claude', installed: true, installState: 'installed', signedIn: 'yes',
|
|
198
265
|
installHint: 'Install the claude command, then check again.' },
|
|
199
|
-
{ kind: 'codex', installed: true, signedIn: 'no',
|
|
266
|
+
{ kind: 'codex', installed: true, installState: 'installed', signedIn: 'no',
|
|
200
267
|
installHint: 'Install the codex command, then check again.',
|
|
201
268
|
signInHint: 'On this computer run `codex`, sign in, then come back.' },
|
|
202
269
|
]
|
|
@@ -206,13 +273,35 @@ console.log(await kit.agentStatus(['pi', 'claude', 'codex']));
|
|
|
206
273
|
|---|---|---|
|
|
207
274
|
| `claude` | `claude auth status` (JSON `loggedIn`) | The CLI's own documented status; a signed-out CLI still prints its JSON, so only an empty answer reads `unknown`. |
|
|
208
275
|
| `codex` | `codex app-server` with a pipelined `initialize` + `account/read` round | Codex exposes sign-in only over its app-server protocol; a record `account` in the second answer means signed in. |
|
|
209
|
-
| `pi` | none (Herdr installs it via mise) |
|
|
276
|
+
| `pi` | none (Herdr installs it via mise) | Missing or shimmed: `installed: false`, `installs on first start`. A real binary reads `installed: true`. |
|
|
210
277
|
| any other kind | none | No documented non-secret status command, so `unknown` until the kit covers it. |
|
|
211
278
|
|
|
212
|
-
Options: `{ path, aliases }` as in `installedAgentKinds`; `{
|
|
279
|
+
Options: `{ path, aliases }` as in `installedAgentKinds`; `{ readFile }` overrides the shim
|
|
280
|
+
sniff's file-head reader (tests use fakes); `{ run }` injects the command runner
|
|
213
281
|
(`(command, args, { stdin, timeoutMs }) => Promise<{ stdout } | undefined>`, so tests use fakes);
|
|
214
282
|
`{ timeoutMs }` bounds each probe. `signInHint` rides kinds the kit knows how to check whenever they
|
|
215
283
|
are not signed in; `installHint` always rides along.
|
|
284
|
+
|
|
285
|
+
## Agent start lifecycle
|
|
286
|
+
|
|
287
|
+
`startAgent` keeps its typed shape and rejection, and additionally reports the launch as events
|
|
288
|
+
so an app shows `Installing…` instead of a blank start: `installing` (with the progress words)
|
|
289
|
+
before a start that needs an install, `ready` with the fresh ref, and `launchFailed` with a typed
|
|
290
|
+
`reason` (`placement-failed` | `pane-busy` | `install-failed` | `start-rejected`) plus plain words.
|
|
291
|
+
Subscribe per call with `onEvent`, or app-wide with `kit.onStartAgent` — no polling either way.
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
import { HerdrKit } from '@byokit/herdr';
|
|
295
|
+
|
|
296
|
+
const kit = new HerdrKit({ mode: 'adopt', bin: 'herdr', socketPath: '/tmp/herdr.sock' });
|
|
297
|
+
const off = kit.onStartAgent((e) => {
|
|
298
|
+
if (e.phase === 'installing') console.log(e.message); // Installing pi…
|
|
299
|
+
if (e.phase === 'launchFailed') console.log(e.reason, e.message);
|
|
300
|
+
});
|
|
301
|
+
await kit.startAgent({ kind: 'pi', cwd: '/home/me/project', place: { workspace: 'new' },
|
|
302
|
+
onEvent: (e) => { if (e.phase === 'ready') console.log(e.ref.paneId); } });
|
|
303
|
+
off();
|
|
304
|
+
```
|
|
216
305
|
`call` and `subscribe` are typed pass-throughs to the complete socket API, and `cli()` reaches the full CLI.
|
|
217
306
|
|
|
218
307
|
`start()` is non-fatal: if Herdr is down it rejects (state `failed`, e.g. `failed/socket`) and may be called again
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Call } from './agents.ts';
|
|
2
|
+
import type { AgentRef, Move, MoveResult, MoveToAccount, MoveToAccountResult, OpenSignInTab, StartAgent } from './types.ts';
|
|
3
|
+
export declare function createAccountPanes(ctx: {
|
|
4
|
+
call: Call;
|
|
5
|
+
startAgent(o: StartAgent): Promise<AgentRef>;
|
|
6
|
+
}): {
|
|
7
|
+
openSignInTab: (o: OpenSignInTab) => Promise<AgentRef>;
|
|
8
|
+
move: (o: Move) => Promise<MoveResult>;
|
|
9
|
+
moveToAccount: (target: AgentRef, o: MoveToAccount) => Promise<MoveToAccountResult>;
|
|
10
|
+
};
|
package/dist/accounts.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Account-specific CLI panes (D11/6.6). No credentials are read or copied. Moves follow
|
|
2
|
+
// the decided start-then-close order; only fake transports exercise this in tests.
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { words } from "./words.js";
|
|
5
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
|
+
export function createAccountPanes(ctx) {
|
|
7
|
+
const moving = new Set();
|
|
8
|
+
const call = ctx.call;
|
|
9
|
+
async function close(paneId) {
|
|
10
|
+
try {
|
|
11
|
+
await call('pane.close', { pane_id: paneId });
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
// A shell rc can override placement env. Verify what the shell actually resolves, rather
|
|
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, absent = false) {
|
|
21
|
+
const marker = `BYOKIT_ACCOUNT_${randomBytes(8).toString('hex')}`;
|
|
22
|
+
const deadline = Date.now() + timeout;
|
|
23
|
+
const expansion = absent ? '${' + variable + '+x}' : '$' + variable;
|
|
24
|
+
try {
|
|
25
|
+
await call('pane.send_text', { pane_id: paneId, text: `echo ${marker}="${expansion}"\n` }, timeout);
|
|
26
|
+
for (;;) {
|
|
27
|
+
const result = await call('pane.read', { pane_id: paneId, source: 'recent_unwrapped', lines: 40,
|
|
28
|
+
format: 'text', strip_ansi: true }, Math.max(1, deadline - Date.now()));
|
|
29
|
+
const readings = String(result?.read?.text ?? '').split('\n').map((line) => line.trim())
|
|
30
|
+
.filter((line) => line.startsWith(`${marker}=`)).map((line) => line.slice(marker.length + 1));
|
|
31
|
+
if (readings.length > 0)
|
|
32
|
+
return readings.includes(folder);
|
|
33
|
+
if (Date.now() >= deadline)
|
|
34
|
+
return false;
|
|
35
|
+
await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
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
|
+
}
|
|
64
|
+
async function openSignInTab(o) {
|
|
65
|
+
const { workspaceId, label, ...start } = o;
|
|
66
|
+
try {
|
|
67
|
+
return await ctx.startAgent({ ...start, place: { tab: 'new', workspaceId,
|
|
68
|
+
...(label === undefined ? {} : { label }) } });
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// A runtime error can contain account env. Never relay it to the sign-in screen.
|
|
72
|
+
throw Object.assign(new Error(words('agent.notReady')), { code: 'sign_in_failed' });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function perform(target, o, request) {
|
|
76
|
+
const failed = (code, live = target.paneId) => ({ ok: false, code, message: words(`move.${code}`), live });
|
|
77
|
+
if (moving.has(target.paneId))
|
|
78
|
+
return failed('busy');
|
|
79
|
+
moving.add(target.paneId);
|
|
80
|
+
try {
|
|
81
|
+
let agent;
|
|
82
|
+
try {
|
|
83
|
+
agent = (await call('agent.get', { target: target.paneId }))?.agent;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return failed('too_early');
|
|
87
|
+
}
|
|
88
|
+
const session = agent?.agent_session;
|
|
89
|
+
if (!agent || agent.launch_pending === true || !session || typeof session.value !== 'string'
|
|
90
|
+
|| session.value.length === 0)
|
|
91
|
+
return failed('too_early');
|
|
92
|
+
if (!['idle', 'done'].includes(agent.agent_status))
|
|
93
|
+
return failed('busy');
|
|
94
|
+
const kind = agent.agent;
|
|
95
|
+
if (!['claude', 'codex'].includes(o.provider) || kind !== o.provider
|
|
96
|
+
|| (request !== undefined && request.kind !== kind)
|
|
97
|
+
|| session.agent !== kind || session.kind !== 'id')
|
|
98
|
+
return failed('unsupported');
|
|
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)))
|
|
102
|
+
return failed('unsupported');
|
|
103
|
+
if (!o.folder || /[\r\n\0]/.test(o.folder))
|
|
104
|
+
return failed('env_mismatch');
|
|
105
|
+
const timeout = o.timeoutMs ?? 60_000;
|
|
106
|
+
if (!Number.isFinite(timeout) || timeout <= 0)
|
|
107
|
+
return failed('start_failed');
|
|
108
|
+
const variable = o.provider === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME';
|
|
109
|
+
let paneId;
|
|
110
|
+
try {
|
|
111
|
+
const split = await call('pane.split', { target_pane_id: target.paneId,
|
|
112
|
+
direction: o.direction ?? 'right', focus: false,
|
|
113
|
+
...(typeof (agent.foreground_cwd ?? agent.cwd) === 'string' ? { cwd: agent.foreground_cwd ?? agent.cwd } : {}),
|
|
114
|
+
env: { ...o.env, [variable]: o.folder } }, timeout);
|
|
115
|
+
if (typeof split?.pane?.pane_id !== 'string')
|
|
116
|
+
return failed('start_failed');
|
|
117
|
+
paneId = split.pane.pane_id;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return failed('start_failed');
|
|
121
|
+
}
|
|
122
|
+
const rollback = async (code) => failed(code, await close(paneId) ? target.paneId : paneId);
|
|
123
|
+
try {
|
|
124
|
+
request?.onStaged?.(paneId);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return rollback('start_failed');
|
|
128
|
+
}
|
|
129
|
+
if (request !== undefined && !await prepareEnv(paneId, request.set, request.unset ?? [], variable, timeout)) {
|
|
130
|
+
return rollback('env_mismatch');
|
|
131
|
+
}
|
|
132
|
+
if (request === undefined && !await checkEnv(paneId, variable, o.folder, timeout)) {
|
|
133
|
+
return rollback('env_mismatch');
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
// Unique names avoid colliding with the still-live source agent.
|
|
137
|
+
await ctx.startAgent({ kind, cwd: agent.foreground_cwd ?? agent.cwd ?? '.',
|
|
138
|
+
name: `move-${randomBytes(8).toString('hex')}`, place: { pane: paneId }, args, timeoutMs: timeout });
|
|
139
|
+
await call('agent.wait', { target: paneId, until: ['idle', 'done'], timeout_ms: timeout }, timeout + 5000);
|
|
140
|
+
const deadline = Date.now() + timeout;
|
|
141
|
+
for (;;) {
|
|
142
|
+
const ready = (await call('agent.get', { target: paneId }, Math.max(1, deadline - Date.now())))?.agent;
|
|
143
|
+
if (ready && ready.launch_pending !== true && ['idle', 'done'].includes(ready.agent_status)
|
|
144
|
+
&& (request === undefined || ready.interactive_ready === true)
|
|
145
|
+
&& ready.agent === kind && ready.agent_session?.agent === kind
|
|
146
|
+
&& ready.agent_session?.kind === session.kind && typeof ready.agent_session?.value === 'string'
|
|
147
|
+
&& ready.agent_session.value.length > 0)
|
|
148
|
+
break;
|
|
149
|
+
if (Date.now() >= deadline)
|
|
150
|
+
throw new Error('not ready');
|
|
151
|
+
await sleep(Math.min(100, Math.max(1, deadline - Date.now())));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
const cleaned = await close(paneId);
|
|
156
|
+
return failed('start_failed', cleaned ? target.paneId : paneId);
|
|
157
|
+
}
|
|
158
|
+
if (!await close(target.paneId)) {
|
|
159
|
+
const cleaned = await close(paneId);
|
|
160
|
+
return failed('close_failed', cleaned ? target.paneId : paneId);
|
|
161
|
+
}
|
|
162
|
+
// The source is closed: a notification failure cannot undo a successful move.
|
|
163
|
+
try {
|
|
164
|
+
request?.onReplaced?.(paneId);
|
|
165
|
+
}
|
|
166
|
+
catch { /* notification only */ }
|
|
167
|
+
return { ok: true, session: paneId };
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
moving.delete(target.paneId);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function move(o) {
|
|
174
|
+
const variable = o.kind === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME';
|
|
175
|
+
const result = await perform({ paneId: o.paneId }, {
|
|
176
|
+
provider: o.kind === 'claude' ? 'claude' : 'codex', folder: o.set[variable] ?? '',
|
|
177
|
+
env: o.set, timeoutMs: o.timeoutMs,
|
|
178
|
+
}, o);
|
|
179
|
+
return result.ok ? { ok: true, paneId: result.session } : result;
|
|
180
|
+
}
|
|
181
|
+
return { openSignInTab, move, moveToAccount: (target, o) => perform(target, o) };
|
|
182
|
+
}
|
package/dist/agents.d.ts
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
1
|
import type { HerdrKit } from './kit.ts';
|
|
2
|
-
import type { HerdrSnapshot } from './types.ts';
|
|
2
|
+
import type { AgentInstallProbe, AgentInstallState, AgentLaunchFailureReason, AgentStartEvent, HerdrSnapshot } from './types.ts';
|
|
3
3
|
export type Call = (method: string, params: Record<string, unknown>, timeoutMs?: number) => Promise<unknown>;
|
|
4
4
|
export declare function createAgents(ctx: {
|
|
5
5
|
call: Call;
|
|
6
6
|
snapshot(): HerdrSnapshot;
|
|
7
7
|
reread(paneId: string): Promise<void>;
|
|
8
|
+
emitStart?(e: AgentStartEvent): void;
|
|
8
9
|
}): Pick<HerdrKit, 'startAgent' | 'prompt' | 'sendKeys' | 'wait' | 'read' | 'agentKinds' | 'installedAgentKinds' | 'agentStatus'>;
|
|
10
|
+
export declare function resolveAgentBinary(kind: string, o: {
|
|
11
|
+
path: string[];
|
|
12
|
+
aliases?: Record<string, string[]>;
|
|
13
|
+
}): {
|
|
14
|
+
binary: string;
|
|
15
|
+
path?: string;
|
|
16
|
+
};
|
|
17
|
+
export declare function isAutoInstallShim(file: string, readFile?: (file: string) => string | undefined): boolean;
|
|
18
|
+
export declare function agentInstallState(kind: string, o?: AgentInstallProbe): {
|
|
19
|
+
kind: string;
|
|
20
|
+
state: AgentInstallState;
|
|
21
|
+
path?: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function classifyStartFailure(error: unknown, o?: {
|
|
24
|
+
installExpected?: boolean;
|
|
25
|
+
stage?: 'placement' | 'start';
|
|
26
|
+
}): AgentLaunchFailureReason;
|
|
9
27
|
export declare function extraPathDirs(home?: string): string[];
|
|
10
28
|
export declare function agentProbePath(path?: string[], home?: string): string[];
|
|
11
29
|
export declare function runStatusCommand(command: string, args: string[], o?: {
|
package/dist/agents.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// disagree the schema wins. `pane.split` takes `target_pane_id` (muxr agrees), and `agent.start`
|
|
4
4
|
// has no `env` param — a start's env belongs to the placement create/split call (src/generated/methods.ts).
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
|
-
import { accessSync, constants as fsConstants } from 'node:fs';
|
|
6
|
+
import { accessSync, constants as fsConstants, openSync, readSync, closeSync } from 'node:fs';
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
8
|
import { delimiter, join } from 'node:path';
|
|
9
|
+
import { words } from "./words.js";
|
|
9
10
|
const PROMPTABLE = ['idle', 'working', 'blocked', 'done'];
|
|
10
11
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
11
12
|
// `agent.start` lands while the fresh pane is still at its shell prompt: the server answers
|
|
@@ -43,10 +44,50 @@ function rootPaneOf(result) {
|
|
|
43
44
|
}
|
|
44
45
|
export function createAgents(ctx) {
|
|
45
46
|
const call = ctx.call;
|
|
47
|
+
const emitStart = (e) => {
|
|
48
|
+
try {
|
|
49
|
+
ctx.emitStart?.(e);
|
|
50
|
+
}
|
|
51
|
+
catch { /* a listener never breaks a start */ }
|
|
52
|
+
};
|
|
46
53
|
// Every placement takes its pane id from the server's answer, never a prediction (6.4).
|
|
47
54
|
// Placements that make a pane (every path but `pane`) roll it back with `pane.close` when the
|
|
48
55
|
// start fails, so a failed start leaves no extra pane behind.
|
|
49
56
|
async function startAgent(o) {
|
|
57
|
+
const emit = (e) => {
|
|
58
|
+
try {
|
|
59
|
+
o.onEvent?.(e);
|
|
60
|
+
}
|
|
61
|
+
catch { /* a listener never breaks a start */ }
|
|
62
|
+
emitStart(e);
|
|
63
|
+
};
|
|
64
|
+
// Install pre-check: a shim or nothing on PATH means Herdr installs on first start, so the
|
|
65
|
+
// app hears `installing` before the long start instead of watching a blank pane.
|
|
66
|
+
let installExpected = false;
|
|
67
|
+
try {
|
|
68
|
+
installExpected = agentInstallState(o.kind, o.installProbe).state === 'installs-on-first-start';
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
installExpected = false;
|
|
72
|
+
}
|
|
73
|
+
if (installExpected)
|
|
74
|
+
emit({ phase: 'installing', kind: o.kind, message: words('agent.installing', { agent: o.kind }) });
|
|
75
|
+
let stage = 'placement';
|
|
76
|
+
try {
|
|
77
|
+
const ref = await startAgentInner(o, () => { stage = 'start'; });
|
|
78
|
+
emit({ phase: 'ready', kind: o.kind, ref });
|
|
79
|
+
return ref;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
const reason = classifyStartFailure(error, { installExpected, stage });
|
|
83
|
+
emit({ phase: 'launchFailed', kind: o.kind, reason, message: launchFailureWords(reason, o.kind) });
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function startAgentInner(o, onStartPhase) {
|
|
88
|
+
if ('pane' in o.place && Object.keys(o.env ?? {}).length > 0) {
|
|
89
|
+
throw fail('env_mismatch', 'This pane needs to be opened again to use that sign-in.');
|
|
90
|
+
}
|
|
50
91
|
const timeout = o.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
51
92
|
const env = o.env === undefined ? {} : { env: o.env };
|
|
52
93
|
let paneId;
|
|
@@ -86,6 +127,7 @@ export function createAgents(ctx) {
|
|
|
86
127
|
}
|
|
87
128
|
if (paneId === undefined)
|
|
88
129
|
throw new Error('herdr: the placement answered no pane id');
|
|
130
|
+
onStartPhase?.();
|
|
89
131
|
const name = agentName(o);
|
|
90
132
|
const params = { pane_id: paneId, kind: o.kind, name,
|
|
91
133
|
...(o.args === undefined ? {} : { args: o.args }), timeout_ms: timeout };
|
|
@@ -152,7 +194,12 @@ export function createAgents(ctx) {
|
|
|
152
194
|
|| a.pane_id !== target.paneId) {
|
|
153
195
|
throw new Error('Herdr did not queue the prompt.');
|
|
154
196
|
}
|
|
155
|
-
|
|
197
|
+
const session = a.agent_session;
|
|
198
|
+
const agentSession = isObj(session) && typeof session.source === 'string'
|
|
199
|
+
&& typeof session.agent === 'string' && typeof session.kind === 'string' && typeof session.value === 'string'
|
|
200
|
+
? { source: session.source, agent: session.agent, kind: session.kind, value: session.value } : undefined;
|
|
201
|
+
return { paneId: a.pane_id, terminalId: a.terminal_id, revision: a.revision, status: a.agent_status,
|
|
202
|
+
...(agentSession === undefined ? {} : { agentSession }) };
|
|
156
203
|
}
|
|
157
204
|
async function wait(target, o) {
|
|
158
205
|
const made = await call('agent.wait', { target: target.paneId,
|
|
@@ -176,58 +223,123 @@ export function createAgents(ctx) {
|
|
|
176
223
|
return manifests.map((m) => (typeof m?.agent === 'string' ? m.agent : '')).filter((kind) => kind !== '');
|
|
177
224
|
}
|
|
178
225
|
function installedAgentKinds(kinds, o) {
|
|
179
|
-
|
|
180
|
-
for (const kind of kinds) {
|
|
181
|
-
const names = [kind, ...(o.aliases?.[kind] ?? [])];
|
|
182
|
-
if (names.some((name) => o.path.some((dir) => {
|
|
183
|
-
try {
|
|
184
|
-
accessSync(join(dir, name), fsConstants.X_OK);
|
|
185
|
-
return true;
|
|
186
|
-
}
|
|
187
|
-
catch {
|
|
188
|
-
return false;
|
|
189
|
-
}
|
|
190
|
-
})))
|
|
191
|
-
installed.push(kind);
|
|
192
|
-
}
|
|
193
|
-
return installed;
|
|
194
|
-
}
|
|
195
|
-
// B5: the extra install dirs muxr's `executableOnPath` probes past PATH (a GUI daemon's
|
|
196
|
-
// inherited PATH omits them), moved into the kit so the host probe is trusted.
|
|
197
|
-
function installedSet(kinds, o) {
|
|
198
|
-
return new Set(installedAgentKinds(kinds.filter((k) => k !== 'pi'), o));
|
|
226
|
+
return kinds.filter((kind) => resolveAgentBinary(kind, o).path !== undefined);
|
|
199
227
|
}
|
|
200
228
|
async function agentStatus(kinds, o) {
|
|
201
229
|
const path = o?.path ?? agentProbePath();
|
|
202
230
|
const run = o?.run ?? runStatusCommand;
|
|
203
231
|
const timeoutMs = o?.timeoutMs ?? STATUS_TIMEOUT_MS;
|
|
204
|
-
const
|
|
232
|
+
const probe = { path, ...(o?.aliases === undefined ? {} : { aliases: o.aliases }),
|
|
233
|
+
...(o?.readFile === undefined ? {} : { readFile: o.readFile }) };
|
|
205
234
|
return Promise.all(kinds.map(async (kind) => {
|
|
206
|
-
|
|
207
|
-
// Pi always reads not-installed with the readiness words instead of a missing-install error.
|
|
208
|
-
if (kind === 'pi')
|
|
209
|
-
return { kind, installed: false, signedIn: 'unknown', installHint: PI_INSTALL_HINT };
|
|
235
|
+
const install = agentInstallState(kind, probe);
|
|
210
236
|
const binary = o?.aliases?.[kind]?.[0] ?? kind;
|
|
211
|
-
const installHint = `Install the ${binary} command, then check again.`;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (
|
|
216
|
-
return { kind, installed:
|
|
237
|
+
const installHint = kind === 'pi' ? PI_INSTALL_HINT : `Install the ${binary} command, then check again.`;
|
|
238
|
+
// A shim or nothing on PATH means Herdr installs on first start: not installed, with the
|
|
239
|
+
// readiness words instead of a missing-install error (pi included — a real pi binary is
|
|
240
|
+
// the only case that reads installed).
|
|
241
|
+
if (install.state !== 'installed')
|
|
242
|
+
return { kind, installed: false, installState: install.state, signedIn: 'unknown', installHint };
|
|
243
|
+
const statusProbe = STATUS_PROBES[kind];
|
|
244
|
+
if (statusProbe === undefined)
|
|
245
|
+
return { kind, installed: true, installState: 'installed', signedIn: 'unknown', installHint };
|
|
217
246
|
let answer;
|
|
218
247
|
try {
|
|
219
|
-
answer = await run(
|
|
248
|
+
answer = await run(statusProbe.command, statusProbe.args, { ...(statusProbe.stdin === undefined ? {} : { stdin: statusProbe.stdin }), timeoutMs });
|
|
220
249
|
}
|
|
221
250
|
catch {
|
|
222
251
|
answer = undefined;
|
|
223
252
|
}
|
|
224
|
-
const signedIn = answer === undefined ? 'unknown' :
|
|
225
|
-
return { kind, installed: true, signedIn, installHint,
|
|
253
|
+
const signedIn = answer === undefined ? 'unknown' : statusProbe.parse(answer.stdout);
|
|
254
|
+
return { kind, installed: true, installState: 'installed', signedIn, installHint,
|
|
226
255
|
...(signedIn === 'yes' ? {} : { signInHint: SIGNIN_HINTS[kind] ?? `Sign in to ${kind} on this computer, then check again.` }) };
|
|
227
256
|
}));
|
|
228
257
|
}
|
|
229
258
|
return { startAgent, prompt, sendKeys, wait, read, agentKinds, installedAgentKinds, agentStatus };
|
|
230
259
|
}
|
|
260
|
+
// One source of truth for install detection (B5's probe plus the shim layer below): the first
|
|
261
|
+
// executable file named for the kind across the probe path, with its absolute path.
|
|
262
|
+
export function resolveAgentBinary(kind, o) {
|
|
263
|
+
const names = [kind, ...(o.aliases?.[kind] ?? [])];
|
|
264
|
+
for (const name of names) {
|
|
265
|
+
for (const dir of o.path) {
|
|
266
|
+
if (dir === '')
|
|
267
|
+
continue;
|
|
268
|
+
const file = join(dir, name);
|
|
269
|
+
try {
|
|
270
|
+
accessSync(file, fsConstants.X_OK);
|
|
271
|
+
return { binary: name, path: file };
|
|
272
|
+
}
|
|
273
|
+
catch { /* keep looking */ }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return { binary: o.aliases?.[kind]?.[0] ?? kind };
|
|
277
|
+
}
|
|
278
|
+
// Bounded head read for the shim sniff: the first 2 KiB as text, undefined when unreadable.
|
|
279
|
+
function readHead(file) {
|
|
280
|
+
let fd;
|
|
281
|
+
try {
|
|
282
|
+
fd = openSync(file, 'r');
|
|
283
|
+
const buf = Buffer.alloc(2048);
|
|
284
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
285
|
+
return buf.subarray(0, n).toString('utf8');
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
if (fd !== undefined)
|
|
292
|
+
try {
|
|
293
|
+
closeSync(fd);
|
|
294
|
+
}
|
|
295
|
+
catch { /* already gone */ }
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// A mise-style auto-install launcher reads as a shell script that execs through mise — the
|
|
299
|
+
// found "binary" runs, but the first run installs the agent instead. Real binaries (ELF,
|
|
300
|
+
// Mach-O) never match: the shebang gate keeps a stray "mise" substring ("promise", …) out.
|
|
301
|
+
export function isAutoInstallShim(file, readFile = readHead) {
|
|
302
|
+
let head;
|
|
303
|
+
try {
|
|
304
|
+
head = readFile(file);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
return head !== undefined && head.startsWith('#!') && head.includes('mise');
|
|
310
|
+
}
|
|
311
|
+
// Per-kind install readiness sharing the B5 probe path: `installed` for a real runnable binary,
|
|
312
|
+
// `installs-on-first-start` for an auto-install launcher/shim or nothing on PATH (Herdr fetches
|
|
313
|
+
// the agent on first start). `missing` stays for callers that probe kinds Herdr never installs.
|
|
314
|
+
export function agentInstallState(kind, o) {
|
|
315
|
+
const path = o?.path ?? agentProbePath();
|
|
316
|
+
const found = resolveAgentBinary(kind, { path, ...(o?.aliases === undefined ? {} : { aliases: o.aliases }) });
|
|
317
|
+
if (found.path === undefined)
|
|
318
|
+
return { kind, state: 'installs-on-first-start' };
|
|
319
|
+
const shim = isAutoInstallShim(found.path, o?.readFile);
|
|
320
|
+
return shim ? { kind, state: 'installs-on-first-start', path: found.path }
|
|
321
|
+
: { kind, state: 'installed', path: found.path };
|
|
322
|
+
}
|
|
323
|
+
// Typed launch failure for the `launchFailed` event. The rejection itself is unchanged — this
|
|
324
|
+
// only classifies it: a busy pane that outlasted the retry budget, an install that never
|
|
325
|
+
// finished, a placement that made no pane, or any other rejected start.
|
|
326
|
+
export function classifyStartFailure(error, o) {
|
|
327
|
+
const code = codeOf(error);
|
|
328
|
+
if (code !== undefined && START_RETRYABLE.has(code))
|
|
329
|
+
return 'pane-busy';
|
|
330
|
+
if (o?.stage === 'placement')
|
|
331
|
+
return 'placement-failed';
|
|
332
|
+
if (o?.installExpected === true)
|
|
333
|
+
return 'install-failed';
|
|
334
|
+
return 'start-rejected';
|
|
335
|
+
}
|
|
336
|
+
function launchFailureWords(reason, kind) {
|
|
337
|
+
if (reason === 'pane-busy')
|
|
338
|
+
return words('agent.notReady');
|
|
339
|
+
if (reason === 'install-failed')
|
|
340
|
+
return words('agent.installFailed', { agent: kind });
|
|
341
|
+
return words('agent.launchFailed');
|
|
342
|
+
}
|
|
231
343
|
// Extra install dirs muxr's `executableOnPath`
|
|
232
344
|
// (`apps/host/src/agent/infrastructure/herdrSessionSource.ts`) probes past PATH: user-install
|
|
233
345
|
// dirs a service or GUI daemon's inherited PATH omits — mise shims (Herdr installs Pi through
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
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
|
-
export { agentProbePath, extraPathDirs, runStatusCommand } from './agents.ts';
|
|
5
|
-
export type { AgentCliSignIn, AgentReadiness, AgentStatusOptions, AgentStatusRunner, AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrMethods, HerdrParams, HerdrProtocolRange, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, HerdrSubscribeStop, HerdrTransport, PromptReceipt, StartAgent, TerminalSession, } from './types.ts';
|
|
4
|
+
export { agentProbePath, extraPathDirs, runStatusCommand, agentInstallState, isAutoInstallShim, resolveAgentBinary, classifyStartFailure } from './agents.ts';
|
|
5
|
+
export type { AgentCliSignIn, AgentInstallProbe, AgentInstallState, AgentLaunchFailureReason, AgentReadiness, AgentStartEvent, AgentStatusOptions, AgentStatusRunner, AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrMethods, HerdrParams, HerdrProtocolRange, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, HerdrSubscribeStop, HerdrTransport, Move, MoveToAccount, MoveToAccountResult, MoveResult, OpenSignInTab, PromptReceipt, StartAgent, TerminalSession, } from './types.ts';
|
package/dist/index.js
CHANGED
|
@@ -3,4 +3,4 @@
|
|
|
3
3
|
export { HERDR_VERSION, HERDR_PROTOCOL } from "./constants.js";
|
|
4
4
|
export { HerdrKit } from "./kit.js";
|
|
5
5
|
export { agentWords, stateWords, words, WORDS } from "./words.js";
|
|
6
|
-
export { agentProbePath, extraPathDirs, runStatusCommand } from "./agents.js";
|
|
6
|
+
export { agentProbePath, extraPathDirs, runStatusCommand, agentInstallState, isAutoInstallShim, resolveAgentBinary, classifyStartFailure } from "./agents.js";
|
package/dist/kit.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentReadiness, AgentStatusOptions, AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrParams, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, HerdrSubscribeStop, PromptReceipt, StartAgent, TerminalSession } 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
2
|
export declare class HerdrKit {
|
|
3
3
|
private readonly supervisor;
|
|
4
4
|
private transport?;
|
|
@@ -14,7 +14,9 @@ export declare class HerdrKit {
|
|
|
14
14
|
private statusEpoch;
|
|
15
15
|
private readonly o;
|
|
16
16
|
private readonly callAny;
|
|
17
|
+
private readonly accountPanes;
|
|
17
18
|
private readonly agents;
|
|
19
|
+
private readonly startListeners;
|
|
18
20
|
private readonly blockedList;
|
|
19
21
|
constructor(o: HerdrKitOptions);
|
|
20
22
|
get state(): HerdrState;
|
|
@@ -37,6 +39,7 @@ export declare class HerdrKit {
|
|
|
37
39
|
subscribe<E extends HerdrEventName>(subs: HerdrSubscription<E>[], on: (e: HerdrEventOf<E>) => void, onError?: (code: string, message: string) => void): HerdrSubscribeStop;
|
|
38
40
|
cli(args: string[], o?: {
|
|
39
41
|
timeoutMs?: number;
|
|
42
|
+
env?: Record<string, string>;
|
|
40
43
|
}): Promise<{
|
|
41
44
|
stdout: string;
|
|
42
45
|
stderr: string;
|
|
@@ -65,7 +68,11 @@ export declare class HerdrKit {
|
|
|
65
68
|
* status events instead of opening a second watch.
|
|
66
69
|
*/
|
|
67
70
|
statusWatchReady(): Promise<void>;
|
|
71
|
+
openSignInTab(o: OpenSignInTab): Promise<AgentRef>;
|
|
72
|
+
move(o: Move): Promise<MoveResult>;
|
|
73
|
+
moveToAccount(target: AgentRef, o: MoveToAccount): Promise<MoveToAccountResult>;
|
|
68
74
|
startAgent(o: StartAgent): Promise<AgentRef>;
|
|
75
|
+
onStartAgent(fn: (e: AgentStartEvent) => void): () => void;
|
|
69
76
|
prompt(target: AgentRef, text: string, o?: {
|
|
70
77
|
wait?: {
|
|
71
78
|
until?: AgentStatus[];
|
package/dist/kit.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createAccountPanes } from "./accounts.js";
|
|
1
2
|
import { createAgents } from "./agents.js";
|
|
2
3
|
import { Blocked } from "./approvals.js";
|
|
3
4
|
import { runCli } from "./cli.js";
|
|
@@ -126,13 +127,21 @@ export class HerdrKit {
|
|
|
126
127
|
// a snapshot read wins over the read (see bootstrap)
|
|
127
128
|
o;
|
|
128
129
|
callAny = (method, params, timeoutMs) => this.call(method, params, timeoutMs === undefined ? undefined : { timeoutMs });
|
|
130
|
+
accountPanes;
|
|
129
131
|
agents;
|
|
132
|
+
startListeners = new Set();
|
|
130
133
|
blockedList;
|
|
131
134
|
constructor(o) {
|
|
132
135
|
this.o = o;
|
|
133
136
|
this.supervisor = new Supervisor(o, (s) => { this.current = s; o.onState?.(s); });
|
|
137
|
+
this.accountPanes = createAccountPanes({ call: this.callAny, startAgent: (o) => this.startAgent(o) });
|
|
134
138
|
this.agents = createAgents({ call: this.callAny, snapshot: () => this.snapshot(),
|
|
135
|
-
reread: (paneId) => this.reread(paneId)
|
|
139
|
+
reread: (paneId) => this.reread(paneId),
|
|
140
|
+
emitStart: (e) => { for (const fn of [...this.startListeners])
|
|
141
|
+
try {
|
|
142
|
+
fn(e);
|
|
143
|
+
}
|
|
144
|
+
catch { /* a listener never breaks a start */ } } });
|
|
136
145
|
this.blockedList = new Blocked({ call: this.callAny });
|
|
137
146
|
}
|
|
138
147
|
get state() { return this.current; }
|
|
@@ -418,7 +427,7 @@ export class HerdrKit {
|
|
|
418
427
|
throw new Error('herdr: not connected');
|
|
419
428
|
return this.transport.subscribe(subs, on, onError ?? (() => { }));
|
|
420
429
|
}
|
|
421
|
-
cli(args, o) { return runCli(this.o.bin, this.supervisor.env(), args, o?.timeoutMs); }
|
|
430
|
+
cli(args, o) { return runCli(this.o.bin, { ...this.supervisor.env(), ...o?.env }, args, o?.timeoutMs); }
|
|
422
431
|
terminal(paneId, o) { return openTerminal(this.o.bin, this.supervisor.env(), paneId, o); }
|
|
423
432
|
snapshot() { return structuredClone(this.tree); }
|
|
424
433
|
onChange(fn) { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; }
|
|
@@ -437,7 +446,16 @@ export class HerdrKit {
|
|
|
437
446
|
* status events instead of opening a second watch.
|
|
438
447
|
*/
|
|
439
448
|
statusWatchReady() { return this.statusReadyPromise; }
|
|
449
|
+
openSignInTab(o) { return this.accountPanes.openSignInTab(o); }
|
|
450
|
+
move(o) { return this.accountPanes.move(o); }
|
|
451
|
+
moveToAccount(target, o) { return this.accountPanes.moveToAccount(target, o); }
|
|
440
452
|
startAgent(o) { return this.agents.startAgent(o); }
|
|
453
|
+
// App-wide start lifecycle without polling: every startAgent's installing/ready/launchFailed
|
|
454
|
+
// lands here as well as on that call's own `onEvent`. Returns the unsubscribe function.
|
|
455
|
+
onStartAgent(fn) {
|
|
456
|
+
this.startListeners.add(fn);
|
|
457
|
+
return () => { this.startListeners.delete(fn); };
|
|
458
|
+
}
|
|
441
459
|
prompt(target, text, o) { return this.agents.prompt(target, text, o); }
|
|
442
460
|
sendKeys(target, keys) { return this.agents.sendKeys(target, keys); }
|
|
443
461
|
wait(target, o) { return this.agents.wait(target, o); }
|
package/dist/types.d.ts
CHANGED
|
@@ -48,9 +48,16 @@ export type { HerdrMethods, HerdrMethod, HerdrParams, HerdrResult } from './gene
|
|
|
48
48
|
export type { HerdrEvents, HerdrEventName, HerdrEventOf, HerdrSubscription } from './generated/events.ts';
|
|
49
49
|
export type AgentStatus = 'idle' | 'working' | 'blocked' | 'done' | 'unknown';
|
|
50
50
|
export type AgentCliSignIn = 'yes' | 'no' | 'unknown';
|
|
51
|
+
export type AgentInstallState = 'installed' | 'installs-on-first-start' | 'missing';
|
|
52
|
+
export type AgentInstallProbe = {
|
|
53
|
+
path?: string[];
|
|
54
|
+
aliases?: Record<string, string[]>;
|
|
55
|
+
readFile?: (file: string) => string | undefined;
|
|
56
|
+
};
|
|
51
57
|
export type AgentReadiness = {
|
|
52
58
|
kind: string;
|
|
53
59
|
installed: boolean;
|
|
60
|
+
installState: AgentInstallState;
|
|
54
61
|
signedIn: AgentCliSignIn;
|
|
55
62
|
installHint: string;
|
|
56
63
|
signInHint?: string;
|
|
@@ -64,9 +71,25 @@ export type AgentStatusRunner = (command: string, args: string[], o?: {
|
|
|
64
71
|
export type AgentStatusOptions = {
|
|
65
72
|
path?: string[];
|
|
66
73
|
aliases?: Record<string, string[]>;
|
|
74
|
+
readFile?: (file: string) => string | undefined;
|
|
67
75
|
run?: AgentStatusRunner;
|
|
68
76
|
timeoutMs?: number;
|
|
69
77
|
};
|
|
78
|
+
export type AgentLaunchFailureReason = 'placement-failed' | 'pane-busy' | 'install-failed' | 'start-rejected';
|
|
79
|
+
export type AgentStartEvent = {
|
|
80
|
+
phase: 'installing';
|
|
81
|
+
kind: string;
|
|
82
|
+
message: string;
|
|
83
|
+
} | {
|
|
84
|
+
phase: 'ready';
|
|
85
|
+
kind: string;
|
|
86
|
+
ref: AgentRef;
|
|
87
|
+
} | {
|
|
88
|
+
phase: 'launchFailed';
|
|
89
|
+
kind: string;
|
|
90
|
+
reason: AgentLaunchFailureReason;
|
|
91
|
+
message: string;
|
|
92
|
+
};
|
|
70
93
|
export type AgentRef = {
|
|
71
94
|
paneId: string;
|
|
72
95
|
name?: string;
|
|
@@ -95,12 +118,51 @@ export type StartAgent = {
|
|
|
95
118
|
args?: string[];
|
|
96
119
|
env?: Record<string, string>;
|
|
97
120
|
timeoutMs?: number;
|
|
121
|
+
onEvent?: (e: AgentStartEvent) => void;
|
|
122
|
+
installProbe?: AgentInstallProbe;
|
|
123
|
+
};
|
|
124
|
+
export type OpenSignInTab = Omit<StartAgent, 'place' | 'worktree'> & {
|
|
125
|
+
workspaceId: string;
|
|
126
|
+
label?: string;
|
|
98
127
|
};
|
|
128
|
+
export type MoveToAccount = {
|
|
129
|
+
provider: 'claude' | 'codex';
|
|
130
|
+
folder: string;
|
|
131
|
+
env?: Record<string, string>;
|
|
132
|
+
direction?: 'right' | 'down';
|
|
133
|
+
timeoutMs?: number;
|
|
134
|
+
};
|
|
135
|
+
export type MoveToAccountResult = {
|
|
136
|
+
ok: true;
|
|
137
|
+
session: string;
|
|
138
|
+
} | {
|
|
139
|
+
ok: false;
|
|
140
|
+
code: 'too_early' | 'busy' | 'unsupported' | 'env_mismatch' | 'close_failed' | 'start_failed';
|
|
141
|
+
message: string;
|
|
142
|
+
live?: string;
|
|
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
|
+
}>;
|
|
99
160
|
export type PromptReceipt = {
|
|
100
161
|
paneId: string;
|
|
101
162
|
terminalId: string;
|
|
102
163
|
revision: number;
|
|
103
164
|
status: AgentStatus;
|
|
165
|
+
agentSession?: AgentSessionRef;
|
|
104
166
|
};
|
|
105
167
|
export type BlockedAgent = {
|
|
106
168
|
paneId: string;
|
package/dist/words.json
CHANGED
|
@@ -13,7 +13,16 @@
|
|
|
13
13
|
"agent.unknown": "Running.",
|
|
14
14
|
"agent.signIn": "Sign in inside {agent}: follow its own steps on the screen.",
|
|
15
15
|
"agent.notReady": "This helper isn't ready yet. Try again in a moment.",
|
|
16
|
+
"agent.installing": "Installing {agent}…",
|
|
17
|
+
"agent.installFailed": "Installing {agent} didn't finish. Try again in a moment.",
|
|
18
|
+
"agent.launchFailed": "That helper couldn't start. Try again in a moment.",
|
|
16
19
|
"approval.stale": "That question already changed. Look again before answering.",
|
|
17
20
|
"close.wouldWiden": "Closing this would close more than you picked. Close the bigger one instead.",
|
|
18
|
-
"link.notAllowed": "This device can't do that. Ask the person at the computer."
|
|
21
|
+
"link.notAllowed": "This device can't do that. Ask the person at the computer.",
|
|
22
|
+
"move.too_early": "This conversation has not started yet. Try again in a moment.",
|
|
23
|
+
"move.busy": "Wait for this conversation to finish before moving it.",
|
|
24
|
+
"move.unsupported": "This conversation cannot move between these accounts.",
|
|
25
|
+
"move.env_mismatch": "The new pane did not receive that sign-in. Try again.",
|
|
26
|
+
"move.close_failed": "The old pane could not close. The move was undone where possible.",
|
|
27
|
+
"move.start_failed": "The new account could not take over. Try again."
|
|
19
28
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@byokit/herdr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Drive the Herdr on this computer — workspaces, tabs, panes, agents and blocked-approval answers — from an app, or hand it to a phone over @byokit/link.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -52,9 +52,9 @@
|
|
|
52
52
|
"prepack": "tsc -b && node ../../scripts/fix-words-dts.cjs"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@byokit/link": "0.
|
|
56
|
-
"@byokit/relay": "0.
|
|
57
|
-
"@byokit/reach": "0.
|
|
55
|
+
"@byokit/link": "0.7.0",
|
|
56
|
+
"@byokit/relay": "0.5.0",
|
|
57
|
+
"@byokit/reach": "0.6.0",
|
|
58
58
|
"@byokit/seal": "0.2.0",
|
|
59
59
|
"ws": "8.21.3"
|
|
60
60
|
},
|