@phnx-labs/agents-cli 1.22.117 → 1.22.119
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 +301 -0
- package/dist/bootstrap.js +4 -11
- package/dist/commands/accounts.js +7 -4
- package/dist/commands/browser.d.ts +52 -70
- package/dist/commands/browser.js +192 -3562
- package/dist/commands/daemon.d.ts +39 -2
- package/dist/commands/daemon.js +72 -73
- package/dist/commands/events.js +1 -1
- package/dist/commands/exec.js +53 -1
- package/dist/commands/projects.js +92 -7
- package/dist/commands/repo.js +13 -49
- package/dist/commands/route.js +5 -5
- package/dist/commands/routines.js +39 -12
- package/dist/commands/sessions-render.d.ts +1 -1
- package/dist/commands/sessions-render.js +0 -1
- package/dist/commands/sessions.test-fixture.d.ts +0 -1
- package/dist/commands/sessions.test-fixture.js +0 -37
- package/dist/commands/setup-browser.d.ts +17 -12
- package/dist/commands/setup-browser.js +52 -87
- package/dist/commands/setup-preferences.d.ts +0 -17
- package/dist/commands/setup-preferences.js +18 -49
- package/dist/commands/setup.js +9 -9
- package/dist/commands/ssh.js +10 -1
- package/dist/commands/sync.js +6 -44
- package/dist/commands/teams.js +0 -1
- package/dist/commands/update.d.ts +19 -0
- package/dist/commands/update.js +34 -6
- package/dist/commands/view.d.ts +12 -1
- package/dist/commands/view.js +47 -2
- package/dist/index.js +107 -26
- package/dist/lib/account-capabilities.js +0 -2
- package/dist/lib/account-catalog.d.ts +38 -0
- package/dist/lib/account-catalog.js +110 -26
- package/dist/lib/account-provider-registry.js +1 -1
- package/dist/lib/account-registry.d.ts +1 -1
- package/dist/lib/account-registry.js +36 -2
- package/dist/lib/accounting/rotate.js +22 -8
- package/dist/lib/accounting/usage-ingest.d.ts +6 -1
- package/dist/lib/accounting/usage-ingest.js +115 -45
- package/dist/lib/accounting/usage-sync.d.ts +134 -31
- package/dist/lib/accounting/usage-sync.js +227 -66
- package/dist/lib/accounting/usage.d.ts +16 -3
- package/dist/lib/accounting/usage.js +26 -8
- package/dist/lib/accounts/add.d.ts +3 -1
- package/dist/lib/accounts/add.js +24 -13
- package/dist/lib/accounts/slots.d.ts +8 -0
- package/dist/lib/accounts/slots.js +17 -0
- package/dist/lib/add-dir.js +0 -1
- package/dist/lib/agent-cli-commands.js +0 -1
- package/dist/lib/agent-spec/agents.d.ts +6 -7
- package/dist/lib/agent-spec/agents.js +7 -60
- package/dist/lib/auth-health.d.ts +69 -1
- package/dist/lib/auth-health.js +133 -4
- package/dist/lib/browser/context.d.ts +82 -0
- package/dist/lib/browser/context.js +88 -0
- package/dist/lib/browser/paths.d.ts +25 -0
- package/dist/lib/browser/paths.js +51 -0
- package/dist/lib/browser/record.d.ts +38 -0
- package/dist/lib/browser/record.js +57 -0
- package/dist/lib/browser/sessions-list.js +1 -2
- package/dist/lib/browser-client.d.ts +158 -0
- package/dist/lib/browser-client.js +234 -0
- package/dist/lib/channels/owner-sink.d.ts +1 -18
- package/dist/lib/channels/owner-sink.js +29 -82
- package/dist/lib/channels/providers/rush.d.ts +7 -7
- package/dist/lib/channels/providers/rush.js +127 -57
- package/dist/lib/claude-account-token.d.ts +7 -1
- package/dist/lib/claude-account-token.js +26 -4
- package/dist/lib/claude-statusline.d.ts +1 -1
- package/dist/lib/claude-statusline.js +15 -3
- package/dist/lib/commands.js +1 -1
- package/dist/lib/crabbox/lease.js +1 -1
- package/dist/lib/crabbox/runtimes.js +1 -3
- package/dist/lib/daemon/account-state-daemon-service.js +1 -0
- package/dist/lib/daemon/auth-sync-service.d.ts +21 -18
- package/dist/lib/daemon/auth-sync-service.js +37 -43
- package/dist/lib/daemon/daemon.d.ts +25 -14
- package/dist/lib/daemon/daemon.js +92 -154
- package/dist/lib/daemon/runner.d.ts +2 -2
- package/dist/lib/daemon/runner.js +32 -9
- package/dist/lib/daemon/self-update-service.d.ts +35 -34
- package/dist/lib/daemon/self-update-service.js +40 -36
- package/dist/lib/daemon/service.d.ts +12 -8
- package/dist/lib/daemon/supervisor.d.ts +65 -54
- package/dist/lib/daemon/supervisor.js +136 -171
- package/dist/lib/daemon/testdata/process-view-start.js +5 -2
- package/dist/lib/daemon/usage-sync-service.d.ts +21 -30
- package/dist/lib/daemon/usage-sync-service.js +46 -78
- package/dist/lib/daemon-health.d.ts +22 -5
- package/dist/lib/daemon-health.js +47 -3
- package/dist/lib/daemon-services.d.ts +1 -1
- package/dist/lib/daemon-services.js +0 -10
- package/dist/lib/daemon-ticks.js +4 -2
- package/dist/lib/devices/doctor-findings.js +8 -11
- package/dist/lib/devices/harness-inventory.js +14 -5
- package/dist/lib/exec-account-home.js +5 -0
- package/dist/lib/exec.js +10 -13
- package/dist/lib/feed/events.js +0 -1
- package/dist/lib/feed/tool-activity.js +5 -2
- package/dist/lib/fleet-shared-state.d.ts +45 -0
- package/dist/lib/fleet-shared-state.js +78 -9
- package/dist/lib/fs-atomic.js +25 -0
- package/dist/lib/github/pr-mergeable.d.ts +4 -2
- package/dist/lib/github/pr-mergeable.js +5 -3
- package/dist/lib/github/project-prs.d.ts +58 -3
- package/dist/lib/github/project-prs.js +205 -7
- package/dist/lib/harness/adapters/cursor.js +14 -4
- package/dist/lib/harness-auth-capabilities.d.ts +4 -3
- package/dist/lib/harness-auth-capabilities.js +1 -2
- package/dist/lib/helper-versions.js +1 -1
- package/dist/lib/hooks/cache.d.ts +7 -0
- package/dist/lib/hooks/cache.js +27 -2
- package/dist/lib/hooks/install.js +7 -1
- package/dist/lib/hosts/ready.d.ts +38 -8
- package/dist/lib/hosts/ready.js +62 -14
- package/dist/lib/installations/active-check.d.ts +27 -0
- package/dist/lib/installations/active-check.js +61 -0
- package/dist/lib/installations/migrate.js +6 -3
- package/dist/lib/installations/shims.d.ts +1 -1
- package/dist/lib/installations/shims.js +32 -12
- package/dist/lib/installations/store.d.ts +9 -0
- package/dist/lib/installations/store.js +24 -0
- package/dist/lib/installations/update.js +21 -15
- package/dist/lib/installations/versions.js +15 -3
- package/dist/lib/manifest.js +1 -1
- package/dist/lib/models.js +0 -2
- package/dist/lib/open-url.js +28 -54
- package/dist/lib/profiles.js +0 -2
- package/dist/lib/projects.d.ts +16 -0
- package/dist/lib/projects.js +31 -0
- package/dist/lib/refresh.js +1 -1
- package/dist/lib/routine-readiness.d.ts +35 -0
- package/dist/lib/routine-readiness.js +74 -16
- package/dist/lib/rules/compile.js +6 -0
- package/dist/lib/sandbox.js +0 -2
- package/dist/lib/secrets-policy.d.ts +89 -11
- package/dist/lib/secrets-policy.js +175 -28
- package/dist/lib/session/active.d.ts +17 -6
- package/dist/lib/session/active.js +105 -49
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +65 -18
- package/dist/lib/session/discover.js +1 -283
- package/dist/lib/session/mirror.d.ts +4 -3
- package/dist/lib/session/mirror.js +4 -3
- package/dist/lib/session/parse.d.ts +0 -2
- package/dist/lib/session/parse.js +1 -138
- package/dist/lib/session/pid-registry.d.ts +6 -3
- package/dist/lib/session/pid-registry.js +18 -8
- package/dist/lib/session/remote/watch.d.ts +1 -1
- package/dist/lib/session/remote/watch.js +7 -3
- package/dist/lib/session/throughput.d.ts +1 -3
- package/dist/lib/session/throughput.js +0 -20
- package/dist/lib/session/types.d.ts +2 -2
- package/dist/lib/session/types.js +2 -2
- package/dist/lib/sessions-client.d.ts +60 -1
- package/dist/lib/sessions-client.js +227 -4
- package/dist/lib/setup-tool-status.js +5 -10
- package/dist/lib/signin-badge.d.ts +3 -2
- package/dist/lib/signin-badge.js +6 -3
- package/dist/lib/smart-launch.js +4 -1
- package/dist/lib/staleness/detectors/commands.d.ts +1 -1
- package/dist/lib/staleness/detectors/hooks.d.ts +1 -1
- package/dist/lib/staleness/detectors/mcp.d.ts +1 -1
- package/dist/lib/staleness/detectors/permissions.d.ts +1 -1
- package/dist/lib/staleness/detectors/plugins.d.ts +1 -1
- package/dist/lib/staleness/detectors/rules.d.ts +1 -1
- package/dist/lib/staleness/detectors/skills.d.ts +1 -1
- package/dist/lib/staleness/detectors/subagents.d.ts +1 -1
- package/dist/lib/staleness/detectors/workflows.d.ts +1 -1
- package/dist/lib/staleness/writers/commands.d.ts +1 -1
- package/dist/lib/staleness/writers/hooks.d.ts +1 -1
- package/dist/lib/staleness/writers/mcp.d.ts +1 -1
- package/dist/lib/staleness/writers/permissions.d.ts +1 -1
- package/dist/lib/staleness/writers/plugins.d.ts +1 -1
- package/dist/lib/staleness/writers/rules.d.ts +1 -1
- package/dist/lib/staleness/writers/skills.d.ts +1 -1
- package/dist/lib/staleness/writers/sources.js +5 -0
- package/dist/lib/staleness/writers/subagents.d.ts +1 -1
- package/dist/lib/staleness/writers/workflows.d.ts +1 -1
- package/dist/lib/state.d.ts +6 -4
- package/dist/lib/state.js +7 -17
- package/dist/lib/teams/parsers.d.ts +1 -1
- package/dist/lib/teams/parsers.js +1 -136
- package/dist/lib/teams/placement-probe.js +50 -15
- package/dist/lib/teams/scheduler.d.ts +7 -0
- package/dist/lib/types.d.ts +16 -1
- package/dist/lib/types.js +1 -1
- package/dist/lib/view-types.d.ts +32 -0
- package/dist/lib/watchdog/read.js +0 -1
- package/dist/lib/watchdog/rotate.js +1 -2
- package/dist/lib/watchdog/runner.js +2 -2
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdogTail.js +0 -31
- package/package.json +1 -1
- package/scripts/postinstall.js +8 -8
- package/dist/browser.d.ts +0 -2
- package/dist/browser.js +0 -17
- package/dist/commands/browser-picker.d.ts +0 -18
- package/dist/commands/browser-picker.js +0 -97
- package/dist/lib/browser/arc-discovery.d.ts +0 -52
- package/dist/lib/browser/arc-discovery.js +0 -197
- package/dist/lib/browser/arc-dom.d.ts +0 -14
- package/dist/lib/browser/arc-dom.js +0 -121
- package/dist/lib/browser/caller-identity.d.ts +0 -40
- package/dist/lib/browser/caller-identity.js +0 -175
- package/dist/lib/browser/cdp.d.ts +0 -51
- package/dist/lib/browser/cdp.js +0 -254
- package/dist/lib/browser/chrome.d.ts +0 -141
- package/dist/lib/browser/chrome.js +0 -704
- package/dist/lib/browser/chromium-discovery.d.ts +0 -28
- package/dist/lib/browser/chromium-discovery.js +0 -96
- package/dist/lib/browser/devices.d.ts +0 -34
- package/dist/lib/browser/devices.js +0 -61
- package/dist/lib/browser/domain-skills.d.ts +0 -71
- package/dist/lib/browser/domain-skills.js +0 -195
- package/dist/lib/browser/drivers/arc.d.ts +0 -40
- package/dist/lib/browser/drivers/arc.js +0 -276
- package/dist/lib/browser/drivers/firefox.d.ts +0 -99
- package/dist/lib/browser/drivers/firefox.js +0 -377
- package/dist/lib/browser/drivers/local.d.ts +0 -55
- package/dist/lib/browser/drivers/local.js +0 -266
- package/dist/lib/browser/drivers/ssh.d.ts +0 -120
- package/dist/lib/browser/drivers/ssh.js +0 -467
- package/dist/lib/browser/editor.d.ts +0 -3
- package/dist/lib/browser/editor.js +0 -50
- package/dist/lib/browser/ffmpeg.d.ts +0 -12
- package/dist/lib/browser/ffmpeg.js +0 -184
- package/dist/lib/browser/firefox-discovery.d.ts +0 -69
- package/dist/lib/browser/firefox-discovery.js +0 -162
- package/dist/lib/browser/har.d.ts +0 -85
- package/dist/lib/browser/har.js +0 -77
- package/dist/lib/browser/hygiene.d.ts +0 -97
- package/dist/lib/browser/hygiene.js +0 -153
- package/dist/lib/browser/index.d.ts +0 -5
- package/dist/lib/browser/index.js +0 -5
- package/dist/lib/browser/input.d.ts +0 -7
- package/dist/lib/browser/input.js +0 -92
- package/dist/lib/browser/ipc.d.ts +0 -204
- package/dist/lib/browser/ipc.js +0 -1453
- package/dist/lib/browser/login-detection.d.ts +0 -87
- package/dist/lib/browser/login-detection.js +0 -274
- package/dist/lib/browser/profiles.d.ts +0 -422
- package/dist/lib/browser/profiles.js +0 -1157
- package/dist/lib/browser/refs.d.ts +0 -89
- package/dist/lib/browser/refs.js +0 -191
- package/dist/lib/browser/registry.d.ts +0 -70
- package/dist/lib/browser/registry.js +0 -252
- package/dist/lib/browser/remote-control.d.ts +0 -59
- package/dist/lib/browser/remote-control.js +0 -85
- package/dist/lib/browser/resolve-target.d.ts +0 -66
- package/dist/lib/browser/resolve-target.js +0 -264
- package/dist/lib/browser/runtime-state.d.ts +0 -291
- package/dist/lib/browser/runtime-state.js +0 -584
- package/dist/lib/browser/secret-ref.d.ts +0 -10
- package/dist/lib/browser/secret-ref.js +0 -14
- package/dist/lib/browser/service.d.ts +0 -812
- package/dist/lib/browser/service.js +0 -4693
- package/dist/lib/browser/stream.d.ts +0 -17
- package/dist/lib/browser/stream.js +0 -72
- package/dist/lib/browser/task-index.d.ts +0 -78
- package/dist/lib/browser/task-index.js +0 -199
- package/dist/lib/browser/types.d.ts +0 -647
- package/dist/lib/browser/types.js +0 -61
- package/dist/lib/browser/upload.d.ts +0 -29
- package/dist/lib/browser/upload.js +0 -298
- package/dist/lib/daemon/browser-ipc-service.d.ts +0 -20
- package/dist/lib/daemon/browser-ipc-service.js +0 -46
- package/dist/lib/daemon/browser-task-reap-service.d.ts +0 -14
- package/dist/lib/daemon/browser-task-reap-service.js +0 -26
- package/dist/lib/fleet-shared-repo-sync.d.ts +0 -55
- package/dist/lib/fleet-shared-repo-sync.js +0 -490
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Non-secret daemon state exchanged
|
|
2
|
+
* Non-secret daemon state exchanged over SSH between fleet devices (PHNX-4116).
|
|
3
3
|
*
|
|
4
|
-
* Each device owns exactly one file under `~/.agents/devices/<device
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Each device owns exactly one file under `~/.agents/devices/<device>/`
|
|
5
|
+
* (`daemon-state.json`, UNTRACKED in the user repo). A device writes its OWN
|
|
6
|
+
* file locally; a PEER's file on this box is written only by the usage-sync
|
|
7
|
+
* exchange — a headed daemon dials each peer with `agents __usage-ingest
|
|
8
|
+
* --reply`, sending its own envelope on stdin and writing the peer's reply
|
|
9
|
+
* envelope here stamped with `receivedAt`. The envelope used to ride the user
|
|
10
|
+
* repo as a tracked file, which turned the shared store into 18k `chore(devices)`
|
|
11
|
+
* commits and wedged every clone behind a `git fetch` that timed out; git now
|
|
12
|
+
* carries only human-authored resources. OAuth/setup-token values never belong
|
|
13
|
+
* here: auth publishes only a readiness verdict; the existing encrypted SSH
|
|
14
|
+
* bundle push remains the one path that may carry secret material.
|
|
9
15
|
*/
|
|
10
16
|
import * as fs from 'node:fs';
|
|
11
17
|
import * as fsp from 'node:fs/promises';
|
|
@@ -23,11 +29,19 @@ export function fleetSharedStatePath(device, userAgentsDir = getUserAgentsDir())
|
|
|
23
29
|
function isRecord(value) {
|
|
24
30
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
25
31
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Validate an already-parsed envelope. `owner` pins the `device` field when the
|
|
34
|
+
* caller knows whose envelope this must be (a file under `devices/<owner>/`);
|
|
35
|
+
* an envelope arriving over the exchange names its own owner, so the caller
|
|
36
|
+
* passes none and checks `device` against the dialed peer afterwards.
|
|
37
|
+
*/
|
|
38
|
+
export function parseFleetSharedDeviceStateEnvelope(parsed, owner) {
|
|
39
|
+
if (!isRecord(parsed) || parsed.version !== FLEET_SHARED_STATE_VERSION || typeof parsed.device !== 'string' || !parsed.device) {
|
|
29
40
|
throw new Error('unrecognized shared-state envelope');
|
|
30
41
|
}
|
|
42
|
+
if (owner !== undefined && parsed.device !== owner)
|
|
43
|
+
throw new Error('unrecognized shared-state envelope');
|
|
44
|
+
assertValidDeviceName(parsed.device);
|
|
31
45
|
const usage = parsed.usage;
|
|
32
46
|
if (usage !== undefined && (!isRecord(usage) || !isRecord(usage.rows))) {
|
|
33
47
|
throw new Error('unrecognized usage snapshot');
|
|
@@ -41,8 +55,18 @@ function parseFleetSharedDeviceState(raw, owner) {
|
|
|
41
55
|
if (sessions !== undefined && (!isRecord(sessions) || !Array.isArray(sessions.rows))) {
|
|
42
56
|
throw new Error('unrecognized session mirror');
|
|
43
57
|
}
|
|
58
|
+
const accounts = parsed.accounts;
|
|
59
|
+
if (accounts !== undefined && (!isRecord(accounts) || !Array.isArray(accounts.rows))) {
|
|
60
|
+
throw new Error('unrecognized account verdict rows');
|
|
61
|
+
}
|
|
62
|
+
if (parsed.receivedAt !== undefined && (typeof parsed.receivedAt !== 'number' || !Number.isFinite(parsed.receivedAt))) {
|
|
63
|
+
throw new Error('unrecognized receivedAt');
|
|
64
|
+
}
|
|
44
65
|
return parsed;
|
|
45
66
|
}
|
|
67
|
+
function parseFleetSharedDeviceState(raw, owner) {
|
|
68
|
+
return parseFleetSharedDeviceStateEnvelope(JSON.parse(raw), owner);
|
|
69
|
+
}
|
|
46
70
|
/**
|
|
47
71
|
* Merge one daemon-owned field into this device's shared file under a real
|
|
48
72
|
* inter-process lock. Stable serialization avoids dirtying the user repo when
|
|
@@ -63,6 +87,8 @@ function mergeFleetState(currentRaw, device, patch) {
|
|
|
63
87
|
...(patch.usage !== undefined ? { usage: patch.usage } : {}),
|
|
64
88
|
...(patch.auth !== undefined ? { auth: patch.auth } : {}),
|
|
65
89
|
...(patch.sessions !== undefined ? { sessions: patch.sessions } : {}),
|
|
90
|
+
...(patch.accounts !== undefined ? { accounts: patch.accounts } : {}),
|
|
91
|
+
...(patch.receivedAt !== undefined ? { receivedAt: patch.receivedAt } : {}),
|
|
66
92
|
version: FLEET_SHARED_STATE_VERSION,
|
|
67
93
|
device,
|
|
68
94
|
};
|
|
@@ -138,3 +164,46 @@ export function readFleetSharedDeviceStates(userAgentsDir = getUserAgentsDir())
|
|
|
138
164
|
}
|
|
139
165
|
return { states, errors };
|
|
140
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* This device's own envelope as it stands on disk — what the usage-sync exchange
|
|
169
|
+
* sends to peers and what `__usage-ingest --reply` prints back. A device that has
|
|
170
|
+
* never published anything yields the bare `{version, device}` envelope, so a
|
|
171
|
+
* peer still learns the device exists and answered.
|
|
172
|
+
*/
|
|
173
|
+
export function readOwnFleetSharedDeviceState(device, userAgentsDir = getUserAgentsDir()) {
|
|
174
|
+
const file = fleetSharedStatePath(device, userAgentsDir);
|
|
175
|
+
let raw = '';
|
|
176
|
+
try {
|
|
177
|
+
raw = fs.readFileSync(file, 'utf-8');
|
|
178
|
+
}
|
|
179
|
+
catch { /* never published → bare envelope */ }
|
|
180
|
+
if (!raw.trim())
|
|
181
|
+
return { version: FLEET_SHARED_STATE_VERSION, device };
|
|
182
|
+
const state = parseFleetSharedDeviceState(raw, device);
|
|
183
|
+
// `receivedAt` is a receiver-side stamp; the owner never advertises one.
|
|
184
|
+
const { receivedAt: _receivedAt, ...own } = state;
|
|
185
|
+
return own;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Store a PEER's envelope, received over the SSH exchange, in that peer's file on
|
|
189
|
+
* this box, stamped with `receivedAt`. Merges field-by-field onto whatever the
|
|
190
|
+
* file already holds, so a partial envelope (a placement probe sends usage only)
|
|
191
|
+
* refreshes that field without erasing the peer's last auth verdict, session
|
|
192
|
+
* digests, or account rows. The five readers of `devices/<peer>/daemon-state.json`
|
|
193
|
+
* (usage merge, poller claims, auth verdicts, session mirror, account catalog)
|
|
194
|
+
* are unchanged by the transport swap. Async because it runs on the daemon's
|
|
195
|
+
* usage-sync tick once per peer reply: the sync lock would `sleepSync` the event
|
|
196
|
+
* loop for up to 30 s under contention (see {@link updateFleetSharedDeviceStateAsync}).
|
|
197
|
+
*/
|
|
198
|
+
export async function storePeerFleetSharedDeviceState(state, userAgentsDir = getUserAgentsDir(), receivedAt = Date.now()) {
|
|
199
|
+
const patch = { receivedAt };
|
|
200
|
+
if (state.usage !== undefined)
|
|
201
|
+
patch.usage = state.usage;
|
|
202
|
+
if (state.auth !== undefined)
|
|
203
|
+
patch.auth = state.auth;
|
|
204
|
+
if (state.sessions !== undefined)
|
|
205
|
+
patch.sessions = state.sessions;
|
|
206
|
+
if (state.accounts !== undefined)
|
|
207
|
+
patch.accounts = state.accounts;
|
|
208
|
+
return updateFleetSharedDeviceStateAsync(state.device, patch, userAgentsDir);
|
|
209
|
+
}
|
package/dist/lib/fs-atomic.js
CHANGED
|
@@ -106,6 +106,21 @@ export function withFileLock(filePath, fn, opts = {}) {
|
|
|
106
106
|
}
|
|
107
107
|
catch (err) {
|
|
108
108
|
lastError = err;
|
|
109
|
+
// proper-lockfile breaks stale locks with rmdir, which fails with ENOTDIR
|
|
110
|
+
// when a prior crash left a regular file instead of the expected directory.
|
|
111
|
+
// Remove the stale file so the next attempt can succeed. Match
|
|
112
|
+
// proper-lockfile's own path resolution: realpath when the option is on
|
|
113
|
+
// (the default), raw path when it is off.
|
|
114
|
+
if (err instanceof Error && err.code === 'ENOTDIR') {
|
|
115
|
+
try {
|
|
116
|
+
const base = (opts.realpath ?? true) ? fs.realpathSync(filePath) : filePath;
|
|
117
|
+
const lockPath = `${base}.lock`;
|
|
118
|
+
const st = fs.statSync(lockPath);
|
|
119
|
+
if (st.isFile())
|
|
120
|
+
fs.unlinkSync(lockPath);
|
|
121
|
+
}
|
|
122
|
+
catch { /* best effort */ }
|
|
123
|
+
}
|
|
109
124
|
if (Date.now() >= deadline)
|
|
110
125
|
break;
|
|
111
126
|
const backoff = Math.min(LOCK_RETRY_MIN_MS * (attempt + 1), LOCK_RETRY_MAX_MS);
|
|
@@ -175,6 +190,16 @@ export async function withFileLockAsync(filePath, fn, opts = {}) {
|
|
|
175
190
|
}
|
|
176
191
|
catch (err) {
|
|
177
192
|
lastError = err;
|
|
193
|
+
if (err instanceof Error && err.code === 'ENOTDIR') {
|
|
194
|
+
try {
|
|
195
|
+
const base = (opts.realpath ?? true) ? fs.realpathSync(filePath) : filePath;
|
|
196
|
+
const lockPath = `${base}.lock`;
|
|
197
|
+
const st = fs.statSync(lockPath);
|
|
198
|
+
if (st.isFile())
|
|
199
|
+
fs.unlinkSync(lockPath);
|
|
200
|
+
}
|
|
201
|
+
catch { /* best effort */ }
|
|
202
|
+
}
|
|
178
203
|
if (Date.now() >= deadline)
|
|
179
204
|
break;
|
|
180
205
|
const backoff = Math.min(LOCK_RETRY_MIN_MS * (attempt + 1), LOCK_RETRY_MAX_MS);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* This helper:
|
|
9
9
|
* 1. Collects GitHub slugs from `~/.agents/projects/*.yaml` (`repo` / `repos[].slug`).
|
|
10
|
-
* 2. Canonicalizes each slug (
|
|
10
|
+
* 2. Canonicalizes each slug (REST `repos/{slug}` — `phnx-labs/agents-cli` lists
|
|
11
11
|
* nothing; the live name is `phnx-labs/agi-cli`).
|
|
12
12
|
* 3. Runs `gh pr list --repo <slug> --author @me` (cwd-independent).
|
|
13
13
|
* 4. Keeps CI-green PRs with a merge-guard verdict (formal APPROVED review
|
|
@@ -27,7 +27,9 @@ export declare function projectRepoSlugs(defs: readonly ProjectDef[]): string[];
|
|
|
27
27
|
/**
|
|
28
28
|
* Resolve a slug to GitHub's current `nameWithOwner`. A renamed repo
|
|
29
29
|
* (`phnx-labs/agents-cli` → `phnx-labs/agi-cli`) lists zero PRs under the old
|
|
30
|
-
* name;
|
|
30
|
+
* name; the REST repo read follows the rename redirect and returns the live one.
|
|
31
|
+
* REST + a 24h gh cache: a rename is rare, and every `projects prs` refresh asks.
|
|
32
|
+
* On error, keep the input slug.
|
|
31
33
|
*/
|
|
32
34
|
export declare function canonicalizeRepo(slug: string, gh: GhExec): Promise<string>;
|
|
33
35
|
interface ListedPr {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* This helper:
|
|
9
9
|
* 1. Collects GitHub slugs from `~/.agents/projects/*.yaml` (`repo` / `repos[].slug`).
|
|
10
|
-
* 2. Canonicalizes each slug (
|
|
10
|
+
* 2. Canonicalizes each slug (REST `repos/{slug}` — `phnx-labs/agents-cli` lists
|
|
11
11
|
* nothing; the live name is `phnx-labs/agi-cli`).
|
|
12
12
|
* 3. Runs `gh pr list --repo <slug> --author @me` (cwd-independent).
|
|
13
13
|
* 4. Keeps CI-green PRs with a merge-guard verdict (formal APPROVED review
|
|
@@ -65,12 +65,14 @@ export function projectRepoSlugs(defs) {
|
|
|
65
65
|
/**
|
|
66
66
|
* Resolve a slug to GitHub's current `nameWithOwner`. A renamed repo
|
|
67
67
|
* (`phnx-labs/agents-cli` → `phnx-labs/agi-cli`) lists zero PRs under the old
|
|
68
|
-
* name;
|
|
68
|
+
* name; the REST repo read follows the rename redirect and returns the live one.
|
|
69
|
+
* REST + a 24h gh cache: a rename is rare, and every `projects prs` refresh asks.
|
|
70
|
+
* On error, keep the input slug.
|
|
69
71
|
*/
|
|
70
72
|
export async function canonicalizeRepo(slug, gh) {
|
|
71
73
|
try {
|
|
72
74
|
const out = (await gh([
|
|
73
|
-
'
|
|
75
|
+
'api', `repos/${slug}`, '--cache', '24h', '--jq', '.full_name',
|
|
74
76
|
])).trim();
|
|
75
77
|
return out || slug;
|
|
76
78
|
}
|
|
@@ -23,13 +23,22 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { type GhExec } from './pr-mergeable.js';
|
|
25
25
|
import { type RollupItem } from './rest.js';
|
|
26
|
-
import type
|
|
26
|
+
import { type ProjectDef } from '../projects.js';
|
|
27
27
|
/** The author of a PR, as the menu renders it (login + avatar). */
|
|
28
28
|
export interface ProjectPrAuthor {
|
|
29
29
|
login: string;
|
|
30
30
|
avatarUrl: string;
|
|
31
31
|
}
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Where a PR sits for a project whose repository is SHARED with another project
|
|
34
|
+
* (a monorepo): `project` touches the paths this project claims, `repo-wide`
|
|
35
|
+
* touches no sharing project's paths (root config, CI, docs). A PR that touches
|
|
36
|
+
* only another project's paths is not listed at all. Null when the repository is
|
|
37
|
+
* not shared, or this project claims all of it. Claims are {@link repoPathClaims},
|
|
38
|
+
* the same ownership that attributes a session's cwd.
|
|
39
|
+
*/
|
|
40
|
+
export type ProjectPrScope = 'project' | 'repo-wide';
|
|
41
|
+
/** One open PR row. `checks`/`reviewDecision`/merge state are null unless the PR was enriched. */
|
|
33
42
|
export interface ProjectPr {
|
|
34
43
|
number: number;
|
|
35
44
|
title: string;
|
|
@@ -37,12 +46,18 @@ export interface ProjectPr {
|
|
|
37
46
|
isDraft: boolean;
|
|
38
47
|
/** Upper-cased GitHub state — always `OPEN` here (only open PRs are listed). */
|
|
39
48
|
state: string;
|
|
49
|
+
createdAt: string;
|
|
40
50
|
updatedAt: string;
|
|
41
51
|
author: ProjectPrAuthor;
|
|
42
52
|
headRefName: string;
|
|
43
53
|
baseRefName: string;
|
|
44
54
|
headSha: string;
|
|
45
55
|
body: string;
|
|
56
|
+
scope: ProjectPrScope | null;
|
|
57
|
+
/** Populated only for the `--number` PR: GitHub's mergeability (null while it computes). */
|
|
58
|
+
mergeable: boolean | null;
|
|
59
|
+
/** Populated only for the `--number` PR: `clean` | `unstable` | `blocked` | `behind` | `dirty` | `draft` | `unknown` … */
|
|
60
|
+
mergeableState: string | null;
|
|
46
61
|
/** Populated only for the `--number` PR: the head-SHA status-check rollup. */
|
|
47
62
|
checks: RollupItem[] | null;
|
|
48
63
|
/** Populated only for the `--number` PR: GitHub's computed review decision. */
|
|
@@ -51,6 +66,8 @@ export interface ProjectPr {
|
|
|
51
66
|
/** One repository's open PRs, or the error that stopped its fetch. */
|
|
52
67
|
export interface ProjectRepoPrs {
|
|
53
68
|
slug: string;
|
|
69
|
+
/** Other project definitions attached to this same repository. */
|
|
70
|
+
sharedWith: string[];
|
|
54
71
|
pullRequests: ProjectPr[];
|
|
55
72
|
/** Non-null when the fetch failed — the list is then NOT authoritative. */
|
|
56
73
|
error: string | null;
|
|
@@ -61,6 +78,8 @@ export interface ProjectPrsEnvelope {
|
|
|
61
78
|
name: string;
|
|
62
79
|
linearProjectId: string | null;
|
|
63
80
|
};
|
|
81
|
+
/** The authenticated GitHub login — what a "mine" filter compares `author.login` to. */
|
|
82
|
+
viewer: string | null;
|
|
64
83
|
repositories: ProjectRepoPrs[];
|
|
65
84
|
/** True when at least one repository fetch failed (its list is incomplete). */
|
|
66
85
|
partial: boolean;
|
|
@@ -110,9 +129,45 @@ export declare function enrichPr(repo: string, pr: ProjectPr, gh?: GhExec): Prom
|
|
|
110
129
|
* is not one of the project's attached repos (raw or canonical) is refused.
|
|
111
130
|
*/
|
|
112
131
|
export declare function resolveTargetSlugs(def: ProjectDef, repo: string | undefined, gh: GhExec): Promise<string[]>;
|
|
132
|
+
/**
|
|
133
|
+
* Classify one PR of a shared repository by the files it changes. `own` are
|
|
134
|
+
* this project's prefixes, `others` every other sharing project's. Returns
|
|
135
|
+
* null when the PR belongs only to another project.
|
|
136
|
+
*/
|
|
137
|
+
export declare function scopeForFiles(files: readonly string[], own: readonly string[], others: readonly string[]): ProjectPrScope | null;
|
|
138
|
+
/** The authenticated login, from a REST read gh caches for a day. */
|
|
139
|
+
export declare function fetchViewer(gh?: GhExec): Promise<string | null>;
|
|
113
140
|
/**
|
|
114
141
|
* Build the full envelope. `number` (which the command requires alongside
|
|
115
142
|
* `repo`) fetches and enriches exactly that one PR; otherwise every target
|
|
116
143
|
* repo's open PRs are listed with checks/reviewDecision left null.
|
|
144
|
+
*
|
|
145
|
+
* `peers` are every project definition. A repository attached to more than one
|
|
146
|
+
* project is scoped: when this project claims part of it ({@link repoPathClaims}),
|
|
147
|
+
* its list keeps only PRs that touch those paths (`scope: project`) or touch no
|
|
148
|
+
* sharing project's paths (`scope: repo-wide`).
|
|
149
|
+
*/
|
|
150
|
+
export declare function buildProjectPrs(def: ProjectDef, opts?: ProjectPrsOptions, gh?: GhExec, peers?: readonly ProjectDef[]): Promise<ProjectPrsEnvelope>;
|
|
151
|
+
/** How a PR is merged; the default is the first of these the repository allows. */
|
|
152
|
+
export declare const MERGE_METHODS: readonly ["rebase", "squash", "merge"];
|
|
153
|
+
export type MergeMethod = (typeof MERGE_METHODS)[number];
|
|
154
|
+
/** The result of one `projects prs merge`. */
|
|
155
|
+
export interface ProjectPrMergeResult {
|
|
156
|
+
repo: string;
|
|
157
|
+
number: number;
|
|
158
|
+
method: MergeMethod;
|
|
159
|
+
merged: boolean;
|
|
160
|
+
/** The merge commit SHA GitHub reports. */
|
|
161
|
+
sha: string | null;
|
|
162
|
+
message: string;
|
|
163
|
+
}
|
|
164
|
+
/** The first of {@link MERGE_METHODS} the repository allows. */
|
|
165
|
+
export declare function defaultMergeMethod(repo: string, gh?: GhExec): Promise<MergeMethod>;
|
|
166
|
+
/**
|
|
167
|
+
* Merge one PR over REST (`PUT repos/{repo}/pulls/{n}/merge`), pinned to `sha`:
|
|
168
|
+
* GitHub refuses with 409 when the head moved since the caller looked, so a push
|
|
169
|
+
* that landed after the menu rendered is never merged unseen. Branch protection,
|
|
170
|
+
* required checks and reviews are GitHub's to enforce; their refusal comes back
|
|
171
|
+
* as `merged: false` with GitHub's own message.
|
|
117
172
|
*/
|
|
118
|
-
export declare function
|
|
173
|
+
export declare function mergeProjectPr(repo: string, number: number, sha: string, method: MergeMethod | undefined, gh?: GhExec): Promise<ProjectPrMergeResult>;
|
|
@@ -21,14 +21,20 @@
|
|
|
21
21
|
* - A per-repo fetch failure is reported as `repositories[].error` and flips
|
|
22
22
|
* `partial` — it is NEVER relabeled as "zero open PRs".
|
|
23
23
|
*/
|
|
24
|
+
import * as fs from 'fs';
|
|
25
|
+
import * as path from 'path';
|
|
24
26
|
import { ghExec, canonicalizeRepo, projectRepoSlugs } from './pr-mergeable.js';
|
|
25
27
|
import { rollupForSha } from './rest.js';
|
|
28
|
+
import { repoPathClaims } from '../projects.js';
|
|
29
|
+
import { getCacheDir } from '../state.js';
|
|
30
|
+
import { atomicWriteFileSync } from '../fs-atomic.js';
|
|
26
31
|
/** The jq projection that flattens a REST PR object into {@link ProjectPr}'s scalars. */
|
|
27
32
|
const PR_JQ = '{number, title, url: .html_url, isDraft: (.draft // false), ' +
|
|
28
|
-
'state: (.state // "" | ascii_upcase), updatedAt: (.updated_at // ""), ' +
|
|
33
|
+
'state: (.state // "" | ascii_upcase), createdAt: (.created_at // ""), updatedAt: (.updated_at // ""), ' +
|
|
29
34
|
'login: (.user.login // ""), avatarUrl: (.user.avatar_url // ""), ' +
|
|
30
35
|
'headRefName: (.head.ref // ""), baseRefName: (.base.ref // ""), ' +
|
|
31
|
-
'headSha: (.head.sha // ""), body: (.body // "")
|
|
36
|
+
'headSha: (.head.sha // ""), body: (.body // ""), ' +
|
|
37
|
+
'mergeable: .mergeable, mergeableState: .mergeable_state}';
|
|
32
38
|
/** Parse newline-delimited JSON (gh `--jq` streams one object per line/page). */
|
|
33
39
|
function parseNdjson(out) {
|
|
34
40
|
const rows = [];
|
|
@@ -48,12 +54,16 @@ export function rowToProjectPr(row) {
|
|
|
48
54
|
url: String(row.url ?? ''),
|
|
49
55
|
isDraft: Boolean(row.isDraft),
|
|
50
56
|
state: String(row.state ?? ''),
|
|
57
|
+
createdAt: String(row.createdAt ?? ''),
|
|
51
58
|
updatedAt: String(row.updatedAt ?? ''),
|
|
52
59
|
author: { login: String(row.login ?? ''), avatarUrl: String(row.avatarUrl ?? '') },
|
|
53
60
|
headRefName: String(row.headRefName ?? ''),
|
|
54
61
|
baseRefName: String(row.baseRefName ?? ''),
|
|
55
62
|
headSha: String(row.headSha ?? ''),
|
|
56
63
|
body: String(row.body ?? ''),
|
|
64
|
+
scope: null,
|
|
65
|
+
mergeable: typeof row.mergeable === 'boolean' ? row.mergeable : null,
|
|
66
|
+
mergeableState: typeof row.mergeableState === 'string' ? row.mergeableState : null,
|
|
57
67
|
checks: null,
|
|
58
68
|
reviewDecision: null,
|
|
59
69
|
};
|
|
@@ -121,7 +131,7 @@ export async function enrichPr(repo, pr, gh = ghExec) {
|
|
|
121
131
|
*/
|
|
122
132
|
export async function resolveTargetSlugs(def, repo, gh) {
|
|
123
133
|
const raw = projectRepoSlugs([def]);
|
|
124
|
-
// Canonicalize CONCURRENTLY — each
|
|
134
|
+
// Canonicalize CONCURRENTLY — each repo read can take up to gh's 30s
|
|
125
135
|
// timeout, so a sequential walk of N repos would stack N×30s and blow past a
|
|
126
136
|
// native caller's bounded deadline. Repo count per project is small.
|
|
127
137
|
const canonPairs = await Promise.all(raw.map(async (slug) => [slug, await canonicalizeRepo(slug, gh)]));
|
|
@@ -139,19 +149,145 @@ export async function resolveTargetSlugs(def, repo, gh) {
|
|
|
139
149
|
}
|
|
140
150
|
return [requested];
|
|
141
151
|
}
|
|
152
|
+
/**
|
|
153
|
+
* Classify one PR of a shared repository by the files it changes. `own` are
|
|
154
|
+
* this project's prefixes, `others` every other sharing project's. Returns
|
|
155
|
+
* null when the PR belongs only to another project.
|
|
156
|
+
*/
|
|
157
|
+
export function scopeForFiles(files, own, others) {
|
|
158
|
+
const under = (prefixes) => files.some((f) => prefixes.some((p) => f.startsWith(p)));
|
|
159
|
+
if (under(own))
|
|
160
|
+
return 'project';
|
|
161
|
+
if (under(others))
|
|
162
|
+
return null;
|
|
163
|
+
return 'repo-wide';
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* The files a PR changes, keyed by its head SHA. The list for a given head is
|
|
167
|
+
* immutable, so a cached entry never goes stale; a push mints a new key. Entries
|
|
168
|
+
* for heads that are no longer open in that repo are pruned on write.
|
|
169
|
+
*/
|
|
170
|
+
class PrFilesCache {
|
|
171
|
+
file = path.join(getCacheDir(), 'project-pr-files.json');
|
|
172
|
+
entries = {};
|
|
173
|
+
dirty = false;
|
|
174
|
+
constructor() {
|
|
175
|
+
// A missing or unreadable cache only costs re-reads; it never fails a list.
|
|
176
|
+
try {
|
|
177
|
+
const parsed = JSON.parse(fs.readFileSync(this.file, 'utf-8'));
|
|
178
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
|
|
179
|
+
this.entries = parsed;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
this.entries = {};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async files(slug, pr, gh) {
|
|
186
|
+
const key = `${slug}@${pr.headSha}`;
|
|
187
|
+
const hit = this.entries[key];
|
|
188
|
+
if (hit)
|
|
189
|
+
return hit;
|
|
190
|
+
// A rename counts on both sides: moving a file out of a project's path touches that project.
|
|
191
|
+
const out = await gh(['api', `repos/${slug}/pulls/${pr.number}/files?per_page=100`, '--paginate', '--jq', '.[] | .filename, (.previous_filename // empty)']);
|
|
192
|
+
const files = out.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
193
|
+
this.entries[key] = files;
|
|
194
|
+
this.dirty = true;
|
|
195
|
+
return files;
|
|
196
|
+
}
|
|
197
|
+
prune(slug, openHeads) {
|
|
198
|
+
for (const key of Object.keys(this.entries)) {
|
|
199
|
+
const at = key.lastIndexOf('@');
|
|
200
|
+
if (key.slice(0, at) === slug && !openHeads.has(key.slice(at + 1))) {
|
|
201
|
+
delete this.entries[key];
|
|
202
|
+
this.dirty = true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
save() {
|
|
207
|
+
if (!this.dirty)
|
|
208
|
+
return;
|
|
209
|
+
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
|
210
|
+
atomicWriteFileSync(this.file, JSON.stringify(this.entries));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** Run `fn` over `items` with at most `limit` in flight, preserving order. */
|
|
214
|
+
async function mapBounded(items, limit, fn) {
|
|
215
|
+
const results = new Array(items.length);
|
|
216
|
+
let next = 0;
|
|
217
|
+
const worker = async () => {
|
|
218
|
+
while (next < items.length) {
|
|
219
|
+
const i = next++;
|
|
220
|
+
results[i] = await fn(items[i]);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
224
|
+
return results;
|
|
225
|
+
}
|
|
226
|
+
/** The authenticated login, from a REST read gh caches for a day. */
|
|
227
|
+
export async function fetchViewer(gh = ghExec) {
|
|
228
|
+
try {
|
|
229
|
+
return (await gh(['api', 'user', '--cache', '24h', '--jq', '.login'])).trim() || null;
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* The repository paths `def` claims, keyed by canonical slug. Canonicalization
|
|
237
|
+
* is cached, so this costs no network after the first run of the day.
|
|
238
|
+
*/
|
|
239
|
+
async function canonicalClaims(def, gh) {
|
|
240
|
+
const out = new Map();
|
|
241
|
+
for (const { slug, prefix } of repoPathClaims(def)) {
|
|
242
|
+
const canonical = await canonicalizeRepo(slug, gh);
|
|
243
|
+
out.set(canonical, [...(out.get(canonical) ?? []), prefix]);
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
/** For each canonical slug, the other projects attached to it and the paths they claim. */
|
|
248
|
+
async function repoPeers(def, peers, gh) {
|
|
249
|
+
const bySlug = new Map();
|
|
250
|
+
await Promise.all(peers.filter((p) => p.name !== def.name).map(async (peer) => {
|
|
251
|
+
const [slugs, claims] = await Promise.all([
|
|
252
|
+
Promise.all(projectRepoSlugs([peer]).map((s) => canonicalizeRepo(s, gh))),
|
|
253
|
+
canonicalClaims(peer, gh),
|
|
254
|
+
]);
|
|
255
|
+
for (const slug of new Set(slugs)) {
|
|
256
|
+
const entry = bySlug.get(slug) ?? { names: [], prefixes: [] };
|
|
257
|
+
entry.names.push(peer.name);
|
|
258
|
+
entry.prefixes.push(...(claims.get(slug) ?? []));
|
|
259
|
+
bySlug.set(slug, entry);
|
|
260
|
+
}
|
|
261
|
+
}));
|
|
262
|
+
for (const entry of bySlug.values())
|
|
263
|
+
entry.names.sort();
|
|
264
|
+
return bySlug;
|
|
265
|
+
}
|
|
142
266
|
/**
|
|
143
267
|
* Build the full envelope. `number` (which the command requires alongside
|
|
144
268
|
* `repo`) fetches and enriches exactly that one PR; otherwise every target
|
|
145
269
|
* repo's open PRs are listed with checks/reviewDecision left null.
|
|
270
|
+
*
|
|
271
|
+
* `peers` are every project definition. A repository attached to more than one
|
|
272
|
+
* project is scoped: when this project claims part of it ({@link repoPathClaims}),
|
|
273
|
+
* its list keeps only PRs that touch those paths (`scope: project`) or touch no
|
|
274
|
+
* sharing project's paths (`scope: repo-wide`).
|
|
146
275
|
*/
|
|
147
|
-
export async function buildProjectPrs(def, opts = {}, gh = ghExec) {
|
|
276
|
+
export async function buildProjectPrs(def, opts = {}, gh = ghExec, peers = []) {
|
|
148
277
|
const project = { name: def.name, linearProjectId: def.linear?.projectId ?? null };
|
|
149
278
|
const slugs = await resolveTargetSlugs(def, opts.repo, gh);
|
|
279
|
+
if (slugs.length === 0)
|
|
280
|
+
return { project, viewer: null, repositories: [], partial: false };
|
|
281
|
+
const viewerRead = fetchViewer(gh);
|
|
282
|
+
const [shared, ownClaims] = await Promise.all([repoPeers(def, peers, gh), canonicalClaims(def, gh)]);
|
|
283
|
+
const filesCache = new PrFilesCache();
|
|
150
284
|
// Fetch repos in PARALLEL so a native caller's overall deadline scales with the
|
|
151
285
|
// slowest repo, not the sum — a serial multi-repo walk of 30s gh calls can blow
|
|
152
286
|
// past a bounded caller timeout and lose the whole result. Repo count is small
|
|
153
287
|
// (a project's attached repos), so this is a bounded fan-out, not unbounded.
|
|
154
288
|
const repositories = await Promise.all(slugs.map(async (slug) => {
|
|
289
|
+
const peer = shared.get(slug);
|
|
290
|
+
const sharedWith = peer?.names ?? [];
|
|
155
291
|
try {
|
|
156
292
|
let pullRequests;
|
|
157
293
|
if (opts.number !== undefined) {
|
|
@@ -160,14 +296,76 @@ export async function buildProjectPrs(def, opts = {}, gh = ghExec) {
|
|
|
160
296
|
}
|
|
161
297
|
else {
|
|
162
298
|
pullRequests = await listOpenPrs(slug, gh);
|
|
299
|
+
const own = ownClaims.get(slug);
|
|
300
|
+
if (peer && own) {
|
|
301
|
+
filesCache.prune(slug, new Set(pullRequests.map((pr) => pr.headSha)));
|
|
302
|
+
const scopes = await mapBounded(pullRequests, 8, async (pr) => scopeForFiles(await filesCache.files(slug, pr, gh), own, peer.prefixes));
|
|
303
|
+
pullRequests = pullRequests
|
|
304
|
+
.map((pr, i) => ({ ...pr, scope: scopes[i] }))
|
|
305
|
+
.filter((pr) => pr.scope !== null);
|
|
306
|
+
}
|
|
163
307
|
}
|
|
164
|
-
return { slug, pullRequests, error: null };
|
|
308
|
+
return { slug, sharedWith, pullRequests, error: null };
|
|
165
309
|
}
|
|
166
310
|
catch (err) {
|
|
167
311
|
// A fetch failure is reported, never relabeled as zero open PRs.
|
|
168
|
-
return { slug, pullRequests: [], error: err instanceof Error ? err.message : String(err) };
|
|
312
|
+
return { slug, sharedWith, pullRequests: [], error: err instanceof Error ? err.message : String(err) };
|
|
169
313
|
}
|
|
170
314
|
}));
|
|
315
|
+
filesCache.save();
|
|
171
316
|
const partial = repositories.some((r) => r.error !== null);
|
|
172
|
-
return { project, repositories, partial };
|
|
317
|
+
return { project, viewer: await viewerRead, repositories, partial };
|
|
318
|
+
}
|
|
319
|
+
/** How a PR is merged; the default is the first of these the repository allows. */
|
|
320
|
+
export const MERGE_METHODS = ['rebase', 'squash', 'merge'];
|
|
321
|
+
/** The first of {@link MERGE_METHODS} the repository allows. */
|
|
322
|
+
export async function defaultMergeMethod(repo, gh = ghExec) {
|
|
323
|
+
const allowed = JSON.parse((await gh([
|
|
324
|
+
'api', `repos/${repo}`, '--cache', '1h',
|
|
325
|
+
'--jq', '{rebase: .allow_rebase_merge, squash: .allow_squash_merge, merge: .allow_merge_commit}',
|
|
326
|
+
])).trim());
|
|
327
|
+
const method = MERGE_METHODS.find((m) => allowed[m]);
|
|
328
|
+
if (!method)
|
|
329
|
+
throw new Error(`${repo} allows no merge method this token can see.`);
|
|
330
|
+
return method;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* gh prints GitHub's refusal on stderr (`gh: Required status check … (HTTP 405)`),
|
|
334
|
+
* sometimes followed by a hint line (a 401 adds `try authenticating with: gh auth
|
|
335
|
+
* login`). Keep the line carrying the HTTP status, else the first line.
|
|
336
|
+
*/
|
|
337
|
+
function ghFailure(err) {
|
|
338
|
+
const stderr = err?.stderr;
|
|
339
|
+
const text = typeof stderr === 'string' && stderr.trim() ? stderr : err instanceof Error ? err.message : String(err);
|
|
340
|
+
const lines = text.trim().split('\n').map((l) => l.trim()).filter(Boolean);
|
|
341
|
+
const line = lines.find((l) => /\(HTTP \d{3}\)/.test(l)) ?? lines[0] ?? text;
|
|
342
|
+
return line.replace(/^gh:\s*/, '');
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Merge one PR over REST (`PUT repos/{repo}/pulls/{n}/merge`), pinned to `sha`:
|
|
346
|
+
* GitHub refuses with 409 when the head moved since the caller looked, so a push
|
|
347
|
+
* that landed after the menu rendered is never merged unseen. Branch protection,
|
|
348
|
+
* required checks and reviews are GitHub's to enforce; their refusal comes back
|
|
349
|
+
* as `merged: false` with GitHub's own message.
|
|
350
|
+
*/
|
|
351
|
+
export async function mergeProjectPr(repo, number, sha, method, gh = ghExec) {
|
|
352
|
+
let chosen;
|
|
353
|
+
try {
|
|
354
|
+
chosen = method ?? await defaultMergeMethod(repo, gh);
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
return { repo, number, method: method ?? MERGE_METHODS[0], merged: false, sha: null, message: ghFailure(err) };
|
|
358
|
+
}
|
|
359
|
+
let out;
|
|
360
|
+
try {
|
|
361
|
+
out = await gh([
|
|
362
|
+
'api', '-X', 'PUT', `repos/${repo}/pulls/${number}/merge`,
|
|
363
|
+
'-f', `sha=${sha}`, '-f', `merge_method=${chosen}`, '--jq', '.sha',
|
|
364
|
+
]);
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
return { repo, number, method: chosen, merged: false, sha: null, message: ghFailure(err) };
|
|
368
|
+
}
|
|
369
|
+
// GitHub answers this endpoint 200 only once the PR is merged; anything else made gh exit non-zero.
|
|
370
|
+
return { repo, number, method: chosen, merged: true, sha: out.trim() || null, message: 'Merged' };
|
|
173
371
|
}
|
|
@@ -15,13 +15,23 @@ export const cursorAdapter = {
|
|
|
15
15
|
stripForeignConfigDir(result);
|
|
16
16
|
},
|
|
17
17
|
execPreModeArgs(ctx) {
|
|
18
|
-
// A configured headless run is the workspace trust decision
|
|
19
|
-
//
|
|
20
|
-
|
|
18
|
+
// A configured headless run is the workspace trust decision, in plan mode
|
|
19
|
+
// too: an untrusted cwd otherwise stops on Cursor's trust prompt with no one
|
|
20
|
+
// to answer it. Keep this narrower than --yolo/-f, which skip already passes
|
|
21
|
+
// and which also bypasses permission checks.
|
|
22
|
+
return ctx.resolvedMode !== 'skip' && !ctx.interactive ? ['--trust'] : undefined;
|
|
23
|
+
},
|
|
24
|
+
// Headless read-only runs use ask mode, not --plan. In plan mode Cursor
|
|
25
|
+
// delivers its answer through the createPlan tool, which `-p` text output
|
|
26
|
+
// never prints, so the run exits 0 with an empty stdout. Ask mode is equally
|
|
27
|
+
// read-only (it refuses file writes) and prints its answer. Interactive plan
|
|
28
|
+
// keeps --plan, where the TUI renders the plan.
|
|
29
|
+
execModeArgs(ctx) {
|
|
30
|
+
return ctx.resolvedMode === 'plan' && !ctx.interactive ? ['--mode', 'ask'] : undefined;
|
|
21
31
|
},
|
|
22
32
|
routineModeArgs(cmd, ctx) {
|
|
23
33
|
if (ctx.mode === 'plan') {
|
|
24
|
-
cmd.push('--
|
|
34
|
+
cmd.push('--mode', 'ask', '--trust');
|
|
25
35
|
}
|
|
26
36
|
else if (ctx.mode === 'skip') {
|
|
27
37
|
cmd.push('-f');
|
|
@@ -16,9 +16,10 @@ import type { AgentId } from './types.js';
|
|
|
16
16
|
type HarnessIdentityKind = 'strong' | 'email' | 'opaque';
|
|
17
17
|
/**
|
|
18
18
|
* Durable worker credential, or `none` when the harness must log in per box.
|
|
19
|
-
* Codex
|
|
20
|
-
*
|
|
21
|
-
* store because it is a
|
|
19
|
+
* Codex and Grok are both: an API key (portable, bills the API) OR a
|
|
20
|
+
* per-device device-auth login (the subscription seat — ChatGPT plan,
|
|
21
|
+
* SuperGrok, X Premium+; never stored in the reserved store because it is a
|
|
22
|
+
* rotating session, so each box signs in for itself).
|
|
22
23
|
*/
|
|
23
24
|
type HarnessWorkerKind = 'setup-token' | 'none' | `api-key:${string}` | `per-device${'' | `:${string}`}`;
|
|
24
25
|
type HarnessWorker = HarnessWorkerKind | HarnessWorkerKind[];
|
|
@@ -6,7 +6,7 @@ export const HARNESS_AUTH = {
|
|
|
6
6
|
// the OS defaults to. The device-code screen prints the URL + code instead,
|
|
7
7
|
// so the human finishes it in the browser profile they choose — which is
|
|
8
8
|
// what makes it usable over an SSH shell on a worker.
|
|
9
|
-
grok: { login: ['login', '--device-auth'], status: null, identity: 'strong', worker: 'api-key:XAI_API_KEY', slotEnv: 'GROK_HOME' },
|
|
9
|
+
grok: { login: ['login', '--device-auth'], status: null, identity: 'strong', worker: ['api-key:XAI_API_KEY', 'per-device:device-auth'], slotEnv: 'GROK_HOME' },
|
|
10
10
|
// auth.json has no email claim; identity is the sorted provider-id join
|
|
11
11
|
// (`resolveOpenCodeAccountId`). NATIVE_ACCOUNT_CAPABILITIES.opencode.inspection
|
|
12
12
|
// is already 'opaque'.
|
|
@@ -17,7 +17,6 @@ export const HARNESS_AUTH = {
|
|
|
17
17
|
kimi: { login: null, status: null, identity: 'opaque', worker: 'none', slotEnv: 'KIMI_CODE_HOME' },
|
|
18
18
|
antigravity: { login: null, status: null, identity: 'opaque', worker: 'none', slotEnv: null },
|
|
19
19
|
droid: { login: null, status: null, identity: 'opaque', worker: 'api-key:FACTORY_API_KEY', slotEnv: null },
|
|
20
|
-
gemini: { login: null, status: null, identity: 'email', worker: 'none', slotEnv: null },
|
|
21
20
|
copilot: { login: null, status: null, identity: 'opaque', worker: 'none', slotEnv: 'COPILOT_HOME' },
|
|
22
21
|
openclaw: { login: null, status: null, identity: 'opaque', worker: 'none', slotEnv: null },
|
|
23
22
|
amp: { login: null, status: null, identity: 'opaque', worker: 'none', slotEnv: null },
|