@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/dist/commands/exec.js +54 -11
  3. package/dist/commands/secrets.d.ts +18 -0
  4. package/dist/commands/secrets.js +105 -30
  5. package/dist/commands/teams.js +61 -3
  6. package/dist/index.js +14 -119
  7. package/dist/lib/daemon.js +9 -6
  8. package/dist/lib/hosts/dispatch.d.ts +29 -0
  9. package/dist/lib/hosts/dispatch.js +46 -1
  10. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  11. package/dist/lib/hosts/remote-cmd.js +27 -0
  12. package/dist/lib/hosts/session-index.d.ts +15 -0
  13. package/dist/lib/hosts/session-index.js +28 -2
  14. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  15. package/dist/lib/rotate.d.ts +33 -0
  16. package/dist/lib/rotate.js +37 -0
  17. package/dist/lib/secrets/remote.d.ts +14 -0
  18. package/dist/lib/secrets/remote.js +18 -1
  19. package/dist/lib/self-heal/checks/path.d.ts +2 -0
  20. package/dist/lib/self-heal/checks/path.js +30 -0
  21. package/dist/lib/self-heal/checks/resources.d.ts +2 -0
  22. package/dist/lib/self-heal/checks/resources.js +36 -0
  23. package/dist/lib/self-heal/checks/shadowing.d.ts +2 -0
  24. package/dist/lib/self-heal/checks/shadowing.js +48 -0
  25. package/dist/lib/self-heal/checks/shims.d.ts +2 -0
  26. package/dist/lib/self-heal/checks/shims.js +35 -0
  27. package/dist/lib/self-heal/registry.d.ts +22 -0
  28. package/dist/lib/self-heal/registry.js +66 -0
  29. package/dist/lib/self-heal/types.d.ts +41 -0
  30. package/dist/lib/self-heal/types.js +21 -0
  31. package/dist/lib/session/active.d.ts +4 -0
  32. package/dist/lib/session/active.js +2 -0
  33. package/dist/lib/session/db.d.ts +16 -9
  34. package/dist/lib/session/db.js +66 -44
  35. package/dist/lib/session/discover.d.ts +4 -0
  36. package/dist/lib/session/discover.js +84 -13
  37. package/dist/lib/session/run-names.d.ts +9 -7
  38. package/dist/lib/session/run-names.js +9 -7
  39. package/dist/lib/session/state.d.ts +29 -3
  40. package/dist/lib/session/state.js +84 -5
  41. package/dist/lib/session/types.d.ts +19 -8
  42. package/dist/lib/shim-heal.d.ts +23 -0
  43. package/dist/lib/shim-heal.js +109 -0
  44. package/dist/lib/shims.d.ts +6 -0
  45. package/dist/lib/shims.js +1 -1
  46. package/dist/lib/teams/agents.js +9 -0
  47. 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: only now load the shim + agent
481
- // tables this interactive repair flow needs, so fast commands never pay for
482
- // them at module-eval time.
483
- const { confirm } = await import('@inquirer/prompts');
484
- const { AGENTS } = await import('./lib/agents.js');
485
- const { getGlobalDefault, listInstalledVersions } = await import('./lib/versions.js');
486
- const { addShimsToPath, adoptShadowingLauncher, ensureShimCurrent, ensureVersionedAliasCurrent, getPathShadowingExecutable, getPathSetupInstructions, getShimsDir, isShimsInPath, listAgentsWithInstalledVersions, removeLegacyUserShim, } = await import('./lib/shims.js');
487
- const installedAgents = listAgentsWithInstalledVersions();
488
- if (installedAgents.length === 0) {
489
- return;
490
- }
491
- const createdOrUpdated = [];
492
- for (const agent of installedAgents) {
493
- const status = ensureShimCurrent(agent);
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
@@ -374,15 +374,18 @@ export async function runDaemon() {
374
374
  return;
375
375
  healing = true;
376
376
  try {
377
- const { heal, summarizeHeal, notifyHeal, healChangedAnything } = await import('./heal.js');
378
- const result = await heal({ mode: 'safe' });
379
- if (healChangedAnything(result) || result.skippedPlugins.length > 0) {
380
- log('INFO', `heal: ${summarizeHeal(result)}`);
381
- notifyHeal(result);
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: `[host/${task.host}]`,
39
- name: task.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
+ }
@@ -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
  *
@@ -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,2 @@
1
+ import type { HealCheck } from '../types.js';
2
+ export declare const pathCheck: HealCheck;
@@ -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,2 @@
1
+ import type { HealCheck } from '../types.js';
2
+ export declare const resourcesCheck: HealCheck;
@@ -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
+ };
@@ -0,0 +1,2 @@
1
+ import type { HealCheck } from '../types.js';
2
+ export declare const shadowingCheck: HealCheck;