cumora 0.1.46 → 0.1.48
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/dist/cli.js +206 -23
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// ../server/src/agents/computer/daemon.ts
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
4
5
|
import { mkdir as mkdir2, writeFile as writeFile2, readFile, chmod } from "node:fs/promises";
|
|
5
6
|
import { homedir, hostname } from "node:os";
|
|
6
7
|
import { join as join2 } from "node:path";
|
|
@@ -68,6 +69,26 @@ async function ensureCommonHome(home) {
|
|
|
68
69
|
);
|
|
69
70
|
}
|
|
70
71
|
}
|
|
72
|
+
var MAX_FAILURE_LINES = 30;
|
|
73
|
+
var MAX_FAILURE_CHARS = 4e3;
|
|
74
|
+
var ANSI_RE = /\x1B\[[0-?]*[ -/]*[@-~]/g;
|
|
75
|
+
function cleanLine(line) {
|
|
76
|
+
return line.replace(ANSI_RE, "").replace(/\r/g, "").trim();
|
|
77
|
+
}
|
|
78
|
+
function pushTail(lines, line) {
|
|
79
|
+
if (!line) return;
|
|
80
|
+
lines.push(line);
|
|
81
|
+
if (lines.length > MAX_FAILURE_LINES) lines.shift();
|
|
82
|
+
}
|
|
83
|
+
function failurePreview(args) {
|
|
84
|
+
const parts = [];
|
|
85
|
+
if (args.stderr.length > 0) parts.push(args.stderr.join("\n"));
|
|
86
|
+
if (args.stdout.length > 0) parts.push(args.stdout.join("\n"));
|
|
87
|
+
const detail = parts.join("\n").trim();
|
|
88
|
+
const prefix = args.signalName ? `process terminated by ${args.signalName}` : `process exited with code ${args.exitCode}`;
|
|
89
|
+
return detail ? `${prefix}
|
|
90
|
+
${detail}`.slice(0, MAX_FAILURE_CHARS) : prefix;
|
|
91
|
+
}
|
|
71
92
|
function spawnEngine(bin, args, { home, env, onLog, signal }) {
|
|
72
93
|
return new Promise((resolve, reject) => {
|
|
73
94
|
const child = spawn(bin, args, { cwd: home, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -75,20 +96,29 @@ function spawnEngine(bin, args, { home, env, onLog, signal }) {
|
|
|
75
96
|
child.kill("SIGTERM");
|
|
76
97
|
};
|
|
77
98
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
78
|
-
const
|
|
99
|
+
const stderrTail = [];
|
|
100
|
+
const stdoutTail = [];
|
|
101
|
+
const pump = (stream, buf) => {
|
|
79
102
|
for (const line of buf.toString("utf8").split("\n")) {
|
|
80
|
-
|
|
103
|
+
const cleaned = cleanLine(line);
|
|
104
|
+
if (!cleaned) continue;
|
|
105
|
+
pushTail(stream === "stderr" ? stderrTail : stdoutTail, cleaned);
|
|
106
|
+
onLog(cleaned);
|
|
81
107
|
}
|
|
82
108
|
};
|
|
83
|
-
child.stdout.on("data", pump);
|
|
84
|
-
child.stderr.on("data", pump);
|
|
109
|
+
child.stdout.on("data", (buf) => pump("stdout", buf));
|
|
110
|
+
child.stderr.on("data", (buf) => pump("stderr", buf));
|
|
85
111
|
child.on("error", (err) => {
|
|
86
112
|
signal.removeEventListener("abort", onAbort);
|
|
87
113
|
reject(err);
|
|
88
114
|
});
|
|
89
|
-
child.on("close", (code) => {
|
|
115
|
+
child.on("close", (code, signalName) => {
|
|
90
116
|
signal.removeEventListener("abort", onAbort);
|
|
91
|
-
|
|
117
|
+
const exitCode = code ?? (signalName ? 128 : 1);
|
|
118
|
+
resolve({
|
|
119
|
+
exitCode,
|
|
120
|
+
error: exitCode === 0 ? void 0 : failurePreview({ exitCode, signalName, stderr: stderrTail, stdout: stdoutTail })
|
|
121
|
+
});
|
|
92
122
|
});
|
|
93
123
|
});
|
|
94
124
|
}
|
|
@@ -151,7 +181,11 @@ var ClaudeAdapter = class {
|
|
|
151
181
|
const flags = extraArgs("CUMORA_CLAUDE_ARGS");
|
|
152
182
|
const model = args.model ? ["--model", args.model] : [];
|
|
153
183
|
const argv = flags.length ? [...flags, "-p", args.prompt] : ["-p", args.prompt, ...model, "--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions"];
|
|
154
|
-
const env =
|
|
184
|
+
const env = {
|
|
185
|
+
...args.env,
|
|
186
|
+
MAX_THINKING_TOKENS: args.env.MAX_THINKING_TOKENS ?? "0"
|
|
187
|
+
};
|
|
188
|
+
if (args.fastModel) env.ANTHROPIC_SMALL_FAST_MODEL = args.fastModel;
|
|
155
189
|
return spawnEngine(this.bin, argv, { ...args, env });
|
|
156
190
|
}
|
|
157
191
|
};
|
|
@@ -192,6 +226,8 @@ var DEFAULT_SERVER = process.env.CUMORA_SERVER_URL || "https://api.cumora.ai";
|
|
|
192
226
|
var TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1e3;
|
|
193
227
|
var AGENT_POLL_MS = 6e4;
|
|
194
228
|
var HEARTBEAT_MS = 3e4;
|
|
229
|
+
var INBOX_POLL_MS = 2e4;
|
|
230
|
+
var MAX_VISIBLE_ERROR_CHARS = 900;
|
|
195
231
|
function parseArgs(argv) {
|
|
196
232
|
const out = {};
|
|
197
233
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -225,6 +261,49 @@ async function runtimeBest(serverUrl, path, token, body) {
|
|
|
225
261
|
return null;
|
|
226
262
|
}
|
|
227
263
|
}
|
|
264
|
+
async function runtimeGet(serverUrl, path, token) {
|
|
265
|
+
try {
|
|
266
|
+
const res = await fetch(`${serverUrl}/runtime${path}`, {
|
|
267
|
+
method: "GET",
|
|
268
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
269
|
+
});
|
|
270
|
+
return res.ok ? await res.json().catch(() => null) : null;
|
|
271
|
+
} catch {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function hashText(text) {
|
|
276
|
+
return createHash("sha1").update(text).digest("hex").slice(0, 12);
|
|
277
|
+
}
|
|
278
|
+
function conciseError(text) {
|
|
279
|
+
return text.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "").replace(/\r/g, "").trim().slice(0, MAX_VISIBLE_ERROR_CHARS);
|
|
280
|
+
}
|
|
281
|
+
function authFailureHint(engine, detail) {
|
|
282
|
+
if (!/(auth|login|token|quota|credit|billing|subscription|rate limit|usage limit|api key)/i.test(detail)) {
|
|
283
|
+
return "Check the daemon terminal for details, then wake the agent again.";
|
|
284
|
+
}
|
|
285
|
+
if (engine === "claude") {
|
|
286
|
+
return "Open Claude Code on that computer and sign in, refresh quota, or add credits, then wake the agent again.";
|
|
287
|
+
}
|
|
288
|
+
return "Open Codex on that computer and refresh its login or quota, then wake the agent again.";
|
|
289
|
+
}
|
|
290
|
+
function missingEngineMessage() {
|
|
291
|
+
return [
|
|
292
|
+
"no supported local agent engine found on PATH",
|
|
293
|
+
"",
|
|
294
|
+
"Install and sign in to at least one of:",
|
|
295
|
+
" - Claude Code: install the `claude` CLI, then run `claude` once to sign in",
|
|
296
|
+
" - Codex: install the `codex` CLI, then run `codex` once to sign in",
|
|
297
|
+
"",
|
|
298
|
+
"After that, rerun:",
|
|
299
|
+
" npx cumora agent computer --pair <code>"
|
|
300
|
+
].join("\n");
|
|
301
|
+
}
|
|
302
|
+
async function requireLocalEngine() {
|
|
303
|
+
const engines = await detectEngines();
|
|
304
|
+
if (engines.length === 0) throw new Error(missingEngineMessage());
|
|
305
|
+
return engines;
|
|
306
|
+
}
|
|
228
307
|
async function loadConfig() {
|
|
229
308
|
try {
|
|
230
309
|
return JSON.parse(await readFile(CONFIG_PATH, "utf8"));
|
|
@@ -280,10 +359,7 @@ async function detectHostName() {
|
|
|
280
359
|
return base || "My computer";
|
|
281
360
|
}
|
|
282
361
|
async function doPair(code, serverUrl) {
|
|
283
|
-
const engines = await
|
|
284
|
-
if (engines.length === 0) {
|
|
285
|
-
console.warn("[computer] warning: no engine found on PATH (need `claude` or `codex`).");
|
|
286
|
-
}
|
|
362
|
+
const engines = await requireLocalEngine();
|
|
287
363
|
const paired = await api(
|
|
288
364
|
serverUrl,
|
|
289
365
|
"/api/computers/pair",
|
|
@@ -308,14 +384,22 @@ var AgentRunner = class {
|
|
|
308
384
|
pendingRerun = false;
|
|
309
385
|
stopped = false;
|
|
310
386
|
lastWakeConvo = null;
|
|
387
|
+
pollTimer;
|
|
311
388
|
adapter;
|
|
312
389
|
async start() {
|
|
313
390
|
await this.adapter.seedHome(this.home, { id: this.agent.id, name: this.agent.name, role: this.agent.role });
|
|
314
391
|
await writeShim(this.binDir);
|
|
315
392
|
void this.streamLoop();
|
|
393
|
+
this.pollTimer = setInterval(() => {
|
|
394
|
+
if (!this.busy && !this.stopped) void this.runTurn();
|
|
395
|
+
}, INBOX_POLL_MS);
|
|
316
396
|
}
|
|
317
397
|
stop() {
|
|
318
398
|
this.stopped = true;
|
|
399
|
+
if (this.pollTimer) {
|
|
400
|
+
clearInterval(this.pollTimer);
|
|
401
|
+
this.pollTimer = void 0;
|
|
402
|
+
}
|
|
319
403
|
}
|
|
320
404
|
/** Does this runner's live config still match the latest server state? The
|
|
321
405
|
* engine + model + persona are captured at construction (the adapter is
|
|
@@ -347,6 +431,44 @@ var AgentRunner = class {
|
|
|
347
431
|
CUMORA_AGENT_ID: this.agent.id
|
|
348
432
|
};
|
|
349
433
|
}
|
|
434
|
+
visibleEngineError(exitCode, detail) {
|
|
435
|
+
const raw = conciseError(detail || `process exited with code ${exitCode}`);
|
|
436
|
+
const redacted = raw.split(this.home).join("<agent home>").split(homedir()).join("~");
|
|
437
|
+
return `local ${this.adapter.id} failed (exit ${exitCode}): ${redacted}`;
|
|
438
|
+
}
|
|
439
|
+
async publishEngineFailure(args) {
|
|
440
|
+
if (args.runId) {
|
|
441
|
+
await runtimeBest(this.cfg.serverUrl, "/events", args.token, {
|
|
442
|
+
runId: args.runId,
|
|
443
|
+
kind: "engine.failed",
|
|
444
|
+
level: "error",
|
|
445
|
+
title: `${this.adapter.id} exited ${args.exitCode}`,
|
|
446
|
+
data: { error: args.error },
|
|
447
|
+
stage: "failed"
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
const hint = authFailureHint(this.adapter.id, args.error);
|
|
451
|
+
const conversationIds = await this.failureConversationIds(args.token, args.conversationId);
|
|
452
|
+
await Promise.all(conversationIds.map((conversationId) => runtimeBest(this.cfg.serverUrl, "/notices", args.token, {
|
|
453
|
+
conversationId,
|
|
454
|
+
agentId: this.agent.id,
|
|
455
|
+
noticeKind: "byoa_engine_failed",
|
|
456
|
+
text: `${this.agent.name} could not run on local ${this.adapter.id}: ${args.error}
|
|
457
|
+
${hint}`,
|
|
458
|
+
dedupeKey: `byoa_engine_failed:${this.agent.id}:${conversationId}:${hashText(args.error)}`,
|
|
459
|
+
dedupeTtlSec: 900
|
|
460
|
+
})));
|
|
461
|
+
}
|
|
462
|
+
async failureConversationIds(token, conversationId) {
|
|
463
|
+
if (conversationId) return [conversationId];
|
|
464
|
+
const inbox = await runtimeGet(this.cfg.serverUrl, "/inbox", token);
|
|
465
|
+
const ids = /* @__PURE__ */ new Set();
|
|
466
|
+
for (const row of inbox?.rows ?? []) {
|
|
467
|
+
if (typeof row.conversation_id === "string" && row.conversation_id) ids.add(row.conversation_id);
|
|
468
|
+
if (ids.size >= 5) break;
|
|
469
|
+
}
|
|
470
|
+
return [...ids];
|
|
471
|
+
}
|
|
350
472
|
/** Preload the memory index (memory/MEMORY.md) so it's ALWAYS in the wake
|
|
351
473
|
* prompt — the lightweight BYOA analog of the cloud agent's RAG memory
|
|
352
474
|
* preload. We inject only the small index; the agent opens detail files on
|
|
@@ -361,13 +483,29 @@ var AgentRunner = class {
|
|
|
361
483
|
return "";
|
|
362
484
|
}
|
|
363
485
|
}
|
|
364
|
-
|
|
486
|
+
async inboxTriage(token) {
|
|
487
|
+
return runtimeGet(this.cfg.serverUrl, "/inbox-triage", token);
|
|
488
|
+
}
|
|
489
|
+
formatTriageNote(triage) {
|
|
490
|
+
if (!triage) return "";
|
|
491
|
+
const note = typeof triage?.promptNote === "string" ? triage.promptNote.trim() : "";
|
|
492
|
+
if (!note) return "";
|
|
493
|
+
const state = triage.actionable === false ? "not relevant" : "relevant";
|
|
494
|
+
const source = typeof triage.source === "string" ? triage.source : "server";
|
|
495
|
+
const reason = typeof triage.reason === "string" && triage.reason.trim() ? `
|
|
496
|
+
Reason: ${triage.reason.trim().slice(0, 500)}` : "";
|
|
497
|
+
return `Small-brain inbox triage (${state}, ${source}): ${note}${reason}`;
|
|
498
|
+
}
|
|
499
|
+
prompt(memoryDigest, triageNote) {
|
|
365
500
|
return `You've been woken because there's new activity in your Cumora conversations.
|
|
366
501
|
|
|
367
|
-
|
|
502
|
+
` + (triageNote ? `${triageNote}
|
|
503
|
+
|
|
504
|
+
` : ``) + `Use the \`cumora\` tool to catch up and respond:
|
|
368
505
|
1. \`cumora inbox\` \u2014 see what's unread
|
|
369
506
|
2. \`cumora messages <conversationId> --tail 30\` \u2014 read the relevant thread(s)
|
|
370
|
-
3. \`cumora
|
|
507
|
+
3. \`cumora glance <conversationId>\` \u2014 in group threads, read the room right before replying
|
|
508
|
+
4. \`cumora reply <conversationId> '<text>'\` \u2014 reply in your own voice
|
|
371
509
|
|
|
372
510
|
` + (memoryDigest ? `Your memory index (\`memory/MEMORY.md\`) \u2014 consult before replying; \`cat memory/<file>\` for any detail it points to:
|
|
373
511
|
${memoryDigest}
|
|
@@ -376,6 +514,10 @@ ${memoryDigest}
|
|
|
376
514
|
|
|
377
515
|
Mentions: to notify or address a specific teammate, write @<their-id> using their participant id (the short id shown in \`cumora messages\` and \`cumora participants\`), NOT their display name. "@Alex P" does nothing; "@alex-3f9a" actually pings them. Run \`cumora participants\` if you need to look up an id.
|
|
378
516
|
|
|
517
|
+
When a HUMAN says \`@all\` in a group conversation, they addressed YOU too. Do not silently ack an @all just because it is simple. If it is a greeting like "hello @all" or "hi @all", reply briefly as yourself. If it asks every agent to introduce themselves, vote, count, or each give an answer, you should answer your part. Before replying in a group, run \`cumora glance <conversationId>\` so you do not duplicate a peer's identical answer.
|
|
518
|
+
|
|
519
|
+
Concrete task handoff: if the human asks for one concrete deliverable in a group (generate images, draft the email, summarize the thread, make the chart, fix X) and another teammate has clearly claimed or is already executing that exact task, stay quiet unless you have been specifically named or the result lands and needs your specific missing contribution. Do not add side commentary while a teammate is working.
|
|
520
|
+
|
|
379
521
|
Know when to stay quiet \u2014 this is how teams avoid endless back-and-forth:
|
|
380
522
|
- Read the WHOLE thread first, including your own past replies. If you've already made your point, don't repeat it. Saying nothing is better than echoing.
|
|
381
523
|
- Only reply where you genuinely add something the thread doesn't already have. Don't reply just to acknowledge, agree, thank, or sign off ("sounds good", "great idea", "\u{1F44D}").
|
|
@@ -401,24 +543,43 @@ If nothing genuinely needs you, it's fine to do nothing and stop. When finished,
|
|
|
401
543
|
const token = this.token;
|
|
402
544
|
const convo = this.lastWakeConvo;
|
|
403
545
|
this.lastWakeConvo = null;
|
|
546
|
+
const triage = await this.inboxTriage(token);
|
|
547
|
+
if (triage?.actionable === false) {
|
|
548
|
+
const reason = typeof triage.reason === "string" ? triage.reason : "not relevant";
|
|
549
|
+
console.log(`[computer] ${this.agent.id} skipped by small-brain inbox triage: ${reason}`);
|
|
550
|
+
await runtimeBest(this.cfg.serverUrl, "/status", token, { status: "avail" });
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
404
553
|
await runtimeBest(this.cfg.serverUrl, "/status", token, { status: "thinking" });
|
|
405
554
|
let typingTimer;
|
|
406
555
|
if (convo) {
|
|
407
556
|
const ping = () => {
|
|
408
557
|
void runtimeBest(this.cfg.serverUrl, "/typing", token, { conversationId: convo, done: false });
|
|
409
558
|
};
|
|
559
|
+
const thinkingPing = () => {
|
|
560
|
+
void runtimeBest(this.cfg.serverUrl, "/thinking/mark", token, { conversationIds: [convo], ttlSec: 60 });
|
|
561
|
+
};
|
|
410
562
|
ping();
|
|
411
|
-
|
|
563
|
+
thinkingPing();
|
|
564
|
+
typingTimer = setInterval(() => {
|
|
565
|
+
ping();
|
|
566
|
+
thinkingPing();
|
|
567
|
+
}, 6e3);
|
|
412
568
|
}
|
|
413
569
|
const run = await runtimeBest(this.cfg.serverUrl, "/runs", token, {
|
|
414
570
|
trigger: { source: "byoa", engine: this.adapter.id }
|
|
415
571
|
});
|
|
416
572
|
const controller = new AbortController();
|
|
417
573
|
let exitCode = 0;
|
|
574
|
+
let engineError = null;
|
|
418
575
|
try {
|
|
576
|
+
const [memoryDigest, triageNote] = await Promise.all([
|
|
577
|
+
this.memoryDigest(),
|
|
578
|
+
Promise.resolve(this.formatTriageNote(triage))
|
|
579
|
+
]);
|
|
419
580
|
const result = await this.adapter.run({
|
|
420
581
|
home: this.home,
|
|
421
|
-
prompt: this.prompt(
|
|
582
|
+
prompt: this.prompt(memoryDigest, triageNote),
|
|
422
583
|
env: this.engineEnv(),
|
|
423
584
|
model: this.agent.model,
|
|
424
585
|
fastModel: this.agent.fastModel,
|
|
@@ -426,17 +587,32 @@ If nothing genuinely needs you, it's fine to do nothing and stop. When finished,
|
|
|
426
587
|
signal: controller.signal
|
|
427
588
|
});
|
|
428
589
|
exitCode = result.exitCode;
|
|
590
|
+
if (result.error) engineError = this.visibleEngineError(exitCode, result.error);
|
|
429
591
|
} catch (err) {
|
|
430
592
|
console.error(`[computer] ${this.agent.id} engine spawn failed:`, err instanceof Error ? err.message : err);
|
|
431
593
|
exitCode = 1;
|
|
594
|
+
engineError = this.visibleEngineError(exitCode, err instanceof Error ? err.stack || err.message : String(err));
|
|
432
595
|
} finally {
|
|
433
596
|
if (typingTimer) clearInterval(typingTimer);
|
|
434
|
-
if (convo)
|
|
597
|
+
if (convo) {
|
|
598
|
+
await runtimeBest(this.cfg.serverUrl, "/typing", token, { conversationId: convo, done: true });
|
|
599
|
+
await runtimeBest(this.cfg.serverUrl, "/thinking/unmark", token, { conversationIds: [convo] });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (engineError) {
|
|
603
|
+
await this.publishEngineFailure({
|
|
604
|
+
token,
|
|
605
|
+
runId: run?.runId,
|
|
606
|
+
conversationId: convo,
|
|
607
|
+
error: engineError,
|
|
608
|
+
exitCode
|
|
609
|
+
});
|
|
435
610
|
}
|
|
436
611
|
if (run?.runId) {
|
|
437
612
|
await runtimeBest(this.cfg.serverUrl, `/runs/${run.runId}/finish`, token, {
|
|
438
613
|
status: exitCode === 0 ? "completed" : "failed",
|
|
439
|
-
summary: `byoa ${this.adapter.id} run (exit ${exitCode})
|
|
614
|
+
summary: engineError ?? `byoa ${this.adapter.id} run (exit ${exitCode})`,
|
|
615
|
+
error: engineError
|
|
440
616
|
});
|
|
441
617
|
}
|
|
442
618
|
await runtimeBest(this.cfg.serverUrl, "/status", token, { status: "avail" });
|
|
@@ -487,10 +663,12 @@ async function doRun(serverOverride) {
|
|
|
487
663
|
return;
|
|
488
664
|
}
|
|
489
665
|
if (serverOverride) cfg.serverUrl = serverOverride;
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
666
|
+
let available;
|
|
667
|
+
try {
|
|
668
|
+
available = await requireLocalEngine();
|
|
669
|
+
} catch (err) {
|
|
670
|
+
console.error(`[computer] ${err instanceof Error ? err.message : String(err)}`);
|
|
671
|
+
process.exitCode = 70;
|
|
494
672
|
return;
|
|
495
673
|
}
|
|
496
674
|
console.log(`[computer] starting ${cfg.computerId} @ ${cfg.serverUrl} (engines: ${available.join(", ")})`);
|
|
@@ -580,4 +758,9 @@ async function main() {
|
|
|
580
758
|
);
|
|
581
759
|
process.exit(argv.length ? 1 : 0);
|
|
582
760
|
}
|
|
583
|
-
void main()
|
|
761
|
+
void main().catch((err) => {
|
|
762
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
763
|
+
process.stderr.write(`cumora: ${message}
|
|
764
|
+
`);
|
|
765
|
+
process.exit(70);
|
|
766
|
+
});
|