@c0mpute/code 0.6.0 → 0.6.1

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/cli.mjs +62 -24
  2. package/package.json +1 -1
package/cli.mjs CHANGED
@@ -7,10 +7,10 @@
7
7
  // C0MPUTE_API_KEY=sk-... c0mpute-code # interactive
8
8
  // C0MPUTE_API_KEY=sk-... c0mpute-code "task" # one task, then exit
9
9
  import { spawnSync } from 'child_process';
10
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs';
10
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
11
11
  import { createInterface } from 'readline';
12
12
  import { stdin, stdout } from 'process';
13
- import { homedir } from 'os';
13
+ import { homedir, tmpdir } from 'os';
14
14
  import { resolve, isAbsolute, join, dirname } from 'path';
15
15
  import { fileURLToPath } from 'url';
16
16
 
@@ -35,7 +35,16 @@ const WS_JOURNAL = join(WS_DIR, 'journal.md');
35
35
 
36
36
  // ── ansi ──
37
37
  const e = (n) => (s) => `\x1b[${n}m${s}\x1b[0m`;
38
- const c = { dim: e(2), bold: e(1), red: e(31), grn: e(32), yel: e(33), blu: e(34), cyn: e(36), mag: e(35), gry: e(90), b: (s) => `\x1b[1m${s}\x1b[0m` };
38
+ const c = { dim: e(2), bold: e(1), red: e(31), grn: e(32), yel: e(33), blu: e(34), cyn: e(36), mag: e(35), gry: e(90), it: e(3), b: (s) => `\x1b[1m${s}\x1b[0m` };
39
+ // Render a single line of the model's markdown prose to ANSI: headers, bullets,
40
+ // bold, italic, inline code. Applied per completed line (we buffer prose by line).
41
+ const mdLine = (s) => s
42
+ .replace(/^(\s*)#{1,6}\s+(.*)$/, (_, sp, t) => sp + c.b(t)) // # headers → bold
43
+ .replace(/^(\s*)([-*+])\s+/, (_, sp) => sp + c.grn('•') + ' ') // - bullets → •
44
+ .replace(/^(\s*)(\d+)\.\s+/, (_, sp, n) => sp + c.gry(n + '.') + ' ') // 1. numbered
45
+ .replace(/\*\*([^*]+)\*\*/g, (_, t) => c.b(t)) // **bold**
46
+ .replace(/`([^`]+)`/g, (_, t) => c.cyn(t)) // `code`
47
+ .replace(/(^|[\s(])[*_]([^*_\s][^*_]*?)[*_]([\s).,!?]|$)/g, (_, a, t, z) => a + c.it(t) + z); // *italic*
39
48
  // c0mpute brand: pure black, green accent (#5af78e), pixel square marker (not Claude's round/orange dot).
40
49
  const MARK = c.grn('▪');
41
50
  const vlen = (s) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
@@ -64,9 +73,17 @@ const redact = (t) => { let s = String(t ?? ''); for (const rx of SECRET_RX) s =
64
73
  // ── git / shell ──
65
74
  const isGit = existsSync(`${CWD}/.git`);
66
75
  const sh = (cmd) => {
67
- const r = spawnSync('/bin/bash', ['-c', cmd], { cwd: CWD, timeout: 120000, maxBuffer: 1 << 24 });
68
- if (r.error) return `error: ${r.error.code === 'ETIMEDOUT' ? 'timed out after 120s' : r.error.message}`;
69
- const out = (r.stdout?.toString() || '') + (r.stderr?.toString() || ''); // tools like pytest write to stderr
76
+ // Redirect the whole command's output to a temp file rather than capturing via a
77
+ // pipe. A backgrounded child (a server: `npm start & …`) inherits the pipe and
78
+ // holds it open, so a pipe-capturing spawnSync hangs until the 120s timeout even
79
+ // though the foreground finished. With a file, the bash process exits promptly,
80
+ // the server keeps running (orphaned, still serving), and we read what it printed.
81
+ const log = join(tmpdir(), `cc-${process.pid}-${Date.now()}.log`);
82
+ const r = spawnSync('/bin/bash', ['-c', `( ${cmd} ) >${shq(log)} 2>&1`], { cwd: CWD, timeout: 120000 });
83
+ let out = ''; try { out = readFileSync(log, 'utf8'); } catch {}
84
+ try { unlinkSync(log); } catch {}
85
+ if (out.length > (1 << 24)) out = out.slice(0, 1 << 24);
86
+ if (r.error) return `error: ${r.error.code === 'ETIMEDOUT' ? 'timed out after 120s' : r.error.message}` + (out ? `\n${out}` : '');
70
87
  return (r.status ? `exit ${r.status}\n` : '') + out;
71
88
  };
72
89
 
@@ -121,21 +138,30 @@ function recordWork(task, summary) {
121
138
  // ── streaming over the network ──
122
139
  const PULSE = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█', '▇', '▆', '▅', '▄', '▃', '▂']; // compute pulse
123
140
  async function think(messages) {
124
- let i = 0, tick = null, first = false;
125
- if (stdout.isTTY && !process.env.C0MPUTE_NO_SPINNER) tick = setInterval(() => { if (!first) process.stdout.write('\r' + c.grn(PULSE[i++ % PULSE.length]) + ' '); }, 80);
126
- const stop = () => { if (tick) { clearInterval(tick); tick = null; if (stdout.isTTY) process.stdout.write('\r\x1b[K'); } };
141
+ let i = 0, tick = null;
142
+ // The indicator stays alive whenever we're waiting (before the first prose line,
143
+ // between lines, and through the whole action-block generation) so there's never
144
+ // dead air — only paused while a prose line is actually being written.
145
+ const spin = () => { if (stdout.isTTY && !process.env.C0MPUTE_NO_SPINNER && !tick) tick = setInterval(() => process.stdout.write('\r' + c.grn(PULSE[i++ % PULSE.length]) + ' '), 80); };
146
+ const unspin = () => { if (tick) { clearInterval(tick); tick = null; if (stdout.isTTY) process.stdout.write('\r\x1b[K'); } };
147
+ spin();
127
148
  currentAbort = new AbortController();
128
149
  try {
129
150
  const r = await fetch(API, { method: 'POST', signal: currentAbort.signal, headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ model: MODEL, messages, temperature: 0.2, max_tokens: 1024, stream: true }) });
130
151
  if (!r.ok) throw new Error(`c0mpute API ${r.status}: ${(await r.text()).slice(0, 200)}`);
131
152
  const reader = r.body.getReader(), dec = new TextDecoder();
132
153
  let buf = '', full = '', inCode = false, shown = false, prose = '', pp = 0;
133
- // stream prose, stripping the model's "THOUGHT:" label withhold a 9-char tail
134
- // (len of "THOUGHT: ") so a half-arrived keyword never leaks to the screen.
154
+ // Emit prose a COMPLETE LINE at a time, rendered as markdown (bold/code/headers/
155
+ // bullets). Strip the model's "THOUGHT:" label; withhold a 9-char tail on the
156
+ // non-final pass so a half-arrived keyword never leaks to the screen.
135
157
  const flush = (final) => {
136
158
  const clean = prose.replace(/\bTHOUGHT:?\s*/gi, '');
137
- const upto = final ? clean.length : Math.max(pp, clean.length - 9);
138
- if (upto > pp) { process.stdout.write(clean.slice(pp, upto)); pp = upto; shown = true; }
159
+ const safeEnd = final ? clean.length : Math.max(pp, clean.length - 9);
160
+ let nl;
161
+ while ((nl = clean.indexOf('\n', pp)) !== -1 && nl < safeEnd) {
162
+ unspin(); process.stdout.write(mdLine(clean.slice(pp, nl)) + '\n'); pp = nl + 1; shown = true;
163
+ }
164
+ if (final && pp < clean.length) { unspin(); process.stdout.write(mdLine(clean.slice(pp))); pp = clean.length; shown = true; }
139
165
  };
140
166
  while (true) {
141
167
  const { done, value } = await reader.read(); if (done) break;
@@ -144,18 +170,20 @@ async function think(messages) {
144
170
  if (!ln.startsWith('data:')) continue; const p = ln.slice(5).trim(); if (p === '[DONE]') continue;
145
171
  let tok = ''; try { tok = JSON.parse(p).choices?.[0]?.delta?.content || ''; } catch { continue; }
146
172
  if (!tok) continue;
147
- if (!first) { first = true; stop(); }
148
173
  full += tok;
149
174
  if (!inCode) {
150
- if (full.includes('```')) { inCode = true; flush(true); }
151
- else { prose += tok; flush(false); }
175
+ if (full.includes('```')) {
176
+ inCode = true; flush(true); // emit any remaining prose
177
+ if (shown) process.stdout.write('\n'); // separate prose from the action below
178
+ spin(); // keep the indicator alive while the action generates
179
+ } else { prose += tok; flush(false); spin(); } // re-arm the indicator between prose lines
152
180
  }
