@phnx-labs/agents-cli 1.20.49 → 1.20.51

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 (100) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +3 -0
  3. package/dist/commands/browser-picker.js +1 -18
  4. package/dist/commands/cloud.js +1 -25
  5. package/dist/commands/computer.d.ts +1 -0
  6. package/dist/commands/computer.js +129 -8
  7. package/dist/commands/doctor.js +133 -2
  8. package/dist/commands/exec.js +49 -6
  9. package/dist/commands/factory.js +1 -4
  10. package/dist/commands/inspect.js +1 -11
  11. package/dist/commands/mcp.js +2 -6
  12. package/dist/commands/message.js +1 -4
  13. package/dist/commands/profiles.js +1 -18
  14. package/dist/commands/repo.js +33 -14
  15. package/dist/commands/resource-view.d.ts +1 -0
  16. package/dist/commands/resource-view.js +5 -17
  17. package/dist/commands/secrets.d.ts +1 -0
  18. package/dist/commands/secrets.js +1 -28
  19. package/dist/commands/sessions-picker.js +1 -18
  20. package/dist/commands/sessions.js +6 -8
  21. package/dist/commands/teams-picker.js +1 -32
  22. package/dist/commands/teams.js +218 -97
  23. package/dist/commands/tmux.js +1 -3
  24. package/dist/commands/view.js +1 -9
  25. package/dist/commands/worktree.js +1 -4
  26. package/dist/lib/agents.d.ts +0 -4
  27. package/dist/lib/agents.js +20 -33
  28. package/dist/lib/auto-dispatch-linear.d.ts +18 -0
  29. package/dist/lib/auto-dispatch-linear.js +107 -0
  30. package/dist/lib/auto-dispatch-provider.d.ts +10 -0
  31. package/dist/lib/auto-dispatch-provider.js +25 -0
  32. package/dist/lib/auto-dispatch.d.ts +87 -0
  33. package/dist/lib/auto-dispatch.js +142 -0
  34. package/dist/lib/browser/cdp.js +11 -2
  35. package/dist/lib/browser/drivers/ssh.d.ts +28 -10
  36. package/dist/lib/browser/drivers/ssh.js +57 -18
  37. package/dist/lib/browser/refs.js +1 -5
  38. package/dist/lib/cli-resources.d.ts +0 -2
  39. package/dist/lib/cli-resources.js +30 -13
  40. package/dist/lib/cloud/rush.d.ts +0 -24
  41. package/dist/lib/cloud/rush.js +0 -31
  42. package/dist/lib/crabbox/cli.js +4 -1
  43. package/dist/lib/crabbox/lease.js +29 -1
  44. package/dist/lib/daemon.js +41 -0
  45. package/dist/lib/exec.js +43 -18
  46. package/dist/lib/format.d.ts +38 -0
  47. package/dist/lib/format.js +108 -0
  48. package/dist/lib/git.d.ts +21 -0
  49. package/dist/lib/git.js +92 -0
  50. package/dist/lib/hooks/cache.d.ts +9 -2
  51. package/dist/lib/hooks/cache.js +220 -8
  52. package/dist/lib/hooks.js +17 -8
  53. package/dist/lib/hosts/passthrough.js +30 -1
  54. package/dist/lib/hosts/progress.d.ts +31 -0
  55. package/dist/lib/hosts/progress.js +35 -0
  56. package/dist/lib/hosts/remote-cmd.d.ts +1 -1
  57. package/dist/lib/hosts/remote-cmd.js +13 -3
  58. package/dist/lib/platform/exec.d.ts +4 -1
  59. package/dist/lib/platform/exec.js +8 -2
  60. package/dist/lib/resources.d.ts +0 -8
  61. package/dist/lib/resources.js +0 -10
  62. package/dist/lib/runner.js +10 -2
  63. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  64. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  65. package/dist/lib/session/active.d.ts +11 -1
  66. package/dist/lib/session/active.js +3 -0
  67. package/dist/lib/session/db.d.ts +1 -4
  68. package/dist/lib/session/db.js +20 -25
  69. package/dist/lib/session/discover.d.ts +2 -2
  70. package/dist/lib/session/discover.js +61 -48
  71. package/dist/lib/session/parse.js +35 -34
  72. package/dist/lib/session/render.d.ts +7 -3
  73. package/dist/lib/session/render.js +15 -9
  74. package/dist/lib/session/state.d.ts +55 -0
  75. package/dist/lib/session/state.js +87 -10
  76. package/dist/lib/session/types.d.ts +9 -0
  77. package/dist/lib/shims.d.ts +25 -2
  78. package/dist/lib/shims.js +73 -8
  79. package/dist/lib/ssh-tunnel.d.ts +33 -2
  80. package/dist/lib/ssh-tunnel.js +94 -7
  81. package/dist/lib/staleness/types.d.ts +0 -1
  82. package/dist/lib/teams/agents.d.ts +108 -1
  83. package/dist/lib/teams/agents.js +511 -11
  84. package/dist/lib/teams/api.d.ts +7 -1
  85. package/dist/lib/teams/api.js +5 -2
  86. package/dist/lib/teams/registry.d.ts +17 -0
  87. package/dist/lib/teams/registry.js +2 -0
  88. package/dist/lib/teams/remoteWorktree.d.ts +57 -0
  89. package/dist/lib/teams/remoteWorktree.js +213 -0
  90. package/dist/lib/teams/scheduler.d.ts +29 -0
  91. package/dist/lib/teams/scheduler.js +78 -0
  92. package/dist/lib/teams/supervisor.js +7 -0
  93. package/dist/lib/types.d.ts +14 -1
  94. package/dist/lib/versions.d.ts +7 -26
  95. package/dist/lib/versions.js +44 -146
  96. package/dist/lib/warn-unpushed.d.ts +40 -0
  97. package/dist/lib/warn-unpushed.js +128 -0
  98. package/package.json +3 -1
  99. package/dist/lib/resources/index.d.ts +0 -53
  100. package/dist/lib/resources/index.js +0 -76
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Shared terminal-formatting helpers.
3
+ *
4
+ * These small utilities were previously copy-pasted across ~20 command and lib
5
+ * files, and had drifted into behavior differences (truncation ellipsis `...`
6
+ * vs `…` vs `.`; `relTime` long "5 minutes ago" vs short "5m ago"; a
7
+ * `visibleWidth` regex missing its `\x1b` escape). This module is the single
8
+ * canonical home — every consumer imports from here.
9
+ */
10
+ import chalk from 'chalk';
11
+ import { readSync } from 'node:fs';
12
+ /** Print `msg` in red to stderr and exit the process with `code`. */
13
+ export function die(msg, code = 1) {
14
+ console.error(chalk.red(msg));
15
+ process.exit(code);
16
+ }
17
+ /**
18
+ * Truncate `s` to at most `max` characters, appending a single-char ellipsis
19
+ * (`…`) when shortened. Character-count based (not ANSI/width aware — use
20
+ * `truncateToWidth` from `session/width.ts` for colored strings).
21
+ */
22
+ export function truncate(s, max) {
23
+ return s.length <= max ? s : s.slice(0, max - 1) + '…';
24
+ }
25
+ /**
26
+ * Format an ISO timestamp as a compact relative age: "just now", "5m ago",
27
+ * "3h ago", "2d ago". The canonical short form — the long "5 minutes ago"
28
+ * variant that once lived in `cloud.ts` is deliberately dropped. (For the
29
+ * session-list long form with calendar fallback, see
30
+ * `formatRelativeTime` in `session/relative-time.ts`.)
31
+ */
32
+ export function relTime(iso) {
33
+ const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
34
+ if (secs < 10)
35
+ return 'just now';
36
+ if (secs < 60)
37
+ return `${secs}s ago`;
38
+ if (secs < 3600)
39
+ return `${Math.floor(secs / 60)}m ago`;
40
+ if (secs < 86400)
41
+ return `${Math.floor(secs / 3600)}h ago`;
42
+ return `${Math.floor(secs / 86400)}d ago`;
43
+ }
44
+ /** Format a millisecond duration as "45s", "3m", "2h 5m", "1d 3h". */
45
+ export function humanDuration(ms) {
46
+ const s = Math.floor(ms / 1000);
47
+ if (s < 60)
48
+ return `${s}s`;
49
+ const m = Math.floor(s / 60);
50
+ if (m < 60)
51
+ return `${m}m`;
52
+ const h = Math.floor(m / 60);
53
+ const mm = m % 60;
54
+ if (h < 24)
55
+ return mm ? `${h}h ${mm}m` : `${h}h`;
56
+ const d = Math.floor(h / 24);
57
+ const hh = h % 24;
58
+ return hh ? `${d}d ${hh}h` : `${d}d`;
59
+ }
60
+ /**
61
+ * Visible column width of `s`, ignoring ANSI SGR color codes (e.g. chalk
62
+ * wrappers). Matches the full CSI sequence including the `\x1b` escape.
63
+ */
64
+ export function visibleWidth(s) {
65
+ // eslint-disable-next-line no-control-regex
66
+ return s.replace(/\x1b\[[0-9;]*m/g, '').length;
67
+ }
68
+ /** Pad `s` with trailing spaces to a target character width. */
69
+ export function padRight(s, width) {
70
+ return s.length >= width ? s : s + ' '.repeat(width - s.length);
71
+ }
72
+ /** Pad `s` with trailing spaces to a target *visible* width (ANSI-aware). */
73
+ export function padVisible(s, width) {
74
+ const w = visibleWidth(s);
75
+ return w >= width ? s : s + ' '.repeat(width - w);
76
+ }
77
+ /** True when `--json` was passed or stdout is not a TTY. */
78
+ export function isJsonMode(opts) {
79
+ return Boolean(opts.json) || !process.stdout.isTTY;
80
+ }
81
+ /** Read all of stdin synchronously and return it UTF-8 decoded and trimmed. */
82
+ export function readStdinSync() {
83
+ const chunks = [];
84
+ const buf = Buffer.alloc(65536);
85
+ while (true) {
86
+ let bytesRead;
87
+ try {
88
+ bytesRead = readSync(0, buf, 0, buf.length, null);
89
+ }
90
+ catch {
91
+ break;
92
+ }
93
+ if (bytesRead === 0)
94
+ break;
95
+ chunks.push(Buffer.from(buf.subarray(0, bytesRead)));
96
+ }
97
+ return Buffer.concat(chunks).toString('utf-8').trim();
98
+ }
99
+ /**
100
+ * Wrap `text` in an OSC 8 hyperlink to `filePath` (as a `file://` URL) when
101
+ * stdout is a TTY; otherwise return `text` unchanged.
102
+ */
103
+ export function termLink(text, filePath) {
104
+ if (!filePath || !process.stdout.isTTY)
105
+ return text;
106
+ const url = `file://${filePath}`;
107
+ return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
108
+ }
package/dist/lib/git.d.ts CHANGED
@@ -104,6 +104,27 @@ export declare function cloneIntoExisting(source: string, targetDir: string): Pr
104
104
  commit: string;
105
105
  error?: string;
106
106
  }>;
