@phnx-labs/agents-cli 1.22.8 → 1.22.10

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 (65) hide show
  1. package/CHANGELOG.md +64 -4
  2. package/README.md +5 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +2 -2
  5. package/dist/commands/exec.js +41 -9
  6. package/dist/commands/feed.js +38 -1
  7. package/dist/commands/harness.d.ts +40 -1
  8. package/dist/commands/harness.js +316 -76
  9. package/dist/commands/profiles.d.ts +42 -0
  10. package/dist/commands/profiles.js +91 -3
  11. package/dist/commands/secrets.js +38 -39
  12. package/dist/commands/sessions-picker.js +3 -3
  13. package/dist/commands/sessions-render.d.ts +12 -0
  14. package/dist/commands/sessions-render.js +124 -0
  15. package/dist/commands/sessions.js +28 -1
  16. package/dist/commands/ssh.js +30 -8
  17. package/dist/commands/teams.js +12 -1
  18. package/dist/commands/workflows.js +1 -1
  19. package/dist/index.js +9 -2
  20. package/dist/lib/crabbox/cli.d.ts +35 -0
  21. package/dist/lib/crabbox/cli.js +46 -0
  22. package/dist/lib/crabbox/config.d.ts +21 -0
  23. package/dist/lib/crabbox/config.js +43 -0
  24. package/dist/lib/crabbox/lease.d.ts +15 -5
  25. package/dist/lib/crabbox/lease.js +57 -17
  26. package/dist/lib/daemon.js +12 -11
  27. package/dist/lib/devices/resolve-target.d.ts +4 -3
  28. package/dist/lib/devices/resolve-target.js +4 -3
  29. package/dist/lib/hosts/passthrough.js +18 -1
  30. package/dist/lib/hosts/registry.d.ts +4 -0
  31. package/dist/lib/hosts/registry.js +16 -0
  32. package/dist/lib/hosts/remote-cmd.js +1 -0
  33. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  34. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  35. package/dist/lib/observe-aliases.d.ts +30 -0
  36. package/dist/lib/observe-aliases.js +56 -0
  37. package/dist/lib/plugins.d.ts +14 -5
  38. package/dist/lib/plugins.js +29 -5
  39. package/dist/lib/redact.js +4 -0
  40. package/dist/lib/resources/types.d.ts +2 -1
  41. package/dist/lib/resources/workflows.d.ts +1 -1
  42. package/dist/lib/resources/workflows.js +22 -20
  43. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  44. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  45. package/dist/lib/secrets/bundles.d.ts +9 -0
  46. package/dist/lib/secrets/bundles.js +9 -23
  47. package/dist/lib/secrets/headless.d.ts +3 -15
  48. package/dist/lib/secrets/headless.js +5 -33
  49. package/dist/lib/secrets/index.d.ts +8 -0
  50. package/dist/lib/secrets/index.js +16 -36
  51. package/dist/lib/session/parse.d.ts +5 -1
  52. package/dist/lib/session/parse.js +23 -13
  53. package/dist/lib/session/prompt.js +5 -0
  54. package/dist/lib/session/render.d.ts +3 -0
  55. package/dist/lib/session/render.js +33 -10
  56. package/dist/lib/share/config.js +5 -7
  57. package/dist/lib/startup/command-registry.js +15 -1
  58. package/dist/lib/types.d.ts +6 -0
  59. package/dist/lib/usage-refresh.d.ts +19 -11
  60. package/dist/lib/usage-refresh.js +75 -43
  61. package/dist/lib/usage.d.ts +12 -0
  62. package/dist/lib/usage.js +37 -6
  63. package/dist/lib/workflows.d.ts +19 -4
  64. package/dist/lib/workflows.js +81 -7
  65. package/package.json +1 -1
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The repo-local crabbox config (`.crabbox.yaml` at the repo root).
3
+ *
4
+ * crabbox itself reads this file when warming a box (the `profile:` key becomes
5
+ * the box's `profile` label). `agents run --lease` reads it too so the warm-pool
6
+ * reuse check matches on the SAME profile the warmup would have used — a reused
7
+ * box is then interchangeable with a fresh one (scripts/sandbox.sh's
8
+ * `pick_ready_box` resolves the pool the same way).
9
+ */
10
+ /**
11
+ * The profile label a box warm pool shares. Matches sandbox.sh's
12
+ * `PROFILE="${PROFILE:-default}"`: a run with no configured profile and a box
13
+ * with no `profile` label both normalize here, so they still match each other.
14
+ */
15
+ export declare const DEFAULT_CRABBOX_PROFILE = "default";
16
+ /**
17
+ * The `profile:` declared by `<repoRoot>/.crabbox.yaml`, or undefined when the
18
+ * file is missing, unreadable, or has no profile key (crabbox then applies its
19
+ * own default, which the pool matcher treats as DEFAULT_CRABBOX_PROFILE).
20
+ */
21
+ export declare function readCrabboxRepoProfile(repoRoot: string): string | undefined;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The repo-local crabbox config (`.crabbox.yaml` at the repo root).
3
+ *
4
+ * crabbox itself reads this file when warming a box (the `profile:` key becomes
5
+ * the box's `profile` label). `agents run --lease` reads it too so the warm-pool
6
+ * reuse check matches on the SAME profile the warmup would have used — a reused
7
+ * box is then interchangeable with a fresh one (scripts/sandbox.sh's
8
+ * `pick_ready_box` resolves the pool the same way).
9
+ */
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import * as yaml from 'yaml';
13
+ /**
14
+ * The profile label a box warm pool shares. Matches sandbox.sh's
15
+ * `PROFILE="${PROFILE:-default}"`: a run with no configured profile and a box
16
+ * with no `profile` label both normalize here, so they still match each other.
17
+ */
18
+ export const DEFAULT_CRABBOX_PROFILE = 'default';
19
+ /**
20
+ * The `profile:` declared by `<repoRoot>/.crabbox.yaml`, or undefined when the
21
+ * file is missing, unreadable, or has no profile key (crabbox then applies its
22
+ * own default, which the pool matcher treats as DEFAULT_CRABBOX_PROFILE).
23
+ */
24
+ export function readCrabboxRepoProfile(repoRoot) {
25
+ let raw;
26
+ try {
27
+ raw = fs.readFileSync(path.join(repoRoot, '.crabbox.yaml'), 'utf-8');
28
+ }
29
+ catch {
30
+ return undefined; // no repo crabbox config — crabbox's own default applies
31
+ }
32
+ let parsed;
33
+ try {
34
+ parsed = yaml.parse(raw);
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ if (!parsed || typeof parsed !== 'object')
40
+ return undefined;
41
+ const profile = parsed.profile;
42
+ return typeof profile === 'string' && profile.length > 0 ? profile : undefined;
43
+ }
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * `agents run --lease` orchestrator.
3
3
  *
4
- * Lease an ephemeral crabbox → provision the picked runtime(s) + their
5
- * credentials → run the agent on the box (via `crabbox run`, which owns the
6
- * SSH) → tear the box down. The whole box-side sequence rides a single
7
- * `--script-stdin` body so the token contents never touch argv.
4
+ * Acquire a box → provision the picked runtime(s) + their credentials → run the
5
+ * agent on the box (via `crabbox run`, which owns the SSH) → tear the box down.
6
+ * Acquisition is reuse-first against the warm profile pool (a ready box carrying
7
+ * the run's profile + netMode labels is reused and kept; `--fresh` opts out and
8
+ * always leases a new, torn-down box). The whole box-side sequence rides a
9
+ * single `--script-stdin` body so the token contents never touch argv.
8
10
  *
9
11
  * ── Command-layer contract (RUSH-1920/1921/1924) ─────────────────────────────
10
12
  * Exports the commands layer (exec.ts / lease.ts / ssh.ts) consumes:
@@ -12,7 +14,9 @@
12
14
  * --bare) gates the `copy-setup` progress sentinel; `opts.netMode`
13
15
  * ('public' | 'tailscale', default 'public') adds the `joined-tailnet` step.
14
16
  * • `LeaseRunOptions.copySetup` / `LeaseRunOptions.netMode` — forwarded by
15
- * `leaseAndRun` (netMode → `crabboxWarmup`).
17
+ * `leaseAndRun` (netMode → `crabboxWarmup`). `LeaseRunOptions.fresh`
18
+ * (`--fresh`) skips the warm profile-pool reuse check and always leases a
19
+ * new box, torn down after the run.
16
20
  * • `crabboxWarmup(opts.netMode)` — 'tailscale' leases onto the tailnet
17
21
  * (`--network tailscale -tailscale-tags tag:crabbox`); auth key rides the
18
22
  * child env as `CRABBOX_TAILSCALE_AUTH_KEY` (crabboxEnv, cli.ts).
@@ -80,6 +84,12 @@ export interface LeaseRunOptions {
80
84
  keep?: boolean;
81
85
  /** Existing warm crabbox slug to reuse instead of provisioning a new lease. */
82
86
  reuseBox?: string;
87
+ /**
88
+ * Force a brand-new box: skip the warm profile-pool reuse check and tear the
89
+ * box down after the run (the pre-pool `--lease` behavior). `--fresh` at the
90
+ * command layer.
91
+ */
92
+ fresh?: boolean;
83
93
  /**
84
94
  * Raw wrapped Claude OAuth payload (from `resolveClaudeCredentialsBlob`), written
85
95
  * to `~/.claude/.credentials.json` on the box. The command layer resolves it
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * `agents run --lease` orchestrator.
3
3
  *
4
- * Lease an ephemeral crabbox → provision the picked runtime(s) + their
5
- * credentials → run the agent on the box (via `crabbox run`, which owns the
6
- * SSH) → tear the box down. The whole box-side sequence rides a single
7
- * `--script-stdin` body so the token contents never touch argv.
4
+ * Acquire a box → provision the picked runtime(s) + their credentials → run the
5
+ * agent on the box (via `crabbox run`, which owns the SSH) → tear the box down.
6
+ * Acquisition is reuse-first against the warm profile pool (a ready box carrying
7
+ * the run's profile + netMode labels is reused and kept; `--fresh` opts out and
8
+ * always leases a new, torn-down box). The whole box-side sequence rides a
9
+ * single `--script-stdin` body so the token contents never touch argv.
8
10
  *
9
11
  * ── Command-layer contract (RUSH-1920/1921/1924) ─────────────────────────────
10
12
  * Exports the commands layer (exec.ts / lease.ts / ssh.ts) consumes:
@@ -12,7 +14,9 @@
12
14
  * --bare) gates the `copy-setup` progress sentinel; `opts.netMode`
13
15
  * ('public' | 'tailscale', default 'public') adds the `joined-tailnet` step.
14
16
  * • `LeaseRunOptions.copySetup` / `LeaseRunOptions.netMode` — forwarded by
15
- * `leaseAndRun` (netMode → `crabboxWarmup`).
17
+ * `leaseAndRun` (netMode → `crabboxWarmup`). `LeaseRunOptions.fresh`
18
+ * (`--fresh`) skips the warm profile-pool reuse check and always leases a
19
+ * new box, torn down after the run.
16
20
  * • `crabboxWarmup(opts.netMode)` — 'tailscale' leases onto the tailnet
17
21
  * (`--network tailscale -tailscale-tags tag:crabbox`); auth key rides the
18
22
  * child env as `CRABBOX_TAILSCALE_AUTH_KEY` (crabboxEnv, cli.ts).
@@ -24,7 +28,7 @@
24
28
  * secretsBundle?, userAgentsDir?, onData? }): Promise<CopySetupResult>` — the
25
29
  * push-from-local the command layer runs before the box run.
26
30
  */
27
- import { crabboxFind, crabboxWarmup, crabboxWaitReady, crabboxRunScript, crabboxStop } from './cli.js';
31
+ import { crabboxFind, crabboxList, crabboxStatusReady, crabboxWarmup, crabboxWaitReady, crabboxRunScript, crabboxStop, poolReusableBoxes } from './cli.js';
28
32
  import * as yaml from 'yaml';
29
33
  import { buildCredentialScript, buildHomeFileWriteScript, CLAUDE_TOKEN_REMOTE } from './runtimes.js';
30
34
  import { LEASE_AGENT_MARKER, leasePhaseSentinel } from './progress.js';
@@ -149,9 +153,32 @@ export function buildBootstrapScript(opts) {
149
153
  .filter((l) => l.length > 0)
150
154
  .join('\n');
151
155
  }
156
+ /**
157
+ * The first warm box in this run's profile pool that is actually SSH-ready, or
158
+ * null. Mirrors scripts/sandbox.sh's `pick_ready_box`: list the running boxes
159
+ * for the run's profile + netMode, then gate each on `crabbox status`
160
+ * ready=true — a box whose bootstrap failed still lists as `running` and would
161
+ * burn the whole SSH wait before hard-failing. A skipped box is left alone
162
+ * (never stopped): a concurrent run may be mid-boot on it, and crabbox's idle
163
+ * timeout reaps genuine duds.
164
+ */
165
+ function pickReadyPoolBox(opts) {
166
+ const candidates = poolReusableBoxes(crabboxList({ secretsBundle: opts.secretsBundle }), {
167
+ profile: opts.profile,
168
+ netMode: opts.netMode,
169
+ });
170
+ for (const b of candidates) {
171
+ if (crabboxStatusReady(b.slug, { secretsBundle: opts.secretsBundle }))
172
+ return b;
173
+ }
174
+ return null;
175
+ }
152
176
  export async function leaseAndRun(opts) {
153
177
  const startedAt = Date.now();
154
178
  let box;
179
+ // A box this run did NOT provision — either the caller named it (`--box`) or
180
+ // it came out of the warm profile pool. Reused boxes are never torn down.
181
+ let reused = false;
155
182
  if (opts.reuseBox) {
156
183
  opts.onPhase?.({ kind: 'reuse', slug: opts.reuseBox });
157
184
  const found = crabboxFind(opts.reuseBox, { secretsBundle: opts.secretsBundle });
@@ -160,17 +187,29 @@ export async function leaseAndRun(opts) {
160
187
  box = found.ready
161
188
  ? found
162
189
  : await crabboxWaitReady(opts.reuseBox, { secretsBundle: opts.secretsBundle });
190
+ reused = true;
163
191
  }
164
192
  else {
165
- opts.onPhase?.({ kind: 'warmup', backend: opts.backend });
166
- box = await crabboxWarmup({
167
- class: opts.boxClass,
168
- profile: opts.profile,
169
- provider: opts.backend,
170
- secretsBundle: opts.secretsBundle,
171
- netMode: opts.netMode,
172
- });
173
- await crabboxWaitReady(box.slug, { secretsBundle: opts.secretsBundle });
193
+ // Reuse-first: before paying for a fresh lease, look for a warm box in this
194
+ // run's profile pool (same profile label the warmup would use, same netMode).
195
+ // `--fresh` opts out and always provisions.
196
+ const pooled = opts.fresh ? null : pickReadyPoolBox(opts);
197
+ if (pooled) {
198
+ opts.onPhase?.({ kind: 'reuse', slug: pooled.slug });
199
+ box = pooled;
200
+ reused = true;
201
+ }
202
+ else {
203
+ opts.onPhase?.({ kind: 'warmup', backend: opts.backend });
204
+ box = await crabboxWarmup({
205
+ class: opts.boxClass,
206
+ profile: opts.profile,
207
+ provider: opts.backend,
208
+ secretsBundle: opts.secretsBundle,
209
+ netMode: opts.netMode,
210
+ });
211
+ await crabboxWaitReady(box.slug, { secretsBundle: opts.secretsBundle });
212
+ }
174
213
  }
175
214
  opts.onPhase?.({ kind: 'ready', box, elapsedMs: Date.now() - startedAt });
176
215
  // Setup-copy (F1, RUSH-1920): push the git-tracked ~/.agents config onto the
@@ -203,8 +242,9 @@ export async function leaseAndRun(opts) {
203
242
  }
204
243
  finally {
205
244
  // Always attempt teardown (bounds credential lifetime to the run) unless the
206
- // caller explicitly asked to keep the box or targeted an existing warm box.
207
- if (!opts.keep && !opts.reuseBox) {
245
+ // caller explicitly asked to keep the box or the box was reused (an explicit
246
+ // --box target or a warm pool box — both outlive this run).
247
+ if (!opts.keep && !reused) {
208
248
  opts.onPhase?.({ kind: 'teardown' });
209
249
  toreDown = crabboxStop(box.slug, { secretsBundle: opts.secretsBundle });
210
250
  }
@@ -888,14 +888,14 @@ export async function runDaemon() {
888
888
  };
889
889
  const fleetCacheInterval = setInterval(() => { void runFleetCacheWarm(); }, 3 * 60_000);
890
890
  const fleetCacheKickoff = setTimeout(() => { void runFleetCacheWarm(); }, 60_000);
891
- // Adaptive usage refresh: keep the usage cache the `agents run` router reads
891
+ // Usage refresh: keep the usage cache the `agents run` router reads
892
892
  // (RUSH-2061, readOnly hot path) fresh, WITHOUT the hot path ever fetching.
893
- // This host is the sole writer for its own local accounts. The tick wakes at
894
- // the 90s floor, but per-account cadence gates the actual live fetches: an
895
- // account racing toward its 5h cap is polled sooner (down to 90s), an idle one
896
- // rarely (up to 15min), capped at ~6 provider calls/account/hour and skipped
897
- // entirely while its provider is under a 429 backoff. Overlap-guarded like the
898
- // probes above; a box signed into no networked-usage account is a clean no-op.
893
+ // This host is the sole writer for its own local accounts. The tick wakes
894
+ // every 60s (USAGE_REFRESH_TICK_MS) to consider due accounts; each account
895
+ // is scheduled at a fixed 5-minute cadence (REFRESH_INTERVAL_MS), capped at
896
+ // ~12 provider calls/account/hour, skipped under 429 backoff, and fetched
897
+ // with fileOnly credentials so a background tick never pops macOS Touch ID.
898
+ // Overlap-guarded: a slow pass cannot stack concurrent refresh loops.
899
899
  let refreshingUsage = false;
900
900
  const runUsageRefreshTick = async () => {
901
901
  if (refreshingUsage)
@@ -910,9 +910,9 @@ export async function runDaemon() {
910
910
  writeUsageCache: writeClaudeUsageCache,
911
911
  backoffUntil: usageRateLimitedUntil,
912
912
  });
913
- if (r.refreshed > 0 || r.failed > 0) {
914
- log('INFO', `usage refresh: ${r.refreshed} refreshed, ${r.failed} failed, ${r.skippedNotDue} not-due, ${r.skippedBackoff} backed-off, ${r.skippedCap} capped`);
915
- }
913
+ // Always log a compact summary so "is refresh working?" is greppable even
914
+ // when every account was not-due (proves the tick ran).
915
+ log('INFO', `usage refresh: ${r.refreshed} refreshed, ${r.failed} failed, ${r.skippedNotDue} not-due, ${r.skippedBackoff} backed-off, ${r.skippedCap} capped`);
916
916
  }
917
917
  catch (err) {
918
918
  log('ERROR', `usage refresh failed: ${err.message}`);
@@ -921,7 +921,8 @@ export async function runDaemon() {
921
921
  refreshingUsage = false;
922
922
  }
923
923
  };
924
- const usageRefreshInterval = setInterval(() => { void runUsageRefreshTick(); }, 90_000);
924
+ // 60s wake matches USAGE_REFRESH_TICK_MS in usage-refresh.ts (keep in sync).
925
+ const usageRefreshInterval = setInterval(() => { void runUsageRefreshTick(); }, 60_000);
925
926
  const usageRefreshKickoff = setTimeout(() => { void runUsageRefreshTick(); }, 30_000);
926
927
  // RUSH-1817: the startup host decision above is one-shot. If a standalone
927
928
  // broker answered agentPing() at daemon start, the daemon declined to host —
@@ -1,4 +1,4 @@
1
- import { splitUserHost } from '../hosts/registry.js';
1
+ import { splitUserHost, type MatchHostOptions } from '../hosts/registry.js';
2
2
  import { type DeviceProfile } from './registry.js';
3
3
  export { splitUserHost };
4
4
  /** A dialable peer: the ssh target, the machine id used to tag its rows, a
@@ -21,9 +21,10 @@ export interface ResolvedExplicitTargetSet {
21
21
  * `user@host` / IP / FQDN literal yields a synthesized key-auth profile. A bare
22
22
  * unregistered alias (no `@`/dot) — or an ssh_config-only alias, which `agents
23
23
  * ssh` has never dialed — returns undefined so the caller reports "Unknown
24
- * device" rather than dialing a literal.
24
+ * device" rather than dialing a literal. `token` may also be the `auto`
25
+ * affinity sentinel (RUSH-2185); `opts.resolveAuto` overrides the pick (tests).
25
26
  */
26
- export declare function resolveDeviceTarget(token: string): Promise<DeviceProfile | undefined>;
27
+ export declare function resolveDeviceTarget(token: string, opts?: Pick<MatchHostOptions, 'resolveAuto'>): Promise<DeviceProfile | undefined>;
27
28
  /**
28
29
  * Resolve an explicit `--host`/`--device` list to dialable targets. A token that
29
30
  * fails the injection guard or names nothing reachable is skipped with a stderr
@@ -71,10 +71,11 @@ async function toResolvedTarget(token) {
71
71
  * `user@host` / IP / FQDN literal yields a synthesized key-auth profile. A bare
72
72
  * unregistered alias (no `@`/dot) — or an ssh_config-only alias, which `agents
73
73
  * ssh` has never dialed — returns undefined so the caller reports "Unknown
74
- * device" rather than dialing a literal.
74
+ * device" rather than dialing a literal. `token` may also be the `auto`
75
+ * affinity sentinel (RUSH-2185); `opts.resolveAuto` overrides the pick (tests).
75
76
  */
76
- export async function resolveDeviceTarget(token) {
77
- const host = await matchHost(token, { allowBareLiteral: true });
77
+ export async function resolveDeviceTarget(token, opts = {}) {
78
+ const host = await matchHost(token, { allowBareLiteral: true, ...opts });
78
79
  if (!host)
79
80
  return undefined;
80
81
  if (host.device) {
@@ -24,6 +24,7 @@ import { dispatchAgentsCommand, withActorEnv } from './dispatch.js';
24
24
  import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } from './remote-cmd.js';
25
25
  import { resolveRemoteOsSync } from './remote-os.js';
26
26
  import { machineId } from '../session/sync/config.js';
27
+ import { isDeviceAuto, resolveDeviceAffinity } from '../smart-launch.js';
27
28
  import { loadDevices } from '../devices/registry.js';
28
29
  import { isSelfHost } from '../devices/self-host.js';
29
30
  import { fanOutDevices, planFleetTargets, runLocalCommand, runOnDevice, } from '../devices/fleet.js';
@@ -396,7 +397,7 @@ export async function maybeRunOnHost(command, allArgs, opts) {
396
397
  const deviceFlag = flagValue(allArgs, 'device');
397
398
  const hostsFlag = flagValue(allArgs, 'hosts');
398
399
  const devicesFlag = flagValue(allArgs, 'devices');
399
- const hostName = hostFlag ?? deviceFlag;
400
+ let hostName = hostFlag ?? deviceFlag;
400
401
  const fleetAll = isFleetAllSentinel(hostFlag, deviceFlag, hostsFlag, devicesFlag);
401
402
  // Proceed when any routing flag is present, including the plural fleet flags
402
403
  // that may carry the `all` sentinel.
@@ -460,6 +461,22 @@ export async function maybeRunOnHost(command, allArgs, opts) {
460
461
  // flags and bare flags were already handled.
461
462
  if (!hostName)
462
463
  return false;
464
+ // `auto` is the same affinity sentinel `agents run --device auto` resolves
465
+ // (RUSH-2185) — pick the concrete target up front via resolveDeviceAffinity so
466
+ // the isSelfHost check right below (which compares a literal name, not "auto")
467
+ // still catches a local pick and runs the command locally rather than
468
+ // resolving to a real Host and self-SSHing (or, if this box isn't itself a
469
+ // registered device, dialing a literal, nonexistent host named "auto").
470
+ if (isDeviceAuto(hostName)) {
471
+ const plan = resolveDeviceAffinity({});
472
+ if (!plan.host) {
473
+ const stripped = stripRoutingFlags(allArgs, STRIP_SPECS);
474
+ process.argv = [process.argv[0], process.argv[1], ...stripped];
475
+ return false;
476
+ }
477
+ process.stderr.write(chalk.gray(`[agents] device=auto → ${plan.host}\n`));
478
+ hostName = plan.host;
479
+ }
463
480
  // Running against your own machine is just a local run — skip the SSH round-trip.
464
481
  // Match EVERY identity the box answers to (short id, loopback, tailscale
465
482
  // dnsName), not just machineId() — a `--host <self-dnsName>` used to slip past a
@@ -19,6 +19,7 @@
19
19
  import type { Host, HostProvider, HostProviderId } from './types.js';
20
20
  import { DeviceOffloadUnsupportedError } from './types.js';
21
21
  import { type DeviceProfile } from '../devices/registry.js';
22
+ import { type DeviceAffinityPlan } from '../smart-launch.js';
22
23
  export { DeviceOffloadUnsupportedError };
23
24
  export declare function getProvider(id: HostProviderId): HostProvider;
24
25
  export declare function getAllProviders(): HostProvider[];
@@ -52,6 +53,9 @@ export interface MatchHostOptions {
52
53
  * "Unknown device" verdict reachable).
53
54
  */
54
55
  allowBareLiteral?: boolean;
56
+ /** Override the affinity pick for the `auto` sentinel (tests). Defaults to
57
+ * `resolveDeviceAffinity({})` — the same engine `agents run --device auto` uses. */
58
+ resolveAuto?: () => DeviceAffinityPlan;
55
59
  }
56
60
  /**
57
61
  * The one place a `--host` / `--device` token becomes a resolved host. Reads the
@@ -25,6 +25,8 @@ import { readMeta } from '../state.js';
25
25
  import { isSshConfigHost } from './ssh-config.js';
26
26
  import { resolveRemoteOsSync } from './remote-os.js';
27
27
  import { loadDevices, isControlDevice } from '../devices/registry.js';
28
+ import { isDeviceAuto, resolveDeviceAffinity } from '../smart-launch.js';
29
+ import { localMachineId } from '../session/origin-machine.js';
28
30
  // Re-export so existing importers (tests, commands) keep their path; the class
29
31
  // itself lives in types.ts so providers can throw it without a circular import.
30
32
  export { DeviceOffloadUnsupportedError };
@@ -146,6 +148,20 @@ function literalHost(token, host, user) {
146
148
  * into a dispatch verdict.
147
149
  */
148
150
  export async function matchHost(name, opts = {}) {
151
+ // `auto` is the same affinity sentinel `agents run --device auto` resolves
152
+ // (isDeviceAuto / resolveDeviceAffinity in ../smart-launch.js) — shared here so
153
+ // every caller through this one core (ssh, teams, dispatch/passthrough) picks a
154
+ // device the SAME way `run` does instead of rejecting it as "Unknown device
155
+ // 'auto'" (RUSH-2185). A `null` plan.host means the affinity engine picked this
156
+ // very machine; resolve that as the local device/host entry (if any) rather than
157
+ // returning nothing — callers that already special-case "target is this
158
+ // machine" (teams add/create, the passthrough self-host check) then treat it as
159
+ // local exactly as they would if the user had typed the local name.
160
+ if (isDeviceAuto(name)) {
161
+ const plan = (opts.resolveAuto ?? (() => resolveDeviceAffinity({})))();
162
+ const picked = plan.host ?? normalizeHost(localMachineId());
163
+ return matchHost(picked, opts);
164
+ }
149
165
  try {
150
166
  assertValidSshTarget(name);
151
167
  }
@@ -113,6 +113,7 @@ export const RUN_OPTION_FORWARDING = {
113
113
  lease: 'local-only',
114
114
  box: 'local-only',
115
115
  keepBox: 'local-only',
116
+ fresh: 'local-only', // skips the warm-pool reuse for --lease; the lease path is always local
116
117
  reuse: 'local-only', // reuse-picker choice for --lease; the lease path is always local
117
118
  bare: 'local-only', // skips the local setup-copy push; lease-only concern
118
119
  tailscale: 'local-only', // --tailscale/--no-tailscale gate the lease net mode; never forwarded
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Observe-umbrella aliases (Phase 3 surface consolidation).
3
+ *
4
+ * Thin name → real command expansion. No store merge — feed / sessions / events
5
+ * remain the stores; these are doors that point at the right reader.
6
+ *
7
+ * inbox → feed (needs-you default)
8
+ * timeline → feed --filter updates (agent progress stream)
9
+ * roster → sessions --active (live agent roster)
10
+ *
11
+ * `audit` is NOT an alias here — `agents audit` is already the tamper-evident
12
+ * run-dispatch log. Ops trail = `agents events` (optionally `--audit`).
13
+ */
14
+ export type ObserveAlias = 'inbox' | 'timeline' | 'roster';
15
+ export declare const OBSERVE_ALIASES: readonly ObserveAlias[];
16
+ export interface ObserveExpandResult {
17
+ /** argv for the real command (no program name): e.g. ['feed', '--filter', 'updates'] */
18
+ argv: string[];
19
+ /** One-line note for stderr (optional); empty when silent. */
20
+ note: string;
21
+ }
22
+ /** True when `rest` already carries a `--filter` / `--filter=…` flag. */
23
+ export declare function hasFilterFlag(rest: readonly string[]): boolean;
24
+ /** True when `rest` already carries `--active`. */
25
+ export declare function hasActiveFlag(rest: readonly string[]): boolean;
26
+ /**
27
+ * Expand an observe alias + remaining user args into the real command argv.
28
+ * Returns null when `alias` is not an observe alias.
29
+ */
30
+ export declare function expandObserveAlias(alias: string, rest?: readonly string[]): ObserveExpandResult | null;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Observe-umbrella aliases (Phase 3 surface consolidation).
3
+ *
4
+ * Thin name → real command expansion. No store merge — feed / sessions / events
5
+ * remain the stores; these are doors that point at the right reader.
6
+ *
7
+ * inbox → feed (needs-you default)
8
+ * timeline → feed --filter updates (agent progress stream)
9
+ * roster → sessions --active (live agent roster)
10
+ *
11
+ * `audit` is NOT an alias here — `agents audit` is already the tamper-evident
12
+ * run-dispatch log. Ops trail = `agents events` (optionally `--audit`).
13
+ */
14
+ export const OBSERVE_ALIASES = ['inbox', 'timeline', 'roster'];
15
+ /** True when `rest` already carries a `--filter` / `--filter=…` flag. */
16
+ export function hasFilterFlag(rest) {
17
+ return rest.some((a) => a === '--filter' || a.startsWith('--filter='));
18
+ }
19
+ /** True when `rest` already carries `--active`. */
20
+ export function hasActiveFlag(rest) {
21
+ return rest.some((a) => a === '--active');
22
+ }
23
+ /**
24
+ * Expand an observe alias + remaining user args into the real command argv.
25
+ * Returns null when `alias` is not an observe alias.
26
+ */
27
+ export function expandObserveAlias(alias, rest = []) {
28
+ const tail = [...rest];
29
+ switch (alias) {
30
+ case 'inbox':
31
+ return {
32
+ argv: ['feed', ...tail],
33
+ note: 'agents inbox → agents feed (needs-you inbox)',
34
+ };
35
+ case 'timeline': {
36
+ const argv = hasFilterFlag(tail)
37
+ ? ['feed', ...tail]
38
+ : ['feed', '--filter', 'updates', ...tail];
39
+ return {
40
+ argv,
41
+ note: 'agents timeline → agents feed --filter updates',
42
+ };
43
+ }
44
+ case 'roster': {
45
+ const argv = hasActiveFlag(tail)
46
+ ? ['sessions', ...tail]
47
+ : ['sessions', '--active', ...tail];
48
+ return {
49
+ argv,
50
+ note: 'agents roster → agents sessions --active',
51
+ };
52
+ }
53
+ default:
54
+ return null;
55
+ }
56
+ }
@@ -2,11 +2,12 @@
2
2
  * Plugin discovery, validation, and syncing.
3
3
  *
4
4
  * Plugins are bundles in ~/.agents/plugins/ that package skills, hooks,
5
- * commands, agents, bin scripts, MCP servers, and settings under a single
6
- * manifest (plugin.json). They are user-authored resources, sitting alongside
7
- * skills/, commands/, hooks/, etc. — git-tracked as source of truth. This
8
- * module discovers plugins, validates their manifests, and syncs their
9
- * contents into agent version homes.
5
+ * commands, agents, workflows, bin scripts, MCP servers, and settings under a
6
+ * single manifest (plugin.json). They are user-authored resources, sitting
7
+ * alongside skills/, commands/, hooks/, etc. — git-tracked as source of truth.
8
+ * This module discovers plugins, validates their manifests, and syncs their
9
+ * contents into agent version homes. Workflows under a plugin’s workflows/
10
+ * are resolved at run time by resolveWorkflowRef (Phase 5 packaging).
10
11
  */
11
12
  import type { AgentId, DiscoveredPlugin, PluginManifest, MarketplaceSpec } from './types.js';
12
13
  export interface PluginCapabilities {
@@ -95,6 +96,14 @@ export declare function discoverPluginHooks(pluginRoot: string): string[];
95
96
  export declare function discoverPluginCommands(pluginRoot: string): string[];
96
97
  /** Discover agent definition .md files inside a plugin's agents/ directory. */
97
98
  export declare function discoverPluginAgentDefs(pluginRoot: string): string[];
99
+ /**
100
+ * Discover workflow directories inside a plugin's `workflows/` folder.
101
+ * A valid workflow is a directory containing WORKFLOW.md (same contract as
102
+ * project/user/system workflows). Phase 5: plugins package workflows as
103
+ * entrypoints so `agents run <name>` can resolve them without a separate
104
+ * install into ~/.agents/workflows/.
105
+ */
106
+ export declare function discoverPluginWorkflows(pluginRoot: string): string[];
98
107
  /** Discover executable files in a plugin's bin/ directory. */
99
108
  export declare function discoverPluginBin(pluginRoot: string): string[];
100
109
  /** Discover MCP server names from .mcp.json at the plugin root. */
@@ -2,11 +2,12 @@
2
2
  * Plugin discovery, validation, and syncing.
3
3
  *
4
4
  * Plugins are bundles in ~/.agents/plugins/ that package skills, hooks,
5
- * commands, agents, bin scripts, MCP servers, and settings under a single
6
- * manifest (plugin.json). They are user-authored resources, sitting alongside
7
- * skills/, commands/, hooks/, etc. — git-tracked as source of truth. This
8
- * module discovers plugins, validates their manifests, and syncs their
9
- * contents into agent version homes.
5
+ * commands, agents, workflows, bin scripts, MCP servers, and settings under a
6
+ * single manifest (plugin.json). They are user-authored resources, sitting
7
+ * alongside skills/, commands/, hooks/, etc. — git-tracked as source of truth.
8
+ * This module discovers plugins, validates their manifests, and syncs their
9
+ * contents into agent version homes. Workflows under a plugin’s workflows/
10
+ * are resolved at run time by resolveWorkflowRef (Phase 5 packaging).
10
11
  */
11
12
  import * as fs from 'fs';
12
13
  import * as path from 'path';
@@ -107,6 +108,7 @@ export function buildDiscoveredPlugin(pluginRoot, manifest, spec = { kind: 'user
107
108
  scripts: discoverPluginScripts(pluginRoot),
108
109
  commands: discoverPluginCommands(pluginRoot),
109
110
  agentDefs: discoverPluginAgentDefs(pluginRoot),
111
+ workflows: discoverPluginWorkflows(pluginRoot),
110
112
  memory: discoverPluginMemory(pluginRoot),
111
113
  bin: discoverPluginBin(pluginRoot),
112
114
  mcpServers: discoverPluginMcpServers(pluginRoot),
@@ -131,6 +133,7 @@ export function pluginResourceGroups(plugin) {
131
133
  { label: 'skills', items: plugin.skills.map((s) => `/${plugin.name}:${s}`) },
132
134
  { label: 'commands', items: plugin.commands.map((c) => `/${plugin.name}:${c}`) },
133
135
  { label: 'subagents', items: plugin.agentDefs },
136
+ { label: 'workflows', items: plugin.workflows },
134
137
  { label: 'hooks', items: plugin.hooks },
135
138
  { label: 'memory', items: plugin.memory },
136
139
  { label: 'mcp', items: plugin.mcpServers },
@@ -334,6 +337,27 @@ export function discoverPluginAgentDefs(pluginRoot) {
334
337
  .filter(f => f.endsWith('.md') && !f.startsWith('.'))
335
338
  .map(f => f.slice(0, -3));
336
339
  }
340
+ /**
341
+ * Discover workflow directories inside a plugin's `workflows/` folder.
342
+ * A valid workflow is a directory containing WORKFLOW.md (same contract as
343
+ * project/user/system workflows). Phase 5: plugins package workflows as
344
+ * entrypoints so `agents run <name>` can resolve them without a separate
345
+ * install into ~/.agents/workflows/.
346
+ */
347
+ export function discoverPluginWorkflows(pluginRoot) {
348
+ const workflowsDir = path.join(pluginRoot, 'workflows');
349
+ if (!fs.existsSync(workflowsDir))
350
+ return [];
351
+ try {
352
+ return fs.readdirSync(workflowsDir, { withFileTypes: true })
353
+ .filter((e) => e.isDirectory() && !e.name.startsWith('.') &&
354
+ fs.existsSync(path.join(workflowsDir, e.name, 'WORKFLOW.md')))
355
+ .map((e) => e.name);
356
+ }
357
+ catch {
358
+ return [];
359
+ }
360
+ }
337
361
  /** Discover executable files in a plugin's bin/ directory. */
338
362
  export function discoverPluginBin(pluginRoot) {
339
363
  const binDir = path.join(pluginRoot, 'bin');
@@ -2,6 +2,10 @@
2
2
  * Shared redaction helpers for text that may be exported or logged.
3
3
  */
4
4
  const SECRET_PATTERNS = [
5
+ // Local home paths identify operators and disclose internal filesystem layout.
6
+ // Keep the useful path suffix while masking the machine-specific home prefix.
7
+ [/(^|[\s,"'`(=:])\/(?:home|Users)\/[^/\s,"'`]+/g, '$1[HOME]'],
8
+ [/(^|[\s,"'`(=])[A-Z]:\\Users\\[^\\\s,"'`]+/gi, '$1[HOME]'],
5
9
  [/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED_AWS_KEY]'],
6
10
  // GitHub: classic PATs (ghp_), OAuth (gho_), app/refresh/server tokens
7
11
  // (ghs_/ghr_), and fine-grained PATs (github_pat_). All share the 36-char
@@ -6,7 +6,8 @@
6
6
  * - Override on name conflict: Higher layer wins (project > user > system)
7
7
  */
8
8
  export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'copilot' | 'kiro' | 'goose' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes' | 'pi';
9
- export type Layer = 'system' | 'user' | 'project';
9
+ /** Resource origin. Precedence (highest first): project > user > plugin > system. */
10
+ export type Layer = 'system' | 'user' | 'project' | 'plugin';
10
11
  export type ResourceKind = 'command' | 'hook' | 'skill' | 'rule' | 'mcp' | 'permission' | 'subagent' | 'workflow' | 'memory';
11
12
  /** A resolved resource with its origin layer. */
12
13
  export interface ResolvedItem<T> {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Workflows are directory bundles with a WORKFLOW.md containing YAML frontmatter.
5
5
  * They optionally contain subagents/, skills/, and plugins/ subdirectories.
6
- * Resolution order: project > user > system.
6
+ * Resolution order (docs/07-entrypoints): project > user > plugin > extra > system.
7
7
  */
8
8
  import type { AgentId, ResolvedItem, ResourceHandler } from './types.js';
9
9
  export interface WorkflowItem {