airgate 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -25,7 +25,7 @@ macOS and Linux, Node.js 20.10 or newer.
25
25
  airgate login # approve this terminal in your browser
26
26
  airgate # the full-screen terminal app
27
27
  airgate launch # a new pod
28
- airgate up # move the session you're in to a pod; airgate down brings the work back
28
+ airgate up # move the session you're in to a pod (files, conversation, memory); airgate down brings it back
29
29
  airgate help # every command
30
30
  ```
31
31
 
@@ -68,7 +68,7 @@ Guide: https://airgate.sh/docs/teams
68
68
  ## For your agent
69
69
 
70
70
  Claude Code or Codex on your laptop can send work to pods on its own: one task in a fresh pod
71
- with the result back here, or the whole project and conversation up and back.
71
+ with the result back here, or the whole project, conversation and memory up and back.
72
72
 
73
73
  ```sh
74
74
  airgate run "get the failing tests passing" # a fresh pod; the result comes back as a patch
package/dist/airgate.mjs CHANGED
@@ -130,6 +130,12 @@ var init_pack = __esm({
130
130
  });
131
131
 
132
132
  // ../sandbox/src/types.ts
133
+ var AIRLOCK_MODES = ["ask", "branches", "off"];
134
+ var AIRLOCK_MODE_INFO = {
135
+ ask: { name: "ask", line: "everything waits for you: pushes, pull requests, email, deploys, outside calls" },
136
+ branches: { name: "work branches go through", line: "pushes to work branches go through on their own; main, force pushes, pull requests, email and deploys still wait" },
137
+ off: { name: "off", line: "everything goes through on its own. it's all still recorded in the airlock, but nothing waits for you" }
138
+ };
133
139
  var SIZES = {
134
140
  small: { cpu: 8, memoryGb: 4, diskGb: 100 },
135
141
  standard: { cpu: 8, memoryGb: 8, diskGb: 100 },
@@ -813,6 +819,9 @@ var Api = class {
813
819
  resumeAgentSession(id, body) {
814
820
  return this.request("POST", `/v1/pods/${enc(id)}/agent-session/resume`, { body });
815
821
  }
822
+ exportAgentMemory(id, dir) {
823
+ return this.request("GET", `/v1/pods/${enc(id)}/agent-memory?${new URLSearchParams({ dir })}`);
824
+ }
816
825
  exportAgentSession(id, q) {
817
826
  const qs = new URLSearchParams({ agent: q.agent, dir: q.dir, ...q.id ? { id: q.id } : {} });
818
827
  return this.request("GET", `/v1/pods/${enc(id)}/agent-session?${qs}`);
@@ -2911,12 +2920,12 @@ function typeIn(src, options) {
2911
2920
  };
2912
2921
  });
2913
2922
  }
