@phnx-labs/agents-cli 1.20.50 → 1.20.52

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 (124) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/commands/browser-picker.js +1 -18
  3. package/dist/commands/browser.js +215 -7
  4. package/dist/commands/cloud.js +7 -25
  5. package/dist/commands/computer.d.ts +1 -0
  6. package/dist/commands/computer.js +129 -8
  7. package/dist/commands/events.d.ts +1 -1
  8. package/dist/commands/events.js +2 -3
  9. package/dist/commands/exec.js +66 -8
  10. package/dist/commands/factory.js +9 -4
  11. package/dist/commands/feed.d.ts +9 -0
  12. package/dist/commands/feed.js +69 -0
  13. package/dist/commands/inspect.js +1 -11
  14. package/dist/commands/logs.d.ts +5 -1
  15. package/dist/commands/logs.js +248 -3
  16. package/dist/commands/mcp.js +9 -6
  17. package/dist/commands/message.js +1 -4
  18. package/dist/commands/profiles.js +1 -18
  19. package/dist/commands/repo.js +33 -14
  20. package/dist/commands/resource-view.d.ts +1 -0
  21. package/dist/commands/resource-view.js +5 -17
  22. package/dist/commands/secrets.d.ts +23 -0
  23. package/dist/commands/secrets.js +174 -70
  24. package/dist/commands/sessions-picker.js +1 -18
  25. package/dist/commands/sessions.js +6 -8
  26. package/dist/commands/teams-picker.js +1 -32
  27. package/dist/commands/teams.js +5 -27
  28. package/dist/commands/tmux.js +1 -3
  29. package/dist/commands/view.js +1 -9
  30. package/dist/commands/worktree.js +1 -4
  31. package/dist/index.js +6 -2
  32. package/dist/lib/agents.d.ts +0 -4
  33. package/dist/lib/agents.js +20 -33
  34. package/dist/lib/auto-dispatch-linear.d.ts +18 -0
  35. package/dist/lib/auto-dispatch-linear.js +107 -0
  36. package/dist/lib/auto-dispatch-provider.d.ts +10 -0
  37. package/dist/lib/auto-dispatch-provider.js +25 -0
  38. package/dist/lib/auto-dispatch.d.ts +87 -0
  39. package/dist/lib/auto-dispatch.js +142 -0
  40. package/dist/lib/browser/cdp.js +11 -2
  41. package/dist/lib/browser/drivers/ssh.d.ts +28 -10
  42. package/dist/lib/browser/drivers/ssh.js +57 -18
  43. package/dist/lib/browser/login-detection.d.ts +94 -0
  44. package/dist/lib/browser/login-detection.js +274 -0
  45. package/dist/lib/browser/profiles.d.ts +17 -8
  46. package/dist/lib/browser/profiles.js +27 -8
  47. package/dist/lib/browser/refs.js +1 -5
  48. package/dist/lib/browser/secret-ref.d.ts +10 -0
  49. package/dist/lib/browser/secret-ref.js +14 -0
  50. package/dist/lib/browser/service.js +14 -12
  51. package/dist/lib/cli-resources.d.ts +0 -2
  52. package/dist/lib/cli-resources.js +30 -13
  53. package/dist/lib/cloud/rush.d.ts +15 -24
  54. package/dist/lib/cloud/rush.js +7 -32
  55. package/dist/lib/crabbox/cli.js +4 -1
  56. package/dist/lib/crabbox/lease.d.ts +6 -0
  57. package/dist/lib/crabbox/lease.js +40 -10
  58. package/dist/lib/crabbox/runtimes.d.ts +38 -1
  59. package/dist/lib/crabbox/runtimes.js +98 -5
  60. package/dist/lib/daemon.d.ts +12 -9
  61. package/dist/lib/daemon.js +73 -17
  62. package/dist/lib/events.d.ts +31 -5
  63. package/dist/lib/events.js +288 -101
  64. package/dist/lib/exec.js +32 -14
  65. package/dist/lib/feed.d.ts +56 -0
  66. package/dist/lib/feed.js +251 -0
  67. package/dist/lib/format.d.ts +38 -0
  68. package/dist/lib/format.js +108 -0
  69. package/dist/lib/git.d.ts +21 -0
  70. package/dist/lib/git.js +92 -0
  71. package/dist/lib/hooks/cache.d.ts +9 -2
  72. package/dist/lib/hooks/cache.js +220 -8
  73. package/dist/lib/hooks.js +24 -10
  74. package/dist/lib/hosts/passthrough.js +1 -0
  75. package/dist/lib/platform/exec.d.ts +4 -1
  76. package/dist/lib/platform/exec.js +8 -2
  77. package/dist/lib/resources.d.ts +0 -8
  78. package/dist/lib/resources.js +0 -10
  79. package/dist/lib/rotate.js +2 -0
  80. package/dist/lib/runner.js +10 -2
  81. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  83. package/dist/lib/secrets/agent.d.ts +21 -0
  84. package/dist/lib/secrets/agent.js +63 -1
  85. package/dist/lib/secrets/bundles.d.ts +33 -1
  86. package/dist/lib/secrets/bundles.js +38 -8
  87. package/dist/lib/secrets/icloud-import.d.ts +70 -0
  88. package/dist/lib/secrets/icloud-import.js +173 -0
  89. package/dist/lib/secrets/index.d.ts +36 -0
  90. package/dist/lib/secrets/index.js +99 -9
  91. package/dist/lib/secrets/remote.js +1 -1
  92. package/dist/lib/secrets/sync.js +1 -1
  93. package/dist/lib/session/active.d.ts +11 -1
  94. package/dist/lib/session/active.js +3 -0
  95. package/dist/lib/session/db.d.ts +1 -4
  96. package/dist/lib/session/db.js +20 -25
  97. package/dist/lib/session/discover.d.ts +2 -2
  98. package/dist/lib/session/discover.js +60 -48
  99. package/dist/lib/session/parse.js +35 -34
  100. package/dist/lib/session/render.d.ts +7 -3
  101. package/dist/lib/session/render.js +15 -9
  102. package/dist/lib/session/state.d.ts +55 -0
  103. package/dist/lib/session/state.js +100 -11
  104. package/dist/lib/session/types.d.ts +9 -0
  105. package/dist/lib/shims.d.ts +9 -3
  106. package/dist/lib/shims.js +44 -8
  107. package/dist/lib/ssh-tunnel.d.ts +33 -2
  108. package/dist/lib/ssh-tunnel.js +94 -7
  109. package/dist/lib/staleness/types.d.ts +0 -1
  110. package/dist/lib/startup/command-registry.d.ts +1 -0
  111. package/dist/lib/startup/command-registry.js +2 -0
  112. package/dist/lib/state.d.ts +2 -0
  113. package/dist/lib/state.js +25 -8
  114. package/dist/lib/teams/agents.js +6 -3
  115. package/dist/lib/types.d.ts +24 -1
  116. package/dist/lib/versions.d.ts +0 -26
  117. package/dist/lib/versions.js +2 -145
  118. package/dist/lib/warn-unpushed.d.ts +40 -0
  119. package/dist/lib/warn-unpushed.js +128 -0
  120. package/dist/lib/whats-new.d.ts +5 -3
  121. package/dist/lib/whats-new.js +25 -5
  122. package/package.json +3 -1
  123. package/dist/lib/resources/index.d.ts +0 -53
  124. package/dist/lib/resources/index.js +0 -76
