@neetru/cli 2.19.1 → 2.20.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 (38) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/commands/code.d.ts +8 -2
  3. package/dist/commands/code.js +474 -191
  4. package/dist/commands/code.js.map +1 -1
  5. package/dist/commands/config.js +2 -0
  6. package/dist/commands/config.js.map +1 -1
  7. package/dist/index.js +2 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/lib/code-agent-files.d.ts +9 -1
  10. package/dist/lib/code-agent-files.js +37 -10
  11. package/dist/lib/code-agent-files.js.map +1 -1
  12. package/dist/lib/code-agent-gateway.js +2 -0
  13. package/dist/lib/code-agent-gateway.js.map +1 -1
  14. package/dist/lib/code-agent-git-tools.d.ts +56 -0
  15. package/dist/lib/code-agent-git-tools.js +212 -0
  16. package/dist/lib/code-agent-git-tools.js.map +1 -0
  17. package/dist/lib/code-agent-protocol.d.ts +8 -0
  18. package/dist/lib/code-agent-protocol.js.map +1 -1
  19. package/dist/lib/code-agent-session.d.ts +9 -1
  20. package/dist/lib/code-agent-session.js +58 -21
  21. package/dist/lib/code-agent-session.js.map +1 -1
  22. package/dist/lib/code-agent-settings.d.ts +24 -1
  23. package/dist/lib/code-agent-settings.js +53 -4
  24. package/dist/lib/code-agent-settings.js.map +1 -1
  25. package/dist/lib/code-agent-subagents.d.ts +105 -0
  26. package/dist/lib/code-agent-subagents.js +179 -0
  27. package/dist/lib/code-agent-subagents.js.map +1 -0
  28. package/dist/lib/code-agent-tools.js +16 -0
  29. package/dist/lib/code-agent-tools.js.map +1 -1
  30. package/dist/lib/code-agent-tui.js +44 -9
  31. package/dist/lib/code-agent-tui.js.map +1 -1
  32. package/dist/lib/code-agent-types.d.ts +51 -0
  33. package/dist/lib/code-agent-ui.js +15 -1
  34. package/dist/lib/code-agent-ui.js.map +1 -1
  35. package/dist/lib/config.d.ts +1 -0
  36. package/dist/lib/config.js +1 -0
  37. package/dist/lib/config.js.map +1 -1
  38. package/package.json +1 -1
@@ -9,13 +9,17 @@ import { log } from '../utils/logger.js';
9
9
  import { setSafeExitCode } from '../utils/safe-exit.js';
10
10
  import { parseToolRequest, extractPatch } from '../lib/code-agent-protocol.js';
11
11
  import { applyUnifiedPatch, getGitDiff, getProjectOverview, listFiles, readFileContext, renderColoredDiff, runCodeTool, truncateForGateway, } from '../lib/code-agent-files.js';
12
- import { compactCodeHistory, compactHistoryToFit, MAX_HISTORY_TOTAL_CHARS, saveCodeSession, listCodeSessions, loadCodeSession, logAppliedPatch, CodeAgentStateMachine, } from '../lib/code-agent-session.js';
13
- import { DEFAULT_CODE_MAX_TOKENS, DEFAULT_CODE_MAX_TURNS, DEFAULT_CODE_TEMPERATURE, parseBoundedNumber, resolveCodeModel, resolveCodeProvider, } from '../lib/code-agent-settings.js';
12
+ import { compactCodeHistory, compactHistoryToFit, MAX_HISTORY_TOTAL_CHARS, saveCodeSession, autoSaveCodeSession, listCodeSessions, loadCodeSession, logAppliedPatch, CodeAgentStateMachine, } from '../lib/code-agent-session.js';
13
+ import { DEFAULT_CODE_MAX_TOKENS, DEFAULT_CODE_MAX_TURNS, DEFAULT_CODE_TEMPERATURE, ULTRACODE_EFFORT, ULTRACODE_MAX_TOKENS, ULTRACODE_MAX_TURNS, parseBoundedNumber, resolveCodeEffort, resolveCodeModel, resolveCodeProvider, } from '../lib/code-agent-settings.js';
14
14
  import { callGateway, streamGatewayChat, AI_GATEWAY_CHAT_PATH } from '../lib/code-agent-gateway.js';
15
+ import { CliApiError } from '../lib/api-client.js';
15
16
  import { buildSystemPrompt } from '../lib/code-agent-tools.js';
16
- import { printBox, printEvent, printAssistantText, printPatchPreview, patchSummaryRows, renderHeader, renderInteractiveHelp, renderSessionStatus, renderPromptLabel, printFileSample, printFilePicker, printSessionList, startTurnSpinner, summarizeToolResult, toolDetail, } from '../lib/code-agent-ui.js';
17
+ import { printBox, printEvent, printAssistantText, printPatchPreview, patchSummaryRows, renderHeader, renderInteractiveHelp, renderSessionStatus, renderPromptLabel, printFileSample, printFilePicker, printSessionList, startTurnSpinner, summarizeToolResult, } from '../lib/code-agent-ui.js';
17
18
  import { runInkSession } from '../lib/code-agent-tui.js';
19
+ import { configureSubagentRunner, startSubagent, stopSubagent, stopAllSubagents, listSubagents, getSubagentResult, getApplicablePatch, onSubagentSettled, } from '../lib/code-agent-subagents.js';
18
20
  const SYSTEM_PROMPT = buildSystemPrompt();
21
+ /** Spinner inerte pro modo silencioso (subagentes) — nunca escreve no terminal. */
22
+ const NOOP_TURN_SPINNER = { update: () => { }, stop: () => { } };
19
23
  function asOptionalString(value) {
20
24
  return typeof value === 'string' && value.trim() ? value.trim() : undefined;
21
25
  }
