terminal-smart-cli 0.32.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/lib/acp.js ADDED
@@ -0,0 +1,191 @@
1
+ // lib/acp.js — servidor ACP (Agent Client Protocol) do ts sobre stdio.
2
+ //
3
+ // ACP é o protocolo JSON-RPC 2.0 (mensagens JSON delimitadas por \n, NÃO framing
4
+ // Content-Length do LSP) que editores como o Zed usam pra dirigir um agente de
5
+ // código. O editor lança `ts acp` como subprocesso e conversa por stdin/stdout.
6
+ //
7
+ // Implementa o NÚCLEO:
8
+ // • initialize — handshake + capacidades
9
+ // • authenticate — no-op (a sessão ts vem do `ts login` local)
10
+ // • session/new — cria sessão (cwd do projeto) → sessionId
11
+ // • session/prompt — roda o agente e faz STREAMING de session/update
12
+ // (tool_call / tool_call_update / agent_message_chunk),
13
+ // devolve { stopReason }
14
+ // • session/cancel — notificação; marca a sessão como cancelada
15
+ // Comando DESTRUTIVO → pede session/request_permission ao editor (bidirecional):
16
+ // o agente manda um request e ESPERA a escolha do usuário no IDE.
17
+ //
18
+ // REGRA DE OURO do stdio: stdout carrega SÓ mensagens do protocolo. TODO log vai
19
+ // pra stderr — um console.log solto corromperia o JSON-RPC.
20
+ const readline = require('readline');
21
+ const path = require('path');
22
+ const agent = require('./agent');
23
+
24
+ const PROTOCOL_VERSION = 1;
25
+
26
+ // kind da tool call (dica de ícone pro editor)
27
+ function _kindOf(name) {
28
+ const n = String(name || '');
29
+ if (/escrever|editar|criar|restaurar/i.test(n)) return 'edit';
30
+ if (/ler|listar|buscar|mapa|info|planilha/i.test(n)) return 'read';
31
+ if (/executar|comando|remoto/i.test(n)) return 'execute';
32
+ if (/explorar/i.test(n)) return 'think';
33
+ return 'other';
34
+ }
35
+
36
+ function acpServer({ token, lang = 'pt' }) {
37
+ const write = (obj) => { try { process.stdout.write(JSON.stringify(obj) + '\n'); } catch (_) {} };
38
+ const log = (...a) => { try { process.stderr.write('[ts acp] ' + a.join(' ') + '\n'); } catch (_) {} };
39
+ const notify = (method, params) => write({ jsonrpc: '2.0', method, params });
40
+ const reply = (id, result) => write({ jsonrpc: '2.0', id, result: result === undefined ? {} : result });
41
+ const replyErr = (id, code, message) => write({ jsonrpc: '2.0', id, error: { code, message } });
42
+
43
+ const sessions = new Map(); // sessionId → { cwd, cancelled }
44
+ const pending = new Map(); // id → resolve (requests que NÓS enviamos ao cliente)
45
+ let seq = 0;
46
+
47
+ // envia um REQUEST ao cliente (ex.: pedir permissão) e espera a resposta. TIMEOUT:
48
+ // se o editor não responder em 5 min, resolve null (→ o chamador nega, fail-safe). O
49
+ // sid amarra o pending à sessão pra que session/cancel possa destravá-lo na hora.
50
+ const clientRequest = (method, params, sid, timeoutMs = 300000) => new Promise((resolve) => {
51
+ const id = 'ts-' + (++seq);
52
+ let done = false, timer = null;
53
+ const finish = (v) => { if (done) return; done = true; if (timer) clearTimeout(timer); pending.delete(id); resolve(v); };
54
+ pending.set(id, { finish, sid });
55
+ timer = setTimeout(() => { log('timeout esperando resposta de ' + method); finish(null); }, timeoutMs);
56
+ if (timer.unref) timer.unref();
57
+ write({ jsonrpc: '2.0', id, method, params });
58
+ });
59
+
60
+ const handlers = {
61
+ initialize: async (params) => {
62
+ const pv = Number(params && params.protocolVersion) || PROTOCOL_VERSION;
63
+ return {
64
+ protocolVersion: Math.min(PROTOCOL_VERSION, pv),
65
+ agentCapabilities: { loadSession: false, promptCapabilities: { image: false, audio: false, embeddedContext: true } },
66
+ authMethods: [],
67
+ };
68
+ },
69
+ authenticate: async () => ({}), // a autenticação real é o `ts login` (sessão sk-hub local)
70
+ 'session/new': async (params) => {
71
+ const cwd = (params && params.cwd) ? path.resolve(String(params.cwd)) : process.cwd();
72
+ const id = 'sess-' + (++seq) + '-' + Date.now().toString(36);
73
+ sessions.set(id, { cwd, cancelled: false });
74
+ log('nova sessão', id, 'cwd=' + cwd);
75
+ return { sessionId: id };
76
+ },
77
+ 'session/prompt': async (params) => {
78
+ const sid = params && params.sessionId;
79
+ const sess = sessions.get(sid);
80
+ if (!sess) { const e = new Error('sessão desconhecida'); e.code = -32602; throw e; }
81
+ if (!token) {
82
+ notify('session/update', { sessionId: sid, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Não autenticado. Rode `ts login` no terminal e reabra o editor.' } } });
83
+ return { stopReason: 'refusal' };
84
+ }
85
+ // barra prompt CONCORRENTE na mesma sessão: sobreporia sess.cancelled e os toolCallId
86
+ // (o editor associaria updates ao widget errado). Um prompt por sessão de cada vez.
87
+ if (sess.running) {
88
+ notify('session/update', { sessionId: sid, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: lang === 'en' ? 'A prompt is already running in this session.' : 'Já há uma execução em andamento nesta sessão.' } } });
89
+ return { stopReason: 'refusal' };
90
+ }
91
+ // extrai o texto dos blocos de conteúdo do prompt
92
+ const blocks = Array.isArray(params.prompt) ? params.prompt : [];
93
+ const text = blocks.filter(b => b && (b.type === 'text' || typeof b.text === 'string')).map(b => b.text || '').join('\n').trim();
94
+ if (!text) return { stopReason: 'end_turn' };
95
+
96
+ sess.cancelled = false;
97
+ sess.running = true;
98
+ let out = null;
99
+ try {
100
+ out = await agent.run(text, {
101
+ token, lang, yes: false, cwd: sess.cwd, confineDir: null,
102
+ shouldStop: () => sess.cancelled, // cancelamento cooperativo: para no próximo passo
103
+ onThinking: () => {},
104
+ onStep: ({ name, detail, blocked }) => {
105
+ const tcid = 'tc-' + (++seq); // id GLOBAL → nunca colide entre prompts/sessões
106
+ // tool_call (início) + tool_call_update (fim) — nosso onStep dispara já
107
+ // com o passo concluído, então mandamos os dois em sequência.
108
+ notify('session/update', { sessionId: sid, update: {
109
+ sessionUpdate: 'tool_call', toolCallId: tcid,
110
+ title: name + (detail ? ' · ' + detail : ''), kind: _kindOf(name),
111
+ status: blocked ? 'failed' : 'in_progress',
112
+ } });
113
+ notify('session/update', { sessionId: sid, update: {
114
+ sessionUpdate: 'tool_call_update', toolCallId: tcid,
115
+ status: blocked ? 'failed' : 'completed',
116
+ } });
117
+ },
118
+ askApprove: async (cmd) => {
119
+ if (sess.cancelled) return false;
120
+ // pede a decisão ao EDITOR (o usuário aprova/recusa na UI do IDE)
121
+ const res = await clientRequest('session/request_permission', {
122
+ sessionId: sid,
123
+ toolCall: { toolCallId: 'perm-' + (++seq), title: 'Comando destrutivo: ' + cmd, kind: 'execute' },
124
+ options: [
125
+ { optionId: 'allow', name: lang === 'en' ? 'Allow once' : 'Permitir', kind: 'allow_once' },
126
+ { optionId: 'reject', name: lang === 'en' ? 'Reject' : 'Recusar', kind: 'reject_once' },
127
+ ],
128
+ }, sid);
129
+ const o = res && res.outcome;
130
+ return !!(o && o.outcome === 'selected' && o.optionId === 'allow');
131
+ },
132
+ onRemote: () => {},
133
+ });
134
+ } catch (e) {
135
+ if (e && (e.code === 'no_credits' || e.status === 402)) {
136
+ notify('session/update', { sessionId: sid, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: lang === 'en' ? 'AI limit reached on this plan — top up/upgrade to continue.' : 'Limite de IA do plano atingido — recarregue/upgrade pra continuar.' } } });
137
+ return { stopReason: 'refusal' };
138
+ }
139
+ log('erro no prompt:', String((e && e.message) || e));
140
+ notify('session/update', { sessionId: sid, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: (lang === 'en' ? 'Error: ' : 'Erro: ') + String((e && e.message) || e) } } });
141
+ return { stopReason: 'refusal' };
142
+ } finally {
143
+ sess.running = false;
144
+ }
145
+ if (sess.cancelled) return { stopReason: 'cancelled' };
146
+ // texto final do agente → chunk de mensagem
147
+ if (out && out.text) notify('session/update', { sessionId: sid, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: out.text } } });
148
+ return { stopReason: 'end_turn' };
149
+ },
150
+ // NOTIFICAÇÃO (sem id): marca cancelamento (o agente para no próximo passo via
151
+ // shouldStop) E destrava qualquer permissão em voo DESTA sessão (→ nega na hora),
152
+ // senão a run ficaria presa até o timeout de 5 min esperando o editor.
153
+ 'session/cancel': async (params) => {
154
+ const sid = params && params.sessionId;
155
+ const sess = sessions.get(sid);
156
+ if (sess) sess.cancelled = true;
157
+ for (const [, p] of pending) if (p.sid === sid) p.finish(null);
158
+ return undefined;
159
+ },
160
+ };
161
+
162
+ // loop de entrada: uma mensagem JSON-RPC por LINHA.
163
+ const rl = readline.createInterface({ input: process.stdin });
164
+ rl.on('line', async (line) => {
165
+ line = String(line || '').trim();
166
+ if (!line) return;
167
+ let msg; try { msg = JSON.parse(line); } catch (_) { log('linha não-JSON ignorada'); return; }
168
+ // é uma RESPOSTA (tem result/error e nenhum method)? só nos interessa se casa com um
169
+ // request NOSSO pendente; órfã a gente IGNORA (nunca responder a uma resposta).
170
+ if (msg.method === undefined && (msg.result !== undefined || msg.error !== undefined)) {
171
+ const p = (msg.id !== undefined) ? pending.get(msg.id) : null;
172
+ if (p) p.finish(msg.error ? null : msg.result); // finish já limpa o pending
173
+ return;
174
+ }
175
+ // REQUEST ou NOTIFICAÇÃO vinda do cliente
176
+ const h = handlers[msg.method];
177
+ if (!h) { if (msg.id !== undefined) replyErr(msg.id, -32601, 'método não implementado: ' + msg.method); return; }
178
+ try {
179
+ const result = await h(msg.params || {});
180
+ if (msg.id !== undefined) reply(msg.id, result);
181
+ } catch (e) {
182
+ // code do JSON-RPC DEVE ser inteiro (ApiError/erros de SO têm .code string) → blinda.
183
+ if (msg.id !== undefined) replyErr(msg.id, Number.isInteger(e && e.code) ? e.code : -32603, String((e && e.message) || e));
184
+ else log('erro em notificação', msg.method, String((e && e.message) || e));
185
+ }
186
+ });
187
+ rl.on('close', () => process.exit(0));
188
+ log('pronto (protocolo ' + PROTOCOL_VERSION + ')');
189
+ }
190
+
191
+ module.exports = { acpServer, PROTOCOL_VERSION, _kindOf };