agent-rev 0.3.6 → 0.4.2

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.
@@ -16,6 +16,10 @@ export declare class AgentEngine {
16
16
  private totalTokens;
17
17
  private phaseTokens;
18
18
  constructor(config: AgentConfig, projectDir: string, coordinatorCmd?: string, rl?: readline.Interface, fi?: FixedInput, slashHandler?: SlashHandler);
19
+ /** Start the activity box for a subagent call. Returns { stop, push }. */
20
+ private _startSpinner;
21
+ /** Extract readable text lines from a qwen/CLI streaming chunk. */
22
+ private _parseChunk;
19
23
  /**
20
24
  * FASE 0 — Clarificacion con el programador.
21
25
  * El coordinador (CLI activo, ej: Qwen) conversa con el usuario
@@ -38,6 +42,14 @@ export declare class AgentEngine {
38
42
  runReviewer(taskId: string, plan: TaskPlan, progress: TaskProgress): Promise<{
39
43
  verdict: string;
40
44
  }>;
45
+ /**
46
+ * Scan contextDir for stray files (outside the allowed structure) and clean them up:
47
+ * - Old timestamped explorer-*.md reports → delete (content already in architecture.md)
48
+ * - Other stray .md files at root level → append content to architecture.md, then delete
49
+ * Allowed root-level files: architecture.md, explorer-last.md
50
+ * Allowed subdirectory files: <service>/architecture.md (not touched here)
51
+ */
52
+ private _cleanContextDir;
41
53
  runExplorer(task?: string): Promise<string>;
42
54
  /** Called when the current binary IS the configured explorer CLI (prevents recursion).
43
55
  * Builds the full exploration prompt and calls Qwen API using own credentials. */
@@ -219,6 +219,55 @@ export class AgentEngine {
219
219
  this.fi = fi;
220
220
  this.slashHandler = slashHandler;
221
221
  }
222
+ /** Start the activity box for a subagent call. Returns { stop, push }. */
223
+ _startSpinner(label) {
224
+ const noop = { stop() { }, push(_) { } };
225
+ if (!this.fi)
226
+ return noop;
227
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
228
+ let i = 0;
229
+ const t0 = Date.now();
230
+ const fi = this.fi;
231
+ fi.startActivity(`${frames[0]} ${label} 0s`);
232
+ const iv = setInterval(() => {
233
+ const s = Math.floor((Date.now() - t0) / 1000);
234
+ fi.updateActivityHeader(`${frames[i++ % frames.length]} ${label} ${s}s`);
235
+ }, 100);
236
+ return {
237
+ stop() { clearInterval(iv); fi.stopActivity(); },
238
+ push(line) { fi.pushActivity(line); },
239
+ };
240
+ }
241
+ /** Extract readable text lines from a qwen/CLI streaming chunk. */
242
+ _parseChunk(chunk) {
243
+ const out = [];
244
+ for (const raw of chunk.split('\n')) {
245
+ const line = raw.trim();
246
+ if (!line)
247
+ continue;
248
+ // Try to extract text from JSON streaming events
249
+ if (line.startsWith('{')) {
250
+ try {
251
+ const obj = JSON.parse(line);
252
+ const text = obj.result ||
253
+ obj.data?.content ||
254
+ obj.message?.content?.[0]?.text ||
255
+ obj.choices?.[0]?.delta?.content ||
256
+ obj.choices?.[0]?.message?.content ||
257
+ '';
258
+ if (text.trim())
259
+ out.push(text.trim());
260
+ continue;
261
+ }
262
+ catch { /* not JSON — fall through */ }
263
+ }
264
+ // Skip lines that look like SSE metadata
265
+ if (line.startsWith('data:') || line.startsWith('event:') || line === '[DONE]')
266
+ continue;
267
+ out.push(line);
268
+ }
269
+ return out;
270
+ }
222
271
  /**
223
272
  * FASE 0 — Clarificacion con el programador.
224
273
  * El coordinador (CLI activo, ej: Qwen) conversa con el usuario
@@ -253,11 +302,15 @@ INSTRUCCIONES:
253
302
  if (this.coordinatorCmd.startsWith('qwen')) {
254
303
  // Use Qwen API directly — avoids the qwen CLI's own OAuth flow
255
304
  // which causes mid-session auth popups and breaks display.
305
+ const model = this.coordinatorCmd.match(/(?:-m|--model)\s+(\S+)/)?.[1] || 'coder-model';
306
+ const sp = this._startSpinner(`coordinador ${model}`);
256
307
  try {
257
- const model = this.coordinatorCmd.match(/(?:-m|--model)\s+(\S+)/)?.[1] || 'coder-model';
258
- return await callQwenAPI(prompt, model);
308
+ const result = await callQwenAPI(prompt, model, (c) => this._parseChunk(c).forEach(l => sp.push(l)));
309
+ sp.stop();
310
+ return result;
259
311
  }
260
312
  catch (err) {
313
+ sp.stop();
261
314
  if (err.message?.startsWith('QWEN_AUTH_EXPIRED')) {
262
315
  console.log(chalk.red('\n ✗ Sesión Qwen expirada.'));
263
316
  console.log(chalk.yellow(' Ejecutá: /login para re-autenticarte.\n'));
@@ -269,7 +322,9 @@ INSTRUCCIONES:
269
322
  }
270
323
  }
271
324
  else {
325
+ const sp = this._startSpinner(`coordinador`);
272
326
  res = await runCli(this.coordinatorCmd, prompt, 600000, envOverride);
327
+ sp.stop();
273
328
  }
274
329
  // Extract readable text — search for JSON array even if there's prefix text
275
330
  let responseText = res.output.trim();
@@ -366,15 +421,19 @@ INSTRUCCIONES:
366
421
  const rolePrompt = this.buildRolePrompt(roleName, prompt);
367
422
  /** Try a cmd, and if it fails, auto-detect flags from --help and retry */
368
423
  const tryWithAutoRepair = async (cliName, model, currentCmd) => {
424
+ const sp = this._startSpinner(`${cliName} ${model}`);
369
425
  try {
370
426
  const result = await runCli(currentCmd, rolePrompt);
371
427
  if (result.exitCode !== 0) {
428
+ sp.stop();
372
429
  const detail = result.output.trim().slice(0, 500);
373
430
  throw new Error(`${cliName} exited with code ${result.exitCode}${detail ? `\n${detail}` : ''}`);
374
431
  }
432
+ sp.stop();
375
433
  return result.output;
376
434
  }
377
435
  catch (err) {
436
+ sp.stop();
378
437
  log.warn(`${cliName} failed, detecting flags from --help: ${err.message}`);
379
438
  const detected = detectCliFlags(cliName);
380
439
  if (Object.keys(detected).length === 0) {
@@ -426,8 +485,10 @@ INSTRUCCIONES:
426
485
  // Config file might not exist yet
427
486
  }
428
487
  // Retry with new command
488
+ const sp2 = this._startSpinner(`${cliName} ${model} (retry)`);
429
489
  try {
430
490
  const result = await runCli(newCmd, rolePrompt);
491
+ sp2.stop();
431
492
  if (result.exitCode !== 0) {
432
493
  const detail = result.output.trim().slice(0, 500);
433
494
  throw new Error(`${cliName} (repaired) exited with code ${result.exitCode}${detail ? `\n${detail}` : ''}`);
@@ -436,6 +497,7 @@ INSTRUCCIONES:
436
497
  return result.output;
437
498
  }
438
499
  catch (retryErr) {
500
+ sp2.stop();
439
501
  log.warn(`Repaired ${cliName} also failed: ${retryErr.message}`);
440
502
  return null;
441
503
  }
@@ -452,11 +514,15 @@ INSTRUCCIONES:
452
514
  log.warn(`${cliName} has no credentials — run: ${cliName} --login`);
453
515
  return null;
454
516
  }
517
+ const sp = this._startSpinner(`${cliName} ${model}`);
455
518
  try {
456
519
  log.info(`${cliName}: calling Qwen API with own credentials (${model})`);
457
- return await callQwenAPIFromCreds(rolePrompt, model, credsPath);
520
+ const result = await callQwenAPIFromCreds(rolePrompt, model, credsPath, (c) => this._parseChunk(c).forEach(l => sp.push(l)));
521
+ sp.stop();
522
+ return result;
458
523
  }
459
524
  catch (err) {
525
+ sp.stop();
460
526
  if (err.message?.startsWith('QWEN_AUTH_EXPIRED')) {
461
527
  console.log(chalk.red(`\n ✗ Sesión expirada para ${cliName}.`));
462
528
  console.log(chalk.yellow(` Ejecutá: ${cliName} --login\n`));
@@ -768,6 +834,66 @@ INSTRUCCIONES:
768
834
  log.verdict(verdict);
769
835
  return { verdict };
770
836
  }
837
+ /**
838
+ * Scan contextDir for stray files (outside the allowed structure) and clean them up:
839
+ * - Old timestamped explorer-*.md reports → delete (content already in architecture.md)
840
+ * - Other stray .md files at root level → append content to architecture.md, then delete
841
+ * Allowed root-level files: architecture.md, explorer-last.md
842
+ * Allowed subdirectory files: <service>/architecture.md (not touched here)
843
+ */
844
+ async _cleanContextDir(contextDir) {
845
+ let entries;
846
+ try {
847
+ entries = await fs.readdir(contextDir, { withFileTypes: true });
848
+ }
849
+ catch {
850
+ return;
851
+ } // dir doesn't exist yet
852
+ const archPath = path.join(contextDir, 'architecture.md');
853
+ const stray = [];
854
+ for (const e of entries) {
855
+ if (!e.isFile())
856
+ continue;
857
+ if (!e.name.endsWith('.md'))
858
+ continue;
859
+ if (e.name === 'architecture.md' || e.name === 'explorer-last.md')
860
+ continue;
861
+ stray.push(e.name);
862
+ }
863
+ if (stray.length === 0)
864
+ return;
865
+ log.info(`Limpiando ${stray.length} archivo(s) fuera de estructura en .agent/context/`);
866
+ for (const name of stray) {
867
+ const filePath = path.join(contextDir, name);
868
+ // Old timestamped explorer reports — just delete (content is redundant)
869
+ if (/^explorer-\d{4}-\d{2}-/.test(name)) {
870
+ await fs.unlink(filePath);
871
+ log.ok(` Eliminado: ${name}`);
872
+ continue;
873
+ }
874
+ // Other stray files — migrate content to architecture.md, then delete
875
+ try {
876
+ const content = (await readFile(filePath)).trim();
877
+ if (content) {
878
+ let arch = '';
879
+ try {
880
+ arch = await readFile(archPath);
881
+ }
882
+ catch { /* no arch yet */ }
883
+ const label = name.replace(/\.md$/, '');
884
+ await writeFile(archPath, `${arch}\n\n---\n## ${label}\n\n${content}\n`);
885
+ log.ok(` Migrado: ${name} → architecture.md`);
886
+ }
887
+ else {
888
+ log.ok(` Eliminado (vacío): ${name}`);
889
+ }
890
+ await fs.unlink(filePath);
891
+ }
892
+ catch (err) {
893
+ log.warn(` No se pudo limpiar ${name}: ${err.message}`);
894
+ }
895
+ }
896
+ }
771
897
  async runExplorer(task) {
772
898
  if (!this.config.roles.explorer) {
773
899
  if (!this.config.fallback_global) {
@@ -782,6 +908,8 @@ INSTRUCCIONES:
782
908
  const contextDir = path.join(agentDir, 'context');
783
909
  await fs.mkdir(contextDir, { recursive: true });
784
910
  await fs.mkdir(path.join(agentDir, 'rules'), { recursive: true });
911
+ // Clean up stray files before running
912
+ await this._cleanContextDir(contextDir);
785
913
  // Read existing architecture doc if any
786
914
  const archPath = path.join(contextDir, 'architecture.md');
787
915
  let existingArch = '';
@@ -809,16 +937,16 @@ INSTRUCCIONES:
809
937
  7. Al terminar lista todos los archivos creados/actualizados.
810
938
 
811
939
  REGLAS:
812
- - NO modifiques archivos de aplicacion (solo .agent/context/)
940
+ - Escribe UNICAMENTE los archivos indicados: ${archPath} y ${contextDir}/<servicio>/architecture.md
941
+ - NO crees archivos adicionales ni con otros nombres
942
+ - NO modifiques archivos de aplicacion
813
943
  - NO ejecutes comandos que cambien estado (npm install, migraciones, etc.)
814
944
  - Si un directorio esta en node_modules, dist, .git: ignoralo`;
815
945
  const res = await this.runWithFallback('explorer', prompt, 'Exploracion');
816
946
  const text = extractCliText(res);
817
- // Always save a timestamped explorer report
947
+ // Overwrite the single last-run report (no timestamp accumulation)
818
948
  try {
819
- const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
820
- await writeFile(path.join(contextDir, `explorer-${ts}.md`), `# Explorer Report\n\nTask: ${effectiveTask}\nDate: ${new Date().toISOString()}\n\n${text}\n`);
821
- log.ok(`Saved to .agent/context/explorer-${ts}.md`);
949
+ await writeFile(path.join(contextDir, 'explorer-last.md'), `# Explorer Report\n\nTask: ${effectiveTask}\nDate: ${new Date().toISOString()}\n\n${text}\n`);
822
950
  }
823
951
  catch { /* don't fail if save fails */ }
824
952
  return text;
@@ -832,6 +960,8 @@ REGLAS:
832
960
  const agentDir = path.join(this.projectDir, '.agent');
833
961
  const contextDir = path.join(agentDir, 'context');
834
962
  await fs.mkdir(contextDir, { recursive: true });
963
+ // Clean up stray files before running
964
+ await this._cleanContextDir(contextDir);
835
965
  const archPath = path.join(contextDir, 'architecture.md');
836
966
  let existingArch = '';
837
967
  try {
@@ -852,12 +982,19 @@ INSTRUCCIONES:
852
982
  2. Para cada uno: lee package.json/requirements.txt, explora src/, identifica entry point, rutas/endpoints, puerto.
853
983
  3. Identifica dependencias entre servicios.
854
984
  4. Crea/actualiza ${archPath} con tabla resumen y detalle por servicio.
855
- 5. Crea/actualiza ${contextDir}/<servicio>/architecture.md para cada servicio.`);
985
+ 5. Crea/actualiza ${contextDir}/<servicio>/architecture.md para cada servicio.
986
+
987
+ REGLAS:
988
+ - Escribe UNICAMENTE los archivos indicados: ${archPath} y ${contextDir}/<servicio>/architecture.md
989
+ - NO crees archivos adicionales ni con otros nombres en ningun directorio`);
856
990
  let result;
991
+ const sp = this._startSpinner(`agent-explorer ${role.model}`);
857
992
  try {
858
- result = await callQwenAPI(prompt, role.model);
993
+ result = await callQwenAPI(prompt, role.model, (c) => this._parseChunk(c).forEach(l => sp.push(l)));
994
+ sp.stop();
859
995
  }
860
996
  catch (err) {
997
+ sp.stop();
861
998
  if (err.message?.startsWith('QWEN_AUTH_EXPIRED')) {
862
999
  console.log(chalk.red('\n ✗ Sesión Qwen expirada.'));
863
1000
  console.log(chalk.yellow(' Ejecutá: agent-mp --login (o agent-explorer --login)\n'));
@@ -866,9 +1003,7 @@ INSTRUCCIONES:
866
1003
  throw err;
867
1004
  }
868
1005
  try {
869
- const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
870
- await writeFile(path.join(contextDir, `explorer-${ts}.md`), `# Explorer Report\n\nTask: ${effectiveTask}\n\n${result}\n`);
871
- log.ok(`Saved to .agent/context/explorer-${ts}.md`);
1006
+ await writeFile(path.join(contextDir, 'explorer-last.md'), `# Explorer Report\n\nTask: ${effectiveTask}\nDate: ${new Date().toISOString()}\n\n${result}\n`);
872
1007
  }
873
1008
  catch { /* ignore */ }
874
1009
  return result;
@@ -3,6 +3,11 @@ export declare class FixedInput {
3
3
  private history;
4
4
  private histIdx;
5
5
  private origLog;
6
+ private _pasting;
7
+ private _pasteAccum;
8
+ private _drawPending;
9
+ private _activityHeader;
10
+ private _activityLines;
6
11
  private get rows();
7
12
  get cols(): number;
8
13
  private get scrollBottom();
@@ -11,14 +16,25 @@ export declare class FixedInput {
11
16
  teardown(): void;
12
17
  redrawBox(): void;
13
18
  suspend(): () => void;
19
+ /** Enter activity mode: show the 5-line log box instead of the input box. */
20
+ startActivity(header: string): void;
21
+ /** Update the header line (spinner frame + elapsed time) without clearing lines. */
22
+ updateActivityHeader(header: string): void;
23
+ /**
24
+ * Append a line to the activity log (keeps last ACTIVITY_LINES lines).
25
+ * Strips ANSI codes and skips blank or pure-JSON lines.
26
+ */
27
+ pushActivity(rawLine: string): void;
28
+ /** Leave activity mode and restore the normal input box. */
29
+ stopActivity(): void;
14
30
  readLine(): Promise<string>;
15
31
  println(text: string): void;
16
32
  printSeparator(): void;
17
- /** Set DECSTBM once — only called in setup() and on resize, never during typing. */
33
+ private _scheduleDraw;
18
34
  private _setScrollRegion;
19
- /** Blank every row in the reserved area. */
20
35
  private _clearReserved;
21
36
  private _drawBox;
22
- /** Split text into visual lines: split on \n, then wrap each segment. */
37
+ private _drawActivityBox;
38
+ private _drawInputBox;
23
39
  private _wrapText;
24
40
  }
package/dist/ui/input.js CHANGED
@@ -6,25 +6,27 @@ const PREFIX = T('│') + B(' > ');
6
6
  const PREFIX_CONT = T('│') + B(' '); // continuation lines
7
7
  const PREFIX_COLS = 4; // visual width of "│ > " and "│ "
8
8
  // Maximum content rows the box can grow to (Shift+Enter / word-wrap).
9
- // The reserved area at the bottom is MAX_CONTENT_ROWS + 2 (borders).
9
+ // RESERVED_ROWS covers the activity box (7 rows) and the input box (max 6 rows + 1 status).
10
+ // Activity box: 1 top border + 5 content lines + 1 bottom border = 7 rows.
11
+ // Input box: 1 status row + up to 4 content + 2 borders = 7 rows.
10
12
  const MAX_CONTENT_ROWS = 4;
11
- const RESERVED_ROWS = MAX_CONTENT_ROWS + 2; // 6
13
+ const ACTIVITY_LINES = 5;
14
+ const RESERVED_ROWS = MAX_CONTENT_ROWS + 3; // 7
12
15
  // ─── FixedInput ──────────────────────────────────────────────────────────────
13
- // Keeps an input box pinned to the physical bottom of the terminal.
14
- // The box starts as 3 rows (border + 1 content + border) and grows up to
15
- // RESERVED_ROWS when the user types multiline text (Shift+Enter) or the
16
- // text wraps. The scroll region is set ONCE at setup (and on resize) to
17
- // [1 .. rows-RESERVED_ROWS] so DECSTBM never fires during normal typing.
18
16
  export class FixedInput {
19
17
  buf = '';
20
18
  history = [];
21
19
  histIdx = -1;
22
20
  origLog;
21
+ _pasting = false;
22
+ _pasteAccum = '';
23
+ _drawPending = false;
24
+ // ── Activity box state (null = input mode, string = activity mode) ──────────
25
+ _activityHeader = null;
26
+ _activityLines = [];
23
27
  get rows() { return process.stdout.rows || 24; }
24
28
  get cols() { return process.stdout.columns || 80; }
25
- // The scroll region always ends here — everything below is reserved for the box.
26
29
  get scrollBottom() { return this.rows - RESERVED_ROWS; }
27
- // How many content rows the current buffer needs (1 .. MAX_CONTENT_ROWS).
28
30
  _contentRows() {
29
31
  const w = this.cols - PREFIX_COLS - 2;
30
32
  if (w <= 0)
@@ -47,6 +49,7 @@ export class FixedInput {
47
49
  process.stdout.write(`\x1b[${this.scrollBottom};1H`);
48
50
  this._clearReserved();
49
51
  this._drawBox();
52
+ process.stdout.write('\x1b[?2004h');
50
53
  process.stdout.on('resize', () => {
51
54
  this._setScrollRegion();
52
55
  this._clearReserved();
@@ -54,14 +57,18 @@ export class FixedInput {
54
57
  });
55
58
  }
56
59
  teardown() {
60
+ this._activityHeader = null;
61
+ this._activityLines = [];
57
62
  console.log = this.origLog;
58
- process.stdout.write('\x1b[r'); // reset scroll region
59
- process.stdout.write('\x1b[?25h'); // show cursor
63
+ process.stdout.write('\x1b[?2004l');
64
+ process.stdout.write('\x1b[r');
65
+ process.stdout.write('\x1b[?25h');
60
66
  process.stdout.write(`\x1b[${this.rows};1H\n`);
61
67
  }
62
68
  redrawBox() { this._drawBox(); }
63
69
  suspend() {
64
70
  console.log = this.origLog;
71
+ process.stdout.write('\x1b[?2004l');
65
72
  process.stdout.write('\x1b[r');
66
73
  this._clearReserved();
67
74
  process.stdout.write(`\x1b[${this.scrollBottom};1H`);
@@ -73,8 +80,46 @@ export class FixedInput {
73
80
  this._setScrollRegion();
74
81
  this._clearReserved();
75
82
  this._drawBox();
83
+ process.stdout.write('\x1b[?2004h');
76
84
  };
77
85
  }
86
+ // ── Activity box API ───────────────────────────────────────────────────────
87
+ /** Enter activity mode: show the 5-line log box instead of the input box. */
88
+ startActivity(header) {
89
+ this._activityHeader = header;
90
+ this._activityLines = [];
91
+ this._drawBox();
92
+ }
93
+ /** Update the header line (spinner frame + elapsed time) without clearing lines. */
94
+ updateActivityHeader(header) {
95
+ this._activityHeader = header;
96
+ this._drawBox();
97
+ }
98
+ /**
99
+ * Append a line to the activity log (keeps last ACTIVITY_LINES lines).
100
+ * Strips ANSI codes and skips blank or pure-JSON lines.
101
+ */
102
+ pushActivity(rawLine) {
103
+ if (this._activityHeader === null)
104
+ return;
105
+ // Strip ANSI escape sequences
106
+ const clean = rawLine
107
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
108
+ .replace(/[^\x20-\x7e\u00a0-\uffff]/g, '')
109
+ .trim();
110
+ if (!clean)
111
+ return;
112
+ this._activityLines.push(clean);
113
+ if (this._activityLines.length > ACTIVITY_LINES)
114
+ this._activityLines.shift();
115
+ this._scheduleDraw();
116
+ }
117
+ /** Leave activity mode and restore the normal input box. */
118
+ stopActivity() {
119
+ this._activityHeader = null;
120
+ this._activityLines = [];
121
+ this._drawBox();
122
+ }
78
123
  // ── Input ──────────────────────────────────────────────────────────────────
79
124
  readLine() {
80
125
  this.buf = '';
@@ -94,17 +139,39 @@ export class FixedInput {
94
139
  const onData = (data) => {
95
140
  const hex = data.toString('hex');
96
141
  const key = data.toString();
97
- // ── Shift+Enter insert newline into buffer ──────────────────
98
- // Different terminals send different sequences:
99
- if (hex === '5c0d' || // \\\r (GNOME Terminal, ThinkPad, many Linux)
100
- key === '\x0a' || // LF (Ctrl+J, some terminals)
101
- hex === '1b5b31333b327e' || // \x1b[13;2~ xterm
102
- hex === '1b5b31333b3275' || // \x1b[13;2u kitty
103
- hex === '1b4f4d' // \x1bOM DECNKP
104
- ) {
142
+ // ── Bracketed paste: start ────────────────────────────────────
143
+ if (key.includes('\x1b[200~')) {
144
+ this._pasting = true;
145
+ this._pasteAccum = '';
146
+ const after = key.slice(key.indexOf('\x1b[200~') + 6);
147
+ if (after)
148
+ this._pasteAccum += after;
149
+ return;
150
+ }
151
+ // ── Bracketed paste: accumulate ───────────────────────────────
152
+ if (this._pasting) {
153
+ if (key.includes('\x1b[201~')) {
154
+ const before = key.slice(0, key.indexOf('\x1b[201~'));
155
+ this._pasteAccum += before;
156
+ this.buf += this._pasteAccum;
157
+ this._pasting = false;
158
+ this._pasteAccum = '';
159
+ this._scheduleDraw();
160
+ }
161
+ else {
162
+ this._pasteAccum += key;
163
+ }
164
+ return;
165
+ }
166
+ // ── Shift+Enter → newline ────────────────────────────────────
167
+ if (hex === '5c0d' ||
168
+ key === '\x0a' ||
169
+ hex === '1b5b31333b327e' ||
170
+ hex === '1b5b31333b3275' ||
171
+ hex === '1b4f4d') {
105
172
  this.buf += '\n';
106
- this._drawBox();
107
- // ── Enter → submit ────────────────────────────────────────────
173
+ this._scheduleDraw();
174
+ // ── Enter → submit ───────────────────────────────────────────
108
175
  }
109
176
  else if (key === '\r') {
110
177
  const line = this.buf;
@@ -115,28 +182,28 @@ export class FixedInput {
115
182
  }
116
183
  done(line);
117
184
  }
118
- else if (key === '\x7f' || key === '\x08') { // Backspace
185
+ else if (key === '\x7f' || key === '\x08') {
119
186
  if (this.buf.length > 0) {
120
187
  this.buf = this.buf.slice(0, -1);
121
- this._drawBox();
188
+ this._scheduleDraw();
122
189
  }
123
190
  }
124
- else if (key === '\x03') { // Ctrl+C
191
+ else if (key === '\x03') {
125
192
  this.teardown();
126
193
  process.exit(0);
127
194
  }
128
- else if (key === '\x04') { // Ctrl+D
195
+ else if (key === '\x04') {
129
196
  done('/exit');
130
197
  }
131
- else if (key === '\x15') { // Ctrl+U
198
+ else if (key === '\x15') {
132
199
  this.buf = '';
133
- this._drawBox();
200
+ this._scheduleDraw();
134
201
  }
135
202
  else if (hex === '1b5b41') { // Arrow ↑
136
203
  if (this.histIdx + 1 < this.history.length) {
137
204
  this.histIdx++;
138
205
  this.buf = this.history[this.histIdx];
139
- this._drawBox();
206
+ this._scheduleDraw();
140
207
  }
141
208
  }
142
209
  else if (hex === '1b5b42') { // Arrow ↓
@@ -148,11 +215,11 @@ export class FixedInput {
148
215
  this.histIdx = -1;
149
216
  this.buf = '';
150
217
  }
151
- this._drawBox();
218
+ this._scheduleDraw();
152
219
  }
153
220
  else if (key.length >= 1 && key.charCodeAt(0) >= 32 && !key.startsWith('\x1b')) {
154
221
  this.buf += key;
155
- this._drawBox();
222
+ this._scheduleDraw();
156
223
  }
157
224
  };
158
225
  process.stdin.on('data', onData);
@@ -168,30 +235,60 @@ export class FixedInput {
168
235
  this.println(chalk.rgb(0, 120, 116)('─'.repeat(this.cols - 1)));
169
236
  }
170
237
  // ── Private drawing ────────────────────────────────────────────────────────
171
- /** Set DECSTBM once — only called in setup() and on resize, never during typing. */
238
+ _scheduleDraw() {
239
+ if (this._drawPending)
240
+ return;
241
+ this._drawPending = true;
242
+ setImmediate(() => { this._drawPending = false; this._drawBox(); });
243
+ }
172
244
  _setScrollRegion() {
173
245
  const sb = this.scrollBottom;
174
246
  if (sb >= 1)
175
247
  process.stdout.write(`\x1b[1;${sb}r`);
176
248
  }
177
- /** Blank every row in the reserved area. */
178
249
  _clearReserved() {
179
250
  for (let r = this.scrollBottom + 1; r <= this.rows; r++)
180
251
  process.stdout.write(`\x1b[${r};1H\x1b[2K`);
181
252
  }
182
253
  _drawBox() {
254
+ process.stdout.write('\x1b[?25l');
255
+ this._clearReserved();
256
+ if (this._activityHeader !== null) {
257
+ this._drawActivityBox();
258
+ // Cursor stays hidden while agent is working (no input expected)
259
+ return;
260
+ }
261
+ this._drawInputBox();
262
+ process.stdout.write('\x1b[?25h');
263
+ }
264
+ // ── Activity box (shown while a subagent is running) ───────────────────────
265
+ _drawActivityBox() {
266
+ const cols = this.cols;
267
+ const inner = cols - 4; // │ + space + content + space + │
268
+ const topRow = this.scrollBottom + 1;
269
+ const header = (this._activityHeader || '').slice(0, cols - 4);
270
+ const dashFill = Math.max(0, cols - 3 - header.length);
271
+ // Top border with header text
272
+ process.stdout.write(`\x1b[${topRow};1H`);
273
+ process.stdout.write(T('╭─') + chalk.bold.white(header) + T('─'.repeat(dashFill)) + T('╮'));
274
+ // Content rows (last ACTIVITY_LINES lines, or blank)
275
+ for (let i = 0; i < ACTIVITY_LINES; i++) {
276
+ const row = topRow + 1 + i;
277
+ const line = (this._activityLines[i] ?? '').slice(0, inner);
278
+ const pad = inner - line.length;
279
+ process.stdout.write(`\x1b[${row};1H`);
280
+ process.stdout.write(T('│') + ' ' + chalk.rgb(180, 210, 210)(line) + ' '.repeat(pad) + ' ' + T('│'));
281
+ }
282
+ // Bottom border
283
+ process.stdout.write(`\x1b[${topRow + ACTIVITY_LINES + 1};1H`);
284
+ process.stdout.write(T('╰') + T('─'.repeat(cols - 2)) + T('╯'));
285
+ }
286
+ // ── Normal input box ───────────────────────────────────────────────────────
287
+ _drawInputBox() {
183
288
  const cols = this.cols;
184
289
  const cRows = this._contentRows();
185
290
  const cWidth = cols - PREFIX_COLS - 2;
186
- // The box occupies the bottom of the terminal:
187
- // topBorder = rows - cRows - 1
188
- // content rows = rows - cRows ... rows - 1
189
- // bottomBorder = rows
190
291
  const topBorder = this.rows - cRows - 1;
191
- // Hide cursor while repainting
192
- process.stdout.write('\x1b[?25l');
193
- // Clear entire reserved area (removes stale content from previous draws)
194
- this._clearReserved();
195
292
  // ── Top border ───────────────────────────────────────────────
196
293
  process.stdout.write(`\x1b[${topBorder};1H`);
197
294
  process.stdout.write(T('╭') + T('─'.repeat(cols - 2)));
@@ -203,7 +300,6 @@ export class FixedInput {
203
300
  for (let i = 0; i < cRows; i++) {
204
301
  const row = topBorder + 1 + i;
205
302
  let line = visible[i] ?? '';
206
- // Show overflow indicator when content is clipped above
207
303
  if (i === 0 && showStart > 0)
208
304
  line = '… ' + line.slice(0, Math.max(0, cWidth - 2));
209
305
  else
@@ -217,18 +313,14 @@ export class FixedInput {
217
313
  process.stdout.write(`\x1b[${this.rows};1H`);
218
314
  process.stdout.write(T('╰') + T('─'.repeat(cols - 2)));
219
315
  process.stdout.write(`\x1b[${cols}G` + T('╯'));
220
- // ── Position cursor at end of last visible line ──────────────
316
+ // ── Position cursor ──────────────────────────────────────────
221
317
  const lastLine = visible[visible.length - 1] ?? '';
222
- const cursorRow = topBorder + cRows; // last content row
318
+ const cursorRow = topBorder + cRows;
223
319
  const cursorCol = PREFIX_COLS + 1 + lastLine.length;
224
320
  process.stdout.write(`\x1b[${cursorRow};${cursorCol}H`);
225
- process.stdout.write('\x1b[?25h');
226
321
  }
227
- /** Split text into visual lines: split on \n, then wrap each segment. */
228
322
  _wrapText(text, maxWidth) {
229
- if (!text)
230
- return [''];
231
- if (maxWidth <= 0)
323
+ if (!text || maxWidth <= 0)
232
324
  return [''];
233
325
  const result = [];
234
326
  for (const seg of text.split('\n')) {
@@ -15,7 +15,7 @@ export declare function getQwenAccessToken(): Promise<string | null>;
15
15
  * The qwen CLI manages its own token refresh and uses the correct API format.
16
16
  * Falls back to direct HTTP call if the qwen CLI is not available.
17
17
  */
18
- export declare function callQwenAPI(prompt: string, model?: string): Promise<string>;
18
+ export declare function callQwenAPI(prompt: string, model?: string, onData?: (chunk: string) => void): Promise<string>;
19
19
  /**
20
20
  * Call Qwen API using credentials from a specific file path (for role binaries).
21
21
  * The role binary CLI (e.g. agent-explorer) manages its own qwen auth via the
@@ -23,4 +23,4 @@ export declare function callQwenAPI(prompt: string, model?: string): Promise<str
23
23
  * in non-interactive mode without TTY issues.
24
24
  * Falls back to direct HTTP if the role binary is not found.
25
25
  */
26
- export declare function callQwenAPIFromCreds(prompt: string, model: string, credsPath: string): Promise<string>;
26
+ export declare function callQwenAPIFromCreds(prompt: string, model: string, credsPath: string, onData?: (chunk: string) => void): Promise<string>;
@@ -1,7 +1,25 @@
1
1
  import * as fs from 'fs/promises';
2
2
  import * as path from 'path';
3
3
  import * as crypto from 'crypto';
4
- import { spawnSync } from 'child_process';
4
+ import { spawn } from 'child_process';
5
+ /** Async alternative to spawnSync — keeps the event loop free so UI can update. */
6
+ function spawnAsync(bin, args, input, timeout, onData) {
7
+ return new Promise((resolve, reject) => {
8
+ const child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
9
+ let stdout = '';
10
+ child.stdout?.on('data', (d) => {
11
+ const s = d.toString();
12
+ stdout += s;
13
+ onData?.(s);
14
+ });
15
+ child.stderr?.on('data', (d) => { stdout += d.toString(); });
16
+ const timer = setTimeout(() => { child.kill(); reject(new Error('qwen timeout')); }, timeout);
17
+ child.on('close', (code) => { clearTimeout(timer); resolve({ stdout, status: code ?? 0 }); });
18
+ child.on('error', (err) => { clearTimeout(timer); reject(err); });
19
+ child.stdin?.write(input, 'utf-8');
20
+ child.stdin?.end();
21
+ });
22
+ }
5
23
  import open from 'open';
6
24
  import { AGENT_HOME } from './config.js';
7
25
  const QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai';
@@ -279,17 +297,12 @@ async function callQwenAPIWithToken(token, prompt, model) {
279
297
  * The qwen CLI manages its own token refresh and uses the correct API format.
280
298
  * Falls back to direct HTTP call if the qwen CLI is not available.
281
299
  */
282
- export async function callQwenAPI(prompt, model = 'coder-model') {
300
+ export async function callQwenAPI(prompt, model = 'coder-model', onData) {
283
301
  // Try using the qwen CLI subprocess first — it handles auth/refresh/format automatically
284
302
  const qwenBin = process.env.QWEN_BIN || 'qwen';
285
303
  try {
286
- const result = spawnSync(qwenBin, [], {
287
- input: prompt,
288
- encoding: 'utf-8',
289
- timeout: 300000, // 5 minutes
290
- maxBuffer: 10 * 1024 * 1024,
291
- });
292
- if (result.status === 0 && result.stdout?.trim()) {
304
+ const result = await spawnAsync(qwenBin, [], prompt, 300000, onData);
305
+ if (result.status === 0 && result.stdout.trim()) {
293
306
  return result.stdout.trim();
294
307
  }
295
308
  // qwen not available or failed — fall through to direct API
@@ -325,19 +338,14 @@ export async function callQwenAPI(prompt, model = 'coder-model') {
325
338
  * in non-interactive mode without TTY issues.
326
339
  * Falls back to direct HTTP if the role binary is not found.
327
340
  */
328
- export async function callQwenAPIFromCreds(prompt, model, credsPath) {
341
+ export async function callQwenAPIFromCreds(prompt, model, credsPath, onData) {
329
342
  // Derive the role binary name from the creds path (e.g. ~/.agent-explorer/ → agent-explorer)
330
343
  const cliName = path.basename(path.dirname(credsPath)).replace(/^\./, '');
331
- // Try spawning the role binary with piped stdin (non-interactive mode)
344
+ // Try spawning the qwen CLI with piped stdin (async — keeps event loop free)
332
345
  const qwenBin = process.env.QWEN_BIN || 'qwen';
333
346
  try {
334
- const result = spawnSync(qwenBin, [], {
335
- input: prompt,
336
- encoding: 'utf-8',
337
- timeout: 300000,
338
- maxBuffer: 10 * 1024 * 1024,
339
- });
340
- if (result.status === 0 && result.stdout?.trim()) {
347
+ const result = await spawnAsync(qwenBin, [], prompt, 300000, onData);
348
+ if (result.status === 0 && result.stdout.trim()) {
341
349
  return result.stdout.trim();
342
350
  }
343
351
  }
package/package.json CHANGED
@@ -1 +1,14 @@
1
- {"name":"agent-rev","version":"0.3.6","description":"agent-rev agent","type":"module","main":"./dist/index.js","files":["dist/"],"bin":{"agent-rev":"dist/index.js"},"scripts":{"build":"tsc"},"keywords":["ai","agent","cli"],"license":"MIT","dependencies":{"@anthropic-ai/sdk":"^0.39.0","@google/generative-ai":"^0.24.0","chalk":"^5.4.1","commander":"^13.1.0","open":"^11.0.0","openai":"^4.91.0"},"engines":{"node":">=18.0.0"}}
1
+ {
2
+ "name": "agent-rev",
3
+ "version": "0.4.2",
4
+ "description": "agent-rev agent",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "files": ["dist/"],
8
+ "bin": { "agent-rev": "dist/index.js" },
9
+ "scripts": {},
10
+ "keywords": ["ai","agent","cli"],
11
+ "license": "MIT",
12
+ "dependencies": {"@anthropic-ai/sdk":"^0.39.0","@google/generative-ai":"^0.24.0","chalk":"^5.4.1","commander":"^13.1.0","open":"^11.0.0","openai":"^4.91.0"},
13
+ "engines": { "node": ">=18.0.0" }
14
+ }