agent-mp 0.4.0 → 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,8 +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 an animated spinner on the status row. Returns a stop function. */
19
+ /** Start the activity box for a subagent call. Returns { stop, push }. */
20
20
  private _startSpinner;
21
+ /** Extract readable text lines from a qwen/CLI streaming chunk. */
22
+ private _parseChunk;
21
23
  /**
22
24
  * FASE 0 — Clarificacion con el programador.
23
25
  * El coordinador (CLI activo, ej: Qwen) conversa con el usuario
@@ -40,6 +42,14 @@ export declare class AgentEngine {
40
42
  runReviewer(taskId: string, plan: TaskPlan, progress: TaskProgress): Promise<{
41
43
  verdict: string;
42
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;
43
53
  runExplorer(task?: string): Promise<string>;
44
54
  /** Called when the current binary IS the configured explorer CLI (prevents recursion).
45
55
  * Builds the full exploration prompt and calls Qwen API using own credentials. */
@@ -219,19 +219,54 @@ export class AgentEngine {
219
219
  this.fi = fi;
220
220
  this.slashHandler = slashHandler;
221
221
  }
222
- /** Start an animated spinner on the status row. Returns a stop function. */
222
+ /** Start the activity box for a subagent call. Returns { stop, push }. */
223
223
  _startSpinner(label) {
224
+ const noop = { stop() { }, push(_) { } };
224
225
  if (!this.fi)
225
- return () => { };
226
+ return noop;
226
227
  const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
227
228
  let i = 0;
228
229
  const t0 = Date.now();
229
230
  const fi = this.fi;
231
+ fi.startActivity(`${frames[0]} ${label} 0s`);
230
232
  const iv = setInterval(() => {
231
233
  const s = Math.floor((Date.now() - t0) / 1000);
232
- fi.setStatus(` ${frames[i++ % frames.length]} ${label} ${s}s`);
234
+ fi.updateActivityHeader(`${frames[i++ % frames.length]} ${label} ${s}s`);
233
235
  }, 100);
234
- return () => { clearInterval(iv); fi.setStatus(null); };
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;
235
270
  }
236
271
  /**
237
272
  * FASE 0 — Clarificacion con el programador.
@@ -268,14 +303,14 @@ INSTRUCCIONES:
268
303
  // Use Qwen API directly — avoids the qwen CLI's own OAuth flow
269
304
  // which causes mid-session auth popups and breaks display.
270
305
  const model = this.coordinatorCmd.match(/(?:-m|--model)\s+(\S+)/)?.[1] || 'coder-model';
271
- const stopSpin = this._startSpinner(`coordinador ${model}`);
306
+ const sp = this._startSpinner(`coordinador ${model}`);
272
307
  try {
273
- const result = await callQwenAPI(prompt, model);
274
- stopSpin();
308
+ const result = await callQwenAPI(prompt, model, (c) => this._parseChunk(c).forEach(l => sp.push(l)));
309
+ sp.stop();
275
310
  return result;
276
311
  }
277
312
  catch (err) {
278
- stopSpin();
313
+ sp.stop();
279
314
  if (err.message?.startsWith('QWEN_AUTH_EXPIRED')) {
280
315
  console.log(chalk.red('\n ✗ Sesión Qwen expirada.'));
281
316
  console.log(chalk.yellow(' Ejecutá: /login para re-autenticarte.\n'));
@@ -287,9 +322,9 @@ INSTRUCCIONES:
287
322
  }
288
323
  }
289
324
  else {
290
- const stopSpin = this._startSpinner(`coordinador`);
325
+ const sp = this._startSpinner(`coordinador`);
291
326
  res = await runCli(this.coordinatorCmd, prompt, 600000, envOverride);
292
- stopSpin();
327
+ sp.stop();
293
328
  }
294
329
  // Extract readable text — search for JSON array even if there's prefix text
295
330
  let responseText = res.output.trim();
@@ -386,19 +421,19 @@ INSTRUCCIONES:
386
421
  const rolePrompt = this.buildRolePrompt(roleName, prompt);
387
422
  /** Try a cmd, and if it fails, auto-detect flags from --help and retry */