153
181
  }
154
182
  }
155
- flush(true);
156
- if (shown) process.stdout.write('\n');
183
+ flush(true); unspin();
184
+ if (shown && !inCode) process.stdout.write('\n');
157
185
  return full;
158
- } finally { stop(); }
186
+ } finally { unspin(); }
159
187
  }
160
188
  // ── action protocol: model emits ONE fenced block per turn; first line is the command ──
161
189
  const VERBS = new Set(['list', 'search', 'read', 'edit', 'write', 'run', 'done']);
@@ -321,7 +349,7 @@ const LABELS = { read: 'Read', list: 'List', search: 'Search', edit: 'Update', w
321
349
  async function runTask(task, history) {
322
350
  console.log('');
323
351
  history.push({ role: 'user', content: task });
324
- let ran = false, nudges = 0, lastRunFailed = false, doneNudges = 0, doneSummary = '';
352
+ let ran = false, nudges = 0, lastRunFailed = false, doneNudges = 0, doneSummary = '', doneBody = '';
325
353
  busy = true; interrupted = false;
326
354
  // is this a coding task (enforce actions) or chat/greeting (a prose reply is fine)?
327
355
  const isCoding = /\b(fix|bug|error|fail|implement|add|refactor|test|debug|rename|update|create|build|install|broken|crash|exception|traceback|function|class|import|run)\b/i.test(task) || /[\w./-]+\.\w{1,5}\b/.test(task);
@@ -344,7 +372,7 @@ async function runTask(task, history) {
344
372
  if (verb === 'done') {
345
373
  // don't accept "done" while the last command was still failing — that's a false finish
346
374
  if (lastRunFailed && doneNudges < 2) { doneNudges++; history.push({ role: 'user', content: 'The last command reported failures/errors, so the task is NOT verified. Keep fixing and re-run the test until it passes. If you are genuinely stuck, say plainly what is still broken instead of using `done`.' }); continue; }
347
- ran = true; doneSummary = act.body.split('\n').map(s => s.trim()).filter(Boolean)[0] || ''; break;
375
+ ran = true; doneBody = act.body.trim(); doneSummary = doneBody.split('\n').map(s => s.trim()).filter(Boolean)[0] || ''; break;
348
376
  }
349
377
  const path0 = arg.split(/\s+/)[0] || '';
350
378
  const shown = verb === 'search' ? arg : (verb === 'run' ? (arg || act.body.split('\n')[0]) : path0);
@@ -376,7 +404,13 @@ async function runTask(task, history) {
376
404
  }
377
405
  } finally { busy = false; }
378
406
  if (interrupted) { interrupted = false; console.log(c.dim(' ⊘ stopped.') + '\n'); }
379
- else if (ran) { if (isCoding) recordWork(task, doneSummary); console.log(MARK + ' ' + c.dim('done') + '\n'); }
407
+ else if (ran) {
408
+ if (isCoding) recordWork(task, doneSummary);
409
+ // Closing summary: print the model's done message (what it built + how to run it),
410
+ // markdown-rendered, instead of a bare "done".
411
+ if (doneBody) console.log(MARK + ' ' + c.b('done') + '\n' + doneBody.split('\n').map(l => ' ' + mdLine(l)).join('\n') + '\n');
412
+ else console.log(MARK + ' ' + c.dim('done') + '\n');
413
+ }
380
414
  else console.log('');
381
415
  }
382
416
 
@@ -528,11 +562,15 @@ Create a new file or fully overwrite one. Prefer edit for existing files.
528
562
  \`\`\`
529
563
  run python3 -m pytest -q
530
564
  \`\`\`
531
- Run a shell command (tests, build, repro).
565
+ Run a shell command (tests, build, repro). To start a long-running server, background
566
+ it and probe it, e.g. \`npm start & sleep 3 && curl -s localhost:3000\` — never run a
567
+ server in the foreground; it would block.
532
568
 
533
569
  \`\`\`
534
570
  done
535
- one line on what you changed
571
+ A short summary for the user: what you built or changed. If you started an app or a
572
+ server, say exactly how to run and view it (e.g. "Run: npm start, then open
573
+ http://localhost:3000"). A few lines is fine.
536
574
  \`\`\`
537
575
  Finish — ONLY after you verified the fix (ran the test/repro and it passed).
538
576
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c0mpute/code",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Decentralized coding agent — thinking runs on the c0mpute network, file edits and commands run locally under your approval. Claude Code-style UX, no single provider.",
5
5
  "license": "MIT",
6
6
  "type": "module",