airgate 0.1.4 → 0.1.6
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/README.md +18 -1
- package/dist/airgate.mjs +657 -72
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
⊠ airgate give your AI its own computer
|
|
5
5
|
```
|
|
6
6
|
|
|
7
|
-
A private cloud computer for Claude Code, Codex or any AI agent, ready in about two seconds
|
|
7
|
+
A private cloud computer for Claude Code, Codex or any AI agent, ready in about two seconds, and
|
|
8
|
+
crews of them that build, review and test one task together. It
|
|
8
9
|
can't touch your laptop, keeps working when you close it, and every step can be undone.
|
|
9
10
|
Always reachable, billed while working. [airgate.sh](https://airgate.sh)
|
|
10
11
|
|
|
@@ -28,6 +29,22 @@ airgate up # move the session you're in to a pod; airgate down brings the
|
|
|
28
29
|
airgate help # every command
|
|
29
30
|
```
|
|
30
31
|
|
|
32
|
+
## Crews
|
|
33
|
+
|
|
34
|
+
Several agents, each in its own pod, on one task: they build, review, test and argue it out, and
|
|
35
|
+
you get one patch back.
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
airgate crew contest "add sign-in with GitHub" # one builds, another reviews until it holds
|
|
39
|
+
airgate crew race "make search faster" # several try it, a judge picks the best
|
|
40
|
+
airgate crew council "pick a job queue" --build # plans, critiques, a decision, then the build
|
|
41
|
+
airgate crew squad "add CSV export" # testers write tests and hunt bugs
|
|
42
|
+
airgate crew split "add team billing" # parts in parallel, then merged
|
|
43
|
+
airgate crew down <id> # bring the result back here
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Guide: https://airgate.sh/docs/crews
|
|
47
|
+
|
|
31
48
|
## For your agent
|
|
32
49
|
|
|
33
50
|
Claude Code or Codex on your laptop can send work to pods on its own: one task in a fresh pod
|
package/dist/airgate.mjs
CHANGED
|
@@ -431,6 +431,7 @@ var Api = class {
|
|
|
431
431
|
if (e?.name === "AbortError") throw e;
|
|
432
432
|
throw new NetworkError(this.gateway, e);
|
|
433
433
|
}
|
|
434
|
+
if (opts2.bytes && res.ok) return Buffer.from(await res.arrayBuffer());
|
|
434
435
|
const text2 = await res.text();
|
|
435
436
|
if (opts2.text && res.ok) return text2;
|
|
436
437
|
let data = {};
|
|
@@ -530,11 +531,11 @@ var Api = class {
|
|
|
530
531
|
checkpoint(id, label2) {
|
|
531
532
|
return this.request("POST", `/v1/pods/${enc(id)}/checkpoints`, { body: { label: label2 } }).then((r) => r.pod);
|
|
532
533
|
}
|
|
533
|
-
restore(id,
|
|
534
|
-
return this.request("POST", `/v1/pods/${enc(id)}/restore`, { body: { checkpointId } }).then((r) => r.pod);
|
|
534
|
+
restore(id, checkpointId2) {
|
|
535
|
+
return this.request("POST", `/v1/pods/${enc(id)}/restore`, { body: { checkpointId: checkpointId2 } }).then((r) => r.pod);
|
|
535
536
|
}
|
|
536
|
-
fork(id,
|
|
537
|
-
return this.request("POST", `/v1/pods/${enc(id)}/fork`, { body: { checkpointId, overrides } }).then((r) => r.pod);
|
|
537
|
+
fork(id, checkpointId2, overrides) {
|
|
538
|
+
return this.request("POST", `/v1/pods/${enc(id)}/fork`, { body: { checkpointId: checkpointId2, overrides } }).then((r) => r.pod);
|
|
538
539
|
}
|
|
539
540
|
diff(id, base) {
|
|
540
541
|
return this.request("GET", `/v1/pods/${enc(id)}/diff?base=${encodeURIComponent(base)}`);
|
|
@@ -580,6 +581,38 @@ var Api = class {
|
|
|
580
581
|
githubDisconnect(id) {
|
|
581
582
|
return this.request("DELETE", `/v1/github/installations/${id}`);
|
|
582
583
|
}
|
|
584
|
+
/* a workspace's files, live or at a checkpoint */
|
|
585
|
+
files(id, path = "", checkpoint2) {
|
|
586
|
+
const q = new URLSearchParams({ path, ...checkpoint2 ? { checkpoint: checkpoint2 } : {} });
|
|
587
|
+
return this.request("GET", `/v1/pods/${enc(id)}/files?${q}`);
|
|
588
|
+
}
|
|
589
|
+
fileRaw(id, path, checkpoint2) {
|
|
590
|
+
const q = new URLSearchParams({ path, ...checkpoint2 ? { checkpoint: checkpoint2 } : {} });
|
|
591
|
+
return this.request("GET", `/v1/pods/${enc(id)}/files/raw?${q}`, { bytes: true });
|
|
592
|
+
}
|
|
593
|
+
/** after launch: the network and the AI switch */
|
|
594
|
+
updatePod(id, patch) {
|
|
595
|
+
return this.request("PATCH", `/v1/pods/${enc(id)}`, { body: patch }).then((r) => r.pod);
|
|
596
|
+
}
|
|
597
|
+
/* saved snapshots */
|
|
598
|
+
snapshots() {
|
|
599
|
+
return this.request("GET", "/v1/snapshots").then((r) => r.snapshots);
|
|
600
|
+
}
|
|
601
|
+
saveSnapshot(id, name, checkpointId2) {
|
|
602
|
+
return this.request("POST", `/v1/pods/${enc(id)}/snapshots`, { body: { name, ...checkpointId2 ? { checkpointId: checkpointId2 } : {} } }).then((r) => r.snapshot);
|
|
603
|
+
}
|
|
604
|
+
sharedSnapshot(code3) {
|
|
605
|
+
return this.request("GET", `/v1/snapshots/shared/${enc(code3)}`).then((r) => r.snapshot);
|
|
606
|
+
}
|
|
607
|
+
launchSnapshot(ref, overrides = {}) {
|
|
608
|
+
return this.request("POST", `/v1/snapshots/${enc(ref)}/launch`, { body: { overrides } }).then((r) => r.pod);
|
|
609
|
+
}
|
|
610
|
+
shareSnapshot(ref, on) {
|
|
611
|
+
return this.request(on ? "POST" : "DELETE", `/v1/snapshots/${enc(ref)}/share`).then((r) => r.snapshot);
|
|
612
|
+
}
|
|
613
|
+
deleteSnapshot(ref) {
|
|
614
|
+
return this.request("DELETE", `/v1/snapshots/${enc(ref)}`);
|
|
615
|
+
}
|
|
583
616
|
/* model sign-ins: connected to airgate (opt-in), and whether a laptop relay is connected */
|
|
584
617
|
modelsAuth() {
|
|
585
618
|
return this.request("GET", "/v1/models/auth");
|
|
@@ -1951,7 +1984,7 @@ function borderOf(name, v) {
|
|
|
1951
1984
|
return { chars: chars3, title, color, pad: pad3 };
|
|
1952
1985
|
}
|
|
1953
1986
|
function box(s2, { x, y, cols: w, rows: h }, chars3, title, color) {
|
|
1954
|
-
const [tl, tr, bl, br, across2,
|
|
1987
|
+
const [tl, tr, bl, br, across2, down4] = Array.from(chars3, (ch) => ch.charCodeAt(0));
|
|
1955
1988
|
const set = (cx, cy, ch) => {
|
|
1956
1989
|
const i = s2.index(cx, cy);
|
|
1957
1990
|
if (i >= 0)
|
|
@@ -1960,7 +1993,7 @@ function box(s2, { x, y, cols: w, rows: h }, chars3, title, color) {
|
|
|
1960
1993
|
for (let c = 1; c < w - 1; c++)
|
|
1961
1994
|
set(x + c, y, across2), set(x + c, y + h - 1, across2);
|
|
1962
1995
|
for (let r = 1; r < h - 1; r++)
|
|
1963
|
-
set(x, y + r,
|
|
1996
|
+
set(x, y + r, down4), set(x + w - 1, y + r, down4);
|
|
1964
1997
|
set(x, y, tl), set(x + w - 1, y, tr), set(x, y + h - 1, bl), set(x + w - 1, y + h - 1, br);
|
|
1965
1998
|
if (title && w >= 7) {
|
|
1966
1999
|
const room = w - 6;
|
|
@@ -2951,10 +2984,10 @@ function outline(src, options) {
|
|
|
2951
2984
|
if (!line2(x, y))
|
|
2952
2985
|
continue;
|
|
2953
2986
|
const up2 = line2(x, y - 1) && (is(solid, x - 1, y) || is(solid, x - 1, y - 1) || is(solid, x + 1, y) || is(solid, x + 1, y - 1));
|
|
2954
|
-
const
|
|
2987
|
+
const down4 = line2(x, y + 1) && (is(solid, x - 1, y) || is(solid, x - 1, y + 1) || is(solid, x + 1, y) || is(solid, x + 1, y + 1));
|
|
2955
2988
|
const left = line2(x - 1, y) && (is(solid, x, y - 1) || is(solid, x - 1, y - 1) || is(solid, x, y + 1) || is(solid, x - 1, y + 1));
|
|
2956
2989
|
const right = line2(x + 1, y) && (is(solid, x, y - 1) || is(solid, x + 1, y - 1) || is(solid, x, y + 1) || is(solid, x + 1, y + 1));
|
|
2957
|
-
const bits = (up2 ? 1 : 0) | (
|
|
2990
|
+
const bits = (up2 ? 1 : 0) | (down4 ? 2 : 0) | (left ? 4 : 0) | (right ? 8 : 0);
|
|
2958
2991
|
if (!bits)
|
|
2959
2992
|
continue;
|
|
2960
2993
|
let c = ink;
|
|
@@ -2981,7 +3014,7 @@ function shadow(src, options) {
|
|
|
2981
3014
|
fail2(`shadow.solid takes true or false, not ${show(o.solid)}`);
|
|
2982
3015
|
const solid = o.solid ?? true;
|
|
2983
3016
|
return build2("shadow", src, o, ({ meta: m, inks }) => {
|
|
2984
|
-
const
|
|
3017
|
+
const tone2 = color ? [inks.add(color), inks.add(color)] : m.palette ? [inks.add(QUIET2.dark), inks.add(QUIET2.light)] : [NONE, NONE];
|
|
2985
3018
|
const cols = m.cols + Math.abs(dx), rows2 = m.rows + Math.abs(dy);
|
|
2986
3019
|
const x0 = dx < 0 ? -dx : 0, y0 = dy < 0 ? -dy : 0;
|
|
2987
3020
|
return {
|
|
@@ -2993,7 +3026,7 @@ function shadow(src, options) {
|
|
|
2993
3026
|
const ink = new Uint8Array(cols * rows2), out = new Uint8Array(cols * rows2).fill(1), queue = new Int32Array(cols * rows2);
|
|
2994
3027
|
const over2 = { mask: null };
|
|
2995
3028
|
return (t, s2, g, ctx) => {
|
|
2996
|
-
const c =
|
|
3029
|
+
const c = tone2[ctx.paper ? 1 : 0];
|
|
2997
3030
|
if (solid) {
|
|
2998
3031
|
ink.fill(0);
|
|
2999
3032
|
for (let y = 0, i = 0; y < g.rows; y++)
|
|
@@ -5724,9 +5757,9 @@ function colorOn(stream, env) {
|
|
|
5724
5757
|
}
|
|
5725
5758
|
var ANSI = /\u001b\[[0-9;]*m/g;
|
|
5726
5759
|
var visibleWidth = (s2) => [...s2.replace(ANSI, "")].length;
|
|
5727
|
-
function paint2(on,
|
|
5760
|
+
function paint2(on, tone2, s2) {
|
|
5728
5761
|
if (!on) return s2;
|
|
5729
|
-
const [a, b2] = CODES[
|
|
5762
|
+
const [a, b2] = CODES[tone2];
|
|
5730
5763
|
return `\x1B[${a}m${s2}\x1B[${b2}m`;
|
|
5731
5764
|
}
|
|
5732
5765
|
function formatTable(rows2, head, indent = 0) {
|
|
@@ -5743,16 +5776,16 @@ function paintArt(on, a) {
|
|
|
5743
5776
|
if (!on || !tones2) return line2.trimEnd();
|
|
5744
5777
|
let out = "";
|
|
5745
5778
|
let run2 = "";
|
|
5746
|
-
let
|
|
5779
|
+
let tone2;
|
|
5747
5780
|
const flush = () => {
|
|
5748
|
-
if (run2) out +=
|
|
5781
|
+
if (run2) out += tone2 ? paint2(true, tone2, run2) : run2;
|
|
5749
5782
|
run2 = "";
|
|
5750
5783
|
};
|
|
5751
5784
|
[...line2].forEach((ch, x) => {
|
|
5752
|
-
const t = ch === " " ?
|
|
5753
|
-
if (t !==
|
|
5785
|
+
const t = ch === " " ? tone2 : tones2[x];
|
|
5786
|
+
if (t !== tone2) {
|
|
5754
5787
|
flush();
|
|
5755
|
-
|
|
5788
|
+
tone2 = t;
|
|
5756
5789
|
}
|
|
5757
5790
|
run2 += ch;
|
|
5758
5791
|
});
|
|
@@ -5767,8 +5800,8 @@ function createUi(io) {
|
|
|
5767
5800
|
tty: Boolean(io.stdout.isTTY),
|
|
5768
5801
|
out: (line2 = "") => void io.stdout.write(line2 + "\n"),
|
|
5769
5802
|
err: (line2 = "") => void io.stderr.write(line2 + "\n"),
|
|
5770
|
-
c: (
|
|
5771
|
-
ce: (
|
|
5803
|
+
c: (tone2, s2) => paint2(outColor, tone2, s2),
|
|
5804
|
+
ce: (tone2, s2) => paint2(errColor, tone2, s2),
|
|
5772
5805
|
json: (v) => void io.stdout.write(JSON.stringify(v, null, 2) + "\n"),
|
|
5773
5806
|
table(rows2, opts2 = {}) {
|
|
5774
5807
|
const head = opts2.head ? opts2.head.map((h) => paint2(outColor, "dim", h)) : void 0;
|
|
@@ -7301,6 +7334,7 @@ var launch = {
|
|
|
7301
7334
|
'autopilot jobs: --cron "0 2 * * *" --task "update deps, run tests" (repeat both for more; --job-name names them). more later: airgate job add'
|
|
7302
7335
|
],
|
|
7303
7336
|
flags: {
|
|
7337
|
+
from: { type: "string", value: "snapshot", help: "start from a saved snapshot: its name, or a share code (airgate snapshots)" },
|
|
7304
7338
|
name: { type: "string", value: "name" },
|
|
7305
7339
|
template: { type: "string", value: "template", choices: TEMPLATE_IDS, help: `${TEMPLATE_IDS.join(" | ")} (default code)` },
|
|
7306
7340
|
agent: { type: "string", value: "agent", choices: Object.keys(AGENTS), help: `${AGENT_NAMES.join(" | ")} (default: the template's first)` },
|
|
@@ -7323,6 +7357,18 @@ var launch = {
|
|
|
7323
7357
|
},
|
|
7324
7358
|
async run(ctx) {
|
|
7325
7359
|
if (ctx.args.positionals.length) throw new UsageError(`unexpected "${ctx.args.positionals[0]}"`, "name the pod with --name");
|
|
7360
|
+
const from = str(ctx.args, "from");
|
|
7361
|
+
if (from) {
|
|
7362
|
+
const o = {};
|
|
7363
|
+
if (str(ctx.args, "name")) o.name = str(ctx.args, "name");
|
|
7364
|
+
if (str(ctx.args, "agent")) o.agent = str(ctx.args, "agent");
|
|
7365
|
+
if (ctx.args.flags.memory !== "standard") o.size = ctx.args.flags.memory;
|
|
7366
|
+
if (ctx.args.flags.browser === true) o.browser = true;
|
|
7367
|
+
const pod2 = await ctx.ui.spin(`starting a pod from ${from}`, async () => waitReady(ctx, await ctx.api.launchSnapshot(from, o)));
|
|
7368
|
+
if (ctx.json) return ctx.ui.json(pod2);
|
|
7369
|
+
ctx.ui.out(readyLine(ctx, pod2));
|
|
7370
|
+
return void ctx.ui.out(`next: airgate attach ${pod2.spec.name}`);
|
|
7371
|
+
}
|
|
7326
7372
|
const spec = specFromFlags(ctx);
|
|
7327
7373
|
if (spec.repo) await checkRepo(ctx, spec.repo);
|
|
7328
7374
|
const envFile = str(ctx.args, "env");
|
|
@@ -7369,7 +7415,7 @@ function readDotenv(ctx, file) {
|
|
|
7369
7415
|
}
|
|
7370
7416
|
function specFromFlags(ctx, defaults = {}) {
|
|
7371
7417
|
const a = ctx.args;
|
|
7372
|
-
const
|
|
7418
|
+
const network2 = str(a, "network") ?? "allowlist";
|
|
7373
7419
|
const autoStop = str(a, "auto-stop");
|
|
7374
7420
|
let autoStopMinutes = null;
|
|
7375
7421
|
if (autoStop === "keep-awake" || autoStop === "never") autoStopMinutes = 0;
|
|
@@ -7408,12 +7454,12 @@ function specFromFlags(ctx, defaults = {}) {
|
|
|
7408
7454
|
agent,
|
|
7409
7455
|
size: str(a, "memory") ?? "standard",
|
|
7410
7456
|
masking: a.flags.mask !== false,
|
|
7411
|
-
network,
|
|
7457
|
+
network: network2,
|
|
7412
7458
|
autoStopMinutes,
|
|
7413
7459
|
autoCheckpoint: a.flags.undo !== false
|
|
7414
7460
|
};
|
|
7415
7461
|
if (repo) spec.repo = repo;
|
|
7416
|
-
if (
|
|
7462
|
+
if (network2 === "allowlist") spec.allow = [.../* @__PURE__ */ new Set([...DEFAULT_ALLOW, ...list(a, "allow").flatMap((h) => h.split(",")).map((h) => h.trim()).filter(Boolean)])];
|
|
7417
7463
|
const terms = list(a, "mask-term");
|
|
7418
7464
|
if (terms.length) spec.maskTerms = terms;
|
|
7419
7465
|
if (template !== "code") spec.template = template;
|
|
@@ -7497,7 +7543,10 @@ var destroy = {
|
|
|
7497
7543
|
flags: { yes: { type: "boolean", short: "y", help: "don't ask" } },
|
|
7498
7544
|
async run(ctx) {
|
|
7499
7545
|
const p = await resolvePod(ctx, ctx.args.positionals[0]);
|
|
7500
|
-
|
|
7546
|
+
const pending = await ctx.api.diff(p.id, "up").catch(() => null);
|
|
7547
|
+
const unpulled = pending?.files.length ?? 0;
|
|
7548
|
+
if (unpulled && !ctx.json) ctx.ui.err(ctx.ui.ce("warn", `${p.spec.name} has ${unpulled} changed file(s) not brought back. keep them first: airgate down --pod ${p.spec.name} (or save it: airgate snapshot ${p.spec.name} <name>)`));
|
|
7549
|
+
if (!await confirm(ctx, `destroy ${p.spec.name} (${p.id})? its files and checkpoints are gone for good${unpulled ? `, including ${unpulled} changed file(s)` : ""}.`)) {
|
|
7501
7550
|
throw new CliError("not destroyed");
|
|
7502
7551
|
}
|
|
7503
7552
|
await ctx.ui.spin(`destroying ${p.spec.name}`, ctx.api.destroy(p.id));
|
|
@@ -7728,7 +7777,7 @@ var log = {
|
|
|
7728
7777
|
import { createInterface } from "node:readline";
|
|
7729
7778
|
|
|
7730
7779
|
// src/version.ts
|
|
7731
|
-
var VERSION = "0.1.
|
|
7780
|
+
var VERSION = "0.1.6";
|
|
7732
7781
|
|
|
7733
7782
|
// src/commands/updown.ts
|
|
7734
7783
|
import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync5, realpathSync as realpathSync2, statSync as statSync2, writeFileSync as writeFileSync3 } from "node:fs";
|
|
@@ -7780,7 +7829,7 @@ function gitFiles(dir) {
|
|
|
7780
7829
|
return out;
|
|
7781
7830
|
}
|
|
7782
7831
|
function runTar(cwd, args, input, limit = UPLOAD_LIMIT) {
|
|
7783
|
-
return new Promise((
|
|
7832
|
+
return new Promise((resolve8, reject) => {
|
|
7784
7833
|
const child = spawn("tar", args, { cwd, env: { ...process.env, COPYFILE_DISABLE: "1" }, stdio: ["pipe", "pipe", "pipe"] });
|
|
7785
7834
|
const chunks = [];
|
|
7786
7835
|
let size2 = 0;
|
|
@@ -7800,7 +7849,7 @@ function runTar(cwd, args, input, limit = UPLOAD_LIMIT) {
|
|
|
7800
7849
|
child.on("close", (code3) => {
|
|
7801
7850
|
if (over2) return reject(new TooLargeError(limit));
|
|
7802
7851
|
if (code3 !== 0) return reject(new Error(`tar failed: ${err.trim().split("\n").slice(-3).join(" ")}`));
|
|
7803
|
-
|
|
7852
|
+
resolve8(Buffer.concat(chunks));
|
|
7804
7853
|
});
|
|
7805
7854
|
child.stdin.on("error", () => void 0);
|
|
7806
7855
|
child.stdin.end(input ?? "");
|
|
@@ -7811,10 +7860,10 @@ async function packProject(dir, limit = UPLOAD_LIMIT) {
|
|
|
7811
7860
|
const tracked = new Set(git(dir, ["ls-files", "-c", "-z"]).stdout.split("\0"));
|
|
7812
7861
|
const all = gitFiles(dir).filter((f) => !f.startsWith(".airgate/"));
|
|
7813
7862
|
const skipped2 = all.filter((f) => (isEnvFile(f) || f.split("/").pop() === ".envrc") && !tracked.has(f));
|
|
7814
|
-
const
|
|
7815
|
-
if (!
|
|
7816
|
-
const archive2 = await runTar(dir, ["--null", "-T", "-", "-czf", "-"],
|
|
7817
|
-
return { archive: archive2, files:
|
|
7863
|
+
const files2 = all.filter((f) => !skipped2.includes(f));
|
|
7864
|
+
if (!files2.length) throw new Error("no files to send (git sees nothing tracked or untracked here)");
|
|
7865
|
+
const archive2 = await runTar(dir, ["--null", "-T", "-", "-czf", "-"], files2.join("\0") + "\0", limit);
|
|
7866
|
+
return { archive: archive2, files: files2.length, git: true, skipped: skipped2 };
|
|
7818
7867
|
}
|
|
7819
7868
|
const skipped = readdirSync(dir).filter((f) => isEnvFile(f) || f === ".envrc");
|
|
7820
7869
|
const archive = await runTar(dir, ["--exclude=node_modules", "--exclude=.git", "--exclude=.airgate", ...ENV_EXCLUDES, "-czf", "-", "."], null, limit);
|
|
@@ -7895,7 +7944,7 @@ function firstLine(file) {
|
|
|
7895
7944
|
}
|
|
7896
7945
|
}
|
|
7897
7946
|
function codexRollouts(root, limit = 3e3) {
|
|
7898
|
-
const
|
|
7947
|
+
const files2 = [];
|
|
7899
7948
|
const walk = (d, depth) => {
|
|
7900
7949
|
let list2 = [];
|
|
7901
7950
|
try {
|
|
@@ -7906,11 +7955,11 @@ function codexRollouts(root, limit = 3e3) {
|
|
|
7906
7955
|
for (const x of list2) {
|
|
7907
7956
|
const full = join3(d, x.name);
|
|
7908
7957
|
if (x.isDirectory() && depth < 4) walk(full, depth + 1);
|
|
7909
|
-
else if (x.isFile() && /^rollout-.*\.jsonl$/.test(x.name))
|
|
7958
|
+
else if (x.isFile() && /^rollout-.*\.jsonl$/.test(x.name)) files2.push({ f: full, m: statSync(full).mtimeMs });
|
|
7910
7959
|
}
|
|
7911
7960
|
};
|
|
7912
7961
|
walk(join3(root, "sessions"), 0);
|
|
7913
|
-
return
|
|
7962
|
+
return files2.sort((a, b2) => b2.m - a.m).slice(0, limit).map((x) => x.f);
|
|
7914
7963
|
}
|
|
7915
7964
|
var codexId = (file) => /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/.exec(file)?.[1] ?? null;
|
|
7916
7965
|
function findCodexSession(home, projectDir, id, env = {}) {
|
|
@@ -8161,8 +8210,8 @@ function rewritePaths(text2, pre) {
|
|
|
8161
8210
|
}
|
|
8162
8211
|
return lines.join("\n");
|
|
8163
8212
|
}
|
|
8164
|
-
function orderFiles(
|
|
8165
|
-
return [...
|
|
8213
|
+
function orderFiles(files2) {
|
|
8214
|
+
return [...files2.filter((f) => f.risky), ...files2.filter((f) => !f.risky)];
|
|
8166
8215
|
}
|
|
8167
8216
|
function refusals(blocks) {
|
|
8168
8217
|
const out = [];
|
|
@@ -8416,9 +8465,9 @@ function printSessions(ctx, list2) {
|
|
|
8416
8465
|
function printFiles(ctx, blocks, toErr) {
|
|
8417
8466
|
const line2 = toErr ? ctx.ui.err : ctx.ui.out;
|
|
8418
8467
|
const c = toErr ? ctx.ui.ce : ctx.ui.c;
|
|
8419
|
-
const
|
|
8420
|
-
const width = Math.max(...
|
|
8421
|
-
for (const f of
|
|
8468
|
+
const files2 = orderFiles(blocks.map((b2) => b2.file));
|
|
8469
|
+
const width = Math.max(...files2.map((f) => f.path.length + (f.from ? f.from.length + 4 : 0)));
|
|
8470
|
+
for (const f of files2) {
|
|
8422
8471
|
const name = f.from ? `${f.from} \u2192 ${f.path}` : f.path;
|
|
8423
8472
|
const mark = f.risky ? c("warn", "!") : " ";
|
|
8424
8473
|
line2(` ${mark} ${STATUS_MARK[f.status]} ${f.risky ? name.padEnd(width) + " " + c("warn", f.reason ?? "") : name}`.trimEnd());
|
|
@@ -8469,9 +8518,9 @@ var down2 = {
|
|
|
8469
8518
|
const { blocks, outside } = selectProject(parsePatch(d.patch), prefix);
|
|
8470
8519
|
const refused = refusals(blocks);
|
|
8471
8520
|
const patch = blocks.map((b2) => b2.text).join("");
|
|
8472
|
-
const
|
|
8521
|
+
const files2 = orderFiles(blocks.map((b2) => b2.file));
|
|
8473
8522
|
if (ctx.json && (dry || !blocks.length)) {
|
|
8474
|
-
return ui2.json({ pod: pod.id, base, files, outside, refused, patch, applied: false });
|
|
8523
|
+
return ui2.json({ pod: pod.id, base, files: files2, outside, refused, patch, applied: false });
|
|
8475
8524
|
}
|
|
8476
8525
|
const takeSessions = !dry && !bool(ctx.args, "no-session") && found?.state.podId === pod.id;
|
|
8477
8526
|
if (!blocks.length) {
|
|
@@ -8480,8 +8529,8 @@ var down2 = {
|
|
|
8480
8529
|
if (takeSessions && found) await downSessions(ctx, found.root, found.state, pod);
|
|
8481
8530
|
return;
|
|
8482
8531
|
}
|
|
8483
|
-
const risky =
|
|
8484
|
-
const head = `${
|
|
8532
|
+
const risky = files2.filter((f) => f.risky).length;
|
|
8533
|
+
const head = `${files2.length} file(s) from ${pod.spec.name}${risky ? `, ${risky} risky` : ""}:`;
|
|
8485
8534
|
if (dry) {
|
|
8486
8535
|
ui2.err(head);
|
|
8487
8536
|
printFiles(ctx, blocks, true);
|
|
@@ -8516,7 +8565,7 @@ var down2 = {
|
|
|
8516
8565
|
throw new CliError(`the patch doesn't apply here, nothing changed:
|
|
8517
8566
|
${check.stderr.trim()}`, "commit or stash local edits to those files, or look with: airgate down --dry-run");
|
|
8518
8567
|
}
|
|
8519
|
-
if (!await confirm(ctx, `apply ${
|
|
8568
|
+
if (!await confirm(ctx, `apply ${files2.length} file(s)${risky ? ` (${risky} risky)` : ""}?`)) throw new CliError("nothing applied");
|
|
8520
8569
|
const applied = git(top, ["apply", ...applyArgs], patch);
|
|
8521
8570
|
if (applied.status !== 0) throw new CliError(`git apply failed: ${applied.stderr.trim()}`);
|
|
8522
8571
|
let acked = true;
|
|
@@ -8526,8 +8575,8 @@ ${check.stderr.trim()}`, "commit or stash local edits to those files, or look wi
|
|
|
8526
8575
|
acked = false;
|
|
8527
8576
|
}
|
|
8528
8577
|
const convo = takeSessions && found ? await downSessions(ctx, found.root, found.state, pod, true) : [];
|
|
8529
|
-
if (ctx.json) return ui2.json({ pod: pod.id, base, files, outside, refused, applied: true, acked, sessions: convo });
|
|
8530
|
-
ui2.out(`${ui2.c("ok", "down")}: applied ${
|
|
8578
|
+
if (ctx.json) return ui2.json({ pod: pod.id, base, files: files2, outside, refused, applied: true, acked, sessions: convo });
|
|
8579
|
+
ui2.out(`${ui2.c("ok", "down")}: applied ${files2.length} file(s). review: git status \xB7 git diff`);
|
|
8531
8580
|
if (!acked) ui2.err(ui2.ce("warn", "couldn't tell the pod what was taken; the next down will show these again"));
|
|
8532
8581
|
printSessions(ctx, convo);
|
|
8533
8582
|
}
|
|
@@ -8588,7 +8637,7 @@ var LineReader = class {
|
|
|
8588
8637
|
}
|
|
8589
8638
|
};
|
|
8590
8639
|
function ttyPrompt(stdin, out) {
|
|
8591
|
-
return (question, opts2 = {}) => new Promise((
|
|
8640
|
+
return (question, opts2 = {}) => new Promise((resolve8, reject) => {
|
|
8592
8641
|
out.write(question);
|
|
8593
8642
|
const decoder = new StringDecoder2("utf8");
|
|
8594
8643
|
let answer = "";
|
|
@@ -8601,7 +8650,7 @@ function ttyPrompt(stdin, out) {
|
|
|
8601
8650
|
stdin.pause();
|
|
8602
8651
|
out.write("\n");
|
|
8603
8652
|
if (err) reject(err);
|
|
8604
|
-
else
|
|
8653
|
+
else resolve8(answer);
|
|
8605
8654
|
};
|
|
8606
8655
|
const onData = (d) => {
|
|
8607
8656
|
for (const ch of decoder.write(d)) {
|
|
@@ -8628,16 +8677,16 @@ function ttyPrompt(stdin, out) {
|
|
|
8628
8677
|
}
|
|
8629
8678
|
function openInBrowser(url) {
|
|
8630
8679
|
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url]] : ["xdg-open", [url]];
|
|
8631
|
-
return new Promise((
|
|
8680
|
+
return new Promise((resolve8) => {
|
|
8632
8681
|
try {
|
|
8633
8682
|
const child = spawn2(cmd, args, { stdio: "ignore", detached: true });
|
|
8634
|
-
child.on("error", () =>
|
|
8683
|
+
child.on("error", () => resolve8(false));
|
|
8635
8684
|
child.on("spawn", () => {
|
|
8636
8685
|
child.unref();
|
|
8637
|
-
|
|
8686
|
+
resolve8(true);
|
|
8638
8687
|
});
|
|
8639
8688
|
} catch {
|
|
8640
|
-
|
|
8689
|
+
resolve8(false);
|
|
8641
8690
|
}
|
|
8642
8691
|
});
|
|
8643
8692
|
}
|
|
@@ -9213,26 +9262,257 @@ When you are done, leave your changes in the files there (no need to commit or p
|
|
|
9213
9262
|
ui2.err(ui2.ce("bad", keepNote(`the task finished, but its changes didn't come back: ${why}`)));
|
|
9214
9263
|
return 1;
|
|
9215
9264
|
}
|
|
9216
|
-
const
|
|
9265
|
+
const files2 = result.files ?? [];
|
|
9217
9266
|
const keep = noApply || bool(ctx.args, "keep");
|
|
9218
9267
|
await finish(ctx, pod, keep ? "sleep" : "destroy");
|
|
9219
9268
|
if (!keep) restoreState();
|
|
9220
|
-
if (ctx.json) return out({ applied: !noApply &&
|
|
9269
|
+
if (ctx.json) return out({ applied: !noApply && files2.length > 0, files: files2, ...noApply ? { patch: result.patch ?? "" } : {}, pod_state: keep ? "sleeping" : "deleted" });
|
|
9221
9270
|
ctx.io.stdout.write(r.log.endsWith("\n") || !r.log ? r.log : r.log + "\n");
|
|
9222
9271
|
if (noApply) {
|
|
9223
9272
|
ctx.io.stdout.write(result.patch ?? "");
|
|
9224
|
-
ui2.err(`${
|
|
9273
|
+
ui2.err(`${files2.length} file(s) changed, not applied. apply: airgate down --pod ${name} \xB7 delete the pod: airgate destroy ${name}`);
|
|
9225
9274
|
return 0;
|
|
9226
9275
|
}
|
|
9227
|
-
const risky =
|
|
9276
|
+
const risky = files2.filter((f) => f.risky).length;
|
|
9228
9277
|
ui2.out(
|
|
9229
|
-
`${ui2.c("ok", "done")}: ${
|
|
9278
|
+
`${ui2.c("ok", "done")}: ${files2.length ? `${files2.length} file(s) changed here${risky ? `, ${risky} risky` : ""}. review: git status \xB7 git diff` : "no changes came back"}` + (keep ? ` \xB7 the pod is kept (asleep): ${name}` : " \xB7 the pod is deleted")
|
|
9230
9279
|
);
|
|
9231
9280
|
return 0;
|
|
9232
9281
|
}
|
|
9233
9282
|
}
|
|
9234
9283
|
};
|
|
9235
9284
|
|
|
9285
|
+
// src/commands/crews.ts
|
|
9286
|
+
import { existsSync as existsSync6, readFileSync as readFileSync9, realpathSync as realpathSync3, rmSync as rmSync5, statSync as statSync4, writeFileSync as writeFileSync6 } from "node:fs";
|
|
9287
|
+
import { relative as relative3, resolve as resolve6 } from "node:path";
|
|
9288
|
+
var KINDS = ["race", "contest", "council", "squad", "split"];
|
|
9289
|
+
var ACTIONS = ["show", "say", "stop", "down", "rm", "list"];
|
|
9290
|
+
var AGENT_ALIAS = { claude: "claude-code", "claude-code": "claude-code", codex: "codex", opencode: "opencode", pi: "pi" };
|
|
9291
|
+
function membersFrom(values) {
|
|
9292
|
+
const items = values.flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean);
|
|
9293
|
+
if (!items.length) return void 0;
|
|
9294
|
+
return items.map((it) => {
|
|
9295
|
+
const m = /^([a-z][a-z0-9-]*)=([a-z-]+)(?::(.+))?$/.exec(it);
|
|
9296
|
+
if (!m) throw new UsageError(`--as ${it}: role=agent, e.g. reviewer=codex or worker-2=opencode:qwen/qwen3-coder`);
|
|
9297
|
+
const agent = AGENT_ALIAS[m[2]];
|
|
9298
|
+
if (!agent) throw new UsageError(`--as ${it}: the agent is one of claude, codex, opencode, pi`);
|
|
9299
|
+
return { role: m[1], agent, ...m[3] ? { model: m[3] } : {} };
|
|
9300
|
+
});
|
|
9301
|
+
}
|
|
9302
|
+
var tone = (ctx, state) => state === "done" ? ctx.ui.c("ok", state) : state === "failed" ? ctx.ui.c("bad", state) : state === "stopped" ? ctx.ui.c("dim", state) : ctx.ui.c("warn", state);
|
|
9303
|
+
function printRoom(ctx, messages) {
|
|
9304
|
+
for (const m of messages) {
|
|
9305
|
+
const who = m.author === "airgate" ? ctx.ui.c("dim", "airgate") : m.author === "you" ? ctx.ui.c("warn", "you") : m.author;
|
|
9306
|
+
const lines = m.text.split("\n");
|
|
9307
|
+
ctx.ui.out(`${who}: ${lines[0]}`);
|
|
9308
|
+
for (const l of lines.slice(1, 12)) ctx.ui.out(` ${l}`);
|
|
9309
|
+
if (lines.length > 12) ctx.ui.out(ctx.ui.c("dim", ` \u2026 (${lines.length - 12} more lines: airgate crew show)`));
|
|
9310
|
+
}
|
|
9311
|
+
}
|
|
9312
|
+
async function follow(ctx, id) {
|
|
9313
|
+
let since = 0;
|
|
9314
|
+
const controller = new AbortController();
|
|
9315
|
+
const stop = () => controller.abort();
|
|
9316
|
+
ctx.io.signals.on("SIGINT", stop);
|
|
9317
|
+
ctx.io.signal?.addEventListener("abort", stop);
|
|
9318
|
+
try {
|
|
9319
|
+
for (; ; ) {
|
|
9320
|
+
const r = await ctx.api.request("GET", `/v1/crews/${encodeURIComponent(id)}/messages?since=${since}`);
|
|
9321
|
+
printRoom(ctx, r.messages);
|
|
9322
|
+
if (r.messages.length) since = r.messages[r.messages.length - 1].id;
|
|
9323
|
+
if (["done", "failed", "stopped"].includes(r.state)) break;
|
|
9324
|
+
if (controller.signal.aborted) {
|
|
9325
|
+
ctx.ui.err(ctx.ui.ce("dim", `stopped following; the crew goes on. follow again: airgate crew show ${id} --follow \xB7 stop it: airgate crew stop ${id}`));
|
|
9326
|
+
break;
|
|
9327
|
+
}
|
|
9328
|
+
await new Promise((res) => setTimeout(res, Number(ctx.io.env.AIRGATE_CREW_POLL_MS) || 2e3));
|
|
9329
|
+
}
|
|
9330
|
+
} finally {
|
|
9331
|
+
ctx.io.signals.off("SIGINT", stop);
|
|
9332
|
+
ctx.io.signal?.removeEventListener("abort", stop);
|
|
9333
|
+
}
|
|
9334
|
+
return (await ctx.api.request("GET", `/v1/crews/${encodeURIComponent(id)}`)).crew;
|
|
9335
|
+
}
|
|
9336
|
+
function printResult(ctx, c) {
|
|
9337
|
+
if (c.state === "failed") return void ctx.ui.err(ctx.ui.ce("bad", `failed: ${c.error ?? "unknown"}. its pods are asleep with their work: airgate pods`));
|
|
9338
|
+
if (!c.result) return;
|
|
9339
|
+
ctx.ui.out();
|
|
9340
|
+
ctx.ui.out(`${tone(ctx, c.state)}: ${c.result.summary ?? ""}`);
|
|
9341
|
+
if (c.result.files?.length) ctx.ui.out(ctx.ui.c("dim", `${c.result.files.length} file(s): ${c.result.files.slice(0, 8).join(", ")}${c.result.files.length > 8 ? "\u2026" : ""}`));
|
|
9342
|
+
if (c.result.hasPatch) ctx.ui.out(`bring it back here: airgate crew down ${c.id}`);
|
|
9343
|
+
}
|
|
9344
|
+
async function down3(ctx, id) {
|
|
9345
|
+
const r = await ctx.api.request("GET", `/v1/crews/${encodeURIComponent(id)}/result`);
|
|
9346
|
+
const { blocks, outside } = selectProject(parsePatch(r.patch), r.dir);
|
|
9347
|
+
const refused = refusals(blocks);
|
|
9348
|
+
const patch = blocks.map((b2) => b2.text).join("");
|
|
9349
|
+
const files2 = orderFiles(blocks.map((b2) => b2.file));
|
|
9350
|
+
if (bool(ctx.args, "dry-run")) {
|
|
9351
|
+
if (ctx.json) return ctx.ui.json({ files: files2, outside, refused, patch });
|
|
9352
|
+
return void ctx.io.stdout.write(patch);
|
|
9353
|
+
}
|
|
9354
|
+
if (refused.length) throw new CliError(`refused: ${refused.map((x) => `${x.path} (${x.why})`).join(", ")}`, `nothing applied. look: airgate crew down ${id} --dry-run`);
|
|
9355
|
+
if (!blocks.length) return void ctx.ui.out("nothing to apply.");
|
|
9356
|
+
const top = gitTopLevel(ctx.io.cwd);
|
|
9357
|
+
if (!top) throw new CliError("crew down needs a git repo here", "git init && git add -A && git commit -m base");
|
|
9358
|
+
const rel = relative3(realpathSync3(top), realpathSync3(ctx.io.cwd));
|
|
9359
|
+
const args = ["--whitespace=nowarn", ...rel ? [`--directory=${rel}`] : [], "-"];
|
|
9360
|
+
const check = git(top, ["apply", "--check", ...args], patch);
|
|
9361
|
+
if (check.status !== 0) throw new CliError(`the patch doesn't apply here, nothing changed:
|
|
9362
|
+
${check.stderr.trim()}`, "commit or stash local edits to those files first");
|
|
9363
|
+
const risky = files2.filter((f) => f.risky).length;
|
|
9364
|
+
if (!ctx.json) for (const f of files2) ctx.ui.out(` ${f.risky ? ctx.ui.c("warn", "!") : " "} ${f.path}${f.risky ? ctx.ui.c("warn", ` ${f.reason ?? ""}`) : ""}`);
|
|
9365
|
+
if (!await confirm(ctx, `apply ${files2.length} file(s)${risky ? ` (${risky} risky)` : ""}?`)) throw new CliError("nothing applied");
|
|
9366
|
+
const applied = git(top, ["apply", ...args], patch);
|
|
9367
|
+
if (applied.status !== 0) throw new CliError(`git apply failed: ${applied.stderr.trim()}`);
|
|
9368
|
+
if (ctx.json) return ctx.ui.json({ applied: true, files: files2 });
|
|
9369
|
+
ctx.ui.out(`${ctx.ui.c("ok", "applied")} ${files2.length} file(s) from the crew. review: git status \xB7 git diff`);
|
|
9370
|
+
}
|
|
9371
|
+
var crew = {
|
|
9372
|
+
name: "crew",
|
|
9373
|
+
aliases: ["crews"],
|
|
9374
|
+
group: "pods",
|
|
9375
|
+
usage: '<race|contest|council|squad|split> "<task>" [dir] [--pod p | --from snapshot | --repo owner/name] [--as role=agent] \u2026 | show|say|stop|down|rm <id>',
|
|
9376
|
+
summary: "several agents, each in its own pod, on one task: race, contest, council, squad, split",
|
|
9377
|
+
example: 'airgate crew contest "add OAuth with GitHub"',
|
|
9378
|
+
notes: [
|
|
9379
|
+
"race: builders try the same task, a judge tests each and picks one. contest: a builder builds, a reviewer reviews and tries to break it, until it holds. council: members propose plans and critique each other, a chair decides (--build to build it). squad: a builder builds, testers write tests and hunt bugs, the builder fixes. split: a planner splits the work, workers build the parts, an integrator merges.",
|
|
9380
|
+
"starting point: this folder (sent up to a pod made for the crew, deleted after), --pod (an existing pod), --from (a snapshot) or --repo. members fork from it; each has its own pod.",
|
|
9381
|
+
"--as role=agent[:model] picks a member's agent: --as reviewer=codex --as builder-3=opencode:qwen/qwen3-coder (opencode with a model id uses airgate's built-in models).",
|
|
9382
|
+
"airgate runs the crew, so it keeps going with this laptop closed. this command follows the room until it ends (--detach to return at once). while it follows, this laptop signs the members' model calls if it has a sign-in (airgate models).",
|
|
9383
|
+
"the result is one patch: airgate crew down <id> applies it here like airgate down. --pr opens a pull request instead (it waits in your airlock; needs --repo or a pod with a repo)."
|
|
9384
|
+
],
|
|
9385
|
+
flags: {
|
|
9386
|
+
pod: { type: "string", value: "pod", help: "start from this pod (its workspace)" },
|
|
9387
|
+
from: { type: "string", value: "snapshot", help: "start from a saved snapshot (name or share code)" },
|
|
9388
|
+
repo: { type: "string", value: "owner/name", help: "start from a GitHub repo" },
|
|
9389
|
+
as: { type: "list", value: "role=agent", help: "a member's agent, e.g. reviewer=codex (repeatable)" },
|
|
9390
|
+
rounds: { type: "string", value: "n", help: "contest / squad rounds (1\u20135)" },
|
|
9391
|
+
minutes: { type: "string", value: "n", help: "time limit for the whole crew (default 60)" },
|
|
9392
|
+
build: { type: "boolean", help: "council: also build the decision" },
|
|
9393
|
+
pr: { type: "boolean", help: "open a pull request with the result (it waits in your airlock)" },
|
|
9394
|
+
delete: { type: "boolean", help: "delete member pods afterwards (default: they sleep)" },
|
|
9395
|
+
detach: { type: "boolean", short: "d", help: "start it and return; don't follow the room" },
|
|
9396
|
+
follow: { type: "boolean", short: "f", help: "show: keep following the room" },
|
|
9397
|
+
"dry-run": { type: "boolean", short: "n", help: "down: print the patch, change nothing" },
|
|
9398
|
+
yes: { type: "boolean", short: "y", help: "don't ask" }
|
|
9399
|
+
},
|
|
9400
|
+
async run(ctx) {
|
|
9401
|
+
const { ui: ui2 } = ctx;
|
|
9402
|
+
const [first, second, third, extra] = ctx.args.positionals;
|
|
9403
|
+
if (!first || first === "list") {
|
|
9404
|
+
const list2 = (await ctx.api.request("GET", "/v1/crews")).crews;
|
|
9405
|
+
if (ctx.json) return ui2.json(list2);
|
|
9406
|
+
if (!list2.length) return void ui2.out(ui2.c("dim", 'no crews yet. start one: airgate crew contest "<task>" (in a project folder)'));
|
|
9407
|
+
ui2.table(
|
|
9408
|
+
list2.map((c2) => [c2.id, c2.recipe, tone(ctx, c2.state), c2.task.length > 48 ? `${c2.task.slice(0, 47)}\u2026` : c2.task]),
|
|
9409
|
+
{ head: ["crew", "recipe", "", "task"] }
|
|
9410
|
+
);
|
|
9411
|
+
return;
|
|
9412
|
+
}
|
|
9413
|
+
if (ACTIONS.includes(first)) {
|
|
9414
|
+
if (!second) throw new UsageError(`airgate crew ${first} <id>`);
|
|
9415
|
+
const id = second;
|
|
9416
|
+
if (first === "show") {
|
|
9417
|
+
if (bool(ctx.args, "follow") && !ctx.json) return void printResult(ctx, await follow(ctx, id));
|
|
9418
|
+
const c2 = (await ctx.api.request("GET", `/v1/crews/${encodeURIComponent(id)}`)).crew;
|
|
9419
|
+
if (ctx.json) return ui2.json(c2);
|
|
9420
|
+
ui2.out(`${c2.id} \xB7 ${c2.recipe} \xB7 ${tone(ctx, c2.state)}${c2.step && !["done", "failed", "stopped"].includes(c2.state) ? ` \xB7 ${c2.step}` : ""}`);
|
|
9421
|
+
ui2.out(`task: ${c2.task}`);
|
|
9422
|
+
ui2.table(c2.members.map((m) => [m.role, AGENTS[m.agent]?.label ?? m.agent, m.pod ? `${m.pod.name} (${m.pod.state})` : ui2.c("dim", "not started")]), { head: ["member", "agent", "pod"] });
|
|
9423
|
+
ui2.out();
|
|
9424
|
+
printRoom(ctx, c2.messages ?? []);
|
|
9425
|
+
return void printResult(ctx, c2);
|
|
9426
|
+
}
|
|
9427
|
+
if (first === "say") {
|
|
9428
|
+
const text2 = [third, extra].filter(Boolean).join(" ").trim();
|
|
9429
|
+
if (!text2) throw new UsageError('airgate crew say <id> "what the crew should know"');
|
|
9430
|
+
await ctx.api.request("POST", `/v1/crews/${encodeURIComponent(id)}/messages`, { body: { text: text2 } });
|
|
9431
|
+
return ctx.json ? ui2.json({ ok: true }) : void ui2.out("said to the crew. members read the room between steps.");
|
|
9432
|
+
}
|
|
9433
|
+
if (first === "stop") {
|
|
9434
|
+
const c2 = (await ctx.api.request("POST", `/v1/crews/${encodeURIComponent(id)}/stop`)).crew;
|
|
9435
|
+
return ctx.json ? ui2.json(c2) : void ui2.out(`stopping ${c2.id} at its next step; its pods go to sleep with their work.`);
|
|
9436
|
+
}
|
|
9437
|
+
if (first === "down") return down3(ctx, id);
|
|
9438
|
+
if (first === "rm") {
|
|
9439
|
+
if (!await confirm(ctx, `delete crew ${id} and its member pods?`)) throw new CliError("nothing deleted");
|
|
9440
|
+
await ctx.api.request("DELETE", `/v1/crews/${encodeURIComponent(id)}`);
|
|
9441
|
+
return ctx.json ? ui2.json({ ok: true }) : void ui2.out(`deleted ${id}.`);
|
|
9442
|
+
}
|
|
9443
|
+
}
|
|
9444
|
+
if (!KINDS.includes(first)) throw new UsageError(`"${first}" isn't a recipe or an action`, `recipes: ${KINDS.join(", ")} \xB7 actions: ${ACTIONS.join(", ")}`);
|
|
9445
|
+
const kind = first;
|
|
9446
|
+
const task2 = (second ?? "").trim();
|
|
9447
|
+
if (!task2) throw new UsageError(`what should the crew do? airgate crew ${kind} "<task>"`);
|
|
9448
|
+
if (extra) throw new UsageError(`unexpected "${extra}" (put the task in quotes)`);
|
|
9449
|
+
const sources = ["pod", "from", "repo"].filter((k) => str(ctx.args, k));
|
|
9450
|
+
if (sources.length > 1) throw new UsageError("one starting point: --pod, --from or --repo");
|
|
9451
|
+
if (sources.length && third) throw new UsageError("a folder or --pod / --from / --repo, not both");
|
|
9452
|
+
let basePodId;
|
|
9453
|
+
let dir = "";
|
|
9454
|
+
let temporaryBase = false;
|
|
9455
|
+
let restoreState = null;
|
|
9456
|
+
if (str(ctx.args, "pod")) basePodId = (await resolvePod(ctx, str(ctx.args, "pod"))).id;
|
|
9457
|
+
else if (str(ctx.args, "from")) {
|
|
9458
|
+
const p = await ui2.spin("starting the base pod from the snapshot", async () => waitReady(ctx, await ctx.api.launchSnapshot(str(ctx.args, "from"), { name: `crew-base-${Date.now().toString(36).slice(-4)}` })));
|
|
9459
|
+
basePodId = p.id;
|
|
9460
|
+
temporaryBase = true;
|
|
9461
|
+
} else if (str(ctx.args, "repo")) {
|
|
9462
|
+
const repo = str(ctx.args, "repo");
|
|
9463
|
+
const look = await ctx.api.inspectRepo(repo).catch((e) => e instanceof ApiError && e.status === 404 ? null : Promise.reject(e));
|
|
9464
|
+
if (look && !look.reachable) throw new CliError(`airgate can't reach ${look.repo} (private, and not shared with Airgate)`, "connect GitHub: airgate github connect, or start from a local clone instead");
|
|
9465
|
+
const spec = { name: `crew-base-${Date.now().toString(36).slice(-4)}`, agent: "claude-code", size: "standard", repo: look?.repo ?? repo, masking: true, network: "allowlist", allow: DEFAULT_ALLOW, autoStopMinutes: null, autoCheckpoint: false };
|
|
9466
|
+
basePodId = (await ui2.spin(`starting the base pod on ${spec.repo}`, async () => waitReady(ctx, await ctx.api.launch(spec)))).id;
|
|
9467
|
+
temporaryBase = true;
|
|
9468
|
+
} else {
|
|
9469
|
+
const projectDir = resolve6(ctx.io.cwd, third ?? ".");
|
|
9470
|
+
if (!existsSync6(projectDir) || !statSync4(projectDir).isDirectory()) throw new CliError(`no directory ${projectDir}`);
|
|
9471
|
+
const sp = statePath(projectDir);
|
|
9472
|
+
const before = existsSync6(sp) ? readFileSync9(sp, "utf8") : null;
|
|
9473
|
+
restoreState = () => {
|
|
9474
|
+
try {
|
|
9475
|
+
if (before !== null) writeFileSync6(sp, before);
|
|
9476
|
+
else rmSync5(sp, { force: true });
|
|
9477
|
+
} catch {
|
|
9478
|
+
}
|
|
9479
|
+
};
|
|
9480
|
+
const sent = await ui2.spin(`sending ${podDirName(projectDir)} up for the crew`, runCommand(ctx, up, [projectDir, "--name", `crew-base-${Date.now().toString(36).slice(-4)}`, "--no-session", "--yes"], ctx.io.cwd));
|
|
9481
|
+
restoreState();
|
|
9482
|
+
basePodId = sent.pod.id;
|
|
9483
|
+
dir = sent.dir;
|
|
9484
|
+
temporaryBase = true;
|
|
9485
|
+
}
|
|
9486
|
+
const body = { kind, task: task2, basePodId, dir, temporaryBase, keep: bool(ctx.args, "delete") ? "delete" : "sleep", build: bool(ctx.args, "build"), pr: bool(ctx.args, "pr") };
|
|
9487
|
+
const members = membersFrom(ctx.args.flags.as ?? []);
|
|
9488
|
+
if (members) body.members = members;
|
|
9489
|
+
if (str(ctx.args, "rounds")) body.rounds = Number(str(ctx.args, "rounds"));
|
|
9490
|
+
if (str(ctx.args, "minutes")) body.maxMinutes = Number(str(ctx.args, "minutes"));
|
|
9491
|
+
let c;
|
|
9492
|
+
try {
|
|
9493
|
+
c = (await ctx.api.request("POST", "/v1/crews", { body })).crew;
|
|
9494
|
+
} catch (e) {
|
|
9495
|
+
if (temporaryBase) await ctx.api.destroy(basePodId).catch(() => void 0);
|
|
9496
|
+
throw e;
|
|
9497
|
+
}
|
|
9498
|
+
if (ctx.json || bool(ctx.args, "detach")) {
|
|
9499
|
+
if (ctx.json) return ui2.json(c);
|
|
9500
|
+
ui2.out(`${ui2.c("ok", "started")} ${c.id} (${c.recipe}): ${c.members.map((m) => `${m.role} ${AGENTS[m.agent]?.label ?? m.agent}`).join(" \xB7 ")}`);
|
|
9501
|
+
return void ui2.out(ui2.c("dim", `follow it: airgate crew show ${c.id} --follow \xB7 in the app: airgate.sh/app/crews`));
|
|
9502
|
+
}
|
|
9503
|
+
ui2.out(`${c.id} \xB7 ${c.recipe} \xB7 ${c.members.map((m) => `${m.role} ${AGENTS[m.agent]?.label ?? m.agent}`).join(" \xB7 ")}`);
|
|
9504
|
+
ui2.out(ui2.c("dim", `following the room (ctrl-c stops following; the crew goes on). in the app: airgate.sh/app/crews`));
|
|
9505
|
+
const relayer = relayIfSignedIn(ctx);
|
|
9506
|
+
try {
|
|
9507
|
+
const end = await follow(ctx, c.id);
|
|
9508
|
+
printResult(ctx, end);
|
|
9509
|
+
return end.state === "failed" ? 1 : 0;
|
|
9510
|
+
} finally {
|
|
9511
|
+
relayer?.stop();
|
|
9512
|
+
}
|
|
9513
|
+
}
|
|
9514
|
+
};
|
|
9515
|
+
|
|
9236
9516
|
// src/commands/mcp.ts
|
|
9237
9517
|
var PROTOCOL_VERSION = "2025-06-18";
|
|
9238
9518
|
var obj = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
|
|
@@ -9287,9 +9567,19 @@ var TOOLS = [
|
|
|
9287
9567
|
model: s("built-in model: an OpenRouter id, e.g. qwen/qwen3-coder"),
|
|
9288
9568
|
repo: s("GitHub repo to clone, owner/name"),
|
|
9289
9569
|
browser: b("add Chromium + Playwright the agent can drive"),
|
|
9290
|
-
jobs: { type: "array", items: jobSchema, description: "autopilot: scheduled jobs" }
|
|
9570
|
+
jobs: { type: "array", items: jobSchema, description: "autopilot: scheduled jobs" },
|
|
9571
|
+
fromSnapshot: s("start from a saved snapshot (its name, or a share code): its workspace and settings")
|
|
9291
9572
|
}),
|
|
9292
9573
|
async call(ctx, a) {
|
|
9574
|
+
if (typeof a.fromSnapshot === "string" && a.fromSnapshot) {
|
|
9575
|
+
const o = {};
|
|
9576
|
+
if (typeof a.name === "string" && a.name) o.name = a.name;
|
|
9577
|
+
if (typeof a.agent === "string" && a.agent) o.agent = a.agent;
|
|
9578
|
+
if (a.browser === true) o.browser = true;
|
|
9579
|
+
const pod2 = await waitReady(ctx, await ctx.api.launchSnapshot(a.fromSnapshot, o));
|
|
9580
|
+
OWN.set(pod2.id, "launch");
|
|
9581
|
+
return { name: pod2.spec.name, id: pod2.id, state: pod2.state, spec: pod2.spec };
|
|
9582
|
+
}
|
|
9293
9583
|
const argv = [];
|
|
9294
9584
|
const opt = (flag, v) => typeof v === "string" && v && argv.push(`--${flag}`, v);
|
|
9295
9585
|
opt("name", a.name);
|
|
@@ -9520,6 +9810,130 @@ var TOOLS = [
|
|
|
9520
9810
|
return { deleted: p.spec.name, id: p.id };
|
|
9521
9811
|
}
|
|
9522
9812
|
},
|
|
9813
|
+
{
|
|
9814
|
+
name: "pod_files",
|
|
9815
|
+
description: "List a folder of a pod's workspace (path relative to ~/workspace; '' for the top), live or at a checkpoint. Looking at a checkpoint never rewinds the pod.",
|
|
9816
|
+
inputSchema: obj({ pod: POD, path: s("folder inside the workspace (default: the top)"), checkpoint: s("a checkpoint id or label (default: the live workspace)") }, ["pod"]),
|
|
9817
|
+
async call(ctx, a) {
|
|
9818
|
+
const p = await resolvePod(ctx, need(a, "pod"));
|
|
9819
|
+
const cp = typeof a.checkpoint === "string" && a.checkpoint ? resolveCheckpoint(await ctx.api.checkpoints(p.id), a.checkpoint).id : void 0;
|
|
9820
|
+
return ctx.api.files(p.id, typeof a.path === "string" ? a.path : "", cp);
|
|
9821
|
+
}
|
|
9822
|
+
},
|
|
9823
|
+
{
|
|
9824
|
+
name: "pod_file_read",
|
|
9825
|
+
description: "Read one file of a pod's workspace, live or at a checkpoint (e.g. to compare with how it was before). Text up to 256 KB comes back as text; anything else as base64.",
|
|
9826
|
+
inputSchema: obj({ pod: POD, path: s("file inside the workspace"), checkpoint: s("a checkpoint id or label (default: live)") }, ["pod", "path"]),
|
|
9827
|
+
async call(ctx, a) {
|
|
9828
|
+
const p = await resolvePod(ctx, need(a, "pod"));
|
|
9829
|
+
const cp = typeof a.checkpoint === "string" && a.checkpoint ? resolveCheckpoint(await ctx.api.checkpoints(p.id), a.checkpoint).id : void 0;
|
|
9830
|
+
const body = await ctx.api.fileRaw(p.id, need(a, "path"), cp);
|
|
9831
|
+
const text2 = body.toString("utf8");
|
|
9832
|
+
if (body.length <= 256 * 1024 && !text2.includes("\uFFFD") && !body.includes(0)) return { path: a.path, bytes: body.length, text: text2 };
|
|
9833
|
+
return { path: a.path, bytes: body.length, base64: body.length <= 2 * 1024 * 1024 ? body.toString("base64") : void 0, note: body.length > 2 * 1024 * 1024 ? "too big to return here: airgate pull" : void 0 };
|
|
9834
|
+
}
|
|
9835
|
+
},
|
|
9836
|
+
{
|
|
9837
|
+
name: "snapshot_save",
|
|
9838
|
+
description: "Save a pod's workspace (live, or at a checkpoint) as a named snapshot new pods can start from (pod_launch with fromSnapshot). Only files: never secrets, conversations or the masking vault.",
|
|
9839
|
+
inputSchema: obj({ pod: POD, name: s("a name for it"), checkpoint: s("save it as it was at this checkpoint (default: live)") }, ["pod", "name"]),
|
|
9840
|
+
async call(ctx, a) {
|
|
9841
|
+
const p = await resolvePod(ctx, need(a, "pod"));
|
|
9842
|
+
const cp = typeof a.checkpoint === "string" && a.checkpoint ? resolveCheckpoint(await ctx.api.checkpoints(p.id), a.checkpoint).id : void 0;
|
|
9843
|
+
return ctx.api.saveSnapshot(p.id, need(a, "name"), cp);
|
|
9844
|
+
}
|
|
9845
|
+
},
|
|
9846
|
+
{
|
|
9847
|
+
name: "snapshots_list",
|
|
9848
|
+
description: "Your saved snapshots (name, state, which pod they came from, whether shared).",
|
|
9849
|
+
inputSchema: obj({}),
|
|
9850
|
+
async call(ctx) {
|
|
9851
|
+
return ctx.api.snapshots();
|
|
9852
|
+
}
|
|
9853
|
+
},
|
|
9854
|
+
{
|
|
9855
|
+
name: "pod_settings",
|
|
9856
|
+
description: "Change a pod's network after launch (open | allowlist | off, extra allowlist hosts) or switch its AI off/on (aiOff: no model calls at all). Applies immediately. Only when the user asked for it.",
|
|
9857
|
+
inputSchema: obj({ pod: POD, network: s("open | allowlist | off", { enum: ["open", "allowlist", "off"] }), allow: { type: "array", items: { type: "string" }, description: "extra allowlist hosts (replaces the list)" }, aiOff: b("true switches the pod's AI off; false back on") }, ["pod"]),
|
|
9858
|
+
async call(ctx, a) {
|
|
9859
|
+
const p = await resolvePod(ctx, need(a, "pod"));
|
|
9860
|
+
const patch = {};
|
|
9861
|
+
if (typeof a.network === "string") patch.network = a.network;
|
|
9862
|
+
if (Array.isArray(a.allow)) patch.allow = a.allow.map(String);
|
|
9863
|
+
if (typeof a.aiOff === "boolean") patch.aiOff = a.aiOff;
|
|
9864
|
+
if (!Object.keys(patch).length) throw new UsageError("nothing to change: network, allow or aiOff");
|
|
9865
|
+
const pod = await ctx.api.updatePod(p.id, patch);
|
|
9866
|
+
return { name: pod.spec.name, network: pod.spec.network, allow: pod.spec.allow ?? [], aiOff: Boolean(pod.spec.aiOff) };
|
|
9867
|
+
}
|
|
9868
|
+
},
|
|
9869
|
+
{
|
|
9870
|
+
name: "crew_start",
|
|
9871
|
+
description: "Start a crew: several agents, each in its own pod, on one task, run by airgate (it keeps going without you). kind: race (builders try the same task, a judge tests each and picks one), contest (a builder builds, a reviewer reviews and tries to break it, until it holds), council (members propose plans and critique each other, a chair decides; build: true also builds it), squad (a builder builds, testers write tests and hunt bugs, the builder fixes), split (a planner splits the work, workers build parts, an integrator merges). Starting point: the local project in dir (default), or pod, snapshot or repo. Returns at once with the crew id: follow with crew_status, bring the result back with crew_down. Tell the user it started and roughly what it costs (one pod per member while they work).",
|
|
9872
|
+
inputSchema: obj(
|
|
9873
|
+
{
|
|
9874
|
+
kind: s("race | contest | council | squad | split", { enum: ["race", "contest", "council", "squad", "split"] }),
|
|
9875
|
+
task: s("what the crew should do"),
|
|
9876
|
+
dir: s("the local project to start from (default: where the server started)"),
|
|
9877
|
+
pod: s("start from this existing pod instead"),
|
|
9878
|
+
snapshot: s("start from this saved snapshot instead"),
|
|
9879
|
+
repo: s("start from this GitHub repo instead (owner/name)"),
|
|
9880
|
+
members: { type: "array", items: { type: "string" }, description: 'role=agent[:model] overrides, e.g. ["reviewer=codex", "builder-3=opencode:qwen/qwen3-coder"]' },
|
|
9881
|
+
rounds: n("contest / squad rounds, 1\u20135"),
|
|
9882
|
+
maxMinutes: n("time limit for the whole crew (default 60)"),
|
|
9883
|
+
build: b("council: also build the decision"),
|
|
9884
|
+
pr: b("open a pull request with the result (it waits in the airlock)")
|
|
9885
|
+
},
|
|
9886
|
+
["kind", "task"]
|
|
9887
|
+
),
|
|
9888
|
+
async call(ctx, a) {
|
|
9889
|
+
const argv = [need(a, "kind"), need(a, "task"), "--detach"];
|
|
9890
|
+
const opt = (flag, v) => typeof v === "string" && v && argv.push(`--${flag}`, v);
|
|
9891
|
+
opt("pod", a.pod);
|
|
9892
|
+
opt("from", a.snapshot);
|
|
9893
|
+
opt("repo", a.repo);
|
|
9894
|
+
for (const m of Array.isArray(a.members) ? a.members : []) argv.push("--as", String(m));
|
|
9895
|
+
if (typeof a.rounds === "number") argv.push("--rounds", String(a.rounds));
|
|
9896
|
+
if (typeof a.maxMinutes === "number") argv.push("--minutes", String(a.maxMinutes));
|
|
9897
|
+
if (a.build === true) argv.push("--build");
|
|
9898
|
+
if (a.pr === true) argv.push("--pr");
|
|
9899
|
+
return runCommand(ctx, crew, argv, dirOf(ctx, a));
|
|
9900
|
+
}
|
|
9901
|
+
},
|
|
9902
|
+
{
|
|
9903
|
+
name: "crew_status",
|
|
9904
|
+
description: "A crew's state, members (and their pods), the room's messages after `since` (a message id), and the result once done.",
|
|
9905
|
+
inputSchema: obj({ crew: s("the crew id"), since: n("only messages after this id") }, ["crew"]),
|
|
9906
|
+
async call(ctx, a) {
|
|
9907
|
+
const id = encodeURIComponent(need(a, "crew"));
|
|
9908
|
+
const c = (await ctx.api.request("GET", `/v1/crews/${id}`)).crew;
|
|
9909
|
+
const since = typeof a.since === "number" ? a.since : 0;
|
|
9910
|
+
return { ...c, messages: (c.messages ?? []).filter((m) => m.id > since) };
|
|
9911
|
+
}
|
|
9912
|
+
},
|
|
9913
|
+
{
|
|
9914
|
+
name: "crew_say",
|
|
9915
|
+
description: "Post to a crew's room as the user (members read it between steps). Only what the user asked you to tell them.",
|
|
9916
|
+
inputSchema: obj({ crew: s("the crew id"), text: s("the message") }, ["crew", "text"]),
|
|
9917
|
+
async call(ctx, a) {
|
|
9918
|
+
return ctx.api.request("POST", `/v1/crews/${encodeURIComponent(need(a, "crew"))}/messages`, { body: { text: need(a, "text") } });
|
|
9919
|
+
}
|
|
9920
|
+
},
|
|
9921
|
+
{
|
|
9922
|
+
name: "crew_stop",
|
|
9923
|
+
description: "Stop a crew at its next step; its pods go to sleep with their work.",
|
|
9924
|
+
inputSchema: obj({ crew: s("the crew id") }, ["crew"]),
|
|
9925
|
+
async call(ctx, a) {
|
|
9926
|
+
return ctx.api.request("POST", `/v1/crews/${encodeURIComponent(need(a, "crew"))}/stop`);
|
|
9927
|
+
}
|
|
9928
|
+
},
|
|
9929
|
+
{
|
|
9930
|
+
name: "crew_down",
|
|
9931
|
+
description: "Bring a finished crew's result (one patch) into the local repo, with airgate down's checks (all or nothing; symlinks and paths outside refused). apply: false returns the patch without applying it.",
|
|
9932
|
+
inputSchema: obj({ crew: s("the crew id"), dir: s("the local project (default: where the server started)"), apply: b("false: just return the patch") }, ["crew"]),
|
|
9933
|
+
async call(ctx, a) {
|
|
9934
|
+
return runCommand(ctx, crew, ["down", need(a, "crew"), ...a.apply === false ? ["--dry-run"] : ["--yes"]], dirOf(ctx, a));
|
|
9935
|
+
}
|
|
9936
|
+
},
|
|
9523
9937
|
{
|
|
9524
9938
|
name: "pod_ports",
|
|
9525
9939
|
description: "What is listening in a pod (dev servers on ports 1024+) and its preview links.",
|
|
@@ -9563,7 +9977,7 @@ async function handle(ctx, msg) {
|
|
|
9563
9977
|
protocolVersion: asked,
|
|
9564
9978
|
capabilities: { tools: { listChanged: false } },
|
|
9565
9979
|
serverInfo: { name: "airgate", title: "Airgate", version: VERSION },
|
|
9566
|
-
instructions: "Airgate pods are private cloud computers for coding agents. pod_run does one task in a fresh pod and brings the result back; pod_up / pod_down move the local project and conversation there and back; task_start hands an existing pod a task (run_status / run_log follow it). Sleep pods you are done with; delete only pods you launched (or the user asked). Outbound actions wait in the airlock for the user: never approve on your own initiative."
|
|
9980
|
+
instructions: "Airgate pods are private cloud computers for coding agents; crews put several agents (each in its own pod) on one task: crew_start, crew_status, crew_down. pod_run does one task in a fresh pod and brings the result back; pod_up / pod_down move the local project and conversation there and back; task_start hands an existing pod a task (run_status / run_log follow it). Sleep pods you are done with; delete only pods you launched (or the user asked). Outbound actions wait in the airlock for the user: never approve on your own initiative."
|
|
9567
9981
|
});
|
|
9568
9982
|
}
|
|
9569
9983
|
case "ping":
|
|
@@ -9631,7 +10045,7 @@ var mcp = {
|
|
|
9631
10045
|
};
|
|
9632
10046
|
|
|
9633
10047
|
// src/commands/misc.ts
|
|
9634
|
-
import { mkdirSync as mkdirSync5, writeFileSync as
|
|
10048
|
+
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync7 } from "node:fs";
|
|
9635
10049
|
import { homedir as homedir3 } from "node:os";
|
|
9636
10050
|
import { join as join7 } from "node:path";
|
|
9637
10051
|
var PAGES = ["pods", "airlock", "usage", "settings", "mask", "chat", "notes", "data", "status"];
|
|
@@ -9712,7 +10126,7 @@ var skill = {
|
|
|
9712
10126
|
const base = bool(ctx.args, "project") ? join7(ctx.io.cwd, ".claude") : join7(ctx.io.env.HOME ?? homedir3(), ".claude");
|
|
9713
10127
|
const dir = join7(base, "skills", "airgate");
|
|
9714
10128
|
mkdirSync5(dir, { recursive: true });
|
|
9715
|
-
|
|
10129
|
+
writeFileSync7(join7(dir, "SKILL.md"), text2);
|
|
9716
10130
|
if (ctx.json) return ctx.ui.json({ installed: join7(dir, "SKILL.md") });
|
|
9717
10131
|
ctx.ui.out(`${ctx.ui.c("ok", "installed")} the airgate skill: ${join7(dir, "SKILL.md")}`);
|
|
9718
10132
|
ctx.ui.out(ctx.ui.c("dim", 'claude code picks it up in its next session. ask it to "move this to an airgate pod".'));
|
|
@@ -9867,7 +10281,7 @@ var race = {
|
|
|
9867
10281
|
async run(ctx) {
|
|
9868
10282
|
const [ref, cpRef = "current"] = ctx.args.positionals;
|
|
9869
10283
|
const p = await resolvePod(ctx, ref);
|
|
9870
|
-
const
|
|
10284
|
+
const checkpointId2 = cpRef === "current" ? "current" : resolveCheckpoint(await ctx.api.checkpoints(p.id), cpRef).id;
|
|
9871
10285
|
const overrides = {};
|
|
9872
10286
|
const agent = str(ctx.args, "agent");
|
|
9873
10287
|
if (agent) overrides.agent = agent;
|
|
@@ -9875,7 +10289,7 @@ var race = {
|
|
|
9875
10289
|
if (name) overrides.name = name;
|
|
9876
10290
|
let pod;
|
|
9877
10291
|
try {
|
|
9878
|
-
pod = await ctx.ui.spin(`forking ${p.spec.name}`, async () => waitReady(ctx, await ctx.api.fork(p.id,
|
|
10292
|
+
pod = await ctx.ui.spin(`forking ${p.spec.name}`, async () => waitReady(ctx, await ctx.api.fork(p.id, checkpointId2, overrides)));
|
|
9879
10293
|
} catch (e) {
|
|
9880
10294
|
if (e instanceof ApiError && e.code === "checkpoint_not_mounted") {
|
|
9881
10295
|
throw new CliError("that checkpoint can't be read on the provider right now", `race from the live workspace: airgate race ${p.spec.name} current (or rewind first)`);
|
|
@@ -9884,7 +10298,7 @@ var race = {
|
|
|
9884
10298
|
}
|
|
9885
10299
|
if (ctx.json) return ctx.ui.json(pod);
|
|
9886
10300
|
ctx.ui.out(readyLine(ctx, pod));
|
|
9887
|
-
ctx.ui.out(`racing ${p.spec.name} from ${
|
|
10301
|
+
ctx.ui.out(`racing ${p.spec.name} from ${checkpointId2 === "current" ? "its current state" : `"${cpRef}"`}. compare: airgate down --pod ${pod.spec.name} --dry-run`);
|
|
9888
10302
|
}
|
|
9889
10303
|
};
|
|
9890
10304
|
|
|
@@ -10148,19 +10562,19 @@ function validateForm(f) {
|
|
|
10148
10562
|
return e;
|
|
10149
10563
|
}
|
|
10150
10564
|
function formSpec(f, catalog = null) {
|
|
10151
|
-
const
|
|
10565
|
+
const network2 = NETWORK_LIST[f.network];
|
|
10152
10566
|
const template = formTemplate(f);
|
|
10153
10567
|
const spec = {
|
|
10154
10568
|
name: f.name.trim(),
|
|
10155
10569
|
agent: AGENT_LIST[f.agent],
|
|
10156
10570
|
size: SIZE_LIST[f.memory],
|
|
10157
10571
|
masking: f.mask,
|
|
10158
|
-
network,
|
|
10572
|
+
network: network2,
|
|
10159
10573
|
autoStopMinutes: null,
|
|
10160
10574
|
autoCheckpoint: f.undo
|
|
10161
10575
|
};
|
|
10162
10576
|
if (f.repo.trim()) spec.repo = f.repo.trim();
|
|
10163
|
-
if (
|
|
10577
|
+
if (network2 === "allowlist") spec.allow = DEFAULT_ALLOW2;
|
|
10164
10578
|
if (template !== "code") spec.template = template;
|
|
10165
10579
|
if (f.browser) spec.browser = true;
|
|
10166
10580
|
if (template === "autopilot") spec.jobs = [{ cron: formCron(f), task: f.task.trim(), enabled: true }];
|
|
@@ -10326,8 +10740,8 @@ function modalKey(s2, m, key) {
|
|
|
10326
10740
|
}
|
|
10327
10741
|
}
|
|
10328
10742
|
}
|
|
10329
|
-
function toast(s2, text2,
|
|
10330
|
-
const t = { id: s2.nextToast, text: text2, tone, until: s2.now + TOAST_MS };
|
|
10743
|
+
function toast(s2, text2, tone2 = "ok") {
|
|
10744
|
+
const t = { id: s2.nextToast, text: text2, tone: tone2, until: s2.now + TOAST_MS };
|
|
10331
10745
|
return { ...s2, toasts: [...s2.toasts.slice(-2), t], nextToast: s2.nextToast + 1 };
|
|
10332
10746
|
}
|
|
10333
10747
|
function moveSel(s2, d) {
|
|
@@ -11312,25 +11726,25 @@ function startTui(d) {
|
|
|
11312
11726
|
`);
|
|
11313
11727
|
const { cols, rows: rows2 } = size2();
|
|
11314
11728
|
let note = `back from ${name}. the session keeps running.`;
|
|
11315
|
-
let
|
|
11729
|
+
let tone2 = "ok";
|
|
11316
11730
|
try {
|
|
11317
11731
|
const ws = d.websocket(d.api.terminalUrl(podId, cols, rows2));
|
|
11318
11732
|
const end = await attachSession({ stdin: d.stdin, stdout: d.stdout, ws, signals: d.signals });
|
|
11319
11733
|
if (end.reason === "exit") note = `${name}: session ended (exit ${end.code})`;
|
|
11320
11734
|
else if (end.reason === "error" || end.reason === "closed" && end.code !== 1e3) {
|
|
11321
11735
|
note = `${name}: terminal ${end.reason === "error" ? end.message : "closed"}`;
|
|
11322
|
-
|
|
11736
|
+
tone2 = "bad";
|
|
11323
11737
|
}
|
|
11324
11738
|
} catch (e) {
|
|
11325
11739
|
note = `couldn't attach: ${errText(e)}`;
|
|
11326
|
-
|
|
11740
|
+
tone2 = "bad";
|
|
11327
11741
|
}
|
|
11328
11742
|
if (finished) return;
|
|
11329
11743
|
suspended = false;
|
|
11330
11744
|
shown2 = [];
|
|
11331
11745
|
d.stdout.write(ENTER_ALT);
|
|
11332
11746
|
listenKeys(true);
|
|
11333
|
-
dispatch(
|
|
11747
|
+
dispatch(tone2 === "ok" ? { type: "done", text: note } : { type: "failed", text: note });
|
|
11334
11748
|
}
|
|
11335
11749
|
async function stream() {
|
|
11336
11750
|
for (let attempt = 0; !controller.signal.aborted; attempt++) {
|
|
@@ -11437,6 +11851,171 @@ var ui = {
|
|
|
11437
11851
|
}
|
|
11438
11852
|
};
|
|
11439
11853
|
|
|
11854
|
+
// src/commands/workspace.ts
|
|
11855
|
+
import { writeFileSync as writeFileSync8, existsSync as existsSync7, statSync as statSync5 } from "node:fs";
|
|
11856
|
+
import { basename as basename3, resolve as resolve7 } from "node:path";
|
|
11857
|
+
async function checkpointId(ctx, podId, ref) {
|
|
11858
|
+
if (!ref || ref === "current") return void 0;
|
|
11859
|
+
return resolveCheckpoint(await ctx.api.checkpoints(podId), ref).id;
|
|
11860
|
+
}
|
|
11861
|
+
var files = {
|
|
11862
|
+
name: "files",
|
|
11863
|
+
aliases: ["ls-files"],
|
|
11864
|
+
group: "pods",
|
|
11865
|
+
usage: "<pod> [path] [--at <checkpoint>]",
|
|
11866
|
+
summary: "what's in a pod's workspace, live or at a checkpoint (nothing is rewound)",
|
|
11867
|
+
example: 'airgate files web src --at "#2"',
|
|
11868
|
+
notes: ["--at takes a checkpoint id, its label, or #1 for the newest (airgate checkpoints <pod>). get a file with: airgate pull <pod> <path>"],
|
|
11869
|
+
flags: { at: { type: "string", value: "checkpoint", help: "look at a checkpoint instead of the live workspace" } },
|
|
11870
|
+
async run(ctx) {
|
|
11871
|
+
const [ref, path = ""] = ctx.args.positionals;
|
|
11872
|
+
if (!ref) throw new UsageError("which pod? airgate files <pod> [path]");
|
|
11873
|
+
const pod = await resolvePod(ctx, ref);
|
|
11874
|
+
const at3 = await checkpointId(ctx, pod.id, str(ctx.args, "at"));
|
|
11875
|
+
const r = await ctx.api.files(pod.id, path, at3);
|
|
11876
|
+
if (ctx.json) return ctx.ui.json(r);
|
|
11877
|
+
if (!r.entries.length) return void ctx.ui.out(ctx.ui.c("dim", `${r.path || "~/workspace"} is empty`));
|
|
11878
|
+
ctx.ui.table(
|
|
11879
|
+
r.entries.map((e) => [e.type === "dir" ? `${e.name}/` : e.name, e.type === "dir" ? "" : ctx.ui.c("dim", bytes(e.size))]),
|
|
11880
|
+
{ head: [`${pod.spec.name}:~/workspace/${r.path}${at3 ? ` @ ${str(ctx.args, "at")}` : ""}`, ""] }
|
|
11881
|
+
);
|
|
11882
|
+
}
|
|
11883
|
+
};
|
|
11884
|
+
var pull = {
|
|
11885
|
+
name: "pull",
|
|
11886
|
+
group: "pods",
|
|
11887
|
+
usage: "<pod> <path> [--at <checkpoint>] [-o <file>]",
|
|
11888
|
+
summary: "copy one file from a pod (live or at a checkpoint) to this laptop",
|
|
11889
|
+
example: 'airgate pull web src/app.ts --at "before the refactor"',
|
|
11890
|
+
notes: ["up to 25 MB. without -o it lands in this folder under its own name (never over an existing file unless --force). -o - prints it."],
|
|
11891
|
+
flags: {
|
|
11892
|
+
at: { type: "string", value: "checkpoint", help: "from a checkpoint instead of the live workspace" },
|
|
11893
|
+
out: { type: "string", short: "o", value: "file", help: "where to save it (- prints it)" },
|
|
11894
|
+
force: { type: "boolean", help: "overwrite an existing file" }
|
|
11895
|
+
},
|
|
11896
|
+
async run(ctx) {
|
|
11897
|
+
const [ref, path] = ctx.args.positionals;
|
|
11898
|
+
if (!ref || !path) throw new UsageError("airgate pull <pod> <path>");
|
|
11899
|
+
const pod = await resolvePod(ctx, ref);
|
|
11900
|
+
const at3 = await checkpointId(ctx, pod.id, str(ctx.args, "at"));
|
|
11901
|
+
const body = await ctx.ui.spin(`reading ${path}`, ctx.api.fileRaw(pod.id, path, at3));
|
|
11902
|
+
const out = str(ctx.args, "out") ?? basename3(path);
|
|
11903
|
+
if (out === "-") return void ctx.io.stdout.write(body);
|
|
11904
|
+
const dest = resolve7(ctx.io.cwd, out);
|
|
11905
|
+
if (existsSync7(dest) && !bool(ctx.args, "force")) {
|
|
11906
|
+
if (statSync5(dest).isDirectory()) return void save2(ctx, resolve7(dest, basename3(path)), body);
|
|
11907
|
+
throw new CliError(`${out} exists here`, "pick another name with -o, or --force to overwrite");
|
|
11908
|
+
}
|
|
11909
|
+
save2(ctx, dest, body);
|
|
11910
|
+
}
|
|
11911
|
+
};
|
|
11912
|
+
function save2(ctx, dest, body) {
|
|
11913
|
+
writeFileSync8(dest, body);
|
|
11914
|
+
if (ctx.json) return ctx.ui.json({ saved: dest, bytes: body.length });
|
|
11915
|
+
ctx.ui.out(`${ctx.ui.c("ok", "saved")} ${dest} (${bytes(body.length)})`);
|
|
11916
|
+
}
|
|
11917
|
+
var ai = {
|
|
11918
|
+
name: "ai",
|
|
11919
|
+
group: "pods",
|
|
11920
|
+
usage: "<pod> on|off",
|
|
11921
|
+
summary: "switch a pod's AI off (no model calls at all) or back on",
|
|
11922
|
+
example: "airgate ai web off",
|
|
11923
|
+
notes: ["off: airgate refuses the pod's model calls, and on allowlist or off networks the model hosts are closed too (an open network can still reach them directly)."],
|
|
11924
|
+
async run(ctx) {
|
|
11925
|
+
const [ref, state] = ctx.args.positionals;
|
|
11926
|
+
if (!ref || state !== "on" && state !== "off") throw new UsageError("airgate ai <pod> on|off");
|
|
11927
|
+
const pod = await resolvePod(ctx, ref);
|
|
11928
|
+
const p = await ctx.api.updatePod(pod.id, { aiOff: state === "off" });
|
|
11929
|
+
if (ctx.json) return ctx.ui.json(p);
|
|
11930
|
+
ctx.ui.out(state === "off" ? `${ctx.ui.c("warn", "AI off")} for ${p.spec.name}: no model calls.${p.spec.network === "open" ? ctx.ui.c("dim", " (its network is open, so it could still reach a model directly: airgate network " + p.spec.name + " allowlist)") : ""}` : `${ctx.ui.c("ok", "AI on")} for ${p.spec.name}.`);
|
|
11931
|
+
}
|
|
11932
|
+
};
|
|
11933
|
+
var network = {
|
|
11934
|
+
name: "network",
|
|
11935
|
+
group: "pods",
|
|
11936
|
+
usage: "<pod> [open|allowlist|off] [--allow host,host] [--add host] [--remove host]",
|
|
11937
|
+
summary: "see or change what a pod can reach on the internet, now",
|
|
11938
|
+
example: "airgate network web allowlist --add api.stripe.com",
|
|
11939
|
+
notes: [
|
|
11940
|
+
"allowlist: common package registries and GitHub, plus your hosts; off: only the agent's model and airgate; open: anything.",
|
|
11941
|
+
"the agent's own model and sign-in hosts stay reachable unless its AI is off."
|
|
11942
|
+
],
|
|
11943
|
+
flags: {
|
|
11944
|
+
allow: { type: "string", value: "hosts", help: "replace the extra hosts (comma separated)" },
|
|
11945
|
+
add: { type: "string", value: "host", help: "add hosts (comma separated)" },
|
|
11946
|
+
remove: { type: "string", value: "host", help: "remove hosts (comma separated)" }
|
|
11947
|
+
},
|
|
11948
|
+
async run(ctx) {
|
|
11949
|
+
const [ref, mode] = ctx.args.positionals;
|
|
11950
|
+
if (!ref) throw new UsageError("airgate network <pod> [open|allowlist|off]");
|
|
11951
|
+
if (mode && !["open", "allowlist", "off"].includes(mode)) throw new UsageError("open, allowlist or off");
|
|
11952
|
+
const pod = await resolvePod(ctx, ref);
|
|
11953
|
+
const list2 = (v) => (v ?? "").split(",").map((h) => h.trim().toLowerCase()).filter(Boolean);
|
|
11954
|
+
let allow = pod.spec.allow ?? [];
|
|
11955
|
+
if (str(ctx.args, "allow") !== void 0) allow = list2(str(ctx.args, "allow"));
|
|
11956
|
+
allow = [.../* @__PURE__ */ new Set([...allow, ...list2(str(ctx.args, "add"))])].filter((h) => !list2(str(ctx.args, "remove")).includes(h));
|
|
11957
|
+
const changed = mode || ["allow", "add", "remove"].some((k) => str(ctx.args, k) !== void 0);
|
|
11958
|
+
const p = changed ? await ctx.api.updatePod(pod.id, { ...mode ? { network: mode } : {}, allow }) : pod;
|
|
11959
|
+
if (ctx.json) return ctx.ui.json({ network: p.spec.network, allow: p.spec.allow ?? [], aiOff: Boolean(p.spec.aiOff) });
|
|
11960
|
+
ctx.ui.out(`${p.spec.name}: ${p.spec.network}${p.spec.network === "allowlist" && (p.spec.allow ?? []).length ? ` + ${(p.spec.allow ?? []).join(", ")}` : ""}${p.spec.aiOff ? ctx.ui.c("warn", " \xB7 AI off") : ""}${changed ? ctx.ui.c("dim", " (applied now)") : ""}`);
|
|
11961
|
+
}
|
|
11962
|
+
};
|
|
11963
|
+
var snapshot2 = {
|
|
11964
|
+
name: "snapshot",
|
|
11965
|
+
aliases: ["snapshots"],
|
|
11966
|
+
group: "undo \xB7 race",
|
|
11967
|
+
usage: "[<pod> <name> [--at <checkpoint>] | share|unshare|rm <name> | show <code>]",
|
|
11968
|
+
summary: "save a pod as a snapshot new pods start from; share it by code",
|
|
11969
|
+
example: 'airgate snapshot web "clean base"',
|
|
11970
|
+
notes: [
|
|
11971
|
+
'a snapshot is a frozen copy of the workspace (live, or at a checkpoint): it stays when the pod is deleted. start a pod from it: airgate launch --from "<name>" (or a share code).',
|
|
11972
|
+
"only the workspace is copied: never the pod's secrets, its agent's conversations or its masking vault. share gives a code anyone signed in to airgate can launch from; unshare stops it.",
|
|
11973
|
+
"with no arguments: your snapshots."
|
|
11974
|
+
],
|
|
11975
|
+
flags: { at: { type: "string", value: "checkpoint", help: "save the workspace as it was at a checkpoint" }, yes: { type: "boolean", short: "y", help: "don't ask" } },
|
|
11976
|
+
async run(ctx) {
|
|
11977
|
+
const { ui: ui2 } = ctx;
|
|
11978
|
+
const [a, b2, extra] = ctx.args.positionals;
|
|
11979
|
+
if (extra) throw new UsageError(`unexpected "${extra}" (put names with spaces in quotes)`);
|
|
11980
|
+
if (!a) {
|
|
11981
|
+
const list2 = await ctx.api.snapshots();
|
|
11982
|
+
if (ctx.json) return ui2.json(list2);
|
|
11983
|
+
if (!list2.length) return void ui2.out(ui2.c("dim", "no snapshots yet. save one: airgate snapshot <pod> <name>"));
|
|
11984
|
+
ui2.table(
|
|
11985
|
+
list2.map((s3) => [s3.name, s3.state === "ready" ? ui2.c("ok", "ready") : s3.state === "failed" ? ui2.c("bad", "failed") : ui2.c("warn", "saving"), `from ${s3.from.podName}`, s3.shareCode ? ui2.c("warn", `shared: ${s3.shareCode}`) : ui2.c("dim", "private")]),
|
|
11986
|
+
{ head: ["snapshot", "", "", ""] }
|
|
11987
|
+
);
|
|
11988
|
+
return void ui2.out(ui2.c("dim", 'start a pod from one: airgate launch --from "<name>"'));
|
|
11989
|
+
}
|
|
11990
|
+
if (a === "share" || a === "unshare") {
|
|
11991
|
+
if (!b2) throw new UsageError(`airgate snapshot ${a} <name>`);
|
|
11992
|
+
const s3 = await ctx.api.shareSnapshot(b2, a === "share");
|
|
11993
|
+
if (ctx.json) return ui2.json(s3);
|
|
11994
|
+
if (a === "unshare") return void ui2.out(`${s3.name}: private again; its old code no longer works.`);
|
|
11995
|
+
ui2.out(`${s3.name}: share code ${s3.shareCode ?? ""}`);
|
|
11996
|
+
return void ui2.out(ui2.c("dim", `anyone signed in to airgate can start a pod from it: airgate launch --from ${s3.shareCode}. it shares only the workspace files.`));
|
|
11997
|
+
}
|
|
11998
|
+
if (a === "rm" || a === "delete") {
|
|
11999
|
+
if (!b2) throw new UsageError("airgate snapshot rm <name>");
|
|
12000
|
+
if (!await confirm(ctx, `delete the snapshot "${b2}"? pods already started from it keep their files.`)) throw new CliError("nothing deleted");
|
|
12001
|
+
await ctx.api.deleteSnapshot(b2);
|
|
12002
|
+
return ctx.json ? ui2.json({ ok: true }) : void ui2.out(`deleted ${b2}.`);
|
|
12003
|
+
}
|
|
12004
|
+
if (a === "show") {
|
|
12005
|
+
if (!b2) throw new UsageError("airgate snapshot show <code>");
|
|
12006
|
+
const s3 = await ctx.api.sharedSnapshot(b2);
|
|
12007
|
+
if (ctx.json) return ui2.json(s3);
|
|
12008
|
+
return void ui2.out(`${s3.name} \xB7 ${s3.agent} \xB7 ${s3.template}${s3.state !== "ready" ? ` \xB7 ${s3.state}` : ""}. start a pod from it: airgate launch --from ${b2}`);
|
|
12009
|
+
}
|
|
12010
|
+
if (!b2) throw new UsageError("airgate snapshot <pod> <name> (or: airgate snapshots)");
|
|
12011
|
+
const pod = await resolvePod(ctx, a);
|
|
12012
|
+
const at3 = await checkpointId(ctx, pod.id, str(ctx.args, "at"));
|
|
12013
|
+
const s2 = await ui2.spin(`saving ${pod.spec.name} as "${b2}"`, ctx.api.saveSnapshot(pod.id, b2, at3));
|
|
12014
|
+
if (ctx.json) return ui2.json(s2);
|
|
12015
|
+
ui2.out(`${ui2.c("ok", "saved")} "${s2.name}" from ${pod.spec.name}${at3 ? ` at ${str(ctx.args, "at")}` : ""}. ${ui2.c("dim", `start a pod from it: airgate launch --from "${s2.name}" \xB7 share it: airgate snapshot share "${s2.name}"`)}`);
|
|
12016
|
+
}
|
|
12017
|
+
};
|
|
12018
|
+
|
|
11440
12019
|
// src/cli.ts
|
|
11441
12020
|
var help = {
|
|
11442
12021
|
name: "help",
|
|
@@ -11493,6 +12072,12 @@ var COMMANDS = [
|
|
|
11493
12072
|
runTask,
|
|
11494
12073
|
models,
|
|
11495
12074
|
relay,
|
|
12075
|
+
crew,
|
|
12076
|
+
files,
|
|
12077
|
+
pull,
|
|
12078
|
+
snapshot2,
|
|
12079
|
+
ai,
|
|
12080
|
+
network,
|
|
11496
12081
|
usage,
|
|
11497
12082
|
plan,
|
|
11498
12083
|
upgrade,
|