@byokit/herdr 0.5.0 → 0.7.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 +16 -0
- package/README.md +89 -7
- package/dist/accounts.js +107 -28
- package/dist/agents.d.ts +2 -0
- package/dist/agents.js +82 -27
- 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/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 +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.7.0 (2026-10-02)
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
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.
|
|
11
|
+
|
|
12
|
+
## 0.6.0 (2026-10-02)
|
|
13
|
+
|
|
14
|
+
- 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.
|
|
15
|
+
- 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.
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
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).
|
|
20
|
+
|
|
5
21
|
## 0.5.0 (2026-10-01)
|
|
6
22
|
|
|
7
23
|
- 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
|
@@ -190,15 +190,36 @@ const moved = await kit.moveToAccount({ paneId: 'w1:p2' }, {
|
|
|
190
190
|
if (moved.ok) console.log(moved.session); // new pane to follow
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
-
A move accepts
|
|
193
|
+
A move accepts mapped managed kinds, including Claude (`CLAUDE_CONFIG_DIR`), Codex (`CODEX_HOME`) and Pi
|
|
194
|
+
(`PI_CODING_AGENT_DIR`, `--session <id|absolute path>`). Hosts own cross-account history sharing.
|
|
195
|
+
It verifies the new shell's effective
|
|
194
196
|
account folder, resumes the conversation there, waits for a ready session, then closes the old pane. A failed
|
|
195
197
|
start preserves the original. A failed source close rolls back the new pane only after fresh reads verify
|
|
196
198
|
both conversations' identities. A lost close acknowledgment can mean the original already closed: the kit
|
|
197
199
|
preserves the replacement when the source is absent, replaced or unreachable and returns `close_failed`.
|
|
198
200
|
After uncertain source close or cleanup, `live` names a freshly verified surviving conversation; it is omitted
|
|
199
201
|
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
|
-
|
|
202
|
+
can still happen after verification; the server has no conditional close transaction. Immediately before close,
|
|
203
|
+
the source must still be idle/done with the same conversation, terminal and published `state_change_seq` as
|
|
204
|
+
before splitting. Otherwise the replacement is closed and `changed` returned; `live` is freshly verified.
|
|
205
|
+
This guard applies to idle moves too. It is a bounded observation, not atomic or a new security guarantee.
|
|
206
|
+
Failures return a plain `message`. By default, working or blocked conversations return `busy`.
|
|
207
|
+
|
|
208
|
+
Both move APIs optionally accept:
|
|
209
|
+
```ts
|
|
210
|
+
import type { BusyHandoff } from '@byokit/herdr';
|
|
211
|
+
const whenBusy: BusyHandoff = {
|
|
212
|
+
busy: 'wait',
|
|
213
|
+
confirmed: { session: 'published-conversation-id', terminalId: 'published-terminal-id' },
|
|
214
|
+
waitMs: 300_000,
|
|
215
|
+
}; // Pass whenBusy to either move API after the person confirms these current values.
|
|
216
|
+
```
|
|
217
|
+
Label this **Move when this step finishes**. Confirmation must match the current published session and terminal;
|
|
218
|
+
wait requires a published sequence and finite positive `waitMs` ≤ 300000. Working sources wait for idle/done/blocked
|
|
219
|
+
and are re-read before staging. Timeout returns `busy`, approval returns `blocked`, stale identity returns `changed`,
|
|
220
|
+
and missing sequence or invalid bounds returns `unsupported`. The person may stop the step themselves in the pane.
|
|
221
|
+
`busy: 'interrupt'` (confirmed session/terminal/seq) returns `interrupt_unsupported`, never keys or lifecycle calls.
|
|
222
|
+
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.
|
|
202
223
|
Tokens stay on the device and are never logged. Each provider's own terms apply to how you use your plan.
|
|
203
224
|
|
|
204
225
|
For managed-folder stores that supply resume arguments and credential shedding, use `move`:
|
|
@@ -242,15 +263,17 @@ Helpers cover the common paths (start an agent, deliver a prompt with a receipt,
|
|
|
242
263
|
|
|
243
264
|
`kit.agentStatus(kinds)` reports, per kind, whether the agent's CLI is installed and whether
|
|
244
265
|
it is signed in — the onboarding agents-and-pickers check. It needs no Herdr connection: it
|
|
245
|
-
probes the
|
|
266
|
+
probes the explicit path or, for own-mode kits, the managed launch PATH. Adopt-mode inventory
|
|
267
|
+
uses the local machine; it is not evidence of an existing pane's environment. That inventory uses the host PATH plus
|
|
246
268
|
the extra install dirs a service PATH omits (`extraPathDirs`: `~/.local/bin`, the mise shims,
|
|
247
269
|
`~/.npm-global/bin`, Homebrew and the system dirs — the set muxr probed before this kit did).
|
|
248
270
|
Sign-in comes only from each CLI's own documented non-secret status command, with a 10 s timeout;
|
|
249
271
|
a kind whose CLI has none, or whose command gives no answer, reads `unknown`. Credential files
|
|
250
272
|
are never opened, read or statted — only the signed-in boolean is kept. Install detection tells a
|
|
251
273
|
real runnable binary apart from an auto-install launcher: a mise-style shim on PATH, or nothing
|
|
252
|
-
on PATH at all,
|
|
253
|
-
|
|
274
|
+
on PATH at all, retains the legacy `installState: 'installs-on-first-start'` with `installed: false`.
|
|
275
|
+
**Muse is different:** no executable, an auto-install shim, or an official launcher without its selected native release,
|
|
276
|
+
reads `installState: 'missing'`. Stock Herdr does not install an absent Muse command. That covers `pi`, which
|
|
254
277
|
Herdr installs via mise: a missing or shimmed `pi` reads `installed: false` with `installs on
|
|
255
278
|
first start`, while a real `pi` binary reads `installed: true`.
|
|
256
279
|
|
|
@@ -287,12 +310,71 @@ sniff's file-head reader (tests use fakes); `{ run }` injects the command runner
|
|
|
287
310
|
`{ timeoutMs }` bounds each probe. `signInHint` rides kinds the kit knows how to check whenever they
|
|
288
311
|
are not signed in; `installHint` always rides along.
|
|
289
312
|
|
|
313
|
+
## Explicit private Muse installation
|
|
314
|
+
|
|
315
|
+
`installMuse` is an additive Node helper. Calling it authorizes the official published installer,
|
|
316
|
+
not sign-in or a model request. Show the source (`MUSE_INSTALL_URL`), destination and affected
|
|
317
|
+
files before offering that action. No hidden download happens on import or `startAgent`.
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
import { HerdrKit, installMuse, museReadiness } from '@byokit/herdr';
|
|
321
|
+
|
|
322
|
+
const installed = await installMuse({
|
|
323
|
+
home: '/app-owned/private/muse',
|
|
324
|
+
path: ['/usr/bin', '/bin'], // clean host-selected tools; no inherited environment
|
|
325
|
+
// installDir: '/app-owned/private/muse/tools', signal, timeoutMs: 180_000
|
|
326
|
+
});
|
|
327
|
+
if (installed.ok) {
|
|
328
|
+
const { env, version } = installed.receipt;
|
|
329
|
+
console.log(museReadiness(env), version); // installation only, signedIn stays unknown
|
|
330
|
+
const kit = new HerdrKit({ mode: 'adopt', bin: '/app-owned/tools/herdr',
|
|
331
|
+
socketPath: '/app-owned/herdr.sock' }); // the app's already running stock Herdr
|
|
332
|
+
await kit.start();
|
|
333
|
+
// Existing env-replacement/rollback guards still apply. Use this complete environment
|
|
334
|
+
// rather than shell rc files, including for an existing pane you explicitly prepare.
|
|
335
|
+
await kit.startAgent({ kind: 'muse', cwd: '/app-owned/project',
|
|
336
|
+
place: { tab: 'new', workspaceId: 'project' }, env: { env, unset: [] } });
|
|
337
|
+
} else {
|
|
338
|
+
console.log(installed.code, installed.message); // e.g. protected_download, cancelled
|
|
339
|
+
}
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
Only absolute app-managed private homes/targets are allowed. The destination must be inside
|
|
343
|
+
that home; symlink/escape/default-home/system targets are refused. Bash/curl are required on
|
|
344
|
+
the explicit tool PATH. The unmodified official HTTPS installer runs in a fresh staging
|
|
345
|
+
HOME/XDG/temp, with `MUSE_INSTALL_DIR`, `MUSE_NO_MODIFY_PATH=1`, `MUSE_LOGIN=0`, no inherited
|
|
346
|
+
URL/auth/profile overrides and a bounded curl policy. No credentials are copied or read from
|
|
347
|
+
the caller's existing home. HTTPS-only redirects (at most three), per-download 60 s/512 MiB,
|
|
348
|
+
a total deadline (default 180 s, maximum 300 s) and owned-process-group cancellation are enforced.
|
|
349
|
+
No shell profile or persistent PATH is modified.
|
|
350
|
+
|
|
351
|
+
Success requires both the executable launcher and selected executable `muse-bin-<version>`;
|
|
352
|
+
the receipt records the native release version, SHA-256 hashes, actual paths and launch env.
|
|
353
|
+
A working installation is reused without updates. A failed/cancelled new install leaves no
|
|
354
|
+
partial target; an existing incomplete target is preserved/refused (choose a fresh directory).
|
|
355
|
+
The returned environment disables automatic updates and login. Hosts own these private paths
|
|
356
|
+
and must avoid concurrent writers; filesystem validation is not an OS sandbox for publisher code.
|
|
357
|
+
Linux/macOS x64/arm64 are supported; Windows returns `unsupported_platform`.
|
|
358
|
+
|
|
359
|
+
Known-missing Muse refuses with `agent_not_installed` / `launchFailed.reason: 'not-installed'`
|
|
360
|
+
**before placement/start**. Preflight uses the explicit per-call environment, otherwise the
|
|
361
|
+
kit-owned launch environment for a new pane. Controller-global inventory/probe overrides
|
|
362
|
+
never prove readiness on a known private launch PATH. An unobserved existing/adopted pane
|
|
363
|
+
environment remains unknown (`museReadiness()`), not installed. Exact typed RPC pass-through
|
|
364
|
+
is unchanged; direct stock `agent.start` has upstream behavior.
|
|
365
|
+
|
|
366
|
+
Installation adds no folder/resume/native-move capability: Muse remains tab-only metadata.
|
|
367
|
+
It does not prove sign-in, account catalog or model readiness. The official public model name
|
|
368
|
+
is `muse-spark-1.3-contributor`; the installed account's own catalog still requires separate
|
|
369
|
+
owner qualification. No provider/model fallback is performed. Previously inspected published
|
|
370
|
+
kit versions 0.3.0 and 0.5.0 lack this helper/fix; this source change is not a published-version claim.
|
|
371
|
+
|
|
290
372
|
## Agent start lifecycle
|
|
291
373
|
|
|
292
374
|
`startAgent` keeps its typed shape and rejection, and additionally reports the launch as events
|
|
293
375
|
so an app shows `Installing…` instead of a blank start: `installing` (with the progress words)
|
|
294
376
|
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.
|
|
377
|
+
`reason` (`placement-failed` | `pane-busy` | `install-failed` | `start-rejected` | `not-installed`) plus plain words.
|
|
296
378
|
Subscribe per call with `onEvent`, or app-wide with `kit.onStartAgent` — no polling either way.
|
|
297
379
|
|
|
298
380
|
```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();
|
|
@@ -62,8 +64,17 @@ export function createAccountPanes(ctx) {
|
|
|
62
64
|
}
|
|
63
65
|
}
|
|
64
66
|
async function openSignInTab(o) {
|
|
65
|
-
const { workspaceId, label, ...start } = o;
|
|
67
|
+
const { workspaceId, label, folder, ...start } = o;
|
|
66
68
|
try {
|
|
69
|
+
if (folder !== undefined) {
|
|
70
|
+
const variable = accountKind(start.kind)?.folderVar;
|
|
71
|
+
if (!variable || !folder || /[\r\n\0]/.test(folder))
|
|
72
|
+
throw new Error('unsupported folder');
|
|
73
|
+
const launch = start.env;
|
|
74
|
+
start.env = launch && typeof launch.env === 'object' && Array.isArray(launch.unset)
|
|
75
|
+
? { env: { ...launch.env, [variable]: folder }, unset: launch.unset }
|
|
76
|
+
: { ...launch, [variable]: folder };
|
|
77
|
+
}
|
|
67
78
|
return await ctx.startAgent({ ...start, place: { tab: 'new', workspaceId,
|
|
68
79
|
...(label === undefined ? {} : { label }) } });
|
|
69
80
|
}
|
|
@@ -85,19 +96,69 @@ export function createAccountPanes(ctx) {
|
|
|
85
96
|
catch {
|
|
86
97
|
return failed('too_early');
|
|
87
98
|
}
|
|
88
|
-
const
|
|
99
|
+
const handoff = o.whenBusy;
|
|
100
|
+
const optedIn = handoff?.busy === 'wait' || handoff?.busy === 'interrupt';
|
|
101
|
+
let session = agent?.agent_session;
|
|
89
102
|
if (!agent || agent.launch_pending === true || !session || typeof session.value !== 'string'
|
|
90
103
|
|| session.value.length === 0)
|
|
91
104
|
return failed('too_early');
|
|
92
|
-
if (
|
|
105
|
+
if (agent.agent_status === 'blocked')
|
|
106
|
+
return failed(optedIn ? 'blocked' : 'busy');
|
|
107
|
+
if (!['idle', 'done'].includes(agent.agent_status) && !(optedIn && agent.agent_status === 'working'))
|
|
93
108
|
return failed('busy');
|
|
109
|
+
if (optedIn) {
|
|
110
|
+
if (!handoff.confirmed || handoff.confirmed.session !== session.value
|
|
111
|
+
|| handoff.confirmed.terminalId !== agent.terminal_id)
|
|
112
|
+
return failed('changed');
|
|
113
|
+
if (typeof agent.terminal_id !== 'string' || agent.terminal_id.length === 0)
|
|
114
|
+
return failed('unsupported');
|
|
115
|
+
if (handoff.busy === 'interrupt') {
|
|
116
|
+
if (handoff.confirmed.seq !== agent.state_change_seq)
|
|
117
|
+
return failed('changed');
|
|
118
|
+
return failed('interrupt_unsupported');
|
|
119
|
+
}
|
|
120
|
+
if (!Number.isFinite(handoff.waitMs) || handoff.waitMs <= 0 || handoff.waitMs > 300_000
|
|
121
|
+
|| !Number.isSafeInteger(agent.state_change_seq) || agent.state_change_seq < 0)
|
|
122
|
+
return failed('unsupported');
|
|
123
|
+
if (agent.agent_status === 'working') {
|
|
124
|
+
try {
|
|
125
|
+
await call('agent.wait', { target: target.paneId, until: ['idle', 'done', 'blocked'],
|
|
126
|
+
timeout_ms: handoff.waitMs }, handoff.waitMs + 5000);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return failed('busy');
|
|
130
|
+
}
|
|
131
|
+
let current;
|
|
132
|
+
try {
|
|
133
|
+
current = (await call('agent.get', { target: target.paneId }))?.agent;
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return failed('changed', null);
|
|
137
|
+
}
|
|
138
|
+
if (!current || current.launch_pending === true || current.agent !== agent.agent
|
|
139
|
+
|| current.terminal_id !== agent.terminal_id || current.agent_session?.agent !== session.agent
|
|
140
|
+
|| current.agent_session?.kind !== session.kind || current.agent_session?.value !== session.value)
|
|
141
|
+
return failed('changed', null);
|
|
142
|
+
if (current.agent_status === 'blocked')
|
|
143
|
+
return failed('blocked');
|
|
144
|
+
if (!['idle', 'done'].includes(current.agent_status))
|
|
145
|
+
return failed('busy');
|
|
146
|
+
if (!Number.isSafeInteger(current.state_change_seq) || current.state_change_seq < 0)
|
|
147
|
+
return failed('unsupported');
|
|
148
|
+
agent = current;
|
|
149
|
+
session = current.agent_session;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
94
152
|
const kind = agent.agent;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
||
|
|
153
|
+
const metadata = accountKind(o.provider);
|
|
154
|
+
if (!metadata?.folderVar || !metadata.resume || kind !== metadata.kind
|
|
155
|
+
|| (request !== undefined && accountKind(request.kind)?.kind !== kind)
|
|
156
|
+
|| session.agent !== kind || !metadata.sessionKinds.includes(session.kind))
|
|
157
|
+
return failed('unsupported');
|
|
158
|
+
if (/[\x00-\x1f\x7f]/.test(session.value) || session.value.length > (session.kind === 'path' ? 4096 : 512)
|
|
159
|
+
|| (session.kind === 'path' && !isAbsolute(session.value)))
|
|
98
160
|
return failed('unsupported');
|
|
99
|
-
const args = request?.args ?? (
|
|
100
|
-
: kind === 'codex' && session.kind === 'id' ? ['resume', session.value] : undefined);
|
|
161
|
+
const args = request?.args ?? metadata.resumeArgs?.map((arg) => arg.replace('{session}', () => session.value));
|
|
101
162
|
if (args === undefined || !Array.isArray(args) || args.length === 0 || args.some((a) => typeof a !== 'string' || /[\r\n\0]/.test(a)))
|
|
102
163
|
return failed('unsupported');
|
|
103
164
|
if (!o.folder || /[\r\n\0]/.test(o.folder))
|
|
@@ -105,7 +166,7 @@ export function createAccountPanes(ctx) {
|
|
|
105
166
|
const timeout = o.timeoutMs ?? 60_000;
|
|
106
167
|
if (!Number.isFinite(timeout) || timeout <= 0)
|
|
107
168
|
return failed('start_failed');
|
|
108
|
-
const variable =
|
|
169
|
+
const variable = metadata.folderVar;
|
|
109
170
|
let paneId;
|
|
110
171
|
let replacement;
|
|
111
172
|
try {
|
|
@@ -158,25 +219,43 @@ export function createAccountPanes(ctx) {
|
|
|
158
219
|
const cleaned = await close(paneId);
|
|
159
220
|
return failed('start_failed', cleaned ? target.paneId : paneId);
|
|
160
221
|
}
|
|
222
|
+
// Compare identity, not revision: status changes can legitimately bump revision.
|
|
223
|
+
const survives = async (id, expected) => {
|
|
224
|
+
try {
|
|
225
|
+
const current = (await call('agent.get', { target: id }, timeout))?.agent;
|
|
226
|
+
return !!current && current.agent === expected.agent && current.launch_pending !== true
|
|
227
|
+
&& ['idle', 'working', 'blocked', 'done'].includes(current.agent_status)
|
|
228
|
+
&& current.agent_session?.agent === expected.agent_session?.agent
|
|
229
|
+
&& current.agent_session?.kind === expected.agent_session?.kind
|
|
230
|
+
&& current.agent_session?.value === expected.agent_session?.value
|
|
231
|
+
&& (expected.terminal_id === undefined || current.terminal_id === expected.terminal_id)
|
|
232
|
+
&& (expected.name === undefined || current.name === expected.name);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return false;
|
|
236
|
+
} // unavailable is not proof of survival
|
|
237
|
+
};
|
|
238
|
+
let current;
|
|
239
|
+
try {
|
|
240
|
+
current = (await call('agent.get', { target: target.paneId }, timeout))?.agent;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
current = {};
|
|
244
|
+
}
|
|
245
|
+
if (!current || current.launch_pending === true || !['idle', 'done'].includes(current.agent_status)
|
|
246
|
+
|| current.agent_status !== agent.agent_status || current.agent !== agent.agent || current.terminal_id !== agent.terminal_id
|
|
247
|
+
|| current.agent_session?.agent !== session.agent || current.agent_session?.kind !== session.kind
|
|
248
|
+
|| current.agent_session?.value !== session.value
|
|
249
|
+
|| (agent.state_change_seq !== undefined && current.state_change_seq !== agent.state_change_seq)) {
|
|
250
|
+
await close(paneId);
|
|
251
|
+
const live = await survives(paneId, replacement) ? paneId
|
|
252
|
+
: await survives(target.paneId, agent) ? target.paneId : null;
|
|
253
|
+
return failed('changed', live);
|
|
254
|
+
}
|
|
255
|
+
// Published Herdr has no conditional close: the final observation is not atomic.
|
|
161
256
|
if (!await close(target.paneId)) {
|
|
162
257
|
// A lost ACK can mean the close already applied. Never destroy the ready
|
|
163
258
|
// 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
259
|
const replacementAlive = await survives(paneId, replacement);
|
|
181
260
|
const sourceAlive = await survives(target.paneId, agent);
|
|
182
261
|
if (sourceAlive && replacementAlive)
|
|
@@ -199,10 +278,10 @@ export function createAccountPanes(ctx) {
|
|
|
199
278
|
}
|
|
200
279
|
}
|
|
201
280
|
async function move(o) {
|
|
202
|
-
const variable = o.kind
|
|
281
|
+
const variable = accountKind(o.kind)?.folderVar;
|
|
203
282
|
const result = await perform({ paneId: o.paneId }, {
|
|
204
|
-
provider: o.kind ===
|
|
205
|
-
env: o.set, timeoutMs: o.timeoutMs,
|
|
283
|
+
provider: o.kind, folder: variable === undefined ? '' : o.set[variable] ?? '',
|
|
284
|
+
env: o.set, timeoutMs: o.timeoutMs, whenBusy: o.whenBusy,
|
|
206
285
|
}, o);
|
|
207
286
|
return result.ok ? { ok: true, paneId: result.session } : result;
|
|
208
287
|
}
|
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>;
|
package/dist/agents.js
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
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, openSync, readSync, closeSync } from 'node:fs';
|
|
6
|
+
import { accessSync, constants as fsConstants, openSync, readSync, closeSync, statSync } from 'node:fs';
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
|
-
import { delimiter, join } from 'node:path';
|
|
8
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
9
9
|
import { words } from "./words.js";
|
|
10
|
+
import { accountKind } from "./kinds.js";
|
|
10
11
|
import { prepareLaunchEnv } from "./launch-env.js";
|
|
12
|
+
import { museNative, MUSE_INSTALL_URL } from "./muse.js";
|
|
11
13
|
const PROMPTABLE = ['idle', 'working', 'blocked', 'done'];
|
|
12
14
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
13
15
|
// `agent.start` lands while the fresh pane is still at its shell prompt: the server answers
|
|
@@ -62,19 +64,37 @@ export function createAgents(ctx) {
|
|
|
62
64
|
catch { /* a listener never breaks a start */ }
|
|
63
65
|
emitStart(e);
|
|
64
66
|
};
|
|
65
|
-
// Install pre-check: a shim or nothing on PATH means Herdr installs on first start, so the
|
|
66
|
-
// app hears `installing` before the long start instead of watching a blank pane.
|
|
67
67
|
let installExpected = false;
|
|
68
|
-
try {
|
|
69
|
-
installExpected = agentInstallState(o.kind, o.installProbe).state === 'installs-on-first-start';
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
installExpected = false;
|
|
73
|
-
}
|
|
74
|
-
if (installExpected)
|
|
75
|
-
emit({ phase: 'installing', kind: o.kind, message: words('agent.installing', { agent: o.kind }) });
|
|
76
68
|
let stage = 'placement';
|
|
77
69
|
try {
|
|
70
|
+
if (o.kind === 'muse') {
|
|
71
|
+
const replacement = o.env && typeof o.env.env === 'object' && Array.isArray(o.env.unset)
|
|
72
|
+
? o.env : undefined;
|
|
73
|
+
const existingPane = 'pane' in o.place && o.worktree === undefined;
|
|
74
|
+
// An ordinary env overlay cannot replace an existing shell's environment.
|
|
75
|
+
if (existingPane && !replacement && Object.keys(o.env ?? {}).length > 0) {
|
|
76
|
+
throw fail('env_mismatch', 'This pane needs to be opened again to use that sign-in.');
|
|
77
|
+
}
|
|
78
|
+
const effective = replacement?.env ?? (existingPane ? undefined
|
|
79
|
+
: { ...ctx.launchEnv?.(), ...o.env });
|
|
80
|
+
// No controller/global extras and no probe override when the effective PATH is known.
|
|
81
|
+
// Existing unobserved panes remain unknown; the exact RPC pass-through remains available.
|
|
82
|
+
const path = effective?.PATH?.split(delimiter).filter(isAbsolute);
|
|
83
|
+
const probe = path === undefined ? (existingPane ? undefined : o.installProbe) : { path };
|
|
84
|
+
if (probe && agentInstallState('muse', probe).state === 'missing') {
|
|
85
|
+
throw fail('agent_not_installed', words('agent.notInstalled'));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
try {
|
|
90
|
+
installExpected = agentInstallState(o.kind, o.installProbe).state === 'installs-on-first-start';
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
installExpected = false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (installExpected)
|
|
97
|
+
emit({ phase: 'installing', kind: o.kind, message: words('agent.installing', { agent: o.kind }) });
|
|
78
98
|
const ref = await startAgentInner(o, () => { stage = 'start'; });
|
|
79
99
|
emit({ phase: 'ready', kind: o.kind, ref });
|
|
80
100
|
return ref;
|
|
@@ -82,6 +102,8 @@ export function createAgents(ctx) {
|
|
|
82
102
|
catch (error) {
|
|
83
103
|
const reason = classifyStartFailure(error, { installExpected, stage });
|
|
84
104
|
emit({ phase: 'launchFailed', kind: o.kind, reason, message: launchFailureWords(reason, o.kind) });
|
|
105
|
+
if (codeOf(error) === 'agent_not_installed')
|
|
106
|
+
throw error;
|
|
85
107
|
if (o.env && typeof o.env.env === 'object')
|
|
86
108
|
throw fail(codeOf(error) === 'env_mismatch' ? 'env_mismatch' : 'start_failed', words('agent.notReady'));
|
|
87
109
|
throw error;
|
|
@@ -247,7 +269,7 @@ export function createAgents(ctx) {
|
|
|
247
269
|
return kinds.filter((kind) => resolveAgentBinary(kind, o).path !== undefined);
|
|
248
270
|
}
|
|
249
271
|
async function agentStatus(kinds, o) {
|
|
250
|
-
const path = o?.path ?? agentProbePath();
|
|
272
|
+
const path = o?.path ?? ctx.launchEnv?.()?.PATH?.split(delimiter) ?? agentProbePath();
|
|
251
273
|
const run = o?.run ?? runStatusCommand;
|
|
252
274
|
const timeoutMs = o?.timeoutMs ?? STATUS_TIMEOUT_MS;
|
|
253
275
|
const probe = { path, ...(o?.aliases === undefined ? {} : { aliases: o.aliases }),
|
|
@@ -255,18 +277,27 @@ export function createAgents(ctx) {
|
|
|
255
277
|
return Promise.all(kinds.map(async (kind) => {
|
|
256
278
|
const install = agentInstallState(kind, probe);
|
|
257
279
|
const binary = o?.aliases?.[kind]?.[0] ?? kind;
|
|
258
|
-
const installHint = kind === '
|
|
259
|
-
|
|
260
|
-
//
|
|
261
|
-
//
|
|
280
|
+
const installHint = kind === 'muse' ? `Install Muse explicitly from ${MUSE_INSTALL_URL} into the private launch environment.`
|
|
281
|
+
: kind === 'pi' ? PI_INSTALL_HINT : `Install the ${binary} command, then check again.`;
|
|
282
|
+
// Muse missing/launcher-only stays missing; legacy kinds retain their shim readiness.
|
|
283
|
+
// Install state never proves account or catalog readiness.
|
|
262
284
|
if (install.state !== 'installed')
|
|
263
285
|
return { kind, installed: false, installState: install.state, signedIn: 'unknown', installHint };
|
|
264
286
|
const statusProbe = STATUS_PROBES[kind];
|
|
265
|
-
|
|
287
|
+
const variable = accountKind(kind)?.folderVar;
|
|
288
|
+
const folder = o?.folders?.[kind];
|
|
289
|
+
if (statusProbe === undefined || variable === undefined || !folder || !isAbsolute(folder) || /[\r\n\0]/.test(folder))
|
|
266
290
|
return { kind, installed: true, installState: 'installed', signedIn: 'unknown', installHint };
|
|
267
291
|
let answer;
|
|
292
|
+
// A folder override alone is not isolation: the CLI can fall back to HOME,
|
|
293
|
+
// XDG or platform stores. Supply a clean env and confine every home to this account.
|
|
294
|
+
const env = { ...o?.env, PATH: path.join(delimiter), HOME: folder, USERPROFILE: folder,
|
|
295
|
+
XDG_CONFIG_HOME: join(folder, '.config'), XDG_STATE_HOME: join(folder, '.local', 'state'),
|
|
296
|
+
XDG_DATA_HOME: join(folder, '.local', 'share'), XDG_CACHE_HOME: join(folder, '.cache'),
|
|
297
|
+
APPDATA: join(folder, 'AppData', 'Roaming'), LOCALAPPDATA: join(folder, 'AppData', 'Local'),
|
|
298
|
+
[variable]: folder };
|
|
268
299
|
try {
|
|
269
|
-
answer = await run(
|
|
300
|
+
answer = await run(install.path, statusProbe.args, { ...(statusProbe.stdin === undefined ? {} : { stdin: statusProbe.stdin }), timeoutMs, env });
|
|
270
301
|
}
|
|
271
302
|
catch {
|
|
272
303
|
answer = undefined;
|
|
@@ -329,14 +360,27 @@ export function isAutoInstallShim(file, readFile = readHead) {
|
|
|
329
360
|
}
|
|
330
361
|
return head !== undefined && head.startsWith('#!') && head.includes('mise');
|
|
331
362
|
}
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
// the agent on first start). `missing` stays for callers that probe kinds Herdr never installs.
|
|
363
|
+
// Muse has no absent-CLI auto-installer in stock Herdr. Its official launcher needs the
|
|
364
|
+
// selected native release too. Other kinds retain their legacy shim classification.
|
|
335
365
|
export function agentInstallState(kind, o) {
|
|
336
366
|
const path = o?.path ?? agentProbePath();
|
|
337
367
|
const found = resolveAgentBinary(kind, { path, ...(o?.aliases === undefined ? {} : { aliases: o.aliases }) });
|
|
338
368
|
if (found.path === undefined)
|
|
339
|
-
return { kind, state: 'installs-on-first-start' };
|
|
369
|
+
return { kind, state: kind === 'muse' ? 'missing' : 'installs-on-first-start' };
|
|
370
|
+
if (kind === 'muse') {
|
|
371
|
+
try {
|
|
372
|
+
if (!statSync(found.path).isFile())
|
|
373
|
+
return { kind, state: 'missing', path: found.path };
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
return { kind, state: 'missing', path: found.path };
|
|
377
|
+
}
|
|
378
|
+
const head = (o?.readFile ?? readHead)(found.path);
|
|
379
|
+
if (head?.startsWith('#!') && (head.includes('mise') || (head.includes('MUSE_CHANNEL') && !museNative(found.path)))) {
|
|
380
|
+
return { kind, state: 'missing', path: found.path };
|
|
381
|
+
}
|
|
382
|
+
return { kind, state: 'installed', path: found.path };
|
|
383
|
+
}
|
|
340
384
|
const shim = isAutoInstallShim(found.path, o?.readFile);
|
|
341
385
|
return shim ? { kind, state: 'installs-on-first-start', path: found.path }
|
|
342
386
|
: { kind, state: 'installed', path: found.path };
|
|
@@ -346,6 +390,8 @@ export function agentInstallState(kind, o) {
|
|
|
346
390
|
// finished, a placement that made no pane, or any other rejected start.
|
|
347
391
|
export function classifyStartFailure(error, o) {
|
|
348
392
|
const code = codeOf(error);
|
|
393
|
+
if (code === 'agent_not_installed')
|
|
394
|
+
return 'not-installed';
|
|
349
395
|
if (code !== undefined && START_RETRYABLE.has(code))
|
|
350
396
|
return 'pane-busy';
|
|
351
397
|
if (o?.stage === 'placement')
|
|
@@ -355,6 +401,8 @@ export function classifyStartFailure(error, o) {
|
|
|
355
401
|
return 'start-rejected';
|
|
356
402
|
}
|
|
357
403
|
function launchFailureWords(reason, kind) {
|
|
404
|
+
if (reason === 'not-installed')
|
|
405
|
+
return words('agent.notInstalled');
|
|
358
406
|
if (reason === 'pane-busy')
|
|
359
407
|
return words('agent.notReady');
|
|
360
408
|
if (reason === 'install-failed')
|
|
@@ -396,7 +444,7 @@ const STATUS_PROBES = {
|
|
|
396
444
|
}
|
|
397
445
|
if (!isRecord(status))
|
|
398
446
|
return 'unknown';
|
|
399
|
-
return status.loggedIn === true ? 'yes' : 'no';
|
|
447
|
+
return status.loggedIn === true ? 'yes' : status.loggedIn === false ? 'no' : 'unknown';
|
|
400
448
|
} },
|
|
401
449
|
// Codex exposes sign-in only over its app-server protocol, so the probe pipes a pipelined
|
|
402
450
|
// `initialize` + `account/read` round (same method muxr's planIdentity uses) and reads the
|
|
@@ -413,7 +461,10 @@ const STATUS_PROBES = {
|
|
|
413
461
|
continue;
|
|
414
462
|
}
|
|
415
463
|
if (isRecord(message) && message.id === 2) {
|
|
416
|
-
|
|
464
|
+
if (!isRecord(message.result))
|
|
465
|
+
return 'unknown';
|
|
466
|
+
return isRecord(message.result.account) ? 'yes'
|
|
467
|
+
: message.result.account === null || !Object.hasOwn(message.result, 'account') ? 'no' : 'unknown';
|
|
417
468
|
}
|
|
418
469
|
}
|
|
419
470
|
return 'unknown';
|
|
@@ -422,8 +473,12 @@ const STATUS_PROBES = {
|
|
|
422
473
|
// Default runner: one bounded spawn per probe, stdin piped when the protocol needs it (codex).
|
|
423
474
|
// Failures (missing binary, timeout, non-empty stderr, empty stdout) read as no answer — never throw.
|
|
424
475
|
export async function runStatusCommand(command, args, o) {
|
|
476
|
+
// Public direct calls without an explicit isolated HOME are not allowed to read a login.
|
|
477
|
+
if (!o?.env?.HOME || !isAbsolute(o.env.HOME) || /[\r\n\0]/.test(o.env.HOME))
|
|
478
|
+
return undefined;
|
|
425
479
|
return new Promise((resolve) => {
|
|
426
480
|
let done = false;
|
|
481
|
+
let timer;
|
|
427
482
|
const finish = (value) => {
|
|
428
483
|
if (done)
|
|
429
484
|
return;
|
|
@@ -433,14 +488,14 @@ export async function runStatusCommand(command, args, o) {
|
|
|
433
488
|
};
|
|
434
489
|
let child;
|
|
435
490
|
try {
|
|
436
|
-
child = spawn(command, args, { stdio: ['pipe', 'pipe', 'ignore'], timeout: o
|
|
491
|
+
child = spawn(command, args, { env: o.env, stdio: ['pipe', 'pipe', 'ignore'], timeout: o.timeoutMs ?? STATUS_TIMEOUT_MS });
|
|
437
492
|
}
|
|
438
493
|
catch {
|
|
439
494
|
finish(undefined);
|
|
440
495
|
return;
|
|
441
496
|
}
|
|
442
497
|
let out = '';
|
|
443
|
-
|
|
498
|
+
timer = setTimeout(() => { child.kill('SIGKILL'); finish(undefined); }, (o?.timeoutMs ?? STATUS_TIMEOUT_MS) + 500);
|
|
444
499
|
child.on('error', () => finish(undefined));
|
|
445
500
|
const stdout = child.stdout;
|
|
446
501
|
const stdin = child.stdin;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
export { installMuse, museReadiness, MUSE_INSTALL_URL } from './muse.ts';
|
|
2
|
+
export type { InstallMuseOptions, MuseReadiness, MuseInstallReceipt, MuseInstallResult } from './muse.ts';
|
|
1
3
|
export { HERDR_VERSION, HERDR_PROTOCOL } from './constants.ts';
|
|
4
|
+
export { accountKinds, accountKind, type AccountKind } from './kinds.ts';
|
|
2
5
|
export { HerdrKit } from './kit.ts';
|
|
3
6
|
export { agentWords, stateWords, words, WORDS, type WordKey } from './words.ts';
|
|
4
7
|
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, AgentTurnEnd, AgentTurnOptions, AgentTurnFiles, AgentTurnResult, AgentTurnResultPolicy, } from './types.ts';
|
|
8
|
+
export type { AgentCliSignIn, AgentInstallProbe, AgentInstallState, AgentLaunchFailureReason, AgentReadiness, AgentStartEvent, AgentStatusOptions, AgentStatusRunner, AgentRef, AgentStatus, BlockedAgent, HerdrEvent, HerdrEventName, HerdrEventOf, HerdrKitOptions, HerdrMethod, HerdrMethods, HerdrParams, HerdrProtocolRange, HerdrResult, HerdrSnapshot, HerdrState, HerdrSubscription, HerdrSubscribeStop, HerdrTransport, BusyHandoff, Move, MoveToAccount, MoveToAccountResult, MoveResult, OpenSignInTab, PromptReceipt, StartAgent, TerminalSession, AgentTurnEnd, AgentTurnOptions, AgentTurnFiles, AgentTurnResult, AgentTurnResultPolicy, } from './types.ts';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// `@byokit/herdr` — the Herdr runtime kit (Node host side). The phone and browser side lives in `./device`, the
|
|
2
2
|
// host-side link adapter in `./link`; the fake Herdr and contract suite arrive with H6 in `./testing`.
|
|
3
|
+
export { installMuse, museReadiness, MUSE_INSTALL_URL } from "./muse.js";
|
|
3
4
|
export { HERDR_VERSION, HERDR_PROTOCOL } from "./constants.js";
|
|
5
|
+
export { accountKinds, accountKind } from "./kinds.js";
|
|
4
6
|
export { HerdrKit } from "./kit.js";
|
|
5
7
|
export { agentWords, stateWords, words, WORDS } from "./words.js";
|
|
6
8
|
export { agentProbePath, extraPathDirs, runStatusCommand, agentInstallState, isAutoInstallShim, resolveAgentBinary, classifyStartFailure } from "./agents.js";
|
package/dist/kinds.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Pinned capability data, not login readiness or native move qualification. */
|
|
2
|
+
export type AccountKind = {
|
|
3
|
+
kind: string;
|
|
4
|
+
aliases: string[];
|
|
5
|
+
aliasPattern?: string;
|
|
6
|
+
folderVar?: string;
|
|
7
|
+
resume: boolean;
|
|
8
|
+
sessionKinds: ('id' | 'path')[];
|
|
9
|
+
resumeArgs?: string[];
|
|
10
|
+
loginLabel: string;
|
|
11
|
+
billing: 'unknown';
|
|
12
|
+
offer: 'explicit';
|
|
13
|
+
upstream: {
|
|
14
|
+
version: string;
|
|
15
|
+
revision: string;
|
|
16
|
+
source: string;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
export declare function accountKinds(): AccountKind[];
|
|
20
|
+
export declare function accountKind(kind: string): AccountKind | undefined;
|
package/dist/kinds.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import table from './kinds.json' with { type: 'json' };
|
|
2
|
+
export function accountKinds() { return structuredClone(table); }
|
|
3
|
+
export function accountKind(kind) {
|
|
4
|
+
return accountKinds().find((row) => row.kind === kind || row.aliases.includes(kind)
|
|
5
|
+
|| (row.aliasPattern !== undefined && new RegExp(row.aliasPattern).test(kind)));
|
|
6
|
+
}
|