@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.
Files changed (159) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +32 -2
  3. package/dist/commands/computer-actions.d.ts +15 -0
  4. package/dist/commands/computer-actions.js +120 -22
  5. package/dist/commands/computer.d.ts +23 -0
  6. package/dist/commands/computer.js +45 -3
  7. package/dist/commands/cost.js +17 -12
  8. package/dist/commands/doctor.d.ts +11 -0
  9. package/dist/commands/doctor.js +108 -14
  10. package/dist/commands/events.d.ts +16 -0
  11. package/dist/commands/events.js +173 -0
  12. package/dist/commands/exec.js +6 -1
  13. package/dist/commands/hosts.js +28 -24
  14. package/dist/commands/import.js +1 -1
  15. package/dist/commands/inspect.d.ts +2 -0
  16. package/dist/commands/inspect.js +54 -7
  17. package/dist/commands/logs.d.ts +17 -0
  18. package/dist/commands/logs.js +139 -0
  19. package/dist/commands/models.d.ts +3 -0
  20. package/dist/commands/models.js +27 -8
  21. package/dist/commands/permissions.js +9 -2
  22. package/dist/commands/repo.d.ts +34 -0
  23. package/dist/commands/repo.js +243 -65
  24. package/dist/commands/resource-view.d.ts +20 -0
  25. package/dist/commands/resource-view.js +90 -28
  26. package/dist/commands/rules.js +1 -1
  27. package/dist/commands/secrets-migrate.js +23 -11
  28. package/dist/commands/secrets.d.ts +20 -0
  29. package/dist/commands/secrets.js +93 -17
  30. package/dist/commands/sessions-resume.d.ts +2 -0
  31. package/dist/commands/sessions-resume.js +209 -0
  32. package/dist/commands/sessions-tail.d.ts +10 -0
  33. package/dist/commands/sessions-tail.js +11 -0
  34. package/dist/commands/sessions.d.ts +73 -1
  35. package/dist/commands/sessions.js +273 -53
  36. package/dist/commands/status.d.ts +12 -0
  37. package/dist/commands/status.js +81 -0
  38. package/dist/commands/teams.js +70 -6
  39. package/dist/commands/versions.js +2 -1
  40. package/dist/commands/view.d.ts +42 -0
  41. package/dist/commands/view.js +213 -83
  42. package/dist/commands/wallet.d.ts +6 -0
  43. package/dist/commands/wallet.js +22 -5
  44. package/dist/index.js +61 -32
  45. package/dist/lib/acp/harnesses.d.ts +1 -1
  46. package/dist/lib/acp/harnesses.js +2 -2
  47. package/dist/lib/agents.d.ts +12 -0
  48. package/dist/lib/agents.js +145 -41
  49. package/dist/lib/browser/cdp.js +5 -1
  50. package/dist/lib/browser/chrome.js +20 -0
  51. package/dist/lib/browser/drivers/ssh.d.ts +27 -0
  52. package/dist/lib/browser/drivers/ssh.js +94 -19
  53. package/dist/lib/browser/ipc.d.ts +3 -0
  54. package/dist/lib/browser/ipc.js +13 -9
  55. package/dist/lib/browser/service.js +71 -15
  56. package/dist/lib/daemon.d.ts +35 -0
  57. package/dist/lib/daemon.js +33 -5
  58. package/dist/lib/devices/connect.d.ts +3 -2
  59. package/dist/lib/devices/connect.js +5 -3
  60. package/dist/lib/doctor-diff.js +29 -2
  61. package/dist/lib/drift-sync.d.ts +43 -0
  62. package/dist/lib/drift-sync.js +179 -0
  63. package/dist/lib/events.d.ts +9 -2
  64. package/dist/lib/events.js +70 -11
  65. package/dist/lib/exec.d.ts +15 -0
  66. package/dist/lib/exec.js +43 -11
  67. package/dist/lib/hooks.js +8 -2
  68. package/dist/lib/hosts/dispatch.js +6 -7
  69. package/dist/lib/hosts/logs.d.ts +16 -0
  70. package/dist/lib/hosts/logs.js +45 -0
  71. package/dist/lib/hosts/progress.d.ts +66 -0
  72. package/dist/lib/hosts/progress.js +125 -17
  73. package/dist/lib/hosts/ready.d.ts +23 -2
  74. package/dist/lib/hosts/ready.js +35 -13
  75. package/dist/lib/hosts/reconcile.d.ts +53 -0
  76. package/dist/lib/hosts/reconcile.js +81 -0
  77. package/dist/lib/hosts/tasks.d.ts +8 -0
  78. package/dist/lib/hosts/tasks.js +14 -0
  79. package/dist/lib/permissions.d.ts +4 -0
  80. package/dist/lib/permissions.js +35 -0
  81. package/dist/lib/picker.d.ts +24 -0
  82. package/dist/lib/picker.js +124 -0
  83. package/dist/lib/platform/winpath.d.ts +31 -2
  84. package/dist/lib/platform/winpath.js +133 -24
  85. package/dist/lib/plugin-marketplace.d.ts +30 -0
  86. package/dist/lib/plugin-marketplace.js +215 -2
  87. package/dist/lib/plugins.d.ts +5 -0
  88. package/dist/lib/plugins.js +45 -4
  89. package/dist/lib/pwsh.d.ts +11 -0
  90. package/dist/lib/pwsh.js +13 -0
  91. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  92. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  93. package/dist/lib/secrets/agent.d.ts +42 -1
  94. package/dist/lib/secrets/agent.js +89 -11
  95. package/dist/lib/secrets/bundles.js +40 -9
  96. package/dist/lib/secrets/filestore.js +31 -1
  97. package/dist/lib/secrets/index.d.ts +33 -1
  98. package/dist/lib/secrets/index.js +90 -9
  99. package/dist/lib/secrets/windows.d.ts +74 -0
  100. package/dist/lib/secrets/windows.js +440 -0
  101. package/dist/lib/session/active.d.ts +7 -0
  102. package/dist/lib/session/active.js +11 -3
  103. package/dist/lib/session/discover.js +94 -0
  104. package/dist/lib/session/parse.d.ts +6 -0
  105. package/dist/lib/session/parse.js +283 -2
  106. package/dist/lib/session/pid-registry.d.ts +21 -0
  107. package/dist/lib/session/pid-registry.js +80 -0
  108. package/dist/lib/session/remote-active.d.ts +26 -0
  109. package/dist/lib/session/remote-active.js +141 -0
  110. package/dist/lib/session/remote.js +2 -6
  111. package/dist/lib/session/render.js +1 -1
  112. package/dist/lib/session/sync/config.d.ts +8 -0
  113. package/dist/lib/session/sync/config.js +11 -2
  114. package/dist/lib/session/types.d.ts +1 -1
  115. package/dist/lib/session/types.js +1 -1
  116. package/dist/lib/shims.d.ts +30 -2
  117. package/dist/lib/shims.js +107 -50
  118. package/dist/lib/ssh-exec.d.ts +26 -3
  119. package/dist/lib/ssh-exec.js +45 -3
  120. package/dist/lib/ssh-tunnel.d.ts +24 -5
  121. package/dist/lib/ssh-tunnel.js +60 -62
  122. package/dist/lib/staleness/writers/hooks.js +1 -1
  123. package/dist/lib/startup/command-registry.d.ts +3 -0
  124. package/dist/lib/startup/command-registry.js +6 -0
  125. package/dist/lib/sync-status.d.ts +102 -0
  126. package/dist/lib/sync-status.js +135 -0
  127. package/dist/lib/teams/agents.d.ts +24 -0
  128. package/dist/lib/teams/agents.js +30 -1
  129. package/dist/lib/teams/registry.js +25 -9
  130. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  131. package/dist/lib/terminal/backends/ghostty.js +69 -0
  132. package/dist/lib/terminal/backends/index.d.ts +18 -0
  133. package/dist/lib/terminal/backends/index.js +29 -0
  134. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  135. package/dist/lib/terminal/backends/iterm.js +62 -0
  136. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  137. package/dist/lib/terminal/backends/tmux.js +23 -0
  138. package/dist/lib/terminal/engine.d.ts +39 -0
  139. package/dist/lib/terminal/engine.js +54 -0
  140. package/dist/lib/terminal/index.d.ts +14 -0
  141. package/dist/lib/terminal/index.js +5 -0
  142. package/dist/lib/terminal/policy.d.ts +11 -0
  143. package/dist/lib/terminal/policy.js +11 -0
  144. package/dist/lib/terminal/quote.d.ts +11 -0
  145. package/dist/lib/terminal/quote.js +13 -0
  146. package/dist/lib/terminal/shell.d.ts +6 -0
  147. package/dist/lib/terminal/shell.js +23 -0
  148. package/dist/lib/terminal/transport.d.ts +15 -0
  149. package/dist/lib/terminal/transport.js +40 -0
  150. package/dist/lib/terminal/types.d.ts +59 -0
  151. package/dist/lib/terminal/types.js +13 -0
  152. package/dist/lib/types.d.ts +27 -1
  153. package/dist/lib/usage.d.ts +30 -0
  154. package/dist/lib/usage.js +159 -2
  155. package/dist/lib/versions.d.ts +7 -3
  156. package/dist/lib/versions.js +10 -5
  157. package/dist/lib/whats-new.d.ts +9 -0
  158. package/dist/lib/whats-new.js +35 -0
  159. package/package.json +1 -1
