@phnx-labs/agents-cli 1.20.52 → 1.20.54

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 (115) hide show
  1. package/CHANGELOG.md +63 -2
  2. package/README.md +12 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/cloud.d.ts +3 -0
  5. package/dist/commands/cloud.js +2 -1
  6. package/dist/commands/exec.js +65 -1
  7. package/dist/commands/feed.d.ts +29 -0
  8. package/dist/commands/feed.js +237 -32
  9. package/dist/commands/memory.d.ts +9 -0
  10. package/dist/commands/memory.js +164 -0
  11. package/dist/commands/message.d.ts +11 -6
  12. package/dist/commands/message.js +140 -5
  13. package/dist/commands/routines.js +12 -0
  14. package/dist/commands/secrets-migrate.d.ts +2 -1
  15. package/dist/commands/secrets-migrate.js +88 -13
  16. package/dist/commands/secrets.js +4 -1
  17. package/dist/commands/sessions.js +15 -4
  18. package/dist/commands/ssh.js +18 -6
  19. package/dist/commands/worktree.js +4 -2
  20. package/dist/index.js +16 -21
  21. package/dist/lib/agents.js +249 -17
  22. package/dist/lib/answer-router.d.ts +75 -0
  23. package/dist/lib/answer-router.js +149 -0
  24. package/dist/lib/ask-classifier.d.ts +71 -0
  25. package/dist/lib/ask-classifier.js +197 -0
  26. package/dist/lib/cloud/antigravity.d.ts +0 -2
  27. package/dist/lib/cloud/antigravity.js +2 -17
  28. package/dist/lib/cloud/codex.js +3 -18
  29. package/dist/lib/cloud/rush.js +3 -15
  30. package/dist/lib/cloud/stream.js +2 -0
  31. package/dist/lib/cloud/types.d.ts +21 -0
  32. package/dist/lib/cloud/types.js +81 -0
  33. package/dist/lib/crabbox/cli.d.ts +1 -1
  34. package/dist/lib/crabbox/cli.js +12 -2
  35. package/dist/lib/crabbox/lease.d.ts +13 -0
  36. package/dist/lib/crabbox/lease.js +11 -2
  37. package/dist/lib/crabbox/progress.d.ts +62 -0
  38. package/dist/lib/crabbox/progress.js +129 -0
  39. package/dist/lib/devices/resolve-target.d.ts +21 -4
  40. package/dist/lib/devices/resolve-target.js +82 -14
  41. package/dist/lib/devices/sync.d.ts +18 -0
  42. package/dist/lib/devices/sync.js +34 -1
  43. package/dist/lib/events.js +4 -1
  44. package/dist/lib/exec.js +19 -1
  45. package/dist/lib/feed-outcome.d.ts +101 -0
  46. package/dist/lib/feed-outcome.js +244 -0
  47. package/dist/lib/feed-policy.d.ts +30 -0
  48. package/dist/lib/feed-policy.js +133 -0
  49. package/dist/lib/feed.d.ts +127 -3
  50. package/dist/lib/feed.js +416 -40
  51. package/dist/lib/git.d.ts +17 -1
  52. package/dist/lib/git.js +20 -1
  53. package/dist/lib/hooks.js +522 -12
  54. package/dist/lib/hosts/passthrough.d.ts +3 -3
  55. package/dist/lib/hosts/passthrough.js +3 -4
  56. package/dist/lib/mailbox-gc.d.ts +22 -0
  57. package/dist/lib/mailbox-gc.js +161 -0
  58. package/dist/lib/mailbox.d.ts +26 -2
  59. package/dist/lib/mailbox.js +80 -5
  60. package/dist/lib/mcp.js +82 -0
  61. package/dist/lib/memory.d.ts +55 -0
  62. package/dist/lib/memory.js +274 -0
  63. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  64. package/dist/lib/notify.d.ts +16 -0
  65. package/dist/lib/notify.js +61 -0
  66. package/dist/lib/operator.d.ts +26 -0
  67. package/dist/lib/operator.js +107 -0
  68. package/dist/lib/plugins.d.ts +35 -0
  69. package/dist/lib/plugins.js +217 -0
  70. package/dist/lib/remote-agents-json.d.ts +14 -0
  71. package/dist/lib/remote-agents-json.js +94 -0
  72. package/dist/lib/resources/mcp.js +44 -0
  73. package/dist/lib/resources/memory.d.ts +15 -0
  74. package/dist/lib/resources/memory.js +46 -0
  75. package/dist/lib/resources/types.d.ts +2 -2
  76. package/dist/lib/runner.d.ts +43 -0
  77. package/dist/lib/runner.js +323 -74
  78. package/dist/lib/sandbox.js +6 -0
  79. package/dist/lib/secrets/bundles.js +38 -13
  80. package/dist/lib/secrets/icloud-import.d.ts +12 -3
  81. package/dist/lib/secrets/icloud-import.js +37 -7
  82. package/dist/lib/secrets/index.d.ts +106 -2
  83. package/dist/lib/secrets/index.js +603 -28
  84. package/dist/lib/session/active.d.ts +18 -0
  85. package/dist/lib/session/active.js +47 -17
  86. package/dist/lib/session/db.d.ts +9 -1
  87. package/dist/lib/session/db.js +18 -3
  88. package/dist/lib/session/discover.d.ts +13 -0
  89. package/dist/lib/session/discover.js +31 -0
  90. package/dist/lib/session/parse.d.ts +8 -0
  91. package/dist/lib/session/parse.js +42 -21
  92. package/dist/lib/session/remote-active.js +8 -89
  93. package/dist/lib/session/remote.d.ts +13 -0
  94. package/dist/lib/session/remote.js +27 -4
  95. package/dist/lib/session/state.d.ts +11 -0
  96. package/dist/lib/session/state.js +37 -0
  97. package/dist/lib/session/tail.d.ts +23 -4
  98. package/dist/lib/session/tail.js +34 -16
  99. package/dist/lib/session/throughput.d.ts +30 -0
  100. package/dist/lib/session/throughput.js +86 -0
  101. package/dist/lib/shim-heal.d.ts +12 -3
  102. package/dist/lib/shim-heal.js +12 -6
  103. package/dist/lib/staleness/detectors/subagents.js +57 -3
  104. package/dist/lib/staleness/writers/hooks.js +7 -3
  105. package/dist/lib/staleness/writers/subagents.js +37 -6
  106. package/dist/lib/startup/command-registry.d.ts +1 -0
  107. package/dist/lib/startup/command-registry.js +2 -0
  108. package/dist/lib/subagents.d.ts +52 -0
  109. package/dist/lib/subagents.js +315 -12
  110. package/dist/lib/teams/worktree.d.ts +8 -0
  111. package/dist/lib/teams/worktree.js +8 -0
  112. package/dist/lib/types.d.ts +10 -2
  113. package/dist/lib/versions.js +38 -48
  114. package/package.json +4 -3
  115. package/scripts/postinstall.js +61 -1
