@neetru/cli 2.21.0 → 2.24.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 (48) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +17 -0
  3. package/dist/commands/code.js +239 -34
  4. package/dist/commands/code.js.map +1 -1
  5. package/dist/commands/dev-context.d.ts +28 -0
  6. package/dist/commands/dev-context.js +211 -0
  7. package/dist/commands/dev-context.js.map +1 -0
  8. package/dist/commands/dev-up.d.ts +2 -0
  9. package/dist/commands/dev-up.js +122 -18
  10. package/dist/commands/dev-up.js.map +1 -1
  11. package/dist/commands/dev.d.ts +3 -0
  12. package/dist/commands/dev.js +101 -25
  13. package/dist/commands/dev.js.map +1 -1
  14. package/dist/commands/env.d.ts +1 -1
  15. package/dist/commands/env.js +17 -10
  16. package/dist/commands/env.js.map +1 -1
  17. package/dist/index.js +58 -3
  18. package/dist/index.js.map +1 -1
  19. package/dist/lib/code-agent-budget.d.ts +67 -0
  20. package/dist/lib/code-agent-budget.js +80 -0
  21. package/dist/lib/code-agent-budget.js.map +1 -0
  22. package/dist/lib/code-agent-files.d.ts +1 -0
  23. package/dist/lib/code-agent-files.js +30 -9
  24. package/dist/lib/code-agent-files.js.map +1 -1
  25. package/dist/lib/code-agent-protocol.d.ts +5 -0
  26. package/dist/lib/code-agent-protocol.js +10 -5
  27. package/dist/lib/code-agent-protocol.js.map +1 -1
  28. package/dist/lib/code-agent-session.d.ts +1 -1
  29. package/dist/lib/code-agent-session.js +16 -1
  30. package/dist/lib/code-agent-session.js.map +1 -1
  31. package/dist/lib/code-agent-tools.js +40 -10
  32. package/dist/lib/code-agent-tools.js.map +1 -1
  33. package/dist/lib/code-agent-tui.d.ts +34 -0
  34. package/dist/lib/code-agent-tui.js +120 -6
  35. package/dist/lib/code-agent-tui.js.map +1 -1
  36. package/dist/lib/code-agent-types.d.ts +19 -1
  37. package/dist/lib/code-agent-ui.d.ts +7 -0
  38. package/dist/lib/code-agent-ui.js +44 -30
  39. package/dist/lib/code-agent-ui.js.map +1 -1
  40. package/dist/lib/code-agent-web-tools.js +141 -35
  41. package/dist/lib/code-agent-web-tools.js.map +1 -1
  42. package/dist/lib/code-agent-write-tools.d.ts +5 -0
  43. package/dist/lib/code-agent-write-tools.js +141 -0
  44. package/dist/lib/code-agent-write-tools.js.map +1 -0
  45. package/dist/lib/dev-local-context.d.ts +29 -0
  46. package/dist/lib/dev-local-context.js +114 -0
  47. package/dist/lib/dev-local-context.js.map +1 -0
  48. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,72 @@
2
2
 