107
+ /**
108
+ * Git-back an EXISTING, populated directory from a remote — clone it in place
109
+ * without deleting the local files. Turns a plain `~/.agents` folder (which setup
110
+ * creates as a bare `mkdirSync` and never git-clones — see state.ts ensureAgentsDir)
111
+ * into a real clone of the user's config remote, so `agents repo pull/push` and
112
+ * `agents sync` work on a fresh or Windows machine that never got the manual clone.
113
+ *
114
+ * Unlike cloneIntoExisting (which blindly `checkout .`s over local files), this
115
+ * BACKS UP every tracked file whose local copy differs from the remote — into a
116
+ * sibling `<dir>.pre-adopt-backup/` OUTSIDE the repo so it can't be re-committed —
117
+ * before overwriting it. So a box with local edits to agents.yaml/hooks/rules
118
+ * doesn't silently lose them. Untracked runtime state (.cache/.history/.system,
119
+ * all gitignored) is never touched because `checkout .` only restores tracked paths.
120
+ */
121
+ export declare function adoptRepo(source: string, targetDir: string): Promise<{
122
+ success: boolean;
123
+ commit: string;
124
+ backupDir?: string;
125
+ backedUp: string[];
126
+ error?: string;
127
+ }>;
107
128
  /**
108
129
  * Check if the repo's origin points to the system repo.
109
130
  */
