chatflux-gestao-agente 0.1.3 → 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 CHANGED
@@ -46,11 +46,11 @@ gestao login
46
46
  └─ abre <portal>/agente/login?port=…&state=…
47
47
  └─ a página (só staff) mostra quem você é e pede confirmação
48
48
  └─ o browser — que JÁ tem sua sessão do Clerk — emite o JWT do
49
- template `convex` e entrega no loopback
49
+ template `convex-agente` e entrega no loopback
50
50
  └─ guarda no Keychain (macOS) ou em ~/.config/gestao/token.json (0600)
51
51
  ```
52
52
 
53
- Vale **1 hora**. E na prática você não faz nada disso à mão: o próprio agente
53
+ Vale **até 6 horas** (a validade real aparece no login e no `gestao status`). E na prática você não faz nada disso à mão: o próprio agente
54
54
  chama a tool `gestao_conectar` quando precisa, o navegador abre, você clica em
55
55
  **Autorizar** e ele refaz a ação. O `gestao login` no terminal existe como
56
56
  alternativa, não como o caminho normal.
@@ -58,7 +58,7 @@ alternativa, não como o caminho normal.
58
58
  **O servidor NÃO morre quando a sessão expira.** Ele sobe sempre: lista as tools a
59
59
  partir do último contrato buscado (cache em `~/.config/gestao/`), e `gestao_conectar`
60
60
  fica SEMPRE disponível — é justamente quando a sessão vence que ela precisa existir.
61
- Sem isso, a pessoa veria "Failed to connect — Connection closed" de hora em hora,
61
+ Sem isso, a pessoa veria "Failed to connect — Connection closed" a cada expiração,
62
62
  sem pista do motivo.
63
63
 
64
64
  O cache é conveniência, nunca fonte: qualquer ação de verdade exige token válido.
@@ -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: `login`, `status`, `logout`, `mcp` |
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
- Zero dependências de propósito: isto roda na máquina de cada pessoa, e cada
104
- dependência é superfície.
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. Três verbos: `login`, `status`, `mcp`.
2
+ // CLI do Gestão. Residente pessoal importado somente pelos verbos necessários.
3
3
 
4
- import { login } from "../lib/login.mjs";
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, apagarToken, expiraEm, tokenValido,
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 logout esquece a credencial desta máquina
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,17 +31,37 @@ const [, , verbo] = process.argv;
27
31
  try {
28
32
  switch (verbo) {
29
33
  case "login": {
30
- const { email, onde } = await login();
31
- const eu = await euSou();
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
+ }
38
+ const { email, onde, token, eu } = await loginVerificado();
32
39
  console.log(
33
40
  `Conectado como ${eu?.name ?? email ?? "?"} (${eu?.role ?? "?"}) no ambiente ${ambiente().nome}.`,
34
41
  );
35
42
  console.log(`Credencial guardada em: ${onde}`);
36
- console.log("Vale por 1 hora. Quando expirar, rode `gestao login` de novo.");
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();
37
46
  break;
38
47
  }
39
48
 
40
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
+ }
41
65
  const dados = lerToken();
42
66
  if (!dados) {
43
67
  console.log("Sem credencial nesta máquina. Rode `gestao login`.");
@@ -57,10 +81,32 @@ try {
57
81
  break;
58
82
  }
59
83
 
60
- case "logout":
61
- apagarToken();
62
- console.log("Credencial removida desta máquina.");
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();
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);
63
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
+ }
64
110
 
65
111
  case "mcp":
66
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
+ }