@phnx-labs/agents-cli 1.22.66 → 1.22.69
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.
- package/CHANGELOG.md +56 -0
- package/README.md +21 -8
- package/dist/bootstrap.js +6 -3
- package/dist/commands/browser.js +46 -21
- package/dist/commands/daemon-test-harness.d.ts +1 -1
- package/dist/commands/daemon-test-harness.js +2 -2
- package/dist/commands/daemon.js +21 -22
- package/dist/commands/exec.js +50 -0
- package/dist/commands/feed.js +20 -7
- package/dist/commands/monitors.js +5 -2
- package/dist/commands/projects.d.ts +26 -6
- package/dist/commands/projects.js +55 -22
- package/dist/commands/repo.js +57 -19
- package/dist/commands/resume.d.ts +16 -0
- package/dist/commands/resume.js +41 -8
- package/dist/commands/routines.js +42 -21
- package/dist/commands/send.js +29 -2
- package/dist/commands/sessions-inject.d.ts +58 -0
- package/dist/commands/sessions-inject.js +143 -7
- package/dist/commands/sessions-optimize.js +1 -1
- package/dist/commands/sessions-picker.js +1 -0
- package/dist/commands/sessions.js +4 -11
- package/dist/commands/share.d.ts +5 -3
- package/dist/commands/share.js +73 -20
- package/dist/commands/ssh.js +205 -2
- package/dist/lib/accounting/account-pool-collect.d.ts +6 -4
- package/dist/lib/accounting/account-pool-collect.js +6 -4
- package/dist/lib/accounting/usage-ingest.js +4 -2
- package/dist/lib/accounting/usage-sync.d.ts +38 -93
- package/dist/lib/accounting/usage-sync.js +66 -210
- package/dist/lib/accounting/usage.d.ts +18 -5
- package/dist/lib/accounting/usage.js +165 -20
- package/dist/lib/auth-health.d.ts +8 -0
- package/dist/lib/auth-health.js +4 -4
- package/dist/lib/boot-profile.d.ts +14 -0
- package/dist/lib/boot-profile.js +66 -0
- package/dist/lib/browser/caller-identity.d.ts +12 -0
- package/dist/lib/browser/caller-identity.js +19 -0
- package/dist/lib/browser/ipc.d.ts +37 -32
- package/dist/lib/browser/ipc.js +146 -94
- package/dist/lib/browser/task-index.d.ts +10 -2
- package/dist/lib/browser/task-index.js +22 -3
- package/dist/lib/channels/providers/desktop.d.ts +5 -4
- package/dist/lib/channels/providers/desktop.js +5 -4
- package/dist/lib/claude-account-token.js +108 -4
- package/dist/lib/daemon/account-state-daemon-service.d.ts +49 -9
- package/dist/lib/daemon/account-state-daemon-service.js +81 -18
- package/dist/lib/daemon/auth-sync-service.d.ts +4 -4
- package/dist/lib/daemon/auth-sync-service.js +17 -6
- package/dist/lib/daemon/catchup-service.d.ts +51 -0
- package/dist/lib/daemon/catchup-service.js +51 -0
- package/dist/lib/daemon/daemon.d.ts +12 -22
- package/dist/lib/daemon/daemon.js +463 -176
- package/dist/lib/daemon/runner.js +2 -0
- package/dist/lib/daemon/service.d.ts +22 -4
- package/dist/lib/daemon/service.js +2 -2
- package/dist/lib/daemon/supervisor.d.ts +55 -15
- package/dist/lib/daemon/supervisor.js +119 -29
- package/dist/lib/daemon/usage-sync-service.d.ts +4 -6
- package/dist/lib/daemon/usage-sync-service.js +22 -18
- package/dist/lib/daemon-health.js +36 -31
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +12 -2
- package/dist/lib/daemon-ticks.d.ts +9 -6
- package/dist/lib/daemon-ticks.js +14 -8
- package/dist/lib/devices/health.d.ts +38 -2
- package/dist/lib/devices/health.js +43 -5
- package/dist/lib/devices/registry.js +2 -0
- package/dist/lib/devices/worker-pick.d.ts +1 -1
- package/dist/lib/devices/worker-pick.js +4 -1
- package/dist/lib/exec.js +15 -0
- package/dist/lib/feed/watch.d.ts +3 -0
- package/dist/lib/feed/watch.js +13 -3
- package/dist/lib/feed-broadcast.d.ts +64 -5
- package/dist/lib/feed-broadcast.js +124 -22
- package/dist/lib/fleet-shared-repo-sync.d.ts +36 -0
- package/dist/lib/fleet-shared-repo-sync.js +333 -0
- package/dist/lib/fleet-shared-state.d.ts +38 -0
- package/dist/lib/fleet-shared-state.js +105 -0
- package/dist/lib/hosts/remote-cmd.d.ts +2 -0
- package/dist/lib/hosts/remote-cmd.js +12 -3
- package/dist/lib/lock-compromise.d.ts +8 -0
- package/dist/lib/lock-compromise.js +12 -0
- package/dist/lib/monitors/engine.d.ts +2 -1
- package/dist/lib/monitors/engine.js +27 -2
- package/dist/lib/monitors/sources/command.js +13 -3
- package/dist/lib/monitors/sources/failure.d.ts +32 -0
- package/dist/lib/monitors/sources/failure.js +52 -0
- package/dist/lib/monitors/sources/types.d.ts +9 -0
- package/dist/lib/owner-message.d.ts +12 -0
- package/dist/lib/owner-message.js +44 -0
- package/dist/lib/refresh-coordinator.js +2 -0
- package/dist/lib/run-trace-sync.d.ts +28 -0
- package/dist/lib/run-trace-sync.js +99 -0
- package/dist/lib/secrets/filestore.d.ts +4 -0
- package/dist/lib/secrets/filestore.js +164 -3
- package/dist/lib/secrets/push.d.ts +10 -0
- package/dist/lib/secrets/push.js +86 -7
- package/dist/lib/secrets/remote.d.ts +18 -6
- package/dist/lib/secrets/remote.js +29 -4
- package/dist/lib/secrets/reserved-sync.d.ts +28 -27
- package/dist/lib/secrets/reserved-sync.js +119 -101
- package/dist/lib/session/active.d.ts +13 -1
- package/dist/lib/session/active.js +5 -0
- package/dist/lib/session/actor-sidecar.d.ts +12 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +16 -2
- package/dist/lib/session/db.js +73 -22
- package/dist/lib/session/discover.d.ts +12 -3
- package/dist/lib/session/discover.js +187 -26
- package/dist/lib/session/linear.d.ts +13 -0
- package/dist/lib/session/linear.js +44 -0
- package/dist/lib/session/live-metadata.js +1 -0
- package/dist/lib/session/parse.js +2 -3
- package/dist/lib/session/prompt.d.ts +17 -0
- package/dist/lib/session/prompt.js +35 -0
- package/dist/lib/session/recovery.d.ts +43 -6
- package/dist/lib/session/recovery.js +80 -10
- package/dist/lib/session/remote/remote-list.d.ts +17 -1
- package/dist/lib/session/remote/remote-list.js +29 -4
- package/dist/lib/session/remote/watch.d.ts +25 -2
- package/dist/lib/session/remote/watch.js +188 -11
- package/dist/lib/session/session-cache.d.ts +2 -1
- package/dist/lib/session/session-cache.js +1 -0
- package/dist/lib/session/state.js +11 -13
- package/dist/lib/session/types.d.ts +2 -0
- package/dist/lib/share/backend.d.ts +2 -2
- package/dist/lib/share/backend.js +20 -9
- package/dist/lib/share/delete.d.ts +5 -1
- package/dist/lib/share/delete.js +7 -2
- package/dist/lib/share/http-error.d.ts +52 -0
- package/dist/lib/share/http-error.js +65 -0
- package/dist/lib/share/publish.d.ts +67 -11
- package/dist/lib/share/publish.js +98 -16
- package/dist/lib/share/worker-template.js +105 -9
- package/dist/lib/smart-launch.js +27 -4
- package/dist/lib/ssh-exec.d.ts +2 -0
- package/dist/lib/ssh-exec.js +20 -4
- package/dist/lib/storage/index.d.ts +14 -0
- package/dist/lib/storage/index.js +14 -0
- package/dist/lib/storage/selection.d.ts +48 -0
- package/dist/lib/storage/selection.js +39 -0
- package/dist/lib/storage/visibility.d.ts +82 -0
- package/dist/lib/storage/visibility.js +99 -0
- package/dist/lib/teams/agents.js +3 -1
- package/dist/lib/teams/placement-probe.js +1 -0
- package/dist/lib/teams/registry.js +2 -0
- package/dist/lib/teams/scheduler.d.ts +8 -1
- package/dist/lib/teams/scheduler.js +4 -1
- package/dist/lib/testdata/daemon-health-writer.d.ts +1 -0
- package/dist/lib/testdata/daemon-health-writer.js +8 -0
- package/dist/lib/traces/backend.js +13 -2
- package/dist/lib/traces/sync.d.ts +7 -0
- package/dist/lib/traces/sync.js +9 -0
- package/dist/lib/usage-refresh.d.ts +8 -2
- package/dist/lib/usage-refresh.js +3 -3
- package/dist/lib/worktree/held.d.ts +166 -0
- package/dist/lib/worktree/held.js +368 -0
- package/package.json +2 -2
- package/dist/lib/account-state-service.d.ts +0 -21
- package/dist/lib/account-state-service.js +0 -60
|
@@ -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.
|
|
3
|
+
"version": "1.22.69",
|
|
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",
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/** Device-local owner for usage snapshots and authentication health. */
|
|
2
|
-
export declare const USAGE_STATE_TICK_MS = 60000;
|
|
3
|
-
export declare const AUTH_STATE_TICK_MS: number;
|
|
4
|
-
export interface AccountStateService {
|
|
5
|
-
stop(): void;
|
|
6
|
-
}
|
|
7
|
-
export interface AccountStateServiceDeps {
|
|
8
|
-
refreshUsage: () => Promise<void>;
|
|
9
|
-
refreshAuth: () => Promise<void>;
|
|
10
|
-
onError?: (area: 'usage' | 'auth', error: unknown) => void;
|
|
11
|
-
setInterval?: typeof globalThis.setInterval;
|
|
12
|
-
clearInterval?: typeof globalThis.clearInterval;
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
* Start the daemon-owned account-state loops.
|
|
16
|
-
*
|
|
17
|
-
* Each loop is overlap-safe within the daemon. Provider-level work is also
|
|
18
|
-
* guarded by the cross-process refresh lease, so an explicit CLI refresh and
|
|
19
|
-
* an older routine process converge on the same published result.
|
|
20
|
-
*/
|
|
21
|
-
export declare function startAccountStateService(deps: AccountStateServiceDeps): AccountStateService;
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
/** Device-local owner for usage snapshots and authentication health. */
|
|
2
|
-
export const USAGE_STATE_TICK_MS = 60_000;
|
|
3
|
-
export const AUTH_STATE_TICK_MS = 3 * 60_000;
|
|
4
|
-
/**
|
|
5
|
-
* Start the daemon-owned account-state loops.
|
|
6
|
-
*
|
|
7
|
-
* Each loop is overlap-safe within the daemon. Provider-level work is also
|
|
8
|
-
* guarded by the cross-process refresh lease, so an explicit CLI refresh and
|
|
9
|
-
* an older routine process converge on the same published result.
|
|
10
|
-
*/
|
|
11
|
-
export function startAccountStateService(deps) {
|
|
12
|
-
const setTimer = deps.setInterval ?? globalThis.setInterval;
|
|
13
|
-
const clearTimer = deps.clearInterval ?? globalThis.clearInterval;
|
|
14
|
-
let usageRunning = false;
|
|
15
|
-
let authRunning = false;
|
|
16
|
-
let stopped = false;
|
|
17
|
-
const runUsage = async () => {
|
|
18
|
-
if (stopped || usageRunning)
|
|
19
|
-
return;
|
|
20
|
-
usageRunning = true;
|
|
21
|
-
try {
|
|
22
|
-
await deps.refreshUsage();
|
|
23
|
-
}
|
|
24
|
-
catch (error) {
|
|
25
|
-
deps.onError?.('usage', error);
|
|
26
|
-
}
|
|
27
|
-
finally {
|
|
28
|
-
usageRunning = false;
|
|
29
|
-
}
|
|
30
|
-
};
|
|
31
|
-
const runAuth = async () => {
|
|
32
|
-
if (stopped || authRunning)
|
|
33
|
-
return;
|
|
34
|
-
authRunning = true;
|
|
35
|
-
try {
|
|
36
|
-
await deps.refreshAuth();
|
|
37
|
-
}
|
|
38
|
-
catch (error) {
|
|
39
|
-
deps.onError?.('auth', error);
|
|
40
|
-
}
|
|
41
|
-
finally {
|
|
42
|
-
authRunning = false;
|
|
43
|
-
}
|
|
44
|
-
};
|
|
45
|
-
const usageTimer = setTimer(() => { void runUsage(); }, USAGE_STATE_TICK_MS);
|
|
46
|
-
const authTimer = setTimer(() => { void runAuth(); }, AUTH_STATE_TICK_MS);
|
|
47
|
-
usageTimer.unref?.();
|
|
48
|
-
authTimer.unref?.();
|
|
49
|
-
void runUsage();
|
|
50
|
-
void runAuth();
|
|
51
|
-
return {
|
|
52
|
-
stop() {
|
|
53
|
-
if (stopped)
|
|
54
|
-
return;
|
|
55
|
-
stopped = true;
|
|
56
|
-
clearTimer(usageTimer);
|
|
57
|
-
clearTimer(authTimer);
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
|
-
}
|