@@ -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
@@ -1019,9 +1028,11 @@ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPre
1019
1028
  const configDir = path.join(versionHome, configDirName);
1020
1029
  const settingsPath = path.join(configDir, 'settings.json');
1021
1030
  let config = {};
1031
+ let existingRaw;
1022
1032
  if (fs.existsSync(settingsPath)) {
1023
1033
  try {
1024
- config = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
1034
+ existingRaw = fs.readFileSync(settingsPath, 'utf-8');
1035
+ config = JSON.parse(existingRaw);
1025
1036
  }
1026
1037
  catch {
1027
1038
  errors.push('Failed to parse settings.json');
@@ -1102,7 +1113,10 @@ function registerHooksForClaude(versionHome, manifest, resolveScript, managedPre
1102
1113
  }
1103
1114
  try {
1104
1115
  fs.mkdirSync(configDir, { recursive: true });
1105
- fs.writeFileSync(settingsPath, JSON.stringify(config, null, 2), 'utf-8');
1116
+ const nextRaw = JSON.stringify(config, null, 2);
1117
+ if (existingRaw !== nextRaw) {
1118
+ fs.writeFileSync(settingsPath, nextRaw, 'utf-8');
1119
+ }
1106
1120
  }
1107
1121
  catch (err) {
1108
1122
  errors.push(`Failed to write settings.json: ${err.message}`);
@@ -34,6 +34,7 @@ const REMOTE_PASSTHROUGH = {
34
34
  sync: { nonInteractive: ['--yes'] },
35
35
  teams: {},
36
36
  message: {},
37
+ feed: {},
37
38
  };
38
39
  /** `--no-tty` is stripped like the routing flags but carries no value. */
39
40
  const STRIP_SPECS = [...HOST_ROUTING_SPECS, { long: 'no-tty', takesValue: false }];
@@ -14,7 +14,10 @@ export declare function needsWindowsShell(binary: string, platform?: NodeJS.Plat
14
14
  * Resolve an executable name to its absolute path via the OS PATH search, or
15
15
  * `null` if not found. On Windows `where` can return several lines (one per
16
16
  * PATHEXT match, e.g. `agents.cmd` and `agents.ps1`) — the first is the one the
17
- * shell would actually run, matching `which` semantics on POSIX.
17
+ * shell would actually run, matching `which` semantics on POSIX. stderr is
18
+ * dropped: a miss is an expected outcome here, and `where.exe` announces every
19
+ * miss on stderr ("INFO: Could not find files..."), which would otherwise leak
20
+ * into the caller's terminal once per probe.
18
21
  */
19
22
  export declare function findExecutable(name: string, platform?: NodeJS.Platform): string | null;
20
23
  /**
@@ -27,11 +27,17 @@ export function needsWindowsShell(binary, platform = process.platform) {
27
27
  * Resolve an executable name to its absolute path via the OS PATH search, or
28
28
  * `null` if not found. On Windows `where` can return several lines (one per
29
29
  * PATHEXT match, e.g. `agents.cmd` and `agents.ps1`) — the first is the one the
30
- * shell would actually run, matching `which` semantics on POSIX.
30
+ * shell would actually run, matching `which` semantics on POSIX. stderr is
31
+ * dropped: a miss is an expected outcome here, and `where.exe` announces every
32
+ * miss on stderr ("INFO: Could not find files..."), which would otherwise leak
33
+ * into the caller's terminal once per probe.
31
34
  */
32
35
  export function findExecutable(name, platform = process.platform) {
33
36
  try {
34
- const out = execFileSync(whichCommand(platform), [name], { encoding: 'utf-8' });
37
+ const out = execFileSync(whichCommand(platform), [name], {
38
+ encoding: 'utf-8',
39
+ stdio: ['ignore', 'pipe', 'ignore'],
40
+ });
35
41
  const first = out.trim().split(/\r?\n/)[0]?.trim();
36
42
  return first || null;
37
43
  }
@@ -74,11 +74,3 @@ export interface GetAgentResourcesOptions {
74
74
  * This is the source of truth - not the tracking data in agents.yaml.
75
75
  */
76
76
  export declare function getAgentResources(agentId: AgentId, options?: GetAgentResourcesOptions): AgentResources;
77
- /**
78
- * Get resources for all agents.
79
- */
80
- export declare function getAllAgentResources(agentIds: AgentId[], options?: GetAgentResourcesOptions & {
81
- cliStates?: Record<AgentId, {
82
- installed: boolean;
83
- }>;
84
- }): AgentResources[];
@@ -180,13 +180,3 @@ export function getAgentResources(agentId, options = {}) {
180
180
  workflows,
181
181
  };
182
182
  }
183
- /**
184
- * Get resources for all agents.
185
- */
186
- export function getAllAgentResources(agentIds, options = {}) {
187
- const { cliStates, ...restOptions } = options;
188
- return agentIds.map((agentId) => {
189
- const cliInstalled = cliStates?.[agentId]?.installed ?? true;
190
- return getAgentResources(agentId, { ...restOptions, cliInstalled });
191
- });
192
- }
@@ -10,6 +10,7 @@ import { getAccountInfo } from './agents.js';
10
10
  import { readMeta, writeMeta, getHelpersDir } from './state.js';
11
11
  import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
12
12
  import { getProjectRunConfigs } from './run-config.js';
13
+ import { emit } from './events.js';
13
14
  import { getUsageInfoByIdentity, getUsageLookupKey, deriveUsageStatusFromSnapshot, } from './usage.js';
14
15
  function getRotateDir() {
15
16
  const dir = path.join(getHelpersDir(), 'rotate');
@@ -388,6 +389,7 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd()) {
388
389
  }
389
390
  recordRotationPick(agent, rotation.picked.version);
390
391
  }
392
+ emit('rotation.resolved', { module: 'rotate', agent, version: rotation.picked.version, strategy, healthy: rotation.healthy.length, excluded: rotation.excluded.length });
391
393
  return { version: rotation.picked.version, rotation };
392
394
  }
393
395
  return { version: fallback, rotation: null };
@@ -74,8 +74,16 @@ export function buildJobCommand(config, resolvedPrompt) {
74
74
  appendModelAndReasoning(cmd, config);
75
75
  }
76
76
  if (config.agent === 'codex') {
77
- if (mode === 'edit') {
78
- cmd.push('--dangerously-bypass-approvals-and-sandbox');
77
+ if (mode === 'plan') {
78
+ // The template defaults to workspace-write; plan means read-only.
79
+ const sbIndex = cmd.indexOf('--sandbox');
80
+ if (sbIndex !== -1)
81
+ cmd[sbIndex + 1] = 'read-only';
82
+ }
83
+ else if (mode === 'edit' || mode === 'auto') {
84
+ // Keep the workspace-write sandbox — no approval bypass; only skip drops
85
+ // the guardrails. Re-enable network, which workspace-write turns off.
86
+ cmd.push('-c', 'sandbox_workspace_write.network_access=true');
79
87
  }
80
88
  else if (mode === 'skip') {
81
89
  // Remove sandbox restriction, just --dangerously-bypass-approvals-and-sandbox
@@ -50,6 +50,18 @@ export declare const META_CACHE_PREFIX = "!meta:";
50
50
  * cache on every bump and produced a recurring Touch ID storm.
51
51
  */
52
52
  export declare function shouldSelfHealForUpgrade(persistent: boolean, storeSize: number, runningVersion: string, onDiskVersion: string): boolean;
53
+ /**
54
+ * Client-side twin of shouldSelfHealForUpgrade: whether ensureAgentRunning may
55
+ * tear down a reachable broker whose running version differs from the client's
56
+ * on-disk version. Only while it holds NO real unlocks — tearing down a hot
57
+ * broker wipes every held bundle, so the next read of each one re-prompts for
58
+ * Touch ID. On a machine where installed versions churn (dev builds stamp a
59
+ * fresh 0.0.0-dev.<sha> on every install; an npm copy and a dev copy invoke in
60
+ * turn), an unguarded teardown produced a rolling Touch ID storm — the exact
61
+ * failure #435 fixed on the server side. A hot, protocol-compatible broker
62
+ * keeps serving; its own sweep adopts the new code at the next quiet moment.
63
+ */
64
+ export declare function shouldTeardownVersionSkewedBroker(realHeldBundles: number): boolean;
53
65
  export interface StoredBundle {
54
66
  bundle: SecretsBundle;
55
67
  env: Record<string, string>;
@@ -172,6 +184,15 @@ export declare function agentGetSync(name: string): {
172
184
  bundle: SecretsBundle;
173
185
  env: Record<string, string>;
174
186
  } | null;
187
+ /**
188
+ * Synchronously evict one bundle from the broker. Called after a mutating
189
+ * keychain write (add / rotate / remove / rename / delete) so the broker never
190
+ * keeps serving the pre-write snapshot for up to the ~7d hold — the next read
191
+ * re-resolves from the keychain (one prompt) and re-caches fresh values.
192
+ * Best-effort: no broker, no socket, or any failure is a silent no-op.
193
+ * macOS only.
194
+ */
195
+ export declare function agentEvictSync(name: string): void;
175
196
  /**
176
197
  * Read the cached `secrets list` metadata snapshot for the given keychain
177
198
  * name-set hash, or null on miss / no broker / off-darwin. Reuses the value
@@ -74,6 +74,20 @@ export function shouldSelfHealForUpgrade(persistent, storeSize, runningVersion,
74
74
  return false;
75
75
  return onDiskVersion !== runningVersion;
76
76
  }
77
+ /**
78
+ * Client-side twin of shouldSelfHealForUpgrade: whether ensureAgentRunning may
79
+ * tear down a reachable broker whose running version differs from the client's
80
+ * on-disk version. Only while it holds NO real unlocks — tearing down a hot
81
+ * broker wipes every held bundle, so the next read of each one re-prompts for
82
+ * Touch ID. On a machine where installed versions churn (dev builds stamp a
83
+ * fresh 0.0.0-dev.<sha> on every install; an npm copy and a dev copy invoke in
84
+ * turn), an unguarded teardown produced a rolling Touch ID storm — the exact
85
+ * failure #435 fixed on the server side. A hot, protocol-compatible broker
86
+ * keeps serving; its own sweep adopts the new code at the next quiet moment.
87
+ */
88
+ export function shouldTeardownVersionSkewedBroker(realHeldBundles) {
89
+ return realHeldBundles === 0;
90
+ }
77
91
  function onDarwin() {
78
92
  return process.platform === 'darwin';
79
93
  }
@@ -581,6 +595,50 @@ export function agentGetSync(name) {
581
595
  return null;
582
596
  }
583
597
  }
598
+ /**
599
+ * Inline node program for the synchronous evict path. Mirrors SYNC_GET_PROGRAM:
600
+ * writeBundle is synchronous and called synchronously everywhere, so a stale
601
+ * broker entry must be evicted without awaiting a socket round-trip. Sends one
602
+ * {cmd:'lock', name} and exits 0 (evicted or nothing held) / 3 (agent down).
603
+ * argv after -e: [execPath, <socket>, <name>].
604
+ */
605
+ const SYNC_LOCK_PROGRAM = `
606
+ const net = require('net');
607
+ const sock = process.argv[1], name = process.argv[2];
608
+ const c = net.createConnection(sock);
609
+ let buf = '';
610
+ const down = () => { try { c.destroy(); } catch (e) {} process.exit(3); };
611
+ const timer = setTimeout(down, 2000);
612
+ c.on('error', down);
613
+ c.on('connect', () => c.write(JSON.stringify({ cmd: 'lock', name }) + '\\n'));
614
+ c.setEncoding('utf-8');
615
+ c.on('data', (d) => {
616
+ buf += d;
617
+ const nl = buf.indexOf('\\n');
618
+ if (nl < 0) return;
619
+ clearTimeout(timer);
620
+ try { c.destroy(); } catch (e) {}
621
+ process.exit(0);
622
+ });
623
+ `;
624
+ /**
625
+ * Synchronously evict one bundle from the broker. Called after a mutating
626
+ * keychain write (add / rotate / remove / rename / delete) so the broker never
627
+ * keeps serving the pre-write snapshot for up to the ~7d hold — the next read
628
+ * re-resolves from the keychain (one prompt) and re-caches fresh values.
629
+ * Best-effort: no broker, no socket, or any failure is a silent no-op.
630
+ * macOS only.
631
+ */
632
+ export function agentEvictSync(name) {
633
+ if (!onDarwin())
634
+ return;
635
+ if (!agentSocketExists())
636
+ return;
637
+ try {
638
+ spawnSync(process.execPath, ['-e', SYNC_LOCK_PROGRAM, socketPath(), name], { timeout: 3000 });
639
+ }
640
+ catch { /* best-effort */ }
641
+ }
584
642
  // Key inside the cached entry's env that holds the JSON metadata snapshot.
585
643
  const META_SNAPSHOT_KEY = '__snapshot__';
586
644
  /**
@@ -733,11 +791,15 @@ export async function ensureAgentRunning(timeoutMs = 5000) {
733
791
  // Self-heal: if a broker is reachable but running pre-upgrade code (its
734
792
  // reported version != the version on disk now), tear it down so the paths
735
793
  // below bring up a fresh one on current code. A current, reachable broker is
736
- // accepted immediately.
794
+ // accepted immediately — and so is a version-skewed one that still holds
795
+ // real unlocks (see shouldTeardownVersionSkewedBroker: wiping a hot cache
796
+ // re-prompts Touch ID for every held bundle).
737
797
  const ping = await agentPing();
738
798
  if (ping.reachable) {
739
799
  if (ping.cliVersion === undefined || ping.cliVersion === getCliVersionFresh())
740
800
  return true;
801
+ if (!shouldTeardownVersionSkewedBroker((await agentStatus()).length))
802
+ return true;
741
803
  await teardownStaleBroker();
742
804
  }
743
805
  // Path 1: the persistent service. installSecretsAgentService is idempotent and
@@ -89,6 +89,9 @@ export interface LegacyBundleCandidate {
89
89
  file: string;
90
90
  keys: string[];
91
91
  }
92
+ export declare const BUNDLE_NAME_PATTERN: RegExp;
93
+ export declare const ENV_KEY_PATTERN: RegExp;
94
+ export declare const BUNDLE_META_PREFIX = "agents-cli.bundles.";
92
95
  export declare const RESERVED_ENV_NAMES: Set<string>;
93
96
  export declare function bundleToEnvPrefix(name: string): string;
94
97
  export declare function isReservedEnvName(key: string): boolean;
@@ -114,7 +117,29 @@ export declare function readBundle(name: string): SecretsBundle;
114
117
  export declare function secretsDefaultPolicy(): SecretsPolicy;
115
118
  /** The effective prompt policy of a bundle (absent ⇒ the configured default). */
116
119
  export declare function bundlePolicy(bundle: SecretsBundle): SecretsPolicy;
117
- export declare function writeBundle(bundle: SecretsBundle): void;
120
+ /** Options for writeBundle. */
121
+ export interface WriteBundleOptions {
122
+ /**
123
+ * Skip evicting the bundle from the secrets-agent broker after the write.
124
+ * Only for writers that change nothing the broker serves — today that is
125
+ * stampLastUsed (a usage-telemetry timestamp, fired on every broker HIT):
126
+ * evicting there would make the cache destroy itself on first use. Every
127
+ * mutating writer (add / rotate / remove / rename / policy / import) must
128
+ * leave this unset so a broker-held copy never serves stale values for up
129
+ * to the ~7d hold.
130
+ */
131
+ skipBrokerEviction?: boolean;
132
+ }
133
+ /**
134
+ * Whether a bundle write should evict the broker-held copy. Pure + exported
135
+ * for regression coverage. Skips when the writer opted out (stampLastUsed),
136
+ * when the broker integration is disabled (AGENTS_SECRETS_NO_AGENT — the same
137
+ * kill-switch the read fast-path honors), or when a test keychain backend is
138
+ * installed (an in-memory backend has no real keychain behind it, and a test
139
+ * writing bundle 'prod' must never evict the user's real 'prod' unlock).
140
+ */
141
+ export declare function shouldEvictAfterBundleWrite(skipRequested: boolean, noAgentEnv: string | undefined, backendOverridden: boolean): boolean;
142
+ export declare function writeBundle(bundle: SecretsBundle, opts?: WriteBundleOptions): void;
118
143
  export declare function deleteBundle(name: string): boolean;
119
144
  export declare function listBundles(): SecretsBundle[];
120
145
  export interface BundleEntryInfo {
@@ -140,6 +165,13 @@ export interface ResolveBundleOptions {
140
165
  * needs live values. Also honored via AGENTS_SECRETS_NO_AGENT=1.
141
166
  */
142
167
  noAgent?: boolean;
168
+ /**
169
+ * Resolve only from an already-unlocked secrets-agent snapshot. If the
170
+ * broker has no snapshot, fail before touching Keychain or any other store.
171
+ * Background processes use this to guarantee they never surface a biometric
172
+ * prompt that nobody can answer.
173
+ */
174
+ agentOnly?: boolean;
143
175
  /**
144
176
  * Inject only this subset of keys from the bundle. Keys not in this list are
145
177
  * silently excluded from the returned env map. An error is thrown if any