@@ -5,6 +5,87 @@
5
5
  * Factory) implement, plus the shared task and event types that flow through
6
6
  * the dispatch pipeline.
7
7
  */
8
+ /**
9
+ * Normalize a provider's raw wire status into the canonical `CloudTaskStatus`.
10
+ *
11
+ * Each cloud backend speaks its own status vocabulary and had its own copy of
12
+ * this mapping, which had drifted. This dispatches per provider while keeping
13
+ * provider-specific defaults explicit:
14
+ * - `rush` — switch over the Factory Floor's known strings; includes
15
+ * `allocating` and stopped/resumable `idle` states, has no
16
+ * `queued`, default `running`.
17
+ * - `codex` — substring match on the lowercased CLI status; default
18
+ * `running`.
19
+ * - `antigravity` — substring match on the (possibly `undefined`) Interactions
20
+ * API status; default `completed` (its synchronous response
21
+ * is terminal), and `undefined`-safe.
22
+ *
23
+ * Factory's `mapResultStatus` is structurally different (it maps a droid *exit*
24
+ * result, not a lifecycle string) and deliberately stays in `factory.ts`.
25
+ */
26
+ export function normalizeProviderStatus(provider, wireStatus) {
27
+ switch (provider) {
28
+ case 'rush':
29
+ return normalizeRushStatus(wireStatus ?? '');
30
+ case 'codex':
31
+ return normalizeCodexStatus(wireStatus ?? '');
32
+ case 'antigravity':
33
+ return normalizeAntigravityStatus(wireStatus);
34
+ }
35
+ }
36
+ /** Rush Factory Floor status → canonical enum. Default `running`; no `queued`. */
37
+ function normalizeRushStatus(s) {
38
+ switch (s) {
39
+ case 'allocating': return 'allocating';
40
+ case 'running': return 'running';
41
+ case 'idle':
42
+ case 'paused':
43
+ case 'needs_review': return 'idle';
44
+ case 'input_required': return 'input_required';
45
+ case 'completed': return 'completed';
46
+ case 'failed': return 'failed';
47
+ case 'cancelled': return 'cancelled';
48
+ default: return 'running';
49
+ }
50
+ }
51
+ /** Codex Cloud CLI status → canonical enum. Substring match; default `running`. */
52
+ function normalizeCodexStatus(s) {
53
+ const lower = s.toLowerCase();
54
+ if (lower.includes('queued') || lower.includes('pending'))
55
+ return 'queued';
56
+ if (lower.includes('running') || lower.includes('in_progress'))
57
+ return 'running';
58
+ if (lower.includes('idle') || lower.includes('paused') || lower.includes('needs_review'))
59
+ return 'idle';
60
+ if (lower.includes('completed') || lower.includes('succeeded') || lower.includes('success'))
61
+ return 'completed';
62
+ if (lower.includes('failed') || lower.includes('error'))
63
+ return 'failed';
64
+ if (lower.includes('cancelled') || lower.includes('canceled'))
65
+ return 'cancelled';
66
+ return 'running';
67
+ }
68
+ /**
69
+ * Antigravity Interactions API status → canonical enum. Substring match,
70
+ * `undefined`-safe; default `completed` because the synchronous response is
71
+ * already terminal.
72
+ */
73
+ function normalizeAntigravityStatus(s) {
74
+ const lower = (s ?? '').toLowerCase();
75
+ if (lower.includes('queue') || lower.includes('pending'))
76
+ return 'queued';
77
+ if (lower.includes('run') || lower.includes('progress'))
78
+ return 'running';
79
+ if (lower.includes('idle') || lower.includes('paused') || lower.includes('needs_review'))
80
+ return 'idle';
81
+ if (lower.includes('complete') || lower.includes('success'))
82
+ return 'completed';
83
+ if (lower.includes('fail') || lower.includes('error'))
84
+ return 'failed';
85
+ if (lower.includes('cancel'))
86
+ return 'cancelled';
87
+ return 'completed';
88
+ }
8
89
  /** Maximum images allowed per dispatch (matches Cursor Background Agents). */
