@phnx-labs/agents-cli 1.20.32 → 1.20.34
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 +7 -0
- package/README.md +28 -2
- package/dist/commands/commands.js +3 -3
- package/dist/commands/computer-actions.js +1 -0
- package/dist/commands/computer.d.ts +23 -0
- package/dist/commands/computer.js +45 -3
- package/dist/commands/cost.js +2 -2
- package/dist/commands/doctor.d.ts +10 -0
- package/dist/commands/doctor.js +51 -2
- package/dist/commands/exec.js +56 -1
- package/dist/commands/hooks.js +3 -3
- package/dist/commands/import.js +1 -1
- package/dist/commands/inspect.js +13 -17
- package/dist/commands/mcp.js +3 -3
- package/dist/commands/permissions.js +3 -3
- package/dist/commands/rules.js +3 -3
- package/dist/commands/secrets-migrate.js +23 -11
- package/dist/commands/secrets.d.ts +20 -0
- package/dist/commands/secrets.js +53 -1
- package/dist/commands/sessions.js +18 -1
- package/dist/commands/skills.js +3 -3
- package/dist/commands/status.d.ts +12 -0
- package/dist/commands/status.js +81 -0
- package/dist/commands/sync.js +2 -2
- package/dist/commands/teams.js +77 -18
- package/dist/commands/usage.js +2 -2
- package/dist/commands/utils.d.ts +8 -0
- package/dist/commands/utils.js +20 -0
- package/dist/commands/versions.js +4 -3
- package/dist/commands/view.d.ts +39 -0
- package/dist/commands/view.js +217 -84
- package/dist/commands/workflows.js +3 -3
- package/dist/index.js +16 -2
- package/dist/lib/acp/harnesses.d.ts +1 -1
- package/dist/lib/acp/harnesses.js +2 -2
- package/dist/lib/agent-spec/index.d.ts +18 -0
- package/dist/lib/agent-spec/index.js +35 -0
- package/dist/lib/agent-spec/primitives.d.ts +28 -0
- package/dist/lib/agent-spec/primitives.js +57 -0
- package/dist/lib/agent-spec/provider.d.ts +2 -0
- package/dist/lib/agent-spec/provider.js +9 -0
- package/dist/lib/agent-spec/resolve.d.ts +33 -0
- package/dist/lib/agent-spec/resolve.js +174 -0
- package/dist/lib/agent-spec/types.d.ts +57 -0
- package/dist/lib/agent-spec/types.js +18 -0
- package/dist/lib/agents.d.ts +12 -0
- package/dist/lib/agents.js +115 -32
- package/dist/lib/browser/chrome.js +20 -0
- package/dist/lib/browser/drivers/ssh.d.ts +19 -0
- package/dist/lib/browser/drivers/ssh.js +18 -3
- package/dist/lib/crabbox/cli.d.ts +98 -0
- package/dist/lib/crabbox/cli.js +218 -0
- package/dist/lib/crabbox/lease.d.ts +41 -0
- package/dist/lib/crabbox/lease.js +73 -0
- package/dist/lib/crabbox/runtimes.d.ts +57 -0
- package/dist/lib/crabbox/runtimes.js +109 -0
- package/dist/lib/doctor-diff.js +29 -2
- package/dist/lib/drift-sync.d.ts +43 -0
- package/dist/lib/drift-sync.js +179 -0
- package/dist/lib/exec.d.ts +15 -0
- package/dist/lib/exec.js +21 -11
- package/dist/lib/hosts/dispatch.d.ts +27 -10
- package/dist/lib/hosts/dispatch.js +55 -19
- package/dist/lib/hosts/option.d.ts +14 -0
- package/dist/lib/hosts/option.js +19 -0
- package/dist/lib/hosts/passthrough.d.ts +30 -0
- package/dist/lib/hosts/passthrough.js +141 -0
- package/dist/lib/hosts/remote-cmd.d.ts +36 -0
- package/dist/lib/hosts/remote-cmd.js +56 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/platform/winpath.d.ts +31 -2
- package/dist/lib/platform/winpath.js +133 -24
- package/dist/lib/pwsh.d.ts +11 -0
- package/dist/lib/pwsh.js +13 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +42 -1
- package/dist/lib/secrets/agent.js +89 -11
- package/dist/lib/secrets/bundles.js +61 -21
- package/dist/lib/secrets/filestore.js +31 -1
- package/dist/lib/secrets/index.d.ts +44 -1
- package/dist/lib/secrets/index.js +108 -10
- package/dist/lib/secrets/linux.d.ts +14 -0
- package/dist/lib/secrets/linux.js +21 -0
- package/dist/lib/secrets/windows.d.ts +74 -0
- package/dist/lib/secrets/windows.js +440 -0
- package/dist/lib/session/active.d.ts +8 -0
- package/dist/lib/session/active.js +18 -1
- package/dist/lib/session/provenance.d.ts +56 -0
- package/dist/lib/session/provenance.js +157 -0
- package/dist/lib/shims.d.ts +20 -0
- package/dist/lib/shims.js +53 -20
- package/dist/lib/ssh-exec.d.ts +22 -0
- package/dist/lib/ssh-exec.js +59 -2
- package/dist/lib/ssh-tunnel.d.ts +0 -5
- package/dist/lib/ssh-tunnel.js +65 -8
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/sync-status.d.ts +102 -0
- package/dist/lib/sync-status.js +135 -0
- package/dist/lib/teams/agents.d.ts +24 -0
- package/dist/lib/teams/agents.js +30 -1
- package/dist/lib/types.d.ts +20 -1
- package/dist/lib/usage.d.ts +30 -0
- package/dist/lib/usage.js +159 -2
- package/dist/lib/versions.d.ts +2 -4
- package/dist/lib/versions.js +7 -20
- package/package.json +2 -1
- package/dist/lib/agent-spec.d.ts +0 -36
- package/dist/lib/agent-spec.js +0 -157
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session provenance — where an active agent process actually lives.
|
|
3
|
+
*
|
|
4
|
+
* `detectHost()` in active.ts walks the ppid chain to name the *terminal app*
|
|
5
|
+
* (iterm / code / tmux). That answers "what UI is above it" but not the three
|
|
6
|
+
* things the Agent Feed needs to group and route:
|
|
7
|
+
*
|
|
8
|
+
* 1. Which machine — os.hostname(), for the HOSTS sidebar.
|
|
9
|
+
* 2. Local vs SSH — is SSH_CONNECTION in the process env?
|
|
10
|
+
* 3. Exact tmux pane — TMUX_PANE ('%3'), the send-keys target.
|
|
11
|
+
*
|
|
12
|
+
* All three are inherited env vars, so we read them straight off the running
|
|
13
|
+
* process (no cooperation from the agent needed): `/proc/<pid>/environ` on
|
|
14
|
+
* Linux, `ps eww` on macOS. The read is best-effort — a process we can't stat
|
|
15
|
+
* (gone, or owned by another uid) yields `undefined`, never a guess.
|
|
16
|
+
*
|
|
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 (tmux pane => addressable;
|
|
19
|
+
* inherited/ignored stdin => null). The feed uses it to decide whether to show
|
|
20
|
+
* a Send box. Actually delivering the keystrokes is Gap 2 (pty/tmux send-keys).
|
|
21
|
+
*/
|
|
22
|
+
import * as os from 'os';
|
|
23
|
+
import { execFile } from 'child_process';
|
|
24
|
+
import { promisify } from 'util';
|
|
25
|
+
import { readFile } from 'fs/promises';
|
|
26
|
+
const execFileAsync = promisify(execFile);
|
|
27
|
+
/** Env vars that carry provenance. Kept small so the macOS `ps` scan stays cheap. */
|
|
28
|
+
export const PROVENANCE_ENV_KEYS = [
|
|
29
|
+
'SSH_CONNECTION',
|
|
30
|
+
'SSH_TTY',
|
|
31
|
+
'TMUX',
|
|
32
|
+
'TMUX_PANE',
|
|
33
|
+
'TERM_PROGRAM',
|
|
34
|
+
'STY',
|
|
35
|
+
];
|
|
36
|
+
/** Parse the NUL-separated body of /proc/<pid>/environ into a plain object. */
|
|
37
|
+
export function parseProcEnviron(buf) {
|
|
38
|
+
const env = {};
|
|
39
|
+
for (const pair of buf.split('\0')) {
|
|
40
|
+
if (!pair)
|
|
41
|
+
continue;
|
|
42
|
+
const eq = pair.indexOf('=');
|
|
43
|
+
if (eq <= 0)
|
|
44
|
+
continue;
|
|
45
|
+
env[pair.slice(0, eq)] = pair.slice(eq + 1);
|
|
46
|
+
}
|
|
47
|
+
return env;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* How many whitespace-separated tokens each key's value spans. macOS
|
|
51
|
+
* `ps eww -o command=` space-joins the env after the command, so a value that
|
|
52
|
+
* itself contains spaces (SSH_CONNECTION is four fields) can't be recovered by
|
|
53
|
+
* boundary-guessing when the next token is an *unknown* var. Every provenance
|
|
54
|
+
* key except SSH_CONNECTION is a single token, so we read exactly its arity.
|
|
55
|
+
*/
|
|
56
|
+
const ENV_VALUE_TOKENS = { SSH_CONNECTION: 4 };
|
|
57
|
+
/**
|
|
58
|
+
* Pull known env vars out of a macOS `ps eww` command+env line. For each
|
|
59
|
+
* `KEY=` match we consume the declared number of tokens (default 1), so
|
|
60
|
+
* SSH_CONNECTION's internal spaces survive while a following unknown var
|
|
61
|
+
* (e.g. `PWD=…`) is not swallowed into the previous value.
|
|
62
|
+
*/
|
|
63
|
+
export function extractKnownEnv(text, keys) {
|
|
64
|
+
const alt = keys.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
|
65
|
+
const boundary = new RegExp(`(?:^|\\s)(${alt})=`, 'g');
|
|
66
|
+
const env = {};
|
|
67
|
+
let m;
|
|
68
|
+
while ((m = boundary.exec(text)) !== null) {
|
|
69
|
+
const key = m[1];
|
|
70
|
+
const rest = text.slice(m.index + m[0].length);
|
|
71
|
+
const tokens = rest.split(/\s+/);
|
|
72
|
+
const want = ENV_VALUE_TOKENS[key] ?? 1;
|
|
73
|
+
env[key] = tokens.slice(0, want).join(' ');
|
|
74
|
+
}
|
|
75
|
+
return env;
|
|
76
|
+
}
|
|
77
|
+
/** `<client_ip> <client_port> <server_ip> <server_port>` → structured origin. */
|
|
78
|
+
export function parseSshConnection(value) {
|
|
79
|
+
const parts = value.trim().split(/\s+/);
|
|
80
|
+
if (parts.length < 4)
|
|
81
|
+
return undefined;
|
|
82
|
+
const clientPort = parseInt(parts[1], 10);
|
|
83
|
+
const serverPort = parseInt(parts[3], 10);
|
|
84
|
+
if (!Number.isFinite(clientPort) || !Number.isFinite(serverPort))
|
|
85
|
+
return undefined;
|
|
86
|
+
return { clientIp: parts[0], clientPort, serverIp: parts[2], serverPort };
|
|
87
|
+
}
|
|
88
|
+
/** Build a SessionProvenance from a raw env map + the local hostname. Pure. */
|
|
89
|
+
export function deriveProvenance(env, hostname) {
|
|
90
|
+
const ssh = env.SSH_CONNECTION ? parseSshConnection(env.SSH_CONNECTION) : undefined;
|
|
91
|
+
let mux;
|
|
92
|
+
if (env.TMUX) {
|
|
93
|
+
mux = {
|
|
94
|
+
kind: 'tmux',
|
|
95
|
+
socket: env.TMUX.split(',')[0] || undefined,
|
|
96
|
+
pane: env.TMUX_PANE || undefined,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
else if (env.STY) {
|
|
100
|
+
mux = { kind: 'screen', session: env.STY };
|
|
101
|
+
}
|
|
102
|
+
// A tmux pane is the one rail that lets an external process type into an
|
|
103
|
+
// already-running interactive agent (`tmux send-keys -t <pane>`). Everything
|
|
104
|
+
// else (inherited stdin from `agents run`, ignored stdin from teams) is not
|
|
105
|
+
// externally addressable without relaunching under a pty/tmux rail.
|
|
106
|
+
const reply = mux?.kind === 'tmux' && mux.pane
|
|
107
|
+
? { rail: 'tmux', target: mux.pane, socket: mux.socket }
|
|
108
|
+
: null;
|
|
109
|
+
return {
|
|
110
|
+
host: hostname,
|
|
111
|
+
transport: ssh ? 'ssh' : 'local',
|
|
112
|
+
ssh,
|
|
113
|
+
term: env.TERM_PROGRAM || undefined,
|
|
114
|
+
mux,
|
|
115
|
+
reply,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/** Read a process's environment. Linux: /proc. macOS: `ps eww`. Best-effort. */
|
|
119
|
+
async function readProcEnv(pid) {
|
|
120
|
+
if (process.platform === 'linux') {
|
|
121
|
+
try {
|
|
122
|
+
const buf = await readFile(`/proc/${pid}/environ`, 'utf8');
|
|
123
|
+
return parseProcEnviron(buf);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (process.platform === 'darwin') {
|
|
130
|
+
try {
|
|
131
|
+
const { stdout } = await execFileAsync('ps', ['eww', '-p', String(pid), '-o', 'command='], {
|
|
132
|
+
encoding: 'utf8',
|
|
133
|
+
maxBuffer: 1024 * 1024,
|
|
134
|
+
});
|
|
135
|
+
if (!stdout.trim())
|
|
136
|
+
return undefined;
|
|
137
|
+
return extractKnownEnv(stdout, PROVENANCE_ENV_KEYS);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Resolve provenance for a live pid. Returns undefined when the process env
|
|
147
|
+
* can't be read (process gone, foreign uid, unsupported platform) — we never
|
|
148
|
+
* fabricate a 'local' answer we can't back with the env.
|
|
149
|
+
*/
|
|
150
|
+
export async function detectProvenance(pid) {
|
|
151
|
+
if (!pid || pid < 1)
|
|
152
|
+
return undefined;
|
|
153
|
+
const env = await readProcEnv(pid);
|
|
154
|
+
if (!env)
|
|
155
|
+
return undefined;
|
|
156
|
+
return deriveProvenance(env, os.hostname());
|
|
157
|
+
}
|
package/dist/lib/shims.d.ts
CHANGED
|
@@ -83,6 +83,18 @@ export declare const SHIM_SCHEMA_VERSION = 19;
|
|
|
83
83
|
* is written to ~/.agents/shims/{cliCommand} and made executable.
|
|
84
84
|
*/
|
|
85
85
|
export declare function generateShimScript(agent: AgentId): string;
|
|
86
|
+
/**
|
|
87
|
+
* Which shim files to materialize for a platform. Pure — testable on any host.
|
|
88
|
+
*
|
|
89
|
+
* POSIX writes the extensionless `#!/bin/bash` shim — the file PATH resolution
|
|
90
|
+
* execs. Windows writes only the `.cmd` companion: PATHEXT makes it the runnable
|
|
91
|
+
* form, and the bash file (mode 0o755 is a no-op there) is never executed — so
|
|
92
|
+
* emitting it is dead weight that only ever confuses `where agents`.
|
|
93
|
+
*/
|
|
94
|
+
export declare function shimTargetsFor(platform: NodeJS.Platform): {
|
|
95
|
+
bash: boolean;
|
|
96
|
+
cmd: boolean;
|
|
97
|
+
};
|
|
86
98
|
/**
|
|
87
99
|
* Create a shim for an agent.
|
|
88
100
|
*/
|
|
@@ -219,6 +231,14 @@ export declare function getConfigSymlinkVersion(agent: AgentId): string | null;
|
|
|
219
231
|
/**
|
|
220
232
|
* Check if shim exists for an agent.
|
|
221
233
|
*/
|
|
234
|
+
/**
|
|
235
|
+
* The on-disk shim FILENAME for a platform — derived from `shimTargetsFor` (the
|
|
236
|
+
* write-side source of truth) so the exists/remove/version checks can never
|
|
237
|
+
* drift from what `createShim` actually writes: `<cmd>.cmd` on Windows (the only
|
|
238
|
+
* file written there), the bare `<cmd>` script on POSIX. Pure — testable on any
|
|
239
|
+
* host.
|
|
240
|
+
*/
|
|
241
|
+
export declare function onDiskShimFile(cliCommand: string, platform: NodeJS.Platform): string;
|
|
222
242
|
export declare function shimExists(agent: AgentId): boolean;
|
|
223
243
|
/**
|
|
224
244
|
* Regenerate the shim if it's missing or outdated. Returns a status describing
|
package/dist/lib/shims.js
CHANGED
|
@@ -528,6 +528,19 @@ fi
|
|
|
528
528
|
exec "$BINARY"${launchArgs} "$@"
|
|
529
529
|
`;
|
|
530
530
|
}
|
|
531
|
+
/**
|
|
532
|
+
* Which shim files to materialize for a platform. Pure — testable on any host.
|
|
533
|
+
*
|
|
534
|
+
* POSIX writes the extensionless `#!/bin/bash` shim — the file PATH resolution
|
|
535
|
+
* execs. Windows writes only the `.cmd` companion: PATHEXT makes it the runnable
|
|
536
|
+
* form, and the bash file (mode 0o755 is a no-op there) is never executed — so
|
|
537
|
+
* emitting it is dead weight that only ever confuses `where agents`.
|
|
538
|
+
*/
|
|
539
|
+
export function shimTargetsFor(platform) {
|
|
540
|
+
if (platform === 'win32')
|
|
541
|
+
return { bash: false, cmd: true };
|
|
542
|
+
return { bash: true, cmd: false };
|
|
543
|
+
}
|
|
531
544
|
/**
|
|
532
545
|
* Create a shim for an agent.
|
|
533
546
|
*/
|
|
@@ -536,12 +549,15 @@ export function createShim(agent) {
|
|
|
536
549
|
const shimsDir = getShimsDir();
|
|
537
550
|
const agentConfig = AGENTS[agent];
|
|
538
551
|
const shimPath = path.join(shimsDir, agentConfig.cliCommand);
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
//
|
|
544
|
-
|
|
552
|
+
const targets = shimTargetsFor(process.platform);
|
|
553
|
+
if (targets.bash) {
|
|
554
|
+
fs.writeFileSync(shimPath, generateShimScript(agent), { mode: 0o755 });
|
|
555
|
+
}
|
|
556
|
+
// Windows can't execute the bash shim directly. Drop a `.cmd` companion — which
|
|
557
|
+
// delegates to the node-side transparent resolver (`agents __shim`) so version
|
|
558
|
+
// resolution stays single-sourced instead of reimplemented in batch — and skip
|
|
559
|
+
// the vestigial bash file entirely.
|
|
560
|
+
if (targets.cmd) {
|
|
545
561
|
writeWindowsCmdShim(shimPath + '.cmd', agentConfig.cliCommand);
|
|
546
562
|
}
|
|
547
563
|
return shimPath;
|
|
@@ -557,6 +573,7 @@ function writeWindowsCmdShim(cmdPath, spec) {
|
|
|
557
573
|
const indexJs = getAgentsBinForGeneratedShim();
|
|
558
574
|
const content = `@echo off\r\n` +
|
|
559
575
|
`rem Auto-generated by agents-cli - do not edit\r\n` +
|
|
576
|
+
`rem ${SHIM_VERSION_MARKER} ${SHIM_SCHEMA_VERSION}\r\n` +
|
|
560
577
|
`node "${indexJs}" __shim ${spec} %*\r\n`;
|
|
561
578
|
fs.writeFileSync(cmdPath, content);
|
|
562
579
|
}
|
|
@@ -567,17 +584,18 @@ export function removeShim(agent) {
|
|
|
567
584
|
const shimsDir = getShimsDir();
|
|
568
585
|
const agentConfig = AGENTS[agent];
|
|
569
586
|
const shimPath = path.join(shimsDir, agentConfig.cliCommand);
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
587
|
+
// Remove whichever companions exist: the extensionless script (POSIX, or a
|
|
588
|
+
// legacy Windows install that wrote it) AND the `.cmd` (Windows). Keying only
|
|
589
|
+
// off the extensionless path would orphan the `.cmd` on Windows, where
|
|
590
|
+
// createShim now writes only the `.cmd`.
|
|
591
|
+
let removed = false;
|
|
592
|
+
for (const p of [shimPath, shimPath + '.cmd']) {
|
|
593
|
+
if (fs.existsSync(p)) {
|
|
594
|
+
fs.unlinkSync(p);
|
|
595
|
+
removed = true;
|
|
577
596
|
}
|
|
578
|
-
return true;
|
|
579
597
|
}
|
|
580
|
-
return
|
|
598
|
+
return removed;
|
|
581
599
|
}
|
|
582
600
|
/**
|
|
583
601
|
* Current versioned-alias schema. Bump whenever `generateVersionedAliasScript`
|
|
@@ -1376,11 +1394,26 @@ async function copyDirContents(src, dest, strategy = 'keep-dest', context) {
|
|
|
1376
1394
|
/**
|
|
1377
1395
|
* Check if shim exists for an agent.
|
|
1378
1396
|
*/
|
|
1397
|
+
/**
|
|
1398
|
+
* The on-disk shim FILENAME for a platform — derived from `shimTargetsFor` (the
|
|
1399
|
+
* write-side source of truth) so the exists/remove/version checks can never
|
|
1400
|
+
* drift from what `createShim` actually writes: `<cmd>.cmd` on Windows (the only
|
|
1401
|
+
* file written there), the bare `<cmd>` script on POSIX. Pure — testable on any
|
|
1402
|
+
* host.
|
|
1403
|
+
*/
|
|
1404
|
+
export function onDiskShimFile(cliCommand, platform) {
|
|
1405
|
+
return shimTargetsFor(platform).cmd ? `${cliCommand}.cmd` : cliCommand;
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* The actual on-disk shim path for the current platform. This is what
|
|
1409
|
+
* exists/version checks must stat — `getShimPath` returns the logical
|
|
1410
|
+
* (extensionless) launch path, which is not always a real file on Windows.
|
|
1411
|
+
*/
|
|
1412
|
+
function onDiskShimPath(agent) {
|
|
1413
|
+
return path.join(getShimsDir(), onDiskShimFile(AGENTS[agent].cliCommand, process.platform));
|
|
1414
|
+
}
|
|
1379
1415
|
export function shimExists(agent) {
|
|
1380
|
-
|
|
1381
|
-
const agentConfig = AGENTS[agent];
|
|
1382
|
-
const shimPath = path.join(shimsDir, agentConfig.cliCommand);
|
|
1383
|
-
return fs.existsSync(shimPath);
|
|
1416
|
+
return fs.existsSync(onDiskShimPath(agent));
|
|
1384
1417
|
}
|
|
1385
1418
|
/**
|
|
1386
1419
|
* Read the schema version embedded in an existing on-disk shim. Returns
|
|
@@ -1390,7 +1423,7 @@ function readShimSchemaVersion(agent) {
|
|
|
1390
1423
|
if (!shimExists(agent))
|
|
1391
1424
|
return null;
|
|
1392
1425
|
try {
|
|
1393
|
-
const content = fs.readFileSync(
|
|
1426
|
+
const content = fs.readFileSync(onDiskShimPath(agent), 'utf8');
|
|
1394
1427
|
// Look at the first ~10 lines only — the marker lives in the header.
|
|
1395
1428
|
const header = content.split('\n', 10).join('\n');
|
|
1396
1429
|
const match = header.match(new RegExp(SHIM_VERSION_MARKER + '\\s*(\\d+)'));
|
package/dist/lib/ssh-exec.d.ts
CHANGED
|
@@ -19,6 +19,7 @@ export declare function assertValidSshTarget(host: string): void;
|
|
|
19
19
|
export declare function shellQuote(s: string): string;
|
|
20
20
|
/** Hardened ssh options applied to every connection. */
|
|
21
21
|
export declare const SSH_OPTS: readonly string[];
|
|
22
|
+
export declare function controlOpts(): string[];
|
|
22
23
|
export interface SshExecOptions {
|
|
23
24
|
/** Piped to the remote command's stdin (never interpolated into the shell). */
|
|
24
25
|
input?: string;
|
|
@@ -26,6 +27,8 @@ export interface SshExecOptions {
|
|
|
26
27
|
timeoutMs?: number;
|
|
27
28
|
/** Extra ssh flags inserted before the target (e.g. `-tt`). */
|
|
28
29
|
extraSshArgs?: string[];
|
|
30
|
+
/** Reuse a persistent control socket across calls (see `controlOpts`). */
|
|
31
|
+
multiplex?: boolean;
|
|
29
32
|
}
|
|
30
33
|
export interface SshExecResult {
|
|
31
34
|
/** Remote exit status, or null if ssh itself failed / timed out. */
|
|
@@ -43,3 +46,22 @@ export interface SshExecResult {
|
|
|
43
46
|
export declare function sshExec(target: string, remoteCmd: string, opts?: SshExecOptions): SshExecResult;
|
|
44
47
|
/** True if `target` is reachable over ssh (a passwordless `true` succeeds quickly). */
|
|
45
48
|
export declare function sshReachable(target: string, timeoutMs?: number): boolean;
|
|
49
|
+
export interface SshStreamOptions {
|
|
50
|
+
/**
|
|
51
|
+
* Allocate a remote pseudo-terminal (`ssh -tt`) so an interactive remote
|
|
52
|
+
* command (a picker, a prompt) renders live on the local terminal. Callers
|
|
53
|
+
* pass this when the *local* process is itself a TTY; piped/scripted callers
|
|
54
|
+
* leave it off and forward a non-interactive invocation instead.
|
|
55
|
+
*/
|
|
56
|
+
tty?: boolean;
|
|
57
|
+
/** Reuse a persistent control socket across calls (see `controlOpts`). */
|
|
58
|
+
multiplex?: boolean;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Foreground counterpart to `sshExec`: run `remoteCmd` on `target` with the
|
|
62
|
+
* local stdio wired straight through (`stdio: 'inherit'`), so output streams as
|
|
63
|
+
* it is produced and — with `tty` — keystrokes reach a remote picker. Blocks
|
|
64
|
+
* until the remote command exits and returns its exit code (255 is ssh's own
|
|
65
|
+
* connection-layer failure; any other non-zero is the remote command's code).
|
|
66
|
+
*/
|
|
67
|
+
export declare function sshStream(target: string, remoteCmd: string, opts?: SshStreamOptions): number;
|
package/dist/lib/ssh-exec.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* canonical definition; `commands/secrets.ts` re-exports it.
|
|
9
9
|
*/
|
|
10
10
|
import { spawnSync } from 'child_process';
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import { getCacheDir } from './state.js';
|
|
11
14
|
/**
|
|
12
15
|
* SSH target: a bare ssh-config host alias (e.g. `yosemite-s0`) or `user@host`.
|
|
13
16
|
* The strict allowlist blocks shell metacharacters so a target can't be
|
|
@@ -32,6 +35,42 @@ export const SSH_OPTS = [
|
|
|
32
35
|
'-o', 'BatchMode=yes',
|
|
33
36
|
'-o', 'ConnectTimeout=10',
|
|
34
37
|
];
|
|
38
|
+
/**
|
|
39
|
+
* OpenSSH connection-multiplexing options. The first connection to a host opens
|
|
40
|
+
* a control socket; subsequent connections (even from a *separate* `agents`
|
|
41
|
+
* invocation) reuse it, skipping the TCP+auth handshake — so repeated
|
|
42
|
+
* `--host <name>` calls to the same box feel local instead of paying ~100-300ms
|
|
43
|
+
* each. `ControlPersist=60s` keeps the master alive briefly after the last
|
|
44
|
+
* client exits. `%C` (a short fixed-length hash of local-host/remote/port/user)
|
|
45
|
+
* keeps the socket path well under macOS's 104-char `sun_path` limit.
|
|
46
|
+
*
|
|
47
|
+
* The socket directory is created lazily; if ssh can't open the control socket
|
|
48
|
+
* it falls back to a normal connection (multiplexing is an optimisation, never a
|
|
49
|
+
* requirement), so this can never make a reachable host unreachable.
|
|
50
|
+
*/
|
|
51
|
+
let controlDirEnsured = false;
|
|
52
|
+
export function controlOpts() {
|
|
53
|
+
// OpenSSH on Windows has no ControlMaster/ControlPath (unix-socket) support —
|
|
54
|
+
// passing those options makes ssh error out. Multiplexing is a pure latency
|
|
55
|
+
// optimisation, so on Windows we simply skip it and use a fresh connection.
|
|
56
|
+
if (process.platform === 'win32')
|
|
57
|
+
return [];
|
|
58
|
+
const dir = path.join(getCacheDir(), 'ssh');
|
|
59
|
+
if (!controlDirEnsured) {
|
|
60
|
+
try {
|
|
61
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* best-effort — ssh degrades to a fresh connection if the dir is missing */
|
|
65
|
+
}
|
|
66
|
+
controlDirEnsured = true;
|
|
67
|
+
}
|
|
68
|
+
return [
|
|
69
|
+
'-o', 'ControlMaster=auto',
|
|
70
|
+
'-o', `ControlPath=${path.join(dir, 'cm-%C')}`,
|
|
71
|
+
'-o', 'ControlPersist=60s',
|
|
72
|
+
];
|
|
73
|
+
}
|
|
35
74
|
/**
|
|
36
75
|
* Run `remoteCmd` on `target` over ssh and capture stdout/stderr/exit.
|
|
37
76
|
*
|
|
@@ -40,7 +79,8 @@ export const SSH_OPTS = [
|
|
|
40
79
|
*/
|
|
41
80
|
export function sshExec(target, remoteCmd, opts = {}) {
|
|
42
81
|
assertValidSshTarget(target);
|
|
43
|
-
const
|
|
82
|
+
const mux = opts.multiplex ? controlOpts() : [];
|
|
83
|
+
const args = [...SSH_OPTS, ...mux, ...(opts.extraSshArgs ?? []), target, remoteCmd];
|
|
44
84
|
const res = spawnSync('ssh', args, {
|
|
45
85
|
input: opts.input,
|
|
46
86
|
encoding: 'utf-8',
|
|
@@ -57,5 +97,22 @@ export function sshExec(target, remoteCmd, opts = {}) {
|
|
|
57
97
|
}
|
|
58
98
|
/** True if `target` is reachable over ssh (a passwordless `true` succeeds quickly). */
|
|
59
99
|
export function sshReachable(target, timeoutMs = 10000) {
|
|
60
|
-
return sshExec(target, 'true', { timeoutMs }).code === 0;
|
|
100
|
+
return sshExec(target, 'true', { timeoutMs, multiplex: true }).code === 0;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Foreground counterpart to `sshExec`: run `remoteCmd` on `target` with the
|
|
104
|
+
* local stdio wired straight through (`stdio: 'inherit'`), so output streams as
|
|
105
|
+
* it is produced and — with `tty` — keystrokes reach a remote picker. Blocks
|
|
106
|
+
* until the remote command exits and returns its exit code (255 is ssh's own
|
|
107
|
+
* connection-layer failure; any other non-zero is the remote command's code).
|
|
108
|
+
*/
|
|
109
|
+
export function sshStream(target, remoteCmd, opts = {}) {
|
|
110
|
+
assertValidSshTarget(target);
|
|
111
|
+
const mux = opts.multiplex ? controlOpts() : [];
|
|
112
|
+
const tty = opts.tty ? ['-tt'] : [];
|
|
113
|
+
const args = [...SSH_OPTS, ...mux, ...tty, target, remoteCmd];
|
|
114
|
+
const res = spawnSync('ssh', args, { stdio: 'inherit' });
|
|
115
|
+
if (typeof res.status === 'number')
|
|
116
|
+
return res.status;
|
|
117
|
+
return 255; // spawn error / signal — treat as a connection-layer failure
|
|
61
118
|
}
|
package/dist/lib/ssh-tunnel.d.ts
CHANGED
|
@@ -90,11 +90,6 @@ export declare function buildPushScript(): string;
|
|
|
90
90
|
export declare function buildRegisterTaskScript(port: number, taskName: string): string;
|
|
91
91
|
/** PowerShell that unregisters the task and stops any running daemon process. */
|
|
92
92
|
export declare function buildUnregisterTaskScript(taskName: string): string;
|
|
93
|
-
/**
|
|
94
|
-
* `setup --host`: push the exe, then register + start the LOGON task. Both hops
|
|
95
|
-
* go through `sshExec` (BatchMode key auth — the same hardening the browser
|
|
96
|
-
* driver and `agents ssh` use). Throws with the remote stderr on any failure.
|
|
97
|
-
*/
|
|
98
93
|
export declare function setupRemoteHelper(name: string): Promise<{
|
|
99
94
|
target: string;
|
|
100
95
|
taskName: string;
|
package/dist/lib/ssh-tunnel.js
CHANGED
|
@@ -22,7 +22,8 @@ import * as fs from 'fs';
|
|
|
22
22
|
import * as path from 'path';
|
|
23
23
|
import { fileURLToPath } from 'url';
|
|
24
24
|
import { randomBytes } from 'crypto';
|
|
25
|
-
import {
|
|
25
|
+
import { Transform } from 'stream';
|
|
26
|
+
import { sshExec, SSH_OPTS } from './ssh-exec.js';
|
|
26
27
|
import { encodePowerShell } from './browser/drivers/ssh.js';
|
|
27
28
|
import { getDevice } from './devices/registry.js';
|
|
28
29
|
import { sshTargetFor } from './devices/connect.js';
|
|
@@ -199,20 +200,76 @@ export function buildUnregisterTaskScript(taskName) {
|
|
|
199
200
|
* go through `sshExec` (BatchMode key auth — the same hardening the browser
|
|
200
201
|
* driver and `agents ssh` use). Throws with the remote stderr on any failure.
|
|
201
202
|
*/
|
|
203
|
+
/**
|
|
204
|
+
* Base64-encode a byte stream in 3-byte-aligned chunks so the concatenated
|
|
205
|
+
* output is valid (every chunk boundary lands on a base64 quantum).
|
|
206
|
+
*/
|
|
207
|
+
class Base64Encode extends Transform {
|
|
208
|
+
leftover = Buffer.alloc(0);
|
|
209
|
+
_transform(chunk, _enc, cb) {
|
|
210
|
+
const buf = this.leftover.length ? Buffer.concat([this.leftover, chunk]) : chunk;
|
|
211
|
+
const usable = buf.length - (buf.length % 3);
|
|
212
|
+
this.leftover = Buffer.from(buf.subarray(usable));
|
|
213
|
+
if (usable > 0)
|
|
214
|
+
this.push(buf.subarray(0, usable).toString('base64'));
|
|
215
|
+
cb();
|
|
216
|
+
}
|
|
217
|
+
_flush(cb) {
|
|
218
|
+
if (this.leftover.length)
|
|
219
|
+
this.push(this.leftover.toString('base64'));
|
|
220
|
+
cb();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Stream a local file to a remote command's stdin over ssh, base64-encoded on
|
|
225
|
+
* the fly. Async spawn + piping honors backpressure; the previous
|
|
226
|
+
* `spawnSync({ input })` blob deadlocked once the ssh socket buffer filled
|
|
227
|
+
* (~4MB) on large files (the 157MB Windows helper reproduced this reliably),
|
|
228
|
+
* and worse, reported a false success leaving a 0-byte remote file. Rejects on
|
|
229
|
+
* any pipe error so a broken transfer fails loudly instead.
|
|
230
|
+
*/
|
|
231
|
+
function streamFileOverSsh(target, remoteCmd, filePath, timeoutMs = 600_000) {
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
const child = spawn('ssh', [...SSH_OPTS, target, remoteCmd], {
|
|
234
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
235
|
+
});
|
|
236
|
+
let stderr = '';
|
|
237
|
+
let stdout = '';
|
|
238
|
+
child.stderr.on('data', (d) => (stderr += d.toString()));
|
|
239
|
+
child.stdout.on('data', (d) => (stdout += d.toString()));
|
|
240
|
+
const timer = setTimeout(() => {
|
|
241
|
+
child.kill('SIGKILL');
|
|
242
|
+
reject(new Error(`ssh push to ${target} timed out after ${timeoutMs}ms`));
|
|
243
|
+
}, timeoutMs);
|
|
244
|
+
const fail = (e) => {
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
child.kill('SIGKILL');
|
|
247
|
+
reject(e);
|
|
248
|
+
};
|
|
249
|
+
child.on('error', fail);
|
|
250
|
+
child.stdin.on('error', fail); // EPIPE if the remote decoder dies mid-stream
|
|
251
|
+
child.on('close', (code) => {
|
|
252
|
+
clearTimeout(timer);
|
|
253
|
+
resolve({ code, stderr: stderr || stdout });
|
|
254
|
+
});
|
|
255
|
+
const src = fs.createReadStream(filePath);
|
|
256
|
+
src.on('error', fail);
|
|
257
|
+
// disk -> aligned base64 -> ssh stdin; .pipe() applies backpressure
|
|
258
|
+
src.pipe(new Base64Encode()).pipe(child.stdin);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
202
261
|
export async function setupRemoteHelper(name) {
|
|
203
262
|
const { target } = await resolveRemoteDevice(name);
|
|
204
263
|
const exe = resolveWinHelperExe();
|
|
205
264
|
if (!exe) {
|
|
206
265
|
throw new Error(`Windows helper exe not built. Run: bash scripts/build-win.sh`);
|
|
207
266
|
}
|
|
208
|
-
// Push:
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
timeoutMs: 600_000, // ~156MB over the wire — allow up to 10 minutes
|
|
213
|
-
});
|
|
267
|
+
// Push: stream the exe from disk, base64-encoded on the fly, to the remote
|
|
268
|
+
// decoder. Streaming (vs a single spawnSync `input` blob) honors ssh socket
|
|
269
|
+
// backpressure — the blob path deadlocks once the socket buffer fills (~4MB).
|
|
270
|
+
const push = await streamFileOverSsh(target, encodePowerShell(buildPushScript()), exe);
|
|
214
271
|
if (push.code !== 0) {
|
|
215
|
-
throw new Error(`pushing helper exe to '${name}' failed (exit ${push.code ?? 'null'}): ${push.stderr.trim()
|
|
272
|
+
throw new Error(`pushing helper exe to '${name}' failed (exit ${push.code ?? 'null'}): ${push.stderr.trim()}`);
|
|
216
273
|
}
|
|
217
274
|
// Register + start the LOGON task.
|
|
218
275
|
const reg = sshExec(target, encodePowerShell(buildRegisterTaskScript(REMOTE_HELPER_PORT, REMOTE_TASK_NAME)), {
|
|
@@ -49,6 +49,7 @@ export declare const loadPrune: ModuleLoader;
|
|
|
49
49
|
export declare const loadTrash: ModuleLoader;
|
|
50
50
|
export declare const loadRestore: ModuleLoader;
|
|
51
51
|
export declare const loadDoctor: ModuleLoader;
|
|
52
|
+
export declare const loadStatus: ModuleLoader;
|
|
52
53
|
export declare const loadProfiles: ModuleLoader;
|
|
53
54
|
export declare const loadSecrets: ModuleLoader;
|
|
54
55
|
export declare const loadWallet: ModuleLoader;
|
|
@@ -27,6 +27,7 @@ export const loadPrune = async () => (await import('../../commands/prune.js')).r
|
|
|
27
27
|
export const loadTrash = async () => (await import('../../commands/trash.js')).registerTrashCommands;
|
|
28
28
|
export const loadRestore = async () => (await import('../../commands/trash.js')).registerRestoreCommand;
|
|
29
29
|
export const loadDoctor = async () => (await import('../../commands/doctor.js')).registerDoctorCommand;
|
|
30
|
+
export const loadStatus = async () => (await import('../../commands/status.js')).registerStatusCommand;
|
|
30
31
|
export const loadProfiles = async () => (await import('../../commands/profiles.js')).registerProfilesCommands;
|
|
31
32
|
export const loadSecrets = async () => (await import('../../commands/secrets.js')).registerSecretsCommands;
|
|
32
33
|
export const loadWallet = async () => (await import('../../commands/wallet.js')).registerWalletCommands;
|
|
@@ -110,6 +111,7 @@ export const COMMAND_LOADERS = {
|
|
|
110
111
|
trash: [loadTrash],
|
|
111
112
|
restore: [loadRestore],
|
|
112
113
|
doctor: [loadDoctor],
|
|
114
|
+
status: [loadStatus],
|
|
113
115
|
profiles: [loadProfiles],
|
|
114
116
|
secrets: [loadSecrets],
|
|
115
117
|
wallet: [loadWallet],
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified sync-status engine — the SINGLE source of truth for "is this resource
|
|
3
|
+
* synced to this agent version?" consumed by `agents doctor`, `agents view`, the
|
|
4
|
+
* menu-bar app, and any Agency surface.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: before this module there were four different notions of
|
|
7
|
+
* "synced" living in four files:
|
|
8
|
+
* - doctor: isStale() vs .sync-manifest.json — catches SOURCE drift only.
|
|
9
|
+
* - view: git working-tree state of ~/.agents/ — a resource can show green
|
|
10
|
+
* while its installed copy is stale/deleted/corrupted (false positive).
|
|
11
|
+
* - lists: file-exists-in-home — never reports content drift at all.
|
|
12
|
+
* - menubar: read doctor --json (so it inherited doctor's source-only blind spot).
|
|
13
|
+
*
|
|
14
|
+
* The reliable signal is diffVersionResources() (src/lib/doctor-diff.ts): it reads
|
|
15
|
+
* the ACTUAL version home and compares it to the resolved sources, so it catches
|
|
16
|
+
* every drift class — source-side changes AND home-side rot (deleted / corrupted /
|
|
17
|
+
* hand-edited installed copies) AND orphans. This module wraps it once, maps its
|
|
18
|
+
* per-resource DiffStatus onto one stable enum, folds in `.system` repo freshness,
|
|
19
|
+
* and lets every surface render the same warnings instead of re-deriving them.
|
|
20
|
+
*/
|
|
21
|
+
import { AgentId } from './types.js';
|
|
22
|
+
import { type DoctorKind } from './doctor-diff.js';
|
|
23
|
+
/**
|
|
24
|
+
* One stable status per resource, unified across every surface.
|
|
25
|
+
* - `synced` — installed copy matches the resolved source (DiffStatus 'ok').
|
|
26
|
+
* - `drifted` — installed copy exists but differs from source (DiffStatus 'diff').
|
|
27
|
+
* - `missing` — source exists, nothing installed in the version home ('missing').
|
|
28
|
+
* - `orphan` — installed in the home with no source ('extra'); prune's job, not sync's.
|
|
29
|
+
*/
|
|
30
|
+
export type ResourceSyncStatus = 'synced' | 'drifted' | 'missing' | 'orphan';
|
|
31
|
+
export interface ResourceStatusRow {
|
|
32
|
+
agent: AgentId;
|
|
33
|
+
version: string;
|
|
34
|
+
kind: DoctorKind;
|
|
35
|
+
name: string;
|
|
36
|
+
status: ResourceSyncStatus;
|
|
37
|
+
/** Human-readable specifics for a drifted row (e.g. plugin version delta). */
|
|
38
|
+
detail?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface AgentVersionStatus {
|
|
41
|
+
agent: AgentId;
|
|
42
|
+
version: string;
|
|
43
|
+
isDefault: boolean;
|
|
44
|
+
/** False = no .sync-manifest.json: this version was never synced (cold). */
|
|
45
|
+
everSynced: boolean;
|
|
46
|
+
counts: {
|
|
47
|
+
synced: number;
|
|
48
|
+
drifted: number;
|
|
49
|
+
missing: number;
|
|
50
|
+
orphan: number;
|
|
51
|
+
};
|
|
52
|
+
/** drifted + missing > 0 — a real reconcile is owed. Orphans do NOT set this
|
|
53
|
+
* (heal never deletes; orphan removal is `agents prune cleanup`). */
|
|
54
|
+
needsSync: boolean;
|
|
55
|
+
resources: ResourceStatusRow[];
|
|
56
|
+
}
|
|
57
|
+
export interface SystemRepoStatus {
|
|
58
|
+
dir: string;
|
|
59
|
+
/** Commits the local `.system` checkout is behind its tracking branch, as of
|
|
60
|
+
* the last background fetch (no network is performed here). 0 = up to date. */
|
|
61
|
+
behind: number;
|
|
62
|
+
ahead: number;
|
|
63
|
+
branch: string | null;
|
|
64
|
+
/** True when the dir isn't a git repo or has no upstream — behind is unknown. */
|
|
65
|
+
unknown: boolean;
|
|
66
|
+
}
|
|
67
|
+
export interface UnifiedSyncStatus {
|
|
68
|
+
system: SystemRepoStatus;
|
|
69
|
+
agents: AgentVersionStatus[];
|
|
70
|
+
totals: {
|
|
71
|
+
drifted: number;
|
|
72
|
+
missing: number;
|
|
73
|
+
orphan: number;
|
|
74
|
+
/** Versions with a manifest that are behind on content. */
|
|
75
|
+
versionsNeedingSync: number;
|
|
76
|
+
/** Versions that were never synced at all. */
|
|
77
|
+
versionsNeverSynced: number;
|
|
78
|
+
/** Distinct agent ids that own at least one version needing sync. */
|
|
79
|
+
agentsNeedingSync: number;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export interface SyncStatusOptions {
|
|
83
|
+
cwd?: string;
|
|
84
|
+
/** Restrict to specific agent ids; undefined = every supported agent. */
|
|
85
|
+
agents?: AgentId[];
|
|
86
|
+
/** Restrict to specific resource kinds; undefined = all. */
|
|
87
|
+
kinds?: DoctorKind[];
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Read `.system` repo freshness WITHOUT touching the network. `git status`
|
|
91
|
+
* reports ahead/behind against the remote-tracking ref, which the detached
|
|
92
|
+
* auto-pull worker keeps warm via periodic `git fetch`. This is the same number
|
|
93
|
+
* the menu-bar surfaces; we read it once, here, so every surface agrees.
|
|
94
|
+
*/
|
|
95
|
+
export declare function getSystemRepoStatus(): Promise<SystemRepoStatus>;
|
|
96
|
+
/**
|
|
97
|
+
* Compute unified sync status across the fleet. Resolves against non-project
|
|
98
|
+
* layers only (`excludeProject: true`) — the GLOBAL version home is never
|
|
99
|
+
* reconciled against per-cwd `<cwd>/.agents/` resources, so counting them as
|
|
100
|
+
* "missing" there would be a false gap (matches doctor's overview semantics).
|
|
101
|
+
*/
|
|
102
|
+
export declare function computeSyncStatus(options?: SyncStatusOptions): Promise<UnifiedSyncStatus>;
|