@phnx-labs/agents-cli 1.20.73 → 1.20.76
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 +989 -0
- package/README.md +36 -10
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +21 -3
- package/dist/commands/attach.d.ts +10 -0
- package/dist/commands/attach.js +41 -0
- package/dist/commands/computer.js +3 -3
- package/dist/commands/detach-core.d.ts +46 -0
- package/dist/commands/detach-core.js +61 -0
- package/dist/commands/detach.d.ts +5 -0
- package/dist/commands/detach.js +171 -0
- package/dist/commands/doctor.js +31 -1
- package/dist/commands/exec.js +31 -3
- package/dist/commands/export.d.ts +11 -0
- package/dist/commands/export.js +215 -0
- package/dist/commands/feed.d.ts +7 -1
- package/dist/commands/feed.js +47 -4
- package/dist/commands/go.d.ts +9 -2
- package/dist/commands/go.js +16 -6
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +145 -0
- package/dist/commands/import.js +87 -9
- package/dist/commands/inspect.js +5 -2
- package/dist/commands/profiles.d.ts +20 -0
- package/dist/commands/profiles.js +68 -21
- package/dist/commands/repo.js +14 -0
- package/dist/commands/routines.js +15 -2
- package/dist/commands/secrets.js +44 -21
- package/dist/commands/sessions-browser.js +1 -1
- package/dist/commands/sessions-export.js +19 -5
- package/dist/commands/sessions-migrate.d.ts +29 -0
- package/dist/commands/sessions-migrate.js +596 -0
- package/dist/commands/sessions-picker.js +16 -1
- package/dist/commands/sessions.d.ts +36 -13
- package/dist/commands/sessions.js +210 -40
- package/dist/commands/setup-computer.js +2 -2
- package/dist/commands/setup.js +6 -1
- package/dist/commands/ssh.js +71 -35
- package/dist/commands/status.js +10 -2
- package/dist/commands/versions.js +51 -11
- package/dist/commands/view.d.ts +5 -0
- package/dist/commands/view.js +99 -31
- package/dist/commands/watchdog.d.ts +7 -2
- package/dist/commands/watchdog.js +73 -57
- package/dist/index.js +59 -7
- package/dist/lib/activity.d.ts +14 -4
- package/dist/lib/activity.js +17 -5
- package/dist/lib/actor.d.ts +50 -0
- package/dist/lib/actor.js +166 -0
- package/dist/lib/agent-spec/provider.js +2 -1
- package/dist/lib/agent-spec/resolve.js +19 -5
- package/dist/lib/agent-spec/types.d.ts +9 -1
- package/dist/lib/agents.d.ts +56 -0
- package/dist/lib/agents.js +133 -7
- package/dist/lib/cloud/codex.d.ts +2 -0
- package/dist/lib/cloud/codex.js +14 -3
- package/dist/lib/cloud/session-index.d.ts +32 -0
- package/dist/lib/cloud/session-index.js +58 -0
- package/dist/lib/cloud/store.d.ts +7 -0
- package/dist/lib/cloud/store.js +25 -0
- package/dist/lib/config-transfer.d.ts +16 -0
- package/dist/lib/config-transfer.js +60 -0
- package/dist/lib/daemon.js +18 -2
- package/dist/lib/devices/connect.d.ts +15 -1
- package/dist/lib/devices/connect.js +15 -1
- package/dist/lib/devices/fleet.d.ts +21 -0
- package/dist/lib/devices/fleet.js +26 -0
- package/dist/lib/devices/health-report.d.ts +38 -0
- package/dist/lib/devices/health-report.js +214 -0
- package/dist/lib/devices/health.js +4 -1
- package/dist/lib/devices/reachability.d.ts +31 -0
- package/dist/lib/devices/reachability.js +40 -0
- package/dist/lib/devices/registry.d.ts +33 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/devices/resolve-target.d.ts +15 -27
- package/dist/lib/devices/resolve-target.js +63 -102
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +23 -1
- package/dist/lib/devices/tailscale.d.ts +3 -0
- package/dist/lib/devices/tailscale.js +1 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +66 -0
- package/dist/lib/exec.js +195 -6
- package/dist/lib/export.d.ts +72 -0
- package/dist/lib/export.js +269 -0
- package/dist/lib/feed-post.d.ts +63 -0
- package/dist/lib/feed-post.js +204 -0
- package/dist/lib/fleet/apply.d.ts +32 -2
- package/dist/lib/fleet/apply.js +97 -10
- package/dist/lib/fleet/types.d.ts +11 -0
- package/dist/lib/fs-walk.d.ts +13 -0
- package/dist/lib/fs-walk.js +16 -7
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +156 -0
- package/dist/lib/hosts/dispatch.d.ts +7 -0
- package/dist/lib/hosts/dispatch.js +2 -0
- package/dist/lib/hosts/passthrough.js +2 -0
- package/dist/lib/hosts/reconnect.d.ts +78 -0
- package/dist/lib/hosts/reconnect.js +127 -0
- package/dist/lib/hosts/registry.d.ts +75 -14
- package/dist/lib/hosts/registry.js +205 -30
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/hosts/run-target.d.ts +13 -5
- package/dist/lib/hosts/run-target.js +26 -10
- package/dist/lib/hosts/session-index.d.ts +18 -1
- package/dist/lib/hosts/session-index.js +37 -2
- package/dist/lib/hosts/session-marker.d.ts +33 -0
- package/dist/lib/hosts/session-marker.js +51 -0
- package/dist/lib/hosts/tasks.d.ts +8 -4
- package/dist/lib/import.d.ts +25 -0
- package/dist/lib/import.js +107 -2
- package/dist/lib/isolation-boundary-report.d.ts +10 -0
- package/dist/lib/isolation-boundary-report.js +35 -0
- package/dist/lib/menubar/install-menubar.d.ts +20 -0
- package/dist/lib/menubar/install-menubar.js +45 -1
- package/dist/lib/migrate.d.ts +16 -0
- package/dist/lib/migrate.js +36 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +54 -1
- package/dist/lib/overdue.d.ts +7 -2
- package/dist/lib/overdue.js +18 -11
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/process.d.ts +17 -0
- package/dist/lib/platform/process.js +70 -0
- package/dist/lib/profiles-presets.js +9 -7
- package/dist/lib/profiles.d.ts +31 -0
- package/dist/lib/profiles.js +70 -0
- package/dist/lib/project-resources.js +20 -5
- package/dist/lib/pty-server.d.ts +2 -10
- package/dist/lib/pty-server.js +4 -38
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +48 -8
- package/dist/lib/rotate.d.ts +18 -0
- package/dist/lib/rotate.js +28 -0
- package/dist/lib/routines.d.ts +26 -0
- package/dist/lib/routines.js +19 -2
- package/dist/lib/runner.js +104 -7
- package/dist/lib/secrets/agent.d.ts +66 -1
- package/dist/lib/secrets/agent.js +154 -90
- package/dist/lib/secrets/bundles.d.ts +34 -0
- package/dist/lib/secrets/bundles.js +179 -19
- package/dist/lib/secrets/index.d.ts +15 -1
- package/dist/lib/secrets/index.js +21 -3
- package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
- package/dist/lib/secrets/rc-hygiene.js +154 -0
- package/dist/lib/secrets/remote.d.ts +7 -4
- package/dist/lib/secrets/remote.js +7 -4
- package/dist/lib/secrets/sync-commands.d.ts +21 -0
- package/dist/lib/secrets/sync-commands.js +21 -0
- package/dist/lib/self-update.d.ts +23 -2
- package/dist/lib/self-update.js +86 -5
- package/dist/lib/session/active.d.ts +114 -7
- package/dist/lib/session/active.js +323 -52
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +48 -0
- package/dist/lib/session/db.js +238 -14
- package/dist/lib/session/detached.d.ts +30 -0
- package/dist/lib/session/detached.js +92 -0
- package/dist/lib/session/discover.d.ts +465 -2
- package/dist/lib/session/discover.js +1648 -468
- package/dist/lib/session/fork.js +2 -1
- package/dist/lib/session/hook-sessions.d.ts +43 -0
- package/dist/lib/session/hook-sessions.js +135 -0
- package/dist/lib/session/linear.d.ts +6 -0
- package/dist/lib/session/linear.js +55 -0
- package/dist/lib/session/migrate-targets.d.ts +65 -0
- package/dist/lib/session/migrate-targets.js +94 -0
- package/dist/lib/session/migrations.d.ts +37 -0
- package/dist/lib/session/migrations.js +60 -0
- package/dist/lib/session/parse.d.ts +18 -0
- package/dist/lib/session/parse.js +130 -30
- package/dist/lib/session/pid-registry.d.ts +34 -2
- package/dist/lib/session/pid-registry.js +49 -2
- package/dist/lib/session/render.d.ts +7 -2
- package/dist/lib/session/render.js +22 -10
- package/dist/lib/session/short-id.d.ts +17 -0
- package/dist/lib/session/short-id.js +20 -0
- package/dist/lib/session/state.js +6 -2
- package/dist/lib/shims.d.ts +27 -1
- package/dist/lib/shims.js +88 -6
- package/dist/lib/sqlite.d.ts +3 -2
- package/dist/lib/sqlite.js +27 -4
- package/dist/lib/staleness/detectors/commands.js +1 -1
- package/dist/lib/staleness/detectors/workflows.js +13 -2
- package/dist/lib/staleness/writers/commands.js +7 -7
- package/dist/lib/staleness/writers/workflows.d.ts +4 -2
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +5 -0
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +15 -36
- package/dist/lib/teams/agents.d.ts +19 -10
- package/dist/lib/teams/agents.js +40 -39
- package/dist/lib/types.d.ts +48 -2
- package/dist/lib/uninstall.js +1 -45
- package/dist/lib/usage.d.ts +33 -1
- package/dist/lib/usage.js +172 -12
- package/dist/lib/versions.d.ts +22 -2
- package/dist/lib/versions.js +98 -10
- package/dist/lib/watchdog/log.d.ts +43 -0
- package/dist/lib/watchdog/log.js +69 -0
- package/dist/lib/watchdog/routine.d.ts +44 -0
- package/dist/lib/watchdog/routine.js +69 -0
- package/dist/lib/watchdog/runner.d.ts +51 -7
- package/dist/lib/watchdog/runner.js +239 -64
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +31 -16
- package/dist/lib/workflows.d.ts +16 -0
- package/dist/lib/workflows.js +110 -1
- package/package.json +1 -1
package/dist/lib/agents.js
CHANGED
|
@@ -320,8 +320,8 @@ export const AGENTS = {
|
|
|
320
320
|
instructionsFile: 'AGENTS.md',
|
|
321
321
|
format: 'markdown',
|
|
322
322
|
variableSyntax: '$ARGUMENTS',
|
|
323
|
-
supportsHooks:
|
|
324
|
-
capabilities: { hooks:
|
|
323
|
+
supportsHooks: true,
|
|
324
|
+
capabilities: { hooks: { since: '0.3.130' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: { since: '1.1.1' }, skills: true, commands: true, plugins: true, subagents: true, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['plan', 'edit'] },
|
|
325
325
|
},
|
|
326
326
|
openclaw: {
|
|
327
327
|
id: 'openclaw',
|
|
@@ -479,6 +479,8 @@ export const AGENTS = {
|
|
|
479
479
|
// ~/.grok/hooks/ (+ project .grok/hooks/) — events PreToolUse, PostToolUse, etc.
|
|
480
480
|
// Plugins live in ~/.grok/plugins/ with marketplaces. Permissions: --allow/--deny
|
|
481
481
|
// CLI flags or [permission] TOML block in ~/.grok/config.toml.
|
|
482
|
+
// Workflows (native Rhai orchestration) enabled by default as of v0.2.111 —
|
|
483
|
+
// projected into ~/.grok/workflows/<name>.rhai (under GROK_HOME isolation).
|
|
482
484
|
grok: {
|
|
483
485
|
id: 'grok',
|
|
484
486
|
name: 'Grok',
|
|
@@ -487,8 +489,12 @@ export const AGENTS = {
|
|
|
487
489
|
npmPackage: '',
|
|
488
490
|
installScript: 'curl -fsSL https://x.ai/cli/install.sh | bash',
|
|
489
491
|
configDir: path.join(HOME, '.grok'),
|
|
490
|
-
|
|
491
|
-
|
|
492
|
+
// Grok discovers file-based slash commands from ~/.agents/commands/ (the
|
|
493
|
+
// cross-agent compat dir it mirrors from Claude Code) as well as the legacy
|
|
494
|
+
// ~/.claude/commands/ symlink. We write there directly so the per-agent
|
|
495
|
+
// install path and the central user repo stay in sync.
|
|
496
|
+
commandsDir: path.join(HOME, '.agents', 'commands'),
|
|
497
|
+
commandsSubdir: path.join('..', '.agents', 'commands'),
|
|
492
498
|
skillsDir: path.join(HOME, '.grok', 'skills'),
|
|
493
499
|
hooksDir: path.join(HOME, '.grok', 'hooks'),
|
|
494
500
|
instructionsFile: 'AGENTS.md',
|
|
@@ -502,11 +508,13 @@ export const AGENTS = {
|
|
|
502
508
|
mcpHeaders: false,
|
|
503
509
|
allowlist: true, // maps to Grok's granular Bash/Edit/Write/Read/Grep/WebFetch/MCPTool rules
|
|
504
510
|
skills: true,
|
|
505
|
-
commands:
|
|
511
|
+
commands: true, // Grok >= 0.2.111: file-based slash commands from ~/.agents/commands/ (docs) + ~/.claude/commands/ (Claude Code compat)
|
|
506
512
|
plugins: true,
|
|
507
513
|
subagents: true, // ~/.grok/agents/*.md (Claude-compatible agent defs)
|
|
508
514
|
rules: { file: 'AGENTS.md' },
|
|
509
|
-
workflows
|
|
515
|
+
// Native workflows (`.rhai` under ~/.grok/workflows/) shipped on-by-default
|
|
516
|
+
// in v0.2.111 (2026-07-22). See transformWorkflowForGrok in workflows.ts.
|
|
517
|
+
workflows: { since: '0.2.111' },
|
|
510
518
|
memory: true,
|
|
511
519
|
modes: ['plan', 'edit', 'skip'],
|
|
512
520
|
// grok's `--permission-mode plan` silently stalls a headless `-p` run at
|
|
@@ -583,6 +591,24 @@ export const AGENTS = {
|
|
|
583
591
|
// `.factory-plugin/` (it also reads `.claude-plugin/` for compatibility) — set
|
|
584
592
|
// pluginManifestDir so syncPluginToVersion mirrors the manifest into it, the
|
|
585
593
|
// same pattern codex uses with `.codex-plugin`.
|
|
594
|
+
//
|
|
595
|
+
// Workflows / Factory Missions (RUSH-1864, probed droid v0.177.0; ticket cited
|
|
596
|
+
// v0.161.0 — self-updating, no pinnable semver): Factory ships genuine multi-
|
|
597
|
+
// step orchestration ("Missions") via `/missions` (interactive) and
|
|
598
|
+
// `droid exec --mission` (headless; optional `-f <prompt.md>` is a *prompt*
|
|
599
|
+
// file, not a named mission template). Docs:
|
|
600
|
+
// https://docs.factory.ai/cli/features/missions and
|
|
601
|
+
// https://docs.factory.ai/docs/droid-exec/overview (Mission Mode).
|
|
602
|
+
//
|
|
603
|
+
// Mission state may land under `~/.factory/missions/<sessionId>/` at runtime
|
|
604
|
+
// (`getMissionsDir` = join(home, ".factory", "missions") + per-session
|
|
605
|
+
// mission.md / features.json / progress_log.jsonl) — that is **session
|
|
606
|
+
// runtime state**, not a discovery directory of installable named workflows.
|
|
607
|
+
// There is no discoverMission / template registry, and a cold install has no
|
|
608
|
+
// `~/.factory/missions/` until a mission is run. agents-cli's workflows
|
|
609
|
+
// capability means "sync WORKFLOW.md into an auto-discovered dir" — Droid
|
|
610
|
+
// has no such dir, so workflows stays false (invoke-only; do not fabricate a
|
|
611
|
+
// writer target).
|
|
586
612
|
droid: {
|
|
587
613
|
id: 'droid',
|
|
588
614
|
name: 'Droid',
|
|
@@ -615,6 +641,7 @@ export const AGENTS = {
|
|
|
615
641
|
plugins: true,
|
|
616
642
|
subagents: true,
|
|
617
643
|
rules: { file: 'AGENTS.md' },
|
|
644
|
+
// Factory Missions are invoke-only — no installable discovery dir (RUSH-1864).
|
|
618
645
|
workflows: false,
|
|
619
646
|
memory: false,
|
|
620
647
|
modes: ['plan', 'edit', 'auto', 'skip'],
|
|
@@ -860,7 +887,20 @@ export async function getCliState(agentId) {
|
|
|
860
887
|
}
|
|
861
888
|
}
|
|
862
889
|
}
|
|
863
|
-
|
|
890
|
+
return getUnmanagedCliState(agentId);
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Resolve the agent's OWN install — the one agents-cli does not manage — by plain
|
|
894
|
+
* PATH lookup, ignoring the version dirs entirely.
|
|
895
|
+
*
|
|
896
|
+
* Callers that specifically mean "the user's own globally-installed CLI" must use
|
|
897
|
+
* this rather than `getCliState`, whose managed fast path reports an installed
|
|
898
|
+
* version (any version dir, in readdir order) and would therefore hand back an
|
|
899
|
+
* isolated copy — a copy that is deliberately unreachable from PATH — labelled as
|
|
900
|
+
* the global install.
|
|
901
|
+
*/
|
|
902
|
+
export async function getUnmanagedCliState(agentId) {
|
|
903
|
+
const agent = AGENTS[agentId];
|
|
864
904
|
// Special case for grok: it manages its own binaries in ~/.grok/downloads/
|
|
865
905
|
if (agentId === 'grok') {
|
|
866
906
|
const grokBin = resolveGrokBinary();
|
|
@@ -1322,6 +1362,85 @@ function isValidOpenCodeCredential(value) {
|
|
|
1322
1362
|
default: return false;
|
|
1323
1363
|
}
|
|
1324
1364
|
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Whether a Claude version home's credential file is present but carries no
|
|
1367
|
+
* token — the "must have a real credential" floor (see
|
|
1368
|
+
* `isValidOpenCodeCredential`) applied to claude.
|
|
1369
|
+
*
|
|
1370
|
+
* A FAILED OAuth refresh leaves exactly this state behind: Claude Code rewrites
|
|
1371
|
+
* `.claude/.credentials.json` with `accessToken: ""`, `refreshToken: ""` and
|
|
1372
|
+
* `expiresAt: 0`, keeping only the descriptive fields (`subscriptionType`,
|
|
1373
|
+
* `rateLimitTier`, `refreshTokenExpiresAt`). Everything we derive from
|
|
1374
|
+
* `.claude.json` — email, plan — still looks healthy, so the install reported
|
|
1375
|
+
* `signedIn: true`, `agents view` drew usage bars for it, and balanced rotation
|
|
1376
|
+
* (whose `authValid` is just "email present") kept picking it — every pick dying
|
|
1377
|
+
* at spawn on "OAuth session expired and could not be refreshed".
|
|
1378
|
+
*
|
|
1379
|
+
* Only decidable off macOS: there the login Keychain is the canonical store and
|
|
1380
|
+
* this file is not authoritative, and probing the Keychain would raise an
|
|
1381
|
+
* authorization sheet per installed version on every `agents run` — the reason
|
|
1382
|
+
* rotation stopped calling `isClaudeAuthValid` at all. Off macOS the file IS the
|
|
1383
|
+
* only store, so a token-less file is proof of signed-out. `platform` is a
|
|
1384
|
+
* parameter so both branches are testable on any host.
|
|
1385
|
+
*
|
|
1386
|
+
* Sync, no Keychain, no network — safe on the `agents run` hot path.
|
|
1387
|
+
*/
|
|
1388
|
+
export function isClaudeCredentialFileBlank(base, platform = process.platform) {
|
|
1389
|
+
if (platform === 'darwin')
|
|
1390
|
+
return false;
|
|
1391
|
+
try {
|
|
1392
|
+
const raw = fs.readFileSync(path.join(base, '.claude', '.credentials.json'), 'utf-8');
|
|
1393
|
+
const oauth = JSON.parse(raw).claudeAiOauth;
|
|
1394
|
+
if (!oauth)
|
|
1395
|
+
return false;
|
|
1396
|
+
const nonEmpty = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
1397
|
+
return !nonEmpty(oauth.accessToken) && !nonEmpty(oauth.refreshToken);
|
|
1398
|
+
}
|
|
1399
|
+
catch {
|
|
1400
|
+
// No file (a Keychain-backed home, or never logged in here) or an
|
|
1401
|
+
// unreadable/corrupt one: not positive evidence of a blank credential, so
|
|
1402
|
+
// leave the existing signal alone rather than declaring a working install
|
|
1403
|
+
// signed out.
|
|
1404
|
+
return false;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* Whether a Claude version home holds its OWN on-disk credential — a non-blank
|
|
1409
|
+
* `.claude/.credentials.json` (the store Claude Code writes on login off macOS)
|
|
1410
|
+
* or a `.claude/.oauth_token` file (the keychain-less shim fallback, shims.ts).
|
|
1411
|
+
*
|
|
1412
|
+
* RUSH-1979: the routines daemon injects one ambient `CLAUDE_CODE_OAUTH_TOKEN`
|
|
1413
|
+
* (RUSH-1759) so a token-less default account still authenticates. But Claude —
|
|
1414
|
+
* and the Linux shim's own `-z CLAUDE_CODE_OAUTH_TOKEN` guard — both prefer that
|
|
1415
|
+
* env var over a pinned account's config dir, so when balanced rotation pins a
|
|
1416
|
+
* specific account's `CLAUDE_CONFIG_DIR` the ambient token shadows it: the pool
|
|
1417
|
+
* is inert and every fire 401s on the one stale token. A routine spawn drops the
|
|
1418
|
+
* ambient token when the pinned account has its own credential (this returns
|
|
1419
|
+
* true) so the account authenticates itself; when it does not (the RUSH-1759
|
|
1420
|
+
* default), the injected token is kept.
|
|
1421
|
+
*
|
|
1422
|
+
* Off-darwin only, mirroring {@link isClaudeCredentialFileBlank}: on macOS the
|
|
1423
|
+
* login Keychain is the canonical store and probing it raises an auth sheet, and
|
|
1424
|
+
* there the daemon injects no token to shadow (broker-only, usually absent), so
|
|
1425
|
+
* returning false changes nothing. Sync, no Keychain, no network.
|
|
1426
|
+
*/
|
|
1427
|
+
export function claudeHomeHasOwnCredential(base, platform = process.platform) {
|
|
1428
|
+
if (platform === 'darwin')
|
|
1429
|
+
return false;
|
|
1430
|
+
if (fs.existsSync(path.join(base, '.claude', '.oauth_token')))
|
|
1431
|
+
return true;
|
|
1432
|
+
try {
|
|
1433
|
+
const raw = fs.readFileSync(path.join(base, '.claude', '.credentials.json'), 'utf-8');
|
|
1434
|
+
const oauth = JSON.parse(raw).claudeAiOauth;
|
|
1435
|
+
if (!oauth)
|
|
1436
|
+
return false;
|
|
1437
|
+
const nonEmpty = (v) => typeof v === 'string' && v.trim().length > 0;
|
|
1438
|
+
return nonEmpty(oauth.accessToken) || nonEmpty(oauth.refreshToken);
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
return false;
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1325
1444
|
export async function getAccountInfo(agentId, home) {
|
|
1326
1445
|
const base = home || os.homedir();
|
|
1327
1446
|
const empty = {
|
|
@@ -1358,6 +1477,13 @@ export async function getAccountInfo(agentId, home) {
|
|
|
1358
1477
|
const accountId = normalizeIdentityPart(oa?.accountUuid);
|
|
1359
1478
|
const organizationId = normalizeIdentityPart(oa?.organizationUuid);
|
|
1360
1479
|
const email = oa?.emailAddress || null;
|
|
1480
|
+
// Credential floor: a blanked credential file means this home cannot
|
|
1481
|
+
// authenticate, whatever `.claude.json` still says. Report it signed out
|
|
1482
|
+
// so `agents view` prompts a re-login and rotation routes around it,
|
|
1483
|
+
// instead of handing runs to an install that dies at spawn.
|
|
1484
|
+
if (email && isClaudeCredentialFileBlank(base)) {
|
|
1485
|
+
return { ...empty, lastActive };
|
|
1486
|
+
}
|
|
1361
1487
|
const accountKey = buildIdentityKey(agentId, [
|
|
1362
1488
|
['account', accountId],
|
|
1363
1489
|
['org', organizationId],
|
|
@@ -24,3 +24,5 @@ export declare class CodexCloudProvider implements CloudProvider {
|
|
|
24
24
|
cancel(_taskId: string): Promise<void>;
|
|
25
25
|
message(_taskId: string, _content: string): Promise<void>;
|
|
26
26
|
}
|
|
27
|
+
/** Extract a task ID from codex CLI output (JSON field, key:value line, or UUID pattern). */
|
|
28
|
+
export declare function extractTaskId(output: string): string | undefined;
|
package/dist/lib/cloud/codex.js
CHANGED
|
@@ -127,8 +127,19 @@ export class CodexCloudProvider {
|
|
|
127
127
|
if (code !== 0) {
|
|
128
128
|
throw new Error(`codex cloud exec failed: ${stderr || stdout}`);
|
|
129
129
|
}
|
|
130
|
-
//
|
|
131
|
-
|
|
130
|
+
// The task id is the ONLY handle to the just-created execution — status,
|
|
131
|
+
// list, and the session-index reconcile all key on it. Codex prints it to
|
|
132
|
+
// stdout (JSON or a task_/id: line); some builds route it to stderr, so scan
|
|
133
|
+
// both. A synthetic `codex-<ts>` id was the old fallback here — it can never
|
|
134
|
+
// match the real execution, so it silently broke every follow-up. If the id
|
|
135
|
+
// genuinely can't be parsed, fail loudly and point at `agents cloud list`
|
|
136
|
+
// (which recovers the newest execution) rather than persist a bogus id.
|
|
137
|
+
const taskId = extractTaskId(stdout) ?? extractTaskId(stderr);
|
|
138
|
+
if (!taskId) {
|
|
139
|
+
throw new Error('codex cloud exec did not report a task id — the run may have dispatched, but ' +
|
|
140
|
+
'without the id it cannot be tracked. Find it with `agents cloud list --provider codex`, ' +
|
|
141
|
+
`then \`agents cloud status <id>\`.\nRaw output: ${(stdout || stderr).trim().slice(0, 400)}`);
|
|
142
|
+
}
|
|
132
143
|
const now = new Date().toISOString();
|
|
133
144
|
return {
|
|
134
145
|
id: taskId,
|
|
@@ -232,7 +243,7 @@ export class CodexCloudProvider {
|
|
|
232
243
|
}
|
|
233
244
|
}
|
|
234
245
|
/** Extract a task ID from codex CLI output (JSON field, key:value line, or UUID pattern). */
|
|
235
|
-
function extractTaskId(output) {
|
|
246
|
+
export function extractTaskId(output) {
|
|
236
247
|
// Try JSON first
|
|
237
248
|
try {
|
|
238
249
|
const data = JSON.parse(output);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Register cloud-dispatched tasks into the LOCAL session index, keyed by the
|
|
3
|
+
* real execution id, so a launch is mappable to a session immediately.
|
|
4
|
+
*
|
|
5
|
+
* The cloud store (`tasks.db`) and the session index (`sessions` table) were two
|
|
6
|
+
* disjoint stores: a `agents cloud run` wrote only the store, and the session
|
|
7
|
+
* index learned about the run only later, via `discoverCloudSessions` hitting the
|
|
8
|
+
* proxy. Until that discovery ran there was no launch→session mapping at all —
|
|
9
|
+
* the run was orphaned in `agents sessions` exactly like the non-Claude host runs.
|
|
10
|
+
*
|
|
11
|
+
* This mirrors hosts/session-index.ts: the transcript is remote (fetched on
|
|
12
|
+
* demand by session/cloud.ts), so the row is registered with an EMPTY `file_path`
|
|
13
|
+
* — the sentinel db.js's stale-row filter treats as "always live" — and a
|
|
14
|
+
* `[cloud/<status>]` label. The row is written at dispatch and refreshed on every
|
|
15
|
+
* status poll so the label tracks the task's lifecycle. Non-session-agent cloud
|
|
16
|
+
* providers (a task whose agent has no transcript format) are skipped: there is
|
|
17
|
+
* nothing to resolve or resume by id.
|
|
18
|
+
*/
|
|
19
|
+
import type { CloudTask } from './types.js';
|
|
20
|
+
/** Context a cloud task needs to become a session row — the LOCAL dir it was launched from. */
|
|
21
|
+
export interface CloudSessionContext {
|
|
22
|
+
/** Local directory `agents cloud run` was invoked from. Defaults to process.cwd(). */
|
|
23
|
+
cwd?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Register (or refresh) a cloud task in the local session index. No-op when the
|
|
27
|
+
* task's agent isn't a known session agent, or its id isn't a usable execution id
|
|
28
|
+
* (never a fabricated `codex-<ts>` — codex.ts fails loud instead of minting one,
|
|
29
|
+
* but guard here too so a bad id can never seed a bogus row). Best-effort: a
|
|
30
|
+
* failed index write must never break the dispatch/poll it rides on.
|
|
31
|
+
*/
|
|
32
|
+
export declare function registerCloudSession(task: CloudTask, ctx?: CloudSessionContext): void;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Register cloud-dispatched tasks into the LOCAL session index, keyed by the
|
|
3
|
+
* real execution id, so a launch is mappable to a session immediately.
|
|
4
|
+
*
|
|
5
|
+
* The cloud store (`tasks.db`) and the session index (`sessions` table) were two
|
|
6
|
+
* disjoint stores: a `agents cloud run` wrote only the store, and the session
|
|
7
|
+
* index learned about the run only later, via `discoverCloudSessions` hitting the
|
|
8
|
+
* proxy. Until that discovery ran there was no launch→session mapping at all —
|
|
9
|
+
* the run was orphaned in `agents sessions` exactly like the non-Claude host runs.
|
|
10
|
+
*
|
|
11
|
+
* This mirrors hosts/session-index.ts: the transcript is remote (fetched on
|
|
12
|
+
* demand by session/cloud.ts), so the row is registered with an EMPTY `file_path`
|
|
13
|
+
* — the sentinel db.js's stale-row filter treats as "always live" — and a
|
|
14
|
+
* `[cloud/<status>]` label. The row is written at dispatch and refreshed on every
|
|
15
|
+
* status poll so the label tracks the task's lifecycle. Non-session-agent cloud
|
|
16
|
+
* providers (a task whose agent has no transcript format) are skipped: there is
|
|
17
|
+
* nothing to resolve or resume by id.
|
|
18
|
+
*/
|
|
19
|
+
import { upsertSession } from '../session/db.js';
|
|
20
|
+
import { SESSION_AGENTS } from '../session/types.js';
|
|
21
|
+
import { deriveShortId } from '../session/short-id.js';
|
|
22
|
+
/** The execution-id charset the session index will accept as a row id. */
|
|
23
|
+
const EXECUTION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/;
|
|
24
|
+
/**
|
|
25
|
+
* Register (or refresh) a cloud task in the local session index. No-op when the
|
|
26
|
+
* task's agent isn't a known session agent, or its id isn't a usable execution id
|
|
27
|
+
* (never a fabricated `codex-<ts>` — codex.ts fails loud instead of minting one,
|
|
28
|
+
* but guard here too so a bad id can never seed a bogus row). Best-effort: a
|
|
29
|
+
* failed index write must never break the dispatch/poll it rides on.
|
|
30
|
+
*/
|
|
31
|
+
export function registerCloudSession(task, ctx = {}) {
|
|
32
|
+
if (!SESSION_AGENTS.includes(task.agent))
|
|
33
|
+
return;
|
|
34
|
+
if (!task.id || !EXECUTION_ID_RE.test(task.id))
|
|
35
|
+
return;
|
|
36
|
+
try {
|
|
37
|
+
upsertSession({
|
|
38
|
+
id: task.id,
|
|
39
|
+
shortId: deriveShortId(task.id),
|
|
40
|
+
agent: task.agent,
|
|
41
|
+
timestamp: task.createdAt,
|
|
42
|
+
lastActivity: task.updatedAt,
|
|
43
|
+
cwd: ctx.cwd ?? process.cwd(),
|
|
44
|
+
project: task.repo ?? task.repos?.[0],
|
|
45
|
+
// Remote transcript — no local file yet. Empty file_path is the sentinel
|
|
46
|
+
// the DB stale-filter treats as "always live" (see hosts/session-index.ts).
|
|
47
|
+
filePath: '',
|
|
48
|
+
topic: task.prompt.split('\n')[0]?.slice(0, 120) || undefined,
|
|
49
|
+
// Mirrors session/cloud.ts's discovered label, so the dispatch-time row and
|
|
50
|
+
// a later proxy-discovered row read the same in `agents sessions`.
|
|
51
|
+
label: `[cloud/${task.status}]${task.branch ? ` ${task.branch}` : ''}`,
|
|
52
|
+
prUrl: task.prUrl,
|
|
53
|
+
}, '');
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
/* index write is best-effort; the task is already persisted in the cloud store */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
* remote provider each time.
|
|
7
7
|
*/
|
|
8
8
|
import type { CloudTask, CloudProviderId, CloudTaskStatus } from './types.js';
|
|
9
|
+
/**
|
|
10
|
+
* Release the tasks.db connection so the file (and its WAL sidecars) can be
|
|
11
|
+
* removed. The mirror of `closeDB()` in `../session/db.ts`, which this module
|
|
12
|
+
* had no counterpart for — on Windows an open handle makes the file
|
|
13
|
+
* un-unlinkable, so anything tearing down a temp home must close this too.
|
|
14
|
+
*/
|
|
15
|
+
export declare function closeStore(): void;
|
|
9
16
|
/** Persist a task snapshot, replacing any existing row with the same ID. */
|
|
10
17
|
export declare function insertTask(task: CloudTask): void;
|
|
11
18
|
/** Update a task's status and optionally patch summary, PR URL, or branch. */
|
package/dist/lib/cloud/store.js
CHANGED
|
@@ -9,6 +9,7 @@ import * as fs from 'fs';
|
|
|
9
9
|
import * as path from 'path';
|
|
10
10
|
import Database from '../sqlite.js';
|
|
11
11
|
import { getCloudDir } from '../state.js';
|
|
12
|
+
import { registerCloudSession } from './session-index.js';
|
|
12
13
|
const CLOUD_DIR = getCloudDir();
|
|
13
14
|
const DB_PATH = path.join(CLOUD_DIR, 'tasks.db');
|
|
14
15
|
const SCHEMA = `
|
|
@@ -41,12 +42,29 @@ function db() {
|
|
|
41
42
|
_db.exec(SCHEMA);
|
|
42
43
|
return _db;
|
|
43
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Release the tasks.db connection so the file (and its WAL sidecars) can be
|
|
47
|
+
* removed. The mirror of `closeDB()` in `../session/db.ts`, which this module
|
|
48
|
+
* had no counterpart for — on Windows an open handle makes the file
|
|
49
|
+
* un-unlinkable, so anything tearing down a temp home must close this too.
|
|
50
|
+
*/
|
|
51
|
+
export function closeStore() {
|
|
52
|
+
if (_db) {
|
|
53
|
+
_db.close();
|
|
54
|
+
_db = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
44
57
|
/** Persist a task snapshot, replacing any existing row with the same ID. */
|
|
45
58
|
export function insertTask(task) {
|
|
46
59
|
db().prepare(`
|
|
47
60
|
INSERT OR REPLACE INTO tasks (id, provider, status, agent, prompt, repo, branch, pr_url, summary, created_at, updated_at)
|
|
48
61
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
49
62
|
`).run(task.id, task.provider, task.status, task.agent ?? null, task.prompt, task.repo ?? null, task.branch ?? null, task.prUrl ?? null, task.summary ?? null, task.createdAt, task.updatedAt);
|
|
63
|
+
// Every cloud dispatch flows through here, so this is the one chokepoint that
|
|
64
|
+
// reconciles the cloud store with the session index: register a session row
|
|
65
|
+
// keyed by the real execution id so the launch is mappable to a session
|
|
66
|
+
// immediately, not only after a later proxy discovery (see session-index.ts).
|
|
67
|
+
registerCloudSession(task);
|
|
50
68
|
}
|
|
51
69
|
/** Update a task's status and optionally patch summary, PR URL, or branch. */
|
|
52
70
|
export function updateTaskStatus(id, status, extra) {
|
|
@@ -67,6 +85,13 @@ export function updateTaskStatus(id, status, extra) {
|
|
|
67
85
|
}
|
|
68
86
|
params.push(id);
|
|
69
87
|
db().prepare(`UPDATE tasks SET ${sets.join(', ')} WHERE id = ?`).run(...params);
|
|
88
|
+
// Keep the session index in lockstep with the store on every poll: refresh the
|
|
89
|
+
// row so its `[cloud/<status>]` label tracks the task's lifecycle (and picks up
|
|
90
|
+
// a newly-opened PR url). Reads the just-written row so the label reflects the
|
|
91
|
+
// update we just made, not a stale snapshot.
|
|
92
|
+
const updated = getTaskById(id);
|
|
93
|
+
if (updated)
|
|
94
|
+
registerCloudSession(updated);
|
|
70
95
|
}
|
|
71
96
|
/** Fetch a single task by its provider-assigned ID, or null if not found locally. */
|
|
72
97
|
export function getTaskById(id) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Move `source` onto `dest` across possibly-different volumes. `renameSync` is
|
|
3
|
+
* atomic but throws EXDEV when `~/.agents` lives on a different filesystem than
|
|
4
|
+
* `$HOME`; fall back to copy-then-remove so the restore still completes. The
|
|
5
|
+
* source is removed only after the copy succeeds, so a mid-copy failure never
|
|
6
|
+
* destroys the sole surviving copy.
|
|
7
|
+
*/
|
|
8
|
+
export declare function moveDirCrossDevice(source: string, dest: string): void;
|
|
9
|
+
/**
|
|
10
|
+
* Copy `source` to `dest`, dropping any symlink whose target resolves back into
|
|
11
|
+
* `~/.agents`. Managed resources (skills/commands) are synced into a version home
|
|
12
|
+
* as symlinks into `~/.agents`; copying them verbatim would leave the result full
|
|
13
|
+
* of links that dangle the moment `~/.agents` is disposed. Stripping them yields a
|
|
14
|
+
* clean, self-contained copy — which is the entire point of an export.
|
|
15
|
+
*/
|
|
16
|
+
export declare function copyDirStrippingAgentsSymlinks(source: string, dest: string, agentsDir: string): void;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Primitives for moving an agent config directory across the agents-cli boundary.
|
|
3
|
+
*
|
|
4
|
+
* Both helpers were part of {@link ./uninstall.ts} and are unchanged — teardown was
|
|
5
|
+
* simply the first caller. `agents export` needs the same two operations (relocate a
|
|
6
|
+
* directory that may sit on another volume; copy one without dragging `~/.agents`
|
|
7
|
+
* symlinks along), so they live here rather than being duplicated or imported out of
|
|
8
|
+
* a module named for teardown.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
/**
|
|
13
|
+
* Move `source` onto `dest` across possibly-different volumes. `renameSync` is
|
|
14
|
+
* atomic but throws EXDEV when `~/.agents` lives on a different filesystem than
|
|
15
|
+
* `$HOME`; fall back to copy-then-remove so the restore still completes. The
|
|
16
|
+
* source is removed only after the copy succeeds, so a mid-copy failure never
|
|
17
|
+
* destroys the sole surviving copy.
|
|
18
|
+
*/
|
|
19
|
+
export function moveDirCrossDevice(source, dest) {
|
|
20
|
+
try {
|
|
21
|
+
fs.renameSync(source, dest);
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
if (err.code !== 'EXDEV')
|
|
25
|
+
throw err;
|
|
26
|
+
fs.cpSync(source, dest, { recursive: true });
|
|
27
|
+
fs.rmSync(source, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Copy `source` to `dest`, dropping any symlink whose target resolves back into
|
|
32
|
+
* `~/.agents`. Managed resources (skills/commands) are synced into a version home
|
|
33
|
+
* as symlinks into `~/.agents`; copying them verbatim would leave the result full
|
|
34
|
+
* of links that dangle the moment `~/.agents` is disposed. Stripping them yields a
|
|
35
|
+
* clean, self-contained copy — which is the entire point of an export.
|
|
36
|
+
*/
|
|
37
|
+
export function copyDirStrippingAgentsSymlinks(source, dest, agentsDir) {
|
|
38
|
+
const inside = agentsDir + path.sep;
|
|
39
|
+
fs.cpSync(source, dest, {
|
|
40
|
+
recursive: true,
|
|
41
|
+
// `force: true` is Node's default, but Bun drops it when a `filter` is supplied —
|
|
42
|
+
// existing files are then silently left alone. `dist/bin/agents` is bun-compiled,
|
|
43
|
+
// so this is a production path, not just a test artifact. State it explicitly.
|
|
44
|
+
force: true,
|
|
45
|
+
filter: (src) => {
|
|
46
|
+
try {
|
|
47
|
+
const st = fs.lstatSync(src);
|
|
48
|
+
if (st.isSymbolicLink()) {
|
|
49
|
+
const tgt = path.resolve(path.dirname(src), fs.readlinkSync(src));
|
|
50
|
+
if (tgt === agentsDir || tgt.startsWith(inside))
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* unreadable entry — let cpSync surface it on the real copy */
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
package/dist/lib/daemon.js
CHANGED
|
@@ -16,7 +16,7 @@ import { listJobs as listAllJobs } from './routines.js';
|
|
|
16
16
|
import { JobScheduler } from './scheduler.js';
|
|
17
17
|
import { MonitorEngine } from './monitors/engine.js';
|
|
18
18
|
import { executeJobDetached, monitorRunningJobs } from './runner.js';
|
|
19
|
-
import { detectOverdueJobs, notifyOverdue } from './overdue.js';
|
|
19
|
+
import { detectOverdueJobs, notifyOverdue, notifyDesktop } from './overdue.js';
|
|
20
20
|
import { BrowserService } from './browser/service.js';
|
|
21
21
|
import { BrowserIPCServer } from './browser/ipc.js';
|
|
22
22
|
import { readAndResolveBundleEnv } from './secrets/bundles.js';
|
|
@@ -320,6 +320,15 @@ export async function runDaemon() {
|
|
|
320
320
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
|
|
321
321
|
log('INFO', 'Loaded Claude OAuth token from secrets bundle for routine runs');
|
|
322
322
|
}
|
|
323
|
+
else {
|
|
324
|
+
// No token available (e.g. a headless macOS daemon whose keychain was
|
|
325
|
+
// locked at start resolves broker-only and gets nothing). Historically
|
|
326
|
+
// this was silent, so every Claude routine the daemon spawned failed auth
|
|
327
|
+
// with no signal. Make it loud: WARN in the log and fire a desktop alert.
|
|
328
|
+
log('WARN', 'No Claude OAuth token available — Claude routine runs will fail auth on this host. ' +
|
|
329
|
+
'Restart the daemon with the keychain unlocked, or unlock the `claude` secrets bundle.');
|
|
330
|
+
notifyDesktop('agents daemon: no Claude credential', 'Claude routines will fail auth on this host. Restart the daemon with the keychain unlocked.');
|
|
331
|
+
}
|
|
323
332
|
}
|
|
324
333
|
// Reap any stray duplicate daemon of this install that slipped past the start
|
|
325
334
|
// lock or was orphaned by a hard-crash — before it can double-fire jobs.
|
|
@@ -623,9 +632,16 @@ export async function runDaemon() {
|
|
|
623
632
|
checkingLaunchHealth = true;
|
|
624
633
|
try {
|
|
625
634
|
const { healBrokenDefaultLaunches } = await import('./versions.js');
|
|
626
|
-
|
|
635
|
+
// Unattended pass: repair the current default in place, but NEVER repoint
|
|
636
|
+
// the global default (allowDefaultSwitch: false). A background default
|
|
637
|
+
// switch installs a fresh version home → a fresh empty Claude credential
|
|
638
|
+
// scope, i.e. a silent logout uncorrelated with anything the user did.
|
|
639
|
+
const { repaired, unhealed } = await healBrokenDefaultLaunches((m) => log('INFO', `launch-health: ${m}`), { allowDefaultSwitch: false });
|
|
627
640
|
if (repaired.length)
|
|
628
641
|
log('INFO', `launch-health: repaired ${repaired.join(', ')}`);
|
|
642
|
+
if (unhealed.length) {
|
|
643
|
+
log('WARN', `launch-health: ${unhealed.join(', ')} won't launch and will not be auto-switched — choose a version with \`agents use <agent> <version>\` or \`agents add <agent>@latest\``);
|
|
644
|
+
}
|
|
629
645
|
}
|
|
630
646
|
catch (err) {
|
|
631
647
|
log('ERROR', `launch-health check failed: ${err.message}`);
|
|
@@ -3,6 +3,14 @@ import { type DeviceProfile } from './registry.js';
|
|
|
3
3
|
export declare const ASKPASS_BUNDLE_ENV = "AGENTS_SSH_BUNDLE";
|
|
4
4
|
/** Env var the askpass shim reads to know which key in the bundle is the password. */
|
|
5
5
|
export declare const ASKPASS_KEY_ENV = "AGENTS_SSH_KEY";
|
|
6
|
+
/**
|
|
7
|
+
* Env var that forces the askpass bundle resolve to be broker-only (`agentOnly`)
|
|
8
|
+
* regardless of TTY. A read-only stats probe (`agents devices` load/mem columns)
|
|
9
|
+
* sets this so an uncached password-auth device resolves from the already-unlocked
|
|
10
|
+
* secrets broker or degrades to an unreachable row — never a foreground Touch ID
|
|
11
|
+
* sheet. See {@link buildSshInvocation}'s `agentOnly` option and `runAskpass`. (RUSH-1970)
|
|
12
|
+
*/
|
|
13
|
+
export declare const ASKPASS_AGENT_ONLY_ENV = "AGENTS_SSH_AGENT_ONLY";
|
|
6
14
|
/**
|
|
7
15
|
* Build the `user@host` (or bare `host`) ssh target for a device and validate
|
|
8
16
|
* it against the shared injection guard. Throws if the device has no address.
|
|
@@ -51,8 +59,14 @@ export interface SshHostKeyOptions {
|
|
|
51
59
|
* Host-key checking runs against the CLI-managed known_hosts store (never the
|
|
52
60
|
* user's `~/.ssh/known_hosts`): strict once `hostKey.pinned` is set, else
|
|
53
61
|
* `accept-new` to learn+pin the key on first connect (RUSH-1767).
|
|
62
|
+
*
|
|
63
|
+
* `opts.agentOnly` marks the connection as a read-only probe: for password auth
|
|
64
|
+
* it sets {@link ASKPASS_AGENT_ONLY_ENV} in the overlay so the askpass resolve
|
|
65
|
+
* stays broker-only and never pops a foreground biometric (RUSH-1970).
|
|
54
66
|
*/
|
|
55
|
-
export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string, hostKey?: SshHostKeyOptions
|
|
67
|
+
export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string, hostKey?: SshHostKeyOptions, opts?: {
|
|
68
|
+
agentOnly?: boolean;
|
|
69
|
+
}): {
|
|
56
70
|
args: string[];
|
|
57
71
|
env: Record<string, string>;
|
|
58
72
|
};
|
|
@@ -25,6 +25,14 @@ import { hostNameFor } from './ssh-config.js';
|
|
|
25
25
|
export const ASKPASS_BUNDLE_ENV = 'AGENTS_SSH_BUNDLE';
|
|
26
26
|
/** Env var the askpass shim reads to know which key in the bundle is the password. */
|
|
27
27
|
export const ASKPASS_KEY_ENV = 'AGENTS_SSH_KEY';
|
|
28
|
+
/**
|
|
29
|
+
* Env var that forces the askpass bundle resolve to be broker-only (`agentOnly`)
|
|
30
|
+
* regardless of TTY. A read-only stats probe (`agents devices` load/mem columns)
|
|
31
|
+
* sets this so an uncached password-auth device resolves from the already-unlocked
|
|
32
|
+
* secrets broker or degrades to an unreachable row — never a foreground Touch ID
|
|
33
|
+
* sheet. See {@link buildSshInvocation}'s `agentOnly` option and `runAskpass`. (RUSH-1970)
|
|
34
|
+
*/
|
|
35
|
+
export const ASKPASS_AGENT_ONLY_ENV = 'AGENTS_SSH_AGENT_ONLY';
|
|
28
36
|
/**
|
|
29
37
|
* Build the `user@host` (or bare `host`) ssh target for a device and validate
|
|
30
38
|
* it against the shared injection guard. Throws if the device has no address.
|
|
@@ -83,8 +91,12 @@ export function wrapRemoteCommand(device, cmd) {
|
|
|
83
91
|
* Host-key checking runs against the CLI-managed known_hosts store (never the
|
|
84
92
|
* user's `~/.ssh/known_hosts`): strict once `hostKey.pinned` is set, else
|
|
85
93
|
* `accept-new` to learn+pin the key on first connect (RUSH-1767).
|
|
94
|
+
*
|
|
95
|
+
* `opts.agentOnly` marks the connection as a read-only probe: for password auth
|
|
96
|
+
* it sets {@link ASKPASS_AGENT_ONLY_ENV} in the overlay so the askpass resolve
|
|
97
|
+
* stays broker-only and never pops a foreground biometric (RUSH-1970).
|
|
86
98
|
*/
|
|
87
|
-
export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}) {
|
|
99
|
+
export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, opts = {}) {
|
|
88
100
|
const target = sshTargetFor(device);
|
|
89
101
|
const remote = wrapRemoteCommand(device, cmd);
|
|
90
102
|
const env = {};
|
|
@@ -100,6 +112,8 @@ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}) {
|
|
|
100
112
|
env.SSH_ASKPASS_REQUIRE = 'force';
|
|
101
113
|
env[ASKPASS_BUNDLE_ENV] = device.auth.bundle;
|
|
102
114
|
env[ASKPASS_KEY_ENV] = device.auth.bundleKey ?? 'password';
|
|
115
|
+
if (opts.agentOnly)
|
|
116
|
+
env[ASKPASS_AGENT_ONLY_ENV] = '1';
|
|
103
117
|
args.push('-o', 'PreferredAuthentications=password', '-o', 'PubkeyAuthentication=no', '-o', 'NumberOfPasswordPrompts=1');
|
|
104
118
|
}
|
|
105
119
|
else {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* the remaining devices.
|
|
9
9
|
*/
|
|
10
10
|
import { type DeviceProfile, type DeviceRegistry } from './registry.js';
|
|
11
|
+
import type { DeviceStats } from './health.js';
|
|
11
12
|
export type FleetSkipReason = 'offline' | 'no-address' | 'control';
|
|
12
13
|
/** npm dist-tags / semver pins only — rejects shell metacharacters. */
|
|
13
14
|
export declare const FLEET_VERSION_RE: RegExp;
|
|
@@ -55,6 +56,26 @@ export declare function planFleetTargets(reg: DeviceRegistry): FleetTarget[];
|
|
|
55
56
|
* surface — so their `skip` reason still flows through as an `unreachable` row.
|
|
56
57
|
*/
|
|
57
58
|
export declare function remoteFleetTargets(planned: FleetTarget[], self: string): FleetTarget[];
|
|
59
|
+
/**
|
|
60
|
+
* Decide whether a fleet-health target should skip the expensive version+doctor
|
|
61
|
+
* dials (`agents fleet status`). The cheap stats probe (~2.5s, same registry
|
|
62
|
+
* address) has already tried this box one step earlier. If it came back
|
|
63
|
+
* unreachable, dialing `agents --version` (15s) + `agents doctor --json` (30s)
|
|
64
|
+
* would almost certainly fail the same way — just 45s slower — so one
|
|
65
|
+
* genuinely-offline box would stall the whole matrix (the ~60s hang, RUSH-1964).
|
|
66
|
+
*
|
|
67
|
+
* We trust the stats verdict on the DEFAULT path, not only under
|
|
68
|
+
* `--refresh`/`--live`: `probeDeviceStats` and the version/doctor dials share
|
|
69
|
+
* one ssh path (`fleetDialTarget`), so reachability is not per-probe. The
|
|
70
|
+
* verdict is either freshly probed this run or daemon-warmed (~3min) with the
|
|
71
|
+
* live write-back from RUSH-1965, so a box that came online in the last few
|
|
72
|
+
* minutes is the only false-negative window — it renders `unreachable` until the
|
|
73
|
+
* next stats warm, which beats letting it hang the status glance for 45s.
|
|
74
|
+
*
|
|
75
|
+
* An existing skip (control/offline/no-address from {@link planFleetTargets})
|
|
76
|
+
* always wins — those are classified before any probe.
|
|
77
|
+
*/
|
|
78
|
+
export declare function fleetHealthSkip(currentSkip: FleetSkipReason | string | undefined, stats: DeviceStats | undefined): FleetSkipReason | string | undefined;
|
|
58
79
|
/** Human label for a skip reason. */
|
|
59
80
|
export declare function skipLabel(reason: FleetSkipReason): string;
|
|
60
81
|
/**
|