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.
Files changed (3) hide show
  1. package/README.md +18 -1
  2. package/dist/airgate.mjs +657 -72
  3. 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. It
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, checkpointId) {
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, checkpointId, overrides) {
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, down3] = Array.from(chars3, (ch) => ch.charCodeAt(0));
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, down3), set(x + w - 1, y + r, down3);
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 down3 = 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));
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) | (down3 ? 2 : 0) | (left ? 4 : 0) | (right ? 8 : 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 tone = color ? [inks.add(color), inks.add(color)] : m.palette ? [inks.add(QUIET2.dark), inks.add(QUIET2.light)] : [NONE, NONE];
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 = tone[ctx.paper ? 1 : 0];
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, tone, s2) {
5760
+ function paint2(on, tone2, s2) {
5728
5761
  if (!on) return s2;
5729
- const [a, b2] = CODES[tone];
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 tone;
5779
+ let tone2;
5747
5780
  const flush = () => {
5748
- if (run2) out += tone ? paint2(true, tone, run2) : run2;
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 === " " ? tone : tones2[x];
5753
- if (t !== tone) {
5785
+ const t = ch === " " ? tone2 : tones2[x];
5786
+ if (t !== tone2) {
5754
5787
  flush();
5755
- tone = t;
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: (tone, s2) => paint2(outColor, tone, s2),
5771
- ce: (tone, s2) => paint2(errColor, tone, s2),
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 network = str(a, "network") ?? "allowlist";
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 (network === "allowlist") spec.allow = [.../* @__PURE__ */ new Set([...DEFAULT_ALLOW, ...list(a, "allow").flatMap((h) => h.split(",")).map((h) => h.trim()).filter(Boolean)])];
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
- if (!await confirm(ctx, `destroy ${p.spec.name} (${p.id})? its files and checkpoints are gone for good.`)) {
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.4";
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((resolve6, reject) => {
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
- resolve6(Buffer.concat(chunks));
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 files = all.filter((f) => !skipped2.includes(f));
7815
- if (!files.length) throw new Error("no files to send (git sees nothing tracked or untracked here)");
7816
- const archive2 = await runTar(dir, ["--null", "-T", "-", "-czf", "-"], files.join("\0") + "\0", limit);
7817
- return { archive: archive2, files: files.length, git: true, skipped: skipped2 };
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 files = [];
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)) files.push({ f: full, m: statSync(full).mtimeMs });
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 files.sort((a, b2) => b2.m - a.m).slice(0, limit).map((x) => x.f);
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(files) {
8165
- return [...files.filter((f) => f.risky), ...files.filter((f) => !f.risky)];
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 files = orderFiles(blocks.map((b2) => b2.file));
8420
- const width = Math.max(...files.map((f) => f.path.length + (f.from ? f.from.length + 4 : 0)));
8421
- for (const f of files) {
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 files = orderFiles(blocks.map((b2) => b2.file));
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 = files.filter((f) => f.risky).length;
8484
- const head = `${files.length} file(s) from ${pod.spec.name}${risky ? `, ${risky} risky` : ""}:`;
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 ${files.length} file(s)${risky ? ` (${risky} risky)` : ""}?`)) throw new CliError("nothing applied");
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 ${files.length} file(s). review: git status \xB7 git diff`);
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((resolve6, reject) => {
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 resolve6(answer);
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((resolve6) => {
8680
+ return new Promise((resolve8) => {
8632
8681
  try {
8633
8682
  const child = spawn2(cmd, args, { stdio: "ignore", detached: true });
8634
- child.on("error", () => resolve6(false));
8683
+ child.on("error", () => resolve8(false));
8635
8684
  child.on("spawn", () => {
8636
8685
  child.unref();
8637
- resolve6(true);
8686
+ resolve8(true);
8638
8687
  });
8639
8688
  } catch {
8640
- resolve6(false);
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 files = result.files ?? [];
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 && files.length > 0, files, ...noApply ? { patch: result.patch ?? "" } : {}, pod_state: keep ? "sleeping" : "deleted" });
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(`${files.length} file(s) changed, not applied. apply: airgate down --pod ${name} \xB7 delete the pod: airgate destroy ${name}`);
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 = files.filter((f) => f.risky).length;
9276
+ const risky = files2.filter((f) => f.risky).length;
9228
9277
  ui2.out(
9229
- `${ui2.c("ok", "done")}: ${files.length ? `${files.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")
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 writeFileSync6 } from "node:fs";
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
- writeFileSync6(join7(dir, "SKILL.md"), text2);
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 checkpointId = cpRef === "current" ? "current" : resolveCheckpoint(await ctx.api.checkpoints(p.id), cpRef).id;
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, checkpointId, overrides)));
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 ${checkpointId === "current" ? "its current state" : `"${cpRef}"`}. compare: airgate down --pod ${pod.spec.name} --dry-run`);
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 network = NETWORK_LIST[f.network];
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 (network === "allowlist") spec.allow = DEFAULT_ALLOW2;
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, tone = "ok") {
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 tone = "ok";
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
- tone = "bad";
11736
+ tone2 = "bad";
11323
11737
  }
11324
11738
  } catch (e) {
11325
11739
  note = `couldn't attach: ${errText(e)}`;
11326
- tone = "bad";
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(tone === "ok" ? { type: "done", text: note } : { type: "failed", text: note });
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airgate",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Give your AI its own computer: the airgate command line.",
5
5
  "type": "module",
6
6
  "bin": {