@@ -38,23 +42,25 @@ export function resolveSettings(opts) {
38
42
  envModel: process.env.NEETRU_CODE_MODEL,
39
43
  configuredGatewayModel: config.get('gatewayModel'),
40
44
  });
41
- const provider = resolveCodeProvider(asOptionalString(opts.provider), config.get('gatewayProvider'));
45
+ const provider = resolveCodeProvider(asOptionalString(opts.provider), config.get('gatewayProvider'), model.model);
46
+ const ultracode = opts.ultracode === true;
47
+ const effort = ultracode
48
+ ? ULTRACODE_EFFORT
49
+ : resolveCodeEffort(asOptionalString(opts.effort), config.get('gatewayEffort'));
42
50
  return {
43
51
  cwd,
44
52
  files: normalizeFiles(opts.file),
45
53
  model: model.model,
46
54
  provider,
47
55
  modelWarning: model.warning,
48
- maxTurns: parseBoundedNumber(opts.maxTurns, DEFAULT_CODE_MAX_TURNS, {
49
- min: 1,
50
- max: 30,
51
- integer: true,
52
- }),
53
- maxTokens: parseBoundedNumber(opts.maxTokens, DEFAULT_CODE_MAX_TOKENS, {
54
- min: 256,
55
- max: 32_768,
56
- integer: true,
57
- }),
56
+ // GAP-M1-18: `--ultracode` sobrepoe `--max-turns`/`--max-tokens`
57
+ // explicitos — e um preset "tudo pra cima", nao so um default alternativo.
58
+ maxTurns: ultracode
59
+ ? ULTRACODE_MAX_TURNS
60
+ : parseBoundedNumber(opts.maxTurns, DEFAULT_CODE_MAX_TURNS, { min: 1, max: 30, integer: true }),
61
+ maxTokens: ultracode
62
+ ? ULTRACODE_MAX_TOKENS
63
+ : parseBoundedNumber(opts.maxTokens, DEFAULT_CODE_MAX_TOKENS, { min: 256, max: 32_768, integer: true }),
58
64
  temperature: parseBoundedNumber(opts.temperature, DEFAULT_CODE_TEMPERATURE, {
59
65
  min: 0,
60
66
  max: 2,
@@ -62,6 +68,8 @@ export function resolveSettings(opts) {
62
68
  apply: opts.apply !== false && opts.plan !== true,
63
69
  yes: opts.yes === true,
64
70
  timeoutMs: 120_000,
71
+ effort,
72
+ ultracode,
65
73
  };
66
74
  }
67
75
  async function runReviewCommand(settings, confirm, history) {
@@ -107,34 +115,38 @@ async function defaultConfirm(question) {
107
115
  }
108
116
  }
109
117
  /**
110
- * Chama o AI Gateway mostrando progresso ao vivo spinner animado + cor
111
- * rotativa + tempo decorrido (`startTurnSpinner`, code-agent-ui.ts) em vez de
112
- * ficar estatico durante respostas longas. Cobre TANTO o streaming quanto o
113
- * fallback sem stream (antes o fallback ficava mudo do aviso ate a resposta
114
- * chegar ou o timeout estourar). So anima em TTY real em saida
115
- * nao-interativa (pipe/CI/teste) isso so poluiria o log.
118
+ * Chama o AI Gateway atualizando o `spinner` compartilhado da tarefa inteira
119
+ * (nao cria um spinner proprio por chamada) o indicador de progresso fica
120
+ * vivo e continuo entre turnos, so o `detail` muda. Cobre TANTO o streaming
121
+ * quanto o fallback sem stream (antes o fallback ficava mudo do aviso ate a
122
+ * resposta chegar ou o timeout estourar). So anima em TTY real (no-op em
123
+ * pipe/CI/teste, `startTurnSpinner` ja trata isso).
116
124
  *
117
125
  * Fallback: qualquer falha no streaming (rede, formato inesperado) refaz a
118
126
  * chamada sem streaming — a corretude do turno nunca depende do streaming
119
- * funcionar, so a experiencia visual.
127
+ * funcionar, so a experiencia visual. O aviso de fallback e um evento REAL
128
+ * (nao progresso normal), entao para o spinner pra nao interlear com o
129
+ * `printEvent` e abre um novo pra continuar depois.
120
130
  */
121
- async function callGatewayWithProgress(settings, messages) {
122
- const spinner = startTurnSpinner('AI Gateway');
131
+ async function callGatewayWithProgress(settings, messages, spinner, baseDetail, silent = false) {
123
132
  try {
124
133
  const content = await streamGatewayChat(settings, messages, (_delta, total) => {
125
- spinner.update(`${total} char(s) recebido(s)`);
134
+ spinner.update(`${baseDetail} · ${total} char(s) recebido(s)`);
126
135
  });
127
- spinner.stop();
128
- return content;
136
+ return { content, spinner };
129
137
  }
130
138
  catch (error) {
131
139
  spinner.stop();
132
- printEvent('warn', 'Streaming falhou, tentando sem streaming', error instanceof Error ? error.message : String(error));
133
- const fallbackSpinner = startTurnSpinner('AI Gateway (sem stream)');
140
+ // silent (subagente): sem aviso de fallback e sem novo spinner a saida
141
+ // visual pertence so a tarefa em primeiro plano.
142
+ if (!silent) {
143
+ printEvent('warn', 'Streaming falhou, tentando sem streaming', error instanceof Error ? error.message : String(error));
144
+ }
145
+ const fallbackSpinner = silent ? NOOP_TURN_SPINNER : startTurnSpinner('neetru code');
146
+ fallbackSpinner.update(`${baseDetail} · sem stream`);
134
147
  try {
135
148
  const content = await callGateway(settings, messages);
136
- fallbackSpinner.stop();
137
- return content;
149
+ return { content, spinner: fallbackSpinner };
138
150
  }
139
151
  catch (fallbackError) {
140
152
  fallbackSpinner.stop();
@@ -142,6 +154,41 @@ async function callGatewayWithProgress(settings, messages) {
142
154
  }
143
155
  }
144
156
  }
157
+ // GAP-M1-21 (2026-07-11): achado em uso real via Firestore — as 3 keys Groq
158
+ // do pool entraram em cooldown SIMULTANEO (429 do proprio Groq — rate-limit
159
+ // de conta, nao bug de roteamento) com `cooldownUntil` ~60-90s no futuro
160
+ // (backoff exponencial minimo de `reportKeyThrottle` no Core e 60s). Sem
161
+ // nenhuma key Groq elegivel, `selectKey` devolve pool vazio e o Core responde
162
+ // 503 `ai_unavailable` na hora — o usuario batia nisso e precisava mandar a
163
+ // mesma mensagem de novo manualmente. Retry curto e automatico SO pra esse
164
+ // erro especifico (nunca pra payload/auth/etc, que retry nao resolve).
165
+ const POOL_RETRY_DELAYS_MS = [20_000, 40_000];
166
+ export function isProviderPoolUnavailable(error) {
167
+ return error instanceof CliApiError && error.status === 503 && error.code === 'ai_unavailable';
168
+ }
169
+ function delay(ms, signal) {
170
+ return new Promise((resolve) => {
171
+ const timer = setTimeout(resolve, ms);
172
+ signal?.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
173
+ });
174
+ }
175
+ export async function callGatewayWithProgressRetrying(settings, messages, spinner, baseDetail, silent, signal) {
176
+ for (let attempt = 0;; attempt++) {
177
+ try {
178
+ return await callGatewayWithProgress(settings, messages, spinner, baseDetail, silent);
179
+ }
180
+ catch (error) {
181
+ if (!isProviderPoolUnavailable(error) || attempt >= POOL_RETRY_DELAYS_MS.length || signal?.aborted) {
182
+ throw error;
183
+ }
184
+ const waitMs = POOL_RETRY_DELAYS_MS[attempt];
185
+ spinner.update(`${baseDetail} · pool de IA sem capacidade, tentando de novo em ${Math.round(waitMs / 1000)}s`);
186
+ await delay(waitMs, signal);
187
+ if (signal?.aborted)
188
+ throw error;
189
+ }
190
+ }
191
+ }
145
192
  /**
146
193
  * `history` e mutado e reaproveitado entre chamadas: e o que da ao modo
147
194
  * interativo memoria de conversa entre turnos do REPL (como Claude Code/Codex),
@@ -155,10 +202,16 @@ async function callGatewayWithProgress(settings, messages) {
155
202
  * AI Gateway inteiro. Cada chamada de runAgentTask e um ciclo completo
156
203
  * (comeca e termina em idle/applied/failed/aborted).
157
204
  */
158
- export async function runAgentTask(task, settings, confirm, history = [], onState) {
205
+ export async function runAgentTask(task, settings, confirm, history = [], onState, options = {}) {
159
206
  if (!getActiveTokenSync()) {
160
207
  throw new Error('Sessao Neetru ausente ou expirada. Rode `neetru login`.');
161
208
  }
209
+ const { silent = false, signal } = options;
210
+ // silent (subagente): nada vai pro terminal enquanto roda — o resultado é
211
+ // CAPTURADO no retorno e consultado depois. `emit` vira no-op e o spinner é
212
+ // inerte, senão a saída colidiria com o spinner da tarefa em primeiro plano.
213
+ const emit = silent ? (() => { }) : printEvent;
214
+ const makeSpinner = () => (silent ? NOOP_TURN_SPINNER : startTurnSpinner('neetru code'));
162
215
  const machine = new CodeAgentStateMachine();
163
216
  const goto = (next) => {
164
217
  machine.transition(next);
@@ -172,8 +225,23 @@ export async function runAgentTask(task, settings, confirm, history = [], onStat
172
225
  role: 'user',
173
226
  content: isFirstTurn ? buildInitialPrompt(task, settings) : task,
174
227
  });
228
+ // Um unico spinner vivo pra tarefa inteira (nao uma linha por evento) —
229
+ // achado de uso real: turno/tool-call/tool-result como linhas permanentes
230
+ // poluiam o historico. O detail acumula "turno X/Y" + estagio atual;
231
+ // avisos/erros REAIS (compactacao, fallback de streaming) continuam
232
+ // aparecendo como linha permanente via printEvent, pausando o spinner
233
+ // pra nao interlear as duas saidas.
234
+ let spinner = makeSpinner();
175
235
  try {
176
236
  for (let turn = 1; turn <= settings.maxTurns; turn++) {
237
+ // Cancelamento cooperativo: verificado na fronteira de cada turno. A
238
+ // chamada de rede em voo tem timeout proprio (code-agent-gateway.ts), que
239
+ // este modulo nao controla sem editar aquele arquivo — o corte efetivo
240
+ // acontece antes de iniciar o proximo turno.
241
+ if (signal?.aborted) {
242
+ spinner.stop();
243
+ return { kind: 'aborted' };
244
+ }
177
245
  goto('thinking');
178
246
  // GAP-M1-15: auto-compact por TAMANHO antes de cada chamada — sem isso
179
247
  // uma sessao com varias tool results grandes (cada uma ate
@@ -183,7 +251,9 @@ export async function runAgentTask(task, settings, confirm, history = [], onStat
183
251
  // antes disso se quiser.
184
252
  const autoCompacted = compactHistoryToFit(messages, MAX_HISTORY_TOTAL_CHARS);
185
253
  if (autoCompacted > 0) {
186
- printEvent('warn', 'Historico compactado automaticamente', `${autoCompacted} mensagem(ns) antiga(s) removida(s)`);
254
+ spinner.stop();
255
+ emit('warn', 'Historico compactado automaticamente', `${autoCompacted} mensagem(ns) antiga(s) removida(s)`);
256
+ spinner = makeSpinner();
187
257
  }
188
258
  // Rede de seguranca contra loop de tool-calls que nunca conclui (achado
189
259
  // em producao: "oi" gastou os 8 turnos so chamando tools e falhou com
@@ -196,16 +266,19 @@ export async function runAgentTask(task, settings, confirm, history = [], onStat
196
266
  'texto usando o que voce ja sabe — nao peca mais NEETRU_TOOL nem proponha patch.',
197
267
  });
198
268
  }
199
- printEvent('wait', 'AI Gateway', `turno ${turn}/${settings.maxTurns}`);
200
- const content = await callGatewayWithProgress(settings, messages);
269
+ const baseDetail = `turno ${turn}/${settings.maxTurns}`;
270
+ spinner.update(baseDetail);
271
+ const gatewayResult = await callGatewayWithProgressRetrying(settings, messages, spinner, baseDetail, silent, signal);
272
+ spinner = gatewayResult.spinner;
273
+ const content = gatewayResult.content;
201
274
  messages.push({ role: 'assistant', content });
202
275
  const tool = parseToolRequest(content);
203
276
  if (tool) {
204
277
  goto('tool_requested');
205
- printEvent('tool', tool.tool, toolDetail(tool));
278
+ spinner.update(`${baseDetail} · ${tool.tool}`);
206
279
  goto('tool_running');
207
- const result = runCodeTool(settings.cwd, tool);
208
- printEvent('ok', 'contexto local', summarizeToolResult(result));
280
+ const result = await runCodeTool(settings.cwd, tool, { settings, confirm });
281
+ spinner.update(`${baseDetail} · ${summarizeToolResult(result)}`);
209
282
  messages.push({
210
283
  role: 'user',
211
284
  content: truncateForGateway(`Resultado da tool ${tool.tool}:\n\n${result}`),
@@ -214,48 +287,191 @@ export async function runAgentTask(task, settings, confirm, history = [], onStat
214
287
  }
215
288
  const patch = extractPatch(content);
216
289
  if (patch) {
290
+ spinner.stop();
217
291
  goto('patch_ready');
218
- console.log();
219
- printPatchPreview(patch);
292
+ if (!silent) {
293
+ console.log();
294
+ printPatchPreview(patch);
295
+ }
220
296
  if (!settings.apply) {
221
- printEvent('warn', 'Patch nao aplicado', '--plan/--no-apply');
297
+ // Subagentes SEMPRE caem aqui (apply forçado a false): propõem o
298
+ // patch, nunca aplicam. Quem aplica é a sessão principal via /agents.
299
+ emit('warn', 'Patch nao aplicado', '--plan/--no-apply');
222
300
  goto('aborted');
223
- return;
301
+ return { kind: 'patch', patch };
224
302
  }
225
303
  const approved = settings.yes || (await (async () => {
226
304
  goto('awaiting_confirm');
227
305
  return confirm('\nAplicar este patch? [y/N] ');
228
306
  })());
229
307
  if (!approved) {
230
- printEvent('warn', 'Patch nao aplicado');
308
+ emit('warn', 'Patch nao aplicado');
231
309
  goto('aborted');
232
- return;
310
+ return { kind: 'aborted' };
233
311
  }
234
312
  applyUnifiedPatch(settings.cwd, patch);
235
313
  logAppliedPatch(settings.cwd, patchSummaryRows(patch)[0] ?? 'arquivos: nao detectado');
236
- printEvent('ok', 'Patch aplicado', 'git apply');
314
+ emit('ok', 'Patch aplicado', 'git apply');
237
315
  goto('applied');
238
- return;
316
+ return { kind: 'applied', patch };
317
+ }
318
+ spinner.stop();
319
+ if (!silent) {
320
+ console.log();
321
+ printAssistantText(content);
239
322
  }
240
- console.log();
241
- printAssistantText(content);
242
323
  goto('idle');
243
- return;
324
+ return { kind: 'text', text: content };
244
325
  }
326
+ spinner.stop();
245
327
  goto('failed');
246
328
  throw new Error(`Limite de turnos atingido (${settings.maxTurns}) sem resposta final.`);
247
329
  }
248
330
  catch (error) {
331
+ spinner.stop();
249
332
  if (machine.state !== 'failed' && machine.state !== 'aborted' && machine.state !== 'applied') {
250
333
  goto('failed');
251
334
  }
252
335
  throw error;
253
336
  }
254
337
  }
338
+ // O REPL (runInteractive) chama startSubagent; o orquestrador chama de volta
339
+ // runAgentTask via este runner injetado. Injeção (não import) mantém o grafo de
340
+ // imports unidirecional: commands/code.ts -> code-agent-subagents.ts, sem ciclo.
341
+ configureSubagentRunner(runAgentTask);
255
342
  function printFileSampleForSettings(settings) {
256
343
  const all = listFiles(settings.cwd);
257
344
  printFileSample(all.slice(0, 60), all.length);
258
345
  }
346
+ function formatSubagentDuration(s) {
347
+ const end = s.finishedAt ?? Date.now();
348
+ const secs = Math.max(0, Math.round((end - s.startedAt) / 1000));
349
+ return `${secs}s`;
350
+ }
351
+ function formatSubagentRow(s) {
352
+ const patchTag = s.hasPatch ? ' (patch)' : '';
353
+ return `#${s.id} [${s.status}] ${formatSubagentDuration(s)}${patchTag} — ${s.task}`;
354
+ }
355
+ /**
356
+ * `/agents ...` — controla os subagentes (fan-out de subtarefas). Todo o texto,
357
+ * nomenclatura e fluxo aqui é próprio; a inspiração é só o princípio geral de
358
+ * orquestração (disparar, listar, parar, coletar, aplicar sob confirmação).
359
+ *
360
+ * `apply` reaproveita EXATAMENTE o mesmo caminho de confirmação de um patch
361
+ * normal (preview + confirm + applyUnifiedPatch + logAppliedPatch) — um patch de
362
+ * subagente nunca é aplicado automaticamente.
363
+ */
364
+ async function runAgentsCommand(line, settings, confirm) {
365
+ const afterCmd = line.trim().slice('/agents'.length).trim();
366
+ const spaceIdx = afterCmd.indexOf(' ');
367
+ const sub = (spaceIdx === -1 ? afterCmd : afterCmd.slice(0, spaceIdx)).toLowerCase();
368
+ const arg = spaceIdx === -1 ? '' : afterCmd.slice(spaceIdx + 1).trim();
369
+ const idArg = arg.replace(/^#/, '').trim();
370
+ switch (sub) {
371
+ case '':
372
+ case 'list': {
373
+ const all = listSubagents();
374
+ if (all.length === 0) {
375
+ printEvent('ok', 'Nenhum subagente', 'use /agents start <tarefa>');
376
+ return;
377
+ }
378
+ printBox('subagentes', all.map(formatSubagentRow));
379
+ return;
380
+ }
381
+ case 'start': {
382
+ if (!arg) {
383
+ printEvent('warn', 'Uso', '/agents start <tarefa>');
384
+ return;
385
+ }
386
+ try {
387
+ const id = startSubagent(arg, settings);
388
+ printEvent('ok', 'Subagente iniciado', `#${id}: ${arg}`);
389
+ }
390
+ catch (error) {
391
+ printEvent('error', 'Nao foi possivel iniciar subagente', error instanceof Error ? error.message : String(error));
392
+ }
393
+ return;
394
+ }
395
+ case 'stop': {
396
+ if (!idArg) {
397
+ printEvent('warn', 'Uso', '/agents stop <id>');
398
+ return;
399
+ }
400
+ const ok = stopSubagent(idArg);
401
+ printEvent(ok ? 'ok' : 'warn', ok ? 'Sinal de parada enviado' : 'Subagente nao esta em execucao', `#${idArg}`);
402
+ return;
403
+ }
404
+ case 'result': {
405
+ if (!idArg) {
406
+ printEvent('warn', 'Uso', '/agents result <id>');
407
+ return;
408
+ }
409
+ const record = getSubagentResult(idArg);
410
+ if (!record) {
411
+ printEvent('warn', 'Subagente nao encontrado', `#${idArg}`);
412
+ return;
413
+ }
414
+ if (record.status === 'running') {
415
+ printEvent('ok', 'Ainda em execucao', `#${idArg}`);
416
+ return;
417
+ }
418
+ if (record.status === 'stopped') {
419
+ printEvent('warn', 'Subagente parado', `#${idArg}`);
420
+ return;
421
+ }
422
+ if (record.error || record.status === 'failed') {
423
+ printEvent('error', 'Subagente falhou', record.error ?? `#${idArg}`);
424
+ return;
425
+ }
426
+ const outcome = record.outcome;
427
+ if (outcome?.kind === 'patch' && outcome.patch) {
428
+ printEvent('ok', 'Patch proposto', `#${idArg} — /agents apply ${idArg} pra aplicar`);
429
+ printPatchPreview(outcome.patch);
430
+ }
431
+ else if (outcome?.kind === 'text') {
432
+ printAssistantText(outcome.text ?? '(sem texto)');
433
+ }
434
+ else {
435
+ printEvent('warn', 'Sem resultado aplicavel', `#${idArg}`);
436
+ }
437
+ return;
438
+ }
439
+ case 'apply': {
440
+ if (!idArg) {
441
+ printEvent('warn', 'Uso', '/agents apply <id>');
442
+ return;
443
+ }
444
+ const record = getSubagentResult(idArg);
445
+ if (!record) {
446
+ printEvent('warn', 'Subagente nao encontrado', `#${idArg}`);
447
+ return;
448
+ }
449
+ const patch = getApplicablePatch(idArg);
450
+ if (!patch) {
451
+ printEvent('warn', 'Nada pra aplicar', `#${idArg} (${record.status}${record.outcome ? `, ${record.outcome.kind}` : ''})`);
452
+ return;
453
+ }
454
+ console.log();
455
+ printPatchPreview(patch);
456
+ const approved = settings.yes || (await confirm('\nAplicar patch do subagente? [y/N] '));
457
+ if (!approved) {
458
+ printEvent('warn', 'Patch nao aplicado');
459
+ return;
460
+ }
461
+ try {
462
+ applyUnifiedPatch(settings.cwd, patch);
463
+ logAppliedPatch(settings.cwd, patchSummaryRows(patch)[0] ?? 'arquivos: nao detectado');
464
+ printEvent('ok', 'Patch aplicado', `git apply · subagente #${idArg}`);
465
+ }
466
+ catch (error) {
467
+ printEvent('error', 'Falha ao aplicar patch', error instanceof Error ? error.message : String(error));
468
+ }
469
+ return;
470
+ }
471
+ default:
472
+ printEvent('warn', 'Subcomando desconhecido', 'use: start | list | stop | result | apply');
473
+ }
474
+ }
259
475
  async function runInteractive(settings) {
260
476
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
261
477
  throw new Error('Modo interativo exige terminal TTY. Use: neetru code "tarefa".');
@@ -265,171 +481,238 @@ async function runInteractive(settings) {
265
481
  const { resolveSessionEmail } = await import('../lib/auth.js');
266
482
  const sessionEmail = await resolveSessionEmail().catch(() => null);
267
483
  let history = [];
268
- await runInkSession({
269
- initialPromptLabel: renderPromptLabel(settings),
270
- statusLine: '/help comandos · /exit sai · Ctrl+C cancela',
271
- onStart: () => {
272
- log.banner(sessionEmail);
273
- renderHeader(settings, 'interactive');
274
- if (settings.modelWarning)
275
- log.warn(settings.modelWarning);
276
- },
277
- onLine: async (line, ctx) => {
278
- const lower = line.toLowerCase();
279
- if (lower === '/exit' || lower === '/sair' || lower === 'exit' || lower === 'quit')
280
- return 'exit';
281
- if (lower === '/clear') {
282
- history = [];
484
+ // Notificação de conclusão de subagente: UMA linha permanente quando termina
485
+ // (sucesso/falha/parado). Enquanto rodam, subagentes são silenciosos — o
486
+ // LineBuffer do Ink commita esta única linha acima do prompt/spinner sem
487
+ // interlear. Registrado 1x por sessão; cancelado ao sair.
488
+ const unsubscribeSettled = onSubagentSettled((s) => {
489
+ if (s.status === 'done') {
490
+ printEvent('ok', s.hasPatch ? 'Subagente concluiu (patch proposto)' : 'Subagente concluiu', s.hasPatch ? `#${s.id} — /agents apply ${s.id}` : `#${s.id} — /agents result ${s.id}`);
491
+ }
492
+ else if (s.status === 'stopped') {
493
+ printEvent('warn', 'Subagente parado', `#${s.id}`);
494
+ }
495
+ else {
496
+ printEvent('error', 'Subagente falhou', `#${s.id} — /agents result ${s.id}`);
497
+ }
498
+ });
499
+ try {
500
+ await runInkSession({
501
+ initialPromptLabel: renderPromptLabel(settings),
502
+ statusLine: '/help comandos · /exit sai · Ctrl+C cancela',
503
+ onStart: () => {
504
+ log.banner(sessionEmail);
283
505
  renderHeader(settings, 'interactive');
284
- printEvent('ok', 'Conversa limpa');
285
- return 'continue';
286
- }
287
- if (lower === '/files') {
288
- printFileSampleForSettings(settings);
289
- return 'continue';
290
- }
291
- if (lower === '/pick') {
292
- const all = listFiles(settings.cwd);
293
- const shown = all.slice(0, 60);
294
- printFilePicker(shown, all.length);
295
- if (shown.length === 0)
506
+ if (settings.modelWarning)
507
+ log.warn(settings.modelWarning);
508
+ },
509
+ onLine: async (line, ctx) => {
510
+ const lower = line.toLowerCase();
511
+ if (lower === '/exit' || lower === '/sair' || lower === 'exit' || lower === 'quit')
512
+ return 'exit';
513
+ if (lower === '/agents' || lower.startsWith('/agents ')) {
514
+ await runAgentsCommand(line, settings, async (question) => {
515
+ const answer = await ctx.askLine(question);
516
+ return /^(y|yes|s|sim)$/i.test(answer.trim());
517
+ });
296
518
  return 'continue';
297
- const answer = await ctx.askLine('Numero(s) separado(s) por virgula (Enter cancela): ');
298
- const raw = answer.trim();
299
- if (!raw) {
300
- printEvent('warn', 'Cancelado');
519
+ }
520
+ if (lower === '/clear') {
521
+ history = [];
522
+ renderHeader(settings, 'interactive');
523
+ printEvent('ok', 'Conversa limpa');
301
524
  return 'continue';
302
525
  }
303
- const picked = [];
304
- for (const part of raw.split(',')) {
305
- const idx = Number.parseInt(part.trim(), 10);
306
- if (Number.isInteger(idx) && idx >= 1 && idx <= shown.length) {
307
- picked.push(shown[idx - 1]);
308
- }
526
+ if (lower === '/files') {
527
+ printFileSampleForSettings(settings);
528
+ return 'continue';
309
529
  }
310
- if (picked.length === 0) {
311
- printEvent('warn', 'Nenhum arquivo valido selecionado');
530
+ if (lower === '/pick') {
531
+ const all = listFiles(settings.cwd);
532
+ const shown = all.slice(0, 60);
533
+ printFilePicker(shown, all.length);
534
+ if (shown.length === 0)
535
+ return 'continue';
536
+ const answer = await ctx.askLine('Numero(s) separado(s) por virgula (Enter cancela): ');
537
+ const raw = answer.trim();
538
+ if (!raw) {
539
+ printEvent('warn', 'Cancelado');
540
+ return 'continue';
541
+ }
542
+ const picked = [];
543
+ for (const part of raw.split(',')) {
544
+ const idx = Number.parseInt(part.trim(), 10);
545
+ if (Number.isInteger(idx) && idx >= 1 && idx <= shown.length) {
546
+ picked.push(shown[idx - 1]);
547
+ }
548
+ }
549
+ if (picked.length === 0) {
550
+ printEvent('warn', 'Nenhum arquivo valido selecionado');
551
+ return 'continue';
552
+ }
553
+ const blocks = picked.map((file) => readFileContext(settings.cwd, file));
554
+ history.push({
555
+ role: 'user',
556
+ content: truncateForGateway(`Arquivos anexados manualmente via /pick:\n\n${blocks.join('\n\n')}`),
557
+ });
558
+ printEvent('ok', 'Arquivo(s) anexado(s) ao contexto', picked.join(', '));
312
559
  return 'continue';
313
560
  }
314
- const blocks = picked.map((file) => readFileContext(settings.cwd, file));
315
- history.push({
316
- role: 'user',
317
- content: truncateForGateway(`Arquivos anexados manualmente via /pick:\n\n${blocks.join('\n\n')}`),
318
- });
319
- printEvent('ok', 'Arquivo(s) anexado(s) ao contexto', picked.join(', '));
320
- return 'continue';
321
- }
322
- if (lower === '/overview' || lower === '/visao') {
323
- printBox('visao do projeto', getProjectOverview(settings.cwd).split(/\r?\n/));
324
- return 'continue';
325
- }
326
- if (lower === '/diff') {
327
- const diff = getGitDiff(settings.cwd);
328
- if (diff === '(sem diff)') {
329
- printEvent('ok', 'Sem diff local');
561
+ if (lower === '/overview' || lower === '/visao') {
562
+ printBox('visao do projeto', getProjectOverview(settings.cwd).split(/\r?\n/));
563
+ return 'continue';
330
564
  }
331
- else if (diff.startsWith('ERRO:')) {
332
- printEvent('error', 'git diff', diff);
565
+ if (lower === '/diff') {
566
+ const diff = getGitDiff(settings.cwd);
567
+ if (diff === '(sem diff)') {
568
+ printEvent('ok', 'Sem diff local');
569
+ }
570
+ else if (diff.startsWith('ERRO:')) {
571
+ printEvent('error', 'git diff', diff);
572
+ }
573
+ else {
574
+ printBox('diff atual', ['git diff -- .']);
575
+ process.stdout.write(renderColoredDiff(diff));
576
+ if (!diff.endsWith('\n'))
577
+ process.stdout.write('\n');
578
+ }
579
+ return 'continue';
333
580
  }
334
- else {
335
- printBox('diff atual', ['git diff -- .']);
336
- process.stdout.write(renderColoredDiff(diff));
337
- if (!diff.endsWith('\n'))
338
- process.stdout.write('\n');
581
+ if (lower === '/review') {
582
+ await runReviewCommand(settings, async (question) => {
583
+ const answer = await ctx.askLine(question);
584
+ return /^(y|yes|s|sim)$/i.test(answer.trim());
585
+ }, history);
586
+ return 'continue';
339
587
  }
340
- return 'continue';
341
- }
342
- if (lower === '/review') {
343
- await runReviewCommand(settings, async (question) => {
344
- const answer = await ctx.askLine(question);
345
- return /^(y|yes|s|sim)$/i.test(answer.trim());
346
- }, history);
347
- return 'continue';
348
- }
349
- if (lower === '/save') {
350
- const saved = saveCodeSession(settings, history);
351
- printEvent('ok', 'Sessao salva', saved);
352
- return 'continue';
353
- }
354
- if (lower === '/resume') {
355
- const sessions = listCodeSessions(settings.cwd);
356
- printSessionList(sessions);
357
- if (sessions.length === 0)
588
+ if (lower === '/save') {
589
+ const saved = saveCodeSession(settings, history);
590
+ printEvent('ok', 'Sessao salva', saved);
358
591
  return 'continue';
359
- if (history.length > 0) {
360
- const overwrite = await ctx.askLine('Conversa atual sera substituida. Continuar? [y/N] ');
361
- if (!/^(y|yes|s|sim)$/i.test(overwrite.trim())) {
592
+ }
593
+ if (lower === '/resume') {
594
+ const sessions = listCodeSessions(settings.cwd);
595
+ printSessionList(sessions);
596
+ if (sessions.length === 0)
597
+ return 'continue';
598
+ if (history.length > 0) {
599
+ const overwrite = await ctx.askLine('Conversa atual sera substituida. Continuar? [y/N] ');
600
+ if (!/^(y|yes|s|sim)$/i.test(overwrite.trim())) {
601
+ printEvent('warn', 'Cancelado');
602
+ return 'continue';
603
+ }
604
+ }
605
+ const answer = await ctx.askLine('Numero da sessao (Enter cancela): ');
606
+ const idx = Number.parseInt(answer.trim(), 10);
607
+ if (!Number.isInteger(idx) || idx < 1 || idx > sessions.length) {
362
608
  printEvent('warn', 'Cancelado');
363
609
  return 'continue';
364
610
  }
611
+ const picked = sessions[idx - 1];
612
+ try {
613
+ history = loadCodeSession(settings.cwd, picked.file);
614
+ printEvent('ok', 'Sessao retomada', `${picked.file} (${history.length} mensagem(ns))`);
615
+ }
616
+ catch (error) {
617
+ printEvent('error', 'Falha ao retomar sessao', error instanceof Error ? error.message : String(error));
618
+ }
619
+ return 'continue';
620
+ }
621
+ if (lower === '/compact') {
622
+ const removed = compactCodeHistory(history);
623
+ printEvent('ok', 'Historico compactado', `${removed} mensagem(ns) removida(s)`);
624
+ return 'continue';
625
+ }
626
+ if (lower === '/status' || lower === '/context') {
627
+ renderSessionStatus(settings, history);
628
+ return 'continue';
629
+ }
630
+ if (lower === '/model') {
631
+ printBox('modelo', [settings.model]);
632
+ return 'continue';
633
+ }
634
+ if (lower === '/effort' || lower.startsWith('/effort ')) {
635
+ const arg = line.trim().slice('/effort'.length).trim().toLowerCase();
636
+ if (!arg) {
637
+ printEvent('ok', 'Effort atual', settings.effort ?? 'default do provider');
638
+ return 'continue';
639
+ }
640
+ const next = resolveCodeEffort(arg);
641
+ if (!next) {
642
+ printEvent('warn', 'Nivel invalido', 'use: low | medium | high');
643
+ return 'continue';
644
+ }
645
+ settings.effort = next;
646
+ printEvent('ok', 'Effort atualizado', next);
647
+ return 'continue';
648
+ }
649
+ if (lower === '/ultracode') {
650
+ settings.ultracode = !settings.ultracode;
651
+ if (settings.ultracode) {
652
+ settings.maxTurns = ULTRACODE_MAX_TURNS;
653
+ settings.maxTokens = ULTRACODE_MAX_TOKENS;
654
+ settings.effort = ULTRACODE_EFFORT;
655
+ printEvent('ok', 'Ultracode ligado', `${ULTRACODE_MAX_TURNS} turnos · ${ULTRACODE_MAX_TOKENS} tokens · effort ${ULTRACODE_EFFORT}`);
656
+ }
657
+ else {
658
+ settings.maxTurns = DEFAULT_CODE_MAX_TURNS;
659
+ settings.maxTokens = DEFAULT_CODE_MAX_TOKENS;
660
+ settings.effort = resolveCodeEffort(undefined, config.get('gatewayEffort'));
661
+ printEvent('ok', 'Ultracode desligado', `voltou pro default (${DEFAULT_CODE_MAX_TURNS} turnos)`);
662
+ }
663
+ return 'continue';
664
+ }
665
+ if (lower === '/plan') {
666
+ settings.apply = false;
667
+ printEvent('ok', 'Modo plano', 'patches nao serao aplicados');
668
+ return 'continue';
365
669
  }
366
- const answer = await ctx.askLine('Numero da sessao (Enter cancela): ');
367
- const idx = Number.parseInt(answer.trim(), 10);
368
- if (!Number.isInteger(idx) || idx < 1 || idx > sessions.length) {
369
- printEvent('warn', 'Cancelado');
670
+ if (lower === '/apply') {
671
+ settings.apply = true;
672
+ settings.yes = false;
673
+ printEvent('ok', 'Modo aplicar', 'pedira confirmacao antes do git apply');
674
+ return 'continue';
675
+ }
676
+ if (lower === '/yes') {
677
+ settings.apply = true;
678
+ settings.yes = true;
679
+ printEvent('warn', 'Auto-aplicar ativo', 'patches validos serao aplicados sem pergunta');
680
+ return 'continue';
681
+ }
682
+ if (lower === '/no') {
683
+ settings.yes = false;
684
+ printEvent('ok', 'Confirmacao manual ativa');
685
+ return 'continue';
686
+ }
687
+ if (lower === '/help' || lower === '/ajuda') {
688
+ renderInteractiveHelp();
370
689
  return 'continue';
371
690
  }
372
- const picked = sessions[idx - 1];
373
691
  try {
374
- history = loadCodeSession(settings.cwd, picked.file);
375
- printEvent('ok', 'Sessao retomada', `${picked.file} (${history.length} mensagem(ns))`);
692
+ await runAgentTask(line, settings, async (question) => {
693
+ const answer = await ctx.askLine(question);
694
+ return /^(y|yes|s|sim)$/i.test(answer.trim());
695
+ }, history);
376
696
  }
377
697
  catch (error) {
378
- printEvent('error', 'Falha ao retomar sessao', error instanceof Error ? error.message : String(error));
698
+ printEvent('error', 'Falha', error instanceof Error ? error.message : String(error));
379
699
  }
700
+ // GAP-M1-18: checkpoint automatico apos cada turno (sucesso OU falha —
701
+ // a conversa ate aqui e valida de qualquer jeito) pra sessao longa
702
+ // sobreviver a fechar o terminal sem lembrar de `/save`. Silencioso
703
+ // (sem printEvent) e best-effort — nunca deve virar ruido visual nem
704
+ // interromper a sessao.
705
+ autoSaveCodeSession(settings, history);
380
706
  return 'continue';
381
- }
382
- if (lower === '/compact') {
383
- const removed = compactCodeHistory(history);
384
- printEvent('ok', 'Historico compactado', `${removed} mensagem(ns) removida(s)`);
385
- return 'continue';
386
- }
387
- if (lower === '/status' || lower === '/context') {
388
- renderSessionStatus(settings, history);
389
- return 'continue';
390
- }
391
- if (lower === '/model') {
392
- printBox('modelo', [settings.model]);
393
- return 'continue';
394
- }
395
- if (lower === '/plan') {
396
- settings.apply = false;
397
- printEvent('ok', 'Modo plano', 'patches nao serao aplicados');
398
- return 'continue';
399
- }
400
- if (lower === '/apply') {
401
- settings.apply = true;
402
- settings.yes = false;
403
- printEvent('ok', 'Modo aplicar', 'pedira confirmacao antes do git apply');
404
- return 'continue';
405
- }
406
- if (lower === '/yes') {
407
- settings.apply = true;
408
- settings.yes = true;
409
- printEvent('warn', 'Auto-aplicar ativo', 'patches validos serao aplicados sem pergunta');
410
- return 'continue';
411
- }
412
- if (lower === '/no') {
413
- settings.yes = false;
414
- printEvent('ok', 'Confirmacao manual ativa');
415
- return 'continue';
416
- }
417
- if (lower === '/help' || lower === '/ajuda') {
418
- renderInteractiveHelp();
419
- return 'continue';
420
- }
421
- try {
422
- await runAgentTask(line, settings, async (question) => {
423
- const answer = await ctx.askLine(question);
424
- return /^(y|yes|s|sim)$/i.test(answer.trim());
425
- }, history);
426
- }
427
- catch (error) {
428
- printEvent('error', 'Falha', error instanceof Error ? error.message : String(error));
429
- }
430
- return 'continue';
431
- },
432
- });
707
+ },
708
+ });
709
+ }
710
+ finally {
711
+ unsubscribeSettled();
712
+ // Ao sair da sessão, corta subagentes ainda em execução — sem isso ficariam
713
+ // órfãos consumindo o AI Gateway em background depois do REPL fechar.
714
+ stopAllSubagents();
715
+ }
433
716
  }
434
717
  function runDoctor(settings) {
435
718
  const token = getActiveTokenSync();