@byokit/herdr 0.5.0 → 0.7.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 +29 -0
- package/README.md +97 -8
- package/dist/accounts.js +150 -67
- package/dist/agents.d.ts +2 -0
- package/dist/agents.js +88 -28
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -0
- package/dist/kinds.d.ts +20 -0
- package/dist/kinds.js +6 -0
- package/dist/kinds.json +503 -0
- package/dist/kit.js +1 -0
- package/dist/link.d.ts +2 -2
- package/dist/link.js +1 -1
- package/dist/muse.d.ts +47 -0
- package/dist/muse.js +257 -0
- package/dist/types.d.ts +28 -3
- package/dist/words.json +4 -0
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.7.1 (2026-10-07)
|
|
6
|
+
|
|
7
|
+
- Dependency update: pins @byokit/relay 0.5.3.
|
|
8
|
+
- Dependency update: pins @byokit/link 0.7.1.
|
|
9
|
+
|
|
10
|
+
- `move` and `moveToAccount` no longer type env prep into the new pane: the split placement env is verified silently through `pane.get` (set vars exact, unset vars absent, fail closed), so no `unset` lines, echo probes or account folder values stay in the scrollback after a move.
|
|
11
|
+
- The replacement agent keeps the conversation's name: it starts unique while the source is still live, then takes the source's session name through `agent.rename` once the source pane closes (best-effort; a refused rename never fails the move). New panes no longer show `move-<hex>` names.
|
|
12
|
+
- The quickstart now says what to read next (the kit spec's decisions and work packages, then the two ways in
|
|
13
|
+
and agent readiness). Docs only; no typed surface changed.
|
|
14
|
+
- The `relay` option takes anything with `RelayClient`'s `notify`, so a host can hand the kit a relay it opens after `Host.open` (the client needs the open host). A `RelayClient` still fits; behaviour is unchanged.
|
|
15
|
+
- Unreleased source dependency metadata: pin @byokit/seal 0.3.0 and @byokit/relay 0.5.2; this package's existing version is not republished. Previously published consumer metadata remains unchanged.
|
|
16
|
+
- `startAgent` with a record `env` that sets `HOME` now also places the pane's XDG config, data, state and cache directories under that home (unless set), so a sign-in inside the pane stays in the task-owned home instead of the server's.
|
|
17
|
+
|
|
18
|
+
## 0.7.0 (2026-10-02)
|
|
19
|
+
|
|
20
|
+
- Includes the Muse installation, managed-kind discovery and isolated readiness FIX/SECURITY changes recorded in the unpublished 0.6.0 preparation below; none add native login qualification.
|
|
21
|
+
|
|
22
|
+
FIX: Re-read source quiescence, conversation, terminal and published sequence immediately before closing on every account move; roll back replacements on change and verify surviving conversations after uncertain cleanup. Published Herdr has no atomic conditional close.
|
|
23
|
+
FIX: Add explicit confirmed, bounded `whenBusy: { busy: 'wait', confirmed, waitMs }` account handoff: move when this step finishes, never interrupt. Preserve default busy refusal, refuse missing sequence/stale identity, stop on blocked approvals, and return typed `interrupt_unsupported` without keys or lifecycle calls. Native working-step qualification remains separate from offline protocol fixtures.
|
|
24
|
+
|
|
25
|
+
## 0.6.0 (2026-10-02)
|
|
26
|
+
|
|
27
|
+
- SECURITY: Explicit Muse installation uses private staged HOME/XDG/temp paths, a clean environment, bounded HTTPS downloads and owned-process cancellation; it never imports personal credentials, edits shell profiles, or starts automatic login or updates.
|
|
28
|
+
- FIX: Missing Muse on the effective private launch PATH now reports not installed and refuses before pane placement or start instead of showing a fake installation and waiting for the startup timeout. The explicit official installer returns verified local release paths for launch; account/catalog readiness remains unknown.
|
|
29
|
+
|
|
30
|
+
FIX: Discover every pinned agent kind and use its actual account-folder and resume support for sign-in tabs and managed moves. Kinds without a folder override remain one sign-in per computer user, tab only. New move support is fixture-tested, not native-qualified.
|
|
31
|
+
|
|
32
|
+
SECURITY: Readiness no longer probes default CLI logins. Pass `agentStatus(kinds, { folders: { [kind]: appManagedFolder }, env? })` to select an app-owned account; absent or invalid folders return unknown without running a CLI. Status commands receive a clean env with HOME/XDG/platform directories confined to that folder. Direct `runStatusCommand` calls without an explicit HOME return no answer. Injected status runners now receive the resolved executable path and explicit env (not an inherited default login).
|
|
33
|
+
|
|
5
34
|
## 0.5.0 (2026-10-01)
|
|
6
35
|
|
|
7
36
|
- SECURITY: StartAgent and openSignInTab accept a clean launch environment with explicit unsets, verified before launch in idle shells on new or existing panes; credential values stay out of terminal commands, argv and errors, preventing inherited API key (billed per use) variables from overriding subscription sign-in.
|
package/README.md
CHANGED
|
@@ -145,6 +145,10 @@ for (const q of await hd.blocked()) {
|
|
|
145
145
|
The [herdr-kit example](../../examples/herdr-kit) is the full version: pairing with a QR code and typed code, a
|
|
146
146
|
persistent grant store, and the phone page above.
|
|
147
147
|
|
|
148
|
+
Read next: [docs/runtime-kits.md](../../docs/runtime-kits.md) §11 for the decisions and the work packages this
|
|
149
|
+
kit does not have yet, and [Two ways in](#two-ways-in) plus
|
|
150
|
+
[Agent readiness](#agent-readiness) for the parts a new app picks first.
|
|
151
|
+
|
|
148
152
|
## API at a glance
|
|
149
153
|
|
|
150
154
|
| Export | What it does |
|
|
@@ -154,7 +158,7 @@ persistent grant store, and the phone page above.
|
|
|
154
158
|
| `HERDR_VERSION`, `HERDR_PROTOCOL` | The pinned Herdr release (`0.9.1`) and the protocol the kit speaks (`22`) |
|
|
155
159
|
| `words`, `agentWords`, `stateWords`, `WORDS` | Plain sentences for agent statuses and kit states |
|
|
156
160
|
| `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 |
|
|
157
|
-
| `serve` (`@byokit/herdr/link`) | Finds the address with `@byokit/
|
|
161
|
+
| `serve` (`@byokit/herdr/link`) | Finds the address with `@byokit/discover` and serves the link (and an optional page) on one port |
|
|
158
162
|
| `herdrDevice` (`@byokit/herdr/device`) | The phone and browser side: typed calls over a `DeviceLink` (`tree`, `startAgent`, `prompt`, `read`, `blocked`, `answer`, `events`, `terminal`, notices); no Node import |
|
|
159
163
|
| `openNotice` (`@byokit/herdr/device`) | Opens a sealed approval notice with the device's own seed |
|
|
160
164
|
| `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 |
|
|
@@ -190,15 +194,39 @@ const moved = await kit.moveToAccount({ paneId: 'w1:p2' }, {
|
|
|
190
194
|
if (moved.ok) console.log(moved.session); // new pane to follow
|
|
191
195
|
```
|
|
192
196
|
|
|
193
|
-
A move accepts
|
|
197
|
+
A move accepts mapped managed kinds, including Claude (`CLAUDE_CONFIG_DIR`), Codex (`CODEX_HOME`) and Pi
|
|
198
|
+
(`PI_CODING_AGENT_DIR`, `--session <id|absolute path>`). Hosts own cross-account history sharing.
|
|
199
|
+
It verifies the new shell's effective
|
|
194
200
|
account folder, resumes the conversation there, waits for a ready session, then closes the old pane. A failed
|
|
195
201
|
start preserves the original. A failed source close rolls back the new pane only after fresh reads verify
|
|
196
202
|
both conversations' identities. A lost close acknowledgment can mean the original already closed: the kit
|
|
197
203
|
preserves the replacement when the source is absent, replaced or unreachable and returns `close_failed`.
|
|
198
204
|
After uncertain source close or cleanup, `live` names a freshly verified surviving conversation; it is omitted
|
|
199
205
|
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.
|
|
201
|
-
|
|
206
|
+
can still happen after verification; the server has no conditional close transaction. Immediately before close,
|
|
207
|
+
the source must still be idle/done with the same conversation, terminal and published `state_change_seq` as
|
|
208
|
+
before splitting. Otherwise the replacement is closed and `changed` returned; `live` is freshly verified.
|
|
209
|
+
This guard applies to idle moves too. It is a bounded observation, not atomic or a new security guarantee.
|
|
210
|
+
Failures return a plain `message`. By default, working or blocked conversations return `busy`.
|
|
211
|
+
|
|
212
|
+
Both move APIs optionally accept:
|
|
213
|
+
```ts
|
|
214
|
+
import type { BusyHandoff } from '@byokit/herdr';
|
|
215
|
+
const whenBusy: BusyHandoff = {
|
|
216
|
+
busy: 'wait',
|
|
217
|
+
confirmed: { session: 'published-conversation-id', terminalId: 'published-terminal-id' },
|
|
218
|
+
waitMs: 300_000,
|
|
219
|
+
}; // Pass whenBusy to either move API after the person confirms these current values.
|
|
220
|
+
```
|
|
221
|
+
Label this **Move when this step finishes**. Confirmation must match the current published session and terminal;
|
|
222
|
+
wait requires a published sequence and finite positive `waitMs` ≤ 300000. Working sources wait for idle/done/blocked
|
|
223
|
+
and are re-read before staging. Timeout returns `busy`, approval returns `blocked`, stale identity returns `changed`,
|
|
224
|
+
and missing sequence or invalid bounds returns `unsupported`. The person may stop the step themselves in the pane.
|
|
225
|
+
`busy: 'interrupt'` (confirmed session/terminal/seq) returns `interrupt_unsupported`, never keys or lifecycle calls.
|
|
226
|
+
Offline fixtures do not qualify real working-step handoff or native Pi moves. `StartAgent.env` applies to newly created placements; existing shells cannot receive a new env.
|
|
227
|
+
A record env with `HOME` gives the pane a task-owned home: unless the env sets them, `XDG_CONFIG_HOME`,
|
|
228
|
+
`XDG_DATA_HOME`, `XDG_STATE_HOME` and `XDG_CACHE_HOME` move under it too, so a sign-in inside the pane stays
|
|
229
|
+
there. Other server variables still reach the pane; pass `{ env, unset }` for a fully replaced environment.
|
|
202
230
|
Tokens stay on the device and are never logged. Each provider's own terms apply to how you use your plan.
|
|
203
231
|
|
|
204
232
|
For managed-folder stores that supply resume arguments and credential shedding, use `move`:
|
|
@@ -242,15 +270,17 @@ Helpers cover the common paths (start an agent, deliver a prompt with a receipt,
|
|
|
242
270
|
|
|
243
271
|
`kit.agentStatus(kinds)` reports, per kind, whether the agent's CLI is installed and whether
|
|
244
272
|
it is signed in — the onboarding agents-and-pickers check. It needs no Herdr connection: it
|
|
245
|
-
probes the
|
|
273
|
+
probes the explicit path or, for own-mode kits, the managed launch PATH. Adopt-mode inventory
|
|
274
|
+
uses the local machine; it is not evidence of an existing pane's environment. That inventory uses the host PATH plus
|
|
246
275
|
the extra install dirs a service PATH omits (`extraPathDirs`: `~/.local/bin`, the mise shims,
|
|
247
276
|
`~/.npm-global/bin`, Homebrew and the system dirs — the set muxr probed before this kit did).
|
|
248
277
|
Sign-in comes only from each CLI's own documented non-secret status command, with a 10 s timeout;
|
|
249
278
|
a kind whose CLI has none, or whose command gives no answer, reads `unknown`. Credential files
|
|
250
279
|
are never opened, read or statted — only the signed-in boolean is kept. Install detection tells a
|
|
251
280
|
real runnable binary apart from an auto-install launcher: a mise-style shim on PATH, or nothing
|
|
252
|
-
on PATH at all,
|
|
253
|
-
|
|
281
|
+
on PATH at all, retains the legacy `installState: 'installs-on-first-start'` with `installed: false`.
|
|
282
|
+
**Muse is different:** no executable, an auto-install shim, or an official launcher without its selected native release,
|
|
283
|
+
reads `installState: 'missing'`. Stock Herdr does not install an absent Muse command. That covers `pi`, which
|
|
254
284
|
Herdr installs via mise: a missing or shimmed `pi` reads `installed: false` with `installs on
|
|
255
285
|
first start`, while a real `pi` binary reads `installed: true`.
|
|
256
286
|
|
|
@@ -287,12 +317,71 @@ sniff's file-head reader (tests use fakes); `{ run }` injects the command runner
|
|
|
287
317
|
`{ timeoutMs }` bounds each probe. `signInHint` rides kinds the kit knows how to check whenever they
|
|
288
318
|
are not signed in; `installHint` always rides along.
|
|
289
319
|
|
|
320
|
+
## Explicit private Muse installation
|
|
321
|
+
|
|
322
|
+
`installMuse` is an additive Node helper. Calling it authorizes the official published installer,
|
|
323
|
+
not sign-in or a model request. Show the source (`MUSE_INSTALL_URL`), destination and affected
|
|
324
|
+
files before offering that action. No hidden download happens on import or `startAgent`.
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
import { HerdrKit, installMuse, museReadiness } from '@byokit/herdr';
|
|
328
|
+
|
|
329
|
+
const installed = await installMuse({
|
|
330
|
+
home: '/app-owned/private/muse',
|
|
331
|
+
path: ['/usr/bin', '/bin'], // clean host-selected tools; no inherited environment
|
|
332
|
+
// installDir: '/app-owned/private/muse/tools', signal, timeoutMs: 180_000
|
|
333
|
+
});
|
|
334
|
+
if (installed.ok) {
|
|
335
|
+
const { env, version } = installed.receipt;
|
|
336
|
+
console.log(museReadiness(env), version); // installation only, signedIn stays unknown
|
|
337
|
+
const kit = new HerdrKit({ mode: 'adopt', bin: '/app-owned/tools/herdr',
|
|
338
|
+
socketPath: '/app-owned/herdr.sock' }); // the app's already running stock Herdr
|
|
339
|
+
await kit.start();
|
|
340
|
+
// Existing env-replacement/rollback guards still apply. Use this complete environment
|
|
341
|
+
// rather than shell rc files, including for an existing pane you explicitly prepare.
|
|
342
|
+
await kit.startAgent({ kind: 'muse', cwd: '/app-owned/project',
|
|
343
|
+
place: { tab: 'new', workspaceId: 'project' }, env: { env, unset: [] } });
|
|
344
|
+
} else {
|
|
345
|
+
console.log(installed.code, installed.message); // e.g. protected_download, cancelled
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Only absolute app-managed private homes/targets are allowed. The destination must be inside
|
|
350
|
+
that home; symlink/escape/default-home/system targets are refused. Bash/curl are required on
|
|
351
|
+
the explicit tool PATH. The unmodified official HTTPS installer runs in a fresh staging
|
|
352
|
+
HOME/XDG/temp, with `MUSE_INSTALL_DIR`, `MUSE_NO_MODIFY_PATH=1`, `MUSE_LOGIN=0`, no inherited
|
|
353
|
+
URL/auth/profile overrides and a bounded curl policy. No credentials are copied or read from
|
|
354
|
+
the caller's existing home. HTTPS-only redirects (at most three), per-download 60 s/512 MiB,
|
|
355
|
+
a total deadline (default 180 s, maximum 300 s) and owned-process-group cancellation are enforced.
|
|
356
|
+
No shell profile or persistent PATH is modified.
|
|
357
|
+
|
|
358
|
+
Success requires both the executable launcher and selected executable `muse-bin-<version>`;
|
|
359
|
+
the receipt records the native release version, SHA-256 hashes, actual paths and launch env.
|
|
360
|
+
A working installation is reused without updates. A failed/cancelled new install leaves no
|
|
361
|
+
partial target; an existing incomplete target is preserved/refused (choose a fresh directory).
|
|
362
|
+
The returned environment disables automatic updates and login. Hosts own these private paths
|
|
363
|
+
and must avoid concurrent writers; filesystem validation is not an OS sandbox for publisher code.
|
|
364
|
+
Linux/macOS x64/arm64 are supported; Windows returns `unsupported_platform`.
|
|
365
|
+
|
|
366
|
+
Known-missing Muse refuses with `agent_not_installed` / `launchFailed.reason: 'not-installed'`
|
|
367
|
+
**before placement/start**. Preflight uses the explicit per-call environment, otherwise the
|
|
368
|
+
kit-owned launch environment for a new pane. Controller-global inventory/probe overrides
|
|
369
|
+
never prove readiness on a known private launch PATH. An unobserved existing/adopted pane
|
|
370
|
+
environment remains unknown (`museReadiness()`), not installed. Exact typed RPC pass-through
|
|
371
|
+
is unchanged; direct stock `agent.start` has upstream behavior.
|
|
372
|
+
|
|
373
|
+
Installation adds no folder/resume/native-move capability: Muse remains tab-only metadata.
|
|
374
|
+
It does not prove sign-in, account catalog or model readiness. The official public model name
|
|
375
|
+
is `muse-spark-1.3-contributor`; the installed account's own catalog still requires separate
|
|
376
|
+
owner qualification. No provider/model fallback is performed. Previously inspected published
|
|
377
|
+
kit versions 0.3.0 and 0.5.0 lack this helper/fix; this source change is not a published-version claim.
|
|
378
|
+
|
|
290
379
|
## Agent start lifecycle
|
|
291
380
|
|
|
292
381
|
`startAgent` keeps its typed shape and rejection, and additionally reports the launch as events
|
|
293
382
|
so an app shows `Installing…` instead of a blank start: `installing` (with the progress words)
|
|
294
383
|
before a start that needs an install, `ready` with the fresh ref, and `launchFailed` with a typed
|
|
295
|
-
`reason` (`placement-failed` | `pane-busy` | `install-failed` | `start-rejected`) plus plain words.
|
|
384
|
+
`reason` (`placement-failed` | `pane-busy` | `install-failed` | `start-rejected` | `not-installed`) plus plain words.
|
|
296
385
|
Subscribe per call with `onEvent`, or app-wide with `kit.onStartAgent` — no polling either way.
|
|
297
386
|
|
|
298
387
|
```ts
|
package/dist/accounts.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Account-specific CLI panes (D11/6.6). No credentials are read or copied. Moves follow
|
|
2
2
|
// the decided start-then-close order; only fake transports exercise this in tests.
|
|
3
3
|
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { isAbsolute } from 'node:path';
|
|
4
5
|
import { words } from "./words.js";
|
|
6
|
+
import { accountKind } from "./kinds.js";
|
|
5
7
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
8
|
export function createAccountPanes(ctx) {
|
|
7
9
|
const moving = new Set();
|
|
@@ -15,55 +17,58 @@ export function createAccountPanes(ctx) {
|
|
|
15
17
|
return false;
|
|
16
18
|
}
|
|
17
19
|
}
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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) {
|
|
20
|
+
// Env verification types nothing into the pane: every prep keystroke (unset lines,
|
|
21
|
+
// echo probes) stays in the new pane's scrollback where users see it after a move. The
|
|
22
|
+
// placement env rides `pane.split`, applied by the server before the shell is shown,
|
|
23
|
+
// so `pane.get` proves the new shell's launch env: every set var exact, every unset
|
|
24
|
+
// var absent. Fail closed: the split call cannot express removals, so a leaked
|
|
25
|
+
// credential in the launch env refuses the move instead of running on it.
|
|
26
|
+
// Limitation: a shell rc overriding placement env after launch is not visible here;
|
|
27
|
+
// no pinned RPC reads shell-effective env without typing into the pane.
|
|
28
|
+
async function verifyPaneEnv(paneId, set, unset, timeout) {
|
|
45
29
|
const entries = Object.entries(set);
|
|
46
30
|
if ([...entries.map(([key]) => key), ...unset].some((key) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
47
31
|
|| entries.some(([, value]) => typeof value !== 'string' || /[\r\n\0]/.test(value))
|
|
48
32
|
|| unset.some((key) => key in set))
|
|
49
33
|
return false;
|
|
50
34
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (!
|
|
35
|
+
const pane = (await call('pane.get', { pane_id: paneId }, timeout))?.pane;
|
|
36
|
+
const env = pane?.env;
|
|
37
|
+
if (!pane || typeof env !== 'object' || env === null || Array.isArray(env))
|
|
54
38
|
return false;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return true;
|
|
39
|
+
if (unset.some((key) => Object.hasOwn(env, key)))
|
|
40
|
+
return false;
|
|
41
|
+
return entries.every(([key, value]) => env[key] === value);
|
|
59
42
|
}
|
|
60
43
|
catch {
|
|
61
44
|
return false;
|
|
62
45
|
}
|
|
63
46
|
}
|
|
47
|
+
// The replacement keeps the conversation's user-visible name. The server rejects
|
|
48
|
+
// duplicate agent names, so it starts unique while the source is still live, then
|
|
49
|
+
// takes the source's name once that pane is closed. A failed rename keeps the
|
|
50
|
+
// unique start name and never fails the move.
|
|
51
|
+
function moveNames(sourceName, kind) {
|
|
52
|
+
const clean = (value) => {
|
|
53
|
+
const slug = String(value ?? '').toLowerCase().replace(/[^a-z0-9_-]+/g, '-')
|
|
54
|
+
.replace(/^[-_0-9]+/, '').slice(0, 32);
|
|
55
|
+
return /^[a-z][a-z0-9_-]{0,31}$/.test(slug) ? slug : undefined;
|
|
56
|
+
};
|
|
57
|
+
const want = clean(sourceName) ?? clean(kind) ?? 'agent';
|
|
58
|
+
return { start: `${want.slice(0, 25)}-${randomBytes(3).toString('hex')}`, want };
|
|
59
|
+
}
|
|
64
60
|
async function openSignInTab(o) {
|
|
65
|
-
const { workspaceId, label, ...start } = o;
|
|
61
|
+
const { workspaceId, label, folder, ...start } = o;
|
|
66
62
|
try {
|
|
63
|
+
if (folder !== undefined) {
|
|
64
|
+
const variable = accountKind(start.kind)?.folderVar;
|
|
65
|
+
if (!variable || !folder || /[\r\n\0]/.test(folder))
|
|
66
|
+
throw new Error('unsupported folder');
|
|
67
|
+
const launch = start.env;
|
|
68
|
+
start.env = launch && typeof launch.env === 'object' && Array.isArray(launch.unset)
|
|
69
|
+
? { env: { ...launch.env, [variable]: folder }, unset: launch.unset }
|
|
70
|
+
: { ...launch, [variable]: folder };
|
|
71
|
+
}
|
|
67
72
|
return await ctx.startAgent({ ...start, place: { tab: 'new', workspaceId,
|
|
68
73
|
...(label === undefined ? {} : { label }) } });
|
|
69
74
|
}
|
|
@@ -85,19 +90,69 @@ export function createAccountPanes(ctx) {
|
|
|
85
90
|
catch {
|
|
86
91
|
return failed('too_early');
|
|
87
92
|
}
|
|
88
|
-
const
|
|
93
|
+
const handoff = o.whenBusy;
|
|
94
|
+
const optedIn = handoff?.busy === 'wait' || handoff?.busy === 'interrupt';
|
|
95
|
+
let session = agent?.agent_session;
|
|
89
96
|
if (!agent || agent.launch_pending === true || !session || typeof session.value !== 'string'
|
|
90
97
|
|| session.value.length === 0)
|
|
91
98
|
return failed('too_early');
|
|
92
|
-
if (
|
|
99
|
+
if (agent.agent_status === 'blocked')
|
|
100
|
+
return failed(optedIn ? 'blocked' : 'busy');
|
|
101
|
+
if (!['idle', 'done'].includes(agent.agent_status) && !(optedIn && agent.agent_status === 'working'))
|
|
93
102
|
return failed('busy');
|
|
103
|
+
if (optedIn) {
|
|
104
|
+
if (!handoff.confirmed || handoff.confirmed.session !== session.value
|
|
105
|
+
|| handoff.confirmed.terminalId !== agent.terminal_id)
|
|
106
|
+
return failed('changed');
|
|
107
|
+
if (typeof agent.terminal_id !== 'string' || agent.terminal_id.length === 0)
|
|
108
|
+
return failed('unsupported');
|
|
109
|
+
if (handoff.busy === 'interrupt') {
|
|
110
|
+
if (handoff.confirmed.seq !== agent.state_change_seq)
|
|
111
|
+
return failed('changed');
|
|
112
|
+
return failed('interrupt_unsupported');
|
|
113
|
+
}
|
|
114
|
+
if (!Number.isFinite(handoff.waitMs) || handoff.waitMs <= 0 || handoff.waitMs > 300_000
|
|
115
|
+
|| !Number.isSafeInteger(agent.state_change_seq) || agent.state_change_seq < 0)
|
|
116
|
+
return failed('unsupported');
|
|
117
|
+
if (agent.agent_status === 'working') {
|
|
118
|
+
try {
|
|
119
|
+
await call('agent.wait', { target: target.paneId, until: ['idle', 'done', 'blocked'],
|
|
120
|
+
timeout_ms: handoff.waitMs }, handoff.waitMs + 5000);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return failed('busy');
|
|
124
|
+
}
|
|
125
|
+
let current;
|
|
126
|
+
try {
|
|
127
|
+
current = (await call('agent.get', { target: target.paneId }))?.agent;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return failed('changed', null);
|
|
131
|
+
}
|
|
132
|
+
if (!current || current.launch_pending === true || current.agent !== agent.agent
|
|
133
|
+
|| current.terminal_id !== agent.terminal_id || current.agent_session?.agent !== session.agent
|
|
134
|
+
|| current.agent_session?.kind !== session.kind || current.agent_session?.value !== session.value)
|
|
135
|
+
return failed('changed', null);
|
|
136
|
+
if (current.agent_status === 'blocked')
|
|
137
|
+
return failed('blocked');
|
|
138
|
+
if (!['idle', 'done'].includes(current.agent_status))
|
|
139
|
+
return failed('busy');
|
|
140
|
+
if (!Number.isSafeInteger(current.state_change_seq) || current.state_change_seq < 0)
|
|
141
|
+
return failed('unsupported');
|
|
142
|
+
agent = current;
|
|
143
|
+
session = current.agent_session;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
94
146
|
const kind = agent.agent;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
||
|
|
147
|
+
const metadata = accountKind(o.provider);
|
|
148
|
+
if (!metadata?.folderVar || !metadata.resume || kind !== metadata.kind
|
|
149
|
+
|| (request !== undefined && accountKind(request.kind)?.kind !== kind)
|
|
150
|
+
|| session.agent !== kind || !metadata.sessionKinds.includes(session.kind))
|
|
151
|
+
return failed('unsupported');
|
|
152
|
+
if (/[\x00-\x1f\x7f]/.test(session.value) || session.value.length > (session.kind === 'path' ? 4096 : 512)
|
|
153
|
+
|| (session.kind === 'path' && !isAbsolute(session.value)))
|
|
98
154
|
return failed('unsupported');
|
|
99
|
-
const args = request?.args ?? (
|
|
100
|
-
: kind === 'codex' && session.kind === 'id' ? ['resume', session.value] : undefined);
|
|
155
|
+
const args = request?.args ?? metadata.resumeArgs?.map((arg) => arg.replace('{session}', () => session.value));
|
|
101
156
|
if (args === undefined || !Array.isArray(args) || args.length === 0 || args.some((a) => typeof a !== 'string' || /[\r\n\0]/.test(a)))
|
|
102
157
|
return failed('unsupported');
|
|
103
158
|
if (!o.folder || /[\r\n\0]/.test(o.folder))
|
|
@@ -105,7 +160,7 @@ export function createAccountPanes(ctx) {
|
|
|
105
160
|
const timeout = o.timeoutMs ?? 60_000;
|
|
106
161
|
if (!Number.isFinite(timeout) || timeout <= 0)
|
|
107
162
|
return failed('start_failed');
|
|
108
|
-
const variable =
|
|
163
|
+
const variable = metadata.folderVar;
|
|
109
164
|
let paneId;
|
|
110
165
|
let replacement;
|
|
111
166
|
try {
|
|
@@ -127,16 +182,17 @@ export function createAccountPanes(ctx) {
|
|
|
127
182
|
catch {
|
|
128
183
|
return rollback('start_failed');
|
|
129
184
|
}
|
|
130
|
-
if (request !== undefined
|
|
131
|
-
|
|
185
|
+
if (request !== undefined) {
|
|
186
|
+
if (!await verifyPaneEnv(paneId, request.set, request.unset ?? [], timeout))
|
|
187
|
+
return rollback('env_mismatch');
|
|
132
188
|
}
|
|
133
|
-
if (
|
|
189
|
+
else if (!await verifyPaneEnv(paneId, { ...o.env, [variable]: o.folder }, [], timeout)) {
|
|
134
190
|
return rollback('env_mismatch');
|
|
135
191
|
}
|
|
192
|
+
const names = moveNames(agent?.name, kind);
|
|
136
193
|
try {
|
|
137
|
-
// Unique names avoid colliding with the still-live source agent.
|
|
138
194
|
await ctx.startAgent({ kind, cwd: agent.foreground_cwd ?? agent.cwd ?? '.',
|
|
139
|
-
name:
|
|
195
|
+
name: names.start, place: { pane: paneId }, args, timeoutMs: timeout });
|
|
140
196
|
await call('agent.wait', { target: paneId, until: ['idle', 'done'], timeout_ms: timeout }, timeout + 5000);
|
|
141
197
|
const deadline = Date.now() + timeout;
|
|
142
198
|
for (;;) {
|
|
@@ -158,25 +214,43 @@ export function createAccountPanes(ctx) {
|
|
|
158
214
|
const cleaned = await close(paneId);
|
|
159
215
|
return failed('start_failed', cleaned ? target.paneId : paneId);
|
|
160
216
|
}
|
|
217
|
+
// Compare identity, not revision: status changes can legitimately bump revision.
|
|
218
|
+
const survives = async (id, expected) => {
|
|
219
|
+
try {
|
|
220
|
+
const current = (await call('agent.get', { target: id }, timeout))?.agent;
|
|
221
|
+
return !!current && current.agent === expected.agent && current.launch_pending !== true
|
|
222
|
+
&& ['idle', 'working', 'blocked', 'done'].includes(current.agent_status)
|
|
223
|
+
&& current.agent_session?.agent === expected.agent_session?.agent
|
|
224
|
+
&& current.agent_session?.kind === expected.agent_session?.kind
|
|
225
|
+
&& current.agent_session?.value === expected.agent_session?.value
|
|
226
|
+
&& (expected.terminal_id === undefined || current.terminal_id === expected.terminal_id)
|
|
227
|
+
&& (expected.name === undefined || current.name === expected.name);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return false;
|
|
231
|
+
} // unavailable is not proof of survival
|
|
232
|
+
};
|
|
233
|
+
let current;
|
|
234
|
+
try {
|
|
235
|
+
current = (await call('agent.get', { target: target.paneId }, timeout))?.agent;
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
current = {};
|
|
239
|
+
}
|
|
240
|
+
if (!current || current.launch_pending === true || !['idle', 'done'].includes(current.agent_status)
|
|
241
|
+
|| current.agent_status !== agent.agent_status || current.agent !== agent.agent || current.terminal_id !== agent.terminal_id
|
|
242
|
+
|| current.agent_session?.agent !== session.agent || current.agent_session?.kind !== session.kind
|
|
243
|
+
|| current.agent_session?.value !== session.value
|
|
244
|
+
|| (agent.state_change_seq !== undefined && current.state_change_seq !== agent.state_change_seq)) {
|
|
245
|
+
await close(paneId);
|
|
246
|
+
const live = await survives(paneId, replacement) ? paneId
|
|
247
|
+
: await survives(target.paneId, agent) ? target.paneId : null;
|
|
248
|
+
return failed('changed', live);
|
|
249
|
+
}
|
|
250
|
+
// Published Herdr has no conditional close: the final observation is not atomic.
|
|
161
251
|
if (!await close(target.paneId)) {
|
|
162
252
|
// A lost ACK can mean the close already applied. Never destroy the ready
|
|
163
253
|
// 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
254
|
const replacementAlive = await survives(paneId, replacement);
|
|
181
255
|
const sourceAlive = await survives(target.paneId, agent);
|
|
182
256
|
if (sourceAlive && replacementAlive)
|
|
@@ -188,6 +262,15 @@ export function createAccountPanes(ctx) {
|
|
|
188
262
|
return failed('close_failed', live);
|
|
189
263
|
}
|
|
190
264
|
// The source is closed: a notification failure cannot undo a successful move.
|
|
265
|
+
// Take the conversation's name now that it is free; the unique start name stays
|
|
266
|
+
// when the rename is refused, and a rename failure never undoes the move.
|
|
267
|
+
if (names.want !== names.start) {
|
|
268
|
+
try {
|
|
269
|
+
await call('agent.rename', { target: paneId, name: names.want }, timeout);
|
|
270
|
+
replacement.name = names.want;
|
|
271
|
+
}
|
|
272
|
+
catch { /* the unique start name stays; the move already succeeded */ }
|
|
273
|
+
}
|
|
191
274
|
try {
|
|
192
275
|
request?.onReplaced?.(paneId);
|
|
193
276
|
}
|
|
@@ -199,10 +282,10 @@ export function createAccountPanes(ctx) {
|
|
|
199
282
|
}
|
|
200
283
|
}
|
|
201
284
|
async function move(o) {
|
|
202
|
-
const variable = o.kind
|
|
285
|
+
const variable = accountKind(o.kind)?.folderVar;
|
|
203
286
|
const result = await perform({ paneId: o.paneId }, {
|
|
204
|
-
provider: o.kind ===
|
|
205
|
-
env: o.set, timeoutMs: o.timeoutMs,
|
|
287
|
+
provider: o.kind, folder: variable === undefined ? '' : o.set[variable] ?? '',
|
|
288
|
+
env: o.set, timeoutMs: o.timeoutMs, whenBusy: o.whenBusy,
|
|
206
289
|
}, o);
|
|
207
290
|
return result.ok ? { ok: true, paneId: result.session } : result;
|
|
208
291
|
}
|
package/dist/agents.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export declare function createAgents(ctx: {
|
|
|
6
6
|
snapshot(): HerdrSnapshot;
|
|
7
7
|
reread(paneId: string): Promise<void>;
|
|
8
8
|
emitStart?(e: AgentStartEvent): void;
|
|
9
|
+
launchEnv?(): Record<string, string> | undefined;
|
|
9
10
|
}): Pick<HerdrKit, 'startAgent' | 'prompt' | 'sendKeys' | 'wait' | 'read' | 'agentKinds' | 'installedAgentKinds' | 'agentStatus'>;
|
|
10
11
|
export declare function resolveAgentBinary(kind: string, o: {
|
|
11
12
|
path: string[];
|
|
@@ -29,6 +30,7 @@ export declare function agentProbePath(path?: string[], home?: string): string[]
|
|
|
29
30
|
export declare function runStatusCommand(command: string, args: string[], o?: {
|
|
30
31
|
stdin?: string;
|
|
31
32
|
timeoutMs?: number;
|
|
33
|
+
env?: Record<string, string>;
|
|
32
34
|
}): Promise<{
|
|
33
35
|
stdout: string;
|
|
34
36
|
} | undefined>;
|