@phnx-labs/agents-cli 1.21.2 → 1.22.0

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 (102) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +32 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/computer-actions.d.ts +4 -0
  5. package/dist/commands/computer-actions.js +35 -0
  6. package/dist/commands/computer.js +4 -2
  7. package/dist/commands/exec.d.ts +27 -0
  8. package/dist/commands/exec.js +123 -6
  9. package/dist/commands/models.js +36 -1
  10. package/dist/commands/perf.d.ts +16 -0
  11. package/dist/commands/perf.js +11 -1
  12. package/dist/commands/projects.d.ts +11 -1
  13. package/dist/commands/projects.js +38 -4
  14. package/dist/commands/sessions-backfill.d.ts +32 -0
  15. package/dist/commands/sessions-backfill.js +186 -0
  16. package/dist/commands/sessions-picker.js +17 -2
  17. package/dist/commands/sessions.d.ts +22 -1
  18. package/dist/commands/sessions.js +331 -18
  19. package/dist/commands/teams.js +1 -1
  20. package/dist/commands/worktree.d.ts +3 -3
  21. package/dist/commands/worktree.js +35 -4
  22. package/dist/index.js +8 -0
  23. package/dist/lib/browser/service.js +13 -0
  24. package/dist/lib/computer/dispatch.d.ts +3 -1
  25. package/dist/lib/computer/dispatch.js +10 -2
  26. package/dist/lib/daemon.d.ts +5 -1
  27. package/dist/lib/daemon.js +63 -14
  28. package/dist/lib/devices/resolve-target.d.ts +6 -0
  29. package/dist/lib/devices/resolve-target.js +9 -3
  30. package/dist/lib/event-stream.d.ts +2 -0
  31. package/dist/lib/event-stream.js +3 -0
  32. package/dist/lib/events.d.ts +3 -1
  33. package/dist/lib/events.js +4 -2
  34. package/dist/lib/exec.js +39 -8
  35. package/dist/lib/git.d.ts +14 -0
  36. package/dist/lib/git.js +36 -0
  37. package/dist/lib/hooks/profile.js +1 -14
  38. package/dist/lib/hosts/dispatch.d.ts +12 -0
  39. package/dist/lib/hosts/dispatch.js +23 -6
  40. package/dist/lib/hosts/reconnect.d.ts +38 -0
  41. package/dist/lib/hosts/reconnect.js +85 -4
  42. package/dist/lib/hosts/run-target.js +14 -2
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/model-tiers.d.ts +54 -0
  46. package/dist/lib/model-tiers.js +229 -0
  47. package/dist/lib/models.d.ts +3 -0
  48. package/dist/lib/models.js +44 -7
  49. package/dist/lib/percentile.d.ts +12 -0
  50. package/dist/lib/percentile.js +24 -0
  51. package/dist/lib/perf/db.d.ts +1 -2
  52. package/dist/lib/perf/db.js +2 -14
  53. package/dist/lib/plugins.js +12 -1
  54. package/dist/lib/pricing/prices.json +16 -1
  55. package/dist/lib/project-focus.d.ts +42 -0
  56. package/dist/lib/project-focus.js +80 -0
  57. package/dist/lib/project-schedule.d.ts +75 -0
  58. package/dist/lib/project-schedule.js +110 -0
  59. package/dist/lib/redact.d.ts +2 -0
  60. package/dist/lib/redact.js +22 -0
  61. package/dist/lib/remote-agents-json.d.ts +2 -0
  62. package/dist/lib/remote-agents-json.js +3 -3
  63. package/dist/lib/resources.d.ts +16 -0
  64. package/dist/lib/resources.js +25 -14
  65. package/dist/lib/rotate.d.ts +84 -1
  66. package/dist/lib/rotate.js +155 -5
  67. package/dist/lib/routines.js +1 -14
  68. package/dist/lib/runner.d.ts +4 -2
  69. package/dist/lib/runner.js +21 -5
  70. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  71. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  72. package/dist/lib/session/bash-command.js +60 -9
  73. package/dist/lib/session/db.d.ts +22 -1
  74. package/dist/lib/session/db.js +516 -24
  75. package/dist/lib/session/discover.d.ts +68 -7
  76. package/dist/lib/session/discover.js +186 -84
  77. package/dist/lib/session/highlights.d.ts +24 -4
  78. package/dist/lib/session/highlights.js +52 -7
  79. package/dist/lib/session/parse.d.ts +8 -1
  80. package/dist/lib/session/parse.js +102 -35
  81. package/dist/lib/session/prompt.d.ts +19 -0
  82. package/dist/lib/session/prompt.js +43 -0
  83. package/dist/lib/session/remote-list.d.ts +71 -0
  84. package/dist/lib/session/remote-list.js +410 -2
  85. package/dist/lib/session/shell-programs.d.ts +15 -0
  86. package/dist/lib/session/shell-programs.js +359 -0
  87. package/dist/lib/session/tool-calls.d.ts +88 -0
  88. package/dist/lib/session/tool-calls.js +612 -0
  89. package/dist/lib/session/tool-index.d.ts +100 -0
  90. package/dist/lib/session/tool-index.js +773 -0
  91. package/dist/lib/session/tool-store.d.ts +15 -0
  92. package/dist/lib/session/tool-store.js +198 -0
  93. package/dist/lib/session/types.d.ts +49 -0
  94. package/dist/lib/state.d.ts +10 -1
  95. package/dist/lib/state.js +11 -2
  96. package/dist/lib/teams/remoteWorktree.d.ts +3 -4
  97. package/dist/lib/teams/remoteWorktree.js +3 -4
  98. package/dist/lib/teams/worktree.d.ts +11 -1
  99. package/dist/lib/teams/worktree.js +42 -4
  100. package/dist/lib/types.d.ts +31 -0
  101. package/dist/lib/types.js +17 -0
  102. package/package.json +3 -1
