@phnx-labs/agents-cli 1.22.27 → 1.22.29

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 (89) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +62 -26
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +54 -10
  5. package/dist/commands/hosts.js +4 -3
  6. package/dist/commands/perf.d.ts +10 -0
  7. package/dist/commands/perf.js +14 -6
  8. package/dist/commands/projects.js +24 -10
  9. package/dist/commands/routines.js +31 -4
  10. package/dist/commands/run-account-picker.d.ts +37 -0
  11. package/dist/commands/run-account-picker.js +101 -17
  12. package/dist/commands/secrets.js +86 -2
  13. package/dist/commands/sessions.d.ts +26 -6
  14. package/dist/commands/sessions.js +46 -12
  15. package/dist/commands/teams.js +19 -8
  16. package/dist/lib/activity.js +45 -99
  17. package/dist/lib/claude-account-token.js +38 -11
  18. package/dist/lib/cloud/host.js +1 -0
  19. package/dist/lib/devices/resolve-target.d.ts +1 -0
  20. package/dist/lib/devices/resolve-target.js +9 -2
  21. package/dist/lib/events.d.ts +1 -1
  22. package/dist/lib/events.js +2 -2
  23. package/dist/lib/git.d.ts +9 -3
  24. package/dist/lib/git.js +39 -22
  25. package/dist/lib/hooks/profile.d.ts +12 -1
  26. package/dist/lib/hooks/profile.js +6 -1
  27. package/dist/lib/hooks.js +4 -1
  28. package/dist/lib/hosts/dispatch.d.ts +10 -0
  29. package/dist/lib/hosts/dispatch.js +78 -22
  30. package/dist/lib/hosts/logs.js +11 -1
  31. package/dist/lib/hosts/passthrough.d.ts +5 -0
  32. package/dist/lib/hosts/passthrough.js +5 -2
  33. package/dist/lib/hosts/progress.d.ts +10 -0
  34. package/dist/lib/hosts/progress.js +37 -5
  35. package/dist/lib/hosts/ready.d.ts +41 -2
  36. package/dist/lib/hosts/ready.js +100 -12
  37. package/dist/lib/hosts/reconcile.d.ts +2 -1
  38. package/dist/lib/hosts/reconcile.js +13 -5
  39. package/dist/lib/hosts/reconnect.js +5 -3
  40. package/dist/lib/hosts/registry.js +1 -0
  41. package/dist/lib/hosts/tasks.d.ts +1 -0
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  44. package/dist/lib/menubar/snapshot.d.ts +16 -0
  45. package/dist/lib/menubar/snapshot.js +37 -3
  46. package/dist/lib/models.d.ts +18 -11
  47. package/dist/lib/models.js +19 -15
  48. package/dist/lib/perf/db.js +16 -3
  49. package/dist/lib/perf/types.d.ts +12 -1
  50. package/dist/lib/project-probe.d.ts +22 -3
  51. package/dist/lib/project-probe.js +105 -17
  52. package/dist/lib/project-status.d.ts +9 -0
  53. package/dist/lib/project-status.js +15 -0
  54. package/dist/lib/refresh.js +22 -11
  55. package/dist/lib/rotate.d.ts +22 -0
  56. package/dist/lib/rotate.js +26 -0
  57. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  58. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  59. package/dist/lib/secrets/agent.d.ts +19 -3
  60. package/dist/lib/secrets/agent.js +58 -20
  61. package/dist/lib/secrets/audit.d.ts +1 -1
  62. package/dist/lib/secrets/audit.js +2 -0
  63. package/dist/lib/secrets/bundles.d.ts +3 -3
  64. package/dist/lib/secrets/bundles.js +15 -5
  65. package/dist/lib/secrets/filestore.d.ts +2 -0
  66. package/dist/lib/secrets/filestore.js +4 -0
  67. package/dist/lib/secrets/session-store.d.ts +7 -0
  68. package/dist/lib/secrets/session-store.js +21 -0
  69. package/dist/lib/session/active.d.ts +49 -0
  70. package/dist/lib/session/active.js +175 -11
  71. package/dist/lib/session/bash-command.d.ts +16 -0
  72. package/dist/lib/session/bash-command.js +65 -0
  73. package/dist/lib/session/db.d.ts +4 -0
  74. package/dist/lib/session/db.js +32 -15
  75. package/dist/lib/session/remote-bundle.js +1 -1
  76. package/dist/lib/staleness/detectors/skills.d.ts +2 -0
  77. package/dist/lib/staleness/detectors/skills.js +17 -2
  78. package/dist/lib/staleness/index.d.ts +10 -1
  79. package/dist/lib/staleness/index.js +28 -3
  80. package/dist/lib/teams/agents.d.ts +1 -0
  81. package/dist/lib/teams/agents.js +38 -11
  82. package/dist/lib/teams/remoteWorktree.d.ts +11 -7
  83. package/dist/lib/teams/remoteWorktree.js +29 -27
  84. package/dist/lib/usage.js +18 -10
  85. package/dist/lib/versions.d.ts +1 -0
  86. package/dist/lib/versions.js +40 -22
  87. package/package.json +3 -1
  88. package/dist/lib/exec.bench.d.ts +0 -1
  89. package/dist/lib/exec.bench.js +0 -186
