airgate 0.2.2 → 0.2.3
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 +2 -2
- package/dist/airgate.mjs +136 -9
- package/package.json +1 -1
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
|
|
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
|
|
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
|
@@ -813,6 +813,9 @@ var Api = class {
|
|
|
813
813
|
resumeAgentSession(id, body) {
|
|
814
814
|
return this.request("POST", `/v1/pods/${enc(id)}/agent-session/resume`, { body });
|
|
815
815
|
}
|
|
816
|
+
exportAgentMemory(id, dir) {
|
|
817
|
+
return this.request("GET", `/v1/pods/${enc(id)}/agent-memory?${new URLSearchParams({ dir })}`);
|
|
818
|
+
}
|
|
816
819
|
exportAgentSession(id, q) {
|
|
817
820
|
const qs = new URLSearchParams({ agent: q.agent, dir: q.dir, ...q.id ? { id: q.id } : {} });
|
|
818
821
|
return this.request("GET", `/v1/pods/${enc(id)}/agent-session?${qs}`);
|
|
@@ -7559,7 +7562,7 @@ import { rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
|
7559
7562
|
import { resolve as resolve4 } from "node:path";
|
|
7560
7563
|
|
|
7561
7564
|
// src/version.ts
|
|
7562
|
-
var VERSION = "0.2.
|
|
7565
|
+
var VERSION = "0.2.3";
|
|
7563
7566
|
|
|
7564
7567
|
// src/commands/team.ts
|
|
7565
7568
|
async function checkSimilar(ctx, task2) {
|
|
@@ -8754,6 +8757,79 @@ function bringBack(sent, podContent, now = /* @__PURE__ */ new Date()) {
|
|
|
8754
8757
|
atomic(file, body);
|
|
8755
8758
|
return { agent: sent.agent, id, file, outcome: "new", sha256: sha256(body) };
|
|
8756
8759
|
}
|
|
8760
|
+
var TEXTISH = /\.(md|markdown|txt|json|jsonl|ya?ml|toml)$/i;
|
|
8761
|
+
function findClaudeMemory(home, projectDir, env = {}) {
|
|
8762
|
+
for (const p of spellings(projectDir)) {
|
|
8763
|
+
const dir = join3(claudeHome(home, env), "projects", claudeKey(p), "memory");
|
|
8764
|
+
try {
|
|
8765
|
+
if (statSync(dir).isDirectory() && listFiles(dir).length) return dir;
|
|
8766
|
+
} catch {
|
|
8767
|
+
}
|
|
8768
|
+
}
|
|
8769
|
+
return null;
|
|
8770
|
+
}
|
|
8771
|
+
function listFiles(dir, rel = "") {
|
|
8772
|
+
const out = [];
|
|
8773
|
+
for (const x of readdirSync2(join3(dir, rel), { withFileTypes: true })) {
|
|
8774
|
+
const r = rel ? `${rel}/${x.name}` : x.name;
|
|
8775
|
+
if (x.isDirectory()) out.push(...listFiles(dir, r));
|
|
8776
|
+
else if (x.isFile()) out.push(r);
|
|
8777
|
+
}
|
|
8778
|
+
return out;
|
|
8779
|
+
}
|
|
8780
|
+
async function packMemory(dir, projectDir, podPath, limit = UPLOAD_LIMIT) {
|
|
8781
|
+
const froms = spellings(projectDir);
|
|
8782
|
+
const stage = mkdtempSync(join3(tmpdir(), "airgate-memory-"));
|
|
8783
|
+
try {
|
|
8784
|
+
const files2 = {};
|
|
8785
|
+
for (const rel of listFiles(dir)) {
|
|
8786
|
+
const body = readFileSync4(join3(dir, rel));
|
|
8787
|
+
files2[rel] = sha256(body);
|
|
8788
|
+
const out = join3(stage, "memory", rel);
|
|
8789
|
+
mkdirSync2(dirname(out), { recursive: true });
|
|
8790
|
+
writeFileSync3(out, TEXTISH.test(rel) ? rewriteAll(body.toString("utf8"), froms, podPath) : body);
|
|
8791
|
+
}
|
|
8792
|
+
const archive = await runTar(stage, ["-czf", "-", "memory"], null, limit);
|
|
8793
|
+
return { podDir: `.claude/projects/${claudeKey(podPath)}`, archive, sent: { dir, localPath: projectDir, podPath, files: files2 }, count: Object.keys(files2).length };
|
|
8794
|
+
} finally {
|
|
8795
|
+
rmSync3(stage, { recursive: true, force: true });
|
|
8796
|
+
}
|
|
8797
|
+
}
|
|
8798
|
+
function bringBackMemory(sent, pod) {
|
|
8799
|
+
const out = { added: [], updated: [], conflicts: [], sent: { ...sent, files: { ...sent.files } } };
|
|
8800
|
+
const write2 = (file, body) => {
|
|
8801
|
+
mkdirSync2(dirname(file), { recursive: true });
|
|
8802
|
+
const tmp = `${file}.airgate-${process.pid}`;
|
|
8803
|
+
writeFileSync3(tmp, body, { mode: 384 });
|
|
8804
|
+
renameSync2(tmp, file);
|
|
8805
|
+
};
|
|
8806
|
+
for (const f of pod) {
|
|
8807
|
+
const rel = f.path.replace(/\\/g, "/");
|
|
8808
|
+
if (!rel || rel.startsWith("/") || rel.split("/").some((p) => p === ".." || p === "")) continue;
|
|
8809
|
+
const body = TEXTISH.test(rel) ? Buffer.from(rewritePath(f.body.toString("utf8"), sent.podPath, sent.localPath)) : f.body;
|
|
8810
|
+
const file = join3(sent.dir, rel);
|
|
8811
|
+
const local = existsSync2(file) ? readFileSync4(file) : null;
|
|
8812
|
+
if (local && local.equals(body)) {
|
|
8813
|
+
out.sent.files[rel] = sha256(local);
|
|
8814
|
+
continue;
|
|
8815
|
+
}
|
|
8816
|
+
if (!local) {
|
|
8817
|
+
write2(file, body);
|
|
8818
|
+
out.added.push(rel);
|
|
8819
|
+
out.sent.files[rel] = sha256(body);
|
|
8820
|
+
} else if (sent.files[rel] && sha256(local) === sent.files[rel]) {
|
|
8821
|
+
write2(file, body);
|
|
8822
|
+
out.updated.push(rel);
|
|
8823
|
+
out.sent.files[rel] = sha256(body);
|
|
8824
|
+
} else {
|
|
8825
|
+
const dot = rel.lastIndexOf(".");
|
|
8826
|
+
const savedAs = dot > rel.lastIndexOf("/") ? `${rel.slice(0, dot)}.from-pod${rel.slice(dot)}` : `${rel}.from-pod`;
|
|
8827
|
+
write2(join3(sent.dir, savedAs), body);
|
|
8828
|
+
out.conflicts.push({ file: rel, savedAs });
|
|
8829
|
+
}
|
|
8830
|
+
}
|
|
8831
|
+
return out;
|
|
8832
|
+
}
|
|
8757
8833
|
|
|
8758
8834
|
// src/patch.ts
|
|
8759
8835
|
var RULES2 = [
|
|
@@ -8963,7 +9039,7 @@ function podDirName(projectDir) {
|
|
|
8963
9039
|
var up = {
|
|
8964
9040
|
name: "up",
|
|
8965
9041
|
group: "up \xB7 down",
|
|
8966
|
-
usage: "[dir] [--pod p] [--agent a] [--with-config] [--env [.env]] [--session id | --no-session]",
|
|
9042
|
+
usage: "[dir] [--pod p] [--agent a] [--with-config] [--env [.env]] [--session id | --no-session] [--no-memory]",
|
|
8967
9043
|
summary: "move this project (with uncommitted work) into a pod",
|
|
8968
9044
|
example: "airgate up --with-config",
|
|
8969
9045
|
notes: [
|
|
@@ -8972,6 +9048,7 @@ var up = {
|
|
|
8972
9048
|
"--with-config also sends ~/.claude settings.json, CLAUDE.md, skills/, agents/, commands/ (never credentials).",
|
|
8973
9049
|
"remembers the pod in .airgate/state.json (kept out of git via .git/info/exclude) so `airgate down` knows where to look.",
|
|
8974
9050
|
"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.",
|
|
9051
|
+
"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
9052
|
"--env [file] sends the keys of a dotenv file (default .env) as secrets of that pod; the names sent are listed."
|
|
8976
9053
|
],
|
|
8977
9054
|
flags: {
|
|
@@ -8983,6 +9060,7 @@ var up = {
|
|
|
8983
9060
|
env: { type: "string", value: "file", implicit: ".env", help: "send a dotenv file's keys as this pod's secrets (default .env)" },
|
|
8984
9061
|
session: { type: "string", value: "id", help: "send this Claude Code / Codex conversation (default: the latest here)" },
|
|
8985
9062
|
"no-session": { type: "boolean", help: "don't send the agent conversation" },
|
|
9063
|
+
"no-memory": { type: "boolean", help: "don't send Claude Code's memory for this project" },
|
|
8986
9064
|
yes: { type: "boolean", short: "y", help: "don't ask" }
|
|
8987
9065
|
},
|
|
8988
9066
|
async run(ctx) {
|
|
@@ -9026,6 +9104,16 @@ var up = {
|
|
|
9026
9104
|
for (const f of [findClaudeSession(home, projectDir, sessionId, ctx.io.env), findCodexSession(home, projectDir, sessionId, ctx.io.env)]) if (f) found.push(f);
|
|
9027
9105
|
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
9106
|
}
|
|
9107
|
+
let memory2 = null;
|
|
9108
|
+
const memDir = bool(ctx.args, "no-memory") ? null : findClaudeMemory(home, projectDir, ctx.io.env);
|
|
9109
|
+
if (memDir) {
|
|
9110
|
+
try {
|
|
9111
|
+
memory2 = await packMemory(memDir, projectDir, podPath);
|
|
9112
|
+
} catch (e) {
|
|
9113
|
+
if (!(e instanceof TooLargeError)) throw e;
|
|
9114
|
+
ui2.err(ui2.ce("warn", `Claude Code's memory for this project is over ${bytes(UPLOAD_LIMIT)} packed: not sent`));
|
|
9115
|
+
}
|
|
9116
|
+
}
|
|
9029
9117
|
const sessions = [];
|
|
9030
9118
|
for (const f of found) {
|
|
9031
9119
|
try {
|
|
@@ -9072,6 +9160,15 @@ var up = {
|
|
|
9072
9160
|
let envSent = null;
|
|
9073
9161
|
const envFile = str(ctx.args, "env");
|
|
9074
9162
|
if (envFile) envSent = await ui2.spin(`sending ${envFile} as secrets`, sendDotenv(ctx, envFile, pod.id));
|
|
9163
|
+
let memorySent;
|
|
9164
|
+
if (memory2) {
|
|
9165
|
+
try {
|
|
9166
|
+
await ui2.spin(`sending Claude Code's memory (${memory2.count} file${memory2.count === 1 ? "" : "s"})`, ctx.api.upload(pod.id, memory2.podDir, memory2.archive));
|
|
9167
|
+
memorySent = memory2.sent;
|
|
9168
|
+
} catch (e) {
|
|
9169
|
+
ui2.err(ui2.ce("warn", `couldn't send the memory: ${e.message}`));
|
|
9170
|
+
}
|
|
9171
|
+
}
|
|
9075
9172
|
const sent = [];
|
|
9076
9173
|
const resumed = [];
|
|
9077
9174
|
for (const p of sessions) {
|
|
@@ -9101,7 +9198,7 @@ var up = {
|
|
|
9101
9198
|
} catch {
|
|
9102
9199
|
based = false;
|
|
9103
9200
|
}
|
|
9104
|
-
const state = { gateway: ctx.cfg.gateway, podId: pod.id, podName: pod.spec.name, dir: podDir, at: Date.now(), ...sent.length ? { sessions: sent } : {} };
|
|
9201
|
+
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
9202
|
writeState(projectDir, state);
|
|
9106
9203
|
if (ctx.json) {
|
|
9107
9204
|
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 +9211,7 @@ var up = {
|
|
|
9114
9211
|
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
9212
|
for (const f of envSent.failed) ui2.err(ui2.ce("warn", `not sent: ${f}`));
|
|
9116
9213
|
}
|
|
9214
|
+
if (memorySent) ui2.out(`memory: ${Object.keys(memorySent.files).length} file(s) of Claude Code's memory for this project`);
|
|
9117
9215
|
for (const r of resumed) {
|
|
9118
9216
|
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
9217
|
}
|
|
@@ -9138,8 +9236,35 @@ async function downSessions(ctx, root, state, pod, quiet2 = false) {
|
|
|
9138
9236
|
}
|
|
9139
9237
|
}
|
|
9140
9238
|
if (state.sessions?.length) writeState(root, { ...state, sessions: next });
|
|
9141
|
-
|
|
9142
|
-
|
|
9239
|
+
let memory2 = null;
|
|
9240
|
+
if (state.memory) {
|
|
9241
|
+
try {
|
|
9242
|
+
const x = await ctx.api.exportAgentMemory(pod.id, state.dir);
|
|
9243
|
+
memory2 = bringBackMemory(state.memory, x.files.map((f) => ({ path: f.path, body: Buffer.from(f.b64, "base64") })));
|
|
9244
|
+
writeState(root, { ...readState(root, state), memory: memory2.sent });
|
|
9245
|
+
} catch (e) {
|
|
9246
|
+
if (!(e instanceof ApiError && e.status === 404)) ctx.ui.err(ctx.ui.ce("warn", `couldn't bring Claude Code's memory back: ${e.message}`));
|
|
9247
|
+
}
|
|
9248
|
+
}
|
|
9249
|
+
if (!quiet2 && !ctx.json) {
|
|
9250
|
+
printSessions(ctx, out);
|
|
9251
|
+
printMemory(ctx, memory2);
|
|
9252
|
+
}
|
|
9253
|
+
return { convo: out, memory: memory2 };
|
|
9254
|
+
}
|
|
9255
|
+
function readState(root, fallback) {
|
|
9256
|
+
try {
|
|
9257
|
+
return JSON.parse(readFileSync5(statePath(root), "utf8"));
|
|
9258
|
+
} catch {
|
|
9259
|
+
return fallback;
|
|
9260
|
+
}
|
|
9261
|
+
}
|
|
9262
|
+
function printMemory(ctx, m) {
|
|
9263
|
+
if (!m) return;
|
|
9264
|
+
const n2 = m.added.length + m.updated.length;
|
|
9265
|
+
if (!n2 && !m.conflicts.length) return ctx.ui.out(ctx.ui.c("dim", "memory: nothing new in the pod"));
|
|
9266
|
+
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`);
|
|
9267
|
+
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
9268
|
}
|
|
9144
9269
|
function printSessions(ctx, list2) {
|
|
9145
9270
|
for (const r of list2) {
|
|
@@ -9261,11 +9386,13 @@ ${check.stderr.trim()}`, "commit or stash local edits to those files, or look wi
|
|
|
9261
9386
|
} catch {
|
|
9262
9387
|
acked = false;
|
|
9263
9388
|
}
|
|
9264
|
-
const
|
|
9265
|
-
|
|
9389
|
+
const back = takeSessions && found ? await downSessions(ctx, found.root, found.state, pod, true) : { convo: [], memory: null };
|
|
9390
|
+
const convo = back.convo;
|
|
9391
|
+
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
9392
|
ui2.out(`${ui2.c("ok", "down")}: applied ${files2.length} file(s). review: git status \xB7 git diff`);
|
|
9267
9393
|
if (!acked) ui2.err(ui2.ce("warn", "couldn't tell the pod what was taken; the next down will show these again"));
|
|
9268
9394
|
printSessions(ctx, convo);
|
|
9395
|
+
printMemory(ctx, back.memory);
|
|
9269
9396
|
}
|
|
9270
9397
|
};
|
|
9271
9398
|
|
|
@@ -10404,7 +10531,7 @@ var TOOLS = [
|
|
|
10404
10531
|
},
|
|
10405
10532
|
{
|
|
10406
10533
|
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.",
|
|
10534
|
+
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
10535
|
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
10536
|
async call(ctx, a) {
|
|
10410
10537
|
const argv = ["--yes"];
|
|
@@ -10447,7 +10574,7 @@ var TOOLS = [
|
|
|
10447
10574
|
},
|
|
10448
10575
|
{
|
|
10449
10576
|
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.",
|
|
10577
|
+
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
10578
|
inputSchema: obj({
|
|
10452
10579
|
dir: s("the local project directory (default: where the server started)"),
|
|
10453
10580
|
pod: s("send into this existing pod instead of launching one"),
|