@phnx-labs/agents-cli 1.20.35 → 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 +91 -0
- package/LICENSE +185 -21
- package/README.md +8 -4
- 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.js +81 -0
- package/dist/commands/daemon.js +4 -1
- package/dist/commands/doctor.js +1 -89
- package/dist/commands/events.js +3 -3
- package/dist/commands/exec.d.ts +19 -0
- package/dist/commands/exec.js +277 -11
- package/dist/commands/hosts.js +10 -6
- package/dist/commands/inspect.js +8 -9
- package/dist/commands/lock.d.ts +12 -0
- package/dist/commands/lock.js +70 -0
- package/dist/commands/message.d.ts +15 -0
- package/dist/commands/message.js +56 -0
- 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 +156 -75
- 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.js +20 -8
- package/dist/commands/sessions.d.ts +71 -1
- package/dist/commands/sessions.js +365 -37
- 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/watchdog.d.ts +18 -0
- package/dist/commands/watchdog.js +238 -0
- package/dist/index.js +25 -2
- 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/chrome.d.ts +10 -0
- package/dist/lib/browser/chrome.js +18 -7
- package/dist/lib/browser/drivers/ssh.js +2 -1
- package/dist/lib/browser/har.d.ts +84 -0
- package/dist/lib/browser/har.js +77 -0
- package/dist/lib/browser/ipc.js +24 -3
- 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 +112 -8
- 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 +57 -0
- package/dist/lib/daemon.js +192 -16
- 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 +1 -1
- package/dist/lib/events.js +31 -13
- package/dist/lib/exec.d.ts +17 -0
- package/dist/lib/exec.js +79 -13
- 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 +127 -8
- package/dist/lib/hosts/dispatch.d.ts +15 -0
- package/dist/lib/hosts/dispatch.js +39 -6
- package/dist/lib/hosts/logs.js +30 -1
- package/dist/lib/hosts/option.js +1 -1
- package/dist/lib/hosts/passthrough.js +3 -1
- package/dist/lib/hosts/ready.d.ts +29 -6
- package/dist/lib/hosts/ready.js +66 -15
- 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 +14 -0
- package/dist/lib/hosts/tasks.js +15 -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/picker.d.ts +2 -0
- package/dist/lib/picker.js +4 -1
- 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/plugins.js +16 -6
- 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 +54 -0
- package/dist/lib/session/active.js +190 -19
- package/dist/lib/session/discover.d.ts +37 -0
- package/dist/lib/session/discover.js +111 -28
- package/dist/lib/session/inject.d.ts +18 -0
- package/dist/lib/session/inject.js +21 -0
- package/dist/lib/session/parse.js +23 -20
- package/dist/lib/session/pid-registry.d.ts +1 -0
- package/dist/lib/session/pid-registry.js +24 -0
- package/dist/lib/session/provenance.d.ts +14 -2
- package/dist/lib/session/provenance.js +39 -8
- package/dist/lib/session/remote-active.js +19 -7
- 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 +16 -2
- package/dist/lib/session/sync/config.d.ts +1 -15
- package/dist/lib/session/sync/config.js +4 -20
- package/dist/lib/session/types.d.ts +17 -0
- package/dist/lib/shims.d.ts +36 -6
- package/dist/lib/shims.js +91 -29
- package/dist/lib/ssh-exec.js +2 -0
- package/dist/lib/ssh-tunnel.js +2 -1
- package/dist/lib/startup/command-registry.d.ts +6 -0
- package/dist/lib/startup/command-registry.js +13 -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/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/index.d.ts +2 -1
- package/dist/lib/terminal/backends/index.js +3 -1
- 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/index.d.ts +4 -1
- package/dist/lib/terminal/index.js +4 -1
- package/dist/lib/terminal/inject.d.ts +204 -0
- package/dist/lib/terminal/inject.js +247 -0
- package/dist/lib/terminal/resolve.d.ts +64 -0
- package/dist/lib/terminal/resolve.js +90 -0
- package/dist/lib/terminal/types.d.ts +1 -1
- package/dist/lib/triggers/webhook.d.ts +85 -0
- package/dist/lib/triggers/webhook.js +141 -0
- package/dist/lib/versions.d.ts +23 -0
- package/dist/lib/versions.js +119 -13
- 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/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
|
+
}
|
|
@@ -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
|
*
|
|
@@ -5,6 +5,7 @@ import type { Backend, EngineContext, TerminalBackend } from '../types.js';
|
|
|
5
5
|
import { itermBackend } from './iterm.js';
|
|
6
6
|
import { ghosttyBackend } from './ghostty.js';
|
|
7
7
|
import { tmuxBackend } from './tmux.js';
|
|
8
|
+
import { vscodiumAgentBackend } from './vscodium-agent.js';
|
|
8
9
|
/** All known interactive backends, keyed by id. */
|
|
9
10
|
export declare const BACKENDS: Record<Backend, TerminalBackend>;
|
|
10
11
|
/**
|
|
@@ -15,4 +16,4 @@ export declare const BACKENDS: Record<Backend, TerminalBackend>;
|
|
|
15
16
|
export declare function detectCurrentBackend(ctx: EngineContext): Backend | null;
|
|
16
17
|
/** Backends that can actually be driven in this context. */
|
|
17
18
|
export declare function availableBackends(ctx: EngineContext): TerminalBackend[];
|
|
18
|
-
export { itermBackend, ghosttyBackend, tmuxBackend };
|
|
19
|
+
export { itermBackend, ghosttyBackend, tmuxBackend, vscodiumAgentBackend };
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { itermBackend } from './iterm.js';
|
|
2
2
|
import { ghosttyBackend } from './ghostty.js';
|
|
3
3
|
import { tmuxBackend } from './tmux.js';
|
|
4
|
+
import { vscodiumAgentBackend } from './vscodium-agent.js';
|
|
4
5
|
/** All known interactive backends, keyed by id. */
|
|
5
6
|
export const BACKENDS = {
|
|
6
7
|
iterm: itermBackend,
|
|
7
8
|
ghostty: ghosttyBackend,
|
|
8
9
|
tmux: tmuxBackend,
|
|
10
|
+
'vscodium-agent': vscodiumAgentBackend,
|
|
9
11
|
};
|
|
10
12
|
/**
|
|
11
13
|
* The backend for the terminal the CLI is currently running in, or null if we
|
|
@@ -26,4 +28,4 @@ export function detectCurrentBackend(ctx) {
|
|
|
26
28
|
export function availableBackends(ctx) {
|
|
27
29
|
return Object.values(BACKENDS).filter((b) => b.isAvailable(ctx));
|
|
28
30
|
}
|
|
29
|
-
export { itermBackend, ghosttyBackend, tmuxBackend };
|
|
31
|
+
export { itermBackend, ghosttyBackend, tmuxBackend, vscodiumAgentBackend };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { TerminalBackend, SplitDirection } from '../types.js';
|
|
2
|
+
/** An editor flavour that speaks the swarm-ext URI protocol. */
|
|
3
|
+
export interface EditorVariant {
|
|
4
|
+
/** CLI on PATH, invoked with `--open-url`. */
|
|
5
|
+
cli: string;
|
|
6
|
+
/** URL scheme the product registers (must match the CLI's product). */
|
|
7
|
+
scheme: string;
|
|
8
|
+
/** macOS app bundle, for local availability detection. */
|
|
9
|
+
app: string;
|
|
10
|
+
label: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Known editors, in preference order. VSCodium first — the backend is named for
|
|
14
|
+
* it and is the user's editor; Cursor and VS Code also ship the extension and
|
|
15
|
+
* are wired here for `makeVscodiumAgentBackend` / future registration.
|
|
16
|
+
*/
|
|
17
|
+
export declare const EDITOR_VARIANTS: EditorVariant[];
|
|
18
|
+
/**
|
|
19
|
+
* The `<scheme>://swarmify.swarm-ext/spawn?p=<payload>` URL the extension handles.
|
|
20
|
+
*
|
|
21
|
+
* The payload is base64url-encoded JSON in a single `p` param — NOT one param per
|
|
22
|
+
* field. VS Code percent-*decodes* `uri.query` once before the extension parses
|
|
23
|
+
* it, so a `command`/`cwd` containing `&` (or `=`) would be mis-split by a naive
|
|
24
|
+
* multi-param query. base64url is `[A-Za-z0-9_-]` only (no `&`, `=`, `%`, `+`,
|
|
25
|
+
* `/`), so it survives that decode untouched and round-trips exactly.
|
|
26
|
+
*/
|
|
27
|
+
export declare function spawnUri(scheme: string, cwd: string, command: string[], direction?: SplitDirection): string;
|
|
28
|
+
/** Build a backend bound to one editor variant. */
|
|
29
|
+
export declare function makeVscodiumAgentBackend(variant: EditorVariant): TerminalBackend;
|
|
30
|
+
/** Default backend: VSCodium (`codium` CLI, `vscodium://` scheme). */
|
|
31
|
+
export declare const vscodiumAgentBackend: TerminalBackend;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VSCodium agent-terminal backend — opens an agent terminal in a VSCodium /
|
|
3
|
+
* Cursor / VS Code window via the swarmify `swarm-ext` extension's URI handler.
|
|
4
|
+
*
|
|
5
|
+
* Unlike iTerm/Ghostty (GUI terminal apps driven by AppleScript), the editor is
|
|
6
|
+
* already running; we hand it a `<scheme>://swarmify.swarm-ext/spawn?…` URL via
|
|
7
|
+
* the editor CLI's `--open-url` flag. The extension opens an editor-tab terminal
|
|
8
|
+
* in `cwd`, runs `command`, and its shell-adoption promotes a resume command
|
|
9
|
+
* (e.g. `claude --resume <id>`) to the matching agent chip. Driving the editor
|
|
10
|
+
* CLI (not macOS `open`) means this works over `--host` SSH and on Linux, needs
|
|
11
|
+
* no OS URL-scheme handler registration, and sends the command into an
|
|
12
|
+
* already-interactive login shell — so no `zsh -ilc` wrap (the other backends'
|
|
13
|
+
* wrapper) is applied here.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
/** The swarmify extension identifier that owns the `/spawn` URI verb. */
|
|
17
|
+
const EXTENSION_AUTHORITY = 'swarmify.swarm-ext';
|
|
18
|
+
/**
|
|
19
|
+
* Known editors, in preference order. VSCodium first — the backend is named for
|
|
20
|
+
* it and is the user's editor; Cursor and VS Code also ship the extension and
|
|
21
|
+
* are wired here for `makeVscodiumAgentBackend` / future registration.
|
|
22
|
+
*/
|
|
23
|
+
export const EDITOR_VARIANTS = [
|
|
24
|
+
{ cli: 'codium', scheme: 'vscodium', app: '/Applications/VSCodium.app', label: 'VSCodium' },
|
|
25
|
+
{ cli: 'cursor', scheme: 'cursor', app: '/Applications/Cursor.app', label: 'Cursor' },
|
|
26
|
+
{ cli: 'code', scheme: 'vscode', app: '/Applications/Visual Studio Code.app', label: 'VS Code' },
|
|
27
|
+
];
|
|
28
|
+
function appExists(p) {
|
|
29
|
+
try {
|
|
30
|
+
return fs.existsSync(p);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The `<scheme>://swarmify.swarm-ext/spawn?p=<payload>` URL the extension handles.
|
|
38
|
+
*
|
|
39
|
+
* The payload is base64url-encoded JSON in a single `p` param — NOT one param per
|
|
40
|
+
* field. VS Code percent-*decodes* `uri.query` once before the extension parses
|
|
41
|
+
* it, so a `command`/`cwd` containing `&` (or `=`) would be mis-split by a naive
|
|
42
|
+
* multi-param query. base64url is `[A-Za-z0-9_-]` only (no `&`, `=`, `%`, `+`,
|
|
43
|
+
* `/`), so it survives that decode untouched and round-trips exactly.
|
|
44
|
+
*/
|
|
45
|
+
export function spawnUri(scheme, cwd, command, direction) {
|
|
46
|
+
const payload = {
|
|
47
|
+
command: command.join(' '),
|
|
48
|
+
cwd,
|
|
49
|
+
};
|
|
50
|
+
if (direction)
|
|
51
|
+
payload.split = direction;
|
|
52
|
+
const p = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
53
|
+
return `${scheme}://${EXTENSION_AUTHORITY}/spawn?p=${p}`;
|
|
54
|
+
}
|
|
55
|
+
/** Build a backend bound to one editor variant. */
|
|
56
|
+
export function makeVscodiumAgentBackend(variant) {
|
|
57
|
+
return {
|
|
58
|
+
id: 'vscodium-agent',
|
|
59
|
+
label: `${variant.label} agent`,
|
|
60
|
+
isAvailable(ctx) {
|
|
61
|
+
return ctx.platform === 'darwin' && appExists(variant.app);
|
|
62
|
+
},
|
|
63
|
+
buildTab(cwd, command) {
|
|
64
|
+
return { argv: [variant.cli, '--open-url', spawnUri(variant.scheme, cwd, command)] };
|
|
65
|
+
},
|
|
66
|
+
buildSplit(cwd, command, direction) {
|
|
67
|
+
return { argv: [variant.cli, '--open-url', spawnUri(variant.scheme, cwd, command, direction)] };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Default backend: VSCodium (`codium` CLI, `vscodium://` scheme). */
|
|
72
|
+
export const vscodiumAgentBackend = makeVscodiumAgentBackend(EDITOR_VARIANTS[0]);
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export type { Backend, SplitDirection, Layout, EngineContext, LaunchSpec, LaunchRequest, LaunchResult, TerminalBackend, } from './types.js';
|
|
10
10
|
export { currentContext } from './types.js';
|
|
11
|
-
export { BACKENDS, detectCurrentBackend, availableBackends, itermBackend, ghosttyBackend, tmuxBackend } from './backends/index.js';
|
|
11
|
+
export { BACKENDS, detectCurrentBackend, availableBackends, itermBackend, ghosttyBackend, tmuxBackend, vscodiumAgentBackend } from './backends/index.js';
|
|
12
|
+
export { makeVscodiumAgentBackend, spawnUri, EDITOR_VARIANTS, type EditorVariant } from './backends/vscodium-agent.js';
|
|
12
13
|
export { planLayouts, type Packing } from './policy.js';
|
|
13
14
|
export { specForRequest, buildRequests, openSurface, openSurfaces, type OpenOptions, type OpenManyOptions, type BuildRequestsOptions, type SurfaceItem, } from './engine.js';
|
|
14
15
|
export { runLocal, runRemote, runSpec, remoteCommand, type HostResolver, type RunResult } from './transport.js';
|
|
16
|
+
export { injectIntoTerminal, tmuxSendKeysArgv, tmuxInjectSpecs, itermInjectScript, ghosttyInjectScript, appleScriptInjectSpec, vscodiumInjectUri, vscodiumInjectSpec, type InjectTarget, type InjectBackend, type InjectOptions, type InjectResult, } from './inject.js';
|
|
17
|
+
export { resolveInjectTarget, resolveInjectTargetForSession, type InjectResolution, type InjectRail, type ResolveOptions, } from './resolve.js';
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export { currentContext } from './types.js';
|
|
2
|
-
export { BACKENDS, detectCurrentBackend, availableBackends, itermBackend, ghosttyBackend, tmuxBackend } from './backends/index.js';
|
|
2
|
+
export { BACKENDS, detectCurrentBackend, availableBackends, itermBackend, ghosttyBackend, tmuxBackend, vscodiumAgentBackend } from './backends/index.js';
|
|
3
|
+
export { makeVscodiumAgentBackend, spawnUri, EDITOR_VARIANTS } from './backends/vscodium-agent.js';
|
|
3
4
|
export { planLayouts } from './policy.js';
|
|
4
5
|
export { specForRequest, buildRequests, openSurface, openSurfaces, } from './engine.js';
|
|
5
6
|
export { runLocal, runRemote, runSpec, remoteCommand } from './transport.js';
|
|
7
|
+
export { injectIntoTerminal, tmuxSendKeysArgv, tmuxInjectSpecs, itermInjectScript, ghosttyInjectScript, appleScriptInjectSpec, vscodiumInjectUri, vscodiumInjectSpec, } from './inject.js';
|
|
8
|
+
export { resolveInjectTarget, resolveInjectTargetForSession, } from './resolve.js';
|