@phnx-labs/agents-cli 1.20.64 → 1.20.65

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 (155) hide show
  1. package/CHANGELOG.md +39 -3
  2. package/README.md +37 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.d.ts +12 -0
  5. package/dist/commands/apply.js +274 -0
  6. package/dist/commands/browser.js +2 -2
  7. package/dist/commands/cloud.js +32 -2
  8. package/dist/commands/doctor.js +4 -1
  9. package/dist/commands/exec.js +94 -49
  10. package/dist/commands/feed.js +25 -11
  11. package/dist/commands/hosts.js +44 -6
  12. package/dist/commands/mcp.js +55 -5
  13. package/dist/commands/monitors.d.ts +12 -0
  14. package/dist/commands/monitors.js +740 -0
  15. package/dist/commands/output.js +2 -2
  16. package/dist/commands/routines.js +23 -2
  17. package/dist/commands/secrets.d.ts +16 -0
  18. package/dist/commands/secrets.js +215 -64
  19. package/dist/commands/serve.js +31 -0
  20. package/dist/commands/sessions-export.js +8 -3
  21. package/dist/commands/sessions.d.ts +16 -0
  22. package/dist/commands/sessions.js +36 -6
  23. package/dist/commands/ssh.js +45 -2
  24. package/dist/commands/versions.js +7 -3
  25. package/dist/commands/view.d.ts +26 -0
  26. package/dist/commands/view.js +32 -9
  27. package/dist/commands/webhook.js +10 -2
  28. package/dist/index.js +34 -14
  29. package/dist/lib/agents.d.ts +18 -0
  30. package/dist/lib/agents.js +53 -3
  31. package/dist/lib/auto-dispatch-provider.js +7 -2
  32. package/dist/lib/auto-dispatch.d.ts +3 -0
  33. package/dist/lib/auto-dispatch.js +3 -0
  34. package/dist/lib/browser/chrome.js +2 -2
  35. package/dist/lib/cloud/antigravity.js +2 -2
  36. package/dist/lib/cloud/host.d.ts +59 -0
  37. package/dist/lib/cloud/host.js +224 -0
  38. package/dist/lib/cloud/registry.js +4 -0
  39. package/dist/lib/cloud/types.d.ts +6 -4
  40. package/dist/lib/computer-rpc.js +3 -1
  41. package/dist/lib/crabbox/cli.js +5 -1
  42. package/dist/lib/crabbox/runtimes.js +11 -2
  43. package/dist/lib/daemon.d.ts +20 -4
  44. package/dist/lib/daemon.js +62 -19
  45. package/dist/lib/devices/fleet.d.ts +3 -2
  46. package/dist/lib/devices/fleet.js +9 -0
  47. package/dist/lib/devices/registry.d.ts +15 -0
  48. package/dist/lib/devices/registry.js +9 -0
  49. package/dist/lib/exec.d.ts +19 -2
  50. package/dist/lib/exec.js +41 -13
  51. package/dist/lib/fleet/apply.d.ts +63 -0
  52. package/dist/lib/fleet/apply.js +214 -0
  53. package/dist/lib/fleet/auth-sync.d.ts +67 -0
  54. package/dist/lib/fleet/auth-sync.js +142 -0
  55. package/dist/lib/fleet/manifest.d.ts +29 -0
  56. package/dist/lib/fleet/manifest.js +127 -0
  57. package/dist/lib/fleet/types.d.ts +129 -0
  58. package/dist/lib/fleet/types.js +13 -0
  59. package/dist/lib/git.d.ts +27 -0
  60. package/dist/lib/git.js +34 -2
  61. package/dist/lib/hosts/dispatch.d.ts +29 -8
  62. package/dist/lib/hosts/dispatch.js +46 -18
  63. package/dist/lib/hosts/passthrough.js +2 -0
  64. package/dist/lib/hosts/providers/devices.d.ts +27 -0
  65. package/dist/lib/hosts/providers/devices.js +98 -0
  66. package/dist/lib/hosts/registry.d.ts +10 -16
  67. package/dist/lib/hosts/registry.js +17 -50
  68. package/dist/lib/hosts/remote-cmd.d.ts +23 -0
  69. package/dist/lib/hosts/remote-cmd.js +71 -0
  70. package/dist/lib/hosts/run-target.d.ts +84 -0
  71. package/dist/lib/hosts/run-target.js +99 -0
  72. package/dist/lib/hosts/types.d.ts +23 -5
  73. package/dist/lib/hosts/types.js +22 -4
  74. package/dist/lib/linear-autoclose.d.ts +30 -0
  75. package/dist/lib/linear-autoclose.js +22 -0
  76. package/dist/lib/mcp.d.ts +27 -1
  77. package/dist/lib/mcp.js +126 -12
  78. package/dist/lib/monitors/config.d.ts +161 -0
  79. package/dist/lib/monitors/config.js +372 -0
  80. package/dist/lib/monitors/dispatch.d.ts +28 -0
  81. package/dist/lib/monitors/dispatch.js +91 -0
  82. package/dist/lib/monitors/engine.d.ts +61 -0
  83. package/dist/lib/monitors/engine.js +201 -0
  84. package/dist/lib/monitors/sources/command.d.ts +11 -0
  85. package/dist/lib/monitors/sources/command.js +31 -0
  86. package/dist/lib/monitors/sources/device.d.ts +13 -0
  87. package/dist/lib/monitors/sources/device.js +35 -0
  88. package/dist/lib/monitors/sources/file.d.ts +14 -0
  89. package/dist/lib/monitors/sources/file.js +57 -0
  90. package/dist/lib/monitors/sources/http.d.ts +10 -0
  91. package/dist/lib/monitors/sources/http.js +34 -0
  92. package/dist/lib/monitors/sources/index.d.ts +14 -0
  93. package/dist/lib/monitors/sources/index.js +31 -0
  94. package/dist/lib/monitors/sources/poll.d.ts +9 -0
  95. package/dist/lib/monitors/sources/poll.js +9 -0
  96. package/dist/lib/monitors/sources/types.d.ts +18 -0
  97. package/dist/lib/monitors/sources/types.js +9 -0
  98. package/dist/lib/monitors/sources/webhook.d.ts +23 -0
  99. package/dist/lib/monitors/sources/webhook.js +47 -0
  100. package/dist/lib/monitors/sources/ws.d.ts +14 -0
  101. package/dist/lib/monitors/sources/ws.js +45 -0
  102. package/dist/lib/monitors/state.d.ts +69 -0
  103. package/dist/lib/monitors/state.js +144 -0
  104. package/dist/lib/platform/exec.d.ts +16 -0
  105. package/dist/lib/platform/exec.js +17 -0
  106. package/dist/lib/plugins.js +101 -2
  107. package/dist/lib/redact.d.ts +14 -1
  108. package/dist/lib/redact.js +47 -1
  109. package/dist/lib/remote-agents-json.js +7 -1
  110. package/dist/lib/rotate.d.ts +6 -3
  111. package/dist/lib/rotate.js +0 -1
  112. package/dist/lib/routines.d.ts +16 -0
  113. package/dist/lib/routines.js +19 -0
  114. package/dist/lib/runner.d.ts +1 -0
  115. package/dist/lib/runner.js +102 -9
  116. package/dist/lib/secrets/agent.d.ts +48 -10
  117. package/dist/lib/secrets/agent.js +123 -15
  118. package/dist/lib/secrets/bundles.d.ts +26 -0
  119. package/dist/lib/secrets/bundles.js +59 -8
  120. package/dist/lib/secrets/mcp.js +4 -2
  121. package/dist/lib/secrets/remote.d.ts +17 -0
  122. package/dist/lib/secrets/remote.js +40 -0
  123. package/dist/lib/self-update.d.ts +20 -0
  124. package/dist/lib/self-update.js +54 -1
  125. package/dist/lib/serve/control.d.ts +95 -0
  126. package/dist/lib/serve/control.js +260 -0
  127. package/dist/lib/serve/server.d.ts +35 -1
  128. package/dist/lib/serve/server.js +106 -76
  129. package/dist/lib/serve/stream.d.ts +43 -0
  130. package/dist/lib/serve/stream.js +116 -0
  131. package/dist/lib/serve/token.d.ts +35 -0
  132. package/dist/lib/serve/token.js +85 -0
  133. package/dist/lib/session/bundle.d.ts +14 -0
  134. package/dist/lib/session/bundle.js +12 -1
  135. package/dist/lib/session/remote-list.js +5 -1
  136. package/dist/lib/session/state.d.ts +7 -25
  137. package/dist/lib/session/state.js +16 -6
  138. package/dist/lib/session/sync/config.js +8 -2
  139. package/dist/lib/session/types.d.ts +30 -0
  140. package/dist/lib/ssh-tunnel.d.ts +19 -1
  141. package/dist/lib/ssh-tunnel.js +86 -7
  142. package/dist/lib/startup/command-registry.d.ts +2 -0
  143. package/dist/lib/startup/command-registry.js +4 -0
  144. package/dist/lib/state.d.ts +5 -0
  145. package/dist/lib/state.js +12 -0
  146. package/dist/lib/tmux/session.d.ts +7 -0
  147. package/dist/lib/tmux/session.js +3 -1
  148. package/dist/lib/triggers/webhook.d.ts +18 -0
  149. package/dist/lib/triggers/webhook.js +105 -0
  150. package/dist/lib/types.d.ts +26 -1
  151. package/dist/lib/usage.js +7 -5
  152. package/dist/lib/versions.js +14 -11
  153. package/dist/lib/workflows.d.ts +20 -0
  154. package/dist/lib/workflows.js +24 -0
  155. package/package.json +2 -1