package/dist/lib/git.js CHANGED
@@ -462,6 +462,98 @@ export async function cloneIntoExisting(source, targetDir) {
462
462
  return { success: false, commit: '', error: err.message };
463
463
  }
464
464
  }
465
+ /**
466
+ * Git-back an EXISTING, populated directory from a remote — clone it in place
467
+ * without deleting the local files. Turns a plain `~/.agents` folder (which setup
468
+ * creates as a bare `mkdirSync` and never git-clones — see state.ts ensureAgentsDir)
469
+ * into a real clone of the user's config remote, so `agents repo pull/push` and
470
+ * `agents sync` work on a fresh or Windows machine that never got the manual clone.
471
+ *
472
+ * Unlike cloneIntoExisting (which blindly `checkout .`s over local files), this
473
+ * BACKS UP every tracked file whose local copy differs from the remote — into a
474
+ * sibling `<dir>.pre-adopt-backup/` OUTSIDE the repo so it can't be re-committed —
475
+ * before overwriting it. So a box with local edits to agents.yaml/hooks/rules
476
+ * doesn't silently lose them. Untracked runtime state (.cache/.history/.system,
477
+ * all gitignored) is never touched because `checkout .` only restores tracked paths.
478
+ */
479
+ export async function adoptRepo(source, targetDir) {
480
+ const trimmed = source.trim();
481
+ if (fs.existsSync(path.join(targetDir, '.git'))) {
482
+ return { success: false, commit: '', backedUp: [], error: 'Already a git repo — nothing to adopt' };
483
+ }
484
+ // Preserve the user's transport. `parseSource` THROWS for `ssh://` and any
485
+ // non-github `git@host:` URL, and rewrites `git@github.com:x` → https (breaking
486
+ // SSH-key-only auth — the common config-repo setup — so a private clone hangs on
487
+ // a credential prompt). So for an SSH URL, clone it AS-IS and never call
488
+ // parseSource; for everything else, normalize + reject local via parseSource —
489
+ // inside the try, so a malformed URL returns a graceful error, not a stack trace.
490
+ const isSsh = trimmed.startsWith('git@') || trimmed.startsWith('ssh://');
491
+ const tempDir = path.join(targetDir, '.git-adopt-temp');
492
+ try {
493
+ let cloneUrl;
494
+ let ref;
495
+ if (isSsh) {
496
+ cloneUrl = trimmed; // SSH stays SSH; clone the remote's default HEAD.
497
+ }
498
+ else {
499
+ const parsed = parseSource(source);
500
+ if (parsed.type === 'local') {
501
+ return { success: false, commit: '', backedUp: [], error: 'Cannot adopt from a local source' };
502
+ }
503
+ cloneUrl = parsed.url;
504
+ ref = parsed.ref;
505
+ }
506
+ assertSafeGitTransport(cloneUrl);
507
+ fs.mkdirSync(targetDir, { recursive: true });
508
+ // Idempotency: clear a stale temp left by an interrupted prior run.
509
+ if (fs.existsSync(tempDir))
510
+ fs.rmSync(tempDir, { recursive: true, force: true });
511
+ // Clone to temp, then move its .git in so the index == remote HEAD.
512
+ // Fail fast on a missing credential instead of hanging on a prompt: set
513
+ // GIT_TERMINAL_PROMPT=0 on the inherited env directly rather than via
514
+ // simple-git's `.env()`, which validates and rejects command-like vars the
515
+ // harness may set (GIT_EDITOR, PAGER, …) — the child inherits process.env,
516
+ // and non-interactive git is what we always want in the CLI anyway.
517
+ process.env.GIT_TERMINAL_PROMPT = '0';
518
+ await simpleGit().clone(cloneUrl, tempDir);
519
+ const repoGit = simpleGit(tempDir);
520
+ if (ref)
521
+ await repoGit.checkout(ref);
522
+ fs.renameSync(path.join(tempDir, '.git'), path.join(targetDir, '.git'));
523
+ fs.rmSync(tempDir, { recursive: true, force: true });
524
+ const targetGit = simpleGit(targetDir);
525
+ // Back up any TRACKED file whose local copy differs from the remote before the
526
+ // checkout clobbers it. `diff --name-only` (worktree vs the moved-in index) is
527
+ // exactly that set; a deleted-locally file has nothing to preserve.
528
+ const diff = await targetGit.diff(['--name-only']);
529
+ const clobbered = diff.split('\n').map((s) => s.trim()).filter(Boolean);
530
+ let backupDir;
531
+ const backedUp = [];
532
+ if (clobbered.length > 0) {
533
+ backupDir = path.join(path.dirname(targetDir), path.basename(targetDir) + '.pre-adopt-backup');
534
+ for (const rel of clobbered) {
535
+ const src = path.join(targetDir, rel);
536
+ if (!fs.existsSync(src))
537
+ continue;
538
+ const dst = path.join(backupDir, rel);
539
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
540
+ fs.copyFileSync(src, dst);
541
+ backedUp.push(rel);
542
+ }
543
+ }
544
+ // Materialize the remote's tracked files (respects .gitignore, so
545
+ // .cache/.history/.system stay put), overwriting the now-backed-up locals.
546
+ await targetGit.checkout('.');
547
+ installGithooksSymlinks(targetDir);
548
+ const log = await targetGit.log({ maxCount: 1 });
549
+ return { success: true, commit: log.latest?.hash.slice(0, 8) || 'unknown', backupDir, backedUp };
550
+ }
551
+ catch (err) {
552
+ if (fs.existsSync(tempDir))
553
+ fs.rmSync(tempDir, { recursive: true, force: true });
554
+ return { success: false, commit: '', backedUp: [], error: err.message };
555
+ }
556
+ }
465
557
  /**
466
558
  * Check if the repo's origin points to the system repo.
467
559
  */
