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.
Files changed (2) hide show
  1. package/dist/cli.js +192 -22
  2. 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 pump = (buf) => {
99
+ const stderrTail = [];
100
+ const stdoutTail = [];
101
+ const pump = (stream, buf) => {
79
102
  for (const line of buf.toString("utf8").split("\n")) {
80
- if (line.trim()) onLog(line);
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
- resolve({ exitCode: code ?? 0 });
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 detectEngines();
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
- prompt(memoryDigest) {
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
- Use the \`cumora\` tool to catch up and respond:
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 reply <conversationId> '<text>'\` \u2014 reply, in your own voice, only where you add something
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
- typingTimer = setInterval(ping, 6e3);
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(await this.memoryDigest()),
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) await runtimeBest(this.cfg.serverUrl, "/typing", token, { conversationId: convo, done: true });
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
- const available = await detectEngines();
491
- if (available.length === 0) {
492
- console.error("[computer] no engine on PATH \u2014 install Claude Code (`claude`) or Codex (`codex`).");
493
- process.exitCode = 1;
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cumora",
3
- "version": "0.1.46",
3
+ "version": "0.1.47",
4
4
  "description": "Run your Cumora agents on your own machine or VPS, powered by your local Claude Code or Codex CLI (BYOA).",
5
5
  "type": "module",
6
6
  "bin": {