@@ -22,7 +22,6 @@ 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 { Transform } from 'stream';
26
25
  import { sshExec, SSH_OPTS } from './ssh-exec.js';
27
26
  import { encodePowerShell } from './browser/drivers/ssh.js';
28
27
  import { getDevice } from './devices/registry.js';
@@ -30,19 +29,18 @@ import { sshTargetFor } from './devices/connect.js';
30
29
  import { hostNameFor } from './devices/ssh-config.js';
31
30
  import { getCacheDir } from './state.js';
32
31
  import { openComputerClient, resolveTcpEndpoint } from './computer-rpc.js';
33
- /** Build the ssh argv (after the `ssh` program name) for an `-L` tunnel. Pure. */
32
+ /** Build the ssh argv (after the `ssh` program name) for an `-L` tunnel. Pure.
33
+ *
34
+ * Composes the shared hardened baseline (`SSH_OPTS`) rather than re-listing it,
35
+ * so the tunnel inherits the same options — crucially the keepalive, which lets
36
+ * a dropped `-N` tunnel exit instead of lingering as a zombie on the laptop. */
34
37
  export function buildTunnelArgs(user, host, localPort, remotePort) {
35
38
  return [
36
39
  '-L',
37
40
  `${localPort}:127.0.0.1:${remotePort}`,
38
41
  `${user}@${host}`,
39
42
  '-N',
40
- '-o',
41
- 'StrictHostKeyChecking=accept-new',
42
- '-o',
43
- 'BatchMode=yes',
44
- '-o',
45
- 'ConnectTimeout=10',
43
+ ...SSH_OPTS,
46
44
  ];
47
45
  }
