@phnx-labs/agents-cli 1.20.26 → 1.20.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/commands/doctor.d.ts +5 -2
  3. package/dist/commands/doctor.js +182 -30
  4. package/dist/commands/exec.d.ts +1 -1
  5. package/dist/commands/exec.js +177 -6
  6. package/dist/commands/hosts.d.ts +11 -0
  7. package/dist/commands/hosts.js +229 -0
  8. package/dist/commands/repo.d.ts +29 -0
  9. package/dist/commands/repo.js +174 -38
  10. package/dist/commands/secrets.d.ts +2 -7
  11. package/dist/commands/secrets.js +15 -23
  12. package/dist/commands/sessions.d.ts +2 -0
  13. package/dist/commands/sessions.js +27 -25
  14. package/dist/commands/sync.d.ts +2 -0
  15. package/dist/commands/sync.js +22 -5
  16. package/dist/commands/view.js +27 -11
  17. package/dist/index.js +4 -13
  18. package/dist/lib/agent-spec.d.ts +36 -0
  19. package/dist/lib/agent-spec.js +157 -0
  20. package/dist/lib/agents.d.ts +1 -0
  21. package/dist/lib/agents.js +45 -4
  22. package/dist/lib/browser/drivers/ssh.d.ts +47 -2
  23. package/dist/lib/browser/drivers/ssh.js +113 -24
  24. package/dist/lib/browser/profiles.js +28 -1
  25. package/dist/lib/browser/runtime-state.js +28 -8
  26. package/dist/lib/browser/types.d.ts +10 -1
  27. package/dist/lib/cli-resources.js +10 -1
  28. package/dist/lib/daemon.js +32 -0
  29. package/dist/lib/doctor-diff.d.ts +19 -0
  30. package/dist/lib/doctor-diff.js +107 -15
  31. package/dist/lib/exec.d.ts +27 -0
  32. package/dist/lib/exec.js +62 -19
  33. package/dist/lib/heal.d.ts +107 -0
  34. package/dist/lib/heal.js +279 -0
  35. package/dist/lib/hooks.d.ts +17 -0
  36. package/dist/lib/hooks.js +127 -3
  37. package/dist/lib/hosts/dispatch.d.ts +26 -0
  38. package/dist/lib/hosts/dispatch.js +71 -0
  39. package/dist/lib/hosts/progress.d.ts +21 -0
  40. package/dist/lib/hosts/progress.js +49 -0
  41. package/dist/lib/hosts/providers/local.d.ts +17 -0
  42. package/dist/lib/hosts/providers/local.js +81 -0
  43. package/dist/lib/hosts/ready.d.ts +37 -0
  44. package/dist/lib/hosts/ready.js +88 -0
  45. package/dist/lib/hosts/registry.d.ts +22 -0
  46. package/dist/lib/hosts/registry.js +65 -0
  47. package/dist/lib/hosts/ssh-config.d.ts +37 -0
  48. package/dist/lib/hosts/ssh-config.js +157 -0
  49. package/dist/lib/hosts/tasks.d.ts +32 -0
  50. package/dist/lib/hosts/tasks.js +58 -0
  51. package/dist/lib/hosts/types.d.ts +51 -0
  52. package/dist/lib/hosts/types.js +21 -0
  53. package/dist/lib/loop.d.ts +9 -0
  54. package/dist/lib/loop.js +13 -1
  55. package/dist/lib/mcp.js +12 -3
  56. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  57. package/dist/lib/migrate.js +9 -5
  58. package/dist/lib/platform/exec.d.ts +10 -0
  59. package/dist/lib/platform/exec.js +17 -0
  60. package/dist/lib/platform/index.d.ts +1 -0
  61. package/dist/lib/platform/index.js +1 -0
  62. package/dist/lib/platform/links.d.ts +15 -0
  63. package/dist/lib/platform/links.js +42 -0
  64. package/dist/lib/platform/paths.d.ts +18 -0
  65. package/dist/lib/platform/paths.js +22 -0
  66. package/dist/lib/platform/posixpath.d.ts +28 -0
  67. package/dist/lib/platform/posixpath.js +153 -0
  68. package/dist/lib/plugin-marketplace.d.ts +18 -0
  69. package/dist/lib/plugin-marketplace.js +67 -1
  70. package/dist/lib/plugins.d.ts +33 -1
  71. package/dist/lib/plugins.js +56 -11
  72. package/dist/lib/project-launch.js +6 -3
  73. package/dist/lib/sandbox.js +5 -2
  74. package/dist/lib/self-update.js +7 -2
  75. package/dist/lib/session/db.d.ts +23 -0
  76. package/dist/lib/session/db.js +76 -1
  77. package/dist/lib/session/discover.d.ts +26 -0
  78. package/dist/lib/session/discover.js +75 -4
  79. package/dist/lib/session/relative-time.d.ts +7 -0
  80. package/dist/lib/session/relative-time.js +28 -0
  81. package/dist/lib/session/remote.d.ts +61 -0
  82. package/dist/lib/session/remote.js +221 -0
  83. package/dist/lib/ssh-exec.d.ts +45 -0
  84. package/dist/lib/ssh-exec.js +61 -0
  85. package/dist/lib/staleness/detectors/commands.js +7 -6
  86. package/dist/lib/staleness/writers/commands.js +7 -12
  87. package/dist/lib/startup/command-registry.d.ts +1 -0
  88. package/dist/lib/startup/command-registry.js +2 -0
  89. package/dist/lib/startup/dev-build.d.ts +22 -0
  90. package/dist/lib/startup/dev-build.js +41 -0
  91. package/dist/lib/types.d.ts +28 -0
  92. package/dist/lib/versions.d.ts +9 -3
  93. package/dist/lib/versions.js +43 -7
  94. package/package.json +3 -3
  95. package/scripts/postinstall.js +62 -0
