cumora 0.1.46 → 0.1.47
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 +192 -22
- 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
|
}
|
|
@@ -192,6 +222,7 @@ var DEFAULT_SERVER = process.env.CUMORA_SERVER_URL || "https://api.cumora.ai";
|
|
|
192
222
|
var TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1e3;
|
|
193
223
|
var AGENT_POLL_MS = 6e4;
|
|
194
224
|
var HEARTBEAT_MS = 3e4;
|
|
225
|
+
var MAX_VISIBLE_ERROR_CHARS = 900;
|
|
195
226
|
function parseArgs(argv) {
|
|
196
227
|
const out = {};
|
|
197
228
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -225,6 +256,49 @@ async function runtimeBest(serverUrl, path, token, body) {
|
|
|
225
256
|
return null;
|
|
226
257
|
}
|
|
227
258
|
}
|
|
259
|
+
async function runtimeGet(serverUrl, path, token) {
|
|
260
|
+
try {
|
|
261
|
+
const res = await fetch(`${serverUrl}/runtime${path}`, {
|
|
262
|
+
method: "GET",
|
|
263
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
264
|
+
});
|
|
265
|
+
return res.ok ? await res.json().catch(() => null) : null;
|
|
266
|
+
} catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function hashText(text) {
|
|
271
|
+
return createHash("sha1").update(text).digest("hex").slice(0, 12);
|
|
272
|
+
}
|
|
273
|
+
function conciseError(text) {
|
|
274
|
+
return text.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "").replace(/\r/g, "").trim().slice(0, MAX_VISIBLE_ERROR_CHARS);
|
|
275
|
+
}
|
|
276
|
+
function authFailureHint(engine, detail) {
|
|
277
|
+
if (!/(auth|login|token|quota|credit|billing|subscription|rate limit|usage limit|api key)/i.test(detail)) {
|
|
278
|
+
return "Check the daemon terminal for details, then wake the agent again.";
|
|
279
|
+
}
|
|
280
|
+
if (engine === "claude") {
|
|
281
|
+
return "Open Claude Code on that computer and sign in, refresh quota, or add credits, then wake the agent again.";
|
|
282
|
+
}
|
|
283
|
+
return "Open Codex on that computer and refresh its login or quota, then wake the agent again.";
|
|
284
|
+
}
|
|
285
|
+
function missingEngineMessage() {
|
|
286
|
+
return [
|
|
287
|
+
"no supported local agent engine found on PATH",
|
|
288
|
+
"",
|
|
289
|
+
"Install and sign in to at least one of:",
|
|
290
|
+
" - Claude Code: install the `claude` CLI, then run `claude` once to sign in",
|
|
291
|
+
" - Codex: install the `codex` CLI, then run `codex` once to sign in",
|
|
292
|
+
"",
|
|
293
|
+
"After that, rerun:",
|
|
294
|
+
" npx cumora agent computer --pair <code>"
|
|
295
|
+
].join("\n");
|
|
296
|
+
}
|
|
297
|
+
async function requireLocalEngine() {
|
|
298
|
+
const engines = await detectEngines();
|
|
299
|
+
if (engines.length === 0) throw new Error(missingEngineMessage());
|
|
300
|
+
return engines;
|
|
301
|
+
}
|
|
228
302
|
async function loadConfig() {
|
|
229
303
|
try {
|
|
230
304
|
return JSON.parse(await readFile(CONFIG_PATH, "utf8"));
|
|
@@ -280,10 +354,7 @@ async function detectHostName() {
|
|
|
280
354
|
return base || "My computer";
|
|
281
355
|
}
|
|
282
356
|
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
|
-
}
|
|
357
|
+
const engines = await requireLocalEngine();
|
|
287
358
|
const paired = await api(
|
|
288
359
|
serverUrl,
|
|
289
360
|
"/api/computers/pair",
|
|
@@ -347,6 +418,44 @@ var AgentRunner = class {
|
|
|
347
418
|
CUMORA_AGENT_ID: this.agent.id
|
|
348
419
|
};
|
|
349
420
|
}
|
|
421
|
+
visibleEngineError(exitCode, detail) {
|
|
422
|
+
const raw = conciseError(detail || `process exited with code ${exitCode}`);
|
|
423
|
+
const redacted = raw.split(this.home).join("<agent home>").split(homedir()).join("~");
|
|
424
|
+
return `local ${this.adapter.id} failed (exit ${exitCode}): ${redacted}`;
|
|
425
|
+
}
|
|
426
|
+
async publishEngineFailure(args) {
|
|
427
|
+
if (args.runId) {
|
|
428
|
+
await runtimeBest(this.cfg.serverUrl, "/events", args.token, {
|
|
429
|
+
runId: args.runId,
|
|
430
|
+
kind: "engine.failed",
|
|
431
|
+
level: "error",
|
|
432
|
+
title: `${this.adapter.id} exited ${args.exitCode}`,
|
|
433
|
+
data: { error: args.error },
|
|
434
|
+
stage: "failed"
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
const hint = authFailureHint(this.adapter.id, args.error);
|
|
438
|
+
const conversationIds = await this.failureConversationIds(args.token, args.conversationId);
|
|
439
|
+
await Promise.all(conversationIds.map((conversationId) => runtimeBest(this.cfg.serverUrl, "/notices", args.token, {
|
|
440
|
+
conversationId,
|
|
441
|
+
agentId: this.agent.id,
|
|
442
|
+
noticeKind: "byoa_engine_failed",
|
|
443
|
+
text: `${this.agent.name} could not run on local ${this.adapter.id}: ${args.error}
|
|
444
|
+
${hint}`,
|
|
445
|
+
dedupeKey: `byoa_engine_failed:${this.agent.id}:${conversationId}:${hashText(args.error)}`,
|
|
446
|
+
dedupeTtlSec: 900
|
|
447
|
+
})));
|
|
448
|
+
}
|
|
449
|
+
async failureConversationIds(token, conversationId) {
|
|
450
|
+
if (conversationId) return [conversationId];
|
|
451
|
+
const inbox = await runtimeGet(this.cfg.serverUrl, "/inbox", token);
|
|
452
|
+
const ids = /* @__PURE__ */ new Set();
|
|
453
|
+
for (const row of inbox?.rows ?? []) {
|
|
454
|
+
if (typeof row.conversation_id === "string" && row.conversation_id) ids.add(row.conversation_id);
|
|
455
|
+
if (ids.size >= 5) break;
|
|
456
|
+
}
|
|
457
|
+
return [...ids];
|
|
458
|
+
}
|
|
350
459
|
/** Preload the memory index (memory/MEMORY.md) so it's ALWAYS in the wake
|
|
351
460
|
* prompt — the lightweight BYOA analog of the cloud agent's RAG memory
|
|
352
461
|
* preload. We inject only the small index; the agent opens detail files on
|
|
@@ -361,13 +470,29 @@ var AgentRunner = class {
|
|
|
361
470
|
return "";
|
|
362
471
|
}
|
|
363
472
|
}
|
|
364
|
-
|
|
473
|
+
async inboxTriage(token) {
|
|
474
|
+
return runtimeGet(this.cfg.serverUrl, "/inbox-triage", token);
|
|
475
|
+
}
|
|
476
|
+
formatTriageNote(triage) {
|
|
477
|
+
if (!triage) return "";
|
|
478
|
+
const note = typeof triage?.promptNote === "string" ? triage.promptNote.trim() : "";
|
|
479
|
+
if (!note) return "";
|
|
480
|
+
const state = triage.actionable === false ? "not relevant" : "relevant";
|
|
481
|
+
const source = typeof triage.source === "string" ? triage.source : "server";
|
|
482
|
+
const reason = typeof triage.reason === "string" && triage.reason.trim() ? `
|
|
483
|
+
Reason: ${triage.reason.trim().slice(0, 500)}` : "";
|
|
484
|
+
return `Small-brain inbox triage (${state}, ${source}): ${note}${reason}`;
|
|
485
|
+
}
|
|
486
|
+
prompt(memoryDigest, triageNote) {
|
|
365
487
|
return `You've been woken because there's new activity in your Cumora conversations.
|
|
366
488
|
|
|
367
|
-
|
|
489
|
+
` + (triageNote ? `${triageNote}
|
|
490
|
+
|
|
491
|
+
` : ``) + `Use the \`cumora\` tool to catch up and respond:
|
|
368
492
|
1. \`cumora inbox\` \u2014 see what's unread
|
|
369
493
|
2. \`cumora messages <conversationId> --tail 30\` \u2014 read the relevant thread(s)
|
|
370
|
-
3. \`cumora
|
|
494
|
+
3. \`cumora glance <conversationId>\` \u2014 in group threads, read the room right before replying
|
|
495
|
+
4. \`cumora reply <conversationId> '<text>'\` \u2014 reply in your own voice
|
|
371
496
|
|
|
372
497
|
` + (memoryDigest ? `Your memory index (\`memory/MEMORY.md\`) \u2014 consult before replying; \`cat memory/<file>\` for any detail it points to:
|
|
373
498
|
${memoryDigest}
|
|
@@ -376,6 +501,10 @@ ${memoryDigest}
|
|
|
376
501
|
|
|
377
502
|
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
503
|
|
|
504
|
+
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.
|
|
505
|
+
|
|
506
|
+
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.
|
|
507
|
+
|
|
379
508
|
Know when to stay quiet \u2014 this is how teams avoid endless back-and-forth:
|
|
380
509
|
- 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
510
|
- 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 +530,43 @@ If nothing genuinely needs you, it's fine to do nothing and stop. When finished,
|
|
|
401
530
|
const token = this.token;
|
|
402
531
|
const convo = this.lastWakeConvo;
|
|
403
532
|
this.lastWakeConvo = null;
|
|
533
|
+
const triage = await this.inboxTriage(token);
|
|
534
|
+
if (triage?.actionable === false) {
|
|
535
|
+
const reason = typeof triage.reason === "string" ? triage.reason : "not relevant";
|
|
536
|
+
console.log(`[computer] ${this.agent.id} skipped by small-brain inbox triage: ${reason}`);
|
|
537
|
+
await runtimeBest(this.cfg.serverUrl, "/status", token, { status: "avail" });
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
404
540
|
await runtimeBest(this.cfg.serverUrl, "/status", token, { status: "thinking" });
|
|
405
541
|
let typingTimer;
|
|
406
542
|
if (convo) {
|
|
407
543
|
const ping = () => {
|
|
408
544
|
void runtimeBest(this.cfg.serverUrl, "/typing", token, { conversationId: convo, done: false });
|
|
409
545
|
};
|
|
546
|
+
const thinkingPing = () => {
|
|
547
|
+
void runtimeBest(this.cfg.serverUrl, "/thinking/mark", token, { conversationIds: [convo], ttlSec: 60 });
|
|
548
|
+
};
|
|
410
549
|
ping();
|
|
411
|
-
|
|
550
|
+
thinkingPing();
|
|
551
|
+
typingTimer = setInterval(() => {
|
|
552
|
+
ping();
|
|
553
|
+
thinkingPing();
|
|
554
|
+
}, 6e3);
|
|
412
555
|
}
|
|
413
556
|
const run = await runtimeBest(this.cfg.serverUrl, "/runs", token, {
|
|
414
557
|
trigger: { source: "byoa", engine: this.adapter.id }
|
|
415
558
|
});
|
|
416
559
|
const controller = new AbortController();
|
|
417
560
|
let exitCode = 0;
|
|
561
|
+
let engineError = null;
|
|
418
562
|
try {
|
|
563
|
+
const [memoryDigest, triageNote] = await Promise.all([
|
|
564
|
+
this.memoryDigest(),
|
|
565
|
+
Promise.resolve(this.formatTriageNote(triage))
|
|
566
|
+
]);
|
|
419
567
|
const result = await this.adapter.run({
|
|
420
568
|
home: this.home,
|
|
421
|
-
prompt: this.prompt(
|
|
569
|
+
prompt: this.prompt(memoryDigest, triageNote),
|
|
422
570
|
env: this.engineEnv(),
|
|
423
571
|
model: this.agent.model,
|
|
424
572
|
fastModel: this.agent.fastModel,
|
|
@@ -426,17 +574,32 @@ If nothing genuinely needs you, it's fine to do nothing and stop. When finished,
|
|
|
426
574
|
signal: controller.signal
|
|
427
575
|
});
|
|
428
576
|
exitCode = result.exitCode;
|
|
577
|
+
if (result.error) engineError = this.visibleEngineError(exitCode, result.error);
|
|
429
578
|
} catch (err) {
|
|
430
579
|
console.error(`[computer] ${this.agent.id} engine spawn failed:`, err instanceof Error ? err.message : err);
|
|
431
580
|
exitCode = 1;
|
|
581
|
+
engineError = this.visibleEngineError(exitCode, err instanceof Error ? err.stack || err.message : String(err));
|
|
432
582
|
} finally {
|
|
433
583
|
if (typingTimer) clearInterval(typingTimer);
|
|
434
|
-
if (convo)
|
|
584
|
+
if (convo) {
|
|
585
|
+
await runtimeBest(this.cfg.serverUrl, "/typing", token, { conversationId: convo, done: true });
|
|
586
|
+
await runtimeBest(this.cfg.serverUrl, "/thinking/unmark", token, { conversationIds: [convo] });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (engineError) {
|
|
590
|
+
await this.publishEngineFailure({
|
|
591
|
+
token,
|
|
592
|
+
runId: run?.runId,
|
|
593
|
+
conversationId: convo,
|
|
594
|
+
error: engineError,
|
|
595
|
+
exitCode
|
|
596
|
+
});
|
|
435
597
|
}
|
|
436
598
|
if (run?.runId) {
|
|
437
599
|
await runtimeBest(this.cfg.serverUrl, `/runs/${run.runId}/finish`, token, {
|
|
438
600
|
status: exitCode === 0 ? "completed" : "failed",
|
|
439
|
-
summary: `byoa ${this.adapter.id} run (exit ${exitCode})
|
|
601
|
+
summary: engineError ?? `byoa ${this.adapter.id} run (exit ${exitCode})`,
|
|
602
|
+
error: engineError
|
|
440
603
|
});
|
|
441
604
|
}
|
|
442
605
|
await runtimeBest(this.cfg.serverUrl, "/status", token, { status: "avail" });
|
|
@@ -487,10 +650,12 @@ async function doRun(serverOverride) {
|
|
|
487
650
|
return;
|
|
488
651
|
}
|
|
489
652
|
if (serverOverride) cfg.serverUrl = serverOverride;
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
653
|
+
let available;
|
|
654
|
+
try {
|
|
655
|
+
available = await requireLocalEngine();
|
|
656
|
+
} catch (err) {
|
|
657
|
+
console.error(`[computer] ${err instanceof Error ? err.message : String(err)}`);
|
|
658
|
+
process.exitCode = 70;
|
|
494
659
|
return;
|
|
495
660
|
}
|
|
496
661
|
console.log(`[computer] starting ${cfg.computerId} @ ${cfg.serverUrl} (engines: ${available.join(", ")})`);
|
|
@@ -580,4 +745,9 @@ async function main() {
|
|
|
580
745
|
);
|
|
581
746
|
process.exit(argv.length ? 1 : 0);
|
|
582
747
|
}
|
|
583
|
-
void main()
|
|
748
|
+
void main().catch((err) => {
|
|
749
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
750
|
+
process.stderr.write(`cumora: ${message}
|
|
751
|
+
`);
|
|
752
|
+
process.exit(70);
|
|
753
|
+
});
|