9
90
  export const MAX_IMAGES_PER_DISPATCH = 5;
10
91
  /**
@@ -62,7 +62,7 @@ export interface WarmupOptions extends CrabboxOptions {
62
62
  * created even if warmup's stdout format changes — the new lease id is the one
63
63
  * that wasn't present before.
64
64
  */
65
- export declare function crabboxWarmup(opts?: WarmupOptions): CrabboxBox;
65
+ export declare function crabboxWarmup(opts?: WarmupOptions): Promise<CrabboxBox>;
66
66
  /**
67
67
  * Poll until the box reports ready, or throw after timeoutMs.
68
68
  * `sleep` is injectable so tests don't wall-clock wait.
@@ -87,7 +87,7 @@ export function crabboxFind(slug, opts = {}) {
87
87
  * created even if warmup's stdout format changes — the new lease id is the one
88
88
  * that wasn't present before.
89
89
  */
90
- export function crabboxWarmup(opts = {}) {
90
+ export async function crabboxWarmup(opts = {}) {
91
91
  findCrabbox();
92
92
  const env = crabboxEnv(opts);
93
93
  const before = new Set(crabboxList(opts).map((b) => b.lease));
@@ -100,7 +100,17 @@ export function crabboxWarmup(opts = {}) {
100
100
  args.push('--provider', opts.provider);
101
101
  if (opts.code)
102
102
  args.push('--code');
103
- const r = spawnSync('crabbox', args, { encoding: 'utf-8', env, stdio: ['ignore', 'pipe', 'pipe'] });
103
+ // Async spawn (not spawnSync): provisioning takes 30-90s and a blocking call
104
+ // would freeze any caller's progress spinner. Output is captured, not streamed.
105
+ const r = await new Promise((resolve) => {
106
+ const proc = spawn('crabbox', args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
107
+ let stdout = '';
108
+ let stderr = '';
109
+ proc.stdout.on('data', (c) => (stdout += c.toString('utf-8')));
110
+ proc.stderr.on('data', (c) => (stderr += c.toString('utf-8')));
111
+ proc.on('error', () => resolve({ status: null, stdout, stderr }));
112
+ proc.on('close', (code) => resolve({ status: code, stdout, stderr }));
113
+ });
104
114
  if (r.status !== 0) {
105
115
  const detail = (r.stderr || r.stdout || '').trim();
106
116
  throw new Error(`crabbox warmup failed: ${detail || 'unknown error'}. ` +
@@ -9,6 +9,17 @@
9
9
  import type { AgentId } from '../types.js';
10
10
  import { type CrabboxBox } from './cli.js';
11
11
  import { type DetectedRuntime } from './runtimes.js';
12
+ /** Phase signal for a lease run, so the command layer can drive a progress UI. */
13
+ export type LeasePhase = {
14
+ kind: 'warmup';
15
+ backend?: string;
16
+ } | {
17
+ kind: 'ready';
18
+ box: CrabboxBox;
19
+ elapsedMs: number;
20
+ } | {
21
+ kind: 'teardown';
22
+ };
12
23
  export interface LeaseRunOptions {
13
24
  agent: string;
14
25
  prompt: string;
@@ -24,6 +35,8 @@ export interface LeaseRunOptions {
24
35
  /** Secrets bundle providing crabbox's provider token. */
25
36
  secretsBundle?: string;
26
37
  onData?: (s: string) => void;
38
+ /** Progress phases (warmup → ready → teardown) for a command-layer spinner. */
39
+ onPhase?: (phase: LeasePhase) => void;
27
40
  /** Keep the box after the run instead of stopping it. */
28
41
  keep?: boolean;
29
42
  /**
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { crabboxWarmup, crabboxWaitReady, crabboxRunScript, crabboxStop } from './cli.js';
10
10
  import { buildCredentialScript, CLAUDE_TOKEN_REMOTE } from './runtimes.js';
11
+ import { LEASE_AGENT_MARKER } from './progress.js';
11
12
  /** POSIX single-quote for safe embedding in the generated bootstrap script. */
12
13
  function q(s) {
13
14
  return "'" + s.replace(/'/g, "'\\''") + "'";
@@ -68,6 +69,9 @@ export function buildBootstrapScript(opts) {
68
69
  ENSURE_AGENTS_CLI,
69
70
  installRuntimes,
70
71
  credScript,
72
+ // Marker on its own line: the command layer shows everything before this as
73
+ // setup progress and everything after (the agent's output) verbatim.
74
+ `echo ${q(LEASE_AGENT_MARKER)}`,
71
75
  `${runParts.join(' ')}`,
72
76
  'rc=$?',
73
77
  shred,
@@ -77,13 +81,16 @@ export function buildBootstrapScript(opts) {
77
81
  .join('\n');
78
82
  }
79
83
  export async function leaseAndRun(opts) {
80
- const box = crabboxWarmup({
84
+ const startedAt = Date.now();
85
+ opts.onPhase?.({ kind: 'warmup', backend: opts.backend });
86
+ const box = await crabboxWarmup({
81
87
  class: opts.boxClass,
82
88
  profile: opts.profile,
83
89
  provider: opts.backend,
84
90
  secretsBundle: opts.secretsBundle,
85
91
  });
86
92
  await crabboxWaitReady(box.slug, { secretsBundle: opts.secretsBundle });
93
+ opts.onPhase?.({ kind: 'ready', box, elapsedMs: Date.now() - startedAt });
87
94
  const script = buildBootstrapScript(opts);
88
95
  let exitCode = null;
89
96
  let toreDown = false;
@@ -96,8 +103,10 @@ export async function leaseAndRun(opts) {
96
103
  finally {
97
104
  // Always attempt teardown (bounds credential lifetime to the run) unless the
98
105
  // caller explicitly asked to keep the box.
99
- if (!opts.keep)
106
+ if (!opts.keep) {
107
+ opts.onPhase?.({ kind: 'teardown' });
100
108
  toreDown = crabboxStop(box.slug, { secretsBundle: opts.secretsBundle });
109
+ }
101
110
  }
102
111
  return { box, exitCode, toreDown };
103
112
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Progress routing for `agents run --lease`.
3
+ *
4
+ * The box-side bootstrap emits `LEASE_AGENT_MARKER` on its own line right before
5
+ * `agents run`. Everything the crabbox run streams BEFORE that marker is setup
6
+ * noise (sync bytes, node/agents install, `agents setup`) — shown as spinner
7
+ * text and captured for a failure dump. Everything AFTER is the agent's own
8
+ * output — printed through verbatim. The marker line itself is swallowed.
9
+ */
10
+ /** Sentinel echoed on the box right before `agents run`. Distinctive + collision-proof. */
11
+ export declare const LEASE_AGENT_MARKER = "___AGENTS_LEASE_AGENT_OUTPUT_b1f4c2___";
12
+ export interface Spinner {
13
+ /** Begin animating a phase (renders `frame text` on one line, TTY only). */
14
+ start(text: string): void;
15
+ /** Change the text shown; rendered on the next throttled tick (no write itself). */
16
+ update(text: string): void;
17
+ /** Finalize the current line with a symbol (e.g. ✔) and a newline. */
18
+ stopAndPersist(symbol: string, text: string): void;
19
+ /** Clear the current animated line without persisting anything. */
20
+ stop(): void;
21
+ /** True while a phase is active. */
22
+ readonly active: boolean;
23
+ }
24
+ /**
25
+ * A deliberately minimal, self-throttled spinner. Unlike `ora`, it does NOT hook
26
+ * the stream's `write` to re-render on external output — it writes exactly one
27
+ * short line per fixed tick and nowhere else, so it is structurally incapable of
28
+ * a re-render feedback loop (the failure mode that made `ora` blow up when a
29
+ * lease streamed output past a live spinner). On a non-TTY it prints each phase
30
+ * label once and stays silent on `update`, so piped/CI output never floods.
31
+ *
32
+ * Only ever run ONE phase at a time, and never stream other output to the same
33
+ * stream while a phase is active — stop it first.
34
+ */
35
+ export declare function createSpinner(opts?: {
36
+ stream?: {
37
+ write(s: string): unknown;
38
+ isTTY?: boolean;
39
+ };
40
+ enabled?: boolean;
41
+ intervalMs?: number;
42
+ }): Spinner;
43
+ export interface LeaseOutputRouter {
44
+ /** Feed a raw chunk of the crabbox run's combined stdout/stderr. */
45
+ push(chunk: string): void;
46
+ /** Flush any buffered partial line (call once the stream closes). */
47
+ end(): void;
48
+ /** True once the agent-output marker has been seen. */
49
+ sawAgent(): boolean;
50
+ /** The setup lines seen so far (for a failure dump). */
51
+ setupLines(): string[];
52
+ }
53
+ /**
54
+ * Split the crabbox run stream at `LEASE_AGENT_MARKER`. `onSetupLine` fires for
55
+ * each complete non-empty line before the marker; `onAgentChunk` fires with raw
56
+ * text after it (streamed promptly, not line-buffered, so agent output is live).
57
+ */
58
+ export declare function createLeaseOutputRouter(cb: {
59
+ onSetupLine: (line: string) => void;
60
+ onAgentChunk: (chunk: string) => void;
61
+ marker?: string;
62
+ }): LeaseOutputRouter;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Progress routing for `agents run --lease`.
3
+ *
4
+ * The box-side bootstrap emits `LEASE_AGENT_MARKER` on its own line right before
5
+ * `agents run`. Everything the crabbox run streams BEFORE that marker is setup
6
+ * noise (sync bytes, node/agents install, `agents setup`) — shown as spinner
7
+ * text and captured for a failure dump. Everything AFTER is the agent's own
8
+ * output — printed through verbatim. The marker line itself is swallowed.
9
+ */
10
+ /** Sentinel echoed on the box right before `agents run`. Distinctive + collision-proof. */
11
+ export const LEASE_AGENT_MARKER = '___AGENTS_LEASE_AGENT_OUTPUT_b1f4c2___';
12
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
13
+ /**
14
+ * A deliberately minimal, self-throttled spinner. Unlike `ora`, it does NOT hook
15
+ * the stream's `write` to re-render on external output — it writes exactly one
16
+ * short line per fixed tick and nowhere else, so it is structurally incapable of
17
+ * a re-render feedback loop (the failure mode that made `ora` blow up when a
18
+ * lease streamed output past a live spinner). On a non-TTY it prints each phase
19
+ * label once and stays silent on `update`, so piped/CI output never floods.
20
+ *
21
+ * Only ever run ONE phase at a time, and never stream other output to the same
22
+ * stream while a phase is active — stop it first.
23
+ */
24
+ export function createSpinner(opts = {}) {
25
+ const stream = opts.stream ?? process.stderr;
26
+ const enabled = opts.enabled ?? !!stream.isTTY;
27
+ const intervalMs = opts.intervalMs ?? 120;
28
+ const CLEAR = '\r\u001b[2K';
29
+ let text = '';
30
+ let frame = 0;
31
+ let timer;
32
+ let running = false;
33
+ const write = (s) => stream.write(s);
34
+ return {
35
+ start(t) {
36
+ text = t;
37
+ running = true;
38
+ if (!enabled) {
39
+ write(`${t}\n`);
40
+ return;
41
+ }
42
+ if (timer)
43
+ return;
44
+ frame = 0;
45
+ write(`${CLEAR}${SPINNER_FRAMES[0]} ${text}`);
46
+ timer = setInterval(() => {
47
+ frame = (frame + 1) % SPINNER_FRAMES.length;
48
+ write(`${CLEAR}${SPINNER_FRAMES[frame]} ${text}`);
49
+ }, intervalMs);
50
+ },
51
+ update(t) {
52
+ text = t; // next tick renders it; on a non-TTY we stay silent (no flood)
53
+ },
54
+ stopAndPersist(symbol, t) {
55
+ if (timer) {
56
+ clearInterval(timer);
57
+ timer = undefined;
58
+ }
59
+ running = false;
60
+ write(enabled ? `${CLEAR}${symbol} ${t}\n` : `${symbol} ${t}\n`);
61
+ },
62
+ stop() {
63
+ if (timer) {
64
+ clearInterval(timer);
65
+ timer = undefined;
66
+ if (enabled)
67
+ write(CLEAR);
68
+ }
69
+ running = false;
70
+ },
71
+ get active() {
72
+ return running;
73
+ },
74
+ };
75
+ }
76
+ /**
77
+ * Split the crabbox run stream at `LEASE_AGENT_MARKER`. `onSetupLine` fires for
78
+ * each complete non-empty line before the marker; `onAgentChunk` fires with raw
79
+ * text after it (streamed promptly, not line-buffered, so agent output is live).
80
+ */
81
+ export function createLeaseOutputRouter(cb) {
82
+ const marker = cb.marker ?? LEASE_AGENT_MARKER;
83
+ let seen = false;
84
+ let buf = '';
85
+ const setup = [];
86
+ const emitLine = (line) => {
87
+ const t = line.replace(/\r$/, '');
88
+ if (t.trim()) {
89
+ setup.push(t);
90
+ cb.onSetupLine(t);
91
+ }
92
+ };
93
+ return {
94
+ push(chunk) {
95
+ if (seen) {
96
+ cb.onAgentChunk(chunk);
97
+ return;
98
+ }
99
+ buf += chunk;
100
+ let idx;
101
+ while ((idx = buf.indexOf('\n')) !== -1) {
102
+ const line = buf.slice(0, idx);
103
+ buf = buf.slice(idx + 1);
104
+ if (line.includes(marker)) {
105
+ seen = true;
106
+ // Anything already buffered past the marker is agent output.
107
+ if (buf) {
108
+ cb.onAgentChunk(buf);
109
+ buf = '';
110
+ }
111
+ return;
112
+ }
113
+ emitLine(line);
114
+ }
115
+ // A trailing partial line stays in `buf` — it may be the marker forming.
116
+ },
117
+ end() {
118
+ if (!seen && buf)
119
+ emitLine(buf);
120
+ buf = '';
121
+ },
122
+ sawAgent() {
123
+ return seen;
124
+ },
125
+ setupLines() {
126
+ return setup;
127
+ },
128
+ };
129
+ }
@@ -1,4 +1,4 @@
1
- import { type DeviceRegistry } from './registry.js';
1
+ import { type DeviceProfile, type DeviceRegistry } from './registry.js';
2
2
  /** A dialable peer: the ssh target, the machine id used to tag its rows, a
3
3
  * display name, and the OS family that picks the remote shell dialect. */
4
4
  export interface ResolvedSshTarget {
@@ -7,14 +7,31 @@ export interface ResolvedSshTarget {
7
7
  name: string;
8
8
  os?: string;
9
9
  }
10
+ /** Split a `user@host` / `host` token into its login user (if any) and host part. */
11
+ export declare function splitUserHost(token: string): {
12
+ user?: string;
13
+ host: string;
14
+ };
10
15
  /**
11
16
  * Resolve one `--host`/`--device` token to a concrete ssh target through the
12
17
  * registry. Registry hit → the device's real address + platform (so the machine
13
- * id, route, and OS all match the auto-discovery sweep). Miss a literal
14
- * `user@host` fallback, its OS taken from the host overlay if enrolled. Returns
15
- * undefined only when the token fails the shared ssh-target injection guard.
18
+ * id, route, and OS all match the auto-discovery sweep), with any `user@`
19
+ * overriding the login account. Miss → a literal `user@host` fallback, its OS
20
+ * taken from the host overlay if enrolled. Returns undefined only when the token
21
+ * fails the shared ssh-target injection guard.
16
22
  */
17
23
  export declare function resolveSshTarget(token: string, reg: DeviceRegistry): ResolvedSshTarget | undefined;
24
+ /**
25
+ * Resolve a target token to a full {@link DeviceProfile} for `agents ssh`. Same
26
+ * grammar as {@link resolveSshTarget}, but returns the whole profile (auth,
27
+ * shell, tailscale metadata) `buildSshInvocation` needs — not just a target
28
+ * string. A registered `name` or `user@device` yields that profile (with the
29
+ * login user overridden by any `user@`); an ad-hoc `user@host`/`host` literal
30
+ * yields a synthesized key-auth profile. A bare unregistered alias (no `@`/dot)
31
+ * returns undefined so the caller reports "Unknown device" rather than dialing a
32
+ * literal — the strict behaviour the interactive wrapper has always had.
33
+ */
34
+ export declare function resolveDeviceTarget(token: string, reg: DeviceRegistry): DeviceProfile | undefined;
18
35
  /**
19
36
  * Resolve an explicit `--host`/`--device` list to dialable targets, reading the
20
37
  * registry once. A token that fails the injection guard is skipped with a
@@ -13,8 +13,12 @@
13
13
  * `%C` hash → a cold dial every time) and could read a perfectly reachable box
14
14
  * as "unreachable" when only the non-Tailscale route was down.
15
15
  *
16
- * A raw `user@host` that matches no registered device falls back to a literal
17
- * target so ad-hoc boxes still work.
16
+ * The grammar is uniform across the fleet: `mac-mini` (device name), and
17
+ * `muqsit@mac-mini` (same device, login user overridden) both resolve through
18
+ * the registry to the device's Tailscale route — the `user@` form no longer
19
+ * short-circuits to a bare `ssh muqsit@mac-mini` (LAN DNS). A `user@host` that
20
+ * matches no registered device falls back to a literal target so ad-hoc boxes
21
+ * still work.
18
22
  */
19
23
  import chalk from 'chalk';
20
24
  import { assertValidSshTarget } from '../ssh-exec.js';
@@ -22,12 +26,28 @@ import { normalizeHost } from '../machine-id.js';
22
26
  import { resolveRemoteOsSync } from '../hosts/remote-os.js';
23
27
  import { sshTargetFor } from './connect.js';
24
28
  import { loadDevices } from './registry.js';
29
+ /** Split a `user@host` / `host` token into its login user (if any) and host part. */
30
+ export function splitUserHost(token) {
31
+ const at = token.indexOf('@');
32
+ return at === -1 ? { host: token } : { user: token.slice(0, at), host: token.slice(at + 1) };
33
+ }
34
+ /**
35
+ * Match a host part (the piece after any `user@`) to a registered device: exact
36
+ * registry key first, then a normalized-host match so `yosemite-s0` and
37
+ * `yosemite-s0.<tailnet>.ts.net` land on the same profile. The single source of
38
+ * truth both `resolveSshTarget` (fan-out) and `resolveDeviceTarget` (`agents
39
+ * ssh`) share, so a `user@device` can never resolve two different routes.
40
+ */
41
+ function matchDevice(host, reg) {
42
+ return reg[host] ?? Object.values(reg).find((d) => normalizeHost(d.name) === normalizeHost(host));
43
+ }
25
44
  /**
26
45
  * Resolve one `--host`/`--device` token to a concrete ssh target through the
27
46
  * registry. Registry hit → the device's real address + platform (so the machine
28
- * id, route, and OS all match the auto-discovery sweep). Miss a literal
29
- * `user@host` fallback, its OS taken from the host overlay if enrolled. Returns
30
- * undefined only when the token fails the shared ssh-target injection guard.
47
+ * id, route, and OS all match the auto-discovery sweep), with any `user@`
48
+ * overriding the login account. Miss → a literal `user@host` fallback, its OS
49
+ * taken from the host overlay if enrolled. Returns undefined only when the token
50
+ * fails the shared ssh-target injection guard.
31
51
  */
32
52
  export function resolveSshTarget(token, reg) {
33
53
  try {
@@ -36,22 +56,70 @@ export function resolveSshTarget(token, reg) {
36
56
  catch {
37
57
  return undefined;
38
58
  }
39
- const bare = token.split('@').pop() || token;
40
- // An explicit `user@host` names an exact account/target honour it literally.
41
- // A bare alias (`yosemite-s0`) resolves through the registry to the device's
42
- // real address, so it never diverges from the auto-discovery sweep.
43
- const device = token.includes('@')
44
- ? undefined
45
- : reg[token] ?? Object.values(reg).find((d) => normalizeHost(d.name) === normalizeHost(bare));
59
+ const { user, host } = splitUserHost(token);
60
+ // A device and a `user@device` are the same box; resolve the host part through
61
+ // the registry so both dial the Tailscale route, and let an explicit `user@`
62
+ // override only the login account.
63
+ const device = matchDevice(host, reg);
46
64
  if (device) {
47
65
  try {
48
- return { target: sshTargetFor(device), machine: normalizeHost(device.name), name: device.name, os: device.platform };
66
+ const effective = user ? { ...device, user } : device;
67
+ return { target: sshTargetFor(effective), machine: normalizeHost(device.name), name: device.name, os: device.platform };
49
68
  }
50
69
  catch {
51
70
  // Registered but has no address to dial — fall through to the literal token.
52
71
  }
53
72
  }
54
- return { target: token, machine: normalizeHost(bare), name: token, os: resolveRemoteOsSync(token) };
73
+ return { target: token, machine: normalizeHost(host), name: token, os: resolveRemoteOsSync(token) };
74
+ }
75
+ /** Timestamps for a synthesized ad-hoc profile — never persisted, so a constant
76
+ * keeps the value deterministic (and side-effect free) without reading the clock. */
77
+ const SYNTH_TS = '1970-01-01T00:00:00.000Z';
78
+ /** True when a token is clearly a network target (a `user@`, or a dotted/IPv6
79
+ * host / IP) rather than a bare alias. A bare unknown word is a typo, so `agents
80
+ * ssh foo` still says "Unknown device" instead of dialing a literal `foo`. */
81
+ function looksLikeHostLiteral(token) {
82
+ return token.includes('@') || token.includes('.') || token.includes(':');
83
+ }
84
+ /** Synthesize a throwaway device profile for an ad-hoc `user@host` / `host`
85
+ * literal so `agents ssh` can dial a box that was never registered. */
86
+ function adHocDevice(token, host, user) {
87
+ const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
88
+ return {
89
+ name: token,
90
+ platform: 'unknown',
91
+ shell: 'posix',
92
+ user,
93
+ address: { via: 'manual', dnsName: isIp ? undefined : host, ip: isIp ? host : undefined },
94
+ auth: { method: 'key' },
95
+ createdAt: SYNTH_TS,
96
+ updatedAt: SYNTH_TS,
97
+ };
98
+ }
99
+ /**
100
+ * Resolve a target token to a full {@link DeviceProfile} for `agents ssh`. Same
101
+ * grammar as {@link resolveSshTarget}, but returns the whole profile (auth,
102
+ * shell, tailscale metadata) `buildSshInvocation` needs — not just a target
103
+ * string. A registered `name` or `user@device` yields that profile (with the
104
+ * login user overridden by any `user@`); an ad-hoc `user@host`/`host` literal
105
+ * yields a synthesized key-auth profile. A bare unregistered alias (no `@`/dot)
106
+ * returns undefined so the caller reports "Unknown device" rather than dialing a
107
+ * literal — the strict behaviour the interactive wrapper has always had.
108
+ */
109
+ export function resolveDeviceTarget(token, reg) {
110
+ try {
111
+ assertValidSshTarget(token);
112
+ }
113
+ catch {
114
+ return undefined;
115
+ }
116
+ const { user, host } = splitUserHost(token);
117
+ const device = matchDevice(host, reg);
118
+ if (device)
119
+ return user ? { ...device, user } : device;
120
+ if (looksLikeHostLiteral(token))
121
+ return adHocDevice(token, host, user);
122
+ return undefined;
55
123
  }
56
124
  /**
57
125
  * Resolve an explicit `--host`/`--device` list to dialable targets, reading the
@@ -1,5 +1,23 @@
1
+ import { type DeviceInput } from './registry.js';
1
2
  import { type TailscaleNode } from './tailscale.js';
2
3
  import type { PendingDevice } from './pending.js';
4
+ /**
5
+ * The login user to stamp onto newly-synced devices. Tailscale status carries a
6
+ * node's OS and address but NOT the account you ssh in as, so we materialize the
7
+ * local operator's username — tailnet devices are overwhelmingly one person's
8
+ * boxes, and this is exactly the account ssh would already fall back to. Pinning
9
+ * it in the registry makes `--host <device>` dial that account no matter which
10
+ * machine launches the fan-out (a peer whose local user differs otherwise dials
11
+ * the wrong account). Returns undefined when the username isn't a safe ssh
12
+ * identifier, so a weird value never lands in the registry. */
13
+ export declare function localLoginUser(): string | undefined;
14
+ /**
15
+ * Fill in a device's login user during sync WITHOUT ever clobbering an account
16
+ * the user pinned. Precedence: an existing registered user wins; else the local
17
+ * operator's username; else leave it unset (ssh's implicit local default still
18
+ * applies). Pure so the "never overwrite an explicit user" guard is unit-tested
19
+ * without a tailnet. */
20
+ export declare function withDefaultUser(input: DeviceInput, prevUser: string | undefined, localUser: string | undefined): DeviceInput;
3
21
  /**
4
22
  * bootstrap — register every non-ignored node (opt-out). First-run `agents
5
23
  * setup` and manual `agents devices sync`, so the fleet is usable out of box.
@@ -14,8 +14,39 @@
14
14
  * - soft (`soft: true`): auto-callers must never abort setup/sync because a
15
15
  * machine has no tailscale — they get a result with `ok: false` instead.
16
16
  */
17
+ import * as os from 'os';
17
18
  import { loadDevices, loadIgnored, upsertDevice, } from './registry.js';
18
19
  import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from './tailscale.js';
20
+ /**
21
+ * The login user to stamp onto newly-synced devices. Tailscale status carries a
22
+ * node's OS and address but NOT the account you ssh in as, so we materialize the
23
+ * local operator's username — tailnet devices are overwhelmingly one person's
24
+ * boxes, and this is exactly the account ssh would already fall back to. Pinning
25
+ * it in the registry makes `--host <device>` dial that account no matter which
26
+ * machine launches the fan-out (a peer whose local user differs otherwise dials
27
+ * the wrong account). Returns undefined when the username isn't a safe ssh
28
+ * identifier, so a weird value never lands in the registry. */
29
+ export function localLoginUser() {
30
+ let u;
31
+ try {
32
+ u = os.userInfo().username;
33
+ }
34
+ catch {
35
+ u = process.env.USER || process.env.USERNAME || undefined;
36
+ }
37
+ return u && /^[a-zA-Z0-9._-]+$/.test(u) ? u : undefined;
38
+ }
39
+ /**
40
+ * Fill in a device's login user during sync WITHOUT ever clobbering an account
41
+ * the user pinned. Precedence: an existing registered user wins; else the local
42
+ * operator's username; else leave it unset (ssh's implicit local default still
43
+ * applies). Pure so the "never overwrite an explicit user" guard is unit-tested
44
+ * without a tailnet. */
45
+ export function withDefaultUser(input, prevUser, localUser) {
46
+ if (input.user || prevUser || !localUser)
47
+ return input;
48
+ return { ...input, user: localUser };
49
+ }
19
50
  /**
20
51
  * Node names present on the tailnet but neither already in the registry nor on
21
52
  * the ignore-list — i.e. genuinely new devices worth surfacing. Pure so the
@@ -68,8 +99,10 @@ export async function runDeviceSync(opts = {}) {
68
99
  platform: byName.get(name)?.platform ?? 'unknown',
69
100
  }));
70
101
  const toUpsert = selectNodesToUpsert(nodes, registered, ignored, mode);
102
+ const localUser = localLoginUser();
71
103
  for (const node of toUpsert) {
72
- await upsertDevice(node.name, nodeToDeviceInput(node));
104
+ const input = withDefaultUser(nodeToDeviceInput(node), registeredBefore[node.name]?.user, localUser);
105
+ await upsertDevice(node.name, input);
73
106
  }
74
107
  return { ok: true, synced: toUpsert.length, pending };
75
108
  }
@@ -22,9 +22,12 @@ import { getUserAgentsDir } from './state.js';
22
22
  // ─── Constants ────────────────────────────────────────────────────────────────
23
23
  // Resolved lazily: events.ts is imported transitively by most CLI surfaces, and
24
24
  // import itself must stay side-effect free. Tests may override the exact path.
25
+ // AGENTS_EVENTS_PATH redirects the sink — a test seam like AGENTS_SECRETS_AGENT_DIR:
26
+ // unlike _resetForTest it survives a bare reset AND propagates to CLI subprocesses
27
+ // a test spawns, so fixture events can never land in the user's real log (#910).
25
28
  let _eventsPath;
26
29
  function eventsPath() {
27
- return (_eventsPath ??= path.join(getUserAgentsDir(), 'events.jsonl'));
30
+ return (_eventsPath ??= process.env.AGENTS_EVENTS_PATH || path.join(getUserAgentsDir(), 'events.jsonl'));
28
31
  }
29
32
  function eventsDir() {
30
33
  return path.dirname(eventsPath());