@phnx-labs/agents-cli 1.22.68 → 1.22.70

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 (132) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +17 -14
  3. package/dist/bootstrap.js +3 -0
  4. package/dist/commands/accounts.js +23 -7
  5. package/dist/commands/exec.js +33 -23
  6. package/dist/commands/feed.js +20 -7
  7. package/dist/commands/import.js +2 -2
  8. package/dist/commands/models.js +2 -2
  9. package/dist/commands/monitors.js +3 -0
  10. package/dist/commands/permissions.js +2 -2
  11. package/dist/commands/projects.d.ts +26 -6
  12. package/dist/commands/projects.js +55 -22
  13. package/dist/commands/rules.js +1 -1
  14. package/dist/commands/send.js +35 -2
  15. package/dist/commands/sessions-inject.d.ts +58 -0
  16. package/dist/commands/sessions-inject.js +143 -7
  17. package/dist/commands/sessions-picker.js +1 -0
  18. package/dist/commands/share.js +43 -14
  19. package/dist/commands/ssh.js +205 -2
  20. package/dist/commands/traces.js +1 -1
  21. package/dist/lib/account-capabilities.js +0 -2
  22. package/dist/lib/account-registry.d.ts +3 -3
  23. package/dist/lib/account-registry.js +25 -7
  24. package/dist/lib/accounting/usage.d.ts +7 -2
  25. package/dist/lib/accounting/usage.js +142 -10
  26. package/dist/lib/acp/client.d.ts +1 -1
  27. package/dist/lib/acp/client.js +12 -1
  28. package/dist/lib/acp/harnesses.js +1 -1
  29. package/dist/lib/add-dir.js +0 -2
  30. package/dist/lib/agent-cli-commands.js +0 -2
  31. package/dist/lib/agent-spec/agents.d.ts +1 -1
  32. package/dist/lib/agent-spec/agents.js +2 -83
  33. package/dist/lib/boot-profile.d.ts +14 -0
  34. package/dist/lib/boot-profile.js +66 -0
  35. package/dist/lib/browser/ipc.js +9 -1
  36. package/dist/lib/browser/remote-control.d.ts +6 -3
  37. package/dist/lib/browser/remote-control.js +6 -3
  38. package/dist/lib/browser/service.d.ts +4 -1
  39. package/dist/lib/browser/service.js +7 -1
  40. package/dist/lib/channels/providers/desktop.d.ts +5 -4
  41. package/dist/lib/channels/providers/desktop.js +5 -4
  42. package/dist/lib/channels/registry.d.ts +6 -0
  43. package/dist/lib/channels/send.d.ts +11 -2
  44. package/dist/lib/channels/send.js +11 -2
  45. package/dist/lib/claude-account-token.js +108 -4
  46. package/dist/lib/cloud/rush.d.ts +10 -2
  47. package/dist/lib/cloud/rush.js +15 -9
  48. package/dist/lib/daemon/daemon.js +28 -8
  49. package/dist/lib/daemon/runner.js +6 -2
  50. package/dist/lib/devices/health.d.ts +38 -2
  51. package/dist/lib/devices/health.js +43 -5
  52. package/dist/lib/devices/worker-pick.d.ts +1 -1
  53. package/dist/lib/devices/worker-pick.js +4 -1
  54. package/dist/lib/exec.d.ts +2 -2
  55. package/dist/lib/exec.js +7 -34
  56. package/dist/lib/feed-broadcast.d.ts +47 -5
  57. package/dist/lib/feed-broadcast.js +127 -24
  58. package/dist/lib/hooks/install.js +0 -87
  59. package/dist/lib/installations/strategies.js +1 -1
  60. package/dist/lib/mcp-registry.js +0 -13
  61. package/dist/lib/mcp.js +2 -2
  62. package/dist/lib/model-tiers.js +1 -1
  63. package/dist/lib/models.js +0 -63
  64. package/dist/lib/monitors/engine.js +18 -0
  65. package/dist/lib/monitors/sources/command.js +13 -3
  66. package/dist/lib/monitors/sources/failure.d.ts +32 -0
  67. package/dist/lib/monitors/sources/failure.js +52 -0
  68. package/dist/lib/monitors/sources/types.d.ts +9 -0
  69. package/dist/lib/notify.d.ts +11 -0
  70. package/dist/lib/notify.js +17 -4
  71. package/dist/lib/owner-message.d.ts +55 -0
  72. package/dist/lib/owner-message.js +64 -0
  73. package/dist/lib/permissions-registry.d.ts +0 -2
  74. package/dist/lib/permissions-registry.js +3 -50
  75. package/dist/lib/permissions.d.ts +3 -17
  76. package/dist/lib/permissions.js +4 -73
  77. package/dist/lib/run-trace-sync.d.ts +15 -0
  78. package/dist/lib/run-trace-sync.js +43 -21
  79. package/dist/lib/rush-session.d.ts +19 -0
  80. package/dist/lib/rush-session.js +24 -0
  81. package/dist/lib/secrets/drivers/rush.js +2 -1
  82. package/dist/lib/secrets/filestore.d.ts +4 -0
  83. package/dist/lib/secrets/filestore.js +164 -3
  84. package/dist/lib/session/active.d.ts +10 -0
  85. package/dist/lib/session/active.js +3 -0
  86. package/dist/lib/session/cloud.js +2 -1
  87. package/dist/lib/session/db.d.ts +12 -1
  88. package/dist/lib/session/db.js +20 -1
  89. package/dist/lib/session/discover.js +81 -1
  90. package/dist/lib/session/linear.d.ts +13 -0
  91. package/dist/lib/session/linear.js +44 -0
  92. package/dist/lib/session/live-metadata.js +1 -0
  93. package/dist/lib/session/parse.js +2 -3
  94. package/dist/lib/session/prompt.d.ts +7 -1
  95. package/dist/lib/session/prompt.js +12 -2
  96. package/dist/lib/session/recovery.d.ts +21 -12
  97. package/dist/lib/session/recovery.js +29 -11
  98. package/dist/lib/session/remote/watch.js +5 -2
  99. package/dist/lib/session/state.js +11 -13
  100. package/dist/lib/share/backend.d.ts +2 -2
  101. package/dist/lib/share/backend.js +20 -9
  102. package/dist/lib/share/delete.d.ts +5 -1
  103. package/dist/lib/share/delete.js +7 -2
  104. package/dist/lib/share/http-error.d.ts +52 -0
  105. package/dist/lib/share/http-error.js +65 -0
  106. package/dist/lib/share/publish.d.ts +13 -3
  107. package/dist/lib/share/publish.js +19 -15
  108. package/dist/lib/share/worker-template.js +5 -1
  109. package/dist/lib/sink-format.d.ts +34 -0
  110. package/dist/lib/sink-format.js +17 -0
  111. package/dist/lib/smart-launch.js +27 -4
  112. package/dist/lib/staleness/detectors/permissions.js +0 -20
  113. package/dist/lib/staleness/writers/commands.js +1 -1
  114. package/dist/lib/staleness/writers/hooks.js +2 -2
  115. package/dist/lib/storage/index.d.ts +14 -0
  116. package/dist/lib/storage/index.js +14 -0
  117. package/dist/lib/storage/selection.d.ts +48 -0
  118. package/dist/lib/storage/selection.js +39 -0
  119. package/dist/lib/storage/visibility.d.ts +82 -0
  120. package/dist/lib/storage/visibility.js +99 -0
  121. package/dist/lib/subagents-registry.js +2 -12
  122. package/dist/lib/subagents.d.ts +0 -10
  123. package/dist/lib/subagents.js +0 -37
  124. package/dist/lib/teams/agents.js +3 -1
  125. package/dist/lib/teams/placement-probe.js +1 -0
  126. package/dist/lib/teams/scheduler.d.ts +8 -1
  127. package/dist/lib/teams/scheduler.js +4 -1
  128. package/dist/lib/traces/backend.js +13 -2
  129. package/dist/lib/types.d.ts +1 -1
  130. package/dist/lib/worktree/held.d.ts +166 -0
  131. package/dist/lib/worktree/held.js +368 -0
  132. package/package.json +2 -2
