liberty-code-cli 1.0.0 → 2.0.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.
package/src/repl.js ADDED
@@ -0,0 +1,285 @@
1
+ 'use strict';
2
+ /**
3
+ * REPL interativo — equivalente ao loop principal que antes vivia inteiro
4
+ * dentro de cmdChat() em bin/liberty.js. Agora é só orquestração: cada
5
+ * feature nova (permissões, memória, sessões, hooks, skills, mcp,
6
+ * subagentes) vive no seu próprio módulo, e este arquivo os conecta.
7
+ */
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const readline = require('readline');
11
+ const pkg = require('../package.json');
12
+ const { PERMISSION_MODES, MAX_TOOL_ITERATIONS } = require('./constants');
13
+ const { paint, banner } = require('./ui');
14
+ const { PermissionEngine } = require('./permissions');
15
+ const { buildIgnoreEngine } = require('./ignoreEngine');
16
+ const memory = require('./memory');
17
+ const sessionStore = require('./session');
18
+ const hooks = require('./hooks');
19
+ const { discoverSkills, buildSkillsManifestBlock } = require('./skills');
20
+ const { McpManager } = require('./mcp');
21
+ const { buildToolSchemas } = require('./tools');
22
+ const { runAgentLoop } = require('./engine');
23
+ const { runSubagent } = require('./subagents');
24
+ const { renderTodos } = require('./tools/todo-tool');
25
+
26
+ function confirm(rl, label) {
27
+ return new Promise(resolve => rl.question(label, a => resolve(['y', 'yes', 's', 'sim'].includes(a.trim().toLowerCase()))));
28
+ }
29
+
30
+ async function start(config, opts = {}) {
31
+ const cwd = process.cwd();
32
+ memory.ensureGlobalDir();
33
+
34
+ const permissions = new PermissionEngine(opts.permissionMode || PERMISSION_MODES.DEFAULT);
35
+ const ignoreEngine = buildIgnoreEngine(cwd);
36
+ const skills = discoverSkills(cwd);
37
+ const mcpManager = new McpManager();
38
+
39
+ console.log(banner(pkg.version, cwd));
40
+ console.log(paint('gray', ` modo: ${permissions.describeMode()}`));
41
+
42
+ const { connected, failed } = await mcpManager.connectAll(cwd);
43
+ if (connected.length || failed.length) mcpManager.printSummary(connected, failed);
44
+
45
+ const memoryBlock = memory.loadHierarchicalMemory(cwd);
46
+ const skillsBlock = buildSkillsManifestBlock(skills, cwd);
47
+ const sessionStartHook = hooks.runHooks('SessionStart', { cwd }, cwd);
48
+ sessionStartHook.infoLines.forEach(l => console.log(l));
49
+
50
+ const primingParts = [
51
+ memoryBlock,
52
+ skillsBlock,
53
+ sessionStartHook.rawOutputs.length ? `### Informações do início da sessão (hook SessionStart)\n${sessionStartHook.rawOutputs.join('\n\n')}` : null,
54
+ ].filter(Boolean);
55
+
56
+ let messages = [];
57
+ let todos = [];
58
+ let sessionId = sessionStore.newSessionId();
59
+
60
+ if (opts.resumeId) {
61
+ const loaded = sessionStore.loadSession(cwd, opts.resumeId);
62
+ if (loaded) {
63
+ messages = loaded.messages;
64
+ todos = loaded.todos || [];
65
+ sessionId = loaded.id;
66
+ console.log(paint('gray', ` sessão retomada: ${loaded.id} (${messages.length} mensagens)`));
67
+ } else {
68
+ console.log(paint('yellow', ` nenhuma sessão encontrada para "${opts.resumeId}" — começando uma nova.`));
69
+ }
70
+ }
71
+
72
+ if (!messages.length && primingParts.length) {
73
+ messages = [
74
+ { role: 'user', content: `[Contexto inicial da sessão — fornecido automaticamente, não é um pedido, só referência]\n\n${primingParts.join('\n\n---\n\n')}` },
75
+ { role: 'assistant', content: 'Entendido, vou levar esse contexto em conta.' },
76
+ ];
77
+ }
78
+ const primedMessages = messages.map(m => ({ ...m }));
79
+
80
+ if (memory.hasProjectMemory(cwd)) console.log(paint('gray', ' LIBERTY.md do projeto carregado'));
81
+ if (memory.hasGlobalMemory()) console.log(paint('gray', ' LIBERTY.md global carregado'));
82
+ if (skills.length) console.log(paint('gray', ` ${skills.length} skill(s) disponível(is) — /skills pra ver`));
83
+ console.log(paint('gray', '\nDigite seu pedido · /help pra ver comandos · # anota algo na memória · Ctrl+C cancela/sai\n'));
84
+
85
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
86
+ const backgroundProcs = [];
87
+ let currentAbort = null;
88
+
89
+ const ctx = {
90
+ cwd, rl, permissions, ignoreEngine, hooks, todos, backgroundProcs,
91
+ mcpManager, allowSubagents: true, token: config.token,
92
+ };
93
+ ctx.runSubagent = (args) => runSubagent(args, ctx);
94
+
95
+ rl.on('SIGINT', () => {
96
+ if (currentAbort) { currentAbort.abort(); console.log(paint('gray', '\n(cancelado)')); }
97
+ else { rl.close(); }
98
+ });
99
+
100
+ function autosave() {
101
+ try { sessionStore.saveSession(cwd, sessionId, { messages, todos, cwd }); } catch { /* não trava o REPL por falha ao salvar sessão */ }
102
+ }
103
+
104
+ function currentTools() {
105
+ return buildToolSchemas({ mode: permissions.mode, includeTask: true, mcpTools: mcpManager.getToolSchemas() });
106
+ }
107
+
108
+ async function runTurn(userText) {
109
+ messages.push({ role: 'user', content: userText });
110
+ currentAbort = new AbortController();
111
+ let result;
112
+ try {
113
+ result = await runAgentLoop({
114
+ messages, token: config.token, tools: currentTools(), ctx,
115
+ maxIterations: MAX_TOOL_ITERATIONS, verbose: true, signal: currentAbort.signal,
116
+ });
117
+ } catch (e) {
118
+ console.log(paint('red', `\n❌ ${e.message}\n`));
119
+ currentAbort = null;
120
+ autosave();
121
+ return;
122
+ }
123
+ currentAbort = null;
124
+
125
+ if (result.cancelled) { autosave(); return; }
126
+
127
+ if (result.stoppedByLimit) {
128
+ console.log(paint('yellow', `\n⚠ ${result.limitMessage}\n`));
129
+ messages.push({ role: 'assistant', content: result.limitMessage });
130
+ autosave();
131
+ return;
132
+ }
133
+
134
+ console.log('\n' + paint('bold', paint('gray', '● ')) + result.finalText + '\n');
135
+ if (todos.length) renderTodos(todos);
136
+ hooks.runHooks('Stop', { cwd }, cwd).infoLines.forEach(l => console.log(l));
137
+ autosave();
138
+
139
+ if (permissions.mode === PERMISSION_MODES.PLAN) {
140
+ const proceed = await confirm(rl, paint('cyan', '\nSair do modo plano e executar o que foi combinado? [y/N] '));
141
+ if (proceed) {
142
+ permissions.setMode(PERMISSION_MODES.DEFAULT);
143
+ console.log(paint('gray', ` modo alterado para: ${permissions.describeMode()}\n`));
144
+ await runTurn('Prossiga executando o plano combinado acima.');
145
+ }
146
+ }
147
+ }
148
+
149
+ function printHelp() {
150
+ console.log(paint('gray', `
151
+ Comandos:
152
+ /help Mostra estes comandos
153
+ /clear Limpa o histórico (mantém memória/skills carregadas)
154
+ /exit, /quit Sai
155
+ /init Explora o projeto e cria/atualiza o LIBERTY.md
156
+ /memory Mostra a memória (global + projeto) carregada agora
157
+ /mode [nome] Mostra ou troca o modo: default, acceptEdits, bypassPermissions, plan
158
+ /sessions Lista sessões salvas deste projeto
159
+ /resume <id|latest> Retoma uma sessão salva
160
+ /skills Lista skills carregadas (.liberty/skills)
161
+ /mcp Lista servidores MCP conectados (.liberty/mcp.json)
162
+ /bg Lista processos em background desta sessão
163
+ # texto Anota "texto" no LIBERTY.md do projeto sem enviar ao modelo
164
+
165
+ Ctrl+C durante uma resposta cancela só aquela resposta; parado no prompt, sai.
166
+ `));
167
+ }
168
+
169
+ const promptUser = () => rl.question(paint('cyan', '› '), async (input) => {
170
+ const trimmed = input.trim();
171
+ if (!trimmed) return promptUser();
172
+
173
+ if (trimmed.startsWith('#')) {
174
+ const note = trimmed.slice(1).trim();
175
+ if (note) {
176
+ const file = memory.appendQuickNote(cwd, note);
177
+ console.log(paint('gray', ` anotado em ${path.relative(cwd, file)}\n`));
178
+ }
179
+ return promptUser();
180
+ }
181
+
182
+ if (trimmed === '/exit' || trimmed === '/quit') { rl.close(); return; }
183
+ if (trimmed === '/help') { printHelp(); return promptUser(); }
184
+
185
+ if (trimmed === '/clear') {
186
+ messages = primedMessages.map(m => ({ ...m }));
187
+ todos.length = 0;
188
+ sessionId = sessionStore.newSessionId();
189
+ console.log(paint('gray', '\n(histórico limpo — memória/skills continuam carregadas)\n'));
190
+ return promptUser();
191
+ }
192
+
193
+ if (trimmed === '/memory') {
194
+ const block = memory.loadHierarchicalMemory(cwd);
195
+ console.log(block ? '\n' + paint('gray', block) + '\n' : paint('gray', '\nNenhuma memória carregada ainda. Use /init pra gerar uma, ou crie LIBERTY.md na raiz do projeto.\n'));
196
+ return promptUser();
197
+ }
198
+
199
+ if (trimmed === '/init') {
200
+ await runTurn(
201
+ 'Explore a estrutura deste projeto (list_directory recursivo, leia package.json/requirements.txt/etc, identifique stack e scripts de build/teste/lint) e depois escreva um arquivo LIBERTY.md na raiz com: visão geral do projeto, comandos de build/teste/lint, convenções de código observadas e o que evitar. Se já existir um LIBERTY.md, releia-o primeiro e proponha uma versão atualizada em vez de ignorar o que já está lá.'
202
+ );
203
+ return promptUser();
204
+ }
205
+
206
+ if (trimmed.startsWith('/mode')) {
207
+ const target = trimmed.split(/\s+/)[1];
208
+ if (!target) {
209
+ console.log(paint('gray', `\nModo atual: ${permissions.describeMode()}\nOpções: default, acceptEdits, bypassPermissions, plan\n`));
210
+ return promptUser();
211
+ }
212
+ if (target === PERMISSION_MODES.BYPASS) {
213
+ const ok = await confirm(rl, paint('red', 'bypassPermissions aprova QUALQUER escrita de arquivo ou comando sem perguntar. Confirma? [y/N] '));
214
+ if (!ok) { console.log(paint('gray', 'Mantido no modo anterior.\n')); return promptUser(); }
215
+ }
216
+ try {
217
+ permissions.setMode(target);
218
+ console.log(paint('gray', `\nModo alterado: ${permissions.describeMode()}\n`));
219
+ } catch (e) { console.log(paint('red', `\n${e.message}\n`)); }
220
+ return promptUser();
221
+ }
222
+
223
+ if (trimmed === '/sessions') {
224
+ const list = sessionStore.listSessions(cwd);
225
+ console.log(list.length
226
+ ? '\n' + list.map(s => paint('gray', ` ${s.id} (${s.messageCount} msgs, ${s.updatedAt}) `) + s.preview).join('\n') + '\n'
227
+ : paint('gray', '\nNenhuma sessão salva ainda pra este projeto.\n'));
228
+ return promptUser();
229
+ }
230
+
231
+ if (trimmed.startsWith('/resume')) {
232
+ const id = trimmed.split(/\s+/)[1] || 'latest';
233
+ const loaded = sessionStore.loadSession(cwd, id);
234
+ if (!loaded) { console.log(paint('red', `\nSessão "${id}" não encontrada. Use /sessions pra ver as disponíveis.\n`)); return promptUser(); }
235
+ messages = loaded.messages;
236
+ todos.length = 0; todos.push(...(loaded.todos || []));
237
+ sessionId = loaded.id;
238
+ if (todos.length) renderTodos(todos);
239
+ console.log(paint('gray', `\nSessão retomada: ${loaded.id} (${messages.length} mensagens)\n`));
240
+ return promptUser();
241
+ }
242
+
243
+ if (trimmed === '/skills') {
244
+ console.log(skills.length
245
+ ? '\n' + skills.map(s => paint('gray', ` [${s.scope}] `) + paint('bold', s.name) + paint('gray', ` — ${s.description}`)).join('\n') + '\n'
246
+ : paint('gray', '\nNenhuma skill encontrada. Crie .liberty/skills/<nome>/SKILL.md (ou ~/.libertycode/skills/<nome>/SKILL.md pra reusar entre projetos).\n'));
247
+ return promptUser();
248
+ }
249
+
250
+ if (trimmed === '/mcp') {
251
+ const names = [...mcpManager.servers.keys()];
252
+ console.log(names.length
253
+ ? '\n' + names.map(n => paint('gray', ` ${n}`)).join('\n') + '\n'
254
+ : paint('gray', '\nNenhum servidor MCP conectado. Configure .liberty/mcp.json (mesmo formato do mcpServers do Claude Code).\n'));
255
+ return promptUser();
256
+ }
257
+
258
+ if (trimmed === '/bg') {
259
+ console.log(backgroundProcs.length
260
+ ? '\n' + backgroundProcs.map(p => paint('gray', ` PID ${p.pid} ${p.command} → ${p.logFile}`)).join('\n') + '\n'
261
+ : paint('gray', '\nNenhum processo em background nesta sessão.\n'));
262
+ return promptUser();
263
+ }
264
+
265
+ const promptHook = hooks.runHooks('UserPromptSubmit', { cwd, prompt: trimmed }, cwd);
266
+ promptHook.infoLines.forEach(l => console.log(l));
267
+ if (promptHook.blocked) { console.log(paint('red', `\n🚫 ${promptHook.reason}\n`)); return promptUser(); }
268
+ const finalInput = promptHook.rawOutputs.length
269
+ ? `${input}\n\n[Contexto adicional de hook UserPromptSubmit]\n${promptHook.rawOutputs.join('\n')}`
270
+ : input;
271
+
272
+ await runTurn(finalInput);
273
+ promptUser();
274
+ });
275
+
276
+ rl.on('close', async () => {
277
+ try { await mcpManager.disconnectAll(); } catch { /* processo já está saindo mesmo */ }
278
+ console.log(paint('gray', '\nAté mais!'));
279
+ process.exit(0);
280
+ });
281
+
282
+ promptUser();
283
+ }
284
+
285
+ module.exports = { start };
package/src/session.js ADDED
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+ /**
3
+ * Sessões retomáveis — equivalente ao --continue / --resume do Claude Code.
4
+ *
5
+ * ANTES: `messages` vivia só na memória do processo; fechar o terminal (ou
6
+ * o Ctrl+C do dia seguinte) perdia toda a conversa e o contexto acumulado.
7
+ *
8
+ * AGORA: depois de cada turno completo, a sessão inteira (mensagens + todos)
9
+ * é salva em ~/.libertycode/sessions/<projeto>/<id>.json. `liberty --continue`
10
+ * retoma a última sessão daquele diretório; `liberty --resume` lista as
11
+ * sessões recentes pra escolher; `/sessions` faz o mesmo de dentro do REPL.
12
+ *
13
+ * Fica em ~/.libertycode (fora do projeto) de propósito — sessão pode conter
14
+ * trechos de código/arquivos lidos, não é algo pra acabar commitado sem querer.
15
+ */
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { SESSIONS_ROOT, slugifyProjectPath, ensureDir } = require('./constants');
19
+
20
+ function sessionsDirFor(cwd) {
21
+ return ensureDir(path.join(SESSIONS_ROOT, slugifyProjectPath(cwd)));
22
+ }
23
+
24
+ function newSessionId() {
25
+ const now = new Date();
26
+ const stamp = now.toISOString().replace(/[:.]/g, '-');
27
+ return `${stamp}-${Math.random().toString(36).slice(2, 7)}`;
28
+ }
29
+
30
+ function sessionPath(cwd, id) {
31
+ return path.join(sessionsDirFor(cwd), `${id}.json`);
32
+ }
33
+
34
+ function saveSession(cwd, id, { messages, todos, cwd: cwdField }) {
35
+ const file = sessionPath(cwd, id);
36
+ const existing = fs.existsSync(file) ? safeReadJson(file) : null;
37
+ const payload = {
38
+ id,
39
+ cwd: cwdField || cwd,
40
+ createdAt: existing?.createdAt || new Date().toISOString(),
41
+ updatedAt: new Date().toISOString(),
42
+ messages,
43
+ todos: todos || [],
44
+ };
45
+ fs.writeFileSync(file, JSON.stringify(payload, null, 2), 'utf8');
46
+ return file;
47
+ }
48
+
49
+ function safeReadJson(file) {
50
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
51
+ }
52
+
53
+ /** Lista sessões do projeto atual, mais recente primeiro. */
54
+ function listSessions(cwd, limit = 15) {
55
+ const dir = sessionsDirFor(cwd);
56
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.json'));
57
+ const sessions = files.map(f => safeReadJson(path.join(dir, f))).filter(Boolean);
58
+ sessions.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''));
59
+ return sessions.slice(0, limit).map(s => ({
60
+ id: s.id,
61
+ updatedAt: s.updatedAt,
62
+ messageCount: (s.messages || []).length,
63
+ preview: firstUserPreview(s.messages),
64
+ }));
65
+ }
66
+
67
+ function firstUserPreview(messages) {
68
+ const firstUser = (messages || []).find(m => m.role === 'user' && typeof m.content === 'string' && !m.content.startsWith('[Contexto do projeto'));
69
+ if (!firstUser) return '(sem mensagens do usuário ainda)';
70
+ return firstUser.content.slice(0, 70).replace(/\n/g, ' ');
71
+ }
72
+
73
+ function loadSession(cwd, id) {
74
+ if (id === 'latest') {
75
+ const [latest] = listSessions(cwd, 1);
76
+ if (!latest) return null;
77
+ return safeReadJson(sessionPath(cwd, latest.id));
78
+ }
79
+ return safeReadJson(sessionPath(cwd, id));
80
+ }
81
+
82
+ module.exports = { newSessionId, saveSession, listSessions, loadSession, sessionsDirFor };
package/src/skills.js ADDED
@@ -0,0 +1,94 @@
1
+ 'use strict';
2
+ /**
3
+ * Skills — equivalente às Skills do Claude Code.
4
+ *
5
+ * NOVO (v2): conceito não existia pro CLI (o "skills/" que já existia no
6
+ * server.js é outra coisa — prompts internos do servidor, não isso aqui).
7
+ *
8
+ * Ideia central, igual ao Claude Code: NÃO carregar o conteúdo de todas as
9
+ * skills na mensagem de sistema (isso ia inflar o contexto rápido). Em vez
10
+ * disso:
11
+ * 1. No início da sessão, escaneia .liberty/skills/*\/SKILL.md (projeto) e
12
+ * ~/.libertycode/skills/*\/SKILL.md (global/reutilizável entre projetos).
13
+ * 2. Monta só um MANIFESTO curto (nome + descrição de 1 linha de cada) e
14
+ * injeta isso no contexto.
15
+ * 3. O modelo decide, pelo próprio manifesto, se alguma skill combina com o
16
+ * pedido do usuário — e se sim, abre o arquivo completo com a ferramenta
17
+ * read_file que ele já tem (não precisa de uma ferramenta nova).
18
+ *
19
+ * Cada SKILL.md pode ter um front-matter simples:
20
+ * ---
21
+ * name: revisar-pr
22
+ * description: Use ao revisar um Pull Request — checklist de segurança, testes e estilo.
23
+ * ---
24
+ * Se não tiver front-matter, usa a 1ª linha "# Título" como nome e a 1ª
25
+ * linha de texto depois dela como descrição (fallback tolerante).
26
+ */
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const { projectSkillsDir, GLOBAL_SKILLS_DIR } = require('./constants');
30
+
31
+ function parseFrontMatter(content) {
32
+ const fm = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
33
+ if (!fm) return null;
34
+ const meta = {};
35
+ fm[1].split('\n').forEach(line => {
36
+ const m = line.match(/^(\w[\w-]*)\s*:\s*(.+)$/);
37
+ if (m) meta[m[1].trim()] = m[2].trim();
38
+ });
39
+ return meta;
40
+ }
41
+
42
+ function fallbackMeta(content, dirName) {
43
+ const titleMatch = content.match(/^#\s+(.+)$/m);
44
+ const afterTitle = content.split('\n').slice(1).find(l => l.trim() && !l.startsWith('#'));
45
+ return {
46
+ name: titleMatch ? titleMatch[1].trim() : dirName,
47
+ description: (afterTitle || 'Sem descrição.').trim().slice(0, 160),
48
+ };
49
+ }
50
+
51
+ function scanSkillsDir(dir, scope) {
52
+ const found = [];
53
+ if (!fs.existsSync(dir)) return found;
54
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
55
+ if (!entry.isDirectory()) continue;
56
+ const skillFile = path.join(dir, entry.name, 'SKILL.md');
57
+ if (!fs.existsSync(skillFile)) continue;
58
+ try {
59
+ const content = fs.readFileSync(skillFile, 'utf8');
60
+ const meta = parseFrontMatter(content) || fallbackMeta(content, entry.name);
61
+ found.push({
62
+ name: meta.name || entry.name,
63
+ description: meta.description || '(sem descrição)',
64
+ path: skillFile,
65
+ scope,
66
+ });
67
+ } catch { /* SKILL.md ilegível — ignora essa skill, não derruba o resto */ }
68
+ }
69
+ return found;
70
+ }
71
+
72
+ function discoverSkills(cwd) {
73
+ return [
74
+ ...scanSkillsDir(GLOBAL_SKILLS_DIR, 'global'),
75
+ ...scanSkillsDir(projectSkillsDir(cwd), 'projeto'),
76
+ ];
77
+ }
78
+
79
+ function buildSkillsManifestBlock(skills, cwd) {
80
+ if (!skills.length) return null;
81
+ const lines = skills.map(s => {
82
+ const rel = path.relative(cwd, s.path);
83
+ return `- **${s.name}** (${s.scope}, leia com read_file em \`${rel}\`): ${s.description}`;
84
+ });
85
+ return [
86
+ '### Skills disponíveis',
87
+ 'Antes de tarefas que envolvam código, arquivos ou comandos, verifique se alguma skill abaixo combina com o pedido.',
88
+ 'Se combinar, leia o SKILL.md completo (read_file) ANTES de agir — ele tem instruções específicas deste projeto/usuário que você não tem por padrão.',
89
+ 'Não leia skills irrelevantes só por precaução; isso desperdiça contexto.',
90
+ ...lines,
91
+ ].join('\n');
92
+ }
93
+
94
+ module.exports = { discoverSkills, buildSkillsManifestBlock };
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+ /**
3
+ * Subagentes — equivalente à ferramenta Task do Claude Code.
4
+ *
5
+ * NOVO (v2): antes cada chamada de ferramenta acontecia dentro da MESMA
6
+ * conversa/contexto. Isso é ruim pra tarefas tipo "vasculhe o repositório
7
+ * inteiro procurando todo lugar que usa a função X" — o histórico de
8
+ * list_directory/read_file/grep_search intermediários (que pode ser MUITO
9
+ * texto) fica misturado com a conversa principal pro resto da sessão, mesmo
10
+ * que só o resultado final importe.
11
+ *
12
+ * O subagente resolve isso: roda o MESMO engine.runAgentLoop, mas com:
13
+ * - histórico próprio, começando do zero (só o `prompt` recebido, sem ver
14
+ * nada da conversa principal — isolamento real, não só "resumo")
15
+ * - ferramentas restritas por subagent_type
16
+ * - teto de iterações menor (é uma tarefa focada, não uma conversa livre)
17
+ * - allowSubagents:false (profundidade máxima 1 — subagente não dispara
18
+ * outro subagente, evita explosão de custo/recursão)
19
+ * e devolve pro agente principal só o TEXTO FINAL — o resto (chamadas de
20
+ * ferramenta intermediárias) fica só no log do terminal, nunca entra na
21
+ * conversa principal.
22
+ */
23
+ const { MAX_SUBAGENT_ITERATIONS } = require('./constants');
24
+ const { buildSubagentToolSchemas } = require('./tools');
25
+ const { runAgentLoop } = require('./engine');
26
+ const { paint, spinner } = require('./ui');
27
+
28
+ function lastAssistantText(messages) {
29
+ const last = [...messages].reverse().find(m => m.role === 'assistant' && typeof m.content === 'string' && m.content.trim());
30
+ return last ? last.content : '(sem progresso textual registrado)';
31
+ }
32
+
33
+ async function runSubagent(args, parentCtx) {
34
+ const { description, prompt, subagent_type = 'general' } = args;
35
+ if (!prompt || !prompt.trim()) throw new Error('prompt é obrigatório e precisa ser autocontido — o subagente não vê o histórico da conversa principal.');
36
+ if (!['general', 'read-only-researcher'].includes(subagent_type)) throw new Error('subagent_type deve ser "general" ou "read-only-researcher".');
37
+
38
+ console.log(paint('magenta', `\n🤖 Subagente [${subagent_type}]: `) + paint('bold', description || prompt.slice(0, 60)));
39
+ const spin = spinner(` trabalhando`);
40
+
41
+ const mcpTools = parentCtx.mcpManager ? parentCtx.mcpManager.getToolSchemas() : [];
42
+ const tools = buildSubagentToolSchemas(subagent_type, mcpTools);
43
+ const subMessages = [{ role: 'user', content: prompt }];
44
+ const subCtx = { ...parentCtx, allowSubagents: false, todos: [] }; // profundidade máx. 1; lista de todos própria, não polui a principal
45
+ const systemPromptSuffix = [
46
+ 'Você é um SUBAGENTE disparado pela ferramenta "task" de outra instância sua.',
47
+ 'Você NÃO tem acesso ao histórico da conversa principal — apenas à instrução do usuário abaixo. Se algo essencial não foi dito, assuma o caminho mais razoável e deixe isso explícito na resposta final.',
48
+ 'Ao terminar, responda com um RESUMO CONCISO e acionável do que encontrou/fez — é a ÚNICA parte que a conversa principal vai ver, os passos intermediários se perdem.',
49
+ subagent_type === 'read-only-researcher' ? 'Você só tem ferramentas de leitura/busca — não pode editar arquivos nem rodar comandos.' : '',
50
+ ].filter(Boolean).join(' ');
51
+
52
+ let result;
53
+ try {
54
+ result = await runAgentLoop({
55
+ messages: subMessages, token: parentCtx.token, tools, systemPromptSuffix,
56
+ ctx: subCtx, maxIterations: MAX_SUBAGENT_ITERATIONS, verbose: false,
57
+ });
58
+ } finally {
59
+ spin.stop();
60
+ }
61
+
62
+ if (result.cancelled) return '[Subagente cancelado pelo usuário.]';
63
+ if (result.stoppedByLimit) {
64
+ return `[Subagente atingiu o limite de ${MAX_SUBAGENT_ITERATIONS} passos sem concluir formalmente. Progresso registrado até aqui:]\n${lastAssistantText(subMessages)}`;
65
+ }
66
+ console.log(paint('gray', ` ✓ subagente concluído`));
67
+ return result.finalText || '(subagente não retornou texto)';
68
+ }
69
+
70
+ module.exports = { runSubagent };
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+ /**
3
+ * run_command — v1 só tinha execução síncrona bloqueante (execSync), o que
4
+ * é um problema real pra qualquer comando de longa duração: `npm run dev`,
5
+ * um servidor, um watcher... o comando trava o loop do agente até terminar
6
+ * (ou até o timeout de 60s matar ele), e nunca devolve controle.
7
+ *
8
+ * NOVO: run_in_background:true usa spawn() destacado, redireciona
9
+ * stdout+stderr pra um arquivo de log em .liberty/logs/ e devolve o controle
10
+ * IMEDIATAMENTE com o caminho do log + PID — o modelo pode continuar a
11
+ * conversa e checar o log depois com read_file (ou grep_search) quando quiser
12
+ * saber se o servidor subiu.
13
+ */
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const { execSync, spawn } = require('child_process');
17
+ const { RUN_COMMAND_TIMEOUT_MS, RUN_COMMAND_MAX_BUFFER, projectLogsDir, ensureDir } = require('../constants');
18
+ const { paint } = require('../ui');
19
+
20
+ async function toolRunCommand(args, ctx) {
21
+ console.log(paint('yellow', '\n💻 Executar comando: ') + paint('bold', args.command) +
22
+ (args.run_in_background ? paint('gray', ' (background)') : ''));
23
+
24
+ const hookRes = ctx.hooks.runHooks('PreToolUse', { toolName: 'run_command', args, cwd: ctx.cwd }, ctx.cwd);
25
+ hookRes.infoLines.forEach(l => console.log(l));
26
+ if (hookRes.blocked) return `[Bloqueado por hook: ${hookRes.reason}]`;
27
+
28
+ const ok = await ctx.permissions.check('run', args.command, ' Permitir?', ctx.rl);
29
+ if (!ok) return '[Usuário negou a execução deste comando.]';
30
+
31
+ const result = args.run_in_background
32
+ ? runInBackground(args.command, ctx)
33
+ : runForeground(args.command, ctx);
34
+
35
+ ctx.hooks.runHooks('PostToolUse', { toolName: 'run_command', args, cwd: ctx.cwd }, ctx.cwd)
36
+ .infoLines.forEach(l => console.log(l));
37
+ return result;
38
+ }
39
+
40
+ function runForeground(command, ctx) {
41
+ try {
42
+ const output = execSync(command, {
43
+ cwd: ctx.cwd, encoding: 'utf8',
44
+ timeout: RUN_COMMAND_TIMEOUT_MS, maxBuffer: RUN_COMMAND_MAX_BUFFER,
45
+ });
46
+ return output || '(comando executado sem output)';
47
+ } catch (e) {
48
+ if (e.signal === 'SIGTERM' || e.killed) {
49
+ return `[Comando interrompido após ${RUN_COMMAND_TIMEOUT_MS / 1000}s (timeout). Se é um processo de longa duração (servidor, watcher), rode com run_in_background:true.]`;
50
+ }
51
+ return `Erro (exit ${e.status ?? '?'}):\n${(e.stdout || '') + (e.stderr || e.message)}`;
52
+ }
53
+ }
54
+
55
+ function runInBackground(command, ctx) {
56
+ const logsDir = ensureDir(projectLogsDir(ctx.cwd));
57
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
58
+ const logFile = path.join(logsDir, `${stamp}.log`);
59
+ const fd = fs.openSync(logFile, 'a');
60
+
61
+ const child = spawn(command, {
62
+ cwd: ctx.cwd, shell: true, detached: true, stdio: ['ignore', fd, fd],
63
+ });
64
+ child.unref();
65
+ const relLog = path.relative(ctx.cwd, logFile);
66
+ ctx.backgroundProcs = ctx.backgroundProcs || [];
67
+ ctx.backgroundProcs.push({ pid: child.pid, command, logFile: relLog, startedAt: new Date().toISOString() });
68
+
69
+ return `Processo iniciado em background (PID ${child.pid}). Saída sendo gravada em ${relLog} — use read_file nesse caminho pra acompanhar. ` +
70
+ `Este comando NÃO é encerrado automaticamente ao fim da sessão; para parar, rode "kill ${child.pid}" (Linux/Mac) via run_command.`;
71
+ }
72
+
73
+ module.exports = { toolRunCommand };