@@ -15,6 +15,7 @@ import * as path from 'path';
15
15
  import * as os from 'os';
16
16
  import * as TOML from 'smol-toml';
17
17
  import chalk from 'chalk';
18
+ import { needsWindowsShell } from './platform/index.js';
18
19
  import { latestFileMtimeMs } from './fs-walk.js';
19
20
  import { damerauLevenshtein } from './fuzzy.js';
20
21
  import { getCacheDir, getVersionsDir, getShimsDir, getCliVersionCachePath } from './state.js';
@@ -296,6 +297,7 @@ export const AGENTS = {
296
297
  commandsDir: '', // OpenClaw uses Gateway-based slash commands, not file-based
297
298
  commandsSubdir: '',
298
299
  skillsDir: path.join(HOME, '.openclaw', 'skills'),
300
+ nativeCommandRuntime: true, // Gateway resolves slash commands — don't convert commands to skills
299
301
  hooksDir: 'hooks',
300
302
  instructionsFile: 'workspace/AGENTS.md', // Primary memory file (also has SOUL.md, IDENTITY.md, etc.)
301
303
  format: 'markdown',
@@ -781,6 +783,7 @@ export async function getAccountInfo(agentId, home) {
781
783
  usageStatus: null,
782
784
  overageCredits: null,
783
785
  lastActive: null,
786
+ signedIn: false,
784
787
  };
785
788
  const configFiles = {
786
789
  claude: path.join(base, '.claude.json'),
@@ -852,6 +855,7 @@ export async function getAccountInfo(agentId, home) {
852
855
  usageStatus,
853
856
  overageCredits,
854
857
  lastActive,
858
+ signedIn: !!email,
855
859
  };
856
860
  }
857
861
  case 'codex': {
@@ -893,11 +897,13 @@ export async function getAccountInfo(agentId, home) {
893
897
  usageStatus,
894
898
  overageCredits: null,
895
899
  lastActive,
900
+ signedIn: !!email,
896
901
  };
897
902
  }
898
903
  case 'gemini': {
899
904
  const data = JSON.parse(await fs.promises.readFile(path.join(base, '.gemini', 'google_accounts.json'), 'utf-8'));
900
- return { ...empty, email: data.active || null, lastActive };
905
+ const email = data.active || null;
906
+ return { ...empty, email, signedIn: !!email, lastActive };
901
907
  }
902
908
  case 'grok': {
903
909
  // Grok stores auth in ~/.grok/auth.json
@@ -906,12 +912,44 @@ export async function getAccountInfo(agentId, home) {
906
912
  if (fs.existsSync(authPath)) {
907
913
  const data = JSON.parse(await fs.promises.readFile(authPath, 'utf-8'));
908
914
  const email = data.email || data.user?.email || data.account?.email || null;
909
- return { ...empty, email, lastActive };
915
+ return { ...empty, email, signedIn: !!email, lastActive };
910
916
  }
911
917
  }
912
918
  catch { }
913
919
  return { ...empty, lastActive };
914
920
  }
921
+ case 'antigravity': {
922
+ // Antigravity (`agy`) stores a Google OAuth token at
923
+ // ~/.gemini/antigravity-cli/antigravity-oauth-token. It's a consumer
924
+ // OAuth grant (access + refresh token, no id_token), so there's no email
925
+ // claim to read locally — presence of a refresh token is the only
926
+ // signed-in signal we can derive without a network call.
927
+ const tokenPath = path.join(base, '.gemini', 'antigravity-cli', 'antigravity-oauth-token');
928
+ if (!fs.existsSync(tokenPath))
929
+ return { ...empty, lastActive };
930
+ const data = JSON.parse(await fs.promises.readFile(tokenPath, 'utf-8'));
931
+ const hasToken = typeof data?.token?.refresh_token === 'string' && !!data.token.refresh_token;
932
+ if (!hasToken)
933
+ return { ...empty, lastActive };
934
+ return { ...empty, signedIn: true, lastActive };
935
+ }
936
+ case 'kimi': {
937
+ // Kimi Code stores OAuth credentials at
938
+ // ~/.kimi-code/credentials/kimi-code.json. The access token is a JWT
939
+ // whose payload carries an opaque user_id (no email), so we report
940
+ // signed-in state plus a stable account key for usage dedup.
941
+ const credPath = path.join(base, '.kimi-code', 'credentials', 'kimi-code.json');
942
+ if (!fs.existsSync(credPath))
943
+ return { ...empty, lastActive };
944
+ const data = JSON.parse(await fs.promises.readFile(credPath, 'utf-8'));
945
+ const accessToken = data?.access_token;
946
+ if (typeof accessToken !== 'string' || !accessToken)
947
+ return { ...empty, lastActive };
948
+ const decoded = decodeJwtPayload(accessToken);
949
+ const userId = normalizeIdentityPart(decoded?.user_id ?? decoded?.sub);
950
+ const accountKey = buildIdentityKey(agentId, [['user', userId]]);
951
+ return { ...empty, signedIn: true, accountId: userId, accountKey, lastActive };
952
+ }
915
953
  default:
916
954
  return { ...empty, lastActive };
917
955
  }