3
3
  Convenção: [Keep a Changelog](https://keepachangelog.com/) + [SemVer](https://semver.org/).
4
4
 
5
+ ## [2.24.0] — 2026-07-13 (contexto dev-local: `neetru dev use/status/sync`)
6
+
7
+ > Trabalho do Codex nesta sessão (mesmo repo, frente separada do `neetru code`).
8
+
9
+ ### Adicionado
10
+
11
+ - **Contexto de projeto local** (`.neetru/dev.json`) com 2 modos: `core-product` (projeto ligado a produto/workspace do Core) e `prototype` (rascunho local ainda não cadastrado). Comandos novos: `neetru dev use --product <slug> [--sync]`, `neetru dev use --prototype <nome>`, `neetru dev status`, `neetru dev sync --write-db`.
12
+ - `dev up`/`dev exec` agora leem esse contexto automaticamente pra decidir `corePath`, emuladores padrão e mostrar em qual produto/protótipo a sessão está.
13
+ - `dev sync` busca workspace/tenant dev ativo e bancos dev-local do Core; `--write-db` mescla em `.neetru/db.json`.
14
+
15
+ ### Docs
16
+
17
+ - README documenta os 4 comandos novos + o arquivo `.neetru/dev.json`.
18
+
19
+ 49 testes novos passando, build limpo. Ver seção abaixo para o restante do que entrou nesta versão (Objective Runtime Controller + hardening de segurança do `neetru code`).
20
+
21
+ ## [2.23.0] — 2026-07-13 (`neetru code` — multi-tool por turno, auto-extensao silenciosa em auto mode, HOTFIX render loop no autocomplete)
22
+
23
+ > Sessão com trabalho concorrente Claude+Codex sobre o mesmo turn-loop de `runAgentTask`. Codex: múltiplas `NEETRU_TOOL` por resposta (leitura/pesquisa em lote, mutação continua 1 por vez), auto mode estende o limite de turnos automaticamente sem perguntar `[y/N]` (respeitando o mesmo teto `MAX_TURN_LIMIT_EXTENSIONS`), e `NEETRU_DONE`/`NEETRU_BLOCKED` sem texto não vazam mais crus na UI. Eu: HOTFIX de um crash real reportado ao vivo no autocomplete (2.22.0, ver abaixo).
24
+
25
+ ### Adicionado
26
+
27
+ - **Múltiplas ferramentas por resposta** — o modelo pode pedir até 5 `NEETRU_TOOL` na mesma resposta (`parseToolRequests`); leitura/pesquisa roda em lote, mas ferramentas que escrevem/executam/publicam (`write_file`, `git_*`, `shell_exec`, etc. — `SINGLE_ACTION_TOOLS`) continuam limitadas a uma por resposta, as demais voltam com erro claro pedindo pra tentar de novo no próximo turno.
28
+ - **Auto mode estende o limite de turnos sem perguntar** — dentro do mesmo teto `MAX_TURN_LIMIT_EXTENSIONS` (2) já existente; fora do auto mode continua perguntando `[y/N]` a um humano de verdade (ou falhando direto em subagente/não-interativo).
29
+
30
+ ### Corrigido
31
+
32
+ - **HOTFIX (2.22.0 → 2.22.1, achado em uso real ao vivo): "Maximum update depth exceeded" ao digitar `/agents` ou `/skills`** — o dropdown do autocomplete usava `key={s.word}` no JSX, mas vários comandos compartilham a mesma 1ª palavra (`/agents start|list|stop|result|apply` todos têm `word="/agents"`; `/skills`/`/skills init`/`/skills show` idem). Key duplicada disparava o warning de dev do React; como `console.error` é redirecionado pro `LineBuffer` (que chama `setState`), o próprio warning disparava outro render, que warnava de novo — loop infinito, painel travava ao abrir. Corrigido pra `key={s.cmd}` (esse sim único em toda a lista) + teste de regressão garantindo que `cmd` nunca colide.
33
+ - `NEETRU_DONE`/`NEETRU_BLOCKED` sem nenhum texto depois do marcador não vazam mais crus pro usuário — caem em "Concluido."/"Bloqueado." como fallback.
34
+ - **Ainda não estava corrigido apesar da alegação**: a redação "NEETRU_DONE se concluiu o máximo possível" (última chamada, quando o limite de turnos se aproxima) misturava "fiz o que deu" com "objetivo realmente concluído" — o modelo podia marcar uma tarefa incompleta como terminada só por ter ficado sem turnos. `NEETRU_DONE` agora exige conclusão REAL; sem isso, cai em texto normal resumindo o progresso.
35
+
36
+ ### Segurança (achados de revisão de segurança sobre os commits desta sessão)
37
+
38
+ - **HIGH — escrita através de symlink escapava do projeto**: `resolveInside` só revalidava o realpath quando o ALVO já existia; `write_file`/`append_file` para um arquivo NOVO dentro de uma pasta symlinkada apontando pra fora do cwd escapavam da checagem por completo (o arquivo-alvo em si nunca existe antes da escrita). Corrigido pra resolver o realpath do ancestral EXISTENTE mais próximo do alvo, não só do alvo.
39
+ - **HIGH — SSRF via redirecionamento em `web_fetch`**: validava só a URL inicial e seguia redirecionamento automaticamente (`redirect: 'follow'`); um domínio público podia redirecionar pra `169.254.169.254`/localhost/IP privado sem revalidar. Trocado pra `redirect: 'manual'` com revalidação a cada salto (máx. 5) + resolução de DNS também validada contra rede privada/link-local (best-effort — não é prova contra DNS rebinding sem um dispatcher fixando o IP).
40
+ - **Médio — `web_fetch` só checava o limite de tamanho DEPOIS de carregar o corpo inteiro**: `content-length` era validado, mas a resposta inteira já tinha sido lida antes da checagem de tamanho real disparar. Agora lê em chunks e aborta assim que passa do limite.
41
+
42
+ ### Adicionado — Objective Runtime Controller (`code-agent-budget.ts`, novo módulo)
43
+
44
+ > Pedido direto do usuário após um transcript real de sessão travada: o modelo ficava repetindo "avanço parcial" em modo objetivo sem nunca chamar tool nem propor patch, e `settings.maxTurns` — um CONTADOR, não um sinal de progresso — não enxergava isso. Design pesquisado (arquitetura documentada do Claude Code: modos de permissão, loop context→ação→verificação, bloqueio-com-motivo em vez de `[y/N]`) e sintetizado via 3 propostas independentes + 3 rodadas de verificação adversarial antes de implementar — a 1ª síntese tinha 4 problemas reais (edge de estado faltando, promessa falsa de segurança do tsc + bug real de `pendingPlanTask`, colisão com o mecanismo de patch-repair já testado, import circular), todos corrigidos e reverificados antes do código ser escrito.
45
+
46
+ - **2 novas dimensões de orçamento, ortogonais a `maxTurns`**: `noProgress` (turnos consecutivos em modo objetivo sem tool call nem patch — o modelo só descreve avanço) e `repeatedErrors` (total CUMULATIVO de patches inválidos na sessão inteira — nunca reseta, ao contrário do `patchRepairStreak` local que já existia e continua intacto; pega o padrão que o mecanismo antigo não pega: erra 3x, muda de estratégia, reseta, erra 3x de novo, indefinidamente sem convergir).
47
+ - **Novo outcome `budget_exceeded`** em `AgentTaskOutcome` — nunca se estende (nem em auto mode, diferente do limite de turnos): as duas dimensões são sinal de que o modelo não está CONVERGINDO, não de que faltou tempo.
48
+ - **Checkpoint roda logo após `goto('thinking')`** no loop — só precisou de 1 edge nova na máquina de estados (`thinking -> budget_exceeded`) em vez de enumerar os 3 estados alcançáveis no topo do loop, porque todos já têm edge legal pra `thinking` hoje.
49
+ - `MAX_CONSECUTIVE_PATCH_REPAIRS`/`MAX_TOOLS_PER_TURN`/`SINGLE_ACTION_TOOLS` migraram de `commands/code.ts` pra `code-agent-budget.ts` (evita um import circular: o novo módulo não pode importar essas constantes DE VOLTA de `code.ts` já que `code.ts` importa `decideBudgetAction`/`BudgetTracker` de lá).
50
+ - Os dois pontos que comparavam `outcome.kind` com `if/else` (não pegos por `tsc` — nenhum é um `switch` exaustivo hoje) viraram `switch` com `const _exhaustive: never = outcome.kind` no `default`, fechando de vez um bug real e independente: em modo plano, um outcome `'aborted'` deixava `pendingPlanTask` num estado obsoleto (nem atualizado, nem limpo) — `/approve` executaria um plano errado depois. Corrigido preservando um plano anterior ainda válido quando o turno atual não produziu nem plano nem execução real.
51
+
52
+ 1051 testes passando, tsc limpo (isolado das mudanças desta sessão — um arquivo novo e não relacionado de outra frente de trabalho em andamento no mesmo repo tem 2 erros de tipo pré-existentes, fora do escopo desta mudança).
53
+
54
+ > Feedback direto do uso real (transcript colado pelo usuário): um modelo preso reescrevendo o mesmo diff quebrado gastava o orçamento de turnos INTEIRO em correções de patch e a sessão inteira falhava com "Limite de turnos atingido" sem chance de continuar. Diagnóstico confirmado por revisão de código (Codex): o problema é arquitetural — o CLI depende só de `NEETRU_PATCH` (unified diff cru gerado pelo modelo) como único caminho de escrita.
55
+
56
+ ### Adicionado
57
+
58
+ - **Autocomplete de slash commands** — digitar `/` na sessão interativa lista os comandos, narrowing conforme digita (substring, não só prefixo), setas ↑/↓ navegam, Tab ou Enter (quando a sugestão ainda não é o texto exato) completa em vez de submeter. Fonte única (`SLASH_COMMANDS` em `code-agent-ui.ts`) compartilhada com `/help`, pra nunca ficar desalinhada.
59
+ - **3 ferramentas deterministicas de escrita** (`code-agent-write-tools.ts`) — `write_file` (cria/sobrescreve um arquivo inteiro), `append_file` (adiciona ao final), `replace_in_file` (troca um trecho exato, com detecção de ocorrência ambígua e `"replace_all":true` explícito). Viram o caminho PRINCIPAL pra criar/editar UM arquivo por vez; `NEETRU_PATCH` vira fallback só pra diff multi-arquivo/multi-hunk num único turno. Mesmo tier de confirmação de `git_commit`/`git_branch` (local/reversível, `--yes` dispensa a pergunta); path sempre resolvido via `resolveInside` (contenção + symlink real path) e escrita em arquivo de segredo (.env/chave) bloqueada, espelhando o bloqueio de leitura. Bloqueadas em modo plano.
60
+
61
+ ### Corrigido
62
+
63
+ - **Loop de patch inválido gastava o orçamento de turnos inteiro** — cada tentativa de correção consumia 1 turno igual a qualquer outro; um modelo preso no mesmo erro nunca sobrava turno pra nada. Depois de 3 tentativas SEGUIDAS de patch inválido, o loop para de pedir "corrija o diff" e força o modelo a mudar de estratégia (explicar em texto, ou — com as novas ferramentas de escrita — usar `write_file`/`replace_in_file` no arquivo específico).
64
+ - **"Limite de turnos atingido" matava a sessão sem chance de continuar** — esgotar `maxTurns` agora pergunta "Continuar mais N turnos?" antes de falhar, mas SÓ quando há um humano de verdade (`!silent`) — subagentes e sessões não-interativas (sem TTY) continuam falhando direto, sem regressão de comportamento nem risco de loop custando dinheiro sem supervisão.
65
+ - **Achado testando a própria correção acima: a extensão de turnos podia rodar PARA SEMPRE** — um `confirm` que sempre aprova (script/automação, ou um `confirm` de teste ingênuo) mantinha um modelo genuinamente preso (nunca conclui, nunca bloqueia) estendendo o limite indefinidamente — travou de verdade um worker de teste até estourar memória. Novo teto `MAX_TURN_LIMIT_EXTENSIONS` (2): depois disso, falha direto mesmo que a extensão continue sendo aprovada.
66
+
67
+ ### Docs
68
+
69
+ - System prompt (`buildSystemPrompt`) atualizado pra orientar o modelo a preferir write_file/append_file/replace_in_file sobre NEETRU_PATCH pra mudança de um arquivo só, e a abandonar um diff que já falhou 2-3 vezes em vez de insistir.
70
+
5
71
  ## [2.21.0] — 2026-07-13 (`neetru code` — skills, modo objetivo/plano/auto, ferramentas web/GitHub + hardening pós-review)
6
72
 
7
73
  > Sessão com trabalho concorrente Claude+Codex no mesmo repo. Codex implementou skills, patch pre-check, modo objetivo/plano/auto, `/approve`/`/auto` e as ferramentas `web_search`/`web_fetch`/`github_search`/`github_repo`. Um Codex-review dedicado (`codex-rescue`) auditou os 4 commits anteriores (2.20.1-2.20.3 + o `feat` de base); os achados P1/P2 confirmados por leitura direta do código + os achados adicionais encontrados nesta revisão foram corrigidos nesta mesma versão, antes da publicação.
package/README.md CHANGED
@@ -111,8 +111,25 @@ neetru dev up --skip-core
111
111
  neetru dev exec "npm run test:integration"
112
112
  ```
113
113
 
114
+ Contexto do projeto local:
115
+
116
+ ```bash
117
+ # Projeto ligado a um produto já existente no Core:
118
+ neetru dev use --product meu-produto --sync
119
+
120
+ # Protótipo local ainda não cadastrado no Core:
121
+ neetru dev use --prototype meu-rascunho
122
+
123
+ # Ver o contexto efetivo que dev up/dev exec vão usar:
124
+ neetru dev status
125
+
126
+ # Sincronizar bancos dev-local do Core para .neetru/db.json:
127
+ neetru dev sync --write-db
128
+ ```
129
+
114
130
  > `neetru dev` (sem subcomando) mantém o modo legado: banco Docker local + watch de schema.
115
131
  > Flags: `--only firestore,auth,functions`, `--core-port 9003`, `--core-path <dir>`.
132
+ > O arquivo `.neetru/dev.json` guarda o contexto local: produto Core ou protótipo, Core path e emuladores padrão.
116
133
 
117
134
  ## Configuração
118
135
 
@@ -7,11 +7,12 @@ import { config } from '../lib/config.js';
7
7
  import { getActiveTokenSync } from '../lib/token-resolver.js';
8
8
  import { log } from '../utils/logger.js';
9
9
  import { setSafeExitCode } from '../utils/safe-exit.js';
10
- import { parseToolRequest, extractPatch } from '../lib/code-agent-protocol.js';
10
+ import { parseToolRequests, extractPatch } from '../lib/code-agent-protocol.js';
11
11
  import { applyUnifiedPatch, checkPatchApplies, getGitDiff, getProjectOverview, listFiles, readFileContext, renderColoredDiff, runCodeTool, truncateForGateway, } from '../lib/code-agent-files.js';
12
12
  import { compactCodeHistory, compactHistoryToFit, MAX_HISTORY_TOTAL_CHARS, saveCodeSession, autoSaveCodeSession, listCodeSessions, loadCodeSession, logAppliedPatch, CodeAgentStateMachine, } from '../lib/code-agent-session.js';
13
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 { createBudgetTracker, decideBudgetAction, MAX_CONSECUTIVE_PATCH_REPAIRS, MAX_TOOLS_PER_TURN, SINGLE_ACTION_TOOLS, } from '../lib/code-agent-budget.js';
15
16
  import { CliApiError } from '../lib/api-client.js';
16
17
  import { buildSystemPrompt } from '../lib/code-agent-tools.js';
17
18
  import { CODE_SKILLS_ROOT, installAllBuiltinSkills, loadCodeSkills, normalizeSkillName } from '../lib/code-agent-skills.js';
@@ -206,6 +207,32 @@ export async function callGatewayWithProgressRetrying(settings, messages, spinne
206
207
  }
207
208
  }
208
209
  }
210
+ // 2026-07-13 (achado em uso real, transcript colado pelo usuario): um modelo
211
+ // preso repetindo o MESMO diff quebrado ("corrupt patch") gastava o
212
+ // orcamento de turnos INTEIRO so em correcoes de patch — cada tentativa
213
+ // invalida consome 1 turno igual a qualquer outro, entao 3-4 tentativas
214
+ // seguidas ja fecham a sessao inteira sem sobrar turno pra nada. Depois de
215
+ // N tentativas seguidas, para de pedir "corrija o diff" (que nao esta
216
+ // funcionando) e forca o modelo a mudar de estrategia: explicar em texto em
217
+ // vez de insistir no mesmo formato.
218
+ // (MAX_CONSECUTIVE_PATCH_REPAIRS/MAX_TOOLS_PER_TURN/SINGLE_ACTION_TOOLS agora
219
+ // vivem em code-agent-budget.ts — evita um ciclo de import com o Objective
220
+ // Runtime Controller, que consome esse modulo; ver comentario la.)
221
+ // Limite de turnos "duro demais" era a outra metade da mesma reclamacao:
222
+ // ao esgotar `maxTurns` a sessao INTEIRA falhava com um erro opaco, sem
223
+ // chance do usuario (que esta ali, na sessao interativa) simplesmente pedir
224
+ // mais turnos pra terminar algo que estava proximo. So oferece a extensao
225
+ // quando ha um humano de verdade pra perguntar — subagentes (`silent`) e
226
+ // modo nao-interativo sem TTY (`defaultConfirm` ja retorna false) continuam
227
+ // falhando direto, sem regressao de comportamento.
228
+ const TURN_LIMIT_EXTENSION = 6;
229
+ // Teto de extensoes por tarefa — sem isso, um `confirm` que sempre aprova
230
+ // (script/automacao, ou um humano otimista demais) mantem um modelo preso
231
+ // num loop que nunca conclui rodando PARA SEMPRE, um turno estendido atras
232
+ // do outro (achado rodando os testes: um mock de tool-loop infinito com
233
+ // confirm sempre-true travou o worker de teste ate estourar memoria).
234
+ // Depois do teto, falha direto — nao pergunta mais, mesmo que aprovassem.
235
+ const MAX_TURN_LIMIT_EXTENSIONS = 2;
209
236
  function classifyObjectiveText(content) {
210
237
  const trimmed = content.trimStart();
211
238
  if (/^NEETRU_DONE\b/i.test(trimmed))
@@ -220,6 +247,22 @@ function stripObjectiveMarker(content) {
220
247
  .replace(/^\s*NEETRU_BLOCKED\b[:\s-]*/i, '')
221
248
  .trim();
222
249
  }
250
+ function objectiveDisplayText(state, content) {
251
+ if (state === 'done')
252
+ return stripObjectiveMarker(content) || 'Concluido.';
253
+ if (state === 'blocked')
254
+ return stripObjectiveMarker(content) || 'Bloqueado.';
255
+ return content;
256
+ }
257
+ function isAutoMode(settings) {
258
+ return settings.apply === true && settings.yes === true && settings.objectiveMode === true;
259
+ }
260
+ function canAutoExtendTurnLimit(settings, silent, usedExtensions) {
261
+ return !silent && isAutoMode(settings) && usedExtensions < MAX_TURN_LIMIT_EXTENSIONS;
262
+ }
263
+ function extendTurnLimit(settings) {
264
+ settings.maxTurns += TURN_LIMIT_EXTENSION;
265
+ }
223
266
  function buildObjectiveContinuationPrompt(turn, maxTurns) {
224
267
  return [
225
268
  `Modo objetivo: sua resposta anterior parece um avanco parcial, nao uma conclusao marcada.`,
@@ -331,8 +374,33 @@ export async function runAgentTask(task, settings, confirm, history = [], onStat
331
374
  // aparecendo como linha permanente via printEvent, pausando o spinner
332
375
  // pra nao interlear as duas saidas.
333
376
  let spinner = makeSpinner();
377
+ let patchRepairStreak = 0;
378
+ let turnLimitExtensionsUsed = 0;
379
+ const budgetTracker = createBudgetTracker();
334
380
  try {
335
- for (let turn = 1; turn <= settings.maxTurns; turn++) {
381
+ for (let turn = 1;; turn++) {
382
+ if (turn > settings.maxTurns) {
383
+ // Rede de seguranca virou pergunta em vez de falha direta (achado em
384
+ // uso real): so faz sentido quando ha um humano de verdade podendo
385
+ // responder — `confirm` ja retorna false sem TTY/em subagentes
386
+ // (`silent`), preservando o comportamento antigo nesses casos. Teto
387
+ // de MAX_TURN_LIMIT_EXTENSIONS: mesmo aprovado, nao pergunta pra
388
+ // sempre — um modelo genuinamente preso (nunca conclui, nunca
389
+ // bloqueia) nao pode virar loop infinito so porque alguem/algo
390
+ // aprovou a extensao uma vez.
391
+ spinner.stop();
392
+ const autoExtend = canAutoExtendTurnLimit(settings, silent, turnLimitExtensionsUsed);
393
+ const canOfferExtension = !silent && !autoExtend && turnLimitExtensionsUsed < MAX_TURN_LIMIT_EXTENSIONS;
394
+ const shouldExtend = autoExtend || (canOfferExtension && (await confirm(`\nLimite de ${settings.maxTurns} turno(s) atingido sem concluir. Continuar mais ${TURN_LIMIT_EXTENSION} turno(s)? [y/N] `)));
395
+ if (!shouldExtend) {
396
+ goto('failed');
397
+ throw new Error(`Limite de turnos atingido (${settings.maxTurns}) sem resposta final.`);
398
+ }
399
+ turnLimitExtensionsUsed++;
400
+ extendTurnLimit(settings);
401
+ emit('ok', autoExtend ? 'Limite de turnos estendido automaticamente' : 'Limite de turnos estendido', `+${TURN_LIMIT_EXTENSION} (agora ${settings.maxTurns} no total)`);
402
+ spinner = makeSpinner();
403
+ }
336
404
  // Cancelamento cooperativo: verificado na fronteira de cada turno. A
337
405
  // chamada de rede em voo tem timeout proprio (code-agent-gateway.ts), que
338
406
  // este modulo nao controla sem editar aquele arquivo — o corte efetivo
@@ -342,6 +410,24 @@ export async function runAgentTask(task, settings, confirm, history = [], onStat
342
410
  return { kind: 'aborted' };
343
411
  }
344
412
  goto('thinking');
413
+ // Objective Runtime Controller (2026-07-13) — checkpoint de orcamento
414
+ // logo APOS goto('thinking') de proposito: os 3 estados alcancaveis no
415
+ // topo do loop (idle/thinking/tool_running, via os `continue` do loop)
416
+ // ja tem edge legal pra 'thinking' hoje, entao colocar o checkpoint
417
+ // AQUI exige so UMA edge nova na maquina de estados (thinking ->
418
+ // budget_exceeded), em vez de enumerar as 3. Nunca estende (nem em
419
+ // auto mode, diferente do limite de turnos acima): noProgress/
420
+ // repeatedErrors sao sinal de que o modelo nao esta CONVERGINDO, nao
421
+ // de que faltou tempo — estender so prolongaria a mesma falha.
422
+ const budgetDecision = decideBudgetAction(budgetTracker, options.budgetOverrides);
423
+ if (budgetDecision.exceeded) {
424
+ spinner.stop();
425
+ if (!silent) {
426
+ emit('warn', 'Orcamento do modo objetivo excedido', `${budgetDecision.dimension} — ${budgetDecision.reason}`);
427
+ }
428
+ goto('budget_exceeded');
429
+ return { kind: 'budget_exceeded', dimension: budgetDecision.dimension, reason: budgetDecision.reason };
430
+ }
345
431
  // GAP-M1-15: auto-compact por TAMANHO antes de cada chamada — sem isso
346
432
  // uma sessao com varias tool results grandes (cada uma ate
347
433
  // MAX_GATEWAY_SAFE_CHARS) estoura o limite de payload TOTAL do
@@ -358,9 +444,16 @@ export async function runAgentTask(task, settings, confirm, history = [], onStat
358
444
  // em producao: "oi" gastou os 8 turnos so chamando tools e falhou com
359
445
  // "Limite de turnos atingido"). No ultimo turno permitido, forca o
360
446
  // modelo a responder AGORA em vez de so descobrir isso ao falhar.
361
- if (turn === settings.maxTurns) {
447
+ if (turn === settings.maxTurns && !canAutoExtendTurnLimit(settings, silent, turnLimitExtensionsUsed)) {
448
+ // 2026-07-13 (achado do Codex review): a redacao antiga ("NEETRU_DONE
449
+ // se concluiu o maximo possivel") misturava "fiz o que deu" com
450
+ // "objetivo realmente concluido" — o modelo podia marcar NEETRU_DONE
451
+ // so por estar sem turnos, reportando uma tarefa incompleta como
452
+ // terminada. NEETRU_DONE agora exige conclusao REAL; sem isso, texto
453
+ // normal resumindo o progresso (cai no classificador 'partial', que
454
+ // ja avisa "Limite do modo objetivo atingido" em vez de fingir feito).
362
455
  const finalTurnInstruction = settings.objectiveMode
363
- ? 'Este e o ultimo turno disponivel. Responda AGORA com NEETRU_DONE se concluiu o maximo possivel, ou NEETRU_BLOCKED se ha bloqueio real. Nao peca mais NEETRU_TOOL nem proponha patch.'
456
+ ? 'Este e o ultimo turno disponivel. Se o objetivo esta REALMENTE concluido, responda com NEETRU_DONE. Se ha um bloqueio real (falta de informacao/credencial/decisao externa), responda com NEETRU_BLOCKED. Caso contrario — se ainda falta trabalho mas o tempo acabou — responda em texto normal resumindo o progresso ate aqui, SEM usar NEETRU_DONE (nao esta concluido so porque o tempo acabou). Nao peca mais NEETRU_TOOL nem proponha patch.'
364
457
  : 'Este e o ultimo turno disponivel. Responda AGORA com uma conclusao em texto usando o que voce ja sabe — nao peca mais NEETRU_TOOL nem proponha patch.';
365
458
  messages.push({
366
459
  role: 'user',
@@ -373,16 +466,40 @@ export async function runAgentTask(task, settings, confirm, history = [], onStat
373
466
  spinner = gatewayResult.spinner;
374
467
  const content = gatewayResult.content;
375
468
  messages.push({ role: 'assistant', content });
376
- const tool = parseToolRequest(content);
377
- if (tool) {
378
- goto('tool_requested');
379
- spinner.update(`${baseDetail} · ${tool.tool}`);
380
- goto('tool_running');
381
- const result = await runCodeTool(settings.cwd, tool, { settings, confirm });
382
- spinner.update(`${baseDetail} · ${summarizeToolResult(result)}`);
469
+ const tools = parseToolRequests(content);
470
+ if (tools.length > 0) {
471
+ patchRepairStreak = 0;
472
+ budgetTracker.noProgressStreak = 0; // acao real (tool) — nao e "so texto"
473
+ const selectedTools = tools.slice(0, MAX_TOOLS_PER_TURN);
474
+ const results = [];
475
+ let singleActionSeen = false;
476
+ for (const [index, tool] of selectedTools.entries()) {
477
+ const isSingleAction = SINGLE_ACTION_TOOLS.has(tool.tool);
478
+ if (isSingleAction && singleActionSeen) {
479
+ results.push(`--- Resultado da tool ${index + 1}/${selectedTools.length}: ${tool.tool} ---\n` +
480
+ `ERRO: apenas uma ferramenta que escreve, executa codigo ou publica pode rodar por resposta. ` +
481
+ `Peca esta acao novamente no proximo turno se ainda for necessaria.`);
482
+ continue;
483
+ }
484
+ if (isSingleAction)
485
+ singleActionSeen = true;
486
+ if (index > 0)
487
+ goto('thinking');
488
+ goto('tool_requested');
489
+ spinner.update(`${baseDetail} · tool ${index + 1}/${selectedTools.length}: ${tool.tool}`);
490
+ goto('tool_running');
491
+ const result = await runCodeTool(settings.cwd, tool, { settings, confirm });
492
+ spinner.update(`${baseDetail} · tool ${index + 1}/${selectedTools.length}: ${summarizeToolResult(result)}`);
493
+ results.push(`--- Resultado da tool ${index + 1}/${selectedTools.length}: ${tool.tool} ---\n${result}`);
494
+ }
495
+ if (tools.length > selectedTools.length) {
496
+ results.push(`--- Ferramentas ignoradas ---\n` +
497
+ `A resposta pediu ${tools.length} ferramentas; o limite por turno e ${MAX_TOOLS_PER_TURN}. ` +
498
+ `Peca as restantes no proximo turno se ainda forem necessarias.`);
499
+ }
383
500
  messages.push({
384
501
  role: 'user',
385
- content: truncateForGateway(`Resultado da tool ${tool.tool}:\n\n${result}`),
502
+ content: truncateForGateway(`Resultados das tools:\n\n${results.join('\n\n')}`),
386
503
  });
387
504
  continue;
388
505
  }
@@ -398,6 +515,29 @@ export async function runAgentTask(task, settings, confirm, history = [], onStat
398
515
  if (settings.apply) {
399
516
  const checkError = checkPatchApplies(settings.cwd, patch);
400
517
  if (checkError) {
518
+ patchRepairStreak++;
519
+ // Cumulativo, NUNCA reseta (nem quando patchRepairStreak reseta
520
+ // 2 linhas abaixo) — pega o padrao que o streak local nao pega:
521
+ // erra 3x, muda de estrategia (streak reseta), volta a errar 3x,
522
+ // reseta de novo, indefinidamente sem convergir. Ver
523
+ // code-agent-budget.ts.
524
+ budgetTracker.repeatedErrorTotal++;
525
+ // 3+ tentativas seguidas de patch invalido: pedir "corrija o
526
+ // diff" de novo claramente nao esta funcionando (achado em uso
527
+ // real — o mesmo erro se repetia turno apos turno ate esgotar o
528
+ // limite). Muda de estrategia em vez de insistir no mesmo pedido.
529
+ if (patchRepairStreak >= MAX_CONSECUTIVE_PATCH_REPAIRS) {
530
+ spinner.update(`${baseDetail} · patch invalido varias vezes, mudando de estrategia`);
531
+ emit('warn', 'Patch invalido varias vezes seguidas — pedindo explicacao em texto', `${patchRepairStreak} tentativas · ultimo erro: ${checkError.slice(0, 200)}`);
532
+ messages.push({
533
+ role: 'user',
534
+ content: truncateForGateway(`Ja foram ${patchRepairStreak} tentativas seguidas de patch invalido (ultimo erro: ${checkError.slice(0, 200)}). ` +
535
+ 'Pare de tentar gerar um diff neste turno. Descreva em texto claro, para o usuario, exatamente o ' +
536
+ 'que precisa mudar (arquivo, trecho, motivo) — sem NEETRU_PATCH desta vez.'),
537
+ });
538
+ patchRepairStreak = 0;
539
+ continue;
540
+ }
401
541
  spinner.update(`${baseDetail} · patch invalido, pedindo correcao`);
402
542
  emit('warn', 'Patch nao aplicavel, pedindo correcao ao modelo', checkError.slice(0, 300));
403
543
  messages.push({
@@ -408,6 +548,7 @@ export async function runAgentTask(task, settings, confirm, history = [], onStat
408
548
  continue;
409
549
  }
410
550
  }
551
+ budgetTracker.noProgressStreak = 0; // patch valido — acao real, nao "so texto"
411
552
  spinner.stop();
412
553
  goto('patch_ready');
413
554
  if (!silent) {
@@ -438,8 +579,19 @@ export async function runAgentTask(task, settings, confirm, history = [], onStat
438
579
  }
439
580
  if (settings.objectiveMode) {
440
581
  const objectiveState = classifyObjectiveText(content);
441
- const cleanContent = objectiveState === 'partial' ? content : stripObjectiveMarker(content);
442
- if (objectiveState === 'partial' && turn < settings.maxTurns) {
582
+ const displayContent = objectiveDisplayText(objectiveState, content);
583
+ if (objectiveState === 'partial' &&
584
+ (turn < settings.maxTurns || canAutoExtendTurnLimit(settings, silent, turnLimitExtensionsUsed))) {
585
+ if (turn >= settings.maxTurns) {
586
+ turnLimitExtensionsUsed++;
587
+ extendTurnLimit(settings);
588
+ emit('ok', 'Limite de turnos estendido automaticamente', `+${TURN_LIMIT_EXTENSION} (agora ${settings.maxTurns} no total)`);
589
+ }
590
+ patchRepairStreak = 0;
591
+ // Chegar aqui significa: sem tool call, sem patch (os dois ramos
592
+ // anteriores no loop ja fazem `continue`/`return` antes disto) —
593
+ // "sem progresso" e definicional pra este turno.
594
+ budgetTracker.noProgressStreak++;
443
595
  spinner.stop();
444
596
  if (!silent) {
445
597
  emit('ok', 'Avanco parcial', 'modo objetivo continuara trabalhando');
@@ -458,10 +610,10 @@ export async function runAgentTask(task, settings, confirm, history = [], onStat
458
610
  if (objectiveState === 'partial') {
459
611
  emit('warn', 'Limite do modo objetivo atingido', `${settings.maxTurns} turno(s)`);
460
612
  }
461
- printAssistantText(cleanContent || content);
613
+ printAssistantText(displayContent);
462
614
  }
463
615
  goto('idle');
464
- return { kind: 'text', text: cleanContent || content };
616
+ return { kind: 'text', text: displayContent };
465
617
  }
466
618
  spinner.stop();
467
619
  if (!silent) {
@@ -471,9 +623,6 @@ export async function runAgentTask(task, settings, confirm, history = [], onStat
471
623
  goto('idle');
472
624
  return { kind: 'text', text: content };
473
625
  }
474
- spinner.stop();
475
- goto('failed');
476
- throw new Error(`Limite de turnos atingido (${settings.maxTurns}) sem resposta final.`);
477
626
  }
478
627
  catch (error) {
479
628
  spinner.stop();
@@ -581,17 +730,44 @@ async function runAgentsCommand(line, settings, confirm) {
581
730
  return;
582
731
  }
583
732
  const outcome = record.outcome;
584
- if (outcome?.kind === 'patch' && outcome.patch) {
585
- printEvent('ok', 'Patch proposto', `#${idArg} — /agents apply ${idArg} pra aplicar`);
586
- printPatchPreview(outcome.patch);
587
- }
588
- else if (outcome?.kind === 'text') {
589
- printAssistantText(outcome.text ?? '(sem texto)');
590
- }
591
- else {
733
+ if (!outcome) {
592
734
  printEvent('warn', 'Sem resultado aplicavel', `#${idArg}`);
735
+ return;
736
+ }
737
+ // switch exaustivo (nao if/else) de proposito: um `outcome.kind` novo
738
+ // sem `case` aqui quebra `tsc --noEmit` de verdade (assign pro `never`
739
+ // no default), ao contrario do if/else que isto substitui — ver
740
+ // achado do Codex review 2026-07-13 (a promessa antiga de "o tsc pega"
741
+ // era falsa pra if/else). Todo `case` (inclusive `default`) termina em
742
+ // `return` — isto e o ULTIMO statement de `case 'result':` no switch
743
+ // externo (`switch (sub)` acima); sem `return` em todo ramo, cair do
744
+ // switch interno cairia direto no proximo `case` do switch externo.
745
+ switch (outcome.kind) {
746
+ case 'patch':
747
+ if (outcome.patch) {
748
+ printEvent('ok', 'Patch proposto', `#${idArg} — /agents apply ${idArg} pra aplicar`);
749
+ printPatchPreview(outcome.patch);
750
+ }
751
+ else {
752
+ printEvent('warn', 'Sem resultado aplicavel', `#${idArg}`);
753
+ }
754
+ return;
755
+ case 'text':
756
+ printAssistantText(outcome.text ?? '(sem texto)');
757
+ return;
758
+ case 'budget_exceeded':
759
+ printEvent('warn', 'Subagente parou por orcamento excedido', `${outcome.dimension ?? '?'} — ${outcome.reason ?? `#${idArg}`}`);
760
+ return;
761
+ case 'applied':
762
+ case 'aborted':
763
+ printEvent('warn', 'Sem resultado aplicavel', `#${idArg}`);
764
+ return;
765
+ default: {
766
+ const _exhaustive = outcome.kind;
767
+ printEvent('warn', 'Sem resultado aplicavel', `#${idArg}`);
768
+ return;
769
+ }
593
770
  }
594
- return;
595
771
  }
596
772
  case 'apply': {
597
773
  if (!idArg) {
@@ -986,12 +1162,41 @@ async function runInteractive(settings) {
986
1162
  const answer = await ctx.askLine(question);
987
1163
  return /^(y|yes|s|sim)$/i.test(answer.trim());
988
1164
  }, history);
989
- if (wasPlanMode && (outcome.kind === 'text' || outcome.kind === 'patch')) {
990
- pendingPlanTask = line;
991
- printEvent('ok', 'Plano aguardando aprovacao', 'use /approve para entrar em auto mode e executar');
992
- }
993
- else if (!wasPlanMode) {
994
- pendingPlanTask = null;
1165
+ // switch exaustivo (2026-07-13, Objective Runtime Controller): o
1166
+ // if/else antigo so cobria 'text'/'patch' explicitamente e
1167
+ // `!wasPlanMode`; um outcome 'aborted' EM modo plano nao caia em
1168
+ // nenhum ramo, deixando pendingPlanTask parado num valor antigo —
1169
+ // achado real do Codex review. `budget_exceeded` teria o mesmo gap.
1170
+ switch (outcome.kind) {
1171
+ case 'text':
1172
+ case 'patch':
1173
+ if (wasPlanMode) {
1174
+ pendingPlanTask = line;
1175
+ printEvent('ok', 'Plano aguardando aprovacao', 'use /approve para entrar em auto mode e executar');
1176
+ }
1177
+ else {
1178
+ pendingPlanTask = null;
1179
+ }
1180
+ break;
1181
+ case 'applied':
1182
+ case 'aborted':
1183
+ case 'budget_exceeded':
1184
+ // Nenhum destes produz um plano novo. So limpa pendingPlanTask
1185
+ // quando NAO estavamos em modo plano (a tarefa rodou de verdade).
1186
+ // Em modo plano, um turno que nao produziu plano (abortado,
1187
+ // orcamento excedido) NAO pode apagar um plano anterior ainda
1188
+ // valido esperando /approve — senao um Ctrl+C numa pergunta nao
1189
+ // relacionada destruiria um plano aprovavel (achado da 2a rodada
1190
+ // de revisao adversarial: a correcao ingenua deste mesmo bug
1191
+ // introduzia esta regressao especificamente pro caso 'aborted').
1192
+ if (!wasPlanMode)
1193
+ pendingPlanTask = null;
1194
+ break;
1195
+ default: {
1196
+ const _exhaustive = outcome.kind;
1197
+ if (!wasPlanMode)
1198
+ pendingPlanTask = null;
1199
+ }
995
1200
  }
996
1201
  }
997
1202
  catch (error) {