@phnx-labs/agents-cli 1.20.34 → 1.20.36
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 +113 -0
- package/LICENSE +185 -21
- package/README.md +13 -5
- package/dist/commands/audit.d.ts +14 -0
- package/dist/commands/audit.js +68 -0
- package/dist/commands/browser.js +82 -8
- package/dist/commands/check.d.ts +15 -0
- package/dist/commands/check.js +84 -0
- package/dist/commands/cloud.js +143 -3
- package/dist/commands/computer-actions.d.ts +15 -0
- package/dist/commands/computer-actions.js +120 -22
- package/dist/commands/computer.js +81 -0
- package/dist/commands/cost.js +17 -12
- package/dist/commands/daemon.js +4 -1
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.js +54 -97
- package/dist/commands/events.d.ts +16 -0
- package/dist/commands/events.js +173 -0
- package/dist/commands/exec.d.ts +19 -0
- package/dist/commands/exec.js +283 -12
- package/dist/commands/hosts.js +37 -29
- package/dist/commands/inspect.d.ts +2 -0
- package/dist/commands/inspect.js +62 -16
- package/dist/commands/lock.d.ts +12 -0
- package/dist/commands/lock.js +70 -0
- package/dist/commands/logs.d.ts +17 -0
- package/dist/commands/logs.js +139 -0
- package/dist/commands/message.d.ts +15 -0
- package/dist/commands/message.js +56 -0
- package/dist/commands/models.d.ts +3 -0
- package/dist/commands/models.js +27 -8
- package/dist/commands/permissions.js +9 -2
- package/dist/commands/repo.d.ts +34 -0
- package/dist/commands/repo.js +243 -65
- package/dist/commands/resource-view.d.ts +20 -0
- package/dist/commands/resource-view.js +90 -28
- package/dist/commands/routines.js +101 -5
- package/dist/commands/secrets-migrate.js +106 -57
- package/dist/commands/secrets.d.ts +31 -18
- package/dist/commands/secrets.js +196 -91
- package/dist/commands/serve.d.ts +10 -0
- package/dist/commands/serve.js +37 -0
- package/dist/commands/sessions-inject.d.ts +14 -0
- package/dist/commands/sessions-inject.js +111 -0
- package/dist/commands/sessions-picker.d.ts +2 -0
- package/dist/commands/sessions-picker.js +24 -3
- package/dist/commands/sessions-resume.d.ts +2 -0
- package/dist/commands/sessions-resume.js +221 -0
- package/dist/commands/sessions-tail.d.ts +10 -0
- package/dist/commands/sessions-tail.js +11 -0
- package/dist/commands/sessions.d.ts +143 -1
- package/dist/commands/sessions.js +628 -80
- package/dist/commands/setup.js +4 -2
- package/dist/commands/sync.d.ts +3 -1
- package/dist/commands/sync.js +156 -4
- package/dist/commands/teams.js +217 -0
- package/dist/commands/versions.js +2 -4
- package/dist/commands/view.d.ts +3 -0
- package/dist/commands/view.js +19 -8
- package/dist/commands/wallet.d.ts +6 -0
- package/dist/commands/wallet.js +22 -5
- package/dist/commands/watchdog.d.ts +18 -0
- package/dist/commands/watchdog.js +238 -0
- package/dist/index.js +82 -32
- package/dist/lib/agents.js +30 -9
- package/dist/lib/audit/log.d.ts +92 -0
- package/dist/lib/audit/log.js +177 -0
- package/dist/lib/auto-pull.js +2 -1
- package/dist/lib/browser/cdp.js +5 -1
- package/dist/lib/browser/chrome.d.ts +10 -0
- package/dist/lib/browser/chrome.js +18 -7
- package/dist/lib/browser/drivers/ssh.d.ts +8 -0
- package/dist/lib/browser/drivers/ssh.js +77 -16
- package/dist/lib/browser/har.d.ts +84 -0
- package/dist/lib/browser/har.js +77 -0
- package/dist/lib/browser/ipc.d.ts +3 -0
- package/dist/lib/browser/ipc.js +37 -12
- package/dist/lib/browser/profiles.d.ts +1 -1
- package/dist/lib/browser/profiles.js +8 -10
- package/dist/lib/browser/refs.d.ts +65 -0
- package/dist/lib/browser/refs.js +73 -1
- package/dist/lib/browser/runtime-state.js +1 -0
- package/dist/lib/browser/service.d.ts +38 -2
- package/dist/lib/browser/service.js +183 -23
- package/dist/lib/browser/types.d.ts +14 -1
- package/dist/lib/budget/live-cloud.d.ts +42 -0
- package/dist/lib/budget/live-cloud.js +79 -0
- package/dist/lib/budget/live-team.d.ts +31 -0
- package/dist/lib/budget/live-team.js +115 -0
- package/dist/lib/cloud/codex.js +4 -0
- package/dist/lib/cloud/rush.d.ts +12 -1
- package/dist/lib/cloud/rush.js +13 -3
- package/dist/lib/cloud/types.d.ts +9 -0
- package/dist/lib/computer/dispatch.d.ts +8 -0
- package/dist/lib/computer/dispatch.js +125 -0
- package/dist/lib/computer/loop.d.ts +62 -0
- package/dist/lib/computer/loop.js +98 -0
- package/dist/lib/computer/model.d.ts +44 -0
- package/dist/lib/computer/model.js +157 -0
- package/dist/lib/concurrency.d.ts +19 -0
- package/dist/lib/concurrency.js +33 -0
- package/dist/lib/daemon.d.ts +92 -0
- package/dist/lib/daemon.js +222 -18
- package/dist/lib/devices/connect.d.ts +3 -2
- package/dist/lib/devices/connect.js +5 -3
- package/dist/lib/devices/registry.d.ts +7 -0
- package/dist/lib/devices/registry.js +24 -0
- package/dist/lib/devices/tailscale.js +1 -1
- package/dist/lib/drift.d.ts +52 -0
- package/dist/lib/drift.js +112 -0
- package/dist/lib/events.d.ts +10 -3
- package/dist/lib/events.js +101 -24
- package/dist/lib/exec.d.ts +17 -0
- package/dist/lib/exec.js +100 -12
- package/dist/lib/git.d.ts +27 -0
- package/dist/lib/git.js +56 -1
- package/dist/lib/hooks/cache.d.ts +6 -0
- package/dist/lib/hooks/cache.js +54 -12
- package/dist/lib/hooks.d.ts +27 -0
- package/dist/lib/hooks.js +135 -10
- package/dist/lib/hosts/dispatch.d.ts +15 -0
- package/dist/lib/hosts/dispatch.js +45 -13
- package/dist/lib/hosts/logs.d.ts +16 -0
- package/dist/lib/hosts/logs.js +74 -0
- package/dist/lib/hosts/option.js +1 -1
- package/dist/lib/hosts/passthrough.js +3 -1
- package/dist/lib/hosts/progress.d.ts +66 -0
- package/dist/lib/hosts/progress.js +125 -17
- package/dist/lib/hosts/ready.d.ts +51 -7
- package/dist/lib/hosts/ready.js +98 -25
- package/dist/lib/hosts/reconcile.d.ts +53 -0
- package/dist/lib/hosts/reconcile.js +81 -0
- package/dist/lib/hosts/registry.d.ts +19 -2
- package/dist/lib/hosts/registry.js +58 -2
- package/dist/lib/hosts/remote-cmd.d.ts +62 -1
- package/dist/lib/hosts/remote-cmd.js +70 -1
- package/dist/lib/hosts/remote-os.d.ts +17 -0
- package/dist/lib/hosts/remote-os.js +30 -0
- package/dist/lib/hosts/session-index.d.ts +34 -0
- package/dist/lib/hosts/session-index.js +56 -0
- package/dist/lib/hosts/tasks.d.ts +22 -0
- package/dist/lib/hosts/tasks.js +29 -0
- package/dist/lib/lock.d.ts +93 -0
- package/dist/lib/lock.js +207 -0
- package/dist/lib/loop.js +16 -1
- package/dist/lib/machine-id.d.ts +21 -0
- package/dist/lib/machine-id.js +26 -0
- package/dist/lib/mailbox-target.d.ts +36 -0
- package/dist/lib/mailbox-target.js +45 -0
- package/dist/lib/mailbox.d.ts +47 -0
- package/dist/lib/mailbox.js +194 -0
- package/dist/lib/mcp.d.ts +5 -0
- package/dist/lib/mcp.js +24 -8
- package/dist/lib/migrate.d.ts +19 -0
- package/dist/lib/migrate.js +134 -26
- package/dist/lib/overdue.js +3 -0
- package/dist/lib/permissions.d.ts +4 -0
- package/dist/lib/permissions.js +35 -0
- package/dist/lib/picker.d.ts +26 -0
- package/dist/lib/picker.js +127 -0
- package/dist/lib/platform/exec.d.ts +46 -0
- package/dist/lib/platform/exec.js +74 -0
- package/dist/lib/platform/process.d.ts +31 -0
- package/dist/lib/platform/process.js +34 -1
- package/dist/lib/platform/winpath.js +2 -0
- package/dist/lib/plugin-marketplace.d.ts +30 -0
- package/dist/lib/plugin-marketplace.js +215 -2
- package/dist/lib/plugins.d.ts +5 -0
- package/dist/lib/plugins.js +61 -10
- package/dist/lib/profiles.d.ts +25 -0
- package/dist/lib/profiles.js +22 -6
- package/dist/lib/pty-client.js +2 -1
- package/dist/lib/rotate.d.ts +61 -0
- package/dist/lib/rotate.js +52 -0
- package/dist/lib/routines.d.ts +40 -2
- package/dist/lib/routines.js +66 -8
- package/dist/lib/runner.d.ts +11 -2
- package/dist/lib/runner.js +49 -7
- package/dist/lib/scheduler.js +6 -1
- package/dist/lib/secrets/bundles.d.ts +60 -4
- package/dist/lib/secrets/bundles.js +131 -12
- package/dist/lib/secrets/filestore.d.ts +3 -0
- package/dist/lib/secrets/filestore.js +42 -16
- package/dist/lib/secrets/index.d.ts +43 -2
- package/dist/lib/secrets/index.js +102 -3
- package/dist/lib/secrets/mcp.d.ts +93 -0
- package/dist/lib/secrets/mcp.js +205 -0
- package/dist/lib/secrets/remote.js +12 -5
- package/dist/lib/secrets/sync.js +83 -4
- package/dist/lib/secrets/windows.js +14 -3
- package/dist/lib/serve/data.d.ts +81 -0
- package/dist/lib/serve/data.js +91 -0
- package/dist/lib/serve/page.d.ts +7 -0
- package/dist/lib/serve/page.js +140 -0
- package/dist/lib/serve/server.d.ts +46 -0
- package/dist/lib/serve/server.js +115 -0
- package/dist/lib/session/active.d.ts +61 -0
- package/dist/lib/session/active.js +196 -17
- package/dist/lib/session/discover.d.ts +37 -0
- package/dist/lib/session/discover.js +204 -27
- package/dist/lib/session/inject.d.ts +18 -0
- package/dist/lib/session/inject.js +21 -0
- package/dist/lib/session/parse.d.ts +6 -0
- package/dist/lib/session/parse.js +286 -2
- package/dist/lib/session/pid-registry.d.ts +22 -0
- package/dist/lib/session/pid-registry.js +104 -0
- package/dist/lib/session/provenance.d.ts +14 -2
- package/dist/lib/session/provenance.js +39 -8
- package/dist/lib/session/remote-active.d.ts +26 -0
- package/dist/lib/session/remote-active.js +153 -0
- package/dist/lib/session/remote-list.d.ts +51 -0
- package/dist/lib/session/remote-list.js +213 -0
- package/dist/lib/session/remote.d.ts +7 -1
- package/dist/lib/session/remote.js +18 -8
- package/dist/lib/session/render.js +1 -1
- package/dist/lib/session/sync/config.d.ts +1 -7
- package/dist/lib/session/sync/config.js +4 -11
- package/dist/lib/session/types.d.ts +18 -1
- package/dist/lib/session/types.js +1 -1
- package/dist/lib/shims.d.ts +44 -6
- package/dist/lib/shims.js +143 -57
- package/dist/lib/ssh-exec.d.ts +26 -3
- package/dist/lib/ssh-exec.js +47 -3
- package/dist/lib/ssh-tunnel.d.ts +24 -5
- package/dist/lib/ssh-tunnel.js +62 -63
- package/dist/lib/staleness/writers/hooks.js +1 -1
- package/dist/lib/startup/command-registry.d.ts +8 -0
- package/dist/lib/startup/command-registry.js +17 -1
- package/dist/lib/state.d.ts +13 -0
- package/dist/lib/state.js +103 -9
- package/dist/lib/sync-umbrella.d.ts +14 -7
- package/dist/lib/sync-umbrella.js +17 -9
- package/dist/lib/teams/forEach.d.ts +110 -0
- package/dist/lib/teams/forEach.js +186 -0
- package/dist/lib/teams/index.d.ts +1 -0
- package/dist/lib/teams/index.js +1 -0
- package/dist/lib/teams/pr-watch.d.ts +226 -0
- package/dist/lib/teams/pr-watch.js +371 -0
- package/dist/lib/teams/registry.js +25 -9
- package/dist/lib/teams/supervisor.d.ts +14 -1
- package/dist/lib/teams/supervisor.js +19 -0
- package/dist/lib/teams/worktree.d.ts +9 -0
- package/dist/lib/teams/worktree.js +32 -0
- package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
- package/dist/lib/terminal/backends/ghostty.js +69 -0
- package/dist/lib/terminal/backends/index.d.ts +19 -0
- package/dist/lib/terminal/backends/index.js +31 -0
- package/dist/lib/terminal/backends/iterm.d.ts +6 -0
- package/dist/lib/terminal/backends/iterm.js +62 -0
- package/dist/lib/terminal/backends/tmux.d.ts +14 -0
- package/dist/lib/terminal/backends/tmux.js +23 -0
- package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
- package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
- package/dist/lib/terminal/engine.d.ts +39 -0
- package/dist/lib/terminal/engine.js +54 -0
- package/dist/lib/terminal/index.d.ts +17 -0
- package/dist/lib/terminal/index.js +8 -0
- package/dist/lib/terminal/inject.d.ts +204 -0
- package/dist/lib/terminal/inject.js +247 -0
- package/dist/lib/terminal/policy.d.ts +11 -0
- package/dist/lib/terminal/policy.js +11 -0
- package/dist/lib/terminal/quote.d.ts +11 -0
- package/dist/lib/terminal/quote.js +13 -0
- package/dist/lib/terminal/resolve.d.ts +64 -0
- package/dist/lib/terminal/resolve.js +90 -0
- package/dist/lib/terminal/shell.d.ts +6 -0
- package/dist/lib/terminal/shell.js +23 -0
- package/dist/lib/terminal/transport.d.ts +15 -0
- package/dist/lib/terminal/transport.js +40 -0
- package/dist/lib/terminal/types.d.ts +59 -0
- package/dist/lib/terminal/types.js +13 -0
- package/dist/lib/triggers/webhook.d.ts +85 -0
- package/dist/lib/triggers/webhook.js +141 -0
- package/dist/lib/types.d.ts +7 -0
- package/dist/lib/versions.d.ts +30 -3
- package/dist/lib/versions.js +129 -18
- package/dist/lib/watchdog/index.d.ts +3 -0
- package/dist/lib/watchdog/index.js +5 -0
- package/dist/lib/watchdog/read.d.ts +35 -0
- package/dist/lib/watchdog/read.js +149 -0
- package/dist/lib/watchdog/runner.d.ts +127 -0
- package/dist/lib/watchdog/runner.js +322 -0
- package/dist/lib/watchdog/watchdog.d.ts +40 -0
- package/dist/lib/watchdog/watchdog.js +166 -0
- package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
- package/dist/lib/watchdog/watchdogTail.js +154 -0
- package/dist/lib/whats-new.d.ts +9 -0
- package/dist/lib/whats-new.js +35 -0
- package/dist/lib/workflows.d.ts +166 -0
- package/dist/lib/workflows.js +193 -0
- package/package.json +5 -4
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autonomous PR lifecycle for teams (issue #338).
|
|
3
|
+
*
|
|
4
|
+
* A team's teammates open PRs. This module watches those PRs and reacts:
|
|
5
|
+
* - RED CI -> spawn a fresh fix wave (a teammate --after the one that failed,
|
|
6
|
+
* with the CI failure logs injected) that pushes a follow-up commit.
|
|
7
|
+
* - NEW REVIEW COMMENT -> route it to a `bugfix` teammate (--after the source),
|
|
8
|
+
* with the comment body injected.
|
|
9
|
+
*
|
|
10
|
+
* The DECISION logic here is a pure function (`decidePrActions`) over a snapshot
|
|
11
|
+
* of check results + review comments + a set of already-handled ids. It never
|
|
12
|
+
* touches the network — the poll-based collector (`pollPrSnapshot`, backed by the
|
|
13
|
+
* `gh` CLI) and the orchestrator (`runPrWatch`) sit on top and feed it data. That
|
|
14
|
+
* split keeps the spawn-or-not-spawn logic fully unit-testable (see
|
|
15
|
+
* pr-watch.test.ts) and dedupe-correct.
|
|
16
|
+
*
|
|
17
|
+
* Follow-up (deferred, not required here): replace the poll loop with the
|
|
18
|
+
* event-driven webhook receiver from #331 — `pollPrSnapshot` is the seam where a
|
|
19
|
+
* `check_run` / `pull_request_review_comment` webhook payload would plug in,
|
|
20
|
+
* producing the same PrSnapshot the pure decider already consumes.
|
|
21
|
+
*/
|
|
22
|
+
import { execFile } from 'child_process';
|
|
23
|
+
import { promisify } from 'util';
|
|
24
|
+
const execFileAsync = promisify(execFile);
|
|
25
|
+
/**
|
|
26
|
+
* Default cap on how many fix/bugfix waves pr-watch spawns for a single PR before
|
|
27
|
+
* it gives up and escalates to a human. A persistently-RED PR re-runs CI on every
|
|
28
|
+
* follow-up commit; without a cap the loop would spawn teammates without bound
|
|
29
|
+
* (issue #338). Configurable via `--max-waves`.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_MAX_WAVES = 3;
|
|
32
|
+
/**
|
|
33
|
+
* Check states that count as a RED CI failure worth spawning a fix wave for.
|
|
34
|
+
* PENDING / SUCCESS / SKIPPING / NEUTRAL are deliberately excluded — a fix wave
|
|
35
|
+
* only fires on a genuine, terminal failure.
|
|
36
|
+
*/
|
|
37
|
+
const FAILED_STATES = new Set([
|
|
38
|
+
'FAILURE',
|
|
39
|
+
'ERROR',
|
|
40
|
+
'CANCELLED',
|
|
41
|
+
'TIMED_OUT',
|
|
42
|
+
'ACTION_REQUIRED',
|
|
43
|
+
'STARTUP_FAILURE',
|
|
44
|
+
]);
|
|
45
|
+
/** True when a check is in a terminal failing state. */
|
|
46
|
+
export function isFailedCheck(check) {
|
|
47
|
+
return FAILED_STATES.has((check.state || '').trim().toUpperCase());
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Idempotency key for a failed check on a PR — dedupe by check NAME.
|
|
51
|
+
*
|
|
52
|
+
* The name is the ONLY identity that's stable across re-runs: when a fixer pushes
|
|
53
|
+
* a follow-up commit, GitHub creates a fresh workflow run with a new `link` URL but
|
|
54
|
+
* the SAME check name. Keying off `link` (as an earlier draft did) would treat every
|
|
55
|
+
* re-run as a brand-new failure and spawn an unbounded chain of fixers (issue #338).
|
|
56
|
+
* Keying off the name means a re-run of the same check is recognised as the same
|
|
57
|
+
* logical failure; the per-PR wave counter then bounds how many times we retry it.
|
|
58
|
+
*/
|
|
59
|
+
export function checkDedupeKey(prUrl, check) {
|
|
60
|
+
return `ci:${prUrl}:${check.name}`;
|
|
61
|
+
}
|
|
62
|
+
/** Idempotency key for a review comment on a PR — dedupe by comment id. */
|
|
63
|
+
export function commentDedupeKey(prUrl, comment) {
|
|
64
|
+
return `review:${prUrl}:${comment.id}`;
|
|
65
|
+
}
|
|
66
|
+
/** Idempotency key for the one-shot "needs human" escalation on a PR. */
|
|
67
|
+
export function needsHumanKey(prUrl) {
|
|
68
|
+
return `needs-human:${prUrl}`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The pure heart of pr-watch: given a PR snapshot, the set of dedupe keys already
|
|
72
|
+
* acted on, and how many fix waves each PR has already spent, decide which
|
|
73
|
+
* follow-up teammates to spawn. Emits:
|
|
74
|
+
* - one `ci-fix` action per NEW failed check (dedup by check NAME)
|
|
75
|
+
* - one `review-fix` action per NEW review comment (dedup by comment id)
|
|
76
|
+
* - a single `needs-human` action (per PR) once the wave budget is exhausted,
|
|
77
|
+
* INSTEAD of spawning — so a persistently-failing PR escalates rather than
|
|
78
|
+
* spawning teammates without bound (issue #338).
|
|
79
|
+
*
|
|
80
|
+
* `waves` maps a PR URL to the number of fix/bugfix teammates already spawned for
|
|
81
|
+
* it; once that reaches `maxWaves`, no further spawns are emitted for that PR.
|
|
82
|
+
* Both the CI-fix and comment-routing paths draw from the SAME per-PR budget, so
|
|
83
|
+
* neither can loop unboundedly.
|
|
84
|
+
*
|
|
85
|
+
* No network, no side effects, no reference to state beyond the passed
|
|
86
|
+
* `handled` / `waves` — so the same failure never spawns twice and the caller
|
|
87
|
+
* stays in control of when a wave is "spent".
|
|
88
|
+
*/
|
|
89
|
+
export function decidePrActions(snapshot, handled, waves = new Map(), maxWaves = DEFAULT_MAX_WAVES) {
|
|
90
|
+
const actions = [];
|
|
91
|
+
const { prUrl } = snapshot;
|
|
92
|
+
// Waves already spent, plus the ones we're about to emit in THIS pass — so a
|
|
93
|
+
// single pass with several fresh failures can't blow past the budget.
|
|
94
|
+
let projected = waves.get(prUrl) ?? 0;
|
|
95
|
+
let escalated = false;
|
|
96
|
+
const escalate = (subject) => {
|
|
97
|
+
if (escalated)
|
|
98
|
+
return;
|
|
99
|
+
const dedupeKey = needsHumanKey(prUrl);
|
|
100
|
+
if (handled.has(dedupeKey))
|
|
101
|
+
return; // already escalated this PR — stay silent
|
|
102
|
+
escalated = true;
|
|
103
|
+
actions.push({
|
|
104
|
+
kind: 'needs-human',
|
|
105
|
+
prUrl,
|
|
106
|
+
sourceTeammate: snapshot.sourceTeammate,
|
|
107
|
+
subject,
|
|
108
|
+
waves: projected,
|
|
109
|
+
dedupeKey,
|
|
110
|
+
});
|
|
111
|
+
};
|
|
112
|
+
for (const check of snapshot.checks) {
|
|
113
|
+
if (!isFailedCheck(check))
|
|
114
|
+
continue;
|
|
115
|
+
const dedupeKey = checkDedupeKey(prUrl, check);
|
|
116
|
+
if (handled.has(dedupeKey))
|
|
117
|
+
continue;
|
|
118
|
+
if (projected >= maxWaves) {
|
|
119
|
+
escalate(`CI check "${check.name}"`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
projected++;
|
|
123
|
+
actions.push({
|
|
124
|
+
kind: 'ci-fix',
|
|
125
|
+
prUrl,
|
|
126
|
+
sourceTeammate: snapshot.sourceTeammate,
|
|
127
|
+
check,
|
|
128
|
+
dedupeKey,
|
|
129
|
+
wave: projected,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
for (const comment of snapshot.comments) {
|
|
133
|
+
const dedupeKey = commentDedupeKey(prUrl, comment);
|
|
134
|
+
if (handled.has(dedupeKey))
|
|
135
|
+
continue;
|
|
136
|
+
if (projected >= maxWaves) {
|
|
137
|
+
escalate(`review comment #${comment.id}`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
projected++;
|
|
141
|
+
actions.push({
|
|
142
|
+
kind: 'review-fix',
|
|
143
|
+
prUrl,
|
|
144
|
+
sourceTeammate: snapshot.sourceTeammate,
|
|
145
|
+
comment,
|
|
146
|
+
dedupeKey,
|
|
147
|
+
wave: projected,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return actions;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Build the task prompt for a CI-fix teammate. Pure so it's unit-testable: the
|
|
154
|
+
* failing check + the fetched logs are injected as data.
|
|
155
|
+
*/
|
|
156
|
+
export function buildCiFixPrompt(action, logs) {
|
|
157
|
+
const logsBlock = logs.trim()
|
|
158
|
+
? `\n\nCI failure logs:\n\`\`\`\n${logs.trim()}\n\`\`\``
|
|
159
|
+
: `\n\n(No CI logs could be fetched — inspect the run at ${action.check.link ?? action.prUrl}.)`;
|
|
160
|
+
return (`CI is RED on PR ${action.prUrl}. The check "${action.check.name}"` +
|
|
161
|
+
(action.check.workflow ? ` (workflow: ${action.check.workflow})` : '') +
|
|
162
|
+
` failed with state ${action.check.state}. ` +
|
|
163
|
+
`Diagnose the failure from the logs below, fix it, and push a follow-up commit to the SAME PR branch ` +
|
|
164
|
+
`(check out the PR branch with \`gh pr checkout ${action.prUrl}\`, make the fix, commit, and push). ` +
|
|
165
|
+
`Do not open a new PR — push to the existing branch so this PR goes green.` +
|
|
166
|
+
logsBlock);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Build the task prompt for a review-comment bugfix teammate. Pure so it's
|
|
170
|
+
* unit-testable: the review comment is injected as data.
|
|
171
|
+
*/
|
|
172
|
+
export function buildReviewFixPrompt(action) {
|
|
173
|
+
const c = action.comment;
|
|
174
|
+
const where = c.path ? ` on \`${c.path}\`` : '';
|
|
175
|
+
const who = c.user ? `@${c.user}` : 'A reviewer';
|
|
176
|
+
return (`${who} left a review comment${where} on PR ${action.prUrl}. ` +
|
|
177
|
+
`Address it and push a follow-up commit to the SAME PR branch ` +
|
|
178
|
+
`(check out the PR branch with \`gh pr checkout ${action.prUrl}\`, make the change, commit, and push). ` +
|
|
179
|
+
`Do not open a new PR.\n\n` +
|
|
180
|
+
`Review comment:\n${c.body}` +
|
|
181
|
+
(c.html_url ? `\n\n(thread: ${c.html_url})` : ''));
|
|
182
|
+
}
|
|
183
|
+
/** Parse an owner/repo/number triple out of a GitHub PR URL. Returns null when it doesn't match. */
|
|
184
|
+
export function parsePrUrl(prUrl) {
|
|
185
|
+
const m = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
|
|
186
|
+
if (!m)
|
|
187
|
+
return null;
|
|
188
|
+
return { owner: m[1], repo: m[2], number: Number(m[3]) };
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Impure collectors — thin `gh` CLI wrappers. Kept out of the pure decider so
|
|
192
|
+
// tests never hit the network. These are the seam the #331 webhook receiver
|
|
193
|
+
// would replace: a webhook payload can synthesize the same PrCheck /
|
|
194
|
+
// PrReviewComment shapes and hand them straight to decidePrActions.
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
/** Fetch the current CI checks for a PR via `gh pr checks`. Returns [] on any gh error. */
|
|
197
|
+
export async function fetchPrChecks(prUrl) {
|
|
198
|
+
try {
|
|
199
|
+
const { stdout } = await execFileAsync('gh', ['pr', 'checks', prUrl, '--json', 'name,state,link,workflow'], { maxBuffer: 8 * 1024 * 1024 });
|
|
200
|
+
const raw = JSON.parse(stdout);
|
|
201
|
+
return raw.map((r) => ({
|
|
202
|
+
name: String(r.name ?? ''),
|
|
203
|
+
state: String(r.state ?? ''),
|
|
204
|
+
link: r.link ? String(r.link) : undefined,
|
|
205
|
+
workflow: r.workflow ? String(r.workflow) : undefined,
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// `gh pr checks` exits non-zero when checks are failing OR when none are
|
|
210
|
+
// configured. Both surface as a throw here; treat "can't read" as "nothing
|
|
211
|
+
// to act on" rather than crashing the watch loop.
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Fetch review comments for a PR via `gh api`. Returns [] on any gh error. */
|
|
216
|
+
export async function fetchPrReviewComments(prUrl) {
|
|
217
|
+
const parsed = parsePrUrl(prUrl);
|
|
218
|
+
if (!parsed)
|
|
219
|
+
return [];
|
|
220
|
+
try {
|
|
221
|
+
const { stdout } = await execFileAsync('gh', [
|
|
222
|
+
'api',
|
|
223
|
+
`repos/${parsed.owner}/${parsed.repo}/pulls/${parsed.number}/comments`,
|
|
224
|
+
'--paginate',
|
|
225
|
+
], { maxBuffer: 16 * 1024 * 1024 });
|
|
226
|
+
const raw = JSON.parse(stdout);
|
|
227
|
+
return raw.map((r) => ({
|
|
228
|
+
id: Number(r.id),
|
|
229
|
+
body: String(r.body ?? ''),
|
|
230
|
+
user: r.user && typeof r.user === 'object'
|
|
231
|
+
? String(r.user.login ?? '')
|
|
232
|
+
: undefined,
|
|
233
|
+
path: r.path ? String(r.path) : undefined,
|
|
234
|
+
html_url: r.html_url ? String(r.html_url) : undefined,
|
|
235
|
+
}));
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/** Fetch the failing-run logs for a check via `gh run view --log-failed`. Best-effort, truncated. */
|
|
242
|
+
export async function fetchCiFailureLogs(check, maxChars = 8000) {
|
|
243
|
+
const runId = check.link?.match(/\/runs\/(\d+)/)?.[1] ?? check.link?.match(/\/actions\/runs\/(\d+)/)?.[1];
|
|
244
|
+
if (!runId)
|
|
245
|
+
return '';
|
|
246
|
+
try {
|
|
247
|
+
const { stdout } = await execFileAsync('gh', ['run', 'view', runId, '--log-failed'], { maxBuffer: 32 * 1024 * 1024 });
|
|
248
|
+
if (stdout.length <= maxChars)
|
|
249
|
+
return stdout;
|
|
250
|
+
// Keep the tail — the actual error is almost always at the end of the log.
|
|
251
|
+
return `... (truncated ${stdout.length - maxChars} chars) ...\n` + stdout.slice(-maxChars);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return '';
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/** Collect a live PR snapshot from `gh`. The poll-mode seam for `runPrWatch`. */
|
|
258
|
+
export async function pollPrSnapshot(prUrl, sourceTeammate) {
|
|
259
|
+
const [checks, comments] = await Promise.all([
|
|
260
|
+
fetchPrChecks(prUrl),
|
|
261
|
+
fetchPrReviewComments(prUrl),
|
|
262
|
+
]);
|
|
263
|
+
return { prUrl, sourceTeammate, checks, comments };
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Run the pr-watch poll loop. Each pass: retire the dedupe guards of any fixers
|
|
267
|
+
* that have finished (so a still-red check can spawn its next wave), resolve watch
|
|
268
|
+
* targets, snapshot each PR, decide actions against the running `handled` set and
|
|
269
|
+
* per-PR `waves` budget, then react — spawning fresh fixers, or emitting a
|
|
270
|
+
* one-shot `needs-human` event once a PR's wave budget is spent. Every acted-on
|
|
271
|
+
* dedupe key is recorded so a failure/comment never spawns twice within a wave,
|
|
272
|
+
* and `waves` hard-caps the total spawns per PR (issue #338).
|
|
273
|
+
*/
|
|
274
|
+
export async function runPrWatch(deps, opts = {}) {
|
|
275
|
+
const intervalMs = opts.intervalMs ?? 15000;
|
|
276
|
+
const maxPolls = opts.maxPolls ?? 0;
|
|
277
|
+
const maxWaves = opts.maxWaves ?? DEFAULT_MAX_WAVES;
|
|
278
|
+
const handled = opts.handled ?? new Set();
|
|
279
|
+
const waves = opts.waves ?? new Map();
|
|
280
|
+
const pollSnapshot = deps.pollSnapshot ?? pollPrSnapshot;
|
|
281
|
+
const fetchLogs = deps.fetchLogs ?? fetchCiFailureLogs;
|
|
282
|
+
const emit = deps.onEvent ?? (() => { });
|
|
283
|
+
// dedupeKey -> spawned teammate label, for the settle sweep. When a fixer
|
|
284
|
+
// finishes, its guard clears so the next wave (if still warranted) can fire.
|
|
285
|
+
const inFlight = new Map();
|
|
286
|
+
let polls = 0;
|
|
287
|
+
let spawned = 0;
|
|
288
|
+
let neededHuman = 0;
|
|
289
|
+
for (;;) {
|
|
290
|
+
if (opts.shouldStop?.()) {
|
|
291
|
+
return { polls, spawned, neededHuman, handled, waves, stoppedBy: 'signal' };
|
|
292
|
+
}
|
|
293
|
+
// Retire guards for finished fixers so a check that's STILL red after a fix
|
|
294
|
+
// can spawn its next wave (bounded by `waves`). Skipped when no settle probe
|
|
295
|
+
// is wired — then a check gets one wave until pr-watch restarts.
|
|
296
|
+
if (deps.reactionSettled && inFlight.size > 0) {
|
|
297
|
+
for (const [key, label] of [...inFlight]) {
|
|
298
|
+
let done = false;
|
|
299
|
+
try {
|
|
300
|
+
done = await deps.reactionSettled(label);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
done = false;
|
|
304
|
+
}
|
|
305
|
+
if (done) {
|
|
306
|
+
handled.delete(key);
|
|
307
|
+
inFlight.delete(key);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const targets = await deps.resolveTargets();
|
|
312
|
+
polls++;
|
|
313
|
+
emit({ type: 'poll', targets: targets.length, timestamp: new Date().toISOString() });
|
|
314
|
+
for (const target of targets) {
|
|
315
|
+
let snap;
|
|
316
|
+
try {
|
|
317
|
+
snap = await pollSnapshot(target.prUrl, target.sourceTeammate);
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
emit({
|
|
321
|
+
type: 'error',
|
|
322
|
+
prUrl: target.prUrl,
|
|
323
|
+
message: err.message,
|
|
324
|
+
timestamp: new Date().toISOString(),
|
|
325
|
+
});
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const actions = decidePrActions(snap, handled, waves, maxWaves);
|
|
329
|
+
for (const action of actions) {
|
|
330
|
+
// Record the key BEFORE reacting so a spawn failure can't loop-spam the
|
|
331
|
+
// same failure — a bad action is dropped, not retried forever.
|
|
332
|
+
handled.add(action.dedupeKey);
|
|
333
|
+
if (action.kind === 'needs-human') {
|
|
334
|
+
neededHuman++;
|
|
335
|
+
emit({
|
|
336
|
+
type: 'needs-human',
|
|
337
|
+
prUrl: action.prUrl,
|
|
338
|
+
subject: action.subject,
|
|
339
|
+
waves: action.waves,
|
|
340
|
+
timestamp: new Date().toISOString(),
|
|
341
|
+
});
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
// Spend a wave from this PR's budget for the spawn we're about to make.
|
|
345
|
+
waves.set(action.prUrl, (waves.get(action.prUrl) ?? 0) + 1);
|
|
346
|
+
const prompt = action.kind === 'ci-fix'
|
|
347
|
+
? buildCiFixPrompt(action, await fetchLogs(action.check))
|
|
348
|
+
: buildReviewFixPrompt(action);
|
|
349
|
+
try {
|
|
350
|
+
const label = await deps.react(action, prompt);
|
|
351
|
+
spawned++;
|
|
352
|
+
if (label)
|
|
353
|
+
inFlight.set(action.dedupeKey, label);
|
|
354
|
+
emit({ type: 'spawned', action, label, timestamp: new Date().toISOString() });
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
emit({
|
|
358
|
+
type: 'error',
|
|
359
|
+
prUrl: action.prUrl,
|
|
360
|
+
message: err.message,
|
|
361
|
+
timestamp: new Date().toISOString(),
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (maxPolls > 0 && polls >= maxPolls) {
|
|
367
|
+
return { polls, spawned, neededHuman, handled, waves, stoppedBy: 'max-polls' };
|
|
368
|
+
}
|
|
369
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
@@ -13,6 +13,7 @@ import * as path from 'path';
|
|
|
13
13
|
import { randomBytes } from 'crypto';
|
|
14
14
|
import lockfile from 'proper-lockfile';
|
|
15
15
|
import { getTeamsRegistryPath } from '../state.js';
|
|
16
|
+
import { emit } from '../events.js';
|
|
16
17
|
async function registryPath() {
|
|
17
18
|
return getTeamsRegistryPath();
|
|
18
19
|
}
|
|
@@ -98,39 +99,50 @@ export async function createTeam(name, options) {
|
|
|
98
99
|
throw new Error('Cannot use both --enable-worktrees and --use-worktree. Pick one.');
|
|
99
100
|
}
|
|
100
101
|
const p = await registryPath();
|
|
101
|
-
|
|
102
|
+
const meta = await withRegistryLock(p, async () => {
|
|
102
103
|
const reg = await loadTeams();
|
|
103
104
|
if (reg[name]) {
|
|
104
105
|
throw new Error(`Team '${name}' already exists`);
|
|
105
106
|
}
|
|
106
|
-
const
|
|
107
|
+
const m = {
|
|
107
108
|
created_at: new Date().toISOString(),
|
|
108
109
|
...(options?.description ? { description: options.description } : {}),
|
|
109
110
|
...(options?.enableWorktrees ? { enable_worktrees: true } : {}),
|
|
110
111
|
...(options?.useWorktree ? { use_worktree: options.useWorktree } : {}),
|
|
111
112
|
};
|
|
112
|
-
reg[name] =
|
|
113
|
+
reg[name] = m;
|
|
113
114
|
await saveTeams(reg);
|
|
114
|
-
return
|
|
115
|
+
return m;
|
|
115
116
|
});
|
|
117
|
+
// Audit the lifecycle boundary, not the CLI shell — captures every creation
|
|
118
|
+
// path (create + ensure) with team metadata the generic command log lacks.
|
|
119
|
+
emit('teams.create', { module: 'teams', team: name, worktrees: Boolean(options?.enableWorktrees || options?.useWorktree) });
|
|
120
|
+
return meta;
|
|
116
121
|
}
|
|
117
122
|
/** Return existing team metadata or create a new team if it does not exist. */
|
|
118
123
|
export async function ensureTeam(name) {
|
|
119
124
|
const p = await registryPath();
|
|
120
|
-
|
|
125
|
+
let created = false;
|
|
126
|
+
const meta = await withRegistryLock(p, async () => {
|
|
121
127
|
const reg = await loadTeams();
|
|
122
128
|
if (reg[name])
|
|
123
129
|
return reg[name];
|
|
124
|
-
const
|
|
125
|
-
reg[name] =
|
|
130
|
+
const m = { created_at: new Date().toISOString() };
|
|
131
|
+
reg[name] = m;
|
|
126
132
|
await saveTeams(reg);
|
|
127
|
-
|
|
133
|
+
created = true;
|
|
134
|
+
return m;
|
|
128
135
|
});
|
|
136
|
+
// `teams add` auto-creates the team on first teammate — audit that creation
|
|
137
|
+
// too, but only when it actually happened (not the get-existing path).
|
|
138
|
+
if (created)
|
|
139
|
+
emit('teams.create', { module: 'teams', team: name, worktrees: false });
|
|
140
|
+
return meta;
|
|
129
141
|
}
|
|
130
142
|
/** Remove a team from the registry. Returns false if the team did not exist. */
|
|
131
143
|
export async function removeTeam(name) {
|
|
132
144
|
const p = await registryPath();
|
|
133
|
-
|
|
145
|
+
const existed = await withRegistryLock(p, async () => {
|
|
134
146
|
const reg = await loadTeams();
|
|
135
147
|
if (!reg[name])
|
|
136
148
|
return false;
|
|
@@ -138,6 +150,10 @@ export async function removeTeam(name) {
|
|
|
138
150
|
await saveTeams(reg);
|
|
139
151
|
return true;
|
|
140
152
|
});
|
|
153
|
+
// "Disband" — only fires when a real team was removed, not a no-op.
|
|
154
|
+
if (existed)
|
|
155
|
+
emit('teams.disband', { module: 'teams', team: name });
|
|
156
|
+
return existed;
|
|
141
157
|
}
|
|
142
158
|
/** Check whether a team with the given name exists in the registry. */
|
|
143
159
|
export async function teamExists(name) {
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* terminal output, json-per-wave output, or a TUI.
|
|
19
19
|
*/
|
|
20
20
|
import type { AgentManager, AgentProcess } from './agents.js';
|
|
21
|
+
import type { TeamBudgetWatcher } from '../budget/live-team.js';
|
|
22
|
+
import type { BreachInfo } from '../budget/enforce.js';
|
|
21
23
|
export interface WaveSummary {
|
|
22
24
|
wave: number;
|
|
23
25
|
timestamp: string;
|
|
@@ -35,11 +37,22 @@ export interface SupervisorOptions {
|
|
|
35
37
|
maxWaves?: number;
|
|
36
38
|
/** Called once per wave. Return false to stop the loop gracefully. */
|
|
37
39
|
onWave: (summary: WaveSummary) => void | Promise<void> | boolean | Promise<boolean>;
|
|
40
|
+
/**
|
|
41
|
+
* Optional live-spend watcher (issue #399). Polled once per wave; on first
|
|
42
|
+
* breach the supervisor calls `manager.stopByTask(team)` and exits with
|
|
43
|
+
* `stoppedBy: 'budget'`. Dormant when null — supervisor behavior is unchanged
|
|
44
|
+
* for teams that never opt into budget enforcement.
|
|
45
|
+
*/
|
|
46
|
+
budgetWatcher?: TeamBudgetWatcher | null;
|
|
47
|
+
/** Called with the breach details when the budget watcher trips. */
|
|
48
|
+
onBudgetBreach?: (breach: BreachInfo) => void;
|
|
38
49
|
}
|
|
39
50
|
export interface SupervisorResult {
|
|
40
51
|
waves: number;
|
|
41
|
-
stoppedBy: 'drained' | 'max-waves' | 'signal' | 'callback';
|
|
52
|
+
stoppedBy: 'drained' | 'max-waves' | 'signal' | 'callback' | 'budget';
|
|
42
53
|
elapsed_ms: number;
|
|
54
|
+
/** Set when `stoppedBy === 'budget'` — the breach that terminated the team. */
|
|
55
|
+
budgetBreach?: BreachInfo;
|
|
43
56
|
}
|
|
44
57
|
/**
|
|
45
58
|
* Run the continuous DAG dispatcher until the team drains, the caller
|
|
@@ -45,6 +45,25 @@ export async function runSupervisor(mgr, opts) {
|
|
|
45
45
|
if (keepGoing === false) {
|
|
46
46
|
return { waves: wave, stoppedBy: 'callback', elapsed_ms: Date.now() - startedAt };
|
|
47
47
|
}
|
|
48
|
+
// Live budget kill (issue #399). Poll AFTER the wave's callback so the
|
|
49
|
+
// most recent teammate stdout is already flushed to disk. On breach we
|
|
50
|
+
// stop every RUNNING teammate — the DAG effectively drains this wave.
|
|
51
|
+
if (opts.budgetWatcher) {
|
|
52
|
+
await opts.budgetWatcher.poll();
|
|
53
|
+
if (opts.budgetWatcher.breached()) {
|
|
54
|
+
const breach = opts.budgetWatcher.breach();
|
|
55
|
+
await mgr.stopByTask(team);
|
|
56
|
+
if (breach && opts.onBudgetBreach)
|
|
57
|
+
opts.onBudgetBreach(breach);
|
|
58
|
+
opts.budgetWatcher.dispose();
|
|
59
|
+
return {
|
|
60
|
+
waves: wave,
|
|
61
|
+
stoppedBy: 'budget',
|
|
62
|
+
elapsed_ms: Date.now() - startedAt,
|
|
63
|
+
budgetBreach: breach ?? undefined,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
48
67
|
// Re-check drain AFTER the callback. The callback may have added new
|
|
49
68
|
// teammates mid-flight (that's the whole point of the dynamic DAG), so
|
|
50
69
|
// trusting the pre-callback snapshot would drain prematurely. Rescan
|
|
@@ -4,6 +4,15 @@ export declare function getGitRoot(dir: string): Promise<string>;
|
|
|
4
4
|
* Check if a worktree directory has uncommitted changes.
|
|
5
5
|
*/
|
|
6
6
|
export declare function hasUncommittedChanges(worktreePath: string): Promise<boolean>;
|
|
7
|
+
/** Default cap on diff text so one giant worktree can't blow up the serve dashboard. */
|
|
8
|
+
export declare const DEFAULT_DIFF_MAX_BYTES = 200000;
|
|
9
|
+
/**
|
|
10
|
+
* Return the uncommitted working-tree diff for a worktree (staged + unstaged,
|
|
11
|
+
* relative to HEAD), capped so a huge diff can't overwhelm the read-only serve
|
|
12
|
+
* dashboard. Read-only: shells out to `git diff HEAD` and never mutates state.
|
|
13
|
+
* Returns '' when the path isn't a git worktree or has no pending changes.
|
|
14
|
+
*/
|
|
15
|
+
export declare function gitDiff(worktreePath: string, maxBytes?: number): Promise<string>;
|
|
7
16
|
/**
|
|
8
17
|
* Create a new git worktree for a teammate.
|
|
9
18
|
*
|
|
@@ -36,6 +36,38 @@ export async function hasUncommittedChanges(worktreePath) {
|
|
|
36
36
|
return false;
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
|
+
/** Default cap on diff text so one giant worktree can't blow up the serve dashboard. */
|
|
40
|
+
export const DEFAULT_DIFF_MAX_BYTES = 200_000;
|
|
41
|
+
/**
|
|
42
|
+
* Return the uncommitted working-tree diff for a worktree (staged + unstaged,
|
|
43
|
+
* relative to HEAD), capped so a huge diff can't overwhelm the read-only serve
|
|
44
|
+
* dashboard. Read-only: shells out to `git diff HEAD` and never mutates state.
|
|
45
|
+
* Returns '' when the path isn't a git worktree or has no pending changes.
|
|
46
|
+
*/
|
|
47
|
+
export async function gitDiff(worktreePath, maxBytes = DEFAULT_DIFF_MAX_BYTES) {
|
|
48
|
+
// Size the capture buffer off the cap (not an arbitrary 1MB floor) so a diff
|
|
49
|
+
// that overshoots gets TRUNCATED, never silently dropped. A diff larger than
|
|
50
|
+
// the buffer overflows maxBuffer, but Node still hands us the captured prefix
|
|
51
|
+
// on the rejection — we truncate that below rather than losing it to ''.
|
|
52
|
+
const maxBuffer = maxBytes * 4;
|
|
53
|
+
const truncate = (s) => s.slice(0, maxBytes) + `\n… [diff truncated at ${maxBytes} bytes]`;
|
|
54
|
+
try {
|
|
55
|
+
const { stdout } = await execFileAsync('git', ['diff', 'HEAD'], {
|
|
56
|
+
cwd: worktreePath,
|
|
57
|
+
maxBuffer,
|
|
58
|
+
});
|
|
59
|
+
return stdout.length > maxBytes ? truncate(stdout) : stdout;
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
// ENOBUFS on an oversized diff still carries the partial capture; truncate
|
|
63
|
+
// it so the dashboard shows "[diff truncated]" instead of "no changes".
|
|
64
|
+
// A genuine non-git-worktree error has no stdout → fall through to ''.
|
|
65
|
+
const partial = err?.stdout;
|
|
66
|
+
if (typeof partial === 'string' && partial.length > 0)
|
|
67
|
+
return truncate(partial);
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
39
71
|
/**
|
|
40
72
|
* Create a new git worktree for a teammate.
|
|
41
73
|
*
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { TerminalBackend, SplitDirection } from '../types.js';
|
|
2
|
+
/** AppleScript that opens a Ghostty tab (a window if none is open). */
|
|
3
|
+
export declare function ghosttyTabScript(cwd: string, command: string[]): string;
|
|
4
|
+
/** AppleScript that splits the current Ghostty surface (a window if none is open). */
|
|
5
|
+
export declare function ghosttySplitScript(cwd: string, command: string[], direction: SplitDirection): string;
|
|
6
|
+
export declare const ghosttyBackend: TerminalBackend;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ghostty backend — drives Ghostty (>= 1.3) via AppleScript (`osascript`).
|
|
3
|
+
*
|
|
4
|
+
* Ghostty's `new surface configuration` record carries the working directory and
|
|
5
|
+
* command natively, so no `cd` wrapper is needed. Tab: `new tab` (or a window
|
|
6
|
+
* when none is open). Split: `split <surface> direction right|down`, where the
|
|
7
|
+
* current surface is `focused terminal of selected tab of front window`
|
|
8
|
+
* (verified against Ghostty 1.3.1 — a surface is a "terminal" in its AS model).
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import { appleScriptStr } from '../quote.js';
|
|
12
|
+
import { execOnly, iLoginShell } from '../shell.js';
|
|
13
|
+
const GHOSTTY_APP = '/Applications/Ghostty.app';
|
|
14
|
+
function appExists(p) {
|
|
15
|
+
try {
|
|
16
|
+
return fs.existsSync(p);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Shared prologue: activate + a surface configuration carrying cwd + command. */
|
|
23
|
+
function configPrologue(cwd, command) {
|
|
24
|
+
const cmd = appleScriptStr(iLoginShell(execOnly(command)));
|
|
25
|
+
return [
|
|
26
|
+
'tell application "Ghostty"',
|
|
27
|
+
' activate',
|
|
28
|
+
' set cfg to new surface configuration',
|
|
29
|
+
` set initial working directory of cfg to ${appleScriptStr(cwd)}`,
|
|
30
|
+
` set command of cfg to ${cmd}`,
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
/** AppleScript that opens a Ghostty tab (a window if none is open). */
|
|
34
|
+
export function ghosttyTabScript(cwd, command) {
|
|
35
|
+
return [
|
|
36
|
+
...configPrologue(cwd, command),
|
|
37
|
+
' if (count of windows) is 0 then',
|
|
38
|
+
' new window with configuration cfg',
|
|
39
|
+
' else',
|
|
40
|
+
' new tab in front window with configuration cfg',
|
|
41
|
+
' end if',
|
|
42
|
+
'end tell',
|
|
43
|
+
].join('\n');
|
|
44
|
+
}
|
|
45
|
+
/** AppleScript that splits the current Ghostty surface (a window if none is open). */
|
|
46
|
+
export function ghosttySplitScript(cwd, command, direction) {
|
|
47
|
+
return [
|
|
48
|
+
...configPrologue(cwd, command),
|
|
49
|
+
' if (count of windows) is 0 then',
|
|
50
|
+
' new window with configuration cfg',
|
|
51
|
+
' else',
|
|
52
|
+
` split (focused terminal of selected tab of front window) direction ${direction} with configuration cfg`,
|
|
53
|
+
' end if',
|
|
54
|
+
'end tell',
|
|
55
|
+
].join('\n');
|
|
56
|
+
}
|
|
57
|
+
export const ghosttyBackend = {
|
|
58
|
+
id: 'ghostty',
|
|
59
|
+
label: 'Ghostty',
|
|
60
|
+
isAvailable(ctx) {
|
|
61
|
+
return ctx.platform === 'darwin' && appExists(GHOSTTY_APP);
|
|
62
|
+
},
|
|
63
|
+
buildTab(cwd, command) {
|
|
64
|
+
return { argv: ['osascript', '-e', ghosttyTabScript(cwd, command)] };
|
|
65
|
+
},
|
|
66
|
+
buildSplit(cwd, command, direction) {
|
|
67
|
+
return { argv: ['osascript', '-e', ghosttySplitScript(cwd, command, direction)] };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend registry + current-terminal detection.
|
|
3
|
+
*/
|
|
4
|
+
import type { Backend, EngineContext, TerminalBackend } from '../types.js';
|
|
5
|
+
import { itermBackend } from './iterm.js';
|
|
6
|
+
import { ghosttyBackend } from './ghostty.js';
|
|
7
|
+
import { tmuxBackend } from './tmux.js';
|
|
8
|
+
import { vscodiumAgentBackend } from './vscodium-agent.js';
|
|
9
|
+
/** All known interactive backends, keyed by id. */
|
|
10
|
+
export declare const BACKENDS: Record<Backend, TerminalBackend>;
|
|
11
|
+
/**
|
|
12
|
+
* The backend for the terminal the CLI is currently running in, or null if we
|
|
13
|
+
* can't open a surface into it. tmux wins (via `$TMUX`) because a tmux pane can
|
|
14
|
+
* live inside iTerm/Ghostty; otherwise fall back to `TERM_PROGRAM`.
|
|
15
|
+
*/
|
|
16
|
+
export declare function detectCurrentBackend(ctx: EngineContext): Backend | null;
|
|
17
|
+
/** Backends that can actually be driven in this context. */
|
|
18
|
+
export declare function availableBackends(ctx: EngineContext): TerminalBackend[];
|
|
19
|
+
export { itermBackend, ghosttyBackend, tmuxBackend, vscodiumAgentBackend };
|