48
46
  /**
@@ -150,10 +148,15 @@ export async function resolveRemoteDevice(name) {
150
148
  return { device, target, user, host };
151
149
  }
152
150
  /**
153
- * PowerShell that streams base64 from stdin, decodes it incrementally to
154
- * %LOCALAPPDATA%\agents\computer-helper-win.exe, and stops any running instance
155
- * first so the file isn't locked. The CryptoStream/FromBase64Transform decode
156
- * is streaming the ~156MB exe never lands in memory whole on the remote.
151
+ * Single-quote a string for embedding inside a PowerShell literal.
152
+ */
153
+ function psSingleQuote(s) {
154
+ return "'" + s.replace(/'/g, "''") + "'";
155
+ }
156
+ /**
157
+ * PowerShell that resolves the destination under %LOCALAPPDATA%\agents and
158
+ * stops any running instance first so the file is not locked. The caller copies
159
+ * the exe with scp and then verifies the byte count separately.
157
160
  */
158
161
  export function buildPushScript() {
159
162
  return [
@@ -161,15 +164,18 @@ export function buildPushScript() {
161
164
  `New-Item -ItemType Directory -Force -Path $dir | Out-Null`,
162
165
  `$dst = Join-Path $dir '${WIN_HELPER_EXE}'`,
163
166
  `Get-Process -Name 'computer-helper-win' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue`,
164
- `$si = [Console]::OpenStandardInput()`,
165
- `$t = New-Object Security.Cryptography.FromBase64Transform`,
166
- `$cs = New-Object Security.Cryptography.CryptoStream($si, $t, [Security.Cryptography.CryptoStreamMode]::Read)`,
167
- `$fs = [IO.File]::Create($dst)`,
168
- `$cs.CopyTo($fs)`,
169
- `$fs.Close(); $cs.Close()`,
170
167
  `Write-Output $dst`,
171
168
  ].join('; ');
172
169
  }
170
+ /** PowerShell that verifies scp wrote the expected number of bytes. */
171
+ export function buildVerifyPushScript(remotePath, expectedBytes) {
172
+ return [
173
+ `$dst = ${psSingleQuote(remotePath)}`,
174
+ `$item = Get-Item -LiteralPath $dst -ErrorAction Stop`,
175
+ `if ($item.Length -ne ${expectedBytes}) { throw "helper copy length mismatch: expected ${expectedBytes}, got $($item.Length)" }`,
176
+ `Write-Output "$dst $($item.Length)"`,
177
+ ].join('; ');
178
+ }
173
179
  /**
174
180
  * PowerShell that registers the daemon as a LOGON scheduled task. Interactive
175
181
  * logon type + Highest run level so the daemon runs in the real desktop session
@@ -195,51 +201,32 @@ export function buildUnregisterTaskScript(taskName) {
195
201
  `Get-Process -Name 'computer-helper-win' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue`,
196
202
  ].join('; ');
197
203
  }
204
+ /** Convert a Windows path returned by PowerShell into the scp/SFTP path form. */
205
+ export function scpRemotePath(remotePath) {
206
+ return remotePath.replace(/\\/g, '/');
207
+ }
198
208
  /**
199
- * `setup --host`: push the exe, then register + start the LOGON task. Both hops
200
- * go through `sshExec` (BatchMode key auth the same hardening the browser
201
- * driver and `agents ssh` use). Throws with the remote stderr on any failure.
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).
209
+ * Build the scp argv used for the helper exe transfer. Exported so tests can
210
+ * assert the real binary copy path keeps BatchMode and does not route bytes
211
+ * through a PowerShell decoder.
206
212
  */
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
- }
213
+ export function buildScpArgs(target, remotePath, filePath) {
214
+ return [...SSH_OPTS, filePath, `${target}:${scpRemotePath(remotePath)}`];
222
215
  }
223
216
  /**
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.
217
+ * Copy a local file to the remote destination with scp. This is a binary
218
+ * transfer; no base64 transform runs on either side.
230
219
  */
231
- function streamFileOverSsh(target, remoteCmd, filePath, timeoutMs = 600_000) {
220
+ function copyFileOverScp(target, remotePath, filePath, timeoutMs = 600_000) {
232
221
  return new Promise((resolve, reject) => {
233
- const child = spawn('ssh', [...SSH_OPTS, target, remoteCmd], {
234
- stdio: ['pipe', 'pipe', 'pipe'],
222
+ const child = spawn('scp', buildScpArgs(target, remotePath, filePath), {
223
+ stdio: ['ignore', 'ignore', 'pipe'],
235
224
  });
236
225
  let stderr = '';
237
- let stdout = '';
238
226
  child.stderr.on('data', (d) => (stderr += d.toString()));
239
- child.stdout.on('data', (d) => (stdout += d.toString()));
240
227
  const timer = setTimeout(() => {
241
228
  child.kill('SIGKILL');
242
- reject(new Error(`ssh push to ${target} timed out after ${timeoutMs}ms`));
229
+ reject(new Error(`scp push to ${target} timed out after ${timeoutMs}ms`));
243
230
  }, timeoutMs);
244
231
  const fail = (e) => {
245
232
  clearTimeout(timer);
@@ -247,30 +234,41 @@ function streamFileOverSsh(target, remoteCmd, filePath, timeoutMs = 600_000) {
247
234
  reject(e);
248
235
  };
249
236
  child.on('error', fail);
250
- child.stdin.on('error', fail); // EPIPE if the remote decoder dies mid-stream
251
237
  child.on('close', (code) => {
252
238
  clearTimeout(timer);
253
- resolve({ code, stderr: stderr || stdout });
239
+ resolve({ code, stderr });
254
240
  });
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
241
  });
260
242
  }
243
+ /**
244
+ * `setup --host`: push the exe, then register + start the LOGON task. Remote
245
+ * PowerShell hops go through `sshExec` (BatchMode key auth — the same hardening
246
+ * the browser driver and `agents ssh` use), and the large exe rides a binary
247
+ * scp transfer. Throws with the remote stderr on any failure.
248
+ */
261
249
  export async function setupRemoteHelper(name) {
262
250
  const { target } = await resolveRemoteDevice(name);
263
251
  const exe = resolveWinHelperExe();
264
252
  if (!exe) {
265
253
  throw new Error(`Windows helper exe not built. Run: bash scripts/build-win.sh`);
266
254
  }
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);
255
+ const prep = sshExec(target, encodePowerShell(buildPushScript()), { timeoutMs: 60_000 });
256
+ if (prep.code !== 0) {
257
+ throw new Error(`preparing helper exe path on '${name}' failed (exit ${prep.code ?? 'null'}): ${prep.stderr.trim() || prep.stdout.trim()}`);
258
+ }
259
+ const remotePath = prep.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
260
+ if (!remotePath) {
261
+ throw new Error(`preparing helper exe path on '${name}' did not return a destination path`);
262
+ }
263
+ const push = await copyFileOverScp(target, remotePath, exe);
271
264
  if (push.code !== 0) {
272
265
  throw new Error(`pushing helper exe to '${name}' failed (exit ${push.code ?? 'null'}): ${push.stderr.trim()}`);
273
266
  }
267
+ const expectedBytes = fs.statSync(exe).size;
268
+ const verify = sshExec(target, encodePowerShell(buildVerifyPushScript(remotePath, expectedBytes)), { timeoutMs: 60_000 });
269
+ if (verify.code !== 0) {
270
+ throw new Error(`verifying helper exe on '${name}' failed (exit ${verify.code ?? 'null'}): ${verify.stderr.trim() || verify.stdout.trim()}`);
271
+ }
274
272
  // Register + start the LOGON task.
275
273
  const reg = sshExec(target, encodePowerShell(buildRegisterTaskScript(REMOTE_HELPER_PORT, REMOTE_TASK_NAME)), {
276
274
  timeoutMs: 60_000,
@@ -32,7 +32,7 @@ function buildHooksWriter(agent) {
32
32
  }
33
33
  // Native hook registration in settings.json/hooks.json. Grok auto-
34
34
  // discovers from ~/.grok/hooks/ so the file copy is sufficient.
35
- if (agent === 'claude' || agent === 'codex' || agent === 'gemini' || agent === 'antigravity' || agent === 'kimi') {
35
+ if (agent === 'claude' || agent === 'codex' || agent === 'gemini' || agent === 'antigravity' || agent === 'kimi' || agent === 'droid') {
36
36
  registerHooksToSettings(agent, versionHome);
37
37
  }
38
38
  return { synced };
@@ -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;
@@ -68,6 +69,8 @@ export declare const loadTmux: ModuleLoader;
68
69
  export declare const loadBrowser: ModuleLoader;
69
70
  export declare const loadComputer: ModuleLoader;
70
71
  export declare const loadHosts: ModuleLoader;
72
+ export declare const loadLogs: ModuleLoader;
73
+ export declare const loadEvents: ModuleLoader;
71
74
  export declare const loadSsh: ModuleLoader;
72
75
  export declare const loadPull: ModuleLoader;
73
76
  export declare const loadPush: 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;
@@ -46,6 +47,8 @@ export const loadTmux = async () => (await import('../../commands/tmux.js')).reg
46
47
  export const loadBrowser = async () => (await import('../../commands/browser.js')).registerBrowserCommand;
47
48
  export const loadComputer = async () => (await import('../../commands/computer.js')).registerComputerCommand;
48
49
  export const loadHosts = async () => (await import('../../commands/hosts.js')).registerHostsCommand;
50
+ export const loadLogs = async () => (await import('../../commands/logs.js')).registerLogsCommand;
51
+ export const loadEvents = async () => (await import('../../commands/events.js')).registerEventsCommand;
49
52
  export const loadSsh = async () => (await import('../../commands/ssh.js')).registerSshCommands;
50
53
  export const loadPull = async () => (await import('../../commands/pull.js')).registerPullCommand;
51
54
  export const loadPush = async () => (await import('../../commands/push.js')).registerPushCommand;
@@ -110,6 +113,7 @@ export const COMMAND_LOADERS = {
110
113
  trash: [loadTrash],
111
114
  restore: [loadRestore],
112
115
  doctor: [loadDoctor],
116
+ status: [loadStatus],
113
117
  profiles: [loadProfiles],
114
118
  secrets: [loadSecrets],
115
119
  wallet: [loadWallet],
@@ -129,6 +133,8 @@ export const COMMAND_LOADERS = {
129
133
  browser: [loadBrowser],
130
134
  computer: [loadComputer],
131
135
  hosts: [loadHosts],
136
+ logs: [loadLogs],
137
+ events: [loadEvents],
132
138
  ssh: [loadSsh],
133
139
  devices: [loadSsh],
134
140
  pull: [loadPull],
@@ -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>;
@@ -0,0 +1,135 @@
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 simpleGit from 'simple-git';
22
+ import { ALL_AGENT_IDS } from './agents.js';
23
+ import { diffVersionResources, } from './doctor-diff.js';
24
+ import { listInstalledVersions, getGlobalDefault } from './versions.js';
25
+ import { loadManifest } from './staleness/index.js';
26
+ import { getSystemAgentsDir } from './state.js';
27
+ import { isGitRepo } from './git.js';
28
+ const STATUS_MAP = {
29
+ ok: 'synced',
30
+ diff: 'drifted',
31
+ missing: 'missing',
32
+ extra: 'orphan',
33
+ };
34
+ function rowsFromReport(agent, version, report) {
35
+ const out = [];
36
+ for (const list of Object.values(report.kinds)) {
37
+ for (const r of list) {
38
+ out.push({
39
+ agent,
40
+ version,
41
+ kind: r.kind,
42
+ name: r.name,
43
+ status: STATUS_MAP[r.status],
44
+ ...(r.detail ? { detail: r.detail } : {}),
45
+ });
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+ /**
51
+ * Read `.system` repo freshness WITHOUT touching the network. `git status`
52
+ * reports ahead/behind against the remote-tracking ref, which the detached
53
+ * auto-pull worker keeps warm via periodic `git fetch`. This is the same number
54
+ * the menu-bar surfaces; we read it once, here, so every surface agrees.
55
+ */
56
+ export async function getSystemRepoStatus() {
57
+ const dir = getSystemAgentsDir();
58
+ const base = { dir, behind: 0, ahead: 0, branch: null, unknown: true };
59
+ if (!isGitRepo(dir))
60
+ return base;
61
+ try {
62
+ const status = await simpleGit(dir).status();
63
+ return {
64
+ dir,
65
+ behind: status.behind ?? 0,
66
+ ahead: status.ahead ?? 0,
67
+ branch: status.tracking ?? status.current ?? null,
68
+ // Without a tracking branch there's no upstream to compare against.
69
+ unknown: !status.tracking,
70
+ };
71
+ }
72
+ catch {
73
+ return base;
74
+ }
75
+ }
76
+ /**
77
+ * Compute unified sync status across the fleet. Resolves against non-project
78
+ * layers only (`excludeProject: true`) — the GLOBAL version home is never
79
+ * reconciled against per-cwd `<cwd>/.agents/` resources, so counting them as
80
+ * "missing" there would be a false gap (matches doctor's overview semantics).
81
+ */
82
+ export async function computeSyncStatus(options = {}) {
83
+ const cwd = options.cwd ?? process.cwd();
84
+ const agentIds = options.agents ?? ALL_AGENT_IDS;
85
+ const agents = [];
86
+ for (const agent of agentIds) {
87
+ const def = getGlobalDefault(agent);
88
+ for (const version of listInstalledVersions(agent)) {
89
+ const report = diffVersionResources(agent, version, {
90
+ cwd,
91
+ excludeProject: true,
92
+ ...(options.kinds ? { kinds: options.kinds } : {}),
93
+ });
94
+ const resources = rowsFromReport(agent, version, report);
95
+ const counts = { synced: 0, drifted: 0, missing: 0, orphan: 0 };
96
+ for (const r of resources)
97
+ counts[r.status]++;
98
+ agents.push({
99
+ agent,
100
+ version,
101
+ isDefault: version === def,
102
+ everSynced: loadManifest(agent, version) !== null,
103
+ counts,
104
+ needsSync: counts.drifted + counts.missing > 0,
105
+ resources,
106
+ });
107
+ }
108
+ }
109
+ const system = await getSystemRepoStatus();
110
+ const agentsNeedingSync = new Set();
111
+ let drifted = 0, missing = 0, orphan = 0, versionsNeedingSync = 0, versionsNeverSynced = 0;
112
+ for (const v of agents) {
113
+ drifted += v.counts.drifted;
114
+ missing += v.counts.missing;
115
+ orphan += v.counts.orphan;
116
+ if (!v.everSynced)
117
+ versionsNeverSynced++;
118
+ if (v.needsSync) {
119
+ versionsNeedingSync++;
120
+ agentsNeedingSync.add(v.agent);
121
+ }
122
+ }
123
+ return {
124
+ system,
125
+ agents,
126
+ totals: {
127
+ drifted,
128
+ missing,
129
+ orphan,
130
+ versionsNeedingSync,
131
+ versionsNeverSynced,
132
+ agentsNeedingSync: agentsNeedingSync.size,
133
+ },
134
+ };
135
+ }
@@ -68,6 +68,30 @@ export declare function checkAllClis(): Record<string, {
68
68
  path: string | null;
69
69
  error: string | null;
70
70
  }>;
71
+ /**
72
+ * Advisory sign-in probe for a teammate's agent. Reads the account-global login
73
+ * (no `home` → active config) via `getAccountInfo`. Deliberately best-effort:
74
+ * sign-in detection is UNRELIABLE for opaque-credential agents (Kimi/Antigravity
75
+ * store an OAuth/JWT with no email claim) and for keychain-probed agents, so a
76
+ * `false` here is often a false negative. Callers must WARN and continue — never
77
+ * block a team on this result. Never throws (returns false on any error).
78
+ */
79
+ export declare function checkCliSignedIn(agentType: AgentType): Promise<boolean>;
80
+ /** Advisory sign-in status for a `teams doctor` row. */
81
+ export interface SignInAdvisory {
82
+ /** true / false from the probe, or null when the agent isn't installed. */
83
+ signedIn: boolean | null;
84
+ /** Whether the agent is currently a running teammate. */
85
+ running: boolean;
86
+ }
87
+ /**
88
+ * Resolve the advisory sign-in status shown by `teams doctor`. An agent that is
89
+ * currently RUNNING in a team is live proof it works, so it overrides a
90
+ * (frequently false-negative) sign-in probe — doctor must never report a
91
+ * working agent as logged out. Not installed → `signedIn: null` (nothing to
92
+ * probe). Never flips the authoritative installed/ready column.
93
+ */
94
+ export declare function resolveSignInAdvisory(installed: boolean, running: boolean, probeSignedIn: boolean): SignInAdvisory;
71
95
  /** Resolve and cache the base directory where teammate process data is stored. */
72
96
  export declare function getAgentsDir(): Promise<string>;
73
97
  /**
@@ -19,7 +19,7 @@ import { normalizeEvents } from './parsers.js';
19
19
  import { debug } from './debug.js';
20
20
  import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from '../gemini-settings.js';
21
21
  import { getAgentsDir as getSystemAgentsDir, getShimsDir } from '../state.js';
22
- import { AGENTS } from '../agents.js';
22
+ import { AGENTS, getAccountInfo } from '../agents.js';
23
23
  import { sanitizeProcessEnv } from '../secrets/bundles.js';
24
24
  let lastMemoryWarnAt = 0;
25
25
  // On macOS, os.freemem() returns only the truly-free pool and ignores the
@@ -353,6 +353,35 @@ export function checkAllClis() {
353
353
  }
354
354
  return results;
355
355
  }
356
+ /**
357
+ * Advisory sign-in probe for a teammate's agent. Reads the account-global login
358
+ * (no `home` → active config) via `getAccountInfo`. Deliberately best-effort:
359
+ * sign-in detection is UNRELIABLE for opaque-credential agents (Kimi/Antigravity
360
+ * store an OAuth/JWT with no email claim) and for keychain-probed agents, so a
361
+ * `false` here is often a false negative. Callers must WARN and continue — never
362
+ * block a team on this result. Never throws (returns false on any error).
363
+ */
364
+ export async function checkCliSignedIn(agentType) {
365
+ try {
366
+ const info = await getAccountInfo(agentType);
367
+ return info.signedIn;
368
+ }
369
+ catch {
370
+ return false;
371
+ }
372
+ }
373
+ /**
374
+ * Resolve the advisory sign-in status shown by `teams doctor`. An agent that is
375
+ * currently RUNNING in a team is live proof it works, so it overrides a
376
+ * (frequently false-negative) sign-in probe — doctor must never report a
377
+ * working agent as logged out. Not installed → `signedIn: null` (nothing to
378
+ * probe). Never flips the authoritative installed/ready column.
379
+ */
380
+ export function resolveSignInAdvisory(installed, running, probeSignedIn) {
381
+ if (!installed)
382
+ return { signedIn: null, running: false };
383
+ return { signedIn: running ? true : probeSignedIn, running };
384
+ }
356
385
  let AGENTS_DIR = null;
357
386
  /** Resolve and cache the base directory where teammate process data is stored. */
358
387
  export async function getAgentsDir() {
@@ -13,6 +13,7 @@ import * as path from 'path';
13
13
  import { randomBytes } from 'crypto';
14
14
  import lockfile from 'proper-lockfile';
15
15
  import { getTeamsRegistryPath } from '../state.js';
16
+ import { emit } from '../events.js';
16
17
  async function registryPath() {
17
18
  return getTeamsRegistryPath();
18
19
  }
@@ -98,39 +99,50 @@ export async function createTeam(name, options) {
98
99
  throw new Error('Cannot use both --enable-worktrees and --use-worktree. Pick one.');
99
100
  }
100
101
  const p = await registryPath();
101
- return withRegistryLock(p, async () => {
102
+ const meta = await withRegistryLock(p, async () => {
102
103
  const reg = await loadTeams();
103
104
  if (reg[name]) {
104
105
  throw new Error(`Team '${name}' already exists`);
105
106
  }
106
- const meta = {
107
+ const m = {
107
108
  created_at: new Date().toISOString(),
108
109
  ...(options?.description ? { description: options.description } : {}),
109
110
  ...(options?.enableWorktrees ? { enable_worktrees: true } : {}),
110
111
  ...(options?.useWorktree ? { use_worktree: options.useWorktree } : {}),
111
112
  };
112
- reg[name] = meta;
113
+ reg[name] = m;
113
114
  await saveTeams(reg);
114
- return meta;
115
+ return m;
115
116
  });
117
+ // Audit the lifecycle boundary, not the CLI shell — captures every creation
118
+ // path (create + ensure) with team metadata the generic command log lacks.
119
+ emit('teams.create', { module: 'teams', team: name, worktrees: Boolean(options?.enableWorktrees || options?.useWorktree) });
120
+ return meta;
116
121
  }
117
122
  /** Return existing team metadata or create a new team if it does not exist. */
118
123
  export async function ensureTeam(name) {
119
124
  const p = await registryPath();
120
- return withRegistryLock(p, async () => {
125
+ let created = false;
126
+ const meta = await withRegistryLock(p, async () => {
121
127
  const reg = await loadTeams();
122
128
  if (reg[name])
123
129
  return reg[name];
124
- const meta = { created_at: new Date().toISOString() };
125
- reg[name] = meta;
130
+ const m = { created_at: new Date().toISOString() };
131
+ reg[name] = m;
126
132
  await saveTeams(reg);
127
- return meta;
133
+ created = true;
134
+ return m;
128
135
  });
136
+ // `teams add` auto-creates the team on first teammate — audit that creation
137
+ // too, but only when it actually happened (not the get-existing path).
138
+ if (created)
139
+ emit('teams.create', { module: 'teams', team: name, worktrees: false });
140
+ return meta;
129
141
  }
130
142
  /** Remove a team from the registry. Returns false if the team did not exist. */
131
143
  export async function removeTeam(name) {
132
144
  const p = await registryPath();
133
- return withRegistryLock(p, async () => {
145
+ const existed = await withRegistryLock(p, async () => {
134
146
  const reg = await loadTeams();
135
147
  if (!reg[name])
136
148
  return false;
@@ -138,6 +150,10 @@ export async function removeTeam(name) {
138
150
  await saveTeams(reg);
139
151
  return true;
140
152
  });
153
+ // "Disband" — only fires when a real team was removed, not a no-op.
154
+ if (existed)
155
+ emit('teams.disband', { module: 'teams', team: name });
156
+ return existed;
141
157
  }
142
158
  /** Check whether a team with the given name exists in the registry. */
143
159
  export async function teamExists(name) {
@@ -0,0 +1,6 @@
1
+ import type { TerminalBackend, SplitDirection } from '../types.js';
2
+ /** AppleScript that opens a Ghostty tab (a window if none is open). */
3
+ export declare function ghosttyTabScript(cwd: string, command: string[]): string;
4
+ /** AppleScript that splits the current Ghostty surface (a window if none is open). */
5
+ export declare function ghosttySplitScript(cwd: string, command: string[], direction: SplitDirection): string;
6
+ export declare const ghosttyBackend: TerminalBackend;