@@ -1,4 +1,4 @@
1
- import type { HookCache, HookCacheConfig } from '../types.js';
1
+ import type { HookCache, HookCacheConfig, HookMatches } from '../types.js';
2
2
  /**
3
3
  * Parse a `cache:` value from hooks.yaml into the canonical config form.
4
4
  * Accepts the shorthand string ("5m", "30s-bg") or the full object form.
@@ -30,11 +30,18 @@ export interface HookShimPaths {
30
30
  /**
31
31
  * Generate (or refresh) the shim script for a hook. Idempotent — only writes
32
32
  * when the content differs from what's on disk. Returns the absolute shim path.
33
+ *
34
+ * A shim is generated when the hook opts into caching (`cache`) and/or declares
35
+ * `matches:` predicates. When `matches` is present the shim gates execution on
36
+ * those predicates before running the underlying script (see `renderShim`);
37
+ * when `cache` is absent the shim is a thin pass-through wrapper that only
38
+ * applies the gate and forwards stdin/stdout unchanged.
33
39
  */
34
40
  export declare function generateHookShim(args: {
35
41
  name: string;
36
42
  scriptPath: string;
37
- cache: HookCacheConfig;
43
+ cache?: HookCacheConfig | null;
44
+ matches?: HookMatches;
38
45
  paths?: HookShimPaths;
39
46
  }): string;
40
47
  /**
@@ -105,13 +105,19 @@ export function getHookShimPath(name) {
105
105
  /**
106
106
  * Generate (or refresh) the shim script for a hook. Idempotent — only writes
107
107
  * when the content differs from what's on disk. Returns the absolute shim path.
108
+ *
109
+ * A shim is generated when the hook opts into caching (`cache`) and/or declares
110
+ * `matches:` predicates. When `matches` is present the shim gates execution on
111
+ * those predicates before running the underlying script (see `renderShim`);
112
+ * when `cache` is absent the shim is a thin pass-through wrapper that only
113
+ * applies the gate and forwards stdin/stdout unchanged.
108
114
  */