@@ -14,7 +14,7 @@ import * as yaml from 'yaml';
14
14
  import { execFileSync } from 'child_process';
15
15
  import { getPluginsDir, getTrashPluginsDir, getExtraPluginsDir, getProjectPluginsDir, getSystemPluginsDir } from './state.js';
16
16
  import { IS_WINDOWS, isWindowsAbsolutePath, homeDir } from './platform/index.js';
17
- import { assertSafeGitTransport } from './git.js';
17
+ import { assertSafeGitTransport, resolveSnapshotSha } from './git.js';
18
18
  import { listInstalledVersions, getVersionHomePath } from './versions.js';
19
19
  import { AGENTS, agentConfigDirName } from './agents.js';
20
20
  import { capableAgents, isCapable } from './capabilities.js';
@@ -90,6 +90,13 @@ export function discoverPlugins(opts = {}) {
90
90
  return out;
91
91
  }
92
92
  export function buildDiscoveredPlugin(pluginRoot, manifest, spec = { kind: 'user' }) {
93
+ // Every marketplace kind lays plugins out as `<repo>/plugins/<name>`, so the
94
+ // repo root is always the grandparent of pluginRoot — true for user
95
+ // (~/.agents), system (~/.agents/.system), each extra repo, and the project
96
+ // repo (<cwd>/.agents) alike. Deriving it here means every caller of
97
+ // buildDiscoveredPlugin (discoverPluginsInDir, inspectPluginCapabilities, …)
98
+ // gets provenance for free with no signature change.
99
+ const repoRoot = path.dirname(path.dirname(pluginRoot));
93
100
  return {
94
101
  name: manifest.name,
95
102
  root: pluginRoot,
@@ -107,6 +114,10 @@ export function buildDiscoveredPlugin(pluginRoot, manifest, spec = { kind: 'user
107
114
  monitors: discoverPluginMonitors(pluginRoot),
108
115
  hasMcp: fs.existsSync(path.join(pluginRoot, '.mcp.json')),
109
116
  hasSettings: pluginHasNonPermissionSettings(pluginRoot),
117
+ repoRoot,
118
+ get snapshotSha() {
119
+ return resolveSnapshotSha(repoRoot);
120
+ },
110
121
  };
111
122
  }
112
123
  /**
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2026-06-24",
2
+ "version": "2026-08-03",
3
3
  "models": {
4
4
  "claude-opus-4": {
5
5
  "inputPerToken": 0.000005,
@@ -49,6 +49,21 @@
49
49
  "cacheReadPerToken": 0.0000015,
50
50
  "cacheWritePerToken": 0.00001875
51
51
  },
52
+ "gpt-5.6-sol": {
53
+ "inputPerToken": 0.000005,
54
+ "outputPerToken": 0.00003,
55
+ "cacheReadPerToken": 0.0000005
56
+ },
57
+ "gpt-5.6-terra": {
58
+ "inputPerToken": 0.0000025,
59
+ "outputPerToken": 0.000015,
60
+ "cacheReadPerToken": 0.00000025
61
+ },
62
+ "gpt-5.6-luna": {
63
+ "inputPerToken": 0.000001,
64
+ "outputPerToken": 0.000006,
65
+ "cacheReadPerToken": 0.0000001
66
+ },
52
67
  "gpt-5.5": {
53
68
  "inputPerToken": 0.000005,
54
69
  "outputPerToken": 0.00003,
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Where a project's work actually went, from the local git history.
3
+ *
4
+ * The card can say how many agents are running and how many PRs merged, but not
5
+ * *what was worked on*. That answer is already sitting in the checkout — every
6
+ * merged commit names the files it touched — so it costs no API call, no
7
+ * credential, and no rate-limit budget. Measured at 0.23s on this repo's own
8
+ * 7-day window (897 commits), which is why it runs unconditionally rather than
9
+ * behind a flag.
10
+ *
11
+ * Deliberately NOT from `gh`: the GitHub API would spend a request per PR to
12
+ * learn what `git log --name-only` already knows locally, and it would be wrong
13
+ * on a monorepo whose interesting unit is a subdirectory rather than a repo.
14
+ */
15
+ /** One directory and how many file-touches landed in it during the window. */
16
+ export interface FocusArea {
17
+ path: string;
18
+ touches: number;
19
+ }
20
+ /** Areas shown on the card before the tail is dropped. */
21
+ export declare const FOCUS_LIMIT = 4;
22
+ /**
23
+ * Bucket a file path to its area. Files shallower than {@link DEPTH} bucket to
24
+ * their own directory, so a repo-root `README.md` does not vanish.
25
+ */
26
+ export declare function focusBucket(file: string): string | undefined;
27
+ /**
28
+ * Rank areas by file-touches, descending, ties broken by path so the order is
29
+ * stable across runs. Pure — the caller supplies the file list, so this is
30
+ * testable without a git repo.
31
+ */
32
+ export declare function rankFocusAreas(files: string[], limit?: number): FocusArea[];
33
+ /**
34
+ * Read the window's changed files from a checkout. Best-effort in the same shape
35
+ * as the rest of the card's enrichment: a missing checkout, a shallow clone, or
36
+ * a repo with no commits in the window yields an empty list, never a throw.
37
+ *
38
+ * Reads the LOCAL default branch ref rather than fetching — a status command
39
+ * must not mutate the repo it is describing, so the answer is only as fresh as
40
+ * the user's last fetch, which is the correct trade for a read-only card.
41
+ */
42
+ export declare function readFocusAreas(root: string, windowDays: number): Promise<FocusArea[]>;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Where a project's work actually went, from the local git history.
3
+ *
4
+ * The card can say how many agents are running and how many PRs merged, but not
5
+ * *what was worked on*. That answer is already sitting in the checkout — every
6
+ * merged commit names the files it touched — so it costs no API call, no
7
+ * credential, and no rate-limit budget. Measured at 0.23s on this repo's own
8
+ * 7-day window (897 commits), which is why it runs unconditionally rather than
9
+ * behind a flag.
10
+ *
11
+ * Deliberately NOT from `gh`: the GitHub API would spend a request per PR to
12
+ * learn what `git log --name-only` already knows locally, and it would be wrong
13
+ * on a monorepo whose interesting unit is a subdirectory rather than a repo.
14
+ */
15
+ import { execFile } from 'child_process';
16
+ import { promisify } from 'util';
17
+ const execFileAsync = promisify(execFile);
18
+ /** How deep a bucket goes: `apps/cli/src`, not `apps` and not every leaf file. */
19
+ const DEPTH = 3;
20
+ /** Areas shown on the card before the tail is dropped. */
21
+ export const FOCUS_LIMIT = 4;
22
+ /**
23
+ * Paths whose churn is process, not engineering.
24
+ *
25
+ * This repo files one changelog fragment per PR, so `.changelog` ranks second by
26
+ * raw file-touches — presenting it as an "area of focus" would read as a signal
27
+ * while measuring nothing but the number of PRs. Same for the generated
28
+ * CHANGELOG and lockfiles.
29
+ */
30
+ const NOISE = /(^|\/)(\.changelog|CHANGELOG\.md|bun\.lock|package-lock\.json|yarn\.lock)(\/|$)/;
31
+ /**
32
+ * Bucket a file path to its area. Files shallower than {@link DEPTH} bucket to
33
+ * their own directory, so a repo-root `README.md` does not vanish.
34
+ */
35
+ export function focusBucket(file) {
36
+ if (NOISE.test(file))
37
+ return undefined;
38
+ const parts = file.split('/').filter(Boolean);
39
+ if (parts.length === 0)
40
+ return undefined;
41
+ if (parts.length === 1)
42
+ return parts[0];
43
+ return parts.slice(0, Math.min(DEPTH, parts.length - 1)).join('/');
44
+ }
45
+ /**
46
+ * Rank areas by file-touches, descending, ties broken by path so the order is
47
+ * stable across runs. Pure — the caller supplies the file list, so this is
48
+ * testable without a git repo.
49
+ */
50
+ export function rankFocusAreas(files, limit = FOCUS_LIMIT) {
51
+ const counts = new Map();
52
+ for (const f of files) {
53
+ const bucket = focusBucket(f.trim());
54
+ if (!bucket)
55
+ continue;
56
+ counts.set(bucket, (counts.get(bucket) ?? 0) + 1);
57
+ }
58
+ return [...counts.entries()]
59
+ .map(([path, touches]) => ({ path, touches }))
60
+ .sort((a, b) => b.touches - a.touches || a.path.localeCompare(b.path))
61
+ .slice(0, Math.max(1, limit));
62
+ }
63
+ /**
64
+ * Read the window's changed files from a checkout. Best-effort in the same shape
65
+ * as the rest of the card's enrichment: a missing checkout, a shallow clone, or
66
+ * a repo with no commits in the window yields an empty list, never a throw.
67
+ *
68
+ * Reads the LOCAL default branch ref rather than fetching — a status command
69
+ * must not mutate the repo it is describing, so the answer is only as fresh as
70
+ * the user's last fetch, which is the correct trade for a read-only card.
71
+ */
72
+ export async function readFocusAreas(root, windowDays) {
73
+ try {
74
+ const { stdout } = await execFileAsync('git', ['-C', root, 'log', `--since=${windowDays} days ago`, '--name-only', '--pretty=format:'], { timeout: 5000, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
75
+ return rankFocusAreas(stdout.split('\n').filter((l) => l.trim().length > 0));
76
+ }
77
+ catch {
78
+ return [];
79
+ }
80
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * What a project's milestone dates prove about its schedule — and nothing more.
3
+ *
4
+ * The tempting version of this file computes "on track / at risk". It cannot be
5
+ * written honestly against this data. Probed live against a real workspace:
6
+ *
7
+ * health: null projectUpdates: []
8
+ * startDate: null targetDate: null
9
+ * scopeHistory: [] completedScopeHistory: []
10
+ *
11
+ * "Behind schedule" needs either start+target dates to interpolate an expected
12
+ * progress line, or a history series to extrapolate a finish date. Both are
13
+ * absent, so any on-track/at-risk chip would be invented — and a confident wrong
14
+ * answer on a status card is worse than a blank one, because it is unfalsifiable
15
+ * from the card itself.
16
+ *
17
+ * So every verdict here is arithmetic on a stored date or a count:
18
+ *
19
+ * overdue a milestone's targetDate has passed and it is unfinished
20
+ * due-soon the next one lands within DUE_SOON_DAYS
21
+ * untracked milestones exist but nothing is filed against any of them,
22
+ * so their progress is not measurable
23
+ * scheduled dated milestones ahead, none due soon, work is filed
24
+ * no-dates milestones exist, none carries a date
25
+ * none the project declares no milestones
26
+ *
27
+ * `declared` relays Linear's OWN health when a human has posted one. It is
28
+ * passed through and attributed, never synthesized — if the user starts posting
29
+ * project updates, their answer wins over anything derived here.
30
+ */
31
+ import type { LinearMilestone } from './linear-project-counts.js';
32
+ /** How far ahead counts as "due soon" — one sprint's notice. */
33
+ export declare const DUE_SOON_DAYS = 14;
34
+ /** A verdict about the schedule, as a tagged union so `--json` stays stable. */
35
+ export type ProjectVerdict = {
36
+ kind: 'declared';
37
+ health: string;
38
+ } | {
39
+ kind: 'overdue';
40
+ milestone: string;
41
+ days: number;
42
+ } | {
43
+ kind: 'due-soon';
44
+ milestone: string;
45
+ days: number;
46
+ } | {
47
+ kind: 'untracked';
48
+ milestones: number;
49
+ } | {
50
+ kind: 'scheduled';
51
+ milestone: string;
52
+ days: number;
53
+ } | {
54
+ kind: 'no-dates';
55
+ milestones: number;
56
+ } | {
57
+ kind: 'none';
58
+ };
59
+ /** Whole days from `nowMs` to a `YYYY-MM-DD` date, compared at LOCAL midnight. */
60
+ export declare function daysUntil(targetDate: string, nowMs: number): number | undefined;
61
+ /**
62
+ * Decide what the dates prove. Precedence is time-sensitivity first:
63
+ *
64
+ * declared > overdue > due-soon > untracked > no-dates > scheduled
65
+ *
66
+ * An overdue milestone outranks an approaching one, and both outrank the
67
+ * observation that nothing is filed — a deadline moves, that observation does
68
+ * not. `declared` overrides everything, because a human said it.
69
+ */
70
+ export declare function scheduleVerdict(milestones: LinearMilestone[], nowMs: number, declaredHealth?: string | null): ProjectVerdict;
71
+ /** One line for the card. Returns undefined for `none` — an empty row says nothing. */
72
+ export declare function formatVerdict(v: ProjectVerdict): {
73
+ text: string;
74
+ warn: boolean;
75
+ } | undefined;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * What a project's milestone dates prove about its schedule — and nothing more.
3
+ *
4
+ * The tempting version of this file computes "on track / at risk". It cannot be
5
+ * written honestly against this data. Probed live against a real workspace:
6
+ *
7
+ * health: null projectUpdates: []
8
+ * startDate: null targetDate: null
9
+ * scopeHistory: [] completedScopeHistory: []
10
+ *
11
+ * "Behind schedule" needs either start+target dates to interpolate an expected
12
+ * progress line, or a history series to extrapolate a finish date. Both are
13
+ * absent, so any on-track/at-risk chip would be invented — and a confident wrong
14
+ * answer on a status card is worse than a blank one, because it is unfalsifiable
15
+ * from the card itself.
16
+ *
17
+ * So every verdict here is arithmetic on a stored date or a count:
18
+ *
19
+ * overdue a milestone's targetDate has passed and it is unfinished
20
+ * due-soon the next one lands within DUE_SOON_DAYS
21
+ * untracked milestones exist but nothing is filed against any of them,
22
+ * so their progress is not measurable
23
+ * scheduled dated milestones ahead, none due soon, work is filed
24
+ * no-dates milestones exist, none carries a date
25
+ * none the project declares no milestones
26
+ *
27
+ * `declared` relays Linear's OWN health when a human has posted one. It is
28
+ * passed through and attributed, never synthesized — if the user starts posting
29
+ * project updates, their answer wins over anything derived here.
30
+ */
31
+ /** How far ahead counts as "due soon" — one sprint's notice. */
32
+ export const DUE_SOON_DAYS = 14;
33
+ /** Whole days from `nowMs` to a `YYYY-MM-DD` date, compared at LOCAL midnight. */
34
+ export function daysUntil(targetDate, nowMs) {
35
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(targetDate.trim());
36
+ if (!m)
37
+ return undefined;
38
+ const due = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
39
+ if (Number.isNaN(due.getTime()))
40
+ return undefined;
41
+ const now = new Date(nowMs);
42
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
43
+ return Math.round((due.getTime() - today.getTime()) / 86_400_000);
44
+ }
45
+ const unfinished = (m) => m.total === 0 || m.done < m.total;
46
+ /**
47
+ * Decide what the dates prove. Precedence is time-sensitivity first:
48
+ *
49
+ * declared > overdue > due-soon > untracked > no-dates > scheduled
50
+ *
51
+ * An overdue milestone outranks an approaching one, and both outrank the
52
+ * observation that nothing is filed — a deadline moves, that observation does
53
+ * not. `declared` overrides everything, because a human said it.
54
+ */
55
+ export function scheduleVerdict(milestones, nowMs, declaredHealth) {
56
+ if (declaredHealth)
57
+ return { kind: 'declared', health: declaredHealth };
58
+ if (milestones.length === 0)
59
+ return { kind: 'none' };
60
+ const open = milestones.filter(unfinished);
61
+ const dated = open
62
+ .map((m) => ({ m, days: m.targetDate ? daysUntil(m.targetDate, nowMs) : undefined }))
63
+ .filter((x) => x.days !== undefined)
64
+ .sort((a, b) => a.days - b.days);
65
+ const worst = dated[0];
66
+ if (worst && worst.days < 0)
67
+ return { kind: 'overdue', milestone: worst.m.name, days: -worst.days };
68
+ // A date bearing down is time-sensitive; "nothing is filed" is a standing
69
+ // condition that will still be true tomorrow. So an approaching deadline is
70
+ // reported even when the milestone has no issues against it — reversing these
71
+ // hid a milestone due in two days behind "3 milestones, no issues filed".
72
+ if (worst && worst.days <= DUE_SOON_DAYS)
73
+ return { kind: 'due-soon', milestone: worst.m.name, days: worst.days };
74
+ // Nothing is filed against ANY milestone, so no progress can be computed for
75
+ // them — the useful thing to say, and the actual state of a project whose
76
+ // milestones were created before its issues. Checked against the full list,
77
+ // not just the open ones: a COMPLETED milestone necessarily has issues, so a
78
+ // project with one cannot honestly be called untracked.
79
+ if (milestones.every((m) => m.total === 0))
80
+ return { kind: 'untracked', milestones: milestones.length };
81
+ if (!worst)
82
+ return { kind: 'no-dates', milestones: open.length };
83
+ return { kind: 'scheduled', milestone: worst.m.name, days: worst.days };
84
+ }
85
+ /** One line for the card. Returns undefined for `none` — an empty row says nothing. */
86
+ export function formatVerdict(v) {
87
+ switch (v.kind) {
88
+ case 'declared':
89
+ // Attributed, so nobody mistakes a human's call for a derived one.
90
+ return { text: `per Linear: ${v.health}`, warn: v.health.toLowerCase() !== 'ontrack' };
91
+ case 'overdue':
92
+ return { text: `${v.milestone} overdue by ${v.days} day${v.days === 1 ? '' : 's'}`, warn: true };
93
+ case 'due-soon':
94
+ return {
95
+ text: `${v.milestone} due ${v.days === 0 ? 'today' : v.days === 1 ? 'tomorrow' : `in ${v.days} days`}`,
96
+ warn: false,
97
+ };
98
+ case 'untracked':
99
+ return {
100
+ text: `${v.milestones} milestone${v.milestones === 1 ? '' : 's'}, no issues filed against any — progress is not measurable`,
101
+ warn: true,
102
+ };
103
+ case 'scheduled':
104
+ return { text: `${v.milestone} in ${v.days} days`, warn: false };
105
+ case 'no-dates':
106
+ return { text: `${v.milestones} open milestone${v.milestones === 1 ? '' : 's'}, none dated`, warn: false };
107
+ case 'none':
108
+ return undefined;
109
+ }
110
+ }
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Shared redaction helpers for text that may be exported or logged.
3
3
  */
4
+ /** Remove terminal control sequences from untrusted text before storage or display. */
5
+ export declare function sanitizeForTerminal(text: string): string;
4
6
  /**
5
7
  * Scrub secrets from `text`. Two passes: format-based patterns (above), then a
6
8
  * value-aware pass that masks any `knownValues` verbatim — a credential we
@@ -20,9 +20,31 @@ const SECRET_PATTERNS = [
20
20
  [/\bxapp-[A-Za-z0-9-]{10,}\b/g, '[REDACTED_SLACK_TOKEN]'],
21
21
  [/\bnpm_[A-Za-z0-9]{36}\b/g, '[REDACTED_NPM_TOKEN]'],
22
22
  [/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, '[REDACTED_JWT]'],
23
+ // Headers frequently appear inside shell arguments. Consume a quoted header
24
+ // as one unit so cookie attributes after a space do not survive redaction.
25
+ [/(^|\s)(["'])(Cookie|Set-Cookie|Authorization|Proxy-Authorization)\s*:\s*.*?\2/gi, '$1$2$3: [REDACTED]$2'],
26
+ [/(^|\s)(["'])(Cookie|Set-Cookie|Authorization|Proxy-Authorization)\s*:\s*(?:(?!\2).)*$/gi, '$1$2$3: [REDACTED]'],
23
27
  [/Bearer\s+\S+/gi, 'Bearer [REDACTED]'],
28
+ [/\b((?:Cookie|Set-Cookie)\s*:\s*)\S+/gi, '$1[REDACTED]'],
29
+ [/\b(Authorization\s*:\s*)(?!Bearer\s+\[REDACTED\])\S+(?:\s+\S+)?/gi, '$1[REDACTED]'],
30
+ // Structured secret fields can arrive as raw JSON output rather than an
31
+ // argument object, so the object walker alone is not sufficient.
32
+ [/(^[,{\s]|["'])([A-Z0-9_-]*(?:TOKEN|KEY|SECRET|PASSWORD|AUTHORIZATION|COOKIE)[A-Z0-9_-]*["']?\s*:\s*)(["'][^"']*["']|[^,}\]\s]+)/gim, '$1$2"[REDACTED]"'],
33
+ [/(\s--?(?:password|token|secret|api[_-]?key)(?:=|\s+))(["']?)[^\s"']+\2/gi, '$1[REDACTED]'],
34
+ [/(\s--user(?:=|\s+))(["']?)[^\s"']+\2/gi, '$1[REDACTED]'],
35
+ [/(\s-u\s+)(["']?)[^\s"']+\2/g, '$1[REDACTED]'],
36
+ [/(\s--proxy-user(?:=|\s+))(["']?)[^\s"']+\2/gi, '$1[REDACTED]'],
37
+ [/(\s-U\s+)(["']?)[^\s"']+\2/g, '$1[REDACTED]'],
38
+ [/(https?:\/\/)[^/\s:@]+:[^@\s/]+@/gi, '$1[REDACTED]@'],
24
39
  [/\b([A-Z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD)[A-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)/gi, '$1=[REDACTED]'],
25
40
  ];
41
+ const TERMINAL_ESCAPE_REGEX = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x9d[^\x07\x9c]*(?:\x07|\x9c)|\x1b\[[0-?]*[ -/]*[@-~]|\x9b[0-?]*[ -/]*[@-~]|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
42
+ /** Remove terminal control sequences from untrusted text before storage or display. */
43
+ export function sanitizeForTerminal(text) {
44
+ if (!text)
45
+ return text;
46
+ return text.replace(TERMINAL_ESCAPE_REGEX, '');
47
+ }
26
48
  /** Env vars whose NAME marks their VALUE as a credential worth masking literally. */
27
49
  const SECRET_ENV_NAME = /(?:TOKEN|KEY|SECRET|PASSWORD)/i;
28
50
  /** Don't literal-mask trivially short values — they collide with ordinary text. */
@@ -10,6 +10,8 @@ export interface RemoteAgentsJsonOptions<T> {
10
10
  * printing a line per offline box above its output. Never a silent drop.
11
11
  */
12
12
  quiet?: boolean;
13
+ /** Per-peer deadline. Long-running maintenance commands override 12 seconds. */
14
+ timeoutMs?: number;
13
15
  }
14
16
  export interface RemoteAgentsJsonParseResult<T> {
15
17
  items: T[];
@@ -32,7 +32,7 @@ export function remoteAgentsJsonCommand(args, noFanoutEnv, os) {
32
32
  const inner = `${noFanoutEnv}=1 agents ${args.map(shellQuote).join(' ')}`;
33
33
  return `bash -lc ${shellQuote(inner)}`;
34
34
  }
35
- function sshCapture(target, remoteCmd) {
35
+ function sshCapture(target, remoteCmd, timeoutMs) {
36
36
  assertValidSshTarget(target);
37
37
  return new Promise((resolve) => {
38
38
  const args = [...SSH_OPTS, ...controlOpts(), target, remoteCmd];
@@ -49,7 +49,7 @@ function sshCapture(target, remoteCmd) {
49
49
  const timer = setTimeout(() => {
50
50
  child.kill('SIGKILL');
51
51
  done(null);
52
- }, REMOTE_TIMEOUT_MS);
52
+ }, timeoutMs);
53
53
  child.stdout.on('data', (data) => { stdout += data.toString(); });
54
54
  child.on('error', () => done(null));
55
55
  child.on('close', (code) => done(code));
@@ -102,7 +102,7 @@ export async function gatherRemoteAgentsJson(options) {
102
102
  const parseFailed = [];
103
103
  const results = await Promise.all(targets.map(async (target) => {
104
104
  const command = remoteAgentsJsonCommand(options.args, options.noFanoutEnv, target.os);
105
- const result = await sshCapture(target.target, command);
105
+ const result = await sshCapture(target.target, command, options.timeoutMs ?? REMOTE_TIMEOUT_MS);
106
106
  if (result.code !== 0) {
107
107
  skipped.push(target.name);
108
108
  if (!options.quiet) {
@@ -16,6 +16,22 @@ export interface ResolvedResource {
16
16
  * or the alias name (e.g. 'rush') for extra repos registered in agents.yaml.
17
17
  */
18
18
  source: string;
19
+ /**
20
+ * Absolute path to the DotAgents repo root this resource resolved from (the
21
+ * project/user/system/extra-repo dir — one level above the `kind`
22
+ * subdirectory). DotAgents repos are git-tracked (plugins.ts), so this pairs
23
+ * with {@link snapshotSha} to answer "which commit of which repo".
24
+ */
25
+ repoRoot: string;
26
+ /**
27
+ * Short HEAD sha of `repoRoot`'s git checkout, lazily resolved (a getter,
28
+ * not computed at construction) and memoized per repoRoot
29
+ * (`git.ts` `resolveSnapshotSha`) — a caller that never inspects provenance
30
+ * never pays for the git shell-out, and resolving many resources from the
31
+ * same repo pays for exactly one. `undefined` when `repoRoot` isn't a git
32
+ * repo (or has no commits).
33
+ */
34
+ readonly snapshotSha: string | undefined;
19
35
  }
20
36
  /**
21
37
  * True when `rawName` (a filename with its extension already stripped) names a
@@ -15,6 +15,16 @@ import { WorkflowsHandler } from './resources/workflows.js';
15
15
  import { isCapable } from './capabilities.js';
16
16
  import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, } from './state.js';
17
17
  import { isNameActiveInResourceProfile } from './resource-profiles.js';
18
+ import { resolveSnapshotSha } from './git.js';
19
+ /** Build a ResolvedResource with a lazy, memoized `snapshotSha` getter. */
20
+ function withProvenance(base) {
21
+ return {
22
+ ...base,
23
+ get snapshotSha() {
24
+ return resolveSnapshotSha(base.repoRoot);
25
+ },
26
+ };
27
+ }
18
28
  function profiledKind(kind) {
19
29
  switch (kind) {
20
30
  case 'commands':
@@ -70,19 +80,19 @@ export function resolveResource(kind, name, cwd) {
70
80
  const projectDir = getProjectAgentsDir(cwd);
71
81
  const extraRepos = getEnabledExtraRepos();
72
82
  const candidates = [
73
- ...(projectDir ? [[path.join(projectDir, kind), 'project']] : []),
74
- [path.join(getUserAgentsDir(), kind), 'user'],
75
- [path.join(getSystemAgentsDir(), kind), 'system'],
76
- ...extraRepos.map((e) => [path.join(e.dir, kind), e.alias]),
83
+ ...(projectDir ? [[path.join(projectDir, kind), 'project', projectDir]] : []),
84
+ [path.join(getUserAgentsDir(), kind), 'user', getUserAgentsDir()],
85
+ [path.join(getSystemAgentsDir(), kind), 'system', getSystemAgentsDir()],
86
+ ...extraRepos.map((e) => [path.join(e.dir, kind), e.alias, e.dir]),
77
87
  ];
78
- for (const [dir, source] of candidates) {
88
+ for (const [dir, source, repoRoot] of candidates) {
79
89
  if (!fs.existsSync(dir))
80
90
  continue;
81
91
  // Try exact name (for directories like skills/subagents)
82
92
  const exactPath = path.join(dir, name);
83
93
  if (fs.existsSync(exactPath)) {
84
94
  if (resourceIsActive(kind, name, source)) {
85
- return { name, path: exactPath, source };
95
+ return withProvenance({ name, path: exactPath, source, repoRoot });
86
96
  }
87
97
  continue;
88
98
  }
@@ -94,7 +104,7 @@ export function resolveResource(kind, name, cwd) {
94
104
  const withExt = exactPath + ext;
95
105
  if (fs.existsSync(withExt)) {
96
106
  if (resourceIsActive(kind, name, source)) {
97
- return { name, path: withExt, source };
107
+ return withProvenance({ name, path: withExt, source, repoRoot });
98
108
  }
99
109
  continue;
100
110
  }
@@ -113,12 +123,12 @@ export function listResources(kind, cwd) {
113
123
  const projectDir = getProjectAgentsDir(cwd);
114
124
  const extraRepos = getEnabledExtraRepos();
115
125
  const roots = [
116
- ...(projectDir ? [[path.join(projectDir, kind), 'project']] : []),
117
- [path.join(getUserAgentsDir(), kind), 'user'],
118
- [path.join(getSystemAgentsDir(), kind), 'system'],
119
- ...extraRepos.map((e) => [path.join(e.dir, kind), e.alias]),
126
+ ...(projectDir ? [[path.join(projectDir, kind), 'project', projectDir]] : []),
127
+ [path.join(getUserAgentsDir(), kind), 'user', getUserAgentsDir()],
128
+ [path.join(getSystemAgentsDir(), kind), 'system', getSystemAgentsDir()],
129
+ ...extraRepos.map((e) => [path.join(e.dir, kind), e.alias, e.dir]),
120
130
  ];
121
- for (const [dir, source] of roots) {
131
+ for (const [dir, source, repoRoot] of roots) {
122
132
  if (!fs.existsSync(dir))
123
133
  continue;
124
134
  let entries;
@@ -143,11 +153,12 @@ export function listResources(kind, cwd) {
143
153
  if (!resourceIsActive(kind, rawName, source))
144
154
  continue;
145
155
  seen.add(rawName);
146
- results.push({
156
+ results.push(withProvenance({
147
157
  name: rawName,
148
158
  path: path.join(dir, entry.name),
149
159
  source,
150
- });
160
+ repoRoot,
161
+ }));
151
162
  }
152
163
  }
153
164
  return results;
@@ -174,7 +174,81 @@ export declare function capacityWeight(usedPercent: number | null, minutesToLimi
174
174
  * usage headroom.
175
175
  */
176
176
  export declare function pickAvailableCandidate(candidates: RotateCandidate[], preferredVersion?: string | null, nowMs?: number): RotateResult | null;
177
+ /**
178
+ * Per-harness routing summary for `agents run auto` — the cross-harness layer
179
+ * that sits above `pickBalancedCandidate` (which is strictly per-harness).
180
+ */
181
+ export interface HarnessSummary {
182
+ agent: AgentId;
183
+ /** Every installed account slot probed for this harness. */
184
+ candidates: RotateCandidate[];
185
+ /** Healthy accounts after identity dedupe, sorted by headroom. */
186
+ healthy: RotateCandidate[];
187
+ /** The account this harness would route to (best verified headroom). Null when the harness is excluded. */
188
+ best: RotateCandidate | null;
189
+ /** Routing used% of `best` (max across non-session windows); null when unknown. */
190
+ bestUsedPercent: number | null;
191
+ /** Why the harness was excluded, e.g. ['2 rate_limited', '1 signed_out']. Empty when healthy. */
192
+ exclusionReasons: string[];
193
+ }
194
+ export interface HarnessPickResult {
195
+ /** The harness picked for this run. */
196
+ picked: HarnessSummary;
197
+ /** Harnesses with ≥1 healthy account (including the picked one). */
198
+ healthy: HarnessSummary[];
199
+ /** Harnesses with zero healthy accounts — excluded, not down-weighted. */
200
+ excluded: HarnessSummary[];
201
+ }
202
+ /**
203
+ * Classify every harness's candidates into healthy (with a representative
204
+ * best account) vs excluded (with per-reason counts). Pure — the pick and the
205
+ * zero-healthy error message both read this, so they can never disagree.
206
+ *
207
+ * Health uses the exact account-layer gate (`isRotationEligible`: signed in
208
+ * AND not maxed on ANY blocking window, weekly included). The representative
209
+ * best account honors `preferVerified`: confirmed headroom beats apparent
210
+ * headroom, the same freshness rule the account layer routes on.
211
+ */
212
+ export declare function classifyHarnessCandidates(byHarness: ReadonlyMap<AgentId, RotateCandidate[]>, nowMs?: number): HarnessSummary[];
213
+ /**
214
+ * Pick a harness for `agents run auto` using weighted random by best-account
215
+ * headroom (RUSH-2132).
216
+ *
217
+ * A harness's capacity is `100 − min(routingUsed% across its healthy accounts)`
218
+ * — its best account's headroom. The pick reuses `weightedRandomByCapacity` on
219
+ * the representative best accounts, so host/harness/account layers all share
220
+ * one sampling behavior. Harnesses with zero healthy accounts are EXCLUDED,
221
+ * not down-weighted. Returns null when no harness has any healthy account;
222
+ * call `classifyHarnessCandidates` for the exclusion detail to message with.
223
+ */
224
+ export declare function pickHarnessWeighted(byHarness: ReadonlyMap<AgentId, RotateCandidate[]>, nowMs?: number): HarnessPickResult | null;
225
+ /** One-line banner naming the auto-picked harness and why (headroom). */
226
+ export declare function formatHarnessPickBanner(result: HarnessPickResult): string;
227
+ /**
228
+ * The earliest FUTURE window reset across these candidates' usage snapshots —
229
+ * when the first exhausted account becomes usable again. Null when no snapshot
230
+ * carries a reset timestamp.
231
+ */
232
+ export declare function earliestResetAcross(candidates: RotateCandidate[], nowMs?: number): Date | null;
233
+ /**
234
+ * The zero-healthy-account error (RUSH-2132). EXACT contract — the Factory
235
+ * watchdog tail-detects this text: it must contain the literal `no healthy`
236
+ * and `resets <time>` (parsed for the rotate cooldown). Do not deviate.
237
+ */
238
+ export declare function formatNoHealthyAccountError(agent: AgentId, strategy: RunStrategy, excluded: RotateCandidate[], nowMs?: number): string;
239
+ /**
240
+ * The zero-healthy-harness error for `agents run auto` — names each harness's
241
+ * exclusion reason plus the earliest reset across all snapshots.
242
+ */
243
+ export declare function formatNoHealthyHarnessError(summaries: HarnessSummary[], nowMs?: number): string;
177
244
  export declare function collectRunCandidates(agent: AgentId): Promise<RotateCandidate[]>;
245
+ /**
246
+ * Collect run candidates for every harness with ≥1 installed version — the
247
+ * probe `agents run auto` routes on (the same per-harness account probe
248
+ * `agents view` aggregates). Harnesses with nothing installed are absent from
249
+ * the map: not a candidate at all, rather than an excluded one.
250
+ */
251
+ export declare function collectHarnessCandidates(agentIds?: AgentId[]): Promise<Map<AgentId, RotateCandidate[]>>;
178
252
  /**
179
253
  * Resolve an account identity to the installed version slot that holds it, over
180
254
  * an already-collected candidate list. Pure — no I/O — so it is unit-tested
@@ -207,9 +281,18 @@ export declare function resolveAccountVersion(agent: AgentId, account: string):
207
281
  export declare function selectBalancedVersion(agent: AgentId): Promise<RotateResult | null>;
208
282
  /** Select the configured version if available, otherwise another available version. */
209
283
  export declare function selectAvailableVersion(agent: AgentId, preferredVersion?: string | null): Promise<RotateResult | null>;
210
- export declare function resolveRunVersion(agent: AgentId, strategy: RunStrategy, cwd?: string): Promise<{
284
+ export declare function resolveRunVersion(agent: AgentId, strategy: RunStrategy, cwd?: string, collect?: (agent: AgentId) => Promise<RotateCandidate[]>): Promise<{
211
285
  version: string | null;
212
286
  rotation: RotateResult | null;
287
+ /**
288
+ * Set when a non-pinned strategy found ZERO healthy candidates among the
289
+ * installed versions: the full excluded set, so callers fail loud with
290
+ * per-account reasons instead of launching the exhausted pinned default
291
+ * (RUSH-2132). Undefined for pinned, for successful picks, and when no
292
+ * version is installed at all (the pre-existing not-installed path — there
293
+ * is no account to be "unhealthy").
294
+ */
295
+ exhausted?: RotateCandidate[];
213
296
  }>;
214
297
  /**
215
298
  * Cap on the number of healthy accounts a single run will re-dispatch through