@@ -1150,7 +1188,10 @@ export async function registerMcp(agentId, name, command, scope = 'user', transp
1150
1188
  }
1151
1189
  // When home is specified, override HOME so MCP config writes to the version's config dir
1152
1190
  const env = options?.home ? { ...process.env, HOME: options.home } : undefined;
1153
- await execFileAsync(bin, args, env ? { env } : undefined);
1191
+ // On Windows a bare command name / `.cmd` wrapper (the npm-installed agent
1192
+ // CLI) can't be exec'd directly — it needs shell:true for PATHEXT/cmd. Off
1193
+ // Windows this is always false, so the no-shell argv path is unchanged.
1194
+ await execFileAsync(bin, args, { ...(env ? { env } : {}), shell: needsWindowsShell(bin) });
1154
1195
  return { success: true };
1155
1196
  }
1156
1197
  catch (err) {
@@ -1169,7 +1210,7 @@ export async function unregisterMcp(agentId, name, options) {
1169
1210
  try {
1170
1211
  const bin = options?.binary || agent.cliCommand;
1171
1212
  const env = options?.home ? { ...process.env, HOME: options.home } : undefined;
1172
- await execFileAsync(bin, ['mcp', 'remove', name], env ? { env } : undefined);
1213
+ await execFileAsync(bin, ['mcp', 'remove', name], { ...(env ? { env } : {}), shell: needsWindowsShell(bin) });
1173
1214
  return { success: true };
1174
1215
  }
1175
1216
  catch (err) {
@@ -1,11 +1,56 @@
1
1
  import { CDPClient } from '../cdp.js';
2
2
  import type { BrowserProfile } from '../types.js';
3
+ import { shellQuote } from '../../ssh-exec.js';
4
+ export { shellQuote };
3
5
  export interface SSHConnection {
4
6
  cdp: CDPClient;
5
7
  port: number;
6
8
  pid: number;
7
9
  cleanup: () => void;
8
10
  }
9
- export declare function shellQuote(s: string): string;
11
+ /**
12
+ * Which shell dialect the *remote* host speaks. Selected per-endpoint via the
13
+ * `&os=windows` query param on an `ssh://` target. POSIX is the default — the
14
+ * historical behavior for macOS/Linux remotes. Windows remotes run OpenSSH
15
+ * Server with cmd.exe as the default shell, so the launch/teardown command
16
+ * strings differ (no `&` backgrounding, no `lsof`, `.exe` instead of `.app`).
17
+ */
18
+ export type RemoteOs = 'windows' | 'posix';
10
19
  export declare function connectSSH(endpoint: string, profile: BrowserProfile): Promise<SSHConnection>;
11
- export declare function restartRemoteBrowser(user: string, host: string, browserType: string, port: number, customBinary?: string): Promise<void>;
20
+ /**
21
+ * Wrap a PowerShell script as a `-EncodedCommand` invocation. Base64 of the
22
+ * UTF-16LE bytes is a single quote-free token, so it rides through Node spawn
23
+ * → Windows sshd → cmd.exe with zero escaping hazards (hand-quoted
24
+ * `powershell -Command "…"` is fragile the moment a path or URL is involved).
25
+ */
26
+ export declare function encodePowerShell(script: string): string;
27
+ /**
28
+ * The PowerShell that launches the browser on a Windows remote. Two hard
29
+ * requirements shaped this:
30
+ * 1. The browser must OUTLIVE the ssh session. Windows OpenSSH terminates
31
+ * the session's job tree on disconnect, which reaps both `start /B` and
32
+ * `Start-Process` children (verified against a real box). WMI
33
+ * `Win32_Process.Create` spawns under the WMI provider service instead,
34
+ * so the process survives after we drop the ssh connection and reconnect
35
+ * over the CDP tunnel.
36
+ * 2. A distinct `--user-data-dir` so a fresh instance bound to the debugging
37
+ * port comes up even when the user already has Edge open.
38
+ * CreateProcess ignores App Paths, so we resolve the real `.exe` from the
39
+ * registry at runtime rather than relying on a bare `msedge` name.
40
+ */
41
+ export declare function buildWindowsLaunchScript(browserType: string, port: number, customBinary?: string): string;
42
+ /** The PowerShell that kills whatever holds the CDP port on a Windows remote. */
43
+ export declare function buildWindowsKillScript(port: number): string;
44
+ /**
45
+ * Build the remote command that launches the browser detached with a CDP port.
46
+ * POSIX backgrounds the `.app` binary with `… &`; Windows resolves the exe and
47
+ * spawns it via WMI (encoded PowerShell) so it survives the ssh session.
48
+ */
49
+ export declare function buildLaunchCmd(remoteOs: RemoteOs, browserType: string, port: number, customBinary?: string): string;
50
+ /**
51
+ * Build the remote command that kills whatever holds the CDP port.
52
+ * POSIX uses `lsof`+`kill`; Windows uses encoded PowerShell
53
+ * (Get-NetTCPConnection → Stop-Process).
54
+ */
55
+ export declare function buildKillCmd(remoteOs: RemoteOs, port: number): string;
56
+ export declare function restartRemoteBrowser(user: string, host: string, browserType: string, port: number, remoteOs: RemoteOs, customBinary?: string): Promise<void>;
@@ -4,11 +4,10 @@ import { CDPClient, discoverBrowserWsUrl, verifyBrowserIdentity } from '../cdp.j
4
4
  import { getPortOccupant } from '../chrome.js';
5
5
  import { parseEndpointUrl } from '../profiles.js';
6
6
  import { writeProfileRuntime, clearProfileRuntime } from '../runtime-state.js';
7
- export function shellQuote(s) {
8
- if (/^[A-Za-z0-9_./:=@%+-]+$/.test(s))
9
- return s;
10
- return "'" + s.replace(/'/g, "'\\''") + "'";
11
- }
7
+ // shellQuote lives in the shared ssh-exec helper (single choke point); re-export
8
+ // so existing importers of `shellQuote` from this module keep working.
9
+ import { shellQuote } from '../../ssh-exec.js';
10
+ export { shellQuote };
12
11
  export async function connectSSH(endpoint, profile) {
13
12
  const url = new URL(endpoint);
14
13
  if (url.protocol !== 'ssh:') {
@@ -24,6 +23,11 @@ export async function connectSSH(endpoint, profile) {
24
23
  }
25
24
  const host = parsed.host;
26
25
  const remotePort = parsed.port;
26
+ // `&os=windows` switches the remote-command dialect (cmd.exe launch via
27
+ // `start`, taskkill teardown). Anything else — including absent — is posix.
28
+ // The query param is the single source of truth so the driver never has to
29
+ // be threaded a separate per-profile field.
30
+ const remoteOs = (url.searchParams.get('os') || '').toLowerCase() === 'windows' ? 'windows' : 'posix';
27
31
  // Bind the tunnel to the SAME local port the user configured. Using an
28
32
  // allocated port instead made `status` print confusing rows like
29
33
  // `port 9200 (configured 10005)` and made it impossible to predict which
@@ -40,7 +44,7 @@ export async function connectSSH(endpoint, profile) {
40
44
  `Either kill that process (\`kill ${occupant.pid}\`) or change the profile's port.`);
41
45
  }
42
46
  try {
43
- await ensureRemoteBrowser(user, host, profile.browser, remotePort, profile.binary);
47
+ await ensureRemoteBrowser(user, host, profile.browser, remotePort, remoteOs, profile.binary);
44
48
  }
45
49
  catch {
46
50
  // Browser may already be running, continue
@@ -156,28 +160,100 @@ function tryConnect(port) {
156
160
  socket.on('error', reject);
157
161
  });
158
162
  }
159
- async function ensureRemoteBrowser(user, host, browserType, port, customBinary) {
160
- const browserPaths = {
161
- chrome: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
162
- comet: '/Applications/Comet.app/Contents/MacOS/Comet',
163
- chromium: '/Applications/Chromium.app/Contents/MacOS/Chromium',
164
- brave: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
165
- edge: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
166
- };
167
- let browserPath;
163
+ // macOS .app launchers the historical POSIX table.
164
+ const POSIX_BROWSER_PATHS = {
165
+ chrome: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
166
+ comet: '/Applications/Comet.app/Contents/MacOS/Comet',
167
+ chromium: '/Applications/Chromium.app/Contents/MacOS/Chromium',
168
+ brave: '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
169
+ edge: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
170
+ };
171
+ // Windows App Paths registry keys per browser. CreateProcess (used by WMI
172
+ // Win32_Process.Create) does not honor App Paths the way ShellExecute/`start`
173
+ // does, so the launch script resolves the real `.exe` path from this registry
174
+ // key at runtime — covering both Program Files and Program Files (x86)
175
+ // installs without hardcoding (or guessing) the location.
176
+ const WIN_BROWSER_APPPATH = {
177
+ chrome: 'chrome.exe',
178
+ chromium: 'chrome.exe',
179
+ brave: 'brave.exe',
180
+ edge: 'msedge.exe',
181
+ };
182
+ /** Single-quote a string for embedding inside a PowerShell literal. */
183
+ function psSingleQuote(s) {
184
+ return "'" + s.replace(/'/g, "''") + "'";
185
+ }
186
+ /**
187
+ * Wrap a PowerShell script as a `-EncodedCommand` invocation. Base64 of the
188
+ * UTF-16LE bytes is a single quote-free token, so it rides through Node spawn
189
+ * → Windows sshd → cmd.exe with zero escaping hazards (hand-quoted
190
+ * `powershell -Command "…"` is fragile the moment a path or URL is involved).
191
+ */
192
+ export function encodePowerShell(script) {
193
+ const b64 = Buffer.from(script, 'utf16le').toString('base64');
194
+ return `powershell -NoProfile -EncodedCommand ${b64}`;
195
+ }
196
+ /**
197
+ * The PowerShell that launches the browser on a Windows remote. Two hard
198
+ * requirements shaped this:
199
+ * 1. The browser must OUTLIVE the ssh session. Windows OpenSSH terminates
200
+ * the session's job tree on disconnect, which reaps both `start /B` and
201
+ * `Start-Process` children (verified against a real box). WMI
202
+ * `Win32_Process.Create` spawns under the WMI provider service instead,
203
+ * so the process survives after we drop the ssh connection and reconnect
204
+ * over the CDP tunnel.
205
+ * 2. A distinct `--user-data-dir` so a fresh instance bound to the debugging
206
+ * port comes up even when the user already has Edge open.
207
+ * CreateProcess ignores App Paths, so we resolve the real `.exe` from the
208
+ * registry at runtime rather than relying on a bare `msedge` name.
209
+ */
210
+ export function buildWindowsLaunchScript(browserType, port, customBinary) {
211
+ let exeExpr;
168
212
  if (customBinary) {
169
- browserPath = customBinary;
213
+ exeExpr = psSingleQuote(customBinary);
170
214
  }
171
215
  else if (browserType === 'custom') {
172
216
  throw new Error('browser: custom requires a binary path in the profile');
173
217
  }
174
218
  else {
175
- browserPath = browserPaths[browserType];
176
- if (!browserPath) {
177
- throw new Error(`Unknown browser type: ${browserType}`);
219
+ const exeKey = WIN_BROWSER_APPPATH[browserType];
220
+ if (!exeKey)
221
+ throw new Error(`Unknown browser type for windows remote: ${browserType}`);
222
+ exeExpr =
223
+ `(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\${exeKey}').'(default)'`;
224
+ }
225
+ // Keep the `--remote-allow-origins=http://127.0.0.1:${port}` literal in
226
+ // source — a test asserts CDP is never opened to `*`.
227
+ return [
228
+ `$exe = ${exeExpr}`,
229
+ `$cl = '"' + $exe + '" --remote-debugging-port=${port}` +
230
+ ` --remote-allow-origins=http://127.0.0.1:${port}` +
231
+ ` --disable-background-timer-throttling --user-data-dir="' + $env:TEMP + '\\agents-browser-${port}"'`,
232
+ `Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = $cl } | Out-Null`,
233
+ ].join('; ');
234
+ }
235
+ /** The PowerShell that kills whatever holds the CDP port on a Windows remote. */
236
+ export function buildWindowsKillScript(port) {
237
+ return (`Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue ` +
238
+ `| ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }`);
239
+ }
240
+ /**
241
+ * Build the remote command that launches the browser detached with a CDP port.
242
+ * POSIX backgrounds the `.app` binary with `… &`; Windows resolves the exe and
243
+ * spawns it via WMI (encoded PowerShell) so it survives the ssh session.
244
+ */
245
+ export function buildLaunchCmd(remoteOs, browserType, port, customBinary) {
246
+ if (remoteOs === 'windows') {
247
+ return encodePowerShell(buildWindowsLaunchScript(browserType, port, customBinary));
248
+ }
249
+ const browserPath = customBinary ?? POSIX_BROWSER_PATHS[browserType];
250
+ if (!browserPath) {
251
+ if (browserType === 'custom') {
252
+ throw new Error('browser: custom requires a binary path in the profile');
178
253
  }
254
+ throw new Error(`Unknown browser type for posix remote: ${browserType}`);
179
255
  }
180
- const remoteCmd = [
256
+ return [
181
257
  shellQuote(browserPath),
182
258
  `--remote-debugging-port=${port}`,
183
259
  shellQuote(`--remote-allow-origins=http://127.0.0.1:${port}`),
@@ -185,6 +261,20 @@ async function ensureRemoteBrowser(user, host, browserType, port, customBinary)
185
261
  `--user-data-dir=/tmp/agents-browser-${port}`,
186
262
  '</dev/null >/dev/null 2>&1 &',
187
263
  ].join(' ');
264
+ }
265
+ /**
266
+ * Build the remote command that kills whatever holds the CDP port.
267
+ * POSIX uses `lsof`+`kill`; Windows uses encoded PowerShell
268
+ * (Get-NetTCPConnection → Stop-Process).
269
+ */
270
+ export function buildKillCmd(remoteOs, port) {
271
+ if (remoteOs === 'windows') {
272
+ return encodePowerShell(buildWindowsKillScript(port));
273
+ }
274
+ return `pids=$(lsof -ti ${shellQuote(`:${port}`)} 2>/dev/null); [ -z "$pids" ] || kill -9 $pids 2>/dev/null || true`;
275
+ }
276
+ async function ensureRemoteBrowser(user, host, browserType, port, remoteOs, customBinary) {
277
+ const remoteCmd = buildLaunchCmd(remoteOs, browserType, port, customBinary);
188
278
  return new Promise((resolve, reject) => {
189
279
  const child = spawn('ssh', [
190
280
  `${user}@${host}`,
@@ -200,12 +290,11 @@ async function ensureRemoteBrowser(user, host, browserType, port, customBinary)
200
290
  }, 2000);
201
291
  });
202
292
  }
203
- export async function restartRemoteBrowser(user, host, browserType, port, customBinary) {
293
+ export async function restartRemoteBrowser(user, host, browserType, port, remoteOs, customBinary) {
204
294
  // Kill any process using the remote debugging port
205
- const killCmd = `pids=$(lsof -ti ${shellQuote(`:${port}`)} 2>/dev/null); [ -z "$pids" ] || kill -9 $pids 2>/dev/null || true`;
206
- await runSSHCommand(user, host, killCmd);
295
+ await runSSHCommand(user, host, buildKillCmd(remoteOs, port));
207
296
  await sleep(500);
208
- await ensureRemoteBrowser(user, host, browserType, port, customBinary);
297
+ await ensureRemoteBrowser(user, host, browserType, port, remoteOs, customBinary);
209
298
  await sleep(1500);
210
299
  }
211
300
  function runSSHCommand(user, host, cmd) {
@@ -194,6 +194,27 @@ function hasSshEndpoint(endpoints) {
194
194
  }
195
195
  });
196
196
  }
197
+ /**
198
+ * True when any endpoint is an `ssh://…?os=windows` target — i.e. the browser
199
+ * lives on a remote Windows host. Such a profile's binary (`msedge.exe`) will
200
+ * never exist on this Mac, so create-time local-binary validation must be
201
+ * skipped; the binary is resolved on the remote at connect time instead.
202
+ */
203
+ function hasRemoteWindowsEndpoint(endpoints) {
204
+ const targets = Array.isArray(endpoints)
205
+ ? endpoints
206
+ : Object.values(endpoints).map((preset) => preset.target);
207
+ return targets.some((target) => {
208
+ try {
209
+ const url = new URL(target);
210
+ return (url.protocol === 'ssh:' &&
211
+ (url.searchParams.get('os') || '').toLowerCase() === 'windows');
212
+ }
213
+ catch {
214
+ return false;
215
+ }
216
+ });
217
+ }
197
218
  export async function createProfile(profile) {
198
219
  const meta = readMeta();
199
220
  if (meta.browser?.[profile.name]) {
@@ -220,7 +241,13 @@ export async function createProfile(profile) {
220
241
  // error ("Comet not installed at /Applications/Comet.app") rather than
221
242
  // deferring the failure to the first task. `findBrowserPath` short-circuits
222
243
  // for browser=custom without a binary by throwing — same outcome.
223
- findBrowserPath(profile.browser, profile.binary);
244
+ //
245
+ // Skip for remote-Windows profiles: the browser is `msedge.exe` on the
246
+ // remote box, never on this Mac, so a local lookup would always (wrongly)
247
+ // fail. The remote launcher resolves it at connect time via App Paths.
248
+ if (!hasRemoteWindowsEndpoint(profile.endpoints)) {
249
+ findBrowserPath(profile.browser, profile.binary);
250
+ }
224
251
  meta.browser = meta.browser ?? {};
225
252
  meta.browser[profile.name] = profileToConfig(profile);
226
253
  writeMeta(meta);
@@ -248,20 +248,40 @@ export function reapOrphanedProcesses() {
248
248
  return { reaped, details };
249
249
  }
250
250
  function matchesCommand(pid, expectedCommand) {
251
+ const out = liveProcessCommand(pid);
252
+ if (!out)
253
+ return false;
254
+ // Match on the basename only — `/Applications/Comet.app/Contents/MacOS/Comet`
255
+ // vs the recorded `Comet`, vs `Google\ Chrome`, vs Windows `chrome.exe`.
256
+ // Case-insensitive.
257
+ const live = path.basename(out).toLowerCase();
258
+ const want = path.basename(expectedCommand).toLowerCase();
259
+ return live === want || live.startsWith(want) || want.startsWith(live);
260
+ }
261
+ /**
262
+ * The executable/image name the live `pid` is running, or null if it can't be
263
+ * determined. The process-listing API differs per OS: Windows has no `ps`, so
264
+ * we query `tasklist` (CSV image name in column 1); POSIX uses `ps -o comm=`.
265
+ */
266
+ function liveProcessCommand(pid) {
251
267
  try {
268
+ if (process.platform === 'win32') {
269
+ const out = execFileSync('tasklist', ['/FI', `PID eq ${pid}`, '/NH', '/FO', 'CSV'], {
270
+ encoding: 'utf-8',
271
+ stdio: ['ignore', 'pipe', 'ignore'],
272
+ }).trim();
273
+ // Rows look like: "node.exe","1234","Console","1","12,345 K"
274
+ // A no-match prints an "INFO: No tasks..." line that won't match the regex.
275
+ const m = out.match(/^"([^"]+)"/);
276
+ return m ? m[1] : null;
277
+ }
252
278
  const out = execFileSync('ps', ['-p', String(pid), '-o', 'comm='], {
253
279
  encoding: 'utf-8',
254
280
  stdio: ['ignore', 'pipe', 'ignore'],
255
281
  }).trim();
256
- if (!out)
257
- return false;
258
- // Match on the basename only — `/Applications/Comet.app/Contents/MacOS/Comet`
259
- // vs the recorded `Comet`, vs `Google\ Chrome`. Case-insensitive.
260
- const live = path.basename(out).toLowerCase();
261
- const want = path.basename(expectedCommand).toLowerCase();
262
- return live === want || live.startsWith(want) || want.startsWith(live);
282
+ return out || null;
263
283
  }
264
284
  catch {
265
- return false;
285
+ return null;
266
286
  }
267
287
  }
@@ -7,7 +7,16 @@ export type BrowserType = 'chrome' | 'comet' | 'chromium' | 'brave' | 'edge' | '
7
7
  * Per-endpoint overrides take precedence over profile-level fields.
8
8
  */
9
9
  export interface EndpointPreset {
10
- /** CDP URL — `cdp://host:port` or `ssh://host?port=N` */
10
+ /**
11
+ * CDP URL — `cdp://host:port` or `ssh://host?port=N`.
12
+ *
13
+ * For an SSH target whose remote is Windows, append `&os=windows` (e.g.
14
+ * `ssh://user@host?port=9222&os=windows`). The driver then speaks the
15
+ * Windows dialect (launch via WMI Win32_Process.Create so the browser
16
+ * survives the ssh session, teardown via Get-NetTCPConnection/Stop-Process)
17
+ * instead of the POSIX default. The query param is the single source of
18
+ * truth for remote-OS selection.
19
+ */
11
20
  target: string;
12
21
  /** Override the profile-level binary (e.g. a remote host has no local binary). */
13
22
  binary?: string;
@@ -116,7 +116,16 @@ export function parseCheckSpec(raw, defaultName) {
116
116
  * decide whether to surface or swallow the error per file.
117
117
  */
118
118
  export function parseCliManifest(contents, opts) {
119
- const raw = yaml.parse(contents);
119
+ // Tolerant parse. A manifest may legitimately carry OS-specific strings — a
120
+ // Windows path like `C:\Users\...` embedded in a double-quoted YAML scalar
121
+ // trips YAML's escape rules (`\U` is an invalid escape) and makes the strict
122
+ // `yaml.parse` throw a parser error. That throw must NOT pre-empt the
123
+ // security validation below: the per-field allowlist checks (unsafe tokens,
124
+ // non-https URLs, path traversal) are the authoritative gate on a hostile
125
+ // manifest. parseDocument collects those escape errors instead of throwing
126
+ // and still recovers the scalar values, so the dangerous content reaches
127
+ // assertSafeCheckToken / assertNpmPackage and is rejected on its merits.
128
+ const raw = yaml.parseDocument(contents, { strict: false }).toJS();
120
129
  if (!raw || typeof raw !== 'object') {
121
130
  throw new Error('manifest must be a YAML object');
122
131
  }
@@ -260,6 +260,36 @@ export async function runDaemon() {
260
260
  };
261
261
  const syncInterval = setInterval(() => { void runSessionSync(); }, 90_000);
262
262
  void runSessionSync(); // kick once at startup
263
+ // Resource safety check: heal gaps between what DotAgents repos define and
264
+ // what's actually installed in each agent home — the slow rot that nothing
265
+ // else catches (a non-default version left stale, a Claude-invalid plugin
266
+ // manifest silently rejecting a whole plugin). Conservative 'safe' mode: it
267
+ // fills missing resources, repairs invalid manifests, and fast-forwards
268
+ // provably-unmodified stale plugins, but never overwrites hand-edited content
269
+ // or a plugin it can't prove is pristine — those it reports for `doctor --fix`.
270
+ // Runs ~every 6h plus once ~30s after startup (staggered so launch isn't busy).
271
+ let healing = false;
272
+ const runHealCheck = async () => {
273
+ if (healing)
274
+ return;
275
+ healing = true;
276
+ try {
277
+ const { heal, summarizeHeal, notifyHeal, healChangedAnything } = await import('./heal.js');
278
+ const result = await heal({ mode: 'safe' });
279
+ if (healChangedAnything(result) || result.skippedPlugins.length > 0) {
280
+ log('INFO', `heal: ${summarizeHeal(result)}`);
281
+ notifyHeal(result);
282
+ }
283
+ }
284
+ catch (err) {
285
+ log('ERROR', `heal check failed: ${err.message}`);
286
+ }
287
+ finally {
288
+ healing = false;
289
+ }
290
+ };
291
+ const healInterval = setInterval(() => { void runHealCheck(); }, 6 * 60 * 60_000);
292
+ const healKickoff = setTimeout(() => { void runHealCheck(); }, 30_000);
263
293
  const handleReload = () => {
264
294
  log('INFO', 'Reloading jobs (SIGHUP)');
265
295
  scheduler.reloadAll();
@@ -275,6 +305,8 @@ export async function runDaemon() {
275
305
  await browserIPC.stop();
276
306
  clearInterval(monitorInterval);
277
307
  clearInterval(syncInterval);
308
+ clearInterval(healInterval);
309
+ clearTimeout(healKickoff);
278
310
  removeDaemonPid();
279
311
  process.exit(0);
280
312
  };
@@ -20,6 +20,7 @@
20
20
  * against the compiled artifact, not the raw source file.
21
21
  */
22
22
  import type { AgentId } from './types.js';
23
+ import type { DiscoveredPlugin } from './types.js';
23
24
  export type DoctorKind = 'commands' | 'skills' | 'hooks' | 'rules' | 'mcp' | 'permissions' | 'subagents' | 'plugins' | 'promptcuts';
24
25
  export type DiffStatus = 'ok' | 'diff' | 'missing' | 'extra';
25
26
  export type SourceLayer = 'project' | 'user' | 'system' | 'extra';
@@ -32,6 +33,9 @@ export interface ResourceDiff {
32
33
  sourcePath?: string;
33
34
  /** Absolute path to the file/dir inside the version home (when present). */
34
35
  homePath?: string;
36
+ /** Human-readable specifics for a divergent row — e.g. for a stale plugin:
37
+ * "0.6.1→0.7.0, missing skills: ship, learn". Currently set for plugins. */
38
+ detail?: string;
35
39
  }
36
40
  export interface VersionResourceReport {
37
41
  agent: AgentId;
@@ -55,10 +59,25 @@ export interface VersionResourceReport {
55
59
  extra: number;
56
60
  };
57
61
  }
62
+ /**
63
+ * Describe how a version's marketplace MIRROR of a plugin diverges from its
64
+ * central source — the detail presence-only checks miss. Surfaces a stale mirror
65
+ * version, a Claude-invalid manifest, and (the part users care about) the
66
+ * plugin's own skills/commands that never made it into the mirror. Returns null
67
+ * when the mirror faithfully matches source.
68
+ */
69
+ export declare function describePluginDrift(central: DiscoveredPlugin, mirrorDir: string): string | null;
58
70
  export interface DiffOptions {
59
71
  cwd?: string;
60
72
  /** Restrict to specific kinds; undefined = all. */
61
73
  kinds?: DoctorKind[];
74
+ /**
75
+ * Drop the project (`<cwd>/.agents/`) layer from resolution. Used by the heal
76
+ * path: the GLOBAL version home is only ever reconciled against user/system/
77
+ * extra sources — project resources are layered at launch, never synced into
78
+ * the global home, so counting them as "missing" there is a false gap.
79
+ */
80
+ excludeProject?: boolean;
62
81
  }
63
82
  export declare function diffVersionResources(agent: AgentId, version: string, options?: DiffOptions): VersionResourceReport;
64
83
  export declare const DOCTOR_ALL_KINDS: DoctorKind[];