primocode 9.8.0-beta.0 → 9.8.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,630 @@
1
+ /**
2
+ * claude-engine.js — motor alternativo do agente: Claude Code local em vez
3
+ * de OpenRouter/Groq via primocode-server.
4
+ *
5
+ * Implementa a MESMA forma que lib/api.js (requestAct(server, payload,
6
+ * onEvent)) — é o que permite lib/act.js trocar de motor sem saber disso.
7
+ * Ver docs/superpowers/specs/2026-08-15-claude-engine-design.md.
8
+ */
9
+
10
+ 'use strict';
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+ const { execSync, execFileSync } = require('child_process');
16
+
17
+ /* ── O MOTOR EXTERNO NÃO APARECE ────────────────────────────────────────
18
+ *
19
+ * "O Primo Code abre esses processos ocultamente (em segundo plano) e usa
20
+ * apenas o modelo dessas ferramentas. Toda a interface, comandos, agentes e
21
+ * experiência continuam sendo 100% do Primo Code."
22
+ *
23
+ * `execFileSync` sem `stdio` HERDA o stderr do pai — é o padrão do Node, e não
24
+ * parece nada até o dia em que o motor imprime um aviso de versão, de
25
+ * atualização ou de configuração. Aquilo cai no meio da tela do PrimoCode, com
26
+ * a cara e o vocabulário de outro produto, no meio de uma tarefa.
27
+ *
28
+ * Com o stderr CAPTURADO ele some da tela e ganha um segundo uso: `e.stderr` só
29
+ * existe quando é canalizado, e o tratamento de erro daqui (e o do
30
+ * codex-engine) já lia esse campo — estava lendo `null` desde sempre, e a
31
+ * mensagem de falha do motor saía sem o motivo que ele mesmo tinha escrito.
32
+ *
33
+ * `windowsHide` é a outra metade do "ocultamente": sem ele, cada chamada
34
+ * pisca um console preto na frente de quem usa Windows. */
35
+ const SEM_APARECER = { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true };
36
+
37
+
38
+ const MARCA_INICIO = '<<<PRIMOCODE>>>';
39
+ const MARCA_FIM = '<<<FIM>>>';
40
+
41
+ /**
42
+ * O modelo que o /claude pede.
43
+ *
44
+ * "Ele usa o modelo Opus 5 [no Claude]."
45
+ *
46
+ * Vai o APELIDO (`opus`), não o identificador completo: o apelido é o que o
47
+ * Claude Code resolve para o Opus do dia, e um identificador fixo aqui
48
+ * envelheceria dentro de um pacote publicado no npm — a pessoa atualizaria o
49
+ * Claude Code, o modelo sairia de catálogo, e o PrimoCode pediria um modelo
50
+ * que não existe mais.
51
+ *
52
+ * Quem quiser outro põe PRIMOCODE_CLAUDE_MODELO. Vazio manda sem `--model`, e
53
+ * aí vale o padrão da conta da pessoa.
54
+ */
55
+ const MODELO_CLAUDE = process.env.PRIMOCODE_CLAUDE_MODELO !== undefined
56
+ ? process.env.PRIMOCODE_CLAUDE_MODELO
57
+ : 'opus';
58
+
59
+ /** Os argumentos do modelo, ou nenhum quando a escolha é do dono da conta. */
60
+ function argsDoModelo() {
61
+ return MODELO_CLAUDE ? ['--model', MODELO_CLAUDE] : [];
62
+ }
63
+
64
+ /**
65
+ * Acha o ÚLTIMO bloco marcado no texto (pode vir ruído de terminal — prompt
66
+ * ANSI, texto solto — antes e depois) e devolve o JSON de dentro dele.
67
+ * null quando não achou bloco válido: quem chama decide o que fazer.
68
+ */
69
+ function extrairDecisao(texto) {
70
+ const t = String(texto || '');
71
+
72
+ // Varre TODOS os blocos completos e fica com o último que dá para ler.
73
+ //
74
+ // A versão anterior fazia lastIndexOf(INICIO) e depois procurava o FIM
75
+ // para frente. Bastava o modelo escrever a marca de abertura mais uma vez
76
+ // — explicando o formato, ou começando um bloco e desistindo — para o FIM
77
+ // não existir depois dela: a extração devolvia null e o protocolo CRU ia
78
+ // parar na tela do usuário, com tool_call e tudo. Foi o que aconteceu num
79
+ // pedido real de vídeo.
80
+ const blocos = [];
81
+ let i = 0;
82
+ while (true) {
83
+ const inicio = t.indexOf(MARCA_INICIO, i);
84
+ if (inicio === -1) break;
85
+ const fim = t.indexOf(MARCA_FIM, inicio + MARCA_INICIO.length);
86
+ if (fim === -1) break; // bloco aberto e não fechado: ignora
87
+ blocos.push(t.slice(inicio + MARCA_INICIO.length, fim));
88
+ i = fim + MARCA_FIM.length;
89
+ }
90
+
91
+ for (let k = blocos.length - 1; k >= 0; k--) {
92
+ const lido = lerJSON(blocos[k]);
93
+ if (lido) return lido;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /** JSON com quebra de linha CRUA dentro das aspas — o jeito mais comum de o
99
+ * motor falhar. O Claude escreve o content de um write_file com linhas de
100
+ * verdade em vez de \n, o JSON.parse recusa, e o turno inteiro morria com
101
+ * "decisão que não consegui ler". O conserto é mecânico: dentro de string,
102
+ * controle vira escape. */
103
+ function consertarControles(s) {
104
+ let saida = '';
105
+ let dentro = false, escapa = false;
106
+ for (const c of String(s || '')) {
107
+ if (dentro && !escapa && (c === '\n' || c === '\r' || c === '\t')) {
108
+ saida += c === '\n' ? '\\n' : c === '\r' ? '\\r' : '\\t';
109
+ continue;
110
+ }
111
+ if (escapa) { escapa = false; saida += c; continue; }
112
+ if (c === '\\') { escapa = true; saida += c; continue; }
113
+ if (c === '"') dentro = !dentro;
114
+ saida += c;
115
+ }
116
+ return saida;
117
+ }
118
+
119
+ /** O JSON do bloco, tolerando o que o modelo costuma acrescentar sem querer. */
120
+ function lerJSON(bruto) {
121
+ let s = String(bruto || '').trim();
122
+ // Cerca de markdown em volta do JSON — o modelo põe por hábito.
123
+ s = s.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
124
+ try { return JSON.parse(s); } catch {}
125
+ try { return JSON.parse(consertarControles(s)); } catch {}
126
+ s = consertarControles(s);
127
+ // Sobrou texto depois do objeto? Corta no fecha-chaves que casa com o primeiro.
128
+ const abre = s.indexOf('{');
129
+ if (abre === -1) return null;
130
+ let nivel = 0, dentro = false, escapa = false;
131
+ for (let k = abre; k < s.length; k++) {
132
+ const c = s[k];
133
+ if (escapa) { escapa = false; continue; }
134
+ if (c === '\\') { escapa = true; continue; }
135
+ if (c === '"') { dentro = !dentro; continue; }
136
+ if (dentro) continue;
137
+ if (c === '{') nivel++;
138
+ else if (c === '}' && --nivel === 0) {
139
+ const trecho = s.slice(abre, k + 1);
140
+ try { return JSON.parse(trecho); } catch {}
141
+ try { return JSON.parse(consertarControles(trecho)); } catch { return null; }
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ const catalogo = require('./catalogo');
148
+
149
+ /** Serializa lib/catalogo.js em texto — única fonte de verdade, sem
150
+ * duplicar a lista de ferramentas num arquivo separado. */
151
+ function catalogoParaTexto() {
152
+ const linhas = [];
153
+ for (const grupo of catalogo.GRUPOS) {
154
+ linhas.push(`## ${grupo.nome} — ${grupo.resumo}`);
155
+ for (const item of grupo.itens) {
156
+ const args = item.args ? ` | args: ${item.args}` : ' | sem args';
157
+ linhas.push(`- ${item.tool}: ${item.faz}${args}`);
158
+ }
159
+ linhas.push('');
160
+ }
161
+ return linhas.join('\n').trim();
162
+ }
163
+
164
+ /** Texto completo que ensina o Claude Code a se comportar como motor do
165
+ * PrimoCode. Vira o conteúdo de ~/.claude/skills/primocode-engine/SKILL.md
166
+ * (Task 4) e é referenciado na primeira mensagem de cada sessão. */
167
+ function textoBootstrap() {
168
+ return `---
169
+ name: primocode-engine
170
+ description: Skill instalada pelo PrimoCode para o modo /claude — motor de decisão do agente, não um agente autônomo.
171
+ ---
172
+
173
+ # PrimoCode — modo motor
174
+
175
+ Você está sendo usado como o "cérebro" do PrimoCode, um CLI que executa
176
+ tarefas de engenharia. Não use suas próprias ferramentas nativas (Read,
177
+ Write, Edit, Bash, Glob, Grep, WebFetch, etc.) — quem executa é o
178
+ PrimoCode, não você. Sua única saída é dizer QUAL ferramenta do PrimoCode
179
+ chamar, com quais argumentos, ou a resposta final.
180
+
181
+ ## Ferramentas disponíveis
182
+
183
+ ${catalogoParaTexto()}
184
+
185
+ ## Formato de resposta — obrigatório
186
+
187
+ Termine toda resposta com exatamente um bloco, sem nada depois dele.
188
+
189
+ Para chamar uma ferramenta:
190
+
191
+ ${MARCA_INICIO}
192
+ {"tool_call": {"name": "write_file", "arguments": {"path": "index.html", "content": "..."}}}
193
+ ${MARCA_FIM}
194
+
195
+ Para terminar a tarefa (sem mais ferramenta pra chamar):
196
+
197
+ ${MARCA_INICIO}
198
+ {"assistant_message": "frase curta do que foi feito"}
199
+ ${MARCA_FIM}
200
+
201
+ Nunca omita o bloco. Nunca escreva mais de um bloco por resposta.`;
202
+ }
203
+
204
+ const SKILL_DIR = () => path.join(os.homedir(), '.claude', 'skills', 'primocode-engine');
205
+ const SKILL_PATH = () => path.join(SKILL_DIR(), 'SKILL.md');
206
+
207
+ /** Grava (ou regrava) a skill em ~/.claude/skills/primocode-engine/SKILL.md.
208
+ * Sempre reescreve com o texto atual — barato, e garante que uma versão
209
+ * nova do PrimoCode (com ferramenta nova em catalogo.js) atualiza a skill
210
+ * sozinha na próxima vez que /claude ligar. */
211
+ function garantirSkill() {
212
+ const dir = SKILL_DIR();
213
+ fs.mkdirSync(dir, { recursive: true });
214
+ const caminho = SKILL_PATH();
215
+ fs.writeFileSync(caminho, textoBootstrap(), 'utf-8');
216
+ return { ok: true, caminho };
217
+ }
218
+
219
+ /** executor(cmd) roda um comando e devolve a saída, ou lança. Default:
220
+ * execSync de verdade. Testes injetam uma função fake — sem mock framework. */
221
+ function temClaudeCode(executor = (cmd) => execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] })) {
222
+ try { executor('claude --version'); return true; }
223
+ catch { return false; }
224
+ }
225
+
226
+ function temOrca(executor = (cmd) => execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] })) {
227
+ try { executor('orca --version'); return true; }
228
+ catch { return false; }
229
+ }
230
+
231
+ /** executor aqui devolve TEXTO (o JSON cru de `orca status --json`), não
232
+ * só "roda ou lança" — por isso um default diferente do de cima. */
233
+ function orcaAlcancavel(executor = (cmd) => execSync(cmd, { encoding: 'utf-8' })) {
234
+ try {
235
+ const saida = executor('orca status --json');
236
+ const j = JSON.parse(saida);
237
+ return !!(j && j.result && j.result.runtime && j.result.runtime.reachable);
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ /** Função pura — nenhum I/O aqui, só decide. */
244
+ function escolherCaminho({ temClaude, temOrcaCli, orcaOk }) {
245
+ if (!temClaude) return 'sem-claude';
246
+ if (temOrcaCli && orcaOk) return 'A';
247
+ return 'B';
248
+ }
249
+
250
+ // Handle do terminal ORCA desta execução do processo — sessão contínua
251
+ // enquanto o CLI ficar de pé. Cursor de leitura por handle, pra "terminal
252
+ // read --cursor" trazer só o que é novo desde o turno anterior.
253
+ let sessao = { handle: null, cursor: '0', bootstrapEnviado: false };
254
+
255
+ function rodarOrca(args, opts = {}) {
256
+ return execFileSync('orca', args,
257
+ { encoding: 'utf-8', timeout: opts.timeout || 15000, ...SEM_APARECER });
258
+ }
259
+
260
+ let contadorId = 0;
261
+ /** Id sintético pro tool_call — o bloco marcado do Claude Code não traz um
262
+ * (só existe UM tool_call por resposta, pela própria regra do bootstrap),
263
+ * mas lib/act.js precisa de tc.id pra parear com a mensagem role:'tool' que
264
+ * vem depois. Único dentro do processo já basta. */
265
+ function proximoId() {
266
+ contadorId += 1;
267
+ return `claude-engine-${contadorId}`;
268
+ }
269
+
270
+ // Caminho A (sessão contínua via ORCA) tem um problema arquitetural real,
271
+ // achado rodando de verdade na verificação manual (Task 9 do plano): o
272
+ // Claude Code interativo desenha a TUI em tela alternada, e
273
+ // `orca terminal read` (scrollback linha a linha) nunca enxerga esse
274
+ // conteúdo — o tail fica preso no eco do comando de lançamento. `orca
275
+ // terminal show`'s preview mostra a tela renderizada de verdade, mas é só
276
+ // um recorte das últimas linhas visíveis, sem garantia de conter o bloco
277
+ // marcado inteiro numa resposta mais longa que o painel. Até esse
278
+ // mecanismo de leitura ser resolvido, o Caminho B (testado de ponta a
279
+ // ponta, funciona) é o único confiável — força ele sempre, mesmo com ORCA
280
+ // disponível. O código do Caminho A fica pronto pra quando isso for
281
+ // resolvido, só não é escolhido.
282
+ const CAMINHO_A_HABILITADO = false;
283
+
284
+ async function garantirEngine() {
285
+ // execFileSync('claude', …) no caminho headless não resolve o shim
286
+ // .cmd/.bat do Windows sem shell:true — quebraria em EVERY turno, mesmo
287
+ // com a detecção acima (que usa execSync com shell) passando. Recusa
288
+ // antes de instalar a skill: honesto e seguro, em vez de "sucesso" que
289
+ // quebra na primeira mensagem real.
290
+ if (process.platform === 'win32') {
291
+ return {
292
+ ok: false,
293
+ error: 'O motor Claude Code ainda não funciona no Windows.',
294
+ comoResolver: 'Aguarde uma próxima versão, ou escolha outra opção no /api.',
295
+ };
296
+ }
297
+ if (!temClaudeCode()) {
298
+ return {
299
+ ok: false,
300
+ error: 'Claude Code não está instalado nesta máquina.',
301
+ comoResolver: 'Instale o Claude Code (veja https://docs.claude.com/claude-code) e escolha Claude Code no /api de novo.',
302
+ };
303
+ }
304
+ garantirSkill();
305
+ const orcaCli = CAMINHO_A_HABILITADO && temOrca();
306
+ const caminho = escolherCaminho({
307
+ temClaude: true,
308
+ temOrcaCli: orcaCli,
309
+ orcaOk: orcaCli ? orcaAlcancavel() : false,
310
+ });
311
+ return { ok: true, caminho };
312
+ }
313
+
314
+ function abrirTerminalOrca() {
315
+ // --dangerously-skip-permissions aqui não dá poder novo ao Claude Code:
316
+ // a skill (textoBootstrap) já instrui ele a nunca usar suas próprias
317
+ // ferramentas nativas. A flag só pula os PRÓPRIOS prompts de permissão
318
+ // dele, que travariam a sessão sem ninguém pra responder.
319
+ const saida = rodarOrca(['terminal', 'create', '--command', 'claude --dangerously-skip-permissions', '--title', 'PrimoCode', '--json']);
320
+ const r = JSON.parse(saida);
321
+ // `orca terminal create --json` aninha o handle em result.terminal.handle,
322
+ // não em result.handle — confirmado rodando o comando de verdade (Task 9).
323
+ return r.result.terminal.handle;
324
+ }
325
+
326
+ function esperarIdleOrca(handle, timeoutMs) {
327
+ rodarOrca(['terminal', 'wait', '--terminal', handle, '--for', 'tui-idle', '--timeout-ms', String(timeoutMs)], { timeout: timeoutMs + 5000 });
328
+ }
329
+
330
+ function ultimaMensagem(messages) {
331
+ const m = (messages || [])[messages.length - 1];
332
+ return m && m.content ? String(m.content) : '';
333
+ }
334
+
335
+ /** Devolve a decisão pro onEvent — tool_call, ou assistant_message (com
336
+ * degradação pra texto puro quando o bloco marcado não veio, em vez de
337
+ * travar o turno em silêncio). */
338
+ /* ─────────────────────────────────────────────── o que não deu para ler ──
339
+ O motor às vezes devolve algo que este lado não sabe processar: bloco com
340
+ sintaxe torta, ou uma ferramenta que não existe. O usuário via isso na
341
+ tela — "O motor Claude Code devolveu uma decisão que não consegui ler" —
342
+ e a tarefa parava.
343
+
344
+ Erro de forma não é assunto do usuário. Quem errou a forma foi o motor, e
345
+ quem sabe consertar é ele. Então o problema volta PARA ELE, dizendo
346
+ exatamente o que estava errado, e ele reescreve a ação. Só depois de
347
+ algumas tentativas seguidas é que alguém precisa saber.
348
+
349
+ `pedirCorrecao` é preenchido por quem chamou (o caminho headless ou o
350
+ ORCA), porque só eles sabem como falar com o motor de novo. */
351
+ function diagnosticar(cru, nomesValidos) {
352
+ if (cru.includes(MARCA_INICIO) && !cru.includes(MARCA_FIM)) {
353
+ return 'você abriu ' + MARCA_INICIO + ' e nunca fechou com ' + MARCA_FIM + '.';
354
+ }
355
+ if (!cru.includes(MARCA_INICIO) && cru.includes(MARCA_FIM)) {
356
+ return 'apareceu ' + MARCA_FIM + ' sem o ' + MARCA_INICIO + ' antes.';
357
+ }
358
+ const dentro = /<<<PRIMOCODE>>>([\s\S]*?)<<<FIM>>>/.exec(cru);
359
+ if (dentro) {
360
+ try {
361
+ JSON.parse(dentro[1].trim());
362
+ return 'o JSON até é válido, mas não tem "tool_call" nem "assistant_message" na raiz.';
363
+ } catch (e) {
364
+ return 'o conteúdo entre as marcas não é JSON válido: ' + e.message
365
+ + '. Lembre de escapar quebra de linha como \\n dentro das strings.';
366
+ }
367
+ }
368
+ if (nomesValidos) return 'a ferramenta pedida não existe. As que existem: ' + nomesValidos + '.';
369
+ return 'não achei um bloco marcado na sua resposta.';
370
+ }
371
+
372
+ function textoDeCorrecao(cru, motivo) {
373
+ return 'A sua última resposta não pôde ser executada: ' + motivo
374
+ + '\n\nO que você mandou foi:\n---\n' + String(cru).slice(0, 1200) + '\n---\n\n'
375
+ + 'Reescreva AGORA a mesma intenção numa ação que o sistema reconheça. '
376
+ + 'Responda com exatamente UM bloco:\n'
377
+ + MARCA_INICIO + '\n{"tool_call":{"name":"<uma ferramenta do catálogo>","arguments":{...}}}\n' + MARCA_FIM
378
+ + '\nou, se era só texto para a pessoa, ' + MARCA_INICIO
379
+ + '\n{"assistant_message":"..."}\n' + MARCA_FIM
380
+ + '\nNada fora do bloco.';
381
+ }
382
+
383
+ const MAX_CORRECOES = 3;
384
+
385
+ async function emitirDecisao(textoLido, onEvent, pedirCorrecao, tentativa, validos) {
386
+ tentativa = tentativa || 0;
387
+ const decisao = extrairDecisao(textoLido);
388
+ if (!decisao) {
389
+ const cru = String(textoLido || '').trim();
390
+ // Traz a marca do protocolo mas o bloco não dá para ler? O motor
391
+ // TENTOU chamar ferramenta e errou a sintaxe. Em vez de mostrar isso
392
+ // ao usuário, devolvemos o problema ao motor com o diagnóstico e
393
+ // pedimos a mesma intenção escrita direito.
394
+ if (cru.includes(MARCA_INICIO) || cru.includes(MARCA_FIM)) {
395
+ if (pedirCorrecao && tentativa < MAX_CORRECOES) {
396
+ const motivo = diagnosticar(cru);
397
+ try {
398
+ const novo = await pedirCorrecao(textoDeCorrecao(cru, motivo));
399
+ return await emitirDecisao(novo, onEvent, pedirCorrecao, tentativa + 1, validos);
400
+ } catch (e) {
401
+ // A correção em si falhou (motor fora do ar, tempo
402
+ // esgotado). Cai no caminho antigo, que o loop repete.
403
+ }
404
+ }
405
+ onEvent({ erro: 'Failed to call a function: o motor Claude devolveu um bloco ilegível — repetindo a rodada.' });
406
+ return;
407
+ }
408
+ // Sem marca nenhuma: é resposta em texto, e texto é resposta válida.
409
+ onEvent({ assistant_message: { content: cru } });
410
+ return;
411
+ }
412
+ if (decisao.tool_call) {
413
+ // Ferramenta que não existe é o outro jeito de o motor devolver algo
414
+ // que este lado não sabe processar. Antes viraria uma ação falhada
415
+ // com "ferramenta desconhecida" — ruído para o usuário por um erro
416
+ // que é do motor. Volta para ele, com a lista do que existe.
417
+ const nome = decisao.tool_call.name;
418
+ if (validos && validos.length && validos.indexOf(nome) === -1) {
419
+ if (pedirCorrecao && tentativa < MAX_CORRECOES) {
420
+ try {
421
+ const novo = await pedirCorrecao(textoDeCorrecao(
422
+ JSON.stringify(decisao),
423
+ 'a ferramenta "' + nome + '" não existe. As que existem: ' + validos.join(', ') + '.'));
424
+ return await emitirDecisao(novo, onEvent, pedirCorrecao, tentativa + 1, validos);
425
+ } catch (e) { /* motor mudo: segue e deixa o loop tratar */ }
426
+ }
427
+ onEvent({ erro: 'Failed to call a function: ferramenta "' + nome + '" não existe — repetindo a rodada.' });
428
+ return;
429
+ }
430
+ // lib/act.js usa tc.id pra parear a mensagem role:'tool' com este
431
+ // tool_call (messages.push({ tool_call_id: tc.id, ... })) — sem id
432
+ // aqui, esse pareamento quebra.
433
+ onEvent({ tool_call: { id: proximoId(), name: nome, arguments: decisao.tool_call.arguments || {} } });
434
+ }
435
+ else if (decisao.assistant_message) {
436
+ // O modelo pode confundir a forma com a do tool_call (que É um
437
+ // objeto) e emitir {"assistant_message": {"content": "..."}} em vez
438
+ // de string pura. Sem essa coerção, `content` vira um objeto
439
+ // aninhado e lib/act.js quebra com TypeError no `.trim()` — a
440
+ // travessia direta que este arquivo inteiro existe pra evitar.
441
+ const am = decisao.assistant_message;
442
+ const conteudo = typeof am === 'string'
443
+ ? am
444
+ : (am && typeof am.content === 'string' ? am.content : JSON.stringify(am));
445
+ onEvent({ assistant_message: { content: conteudo } });
446
+ }
447
+ else onEvent({ assistant_message: { content: JSON.stringify(decisao) } });
448
+ }
449
+
450
+ async function requestActViaOrca(payload, onEvent) {
451
+ if (!sessao.handle) {
452
+ try {
453
+ sessao.handle = abrirTerminalOrca();
454
+ sessao.cursor = '0';
455
+ sessao.bootstrapEnviado = false;
456
+ esperarIdleOrca(sessao.handle, 30000);
457
+ } catch (e) {
458
+ // Mesmo tratamento do try/catch abaixo: se o terminal não
459
+ // chegou a ficar pronto (timeout do tui-idle, por exemplo), o
460
+ // handle nunca foi confirmado usável — zera pra próxima
461
+ // chamada abrir um terminal novo do zero, em vez de deixar
462
+ // sessao.handle setado com algo que nunca funcionou.
463
+ sessao.handle = null;
464
+ throw e;
465
+ }
466
+ }
467
+ let texto = ultimaMensagem(payload.messages);
468
+ if (!sessao.bootstrapEnviado) {
469
+ texto = 'Use a skill primocode-engine para esta sessão inteira — ela está em ~/.claude/skills/primocode-engine/SKILL.md.\n\n' + texto;
470
+ sessao.bootstrapEnviado = true;
471
+ }
472
+ try {
473
+ rodarOrca(['terminal', 'send', '--terminal', sessao.handle, '--text', texto, '--enter']);
474
+ esperarIdleOrca(sessao.handle, 120000);
475
+ const lida = rodarOrca(['terminal', 'read', '--terminal', sessao.handle, '--cursor', sessao.cursor, '--json']);
476
+ const leitura = JSON.parse(lida);
477
+ // Mesma correção do handle: `terminal read --json` também aninha em
478
+ // result.terminal — e o texto vem como ARRAY de linhas (`tail`), não
479
+ // uma string única (`text`). Confirmado rodando de verdade (Task 9).
480
+ const t = leitura.result.terminal || {};
481
+ sessao.cursor = t.nextCursor != null ? String(t.nextCursor) : sessao.cursor;
482
+ // No terminal a correção é só mais uma mensagem na MESMA sessão: o
483
+ // motor já tem o protocolo e a conversa toda em contexto, então
484
+ // basta dizer o que saiu errado.
485
+ const pedirCorrecao = (aviso) => {
486
+ rodarOrca(['terminal', 'send', '--terminal', sessao.handle, '--text', aviso, '--enter']);
487
+ esperarIdleOrca(sessao.handle, 120000);
488
+ const outra = JSON.parse(rodarOrca(['terminal', 'read', '--terminal', sessao.handle,
489
+ '--cursor', sessao.cursor, '--json']));
490
+ const t2 = outra.result.terminal || {};
491
+ sessao.cursor = t2.nextCursor != null ? String(t2.nextCursor) : sessao.cursor;
492
+ return (t2.tail || []).join('\n');
493
+ };
494
+ await emitirDecisao((t.tail || []).join('\n'), onEvent, pedirCorrecao, 0, nomesDasTools(payload));
495
+ } catch (e) {
496
+ // Handle obsoleto (terminal fechado, ORCA reiniciou) — documentado
497
+ // pelo próprio `orca agent-context` como terminal_handle_stale. Zera
498
+ // a sessão: a PRÓXIMA chamada abre um terminal novo do zero. Esta
499
+ // chamada ainda falha (o usuário vê o erro e pode só pedir de novo).
500
+ sessao.handle = null;
501
+ throw e;
502
+ }
503
+ }
504
+
505
+ /** Sem sessão viva (caminho B), o histórico inteiro vira texto a cada
506
+ * chamada — mas `content` sozinho perde a mensagem quando é um turno de
507
+ * tool_calls (content vem null ali, o que importa está em tool_calls) ou
508
+ * uma resposta de ferramenta (role:'tool'). Reconstrói os dois casos em
509
+ * vez de deixar "assistant: null" no texto. */
510
+ function linhaDoHistorico(m) {
511
+ if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
512
+ const chamadas = m.tool_calls
513
+ .map((tc) => `chamou ${tc.function.name} com ${tc.function.arguments}`)
514
+ .join('; ');
515
+ return `assistant: ${chamadas}`;
516
+ }
517
+ if (m.role === 'tool') return `resultado da ferramenta (${m.tool_call_id}): ${m.content}`;
518
+ return `${m.role}: ${m.content}`;
519
+ }
520
+
521
+ /**
522
+ * O protocolo INTEIRO, mais a conversa, num prompt só.
523
+ *
524
+ * A versão anterior dizia "use a skill primocode-engine" e confiava que o
525
+ * headless a carregaria — quando não carregava, o Claude respondia sem bloco
526
+ * nenhum e o turno morria. Prompt maior, mas o motor tem contexto de sobra e
527
+ * o formato SEMPRE chega.
528
+ *
529
+ * SEM o cabeçalho YAML da skill: ele começa com "---", e um prompt que começa
530
+ * com traço o parser de argumentos lê como OPÇÃO — "error: unknown option
531
+ * '---'" na cara do usuário, com o protocolo inteiro despejado no terminal.
532
+ * Foi exatamente o que aconteceu.
533
+ *
534
+ * Isto é o que o /claude e o /codex têm em comum, e é de propósito que more
535
+ * num lugar só: são dois motores, e tem de ser UM protocolo.
536
+ */
537
+ function corpoDoProtocolo() {
538
+ return textoBootstrap().replace(/^---[\s\S]*?---\s*/, '');
539
+ }
540
+
541
+ /** O pedido de conserto: o protocolo de novo, mais o que saiu errado. Sem a
542
+ * conversa — o assunto aqui é a FORMA, e repetir o histórico inteiro só
543
+ * gastaria contexto no que não é o problema. */
544
+ function promptDeCorrecao(aviso) {
545
+ return corpoDoProtocolo() + '\n\n' + aviso;
546
+ }
547
+
548
+ function promptDoProtocolo(payload) {
549
+ const historico = (payload.messages || []).map(linhaDoHistorico).join('\n\n');
550
+ return corpoDoProtocolo()
551
+ + '\n\nIMPORTANTE: no JSON do bloco, escreva quebras de linha como \\n (escapadas), nunca linha de verdade.'
552
+ + '\n\n# A conversa até aqui\n\n' + historico
553
+ + '\n\nResponda com exatamente UM bloco marcado.';
554
+ }
555
+
556
+ async function requestActViaHeadless(payload, onEvent) {
557
+ const corpoBootstrap = corpoDoProtocolo();
558
+ const prompt = promptDoProtocolo(payload);
559
+ // --dangerously-skip-permissions: mesma razão do caminho ORCA acima —
560
+ // a skill já proíbe o Claude Code de usar suas próprias ferramentas
561
+ // nativas; a flag só evita que ele pare pedindo permissão pra si mesmo
562
+ // numa chamada headless sem ninguém pra responder.
563
+ // O prompt vai por STDIN, nunca por argumento: argumento tem limite de
564
+ // tamanho (a conversa cresce) e qualquer conteúdo começando com traço
565
+ // seria lido como opção. stdin não tem nenhum dos dois problemas.
566
+ const saida = execFileSync('claude', [
567
+ '-p',
568
+ '--output-format', 'json',
569
+ '--dangerously-skip-permissions',
570
+ ...argsDoModelo(),
571
+ ], { encoding: 'utf-8', timeout: 180000, input: prompt, maxBuffer: 32 * 1024 * 1024,
572
+ env: process.env, ...SEM_APARECER });
573
+ const r = JSON.parse(saida);
574
+ // A correção é uma segunda ida ao motor, com o mesmo protocolo e o
575
+ // diagnóstico do que saiu errado. Barata: o prompt é curto, e evita que
576
+ // um erro de sintaxe do motor vire erro na cara do usuário.
577
+ const pedirCorrecao = (aviso) => {
578
+ const s = execFileSync('claude', [
579
+ '-p', '--output-format', 'json', '--dangerously-skip-permissions',
580
+ ...argsDoModelo(),
581
+ ], {
582
+ encoding: 'utf-8', timeout: 120000, maxBuffer: 32 * 1024 * 1024,
583
+ env: process.env,
584
+ input: promptDeCorrecao(aviso),
585
+ ...SEM_APARECER,
586
+ });
587
+ return JSON.parse(s).result || '';
588
+ };
589
+ await emitirDecisao(r.result || '', onEvent, pedirCorrecao, 0, nomesDasTools(payload));
590
+ }
591
+
592
+ /** Os nomes que o sistema realmente sabe executar, tirados do payload. */
593
+ function nomesDasTools(payload) {
594
+ return ((payload && payload.tools) || [])
595
+ .map((t) => (t && t.function && t.function.name) || t.name)
596
+ .filter(Boolean);
597
+ }
598
+
599
+ /** Mesma forma de api.requestAct(server, payload, onEvent) — `server` não
600
+ * é usado aqui (não existe servidor neste motor), fica só pra manter a
601
+ * assinatura uniforme com o outro lado da escolha em lib/act.js. */
602
+ async function requestAct(server, payload, onEvent) {
603
+ try {
604
+ const engineInfo = await garantirEngine();
605
+ if (!engineInfo.ok) {
606
+ onEvent({ erro: `${engineInfo.error} ${engineInfo.comoResolver || ''}`.trim() });
607
+ return;
608
+ }
609
+ if (engineInfo.caminho === 'A') await requestActViaOrca(payload, onEvent);
610
+ else await requestActViaHeadless(payload, onEvent);
611
+ } catch (e) {
612
+ onEvent({ erro: e.message });
613
+ }
614
+ }
615
+
616
+ module.exports = {
617
+ MARCA_INICIO, MARCA_FIM, extrairDecisao,
618
+ catalogoParaTexto, textoBootstrap,
619
+ // O protocolo sai daqui para o lib/codex-engine.js usar o MESMO. Ele é o
620
+ // que faz um motor de fora virar cérebro do PrimoCode: o texto que
621
+ // ensina o formato, a leitura do bloco marcado e a volta para consertar
622
+ // o que veio torto. Reimplementar aquilo no outro motor seria manter dois
623
+ // protocolos que divergem na primeira correção de um deles.
624
+ linhaDoHistorico, nomesDasTools, promptDoProtocolo, promptDeCorrecao,
625
+ MODELO_CLAUDE, argsDoModelo,
626
+ SKILL_DIR, SKILL_PATH, garantirSkill,
627
+ temClaudeCode, temOrca, orcaAlcancavel, escolherCaminho,
628
+ garantirEngine, requestAct, emitirDecisao,
629
+ _emitirDecisao: emitirDecisao,
630
+ };