chatflux-gestao-agente 0.1.4 → 0.2.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/README.md +101 -3
- package/bin/gestao.mjs +53 -6
- package/lib/computador-abrir-uri.ps1 +27 -0
- package/lib/computador-adaptadores.mjs +257 -0
- package/lib/computador-caminhos.mjs +93 -0
- package/lib/computador-cli.mjs +413 -0
- package/lib/computador-diagnostico.mjs +188 -0
- package/lib/computador-editor.mjs +193 -0
- package/lib/computador-escolher-pasta.applescript +5 -0
- package/lib/computador-escolher-pasta.ps1 +25 -0
- package/lib/computador-estado.mjs +318 -0
- package/lib/computador-executor.mjs +773 -0
- package/lib/computador-git.mjs +319 -0
- package/lib/computador-historico.mjs +327 -0
- package/lib/computador-identidade.mjs +174 -0
- package/lib/computador-pastas.mjs +157 -0
- package/lib/computador-plugin.mjs +137 -0
- package/lib/computador-portal.mjs +300 -0
- package/lib/computador-resposta.mjs +207 -0
- package/lib/computador-startup.mjs +219 -0
- package/lib/computador-windows-launcher.vbs +32 -0
- package/lib/config.mjs +10 -1
- package/lib/gestao.mjs +3 -0
- package/lib/mcp.mjs +10 -0
- package/lib/servico.mjs +80 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -93,12 +93,110 @@ arquivar empresa e encerrar projeto **cascateiam e não têm desfazer**.
|
|
|
93
93
|
|
|
94
94
|
| arquivo | o quê |
|
|
95
95
|
|---|---|
|
|
96
|
-
| `bin/gestao.mjs` | CLI
|
|
96
|
+
| `bin/gestao.mjs` | CLI pessoal/MCP e comandos do computador |
|
|
97
|
+
| `lib/computador-cli.mjs` | registro, startup, controle local, desligamento offline e composição A1–A9 |
|
|
97
98
|
| `lib/login.mjs` | loopback + `state` + abre o navegador |
|
|
98
99
|
| `lib/config.mjs` | ambientes e guarda do token (Keychain / arquivo 0600) |
|
|
99
100
|
| `lib/gestao.mjs` | busca o contrato e chama o Convex por nome |
|
|
100
101
|
| `lib/tools.mjs` | contrato → lista de tools MCP |
|
|
101
102
|
| `lib/mcp.mjs` | servidor MCP (stdio, JSON-RPC) |
|
|
102
103
|
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
A única dependência é `convex` **1.41.0**, carregada sob demanda pelo residente
|
|
105
|
+
para a assinatura oficial da fila. CLI/MCP legados continuam usando HTTP.
|
|
106
|
+
|
|
107
|
+
## Meu computador (macOS e Windows)
|
|
108
|
+
|
|
109
|
+
Instale **Node >=20**, VS Code estável, extensões Claude Code/Codex e git.
|
|
110
|
+
Para repositórios privados, use suas credenciais locais de git/GitHub (`gh auth
|
|
111
|
+
login`, se usar gh). O programa não instala essas ferramentas nem recebe o
|
|
112
|
+
acesso GitHub do servidor.
|
|
113
|
+
|
|
114
|
+
Faça uma instalação persistente; o cache temporário do npx não serve para iniciar
|
|
115
|
+
com o sistema:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
npm install -g chatflux-gestao-agente
|
|
119
|
+
gestao ligar
|
|
120
|
+
gestao status
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`ligar` reutiliza o login pessoal e o clique **Autorizar** se necessário. Mostra
|
|
124
|
+
conta e ambiente, registra a instalação, instala o início automático do usuário e
|
|
125
|
+
abre **Meu computador**. Na página, confirme **Usar este computador neste
|
|
126
|
+
navegador** e escolha a pasta padrão. O link contém só o ID público; não compartilhe
|
|
127
|
+
arquivos locais de identidade/controle. Repetir `ligar` mantém a instalação e suas
|
|
128
|
+
preferências. Um segundo computador tem identidade própria.
|
|
129
|
+
|
|
130
|
+
No Mac, o início automático usa um LaunchAgent na sessão gráfica do usuário:
|
|
131
|
+
`~/Library/LaunchAgents/ai.chatflux.gestao-computador.<env>.plist`. No Windows, usa
|
|
132
|
+
**HKCU Run**, entrada `ChatFluxGestaoComputador-<env>` e launcher VBS sem console,
|
|
133
|
+
sem administrador/SYSTEM. Ambos guardam caminhos absolutos do Node e do pacote;
|
|
134
|
+
não dependem do PATH de uma janela de terminal. O subcomando técnico é
|
|
135
|
+
`gestao computador executar`; normalmente o login do SO o chama.
|
|
136
|
+
|
|
137
|
+
**Limites de campo desta versão:** F02 ainda precisa provar seletor, prontidão e
|
|
138
|
+
nova/retomada Codex na extensão VS Code; as flags correspondentes continuam
|
|
139
|
+
desabilitadas na composição padrão. Os adaptadores Windows (startup, ACL,
|
|
140
|
+
lançadores e comandos) ainda precisam dos ensaios reais. Testes com adaptadores
|
|
141
|
+
falsos não certificam o aceite nos dois sistemas. Os links manuais do portal
|
|
142
|
+
continuam disponíveis; não contam como prova da abertura assistida.
|
|
143
|
+
|
|
144
|
+
### Reconectar, trocar ambiente ou conta
|
|
145
|
+
|
|
146
|
+
O login vence (normalmente até 6 h). O residente pausa os pedidos e orienta
|
|
147
|
+
`gestao login` ou `gestao_conectar`; ele não renova o token silenciosamente.
|
|
148
|
+
`gestao status` mostra ambiente, startup, computador e a causa local mesmo sem
|
|
149
|
+
rede/login. O indicador do portal pode levar até 180 s para perder a conexão.
|
|
150
|
+
|
|
151
|
+
O padrão é **prod** (`portal.chatflux.ai`). Para desenvolvimento, use o mesmo
|
|
152
|
+
ambiente em todos os comandos:
|
|
153
|
+
|
|
154
|
+
```sh
|
|
155
|
+
# macOS
|
|
156
|
+
GESTAO_ENV=dev gestao ligar
|
|
157
|
+
GESTAO_ENV=dev gestao status
|
|
158
|
+
GESTAO_ENV=dev gestao desligar
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```powershell
|
|
162
|
+
# Windows PowerShell (somente esta sessão)
|
|
163
|
+
$env:GESTAO_ENV = 'dev'
|
|
164
|
+
gestao ligar
|
|
165
|
+
gestao status
|
|
166
|
+
gestao desligar
|
|
167
|
+
Remove-Item Env:GESTAO_ENV
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
O fallback `~/.config/gestao/token.json` é compartilhado entre ambientes: um login
|
|
171
|
+
dev não autoriza o residente prod. O programa confere ambiente e subject a cada
|
|
172
|
+
conexão. Para trocar a pessoa nesse perfil do SO, primeiro desligue e entregue a
|
|
173
|
+
revogação com o login da pessoa anterior; depois faça login/ligar com a nova.
|
|
174
|
+
Nenhuma pasta ou autorização é herdada. Perder `identidade.json` exige recuperação
|
|
175
|
+
explícita e outra instalação, sem reutilizar caminhos do registro perdido.
|
|
176
|
+
|
|
177
|
+
`GESTAO_SERVICO` é outro modo: um agente autônomo, não o residente pessoal.
|
|
178
|
+
`ligar`/`desligar`/`computador executar` o recusam; retire essa variável para usar
|
|
179
|
+
sua conta. Não use serviço para contornar o vencimento do login.
|
|
180
|
+
|
|
181
|
+
### Desligar e remover
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
gestao desligar # funciona offline; preserva login, projetos e pastas
|
|
185
|
+
gestao logout # pausa residente e remove apenas o login
|
|
186
|
+
npm uninstall -g chatflux-gestao-agente
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Antes de desinstalar, rode `desligar` em cada ambiente que ligou. Ele bloqueia
|
|
190
|
+
pedidos no journal, remove o startup e interrompe a instância pelo canal local
|
|
191
|
+
autenticado. Sem rede/login, guarda revogação pendente: no próximo `gestao login`,
|
|
192
|
+
`ligar`, `desligar` ou início técnico com conexão entrega **só a revogação** antes
|
|
193
|
+
de qualquer fila. Login sozinho não religa computador desligado. Use `ligar`
|
|
194
|
+
explicitamente. Logout preserva startup; uma nova autorização da mesma conta
|
|
195
|
+
permite ao residente pausado continuar.
|
|
196
|
+
|
|
197
|
+
Se mudou a instalação/caminho do Node ou do pacote, rode `desligar` usando um Node
|
|
198
|
+
válido e reinstale/religue no caminho novo. `status` mostra startup que precisa de
|
|
199
|
+
reparo. Não apague journal/locks para repetir uma abertura incerta: preserve os
|
|
200
|
+
arquivos, confirme que a instância terminou e recupere o bloqueio explicitamente.
|
|
201
|
+
Uma falha parcial informa se o rollback não foi confirmado; não significa que o
|
|
202
|
+
início automático está funcionando.
|
package/bin/gestao.mjs
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// CLI do Gestão.
|
|
2
|
+
// CLI do Gestão. Residente pessoal importado somente pelos verbos necessários.
|
|
3
3
|
|
|
4
4
|
import { loginVerificado } from "../lib/login.mjs";
|
|
5
5
|
import { servir } from "../lib/mcp.mjs";
|
|
6
6
|
import { contrato, euSou, NaoAutenticado } from "../lib/gestao.mjs";
|
|
7
7
|
import {
|
|
8
|
-
ambiente, lerToken,
|
|
8
|
+
ambiente, lerToken, expiraEm, tokenValido, descreverValidade,
|
|
9
9
|
} from "../lib/config.mjs";
|
|
10
10
|
import { montarTools } from "../lib/tools.mjs";
|
|
11
|
+
import { nomeServico } from "../lib/servico.mjs";
|
|
11
12
|
|
|
12
13
|
const AJUDA = `gestao — portal Gestão pela linha de comando e por agente (MCP)
|
|
13
14
|
|
|
14
15
|
gestao login autoriza este computador a agir como você (abre o navegador)
|
|
15
16
|
gestao status mostra quem está conectado e quanto tempo falta
|
|
16
|
-
gestao
|
|
17
|
+
gestao ligar instala início automático e associa este computador ao navegador
|
|
18
|
+
gestao desligar remove início automático e interrompe o computador, mesmo offline
|
|
19
|
+
gestao computador executar residente técnico (usado pelo login do SO)
|
|
20
|
+
gestao logout suspende o residente e esquece a credencial desta máquina
|
|
17
21
|
gestao mcp roda o servidor MCP (é o que o Claude Code/Desktop chama)
|
|
18
22
|
|
|
19
23
|
Ambiente: ${ambiente().nome} (GESTAO_ENV=dev para apontar ao dev)
|
|
@@ -27,16 +31,37 @@ const [, , verbo] = process.argv;
|
|
|
27
31
|
try {
|
|
28
32
|
switch (verbo) {
|
|
29
33
|
case "login": {
|
|
34
|
+
if (nomeServico()) {
|
|
35
|
+
console.error("Modo serviço (GESTAO_SERVICO) ativo: não há login de pessoa. Use `gestao status`.");
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
30
38
|
const { email, onde, token, eu } = await loginVerificado();
|
|
31
39
|
console.log(
|
|
32
40
|
`Conectado como ${eu?.name ?? email ?? "?"} (${eu?.role ?? "?"}) no ambiente ${ambiente().nome}.`,
|
|
33
41
|
);
|
|
34
42
|
console.log(`Credencial guardada em: ${onde}`);
|
|
35
43
|
console.log(`Vale por ${descreverValidade(token)}. Quando expirar, rode \`gestao login\` de novo.`);
|
|
44
|
+
const { afterComputerLogin } = await import("../lib/computador-cli.mjs");
|
|
45
|
+
await afterComputerLogin();
|
|
36
46
|
break;
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
case "status": {
|
|
50
|
+
if (nomeServico()) {
|
|
51
|
+
const eu = await euSou();
|
|
52
|
+
const tools = montarTools(await contrato());
|
|
53
|
+
console.log(`Conectado (serviço ${nomeServico()}): ${eu?.name ?? "?"} — papel ${eu?.role ?? "?"}`);
|
|
54
|
+
console.log(`Ambiente: ${ambiente().nome}`);
|
|
55
|
+
console.log(`Tools: ${tools.length} (do contrato servido pelo deployment)`);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
const { statusComputer } = await import("../lib/computador-cli.mjs");
|
|
59
|
+
const computer = await statusComputer();
|
|
60
|
+
console.log(`Computador: ${computer.computerId ?? "não registrado"} · ${computer.state}`);
|
|
61
|
+
console.log(`Startup: ${computer.startupInstalled ? (computer.startupHealthy ? "instalado" : "precisa reparar com gestao ligar") : "removido"} · ambiente ${computer.env}`);
|
|
62
|
+
for (const [name, capability] of Object.entries(computer.capabilities ?? {})) {
|
|
63
|
+
console.log(`${name}: ${capability.enabled ? 'ligada' : 'desligada'}${capability.target ? ` (${capability.target})` : ''}${capability.reason || capability.pending ? ` · ${capability.reason ?? capability.pending}` : ''}`);
|
|
64
|
+
}
|
|
40
65
|
const dados = lerToken();
|
|
41
66
|
if (!dados) {
|
|
42
67
|
console.log("Sem credencial nesta máquina. Rode `gestao login`.");
|
|
@@ -56,10 +81,32 @@ try {
|
|
|
56
81
|
break;
|
|
57
82
|
}
|
|
58
83
|
|
|
59
|
-
case "
|
|
60
|
-
|
|
61
|
-
|
|
84
|
+
case "ligar": {
|
|
85
|
+
const { ligarComputer } = await import("../lib/computador-cli.mjs");
|
|
86
|
+
await ligarComputer();
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
case "desligar": {
|
|
90
|
+
const { desligarComputer } = await import("../lib/computador-cli.mjs");
|
|
91
|
+
await desligarComputer();
|
|
62
92
|
break;
|
|
93
|
+
}
|
|
94
|
+
case "computador": {
|
|
95
|
+
if (process.argv[3] !== "executar" || process.argv.length !== 4) throw new Error("Use gestao computador executar.");
|
|
96
|
+
const { executarComputer } = await import("../lib/computador-cli.mjs");
|
|
97
|
+
const resident = await executarComputer();
|
|
98
|
+
const stop = () => { void resident.close(); };
|
|
99
|
+
process.once("SIGTERM", stop); process.once("SIGINT", stop);
|
|
100
|
+
await resident.done;
|
|
101
|
+
process.removeListener("SIGTERM", stop); process.removeListener("SIGINT", stop);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
case "logout": {
|
|
105
|
+
const { logoutComputer } = await import("../lib/computador-cli.mjs");
|
|
106
|
+
await logoutComputer();
|
|
107
|
+
console.log("Residente suspenso; credencial removida desta máquina.");
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
63
110
|
|
|
64
111
|
case "mcp":
|
|
65
112
|
await servir();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# A4: receita Windows candidata, F02 pendente. Launcher local fixo, sem shell livre.
|
|
2
|
+
param(
|
|
3
|
+
[Parameter(Mandatory=$true)][ValidateSet('folder', 'uri')][string]$Mode,
|
|
4
|
+
[Parameter(Mandatory=$true)][string]$ValueBase64
|
|
5
|
+
)
|
|
6
|
+
$ErrorActionPreference = 'Stop'
|
|
7
|
+
try {
|
|
8
|
+
$utf8 = New-Object System.Text.UTF8Encoding($false, $true)
|
|
9
|
+
$value = $utf8.GetString([Convert]::FromBase64String($ValueBase64))
|
|
10
|
+
if ($value.Contains([char]0)) { exit 1 }
|
|
11
|
+
if ($Mode -eq 'folder') {
|
|
12
|
+
if (-not [System.IO.Path]::IsPathRooted($value)) { exit 1 }
|
|
13
|
+
$segments = $value.Replace('\', '/') -split '/'
|
|
14
|
+
if ($segments[0] -notmatch '^[A-Za-z]:$') { exit 1 }
|
|
15
|
+
$encoded = @($segments[0]) + @($segments | Select-Object -Skip 1 | ForEach-Object { [Uri]::EscapeDataString($_) })
|
|
16
|
+
$value = 'vscode://file/' + ($encoded -join '/')
|
|
17
|
+
}
|
|
18
|
+
$uri = [Uri]$value
|
|
19
|
+
if ($uri.Scheme -ne 'vscode') { exit 1 }
|
|
20
|
+
$info = New-Object System.Diagnostics.ProcessStartInfo
|
|
21
|
+
$info.FileName = $value
|
|
22
|
+
$info.UseShellExecute = $true
|
|
23
|
+
[System.Diagnostics.Process]::Start($info) | Out-Null
|
|
24
|
+
exit 0 # somente envio ao SO; nunca prova de conversa iniciada
|
|
25
|
+
} catch {
|
|
26
|
+
exit 1 # nunca devolver output livre, URI ou prompt
|
|
27
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/** A12: composição real, receitas locais fechadas. Nada vindo do portal escolhe
|
|
2
|
+
* binário/argv/flags. Readiness Mac é provisória (processo + 5s/2s), não uma
|
|
3
|
+
* inspeção da janela/extensão. F02/F04 continuam como aceite de campo.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawn, execFile } from 'node:child_process';
|
|
9
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
10
|
+
import { createEditorAdapter } from './computador-editor.mjs';
|
|
11
|
+
import { createReplyAdapter } from './computador-resposta.mjs';
|
|
12
|
+
import { createHistoryAdapter, resolveLocalTranscript } from './computador-historico.mjs';
|
|
13
|
+
import { createGitAdapter, normalizeGitHubRepo } from './computador-git.mjs';
|
|
14
|
+
import { validatePath } from './computador-caminhos.mjs';
|
|
15
|
+
import * as folders from './computador-pastas.mjs';
|
|
16
|
+
import * as plugins from './computador-plugin.mjs';
|
|
17
|
+
|
|
18
|
+
const flags = ['abrirVscode', 'abrirClaude', 'retomarClaude', 'abrirCodex', 'retomarCodex',
|
|
19
|
+
'clonar', 'atualizarPasta', 'atualizarPlugin', 'seletorPasta', 'responderClaude', 'responderCodex', 'lerHistoricoClaude', 'lerHistoricoCodex'];
|
|
20
|
+
const blocked = () => Object.assign(new Error('Esta conversa ou pasta está em uso por outro processo.'), { errorType: 'editor', motivo: 'conversa_em_uso' });
|
|
21
|
+
const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value);
|
|
22
|
+
|
|
23
|
+
export async function createComputerAdapters(deps = {}) {
|
|
24
|
+
const { platform = process.platform, fsImpl = fs, home = os.homedir(), env = process.env,
|
|
25
|
+
spawnImpl = spawn, execFileImpl = execFile, killImpl = process.kill.bind(process), journal } = deps;
|
|
26
|
+
const now = deps.clock?.now ?? Date.now;
|
|
27
|
+
const delay = deps.clock?.sleep ?? ((ms, signal) => sleep(ms, undefined, { signal }));
|
|
28
|
+
const repeat = deps.clock?.setInterval ?? setInterval, unrepeat = deps.clock?.clearInterval ?? clearInterval;
|
|
29
|
+
const capabilityDetails = Object.fromEntries(flags.map(flag => [flag, { enabled: false, reason: 'Receita Windows sem ensaio de campo; capacidade desligada.' }]));
|
|
30
|
+
if (platform !== 'darwin') {
|
|
31
|
+
const unavailable = async () => { throw new Error('Receita Windows sem ensaio de campo.'); };
|
|
32
|
+
return { editor: createEditorAdapter({ platform, spawnImpl }), reply: createReplyAdapter({ platform }),
|
|
33
|
+
history: { capabilities: { lerHistoricoClaude: false, lerHistoricoCodex: false }, read: unavailable },
|
|
34
|
+
folders: { ...folders, capabilities: { seletorPasta: false }, chooseFolder: unavailable },
|
|
35
|
+
git: {}, plugins: {}, capabilityDetails };
|
|
36
|
+
}
|
|
37
|
+
// launchd does not inherit the login-shell PATH. Keep personal credentials,
|
|
38
|
+
// and make shebangs such as /usr/bin/env node use the resident's own Node.
|
|
39
|
+
const directories = [...new Set([path.dirname(process.execPath), ...(env.PATH ?? '').split(path.delimiter),
|
|
40
|
+
...(deps.binaryDirectories ?? [path.join(home, '.local/bin'), '/opt/homebrew/bin', '/usr/local/bin'])])].filter(p => path.isAbsolute(p));
|
|
41
|
+
const commandEnv = { ...env, PATH: directories.join(path.delimiter) };
|
|
42
|
+
function probe(binary, argv, signal) {
|
|
43
|
+
return new Promise((resolve, reject) => execFileImpl(binary, argv, {
|
|
44
|
+
shell: false, timeout: 3000, maxBuffer: 1024 * 1024, encoding: 'utf8',
|
|
45
|
+
env: commandEnv,
|
|
46
|
+
stdio: ['ignore', 'pipe', 'ignore'], ...(signal ? { signal } : {}),
|
|
47
|
+
}, (error, output) => error ? reject(error) : resolve(output)));
|
|
48
|
+
}
|
|
49
|
+
async function processes(signal) {
|
|
50
|
+
const output = await probe('/bin/ps', ['-U', String(process.getuid()), '-o', 'pid=,ppid=,pgid=,comm='], signal);
|
|
51
|
+
if (!output.trim()) throw blocked();
|
|
52
|
+
return output.split('\n').filter(line => line.trim()).map(line => {
|
|
53
|
+
const m = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/u);
|
|
54
|
+
if (!m) throw blocked(); // unknown inventory never proves closure
|
|
55
|
+
return { pid: Number(m[1]), ppid: Number(m[2]), group: Number(m[3]), command: m[4] };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const isCode = row => /\/Visual Studio Code\.app\//u.test(row.command);
|
|
59
|
+
const isCandidate = row => row.pid !== process.pid &&
|
|
60
|
+
/(?:^|\/)(?:claude(?:\.exe)?|codex|node(?:js)?)(?:$|\s)/u.test(row.command);
|
|
61
|
+
async function writerConflict({ sessionKey, path: sessionPath, signal, owner }) {
|
|
62
|
+
const rows = await processes(signal);
|
|
63
|
+
// Read the live owner after ps: this probe may have started before spawn.
|
|
64
|
+
const owned = new Set(Number.isSafeInteger(owner?.pid) ? [owner.pid] : []);
|
|
65
|
+
let added;
|
|
66
|
+
do {
|
|
67
|
+
added = false;
|
|
68
|
+
for (const row of rows) if (owned.has(row.ppid) && !owned.has(row.pid)) {
|
|
69
|
+
owned.add(row.pid); added = true;
|
|
70
|
+
}
|
|
71
|
+
} while (added);
|
|
72
|
+
const candidates = rows.filter(row => !owned.has(row.pid) && row.group !== owner?.group && isCandidate(row));
|
|
73
|
+
if (!candidates.length) return false;
|
|
74
|
+
const output = await probe('/bin/ps', ['-ww', '-p', candidates.map(row => row.pid).join(','), '-o', 'pid=,args='], signal);
|
|
75
|
+
const argumentsByPid = new Map(output.split('\n').flatMap(line => {
|
|
76
|
+
const match = line.match(/^\s*(\d+)\s+(.+)$/u);
|
|
77
|
+
return match ? [[Number(match[1]), match[2]]] : [];
|
|
78
|
+
}));
|
|
79
|
+
const writers = candidates.filter(row => {
|
|
80
|
+
const args = argumentsByPid.get(row.pid);
|
|
81
|
+
// A missing argument inventory cannot prove this candidate is unrelated.
|
|
82
|
+
if (!args || !/(?:^|\/)node(?:js)?$/u.test(row.command)) return true;
|
|
83
|
+
const script = args.trim().split(/\s+/u).slice(1).find(arg => !arg.startsWith('-')) ?? '';
|
|
84
|
+
return /(?:^|[/@])(?:claude|claude-code|codex)(?:[/.]|$)/u.test(script);
|
|
85
|
+
});
|
|
86
|
+
if (!writers.length) return false;
|
|
87
|
+
if (writers.some(row => (argumentsByPid.get(row.pid) ?? '').split(/[^a-z0-9-]+/iu)
|
|
88
|
+
.some(arg => arg.toLowerCase() === sessionKey.toLowerCase()))) return true;
|
|
89
|
+
let cwdOutput = '';
|
|
90
|
+
try {
|
|
91
|
+
cwdOutput = await probe('/usr/sbin/lsof', ['-a', '-d', 'cwd', '-p', writers.map(row => row.pid).join(','), '-Fn'], signal);
|
|
92
|
+
} catch { /* Check each unreadable candidate below; it may have exited during lsof. */ }
|
|
93
|
+
const cwds = new Map(); let pid;
|
|
94
|
+
for (const line of cwdOutput.split('\n')) {
|
|
95
|
+
if (/^p\d+$/u.test(line)) pid = Number(line.slice(1));
|
|
96
|
+
else if (line.startsWith('n') && pid) cwds.set(pid, line.slice(1));
|
|
97
|
+
}
|
|
98
|
+
return writers.some(row => {
|
|
99
|
+
try {
|
|
100
|
+
const cwd = fsImpl.realpathSync(cwds.get(row.pid)), relative = path.relative(sessionPath, cwd);
|
|
101
|
+
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
102
|
+
} catch {
|
|
103
|
+
try { killImpl(row.pid, 0); }
|
|
104
|
+
catch (e) { if (e.code === 'ESRCH') return false; }
|
|
105
|
+
console.warn('Responder pelo portal: conversa_em_uso (cwd de processo candidato ilegível).');
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function executable(name) {
|
|
111
|
+
for (const dir of directories) {
|
|
112
|
+
const file = path.join(dir, name);
|
|
113
|
+
try { fsImpl.accessSync(file, fs.constants.X_OK); if (fsImpl.statSync(file).isFile()) return file; } catch { /* absent */ }
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const claude = executable('claude'), codex = executable('codex');
|
|
118
|
+
let codexResume = false;
|
|
119
|
+
if (codex) {
|
|
120
|
+
try { codexResume = /Usage:\s*codex exec resume\b[^\n]*\[SESSION_ID\][^\n]*\[PROMPT\]/u.test(await probe(codex, ['exec', 'resume', '--help'])); }
|
|
121
|
+
catch { /* no guessed fallback to interactive resume */ }
|
|
122
|
+
}
|
|
123
|
+
const history = createHistoryAdapter({ fsImpl, home, clock: deps.clock });
|
|
124
|
+
async function localSessionExists(args) {
|
|
125
|
+
let fd;
|
|
126
|
+
try {
|
|
127
|
+
const file = await resolveLocalTranscript({ ...args, fsImpl, home });
|
|
128
|
+
if (!file) return false;
|
|
129
|
+
if (args.harness !== 'claude' || !args.requireProject) return true;
|
|
130
|
+
// Preserve the older automatic-binding proof's first-100-lines bound.
|
|
131
|
+
// Path resolution, symlink/identity checks remain in the shared reader.
|
|
132
|
+
fd = fsImpl.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
133
|
+
const header = Buffer.alloc(65536), size = fsImpl.readSync(fd, header, 0, header.length, 0);
|
|
134
|
+
return header.subarray(0, size).toString('utf8').split('\n').slice(0, 100).some(line => {
|
|
135
|
+
try {
|
|
136
|
+
const cwd = JSON.parse(line)?.cwd;
|
|
137
|
+
return typeof cwd === 'string' && path.isAbsolute(cwd) && fsImpl.realpathSync(cwd) === fsImpl.realpathSync(args.path);
|
|
138
|
+
} catch { return false; }
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
catch { return false; } // Existing editor interface is a closed boolean proof.
|
|
142
|
+
finally { if (fd !== undefined) fsImpl.closeSync(fd); }
|
|
143
|
+
}
|
|
144
|
+
// inspectRepo may fetch upstream refs. History needs only local root/origin
|
|
145
|
+
// evidence, so keep this read-only path separate from the opening snapshot.
|
|
146
|
+
async function validateOrigin({ path: candidate, expectedRepo, authorization, signal }) {
|
|
147
|
+
const cwd = validatePath({ candidate, ...authorization, platform, fsImpl });
|
|
148
|
+
const expected = normalizeGitHubRepo(expectedRepo);
|
|
149
|
+
const localEnv = Object.fromEntries(Object.entries(commandEnv).filter(([key]) => !key.startsWith('GIT_')));
|
|
150
|
+
const read = argv => new Promise((resolve, reject) => execFileImpl('/usr/bin/git', ['--no-optional-locks', ...argv], {
|
|
151
|
+
cwd, env: localEnv, shell: false, timeout: 3000, maxBuffer: 65536, encoding: 'utf8',
|
|
152
|
+
stdio: ['ignore', 'pipe', 'ignore'], ...(signal ? { signal } : {}),
|
|
153
|
+
}, (error, output) => error ? reject(Object.assign(new Error('Não foi possível conferir o origin local.'), { code: 'GIT_STATE' })) : resolve(output.trim())));
|
|
154
|
+
if (fsImpl.realpathSync(await read(['rev-parse', '--show-toplevel'])) !== cwd) throw Object.assign(new Error('Escolha a raiz do repositório.'), { code: 'GIT_NOT_ROOT' });
|
|
155
|
+
const origin = await read(['config', '--local', '--no-includes', '--get-all', 'remote.origin.url']);
|
|
156
|
+
if (normalizeGitHubRepo(origin) !== expected) throw Object.assign(new Error('Origin local divergente.'), { code: 'GIT_WRONG_REPO' });
|
|
157
|
+
if (validatePath({ candidate, ...authorization, platform, fsImpl }) !== cwd) throw Object.assign(new Error('Pasta local divergente.'), { code: 'PATH_NOT_AUTHORIZED' });
|
|
158
|
+
}
|
|
159
|
+
let readyAt = Infinity, openedPath = null, exclusion = null;
|
|
160
|
+
const rawEditor = createEditorAdapter({ platform, spawnImpl, localSessionExists,
|
|
161
|
+
readinessProbe: async ({ path: candidate, signal }) => {
|
|
162
|
+
if (candidate !== openedPath) return { ready: false, path: candidate };
|
|
163
|
+
if (now() < readyAt) await delay(readyAt - now(), signal);
|
|
164
|
+
return { ready: (await processes(signal)).some(isCode) && now() >= readyAt, path: candidate };
|
|
165
|
+
},
|
|
166
|
+
codexRecipe: { target: 'app',
|
|
167
|
+
newUri: ({ path: cwd, prompt }) => `codex://threads/new?path=${encodeURIComponent(cwd)}&prompt=${encodeURIComponent(prompt)}&mode=codex`,
|
|
168
|
+
resumeUri: ({ sessionKey }) => {
|
|
169
|
+
if (!uuid(sessionKey)) throw new Error('UUID Codex inválido.');
|
|
170
|
+
return `codex://threads/${sessionKey}`;
|
|
171
|
+
} },
|
|
172
|
+
});
|
|
173
|
+
const editor = { ...rawEditor,
|
|
174
|
+
hasLocalSessionInProject: args => localSessionExists({ ...args, requireProject: true }),
|
|
175
|
+
async openFolder(args) {
|
|
176
|
+
if (exclusion) throw blocked();
|
|
177
|
+
const wasOpen = (await processes(args.signal)).some(isCode);
|
|
178
|
+
await rawEditor.openFolder(args);
|
|
179
|
+
openedPath = args.path; readyAt = now() + (wasOpen ? 2000 : 5000);
|
|
180
|
+
},
|
|
181
|
+
async openNew(args) { if (exclusion) throw blocked(); return rawEditor.openNew(args); },
|
|
182
|
+
async resume(args) { if (exclusion) throw blocked(); return rawEditor.resume(args); },
|
|
183
|
+
};
|
|
184
|
+
const ownedGroups = new WeakMap(), failedLaunches = new WeakSet();
|
|
185
|
+
const reply = createReplyAdapter({ platform, fsImpl, clock: deps.replyClock,
|
|
186
|
+
spawnImpl(binary, argv, options) {
|
|
187
|
+
const child = spawnImpl(binary, argv, { ...options, env: commandEnv });
|
|
188
|
+
ownedGroups.set(child, child.pid);
|
|
189
|
+
child.once('error', () => { if (child.pid == null) failedLaunches.add(child); });
|
|
190
|
+
if (exclusion) { exclusion.pid = child.pid; exclusion.group = child.pid; }
|
|
191
|
+
return child;
|
|
192
|
+
},
|
|
193
|
+
recipes: { ...(claude ? { claude: { verified: true, platform, binary: claude } } : {}),
|
|
194
|
+
...(codexResume ? { codex: { verified: true, platform, binary: codex,
|
|
195
|
+
argv: ({ sessionKey, texto }) => ['exec', 'resume', sessionKey, '--', texto] } } : {}) },
|
|
196
|
+
async acquireEditorExclusion({ sessionKey, path: sessionPath, signal, onConflict }) {
|
|
197
|
+
if (exclusion) throw blocked();
|
|
198
|
+
const guard = { pid: null, group: null }; exclusion = guard;
|
|
199
|
+
let timer, checking = false;
|
|
200
|
+
try {
|
|
201
|
+
if (await writerConflict({ sessionKey, path: sessionPath, signal })) throw blocked();
|
|
202
|
+
timer = repeat(async () => {
|
|
203
|
+
if (checking || exclusion !== guard) return;
|
|
204
|
+
checking = true;
|
|
205
|
+
try {
|
|
206
|
+
if (await writerConflict({ sessionKey, path: sessionPath, owner: guard })) onConflict(blocked());
|
|
207
|
+
} catch { onConflict(blocked()); }
|
|
208
|
+
finally { checking = false; }
|
|
209
|
+
}, 100);
|
|
210
|
+
timer?.unref?.();
|
|
211
|
+
return { closed: true, release() { unrepeat(timer); if (exclusion === guard) exclusion = null; } };
|
|
212
|
+
} catch (e) { if (timer) unrepeat(timer); if (exclusion === guard) exclusion = null; throw e; }
|
|
213
|
+
},
|
|
214
|
+
processTree: {
|
|
215
|
+
async stop(child) {
|
|
216
|
+
if (failedLaunches.has(child)) return;
|
|
217
|
+
const group = ownedGroups.get(child);
|
|
218
|
+
if (!Number.isSafeInteger(group) || group <= 1) throw blocked();
|
|
219
|
+
try { killImpl(-group, 'SIGTERM'); } catch (e) { if (e.code !== 'ESRCH') throw e; }
|
|
220
|
+
await delay(500);
|
|
221
|
+
try { killImpl(-group, 0); } catch (e) { if (e.code === 'ESRCH') return; throw e; }
|
|
222
|
+
try { killImpl(-group, 'SIGKILL'); } catch (e) { if (e.code !== 'ESRCH') throw e; }
|
|
223
|
+
},
|
|
224
|
+
async confirmExited(child) {
|
|
225
|
+
if (failedLaunches.has(child)) return true;
|
|
226
|
+
const group = ownedGroups.get(child);
|
|
227
|
+
if (!Number.isSafeInteger(group) || group <= 1) return false;
|
|
228
|
+
try { killImpl(-group, 0); return false; } catch (e) { return e.code === 'ESRCH'; }
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
for (const flag of flags) capabilityDetails[flag] = { enabled: true,
|
|
233
|
+
target: flag.startsWith('lerHistorico') ? 'filesystem' : flag.includes('Codex') && !flag.startsWith('responder') ? 'app'
|
|
234
|
+
: flag.startsWith('responder') || ['clonar', 'atualizarPasta', 'atualizarPlugin'].includes(flag) ? 'cli'
|
|
235
|
+
: flag === 'seletorPasta' ? 'native' : 'vscode' };
|
|
236
|
+
for (const [flag, on] of Object.entries(reply.capabilities)) {
|
|
237
|
+
capabilityDetails[flag] = { enabled: on, target: 'cli', ...(!on
|
|
238
|
+
? { reason: flag === 'responderClaude' ? 'Claude CLI executável ausente na instalação local.' : 'Codex exec resume não interativo não comprovado pelo --help instalado.' }
|
|
239
|
+
: { pending: 'F04: exclusão externa e histórico ainda sem aceite de campo; guarda conservadora de processos.' }) };
|
|
240
|
+
}
|
|
241
|
+
capabilityDetails.abrirCodex.pending = capabilityDetails.retomarCodex.pending = 'Extensão VS Code sem receita comprovada; abertura pelo app.';
|
|
242
|
+
journal?.recordRecipe({ id: 'mac_vscode_readiness', status: 'provisoria' });
|
|
243
|
+
const pluginSpawn = (binary, argv, options) => {
|
|
244
|
+
const executable = binary.endsWith('/claude') ? claude : codex;
|
|
245
|
+
if (!executable) throw Object.assign(new Error('CLI local ausente.'), { code: 'ENOENT' });
|
|
246
|
+
return spawnImpl(executable, argv, { ...options, env: commandEnv });
|
|
247
|
+
};
|
|
248
|
+
return { editor, reply, history, folders: { ...folders, capabilities: { seletorPasta: true },
|
|
249
|
+
chooseFolder: options => folders.chooseFolder({ ...options, platform, fsImpl, spawnImpl }),
|
|
250
|
+
adoptKnownFolder: options => folders.adoptKnownFolder({ ...options, platform, fsImpl, home, execFileImpl }) },
|
|
251
|
+
git: { ...createGitAdapter({ platform, fsImpl, spawnImpl: (binary, argv, options) =>
|
|
252
|
+
spawnImpl(binary, argv, { ...options, env: { ...options.env, PATH: commandEnv.PATH } }) }), validateOrigin },
|
|
253
|
+
plugins: {
|
|
254
|
+
readPluginVersions: (options = {}) => plugins.readPluginVersions({ ...options, platform, spawnImpl: pluginSpawn }),
|
|
255
|
+
updatePlugins: (options = {}) => plugins.updatePlugins({ ...options, platform, spawnImpl: pluginSpawn }),
|
|
256
|
+
}, capabilityDetails };
|
|
257
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/** Os anchors defaultRoot/registeredPath são caminhos CANÔNICOS do espelho local
|
|
2
|
+
* confirmado, nunca strings recebidas da configuração/browser. Reexecutar esta
|
|
3
|
+
* função imediatamente antes de cada efeito; seu retorno não é um lease de FS.
|
|
4
|
+
* fsImpl tem a interface síncrona realpathSync/statSync de node:fs.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
const grants = new WeakSet();
|
|
11
|
+
// Windows/F02 candidato: a prova de volume é uma operação estática, nunca shell.
|
|
12
|
+
// Doubles must explicitly prove a local volume; missing/unknown fails closed.
|
|
13
|
+
function localVolume(candidate, fsImpl) {
|
|
14
|
+
if (fsImpl !== fs) return fsImpl.isLocalVolume?.(candidate) === true;
|
|
15
|
+
try {
|
|
16
|
+
return execFileSync('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', [
|
|
17
|
+
'-NoLogo', '-NoProfile', '-NonInteractive', '-File',
|
|
18
|
+
fileURLToPath(new URL('./computador-escolher-pasta.ps1', import.meta.url)), '-CheckPath', candidate,
|
|
19
|
+
], { encoding: 'utf8', shell: false, windowsHide: true, timeout: 10_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim() === 'local';
|
|
20
|
+
} catch { return false; }
|
|
21
|
+
}
|
|
22
|
+
export const pathError = () => Object.assign(new Error('Pasta local não autorizada ou inválida.'), { code: 'PATH_NOT_AUTHORIZED' });
|
|
23
|
+
function rules(platform) {
|
|
24
|
+
if (!['darwin', 'win32'].includes(platform)) throw pathError();
|
|
25
|
+
return platform === 'win32' ? path.win32 : path.posix;
|
|
26
|
+
}
|
|
27
|
+
function lexical(value, platform) {
|
|
28
|
+
const p = rules(platform);
|
|
29
|
+
if (typeof value !== 'string' || !value || /[\x00-\x1f\x7f]/u.test(value) || value.startsWith('-') || !p.isAbsolute(value)) throw pathError();
|
|
30
|
+
if (value.split(/[\\/]/u).includes('..')) throw pathError();
|
|
31
|
+
if (platform === 'win32') {
|
|
32
|
+
if (!/^[a-z]:[\\/]/iu.test(value) || value.slice(2).includes(':')) throw pathError();
|
|
33
|
+
if (value.slice(3).split(/[\\/]/u).some(s => /[. ]$/u.test(s) || /[<>"|?*]/u.test(s) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(s))) throw pathError();
|
|
34
|
+
} else if (value.startsWith('//') || /^\/(mnt|net|Volumes)\//u.test(value)) {
|
|
35
|
+
// Fail closed for network/WSL mounts; local external-volume support needs a field probe.
|
|
36
|
+
throw pathError();
|
|
37
|
+
}
|
|
38
|
+
return p.normalize(value);
|
|
39
|
+
}
|
|
40
|
+
/** Resolve o ancestral existente mais próximo; ENOTDIR/EACCES não são ausência. */
|
|
41
|
+
export function canonicalizePath({ candidate, platform = process.platform, fsImpl = fs }) {
|
|
42
|
+
const p = rules(platform), normalized = lexical(candidate, platform), missing = [];
|
|
43
|
+
let ancestor = normalized;
|
|
44
|
+
for (;;) {
|
|
45
|
+
try {
|
|
46
|
+
const real = lexical(fsImpl.realpathSync(ancestor), platform);
|
|
47
|
+
if (platform === 'win32' && !localVolume(real, fsImpl)) throw pathError();
|
|
48
|
+
if (!fsImpl.statSync(ancestor).isDirectory()) throw pathError();
|
|
49
|
+
return p.join(real, ...missing);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (e.code !== 'ENOENT') throw pathError();
|
|
52
|
+
// A dangling symlink must not be treated as a new clone directory.
|
|
53
|
+
if (fsImpl.lstatSync) {
|
|
54
|
+
try { fsImpl.lstatSync(ancestor); throw pathError(); }
|
|
55
|
+
catch (entry) { if (entry.code !== 'ENOENT') throw pathError(); }
|
|
56
|
+
}
|
|
57
|
+
const parent = p.dirname(ancestor);
|
|
58
|
+
if (parent === ancestor) throw pathError();
|
|
59
|
+
missing.unshift(p.basename(ancestor)); ancestor = parent;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function inside(candidate, root, p) {
|
|
64
|
+
// realpath has already asked the volume for canonical case. Do not lowercase:
|
|
65
|
+
// Windows supports case-sensitive directories, even on the same drive.
|
|
66
|
+
const parts = value => value.split(p.sep).filter(Boolean);
|
|
67
|
+
const a = parts(candidate), b = parts(root);
|
|
68
|
+
return b.length <= a.length && b.every((s, i) => s === a[i]);
|
|
69
|
+
}
|
|
70
|
+
export function validatePath({ candidate, defaultRoot, registeredPath, nativeGrant, platform = process.platform, fsImpl = fs }) {
|
|
71
|
+
const p = rules(platform), input = lexical(candidate, platform);
|
|
72
|
+
const canonicalPath = canonicalizePath({ candidate: input, platform, fsImpl });
|
|
73
|
+
const anchor = value => {
|
|
74
|
+
const normalized = lexical(value, platform);
|
|
75
|
+
const current = canonicalizePath({ candidate: normalized, platform, fsImpl });
|
|
76
|
+
// Stored anchors were canonical when confirmed; never follow a replacement link.
|
|
77
|
+
return normalized === current ? current : null;
|
|
78
|
+
};
|
|
79
|
+
if (defaultRoot) {
|
|
80
|
+
const root = anchor(defaultRoot);
|
|
81
|
+
// Compare canonical segment names supplied by the filesystem; the lexical
|
|
82
|
+
// check uses realpath on the existing ancestor too, so volume casing works.
|
|
83
|
+
if (root && inside(canonicalPath, root, p)) return canonicalPath;
|
|
84
|
+
}
|
|
85
|
+
if (registeredPath && anchor(registeredPath) === canonicalPath) return canonicalPath;
|
|
86
|
+
if (nativeGrant && grants.has(nativeGrant) && nativeGrant.canonicalPath === canonicalPath && anchor(canonicalPath)) return canonicalPath;
|
|
87
|
+
throw pathError();
|
|
88
|
+
}
|
|
89
|
+
// Only the local selector/grant store calls this; JSON from the portal cannot
|
|
90
|
+
// carry the in-process capability. No grant IDs or flags authorize by themselves.
|
|
91
|
+
export function localGrant(canonicalPath) {
|
|
92
|
+
const grant = Object.freeze({ canonicalPath }); grants.add(grant); return grant;
|
|
93
|
+
}
|