2914
- function level(t, period2, mode) {
2923
+ function level(t, period2, mode2) {
2915
2924
  const u = mod(t, period2) / period2;
2916
2925
  const ease2 = (k) => smoothstep(0, 1, k);
2917
- if (mode === "in")
2926
+ if (mode2 === "in")
2918
2927
  return u < 0.4 ? ease2(u / 0.4) : 1;
2919
- if (mode === "out")
2928
+ if (mode2 === "out")
2920
2929
  return u < 0.6 ? 1 : 1 - ease2((u - 0.6) / 0.4);
2921
2930
  return u < 0.3 ? ease2(u / 0.3) : u < 0.6 ? 1 : u < 0.9 ? 1 - ease2((u - 0.6) / 0.3) : 0;
2922
2931
  }
@@ -2926,7 +2935,7 @@ var MODE_WHAT = { in: "in", out: "out", inout: "in and out" };
2926
2935
  function dissolve(src, options) {
2927
2936
  const o = optionsOf2(options, "dissolve", ["period", "mode", "blob", "edge", "seed"]);
2928
2937
  const period2 = num(o.period, "dissolve.period", 6, "seconds");
2929
- const mode = choice(o.mode, "dissolve.mode", MODES, "inout");
2938
+ const mode2 = choice(o.mode, "dissolve.mode", MODES, "inout");
2930
2939
  const scale2 = 1 / num(o.blob, "dissolve.blob", 7, "atLeast1");
2931
2940
  const auto = o.edge === void 0;
2932
2941
  const edge = o.edge === "" ? "" : chars2(o.edge, "dissolve.edge", ".:", 1);
@@ -2944,10 +2953,10 @@ function dissolve(src, options) {
2944
2953
  return {
2945
2954
  moves: true,
2946
2955
  period: period2,
2947
- still: HELD[mode] * period2,
2948
- what: `dissolving ${MODE_WHAT[mode]}`,
2956
+ still: HELD[mode2] * period2,
2957
+ what: `dissolving ${MODE_WHAT[mode2]}`,
2949
2958
  setup: () => (t, s2, g) => {
2950
- const k = level(t, period2, mode);
2959
+ const k = level(t, period2, mode2);
2951
2960
  if (k >= 1)
2952
2961
  return copy(s2, g);
2953
2962
  const front = k * (1 + band);
@@ -2968,7 +2977,7 @@ function dissolve(src, options) {
2968
2977
  function fade2(src, options) {
2969
2978
  const o = optionsOf2(options, "fade", ["period", "mode", "ramp"]);
2970
2979
  const period2 = num(o.period, "fade.period", 6, "seconds");
2971
- const mode = choice(o.mode, "fade.mode", MODES, "inout");
2980
+ const mode2 = choice(o.mode, "fade.mode", MODES, "inout");
2972
2981
  const auto = o.ramp === void 0 || o.ramp === "auto";
2973
2982
  const chars3 = ramp(auto ? "standard" : o.ramp);
2974
2983
  if (chars3[0] !== " ")
@@ -2979,8 +2988,8 @@ function fade2(src, options) {
2979
2988
  return build2("fade", src, o, () => ({
2980
2989
  moves: true,
2981
2990
  period: period2,
2982
- still: HELD[mode] * period2,
2983
- what: `fading ${MODE_WHAT[mode]}`,
2991
+ still: HELD[mode2] * period2,
2992
+ what: `fading ${MODE_WHAT[mode2]}`,
2984
2993
  setup: () => {
2985
2994
  const steps = /* @__PURE__ */ new Map();
2986
2995
  const stepsOf = (c) => {
@@ -2993,7 +3002,7 @@ function fade2(src, options) {
2993
3002
  return v;
2994
3003
  };
2995
3004
  return (t, s2, g) => {
2996
- const k = level(t, period2, mode);
3005
+ const k = level(t, period2, mode2);
2997
3006
  if (k >= 1)
2998
3007
  return copy(s2, g);
2999
3008
  for (let y = 0, i = 0; y < g.rows; y++)
@@ -7067,7 +7076,7 @@ function approvalRows(ctx, items, names) {
7067
7076
  ctx.ui.c(a.state === "pending" ? "warn" : a.state === "denied" || a.state === "failed" ? "bad" : "ok", a.state),
7068
7077
  a.kind,
7069
7078
  names.get(a.podId) ?? (a.requestedBy ? `${a.requestedBy}'s pod` : a.podId),
7070
- a.summary + (a.requestedBy ? ctx.ui.c("dim", ` \xB7 from ${a.requestedBy}`) : "") + (a.needsApprover && a.state === "pending" && !a.requestedBy ? ctx.ui.c("warn", " \xB7 waits for an approver") : ""),
7079
+ a.summary + (a.auto ? ctx.ui.c("dim", ` \xB7 went through on its own (${a.auto})`) : "") + (a.requestedBy ? ctx.ui.c("dim", ` \xB7 from ${a.requestedBy}`) : "") + (a.needsApprover && a.state === "pending" && !a.requestedBy ? ctx.ui.c("warn", " \xB7 waits for an approver") : ""),
7071
7080
  ago(a.createdAt),
7072
7081
  ctx.ui.c("dim", a.id)
7073
7082
  ]);
@@ -7080,12 +7089,21 @@ var airlock = {
7080
7089
  name: "airlock",
7081
7090
  aliases: ["approvals"],
7082
7091
  group: "airlock \xB7 mask",
7083
- usage: "[--watch]",
7084
- summary: "what your pods want to send out: pending first, then recent",
7085
- example: "airgate airlock --watch",
7086
- notes: ["--watch follows the live stream and prints each new request as it arrives; ctrl-c to stop."],
7087
- flags: { watch: { type: "boolean", short: "w", help: "follow new requests" } },
7092
+ usage: "[--watch] | mode [ask|branches|off|account] [--pod p]",
7093
+ summary: "what your pods want to send out: pending first, then recent \xB7 how strict it is",
7094
+ example: "airgate airlock mode branches",
7095
+ notes: [
7096
+ "--watch follows the live stream and prints each new request as it arrives; ctrl-c to stop.",
7097
+ "mode: how much waits for you, like Claude Code's permission modes. ask (the default): everything waits. branches: pushes to work branches go through on their own; main, force pushes, pull requests, email and deploys still wait. off: everything goes through, still recorded.",
7098
+ "mode with --pod sets one pod's own mode (account: back to the account's). a team's approver rules always win."
7099
+ ],
7100
+ flags: {
7101
+ watch: { type: "boolean", short: "w", help: "follow new requests" },
7102
+ pod: { type: "string", value: "pod", help: "with mode: this pod only" }
7103
+ },
7088
7104
  async run(ctx) {
7105
+ if (ctx.args.positionals[0] === "mode") return mode(ctx);
7106
+ if (ctx.args.positionals.length) throw new UsageError("airgate airlock [--watch] | airgate airlock mode [ask|branches|off]");
7089
7107
  if (bool(ctx.args, "watch")) return watch(ctx);
7090
7108
  const [items, names] = await Promise.all([ctx.api.approvals(), podNames(ctx)]);
7091
7109
  const pending = items.filter((a) => a.state === "pending").sort((a, b2) => a.createdAt - b2.createdAt);
@@ -7104,6 +7122,30 @@ var airlock = {
7104
7122
  }
7105
7123
  }
7106
7124
  };
7125
+ async function mode(ctx) {
7126
+ const want = ctx.args.positionals[1];
7127
+ const podRef = str(ctx.args, "pod");
7128
+ if (want && !AIRLOCK_MODES.includes(want) && !(podRef && want === "account")) {
7129
+ throw new UsageError(`${AIRLOCK_MODES.join(", ")}${podRef ? " or account" : ""}`, "airgate airlock mode branches");
7130
+ }
7131
+ const account = (await ctx.api.me()).user.airlockMode ?? "ask";
7132
+ const say = (who, m2, own) => {
7133
+ ctx.ui.out(`${who}: ${m2 === "off" ? ctx.ui.c("warn", AIRLOCK_MODE_INFO[m2].name) : AIRLOCK_MODE_INFO[m2].name}${own ? "" : ctx.ui.c("dim", " (the account's)")}`);
7134
+ ctx.ui.out(ctx.ui.c("dim", ` ${AIRLOCK_MODE_INFO[m2].line}`));
7135
+ if (m2 === "off") ctx.ui.err(ctx.ui.ce("warn", " nothing waits: an agent's push to main, a pull request or an email goes out on its own"));
7136
+ };
7137
+ if (podRef) {
7138
+ let pod = await resolvePod(ctx, podRef);
7139
+ if (want) pod = await ctx.api.updatePod(pod.id, { airlock: want === "account" ? null : want });
7140
+ const m2 = pod.spec.airlock ?? account;
7141
+ if (ctx.json) return ctx.ui.json({ pod: pod.spec.name, mode: m2, own: Boolean(pod.spec.airlock) });
7142
+ return say(pod.spec.name, m2, Boolean(pod.spec.airlock));
7143
+ }
7144
+ const m = want ? (await ctx.api.updateMe({ airlockMode: want })).user.airlockMode ?? want : account;
7145
+ if (ctx.json) return ctx.ui.json({ mode: m });
7146
+ say("airlock", m, true);
7147
+ if (!want) ctx.ui.out(ctx.ui.c("dim", ` change it: airgate airlock mode ${AIRLOCK_MODES.join("|")} \xB7 one pod: --pod <p>`));
7148
+ }
7107
7149
  async function watch(ctx) {
7108
7150
  const seen = /* @__PURE__ */ new Set();
7109
7151
  let first = true;
@@ -7559,7 +7601,7 @@ import { rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
7559
7601
  import { resolve as resolve4 } from "node:path";
7560
7602
 
7561
7603
  // src/version.ts
7562
- var VERSION = "0.2.2";
7604
+ var VERSION = "0.2.4";
7563
7605
 
7564
7606
  // src/commands/team.ts
7565
7607
  async function checkSimilar(ctx, task2) {
@@ -8133,6 +8175,7 @@ var launch = {
8133
8175
  env: { type: "string", value: "file", help: "a .env file: its keys become this pod's secrets before it boots" },
8134
8176
  "public-repo": { type: "boolean", help: "the repo is public: don't check it against your GitHub App" },
8135
8177
  mask: { type: "boolean", default: true, help: "mask model traffic (--no-mask to turn off)" },
8178
+ airlock: { type: "string", value: "mode", choices: AIRLOCK_MODES, help: "this pod's airlock: ask | branches | off (default: your account's, see airgate airlock mode)" },
8136
8179
  undo: { type: "boolean", default: true, help: "checkpoint after every agent step (--no-undo to turn off)" },
8137
8180
  network: { type: "string", value: "policy", choices: NETWORK_NAMES, default: "allowlist", help: "allowlist | open | off" },
8138
8181
  allow: { type: "list", value: "host", help: "extra hosts for the allowlist (repeatable, or comma-separated)" },
@@ -8252,6 +8295,8 @@ function specFromFlags(ctx, defaults = {}) {
8252
8295
  if (jobs2.length) spec.jobs = jobs2;
8253
8296
  if (builtin) spec.builtinModel = true;
8254
8297
  if (builtin && model) spec.model = model;
8298
+ const airlockMode = str(a, "airlock");
8299
+ if (airlockMode) spec.airlock = airlockMode;
8255
8300
  return spec;
8256
8301
  }
8257
8302
  var info = {
@@ -8275,6 +8320,7 @@ var info = {
8275
8320
  ["network", s2.network + (s2.network === "allowlist" && s2.allow?.length ? ` \xB7 ${s2.allow.join(", ")}` : "")],
8276
8321
  ["mask", s2.masking ? `on${s2.maskTerms?.length ? ` \xB7 ${s2.maskTerms.length} terms` : ""}` : ctx.ui.c("warn", "off")],
8277
8322
  ["undo", s2.autoCheckpoint ? "auto checkpoints" : "manual only"],
8323
+ ["airlock", s2.airlock ? s2.airlock === "off" ? ctx.ui.c("warn", AIRLOCK_MODE_INFO.off.name) : AIRLOCK_MODE_INFO[s2.airlock].name : "as the account"],
8278
8324
  ["auto-stop", s2.autoStopMinutes === 0 ? "keep awake (never sleeps: every hour counts, ~720 a month)" : `sleeps after ${s2.autoStopMinutes ?? 10} min idle`]
8279
8325
  ];
8280
8326
  if (s2.repo) rows2.push(["repo", s2.repo]);
@@ -8754,6 +8800,79 @@ function bringBack(sent, podContent, now = /* @__PURE__ */ new Date()) {
8754
8800
  atomic(file, body);
8755
8801
  return { agent: sent.agent, id, file, outcome: "new", sha256: sha256(body) };
8756
8802
  }
8803
+ var TEXTISH = /\.(md|markdown|txt|json|jsonl|ya?ml|toml)$/i;
8804
+ function findClaudeMemory(home, projectDir, env = {}) {
8805
+ for (const p of spellings(projectDir)) {
8806
+ const dir = join3(claudeHome(home, env), "projects", claudeKey(p), "memory");
8807
+ try {
8808
+ if (statSync(dir).isDirectory() && listFiles(dir).length) return dir;
8809
+ } catch {
8810
+ }
8811
+ }
8812
+ return null;
8813
+ }
8814
+ function listFiles(dir, rel = "") {
8815
+ const out = [];
8816
+ for (const x of readdirSync2(join3(dir, rel), { withFileTypes: true })) {
8817
+ const r = rel ? `${rel}/${x.name}` : x.name;
8818
+ if (x.isDirectory()) out.push(...listFiles(dir, r));
8819
+ else if (x.isFile()) out.push(r);
8820
+ }
8821
+ return out;
8822
+ }
8823
+ async function packMemory(dir, projectDir, podPath, limit = UPLOAD_LIMIT) {
8824
+ const froms = spellings(projectDir);
8825
+ const stage = mkdtempSync(join3(tmpdir(), "airgate-memory-"));
8826
+ try {
8827
+ const files2 = {};
8828
+ for (const rel of listFiles(dir)) {
8829
+ const body = readFileSync4(join3(dir, rel));
8830
+ files2[rel] = sha256(body);
8831
+ const out = join3(stage, "memory", rel);
8832
+ mkdirSync2(dirname(out), { recursive: true });
8833
+ writeFileSync3(out, TEXTISH.test(rel) ? rewriteAll(body.toString("utf8"), froms, podPath) : body);
8834
+ }
8835
+ const archive = await runTar(stage, ["-czf", "-", "memory"], null, limit);
8836
+ return { podDir: `.claude/projects/${claudeKey(podPath)}`, archive, sent: { dir, localPath: projectDir, podPath, files: files2 }, count: Object.keys(files2).length };
8837
+ } finally {
8838
+ rmSync3(stage, { recursive: true, force: true });
8839
+ }
8840
+ }
8841
+ function bringBackMemory(sent, pod) {
8842
+ const out = { added: [], updated: [], conflicts: [], sent: { ...sent, files: { ...sent.files } } };
8843
+ const write2 = (file, body) => {
8844
+ mkdirSync2(dirname(file), { recursive: true });
8845
+ const tmp = `${file}.airgate-${process.pid}`;
8846
+ writeFileSync3(tmp, body, { mode: 384 });
8847
+ renameSync2(tmp, file);
8848
+ };
8849
+ for (const f of pod) {
8850
+ const rel = f.path.replace(/\\/g, "/");
8851
+ if (!rel || rel.startsWith("/") || rel.split("/").some((p) => p === ".." || p === "")) continue;
8852
+ const body = TEXTISH.test(rel) ? Buffer.from(rewritePath(f.body.toString("utf8"), sent.podPath, sent.localPath)) : f.body;
8853
+ const file = join3(sent.dir, rel);
8854
+ const local = existsSync2(file) ? readFileSync4(file) : null;
8855
+ if (local && local.equals(body)) {
8856
+ out.sent.files[rel] = sha256(local);
8857
+ continue;
8858
+ }
8859
+ if (!local) {
8860
+ write2(file, body);
8861
+ out.added.push(rel);
8862
+ out.sent.files[rel] = sha256(body);
8863
+ } else if (sent.files[rel] && sha256(local) === sent.files[rel]) {
8864
+ write2(file, body);
8865
+ out.updated.push(rel);
8866
+ out.sent.files[rel] = sha256(body);
8867
+ } else {
8868
+ const dot = rel.lastIndexOf(".");
8869
+ const savedAs = dot > rel.lastIndexOf("/") ? `${rel.slice(0, dot)}.from-pod${rel.slice(dot)}` : `${rel}.from-pod`;
8870
+ write2(join3(sent.dir, savedAs), body);
8871
+ out.conflicts.push({ file: rel, savedAs });
8872
+ }
8873
+ }
8874
+ return out;
8875
+ }
8757
8876
 
8758
8877
  // src/patch.ts
8759
8878
  var RULES2 = [
@@ -8963,7 +9082,7 @@ function podDirName(projectDir) {
8963
9082
  var up = {
8964
9083
  name: "up",
8965
9084
  group: "up \xB7 down",
8966
- usage: "[dir] [--pod p] [--agent a] [--with-config] [--env [.env]] [--session id | --no-session]",
9085
+ usage: "[dir] [--pod p] [--agent a] [--with-config] [--env [.env]] [--session id | --no-session] [--no-memory]",
8967
9086
  summary: "move this project (with uncommitted work) into a pod",
8968
9087
  example: "airgate up --with-config",
8969
9088
  notes: [
@@ -8972,6 +9091,7 @@ var up = {
8972
9091
  "--with-config also sends ~/.claude settings.json, CLAUDE.md, skills/, agents/, commands/ (never credentials).",
8973
9092
  "remembers the pod in .airgate/state.json (kept out of git via .git/info/exclude) so `airgate down` knows where to look.",
8974
9093
  "also sends your latest Claude Code / Codex conversation in this directory and resumes it in the pod (paths rewritten); --no-session to skip, --session <id> to pick one. dropped with a warning if it is over the size limit.",
9094
+ "and Claude Code's memory for this project (~/.claude/projects/<project>/memory); --no-memory to skip. down brings new and changed memories back, never overwriting one you changed here.",
8975
9095
  "--env [file] sends the keys of a dotenv file (default .env) as secrets of that pod; the names sent are listed."
8976
9096
  ],
8977
9097
  flags: {
@@ -8983,6 +9103,7 @@ var up = {
8983
9103
  env: { type: "string", value: "file", implicit: ".env", help: "send a dotenv file's keys as this pod's secrets (default .env)" },
8984
9104
  session: { type: "string", value: "id", help: "send this Claude Code / Codex conversation (default: the latest here)" },
8985
9105
  "no-session": { type: "boolean", help: "don't send the agent conversation" },
9106
+ "no-memory": { type: "boolean", help: "don't send Claude Code's memory for this project" },
8986
9107
  yes: { type: "boolean", short: "y", help: "don't ask" }
8987
9108
  },
8988
9109
  async run(ctx) {
@@ -9026,6 +9147,16 @@ var up = {
9026
9147
  for (const f of [findClaudeSession(home, projectDir, sessionId, ctx.io.env), findCodexSession(home, projectDir, sessionId, ctx.io.env)]) if (f) found.push(f);
9027
9148
  if (sessionId && !found.length) throw new CliError(`no Claude Code or Codex session ${sessionId} for ${projectDir}`, "leave out --session to take the latest one");
9028
9149
  }
9150
+ let memory2 = null;
9151
+ const memDir = bool(ctx.args, "no-memory") ? null : findClaudeMemory(home, projectDir, ctx.io.env);
9152
+ if (memDir) {
9153
+ try {
9154
+ memory2 = await packMemory(memDir, projectDir, podPath);
9155
+ } catch (e) {
9156
+ if (!(e instanceof TooLargeError)) throw e;
9157
+ ui2.err(ui2.ce("warn", `Claude Code's memory for this project is over ${bytes(UPLOAD_LIMIT)} packed: not sent`));
9158
+ }
9159
+ }
9029
9160
  const sessions = [];
9030
9161
  for (const f of found) {
9031
9162
  try {
@@ -9072,6 +9203,15 @@ var up = {
9072
9203
  let envSent = null;
9073
9204
  const envFile = str(ctx.args, "env");
9074
9205
  if (envFile) envSent = await ui2.spin(`sending ${envFile} as secrets`, sendDotenv(ctx, envFile, pod.id));
9206
+ let memorySent;
9207
+ if (memory2) {
9208
+ try {
9209
+ await ui2.spin(`sending Claude Code's memory (${memory2.count} file${memory2.count === 1 ? "" : "s"})`, ctx.api.upload(pod.id, memory2.podDir, memory2.archive));
9210
+ memorySent = memory2.sent;
9211
+ } catch (e) {
9212
+ ui2.err(ui2.ce("warn", `couldn't send the memory: ${e.message}`));
9213
+ }
9214
+ }
9075
9215
  const sent = [];
9076
9216
  const resumed = [];
9077
9217
  for (const p of sessions) {
@@ -9101,7 +9241,7 @@ var up = {
9101
9241
  } catch {
9102
9242
  based = false;
9103
9243
  }
9104
- const state = { gateway: ctx.cfg.gateway, podId: pod.id, podName: pod.spec.name, dir: podDir, at: Date.now(), ...sent.length ? { sessions: sent } : {} };
9244
+ const state = { gateway: ctx.cfg.gateway, podId: pod.id, podName: pod.spec.name, dir: podDir, at: Date.now(), ...sent.length ? { sessions: sent } : {}, ...memorySent ? { memory: memorySent } : {} };
9105
9245
  writeState(projectDir, state);
9106
9246
  if (ctx.json) {
9107
9247
  return ui2.json({ pod, dir: podDir, bytes: packed.archive.length, files: packed.files, skipped: packed.skipped, config: config?.parts ?? [], baseSet: based, sessions: resumed, secrets: envSent });
@@ -9114,6 +9254,7 @@ var up = {
9114
9254
  ui2.out(`secrets for ${pod.spec.name}: ${envSent.sent.length ? envSent.sent.join(", ") : "none"}${envSent.skipped.length ? ui2.c("dim", ` (skipped: ${envSent.skipped.join(", ")})`) : ""}`);
9115
9255
  for (const f of envSent.failed) ui2.err(ui2.ce("warn", `not sent: ${f}`));
9116
9256
  }
9257
+ if (memorySent) ui2.out(`memory: ${Object.keys(memorySent.files).length} file(s) of Claude Code's memory for this project`);
9117
9258
  for (const r of resumed) {
9118
9259
  ui2.out(`conversation: ${r.command}${r.running ? ui2.c("dim", ` \xB7 resuming in the pod (cd ~/${podDir})`) : ui2.c("dim", ` \xB7 sent; in the pod: cd ~/${podDir} && ${r.command}`)}`);
9119
9260
  }
@@ -9138,8 +9279,35 @@ async function downSessions(ctx, root, state, pod, quiet2 = false) {
9138
9279
  }
9139
9280
  }
9140
9281
  if (state.sessions?.length) writeState(root, { ...state, sessions: next });
9141
- if (!quiet2 && !ctx.json) printSessions(ctx, out);
9142
- return out;
9282
+ let memory2 = null;
9283
+ if (state.memory) {
9284
+ try {
9285
+ const x = await ctx.api.exportAgentMemory(pod.id, state.dir);
9286
+ memory2 = bringBackMemory(state.memory, x.files.map((f) => ({ path: f.path, body: Buffer.from(f.b64, "base64") })));
9287
+ writeState(root, { ...readState(root, state), memory: memory2.sent });
9288
+ } catch (e) {
9289
+ if (!(e instanceof ApiError && e.status === 404)) ctx.ui.err(ctx.ui.ce("warn", `couldn't bring Claude Code's memory back: ${e.message}`));
9290
+ }
9291
+ }
9292
+ if (!quiet2 && !ctx.json) {
9293
+ printSessions(ctx, out);
9294
+ printMemory(ctx, memory2);
9295
+ }
9296
+ return { convo: out, memory: memory2 };
9297
+ }
9298
+ function readState(root, fallback) {
9299
+ try {
9300
+ return JSON.parse(readFileSync5(statePath(root), "utf8"));
9301
+ } catch {
9302
+ return fallback;
9303
+ }
9304
+ }
9305
+ function printMemory(ctx, m) {
9306
+ if (!m) return;
9307
+ const n2 = m.added.length + m.updated.length;
9308
+ if (!n2 && !m.conflicts.length) return ctx.ui.out(ctx.ui.c("dim", "memory: nothing new in the pod"));
9309
+ if (n2) ctx.ui.out(`memory: ${[m.added.length ? `${m.added.length} new` : "", m.updated.length ? `${m.updated.length} updated` : ""].filter(Boolean).join(", ")} from the pod`);
9310
+ for (const c of m.conflicts) ctx.ui.out(`memory: ${c.file} changed here and in the pod; yours kept, the pod's saved as ${c.savedAs}`);
9143
9311
  }
9144
9312
  function printSessions(ctx, list2) {
9145
9313
  for (const r of list2) {
@@ -9261,11 +9429,13 @@ ${check.stderr.trim()}`, "commit or stash local edits to those files, or look wi
9261
9429
  } catch {
9262
9430
  acked = false;
9263
9431
  }
9264
- const convo = takeSessions && found ? await downSessions(ctx, found.root, found.state, pod, true) : [];
9265
- if (ctx.json) return ui2.json({ pod: pod.id, base, files: files2, outside, refused, applied: true, acked, sessions: convo });
9432
+ const back = takeSessions && found ? await downSessions(ctx, found.root, found.state, pod, true) : { convo: [], memory: null };
9433
+ const convo = back.convo;
9434
+ if (ctx.json) return ui2.json({ pod: pod.id, base, files: files2, outside, refused, applied: true, acked, sessions: convo, memory: back.memory && { added: back.memory.added, updated: back.memory.updated, conflicts: back.memory.conflicts } });
9266
9435
  ui2.out(`${ui2.c("ok", "down")}: applied ${files2.length} file(s). review: git status \xB7 git diff`);
9267
9436
  if (!acked) ui2.err(ui2.ce("warn", "couldn't tell the pod what was taken; the next down will show these again"));
9268
9437
  printSessions(ctx, convo);
9438
+ printMemory(ctx, back.memory);
9269
9439
  }
9270
9440
  };
9271
9441
 
@@ -10404,7 +10574,7 @@ var TOOLS = [
10404
10574
  },
10405
10575
  {
10406
10576
  name: "pod_down",
10407
- description: "Apply a pod\u2019s work to the local repo as a git patch, with the CLI\u2019s safety checks (refuses symlinks and paths outside the repo; all or nothing). Look with pod_diff first and tell the user about risky files.",
10577
+ description: "Apply a pod\u2019s work to the local repo as a git patch (and bring back the conversation and Claude Code\u2019s memory that up sent), with the CLI\u2019s safety checks (refuses symlinks and paths outside the repo; all or nothing). Look with pod_diff first and tell the user about risky files.",
10408
10578
  inputSchema: obj({ pod: s("pod name or id (default: the pod from airgate up in dir)"), dir: s("the local project directory (default: where the server started)") }),
10409
10579
  async call(ctx, a) {
10410
10580
  const argv = ["--yes"];
@@ -10447,7 +10617,7 @@ var TOOLS = [
10447
10617
  },
10448
10618
  {
10449
10619
  name: "pod_up",
10450
- description: "Move the local project into a pod: its files (git-tracked and untracked-but-not-ignored), and by default the latest Claude Code / Codex conversation here, which resumes in the pod. Launches a new pod named after the folder unless pod is given. Bring the work back later with pod_diff / pod_down.",
10620
+ description: "Move the local project into a pod: its files (git-tracked and untracked-but-not-ignored), and by default the latest Claude Code / Codex conversation here, which resumes in the pod, plus Claude Code\u2019s memory for the project. Launches a new pod named after the folder unless pod is given. Bring the work back later with pod_diff / pod_down.",
10451
10621
  inputSchema: obj({
10452
10622
  dir: s("the local project directory (default: where the server started)"),
10453
10623
  pod: s("send into this existing pod instead of launching one"),
@@ -10548,17 +10718,18 @@ var TOOLS = [
10548
10718
  },
10549
10719
  {
10550
10720
  name: "pod_settings",
10551
- 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.",
10552
- 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"]),
10721
+ description: "Change a pod's network after launch (open | allowlist | off, extra allowlist hosts), switch its AI off/on (aiOff: no model calls at all), or set its airlock (ask | branches | off | account). Applies immediately. Only when the user asked for it; never loosen the airlock on your own.",
10722
+ 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"), airlock: s("ask (everything waits) | branches (pushes to work branches go through) | off (nothing waits) | account (the account's mode)", { enum: ["ask", "branches", "off", "account"] }) }, ["pod"]),
10553
10723
  async call(ctx, a) {
10554
10724
  const p = await resolvePod(ctx, need(a, "pod"));
10555
10725
  const patch = {};
10556
10726
  if (typeof a.network === "string") patch.network = a.network;
10557
10727
  if (Array.isArray(a.allow)) patch.allow = a.allow.map(String);
10558
10728
  if (typeof a.aiOff === "boolean") patch.aiOff = a.aiOff;
10559
- if (!Object.keys(patch).length) throw new UsageError("nothing to change: network, allow or aiOff");
10729
+ if (typeof a.airlock === "string") patch.airlock = a.airlock === "account" ? null : a.airlock;
10730
+ if (!Object.keys(patch).length) throw new UsageError("nothing to change: network, allow, aiOff or airlock");
10560
10731
  const pod = await ctx.api.updatePod(p.id, patch);
10561
- return { name: pod.spec.name, network: pod.spec.network, allow: pod.spec.allow ?? [], aiOff: Boolean(pod.spec.aiOff) };
10732
+ return { name: pod.spec.name, network: pod.spec.network, allow: pod.spec.allow ?? [], aiOff: Boolean(pod.spec.aiOff), airlock: pod.spec.airlock ?? "account" };
10562
10733
  }
10563
10734
  },
10564
10735
  {
@@ -12763,16 +12934,16 @@ var network = {
12763
12934
  remove: { type: "string", value: "host", help: "remove hosts (comma separated)" }
12764
12935
  },
12765
12936
  async run(ctx) {
12766
- const [ref, mode] = ctx.args.positionals;
12937
+ const [ref, mode2] = ctx.args.positionals;
12767
12938
  if (!ref) throw new UsageError("airgate network <pod> [open|allowlist|off]");
12768
- if (mode && !["open", "allowlist", "off"].includes(mode)) throw new UsageError("open, allowlist or off");
12939
+ if (mode2 && !["open", "allowlist", "off"].includes(mode2)) throw new UsageError("open, allowlist or off");
12769
12940
  const pod = await resolvePod(ctx, ref);
12770
12941
  const list2 = (v) => (v ?? "").split(",").map((h) => h.trim().toLowerCase()).filter(Boolean);
12771
12942
  let allow = pod.spec.allow ?? [];
12772
12943
  if (str(ctx.args, "allow") !== void 0) allow = list2(str(ctx.args, "allow"));
12773
12944
  allow = [.../* @__PURE__ */ new Set([...allow, ...list2(str(ctx.args, "add"))])].filter((h) => !list2(str(ctx.args, "remove")).includes(h));
12774
- const changed = mode || ["allow", "add", "remove"].some((k) => str(ctx.args, k) !== void 0);
12775
- const p = changed ? await ctx.api.updatePod(pod.id, { ...mode ? { network: mode } : {}, allow }) : pod;
12945
+ const changed = mode2 || ["allow", "add", "remove"].some((k) => str(ctx.args, k) !== void 0);
12946
+ const p = changed ? await ctx.api.updatePod(pod.id, { ...mode2 ? { network: mode2 } : {}, allow }) : pod;
12776
12947
  if (ctx.json) return ctx.ui.json({ network: p.spec.network, allow: p.spec.allow ?? [], aiOff: Boolean(p.spec.aiOff) });
12777
12948
  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)") : ""}`);
12778
12949
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airgate",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Give your AI its own computer: the airgate command line.",
5
5
  "type": "module",
6
6
  "bin": {