@phnx-labs/agents-cli 1.20.33 → 1.20.35
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 +29 -0
- package/README.md +32 -2
- package/dist/commands/computer-actions.d.ts +15 -0
- package/dist/commands/computer-actions.js +120 -22
- package/dist/commands/computer.d.ts +23 -0
- package/dist/commands/computer.js +45 -3
- package/dist/commands/cost.js +17 -12
- package/dist/commands/doctor.d.ts +11 -0
- package/dist/commands/doctor.js +108 -14
- package/dist/commands/events.d.ts +16 -0
- package/dist/commands/events.js +173 -0
- package/dist/commands/exec.js +6 -1
- package/dist/commands/hosts.js +28 -24
- package/dist/commands/import.js +1 -1
- package/dist/commands/inspect.d.ts +2 -0
- package/dist/commands/inspect.js +54 -7
- package/dist/commands/logs.d.ts +17 -0
- package/dist/commands/logs.js +139 -0
- package/dist/commands/models.d.ts +3 -0
- package/dist/commands/models.js +27 -8
- package/dist/commands/permissions.js +9 -2
- package/dist/commands/repo.d.ts +34 -0
- package/dist/commands/repo.js +243 -65
- package/dist/commands/resource-view.d.ts +20 -0
- package/dist/commands/resource-view.js +90 -28
- package/dist/commands/rules.js +1 -1
- package/dist/commands/secrets-migrate.js +23 -11
- package/dist/commands/secrets.d.ts +20 -0
- package/dist/commands/secrets.js +93 -17
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +209 -0
- package/dist/commands/sessions-tail.d.ts +10 -0
- package/dist/commands/sessions-tail.js +11 -0
- package/dist/commands/sessions.d.ts +73 -1
- package/dist/commands/sessions.js +273 -53
- package/dist/commands/status.d.ts +12 -0
- package/dist/commands/status.js +81 -0
- package/dist/commands/teams.js +70 -6
- package/dist/commands/versions.js +2 -1
- package/dist/commands/view.d.ts +42 -0
- package/dist/commands/view.js +213 -83
- package/dist/commands/wallet.d.ts +6 -0
- package/dist/commands/wallet.js +22 -5
- package/dist/index.js +61 -32
- package/dist/lib/acp/harnesses.d.ts +1 -1
- package/dist/lib/acp/harnesses.js +2 -2
- package/dist/lib/agents.d.ts +12 -0
- package/dist/lib/agents.js +145 -41
- package/dist/lib/browser/cdp.js +5 -1
- package/dist/lib/browser/chrome.js +20 -0
- package/dist/lib/browser/drivers/ssh.d.ts +27 -0
- package/dist/lib/browser/drivers/ssh.js +94 -19
- package/dist/lib/browser/ipc.d.ts +3 -0
- package/dist/lib/browser/ipc.js +13 -9
- package/dist/lib/browser/service.js +71 -15
- package/dist/lib/daemon.d.ts +35 -0
- package/dist/lib/daemon.js +33 -5
- package/dist/lib/devices/connect.d.ts +3 -2
- package/dist/lib/devices/connect.js +5 -3
- 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/events.d.ts +9 -2
- package/dist/lib/events.js +70 -11
- package/dist/lib/exec.d.ts +15 -0
- package/dist/lib/exec.js +43 -11
- package/dist/lib/hooks.js +8 -2
- package/dist/lib/hosts/dispatch.js +6 -7
- package/dist/lib/hosts/logs.d.ts +16 -0
- package/dist/lib/hosts/logs.js +45 -0
- package/dist/lib/hosts/progress.d.ts +66 -0
- package/dist/lib/hosts/progress.js +125 -17
- package/dist/lib/hosts/ready.d.ts +23 -2
- package/dist/lib/hosts/ready.js +35 -13
- package/dist/lib/hosts/reconcile.d.ts +53 -0
- package/dist/lib/hosts/reconcile.js +81 -0
- package/dist/lib/hosts/tasks.d.ts +8 -0
- package/dist/lib/hosts/tasks.js +14 -0
- package/dist/lib/permissions.d.ts +4 -0
- package/dist/lib/permissions.js +35 -0
- package/dist/lib/picker.d.ts +24 -0
- package/dist/lib/picker.js +124 -0
- package/dist/lib/platform/winpath.d.ts +31 -2
- package/dist/lib/platform/winpath.js +133 -24
- package/dist/lib/plugin-marketplace.d.ts +30 -0
- package/dist/lib/plugin-marketplace.js +215 -2
- package/dist/lib/plugins.d.ts +5 -0
- package/dist/lib/plugins.js +45 -4
- 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 +40 -9
- package/dist/lib/secrets/filestore.js +31 -1
- package/dist/lib/secrets/index.d.ts +33 -1
- package/dist/lib/secrets/index.js +90 -9
- package/dist/lib/secrets/windows.d.ts +74 -0
- package/dist/lib/secrets/windows.js +440 -0
- package/dist/lib/session/active.d.ts +7 -0
- package/dist/lib/session/active.js +11 -3
- package/dist/lib/session/discover.js +94 -0
- package/dist/lib/session/parse.d.ts +6 -0
- package/dist/lib/session/parse.js +283 -2
- package/dist/lib/session/pid-registry.d.ts +21 -0
- package/dist/lib/session/pid-registry.js +80 -0
- package/dist/lib/session/remote-active.d.ts +26 -0
- package/dist/lib/session/remote-active.js +141 -0
- package/dist/lib/session/remote.js +2 -6
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +11 -2
- package/dist/lib/session/types.d.ts +1 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/shims.d.ts +30 -2
- package/dist/lib/shims.js +107 -50
- package/dist/lib/ssh-exec.d.ts +26 -3
- package/dist/lib/ssh-exec.js +45 -3
- package/dist/lib/ssh-tunnel.d.ts +24 -5
- package/dist/lib/ssh-tunnel.js +60 -62
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +3 -0
- package/dist/lib/startup/command-registry.js +6 -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/teams/registry.js +25 -9
- package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
- package/dist/lib/terminal/backends/ghostty.js +69 -0
- package/dist/lib/terminal/backends/index.d.ts +18 -0
- package/dist/lib/terminal/backends/index.js +29 -0
- package/dist/lib/terminal/backends/iterm.d.ts +6 -0
- package/dist/lib/terminal/backends/iterm.js +62 -0
- package/dist/lib/terminal/backends/tmux.d.ts +14 -0
- package/dist/lib/terminal/backends/tmux.js +23 -0
- package/dist/lib/terminal/engine.d.ts +39 -0
- package/dist/lib/terminal/engine.js +54 -0
- package/dist/lib/terminal/index.d.ts +14 -0
- package/dist/lib/terminal/index.js +5 -0
- package/dist/lib/terminal/policy.d.ts +11 -0
- package/dist/lib/terminal/policy.js +11 -0
- package/dist/lib/terminal/quote.d.ts +11 -0
- package/dist/lib/terminal/quote.js +13 -0
- package/dist/lib/terminal/shell.d.ts +6 -0
- package/dist/lib/terminal/shell.js +23 -0
- package/dist/lib/terminal/transport.d.ts +15 -0
- package/dist/lib/terminal/transport.js +40 -0
- package/dist/lib/terminal/types.d.ts +59 -0
- package/dist/lib/terminal/types.js +13 -0
- package/dist/lib/types.d.ts +27 -1
- package/dist/lib/usage.d.ts +30 -0
- package/dist/lib/usage.js +159 -2
- package/dist/lib/versions.d.ts +7 -3
- package/dist/lib/versions.js +10 -5
- package/dist/lib/whats-new.d.ts +9 -0
- package/dist/lib/whats-new.js +35 -0
- package/package.json +1 -1
|
@@ -12,6 +12,7 @@ export { shellQuote };
|
|
|
12
12
|
// the single ssh-tunnel helper. Calling it with no options preserves this
|
|
13
13
|
// driver's original foreground, stderr-captured behavior exactly.
|
|
14
14
|
import { startSSHTunnel } from '../../ssh-tunnel.js';
|
|
15
|
+
import { encodePwshBase64 } from '../../pwsh.js';
|
|
15
16
|
export async function connectSSH(endpoint, profile) {
|
|
16
17
|
const url = new URL(endpoint);
|
|
17
18
|
if (url.protocol !== 'ssh:') {
|
|
@@ -47,12 +48,12 @@ export async function connectSSH(endpoint, profile) {
|
|
|
47
48
|
`is already in use by ${occupant.command} (pid ${occupant.pid}). ` +
|
|
48
49
|
`Either kill that process (\`kill ${occupant.pid}\`) or change the profile's port.`);
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
// A non-zero remote exit (missing exe, auth failure, bad launch) now rejects
|
|
52
|
+
// with the captured stderr and propagates. An already-running browser exits 0
|
|
53
|
+
// (the launch backgrounds/WMI-spawns regardless), so this does not spuriously
|
|
54
|
+
// fail the reconnect path — only genuine launch failures reach the caller
|
|
55
|
+
// instead of being swallowed and mis-reported later as a tunnel timeout.
|
|
56
|
+
await ensureRemoteBrowser(user, host, profile.browser, remotePort, remoteOs, profile.binary);
|
|
56
57
|
let tunnel;
|
|
57
58
|
if (occupant) {
|
|
58
59
|
// Reuse the existing tunnel rather than spawning a duplicate.
|
|
@@ -101,6 +102,11 @@ export async function connectSSH(endpoint, profile) {
|
|
|
101
102
|
pid: tunnelPid,
|
|
102
103
|
cleanup: () => {
|
|
103
104
|
cdp.close();
|
|
105
|
+
// Kill the remote browser BEFORE tearing down the tunnel. It runs on a
|
|
106
|
+
// separate ssh connection (independent of the tunnel we're about to
|
|
107
|
+
// kill), so tunnel teardown can't cut it off. Fire-and-forget — cleanup
|
|
108
|
+
// stays synchronous, and killRemoteBrowser never rejects.
|
|
109
|
+
killRemoteBrowser(user, host, remoteOs, remotePort).catch(() => { });
|
|
104
110
|
tunnel.kill();
|
|
105
111
|
clearProfileRuntime(profile.name);
|
|
106
112
|
},
|
|
@@ -159,8 +165,7 @@ function psSingleQuote(s) {
|
|
|
159
165
|
* `powershell -Command "…"` is fragile the moment a path or URL is involved).
|
|
160
166
|
*/
|
|
161
167
|
export function encodePowerShell(script) {
|
|
162
|
-
|
|
163
|
-
return `powershell -NoProfile -EncodedCommand ${b64}`;
|
|
168
|
+
return `powershell -NoProfile -EncodedCommand ${encodePwshBase64(script)}`;
|
|
164
169
|
}
|
|
165
170
|
/**
|
|
166
171
|
* The PowerShell that launches the browser on a Windows remote. Two hard
|
|
@@ -177,9 +182,9 @@ export function encodePowerShell(script) {
|
|
|
177
182
|
* registry at runtime rather than relying on a bare `msedge` name.
|
|
178
183
|
*/
|
|
179
184
|
export function buildWindowsLaunchScript(browserType, port, customBinary) {
|
|
180
|
-
let
|
|
185
|
+
let exeStmts;
|
|
181
186
|
if (customBinary) {
|
|
182
|
-
|
|
187
|
+
exeStmts = [`$exe = ${psSingleQuote(customBinary)}`];
|
|
183
188
|
}
|
|
184
189
|
else if (browserType === 'custom') {
|
|
185
190
|
throw new Error('browser: custom requires a binary path in the profile');
|
|
@@ -188,13 +193,24 @@ export function buildWindowsLaunchScript(browserType, port, customBinary) {
|
|
|
188
193
|
const exeKey = WIN_BROWSER_APPPATH[browserType];
|
|
189
194
|
if (!exeKey)
|
|
190
195
|
throw new Error(`Unknown browser type for windows remote: ${browserType}`);
|
|
191
|
-
|
|
192
|
-
|
|
196
|
+
const appPath = `SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${exeKey}`;
|
|
197
|
+
// Per-user installs — the DEFAULT for Edge and Chrome — register App Paths
|
|
198
|
+
// under HKCU, not HKLM, so an HKLM-only lookup silently missed them and the
|
|
199
|
+
// launch failed. Resolve from HKLM first, then fall through to HKCU.
|
|
200
|
+
// Windows PowerShell 5.1 (what `powershell.exe` is) has no `??`, so the
|
|
201
|
+
// fallback is an explicit `if`. A missing exe `throw`s so the failure rides
|
|
202
|
+
// out as a non-zero ssh exit (surfaced by ensureRemoteBrowser) instead of a
|
|
203
|
+
// silent empty launch.
|
|
204
|
+
exeStmts = [
|
|
205
|
+
`$exe = (Get-ItemProperty 'HKLM:\\${appPath}' -EA SilentlyContinue).'(default)'`,
|
|
206
|
+
`if (-not $exe) { $exe = (Get-ItemProperty 'HKCU:\\${appPath}' -EA SilentlyContinue).'(default)' }`,
|
|
207
|
+
`if (-not $exe) { throw 'browser exe not found in HKLM/HKCU App Paths: ${exeKey}' }`,
|
|
208
|
+
];
|
|
193
209
|
}
|
|
194
210
|
// Keep the `--remote-allow-origins=http://127.0.0.1:${port}` literal in
|
|
195
211
|
// source — a test asserts CDP is never opened to `*`.
|
|
196
212
|
return [
|
|
197
|
-
|
|
213
|
+
...exeStmts,
|
|
198
214
|
`$cl = '"' + $exe + '" --remote-debugging-port=${port}` +
|
|
199
215
|
` --remote-allow-origins=http://127.0.0.1:${port}` +
|
|
200
216
|
` --disable-background-timer-throttling --user-data-dir="' + $env:TEMP + '\\agents-browser-${port}"'`,
|
|
@@ -242,21 +258,56 @@ export function buildKillCmd(remoteOs, port) {
|
|
|
242
258
|
}
|
|
243
259
|
return `pids=$(lsof -ti ${shellQuote(`:${port}`)} 2>/dev/null); [ -z "$pids" ] || kill -9 $pids 2>/dev/null || true`;
|
|
244
260
|
}
|
|
245
|
-
async function ensureRemoteBrowser(user, host, browserType, port, remoteOs, customBinary) {
|
|
261
|
+
export async function ensureRemoteBrowser(user, host, browserType, port, remoteOs, customBinary) {
|
|
246
262
|
const remoteCmd = buildLaunchCmd(remoteOs, browserType, port, customBinary);
|
|
247
263
|
return new Promise((resolve, reject) => {
|
|
264
|
+
// Capture stderr so a real launch failure (missing exe, auth denied, bad
|
|
265
|
+
// CreateProcess) surfaces here as a clear error. Previously stdio was
|
|
266
|
+
// ignored and this resolved on close regardless of exit code, so failures
|
|
267
|
+
// were swallowed and only re-emerged 8s later as a generic
|
|
268
|
+
// "SSH tunnel failed to establish".
|
|
248
269
|
const child = spawn('ssh', [
|
|
249
270
|
`${user}@${host}`,
|
|
250
271
|
'-o',
|
|
251
272
|
'BatchMode=yes',
|
|
252
273
|
remoteCmd,
|
|
253
|
-
], { stdio: 'ignore' });
|
|
254
|
-
|
|
255
|
-
child.on('
|
|
256
|
-
|
|
274
|
+
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
275
|
+
let stderr = '';
|
|
276
|
+
child.stderr?.on('data', (d) => {
|
|
277
|
+
stderr += d.toString();
|
|
278
|
+
});
|
|
279
|
+
let settled = false;
|
|
280
|
+
const timer = setTimeout(() => {
|
|
281
|
+
if (settled)
|
|
282
|
+
return;
|
|
283
|
+
settled = true;
|
|
284
|
+
// ssh is still running the backgrounding launch command past our window.
|
|
285
|
+
// Killing the local ssh client does NOT reap the WMI/detached remote
|
|
286
|
+
// browser, so treat this as launched and let waitForPort confirm.
|
|
257
287
|
child.kill();
|
|
258
288
|
resolve();
|
|
259
289
|
}, 2000);
|
|
290
|
+
child.on('close', (code) => {
|
|
291
|
+
if (settled)
|
|
292
|
+
return;
|
|
293
|
+
settled = true;
|
|
294
|
+
clearTimeout(timer);
|
|
295
|
+
if (code && code !== 0) {
|
|
296
|
+
const detail = stderr.trim();
|
|
297
|
+
reject(new Error(`Remote browser launch failed on ${host} (ssh exit ${code})` +
|
|
298
|
+
(detail ? `: ${detail}` : '')));
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
resolve();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
child.on('error', (err) => {
|
|
305
|
+
if (settled)
|
|
306
|
+
return;
|
|
307
|
+
settled = true;
|
|
308
|
+
clearTimeout(timer);
|
|
309
|
+
reject(err);
|
|
310
|
+
});
|
|
260
311
|
});
|
|
261
312
|
}
|
|
262
313
|
export async function restartRemoteBrowser(user, host, browserType, port, remoteOs, customBinary) {
|
|
@@ -266,6 +317,15 @@ export async function restartRemoteBrowser(user, host, browserType, port, remote
|
|
|
266
317
|
await ensureRemoteBrowser(user, host, browserType, port, remoteOs, customBinary);
|
|
267
318
|
await sleep(1500);
|
|
268
319
|
}
|
|
320
|
+
/**
|
|
321
|
+
* Kill the remote browser holding the CDP port. Invoked on stop/cleanup so the
|
|
322
|
+
* WMI-spawned (Windows) / detached (posix) browser process is not orphaned when
|
|
323
|
+
* the ssh tunnel is torn down — killing only the tunnel left it running on
|
|
324
|
+
* win-mini after every `browser stop`. Best-effort: never rejects.
|
|
325
|
+
*/
|
|
326
|
+
export function killRemoteBrowser(user, host, remoteOs, port) {
|
|
327
|
+
return runSSHCommand(user, host, buildKillCmd(remoteOs, port));
|
|
328
|
+
}
|
|
269
329
|
function runSSHCommand(user, host, cmd) {
|
|
270
330
|
return new Promise((resolve) => {
|
|
271
331
|
const child = spawn('ssh', [`${user}@${host}`, '-o', 'BatchMode=yes', cmd], {
|
|
@@ -290,8 +350,23 @@ function sleep(ms) {
|
|
|
290
350
|
*
|
|
291
351
|
* Best-effort match against the ssh -L command line via `ps`. If we
|
|
292
352
|
* can't read the cmd or the args don't look like ours, treat as not-ours.
|
|
353
|
+
*
|
|
354
|
+
* Windows has no `ps`, so the POSIX branch below always throws there and every
|
|
355
|
+
* reuse check returned false — the second invocation to the same host:port then
|
|
356
|
+
* failed "port in use". On win32 we instead reuse the netstat -ano + tasklist
|
|
357
|
+
* occupant lookup (getPortOccupant): the tunnel binds `remotePort` locally
|
|
358
|
+
* (localPort === remotePort in the caller), so the pid holding that port is our
|
|
359
|
+
* ssh.exe. tasklist only exposes the image name, not the full command line, so
|
|
360
|
+
* we can't match host/remotePort as tightly as the POSIX branch — an ssh
|
|
361
|
+
* process on our exact local port is our tunnel in practice.
|
|
293
362
|
*/
|
|
294
|
-
function isOwnTunnel(pid, host, remotePort) {
|
|
363
|
+
export function isOwnTunnel(pid, host, remotePort) {
|
|
364
|
+
if (process.platform === 'win32') {
|
|
365
|
+
const occupant = getPortOccupant(remotePort);
|
|
366
|
+
if (!occupant || occupant.pid !== pid)
|
|
367
|
+
return false;
|
|
368
|
+
return occupant.command.toLowerCase().startsWith('ssh');
|
|
369
|
+
}
|
|
295
370
|
try {
|
|
296
371
|
const out = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
|
|
297
372
|
encoding: 'utf-8',
|
|
@@ -8,6 +8,9 @@ export declare class BrowserDaemonNotRunningError extends Error {
|
|
|
8
8
|
}
|
|
9
9
|
export declare function formatBrowserDaemonNotRunningError(): string;
|
|
10
10
|
export declare function getSocketPath(): string;
|
|
11
|
+
/** Is the daemon reachable? A real connect probe on every platform — a socket
|
|
12
|
+
* file existing on disk is not proof a daemon is listening on it. */
|
|
13
|
+
export declare function isDaemonReachable(): Promise<boolean>;
|
|
11
14
|
export declare class BrowserIPCServer {
|
|
12
15
|
private server;
|
|
13
16
|
private service;
|
package/dist/lib/browser/ipc.js
CHANGED
|
@@ -32,8 +32,12 @@ export function getSocketPath() {
|
|
|
32
32
|
function getIpcEndpoint() {
|
|
33
33
|
return ipcEndpoint(getSocketPath());
|
|
34
34
|
}
|
|
35
|
-
/** Can we open a connection to the daemon right now?
|
|
36
|
-
*
|
|
35
|
+
/** Can we open a connection to the daemon right now? Resolves false on any
|
|
36
|
+
* error. This is the authoritative liveness check on every platform: a live
|
|
37
|
+
* daemon accepts the connection, while a stale POSIX socket file left behind by
|
|
38
|
+
* a crashed daemon (or one that "appears and is immediately destroyed", #556)
|
|
39
|
+
* rejects with ECONNREFUSED and is correctly reported as not reachable —
|
|
40
|
+
* something fs.existsSync can't distinguish. */
|
|
37
41
|
function probeDaemon(endpoint, timeoutMs = 500) {
|
|
38
42
|
return new Promise((resolve) => {
|
|
39
43
|
const sock = net.createConnection(endpoint);
|
|
@@ -45,16 +49,16 @@ function probeDaemon(endpoint, timeoutMs = 500) {
|
|
|
45
49
|
sock.on('error', () => { clearTimeout(timer); finish(false); });
|
|
46
50
|
});
|
|
47
51
|
}
|
|
48
|
-
/** Is the daemon reachable?
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return fs.existsSync(getSocketPath());
|
|
52
|
+
/** Is the daemon reachable? A real connect probe on every platform — a socket
|
|
53
|
+
* file existing on disk is not proof a daemon is listening on it. */
|
|
54
|
+
export async function isDaemonReachable() {
|
|
55
|
+
return probeDaemon(getIpcEndpoint());
|
|
53
56
|
}
|
|
54
|
-
async function waitForSocket(
|
|
57
|
+
async function waitForSocket(_socketPath, timeoutMs) {
|
|
58
|
+
const endpoint = getIpcEndpoint();
|
|
55
59
|
const deadline = Date.now() + timeoutMs;
|
|
56
60
|
while (Date.now() < deadline) {
|
|
57
|
-
if (
|
|
61
|
+
if (await probeDaemon(endpoint))
|
|
58
62
|
return;
|
|
59
63
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
60
64
|
}
|
|
@@ -412,12 +412,23 @@ export class BrowserService {
|
|
|
412
412
|
return this.stop(taskName);
|
|
413
413
|
}
|
|
414
414
|
async stopProfile(profileName) {
|
|
415
|
-
|
|
416
|
-
|
|
415
|
+
// Connections are keyed by the composite `<profile>@<endpoint>` (see start()),
|
|
416
|
+
// but callers pass the bare profile name (or, occasionally, an exact composite).
|
|
417
|
+
// A plain `connections.get(profileName)` therefore missed every real remote
|
|
418
|
+
// connection, so `cleanup()` never ran — leaving the SSH tunnel and, on
|
|
419
|
+
// Windows, the WMI-spawned browser orphaned on the remote host after every
|
|
420
|
+
// `browser stop --profile` (#559). Match the exact key AND every
|
|
421
|
+
// `<profileName>@<endpoint>` composite so all of a profile's live connections
|
|
422
|
+
// are torn down.
|
|
423
|
+
const keys = [...this.connections.keys()].filter((k) => k === profileName || k.startsWith(`${profileName}@`));
|
|
424
|
+
for (const key of keys) {
|
|
425
|
+
const conn = this.connections.get(key);
|
|
426
|
+
if (!conn)
|
|
427
|
+
continue;
|
|
417
428
|
conn.cdp.close();
|
|
418
429
|
killChrome(conn.pid);
|
|
419
430
|
conn.cleanup?.();
|
|
420
|
-
this.connections.delete(
|
|
431
|
+
this.connections.delete(key);
|
|
421
432
|
}
|
|
422
433
|
const runtimeDir = getProfileRuntimeDir(profileName);
|
|
423
434
|
const pidFile = path.join(runtimeDir, 'pid');
|
|
@@ -693,14 +704,22 @@ export class BrowserService {
|
|
|
693
704
|
const outputPath = path.join(recordingsDir, `${Date.now()}.webm`);
|
|
694
705
|
// Resolve ffmpeg lazily so non-recording paths don't pay the import cost.
|
|
695
706
|
const { spawn } = await import('child_process');
|
|
707
|
+
// CDP `Page.startScreencast` delivers frames at a VARIABLE cadence (paints
|
|
708
|
+
// are event-driven, not clocked). Feeding those into a fixed `-framerate`
|
|
709
|
+
// input made ffmpeg assume every frame was 1/fps apart, so a page that
|
|
710
|
+
// painted slower/faster than `fps` played back at the wrong speed. Instead
|
|
711
|
+
// stamp each frame with its wall-clock arrival time
|
|
712
|
+
// (`-use_wallclock_as_timestamps 1`) and keep those timings on output
|
|
713
|
+
// (`-vsync vfr`), so playback duration matches the real capture.
|
|
696
714
|
const ffmpeg = spawn('ffmpeg', [
|
|
697
715
|
'-loglevel', 'error',
|
|
698
716
|
'-f', 'image2pipe',
|
|
699
|
-
'-
|
|
717
|
+
'-use_wallclock_as_timestamps', '1',
|
|
700
718
|
'-i', '-',
|
|
701
719
|
'-c:v', 'libvpx-vp9',
|
|
702
720
|
'-b:v', '1M',
|
|
703
721
|
'-pix_fmt', 'yuv420p',
|
|
722
|
+
'-vsync', 'vfr',
|
|
704
723
|
'-y',
|
|
705
724
|
outputPath,
|
|
706
725
|
], { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
@@ -724,9 +743,16 @@ export class BrowserService {
|
|
|
724
743
|
ffmpeg.once('error', onError);
|
|
725
744
|
ffmpeg.once('spawn', onSpawn);
|
|
726
745
|
});
|
|
727
|
-
//
|
|
728
|
-
//
|
|
729
|
-
ffmpeg
|
|
746
|
+
// Capture ffmpeg's own diagnostics (encoder error, bad codec, etc.) so a
|
|
747
|
+
// failing encode is DIAGNOSABLE at recordStop instead of being discarded.
|
|
748
|
+
// Cap the buffer so a chatty ffmpeg can't grow it unbounded.
|
|
749
|
+
let stderrBuf = '';
|
|
750
|
+
ffmpeg.stderr?.on('data', (d) => {
|
|
751
|
+
stderrBuf += d.toString();
|
|
752
|
+
if (stderrBuf.length > 64 * 1024)
|
|
753
|
+
stderrBuf = stderrBuf.slice(-64 * 1024);
|
|
754
|
+
});
|
|
755
|
+
const ffmpegStderr = () => stderrBuf;
|
|
730
756
|
ffmpeg.on('error', () => { });
|
|
731
757
|
// 30 fps is CDP's screencast cap; everyNthFrame = round(30/fps).
|
|
732
758
|
const everyNthFrame = Math.max(1, Math.round(30 / fps));
|
|
@@ -752,6 +778,7 @@ export class BrowserService {
|
|
|
752
778
|
durationMs,
|
|
753
779
|
maxBytes,
|
|
754
780
|
ffmpeg,
|
|
781
|
+
ffmpegStderr,
|
|
755
782
|
sessionId,
|
|
756
783
|
conn,
|
|
757
784
|
frameHandler,
|
|
@@ -795,27 +822,56 @@ export class BrowserService {
|
|
|
795
822
|
catch {
|
|
796
823
|
// session may already be gone
|
|
797
824
|
}
|
|
798
|
-
// Close ffmpeg stdin so it flushes the output file cleanly
|
|
799
|
-
|
|
800
|
-
|
|
825
|
+
// Close ffmpeg stdin so it flushes the output file cleanly, and observe how
|
|
826
|
+
// it exits. A non-zero exit means the encode failed — the recording must
|
|
827
|
+
// NOT be reported as success. If ffmpeg hangs, KILL it (don't just abandon
|
|
828
|
+
// the promise, which leaked the process) and treat the recording as failed.
|
|
829
|
+
const finalize = await new Promise((resolve) => {
|
|
830
|
+
let done = false;
|
|
831
|
+
const settle = (r) => {
|
|
832
|
+
if (done)
|
|
833
|
+
return;
|
|
834
|
+
done = true;
|
|
835
|
+
clearTimeout(timer);
|
|
836
|
+
resolve(r);
|
|
837
|
+
};
|
|
838
|
+
const timer = setTimeout(() => {
|
|
839
|
+
try {
|
|
840
|
+
rec.ffmpeg.kill('SIGKILL');
|
|
841
|
+
}
|
|
842
|
+
catch { /* already gone */ }
|
|
843
|
+
settle({ code: null, timedOut: true });
|
|
844
|
+
}, 5000);
|
|
845
|
+
rec.ffmpeg.once('exit', (code) => settle({ code, timedOut: false }));
|
|
801
846
|
try {
|
|
802
847
|
rec.ffmpeg.stdin?.end();
|
|
803
848
|
}
|
|
804
849
|
catch {
|
|
805
|
-
|
|
850
|
+
// stdin already closed; wait for exit or the hard timeout above.
|
|
806
851
|
}
|
|
807
|
-
setTimeout(resolve, 5000); // Hard timeout if ffmpeg hangs
|
|
808
852
|
});
|
|
853
|
+
const durationMs = Date.now() - rec.startedAt;
|
|
854
|
+
// Drop the recording from the map before any throw, so a failed stop
|
|
855
|
+
// doesn't wedge the task in a permanent "already recording" state.
|
|
856
|
+
this.recordings.delete(taskId);
|
|
857
|
+
if (finalize.timedOut) {
|
|
858
|
+
throw new Error(`ffmpeg did not exit within 5s while finalizing the recording; killed it. ` +
|
|
859
|
+
`The recording at ${rec.outputPath} is incomplete.`);
|
|
860
|
+
}
|
|
861
|
+
if (finalize.code !== 0) {
|
|
862
|
+
const err = rec.ffmpegStderr().trim();
|
|
863
|
+
throw new Error(`ffmpeg exited abnormally (code ${finalize.code}) while finalizing the recording at ` +
|
|
864
|
+
`${rec.outputPath}; the file is likely corrupt or empty.` +
|
|
865
|
+
(err ? ` ffmpeg: ${err.slice(-800)}` : ''));
|
|
866
|
+
}
|
|
809
867
|
let bytes = 0;
|
|
810
868
|
try {
|
|
811
869
|
const st = await fs.promises.stat(rec.outputPath);
|
|
812
870
|
bytes = st.size;
|
|
813
871
|
}
|
|
814
872
|
catch {
|
|
815
|
-
// ffmpeg
|
|
873
|
+
// ffmpeg exited 0 but the file is missing — still a failed recording.
|
|
816
874
|
}
|
|
817
|
-
const durationMs = Date.now() - rec.startedAt;
|
|
818
|
-
this.recordings.delete(taskId);
|
|
819
875
|
return { path: rec.outputPath, bytes, durationMs, reason };
|
|
820
876
|
}
|
|
821
877
|
async recordStatus(taskId) {
|
package/dist/lib/daemon.d.ts
CHANGED
|
@@ -46,6 +46,40 @@ export declare function startDaemon(): {
|
|
|
46
46
|
* value (e.g. inherited from launchd) is left untouched.
|
|
47
47
|
*/
|
|
48
48
|
export declare function buildDetachedDaemonEnv(baseEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
49
|
+
/**
|
|
50
|
+
* Resolve how to launch the daemon: `node <entry> daemon _run`, matching the
|
|
51
|
+
* exact form that works under a direct `daemon _run`.
|
|
52
|
+
*
|
|
53
|
+
* We spawn the Node runtime (`process.execPath`) with the CLI entry as an
|
|
54
|
+
* argument rather than executing the entry path directly. Executing the `.js`
|
|
55
|
+
* path relies on its shebang on POSIX, and on Windows CreateProcess can't run a
|
|
56
|
+
* `.js`/shim directly at all — it gets launched through a transient
|
|
57
|
+
* console-owning wrapper (cmd.exe / the npm shim). When that wrapper exits it
|
|
58
|
+
* closes its console, and the detached daemon sharing that console receives a
|
|
59
|
+
* console-close event that trips its shutdown handler — the daemon comes up,
|
|
60
|
+
* binds the browser IPC socket, then tears itself down ~36ms later (#556).
|
|
61
|
+
* Going through `process.execPath` means a real PE/binary is spawned with
|
|
62
|
+
* `detached: true` and no console, so nothing signals the daemon after launch.
|
|
63
|
+
*
|
|
64
|
+
* When the entry isn't a JS file (e.g. a native launcher resolved via
|
|
65
|
+
* `which agents`), run it directly — it owns its own runtime resolution.
|
|
66
|
+
*/
|
|
67
|
+
export declare function getDaemonLaunch(agentsBin?: string): {
|
|
68
|
+
command: string;
|
|
69
|
+
args: string[];
|
|
70
|
+
};
|
|
71
|
+
interface StartDetachedOptions {
|
|
72
|
+
/** CLI entry to launch (defaults to the running binary). Injectable for tests. */
|
|
73
|
+
agentsBin?: string;
|
|
74
|
+
/** Log file the daemon's stdio is redirected to (defaults to the daemon log). */
|
|
75
|
+
logPath?: string;
|
|
76
|
+
/** Environment for the child (defaults to the OAuth-augmented detached env). */
|
|
77
|
+
env?: NodeJS.ProcessEnv;
|
|
78
|
+
}
|
|
79
|
+
export declare function startDetached(opts?: StartDetachedOptions): {
|
|
80
|
+
pid: number | null;
|
|
81
|
+
method: string;
|
|
82
|
+
};
|
|
49
83
|
/** Stop the daemon, unloading it from launchd/systemd if applicable. */
|
|
50
84
|
export declare function stopDaemon(): boolean;
|
|
51
85
|
/** Get current daemon status including running state, PID, and enabled job count. */
|
|
@@ -59,3 +93,4 @@ export declare function getDaemonStatus(): {
|
|
|
59
93
|
export declare function readDaemonLog(lines?: number): string;
|
|
60
94
|
/** Send SIGHUP to the daemon to trigger a job reload. */
|
|
61
95
|
export declare function signalDaemonReload(): boolean;
|
|
96
|
+
export {};
|
package/dist/lib/daemon.js
CHANGED
|
@@ -538,14 +538,42 @@ export function buildDetachedDaemonEnv(baseEnv = process.env) {
|
|
|
538
538
|
}
|
|
539
539
|
return env;
|
|
540
540
|
}
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
541
|
+
/**
|
|
542
|
+
* Resolve how to launch the daemon: `node <entry> daemon _run`, matching the
|
|
543
|
+
* exact form that works under a direct `daemon _run`.
|
|
544
|
+
*
|
|
545
|
+
* We spawn the Node runtime (`process.execPath`) with the CLI entry as an
|
|
546
|
+
* argument rather than executing the entry path directly. Executing the `.js`
|
|
547
|
+
* path relies on its shebang on POSIX, and on Windows CreateProcess can't run a
|
|
548
|
+
* `.js`/shim directly at all — it gets launched through a transient
|
|
549
|
+
* console-owning wrapper (cmd.exe / the npm shim). When that wrapper exits it
|
|
550
|
+
* closes its console, and the detached daemon sharing that console receives a
|
|
551
|
+
* console-close event that trips its shutdown handler — the daemon comes up,
|
|
552
|
+
* binds the browser IPC socket, then tears itself down ~36ms later (#556).
|
|
553
|
+
* Going through `process.execPath` means a real PE/binary is spawned with
|
|
554
|
+
* `detached: true` and no console, so nothing signals the daemon after launch.
|
|
555
|
+
*
|
|
556
|
+
* When the entry isn't a JS file (e.g. a native launcher resolved via
|
|
557
|
+
* `which agents`), run it directly — it owns its own runtime resolution.
|
|
558
|
+
*/
|
|
559
|
+
export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
|
|
560
|
+
if (/\.(c|m)?js$/.test(agentsBin)) {
|
|
561
|
+
return { command: process.execPath, args: [agentsBin, 'daemon', '_run'] };
|
|
562
|
+
}
|
|
563
|
+
return { command: agentsBin, args: ['daemon', '_run'] };
|
|
564
|
+
}
|
|
565
|
+
export function startDetached(opts = {}) {
|
|
566
|
+
const agentsBin = opts.agentsBin ?? getAgentsBinPath();
|
|
567
|
+
const logPath = opts.logPath ?? getLogPath();
|
|
544
568
|
const logFd = fs.openSync(logPath, 'a');
|
|
545
|
-
const
|
|
569
|
+
const { command, args } = getDaemonLaunch(agentsBin);
|
|
570
|
+
const child = spawn(command, args, {
|
|
546
571
|
stdio: ['ignore', logFd, logFd],
|
|
547
572
|
detached: true,
|
|
548
|
-
|
|
573
|
+
// No console window on Windows — a hidden, consoleless process can't be
|
|
574
|
+
// torn down by a console-close event when its launcher exits (#556).
|
|
575
|
+
windowsHide: true,
|
|
576
|
+
env: opts.env ?? buildDetachedDaemonEnv(),
|
|
549
577
|
});
|
|
550
578
|
child.unref();
|
|
551
579
|
fs.closeSync(logFd);
|
|
@@ -11,8 +11,9 @@ export declare function sshTargetFor(device: DeviceProfile): string;
|
|
|
11
11
|
/**
|
|
12
12
|
* Wrap a remote command for the device's shell. Windows devices speak
|
|
13
13
|
* PowerShell, so a bare command is run through `powershell -NoProfile
|
|
14
|
-
* -
|
|
15
|
-
* parses it). Returns undefined when no command was given (interactive
|
|
14
|
+
* -EncodedCommand`; POSIX devices get the command verbatim (the remote login
|
|
15
|
+
* shell parses it). Returns undefined when no command was given (interactive
|
|
16
|
+
* login).
|
|
16
17
|
*/
|
|
17
18
|
export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[]): string | undefined;
|
|
18
19
|
/**
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import * as fs from 'fs';
|
|
17
17
|
import * as path from 'path';
|
|
18
18
|
import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
|
+
import { encodePwshBase64 } from '../pwsh.js';
|
|
19
20
|
import { getCacheDir } from '../state.js';
|
|
20
21
|
import { hostNameFor } from './ssh-config.js';
|
|
21
22
|
/** Env var the askpass shim reads to know which bundle holds the password. */
|
|
@@ -38,15 +39,16 @@ export function sshTargetFor(device) {
|
|
|
38
39
|
/**
|
|
39
40
|
* Wrap a remote command for the device's shell. Windows devices speak
|
|
40
41
|
* PowerShell, so a bare command is run through `powershell -NoProfile
|
|
41
|
-
* -
|
|
42
|
-
* parses it). Returns undefined when no command was given (interactive
|
|
42
|
+
* -EncodedCommand`; POSIX devices get the command verbatim (the remote login
|
|
43
|
+
* shell parses it). Returns undefined when no command was given (interactive
|
|
44
|
+
* login).
|
|
43
45
|
*/
|
|
44
46
|
export function wrapRemoteCommand(device, cmd) {
|
|
45
47
|
if (cmd.length === 0)
|
|
46
48
|
return undefined;
|
|
47
49
|
const joined = cmd.join(' ');
|
|
48
50
|
if (device.shell === 'powershell') {
|
|
49
|
-
return `powershell -NoProfile -
|
|
51
|
+
return `powershell -NoProfile -EncodedCommand ${encodePwshBase64(joined)}`;
|
|
50
52
|
}
|
|
51
53
|
return joined;
|
|
52
54
|
}
|
package/dist/lib/doctor-diff.js
CHANGED
|
@@ -29,6 +29,8 @@ import { pluginInstallDir, repairableManifestFields } from './plugin-marketplace
|
|
|
29
29
|
import { markdownToToml } from './convert.js';
|
|
30
30
|
import { resolveImports, supportsRulesImports } from './rules/compile.js';
|
|
31
31
|
import { listCommandsInVersionHome, getVersionCommandsDir } from './commands.js';
|
|
32
|
+
import { shouldInstallCommandAsSkill, commandSkillMatches, commandSkillName } from './command-skills.js';
|
|
33
|
+
import { supports } from './capabilities.js';
|
|
32
34
|
import { listSkillsInVersionHome, getVersionSkillsDir } from './skills.js';
|
|
33
35
|
import { listHooksInVersionHome, listHookEntriesFromDir } from './hooks.js';
|
|
34
36
|
const RULES_DOC_FILENAME = 'README.md';
|
|
@@ -87,6 +89,15 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
|
|
|
87
89
|
const isToml = agentConfig.format === 'toml';
|
|
88
90
|
const ext = isToml ? '.toml' : '.md';
|
|
89
91
|
const homeDir = getVersionCommandsDir(agent, version);
|
|
92
|
+
// Command-as-skill agents (kimi, codex>=0.117, grok) install every command as a
|
|
93
|
+
// SKILL wrapper at <agentDir>/skills/<cmd>/SKILL.md, not a native command file.
|
|
94
|
+
// The compare must follow that or every command false-reports as drifted.
|
|
95
|
+
const asSkill = shouldInstallCommandAsSkill(agent, version);
|
|
96
|
+
// Agents that hold commands neither natively nor as skills (e.g. goose) must not
|
|
97
|
+
// report source commands as "missing" — they structurally can't take them.
|
|
98
|
+
if (!asSkill && !supports(agent, 'commands', version).ok)
|
|
99
|
+
return [];
|
|
100
|
+
const agentDir = path.join(getVersionHomePath(agent, version), agentConfigDirName(agent));
|
|
90
101
|
const installed = new Set(listCommandsInVersionHome(agent, version));
|
|
91
102
|
const layerBases = buildLayerBases(cwd, 'commands', { excludeProject });
|
|
92
103
|
const sourceByName = new Map();
|
|
@@ -113,11 +124,24 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
|
|
|
113
124
|
const seen = new Set();
|
|
114
125
|
for (const [name, src] of sourceByName) {
|
|
115
126
|
seen.add(name);
|
|
116
|
-
const homePath = path.join(homeDir, `${name}${ext}`);
|
|
117
127
|
if (!installed.has(name)) {
|
|
118
128
|
rows.push({ kind: 'commands', name, status: 'missing', source: src.layer, sourcePath: src.path });
|
|
119
129
|
continue;
|
|
120
130
|
}
|
|
131
|
+
if (asSkill) {
|
|
132
|
+
// Compare against the installed command-skill wrapper, not a native path.
|
|
133
|
+
const matches = commandSkillMatches(agentDir, name, src.path);
|
|
134
|
+
rows.push({
|
|
135
|
+
kind: 'commands',
|
|
136
|
+
name,
|
|
137
|
+
status: matches ? 'ok' : 'diff',
|
|
138
|
+
source: src.layer,
|
|
139
|
+
sourcePath: src.path,
|
|
140
|
+
homePath: path.join(agentDir, 'skills', commandSkillName(name), 'SKILL.md'),
|
|
141
|
+
});
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const homePath = path.join(homeDir, `${name}${ext}`);
|
|
121
145
|
const installedContent = readSafe(homePath);
|
|
122
146
|
const sourceContent = readSafe(src.path);
|
|
123
147
|
if (installedContent == null || sourceContent == null) {
|
|
@@ -138,7 +162,10 @@ function diffCommands(agent, version, cwd, excludeProject = false) {
|
|
|
138
162
|
for (const name of installed) {
|
|
139
163
|
if (seen.has(name))
|
|
140
164
|
continue;
|
|
141
|
-
|
|
165
|
+
const extraHome = asSkill
|
|
166
|
+
? path.join(agentDir, 'skills', commandSkillName(name), 'SKILL.md')
|
|
167
|
+
: path.join(homeDir, `${name}${ext}`);
|
|
168
|
+
rows.push({ kind: 'commands', name, status: 'extra', homePath: extraHome });
|
|
142
169
|
}
|
|
143
170
|
return rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
144
171
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive drift-sync flow — the single "we detected drift, want to fix it?"
|
|
3
|
+
* action, shared by `agents status`, `agents doctor`, and the menu-bar "NEEDS
|
|
4
|
+
* SYNC" row.
|
|
5
|
+
*
|
|
6
|
+
* It composes existing pieces, re-implementing nothing:
|
|
7
|
+
* - computeSyncStatus() — the unified detection engine (sync-status.ts)
|
|
8
|
+
* - pullRepo() — fast-forward the `.system` repo (git.ts)
|
|
9
|
+
* - promptAgentVersionSelection() — the "which agent types / versions?" picker
|
|
10
|
+
* - heal({ mode: 'full' }) — the reconcile engine `doctor --fix` uses
|
|
11
|
+
*
|
|
12
|
+
* Combined flow (one confirmation): if `.system` is behind AND resources drifted,
|
|
13
|
+
* a single "Sync all detected" both pulls `.system` and reconciles the chosen
|
|
14
|
+
* version homes. The security posture is preserved — the `.system` pull only ever
|
|
15
|
+
* happens on an explicit user choice here, never silently (see auto-pull-worker.ts
|
|
16
|
+
* for why system auto-pull is off by default).
|
|
17
|
+
*/
|
|
18
|
+
import { type VersionHealResult } from './heal.js';
|
|
19
|
+
import { type UnifiedSyncStatus } from './sync-status.js';
|
|
20
|
+
export interface DriftSyncOptions {
|
|
21
|
+
cwd?: string;
|
|
22
|
+
/** Reconcile everything detected with no prompts — the "kick it" path, also the
|
|
23
|
+
* non-TTY / menu-bar-launched-with-flag behavior. Pulls `.system` if behind. */
|
|
24
|
+
yes?: boolean;
|
|
25
|
+
/** Pre-computed status, to avoid a second scan when the caller already has one. */
|
|
26
|
+
status?: UnifiedSyncStatus;
|
|
27
|
+
/** Skip the drift summary — set by callers that already printed their own. */
|
|
28
|
+
quiet?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface DriftSyncResult {
|
|
31
|
+
systemBehindBefore: number;
|
|
32
|
+
systemPulled: boolean;
|
|
33
|
+
healed: VersionHealResult[];
|
|
34
|
+
/** True when the user declined at the prompt (nothing was changed). */
|
|
35
|
+
cancelled: boolean;
|
|
36
|
+
/** True when there was no drift to act on in the first place. */
|
|
37
|
+
nothingToDo: boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The unified "drift detected — sync now?" flow. Returns a structured result so
|
|
41
|
+
* callers (menu-bar, doctor) can report without re-scanning.
|
|
42
|
+
*/
|
|
43
|
+
export declare function promptDriftSync(opts?: DriftSyncOptions): Promise<DriftSyncResult>;
|