@phnx-labs/agents-cli 1.20.44 → 1.20.46
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 +12 -1
- package/dist/commands/exec.js +54 -11
- package/dist/commands/secrets.d.ts +18 -0
- package/dist/commands/secrets.js +105 -30
- package/dist/commands/teams.js +61 -3
- package/dist/index.js +14 -119
- package/dist/lib/daemon.js +9 -6
- package/dist/lib/hosts/dispatch.d.ts +29 -0
- package/dist/lib/hosts/dispatch.js +46 -1
- package/dist/lib/hosts/remote-cmd.d.ts +17 -0
- package/dist/lib/hosts/remote-cmd.js +27 -0
- package/dist/lib/hosts/session-index.d.ts +15 -0
- package/dist/lib/hosts/session-index.js +28 -2
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/rotate.d.ts +33 -0
- package/dist/lib/rotate.js +37 -0
- package/dist/lib/secrets/remote.d.ts +14 -0
- package/dist/lib/secrets/remote.js +18 -1
- package/dist/lib/self-heal/checks/path.d.ts +2 -0
- package/dist/lib/self-heal/checks/path.js +30 -0
- package/dist/lib/self-heal/checks/resources.d.ts +2 -0
- package/dist/lib/self-heal/checks/resources.js +36 -0
- package/dist/lib/self-heal/checks/shadowing.d.ts +2 -0
- package/dist/lib/self-heal/checks/shadowing.js +48 -0
- package/dist/lib/self-heal/checks/shims.d.ts +2 -0
- package/dist/lib/self-heal/checks/shims.js +35 -0
- package/dist/lib/self-heal/registry.d.ts +22 -0
- package/dist/lib/self-heal/registry.js +66 -0
- package/dist/lib/self-heal/types.d.ts +41 -0
- package/dist/lib/self-heal/types.js +21 -0
- package/dist/lib/session/active.d.ts +4 -0
- package/dist/lib/session/active.js +2 -0
- package/dist/lib/session/db.d.ts +16 -9
- package/dist/lib/session/db.js +66 -44
- package/dist/lib/session/discover.d.ts +4 -0
- package/dist/lib/session/discover.js +84 -13
- package/dist/lib/session/run-names.d.ts +9 -7
- package/dist/lib/session/run-names.js +9 -7
- package/dist/lib/session/state.d.ts +29 -3
- package/dist/lib/session/state.js +84 -5
- package/dist/lib/session/types.d.ts +19 -8
- package/dist/lib/shim-heal.d.ts +23 -0
- package/dist/lib/shim-heal.js +109 -0
- package/dist/lib/shims.d.ts +6 -0
- package/dist/lib/shims.js +1 -1
- package/dist/lib/teams/agents.js +9 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
import { Command } from 'commander';
|
|
9
9
|
import chalk from 'chalk';
|
|
10
10
|
import * as fs from 'fs';
|
|
11
|
-
import * as os from 'os';
|
|
12
11
|
import * as path from 'path';
|
|
13
12
|
import { fileURLToPath } from 'url';
|
|
14
13
|
import { detectDevBuild } from './lib/startup/dev-build.js';
|
|
@@ -54,7 +53,6 @@ if (IS_DEV_BUILD) {
|
|
|
54
53
|
import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadRun, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadSsh, loadPull, loadPush, loadRepo, loadSetup, } from './lib/startup/command-registry.js';
|
|
55
54
|
import { applyGlobalHelpConventions } from './lib/help.js';
|
|
56
55
|
import { renderWhatsNew } from './lib/whats-new.js';
|
|
57
|
-
import { IS_WINDOWS } from './lib/platform/index.js';
|
|
58
56
|
import { emit, redactArgs } from './lib/events.js';
|
|
59
57
|
// Transparent shim delegate: the generated Windows `.cmd` shims invoke
|
|
60
58
|
// `agents __shim <agent>[@version] <raw args>`. Intercept here, before commander
|
|
@@ -477,124 +475,21 @@ async function maybeBootstrapShimIntegration(requestedCommand, helpOrVersionRequ
|
|
|
477
475
|
if (requestedCommand === 'sync' || requestedCommand === 'refresh-rules') {
|
|
478
476
|
return;
|
|
479
477
|
}
|
|
480
|
-
// Past the documentation/non-TTY guards:
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
if (status !== 'current') {
|
|
495
|
-
createdOrUpdated.push(`${status === 'created' ? 'Created' : 'Updated'} ${AGENTS[agent].cliCommand} shim`);
|
|
496
|
-
}
|
|
497
|
-
for (const version of listInstalledVersions(agent)) {
|
|
498
|
-
const aliasStatus = ensureVersionedAliasCurrent(agent, version);
|
|
499
|
-
if (aliasStatus !== 'current') {
|
|
500
|
-
createdOrUpdated.push(`${aliasStatus === 'created' ? 'Created' : 'Updated'} ${AGENTS[agent].cliCommand}@${version} alias`);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
for (const notice of createdOrUpdated) {
|
|
505
|
-
console.log(chalk.green(notice));
|
|
506
|
-
}
|
|
507
|
-
// Best-effort: remove leftover ~/.agents/shims/<cli> files from the pre-split
|
|
508
|
-
// layout BEFORE running detection. These cause false-positive "shadowing"
|
|
509
|
-
// results that make the repair prompt loop forever (the prompt user said
|
|
510
|
-
// "yes" to never deletes the file; next invocation finds it again).
|
|
511
|
-
for (const agent of installedAgents) {
|
|
512
|
-
removeLegacyUserShim(agent);
|
|
513
|
-
}
|
|
514
|
-
// The remaining flow is rc-file PATH repair, which is POSIX-only. On Windows
|
|
515
|
-
// the shims were just regenerated (incl. `.cmd` companions) above; PATH setup
|
|
516
|
-
// is covered by the install-time guidance, so stop here rather than printing
|
|
517
|
-
// shell-rc instructions that don't apply.
|
|
518
|
-
if (IS_WINDOWS) {
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
const defaultAgents = installedAgents.filter((agent) => getGlobalDefault(agent));
|
|
522
|
-
// Auto-adopt any harness launcher that shadows our shim. PATH-order repair
|
|
523
|
-
// (below) cannot win against `~/.local/bin` — it's prepended in .zshenv for
|
|
524
|
-
// every shell while our prepend only lands in .zshrc — so for symlink
|
|
525
|
-
// launchers we *become* the launcher instead. Detection keys on the launcher
|
|
526
|
-
// symlink EXISTING (via adoptShadowingLauncher's own fallback), not on this
|
|
527
|
-
// shell's PATH order, so it also heals the GUI/non-interactive shadow an
|
|
528
|
-
// interactive run can't see. Reversible; only ever rewrites a symlink.
|
|
529
|
-
for (const agent of defaultAgents) {
|
|
530
|
-
const result = adoptShadowingLauncher(agent);
|
|
531
|
-
if (result.adopted) {
|
|
532
|
-
console.log(chalk.green(`Adopted ${AGENTS[agent].cliCommand} launcher (${result.launcher}) — version management now wins regardless of PATH order.`));
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
// Recompute AFTER adoption so anything we just took over drops out. What
|
|
536
|
-
// remains is a real binary we deliberately don't touch (adoption is
|
|
537
|
-
// symlink-only) — those get an honest one-time note, never a looping prompt.
|
|
538
|
-
const shadowed = defaultAgents
|
|
539
|
-
.map((agent) => ({ agent, shadowedBy: getPathShadowingExecutable(agent) }))
|
|
540
|
-
.filter((item) => Boolean(item.shadowedBy));
|
|
541
|
-
// After adoption, the only things left are (a) real-binary shadows we won't
|
|
542
|
-
// touch, and (b) a genuinely missing PATH entry. Nothing else needs the user.
|
|
543
|
-
const pathMissing = !isShimsInPath();
|
|
544
|
-
if (shadowed.length === 0 && !pathMissing) {
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
547
|
-
// Suppress repeated notices within the same shell. A successful rc-file edit
|
|
548
|
-
// doesn't reload the parent shell, so the next invocation re-fires detection.
|
|
549
|
-
// The sentinel survives only as long as the parent shell process — a new
|
|
550
|
-
// terminal (new PPID) is allowed to surface it again.
|
|
551
|
-
const sentinelPath = path.join(os.tmpdir(), `agents-shim-prompted-${process.ppid}`);
|
|
552
|
-
if (fs.existsSync(sentinelPath)) {
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
|
-
// Real-binary shadows: adoption is symlink-only (we never rename a real native
|
|
556
|
-
// binary), and `addShimsToPath` provably can't outrank an early-PATH dir like
|
|
557
|
-
// ~/.local/bin across zsh's whole sourcing chain. So DON'T offer a "Repair?"
|
|
558
|
-
// prompt here — that was the infinite-loop bug (Yes was always a no-op).
|
|
559
|
-
// Inform once and point at the real levers.
|
|
560
|
-
if (shadowed.length > 0) {
|
|
561
|
-
const targets = shadowed
|
|
562
|
-
.map(({ agent, shadowedBy }) => ` ${AGENTS[agent].cliCommand}: ${shadowedBy}`)
|
|
563
|
-
.join('\n');
|
|
564
|
-
console.log(chalk.yellow('These agent commands run a native binary instead of the version-managed shim:'));
|
|
565
|
-
console.log(chalk.gray(targets));
|
|
566
|
-
console.log(chalk.gray(`It's a real binary (not a symlink), so agents-cli won't move it. To hand it to agents-cli, remove/reorder it, or put ${getShimsDir()} earlier in PATH.`));
|
|
567
|
-
}
|
|
568
|
-
// Genuinely-missing PATH entry is the one thing addShimsToPath actually fixes,
|
|
569
|
-
// so it's the only case that still earns an interactive prompt.
|
|
570
|
-
if (pathMissing) {
|
|
571
|
-
const shouldRepair = await confirm({
|
|
572
|
-
message: 'Add the agents-cli shims directory to your PATH now?',
|
|
573
|
-
default: true,
|
|
574
|
-
});
|
|
575
|
-
if (!shouldRepair) {
|
|
576
|
-
console.log(chalk.gray(getPathSetupInstructions()));
|
|
577
|
-
}
|
|
578
|
-
else {
|
|
579
|
-
const pathResult = addShimsToPath();
|
|
580
|
-
if (!pathResult.success) {
|
|
581
|
-
console.log(chalk.yellow('Could not update PATH automatically.'));
|
|
582
|
-
console.log(chalk.gray(pathResult.error || getPathSetupInstructions()));
|
|
583
|
-
}
|
|
584
|
-
else if (pathResult.alreadyPresent) {
|
|
585
|
-
console.log(chalk.yellow(`Shim PATH entry is already in ~/${pathResult.rcFile} — this shell just needs to reload it.`));
|
|
586
|
-
console.log(chalk.gray(`Run: source ~/${pathResult.rcFile} (or open a new terminal)`));
|
|
587
|
-
}
|
|
588
|
-
else {
|
|
589
|
-
console.log(chalk.green(`Added shims to PATH in ~/${pathResult.rcFile}`));
|
|
590
|
-
console.log(chalk.gray(getPathSetupInstructions()));
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
try {
|
|
595
|
-
fs.writeFileSync(sentinelPath, '1');
|
|
478
|
+
// Past the documentation/non-TTY guards: heal the shim/shadow/PATH conditions
|
|
479
|
+
// through the unified self-heal registry — the SAME checks the daemon runs, but
|
|
480
|
+
// driven silently on this interactive invocation so a user who never starts the
|
|
481
|
+
// daemon still gets healed. Regenerating stale shims, adopting symlink launchers,
|
|
482
|
+
// and adding the shims dir to PATH now happen without any output. The only thing
|
|
483
|
+
// that ever prints is a ONE-TIME notice for what a machine can't silently fix
|
|
484
|
+
// (a real native binary shadowing the shim) or is worth saying once (a PATH entry
|
|
485
|
+
// just added). Suppression is persistent and keyed to the condition — a new
|
|
486
|
+
// terminal no longer re-nags (the old per-PPID sentinel did, every shell).
|
|
487
|
+
const { healShimsInteractive } = await import('./lib/shim-heal.js');
|
|
488
|
+
const noticeLines = await healShimsInteractive();
|
|
489
|
+
if (noticeLines) {
|
|
490
|
+
for (const line of noticeLines)
|
|
491
|
+
console.log(chalk.gray(line));
|
|
596
492
|
}
|
|
597
|
-
catch { /* best-effort */ }
|
|
598
493
|
}
|
|
599
494
|
// --- Inline command registrars ----------------------------------------------
|
|
600
495
|
// These commands are defined here rather than in a command module because they
|
package/dist/lib/daemon.js
CHANGED
|
@@ -374,15 +374,18 @@ export async function runDaemon() {
|
|
|
374
374
|
return;
|
|
375
375
|
healing = true;
|
|
376
376
|
try {
|
|
377
|
-
const {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
377
|
+
const { runSelfHeal, selfHealChangedAnything, selfHealNeedsAttention, summarizeSelfHeal } = await import('./self-heal/registry.js');
|
|
378
|
+
// Background heal is conservative (mode: 'safe'): fixes low-risk drift (shims,
|
|
379
|
+
// symlink adoption, PATH, missing resources) and only reports risky ones. The
|
|
380
|
+
// 30s kickoff means shims/PATH settle shortly after the daemon starts. No
|
|
381
|
+
// desktop toast here — background heal is silent by design; the log is the record.
|
|
382
|
+
const report = await runSelfHeal({ mode: 'safe' });
|
|
383
|
+
if (selfHealChangedAnything(report) || selfHealNeedsAttention(report)) {
|
|
384
|
+
log('INFO', `self-heal: ${summarizeSelfHeal(report)}`);
|
|
382
385
|
}
|
|
383
386
|
}
|
|
384
387
|
catch (err) {
|
|
385
|
-
log('ERROR', `heal check failed: ${err.message}`);
|
|
388
|
+
log('ERROR', `self-heal check failed: ${err.message}`);
|
|
386
389
|
}
|
|
387
390
|
finally {
|
|
388
391
|
healing = false;
|
|
@@ -45,6 +45,35 @@ export interface DispatchOptions {
|
|
|
45
45
|
* resume wins when — defensively — both are set.
|
|
46
46
|
*/
|
|
47
47
|
export declare function buildRunForwardedArgs(opts: DispatchOptions): string[];
|
|
48
|
+
export interface InteractiveDispatchOptions {
|
|
49
|
+
agent: string;
|
|
50
|
+
/** Optional prompt — forwarded only when the caller explicitly forced interactive mode. */
|
|
51
|
+
prompt?: string;
|
|
52
|
+
mode?: string;
|
|
53
|
+
model?: string;
|
|
54
|
+
remoteCwd?: string;
|
|
55
|
+
sessionId?: string;
|
|
56
|
+
name?: string;
|
|
57
|
+
resume?: string;
|
|
58
|
+
passthroughArgs?: string[];
|
|
59
|
+
raw?: boolean;
|
|
60
|
+
/** Forward `--interactive` to the remote so a prompt-bearing run still starts the TUI. */
|
|
61
|
+
forceInteractive?: boolean;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Build the remote `agents run …` argv for an INTERACTIVE host dispatch. The
|
|
65
|
+
* remote agent sees a TTY, so we omit `--quiet`; the remote CLI will launch its
|
|
66
|
+
* normal interactive TUI / tmux wrapper. A prompt is only included when the
|
|
67
|
+
* caller explicitly forced interactive mode (otherwise the remote CLI would
|
|
68
|
+
* infer headless from the prompt).
|
|
69
|
+
*/
|
|
70
|
+
export declare function buildInteractiveRunForwardedArgs(opts: InteractiveDispatchOptions): string[];
|
|
71
|
+
/**
|
|
72
|
+
* Run an agent interactively on a host, forwarding the local TTY over SSH.
|
|
73
|
+
* Returns the SSH exit code. The remote `agents` CLI is responsible for its own
|
|
74
|
+
* tmux wrapping; the local machine is just the transport.
|
|
75
|
+
*/
|
|
76
|
+
export declare function runInteractiveOnHost(host: Host, opts: InteractiveDispatchOptions): Promise<number>;
|
|
48
77
|
/** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
|
|
49
78
|
export declare function dispatchToHost(host: Host, opts: DispatchOptions): Promise<DispatchResult>;
|
|
50
79
|
export interface CommandDispatchOptions {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* same core so a remote team supervisor keeps running after you disconnect.
|
|
10
10
|
*/
|
|
11
11
|
import { randomUUID } from 'crypto';
|
|
12
|
-
import { sshExec, shellQuote } from '../ssh-exec.js';
|
|
12
|
+
import { sshExec, sshStream, shellQuote } from '../ssh-exec.js';
|
|
13
13
|
import { sshTargetFor } from './types.js';
|
|
14
14
|
import { ensureHostReady } from './ready.js';
|
|
15
15
|
import { remoteShellFor } from './remote-cmd.js';
|
|
@@ -105,6 +105,51 @@ export function buildRunForwardedArgs(opts) {
|
|
|
105
105
|
args.push('--session-id', opts.sessionId);
|
|
106
106
|
return args;
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* Build the remote `agents run …` argv for an INTERACTIVE host dispatch. The
|
|
110
|
+
* remote agent sees a TTY, so we omit `--quiet`; the remote CLI will launch its
|
|
111
|
+
* normal interactive TUI / tmux wrapper. A prompt is only included when the
|
|
112
|
+
* caller explicitly forced interactive mode (otherwise the remote CLI would
|
|
113
|
+
* infer headless from the prompt).
|
|
114
|
+
*/
|
|
115
|
+
export function buildInteractiveRunForwardedArgs(opts) {
|
|
116
|
+
const args = ['run', opts.agent];
|
|
117
|
+
if (opts.prompt && opts.forceInteractive)
|
|
118
|
+
args.push(opts.prompt);
|
|
119
|
+
if (opts.forceInteractive)
|
|
120
|
+
args.push('--interactive');
|
|
121
|
+
if (opts.mode)
|
|
122
|
+
args.push('--mode', opts.mode);
|
|
123
|
+
if (opts.model)
|
|
124
|
+
args.push('--model', opts.model);
|
|
125
|
+
if (opts.name)
|
|
126
|
+
args.push('--name', opts.name);
|
|
127
|
+
if (opts.resume)
|
|
128
|
+
args.push('--resume', opts.resume);
|
|
129
|
+
else if (opts.sessionId)
|
|
130
|
+
args.push('--session-id', opts.sessionId);
|
|
131
|
+
if (opts.raw)
|
|
132
|
+
args.push('--raw');
|
|
133
|
+
if (opts.passthroughArgs && opts.passthroughArgs.length > 0) {
|
|
134
|
+
args.push('--', ...opts.passthroughArgs);
|
|
135
|
+
}
|
|
136
|
+
return args;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Run an agent interactively on a host, forwarding the local TTY over SSH.
|
|
140
|
+
* Returns the SSH exit code. The remote `agents` CLI is responsible for its own
|
|
141
|
+
* tmux wrapping; the local machine is just the transport.
|
|
142
|
+
*/
|
|
143
|
+
export async function runInteractiveOnHost(host, opts) {
|
|
144
|
+
const target = sshTargetFor(host);
|
|
145
|
+
const { warnings } = ensureHostReady(host, { agent: opts.agent });
|
|
146
|
+
for (const w of warnings)
|
|
147
|
+
process.stderr.write(`[hosts] warning: ${w}\n`);
|
|
148
|
+
const invocation = ['agents', ...buildInteractiveRunForwardedArgs(opts)].map(shellQuote).join(' ');
|
|
149
|
+
const cwd = opts.remoteCwd ? `cd ${shellQuote(opts.remoteCwd)} && ` : '';
|
|
150
|
+
const remoteCmd = `${cwd}${invocation}`;
|
|
151
|
+
return sshStream(target, remoteCmd, { tty: process.stdin.isTTY, multiplex: true });
|
|
152
|
+
}
|
|
108
153
|
/** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
|
|
109
154
|
export async function dispatchToHost(host, opts) {
|
|
110
155
|
const target = sshTargetFor(host);
|
|
@@ -101,3 +101,20 @@ export declare function windowsAgentsScript(cmd: WindowsAgentsCommand): string;
|
|
|
101
101
|
* Windows counterpart of `bash -lc '<...>'`, shared by every `--host` site.
|
|
102
102
|
*/
|
|
103
103
|
export declare function buildWindowsAgentsCommand(cmd: WindowsAgentsCommand): string;
|
|
104
|
+
/**
|
|
105
|
+
* Build the `ssh <target> <cmd>` string for `agents secrets import` on a Windows
|
|
106
|
+
* remote where the `.env` is piped over ssh stdin.
|
|
107
|
+
*
|
|
108
|
+
* We can't just run `agents secrets import <bundle> --from -`: the npm
|
|
109
|
+
* `agents.ps1` shim does NOT forward the ssh-piped stdin down to the underlying
|
|
110
|
+
* node process, so a raw fd-0 read (`--from -`) hangs forever (observed: the
|
|
111
|
+
* push to a Windows host times out). PowerShell ITSELF can read the pipe, so we
|
|
112
|
+
* read stdin into a temp file in PowerShell, import `--from <file>` (a plain
|
|
113
|
+
* file read, which the shim handles fine), and delete the temp file afterwards
|
|
114
|
+
* — success or failure. Backend defaults to the platform native store
|
|
115
|
+
* (Credential Manager, or the headless file store when there's no logon
|
|
116
|
+
* session), matching a local `agents secrets import`.
|
|
117
|
+
*/
|
|
118
|
+
export declare function buildWindowsStdinImportCommand(bundle: string, opts?: {
|
|
119
|
+
force?: boolean;
|
|
120
|
+
}): string;
|
|
@@ -130,3 +130,30 @@ export function windowsAgentsScript(cmd) {
|
|
|
130
130
|
export function buildWindowsAgentsCommand(cmd) {
|
|
131
131
|
return `powershell -NoProfile -EncodedCommand ${encodePowershell(windowsAgentsScript(cmd))}`;
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* Build the `ssh <target> <cmd>` string for `agents secrets import` on a Windows
|
|
135
|
+
* remote where the `.env` is piped over ssh stdin.
|
|
136
|
+
*
|
|
137
|
+
* We can't just run `agents secrets import <bundle> --from -`: the npm
|
|
138
|
+
* `agents.ps1` shim does NOT forward the ssh-piped stdin down to the underlying
|
|
139
|
+
* node process, so a raw fd-0 read (`--from -`) hangs forever (observed: the
|
|
140
|
+
* push to a Windows host times out). PowerShell ITSELF can read the pipe, so we
|
|
141
|
+
* read stdin into a temp file in PowerShell, import `--from <file>` (a plain
|
|
142
|
+
* file read, which the shim handles fine), and delete the temp file afterwards
|
|
143
|
+
* — success or failure. Backend defaults to the platform native store
|
|
144
|
+
* (Credential Manager, or the headless file store when there's no logon
|
|
145
|
+
* session), matching a local `agents secrets import`.
|
|
146
|
+
*/
|
|
147
|
+
export function buildWindowsStdinImportCommand(bundle, opts = {}) {
|
|
148
|
+
const force = opts.force ? ' --force' : '';
|
|
149
|
+
const script = [
|
|
150
|
+
'$in = [Console]::In.ReadToEnd()',
|
|
151
|
+
'$tmp = [System.IO.Path]::GetTempFileName()',
|
|
152
|
+
'[System.IO.File]::WriteAllText($tmp, $in)',
|
|
153
|
+
`try { & agents secrets import ${powershellQuote(bundle)} --from $tmp${force}; $code = $LASTEXITCODE } ` +
|
|
154
|
+
`finally { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }`,
|
|
155
|
+
'if ($null -eq $code) { $code = 1 }',
|
|
156
|
+
'exit $code',
|
|
157
|
+
].join('; ');
|
|
158
|
+
return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
|
|
159
|
+
}
|
|
@@ -32,3 +32,18 @@ export declare function hostSessionMeta(task: HostTask, ctx: HostSessionContext)
|
|
|
32
32
|
* break the dispatch itself, which has already been launched on the host.
|
|
33
33
|
*/
|
|
34
34
|
export declare function registerHostSession(task: HostTask, ctx: HostSessionContext): void;
|
|
35
|
+
export interface InteractiveHostSessionContext {
|
|
36
|
+
cwd: string;
|
|
37
|
+
host: string;
|
|
38
|
+
agent: string;
|
|
39
|
+
sessionId: string;
|
|
40
|
+
name?: string;
|
|
41
|
+
createdAt?: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Register an interactive host run (no prompt, TTY forwarded over SSH) in the
|
|
45
|
+
* local session index. Unlike detached host runs, there is no remote log/exit
|
|
46
|
+
* file and no HostTask; we only need the session id so `agents sessions` can
|
|
47
|
+
* surface and resume it by id.
|
|
48
|
+
*/
|
|
49
|
+
export declare function registerInteractiveHostSession(ctx: InteractiveHostSessionContext): void;
|
|
@@ -35,8 +35,10 @@ export function hostSessionMeta(task, ctx) {
|
|
|
35
35
|
// stale-filter treats as "always live" (see module doc).
|
|
36
36
|
filePath: '',
|
|
37
37
|
topic: ctx.prompt.split('\n')[0]?.slice(0, 120) || undefined,
|
|
38
|
-
label
|
|
39
|
-
name
|
|
38
|
+
// The run's `--name` seeds the label (resolves `agents sessions <name>` and
|
|
39
|
+
// `agents hosts logs <name>`); an unnamed host run falls back to the
|
|
40
|
+
// `[host/<name>]` indicator, mirroring the cloud path's `[cloud/<status>]`.
|
|
41
|
+
label: task.name || `[host/${task.host}]`,
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
/**
|
|
@@ -55,3 +57,27 @@ export function registerHostSession(task, ctx) {
|
|
|
55
57
|
/* index write is best-effort; the run is already live on the host */
|
|
56
58
|
}
|
|
57
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Register an interactive host run (no prompt, TTY forwarded over SSH) in the
|
|
62
|
+
* local session index. Unlike detached host runs, there is no remote log/exit
|
|
63
|
+
* file and no HostTask; we only need the session id so `agents sessions` can
|
|
64
|
+
* surface and resume it by id.
|
|
65
|
+
*/
|
|
66
|
+
export function registerInteractiveHostSession(ctx) {
|
|
67
|
+
if (!SESSION_AGENTS.includes(ctx.agent))
|
|
68
|
+
return;
|
|
69
|
+
try {
|
|
70
|
+
upsertSession({
|
|
71
|
+
id: ctx.sessionId,
|
|
72
|
+
shortId: ctx.sessionId.slice(0, 8),
|
|
73
|
+
agent: ctx.agent,
|
|
74
|
+
timestamp: ctx.createdAt ?? new Date().toISOString(),
|
|
75
|
+
cwd: ctx.cwd,
|
|
76
|
+
filePath: '',
|
|
77
|
+
label: ctx.name || `[host/${ctx.host}]`,
|
|
78
|
+
}, '');
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* index write is best-effort; the run is already live on the host */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
Binary file
|
package/dist/lib/rotate.d.ts
CHANGED
|
@@ -56,6 +56,39 @@ export declare function getProjectRunStrategy(agent: AgentId, startPath: string)
|
|
|
56
56
|
export declare function getConfiguredRunStrategy(agent: AgentId, startPath?: string): RunStrategy;
|
|
57
57
|
/** Persist the global run strategy used by bare `agents run <agent>`. */
|
|
58
58
|
export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrategy): void;
|
|
59
|
+
/**
|
|
60
|
+
* Whether a specific account can serve a run right now, and — when it can't —
|
|
61
|
+
* why. `signed_out` covers no-email / invalid-auth; `rate_limited` and
|
|
62
|
+
* `out_of_credits` name the throttle. Used to pre-warn on a version-pinned
|
|
63
|
+
* teammate whose account rotation won't route around (a pin IS the target).
|
|
64
|
+
*/
|
|
65
|
+
export type AccountReadiness = {
|
|
66
|
+
ready: true;
|
|
67
|
+
} | {
|
|
68
|
+
ready: false;
|
|
69
|
+
reason: 'rate_limited' | 'out_of_credits' | 'signed_out';
|
|
70
|
+
email: string | null;
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Pure decision reusing the router's own eligibility gate (`hasUsageAvailable`
|
|
74
|
+
* + email/auth, i.e. `isRotationEligible`), so a pre-flight warning can NEVER
|
|
75
|
+
* disagree with what rotation would actually do. The `reason` combines the two
|
|
76
|
+
* signals `hasUsageAvailable` reads: the live snapshot (session-inclusive
|
|
77
|
+
* rate-limit) and the coarse cached `usageStatus` (out-of-credits, which a
|
|
78
|
+
* snapshot never carries). When a live snapshot exists it wins over the cached
|
|
79
|
+
* status — matching the gate — so a stale `out_of_credits` cache is not
|
|
80
|
+
* reported while the account is actually serving requests.
|
|
81
|
+
*/
|
|
82
|
+
export declare function readinessFromCandidate(candidate: RotateCandidate): AccountReadiness;
|
|
83
|
+
/**
|
|
84
|
+
* Readiness for a specific installed (agent, version). Returns `{ ready: true }`
|
|
85
|
+
* when the version isn't among the collected candidates — absence is the
|
|
86
|
+
* caller's `isVersionInstalled` concern, not ours; don't cry wolf. Only
|
|
87
|
+
* meaningful for a version-pinned target: a bare target rotates to a healthy
|
|
88
|
+
* account on its own, and a profile injects its own auth (a different account
|
|
89
|
+
* than the version home carries), so neither is checkable here.
|
|
90
|
+
*/
|
|
91
|
+
export declare function checkRunAccountReadiness(agent: AgentId, version: string): Promise<AccountReadiness>;
|
|
59
92
|
/**
|
|
60
93
|
* Pick a healthy candidate using weighted random by remaining capacity.
|
|
61
94
|
*
|
package/dist/lib/rotate.js
CHANGED
|
@@ -92,6 +92,43 @@ function hasUsageAvailable(candidate) {
|
|
|
92
92
|
}
|
|
93
93
|
return true;
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Pure decision reusing the router's own eligibility gate (`hasUsageAvailable`
|
|
97
|
+
* + email/auth, i.e. `isRotationEligible`), so a pre-flight warning can NEVER
|
|
98
|
+
* disagree with what rotation would actually do. The `reason` combines the two
|
|
99
|
+
* signals `hasUsageAvailable` reads: the live snapshot (session-inclusive
|
|
100
|
+
* rate-limit) and the coarse cached `usageStatus` (out-of-credits, which a
|
|
101
|
+
* snapshot never carries). When a live snapshot exists it wins over the cached
|
|
102
|
+
* status — matching the gate — so a stale `out_of_credits` cache is not
|
|
103
|
+
* reported while the account is actually serving requests.
|
|
104
|
+
*/
|
|
105
|
+
export function readinessFromCandidate(candidate) {
|
|
106
|
+
if (!candidate.email || !candidate.authValid) {
|
|
107
|
+
return { ready: false, reason: 'signed_out', email: candidate.email };
|
|
108
|
+
}
|
|
109
|
+
if (hasUsageAvailable(candidate)) {
|
|
110
|
+
return { ready: true };
|
|
111
|
+
}
|
|
112
|
+
const snap = candidate.usageSnapshot;
|
|
113
|
+
const snapRateLimited = !!snap && snap.windows.length > 0 && deriveUsageStatusFromSnapshot(snap) === 'rate_limited';
|
|
114
|
+
const reason = !snapRateLimited && candidate.usageStatus === 'out_of_credits' ? 'out_of_credits' : 'rate_limited';
|
|
115
|
+
return { ready: false, reason, email: candidate.email };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Readiness for a specific installed (agent, version). Returns `{ ready: true }`
|
|
119
|
+
* when the version isn't among the collected candidates — absence is the
|
|
120
|
+
* caller's `isVersionInstalled` concern, not ours; don't cry wolf. Only
|
|
121
|
+
* meaningful for a version-pinned target: a bare target rotates to a healthy
|
|
122
|
+
* account on its own, and a profile injects its own auth (a different account
|
|
123
|
+
* than the version home carries), so neither is checkable here.
|
|
124
|
+
*/
|
|
125
|
+
export async function checkRunAccountReadiness(agent, version) {
|
|
126
|
+
const candidates = await collectRunCandidates(agent);
|
|
127
|
+
const candidate = candidates.find((c) => c.version === version);
|
|
128
|
+
if (!candidate)
|
|
129
|
+
return { ready: true };
|
|
130
|
+
return readinessFromCandidate(candidate);
|
|
131
|
+
}
|
|
95
132
|
function getRoutingUsedPercent(snapshot) {
|
|
96
133
|
if (!snapshot || snapshot.windows.length === 0)
|
|
97
134
|
return null;
|
|
@@ -51,6 +51,20 @@ export declare function remoteSecretsRaw(target: string, args: string[], opts?:
|
|
|
51
51
|
tty?: boolean;
|
|
52
52
|
input?: string;
|
|
53
53
|
}): SshExecResult;
|
|
54
|
+
/**
|
|
55
|
+
* Run a remote `agents secrets <args>` FOREGROUND, with the local stdio wired
|
|
56
|
+
* straight through (`stdio: 'inherit'` + `-tt`), and return its exit code.
|
|
57
|
+
*
|
|
58
|
+
* Unlike `remoteSecretsRaw` — which pipes stdin, so even with `-tt` the remote
|
|
59
|
+
* process's `process.stdin.isTTY` is false and a passphrase prompt refuses to
|
|
60
|
+
* appear (the macOS file-store guard then hard-errors "needs
|
|
61
|
+
* AGENTS_SECRETS_PASSPHRASE") — this inherits the caller's real terminal, so the
|
|
62
|
+
* remote sees a genuine TTY and its hidden passphrase prompt surfaces and reads
|
|
63
|
+
* the keystrokes. This is the transport for `unlock --host`: you type the remote
|
|
64
|
+
* bundle's passphrase at your own terminal. Output is NOT captured (it streams
|
|
65
|
+
* to the terminal); only the exit code is returned.
|
|
66
|
+
*/
|
|
67
|
+
export declare function remoteSecretsStream(target: string, args: string[]): number;
|
|
54
68
|
/**
|
|
55
69
|
* Resolve a remote bundle to a plaintext env map by driving the remote's
|
|
56
70
|
* `agents secrets export <bundle> --plaintext --format json`. Values cross over
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* file-backend passphrase travels over ssh stdin (first line) so it never lands
|
|
16
16
|
* in argv / `ps` / remote shell history. Nothing is persisted locally.
|
|
17
17
|
*/
|
|
18
|
-
import { sshExec, assertValidSshTarget } from '../ssh-exec.js';
|
|
18
|
+
import { sshExec, sshStream, assertValidSshTarget } from '../ssh-exec.js';
|
|
19
19
|
import { resolveHost } from '../hosts/registry.js';
|
|
20
20
|
import { emit } from '../events.js';
|
|
21
21
|
import { sshTargetFor } from '../hosts/types.js';
|
|
@@ -93,6 +93,23 @@ export function remoteSecretsRaw(target, args, opts = {}) {
|
|
|
93
93
|
extraSshArgs: opts.tty ? ['-tt'] : undefined,
|
|
94
94
|
});
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Run a remote `agents secrets <args>` FOREGROUND, with the local stdio wired
|
|
98
|
+
* straight through (`stdio: 'inherit'` + `-tt`), and return its exit code.
|
|
99
|
+
*
|
|
100
|
+
* Unlike `remoteSecretsRaw` — which pipes stdin, so even with `-tt` the remote
|
|
101
|
+
* process's `process.stdin.isTTY` is false and a passphrase prompt refuses to
|
|
102
|
+
* appear (the macOS file-store guard then hard-errors "needs
|
|
103
|
+
* AGENTS_SECRETS_PASSPHRASE") — this inherits the caller's real terminal, so the
|
|
104
|
+
* remote sees a genuine TTY and its hidden passphrase prompt surfaces and reads
|
|
105
|
+
* the keystrokes. This is the transport for `unlock --host`: you type the remote
|
|
106
|
+
* bundle's passphrase at your own terminal. Output is NOT captured (it streams
|
|
107
|
+
* to the terminal); only the exit code is returned.
|
|
108
|
+
*/
|
|
109
|
+
export function remoteSecretsStream(target, args) {
|
|
110
|
+
const remoteCmd = buildRemoteAgentsInvocation(['secrets', ...args], undefined, osForTarget(target));
|
|
111
|
+
return sshStream(target, remoteCmd, { tty: true });
|
|
112
|
+
}
|
|
96
113
|
/**
|
|
97
114
|
* Resolve a remote bundle to a plaintext env map by driving the remote's
|
|
98
115
|
* `agents secrets export <bundle> --plaintext --format json`. Values cross over
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// path check — ensures the shims dir is on PATH. On POSIX it appends to the shell
|
|
2
|
+
// rc file; on Windows it registers on the user PATH (registry). addShimsToPath is a
|
|
3
|
+
// no-op when already present, so this is idempotent. Formerly an interactive prompt
|
|
4
|
+
// in index.ts fired on every new shell; here the daemon does it once, silently.
|
|
5
|
+
//
|
|
6
|
+
// Caveat: an already-open shell won't pick up the new rc/PATH entry until it reloads
|
|
7
|
+
// — but new shells will, so the recurring prompt stops.
|
|
8
|
+
import { resultOf } from '../types.js';
|
|
9
|
+
import { isShimsInPath, addShimsToPath } from '../../shims.js';
|
|
10
|
+
export const pathCheck = {
|
|
11
|
+
id: 'path',
|
|
12
|
+
title: 'Shims directory on PATH',
|
|
13
|
+
cadence: 'startup',
|
|
14
|
+
async run(ctx) {
|
|
15
|
+
if (isShimsInPath())
|
|
16
|
+
return resultOf([], []);
|
|
17
|
+
if (ctx.dryRun)
|
|
18
|
+
return resultOf(['add shims dir to PATH'], []);
|
|
19
|
+
const r = addShimsToPath();
|
|
20
|
+
if (r.success && !r.alreadyPresent) {
|
|
21
|
+
return resultOf([`added shims to PATH (${r.location ?? r.rcFile ?? 'PATH'})`], []);
|
|
22
|
+
}
|
|
23
|
+
if (r.success && r.alreadyPresent) {
|
|
24
|
+
// Present in the rc file but not in THIS process's PATH — a reload issue,
|
|
25
|
+
// not something to fix again. Report quietly.
|
|
26
|
+
return resultOf([], [`shims dir in ${r.rcFile ?? 'rc file'} but not loaded — open a new terminal`]);
|
|
27
|
+
}
|
|
28
|
+
return resultOf([], [`could not add shims to PATH: ${r.error ?? 'unknown'}`]);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// resources check — reconciles each installed version's home against the DotAgents
|
|
2
|
+
// definitions (commands, skills, hooks, rules, mcp, plugins). This is a thin adapter
|
|
3
|
+
// over the existing, battle-tested heal() engine (lib/heal.ts) — no behavior change;
|
|
4
|
+
// it just re-expresses heal()'s result in the unified CheckResult shape.
|
|
5
|
+
import { resultOf } from '../types.js';
|
|
6
|
+
export const resourcesCheck = {
|
|
7
|
+
id: 'resources',
|
|
8
|
+
title: 'Resource sync (commands, skills, hooks, rules, plugins)',
|
|
9
|
+
cadence: 'periodic',
|
|
10
|
+
async run(ctx) {
|
|
11
|
+
// Lazy import so the (heavy) heal graph only loads when this check actually runs.
|
|
12
|
+
const { heal } = await import('../../heal.js');
|
|
13
|
+
const result = await heal({ mode: ctx.mode, dryRun: ctx.dryRun });
|
|
14
|
+
const fixed = [];
|
|
15
|
+
const needsAttention = [];
|
|
16
|
+
let healed = 0;
|
|
17
|
+
for (const v of result.versions) {
|
|
18
|
+
healed += v.healed.length;
|
|
19
|
+
for (const s of v.skipped) {
|
|
20
|
+
needsAttention.push(`${v.agent}@${v.version}: ${s.kind}/${s.name} (${s.reason})`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (healed > 0)
|
|
24
|
+
fixed.push(`${healed} resource(s) reconciled`);
|
|
25
|
+
for (const m of result.repairedManifests) {
|
|
26
|
+
fixed.push(`plugin ${m.plugin}: dropped ${m.droppedFields.join(', ')}`);
|
|
27
|
+
}
|
|
28
|
+
for (const p of result.refreshedPlugins) {
|
|
29
|
+
fixed.push(`plugin ${p.plugin}: ${p.from} -> ${p.to}`);
|
|
30
|
+
}
|
|
31
|
+
for (const s of result.skippedPlugins) {
|
|
32
|
+
needsAttention.push(`plugin ${s.plugin}: ${s.reason} (${s.from} vs ${s.upstream})`);
|
|
33
|
+
}
|
|
34
|
+
return resultOf(fixed, needsAttention);
|
|
35
|
+
},
|
|
36
|
+
};
|