109
115
  export function generateHookShim(args) {
110
116
  const shimsDir = args.paths?.shimsDir ?? getHookShimsDir();
111
117
  const cacheDir = args.paths?.cacheDir ?? getHookCacheDir();
112
118
  const logsDir = args.paths?.logsDir ?? getLogsDir();
113
119
  const shimPath = resolveContainedHookShimPath(shimsDir, args.name);
114
- const content = renderShim(args.name, args.scriptPath, args.cache, { cacheDir, logsDir });
120
+ const content = renderShim(args.name, args.scriptPath, args.cache ?? null, args.matches, { cacheDir, logsDir });
115
121
  fs.mkdirSync(shimsDir, { recursive: true });
116
122
  let existing = null;
117
123
  if (fs.existsSync(shimPath)) {
@@ -132,24 +138,204 @@ export function generateHookShim(args) {
132
138
  }
133
139
  return shimPath;
134
140
  }
141
+ /**
142
+ * The matches: gate, as a self-contained Python program run once per fire.
143
+ *
144
+ * Reads the hook's `matches:` block from $MATCHES_JSON and the event JSON from
145
+ * stdin, then prints `FIRE` or `SKIP`. A faithful port of `shouldFire()` in
146
+ * src/lib/hooks/match.ts — all declared predicates AND together, an empty block
147
+ * always fires, and the same ReDoS guard (`isSafeHookRegex`) rejects unsafe
148
+ * regexes to `SKIP`. Kept in double-quotes/apostrophe-free so it survives being
149
+ * embedded in a single-quoted `python -c '...'` argument in the shim. Behavioural
150
+ * parity with shouldFire() is pinned by a conformance test (match-parity.test.ts).
151
+ *
152
+ * On any exception it does NOT print SKIP — the shim treats a missing/garbled
153
+ * verdict as FIRE (fail-open), so a broken gate never silently disables a hook.
154
+ */
155
+ const GATE_PY = `import json, os, re, subprocess, sys
156
+
157
+ def arr(v):
158
+ if v is None:
159
+ return []
160
+ return v if isinstance(v, list) else [v]
161
+
162
+ def max_group_depth(src):
163
+ depth = 0
164
+ mx = 0
165
+ escaped = False
166
+ in_class = False
167
+ for ch in src:
168
+ if escaped:
169
+ escaped = False
170
+ continue
171
+ if ch == chr(92):
172
+ escaped = True
173
+ continue
174
+ if ch == "[":
175
+ in_class = True
176
+ continue
177
+ if ch == "]":
178
+ in_class = False
179
+ continue
180
+ if in_class:
181
+ continue
182
+ if ch == "(":
183
+ depth += 1
184
+ if depth > mx:
185
+ mx = depth
186
+ elif ch == ")" and depth > 0:
187
+ depth -= 1
188
+ return mx
189
+
190
+ _NESTED = re.compile(r"\\((?:\\?:)?[^)]*[*+][?+*{,\\d}]*[^)]*\\)\\s*(?:[+*]|\\{\\d*,?\\d*\\})")
191
+
192
+ def is_safe(src):
193
+ if len(src) > 200:
194
+ return False
195
+ if max_group_depth(src) > 3:
196
+ return False
197
+ if _NESTED.search(src):
198
+ return False
199
+ return True
200
+
201
+ def compile_rx(src):
202
+ if not is_safe(src):
203
+ return None
204
+ try:
205
+ return re.compile(src)
206
+ except re.error:
207
+ return None
208
+
209
+ def find_root(start):
210
+ d = os.path.abspath(start)
211
+ while True:
212
+ if os.path.exists(os.path.join(d, ".git")):
213
+ return d
214
+ parent = os.path.dirname(d)
215
+ if parent == d:
216
+ return None
217
+ d = parent
218
+
219
+ def git_dirty(cwd):
220
+ try:
221
+ out = subprocess.run(
222
+ ["git", "status", "--porcelain"],
223
+ cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
224
+ )
225
+ return len(out.stdout.decode().strip()) > 0
226
+ except Exception:
227
+ return False
228
+
229
+ def should_fire():
230
+ m = json.loads(os.environ.get("MATCHES_JSON") or "{}")
231
+ if not m:
232
+ return True
233
+ try:
234
+ inp = json.load(sys.stdin)
235
+ except Exception:
236
+ inp = {}
237
+ cwd = inp.get("cwd") or os.getcwd()
238
+
239
+ v = m.get("prompt_contains")
240
+ if v is not None:
241
+ if v not in (inp.get("prompt") or ""):
242
+ return False
243
+
244
+ v = m.get("prompt_matches")
245
+ if v is not None:
246
+ rx = compile_rx(v)
247
+ if rx is None or not rx.search(inp.get("prompt") or ""):
248
+ return False
249
+
250
+ v = m.get("tool_name")
251
+ if v is not None:
252
+ allowed = arr(v)
253
+ if allowed:
254
+ tn = inp.get("tool_name")
255
+ if not tn or tn not in allowed:
256
+ return False
257
+
258
+ v = m.get("tool_args_match")
259
+ if v is not None:
260
+ ta = inp.get("tool_args")
261
+ ser = ta if isinstance(ta, str) else json.dumps(ta if ta is not None else "", separators=(",", ":"))
262
+ rx = compile_rx(v)
263
+ if rx is None or not rx.search(ser):
264
+ return False
265
+
266
+ v = m.get("cwd_includes")
267
+ if v is not None:
268
+ needles = arr(v)
269
+ if needles and not any(n in cwd for n in needles):
270
+ return False
271
+
272
+ v = m.get("project_has")
273
+ if v is not None:
274
+ root = find_root(cwd)
275
+ if not root or not os.path.exists(os.path.join(root, v)):
276
+ return False
277
+
278
+ v = m.get("git_dirty")
279
+ if v is not None:
280
+ if bool(v) != git_dirty(cwd):
281
+ return False
282
+
283
+ return True
284
+
285
+ print("FIRE" if should_fire() else "SKIP")
286
+ `;
287
+ /**
288
+ * Gate-only pass-through tail: no caching, just run the underlying script with
289
+ * stdin forwarded and stdout/exit code propagated, plus one timing log line.
290
+ * Used when a hook declares \`matches:\` but no \`cache:\`.
291
+ */
292
+ const PASSTHROUGH_TAIL = `now_ns() { "$PY" -c 'import time; print(int(time.time()*1e9))'; }
293
+ START_NS=$(now_ns)
294
+ EXIT=0
295
+ if printf '%s' "$STDIN_PAYLOAD" | "$SOURCE"; then
296
+ EXIT=0
297
+ else
298
+ EXIT=$?
299
+ fi
300
+ END_NS=$(now_ns)
301
+ MS=$(( (END_NS - START_NS) / 1000000 ))
302
+ TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
303
+ LOG_FILE="$LOGS_DIR/events-$(date -u +%Y-%m-%d).jsonl"
304
+ printf '{"ts":"%s","event":"hook.fire","hook":"%s","ms":%d,"cache":"%s","exit":%d}\\n' \\
305
+ "$TS" "$HOOK_NAME" "$MS" "none" "$EXIT" >>"$LOG_FILE" 2>/dev/null || true
306
+
307
+ exit "$EXIT"`;
135
308
  /**
136
309
  * Render the bash shim. Bash 3.2-compatible (macOS default). Uses Python for
137
310
  * hashing + monotonic-ish nanosecond timing + portable mtime, resolved at
138
311
  * runtime (python3, then python) so a Windows Microsoft Store `python3` alias
139
312
  * stub — which exits non-zero without running — doesn't silently break caching.
313
+ *
314
+ * When `matches` is set, an early gate block evaluates the `matches:` predicates
315
+ * against the event JSON on stdin and exits 0 without running the script when
316
+ * they don't hold — this is the runtime enforcement of the documented `matches:`
317
+ * gating (mirrors `shouldFire()` in match.ts). When `cache` is null the shim is
318
+ * a gate-only pass-through: it forwards stdin to the script and its stdout back,
319
+ * with no cache read/write.
140
320
  */
141
- function renderShim(name, scriptPath, cache, paths) {
142
- const ttl = typeof cache.ttl === 'number' ? cache.ttl : (parseDuration(cache.ttl) ?? 0);
143
- const key = cache.key ?? 'global';
144
- const prefetch = cache.prefetch ?? 'none';
321
+ function renderShim(name, scriptPath, cache, matches, paths) {
322
+ const ttl = cache ? (typeof cache.ttl === 'number' ? cache.ttl : (parseDuration(cache.ttl) ?? 0)) : 0;
323
+ const key = cache?.key ?? 'global';
324
+ const prefetch = cache?.prefetch ?? 'none';
145
325
  const { cacheDir, logsDir } = paths;
326
+ const hasMatches = matches != null && Object.keys(matches).length > 0;
327
+ const matchesJson = hasMatches ? JSON.stringify(matches) : '';
146
328
  // sh-escape: wrap in single quotes, escape any embedded single quotes.
147
329
  const q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
330
+ const cacheHeader = cache
331
+ ? `# Cache: key=${key} ttl=${ttl}s prefetch=${prefetch}`
332
+ : `# Cache: none (gate-only pass-through)`;
333
+ const matchesHeader = hasMatches ? `\n# Matches: ${matchesJson}` : '';
148
334
  return `#!/usr/bin/env bash
149
335
  # GENERATED by agents-cli. Do not edit — re-run \`agents hooks sync\` to refresh.
150
336
  # Hook: ${name}
151
337
  # Source: ${scriptPath}
152
- # Cache: key=${key} ttl=${ttl}s prefetch=${prefetch}
338
+ ${cacheHeader}${matchesHeader}
153
339
  set -u
154
340
 
155
341
  HOOK_NAME=${q(name)}
@@ -159,6 +345,7 @@ LOGS_DIR=${q(logsDir)}
159
345
  TTL=${ttl}
160
346
  PREFETCH=${q(prefetch)}
161
347
  KEY_MODE=${q(key)}
348
+ MATCHES_JSON=${q(matchesJson)}
162
349
 
163
350
  mkdir -p "$CACHE_DIR" "$LOGS_DIR"
164
351
 
@@ -178,7 +365,33 @@ done
178
365
  # Read stdin once (Claude/Codex/Gemini pass JSON on stdin to every hook).
179
366
  STDIN_PAYLOAD="$(cat || true)"
180
367
 
181
- # Portable sha1 \`shasum\` is Perl, missing on minimal Linux images;
368
+ # --- matches: gate (issue #744 / RUSH-1506) -------------------------------
369
+ # Enforce the hook's declared \`matches:\` predicates at fire time. Mirrors
370
+ # shouldFire() in src/lib/hooks/match.ts: all declared predicates AND together;
371
+ # an empty/absent block always fires. When the predicates don't hold we exit 0
372
+ # WITHOUT running the script (a skipped hook is not an error). Fail-open: any
373
+ # gate-eval error runs the script, so a broken predicate can never silently
374
+ # disable a safety hook (e.g. git-guard).
375
+ if [ -n "$MATCHES_JSON" ]; then
376
+ _GATE="$(printf '%s' "$STDIN_PAYLOAD" | MATCHES_JSON="$MATCHES_JSON" "$PY" -c ${q(GATE_PY)} 2>/dev/null || printf FIRE)"
377
+ [ -z "$_GATE" ] && _GATE=FIRE
378
+ if [ "$_GATE" = SKIP ]; then
379
+ _TS=$(date -u +%Y-%m-%dT%H:%M:%SZ)
380
+ _LOG_FILE="$LOGS_DIR/events-$(date -u +%Y-%m-%d).jsonl"
381
+ printf '{"ts":"%s","event":"hook.fire","hook":"%s","ms":0,"cache":"skip","exit":0}\\n' \\
382
+ "$_TS" "$HOOK_NAME" >>"$_LOG_FILE" 2>/dev/null || true
383
+ exit 0
384
+ fi
385
+ fi
386
+ ${cache ? CACHE_TAIL : PASSTHROUGH_TAIL}
387
+ `;
388
+ }
389
+ /**
390
+ * Cache tail: the full cache lookup / stale-while-revalidate / timing machinery.
391
+ * Emitted only when the hook opts into \`cache:\`. (When only \`matches:\` is set,
392
+ * PASSTHROUGH_TAIL runs instead — no cache read/write.)
393
+ */
394
+ const CACHE_TAIL = `# Portable sha1 — \`shasum\` is Perl, missing on minimal Linux images;
182
395
  # \`sha1sum\` is coreutils, missing on macOS. Truncate to 12 hex chars.
183
396
  sha1_12() { "$PY" -c 'import hashlib,sys; print(hashlib.sha1(sys.stdin.read().encode()).hexdigest()[:12])'; }
184
397
 
@@ -267,7 +480,6 @@ printf '{"ts":"%s","event":"hook.fire","hook":"%s","ms":%d,"cache":"%s","exit":%
267
480
 
268
481
  exit "$EXIT"
269
482
  `;
270
- }
271
483
  /**
272
484
  * Remove a hook's shim. Called by the registrar's garbage collection when a
273
485
  * hook is renamed/deleted or has its `cache:` field removed.
package/dist/lib/hooks.js CHANGED
@@ -153,10 +153,12 @@ import { getHookShimsDir } from './state.js';
153
153
  /**
154
154
  * Resolve the command path to register for a hook.
155
155
  *
156
- * Returns either the raw script path (no `cache:` set, legacy behavior) or
157
- * the path to a generated caching/timing shim. The shim is written as a
158
- * side effect when `cache:` is configured. The agent-native settings file
159
- * gets the same shape either way just a different command path.
156
+ * Returns either the raw script path (neither `cache:` nor `matches:` set,
157
+ * legacy behavior) or the path to a generated wrapper shim. The shim is written
158
+ * as a side effect when `cache:` and/or `matches:` is configured it enforces
159
+ * the `matches:` gate at fire time and layers the caching/timing machinery when
160
+ * `cache:` is set. The agent-native settings file gets the same shape either
161
+ * way — just a different command path.
160
162
  */
161
163
  function resolveHookCommand(name, hookDef, resolveScript) {
162
164
  const scriptPath = resolveScript(hookDef.script);
@@ -165,13 +167,20 @@ function resolveHookCommand(name, hookDef, resolveScript) {
165
167
  if (!isValidHookShimName(name))
166
168
  return null;
167
169
  const cache = parseCacheConfig(hookDef.cache);
168
- if (!cache) {
169
- // No caching opted in make sure a previously generated shim from an
170
- // earlier `cache:` config is gone so the JSONL doesn't keep claiming hits.
170
+ const matches = hookDef.matches;
171
+ const hasMatches = matches != null && Object.keys(matches).length > 0;
172
+ if (!cache && !hasMatches) {
173
+ // No caching and no matches: gate opted in — make sure a previously
174
+ // generated shim from an earlier `cache:`/`matches:` config is gone so the
175
+ // JSONL doesn't keep claiming hits.
171
176
  removeHookShim(name);
172
177
  return toPortableCommand(scriptPath);
173
178
  }
174
- return toPortableCommand(generateHookShim({ name, scriptPath, cache }));
179
+ // A shim is generated when the hook opts into caching and/or declares
180
+ // `matches:` predicates. The shim enforces the `matches:` gate at fire time
181
+ // (skipping the script when predicates don't hold) and, when `cache:` is set,
182
+ // layers the cache/timing machinery on top.
183
+ return toPortableCommand(generateHookShim({ name, scriptPath, cache, matches }));
175
184
  }
176
185
  /**
177
186
  * Extensions that are NEVER hooks — docs, configuration, plain data. A file
@@ -91,6 +91,20 @@ export async function maybeRunOnHost(command, allArgs) {
91
91
  const spec = REMOTE_PASSTHROUGH[command];
92
92
  if (!spec)
93
93
  return false;
94
+ // Placement, not routing: `teams add`/`teams create` read `--device`/`--devices`
95
+ // (and `--host`/`--hosts`) as WHERE to place a teammate / the team pool — the
96
+ // command itself always runs locally on the orchestrator. Bail before the
97
+ // generic teams routing below so those flags reach the local action. Every
98
+ // other teams subcommand (`status`/`logs`/`stop`/…) keeps `--host` routing.
99
+ // Find the subcommand = the first non-flag token AFTER `teams` (robust to any
100
+ // leading global flags), then bail for the add/create aliases.
101
+ if (command === 'teams') {
102
+ const teamsIdx = allArgs.indexOf('teams');
103
+ const sub = teamsIdx >= 0 ? allArgs.slice(teamsIdx + 1).find((a) => !a.startsWith('-')) : undefined;
104
+ if (sub === 'add' || sub === 'a' || sub === 'create' || sub === 'c' || sub === 'new') {
105
+ return false;
106
+ }
107
+ }
94
108
  // `--device` is a first-class alias of `--host` (mirrors `agents run`); the
95
109
  // device registry is the source of truth for machine identity. Reject a
96
110
  // conflicting pair rather than silently preferring one — same rule as run.
@@ -101,6 +115,11 @@ export async function maybeRunOnHost(command, allArgs) {
101
115
  process.exitCode = 1;
102
116
  return true;
103
117
  }
118
+ // `--devices` / `--hosts` fan out to every registered device locally; don't
119
+ // let a per-host passthrough turn it into a cascading remote fan-out.
120
+ const fleetFlag = allArgs.includes('--devices') || allArgs.includes('--hosts');
121
+ if (fleetFlag)
122
+ return false;
104
123
  const hostName = hostFlag ?? deviceFlag;
105
124
  if (!hostName)
106
125
  return false;
@@ -142,7 +161,17 @@ export async function maybeRunOnHost(command, allArgs) {
142
161
  }
143
162
  return true;
144
163
  }
145
- const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd, resolveRemoteOsSync(host.name));
164
+ // Doctor commands probe the agent CLIs; remote POSIX login shells often don't
165
+ // have the agents shims on PATH, which produces false "not installed" negatives.
166
+ // Bootstrap PATH with the canonical shim locations before the remote command.
167
+ // Windows is skipped: PowerShell usually has the shim dir via the install
168
+ // profile, and single-quoted env values would not expand $HOME/$PATH.
169
+ const isDoctorCommand = command === 'doctor' || (command === 'teams' && forwarded[1] === 'doctor');
170
+ const remoteOs = resolveRemoteOsSync(host.name);
171
+ const env = isDoctorCommand && !/^win/i.test((remoteOs ?? '').trim())
172
+ ? { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' }
173
+ : undefined;
174
+ const remoteCmd = buildRemoteAgentsInvocation(forwarded, remoteCwd, remoteOs, env);
146
175
  const code = sshStream(target, remoteCmd, { tty: interactive, multiplex: true });
147
176
  if (code === 255) {
148
177
  console.error(chalk.red(`${host.name}: unreachable over SSH (asleep, offline, or host key changed?).`) +
@@ -13,6 +13,37 @@
13
13
  * toward `maxPollMs` while the job is idle, so a quiet long-running follow no
14
14
  * longer spawns thousands of ssh processes per hour on the laptop.
15
15
  */
16
+ /**
17
+ * Cap for the LOCAL mirror of a distributed teammate's remote log. `followHostTask`
18
+ * appends remote bytes into the local mirror forever; a team can spin 10+ chatty
19
+ * remote teammates, so the orchestrator must keep a bounded window (the full log
20
+ * always lives on the host). The teams remote path (agents.ts readNewEvents) writes
21
+ * its own append into each teammate's `stdout.log`, then truncates that file to its
22
+ * trailing `REMOTE_MIRROR_MAX_BYTES` — the parser has already consumed the bytes
23
+ * (status/digest updated via lastReadPos), so trailing-tail history is dead weight.
24
+ */
25
+ export declare const REMOTE_MIRROR_MAX_BYTES: number;
26
+ /**
27
+ * Pull the new bytes of a remote log since `offset` in ONE ssh round-trip.
28
+ *
29
+ * The teams remote-teammate monitor calls this each poll to advance its offset-tail
30
+ * cursor into the host's log, mirroring only the delta into the local `stdout.log`
31
+ * the stream-json parser consumes. Byte-exact (raw Buffer, no UTF-8 decode) so a
32
+ * multibyte character split at the `tail -c` boundary neither drifts the offset nor
33
+ * renders as U+FFFD — the same discipline `fetchProgress` uses. `bytes.length` is
34
+ * the exact wire count; `newOffset` is `offset + bytes.length`.
35
+ *
36
+ * Returns null on a transient ssh failure (the caller retries next poll without
37
+ * advancing). `remoteLog` is a $HOME-prefixed path with a safe basename; it's
38
+ * shell-quoted defensively even so.
39
+ */
40
+ export declare function pullRemoteLogDelta(target: string, opts: {
41
+ remoteLog: string;
42
+ offset: number;
43
+ }): {
44
+ bytes: Buffer;
45
+ newOffset: number;
46
+ } | null;
16
47
  export interface FollowOptions {
17
48
  remoteLog: string;
18
49
  remoteExit: string;