@@ -26,6 +26,7 @@ import { getActivityDir, getUserAgentsDir } from './state.js';
26
26
  import { normalizeHost } from './machine-id.js';
27
27
  import { projectKeyFromCwd } from './project-key.js';
28
28
  import { stampProvenance } from './event-provenance.js';
29
+ import { pythonToolRegistryLiteral, pythonValueFlagsLiteral, } from './session/bash-command.js';
29
30
  /** The set of milestone events, for tier classification and ordering. */
30
31
  export const MILESTONE_EVENTS = [
31
32
  'plan.created',
@@ -730,115 +731,30 @@ def first_line(text, limit=140):
730
731
  return ""
731
732
 
732
733
 
733
- # Canonical command taxonomy for Bash tool calls. Mirrors the TypeScript
734
- # registry in lib/session/bash-command.ts; keep them in sync.
734
+ # Canonical command taxonomy for Bash tool calls, generated from the single
735
+ # source of truth in lib/session/bash-command.ts's TOOL_REGISTRY (#1889) so the
736
+ # two can no longer drift — this hook runs standalone under python3 and can't
737
+ # import that module, so the literal below is generated, not hand-duplicated.
735
738
  BASH_TOOL_REGISTRY = {
736
- "git": {"category": "vcs", "action": "working in git"},
737
- "gh": {"category": "vcs", "action": "using GitHub CLI"},
738
- "bun": {"category": "build-test", "action": "running bun"},
739
- "npm": {"category": "build-test", "action": "running npm"},
740
- "pnpm": {"category": "build-test", "action": "running pnpm"},
741
- "yarn": {"category": "build-test", "action": "running yarn"},
742
- "vitest": {"category": "build-test", "action": "running vitest"},
743
- "jest": {"category": "build-test", "action": "running jest"},
744
- "mocha": {"category": "build-test", "action": "running mocha"},
745
- "pytest": {"category": "build-test", "action": "running pytest"},
746
- "cargo": {"category": "build-test", "action": "running cargo"},
747
- "go": {"category": "build-test", "action": "running go"},
748
- "tsc": {"category": "build-test", "action": "running tsc"},
749
- "tsx": {"category": "build-test", "action": "running tsx"},
750
- "node": {"category": "build-test", "action": "running node"},
751
- "python": {"category": "build-test", "action": "running python"},
752
- "python3": {"category": "build-test", "action": "running python"},
753
- "make": {"category": "build-test", "action": "running make"},
754
- "brew": {"category": "install", "action": "installing with brew"},
755
- "pip": {"category": "install", "action": "installing with pip"},
756
- "pip3": {"category": "install", "action": "installing with pip"},
757
- "apt": {"category": "install", "action": "installing with apt"},
758
- "apk": {"category": "install", "action": "installing with apk"},
759
- "ssh": {"category": "remote", "action": "using ssh"},
760
- "scp": {"category": "remote", "action": "using scp"},
761
- "rsync": {"category": "remote", "action": "using rsync"},
762
- "curl": {"category": "http", "action": "fetching with curl"},
763
- "wget": {"category": "http", "action": "fetching with wget"},
764
- "ffmpeg": {"category": "media", "action": "using ffmpeg"},
765
- "ffprobe": {"category": "media", "action": "probing media"},
766
- "magick": {"category": "media", "action": "using ImageMagick"},
767
- "convert": {"category": "media", "action": "converting images"},
768
- "composite": {"category": "media", "action": "compositing images"},
769
- "montage": {"category": "media", "action": "montaging images"},
770
- "identify": {"category": "media", "action": "identifying images"},
771
- "realesrgan": {"category": "upscaling", "action": "upscaling with realesrgan"},
772
- "realesrgan-ncnn-vulkan": {"category": "upscaling", "action": "upscaling with realesrgan"},
773
- "waifu2x": {"category": "upscaling", "action": "upscaling with waifu2x"},
774
- "waifu2x-caffe": {"category": "upscaling", "action": "upscaling with waifu2x"},
775
- "waifu2x-converter-cpp": {"category": "upscaling", "action": "upscaling with waifu2x"},
776
- "swin2sr": {"category": "upscaling", "action": "upscaling with swin2sr"},
777
- "resdet": {"category": "upscaling", "action": "detecting upscale"},
778
- "id3v2": {"category": "metadata", "action": "editing id3 tags"},
779
- "exiftool": {"category": "metadata", "action": "editing exif metadata"},
780
- "metaflac": {"category": "metadata", "action": "editing flac metadata"},
781
- "vorbiscomment": {"category": "metadata", "action": "editing vorbis comments"},
782
- # Shell
783
- "rm": {"category": "shell", "action": "removing files"},
784
- "mv": {"category": "shell", "action": "moving files"},
785
- "cp": {"category": "shell", "action": "copying files"},
786
- "mkdir": {"category": "shell", "action": "making directories"},
787
- "touch": {"category": "shell", "action": "touching files"},
788
- "echo": {"category": "shell", "action": "echoing"},
789
- "printf": {"category": "shell", "action": "printing"},
790
- "chmod": {"category": "shell", "action": "changing permissions"},
791
- "ln": {"category": "shell", "action": "linking files"},
792
- "awk": {"category": "shell", "action": "running awk"},
793
- "sed": {"category": "shell", "action": "running sed"},
794
- "tee": {"category": "shell", "action": "teeing output"},
795
- "xargs": {"category": "shell", "action": "running xargs"},
796
- # Probes
797
- "ls": {"category": "probe", "action": "listing files"},
798
- "cat": {"category": "probe", "action": "reading files"},
799
- "head": {"category": "probe", "action": "reading files"},
800
- "tail": {"category": "probe", "action": "reading files"},
801
- "wc": {"category": "probe", "action": "counting"},
802
- "stat": {"category": "probe", "action": "statting files"},
803
- "file": {"category": "probe", "action": "inspecting files"},
804
- "which": {"category": "probe", "action": "locating binaries"},
805
- "tree": {"category": "probe", "action": "listing files"},
806
- "pwd": {"category": "probe", "action": "printing pwd"},
807
- # Search
808
- "grep": {"category": "search", "action": "searching"},
809
- "rg": {"category": "search", "action": "searching with ripgrep"},
810
- "ag": {"category": "search", "action": "searching with the silver searcher"},
811
- "fd": {"category": "search", "action": "searching files"},
812
- "find": {"category": "search", "action": "finding files"},
813
- # Wait
814
- "sleep": {"category": "wait", "action": "sleeping"},
815
- "wait": {"category": "wait", "action": "waiting"},
739
+ ${pythonToolRegistryLiteral()}
816
740
  }
817
741
 
818
742
  # Two-level tools mapped to the flags that consume the following token as their
819
- # value, per tool; the subcommand scan skips both. Missing a value flag mis-reads
820
- # the value as the subcommand; over-listing only drops the subcommand (safe). A
821
- # tool with no such leading flags maps to an empty set. Mirrors VALUE_FLAGS in
822
- # lib/session/bash-command.ts; TWO_LEVEL_TOOLS is derived from its keys.
743
+ # value, per tool; the subcommand scan skips both. Generated from VALUE_FLAGS in
744
+ # lib/session/bash-command.ts (#1889) TWO_LEVEL_TOOLS is derived from its keys.
823
745
  VALUE_FLAGS = {
824
- "git": {"-C", "-c", "--git-dir", "--work-tree"},
825
- "gh": {"-R", "--repo"},
826
- "bun": {"--cwd"},
827
- "npm": {"--prefix", "-w", "--workspace"},
828
- "pnpm": {"--filter", "-C", "--dir"},
829
- "yarn": {"--cwd"},
830
- "cargo": {"--manifest-path"},
831
- "docker": {"-H", "--host", "-c", "--context", "--config", "-l", "--log-level"},
832
- "kubectl": {"-n", "--namespace", "--kubeconfig", "--context", "--cluster", "--user", "-s", "--server", "--as", "--token", "--cache-dir", "--request-timeout"},
833
- "rush": set(),
834
- "openclaw": set(),
746
+ ${pythonValueFlagsLiteral()}
835
747
  }
836
748
 
837
749
  TWO_LEVEL_TOOLS = set(VALUE_FLAGS.keys())
838
750
 
839
751
 
840
752
  def _unwrap_command(cmd):
841
- """Strip wrapper prefixes so the real executable is classified."""
753
+ """Strip wrapper prefixes so the real executable is classified.
754
+
755
+ Mirrors unwrapCommand() in lib/session/bash-command.ts — keep the shapes
756
+ aligned when extending either side (#1889).
757
+ """
842
758
  s = (cmd or "").strip()
843
759
  ssh = re.match(r'^ssh\s+\S+\s+["\']?(.+?)["\']?\s*(?:\|.*)?$', s)
844
760
  if ssh:
@@ -847,16 +763,46 @@ def _unwrap_command(cmd):
847
763
  env = re.match(r'^([A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|' + r"'[^']*'" + r'|\S+)\s+)+(.+)$', s)
848
764
  if env:
849
765
  return _unwrap_command(env.group(2))
766
+ # export VAR=value && command / export A=1 B=2; command
767
+ export_prefix = re.match(
768
+ r'^export\s+(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|' + r"'[^']*'" + r'|\S+)\s+)*'
769
+ r'[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|' + r"'[^']*'" + r'|\S+)\s*(?:&&|;|\n)\s*([\s\S]+)$',
770
+ s,
771
+ )
772
+ if export_prefix:
773
+ return _unwrap_command(export_prefix.group(1))
850
774
  # sudo / time prefix (value-taking flags such as -u user consume their argument)
851
775
  prefix = re.match(r'^(?:sudo|time)(?:\s+(?:-[uUgGhpCrtDR]\s+\S+|-\S+))*\s+(.+)$', s)
852
776
  if prefix:
853
777
  return _unwrap_command(prefix.group(1))
854
- cd = re.match(r'^cd\s+\S+\s*&&\s*(.+)$', s)
778
+ # set -euo pipefail && command / set -x; command
779
+ set_prefix = re.match(r'^set\s+[^&;\n]+?(?:&&|;|\n)\s*([\s\S]+)$', s)
780
+ if set_prefix:
781
+ return _unwrap_command(set_prefix.group(1))
782
+ # cd foo && command (also ; and newline separators)
783
+ cd = re.match(r'^cd\s+\S+\s*(?:&&|;|\n)\s*([\s\S]+)$', s)
855
784
  if cd:
856
785
  return _unwrap_command(cd.group(1))
857
786
  npx = re.match(r'^(?:npx|bunx)\s+(?:-\S+\s+)*(.+)$', s)
858
787
  if npx:
859
788
  return _unwrap_command(npx.group(1))
789
+ for_loop = re.match(r'^for\s+[\s\S]+?\bdo\b\s*([\s\S]+?)\s*;?\s*done\b[\s\S]*$', s)
790
+ if for_loop:
791
+ return _unwrap_command(for_loop.group(1))
792
+ until_loop = re.match(r'^until\s+[\s\S]+?\bdo\b\s*([\s\S]+?)\s*;?\s*done\b[\s\S]*$', s)
793
+ if until_loop:
794
+ return _unwrap_command(until_loop.group(1))
795
+ if_cond = re.match(
796
+ r'^if\s+[\s\S]+?\bthen\b\s*([\s\S]+?)\s*;?\s*(?:elif\b|else\b|fi\b)[\s\S]*$',
797
+ s,
798
+ )
799
+ if if_cond:
800
+ return _unwrap_command(if_cond.group(1))
801
+ subshell = re.match(r'^\(\s*([\s\S]+?)\s*\)\s*(?:&&|;|\|\|)[\s\S]*$', s) or re.match(
802
+ r'^\(\s*([\s\S]+?)\s*\)\s*$', s
803
+ )
804
+ if subshell:
805
+ return _unwrap_command(subshell.group(1))
860
806
  return s
861
807
 
862
808
 
@@ -2,6 +2,8 @@ import * as fs from 'fs';
2
2
  import * as os from 'os';
3
3
  import * as path from 'path';
4
4
  import { bundleBackend, bundleExists, readAndResolveBundleEnv } from './secrets/bundles.js';
5
+ import { fileStoreItemPath } from './secrets/filestore.js';
6
+ import { secretsKeychainItem } from './secrets/index.js';
5
7
  /**
6
8
  * Reserved FILE-BASED secrets bundle holding long-lived, non-rotating Claude
7
9
  * setup-tokens. Usage/probe reads authenticate with these instead of Claude
@@ -25,6 +27,17 @@ const AUTH_BUNDLE = 'auth';
25
27
  * where a corrupt bundle entry must be caught before it reaches the auth header.
26
28
  */
27
29
  const SETUP_TOKEN_RE = /^sk-ant-oat01-[A-Za-z0-9_-]+$/;
30
+ /** Process-local only: plaintext setup-tokens are never written to another cache. */
31
+ const setupTokenCache = new Map();
32
+ function credentialFingerprint(credentialPath) {
33
+ try {
34
+ const stat = fs.statSync(credentialPath, { bigint: true });
35
+ return `${stat.dev}:${stat.ino}:${stat.ctimeNs}:${stat.mtimeNs}:${stat.size}`;
36
+ }
37
+ catch {
38
+ return 'missing';
39
+ }
40
+ }
28
41
  /** True only for a clean, single-line `sk-ant-oat01-…` token — see {@link SETUP_TOKEN_RE}. */
29
42
  export function isValidClaudeSetupToken(value) {
30
43
  return SETUP_TOKEN_RE.test(value);
@@ -71,17 +84,31 @@ export function resolveClaudeSetupToken(home) {
71
84
  return null;
72
85
  if (!bundleExists(AUTH_BUNDLE) || bundleBackend(AUTH_BUNDLE) !== 'file')
73
86
  return null;
74
- const { env } = readAndResolveBundleEnv(AUTH_BUNDLE, { caller: 'usage', agentOnly: true });
75
- const v = (env[claudeAccountTokenKey(email)] ?? '').trim();
76
- if (v.length === 0)
77
- return null;
78
- // Reject a malformed stored value (e.g. a captured setup-token TTY blob, #1767)
79
- // rather than let it become an `Authorization: Bearer <blob>` header that
80
- // Anthropic rejects and crashes the run. A corrupt entry is treated as no
81
- // usable file-based token, so the caller falls back to the normal login.
82
- if (!isValidClaudeSetupToken(v))
83
- return null;
84
- return v;
87
+ const cacheKey = home ?? os.homedir();
88
+ const item = secretsKeychainItem(AUTH_BUNDLE, claudeAccountTokenKey(email));
89
+ const credentialPath = fileStoreItemPath(item);
90
+ for (let attempt = 0; attempt < 2; attempt++) {
91
+ const before = credentialFingerprint(credentialPath);
92
+ const cached = setupTokenCache.get(cacheKey);
93
+ if (cached?.credentialPath === credentialPath && cached.fingerprint === before) {
94
+ return cached.token;
95
+ }
96
+ if (before === 'missing') {
97
+ setupTokenCache.set(cacheKey, { credentialPath, fingerprint: before, token: null });
98
+ return null;
99
+ }
100
+ const { env } = readAndResolveBundleEnv(AUTH_BUNDLE, { caller: 'usage', agentOnly: true });
101
+ const v = (env[claudeAccountTokenKey(email)] ?? '').trim();
102
+ const token = v.length > 0 && isValidClaudeSetupToken(v) ? v : null;
103
+ const after = credentialFingerprint(credentialPath);
104
+ if (before === after) {
105
+ setupTokenCache.set(cacheKey, { credentialPath, fingerprint: after, token });
106
+ return token;
107
+ }
108
+ }
109
+ // The credential changed during both decrypt attempts. Fail closed rather
110
+ // than return a token whose current file fingerprint was never observed.
111
+ return null;
85
112
  }
86
113
  catch {
87
114
  return null;
@@ -153,6 +153,7 @@ export class HostCloudProvider {
153
153
  remoteExit: task.remoteExit,
154
154
  taskId: task.id,
155
155
  offset,
156
+ extraSshArgs: task.identityFile ? ['-i', task.identityFile, '-o', 'IdentitiesOnly=yes'] : [],
156
157
  });
157
158
  if (fetched) {
158
159
  if (fetched.logChunk.length > 0) {
@@ -8,6 +8,7 @@ export interface ResolvedSshTarget {
8
8
  machine: string;
9
9
  name: string;
10
10
  os?: string;
11
+ extraSshArgs?: string[];
11
12
  }
12
13
  export interface ResolvedExplicitTargetSet {
13
14
  targets: ResolvedSshTarget[];
@@ -18,7 +18,7 @@
18
18
  * `user@host` all resolve identically no matter which subcommand called.
19
19
  */
20
20
  import chalk from 'chalk';
21
- import { sshTargetFor } from '../hosts/types.js';
21
+ import { hostIdentityArgs, sshTargetFor } from '../hosts/types.js';
22
22
  import { matchHost, splitUserHost } from '../hosts/registry.js';
23
23
  import { normalizeHost } from '../machine-id.js';
24
24
  import { resolveRemoteOsSync } from '../hosts/remote-os.js';
@@ -61,7 +61,14 @@ async function toResolvedTarget(token) {
61
61
  }
62
62
  const hostPart = host.device ? host.device.name : splitUserHost(host.name).host;
63
63
  const name = host.device ? host.device.name : host.name;
64
- return { target, machine: normalizeHost(hostPart), name, os: host.os ?? resolveRemoteOsSync(name) };
64
+ const extraSshArgs = hostIdentityArgs(host);
65
+ return {
66
+ target,
67
+ machine: normalizeHost(hostPart),
68
+ name,
69
+ os: host.os ?? resolveRemoteOsSync(name),
70
+ ...(extraSshArgs.length > 0 ? { extraSshArgs } : {}),
71
+ };
65
72
  }
66
73
  /**
67
74
  * Resolve a target token to a full {@link DeviceProfile} for `agents ssh`. Same
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import type { ActorKind } from './actor.js';
15
15
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
16
- export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'computer.action' | 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.handler.start' | 'webhook.handler.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'factory.command' | 'factory.action' | 'factory.uri' | 'factory.launch' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
16
+ export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'computer.action' | 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.lease-denied' | 'secrets.lease-expire' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.handler.start' | 'webhook.handler.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'factory.command' | 'factory.action' | 'factory.uri' | 'factory.launch' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
17
17
  /** Every known event kind. Derived from {@link EVENT_TYPE_TABLE}, never hand-listed. */
18
18
  export declare const EVENT_TYPES: readonly EventType[];
19
19
  /** Runtime guard for an event kind arriving from outside this process. */
@@ -113,7 +113,7 @@ const EVENT_TYPE_TABLE = {
113
113
  'skill.install': true, 'skill.remove': true,
114
114
  'browser.launch': true, 'browser.close': true, 'browser.navigate': true, 'browser.screenshot': true,
115
115
  'computer.action': true,
116
- 'secrets.get': true, 'secrets.unlocked': true, 'secrets.create': true, 'secrets.import': true, 'secrets.export': true, 'secrets.view': true, 'secrets.set': true, 'secrets.delete': true, 'secrets.rename': true,
116
+ 'secrets.get': true, 'secrets.unlocked': true, 'secrets.create': true, 'secrets.import': true, 'secrets.export': true, 'secrets.view': true, 'secrets.lease-denied': true, 'secrets.lease-expire': true, 'secrets.set': true, 'secrets.delete': true, 'secrets.rename': true,
117
117
  'cloud.dispatch': true, 'cloud.complete': true, 'cloud.cancel': true, 'cloud.message': true,
118
118
  'teams.create': true, 'teams.add': true, 'teams.start': true, 'teams.complete': true, 'teams.disband': true,
119
119
  'hook.fire': true, 'hook.complete': true, 'hook.error': true,
@@ -141,7 +141,7 @@ export function isEventType(value) {
141
141
  }
142
142
  const AUDIT_EVENTS = new Set([
143
143
  'command.start', 'command.end',
144
- 'secrets.get', 'secrets.unlocked', 'secrets.create', 'secrets.import', 'secrets.export', 'secrets.view',
144
+ 'secrets.get', 'secrets.unlocked', 'secrets.create', 'secrets.import', 'secrets.export', 'secrets.view', 'secrets.lease-denied', 'secrets.lease-expire',
145
145
  'secrets.set', 'secrets.delete', 'secrets.rename',
146
146
  'teams.create', 'teams.add', 'teams.start', 'teams.complete', 'teams.disband',
147
147
  'cloud.dispatch', 'cloud.complete', 'cloud.cancel', 'cloud.message',
package/dist/lib/git.d.ts CHANGED
@@ -269,9 +269,15 @@ export declare function displayHomePath(dir: string): string;
269
269
  * Pull changes in an existing repo.
270
270
  * Refuses to pull if the working tree is dirty -- user must commit or discard changes first.
271
271
  *
272
- * Uses `git pull --rebase` (same strategy as {@link syncRepoGit}) so a diverged
273
- * branch reconciles instead of failing with "Need to specify how to reconcile
274
- * divergent branches".
272
+ * Strategy (RUSH-2282):
273
+ * 1. Fetch, then compare HEAD to the resolved tracking ref.
274
+ * 2. Clean behind-only (HEAD is an ancestor of tracking) → `merge --ff-only`
275
+ * against the tracking ref. Never re-enter `git pull` after a bare fetch —
276
+ * multi-entry FETCH_HEAD (concurrent fetch, multi-branch remote) makes
277
+ * `git pull --rebase` die with "Cannot rebase onto multiple branches" even
278
+ * when the checkout is a pure fast-forward.
279
+ * 3. Genuinely diverged (local commits not on tracking) → `rebase` onto the
280
+ * tracking ref (same outcome as {@link syncRepoGit}, without a second pull).
275
281
  */
276
282
  export declare function pullRepo(dir: string): Promise<{
277
283
  success: boolean;
package/dist/lib/git.js CHANGED
@@ -867,9 +867,15 @@ export function displayHomePath(dir) {
867
867
  * Pull changes in an existing repo.
868
868
  * Refuses to pull if the working tree is dirty -- user must commit or discard changes first.
869
869
  *
870
- * Uses `git pull --rebase` (same strategy as {@link syncRepoGit}) so a diverged
871
- * branch reconciles instead of failing with "Need to specify how to reconcile
872
- * divergent branches".
870
+ * Strategy (RUSH-2282):
871
+ * 1. Fetch, then compare HEAD to the resolved tracking ref.
872
+ * 2. Clean behind-only (HEAD is an ancestor of tracking) → `merge --ff-only`
873
+ * against the tracking ref. Never re-enter `git pull` after a bare fetch —
874
+ * multi-entry FETCH_HEAD (concurrent fetch, multi-branch remote) makes
875
+ * `git pull --rebase` die with "Cannot rebase onto multiple branches" even
876
+ * when the checkout is a pure fast-forward.
877
+ * 3. Genuinely diverged (local commits not on tracking) → `rebase` onto the
878
+ * tracking ref (same outcome as {@link syncRepoGit}, without a second pull).
873
879
  */
874
880
  export async function pullRepo(dir) {
875
881
  try {
@@ -933,16 +939,16 @@ export async function pullRepo(dir) {
933
939
  tracking = `origin/${branch}`;
934
940
  }
935
941
  }
936
- // Split the remote-tracking ref (<remote>/<branch>) into its parts and pull
937
- // THOSE. Hardcoding 'origin' while comparing against `tracking` is how a
938
- // branch tracking e.g. upstream/main silently 'succeeded': revparse saw a
939
- // difference, the pull fetched origin/main (already current), and pullRepo
940
- // returned success having moved nothing the same "reported ok, pulled
941
- // nothing" failure this change exists to remove. Branch names may contain
942
- // slashes, so split on the FIRST separator only.
942
+ // Split the remote-tracking ref (<remote>/<branch>) so callers/logs can name
943
+ // the remote. Branch names may contain slashes, so split on the FIRST
944
+ // separator only. The integrate step below uses `tracking` directly (not a
945
+ // second `git pull <remote> <branch>`) so a multi-entry FETCH_HEAD cannot
946
+ // turn a clean fast-forward into "Cannot rebase onto multiple branches".
943
947
  const sep = tracking.indexOf('/');
944
- const remoteName = sep > 0 ? tracking.slice(0, sep) : 'origin';
945
948
  const remoteBranch = sep > 0 ? tracking.slice(sep + 1) : branch;
949
+ // Keep branch-name validation on the ref we would have passed to pull —
950
+ // rejects traversal / flag-smuggling shapes before any integrate command.
951
+ assertValidBranchName(remoteBranch);
946
952
  // Bare fetch: updates every remote, so the revparse below sees a fresh ref
947
953
  // whichever one the branch tracks. Deliberately argument-less — simple-git's
948
954
  // fetchTask only forwards a remote when BOTH remote and branch are passed,
@@ -962,17 +968,27 @@ export async function pullRepo(dir) {
962
968
  branch,
963
969
  };
964
970
  }
971
+ // Behind-only when tracking has commits we lack AND we have none of our own
972
+ // on top. Use rev-list counts — simple-git does not reject on the exit-1
973
+ // that `merge-base --is-ancestor` returns for a non-ancestor, so a try/catch
974
+ // around that call would always report "can ff" (RUSH-2282).
975
+ const aheadCount = parseInt((await git.raw(['rev-list', '--count', `${tracking}..HEAD`])).trim(), 10);
976
+ const behindCount = parseInt((await git.raw(['rev-list', '--count', `HEAD..${tracking}`])).trim(), 10);
977
+ const canFastForward = Number.isFinite(aheadCount) &&
978
+ Number.isFinite(behindCount) &&
979
+ aheadCount === 0 &&
980
+ behindCount > 0;
965
981
  try {
966
- // Rebase, not --ff-only. Fast-forward refuses ANY divergence, conflict or
967
- // not, so a single local commit permanently wedged the pull with nothing
968
- // actually in conflict. Matches syncRepoGit (below) and this function's doc.
969
- //
970
- // Pull the RESOLVED tracking ref, not `branch`: when the local branch has
971
- // no tracking config the block above falls back to origin's default head,
972
- // which may be named differently (local `main` vs origin `master`). Using
973
- // `branch` there asks origin for a ref it does not have.
974
- assertValidBranchName(remoteBranch);
975
- await git.pull(remoteName, remoteBranch, { '--rebase': 'true' });
982
+ if (canFastForward) {
983
+ // Integrate the already-fetched tracking ref. No network, no FETCH_HEAD.
984
+ await git.raw(['merge', '--ff-only', tracking]);
985
+ }
986
+ else {
987
+ // Diverged (or local-only commits). Rebase onto the tracking tip
988
+ // same outcome as `git pull --rebase <remote> <branch>` without
989
+ // re-fetching or consulting FETCH_HEAD.
990
+ await git.raw(['rebase', tracking]);
991
+ }
976
992
  }
977
993
  catch (err) {
978
994
  // Abort so the tree is restored, matching the atomicity --ff-only gave us.
@@ -982,10 +998,11 @@ export async function pullRepo(dir) {
982
998
  // the cause. `agents sync` reaches this path unattended across the fleet,
983
999
  // so a wedged checkout would be worse than the bug this fixes.
984
1000
  await git.raw(['rebase', '--abort']).catch(() => { });
1001
+ const verb = canFastForward ? 'Fast-forward' : 'Rebase';
985
1002
  return {
986
1003
  success: false,
987
1004
  commit: '',
988
- error: `Rebase onto ${tracking} hit a conflict — the pull was rolled back, nothing changed.\n\nResolve the divergence, then pull again:\n\n cd ${displayHomePath(dir)} && git log --oneline HEAD...${tracking}\n\n${err.message}`,
1005
+ error: `${verb} onto ${tracking} failed — the pull was rolled back, nothing changed.\n\nResolve the divergence, then pull again:\n\n cd ${displayHomePath(dir)} && git log --oneline HEAD...${tracking}\n\n${err.message}`,
989
1006
  };
990
1007
  }
991
1008
  installGithooksSymlinks(dir);
@@ -10,8 +10,19 @@ export interface HookProfileRow {
10
10
  cacheStalePct: number;
11
11
  cacheMissPct: number;
12
12
  errorCount: number;
13
- /** Fraction (0-1) of fires with a nonzero exit code. */
13
+ /**
14
+ * Fraction (0-1) of fires with a real crash exit (exit 1 / other nonzero
15
+ * except intentional PreToolUse deny code 2). Exit 2 is blockRate.
16
+ */
14
17
  errorRate?: number;
18
+ /**
19
+ * Count of intentional deny/block exits (PreToolUse exit 2). Deny-by-design
20
+ * guards (ask-user-question-guard, git-guard, plan-html-reminder) use this
21
+ * path — not a crash.
22
+ */
23
+ blockCount: number;
24
+ /** Fraction (0-1) of fires with exit code 2 (intentional deny/block). */
25
+ blockRate?: number;
15
26
  /** Fraction (0-1) of fires that hit their configured timeout. */
16
27
  timeoutRate?: number;
17
28
  /** Project key the row is scoped to (see project-key.ts) — set only when
@@ -72,7 +72,10 @@ export function aggregateHookProfile(events) {
72
72
  const hits = evs.filter(e => e.cache === 'hit').length;
73
73
  const stale = evs.filter(e => e.cache === 'stale-prefetch').length;
74
74
  const misses = evs.filter(e => e.cache === 'miss').length;
75
- const errors = evs.filter(e => typeof e.exit === 'number' && e.exit !== 0).length;
75
+ // Exit classes (Claude/Codex PreToolUse convention): 0 allow, 2 deny/block,
76
+ // 1 / other nonzero = real error. Exit 2 is not a crash.
77
+ const blocks = evs.filter(e => e.exit === 2).length;
78
+ const errors = evs.filter(e => typeof e.exit === 'number' && e.exit !== 0 && e.exit !== 2).length;
76
79
  rows.push({
77
80
  hook,
78
81
  n,
@@ -85,7 +88,9 @@ export function aggregateHookProfile(events) {
85
88
  cacheStalePct: Math.round((stale / n) * 100),
86
89
  cacheMissPct: Math.round((misses / n) * 100),
87
90
  errorCount: errors,
91
+ blockCount: blocks,
88
92
  ...(errors > 0 ? { errorRate: Math.round((errors / n) * 1000) / 1000 } : {}),
93
+ ...(blocks > 0 ? { blockRate: Math.round((blocks / n) * 1000) / 1000 } : {}),
89
94
  // timeoutRate is not derivable here: the daily JSONL a shim writes only
90
95
  // covers fires that reached their own trailing printf — an externally
91
96
  // enforced timeout (the agent harness killing the process) never gets
package/dist/lib/hooks.js CHANGED
@@ -1610,6 +1610,7 @@ function registerHooksForOpenCode(versionHome, manifest, resolveScript) {
1610
1610
  const pluginSource = `// Generated by agents-cli. Re-run agents sync to update.
1611
1611
  import fs from "node:fs"
1612
1612
  import os from "node:os"
1613
+ import path from "node:path"
1613
1614
 
1614
1615
  const directHooks = ${serializedDirect}
1615
1616
  const lifecycleHooks = ${serializedLifecycle}
@@ -1622,7 +1623,9 @@ function recordTimeoutSample(hook, payload) {
1622
1623
  // printf, so a timed-out fire would otherwise leave ZERO trace in the
1623
1624
  // warehouse. Write the sample ourselves from the side that knows it timed out.
1624
1625
  try {
1625
- fs.mkdirSync(PERF_SPOOL.slice(0, PERF_SPOOL.lastIndexOf("/")), { recursive: true })
1626
+ // path.dirname — not lastIndexOf('/') so Windows backslash spool paths
1627
+ // still resolve to the parent dir (see #1869).
1628
+ fs.mkdirSync(path.dirname(PERF_SPOOL), { recursive: true })
1626
1629
  const line = JSON.stringify({
1627
1630
  ts_ms: Date.now(),
1628
1631
  kind: "hook.fire",
@@ -74,6 +74,15 @@ export declare function remoteRunShellPrelude(agent: string): string;
74
74
  * reliable whole-tree operation for both normal stops and rollback cleanup.
75
75
  */
76
76
  export declare function buildDetachedLaunchCommand(inner: string): string;
77
+ /** Build the detached PowerShell launch protocol used by Windows SSH hosts. */
78
+ export declare function buildWindowsDetachedLaunchCommand(opts: {
79
+ forwardedArgs: string[];
80
+ remoteCwd?: string;
81
+ mirrorCwd?: boolean;
82
+ remoteLog: string;
83
+ remoteExit: string;
84
+ env: Record<string, string>;
85
+ }): string;
77
86
  export interface DispatchResult {
78
87
  task: HostTask;
79
88
  /** Exit code when followed; undefined when detached (--no-follow). */
@@ -92,6 +101,7 @@ export declare function terminateDispatchedTask(task: HostTask): void;
92
101
  * Exit 1 if the group is still alive after TERM/KILL (can't stop it).
93
102
  */
94
103
  export declare function buildStopRemoteCommand(pid: number, remoteExit: string): string;
104
+ export declare function buildWindowsStopRemoteCommand(pid: number, remoteExit: string): string;
95
105
  /**
96
106
  * Stop a running host task from the origin machine (`agents hosts stop <id>`).
97
107
  *