@@ -3,7 +3,7 @@
3
3
  * machine's stable identity. Credentials come from the `r2.backups` secrets
4
4
  * bundle (OS keychain on macOS, libsecret on Linux) — never from env or disk.
5
5
  */
6
- import { readAndResolveBundleEnv } from '../../secrets/bundles.js';
6
+ import { readAndResolveBundleEnv, isHeadlessSecretsContext } from '../../secrets/bundles.js';
7
7
  /** Secrets bundle holding the R2 credentials. */
8
8
  export const SYNC_BUNDLE = 'r2.backups';
9
9
  /**
@@ -12,7 +12,13 @@ export const SYNC_BUNDLE = 'r2.backups';
12
12
  * without real credentials (no silent fallback).
13
13
  */
14
14
  function resolveR2Config() {
15
- const { env } = readAndResolveBundleEnv(SYNC_BUNDLE, { caller: 'sessions-sync' });
15
+ // The daemon monitor loop is headless by construction (no TTY, ever), so
16
+ // isHeadlessSecretsContext() is true here and this resolves broker-only — a
17
+ // broker miss can never pop an unattended Touch ID sheet on the user's screen
18
+ // (same rationale as daemon.ts readDaemonClaudeOAuthToken). Using the shared
19
+ // predicate rather than a literal keeps it consistent with the other callers
20
+ // and lets any interactive caller of loadR2Config still prompt.
21
+ const { env } = readAndResolveBundleEnv(SYNC_BUNDLE, { caller: 'sessions-sync', agentOnly: isHeadlessSecretsContext() });
16
22
  const accountId = env.R2_ACCOUNT_ID?.trim();
17
23
  const bucket = env.R2_BUCKET_NAME?.trim();
18
24
  const accessKeyId = env.R2_ACCESS_KEY_ID?.trim();
@@ -41,6 +41,29 @@ export interface SessionAttachment {
41
41
  mediaType: string;
42
42
  sizeBytes?: number;
43
43
  }
44
+ /** One checklist item, as Claude's `TodoWrite` (`content`) or Codex's `update_plan` (`step`) emits it. */
45
+ export interface TodoItem {
46
+ content: string;
47
+ status: 'pending' | 'in_progress' | 'completed';
48
+ /** Present-continuous label shown while this item is the active step. */
49
+ activeForm?: string;
50
+ }
51
+ /**
52
+ * Live plan progress derived from the most recent checklist write in the transcript
53
+ * (Claude `TodoWrite` / Codex `update_plan`, RUSH-1380). Lets consumers show "N/M
54
+ * done" + the current step for any session — including remote / device-dispatched
55
+ * agents that carry no local tool-call stream — instead of only a coarse
56
+ * working/idle verb.
57
+ */
58
+ export interface TodoProgress {
59
+ items: TodoItem[];
60
+ /** Count of completed items. */
61
+ done: number;
62
+ /** Total items. */
63
+ total: number;
64
+ /** The in-progress item's activeForm (falls back to its content). The live step. */
65
+ activeForm?: string;
66
+ }
44
67
  /** Metadata attached when a session was spawned by `agents teams`. */
45
68
  export interface TeamOrigin {
46
69
  /** Teammate name if set, otherwise first 8 chars of the agent UUID. */
@@ -116,6 +139,13 @@ export interface SessionMeta {
116
139
  * recover the plan text — the CLI now carries it on the metadata row.
117
140
  */
118
141
  plan?: string;
142
+ /**
143
+ * Live plan progress from the most recent checklist write (Claude `TodoWrite`
144
+ * / Codex `update_plan`, RUSH-1503). Populated on `agents sessions <id> --json`
145
+ * from the state engine so the Factory panel reads the CLI's computed checklist
146
+ * instead of re-parsing the transcript. Absent when the session wrote no list.
147
+ */
148
+ todos?: TodoProgress;
119
149
  /**
120
150
  * True when the session was spawned programmatically (SDK entrypoint) rather
121
151
  * than by a human at the Claude CLI. Captured at scan time from the JSONL
@@ -49,6 +49,8 @@ export declare const REMOTE_HELPER_PORT = 8765;
49
49
  export declare const REMOTE_TASK_NAME = "AgentsComputerHelper";
50
50
  /** Basename of the cross-published exe under native/computer-win/dist. */
51
51
  export declare const WIN_HELPER_EXE = "computer-helper-win.exe";
52
+ /** Basename of the daemon's shared-secret token file under %LOCALAPPDATA%\agents. */
53
+ export declare const WIN_HELPER_TOKEN_FILE = "helper-token";
52
54
  /**
53
55
  * Locate a locally built Windows daemon exe — a repo-checkout build output from
54
56
  * `scripts/build-win.sh`, or one bundled next to the package. Local paths are
@@ -101,6 +103,16 @@ export declare function remoteStatePath(device: string): string;
101
103
  export declare function readRemoteState(device: string): RemoteTunnelState | null;
102
104
  export declare function writeRemoteState(state: RemoteTunnelState): void;
103
105
  export declare function clearRemoteState(device: string): void;
106
+ /**
107
+ * Local file holding the shared-secret token for a device's helper daemon.
108
+ * Written at `setup --host` (0600), read at `start --host` so the RPC client
109
+ * authenticates. The remote daemon reads the same value from its own
110
+ * `--token-file`; the CLI is the source of truth and never round-trips it back.
111
+ */
112
+ export declare function helperTokenPath(device: string): string;
113
+ export declare function readHelperToken(device: string): string | null;
114
+ export declare function writeHelperToken(device: string, token: string): void;
115
+ export declare function clearHelperToken(device: string): void;
104
116
  /** Resolve a registered device to its ssh pieces, or throw a clear error. */
105
117
  export declare function resolveRemoteDevice(name: string): Promise<{
106
118
  device: DeviceProfile;
@@ -123,7 +135,13 @@ export declare function buildVerifyPushScript(remotePath: string, expectedBytes:
123
135
  * survives ssh disconnect — the same rationale as the browser WMI launch. The
124
136
  * task is started immediately so the caller need not log out/in.
125
137
  */
126
- export declare function buildRegisterTaskScript(port: number, taskName: string): string;
138
+ /**
139
+ * PowerShell that writes the shared-secret token under %LOCALAPPDATA%\agents
140
+ * with an owner-only ACL (inheritance removed, read granted only to the current
141
+ * user) and echoes the resolved path so the caller can pass it to `--token-file`.
142
+ */
143
+ export declare function buildWriteTokenScript(token: string): string;
144
+ export declare function buildRegisterTaskScript(port: number, taskName: string, tokenPath: string): string;
127
145
  /** PowerShell that unregisters the task and stops any running daemon process. */
128
146
  export declare function buildUnregisterTaskScript(taskName: string): string;
129
147
  /** Convert a Windows path returned by PowerShell into the scp/SFTP path form. */
@@ -103,6 +103,8 @@ export const REMOTE_HELPER_PORT = 8765;
103
103
  export const REMOTE_TASK_NAME = 'AgentsComputerHelper';
104
104
  /** Basename of the cross-published exe under native/computer-win/dist. */
105
105
  export const WIN_HELPER_EXE = 'computer-helper-win.exe';
106
+ /** Basename of the daemon's shared-secret token file under %LOCALAPPDATA%\agents. */
107
+ export const WIN_HELPER_TOKEN_FILE = 'helper-token';
106
108
  /**
107
109
  * Locate a locally built Windows daemon exe — a repo-checkout build output from
108
110
  * `scripts/build-win.sh`, or one bundled next to the package. Local paths are
@@ -234,6 +236,37 @@ export function clearRemoteState(device) {
234
236
  /* already gone */
235
237
  }
236
238
  }
239
+ /**
240
+ * Local file holding the shared-secret token for a device's helper daemon.
241
+ * Written at `setup --host` (0600), read at `start --host` so the RPC client
242
+ * authenticates. The remote daemon reads the same value from its own
243
+ * `--token-file`; the CLI is the source of truth and never round-trips it back.
244
+ */
245
+ export function helperTokenPath(device) {
246
+ return path.join(remoteStateDir(), `${device}.token`);
247
+ }
248
+ export function readHelperToken(device) {
249
+ try {
250
+ const t = fs.readFileSync(helperTokenPath(device), 'utf-8').trim();
251
+ return t.length > 0 ? t : null;
252
+ }
253
+ catch {
254
+ return null;
255
+ }
256
+ }
257
+ export function writeHelperToken(device, token) {
258
+ const dir = remoteStateDir();
259
+ fs.mkdirSync(dir, { recursive: true });
260
+ fs.writeFileSync(helperTokenPath(device), token, { mode: 0o600 });
261
+ }
262
+ export function clearHelperToken(device) {
263
+ try {
264
+ fs.unlinkSync(helperTokenPath(device));
265
+ }
266
+ catch {
267
+ /* already gone */
268
+ }
269
+ }
237
270
  /** Resolve a registered device to its ssh pieces, or throw a clear error. */
238
271
  export async function resolveRemoteDevice(name) {
239
272
  const device = await getDevice(name);
@@ -284,10 +317,26 @@ export function buildVerifyPushScript(remotePath, expectedBytes) {
284
317
  * survives ssh disconnect — the same rationale as the browser WMI launch. The
285
318
  * task is started immediately so the caller need not log out/in.
286
319
  */
287
- export function buildRegisterTaskScript(port, taskName) {
320
+ /**
321
+ * PowerShell that writes the shared-secret token under %LOCALAPPDATA%\agents
322
+ * with an owner-only ACL (inheritance removed, read granted only to the current
323
+ * user) and echoes the resolved path so the caller can pass it to `--token-file`.
324
+ */
325
+ export function buildWriteTokenScript(token) {
326
+ return [
327
+ `$dir = Join-Path $env:LOCALAPPDATA 'agents'`,
328
+ `New-Item -ItemType Directory -Force -Path $dir | Out-Null`,
329
+ `$tok = Join-Path $dir '${WIN_HELPER_TOKEN_FILE}'`,
330
+ `Set-Content -LiteralPath $tok -Value ${psSingleQuote(token)} -NoNewline -Encoding ascii`,
331
+ `icacls $tok /inheritance:r /grant:r ("$($env:USERNAME):(R)") | Out-Null`,
332
+ `Write-Output $tok`,
333
+ ].join('; ');
334
+ }
335
+ export function buildRegisterTaskScript(port, taskName, tokenPath) {
336
+ const argument = `--port ${port} --token-file "${tokenPath}"`;
288
337
  return [
289
338
  `$exe = Join-Path (Join-Path $env:LOCALAPPDATA 'agents') '${WIN_HELPER_EXE}'`,
290
- `$action = New-ScheduledTaskAction -Execute $exe -Argument '--port ${port}'`,
339
+ `$action = New-ScheduledTaskAction -Execute $exe -Argument ${psSingleQuote(argument)}`,
291
340
  `$trigger = New-ScheduledTaskTrigger -AtLogOn`,
292
341
  `$principal = New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest`,
293
342
  `$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero)`,
@@ -369,13 +418,28 @@ export async function setupRemoteHelper(name) {
369
418
  if (verify.code !== 0) {
370
419
  throw new Error(`verifying helper exe on '${name}' failed (exit ${verify.code ?? 'null'}): ${verify.stderr.trim() || verify.stdout.trim()}`);
371
420
  }
372
- // Register + start the LOGON task.
373
- const reg = sshExec(target, encodePowerShell(buildRegisterTaskScript(REMOTE_HELPER_PORT, REMOTE_TASK_NAME)), {
421
+ // Provision the auth token: generate it locally, write it on the remote with
422
+ // an owner-only ACL, and register the task with --token-file. The daemon now
423
+ // refuses to start without a token, so a token-less (open-to-any-local-process)
424
+ // daemon can no longer be stood up through the CLI.
425
+ const token = generateToken();
426
+ const tokWrite = sshExec(target, encodePowerShell(buildWriteTokenScript(token)), { timeoutMs: 60_000 });
427
+ if (tokWrite.code !== 0) {
428
+ throw new Error(`writing helper token on '${name}' failed (exit ${tokWrite.code ?? 'null'}): ${tokWrite.stderr.trim() || tokWrite.stdout.trim()}`);
429
+ }
430
+ const remoteTokenPath = tokWrite.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
431
+ if (!remoteTokenPath) {
432
+ throw new Error(`writing helper token on '${name}' did not return a destination path`);
433
+ }
434
+ // Register + start the LOGON task, pointing it at the token file.
435
+ const reg = sshExec(target, encodePowerShell(buildRegisterTaskScript(REMOTE_HELPER_PORT, REMOTE_TASK_NAME, remoteTokenPath)), {
374
436
  timeoutMs: 60_000,
375
437
  });
376
438
  if (reg.code !== 0) {
377
439
  throw new Error(`registering scheduled task on '${name}' failed (exit ${reg.code ?? 'null'}): ${reg.stderr.trim() || reg.stdout.trim()}`);
378
440
  }
441
+ // Persist the token locally so `start --host` authenticates to the daemon.
442
+ writeHelperToken(name, token);
379
443
  return { target, taskName: REMOTE_TASK_NAME };
380
444
  }
381
445
  /** Reserve a free local TCP port by binding :0 and reading the assigned port. */
@@ -403,9 +467,17 @@ export async function startRemoteTunnel(name) {
403
467
  const tunnelPid = tunnel.pid ?? 0;
404
468
  // Verify the daemon answers through the tunnel before we record it. This is
405
469
  // the real end-to-end check: tunnel up + daemon listening + RPC round-trips.
406
- const token = null; // tunnel-gated; the daemon runs token-less
470
+ // The daemon now requires a token, so the probe must authenticate with the one
471
+ // provisioned at `setup`. A missing token means setup predates auth — guide
472
+ // the user to re-run it rather than silently failing the RPC.
473
+ const token = readHelperToken(name);
407
474
  const prevTcp = process.env.COMPUTER_HELPER_TCP;
475
+ const prevTok = process.env.COMPUTER_HELPER_TOKEN;
408
476
  process.env.COMPUTER_HELPER_TCP = `127.0.0.1:${localPort}`;
477
+ if (token)
478
+ process.env.COMPUTER_HELPER_TOKEN = token;
479
+ else
480
+ delete process.env.COMPUTER_HELPER_TOKEN;
409
481
  const client = openComputerClient();
410
482
  let ok = false;
411
483
  let probeErr = '';
@@ -424,6 +496,10 @@ export async function startRemoteTunnel(name) {
424
496
  delete process.env.COMPUTER_HELPER_TCP;
425
497
  else
426
498
  process.env.COMPUTER_HELPER_TCP = prevTcp;
499
+ if (prevTok === undefined)
500
+ delete process.env.COMPUTER_HELPER_TOKEN;
501
+ else
502
+ process.env.COMPUTER_HELPER_TOKEN = prevTok;
427
503
  }
428
504
  if (!ok) {
429
505
  try {
@@ -431,8 +507,10 @@ export async function startRemoteTunnel(name) {
431
507
  process.kill(tunnelPid);
432
508
  }
433
509
  catch { /* gone */ }
434
- throw new Error(`tunnel to '${name}' opened but the daemon did not answer (${probeErr}). ` +
435
- `Is it installed? Run: agents computer setup --host ${name}`);
510
+ const hint = token
511
+ ? `Is it installed? Run: agents computer setup --host ${name}`
512
+ : `No auth token on record for '${name}' — re-run: agents computer setup --host ${name}`;
513
+ throw new Error(`tunnel to '${name}' opened but the daemon did not answer (${probeErr}). ${hint}`);
436
514
  }
437
515
  const state = {
438
516
  device: name,
@@ -473,6 +551,7 @@ export async function stopRemoteHelper(name) {
473
551
  /* device gone / offline — local teardown still succeeds */
474
552
  }
475
553
  clearRemoteState(name);
554
+ clearHelperToken(name);
476
555
  return { tunnelKilled, taskRemoved };
477
556
  }
478
557
  /**
@@ -43,6 +43,7 @@ export declare const loadImport: ModuleLoader;
43
43
  export declare const loadPackages: ModuleLoader;
44
44
  export declare const loadDaemon: ModuleLoader;
45
45
  export declare const loadRoutines: ModuleLoader;
46
+ export declare const loadMonitors: ModuleLoader;
46
47
  export declare const loadRun: ModuleLoader;
47
48
  export declare const loadDefaults: ModuleLoader;
48
49
  export declare const loadModels: ModuleLoader;
@@ -50,6 +51,7 @@ export declare const loadPrune: ModuleLoader;
50
51
  export declare const loadTrash: ModuleLoader;
51
52
  export declare const loadRestore: ModuleLoader;
52
53
  export declare const loadDoctor: ModuleLoader;
54
+ export declare const loadApply: ModuleLoader;
53
55
  export declare const loadCheck: ModuleLoader;
54
56
  export declare const loadStatus: ModuleLoader;
55
57
  export declare const loadProfiles: ModuleLoader;
@@ -21,6 +21,7 @@ export const loadImport = async () => (await import('../../commands/import.js'))
21
21
  export const loadPackages = async () => (await import('../../commands/packages.js')).registerPackagesCommands;
22
22
  export const loadDaemon = async () => (await import('../../commands/daemon.js')).registerDaemonCommands;
23
23
  export const loadRoutines = async () => (await import('../../commands/routines.js')).registerRoutinesCommands;
24
+ export const loadMonitors = async () => (await import('../../commands/monitors.js')).registerMonitorsCommands;
24
25
  export const loadRun = async () => (await import('../../commands/exec.js')).registerRunCommand;
25
26
  export const loadDefaults = async () => (await import('../../commands/defaults.js')).registerDefaultsCommands;
26
27
  export const loadModels = async () => (await import('../../commands/models.js')).registerModelsCommand;
@@ -28,6 +29,7 @@ export const loadPrune = async () => (await import('../../commands/prune.js')).r
28
29
  export const loadTrash = async () => (await import('../../commands/trash.js')).registerTrashCommands;
29
30
  export const loadRestore = async () => (await import('../../commands/trash.js')).registerRestoreCommand;
30
31
  export const loadDoctor = async () => (await import('../../commands/doctor.js')).registerDoctorCommand;
32
+ export const loadApply = async () => (await import('../../commands/apply.js')).registerApplyCommand;
31
33
  export const loadCheck = async () => (await import('../../commands/check.js')).registerCheckCommand;
32
34
  export const loadStatus = async () => (await import('../../commands/status.js')).registerStatusCommand;
33
35
  export const loadProfiles = async () => (await import('../../commands/profiles.js')).registerProfilesCommands;
@@ -121,12 +123,14 @@ export const COMMAND_LOADERS = {
121
123
  install: [loadPackages],
122
124
  daemon: [loadDaemon],
123
125
  routines: [loadRoutines],
126
+ monitors: [loadMonitors],
124
127
  run: [loadRun],
125
128
  defaults: [loadDefaults],
126
129
  models: [loadModels],
127
130
  trash: [loadTrash],
128
131
  restore: [loadRestore],
129
132
  doctor: [loadDoctor],
133
+ apply: [loadApply],
130
134
  check: [loadCheck],
131
135
  status: [loadStatus],
132
136
  profiles: [loadProfiles],
@@ -128,6 +128,11 @@ export declare function getSystemRoutinesDir(): string;
128
128
  export declare function getProjectRoutinesDir(cwd?: string): string | null;
129
129
  /** Path to routine execution logs (~/.agents/.history/runs/). */
130
130
  export declare function getRunsDir(): string;
131
+ /** Path to monitor YAML definitions (~/.agents/monitors/). */
132
+ export declare function getMonitorsDir(): string;
133
+ /** Path to the durable per-monitor state-diff store + fire history
134
+ * (~/.agents/.history/monitors/). */
135
+ export declare function getMonitorsHistoryDir(): string;
131
136
  /** Root for per-agent mailboxes (~/.agents/.history/mailbox/). */
132
137
  export declare function getMailboxRootDir(): string;
133
138
  /** Root for open-block feed records (~/.agents/.history/feed/). */
package/dist/lib/state.js CHANGED
@@ -83,12 +83,19 @@ const HISTORY_DIR = path.join(USER_AGENTS_DIR, '.history');
83
83
  const CACHE_DIR = path.join(USER_AGENTS_DIR, '.cache');
84
84
  // Top-level user dirs (config/definitions only — runtime moves into .history/.cache).
85
85
  const ROUTINES_DIR = path.join(USER_AGENTS_DIR, 'routines');
86
+ // Monitor definitions (event-triggered watchers). Sibling of ROUTINES_DIR: a
87
+ // monitor is a routine whose trigger is a watched source instead of a clock.
88
+ const MONITORS_DIR = path.join(USER_AGENTS_DIR, 'monitors');
86
89
  const TEAMS_DIR = path.join(USER_AGENTS_DIR, 'teams');
87
90
  // History bucket (durable).
88
91
  const SESSIONS_DIR = path.join(HISTORY_DIR, 'sessions');
89
92
  const SESSIONS_DB_PATH = path.join(SESSIONS_DIR, 'sessions.db');
90
93
  const VERSIONS_DIR = path.join(HISTORY_DIR, 'versions');
91
94
  const RUNS_DIR = path.join(HISTORY_DIR, 'runs');
95
+ // Durable per-monitor state-diff store + fire history (last-seen value/hash,
96
+ // fires/<id>/). Sibling of RUNS_DIR — the native diff store that replaces the
97
+ // hand-rolled markdown memory files monitors used to need.
98
+ const MONITORS_HISTORY_DIR = path.join(HISTORY_DIR, 'monitors');
92
99
  const TEAMS_AGENTS_DIR = path.join(HISTORY_DIR, 'teams', 'agents');
93
100
  const BACKUPS_DIR = path.join(HISTORY_DIR, 'backups');
94
101
  const TRASH_DIR = path.join(HISTORY_DIR, 'trash');
@@ -324,6 +331,11 @@ export function getProjectRoutinesDir(cwd = process.cwd()) {
324
331
  }
325
332
  /** Path to routine execution logs (~/.agents/.history/runs/). */
326
333
  export function getRunsDir() { return RUNS_DIR; }
334
+ /** Path to monitor YAML definitions (~/.agents/monitors/). */
335
+ export function getMonitorsDir() { return MONITORS_DIR; }
336
+ /** Path to the durable per-monitor state-diff store + fire history
337
+ * (~/.agents/.history/monitors/). */
338
+ export function getMonitorsHistoryDir() { return MONITORS_HISTORY_DIR; }
327
339
  /** Root for per-agent mailboxes (~/.agents/.history/mailbox/). */
328
340
  export function getMailboxRootDir() { return MAILBOX_DIR; }
329
341
  /** Root for open-block feed records (~/.agents/.history/feed/). */
@@ -33,6 +33,13 @@ export interface SessionMeta {
33
33
  export interface CreateSessionOptions {
34
34
  name: string;
35
35
  cmd?: string;
36
+ /**
37
+ * Redacted copy of `cmd` to persist in SessionMeta.cmd instead of the real
38
+ * command. When set, `cmd` is still executed but only `metaCmd` is written to
39
+ * disk — used to keep resolved secret values out of the informational cmd
40
+ * field (see exec.ts buildTmuxAgentCommand, RUSH-1758). Defaults to `cmd`.
41
+ */
42
+ metaCmd?: string;
36
43
  cwd?: string;
37
44
  env?: NodeJS.ProcessEnv;
38
45
  socket?: string;
@@ -106,7 +106,9 @@ export async function createSession(opts) {
106
106
  name: opts.name,
107
107
  socket,
108
108
  createdAt: Date.now(),
109
- cmd: opts.cmd,
109
+ // Persist the redacted copy when provided so resolved secret values in the
110
+ // launch command never land on disk (RUSH-1758).
111
+ cmd: opts.metaCmd ?? opts.cmd,
110
112
  cwd: opts.cwd,
111
113
  source: opts.source ?? 'cli',
112
114
  labels: opts.labels,
@@ -99,6 +99,18 @@ export interface DeliveryStore {
99
99
  markJob(id: string, jobName: string): void;
100
100
  }
101
101
  export declare function createMemoryDeliveryStore(maxEntries?: number): DeliveryStore;
102
+ /**
103
+ * A durable, disk-backed delivery store. Unlike `createMemoryDeliveryStore`,
104
+ * seen delivery ids survive a process restart and are bounded by AGE, not by a
105
+ * fixed entry count — so a captured valid delivery cannot re-fire after a
106
+ * restart or after count-based LRU eviction would have dropped it.
107
+ *
108
+ * `retentionMs` doubles as the replay-acceptance window: a delivery whose id is
109
+ * still on record (younger than the window) is rejected as a duplicate; entries
110
+ * older than the window are pruned (keeping the file bounded) since a webhook
111
+ * source will not legitimately retry a delivery that old.
112
+ */
113
+ export declare function createFileDeliveryStore(filePath: string, retentionMs?: number): DeliveryStore;
102
114
  export interface RateLimiter {
103
115
  take(key: string): boolean;
104
116
  }
@@ -117,6 +129,12 @@ export interface WebhookServerOptions {
117
129
  rateLimiter?: RateLimiter;
118
130
  rateLimitPerMinute?: number;
119
131
  maxBodyBytes?: number;
132
+ /** Per-IP ingress throttle applied BEFORE the body read (bad-sig flood guard). */
133
+ ipRateLimiter?: RateLimiter;
134
+ /** Per-source-IP requests/minute allowed through to the body read. Default 120. */
135
+ ipRateLimitPerMinute?: number;
136
+ /** Max concurrent TCP connections the receiver accepts. Default 256. */
137
+ maxConnections?: number;
120
138
  }
121
139
  /**
122
140
  * Start a localhost-bound receiver. It accepts only:
@@ -12,7 +12,9 @@
12
12
  * verification, idempotency, source allow-listing, and rate limiting.
13
13
  */
14
14
  import * as crypto from 'crypto';
15
+ import * as fs from 'fs';
15
16
  import * as http from 'http';
17
+ import * as path from 'path';
16
18
  import { jobRunsOnThisDevice, listJobs } from '../routines.js';
17
19
  import { executeJobDetached } from '../runner.js';
18
20
  /** Read `repository.full_name` (`owner/name`) from a webhook payload, if present. */
@@ -236,6 +238,88 @@ export function createMemoryDeliveryStore(maxEntries = 1000) {
236
238
  },
237
239
  };
238
240
  }
241
+ /**
242
+ * A durable, disk-backed delivery store. Unlike `createMemoryDeliveryStore`,
243
+ * seen delivery ids survive a process restart and are bounded by AGE, not by a
244
+ * fixed entry count — so a captured valid delivery cannot re-fire after a
245
+ * restart or after count-based LRU eviction would have dropped it.
246
+ *
247
+ * `retentionMs` doubles as the replay-acceptance window: a delivery whose id is
248
+ * still on record (younger than the window) is rejected as a duplicate; entries
249
+ * older than the window are pruned (keeping the file bounded) since a webhook
250
+ * source will not legitimately retry a delivery that old.
251
+ */
252
+ export function createFileDeliveryStore(filePath, retentionMs = 14 * 24 * 60 * 60 * 1000) {
253
+ const seen = new Map();
254
+ // Load persisted state (best-effort: a corrupt/missing file starts empty).
255
+ try {
256
+ const raw = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
257
+ const loadedAt = Date.now();
258
+ for (const [id, entry] of Object.entries(raw)) {
259
+ if (typeof entry?.updatedAt !== 'number' || loadedAt - entry.updatedAt > retentionMs)
260
+ continue;
261
+ seen.set(id, {
262
+ complete: entry.complete === true,
263
+ jobs: new Set(Array.isArray(entry.jobs) ? entry.jobs : []),
264
+ updatedAt: entry.updatedAt,
265
+ });
266
+ }
267
+ }
268
+ catch {
269
+ // no prior file / unreadable — start empty
270
+ }
271
+ const persist = () => {
272
+ const snapshot = {};
273
+ for (const [id, entry] of seen) {
274
+ snapshot[id] = { complete: entry.complete, jobs: [...entry.jobs], updatedAt: entry.updatedAt };
275
+ }
276
+ try {
277
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
278
+ const tmp = `${filePath}.${process.pid}.tmp`;
279
+ fs.writeFileSync(tmp, JSON.stringify(snapshot), 'utf-8');
280
+ fs.renameSync(tmp, filePath);
281
+ }
282
+ catch {
283
+ // best-effort durability; an unwritable dir must not crash ingress
284
+ }
285
+ };
286
+ const prune = (now) => {
287
+ for (const [id, entry] of seen) {
288
+ if (now - entry.updatedAt > retentionMs)
289
+ seen.delete(id);
290
+ }
291
+ };
292
+ const touch = (id) => {
293
+ const now = Date.now();
294
+ prune(now);
295
+ let current = seen.get(id);
296
+ if (!current) {
297
+ current = { complete: false, jobs: new Set(), updatedAt: now };
298
+ seen.set(id, current);
299
+ }
300
+ current.updatedAt = now;
301
+ return current;
302
+ };
303
+ return {
304
+ seen: (id) => {
305
+ const entry = seen.get(id);
306
+ if (!entry)
307
+ return false;
308
+ if (Date.now() - entry.updatedAt > retentionMs)
309
+ return false;
310
+ return entry.complete === true;
311
+ },
312
+ mark: (id) => {
313
+ touch(id).complete = true;
314
+ persist();
315
+ },
316
+ completedJobs: (id) => new Set(seen.get(id)?.jobs ?? []),
317
+ markJob: (id, jobName) => {
318
+ touch(id).jobs.add(jobName);
319
+ persist();
320
+ },
321
+ };
322
+ }
239
323
  export function createMemoryRateLimiter(limit, windowMs) {
240
324
  const buckets = new Map();
241
325
  return {
@@ -285,6 +369,7 @@ async function readRawBody(req, maxBytes) {
285
369
  export function startWebhookServer(options) {
286
370
  const deliveryStore = options.deliveryStore ?? createMemoryDeliveryStore();
287
371
  const rateLimiter = options.rateLimiter ?? createMemoryRateLimiter(options.rateLimitPerMinute ?? 60, 60_000);
372
+ const ipRateLimiter = options.ipRateLimiter ?? createMemoryRateLimiter(options.ipRateLimitPerMinute ?? 120, 60_000);
288
373
  const maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024;
289
374
  const server = http.createServer((req, res) => {
290
375
  void (async () => {
@@ -305,6 +390,23 @@ export function startWebhookServer(options) {
305
390
  res.end(JSON.stringify({ ok: false, error: `missing ${source} webhook secret` }));
306
391
  return;
307
392
  }
393
+ // Per-IP throttle + declared-size cap BEFORE the (expensive) body read +
394
+ // HMAC. A bad-signature flood of 1 MiB POSTs must be rejected without
395
+ // forcing a full body read and an HMAC per request — the signed-delivery
396
+ // rate limit further down runs only after a signature passes, so it can't
397
+ // shed this load on its own.
398
+ const ip = req.socket.remoteAddress ?? 'unknown';
399
+ if (!ipRateLimiter.take(ip)) {
400
+ res.writeHead(429, { 'content-type': 'application/json' });
401
+ res.end(JSON.stringify({ ok: false, error: 'rate limit exceeded' }));
402
+ return;
403
+ }
404
+ const declaredLength = Number.parseInt(header(req.headers, 'content-length') ?? '', 10);
405
+ if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
406
+ res.writeHead(413, { 'content-type': 'application/json' });
407
+ res.end(JSON.stringify({ ok: false, error: `payload exceeds ${maxBodyBytes} bytes` }));
408
+ return;
409
+ }
308
410
  try {
309
411
  const rawBody = await readRawBody(req, maxBodyBytes);
310
412
  const valid = source === 'github'
@@ -357,6 +459,9 @@ export function startWebhookServer(options) {
357
459
  }
358
460
  })();
359
461
  });
462
+ // Connection cap: bound how many concurrent TCP connections the receiver
463
+ // will hold open, so a flood cannot exhaust file descriptors / memory.
464
+ server.maxConnections = options.maxConnections ?? 256;
360
465
  server.listen(options.port ?? 0, options.host ?? '127.0.0.1');
361
466
  return server;
362
467
  }
@@ -515,6 +515,20 @@ export interface PluginManifest {
515
515
  userConfig?: PluginUserConfigField[];
516
516
  /** Other plugin names this plugin depends on. Missing deps produce a warning. */
517
517
  dependencies?: string[];
518
+ /**
519
+ * Inline hook configuration (or a path to a hooks JSON file) declared directly
520
+ * in the manifest, per the official plugin format — an execution surface even
521
+ * when the plugin ships no `hooks/` directory. Untyped because the shape is a
522
+ * path string or an inline event map; capability detection only needs to know
523
+ * whether it is present and non-empty.
524
+ */
525
+ hooks?: unknown;
526
+ /**
527
+ * Inline MCP-server configuration (or a path to an MCP JSON file) declared
528
+ * directly in the manifest — an execution surface even when the plugin ships
529
+ * no `.mcp.json`. Untyped for the same reason as `hooks`.
530
+ */
531
+ mcpServers?: unknown;
518
532
  }
519
533
  /** A plugin found on disk with its parsed manifest and resource inventory. */
520
534
  export interface DiscoveredPlugin {
@@ -637,11 +651,15 @@ export interface Meta {
637
651
  * bundles without an explicit per-bundle policy: `daily` (the default) asks
638
652
  * once per ~7 days, `always` asks every time. `auto` (default on) lets the
639
653
  * first real keychain read of a `daily` bundle populate the broker so
640
- * concurrent runs read silently — set it `false` to force a prompt on every read. */
654
+ * concurrent runs read silently — set it `false` to force a prompt on every read.
655
+ * `holdMs` caps how long an unlocked/auto-cached bundle is held before the next
656
+ * read re-prompts (default 7 days; e.g. 86400000 for a 24h cap). Clamped to
657
+ * [1 minute, 30 days]. */
641
658
  secrets?: {
642
659
  policy?: 'always' | 'daily';
643
660
  agent?: {
644
661
  auto?: boolean;
662
+ holdMs?: number;
645
663
  };
646
664
  };
647
665
  /** Spend guardrails (issue #346). User-global caps; project agents.yaml overrides. */
@@ -698,6 +716,13 @@ export interface Meta {
698
716
  * are never copied. `inline` hosts carry their own address/user.
699
717
  */
700
718
  hosts?: Record<string, HostEntry>;
719
+ /**
720
+ * Declarative fleet profile (`agents apply` / `ag apply`). Additive to the
721
+ * schema — project `agents:` version-pins are untouched. Declares which agents
722
+ * every device should have, which config to sync, and how login propagates.
723
+ * Full shape in `lib/fleet/types.ts` (FleetManifest).
724
+ */
725
+ fleet?: import('./fleet/types.js').FleetManifest;
701
726
  }
702
727
  /** Persisted agent-host entry in agents.yaml (overlay or inline). */
703
728
  export interface HostEntry {
package/dist/lib/usage.js CHANGED
@@ -207,12 +207,14 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3) {
207
207
  parts.push(chalk.gray(plan.padEnd(planWidth)));
208
208
  }
209
209
  if (snapshot) {
210
- // Compact rows show the two windows every agent shares (session + week);
211
- // extra windows (Claude's Sonnet week, Droid's month) render only in the
212
- // full per-version usage section. An exhausted month window still surfaces
213
- // here as the rate-limited badge via deriveUsageStatusFromSnapshot.
210
+ // Compact rows show every BLOCKING window the same set
211
+ // deriveUsageStatusFromSnapshot uses for the rate-limited badge so an
212
+ // account throttled by its month window (Droid meters on 5h/week/month)
213
+ // shows the bar that explains why. Claude's Sonnet week is a per-model
214
+ // sub-limit, not a blocking window; it renders only in the full
215
+ // per-version usage section.
214
216
  const windows = snapshot.windows
215
- .filter((window) => window.key === 'session' || window.key === 'week')
217
+ .filter((window) => window.key !== 'sonnet_week')
216
218
  .map((window) => `${chalk.gray(`${window.shortLabel}:`)} ${renderCompactUsageBar(window.usedPercent)}`);
217
219
  if (windows.length > 0) {
218
220
  parts.push(windows.join(' '));