airgate 0.1.5 → 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 +339 -39
  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
@@ -1984,7 +1984,7 @@ function borderOf(name, v) {
1984
1984
  return { chars: chars3, title, color, pad: pad3 };
1985
1985
  }
1986
1986
  function box(s2, { x, y, cols: w, rows: h }, chars3, title, color) {
1987
- 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));
1988
1988
  const set = (cx, cy, ch) => {
1989
1989
  const i = s2.index(cx, cy);
1990
1990
  if (i >= 0)
@@ -1993,7 +1993,7 @@ function box(s2, { x, y, cols: w, rows: h }, chars3, title, color) {
1993
1993
  for (let c = 1; c < w - 1; c++)
1994
1994
  set(x + c, y, across2), set(x + c, y + h - 1, across2);
1995
1995
  for (let r = 1; r < h - 1; r++)
1996
- 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);
1997
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);
1998
1998
  if (title && w >= 7) {
1999
1999
  const room = w - 6;
@@ -2984,10 +2984,10 @@ function outline(src, options) {
2984
2984
  if (!line2(x, y))
2985
2985
  continue;
2986
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));
2987
- 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));
2988
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));
2989
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));
2990
- 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);
2991
2991
  if (!bits)
2992
2992
  continue;
2993
2993
  let c = ink;