388
423
  const tryWithAutoRepair = async (cliName, model, currentCmd) => {
389
- const stopSpin = this._startSpinner(`${cliName} ${model}`);
424
+ const sp = this._startSpinner(`${cliName} ${model}`);
390
425
  try {
391
426
  const result = await runCli(currentCmd, rolePrompt);
392
427
  if (result.exitCode !== 0) {
393
- stopSpin();
428
+ sp.stop();
394
429
  const detail = result.output.trim().slice(0, 500);
395
430
  throw new Error(`${cliName} exited with code ${result.exitCode}${detail ? `\n${detail}` : ''}`);
396
431
  }
397
- stopSpin();
432
+ sp.stop();
398
433
  return result.output;
399
434
  }
400
435
  catch (err) {
401
- stopSpin();
436
+ sp.stop();
402
437
  log.warn(`${cliName} failed, detecting flags from --help: ${err.message}`);
403
438
  const detected = detectCliFlags(cliName);
404
439
  if (Object.keys(detected).length === 0) {
@@ -450,10 +485,10 @@ INSTRUCCIONES:
450
485
  // Config file might not exist yet
451
486
  }
452
487
  // Retry with new command
453
- const stopSpin2 = this._startSpinner(`${cliName} ${model} (retry)`);
488
+ const sp2 = this._startSpinner(`${cliName} ${model} (retry)`);
454
489
  try {
455
490
  const result = await runCli(newCmd, rolePrompt);
456
- stopSpin2();
491
+ sp2.stop();
457
492
  if (result.exitCode !== 0) {
458
493
  const detail = result.output.trim().slice(0, 500);
459
494
  throw new Error(`${cliName} (repaired) exited with code ${result.exitCode}${detail ? `\n${detail}` : ''}`);
@@ -462,7 +497,7 @@ INSTRUCCIONES:
462
497
  return result.output;
463
498
  }
464
499
  catch (retryErr) {
465
- stopSpin2();
500
+ sp2.stop();
466
501
  log.warn(`Repaired ${cliName} also failed: ${retryErr.message}`);
467
502
  return null;
468
503
  }
@@ -479,15 +514,15 @@ INSTRUCCIONES:
479
514
  log.warn(`${cliName} has no credentials — run: ${cliName} --login`);
480
515
  return null;
481
516
  }
482
- const stopSpin = this._startSpinner(`${cliName} ${model}`);
517
+ const sp = this._startSpinner(`${cliName} ${model}`);
483
518
  try {
484
519
  log.info(`${cliName}: calling Qwen API with own credentials (${model})`);
485
- const result = await callQwenAPIFromCreds(rolePrompt, model, credsPath);
486
- stopSpin();
520
+ const result = await callQwenAPIFromCreds(rolePrompt, model, credsPath, (c) => this._parseChunk(c).forEach(l => sp.push(l)));
521
+ sp.stop();
487
522
  return result;
488
523
  }
489
524
  catch (err) {
490
- stopSpin();
525
+ sp.stop();
491
526
  if (err.message?.startsWith('QWEN_AUTH_EXPIRED')) {
492
527
  console.log(chalk.red(`\n ✗ Sesión expirada para ${cliName}.`));
493
528
  console.log(chalk.yellow(` Ejecutá: ${cliName} --login\n`));
@@ -799,6 +834,66 @@ INSTRUCCIONES:
799
834
  log.verdict(verdict);
800
835
  return { verdict };
801
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
+ }
802
897
  async runExplorer(task) {
803
898
  if (!this.config.roles.explorer) {
804
899
  if (!this.config.fallback_global) {
@@ -813,6 +908,8 @@ INSTRUCCIONES:
813
908
  const contextDir = path.join(agentDir, 'context');
814
909
  await fs.mkdir(contextDir, { recursive: true });
815
910
  await fs.mkdir(path.join(agentDir, 'rules'), { recursive: true });
911
+ // Clean up stray files before running
912
+ await this._cleanContextDir(contextDir);
816
913
  // Read existing architecture doc if any
817
914
  const archPath = path.join(contextDir, 'architecture.md');
818
915
  let existingArch = '';
@@ -863,6 +960,8 @@ REGLAS:
863
960
  const agentDir = path.join(this.projectDir, '.agent');
864
961
  const contextDir = path.join(agentDir, 'context');
865
962
  await fs.mkdir(contextDir, { recursive: true });
963
+ // Clean up stray files before running
964
+ await this._cleanContextDir(contextDir);
866
965
  const archPath = path.join(contextDir, 'architecture.md');
867
966
  let existingArch = '';
868
967
  try {
@@ -889,13 +988,13 @@ REGLAS:
889
988
  - Escribe UNICAMENTE los archivos indicados: ${archPath} y ${contextDir}/<servicio>/architecture.md
890
989
  - NO crees archivos adicionales ni con otros nombres en ningun directorio`);
891
990
  let result;
892
- const stopSpin = this._startSpinner(`agent-explorer ${role.model}`);
991
+ const sp = this._startSpinner(`agent-explorer ${role.model}`);
893
992
  try {
894
- result = await callQwenAPI(prompt, role.model);
895
- stopSpin();
993
+ result = await callQwenAPI(prompt, role.model, (c) => this._parseChunk(c).forEach(l => sp.push(l)));
994
+ sp.stop();
896
995
  }
897
996
  catch (err) {
898
- stopSpin();
997
+ sp.stop();
899
998
  if (err.message?.startsWith('QWEN_AUTH_EXPIRED')) {
900
999
  console.log(chalk.red('\n ✗ Sesión Qwen expirada.'));
901
1000
  console.log(chalk.yellow(' Ejecutá: agent-mp --login (o agent-explorer --login)\n'));
@@ -6,7 +6,8 @@ export declare class FixedInput {
6
6
  private _pasting;
7
7
  private _pasteAccum;
8
8
  private _drawPending;
9
- private _statusText;
9
+ private _activityHeader;
10
+ private _activityLines;
10
11
  private get rows();
11
12
  get cols(): number;
12
13
  private get scrollBottom();
@@ -14,21 +15,26 @@ export declare class FixedInput {
14
15
  setup(): void;
15
16
  teardown(): void;
16
17
  redrawBox(): void;
17
- /** Show or clear the status / spinner line above the input box. */
18
- setStatus(text: string | null): void;
19
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;
20
30
  readLine(): Promise<string>;
21
31
  println(text: string): void;
22
32
  printSeparator(): void;
23
- /** Repaint the status row (between scroll region and input box). */
24
- private _drawStatusRow;
25
- /** Debounced draw: coalesces rapid calls (e.g. during paste) into a single repaint. */
26
33
  private _scheduleDraw;
27
- /** Set DECSTBM once — only called in setup() and on resize, never during typing. */
28
34
  private _setScrollRegion;
29
- /** Blank every row in the reserved area. */
30
35
  private _clearReserved;
31
36
  private _drawBox;
32
- /** Split text into visual lines: split on \n, then wrap each segment. */
37
+ private _drawActivityBox;
38
+ private _drawInputBox;
33
39
  private _wrapText;
34
40
  }
package/dist/ui/input.js CHANGED
@@ -6,29 +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) + 1 (status row).
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 + 3; // 7 = 4 content + 2 borders + 1 status
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;
23
- _pasting = false; // true while inside bracketed paste sequence
24
- _pasteAccum = ''; // accumulates paste content between \x1b[200~ and \x1b[201~
25
- _drawPending = false; // debounce flag
26
- _statusText = ''; // spinner / status line above the input box
21
+ _pasting = false;
22
+ _pasteAccum = '';
23
+ _drawPending = false;
24
+ // ── Activity box state (null = input mode, string = activity mode) ──────────
25
+ _activityHeader = null;
26
+ _activityLines = [];
27
27
  get rows() { return process.stdout.rows || 24; }
28
28
  get cols() { return process.stdout.columns || 80; }
29
- // The scroll region always ends here — everything below is reserved for the box.
30
29
  get scrollBottom() { return this.rows - RESERVED_ROWS; }
31
- // How many content rows the current buffer needs (1 .. MAX_CONTENT_ROWS).
32
30
  _contentRows() {
33
31
  const w = this.cols - PREFIX_COLS - 2;
34
32
  if (w <= 0)
@@ -51,7 +49,7 @@ export class FixedInput {
51
49
  process.stdout.write(`\x1b[${this.scrollBottom};1H`);
52
50
  this._clearReserved();
53
51
  this._drawBox();
54
- process.stdout.write('\x1b[?2004h'); // enable bracketed paste mode
52
+ process.stdout.write('\x1b[?2004h');
55
53
  process.stdout.on('resize', () => {
56
54
  this._setScrollRegion();
57
55
  this._clearReserved();
@@ -59,21 +57,18 @@ export class FixedInput {
59
57
  });
60
58
  }
61
59
  teardown() {
60
+ this._activityHeader = null;
61
+ this._activityLines = [];
62
62
  console.log = this.origLog;
63
- process.stdout.write('\x1b[?2004l'); // disable bracketed paste mode
64
- process.stdout.write('\x1b[r'); // reset scroll region
65
- 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');
66
66
  process.stdout.write(`\x1b[${this.rows};1H\n`);
67
67
  }
68
68
  redrawBox() { this._drawBox(); }
69
- /** Show or clear the status / spinner line above the input box. */
70
- setStatus(text) {
71
- this._statusText = text || '';
72
- this._drawBox();
73
- }
74
69
  suspend() {
75
70
  console.log = this.origLog;
76
- process.stdout.write('\x1b[?2004l'); // disable bracketed paste while suspended
71
+ process.stdout.write('\x1b[?2004l');
77
72
  process.stdout.write('\x1b[r');
78
73
  this._clearReserved();
79
74
  process.stdout.write(`\x1b[${this.scrollBottom};1H`);
@@ -85,9 +80,46 @@ export class FixedInput {
85
80
  this._setScrollRegion();
86
81
  this._clearReserved();
87
82
  this._drawBox();
88
- process.stdout.write('\x1b[?2004h'); // re-enable bracketed paste mode
83
+ process.stdout.write('\x1b[?2004h');
89
84
  };
90
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
+ }
91
123
  // ── Input ──────────────────────────────────────────────────────────────────
92
124
  readLine() {
93
125
  this.buf = '';
@@ -111,7 +143,6 @@ export class FixedInput {
111
143
  if (key.includes('\x1b[200~')) {
112
144
  this._pasting = true;
113
145
  this._pasteAccum = '';
114
- // Strip the start marker and handle any content after it in the same chunk
115
146
  const after = key.slice(key.indexOf('\x1b[200~') + 6);
116
147
  if (after)
117
148
  this._pasteAccum += after;
@@ -120,7 +151,6 @@ export class FixedInput {
120
151
  // ── Bracketed paste: accumulate ───────────────────────────────
121
152
  if (this._pasting) {
122
153
  if (key.includes('\x1b[201~')) {
123
- // End marker — append everything before it, then commit
124
154
  const before = key.slice(0, key.indexOf('\x1b[201~'));
125
155
  this._pasteAccum += before;
126
156
  this.buf += this._pasteAccum;
@@ -133,17 +163,15 @@ export class FixedInput {
133
163
  }
134
164
  return;
135
165
  }
136
- // ── Shift+Enter → insert newline into buffer ──────────────────
137
- // Different terminals send different sequences:
138
- if (hex === '5c0d' || // \\\r (GNOME Terminal, ThinkPad, many Linux)
139
- key === '\x0a' || // LF (Ctrl+J, some terminals)
140
- hex === '1b5b31333b327e' || // \x1b[13;2~ xterm
141
- hex === '1b5b31333b3275' || // \x1b[13;2u kitty
142
- hex === '1b4f4d' // \x1bOM DECNKP
143
- ) {
166
+ // ── Shift+Enter → newline ────────────────────────────────────
167
+ if (hex === '5c0d' ||
168
+ key === '\x0a' ||
169
+ hex === '1b5b31333b327e' ||
170
+ hex === '1b5b31333b3275' ||
171
+ hex === '1b4f4d') {
144
172
  this.buf += '\n';
145
173
  this._scheduleDraw();
146
- // ── Enter → submit ────────────────────────────────────────────
174
+ // ── Enter → submit ───────────────────────────────────────────
147
175
  }
148
176
  else if (key === '\r') {
149
177
  const line = this.buf;
@@ -154,20 +182,20 @@ export class FixedInput {
154
182
  }
155
183
  done(line);
156
184
  }
157
- else if (key === '\x7f' || key === '\x08') { // Backspace
185
+ else if (key === '\x7f' || key === '\x08') {
158
186
  if (this.buf.length > 0) {
159
187
  this.buf = this.buf.slice(0, -1);
160
188
  this._scheduleDraw();
161
189
  }
162
190
  }
163
- else if (key === '\x03') { // Ctrl+C
191
+ else if (key === '\x03') {
164
192
  this.teardown();
165
193
  process.exit(0);
166
194
  }
167
- else if (key === '\x04') { // Ctrl+D
195
+ else if (key === '\x04') {
168
196
  done('/exit');
169
197
  }
170
- else if (key === '\x15') { // Ctrl+U
198
+ else if (key === '\x15') {
171
199
  this.buf = '';
172
200
  this._scheduleDraw();
173
201
  }
@@ -207,49 +235,60 @@ export class FixedInput {
207
235
  this.println(chalk.rgb(0, 120, 116)('─'.repeat(this.cols - 1)));
208
236
  }
209
237
  // ── Private drawing ────────────────────────────────────────────────────────
210
- /** Repaint the status row (between scroll region and input box). */
211
- _drawStatusRow() {
212
- const row = this.scrollBottom + 1;
213
- process.stdout.write(`\x1b[${row};1H\x1b[2K`);
214
- if (this._statusText) {
215
- process.stdout.write(chalk.rgb(0, 185, 180)(this._statusText));
216
- }
217
- }
218
- /** Debounced draw: coalesces rapid calls (e.g. during paste) into a single repaint. */
219
238
  _scheduleDraw() {
220
239
  if (this._drawPending)
221
240
  return;
222
241
  this._drawPending = true;
223
- setImmediate(() => {
224
- this._drawPending = false;
225
- this._drawBox();
226
- });
242
+ setImmediate(() => { this._drawPending = false; this._drawBox(); });
227
243
  }
228
- /** Set DECSTBM once — only called in setup() and on resize, never during typing. */
229
244
  _setScrollRegion() {
230
245
  const sb = this.scrollBottom;
231
246
  if (sb >= 1)
232
247
  process.stdout.write(`\x1b[1;${sb}r`);
233
248
  }
234
- /** Blank every row in the reserved area. */
235
249
  _clearReserved() {
236
250
  for (let r = this.scrollBottom + 1; r <= this.rows; r++)
237
251
  process.stdout.write(`\x1b[${r};1H\x1b[2K`);
238
252
  }
239
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() {
240
288
  const cols = this.cols;
241
289
  const cRows = this._contentRows();
242
290
  const cWidth = cols - PREFIX_COLS - 2;
243
- // The box occupies the bottom of the terminal:
244
- // topBorder = rows - cRows - 1
245
- // content rows = rows - cRows ... rows - 1
246
- // bottomBorder = rows
247
291
  const topBorder = this.rows - cRows - 1;
248
- // Hide cursor while repainting
249
- process.stdout.write('\x1b[?25l');
250
- // Clear entire reserved area (removes stale content from previous draws)
251
- this._clearReserved();
252
- this._drawStatusRow();
253
292
  // ── Top border ───────────────────────────────────────────────
254
293
  process.stdout.write(`\x1b[${topBorder};1H`);
255
294
  process.stdout.write(T('╭') + T('─'.repeat(cols - 2)));
@@ -261,7 +300,6 @@ export class FixedInput {
261
300
  for (let i = 0; i < cRows; i++) {
262
301
  const row = topBorder + 1 + i;
263
302
  let line = visible[i] ?? '';
264
- // Show overflow indicator when content is clipped above
265
303
  if (i === 0 && showStart > 0)
266
304
  line = '… ' + line.slice(0, Math.max(0, cWidth - 2));
267
305
  else
@@ -275,18 +313,14 @@ export class FixedInput {
275
313
  process.stdout.write(`\x1b[${this.rows};1H`);
276
314
  process.stdout.write(T('╰') + T('─'.repeat(cols - 2)));
277
315
  process.stdout.write(`\x1b[${cols}G` + T('╯'));
278
- // ── Position cursor at end of last visible line ──────────────
316
+ // ── Position cursor ──────────────────────────────────────────
279
317
  const lastLine = visible[visible.length - 1] ?? '';
280
- const cursorRow = topBorder + cRows; // last content row
318
+ const cursorRow = topBorder + cRows;
281
319
  const cursorCol = PREFIX_COLS + 1 + lastLine.length;
282
320
  process.stdout.write(`\x1b[${cursorRow};${cursorCol}H`);
283
- process.stdout.write('\x1b[?25h');
284
321
  }
285
- /** Split text into visual lines: split on \n, then wrap each segment. */
286
322
  _wrapText(text, maxWidth) {
287
- if (!text)
288
- return [''];
289
- if (maxWidth <= 0)
323
+ if (!text || maxWidth <= 0)
290
324
  return [''];
291
325
  const result = [];
292
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,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-mp",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Deterministic multi-agent CLI orchestrator — plan, code, review",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",