@phnx-labs/agents-cli 1.20.35 → 1.20.36
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 +91 -0
- package/LICENSE +185 -21
- package/README.md +8 -4
- package/dist/commands/audit.d.ts +14 -0
- package/dist/commands/audit.js +68 -0
- package/dist/commands/browser.js +82 -8
- package/dist/commands/check.d.ts +15 -0
- package/dist/commands/check.js +84 -0
- package/dist/commands/cloud.js +143 -3
- package/dist/commands/computer.js +81 -0
- package/dist/commands/daemon.js +4 -1
- package/dist/commands/doctor.js +1 -89
- package/dist/commands/events.js +3 -3
- package/dist/commands/exec.d.ts +19 -0
- package/dist/commands/exec.js +277 -11
- package/dist/commands/hosts.js +10 -6
- package/dist/commands/inspect.js +8 -9
- package/dist/commands/lock.d.ts +12 -0
- package/dist/commands/lock.js +70 -0
- package/dist/commands/message.d.ts +15 -0
- package/dist/commands/message.js +56 -0
- package/dist/commands/routines.js +101 -5
- package/dist/commands/secrets-migrate.js +106 -57
- package/dist/commands/secrets.d.ts +31 -18
- package/dist/commands/secrets.js +156 -75
- package/dist/commands/serve.d.ts +10 -0
- package/dist/commands/serve.js +37 -0
- package/dist/commands/sessions-inject.d.ts +14 -0
- package/dist/commands/sessions-inject.js +111 -0
- package/dist/commands/sessions-picker.d.ts +2 -0
- package/dist/commands/sessions-picker.js +24 -3
- package/dist/commands/sessions-resume.js +20 -8
- package/dist/commands/sessions.d.ts +71 -1
- package/dist/commands/sessions.js +365 -37
- package/dist/commands/setup.js +4 -2
- package/dist/commands/sync.d.ts +3 -1
- package/dist/commands/sync.js +156 -4
- package/dist/commands/teams.js +217 -0
- package/dist/commands/versions.js +2 -4
- package/dist/commands/watchdog.d.ts +18 -0
- package/dist/commands/watchdog.js +238 -0
- package/dist/index.js +25 -2
- package/dist/lib/audit/log.d.ts +92 -0
- package/dist/lib/audit/log.js +177 -0
- package/dist/lib/auto-pull.js +2 -1
- package/dist/lib/browser/chrome.d.ts +10 -0
- package/dist/lib/browser/chrome.js +18 -7
- package/dist/lib/browser/drivers/ssh.js +2 -1
- package/dist/lib/browser/har.d.ts +84 -0
- package/dist/lib/browser/har.js +77 -0
- package/dist/lib/browser/ipc.js +24 -3
- package/dist/lib/browser/profiles.d.ts +1 -1
- package/dist/lib/browser/profiles.js +8 -10
- package/dist/lib/browser/refs.d.ts +65 -0
- package/dist/lib/browser/refs.js +73 -1
- package/dist/lib/browser/runtime-state.js +1 -0
- package/dist/lib/browser/service.d.ts +38 -2
- package/dist/lib/browser/service.js +112 -8
- package/dist/lib/browser/types.d.ts +14 -1
- package/dist/lib/budget/live-cloud.d.ts +42 -0
- package/dist/lib/budget/live-cloud.js +79 -0
- package/dist/lib/budget/live-team.d.ts +31 -0
- package/dist/lib/budget/live-team.js +115 -0
- package/dist/lib/cloud/codex.js +4 -0
- package/dist/lib/cloud/rush.d.ts +12 -1
- package/dist/lib/cloud/rush.js +13 -3
- package/dist/lib/cloud/types.d.ts +9 -0
- package/dist/lib/computer/dispatch.d.ts +8 -0
- package/dist/lib/computer/dispatch.js +125 -0
- package/dist/lib/computer/loop.d.ts +62 -0
- package/dist/lib/computer/loop.js +98 -0
- package/dist/lib/computer/model.d.ts +44 -0
- package/dist/lib/computer/model.js +157 -0
- package/dist/lib/concurrency.d.ts +19 -0
- package/dist/lib/concurrency.js +33 -0
- package/dist/lib/daemon.d.ts +57 -0
- package/dist/lib/daemon.js +192 -16
- package/dist/lib/devices/registry.d.ts +7 -0
- package/dist/lib/devices/registry.js +24 -0
- package/dist/lib/devices/tailscale.js +1 -1
- package/dist/lib/drift.d.ts +52 -0
- package/dist/lib/drift.js +112 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/events.js +31 -13
- package/dist/lib/exec.d.ts +17 -0
- package/dist/lib/exec.js +79 -13
- package/dist/lib/git.d.ts +27 -0
- package/dist/lib/git.js +56 -1
- package/dist/lib/hooks/cache.d.ts +6 -0
- package/dist/lib/hooks/cache.js +54 -12
- package/dist/lib/hooks.d.ts +27 -0
- package/dist/lib/hooks.js +127 -8
- package/dist/lib/hosts/dispatch.d.ts +15 -0
- package/dist/lib/hosts/dispatch.js +39 -6
- package/dist/lib/hosts/logs.js +30 -1
- package/dist/lib/hosts/option.js +1 -1
- package/dist/lib/hosts/passthrough.js +3 -1
- package/dist/lib/hosts/ready.d.ts +29 -6
- package/dist/lib/hosts/ready.js +66 -15
- package/dist/lib/hosts/registry.d.ts +19 -2
- package/dist/lib/hosts/registry.js +58 -2
- package/dist/lib/hosts/remote-cmd.d.ts +62 -1
- package/dist/lib/hosts/remote-cmd.js +70 -1
- package/dist/lib/hosts/remote-os.d.ts +17 -0
- package/dist/lib/hosts/remote-os.js +30 -0
- package/dist/lib/hosts/session-index.d.ts +34 -0
- package/dist/lib/hosts/session-index.js +56 -0
- package/dist/lib/hosts/tasks.d.ts +14 -0
- package/dist/lib/hosts/tasks.js +15 -0
- package/dist/lib/lock.d.ts +93 -0
- package/dist/lib/lock.js +207 -0
- package/dist/lib/loop.js +16 -1
- package/dist/lib/machine-id.d.ts +21 -0
- package/dist/lib/machine-id.js +26 -0
- package/dist/lib/mailbox-target.d.ts +36 -0
- package/dist/lib/mailbox-target.js +45 -0
- package/dist/lib/mailbox.d.ts +47 -0
- package/dist/lib/mailbox.js +194 -0
- package/dist/lib/mcp.d.ts +5 -0
- package/dist/lib/mcp.js +24 -8
- package/dist/lib/migrate.d.ts +19 -0
- package/dist/lib/migrate.js +134 -26
- package/dist/lib/overdue.js +3 -0
- package/dist/lib/picker.d.ts +2 -0
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/exec.d.ts +46 -0
- package/dist/lib/platform/exec.js +74 -0
- package/dist/lib/platform/process.d.ts +31 -0
- package/dist/lib/platform/process.js +34 -1
- package/dist/lib/platform/winpath.js +2 -0
- package/dist/lib/plugins.js +16 -6
- package/dist/lib/profiles.d.ts +25 -0
- package/dist/lib/profiles.js +22 -6
- package/dist/lib/pty-client.js +2 -1
- package/dist/lib/rotate.d.ts +61 -0
- package/dist/lib/rotate.js +52 -0
- package/dist/lib/routines.d.ts +40 -2
- package/dist/lib/routines.js +66 -8
- package/dist/lib/runner.d.ts +11 -2
- package/dist/lib/runner.js +49 -7
- package/dist/lib/scheduler.js +6 -1
- package/dist/lib/secrets/bundles.d.ts +60 -4
- package/dist/lib/secrets/bundles.js +131 -12
- package/dist/lib/secrets/filestore.d.ts +3 -0
- package/dist/lib/secrets/filestore.js +42 -16
- package/dist/lib/secrets/index.d.ts +43 -2
- package/dist/lib/secrets/index.js +102 -3
- package/dist/lib/secrets/mcp.d.ts +93 -0
- package/dist/lib/secrets/mcp.js +205 -0
- package/dist/lib/secrets/remote.js +12 -5
- package/dist/lib/secrets/sync.js +83 -4
- package/dist/lib/secrets/windows.js +14 -3
- package/dist/lib/serve/data.d.ts +81 -0
- package/dist/lib/serve/data.js +91 -0
- package/dist/lib/serve/page.d.ts +7 -0
- package/dist/lib/serve/page.js +140 -0
- package/dist/lib/serve/server.d.ts +46 -0
- package/dist/lib/serve/server.js +115 -0
- package/dist/lib/session/active.d.ts +54 -0
- package/dist/lib/session/active.js +190 -19
- package/dist/lib/session/discover.d.ts +37 -0
- package/dist/lib/session/discover.js +111 -28
- package/dist/lib/session/inject.d.ts +18 -0
- package/dist/lib/session/inject.js +21 -0
- package/dist/lib/session/parse.js +23 -20
- package/dist/lib/session/pid-registry.d.ts +1 -0
- package/dist/lib/session/pid-registry.js +24 -0
- package/dist/lib/session/provenance.d.ts +14 -2
- package/dist/lib/session/provenance.js +39 -8
- package/dist/lib/session/remote-active.js +19 -7
- package/dist/lib/session/remote-list.d.ts +51 -0
- package/dist/lib/session/remote-list.js +213 -0
- package/dist/lib/session/remote.d.ts +7 -1
- package/dist/lib/session/remote.js +16 -2
- package/dist/lib/session/sync/config.d.ts +1 -15
- package/dist/lib/session/sync/config.js +4 -20
- package/dist/lib/session/types.d.ts +17 -0
- package/dist/lib/shims.d.ts +36 -6
- package/dist/lib/shims.js +91 -29
- package/dist/lib/ssh-exec.js +2 -0
- package/dist/lib/ssh-tunnel.js +2 -1
- package/dist/lib/startup/command-registry.d.ts +6 -0
- package/dist/lib/startup/command-registry.js +13 -1
- package/dist/lib/state.d.ts +13 -0
- package/dist/lib/state.js +103 -9
- package/dist/lib/sync-umbrella.d.ts +14 -7
- package/dist/lib/sync-umbrella.js +17 -9
- package/dist/lib/teams/forEach.d.ts +110 -0
- package/dist/lib/teams/forEach.js +186 -0
- package/dist/lib/teams/index.d.ts +1 -0
- package/dist/lib/teams/index.js +1 -0
- package/dist/lib/teams/pr-watch.d.ts +226 -0
- package/dist/lib/teams/pr-watch.js +371 -0
- package/dist/lib/teams/supervisor.d.ts +14 -1
- package/dist/lib/teams/supervisor.js +19 -0
- package/dist/lib/teams/worktree.d.ts +9 -0
- package/dist/lib/teams/worktree.js +32 -0
- package/dist/lib/terminal/backends/index.d.ts +2 -1
- package/dist/lib/terminal/backends/index.js +3 -1
- package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
- package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
- package/dist/lib/terminal/index.d.ts +4 -1
- package/dist/lib/terminal/index.js +4 -1
- package/dist/lib/terminal/inject.d.ts +204 -0
- package/dist/lib/terminal/inject.js +247 -0
- package/dist/lib/terminal/resolve.d.ts +64 -0
- package/dist/lib/terminal/resolve.js +90 -0
- package/dist/lib/terminal/types.d.ts +1 -1
- package/dist/lib/triggers/webhook.d.ts +85 -0
- package/dist/lib/triggers/webhook.js +141 -0
- package/dist/lib/versions.d.ts +23 -0
- package/dist/lib/versions.js +119 -13
- package/dist/lib/watchdog/index.d.ts +3 -0
- package/dist/lib/watchdog/index.js +5 -0
- package/dist/lib/watchdog/read.d.ts +35 -0
- package/dist/lib/watchdog/read.js +149 -0
- package/dist/lib/watchdog/runner.d.ts +127 -0
- package/dist/lib/watchdog/runner.js +322 -0
- package/dist/lib/watchdog/watchdog.d.ts +40 -0
- package/dist/lib/watchdog/watchdog.js +166 -0
- package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
- package/dist/lib/watchdog/watchdogTail.js +154 -0
- package/dist/lib/workflows.d.ts +166 -0
- package/dist/lib/workflows.js +193 -0
- package/package.json +5 -4
|
@@ -74,6 +74,22 @@ interface ClaudeSessionScan {
|
|
|
74
74
|
* TTL, no external lock files needed.
|
|
75
75
|
*/
|
|
76
76
|
export declare function discoverSessions(options?: DiscoverOptions): Promise<SessionMeta[]>;
|
|
77
|
+
/**
|
|
78
|
+
* How many agents' dotfile dirs we scan at once, and the minimum spacing between
|
|
79
|
+
* successive scan starts. A small bound + stagger turns a simultaneous bulk
|
|
80
|
+
* multi-dotfile sweep (a behavioral-EDR file-enumeration trigger) into a trickle.
|
|
81
|
+
*/
|
|
82
|
+
export declare const DOTFILE_SCAN_CONCURRENCY = 2;
|
|
83
|
+
/** Run each agent's incremental scan, bounded + staggered. Order is irrelevant (each scan writes its own rows). */
|
|
84
|
+
export declare function scanAgentsBounded<T>(items: readonly T[], run: (item: T) => Promise<void>): Promise<void[]>;
|
|
85
|
+
/**
|
|
86
|
+
* The machine a discovered session originated on. Cross-machine sync mirrors a
|
|
87
|
+
* remote transcript to backups/<agent>/<machine>/<subdir>/… (see mirrorPath in
|
|
88
|
+
* sync/agents.ts); every other transcript is a live-home file on this box. So:
|
|
89
|
+
* when the path sits under the agent's backups root, the first segment below it
|
|
90
|
+
* is the origin machine id; otherwise it's the local machine.
|
|
91
|
+
*/
|
|
92
|
+
export declare function machineForSessionFile(filePath: string, agent: string): string;
|
|
77
93
|
/**
|
|
78
94
|
* Count sessions in scope without running an incremental scan. Assumes the DB
|
|
79
95
|
* is already fresh (typically true because `discoverSessions` ran first this
|
|
@@ -105,8 +121,29 @@ export declare function getAgentSessionDirs(agent: string, subdir: string): stri
|
|
|
105
121
|
* recent startedAt.
|
|
106
122
|
*/
|
|
107
123
|
export declare function buildClaudeLabelMap(): Map<string, string | null>;
|
|
124
|
+
/**
|
|
125
|
+
* Base64url-decode a JWT and return its `email` claim, if present. Split out so
|
|
126
|
+
* the decode is a single, testable step — and so it only runs when someone
|
|
127
|
+
* actually reads the Codex account (see the lazy resolution below).
|
|
128
|
+
*/
|
|
129
|
+
export declare function decodeJwtEmail(idToken: string): string | undefined;
|
|
130
|
+
/** Test seam: how many times getCodexAccount has actually resolved (decoded) since the last reset. */
|
|
131
|
+
export declare function __codexAccountResolveCountForTest(): number;
|
|
132
|
+
/** Test seam: clear the memoized account + resolve counter so laziness can be observed from a clean slate. */
|
|
133
|
+
export declare function __resetCodexAccountCacheForTest(): void;
|
|
108
134
|
/** Parse the lines of a Codex `session_index.jsonl` into a session id -> title map. */
|
|
109
135
|
export declare function parseCodexThreadNameIndex(raw: string): Map<string, string>;
|
|
136
|
+
/**
|
|
137
|
+
* Stream-parse a single Codex JSONL file to extract session metadata.
|
|
138
|
+
*
|
|
139
|
+
* `resolveAccount` is a lazy thunk (not a resolved string): the JWT decode it
|
|
140
|
+
* performs is deferred until we know this file is a real session worth building
|
|
141
|
+
* metadata for, and only then — never during the file walk / stat phase.
|
|
142
|
+
*/
|
|
143
|
+
export declare function readCodexMeta(filePath: string, resolveAccount?: () => string | undefined, currentVersion?: string): Promise<{
|
|
144
|
+
meta: SessionMeta;
|
|
145
|
+
content: string;
|
|
146
|
+
} | null>;
|
|
110
147
|
/** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
|
|
111
148
|
export declare function scanClaudeSession(filePath: string): Promise<ClaudeSessionScan>;
|
|
112
149
|
/** Read up to maxLines non-empty lines from the beginning of a file. */
|
|
@@ -23,6 +23,8 @@ import { extractSessionTopic } from './prompt.js';
|
|
|
23
23
|
import { parseAntigravity } from './parse.js';
|
|
24
24
|
import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand } from './state.js';
|
|
25
25
|
import { costOfUsage } from '../pricing/index.js';
|
|
26
|
+
import { machineId } from './sync/config.js';
|
|
27
|
+
import { mapBounded } from '../concurrency.js';
|
|
26
28
|
import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
|
|
27
29
|
const HOME = os.homedir();
|
|
28
30
|
// Versions can live under either repo: the user repo (current canonical
|
|
@@ -56,28 +58,68 @@ export async function discoverSessions(options) {
|
|
|
56
58
|
const onProgress = options?.onProgress;
|
|
57
59
|
if (tryClaimScan(process.pid)) {
|
|
58
60
|
try {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
case 'antigravity': return scanAntigravityIncremental(onProgress);
|
|
65
|
-
case 'opencode': return scanOpenCodeIncremental();
|
|
66
|
-
case 'openclaw': return scanOpenClawIncremental();
|
|
67
|
-
case 'rush': return scanRushIncremental(onProgress);
|
|
68
|
-
case 'hermes': return scanHermesIncremental(onProgress);
|
|
69
|
-
case 'kimi': return scanKimiIncremental(onProgress);
|
|
70
|
-
case 'droid': return scanDroidIncremental(onProgress);
|
|
71
|
-
}
|
|
72
|
-
}));
|
|
61
|
+
// Bounded + staggered instead of a single Promise.all: scanning every
|
|
62
|
+
// agent's dotfile dir (~/.claude, ~/.codex, ~/.gemini, …) simultaneously
|
|
63
|
+
// reads to behavioral EDR (CrowdStrike Falcon) as a ransomware-style bulk
|
|
64
|
+
// file-enumeration sweep. Same dirs, same results — just not all at once.
|
|
65
|
+
await scanAgentsBounded(agents, agent => dispatchAgentScan(agent, onProgress));
|
|
73
66
|
}
|
|
74
67
|
finally {
|
|
75
68
|
releaseScan(process.pid);
|
|
76
69
|
}
|
|
77
70
|
}
|
|
78
71
|
const sessions = querySessions(buildQueryOptions(options, agents, { includeLimit: true }));
|
|
72
|
+
for (const s of sessions)
|
|
73
|
+
s.machine = machineForSessionFile(s.filePath, s.agent);
|
|
79
74
|
return sessions;
|
|
80
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* How many agents' dotfile dirs we scan at once, and the minimum spacing between
|
|
78
|
+
* successive scan starts. A small bound + stagger turns a simultaneous bulk
|
|
79
|
+
* multi-dotfile sweep (a behavioral-EDR file-enumeration trigger) into a trickle.
|
|
80
|
+
*/
|
|
81
|
+
export const DOTFILE_SCAN_CONCURRENCY = 2;
|
|
82
|
+
const DOTFILE_SCAN_STAGGER_MS = 15;
|
|
83
|
+
/** Run each agent's incremental scan, bounded + staggered. Order is irrelevant (each scan writes its own rows). */
|
|
84
|
+
export function scanAgentsBounded(items, run) {
|
|
85
|
+
return mapBounded(items, run, {
|
|
86
|
+
concurrency: DOTFILE_SCAN_CONCURRENCY,
|
|
87
|
+
staggerMs: DOTFILE_SCAN_STAGGER_MS,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/** Dispatch a single agent's incremental dotfile scan. */
|
|
91
|
+
function dispatchAgentScan(agent, onProgress) {
|
|
92
|
+
switch (agent) {
|
|
93
|
+
case 'claude': return scanClaudeIncremental(onProgress);
|
|
94
|
+
case 'codex': return scanCodexIncremental(onProgress);
|
|
95
|
+
case 'gemini': return scanGeminiIncremental(onProgress);
|
|
96
|
+
case 'antigravity': return scanAntigravityIncremental(onProgress);
|
|
97
|
+
case 'opencode': return scanOpenCodeIncremental();
|
|
98
|
+
case 'openclaw': return scanOpenClawIncremental();
|
|
99
|
+
case 'rush': return scanRushIncremental(onProgress);
|
|
100
|
+
case 'hermes': return scanHermesIncremental(onProgress);
|
|
101
|
+
case 'kimi': return scanKimiIncremental(onProgress);
|
|
102
|
+
case 'droid': return scanDroidIncremental(onProgress);
|
|
103
|
+
default: return Promise.resolve();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
let _localMachineId;
|
|
107
|
+
/**
|
|
108
|
+
* The machine a discovered session originated on. Cross-machine sync mirrors a
|
|
109
|
+
* remote transcript to backups/<agent>/<machine>/<subdir>/… (see mirrorPath in
|
|
110
|
+
* sync/agents.ts); every other transcript is a live-home file on this box. So:
|
|
111
|
+
* when the path sits under the agent's backups root, the first segment below it
|
|
112
|
+
* is the origin machine id; otherwise it's the local machine.
|
|
113
|
+
*/
|
|
114
|
+
export function machineForSessionFile(filePath, agent) {
|
|
115
|
+
const base = path.join(getHistoryDir(), 'backups', agent) + path.sep;
|
|
116
|
+
if (filePath.startsWith(base)) {
|
|
117
|
+
const seg = filePath.slice(base.length).split(path.sep)[0];
|
|
118
|
+
if (seg)
|
|
119
|
+
return seg;
|
|
120
|
+
}
|
|
121
|
+
return (_localMachineId ??= machineId());
|
|
122
|
+
}
|
|
81
123
|
/**
|
|
82
124
|
* Count sessions in scope without running an incremental scan. Assumes the DB
|
|
83
125
|
* is already fresh (typically true because `discoverSessions` ran first this
|
|
@@ -464,10 +506,37 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
|
|
|
464
506
|
// Codex account info
|
|
465
507
|
// ---------------------------------------------------------------------------
|
|
466
508
|
let cachedCodexAccount;
|
|
467
|
-
/**
|
|
509
|
+
/** Number of times the auth.json JWT was actually base64-decoded. Test seam for the lazy-decode contract. */
|
|
510
|
+
let codexAccountResolveCount = 0;
|
|
511
|
+
/**
|
|
512
|
+
* Base64url-decode a JWT and return its `email` claim, if present. Split out so
|
|
513
|
+
* the decode is a single, testable step — and so it only runs when someone
|
|
514
|
+
* actually reads the Codex account (see the lazy resolution below).
|
|
515
|
+
*/
|
|
516
|
+
export function decodeJwtEmail(idToken) {
|
|
517
|
+
const parts = idToken.split('.');
|
|
518
|
+
if (parts.length < 2)
|
|
519
|
+
return undefined;
|
|
520
|
+
try {
|
|
521
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
|
|
522
|
+
return typeof payload.email === 'string' ? payload.email : undefined;
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
return undefined; // malformed JWT
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Extract the Codex account email from the JWT id_token in auth.json.
|
|
530
|
+
*
|
|
531
|
+
* Memoized and resolved LAZILY: the credential-harvesting-shaped JWT decode
|
|
532
|
+
* (base64-decoding ~/.codex/auth.json) only runs when the account is actually
|
|
533
|
+
* needed to build a session's metadata — never eagerly during the bulk scan.
|
|
534
|
+
* A scan with no changed Codex files never touches the auth file.
|
|
535
|
+
*/
|
|
468
536
|
function getCodexAccount() {
|
|
469
537
|
if (cachedCodexAccount !== undefined)
|
|
470
538
|
return cachedCodexAccount || undefined;
|
|
539
|
+
codexAccountResolveCount++;
|
|
471
540
|
const candidates = [path.join(HOME, '.codex', 'auth.json')];
|
|
472
541
|
for (const root of VERSIONS_ROOTS) {
|
|
473
542
|
const versionsBase = path.join(root, 'versions', 'codex');
|
|
@@ -487,27 +556,35 @@ function getCodexAccount() {
|
|
|
487
556
|
const data = JSON.parse(fs.readFileSync(candidate, 'utf-8'));
|
|
488
557
|
const idToken = data.tokens?.id_token;
|
|
489
558
|
if (idToken) {
|
|
490
|
-
const
|
|
491
|
-
if (
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
cachedCodexAccount = payload.email;
|
|
495
|
-
return payload.email;
|
|
496
|
-
}
|
|
559
|
+
const email = decodeJwtEmail(idToken);
|
|
560
|
+
if (email) {
|
|
561
|
+
cachedCodexAccount = email;
|
|
562
|
+
return email;
|
|
497
563
|
}
|
|
498
564
|
}
|
|
499
565
|
}
|
|
500
|
-
catch { /* auth file
|
|
566
|
+
catch { /* auth file malformed */ }
|
|
501
567
|
}
|
|
502
568
|
cachedCodexAccount = '';
|
|
503
569
|
return undefined;
|
|
504
570
|
}
|
|
571
|
+
/** Test seam: how many times getCodexAccount has actually resolved (decoded) since the last reset. */
|
|
572
|
+
export function __codexAccountResolveCountForTest() {
|
|
573
|
+
return codexAccountResolveCount;
|
|
574
|
+
}
|
|
575
|
+
/** Test seam: clear the memoized account + resolve counter so laziness can be observed from a clean slate. */
|
|
576
|
+
export function __resetCodexAccountCacheForTest() {
|
|
577
|
+
cachedCodexAccount = undefined;
|
|
578
|
+
codexAccountResolveCount = 0;
|
|
579
|
+
}
|
|
505
580
|
// ---------------------------------------------------------------------------
|
|
506
581
|
// Codex
|
|
507
582
|
// ---------------------------------------------------------------------------
|
|
508
583
|
/** Incrementally re-scan changed Codex session files and upsert into the DB. */
|
|
509
584
|
async function scanCodexIncremental(onProgress) {
|
|
510
|
-
|
|
585
|
+
// Lazy: getCodexAccount (the auth.json JWT decode) is only resolved by
|
|
586
|
+
// readCodexMeta when a changed session actually needs it — never eagerly here,
|
|
587
|
+
// so a no-op scan (changed.length === 0) never touches the credential file.
|
|
511
588
|
const currentVersion = await getCurrentAgentVersion('codex');
|
|
512
589
|
const filePaths = [];
|
|
513
590
|
for (const sessionsDir of getAgentSessionDirs('codex', 'sessions')) {
|
|
@@ -532,7 +609,7 @@ async function scanCodexIncremental(onProgress) {
|
|
|
532
609
|
let parsed = 0;
|
|
533
610
|
for (const { filePath, scan } of changed) {
|
|
534
611
|
try {
|
|
535
|
-
const result = await readCodexMeta(filePath,
|
|
612
|
+
const result = await readCodexMeta(filePath, getCodexAccount, currentVersion);
|
|
536
613
|
if (result && !seen.has(result.meta.id)) {
|
|
537
614
|
seen.add(result.meta.id);
|
|
538
615
|
// Prefer the Codex-generated title over the first-prompt fallback.
|
|
@@ -596,8 +673,14 @@ function readCodexThreadNames() {
|
|
|
596
673
|
}
|
|
597
674
|
return titles;
|
|
598
675
|
}
|
|
599
|
-
/**
|
|
600
|
-
|
|
676
|
+
/**
|
|
677
|
+
* Stream-parse a single Codex JSONL file to extract session metadata.
|
|
678
|
+
*
|
|
679
|
+
* `resolveAccount` is a lazy thunk (not a resolved string): the JWT decode it
|
|
680
|
+
* performs is deferred until we know this file is a real session worth building
|
|
681
|
+
* metadata for, and only then — never during the file walk / stat phase.
|
|
682
|
+
*/
|
|
683
|
+
export async function readCodexMeta(filePath, resolveAccount, currentVersion) {
|
|
601
684
|
const scan = await scanCodexSession(filePath);
|
|
602
685
|
const sessionId = scan.sessionId || '';
|
|
603
686
|
if (!sessionId)
|
|
@@ -620,7 +703,7 @@ async function readCodexMeta(filePath, account, currentVersion) {
|
|
|
620
703
|
tokenCount: scan.tokenCount,
|
|
621
704
|
costUsd: scan.costUsd,
|
|
622
705
|
durationMs: scan.durationMs,
|
|
623
|
-
account,
|
|
706
|
+
account: resolveAccount?.(),
|
|
624
707
|
prUrl: scan.prUrl,
|
|
625
708
|
prNumber: scan.prNumber,
|
|
626
709
|
worktreeSlug: scan.worktreeSlug,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session → Terminal-Engine injection adapter.
|
|
3
|
+
*
|
|
4
|
+
* The Terminal Engine owns the injection primitive (`injectIntoTerminal`,
|
|
5
|
+
* src/lib/terminal/inject.ts). This thin adapter maps a session's provenance
|
|
6
|
+
* `ReplyRail` (the addressable terminal the feed already derives — provenance.ts:47)
|
|
7
|
+
* to the engine's `InjectTarget`, so a caller can go session → keystrokes in one
|
|
8
|
+
* hop. Kept on the session side because `ReplyRail` is a session concept; the
|
|
9
|
+
* engine stays agnostic of how a target was discovered.
|
|
10
|
+
*/
|
|
11
|
+
import type { ReplyRail } from './provenance.js';
|
|
12
|
+
import type { InjectTarget } from '../terminal/inject.js';
|
|
13
|
+
/**
|
|
14
|
+
* Map a session's `ReplyRail` to an engine `InjectTarget`. Today only tmux rails
|
|
15
|
+
* are externally addressable (provenance.ts:143-149); a null rail yields null and
|
|
16
|
+
* the caller must supply a target another way (a pty id, a macOS window).
|
|
17
|
+
*/
|
|
18
|
+
export declare function injectTargetFromReplyRail(rail: ReplyRail): InjectTarget | null;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session → Terminal-Engine injection adapter.
|
|
3
|
+
*
|
|
4
|
+
* The Terminal Engine owns the injection primitive (`injectIntoTerminal`,
|
|
5
|
+
* src/lib/terminal/inject.ts). This thin adapter maps a session's provenance
|
|
6
|
+
* `ReplyRail` (the addressable terminal the feed already derives — provenance.ts:47)
|
|
7
|
+
* to the engine's `InjectTarget`, so a caller can go session → keystrokes in one
|
|
8
|
+
* hop. Kept on the session side because `ReplyRail` is a session concept; the
|
|
9
|
+
* engine stays agnostic of how a target was discovered.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Map a session's `ReplyRail` to an engine `InjectTarget`. Today only tmux rails
|
|
13
|
+
* are externally addressable (provenance.ts:143-149); a null rail yields null and
|
|
14
|
+
* the caller must supply a target another way (a pty id, a macOS window).
|
|
15
|
+
*/
|
|
16
|
+
export function injectTargetFromReplyRail(rail) {
|
|
17
|
+
if (rail && rail.rail === 'tmux') {
|
|
18
|
+
return { backend: 'tmux', pane: rail.target, socket: rail.socket };
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import * as fs from 'fs';
|
|
9
9
|
import * as path from 'path';
|
|
10
10
|
import { execFileSync } from 'child_process';
|
|
11
|
+
import Database from '../sqlite.js';
|
|
11
12
|
/**
|
|
12
13
|
* Largest session file we will load into memory. Above this we throw a clean
|
|
13
14
|
* error instead of OOMing or hitting V8's ERR_STRING_TOO_LONG. Aligns with
|
|
@@ -875,19 +876,28 @@ const ANTIGRAVITY_TOOL_MAP = {
|
|
|
875
876
|
* request step and a completion step that share the id).
|
|
876
877
|
*/
|
|
877
878
|
export function parseAntigravity(dbPath) {
|
|
879
|
+
// Read the raw BLOB payloads through the node/bun SQLite wrapper (not the
|
|
880
|
+
// `sqlite3` CLI) so this works on every OS — the CLI is absent on Windows.
|
|
878
881
|
let rows;
|
|
882
|
+
let db;
|
|
879
883
|
try {
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
stdio: ['ignore', 'pipe', 'ignore'],
|
|
885
|
-
});
|
|
884
|
+
db = new Database(dbPath);
|
|
885
|
+
rows = db
|
|
886
|
+
.prepare('SELECT idx, step_type, step_payload FROM steps ORDER BY idx;')
|
|
887
|
+
.all();
|
|
886
888
|
}
|
|
887
889
|
catch {
|
|
888
|
-
/* DB not accessible,
|
|
890
|
+
/* DB not accessible, sqlite module unavailable, or query failed */
|
|
889
891
|
return [];
|
|
890
892
|
}
|
|
893
|
+
finally {
|
|
894
|
+
try {
|
|
895
|
+
db?.close();
|
|
896
|
+
}
|
|
897
|
+
catch {
|
|
898
|
+
/* best-effort close */
|
|
899
|
+
}
|
|
900
|
+
}
|
|
891
901
|
// Single timestamp for the whole session: the steps table carries no per-step
|
|
892
902
|
// time column, so fall back to the DB file's mtime for a stable, sortable value.
|
|
893
903
|
let timestamp = new Date().toISOString();
|
|
@@ -899,20 +909,13 @@ export function parseAntigravity(dbPath) {
|
|
|
899
909
|
}
|
|
900
910
|
const events = [];
|
|
901
911
|
const seenCallIds = new Set();
|
|
902
|
-
for (const
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
if (
|
|
907
|
-
continue;
|
|
908
|
-
const tab2 = line.indexOf('\t', tab1 + 1);
|
|
909
|
-
if (tab2 === -1)
|
|
910
|
-
continue;
|
|
911
|
-
const hex = line.slice(tab2 + 1).trim();
|
|
912
|
-
// sqlite3 quote() renders a BLOB as X'...'; anything else (NULL) is skipped.
|
|
913
|
-
if (!hex.startsWith("X'") || !hex.endsWith("'"))
|
|
912
|
+
for (const row of rows) {
|
|
913
|
+
const payload = row.step_payload;
|
|
914
|
+
// Both node:sqlite and bun:sqlite return a BLOB as a Uint8Array (Buffer is
|
|
915
|
+
// a subclass). NULL / non-blob payloads are skipped.
|
|
916
|
+
if (!(payload instanceof Uint8Array))
|
|
914
917
|
continue;
|
|
915
|
-
const bytes =
|
|
918
|
+
const bytes = payload;
|
|
916
919
|
let fields;
|
|
917
920
|
try {
|
|
918
921
|
fields = decodeProtoMessage(bytes);
|
|
@@ -13,6 +13,7 @@ export interface PidSessionEntry {
|
|
|
13
13
|
tmuxPane?: string;
|
|
14
14
|
startedAtMs: number;
|
|
15
15
|
}
|
|
16
|
+
export declare function extractSessionIdArg(args: string[]): string | undefined;
|
|
16
17
|
/** Record a launched agent process. Never throws — the registry is an optimization. */
|
|
17
18
|
export declare function writePidSessionEntry(entry: PidSessionEntry): void;
|
|
18
19
|
/** Look up a live pid's recorded session. Returns undefined if absent/corrupt. */
|
|
@@ -19,6 +19,30 @@
|
|
|
19
19
|
import fs from 'fs';
|
|
20
20
|
import path from 'path';
|
|
21
21
|
import { getTerminalsDir } from '../state.js';
|
|
22
|
+
/**
|
|
23
|
+
* Pull an explicit `--session-id <uuid>` (or `--session-id=<uuid>`) out of a
|
|
24
|
+
* raw agent arg vector. The transparent shim forwards args untouched, but when
|
|
25
|
+
* a launcher (Claude Code background jobs, IDE harnesses) already names the
|
|
26
|
+
* session, recording it gives the same exact pid -> session mapping `ag run`
|
|
27
|
+
* gets from generating the id itself.
|
|
28
|
+
*/
|
|
29
|
+
const SESSION_ID_VALUE_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
30
|
+
export function extractSessionIdArg(args) {
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
const a = args[i];
|
|
33
|
+
if (a === '--session-id') {
|
|
34
|
+
const v = args[i + 1];
|
|
35
|
+
if (v && SESSION_ID_VALUE_RE.test(v))
|
|
36
|
+
return v;
|
|
37
|
+
}
|
|
38
|
+
else if (a.startsWith('--session-id=')) {
|
|
39
|
+
const v = a.slice('--session-id='.length);
|
|
40
|
+
if (SESSION_ID_VALUE_RE.test(v))
|
|
41
|
+
return v;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
22
46
|
function pidRegistryDir() {
|
|
23
47
|
return path.join(getTerminalsDir(), 'by-pid');
|
|
24
48
|
}
|
|
@@ -13,11 +13,16 @@ export interface MuxLocation {
|
|
|
13
13
|
/** screen session name from $STY, e.g. '12345.pts-0.host'. */
|
|
14
14
|
session?: string;
|
|
15
15
|
}
|
|
16
|
-
/** How the feed can type back into a session, derived from rails that exist today. */
|
|
16
|
+
/** How the feed can type back into a session, derived from env rails that exist today. */
|
|
17
17
|
export type ReplyRail = {
|
|
18
18
|
rail: 'tmux';
|
|
19
19
|
target: string;
|
|
20
20
|
socket?: string;
|
|
21
|
+
}
|
|
22
|
+
/** iTerm2 split addressed by its session UUID (the part of $ITERM_SESSION_ID after ':'). */
|
|
23
|
+
| {
|
|
24
|
+
rail: 'iterm';
|
|
25
|
+
session: string;
|
|
21
26
|
} | null;
|
|
22
27
|
export interface SessionProvenance {
|
|
23
28
|
/** Machine the process runs on — os.hostname(). Drives HOSTS grouping. */
|
|
@@ -34,7 +39,14 @@ export interface SessionProvenance {
|
|
|
34
39
|
reply: ReplyRail;
|
|
35
40
|
}
|
|
36
41
|
/** Env vars that carry provenance. Kept small so the macOS `ps` scan stays cheap. */
|
|
37
|
-
export declare const PROVENANCE_ENV_KEYS: readonly ["SSH_CONNECTION", "SSH_TTY", "TMUX", "TMUX_PANE", "TERM_PROGRAM", "STY"];
|
|
42
|
+
export declare const PROVENANCE_ENV_KEYS: readonly ["SSH_CONNECTION", "SSH_TTY", "TMUX", "TMUX_PANE", "TERM_PROGRAM", "STY", "ITERM_SESSION_ID"];
|
|
43
|
+
/**
|
|
44
|
+
* Extract the iTerm2 session UUID from `$ITERM_SESSION_ID`, whose value is
|
|
45
|
+
* `w<window>t<tab>p<pane>:<UUID>` — the `:`-suffix is exactly what iTerm2's
|
|
46
|
+
* `id of session` returns, so it's the address for `tell session id …`. Returns
|
|
47
|
+
* undefined for an empty/absent value; tolerates a bare value with no `:`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function parseItermSession(value?: string): string | undefined;
|
|
38
50
|
/** Parse the NUL-separated body of /proc/<pid>/environ into a plain object. */
|
|
39
51
|
export declare function parseProcEnviron(buf: string): Record<string, string>;
|
|
40
52
|
/**
|
|
@@ -15,9 +15,22 @@
|
|
|
15
15
|
* (gone, or owned by another uid) yields `undefined`, never a guess.
|
|
16
16
|
*
|
|
17
17
|
* `reply` is a read-only hint, not a send channel: it reports whether a rail
|
|
18
|
-
* that can type back into this session exists today
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* that can type back into this session exists today and, if so, the exact
|
|
19
|
+
* address to type into. Two env-derived rails, in precedence order:
|
|
20
|
+
*
|
|
21
|
+
* - tmux — `$TMUX_PANE` (+ `$TMUX` socket): `tmux send-keys -t <pane>`. Works
|
|
22
|
+
* inside ANY host app (a tmux pane can live under iTerm/Ghostty/VS
|
|
23
|
+
* Code), so it wins whenever present.
|
|
24
|
+
* - iterm — `$ITERM_SESSION_ID` (`w<n>t<n>p<n>:<UUID>`): the exact iTerm2
|
|
25
|
+
* split, addressable by its session UUID via `tell session id …`
|
|
26
|
+
* (focus-safe, no `activate`). Used when there's no tmux above it.
|
|
27
|
+
*
|
|
28
|
+
* Both are inherited env vars, so the split the agent lives in is recovered with
|
|
29
|
+
* zero cooperation from the agent. Inherited/ignored stdin with neither rail =>
|
|
30
|
+
* null (not env-addressable — resolution may still find an IDE rail off disk,
|
|
31
|
+
* see src/lib/terminal/resolve.ts). The feed uses `reply` to decide whether to
|
|
32
|
+
* show a Send box; the resolver turns it into a concrete inject target (Gap 2,
|
|
33
|
+
* src/lib/terminal/inject.ts).
|
|
21
34
|
*/
|
|
22
35
|
import * as os from 'os';
|
|
23
36
|
import { execFile } from 'child_process';
|
|
@@ -32,7 +45,20 @@ export const PROVENANCE_ENV_KEYS = [
|
|
|
32
45
|
'TMUX_PANE',
|
|
33
46
|
'TERM_PROGRAM',
|
|
34
47
|
'STY',
|
|
48
|
+
'ITERM_SESSION_ID',
|
|
35
49
|
];
|
|
50
|
+
/**
|
|
51
|
+
* Extract the iTerm2 session UUID from `$ITERM_SESSION_ID`, whose value is
|
|
52
|
+
* `w<window>t<tab>p<pane>:<UUID>` — the `:`-suffix is exactly what iTerm2's
|
|
53
|
+
* `id of session` returns, so it's the address for `tell session id …`. Returns
|
|
54
|
+
* undefined for an empty/absent value; tolerates a bare value with no `:`.
|
|
55
|
+
*/
|
|
56
|
+
export function parseItermSession(value) {
|
|
57
|
+
if (!value)
|
|
58
|
+
return undefined;
|
|
59
|
+
const uuid = value.includes(':') ? value.slice(value.lastIndexOf(':') + 1) : value;
|
|
60
|
+
return uuid.trim() || undefined;
|
|
61
|
+
}
|
|
36
62
|
/** Parse the NUL-separated body of /proc/<pid>/environ into a plain object. */
|
|
37
63
|
export function parseProcEnviron(buf) {
|
|
38
64
|
const env = {};
|
|
@@ -99,13 +125,18 @@ export function deriveProvenance(env, hostname) {
|
|
|
99
125
|
else if (env.STY) {
|
|
100
126
|
mux = { kind: 'screen', session: env.STY };
|
|
101
127
|
}
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
128
|
+
// Env-addressable rails, in precedence order. A tmux pane wins because it can
|
|
129
|
+
// live inside any host app (`tmux send-keys -t <pane>` reaches it regardless of
|
|
130
|
+
// whether iTerm/Ghostty/VS Code is above it). Absent tmux, an iTerm2 split is
|
|
131
|
+
// addressable by its session UUID. Everything else (inherited stdin from
|
|
132
|
+
// `agents run`, ignored stdin from teams, a plain VS Code integrated terminal)
|
|
133
|
+
// is not env-addressable — the resolver may still find an IDE rail off disk.
|
|
134
|
+
const itermSession = parseItermSession(env.ITERM_SESSION_ID);
|
|
106
135
|
const reply = mux?.kind === 'tmux' && mux.pane
|
|
107
136
|
? { rail: 'tmux', target: mux.pane, socket: mux.socket }
|
|
108
|
-
:
|
|
137
|
+
: itermSession
|
|
138
|
+
? { rail: 'iterm', session: itermSession }
|
|
139
|
+
: null;
|
|
109
140
|
return {
|
|
110
141
|
host: hostname,
|
|
111
142
|
transport: ssh ? 'ssh' : 'local',
|
|
@@ -19,6 +19,8 @@ import chalk from 'chalk';
|
|
|
19
19
|
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
20
20
|
import { sshTargetFor } from '../devices/connect.js';
|
|
21
21
|
import { loadDevices } from '../devices/registry.js';
|
|
22
|
+
import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
|
|
23
|
+
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
22
24
|
import { machineId, normalizeHost } from './sync/config.js';
|
|
23
25
|
/** Per-host SSH budget. Slightly above SSH_OPTS' ConnectTimeout=10 so a
|
|
24
26
|
* reachable-but-slow remote still answers before we give up. */
|
|
@@ -29,8 +31,16 @@ const REMOTE_TIMEOUT_MS = 12_000;
|
|
|
29
31
|
* on an unknown option. A remote new enough to fan out reads it and stays local.
|
|
30
32
|
*/
|
|
31
33
|
export const NO_FANOUT_ENV = 'AGENTS_SESSIONS_LOCAL';
|
|
32
|
-
/** The command run on each peer: answer for itself, as JSON, without recursing.
|
|
33
|
-
|
|
34
|
+
/** The command run on each peer: answer for itself, as JSON, without recursing.
|
|
35
|
+
* A Windows peer gets a PowerShell invocation (ssh lands in cmd.exe/PowerShell
|
|
36
|
+
* there, where `bash -lc` is not a command); every other OS keeps `bash -lc`. */
|
|
37
|
+
function remoteActiveCommand(os) {
|
|
38
|
+
if (remoteShellFor(os) === 'powershell') {
|
|
39
|
+
return buildWindowsAgentsCommand({
|
|
40
|
+
args: ['sessions', '--active', '--json'],
|
|
41
|
+
env: { [NO_FANOUT_ENV]: '1' },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
34
44
|
const inner = `${NO_FANOUT_ENV}=1 agents sessions --active --json`;
|
|
35
45
|
return `bash -lc ${shellQuote(inner)}`;
|
|
36
46
|
}
|
|
@@ -80,8 +90,8 @@ function sshCapture(target, remoteCmd, timeoutMs) {
|
|
|
80
90
|
child.on('close', (code) => done(code));
|
|
81
91
|
});
|
|
82
92
|
}
|
|
83
|
-
async function fetchByTarget(target, machine, display) {
|
|
84
|
-
const { code, stdout } = await sshCapture(target, remoteActiveCommand(), REMOTE_TIMEOUT_MS);
|
|
93
|
+
async function fetchByTarget(target, machine, display, os) {
|
|
94
|
+
const { code, stdout } = await sshCapture(target, remoteActiveCommand(os), REMOTE_TIMEOUT_MS);
|
|
85
95
|
if (code !== 0) {
|
|
86
96
|
process.stderr.write(chalk.gray(` ${display}: unreachable or no agents CLI — skipped\n`));
|
|
87
97
|
return [];
|
|
@@ -107,7 +117,9 @@ export async function gatherRemoteActive(hosts) {
|
|
|
107
117
|
continue;
|
|
108
118
|
}
|
|
109
119
|
const bareHost = h.split('@').pop() || h;
|
|
110
|
-
|
|
120
|
+
// Resolve the OS by the name the user passed (a bare alias like `win-mini`
|
|
121
|
+
// matches a device-registry entry; a raw `user@host` falls back to POSIX).
|
|
122
|
+
targets.push({ target: h, machine: normalizeHost(bareHost), name: h, os: resolveRemoteOsSync(h) });
|
|
111
123
|
}
|
|
112
124
|
}
|
|
113
125
|
else {
|
|
@@ -129,13 +141,13 @@ export async function gatherRemoteActive(hosts) {
|
|
|
129
141
|
if (d.platform !== 'windows' && d.platform !== 'linux' && d.platform !== 'macos')
|
|
130
142
|
continue;
|
|
131
143
|
try {
|
|
132
|
-
targets.push({ target: sshTargetFor(d), machine: normalizeHost(d.name), name: d.name });
|
|
144
|
+
targets.push({ target: sshTargetFor(d), machine: normalizeHost(d.name), name: d.name, os: d.platform });
|
|
133
145
|
}
|
|
134
146
|
catch {
|
|
135
147
|
// No address on the profile — nothing to dial; skip silently.
|
|
136
148
|
}
|
|
137
149
|
}
|
|
138
150
|
}
|
|
139
|
-
const results = await Promise.all(targets.map((t) => fetchByTarget(t.target, t.machine, t.name)));
|
|
151
|
+
const results = await Promise.all(targets.map((t) => fetchByTarget(t.target, t.machine, t.name, t.os)));
|
|
140
152
|
return { sessions: results.flat(), deviceCount: targets.length };
|
|
141
153
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { SessionMeta } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The command run on each peer: answer for itself, as JSON, without recursing.
|
|
4
|
+
* `forwardedArgs` carry the caller's own query/filters (already including the
|
|
5
|
+
* leading `sessions` and a `--json`) so each peer returns a comparable slice.
|
|
6
|
+
* A Windows peer gets a PowerShell invocation (ssh lands in cmd.exe/PowerShell
|
|
7
|
+
* there, where `bash -lc` is not a command); every other OS keeps `bash -lc`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function remoteListCommand(forwardedArgs: string[], os?: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Parse a peer's `sessions --json` stdout into `SessionMeta[]`, tagging each
|
|
12
|
+
* with `machine`. Defensive against version skew / partial output: non-JSON or
|
|
13
|
+
* a non-array yields `[]`, and non-object entries are dropped rather than
|
|
14
|
+
* throwing. The `machine` we dialed always wins over any value the peer set on
|
|
15
|
+
* its own rows, so grouping keys off the computer we asked. Exported for unit
|
|
16
|
+
* testing without a live tailnet.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseRemoteList(stdout: string, machine: string): SessionMeta[];
|
|
19
|
+
export interface RemoteListResult {
|
|
20
|
+
sessions: SessionMeta[];
|
|
21
|
+
/** How many peer machines we attempted to reach (drives the empty-fleet tip). */
|
|
22
|
+
deviceCount: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Gather listing sessions from other machines. With an explicit `hosts` list
|
|
26
|
+
* (from `--host`), fan out to exactly those. Otherwise sweep the registered,
|
|
27
|
+
* online devices from `ag devices`, excluding this machine and any without an
|
|
28
|
+
* address. `forwardedArgs` are the caller's own sessions args (query + filters,
|
|
29
|
+
* already `--json`) so every peer returns the same slice this machine asked for.
|
|
30
|
+
*/
|
|
31
|
+
export declare function gatherRemoteList(forwardedArgs: string[], hosts?: string[]): Promise<RemoteListResult>;
|
|
32
|
+
/** Resolve a peer's SSH target (and OS) from the device registry by its
|
|
33
|
+
* normalized machine id — the same id the fan-out tags rows with. Returns
|
|
34
|
+
* undefined when no registered device with an address matches. */
|
|
35
|
+
export declare function resolvePeerTarget(machine: string): Promise<{
|
|
36
|
+
target: string;
|
|
37
|
+
os?: string;
|
|
38
|
+
} | undefined>;
|
|
39
|
+
/**
|
|
40
|
+
* Run `agents <args>` ON a peer over SSH, attached to this terminal (inherited
|
|
41
|
+
* stdio). `args` is the full arg vector after the binary — callers pass e.g.
|
|
42
|
+
* `['sessions', id, '--markdown']` or `['sessions', 'resume', id]`. Used when a
|
|
43
|
+
* picked session lives on another machine: its transcript and agent binary are
|
|
44
|
+
* there, so both reading (no TTY) and resuming (TTY) must execute on the peer —
|
|
45
|
+
* not via a local `--host` hop, which would discover locally and dead-end for a
|
|
46
|
+
* session that exists only on the peer. Resolves 'no-target' when the machine
|
|
47
|
+
* isn't a dialable registered device; the caller surfaces a clear message.
|
|
48
|
+
*/
|
|
49
|
+
export declare function runOnPeer(args: string[], machine: string, opts?: {
|
|
50
|
+
tty?: boolean;
|
|
51
|
+
}): Promise<'ok' | 'no-target'>;
|