@@ -0,0 +1,166 @@
1
+ /**
2
+ * The coarse bucket a held worktree falls into. These are the three the ticket
3
+ * names, and they demand very different follow-ups:
4
+ *
5
+ * - `unmerged-commits` — the one that matters. The branch carries commits with
6
+ * no patch-equivalent upstream: real work visible to nobody. Candidate for
7
+ * "push the branch or open a PR", never deletion.
8
+ * - `uncommitted-changes` — a dirty tree. Could be live work, could be build
9
+ * output nobody will miss. Needs a human eye, not an automatic action.
10
+ * - `undeterminable` — a broken or locked checkout whose merge state or status
11
+ * could not be read. Fails closed by design; must be re-examined, never swept.
12
+ */
13
+ export type HeldBucket = 'unmerged-commits' | 'uncommitted-changes' | 'undeterminable';
14
+ /**
15
+ * The specific reason inside a bucket. `undeterminable` splits into the two the
16
+ * sweep distinguishes so an operator can tell a locked index (`status-unreadable`)
17
+ * from a repo with no resolvable default ref (`merge-state-unknown`).
18
+ */
19
+ export type HeldReason = 'unmerged-commits' | 'uncommitted-changes' | 'status-unreadable' | 'merge-state-unknown';
20
+ /** One classified held worktree — the structured record the sweep threw away. */
21
+ export interface HeldWorktree {
22
+ /** Repo root that owns this worktree (the dir holding `.agents/worktrees`). */
23
+ repo: string;
24
+ /** basename(repo), for compact tables. */
25
+ repoName: string;
26
+ /** Worktree slug (the dir under `.agents/worktrees`). */
27
+ name: string;
28
+ /** Absolute path to the worktree. */
29
+ path: string;
30
+ /** Checked-out branch, or null for a detached HEAD. */
31
+ branch: string | null;
32
+ bucket: HeldBucket;
33
+ reason: HeldReason;
34
+ /**
35
+ * Commits on this worktree with no patch-equivalent upstream (`git cherry`).
36
+ * -1 means the answer could not be established.
37
+ */
38
+ unmergedCommits: number;
39
+ /** `git status --porcelain` line count; -1 means it could not be read. */
40
+ dirtyFiles: number;
41
+ /** True when `<branch>` already exists on `origin` (so the work is visible). */
42
+ hasRemoteBranch: boolean;
43
+ /** Whole days since the worktree dir was last modified. */
44
+ ageDays: number;
45
+ /** Disk footprint of the worktree dir in bytes, or -1 if it could not be read. */
46
+ sizeBytes: number;
47
+ }
48
+ /**
49
+ * Resolve the default branch ref to compare against: `origin/HEAD` when set,
50
+ * else whichever of `origin/main` / `origin/master` exists. Returns null when
51
+ * none resolve, which makes every worktree `merge-state-unknown` rather than
52
+ * silently comparing against nothing.
53
+ */
54
+ export declare function resolveDefaultRef(repoRoot: string): Promise<string | null>;
55
+ /**
56
+ * Count commits with no patch-equivalent upstream. Returns -1 when the answer
57
+ * cannot be established, which the classifier treats as `merge-state-unknown`.
58
+ *
59
+ * Patch-id (`git cherry`), NOT ancestry, is load-bearing: this fleet
60
+ * rebase-merges, so a landed branch's SHAs are rewritten and
61
+ * `merge-base --is-ancestor` reports *not merged* for work that fully landed.
62
+ * It also subsumes the unpushed check — an unpushed commit has no upstream
63
+ * equivalent, so it surfaces as unmerged rather than needing a separate probe.
64
+ */
65
+ export declare function countUnmergedCommits(worktreePath: string, defaultRef: string | null): Promise<number>;
66
+ /** One `git worktree list --porcelain` record. */
67
+ interface PorcelainEntry {
68
+ path: string;
69
+ branch: string | null;
70
+ detached: boolean;
71
+ }
72
+ export declare function parseWorktreePorcelain(out: string): PorcelainEntry[];
73
+ /**
74
+ * True when `child` is `parent` or sits beneath it, compared on path
75
+ * boundaries. A raw `startsWith` makes `.../fix-1103` look like it is inside
76
+ * `.../fix-110`, so the primary checkout would be mistaken for a neighbour.
77
+ */
78
+ export declare function isInside(child: string, parent: string): boolean;
79
+ /** Everything the bucket decision depends on, gathered once. */
80
+ export interface HeldFacts {
81
+ /** null for detached HEAD. */
82
+ branch: string | null;
83
+ /** `git status --porcelain` line count; -1 = could not read. */
84
+ dirtyFiles: number;
85
+ /** `git cherry` count; -1 = could not determine. */
86
+ unmergedCommits: number;
87
+ }
88
+ /**
89
+ * Decide which bucket a worktree falls into, or null when it is neither stranded
90
+ * nor dirty nor broken (clean and fully upstream — nothing to surface). Pure.
91
+ *
92
+ * Precedence is chosen for SURFACING, not for the sweep's deletion decision:
93
+ * the work-loss signal wins. A determinable `unmerged-commits` is reported as
94
+ * exactly that even when the tree is also dirty, because the recoverable work
95
+ * is the priority — an operator seeing `uncommitted-changes` would push nothing.
96
+ * Only when the merge state itself is unreadable do we fall to `undeterminable`,
97
+ * because then we genuinely cannot tell whether work is stranded.
98
+ */
99
+ export declare function classifyHeld(facts: HeldFacts): {
100
+ bucket: HeldBucket;
101
+ reason: HeldReason;
102
+ } | null;
103
+ /**
104
+ * Classify every worktree registered under one repo and return only the held
105
+ * ones. Read-only — no `git worktree remove`, no `branch -d`, no push. The
106
+ * primary checkout (the first porcelain record, always the repo root) is never
107
+ * a `.agents/worktrees` slug, so it is skipped implicitly.
108
+ */
109
+ export declare function collectHeldWorktrees(repoRoot: string): Promise<HeldWorktree[]>;
110
+ /**
111
+ * Discover repo roots that own a `.agents/worktrees` container beneath
112
+ * `searchHome`. Mirrors the sweep's discovery: match directory SHAPE, prune the
113
+ * heavy dirs so a large home stays fast. Read-only.
114
+ */
115
+ export declare function discoverWorktreeRepos(searchHome: string, maxDepth?: number): Promise<string[]>;
116
+ /** Discover + classify every held worktree beneath `searchHome`. Read-only. */
117
+ export declare function collectHeldWorktreesUnder(searchHome: string): Promise<HeldWorktree[]>;
118
+ /** A held set grouped by bucket, with the itemised entries kept. */
119
+ export interface HeldSummary {
120
+ total: number;
121
+ buckets: Record<HeldBucket, HeldWorktree[]>;
122
+ }
123
+ /** Group a flat held list into its three buckets. Pure. */
124
+ export declare function summarizeHeld(held: HeldWorktree[]): HeldSummary;
125
+ /** One device's contribution to a fleet-wide roll-up. */
126
+ export interface DeviceHeld {
127
+ device: string;
128
+ held: HeldWorktree[];
129
+ }
130
+ /** A fleet-wide roll-up: every device's held worktrees, still bucketed. Pure. */
131
+ export interface FleetHeldSummary extends HeldSummary {
132
+ devices: {
133
+ device: string;
134
+ total: number;
135
+ }[];
136
+ }
137
+ /**
138
+ * Merge several devices' held sets into one fleet roll-up, stamping each entry
139
+ * with its source device so the `unmerged-commits` bucket names where the
140
+ * stranded work lives. Pure — the SSH fan-out that produces the input lives in
141
+ * the command layer.
142
+ */
143
+ export declare function aggregateHeld(perDevice: DeviceHeld[]): FleetHeldSummary;
144
+ /** Outcome of the safe recovery action on one stranded worktree. */
145
+ export interface PushResult {
146
+ name: string;
147
+ branch: string | null;
148
+ pushed: boolean;
149
+ reason: string;
150
+ }
151
+ /**
152
+ * The safe automatic action for the `unmerged-commits` bucket (ticket step 3):
153
+ * PUBLISH the branch so the work becomes visible, never remove anything.
154
+ *
155
+ * Gated hard, fails closed:
156
+ * - only a worktree whose live classification is still `unmerged-commits`;
157
+ * - only when the branch does NOT already exist on `origin` — a branch that is
158
+ * already pushed (including one behind an open PR) needs nothing, and this
159
+ * also means we never force-update a remote ref;
160
+ * - a slug-shaped worktree name only, never shelled otherwise.
161
+ *
162
+ * `git push` sets no `--force`: it fast-forwards a new ref or fails loud. A
163
+ * failure is returned, never swallowed.
164
+ */
165
+ export declare function pushStrandedBranch(repoRoot: string, wt: HeldWorktree): Promise<PushResult>;
166
+ export {};
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Surfacing the held set of agent worktrees (PHNX-3520).
3
+ *
4
+ * The nightly `worktree-sweep` routine (PHNX-3503, in phnx-labs/.agents)
5
+ * reclaims merged worktrees and correctly HOLDS anything dirty, unmerged, or
6
+ * undeterminable. That fail-closed bias is right for a destructive job, but it
7
+ * left the held set a silent, growing residue that nothing ever resolves — the
8
+ * sweep prints only a `held=<n>` count. Measured 2026-08-30 the held set is
9
+ * consistently LARGER than the reclaimed set (~800 worktrees fleet-wide), and
10
+ * one bucket inside it — worktrees whose branch carries commits on no remote —
11
+ * is real stranded work (the PHNX-2951 / PHNX-2732 failure class: an agent
12
+ * finished, its commits never reached a remote, and nothing surfaces it).
13
+ *
14
+ * This module is the READ-ONLY surfacing half. It does not remove anything —
15
+ * the destructive reclaim was deliberately reverted from the CLI (3a8d1eb04 →
16
+ * 7e48361f5) and lives as a shell routine. Here we only classify each held
17
+ * worktree into a bucket so the `unmerged-commits` set can be surfaced and,
18
+ * with `pushStrandedBranch`, recovered by publishing the branch — never by
19
+ * deleting anything. Everything fails CLOSED: an unreadable status or an
20
+ * undeterminable merge state is a bucket of its own, never read as "fine".
21
+ */
22
+ import { execFile } from 'child_process';
23
+ import { promisify } from 'util';
24
+ import * as fs from 'fs/promises';
25
+ import * as path from 'path';
26
+ const execFileAsync = promisify(execFile);
27
+ /** Worktree dir names are slugs; anything else is refused rather than shelled. */
28
+ const WORKTREE_NAME_RE = /^[A-Za-z0-9._-]+$/;
29
+ async function git(cwd, args) {
30
+ const { stdout } = await execFileAsync('git', args, { cwd, maxBuffer: 8 * 1024 * 1024 });
31
+ return stdout.trim();
32
+ }
33
+ /**
34
+ * Resolve the default branch ref to compare against: `origin/HEAD` when set,
35
+ * else whichever of `origin/main` / `origin/master` exists. Returns null when
36
+ * none resolve, which makes every worktree `merge-state-unknown` rather than
37
+ * silently comparing against nothing.
38
+ */
39
+ export async function resolveDefaultRef(repoRoot) {
40
+ try {
41
+ const head = await git(repoRoot, ['symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD']);
42
+ if (head)
43
+ return head;
44
+ }
45
+ catch {
46
+ /* fall through to the probes below */
47
+ }
48
+ for (const ref of ['origin/main', 'origin/master']) {
49
+ try {
50
+ await git(repoRoot, ['rev-parse', '--verify', '--quiet', ref]);
51
+ return ref;
52
+ }
53
+ catch {
54
+ /* try the next candidate */
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+ /**
60
+ * Count commits with no patch-equivalent upstream. Returns -1 when the answer
61
+ * cannot be established, which the classifier treats as `merge-state-unknown`.
62
+ *
63
+ * Patch-id (`git cherry`), NOT ancestry, is load-bearing: this fleet
64
+ * rebase-merges, so a landed branch's SHAs are rewritten and
65
+ * `merge-base --is-ancestor` reports *not merged* for work that fully landed.
66
+ * It also subsumes the unpushed check — an unpushed commit has no upstream
67
+ * equivalent, so it surfaces as unmerged rather than needing a separate probe.
68
+ */
69
+ export async function countUnmergedCommits(worktreePath, defaultRef) {
70
+ if (!defaultRef)
71
+ return -1;
72
+ try {
73
+ const out = await git(worktreePath, ['cherry', defaultRef, 'HEAD']);
74
+ if (!out)
75
+ return 0;
76
+ return out.split('\n').filter((l) => l.startsWith('+')).length;
77
+ }
78
+ catch {
79
+ return -1;
80
+ }
81
+ }
82
+ export function parseWorktreePorcelain(out) {
83
+ const entries = [];
84
+ let cur = null;
85
+ for (const line of out.split('\n')) {
86
+ if (line.startsWith('worktree ')) {
87
+ if (cur)
88
+ entries.push(cur);
89
+ cur = { path: line.slice('worktree '.length), branch: null, detached: false };
90
+ }
91
+ else if (!cur) {
92
+ continue;
93
+ }
94
+ else if (line.startsWith('branch ')) {
95
+ cur.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
96
+ }
97
+ else if (line === 'detached') {
98
+ cur.detached = true;
99
+ }
100
+ }
101
+ if (cur)
102
+ entries.push(cur);
103
+ return entries;
104
+ }
105
+ /**
106
+ * True when `child` is `parent` or sits beneath it, compared on path
107
+ * boundaries. A raw `startsWith` makes `.../fix-1103` look like it is inside
108
+ * `.../fix-110`, so the primary checkout would be mistaken for a neighbour.
109
+ */
110
+ export function isInside(child, parent) {
111
+ const rel = path.relative(parent, child);
112
+ return rel === '' || (rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel));
113
+ }
114
+ /**
115
+ * Decide which bucket a worktree falls into, or null when it is neither stranded
116
+ * nor dirty nor broken (clean and fully upstream — nothing to surface). Pure.
117
+ *
118
+ * Precedence is chosen for SURFACING, not for the sweep's deletion decision:
119
+ * the work-loss signal wins. A determinable `unmerged-commits` is reported as
120
+ * exactly that even when the tree is also dirty, because the recoverable work
121
+ * is the priority — an operator seeing `uncommitted-changes` would push nothing.
122
+ * Only when the merge state itself is unreadable do we fall to `undeterminable`,
123
+ * because then we genuinely cannot tell whether work is stranded.
124
+ */
125
+ export function classifyHeld(facts) {
126
+ // Fail closed first: if we could not establish the merge state, we cannot
127
+ // claim the work is safe, so it is undeterminable — never silently "clean".
128
+ if (facts.unmergedCommits < 0)
129
+ return { bucket: 'undeterminable', reason: 'merge-state-unknown' };
130
+ // The bucket that matters: commits on no upstream. Reported even over a dirty
131
+ // tree, since the branch carries recoverable work regardless of the worktree.
132
+ if (facts.unmergedCommits > 0)
133
+ return { bucket: 'unmerged-commits', reason: 'unmerged-commits' };
134
+ // Merge state is clean; now an unreadable status is its own broken-checkout case.
135
+ if (facts.dirtyFiles < 0)
136
+ return { bucket: 'undeterminable', reason: 'status-unreadable' };
137
+ if (facts.dirtyFiles > 0)
138
+ return { bucket: 'uncommitted-changes', reason: 'uncommitted-changes' };
139
+ return null;
140
+ }
141
+ /** Recursively total the byte size of a directory tree; -1 if it can't be read. */
142
+ async function dirSize(dir) {
143
+ let total = 0;
144
+ let entries;
145
+ try {
146
+ entries = await fs.readdir(dir, { withFileTypes: true });
147
+ }
148
+ catch {
149
+ return -1;
150
+ }
151
+ for (const e of entries) {
152
+ const p = path.join(dir, e.name);
153
+ try {
154
+ if (e.isDirectory()) {
155
+ const sub = await dirSize(p);
156
+ if (sub > 0)
157
+ total += sub;
158
+ }
159
+ else if (e.isFile()) {
160
+ const st = await fs.stat(p);
161
+ total += st.size;
162
+ }
163
+ }
164
+ catch {
165
+ /* a file that vanished mid-walk contributes nothing */
166
+ }
167
+ }
168
+ return total;
169
+ }
170
+ /**
171
+ * Classify every worktree registered under one repo and return only the held
172
+ * ones. Read-only — no `git worktree remove`, no `branch -d`, no push. The
173
+ * primary checkout (the first porcelain record, always the repo root) is never
174
+ * a `.agents/worktrees` slug, so it is skipped implicitly.
175
+ */
176
+ export async function collectHeldWorktrees(repoRoot) {
177
+ let porcelain;
178
+ try {
179
+ porcelain = await git(repoRoot, ['worktree', 'list', '--porcelain']);
180
+ }
181
+ catch {
182
+ return [];
183
+ }
184
+ const entries = parseWorktreePorcelain(porcelain);
185
+ const defaultRef = await resolveDefaultRef(repoRoot);
186
+ const now = Date.now();
187
+ const wtContainer = path.join(repoRoot, '.agents', 'worktrees');
188
+ const repoName = path.basename(repoRoot);
189
+ const held = [];
190
+ for (const e of entries) {
191
+ // Only the PR-bound agent worktrees the sweep governs — not the primary
192
+ // checkout and not any ad-hoc worktree elsewhere on disk.
193
+ if (!isInside(e.path, wtContainer))
194
+ continue;
195
+ let dirtyFiles = -1;
196
+ try {
197
+ const status = await git(e.path, ['status', '--porcelain']);
198
+ dirtyFiles = status ? status.split('\n').length : 0;
199
+ }
200
+ catch {
201
+ dirtyFiles = -1;
202
+ }
203
+ const unmergedCommits = await countUnmergedCommits(e.path, defaultRef);
204
+ const verdict = classifyHeld({ branch: e.branch, dirtyFiles, unmergedCommits });
205
+ if (!verdict)
206
+ continue;
207
+ let hasRemoteBranch = false;
208
+ if (e.branch) {
209
+ try {
210
+ const ls = await git(repoRoot, ['ls-remote', '--heads', 'origin', e.branch]);
211
+ hasRemoteBranch = ls.length > 0;
212
+ }
213
+ catch {
214
+ hasRemoteBranch = false;
215
+ }
216
+ }
217
+ let ageDays = 0;
218
+ try {
219
+ const st = await fs.stat(e.path);
220
+ ageDays = Math.max(0, Math.floor((now - st.mtimeMs) / 86_400_000));
221
+ }
222
+ catch {
223
+ ageDays = 0;
224
+ }
225
+ held.push({
226
+ repo: repoRoot,
227
+ repoName,
228
+ name: path.basename(e.path),
229
+ path: e.path,
230
+ branch: e.branch,
231
+ bucket: verdict.bucket,
232
+ reason: verdict.reason,
233
+ unmergedCommits,
234
+ dirtyFiles,
235
+ hasRemoteBranch,
236
+ ageDays,
237
+ sizeBytes: await dirSize(e.path),
238
+ });
239
+ }
240
+ return held;
241
+ }
242
+ /**
243
+ * Discover repo roots that own a `.agents/worktrees` container beneath
244
+ * `searchHome`. Mirrors the sweep's discovery: match directory SHAPE, prune the
245
+ * heavy dirs so a large home stays fast. Read-only.
246
+ */
247
+ export async function discoverWorktreeRepos(searchHome, maxDepth = 7) {
248
+ const PRUNE = new Set([
249
+ 'node_modules', '.cache', '.npm', '.bun', 'Library', '.venv', 'dist', 'target', '.git',
250
+ ]);
251
+ const repos = new Set();
252
+ async function walk(dir, depth) {
253
+ if (depth > maxDepth)
254
+ return;
255
+ let entries;
256
+ try {
257
+ entries = await fs.readdir(dir, { withFileTypes: true });
258
+ }
259
+ catch {
260
+ return;
261
+ }
262
+ for (const e of entries) {
263
+ if (!e.isDirectory())
264
+ continue;
265
+ if (PRUNE.has(e.name))
266
+ continue;
267
+ const p = path.join(dir, e.name);
268
+ // A `.agents/worktrees` dir marks its grandparent as a repo root.
269
+ if (e.name === '.agents') {
270
+ try {
271
+ const st = await fs.stat(path.join(p, 'worktrees'));
272
+ if (st.isDirectory())
273
+ repos.add(dir);
274
+ }
275
+ catch {
276
+ /* no worktrees container here */
277
+ }
278
+ continue; // never descend into a .agents dir
279
+ }
280
+ await walk(p, depth + 1);
281
+ }
282
+ }
283
+ await walk(searchHome, 0);
284
+ return [...repos].sort();
285
+ }
286
+ /** Discover + classify every held worktree beneath `searchHome`. Read-only. */
287
+ export async function collectHeldWorktreesUnder(searchHome) {
288
+ const repos = await discoverWorktreeRepos(searchHome);
289
+ const all = [];
290
+ for (const repo of repos) {
291
+ all.push(...(await collectHeldWorktrees(repo)));
292
+ }
293
+ return all;
294
+ }
295
+ const EMPTY_BUCKETS = () => ({
296
+ 'unmerged-commits': [],
297
+ 'uncommitted-changes': [],
298
+ 'undeterminable': [],
299
+ });
300
+ /** Group a flat held list into its three buckets. Pure. */
301
+ export function summarizeHeld(held) {
302
+ const buckets = EMPTY_BUCKETS();
303
+ for (const w of held)
304
+ buckets[w.bucket].push(w);
305
+ return { total: held.length, buckets };
306
+ }
307
+ /**
308
+ * Merge several devices' held sets into one fleet roll-up, stamping each entry
309
+ * with its source device so the `unmerged-commits` bucket names where the
310
+ * stranded work lives. Pure — the SSH fan-out that produces the input lives in
311
+ * the command layer.
312
+ */
313
+ export function aggregateHeld(perDevice) {
314
+ const buckets = EMPTY_BUCKETS();
315
+ const devices = [];
316
+ let total = 0;
317
+ for (const d of perDevice) {
318
+ devices.push({ device: d.device, total: d.held.length });
319
+ total += d.held.length;
320
+ for (const w of d.held)
321
+ buckets[w.bucket].push({ ...w, device: d.device });
322
+ }
323
+ return { total, buckets, devices };
324
+ }
325
+ /**
326
+ * The safe automatic action for the `unmerged-commits` bucket (ticket step 3):
327
+ * PUBLISH the branch so the work becomes visible, never remove anything.
328
+ *
329
+ * Gated hard, fails closed:
330
+ * - only a worktree whose live classification is still `unmerged-commits`;
331
+ * - only when the branch does NOT already exist on `origin` — a branch that is
332
+ * already pushed (including one behind an open PR) needs nothing, and this
333
+ * also means we never force-update a remote ref;
334
+ * - a slug-shaped worktree name only, never shelled otherwise.
335
+ *
336
+ * `git push` sets no `--force`: it fast-forwards a new ref or fails loud. A
337
+ * failure is returned, never swallowed.
338
+ */
339
+ export async function pushStrandedBranch(repoRoot, wt) {
340
+ const base = { name: wt.name, branch: wt.branch, pushed: false, reason: '' };
341
+ if (!WORKTREE_NAME_RE.test(wt.name))
342
+ return { ...base, reason: 'unsafe worktree name' };
343
+ if (!wt.branch)
344
+ return { ...base, reason: 'detached HEAD — no branch to push' };
345
+ // Re-read the facts NOW: the collected snapshot may be stale, and pushing on
346
+ // a stale "unmerged" verdict is the one thing we must not get wrong.
347
+ const defaultRef = await resolveDefaultRef(repoRoot);
348
+ const unmerged = await countUnmergedCommits(wt.path, defaultRef);
349
+ const fresh = classifyHeld({ branch: wt.branch, dirtyFiles: 0, unmergedCommits: unmerged });
350
+ if (!fresh || fresh.bucket !== 'unmerged-commits') {
351
+ return { ...base, reason: `no longer stranded (${fresh?.reason ?? 'clean'})` };
352
+ }
353
+ try {
354
+ const ls = await git(repoRoot, ['ls-remote', '--heads', 'origin', wt.branch]);
355
+ if (ls.length > 0)
356
+ return { ...base, reason: 'branch already on origin' };
357
+ }
358
+ catch (err) {
359
+ return { ...base, reason: `could not query origin: ${String(err?.stderr || err?.message || err).trim()}` };
360
+ }
361
+ try {
362
+ await git(wt.path, ['push', '-u', 'origin', wt.branch]);
363
+ return { ...base, pushed: true, reason: 'pushed to origin' };
364
+ }
365
+ catch (err) {
366
+ return { ...base, reason: `push failed: ${String(err?.stderr || err?.message || err).trim()}` };
367
+ }
368
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.68",
3
+ "version": "1.22.70",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -53,7 +53,7 @@
53
53
  "gen:index": "bun scripts/gen-command-index.ts",
54
54
  "verify:index": "bash scripts/verify-command-index.sh",
55
55
  "start": "node dist/index.js",
56
- "test": "node ./node_modules/vitest/vitest.mjs run",
56
+ "test": "TZ=UTC node ./node_modules/vitest/vitest.mjs run",
57
57
  "bench": "node ./node_modules/vitest/vitest.mjs bench --run",
58
58
  "typecheck:bench": "tsc --noEmit --ignoreConfig --skipLibCheck --module esnext --moduleResolution bundler --target es2022 --strict --esModuleInterop --lib es2022 --types node src/lib/*.bench.ts src/lib/**/*.bench.ts",
59
59
  "test:remote": "scripts/sandbox.sh test",