novaprime 1.5.1 → 1.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.
package/bin/novaprime.js CHANGED
@@ -5,6 +5,7 @@ const ui = require('../src/ui');
5
5
  const { c } = ui;
6
6
  const { ask, close, boxInput } = require('../src/prompt');
7
7
  const { runTurn, fetchMe } = require('../src/agent');
8
+ const tools = require('../src/tools');
8
9
  const pkg = require('../package.json');
9
10
 
10
11
  // Validate the key against the server before saving — a wrong key never logs in.
@@ -82,7 +83,8 @@ async function repl() {
82
83
  let me = await fetchMe(config.getServer(), cfg.key);
83
84
  console.clear(); // hide login/clutter — start clean with the header at the top
84
85
  ui.banner(meToBanner(cfg, me));
85
- process.on('SIGINT', () => { console.log(c.muted('\n bye')); process.exit(0); });
86
+ process.on('exit', () => { try { tools.stopBackground(); } catch (_) {} }); // stop bg dev servers on quit
87
+ process.on('SIGINT', () => { try { tools.stopBackground(); } catch (_) {} console.log(c.muted('\n bye')); process.exit(0); });
86
88
 
87
89
  ui.hint(' Tip: press ' + c.indigo('Shift+Tab') + c.dim(' for auto-accept (no permission prompts), or answer ') + c.indigo('a') + c.dim(' at any prompt.'));
88
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novaprime",
3
- "version": "1.5.1",
3
+ "version": "1.6.1",
4
4
  "description": "NovaPrime — an AI coding assistant in your terminal, powered by GLM.",
5
5
  "bin": {
6
6
  "novaprime": "bin/novaprime.js"
package/src/agent.js CHANGED
@@ -17,9 +17,9 @@ const SYSTEM_PROMPT =
17
17
  `Use clear markdown: short paragraphs, bullet lists, and fenced code blocks with a language tag. ` +
18
18
  `When asked to create or change files, call the tool RIGHT AWAY with at most a one-line intro — ` +
19
19
  `do NOT write long explanations or feature lists before creating the file. Keep any summary to 1-2 short lines after. ` +
20
- `NEVER start dev servers or long-running/blocking processes (e.g. "npm run dev", "npm start", "vite", "next dev", ` +
21
- `"nodemon", watchers) they block the session forever. After creating the files, finish, then tell the user the ` +
22
- `commands to run it themselves (e.g. cd into the folder and run "npm run dev", then open the localhost URL). ` +
20
+ `You MAY start the dev server when the project is ready — run it with run_command (e.g. "cd <project> && npm run dev"). ` +
21
+ `Dev/watch servers are automatically run in the BACKGROUND and the tool returns the localhost URL, so they do NOT block. ` +
22
+ `After it starts, give the user the localhost URL to open in their browser, and do NOT start it a second time. ` +
23
23
  `Be concise and warm. Current OS: ${os.platform()}. Working directory: ${process.cwd()}.`;
24
24
 
25
25
  // Fetch read-only account info for the header (name, plan, usage). Never throws.
package/src/prompt.js CHANGED
@@ -3,8 +3,8 @@ const readline = require('readline');
3
3
  const { c } = require('./ui');
4
4
  const mode = require('./mode');
5
5
 
6
- // Use Node's readline for single-line prompts (login key, y/N) it handles
7
- // editing and history. The multi-line chat box uses a paste-aware reader below.
6
+ // Use Node's readline for single-line prompts (login key, y/N). The multi-line
7
+ // chat box below is a custom raw-mode reader (paste-aware + Shift+Tab + full box).
8
8
  let rl = null;
9
9
  function getRl() {
10
10
  if (!rl) {
@@ -28,16 +28,13 @@ function confirm(message) {
28
28
  function close() { if (rl) { rl.close(); rl = null; } }
29
29
 
30
30
  // ---- bracketed-paste-aware chat input ----------------------------------
31
- // Terminals that support bracketed paste wrap pasted text in these markers.
32
- // We capture everything between them (newlines included) WITHOUT submitting,
33
- // so pasting a big multi-line prompt no longer auto-sends on the first line.
34
31
  const PASTE_START = '\x1b[200~';
35
32
  const PASTE_END = '\x1b[201~';
36
33
 
37
34
  function normalize(t) { return t.replace(/\r\n?/g, '\n'); }
35
+ function visLen(s) { return String(s).replace(/\x1b\[[0-9;]*m/g, '').length; } // visible width, minus ANSI
38
36
 
39
- // length of the longest suffix of s that is a prefix of marker (handles a
40
- // marker that got split across two stdin chunks)
37
+ // longest suffix of s that is a prefix of marker (marker split across chunks)
41
38
  function partialTail(s, marker) {
42
39
  const max = Math.min(s.length, marker.length - 1);
43
40
  for (let n = max; n > 0; n--) {
@@ -46,37 +43,30 @@ function partialTail(s, marker) {
46
43
  return 0;
47
44
  }
48
45
 
49
- // Handle a run of plain (non-paste) bytes: typing, Enter, backspace, Ctrl+C/D,
50
- // and swallow escape sequences (arrow keys, etc.) so they don't corrupt input.
46
+ // Plain (non-paste) bytes: typing, Enter, backspace, Ctrl+C/D, Shift+Tab, skip arrows.
47
+ // Only mutates state; the caller redraws the whole box afterward.
51
48
  function handlePlain(state, s, sink) {
52
49
  for (let i = 0; i < s.length && !state.finished; i++) {
53
50
  const ch = s[i];
54
- if (ch === '\x03') { sink.cancel(); return; } // Ctrl+C
51
+ if (ch === '\x03') { sink.cancel(); return; } // Ctrl+C
55
52
  if (ch === '\x04') { if (!state.buf) { sink.cancel(); return; } continue; } // Ctrl+D on empty
56
- if (ch === '\r' || ch === '\n') { sink.submit(state.buf); return; } // Enter -> send
57
- if (ch === '\x7f' || ch === '\b') { // backspace
58
- if (state.buf.length) {
59
- const last = state.buf[state.buf.length - 1];
60
- state.buf = state.buf.slice(0, -1);
61
- if (last !== '\n') sink.out('\b \b');
62
- }
63
- continue;
64
- }
53
+ if (ch === '\r' || ch === '\n') { sink.submit(state.buf); return; } // Enter -> send
54
+ if (ch === '\x7f' || ch === '\b') { state.buf = state.buf.slice(0, -1); continue; } // backspace
65
55
  if (ch === '\x1b') {
66
56
  if (s.slice(i, i + 3) === '\x1b[Z') { if (sink.toggleMode) sink.toggleMode(); i += 2; continue; } // Shift+Tab
67
- if (s[i + 1] === '[' || s[i + 1] === 'O') { // skip other CSI/SS3 escapes (arrows, fn keys)
57
+ if (s[i + 1] === '[' || s[i + 1] === 'O') { // skip arrows / fn keys
68
58
  i += 2;
69
59
  while (i < s.length && !/[A-Za-z~]/.test(s[i])) i++;
70
60
  }
71
61
  continue;
72
62
  }
73
- if (ch === '\t') { state.buf += ' '; sink.out(' '); continue; } // tab -> 2 spaces
74
- if (ch >= ' ') { state.buf += ch; sink.out(ch); } // printable
63
+ if (ch === '\t') { state.buf += ' '; continue; } // tab -> 2 spaces
64
+ if (ch >= ' ') { state.buf += ch; } // printable
75
65
  }
76
66
  }
77
67
 
78
68
  // Pure state machine — testable. state = { buf, pasting, pending, finished }.
79
- // sink = { out(str), submit(value), cancel() }.
69
+ // sink = { submit(value), cancel(), toggleMode() }.
80
70
  function processChunk(state, chunk, sink) {
81
71
  let s = state.pending + chunk;
82
72
  state.pending = '';
@@ -85,13 +75,11 @@ function processChunk(state, chunk, sink) {
85
75
  const end = s.indexOf(PASTE_END);
86
76
  if (end === -1) {
87
77
  const hold = partialTail(s, PASTE_END);
88
- const seg = normalize(s.slice(0, s.length - hold));
89
- if (seg) { state.buf += seg; sink.out(seg); }
78
+ state.buf += normalize(s.slice(0, s.length - hold));
90
79
  state.pending = s.slice(s.length - hold);
91
80
  return;
92
81
  }
93
- const seg = normalize(s.slice(0, end));
94
- if (seg) { state.buf += seg; sink.out(seg); }
82
+ state.buf += normalize(s.slice(0, end));
95
83
  state.pasting = false;
96
84
  s = s.slice(end + PASTE_END.length);
97
85
  continue;
@@ -99,8 +87,7 @@ function processChunk(state, chunk, sink) {
99
87
  const start = s.indexOf(PASTE_START);
100
88
  if (start === -1) {
101
89
  const hold = partialTail(s, PASTE_START);
102
- const plain = s.slice(0, s.length - hold);
103
- handlePlain(state, plain, sink);
90
+ handlePlain(state, s.slice(0, s.length - hold), sink);
104
91
  if (!state.finished) state.pending = s.slice(s.length - hold);
105
92
  return;
106
93
  }
@@ -111,52 +98,70 @@ function processChunk(state, chunk, sink) {
111
98
  }
112
99
  }
113
100
 
114
- function ttyRaw() { return process.stdin.isTTY && typeof process.stdin.setRawMode === 'function'; }
101
+ function ttyRaw() { return process.stdout.isTTY && process.stdin.isTTY && typeof process.stdin.setRawMode === 'function'; }
115
102
 
116
- // Chat input box. Top border above; readline-free raw reader captures multi-line
117
- // pastes; bottom border drawn after submit so the message shows as a complete box.
103
+ // Chat input box: a COMPLETE bordered box (top + input lines + bottom) that
104
+ // redraws on every change so the bottom stroke is always visible, multi-line
105
+ // pastes grow the box, and auto-accept status shows inside.
118
106
  function boxInput(top, bottom, prompt) {
119
- // Fallback for piped / non-TTY input (e.g. `echo ... | novaprime`): plain readline.
120
- if (!ttyRaw()) {
107
+ if (!ttyRaw()) { // piped / non-TTY: fall back to readline
121
108
  return new Promise((resolve) => {
122
109
  process.stdout.write(top + '\n');
123
110
  const r = getRl();
124
111
  r.question(prompt, (answer) => { process.stdout.write(bottom + '\n'); resolve(answer); });
125
112
  });
126
113
  }
127
- if (rl) { rl.close(); rl = null; } // release stdin from readline
114
+ if (rl) { rl.close(); rl = null; }
128
115
 
129
116
  return new Promise((resolve) => {
130
117
  const stdin = process.stdin;
131
118
  const state = { buf: '', pasting: false, pending: '', finished: false };
119
+ const contPrefix = c.dim('│ ');
120
+ let firstRender = true;
121
+ let prevUp = 0; // lines from the input line up to the first dynamic line (for clearing)
122
+
123
+ function render() {
124
+ const statusLines = mode.isAuto()
125
+ ? [c.green(' ⚡ auto-accept ON') + c.dim(' · Shift+Tab to turn off')]
126
+ : [];
127
+ const inputLines = state.buf.split('\n');
128
+ const dyn = statusLines
129
+ .concat(inputLines.map((ln, i) => (i === 0 ? prompt : contPrefix) + ln))
130
+ .concat([bottom]);
132
131
 
133
- const onData = (chunk) => processChunk(state, chunk, sink);
134
- const cleanup = () => {
132
+ let out = '';
133
+ if (!firstRender) { if (prevUp > 0) out += '\x1b[' + prevUp + 'A'; out += '\r\x1b[0J'; }
134
+ out += dyn.join('\n');
135
+ out += '\x1b[1A\r'; // from end of bottom up to the last input line, col 0
136
+ const prefVis = inputLines.length === 1 ? visLen(prompt) : visLen(contPrefix);
137
+ const col = prefVis + inputLines[inputLines.length - 1].length;
138
+ if (col > 0) out += '\x1b[' + col + 'C';
139
+ process.stdout.write(out);
140
+ prevUp = statusLines.length + (inputLines.length - 1);
141
+ firstRender = false;
142
+ }
143
+
144
+ function cleanup() {
135
145
  try { process.stdout.write('\x1b[?2004l'); } catch (_) {}
136
146
  try { stdin.setRawMode(false); } catch (_) {}
137
147
  stdin.pause();
138
148
  stdin.removeListener('data', onData);
139
- };
140
- const sink = {
141
- out: (str) => process.stdout.write(str),
142
- submit: (val) => { state.finished = true; cleanup(); process.stdout.write('\n' + bottom + '\n'); resolve(val); },
143
- cancel: () => { state.finished = true; cleanup(); process.stdout.write('\n' + bottom + '\n'); resolve(null); },
144
- toggleMode: () => {
145
- const on = mode.toggle();
146
- process.stdout.write('\r\x1b[2K'); // clear current input line
147
- process.stdout.write((on
148
- ? c.green(' ⚡ auto-accept: ON') + c.dim(' — running everything without asking (Shift+Tab to undo)')
149
- : c.dim(' ⚡ auto-accept: OFF — will ask before changes')) + '\n');
150
- process.stdout.write(prompt + state.buf); // redraw input line with whatever was typed
151
- },
152
- };
149
+ }
150
+ function finish(val) {
151
+ state.finished = true;
152
+ process.stdout.write('\x1b[1B\r\n'); // move below the bottom border
153
+ cleanup();
154
+ resolve(val);
155
+ }
156
+ const sink = { submit: (v) => finish(v), cancel: () => finish(null), toggleMode: () => mode.toggle() };
157
+ const onData = (chunk) => { processChunk(state, chunk, sink); if (!state.finished) render(); };
153
158
 
154
159
  process.stdout.write(top + '\n');
155
- process.stdout.write(prompt);
160
+ render();
156
161
  stdin.setRawMode(true);
157
162
  stdin.resume();
158
163
  stdin.setEncoding('utf8');
159
- process.stdout.write('\x1b[?2004h'); // enable bracketed paste
164
+ process.stdout.write('\x1b[?2004h'); // bracketed paste on
160
165
  stdin.on('data', onData);
161
166
  });
162
167
  }
package/src/tools.js CHANGED
@@ -1,13 +1,67 @@
1
1
  'use strict';
2
2
  const fs = require('fs');
3
+ const os = require('os');
3
4
  const path = require('path');
4
- const { spawnSync } = require('child_process');
5
+ const { spawnSync, spawn } = require('child_process');
5
6
  const { ask } = require('./prompt');
6
7
  const { c, tool } = require('./ui');
7
8
  const mode = require('./mode');
8
9
 
9
10
  const MAX_OUTPUT = 20000; // cap tool output sent back to the model
10
11
 
12
+ // dev/watch servers: long-running, so we run them DETACHED in the background and
13
+ // hand back the localhost URL instead of blocking the session.
14
+ const DEV_SERVER_RE = /\b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|serve|watch)\b|\bvite\b(?!\s+build)|\bnext\s+dev\b|\bnodemon\b|webpack-dev-server|webpack\s+serve|\bnpx\s+serve\b|\bhttp-server\b|\blive-server\b|php\s+-S|python\s+-m\s+http\.server|flask\s+run|rails\s+s(erver)?\b|\bng\s+serve\b/i;
15
+ const URL_RE = /https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?\/?\S*/i;
16
+ const bgServers = []; // { pid, cmd, child } — kept so they can be stopped on exit
17
+
18
+ // Start a long-running command (dev server) in the background. We DON'T await it —
19
+ // we stream its output to a log, grab the localhost URL, and return right away so the
20
+ // session never blocks. It keeps running as a child of the CLI for the whole session.
21
+ async function runBackground(cmd) {
22
+ const logPath = path.join(os.tmpdir(), 'novaprime-dev-' + Date.now() + '.log');
23
+ let log = '';
24
+ let child;
25
+ try {
26
+ child = spawn(cmd, { shell: true, windowsHide: true });
27
+ } catch (err) {
28
+ return 'ERROR starting dev server: ' + err.message;
29
+ }
30
+ bgServers.push({ pid: child.pid, cmd, child });
31
+ const onData = (d) => { const s = d.toString(); log += s; if (log.length > MAX_OUTPUT) log = log.slice(-MAX_OUTPUT); try { fs.appendFileSync(logPath, s); } catch (_) {} };
32
+ if (child.stdout) child.stdout.on('data', onData);
33
+ if (child.stderr) child.stderr.on('data', onData);
34
+ let earlyExit = null;
35
+ child.on('error', (e) => { log += '\n[spawn error] ' + e.message; });
36
+ child.on('exit', (code) => { earlyExit = code; });
37
+
38
+ const url = await new Promise((resolve) => {
39
+ const start = Date.now();
40
+ const t = setInterval(() => {
41
+ const m = log.match(URL_RE);
42
+ if (m) { clearInterval(t); resolve(m[0]); }
43
+ else if (earlyExit !== null) { clearInterval(t); resolve(null); } // it crashed on startup
44
+ else if (Date.now() - start >= 10000) { clearInterval(t); resolve(null); }
45
+ }, 250);
46
+ });
47
+
48
+ if (earlyExit !== null && !url) {
49
+ console.log(c.amber(' ! dev server exited (code ' + earlyExit + ')'));
50
+ return 'The dev command "' + cmd + '" exited immediately (code ' + earlyExit + ') instead of staying up. It likely failed. Read the log and fix the problem, then start it again.\n\nOutput:\n' + clip(log);
51
+ }
52
+ console.log(c.green(' ✓ dev server running in background') + c.dim(' (pid ' + child.pid + ')'));
53
+ if (url) console.log(c.green(' → ') + c.white(url) + c.dim(' — open this in your browser'));
54
+ else console.log(c.dim(' · still starting… log: ' + logPath));
55
+ return 'OK: started "' + cmd + '" in the BACKGROUND (pid ' + child.pid + '), so the session is NOT blocked and the server keeps running. ' +
56
+ (url ? 'It is serving at ' + url + ' — tell the user to open that URL in their browser. ' : 'It is still booting; tell the user to watch for the localhost URL. ') +
57
+ 'Do NOT start it again.\n\nStartup log:\n' + clip(log);
58
+ }
59
+
60
+ // Stop any background dev servers (called when the CLI exits).
61
+ function stopBackground() {
62
+ for (const s of bgServers) { try { s.child.kill(); } catch (_) {} }
63
+ }
64
+
11
65
  // Permission gate. Returns true if allowed. In auto-accept mode it never asks.
12
66
  // Answering "a" (always) flips auto-accept ON for the rest of the session.
13
67
  async function allow(label) {
@@ -125,18 +179,13 @@ async function execute(name, input) {
125
179
  }
126
180
  case 'run_command': {
127
181
  const cmd = String(input.command || '');
128
- // Refuse blocking dev servers / watchers — they never exit and freeze the session.
129
- if (/\b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|serve|watch)\b|\bvite\b(?!\s+build)|\bnext\s+dev\b|\bnodemon\b|webpack-dev-server|webpack\s+serve|\bnpx\s+serve\b|\bhttp-server\b|\blive-server\b|php\s+-S|python\s+-m\s+http\.server|flask\s+run|rails\s+s(erver)?\b|\bng\s+serve\b/i.test(cmd)) {
130
- console.log('');
131
- console.log(c.amber(' ! ') + c.amber('skipped dev server: ') + c.white(cmd));
132
- console.log(c.dim(' (long-running — start it yourself in another terminal when ready)'));
133
- return 'SKIPPED: "' + cmd + '" is a long-running dev/watch server and was NOT run (it would block the session forever). Do NOT start dev servers. Finish the work, then tell the user how to run it themselves in a separate terminal, e.g. `cd <project>` then `npm run dev`, and to open the localhost URL it prints.';
134
- }
182
+ const isDevServer = DEV_SERVER_RE.test(cmd);
135
183
  console.log('');
136
184
  console.log(c.red(' ╭─ permission · run command ') + c.dim('───────────────────'));
137
- console.log(c.red(' │ ') + c.bold(input.command));
185
+ console.log(c.red(' │ ') + c.bold(cmd) + (isDevServer ? c.dim(' (runs in background)') : ''));
138
186
  if (!(await allow(c.red(' ╰─ run this command?')))) { console.log(c.dim(' · skipped')); return 'DENIED: user did not allow running the command.'; }
139
- const r = spawnSync(input.command, { shell: true, encoding: 'utf8', timeout: 1000 * 120 });
187
+ if (isDevServer) return await runBackground(cmd); // long-running: detach so it doesn't block
188
+ const r = spawnSync(cmd, { shell: true, encoding: 'utf8', timeout: 1000 * 120 });
140
189
  const out = (r.stdout || '') + (r.stderr || '');
141
190
  return clip(`exit_code=${r.status}\n${out}`.trim());
142
191
  }
@@ -148,4 +197,4 @@ async function execute(name, input) {
148
197
  }
149
198
  }
150
199
 
151
- module.exports = { definitions, execute };
200
+ module.exports = { definitions, execute, runBackground, stopBackground };