@@ -3014,7 +3014,7 @@ function shadow(src, options) {
3014
3014
  fail2(`shadow.solid takes true or false, not ${show(o.solid)}`);
3015
3015
  const solid = o.solid ?? true;
3016
3016
  return build2("shadow", src, o, ({ meta: m, inks }) => {
3017
- 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];
3018
3018
  const cols = m.cols + Math.abs(dx), rows2 = m.rows + Math.abs(dy);
3019
3019
  const x0 = dx < 0 ? -dx : 0, y0 = dy < 0 ? -dy : 0;
3020
3020
  return {
@@ -3026,7 +3026,7 @@ function shadow(src, options) {
3026
3026
  const ink = new Uint8Array(cols * rows2), out = new Uint8Array(cols * rows2).fill(1), queue = new Int32Array(cols * rows2);
3027
3027
  const over2 = { mask: null };
3028
3028
  return (t, s2, g, ctx) => {
3029
- const c = tone[ctx.paper ? 1 : 0];
3029
+ const c = tone2[ctx.paper ? 1 : 0];
3030
3030
  if (solid) {
3031
3031
  ink.fill(0);
3032
3032
  for (let y = 0, i = 0; y < g.rows; y++)
@@ -5757,9 +5757,9 @@ function colorOn(stream, env) {
5757
5757
  }
5758
5758
  var ANSI = /\u001b\[[0-9;]*m/g;
5759
5759
  var visibleWidth = (s2) => [...s2.replace(ANSI, "")].length;
5760
- function paint2(on, tone, s2) {
5760
+ function paint2(on, tone2, s2) {
5761
5761
  if (!on) return s2;
5762
- const [a, b2] = CODES[tone];
5762
+ const [a, b2] = CODES[tone2];
5763
5763
  return `\x1B[${a}m${s2}\x1B[${b2}m`;
5764
5764
  }
5765
5765
  function formatTable(rows2, head, indent = 0) {
@@ -5776,16 +5776,16 @@ function paintArt(on, a) {
5776
5776
  if (!on || !tones2) return line2.trimEnd();
5777
5777
  let out = "";
5778
5778
  let run2 = "";
5779
- let tone;
5779
+ let tone2;
5780
5780
  const flush = () => {
5781
- if (run2) out += tone ? paint2(true, tone, run2) : run2;
5781
+ if (run2) out += tone2 ? paint2(true, tone2, run2) : run2;
5782
5782
  run2 = "";
5783
5783
  };
5784
5784
  [...line2].forEach((ch, x) => {
5785
- const t = ch === " " ? tone : tones2[x];
5786
- if (t !== tone) {
5785
+ const t = ch === " " ? tone2 : tones2[x];
5786
+ if (t !== tone2) {
5787
5787
  flush();
5788
- tone = t;
5788
+ tone2 = t;
5789
5789
  }
5790
5790
  run2 += ch;
5791
5791
  });
@@ -5800,8 +5800,8 @@ function createUi(io) {
5800
5800
  tty: Boolean(io.stdout.isTTY),
5801
5801
  out: (line2 = "") => void io.stdout.write(line2 + "\n"),
5802
5802
  err: (line2 = "") => void io.stderr.write(line2 + "\n"),
5803
- c: (tone, s2) => paint2(outColor, tone, s2),
5804
- ce: (tone, s2) => paint2(errColor, tone, s2),
5803
+ c: (tone2, s2) => paint2(outColor, tone2, s2),
5804
+ ce: (tone2, s2) => paint2(errColor, tone2, s2),
5805
5805
  json: (v) => void io.stdout.write(JSON.stringify(v, null, 2) + "\n"),
5806
5806
  table(rows2, opts2 = {}) {
5807
5807
  const head = opts2.head ? opts2.head.map((h) => paint2(outColor, "dim", h)) : void 0;
@@ -7777,7 +7777,7 @@ var log = {
7777
7777
  import { createInterface } from "node:readline";
7778
7778
 
7779
7779
  // src/version.ts
7780
- var VERSION = "0.1.5";
7780
+ var VERSION = "0.1.6";
7781
7781
 
7782
7782
  // src/commands/updown.ts
7783
7783
  import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync5, realpathSync as realpathSync2, statSync as statSync2, writeFileSync as writeFileSync3 } from "node:fs";
@@ -7829,7 +7829,7 @@ function gitFiles(dir) {
7829
7829
  return out;
7830
7830
  }
7831
7831
  function runTar(cwd, args, input, limit = UPLOAD_LIMIT) {
7832
- return new Promise((resolve7, reject) => {
7832
+ return new Promise((resolve8, reject) => {
7833
7833
  const child = spawn("tar", args, { cwd, env: { ...process.env, COPYFILE_DISABLE: "1" }, stdio: ["pipe", "pipe", "pipe"] });
7834
7834
  const chunks = [];
7835
7835
  let size2 = 0;
@@ -7849,7 +7849,7 @@ function runTar(cwd, args, input, limit = UPLOAD_LIMIT) {
7849
7849
  child.on("close", (code3) => {
7850
7850
  if (over2) return reject(new TooLargeError(limit));
7851
7851
  if (code3 !== 0) return reject(new Error(`tar failed: ${err.trim().split("\n").slice(-3).join(" ")}`));
7852
- resolve7(Buffer.concat(chunks));
7852
+ resolve8(Buffer.concat(chunks));
7853
7853
  });
7854
7854
  child.stdin.on("error", () => void 0);
7855
7855
  child.stdin.end(input ?? "");
@@ -8637,7 +8637,7 @@ var LineReader = class {
8637
8637
  }
8638
8638
  };
8639
8639
  function ttyPrompt(stdin, out) {
8640
- return (question, opts2 = {}) => new Promise((resolve7, reject) => {
8640
+ return (question, opts2 = {}) => new Promise((resolve8, reject) => {
8641
8641
  out.write(question);
8642
8642
  const decoder = new StringDecoder2("utf8");
8643
8643
  let answer = "";
@@ -8650,7 +8650,7 @@ function ttyPrompt(stdin, out) {
8650
8650
  stdin.pause();
8651
8651
  out.write("\n");
8652
8652
  if (err) reject(err);
8653
- else resolve7(answer);
8653
+ else resolve8(answer);
8654
8654
  };
8655
8655
  const onData = (d) => {
8656
8656
  for (const ch of decoder.write(d)) {
@@ -8677,16 +8677,16 @@ function ttyPrompt(stdin, out) {
8677
8677
  }
8678
8678
  function openInBrowser(url) {
8679
8679
  const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url]] : ["xdg-open", [url]];
8680
- return new Promise((resolve7) => {
8680
+ return new Promise((resolve8) => {
8681
8681
  try {
8682
8682
  const child = spawn2(cmd, args, { stdio: "ignore", detached: true });
8683
- child.on("error", () => resolve7(false));
8683
+ child.on("error", () => resolve8(false));
8684
8684
  child.on("spawn", () => {
8685
8685
  child.unref();
8686
- resolve7(true);
8686
+ resolve8(true);
8687
8687
  });
8688
8688
  } catch {
8689
- resolve7(false);
8689
+ resolve8(false);
8690
8690
  }
8691
8691
  });
8692
8692
  }
@@ -9282,6 +9282,237 @@ When you are done, leave your changes in the files there (no need to commit or p
9282
9282
  }
9283
9283
  };
9284
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
+
9285
9516
  // src/commands/mcp.ts
9286
9517
  var PROTOCOL_VERSION = "2025-06-18";
9287
9518
  var obj = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
@@ -9635,6 +9866,74 @@ var TOOLS = [
9635
9866
  return { name: pod.spec.name, network: pod.spec.network, allow: pod.spec.allow ?? [], aiOff: Boolean(pod.spec.aiOff) };
9636
9867
  }
9637
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
+ },
9638
9937
  {
9639
9938
  name: "pod_ports",
9640
9939
  description: "What is listening in a pod (dev servers on ports 1024+) and its preview links.",
@@ -9678,7 +9977,7 @@ async function handle(ctx, msg) {
9678
9977
  protocolVersion: asked,
9679
9978
  capabilities: { tools: { listChanged: false } },
9680
9979
  serverInfo: { name: "airgate", title: "Airgate", version: VERSION },
9681
- 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."
9682
9981
  });
9683
9982
  }
9684
9983
  case "ping":
@@ -9746,7 +10045,7 @@ var mcp = {
9746
10045
  };
9747
10046
 
9748
10047
  // src/commands/misc.ts
9749
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync6 } from "node:fs";
10048
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync7 } from "node:fs";
9750
10049
  import { homedir as homedir3 } from "node:os";
9751
10050
  import { join as join7 } from "node:path";
9752
10051
  var PAGES = ["pods", "airlock", "usage", "settings", "mask", "chat", "notes", "data", "status"];
@@ -9827,7 +10126,7 @@ var skill = {
9827
10126
  const base = bool(ctx.args, "project") ? join7(ctx.io.cwd, ".claude") : join7(ctx.io.env.HOME ?? homedir3(), ".claude");
9828
10127
  const dir = join7(base, "skills", "airgate");
9829
10128
  mkdirSync5(dir, { recursive: true });
9830
- writeFileSync6(join7(dir, "SKILL.md"), text2);
10129
+ writeFileSync7(join7(dir, "SKILL.md"), text2);
9831
10130
  if (ctx.json) return ctx.ui.json({ installed: join7(dir, "SKILL.md") });
9832
10131
  ctx.ui.out(`${ctx.ui.c("ok", "installed")} the airgate skill: ${join7(dir, "SKILL.md")}`);
9833
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".'));
@@ -10441,8 +10740,8 @@ function modalKey(s2, m, key) {
10441
10740
  }
10442
10741
  }
10443
10742
  }
10444
- function toast(s2, text2, tone = "ok") {
10445
- 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 };
10446
10745
  return { ...s2, toasts: [...s2.toasts.slice(-2), t], nextToast: s2.nextToast + 1 };
10447
10746
  }
10448
10747
  function moveSel(s2, d) {
@@ -11427,25 +11726,25 @@ function startTui(d) {
11427
11726
  `);
11428
11727
  const { cols, rows: rows2 } = size2();
11429
11728
  let note = `back from ${name}. the session keeps running.`;
11430
- let tone = "ok";
11729
+ let tone2 = "ok";
11431
11730
  try {
11432
11731
  const ws = d.websocket(d.api.terminalUrl(podId, cols, rows2));
11433
11732
  const end = await attachSession({ stdin: d.stdin, stdout: d.stdout, ws, signals: d.signals });
11434
11733
  if (end.reason === "exit") note = `${name}: session ended (exit ${end.code})`;
11435
11734
  else if (end.reason === "error" || end.reason === "closed" && end.code !== 1e3) {
11436
11735
  note = `${name}: terminal ${end.reason === "error" ? end.message : "closed"}`;
11437
- tone = "bad";
11736
+ tone2 = "bad";
11438
11737
  }
11439
11738
  } catch (e) {
11440
11739
  note = `couldn't attach: ${errText(e)}`;
11441
- tone = "bad";
11740
+ tone2 = "bad";
11442
11741
  }
11443
11742
  if (finished) return;
11444
11743
  suspended = false;
11445
11744
  shown2 = [];
11446
11745
  d.stdout.write(ENTER_ALT);
11447
11746
  listenKeys(true);
11448
- dispatch(tone === "ok" ? { type: "done", text: note } : { type: "failed", text: note });
11747
+ dispatch(tone2 === "ok" ? { type: "done", text: note } : { type: "failed", text: note });
11449
11748
  }
11450
11749
  async function stream() {
11451
11750
  for (let attempt = 0; !controller.signal.aborted; attempt++) {
@@ -11553,8 +11852,8 @@ var ui = {
11553
11852
  };
11554
11853
 
11555
11854
  // src/commands/workspace.ts
11556
- import { writeFileSync as writeFileSync7, existsSync as existsSync6, statSync as statSync4 } from "node:fs";
11557
- import { basename as basename3, resolve as resolve6 } from "node:path";
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";
11558
11857
  async function checkpointId(ctx, podId, ref) {
11559
11858
  if (!ref || ref === "current") return void 0;
11560
11859
  return resolveCheckpoint(await ctx.api.checkpoints(podId), ref).id;
@@ -11602,16 +11901,16 @@ var pull = {
11602
11901
  const body = await ctx.ui.spin(`reading ${path}`, ctx.api.fileRaw(pod.id, path, at3));
11603
11902
  const out = str(ctx.args, "out") ?? basename3(path);
11604
11903
  if (out === "-") return void ctx.io.stdout.write(body);
11605
- const dest = resolve6(ctx.io.cwd, out);
11606
- if (existsSync6(dest) && !bool(ctx.args, "force")) {
11607
- if (statSync4(dest).isDirectory()) return void save2(ctx, resolve6(dest, basename3(path)), 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);
11608
11907
  throw new CliError(`${out} exists here`, "pick another name with -o, or --force to overwrite");
11609
11908
  }
11610
11909
  save2(ctx, dest, body);
11611
11910
  }
11612
11911
  };
11613
11912
  function save2(ctx, dest, body) {
11614
- writeFileSync7(dest, body);
11913
+ writeFileSync8(dest, body);
11615
11914
  if (ctx.json) return ctx.ui.json({ saved: dest, bytes: body.length });
11616
11915
  ctx.ui.out(`${ctx.ui.c("ok", "saved")} ${dest} (${bytes(body.length)})`);
11617
11916
  }
@@ -11773,6 +12072,7 @@ var COMMANDS = [
11773
12072
  runTask,
11774
12073
  models,
11775
12074
  relay,
12075
+ crew,
11776
12076
  files,
11777
12077
  pull,
11778
12078
  snapshot2,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airgate",
3
- "version": "0.1.5",
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": {