ramwisp 0.1.6 → 0.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ramwisp",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "ramwisp: Claude Code and Codex subagents on their own cloud machines (AWS Nitro Enclaves), with your own subscription. MCP + CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  "node": ">=20"
20
20
  },
21
21
  "scripts": {
22
- "test": "node --test test/"
22
+ "test": "node --test"
23
23
  },
24
24
  "license": "Apache-2.0",
25
25
  "keywords": [
@@ -32,7 +32,7 @@
32
32
  "homepage": "https://ramwisp.com",
33
33
  "repository": {
34
34
  "type": "git",
35
- "url": "https://github.com/spectre-systems/wisp-saas",
35
+ "url": "https://github.com/spectre-systems/ramwisp",
36
36
  "directory": "mcp"
37
37
  }
38
38
  }
package/src/account.js CHANGED
@@ -41,6 +41,7 @@ export function logout() {
41
41
  let client = "cli";
42
42
  /** Quem está usando o MCP (vem do clientInfo do initialize: "claude-code", "codex-mcp-client"…). */
43
43
  export function setClient(name) { if (name) client = String(name).slice(0, 60); }
44
+ export const getClient = () => client;
44
45
 
45
46
  export async function call(method, path, body, { token = getToken(), timeoutMs = 30_000 } = {}) {
46
47
  const r = await fetch(API + path, {
package/src/client.js CHANGED
@@ -11,10 +11,16 @@ import { engineCredential } from "./creds.js";
11
11
  export const FINAL = ["done", "failed", "killed", "expired"];
12
12
  const sealers = new Map(); // id -> Promise (selagem em andamento neste processo)
13
13
  const sealErrors = new Map();
14
+ const collecting = new Map();
15
+ const watchers = new Map(); // id -> Promise (recolhe sozinho quando termina) // id -> Promise (abertura em andamento: duas chamadas juntas não disputam a chave)
14
16
 
15
17
  export class LoginRequired extends Error {}
16
18
 
17
19
  const keyFile = (id) => join(ensureDir("jobs"), `${id}.json`);
20
+ // Cópia local do resultado já aberto: uma espera em segundo plano pode recolher o resultado e a resposta dela
21
+ // nunca chegar ao agente; com a cópia, a próxima chamada devolve o mesmo resultado em vez de "already collected".
22
+ export const RESULT_TTL_MS = 7 * 86400_000;
23
+ const resultFile = (id) => join(ensureDir("results"), `${id}.json`);
18
24
  const MAX_WORKSPACE = 15 * 1024 * 1024; // compactado
19
25
  const SKIP_DIRS = new Set(["node_modules", ".git", ".venv", "venv", "__pycache__", "dist", "build", ".next", "target", ".cache"]);
20
26
 
@@ -64,7 +70,7 @@ export async function spawnAgent(o) {
64
70
  const job = await call("POST", "/api/jobs", { engine, ram_gb: o.ram_gb ?? 2, timeout_s: timeout,
65
71
  nonce: nonce.toString("base64"), label: o.label });
66
72
  writeSecret(keyFile(job.id), JSON.stringify({ id: job.id, priv: exportKey(priv), nonce: nonce.toString("base64"), engine,
67
- workspace: ws?.root }));
73
+ workspace: ws?.root, pid: process.pid }));
68
74
  const payload = Buffer.from(JSON.stringify({ engine, model: o.model, mission: o.mission, turns: o.max_turns ?? 20,
69
75
  timeout, auth: { kind: cred.kind, value: cred.value }, ...(ws ? { workspace_tgz: ws.tgz.toString("base64") } : {}) }));
70
76
  const p = sealWhenReady(job.id, priv, nonce, payload).finally(() => { payload.fill(0); sealers.delete(job.id); });
@@ -126,22 +132,53 @@ function parseOutput(stdout) {
126
132
  }
127
133
  }
128
134
 
129
- /** Resultado final (e apaga a chave local e a saída cifrada no servidor), ou o status se ainda roda. */
135
+ function cachedResult(id) {
136
+ try {
137
+ const f = resultFile(id);
138
+ if (Date.now() - statSync(f).mtimeMs > RESULT_TTL_MS) { rmSync(f, { force: true }); return null; }
139
+ return JSON.parse(readFileSync(f, "utf8"));
140
+ } catch { return null; }
141
+ }
142
+
143
+ /** Apaga cópias locais com mais de 7 dias (melhor esforço). */
144
+ export function pruneResults() {
145
+ try {
146
+ const d = ensureDir("results");
147
+ for (const n of readdirSync(d)) {
148
+ const f = join(d, n);
149
+ if (Date.now() - statSync(f).mtimeMs > RESULT_TTL_MS) rmSync(f, { force: true });
150
+ }
151
+ } catch { /* sem pasta ainda */ }
152
+ }
153
+
154
+ /**
155
+ * Resultado final, ou o status se ainda roda. Ao abrir, guarda uma cópia local (0600, 7 dias), avisa o servidor
156
+ * (que apaga a saída cifrada) e apaga a chave. Chamadas seguintes devolvem a cópia, na mesma sessão ou em outra.
157
+ */
130
158
  export async function result(id) {
159
+ const cached = cachedResult(id);
160
+ if (cached) return { ...cached, from_local_copy: true };
161
+ if (collecting.has(id)) return collecting.get(id);
162
+ const p = fetchResult(id).finally(() => collecting.delete(id));
163
+ collecting.set(id, p);
164
+ return p;
165
+ }
166
+
167
+ async function fetchResult(id) {
131
168
  const j = await call("GET", `/api/jobs/${id}`);
132
169
  const meta = { id, status: j.status, ram_gb: j.ram_gb, peak_mem_mib: j.peak_mem_mib, cost_usd: j.cost_cents != null ? +(j.cost_cents / 100).toFixed(4) : null };
133
170
  if (sealErrors.has(id)) return { ...meta, status: "failed", error: `refused for security: ${sealErrors.get(id)}` };
134
171
  if (!FINAL.includes(j.status)) return { ...meta, mem_used_mib: j.mem_used_mib };
135
172
  if (j.status !== "done" || !j.output) {
136
173
  rmSync(keyFile(id), { force: true });
137
- return { ...meta, error: j.error ?? (j.collected_at ? "result was already collected" : j.status) };
174
+ return { ...meta, error: j.error ?? (j.collected_at
175
+ ? `result was already collected on another machine (it is kept only where it was opened, in ${ensureDir("results")})`
176
+ : j.status) };
138
177
  }
139
178
  const f = keyFile(id);
140
179
  if (!existsSync(f)) return { ...meta, error: "the key to open this result is not on this machine" };
141
180
  const k = JSON.parse(readFileSync(f, "utf8"));
142
181
  const out = JSON.parse(openOutput(importKey(k.priv), Buffer.from(k.enclave_pub, "base64"), Buffer.from(k.nonce, "base64"), j.output).toString());
143
- await call("POST", `/api/jobs/${id}/collected`).catch(() => {});
144
- rmSync(f, { force: true });
145
182
  const parsed = parseOutput(out.stdout ?? "");
146
183
  const res = { ...parsed, ...meta, exit_code: out.exit_code, duration_s: out.duration_s };
147
184
  if (out.exit_code === 124) res.error = "timeout";
@@ -158,9 +195,71 @@ export async function result(id) {
158
195
  res.patch_stat = out.patch_stat;
159
196
  }
160
197
  if (out.exit_code !== 0 && out.stderr_tail) res.stderr_tail = out.stderr_tail.slice(-1500);
198
+ // a cópia vem ANTES de avisar o servidor e apagar a chave: se algo cair no meio, o resultado não se perde
199
+ writeSecret(resultFile(id), JSON.stringify(res));
200
+ await call("POST", `/api/jobs/${id}/collected`).catch(() => {});
201
+ rmSync(f, { force: true });
202
+ pruneResults();
161
203
  return res;
162
204
  }
163
205
 
206
+ const nap = (ms) => new Promise((r) => setTimeout(r, ms).unref());
207
+
208
+ /**
209
+ * Recolhe o resultado sozinho quando o subagente termina (guarda a cópia local), sem ninguém bloqueado esperando.
210
+ * Os timers não seguram o processo: no CLI, sair continua saindo.
211
+ */
212
+ export function watch(id) {
213
+ if (watchers.has(id)) return;
214
+ const p = (async () => {
215
+ const until = Date.now() + 3 * 3600_000;
216
+ while (Date.now() < until) {
217
+ await nap(Number(process.env.WISP_WATCH_MS ?? 10_000));
218
+ const r = await result(id).catch(() => null);
219
+ if (r && (FINAL.includes(r.status) || r.error)) return;
220
+ }
221
+ })().finally(() => watchers.delete(id));
222
+ watchers.set(id, p);
223
+ }
224
+
225
+ /**
226
+ * Ao subir o MCP: volta a vigiar os subagentes desta máquina que ainda têm chave (sessão anterior caiu, etc.).
227
+ * Um job que nunca foi selado e cujo processo selador morreu (a sessão acabou antes da máquina subir) não tem como
228
+ * rodar: a missão só existia na memória daquele processo. Esse é derrubado na hora, para não ficar cobrando à toa.
229
+ * Se o selador está vivo (outra sessão, ou o ajudante do Codex, que sobe o próprio MCP), só vigia.
230
+ */
231
+ export function resumeWatches() {
232
+ let names = [];
233
+ try { names = readdirSync(ensureDir("jobs")).filter((n) => n.endsWith(".json")); } catch { return; }
234
+ for (const n of names) {
235
+ const id = n.slice(0, -5);
236
+ if (sealers.has(id) || watchers.has(id)) continue;
237
+ let k;
238
+ try { k = JSON.parse(readFileSync(join(ensureDir("jobs"), n), "utf8")); } catch { continue; }
239
+ if (k.enclave_pub || alive(k.pid)) watch(id);
240
+ else if (getToken()) {
241
+ killAgent(id).then(() => process.stderr.write(`ramwisp: ${id} was never delivered (the session ended first); stopped it\n`),
242
+ () => {});
243
+ }
244
+ }
245
+ }
246
+
247
+ function alive(pid) {
248
+ if (!pid) return false;
249
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
250
+ }
251
+
252
+ /** Espera vários de uma vez: volta quando todos terminarem (ou no limite), com o resultado de cada um. */
253
+ export async function waitAgents(ids, maxWaitS = 900) {
254
+ const until = Date.now() + maxWaitS * 1000;
255
+ for (;;) {
256
+ const agents = await Promise.all(ids.map((id) => result(id).catch((e) => ({ id, error: e.message }))));
257
+ const done = agents.every((r) => FINAL.includes(r.status) || r.error);
258
+ if (done || Date.now() > until) return { all_done: done, agents };
259
+ await nap(3000);
260
+ }
261
+ }
262
+
164
263
  export async function waitAgent(id, maxWaitS = 900) {
165
264
  const until = Date.now() + maxWaitS * 1000;
166
265
  for (;;) {
package/src/mcp.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // Servidor MCP (stdio, JSON-RPC por linha). Sem dependências.
2
2
  import { createInterface } from "node:readline";
3
- import { API, getToken, setClient, startLogin } from "./account.js";
4
- import { LoginRequired, killAgent, listAgents, result, spawnAgent, waitAgent } from "./client.js";
3
+ import { API, getClient, getToken, setClient, startLogin } from "./account.js";
4
+ import { LoginRequired, killAgent, listAgents, result, resumeWatches, spawnAgent, waitAgent, waitAgents, watch } from "./client.js";
5
5
 
6
6
  const PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
7
- const VERSION = "0.1.6";
7
+ const VERSION = "0.1.8";
8
8
 
9
9
  const INSTRUCTIONS = `ramwisp runs Claude Code or Codex subagents on ephemeral cloud machines with the RAM you ask for,
10
10
  without loading this machine. Each subagent starts inside an isolated enclave (AWS Nitro): before sending anything,
@@ -15,17 +15,30 @@ When to use: the user asks for remote subagents / "run it on ramwisp" / "spin up
15
15
  independent, heavy tasks (builds, test suites, long research) that can run in parallel elsewhere.
16
16
 
17
17
  How to use it well:
18
- - Parallel: call spawn_agent for every task first, then wait_agent for each id.
18
+ - Parallel: call spawn_agent for every task first, then wait for all of them with ONE wait_agent call (ids: [...]).
19
+ - Don't block the main conversation while subagents run (minutes to an hour). This MCP collects each result by itself
20
+ when it finishes and keeps it on this machine for 7 days, so nothing is lost if nobody is waiting. By default,
21
+ hand the waiting to a small background helper and keep working with the user:
22
+ - Claude Code: launch a background Agent with model "sonnet" whose only job is to call wait_agent with the ids
23
+ (repeat while it returns running) and report the full result text back. It must not spawn or kill anything.
24
+ - Codex: spawn a sub-agent with model "gpt-6-luna" for that same job, then keep working and check on it later.
25
+ - Without sub-agents: carry on with other work and call agent_result (instant) now and then.
26
+ Only wait in the main thread yourself if the user explicitly asks to wait, or if this session is about to end
27
+ (one-shot runs like \`claude -p\` or \`codex exec\`): the task is sent from this process once the machine is up,
28
+ so ending the session before status is "running" stops the subagent.
19
29
  - To work on the user's code, pass workspace (e.g. the project directory): an encrypted copy is sent,
20
30
  the subagent works on it and the changes come back as a patch (apply it with the "apply" command after reviewing).
21
31
  Without workspace the machine starts empty: put all context in the mission. It has internet (HTTPS) but no git/SSH access of the user.
22
- - A machine takes ~1-3 min to start; wait_agent waits up to 15 min per call (call it again if it returns running).
32
+ - A machine takes ~1-3 min to start; wait_agent waits up to max_wait_s per call (call it again if it returns running).
23
33
  - Always collect with wait_agent or agent_result: the answer can only be decrypted on this machine.
34
+ Once opened, the result stays readable here for 7 days: calling wait_agent/agent_result again returns the same answer,
35
+ so a wait that ran in the background never loses it. While running, only status, RAM and cost are visible.
24
36
  - Each subagent uses ramwisp credit (the machine) and the user's subscription/key (the model). Don't launch dozens.
25
37
  - If a response says the user needs to sign in, show them the link.`;
26
38
 
27
39
  const TOOLS = [
28
- { name: "spawn_agent", description: "Launch an ephemeral subagent on a machine with the requested RAM and return its id right away.",
40
+ { name: "spawn_agent", annotations: { title: "Launch a subagent", readOnlyHint: false, destructiveHint: false, openWorldHint: true },
41
+ description: "Launch an ephemeral subagent on a machine with the requested RAM and return its id right away.",
29
42
  inputSchema: { type: "object", required: ["mission"], properties: {
30
43
  mission: { type: "string", description: "Complete, self-contained task with all the context it needs." },
31
44
  engine: { type: "string", enum: ["claude", "codex"], default: "claude" },
@@ -37,14 +50,20 @@ const TOOLS = [
37
50
  description: "login = the user's subscription on this machine; key = API key from env; auto = key if present." },
38
51
  workspace: { type: "string", description: "Path to a directory/repo ON THIS MACHINE to send along. An encrypted copy is sent (in git: tracked files + new non-ignored files; never anything in .gitignore). The subagent works in ~/work and changes come back as a patch (patch_file + apply command). Max 15 MB compressed." },
39
52
  label: { type: "string", description: "Short label VISIBLE in the dashboard (don't put anything sensitive)." } } } },
40
- { name: "wait_agent", description: "Wait for the subagent to finish and return the result (field result = the answer).",
41
- inputSchema: { type: "object", required: ["id"], properties: {
42
- id: { type: "string" }, max_wait_s: { type: "integer", default: 900, maximum: 1800 } } } },
43
- { name: "agent_result", description: "Result without waiting: returns the answer or the current status (running, RAM in use).",
53
+ { name: "wait_agent", annotations: { title: "Wait for a subagent's result", readOnlyHint: true, openWorldHint: false },
54
+ description: "Wait for the subagent to finish and return the result (field result = the answer). Safe to call again: a result already opened on this machine is returned again.",
55
+ inputSchema: { type: "object", properties: {
56
+ id: { type: "string", description: "One subagent id." },
57
+ ids: { type: "array", items: { type: "string" }, description: "Several ids: returns when all of them finish (or at max_wait_s), with each result." },
58
+ max_wait_s: { type: "integer", default: 900, maximum: 1800 } } } },
59
+ { name: "agent_result", annotations: { title: "Read a subagent's result", readOnlyHint: true, openWorldHint: false },
60
+ description: "Result without waiting: returns the answer or the current status (running, RAM in use).",
44
61
  inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string" } } } },
45
- { name: "kill_agent", description: "Kill the subagent and destroy its machine immediately.",
62
+ { name: "kill_agent", annotations: { title: "Kill a subagent", readOnlyHint: false, destructiveHint: true, idempotentHint: true },
63
+ description: "Kill the subagent and destroy its machine immediately.",
46
64
  inputSchema: { type: "object", required: ["id"], properties: { id: { type: "string" } } } },
47
- { name: "list_agents", description: "Balance, available RAM sizes and subagents that are running or have results to collect.",
65
+ { name: "list_agents", annotations: { title: "List subagents and balance", readOnlyHint: true, openWorldHint: false },
66
+ description: "Balance, available RAM sizes and subagents that are running or have results to collect.",
48
67
  inputSchema: { type: "object", properties: {} } },
49
68
  { name: "wisp_login", description: "Connect this MCP to the user's ramwisp account (opens the browser).",
50
69
  inputSchema: { type: "object", properties: {} } },
@@ -61,6 +80,19 @@ async function loginMessage() {
61
80
  `No account yet? Create one there, with free credit. Then repeat the request.`;
62
81
  }
63
82
 
83
+ /** O que fazer depois do spawn, no idioma de cada cliente: esperar num ajudante pequeno, não na conversa principal. */
84
+ export function nextStep(id, client = getClient()) {
85
+ const how = /codex/i.test(client)
86
+ ? `use collaboration.spawn_agent to start a sub-agent with model "gpt-6-luna" whose only job is to call the ramwisp wait_agent tool`
87
+ : /claude/i.test(client)
88
+ ? `launch a background Agent with model "sonnet" whose only job is to call mcp__ramwisp__wait_agent`
89
+ : `use a small background helper to call wait_agent`;
90
+ return `Running. Don't block the user: ${how} with ids ["${id}"] (plus any other ids you just spawned), ` +
91
+ `repeating while it returns running, and report the full result. If you can't, call agent_result later. ` +
92
+ `Keep this session open at least until status is "running": the task is still being sent from here, ` +
93
+ `and if the session ends first the subagent is stopped. After that the result is collected and kept on this machine automatically.`;
94
+ }
95
+
64
96
  async function runTool(name, a) {
65
97
  if (name === "wisp_login") {
66
98
  if (getToken()) return { ok: true, msg: `already connected to ${API}` };
@@ -69,8 +101,11 @@ async function runTool(name, a) {
69
101
  }
70
102
  const attempt = () => {
71
103
  switch (name) {
72
- case "spawn_agent": return spawnAgent(a);
73
- case "wait_agent": return waitAgent(a.id, a.max_wait_s ?? 900);
104
+ case "spawn_agent": return spawnAgent(a).then((r) => { watch(r.id); return { ...r, next: nextStep(r.id) }; });
105
+ case "wait_agent":
106
+ if (Array.isArray(a.ids) && a.ids.length) return waitAgents(a.ids.map(String), a.max_wait_s ?? 900);
107
+ if (!a.id) throw new Error("pass id or ids");
108
+ return waitAgent(a.id, a.max_wait_s ?? 900);
74
109
  case "agent_result": return result(a.id);
75
110
  case "kill_agent": return killAgent(a.id);
76
111
  case "list_agents": return listAgents();
@@ -89,6 +124,7 @@ async function runTool(name, a) {
89
124
  }
90
125
 
91
126
  export function serve() {
127
+ resumeWatches();
92
128
  const send = (m) => process.stdout.write(JSON.stringify(m) + "\n");
93
129
  const rl = createInterface({ input: process.stdin });
94
130
  rl.on("line", async (line) => {