@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.
- package/CHANGELOG.md +63 -2
- package/README.md +12 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/cloud.d.ts +3 -0
- package/dist/commands/cloud.js +2 -1
- package/dist/commands/exec.js +65 -1
- package/dist/commands/feed.d.ts +29 -0
- package/dist/commands/feed.js +237 -32
- package/dist/commands/memory.d.ts +9 -0
- package/dist/commands/memory.js +164 -0
- package/dist/commands/message.d.ts +11 -6
- package/dist/commands/message.js +140 -5
- package/dist/commands/routines.js +12 -0
- package/dist/commands/secrets-migrate.d.ts +2 -1
- package/dist/commands/secrets-migrate.js +88 -13
- package/dist/commands/secrets.js +4 -1
- package/dist/commands/sessions.js +15 -4
- package/dist/commands/ssh.js +18 -6
- package/dist/commands/worktree.js +4 -2
- package/dist/index.js +16 -21
- package/dist/lib/agents.js +249 -17
- package/dist/lib/answer-router.d.ts +75 -0
- package/dist/lib/answer-router.js +149 -0
- package/dist/lib/ask-classifier.d.ts +71 -0
- package/dist/lib/ask-classifier.js +197 -0
- package/dist/lib/cloud/antigravity.d.ts +0 -2
- package/dist/lib/cloud/antigravity.js +2 -17
- package/dist/lib/cloud/codex.js +3 -18
- package/dist/lib/cloud/rush.js +3 -15
- package/dist/lib/cloud/stream.js +2 -0
- package/dist/lib/cloud/types.d.ts +21 -0
- package/dist/lib/cloud/types.js +81 -0
- package/dist/lib/crabbox/cli.d.ts +1 -1
- package/dist/lib/crabbox/cli.js +12 -2
- package/dist/lib/crabbox/lease.d.ts +13 -0
- package/dist/lib/crabbox/lease.js +11 -2
- package/dist/lib/crabbox/progress.d.ts +62 -0
- package/dist/lib/crabbox/progress.js +129 -0
- package/dist/lib/devices/resolve-target.d.ts +21 -4
- package/dist/lib/devices/resolve-target.js +82 -14
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +34 -1
- package/dist/lib/events.js +4 -1
- package/dist/lib/exec.js +19 -1
- package/dist/lib/feed-outcome.d.ts +101 -0
- package/dist/lib/feed-outcome.js +244 -0
- package/dist/lib/feed-policy.d.ts +30 -0
- package/dist/lib/feed-policy.js +133 -0
- package/dist/lib/feed.d.ts +127 -3
- package/dist/lib/feed.js +416 -40
- package/dist/lib/git.d.ts +17 -1
- package/dist/lib/git.js +20 -1
- package/dist/lib/hooks.js +522 -12
- package/dist/lib/hosts/passthrough.d.ts +3 -3
- package/dist/lib/hosts/passthrough.js +3 -4
- package/dist/lib/mailbox-gc.d.ts +22 -0
- package/dist/lib/mailbox-gc.js +161 -0
- package/dist/lib/mailbox.d.ts +26 -2
- package/dist/lib/mailbox.js +80 -5
- package/dist/lib/mcp.js +82 -0
- package/dist/lib/memory.d.ts +55 -0
- package/dist/lib/memory.js +274 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/notify.d.ts +16 -0
- package/dist/lib/notify.js +61 -0
- package/dist/lib/operator.d.ts +26 -0
- package/dist/lib/operator.js +107 -0
- package/dist/lib/plugins.d.ts +35 -0
- package/dist/lib/plugins.js +217 -0
- package/dist/lib/remote-agents-json.d.ts +14 -0
- package/dist/lib/remote-agents-json.js +94 -0
- package/dist/lib/resources/mcp.js +44 -0
- package/dist/lib/resources/memory.d.ts +15 -0
- package/dist/lib/resources/memory.js +46 -0
- package/dist/lib/resources/types.d.ts +2 -2
- package/dist/lib/runner.d.ts +43 -0
- package/dist/lib/runner.js +323 -74
- package/dist/lib/sandbox.js +6 -0
- package/dist/lib/secrets/bundles.js +38 -13
- package/dist/lib/secrets/icloud-import.d.ts +12 -3
- package/dist/lib/secrets/icloud-import.js +37 -7
- package/dist/lib/secrets/index.d.ts +106 -2
- package/dist/lib/secrets/index.js +603 -28
- package/dist/lib/session/active.d.ts +18 -0
- package/dist/lib/session/active.js +47 -17
- package/dist/lib/session/db.d.ts +9 -1
- package/dist/lib/session/db.js +18 -3
- package/dist/lib/session/discover.d.ts +13 -0
- package/dist/lib/session/discover.js +31 -0
- package/dist/lib/session/parse.d.ts +8 -0
- package/dist/lib/session/parse.js +42 -21
- package/dist/lib/session/remote-active.js +8 -89
- package/dist/lib/session/remote.d.ts +13 -0
- package/dist/lib/session/remote.js +27 -4
- package/dist/lib/session/state.d.ts +11 -0
- package/dist/lib/session/state.js +37 -0
- package/dist/lib/session/tail.d.ts +23 -4
- package/dist/lib/session/tail.js +34 -16
- package/dist/lib/session/throughput.d.ts +30 -0
- package/dist/lib/session/throughput.js +86 -0
- package/dist/lib/shim-heal.d.ts +12 -3
- package/dist/lib/shim-heal.js +12 -6
- package/dist/lib/staleness/detectors/subagents.js +57 -3
- package/dist/lib/staleness/writers/hooks.js +7 -3
- package/dist/lib/staleness/writers/subagents.js +37 -6
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/subagents.d.ts +52 -0
- package/dist/lib/subagents.js +315 -12
- package/dist/lib/teams/worktree.d.ts +8 -0
- package/dist/lib/teams/worktree.js +8 -0
- package/dist/lib/types.d.ts +10 -2
- package/dist/lib/versions.js +38 -48
- package/package.json +4 -3
- package/scripts/postinstall.js +61 -1
package/dist/lib/cloud/types.js
CHANGED
|
@@ -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.
|
package/dist/lib/crabbox/cli.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|
|
14
|
-
* `user@host` fallback, its OS
|
|
15
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
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)
|
|
29
|
-
* `user@host` fallback, its OS
|
|
30
|
-
*
|
|
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
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
const device =
|
|
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
|
-
|
|
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(
|
|
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.
|
package/dist/lib/devices/sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/lib/events.js
CHANGED
|
@@ -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());
|