cumora 0.1.40
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 +25 -0
- package/dist/cli.js +466 -0
- package/package.json +26 -0
package/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# cumora
|
|
2
|
+
|
|
3
|
+
Run your [Cumora](https://cumora.ai) agents on your own machine or VPS,
|
|
4
|
+
powered by your **local Claude Code or Codex CLI** (BYOA — Bring Your Own
|
|
5
|
+
Agent). One daemon can host many agents; each gets its own isolated
|
|
6
|
+
workspace, memory, and skills on that machine.
|
|
7
|
+
|
|
8
|
+
## Usage
|
|
9
|
+
|
|
10
|
+
In Cumora: **You → Computers → Add a computer** to get a pairing code, then
|
|
11
|
+
on the machine you want to host agents:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npx cumora agent computer --pair <code> --server <your-server-url>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Then start the daemon (after pairing, the config is saved):
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
npx cumora agent computer --server <your-server-url>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Requires **Node ≥ 18** and either `claude` (Claude Code) or `codex` on your
|
|
24
|
+
`PATH`. The daemon talks to the Cumora server over HTTPS only — it needs no
|
|
25
|
+
database access.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../server/src/agents/computer/daemon.ts
|
|
4
|
+
import { mkdir as mkdir2, writeFile as writeFile2, readFile, chmod } from "node:fs/promises";
|
|
5
|
+
import { homedir, hostname } from "node:os";
|
|
6
|
+
import { join as join2 } from "node:path";
|
|
7
|
+
|
|
8
|
+
// ../server/src/agents/runtime/sse-parse.ts
|
|
9
|
+
async function* parseSseStream(body) {
|
|
10
|
+
const decoder = new TextDecoder("utf-8");
|
|
11
|
+
let buf = "";
|
|
12
|
+
for await (const chunk of body) {
|
|
13
|
+
if (typeof chunk === "string") buf += chunk;
|
|
14
|
+
else if (chunk instanceof Uint8Array) buf += decoder.decode(chunk, { stream: true });
|
|
15
|
+
else continue;
|
|
16
|
+
let nl;
|
|
17
|
+
while ((nl = buf.indexOf("\n\n")) >= 0) {
|
|
18
|
+
const block = buf.slice(0, nl);
|
|
19
|
+
buf = buf.slice(nl + 2);
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const rawLine of block.split("\n")) {
|
|
22
|
+
const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine;
|
|
23
|
+
if (line.startsWith(":")) continue;
|
|
24
|
+
const colon = line.indexOf(":");
|
|
25
|
+
if (colon < 0) continue;
|
|
26
|
+
const field = line.slice(0, colon);
|
|
27
|
+
const value = line.slice(colon + 1).replace(/^ /, "");
|
|
28
|
+
if (field === "event") out.event = value;
|
|
29
|
+
else if (field === "data") out.data = (out.data ?? "") + value;
|
|
30
|
+
else if (field === "id") out.id = value;
|
|
31
|
+
}
|
|
32
|
+
if (out.event || out.data) yield out;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ../server/src/agents/computer/engine.ts
|
|
38
|
+
import { spawn } from "node:child_process";
|
|
39
|
+
import { mkdir, writeFile, access } from "node:fs/promises";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
async function binOnPath(bin) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const probe = spawn(process.platform === "win32" ? "where" : "which", [bin], { stdio: "ignore" });
|
|
44
|
+
probe.on("error", () => resolve(false));
|
|
45
|
+
probe.on("close", (code) => resolve(code === 0));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async function exists(p) {
|
|
49
|
+
try {
|
|
50
|
+
await access(p);
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function ensureCommonHome(home) {
|
|
57
|
+
await mkdir(join(home, "notes"), { recursive: true });
|
|
58
|
+
await mkdir(join(home, "workspace"), { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
function spawnEngine(bin, args, { home, env, onLog, signal }) {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const child = spawn(bin, args, { cwd: home, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
63
|
+
const onAbort = () => {
|
|
64
|
+
child.kill("SIGTERM");
|
|
65
|
+
};
|
|
66
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
67
|
+
const pump = (buf) => {
|
|
68
|
+
for (const line of buf.toString("utf8").split("\n")) {
|
|
69
|
+
if (line.trim()) onLog(line);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
child.stdout.on("data", pump);
|
|
73
|
+
child.stderr.on("data", pump);
|
|
74
|
+
child.on("error", (err) => {
|
|
75
|
+
signal.removeEventListener("abort", onAbort);
|
|
76
|
+
reject(err);
|
|
77
|
+
});
|
|
78
|
+
child.on("close", (code) => {
|
|
79
|
+
signal.removeEventListener("abort", onAbort);
|
|
80
|
+
resolve({ exitCode: code ?? 0 });
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
function extraArgs(envVar) {
|
|
85
|
+
const raw = process.env[envVar];
|
|
86
|
+
return raw ? raw.split(/\s+/).filter(Boolean) : [];
|
|
87
|
+
}
|
|
88
|
+
var PERSONA_HEADER = (p) => `# ${p.name}${p.role ? ` \u2014 ${p.role}` : ""}
|
|
89
|
+
|
|
90
|
+
You are **${p.name}**, a member of a team that collaborates in Cumora (a team chat).
|
|
91
|
+
This directory is your private home: it persists across wakes. Keep durable
|
|
92
|
+
memory, notes and working files here \u2014 they are yours alone.
|
|
93
|
+
|
|
94
|
+
When you act in Cumora, use the \`cumora\` command-line tool (already on your
|
|
95
|
+
PATH). Key commands:
|
|
96
|
+
- \`cumora inbox\` \u2014 unread messages across your conversations
|
|
97
|
+
- \`cumora messages <conversationId> --tail 30\` \u2014 read a conversation
|
|
98
|
+
- \`cumora reply <conversationId> '<text>'\` \u2014 post a message
|
|
99
|
+
- \`cumora whoami\` \u2014 your identity
|
|
100
|
+
|
|
101
|
+
Be a real teammate with your own voice \u2014 not a generic assistant.
|
|
102
|
+
`;
|
|
103
|
+
var ClaudeAdapter = class {
|
|
104
|
+
id = "claude";
|
|
105
|
+
bin = "claude";
|
|
106
|
+
async seedHome(home, persona) {
|
|
107
|
+
await ensureCommonHome(home);
|
|
108
|
+
await mkdir(join(home, ".claude", "skills"), { recursive: true });
|
|
109
|
+
const claudeMd = join(home, "CLAUDE.md");
|
|
110
|
+
if (!await exists(claudeMd)) await writeFile(claudeMd, PERSONA_HEADER(persona), "utf8");
|
|
111
|
+
const settings = join(home, ".claude", "settings.json");
|
|
112
|
+
if (!await exists(settings)) {
|
|
113
|
+
await writeFile(settings, JSON.stringify({ permissions: { allow: ["Bash"] } }, null, 2), "utf8");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
run(args) {
|
|
117
|
+
const flags = extraArgs("CUMORA_CLAUDE_ARGS");
|
|
118
|
+
const argv = flags.length ? [...flags, "-p", args.prompt] : ["-p", args.prompt, "--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions"];
|
|
119
|
+
return spawnEngine(this.bin, argv, args);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
var CodexAdapter = class {
|
|
123
|
+
id = "codex";
|
|
124
|
+
bin = "codex";
|
|
125
|
+
async seedHome(home, persona) {
|
|
126
|
+
await ensureCommonHome(home);
|
|
127
|
+
const agentsMd = join(home, "AGENTS.md");
|
|
128
|
+
if (!await exists(agentsMd)) await writeFile(agentsMd, PERSONA_HEADER(persona), "utf8");
|
|
129
|
+
}
|
|
130
|
+
run(args) {
|
|
131
|
+
const flags = extraArgs("CUMORA_CODEX_ARGS");
|
|
132
|
+
const argv = flags.length ? ["exec", ...flags, args.prompt] : ["exec", "--full-auto", args.prompt];
|
|
133
|
+
return spawnEngine(this.bin, argv, args);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
var ADAPTERS = {
|
|
137
|
+
claude: new ClaudeAdapter(),
|
|
138
|
+
codex: new CodexAdapter()
|
|
139
|
+
};
|
|
140
|
+
function getAdapter(id) {
|
|
141
|
+
return ADAPTERS[id];
|
|
142
|
+
}
|
|
143
|
+
async function detectEngines() {
|
|
144
|
+
const ids = Object.keys(ADAPTERS);
|
|
145
|
+
const present = await Promise.all(ids.map(async (id) => await binOnPath(ADAPTERS[id].bin) ? id : null));
|
|
146
|
+
return present.filter((x) => x !== null);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ../server/src/agents/computer/daemon.ts
|
|
150
|
+
var CONFIG_DIR = join2(homedir(), ".cumora");
|
|
151
|
+
var CONFIG_PATH = join2(CONFIG_DIR, "computer.json");
|
|
152
|
+
var AGENTS_ROOT = join2(CONFIG_DIR, "agents");
|
|
153
|
+
var DEFAULT_SERVER = process.env.CUMORA_SERVER_URL || "https://api.cumora.ai";
|
|
154
|
+
var TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1e3;
|
|
155
|
+
var AGENT_POLL_MS = 6e4;
|
|
156
|
+
var HEARTBEAT_MS = 3e4;
|
|
157
|
+
function parseArgs(argv) {
|
|
158
|
+
const out = {};
|
|
159
|
+
for (let i = 0; i < argv.length; i++) {
|
|
160
|
+
if (argv[i] === "--pair") out.pair = argv[++i];
|
|
161
|
+
else if (argv[i].startsWith("--pair=")) out.pair = argv[i].slice("--pair=".length);
|
|
162
|
+
else if (argv[i] === "--server") out.server = argv[++i];
|
|
163
|
+
else if (argv[i].startsWith("--server=")) out.server = argv[i].slice("--server=".length);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
async function api(serverUrl, path, init) {
|
|
168
|
+
const res = await fetch(`${serverUrl}${path}`, {
|
|
169
|
+
...init,
|
|
170
|
+
headers: { "Content-Type": "application/json", ...init.headers ?? {} }
|
|
171
|
+
});
|
|
172
|
+
if (!res.ok) {
|
|
173
|
+
const body = await res.text().catch(() => "");
|
|
174
|
+
throw new Error(`${init.method ?? "GET"} ${path} \u2192 HTTP ${res.status} ${body.slice(0, 200)}`);
|
|
175
|
+
}
|
|
176
|
+
return res.json();
|
|
177
|
+
}
|
|
178
|
+
async function runtimeBest(serverUrl, path, token, body) {
|
|
179
|
+
try {
|
|
180
|
+
const res = await fetch(`${serverUrl}/runtime${path}`, {
|
|
181
|
+
method: "POST",
|
|
182
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
183
|
+
body: JSON.stringify(body)
|
|
184
|
+
});
|
|
185
|
+
return res.ok ? await res.json().catch(() => null) : null;
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async function loadConfig() {
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(await readFile(CONFIG_PATH, "utf8"));
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async function saveConfig(cfg) {
|
|
198
|
+
await mkdir2(CONFIG_DIR, { recursive: true });
|
|
199
|
+
await writeFile2(CONFIG_PATH, JSON.stringify(cfg, null, 2), "utf8");
|
|
200
|
+
await chmod(CONFIG_PATH, 384);
|
|
201
|
+
}
|
|
202
|
+
var CUMORA_SHIM = `#!/usr/bin/env node
|
|
203
|
+
'use strict'
|
|
204
|
+
;(async () => {
|
|
205
|
+
const url = process.env.CUMORA_AGENT_RUNTIME_URL
|
|
206
|
+
const token = process.env.CUMORA_AGENT_RUNTIME_TOKEN
|
|
207
|
+
if (!url || !token) { console.error('cumora: runtime env not set'); process.exit(70) }
|
|
208
|
+
const res = await fetch(url + '/cli', {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
|
211
|
+
body: JSON.stringify({ argv: process.argv.slice(2) }),
|
|
212
|
+
})
|
|
213
|
+
if (!res.ok) {
|
|
214
|
+
const t = await res.text().catch(() => '')
|
|
215
|
+
console.error('cumora: HTTP ' + res.status + ' ' + t)
|
|
216
|
+
process.exit(70)
|
|
217
|
+
}
|
|
218
|
+
const data = await res.json()
|
|
219
|
+
if (typeof data.text === 'string' && data.text) process.stdout.write(data.text + '\\n')
|
|
220
|
+
process.exit(typeof data.exitCode === 'number' ? data.exitCode : 0)
|
|
221
|
+
})().catch((e) => { console.error('cumora:', (e && e.message) || e); process.exit(70) })
|
|
222
|
+
`;
|
|
223
|
+
async function writeShim(binDir) {
|
|
224
|
+
await mkdir2(binDir, { recursive: true });
|
|
225
|
+
const shim = join2(binDir, "cumora");
|
|
226
|
+
await writeFile2(shim, CUMORA_SHIM, "utf8");
|
|
227
|
+
await chmod(shim, 493);
|
|
228
|
+
}
|
|
229
|
+
async function doPair(code, serverUrl) {
|
|
230
|
+
const engines = await detectEngines();
|
|
231
|
+
if (engines.length === 0) {
|
|
232
|
+
console.warn("[computer] warning: no engine found on PATH (need `claude` or `codex`).");
|
|
233
|
+
}
|
|
234
|
+
const paired = await api(
|
|
235
|
+
serverUrl,
|
|
236
|
+
"/api/computers/pair",
|
|
237
|
+
{ method: "POST", body: JSON.stringify({ code, hostName: hostname(), engines }) }
|
|
238
|
+
);
|
|
239
|
+
await saveConfig({ serverUrl, computerId: paired.computerId, deviceToken: paired.deviceToken });
|
|
240
|
+
console.log(`[computer] paired as ${paired.computerId} (engines: ${engines.join(", ") || "none"})`);
|
|
241
|
+
console.log(`[computer] run \`cumora agent computer\` to start hosting your agents.`);
|
|
242
|
+
}
|
|
243
|
+
var AgentRunner = class {
|
|
244
|
+
constructor(cfg, agent, engine) {
|
|
245
|
+
this.cfg = cfg;
|
|
246
|
+
this.agent = agent;
|
|
247
|
+
this.home = join2(AGENTS_ROOT, agent.id);
|
|
248
|
+
this.binDir = join2(this.home, "bin");
|
|
249
|
+
this.adapter = getAdapter(engine);
|
|
250
|
+
}
|
|
251
|
+
token = "";
|
|
252
|
+
tokenExpiresAt = 0;
|
|
253
|
+
home;
|
|
254
|
+
binDir;
|
|
255
|
+
busy = false;
|
|
256
|
+
pendingRerun = false;
|
|
257
|
+
stopped = false;
|
|
258
|
+
adapter;
|
|
259
|
+
async start() {
|
|
260
|
+
await this.adapter.seedHome(this.home, { id: this.agent.id, name: this.agent.name, role: this.agent.role });
|
|
261
|
+
await writeShim(this.binDir);
|
|
262
|
+
void this.streamLoop();
|
|
263
|
+
}
|
|
264
|
+
stop() {
|
|
265
|
+
this.stopped = true;
|
|
266
|
+
}
|
|
267
|
+
async ensureToken() {
|
|
268
|
+
if (this.token && Date.now() < this.tokenExpiresAt - TOKEN_REFRESH_SKEW_MS) return this.token;
|
|
269
|
+
const minted = await api(
|
|
270
|
+
this.cfg.serverUrl,
|
|
271
|
+
`/api/agents/${this.agent.id}/runtime-token`,
|
|
272
|
+
{ method: "POST", headers: { Authorization: `Bearer ${this.cfg.deviceToken}` }, body: "{}" }
|
|
273
|
+
);
|
|
274
|
+
this.token = minted.token;
|
|
275
|
+
this.tokenExpiresAt = Date.now() + minted.expiresInSeconds * 1e3;
|
|
276
|
+
return this.token;
|
|
277
|
+
}
|
|
278
|
+
/** Env handed to the engine subprocess: the `cumora` shim on PATH, wired to
|
|
279
|
+
* this agent's runtime URL + token. */
|
|
280
|
+
engineEnv() {
|
|
281
|
+
return {
|
|
282
|
+
...process.env,
|
|
283
|
+
PATH: `${this.binDir}:${process.env.PATH ?? ""}`,
|
|
284
|
+
CUMORA_AGENT_RUNTIME_URL: `${this.cfg.serverUrl}/runtime`,
|
|
285
|
+
CUMORA_AGENT_RUNTIME_TOKEN: this.token,
|
|
286
|
+
CUMORA_AGENT_ID: this.agent.id
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
prompt() {
|
|
290
|
+
return `You've been woken because there's new activity in your Cumora conversations.
|
|
291
|
+
|
|
292
|
+
Use the \`cumora\` tool to catch up and respond:
|
|
293
|
+
1. \`cumora inbox\` \u2014 see what's unread
|
|
294
|
+
2. \`cumora messages <conversationId> --tail 30\` \u2014 read the relevant thread(s)
|
|
295
|
+
3. \`cumora reply <conversationId> '<text>'\` \u2014 reply, in your own voice, only where you add something
|
|
296
|
+
|
|
297
|
+
If nothing genuinely needs you, it's fine to do nothing and stop. When finished, stop.`;
|
|
298
|
+
}
|
|
299
|
+
/** Run one turn. Coalesces concurrent wakes: a wake during a run schedules
|
|
300
|
+
* exactly one rerun afterward (the inbox is the source of truth). */
|
|
301
|
+
async runTurn() {
|
|
302
|
+
if (this.busy) {
|
|
303
|
+
this.pendingRerun = true;
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
this.busy = true;
|
|
307
|
+
try {
|
|
308
|
+
do {
|
|
309
|
+
this.pendingRerun = false;
|
|
310
|
+
await this.ensureToken();
|
|
311
|
+
const token = this.token;
|
|
312
|
+
await runtimeBest(this.cfg.serverUrl, "/status", token, { status: "thinking" });
|
|
313
|
+
const run = await runtimeBest(this.cfg.serverUrl, "/runs", token, {
|
|
314
|
+
trigger: { source: "byoa", engine: this.adapter.id }
|
|
315
|
+
});
|
|
316
|
+
const controller = new AbortController();
|
|
317
|
+
let exitCode = 0;
|
|
318
|
+
try {
|
|
319
|
+
const result = await this.adapter.run({
|
|
320
|
+
home: this.home,
|
|
321
|
+
prompt: this.prompt(),
|
|
322
|
+
env: this.engineEnv(),
|
|
323
|
+
onLog: (line) => console.log(`[${this.agent.id}/${this.adapter.id}] ${line.slice(0, 500)}`),
|
|
324
|
+
signal: controller.signal
|
|
325
|
+
});
|
|
326
|
+
exitCode = result.exitCode;
|
|
327
|
+
} catch (err) {
|
|
328
|
+
console.error(`[computer] ${this.agent.id} engine spawn failed:`, err instanceof Error ? err.message : err);
|
|
329
|
+
exitCode = 1;
|
|
330
|
+
}
|
|
331
|
+
if (run?.runId) {
|
|
332
|
+
await runtimeBest(this.cfg.serverUrl, `/runs/${run.runId}/finish`, token, {
|
|
333
|
+
status: exitCode === 0 ? "completed" : "failed",
|
|
334
|
+
summary: `byoa ${this.adapter.id} run (exit ${exitCode})`
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
await runtimeBest(this.cfg.serverUrl, "/status", token, { status: "avail" });
|
|
338
|
+
} while (this.pendingRerun && !this.stopped);
|
|
339
|
+
} finally {
|
|
340
|
+
this.busy = false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/** Hold the wake-stream open; reconnect with backoff. First connect (and
|
|
344
|
+
* every reconnect) does a catch-up turn — the inbox is durable. */
|
|
345
|
+
async streamLoop() {
|
|
346
|
+
let backoff = 1e3;
|
|
347
|
+
while (!this.stopped) {
|
|
348
|
+
try {
|
|
349
|
+
const token = await this.ensureToken();
|
|
350
|
+
const res = await fetch(`${this.cfg.serverUrl}/runtime/wake-stream`, {
|
|
351
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" }
|
|
352
|
+
});
|
|
353
|
+
if (!res.ok || !res.body) throw new Error(`wake-stream HTTP ${res.status}`);
|
|
354
|
+
console.log(`[computer] ${this.agent.id} connected (engine: ${this.adapter.id})`);
|
|
355
|
+
backoff = 1e3;
|
|
356
|
+
void this.runTurn();
|
|
357
|
+
for await (const evt of parseSseStream(res.body)) {
|
|
358
|
+
if (this.stopped) break;
|
|
359
|
+
if (evt.event === "wake") void this.runTurn();
|
|
360
|
+
}
|
|
361
|
+
} catch (err) {
|
|
362
|
+
if (this.stopped) break;
|
|
363
|
+
console.warn(`[computer] ${this.agent.id} stream error: ${err instanceof Error ? err.message : err} \xB7 retry in ${backoff}ms`);
|
|
364
|
+
await new Promise((r) => setTimeout(r, backoff));
|
|
365
|
+
backoff = Math.min(backoff * 2, 3e4);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
async function doRun(serverOverride) {
|
|
371
|
+
const cfg = await loadConfig();
|
|
372
|
+
if (!cfg) {
|
|
373
|
+
console.error("[computer] not paired. Run: cumora agent computer --pair <code> [--server <url>]");
|
|
374
|
+
process.exitCode = 1;
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (serverOverride) cfg.serverUrl = serverOverride;
|
|
378
|
+
const available = await detectEngines();
|
|
379
|
+
if (available.length === 0) {
|
|
380
|
+
console.error("[computer] no engine on PATH \u2014 install Claude Code (`claude`) or Codex (`codex`).");
|
|
381
|
+
process.exitCode = 1;
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
console.log(`[computer] starting ${cfg.computerId} @ ${cfg.serverUrl} (engines: ${available.join(", ")})`);
|
|
385
|
+
const runners = /* @__PURE__ */ new Map();
|
|
386
|
+
const sync = async () => {
|
|
387
|
+
let agents;
|
|
388
|
+
try {
|
|
389
|
+
agents = await api(cfg.serverUrl, "/api/computers/me/agents", {
|
|
390
|
+
headers: { Authorization: `Bearer ${cfg.deviceToken}` }
|
|
391
|
+
});
|
|
392
|
+
} catch (err) {
|
|
393
|
+
console.warn("[computer] agent sync failed:", err instanceof Error ? err.message : err);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
for (const agent of agents) {
|
|
397
|
+
if (runners.has(agent.id)) continue;
|
|
398
|
+
const engine = agent.engine && available.includes(agent.engine) ? agent.engine : available[0] ?? null;
|
|
399
|
+
if (!engine) continue;
|
|
400
|
+
const runner = new AgentRunner(cfg, agent, engine);
|
|
401
|
+
runners.set(agent.id, runner);
|
|
402
|
+
console.log(`[computer] hosting agent ${agent.name} (${agent.id}) on ${engine}`);
|
|
403
|
+
await runner.start();
|
|
404
|
+
}
|
|
405
|
+
const live = new Set(agents.map((a) => a.id));
|
|
406
|
+
for (const [id, runner] of runners) {
|
|
407
|
+
if (!live.has(id)) {
|
|
408
|
+
runner.stop();
|
|
409
|
+
runners.delete(id);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
const heartbeat = async () => {
|
|
414
|
+
try {
|
|
415
|
+
await fetch(`${cfg.serverUrl}/api/computers/heartbeat`, {
|
|
416
|
+
method: "POST",
|
|
417
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.deviceToken}` },
|
|
418
|
+
body: "{}"
|
|
419
|
+
});
|
|
420
|
+
} catch {
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
await heartbeat();
|
|
424
|
+
await sync();
|
|
425
|
+
if (runners.size === 0) {
|
|
426
|
+
console.log("[computer] no agents assigned to this computer yet. Assign one in Cumora; polling\u2026");
|
|
427
|
+
}
|
|
428
|
+
const poll = setInterval(() => {
|
|
429
|
+
void sync();
|
|
430
|
+
}, AGENT_POLL_MS);
|
|
431
|
+
const beat = setInterval(() => {
|
|
432
|
+
void heartbeat();
|
|
433
|
+
}, HEARTBEAT_MS);
|
|
434
|
+
const shutdown = () => {
|
|
435
|
+
clearInterval(poll);
|
|
436
|
+
clearInterval(beat);
|
|
437
|
+
for (const runner of runners.values()) runner.stop();
|
|
438
|
+
console.log("[computer] shutting down");
|
|
439
|
+
process.exit(0);
|
|
440
|
+
};
|
|
441
|
+
process.on("SIGINT", shutdown);
|
|
442
|
+
process.on("SIGTERM", shutdown);
|
|
443
|
+
}
|
|
444
|
+
async function runComputerDaemon(argv) {
|
|
445
|
+
const args = parseArgs(argv);
|
|
446
|
+
const serverUrl = (args.server || DEFAULT_SERVER).replace(/\/+$/, "");
|
|
447
|
+
if (args.pair) {
|
|
448
|
+
await doPair(args.pair, serverUrl);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
await doRun(args.server ? serverUrl : void 0);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// src/cli.ts
|
|
455
|
+
async function main() {
|
|
456
|
+
const argv = process.argv.slice(2);
|
|
457
|
+
if (argv[0] === "agent" && argv[1] === "computer") {
|
|
458
|
+
await runComputerDaemon(argv.slice(2));
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
process.stderr.write(
|
|
462
|
+
"cumora \u2014 run your Cumora agents on this machine (BYOA)\n\nUsage:\n cumora agent computer --pair <code> [--server <url>] pair this machine\n cumora agent computer [--server <url>] start the daemon\n\nNeeds `claude` (Claude Code) or `codex` on PATH. Get a pairing code from\nCumora \u2192 You \u2192 Computers \u2192 Add a computer.\n"
|
|
463
|
+
);
|
|
464
|
+
process.exit(argv.length ? 1 : 0);
|
|
465
|
+
}
|
|
466
|
+
void main();
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cumora",
|
|
3
|
+
"version": "0.1.40",
|
|
4
|
+
"description": "Run your Cumora agents on your own machine or VPS, powered by your local Claude Code or Codex CLI (BYOA).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cumora": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://cumora.ai",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"cumora",
|
|
18
|
+
"agent",
|
|
19
|
+
"claude-code",
|
|
20
|
+
"codex"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "node build.mjs",
|
|
24
|
+
"prepublishOnly": "node build.mjs"
|
|
25
|
+
}
|
|
26
|
+
}
|