@neetru/cli 2.19.0 → 2.20.0

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